Every escrow is indexed by a unique invoice_id, which is a Soroban Symbol.
To ensure compatibility with indexers and stable URL routing in off-chain dashboard systems, invoice_id strings provided at init are strictly validated:
Most scalar contract keys use env.storage().instance(). Instance storage is scoped to a single
contract address and is loaded in full on every host-function invocation.
The contract uses env.storage().persistent() for per-address entries:
DataKey::InvestorContribution(Address), DataKey::InvestorEffectiveYield(Address),
DataKey::InvestorClaimNotBefore(Address), DataKey::InvestorClaimed(Address), and
DataKey::InvestorAllowlisted(Address). These are naturally modeled as independent persistent keys
with per-address TTLs (see Stellar/Soroban storage guidance on instance vs persistent storage).
Consequence: the total serialised size of all instance entries must stay within Soroban's
contract-data entry limit. Per-investor accounting no longer grows the instance footprint; investor
cardinality is still bounded by MaxUniqueInvestorsCap when configured.
DataKey is a #[contracttype] enum. Each variant becomes a distinct XDR-encoded key in whichever
storage backend reads or writes it. The enum derives Clone so key values can be reused across
get/set calls in the same execution path without moving ownership.
| Variant | Rust type stored | Set by | Mutable? |
|---|---|---|---|
Escrow |
InvoiceEscrow |
init, every state-changing entrypoint |
Yes — rewritten atomically on each transition |
Version |
u32 |
init |
Only via migrate |
FundingToken |
Address |
init |
No — immutable after init |
Treasury |
Address |
init |
No — immutable after init |
MinContributionFloor |
i128 |
init |
No |
UniqueFunderCount |
u32 |
init (0), incremented by fund_impl |
Yes — incremented on each new investor |
LegalHold |
bool |
set_legal_hold / clear_legal_hold |
Yes — toggled by admin |
MinContributionFloor is written as 0 even when no floor is configured, so reads always succeed
with unwrap_or(0).
| Variant | Rust type stored | Set by | Notes |
|---|---|---|---|
RegistryRef |
Address |
init (when registry arg is Some) |
Hint only — not an on-chain authority |
YieldTierTable |
Vec<YieldTier> |
init (when non-empty tiers supplied) |
Immutable after init |
FundingCloseSnapshot |
FundingCloseSnapshot |
fund_impl on first transition to status == 1 |
Immutable once written |
SmeCollateralPledge |
SmeCollateralCommitment |
record_sme_collateral_commitment |
Record-only; replaceable by SME |
MaxUniqueInvestorsCap |
u32 |
init (when max_unique_investors arg is Some) |
Absent means unlimited |
MaxPerInvestorCap |
i128 |
init (when max_per_investor arg is Some) |
Absent means unlimited |
PrimaryAttestationHash |
BytesN<32> |
bind_primary_attestation_hash |
Single-set; panics on second call |
AttestationAppendLog |
Vec<BytesN<32>> |
append_attestation_digest |
Bounded by MAX_ATTESTATION_APPEND_ENTRIES (32) |
SettledAt |
u64 |
settle on status 1→2 transition |
Write-once; None before settlement; legacy-safe |
FundingDeadline |
u64 |
init (when funding_deadline arg is Some) |
Ledger timestamp; new funds rejected after this |
These variants carry an Address discriminator so each investor gets an independent persistent
storage slot and TTL.
| Variant | Rust type stored | Set by | Default when absent |
|---|---|---|---|
InvestorContribution(Address) |
i128 |
fund_impl |
0 |
InvestorClaimed(Address) |
bool |
claim_investor_payout |
false |
InvestorEffectiveYield(Address) |
i64 (bps) |
fund_impl on first deposit |
InvoiceEscrow::yield_bps |
InvestorClaimNotBefore(Address) |
u64 (ledger timestamp) |
fund_impl on first deposit |
0 (no gate) |
All four per-address keys are written together on an investor's first fund or
fund_with_commitment call. Subsequent fund calls update only InvestorContribution.
These entries also live in persistent storage (not instance storage).
| Variant | Rust type stored | Set by | Default when absent |
|---|---|---|---|
InvestorAllowlisted(Address) |
bool |
set_investor_allowlisted |
false |
When AllowlistActive is enabled (instance storage flag), fund_impl gates fund and
fund_with_commitment by asserting InvestorAllowlisted(investor) == true. Only the admin may
mutate allowlist membership.
See escrow-allowlist.md for the complete allowlist model documentation,
including the active/inactive toggle interaction, TTL/archival behavior, batch operations, and
fund-gate enforcement rules.
pub struct InvoiceEscrow {
pub invoice_id: Symbol, // validated ASCII [A-Za-z0-9_], max 32 chars
pub admin: Address,
pub sme_address: Address,
pub amount: i128, // original invoice face value
pub funding_target: i128, // may be updated while status == 0
pub funded_amount: i128, // running total; checked_add on each fund call
pub yield_bps: i64, // base annualised yield, 0..=10_000
pub maturity: u64, // ledger timestamp; 0 = no maturity gate
pub status: u32, // 0=open 1=funded 2=settled 3=withdrawn
}status transitions are strictly forward. See ADR-001.
pub struct FundingCloseSnapshot {
pub total_principal: i128, // funded_amount at the moment status became 1
pub funding_target: i128,
pub closed_at_ledger_timestamp: u64,
pub closed_at_ledger_sequence: u32,
}Written once, atomically, inside fund_impl on the first transition to status == 1. Immutable
thereafter. Off-chain pro-rata share: get_contribution(addr) / snapshot.total_principal.
Not stored directly; assembled at read time from multiple storage keys. Combines core escrow state with three metadata families so callers obtain a complete view in a single host invocation.
pub struct EscrowSummary {
pub escrow: InvoiceEscrow,
pub has_maturity_lock: bool,
pub legal_hold: bool,
pub funding_close_snapshot: EscrowCloseSnapshot,
pub unique_funder_count: u32,
pub is_allowlist_active: bool,
pub schema_version: u32,
pub sme_collateral_commitment: Option<SmeCollateralCommitment>,
pub has_primary_attestation: bool,
pub attestation_log_length: u32,
}sme_collateral_commitment— pulled fromDataKey::SmeCollateralPledge;Nonewhen never recorded.has_primary_attestation—truewhenDataKey::PrimaryAttestationHashis present.attestation_log_length— length of theVecstored atDataKey::AttestationAppendLog;0when absent.
Legacy instances (no collateral or attestation keys) return None / false / 0 respectively,
per the additive-key policy.
pub struct SmeCollateralCommitment {
pub asset: Symbol,
pub amount: i128,
pub recorded_at: u64, // ledger timestamp at record time
}Record-only. Does not custody tokens or trigger liquidation.
pub struct YieldTier {
pub min_lock_secs: u64,
pub yield_bps: i64,
}Validated at init: min_lock_secs strictly increasing, yield_bps non-decreasing and each
>= base yield_bps. See ADR-005.
DataKey::Version stores a u32 written as SCHEMA_VERSION (currently 6) at init. The
migrate entrypoint validates from_version == stored before applying any migration path. No
migration paths are currently implemented; adding a new optional key does not require a version bump
(see additive-key policy below).
A new DataKey variant is backward-compatible when:
- It is read with
.get(...).unwrap_or(default)so old deployments (where the key is absent) behave as "unset / default". - It does not change the XDR shape of any existing variant or stored struct.
- It does not alter the semantics of existing entrypoints when absent.
A change is breaking (requires migration or redeploy) when:
- An existing stored struct gains a required field (e.g. a new non-optional field on
InvoiceEscrow). - An existing
DataKeyvariant is renamed or its XDR discriminant changes. - An existing key's stored type changes (e.g.
LegalHoldfrombooltou32).
See ADR-007 for the full decision record and compatibility test plan.
Two private helpers centralise storage reads for immutable addresses, ensuring consistent error codes across all entrypoints:
| Accessor | Key read | Error on absence |
|---|---|---|
funding_token_or_fail(&env) |
DataKey::FundingToken |
[EscrowError::FundingTokenNotSet] (code 21) |
treasury_or_fail(&env) |
DataKey::Treasury |
[EscrowError::TreasuryNotSet] (code 22) |
Both are defined as fn(&Env) -> Address inside impl LiquifactEscrow (not public
entrypoints). They panic with the typed error listed above when called before init.
The public getters get_funding_token and get_treasury delegate to them; internal
callers (sweep_terminal_dust, refund) also use them instead of inlining the
.get().unwrap_or_else(|| fail(...)) pattern.
- Token economics:
external_calls::transfer_funding_token_with_balance_checksasserts exact pre/post balance deltas. Fee-on-transfer or rebasing tokens are out of scope and will cause a safe panic. See ADR-006. - Collateral record:
SmeCollateralPledgeis metadata only. It is not proof of encumbrance until a future version explicitly enforces token transfers. - Registry hint:
RegistryRefmust not be used as an authority without verifying registry behavior off-chain or in a dedicated integration. - Attestation digests:
PrimaryAttestationHashandAttestationAppendLogstore raw byte digests. The contract does not verify what the digest commits to; that is an off-chain concern. - Storage growth: per-address investor keys use persistent storage to avoid unbounded instance
growth.
MaxUniqueInvestorsCapcan still bound investor count. Any schema change that adds per-address keys must re-evaluate storage footprint and TTL behavior.