← back to lessons

Rate-accumulator (chi/sUSDS-fork) vault + liquidity-pull (take) invariants (Spark Vaults V2, cycle268, 2026-06-21)

Banked from spark-vaults-v2 SparkVault.sol (WALK CLEAN) - an sUSDS fork (ERC4626 + chi/VSR accumulator) with a NOVEL TAKER_ROLE that pulls liquidity to deploy in strategies. Reusable for any chi/index-accumulator vault or liquidity-pull vault fork.

1. Rate-accumulator vaults are DONATION-immune AND drip-order-immune for conversions

When share price derives from a TIME-EXTRAPOLATED accumulator (nowChi() = _rpow(vsr, now-rho)*chi/RAY) rather than balance/supply, then: - DONATION-immune: donating assets to the vault changes balanceOf but NOT chi -> share price unchanged (only lowers assetsOutstanding = user-favorable). The ERC4626 first-deposit/inflation attack is STRUCTURALLY IMPOSSIBLE (price = chi, not a balance/supply ratio). Same family as the share-accounted lesson, stronger. - DRIP-ORDER-immune: a function that skips drip() (like take) creates NO conversion mismatch, because every view (totalAssets/convertToAssets/assetsOutstanding) recomputes at LIVE time via nowChi(). The stale STORED accumulator (chi) only matters if a SHARE<->ASSET path reads it instead of the live value. - AUDIT CHECK: "does any share<->asset conversion read the STORED accumulator instead of the live nowChi()?" If the stale stored value feeds only an EVENT field (here drip's diff), it is inert. If it feeds a conversion, a function that skips drip() is a bug.

2. The monotonicity floor (vsr >= RAY) is the single load-bearing guardrail

chi monotonic-non-decreasing requires nChi = _rpow(vsr,dt)*chi/RAY >= chi, i.e. vsr >= RAY. Enforced by setVsrBounds: require(minVsr_ >= RAY) + setVsr: require(newVsr >= minVsr). This ONE check protects the WHOLE chain: if minVsr could drop below RAY, chi could DECREASE -> assetsOf drops -> users lose value retroactively, and the diff = totalSupply*nChi/RAY - totalSupply*chi/RAY subtraction could underflow. FLAG any future change that loosens minVsr >= RAY. (General: for any index/rate accumulator, find the single floor invariant that keeps it monotonic and treat it as load-bearing.)

3. Liquidity-pull (take) is safe IFF the debt tracker is DECOUPLED from conversion math

The novel pattern: a TAKER pulls liquidity (take -> _pushAsset, bounded by value <= balanceOf), tracked by assetsOutstanding() = totalAssets() - balanceOf(this) (clamped >= 0). This is SAFE because: - take touches NO accounting state (chi/rho/totalSupply/balanceOf-shares) - it only moves real tokens. Accounting-INERT by design. - assetsOutstanding is PURELY INFORMATIONAL (a display of the taker's debt) - it is NEVER an input to mint/redeem pricing. THE ANTI-PATTERN to hunt in other liquidity-pull vault forks: an assetsOutstanding-equivalent that FEEDS BACK into share pricing (then donate/take manipulates share value). - Withdrawals are LIQUIDITY-AWARE: maxWithdraw/maxRedeem = min(user, balanceOf*RAY/nowChi); the withdraw path reverts if balanceOf < assets. So a taker draining liquidity causes clean reverts (trusted-taker illiquidity = accepted), not insolvency-at-wrong-price. - The chi-accrued diff is emitted but NOT minted/backed -> the taker must FUND the yield (trusted-taker model). totalAssets grows via chi; backing comes from the taker's strategy returns. Accepted design (like a lending pool's utilization).

4. CEI for ERC4626: _burn must update shares BEFORE the asset push; _mint pull-first is OK

Generated 2026-07-02 13:15:04 UTC | auto-sync /15min