Repository navigation
payment-router: guard value paths against double-release and reentrancy - #86
Merged
joelpeace48-cell merged 1 commit intoAug 24, 2026
Conversation
The settlement paths were relying on ordering alone to prevent a second payout against the same escrow, and one path did not have the ordering right. This closes both halves. Checks-effects-interactions: - open_escrow now writes the escrow row before calling the token, rather than transferring first and recording afterwards. - claim and close_escrow already committed before transferring; the reasoning is now written down at each site so it survives future edits. - release_partial calls no token at all - it makes funds claimable and the payee pulls separately, which keeps an untrusted destination off the settlement path entirely. Idempotence: - A second close_escrow returns AlreadySettled and refunds nothing, where previously it reported the generic EscrowClosed. - A claim against a settled escrow with nothing left to pull returns AlreadySettled, kept distinct from NothingToClaim - the latter is worth retrying once more has been released, the former never is. Reentrancy: - A guard over the value-moving entry points takes a marker in temporary storage for the duration of the call; a second entry gets ReentrantCall. Temporary is the right storage class: the marker only means anything inside the transaction that set it, so the host drops it at end of ledger and no rent is paid on it. It is kept out of DataKey because it is not escrow state. The guard is not redundant with the ordering. open_escrow must write the escrow row before the deposit arrives, so for the duration of the token call the ledger holds a claim on funds that have not landed - a hostile token calling back into close_escrow on that row would be refunded its total out of other escrows' deposits. The guard is what closes that. Tests, in a new invariants module so the behavioural suite stays as it is: - Conservation asserted directly - the router's balance always equals the sum of outstanding escrow claims - across a full lifecycle, across a dispute, and with several escrows sharing the contract balance. - claimed <= released and released + disputed <= total checked on every step of those walks. - Double-close and double-claim assert both the error and that no value moved, which is the part that actually matters. - Two adversarial cases run against a real token contract that re-enters the router from inside transfer: one during a claim, one during a deposit while a second escrow is open. Neither moves funds. docs/payment-router-value-safety.md writes down the invariants, the storage-class reasoning, and what a hostile token can and cannot do. Also gitignores test_snapshots/, which cargo test generates into each crate and which is not currently ignored - it is 30k lines of generated JSON that lands in the working tree on any test run. Closes FinesseStudioLab#58
4 tasks done
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #58
The settlement paths were relying on ordering alone to prevent a second payout against the same escrow, and one path did not have the ordering right. This closes both halves, and asserts the result rather than arguing for it.
The bug
open_escrowtransferred the deposit before recording the escrow:The payer chooses the token address, so
transferis untrusted code running before any state exists. Reordered so the escrow row is committed first.Checks-effects-interactions
claimclaimed += amounttransferoutclose_escrowclosed = truetransferopen_escrowtransferin (reordered)release_partialreleased += amountclaimandclose_escrowalready had this right; the reasoning is now written down at each site so it survives future edits.release_partialis the one worth noting — it makes funds claimable and calls no token, so an untrusted destination is never on the settlement path.Idempotence
close_escrowreturnsAlreadySettledand refunds nothing, where previously it reported the genericEscrowClosed.claimagainst a settled escrow with nothing left to pull returnsAlreadySettled, kept distinct fromNothingToClaim: the latter is worth retrying once more has been released, the former never is. Collapsing them would tell an integrator to retry forever.Reentrancy guard
A guard over the value-moving entry points takes a marker in temporary storage for the duration of the call; a second entry gets
ReentrantCall.Per the storage-TTL review bar: temporary is the right class because the marker only means anything inside the transaction that set it, so the host drops it at end of ledger and no rent is paid on it. It is kept out of
DataKeybecause it is not escrow state. On a panic the transaction reverts, which unwinds the marker — a failed call cannot leave the contract wedged.The guard is not redundant with the ordering. This is the part worth reviewing closely.
open_escrowmust write the escrow row before the deposit arrives, so for the duration of the token call the ledger holds a claim on funds that have not landed. A hostile token calling back intoclose_escrowon that row would be refunded itstotalout of other escrows' deposits. Ordering alone does not close that; the guard does. There is a test for exactly this.Tests
In a new
invariants.rsmodule, so the existing behavioural suite is untouched:claimed <= releasedandreleased + disputed <= totalchecked at every step of those walks.transfer: one during a claim, one during a deposit while a second escrow is open. Neither moves funds, and the reverted transaction takes the state write with it.docs/payment-router-value-safety.mdwrites down the invariants, the storage-class reasoning, and a table of what a hostile token can and cannot do.Acceptance criteria
One drive-by, easy to drop
cargo testgeneratestest_snapshots/into every crate — about 30k lines of JSON that is not currently gitignored and lands in the working tree on any test run. Added one.gitignoreline. Happy to pull it out into its own PR if you would rather keep this one purely about the security fix.Verification
cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace, andcargo build --release --target wasm32v1-noneall pass.This PR and #59 are conflict-free in either merge order. I merged them both ways locally against
main: zero conflicts each way, both orders produce an identical tree, and the merged result passes all four CI gates. Whichever of the two you merge first, the other needs no rebase.