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
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,13 @@ liquifact-contracts/

| Entrypoint | Auth Role | Description |
|---|---|---|
| `init` | Admin (implicit) | Create an invoice escrow (invoice id, SME, amount, yield bps, maturity). |
| `init` | Admin (implicit) | Create an invoice escrow (invoice id, SME, amount, yield bps, maturity). Optional immutable `protocol_fee_bps` (`0..=10_000`, default `0`) splits the SME disbursement at `withdraw`. |
| `fund` | Investor | Record investor principal and atomically pull the funding token from the investor; marks escrow funded when target is met. |
| `fund_with_commitment` | Investor | First deposit with optional lock period (atomically pulling the funding token); selects tiered yield. |
| `settle` | SME | Mark a funded escrow as settled (SME auth required; maturity enforced). |
| `partial_settle` | SME | SME marks a portion of the escrow as settled before full settlement. |
| `withdraw` | SME | SME pulls funded liquidity (accounting record). |
| `withdraw` | SME | SME pulls funded liquidity, **net of the immutable protocol fee**: `fee = funded_amount * protocol_fee_bps / 10_000` goes to the treasury, the remainder to the SME. |
| `get_protocol_fee_bps` | — | Read the immutable protocol fee in basis points (defaults to `0`). |
| `cancel_funding` | Admin | Admin cancels an open escrow (transitions status 0 → 4). |
| `refund` | Investor | Investor pulls contributed liquidity from a cancelled escrow. Increments `DistributedPrincipal` liability. |
| `claim_investor_payout` | Investor | Investor records a payout claim after settlement. |
Expand Down Expand Up @@ -324,11 +325,42 @@ The escrow supports cancellation by the admin under specific criteria, unlocking
- Residual dust sweeping and the liability floor protecting un-refunded investors
- A worked execution sequence with multiple investors

## Protocol fee on SME withdrawal

An **immutable** protocol fee can be configured at `init` via the optional
`protocol_fee_bps: Option<i64>` parameter (basis points, validated to `0..=10_000`, default `0`)
and stored under `DataKey::ProtocolFeeBps`. On `withdraw`, the funded principal is split:

```text
fee = funded_amount * protocol_fee_bps / 10_000 (integer floor, checked)
sme_payout = funded_amount - fee
```

`fee` is routed to the immutable `DataKey::Treasury`; `sme_payout` to `sme_address`. Key
properties:

- **Conservation:** `sme_payout + fee == funded_amount` (no principal created or destroyed).
- **Floor rounding:** sub-`10_000` residue stays with the SME; the treasury is never over-credited.
- **Backward compatible:** `protocol_fee_bps == 0` (or omitted) makes no treasury transfer and the
SME receives the full `funded_amount` — byte-for-byte the pre-fee behavior.
- **Overflow-safe:** the fee multiplication/division and the net subtraction use checked
arithmetic, with typed errors `WithdrawFeeArithmeticOverflow` and
`WithdrawNetArithmeticUnderflow`; out-of-range `init` values fail with `ProtocolFeeBpsOutOfRange`.
- **Depends on on-chain disbursement:** the fee is only taken on the on-chain `withdraw` path, not
on off-chain `settle`, investor `refund`, or `claim_investor_payout`.
- **Event:** `SmeWithdrew` is extended append-only with `fee`; its `amount` is the net SME payout.

See [`docs/escrow-numeric-model.md`](docs/escrow-numeric-model.md) for the authoritative math and
[`docs/ESCROW_SME_WITHDRAWAL.MD`](docs/ESCROW_SME_WITHDRAWAL.MD) for the disbursement interaction.

## Security notes

