@@ -601,6 +601,9 @@ mod tests {
601601 small_send : OpClassLimits :: enabled_up_to ( u64:: MAX ) ,
602602 period_seconds : DEFAULT_PERIOD_SECONDS ,
603603 period_cap_mojos : u64:: MAX ,
604+ // Permissive here too, so a test that escalates repeatedly fails on the rule it is
605+ // about rather than on the prompt ceiling. The ceiling has its own tests.
606+ max_confirmations_per_period : u32:: MAX ,
604607 }
605608 }
606609
@@ -667,6 +670,43 @@ mod tests {
667670 }
668671
669672 /// The same for a `Result<SpendApproval>`, which `PendingApproval::confirmed` returns.
673+ /// Run the ceremony to `decision` through the real consent seam.
674+ ///
675+ /// A fixed-answer [`AuthProvider`] stands in for the harness, so these tests exercise
676+ /// [`PendingApproval::confirm_with`] — the only public route from a pending approval to a
677+ /// signature — rather than the crate-private tail it delegates to. A host cannot skip this seam,
678+ /// so neither does the test.
679+ async fn confirmed (
680+ pending : PendingApproval ,
681+ decision : crate :: auth:: provider:: SpendDecision ,
682+ ) -> Result < SpendApproval > {
683+ use crate :: auth:: factors:: AuthFactors ;
684+ use crate :: auth:: provider:: { AuthProvider , SpendConfirmRequest , UnlockRequest } ;
685+ use crate :: id:: AccountId ;
686+
687+ use crate :: auth:: provider:: SpendDecision ;
688+
689+ struct Fixed ( SpendDecision ) ;
690+
691+ #[ async_trait:: async_trait]
692+ impl AuthProvider for Fixed {
693+ async fn collect_factors ( & self , _: UnlockRequest ) -> Result < AuthFactors > {
694+ unreachable ! ( "a spend ceremony never collects unlock factors" )
695+ }
696+ async fn confirm_spend ( & self , _: SpendConfirmRequest ) -> Result < SpendDecision > {
697+ Ok ( self . 0 . clone ( ) )
698+ }
699+ }
700+
701+ pending
702+ . confirm_with (
703+ & Fixed ( decision) ,
704+ AccountId :: new ( "ceremony-fixture" ) ,
705+ ProfileIx :: ROOT ,
706+ )
707+ . await
708+ }
709+
670710 fn denial ( result : Result < SpendApproval > ) -> AccountError {
671711 match result {
672712 Ok ( _) => panic ! ( "expected a denial, got a signable approval" ) ,
@@ -1150,8 +1190,8 @@ mod tests {
11501190
11511191 /// A `Confirm`-tier spend reaches the confirm path and, once confirmed, becomes signable — the
11521192 /// whole ceremony, end to end, on a spend the gate will never auto-approve.
1153- #[ test]
1154- fn a_confirm_tier_spend_reaches_the_ceremony_and_a_confirmation_makes_it_signable ( ) {
1193+ #[ tokio :: test]
1194+ async fn a_confirm_tier_spend_reaches_the_ceremony_and_a_confirmation_makes_it_signable ( ) {
11551195 let gate = gate_with ( hot_custody ( ) , permissive_auto_send ( ) ) ;
11561196 let coin_spends = pays_third_party ( CUSTODY_AUTO_SEND_CEILING + 1 ) ;
11571197
@@ -1163,8 +1203,8 @@ mod tests {
11631203 "the user must be shown the spend's real value"
11641204 ) ;
11651205
1166- let approved = escalated
1167- . confirmed ( crate :: auth :: provider :: SpendDecision :: Approve )
1206+ let approved = confirmed ( escalated, crate :: auth :: provider :: SpendDecision :: Approve )
1207+ . await
11681208 . expect ( "a confirmed spend becomes signable" ) ;
11691209 assert_eq ! (
11701210 approved. coin_spends( ) ,
@@ -1180,16 +1220,22 @@ mod tests {
11801220
11811221 /// A declined ceremony yields no approval and charges nothing — a refusal must never cost the
11821222 /// user their allowance.
1183- #[ test]
1184- fn a_declined_ceremony_yields_no_approval_and_charges_nothing ( ) {
1223+ #[ tokio :: test]
1224+ async fn a_declined_ceremony_yields_no_approval_and_charges_nothing ( ) {
11851225 let gate = gate_with ( hot_custody ( ) , permissive_auto_send ( ) ) ;
11861226 let escalated = pending ( gate. authorize_op (
11871227 & pays_third_party ( CUSTODY_AUTO_SEND_CEILING + 1 ) ,
11881228 SpendOpClass :: Tip ,
11891229 ) ) ;
11901230
1191- let err = denial ( escalated. confirmed ( crate :: auth:: provider:: SpendDecision :: Decline ( None ) ) ) ;
1192- assert ! ( matches!( err, AccountError :: PolicyDenied ( _) ) , "{err}" ) ;
1231+ let err = denial (
1232+ confirmed (
1233+ escalated,
1234+ crate :: auth:: provider:: SpendDecision :: Decline ( None ) ,
1235+ )
1236+ . await ,
1237+ ) ;
1238+ assert ! ( matches!( err, AccountError :: UserDeclined ( _) ) , "{err}" ) ;
11931239 assert_eq ! ( ledger_total( & gate) , 0 ) ;
11941240 }
11951241
@@ -1446,24 +1492,89 @@ mod tests {
14461492 approval ( gate. authorize_op ( & pays_third_party ( 1_000 ) , SpendOpClass :: Tip ) ) ;
14471493 }
14481494
1449- /// The layered invariant this gate deliberately does NOT hold alone: an un-hinted output is
1450- /// change, excluded from the summary's recipients, so no amount limit here can see it. The
1451- /// composition is what protects the wallet — the policy gate approves this spend (its whole
1452- /// visible effect is a 1 mojo fee) and the money signer still refuses to sign, because the change
1453- /// output pays a puzzle hash the wallet does not own.
1495+ /// **The value the gate charges is the value that LEAVES — the author cannot shrink it by
1496+ /// omitting a memo.**
1497+ ///
1498+ /// This is the #1702 exploit, and the fixture is built so that the *only* thing that can refuse it
1499+ /// is the rule under test. The destination is `ProfileIx(1)` of the spender's OWN seed: an address
1500+ /// `owns_puzzle_hash` accepts, inside the signer's `0..address_gap` window. So the downstream
1501+ /// "change must be wallet-owned" check — the thing that refused the previous version of this test,
1502+ /// where the destination was a stranger — is silent here by construction. Aim the same 999 mojos at
1503+ /// a stranger and it is impossible to tell which layer refused.
1504+ ///
1505+ /// Before the fix, `analyze` filed the un-hinted output as CHANGE, `recipients` was empty, the
1506+ /// summary read "no recipients, fee 1", the ledger was charged **1**, and the signature authorized
1507+ /// **999**. The human would have been shown a 1 mojo fee and asked to approve a spend of the coin.
14541508 #[ test]
1455- fn refuses_to_sign_unhinted_value_leaving_the_wallet_even_when_the_policy_approves ( ) {
1456- use crate :: wallet:: money_signer:: { LocalMoneySigner , MoneySigner } ;
1457- use dig_wallet_backend:: types:: Network ;
1509+ fn an_unhinted_output_to_an_owned_derivation_is_counted_not_hidden ( ) {
1510+ let attacker_owned = WalletKey :: from_seed_at ( & SPENDER_SEED , ProfileIx ( 1 ) ) . puzzle_hash ( ) ;
1511+ assert_ne ! (
1512+ attacker_owned,
1513+ spender( ) . puzzle_hash( ) ,
1514+ "the fixture must pay a DIFFERENT derivation, or it is testing genuine change"
1515+ ) ;
1516+
1517+ let mut ctx = SpendContext :: new ( ) ;
1518+ StandardLayer :: new ( spender ( ) . public_key ( ) )
1519+ . spend (
1520+ & mut ctx,
1521+ Coin :: new ( Bytes32 :: new ( [ 1u8 ; 32 ] ) , spender ( ) . puzzle_hash ( ) , 1_000 ) ,
1522+ // No memo: `analyze` files this as change, so a hinted-recipient sum cannot see it.
1523+ Conditions :: new ( )
1524+ . create_coin ( attacker_owned, 999 , Memos :: None )
1525+ . reserve_fee ( 1 ) ,
1526+ )
1527+ . unwrap ( ) ;
1528+ let coin_spends = ctx. take ( ) ;
1529+
1530+ let gate = gate_with (
1531+ hot_custody ( ) ,
1532+ AutoSendPolicy {
1533+ enabled : true ,
1534+ rebalance : OpClassLimits :: enabled_up_to ( 10 ) ,
1535+ period_cap_mojos : 10 ,
1536+ ..AutoSendPolicy :: default ( )
1537+ } ,
1538+ ) ;
1539+
1540+ // Escalated, not approved: 1_000 is far past the 10 mojo per-transaction bound.
1541+ let escalated = pending ( gate. authorize_op ( & coin_spends, SpendOpClass :: Rebalance ) ) ;
1542+ let summary = escalated. summary ( ) ;
1543+ assert_eq ! (
1544+ summary. recipients. len( ) ,
1545+ 1 ,
1546+ "the un-hinted output must appear in the line the human confirms: {summary}"
1547+ ) ;
1548+ assert_eq ! ( summary. recipients[ 0 ] . amount_mojos, 999 ) ;
1549+ assert_eq ! (
1550+ summary. native_total_mojos( ) ,
1551+ 1_000 ,
1552+ "the whole coin leaves, so the whole coin is what is weighed"
1553+ ) ;
1554+ assert_eq ! (
1555+ ledger_total( & gate) ,
1556+ 0 ,
1557+ "nothing was auto-approved, so nothing was charged"
1558+ ) ;
1559+ }
14581560
1561+ /// The truthful control for the test above: **genuine change is still free.**
1562+ ///
1563+ /// Identical spend, one field changed — the output pays the exact puzzle hash of the coin being
1564+ /// spent. Value demonstrably has not moved, so it is excluded, the total is the fee alone, and the
1565+ /// spend auto-approves under the same 10 mojo bound that escalated the exploit.
1566+ ///
1567+ /// Without this control the test above would also pass on an implementation that counted every
1568+ /// output unconditionally — which would make every real send unspendable while looking strict.
1569+ #[ test]
1570+ fn change_returning_to_the_spent_coins_own_puzzle_hash_is_not_counted ( ) {
14591571 let mut ctx = SpendContext :: new ( ) ;
14601572 StandardLayer :: new ( spender ( ) . public_key ( ) )
14611573 . spend (
14621574 & mut ctx,
14631575 Coin :: new ( Bytes32 :: new ( [ 1u8 ; 32 ] ) , spender ( ) . puzzle_hash ( ) , 1_000 ) ,
1464- // Un-hinted: `analyze` files this as CHANGE, so it never reaches `recipients`.
14651576 Conditions :: new ( )
1466- . create_coin ( third_party ( ) . puzzle_hash ( ) , 999 , Memos :: None )
1577+ . create_coin ( spender ( ) . puzzle_hash ( ) , 999 , Memos :: None )
14671578 . reserve_fee ( 1 ) ,
14681579 )
14691580 . unwrap ( ) ;
@@ -1481,20 +1592,58 @@ mod tests {
14811592 let approved = approval ( gate. authorize_op ( & coin_spends, SpendOpClass :: Rebalance ) ) ;
14821593 assert ! (
14831594 approved. summary( ) . recipients. is_empty( ) ,
1484- "an un-hinted output is invisible to the summary "
1595+ "returning change to the coin's own puzzle hash moves no value "
14851596 ) ;
1486- assert_eq ! ( approved. summary( ) . fee, 1 , "only the fee is visible" ) ;
1597+ assert_eq ! ( approved. summary( ) . fee, 1 ) ;
1598+ assert_eq ! ( ledger_total( & gate) , 1 , "only the fee is charged" ) ;
1599+ }
14871600
1488- let signer = LocalMoneySigner :: new_canonical (
1489- SPENDER_SEED . to_vec ( ) ,
1490- ProfileIx :: ROOT . 0 ,
1491- Network :: Mainnet ,
1492- )
1493- . unwrap ( ) ;
1494- let err = signer. sign_approved ( approved) . unwrap_err ( ) ;
1495- assert ! (
1496- matches!( err, AccountError :: Spend ( _) ) ,
1497- "the signer must refuse un-hinted value leaving the wallet: {err}"
1601+ /// **Change paid to a FRESH derivation of the same wallet is counted. That is intended.**
1602+ ///
1603+ /// This is the deliberate cost of the rule above, recorded as behaviour rather than discovered as a
1604+ /// bug (`SPEC.md` §6.1.1). This layer holds no key: it cannot tell a fresh derivation of the user's
1605+ /// own wallet from a stranger's address, and the only way it could would be to accept "any owned
1606+ /// derivation" as change — which is exactly the exfiltration target the exploit above uses.
1607+ ///
1608+ /// So the rule over-counts, and a legitimate send whose change goes to a fresh address escalates to
1609+ /// the human instead of auto-sending. Over-counting asks a person; under-counting signs. Only one
1610+ /// of those is a custody failure.
1611+ #[ test]
1612+ fn change_to_a_fresh_derivation_is_deliberately_overcounted_and_escalates ( ) {
1613+ let fresh_change = WalletKey :: from_seed_at ( & SPENDER_SEED , ProfileIx ( 9 ) ) . puzzle_hash ( ) ;
1614+
1615+ let mut ctx = SpendContext :: new ( ) ;
1616+ let recipient = third_party ( ) . puzzle_hash ( ) ;
1617+ let hint = ctx. hint ( recipient) . unwrap ( ) ;
1618+ StandardLayer :: new ( spender ( ) . public_key ( ) )
1619+ . spend (
1620+ & mut ctx,
1621+ Coin :: new ( Bytes32 :: new ( [ 1u8 ; 32 ] ) , spender ( ) . puzzle_hash ( ) , 1_000 ) ,
1622+ Conditions :: new ( )
1623+ . create_coin ( recipient, 5 , hint)
1624+ . create_coin ( fresh_change, 994 , Memos :: None )
1625+ . reserve_fee ( 1 ) ,
1626+ )
1627+ . unwrap ( ) ;
1628+ let coin_spends = ctx. take ( ) ;
1629+
1630+ let gate = gate_with (
1631+ hot_custody ( ) ,
1632+ AutoSendPolicy {
1633+ enabled : true ,
1634+ rebalance : OpClassLimits :: enabled_up_to ( 10 ) ,
1635+ period_cap_mojos : 1_000 ,
1636+ ..AutoSendPolicy :: default ( )
1637+ } ,
1638+ ) ;
1639+
1640+ // The genuine payment is 5 mojos, well inside the 10 mojo bound. It escalates anyway, because
1641+ // the 994 of change to an address this layer cannot vouch for is counted as leaving.
1642+ let escalated = pending ( gate. authorize_op ( & coin_spends, SpendOpClass :: Rebalance ) ) ;
1643+ assert_eq ! (
1644+ escalated. summary( ) . native_total_mojos( ) ,
1645+ 1_000 ,
1646+ "the fresh-derivation change is counted, by design"
14981647 ) ;
14991648 }
15001649
0 commit comments