Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions docs/escrow-snapshot.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
# Funding Close Snapshot

The `FundingCloseSnapshot` is a critical piece of the LiquiFact Escrow contract's audit trail. it captures the exact state of the escrow at the moment it transitions from `Open` (0) to `Funded` (1).
The `FundingCloseSnapshot` is a critical piece of the LiquiFact Escrow contract's audit trail. It captures the exact state of the escrow at the moment it transitions from `Open` (0) to `Funded` (1).

## Purpose

This snapshot serves as the **immutable source of truth** for off-chain pro-rata calculations. When an invoice is over-funded (which is allowed by the contract), the total principal at the moment of funding completion becomes the denominator for investor share calculations.
This snapshot serves as the **immutable source of truth** for off-chain pro-rata calculations. When an invoice is over-funded (which is allowed by the contract), the full `funded_amount` at the threshold-crossing deposit becomes the denominator for investor share calculations, even when it is greater than `funding_target`.

By capturing this state once and making it immutable, the contract ensures that subsequent actions (like SME withdrawals or settlements) do not shift the relative weight of investor contributions.

## Structure

The snapshot is stored under `DataKey::FundingCloseSnapshot` and contains:

- `total_principal`: The sum of all principal contributed at the moment the funding target was met or exceeded.
- `total_principal`: The sum of all principal contributed at the moment the funding target was met or exceeded. This equals `InvoiceEscrow.funded_amount` at close and can be greater than `funding_target`.
- `funding_target`: The original target for the invoice.
- `closed_at_ledger_timestamp`: The ledger timestamp when the snapshot was captured.
- `closed_at_ledger_sequence`: The ledger sequence number when the snapshot was captured.

## Lifecycle and Immutability

1. **Creation**: The snapshot is created during `fund` or `fund_with_commitment` only when `status == 0` and the new `funded_amount >= funding_target`.
2. **Write-Once**: Once the snapshot is written, the contract's logic prevents it from being updated or overwritten.
3. **Persistence**: The snapshot survives all state transitions, including `settle` and `withdraw`.
1. **Before close**: `get_funding_close_snapshot()` returns `None` while the escrow is still open and below target.
2. **Creation**: The snapshot is created during `fund` or `fund_with_commitment` only when `status == 0` and the new `funded_amount >= funding_target`.
3. **Over-funding capture**: If the threshold-crossing deposit overshoots the target, `total_principal` records the full over-funded close amount.
4. **Write-Once**: Once the snapshot is written, the contract's logic prevents it from being updated or overwritten. Later funding attempts are rejected because the escrow is no longer open, and later lifecycle writes do not touch `DataKey::FundingCloseSnapshot`.
5. **Persistence**: The snapshot survives all state transitions, including `settle` and `withdraw`.

## Auditing

Integrators can use the `get_funding_close_snapshot` getter to retrieve this metadata. For historical auditing, the `EscrowFunded` event emitted during the snapshot creation contains the `funded_amount` and `status: 1`, allowing off-chain systems to reconcile the snapshot with the event stream.

The `closed_at_ledger_timestamp` and `closed_at_ledger_sequence` fields are captured from the same ledger as the threshold-crossing funding call. Off-chain indexers should use those fields as the canonical close boundary for pro-rata reporting.

## Security Considerations

1. **Time and Sequence Bounds**: The snapshot captures `env.ledger().timestamp()` and `env.ledger().sequence()`. In Soroban, these are provided by the host environment and are reliable for on-chain time-based logic. Off-chain systems should treat these as the canonical boundaries for the "funded" state transition.
2. **Token Economics and Assumptions**: As detailed in `escrow/src/external_calls.rs`, this contract strictly assumes standard SEP-41 token mechanics. Malicious, rebasing, or fee-on-transfer (FOT) tokens are **explicitly out of scope** and will trigger safe-failure panics at the balance-check boundaries. This ensures that the `total_principal` captured in the snapshot perfectly matches the real token balance stored in the contract treasury, preserving the integrity of off-chain payout calculations.
2. **Write-Once Denominator**: `DataKey::FundingCloseSnapshot` is only set if it does not already exist. State transitions such as `settle` and `withdraw` do not recompute the denominator, which prevents later writes from changing investor weights.
3. **State-Machine Misuse**: Funding after close is rejected by the `status == 0` funding guard before contribution or snapshot state can be mutated.
4. **Overflow and Amount Guards**: Funding uses positive amount checks and checked arithmetic before writing `funded_amount` or contribution records.
5. **Token Economics and Assumptions**: As detailed in `escrow/src/external_calls.rs`, this contract strictly assumes standard SEP-41 token mechanics. Malicious, rebasing, or fee-on-transfer (FOT) tokens are **explicitly out of scope** and will trigger safe-failure panics at the balance-check boundaries. This ensures that the `total_principal` captured in the snapshot matches standard token accounting assumptions, preserving the integrity of off-chain payout calculations.
126 changes: 74 additions & 52 deletions escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,20 +150,18 @@ pub const MAX_DUST_SWEEP_AMOUNT: i128 = 100_000_000;
pub const MAX_INVOICE_ID_STRING_LEN: u32 = 32;

