Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ jobs:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.93.0
components: rustfmt, llvm-tools-preview

- name: Cache cargo registry and build
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Soroban smart contracts for **LiquiFact** on Stellar. This repository contains t
|--------|---------|
| `init` | Create escrow (admin auth). Sets `funding_target = amount`. Binds **`funding_token`**, **`treasury`**, optional **`registry`**, optional **`yield_tiers`**, optional **`min_contribution`** (per-call floor), optional **`max_unique_investors`** (cap on distinct funder addresses); validates **`invoice_id`** (length ≤ 32, charset `[A-Za-z0-9_]`). See [**`escrow/README.md`**](escrow/README.md) for formal invariant stubs and security checklist. |
| `get_escrow` / `get_version` / `get_legal_hold` | Read state. |
| `get_escrow_summary` | Compact read view for indexers/callers: status, amounts/targets, maturity, and funding-close ledger timestamp/sequence (when funded). |
| `get_min_contribution_floor` / `get_max_unique_investors_cap` / `get_unique_funder_count` | Read optional per-call funding floor, optional unique-funder cap, and current distinct funder count. |
| `bind_primary_attestation_hash` / `get_primary_attestation_hash` | Admin **single-set** 32-byte digest for off-chain bundle binding. |
| `append_attestation_digest` / `get_attestation_append_log` | Admin **append-only** digest log (bounded length). |
Expand Down
60 changes: 60 additions & 0 deletions escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,36 @@ pub struct FundingCloseSnapshot {
pub closed_at_ledger_sequence: u32,
}

/// Compact read view for indexers and cross-contract callers.
///
/// This struct intentionally contains only primitive, non-address fields so it can be fetched
/// efficiently and decoded without pulling the full [`InvoiceEscrow`] (which includes `Address`
/// and `Symbol` fields).
///
/// ## Stability across versions
///
/// The field order and types are part of the contract interface. Treat this struct as
/// **append-only**: future versions may add new optional fields at the end, but existing fields
/// will not be reordered or repurposed.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct EscrowSummary {
/// Current escrow status: `0 = open`, `1 = funded`, `2 = settled`, `3 = withdrawn`.
pub status: u32,
/// Invoice amount set at init (base units of the funding token).
pub amount: i128,
/// Funding target (may be updated by admin while status is `open`).
pub funding_target: i128,
/// Total funded principal credited so far (base units of the funding token).
pub funded_amount: i128,
/// Maturity ledger timestamp (seconds). `0` means no maturity gate.
pub maturity: u64,
/// Ledger timestamp when the escrow first became funded; [`None`] until then.
pub funding_close_timestamp: Option<u64>,
/// Ledger sequence when the escrow first became funded; [`None`] until then.
pub funding_close_sequence: Option<u32>,
}

// --- Events ---

#[contractevent]
Expand Down Expand Up @@ -623,6 +653,36 @@ impl LiquifactEscrow {
.unwrap_or_else(|| panic!("Escrow not initialized"))
}

/// Compact summary read for external indexers and cross-contract callers.
///
/// Returns a small struct containing the most commonly-indexed numeric fields from
/// [`InvoiceEscrow`], plus the ledger time metadata from [`FundingCloseSnapshot`] (when present).
pub fn get_escrow_summary(env: Env) -> EscrowSummary {
let escrow = Self::get_escrow(env.clone());
let snap: Option<FundingCloseSnapshot> = env
.storage()
.instance()
.get::<DataKey, FundingCloseSnapshot>(&DataKey::FundingCloseSnapshot);

let (funding_close_timestamp, funding_close_sequence) = match snap {
Some(s) => (
Some(s.closed_at_ledger_timestamp),
Some(s.closed_at_ledger_sequence),
),
None => (None, None),
};

EscrowSummary {
status: escrow.status,
amount: escrow.amount,
funding_target: escrow.funding_target,
funded_amount: escrow.funded_amount,
maturity: escrow.maturity,
funding_close_timestamp,
funding_close_sequence,
}
}

