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
126 changes: 126 additions & 0 deletions docs/adr/ADR-006-insurance-pool-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# ADR-006: Insurance Pool Design

**Date:** 2026-07-26
**Status:** Accepted

## Context

Liquidity providers (LPs) who fund invoices bear the risk that a payer
*defaults*. Before mainnet, ILN needs a way for LPs to hedge that risk without
forcing every LP to accept it — some LPs want default protection and are
willing to pay for it; others are comfortable pricing default risk into their
discount rate and don't want the overhead.

The team had to decide how to structure that protection: build it into the
core `invoice_liquidity` contract directly, or as a separate, optional
contract that LPs opt into. A related question was how much of the real
economics (token custody, risk-based pricing, solvency guarantees) to build in
the first iteration versus deferring to a follow-up once the interface and
integration points are proven.

Key requirements:

- **Optionality** — LPs who don't want insurance shouldn't pay for it or be
affected by it (no forced premium, no coupling to the invoice lifecycle for
uninsured LPs).
- **Separation of concerns** — the core lending contract already carries
significant complexity (funding, discounting, reputation, disputes,
governance); bolting a full insurance economy onto it directly would bloat
its audit surface.
- **Auditable, incremental delivery** — a fully-priced, fully-custodied
insurance pool (real token transfers, actuarial premium pricing, solvency
guards across concurrent claims) is a substantial system in its own right.
Shipping a correct, tested stub first — with the interface and integration
points frozen — lets the surrounding contracts and SDK be built and tested
against a stable API while the economics harden separately.
- **Admin-gated payouts** — claims must only be payable by a caller the pool
trusts to have actually verified a default, not by the LP or payer directly.

## Decision

Implement the insurance pool as a **separate Soroban contract**
(`contracts/insurance_pool`) with a narrow, typed interface
(`InsurancePoolInterface` in `insurance_interface.rs`), rather than embedding
insurance logic in `invoice_liquidity`.

The pool ships as a **design-forward stub**: the full public interface is
implemented and tested, but the underlying economics are deliberately
simplified for v1:

- **Accounting, not custody.** `deposit_premium(lp, amount)` records the
premium as an accounting balance on the pool. No SAC tokens actually move
into the contract yet — real token settlement is explicit follow-up work.
- **Flat coverage cap.** `claim(invoice_id)` pays out
`min(coverage, pool_balance)`, where `coverage` is a single flat cap set at
`initialize` — not priced against the specific invoice amount, the LP's
premium history, or remaining pool solvency.
- **Idempotent, admin-gated claims.** Each `invoice_id` can be claimed exactly
once. `claim` requires the configured pool admin — in production, the
`invoice_liquidity` contract itself — so a payout can only be triggered by a
confirmed default, not by an LP or payer directly.
- **Timelocked admin actions.** Coverage cap changes and admin transfers are
queued behind a `TIMELOCK_DELAY_SECONDS` (3-day) delay
(`propose_coverage_change` / `execute_coverage_change`,
`propose_admin_transfer` / `execute_admin_transfer`), rather than applying
immediately, since both are sensitive to enrolled LPs.

Integration with `invoice_liquidity` is a one-way hook on the default path:
when `claim_default` confirms a default for an enrolled LP, it calls
`InsurancePoolInterfaceClient::claim(invoice_id)` on the configured pool
address and emits a compensation event. The pool is configured with the
liquidity contract as its `admin`, so only a genuine confirmed default can
trigger a payout.

## Alternatives Considered

| Alternative | Why rejected |
|-------------|--------------|
| **Embed insurance state and logic directly in `invoice_liquidity`** | Couples an optional, still-evolving subsystem to the core lending contract's storage and audit surface; every future insurance change would risk regressing core lending logic. |
| **Ship full token custody and risk-priced premiums in v1** | Correct long-term design, but a much larger scope (SAC integration, actuarial pricing, solvency guards across concurrent claims) that would delay shipping the interface LPs and the SDK need to build against. Deferred to follow-up work. |
| **Let LPs or payers call `claim` directly** | Removes the guarantee that a payout only follows a genuine confirmed default; an admin-gated hook from `claim_default` is the only caller that can assert that invariant. |
| **Apply coverage/admin changes immediately (no timelock)** | Coverage cap and admin identity are trust-critical parameters for enrolled LPs; an immediate change gives LPs no time to react to a compromised or malicious admin. Mirrors the timelock pattern already used elsewhere in the protocol (see [ADR-005](ADR-005-governance-timelock.md)). |

## Consequences

