diff --git a/README.md b/README.md index a59f4050..4c1831e9 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ liquifact-contracts/ | `get_escrow` | — | Read current escrow state. | | `get_version` | — | Read stored `DataKey::Version`. | | `get_remaining_investor_slots` | — | Read remaining unique investor capacity before reaching the cap. | +| `get_reconciliation` | — | Read solvency position: live token balance, outstanding liability, and surplus/deficit. See [`docs/escrow-read-api.md`](docs/escrow-read-api.md). | --- diff --git a/docs/escrow-read-api.md b/docs/escrow-read-api.md index e9e420d8..e8e4d18c 100644 --- a/docs/escrow-read-api.md +++ b/docs/escrow-read-api.md @@ -66,6 +66,7 @@ re-implementing storage reads to guarantee identical semantics. **Distributed Principal:** - [get_distributed_principal](#get_distributed_principal--i128) +- [get_reconciliation](#get_reconciliation--reconciliationview) --- @@ -595,6 +596,47 @@ and [`LiquifactEscrow::withdraw`] for liability-floor enforcement. --- +## `get_reconciliation() → ReconciliationView` + +**Storage keys:** reads `DataKey::Escrow`, `DataKey::DistributedPrincipal`, and +`DataKey::FundingToken` (then queries the token contract for the live balance). + +Returns the contract's full reconciliation position in a single call, so operators +no longer have to fetch the balance, funded amount, distributed principal, and +settlement state separately and re-implement the liability arithmetic off-chain +(see the [Reconciliation relationship](#reconciliation-relationship) above). + +```text +outstanding_liability = max(funded_amount - distributed_principal, 0) +surplus = token_balance - outstanding_liability +``` + +`outstanding_liability` uses the **identical floor** that +[`LiquifactEscrow::sweep_terminal_dust`] enforces, so the view and the sweep guard +can never disagree. `surplus` is the sweepable dust when positive and a deficit +when negative. + +### `ReconciliationView` fields + +| Field | Type | Description | +|-------|------|-------------| +| `token_balance` | `i128` | Live SEP-41 funding-token balance held by the contract. | +| `outstanding_liability` | `i128` | Principal still owed to investors: `max(funded_amount - distributed_principal, 0)`. | +| `surplus` | `i128` | `token_balance - outstanding_liability`. Positive = sweepable surplus; negative = deficit. | + +- **Pure read** — no authorization required, no state mutation. +- **Never panics on values** — all arithmetic is saturating. +- Emits [`EscrowError::EscrowNotInitialized`] / [`EscrowError::FundingTokenNotSet`] + only when the escrow has not been initialized. + +**Security note:** in settled (`2`) and withdrawn (`3`) states `distributed_principal` +is `0` by design, so `outstanding_liability` reflects the full `funded_amount` and the +reported `surplus` is never larger than what `sweep_terminal_dust` would actually +permit (that guard only applies the floor in the cancelled state `4`). The view is +therefore conservative and can never over-report sweepable funds. + +--- + ### `is_investor_claimed(investor: Address) → bool` **Storage key:** `DataKey::InvestorClaimed(investor)` (persistent) diff --git a/escrow/src/lib.rs b/escrow/src/lib.rs index aceb5b56..5ad96a51 100644 --- a/escrow/src/lib.rs +++ b/escrow/src/lib.rs @@ -4389,6 +4389,80 @@ impl LiquifactEscrow { .get(&DataKey::DistributedPrincipal) .unwrap_or(0) } + + /// Read-only reconciliation position: the live funding-token balance held by + /// the contract, the outstanding investor liability, and the resulting + /// surplus (sweepable dust) or deficit. + /// + /// `outstanding_liability` is computed with the **same liability floor** that + /// [`LiquifactEscrow::sweep_terminal_dust`] enforces (see line + /// `outstanding = funded_amount - distributed_principal` in that function): + /// + /// ```text + /// outstanding_liability = max(funded_amount - distributed_principal, 0) + /// surplus = token_balance - outstanding_liability + /// ``` + /// + /// so a caller's view of "what may be swept" never disagrees with the on-chain + /// invariant. In settled (`2`) and withdrawn (`3`) states `distributed_principal` + /// stays `0` by design, so `outstanding_liability` reflects the full + /// `funded_amount`; the reported `surplus` is therefore never larger than what + /// `sweep_terminal_dust` would actually permit (it only applies the floor in the + /// cancelled state `4`). `surplus` is negative in a deficit. + /// + /// This is a pure read: no authorization, no storage writes. All arithmetic is + /// saturating, so the view cannot panic on extreme balances or amounts. + /// + /// # Errors + /// + /// Fails with [`EscrowError::EscrowNotInitialized`] / [`EscrowError::FundingTokenNotSet`] + /// only when the escrow has not been initialized; it never panics on numeric values. + pub fn get_reconciliation(env: Env) -> ReconciliationView { + let escrow = Self::get_escrow(env.clone()); + + let distributed: i128 = env + .storage() + .instance() + .get(&DataKey::DistributedPrincipal) + .unwrap_or(0); + + // Same formula as sweep_terminal_dust's liability floor, floored at zero. + let outstanding_liability = escrow.funded_amount.saturating_sub(distributed).max(0); + + let token_addr = Self::funding_token_or_fail(&env); + let this = env.current_contract_address(); + let token_balance = TokenClient::new(&env, &token_addr).balance(&this); + + // Surplus is sweepable dust when positive, a deficit when negative. + let surplus = token_balance.saturating_sub(outstanding_liability); + + ReconciliationView { + token_balance, + outstanding_liability, + surplus, + } + } +} + +/// Read-only reconciliation snapshot returned by +/// [`LiquifactEscrow::get_reconciliation`]. +/// +/// Derive rationale: +/// - `Debug`: improves failure diagnostics in tests. +/// - `PartialEq`: allows exact assertions in tests. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ReconciliationView { + /// Live SEP-41 funding-token balance held by the contract address. + pub token_balance: i128, + /// Principal still owed to investors: + /// `max(funded_amount - distributed_principal, 0)`. Uses the identical floor + /// to [`LiquifactEscrow::sweep_terminal_dust`] so the two never disagree. + pub outstanding_liability: i128, + /// `token_balance - outstanding_liability`. Positive means sweepable dust + /// (a surplus); negative means the contract is in deficit for its remaining + /// obligations. + pub surplus: i128, } #[cfg(test)] diff --git a/escrow/src/tests/external_calls.rs b/escrow/src/tests/external_calls.rs index 355a560a..bd1c6533 100644 --- a/escrow/src/tests/external_calls.rs +++ b/escrow/src/tests/external_calls.rs @@ -674,3 +674,189 @@ fn sweep_liability_floor_all_refunded_sweep_all_dust() { assert_eq!(swept, expected); assert_eq!(token.token.balance(&treasury), expected); } + +// ── get_reconciliation view ────────────────────────────────────────────────── +// +// These tests assert that the reconciliation view's `surplus` equals exactly the +// amount `sweep_terminal_dust` would permit to be swept (the live balance minus +// the outstanding investor liability), both before and after partial refunds. + +#[test] +fn reconciliation_reports_zero_surplus_when_balance_equals_liability() { + // Cancelled escrow, funded 1000, balance 1000, no refunds yet. + // outstanding = 1000, surplus = 0 → nothing is sweepable. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, sme) = setup(&env); + let investor = Address::generate(&env); + let fund_amount = 1_000i128; + let (token, _treasury) = + setup_cancelled_with_token(&env, &client, &admin, &sme, &investor, fund_amount); + + let view = client.get_reconciliation(); + assert_eq!(view.token_balance, fund_amount); + assert_eq!(view.outstanding_liability, fund_amount); + assert_eq!(view.surplus, 0i128); + assert_eq!(view.token_balance, token.token.balance(&client.address)); +} + +#[test] +fn reconciliation_surplus_equals_sweepable_dust_before_and_after_partial_refund() { + // Two investors fund 500 each; 1 unit of dust is minted on top (balance 1001). + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, sme) = setup(&env); + let investor_a = Address::generate(&env); + let investor_b = Address::generate(&env); + let token = install_stellar_asset_token(&env); + let treasury = Address::generate(&env); + + client.init( + &admin, + &soroban_sdk::String::from_str(&env, "RECON01"), + &sme, + &2_000i128, + &0i64, + &0u64, + &token.id, + &None, + &treasury, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + ); + token.stellar.mint(&client.address, &1_001i128); + client.fund(&investor_a, &500i128); + client.fund(&investor_b, &500i128); + client.cancel_funding(); + + // Before any refund: outstanding = 1000, balance = 1001, surplus = 1. + let before = client.get_reconciliation(); + assert_eq!(before.token_balance, 1_001i128); + assert_eq!(before.outstanding_liability, 1_000i128); + assert_eq!(before.surplus, 1i128); + + // After refunding investor A: distributed = 500, outstanding = 500. refund() + // transfers A's 500 principal out, so balance drops to 501 (500 for B + 1 dust). + client.refund(&investor_a); + assert_eq!(client.get_distributed_principal(), 500i128); + + let after = client.get_reconciliation(); + assert_eq!(after.token_balance, 501i128); + assert_eq!(after.outstanding_liability, 500i128); + assert_eq!(after.surplus, 1i128); + + // The reported surplus is exactly what sweep_terminal_dust permits: sweeping + // `surplus` succeeds and leaves balance == outstanding. + let swept = client.sweep_terminal_dust(&after.surplus); + assert_eq!(swept, after.surplus); + let settled = client.get_reconciliation(); + assert_eq!(settled.token_balance, 500i128); + assert_eq!(settled.outstanding_liability, 500i128); + assert_eq!(settled.surplus, 0i128); +} + +#[test] +fn reconciliation_reports_surplus_when_over_funded() { + // Cancelled escrow funded 1000 (balance 1000), then 50 extra dust minted. + // outstanding = 1000, balance = 1050, surplus = 50. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, sme) = setup(&env); + let investor = Address::generate(&env); + let fund_amount = 1_000i128; + let (token, treasury) = + setup_cancelled_with_token(&env, &client, &admin, &sme, &investor, fund_amount); + token.stellar.mint(&client.address, &50i128); + + let view = client.get_reconciliation(); + assert_eq!(view.token_balance, 1_050i128); + assert_eq!(view.outstanding_liability, 1_000i128); + assert_eq!(view.surplus, 50i128); + + // Surplus never exceeds what sweep permits: sweeping exactly `surplus` works. + let swept = client.sweep_terminal_dust(&view.surplus); + assert_eq!(swept, 50i128); + assert_eq!(token.token.balance(&treasury), 50i128); +} + +#[test] +#[should_panic] +fn reconciliation_surplus_is_max_sweepable_one_more_panics() { + // Sweeping one unit more than the reported surplus must violate the floor. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, sme) = setup(&env); + let investor = Address::generate(&env); + let fund_amount = 1_000i128; + let (token, _treasury) = + setup_cancelled_with_token(&env, &client, &admin, &sme, &investor, fund_amount); + token.stellar.mint(&client.address, &50i128); + + let view = client.get_reconciliation(); + assert_eq!(view.surplus, 50i128); + // surplus + 1 dips into outstanding liability → panic. + client.sweep_terminal_dust(&(view.surplus + 1)); +} + +#[test] +fn reconciliation_zero_balance_and_zero_liability() { + // Initialized but never funded and never minted: everything is zero. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, sme) = setup(&env); + let token = install_stellar_asset_token(&env); + let treasury = Address::generate(&env); + client.init( + &admin, + &soroban_sdk::String::from_str(&env, "RECON02"), + &sme, + &1_000i128, + &0i64, + &0u64, + &token.id, + &None, + &treasury, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + &None, + ); + + let view = client.get_reconciliation(); + assert_eq!(view.token_balance, 0i128); + assert_eq!(view.outstanding_liability, 0i128); + assert_eq!(view.surplus, 0i128); +} + +#[test] +fn reconciliation_fully_distributed_reports_only_dust_as_surplus() { + // Fund 1000, add 1 dust (balance 1001), cancel, refund the only investor. + // distributed = 1000 → outstanding = 0; balance = 1 (the dust) → surplus = 1. + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, sme) = setup(&env); + let investor = Address::generate(&env); + let fund_amount = 1_000i128; + let (token, _treasury) = + setup_cancelled_with_token(&env, &client, &admin, &sme, &investor, fund_amount); + token.stellar.mint(&client.address, &1i128); + + client.refund(&investor); + assert_eq!(client.get_distributed_principal(), fund_amount); + + let view = client.get_reconciliation(); + assert_eq!(view.token_balance, 1i128); + assert_eq!(view.outstanding_liability, 0i128); + assert_eq!(view.surplus, 1i128); + assert_eq!(view.token_balance, token.token.balance(&client.address)); +}