Skip to content

Redemption

How investors exit positions. Two redemption models, selected per pool by epoch_duration_days:

Modelepoch_duration_daysDefault forNAV pricingSettlement
Instant0FIXED_TERMNAV-at-request (snapshot)Reserve check at request → immediate settle; shortfall → PENDING_RESERVE → partner fundRedemption auto-settles (v3-82)
Epoch> 0 (v3-26)OPEN_ENDEDNAV-at-settlement (forward pricing)Batch pro-rata at epoch end + rollover + pull claim

Yield is always claimed separately (not included in redemption payout).

For NAV mechanics, see Writedown & NAV. For emergency wind-down (partner unresponsive), see Pool Models → Emergency Wind-Down. For the epoch model, jump to Epoch-Based Redemption.

New pools only, everywhere on this page. Pools are EIP-1167 Clones with the implementation fixed at creation, so a contract behaviour described here binds only pools created after that behaviour deployed. Pools created earlier keep running the implementation they were cloned from, and no redeploy moves them. The FE needs a pool from the current factory to build against. Deploy dates per item: 17-changelog.

External-reserve exit conditions

The external-reserve implementation sends the reserveBps share to reserveWallet and the remainder to fundWallet. It does not accumulate an in-pool reserve balance.

The current source has no claimRedemptionFallback. There is no seven-day fallback payout guarantee. The separate epoch settlement and claim-on-behalf paths also depend on actual funding.

The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process.

The reserve-balance formulas in this section are legacy implementation history. They do not apply to the external-reserve implementation.

Payout Formula

Token value       tokens_burned × nav_at_request
Early penalty     calculated per penalty_type (if applicable, → fund_wallet, v3-85)
──────────────────────────────────────────────
Total redemption  token_value - penalty

Yield is claimed separately via claimYield() — not included in redemption. Yield accrues on the nominal deposit amount, not on NAV-adjusted token value.

Which NAV. Instant pools price at nav_at_request, a snapshot taken in the request transaction and never revisited: later drops don't reduce the payout and later recoveries don't raise it. Epoch pools carry no nav_at_request at all (it is NULL): both the NAV and the penalty are computed at the settlement moment (v3-18 revised) — see Epoch-Based Redemption.

Carve-out — an instant request made during a NAV-decrease timelock prices at the announced NAV (R2·R3). A pending decrease is public for 24h, so a request submitted inside that window uses the announced figure, not the still-current pools.nav_per_token; otherwise an exit could lock $1.00 knowing $0.98 lands tomorrow and leave the loss with remaining holders. Requests submitted before the announcement keep their snapshot, and epoch pools are unaffected (they price at settlement). See Writedown & NAV → R2·R3.

Exit gates — who can stop a withdrawal

Three different things can stop a redemption, and they are easy to conflate. Only one of them is a person pressing a button.

GateWhoApplies to
Lockup / maturityautomaticsee 3-State Lockup Model below
Verification (AML)automatic, on-chainboth pool types
Return position (operator action)ADMIN · SUPER_ADMIN only — FM excluded, see belowinstant pools only

Verification: revoked blocks the exit, expired does not

PlatformKYCSoulbound.canRedeem is the gate, and the asymmetry is deliberate (v3-31):

  • AML / REVOKED → blocked. Value must not leave to a sanctioned or revoked holder.
  • EXPIRED SBT → still passes. Blocking on expiry would trap an investor's own capital behind a lapsed document, which is the failure mode v3-28 exists to prevent. Expiry is an administrative state, not a reason to withhold funds.

It is checked at two moments, because the holder's status can change between them:

Momentinstantepoch
Requesting (requestRedemption)requiresRedeemableKyc✅ same modifier
Being paid_executeRedemptionPayout — every settlement pathclaimRedemption

Epoch is gated exactly the same way. Epoch pools have no per-request operator decision at all (settlement is batch pro-rata), and they do not need one for AML — both moments above are enforced on-chain.

The second check sits inside _executeRedemptionPayout — the one entry point the instant settlement path funnels through (the partner-funding auto-settle inside fundRedemption). It guarded three paths until approveRedemption and claimRedemptionFallback were removed with the reserve they drew on; A holder who no longer passes reverts RedemptionBlockedByKyc. Gating the entry point rather than each caller is deliberate: patching callers guarantees the next one added misses it — and removing two of the three callers is what just demonstrated it, since nothing here had to change.

Two consequences worth knowing:

  • A revert does not strand the partner's money. The transfer and the payout are the same transaction, so the revert rolls the transfer back too. The request is not auto-closed — that would let the partner's transaction execute Aset's compliance decision and leave the partner as the actor in the audit trail. Closing it stays an explicit admin action.
  • A revert is silent to Aset, so an hourly sweep over PENDING_RESERVE requests alerts an admin when a holder stops passing. The sweep changes no state.

(fundRedemption also calls checkExitNotBlocked, but that is the freeze gate, not the verification gate.) Background: v3-99.

Return position — what the operator action actually does

Named Return position rather than "reject" because nothing is taken from the investor: the request is closed and the escrowed LP goes back to them, so they hold their position again instead of being paid. Any partner funding already deposited returns to fund_wallet. Nothing else needs unwinding — no yield is deducted at request time and the penalty is only paid at settlement (v3-84).

  • Who: ADMIN / SUPER_ADMIN only. FM is excluded on purpose — the action's main use is closing a shortfall the fund never funded, and the FM is that fund, so leaving it with them lets the party that owes the money end the investor's exit request. The LP does come back, but the exit stays shut, which repeated is a refusal in effect. This also settles a three-way conflict in favour of the Notion FM-panel PRD, which had said FM has no such gate.
  • Reason is mandatory, and pre-filled. The dialog opens with a category already selected plus a free-text note; both are required. Categories are UNFUNDED (a stalled shortfall), COMPLIANCE (the holder no longer passes verification), and OTHER. The category is editable, because a flag can be a false positive and the operator may be closing for the other reason. Same propose-then-confirm shape as NAV approve/override.
    • Pre-select sources: request state (PENDING_RESERVEUNFUNDED) and a live exit-gate read (GET /redemption-requests/{id}/exit-gate, ADMIN-only) that upgrades it to COMPLIANCE when the holder can no longer be paid. Read at decision time rather than from the hourly sweep, which can be an hour stale and may not have reached the request. It fails open — an unreadable chain or an epoch pool leaves the state-based default — and a category the operator has touched is never overwritten.
  • Allowed states: REQUESTED or PENDING_RESERVE.
  • Why instant-only: epoch requests are created as QUEUED, so they never enter a state this action accepts. They do not need it either — an unfilled epoch remainder rolls over automatically and the investor can cancel inside the request window.
  • Primary use: closing out a shortfall that will never be funded, so it stops holding the investor's LP hostage in escrow.
  • REJECTED has two sources. This action, and the investor cancelling (which collapses to REJECTED + failure_type = 'INVESTOR_CANCELLED' because the DB enum has no CANCELLED). Reporting must split them or voluntary cancellations inflate the rejection rate.

3-State Lockup Model

Which anchor the lock-up uses is a property of the pool's IMPLEMENTATION, so both answers are live at once (v3-144). Up to the 2026-08-21 round the lock-up is per position, measured from the deposit: lockup_end = deposit_time + pool.lockup_days. From the pool-wide round on it runs from the offering close for everybody: lockup_end = subscription_end_date + pool.lockup_days. A pool is a Clones proxy pinned to its implementation at creation, so this is not a migration and there is no cutover date after which one answer is correct: pools on both rules coexist permanently.

lockupAnchorMs in @aset/types is the only place that decides, and the reason it is shared rather than computed per caller is that three parties judge the same lock-up: the investor app picks which button to offer, redemption-requests.post.create decides whether to accept the request, and the contract decides whether to honour it. A disagreement between them is a reverting transaction, not a cosmetic difference. The unknown case reads as pool-wide, because subscription_end_date + lockup ≥ invested_at + lockup always holds: guessing pool-wide only ever shows a later unlock, while guessing per-investor offers a redemption the contract refuses. A pool with no recorded implementation is per-investor, since it predates migration 0205 and therefore predates all of this.

⚠️ A pool-wide pool with no subscription_end_date has no anchor, and the lock-up gates nothing — lockupAnchorMs answers null rather than falling back to the deposit, which would refuse a redemption the contract allows. ✅ That state is now unbuildable (v3-146): a FIXED_TERM pool cannot be published without a close, and an OPEN_ENDED pool — the one shape whose window is optional — carries no lock-up at all. The null branch stays because it is what the contract does with an unset anchor, and because a row written before that rule can still be read.

