Skip to content

Custody & Non-Custodial Guarantees

SoT for custody. Consolidates the non-custodial test, key model, money-path immutability, upgrade governance, and emergency-freeze exit-right in one place. 04/08/09 keep only their feature-level mentions and cross-reference here.

One-line principle

Custody is judged not by "do we hold the assets" but by "can the operator move user funds unilaterally." Aset cannot move user funds on its own → non-custodial.

Exit conditions with external reserve

The seven-day claimRedemptionFallback is removed. Cycle settlement and claims depend on funded amounts. The move to an operational SLA is approved, but this document defines neither a response deadline nor a payout guarantee. This does not remove the separate epoch settlement and claim-on-behalf functions.

Sources: PoolLedgerLib.releaseReserveShare, GovernanceLib.executeWindDown, RedemptionLib, and handoff sections 1-4, 7-9, 8-2 and 8-3. Replacement legal custody wording remains a separate review item.

1. Non-custodial test (6 criteria)

Judges whether — even if the operator holds contract upgrade/operation rights — the state stays "operator cannot move user funds unilaterally." (#1 No unilateral control and #5 Exit right are the crux.)

#CriterionTestAset
1No unilateral controlCan the operator move assets without the user?❌ No (money-path destinations fixed)
2Non-reconstructabilityIf servers are fully breached, are assets safe?✅ We hold no user keys
3No persistent custodyDoes a usable key persist in DB/backups?✅ Not held
4Auth ≠ AuthorityDoes authentication alone grant asset access?✅ Separated
5Exit rightIf the company dies, can users still control assets?🔴 TODO(review, 2026-08-31) — the verdict is not a docs edit. Freeze exit-right (v3-28) is unchanged and in core. Epoch pools keep permissionless executeEpoch + claim-on-behalf. Instant pools lost theirs: claimRedemptionFallback settled from the pool reserve, the reserve is routed to reserve_wallet at deposit, and the function is removed — so an instant-pool holder depends on that wallet funding their request. That is an operational commitment, not a contract one.
6Independent recoveryCan the operator alone gather all recovery shares?✅ (verify if managed-wallet option B is adopted)

2. Key model (cold / hot)

KeyRolePowersBounds
Cold — Admin SafeDEFAULT_ADMIN_ROLErole management, governance propose/execute, lifecycleall timelocked · direct WIND_DOWN set blocked · Gnosis Safe 3-of-5 (v3-37)
Cold — Pauser SafePAUSER_ROLEpause / emergency freezeinstant but halt-only + auto-expiring (v3-28) · separate Gnosis Safe 2-of-3 (v3-37)
Hot (Service Key)Aset Lambda (ORACLE_ROLE)updateNAV · approveRedemption · settleYieldfixed destinations → no unilateral control
Partnerfund_wallet (YIELD_DEPOSITOR_ROLE)depositYield · fundRedemptionfunds IN only (no OUT)
Reserve (Aset ops)reserve_wallet (RESERVE_FUNDER_ROLE)fundRedemptionfunds IN only. Holds the reserve leg of every deposit and meets instant shortfalls under the SLA that replaced the on-chain exit (v3-28, D4-a)

Why a hot key is still non-custodial

Protection comes from "fixed destinations," not key secrecy. Even if the key is stolen, an attacker can only send to the rightful holder / fixed addresstheft is impossible (worst case = timing grief / DoS). So even single-cold-key operation stays non-custodial. Role detail: 09-rbac.

3. Money-path immutability (v3-27)

End-to-end money map & canonical wallet naming: 23-money-path.

User-fund paths (principal · yield) cannot be changed by the operator via setter or upgrade. Only our own (fee) path is changeable.

ItemMutabilityBasis (code)
Redemption payout = request.investor🔒 immutableThe holder-verified address whose LP was locked via transferFrom at request (= msg.sender stored at request). No setter to change it + payout function has no destination parameter. claim-on-behalf still pays this address.
Yield claim = msg.sender (distribution = holdings-derived)🔒 immutablesettleYield names no recipient for the holders' leg — it only funds the accrued liability; claimYield pays the caller, amount derived from their own accrual span
Reserve pathgovernance changeableIn the current external-reserve implementation, the reserve_bps share of each deposit goes to reserve_wallet and the remainder goes to fund_wallet. The in-pool reserve counter and getter are removed. The ratio specifies routing, not retention. An external balance does not automatically fund a pool payout. reserveWallet changes use the 7-day timelock.
Fee receipt (treasury)🟡 mutable (7d timelock)Our money. settleYield's fee legs are bounded by unclaimedYield + fixed destinations (not params) → cannot touch principal/reserve
fund_wallet (partner-remainder destination)🟡 mutable (7d timelock)Partner wallet change (propose/execute)
Oracle address etc. (periphery)🟡 mutable (timelock)Operationally needed; abuse mitigated by timelock

Principle: an operator-settable "investor payout address" simply does not exist — user-fund destinations are hard-coded in logic to requester / holder.

4. Upgrade governance — core immutable + KYC-only upgradeable (v3-27 / v3-30)

  • Current: PlatformPool = EIP-1167 Clones (minimal proxy) → no upgrade mechanism at all. The core money-path is effectively immutable.
  • Direction (decided, v3-30 — targeted modularization) — now implemented: the core money-path stays immutable; the only on-chain piece made upgradeable is the KYC contract (PlatformKYCSoulbound) → UUPS proxy + timelock — live in code (UUPSUpgradeable + proposeUpgrade/executeUpgrade behind _authorizeUpgrade). Everything else is already updatable off-chain: stablecoins via existing add/removeStablecoin setters, oracle source / fee / validation in Lambda, and updateNAV bounds in the immutable core.
  • Why only KYC: fully-immutable Clones means any on-chain change = redeploy + migrate every pool. KYC/compliance is the one on-chain piece that genuinely evolves → make just that upgradeable so compliance changes need no pool migration. Invariants: the KYC periphery may gate eligibility but can never move funds or change a payout destination; safety bounds stay in core; the KYC upgrade is timelocked (exit window, #5).
  • ⚠️ Caveat: KYC gates redemption for REVOKED (AML) → an upgradeable KYC touches the exit right (#5). Mitigated by timelock + the eligibility-only invariant; legal/audit to confirm.
  • Trade-off: a core bug still cannot be patched in place → redeploy a new pool + migrate users. Immutability prevents fund redirection throughout.
  • Multisig (v3-37): a non-upgradeable money-path means even a single key cannot redirect funds → single cold key + timelock already satisfies non-custody (so multisig is not required; pool-create multisig UI removed). Post the 2026-06-23 security review we nonetheless adopt Gnosis Safe as defense-in-depth: DEFAULT_ADMIN3-of-5 cold Safe; PAUSER → a separate 2-of-3 cold Safe (lower threshold for fast emergency freeze — safe because freeze is halt-only + auto-expiring). Thresholds provisional. Config-only — no contract change (Admin & Pauser are already distinct roles; the contract checks hasRole, indifferent to EOA vs Safe).

A unilateral, instant, unlimited upgrade right would allow an "upgrade that points destinations to an attacker" → effectively custodial. So the core is made immutable.

5. Emergency-freeze exit-right (v3-28)

Instant freeze is kept but never indefinite:

  • A full-freeze that blocks exit lasts at most 72h → redemption auto-allowed afterward
  • Other (deposit, etc.) freeze window 7 days
  • Extend / unfreeze only via governance + timelock (asymmetric: easy to halt, hard to trap)

Freeze time limits describe when a freeze stops blocking exits. They do not guarantee external funding or principal repayment.

6. Service Key bounded operations

FunctionBound
approveRedemptiongate paying the original requester (request.investor); fixed destination
settleYield (net leg)holdings-proportional only (no recipient param)
settleYield (fee legs)destinations = immutable treasuryWallet / per-pool fundFeeWallet (not params; treasury is 7-day timelocked) → cannot redirect funds and cannot reach reserveBalance / principal (separate state, structurally unreachable) — guaranteed on-chain by the immutable, non-upgradeable money-path. They draw from unclaimedYield, a shared gross-yield accumulator (depositYield adds gross; the net credit and both fee legs draw it down). Since v3-102 all three are bounded against it in one call, so a fee leg can no longer exceed the fee portion by call-ordering — the split is structural, not a Lambda convention
updateNAVNAV ≤ 1.0 (par) · 24h timelock on decrease · ✅ reserveConsumed != 0 reverts InvalidAmount (R8, shipped) — the reserve is unreachable from NAV structurally, not because the oracle happens to send 0. pendingReserveConsumed has one writer, past that guard, so the consumed > 0 debit inside applyPendingNav is dead code (slated for removal). ✅ deviation cap (navDeviationCapBps) / circuit breaker (circuitBreakerTripped) / staleness (navStalenessSeconds) now implemented in core (v3-32); hot-key-called so the bound lives in the immutable core

7. Recovery / Wind-down

  • Partner distress or absence may lead to a wind-down proposal and timelock. The current executeWindDown() preserves oracle NAV. It does not reprice from external reserves; actual payout requires funding and the redemption process.
  • ⚠️ Only the reserve (~10%) is recoverable on-chain. The remainder sits in the partner fund_walletnot recoverable on-chain → off-chain legal action required. (investor disclosure)
  • Detail: 04-pool-models → Emergency Wind-Down.

8. Regulatory basis

  • Non-custody is the premise for avoiding VASP / money-transmitter licensing in TH / MY (→ legal confirmation needed).
  • However, pooling funds — even briefly — into an Aset-controlled hot wallet, or taking float, could be deemed custodial. (not currently done)
  • Detailed advisory item: Legal Review Required A-1 (VASP / non-custodial).

9. Open items

Implemented in core (2026-07-02 audit-confirmed against apps/contract/src):

  • updateNAV deviation cap (navDeviationCapBps) / circuit breaker (circuitBreakerTripped) / staleness (navStalenessSeconds) live in the immutable core (v3-32)
  • ✅ freeze time-bound (FREEZE_EXIT_WINDOW = 72h, FREEZE_MAX_DURATION = 7d) (v3-28) — unchanged
  • 🔴 permissionless timelocked redemption fallback (claimRedemptionFallback) (v3-31, #5)removed 2026-08-31 with the reserve it drew on. Epoch pools keep their permissionless settle; instant pools do not have one. See #5.
  • PlatformKYCSoulbound is a UUPS proxy + timelocked upgrade (proposeUpgrade/executeUpgrade) (v3-30); core money-path contracts unchanged

Still open:

  • unclaimedYield scope — confirmed in code (2026-07-02): it is a shared gross-yield accumulator, not a fee-only variable. On-chain guarantees: the fee legs cannot reach reserveBalance / principal (separate state) and can only send to the immutable treasuryWallet / fundFeeWalletno exfiltration. ⚠️ Updated 2026-07-31 (v3-102): the fee-vs-investor-yield split used to rest on a Lambda call-ordering invariant (distributeYield before withdrawFees); settleYield now checks net + fees ≤ unclaimedYield in one call, so it is an on-chain bound. (The name still cannot be unclaimedFees — it holds gross between depositYield and settleYield.)
  • ❓ v3-30 caveat: legal/audit confirm an upgradeable KYC (gates REVOKED redemption) does not undermine exit-right #5
  • ✅ DEFAULT_ADMIN unilateral role-grant — accepted: single cold key + 7/30-day timelock, no multisig (theft impossible via immutable money-path; griefing bounded by v3-32) (v3-32)
  • 09-rbac — on-chain roles · cold/hot key detail (v3-27/28 source)
  • 08-smart-contracts — Timelock canonical table · Service Key / Pauser / Admin functions
  • 04-pool-models — Fund Flow & Custody · fund_wallet · reserve · Wind-down
  • 14-decisions — v3-26 / v3-27 / v3-28