Contract Code Reference
✅ Generated from source
This page is a code-level reference of the deployed Solidity contracts under apps/contract/src/ (verified 2026-07-30). It documents the actual functions, roles, and on-chain safety mechanisms as written in code. For the conceptual / decision narrative (why the architecture looks like this), see Smart Contract Architecture.
The on-chain layer is 4 contracts + 7 implementation libraries. The money-path (PlatformPool, PlatformLPToken) is immutable Clones; only PlatformKYCSoulbound is upgradeable (UUPS + 7-day timelock). This page covers, for every contract: (1) an architecture visualization, (2) the full function list with descriptions, and (3) the security mechanisms and features.
| Contract | File | LoC | Role |
|---|---|---|---|
| PlatformPool | src/PlatformPool.sol | 1,539 | Core pool — deposits, LP mint/burn, reserve, yield, redemption, lifecycle (most logic delegated to libraries below) |
| PlatformLPToken | src/PlatformLPToken.sol | 207 | ERC-20 LP share token (one clone per pool) |
| PlatformKYCSoulbound | src/PlatformKYCSoulbound.sol | 620 | Soulbound ERC-721 KYC/KYB attestation (global, UUPS-upgradeable) |
| PlatformPoolFactory | src/PlatformPoolFactory.sol | 152 | Clone factory + on-chain registry |
| RedemptionLib | src/libraries/RedemptionLib.sol | 1,226 | delegatecall — all redemption logic (instant + epoch) |
| GovernanceLib | src/libraries/GovernanceLib.sol | 585 | delegatecall — timelocked config changes (propose/execute/cancel) + direct admin setters |
| YieldLib | src/libraries/YieldLib.sol | 317 | delegatecall — yield deposit/distribute/claim/reinvest + fee withdrawal (onLpTransfer stays inline — hot path) |
| NavLib | src/libraries/NavLib.sol | 170 | delegatecall — NAV update (immediate + timelocked) + apply/cancel pending |
| PoolConfigLib | src/libraries/PoolConfigLib.sol | 87 | delegatecall — init-time config validation (pure) |
| StablecoinAdminLib | src/libraries/StablecoinAdminLib.sol | 118 | delegatecall — accepted-stablecoin add/remove |
| PoolCommonLib | src/libraries/PoolCommonLib.sol | 153 | internal (inlined) — shared gates + decimal math |
| PlatformPoolStorage | src/PlatformPoolStorage.sol | 215 | A struct Layout type (not a deployed contract) — defines the storage layout each pool holds in its own Layout s |
| PlatformPoolInterfaces | src/PlatformPoolInterfaces.sol | 26 | Minimal interfaces (breaks import cycle) |
1. Architecture
Contract topology
EIP-170 library delegation (per-pool storage, shared library code)
PlatformPool exceeded the 24,576 B EIP-170 limit, so its logic is split into deploy-time-linked libraries. The libraries are deployed once and delegatecall'd by every pool. What is shared is the library code, never storage: each pool clone keeps its own independent storage, and because delegatecall runs in the caller's storage context, a library always reads/writes the storage of whichever pool invoked it.
PlatformPoolStorage.sol defines a struct Layout (a type, not a deployed contract). Each pool declares a single Layout s in its own storage; the libraries receive Layout storage s by reference. Sharing one struct type guarantees the slot layout can never drift between a pool and a library — it does not put pools on a common storage.
Pools have isolated storage. Sharing library code does not share state such as
redemptionCommitted.
Why this is invisible off-chain
Errors/events declared in the libraries revert/emit under the pool address with identical selector/topic0, so the ABI and off-chain decoding are unchanged. The split rationale and the bytecode-size result are in Smart Contract Architecture → Implementation Libraries (EIP-170).
Role assignment flow (who holds each role and how it's granted at deploy) is in Smart Contract Architecture → Role Assignment Flow. This page focuses on the code-level access-control table (see §3.1).
2. Function Reference
2.1 PlatformPool
The core contract. Thin role/nonReentrant wrappers delegate redemption + stablecoin-admin logic into libraries. Functions are grouped by caller.
Investor functions (KYC-gated, callable by anyone with a valid SBT)
| Function | Gate | Description |
|---|---|---|
deposit(address stablecoin, uint256 amount) | nonReentrant · whenNotPaused · whenNotFrozen · whenActive · duringSubscription · requiresKYC | The external-reserve implementation sends the reserveBps share to reserveWallet and the remainder to fundWallet. It does not accumulate an in-pool reserve balance. LP is minted to the investor. |
requestRedemption(uint256 lpTokenAmount, address preferredStablecoin) | nonReentrant · requiresRedeemableKyc | Lock LP and open a redemption. Instant pools snapshot NAV + compute penalty now; epoch pools enroll into currentEpochId (QUEUED, deferred to settlement). → RedemptionLib. |
claimRedemption(uint256 requestId) | nonReentrant | Epoch pools — pull the settled portion (O(1) via G/H indices); burns filled LP, pays floor-USDC to the original requester. Remainder rolls over. |
claimRedemptionFallback(uint256 requestId) | — | 🔴 Removed. Its available was reserveBalance + redemptionFundedAmount; with the reserve routed to reserve_wallet the first term is structurally zero, so it could not have freed anyone it was written for. |
cancelRedemption(uint256 requestId) | nonReentrant | Caller cancels a pending request, unlocking LP (epoch: also decrements epochTotalDemandLp, and the epoch branch carries the same request-window gate). ⚠️ On a pool created before the 2026-08-04 deploy it returns the full ep.lpRemaining; from that deploy on, the ClaimBeforeCancel gate applies — see below. |
claimYield(address stablecoin, uint256 amount) | nonReentrant · requiresRedeemableKyc | Pay out amount of accumulated yield in the chosen stablecoin; amount == 0 claims all, and an over-request is clamped to the balance (never reverts). Dust left accrued. → YieldLib. |
reinvest(address stablecoin, uint256 yieldAmount) | nonReentrant · whenNotPaused · whenNotFrozen · whenActive · requiresKYC | Convert accrued yield into new LP (reserve split applied; non-reserve share released to fund_wallet in stablecoin). Partial amounts allowed — only an over-request reverts (InsufficientYield); below minReinvestAmount reverts BelowMinReinvest. ⚠️ Pools deployed before this two-argument signature landed expose the legacy one-argument reinvest(uint256), so a caller must not assume the currency parameter is present. → YieldLib. |
pendingYield(address investor) view | — | Everything the investor has earned and not taken out, funded or not: claimableYield + unfundedYield, each simulated to NOW rather than read out of storage. ⚠️ Does NOT include yield attached to an open redemption request — that is previewEscrowAccrual(requestId), credited per request when the escrow ends (v3-131 (3)). A UI showing this alone under-reports for anyone mid-redemption. |
🟢 Two epoch changes shipped 2026-08-04 — but only a pool created after it has them (v3-105)
poolImplementation is immutable on the factory, so a new implementation means a new factory and existing clones are never upgraded. Pools created before the deploy still run the old code, where settledUnclaimedLp() reverts and both behaviours below are absent.
The deploy. New factory 0xE1E2E974…DA90 + new pool implementation 0x27D9948F…f968 (21,186 B), from commit 0abe168. Verified on that implementation by selector: previewEpochClaim (9ccd0389), setEpochSchedule / setEpochFundingDate / setEpochSettleAfter, and settledUnclaimedLp (915d0aa1) — the last pins the deploy to 0abe168, which also carries R2·R3 and R10. (A further round on 2026-08-06 supersedes this factory; see apps/contract/sepolia.md.)
1. cancelRedemption gains a claim-first gate — RedemptionLib.sol:626. The epoch branch computes _epochFillMath and reverts ClaimBeforeCancel(requestId) while filledLp > 0. On an older pool it still returns the whole escrowed position while the filled slice's cash sits in redemptionCommitted with no claimant. Investor sequence: claimRedemption (no window gate) → cancelRedemption (in-window).
2. New view previewEpochClaim(requestId) → (filledLp, remainingLp, payoutUSD) — PlatformPool.sol:317 → RedemptionLib.sol:703, a view wrapper over _epochFillMath. Needed because the filled / unfilled split is not derivable from public state: epochRequests returns only (epochId, principalLp, lpRemaining, yieldAccSnapshot), and epochH / epochCarryGen / generationCloseH have no getters (only epochG does). So the epoch redemption UI is blocked on having a pool from a current factory, not on the deploy and not on writing the view.
📡 ABI gap — EpochFundingDateSet is emitted but not in PlatformPool.abi.json
EpochScheduleSet, EpochFundingDateSet and EpochSettleAfterSet are declared in GovernanceLib and never re-declared on PlatformPool (the codebase's convention for library events — compare EpochSettled, which is re-declared). External library calls are DELEGATECALL, so the logs do appear under the pool address; they are simply undecodable from the shipped ABI. Adding the event fragments to the off-chain decoder recovers them retroactively for already-deployed pools with no contract change — and EpochFundingDateSet matters specifically because it is the only exact "an operator confirmed this cycle's funding date" signal on-chain (v3-105; settlement's fail-open materialization writes the same storage slot silently).
Partner functions (YIELD_DEPOSITOR_ROLE = fund_wallet)
| Function | Gate | Description |
|---|---|---|
depositYield(address stablecoin, uint256 grossAmount) | nonReentrant · whenNotFrozen | Deposit gross yield; held until the service key distributes net + pulls fees. |
fundRedemption(uint256 requestId, address stablecoin, uint256 amount) | nonReentrant | Top up a PENDING_RESERVE instant request (or epoch top-up); auto-executes payout when covered. → RedemptionLib. |
Service functions (ORACLE_ROLE = Aset automated key — bounded, non-custodial)
None of these can move funds to an arbitrary destination.
| Function | Gate | Description |
|---|---|---|
updateNAV(uint256 newNav, uint256 reserveConsumed) | onlyRole(ORACLE_ROLE) | Set NAV. Increases apply instantly; decreases queue 24h. Passes _checkNavBound (circuit breaker + deviation cap, v3-32). 🔴 reserveConsumed must be 0 (R8): the reserve is not a loss-absorption layer — it is investor money already inside the claim NAV prices, so debiting it against a loss double-counts. v3-16 made the debit atomic with the NAV write and the ABI is unchanged; what R8 changes is the value: the backend passes 0 on every call (NO_RESERVE_CONSUMED in both the propose and approve/override paths, shipped). A reserveConsumed != 0 → revert guard is still recommended for the next deployment so the argument cannot quietly resurrect the old model — until then this is a caller convention, not an on-chain constraint. |
approveRedemption(uint256 requestId) | nonReentrant · onlyRole(ORACLE_ROLE) | Instant pools — approve + auto-execute payout (or mark PENDING_RESERVE). Pays the original requester only. → RedemptionLib. |
rejectRedemption(uint256 requestId, string reason) | nonReentrant · onlyRole(ORACLE_ROLE) | Reject, returning locked LP and reverting yield-based penalty. → RedemptionLib. |
executeEpoch() | nonReentrant | Epoch pools — settle the current epoch (O(1), no loop): carry-first fill ratios, update the G/H ladders, roll the unfilled remainder, advance the epoch. Money does move (v3-100, shipped): _reserveFilledGross debits the filled gross epochFundTopUp[id] → reserveBalance into the single redemptionCommitted scalar — not the per-epoch pot v3-91 originally specified, which was withdrawn for breaking O(1) claims. Gated on settlementAllowedAt (the cycle's funding date, delayable only up to + recallLeadDays), not on cutoff. ORACLE_ROLE or permissionless after that time. → RedemptionLib. |
holdRequest(uint256) / releaseRequest(uint256) | onlyRole(ORACLE_ROLE) | Anomaly hold — exclude/re-include a request from automatic settlement (compliance / large redemption). |
setEpochFundingDate(uint256 epochId, uint256 fundingDate) | onlyRole(ORACLE_ROLE) | Confirm/adjust one cycle's funding date — the number every boundary derives from. Rejects a date that does not sit between its neighbours (InvalidSchedule, equality included — two cycles on one day share a request window; previous neighbour derived, following neighbour read from storage). Free only until that cycle's request window opens, after which it reverts WindowAlreadyOpen(opensAt) (an adjustment then would rewrite a deadline investors are already looking at, or re-open a closed window and change the total already sent to the publisher). Emits EpochFundingDateSet — the only exact confirmation signal on-chain. ✅ Caller exists — pools.post.epoch-schedule.ts (v3-107 closed this). ⚠️ End-to-end still waits on the contract deploy: this function is not on a deployed implementation, so a call against an existing pool reverts. |
setEpochSettleAfter(uint256 epochId, uint256 settleAfter) | onlyRole(ORACLE_ROLE) | The "publisher is running late" knob: delay one cycle's settlement. Delay-only (settleAfter < fundingDate reverts InvalidSchedule) and hard-capped at fundingDate + recallLeadDays (SettleAfterTooLate(cap)) — an uncapped delay would strand demand that can no longer be cancelled (C10) and earns nothing while it waits (C3). Emits EpochSettleAfterSet. ✅ Caller exists — pools.post.epoch-schedule.ts (v3-107 closed this). ⚠️ End-to-end still waits on the contract deploy, as above. |
settleYield(address stablecoin, uint256 treasuryAmount, uint256 poolMgmtAmount) | nonReentrant · onlyRole(ORACLE_ROLE) | Settle one period in one tx: apply unclaimedYield − fees to the accrued liability and pay both fee legs to the governance-locked treasury / per-pool fee wallet (raw; recipients are not parameters). Cannot touch principal/reserve. Replaces distributeYield + withdrawFees (v3-102). ⚠️ netAmount was removed in v3-131 (3): what holders are owed is decided by time, and the argument's only guard was an upper bound that a mis-scaled value always passed. Cash the liability cannot absorb stays in unclaimedYield as prepayment. |
Pauser functions (PAUSER_ROLE = fast Safe — halt-only, no fund movement)
| Function | Gate | Description |
|---|---|---|
pause() / unpause() | onlyRole(PAUSER_ROLE) | Soft pause — blocks deposits/yield-deposit/redemption-requests; existing redemptions continue. Instant. |
emergencyFreeze() / unfreeze() | onlyRole(PAUSER_ROLE) | Hard freeze — records freezeStartedAt. Asymmetric: capital-IN blocked entire freeze; value-OUT auto-unblocks after 72h; whole freeze auto-expires at 7d. |
tripCircuitBreaker() | onlyRole(PAUSER_ROLE) | Trip breaker → blocks updateNAV + executeEpoch (v3-32). |
Admin / governance functions (DEFAULT_ADMIN_ROLE = governance multisig)
Create-time config (immutable once LP totalSupply > 0 — reverts ConfigImmutableAfterDeposit / EpochImmutableAfterDeposit / etc.): setLockupDays, addStablecoin, setMaturityDate, setSubscriptionPeriod, setEpochDurationDays (≤ MAX_EPOCH_DURATION_DAYS = 90), setEpochSchedule, setEnforceJurisdiction, setJurisdictionAllowed, setRedemptionGating, setNavDeviationCap, setNavStaleness. removeStablecoin and setHardCap (raise-only) remain callable post-deposit. resetCircuitBreaker is an operational toggle. (setFundingRestricted, the v3-26 hold-back lever, sat beside it until 0183 removed it.)
🟢 setEpochSchedule(fundingAnchor, recallLeadDays, requestWindowDays) — wired; new pools are anchored, legacy pools never can be
This is the setter that installs the Model B schedule. It had no caller anywhere — not lib/shared/contract/create-pool.ts, not any lambda, not the admin app — so every pool deployed with fundingAnchor == 0, which PoolCommonLib.hasAnchoredSchedule reads as "no anchored schedule", skipping the window gate and running the pre-redesign lazy clock. Wired in 6c0a08f (v3-107 · v3-116): the three schedule terms are required at create, sent through create-pool.ts, asserted back off the chain after deploy, and the contract reverts ScheduleNotConfigured rather than falling through to Model A. Deployed 2026-08-04 (commit 0abe168, new factory) — new pools only; pools created before that deploy are old-implementation clones and stay on the legacy lazy path, so v3-100's "Model B is a new-pool property, not a platform-wide one" is now literally the state of the system.
Two things make it unforgiving:
- Ordering is load-bearing. The setter validates
recallLeadDays + requestWindowDays < epochDurationDays, so it must run aftersetEpochDurationDaysin the deploy sequence or it revertsInvalidSchedule. - Create-only. It reverts
ConfigImmutableAfterDepositonce LP exists, so a pool that takes its first deposit unanchored can never be anchored — same permanence that keeps the 7 legacy pools on the legacy path.
A pool whose DB carries the schedule terms while its chain reads fundingAnchor == 0 is a deploy defect, not a configuration (v3-107).
Lifecycle & emergency:
| Function | Timelock | Description |
|---|---|---|
setLifecycleStatus(LifecycleStatus) | — | DRAFT/UPCOMING/ACTIVE/IMPAIRED/MATURED/CLOSED transitions. |
proposeImpairment / executeImpairment / cancelImpairment | 7d | Partner-distress → IMPAIRED (deposits halt, redemptions continue). |
proposeWindDown / cancelWindDown | 30d | Wind-down proposal/cancel. |
executeWindDown() (permissionless) | after 30d | The current executeWindDown() does not recalculate navPerToken. It preserves oracle NAV; actual payout requires funding and the redemption process. |
Timelocked config changes
Five triples (propose* / execute* / cancel*, GOVERNANCE_TIMELOCK = 7d), all in GovernanceLib:
| Triple | Note |
|---|---|
FundWalletChange | also moves YIELD_DEPOSITOR_ROLE |
ReserveBpsChange | on-chain names are propose/execute/cancelReserveBpsChange |
JurisdictionChange | per-country allow/deny |
EnforceJurisdictionChange | flips whether the whitelist is enforced at all |
RedemptionGatingChange | per-epoch fill cap |
⚠️ Three former entries are not timelocked triples and must not be listed as such. KycLevelChange was removed with the pool-level KYC gate (migration 0063 — the enum value is gone from pool_governance_changes too). TreasuryChange is an instant admin setter, setTreasuryWallet (v3-69: fees auto-distribute with no hold, so a timelock would only strand them at a stale address). FreezeExtend was removed — see the freeze-extend row in 08 for why (its 7d timelock equalled the 7d freeze lifetime). Only pendingFreezeExtend* storage survives, cleared on unfreeze.
NAV pending: applyPendingNav() (permissionless after 24h) · cancelPendingNav() (admin, during window).
LP callback & views
onLpTransfer(from, to, value) — called by the LP token on every balance change to sync tracked balances + yield. Plus ~40 view getters (navPerToken, reserveBalance, totalDeposited, lifecycleStatus, isEmergencyFrozen, getPosition, getInvestorRedemptionRequests, getAcceptedStablecoins, epoch state currentEpochId/epochG/epochSettleNav/…, circuit-breaker circuitBreakerTripped/lastNavUpdateAt, etc.).
2.2 PlatformLPToken (ERC-20)
One clone per pool. 18 decimals. mint/burn are gated by MINTER_ROLE (held by the pool only); DEFAULT_ADMIN_ROLE is the role-admin of MINTER_ROLE.
| Function | Gate | Description |
|---|---|---|
initialize(string name_, string symbol_, address admin) | initializer | Clone-only init; grants DEFAULT_ADMIN_ROLE + MINTER_ROLE to admin (the factory, transiently). |
mint(address to, uint256 amount) | onlyRole(MINTER_ROLE) | Mint LP (not pause-gated — pool governs deposit pause). |
burn(address from, uint256 amount) | onlyRole(MINTER_ROLE) | Burn LP. |
setPool(address _pool) | onlyRole(DEFAULT_ADMIN_ROLE) | Point at the pool + grant it MINTER_ROLE (revokes old pool's). |
pause() / unpause() | onlyRole(DEFAULT_ADMIN_ROLE) | Freeze secondary (investor↔investor) transfers only. Secondary transfers are otherwise permissionless — no allowlist (v3-58). |
decimals / name / symbol view | — | Clone-safe overrides (name/symbol stored per instance, not in the ERC-20 constructor). |
_update transfer hook enforces three things: (1) block direct transfer to the pool by anyone other than the pool (DirectPoolTransferDisallowed — prevents stranding LP outside the redemption flow); (2) on secondary transfers (both ends non-zero, neither is the pool) require not-paused — otherwise permissionless, no allowlist (v3-58); (3) call pool.onLpTransfer(from, to, value) on every mint/burn/transfer so yield follows the actual holder.
2.3 PlatformKYCSoulbound (Soulbound ERC-721, UUPS)
Single global contract; pools reference it by proxy address. The only upgradeable contract.
| Group | Function | Gate | Description |
|---|---|---|---|
| Lifecycle | mint(address to, KYCLevel level, uint256 expiresAt, string countryCode, bool isUSPerson) | onlyRole(DEFAULT_ADMIN_ROLE) · whenNotPaused | Mint one SBT per address (future expiry enforced). |
revoke(uint256 tokenId, string reason) | onlyRole(DEFAULT_ADMIN_ROLE) | Mark revoked (sanctions/fraud), independent of expiry. | |
renew(uint256 tokenId, uint256 newExpiresAt) | onlyRole(DEFAULT_ADMIN_ROLE) | Extend expiry; clears revocation. | |
burn(uint256 tokenId) | onlyRole(DEFAULT_ADMIN_ROLE) | Destroy token + clear mappings. | |
Verification (view) | kycStateOf → NONE/VALID/EXPIRED/REVOKED | — | Resolved state (revocation > expiry). |
isValidKYC / canRedeem / isRevokedKYC | — | Entry gate (VALID) · exit gate (VALID+EXPIRED, not REVOKED) · revocation check. | |
isValidKYCNonUS / isInstitution | — | Non-US attestation · institutional (KYB) level. | |
jurisdictionHashOf → bytes32 | — | keccak256 of ISO country code (0 if invalid) — pools compare against their whitelist. | |
getKYCData / tokenIdOf / totalSupply | — | Full tuple · token id (0 if none) · minted count. | |
| Upgrade (UUPS) | proposeUpgrade(address newImpl) | onlyRole(DEFAULT_ADMIN_ROLE) | Record the pending implementation. UPGRADE_TIMELOCK is 0 (was 7d, v3-71), so executeUpgrade may follow in the same block. |
executeUpgrade() | onlyRole(DEFAULT_ADMIN_ROLE) | Execute after 7d; sets the transient authorize flag. | |
cancelUpgrade() | onlyRole(DEFAULT_ADMIN_ROLE) | Cancel pending proposal. | |
| Admin | pause / unpause / setBaseURI | onlyRole(DEFAULT_ADMIN_ROLE) | Pause mint · set metadata base URI. |
Per-token KYCData: level (NONE/INDIVIDUAL/INSTITUTION), issuedAt, expiresAt, countryCode (ISO-3166-1 alpha-3, e.g. "KOR" — v3-60), isUSPerson, isRevoked.
2.4 PlatformPoolFactory
| Function | Gate | Description |
|---|---|---|
createPool(CreatePoolParams params) | onlyRole(DEFAULT_ADMIN_ROLE) | Clone LP + Pool, wire roles, register. Returns poolId. |
getPoolContracts(uint256 poolId) view | — | Return {pool, lpToken} for a pool id. |
createPool sequence (atomic, single tx): clone LP (factory = transient admin) → clone Pool with caller as admin (no handoff/front-run window) → lp.setPool(pool) (grants pool MINTER_ROLE) → grant LP DEFAULT_ADMIN_ROLE to caller → factory renounces its LP MINTER_ROLE + DEFAULT_ADMIN_ROLE. Net result: pool is the only minter, caller is admin, factory retains nothing. State: poolCounter, poolRegistry, isRegisteredPool / isRegisteredLpToken (off-chain consumers should trust only registered addresses), immutable kycContract / poolImplementation / lpImplementation.
2.5 Implementation Libraries
| Library | Model | Key contents |
|---|---|---|
| RedemptionLib | external (delegatecall) | Instant: requestRedemption, approveRedemption, fundRedemption, claimRedemptionFallback, rejectRedemption, cancelRedemption. Epoch: executeEpoch (settle), claimRedemption (pull). Internal: _finalizeInstantRequest (NAV snapshot + 4 penalty modes), _epochFillMath / _epochFillRatio (O(1) G/H math), _settleEpochYieldAndPenalty, _payEpochClaim (floor-USDC), _drawDown (reserve → held bucket). |
| GovernanceLib | external (delegatecall) | Timelocked change triples (propose/execute/cancel) for fund-wallet, reserve bps, jurisdiction, enforce-jurisdiction, redemption-gating (five — not KYC level, treasury or freeze-extend; see the triples table); direct admin setters (lockup, subscription, maturity, hard cap, epoch duration, funding-restricted, treasury + fund-fee wallet, NAV deviation cap/staleness, circuit breaker). executeFundWalletChange returns (old,new) so the pool wrapper does the _grantRole/_revokeRole (OZ private _roles is unreachable from a delegatecall lib). Internal: _requireExecutableChange / _requirePendingChange. |
| YieldLib | external (delegatecall) | depositYield, settleYield (funds the accrued liability + both fee legs, atomic), claimYield (partial/clamped, funded-only), reinvest (funded yield → LP), pendingYield (view). |
| FixedYieldEngine | internal (inlined) | The FIXED driver's storage side: accrue (roll the index and the liability), fund (apply partner cash to the surviving-fraction ladder), settleHolder, preview. Internal on purpose — onLpTransfer fires on every LP mint/burn/transfer and a per-move delegatecall would tax the hot path, while YieldLib needs the same two functions on its claim/reinvest entries. ✅ That is why the hand-copied _settle twin the pool used to carry is gone: one inlined library is the same gas and one source. |
| YieldAccrualPolicy | internal, pure | The ladder arithmetic with no storage: accrualDelta, foldAccrual, survive, settle. Fuzzed on its own (test/YieldAccrualPolicy.t.sol) because a money-math error here reverts nothing and misprices quietly. |
| NavLib | external (delegatecall) | updateNAV (immediate or deviation-capped/timelocked), applyPendingNav, cancelPendingNav. Internal: _checkNavBound ($1 clamp + deviation-cap bps + reserve-consume rules). |
| PoolConfigLib | external · pure | validate(...) — init-time checks: non-zero addresses, fundWallet != treasuryWallet (v3-26, non-custodial), reserveBps ≤ 10000, penaltyRateBps ≤ 10000, min ≤ max ≤ capacity, liquidity-window array lengths + ordering. |
| StablecoinAdminLib | external | add(...) (validate decimals 6–18, cache, index) · remove(...) (reject removing the last coin while LP exists, or a coin the pool still holds — L-6). Swap-and-pop. |
| PoolCommonLib | internal (inlined) | frozenActive / checkExitNotBlocked (72h exit gate, v3-28) · checkRedeemableKyc · isLockupActive / isBeforeMaturity · normalizeAmount / denormalizeAmount (decimal scaling to 1e18). |
Epoch settlement (G/H global index, O(1)): G[id] = G[id-1] × (1 − fillRatio[id]) (residual unfilled fraction); H[id] = H[id-1] + G[id-1] × fillRatio[id] × settleNav / (1e18 × NAV_PRECISION) (cumulative USD/principal). A claim reads filled = principal × (G[v-1] − G[latest]) / G[v-1] and payout = principal × (H[latest] − H[v-1]) / G[v-1] — independent of request order, so claim time is constant regardless of epoch count. Payout is floored to raw stablecoin (dust stays in the held bucket).
3. Security Mechanisms & Features
3.1 Access control (OpenZeppelin AccessControl)
| Role | Holder (mainnet intent) | Powers | Constraint |
|---|---|---|---|
DEFAULT_ADMIN_ROLE | Governance multisig (Safe) | Governance/lifecycle/NAV-bound/config; role-admin of all roles below | Most fund-touching changes are timelocked |
ORACLE_ROLE | Aset service key (Lambda) | NAV, approve/reject redemptions, distribute yield, withdraw fees, epoch settle | Cannot send funds to an arbitrary address — payout destination is the locked requester; fees go to the locked treasury |
PAUSER_ROLE | Fast Safe | pause / freeze / trip breaker | Halt-only; moves no funds |
YIELD_DEPOSITOR_ROLE | Partner fund_wallet | depositYield, fundRedemption | Can only add funds / fill shortfalls |
initialize wires _setRoleAdmin(ORACLE/PAUSER/YIELD_DEPOSITOR, DEFAULT_ADMIN_ROLE) and grants DEFAULT_ADMIN_ROLE + PAUSER_ROLE to _admin, YIELD_DEPOSITOR_ROLE to _fundWallet. ORACLE_ROLE is granted post-deploy. Because DEFAULT_ADMIN_ROLE administers every role (and is its own admin), making it a multisig requires no contract change — assign the role to a Safe and renounce the bootstrap EOA. → see Smart Contract Architecture → Roles.
3.2 KYC modifiers (asymmetric — gate value IN, never trap value OUT)
requiresKYC(entry — deposit/reinvest): valid SBT and (non-US if!allowsUSPersons) and (institution ifrequiresInstitutional) and (jurisdiction whitelisted ifenforceJurisdiction). RevertsKYCRequired/USPersonsNotAllowed/InstitutionalOnly/JurisdictionNotAllowed.requiresRedeemableKyc(exit — redeem/claim): onlycanRedeem(notREVOKED/NONE;EXPIREDpasses). Deliberately skips jurisdiction/institution so an exit is never blocked by a stale region/level rule.- Second exit check at payout time (v3-99): the modifier above only proves eligibility when the request is created.
_executeRedemptionPayout— the single entry point all three instant settlement paths funnel through (approveRedemption, the partner-funding auto-settle infundRedemption,claimRedemptionFallback) — re-checkscanRedeemand revertsRedemptionBlockedByKyc, so a holder revoked while their request waited inPENDING_RESERVEcannot be paid. Epoch pools get the equivalent check inclaimRedemption. Gated at the entry point rather than per caller on purpose: the gap it closes existed because a caller was added without the check.
3.3 Lifecycle & state gates
whenActive (lifecycle == ACTIVE) · whenNotPaused (soft pause, deposit-side) · whenNotFrozen (hard freeze, capital-IN) · duringSubscription (deposit window). Exit paths instead run checkExitNotBlocked — blocked only within the 72h freeze exit window.
3.4 Timelocks (code constants)
These are the code constant names and what each gates. The canonical change → duration → rationale table (the single source of truth other docs cross-reference) is Smart Contract Architecture → Timelock Configurations; the durations below mirror it.
| Constant | Duration | Gates |
|---|---|---|
NAV_TIMELOCK | 24h | NAV decreases (increases instant) |
GOVERNANCE_TIMELOCK | 7 days | fund_wallet, reserve bps, jurisdiction (+enforce), redemption gating (⚠️ deprecated — not included in MVP, 2026-08-27, v3-150; the timelocked triple is still on-chain), impairment. Not treasury (instant setter, v3-69), not KYC level (removed, 0063), not freeze-extend (removed) |
WIND_DOWN_TIMELOCK | 30 days | wind-down execution |
UPGRADE_TIMELOCK (KYC) | 0 ⚠️ | UUPS implementation upgrade. Was 7 days; dropped by v3-71 2026-08-14. |
FREEZE_EXIT_WINDOW | 72h | how long a freeze may block exits |
FREEZE_MAX_DURATION | 7 days | freeze auto-expiry |
FALLBACK_NOTICE_DAYS | 7 days | instant-pool permissionless fallback exit |
MAX_EPOCH_DURATION_DAYS | 90 | upper bound on epoch length |
3.5 Non-custodial guarantees (v3-28 / v3-31 / v3-32)
- Fixed payout destination —
requestRedemptionlocks LP and records the requester;approveRedemption/claimRedemption/ fallback always pay that address (no destination parameter), so no role can redirect a payout. - Operator-absent exit —
claimRedemptionFallback(instant) and permissionlessexecuteEpoch+ claim-on-behalf (epoch) guarantee investors can exit even if Aset's key goes silent. - Time-bound freeze — a redemption-blocking freeze auto-relaxes at 72h and fully expires at 7d, with no on-chain way to prolong it: the
FreezeExtendpath was removed because its 7d timelock equalled the 7d freeze lifetime. Escalate withpauseorimpairmentinstead. - NAV source-side bounds —
_checkNavBoundrevertsupdateNAV/executeEpochwhen the circuit breaker is tripped or|newNav − navPerToken|exceedsnavDeviationCapBps; a NAV-staleness window guards epoch settlement. Limits the blast radius of a compromised hot key.
3.6 Funds-integrity guards
- Reentrancy —
nonReentranton every fund-moving path (deposit, reinvest, depositYield, claimYield, all redemption request/approve/fund/claim/cancel, executeEpoch, settleYield). - Reserve = redemption liquidity —
reserveBpsretained on deposit; spent on redemption payouts and on the wind-down distribution. Not a loss layer (R8). The on-chain bounds from v3-16 remain (reserveConsumed ≤ reserveBalance, and must be 0 for increases), but the backend passes 0 unconditionally, so no write-down debits the reserve. - The reserve split floors
amount × reserveBps / 10000in raw stablecoin units. This share goes to externalreserveWallet; the remainder goes tofundWallet. - The current
executeWindDown()does not recalculatenavPerToken. 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. - LP integrity —
mint/burnareMINTER_ROLE-only (pool);burnis alwaysfrom = address(this)(pool pulls LP viatransferFromfirst); the_updatehook blocks stray direct-to-pool transfers and can pause secondary transfers (otherwise permissionless — v3-58). - Decimal safety — all internal math normalizes to 1e18; stablecoins must report 6–18 decimals; payouts floor to native units, dust retained.
- Clone safety — implementations call
_disableInitializers()in their constructor; clones initialize exactly once (initializer). - Config immutability — published investor terms (lockup, maturity, stablecoins, epoch duration, jurisdiction, gating) lock once
LP totalSupply > 0; further change needs a redeploy or a timelocked governance path.
3.7 Upgradeability boundary
Only PlatformKYCSoulbound is upgradeable (ERC-1967 UUPS). _authorizeUpgrade reverts unless the upgrade went through proposeUpgrade → executeUpgrade for the exact pending implementation, so even an admin cannot do a direct upgradeToAndCall. ⚠️ The wait between the two is now UPGRADE_TIMELOCK = 0 (v3-71): the gate on how an upgrade happens survives, the delay before it takes effect does not. The money-path (PlatformPool, PlatformLPToken) is immutable Clones — no proxy, no upgrade. Soulbound non-transferability is enforced by the _update override plus reverting approve / setApprovalForAll (SoulboundTokenNonTransferable).
See also: Smart Contract Architecture (rationale & decisions) · Custody & Non-Custodial · Redemption · Admin / RBAC.