**Positive:**
- LPs get an opt-in default-protection product without affecting LPs who
don't enroll.
- The interface (`enroll`, `deposit_premium`, `claim`, `get_pool_balance`,
and the timelocked admin flows) is frozen and fully tested, so the SDK
(`getPoolBalance`, `getCoverage`, `isEnrolled`, `getPremiumsPaid`,
`getInsurancePoolInfo`, plus write methods) and downstream integrations can
be built now, before the economics are finalized.
- Keeping the pool in its own contract limits the blast radius of insurance
bugs — a defect in premium accounting cannot corrupt invoice or escrow
state in `invoice_liquidity`.
- The timelock on coverage/admin changes gives enrolled LPs visibility and
reaction time before a sensitive parameter change takes effect.

**Negative / Trade-offs:**
- The stub does not custody real tokens: `deposit_premium` and `claim` move
accounting balances only. Until real SAC settlement ships, the pool cannot
be used with real funds in production.
- The flat coverage cap does not reflect actual risk (invoice size, LP
concentration, or pool solvency under multiple simultaneous defaults) —
a follow-up must add risk-priced payouts and solvency guards before
mainnet.
- **Integration is documented but not wired into `claim_default`** in the
current `invoice_liquidity` source — the crate did not compile on `main` at
the time the pool was built (an unrelated merge issue), so the hook shown
in `docs/insurance-pool-design.md` is a drop-in that still needs to be
added once available. Contributors should not assume defaults are
automatically compensated today.
- Cross-contract calls between `invoice_liquidity` and `insurance_pool` add
CPU/instruction cost to `claim_default` versus an embedded design.

## Follow-up work (before mainnet)

- Real SAC token custody for premiums and payouts.
- Risk-priced premiums and coverage (vs. a flat cap).
- Pool solvency guards and payout prioritization across simultaneous
defaults.
- Wire the `claim_default` → pool `claim` hook into `invoice_liquidity`.
- End-to-end integration tests across `invoice_liquidity` ⇄ `insurance_pool`.

See `docs/insurance-pool-design.md` for the full interface reference and SDK
usage examples.
3 changes: 3 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ design choices so that future contributors understand *why* the system is built
| [ADR-003](ADR-003-discount-rate-basis-points.md) | Discount Rate Represented in Basis Points | Accepted |
| [ADR-004](ADR-004-lazy-reputation-decay.md) | Lazy Reputation Decay | Accepted |
| [ADR-005](ADR-005-governance-timelock.md) | Governance Timelock Length (No Timelock in v1) | Accepted |
| [ADR-006](ADR-006-insurance-pool-design.md) | Insurance Pool Design | Accepted |
| [ADR-007](adr-007-nft-invoice-representation.md) | NFT Invoice Representation | Accepted |
| [ADR-008](adr-008-multisig-admin.md) | Multi-Signature Admin | Accepted |

## Template

Expand Down
131 changes: 131 additions & 0 deletions docs/adr/adr-007-nft-invoice-representation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# ADR-007: NFT Invoice Representation

**Date:** 2026-07-26
**Status:** Accepted

## Context

An invoice funded by an LP is, economically, a claim on a future payment —
the LP has effectively bought a discounted receivable. The team had to decide
how to represent ownership of that claim on-chain: as an implicit field on
the `Invoice` record (e.g. a `funder`/`owner` address updated in place), or as
an explicit, transferable token.

Motivating factors:

- **Secondary markets.** LPs may want to exit a position before an invoice's
due date — sell their claim to another LP at a discount — rather than
waiting for `mark_paid` or a default. That requires a transferable
representation of "who currently owns this claim," independent of the
invoice's internal funding fields.
- **Composability.** A standard token-like object (mint / transfer / burn,
queryable metadata and ownership) can be referenced by other contracts —
future collateralized-lending or marketplace contracts — without those
contracts needing to understand the full `Invoice` state machine.
- **Auditability.** A dedicated NFT lifecycle (minted on submission,
transferred on funding, burned on settlement) gives a clean, independently
verifiable event trail for who held a claim at any point in time, separate
from the invoice's own status transitions.
- **Incremental delivery.** As with the insurance pool (see
[ADR-006](ADR-006-insurance-pool-design.md)), the team chose to land the
NFT data model and query surface first, proven by tests, before wiring the
mint/transfer/burn lifecycle into the invoice state machine's write paths.

## Decision

Represent each invoice as a **soulbound-until-funded NFT**, modeled in
`contracts/invoice_liquidity/src/nft.rs` as `InvoiceNftMetadata`:

```rust
pub struct InvoiceNftMetadata {
pub invoice_id: u64,
pub amount: i128,
pub due_date: u32,
pub discount_rate: u32,
pub token: Address,
pub owner: Address,
pub minted_at: u32,
}
```

Storage and lifecycle are keyed per invoice — `DataKey::InvoiceNft(invoice_id)`
for metadata, `DataKey::InvoiceNftOwner(invoice_id)` for a lightweight
ownership lookup — rather than using a general-purpose token ID space, since
the invoice ID already uniquely identifies each NFT and there is no need for
multiple NFTs per invoice.

