Integrate recoverable native purchases, registered rentals and explicit payment consent
This commit is contained in:
@@ -290,6 +290,10 @@ pub async fn load_network(data_dir: &Path) -> Result<EcashNetwork> {
|
||||
/// disk untouched, so switching is reversible and loses nothing.
|
||||
pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> {
|
||||
let _mutation = super::mutation::guard(data_dir).await?;
|
||||
crate::content_purchase::Journal::open(data_dir)
|
||||
.await?
|
||||
.ensure_seller_policy_change(network, None)
|
||||
.await?;
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
@@ -443,6 +447,10 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
|
||||
/// Save accepted mints list.
|
||||
pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> {
|
||||
let _mutation = super::mutation::guard(data_dir).await?;
|
||||
crate::content_purchase::Journal::open(data_dir)
|
||||
.await?
|
||||
.ensure_seller_policy_change(load_network(data_dir).await?, Some(&mints.mints))
|
||||
.await?;
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
@@ -823,6 +831,7 @@ pub async fn send_token_recoverable(
|
||||
context_hash,
|
||||
None,
|
||||
|| chrono::Utc::now().timestamp(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -849,6 +858,7 @@ pub async fn send_token_recoverable_before(
|
||||
context_hash,
|
||||
Some(expires_at),
|
||||
|| chrono::Utc::now().timestamp(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -868,6 +878,7 @@ async fn send_token_recoverable_with_deadline(
|
||||
context_hash: &str,
|
||||
expires_at: Option<i64>,
|
||||
now: impl Fn() -> i64 + Send + Sync,
|
||||
plan: Option<&super::purchase_fee_plan::FeePlan>,
|
||||
) -> Result<String> {
|
||||
use super::send_journal::{Binding, Journal, Outcome, Phase, Request};
|
||||
let held = super::mutation::guard(data_dir).await?;
|
||||
@@ -884,6 +895,17 @@ async fn send_token_recoverable_with_deadline(
|
||||
};
|
||||
let journal = Journal::new(&held);
|
||||
let previous = journal.load(operation_id).await?;
|
||||
if let Some(plan) = plan {
|
||||
plan.validate()?;
|
||||
anyhow::ensure!(
|
||||
previous.is_some(),
|
||||
"Original planned inputs are missing; no new proof selection allowed"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
plan.mint_url == mint_url && plan.gross_sats == amount_sats,
|
||||
"Planned amount or mint changed"
|
||||
);
|
||||
}
|
||||
let recovering = previous.is_some();
|
||||
let record = if let Some(record) = previous {
|
||||
anyhow::ensure!(
|
||||
@@ -927,12 +949,26 @@ async fn send_token_recoverable_with_deadline(
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
journal.prepare(binding.clone(), request).await?
|
||||
};
|
||||
anyhow::ensure!(
|
||||
!matches!(record.phase, Phase::Cancelled),
|
||||
"Payment operation was cancelled"
|
||||
);
|
||||
if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) {
|
||||
return journal.commit_wallet(&binding).await;
|
||||
}
|
||||
// An exact Prepared record has never exposed a token: result durability
|
||||
// precedes both wallet commit and return. Do not reserve fresh inputs merely
|
||||
// to reject an already-expired accepted plan.
|
||||
if matches!(&record.request, Request::Exact { .. }) {
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
}
|
||||
journal.reserve_wallet(&binding).await?;
|
||||
let (send, change) = match &record.request {
|
||||
Request::Exact { proofs } => {
|
||||
if let Some(plan) = plan {
|
||||
plan.validate_proofs(proofs)?;
|
||||
plan.verify_mint_keysets(&MintClient::new(mint_url)?.get_keysets().await?, false)?;
|
||||
}
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
(proofs.clone(), vec![])
|
||||
}
|
||||
@@ -961,7 +997,11 @@ async fn send_token_recoverable_with_deadline(
|
||||
"This payment is still pending at the mint; do not pay again"
|
||||
);
|
||||
}
|
||||
if let Some(plan) = plan {
|
||||
plan.verify_mint_keysets(&client.get_keysets().await?, true)?;
|
||||
}
|
||||
ensure_fresh_payment_allowed(expires_at, now())?;
|
||||
journal.mark_dispatched(&binding).await?;
|
||||
client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!(
|
||||
"The mint did not confirm this payment; retry this same operation to recover it"))?
|
||||
};
|
||||
@@ -985,6 +1025,9 @@ async fn send_token_recoverable_with_deadline(
|
||||
};
|
||||
let token = CashuToken::new(&binding.mint_url, send);
|
||||
let encoded = token.serialize_v4().or_else(|_| token.serialize())?;
|
||||
if let Some(plan) = plan {
|
||||
plan.validate_token(&encoded)?;
|
||||
}
|
||||
journal
|
||||
.record_result(
|
||||
&binding,
|
||||
@@ -997,6 +1040,27 @@ async fn send_token_recoverable_with_deadline(
|
||||
journal.commit_wallet(&binding).await
|
||||
}
|
||||
|
||||
/// Executes only a previously pinned private wallet plan. A missing send record
|
||||
/// rejects instead of silently selecting another set of inputs.
|
||||
pub(crate) async fn send_token_preplanned_before(
|
||||
data_dir: &Path,
|
||||
contract: &crate::content_purchase::Contract,
|
||||
plan: &super::purchase_fee_plan::FeePlan,
|
||||
) -> Result<String> {
|
||||
send_token_recoverable_with_deadline(
|
||||
data_dir,
|
||||
&contract.id,
|
||||
contract.network,
|
||||
&contract.mint_url,
|
||||
contract.gross_token_sats,
|
||||
&contract.context_hash()?,
|
||||
Some(contract.expires_at),
|
||||
|| chrono::Utc::now().timestamp(),
|
||||
Some(plan),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mint_url = mint_url.to_string();
|
||||
|
||||
@@ -83,6 +83,14 @@ impl std::fmt::Debug for PreparedSwap {
|
||||
}
|
||||
|
||||
impl PreparedSwap {
|
||||
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
|
||||
pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id }
|
||||
pub(super) fn input_fee_sats(&self) -> Result<u64> {
|
||||
let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?;
|
||||
let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?;
|
||||
inputs.checked_sub(outputs).context("Prepared outputs exceed input value")
|
||||
}
|
||||
|
||||
pub(super) fn inputs(&self) -> &[Proof] {
|
||||
&self.inputs
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod minibits;
|
||||
pub mod mint_client;
|
||||
mod mutation;
|
||||
pub(crate) mod mutation;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
mod send_journal;
|
||||
mod receive_journal;
|
||||
|
||||
pub(crate) mod purchase_fee_plan;
|
||||
|
||||
pub(crate) mod purchase_plan;
|
||||
|
||||
@@ -30,12 +30,12 @@ async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc<Mutex<()>>)> {
|
||||
Ok((canonical, lock))
|
||||
}
|
||||
|
||||
pub(super) struct WalletMutation {
|
||||
pub(crate) struct WalletMutation {
|
||||
pub(super) data_dir: PathBuf,
|
||||
_held: OwnedMutexGuard<()>,
|
||||
}
|
||||
|
||||
pub(super) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
|
||||
pub(crate) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
|
||||
let (data_dir, lock) = canonical_lock(data_dir).await?;
|
||||
Ok(WalletMutation {
|
||||
data_dir,
|
||||
|
||||
@@ -1849,6 +1849,7 @@ async fn purchase_expiring_during_mint_preflight_never_reserves_or_posts_swap()
|
||||
&"ab".repeat(32),
|
||||
Some(2000),
|
||||
|| clock.load(Ordering::SeqCst),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -1933,3 +1934,785 @@ async fn stale_planned_inputs_do_not_reselect_wallet_coins_or_post_to_mint() {
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// Add within wallet payment_tests, using its existing fake proof fixtures.
|
||||
#[tokio::test]
|
||||
async fn expired_saved_exact_plan_never_reserves_spendable_inputs() {
|
||||
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 original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let context = "ab".repeat(32);
|
||||
{
|
||||
let guard = crate::wallet::mutation::guard(root.path()).await.unwrap();
|
||||
let binding = crate::wallet::send_journal::Binding {
|
||||
id: id.clone(),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.into(),
|
||||
amount_sats: 8,
|
||||
context_hash: context.clone(),
|
||||
};
|
||||
crate::wallet::send_journal::Journal::new(&guard)
|
||||
.prepare(
|
||||
binding,
|
||||
crate::wallet::send_journal::Request::Exact {
|
||||
proofs: vec![proof(ACTIVE, 8)],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(send_token_recoverable_before(
|
||||
root.path(),
|
||||
&id,
|
||||
EcashNetwork::Mainnet,
|
||||
mint,
|
||||
8,
|
||||
&context,
|
||||
chrono::Utc::now().timestamp() - 1
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||
}
|
||||
|
||||
// Append to wallet/payment_tests.rs after integrating dispatch/cancellation APIs.
|
||||
#[tokio::test]
|
||||
async fn cancelled_exact_plan_cannot_later_send_and_releases_only_its_reservation() {
|
||||
use crate::wallet::{
|
||||
mutation,
|
||||
send_journal::{Binding, Journal, Request},
|
||||
};
|
||||
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), proof(ACTIVE, 4)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let binding = Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.into(),
|
||||
amount_sats: 8,
|
||||
context_hash: "ab".repeat(32),
|
||||
};
|
||||
{
|
||||
let guard = mutation::guard(root.path()).await.unwrap();
|
||||
let journal = Journal::new(&guard);
|
||||
journal
|
||||
.prepare(
|
||||
binding.clone(),
|
||||
Request::Exact {
|
||||
proofs: vec![proof(ACTIVE, 8)],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
journal.reserve_wallet(&binding).await.unwrap();
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
|
||||
journal.cancel_unspent(&binding).await.unwrap();
|
||||
journal.cancel_unspent(&binding).await.unwrap();
|
||||
}
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
|
||||
assert!(send_token_recoverable(
|
||||
root.path(),
|
||||
&binding.id,
|
||||
binding.network,
|
||||
mint,
|
||||
8,
|
||||
&binding.context_hash
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
|
||||
assert!(load_wallet(root.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.transactions
|
||||
.is_empty());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn dispatched_or_legacy_unknown_swap_cannot_cancel_despite_unspent_mint_inputs() {
|
||||
use crate::wallet::{
|
||||
mutation,
|
||||
send_journal::{Binding, Journal, Request},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
for legacy in [false, true] {
|
||||
let mint = Mint::start(0, None).await;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let binding = Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.url.clone(),
|
||||
amount_sats: 4,
|
||||
context_hash: "ab".repeat(32),
|
||||
};
|
||||
let prepared = MintClient::new(&mint.url)
|
||||
.unwrap()
|
||||
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let guard = mutation::guard(root.path()).await.unwrap();
|
||||
let journal = Journal::new(&guard);
|
||||
journal
|
||||
.prepare(binding.clone(), Request::Swap(prepared))
|
||||
.await
|
||||
.unwrap();
|
||||
journal.reserve_wallet(&binding).await.unwrap();
|
||||
if !legacy {
|
||||
journal.mark_dispatched(&binding).await.unwrap();
|
||||
}
|
||||
}
|
||||
if legacy {
|
||||
let path = root
|
||||
.path()
|
||||
.join("wallet/send-operations")
|
||||
.join(format!("{}.json", binding.id));
|
||||
let mut envelope: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
let mut payload: serde_json::Value =
|
||||
serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap();
|
||||
payload.as_object_mut().unwrap().remove("dispatch");
|
||||
let payload = serde_json::to_string(&payload).unwrap();
|
||||
envelope["checksum"] = json!(hex::encode(Sha256::digest(payload.as_bytes())));
|
||||
envelope["payload"] = json!(payload);
|
||||
std::fs::write(path, serde_json::to_vec(&envelope).unwrap()).unwrap();
|
||||
}
|
||||
let guard = mutation::guard(root.path()).await.unwrap();
|
||||
assert!(Journal::new(&guard).cancel_unspent(&binding).await.is_err());
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn planner_rejects_wrong_network_before_creating_intent_or_reservation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = "http://127.0.0.1:1".into();
|
||||
wallet.add_proofs("http://127.0.0.1:1", vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap();
|
||||
let error = crate::wallet::purchase_plan::prepare(
|
||||
root.path(),
|
||||
"http://127.0.0.1:1",
|
||||
EcashNetwork::Testnet,
|
||||
4,
|
||||
8,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("another wallet network"));
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("wallet/ecash.json")).unwrap(),
|
||||
original
|
||||
);
|
||||
assert!(!root.path().join("content-purchases").exists());
|
||||
assert!(!root.path().join("wallet/send-operations").exists());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn planner_skips_unfundable_swaps_and_finds_later_exact_shape_within_budget() {
|
||||
let mint = Mint::start(2000, None).await;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8), proof(ACTIVE, 4)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let plan =
|
||||
crate::wallet::purchase_plan::prepare(root.path(), &mint.url, EcashNetwork::Mainnet, 7, 12)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(plan.fee_plan.gross_sats, 12);
|
||||
assert_eq!(plan.fee_plan.fee_sats, 4);
|
||||
assert_eq!(plan.fee_plan.net_sats, 8);
|
||||
assert_eq!(plan.wallet_debit_sats, 12);
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_seal_wins_against_an_already_waiting_fresh_swap_executor() {
|
||||
use crate::wallet::{
|
||||
mutation,
|
||||
send_journal::{Binding, Journal, Request},
|
||||
};
|
||||
let mint = Mint::start(0, None).await;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(root.path(), &wallet).await.unwrap();
|
||||
let binding = Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.url.clone(),
|
||||
amount_sats: 4,
|
||||
context_hash: "ab".repeat(32),
|
||||
};
|
||||
let prepared = MintClient::new(&mint.url)
|
||||
.unwrap()
|
||||
.prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4)
|
||||
.await
|
||||
.unwrap();
|
||||
let guard = mutation::guard(root.path()).await.unwrap();
|
||||
let journal = Journal::new(&guard);
|
||||
journal
|
||||
.prepare(binding.clone(), Request::Swap(prepared))
|
||||
.await
|
||||
.unwrap();
|
||||
journal.reserve_wallet(&binding).await.unwrap();
|
||||
let waiting = send_token_recoverable(
|
||||
root.path(),
|
||||
&binding.id,
|
||||
binding.network,
|
||||
&binding.mint_url,
|
||||
binding.amount_sats,
|
||||
&binding.context_hash,
|
||||
);
|
||||
tokio::pin!(waiting);
|
||||
assert!(futures_util::poll!(&mut waiting).is_pending());
|
||||
journal.cancel_unspent(&binding).await.unwrap();
|
||||
drop(journal);
|
||||
drop(guard);
|
||||
assert!(waiting.await.is_err());
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
|
||||
assert!(load_wallet(root.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.transactions
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
// Append under wallet/payment_tests.rs: real local journals + fake mint, no real funds.
|
||||
struct PurchaseTestTransport {
|
||||
seller_root: std::path::PathBuf,
|
||||
template: crate::content_purchase_protocol::Offer,
|
||||
lose_offer: std::sync::atomic::AtomicBool,
|
||||
lose_accept: std::sync::atomic::AtomicBool,
|
||||
lose_settle: std::sync::atomic::AtomicBool,
|
||||
offers: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
impl crate::content_purchase_caller::PurchaseTransport for PurchaseTestTransport {
|
||||
fn seller_did(&self) -> &str {
|
||||
&self.template.seller_did
|
||||
}
|
||||
fn seller_onion(&self) -> &str {
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion"
|
||||
}
|
||||
async fn offer(
|
||||
&self,
|
||||
id: &str,
|
||||
content_id: &str,
|
||||
) -> anyhow::Result<crate::content_purchase_protocol::Offer> {
|
||||
self.offers.lock().unwrap().push(id.into());
|
||||
let mut offer = self.template.clone();
|
||||
offer.id = id.into();
|
||||
offer.content_id = content_id.into();
|
||||
let saved = crate::content_purchase_protocol::save_offer(
|
||||
&self.seller_root,
|
||||
&offer,
|
||||
&offer.buyer_did,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
!self
|
||||
.lose_offer
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst),
|
||||
"Lost offer response"
|
||||
);
|
||||
Ok(saved)
|
||||
}
|
||||
async fn accept(
|
||||
&self,
|
||||
envelope: &crate::content_purchase_protocol::Envelope,
|
||||
) -> anyhow::Result<crate::content_purchase_protocol::Accepted> {
|
||||
let reply = crate::content_purchase_protocol::accept(
|
||||
&self.seller_root,
|
||||
envelope,
|
||||
&envelope.offer.buyer_did,
|
||||
|| chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
!self
|
||||
.lose_accept
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst),
|
||||
"Lost acceptance response"
|
||||
);
|
||||
Ok(reply)
|
||||
}
|
||||
async fn status(
|
||||
&self,
|
||||
envelope: &crate::content_purchase_protocol::Envelope,
|
||||
) -> anyhow::Result<crate::content_purchase_protocol::SellerStatus> {
|
||||
crate::content_purchase_protocol::status(
|
||||
&self.seller_root,
|
||||
envelope,
|
||||
&envelope.offer.buyer_did,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn cancel(
|
||||
&self,
|
||||
envelope: &crate::content_purchase_protocol::Envelope,
|
||||
) -> anyhow::Result<crate::content_purchase_protocol::Cancelled> {
|
||||
crate::content_purchase_protocol::cancel(
|
||||
&self.seller_root,
|
||||
envelope,
|
||||
&envelope.offer.buyer_did,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn settle(
|
||||
&self,
|
||||
request: &crate::content_purchase_protocol::Settlement,
|
||||
) -> anyhow::Result<crate::content_purchase::Receipt> {
|
||||
let reply = crate::content_purchase_protocol::settle(
|
||||
&self.seller_root,
|
||||
request,
|
||||
&request.envelope.offer.buyer_did,
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
!self
|
||||
.lose_settle
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst),
|
||||
"Invalid purchase response after settlement"
|
||||
);
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn full_caller_recovers_lost_acceptance_and_settlement_without_another_payment() {
|
||||
use crate::content_purchase_caller::{purchase, PurchaseConsent, ReadyPurchase};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let mint = Mint::start(0, None).await;
|
||||
let buyer = tempfile::tempdir().unwrap();
|
||||
let seller = mint.wallet().await;
|
||||
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
|
||||
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
save_wallet(seller.path(), &wallet).await.unwrap();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let transport = PurchaseTestTransport {
|
||||
seller_root: seller.path().into(),
|
||||
template: crate::content_purchase_protocol::Offer {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: buyer_did.clone(),
|
||||
seller_did,
|
||||
content_id: "paid-film".into(),
|
||||
filename: "film.mp4".into(),
|
||||
mime_type: "video/mp4".into(),
|
||||
content_sha256: "ab".repeat(32),
|
||||
content_size: 16,
|
||||
viewing_seconds: None,
|
||||
terms_sha256: "cd".repeat(32),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.url.clone(),
|
||||
seller_net_sats: 8,
|
||||
offered_at: now,
|
||||
expires_at: now + 300,
|
||||
},
|
||||
lose_offer: AtomicBool::new(true),
|
||||
lose_accept: AtomicBool::new(true),
|
||||
lose_settle: AtomicBool::new(true),
|
||||
offers: Default::default(),
|
||||
};
|
||||
assert!(purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
None,
|
||||
&transport
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
|
||||
let quote = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
None,
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let consent = match quote {
|
||||
ReadyPurchase::AwaitingConfirmation {
|
||||
operation_id,
|
||||
envelope_sha256,
|
||||
wallet_debit_sats,
|
||||
..
|
||||
} => PurchaseConsent {
|
||||
operation_id,
|
||||
envelope_sha256,
|
||||
wallet_debit_sats,
|
||||
},
|
||||
_ => panic!("Fresh purchase spent before confirmation"),
|
||||
};
|
||||
let reopened = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
9_007_199_254_740_991,
|
||||
None,
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match reopened {
|
||||
ReadyPurchase::AwaitingConfirmation {
|
||||
operation_id,
|
||||
wallet_debit_sats,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(operation_id, consent.operation_id);
|
||||
assert_eq!(wallet_debit_sats, consent.wallet_debit_sats);
|
||||
}
|
||||
_ => panic!("A broad quote budget initiated spending without consent"),
|
||||
}
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
|
||||
// A quote alone does not pin seller policy. Removing acceptance must stop
|
||||
// the buyer before any token is exposed; the same quote can resume later.
|
||||
save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] })
|
||||
.await
|
||||
.unwrap();
|
||||
let rejected = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
Some(&consent),
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(rejected
|
||||
.to_string()
|
||||
.contains("does not accept the quoted mint"));
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
save_accepted_mints(
|
||||
seller.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec![mint.url.clone()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
Some(&consent),
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("The simulated lost response must surface");
|
||||
assert!(
|
||||
error.to_string().contains("Lost acceptance response"),
|
||||
"unexpected purchase failure: {error:#}"
|
||||
);
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
|
||||
// Durable acceptance now pins redemption policy until cancellation or
|
||||
// settlement, including when the acceptance reply was lost.
|
||||
assert!(
|
||||
save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] })
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(save_network(seller.path(), EcashNetwork::Testnet)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
load_network(seller.path()).await.unwrap(),
|
||||
EcashNetwork::Mainnet
|
||||
);
|
||||
let error = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
Some(&consent),
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("The simulated lost response must surface");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Invalid purchase response after settlement"),
|
||||
"unexpected purchase failure: {error:#}"
|
||||
);
|
||||
assert_eq!(mint.requests.lock().unwrap().len(), 1);
|
||||
let recovered = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
None,
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let original = match recovered {
|
||||
ReadyPurchase::Entitlement { contract, receipt } => {
|
||||
assert_eq!(contract.id, consent.operation_id);
|
||||
receipt
|
||||
}
|
||||
_ => panic!("Original entitlement was not recovered"),
|
||||
};
|
||||
let again = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
None,
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match again {
|
||||
ReadyPurchase::Entitlement { receipt, .. } => assert!(receipt == original),
|
||||
_ => panic!("Receipt replay changed"),
|
||||
}
|
||||
assert_eq!(transport.offers.lock().unwrap().len(), 2);
|
||||
assert_ne!(transport.offers.lock().unwrap()[0], consent.operation_id);
|
||||
assert_eq!(transport.offers.lock().unwrap()[1], consent.operation_id);
|
||||
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);
|
||||
assert_eq!(
|
||||
load_wallet(buyer.path()).await.unwrap().transactions.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
load_wallet(seller.path()).await.unwrap().transactions.len(),
|
||||
1
|
||||
);
|
||||
assert!(!transport.lose_accept.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
|
||||
use crate::content_purchase_caller::{purchase_bound, ExpectedRental};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
let buyer = tempfile::tempdir().unwrap();
|
||||
let seller = tempfile::tempdir().unwrap();
|
||||
save_accepted_mints(
|
||||
seller.path(),
|
||||
&AcceptedMints {
|
||||
mints: vec!["http://127.0.0.1:1".into()],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
|
||||
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let transport = PurchaseTestTransport {
|
||||
seller_root: seller.path().into(),
|
||||
template: crate::content_purchase_protocol::Offer {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: buyer_did.clone(),
|
||||
seller_did: seller_did.clone(),
|
||||
content_id: "registered_film".into(),
|
||||
filename: "film.mp4".into(),
|
||||
mime_type: "video/mp4".into(),
|
||||
content_sha256: "ab".repeat(32),
|
||||
content_size: 16,
|
||||
viewing_seconds: Some(60),
|
||||
terms_sha256: "cd".repeat(32),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: "http://127.0.0.1:1".into(),
|
||||
seller_net_sats: 8,
|
||||
offered_at: now,
|
||||
expires_at: now + 300,
|
||||
},
|
||||
lose_offer: AtomicBool::new(false),
|
||||
lose_accept: AtomicBool::new(false),
|
||||
lose_settle: AtomicBool::new(false),
|
||||
offers: Default::default(),
|
||||
};
|
||||
let expected = ExpectedRental {
|
||||
seller_did,
|
||||
content_id: "registered_film".into(),
|
||||
sha256: "ab".repeat(32),
|
||||
price_sats: 8,
|
||||
viewing_seconds: 60,
|
||||
};
|
||||
let mut changed_hash = expected.clone();
|
||||
changed_hash.sha256 = "ef".repeat(32);
|
||||
let mut changed_price = expected.clone();
|
||||
changed_price.price_sats = 9;
|
||||
let mut changed_duration = expected.clone();
|
||||
changed_duration.viewing_seconds = 120;
|
||||
for wrong in [changed_hash, changed_price, changed_duration] {
|
||||
let error = purchase_bound(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"registered_film",
|
||||
None,
|
||||
20,
|
||||
None,
|
||||
&transport,
|
||||
Some(&wrong),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("Published rental"), "{error:#}");
|
||||
assert!(!buyer.path().join("wallet/ecash.json").exists());
|
||||
assert!(!buyer.path().join("wallet/send-operations").exists());
|
||||
assert!(crate::content_purchase::Journal::open(buyer.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.find_buyers(&buyer_did, &expected.seller_did, "registered_film")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
|
||||
use crate::content_purchase_caller::{purchase, ReadyPurchase};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let mint = Mint::start(0, None).await;
|
||||
let buyer = tempfile::tempdir().unwrap();
|
||||
let seller = mint.wallet().await;
|
||||
let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap();
|
||||
let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
|
||||
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||
let mut wallet = WalletState::default();
|
||||
wallet.mint_url = mint.url.clone();
|
||||
save_wallet(seller.path(), &wallet).await.unwrap();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let transport = PurchaseTestTransport {
|
||||
seller_root: seller.path().into(),
|
||||
template: crate::content_purchase_protocol::Offer {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: buyer_did.clone(),
|
||||
seller_did,
|
||||
content_id: "paid-film".into(),
|
||||
filename: "film.mp4".into(),
|
||||
mime_type: "video/mp4".into(),
|
||||
content_sha256: "ab".repeat(32),
|
||||
content_size: 16,
|
||||
viewing_seconds: None,
|
||||
terms_sha256: "cd".repeat(32),
|
||||
network: EcashNetwork::Mainnet,
|
||||
mint_url: mint.url.clone(),
|
||||
seller_net_sats: 8,
|
||||
offered_at: now,
|
||||
expires_at: now + 300,
|
||||
},
|
||||
lose_offer: AtomicBool::new(false),
|
||||
lose_accept: AtomicBool::new(false),
|
||||
lose_settle: AtomicBool::new(false),
|
||||
offers: Default::default(),
|
||||
};
|
||||
let quote = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
None,
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let id = match quote {
|
||||
ReadyPurchase::AwaitingConfirmation { operation_id, .. } => operation_id,
|
||||
_ => panic!("Quote spent funds"),
|
||||
};
|
||||
assert!(!buyer
|
||||
.path()
|
||||
.join("wallet/send-operations")
|
||||
.join(format!("{id}.json"))
|
||||
.exists());
|
||||
let (envelope, plan) = {
|
||||
let journal = crate::content_purchase::Journal::open(buyer.path())
|
||||
.await
|
||||
.unwrap();
|
||||
(
|
||||
journal
|
||||
.protocol_envelope("buyer", &id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
journal.buyer_plan(&id).await.unwrap().unwrap(),
|
||||
)
|
||||
};
|
||||
crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport)
|
||||
.await
|
||||
.unwrap();
|
||||
crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
|
||||
assert!(load_wallet(buyer.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.transactions
|
||||
.is_empty());
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
let next = purchase(
|
||||
buyer.path(),
|
||||
&buyer_did,
|
||||
"paid-film",
|
||||
Some("film.mp4"),
|
||||
8,
|
||||
None,
|
||||
&transport,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match next {
|
||||
ReadyPurchase::AwaitingConfirmation { operation_id, .. } => assert_ne!(operation_id, id),
|
||||
_ => panic!("Replacement quote spent funds"),
|
||||
}
|
||||
assert!(mint.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Local-only deterministic payment planning. No swap POST or reservation here.
|
||||
//! Serialize this object only into the private buyer journal, never onto the wire.
|
||||
use super::{
|
||||
cashu::{KeysetInfo, Proof},
|
||||
ecash,
|
||||
mint_client::MintClient,
|
||||
mutation,
|
||||
purchase_fee_plan::{FeePlan, KeysetPlan},
|
||||
send_journal::{Binding, Journal, Request},
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PreparedPayment {
|
||||
pub fee_plan: FeePlan,
|
||||
/// Includes any buyer funding-swap input fee, distinct from seller redemption.
|
||||
pub wallet_debit_sats: u64,
|
||||
request: Request,
|
||||
}
|
||||
fn exact_shape(proofs: &[Proof], keysets: &[KeysetInfo]) -> Result<Vec<KeysetPlan>> {
|
||||
let mut groups: BTreeMap<String, KeysetPlan> = BTreeMap::new();
|
||||
for proof in proofs {
|
||||
let matches: Vec<_> = keysets
|
||||
.iter()
|
||||
.filter(|keyset| super::cashu::matches_stored_keyset_id(&proof.id, &keyset.id))
|
||||
.collect();
|
||||
anyhow::ensure!(matches.len() == 1, "Missing or ambiguous payment keyset");
|
||||
let keyset = matches[0];
|
||||
anyhow::ensure!(keyset.unit == "sat", "Payment keyset has another unit");
|
||||
groups
|
||||
.entry(keyset.id.to_ascii_lowercase())
|
||||
.or_insert_with(|| KeysetPlan {
|
||||
keyset_id: keyset.id.to_ascii_lowercase(),
|
||||
denominations: vec![],
|
||||
input_fee_ppk: keyset.input_fee_ppk,
|
||||
})
|
||||
.denominations
|
||||
.push(proof.amount);
|
||||
}
|
||||
Ok(groups.into_values().collect())
|
||||
}
|
||||
/// Quote at most 1024 gross amounts. Failure is explicit; never silently increase
|
||||
/// the user-approved debit limit or substitute another mint/network.
|
||||
pub(crate) async fn prepare(
|
||||
data_dir: &Path,
|
||||
mint: &str,
|
||||
expected_network: ecash::EcashNetwork,
|
||||
seller_net_sats: u64,
|
||||
max_wallet_debit: u64,
|
||||
) -> Result<PreparedPayment> {
|
||||
anyhow::ensure!(
|
||||
seller_net_sats > 0 && max_wallet_debit >= seller_net_sats,
|
||||
"Invalid payment budget"
|
||||
);
|
||||
let _held = mutation::guard(data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
ecash::load_network(data_dir).await? == expected_network,
|
||||
"Offer uses another wallet network; no intent was created"
|
||||
);
|
||||
let wallet = ecash::load_wallet(data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
wallet.select_proofs(mint, seller_net_sats).is_some(),
|
||||
"No spendable balance at the seller's mint; no intent was created"
|
||||
);
|
||||
let client =
|
||||
MintClient::new(mint)?.with_recovery(super::nut13::RecoverySource::load(data_dir).await?);
|
||||
let keysets = client.get_keysets().await?;
|
||||
let active = client.get_active_sat_keyset().await?;
|
||||
let active_fee: Vec<_> = keysets
|
||||
.iter()
|
||||
.filter(|keyset| keyset.id == active.id && keyset.active && keyset.unit == "sat")
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
active_fee.len() == 1,
|
||||
"Active payment keyset is not uniquely bound"
|
||||
);
|
||||
for additional in 0..1024u64 {
|
||||
let gross = seller_net_sats
|
||||
.checked_add(additional)
|
||||
.context("Payment amount overflow")?;
|
||||
if gross > max_wallet_debit {
|
||||
break;
|
||||
}
|
||||
let Some((indices, excess)) = wallet.select_proofs(mint, gross) else {
|
||||
break;
|
||||
};
|
||||
let proofs: Vec<_> = indices
|
||||
.iter()
|
||||
.map(|&index| wallet.proofs[index].proof.clone())
|
||||
.collect();
|
||||
let input_shape = exact_shape(&proofs, &keysets)?;
|
||||
let input_ppk = input_shape
|
||||
.iter()
|
||||
.try_fold(0u64, |total, group| {
|
||||
total.checked_add(
|
||||
group
|
||||
.input_fee_ppk
|
||||
.checked_mul(group.denominations.len() as u64)?,
|
||||
)
|
||||
})
|
||||
.context("Funding fee overflow")?;
|
||||
let quoted_funding_fee = input_ppk.checked_add(999).context("Funding fee overflow")? / 1000;
|
||||
if excess > 0
|
||||
&& (quoted_funding_fee > excess
|
||||
|| gross
|
||||
.checked_add(quoted_funding_fee)
|
||||
.is_none_or(|debit| debit > max_wallet_debit))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let shape = if excess == 0 {
|
||||
input_shape
|
||||
} else {
|
||||
vec![KeysetPlan {
|
||||
keyset_id: active.id.to_ascii_lowercase(),
|
||||
denominations: super::cashu::amount_to_denominations(gross),
|
||||
input_fee_ppk: active_fee[0].input_fee_ppk,
|
||||
}]
|
||||
};
|
||||
let ppk = shape
|
||||
.iter()
|
||||
.try_fold(0u64, |total, group| {
|
||||
total.checked_add(
|
||||
group
|
||||
.input_fee_ppk
|
||||
.checked_mul(group.denominations.len() as u64)?,
|
||||
)
|
||||
})
|
||||
.context("Quoted fee overflow")?;
|
||||
let fee = ppk.checked_add(999).context("Quoted fee overflow")? / 1000;
|
||||
if gross <= fee || gross - fee < seller_net_sats {
|
||||
continue;
|
||||
}
|
||||
let fee_plan = FeePlan::from_shape(mint, shape)?;
|
||||
if fee_plan.net_sats < seller_net_sats {
|
||||
continue;
|
||||
}
|
||||
let (request, funding_fee) = if excess == 0 {
|
||||
(Request::Exact { proofs }, 0)
|
||||
} else {
|
||||
let mut amounts = super::cashu::amount_to_denominations(gross);
|
||||
amounts.extend(super::cashu::amount_to_denominations(excess));
|
||||
let prepared = client
|
||||
.prepare_swap_at_least(&proofs, &amounts, gross)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
prepared.payment_keyset_id() == active.id,
|
||||
"Mint rotated while planning; refresh the offer"
|
||||
);
|
||||
let fee = prepared.input_fee_sats()?;
|
||||
anyhow::ensure!(
|
||||
fee == quoted_funding_fee,
|
||||
"Mint funding fee changed while planning; refresh before acceptance"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
client.restore_prepared_swap(&prepared).await?.is_none(),
|
||||
"Planned outputs already exist; recover the previous operation"
|
||||
);
|
||||
(Request::Swap(prepared), fee)
|
||||
};
|
||||
let debit = gross
|
||||
.checked_add(funding_fee)
|
||||
.context("Payment debit overflow")?;
|
||||
anyhow::ensure!(
|
||||
debit <= max_wallet_debit,
|
||||
"Funding fee exceeds the approved debit limit"
|
||||
);
|
||||
return Ok(PreparedPayment {
|
||||
fee_plan,
|
||||
wallet_debit_sats: debit,
|
||||
request,
|
||||
});
|
||||
}
|
||||
anyhow::bail!("No bounded payment plan covers the seller price within the approved debit limit")
|
||||
}
|
||||
/// Must precede authenticated seller acceptance. This durably pins the exact
|
||||
/// inputs/outputs without reserving or spending; stale inputs later reject.
|
||||
pub(crate) async fn persist(
|
||||
data_dir: &Path,
|
||||
contract: &crate::content_purchase::Contract,
|
||||
plan: &PreparedPayment,
|
||||
) -> Result<()> {
|
||||
contract.validate()?;
|
||||
anyhow::ensure!(
|
||||
plan.fee_plan.mint_url == contract.mint_url
|
||||
&& plan.fee_plan.gross_sats == contract.gross_token_sats
|
||||
&& plan.fee_plan.net_sats >= contract.minimum_net_sats,
|
||||
"Wallet plan changed purchase amounts"
|
||||
);
|
||||
let held = mutation::guard(data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
ecash::load_network(data_dir).await? == contract.network,
|
||||
"Purchase wallet network changed"
|
||||
);
|
||||
let binding = Binding {
|
||||
id: contract.id.clone(),
|
||||
network: contract.network,
|
||||
mint_url: contract.mint_url.clone(),
|
||||
amount_sats: contract.gross_token_sats,
|
||||
context_hash: contract.context_hash()?,
|
||||
};
|
||||
let journal = Journal::new(&held);
|
||||
if let Some(existing) = journal.load(&contract.id).await? {
|
||||
anyhow::ensure!(
|
||||
existing.binding == binding
|
||||
&& serde_json::to_value(&existing.request)? == serde_json::to_value(&plan.request)?,
|
||||
"Original wallet preparation changed"
|
||||
);
|
||||
}
|
||||
if journal.load(&contract.id).await?.is_none() {
|
||||
let buyer = crate::content_purchase::Journal::open(data_dir)
|
||||
.await?
|
||||
.buyer(&contract.id)
|
||||
.await?
|
||||
.context("Buyer intent is missing")?;
|
||||
anyhow::ensure!(buyer.phase == crate::content_purchase::BuyerPhase::Intent,
|
||||
"Accepted operation lost its wallet journal; no new preparation or cancellation is allowed");
|
||||
}
|
||||
journal.prepare(binding, plan.request.clone()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seal before contacting seller. Repeating after a lost seller reply is safe.
|
||||
pub(crate) async fn cancel_unspent(
|
||||
data_dir: &Path,
|
||||
contract: &crate::content_purchase::Contract,
|
||||
plan: &PreparedPayment,
|
||||
) -> Result<()> {
|
||||
persist(data_dir, contract, plan).await?;
|
||||
let held = mutation::guard(data_dir).await?;
|
||||
let binding = Binding {
|
||||
id: contract.id.clone(),
|
||||
network: contract.network,
|
||||
mint_url: contract.mint_url.clone(),
|
||||
amount_sats: contract.gross_token_sats,
|
||||
context_hash: contract.context_hash()?,
|
||||
};
|
||||
Journal::new(&held).cancel_unspent(&binding).await
|
||||
}
|
||||
|
||||
/// Publish the buyer intent while holding the wallet network mutation guard, so
|
||||
/// a concurrent network switch cannot strand a newly published wrong-network row.
|
||||
pub(crate) async fn persist_intent(
|
||||
data_dir: &Path,
|
||||
envelope: &crate::content_purchase_protocol::Envelope,
|
||||
plan: &PreparedPayment,
|
||||
) -> Result<()> {
|
||||
let contract = envelope.contract()?;
|
||||
anyhow::ensure!(plan.fee_plan == envelope.fee_plan, "Buyer fee plan changed");
|
||||
let _held = mutation::guard(data_dir).await?;
|
||||
anyhow::ensure!(
|
||||
ecash::load_network(data_dir).await? == contract.network,
|
||||
"Offer uses another wallet network; no intent was created"
|
||||
);
|
||||
let journal = crate::content_purchase::Journal::open(data_dir).await?;
|
||||
journal.save_buyer_plan(&contract.id, plan).await?;
|
||||
journal.save_protocol_envelope("buyer", envelope).await?;
|
||||
journal
|
||||
.prepare_buyer(&contract, chrono::Utc::now().timestamp())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -450,16 +450,26 @@ pub(super) struct Outcome {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub(super) enum Phase {
|
||||
Prepared,
|
||||
Cancelled,
|
||||
Result(Outcome),
|
||||
Committed(Outcome),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(super) enum DispatchState {
|
||||
#[default]
|
||||
Unknown,
|
||||
NotDispatched,
|
||||
Started,
|
||||
}
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Record {
|
||||
pub binding: Binding,
|
||||
pub request: Request,
|
||||
pub phase: Phase,
|
||||
#[serde(default)]
|
||||
pub dispatch: DispatchState,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Record {
|
||||
@@ -528,6 +538,9 @@ impl<'a> Journal<'a> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if matches!(record.phase, Phase::Cancelled) {
|
||||
continue;
|
||||
}
|
||||
let Phase::Committed(outcome) = record.phase else {
|
||||
anyhow::bail!(
|
||||
"Recover pending payments before restoring this mint from the backup phrase"
|
||||
@@ -634,6 +647,7 @@ impl<'a> Journal<'a> {
|
||||
use super::ecash::TransactionType;
|
||||
let record = self.bound_record(binding).await?;
|
||||
let (outcome, committed) = match &record.phase {
|
||||
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
|
||||
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
|
||||
Phase::Result(outcome) => (outcome, false),
|
||||
Phase::Committed(outcome) => (outcome, true),
|
||||
@@ -867,7 +881,7 @@ impl<'a> Journal<'a> {
|
||||
Self::validate_binding(&record.binding)?;
|
||||
Self::validate_request(&record.binding, &record.request)?;
|
||||
match &record.phase {
|
||||
Phase::Prepared => (),
|
||||
Phase::Prepared | Phase::Cancelled => (),
|
||||
Phase::Result(outcome) | Phase::Committed(outcome) => {
|
||||
Self::validate_outcome(&record, outcome)?
|
||||
}
|
||||
@@ -895,6 +909,7 @@ impl<'a> Journal<'a> {
|
||||
binding,
|
||||
request,
|
||||
phase: Phase::Prepared,
|
||||
dispatch: DispatchState::NotDispatched,
|
||||
};
|
||||
self.write(&record).await?;
|
||||
Ok(record)
|
||||
@@ -911,6 +926,7 @@ impl<'a> Journal<'a> {
|
||||
);
|
||||
Self::validate_outcome(&record, &outcome)?;
|
||||
match &record.phase {
|
||||
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
|
||||
Phase::Prepared => record.phase = Phase::Result(outcome),
|
||||
Phase::Result(previous) | Phase::Committed(previous) => {
|
||||
anyhow::ensure!(
|
||||
@@ -935,6 +951,7 @@ impl<'a> Journal<'a> {
|
||||
"Payment operation terms changed"
|
||||
);
|
||||
record.phase = match record.phase {
|
||||
Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"),
|
||||
Phase::Prepared => anyhow::bail!("Payment result is not durable yet"),
|
||||
Phase::Result(outcome) | Phase::Committed(outcome) => Phase::Committed(outcome),
|
||||
};
|
||||
@@ -942,6 +959,60 @@ impl<'a> Journal<'a> {
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Must finish durably immediately before every fresh mint POST, under the
|
||||
/// same wallet mutation guard as cancellation and input reservation.
|
||||
pub async fn mark_dispatched(&self, binding: &Binding) -> Result<()> {
|
||||
let mut record = self.bound_record(binding).await?;
|
||||
anyhow::ensure!(
|
||||
matches!(record.phase, Phase::Prepared),
|
||||
"Payment cannot be dispatched in this phase"
|
||||
);
|
||||
record.dispatch = DispatchState::Started;
|
||||
self.write(&record).await
|
||||
}
|
||||
/// A terminal local seal precedes release. No mint state query can prove an
|
||||
/// ambiguous old POST will not finish later, so such swaps are never released.
|
||||
pub async fn cancel_unspent(&self, binding: &Binding) -> Result<()> {
|
||||
let mut record = self.bound_record(binding).await?;
|
||||
if !matches!(record.phase, Phase::Cancelled) {
|
||||
anyhow::ensure!(
|
||||
matches!(record.phase, Phase::Prepared),
|
||||
"A prepared token/result cannot be cancelled as unspent"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
matches!(record.request, Request::Exact { .. })
|
||||
|| record.dispatch == DispatchState::NotDispatched,
|
||||
"Mint dispatch is possible; recover original results instead of cancelling"
|
||||
);
|
||||
record.phase = Phase::Cancelled;
|
||||
self.write(&record).await?;
|
||||
}
|
||||
let mut wallet = super::ecash::load_wallet(&self.guard.data_dir).await?;
|
||||
let mut changed = false;
|
||||
for stored in &mut wallet.proofs {
|
||||
if stored.reserved_by.as_deref() != Some(binding.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
anyhow::ensure!(
|
||||
Self::inputs(&record.request)
|
||||
.iter()
|
||||
.any(|input| input.secret == stored.proof.secret
|
||||
&& input.amount == stored.proof.amount
|
||||
&& input.id == stored.proof.id
|
||||
&& input.c == stored.proof.c),
|
||||
"Cancellation reservation differs from original inputs"
|
||||
);
|
||||
anyhow::ensure!(!stored.spent, "Cancellation cannot restore spent inputs");
|
||||
stored.reserved = false;
|
||||
stored.reserved_by = None;
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
super::ecash::save_wallet(&self.guard.data_dir, &wallet).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write(&self, record: &Record) -> Result<()> {
|
||||
let payload = serde_json::to_string(record)?;
|
||||
let checksum = hex::encode(Sha256::digest(payload.as_bytes()));
|
||||
|
||||
Reference in New Issue
Block a user