Investment Lifecycle
🚧 In Development
This page describes the v3.0 investment flow. Backend implementation is in progress. v2.x flow (AS/FUND_POOL split) is deprecated.
End-to-end investment flow: Deposit → LP Mint → Active → Yield → Redemption. Same flow for all pools regardless of configuration (no AS_POOL / FUND_POOL split). Pool-specific behaviors come from pool dimensions.
This page is the investor's journey — what happens, in order. Each step says what happens and links to the page that owns the mechanism: money movement to 23-money-path, fee arithmetic to 04 → How Yield Works, lockup and penalty rules to 07-redemption (v3-115).
Roles: 🔵 Investor · 🟢 System (Auto) · 🔴 Admin · 🟠 Partner · 🟡 Oracle
Phase 1 — Deposit
🔄 Unified deposit flow
v3.0 has a single deposit flow for all pools. Aset always mints LP tokens at deposit time, regardless of which partner operates the underlying fund. No Receipt NFT, no D+7 refund window.
① Investor connects wallet & passes KYC 🔵 Investor
SumSub KYC → SBT (Soulbound Token — non-transferable on-chain credential) minted on-chain → kyc_status = APPROVED.
If pool's kyc_level_required = KYC_ONLY, must have INDIVIDUAL SBT. If KYB_ONLY, must have INSTITUTION SBT. If EITHER, either works.
② Investor selects pool & enters amount 🔵 Investor
System checks:
is_paused = falseANDis_emergency_frozen = falsekyc_status = APPROVEDwith appropriate level- Pool's
lifecycle_statusallows deposits (ACTIVEonly — the on-chainwhenActivemodifier reverts withPoolNotActivefor every other status;UPCOMINGshows detail but the Invest button is disabled, andIMPAIRED/WIND_DOWN/CLOSED/MATUREDreject new deposits per v3-12) - Investor wallet has sufficient balance in pool's
accepted_currencies
NAV model keeps investment open during writedowns — no auto-block. See Pool Models — Accepted Stablecoins.
Deposits are disabled for two independent reasons, and they are not the same reason: is_showcase = true (the v3-40 marketing tier — visible, never investable here) and custody_mode = 'MIRROR' (the pool's capital is not in our custody at all, so there is no path in either direction).
③ Stablecoin transferred to Pool contract 🟢 System
The investor calls PlatformPool.deposit(stablecoin, amount) and signs it themselves; no Aset key participates. The transaction pulls the stablecoin, checks the gates and normalizes decimals in one shot — gate list, order of operations and revert names: 23-money-path §3a · 08a.
deposits.currency records which stablecoin was used.
④ LP tokens minted to investor 🟢 System
Atomic with deposit (same transaction): the investor receives LP immediately, priced against the full deposit — the reserve split does not dilute their claim (v3-56). Quantity, price and the announced-NAV case: 23-money-path §3b.
No Receipt NFT in v3.0. The LP token itself is proof of deposit.
⑤ Funds split: reserve + partner 🟢 System
The pool keeps reserve_bps of the deposit as reserveBalance and releases the remainder to fund_wallet in the same transaction. fund_wallet is always partner-controlled and never the Aset treasury (v3-26). Split arithmetic, rounding and where the money goes after that: 23-money-path §3.
⑥ The deposit is recorded on the platform 🟢 System
Pool emits Deposited(investor, stablecoin, amount, lp_amount). The deposit is recorded twice over: POST /deposits is the common path (the FE calls it once the tx confirms) with the on-chain indexer as a ~2-minute reconciler. Neither will credit LP off-chain. Detail: 11-db-schema → deposits · 23-money-path §3f.
The entry price is retained (0125). portfolio_positions.entry_price records what the holder paid per LP token, which is what per-position P&L is computed against — effective_value is the current mark and gets rewritten on every position change. It is a weighted average: a top-up or a reinvest blends its own amount / tokens minted in, so the price used is the one the chain actually charged (during a queued NAV decrease that is the announced NAV, not nav_per_token — v3-111). Partial redemption leaves it alone; a full exit deletes the row. NULL means the basis is not on record and no P&L is shown.
⚠️ nav_at_investment, which this step named until 0125 and which v3-115 recorded as missing, never existed in the schema. It is not being added — entry_price is the field.
📌 Nothing is pushed to the partner
The flow ends at Aset's own record. There is no partner webhook and no partner endpoint is called on deposit — the fund manager reads the platform (GET /deposits is fund-scoped; deposit_confirmed_ops carries fundManagers). The push-to-partner concept is dropped, not pending: v3-115 holds the record of what was published and never built.
📋 Pool-Specific Behavior
Beyond Phase 1 steps, pool behavior varies based on dimensions:
reserve_bps(1000 bps = 10% default, configurable)fund_wallet(where the partner remainder goes)operating_currency(USD vs partner-denominated)tranche_group_id+tranche_role(v3-14: linked pools form tranche group; standalone pools have NULL)- etc.
See Pool Models for the full dimension list.
Phase 2 — Yield
Yield comes from the underlying fund's operations (loan repayments, etc.) and flows back to investors via the Pool contract.
📈 Yield flow at a glance
Partner deposits gross yield → Aset Lambda calculates net (after fees) → distributes to LP holders → investors claim or reinvest.
① Partner deposits gross yield 🟠 Partner
Partner calls Pool.depositYield(gross_amount) from their fund_wallet (role-gated: YIELD_DEPOSITOR_ROLE, granted to the pool's fund_wallet — there are no Aset-direct pools, v3-26).
Pool retains gross_amount and emits YieldDeposited(gross_amount).
② Aset Lambda calculates net yield (off-chain) 🟢 System
Lambda picks up YieldDeposited, reads the pool's net_yield_fee_config and computes net = gross − Aset fees − FM fee. Pools with no fee config get net = gross.
The arithmetic (which fee is charged on what, the performance hurdle, a worked example) lives in 04 → Net Yield Calculation and 23-money-path §4. Why off-chain: fee terms vary by partner, and a DB config change beats a contract upgrade.
③ Settle the period — net to holders + both fee legs, one tx 🟢 System
Lambda calls Pool.settleYield(stablecoin, treasury_fee, pool_mgmt_fee). In that single transaction the net funds what the holders are owed and both fee legs pay out — there is no separate fee call and no half-settled period (v3-102). Where each leg goes: 04 → Fee Destination.
⚠️ net_amount is no longer an argument (v3-131 (3), 2026-08-20). What holders are owed is decided by TIME, so there is nothing for a caller to state: net = unclaimedYield − fees, derived on-chain. The old argument was also the one that could not be validated — every check on it was an upper bound, which a mis-scaled value always passes (see 08 → argument axes). Cash the accrued liability cannot absorb stays in unclaimedYield as prepayment for the next period.
For pools in a tranche group (v3-14), Lambda applies the tranche group waterfall before settling each pool, and each pool's NAV is updated independently.
④ Investor claims yield or reinvests 🔵 Investor
Pool.claimYield(stablecoin) pays out of what a partner has funded — claimableYield(investor) — and leaves sub-raw-unit dust claimable. Yield that has accrued but not been funded is a claim, not cash, and the call reverts YieldAccruedButUnfunded rather than NoYieldToClaim: an investor in a delinquent pool must not be told they earned nothing. unfundedYield(investor) is the other half, and pendingYield(investor) is the sum.
Pool.reinvest(stablecoin, amount) instead mints new LP from that yield — see Reinvest V1 Policy below.
Yield Settlement on LP Transfer
Why this matters
Yield accrues with TIME, per LP token, as an accrual index. If LP tokens move between investors without settling first, the seller's unclaimed yield would wrongly follow the tokens to the buyer — or the buyer would be paid for time before they held the LP. Settling on the tracked balance before it moves is what prevents it.
When LP tokens transfer between investors (secondary; permissionless — no allowlist, pause-gated only, v3-58), the LP token's transfer hook (_update) calls onLpTransfer(from, to, value) on the Pool before the tracked balances change. The Pool then, in this order:
- Rolls the clock forward (
accrue) — mint and burn move the accrual basis, and a burn of pool-held LP touches no holder at all, so nothing else on the path would book the period that just ended. - Prices each side's span at its OLD balance and re-anchors their cursors. The seller keeps what they earned: it lands in their claim buckets and survives a zero balance, so leaving does not forfeit an unfunded claim.
- Moves the supply mirror (
totalLpSupply) on a mint or a burn.
🔴 The order is the contract, and step 3 lives here on purpose. The accrual basis and the set of addresses that can ever claim are now maintained by the same hook, so they cannot disagree. Before, the credit set lived in this hook while the denominator was recomputed from the LP token at distribution time — escrowed LP counted in the second and not the first, so its slice was credited to nobody and evaporated.
Example — Alice holds 100 LP with $5 accrued, transfers all 100 LP to Bob:
- ❌ Without settlement: Bob is paid for the whole span, including the part Alice earned.
- ✅ With settlement: Alice's $5 is banked to her and stays claimable at a zero balance; Bob's cursor starts now, so he is paid only from the moment he holds the LP.
Result: yield always follows the actual holder. Deposits/redemptions (mint/burn against the Pool) settle through the same hook.
An escrow is not an exit
Locking LP for a redemption is a transfer to the Pool, so it runs through this hook — and it moves the LP out of the recognised basis into poolHeldLp, not because it stops earning (it does not) but because its earnings are booked per request when the escrow ends, debt and credit in one instant. totalLpSupply is untouched: nothing was burned. ⚠️ The recognised liability therefore lags while an escrow is open — accrualBasisLp() reaches 0 once every holder has requested, which the post-maturity schedule forces, and previewEscrowAccrual(requestId) is the per-request difference. Nothing is waived. See 07 → Yield does not stop at request and v3-131 (3).
Overdue tracking
next_yield_due = end of the last paid period + yield_frequency (not the settlement time, which would let lateness compound). If now > next_yield_due:
yield_overdue = true(DB flag)- Admin and investors get alerts
- Admin is reminded to call
settleYield(yield is manual-trigger only — AUTO removed v3-20)
yield_frequency is a public commitment visible to investors on pool detail page.
Yield Reconciler (claimable_yield) v3-51
The app's "claimable yield" must show the full on-chain total — settled plus unsettled, i.e. what pendingYield(investor) returns. The DB's accrued_yield mirrors only the settled half, so it drifts from chain whenever a partner distributes on-chain directly, an investor claims on-chain directly, or LP balances change.
An hourly reconciler closes the gap with no contract change: it reads pendingYield per holder into portfolio_positions.claimable_yield, and the YieldClaimed indexer refreshes a holder on a direct on-chain claim. Display: claimable_yield when claimable_yield_synced_at is set, else accrued_yield. Columns and the scheduler: 11-db-schema → portfolio_positions · v3-51.
Reinvest V1 Policy 🟡 BD5
🔴 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 CTAs described below are still on screen.
⚠️ What actually blocks it is allow_rollover, not the UI. The on-chain Pool.reinvest and POST /yield/reinvest both remain, and allow_rollover defaults to false in the DB (pools.allow_rollover BOOLEAN DEFAULT false), which is what makes reinvest() revert RolloverDisabled. Removing the buttons hides the path; the default is what closes it.
🔄 Reinvest Overview
For pools with allow_rollover = true, investors can reinvest their accumulated yield back into the pool instead of claiming to wallet. Yield is converted to additional LP tokens at effectiveNav — the same price a deposit pays (v3-111).
Core rules:
- Partial reinvest allowed.
reinvest(stablecoin, amount)accepts any amount up to the on-chain claimable balance; only an over-request reverts (InsufficientYield) - One-click
[Reinvest $680]stays the default (prefilled with the full balance); the amount is editable min_reinvest_amount: pool config (default $50 ormin_investment × 50%, whichever is lower). Checked against the requested amount, so a partial below the minimum reverts (BelowMinReinvest). If the whole balance is below the minimum: Reinvest disabled with tooltip- Same-pool only — by design.
reinvest()spends the pool's own accrued yield and mints that pool's LP; there is no cross-pool reinvest. To put yield into a different pool,claimYield()to wallet thendeposit()there (a normal deposit, not a reinvest). See v3-64
On-chain: Pool.reinvest(address stablecoin, uint256 yieldAmount) mints new LP at effectiveNav, exactly as a deposit does — the current NAV in normal operation, and the announced (lower) NAV while a decrease sits in its 24h timelock (v3-111 · R2·R3). A reinvest and a fresh deposit of the same size in the same block therefore receive the same number of LP tokens. stablecoin must be an accepted currency of the pool — it is the currency the non-reserve share of the reinvested amount is released to fund_wallet in (E1 money-path parity with deposit). Signature caveats, including the legacy one-argument form on older pools: 08a.
⚠️ Older pools still price a reinvest at the stale NAV
YieldLib.reinvest reads effectiveNav as of the 2026-08-06 implementation (v3-111), one round later than deposit and redemption. Pools are non-upgradeable Clones pinned to the implementation they were created with, so a pool created before that round mints a reinvest during a queued decrease at the stale, higher NAV and receives fewer LP than the rule above specifies. Nothing migrates it; only newly created pools price correctly. Per-round matrix: 06 → R2·R3.
Lockup: A reinvestment follows the same anchor rules as a deposit (v3-130): it leaves a live holding's invested_at alone, and re-anchors a re-entry made at zero balance. BD5 called reinvested LP "lockup-exempt", which had no object — the lock-up is one boolean per investor rather than per LP, so with a balance held neither entry point re-anchors and the exemption changed nothing. See 07 → 3-State Lockup Model.
Investor UI (allow_rollover = true) — ⚠️ not offered in MVP (2026-08-27, still on screen, v3-151):
├─ [Reinvest $680] → primary CTA
├─ [Claim to Wallet] → secondary
└─ [Redeem →] → Withdraw Dialog⚙️ allow_rollover configuration
Configurable at pool creation. Admin can toggle in ACTIVE state without timelock (low-impact change). min_reinvest_amount is also editable in any lifecycle stage.
Phase 3 — Redemption
Investor exits their position by burning LP tokens and receiving USDC. NAV is snapshot-locked at request time. Yield is separate (claim-based, not included in redemption payout). Total redemption = token_value - penalty.
The steps below are the instant path (epoch_duration_days = 0). Redemption owns the full mechanics: lockup states, the four penalty types, the reserve branch, and the epoch flow for open-ended pools.
Unified Redemption Flow
REQUESTED → COMPLETED (or PENDING_RESERVE if the reserve is short) — PROCESSING is an investor-facing display label only, not a persisted redemption status.
① Investor requests redemption 🔵 Investor
Investor calls Pool.requestRedemption(lp_amount). The confirmation modal shows token count, NAV price, token value, accrued yield, penalty (if any) and total payout. LP is locked and status = REQUESTED.
② Lockup check + penalty + NAV snapshot (same tx) 🟢 System
Inside the same transaction the contract takes the nav_at_request snapshot, runs the 3-state lockup check against the investor's own deposit_time (LOCKED reverts; EARLY applies the pool's penalty_type; FREE none) and computes the payout. Penalties go to fund_wallet (v3-85), not the reserve.
State definitions and all four penalty formulas: 07 → 3-State Lockup Model. Lock-up is independent of penalty_type — a NO_EARLY pool may still be LOCKED (v3-83).
③ Reserve decides the branch — in the same tx, not at approval 🟢 System
The requestRedemption transaction itself checks the reserve against the gross (v3-82): if it covers, the pool burns LP, pays USDC and completes in-line; if not, the LP is escrowed and the request rests in PENDING_RESERVE until the partner's fundRedemption covers it, which auto-settles it.
⚠️ There is no admin approval step in the normal flow. approveRedemption survives only as an optional manual settle for a reserve that grew independently. Branch detail and the permissionless fallback: 07 → Instant Redemption Flow.
💡 Investor sees
- "Processing" → settling
- "Pending partner funds" → reserve insufficient, awaiting top-up
- "Completed" → USDC in wallet
Failure path: Any step can transition to FAILED — tracked via failure_type + error_message.
⚠️ Deprecated Fields (v2.x → v3.0)
escrow_model, lp_issuance_model, pool_type, Receipt NFT, D+7 auto-refund and the FM_ACCEPTED redemption status are all removed in v3.0. Do not expose them on forms. The full list with replacements: 04 → Migration Notes.