Partial Refund Accounting — bounty_escrow EscrowData
Issue #1294 — Balance invariant verification for
EscrowStatus::PartiallyRefunded
Overview
The bounty_escrow contract supports partial refunds: an admin can approve returning a
portion of locked funds to the depositor before the full escrow is settled. After each
partial refund the escrow transitions to EscrowStatus::PartiallyRefunded and the
remaining claimable balance is decremented exactly.
This document describes the accounting invariant, the state machine, and the test coverage added in Issue #1294.
Core Invariant
At every point in the escrow lifecycle the following must hold:
escrow.amount - sum(refund_history[*].amount) == escrow.remaining_amount
Where:
| Field | Description |
|---|---|
escrow.amount | Total amount originally locked (immutable after lock_funds) |
refund_history | Append-only log of every refund executed on this escrow |
escrow.remaining_amount | Funds still available for release or further refund |
State Machine
Locked ──(partial refund, amount < remaining)──► PartiallyRefunded
Locked ──(full refund or RefundMode::Full)──────► Refunded
PartiallyRefunded ──(partial refund, amount < remaining)──► PartiallyRefunded
PartiallyRefunded ──(refund drains remainder)───────────────► Refunded
Key rules enforced by the contract:
approve_refund(amount)requiresamount > 0 && amount <= remaining_amount.refund()sets status toRefundedwhenmode == Fulloramount >= remaining_amount.refund()sets status toPartiallyRefundedotherwise.remaining_amountis decremented by exactlyrefund_amount(integer subtraction, no rounding).- Each
refund()call appends exactly one entry torefund_history.
Security Assumptions
- No underflow:
remaining_amountis checked againstrefund_amountbefore subtraction;checked_subis used to catch any arithmetic overflow at the Rust level. - No double-spend: The reentrancy guard prevents concurrent execution of any protected function. State is updated (CEI pattern) before the external token transfer.
- Approval consumed: The
RefundApprovalstorage entry is deleted after a successfulrefund()call, preventing replay of the same approval. - Status guard:
approve_refundandrefundboth reject escrows that are not inLockedorPartiallyRefundedstate, preventing refunds on already-settled escrows. - Isolation: Each escrow's
remaining_amountandrefund_historyare stored under a per-bounty_idkey; partial refunds on one escrow cannot affect another.
Test Coverage (Issue #1294)
All tests live in
contracts/bounty_escrow/contracts/escrow/src/test_boundary_edge_cases.rs.
| Test | What it verifies |
|---|---|
test_sequential_partial_refunds_balance_invariant | Invariant holds after each of 3 sequential partial refunds |
test_partial_refund_of_full_amount_transitions_to_refunded | Partial mode with amount == remaining → Refunded |
test_zero_amount_partial_refund_is_rejected | approve_refund(0) is rejected; state unchanged |
test_two_sequential_partial_refunds_invariant | Two refunds from PartiallyRefunded state; invariant holds |
test_partial_refunds_then_full_drain_transitions_to_refunded | Multiple partials then final drain → Refunded |
test_partial_refund_isolation_between_escrows | Refunding escrow A does not affect escrow B |
test_minimum_unit_partial_refund_invariant | 1-stroop refund accepted; invariant holds |
test_partial_refund_exceeding_remaining_is_rejected | approve_refund(remaining + 1) rejected; state unchanged |
test_full_mode_refund_always_transitions_to_refunded | RefundMode::Full always yields Refunded |
test_refund_history_grows_per_partial_refund | refund_history.len() increments by 1 per call |
Running the tests
cargo test -p escrow -- test_boundary_edge_cases
Example: Sequential Partial Refunds
// Lock 1000 units
client.lock_funds(&depositor, &bounty_id, &1000, &deadline);
// remaining_amount = 1000, status = Locked
// First partial refund: 300
client.approve_refund(&bounty_id, &300, &depositor, &RefundMode::Partial);
client.refund(&bounty_id);
// remaining_amount = 700, status = PartiallyRefunded
// refund_history = [{ amount: 300, ... }]
// Second partial refund: 300
client.approve_refund(&bounty_id, &300, &depositor, &RefundMode::Partial);
client.refund(&bounty_id);
// remaining_amount = 400, status = PartiallyRefunded
// refund_history = [{ amount: 300 }, { amount: 300 }]
// Final refund drains remainder: 400
client.approve_refund(&bounty_id, &400, &depositor, &RefundMode::Partial);
client.refund(&bounty_id);
// remaining_amount = 0, status = Refunded
// refund_history = [{ amount: 300 }, { amount: 300 }, { amount: 400 }]
// Invariant: 1000 - (300 + 300 + 400) == 0 ✓
Related Files
contracts/bounty_escrow/contracts/escrow/src/lib.rs—approve_refund,refund,EscrowStatus,RefundModecontracts/bounty_escrow/contracts/escrow/src/test_boundary_edge_cases.rs— testscontracts/bounty_escrow/contracts/escrow/INVARIANTS_ESCROW.md— broader invariant catalog