🔴 And the close stops being editable once it anchors a lock-up (v3-146). On a pool-wide pool with lockup_days > 0 the contract holds its own copy of subscription_end_date, nothing writes an edit back to it, and it refuses one once the pool holds LP — so a DB-only edit moves half of one boundary, permanently, in the direction that reverts. The admin form and PATCH /pools/{id} both refuse it from offeringCloseLockedByLockupReason. A pool with no lock-up, and every per-investor pool, keeps the field: there the date reaches no contract, and shortening an offering is a legitimate live edit.

Maturity is per pool — the absolute pools.maturity_date, mirroring the contract's maturityDate (written at deploy as subscription_end_date + maturity_days, v3-145), the same for every holder regardless of when they deposited (migration 0161). The contract stores lockupDays as a duration and maturityDate as a date; there is no maturityDays in storage to anchor per holder even if a reader wanted to.

The late-depositor defect is fixed, by moving the term rather than the maturity (v3-145, 2026-08-24). It used to read: a late depositor matures on the same day as an early one and is paid a full term's coupon for a shorter holding. That was true while maturity was deploy_time + maturity_days — the term started before the pool had finished raising. Both ends of the term are now measured from the offering close, and deposits are only taken before it, so nobody can be invested for less than the full term. Maturity did not become per-position; it stopped starting early.

The control that used to compensate for it (a short raise window, enforced as offering close ≤ maturity − one yield period) is therefore retired: a longer offering now pushes maturity out instead of eating into the term.

On a per-investor pool, per position means ONE anchor per (pool, investor), not one per deposit. deposit_time is money_positions.invested_at, mirroring the contract's positions[investor].investedAt, and the lock-up is a single boolean per investor (PoolCommonLib.isLockupActive). There is no per-LP or per-deposit lock-up accounting, and LP is fungible, so there is nothing to attach one to.

Which deposit sets it (v3-130) — the table below is about invested_at, so it describes the per-investor anchor only. On a pool-wide pool none of these rows moves the lock-up, because the anchor is not the deposit; invested_at is still written (it is a Deposited event parameter) and still decides whether a position exists:

Capital inLP balance before itinvested_at
First deposit0set to now
Top-up> 0unchanged, so a later deposit never restarts the lock-up
Re-entry after a full exit0set to now, so a returning investor is locked again
Reinvestmentsame two rules as a depositsame

⚠️ The consequence of the top-up row: money added late in a lock-up is only held for what remains of it. That is the model, not an oversight, and it follows from LP being fungible — the alternative needs per-deposit accounting the schema does not have.

invested_at is never cleared on a burn or a transfer, which is why the re-entry row keys on the BALANCE and not on "is the anchor null": an expired anchor left behind by a full exit would otherwise be inherited and skip the lock-up entirely.

StateConditionBehavior
🔒 LOCKEDnow < lockup_endButton disabled. Cannot redeem. Only exists when lockup_days > 0. v3-83: lock-up is independent of penalty_type — a NO_EARLY pool may still have a lock-up (LOCKED during it, then penalty-free exit).
⚠️ EARLY REDEMPTIONlockup_end ≤ now < maturityFIXED_TERM only (needs a maturity to have an early-exit window)Can redeem; penalty_type applies (0 for NO_EARLY).
FREEnow ≥ maturity (FIXED_TERM) or now ≥ lockup_end (OPEN_ENDED — no maturity, so no EARLY window)No penalty. Standard redemption.

Edge cases:

  • If lockup_days = 0, skip LOCKED state.
  • On a pool-wide pool, lockup_end is the same date for every holder, so a reopen is refused while lockup_days > 0 (D-2): keeping the old close leaves a new investor with a lock-up that has already run, and moving the anchor to a new close re-locks holders who had already cleared theirs. The refusal is written as a property of the anchor, not as "reopen and lock-ups are incompatible", because it dissolves if maturity ever becomes per-investor and the lock-up follows it back to the deposit.
  • If maturity_model = OPEN_ENDED, there is no EARLY window — FREE comes right after lock-up ends, so an early-exit penalty never applies (no maturity to gate it). This is why create/edit hides the penalty section for open-ended pools.
  • If maturity_days = NULL and FIXED_TERM, FREE state never reached (config error).

4 Penalty Types

Set via pool's penalty_type dimension. See Pool Models → Investor Terms.

TypeFormulaPenalty Destination
NO_EARLYNo penalty (0)
FLAT_FEEpenalty = penalty_fee_amount (fixed $)fund_wallet (v3-85)
PRINCIPAL_BASED (default early-exit basis, v3-79)penalty = grossAmount × penalty_rate_bps / 10000, where grossAmount is the redeemed slice priced at the request-time NAV — so a partial exit is penalized only on what leavesfund_wallet (v3-85)
YIELD_BASED (reinstated v3-84 — lock-up pools only)penalty = accrued_yield × penalty_rate_bps / 10000fund_wallet (v3-85)

YIELD_BASED is selectable only behind two guardrails (v3-84), because a "% of accrued yield" penalty is worth 0 when no yield has accrued yet: the pool must have lockup_days > 0, and the lock-up cannot release before the pool's first yield distribution. Together they guarantee an investor reaching the EARLY window has yield to be penalized on. PRINCIPAL_BASED remains the default. The Joob pool (SSA §4.2 = 50% dividend forfeiture) is literally YIELD_BASED 5000 bps — see 20-joob-pool-config.

Every penalty type is deducted from principal and transferred to fund_wallet (v3-85) — instant and epoch alike, and never a top-up to reserveBalance. YIELD_BASED only sizes the penalty off accrued yield; the money still comes out of principal, because yield already distributed cannot be clawed back. Admin cannot waive.

What that routing does and does not change: steady-state navPerToken is oracle-set and does not derive from reserveBalance (reserve feeds NAV only in WIND_DOWN), so remaining LPs' day-to-day NAV is unaffected. What shrinks is the pool's redemption liquidity buffer and the wind-down pro-rata recovery — the penalty goes to the FM instead.

Partial vs Full Redemption (v3-79)

Partial redemption is the default — an investor may redeem any amount up to their balance, and the penalty is pro-rated on the redeemed slice only (the untouched balance keeps its terms). Two terminal states force full-only, because a position there is being closed / liquidated and partial claims complicate the residual accounting:

LifecyclePartialNotes
ACTIVE (EARLY / FREE)✅ allowedpenalty pro-rata on redeemed amount
IMPAIRED✅ allowedat impaired NAV; penalty waived
MATURED🚫 full onlyposition closes; penalty waived
WIND_DOWN🚫 full onlypro-rata share of available liquidity; penalty waived

Same rule for instant and epoch pools — the redeemed-slice formula (grossAmount × rate) is identical, so the model does not branch on redemption mode. Full-only in terminal states is enforced off-chain today (BE request handler rejects amount != full balance; FE locks the amount to full); an on-chain FullRedemptionRequired guard is deferred to the next redeploy batch (partial there is a UX / accounting preference, not a safety issue — NAV pro-rata is safe either way). See 14-decisions v3-79.

Investor-Facing Status Labels

Internal StatusInvestor Sees
REQUESTEDProcessing
QUEUED (epoch)Queued for next settlement (D-N)
PARTIALLY_FILLED (epoch)Partially filled — remainder rolled over
PENDING_RESERVE (instant)Awaiting partner funds
PROCESSINGSettling
COMPLETEDCompleted
FAILEDFailed (with reason)

Redemption Scenarios (NAV-based)

Every row below is a $10,000 deposit in the FREE state (no penalty), redeemed in full. The only variable is the NAV at each end. Payout is always tokens × nav_at_redemption, where tokens = 10,000 / nav_at_investment.

NAV inNAV outTokensPayoutWhat it shows
A1 Normal$1.00$1.0010,000$10,000.00Full principal back
A2 Writedown$1.00$0.8510,000$8,500.00The investor absorbs the 15% NAV loss on token value
B1 Recovered$0.80$0.9512,500$11,875.00Discounted entry, NAV recovers — the "fair entry" upside
B2 Flat$0.85$0.8511,765$10,000.25Principal back; the gain is yield, claimed separately
B3 Dropped further$0.85$0.7011,765$8,235.50A discounted entry does not guarantee safety

