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.)
| # | Criterion | Test | Aset |
|---|---|---|---|
| 1 | No unilateral control | Can the operator move assets without the user? | ❌ No (money-path destinations fixed) |
| 2 | Non-reconstructability | If servers are fully breached, are assets safe? | ✅ We hold no user keys |
| 3 | No persistent custody | Does a usable key persist in DB/backups? | ✅ Not held |
| 4 | Auth ≠ Authority | Does authentication alone grant asset access? | ✅ Separated |
| 5 | Exit right | If 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. |
| 6 | Independent recovery | Can the operator alone gather all recovery shares? | ✅ (verify if managed-wallet option B is adopted) |
2. Key model (cold / hot)
| Key | Role | Powers | Bounds |
|---|---|---|---|
| Cold — Admin Safe | DEFAULT_ADMIN_ROLE | role management, governance propose/execute, lifecycle | all timelocked · direct WIND_DOWN set blocked · Gnosis Safe 3-of-5 (v3-37) |
| Cold — Pauser Safe | PAUSER_ROLE | pause / emergency freeze | instant but halt-only + auto-expiring (v3-28) · separate Gnosis Safe 2-of-3 (v3-37) |
| Hot (Service Key) | Aset Lambda (ORACLE_ROLE) | updateNAV · approveRedemption · settleYield | fixed destinations → no unilateral control |
| Partner | fund_wallet (YIELD_DEPOSITOR_ROLE) | depositYield · fundRedemption | funds IN only (no OUT) |
| Reserve (Aset ops) | reserve_wallet (RESERVE_FUNDER_ROLE) | fundRedemption | funds 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 address → theft 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.
| Item | Mutability | Basis (code) |
|---|---|---|
Redemption payout = request.investor | 🔒 immutable | The 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) | 🔒 immutable | settleYield 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 path | governance changeable | In 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/executeUpgradebehind_authorizeUpgrade). Everything else is already updatable off-chain: stablecoins via existingadd/removeStablecoinsetters, oracle source / fee / validation in Lambda, andupdateNAVbounds in the immutable core. - Why only KYC: fully-immutable
Clonesmeans 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_ADMIN→ 3-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 checkshasRole, 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
| Function | Bound |
|---|---|
approveRedemption | gate 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 |
updateNAV | NAV ≤ 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_wallet→ not 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):
- ✅
updateNAVdeviation 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 (— removed 2026-08-31 with the reserve it drew on. Epoch pools keep their permissionless settle; instant pools do not have one. See #5.claimRedemptionFallback) (v3-31, #5) - ✅
PlatformKYCSoulboundis a UUPS proxy + timelocked upgrade (proposeUpgrade/executeUpgrade) (v3-30); core money-path contracts unchanged
Still open:
- ✅
unclaimedYieldscope — 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 reachreserveBalance/ principal (separate state) and can only send to the immutabletreasuryWallet/fundFeeWallet→ no exfiltration. ⚠️ Updated 2026-07-31 (v3-102): the fee-vs-investor-yield split used to rest on a Lambda call-ordering invariant (distributeYieldbeforewithdrawFees);settleYieldnow checksnet + fees ≤ unclaimedYieldin one call, so it is an on-chain bound. (The name still cannot beunclaimedFees— it holds gross betweendepositYieldandsettleYield.) - ❓ v3-30 caveat: legal/audit confirm an upgradeable KYC (gates
REVOKEDredemption) 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)
Related
- 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