The module exposes four lifecycle operations plus two read-only queries:

| Function | Intended trigger | Effect |
|----------|-------------------|--------|
| `mint_invoice_nft` | Invoice submission | Creates the NFT, owned by the submitting freelancer. |
| `transfer_invoice_nft` | Invoice funding | Reassigns ownership from freelancer to the funding LP. |
| `burn_invoice_nft` | Invoice paid | Destroys the NFT once the underlying claim is settled. |
| `invoice_nft_exists` | — | Existence check without loading metadata. |
| `query_nft_metadata` (public) | — | Returns full metadata, or `None`. |
| `query_nft_owner` (public) | — | Returns current owner, or `None`. |

Each lifecycle operation emits a corresponding event
(`InvoiceNftMinted` / `InvoiceNftTransferred` / `InvoiceNftBurned`) for
off-chain indexing.

Ownership is enforced at the module level: `transfer_invoice_nft` and
`burn_invoice_nft` both verify the caller-supplied `from`/`owner` argument
matches the stored owner, returning `ContractError::Unauthorized` otherwise.

## Alternatives Considered

| Alternative | Why rejected |
|-------------|--------------|
| **Track ownership as a plain field on `Invoice` (no separate NFT module)** | Works for the current single-owner-at-a-time model, but gives no standard mint/transfer/burn interface for other contracts (marketplaces, collateralized lending) to build against, and mixes claim-ownership concerns into the invoice state machine. |
| **General-purpose token ID space (arbitrary `token_id`, not tied to `invoice_id`)** | Adds an indirection layer with no benefit here — invoices are 1:1 with their NFT and already have a unique `u64` ID, so a separate ID space would only add a lookup table to maintain. |
| **Full SEP-41-style fungible/semi-fungible token standard** | Invoices are inherently non-fungible (each has a unique amount, due date, and discount rate) and single-supply; a fungible token standard adds interface surface (allowances, decimals) that doesn't apply. |
| **Wire the full mint/transfer/burn lifecycle into `submit_invoice`/`fund_invoice`/`mark_paid` in this iteration** | The data model, storage layout, and query API needed to be validated and tested first (see `tests_nft_query.rs`) before coupling NFT side-effects to the core lending write paths, which already carry significant complexity (escrow, discounting, reputation, disputes). |

## Consequences

**Positive:**
- A transferable NFT per invoice is a prerequisite for secondary-market
trading of funded claims and for future composability with other
contracts.
- The metadata (`amount`, `due_date`, `discount_rate`, `token`) is
self-contained, so a marketplace or lending contract can price a claim
without cross-calling back into `invoice_liquidity` for invoice details.
- Ownership checks and event emission are centralized in `nft.rs`, giving one
place to audit the NFT invariants rather than scattering them across the
invoice lifecycle handlers.
- Read-only queries (`query_nft_metadata`, `query_nft_owner`) are already
wired into the public contract API and exposed through the SDK
(`getNftMetadata`), so integrators can build against the data model today.

**Negative / Trade-offs:**
- **The mint/transfer/burn lifecycle is not currently invoked from
`submit_invoice`, `fund_invoice`, or `mark_paid`.** As of this writing, no
code path in `lib.rs` calls `nft::mint_invoice_nft`,
`nft::transfer_invoice_nft`, or `nft::burn_invoice_nft` — only the
query functions are wired in. `query_nft_metadata`/`query_nft_owner`
therefore return `None` for every invoice today, and `tests_nft_query.rs`
only exercises the not-found paths. Wiring the lifecycle calls into the
three invoice-state transitions is required before this feature is
functionally complete.
- Once wired, transferring the NFT independently of the invoice's `funder`
field (used internally for escrow accounting) creates two sources of truth
for "who holds this claim" that must be kept in sync — a secondary-market
transfer of the NFT would need to also update `Invoice.funder`, or the two
must be reconciled at read time.
- Persistent storage per invoice (metadata + owner key) adds rent/TTL
management overhead on top of the existing `Invoice` record.
- No `approve`/`transfer_from` pattern exists yet, so a marketplace contract
cannot escrow-and-swap an NFT on a seller's behalf without either the
seller directly calling `transfer_invoice_nft` or a future extension to
the ownership model.

## Follow-up work

- Wire `mint_invoice_nft` into `submit_invoice`, `transfer_invoice_nft` into
`fund_invoice` (and any subsequent LP-to-LP transfer), and
`burn_invoice_nft` into `mark_paid` / default settlement.
- Reconcile `InvoiceNftMetadata.owner` with `Invoice.funder` once the
lifecycle is wired, so the two cannot drift.
- Consider an `approve`/`transfer_from`-style extension if a marketplace
contract needs to move NFTs on behalf of their owner.
Loading
Loading