/// Minimum instance storage TTL extension horizon for time-sensitive escrow entries.

///
/// `bump_ttl` extends instance-storage entries to avoid rent/archival edge cases when
/// maturity/claim locks are far in the future.
///
/// Named as a constant so operators can reason about and audit the threshold.
pub const INSTANCE_TTL_MIN_EXTENSION_SECS: u64 = 60 * 60; // 1h
pub const INSTANCE_TTL_MIN_EXTENSION_LEDGERS: u32 = 60 * 60; // Approx. 1h at 1 ledger/sec.

/// Minimum persistent storage TTL extension horizon for per-investor allowlist entries.
///
/// When the escrow uses the allowlist gate, investor funding depends on persistent entries.
/// Extending persistent allowlist TTL reduces the risk of silent allowlist disablement.
pub const PERSISTENT_TTL_MIN_EXTENSION_SECS: u64 = 60 * 60; // 1h

pub const PERSISTENT_TTL_MIN_EXTENSION_LEDGERS: u32 = 60 * 60; // Approx. 1h at 1 ledger/sec.

// --- Storage keys ---

Expand Down Expand Up @@ -310,12 +308,16 @@ pub struct YieldTier {
pub yield_bps: i64,
}

/// Captured at the first ledger transition to **funded** so partial settlement / claims can use a
/// stable total principal and target. **Immutable** once written.
/// Captured exactly once at the first ledger transition to **funded** so settlement and claims can
/// use a stable total principal and target. If the threshold-crossing deposit overshoots
/// [`InvoiceEscrow::funding_target`], [`FundingCloseSnapshot::total_principal`] records the full
/// credited [`InvoiceEscrow::funded_amount`] at close and becomes the pro-rata denominator.
/// **Immutable** once written.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct FundingCloseSnapshot {
/// Sum of principal credited when the invoice became funded (`funded_amount` at close), including overflow past target.
/// Sum of principal credited when the invoice became funded (`funded_amount` at close),
/// including over-funding past target.
pub total_principal: i128,
pub funding_target: i128,
pub closed_at_ledger_timestamp: u64,
Expand Down Expand Up @@ -899,9 +901,7 @@ impl LiquifactEscrow {
/// Optional cap on total principal for a single investor address.
/// Absent ⇒ unlimited. Enforced on every deposit.
pub fn get_max_per_investor_cap(env: Env) -> Option<i128> {
env.storage()
.instance()
.get(&DataKey::MaxPerInvestorCap)
env.storage().instance().get(&DataKey::MaxPerInvestorCap)
}

/// Distinct funders counted so far (each address counted once when it first receives principal).
Expand Down Expand Up @@ -1019,6 +1019,10 @@ impl LiquifactEscrow {
}

/// Pro-rata denominator captured when the escrow first became **funded**; [`None`] until then.
///
/// The snapshot is write-once. It records the full `funded_amount` at the threshold-crossing
/// funding call, including any over-funding past `funding_target`, plus the close ledger time
/// and sequence used by off-chain auditors.
pub fn get_funding_close_snapshot(env: Env) -> Option<FundingCloseSnapshot> {
env.storage().instance().get(&DataKey::FundingCloseSnapshot)
}
Expand Down Expand Up @@ -1195,7 +1199,7 @@ impl LiquifactEscrow {
let n = investors.len();
assert!(n > 0, "investors vector must be non-empty");
assert!(
(n as u32) <= MAX_INVESTOR_ALLOWLIST_BATCH,
n <= MAX_INVESTOR_ALLOWLIST_BATCH,
"investors vector length exceeds MAX_INVESTOR_ALLOWLIST_BATCH"
);

Expand Down Expand Up @@ -1259,6 +1263,54 @@ impl LiquifactEscrow {
escrow
}

/// Lower the configured distinct-investor cap while the escrow is still open.
///
/// This is admin-only and intentionally cannot raise a cap or impose one on an unlimited
/// escrow. Existing investors remain able to add principal after the cap is lowered; only new
/// investor addresses are blocked once `UniqueFunderCount >= new_cap`.
///
/// # Panics
/// - If the escrow is not open.
/// - If no unique-investor cap was configured at initialization.
/// - If `new_cap` is not strictly lower than the current cap.
/// - If `new_cap` is below the current unique funder count.
pub fn lower_max_unique_investors(env: Env, new_cap: u32) -> u32 {
let escrow = Self::get_escrow(env.clone());
escrow.admin.require_auth();

assert!(escrow.status == 0, "Cap can only be lowered in Open state");

let old_cap: u32 = env
.storage()
.instance()
.get(&DataKey::MaxUniqueInvestorsCap)
.unwrap_or_else(|| panic!("no investor cap configured"));
let unique_count = Self::get_unique_funder_count(env.clone());

assert!(
new_cap < old_cap,
"new cap must be strictly lower than current cap"
);
assert!(
new_cap >= unique_count,
"new cap cannot be below current unique funder count"
);

env.storage()
.instance()
.set(&DataKey::MaxUniqueInvestorsCap, &new_cap);

MaxUniqueInvestorsCapLowered {
name: symbol_short!("inv_cap"),
invoice_id: escrow.invoice_id.clone(),
old_cap,
new_cap,
}
.publish(&env);

new_cap
}

/// Validate the stored schema version and apply a migration if one is implemented.
///
/// # Behavior — **panics on all current paths**
Expand Down Expand Up @@ -1483,9 +1535,6 @@ impl LiquifactEscrow {
}
}

let next_contribution = prev
.checked_add(amount)
.expect("investor contribution overflow");
env.storage()
.instance()
.set(&contribution_key, &new_contribution);
Expand Down Expand Up @@ -1795,56 +1844,29 @@ impl LiquifactEscrow {
// - ADR-007: storage key evolution policy (additive changes / key semantics).
// - docs/escrow-ledger-time.md: all gating uses `Env::ledger().timestamp()` with `>=`.

// Instance storage keys required for settlement + gating behavior.
let k_escrow: DataKey = DataKey::Escrow;
env.storage().instance().extend_ttl(&k_escrow, &INSTANCE_TTL_MIN_EXTENSION_SECS);

let k_version: DataKey = DataKey::Version;
env.storage().instance().extend_ttl(&k_version, &INSTANCE_TTL_MIN_EXTENSION_SECS);

let k_legal_hold: DataKey = DataKey::LegalHold;
env.storage().instance().extend_ttl(&k_legal_hold, &INSTANCE_TTL_MIN_EXTENSION_SECS);

let k_allowlist_active: DataKey = DataKey::AllowlistActive;
env.storage()
.instance()
.extend_ttl(&k_allowlist_active, &INSTANCE_TTL_MIN_EXTENSION_SECS);

let k_snapshot: DataKey = DataKey::FundingCloseSnapshot;
env.storage()
.instance()
.extend_ttl(&k_snapshot, &INSTANCE_TTL_MIN_EXTENSION_SECS);

// Investor contribution + claim gates are instance keys (per investor).
// We cannot enumerate contributors on-chain; extend only what the caller provides.
for addr in allowlisted.iter() {
// Contribution + claim-gate entries are instance-scoped and per-investor.
let k_contrib = DataKey::InvestorContribution(addr.clone());
env.storage()
.instance()
.extend_ttl(&k_contrib, &INSTANCE_TTL_MIN_EXTENSION_SECS);

let k_claim_not_before = DataKey::InvestorClaimNotBefore(addr.clone());
env.storage()
.instance()
.extend_ttl(&k_claim_not_before, &INSTANCE_TTL_MIN_EXTENSION_SECS);
}
env.storage().instance().extend_ttl(
INSTANCE_TTL_MIN_EXTENSION_LEDGERS,
INSTANCE_TTL_MIN_EXTENSION_LEDGERS,
);

// Instance storage TTL is contract-wide under Soroban SDK 25. The call above covers
// Escrow, Version, LegalHold, snapshots, and all per-investor instance keys.

// Persistent allowlist entries.
for addr in allowlisted.iter() {
let k = DataKey::InvestorAllowlisted(addr.clone());
env.storage()
.persistent()
.extend_ttl(&k, &PERSISTENT_TTL_MIN_EXTENSION_SECS);
env.storage().persistent().extend_ttl(
&k,
PERSISTENT_TTL_MIN_EXTENSION_LEDGERS,
PERSISTENT_TTL_MIN_EXTENSION_LEDGERS,
);
}
}

pub fn transfer_admin(env: Env, new_admin: Address) -> InvoiceEscrow {
// env.clone(): env is used again after this call for storage set and publish.
let mut escrow = Self::get_escrow(env.clone());


escrow.admin.require_auth();

assert!(
Expand Down
9 changes: 6 additions & 3 deletions escrow/src/test_allowlist_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use super::{LiquifactEscrow, LiquifactEscrowClient};
use soroban_sdk::{testutils::Address as _, Address, Env};
use super::{
AllowlistEnabledChanged, DataKey, InvestorAllowlistChanged, LiquifactEscrow,
LiquifactEscrowClient,
};
use soroban_sdk::Vec as SorobanVec;
use soroban_sdk::{symbol_short, testutils::Address as _, Address, Env, Event};

fn deploy(env: &Env) -> LiquifactEscrowClient<'_> {
let id = env.register(LiquifactEscrow, ());
Expand All @@ -25,7 +28,7 @@ fn init(env: &Env, client: &LiquifactEscrowClient) -> (Address, Address) {
&None,
&None,
&None,
&None
&None,
);
(admin, sme)
}
Expand Down
3 changes: 2 additions & 1 deletion escrow/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use soroban_sdk::{
mod admin;
mod attestations;
mod cap_validation;
mod coverage;
mod external_calls;
mod external_calls_mocked;
mod funding;
Expand Down Expand Up @@ -109,7 +110,7 @@ pub fn default_init(client: &LiquifactEscrowClient<'_>, env: &Env, admin: &Addre
&None,
&None,
&None,
&None
&None,
);
}

Expand Down
Loading
Loading