This contract uses Soroban host values and Rust integer types directly. It does not emulate EVM integer wrapping, fixed-point decimals, or token-specific decimal rules.
- Funding amounts, targets, contributions, dust-sweep amounts, and collateral metadata amounts are stored as signed
i128values. - State-changing entrypoints that accept funding-like amounts require strictly positive values before storage updates.
- Token amounts must be passed in the token's smallest unit. The escrow contract does not read token decimals to rescale user-facing amounts.
funded_amountis accumulated withchecked_add. Iffunded_amount + amountexceedsi128::MAX, the contract panics withfunded_amount overflowand the Soroban invocation aborts.- Per-investor
InvestorContribution(Address)is accumulated withchecked_add. Ifprev_contribution + amountexceedsi128::MAX, the contract panics withinvestor contribution overflowand the Soroban invocation aborts. - The contract does not saturate, clamp, or intentionally wrap funding totals.
LiquifactEscrow::init rejects amount > MAX_INVOICE_AMOUNT with EscrowError::AmountExceedsMax (code 14) to prevent overflow in settlement-time payout arithmetic. This is a constructor-time guard — no valid init can produce an escrow where compute_investor_payout overflows.
Value: MAX_INVOICE_AMOUNT = (1 << 63) - 1 = 9_223_372_036_854_775_807 (i.e. floor(√(i128::MAX / 2))).
Derivation (see the constant's doc comment in escrow/src/lib.rs):
coupon = total_principal × yield_bps / 10_000 (floor) (1)
settle_pool = total_principal + coupon (2)
gross_payout = contribution × settle_pool / total_principal (3)
The tightest constraint is step (3): with worst-case yield_bps = 10_000 and a single investor (contribution = total_principal), the intermediate product is total_principal × 2 × total_principal = 2 × total_principal². Requiring this to stay within i128 yields total_principal ≤ floor(√(i128::MAX / 2)) = 2⁶³ − 1. This is stricter than both the step (1) bound (i128::MAX / 10_000) and the step (2) bound (i128::MAX / 2).
Tests:
test_cost_baseline_init_max_amount— accepting exactlyMAX_INVOICE_AMOUNTtest_init_amount_exceeds_max_rejected— rejectingMAX_INVOICE_AMOUNT + 1withAmountExceedsMaxtest_max_bound_funded_escrow_compute_investor_payout_no_overflow— full funding + settlement withyield_bps = 10_000at the bound
- Ledger timestamps and lock durations use
u64seconds fromEnv::ledger().timestamp(). fund_with_commitmentstoresInvestorClaimNotBeforeasnow + committed_lock_secswhen the commitment is non-zero.- That addition uses
checked_add. If the result would exceedu64::MAX, the contract panics withinvestor claim time overflowand the Soroban invocation aborts. - A zero commitment stores
0, meaning no additional investor claim-time gate. - Boundary values are inclusive: a timestamp plus commitment that equals
u64::MAXis representable; only values aboveu64::MAXfail.
This contract’s funding accounting and state transitions are intended to obey these invariants for all orderings of fund / fund_with_commitment calls.
- Conservation (principal accounting): while the escrow is open,
escrow.funded_amountmust equal the sum of every investor’s storedget_contribution(addr). - Unique funder count:
get_unique_funder_count()must equal the number of distinct investor addresses whoseget_contribution(addr) > 0. - Cap enforcement (never exceeded):
- When
max_per_investoris configured, each investor’s running contribution must never exceed the configured cap. - When
max_unique_investorsis configured, the contract must never allow more distinct funders than the configured cap.
- When
- Status transition:
escrow.statusmust flip from0(open) to1(funded) exactly at the first call wherefunded_amount >= funding_targetbecomes true. - FundingCloseSnapshot semantics: on the funded transition,
FundingCloseSnapshotis written once withtotal_principal == escrow.funded_amount(including over-funding), and it must remain immutable across later reads.
These invariants are validated with randomized property tests in escrow/src/tests/properties.rs.
- Off-chain callers should validate amount and lock-duration inputs before submitting transactions, especially when simulating near integer limits.
- Risk and accounting systems should use integer arithmetic for base-unit amounts and rational math for pro-rata ratios; avoid floating-point rounding when reconciling on-chain state.
- Maturity and claim-lock checks are ledger-time checks, not wall-clock oracle checks.
- Unsupported token economics remain out of scope. Fee-on-transfer, rebasing, malicious, or callback-heavy tokens are covered separately in
escrow/src/external_calls.rsandESCROW_TOKEN_INTEGRATION_CHECKLIST.md.
In status cancelled (4), the following invariants hold for all refund orderings:
- Each
refund(investor)returns at most that investor's recorded contribution. DistributedPrincipalincreases atomically per refund and never exceedsfunded_amount.- Once every investor has refunded,
DistributedPrincipal == funded_amount. - Double-refund is impossible: contribution is zeroed before the token transfer.
These properties are validated in escrow/src/tests/properties.rs (prop_refund_conservation_never_exceeds_funded_principal).
- Off-chain callers should validate amount and lock-duration inputs before submitting transactions, especially when simulating near integer limits.
- Risk and accounting systems should use integer arithmetic for base-unit amounts and rational math for pro-rata ratios; avoid floating-point rounding when reconciling on-chain state.
- Maturity and claim-lock checks are ledger-time checks, not wall-clock oracle checks.
- Unsupported token economics remain out of scope. Fee-on-transfer, rebasing, malicious, or callback-heavy tokens are covered separately in
escrow/src/external_calls.rsandESCROW_TOKEN_INTEGRATION_CHECKLIST.md.