- **Typed errors:** stable numeric [`EscrowError`](docs/escrow-error-messages.md) codes are
append-only; SDKs must branch on `ContractError(code)`, not panic strings. See
[`docs/escrow-error-messages.md`](docs/escrow-error-messages.md) for the full reference.
- **Protocol fee:** immutable `protocol_fee_bps` split at `withdraw` conserves principal
(`sme_payout + fee == funded_amount`), floors rounding toward the SME, and uses checked
arithmetic on the fee multiplication and net subtraction.
- **Auth:** state-changing entrypoints use `require_auth()` for the
appropriate role (admin, SME, investor, **treasury** for dust sweep).
- **Legal hold:** governance-controlled; misuse risk is mitigated by using a
Expand Down
37 changes: 28 additions & 9 deletions docs/ESCROW_SME_WITHDRAWAL.MD
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,37 @@

## 1. What `withdraw` does (and does not do)

`withdraw` is a **pure on-chain state transition**. It moves the escrow from
`status 1` (funded) to `status 3` (withdrawn) by rewriting the
`DataKey::Escrow` entry atomically inside a single Soroban host-function call.
> **Schema note (on-chain disbursement):** On the current schema, `withdraw` performs the SEP-41
> token transfer **on-chain**, drawing from the principal custodied in this contract. Sections
> below that describe `withdraw` as a "pure state transition" with no token movement reflect the
> older v1 model where disbursement was delegated to an integration layer. The behavior of record
> immutability (`funded_amount`, `FundingCloseSnapshot`, per-investor contributions) still holds.

It does **not**:
### 1.1 Protocol fee split (on-chain disbursement)

- Transfer tokens to the SME or any investor.
- Zero or modify `funded_amount`, `funding_target`, or any investor contribution record.
- Emit anything resembling a payment instruction.
When principal is custodied on-chain, `withdraw` splits `funded_amount` using the immutable
`protocol_fee_bps` configured at `init` (basis points, `0..=10_000`, default `0`, stored under
`DataKey::ProtocolFeeBps`):

Token movement is the responsibility of the **integration layer** that calls
into this contract. See `escrow/src/external_calls.rs` and the
```text
fee = funded_amount * protocol_fee_bps / 10_000 (integer floor, checked)
sme_payout = funded_amount - fee
```

- `fee` is transferred to the immutable `DataKey::Treasury`; `sme_payout` to `sme_address`.
- **Conservation:** `sme_payout + fee == funded_amount`. Floor rounding leaves any residue with the
SME, so the treasury is never over-credited.
- **Dependency:** the fee is only realized on this on-chain disbursement path. Off-chain `settle`,
investor `refund`, and `claim_investor_payout` are unaffected.
- **Zero-fee default:** with `protocol_fee_bps == 0` no treasury transfer occurs and the SME
receives the full `funded_amount`.
- **Event:** `SmeWithdrew` carries `amount` (net SME payout) and `fee` (treasury share) separately.

See [`escrow-numeric-model.md`](escrow-numeric-model.md) for the authoritative math and overflow
analysis.

Token movement (for deployments still using the v1 delegated model) is the responsibility of the
**integration layer** that calls into this contract. See `escrow/src/external_calls.rs` and the
[Token Integration Checklist](../ESCROW_TOKEN_INTEGRATION_CHECKLIST.md).

---
Expand Down
12 changes: 9 additions & 3 deletions docs/EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,15 @@ Topics:

Data:

| Field | Type |
|---|---|
| `amount` | `i128` |
| Field | Type | Meaning |
|---|---|---|
| `amount` | `i128` | **Net** principal transferred to the SME (`funded_amount - fee`) |
| `recipient` | `Address` | SME address that received `amount` |
| `fee` | `i128` | Protocol fee routed to the treasury (`0` when `protocol_fee_bps == 0`) |

> **Append-only:** `fee` was appended after the original `(amount, recipient)` data layout.
> `amount + fee == funded_amount`. Legacy indexers reading only `amount` still observe the SME
> payout; add `fee` to reconstruct the gross disbursed principal.

### `InvestorPayoutClaimed`

