|
50 | 50 | //! a property of the AUTHORIZER THE CALLER BUILT, not a property of the funds — the caller MUST |
51 | 51 | //! construct the authorizer that matches the coins it is about to spend. |
52 | 52 | //! |
53 | | -//! **A [`SpendSummary`] accounts only for HINTED outputs plus the fee.** An un-hinted output is |
54 | | -//! change, which `dig-wallet-backend`'s re-derivation excludes from the recipient list, so no amount |
55 | | -//! limit here can see it. That invariant is held one layer down by |
56 | | -//! [`LocalMoneySigner`](crate::wallet::money_signer::LocalMoneySigner), which refuses to sign a spend |
57 | | -//! with a change output the wallet does not own — which bounds where such value can GO (somewhere |
58 | | -//! under the same seed) but not that it obeyed a policy. |
59 | | -//! `refuses_to_sign_unhinted_value_leaving_the_wallet_even_when_the_policy_approves` pins the |
60 | | -//! composition. |
| 53 | +//! **A [`SpendSummary`] counts every output by DESTINATION, never by hint status** — so this |
| 54 | +//! paragraph's former warning (that an un-hinted output was invisible to every amount limit) no |
| 55 | +//! longer holds, and neither does any custody claim built on it. An output is weighed unless it pays |
| 56 | +//! a puzzle hash the spend is itself spending from. The signer's change-ownership check remains a |
| 57 | +//! required second layer, because this one bounds how MUCH value leaves while that one bounds where |
| 58 | +//! it may go; `an_unhinted_output_to_an_owned_derivation_is_counted_not_hidden` and |
| 59 | +//! `refuses_to_sign_unhinted_value_leaving_the_wallet_even_when_the_policy_approves` pin the two |
| 60 | +//! halves. |
61 | 61 |
|
62 | 62 | use std::collections::VecDeque; |
63 | 63 | use std::sync::{Arc, Mutex}; |
@@ -1761,4 +1761,94 @@ mod tests { |
1761 | 1761 | "the projection must refuse rather than wrap to 0: {err}" |
1762 | 1762 | ); |
1763 | 1763 | } |
| 1764 | + |
| 1765 | + /// A standard-layer spend of `coin_amount` that creates one hinted output per entry in |
| 1766 | + /// `outputs`, with NO total pre-check — the fixture the summable-total rules need. |
| 1767 | + /// |
| 1768 | + /// `spend_paying` deliberately refuses to build an unrepresentable total (it `checked_add`s and |
| 1769 | + /// panics), which is right for every fixture that must be a *well-formed* spend. The rules below |
| 1770 | + /// are about the spends that are NOT well-formed, so they need a builder that will emit one. |
| 1771 | + fn spend_creating(coin_amount: u64, outputs: &[(Bytes32, u64)]) -> Vec<CoinSpend> { |
| 1772 | + let mut ctx = SpendContext::new(); |
| 1773 | + let mut conditions = Conditions::new(); |
| 1774 | + for (puzzle_hash, amount) in outputs { |
| 1775 | + let hint = ctx.hint(*puzzle_hash).unwrap(); |
| 1776 | + conditions = conditions.create_coin(*puzzle_hash, *amount, hint); |
| 1777 | + } |
| 1778 | + StandardLayer::new(spender().public_key()) |
| 1779 | + .spend( |
| 1780 | + &mut ctx, |
| 1781 | + Coin::new(Bytes32::new([9u8; 32]), spender().puzzle_hash(), coin_amount), |
| 1782 | + conditions, |
| 1783 | + ) |
| 1784 | + .unwrap(); |
| 1785 | + ctx.take() |
| 1786 | + } |
| 1787 | + |
| 1788 | + /// **A spend whose CREATED-OUTPUT amounts do not sum in a `u64` never becomes an approval.** |
| 1789 | + /// |
| 1790 | + /// This is the value-conservation bypass, and it is a bypass precisely because the wrap makes the |
| 1791 | + /// spend look conserving. A `1_000`-mojo coin creating `u64::MAX` and `1_001` sums, modulo 2^64, |
| 1792 | + /// back to exactly `1_000` — so an implementation that accumulates output amounts with a wrapping |
| 1793 | + /// `+=` finds `xch_in == xch_out`, declares value conserved, and hands back an effect describing |
| 1794 | + /// a spend of every mojo that will ever exist. The dependency's security gate showed |
| 1795 | + /// `LocalSigner::sign_unsigned` will emit a real aggregated BLS signature over such a spend, so |
| 1796 | + /// the refusal has to happen before an approval exists, not at the signature. |
| 1797 | + /// |
| 1798 | + /// The fixture's amounts are chosen FROM the bound rather than picked large: `u64::MAX + 1_001` |
| 1799 | + /// is the smallest pair that both overflows and lands back on a plausible coin amount, so the |
| 1800 | + /// test cannot pass merely because the numbers were too big to be believed. |
| 1801 | + /// |
| 1802 | + /// Requires `dig-wallet-backend` >= 0.16.1, where all four accumulation sites route through a |
| 1803 | + /// fallible `accumulate`. Pinned by EXECUTION against 0.16.0, where it fails in both build |
| 1804 | + /// profiles for the same underlying reason: debug panics on the unchecked `+=` at |
| 1805 | + /// `client/verify.rs:165`, release wraps and the gate returns an approval. That version |
| 1806 | + /// difference is the whole content of the guarantee, which is why the dependency floor is |
| 1807 | + /// `0.16.1` and not `0.16`. |
| 1808 | + #[test] |
| 1809 | + fn a_spend_whose_output_amounts_overflow_is_never_approved() { |
| 1810 | + let gate = gate_with(hot_custody(), permissive_auto_send()); |
| 1811 | + let overflowing = spend_creating( |
| 1812 | + 1_000, |
| 1813 | + &[ |
| 1814 | + (third_party().puzzle_hash(), u64::MAX), |
| 1815 | + (third_party().puzzle_hash(), 1_001), |
| 1816 | + ], |
| 1817 | + ); |
| 1818 | + |
| 1819 | + let err = refusal(gate.authorize_op(&overflowing, SpendOpClass::Tip)); |
| 1820 | + |
| 1821 | + assert!( |
| 1822 | + matches!(&err, AccountError::Spend(_)), |
| 1823 | + "an unaccountable spend must be refused at the derivation, before any approval or signature exists: {err}" |
| 1824 | + ); |
| 1825 | + } |
| 1826 | + |
| 1827 | + /// THE OTHER SIDE OF THE SAME BOUND — the refusal above must be about overflow, not about size. |
| 1828 | + /// |
| 1829 | + /// A guard tested only from over the bound cannot distinguish "rejects what does not sum" from |
| 1830 | + /// "rejects large amounts", and the second would refuse every legitimate whale spend while every |
| 1831 | + /// test stayed green. So the largest total that DOES sum — outputs of `u64::MAX - 1` and `1`, |
| 1832 | + /// exactly `u64::MAX`, from a coin of exactly `u64::MAX` — must be accounted for and reach a |
| 1833 | + /// ruling. |
| 1834 | + #[test] |
| 1835 | + fn a_spend_whose_output_amounts_sum_to_exactly_u64_max_is_still_accountable() { |
| 1836 | + let gate = gate_with(hot_custody(), permissive_auto_send()); |
| 1837 | + let at_the_bound = spend_creating( |
| 1838 | + u64::MAX, |
| 1839 | + &[ |
| 1840 | + (third_party().puzzle_hash(), u64::MAX - 1), |
| 1841 | + (third_party().puzzle_hash(), 1), |
| 1842 | + ], |
| 1843 | + ); |
| 1844 | + |
| 1845 | + // It escalates rather than auto-sends (it is far over the hot allowance), which is a RULING: |
| 1846 | + // the derivation accounted for it. The point is that it is not refused as unaccountable. |
| 1847 | + let ruling = gate.authorize_op(&at_the_bound, SpendOpClass::Tip); |
| 1848 | + assert!( |
| 1849 | + !matches!(&ruling, Err(AccountError::Spend(_))), |
| 1850 | + "a spend whose outputs sum to exactly u64::MAX is accountable and must not be refused as though it overflowed" |
| 1851 | + ); |
| 1852 | + let _ = pending(ruling); |
| 1853 | + } |
1764 | 1854 | } |
0 commit comments