diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0e2878..ed17677 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 28700b7..91aa5f0 100644 --- a/README.md +++ b/README.md @@ -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). | diff --git a/escrow/src/lib.rs b/escrow/src/lib.rs index 9056a4d..21a1133 100644 --- a/escrow/src/lib.rs +++ b/escrow/src/lib.rs @@ -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, + /// Ledger sequence when the escrow first became funded; [`None`] until then. + pub funding_close_sequence: Option, +} + // --- Events --- #[contractevent] @@ -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 = env + .storage() + .instance() + .get::(&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) } diff --git a/escrow/src/test.rs b/escrow/src/test.rs index beb057b..45ab4bf 100644 --- a/escrow/src/test.rs +++ b/escrow/src/test.rs @@ -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, }; @@ -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] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..609e284 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.93.0" +profile = "minimal"