Pool Models
v3.0 Implemented (testnet)
The v3.0 pool model is implemented across contracts, DB, and backend, and deployed to Sepolia testnet (2026-06-04, Clones/EIP-1167 factory, 183 contract tests passing). Remaining before mainnet GA: external security audit, admin/investor UI for the new controls (wind-down, governance timelocks, emergency freeze), and production deployment.
How Aset pools are set up. Each pool is defined by a set of independent configuration values — not by a fixed type. This lets us add new partners (Joob, LINE BK, future originators) by changing config, without changing the code itself.
Overview
Before v3.0, pools came in two types: AS_POOL and FUND_POOL. As we add more partners with different needs, we switched to a config-based setup: each pool has around 24 independent settings covering custody, asset details, yield, investor terms, compliance, and runtime state.
This page covers:
- The settings (dimensions) that define a pool
- How money flows through any pool
- Risk warnings shown to investors
- Example pools (current and planned)
Related Pages
- Smart Contracts — Contract architecture and roles
- Investment Lifecycle — Deposit/redemption/yield flows
- Writedown & NAV — NAV management
- Redemption — Redemption flow details
- DB Schema — Pool table columns
Core Principles
1. Non-Custodial
Aset never holds investor funds. All money flows through smart contracts. Aset can call certain functions (like approving redemptions), but it can't move funds on its own — only the contract code can. No VASP license required.
Comprehensive summary of the 6 non-custody criteria, key model, money-path invariants, and upgrade governance: 09a-custody (SoT).
| Stage | Location | Custodian |
|---|---|---|
| Deposit received (immediately) | Pool contract | Contract code |
| LP tokens (minted on deposit) | Investor wallet | Investor |
Reserve (reserve_bps) | reserveWallet | External wallet signer |
| External fund capital (partner remainder, post-release) | fund_wallet (partner-controlled — NOT the Aset treasury, v3-26) | External entity |
Yield (received via depositYield()) | Pool contract | Contract code |
| Yield (distributed, pre-claim) | Pool contract | Contract code |
| Yield (after claim) | Investor wallet | Investor |
| Fees (after net calculation) | Aset treasury wallet | Aset (multi-sig) |
| Redemption payout | Pool contract → Investor | Contract code |
| Wind-down distribution (pool-held liquidity) | Pool contract → Investors | Contract code |
Display-only pools
For pools with custody_mode = 'MIRROR' (e.g., legacy investor positions tracked from external contracts), none of the above applies — Aset just mirrors what the partner reports. No funds, no custody, no LP issuance on Aset's side. ⚠️ This was is_display_only until 0203; that column is now is_showcase and means the v3-40 marketing tier only.
2. Aset Always Mints LP
When an investor deposits through Aset, Aset's PlatformLPToken contract mints the LP tokens — no matter which partner operates the underlying fund.
This means:
- One LP token contract tracks all investor positions in one place
- Partners don't need to deploy their own token contracts
- We can add new partners by config changes only
- There is no partner webhook. The platform is the FM's record of a deposit —
GET /depositsis fund-scoped anddeposit_confirmed_opscarriesfundManagers— so there is no partner-side copy to keep in sync (v3-115)
Why LP issuance is centralized (v2.x FUND_ISSUED had partners mint their own, which drifted from Aset's records): v3-02.
3. Reserve and Tranche Are Different
Two things constantly mixed up — and only one of them handles losses (R8):
Reserve (reserve_bps, default 1000 = 10%) — liquidity, not loss absorption
- What it's for: Cash buffer for redemptions and timing gaps
- Where it comes from: 10% of each deposit stays in the Pool contract
- Example: Investor wants to redeem; partner needs a few days to send funds → Reserve covers the immediate payout
- Not a loss layer (R8): because it comes out of investor deposits, it is already inside the claim NAV prices — netting it against a loss would count the same money twice. Standalone-pool first-loss is the manager equity buffer (R6)
- Adjustable per pool
Tranche Group (tranche_group_id + tranche_role, v3-14)
- What it's for: First-loss capital allocation across multiple linked pools
- How it works: Multiple SINGLE pools share a
tranche_group_id. Each has atranche_role(SENIOR/MEZZANINE/JUNIOR). Aset oracle Lambda applies waterfall during NAV computation — Junior absorbs losses first, then Mezzanine, then Senior. - Example: LINE BK product = Senior pool (low APY, protected) + Junior pool (high APY, first-loss). LFC providing the Junior capital.
- Activated by setting
tranche_group_id+tranche_roleon participating pools
A pool can be:
- Standalone with reserve only (most pools —
tranche_group_idis NULL) - Part of a tranche group with reserve + role-based loss waterfall
How a Pool Is Defined
A pool is defined by ~24 independent settings (called dimensions). Each setting controls one aspect of the pool's behavior. Pools are configured by combining values across these dimensions — no fixed pool types.
Legend, used by the index and by every category table below:
- Mutability: 🔒 Immutable after deploy · 🟡 Changeable with timelock + multi-sig · 🟢 Admin can change instantly
- Status: ✅ Implemented in code · 🔨 Written but not live (unapplied migration and/or undeployed API) · 🚧 In development · 📋 Spec only (not yet coded)
Index — every dimension on one screen
Each name links to its full definition below. Categories match the section order.
| Dimension | What it controls | Mutability | Status |
|---|---|---|---|
| Fund Flow & Custody — where the money goes | |||
fund_wallet | Where each deposit's partner remainder is sent | 🟡 Timelock | 📋 |
reserve_bps | Share of each deposit kept in the pool as redemption liquidity | 🟡 Timelock | ✅ |
is_showcase | v3-40 marketing tier — visible, never investable here | 🔒 | 📋 |
custody_mode | Whether Aset custodies and processes this pool's capital (PLATFORM | MIRROR) | 🔒 | ✅ |
is_hidden | Hides the pool from the admin list. No capability change | 🟢 Any state | ✅ |
| Asset Configuration — what the pool invests in | |||
chain_id | Which chain the pool is deployed on | 🔒 | ✅ |
accepted_currencies | Which USD-backed stablecoins the pool takes | 🔒 after first deposit | ✅ |
operating_currency | Currency the underlying assets are denominated in (backend-only) | 🔒 | ✅ |
fx_rate_source | Fixed rate vs partner feed for that currency | 🟢 DRAFT only | ✅ |
collateral_description | Free text describing what secures the assets | 🟢 Instant | 📋 |
collateral_ratio | Collateral as a percentage of exposure | 🟢 Instant | 📋 |
| Yield Configuration — how yield reaches investors | |||
yield_frequency | How often a distribution is expected | 🟢 Instant | ✅ |
net_yield_fee_config | The fee stack applied before investors are credited | 🟢 Instant | 📋 |
| Investor Terms — lockup, maturity, penalties, tranches | |||
lockup_days | How long a position cannot be redeemed at all | 🟢 DRAFT only | ✅ |
maturity_model | FIXED_TERM (has a maturity) vs OPEN_ENDED | 🔒 | 📋 |
maturity_days | When the pool matures | 🟢 DRAFT only | ✅ |
redemption_type | Whether exits are allowed before maturity at all | 🔴 Create-only | ✅ |
penalty_type | What an early exit costs, and on what basis | 🟢 DRAFT only | ✅ |
tranche_group_id | Which tranche group the pool belongs to | 🔒 | 📋 |
tranche_role | SENIOR / MEZZANINE / JUNIOR inside that group | 🔒 | 📋 |
allow_rollover | ⚠️ not offered on any screen in MVP (2026-08-27, v3-151). Whether accrued yield can be reinvested instead of claimed | 🟢 Instant | ✅ |
| Compliance — who is allowed in | |||
enforce_jurisdiction | Turns the jurisdiction gate on | 🟡 Timelock | ✅ |
jurisdiction_whitelist | ISO codes allowed once the gate is on | 🟡 Timelock | ✅ |
allows_us_persons | Whether US persons may invest | 🟡 Timelock | ✅ |
| Pool State — runtime, not configured at deploy | |||
lifecycle_status | DRAFT → ACTIVE → … → WIND_DOWN | Automatic + admin trigger | ✅ |
is_paused | Soft pause: blocks new deposits, exits stay open | 🟢 Instant | ✅ |
is_emergency_frozen | Hard freeze, time-bound and self-expiring | 🟢 Instant | ✅ |
| Operations / Advanced — optional controls | |||
epoch_duration_days | 0 = instant redemption · > 0 = epoch batching | 🔴 Create-only | ✅ |
redemption_gating_bps | ⚠️ deprecated — not included in MVP (2026-08-27, v3-150). Caps how much deposited capital may settle per epoch | 🟡 Timelock | 📋 |
redemption_term_epochs | How many post-maturity cycles the pool repays over (+ epoch_date_basis · epoch_roll_day) | 🔴 Create-only | 🔨 |
nav_deviation_cap_bps | Rejects a NAV update that moves further than this | 🟡 Timelock | ✅ |
nav_staleness_seconds | Blocks epoch settlement on a NAV older than this | 🟡 Timelock | ✅ |
Two rules that are not dimensions but govern the whole page: NAV is always set by the Aset oracle, and yield distribution is manual only.
Fund Flow & Custody
Three settings control where investor deposits go and how funds are held.
| Setting | Type | Default | Mutability | Status |
|---|---|---|---|---|
fund_wallet | TEXT (address) | NULL (required at deploy) | 🟡 Timelock | 📋 Spec |
reserve_bps | INTEGER | 1000 | 🟡 Timelock | ✅ Code |
is_showcase | BOOLEAN | false | 🔒 Immutable | 📋 Spec |
fund_wallet
The external wallet where each deposit's partner remainder (amount − reserve) is sent. Could be:
- A partner's wallet (e.g., Henon for Joob, LFC SPV for LINE BK)
- A multi-sig contract (Gnosis Safe, etc.)
v3-26 — must be partner-controlled (no Aset-direct pools)
fund_wallet must NOT be the Aset treasury. A pool whose fund_wallet = Aset treasury would route the partner remainder of every deposit to Aset → Aset holds investor funds → custodial (breaks non-custody / VASP avoidance). Per v3-26 every pool is fund-linked (fund_id required) so that remainder stays partner-controlled. The Aset-direct config is removed — see the refused Example 1.
Enforced on-chain, at init. PoolConfigLib.validate reverts FundWalletIsTreasury() when fundWallet == treasuryWallet (PoolConfigLib.sol:60), so a direct factory deploy cannot bypass it either. ⚠️ The backend requires fund_wallet to be present for every non-display-only pool (pools.post.create.ts:647) but does not compare it against the treasury, so the deploy revert is the only gate — which also means a is_showcase pool, having no deploy, never meets one.
Required when is_showcase = false. Set at pool deploy. Change requires multi-sig admin proposal + 7-day timelock.
Mutability mechanism: admin (multi-sig) calls proposeFundWalletChange(newWallet) → FundWalletChangeProposed(oldWallet, newWallet, effectiveAt) is emitted (GovernanceLib.sol:291) → after 7 days anyone may call executeFundWalletChange(); within the window multi-sig can cancelFundWalletChange(). Partners learn of it by watching that event — see Smart Contracts → Partner Notification.
reserve_bps
Basis points of each deposit retained in the Pool contract as a liquidity buffer. Used to pay redemptions immediately when partner funds aren't yet available.
- Default: 1000 (= 10%)
- Range: 0 to 5000 (higher than 5000 bps / 50% is unusual)
- Stored as integer basis points (1% = 100 bps, 100% = 10000 bps)
- Used at deposit:
reserve = deposit_amount × reserve_bps / 10000
is_showcase
⚠️ Renamed from is_display_only in 0203, and narrowed. This column used to define the custody statement below; that moved to custody_mode. What is left here is the v3-40 marketing tier — the meaning the FE actually implements.
When true, the pool is visible but never investable here. The investor PDP renders the Overview tab alone and replaces the invest panel with a "Preview / Coming soon / Register interest" card (pool.$id.tsx, which cites v3-40 at that branch); the pool is also excluded from the dashboard's discover list.
Behavior when true:
- No
deposit()and no reinvest - Create does not deploy a contract, and
fund_walletis forbidden (there is no money path) - Performance and Fund Data tabs are hidden — so do not use this for a pool whose partner figures are the reason it is listed
Set at create. Cannot be changed afterward: create couples it to deployment, so flipping it later would let a pool that never deployed start claiming it did.
No pool has ever set it (0 of 8 on dev at the time of the rename), so v3-40's tier is specified and wired but has never rendered.
custody_mode
Whether Aset custodies and processes this pool's capital. PLATFORM (default, every pool to date but one) or MIRROR.
MIRROR means the positions exist outside the platform and we mirror them from a partner report — e.g. ShardLab/Hashed's RPS holdings from Joob's Kaia contract, which is FJL IDN Private Credit 1. There is no capital path in or out through us: deposits, reinvest, redemption requests and yield claims all refuse (business/custody-mode.ts), and 23 admin mutation paths refuse too. The read model is deliberately identical to any other pool's — same ledger, same fold, same projections, same payloads; only the events' origin differs (0198). NAV is recorded rather than proposed, and marked nav_history.attested_off_chain (0201).
A mirrored pool still carries a fund_wallet and a reserve_bps, and still has a pool address — which is exactly why it could not reuse is_showcase, whose create path forbids all of that.
Not settable through the API. custody_mode appears in no create or update handler; only a migration sets it. Making a pool a mirror, or un-mirroring one, is deliberately not an admin action.
is_hidden — visibility, and nothing else (v3-110 A)
A free visibility toggle: 🟢 any state, any time, reversible, no on-chain component, and no change to what the pool can do. Deposits, redemptions, yield claims, NAV updates and notifications are unchanged for anyone holding a position.
⚠️ As shipped it is an admin-list filter only — narrower than the decision text, which said the investor list and PDP. pools.get.list applies .eq('is_hidden', false) inside the operator branch alone (opt back in with ?include_hidden=true), and the investor PDP does not 404 on a hidden pool. A holder has to be able to reach the pool to redeem, claim yield and read a write-down notice.
Distinct from is_showcase (🔒 immutable, the v3-40 marketing tier), from custody_mode (🔒 whether Aset custodies or processes this pool's capital at all) and from archive (deleted_at, gated on a terminal-ish lifecycle with zero remaining positions, mirrors an on-chain pause(), needs a reason to reverse). Full three-way comparison and why archive was narrowed: Status Machines → the hidden axis · v3-110 A.
Asset Configuration
Seven settings describe the underlying asset, currency handling, and how value is measured.
| Setting | Type | Default | Mutability | Status |
|---|---|---|---|---|
chain_id | INTEGER | 8453 (Base) | 🔒 Immutable | ✅ Code |
accepted_currencies | TEXT[] | ['USDC'] | 🔒 Immutable (after first deposit) | ✅ Code |
operating_currency | TEXT | 'USD' | 🔒 Immutable | ✅ Code (accepted by pools.post.create / .patch.update; tranche-group members must match on it) |
fx_rate_source | ENUM | FIXED | 🟢 Admin (DRAFT only) | ✅ Code (enum-validated on create) — ⚠️ no guard yet rejecting a non-USD operating_currency with fx_rate_source unset |
collateral_description | TEXT | NULL | 🟢 Admin (instant) | 📋 Spec |
collateral_ratio | NUMERIC | NULL | 🟢 Admin (instant) | 📋 Spec |
chain_id
Which blockchain the pool is deployed on. Determines stablecoin addresses, block explorer, and contract deployment chain.
Currently supported:
1— Ethereum mainnet8453— Base mainnet8217— Kaia mainnet- Testnets:
11155111(Sepolia),84532(Base Sepolia)
Cannot be changed after deploy — the contracts physically live on that chain. To change chain, deploy a new pool.
accepted_currencies
Array of stablecoins this pool accepts for deposits. Stored as TEXT[] (e.g., ['USDC', 'USDT']), default {USDC}. Supported set = USD-backed stablecoins only — USDC, USDT, DAI (the currency enum). USDC is the default/recommended; a single pool may accept several of these.
One stablecoin per pool recommended (multiple USD-backed supported)
We support multiple USD-backed stablecoins (USDC / USDT / DAI) and a pool's accepted_currencies may list several — but we recommend one stablecoin per pool in practice. Investors can only receive yield in the currency they deposited in — so a multi-stablecoin pool requires the partner to send yield in multiple currencies proportionally (complex).
For partners wanting to accept multiple stablecoins, deploy separate pools (e.g., LINE_BK_USDC, LINE_BK_USDT).
Resolved to chain-specific contract addresses via lookup: STABLECOIN_ADDRESSES[chainId][symbol].
Only native (issuer-deployed) stablecoins are supported. Bridged versions (e.g., USDC.e) are excluded due to depeg risk.
operating_currency
The currency the underlying assets are denominated in. Backend-only: used for the reference USD conversion shown on screen and for the conversion applied at redemption. Investors deposit and redeem in USD (USDC) and never hold the underlying local currency, so they carry no direct FX exposure — there is no investor-facing FX disclosure (see below).
- Default:
'USD' - Examples:
'IDR'(Joob — Indonesian Rupiah),'THB'(Thai Baht),'MYR'(Malaysian Ringgit) - Free text — not an enum. Add new currencies as partners are onboarded.
- Different from
accepted_currencies: investors deposit in USD-pegged stablecoin (USDC), but the underlying fund operates inoperating_currency.
Two rules follow, both owned elsewhere: FX is not an input to NAV — NAV measures asset performance in the operating currency and the USD conversion happens at redemption (R7, mechanics in 06 → FX is not in NAV); and there is no investor-facing FX disclosure or banner, since USD-in / USD-out leaves no direct FX exposure (v3-24, 17-changelog).
fx_rate_source
How the FX rate between operating_currency and USD is determined.
| Value | Meaning |
|---|---|
FIXED | A fixed rate set at pool deploy (e.g., Joob = 16,730 IDR/USD). Doesn't change. |
EXTERNAL_FEED | Pulled from partner API daily (e.g., Joob's API returns current rate). |
No on-chain FX oracle: Chainlink/Pyth coverage for IDR/THB/MYR is poor, and on-chain FX would add complexity without benefit. May be revisited if a partner needs real-time conversion.
NAV processing — Aset oracle always (v3-15)
nav_data_source dimension removed. All pools use the Aset oracle Lambda to compute NAV:
- Raw data inputs from partner API (Joob eNote, LINE BK risk dashboard) or admin form
- Aset applies write-off schedule (v3-13), tranche waterfall (v3-14), and its own valuation formula
- Aset calls on-chain
updateNAV()(oracle role)
This eliminates the "pure mirroring" path. Partner suggestions can still feed the input, but Aset always adjudicates the final NAV.
collateral_description
Free text describing what backs the pool's loans/assets. Shown to investors for transparency.
Examples:
"Indonesian EWA payroll receivables"(Joob)"Thai SME invoice receivables"(LINE BK Thailand candidate)"Tokyo real estate"(hypothetical RE pool)NULL— unspecified (rare; use only if collateral details are confidential)
Can be updated by admin at any time (e.g., partner clarifies asset details).
collateral_ratio
Numeric ratio (percent) of collateral value to outstanding loan principal.
- Format:
100 = 100%(1:1 backed) - Examples:
150= 150% overcollateralized ·80= 80% partially collateralized ·NULL= unsecured or unspecified - Optional — can be NULL if collateral is qualitative only (description suffices)
Shown to investors alongside collateral_description for risk assessment.
Yield Configuration
Three settings control how yield is distributed to investors.
| Setting | Type | Default | Mutability | Status |
|---|---|---|---|---|
yield_frequency | ENUM | MONTHLY | 🟢 Admin (instant) | ✅ Code |
net_yield_fee_config | JSONB | NULL | 🟢 Admin (instant) | 📋 Spec |
Yield distribution trigger — manual only (v3-20)
Yield is claim-based / manual-trigger for every pool: the partner deposits yield (depositYield), Aset settles it pro-rata (settleYield), and investors claim it themselves (claimYield()). There is no AUTO-distribution path and no trigger column — the yield_trigger ENUM was dropped in v3-20 (11-db-schema · 17-changelog).
A pool's promised cadence lives in yield_frequency (below) — a public commitment, not an automation trigger.
yield_frequency
How often yield is committed to be distributed. Shown to investors as a promise.
| Value | Meaning | next_yield_due advances by |
|---|---|---|
MONTHLY | Once per month | one calendar month, 12 a year (addUtcMonths, end-of-month clamped: Jan 31 + 1mo = Feb 28/29) |
QUARTERLY | Once per quarter (Joob default) | three calendar months |
CUSTOM | Other frequency (custom_interval_value + custom_interval_unit) | value × unit; a months unit is calendar months |
These three are what the admin wizard offers. The column is plain TEXT with no CHECK, and the backend also recognises WEEKLY / ANNUAL if a row ever carries one, falling back to MONTHLY for anything it does not know — but nothing in the product can set them, so treat the table above as the set.
This axis is the one place "monthly" means a calendar month. It is not 30 days and it is not the epoch cycle's 28 — those are separate time bases, and the three are reconciled once in glossary → month (v3-124). Distribution dates therefore hold their day-of-month instead of drifting, which is what month-end NAV and statement cycles depend on.
Distribution is always manual-trigger, but yield_frequency is visible to investors as a commitment. If one is missed: yield_overdue = true, next_yield_due = last paid period_end + frequency, and admin + investors get alerts. Nothing auto-pauses the pool (v3-94).
net_yield_fee_config
JSONB column holding the fee structure for partners that take a cut of gross yield. NULL for pools without partner fees.
Keys are bps (1% = 100 bps), grouped by recipient (v3-69):
| Key | Paid to |
|---|---|
platform_yield_take_bps · spc_mgmt_bps · perf_fee_bps (with perf_hurdle_bps as its threshold, not a fee) | Aset treasury |
pool_mgmt_bps | fund_fee_wallet — the fund's fee-receipt address, distinct from the deposit money-path fund_wallet |
sourcing / ramping are off-chain operating costs and are not in this config. Amounts are computed off-chain in Lambda (the contract stores no rates), and both fee legs plus the net credit to holders move in a single settleYield call (v3-102).
Worked example, the destination table, and why the calculation is off-chain: How Yield Works → Net Yield Calculation.
Investor Terms
Eight settings control the investor experience: how long funds are locked, when the pool matures, whether an exit is possible before that, what penalties apply for early exit, how tranches divide returns, and whether yield can be reinvested instead of claimed.
| Setting | Type | Default | Mutability | Status |
|---|---|---|---|---|
lockup_days | NUMERIC | 0 | 🟢 DRAFT only, 🔒 after ACTIVE | ✅ Code |
maturity_model | ENUM | FIXED_TERM | 🔒 Immutable | 📋 Spec |
maturity_days | NUMERIC | NULL | 🟢 DRAFT only, 🔒 after ACTIVE | ✅ Code |
redemption_type | ENUM | ON_DEMAND | 🔴 Create-only (on-chain) | ✅ Code |
penalty_type | ENUM | NO_EARLY | 🟢 DRAFT only, 🔒 after ACTIVE | ✅ Code |
tranche_group_id | UUID NULL | NULL | 🔒 Immutable | 📋 Spec |
tranche_role | ENUM NULL | NULL | 🔒 Immutable | 📋 Spec |
allow_rollover | BOOLEAN | false | 🟢 Admin (instant) | ⚠️ not offered on any screen in MVP (2026-08-27, v3-151) |
lockup_days
How long after deposit an investor must wait before redeeming without penalty. 0 means no lockup.
- Default: 0
- Examples:
90(Joob: 3-month lockup),30,180 - Which anchor applies is a property of the pool's implementation, and both are permanently live (
Clones). On the pool-wide implementationlockup_end = subscription_end_date + lockup_days, the same day for every holder; before it, each holder's deposit. On the pool-wide one the three terms are create-only and validated as a set: a lock-up with no offering close, or one ending after the term, is refused when the pool is created, so such a pool cannot reopen a closed offering either. See 07 → 3-State Lockup Model - 🔴 An OPEN_ENDED pool carries no lock-up at all (v3-146, JY 2026-08-25). A FIXED_TERM pool already needs an offering close (the term is measured from it), so its lock-up always has an anchor. An open-ended pool's window is optional — a blank window is a real answer for a pool that never matures — so a pool-wide lock-up there has no day to count from. The field is dropped on that shape rather than demanding a close from a pool whose whole shape is "no scheduled end", or inventing a second anchor nothing records.
lockupAppliesdecides, the API refuses the pair on create and patch, and the chain refuses it too (LockupRequiresSubscriptionEnd).- The consequence: an open-ended pool has no exit gate at all. A penalty needs a maturity to be early OF (
isBeforeMaturityis false with nomaturityDate), so the lock-up was the only one. Every open-ended pool on dev already runs that way — lock-up 0,NO_EARLY. A minimum holding period is deferred to the per-position round. - ⚠️ The refusal is a property of the ANCHOR, not of open-ended pools. If maturity becomes per-position the lock-up follows it back to each holder's deposit, which every pool has, and this lifts by itself — along with v3-144's reopen ban.
- The consequence: an open-ended pool has no exit gate at all. A penalty needs a maturity to be early OF (
- v3-83:
lockup_daysis independent ofpenalty_type— aNO_EARLYpool may still have a lock-up (LOCKED during it, then penalty-free exit). The penalty only applies in the EARLY window (after lock-up, before maturity). Seepenalty_type(supersedes v3-66)
maturity_model
How the pool handles asset maturity.
| Value | Meaning |
|---|---|
FIXED_TERM | Pool has a fixed end date. After maturity, all investors can redeem without penalty. Pool transitions to MATURED. |
OPEN_ENDED | Underlying assets cycle (e.g., 3-6 month loans replaced as they mature). Pool itself runs for a longer period (e.g., LINE BK PoC 6 months). Referred to as "revolving" in product/UX copy. |
The stored enum value is OPEN_ENDED (DB, create/update handler, contract). "REVOLVING" is a display/UX term only — sending maturity_model: "REVOLVING" to the API returns 400.
Choose based on the underlying asset model:
- Single fund with end date →
FIXED_TERM - Continuous lending operation with rotating assets →
OPEN_ENDED
maturity_days
How many days from pool start until maturity. Only relevant when maturity_model = FIXED_TERM.
- Default: NULL (must be set if
FIXED_TERM) - Examples:
365(Joob: 1-year),180(6-month PoC),730(2-year) maturity_daysis a term length, not a maturity. The maturity itself is the absolutepools.maturity_date, written at deploy assubscription_end_date + maturity_days(v3-145; it wasdeploy_time + maturity_daysbefore 2026-08-24) and mirroring the contract'smaturityDate(migration 0161). It is a property of the pool, not of a holding — do not re-derive it frominvested_at. 🔴 Because the term is measured from the offering close, a FIXED_TERM pool must have one: with no close there is no date to mature on, and the publish endpoint and the deploy worker both refuse it. 🔴 The deploy writesmaturity_dateand leavesend_datealone. So on a pool that waited before being deployed the two disagree, permanently, by however long it waited:end_datekeeps the wizard's estimate andmaturity_dateholds the real term end. Behaviour stays correct becauseresolveMaturityAtprefersmaturity_date, but the admin Configuration tab readsend_datefor its Maturity row, so a drifted pool shows a date it does not honour. This is also why maturity is the only date the deploy decides:start_dateandsubscription_end_date(0204) are absolute dates written in the wizard and do not move, so the gap between the offering close and maturity is not stable across a delay. Deploying past the offering close ships a pool the hourly close sweep shuts to new money before a deposit can land, which the publish and retry paths now refuse (v3-141).
📋 Decided, not built — maturity becomes per investor
A pool-level maturity means a holder who deposits on the last day of the raise matures on the same date as one who deposited on day one, and is paid a full term's coupon for a shorter holding. The decision is to anchor maturity per position, the way lockup_days already is (3-State Lockup Model) — but the contract stores an absolute maturityDate and has no maturityDays, so this is a contract change and nothing below is wired.
Not in the MVP. The interim control is a short raise window (~2 weeks), which bounds the drift to the width of the window. Ship it in the same deploy round as the settlement-date yield end if that round happens, since both touch the contract. Full spec and estimate: Notion "만기 기준을 풀 단위 → 투자자별로 전환".
redemption_type
Whether the pool accepts an exit before maturity at all. On-chain (RedemptionConfig.redemptionType), fixed at creation.
| Value | On-chain | Meaning |
|---|---|---|
ON_DEMAND | 1 | Exits are accepted whenever the 3-State model allows them — after lock-up, subject to penalty_type in the EARLY window |
FIXED_MATURITY | 0 | Exits are refused until maturity. RedemptionLib reverts while block.timestamp < maturityDate, and a pool with maturityDate == 0 refuses them indefinitely |
LIQUIDITY_WINDOWS was a third value and is gone (migration 0106) — the windowed behaviour it named is epoch_duration_days > 0, which is an independent axis.
⚠️ This is not the same axis as epoch_duration_days. redemption_type decides whether an exit is possible before maturity; epoch_duration_days decides how an accepted exit is processed, and the same value serves both the EARLY window and the post-maturity period. The two are orthogonal on-chain — see Redemption → segment × mode for which combinations the platform can actually express.
penalty_type
What penalty (if any) applies in the EARLY window (after lock-up, before maturity). v3-83: penalty_type is independent of lockup_days (see note below) — any type can pair with any lock-up.
| Value | Meaning |
|---|---|
NO_EARLY | No early-exit penalty (rate 0). Independent of lock-up: lockup_days = 0 → redeem anytime free; lockup_days > 0 → locked, then penalty-free exit. |
FLAT_FEE | Fixed dollar amount taken (e.g., $50 per redemption). |
PRINCIPAL_BASED | Percentage of principal taken (e.g., 5% of redeemed amount). |
YIELD_BASED | Percentage of accrued yield forfeited (e.g., Joob: 50% dividend forfeiture). |
Early-exit penalties are transferred to the pool's fund_wallet (v3-85) — they no longer accumulate in the reserve. See Redemption for full mechanics.
⚠️ NO_EARLY means the early-exit penalty is 0 — not "no early redemption allowed" and not "no lock-up". It may pair with any lockup_days and any redemption_type. (v3-66 coupled the two and enforced it on-chain; those guards were removed — v3-83.)
Tranche Group (v3-14)
For tranched products, deploy 2-3 separate SINGLE pools sharing a tranche_group_id. Each pool has a tranche_role:
| Value | Meaning |
|---|---|
| NULL | Standalone pool (no tranche). Default. |
SENIOR | Lowest risk, lowest yield. Absorbs losses last. |
MEZZANINE | Middle tier (optional, max 3 tiers total). |
JUNIOR | Highest risk/yield. Absorbs losses first. Typically partner-provided (LFC's first-loss capital). |
How grouping works, same-group requirements, constraints, and Junior depletion
How grouping works:
- Admin creates 2-3 separate pools with the same
tranche_group_idand differenttranche_role. - Deployment validates the same-group constraints below.
- Investors deposit into the tranche of their choice.
- Aset oracle Lambda fetches partner data (asset values, DPD, losses) for the underlying fund as a whole.
- Lambda applies the loss waterfall (Junior → Mezzanine → Senior) and updates each pool's NAV independently via
Pool.updateNAV(). - Investors redeem from their own pool at their own pool's NAV — no cross-pool interaction on-chain. See Tranche Group Waterfall.
Same-group requirements (enforced at Factory deploy + admin UI validation):
- Must match:
fund_wallet,operating_currency,chain_id,external_provider,maturity_days - Can differ:
reserve_bps,apy_rate,jurisdiction_whitelist,lockup_days,penalty_type,penalty_rate_bps
Constraints:
- Max 3 pools per
tranche_group_id(one per role) - Unique constraint on (
tranche_group_id,tranche_role) — no duplicates within a group - A pool belongs to at most one group (
tranche_group_idis single-valued)
Junior depletion behavior: When a Junior pool's NAV hits 0, it auto-transitions to IMPAIRED (v3-12). Deposits paused; redemptions remain open with lockup waived. Partner can recover by topping up capital → NAV restored → return to ACTIVE. Otherwise eventually wound down.
Cannot change tranche_group_id or tranche_role after deploy.
allow_rollover
Whether investors can reinvest their accrued yield back into the pool (instead of claiming to wallet).
🔴 Not offered on any screen in MVP. Decided 2026-08-27 (v3-151): reinvest is removed from the investor app and from admin (fund manager included). Not yet reflected. ⚠️ The on-chain reinvest() and POST /yield/reinvest both remain — what actually closes the path is this field's false default, not the removal of the buttons.
- Default:
false - When
true: Investor can callreinvest()to mint additional LP from accrued yield (no penalty; lock-up anchored exactly as a deposit is — v3-130) - See Investment Lifecycle → Reinvest V1 for full rules
Compliance
Who can invest is gated by jurisdiction + a valid SBT. There is no pool-level KYC/KYB level gate — the SBT stores the level but it does not gate deposits; investor qualification is handled backend-side by pools.eligibility_mode (STATUS / MIN_TICKET) + users.investor_status, enforced in checkKycGating (v3-74 · 03-kyc-identity).
| Setting | Type | Default | Mutability | Status |
|---|---|---|---|---|
enforce_jurisdiction | BOOLEAN | false | 🟡 Timelock | ✅ Code |
jurisdiction_whitelist | TEXT[] (ISO codes) | [] (all allowed) | 🟡 Timelock | ✅ Code |
allows_us_persons | BOOLEAN | false | 🟡 Timelock | ✅ Code |
enforce_jurisdiction + allows_us_persons
enforce_jurisdiction (0064) is the master switch: off = no country restriction; on = the whitelist applies (empty whitelist + enforce on = block all). allows_us_persons defaults to false, rejecting US-country investors — US-person status is derived from the SBT countryCode, not a separate flag.
jurisdiction_whitelist
ISO 3166-1 alpha-3 codes allowed to deposit (['KOR', 'SGP'] = those two only; [] = open to all). Alpha-3 is canonical end-to-end — SumSub → DB → SBT → on-chain validation — because the contract compares keccak256(whitelist code) against keccak256(SBT.countryCode), so "KR" would never match a stored "KOR". Remaining off-chain cleanup items: v3-60.
Enforced per pool, on-chain: the requiresKYC modifier reverts JurisdictionNotAllowed, in addition to the isValidKYCNonUS() US-person check (08a → §3). Mirrored at the API layer (deposits.post.create via kyc-gating.ts) as defense-in-depth before the tx. Changes need multi-sig + 7-day timelock.
See KYC & Identity.
Pool State
Three runtime settings track the pool's lifecycle and operational state. These are not "configured" at deploy — they change over the pool's lifetime.
| Setting | Type | Default | Mutability | Status |
|---|---|---|---|---|
lifecycle_status | ENUM | DRAFT | Automatic + Admin trigger | ✅ Code |
is_paused | BOOLEAN | false | 🟢 Admin (instant) | ✅ Code |
is_emergency_frozen | BOOLEAN | false | 🟢 Admin (instant) | ✅ Implemented (freeze/unfreeze handlers · freeze_started_at migration 0023 · contract mirror) |
lifecycle_status
The pool's current phase.
| Value | Meaning |
|---|---|
DRAFT | Pool created but not yet deployed. Configurable. |
UPCOMING | Deployed; subscription window not yet open. |
ACTIVE | Subscription open; deposits and redemptions allowed. |
IMPAIRED (v3-12) | Partner distress; deposits paused, redemptions open. Recoverable to ACTIVE. |
CLOSED | Subscription closed; existing investors only (no new deposits). Redemptions and yield claims continue. Reached by POST /pools/{id}/close or automatically at end_date via pools.scheduler.lifecycle, and reopenable (action: reopen) — ✅ shipped (v3-110 B). |
MATURED | Reached maturity; all redemptions allowed without penalty. |
WIND_DOWN | Terminal. NAV is forced to a pro-rata share of what the pool actually holds, and exits run through requestRedemption → claim only — no standalone redeem(). Formula: Emergency Wind-Down. |
⚠️
ARCHIVEDis not in this enum. It is a display label derived fromdeleted_at, layered over whatever the lifecycle actually is — a third axis, not a stage. Same family asis_paused. See Status Machines → the hidden axis and v3-110.
v3-40 — Preview (showcase) tier
A pool can be listed as a marketing/showcase entry — visible but never investable on Aset (e.g. a partner's other funds, to drive interest in a future round). A preview pool shows Overview only (high-level profile / deal structure); Performance and Fund-Data are hidden, and it carries a "register interest" CTA (also shown on CLOSED pools). Distinct from UPCOMING (opens here soon) and CLOSED (was open here). Display tiers — Preview / Display / Full — drive which partner API fields are required (preview = static profile only). See v3-40.
Per-state details + the full transition table (proposeImpairment / cancelImpairment / proposeWindDown timelocks) are canonical in → Status Machines § Pool Lifecycle.
is_paused
Soft pause — admin can temporarily stop new deposits while keeping redemptions open.
- When
true: newdeposit()calls revert; existing redemption requests still process normally - Use case: temporary issue (e.g., partner sync problem), compliance review, market conditions
- Investors can still get their money out
is_emergency_frozen
Hard pause — all activity halts for emergencies (compliance issue, contract bug, security incident).
- When
true: all functions revert (deposits, redemptions, yield claims, etc.) - Use case: critical issues requiring admin intervention
- Investors are blocked from any action until unfrozen
Use sparingly
is_emergency_frozen blocks investors from accessing their funds. Only use for genuine emergencies. Communicate clearly to investors when triggered.
Operations / Advanced (Optional)
Operational-control settings, including the redemption model and NAV safety bounds.
| Setting | Type | Default | Mutability | Status |
|---|---|---|---|---|
epoch_duration_days | NUMERIC | 0 | 🔴 Create-only | ✅ v3-26 / v3-38 |
redemption_gating_bps | INTEGER | NULL | 🟡 Timelock | ⚠️ deprecated — not included in MVP (2026-08-27, v3-150) |
redemption_term_epochs | INTEGER | NULL | 🔴 Create-only | 🔨 0189 (pending dev apply) — required for FIXED_MATURITY + epoch |
epoch_date_basis | TEXT | NULL | 🔴 Create-only | 🔨 0189 (pending dev apply) — CALENDAR / FIXED_DAYS |
epoch_roll_day | SMALLINT | NULL | 🔴 Create-only | 🔨 0189 (pending dev apply) — 1–28 under CALENDAR |
nav_deviation_cap_bps | NUMERIC | (config) | 🟡 Timelock | ✅ v3-32 (core) |
nav_staleness_seconds | NUMERIC | (config) | 🟡 Timelock | ✅ v3-32 (core) |
epoch_duration_days — Redemption model selector (v3-26)
Selects how the pool processes exits. Deposits stay instant/atomic either way.
0 — instant | > 0 — epoch | |
|---|---|---|
| Settlement | Per request, inside the requestRedemption tx: reserve covers → settles immediately, short → PENDING_RESERVE and the partner's fundRedemption auto-settles it. No admin approve step (v3-82) | In bulk at the end of a request window: carry-first pro-rata fill, rollover of the unfilled remainder, pull claim |
| Pricing | NAV locked at request time | Forward priced — NAV at settlement |
| Extra statuses | — | QUEUED · PARTIALLY_FILLED |
| Typical default | FIXED_TERM | OPEN_ENDED |
Windows are funding-date-anchored cycles (fundingAnchor + recallLeadDays + requestWindowDays), not fixed N-day spans: requests are refused outside the window and demand freezes at the cutoff. Full mechanics — and the settlement-escrow and schedule internals — in 07-redemption → Model B.
The default is a creation-time prefill, not a constraint. epoch_duration_days is orthogonal to maturity_model with no validation coupling, so a FIXED_TERM pool may be created with > 0 when run risk warrants epoch fairness — the selection axis is run risk, not whether the pool has a maturity (v3-46). The admin wizard separately hides the selector for redemption_type = FIXED_MATURITY, a wizard-layer guard only (v3-88 D7).
🔨 Built, not deployed — that wizard guard is out
D7 assumed a matured pool pays out once, so a cadence would be meaningless. A pool that repays principal over several post-maturity cycles is exactly that combination, and the chain has always allowed it — the guard was the only thing refusing it. The wizard now allows FIXED_MATURITY + epoch_duration_days > 0 and the schedule block is relabelled "post-maturity redemption", since those terms only take effect after maturity for this combination (v3-132).
⚠️ "No contract change" still holds; "no schema change" does not. Fixing the dates took migration 0189 (three plan columns) and 0190 (redemption_epochs.funding_date), neither applied to dev. The chain was not touched: the funding-date ordering guard added with it lives in the endpoint, not in GovernanceLib (v3-139). Spec: Redemption → post-maturity epoch redemption.
Create-only (v3-38): folded into the deploy multicall as on-chain epochDurationDays and not exposed for editing afterwards — see the Mutability Matrix for why, and Smart Contracts → Field Sync for the sync class.
redemption_gating_bps
⚠️ deprecated — not included in MVP. Decided 2026-08-27 (v3-150), not yet reflected — the cap comes off the screens and the API, and the admin forms and investor copy still carry it today. The contract and the DB column stay as they are — storage, the governance setter and its events are all still there, and this is a labelling change, not a removal. In MVP the value is always 0/NULL, so the settlement formula below is unaffected. ⚠️ The other gate — an investor may only request as much as their own LP balance (InsufficientLPTokens) — is a separate rule and stays in force.
Optional cap on how much of the pool can be redeemed per epoch. For epoch pools this caps available (redeemable reserve) at gating_bps/10000 × totalDeposited before the fill-ratio math (available = min(available, redemption_gating_bps × totalDeposited / 10000)); for instant pools it's a future option. The base is totalDeposited — the on-chain deposited-capital accumulator (cumulative deposits + reinvested yield − redemption payouts), not TVL (LP supply × NAV). The two diverge under writedown / undistributed yield, so gating is measured against book capital, not market value. Protects against mass-redemption ("bank run") scenarios where reserve gets depleted faster than the partner can replenish. (Contract: RedemptionLib._epochFillRatio; v3-67.)
- Default: NULL (no gating)
- Example:
500= max 5% (500 bps) of pool's deposited capital settled per epoch. Excess rolls over (epoch) / queues (instant). - Selective (default off); enforcement only when a partner requires it.
The repayment plan: redemption_term_epochs · epoch_date_basis · epoch_roll_day
🔨 Shipped as three columns (0189), not applied to dev
⚠️ redemption_window_epochs was the spec name; the column shipped as redemption_term_epochs. Older text on this page and in 16-timeline still uses the long name, and both refer to the same field. Two rule columns arrived with it, which the original spec did not anticipate.
redemption_term_epochs is how many post-maturity cycles a post-maturity epoch pool repays principal over. It is the end of the schedule; the anchor and the cadence already exist as fundingAnchor and epoch_duration_days, which is the standard three-value way to state a repayment schedule (ACTUS) and the only one of the three the platform was missing.
- Stored as a cycle count. How it is entered depends on the basis — ⚠️ not "always months", as this page said while the field was a proposal. Under
CALENDARthe wizard asks in months, because the calendar is what fixes the dates. UnderFIXED_DAYSit asks in cycles, because a 28-day cadence is not a month: four cycles is 112 days, which is no whole number of months, so a months label would print a number the schedule does not contain (v3-132). - NULL means unbounded, which is what every existing epoch pool is. Adding the field did not change their behaviour.
- It does not size the instalments. Each cycle settles whatever the partner funded for that cycle, pro-rata — there is no per-cycle amount to store and no assumption of equal quarters. See 07-redemption → settlement internals.
- ⚠️ It stopped being optional. The MVP judgement here was "deferred unless the investor screen needs the counter", on the grounds that operations knows the count from the term sheet. That held while the counter was cosmetic. Once the deploy has to write a finite list of dates (v3-133), the term is what says how many there are, and the reminder, the per-cycle endpoint and the confirmation card all read it. Required for this combination. Still create-only, so it can be decided late but never retrofitted.
epoch_date_basis (CALENDAR / FIXED_DAYS) and epoch_roll_day (1–28) are the create-time answers, kept so the post-deploy card can rebuild the same list against the real maturity instead of asking twice. Roll days stop at 28 because February does, and every rule covering for a missing 29th–31st moves a payout the partner agreed to.
🔴 What may be done with a date these two produce is v3-136: show it to a person, never write it to the chain and never let it decide a schedule. Reading them is fine — the wizard preview, the confirmation diff and the reminder's suggestion all do — the ban is on a machine acting on the answer.
NAV safety bounds (nav_deviation_cap_bps / nav_staleness_seconds) — v3-32
Enforced inside the immutable core (updateNAV), not just off-chain: a per-update deviation cap (e.g. ±5% = 500 bps) + a staleness window + a circuit breaker. These also gate epoch settlement — executeEpoch auto-holds when NAV deviates beyond the cap, is stale, or the circuit breaker is tripped (the NAV-class anomaly hold). Admin setters: setNavDeviationCap / setNavStaleness / tripCircuitBreaker / resetCircuitBreaker.
Mutability Matrix — what changes, and when
Every dimension in one place, answering the question operators actually ask: once a pool goes ACTIVE, what can still change, and through which mechanism? The nuance worth reading twice is that several fields are freely editable in DRAFT and lock at the DRAFT → ACTIVE transition, not at deploy.
Legend: 🔒 locked · 🟡 7-day timelock + multi-sig · 🟢 instant · 🔴 create-only · ⚙️ scheduler + admin trigger.
| Field | DRAFT | After ACTIVE | Why |
|---|---|---|---|
lockup_days | 🟢 Edit freely | 🔒 Locked | Investors deposited under the published lockup; changing it post-deposit breaks their terms |
maturity_days | 🟢 Edit freely | 🔒 Locked | Same — maturity is part of the published investor terms |
penalty_type | 🟢 Edit freely | 🔒 Locked | Early-exit penalty is a contractual term at deposit time |
fx_rate_source | 🟢 Edit freely | 🔒 Locked | NAV-conversion source fixed once the pool is live |
chain_id | 🔒 Immutable | 🔒 Locked | Contract lives on that chain (fixed at deploy) |
accepted_currencies | 🟢 Edit freely | 🔒 Locked after first deposit | Tied to deposit history |
operating_currency | 🔒 Immutable | 🔒 Locked | Underlying asset denomination |
is_showcase | 🔒 Immutable | 🔒 Locked | Changes the fundamental pool nature |
maturity_model | 🔒 Immutable | 🔒 Locked | Underlying contract structure |
tranche_group_id / tranche_role | 🔒 Immutable | 🔒 Locked | Tranche linkage fixed at deploy (v3-14) |
fund_wallet | 🟢 Edit freely | 🟡 Timelock | Material: destination of the partner remainder → 7-day timelock + multi-sig |
treasury (Aset fee wallet) | 🟢 Set at create | 🟢 Instant · multi-sig (no timelock) | Fee destination (Aset). Fee auto-distributes with no hold, so a timelock would only strand fees at a stale address — changed instantly via multi-sig (v3-69) |
fund_fee_wallet (Pool mgmt fee wallet) | 🟢 Set at create | 🟢 Instant · multi-sig (no timelock) | Fee destination (Fund), separate from fund_wallet. Same reason — no hold on fee payout → no timelock (v3-69) |
reserve_bps | 🟢 Edit freely | 🟡 Timelock | Material: investor protection level → 7-day timelock + multi-sig |
jurisdiction_whitelist / enforce_jurisdiction / allows_us_persons | 🟢 Edit freely | 🟡 Timelock | Material: who can invest (jurisdiction) → 7-day timelock + multi-sig |
redemption_gating_bps | 🟢 Edit freely | 🟡 Timelock | ⚠️ deprecated — not included in MVP (2026-08-27, v3-150). Material: liquidity rules → 7-day timelock + multi-sig |
yield_frequency | 🟢 Edit freely | 🟢 Edit freely | Operational cadence (manual-trigger) |
net_yield_fee_config | 🟢 Edit freely | 🟢 Edit freely | Fee structure |
collateral_description / collateral_ratio | 🟢 Edit freely | 🟢 Edit freely | Descriptive asset info, not a contractual term |
allow_rollover | 🟢 Edit freely | 🟢 Edit freely | Reinvest toggle. ⚠️ not offered on any screen in MVP (2026-08-27, not yet reflected, v3-151) — the toggle comes off the admin forms, the field stays |
is_paused / is_emergency_frozen | 🟢 Edit freely | 🟢 Edit freely | Emergency controls — must stay instant by design |
capacity | 🟢 Edit freely | 🟢 Raise-only | Off-chain (Lambda-enforced); raising the cap admits more investors with no harm to existing holders — lowering is rejected (v3-43) |
min_investment | 🟢 Edit freely | 🔒 Locked | Off-chain, but a published investor term — locked after ACTIVE for term integrity; change = new pool (v3-43) |
lifecycle_status | — | ⚙️ Scheduler + admin trigger | Date-driven transitions; admin can trigger CLOSED (+ reopen, v3-110) / IMPAIRED / WIND_DOWN. Archiving is a separate axis, not a value here |
is_hidden | 🟢 Edit freely | 🟢 Edit freely | Visibility only, reversible, no on-chain effect, no capability change. As shipped it filters the admin list only (v3-110 A · is_hidden) |
deleted_at (Archive) | 🟢 Archive/restore | 🟡 Gated | Archive allowed only from a terminal-ish lifecycle with every position at zero; mirrors an on-chain pause(). Restore needs a reason and is audited (v3-110 A) |
redemption_type | 🟢 Set at create | 🔴 Create-only | On-chain in RedemptionConfig, set only in initialize with no setter — a pool is pinned to the value it was created with (migration 0106) |
redemption_term_epochs · epoch_date_basis · epoch_roll_day | 🔴 Create-only | 🔴 Create-only | Built (0189, v3-132), pending dev apply. Create-only by nature: the term names the end of a schedule investors deposited against, and the two rule columns have to still describe the list that was written |
epoch_duration_days | 🟢 Set at create | 🔴 Create-only | Changing the redemption model on investors who already deposited would be an undisclosed redemption gate. The on-chain setter reverts once totalLPSupply > 0; a timelocked governance path is deferred to v2 (v3-38) |
nav_deviation_cap_bps / nav_staleness_seconds | 🟢 Edit freely | 🟡 Timelock | NAV safety bounds enforced inside the immutable core (v3-32) |
Two things the table's mechanism column is easy to misread:
- Fee destinations are instant and not timelocked (v3-69).
treasuryandfund_fee_walletare multi-sig but immediate: fee distribution has no hold, so a delay could not stop a payout to a wrong address during the window — it would only trap the fix.fund_wallet(the investor-capital remainder) keeps its 7-day timelock, which is why the two sit in different rows. - 🔒 after ACTIVE means "deploy a new pool" — there is no unlock path for
lockup_days,maturity_days,penalty_type,fx_rate_sourceormin_investment.
Validation Rules
Invalid dimension combinations rejected at pool deploy or update.
| Rule | Why |
|---|---|
is_showcase = true AND fund_wallet set | A showcase pool never deploys, so there is no money-in signer to name |
is_showcase = true AND any deposit/redemption config | These pools don't process transactions |
maturity_model = FIXED_TERM AND maturity_days = NULL | FIXED_TERM requires a maturity date |
maturity_model = OPEN_ENDED AND maturity_days set | OPEN_ENDED (revolving) has no fixed maturity |
penalty_type = FLAT_FEE AND penalty_fee_amount = NULL | Flat fee requires an amount |
penalty_type IN (PRINCIPAL_BASED, YIELD_BASED) AND penalty_rate_bps = NULL | These types require a rate |
tranche_group_id set AND same-group constraints violated | Pools in same group must share fund_wallet, operating_currency, chain_id, external_provider, maturity_days (v3-14) |
tranche_group_id set AND > 3 pools in group | Max 3 tranches per group (SENIOR/MEZZANINE/JUNIOR) |
tranche_role set AND tranche_group_id NULL (or vice versa) | Both fields required together |
fund_wallet is contract that can't receive ERC20 | Funds would be stuck — fail deploy |
accepted_currencies is empty array | Pool must accept at least one stablecoin |
operating_currency != 'USD' AND fx_rate_source not set | Non-USD pools need FX rate handling |
Deploy script and Admin UI both enforce these. See pools.post.create.ts for backend validation.
How Funds Flow
This section explains how money moves through a pool — from deposit to fund wallet, reserve handling, and stablecoin specifics.
Canonical end-to-end view
For the unified money map across all three streams (capital / fee / Aset fee), canonical wallet & bank-account naming (v3-70), and the on-chain ↔ off-platform boundary, see 23-money-path. The mechanics below feed into that view.
Where Funds Live
Stage-by-stage custody is tabulated once, under Core Principles → Non-Custodial. Aset's only on-chain role is calling specific functions (approve redemption, settle yield); it cannot move funds unilaterally.
Reserve Split Logic
When an investor deposits, the pool keeps a portion (reserve_bps, default 1000 = 10%) as a liquidity buffer and sends the rest to the partner's fund_wallet.
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 in-pool retention example below is a legacy implementation record.
On deposit:
reserve_amount = deposit_amount × reserve_bps / 10000
partner_amount = deposit_amount - reserve_amount
Pool contract retains reserve_amount (added to reserveBalance)
safeTransfer(fund_wallet, partner_amount) — sends the remainder out, emits ReleasedToPartnerExample: $10,000 deposit, 10% reserve
reserve_amount = $10,000 × 10/100 = $1,000 → stays in Pool reserve
partner_amount = $10,000 - $1,000 = $9,000 → sent to fund_wallet
LP minted to investor based on (deposit_amount / nav_per_token)Timing
The split happens in the same transaction as the deposit, always — not "for most pools" and never later. The LP mint and the remainder transfer are both inlined in deposit(), so the investor's capital reaches fund_wallet in the block it leaves their wallet. There is no branch — a hold-back lever used to provide one and was removed in 0183 (23-money-path §3a).
Stablecoin (Per-Pool, Chain-Specific)
Each pool accepts one or more stablecoins, defined in accepted_currencies. The mapping from symbol to contract address depends on the chain.
Currently supported stablecoins:
| Symbol | Decimals | Notes |
|---|---|---|
| USDC | 6 | Default. Issuer-deployed Circle USDC preferred. |
| USDT | 6 | Tether. Available on most chains. |
| DAI | 18 | MakerDAO. Note: 18 decimals (vs 6 for USDC/USDT). |
Chain-specific addresses are resolved via:
STABLECOIN_ADDRESSES[chainId][symbol]Example:
- USDC on Base (chainId 8453):
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 - USDC on Ethereum (chainId 1):
0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 - USDC on Kaia (chainId 8217): TBD
Only native (issuer-deployed) stablecoins
Bridged versions (e.g., USDC.e on Polygon) are excluded due to depeg risk. We only accept Circle-issued USDC, Tether-issued USDT, etc. on chains where the issuer has native deployment.
Deposit Flow Diagram
The same flow as 8 numbered steps — and why there is no step 9
Steps:
- Investor approves Pool to spend their USDC
- Investor calls
Pool.deposit(USDC, amount) - Pool pulls USDC via
safeTransferFrom - Pool mints LP tokens to investor (immediately, in same tx)
- Pool retains reserve percentage as
reserveBalance - Pool
safeTransfers the partner remainder tofund_walletand emitsReleasedToPartner— inlined at the tail ofdeposit(); there is noreleaseToPartner()function to call (23-money-path §3a) - Pool emits
Depositedevent - Aset records the deposit —
POST /deposits(verifies theDepositedevent in the receipt), with the on-chain indexer as a ~2-minute reconciler
There is no step 9. The flow ends at Aset's own record — no partner endpoint is called on deposit, and the FM reads the platform instead (v3-115 holds the full correction). Note ReleasedToPartner is unindexed, so the release leg has no off-chain record at all (23-money-path §3f).
How Yield Works
Yield is generated by the underlying fund (e.g., loans repaid by borrowers) and flows back to investors. The mechanics depend on yield_frequency, tranche group membership (v3-14), and net_yield_fee_config. (Distribution is claim-based / manual-trigger only — AUTO removed v3-20.)
Yield Flow Diagram
Net Yield Calculation
For pools with net_yield_fee_config, gross yield is split into the investor share and the fee legs. The split is computed off-chain in Lambda, because fee terms differ per partner and a DB config change beats a contract upgrade.
Worked example on $1,000 gross, with Joob's config {platform_yield_take_bps: 100, perf_fee_bps: 2000, perf_hurdle_bps: 1500}:
admin_fee = gross × platform_yield_take_bps / 10000 = $1,000 × 100/10000 = $10
# perf fee only applies above the hurdle (15% APY = 1500 bps)
perf_fee = (pool_apy × 100 > perf_hurdle_bps)
? gross × perf_fee_bps / 10000 = $1,000 × 2000/10000 = $200
: 0
net = gross − admin_fee − perf_fee = $790
Pool.settleYield(usdc, 790, 210, 0) # one tx: 790 → LP holders, 210 → treasuryFee Destination
Fees have two destinations, split by recipient (v3-69):
| Fee | Destination | Recipient |
|---|---|---|
| Platform yield take · SPC mgmt · Perf (dormant) | treasury_wallet — Aset's multi-sig (e.g. Gnosis Safe) | Aset |
| Pool mgmt | fund_fee_wallet — per-pool, distinct from the deposit money-path fund_wallet | Fund manager |
Both legs are paid inside the single settleYield(stablecoin, netAmount, treasuryAmount, poolMgmtAmount) call that also credits net to holders: the net credit and the combined fee draw are bounded against unclaimedYield once, then each non-zero leg transfers and emits its own FeesWithdrawn. A pool-mgmt leg with no fund_fee_wallet set reverts (FundFeeWalletNotSet) rather than burning to address(0). Amounts are computed off-chain in Lambda — the contract stores no fee rates. The deposit money-path (90/10, immutable) is untouched; this is the distribution stage only.
Both destination wallets (treasury_wallet, fund_fee_wallet) are instant multi-sig setters with no timelock — see the Mutability Matrix for why they differ from fund_wallet.
Tranche Group Waterfall (v3-14)
When pools share a tranche_group_id, yield and losses follow a waterfall across the linked pools. Each pool is a standard SINGLE pool with its own NAV — the waterfall is applied by Aset oracle Lambda during NAV computation.
Smart contract impact: zero. No two-LP-per-pool, no on-chain waterfall code; Lambda computes each pool's NAV and calls Pool.updateNAV() on each separately. Possible because v3-15 made Aset the sole NAV processor.
The cost of doing it off-chain is a trust model: Senior investors rely on Aset to mark Junior down first. Accepted for v1 — bounded by NAV guards (v3-32) and money-path immutability, so the worst case is mis-marking rather than theft. A trustless on-chain waterfall can be added later as a separate pool type (v3-39).
Yield Waterfall (Senior gets paid first)
⚠️ Design only — not implemented. v3-50 shipped the loss waterfall first, since loss protection is the core of a tranche. Yield is currently distributed per pool independently; Senior-APY-first allocation is added when a deal requires it (v3-50).
The intended design: Lambda pays the Senior pool its target rate first (apy_rate × LP supply × period), then Mezzanine if present, and the Junior pool takes the remainder — so Junior earns more in good periods and nothing in bad ones. Each pool's NAV is updated to its allocated share and investors redeem from their own pool at that NAV.
Loss Waterfall (Junior absorbs first)
✅ Built (v3-50): POST /tranche-writedown runs the engine (lib/shared/tranche/loss-waterfall.ts) Junior → Mezzanine → Senior and writes each pool's new NAV through the per-pool NAV path. Smart-contract change: zero. How the NAV write itself behaves — no nav_proposals row, a per-pool 24h timelock on a decrease, no cross-pool atomicity — is recorded in v3-106.
Three governance rules apply, all in merged code (v3-106):
- ADMIN / SUPER_ADMIN only — an
OPERATORcall is a 403, because marking a whole group down is the largest of the three NAV-write paths. - A wiped Junior escalates on-chain, via
proposeImpairmentOnChain, recordingimpairment_proposed_at+junior_depleted_atand neverlifecycle_status; the label flips atexecuteImpairmentafter the 7-day timelock. Deposits stay open across that window (nothing auto-pauses, v3-94), so the response flagsdeposits_open_during_timelockfor the operator to pause deliberately. - Only complete exhaustion is IMPAIRED — a partially hit Senior or Mezzanine stays
ACTIVEwith NAV < 1.0. An IMPAIRED Junior beside an ACTIVE Senior is the correct steady state, not drift.
A loss (DPD writeoff via v3-13, or any other) hits Junior NAV first, Mezzanine only once Junior is exhausted, and Senior only after that. On a $500 loss with $300 of Junior capital: Junior absorbs $300 and is wiped, Senior's NAV drops by the remaining $200 while staying ACTIVE.
An exhausted Junior escalates to IMPAIRED — deposits paused, redemptions open with lockup and penalty waived — and the partner can recover it by adding capital (v3-12).
⚠️ "NAV 0" is never actually stored. The contract rejects newNav == 0 and the DB has CHECK (nav_per_token > 0), so a wiped tranche is written at 0.000001 (NAV_FLOOR, a constant local to tranche.post.writedown.ts). The lifecycle transition, not the number, is what says "effectively zero".
Tranche Group Parameters
The waterfall uses each pool's standard fields. No special tranche-only columns:
- Each pool's
apy_ratedefines its yield target (Senior = lower, Junior = higher) - Each pool's
capital(=lp_supply × nav) is its loss-absorbing capacity - Loss order is purely by
tranche_role(Junior → Mezzanine → Senior)
Each pool in a group keeps its own reserve, and that reserve still pays its redemptions first — reserve and tranche are different mechanisms, per Core Principles → Reserve and Tranche Are Different.
How Redemption Works
Standard redemption mechanics — lockup check, penalty calculation, NAV snapshot, payout — are detailed in Redemption. This section covers the emergency wind-down path, used when a partner becomes unresponsive.
Emergency Wind-Down
Legacy wind-down formulas
The balance-based formulas and diagrams below describe legacy implementations. The current external-reserve executeWindDown() preserves oracle NAV. External funding and actual payout are separate.
If a partner becomes unresponsive (e.g., systemic failure, insolvency, prolonged inactivity), the pool can be wound down — terminating the pool and distributing whatever's recoverable to investors.
Recovering the partner remainder is NOT guaranteed
The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process.
The remainder sitting in fund_wallet is partner-controlled and not recoverable on-chain — that requires off-chain legal action. This is the inherent risk of partner-operated pools, and investors must be informed at deposit time.
Wind-Down Process (3 steps, ~90 days)
The same 90 days as a dated timeline, with the exact state changes at each step
T+0 Partner's last contact
T+60 days Aset Lambda monitors → flags "60 days unresponsive"
T+60 days Admin (multi-sig) calls Pool.proposeWindDown()
→ Event WindDownProposed emitted
→ Investors and partner notified
→ 30-day timelock begins
T+60..90 Timelock period:
- Partner can respond → multi-sig calls cancelWindDown() to abort
- Aset reaches out via webhooks, emails, off-chain channels
- LP holders see "wind-down pending" warning on pool detail
T+90 days Timelock passes → anyone can call Pool.executeWindDown()
→ navPerToken forced to the pro-rata formula below (v3-12)
→ lifecycle_status = WIND_DOWN (blocks deposits via whenActive gate)
→ isEmergencyFrozen UNCHANGED (freezing would block exits — the opposite of what wind-down needs)
→ Lockup/penalty waived
After Each LP holder redeems // standard requestRedemption → approveRedemption (v3-12)
→ payout = lpAmount × navPerToken (= pro-rata share of reserve)
→ Burns LP, transfers USDC
→ the released remainder is lost (in partner wallet, not recoverable on-chain)redeem() is shorthand — there is no such function. In WIND_DOWN, redemption runs the standard requestRedemption → claim path with lockup and penalty waived (v3-12 removed the once-proposed standalone claimWindDown()). navPerToken is forced to:
navPerToken = (reserveBalance + unconsumed epoch top-ups)
÷ (totalSupply − settledUnclaimedLp)so the sum of every holder's pro-rata payout equals the numerator exactly. Derivation and the reason both sides had to change together: 06 → R10.
⚠️ The request gate measures the same buckets as the numerator, so a PENDING_RESERVE / partner top-up cannot occur in this state — which is the point, since the partner whose silence triggered the wind-down is not going to fund anything. When the two disagreed, a wound-down pool priced an exit it then refused to settle. Pinned by test_WindDown_HeldFundsAreReachable_NoPendingReserve.
Why 60 + 30 days
The reasoning, and the industry precedent it follows
Based on traditional finance norms:
- 60 days unresponsive: enough time to distinguish "partner is busy" from "partner is gone." Aset confirms via multiple channels.
- 30 days timelock: gives partner final chance to respond (e.g., they were in maintenance, regulatory issue, etc.)
- Total ~90 days: matches private credit industry default standards ("90 days past due" = formal default threshold)
Sources: Maple Finance defaults policy, private credit standstill provisions, ABS default frameworks.
Wind-Down Diagram
Every pool with is_showcase = false carries the matching Partner risk and Wind-down recovery limit warnings on its detail page — see Risk Disclosure below for the exact copy and its trigger conditions.
Risk Disclosure
Investors see standard risk warnings on each pool's detail page. Warnings are auto-generated based on the pool's dimension values.
Standard Warnings (auto-generated)
For each pool, the UI shows applicable risk warnings as a collapsible box.
There is deliberately no FX risk warning — see operating_currency.
| Risk | Triggered when | Warning shown |
|---|---|---|
| Partner risk | is_showcase = false AND fund_wallet ≠ Aset treasury | "Most of your deposit is held by the fund operator. In case of operator failure, only the Pool reserve (X%) is recoverable on-chain." |
| Lockup risk | lockup_days > 0 | "Funds are locked for [N] days from your deposit. Early redemption incurs a penalty: [penalty details]." |
| Tranche risk (Senior) | tranche_role = SENIOR (v3-14) | "You hold Senior LP. Junior/Mezzanine pools absorb losses first, but in extreme scenarios you may incur losses if they are depleted." |
| Tranche risk (Junior) | tranche_role = JUNIOR (v3-14) | "You hold Junior LP. You absorb first losses but receive higher yield in good periods. You may lose your entire investment if losses exceed Junior capital." |
| Collateral risk | collateral_ratio < 100 OR collateral_description = NULL | "This pool's underlying loans are [partially collateralized/unsecured]. Higher risk of loss if borrowers default." |
| NAV writedown risk | All pools | "NAV can decrease if the underlying fund loses value (e.g., loan defaults). Your token value drops accordingly. The manager's equity buffer and the Junior tranche (if any) absorb losses first." (the pool reserve does not — R8) |
| Wind-down recovery limit | is_showcase = false | "In the event of wind-down (partner unresponsive 60+ days), only Pool reserve (X%) is recoverable on-chain. The remainder requires off-chain legal action." |
| Mirrored custody | custody_mode = 'MIRROR' | "This pool displays positions tracked from an external contract. Aset has no custody and provides no guarantees on the underlying asset." |
Display Location
- Pool detail page: All applicable warnings shown in a "Risks" section, expandable
- Deposit flow: Key risks repeated in the confirmation modal (before signing tx)
- Pool card (list view): A single risk indicator badge (Low/Medium/High based on combination)
Risk Levels (computed) ✅ Decided 2026-06-16
Auto-computed Low / Medium / High badge shown on the pool card (list view) and detail. Not admin-selected — derived at render time from pool state. Replaces the deprecated collateral_type badge (v3-07). SoT: Decision Log Pool Card Risk Tier.
Why the tier is receivables-first rather than collateralization-based
Receivables-first, not collateralization-based. Our pools are private-credit / receivables (par ~100%) — like Maple / Goldfinch / Centrifuge, which tier by structure (tranche) + portfolio health, not by collateral ratio. So
collateral_ratiois not the primary driver; FX /operating_currencyis excluded (operator-managed). NPL and NAV are distinct signals: NAV reflects realized loss (principal written off), while NPL is a leading, unrealized delinquency signal not yet in NAV — sonpl_ratiofeeds the badge directly as an early warning. (Cumulative written-off loss is display-only — already embedded in NAV, not a separate badge input.)
Precedence: High → Medium → Low (first match wins):
| Level | Conditions (any) |
|---|---|
| 🔴 High | lifecycle_status ∈ {IMPAIRED, WIND_DOWN} · is_emergency_frozen · tranche_role = JUNIOR · nav_per_token < 0.90 (realized writedown) · NPL > 5% (Equity Buffer limit exceeded) |
| 🟡 Medium | nav_per_token 0.90–0.999 (minor writedown) · tranche_role = SENIOR · NPL 2–5% (buffer absorbing — leading signal) or rising DPD30/60 trend · reserve_bps < 1000 |
| 🟢 Low | none of the above (collateral_ratio ≥ 100 / nav ≥ 1.0 / no tranche / lifecycle normal) |
Thresholds (confirmed 2026-06-16): NPL <2 / 2–5 / >5% · reserve baseline 10% · NAV <0.90 / 0.90–0.999 (lower NAV = worse, 1.0 = healthy).
Which signals are live, which are being wired, and how often the badge recomputes
Phasing & data source:
- Phase 1 (live):
lifecycle_status+is_emergency_frozen+nav_per_token+reserve_bps+tranche_role(Junior → High, Senior / Mezzanine → Medium — wired in v3-50; a standalone "no tranche" pool stays Low). - NPL% (S2 — being wired):
Σ overdue exposure where DPD ≥ npl_threshold_days ÷ total_outstanding_principal(partner-reported, from the DPD buckets + snapshot).> 5% → High,2–5% → Medium— a leading signal, separate from NAV. Graceful fallback: if the fund hasn't supplied the data (denominator missing),npl_ratiois null and the pool scores on Phase-1 signals only. Depends on the Joob API 2nd data request. - Phase 3 (deferred): concentration (top-N / HHI) · avg tenor · utilization · sector — optional partner data, not yet consumed. Pools with no external fund-data provider are scored from
reserve_bps+lifecycle_status+nav_per_token+tranche_roleonly.
Update cadence: event-driven & immediate for lifecycle / freeze / material NAV writedown; monthly recompute for portfolio health (NPL / DPD); hysteresis (downgrade immediate, recovery requires 2 consecutive snapshots) to prevent flapping.
This is informational only — investors should read the full risk disclosure.
Reference Examples
Four example pool configurations using the dimension model.
Example 1: Aset Direct Pool (⛔ cannot be deployed)
A pool Aset would operate directly, with no external partner. Kept as a worked example of a configuration the platform refuses, because it is the shape v3-26 rules out and the reason is the whole custody argument.
⛔ This config reverts on deploy
fund_wallet = the Aset treasury routes the partner remainder of every deposit to Aset, which makes Aset custodial. PoolConfigLib.validate reverts FundWalletIsTreasury() when fundWallet == treasuryWallet (PoolConfigLib.sol:60), so this pool cannot be created — not by the admin API, and not by a direct factory call that bypasses it. The YAML below is therefore a counter-example, not a template. See the fund_wallet dimension.
The only pool without a partner wallet is a is_showcase = true listing, which never deploys and so has no deposit flow at all. A custody_mode = 'MIRROR' pool DOES carry a fund_wallet (FJL does).
YAML config — the refused shape, 33 lines
# Fund Flow & Custody
fund_wallet: "0xAsetTreasury..." # ⛔ == treasury_wallet → reverts FundWalletIsTreasury()
reserve_bps: 1000 # 10%
is_showcase: false
# Asset Configuration
chain_id: 8453 # Base mainnet
accepted_currencies: ['USDC']
operating_currency: 'USD'
fx_rate_source: FIXED # Not used since USD
collateral_description: "Diversified short-term loans"
collateral_ratio: 100
# Yield Configuration
yield_frequency: MONTHLY
net_yield_fee_config: NULL # No partner fees
# Investor Terms
lockup_days: 0 # independent of penalty_type (v3-83)
maturity_model: FIXED_TERM
maturity_days: 365
penalty_type: NO_EARLY
# (no tranche group — standalone pool)
allow_rollover: true
# Compliance (jurisdiction only — level gating removed in 0063)
enforce_jurisdiction: false # open to all jurisdictions
allows_us_persons: false
# Pool State (runtime)
lifecycle_status: ACTIVE
is_paused: false
is_emergency_frozen: falseExample 2: Joob FJL1 (new investors via Aset)
The Joob FJL1 pool as configured for new investors going through Aset. Funds flow to Henon's Ethereum wallet; partner is Joob/Henon.
YAML config — 36 lines, epoch redemption + IDR operating currency
# Fund Flow & Custody
fund_wallet: "0x24c5a8866f38b0659d31cbee821559ea22d1e736" # Henon Ethereum USDC
reserve_bps: 1000 # 10%
is_showcase: false
# Asset Configuration
chain_id: 1 # Ethereum mainnet
accepted_currencies: ['USDC']
operating_currency: 'IDR'
fx_rate_source: FIXED # 16,730 IDR/USD
collateral_description: "Indonesian EWA payroll receivables"
collateral_ratio: 100
# Yield Configuration
yield_frequency: QUARTERLY
net_yield_fee_config:
platform_yield_take_bps: 100
perf_fee_bps: 2000
perf_hurdle_bps: 1500
# Investor Terms
lockup_days: 90
maturity_model: FIXED_TERM
maturity_days: 365
penalty_type: YIELD_BASED # 50% dividend forfeiture
# (no tranche group — standalone pool)
allow_rollover: false
# Compliance (jurisdiction only — level gating removed in 0063)
enforce_jurisdiction: false # ShardLab/Hashed are institutions; no country restriction
allows_us_persons: false
# Pool State
lifecycle_status: ACTIVE
is_paused: false
is_emergency_frozen: falseExample 3: Joob FJL1 (ShardLab/Hashed legacy)
The same pool, but a display-only mirror for ShardLab/Hashed's existing RPS holdings. They invested directly with Joob before Aset onboarded them; Aset just shows the positions.
# Fund Flow & Custody
custody_mode: MIRROR # ← Aset is not involved; just mirrors what the partner reports
# Asset Configuration
chain_id: 8217 # Kaia (where Joob's RPS contract lives)
operating_currency: 'IDR'
fx_rate_source: FIXED
collateral_description: "Indonesian EWA payroll receivables"
collateral_ratio: 100
# Other settings: irrelevant when custody_mode = MIRRORAset reads balances via RPS.balanceOf(wallet) on Kaia and displays them. No deposits, redemptions, or yield mechanics on Aset's side.
Example 4: LINE BK PoC (planned)
LINE BK's PoC pool. Specifics TBD pending final agreement with LFC.
YAML config — 40 lines, mostly TBD
# Fund Flow & Custody
fund_wallet: TBD # LFC SG SPV (multi-sig)
reserve_bps: 1000 # 10%; may be higher (e.g., 1500-2000 bps / 15-20%) per LFC negotiation
is_showcase: false
# Asset Configuration
chain_id: TBD # likely Ethereum mainnet or Base
accepted_currencies: ['USDC']
operating_currency: 'THB' # or 'MYR' depending on originator
fx_rate_source: FIXED # or EXTERNAL_FEED if LFC provides daily rate
collateral_description: "Thai SME invoice receivables" # tentative
collateral_ratio: 100
# Yield Configuration
yield_frequency: MONTHLY # PoC requires monthly distribution (manual-trigger; AUTO removed v3-20)
net_yield_fee_config:
# TBD — likely management fee + performance fee
# platform_yield_take_bps: 150
# perf_fee_bps: 1500
# perf_hurdle_bps: 1000
# Investor Terms
lockup_days: 30 # tentative
maturity_model: OPEN_ENDED # "revolving": 3-6 month underlying loans, pool runs 6 months
maturity_days: NULL # not used in OPEN_ENDED
penalty_type: PRINCIPAL_BASED # tentative — ⚠️ INERT on OPEN_ENDED (no EARLY window, so no early-exit penalty ever applies). Effectively NO_EARLY; use FIXED_TERM if a penalty is actually intended.
tranche_group_id: <UUID> # linked Senior+Junior pools (v3-14)
tranche_role: SENIOR # or JUNIOR
allow_rollover: true
# Compliance (jurisdiction only — level gating removed in 0063;
# institution-only pools are no longer enforced on-chain — gate by jurisdiction)
enforce_jurisdiction: true
kyc_jurisdiction_whitelist: ['KOR']
allows_us_persons: false
# Pool State
lifecycle_status: DRAFT # being designed
is_paused: false
is_emergency_frozen: false⚠️ Values above are a working draft from the 2026-05 meeting notes; LINE BK config is still being finalized with LFC and will be updated when the PoC agreement is signed.
Migration Notes
How v2.x's binary pool_type model maps to v3.0 dimensions, and what's deprecated.
v2.x → v3.0 Mapping
v2.x pool_type | v3.0 Equivalent |
|---|---|
AS_POOL | ⛔ no v3.0 equivalent. It mapped to fund_wallet = Aset treasury, which v3-26 removed and the contract now rejects (FundWalletIsTreasury()). A partner-less pool is is_showcase = true instead |
FUND_POOL | fund_wallet = partner wallet + same flexible config |
| (no v2.x equivalent) | is_showcase = true for read-only pools |
| (no v2.x equivalent) | Tranche groups via tranche_group_id + tranche_role (v3-14) |
Deprecated Columns (removed in v3.0)
| Column | Replaced by |
|---|---|
pool_type | Composition of dimensions (no fixed type) |
lp_issuance_model | Removed — Aset always mints LP |
escrow_model | Removed — Escrow absorbed into Pool (10/90 split inline in deposit()) |
signing_method (per-pool multi-sig selection) | Removed (migration 0015) — money-path is non-upgradeable + fund destinations immutable, so non-custody holds with a single cold key + timelock; no per-pool multisig |
Deprecated Contracts
PlatformReceiptNFT— Removed. LP token itself serves as deposit evidence; no separate Receipt NFT needed.PlatformEscrow— Removed. Absorbed intoPlatformPool— the Pool handles the reserve/partner (10/90) split directly insidedeposit(), transferring the partner share and emittingReleasedToPartner(no separate function). No separate escrow contract. The v3.0 architecture is 4 active contracts:PlatformPool,PlatformLPToken,PlatformKYCSoulbound,PlatformPoolFactory. See Smart Contracts.
Deprecated Behaviors
- D+7 auto-refund window: Removed. Investment platforms don't typically offer refunds. Partner insolvency handled via wind-down.
FM_ISSUEDLP tokens by Fund Managers: Removed. Aset always mints LP viaPlatformLPToken.- 2-stage approval (
Operator → Adminfor redemptions): Removed. Single-stage admin approve, withPENDING_RESERVEfallback when reserve is insufficient.
Migration Status
v3.0 is implemented and deployed to Sepolia testnet (2026-06-04). Migration progress:
- ✅ Spec written (this doc)
- ✅ DB schema migrated (new dimension columns added, deprecated columns marked) — applied to dev
- ✅ Smart contracts (PlatformReceiptNFT + PlatformEscrow removed → 4 active contracts, Pool function additions) — 183 tests passing, Clones factory deployed to Sepolia
- ✅ Backend Lambda updated (dimension-based dispatching, yield 3-step, wind-down/impairment/governance/freeze, per-pool KYC gating)
- ✅ Frontend data layer aligned (web + admin-web build clean); 📋 new control UI (wind-down/governance/freeze), external audit, and mainnet deploy still pending
Track implementation in the Phase 1.5 plan.
Glossary & References
Glossary
- Dimension: A single configurable setting on a pool (e.g.,
reserve_bps,fund_wallet). v3.0 model uses ~24 dimensions instead of fixed pool types. fund_wallet: The external wallet where each deposit's partner remainder is sent. Partner-controlled only — it must not equal the Aset treasury, which the contract enforces at init (v3-26).- Reserve: The external-reserve implementation sends the
reserveBpsshare toreserveWalletand the remainder tofundWallet. It does not accumulate an in-pool reserve balance. - Tranche Group (v3-14): A set of 2-3 linked SINGLE pools sharing a
tranche_group_id, each with a differenttranche_role(SENIOR/MEZZANINE/JUNIOR). Aset oracle Lambda applies loss waterfall across them. Standalone pools have NULLtranche_group_id. - Junior Tranche: First-loss pool in a tranche group. Absorbs losses before Mezzanine and Senior. Higher yield potential.
- Senior Tranche: Loss-protected pool in a tranche group. Absorbs losses last, lower yield.
- NAV (Net Asset Value): Value per LP token. Updated via
updateNAV()by Aset's oracle role. - Wind-Down: Emergency pool termination when partner is unresponsive (60+30 day timelock).
- Display-only pool: A pool that mirrors external positions without custody (e.g., legacy holdings).
PENDING_RESERVE: Redemption state when reserve is insufficient and admin coordinates with partner.
Cross-Page References
- Investment Lifecycle — Deposit and yield distribution flows
- Writedown & NAV — NAV update mechanics, $1.00 cap, 24h decrease timelock
- Redemption — Lockup mechanics, penalty types, redemption flow
- Smart Contracts — Contract architecture, functions, roles
- RBAC & Permissions — Admin roles, multi-sig requirements
- Status Machines — Lifecycle transitions, redemption states
- DB Schema — Pool table columns, deprecated fields
- Decisions Log — Why we made specific design choices
- Joob Pool Config — Joob FJL1 specifics
Internal References
- Phase 1.5 Plan — Implementation roadmap
- Pool Dimensions Decision Log (
Claude-Plan/Active/pool-dimensions-decisions-2026-05-29.md, local-only) — Design rationale and decision history