From d795384b9078678b5f97ee4802a7a1b56fe210eb Mon Sep 17 00:00:00 2001 From: owen Date: Fri, 14 Aug 2026 18:04:18 +0100 Subject: [PATCH] add slot locked disperser Similar to the PaymentForwarder and needed for the same reason, but used the block merging rev share payment. --- .gitmodules | 9 + contracts/.gitignore | 1 + contracts/README.md | 104 ++---- contracts/docs/PaymentForwarder.md | 84 +++++ contracts/docs/SlotLockedDisperser.md | 102 ++++++ contracts/foundry.lock | 11 + contracts/foundry.toml | 12 +- contracts/lib/forge-std | 1 + contracts/lib/foundry-huff | 1 + contracts/lib/halmos-cheatcodes | 1 + contracts/src/SlotLockedDisperser.huff | 107 ++++++ .../test/SlotLockedDisperser.security.t.sol | 244 +++++++++++++ .../test/SlotLockedDisperser.symbolic.t.sol | 322 ++++++++++++++++++ contracts/test/SlotLockedDisperser.t.sol | 121 +++++++ 14 files changed, 1044 insertions(+), 76 deletions(-) create mode 100644 .gitmodules create mode 100644 contracts/docs/PaymentForwarder.md create mode 100644 contracts/docs/SlotLockedDisperser.md create mode 100644 contracts/foundry.lock create mode 160000 contracts/lib/forge-std create mode 160000 contracts/lib/foundry-huff create mode 160000 contracts/lib/halmos-cheatcodes create mode 100644 contracts/src/SlotLockedDisperser.huff create mode 100644 contracts/test/SlotLockedDisperser.security.t.sol create mode 100644 contracts/test/SlotLockedDisperser.symbolic.t.sol create mode 100644 contracts/test/SlotLockedDisperser.t.sol diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..01eba98b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "contracts/lib/forge-std"] + path = contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "contracts/lib/foundry-huff"] + path = contracts/lib/foundry-huff + url = https://github.com/huff-language/foundry-huff +[submodule "contracts/lib/halmos-cheatcodes"] + path = contracts/lib/halmos-cheatcodes + url = https://github.com/a16z/halmos-cheatcodes diff --git a/contracts/.gitignore b/contracts/.gitignore index 83f939c7..0ca0325c 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -1,3 +1,4 @@ out/ cache/ broadcast/ +.venv-halmos/ diff --git a/contracts/README.md b/contracts/README.md index 59392f1d..06c03d50 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,88 +1,42 @@ -# PaymentForwarder +# contracts -``` -0xFEEEEEE44046c3f61a8CC081E0918eF0de0a7ffC -``` - -Same address on every chain: deployed with the canonical deterministic-deployment -proxy (`0x4e59b44847b379578588920cA78FbF26c0B4956C`) under a mined salt. - -## Why - -A proposer payment sent as a plain transfer is valid in any block. If the block -it was built for is reorged out, or the slot is missed once the payload is -public, the signed transfer can be replayed in a later slot and the payer pays -the previous slot's fee recipient a second time. - -Paying through this contract binds the payment to one slot. The recipient and the -expected timestamp travel in the calldata, and the call reverts unless -`block.timestamp` matches. +Slot-locked payment contracts: bind a payment to the one block/slot it was +built for, so a payment exposed by a reorg or a missed slot can't be +replayed into a later slot. -## The contract +- **[PaymentForwarder](docs/PaymentForwarder.md)** — forwards the contract's + entire balance to a single recipient. 20 bytes of runtime, deployed at the + same address on every chain, the canonical payment contract relays + validate against. +- **[SlotLockedDisperser](docs/SlotLockedDisperser.md)** — pays N recipients + and refunds the caller's leftover in one atomic call, for splitting a Merged block's added value between several contributors instead of forwarding to one. -20 bytes of runtime, in full: +## Development -``` -5f358060e01c4218600f5760401cff5b5f5ffd00 +Requires [forge](https://getfoundry.sh). `SlotLockedDisperser` additionally +needs [`huffc`](https://docs.huff.sh/get-started/installing/) (`PaymentForwarder` +tests run against a committed runtime and don't need it). -PUSH0 CALLDATALOAD // calldata word: [uint32 timestamp][20-byte recipient] -DUP1 PUSH1 0xe0 SHR // timestamp -TIMESTAMP XOR // zero if it matches -PUSH1 0x0f JUMPI // otherwise revert -PUSH1 0x40 SHR // recipient -SELFDESTRUCT // send balance to recipient -JUMPDEST PUSH0 PUSH0 REVERT +```bash +forge test +forge snapshot # refresh .gas-snapshot ``` -Since EIP-6780 `SELFDESTRUCT` just sends the balance to the recipient. A payment -costs at most 29,022 gas, or 54,022 when the recipient account does not exist -yet, and does not depend on the recipient's code; zero bytes in the calldata come -in a few gas under. That figure is also the minimum viable gas limit, since -nothing is held in flight. The recipient is not executed, so nothing can be left -in the contract and a recipient that would reject a transfer is still paid. - -Calldata is not forwarded and there is no length check: a payment that omits the -recipient sends the balance to the zero address, so callers must encode all 24 -bytes. +### Formal verification (Halmos) -## Payment verification +`SlotLockedDisperser` can't use the SELFDESTRUCT that PaymentForwarder uses so extra steps have been taken to ensure it's safe to use. It's properties are proven, not sampled, with +[Halmos](https://github.com/a16z/halmos) — see +[docs/SlotLockedDisperser.md](docs/SlotLockedDisperser.md#formal-verification) +for what's proven. Install: -This is the canonical payment contract for builders, so a relay validating a -submission has to accept it alongside a direct transfer. - -`ensure_payment` prefers the fee recipient's balance delta, which is unaffected -by the forwarder. When that check does not cover the bid, the fallback inspects -the last transaction, and a forwarded payment does not look like a transfer: `to` -is the forwarder and the calldata is not empty. The fallback therefore accepts -either shape: - -- `to` is the fee recipient with empty calldata, or -- `to` is `PAYMENT_FORWARDER` and the recipient in its calldata is the fee - recipient - -with the value equal to the bid in both cases. A successful receipt for the -second shape means the balance reached the recipient, since the contract has no -other path to success. - -## Bid adjustments - -An adjustment rewrites the payment from the relay's own fee payer. The contract -does not authenticate the sender, so it can keep every field of the original -payment except the nonce and the value. - -Keeping `to` and the calldata is what an implementation must do: the substituted -transaction then costs exactly the gas the builder reported, so the header -`gas_used`, the payment receipt's `cumulative_gas_used` and the fee payer's -balance all stay consistent without re-estimating anything. Replacing a forwarded -payment with a direct transfer changes its gas and invalidates the block. - -## Development +```bash +python3 -m venv .venv-halmos && .venv-halmos/bin/pip install halmos +``` -Foundry project, not wired into the Rust build. Requires -[forge](https://getfoundry.sh). The tests run against the committed runtime, so -no Huff compiler is needed to verify behaviour. +Run (`--ffi` lets `HuffDeployer` shell out to `huffc`, off by default in +Halmos; `--loop` raises the loop-unrolling bound past its default of 2, +enough to cover the largest recipient count checked): ```bash -forge test -forge snapshot # refresh .gas-snapshot +.venv-halmos/bin/halmos --contract SlotLockedDisperserSymbolicTest --ffi --loop 27 ``` diff --git a/contracts/docs/PaymentForwarder.md b/contracts/docs/PaymentForwarder.md new file mode 100644 index 00000000..2614be6e --- /dev/null +++ b/contracts/docs/PaymentForwarder.md @@ -0,0 +1,84 @@ +# PaymentForwarder + +``` +0xFEEEEEE44046c3f61a8CC081E0918eF0de0a7ffC +``` + +Same address on every chain: deployed with the canonical deterministic-deployment +proxy (`0x4e59b44847b379578588920cA78FbF26c0B4956C`) under a mined salt. + +## Why + +A proposer payment sent as a plain transfer is valid in any block. If the block +it was built for is reorged out, or the slot is missed once the payload is +public, the signed transfer can be replayed in a later slot and the payer pays +the previous slot's fee recipient a second time. + +Paying through this contract binds the payment to one slot. The recipient and the +expected timestamp travel in the calldata, and the call reverts unless +`block.timestamp` matches. + +## The contract + +20 bytes of runtime, in full: + +``` +5f358060e01c4218600f5760401cff5b5f5ffd00 + +PUSH0 CALLDATALOAD // calldata word: [uint32 timestamp][20-byte recipient] +DUP1 PUSH1 0xe0 SHR // timestamp +TIMESTAMP XOR // zero if it matches +PUSH1 0x0f JUMPI // otherwise revert +PUSH1 0x40 SHR // recipient +SELFDESTRUCT // send balance to recipient +JUMPDEST PUSH0 PUSH0 REVERT +``` + +Since EIP-6780 `SELFDESTRUCT` just sends the balance to the recipient. A payment +costs at most 29,022 gas, or 54,022 when the recipient account does not exist +yet, and does not depend on the recipient's code; zero bytes in the calldata come +in a few gas under. That figure is also the minimum viable gas limit, since +nothing is held in flight. The recipient is not executed, so nothing can be left +in the contract and a recipient that would reject a transfer is still paid. + +Calldata is not forwarded and there is no length check: a payment that omits the +recipient sends the balance to the zero address, so callers must encode all 24 +bytes. + +## Payment verification + +This is the canonical payment contract for builders, so a relay validating a +submission has to accept it alongside a direct transfer. + +`ensure_payment` prefers the fee recipient's balance delta, which is unaffected +by the forwarder. When that check does not cover the bid, the fallback inspects +the last transaction, and a forwarded payment does not look like a transfer: `to` +is the forwarder and the calldata is not empty. The fallback therefore accepts +either shape: + +- `to` is the fee recipient with empty calldata, or +- `to` is `PAYMENT_FORWARDER` and the recipient in its calldata is the fee + recipient + +with the value equal to the bid in both cases. A successful receipt for the +second shape means the balance reached the recipient, since the contract has no +other path to success. + +## Bid adjustments + +An adjustment rewrites the payment from the relay's own fee payer. The contract +does not authenticate the sender, so it can keep every field of the original +payment except the nonce and the value. + +Keeping `to` and the calldata is what an implementation must do: the substituted +transaction then costs exactly the gas the builder reported, so the header +`gas_used`, the payment receipt's `cumulative_gas_used` and the fee payer's +balance all stay consistent without re-estimating anything. Replacing a forwarded +payment with a direct transfer changes its gas and invalidates the block. + +## Development + +Unlike `SlotLockedDisperser`, tests run against the committed runtime +(`hex"..."`, hardcoded in `test/PaymentForwarder.t.sol`), so no Huff compiler +is needed to verify behaviour — see the top-level README for the general +`forge test` / `forge snapshot` workflow. diff --git a/contracts/docs/SlotLockedDisperser.md b/contracts/docs/SlotLockedDisperser.md new file mode 100644 index 00000000..63138d10 --- /dev/null +++ b/contracts/docs/SlotLockedDisperser.md @@ -0,0 +1,102 @@ +# SlotLockedDisperser + +Multi-recipient generalization of [`PaymentForwarder`](PaymentForwarder.md): +pays N recipients in one call instead of one, for the case where a single +block's extra value needs splitting between several parties (a relay's own +fee, several contributing builders, the proposer) rather than forwarded to +one. Same slot-lock idea, same underlying threat (a payment exposed by a +reorg or a missed slot must not be replayable in a later slot), but `CALL` +in a loop instead of a single `SELFDESTRUCT`, since more than one recipient +needs paying. Not yet deployed. + +## Why not just N `PaymentForwarder` calls + +Sending N separate slot-locked payments doesn't get you atomicity: if +recipient 3 of 5 can't be paid, 1 and 2 already were, and the tx that would +have reverted the whole batch doesn't exist. `SlotLockedDisperser` pays all +recipients and refunds the caller's leftover in one call, atomically — all +of it happens or none of it does. + +## Calldata + +``` +Offset 0..3: uint32 timestamp (must equal block.timestamp) +Offset 4..23: recipient 1 (20 bytes) +Offset 24..55: amount 1 (32 bytes) +Offset 56..75: recipient 2 (20 bytes) +Offset 76..107: amount 2 (32 bytes) +... repeating [20-byte address][32-byte amount] ... +``` + +No function selector, no length check on the trailing entry — same +calldata-is-caller-constructed assumption as `PaymentForwarder`. Any leftover +balance after all recipients are paid is refunded to `msg.sender`. + +## Guarantees + +- **Atomic.** Either every recipient is paid and the caller gets the exact + leftover refund, or the entire call reverts and the contract's balance is + untouched. +- **Block-locked**, same mechanism as `PaymentForwarder`: reverts with + `TimestampMismatch()` unless the first 4 bytes of calldata equal + `block.timestamp`. +- **Reentrancy-guarded.** Unlike `PaymentForwarder`, this contract pays via + `CALL` (a loop needs to keep going after paying each recipient, which + `SELFDESTRUCT` doesn't allow), so a malicious recipient's `receive()` could + otherwise reenter mid-loop — `CALL` forwards all remaining gas — and + redirect funds meant for other recipients or the refund. A storage-backed + lock blocks any nested call while one is in flight. Costs ~2.3k gas per + call (the lock is set then cleared within the same tx, so the EIP-3529 + refund cancels out most of the base `SSTORE` cost). +- **No access control**, same reasoning as `PaymentForwarder`'s "anyone may + pay": nothing checks `msg.sender`, recipients/amounts are fully + caller-controlled. Safe only because the contract is never expected to + hold a balance at rest. + +Revert reasons decode as custom errors, not a bare `revert(0,0)`: + +```solidity +error AlreadyEntered(); // reentrancy guard tripped +error TimestampMismatch(); // calldata timestamp != block.timestamp +error TransferFailed(address recipient); // named recipient's CALL failed, + // including the final refund-to-caller CALL +``` + +`TransferFailed` names whichever `CALL` failed first — useful for identifying +a misbehaving recipient after a failed pre-inclusion simulation without a +tracer. Survives most gas-griefing attempts too: EIP-150 only forwards +63/64ths of remaining gas to a `CALL`, so the outer contract keeps a 1/64th +sliver — usually enough to still name the culprit before reverting. + +## Known accepted trade-offs + +- **Gas griefing.** `CALL` forwards all remaining gas uncapped, so one + adversarial or merely gas-hungry recipient can force the whole batch to + revert, blocking payment to every other recipient in the same call. + Alternatives (capped gas, pull-based claims) trade away either legitimate + contract-wallet recipients or atomicity; accepted as-is since the caller + controls calldata construction and can react out-of-band. +- **Misaligned calldata** (trailing entry not an exact 20+32-byte multiple) + burns gas via an unbounded loop rather than failing fast, since `offset` + never exactly equals `calldatasize`. Not reachable if the caller + constructs calldata correctly. + +## Formal verification + +`test/SlotLockedDisperser.symbolic.t.sol` proves properties with +[Halmos](https://github.com/a16z/halmos) across the full input space rather +than sampling concrete cases: value conservation (exact accounting between +recipients and the caller's refund, for any address/amount/starting balance, +at recipient counts up to the real usage bound), the reentrancy lock +rejecting any nested call regardless of content, a timestamp mismatch never +mutating state, and conservation holding even against a recipient that +always tries to reenter with arbitrary calldata/value. See the test file's +comments for what's proven at which bound and why (mutually-distinct +*symbolic* addresses hit a solver wall around N=5-6; fixing concrete +addresses and leaving amounts symbolic scales the same property to N=25). + +## Development + +Needs [`huffc`](https://docs.huff.sh/get-started/installing/) — tests deploy +via `HuffDeployer`, which shells out to it (`ffi = true` in `foundry.toml`). +See the top-level README for the Halmos setup/invocation. diff --git a/contracts/foundry.lock b/contracts/foundry.lock new file mode 100644 index 00000000..5844519b --- /dev/null +++ b/contracts/foundry.lock @@ -0,0 +1,11 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + }, + "lib/foundry-huff": { + "rev": "7d1ce15ccf92bd68458c7e28e0ae847b64b4fc74" + } +} \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml index fe9603dd..2b535117 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -5,13 +5,23 @@ test = "test" script = "script" libs = ["lib"] solc_version = "0.8.30" -evm_version = "prague" +evm_version = "osaka" optimizer = true optimizer_runs = 1000000 # Strip metadata so the runtime bytecode is reproducible byte-for-byte and the # CREATE2 address is stable. bytecode_hash = "none" cbor_metadata = false +# SlotLockedDisperser.huff needs both: HuffDeployer (foundry-huff) shells out to +# huffc, and Halmos needs the AST in build artifacts (not included by default). +ffi = true +ast = true + +remappings = [ + "forge-std/=lib/forge-std/src/", + "foundry-huff/=lib/foundry-huff/src/", + "halmos-cheatcodes/=lib/halmos-cheatcodes/src/", +] [profile.default.fuzz] runs = 1024 diff --git a/contracts/lib/forge-std b/contracts/lib/forge-std new file mode 160000 index 00000000..bf647bd6 --- /dev/null +++ b/contracts/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf647bd6046f2f7da30d0c2bf435e5c76a780c1b diff --git a/contracts/lib/foundry-huff b/contracts/lib/foundry-huff new file mode 160000 index 00000000..7d1ce15c --- /dev/null +++ b/contracts/lib/foundry-huff @@ -0,0 +1 @@ +Subproject commit 7d1ce15ccf92bd68458c7e28e0ae847b64b4fc74 diff --git a/contracts/lib/halmos-cheatcodes b/contracts/lib/halmos-cheatcodes new file mode 160000 index 00000000..6da4e692 --- /dev/null +++ b/contracts/lib/halmos-cheatcodes @@ -0,0 +1 @@ +Subproject commit 6da4e692c357ba6d641a2e677a28298cac9f76ab diff --git a/contracts/src/SlotLockedDisperser.huff b/contracts/src/SlotLockedDisperser.huff new file mode 100644 index 00000000..557202d3 --- /dev/null +++ b/contracts/src/SlotLockedDisperser.huff @@ -0,0 +1,107 @@ +/// @title Slot-Locked Ether Disperser +/// +/// CALLDATA LAYOUT: +/// Offset 0..3: [uint32 timestamp] (4 bytes) +/// Offset 4..23: Recipient 1 (20 bytes) +/// Offset 24..55: Amount 1 (32 bytes) +/// Offset 56..75: Recipient 2 (20 bytes) +/// Offset 76..107: Amount 2 (32 bytes) +/// ... repeating [20-byte address][32-byte value] ... +/// +/// Revert reasons are encoded as standard Solidity custom errors so callers +/// can decode them with `cast` / an ABI instead of a bare revert(0,0): +/// error AlreadyEntered(); +/// error TimestampMismatch(); +/// error TransferFailed(address recipient); +/// +/// TransferFailed also covers the final refund-to-caller CALL, so a caller +/// that can't accept plain ETH (e.g. a contract with no payable fallback) +/// gets a clean revert instead of having its refund silently stranded here. + +#define constant LOCK_SLOT = FREE_STORAGE_POINTER() + +#define constant SEL_ALREADY_ENTERED = 0x78636683 +#define constant SEL_TIMESTAMP_MISMATCH = 0xf98e0b1d +#define constant SEL_TRANSFER_FAILED = 0x39f1c8d9 + +#define macro CONSTRUCTOR() = takes (0) returns (0) {} + +#define macro MAIN() = takes (0) returns (0) { + // ------------------------------------------------------------------------- + // 0. REENTRANCY GUARD + // ------------------------------------------------------------------------- + // CALL below forwards all remaining gas, so a malicious recipient's + // fallback can call back into this contract while the outer invocation + // is still mid-loop / about to refund. Block any nested entry. + [LOCK_SLOT] sload already_entered jumpi + push1 0x01 [LOCK_SLOT] sstore + + // ------------------------------------------------------------------------- + // 1. TIMESTAMP VALIDATION (From snippet #1) + // ------------------------------------------------------------------------- + push0 calldataload // [calldata_word] + dup1 0xe0 shr // [timestamp_arg, calldata_word] + timestamp xor // [timestamp_arg ^ block.timestamp, calldata_word] + timestamp_mismatch jumpi // Revert if timestamp != block.timestamp + + // ------------------------------------------------------------------------- + // 2. DISPERSAL LOOP (Offset starts at 4 bytes to skip timestamp) + // ------------------------------------------------------------------------- + push1 0x04 // [offset = 4] + loop: + dup1 calldatasize eq done jumpi + + // Read value (32 bytes) starting at offset + 20 + dup1 push1 0x14 add calldataload + + // Read recipient (20 bytes) starting at offset + dup2 calldataload push1 0x60 shr + + // Execute raw CALL + push0 push0 push0 push0 + swap4 swap1 swap5 swap1 + gas call + iszero transfer_failed jumpi + + // Advance offset by 52 bytes (20 + 32) + push1 0x34 add + loop jump + + already_entered: + [SEL_ALREADY_ENTERED] 0xe0 shl push0 mstore + push1 0x04 push0 revert + + timestamp_mismatch: + // stack: [calldata_word] -- unused, ignored by revert + [SEL_TIMESTAMP_MISMATCH] 0xe0 shl push0 mstore + push1 0x04 push0 revert + + transfer_failed: + // stack: [offset, calldata_word] -- recover the recipient that just failed + dup1 calldataload push1 0x60 shr // [recipient, offset, calldata_word] + [SEL_TRANSFER_FAILED] 0xe0 shl push0 mstore // mem[0:4) = selector + push1 0x04 mstore // mem[4:36) = recipient + push1 0x24 push0 revert // revert(0, 36) + + done: + // Refund remaining balance to msg.sender + selfbalance iszero exit jumpi + push0 push0 push0 push0 + selfbalance caller gas call + iszero refund_failed jumpi + + exit: + // Release the lock before returning control to the caller. + push0 [LOCK_SLOT] sstore + stop + + refund_failed: + // If the caller can't accept the refund (e.g. it's a contract with no + // payable receive/fallback), don't silently strand the balance in + // this contract -- revert the whole call atomically, same as any + // other failed payment. + caller + [SEL_TRANSFER_FAILED] 0xe0 shl push0 mstore + push1 0x04 mstore + push1 0x24 push0 revert +} diff --git a/contracts/test/SlotLockedDisperser.security.t.sol b/contracts/test/SlotLockedDisperser.security.t.sol new file mode 100644 index 00000000..e3afe8dc --- /dev/null +++ b/contracts/test/SlotLockedDisperser.security.t.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {HuffDeployer} from "foundry-huff/HuffDeployer.sol"; + +error AlreadyEntered(); +error TimestampMismatch(); +error TransferFailed(address recipient); + +contract ReentrantRecipient { + address public disperser; + uint256 public grabAmount; + bool public armed; + bytes public nestedRevertData; + + constructor(address _disperser) { + disperser = _disperser; + } + + function arm(uint256 _grabAmount) external { + armed = true; + grabAmount = _grabAmount; + } + + receive() external payable { + if (armed) { + armed = false; + bytes memory data = abi.encodePacked(bytes4(uint32(block.timestamp)), address(this), grabAmount); + (bool nestedOk, bytes memory retData) = disperser.call(data); + if (!nestedOk) nestedRevertData = retData; + } + } +} + +contract RevertingRecipient { + receive() external payable { + revert("nope"); + } +} + +contract GasGuzzler { + receive() external payable { + uint256 i; + while (true) { + i++; + } + } +} + +/// A caller with no payable receive/fallback -- e.g. any contract that never +/// expects to be sent plain ETH. +contract NonPayableCaller { + function disperse(address disperser, bytes memory data) external returns (bool ok, bytes memory retData) { + (ok, retData) = disperser.call(data); + } +} + +contract SlotLockedDisperserSecurityTest is Test { + address disperser; + + function setUp() public { + disperser = HuffDeployer.config().with_evm_version("osaka").deploy("SlotLockedDisperser"); + } + + /// A malicious recipient reenters mid-CALL (which forwards all remaining gas) + /// and asks the disperser to pay it again, out of the same live balance. + function test_Reentrancy_StealsRefundMeantForOriginalCaller() public { + vm.warp(1_000_000); + vm.deal(disperser, 10 ether); + + ReentrantRecipient attacker = new ReentrantRecipient(disperser); + attacker.arm(5 ether); + + address payable legitCaller = payable(address(0xCAFE)); + uint256 attackerAllocation = 1 ether; + + bytes memory data = + abi.encodePacked(bytes4(uint32(block.timestamp)), address(attacker), attackerAllocation); + + vm.prank(legitCaller); + (bool ok,) = disperser.call(data); + + assertTrue(ok, "outer call should still succeed for a non-reentrant recipient's own payment"); + assertEq(address(attacker).balance, attackerAllocation, "reentrancy guard should cap attacker at their own allocation"); + assertEq(legitCaller.balance, 10 ether - attackerAllocation, "legit caller should still get the full leftover refund"); + assertEq(disperser.balance, 0); + assertEq( + bytes4(attacker.nestedRevertData()), + AlreadyEntered.selector, + "the blocked nested call should decode as AlreadyEntered()" + ); + } + + /// OPEN DESIGN QUESTION, not fixed here: nothing in the contract checks msg.sender + /// or restricts who the recipients/amounts can be. Anyone who can land a tx in the + /// matching-timestamp block can call this directly and route the entire live balance + /// to themselves. This is only safe if the contract is guaranteed to hold zero balance + /// except atomically within a single funding+dispersal bundle. If it can ever hold a + /// resting balance across blocks, this drains it. + function test_AnyoneCanDrainRestingBalance_NoAccessControl() public { + vm.warp(1_000_000); + vm.deal(disperser, 10 ether); + + address payable randomStranger = payable(address(0xD00D)); + bytes memory data = abi.encodePacked(bytes4(uint32(block.timestamp)), randomStranger, uint256(10 ether)); + + vm.prank(randomStranger); + (bool ok,) = disperser.call(data); + + assertTrue(ok, "documents current behavior: unauthenticated drain succeeds"); + assertEq(randomStranger.balance, 10 ether); + } + + /// Requesting more than the live balance for one recipient must not partially + /// pay earlier recipients and then silently stop — the whole tx should revert. + function test_InsufficientBalanceMidLoop_RevertsEverything() public { + vm.warp(1_000_000); + vm.deal(disperser, 5 ether); + + address r1 = address(0xBEEF1); + address r2 = address(0xBEEF2); + + bytes memory data = abi.encodePacked( + bytes4(uint32(block.timestamp)), + r1, + uint256(3 ether), + r2, + uint256(3 ether) // 3 + 3 > 5 ether available + ); + + (bool ok, bytes memory retData) = disperser.call(data); + + assertFalse(ok, "should revert rather than partially disperse"); + assertEq(r1.balance, 0, "first recipient must not keep a partial payment from a reverted tx"); + assertEq(disperser.balance, 5 ether, "balance must be untouched after revert"); + assertEq(bytes4(retData), TransferFailed.selector, "revert reason should decode as TransferFailed(address)"); + (address failedRecipient) = abi.decode(_stripSelector(retData), (address)); + assertEq(failedRecipient, r2, "revert data should name r2 as the recipient whose transfer failed"); + } + + function _stripSelector(bytes memory data) internal pure returns (bytes memory) { + bytes memory out = new bytes(data.length - 4); + for (uint256 i = 0; i < out.length; i++) { + out[i] = data[i + 4]; + } + return out; + } + + /// One recipient reverting on receipt must fail the whole batch atomically, + /// not silently skip that recipient and continue. + function test_RevertingRecipient_FailsWholeBatchAtomically() public { + vm.warp(1_000_000); + vm.deal(disperser, 5 ether); + + RevertingRecipient bad = new RevertingRecipient(); + address good = address(0xBEEF); + + bytes memory data = abi.encodePacked( + bytes4(uint32(block.timestamp)), good, uint256(1 ether), address(bad), uint256(1 ether) + ); + + (bool ok, bytes memory retData) = disperser.call(data); + + assertFalse(ok); + assertEq(good.balance, 0, "the legitimate recipient must not be paid out of an atomically-reverted tx"); + assertEq(disperser.balance, 5 ether); + assertEq(bytes4(retData), TransferFailed.selector); + (address failedRecipient) = abi.decode(_stripSelector(retData), (address)); + assertEq(failedRecipient, address(bad), "revert data should name the reverting recipient"); + } + + /// Known trade-off of atomic push-based dispersal, not fixed here: CALL forwards + /// all remaining gas with no cap, so a single adversarial or merely gas-hungry + /// recipient can burn the entire tx's gas and force revert_fail, blocking payment + /// to every other (well-behaved) recipient in the same batch. + function test_OneGasGuzzlingRecipient_BlocksPaymentToEveryoneElse() public { + vm.warp(1_000_000); + vm.deal(disperser, 5 ether); + + GasGuzzler guzzler = new GasGuzzler(); + address goodRecipient = address(0xBEEF); + + bytes memory data = abi.encodePacked( + bytes4(uint32(block.timestamp)), + goodRecipient, + uint256(1 ether), + address(guzzler), + uint256(1 ether) + ); + + (bool ok, bytes memory retData) = disperser.call{gas: 5_000_000}(data); + + assertFalse(ok, "documents current behavior: the guzzler forces the whole batch to revert"); + assertEq(goodRecipient.balance, 0, "well-behaved recipient gets nothing because of one bad actor"); + + // The guzzler's own CALL frame burns to zero, but EIP-150 only forwards 63/64ths + // of the available gas, so the outer contract retains a 1/64th sliver -- easily + // enough to still run `transfer_failed` and name the culprit before reverting. + assertEq(bytes4(retData), TransferFailed.selector, "the diagnostic should survive a gas-griefing recipient too"); + (address failedRecipient) = abi.decode(_stripSelector(retData), (address)); + assertEq(failedRecipient, address(guzzler), "revert data should name the gas guzzler as the culprit"); + } + + /// Sanity check: calldata whose trailing entry is short (not an exact multiple of + /// 52 bytes past the 4-byte timestamp) never satisfies `offset == calldatasize`, + /// so the loop keeps reading past the end (calldataload zero-pads) and advancing + /// until it runs out of gas, rather than reverting cleanly up front. + function test_MisalignedCalldata_BurnsGasInsteadOfCleanRevert() public { + vm.warp(1_000_000); + vm.deal(disperser, 1 ether); + + bytes memory data = abi.encodePacked( + bytes4(uint32(block.timestamp)), + address(0xBEEF), + uint256(1 ether), + uint8(0xFF) // one stray trailing byte + ); + + (bool ok,) = disperser.call{gas: 2_000_000}(data); + + assertFalse(ok, "malformed calldata should not succeed, but note it burns gas rather than failing fast"); + } + + /// Regression test for a bug Halmos found that no hand-picked concrete test here + /// ever hit: the refund-to-caller CALL's success was discarded (`pop`), so a caller + /// that can't accept plain ETH used to get a silent success with the leftover + /// balance permanently stranded in the contract instead of a revert. + function test_NonPayableCaller_RefundFailureRevertsInsteadOfStrandingFunds() public { + vm.warp(1_000_000); + vm.deal(disperser, 5 ether); + + NonPayableCaller caller = new NonPayableCaller(); + bytes memory data = abi.encodePacked(bytes4(uint32(block.timestamp))); + + (bool ok, bytes memory retData) = caller.disperse(disperser, data); + + assertFalse(ok, "a refund that can't be delivered must revert the whole call"); + assertEq(disperser.balance, 5 ether, "balance must stay put, not get stranded"); + assertEq(bytes4(retData), TransferFailed.selector); + (address failedRecipient) = abi.decode(_stripSelector(retData), (address)); + assertEq(failedRecipient, address(caller), "revert data should name the caller whose refund failed"); + } +} diff --git a/contracts/test/SlotLockedDisperser.symbolic.t.sol b/contracts/test/SlotLockedDisperser.symbolic.t.sol new file mode 100644 index 00000000..3cd183e6 --- /dev/null +++ b/contracts/test/SlotLockedDisperser.symbolic.t.sol @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {SymTest} from "halmos-cheatcodes/SymTest.sol"; +import {HuffDeployer} from "foundry-huff/HuffDeployer.sol"; + +error AlreadyEntered(); +error TimestampMismatch(); +error TransferFailed(address recipient); + +/// A recipient that always attempts to reenter the disperser on receiving ETH, +/// with fully arbitrary (symbolic) calldata and value. Used to check that +/// conservation holds even against an adversarial recipient, not just an inert +/// address -- see check_AtomicityAndConservation_AdversarialRecipientCannotExploitReentrancy. +contract MaliciousRecipient is SymTest { + address public disperser; + + constructor(address _disperser) { + disperser = _disperser; + } + + receive() external payable { + bytes memory reentrantData = svm.createBytes(56, "reentrantCalldata"); + uint256 reentrantValue = svm.createUint256("reentrantValue"); + disperser.call{value: reentrantValue}(reentrantData); + } +} + +/// Symbolic (Halmos) properties for SlotLockedDisperser. Each `check_*` function +/// is proven, not sampled: Halmos explores every input satisfying the `vm.assume` +/// constraints via an SMT solver, rather than running a fixed set of concrete cases. +contract SlotLockedDisperserSymbolicTest is SymTest, Test { + address disperser; + + function setUp() public { + disperser = HuffDeployer.config().with_evm_version("osaka").deploy("SlotLockedDisperser"); + } + + // vm.toString isn't in Halmos's supported cheatcode set; build decimal strings + // (0-99) by hand just to get unique symbolic-variable names per index. + function _idxStr(uint256 i) internal pure returns (string memory) { + if (i < 10) { + return string(abi.encodePacked(bytes1(uint8(48 + i)))); + } + return string(abi.encodePacked(bytes1(uint8(48 + i / 10)), bytes1(uint8(48 + i % 10)))); + } + + function _symbolicAddresses(uint256 n, string memory tag) internal returns (address[] memory out) { + out = new address[](n); + for (uint256 i = 0; i < n; i++) { + out[i] = svm.createAddress(string.concat(tag, "_r", _idxStr(i))); + } + } + + function _symbolicAmounts(uint256 n, string memory tag) internal returns (uint256[] memory out) { + out = new uint256[](n); + for (uint256 i = 0; i < n; i++) { + out[i] = svm.createUint256(string.concat(tag, "_a", _idxStr(i))); + } + } + + /// Shared core of the atomicity/conservation property, for N recipients built + /// from arbitrary (symbolic) addresses and amounts: either the call reverts and + /// every balance is exactly unchanged, or it succeeds, each recipient gets + /// exactly its own amount, and the caller gets exactly the leftover. + function _assertAtomicityAndConservation(address[] memory recipients, uint256[] memory amounts, uint256 startBalance) + internal + { + uint256 n = recipients.length; + for (uint256 i = 0; i < n; i++) { + vm.assume(recipients[i] != address(this)); + vm.assume(recipients[i] != disperser); + for (uint256 j = i + 1; j < n; j++) { + vm.assume(recipients[i] != recipients[j]); + } + } + vm.deal(disperser, startBalance); + + uint256[] memory before = new uint256[](n); + for (uint256 i = 0; i < n; i++) { + before[i] = recipients[i].balance; + } + uint256 callerBefore = address(this).balance; + + bytes memory data = abi.encodePacked(bytes4(uint32(block.timestamp))); + for (uint256 i = 0; i < n; i++) { + data = abi.encodePacked(data, recipients[i], amounts[i]); + } + + (bool ok,) = disperser.call(data); + + if (ok) { + uint256 total; + for (uint256 i = 0; i < n; i++) { + total += amounts[i]; + assertEq(recipients[i].balance, before[i] + amounts[i], "recipient must receive exactly its amount"); + } + assertEq(disperser.balance, 0, "contract must be fully drained on success"); + assertEq( + address(this).balance, + callerBefore + (startBalance - total), + "caller must receive the exact leftover" + ); + } else { + for (uint256 i = 0; i < n; i++) { + assertEq(recipients[i].balance, before[i], "failed call must not pay any recipient"); + } + assertEq(disperser.balance, startBalance, "failed call must leave contract balance untouched"); + assertEq(address(this).balance, callerBefore, "failed call must not refund the caller anything"); + } + } + + /// Reentrancy lock: once the lock slot (storage slot 0) is set, every call + /// must be rejected before any calldata is even read, regardless of value + /// attached or calldata contents -- and a rejected call must move no value + /// at all. + function check_AlreadyEnteredBlocksAnyCallRegardlessOfCalldata(uint256 attachedValue) public { + vm.store(disperser, bytes32(0), bytes32(uint256(1))); + vm.deal(address(this), attachedValue); + + bytes memory data = svm.createBytes(32, "calldata"); + + uint256 balBefore = disperser.balance; + uint256 callerBalBefore = address(this).balance; + + (bool ok, bytes memory ret) = disperser.call{value: attachedValue}(data); + + assertFalse(ok, "a locked contract must reject any call"); + assertEq(disperser.balance, balBefore, "value must not move onto a rejected call"); + assertEq(address(this).balance, callerBalBefore, "caller must keep its value on a rejected call"); + assertEq(bytes4(ret), AlreadyEntered.selector, "must decode as AlreadyEntered()"); + } + + /// A timestamp that doesn't match block.timestamp must revert before touching + /// any balance, no matter what recipient/amount/starting-balance accompany it. + function check_TimestampMismatchNeverMutatesState(address recipient, uint256 amount, uint256 startBalance) + public + { + vm.warp(1_700_000_000); + uint32 tsArg = uint32(svm.createUint(32, "tsArg")); + vm.assume(uint256(tsArg) != block.timestamp); + vm.deal(disperser, startBalance); + + uint256 recipientBefore = recipient.balance; + + bytes memory data = abi.encodePacked(bytes4(tsArg), recipient, amount); + (bool ok, bytes memory ret) = disperser.call(data); + + assertFalse(ok, "mismatched timestamp must revert"); + assertEq(disperser.balance, startBalance, "balance must be untouched"); + assertEq(recipient.balance, recipientBefore, "recipient must not be paid"); + assertEq(bytes4(ret), TimestampMismatch.selector, "must decode as TimestampMismatch()"); + } + + /// One recipient: either the call reverts and every balance is exactly + /// unchanged, or it succeeds and value is conserved exactly -- the recipient + /// gets precisely `amount`, the caller gets precisely the leftover, and the + /// contract ends at zero. No third outcome is possible. + function check_AtomicityAndConservation_OneRecipient( + address recipient, + uint256 amount, + uint256 startBalance + ) public { + vm.warp(1_700_000_000); + vm.assume(recipient != address(this)); + vm.assume(recipient != disperser); + vm.deal(disperser, startBalance); + + uint256 recipientBefore = recipient.balance; + uint256 callerBefore = address(this).balance; + + bytes memory data = abi.encodePacked(bytes4(uint32(block.timestamp)), recipient, amount); + (bool ok,) = disperser.call(data); + + if (ok) { + assertEq(disperser.balance, 0, "contract must be fully drained on success"); + assertEq(recipient.balance, recipientBefore + amount, "recipient must receive exactly amount"); + assertEq( + address(this).balance, + callerBefore + (startBalance - amount), + "caller must receive the exact leftover" + ); + } else { + assertEq(disperser.balance, startBalance, "failed call must leave contract balance untouched"); + assertEq(recipient.balance, recipientBefore, "failed call must not pay the recipient anything"); + assertEq(address(this).balance, callerBefore, "failed call must not refund the caller anything"); + } + } + + /// Same property, two recipients: exercises the loop running more than once. + function check_AtomicityAndConservation_TwoRecipients( + address r1, + uint256 a1, + address r2, + uint256 a2, + uint256 startBalance + ) public { + vm.warp(1_700_000_000); + vm.assume(r1 != address(this) && r2 != address(this)); + vm.assume(r1 != disperser && r2 != disperser); + vm.assume(r1 != r2); + vm.deal(disperser, startBalance); + + uint256 r1Before = r1.balance; + uint256 r2Before = r2.balance; + uint256 callerBefore = address(this).balance; + + bytes memory data = + abi.encodePacked(bytes4(uint32(block.timestamp)), r1, a1, r2, a2); + (bool ok,) = disperser.call(data); + + if (ok) { + assertEq(disperser.balance, 0, "contract must be fully drained on success"); + assertEq(r1.balance, r1Before + a1); + assertEq(r2.balance, r2Before + a2); + assertEq( + address(this).balance, + callerBefore + (startBalance - a1 - a2), + "caller must receive the exact leftover" + ); + } else { + assertEq(disperser.balance, startBalance, "failed call must leave contract balance untouched"); + assertEq(r1.balance, r1Before); + assertEq(r2.balance, r2Before); + assertEq(address(this).balance, callerBefore); + } + } + + /// Same conservation property, generalized to N=3 recipients via the shared helper. + function check_AtomicityAndConservation_ThreeRecipients(uint256 startBalance) public { + vm.warp(1_700_000_000); + address[] memory recipients = _symbolicAddresses(3, "three"); + uint256[] memory amounts = _symbolicAmounts(3, "three"); + _assertAtomicityAndConservation(recipients, amounts, startBalance); + } + + /// Same property again at N=4: fully arbitrary, mutually-distinct addresses. + /// This is the practical ceiling for that formulation -- N=5 already takes ~40s + /// and N=6 exceeds a 60s solver timeout, an exponential wall driven by reasoning + /// about N mutually-distinct *symbolic* call targets, not by the loop itself (see + /// the fixed-address versions below, which scale to 25 in ~3s once addresses are + /// concrete). Real usage never exceeds ~20, so this ceiling isn't a practical gap: + /// it just means "any address" and "any N up to real usage" are proven separately + /// rather than in one combined property. + function check_AtomicityAndConservation_FourRecipients(uint256 startBalance) public { + vm.warp(1_700_000_000); + address[] memory recipients = _symbolicAddresses(4, "four"); + uint256[] memory amounts = _symbolicAmounts(4, "four"); + _assertAtomicityAndConservation(recipients, amounts, startBalance); + } + + // The loop's logic never branches on *which* address it is -- only on whether + // amounts/balances line up -- so fixing concrete (but still distinct) addresses + // and leaving only the amounts symbolic exercises the same loop/arithmetic path + // at real-world N without paying for N mutually-distinct symbolic call targets. + function _concreteAddresses(uint256 n, uint256 seed) internal pure returns (address[] memory out) { + out = new address[](n); + for (uint256 i = 0; i < n; i++) { + out[i] = address(uint160(0xC0FFEE0000 + seed * 1000 + i)); + } + } + + /// Matches Owen's stated typical real usage: relay + proposer + up to ~8 builders. + /// Fixed distinct addresses, arbitrary amounts -- see note above on why. + function check_AtomicityAndConservation_EightRecipients_FixedAddresses(uint256 startBalance) public { + vm.warp(1_700_000_000); + address[] memory recipients = _concreteAddresses(8, 8); + uint256[] memory amounts = _symbolicAmounts(8, "eightC"); + _assertAtomicityAndConservation(recipients, amounts, startBalance); + } + + /// Matches the stated extreme upper bound (20), with headroom to 25. + function check_AtomicityAndConservation_TwentyFiveRecipients_FixedAddresses(uint256 startBalance) public { + vm.warp(1_700_000_000); + address[] memory recipients = _concreteAddresses(25, 25); + uint256[] memory amounts = _symbolicAmounts(25, "twentyFiveC"); + _assertAtomicityAndConservation(recipients, amounts, startBalance); + } + + /// The property that actually answers "what should not be possible": conservation + /// must hold even when the recipient is an adversarial contract that always tries + /// to reenter on receiving payment, with fully arbitrary calldata and value. This + /// doesn't enumerate attacker strategies -- Halmos can't search over the space of + /// possible programs -- it empirically checks the one channel an attacker actually + /// has (calling back into this exact contract), which the reentrancy-guard proof + /// above already showed is rejected regardless of content. A reverted call has no + /// state effect as a basic EVM guarantee, so those two facts together mean no + /// recipient contract, however it's written, can use reentrancy to break this. + function check_AtomicityAndConservation_AdversarialRecipientCannotExploitReentrancy( + uint256 amount, + uint256 startBalance + ) public { + vm.warp(1_700_000_000); + MaliciousRecipient recipient = new MaliciousRecipient(disperser); + vm.deal(disperser, startBalance); + + uint256 recipientBefore = address(recipient).balance; + uint256 callerBefore = address(this).balance; + + bytes memory data = abi.encodePacked(bytes4(uint32(block.timestamp)), address(recipient), amount); + (bool ok,) = disperser.call(data); + + if (ok) { + assertEq(disperser.balance, 0, "contract must be fully drained on success"); + assertEq( + address(recipient).balance, + recipientBefore + amount, + "recipient must receive exactly amount, even though it tried to reenter" + ); + assertEq( + address(this).balance, + callerBefore + (startBalance - amount), + "caller must receive the exact leftover" + ); + } else { + assertEq(disperser.balance, startBalance, "failed call must leave contract balance untouched"); + assertEq(address(recipient).balance, recipientBefore, "failed call must not pay the recipient anything"); + assertEq(address(this).balance, callerBefore, "failed call must not refund the caller anything"); + } + } +} diff --git a/contracts/test/SlotLockedDisperser.t.sol b/contracts/test/SlotLockedDisperser.t.sol new file mode 100644 index 00000000..9dae84a9 --- /dev/null +++ b/contracts/test/SlotLockedDisperser.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test, console2} from "forge-std/Test.sol"; +import {HuffDeployer} from "foundry-huff/HuffDeployer.sol"; + +error AlreadyEntered(); +error TimestampMismatch(); +error TransferFailed(address recipient); + +contract SlotLockedDisperserTest is Test { + address disperser; + + function setUp() public { + disperser = HuffDeployer.config().with_evm_version("osaka").deploy("SlotLockedDisperser"); + } + + function _tsCalldata(uint256 ts) internal pure returns (bytes memory) { + return abi.encodePacked(bytes4(uint32(ts))); + } + + function test_RevertsOnTimestampMismatch() public { + vm.warp(1_000_000); + bytes memory data = _tsCalldata(block.timestamp - 1); + (bool ok, bytes memory retData) = disperser.call(data); + assertFalse(ok, "should revert on mismatched timestamp"); + assertEq(bytes4(retData), TimestampMismatch.selector, "revert reason should decode as TimestampMismatch()"); + } + + function test_NoRecipients_RefundsCallerFullBalance() public { + vm.warp(1_000_000); + vm.deal(disperser, 5 ether); + + address payable caller = payable(address(0xCAFE)); + bytes memory data = _tsCalldata(block.timestamp); + + vm.prank(caller); + (bool ok,) = disperser.call(data); + + assertTrue(ok, "call should succeed"); + assertEq(disperser.balance, 0, "contract should be drained"); + assertEq(caller.balance, 5 ether, "caller should receive full refund"); + } + + function test_Dispersal_SingleRecipient() public { + vm.warp(1_000_000); + vm.deal(disperser, 10 ether); + + address recipient = address(0xBEEF); + uint256 amount = 3 ether; + address payable caller = payable(address(0xCAFE)); + + bytes memory data = abi.encodePacked( + bytes4(uint32(block.timestamp)), + recipient, + amount + ); + + vm.prank(caller); + (bool ok, bytes memory retData) = disperser.call(data); + + console2.log("call success:", ok); + console2.log("recipient balance:", recipient.balance); + console2.log("caller balance:", caller.balance); + console2.log("contract balance:", disperser.balance); + if (!ok) { + console2.log("revert data length:", retData.length); + } + + assertTrue(ok, "dispersal call should succeed"); + assertEq(recipient.balance, amount, "recipient should receive amount"); + assertEq(caller.balance, 10 ether - amount, "caller should receive leftover refund"); + assertEq(disperser.balance, 0, "contract should be fully drained"); + } + + function test_Dispersal_TwoRecipients() public { + vm.warp(2_000_000); + vm.deal(disperser, 10 ether); + + address r1 = address(0xBEEF1); + address r2 = address(0xBEEF2); + uint256 a1 = 1 ether; + uint256 a2 = 2 ether; + address payable caller = payable(address(0xCAFE)); + + bytes memory data = abi.encodePacked( + bytes4(uint32(block.timestamp)), + r1, a1, + r2, a2 + ); + + vm.prank(caller); + (bool ok,) = disperser.call(data); + + assertTrue(ok, "dispersal call should succeed"); + assertEq(r1.balance, a1); + assertEq(r2.balance, a2); + assertEq(caller.balance, 10 ether - a1 - a2); + } + + function test_Dispersal_RealisticAddressCanRunOutOfGas() public { + vm.warp(3_000_000); + vm.deal(disperser, 10 ether); + + address recipient = 0x1234567890AbcdEF1234567890aBcdef12345678; + uint256 amount = 1 ether; + address payable caller = payable(address(0xCAFE)); + + bytes memory data = abi.encodePacked( + bytes4(uint32(block.timestamp)), + recipient, + amount + ); + + vm.prank(caller); + (bool ok, ) = disperser.call{gas: 30_000_000}(data); + console2.log("realistic-address call success:", ok); + console2.log("recipient balance:", recipient.balance); + console2.log("caller balance:", caller.balance); + } +}