Skip to content

Commit dca98ac

Browse files
test(wallet): pin the vault arm's inheritance of the non-enumerating total
R1's hot-wallet leg was covered; its vault leg was not. The destination rule iterates `summary.recipients`, so while that list held only hinted outputs a vault spend paying a stranger with no memo presented an EMPTY recipient list: the rule looped zero times, returned Ok, and the spend escalated as an ordinary vault move -- skipping the 24h clawback window entirely. Varies exactly one field (the memo) from the hinted third-party vault spend that is already refused, so the refusal is attributable to hint-blindness and the hinted sibling stands as its control. RED evidence: dropping `.chain(effect.change.iter())` from summary.rs fails this with "expected a refusal, got an escalation to the human". Closes R1 (vault leg). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d528965 commit dca98ac

1 file changed

Lines changed: 46 additions & 0 deletions

File tree

src/wallet/enforcer.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,6 +1662,52 @@ mod tests {
16621662
);
16631663
}
16641664

1665+
/// **The vault destination rule inherits the same fix — an un-hinted outflow is not invisible.**
1666+
///
1667+
/// The exploit above is a hot-wallet one: omitting a memo shrank the CHARGED total. The vault arm
1668+
/// fails differently and worse. `reject_vault_outflow_to_anyone_but_the_hot_wallet` iterates
1669+
/// `summary.recipients`, so while that list held only HINTED outputs, a vault spend paying a
1670+
/// stranger with no memo presented an EMPTY recipient list: the destination rule looped zero times
1671+
/// and returned `Ok`, the spend escalated as if it were an ordinary vault move, and the 24-hour
1672+
/// clawback window — the entire reason the rule exists — never applied to it.
1673+
///
1674+
/// The fixture is the hinted third-party vault spend that is already refused
1675+
/// (`a_vault_spend_to_a_third_party_is_forbidden_outright...`) with ONE field varied: the memo is
1676+
/// dropped. Holding everything else equal is what makes the refusal attributable to hint-blindness
1677+
/// rather than to any other vault rule, and pairing it with that hinted sibling gives the property
1678+
/// a truthful control on the other side.
1679+
#[test]
1680+
fn an_unhinted_vault_outflow_to_a_third_party_is_refused_exactly_like_a_hinted_one() {
1681+
let stranger = third_party().puzzle_hash();
1682+
assert_ne!(
1683+
stranger,
1684+
hot_wallet().puzzle_hash(),
1685+
"the fixture must pay someone other than the hot wallet, or nothing is being tested"
1686+
);
1687+
1688+
let mut ctx = SpendContext::new();
1689+
StandardLayer::new(spender().public_key())
1690+
.spend(
1691+
&mut ctx,
1692+
Coin::new(Bytes32::new([1u8; 32]), spender().puzzle_hash(), 1_000),
1693+
// No memo. `analyze` files this under `change`, which is precisely how it used to
1694+
// slip past a destination rule that only ever read `recipients`.
1695+
Conditions::new()
1696+
.create_coin(stranger, 999, Memos::None)
1697+
.reserve_fee(1),
1698+
)
1699+
.unwrap();
1700+
1701+
let gate = gate_with(vault_custody(), permissive_auto_send());
1702+
let denied = refusal(gate.authorize_op(&ctx.take(), SpendOpClass::SmallSend));
1703+
assert!(
1704+
matches!(denied, AccountError::PolicyDenied(_)),
1705+
"an un-hinted vault outflow must be forbidden outright, exactly as the hinted one is — \
1706+
an escalation here would mean the clawback window can be skipped by omitting a memo, \
1707+
got: {denied}"
1708+
);
1709+
}
1710+
16651711
/// A coin-spend set whose INPUT amounts do not sum in a `u64` is refused rather than judged.
16661712
///
16671713
/// This is the guard over `dig-wallet-backend` 0.16's unchecked input accumulation

0 commit comments

Comments
 (0)