Smart Contract Architecture
📌 Status — largely built; deploy + QA is what remains (2026-07/08)
The v3.0 architecture on this page is implemented: four contracts, the epoch engine, the governance triples, the non-custodial safety set. What is still moving is deployment and QA, not the design.
⚠️ A pool is a non-upgradeable Clones proxy pinned to the implementation it was created with, so "shipped" and "true for a given pool" are different claims — a fix reaches new pools only. Per-round addresses live in apps/contract/sepolia.md; the two most recent rounds are 2026-08-04 and 2026-08-06. v2.x architecture is deprecated.
Contract relationships, roles, fund flows, and structural details for v3.0.
📑 Code-level reference
For the per-contract function list, architecture diagrams, and on-chain security mechanisms generated from the actual source (apps/contract/src/), see Contract Code Reference. This page covers the rationale and decisions; that page covers the code as written.
Current source and legacy deployments
The external-reserve implementation sends the reserveBps share to reserveWallet and the remainder to fundWallet. It does not accumulate an in-pool reserve balance.
The current source has no claimRedemptionFallback. There is no seven-day fallback payout guarantee. The separate epoch settlement and claim-on-behalf paths also depend on actual funding.
The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process.
The reserve-balance formulas in this section are legacy implementation history. They do not apply to the external-reserve implementation.
Contract Registry
v3.0 uses 4 active contracts (down from 6 in v2.x). PlatformReceiptNFT and PlatformEscrow both removed — Receipt NFT eliminated (v3-03, LP is proof of deposit), Escrow absorbed into PlatformPool (v3-11, 10/90 split inlined into deposit()).
PlatformPooladditionally links several externaldelegatecall'd libraries to stay under the EIP-170 size limit — these are an implementation detail of the same 4 contracts, not new on-chain services. See Implementation Libraries (EIP-170) below.
PlatformPool — Core ACTIVE
The main pool contract. Holds investor funds (briefly), mints LP tokens, manages reserve, handles yield distribution, processes redemptions, and supports wind-down.
Key responsibilities:
- Receives investor deposits (USDC/USDT/DAI)
- Mints LP tokens to investors immediately on deposit
- Retains
reserve_bpsof each deposit; sends remainder tofund_wallet - Receives yield via
depositYield()(role-gated to partner) - Distributes yield to LP holders pro-rata and pays the fee legs via
settleYield() - Processes redemptions with NAV snapshot + reserve check + PENDING_RESERVE fallback (redemption logic lives in
RedemptionLib— see Implementation Libraries) - Supports emergency wind-down (60+30 day timelock)
Roles: DEFAULT_ADMIN_ROLE ORACLE_ROLE PAUSER_ROLE YIELD_DEPOSITOR_ROLE RESERVE_FUNDER_ROLE
Key parameters (per pool): reserve_bps, fund_wallet, treasury_wallet, reserve_wallet
PlatformLPToken — ERC-20 ACTIVE
LP token representing investor positions. One contract per pool.
Key responsibilities:
- 18 decimals, permissionless secondary transfers (no allowlist — v3-58)
- Mint role granted to PlatformPool only
- Pausable for emergencies (freezes secondary transfers only)
- Auto yield settlement on transfers (calls
onLpTransfer(from, to, value)on the Pool when LP moves, before the tracked balances change) — see Yield Settlement on LP Transfer
Roles: DEFAULT_ADMIN_ROLE MINTER_ROLE
Note (v3-14): Each pool always has exactly one LP token. Tranched products are modeled as 2-3 separate SINGLE pools linked via tranche_group_id, each with its own LP. No two-LP-per-pool pattern.
PlatformKYCSoulbound — Soulbound ERC-721 (UUPS proxy, v3-30) ACTIVE
KYC/KYB verification token. Single global contract (not per-pool).
Key responsibilities:
- One token per wallet (non-transferable)
- Supports INDIVIDUAL (KYC) and INSTITUTION (KYB) levels
- US person attestation, expiry, revocation
- Verified by Pool contracts via
isValidKYC(address),isInstitution(address),isValidKYCNonUS(address)
Roles: DEFAULT_ADMIN_ROLE
Pool contracts call into this for KYC checks based on kyc_level_required. The pool references KYC by proxy address, so a KYC upgrade never requires a pool migration.
Upgradeable — the only upgradeable contract (v3-30). Implemented as an ERC1967 UUPS proxy with a propose/execute gate: proposeUpgrade(newImpl) → executeUpgrade() (or cancelUpgrade()). ⚠️ UPGRADE_TIMELOCK = 0 since 2026-08-14 — it was 7 days; the wait is gone and the two calls can land in the same block (v3-71 adopted). _authorizeUpgrade only accepts an upgrade that went through the timelock for the exact pending implementation — a direct UUPS upgrade is rejected. The money-path (PlatformPool / PlatformLPToken) stays immutable Clones (v3-27); only KYC/compliance logic can evolve in-place. New dependency: openzeppelin-contracts-upgradeable.
Why KYC-only upgradeable
KYC/compliance is the one on-chain piece that genuinely evolves over time. Making only KYC upgradeable (proxy + timelock) lets compliance change without redeploying or migrating every pool, while the non-custodial money-path stays immutable. Invariant: the periphery (KYC) gates eligibility but can never move funds or change a payout destination. See v3-30 · 09a-custody.
PlatformEscrow — Removed DEPRECATED
Status: Removed in v3.0 per v3-11. The 10/90 deposit split (reserve / fund_wallet) is now inlined into PlatformPool.deposit(). No separate escrow contract.
With Receipt NFT removed and deposit atomic, Escrow's only remaining role was a thin 10/90 splitter — keeping it added gas, audit surface, and a separate escrow_address to track for no actual separation-of-concerns benefit.
PlatformPoolFactory — Factory + Registry ACTIVE
Unified factory and on-chain registry for all pools.
Key responsibilities:
createPool(config)— deploys Pool + one LPToken (always; tranching is via DB grouping per v3-14)- Single
poolCounter(incrementing pool IDs) poolRegistrymapping (poolId → contract addresses)getPoolContracts(poolId)— retrieves deployed contracts for a pool- Auto-grants roles (MINTER_ROLE on LP to Pool)
Roles: DEFAULT_ADMIN_ROLE
Factory takes pool config (dimensions) as parameters and validates them at deploy time (see Validation Rules).
Implementation Libraries (EIP-170)
Why (rationale): PlatformPool accreted enough logic (epoch redemption v3-26, non-custodial hardening v3-28/30/31/32) to exceed the EIP-170 24,576 B contract-size limit, making it undeployable. To stay deployable, behavior is split across external delegatecall'd libraries that are deploy-time linked into the pool implementation — the library addresses are baked into the bytecode (no setter, no diamondCut), so this introduces no upgrade path: the money-path stays immutable Clones (v3-27). A first pass extracted RedemptionLib (+ PoolConfigLib/StablecoinAdminLib); when incremental features pushed the pool back to the edge (the claimYield partial-claim change left only +2 B on the default profile), a second pass (2026-07-06) extracted the remaining large clusters — timelocked config (GovernanceLib), yield accounting (YieldLib), and NAV (NavLib) — to restore comfortable headroom. onLpTransfer was deliberately kept inline (LP-transfer hot path — a per-move delegatecall would tax every mint/burn/transfer).
Result (measured 2026-07-30): PlatformPool is 21,232 B (prod, +3,344 B margin) / 20,691 B (default, +3,885) — under the 24,576 B limit, but the margin has been shrinking as the epoch engine and the exit gate landed (20,689 B at the 2026-07-06 split). The largest library is RedemptionLib at 12,016 B. The split is behavior-preserving — the full forge suite (366 tests, incl. conservation/precision, fuzzed backing invariants, and non-custodial audits) stays green. Errors/events declared in the libraries revert/emit under the pool address with identical selector/topic0, so the ABI and off-chain decoding are unchanged (frontend/backend need no code change — they import the regenerated ABI from the contracts package; only the pool implementation is redeployed and the factory re-pointed).
The per-library function inventory, the shared-
Layout(PlatformPoolStorage) storage model, and the delegation diagram live in Contract Code Reference → §2.5 Implementation Libraries and EIP-170 library delegation.
Deprecated Contract (v2.x → v3.0)
PlatformReceiptNFT — Removed DEPRECATED
Removed in v3.0. The LP token itself now serves as proof of deposit. The D+7 refund mechanism (the main use case for Receipt NFT) was also removed — see Migration Notes in Pool Models.
Existing FUND_POOL deployments with Receipt NFT remain functional but are not used for new pools.
On-chain vs DB Boundary (v3-18)
Aset is a permissioned RWA platform, not pure DeFi. We adopt minimum on-chain config — settlement state + KYC compliance fields + early-exit penalty are stored on PlatformPool. Everything else (tranche info, lifecycle metadata, marketing) lives in the DB and is enforced by Aset Lambda.
⚠️ v3-18 correction (penalty SoT): The initial draft classified penalty params as DB-only (Lambda computes payout), but since the
RedemptionConfigcontract stores, computes, and applies the penalty on-chain, the penalty-calculation SoT is on-chain (confirmed). DBpenalty_*is a display/reporting mirror. (Related: YIELD_BASED ignored bps and forfeited the full amount — bug → fixed to apply the rate, SECURITY_REVIEW P-1.)
🟢 On-chain (PlatformPool state)
Settlement-critical + regulatory compliance.
| Field | Purpose | Why on-chain |
|---|---|---|
navPerToken | LP token price | Settlement: payout = LP × NAV |
reserveBalance | Legacy implementations only | The external-reserve implementation sends the reserveBps share to reserveWallet and the remainder to fundWallet. It does not accumulate an in-pool reserve balance. |
lockupDays | Lockup window | requestRedemption() checks lockup |
maturityDate | Pool maturity | requestRedemption() skips lockup after maturity |
paused | Soft pause | Modifier blocks deposit() only |
isEmergencyFrozen | Hard freeze | Modifier blocks ALL activity |
lifecycleStatus | DRAFT / UPCOMING / ACTIVE / IMPAIRED / MATURED / CLOSED / WIND_DOWN | Settlement gating per state |
reserveBps | Used by deposit() for 10/90 split (basis points, ≤ 10000) | Contract must know to split atomically |
fundWallet | Target for the partner-remainder release | Contract sends to this address |
kycContract | Reference to PlatformKYCSoulbound | Contract checks SBT |
kycLevelRequired | KYC vs KYB required | Regulatory enforcement — prevents direct contract bypass |
jurisdictionWhitelist | ISO 3166-1 alpha-3 country codes allowed (v3-60) | Regulatory enforcement — region restriction |
penaltyRateBps, penaltyType, penaltyFeeAmount | Early-exit penalty (RedemptionConfig) | Contract computes + applies penalty at requestRedemption (v3-18 correction) |
🟡 DB only (Aset Lambda enforces)
Mutable, derived, or off-chain-computable.
| Field | Why DB-only |
|---|---|
description, apy_disclosure | Long text — gas-expensive to store on-chain |
collateral_description | Free-text, frequently updated |
apy_rate (target) | Marketing target — actual yield reflected in NAV |
min_investment, capacity | Soft limits — app/Lambda enforces |
penalty_type, penalty_rate_bps, penalty_fee_amount | DB mirror only — penalty SoT is on-chain RedemptionConfig (see the on-chain table above); the DB copy is for display/reporting |
tranche_group_id, tranche_role (v3-14) | Lambda applies loss waterfall during NAV computation; pools stay standard SINGLE on-chain |
maturity_model | Lambda enforces FIXED_TERM vs OPEN_ENDED state transitions |
external_provider, external_fund_id, partner_id | Integration metadata — no settlement role |
investor_count, total_yield_distributed | Derived from on-chain events |
start_date, end_date | Deploy/maturity timestamps recoverable from events |
accepted_currencies (display) | On-chain whitelist tracks valid stablecoins; DB tracks UI display order |
🔴 NEVER on-chain (PII / privacy)
| Field | Why excluded |
|---|---|
| Investor name / email / country / DOB | GDPR Article 17 (right to erasure) impossible on immutable ledger |
| KYC documents / photos | Compliance + privacy laws |
| Borrower-level loan PII (Joob eNote borrower names, etc.) | Partner NDA + data protection |
| Wallet → identity direct mapping | Pseudonymous model breaks |
| Partner internal terms / fee structure | Commercial sensitivity |
These remain in:
- SumSub: KYC documents, sanctions screening data
- Aset DB: wallet ↔ user mapping, display name, email
- Partner system: borrower-level data (we never receive PII)
The on-chain PlatformKYCSoulbound SBT carries only the attestation (level, jurisdiction, expiry, revocation status) — never the underlying PII.
Field Sync & Mutability — DB ↔ on-chain (v3-29)
Which fields must propagate to / lock on-chain when an admin PATCHes pool config. Today
pools.patch.update.tsupdates DB only → enforce per the rules below. (Mutability itself: 04 → Editability After ACTIVE.)
TL;DR — before deploy (DRAFT) every field is freely editable. After the pool is deployed (ACTIVE etc.) whether you can still edit a field depends on its class:
- A → editable, but only through a dedicated endpoint, not the edit form (PATCH).
- B → not editable — locked on-chain; changing it requires a brand-new pool (redeploy).
- C → freely editable anytime via the normal edit form (PATCH).
- C-lock → partially locked:
min_investmentis frozen,capacitycan only be raised.
| Class — meaning | Fields | Editable after deploy? |
|---|---|---|
| A — on-chain setter exists | is_paused/is_emergency_frozen; reserve_bps, fund_wallet, kyc_level_required, jurisdiction_whitelist; nav_per_token; lifecycle_status; allow_rollover | ✅ Yes — via the dedicated endpoint, not PATCH. pause/freeze = instant · reserve/fund_wallet/KYC = 7-day timelock · NAV = /nav-changes · lifecycle = /lifecycle · rollover = setAllowRollover (instant, no timelock) ⚠️ but PATCH still writes DB only — the on-chain twin is not synced |
| B — on-chain, immutable (no setter) | penalty_type/penalty_rate_bps/penalty_fee_amount, lockup_days, maturity_days, accepted_currencies, redemption_type/notice_period, epoch_duration_days (epochDurationDays) | ❌ No — PATCH rejected. Change requires a new-pool redeploy |
| C — off-chain, no on-chain twin | name, description, apy_rate/disclosure, yield_frequency, net_yield_fee_config, collateral_type/collateral_ratio/collateral_description | ✅ Yes — freely, anytime (plain PATCH; no on-chain counterpart) |
| C-lock — off-chain, published investor term (v3-43) | min_investment, capacity | ⚠️ Partial (Lambda-enforced, no redeploy): min_investment locked · capacity raise-only (accepted only if new ≥ current) |
DRAFT (pre-deploy): every class above is freely editable (DB only). The "Editable after deploy?" column is the part that changes once the pool goes live.
Rules:
- A
is_paused→ via the dedicatedPOST /pools/{id}/pauseendpoint (mirrors/freeze), which calls on-chainpause()/unpause()(both instant, no timelock) for deployed pools and DB-only for DRAFT.is_pausedis removed from thePATCHwhitelist. ⚠️ A DB-only pause would let an investor bypass via a directdeposit()call (it iswhenNotPaused-gated) → security gap → must be on-chain. ⚠️ The same applies in reverse (v3-92): every write that clearsis_pausedmust clear the contract too. OZPausableis independent ofemergencyFreeze()and of the lifecycle enum, so the v3-78 auto-clear (freeze / impairment / wind-down) sends on-chainunpause()viaclearOnChainPauseand only then writesis_paused = false— a DB-only clear strands the poolpaused()on-chain (deposits revert invisibly, laterpause()revertsEnforcedPause()→ 502). - B →
pools.patch.update.tsrejects these fields on a deployed pool (lifecycle ≠ DRAFT); DRAFT only. (EIP-170 bytecode headroom + these are investor-facing terms that lock after ACTIVE — matches the 04 matrix; no contract change.) - C
apy_rate→ ⚠️ true today, but a 2026-08-27 decision splits this field by accrual mode (not yet reflected — no migration, v3-154): a FIXED pool carries onlyaccrual_rate_bps(Class B — on-chain, create-only) and leavesapy_rateempty, a TARGET pool the reverse. In a FIXED pool the real rate is therefore the on-chain one, so the "no on-chain twin" classification stops holding for the field that matters. The current schema still hasapy_rate NOT NULL. - C-lock (v3-43) →
min_investmentandcapacityare off-chain (no on-chain counterpart — the on-chain state table above does not include them; Lambda enforces both at deposit gating). After ACTIVE:min_investmentis locked (PATCH rejected — published investor term, change = new pool);capacityis raise-only (PATCH accepted only whennew_capacity >= current_capacity— raising admits more investors with no harm to existing holders, lowering is rejected). No redeploy. Corrects the earlier v3-29 draft, which mislabeledcapacity/min_investmentas Class B "on-chain immutable" — they are off-chain values locked by policy, not by the contract. investment_blocked→ DEPRECATED (v3-29): redundant with on-chainis_paused(now wired). Removed from invest-eligibility (now justACTIVE && !is_paused); "Fully Subscribed" derives fromtvl >= capacity. DB column dropped (migration 0018). Useis_pausedfor the deposit halt.- Already on-chain via dedicated endpoints:
reserve%/fund_wallet/KYC = governance (7-day timelock), NAV =/nav-changes, freeze =/freeze, lifecycle =/lifecycle.
Impl spec: ch
ONCHAIN_VALUE_SYNC_DECISION.md. Epoch fields (v3-38):epoch_duration_days(on-chainepochDurationDays) is Class B — set at pool creation, immutable while live (the deploy multicall sets it; the contract revertssetEpochDurationDaysonce the pool has investors (totalLPSupply > 0) → settable only at deploy, not in product — changing redemption terms on live investors = undisclosed gate / fund-trap risk). Hardening ✅ implemented & tested (pending mainnet deploy + audit):MAX_EPOCH_DURATION_DAYS = 90bound (EpochDurationTooLong) +EpochDurationChangedevent + create-only revert (EpochImmutableAfterDepositoncetotalLPSupply > 0);PlatformPoolEpoch.t.sol23/23. Sepolia (6/22) is pre-hardening → needs redeploy. Live change via 7-day timelock governance is deferred to v2. The runtime epoch state (currentEpochId/currentEpochEndsAt/fill ratios) is read-mirrored, not set off-chain.
Why this boundary
| Reason | Detail |
|---|---|
| Permissioned model | Investors trust Aset operationally — no need for full trustless config (Centrifuge pattern) |
| Audit + deploy speed | Less contract surface = faster security audit + cheaper deploy gas |
| Regulatory hard line | KYC level/jurisdiction MUST be on-chain to prevent sophisticated user bypassing via direct contract call (VASP violation otherwise) |
| Trust assumption | Lambda already trusted for NAV (v3-15), waterfall (v3-14) — no marginal trust by keeping more config off-chain. Penalty is the exception: computed on-chain (SoT on-chain) |
Mitigations against Lambda errors
Since Lambda controls much of the logic, we add safeguards:
- 24h timelock on NAV decrease (admin can cancel during window)
- 7-day timelock on fund_wallet / reserve / KYC config changes
- 30-day timelock on wind-down execution
- Multi-sig admin role for critical functions
- All NAV updates emit events for off-chain audit
See Decisions → v3-18 for the full rationale and v3-15 (Aset always processes NAV) for the related trust framing.
Architecture Diagram
Contract Architecture (v3.0)
Legend: Investor actions · Partner deposits yield · Aset admin governs (multi-sig) · Aset service key (ORACLE_ROLE) posts NAV + settles redemptions/yield
Per-Pool Deployment
Fund Flow
Unified flow for all pools (no more AS/FUND_POOL split). Variations come from pool configuration (dimensions), not contract logic.
Deposit Flow
1. Investor → Pool.deposit(stablecoin, amount)
2. Pool:
- safeTransferFrom(investor, Pool, amount)
- check KYC via PlatformKYCSoulbound
- normalize amount (e.g., USDC 6 decimals → 18 decimals)
- compute reserve_amount = amount × reserve_bps / 10000
- compute partner_amount = amount - reserve_amount
- mint LP tokens to investor: tokens = (amount × NAV_PRECISION) / nav_per_token — full amount ([v3-56](./14-decisions#v3-56); the reserve split does not dilute the claim)
- transfer reserve_amount to reserveWallet
- safeTransfer(fund_wallet, partner_amount) ← sends the remainder to partner, emits ReleasedToPartner
- emit Deposited(investor, stablecoin, amount, lp_amount)
3. Aset indexer picks up the event and mirrors it off-chain (deposits row, position, TVL)⚠️ There is no partner push on deposit. This step used to end "notifies partner via webhook". No such notification exists — grep for aset-lp-mint / partner_endpoint across apps/ returns nothing, and the only webhooks in the codebase are inbound (SumSub KYC, SES bounces). A partner learns of a deposit from the chain, or from a report. Same finding as Partner Notification below, one flow earlier. → v3-02
For pools with custody_mode = 'MIRROR', no deposit flow — Aset only mirrors what the partner reports. (A is_showcase = true pool also takes no deposits, but for the different reason that it never deploys a contract at all.)
Yield Flow
1. Partner → Pool.depositYield(gross_amount)
(role-gated: caller must have YIELD_DEPOSITOR_ROLE (the partner's `fund_wallet`)
or RESERVE_FUNDER_ROLE (the pool's `reserve_wallet`) — either may fund, D4-a)
2. Pool retains gross_amount in unclaimedYield
3. Pool emits YieldDeposited(gross_amount)
4. Aset Lambda picks up event:
- reads pool.net_yield_fee_config from DB
- calculates net_amount and fee_amount off-chain
5. Aset Lambda → Pool.settleYield(stablecoin, treasury_fee, pool_mgmt_fee) // ONE tx
- net = unclaimedYield − fees, DERIVED not stated (v3-131 (3), 2026-08-20)
- applies net to the accrued liability; what it cannot absorb stays as prepayment
- transfers treasury_fee to Aset treasury and pool_mgmt_fee to fund_fee_wallet
- a failing fee leg reverts the whole period
7. Investor → Pool.claimYield(stablecoin, amount) // amount 0 = claim all, over-request clamped
- pays out of what a partner has FUNDED; accrued-but-unfunded reverts YieldAccruedButUnfunded
- transfers to investor walletRedemption Flow
Standard:
1. Investor → Pool.requestRedemption(lp_amount)
- locks LP tokens (transfer-disabled)
- records nav_at_request (snapshot)
- calculates payout = (lp × nav_at_request) − penalty — ✅ **every** penalty type is principal-side and routes the penalty to **`fund_wallet`**, not the reserve ([v3-84](./14-decisions#v3-84) + [v3-85](./14-decisions#v3-85), live in the deployed `RedemptionLib`: `payout = grossAmount − request.penaltyAmount` at `RedemptionLib.sol:524`, penalty paid to `s.fundWallet`). `isYieldPenalty` remains in the request struct for ABI stability but is always `false` — there is no gross-payout branch left.
2. Aset service key (ORACLE_ROLE) checks reserve sufficiency
3. Aset (ORACLE_ROLE) → Pool.approveRedemption(requestId)
- if reserveBalance >= payout: execute immediately
- else: set status to PENDING_RESERVE
4. If PENDING_RESERVE: Partner → Pool.fundRedemption(amount)
- partner sends additional USDC to Pool
- if total now >= payout: execute
5. Execute: Pool burns LP, transfers USDC to investorSee Redemption for full mechanics including penalty calculation.
Wind-Down Flow
T+0 Partner becomes unresponsive
T+60d Admin (multi-sig) → Pool.proposeWindDown()
- sets windDownProposedAt = block.timestamp
- emits WindDownProposed event
T+60..90d 30-day timelock period
- partner can respond → admin calls cancelWindDown()
T+90d Anyone → Pool.executeWindDown()
- requires block.timestamp >= windDownProposedAt + 30 days
- sets navPerToken = distributable / (totalSupply − settledUnclaimedLp) (forced, no 24h timelock)
distributable = reserveBalance + totalEpochTopUp (v3-100 numerator, R10 denominator)
→ `− redemptionCommitted` was removed as a double-count (0abe168); new pools only
- sets lifecycle_status = WIND_DOWN
After LP holder → Pool.requestRedemption(lpAmount) // standard redemption path
- lockup + penalty waived (lifecycle = WIND_DOWN)
- payout = lpAmount × navPerToken (= pro-rata share of distributable
liquidity: reserve + epoch top-ups)
- instant pools: settled by the partner-funding auto-settle inside fundRedemption
- epoch pools: settled via executeEpoch → claimRedemption
- burns LP, transfers USDCWhy this is mathematically equivalent to the old claimWindDown() formula
(their_LP / claimingSupply) × distributable equals their_LP × navPerToken, because that is exactly how executeWindDown sets the price:
navPerToken = distributable / claimingSupply
distributable = reserveBalance + totalEpochTopUp
claimingSupply = totalSupply − settledUnclaimedLpSettled-but-unclaimed debt leaves the denominator (R10), not the numerator — redemptionCommitted is a disjoint bucket the three liquidity counters are already net of, so subtracting it as well double-counted (v3-100 widened the numerator; R10 corrected both sides). Full derivation: 06 → R10.
Unifying into the standard redemption path removes a redundant function and enables partial redemption during wind-down. (There is no dedicated redeem() function on-chain — WIND_DOWN exits go through requestRedemption → claim like any other redemption.) See v3-12.
See Pool Models → Emergency Wind-Down for context and rationale.
On-chain Events (DB mirror)
Function reference lives in 08a
The per-contract function list (signatures, role gates, descriptions for investor / partner / service / pauser / admin functions) is maintained in the Contract Code Reference → §2 Function Reference (generated from source). This page keeps only the events below — they are an indexer/integration concern (on-chain → DB mirror) that the code reference does not cover.
Epoch Redemption Events (v3-26)
Emitted by epoch pools; the Aset indexer mirrors them into redemption_epochs / redemption_requests.
| Event | Emitted by | Carries | Mirror target |
|---|---|---|---|
EpochSettled | executeEpoch | epochId, fillRatio, settleNav, filledGrossUSD, settledLp | redemption_epochs (fill_ratio, settled_nav, settled_at) + money_events (EPOCH_SETTLED) |
RedemptionClaimed | claimRedemption | requester, filledLp, payout, penalty | request lp_filled / settled_nav / payout / status + money_events (REDEMPTION_CLAIMED) |
RedemptionRolledOver | claimRedemption | requestId, nextEpochId, lpRemaining (indexer decodes positionally) | request → PARTIALLY_FILLED, demand carry-forward |
EpochFundingNeeded | executeEpoch (settlement) | epochId, shortfall (no deadline field) | partner-funding alert (notification_events → notification_deliveries, D-2/D-12h) |
Non-Custodial Safety Events (v3-28 / v3-31 / v3-32 / v3-30)
Emitted by the freeze / fallback / NAV-bound / KYC-upgrade paths; the Aset indexer mirrors them for audit + pool-response freeze fields.
| Event | Emitted by | Carries | Notes |
|---|---|---|---|
EmergencyFrozen | emergencyFreeze | timestamp (= freezeStartedAt) | Starts the 72h exit window + 7d auto-expiry clocks (v3-28). |
EmergencyUnfrozen | unfreeze | timestamp | PAUSER recovery; clears the freeze early. |
FreezeExtendProposed / FreezeExtended / FreezeExtendCancelled | removed | — | The freeze-extend path is gone: its 7-day governance timelock equalled the 7-day freeze lifetime, so a proposal only became executable after the freeze had already lapsed — executing it then either did nothing or retroactively re-locked the pool and restarted the 72h exit block on investors who had regained withdrawal rights. Only pendingFreezeExtend* storage remains (cleared on unfreeze); POST /pools/{id}/freeze returns 410 for the extend actions. Escalate with pause or impairment instead. |
RedemptionFallbackClaimed | claimRedemptionFallback | — | 🔴 Removed. The function drew on the pool's reserve, which is routed to reserve_wallet at deposit. The v3-31 exit right is an operational commitment now, not a contract one. |
NavDeviationCapSet | setNavDeviationCap | bps | 0 disables the per-update deviation bound (v3-32). |
CircuitBreakerTripped / CircuitBreakerReset | tripCircuitBreaker / resetCircuitBreaker | by | Tripped breaker reverts updateNAV + executeEpoch (v3-32). |
UpgradeProposed / UpgradeExecuted / UpgradeCancelled | PlatformKYCSoulbound upgrade path | new implementation, effectiveAt | 7-day KYC UUPS upgrade timelock (v3-30). KYC contract only — money-path is immutable. |
Roles & Access Control
All contracts use OpenZeppelin AccessControl. Roles are granted per-contract instance.
Role → permission tables live in 08a
The which-role-can-call-what breakdown for every contract (PlatformPool, LPToken, KYCSoulbound, Factory) is in Contract Code Reference → §3.1 Access control. This page covers the assignment flow (who holds each role, how it's granted at deploy) and the multi-sig + timelock rationale.
Multi-sig admin
DEFAULT_ADMIN_ROLE is typically held by a multi-sig wallet (e.g., Gnosis Safe) and is the role-admin of every other role. Critical changes (fund_wallet, wind-down) additionally have on-chain timelocks for investor protection. At deploy, _admin also bootstraps PAUSER_ROLE (handed to a separate fast Safe on mainnet); ORACLE_ROLE is granted post-deploy.
Role Assignment Flow
Signer Identities (KMS)
The platform's own signing identities. SoT: apps/infra/lib/config/signer-roles.ts.
Until 2026-08-12 a single ADMIN_PRIVATE_KEY held every platform-side role at once, injected as a plaintext Lambda environment variable into 178 functions. That made lambda:GetFunctionConfiguration (an action inside the AWS managed ReadOnlyAccess policy) equivalent to the authority to mint SBTs, create pools, move NAV and change lifecycle. It is replaced by four AWS KMS ECC_SECG_P256K1 signing keys per stage, one per role, each with its own address. The key material never leaves KMS: the backend asks KMS to sign a transaction hash, so there is nothing to read out of a function's configuration and every transaction leaves one kms:Sign record against a named key in CloudTrail.
| Identity | KMS alias | On-chain grants required | Signs (examples) |
|---|---|---|---|
admin | alias/aset-signer-admin-<stage> | PlatformPoolFactory.DEFAULT_ADMIN_ROLE, PlatformPool.DEFAULT_ADMIN_ROLE, PlatformKYCSoulbound.DEFAULT_ADMIN_ROLE, PlatformLPToken.DEFAULT_ADMIN_ROLE | createPool, setLifecycleStatus, governance propose/execute, setEpochSchedule, KYC revoke/burn |
minter | alias/aset-signer-minter-<stage> | PlatformKYCSoulbound.MINTER_ROLE | mint (cannot revoke or burn) |
oracle | alias/aset-signer-oracle-<stage> | PlatformPool.ORACLE_ROLE | updateNAV, approveRedemption, rejectRedemption, holdRequest, releaseRequest, settleYield, setEpochFundingDate |
pauser | alias/aset-signer-pauser-<stage> | PlatformPool.PAUSER_ROLE | pause/unpause, emergencyFreeze/unfreeze, tripCircuitBreaker |
dev and prod get separate stacks, therefore separate keys and different addresses, so a dev signer cannot act on prod contracts even if a deploy targets the wrong environment.
Derived addresses: apps/infra/lib/config/signer-addresses.ts (generated). A KMS key has no address until it exists, so the address is an output of deploying the key, never an input. Both stages are currently unrecorded pending the first deploy.
The split is not symmetric
DEFAULT_ADMIN_ROLE is the role-admin of every other role, so the admin key can always grant itself oracle or pauser. What the split buys is a bounded blast radius for the three narrow keys: a leaked oracle key can move NAV but cannot mint an SBT, create a pool, or hand itself more authority. Treat admin as the key whose compromise is total, and prefer moving it behind the multi-sig described above once the automated DEFAULT_ADMIN_ROLE call sites (lifecycle scheduler, governance execution) can tolerate one.
YIELD_DEPOSITOR_ROLE has no KMS identity, and should not get one
Resolved by the signer audit of 2026-08-13. The contract grants this role to fund_wallet at initialize (PlatformPool.sol:744) and on every fund-wallet change (:1429) — it is the partner's identity, and the platform does not need a standing one:
depositYieldis not signed by the platform.yield-distributions.post.create.tsnow requires a client-signeddeposit_tx_hashand rejects without one, which removed the legacy server-key path.depositYieldOnChainsurvives as dead code with zero callers.fundRedemptionis signed by the platform only wherefund_walletIS the platform key.initializegrants the role tofund_wallet(PlatformPool.sol:739) and nothing else does: the deploy-time self-grant increate-pool.ts— the v3-53 dev shim — was removed 2026-08-13, so a pool with a real partner wallet no longer has a platform copy of the role.executeFundWalletChangekeeps the role tracking the wallet (revoke old, grant new —:1413-1414), so no per-fund or per-FM grant step is needed. Both handlers carry the partner-signed alternative (record-funding/fund_tx_hash: verify and record, send nothing), which admin-web already uses. ⚠️ Pools deployed before 2026-08-13 still carry the extra grant — it must be revoked per pool.
Do not add a fifth identity to make the conditional path work everywhere: that would move a partner capability to the platform, which is exactly what the non-custody determination in 09a-custody depends on not happening.
Order of operations
The order matters: a key with no grant signs transactions that revert, and a grant to an address nobody can sign for is worse — it looks correct on-chain.
- Deploy the keys.
pnpm --filter @aset/infra deploy:<stage>:kms. A standalone CDK app (bin/kms.ts), so it needs nothing but AWS credentials — no certificates, no secrets. It is not part ofcdk deploy --all, deliberately: a signing-key stack should not be swept into a routine deploy. - Record the addresses.
AWS_PROFILE=aset pnpm --filter @aset/infra signer:addresses --stage <stage> --write. Read-only without--write. Commit the generated file. - Grant the on-chain roles to each recorded address, per the table above. Pools are
Clones, so pool-scoped roles (DEFAULT_ADMIN_ROLE,ORACLE_ROLE,PAUSER_ROLE) are granted per pool and there is no registry to repoint. - Switch the runtime (
lib/shared/contract/client.ts) to sign via KMS, and attach theaset-signer-<role>-<stage>-signmanaged policy to only the functions that need each role. - Revoke the old single-key address and delete
ADMIN_PRIVATE_KEY, in that order.
Keys are RETAIN, there is no destroy script, and rotation is a migration
A signing key is not recreatable: its address is what the contracts granted a role to, once per pool clone. Deleting the key orphans every one of those grants permanently, because no future key can produce that address. Hence RemovalPolicy.RETAIN and a 30-day KMS pending window.
The alias is retained too, deliberately. Key's alias prop routes through Key.addAlias, which applies no removal policy — so the alias would default to Delete while the key is Retain. A destroy would then leave a retained key that nothing can address, and the next deploy would mint a new key with a new address while every on-chain grant still named the old one. That failure is silent: the stack deploys green and transactions revert later. The alias is therefore created explicitly with RETAIN, so a re-deploy fails loudly on the existing alias name instead.
No destroy:<stage>:kms script exists, unlike every other stack. Tearing this stack down has no safe outcome worth making one command away: with both resources retained the teardown accomplishes nothing but unmanaging them, and the recovery is manual either way. Deleting a signer is a deliberate operation against KMS directly, after the on-chain roles have been moved.
For the same reason enableKeyRotation is absent (it applies to symmetric encryption keys only, and rotating a signer means a new address and a re-grant on every pool: a runbook, not a background job).
Multi-sig + Timelock Patterns
Critical changes use multi-sig admin + on-chain timelock for investor protection.
Two-step process
- Propose: Admin (multi-sig signers) propose a change
- Wait: Timelock period (visible to investors via event)
- Execute: Anyone can execute after timelock passes (or multi-sig cancels during timelock)
Timelock Configurations (canonical)
Single reference (SoT) for all timelock values. Other docs must not duplicate these values — cross-ref here instead (to prevent drift). Custody context: 09a-custody.
| Change | Timelock | Why |
|---|---|---|
fund_wallet change | 7 days | Material change — investors should see and react |
reserve_bps change | 7 days | Affects investor protection level |
nav_data_source change | — | Dimension removed in v3-15. Aset always processes NAV. |
kyc_level_required change | 7 days | Affects who can invest |
jurisdiction_whitelist change | 7 days | Affects who can invest (region restriction, v3-10/18) |
| Impairment execution | 7 days | Partner distress → pause deposits; review/cancel window (v3-12) |
| NAV decrease | 24 hours | Existing investors get time to react before write-down |
| Wind-down execution | 30 days (after 60 days unresponsive) | Recovery / cancellation window |
| Contract upgrade (KYC only) · Emergency freeze | v3-27 / v3-28 / v3-30 | Core money-path is immutable (non-upgradeable Clones). PlatformKYCSoulbound is the only upgradeable contract — a UUPS proxy behind a propose/execute gate with UPGRADE_TIMELOCK = 0 (was 7 days — v3-71); there are no upgradeable "oracle/fee modules", those are off-chain Lambdas (09a-custody). Freeze can be applied instantly, but a redemption-blocking full freeze auto-releases after at most 72h (exit right). → 09a-custody |
Partner Notification
When admin proposes a change, the on-chain event is the notification: FundWalletChangeProposed(oldWallet, newWallet, effectiveAt) and its siblings (ReserveBpsChangeProposed, JurisdictionChangeProposed, EnforceJurisdictionChangeProposed, RedemptionGatingChangeProposed, WindDownProposed, ImpairmentProposed) are emitted by GovernanceLib, and a partner self-monitors the ones for their pool.
🔴 Corrected 2026-08-05 — the off-chain half of this section was never built. It described a second channel, "Aset Lambda sends POST {partner_endpoint}/aset-governance-action immediately", with a full example payload whose fields included can_object_until — implying a contractual objection window. None of it exists. grep for partner_endpoint / aset-governance-action across apps/infra, apps/web and apps/admin-web returns 0 hits, and pools.post.governance.ts sends no notification of any kind — not to a partner, not in-app, not by email. Naming the fabricated elements rather than deleting them quietly, because a partner could have built against that payload: the endpoint, the payload shape, and the can_object_until objection right are all invented. Same defect class as the LP-mint webhook, recorded in v3-115 and corrected in v3-02.
Open — is a notification wanted here? The 7-day timelock is only a review window if someone is told it started. Build-or-drop is a product call; today the honest statement is "watch the event". → routed, not decided.
Amount Units — Raw vs Normalized
SoT for every on-chain amount argument. Verified 2026-07-31 against apps/contract/src; enforced in code by apps/infra/lib/shared/contract/units.ts + scripts/check-onchain-units.mjs.
One rule decides the scale of every amount PlatformPool accepts:
An argument that names tokens moving in this tx is Raw — that stablecoin's own decimals. An argument that names a figure on the pool's ledger is Normalized — always 18.
| Entry point | Argument | Tokens move? | Axis |
|---|---|---|---|
deposit | amount | safeTransferFrom | Raw |
depositYield | grossAmount | safeTransferFrom | Raw |
fundRedemption | amount | safeTransferFrom | Raw |
settleYield | treasuryAmount, poolMgmtAmount | safeTransfer ×2 | Raw |
claimYield | amount | denormalized internally | Normalized (18) |
reinvest | yieldAmount | denormalized internally | Normalized (18) |
updateNAV | reserveConsumed | no — debits reserveBalance | Normalized (18) — always 0 under R8 (NO_RESERVE_CONSUMED, shipped) |
setHardCap | newHardCap | no — replaces config.capacity | Normalized (18) |
createPool | minInvestment, maxInvestment, capacity, penaltyFeeAmount | no — deposit-time comparisons | Normalized (18) |
The ledger — unclaimedYield, accruedYield, reserveBalance, redemptionCommitted, totalDeposited, config caps — is 18-decimal throughout. PoolCommonLib.normalizeAmount scales each stablecoin up on the way in; denormalizeAmount scales back down at the moment of transfer (truncating sub-unit dust, which is why the contract leaves that dust accrued rather than paying it).
Two further axes exist and are not interchangeable with either: navPerToken / navAtRequest use NAV_PRECISION = 1e6 ($1.00 == 1e6), and bps fields are integers 0..10000. LP token amounts are plain 18-decimal ERC-20 units, which coincide numerically with Normalized.
Why this is typed, not documented
A wrong axis here does not revert. Every on-chain check on these arguments is an upper bound — formerly netAmount > unclaimedYield and reserveConsumed > reserveBalance — so a value 1e12 too small passes, the tx succeeds, and the off-chain records stay plausible because they are computed separately. ✅ Both of those two arguments are now gone: reserveConsumed is pinned at 0 by R8, and settleYield lost netAmount in v3-131 (3) because what holders are owed is decided by time rather than stated by a caller. An argument that cannot be validated is best removed, not better documented. Two such slips were live simultaneously (v3-101):
distributeYield(netAmount)(latersettleYield, and the argument is now removed entirely) received 6 decimals → accrued yield 1e12 short, and every investorclaimYieldwould have reverted withNoYieldToClaim. Caught before the first real distribution.updateNAV(reserveConsumed)received 6 decimals → a write-down debited essentially nothing from the reserve, reporting success. (R8 has since shipped:updateNAVreverts on a non-zeroreserveConsumed, so this particular argument is now unrepresentable rather than merely unused, and the upper-bound framing above no longer applies to it. The class of bug it illustrates does not go away.)
Both were already spelled out in comments (@param netAmount ... (normalized to 18 decimals) in the contract, plus a YIELD_NORMALIZED_DECIMALS = 18 constant in the backend). Comments did not hold. The axis is therefore a branded TypeScript type — RawAmount / NormalizedAmount / NavPrice — so the wrong axis is a compile error. parseUnits is banned outside contract/units.ts by a build guard; branded casts are confined to lib/shared/contract/.
Scope: the guard covers argument construction (parseUnits) and, since v3-102, the read direction too (formatUnits) — the 12 read sites that would have needed day-one exemptions were converted onto the typed decoders (fromRaw / fromNormalized / fromNavPrice) or onto normalizeAmount(value, <NAMED_DECIMALS>) for LP amounts and ratios, so the rule now holds with zero exemptions. A wrong scale on the read side is still only a display/record error; it is enforced because the exemption list reached zero, not because the risk changed.
Stablecoin Management
PlatformPool supports multiple stablecoins per pool. Admin uses addStablecoin(address) / removeStablecoin(address) (DEFAULT_ADMIN_ROLE) to manage accepted stablecoins.
Each pool's accepted_currencies (DB) maps to the on-chain stablecoin whitelist. Contract address per stablecoin is chain-specific — USDC on Base has a different address than USDC on Ethereum or Kaia. (Function signatures: addStablecoin/removeStablecoin in 08a → §2.1 Admin; the on-chain validation rules — decimals 6–18, last-coin/held-balance guards — in 08a → StablecoinAdminLib.)
Multi-Chain Stablecoin Addresses
Stablecoin contract addresses vary by chain. Only native (issuer-deployed) stablecoins are supported — bridged versions (e.g., USDC.e) are excluded due to depeg risk. Address lookup: STABLECOIN_ADDRESSES[chainId][symbol].
See Pool Models → Stablecoin for chain-specific addresses.
Deprecated (v2.x → v3.0)
Removed Contracts
| Contract | Removed in | Reason |
|---|---|---|
PlatformReceiptNFT | v3.0 | Receipt NFT was used for D+7 refund mechanism. Refund mechanism removed; LP token itself proves deposit. |
PlatformEscrowFactory | v2.0 → v3.0 transition | Replaced by unified PlatformPoolFactory. |
Removed Functions / Behaviors
| Feature | Removed in | Replaced by |
|---|---|---|
| D+7 auto-refund | v3.0 | No refund mechanism. Investment platforms don't typically offer refunds. Partner insolvency handled via wind-down. |
FUND_ISSUED LP minting (FM mints externally) | v3.0 | Aset always mints LP via PlatformLPToken. Partners notified via API. |
Multi-sig redemption variants (cosign, execute-transfer) | v3.0 | Single-stage admin approve with PENDING_RESERVE fallback. |
escrow_model enum | v3.0 | No escrow contract — split inlined into PlatformPool.deposit() (v3-11). |
lp_issuance_model enum | v3.0 | Always PLATFORM_ISSUED. |
Removed Spec (was in v2.x docs but not implemented)
| Concept | Notes |
|---|---|
AS_POOL / FUND_POOL binary pool_type | Replaced by dimension model. See Pool Models. |
| 2-stage redemption approval (Operator → Admin) | Single-stage in v3.0. |
Architecture Summary
v3.0 at a glance
- 4 active contracts (down from 6): PlatformPool, PlatformLPToken, PlatformKYCSoulbound, PlatformPoolFactory
- Single fund flow (no AS/FUND_POOL split): Pool receives 100%, retains reserve, releases remainder to
fund_wallet - Aset always mints LP: One source of truth for investor positions
- Multi-sig + timelock for critical changes (fund_wallet, wind-down)
- Role separation: Admin (multi-sig), Oracle, Yield Depositor (partner)
- Wind-down mechanism for partner failure (60+30 day process)
| Contract | Status | Description |
|---|---|---|
| PlatformPool | ACTIVE | The external-reserve implementation sends the reserveBps share to reserveWallet and the remainder to fundWallet. It does not accumulate an in-pool reserve balance. The current source has no claimRedemptionFallback. There is no seven-day fallback payout guarantee. The separate epoch settlement and claim-on-behalf paths also depend on actual funding. The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process. |
| PlatformLPToken | ACTIVE | ERC-20 LP token. Permissionless secondary transfers (whitelist gate removed, v3-58; verification enforced at value boundaries) + yield settlement. One LP per pool always — tranche grouping via DB metadata (v3-14), no two-LP-per-pool. |
| PlatformKYCSoulbound | ACTIVE | Soulbound KYC/KYB token. Global, not per-pool. Only upgradeable contract — ERC1967 UUPS proxy, propose/execute gate, UPGRADE_TIMELOCK = 0 (v3-30; timelock dropped by v3-71); pools reference the proxy address. |
| PlatformPoolFactory | ACTIVE | Unified factory + registry. Single poolCounter. Validates pool config at deploy. |
| PlatformEscrow | DEPRECATED | Removed in v3.0 (v3-11). 10/90 split inlined into PlatformPool.deposit(). |
| PlatformReceiptNFT | DEPRECATED | Removed in v3.0 (v3-03). LP is proof of deposit. |
Contract count: 4 (was 6 in v2.x).