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
5 changes: 3 additions & 2 deletions docs/OPERATOR_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,9 @@ on-chain entrypoint in the current release.

- Use a multisig wallet or a governed contract as `admin` at all times.
- Never use a single-signer hot wallet as `admin` in production.
- `transfer_admin` requires the current admin's authorization — test the
rotation on Testnet before executing on Mainnet.
- Admin rotation is two-step: `propose_admin` requires the current admin's
authorization, and `accept_admin` requires the proposed successor's
authorization. Test both steps on Testnet before executing on Mainnet.

### `migrate()` is not a no-op

Expand Down
12 changes: 9 additions & 3 deletions docs/adr/ADR-002-auth-boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Status:** Accepted
**Date:** 2026-03-28
**Refs:** `escrow/src/lib.rs` — `init`, `fund`, `settle`, `withdraw`, `claim_investor_payout`, `sweep_terminal_dust`, `set_legal_hold`, `transfer_admin`
**Refs:** `escrow/src/lib.rs` — `init`, `fund`, `settle`, `withdraw`, `claim_investor_payout`, `sweep_terminal_dust`, `set_legal_hold`, `propose_admin`, `accept_admin`

---

Expand All @@ -20,17 +20,23 @@ Multiple principals interact with the escrow (admin, SME, investors, treasury).
| `claim_investor_payout` | `investor` |
| `sweep_terminal_dust` | `treasury` (immutable after init) |
| `set_legal_hold`, `clear_legal_hold` | `admin` |
| `update_funding_target`, `update_maturity`, `transfer_admin`, `migrate` | `admin` |
| `update_funding_target`, `update_maturity`, `migrate` | `admin` |
| `propose_admin` | current `admin` |
| `accept_admin` | pending admin stored in `DataKey::PendingAdmin` |
| `record_sme_collateral_commitment` | `sme_address` |

`admin` and `treasury` are stored immutably at `init` (except `admin` which rotates via `transfer_admin`). There is no superuser that can act as all roles simultaneously unless the same key is used for multiple roles — which is a deployment concern, not a contract concern.
`admin` and `treasury` are stored at `init`; `treasury` is immutable, while `admin` rotates only through a two-step handover. The current admin calls `propose_admin(new_admin)`, which stores `DataKey::PendingAdmin` without changing authority. The pending address must then call `accept_admin()`, which requires its own authorization, promotes it into `InvoiceEscrow::admin`, and clears `DataKey::PendingAdmin`. The deprecated `transfer_admin` shim must not be treated as an immediate transfer; it only creates the pending proposal.

There is no superuser that can act as all roles simultaneously unless the same key is used for multiple roles — which is a deployment concern, not a contract concern.

## Consequences

- A compromised investor key cannot settle or sweep funds.
- A compromised SME key cannot change the admin or sweep dust.
- Treasury auth on `sweep_terminal_dust` means the admin cannot drain the contract as "dust" unless it is also the treasury.
- Legal hold can only be set/cleared by admin, so governance controls compliance freezes.
- Admin authority changes only after both the current admin and successor have authorized. A typo in `new_admin` can be corrected by a new `propose_admin` call and does not lock admin-gated paths.
- `DataKey::PendingAdmin` is cleared after successful acceptance so stale proposals cannot be reused.

## Rejected alternatives

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/ADR-004-legal-hold.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ There is no timelock or automatic expiry — clearing always requires an explici
## Rejected alternatives

