Qualify durable purchase and media primitives and preserve app launch paths
This commit is contained in:
@@ -813,6 +813,61 @@ pub async fn send_token_recoverable(
|
||||
mint_url: &str,
|
||||
amount_sats: u64,
|
||||
context_hash: &str,
|
||||
) -> Result<String> {
|
||||
send_token_recoverable_with_deadline(
|
||||
data_dir,
|
||||
operation_id,
|
||||
network,
|
||||
mint_url,
|
||||
amount_sats,
|
||||
context_hash,
|
||||
None,
|
||||
|| chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Recover original results after expiry, but never initiate a fresh exact send
|
||||
/// or swap POST after the immutable purchase deadline. The caller binds this
|
||||
/// deadline into context_hash and must already have seller acceptance saved.
|
||||
pub async fn send_token_recoverable_before(
|
||||
data_dir: &Path,
|
||||
operation_id: &str,
|
||||
network: EcashNetwork,
|
||||
mint_url: &str,
|
||||
amount_sats: u64,
|
||||
context_hash: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<String> {
|
||||
anyhow::ensure!(expires_at > 0, "Invalid purchase spend deadline");
|
||||
send_token_recoverable_with_deadline(
|
||||
data_dir,
|
||||
operation_id,
|
||||
network,
|
||||
mint_url,
|
||||
amount_sats,
|
||||
context_hash,
|
||||
Some(expires_at),
|
||||
|| chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
fn ensure_fresh_payment_allowed(expires_at: Option<i64>, now: i64) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
expires_at.is_none_or(|deadline| now < deadline),
|
||||
"The offer expired; recover the original purchase instead of starting another payment"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
async fn send_token_recoverable_with_deadline(
|
||||
data_dir: &Path,
|
||||
operation_id: &str,
|
||||
network: EcashNetwork,
|
||||
mint_url: &str,
|
||||
amount_sats: u64,
|
||||
context_hash: &str,
|
||||
expires_at: Option<i64>,
|
||||
now: impl Fn() -> i64 + Send + Sync,
|
||||
) -> Result<String> {
|
||||
use super::send_journal::{Binding, Journal, Outcome, Phase, Request};
|
||||
let held = super::mutation::guard(data_dir).await?;
|
||||
@@ -837,6 +892,7 @@ pub async fn send_token_recoverable(
|
||||
);
|
||||
record
|
||||
} else {
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
anyhow::ensure!(amount_sats > 0, "Payment amount must be positive");
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
let (indices, excess) = wallet
|
||||
@@ -868,6 +924,7 @@ pub async fn send_token_recoverable(
|
||||
);
|
||||
Request::Swap(prepared)
|
||||
};
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
journal.prepare(binding.clone(), request).await?
|
||||
};
|
||||
if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) {
|
||||
@@ -875,7 +932,10 @@ pub async fn send_token_recoverable(
|
||||
}
|
||||
journal.reserve_wallet(&binding).await?;
|
||||
let (send, change) = match &record.request {
|
||||
Request::Exact { proofs } => (proofs.clone(), vec![]),
|
||||
Request::Exact { proofs } => {
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
(proofs.clone(), vec![])
|
||||
}
|
||||
Request::Swap(prepared) => {
|
||||
// All recovery material is already durable. Do not derive new
|
||||
// outputs or release reservations after an ambiguous response.
|
||||
@@ -901,6 +961,7 @@ pub async fn send_token_recoverable(
|
||||
"This payment is still pending at the mint; do not pay again"
|
||||
);
|
||||
}
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!(
|
||||
"The mint did not confirm this payment; retry this same operation to recover it"))?
|
||||
};
|
||||
|
||||
@@ -13,3 +13,5 @@ pub mod nut13;
|
||||
pub mod profits;
|
||||
mod send_journal;
|
||||
mod receive_journal;
|
||||
|
||||
pub(crate) mod purchase_fee_plan;
|
||||
|
||||
@@ -23,6 +23,9 @@ struct Mint {
|
||||
lose_swap_reply: Arc<std::sync::atomic::AtomicBool>,
|
||||
restore_reply: Arc<Mutex<Option<Value>>>,
|
||||
state_reply: Arc<Mutex<Option<Value>>>,
|
||||
// Per-fixture clock advancement proves the spend deadline is checked after
|
||||
// mint preflight; no process-global clock or timing-sensitive sleep.
|
||||
restore_clock: Arc<Mutex<Option<(Arc<std::sync::atomic::AtomicI64>, i64)>>>,
|
||||
}
|
||||
impl Drop for Mint {
|
||||
fn drop(&mut self) {
|
||||
@@ -65,6 +68,8 @@ impl Mint {
|
||||
let restore_override = restore_reply.clone();
|
||||
let state_reply = Arc::new(Mutex::new(None::<Value>));
|
||||
let state_override = state_reply.clone();
|
||||
let restore_clock = Arc::new(Mutex::new(None::<(Arc<std::sync::atomic::AtomicI64>, i64)>));
|
||||
let advance_clock = restore_clock.clone();
|
||||
let service = make_service_fn(move |_| {
|
||||
let seen = seen.clone();
|
||||
let rejection = rejection.clone();
|
||||
@@ -73,6 +78,7 @@ impl Mint {
|
||||
let lose_reply = lose_reply.clone();
|
||||
let restore_override = restore_override.clone();
|
||||
let state_override = state_override.clone();
|
||||
let advance_clock = advance_clock.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
||||
let seen = seen.clone();
|
||||
@@ -82,6 +88,7 @@ impl Mint {
|
||||
let lose_reply = lose_reply.clone();
|
||||
let restore_override = restore_override.clone();
|
||||
let state_override = state_override.clone();
|
||||
let advance_clock = advance_clock.clone();
|
||||
async move {
|
||||
let mut status = 200;
|
||||
let body = match req.uri().path() {
|
||||
@@ -164,6 +171,11 @@ impl Mint {
|
||||
})
|
||||
}
|
||||
"/v1/restore" => {
|
||||
if let Some((clock, deadline)) =
|
||||
advance_clock.lock().unwrap().as_ref()
|
||||
{
|
||||
clock.store(*deadline, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
let body: Value = serde_json::from_slice(
|
||||
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||
)
|
||||
@@ -219,6 +231,7 @@ impl Mint {
|
||||
lose_swap_reply,
|
||||
restore_reply,
|
||||
state_reply,
|
||||
restore_clock,
|
||||
}
|
||||
}
|
||||
async fn wallet(&self) -> tempfile::TempDir {
|
||||
@@ -486,10 +499,17 @@ async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_mi
|
||||
}
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
||||
));
|
||||
if !exists {
|
||||
assert!(matches!(result, ServeResult::Unavailable));
|
||||
assert!(content_server::load_catalog(seller.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.id == "paid-test"));
|
||||
} else {
|
||||
assert!(matches!(result, ServeResult::PaymentRequired(_)));
|
||||
}
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -1574,3 +1594,342 @@ async fn recoverable_receive_unspent_retry_reuses_exact_request_and_waits_for_ve
|
||||
assert_eq!(wallet.transactions.len(), 1);
|
||||
assert_eq!(wallet.receive_commits.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purchase_deadline_rejects_fresh_exact_send_but_recovers_prior_committed_token() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mint = "https://unused-mint.invalid";
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.into();
|
||||
wallet.add_proofs(mint, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
assert!(send_token_recoverable_before(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
mint,
|
||||
8,
|
||||
&context,
|
||||
1
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||
// Fixture a prior completed send; an expired caller must recover its result,
|
||||
// not require another payment or credit the old proofs back to the purse.
|
||||
let original =
|
||||
send_token_recoverable(root.path(), &id, EcashNetwork::Mainnet, mint, 8, &context)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
send_token_recoverable_before(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
mint,
|
||||
8,
|
||||
&context,
|
||||
1
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_purchase_recovers_issued_swap_but_never_posts_still_unspent_inputs() {
|
||||
for issued in [false, true] {
|
||||
let mint = Mint::start(0, if issued { None } else { Some(503) }).await;
|
||||
let root = mint.wallet().await;
|
||||
let mut wallet = load_wallet(root.path()).await.unwrap();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
mint.lose_swap_reply
|
||||
.store(issued, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(send_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
4,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
let result = send_token_recoverable_before(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
4,
|
||||
&context,
|
||||
1,
|
||||
)
|
||||
.await;
|
||||
if issued {
|
||||
assert_eq!(
|
||||
CashuToken::deserialize(&result.unwrap())
|
||||
.unwrap()
|
||||
.total_amount(),
|
||||
4
|
||||
);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
|
||||
} else {
|
||||
assert!(result.unwrap_err().to_string().contains("expired"));
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||
assert!(load_wallet(root.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.proofs
|
||||
.iter()
|
||||
.any(|p| p.reserved));
|
||||
}
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purchase_executor_recovers_prior_buyer_spend_and_delayed_accepted_seller_settlement() {
|
||||
use crate::content_purchase::{BuyerPhase, Contract, Journal};
|
||||
use crate::content_purchase_executor::{prepare_buyer_token, settle_seller_token};
|
||||
let mint = Mint::start(0, None).await;
|
||||
let buyer = mint.wallet().await;
|
||||
let seller = mint.wallet().await;
|
||||
let contract = Contract {
|
||||
version: 1,
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([1; 32])).unwrap(),
|
||||
seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([2; 32])).unwrap(),
|
||||
content_id: "film-1".into(),
|
||||
content_sha256: "ab".repeat(32),
|
||||
content_size: 1024,
|
||||
terms_sha256: "cd".repeat(32),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.url.clone(),
|
||||
gross_token_sats: 8,
|
||||
minimum_net_sats: 8,
|
||||
offered_at: 1000,
|
||||
expires_at: 2000,
|
||||
};
|
||||
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
// An expired offer without durable seller acceptance cannot redeem a token
|
||||
// or create settlement state, even when the token and buyer are otherwise valid.
|
||||
let unaccepted_token = CashuToken::new(&mint.url, vec![proof(ACTIVE, 8)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
let seller_wallet_before = std::fs::read(seller.path().join("wallet/ecash.json")).ok();
|
||||
let rejected = settle_seller_token(
|
||||
seller.path(),
|
||||
&contract,
|
||||
&unaccepted_token,
|
||||
&contract.buyer_did,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(rejected
|
||||
.to_string()
|
||||
.contains("Seller intent is not durable"));
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
std::fs::read(seller.path().join("wallet/ecash.json")).ok(),
|
||||
seller_wallet_before
|
||||
);
|
||||
{
|
||||
let journal = Journal::open(seller.path()).await.unwrap();
|
||||
assert!(journal.seller(&contract.id).await.unwrap().is_none());
|
||||
}
|
||||
// Deterministically fixture durable acceptance before the historical deadline.
|
||||
let accepted = {
|
||||
let journal = Journal::open(seller.path()).await.unwrap();
|
||||
journal
|
||||
.prepare_seller(&contract, 1500)
|
||||
.await
|
||||
.unwrap()
|
||||
.acceptance()
|
||||
.unwrap()
|
||||
};
|
||||
{
|
||||
let journal = Journal::open(buyer.path()).await.unwrap();
|
||||
journal.prepare_buyer(&contract, 1500).await.unwrap();
|
||||
assert!(journal
|
||||
.record_acceptance(&contract, &accepted, &contract.buyer_did)
|
||||
.await
|
||||
.is_err());
|
||||
journal
|
||||
.record_acceptance(&contract, &accepted, &contract.seller_did)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// Fixture a prior wallet commit, interrupted before the buyer journal saved
|
||||
// its token. The actual executor must recover that result after expiry.
|
||||
let original = send_token_recoverable(
|
||||
buyer.path(),
|
||||
&contract.id,
|
||||
contract.network,
|
||||
&mint.url,
|
||||
8,
|
||||
&contract.context_hash().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepare_buyer_token(buyer.path(), &contract).await.unwrap(),
|
||||
original
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
mint.lose_swap_reply
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(
|
||||
settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
let receipt = settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
settle_seller_token(seller.path(), &contract, &original, &contract.buyer_did)
|
||||
.await
|
||||
.unwrap()
|
||||
== receipt
|
||||
);
|
||||
assert!(
|
||||
settle_seller_token(seller.path(), &contract, &original, &contract.seller_did)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let altered = CashuToken::new(&mint.url, vec![proof(V2, 8)])
|
||||
.serialize()
|
||||
.unwrap();
|
||||
assert!(
|
||||
settle_seller_token(seller.path(), &contract, &altered, &contract.buyer_did)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
||||
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 8);
|
||||
let journal = Journal::open(buyer.path()).await.unwrap();
|
||||
journal.record_receipt(&contract, &receipt).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.buyer(&contract.id).await.unwrap().unwrap().phase,
|
||||
BuyerPhase::ReceiptSaved
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purchase_expiring_during_mint_preflight_never_reserves_or_posts_swap() {
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
let mint = Mint::start(0, None).await;
|
||||
let root = mint.wallet().await;
|
||||
let mut wallet = load_wallet(root.path()).await.unwrap();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let wallet_before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||
let clock = Arc::new(AtomicI64::new(1000));
|
||||
*mint.restore_clock.lock().unwrap() = Some((clock.clone(), 2000));
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let error = send_token_recoverable_with_deadline(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
4,
|
||||
&"ab".repeat(32),
|
||||
Some(2000),
|
||||
|| clock.load(Ordering::SeqCst),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
clock.load(Ordering::SeqCst),
|
||||
2000,
|
||||
"preflight must have completed"
|
||||
);
|
||||
assert!(error.to_string().contains("expired"));
|
||||
assert!(
|
||||
mint.requests.lock().unwrap().is_empty(),
|
||||
"no swap POST after expiry"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||
wallet_before
|
||||
);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||
let held = crate::wallet::mutation::guard(root.path()).await.unwrap();
|
||||
assert!(crate::wallet::send_journal::Journal::new(&held)
|
||||
.load(&id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// Append to wallet/payment_tests.rs when the next payment-plan batch is applied.
|
||||
#[tokio::test]
|
||||
async fn stale_planned_inputs_do_not_reselect_wallet_coins_or_post_to_mint() {
|
||||
use crate::wallet::{mutation, send_journal};
|
||||
let mint = Mint::start(0, None).await;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
let original = proof(ACTIVE, 8);
|
||||
wallet.add_proofs(&mint.url, vec![original.clone()]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
let prepared = MintClient::new(&mint.url)
|
||||
.unwrap()
|
||||
.prepare_swap_at_least(&[original], &[4, 4], 4)
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let guard = mutation::guard(root.path()).await.unwrap();
|
||||
send_journal::Journal::new(&guard)
|
||||
.prepare(
|
||||
send_journal::Binding {
|
||||
id: id.clone(),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.url.clone(),
|
||||
amount_sats: 4,
|
||||
context_hash: context.clone(),
|
||||
},
|
||||
send_journal::Request::Swap(prepared),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// Simulate a selected proof becoming unavailable before reservation. Other
|
||||
// sufficient coins exist, but the accepted immutable plan cannot select them.
|
||||
wallet.proofs.clear();
|
||||
let mut replacement = proof(ACTIVE, 8);
|
||||
replacement.secret = "replacement-not-in-plan".into();
|
||||
wallet.add_proofs(&mint.url, vec![replacement]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let before = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||
assert!(send_token_recoverable(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
&mint.url,
|
||||
4,
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Quote the actual outgoing proof shape; never assume every exact-send proof
|
||||
//! belongs to the current active keyset. The caller supplies mint-verified fees.
|
||||
use super::cashu::{matches_stored_keyset_id, CashuToken, KeysetInfo, Proof};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct KeysetPlan {
|
||||
pub keyset_id: String,
|
||||
pub denominations: Vec<u64>,
|
||||
pub input_fee_ppk: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct FeePlan {
|
||||
pub mint_url: String,
|
||||
pub keysets: Vec<KeysetPlan>,
|
||||
pub gross_sats: u64,
|
||||
pub fee_sats: u64,
|
||||
pub net_sats: u64,
|
||||
}
|
||||
impl FeePlan {
|
||||
pub fn from_shape(mint_url: &str, mut keysets: Vec<KeysetPlan>) -> Result<Self> {
|
||||
let parsed = reqwest::Url::parse(mint_url)?;
|
||||
anyhow::ensure!(
|
||||
matches!(parsed.scheme(), "http" | "https")
|
||||
&& parsed.host_str().is_some()
|
||||
&& parsed.username().is_empty()
|
||||
&& parsed.password().is_none()
|
||||
&& parsed.query().is_none()
|
||||
&& parsed.fragment().is_none()
|
||||
&& parsed.to_string().trim_end_matches('/') == mint_url,
|
||||
"Invalid canonical payment mint"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!keysets.is_empty() && keysets.len() <= 64,
|
||||
"Invalid payment keyset plan"
|
||||
);
|
||||
keysets.sort_by(|a, b| a.keyset_id.cmp(&b.keyset_id));
|
||||
let mut identifiers = HashSet::new();
|
||||
let mut count = 0usize;
|
||||
let mut gross = 0u64;
|
||||
let mut fee_ppk = 0u64;
|
||||
for group in &mut keysets {
|
||||
anyhow::ensure!(
|
||||
matches!(group.keyset_id.len(), 16 | 66)
|
||||
&& hex::decode(&group.keyset_id).is_ok()
|
||||
&& group.keyset_id == group.keyset_id.to_ascii_lowercase()
|
||||
&& identifiers.insert(group.keyset_id.clone()),
|
||||
"Invalid or duplicate payment keyset"
|
||||
);
|
||||
// Full v2 identities are required in a plan; compact wire IDs only
|
||||
// match an already known unambiguous group at validation time.
|
||||
anyhow::ensure!(
|
||||
group.keyset_id.len() != 16 || !group.keyset_id.starts_with("01"),
|
||||
"Payment plan requires full v2 keyset identity"
|
||||
);
|
||||
anyhow::ensure!(!group.denominations.is_empty(), "Empty payment proof group");
|
||||
group.denominations.sort_unstable();
|
||||
count = count
|
||||
.checked_add(group.denominations.len())
|
||||
.context("Proof count overflow")?;
|
||||
anyhow::ensure!(count <= 1024, "Too many outgoing payment proofs");
|
||||
for amount in &group.denominations {
|
||||
anyhow::ensure!(amount.is_power_of_two(), "Invalid payment denomination");
|
||||
gross = gross
|
||||
.checked_add(*amount)
|
||||
.context("Payment amount overflow")?;
|
||||
fee_ppk = fee_ppk
|
||||
.checked_add(group.input_fee_ppk)
|
||||
.context("Payment fee overflow")?;
|
||||
}
|
||||
}
|
||||
let fee = fee_ppk.div_ceil(1000);
|
||||
let net = gross
|
||||
.checked_sub(fee)
|
||||
.context("Payment fees exceed outgoing value")?;
|
||||
anyhow::ensure!(net > 0, "Payment has no net value");
|
||||
Ok(Self {
|
||||
mint_url: mint_url.into(),
|
||||
keysets,
|
||||
gross_sats: gross,
|
||||
fee_sats: fee,
|
||||
net_sats: net,
|
||||
})
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
&Self::from_shape(&self.mint_url, self.keysets.clone())? == self,
|
||||
"Payment fee plan is inconsistent or noncanonical"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn commitment(&self) -> Result<String> {
|
||||
self.validate()?;
|
||||
let groups: Vec<_> = self
|
||||
.keysets
|
||||
.iter()
|
||||
.map(|group| {
|
||||
serde_json::json!([group.keyset_id, group.input_fee_ppk, group.denominations])
|
||||
})
|
||||
.collect();
|
||||
Ok(hex::encode(Sha256::digest(serde_json::to_vec(
|
||||
&serde_json::json!([
|
||||
"content-payment-fee-plan-v1",
|
||||
self.mint_url,
|
||||
self.gross_sats,
|
||||
self.fee_sats,
|
||||
self.net_sats,
|
||||
groups
|
||||
]),
|
||||
)?)))
|
||||
}
|
||||
/// Check authoritative mint metadata at acceptance, and again immediately
|
||||
/// before a fresh swap POST. Inactive old proofs may still be exact-spent;
|
||||
/// a prepared output keyset must remain active for a new issuance.
|
||||
pub fn verify_mint_keysets(
|
||||
&self,
|
||||
keysets: &[KeysetInfo],
|
||||
require_active_output: bool,
|
||||
) -> Result<()> {
|
||||
self.validate()?;
|
||||
for group in &self.keysets {
|
||||
let mut matching = keysets
|
||||
.iter()
|
||||
.filter(|keyset| keyset.id.eq_ignore_ascii_case(&group.keyset_id));
|
||||
let keyset = matching
|
||||
.next()
|
||||
.context("Quoted payment keyset is missing")?;
|
||||
anyhow::ensure!(
|
||||
matching.next().is_none()
|
||||
&& keyset.unit == "sat"
|
||||
&& keyset.input_fee_ppk == group.input_fee_ppk
|
||||
&& (!require_active_output || keyset.active),
|
||||
"Quoted payment keyset or fees changed"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate_proofs(&self, proofs: &[Proof]) -> Result<()> {
|
||||
self.validate()?;
|
||||
let mut actual: BTreeMap<String, Vec<u64>> = BTreeMap::new();
|
||||
let mut secrets = HashSet::new();
|
||||
for proof in proofs {
|
||||
anyhow::ensure!(
|
||||
secrets.insert(proof.secret.as_str()),
|
||||
"Duplicate outgoing proof"
|
||||
);
|
||||
let mut groups = self
|
||||
.keysets
|
||||
.iter()
|
||||
.filter(|group| matches_stored_keyset_id(&proof.id, &group.keyset_id));
|
||||
let group = groups.next().context("Outgoing proof keyset changed")?;
|
||||
anyhow::ensure!(groups.next().is_none(), "Ambiguous compact outgoing keyset");
|
||||
actual
|
||||
.entry(group.keyset_id.clone())
|
||||
.or_default()
|
||||
.push(proof.amount);
|
||||
}
|
||||
for amounts in actual.values_mut() {
|
||||
amounts.sort_unstable();
|
||||
}
|
||||
let expected: BTreeMap<_, _> = self
|
||||
.keysets
|
||||
.iter()
|
||||
.map(|group| (group.keyset_id.clone(), group.denominations.clone()))
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
actual == expected,
|
||||
"Outgoing proof count or denominations changed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate_token(&self, encoded: &str) -> Result<()> {
|
||||
let token = CashuToken::deserialize(encoded)?;
|
||||
anyhow::ensure!(
|
||||
token.token.len() == 1 && token.token[0].mint.trim_end_matches('/') == self.mint_url,
|
||||
"Outgoing payment mint changed"
|
||||
);
|
||||
self.validate_proofs(&token.token[0].proofs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn group(id: &str, denominations: Vec<u64>, fee: u64) -> KeysetPlan {
|
||||
KeysetPlan {
|
||||
keyset_id: id.into(),
|
||||
denominations,
|
||||
input_fee_ppk: fee,
|
||||
}
|
||||
}
|
||||
fn proof(id: &str, amount: u64, secret: &str) -> Proof {
|
||||
Proof {
|
||||
id: id.into(),
|
||||
amount,
|
||||
secret: secret.into(),
|
||||
c: "unused-in-shape-validation".into(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn mixed_keysets_quote_actual_proof_counts_and_round_fee_once() {
|
||||
let first = "0011223344556677";
|
||||
let second = "008899aabbccddee";
|
||||
let plan = FeePlan::from_shape(
|
||||
"https://mint.invalid",
|
||||
vec![group(first, vec![4, 4], 400), group(second, vec![2], 100)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((plan.gross_sats, plan.fee_sats, plan.net_sats), (10, 1, 9));
|
||||
let proofs = vec![
|
||||
proof(first, 4, "a"),
|
||||
proof(first, 4, "b"),
|
||||
proof(second, 2, "c"),
|
||||
];
|
||||
plan.validate_proofs(&proofs).unwrap();
|
||||
assert!(plan
|
||||
.validate_proofs(&[proof(first, 8, "a"), proof(second, 2, "c")])
|
||||
.is_err());
|
||||
assert!(plan
|
||||
.validate_proofs(&[
|
||||
proof(first, 4, "a"),
|
||||
proof(first, 4, "a"),
|
||||
proof(second, 2, "c")
|
||||
])
|
||||
.is_err());
|
||||
let mut changed = plan.clone();
|
||||
changed.keysets[0].input_fee_ppk += 1000;
|
||||
assert!(changed.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn compact_collision_and_changed_fee_quote_reject_before_token_transfer() {
|
||||
let first = format!("01{}", "11".repeat(32));
|
||||
let mut second = first.clone();
|
||||
second.replace_range(64..66, "22");
|
||||
let plan = FeePlan::from_shape(
|
||||
"https://mint.invalid",
|
||||
vec![group(&first, vec![4], 0), group(&second, vec![4], 0)],
|
||||
)
|
||||
.unwrap();
|
||||
plan.validate_proofs(&[proof(&first, 4, "a"), proof(&second, 4, "b")])
|
||||
.unwrap();
|
||||
assert!(plan
|
||||
.validate_proofs(&[proof(&first[..16], 4, "a"), proof(&second, 4, "b")])
|
||||
.is_err());
|
||||
let mut keysets: Vec<_> = [&first, &second]
|
||||
.into_iter()
|
||||
.map(|id| KeysetInfo {
|
||||
id: id.clone(),
|
||||
unit: "sat".into(),
|
||||
active: true,
|
||||
input_fee_ppk: 0,
|
||||
})
|
||||
.collect();
|
||||
plan.verify_mint_keysets(&keysets, true).unwrap();
|
||||
keysets[0].active = false;
|
||||
assert!(plan.verify_mint_keysets(&keysets, true).is_err());
|
||||
plan.verify_mint_keysets(&keysets, false).unwrap();
|
||||
keysets[0].input_fee_ppk = 1;
|
||||
assert!(plan.verify_mint_keysets(&keysets, false).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn fee_commitment_matches_shared_ordered_array_fixture() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../../../tests/fixtures/purchase-fee-plan-v1.json"
|
||||
))
|
||||
.unwrap();
|
||||
let plan: FeePlan = serde_json::from_value(fixture["plan"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
plan.commitment().unwrap(),
|
||||
fixture["sha256"].as_str().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(Sha256::digest(
|
||||
fixture["preimage"].as_str().unwrap().as_bytes()
|
||||
)),
|
||||
fixture["sha256"].as_str().unwrap()
|
||||
);
|
||||
let mut reversed = plan.keysets.clone();
|
||||
reversed.reverse();
|
||||
assert_eq!(
|
||||
FeePlan::from_shape(&plan.mint_url, reversed)
|
||||
.unwrap()
|
||||
.commitment()
|
||||
.unwrap(),
|
||||
plan.commitment().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user