Glossary: For definitions of terms used throughout this document and the contract READMEs, see
docs/GLOSSARY.md.
Economic parameters:
Treasury::execute_settlementpays out the full, exact proposed amount — there is no protocol fee or comparable deduction. This is a deliberate design choice, not an unaddressed gap; seedocs/economic-parameters.mdfor the rationale and the conditions under which it should be revisited.
The CI size gate (contract-size.yml) rejects any compiled WASM that exceeds 65 536 bytes (64 KiB).
Rationale:
- Soroban's current network-enforced maximum for a deployed contract WASM is 65 536 bytes. Deploying a larger binary is rejected at the protocol level, so the CI threshold mirrors the hard ceiling exactly — there is no separate "margin" buffer because any byte over the limit is already a deploy failure.
- The threshold is set via the
MAX_CONTRACT_SIZEenv var incontract-size.ymlso it can be updated in one place if the network limit changes. - All three contracts (compliance, invoice, treasury) are currently well under this ceiling. The check exists to catch accidental size regressions before they reach a deploy attempt.
This document describes the protocol-level design of the three COMEBACKHERE smart contracts, their data storage, and how they interact during a typical payment lifecycle.
| Contract | Crate path | Responsibility |
|---|---|---|
| Invoice | contracts/invoice |
Invoice state machine and escrow lifecycle |
| Treasury | contracts/treasury |
2-of-3 multi-sig settlement approval and token transfer |
| Compliance | contracts/compliance |
Admin-managed allow/block list for addresses |
sequenceDiagram
participant Merchant
participant Payer
participant Invoice
participant SettlementProposalWorkflow
participant SettlementWorkflow
participant Compliance
participant Treasury
participant Token
Merchant->>Invoice: create_invoice(merchant, amount, expires_in)
Note over Invoice: status = Pending
Payer->>Invoice: (off-chain payment triggers admin)
Invoice-->>Invoice: mark_paid(admin, id, payer)
Note over Invoice: status = Paid
SettlementProposalWorkflow->>Invoice: get_invoice(id)
Invoice-->>SettlementProposalWorkflow: Invoice{status=Pending ✓}
SettlementProposalWorkflow->>Treasury: propose_settlement(signer, merchant, amount)
Note over Treasury: Settlement{status=Pending}
Treasury-->>Treasury: approve_settlement(signer2, id)
Note over Treasury: approval_weight >= threshold
SettlementWorkflow->>Compliance: is_allowed(merchant)
Compliance-->>SettlementWorkflow: true / false
alt is_allowed(merchant) == true
SettlementWorkflow->>Treasury: execute_settlement(signer, id, token)
Treasury->>Token: transfer(treasury → merchant, amount)
Note over Treasury: Settlement{status=Executed}
else is_allowed(merchant) == false
Note over SettlementWorkflow: returns Err(TreasuryError::ComplianceCheckFailed), Treasury untouched
end
Invoice-->>Invoice: release_escrow(admin, id)
Note over Invoice: status = Released
Note:
SettlementProposalWorkflowandSettlementWorkfloware off-chain or intermediary contracts that coordinate the cross-contract calls. Treasury does not call Compliance directly.
| DataKey | Storage | Type | Description |
|---|---|---|---|
Admin |
Instance | Address |
Contract administrator |
InvoiceCount |
Instance | u64 |
Monotonic invoice ID counter |
Paused |
Instance | bool |
Circuit-breaker flag |
GraceWindow |
Instance | u64 |
Seconds added to expires_at during mark_paid |
Invoice(u64) |
Persistent | Invoice |
Full invoice record keyed by ID |
MerchantNonce(Address, u64) |
Persistent | bool |
Idempotency guard; rejects duplicate merchant nonces |
PayerIndexCount(Address) |
Persistent | u64 |
Number of invoices recorded in a payer's index (#711) |
PayerIndexEntry(Address, u64) |
Persistent | u64 |
Payer → invoice ID index, appended in mark_paid order; capped at MAX_PAYER_INDEX_ENTRIES per payer (#711) |
| DataKey | Storage | Type | Description |
|---|---|---|---|
Admin |
Instance | Address |
Contract administrator |
Threshold |
Instance | u32 |
Minimum approval weight to execute a settlement |
SettlementCount |
Instance | u64 |
Monotonic settlement ID counter |
Signer(Address) |
Instance | u32 |
Signing weight per authorized signer |
Paused |
Instance | bool |
Circuit-breaker flag |
DisputeCount |
Instance | u64 |
Monotonic dispute ID counter |
RotationCount |
Instance | u64 |
Monotonic signer-rotation proposal counter |
TokenAllowlist |
Instance | Vec<Address> |
Approved token contracts for settlement |
MerchantPayoutAddress(Address) |
Instance | Address |
Override payout address per merchant |
Settlement(u64) |
Persistent | Settlement |
Settlement record keyed by ID |
Dispute(u64) |
Persistent | Dispute |
Dispute record keyed by ID |
Balance(Address) |
Persistent | i128 |
Deposited balance per depositor |
SignerRotation(u64) |
Persistent | SignerRotationProposal |
Signer rotation proposal keyed by ID |
| DataKey | Storage | Type | Description |
|---|---|---|---|
Admin |
Instance | Address |
Contract administrator |
PendingAdmin |
Instance | Address |
Pending admin for two-step transfer |
Paused |
Instance | bool |
Circuit-breaker flag (allow/block ops disabled; block_address still permitted) |
AddressIndex |
Instance | Vec<Address> |
Index of all tracked addresses for export_snapshot |
Allowed(Address) |
Persistent | bool |
Whether an address is on the allow-list |
Blocked(Address) |
Persistent | bool |
Whether an address is blocked (overrides allow) |
AllowedUntil(Address) |
Persistent | u64 |
Optional expiry timestamp for a temporary allow |
Tier(Address) |
Persistent | u32 |
Compliance tier set via allow_address_with_tier; 0 (basic KYC) if unset. Metadata only — not consulted by is_allowed |
Jurisdiction(Address) |
Persistent | Symbol |
Optional jurisdiction code (e.g. ISO 3166 alpha-2) set via set_jurisdiction. Metadata only — not consulted by is_allowed |
Each contract defines error codes via a #[contracterror] enum. New variants must be appended at the end (highest numeric value) to preserve on-chain backwards compatibility — existing contracts and clients may depend on the current ordinal positions.
| Code | Name | Description |
|---|---|---|
| 1 | Unauthorized |
Caller is not the expected admin or merchant |
| 2 | ContractPaused |
Contract is paused and the operation is blocked |
| 3 | InvalidAmount |
Amount is zero, negative, or gross < amount |
| 4 | NotPending |
Invoice status is not Pending for the required transition |
| 5 | Expired |
Payment window (including grace period) has elapsed |
| 6 | NotFound |
No invoice exists for the given ID |
| 7 | AlreadyInitialized |
initialize has already been called |
| 8 | ZeroDuration |
expires_in_seconds is zero |
| 9 | ExpiryOverflow |
expires_at arithmetic overflowed u64 |
| 10 | NotPaid |
Invoice is not in Paid status |
| 11 | NotReleased |
Invoice has not been released from escrow |
| 12 | AmountPrecision |
Amount below minimum USDC unit (< 10_000_000 stroops) |
| 13 | DuplicateNonce |
Merchant nonce has already been used |
| 14 | ExpiryTooLong |
expires_in_seconds exceeds max (5 years) |
| 15 | MetadataMismatch |
Provided metadata_hash does not match stored hash |
| 16 | NoPendingAdmin |
No pending admin transfer to accept |
| 17 | InvalidPaymentLinkHash |
payment_link_hash is not exactly 32 bytes |
| 18 | NotRefundRequested |
Invoice is not in RefundRequested status |
| 19 | TokenMismatch |
Provided payment token does not match invoice's expected token |
| 20 | BatchTooLarge |
Batch input exceeds MAX_BATCH_SIZE |
| 21 | CooldownActive |
create_invoice called again before CreationCooldown elapsed |
| 22 | InvoiceCountOverflow |
u64 invoice id counter would overflow |
| 23 | HashTooLong |
Optional hash field exceeds MAX_HASH_BYTES (64) |
| 24 | PaymentStateInconsistent |
Invoice does not describe a completed payment (no paid_at, no recorded payer, or amount_usdc/gross_usdc inconsistent) — refunds are refused (#70) |
| 25 | RefundTransferFailed |
The cross-contract token transfer executing a refund failed; the refund is abandoned with no state change (#70) |
| 26 | RefundTokenNotSet |
Refund payout attempted on an invoice created without a token_address (#70) |
| 27 | RefundFeeTooHigh |
fee_bps above MAX_REFUND_FEE_BPS (10_000 bps = 100%); rejected before the payout transfer is attempted (#71) |
| 28 | ArithmeticOverflow |
Refund fee arithmetic exceeded i128; unreachable for any in-range input, kept as a typed backstop so a bound change fails as an error rather than a trap (#71) |
| Code | Name | Description |
|---|---|---|
| 1 | AlreadyInitialized |
initialize has already been called |
| 2 | ZeroThreshold |
Approval threshold cannot be zero |
| 3 | SettlementNotFound |
No settlement exists for the given ID |
| 4 | AlreadyExecuted |
Settlement is already executed, cancelled, or expired |
| 5 | ThresholdNotMet |
Approval weight is below the required threshold |
| 6 | ThresholdNotConfigured |
Threshold has not been set |
| 7 | InvalidAmount |
Amount is zero or negative |
| 8 | ContractPaused |
Contract is paused and the operation is blocked |
| 9 | Unauthorized |
Caller is not the admin |
| 10 | UnauthorizedSigner |
Caller is not an authorised signer (zero weight) |
| 11 | InvalidTokenContract |
Token contract is the treasury itself |
| 12 | TokenNotAllowed |
Token is not on the settlement allowlist |
| 13 | RotationNotFound |
No signer rotation proposal for the given ID |
| 14 | RotationAlreadyExecuted |
Rotation proposal already executed or cancelled |
| 15 | SettlementOnHold |
Settlement is on hold and cannot be executed |
| 16 | DisputeNotExpired |
Dispute expiry timestamp has not been reached |
| 17 | AlreadyOnHold |
Settlement is already on hold |
| 18 | ThresholdUnreachable |
The threshold exceeds the available signer weight |
| 19 | ComplianceCheckFailed |
The address failed the compliance check |
| 20 | ArithmeticOverflow |
The calculation overflowed |
| 21 | DisputeNotFound |
Dispute not found |
| 22 | DisputeAlreadyResolved |
The dispute has already been resolved |
| 23 | ResolutionDirectionMismatch |
The dispute resolution direction is invalid |
| 24 | BatchTooLarge |
Too many items in one batch |
| 25 | WeightOverflow |
The total signer weight is too large |
| 26 | SettlementNotCancellable |
The settlement cannot be cancelled |
| 27 | TtlNotElapsed |
The settlement has not expired yet |
| 28 | AllowlistFull |
The allowlist is full |
| 29 | NotOnHold |
The settlement is not on hold |
| 30 | DestinationNotAllowed |
The destination address is not allowed |
| 31 | InsufficientBalance |
Insufficient treasury balance |
| 32 | NotPaused |
The treasury contract is not paused |
| 33 | RotationProposalCooldown |
Please wait before proposing another signer rotation |
| 34 | WorkflowNotRegisteredSigner |
The settlement workflow is not a registered signer |
| 35 | WithdrawalLimitExceeded |
The withdrawal limit has been exceeded |
| 36 | InvalidSplitRatio |
The dispute split ratio is invalid |
| 37 | ForceCancelNotAllowed |
The settlement cannot be force-cancelled |
| 38 | SignerChangeTooEarly |
The signer change delay has not elapsed yet |
| 39 | SignerChangeNotFound |
Signer change not found |
| 40 | SignerChangeAlreadyFinalised |
The signer change has already been finalised |
| 41 | ExecutionDeadlineExceeded |
The settlement execution deadline has passed |
| 42 | QuorumBreak |
Removing this signer would make the threshold unreachable |
| 43 | TokenHasPendingSettlements |
The token still has pending settlements |
| 44 | ApprovalNotFound |
The signer has not approved this settlement |
| 45 | DuplicateVote |
The signer has already voted on this dispute |
| 46 | NoPendingAdmin |
There is no pending admin transfer |
| 47 | LabelTooLong |
The signer label is too long |
Note: 42–47 were each first merged at a discriminant another PR had already taken (38 or 41).
mainnever compiled with those numbers, so they were re-appended in merge order in multisig 0.6.0; seecrates/multisig/CHANGELOG.md.
| Code | Name | Description |
|---|---|---|
| 1 | Unauthorized (ContractError) |
Caller is not the admin or operator |
| 2 | ContractPaused (ContractError) |
Contract is paused and the operation is blocked |
| 3 | AlreadyInitialized (ContractError) |
initialize has already been called |
| 4 | BatchTooLarge (ContractError) |
Batch input exceeds MAX_BATCH_SIZE |
| 5 | AddressIndexFull (ContractError) |
Tracking a new address would exceed MAX_TRACKED_ADDRESSES |
| 6 | BulkOperationCooldown (ContractError) |
Bulk allow/block called before BULK_OP_COOLDOWN_SECS elapsed |
| 7 | UnexpectedSchemaVersion (ContractError) |
migrate has no path from the stored schema version |
| 8 | OperatorCannotClearAdminBlock (ContractError) |
An operator tried to clear or override an admin-placed block |
| 9 | OperatorSetFull (ContractError) |
add_operator called with MAX_OPERATORS operators already set |
| 10 | InvalidJurisdictionCode (ContractError) |
set_jurisdiction code is not 2–3 uppercase ASCII letters or digits |
| 11 | NoPendingAdmin (ContractError) |
accept_admin / cancel_admin_transfer called with no pending transfer |
Note:
ComplianceErrorenum exists separately with codeAlreadyInitialized = 1for historical compatibility. New error variants should be added toContractError.
Coordination errors raised by the workflow contract itself, distinct from the
TreasuryError values its settlement path returns (borrowed from multisig
because that crate was already a dependency).
| Code | Name | Description |
|---|---|---|
| 1 | Unauthorized |
Caller is not the configured emergency-pause admin |
| 2 | NotConfigured |
initialize_emergency_pause has not been called, so there is no target set |
| 3 | PauseTargetFailed |
A target refused to pause; the whole sweep is rolled back, so nothing is left paused (#73) |
| 4 | UnpauseTargetFailed |
A target refused to unpause; the whole sweep is rolled back, so nothing is left resumed (#73) |
| 5 | NoPauseTargets |
An empty target set was supplied |
| 6 | TooManyPauseTargets |
More than MAX_PAUSE_TARGETS (8) targets |
| 7 | DuplicatePauseTarget |
The same address appears twice in the target set |
| 8 | SelfPauseTarget |
The workflow contract itself was supplied as a target |
| 9 | EmergencyPauseActive |
The target set cannot be repointed while an emergency pause is open |
| 10 | NotEmergencyPaused |
resume_all was called with no emergency pause active |
The shared crates (crates/multisig, crates/protocol-errors, crates/error-macros and crates/protocol-math) provide types, math constants and error definitions that are imported by the contracts. They are not deployed contracts and have no DataKeys or on-chain storage of their own.
| Crate | What it provides | Storage |
|---|---|---|
crates/multisig |
SettlementHoldReason enum, multisig helper types, TreasuryError |
None |
crates/protocol-errors |
Shared error type utilities | None |
crates/error-macros |
declare_contract_error! macro used by every #[contracterror] enum |
None |
crates/protocol-math |
Protocol-wide math constants (BPS_DENOMINATOR) and typed basis-point helpers |
None |
crates/protocol-math exists so a ratio means exactly one thing protocol-wide:
BPS_DENOMINATOR (10 000 bps = 100.00%) is defined once, and
apply_bps_i128 / split_bps_i128 perform the multiply-divide on i128
amounts, returning Option so overflow surfaces as the caller's own error
instead of a wrap. A contract that needs basis-point math (today: the treasury
dispute split in contracts/treasury/src/disputes.rs) takes the denominator and
the helpers from this crate rather than re-declaring its own copy of 10_000
(#707).
If you are looking for where a type like SettlementHoldReason is stored on-chain, look at the Treasury DataKey table above (Settlement(u64) embeds it). The crates themselves are compile-time dependencies only.
flowchart LR
Merchant[Merchant dashboard] --> Invoice[Invoice contract]
Invoice --> Proposal[Settlement proposal workflow]
Proposal --> Treasury[Treasury contract]
SettlementWorkflow[SettlementWorkflow contract] --> Compliance[Compliance contract]
SettlementWorkflow --> Treasury
Treasury --> Token[SEP-41 token]
SettlementProposalWorkflow
├── Invoice::get_invoice(id) → validates status == Pending
└── Treasury::propose_settlement(...) → creates Settlement record
SettlementWorkflow
├── Compliance::is_allowed(merchant) → compliance gate; if false, returns Err(ComplianceCheckFailed)
└── Treasury::execute_settlement(...) → called ONLY if the gate passes; transfers tokens to merchant
SettlementWorkflow (emergency pause coordination, #73)
└── {Invoice,Treasury,Compliance}::pause(admin)
→ fanned out over the configured target set in order, passing this
contract's own address as `admin`. All-or-nothing: the first target that
does not return Ok aborts the whole call with PauseTargetFailed, and
returning Err reverts the targets already paused. The orchestrator's
only authority over the rest of the protocol is "stop it" — the client
trait declares pause/unpause and nothing else.
Treasury::execute_settlement
└── Token::transfer(treasury → merchant) → SEP-41 token transfer
Invoice::process_refund → verify payment state, then
└── Token::transfer(escrow → payer) Token::transfer(escrow → payer) → SEP-41 token transfer; a failure reverts the whole
refund with RefundTransferFailed (#70)
Invoice (cross-contract calls: Token only, on the refund payout path)
Treasury (standalone — no outbound cross-contract calls except Token)
Compliance (standalone — no outbound cross-contract calls)
SettlementWorkflow (cross-contract calls: Compliance::is_allowed and
Treasury::execute_settlement on the settlement path, plus pause/unpause on the
emergency-pause path)
Soroban cross-contract calls have no network-style timeout or retry — a call either
returns synchronously within the same transaction or the invocation aborts. This
documents how the calling contract (Treasury, via compliance-client) reacts to
each failure mode when invoking Compliance::is_allowed:
| Failure mode | Behavior | Caller impact |
|---|---|---|
| Compliance contract not deployed / wrong contract ID | The host call fails immediately (no such contract instance) | The calling transaction aborts entirely; all state changes made earlier in the same transaction (e.g. a prior Treasury write) are rolled back. There is no partial-execution state to clean up. |
| Compliance contract paused | is_allowed does not check the Paused flag — only administrative mutations (allow_address, block_address, etc.) enforce require_not_paused. is_allowed still executes and returns its normal bool result while paused. |
No special handling needed; a pause does not block compliance reads, only compliance list mutations. |
| Compliance contract panics (unexpected internal error) | The panic propagates up through the cross-contract call boundary | The calling transaction aborts entirely, identical to a missing-contract failure. Soroban provides no automatic retry — the caller (or off-chain orchestrator driving SettlementWorkflow) must resubmit a fresh transaction after the underlying issue is resolved. |
Because there is no retry/backoff primitive at the protocol level, any retry policy
(e.g. re-attempting execute_settlement after a transient compliance failure) must
be implemented off-chain by whatever process submits these transactions.
Pending ──mark_paid──► Paid ──release_escrow──► Released
│ │
│ cancel_invoice └── request_refund ──► RefundRequested
▼
Cancelled
Pending ──batch_expire──► Expired (when ledger.timestamp >= expires_at)
Off-chain indexers reconstruct the chronological status history for each invoice
from the invoice contract's emitted events, using the invoice ID topic as the
stream key. invoice_created, invoice_paid, invoice_expired,
invoice_cancelled, invoice_refund_requested, and refund_approved carry the
resulting full Invoice; escrow_released carries the invoice ID, merchant,
amount, and release timestamp. Consumers must process events in ledger/event
order, checkpoint their position, and deduplicate replayed events. The current
state can be reconciled with get_invoice or the batch_get_invoice_status
entrypoint.
Soroban has no automatic storage migration. Once a #[contracttype] struct is deployed, changing its fields is a breaking change for any data already written under that type.
Additive-only field changes are the default strategy:
- New fields must be wrapped in
Option<T>so that existing stored values (which lack the field) deserialise without error. - Removing or reordering fields is forbidden after mainnet deployment.
- Renaming a field is equivalent to removing the old one and adding a new one — treat it as a breaking change.
Explicit migration entrypoint (when additive-only is insufficient):
- Add a
migrate(admin: Address)entrypoint that reads records under the old schema, transforms them, and writes them under the new schema. - Gate it with
require_authonadminand a one-timeMigratedinstance-storage flag so it cannot be called twice. - The entrypoint should be removed (or made a no-op) in a subsequent release once migration is confirmed complete on-chain.
| Contract | Current risk | Notes |
|---|---|---|
| Invoice | Invoice struct has grown incrementally; all new fields are Option<T> |
Follow additive-only going forward |
| Treasury | Settlement and Dispute structs embed SettlementHoldReason from crates/multisig |
Adding variants to SettlementHoldReason is safe; removing or renumbering is not |
| Compliance | Minimal stored types (bool, u64) |
Low migration risk |
The meets_threshold helper in crates/multisig evaluates quorum by comparing
accumulated weight — a u32 value — against a configured threshold, not by
counting how many signers have approved. The decisive expression is:
weight >= thresholdWeight is accumulated across approval calls: each time record_approval is invoked
for a previously-unseen signer, that signer's registered weight is added to the
running total.
The weight model is a deliberate design choice. Operators can assign higher weight to signers they trust more or that offer stronger security guarantees — for example, an HSM-backed key or a cold-storage multisig controlled by a security team. This lets a deployment express trust asymmetry that a plain N-of-M count scheme cannot represent.
A consequence of this model is that a single signer assigned a weight equal to or greater than the threshold can satisfy quorum alone. This is a feature, not a bug — it enables patterns like an emergency break-glass admin key — but operators must understand the implication: that signer effectively acts unilaterally.
A healthy deployment distributes weights such that no single signer's weight meets or exceeds the threshold on its own, unless that is an explicit operational policy (e.g. an emergency admin key that is kept offline and heavily audited).
The threshold should be calibrated so that the minimum number of signers required to collude and satisfy quorum matches the deployment's trust model. A higher threshold forces more weight to accumulate, reducing the risk that a single compromised key can execute a settlement.
Set the threshold to the sum of weights that represents acceptable quorum — not to a signer count. For example:
| Signer | Weight |
|---|---|
| Signer A | 3 |
| Signer B | 2 |
| Signer C | 1 |
With threshold = 4, the following combinations satisfy quorum:
- Signer A (weight 3) + any one of B or C
- Signer B (weight 2) + Signer C (weight 1) — their combined weight is 3, which does not reach 4; they must both approve but that is still insufficient without Signer A — adjust weights to match your intent.
Revise weights and threshold together whenever signers are rotated or the trust
model changes. The set_threshold and set_signer entrypoints require admin
authentication precisely to ensure these changes are deliberate and auditable.
record_approval accumulates weight using checked_add. If the accumulated total
would exceed u32::MAX (4 294 967 295), the call panics with
TreasuryError::WeightOverflow rather than silently wrapping around to a small
number. In practice no deployment should approach this ceiling; the check exists
as a correctness guarantee, not a routine guard.
The external security audit planned under issue #117 should evaluate whether the weight distribution in the actual deployment matches the stated trust model. In particular, auditors should verify that no single signer's weight meets or exceeds the threshold unless an emergency break-glass policy is explicitly documented and justified.
Signer weights are stored under DataKey::Signer(Address) in instance storage
(see the Treasury DataKey table above). signer_weight returns 0 for any address
that has not been registered via set_signer — it never panics on an unknown
address. The .unwrap_or(0) in signer_weight's implementation is the contract:
unregistered addresses are silently treated as having zero weight and will be
rejected by require_authorized_signer before they can influence any approval
accumulation.