Skip to content

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/src and 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.

StreamOn-chain walletFiat bank accountControlled by
Investor capital (the 1 − reserve% released on deposit)fund_walletfund_bank_accountPartner / fund SPV
Fund-manager fee (Pool-management fee)fund_fee_walletfund_fee_bank_accountPartner / fund SPV
Aset fee (platform + SPC + performance)treasury_wallet— (Aset off-ramps separately)Aset
Reservereserve_wallet (reserveWallet)External walletWallet signer, outside pool withdrawal restrictions

Naming rules (v3-70)

  • treasury_wallet is the canonical name for the Aset fee multi-sig. Prose elsewhere that says just "treasury" means this. (DB column is already pools.treasury_wallet.)
  • fund_walletfund_fee_wallet. fund_wallet is the deposit money-path (the partner remainder, amount − reserve); fund_fee_wallet receives only the Pool-management fee and must be a separate address. fund_fee_wallet is shippedpools.fund_fee_wallet (migration 0067) and on-chain fundFeeWallet. 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 default reserve_bps = 1000; reserve_bps is a per-pool config field. Under the reserve-zero launch assumption the remainder is 100%, not 90%, and a pool at reserve_bps = 2000 sends 80%. Writing "90%" turns a configured parameter into a constant.
  • pool_wallet is deprecated — a dead legacy AS_POOL column. Do not use it; the investor-capital wallet is fund_wallet.
  • 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.
  • 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:

  1. GateswhenNotPaused · whenNotFrozen · whenActive (lifecycle must be ACTIVE) · requiresKYC(msg.sender) · accepted stablecoin · minInvestment · per-investor cumulative maxInvestment · capacity measured against totalDeposited. Revert names and exact conditions: 08a-contract-reference.
  2. PullsafeTransferFrom(investor → pool, amount), in raw stablecoin units.
  3. Split, also in raw units — reserveAmountRaw = amount × reserveBps / 10000, partnerAmountRaw = amount − reserveAmountRaw. reserveBalance takes the normalized reserve figure (§3c).
  4. Mint — LP against the full deposit, priced at effectiveNav. The reserve retention is a liquidity allocation and does not dilute the claim (§3b).
  5. Release — the partner remainder transfers to fund_wallet (event ReleasedToPartner). 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 bps 0..10000, required at pool create; shipped into PoolConfig.reserveBps at initialize().
  • Not ordinarily editablePATCH /pools/{id} excludes it. Changing it means POST /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() reads s.config.reserveBps at execution time, so a governed change applies to every later deposit and no earlier one. The deposits table 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

#HopWho holds the moneySynchronous?Recorded where
1investor wallet → PoolPool contract✅ on-chain, atomicDepositeddeposits row (§3f)
2Pool → fund_walletpartner✅ same transactionReleasedToPartner — ✅ in the ledger (money_events, kind RELEASED_TO_PARTNER)
3fund_wallet USDC → OTC counterpartyOTC counterparty❌ manual, off-platform🔴 nowhere
4OTC → fund_bank_account fiatpartner / fund SPV❌ manual, off-platform🔴 nowhere
5fiat deployed into the underlying assetsborrowers❌ partner operationsonly 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_wallet balance is monitored anywhere outside the platform
  • fund_bank_account / fund_fee_bank_account are names only — no such DB column exists on pools or funds. 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). Deposited and ReleasedToPartner are ledger kinds, so reserve_balance is 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 / FundReleasesReleased were kinds too until 0183 removed the hold-back.)
  • Reinvest is recorded on the same path. Reinvested is a ledger kind, so a reinvest whose POST /yield/reinvest call 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 derives is_reinvestment from the event kind.

3g. Failure and in-flight states

  • The on-chain leg is all-or-nothing. One nonReentrant transaction: a revert moves no stablecoin, mints no LP, and leaves reserveBalance and totalDeposited untouched. There is no partial deposit and no pending-deposit state.
  • The only seam is recording, not money. On-chain success plus a failed POST /deposits leaves 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_showcase and custody_mode = 'MIRROR' (both BE-only) — change whether a deposit happens, not where the money goes. Conditions and revert names: 08a. ⚠️ capacity is enforced twice, on-chain against totalDeposited and off-chain against pools.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 deadline

The $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.

Feenet_yield_fee_config keyCharged onDestinationStatus
Aset platformplatform_yield_take_bpsgross yieldtreasury_wallet✅ live
Aset SPC mgmtspc_mgmt_bpsAUM × days/365treasury_wallet✅ live
Aset performanceperf_fee_bps / perf_hurdle_bpsgross, above the hurdletreasury_wallet⏸️ dormant — no admin UI sets the rate, so it computes to 0
FM Pool-mgmtpool_mgmt_bpsAUM × days/365fund_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 = FUND and the partner calls fundRedemption() to top up.
  • fundRedemption is role-gated to YIELD_DEPOSITOR_ROLE (held by fund_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

PathMutability
Redemption payout (request.investor)🔒 immutable
Yield claim (msg.sender, holdings-derived)🔒 immutable
Reserve pathreserveWallet 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:

HoldsWritten by
money_eventsone row per money-moving contract logthe ingest, append-only — rows are never updated or deleted
Projectionsevery balancethe fold, rebuilt from events
Workflow tablesfacts about our processhandlers 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.

EventsEffect
MoneyDeposited · ReleasedToPartner · YieldDeposited · YieldDistributed · FeesWithdrawn · YieldClaimed · Reinvested · RedemptionRequested · RedemptionFunded · RedemptionCompleted · EpochSettled · RedemptionClaimed · RedemptionFallbackClaimed · NavUpdated · NavUpdateApplied · LP Transferappended to money_events, then folded
Lifecycle & governanceEmergencyFrozen · EmergencyUnfrozen · ImpairmentExecuted · WindDownExecuted · LifecycleStatusChanged · FundingRestrictedSetmirrored to pools — state, not money
ScheduleEpochScheduleSet · EpochFundingDateSet · EpochSettleAfterSet · EpochFundingNeededmirrored to redemption_epochs / pools
Terminal outcomes with no amountRedemptionCancelled · RedemptionRejected · RedemptionPendingReserverecorded 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.
  • rebuildPoolProjections re-folds that pool from its events and writes money_pool_state, money_positions and 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

RuleWhy it holds
UNIQUE (chain_id, tx_hash, log_index) is the only idempotency keyEvery 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 idid 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 needsA 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 columnTwo writers of one figure drift silently, because both produce plausible numbers

9f. What is derived, and what is not

FigureSource
pools.tvl · reserve_balance · lp_total_supplyfolded from the ledger
portfolio_positions — tokens, principal, entry price, lock-up anchor, provenancefolded from the ledger
accrued_yield · claimable_yieldfolded — the contract's accumulator, reproduced term for term (§9g)
pools.nav_per_tokennot 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_idread 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, failuresworkflow 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:

  • YieldDistributed emits 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 is totalSupply − 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_yield is pendingYield — 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_getLogs returns nothing for one. A failure is observable only in the request that told us a transaction was coming, and is recorded there as TX_REVERTED on 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 RedemptionRequested was 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:

CheckCompares
scripts/money/replay.tsprojections against a fresh fold of the ledger
the indexer, every runreserve_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 NAVthe 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.