- **Timelock on hold:** adds complexity and a false sense of safety; governance should decide duration.
- **Separate hold roles (compliance officer vs admin):** out of scope for v1; can be added via `transfer_admin` to a multisig that includes a compliance key.
- **Separate hold roles (compliance officer vs admin):** out of scope for v1; can be added by rotating admin authority through `propose_admin` and `accept_admin` to a multisig that includes a compliance key.
5 changes: 3 additions & 2 deletions docs/audit-handoff-escrow.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ These map directly to property tests in `escrow/src/test/properties.rs` and `tes

| Role | Stored at | Entrypoints authorized |
|------|-----------|------------------------|
| `admin` | `InvoiceEscrow::admin` (rotatable via `transfer_admin`) | `init`, `set_legal_hold`, `clear_legal_hold`, `update_maturity`, `update_funding_target`, `transfer_admin`, `migrate`, `bind_primary_attestation_hash`, `append_attestation_digest` |
| `admin` | `InvoiceEscrow::admin` (rotatable via `propose_admin` + `accept_admin`) | `init`, `set_legal_hold`, `clear_legal_hold`, `update_maturity`, `update_funding_target`, `propose_admin`, `accept_admin`, `migrate`, `bind_primary_attestation_hash`, `append_attestation_digest` |
| `sme_address` | `InvoiceEscrow::sme_address` (immutable) | `settle`, `withdraw`, `record_sme_collateral_commitment` |
| `investor` | per-call argument (verified via `require_auth`) | `fund`, `fund_with_commitment`, `claim_investor_payout` |
| `treasury` | `DataKey::Treasury` (immutable) | `sweep_terminal_dust` |
Expand Down Expand Up @@ -102,7 +102,8 @@ Read-only getters are never blocked. Only `admin` can set or clear the hold. The
| `set_legal_hold(true)` | `LegalHoldChanged` | `legalhld` | Alert compliance dashboard; suspend investor UI funding flows |
| `set_legal_hold(false)` / `clear_legal_hold` | `LegalHoldChanged` | `legalhld` | Resume operations; notify relevant parties |
| `update_maturity` | `MaturityUpdatedEvent` | `maturity` | Update off-chain settlement schedule; re-notify investors if material |
| `transfer_admin` | `AdminTransferredEvent` | `admin` | Update key registry and access control records |
| `propose_admin` | `AdminProposedEvent` | `adm_prop` | Notify proposed successor; keep current admin active until acceptance |
| `accept_admin` | `AdminTransferredEvent` | `admin` | Update key registry and access control records; confirm pending proposal cleared |
| `update_funding_target` | `FundingTargetUpdated` | `fund_tgt` | Update off-chain target display; re-evaluate investor communications |
| `record_sme_collateral_commitment` | `CollateralRecordedEvt` | `coll_rec` | Store in compliance/risk system; **do not treat as enforced on-chain lock** |
| `sweep_terminal_dust` | `TreasuryDustSwept` | `dust_sw` | Reconcile treasury balance; log sweep amount and token address |
Expand Down
37 changes: 22 additions & 15 deletions docs/escrow-legal-hold.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Operations that are **not** gated (read-only or metadata-only):
- `get_*` accessors
- `record_sme_collateral_commitment` (metadata record, no token movement)
- `bind_primary_attestation_hash` / `append_attestation_digest`
- `update_maturity`, `update_funding_target`, `transfer_admin`, `migrate`
- `update_maturity`, `update_funding_target`, `propose_admin`, `accept_admin`, `migrate`

---

Expand Down Expand Up @@ -73,7 +73,8 @@ as a governed address:
`set_legal_hold`.
- **Off-chain playbook** covering: who may initiate a hold, required evidence,
maximum hold duration, escalation path if the admin key is lost or
compromised, and emergency recovery via `transfer_admin` + governance vote.
compromised, and emergency recovery via `propose_admin` + `accept_admin`
with governance approval.

Without one of the above, a single compromised admin key can freeze all
investor funds with no on-chain recourse.
Expand All @@ -83,7 +84,7 @@ investor funds with no on-chain recourse.
| Requirement | Rationale |
|---|---|
| Governed `admin` at `init` (multisig or DAO contract) | Single EOA admin + hold + key loss = indefinite fund lock |
| Documented recovery playbook | Operators must know how to execute `transfer_admin` under hold |
| Documented recovery playbook | Operators must know how to execute `propose_admin` and `accept_admin` under hold |
| Testnet rotation drill before mainnet | Confirms new admin can `clear_legal_hold` after rotation |
| Indexer monitoring of `LegalHoldChanged` | Detect holds that exceed policy duration |

Expand All @@ -106,29 +107,35 @@ lost or destroyed:

**On-chain recovery (only path):**

1. Governance executes [`transfer_admin`](../../escrow/src/lib.rs) using a
1. Governance executes [`propose_admin`](../../escrow/src/lib.rs) using a
**still-available** current-admin authorization (e.g. remaining multisig
signers or DAO vote output). This entrypoint is **not** blocked by the hold.
2. The **new** admin calls `clear_legal_hold` (or `set_legal_hold(false)`).
3. Risk-bearing flows resume (`settle`, `withdraw`, etc.).
2. The proposed successor executes [`accept_admin`](../../escrow/src/lib.rs)
with its own authorization. This promotes the successor into
`InvoiceEscrow::admin` and clears `DataKey::PendingAdmin`.
3. The **new** admin calls `clear_legal_hold` (or `set_legal_hold(false)`).
4. Risk-bearing flows resume (`settle`, `withdraw`, etc.).

**Invariant:** a hold is always clearable by the current admin; recovery
requires controlling admin authority — not merely controlling the SME or
treasury roles.

If governance cannot produce a valid current-admin signature for
`transfer_admin`, funds remain locked until off-chain legal or operational
recovery restores signing capability. This is why single-signer production
admins are prohibited.
`propose_admin`, funds remain locked until off-chain legal or operational
recovery restores signing capability. If a proposal was created with the wrong
address, the current admin can overwrite it by calling `propose_admin` again.
This is why single-signer production admins are prohibited.

---

## Admin rotation during a hold

`transfer_admin` is not gated by the hold. This is intentional: if the current
admin is compromised or unresponsive, governance must be able to rotate the
admin key even while a hold is active. After rotation the new admin inherits
the hold state and must explicitly call `clear_legal_hold` to unfreeze.
`propose_admin` and `accept_admin` are not gated by the hold. This is
intentional: if the current admin is compromised or unresponsive, governance
must be able to rotate the admin key even while a hold is active. The handover
still requires both the current admin and successor to authorize. After
acceptance the new admin inherits the hold state and must explicitly call
`clear_legal_hold` to unfreeze.

---

Expand Down Expand Up @@ -156,8 +163,8 @@ The matrix in `escrow/src/tests/legal_hold.rs` covers:
5. Hold defaults to `false` after `init`.
6. Hold persists across status transitions (no bypass via state change).
7. Hold can be toggled and re-blocks operations after re-set.
8. Hold persists after `admin transfer`; new admin must explicitly clear it.
8. Hold persists after two-step admin handover; new admin must explicitly clear it.
9. Edge cases: hold check fires before amount / status / auth validation.
10. Non-gated ops (`update_maturity`, `transfer_admin`, getters) are not blocked.
10. Non-gated ops (`update_maturity`, `propose_admin`, `accept_admin`, getters) are not blocked.
11. Claim idempotency survives a hold toggle.
12. Single hold toggle blocks all gated entrypoints in separate escrows.
5 changes: 3 additions & 2 deletions docs/escrow-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ their principal:
| `cancel_funding()` | Admin only |
| `set_legal_hold()` | Admin only |
| `update_maturity()` | Admin only |
| `transfer_admin()` | Admin only |
| `propose_admin()` | Admin only |
| `accept_admin()` | Pending admin only |

The SME role represents the off-chain settlement policy authority. The admin role
handles on-chain configuration and compliance controls.
Expand Down Expand Up @@ -180,4 +181,4 @@ have been refunded.
- **Snapshot immutability:** `FundingCloseSnapshot` is written once at the
`0 → 1` transition and must remain readable after `settle()` or `withdraw()`.
- **Refund double-spend prevention:** `InvestorContribution` is zeroed before the
token transfer; a second `refund()` call finds contribution `0` and panics.
token transfer; a second `refund()` call finds contribution `0` and panics.
28 changes: 16 additions & 12 deletions docs/escrow-security-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Every state-mutating entrypoint and the identity required to authorize it.
| Entrypoint | Required signer | Auth call site | Notes |
|---|---|---|---|
| `init` | `admin` (caller-supplied) | `admin.require_auth()` | One-time; panics if escrow exists |
| `transfer_admin` | current `escrow.admin` | `escrow.admin.require_auth()` | `new_admin` must differ; overwrites stored admin |
| `propose_admin` | current `escrow.admin` | `escrow.admin.require_auth()` | `new_admin` must differ; writes `DataKey::PendingAdmin` only |
| `accept_admin` | `DataKey::PendingAdmin` | `pending.require_auth()` | Promotes pending address into `escrow.admin`; clears pending key |
| `update_maturity` | `escrow.admin` | `escrow.admin.require_auth()` | Only in `status == 0` |
| `update_funding_target` | `escrow.admin` | `escrow.admin.require_auth()` | Only in `status == 0`; `new_target >= funded_amount` |
| `set_legal_hold` / `clear_legal_hold` | `escrow.admin` | `escrow.admin.require_auth()` | No timelock; no multisig enforced on-chain |
Expand All @@ -40,7 +41,7 @@ All `get_*` and `is_*` functions carry no `require_auth`. They expose full escro

### 2.1 Admin (`InvoiceEscrow::admin`)

- Set at `init`; mutable only via `transfer_admin` (requires current admin signature).
- Set at `init`; mutable only via `propose_admin` plus `accept_admin` (current admin and successor signatures).
- Controls: hold activation, allowlist, attestation binding, maturity, funding target, schema migration (future).
- **Risk**: a single EOA admin can indefinitely freeze funds via `set_legal_hold`. Production deployments **must** use a governed contract or multisig at this address. There is no on-chain escape hatch if the admin key is lost or malicious.

Expand Down Expand Up @@ -189,10 +190,11 @@ This creates a window where `funded_amount` > actual token balance (unfunded com
There is no timelock, no council override, and no programmatic expiry. A
compromised or malicious admin can freeze `settle`, `withdraw`,
`claim_investor_payout`, and `sweep_terminal_dust` indefinitely. **Recovery:**
governance executes `transfer_admin` (not blocked by the hold), then the new
admin calls `clear_legal_hold`. See `docs/escrow-legal-hold.md` and ADR-004.
Production deployments **must** use a governed admin (multisig or DAO) so a
single lost key cannot strand funds without a documented rotation playbook.
governance executes `propose_admin` and the successor executes `accept_admin`
(both not blocked by the hold), then the new admin calls `clear_legal_hold`.
See `docs/escrow-legal-hold.md` and ADR-004. Production deployments **must**
use a governed admin (multisig or DAO) so a single lost key cannot strand funds
without a documented rotation playbook.

### 5.4 Storage type mismatch: AllowlistActive vs. InvestorAllowlisted

Expand All @@ -204,10 +206,11 @@ single lost key cannot strand funds without a documented rotation playbook.

### 5.6 Admin key loss is unrecoverable without admin authority

There is no guardian, recovery address, or protocol DAO escape hatch beyond
`transfer_admin`. If **all** current-admin signers are lost while a legal hold
is active, funds remain blocked until signing capability is restored off-chain.
If at least one signer remains, rotate via `transfer_admin` then clear the hold.
There is no guardian, recovery address, or protocol DAO escape hatch beyond the
two-step admin handover. If **all** current-admin signers are lost while a legal
hold is active, funds remain blocked until signing capability is restored
off-chain. If at least one signer remains, rotate via `propose_admin` and
`accept_admin`, then clear the hold.
See `docs/escrow-legal-hold.md` § "Failure mode: hold + lost admin key".

### 5.7 Over-funding is intentional and unbound above the target
Expand Down Expand Up @@ -248,7 +251,8 @@ and does not weaken the auth boundary. Refactors must not move step 3 above step
| Entrypoint | Signer | Pre-auth reads (no writes) | `require_auth` | First mutation |
|---|---|---|---|---|
| `init` | `admin` | — | line ~549 (`admin`) | `DataKey::Escrow` set |
| `transfer_admin` | current `escrow.admin` | `get_escrow` | line ~1427 | `DataKey::Escrow` set |
| `propose_admin` | current `escrow.admin` | `get_escrow` | line ~1888 | `DataKey::PendingAdmin` set |
| `accept_admin` | `DataKey::PendingAdmin` | pending read, `get_escrow` after auth | line ~1917 | `DataKey::Escrow` set |
| `update_maturity` | `escrow.admin` | `get_escrow` | line ~1400 | `DataKey::Escrow` set |
| `update_funding_target` | `escrow.admin` | `get_escrow` | line ~1007 | `DataKey::Escrow` set |
| `set_legal_hold` / `clear_legal_hold` | current `escrow.admin` | `get_escrow` | line ~940 | `DataKey::LegalHold` set |
Expand All @@ -271,7 +275,7 @@ Line numbers refer to `escrow/src/lib.rs` at schema version 5; re-audit after re
| Entrypoint | Test location |
|---|---|
| `init` | `escrow/src/tests/init.rs` |
| `transfer_admin`, `fund`, `fund_with_commitment`, `settle`, `withdraw`, `claim_investor_payout`, attestation, allowlist, sweep | `escrow/src/tests/admin.rs` § auth audit |
| `propose_admin`, `accept_admin`, `fund`, `fund_with_commitment`, `settle`, `withdraw`, `claim_investor_payout`, attestation, allowlist, sweep | `escrow/src/tests/admin.rs` § auth audit |
| `update_maturity`, `update_funding_target`, collateral | `escrow/src/tests/admin.rs` |
| `set_legal_hold`, `clear_legal_hold` | `escrow/src/tests/legal_hold.rs` |
| `bind_primary_attestation_hash`, `append_attestation_digest` | `escrow/src/tests/attestations.rs` |
Expand Down
19 changes: 16 additions & 3 deletions docs/escrow-sim-stellar-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,7 @@ stellar contract invoke \
--new_maturity 1767139200
```