Yield is unaffected by any of this: it accrues on the nominal $10,000 in every row, is paid in full even under writedown, and is claimed separately via claimYield().

Instant Redemption Flow (epoch_duration_days = 0)

The flow below applies to instant pools (FIXED_TERM default). All instant pools use the same flow — no AS_POOL/FUND_POOL split. Epoch pools (epoch_duration_days > 0) use the Epoch-Based Redemption flow instead.

Roles: 🔵 Investor · 🟠 Partner · 🟢 System · 🔴 Admin (optional manual settle only)

🔑 The reserve check happens at request time, not at admin approval (v3-82). The requestRedemption transaction itself decides the branch: if the reserve covers the payout it settles in-line; if not, the request rests in PENDING_RESERVE and the partner's later fundRedemption auto-settles it the instant the balance covers the payout. There is no admin approval step in the normal flow — approveRedemption survives only as an optional manual settle for a reserve that grows independently (see ④).

① Investor requests redemption 🔵 Investor

Investor calls Pool.requestRedemption(lp_amount). Confirmation modal shows: token count, NAV price, token value, accrued yield, penalty (if any), total payout.

② NAV snapshot + reserve check (same tx) 🟢 System

Inside the requestRedemption transaction the contract:

  • Captures nav_at_request from pools.nav_per_token, except inside a NAV-decrease timelock — see the carve-out above
  • Runs the 3-state check (LOCKED reverts; EARLY applies the penalty_type fee → fund_wallet (v3-85); FREE none)
  • Computes gross = payout + penalty and checks the reserve — deciding the branch here, not later. The penalty is also transferred out to fund_wallet, so checking only the net investor payout would overstate available liquidity:
    • reserveBalance ≥ gross → immediate settle (③)
    • reserveBalance < gross → escrow LP, rest in PENDING_RESERVE (④)

③ Sufficient reserve → immediate settle 🟢 System

No admin step. In the same request transaction the Pool:

  • Burns the investor's LP directly (no escrow)
  • Transfers payout in USDC to the investor wallet
  • Transfers any early-exit penalty to fund_wallet (v3-85, not reserveBalance); draws down reserveBalance / totalDeposited
  • Status → COMPLETED (emits RedemptionRequested + RedemptionCompleted in one tx)

④ Insufficient reserve → partner funds → auto-settle 🟠 Partner

Status PENDING_RESERVE; the LP is escrowed in the Pool and the fund's partner is notified (fm_shortfall).

  • Partner tops up from fund_wallet via Pool.fundRedemption(stablecoin, amount)
  • fundRedemption auto-settles the request the instant the pool balance covers the payout — burn LP + pay the investor + COMPLETED, in the same call, with no admin approve (v3-82)
  • A still-short top-up leaves it PENDING_RESERVE for a follow-up fund
  • 🔴 This is the only settlement path. claimRedemptionFallback (v3-31, permissionless after a notice window) and approveRedemption (Service Key manual settle) both drew on the pool's reserve, and the reserve is routed to reserve_wallet at deposit. Neither function exists on chain any more.
  • 🔴 v3-31's exit right moves off chain with it. "Anyone can settle a stale request even if the partner and Aset are absent" was a property of the contract; it is now the reserve wallet funding the request, which is an operational commitment. The freeze exit right (v3-28) is unaffected and still on chain.

The investor sees "Awaiting partner funds" throughout, then "USDC in wallet".

🚨 Failure Path

If a transfer fails → status = FAILED. failure_type + error_message logged. Admin can retry.

Segment × mode — which combinations exist

An investor's timeline has three segments (LOCKED · EARLY · FREE/MATURED), and each redeemable segment is processed in one of two modes (instant · epoch). Two independent settings decide the shape:

SegmentRedeemable?CostProcessed by
🔒 LOCKEDNo
⚠️ EARLYredemption_type decidespenalty_typeepoch_duration_days
✅ FREE / MATUREDAlwaysNoneepoch_duration_daysthe same value

One epoch_duration_days serves both segments, which is what makes two of the six combinations unrepresentable rather than merely unbuilt:

EARLYMATUREDToday
closedinstantLump-sum at maturity
closedepochPost-maturity epoch redemption — the chain always allowed it; the wizard can now express it (v3-132)
instantinstantJoob
epochepochRun-risk pools
instantepochNot expressible — needs a second cadence field on-chain. Wanted only if a product allows a penalised early exit and staged repayment
epochinstantNot expressible, and no demand: gating exits while the pool runs and then paying out in one shot is close to self-contradictory

⑤ and ⑥ are not a wizard restriction. Separating them requires a second cadence field on-chain, and PlatformPool is not upgradeable — each pool is pinned to the implementation it was created with — so a new field would reach new pools only. That pinning is not hypothetical: four pools created with the unwired LIQUIDITY_WINDOWS value cannot be repaired and are retired rather than relabelled (migration 0106; v3-88 D5 closed the dropdown that produced them). ② is different in kind — it is a wizard guard over behaviour the contract already has.

Epoch-Based Redemption

Applies to any pool with epoch_duration_days > 0 (v3-26) — the default for OPEN_ENDED pools, and an opt-in for any pool (including FIXED_TERM) where concurrent-exit / run risk warrants it (v3-46; see Why epoch instead of instant). Exits are not processed FIFO on demand: they are batched into windows and settled in bulk. Deposits are unchanged and stay instant/atomic — epoch is one-directional.

The sections below are the as-is: the engine redesign (v3-91, as amended by v3-93 and closed out in v3-100 / v3-105) is in the contract, and how each item landed — including the two it landed differently from the original design — is recorded in those cards and dated in 17-changelog. Any sentence elsewhere describing fixed N-day windows or a lazy start is pre-redesign.

epoch_duration_days is fixed at pool creation (v3-38). It is type-prefilled (OPEN_ENDED → 7 / FIXED_TERM → 0) and immutable while the pool is live in v1 — investors deposit knowing the redemption model and it never moves under them. The on-chain setEpochDurationDays reverts once the pool has investors (totalLPSupply > 0) and is not exposed in product: tightening liquidity on a live pool is an undisclosed redemption gate, and an unbounded value is a fund-trapping backdoor that breaks the v3-31 exit guarantee. Changing it live through 7-day timelock governance is a v2 item (needs audit).

Model B — a request window, then a gap

The engine accepts requests only inside a window, and refuses them in the gap between windows. That is Model B, and it is the shipped behaviour (v3-93) — not the "always accepting, batched at the end" Model A that earlier drafts of this page described.

Three config axes, all per pool:

AxisOn-chainMeaning
epoch_duration_daysepochDurationDaysThe cadence the schedule advances by: a fixed day count, MONTHLY → 28 / QUARTERLY → 84. Week multiples, so every derived boundary keeps the same weekday. epoch_schedule_type is a DB label with no on-chain field, and the contract never does calendar arithmetic — so a cycle is not a calendar month and drifts against one. Say "every 28 days", not "monthly" (v3-124, glossary → month)
request_window_daysrequestWindowDaysHow long the window stays open before the cutoff
recall_lead_daysrecallLeadDaysThe gap between the cutoff and the funding date — the publisher's time to recall and wire the confirmed total

Everything is derived backwards from that cycle's funding date (see below), so there is one number per cycle and no stored boundary array:

windowOpen(n) = cutoff(n) − requestWindowDays      ← requests open
cutoff(n)     = fundingDate(n) − recallLeadDays    ← requests close, demand freezes
fundingDate(n)                                      ← settlement allowed from here; claim opens

A cycle is longer than the two configured terms, and the leftover is part of it. Drawn to scale for the monthly preset (cadence 28, window 7, lead 10):

      window (7)          recall lead (10)        leftover (11)
 │◀───────────────▶│◀─────────────────────▶│◀────────────────────▶│
 ├─────────────────┼───────────────────────┼──────────────────────┤
open            cutoff               funding date            next open
 │  requests +    │        closed         │        closed        │
 │  cancels       │   nothing accepted    │   nothing accepted   │
 └────────────────┴───────────────────────┴──────────────────────┘
                                     executeEpoch + claim
 │◀────────── one cycle = epoch_duration_days (28) ──────────────▶│
  • Outside the window both requestRedemption and cancelRedemption revert (RequestWindowClosed, _requireRequestWindow shared by both entry points). The gap exists so the total sent to the publisher cannot change after they have been told it.
  • A window that derives to 0 reverts too (ScheduleNotConfigured), rather than reading as open. epochWindowOpenAt returns 0 when the accepting cycle has no derivable funding date, and block.timestamp < 0 is false, so the gate used to pass and the cycle read as permanently accepting. Nothing reached that state because settlement writes the next cycle's date as it advances — a property of the settlement path, not a guard, and it would disappear with any change that skips a cycle or moves the cursor. ⚠️ In source, not yet deployed; Clones means existing pools keep the old behaviour.
  • The window bounds the wait — it no longer decides whether that wait is paid. Under v3-91 Rule 1 a request placed just after a cutoff waited duration + recallLeadDays earning nothing, which is what made the window load-bearing. Since v3-131 (3) shipped, the wait accrues normally, so what the window still buys is a bounded, predictable queue rather than protection from a forfeit.
  • Distress overrides it: WIND_DOWN and IMPAIRED skip the window gate entirely, so a distressed pool never traps an exit behind a closed window.
  • The leftover is derived, not configured: epoch_duration_days − request_window_days − recall_lead_days. Nobody enters it, and it is the stretch between one cycle's funding date and the next cycle's window opening (windowOpen(n+1) = windowOpen(n) + epoch_duration_days). A payout does not reopen requests. The contract requires the span to be strictly under the cadence (InvalidSchedule), so the leftover is always at least 1 day.
  • Read the window as a share of the cycle, not as a duration. The monthly preset accepts requests on 7 of every 28 days: closed from cutoff through the next open is recall lead + leftover = 21 consecutive days. Both numbers move whenever the operator edits the window or the lead, which is why the admin wizard draws the whole cycle and names the leftover rather than stopping at the funding date (v3-124).
  • Presets: monthly window 7 / recall 10, quarterly window 14 / recall 21 — prefilled defaults, editable per pool.
  • Legacy pools (fundingAnchor == 0) have no window at all and keep the old lazy behaviour. setEpochSchedule is create-only, so they stay on that path permanently (v3-100) — but no new pool can join them: the three schedule terms are required at create, deploy asserts fundingAnchor back off the chain, and the contract reverts ScheduleNotConfigured rather than falling through to the lazy engine (v3-107).

Funding date: confirmed vs derived

Every boundary of an anchored cycle is derived from one number, the funding date (v3-93): cutoff = fundingDate − recallLeadDays, windowOpen = cutoff − requestWindowDays. That number is supposed to come from the publisher via the operator, per cycle — and per C2 it is fail-open: a cycle nobody confirms advances on its own to previous funding date + cadence, so nothing stalls when the operator is silent.

The investor-facing consequence is that a displayed date can mean two different things, and v3-105 settles how to tell them apart:

MeaningSource
확정 / ConfirmedThe operator entered this cycle's date, and it is now locked (setEpochFundingDate reverts WindowAlreadyOpen once the window opens)DB provenance recorded alongside pools.next_funding_date at confirm time
예정 / EstimatedNobody confirmed it; it is previous + cadence and can still move until the window opensThe absence of that provenance

The badge reads the database, not the chain — the chain cannot answer this question by construction.

Why the chain cannot answer it, and where the provenance lives

epochFundingDate(id) returns the same number whether it was confirmed or derived, and a raw-storage getter would not help either: settlement materializes the fail-open value into epochFundingDate[id + 1] (silently, no event) and cycle 1 is backfilled from fundingAnchor, so stored != 0 is true for dates nobody ever confirmed. A trustworthy on-chain answer needs a dedicated flag, which needs new storage and a new view — and pools are EIP-1167 clones with an immutable implementation, so that would apply to new pools only.

Where the provenance lives (v3-107): pools.next_funding_date_confirmed_at + next_funding_date_set_by (migration 0111), written only after the setEpochFundingDate transaction confirms, and nulled when settlement advances the cycle — the new cycle's date starts life derived, so provenance re-arms per cycle rather than inheriting 확정 from the cycle before it. The badge reads 확정 only when both are present for the cycle on screen.

The operator reaches the setter through POST /pools/{id}/epoch-schedule, and GET /pools/{id} returns the schedule columns, so the badge and the countdown have their inputs. For audit and reconciliation, EpochFundingDateSet is the only exact confirmation signal on-chain — settlement's materialization emits nothing — and its ABI fragment plus an indexer writer are in place, so confirmations are mirrored rather than assumed (v3-92: the DB mirrors the chain, not our intent).

Why epoch instead of instant

The redemption model is a gating-structure choice, orthogonal to maturity_model (v3-46): epoch_duration_days is selected per pool independently, and the type-driven default (FIXED_TERM → 0, OPEN_ENDED → epoch) is only a prefill, not a constraint — there is no validation coupling, so a FIXED_TERM pool may be created with > 0. The real decision axis is "can concurrent exit demand exceed available liquidity such that timing/order is unfair?" (run risk), not "does the pool have a maturity date." Note this orthogonality is to maturity_model, not redemption_type: the admin create/edit wizard suppresses the epoch selector for redemption_type = FIXED_MATURITY (v3-88) — a matured pool pays out once, so a cadence is N/A — but this is a wizard guard only and does not change the on-chain behaviour above.

  • OPEN_ENDED has no maturity, so all exit volume is off-schedule and continuously exposed to that condition → epoch is the baseline.
  • FIXED_TERM concentrates exit demand at a planned event (maturity, where the partner returns capital as loans mature); the pre-maturity early-exit tail is sparse and penalty-gated, so instant + PENDING_RESERVE suffices. But a FIXED_TERM pool with concentrated / run-prone investors (or material writedown risk) can opt into epoch at creation for the same fairness guarantees.

Under that condition the instant FIFO model breaks down: ① first-come unfairness / bank-run, ② NAV-at-request locks in arbitrage (early exiters offload loss onto stayers), ③ indefinite PENDING_RESERVE waiting. The epoch model solves this with epoch batching + pro-rata + forward pricing + pull claim (the Maple / Goldfinch / Centrifuge standard).

Epoch ≠ run halt. Epoch makes exits fair and orderly under stress; it does not stop a run. If liquidity is insufficient the epoch still settles a pro-rata partial fill and the unfilled remainder rolls over. Halting exits outright is is_emergency_frozen (72h exit-block window, after which the exit right opens); capping the bleed rate per window is redemption_gating_bps (epoch-only today). Note is_paused and IMPAIRED halt new deposits only and keep redemptions open (IMPAIRED also waives lockup and penalty). Pick the redemption model for fairness and ordering; use the freeze and gating levers for stopping a run.

DimensionInstantEpoch
NAV pricingNAV-at-request (snapshot)NAV-at-settlement (forward pricing, SEC Rule 22c-1 style)
OrderingFIFOPro-rata (no age priority) — fair across all requests in the window
Insufficient liquidityPENDING_RESERVE (indefinite)Pro-rata partial fill + remainder rolls over to next epoch
Settlement triggerThe requestRedemption tx itself — reserve covers → settles in-line; short → PENDING_RESERVE, then the partner's fundRedemption auto-settles (v3-82)Time-based executeEpoch() at window end (O(1), no loop)
Payout deliveryPush, in the settling txPull — investor calls claimRedemption()
Approval gateNone. approveRedemption survives only as an optional manual settle for a PENDING_RESERVE request the reserve grew into; it reverts on a REQUESTED one100% automatic; admin only holds anomalies

Flow: request → settle → claim

Roles: 🔵 Investor · 🟢 System (scheduler / permissionless) · 🟠 Partner · 🔴 Admin

① Request (inside the request window) 🔵 Investor

Investor calls Pool.requestRedemption(lp_amount). The call reverts RequestWindowClosed outside [windowOpen(n), cutoff(n)) — see Model B. Inside the window, LP is locked (transferred to the Pool, not burned) and enrolled into the accepting epoch → status QUEUED, epochNewDemandLp[n] += lp_amount and epochTotalDemandLp[n] += lp_amount (new demand and carry demand are tracked separately — see carry-first fill). No NAV snapshot and no per-request penalty calc; both are deferred to settlement, and the request stores its ladder basis (gBase, hBase, generation) plus, for YIELD_BASED pools, the yield banked at lock time.

Yield does NOT stop here (v3-131 (3), shipped 2026-08-20 — reverses v3-91 Rule 1). Locking LP is a custody move, so the position keeps accruing; the stop is at settlement, the moment funds are actually committed.

⚠️ Where the debt is BOOKED is a separate question from whether it is owed. accrualBasisLp is totalLpSupply − poolHeldLp, so escrowed LP is not recognised debt while it sits there — its entitlement is computed per request and booked, as debt and as the holder's credit in the same instant, when the escrow ends. Recognising the debt early and the credit late is what the first attempt did, and it let one obligation be paid twice; the property tests behind the current shape are in test/EscrowAccrualRecognition.t.sol. previewEscrowAccrual(requestId) is the gap while an escrow is open, and it is what makes "escrow never shrinks the total bill" checkable rather than asserted.

✅ Shipped — the stop is at settlement, not at request

Rule 1 was not a rounding concern, it was a redistribution. The publisher pays a full coupon on outstanding principal; excluding escrowed LP from the denominator did not reduce that coupon, it handed the requester's share to the holders who stayed — and worse, once every holder had requested (which the post-maturity schedule forces) the denominator hit zero and the coupon was owed to nobody at all.

Accrual ends at executeEpoch — not at request, and not at claim. Ending it at claim would pay investors for delaying their own claim. An unfilled remainder keeps accruing, because that principal is still deployed and the publisher is still paying on it.

How the contract does it in O(1). The obstacle was that executeEpoch fixes tier ratios and never materialises a per-holder fill — that is derived at claim — so "stop this holder at settlement" had nothing to hook onto. The epoch ladder therefore carries a third cumulative alongside epochG (surviving fraction) and epochH (cash paid): epochGy, the same surviving fraction integrated against the accrual index. A request's escrow-period yield is then two spans, exactly the two a fill splits into — a flat span on the full principal until its own epoch settles, then a ladder span on the unfilled remainder — and it is credited per request into creditAccrued when the escrow ends. Generation restarts are mirrored by generationCloseGy, as they are for epochH.

All three exclusion segments are closed. The third — a distribution landing between settlement and claim — is closed the other way: it deliberately earns nothing, because by then the payout is priced. A cancellation or a rejection credits to now instead, because those price nothing, so a request opened and cancelled inside one epoch loses no interest either.

The two defences v3-91 relied on are untouched and are what make this safe: pricing is forward (settlement NAV, so a request locks in no price) and cancellation is impossible after the cutoff. Spec: Notion "풀 상환 모델 — 구간 × 모드 (기획서)" §3.2; contract-side derivation in root 04-contract-round.md §4 and 06-yield-accrual-redesign.md §14-2.

:::

② Partner top-up (during the recall window) 🟠 Partner

The funding date is the deadline, and the recallLeadDays span between cutoff and funding date is the window for it: demand is frozen, so the total the publisher is asked to wire cannot move afterwards. Partner sends funds via Pool.fundRedemption()epochFundTopUp[id], accumulated into that epoch's available liquidity (not paid out per request, unlike instant).

⚠️ The on-chain EpochFundingNeeded event fires at settlement, not before it — it reports what could not be filled (_settleTiers, when demandUSD > filledGrossUSD). The pre-deadline signal is off-chain: readEpochShortfall reads epochTotalDemandLp / navPerToken / reserveBalance / epochFundTopUp and drives the D-2 / D-12h reminders. See Partner funding (3-layer) below.

③ Settle — `executeEpoch()` (O(1)) 🟢 System

Gated on settlementAllowedAt(id) = the cycle's funding date, optionally delayed by the operator's settleAfter[id] knob but never past fundingDate + recallLeadDays (the exit-guarantee cap). Legacy unanchored pools still gate on currentEpochEndsAt. Also blocked by the circuit breaker, by a stale NAV (navStalenessSeconds), and reverts InvalidNav if navPerToken == 0 (a corrupt-oracle guard that must precede the fill math, or a zero NAV scores as a full fill).

No per-investor loop, and no LP is burned — but money does move. In one pass it computes what is available, fills carry demand before new demand, rolls the unfilled remainder to the next cycle, and reserves the filled cash into redemptionCommitted rather than merely promising it. Then currentEpochId++ and it emits EpochSettled(id, aggFillRatio, settleNav, filledGrossUSD, settledLp), plus EpochFundingNeeded on a shortfall.

Normally triggered by the Aset scheduler, but permissionless once allowed (v3-31) — settlement happens even if Aset goes silent. ⚠️ This is executeEpoch, and it is unaffected: what v3-31 lost is the INSTANT pools' claimRedemptionFallback. Safe to open because it is O(1) and deterministic. Mechanics: Settlement internals.

④ Claim (pull) — `claimRedemption(requestId)` 🟢 System / 🔵 Investor

The investor (or anyone — see below) pulls their settled amount. O(1) per request, computed by _epochFillMath in exactly two pieces: the request's vintage epoch, where it was new demand, plus every epoch after that, where the remainder was carry demand — so each cycle's own epochSettleNav is applied automatically across the request's whole lifetime.

Effects: burn filledLp, _payCommitted the net to the investor and the penalty to fund_wallet (both out of the settlement reservation, never from live reserve), then re-anchor the request into pure-carry form. Status → PARTIALLY_FILLED if a remainder survives, else COMPLETED. Emits RedemptionClaimed(requestId, investor, filledLp, payout, penalty) (+ RedemptionRolledOver when applicable). Both money legs are in the event because both come out of the settlement reservation: the ledger releases payout + penalty from redemptionCommitted, and reporting only the net left it permanently overstated.

⚠️ lpRemaining only shrinks here. That is why a cancel after an unclaimed fill has to claim first — see Claim before cancel. Exact math: Settlement internals.

Pull claim & claim-on-behalf. claimRedemption is permissionless (v3-31) — anyone (including an Aset keeper) may call it, but it always pays the original requester (destination fixed/immutable, like approveRedemption and the fee legs inside settleYield). This lets Aset sweep unclaimed payouts to rightful owners while staying non-custodial: the contract enforces "only the rightholder is paid," and funds sit in the PlatformPool contract — not Aset the entity — until claimed, the same category as unclaimed accruedYield. The fallback claim respects the AML / KYC-REVOKED gate, so a sanctioned wallet cannot wait out a hold. ⚖️ "Confirmed-but-unclaimed" custody + escheatment policy is a [Legal Review Required] item.

A claim never expires (C7, recorded v3-107). A settled share stays claimable indefinitely: no deadline, no sweep-to-pool, no reversion to remaining holders. Forfeiting an investor's money for the passage of time is untenable in a regulated RWA context, and the money is not in anyone's way — the reservation is isolated inside redemptionCommitted, so an unclaimed payout can never be double-spent on another cycle's fill or on yield. The cost is that redemptionCommitted never returns to zero on its own, which tightens the hold-back release clamp; that is the intended trade, since the cash belongs to a named claimant. Do not add an expiry without answering the escheatment question above first.

Partial fill & rollover

When liquidity is short the epoch fills pro-rata with no age priority inside a tier — but there are two tiers, and carry is filled first (C8):

  1. Carry tier — remainders that already rolled over from earlier cycles. Filled first, so a request that has already waited a cycle is never overtaken by fresh demand.
  2. New tier — this cycle's fresh requests, filled from whatever liquidity the carry tier left.

Whatever neither tier fills becomes carry demand next cycle. Each cycle's own settleNav applies to the slice it filled, so a request that spans three epochs is paid at three prices — and the whole thing stays O(1) to settle and O(1) to claim, however many cycles it survives.

Worked example (3 epochs, NAV $1.00 / $0.90 / $0.95, partial fills) → total payout $94.75. Used as the contract unit-test oracle.

Settlement internals

Everything below is on-chain mechanics. It is here for contract and indexer work; nothing in it changes what an investor sees, which is the two-tier guarantee above.

Fill math, the reservation bucket, and the claim's two pieces

What executeEpoch computes.

  • available = reserveBalance + epochFundTopUp[id], capped at redemption_gating_bps × totalDeposited / 10000 when gating is set (deposited-capital accumulator, not TVL — v3-67). ⚠️ redemption_gating_bps is deprecated — not included in MVP (2026-08-27, not yet reflected, v3-150). The cap is always 0 in MVP, so this term is inert; the formula is left as it stands because the contract still carries it.
  • Carry-first 2-tier fill: the carry tier (epochCarryDemandLp[id]) is filled first at ratioCarry; new demand (epochNewDemandLp[id]) is filled from what is left at ratioNew. Both stored, plus the aggregate epochAggFillRatio[id] = filledGross / demand that EpochSettled reports and the off-chain mirror reads.
  • Ladder advance: epochG / epochH move for the carry tier only, restarting by generation on a full fill (the 🔴F fix — below). epochCarryGen[id] records the generation after the advance.
  • Rollover: whatever neither tier filled becomes next cycle's carry demand (epochCarryDemandLp[id+1], epochTotalDemandLp[id+1]). A closed generation rolls nothing forward — its sub-wei remainder would be demand no request can claim.
  • Cash is reserved, not promised (🔴B / 🔴G): _reserveFilledGross debits the filled gross in the order epochFundTopUp[id] → reserveBalance and adds it to the single redemptionCommitted scalar. Claims draw only from that scalar, so the same cash can never back two epochs and a redemption can never reach the balance backing unclaimedYield. There is no per-epoch epochClaimable pot — that design was withdrawn (v3-100) because a claim spans epochs, making per-epoch pots O(V).
  • currentEpochId++. On an anchored pool the next cycle's fail-open funding date is materialized (epochFundingDate[id+1] = fundingDate(id) + cadence, left untouched if the admin already confirmed one); legacy pools do currentEpochEndsAt += cadence.

The claim's two pieces (_epochFillMath):

  1. its vintage epoch, where the position was new demand → principalLp × epochNewFillRatio[vintage], priced at epochSettleNav[vintage]
  2. every epoch after that, where the remainder is carry demand → the cumulative ladder span from (epochG[vintage], epochH[vintage], epochCarryGen[vintage]) to the latest settled boundary

Payout is floored to the stablecoin's decimals (nearest rounding is forbidden — it would over-draw). Floor dust stays inside redemptionCommitted and is deliberately not swept today. Re-anchoring sets epochId ← 0 (the new-tier piece is now accounted for, so it can never be counted twice), principalLp / lpRemaining ← remainingLp, basis pinned to the latest settled boundary.

Ladder generations — why a full fill restarts rather than collapses (the 🔴F fix)

A plain surviving-fraction product cannot survive its own success. G[id] = G[id−1] × (1 − fillRatio) hits zero on the first full fill, and fillRatio = 1 is the operating goal, not an edge case — so liquidity gating would die on the first healthy cycle, after which the terminal claim branch pays out remaining balance with no liquidity check at all. Snapshotting (gBase, hBase) on the request is necessary but not sufficient: if G[vintage−1] was already zero when the request came in, the snapshot is zero too.

What closes it: a full fill clears all outstanding demand in that generation, so every request in it is finished — which means the ladder can be restarted at that point instead of collapsed.

  • On a full fill: record generationCloseH[gen] = H, increment gen, reset epochG[id] = 1e18 and epochH[id] = 0.
  • A claim whose ep.generation is older than the current one reads "my generation closed on a full fill" → filled in full, priced from generationCloseH[my generation]. That stored value is what keeps an unclaimed straggler whole across a restart.
  • demand == 0 is not a full fill — the ladder carries forward and the generation does not advance. (This is also where v3-93's demand == 0 open item landed.)
  • The same restart absorbs precision exhaustion: once the surviving fraction rounds to 0–2 wei at 1e18 scale it is treated as a full fill, deliberately rather than reverting — blocking settlement traps funds, which is worse than dropping sub-wei dust the payout floor already discards (v3-100).

Claim stays O(1) and settlement stays O(1). Detail: v3-100.

States

StatusMeaning
QUEUEDEnrolled in an epoch, awaiting settlement
PARTIALLY_FILLEDPro-rata partial fill claimed; remainder rolled to next epoch
COMPLETEDFully filled and claimed

Cancellation

An investor may cancelRedemption while QUEUED/PARTIALLY_FILLED (epoch) → locked LP returned + epochTotalDemandLp decremented. The cancelled request persists as REJECTED (there is no separate CANCELLED status; failure_type = INVESTOR_CANCELLED distinguishes it).

🔴 Instant cancellation is out of MVP scope (decided 2026-08-27 — not yet reflected, v3-149). The investor app has no instant cancel button; it is on the epoch flow only. The instant branch of cancelRedemption in RedemptionLib is still on-chain — out of MVP scope, not removed. Epoch cancellation is unchanged: it is possible only inside the request window, and cancelling does not return partner funding.

Cancel window = request window (v3-93). cancelRedemption carries the same gate as requestRedemption and the two open and close together (_requireRequestWindow, shared by both entry points). After the cutoff a cancel would falsify the confirmed total already sent to the publisher, who then over-recalls; after settlement it would break the Σ claims ≤ redemptionCommitted invariant. Rolled-over demand is not trapped — a partially-filled investor cancels in the next request window, so the restriction is scoped to the epoch, not to the request. ⚠️ The old UI copy on this is now wrong. It told the investor that escrow-period yield is not refunded because it had already gone to the remaining holders. Since v3-131 (3) shipped, a cancellation credits the whole wait — to now, not merely to the last settlement, because a cancellation prices nothing.

Still open: whether an admin rejection carries the same cutoff gate, and whether a regulatory forced cancel (KYC revocation, sanctions listing) gets an exception path.

Claim before cancel

A cancel inside the window is not the whole story once a settlement has filled part of the request. Settlement reserves the filled gross in redemptionCommitted but burns no LP, so returning ep.lpRemaining would hand back the full position and leave that cash with no claimant — under-paying every remaining holder. So cancelRedemption reverts ClaimBeforeCancel(requestId) while filledLp > 0 (v3-105), and the investor's sequence is:

  1. claimRedemption — pays the filled slice, burns that LP, re-anchors the remainder to pure-carry form. No window gate, so this step is available at any time.
  2. cancelRedemption — inside the request window, returns the remainder and decrements carry demand.

A request that has already claimed reads filledLp == 0 until the next settlement, so an ordinary cancel is unaffected: the gate fires only on an unclaimed fill.

FE requirements. The two-step must be surfaced as such — a cancel button that reverts with no explanation is worse than a disabled one, and the copy has to name the filled slice and say it is paid out, not forfeited. previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD) supplies the split, which is not derivable from public state: epochRequests exposes only (epochId, principalLp, lpRemaining, yieldAccSnapshot) and there are no getters for epochH / epochCarryGen / generationCloseH. Signature in 08a. Both are deployed, so nothing is left to write — the FE needs a pool from the current factory to build against (see the clone note at the top of this page).

🔴 Interaction to close: a KYC-revoked epoch investor is blocked from claiming (canRedeem) and is now also blocked from cancelling, while rejectRedemption has never accepted QUEUED / PARTIALLY_FILLED — so no party can close the request. Needs an admin unwind or a carve-out in the gate. P1.

Partner funding (3-layer)

If reserve can't cover the epoch's demand, the partner must top up from fund_wallet before the deadline. Notification + enforcement is layered:

  1. On-chain (SoT + hard deadline): the cycle's funding date is the deadline; EpochFundingNeeded is emitted at settlement with the amount that could not be filled, so a partner keeper can react trustlessly (no Aset needed).
  2. Off-chain (alerts, convenience): Aset Lambda reads the shortfall from live pool state (readEpochShortfall) → partner dashboard + D-2 / D-12h email reminders (notification_eventsnotification_deliveries; notification_logs was dropped by migration 0110). This is the only pre-deadline signal. Not a trust boundary.
  3. Enforcement lever (Centrifuge-style, dev decision ①): ⚠️ removed. The lever was the remainder hold-backsetFundingRestricted(true) kept each deposit's fund_wallet release inside the pool as redemption liquidity, on the principle "no new financing while redemptions are pending". It was never wired to any product surface and was deleted in 0183 (v3-112). What remains for a chronic shortfall is escalation to IMPAIRED (v3-12).

The hold-back bucket is gone (0183)

heldFundReleases sat beside reserveBalance inside PlatformPool and could also pay a redemption, which is why the two were routinely conflated — this page carried a table telling them apart. The bucket read 0 on every pool that ever existed, so removing it changes no number; what it removes is a term from five money formulas. Bucket semantics that remain: 23-money-path → Reserve.

reserveBalance is now the pool's only liquidity bucket. It comes from the reserve_bps retention on every deposit and reinvest, it is investor money held for redemption liquidity, it is spent after the epoch's own top-up (epochFundTopUp[id] → reserveBalance), it is never released to the partner, and it does not absorb a NAV loss (R8).

Anomaly hold (hybrid)

Epochs settle 100% automatically unless an anomaly is detected, in which case admin can holdRequest(id) (exclude from settlement) / releaseRequest(id) (re-include) — granularity is per-request for compliance/single-large, per-epoch for NAV/total-volume/low-fill. Triggers (all config, mostly reusing existing signals):

ClassTrigger (default threshold)Granularity
Large redemptionsingle request or epoch total > 25% of TVL (soft 5%) or absolute $ floorrequest / epoch
NAV anomaly / oraclesettlement NAV deviates > ±5% vs prior · NAV stale · circuit breaker tripped (v3-32)epoch
Compliancerequester KYC REVOKED · AML investigation · sanctions matchrequest
Partner distresspool near IMPAIRED/WIND_DOWN · reserve depleted · yield_overdueepoch
Chronic rollover / low fillsame request rolled over N times (3) · epoch fillRatio < 10%request / epoch

⚠️ Dependency: the NAV deviation cap / circuit breaker / staleness checks require v3-32 (NAV bounds in core) — see v3-32. Without it, the NAV-class anomaly detection cannot fire.

Post-maturity epoch redemption

🔨 Built, not deployed — and two of its four columns are not applied to dev

This is combination ② of the segment × mode matrix: exits refused until maturity, then principal repaid across several cycles. The engine that runs it is the epoch engine documented above, unchanged.

What has landed in code: the wizard (v3-132), the plan columns (0189), the deploy writing every cycle's date and the observed-date column behind it (v3-133, 0190), the per-cycle confirm endpoint and its ordering guard (v3-139). No contract change — every part of this rides settings and call sites the chain already had, which was the premise of v3-131 and still holds.

⚠️ None of it is live. Migrations 0189 and 0190 are not applied to dev and the API is a manual deploy. Item (3) below — yield accrual ending at settlement — ✅ shipped and on chain (new implementation 0x7C21153E… + factory 0xE8453DAc…, verified on Base Sepolia 2026-08-21). ⚠️ New pools only, and poolCounter() is 0 — nothing has been created from that factory, so every pool that exists today is still pinned to an older implementation and still pays the wrong people on this product.

Full plan, decision register and open items: Notion "풀 상환 모델 — 구간 × 모드 (기획서)".

What it is

A pool that refuses redemption until maturity (redemption_type = FIXED_MATURITY) and, from maturity onward, settles exits on a cadence (epoch_duration_days > 0) instead of in one lump sum. The publisher repays principal over several cycles rather than all at once, and keeps paying the coupon on what is still outstanding.

Before maturityRedemption requests revert
After maturitySettled every epoch_duration_days, on the standard Model B window
Per-cycle amountNot configured. A cycle settles against reserveBalance + epochFundTopUp[thisCycle], pro-rata across that cycle's requests — so in practice the publisher's funding for that cycle, plus whatever standing reserve the pool holds
Schedule lengthPer pool — pools.redemption_term_epochs, a cycle count (0189). This is the column the spec called redemption_window_epochs; it shipped under the shorter name and both appear in older text
Coupon during the scheduleContinues, on outstanding principal

It is not a new pool type on-chain, and no enum gains a value. It is a combination of two settings that already exist, which is the whole reason it is affordable. redemptionConfig is set only in initialize and has no setter, so a new redemption_type value would reach new pools only — and the last third value, the unwired LIQUIDITY_WINDOWS, left four pools permanently unable to redeem (migration 0106).

Nothing about it is deal-specific. Neither "four months" nor "a quarter of principal each time" is a platform assumption: the schedule length is configured, and uneven instalments already work without any setting, because the per-cycle amount is whatever arrives.

What has to change

#ChangeLayerStatus
1Let the wizard create FIXED_MATURITY + epoch_duration_days > 0 — the cadence and the anchored-schedule terms, which have to travel together — and relabel the schedule block "post-maturity redemption"FE/BE✅ Built (v3-132), not deployed
2The schedule's length — the "cycle 2 of 4" counterFE/BE + schema✅ Built as redemption_term_epochs (0189). Required for this combination, not optional as first specced
3Yield accrual ends at settlement, not at requestContract✅ Shipped 2026-08-20 — new pools only
4Maturity anchored per investorContract⬜ Out of scope — short raise window instead
5Holders who never request after the last cycleOperations⬜ Not a build

(3) was the last MVP item and the one place the engine actively produced a wrong result for this product — it moved a requester's coupon to the holders who stayed, every cycle. ✅ Shipped 2026-08-20: escrowed LP keeps earning for its holder; the entitlement accrues per request against the epoch ladder and is recognised — debt and credit in one instant — when the escrow ends. previewEscrowAccrual(requestId) is the lag while it is open. ⚠️ New pools only. The implementation is on chain (Base Sepolia, verified 2026-08-21) but poolCounter() is 0 — no pool has been created from that factory, and every existing pool is pinned to an older implementation that still runs the old rule.

(2) changed from optional to load-bearing. It was specced as a display counter that operations could take from the term sheet. Once the deploy has to write a finite list of dates (v3-133), the length is what says how many there are — the reminder, the endpoint and the confirmation card all read it, and none of them can work from a term sheet. It is still create-only, so it is decided late at the cost of not being retrofittable.

(1) was more than one guard, and the parts were coupled. pool-create/payload.ts gated three things on redemption_type !== FIXED_MATURITY: the cadence itself, the whole anchored-schedule block (epoch_schedule_type · funding_anchor_date · request_window_days · recall_lead_days · epoch_cycle_mode), and redemption_gating_bps. Only the last stays gated — it is hidden for this combination by decision. ⚠️ epoch_cycle_mode has since left the wizard altogether, for every combination (v3-140). Lifting the cadence without the schedule block would produce a pool the create endpoint rejects, because it requires an epoch pool's chain terms as a set; and setEpochSchedule is create-only, so a pool that slipped through unanchored could never be repaired. Relabelling was the small part of it.

(3) and (4) travel together if they travel at all. Both are contract changes, so shipping (4) separately costs a second deploy round for no additional coverage. Neither reaches an existing pool either way — a pool is pinned to the implementation it was created with, so both land on newly created pools and there is no migration to weigh; the platform is still pre-mainnet besides.

How the dates reach the chain

🔴 An unwritten cycle is not a blank on-chain. It is a wrong date, stated confidently, that hardens with every settlement

PoolCommonLib.epochFundingDateAt is a derivation, not storage. It returns the stored value if a cycle has one and previous + epochDurationDays if it does not — looking back exactly one step. So a cycle nobody wrote answers with a date, and on a calendar monthly plan on the 15th the answer drifts:

What the wizard approved       Mar 15 · Apr 15 · May 15 · Jun 15
What the chain would produce   Mar 15 · Apr 12 · May 10 · Jun  7

Three days out by cycle 2 and eight by cycle 4, with no revert anywhere. It does not stay recoverable either: every settlement writes the next cycle's derived value into storage (RedemptionLib.sol:894), so each settlement permanently fixes one more wrong date and the error ratchets forward.

The deploy writes cycles 2..N at the instant maturity becomes a real timestamp — the first moment the plan can be rebuilt against it, and the last before the pool is live with an incomplete schedule (v3-133). Cycle 1 is already on-chain as the anchor from setEpochSchedule, and the rest are walked forward from that anchor rather than re-derived from maturity, so the whole list belongs to one schedule.

What bounds the runThe Lambda clock (300s budget, 60s reserve), re-read before each write. Not a transaction count — that would have to be guessed from a per-transaction wall time nobody has measured, and the guess fails on the day the chain is slow
A truncated runNot a deploy failure. The pool is on-chain and correctly configured; the missing dates are for cycles whose windows open months out, and each can be written later. DEPLOY_FAILED on a live pool is a false record
What surfaces a truncated runThe funding-date reminder, gated on unwritten cycles (v3-135). ⚠️ Today that is the only thing — the post-deploy confirmation card is not in place, and the admin view layer for this pool shape is still in progress
Where a written date is recordedEpochFundingDateSetredemption_epochs.funding_date (0190). Observed facts onlyNULL means nobody set this, which is the only way to tell a set date from a derived one. ⚠️ It counts from cycle 2: cycle 1 comes from setEpochSchedule, which emits EpochScheduleSet, so it has no row by construction
Finishing the list afterwardsPOST /pools/{id}/epoch-schedule with epoch_id (v3-139)

🔴 Two writers, opposite orders — and merging them breaks one of them silently (v3-134)

Deploy ascending (2 → N): the run can be cut short, so what is left unwritten must be the cycles furthest out. Safe only because the deploy already refuses an anchor whose window opens before maturity.

Confirmation card descending (N → 2): nothing is truncated and the hazard inverts. setEpochFundingDate is refused once a cycle's window has opened, and that window derives from epochFundingDateAt, which returns 0 while the predecessor is unwritten — a zero window reads as "not open yet", so the guard is asleep. Writing cycle K arms the guard on cycle K+1. Ascending here can therefore lock a cycle out on the strength of a date nobody chose.

🔴 Walk primitive vs plan primitive — the same bug appeared in three places in one round

generateFundingDates walks from a first date you give it. repaymentPlanDates finds the first date itself, from a maturity and a roll day.

A caller that already has an anchor and reaches for repaymentPlanDates gets its firstFundingMs ignored under CALENDAR and cycle 1 re-derived from a roll day it may not have. With rollDay: 0 that lands on the end of the month05-30 · 06-30 · 07-30, outside the 1–28 rule and rolling into March every February.

It showed up three times in one round, all silent, all "a rule producing a confidently wrong date": the wizard's copy claiming every date was set up front, the deploy's own writer, and the reminder proposing "confirm 05-30" on a roll-15 plan. The fix is planDatesFromAnchor, which both anchored callers now use; repaymentPlanDates carries a warning. If you hold an anchor, walk from it — never re-derive.

Monotonicity is checked before the send, against both neighbours (v3-139), and the two sides are read differently: the EARLIER one including its derived date, the LATER one only when somebody actually set it. A derived successor's date is epochFundingDate[thisCycle] + cadence, a function of the slot being replaced, so comparing against it refused legitimate moves (cycle 5 stored 06-01, cycle 6 deriving to 06-29, moving cycle 5 to 07-10 reported as a collision when cycle 6 would then derive to 08-07). written comes from the indexer's EpochFundingDateSet mirror (redemption_epochs.funding_date, 0190). A date earlier than the previous cycle's puts that cycle's request window in the past, where _requireRequestWindow refuses every request and setEpochFundingDate refuses every correction — a cycle that can be neither used nor repaired. A neighbour reading 0 is skipped rather than treated as 1970. The same rule now also exists on-chain, in GovernanceLib.setEpochFundingDate alongside the non-zero and window guards, so a caller holding the ORACLE key no longer bypasses it. The on-chain check compares the PREVIOUS neighbour as derived and the FOLLOWING one as stored: a derived successor is epochFundingDate[N] + cadence, a function of the slot being overwritten, so it is the old value rather than a boundary. ⚠️ In source, not yet deployed — pools are Clones with no upgrade path, so every pool created before that deploy keeps the un-guarded implementation. The endpoint check therefore stays and is not redundant: being off-chain is exactly what lets it cover pools already live. It covers operator error everywhere; the contract copy is what covers a compromised key, and only on pools deployed after it ships.

Decisions that came with it

QuestionDecision
Per-cycle instalment sizeNot stored. Whatever the publisher funds that cycle, allocated pro-rata
Request granularityRequesting the whole position once is the default; per-cycle requests remain allowed
redemption_gating_bps⚠️ deprecated — not included in MVP (2026-08-27, not yet reflected, v3-150). Already hidden for this combination. It caps what may settle per cycle, and the publisher is already funding to a schedule — a second cap can only break that schedule
Coupon basisOutstanding principal. No setting is added: it is the standard default and the only behaviour the distribution logic has
Bank holidaysNo business-day convention, holiday calendar or business-centre list. The only segment a bank touches is the publisher recalling funds, which recall_lead_days already covers; investor claims and publisher funding are on-chain and unbounded by banking hours. A holiday that straddles a cutoff moves that cycle's funding date — an existing operator action
Holders who never requestHold, and notify. Sequence matters: notifications, then a termination clause in the terms, then only as much code as the clause requires. Never the wind-down path — the permissions look convenient but the meaning is inverted, and investors would see their pool labelled as liquidating

Constraints this product runs into

  • The first cycle does not start at maturity, and placing it there breaks the pool. A window opens at anchor − recall_lead_days − request_window_days, so the funding anchor must satisfy anchor ≥ maturityDate + recall_lead_days + request_window_days. Anything earlier opens cycle 1's request window before maturity, where _validateRedemption reverts every request — a cycle that exists and can never be used. ✅ The trap used to be that the two values were measured differently — maturityDate was deploy_time + maturity_days and moved when a DRAFT was deployed later, while the anchor is an absolute date the operator types. Both are absolute now (v3-145), so the relation is stable from the wizard onwards. The deploy still re-checks it (W2-4 in pools.worker.deploy), because the funding anchor and the maturity are still two separately-typed dates.
  • The cycle has to be long enough to contain its own window. setEpochSchedule reverts InvalidSchedule unless recall_lead_days + request_window_days < epoch_duration_days (and unless the anchor itself leaves room for both before it). A 28-day cadence therefore has under four weeks to hold the publisher's recall lead and the investor request window combined — a schedule that needs a long recall lead has to take the longer cadence, not a shorter request window.
  • A cycle is 28 or 84 days, not a calendar month (v3-124). A publisher who wires on the 15th of each month drifts against the cadence, and the operator moves each cycle's funding date to compensate.
  • 90-day cadence ceiling. Semi-annual and annual repayment schedules cannot be expressed.
  • Cadence and schedule terms are create-only. They must be settled with the raise plan, not after it.
  • 🔴 A cycle's leftover funding does not carry, and there is no way to take it back (v3-137). epochFundTopUp is keyed by cycle; a settlement reads only reserveBalance + epochFundTopUp[currentEpochId] (RedemptionLib.sol:955), and reserveBalance += appears exactly once in the whole repository (PoolLedgerLib.sol:73, the deposit split) — so no path moves a top-up anywhere. It is not lost: it stays counted in totalEpochTopUp, the wind-down numerator, so it reaches holders in a liquidation. The accurate operating rule is bound to that cycle, and the publisher funds each cycle to its actual demand rather than pre-funding the schedule. The absence of a sweep is a contract decision nobody has taken (into which cycle?), not a feature nobody has built. ⚠️ v3-100 item 1 said the leftover "rolls forward to the next epoch" and that is where the error originates — it is corrected on the v3-100 card, and the over_funding_detected notification still tells fund managers the old version (22-notifications).
  • ⚠️ Do not confuse this with carried demand. Unfilled demand genuinely does roll into the next cycle (epochCarryDemandLp, served before new demand). Funding does not. Copy that reuses the first sentence for the second is how the notification defect above happened.
  • The investor initiates a redemption request; a fallback never creates one for a holder who has not requested. The current source has no claimRedemptionFallback. There is no seven-day fallback payout guarantee. The separate epoch settlement and claim-on-behalf paths also depend on actual funding.

Emergency Wind-Down

If a partner becomes unresponsive for an extended period (60+ days), the pool can be wound down — terminating it and distributing recoverable funds to investors. Only what is inside the pool is recoverable on-chain; the remainder sitting in fund_wallet is partner-controlled and recovering it requires off-chain legal action. Full process in Pool Models → Emergency Wind-Down; the timeline is propose (multi-sig) → 30-day timelock, during which the partner can still respond → executeWindDown(), callable by anyone.

What is specific to the redemption path:

  • The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process.
  • Lockup and penalty are waived on the redemption path.
  • Deposits are blocked by the WIND_DOWN lifecycle (the whenActive gate). Wind-down does not set isEmergencyFrozen — that would block exits for 72h, the opposite of what wind-down needs.
  • The current source has no claimRedemptionFallback. There is no seven-day fallback payout guarantee. The separate epoch settlement and claim-on-behalf paths also depend on actual funding.

Deprecated (v2.x → v3.0)

The AS_POOL/FUND_POOL split, multi-sig escrow, and the FM_ACCEPTED pre-acknowledge step (endpoint included) are all gone — pools now differ only by config, and partner coordination is a state rather than a workflow step (v3-34). Current redemption_status enum: REQUESTED, QUEUED, PARTIALLY_FILLED, PENDING_RESERVE, PROCESSING, COMPLETED, REJECTED, FAILED.