This document describes the InvoiceEscrow.status state machine, valid transitions,
forbidden regressions, and interaction rules between withdraw vs settle paths.
| Value | Name | Meaning |
|---|---|---|
0 |
open |
Escrow is initialized; funding is active |
1 |
funded |
At least one investor reached or exceeded the funding target |
2 |
settled |
SME has finalized settlement after legal/financial review |
3 |
withdrawn |
SME has withdrawn liquidity (pull model, off-chain settlement) |
4 |
cancelled |
Admin cancelled the escrow before it was funded; investors may reclaim principal via refund() |
unfund(investor, amount) [investor]
(partial or full; status stays 0)
│
▼
┌─────────────┐
│ (init) │
│ status = 0 │◄────┐
│ open │ │
└──────┬──────┘─────┘
│
┌─────────────┼──────────────────────┐
│ │ │
│ fund(amount >= funding_target) │ cancel_funding() [admin]
▼ │ ▼
┌─────────────┐ │ ┌─────────────┐
│ funded │ │ │ cancelled │
│ status = 1 │ │ │ status = 4 │
└──────┬──────┘ │ └──────┬──────┘
│ │ │
┌──────┼──────┐ │ (more funding │ refund(investor) [investor]
│ │ │ │ if target not met) │ → returns InvestorContribution
▼ ▼ │ │ ▼
┌────┐ ┌────┐ │ │ (principal returned)
│ 2 │ │ 3 │ └──────┘
│set │ │wd │
└────┘ └────┘
(terminal) (terminal)
To ensure custody is real and on-chain token balances reconcile with funded_amount, the contract performs atomic token transfers during funding:
- Atomic Transfer: Every successful call to
fund(),fund_with_commitment(), orfund_batch()atomically pulls the specified token amount from the investor's balance to the escrow contract (env.current_contract_address()). - Balance-Delta Verification: The transfer utilizes
external_calls::transfer_funding_token_inbound_with_balance_checksto read pre/post balances of the investor and the escrow contract. It asserts that:- The investor's balance decreased by exactly
amount. - The contract's balance increased by exactly
amount. - Any mismatch or insufficient balance reverts the entire transaction, ensuring no double-credit or state mutation on failure.
- The investor's balance decreased by exactly
- Reconciliation Invariant: The contract's token balance always matches or exceeds
funded_amount(reclaimed usingrefund()or settled/withdrawn). This ensures that the terminal dust sweep mathbalance - sweep_amt >= funded_amount - distributed_principalremains sound and protected.
fund_batch(entries: Vec<(Address, i128)>) processes multiple investor contributions in a single call,
reducing transaction overhead for primary issuance workflows.
Semantics:
- Each entry
(investor_address, amount)is processed sequentially - Per-investor
require_auth()is called for each entry - All existing
fund()invariants (allowlist, caps, min contribution, overflow guards) are enforced per entry - One
EscrowFundedevent is emitted per entry - If any entry fails its invariants, the call returns an error without corrupting prior entries (Soroban's transaction atomicity ensures consistent state)
Capacity:
- Batch size must be
> 0and<= MAX_FUND_BATCH(50 entries) - Empty batch panics with
EscrowError::FundingBatchEmpty - Oversized batch panics with
EscrowError::FundingBatchTooLarge - Every investor address must be unique within the batch; a repeated address panics with
EscrowError::FundingBatchDuplicateInvestor(code 84). The entire batch is rejected atomically before any state mutation.
Funded-target snapshot:
- If any entry causes the escrow to transition to funded (status
0 → 1),FundingCloseSnapshotis recorded exactly once at the crossing entry - Remaining entries continue to be processed even after the transition
- The snapshot's
total_principalreflectsfunded_amountat the exact entry that crossed the threshold, not the final batch total
Example:
let entries = vec![
(investor_a, 30_000i128),
(investor_b, 55_000i128), // crosses funding_target = 80_000 → snapshot written here
(investor_c, 10_000i128), // processed post-transition; contribution recorded
];
let result = fund_batch(entries); // All three processed; status = 1Test coverage (see escrow/src/tests/funding.rs):
| Scenario | Test |
|---|---|
N-entry batch == N sequential fund calls (funded_amount, contributions, UniqueFunderCount) |
test_fund_batch_equivalence_funded_amount_contributions_and_unique_count |
| Equivalence holds when batch crosses target | test_fund_batch_equivalence_when_batch_crosses_target |
| Snapshot written once, immutable, crossing-entry total captured | test_fund_batch_mid_batch_transition_snapshot_written_exactly_once |
| First entry crosses target; snapshot immutable | test_fund_batch_first_entry_crosses_target_snapshot_immutable |
| Snapshot captures correct ledger timestamp/sequence | test_fund_batch_snapshot_captures_ledger_time |
| Entries after funded transition are processed | test_fund_batch_entries_after_transition_are_processed |
FundingBatchEmpty typed error |
test_fund_batch_empty_yields_typed_error |
FundingBatchTooLarge typed error |
test_fund_batch_too_large_yields_typed_error |
| Exactly MAX_FUND_BATCH (50) entries succeeds | test_fund_batch_exactly_max_batch_size_succeeds_and_counts_all_investors |
Zero-amount entry → FundingAmountNotPositive |
test_fund_batch_zero_amount_entry_yields_typed_error |
Below min-contribution floor → FundingBelowMinContribution |
test_fund_batch_below_min_contribution_floor_yields_typed_error |
| Per-investor cap enforced per entry | test_fund_batch_per_investor_cap_enforced_per_entry_typed_error |
| Same investor twice accumulates; cap still enforced | test_fund_batch_same_investor_accumulates_and_cap_enforced |
| Max unique investors cap enforced inside batch | test_fund_batch_unique_investor_cap_enforced_inside_batch |
| Legal hold blocks batch | test_fund_batch_blocked_by_legal_hold |
| Allowlist gate blocks non-allowlisted entry | test_fund_batch_blocked_by_allowlist_gate |
| All allowlisted entries succeed | test_fund_batch_succeeds_when_all_entries_allowlisted |
| Batch rejected when escrow already funded | test_fund_batch_rejected_after_escrow_already_funded |
| Unique count increments once per address | test_fund_batch_unique_count_incremented_once_per_investor |
| Sequential batches don't double-count existing investors | test_fund_batch_sequential_batches_unique_count_does_not_double_count |
| Over-funding single entry | test_fund_batch_overfunding_single_entry |
| Over-funding across two entries; snapshot correct | test_fund_batch_overfunding_across_two_entries_snapshot_correct |
Per-investor require_auth recorded for each entry |
test_fund_batch_investor_auth_recorded_for_each_entry |
| Event count == entry count | test_fund_batch_event_count_matches_entry_count |
Adjacent duplicate → FundingBatchDuplicateInvestor (code 84) |
test_fund_batch_rejects_adjacent_duplicate |
Non-adjacent duplicate → FundingBatchDuplicateInvestor (code 84) |
test_fund_batch_rejects_non_adjacent_duplicate |
| Single-element batch (no duplicates possible) succeeds | test_fund_batch_single_element_succeeds |
| All-unique batch succeeds | test_fund_batch_all_unique_succeeds |
| MAX_FUND_BATCH (50) unique entries succeed | test_fund_batch_max_unique_batch_succeeds |
| Duplicate batch leaves no partial state | test_fund_batch_duplicate_leaves_no_partial_state |
| From | To | Trigger | Auth required |
|---|---|---|---|
0 (open) |
1 (funded) |
fund(), fund_with_commitment(), or fund_batch() when funded_amount >= funding_target |
Investor auth (per-investor for batch) |
0 (open) |
4 (cancelled) |
cancel_funding() |
Admin auth; legal hold must be inactive |
0 (open) |
0 (open) |
unfund(investor, amount) |
Investor auth; legal hold must be inactive |
1 (funded) |
2 (settled) |
settle() |
SME auth; legal hold must be inactive; if maturity > 0, ledger timestamp must be >= maturity |
1 (funded) |
3 (withdrawn) |
withdraw() |
SME auth; legal hold must be inactive |
| From | To | Reason |
|---|---|---|
0 (open) |
1 (funded) |
Must reach funding target first |
0 (open) |
2 (settled) |
Escrow must be funded first |
0 (open) |
3 (withdrawn) |
Escrow must be funded first |
1 (funded) |
0 (open) |
Status never regresses |
1 (funded) |
4 (cancelled) |
cancel_funding only allowed in Open state |
2 (settled) |
any | Status never regresses from terminal |
3 (withdrawn) |
any | Status never regresses from terminal |
4 (cancelled) |
any | Status never regresses from terminal |
withdraw and settle are mutually exclusive terminal paths. Both require:
status == 1(funded)- No active legal hold
- SME authentication
Once one path is taken, the other is unreachable:
- After
withdraw()→ status is3;settle()panics - After
settle()→ status is2;withdraw()panics
When an escrow is cancelled before reaching its funding target, investors may recover their principal:
- Admin calls
cancel_funding()— transitionsstatus 0 → 4. Blocked by legal hold. Only status 0 (open) is cancellable; funded (1), settled (2), withdrawn (3), and already-cancelled (4) escrows reject withCancelFundingNotOpen(code 141). Seetest_cancel_funding_transition_matrix_and_refund_unlockinescrow/src/tests/integration.rsfor the full matrix. - Each investor calls
refund(investor)— transfers exactlyDataKey::InvestorContributionback to the investor viaexternal_calls::transfer_funding_token_with_balance_checks. InvestorContributionis zeroed after transfer (checks-effects-interactions pattern).DataKey::DistributedPrincipalis incremented by the refunded amount. This feeds thesweep_terminal_dustliability floor.DataKey::InvestorRefundedis set totrue—is_investor_refunded()returnstrue.- A second
refund()call panics with"no contribution to refund"(contribution is 0).
- Total refunded ≤
funded_amount(each investor can only reclaim their own contribution). - No double-refund: contribution is zeroed before the token transfer.
- Balance-delta checks enforced by
external_callswrapper (SEP-41 conservation). refund()is blocked in all states except4(cancelled).
| Event | When |
|---|---|
FundingCancelled |
cancel_funding() succeeds |
InvestorRefundedEvt |
refund() succeeds |
While an escrow is open, investors may reduce or fully exit their principal position without requiring admin cancellation:
- Investor calls
unfund(investor, amount)— decrementsDataKey::InvestorContributionandInvoiceEscrow::funded_amountbyamount. - If contribution reaches zero:
DataKey::InvestorContributionentry is zeroed,DataKey::UniqueFunderCountis decremented (floor: 0). - Status remains 0 (open) in all cases —
unfundnever transitions status. - Tokens are returned to the investor via
external_calls::transfer_funding_token_with_balance_checks(SEP-41 balance-delta invariants enforced). EscrowUnfundedis emitted with the investor, amount, remaining contribution, newfunded_amount, and ledger timestamp.
- Investor can only unfund their own contribution; no third-party unfunding.
unfundis blocked while a legal hold is active (UnfundLegalHoldActive, code 222).unfundis blocked in any state other than open (0) (UnfundEscrowNotOpen, code 220).amountmust be ≤DataKey::InvestorContribution[investor](OverWithdrawal, code 221).funded_amountnever goes negative (checked arithmetic).UniqueFunderCountnever goes negative (saturating_sub).
| Event | When |
|---|---|
EscrowUnfunded |
unfund() succeeds |
| Function | Role |
|---|---|
settle() |
SME |
withdraw() |
SME |
cancel_funding() |
Admin only |
set_legal_hold() |
Admin only |
update_maturity() |
Admin only |
update_funding_deadline() |
Admin only |
propose_admin() |
Admin only |
accept_admin() |
Pending admin only |
The SME role represents the off-chain settlement policy authority. The admin role handles on-chain configuration and compliance controls.
Legal hold blocks all risk-bearing operations regardless of status:
| Function | Blocked by legal hold |
|---|---|
cancel_funding() |
Yes |
claim_investor_payout() |
Yes |
fund() |
Yes |
settle() |
Yes |
sweep_terminal_dust() |
Yes |
unfund() |
Yes |
withdraw() |
Yes |
Once legal hold is cleared, normal state transitions resume.
When maturity > 0:
settle()requiresenv.ledger().timestamp() >= escrow.maturity- When
maturity == 0:settle()succeeds immediately (no time gate)
withdraw() does not check maturity; it is a pull model for SME liquidity.
extend_funding_deadline(new_deadline: u64) allows the admin to push the funding deadline forward
while the escrow is open (status == 0). Shortening or clearing the deadline is not supported by
this entrypoint.
| Status | extend_funding_deadline result |
|---|---|
| 0 — Open | ✅ Allowed when new_deadline > current and < maturity (when maturity configured) |
| 1 — Funded | ❌ FundingDeadlineUpdateNotOpen |
| 2 — Settled | ❌ FundingDeadlineUpdateNotOpen |
| 3 — Withdrawn | ❌ FundingDeadlineUpdateNotOpen |
| 4 — Cancelled | ❌ FundingDeadlineUpdateNotOpen |
Validation rules:
- A funding deadline must already be configured (
FundingDeadlineNotSetotherwise). new_deadlinemust be strictly greater than the stored deadline (FundingDeadlineNotExtended).- When
maturity > 0,new_deadlinemust be strictly less than maturity (FundingDeadlineBeyondMaturity).
Events: FundingDeadlineExtended carries invoice_id, old_deadline, and new_deadline.
sweep_terminal_dust() is permitted in all three terminal states:
| Status | Terminal | Dust sweep allowed |
|---|---|---|
2 (settled) |
Yes | Yes |
3 (withdrawn) |
Yes | Yes |
4 (cancelled) |
Yes | Yes |
This allows the treasury to recover any rounding residue left after all investors have been refunded.
- Out of scope: Non-standard token economics (rebasing, fee-on-transfer).
See
escrow/src/external_calls.rsanddocs/ESCROW_TOKEN_INTEGRATION_CHECKLIST.md. - funded_amount is a non-decreasing i128. Overflow is checked via
checked_add. - Snapshot immutability:
FundingCloseSnapshotis written once at the0 → 1transition and must remain readable aftersettle()orwithdraw(). - Refund double-spend prevention:
InvestorContributionis zeroed before the token transfer; a secondrefund()call finds contribution0and panics.