### `transfer_admin`
### `propose_admin`

**Auth required:** current `admin`.

Expand All @@ -758,10 +758,22 @@ stellar contract invoke \
--id "$CONTRACT_ID" \
--source admin \
--network local \
-- transfer_admin \
-- propose_admin \
--new_admin "$NEW_ADMIN"
```

### `accept_admin`

**Auth required:** pending admin from `propose_admin`.

```bash
stellar contract invoke \
--id "$CONTRACT_ID" \
--source new_admin \
--network local \
-- accept_admin
```

### `sweep_terminal_dust`

**Auth required:** `treasury`. Only in terminal states (status `2` or `3`). Capped at
Expand Down Expand Up @@ -884,7 +896,8 @@ for that address. For the common case where `--source` is the authorizing addres
| `sweep_terminal_dust` | `treasury` | `treasury` |
| `set_legal_hold` / `clear_legal_hold` | `admin` | `admin` |
| `update_maturity` | `admin` | `admin` |
| `transfer_admin` | `admin` (current) | `admin` |
| `propose_admin` | `admin` (current) | `admin` |
| `accept_admin` | pending admin | new admin |
| `bind_primary_attestation_hash` | `admin` | `admin` |
| `append_attestation_digest` | `admin` | `admin` |
| `update_funding_target` | `admin` | `admin` |
Expand Down
Loading
Loading