Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions migrations/20260720120000_cashu_fee_fields.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-- Track A TA-1f — Cashu fee collection (Option 2, funder-pays-at-lock).
--
-- The seller funds the WHOLE Mostro fee (`2 * order.fee`) as a separate
-- P2PK-1-of-1 token locked to Mostro's arbitrator key, submitted alongside the
-- 2-of-3 escrow in the same `AddCashuEscrow`. These columns persist that fee
-- token crash-safely, in the SAME atomic write as the lock, so a redeem
-- interrupted by a crash between lock and swap is retryable from the DB (a
-- scheduler job picks up `cashu_fee_token IS NOT NULL AND
-- cashu_fee_redeemed_at IS NULL`). All NULL for Lightning orders and for
-- fee-free (`mostro.fee == 0`) Cashu nodes.
--
-- * cashu_fee_token Serialized P2PK-1-of-1 fee token (Mostro's revenue).
-- * cashu_fee_redeemed_at Unix seconds when Mostro redeemed the fee token; NULL
-- until collected.
ALTER TABLE orders ADD COLUMN cashu_fee_token text;
ALTER TABLE orders ADD COLUMN cashu_fee_redeemed_at integer;

-- Cross-order anti-reuse guard. The fee token is P2PK to the node-wide `P_M`
-- (unlike the escrow token, whose 2-of-3 embeds per-order trade keys), so the
-- NUT-07 unspent check only stops SEQUENTIAL reuse. Two concurrent
-- `AddCashuEscrow` for two same-fee orders could both validate the same fee
-- token before the first redeem (TOCTOU). Recording each fee proof's
-- `Y = hash_to_curve(secret)` with a UNIQUE primary key, inserted in the SAME
-- transaction as the lock CAS, makes the second submission fail cleanly.
CREATE TABLE IF NOT EXISTS cashu_fee_proofs (
y text PRIMARY KEY,
order_id text NOT NULL,
created_at integer NOT NULL
);
105 changes: 82 additions & 23 deletions src/app/add_cashu_escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
//! Fee collection (Option 2, §4A) is **not** handled here — it lands in TA-1f.

use crate::app::context::AppContext;
use crate::cashu::{cashu_pubkey_from_xonly_hex, Error as CashuError};
use crate::cashu::{cashu_pubkey_from_xonly_hex, proof_ys_hex, Error as CashuError};
use crate::config::settings::Settings;
use crate::db::update_order_cashu_escrow;
use crate::db::{update_order_cashu_escrow, update_order_cashu_escrow_with_fee, CashuLockOutcome};
use crate::util::{enqueue_order_msg, get_order, update_order_event};
use chrono::Utc;
use mostro_core::db::Crud;
Expand Down Expand Up @@ -140,27 +140,86 @@ pub async fn add_cashu_escrow_action(
.await
.map_err(|e| MostroCantDo(cashu_reason(&e)))?;

// 8. Atomically persist the escrow and advance the status in one write. A
// `false` return means the status changed concurrently or the escrow is
// already locked (replay) — log and return `Ok(())` without notifying
// (idempotent; same shape as the `rows_affected() == 0` guard in
// `release_action`).
let locked = update_order_cashu_escrow(
pool,
order.id,
&configured_mint,
&proof.token,
now,
Status::WaitingPayment,
Status::Active,
)
.await?;
if !locked {
tracing::info!(
"cashu lock: compare-and-set matched zero rows for order {} (replay or status moved on) — no-op",
order.id
);
return Ok(());
// 7A. Fee token (Option 2, §4A). When the node charges a fee, the seller
// must fund the WHOLE Mostro fee (`2 * order.fee`) as a separate
// P2PK-1-of-1 token locked to `P_M`, submitted in the same message. It
// is validated against the mint alongside the escrow token — both must
// be valid before any state changes. A fee-free node (`mostro.fee == 0`)
// accepts a `None` fee token.
let charges_fee = Settings::get_mostro().fee > 0.0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip fee tokens when the stored fee is zero

This gates fee-token enforcement on the configured percentage rather than the actual per-order fee. For small orders where get_fee rounds the stored order.fee to 0 even though mostro.fee > 0 (for example a 100-sat order at a 0.3% fee), the handler requires a fee_token and then expects a 0-sat token, so otherwise valid zero-fee orders cannot be locked. Use the calculated order fee (order.fee > 0) to decide whether a fee token is required.

Useful? React with 👍 / 👎.

let fee_ctx = if charges_fee {
let fee_token = proof
.fee_token
.clone()
.ok_or(MostroCantDo(CantDoReason::CashuSignatureMissing))?;
let expected_fee = u64::try_from(order.fee.saturating_mul(2))
.map_err(|_| MostroCantDo(CantDoReason::InvalidAmount))?;
let fee_token_parsed = cashu_client
.verify_fee_token(&fee_token, p_m, expected_fee)
.await
.map_err(|e| MostroCantDo(cashu_reason(&e)))?;
let fee_ys = proof_ys_hex(&fee_token_parsed).map_err(|e| MostroCantDo(cashu_reason(&e)))?;
Some((fee_token, fee_ys))
} else {
None
};

// 8. Atomically persist the escrow (+ fee token & anti-reuse guard when
// charging) and advance the status in one write. A no-match means the
// status changed concurrently or the escrow is already locked (replay) —
// log and return `Ok(())` without notifying (idempotent; same shape as
// the `rows_affected() == 0` guard in `release_action`).
match &fee_ctx {
Some((fee_token, fee_ys)) => {
match update_order_cashu_escrow_with_fee(
pool,
order.id,
&configured_mint,
&proof.token,
fee_token,
fee_ys,
now,
Status::WaitingPayment,
Status::Active,
)
.await?
{
CashuLockOutcome::Locked => {}
CashuLockOutcome::StatusMismatch => {
tracing::info!(
"cashu lock: CAS matched zero rows for order {} (replay or status moved on) — no-op",
order.id
);
return Ok(());
}
CashuLockOutcome::FeeReuse => {
tracing::warn!(
"cashu lock: fee token reused across orders for {} — rejected",
order.id
);
return Err(MostroCantDo(CantDoReason::InvalidCashuToken));
}
}
}
None => {
let locked = update_order_cashu_escrow(
pool,
order.id,
&configured_mint,
&proof.token,
now,
Status::WaitingPayment,
Status::Active,
)
.await?;
if !locked {
tracing::info!(
"cashu lock: compare-and-set matched zero rows for order {} (replay or status moved on) — no-op",
order.id
);
return Ok(());
}
}
}

// 9. Publish the updated (Active) order event so the public state stays
Expand Down
193 changes: 193 additions & 0 deletions src/cashu/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,79 @@ impl CashuClient {
Ok(token)
}

/// Full fee-token validation for Track A's fee collection
/// (Option 2, §4A). The fee token is a **P2PK 1-of-1** locked to exactly
/// Mostro's arbitrator key `p_m` (no extra pubkeys, no locktime/refund
/// required), value `expected_fee` sats, hosted on the configured mint,
/// DLEQ-valid (NUT-12) and unspent (NUT-07). Mirrors
/// [`Self::verify_escrow_token`] but for the single-key fee token and
/// without the seller-recovery locktime the escrow token carries.
pub async fn verify_fee_token(
&self,
token_str: &str,
p_m: PublicKey,
expected_fee: u64,
) -> Result<Token, Error> {
// 1: condition — exactly p_m signs, 1-of-1 (offline).
let token = verify_fee_condition(token_str, p_m)?;

// 2: the token must be hosted on the node's configured mint.
let token_mint = token
.mint_url()
.map_err(|e| Error::Token(format!("token mint url: {e}")))?;
if token_mint != self.mint_url {
return Err(Error::Token(format!(
"fee token mint {token_mint} does not match configured mint {}",
self.mint_url
)));
}

reject_duplicate_proofs(&token)?;

// 3: amount must equal the expected fee exactly, denominated in sats.
match token.unit() {
Some(CurrencyUnit::Sat) => {}
other => {
return Err(Error::Token(format!("fee token unit {other:?} is not sat")));
}
}
let value = token
.value()
.map_err(|e| Error::Token(format!("fee token value: {e}")))?
.to_u64();
if value != expected_fee {
return Err(Error::Token(format!(
"fee token amount {value} does not match expected {expected_fee}"
)));
}

// 4: mint-issued (DLEQ / NUT-12) + per-proof sat keyset.
self.verify_token_dleq(&token).await?;

// 5: every proof unspent (NUT-07), fail-closed on a short reply.
let secrets = token.token_secrets();
let ys = secrets
.iter()
.map(|s| cdk::dhke::hash_to_curve(s.as_bytes()))
.collect::<Result<Vec<PublicKey>, _>>()
.map_err(|e| Error::Token(format!("proof Y: {e}")))?;
let expected_states = ys.len();
let states = self.check_state(ys).await?;
if states.states.len() != expected_states {
return Err(Error::Token(format!(
"checkstate returned {} states for {expected_states} fee proofs",
states.states.len()
)));
}
if states.states.iter().any(|s| s.state != State::Unspent) {
return Err(Error::Token(
"one or more fee proofs are not unspent".into(),
));
}

Ok(token)
}

/// Whether **every** proof of a stored escrow token is still unspent at
/// the mint (NUT-07). Used by the restore/monitor path (Track A TA-3) to
/// re-hydrate in-flight locks after a restart and surface any escrow that
Expand Down Expand Up @@ -592,6 +665,93 @@ pub fn verify_escrow_conditions(
Ok(token)
}

/// Offline condition check for the Option 2 fee token (§4A): a **P2PK 1-of-1**
/// locked to exactly `p_m`. Every proof must be P2PK with `SIG_INPUTS`, sign to
/// exactly `p_m` (the NUT-10 `data` key) with no extra pubkeys, and require at
/// most one signature (NUT-11 defaults an absent `n_sigs` to 1). Unlike the
/// escrow token there is no locktime/refund requirement — the simple 1-of-1
/// form redeemed by Mostro (the locktime+`P_S`-refund refinement is future
/// work). Split out so it is unit-testable without a mint.
pub fn verify_fee_condition(token_str: &str, p_m: PublicKey) -> Result<Token, Error> {
let token = Token::from_str(token_str).map_err(|e| Error::Token(e.to_string()))?;

let secrets = token.token_secrets();
if secrets.is_empty() {
return Err(Error::Token("Token contains no secrets".into()));
}

for secret in secrets {
let nut10_secret =
Nut10Secret::try_from(secret).map_err(|e| Error::Condition(e.to_string()))?;
reject_duplicate_standard_tags(&nut10_secret)?;

let spending_conditions = SpendingConditions::try_from(nut10_secret)
.map_err(|e| Error::Condition(e.to_string()))?;

let (data, conditions) = match spending_conditions {
SpendingConditions::P2PKConditions { data, conditions } => (data, conditions),
other => {
return Err(Error::Condition(format!(
"fee spending condition kind {:?} is not P2PK",
other.kind()
)));
}
};

// The NUT-11 tag block is optional for a bare 1-of-1 (absent ⇒
// SIG_INPUTS, n_sigs = 1, no extra keys). When present, enforce it.
if let Some(conditions) = conditions {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject refundable Cashu fee tokens

When a seller includes NUT-11 locktime/refund tags on the fee token, this branch still accepts it because it only checks sig_flag, n_sigs, and extra signing pubkeys. The existing escrow validation treats refund_keys as an alternate post-locktime spend path, so a malicious seller can submit a fee token that is initially valid but later spendable by the refund key instead of being exclusively controlled by Mostro, especially while live redemption is deferred. Reject refund/locktime tags here, or enforce a safe fee-token recovery policy.

Useful? React with 👍 / 👎.

if conditions.sig_flag != SigFlag::SigInputs {
return Err(Error::Condition(format!(
"fee sig flag {:?} is not SIG_INPUTS",
conditions.sig_flag
)));
}
if let Some(n) = conditions.num_sigs {
if n != 1 {
return Err(Error::Condition(format!(
"fee token must require exactly 1 signature, got {n}"
)));
}
}
if conditions
.pubkeys
.as_ref()
.is_some_and(|extra| !extra.is_empty())
{
return Err(Error::Condition(
"fee token must lock to exactly Mostro's key (no extra pubkeys)".into(),
));
}
}

if data != p_m {
return Err(Error::Condition(
"fee token is not locked to Mostro's key".into(),
));
}
}

Ok(token)
}

/// The NUT-07 `Y = hash_to_curve(secret)` of every proof in a token, as hex.
///
/// Track A TA-1f records these for the fee token in the `cashu_fee_proofs`
/// anti-reuse table, in the same transaction as the lock CAS, so the same fee
/// token can never be accepted for two orders.
pub fn proof_ys_hex(token: &Token) -> Result<Vec<String>, Error> {
token
.token_secrets()
.iter()
.map(|s| {
cdk::dhke::hash_to_curve(s.as_bytes())
.map(|y| y.to_hex())
.map_err(|e| Error::Token(format!("proof Y: {e}")))
})
.collect()
}

/// Convert a Nostr (BIP340 x-only) public key, given as 64-char hex, into a
/// Cashu (compressed secp256k1) [`PublicKey`].
///
Expand Down Expand Up @@ -750,6 +910,39 @@ mod tests {
assert!(CashuClient::verify_2of3_condition(&token, p_b, p_s, p_m).is_ok());
}

#[test]
fn fee_condition_accepts_1of1_to_mostro() {
let p_m = keypair(3);
// 1-of-1: data = p_m, no extra pubkeys, num_sigs = 1, no locktime.
let token = token_with_condition(p_m, vec![], Some(1), None, None, None);
assert!(verify_fee_condition(&token, p_m).is_ok());
}

#[test]
fn fee_condition_rejects_wrong_key_extra_key_or_wrong_sig_count() {
let (p_b, p_m) = (keypair(1), keypair(3));
// Locked to the wrong key.
let wrong = token_with_condition(p_b, vec![], Some(1), None, None, None);
assert!(verify_fee_condition(&wrong, p_m).is_err());
// An extra co-signer widens the accepted set — must be rejected.
let extra = token_with_condition(p_m, vec![p_b], Some(1), None, None, None);
assert!(verify_fee_condition(&extra, p_m).is_err());
// More than one required signature is not a 1-of-1.
let two = token_with_condition(p_m, vec![p_b], Some(2), None, None, None);
assert!(verify_fee_condition(&two, p_m).is_err());
}

#[test]
fn fee_proof_ys_are_derivable_and_stable() {
let p_m = keypair(3);
let token_str = token_with_condition(p_m, vec![], Some(1), None, None, None);
let token = Token::from_str(&token_str).unwrap();
let ys = proof_ys_hex(&token).unwrap();
assert_eq!(ys.len(), 1);
// Deterministic: the same token yields the same Y.
assert_eq!(proof_ys_hex(&token).unwrap(), ys);
}

#[test]
fn rejects_wrong_sig_count() {
let (p_b, p_s, p_m) = (keypair(1), keypair(2), keypair(3));
Expand Down
Loading