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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

---

Expand Down
42 changes: 42 additions & 0 deletions docs/escrow-read-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
186 changes: 186 additions & 0 deletions escrow/src/tests/external_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading