Money Path — Fund & Fee Flow
Canonical, end-to-end map of where money moves: investor capital in, yield & fees out, and redemption out. This page is the single source of truth for wallet / bank-account naming and the on-chain ↔ off-platform boundary. The per-topic mechanics live in 04-pool-models (deposit/yield), 07-redemption, and 09a-custody (immutability); this page unifies them.
Naming per v3-70 · partner-remainder wording per v3-112. Verified against
apps/contract/srcand the indexer writers 2026-08-05; restructured the same day to the charter above, with implementation depth delegated to 08a / 11-db-schema / 06-writedown-nav.§9 is the record model — the append-only ledger and the projections folded from it (v3-122, 2026-08-11). Sections 3–7 map where money goes; §9 says what is stored about it and which figures are derived.
1. Canonical naming
Every wallet and bank account belongs to one of three streams: investor capital, the fund manager's fee, and Aset's fee. Names are unified on a single axis — the on-chain wallet and its fiat off-ramp share a prefix.
| Stream | On-chain wallet | Fiat bank account | Controlled by |
|---|---|---|---|
Investor capital (the 1 − reserve% released on deposit) | fund_wallet | fund_bank_account | Partner / fund SPV |
| Fund-manager fee (Pool-management fee) | fund_fee_wallet | fund_fee_bank_account | Partner / fund SPV |
| Aset fee (platform + SPC + performance) | treasury_wallet | — (Aset off-ramps separately) | Aset |
| Reserve | reserve_wallet (reserveWallet) | External wallet | Wallet signer, outside pool withdrawal restrictions |
Naming rules (v3-70)
treasury_walletis the canonical name for the Aset fee multi-sig. Prose elsewhere that says just "treasury" means this. (DB column is alreadypools.treasury_wallet.)fund_wallet≠fund_fee_wallet.fund_walletis the deposit money-path (the partner remainder,amount − reserve);fund_fee_walletreceives only the Pool-management fee and must be a separate address.fund_fee_walletis shipped —pools.fund_fee_wallet(migration 0067) and on-chainfundFeeWallet. Destinations and rates: §4.- Say "partner remainder", not "the 90%". The split is
reserveAmountRaw = amount × reserveBps / 10000, partner gets the rest. 90% is only the figure at the defaultreserve_bps = 1000;reserve_bpsis a per-pool config field. Under the reserve-zero launch assumption the remainder is 100%, not 90%, and a pool atreserve_bps = 2000sends 80%. Writing "90%" turns a configured parameter into a constant. pool_walletis deprecated — a dead legacy AS_POOL column. Do not use it; the investor-capital wallet isfund_wallet.- In the current external-reserve implementation, the
reserve_bpsshare of each deposit goes toreserve_walletand the remainder goes tofund_wallet. The in-pool reserve counter and getter are removed. The ratio specifies routing, not retention. An external balance does not automatically fund a pool payout. - No DB migration or on-chain change is required to adopt this naming — the live identifiers already match. Only the fund-data sheet and doc prose are being aligned.
2. On-chain ↔ off-platform boundary
Aset's system is stablecoin (USDC) end-to-end. The on-chain money path begins and ends at fund_wallet / the Pool contract. The conversion between stablecoin and fiat — via an OTC partner into the fund's bank account — happens off Aset's platform, on the fund/partner side.
Why this matters
Everything Aset can prove and enforce (immutable payout destinations, reserve, non-custody) lives on the left of the boundary. The fiat leg (OTC, fund_bank_account, fund_fee_bank_account) is a partner operations concern — Aset records the account details for reconciliation but does not move fiat.
Legacy money-flow examples
Existing clones retain their original implementation. Any in-pool reserve or instant-redemption formulas retained below are legacy implementation history, not the behavior of new pools. The 2026-09-08 handoff retires seven old pools as test pools and requires their removal from investor surfaces. This documentation change does not itself archive those pools or deploy contracts. For the current implementation, use section 6.
3. Stream ① — Capital in (deposit)
No fee is taken at deposit. The only split is reserve vs. released capital, and it happens inside the deposit transaction — the investor's capital reaches the partner in the same block it leaves their wallet. Everything after that block is off-platform and manual.
3a. The on-chain leg — one transaction
deposit(address stablecoin, uint256 amount) is investor-signed; no Aset key participates. In order:
- Gates —
whenNotPaused·whenNotFrozen·whenActive(lifecycle must beACTIVE) ·requiresKYC(msg.sender)· accepted stablecoin ·minInvestment· per-investor cumulativemaxInvestment·capacitymeasured againsttotalDeposited. Revert names and exact conditions: 08a-contract-reference. - Pull —
safeTransferFrom(investor → pool, amount), in raw stablecoin units. - Split, also in raw units —
reserveAmountRaw = amount × reserveBps / 10000,partnerAmountRaw = amount − reserveAmountRaw.reserveBalancetakes the normalized reserve figure (§3c). - Mint — LP against the full deposit, priced at
effectiveNav. The reserve retention is a liquidity allocation and does not dilute the claim (§3b). - Release — the partner remainder transfers to
fund_wallet(eventReleasedToPartner). This step has one arm. It used to have two: a hold-back lever could keep the remainder inside the contract instead, and it was removed in 0183 along with its bucket (§6).
There is no releaseToPartner() function
ReleasedToPartner is an event. The transfer is an inlined safeTransfer at the tail of deposit() — grep releaseToPartner apps/contract/src returns nothing. It was never a separate step an operator could hold, retry, or approve. (04 / 08 / 08a rendered it as a call until 2026-08-05 and now name the event; if you find the call form anywhere else, it is stale.)
3b. What the investor receives — LP quantity and price
LP is minted against the full deposit, priced at effectiveNav. The reserve retention is a liquidity allocation, so it does not dilute the investor's claim.
- First deposit into a fresh pool is 1:1 — $10,000 → 10,000 LP. Not a special case; NAV simply still equals 1.0.
- Inside a NAV-decrease timelock the price is the announced, lower NAV (R2·R3), so the depositor receives more LP instead of an instant loss when the queued write-down lands. Increases apply immediately, so they are never announced-priced.
- Rounding is always toward the pool (at most 1 wei of LP). Scales and formula: 08 → Amount Units · 08a.
⚠️ Reinvest is coded but not deployed
v3-111 puts reinvest on effectiveNav too, so a reinvest and a deposit of the same size in the same block mint the same LP. YieldLib.reinvest now reads it. The change is not in any deployed implementation, and a pool is pinned to the one it was created with, so on every pool that exists today a reinvest inside a queued decrease still mints at the stale, higher NAV and receives fewer LP.
3c. The reserve split
reserveAmountRaw = amount × reserveBps / 10000 on the raw amount; the partner gets the remainder. The reserve rounds down, so the dust (≤ one raw unit, $0.000001 on USDC) goes to the partner. reserveBalance is mirrored to pools.reserve_balance by the indexer every run (~2 min). Rounding rationale and the two distinct floors: 08a → Funds-integrity guards.
3d. reserve_bps — where it comes from, when it is read
- Source of truth
pools.reserve_bps, integer bps0..10000, required at pool create; shipped intoPoolConfig.reserveBpsatinitialize(). - Not ordinarily editable —
PATCH /pools/{id}excludes it. Changing it meansPOST /pools/{id}/governance(RESERVE_BPS) → on-chain propose/execute behind the 7-day timelock, DB mirrored at execute. - Read live, per deposit — never snapshotted.
deposit()readss.config.reserveBpsat execution time, so a governed change applies to every later deposit and no earlier one. Thedepositstable has no reserve column, so the rate a historical deposit got is recoverable only from the chain.
3e. The fiat leg — where the platform stops seeing the money
| # | Hop | Who holds the money | Synchronous? | Recorded where |
|---|---|---|---|---|
| 1 | investor wallet → Pool | Pool contract | ✅ on-chain, atomic | Deposited → deposits row (§3f) |
| 2 | Pool → fund_wallet | partner | ✅ same transaction | ReleasedToPartner — ✅ in the ledger (money_events, kind RELEASED_TO_PARTNER) |
| 3 | fund_wallet USDC → OTC counterparty | OTC counterparty | ❌ manual, off-platform | 🔴 nowhere |
| 4 | OTC → fund_bank_account fiat | partner / fund SPV | ❌ manual, off-platform | 🔴 nowhere |
| 5 | fiat deployed into the underlying assets | borrowers | ❌ partner operations | only as an aggregate, in the 12-hourly fund-data snapshot |
The boundary is between hops 2 and 3 (§2). Nothing in the codebase reads fund_wallet's balance, and no code compares USDC released against what the partner reports receiving — the only partner-side numbers ingested are the 12-hourly fund-data snapshot fields, which feed the NAV loss ratio and a read-only view. So a fund_wallet balance left unconverted for a week is indistinguishable, from every Aset surface, from one converted the same afternoon. There is no per-deposit settlement record, no expected window and no alert.
Redemption's off-platform funding is the mirror image and is instrumented (contract state + a scheduler + POST /redemption-requests/{id}/record-funding) — but even there only the on-chain top-up tx is recorded, never the conversion, so the fiat leg is unrecorded in both directions.
Open items (not answerable from the code, and not to be written as fact until they are):
- who the OTC counterparty is, and whether it is per-fund or per-jurisdiction
- the expected settlement window for hops 3–4, and what counts as late
- who reconciles USDC released against fiat received, on what cadence, and against which artefact
- whether an unconverted
fund_walletbalance is monitored anywhere outside the platform fund_bank_account/fund_fee_bank_accountare names only — no such DB column exists onpoolsorfunds. Whether they should become columns is undecided.
3f. Recording
A deposit is recorded twice over: POST /deposits is the common path and the indexer's writeDeposit is a ~2-minute reconciler for the ones it misses. Neither will credit LP off-chain — both take the amount from the Deposited event, and the API path returns 422 without a verified one. Writer differences (provenance, idempotency, and a valuation divergence between the two) are in 11-db-schema → deposits.
What matters for the money map is what is not recorded:
- The split is in the ledger (§9).
DepositedandReleasedToPartnerare ledger kinds, soreserve_balanceis folded from them rather than re-read from the chain: the deposit credits the reserve and the partner leg debits the remainder, and both are events. (FundReleasesHeld/FundReleasesReleasedwere kinds too until 0183 removed the hold-back.) - Reinvest is recorded on the same path.
Reinvestedis a ledger kind, so a reinvest whosePOST /yield/reinvestcall never lands is picked up by the sweep that catches a deposit. It carries a workflow row like any capital-in event, and the deposit list derivesis_reinvestmentfrom the event kind.
3g. Failure and in-flight states
- The on-chain leg is all-or-nothing. One
nonReentranttransaction: a revert moves no stablecoin, mints no LP, and leavesreserveBalanceandtotalDepositeduntouched. There is no partial deposit and no pending-deposit state. - The only seam is recording, not money. On-chain success plus a failed
POST /depositsleaves capital moved and LP minted while the platform is unaware, until the indexer books it (deposit) or indefinitely (reinvest, §3f). - The fiat leg has no failure state at all, in the sense that no code can observe or represent one.
- Gates that block capital-in —
is_paused, active freeze,capacity,is_showcaseandcustody_mode = 'MIRROR'(both BE-only) — change whether a deposit happens, not where the money goes. Conditions and revert names: 08a. ⚠️capacityis enforced twice, on-chain againsttotalDepositedand off-chain againstpools.tvl, and nothing reconciles the two counters.
3h. Worked example
$10,000 into a pool at reserve_bps = 1000, navPerToken = 1.02, no pending NAV change:
on-chain, one tx
reserve $1,000 → stays as reserveBalance
released $9,000 → fund_wallet (emit ReleasedToPartner)
LP minted 10,000 / 1.02 = 9,803.921568… (emit Deposited)
off-chain, seconds later
deposits +1 COMPLETED · pools.tvl +10,000 · position +9,803.92 LP
off-platform, unbounded, unrecorded
fund_wallet $9,000 USDC ──OTC──► fund_bank_account (local currency)
▲ last figure Aset can prove ▲ no record, no confirmation, no deadlineThe $9,000 is this pool's remainder at reserve_bps = 1000, not a constant — see the naming rule in §1.
Reserve dust does not arise here: 10,000 × 1000 / 10000 divides evenly. At $10,000.000001 the reserve would floor to the same $1,000 and the extra $0.000001 would go to the partner.
Reinvest follows the same money-path (E1)
reinvest(stablecoin, amount) converts accrued yield into LP and splits it identically — reserve_bps stays, the remainder is released — so reinvested principal is deployed by the partner exactly like fresh capital rather than accumulating in the pool. stablecoin picks the release currency, which the pool already holds from depositYield.
Two differences, both above: the price (§3b, v3-111 pending) and the missing reconciler (§3f). Its own gates (allowRollover, minReinvestAmount, spending settled accruedYield rather than the wallet, so no ERC-20 approval): 05-investment-lifecycle.
4. Stream ② — Yield & fees
Yield arrives from the partner; all fees are taken here (never at deposit). Fee amounts are computed off-chain in Aset Lambda from net_yield_fee_config, then applied on-chain in a single settleYield call that credits investors and pays the fee destinations together (v3-102).
Fee taxonomy — three destinations
This is the SoT for fee destinations. §1 carries the names only.
| Fee | net_yield_fee_config key | Charged on | Destination | Status |
|---|---|---|---|---|
| Aset platform | platform_yield_take_bps | gross yield | treasury_wallet | ✅ live |
| Aset SPC mgmt | spc_mgmt_bps | AUM × days/365 | treasury_wallet | ✅ live |
| Aset performance | perf_fee_bps / perf_hurdle_bps | gross, above the hurdle | treasury_wallet | ⏸️ dormant — no admin UI sets the rate, so it computes to 0 |
| FM Pool-mgmt | pool_mgmt_bps | AUM × days/365 | fund_fee_wallet | ✅ live |
Two destinations, both paid inside the single settleYield call in the same tx as the net credit (v3-69 destinations, v3-102 atomicity). The keys are integer bps since migration 0075 — the earlier admin_fee_pct / perf_fee_pct percent fields no longer exist.
Worked example (Joob: {platform_yield_take_bps: 100, perf_fee_bps: 2000, perf_hurdle_bps: 1500}), on $1,000 gross:
platform_take = 1,000 × 100 / 10000 = $10
perf_fee = $0 ← dormant; would be $200 (2000 bps) if a rate were ever set
net = 1,000 − 10 = $990
Pool.settleYield(usdc, 990, 10, 0) # 990 → LP holders, 10 → treasury_wallet (one tx)[off-platform] The FM converts fund_fee_wallet USDC to fiat via OTC → fund_fee_bank_account, the same unrecorded leg as deposit's (§3e).
5. Stream ③ — Capital out (redemption)
Redemption has no fixed-address logic. The reserve pays first from within the Pool; any shortfall is funded by the partner. Payout always goes to the original requester (request.investor) — there is no operator-settable payout address.
- The reserve pays immediately when it can. If not,
transfer_source = FUNDand the partner callsfundRedemption()to top up. fundRedemptionis role-gated toYIELD_DEPOSITOR_ROLE(held byfund_wallet, changeable only via a 7-day timelock) — not a hard-coded address.- The partner's funding leg is symmetric to deposit and equally unrecorded —
fund_bank_account→ OTC → USDC →fundRedemption, off-platform (§3e). - See 07-redemption for instant vs. epoch mechanics.
6. External reserve and redemption funding
In the current external-reserve implementation, the reserve_bps share of each deposit goes to reserve_wallet and the remainder goes to fund_wallet. The in-pool reserve counter and getter are removed. The ratio specifies routing, not retention. An external balance does not automatically fund a pool payout.
The seven-day claimRedemptionFallback is removed. Cycle settlement and claims depend on funded amounts. The move to an operational SLA is approved, but this document defines neither a response deadline nor a payout guarantee. This does not remove the separate epoch settlement and claim-on-behalf functions.
7. Wind-down price and payout funding
In the current source, executeWindDown preserves the oracle NAV. It does not reprice from external-wallet balances, and a NAV value does not establish that payout funding is available.
Sources: PoolLedgerLib.releaseReserveShare, GovernanceLib.executeWindDown, RedemptionLib, and handoff sections 1-4, 7-9, 8-2 and 8-3. Replacement legal custody wording remains a separate review item.
8. Immutability summary
| Path | Mutability |
|---|---|
Redemption payout (request.investor) | 🔒 immutable |
Yield claim (msg.sender, holdings-derived) | 🔒 immutable |
| Reserve path | reserveWallet change: 7-day governance timelock; the external wallet balance is not held by the pool |
Partner remainder destination (fund_wallet) | 🔒 immutable path, 🟡 mutable wallet (7-day timelock) |
Fee receipt (treasury_wallet) | 🟡 mutable — 7-day timelock (Aset's own money) |
fund_wallet (partner-remainder destination) | 🟡 mutable — 7-day timelock (partner change) |
Aset holds a bounded hot key (ORACLE_ROLE) that can only call fixed-destination functions — it cannot redirect funds. Full model: 09a-custody.
9. How movement is recorded — the ledger and its projections
Sections 3–7 map where money goes. This section is the record model: what the platform stores about that movement, and which figures are facts versus derivations.
9a. The shape
Three kinds of storage, and the difference between them is the whole model:
| Holds | Written by | |
|---|---|---|
money_events | one row per money-moving contract log | the ingest, append-only — rows are never updated or deleted |
| Projections | every balance | the fold, rebuilt from events |
| Workflow tables | facts about our process | handlers and usecases |
The ledger is the only thing that determines a balance. A projection is a pure function of the events that precede it: applying the same event twice produces the same answer, and any figure can be recomputed on demand rather than reasoned about. scripts/money/replay.ts is that question asked out loud — it re-folds and reports differences, and --write repairs.
Workflow tables carry what no log contains. An operator's approval and who gave it, a rejection reason, a partner's funding deadline, the investor's risk acknowledgement, a failed submission. These describe our process rather than money that moved, and keeping them separate is what lets the ledger stay purely factual.
Derived lists are views over both. money_deposit_list, money_redemption_list and yield_claim_list join the ledger to the workflow row and present the shape the screens read; status is derived from which events exist, so a redemption is COMPLETED because the ledger holds its completion.
9b. Two write paths into the ledger
Events reach money_events two ways, and they are deliberately redundant: the same append, under two different failure profiles.
Fastpath — lib/money/ingest/fastpath.ts. A handler that knows a transaction hash fetches that one receipt, decodes its logs and appends them immediately, so the investor does not wait for a sweep to see their own deposit. It is non-fatal by design: if it fails, the money is still on-chain and the sweep will pick it up, and failing the request would tell the investor their transaction did not happen when it did. It returns found (mined yet?) and reverted (mined and failed) so the caller can record an attempt that produced no events.
Sweep — lib/shared/indexer/engine.ts. Per chain: read the cursor, poll to head − confirmations in chunks, apply each log in order, advance the cursor per chunk so partial progress survives a crash. The eth_getLogs window is adaptive — it shrinks on a provider range-limit error and is tracked per chain — and a per-run block budget stops a cold chain from monopolising an invocation.
Neither path needs to know what the other did. UNIQUE (chain_id, tx_hash, log_index) decides what is new, and only genuinely new rows trigger notifications and workflow writes, so re-reading a block is free of side effects.
Why the cursor seeds at the head
On a chain with no cursor the sweep seeds at the current safe head rather than rescanning from genesis, so a new chain is indexed forward from the moment it is added and nothing before that is read. Recovering a stretch of earlier blocks is a deliberate, separate act — re-point the cursor and let the sweep run — not something the first run does by itself.
9c. What the indexer watches
eth_getLogs filters on a fixed list, so an event absent from it is never fetched, never decoded and never reaches the ledger — silently. The list is therefore split by what each event is for, and a test asserts that every ledger kind appears in it.
| Events | Effect | |
|---|---|---|
| Money | Deposited · ReleasedToPartner · YieldDeposited · YieldDistributed · FeesWithdrawn · YieldClaimed · Reinvested · RedemptionRequested · RedemptionFunded · RedemptionCompleted · EpochSettled · RedemptionClaimed · RedemptionFallbackClaimed · NavUpdated · NavUpdateApplied · LP Transfer | appended to money_events, then folded |
| Lifecycle & governance | EmergencyFrozen · EmergencyUnfrozen · ImpairmentExecuted · WindDownExecuted · LifecycleStatusChanged · FundingRestrictedSet | mirrored to pools — state, not money |
| Schedule | EpochScheduleSet · EpochFundingDateSet · EpochSettleAfterSet · EpochFundingNeeded | mirrored to redemption_epochs / pools |
| Terminal outcomes with no amount | RedemptionCancelled · RedemptionRejected · RedemptionPendingReserve | recorded in money_redemption_workflow |
The last row is the one that explains the split. Those events name no amount, and the LP each returns is already in the ledger as the Transfer its safeTransfer emits — a ledger row built from one could only assert a number it does not have, or count the same movement twice. What they carry is why, which is a workflow fact.
The lifecycle mirror is load-bearing rather than convenience: executeWindDown is permissionless and a multisig can call executeImpairment directly, so the backend handler is not the only writer. Without these the chain could sit in WIND_DOWN while the mirror still advertised deposits.
9d. What runs after an append
ingestLogs appends, then — for genuinely new rows only — runs the usecases and rebuilds the projections of the pools it touched.
- Usecases (
lib/money/usecases/) create the workflow row and send notifications, keyed on the ledger row id. A notice therefore cannot exist without the chain fact behind it, and re-reading a block cannot send it twice. They also create rows for events no handler of ours saw — a redemption opened by a direct contract call still gets a workflow record, empty of decisions because there were none. rebuildPoolProjectionsre-folds that pool from its events and writesmoney_pool_state,money_positionsand the mirrored columns. It takes the pool's stablecoin decimals and whether it is an epoch pool, because neither can be read off a log and both change the arithmetic.
9e. Invariants
| Rule | Why it holds |
|---|---|
UNIQUE (chain_id, tx_hash, log_index) is the only idempotency key | Every writer agrees on one key. A second convention would be a second answer to "have I seen this?" |
Replay order is (chain_id, block_number, log_index) — never id | id is insertion order. The fastpath inserts a specific transaction ahead of the sweep that later backfills around it, so the two orders diverge routinely |
| Every row records the axis of its amount: raw / normalized(18) / LP(18) / NAV(1e6) | Two amounts of the same kind must share a scale; a price and a token count cannot. The log does not carry the stablecoin's decimals, so the axis is recorded at decode time and converted by the projection |
| An event must carry every number a projection needs | A fold cannot reconstruct what the log omits. EpochSettled reports the gross it reserved and the LP it settled; RedemptionClaimed and RedemptionCompleted report the penalty leg alongside the payout |
| Nothing but the fold writes a projected column | Two writers of one figure drift silently, because both produce plausible numbers |
9f. What is derived, and what is not
| Figure | Source |
|---|---|
pools.tvl · reserve_balance · lp_total_supply | folded from the ledger |
portfolio_positions — tokens, principal, entry price, lock-up anchor, provenance | folded from the ledger |
accrued_yield · claimable_yield | folded — the contract's accumulator, reproduced term for term (§9g) |
pools.nav_per_token | not folded. NAV is oracle-initiated: the backend computes it and calls updateNAV, and the DB write follows the confirmed on-chain outcome. The indexer compares and logs drift rather than writing |
money_redemption_workflow.epoch_id | read from epochRequests() when the request lands. RedemptionRequested does not carry it, and acceptingEpochAt returns a different cycle either side of the cutoff, so a timestamp cannot recover it |
Approvals, rejection reasons, funding deadlines, risk_ack, failures | workflow tables — no log contains them |
A pool the ledger has not priced has no price
money_pool_state.nav_per_token is nullable, and null is not par. NAV is oracle-set, so a pool written down before its first ledger event has a price nothing in the ledger records. Reading null as $1.00 would price its holders above what the pool is worth.
pools.nav_per_token is the pricing source of truth.
9g. Yield accrues by accumulator
⚠️ Rewritten for v3-131 (3), shipped 2026-08-20. The per-share accumulator (accumulatedYieldPerShare + yieldDebt) is gone. It had no memory of time, so "no yield from this point" could only be said by moving a balance — which is why escrowing LP stopped its holder earning, and why the escrowed slice then belonged to nobody.
The contract still does not apportion yield per holder. Instead it accrues an index: yieldIndex rises with time at the pool's committed rate, and the pool's liability rises with it on totalLpSupply − poolHeldLp — the RECOGNISED debt only, an open escrow's entitlement being parked per request (previewEscrowAccrual) until its boundary. A holder's span is priced against that index at each touch (FixedYieldEngine.settleHolder, driven by onLpTransfer), which is what makes leaving harmless — a banked claim is an amount, so it survives a zero balance.
Cash is separate from entitlement, and that separation is the point. settleYield applies partner cash to a surviving-fraction ladder (G, H) so a partial payment reduces every outstanding claim by the same fraction rather than paying whoever asks first. Two buckets per holder follow: claimableYield (funded, withdrawable) and creditAccrued (accrued, waiting on the partner).
The projection folds the same scheme:
YieldDistributedemits the per-share it booked, so the fold reads the rise rather than dividing by a supply it would have to guess — the contract's denominator istotalSupply − escrowedLP, because LP queued to exit earns nothing- every LP transfer settles both endpoints before the balance moves, which is what lets a holder who exits mid-period keep the yield of the period they were in
claimable_yieldispendingYield— banked plus unsettled — which is what the claim pays
The per-investor split in yield_distribution_investors multiplies each holder's balance by that same emitted yieldPerShare. There is no denominator in the calculation, so it cannot disagree with the payout. Where the attributed total falls short of the credited total, the difference is LP held by a wallet with no resolved holder; that gap is logged.
9h. What the ledger does not see
A count that looks complete and is not is worse than no count, so the boundaries are explicit.
- Reverted transactions. A revert rolls its logs back with its state, so
eth_getLogsreturns nothing for one. A failure is observable only in the request that told us a transaction was coming, and is recorded there asTX_REVERTEDon the workflow row. The admin count is named "Failed Submissions" for that reason: it counts attempts the platform was told about, and someone who signs from their wallet and closes the tab leaves no trace. - The fiat leg, in both directions (§3e).
- Pre-reset history. The ledger covers what the chain still holds logs for, which is deposits and redemptions. NAV, epoch schedules and yield distributions leave no log to replay, so they live in the workflow tables that own them — copied across when the pre-reset snapshot was retired, not derived. A redemption emitted before
RedemptionRequestedwas widened cannot be decoded by the current ABI at all. - Behaviour pinned by
Clones. A pool runs the implementation it was created with, so the projection can be correct and the chain still pay by older rules. Wind-down NAV — terminal and irreversible — is therefore derived a second time and compared; the mirror records the contract's number either way, because the contract is what pays.
9i. Reconciliation
Three standing checks, each comparing an independent derivation rather than trusting one:
| Check | Compares |
|---|---|
scripts/money/replay.ts | projections against a fresh fold of the ledger |
| the indexer, every run | reserve_balance and lp_total_supply against a direct contract read, logging LEDGER DRIFT |
yield.scheduler.reconcile (hourly) | claimable_yield against on-chain pendingYield |
| wind-down NAV | the contract's figure against the ledger's buckets, at execution |
Rebuilding the ledger from the chain is the sweep plus scripts/money/replay.ts --force: the sweep re-reads the blocks and appends through appendToLedger, and the replay rebuilds every projection from what the ledger then holds. Both run against the current ABI.
scripts/ledger-recovery/ did this for the pools that predated the reset and is gone (0176). It decoded with a snapshot of the pre-redeploy ABIs, and the 2026-08-11 redeploy widened four events — those ABIs now fail on topic0 before reaching a field, and every pool that used them has been removed from the database.
9j. Further reading
v3-122 for the decision record; 11-db-schema for tables, views and columns.
Related
- 04-pool-models — deposit/yield mechanics, reserve split, tranches
- 07-redemption — instant vs. epoch redemption
- 08-smart-contracts · 08a-contract-reference — contract functions & roles
- 09a-custody — money-path immutability & non-custody
- 11-db-schema —
fund_wallet,treasury_wallet,reserve_balance,net_yield_fee_config - 14-decisions — v3-70 (naming unification), v3-69 (fee split), v3-122 (the ledger)