pub fn get_version(env: Env) -> u32 {
env.storage().instance().get(&DataKey::Version).unwrap_or(0)
}
Expand Down
119 changes: 117 additions & 2 deletions escrow/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use super::{
external_calls, LiquifactEscrow, LiquifactEscrowClient, YieldTier, MAX_DUST_SWEEP_AMOUNT,
SCHEMA_VERSION,
external_calls, EscrowSummary, LiquifactEscrow, LiquifactEscrowClient, YieldTier,
MAX_DUST_SWEEP_AMOUNT, SCHEMA_VERSION,
};
use soroban_sdk::{
symbol_short,
testutils::{Address as _, Ledger as _},
token::StellarAssetClient,
xdr::{FromXdr, ToXdr},
Address, BytesN, Env, String, Vec as SorobanVec,
};

Expand Down Expand Up @@ -165,6 +166,120 @@ fn test_get_escrow_uninitialized_panics() {
client.get_escrow();
}

// --- summary read (#101) ---

#[test]
#[should_panic(expected = "Escrow not initialized")]
fn test_get_escrow_summary_uninitialized_panics() {
let env = Env::default();
let client = deploy(&env);
client.get_escrow_summary();
}

#[test]
fn test_get_escrow_summary_open_roundtrip_and_compact_xdr() {
let env = Env::default();
let (client, admin, sme) = setup(&env);
client.init(
&admin,
&String::from_str(&env, "SUM001"),
&sme,
&TARGET,
&800i64,
&1000u64,
&Address::generate(&env),
&None,
&Address::generate(&env),
&None,
&None,
&None,
);

let summary = client.get_escrow_summary();
assert_eq!(
summary,
EscrowSummary {
status: 0,
amount: TARGET,
funding_target: TARGET,
funded_amount: 0,
maturity: 1000u64,
funding_close_timestamp: None,
funding_close_sequence: None,
}
);

let bytes = summary.clone().to_xdr(&env);
let rt = EscrowSummary::from_xdr(&env, &bytes).expect("xdr roundtrip");
assert_eq!(rt, summary);

let escrow_bytes = client.get_escrow().to_xdr(&env);
assert!(
bytes.len() < escrow_bytes.len(),
"expected summary to be more compact than full InvoiceEscrow"
);
}

#[test]
fn test_get_escrow_summary_reflects_updated_funding_target() {
let env = Env::default();
let (client, admin, sme) = setup(&env);
client.init(
&admin,
&String::from_str(&env, "SUM002"),
&sme,
&5_000i128,
&800i64,
&0u64,
&Address::generate(&env),
&None,
&Address::generate(&env),
&None,
&None,
&None,
);
client.update_funding_target(&10_000i128);

let summary = client.get_escrow_summary();
assert_eq!(summary.amount, 5_000i128);
assert_eq!(summary.funding_target, 10_000i128);
assert_eq!(summary.status, 0);
assert_eq!(summary.funding_close_timestamp, None);
assert_eq!(summary.funding_close_sequence, None);
}

#[test]
fn test_get_escrow_summary_includes_funding_close_metadata_when_funded() {
let env = Env::default();
let (client, admin, sme) = setup(&env);
let investor = Address::generate(&env);
client.init(
&admin,
&String::from_str(&env, "SUM003"),
&sme,
&TARGET,
&800i64,
&0u64,
&Address::generate(&env),
&None,
&Address::generate(&env),
&None,
&None,
&None,
);

env.ledger().set_timestamp(4242);
env.ledger().set_sequence_number(77);

client.fund(&investor, &TARGET);

let summary = client.get_escrow_summary();
assert_eq!(summary.status, 1);
assert_eq!(summary.funded_amount, TARGET);
assert_eq!(summary.funding_close_timestamp, Some(4242));
assert_eq!(summary.funding_close_sequence, Some(77));
}

// --- fund ---

#[test]
Expand Down
3 changes: 3 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.93.0"
profile = "minimal"
Loading