Expand Down
3 changes: 3 additions & 0 deletions docs/escrow-error-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ See also [`docs/escrow-legal-hold.md`](escrow-legal-hold.md),
| 200 | `PartialSettleUnauthorizedCaller` | `partial_settle` | `caller` is neither `sme_address` nor `admin` | Call as the SME or admin | typed |
| 201 | `LegalHoldBlocksPartialSettle` | `partial_settle` | legal hold active | Complete legal-hold clear workflow | typed |
| 202 | `PartialSettleNotOpen` | `partial_settle` | escrow status `!= 0` (open) | Partial settle only while open | typed |
| 180 | `ProtocolFeeBpsOutOfRange` | `init` | `protocol_fee_bps` outside `0..=10_000` | Pass a fee in `0..=10_000` (or omit for `0`) | typed |
| 181 | `WithdrawFeeArithmeticOverflow` | `withdraw` | `funded_amount * protocol_fee_bps` overflows `i128` (over-funded escrow) | Keep `funded_amount` within the overflow-safe envelope | typed |
| 182 | `WithdrawNetArithmeticUnderflow` | `withdraw` | `funded_amount - fee` underflows (unreachable for in-range `fee_bps`) | N/A — defensive guard | typed |

### Legacy panic strings (migration aid)

Expand Down
35 changes: 35 additions & 0 deletions docs/escrow-numeric-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,41 @@ This contract uses Soroban host values and Rust integer types directly. It does
- A zero commitment stores `0`, meaning no additional investor claim-time gate.
- Boundary values are inclusive: a timestamp plus commitment that equals `u64::MAX` is representable; only values above `u64::MAX` fail.

## Protocol fee on SME withdrawal: `i64` basis points

The escrow supports an **immutable** protocol fee on the SME disbursement, configured once at
`init` via the optional `protocol_fee_bps: Option<i64>` parameter and stored under
`DataKey::ProtocolFeeBps`.

- **Range:** `protocol_fee_bps` is validated to `0..=10_000` at `init`
(`EscrowError::ProtocolFeeBpsOutOfRange`). The default when omitted is `0` (no fee), which
preserves the legacy behavior of routing the full `funded_amount` to the SME.
- **Split math (`withdraw`):**

```text
fee = funded_amount * protocol_fee_bps / 10_000 (integer floor)
sme_payout = funded_amount - fee
```

`fee` is transferred to `DataKey::Treasury` and `sme_payout` to `sme_address`. The treasury
transfer is skipped entirely when `fee == 0`, and the SME transfer is skipped when
`sme_payout == 0` (only reachable at `protocol_fee_bps == 10_000`).
- **Rounding:** the division floors, so any residue below one `10_000`-th of the principal stays
with the SME. The treasury is never over-credited by rounding.
- **Conservation:** `sme_payout + fee == funded_amount` for every withdrawal — the split neither
creates nor destroys principal. `DistributedPrincipal` still advances by the full gross
`funded_amount`.
- **Overflow safety:** the multiplication `funded_amount * protocol_fee_bps` and the division use
checked arithmetic. Because an escrow may be over-funded, `funded_amount` is not bounded by
`MAX_INVOICE_AMOUNT`; if `funded_amount * 10_000` would exceed `i128::MAX` the contract panics
with `EscrowError::WithdrawFeeArithmeticOverflow`. The subtraction is likewise checked
(`EscrowError::WithdrawNetArithmeticUnderflow`, unreachable for in-range `fee_bps`).
- **Dependency on on-chain disbursement:** the fee is only realized when principal is custodied
on-chain and the SME calls `withdraw`. It does **not** apply to off-chain `settle`, investor
`refund`, or `claim_investor_payout`. See [`ESCROW_SME_WITHDRAWAL.MD`](ESCROW_SME_WITHDRAWAL.MD).
- **Event:** `SmeWithdrew` is extended append-only with a `fee` field; its `amount` field carries
the **net** SME payout, and `amount + fee` reconstructs the gross `funded_amount`.

## Funding invariants (property-based)

This contract’s funding accounting and state transitions are intended to obey these invariants for all orderings of `fund` / `fund_with_commitment` calls.
Expand Down
Loading
Loading