From 4e13f00076fe292a9f9ebf54a78798c6df5f5150 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Sun, 26 Jul 2026 08:47:53 +0100 Subject: [PATCH 1/4] docs: add ADR for insurance pool design Document the insurance_pool contract's separation from invoice_liquidity, its accounting-not-custody stub scope, timelocked admin actions, and the still-unwired claim_default integration hook, per issue #555. --- docs/adr/ADR-006-insurance-pool-design.md | 126 ++++++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 127 insertions(+) create mode 100644 docs/adr/ADR-006-insurance-pool-design.md diff --git a/docs/adr/ADR-006-insurance-pool-design.md b/docs/adr/ADR-006-insurance-pool-design.md new file mode 100644 index 00000000..10cfb3a0 --- /dev/null +++ b/docs/adr/ADR-006-insurance-pool-design.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0ad23e88..d112eb2b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,6 +13,7 @@ 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 | ## Template From e72a2f658639e3d35e7ca9c6a15cf226eb5c4e9a Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Sun, 26 Jul 2026 08:48:11 +0100 Subject: [PATCH 2/4] docs: add ADR for NFT invoice representation Document the InvoiceNftMetadata data model, its mint/transfer/burn lifecycle design, and that the lifecycle is not yet wired into submit_invoice/fund_invoice/mark_paid, per issue #556. --- docs/adr/README.md | 1 + .../adr/adr-007-nft-invoice-representation.md | 131 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 docs/adr/adr-007-nft-invoice-representation.md diff --git a/docs/adr/README.md b/docs/adr/README.md index d112eb2b..bd57f06a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,6 +14,7 @@ design choices so that future contributors understand *why* the system is built | [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 | ## Template diff --git a/docs/adr/adr-007-nft-invoice-representation.md b/docs/adr/adr-007-nft-invoice-representation.md new file mode 100644 index 00000000..8c925dc3 --- /dev/null +++ b/docs/adr/adr-007-nft-invoice-representation.md @@ -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. From 58d186b5bead6b9a4592e2da482101e26f4541a9 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Sun, 26 Jul 2026 08:48:24 +0100 Subject: [PATCH 3/4] docs: add ADR for multisig admin Document the M-of-N threshold scheme, proposal expiration window, and admin action types in multisig.rs, and note that the lib.rs/storage.rs/ errors.rs integration was lost in a prior merge conflict resolution and is not currently reachable from the contract's public API, per issue #557. --- docs/adr/README.md | 1 + docs/adr/adr-008-multisig-admin.md | 155 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 docs/adr/adr-008-multisig-admin.md diff --git a/docs/adr/README.md b/docs/adr/README.md index bd57f06a..2372a506 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,6 +15,7 @@ design choices so that future contributors understand *why* the system is built | [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 diff --git a/docs/adr/adr-008-multisig-admin.md b/docs/adr/adr-008-multisig-admin.md new file mode 100644 index 00000000..a2084327 --- /dev/null +++ b/docs/adr/adr-008-multisig-admin.md @@ -0,0 +1,155 @@ +# ADR-008: Multi-Signature Admin + +**Date:** 2026-07-26 +**Status:** Accepted + +## Context + +The `invoice_liquidity` contract has a single admin address that can pause +the contract, remove approved tokens, change fee/discount parameters, and +(per [ADR-005](ADR-005-governance-timelock.md)) veto governance proposals. A +single admin key is a single point of failure: a compromised or careless +admin key can pause the protocol, drain configuration integrity, or block +governance unilaterally. + +Issue #124 asked for threshold-based (M-of-N) multi-signature approval for +these high-security admin operations, so that no single key can act alone. + +Key requirements: + +- **Threshold safety** — an M-of-N scheme where M ≤ N, configurable per + deployment (e.g. 2-of-3 for a small team, higher N for broader + decentralization). +- **Bounded proposal lifetime** — a proposal that never gets its required + signatures should not remain executable indefinitely; a stale proposal + signed long ago under different circumstances should not be executable + today. +- **Order-independence** — signers should be able to approve in any order, + and a proposal should execute as soon as the threshold is met, regardless + of who signs last. +- **Minimal action surface for v1** — start with the actions that most need + M-of-N protection (pause/unpause, and reserved slots for token removal, + fee-rate, and discount-rate changes) rather than generalizing to arbitrary + contract calls immediately. + +## Decision + +Implement a threshold multisig scheme in +`contracts/invoice_liquidity/src/multisig.rs`: + +**Configuration** — `MultisigAdmin { signers: Vec
, threshold: u32 }`, +set once via `initialize_multisig_admin(signers, threshold)`. A configuration +is rejected as `InvalidMultisigConfig` if `threshold` is `0` or exceeds +`signers.len()`. + +**Admin action types** — a closed `AdminAction` enum, so a proposal always +carries a specific, typed action rather than an arbitrary payload: + +```rust +pub enum AdminAction { + Pause, + Unpause, + RemoveToken(Address), + SetFeeRate(u32), + SetMaxDiscount(u32), + UpdateMultisig { new_signers: Vec
, new_threshold: u32 }, +} +``` + +`UpdateMultisig` lets the signer set itself change its own membership and +threshold through the same proposal mechanism, rather than requiring a +separate privileged escape hatch. + +**Proposal lifecycle** — `MultisigProposal` tracks `id`, `action`, +`signers_approved`, a `ProposalState` (`Pending` / `Executed` / `Expired`), +and `expires_at`. The workflow is: + +1. Any authorized signer calls `propose_*` to create a `Pending` proposal + with `expires_at = current_ledger + MULTISIG_WINDOW_LEDGERS`. +2. Signers call `sign_proposal` to add their approval; `has_signed` rejects a + duplicate signature from the same signer (`AlreadySigned`), and signers + may approve in any order. +3. Once `signers_approved.len() >= threshold` (`threshold_reached`), any + authorized signer can call `execute_proposal` to apply the action and mark + it `Executed`. A proposal cannot be executed twice + (`ProposalAlreadyExecuted`). + +**Expiration** — `MULTISIG_WINDOW_LEDGERS = 17_280` (~24 hours at 5s/ledger) +bounds how long a proposal can accumulate signatures before it is treated as +expired (`is_expired`). Signing or executing a proposal at or past +`expires_at` is rejected — expiration is enforced on every state-changing +call, not by an active sweep, so no background job is required. + +## Alternatives Considered + +| Alternative | Why rejected | +|-------------|--------------| +| **Arbitrary-payload proposals (raw call data instead of a typed `AdminAction` enum)** | More flexible, but signers would be approving opaque bytes rather than a specific, auditable action — harder to review and easier to mis-sign. A closed enum makes every proposal's effect explicit at the type level. | +| **No expiration (proposals valid until executed)** | A proposal signed under one set of circumstances (e.g. an emergency pause) could sit dormant and be executed much later when it's no longer appropriate, with no way for signers to invalidate it short of an `UpdateMultisig` proposal. A bounded window forces stale proposals to be re-proposed. | +| **Off-chain multisig (e.g. a Gnosis-Safe-style external wallet as the admin address)** | Moves the trust boundary off-chain and outside the contract's own audit surface; also loses the ability to express admin actions as typed, on-chain-verifiable proposals with contract-native expiration. | +| **Weighted voting (signers with different weights)** | Adds complexity not needed for the initial use case (a small, roughly-equal-trust set of operators); listed as a future enhancement rather than v1 scope. | +| **Additional timelock delay after threshold is met, before execution is allowed** | Mirrors the governance timelock question in ADR-005; deferred for the same reason — the admin multisig itself already raises the bar from one key to M-of-N, and stacking a mandatory delay on top would slow emergency pause response, which is the primary use case in v1. | + +## Consequences + +**Positive:** +- No single compromised or careless key can pause the contract, remove a + token, or change fee/discount parameters — an attacker needs to compromise + `threshold` independent keys. +- The typed `AdminAction` enum makes every proposal's effect explicit and + reviewable before signing, rather than opaque call data. +- Order-independent signing and anyone-can-execute-once-threshold-met keep + the workflow operationally simple — no coordinator role is required. +- The 24-hour expiration window bounds how long a stale, partially-signed + proposal remains a latent risk. +- `UpdateMultisig` allows the signer set to evolve (e.g. rotate a + compromised signer, raise the threshold as the team grows) through the + same auditable proposal mechanism, without a separate super-admin + override. + +**Negative / Trade-offs:** +- **This module is not currently wired into the contract's public API.** + `contracts/invoice_liquidity/src/multisig.rs` defines the data structures + and pure helper functions (`is_signer`, `has_signed`, `threshold_reached`, + `is_expired`) but `lib.rs` does not declare `pub mod multisig;`, and none + of `initialize_multisig_admin`, `propose_pause`, `sign_proposal`, or + `execute_proposal` exist as contract entry points today — `pause`/`unpause` + are still callable directly by the single admin address + (`require_admin`). A full lib.rs/storage.rs/errors.rs integration was + implemented in commit `d267e36` (`feat: implement 2-of-3 multi-sig admin + for high-security operations`) but was lost from `lib.rs` in a later merge + conflict resolution (`9e94e45`, "Replace local lib.rs with upstream/main + version to resolve merge markers"); `contracts/invoice_liquidity/src/ + tests_multisig_admin.rs` still contains the corresponding test suite but + is not declared as a module and does not compile against current `lib.rs`. + This ADR documents the design as built; re-wiring the integration + (`pub mod multisig;`, the five contract functions, the `DataKey` storage + variants, and the seven `ContractError` variants listed in + `MULTISIG_IMPLEMENTATION.md`) is tracked as follow-up work, not assumed + complete. +- `MULTISIG_WINDOW_LEDGERS` is a compile-time constant; changing the + expiration window requires a contract upgrade rather than a governance + parameter change. +- The action set is closed (`Pause`, `Unpause`, `RemoveToken`, + `SetFeeRate`, `SetMaxDiscount`, `UpdateMultisig`); adding a new + multisig-gated action requires extending the enum and redeploying, rather + than being data-driven. +- There is no signature revocation — a signer who approved a proposal cannot + retract that approval before execution or expiration. + +## Follow-up work + +- Re-add `pub mod multisig;` and the five contract entry points + (`initialize_multisig_admin`, `propose_pause`, `propose_unpause`, + `sign_proposal`, `execute_proposal`) to `lib.rs`, the storage helpers to + `storage.rs`, and the error variants (`NotAuthorizedSigner`, + `ProposalNotFound`, `AlreadySigned`, `ProposalExpired`, + `ThresholdNotReached`, `ProposalAlreadyExecuted`, `InvalidMultisigConfig`) + to `errors.rs`, per `MULTISIG_IMPLEMENTATION.md`. +- Re-enable `tests_multisig_admin.rs` as a compiled test module once the + integration lands, and confirm it still passes against current `lib.rs`. +- Route `pause`/`unpause` (and eventually token removal / fee / discount + changes) through the multisig proposal flow instead of direct + `require_admin` calls, once re-wired. +- Consider signature revocation and weighted voting as documented future + enhancements. From acebae6c5faddecc2d4d473a3886d1563d4beb05 Mon Sep 17 00:00:00 2001 From: Samuel1505 Date: Sun, 26 Jul 2026 08:48:36 +0100 Subject: [PATCH 4/4] feat: add SDK getLpInvoices method with pagination Expose the contract's list_invoices_by_lp(lp, page, page_size) view as getLpInvoices, a read-only simulation that needs no caller-supplied source account, matching the getReputation/getTopPayers convention. Wired into the free-function API, ILNClient, and the iln singleton. --- sdk/src/client.test.ts | 6 + sdk/src/client.ts | 27 +++++ sdk/src/index.ts | 1 + sdk/src/methods/lpInvoices.test.ts | 188 +++++++++++++++++++++++++++++ sdk/src/methods/lpInvoices.ts | 107 ++++++++++++++++ 5 files changed, 329 insertions(+) create mode 100644 sdk/src/methods/lpInvoices.test.ts create mode 100644 sdk/src/methods/lpInvoices.ts diff --git a/sdk/src/client.test.ts b/sdk/src/client.test.ts index c890c3f8..f991b3b6 100644 --- a/sdk/src/client.test.ts +++ b/sdk/src/client.test.ts @@ -161,6 +161,12 @@ describe("iln singleton", () => { ); }); + it("throws if getLpInvoices is called before configure", async () => { + await expect(iln.getLpInvoices("GAA")).rejects.toThrow( + "not configured" + ); + }); + it("throws if insurance methods are called before configure", async () => { const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; await expect(iln.getInsurancePoolBalance(contractId)).rejects.toThrow( diff --git a/sdk/src/client.ts b/sdk/src/client.ts index cda46f65..868d0f8a 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -89,6 +89,7 @@ export class ILNClient { private _getReputation?: typeof import("./methods/reputation.js").getReputation; private _getContractStats?: typeof import("./methods/stats.js").getContractStats; private _getTopPayers?: typeof import("./methods/topPayers.js").getTopPayers; + private _getLpInvoices?: typeof import("./methods/lpInvoices.js").getLpInvoices; private _getPoolBalance?: typeof import("./methods/insurance.js").getPoolBalance; private _getCoverage?: typeof import("./methods/insurance.js").getCoverage; private _isEnrolled?: typeof import("./methods/insurance.js").isEnrolled; @@ -232,6 +233,28 @@ export class ILNClient { return this._getTopPayers(this.rpc, this.contractId, limit, this.networkPassphrase); } + /** + * Fetch a page of invoices funded by a liquidity provider. + * + * Read-only; does not require a signer. + * + * @param lp - Stellar G… address of the liquidity provider + * @param page - Zero-indexed page number (default 0) + * @param pageSize - Number of invoices per page (default 10, capped at 50 by the contract) + * @returns Array of invoices for the requested page + */ + async getLpInvoices( + lp: string, + page: number = 0, + pageSize: number = 10 + ): Promise { + if (!this._getLpInvoices) { + this._getLpInvoices = (await import("./methods/lpInvoices.js")) + .getLpInvoices; + } + return this._getLpInvoices(this.rpc, this.contractId, lp, page, pageSize, this.networkPassphrase); + } + /** * Fetch the current insurance pool balance. * @@ -415,6 +438,10 @@ class ILNSingleton { return this.client.getTopPayers(limit); } + async getLpInvoices(lp: string, page: number = 0, pageSize: number = 10) { + return this.client.getLpInvoices(lp, page, pageSize); + } + async getInsurancePoolBalance(insurancePoolContractId: string) { return this.client.getInsurancePoolBalance(insurancePoolContractId); } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 54da0b40..4ad471f6 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -45,6 +45,7 @@ export type { } from "./events/types.js"; export { getInvoice, listInvoicesBySubmitter, listInvoicesByLP, getSubmitterInvoices } from "./methods/queries.js"; +export { getLpInvoices } from "./methods/lpInvoices.js"; export { getNftMetadata, getNftOwner } from "./methods/nft.js"; export { submitInvoice } from "./methods/submitInvoice.js"; export { transferLPPosition } from "./methods/transferLPPosition.js"; diff --git a/sdk/src/methods/lpInvoices.test.ts b/sdk/src/methods/lpInvoices.test.ts new file mode 100644 index 00000000..6a1a2a0d --- /dev/null +++ b/sdk/src/methods/lpInvoices.test.ts @@ -0,0 +1,188 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +/** + * Tests for getLpInvoices(). + * + * Mocks scValToNative (the only SDK function that touches the simulated + * retval) so we control decoded output without constructing real ScVals. + */ + +import { getLpInvoices } from "./lpInvoices.js"; +import { SorobanRpc, Keypair, Address } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// vi.mock — patch scValToNative only +// --------------------------------------------------------------------------- + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + scValToNative: vi.fn().mockImplementation(actual.scValToNative), + }; +}); + +import { scValToNative } from "@stellar/stellar-sdk"; +const mockScValToNative = scValToNative as vi.Mock; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +let LP_ADDRESS: string; +let FREELANCER: string; +let PAYER: string; +let TOKEN: string; +let CONTRACT_ID: string; + +function rawInvoice(id: number) { + return { + id: String(id), + freelancer: FREELANCER, + payer: PAYER, + token: TOKEN, + amount: "1000000", + due_date: Math.floor(Date.now() / 1000) + 86400, + discount_rate: 300, + status: "Funded", + funder: LP_ADDRESS, + funded_at: Math.floor(Date.now() / 1000), + amount_funded: "1000000", + amount_paid: "0", + referral_code: undefined, + submitter_reputation: 50, + }; +} + +beforeAll(() => { + LP_ADDRESS = Keypair.random().publicKey(); + FREELANCER = Keypair.random().publicKey(); + PAYER = Keypair.random().publicKey(); + TOKEN = Keypair.random().publicKey(); + const buf = Buffer.alloc(32); + for (let i = 0; i < 32; i++) buf[i] = i + 1; + CONTRACT_ID = Address.contract(buf).toString(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Mock server helpers +// --------------------------------------------------------------------------- + +function serverWith(sim: unknown): SorobanRpc.Server { + return { + simulateTransaction: vi.fn().mockResolvedValue(sim), + } as unknown as SorobanRpc.Server; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("getLpInvoices — success", () => { + it("returns a page of decoded invoices", async () => { + const server = serverWith({ result: { retval: {} } }); + mockScValToNative.mockReturnValue([rawInvoice(1), rawInvoice(2)]); + + const result = await getLpInvoices(server, CONTRACT_ID, LP_ADDRESS); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ id: 1n, freelancer: FREELANCER, payer: PAYER, funder: LP_ADDRESS }); + expect(result[1]).toMatchObject({ id: 2n }); + }); + + it("calls simulateTransaction once", async () => { + const server = serverWith({ result: { retval: {} } }); + mockScValToNative.mockReturnValue([]); + + await getLpInvoices(server, CONTRACT_ID, LP_ADDRESS); + expect(server.simulateTransaction).toHaveBeenCalledTimes(1); + }); + + it("defaults page to 0 and pageSize to 10", async () => { + const server = serverWith({ result: { retval: {} } }); + mockScValToNative.mockReturnValue([]); + + await getLpInvoices(server, CONTRACT_ID, LP_ADDRESS); + + const tx = (server.simulateTransaction as vi.Mock).mock.calls[0][0]; + const args = tx.operations[0].func.invokeContract().args(); + expect(args[1].u32()).toBe(0); + expect(args[2].u32()).toBe(10); + }); + + it("passes custom page and pageSize through to the contract call", async () => { + const server = serverWith({ result: { retval: {} } }); + mockScValToNative.mockReturnValue([]); + + await getLpInvoices(server, CONTRACT_ID, LP_ADDRESS, 2, 25); + + const tx = (server.simulateTransaction as vi.Mock).mock.calls[0][0]; + const args = tx.operations[0].func.invokeContract().args(); + expect(args[1].u32()).toBe(2); + expect(args[2].u32()).toBe(25); + }); +}); + +describe("getLpInvoices — empty result", () => { + it("returns an empty array when simulation returns no retval", async () => { + const server = serverWith({ result: { retval: null } }); + + const result = await getLpInvoices(server, CONTRACT_ID, LP_ADDRESS); + + expect(result).toEqual([]); + }); + + it("returns an empty array past the last page", async () => { + const server = serverWith({ result: { retval: {} } }); + mockScValToNative.mockReturnValue([]); + + const result = await getLpInvoices(server, CONTRACT_ID, LP_ADDRESS, 99, 10); + + expect(result).toEqual([]); + }); +}); + +describe("getLpInvoices — invalid address", () => { + const server = serverWith({}); + + it("throws for empty string", async () => { + await expect(getLpInvoices(server, CONTRACT_ID, "")).rejects.toThrow( + "Invalid Stellar address" + ); + }); + + it("throws for non-G addresses", async () => { + await expect( + getLpInvoices( + server, + CONTRACT_ID, + "SAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" + ) + ).rejects.toThrow("Invalid Stellar address"); + }); +}); + +describe("getLpInvoices — RPC errors", () => { + it("throws when simulation returns an error object", async () => { + const server = serverWith({ error: "contract trap", _parsed: true }); + + await expect(getLpInvoices(server, CONTRACT_ID, LP_ADDRESS)).rejects.toThrow( + "list_invoices_by_lp simulation failed" + ); + }); + + it("propagates RPC connection errors", async () => { + const server = { + simulateTransaction: vi + .fn() + .mockRejectedValue(new Error("connect ECONNREFUSED")), + } as unknown as SorobanRpc.Server; + + await expect(getLpInvoices(server, CONTRACT_ID, LP_ADDRESS)).rejects.toThrow( + "connect ECONNREFUSED" + ); + }); +}); diff --git a/sdk/src/methods/lpInvoices.ts b/sdk/src/methods/lpInvoices.ts new file mode 100644 index 00000000..cdd66977 --- /dev/null +++ b/sdk/src/methods/lpInvoices.ts @@ -0,0 +1,107 @@ +/** + * getLpInvoices — fetch a page of invoices funded by a specific liquidity + * provider from the on-chain invoice-liquidity contract. + * + * Wraps the `list_invoices_by_lp(lp, page, page_size)` view function. + * Read-only simulation — no signer or transaction fees required, and no + * caller-supplied source account needed. + */ + +import { + Contract, + SorobanRpc, + TransactionBuilder, + Account, + BASE_FEE, + scValToNative, + nativeToScVal, + Networks, +} from "@stellar/stellar-sdk"; +import { retry } from "../utils/retry.js"; +import { decodeInvoice } from "../utils/xdrDecoder.js"; +import type { Invoice } from "@invoice-liquidity/types"; + +// --------------------------------------------------------------------------- +// G-address validation +// --------------------------------------------------------------------------- + +const G_ADDRESS_RE = /^G[A-Z2-7]{55}$/; + +function isValidGAddress(address: string): boolean { + return G_ADDRESS_RE.test(address); +} + +// --------------------------------------------------------------------------- +// getLpInvoices +// --------------------------------------------------------------------------- + +/** + * Query a page of invoices funded by a liquidity provider. + * + * Performs a read-only Soroban simulation — no on-chain mutation, no + * transaction fees, and no signer required. The contract clamps + * `pageSize` to 50 regardless of the value requested. + * + * @param server - Soroban RPC server for the target network + * @param contractId - Deployed invoice-liquidity contract address + * @param lp - Stellar G… address of the liquidity provider + * @param page - Zero-indexed page number (default 0) + * @param pageSize - Number of invoices per page (default 10, capped at 50 by the contract) + * @param networkPassphrase - Stellar network passphrase (default: TESTNET) + * @returns Array of invoices for the requested page (empty past the last page) + * + * @throws When `lp` is not a valid Stellar G-address + * @throws When the Soroban simulation fails (RPC unreachable, contract not found) + * + * @example + * ```ts + * const invoices = await getLpInvoices(server, CONTRACT_ID, "GAA...", 0, 10); + * console.log(`Page has ${invoices.length} invoices`); + * ``` + */ +export async function getLpInvoices( + server: SorobanRpc.Server, + contractId: string, + lp: string, + page: number = 0, + pageSize: number = 10, + networkPassphrase: string = Networks.TESTNET +): Promise { + if (!isValidGAddress(lp)) { + throw new Error(`Invalid Stellar address: "${lp}". Must be a G… public key.`); + } + + const contract = new Contract(contractId); + const op = contract.call( + "list_invoices_by_lp", + nativeToScVal(lp, { type: "address" }), + nativeToScVal(page, { type: "u32" }), + nativeToScVal(pageSize, { type: "u32" }) + ); + + const sourceAccount = new Account( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "0" + ); + + const simTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(simTx)); + + if (SorobanRpc.Api.isSimulationError(sim)) { + throw new Error(`list_invoices_by_lp simulation failed: ${sim.error}`); + } + + if (!sim.result?.retval) { + return []; + } + + const rawArr = scValToNative(sim.result.retval) as Record[]; + return rawArr.map((raw) => decodeInvoice(raw)); +}