diff --git a/tests/misc/prototype/README.md b/tests/misc/prototype/README.md new file mode 100644 index 000000000..6914c8009 --- /dev/null +++ b/tests/misc/prototype/README.md @@ -0,0 +1,58 @@ +# Hub & Spoke Prototype + +A lightweight TypeScript prototype of Aave V4's Hub-and-Spoke lending architecture, built for rapid experimentation and invariant testing of the core accounting logic. + +## What it models + +The prototype implements a simplified version of the Hub's central accounting: + +- **Hub**: Tracks global drawn debt (shares + index), supply-side exchange rate (via SharesMath with virtual assets/shares), premium accounting, fee accrual, and deficit (bad debt) tracking. +- **Spokes**: Regional intermediaries that bridge users to the Hub. Each spoke maintains its own view of drawn shares, premium shares, and added shares, mirrored on the Hub side for consistency checks. +- **Users**: Individual positions with drawn shares, premium shares/offset, added shares, and a risk premium that determines their premium debt. +- **Premium system**: Uses `premiumShares` and `premiumOffsetRay` (signed, RAY-precision) to track per-user premium debt. Premium deltas are propagated through `validateApplyPremiumDelta` which enforces the core invariant: `premiumRayAfter + restoredPremiumRay == premiumRayBefore`. +- **Interest accrual**: Simulated via random index multipliers on time advancement (`skip()`), modeling compounding drawn rates. + +## What it does NOT model + +- Oracle pricing, collateral factors, or health factor calculations +- Liquidation engine +- Dynamic risk configuration keys +- Cross-hub routing or cap enforcement +- EVM storage layout or gas considerations + +## Running + +Tests use [bun:test](https://bun.sh/docs/cli/test) (Jest-compatible). + +```bash +# Run all prototype tests +bun test tests/misc/prototype/ + +# Scenario tests only +bun test tests/misc/prototype/scenario.test.ts + +# Invariant fuzz test only +bun test tests/misc/prototype/invariant.test.ts + +# Filter by test name +bun test tests/misc/prototype/ -t "repay deduction" +``` + +## File structure + +| File | Purpose | +|------|---------| +| `core.ts` | Hub, Spoke, User, System classes and invariant checks | +| `utils.ts` | Math primitives (RAY/WAD/BPS), SharesMath, premium calculation, random generators | +| `scenario.test.ts` | Deterministic test scenarios exercising supply/borrow/repay/withdraw flows | +| `invariant.test.ts` | Randomized fuzz test that runs actions and asserts accounting invariants after each step | + +## Invariants checked + +- **Values within bounds**: all unsigned fields non-negative, premium non-negative, liquidity non-negative +- **Hub-Spoke accounting match**: spoke fields on Hub mirror actual spoke fields exactly +- **Sum of drawn debt**: hub drawn debt ~= sum of spoke drawn debts ~= sum of user drawn debts (within precision tolerance) +- **Sum of premium debt**: hub premium shares/offset == sum of spoke == sum of user (exact match) +- **Sum of added shares**: hub added shares ~= sum of spoke ~= sum of user +- **Sum of deficit**: hub deficit == sum of spoke deficit +- **Supply exchange rate non-decreasing**: the supply-side exchange rate never decreases across any action diff --git a/tests/misc/prototype/core.ts b/tests/misc/prototype/core.ts index 00bc7a165..6638d30c7 100644 --- a/tests/misc/prototype/core.ts +++ b/tests/misc/prototype/core.ts @@ -1,124 +1,172 @@ -import { - DEBUG, - MAX_UINT, - Rounding, - assertNonZero, - absDiff, - randomRiskPremium, - randomIndex, - f, - formatBps, - mulDiv, - percentMul, - info, - rayMul, - rayDiv, - RAY, - PRECISION, - maxAbsDiff, - randomAmount, - formatUnits, - assertGeZero, - min, -} from './utils'; +import * as U from './utils'; +const { + DEBUG, MAX_UINT, assertNonZero, absDiff, randomRiskPremium, randomIndex, + f, formatBps, info, rayMulUp, rayMulDown, rayDivUp, rayDivDown, fromRayUp, + toRay, percentMulUp, percentMulDown, calculatePremiumRay, signedSub, + addSigned, toSharesDown, toSharesUp, toAssetsDown, toAssetsUp, + RAY, PRECISION, VIRTUAL_ASSETS, VIRTUAL_SHARES, maxAbsDiff, randomAmount, + formatUnits, min, +} = U; +type PremiumDelta = U.PremiumDelta; let spokeIdCounter = 0n; let userIdCounter = 0n; let currentTime = 1n; -const VIRTUAL_SHARES = 10n ** 6n; - -// type/token transfers to differentiate supplied/debt shares -// notify is unneeded since prototype assumes one asset on hub export class Hub { public spokes: Spoke[] = []; public lastUpdateTimestamp = 0n; - public drawnShares = 0n; // aka totalDrawnShares - public ghostDrawnShares = 0n; - public offset = 0n; - public realisedPremium = 0n; + public drawnShares = 0n; + public premiumShares = 0n; + public premiumOffsetRay = 0n; // signed bigint (can be negative) - public baseDrawnIndex = RAY; + public drawnIndex = RAY; + private _pendingIndex = RAY; + private _pendingIndexTimestamp = 0n; public liquidity = 0n; + public swept = 0n; + public deficitRay = 0n; - public suppliedShares = 0n; + public addedShares = 0n; - // total drawn assets does not incl totalOutstandingPremium to accrue base rate separately - toDrawnAssets(shares: bigint, rounding = Rounding.FLOOR) { - this.accrue(); - return rayMul(shares, this.baseDrawnIndex, rounding); + public realizedFees = 0n; + public liquidityFee = 0n; // BPS (0-10000) + + toDrawnAssetsUp(shares: bigint): bigint { + return rayMulUp(shares, this.getDrawnIndex()); } - toDrawnShares(assets: bigint, rounding = Rounding.FLOOR) { - this.accrue(); - return rayDiv(assets, this.baseDrawnIndex, rounding); + toDrawnAssetsDown(shares: bigint): bigint { + return rayMulDown(shares, this.getDrawnIndex()); } - drawnDebt() { - return this.convertToDrawnAssets(this.drawnShares); + toDrawnSharesUp(assets: bigint): bigint { + return rayDivUp(assets, this.getDrawnIndex()); } - premiumDebt() { - const accruedPremium = this.convertToDrawnAssets(this.ghostDrawnShares) - this.offset; - assertGeZero(accruedPremium); - return accruedPremium + this.realisedPremium; + + toDrawnSharesDown(assets: bigint): bigint { + return rayDivDown(assets, this.getDrawnIndex()); } - totalSupplyAssets() { - this.accrue(); - return this.liquidity + this.drawnDebt() + this.premiumDebt() + 1n; + drawnDebt(): bigint { + return this.toDrawnAssetsUp(this.drawnShares); + } + + premiumRay(): bigint { + return calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, this.getDrawnIndex()); + } + + premiumDebt(): bigint { + return fromRayUp(this.premiumRay()); + } + + calculateAggregatedOwedRay(drawnIndex: bigint): bigint { + const premRay = calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, drawnIndex); + return this.drawnShares * drawnIndex + premRay + this.deficitRay; + } + + calculateAggregatedOwedRayAt(drawnIndex: bigint): bigint { + const premRay = calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, drawnIndex); + return this.drawnShares * drawnIndex + premRay + this.deficitRay; + } + + + getUnrealizedFees(newDrawnIndex: bigint): bigint { + const previousIndex = this.drawnIndex; + if (previousIndex === newDrawnIndex) return 0n; + if (this.liquidityFee === 0n) return 0n; + + const owedRayAfter = this.calculateAggregatedOwedRayAt(newDrawnIndex); + const owedRayBefore = this.calculateAggregatedOwedRayAt(previousIndex); + + return percentMulDown(fromRayUp(owedRayAfter) - fromRayUp(owedRayBefore), this.liquidityFee); + } + + + totalAddedAssets(): bigint { + const drawnIdx = this.getDrawnIndex(); + const aggregatedOwedRay = this.calculateAggregatedOwedRay(drawnIdx); + return ( + this.liquidity + + this.swept + + fromRayUp(aggregatedOwedRay) - + this.realizedFees - + this.getUnrealizedFees(drawnIdx) + ); + } + + toAddedAssetsDown(shares: bigint): bigint { + return toAssetsDown(shares, this.totalAddedAssets(), this.addedShares); } - totalSupplyShares() { - return this.suppliedShares + VIRTUAL_SHARES; + + toAddedAssetsUp(shares: bigint): bigint { + return toAssetsUp(shares, this.totalAddedAssets(), this.addedShares); } - toSupplyAssets(shares: bigint, rounding = Rounding.FLOOR) { - return this.totalSupplyShares() - ? mulDiv(shares, this.totalSupplyAssets(), this.totalSupplyShares(), rounding) - : shares; + toAddedSharesDown(assets: bigint): bigint { + return toSharesDown(assets, this.totalAddedAssets(), this.addedShares); } - toSupplyShares(assets: bigint, rounding = Rounding.FLOOR) { - return this.totalSupplyAssets() - ? mulDiv(assets, this.totalSupplyShares(), this.totalSupplyAssets(), rounding) - : assets; + toAddedSharesUp(assets: bigint): bigint { + return toSharesUp(assets, this.totalAddedAssets(), this.addedShares); } + getDrawnIndex(): bigint { + if ( + this.lastUpdateTimestamp === currentTime || + (this.drawnShares === 0n && this.premiumShares === 0n) + ) { + return this.drawnIndex; + } + if (this._pendingIndexTimestamp !== currentTime) { + this._pendingIndex = rayMulUp(this.drawnIndex, randomIndex()); + this._pendingIndexTimestamp = currentTime; + } + return this._pendingIndex; + } + + accrue() { if (this.lastUpdateTimestamp === currentTime) return; + + const newDrawnIndex = this.getDrawnIndex(); + this.realizedFees += this.getUnrealizedFees(newDrawnIndex); + this.drawnIndex = newDrawnIndex; this.lastUpdateTimestamp = currentTime; - this.baseDrawnIndex = rayMul(this.baseDrawnIndex, randomIndex()); } - supply(amount: bigint, spoke: Spoke) { - const suppliedShares = this.toSupplyShares(amount); - assertNonZero(suppliedShares); + add(amount: bigint, spoke: Spoke): bigint { + const shares = this.toAddedSharesDown(amount); + assertNonZero(shares); - this.suppliedShares += suppliedShares; + this.addedShares += shares; this.liquidity += amount; - this.getSpoke(spoke).suppliedShares += suppliedShares; + this.getSpoke(spoke).addedShares += shares; - return suppliedShares; + return shares; } - withdraw(amount: bigint, spoke: Spoke) { - const suppliedShares = this.toSupplyShares(amount, Rounding.CEIL); - this.suppliedShares -= suppliedShares; + remove(amount: bigint, spoke: Spoke): bigint { + const shares = this.toAddedSharesUp(amount); + + this.addedShares -= shares; this.liquidity -= amount; - this.getSpoke(spoke).suppliedShares -= suppliedShares; + this.getSpoke(spoke).addedShares -= shares; Utils.checkBounds(this); - return suppliedShares; + return shares; } - // @dev spoke data is *expected* to be updated on the `refresh` callback - draw(amount: bigint, spoke: Spoke) { - const drawnShares = this.toDrawnShares(amount, Rounding.CEIL); + + draw(amount: bigint, spoke: Spoke): bigint { + if (amount > this.liquidity) throw new Error('InsufficientLiquidity'); + const drawnShares = this.toDrawnSharesUp(amount); this.liquidity -= amount; this.drawnShares += drawnShares; @@ -128,127 +176,182 @@ export class Hub { return drawnShares; } - // @dev global & spoke premiumDebt (ghost, offset, unrealised) is *expected* to be updated on the `refresh` callback - restore(baseAmount: bigint, premiumAmount: bigint, spoke: Spoke) { - const drawnShares = this.toDrawnShares(baseAmount); - this.liquidity += baseAmount + premiumAmount; - this.drawnShares -= drawnShares; + restore(drawnAmount: bigint, premiumDelta: PremiumDelta, spoke: Spoke): bigint { + const drawnShares = this.toDrawnSharesDown(drawnAmount); + this.drawnShares -= drawnShares; this.getSpoke(spoke).drawnShares -= drawnShares; + this.applyPremiumDelta(premiumDelta, spoke); + + const premiumAmount = fromRayUp(premiumDelta.restoredPremiumRay); + this.liquidity += drawnAmount + premiumAmount; + return drawnShares; } - refresh( - userGhostDrawnSharesDelta: bigint, - userOffsetDelta: bigint, - userRealisedPremiumDelta: bigint, - who: Spoke - ) { - // add invariant: offset <= premiumDebt - // consider enforcing rp limit (per spoke) here using ghost/base (min and max cap) - // when we agree for -ve offset, then consider another configurable check for min limit offset - - // check that total debt can only: - // - reduce until `premiumDebt` if called after a restore (tstore premiumDebt?) - // - remains unchanged on all other calls - // `refresh` is game-able only for premium stuff - - let totalDebtBefore = this.getTotalDebt(); - this.ghostDrawnShares += userGhostDrawnSharesDelta; - this.offset += userOffsetDelta; - this.realisedPremium += userRealisedPremiumDelta; - Utils.checkBounds(this); - Utils.checkTotalDebt(totalDebtBefore, this); - const spoke = this.getSpoke(who); - totalDebtBefore = spoke.getTotalDebt(); - spoke.ghostDrawnShares += userGhostDrawnSharesDelta; - spoke.offset += userOffsetDelta; - spoke.realisedPremium += userRealisedPremiumDelta; - Utils.checkBounds(spoke); - Utils.checkTotalDebt(totalDebtBefore, spoke); + refreshPremium(premiumDelta: PremiumDelta, spoke: Spoke) { + if (premiumDelta.restoredPremiumRay !== 0n) { + throw new Error('refreshPremium: restoredPremiumRay must be 0'); + } + this.applyPremiumDelta(premiumDelta, spoke); } - getSpoke(spoke: Spoke) { - return this.spokes[this.idx(spoke)]; + + reportDeficit(drawnAmount: bigint, premiumDelta: PremiumDelta, spoke: Spoke): bigint { + const drawnShares = this.toDrawnSharesDown(drawnAmount); + const spokeData = this.getSpoke(spoke); + + this.drawnShares -= drawnShares; + spokeData.drawnShares -= drawnShares; + + this.applyPremiumDelta(premiumDelta, spoke); + + const deficitAmountRay = drawnShares * this.drawnIndex + premiumDelta.restoredPremiumRay; + this.deficitRay += deficitAmountRay; + spokeData.deficitRay += deficitAmountRay; + + return drawnShares; } - idx(spoke: Spoke) { - const idx = this.spokes.findIndex((s) => s.id === spoke.id); - if (idx === -1) { - this.addSpoke(spoke); - return this.spokes.length - 1; - } - return idx; + + eliminateDeficit(amount: bigint, callerSpoke: Spoke, coveredSpoke: Spoke): bigint { + this.accrue(); + const callerData = this.getSpoke(callerSpoke); + const coveredData = this.getSpoke(coveredSpoke); + + const defRay = coveredData.deficitRay; + const deficitAmountRay = amount < fromRayUp(defRay) ? toRay(amount) : defRay; + + const shares = this.toAddedSharesUp(fromRayUp(deficitAmountRay)); + this.addedShares -= shares; + callerData.addedShares -= shares; + this.deficitRay -= deficitAmountRay; + coveredData.deficitRay -= deficitAmountRay; + + return shares; } - log(spokes = false, users = false) { - const ghostDebt = this.convertToDrawnAssets(this.ghostDrawnShares) - this.offset; - console.log('--- Hub ---'); - console.log('hub.drawnShares ', f(this.drawnShares)); - console.log('hub.ghostDrawnShares ', f(this.ghostDrawnShares)); - console.log('hub.offset ', f(this.offset)); - console.log('hub.ghostDebt ', f(ghostDebt)); - console.log('hub.realisedPremium ', f(this.realisedPremium)); - - console.log('hub.suppliedShares ', f(this.suppliedShares)); - console.log('hub.totalSupplyAssets ', f(this.totalSupplyAssets())); - console.log('hub.liquidity ', f(this.liquidity)); - console.log('hub.drawnDebt ', f(this.drawnDebt())); - console.log('hub.premiumDebt ', f(this.premiumDebt())); - console.log('hub.lastUpdateTimestamp ', this.lastUpdateTimestamp); - - console.log('hub.getTotalDebt ', f(this.getTotalDebt())); - console.log('hub.getDebt: drawnDebt ', f(this.getDebt().drawnDebt)); - console.log('hub.getDebt: premiumDebt ', f(this.getDebt().premiumDebt)); - console.log(); - if (spokes) this.spokes.forEach((spoke) => spoke.log(false, users)); + applyPremiumDelta(premiumDelta: PremiumDelta, spoke: Spoke) { + const drawnIndex = this.drawnIndex; + + [this.premiumShares, this.premiumOffsetRay] = this.validateApplyPremiumDelta( + drawnIndex, + this.premiumShares, + this.premiumOffsetRay, + premiumDelta + ); + + const spokeData = this.getSpoke(spoke); + [spokeData.premiumShares, spokeData.premiumOffsetRay] = this.validateApplyPremiumDelta( + drawnIndex, + spokeData.premiumShares, + spokeData.premiumOffsetRay, + premiumDelta + ); } - getTotalDebt() { - return Object.values(this.getDebt()).reduce((sum, debt) => sum + debt, 0n); + + validateApplyPremiumDelta( + drawnIndex: bigint, + premiumShares: bigint, + premiumOffsetRay: bigint, + premiumDelta: PremiumDelta + ): [bigint, bigint] { + const premiumRayBefore = calculatePremiumRay(premiumShares, premiumOffsetRay, drawnIndex); + + const newPremiumShares = addSigned(premiumShares, premiumDelta.sharesDelta); + const newPremiumOffsetRay = premiumOffsetRay + premiumDelta.offsetRayDelta; + + const premiumRayAfter = calculatePremiumRay(newPremiumShares, newPremiumOffsetRay, drawnIndex); + + if (premiumRayAfter + premiumDelta.restoredPremiumRay !== premiumRayBefore) { + throw new Error( + `validateApplyPremiumDelta: invariant failed. ` + + `after(${premiumRayAfter}) + restored(${premiumDelta.restoredPremiumRay}) != before(${premiumRayBefore})` + ); + } + return [newPremiumShares, newPremiumOffsetRay]; + } + + getTotalDebt(): bigint { + const d = this.getDebt(); + return d.drawnDebt + d.premiumDebt; } getDebt() { this.accrue(); - const accruedPremium = this.convertToDrawnAssets(this.ghostDrawnShares) - this.offset; - assertGeZero(accruedPremium); + const drawnIdx = this.drawnIndex; return { - drawnDebt: this.convertToDrawnAssets(this.drawnShares), - premiumDebt: accruedPremium + this.realisedPremium, + drawnDebt: rayMulUp(this.drawnShares, drawnIdx), + premiumDebt: fromRayUp(calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, drawnIdx)), }; } - convertToAddedAssets(shares: bigint) { - return this.toSupplyAssets(shares); - } - convertToAddedShares(assets: bigint) { - return this.toSupplyShares(assets); + convertToAddedAssets(shares: bigint): bigint { + return this.toAddedAssetsDown(shares); } - - convertToDrawnAssets(shares: bigint) { - return this.toDrawnAssets(shares, Rounding.CEIL); + convertToAddedShares(assets: bigint): bigint { + return this.toAddedSharesDown(assets); } - convertToDrawnShares(assets: bigint) { - return this.toDrawnShares(assets); + convertToDrawnAssets(shares: bigint): bigint { + return this.toDrawnAssetsUp(shares); } - - previewOffset(premiumShares: bigint) { - return this.toDrawnAssets(premiumShares); + convertToDrawnShares(assets: bigint): bigint { + return this.toDrawnSharesDown(assets); } supplyExchangeRatio() { return { - totalSuppliedAssets: this.totalSupplyAssets(), - totalSuppliedShares: this.totalSupplyShares(), + totalAddedAssets: this.totalAddedAssets(), + totalAddedShares: this.addedShares, }; } + getSpoke(spoke: Spoke) { + return this.spokes[this.idx(spoke)]; + } + + idx(spoke: Spoke) { + const idx = this.spokes.findIndex((s) => s.id === spoke.id); + if (idx === -1) { + this.addSpoke(spoke); + return this.spokes.length - 1; + } + return idx; + } + addSpoke(who: Spoke) { - this.spokes.push(new Spoke(this, who.id)); // clone to maintain independent accounting + this.spokes.push(new Spoke(this, who.id)); + } + + log(spokes = false, users = false) { + const premRay = calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, this.drawnIndex); + console.log('--- Hub ---'); + console.log('hub.drawnShares ', f(this.drawnShares)); + console.log('hub.premiumShares ', f(this.premiumShares)); + console.log('hub.premiumOffsetRay ', this.premiumOffsetRay); + console.log('hub.premiumRay ', premRay); + console.log('hub.premiumDebt ', f(fromRayUp(premRay))); + console.log('hub.addedShares ', f(this.addedShares)); + console.log('hub.totalAddedAssets ', f(this.totalAddedAssets())); + console.log('hub.liquidity ', f(this.liquidity)); + console.log('hub.drawnDebt ', f(this.drawnDebt())); + console.log('hub.premiumDebt() ', f(this.premiumDebt())); + console.log('hub.deficitRay ', this.deficitRay); + console.log('hub.realizedFees ', f(this.realizedFees)); + console.log('hub.liquidityFee ', this.liquidityFee); + console.log('hub.drawnIndex ', this.drawnIndex); + console.log('hub.lastUpdateTimestamp ', this.lastUpdateTimestamp); + console.log('hub.getTotalDebt ', f(this.getTotalDebt())); + console.log('hub.getDebt: drawnDebt ', f(this.getDebt().drawnDebt)); + console.log('hub.getDebt: premiumDebt ', f(this.getDebt().premiumDebt)); + console.log(); + + if (spokes) this.spokes.forEach((spoke) => spoke.log(false, users)); } whoami() { @@ -260,232 +363,225 @@ export class Spoke { public users: User[] = []; public drawnShares = 0n; - public ghostDrawnShares = 0n; - public offset = 0n; - public realisedPremium = 0n; - - public suppliedShares = 0n; + public premiumShares = 0n; + public premiumOffsetRay = 0n; // signed + public addedShares = 0n; + public deficitRay = 0n; - constructor(public hub: Hub, public readonly id = ++spokeIdCounter) {} + constructor( + public hub: Hub, + public readonly id = ++spokeIdCounter + ) {} - supply(amount: bigint, who: User) { + supply(amount: bigint, who: User): bigint { const user = this.getUser(who); - this.hub.accrue(); - const suppliedShares = this.hub.supply(amount, this); + const shares = this.hub.add(amount, this); - this.suppliedShares += suppliedShares; - user.suppliedShares += suppliedShares; + this.addedShares += shares; + user.addedShares += shares; - this.updateUserRiskPremium(user); + this.refreshUserPremium(user); - return suppliedShares; + return shares; } - withdraw(amount: bigint, who: User) { + withdraw(amount: bigint, who: User): bigint { const user = this.getUser(who); - this.hub.accrue(); - amount = min(amount, user.getSuppliedBalance()); - const suppliedShares = this.hub.withdraw(amount, this); + amount = min(amount, min(user.getSuppliedBalance(), this.hub.liquidity)); + if (amount === 0n) return 0n; - this.suppliedShares -= suppliedShares; - user.suppliedShares -= suppliedShares; + let shares = this.hub.toAddedSharesUp(amount); + shares = min(shares, user.addedShares); - this.updateUserRiskPremium(user); + this.hub.addedShares -= shares; + this.hub.liquidity -= amount; + this.hub.getSpoke(this).addedShares -= shares; - return suppliedShares; + this.addedShares -= shares; + user.addedShares -= shares; + + this.refreshUserPremium(user); + + return shares; } - borrow(amount: bigint, who: User) { + borrow(amount: bigint, who: User): bigint { const user = this.getUser(who); - this.hub.accrue(); + const drawnIndex = this.hub.drawnIndex; - let userGhostDrawnShares = user.ghostDrawnShares; - let userOffset = user.offset; - const accruedPremium = this.hub.convertToDrawnAssets(userGhostDrawnShares) - userOffset; - assertGeZero(accruedPremium); - - user.ghostDrawnShares = 0n; - user.offset = 0n; - user.realisedPremium += accruedPremium; - - this.refresh(-userGhostDrawnShares, -userOffset, accruedPremium, user); - const drawnShares = this.hub.draw(amount, this); // asset to share should round up - + const drawnShares = this.hub.draw(amount, this); this.drawnShares += drawnShares; user.drawnShares += drawnShares; - user.riskPremium = randomRiskPremium(); - userGhostDrawnShares = user.ghostDrawnShares = percentMul(user.drawnShares, user.riskPremium); - userOffset = user.offset = this.hub.previewOffset(user.ghostDrawnShares); + const newRiskPremium = randomRiskPremium(); + user.riskPremium = newRiskPremium; - this.refresh(userGhostDrawnShares, userOffset, 0n, user); + const premiumDelta = this.calculatePremiumDelta(user, 0n, drawnIndex, newRiskPremium, 0n); + + this.hub.refreshPremium(premiumDelta, this); + this.applyLocalPremiumDelta(premiumDelta); + this.applyUserPremiumDelta(user, premiumDelta); return drawnShares; } - repay(amount: bigint, who: User) { + repay(amount: bigint, who: User): [bigint, bigint] { const user = this.getUser(who); - this.hub.accrue(); - const {drawnDebt, premiumDebt} = this.getUserDebt(user); - const {drawnDebtRestored, premiumDebtRestored} = this.deductFromPremium( - drawnDebt, - premiumDebt, - amount, - user + const drawnIndex = this.hub.drawnIndex; + + const {drawnDebtRestored, premiumDebtRayRestored} = this.calculateRestoreAmount( + user, + drawnIndex, + amount ); - let userGhostDrawnShares = user.ghostDrawnShares; - let userOffset = user.offset; - const userRealisedPremium = user.realisedPremium; - user.ghostDrawnShares = 0n; - user.offset = 0n; - user.realisedPremium = premiumDebt - premiumDebtRestored; - this.refresh( - -userGhostDrawnShares, - -userOffset, - user.realisedPremium - userRealisedPremium, - user - ); // settle premium debt - const drawnShares = this.hub.restore(drawnDebtRestored, premiumDebtRestored, this); // settle drawn debt + const restoredShares = rayDivDown(drawnDebtRestored, drawnIndex); - this.drawnShares -= drawnShares; - user.drawnShares -= drawnShares; + const premiumDelta = this.calculatePremiumDelta( + user, + restoredShares, + drawnIndex, + user.riskPremium, + premiumDebtRayRestored + ); + + const drawnSharesRestored = this.hub.restore(drawnDebtRestored, premiumDelta, this); - user.riskPremium = randomRiskPremium(); - userGhostDrawnShares = user.ghostDrawnShares = percentMul(user.drawnShares, user.riskPremium); - userOffset = user.offset = this.hub.previewOffset(user.ghostDrawnShares); + this.applyLocalPremiumDelta(premiumDelta); + this.applyUserPremiumDelta(user, premiumDelta); - this.refresh(userGhostDrawnShares, userOffset, 0n, user); + this.drawnShares -= drawnSharesRestored; + user.drawnShares -= drawnSharesRestored; - return [drawnShares, premiumDebtRestored]; + const premiumAmountRestored = fromRayUp(premiumDebtRayRestored); + return [drawnSharesRestored, premiumAmountRestored]; } - deductFromPremium(drawnDebt: bigint, premiumDebt: bigint, amount: bigint, user: User) { - if (amount === MAX_UINT) { - return {drawnDebtRestored: drawnDebt, premiumDebtRestored: premiumDebt}; - } - let drawnDebtRestored = 0n, - premiumDebtRestored = 0n; + calculatePremiumDelta( + user: User, + drawnSharesTaken: bigint, + drawnIndex: bigint, + riskPremium: bigint, + restoredPremiumRay: bigint + ): PremiumDelta { + const oldPremiumShares = user.premiumShares; + const oldPremiumOffsetRay = user.premiumOffsetRay; + const premiumDebtRay = calculatePremiumRay(oldPremiumShares, oldPremiumOffsetRay, drawnIndex); - if (amount < premiumDebt) { - drawnDebtRestored = 0n; - premiumDebtRestored = amount; - } else { - drawnDebtRestored = amount - premiumDebt; - premiumDebtRestored = premiumDebt; - } + const newPremiumShares = percentMulUp(user.drawnShares - drawnSharesTaken, riskPremium); + const newPremiumOffsetRay = signedSub( + newPremiumShares * drawnIndex, + premiumDebtRay - restoredPremiumRay + ); - // sanity - if (drawnDebtRestored > drawnDebt) { - user.log(true, true); - info( - 'drawnDebtRestored, drawnDebt, diff', - f(drawnDebtRestored), - f(drawnDebt), - absDiff(drawnDebtRestored, drawnDebt) - ); - throw new Error('drawnDebtRestored exceeds drawnDebt'); + return { + sharesDelta: signedSub(newPremiumShares, oldPremiumShares), + offsetRayDelta: newPremiumOffsetRay - oldPremiumOffsetRay, + restoredPremiumRay, + }; + } + + + calculateRestoreAmount( + user: User, + drawnIndex: bigint, + amount: bigint + ): {drawnDebtRestored: bigint; premiumDebtRayRestored: bigint} { + const drawnDebt = rayMulUp(user.drawnShares, drawnIndex); + const premiumDebtRay = calculatePremiumRay( + user.premiumShares, + user.premiumOffsetRay, + drawnIndex + ); + const premiumDebt = fromRayUp(premiumDebtRay); + + if (amount >= drawnDebt + premiumDebt || amount === MAX_UINT) { + return {drawnDebtRestored: drawnDebt, premiumDebtRayRestored: premiumDebtRay}; } - if (premiumDebtRestored > premiumDebt) { - user.log(true, true); - info( - 'premiumDebtRestored, premiumDebt, diff', - f(premiumDebtRestored), - f(premiumDebt), - absDiff(premiumDebtRestored, premiumDebt) - ); - throw new Error('premiumDebtRestored exceeds premiumDebt'); + if (amount < premiumDebt) { + return {drawnDebtRestored: 0n, premiumDebtRayRestored: toRay(amount)}; } - return {drawnDebtRestored, premiumDebtRestored}; + return {drawnDebtRestored: amount - premiumDebt, premiumDebtRayRestored: premiumDebtRay}; } - updateUserRiskPremium(who: User) { - const user = this.getUser(who); - user.riskPremium = randomRiskPremium(); - - const oldUserGhostDrawnShares = user.ghostDrawnShares; - const oldUserOffset = user.offset; - - user.ghostDrawnShares = percentMul(user.drawnShares, user.riskPremium); - user.offset = this.hub.previewOffset(user.ghostDrawnShares); + applyLocalPremiumDelta(premiumDelta: PremiumDelta) { + this.premiumShares = addSigned(this.premiumShares, premiumDelta.sharesDelta); + this.premiumOffsetRay = this.premiumOffsetRay + premiumDelta.offsetRayDelta; + } - const accruedPremium = this.hub.convertToDrawnAssets(oldUserGhostDrawnShares) - oldUserOffset; - user.realisedPremium += accruedPremium; - this.refresh( - user.ghostDrawnShares - oldUserGhostDrawnShares, - user.offset - oldUserOffset, - accruedPremium, - user - ); + applyUserPremiumDelta(user: User, premiumDelta: PremiumDelta) { + user.premiumShares = addSigned(user.premiumShares, premiumDelta.sharesDelta); + user.premiumOffsetRay = user.premiumOffsetRay + premiumDelta.offsetRayDelta; } - refresh( - userGhostDrawnSharesDelta: bigint, - userOffsetDelta: bigint, - userRealisedPremiumDelta: bigint, - user: User - ) { - Utils.checkBounds(user); + refreshUserPremium(who: User) { + const user = this.getUser(who); + if (user.drawnShares === 0n) return; - const totalDebtBefore = this.getTotalDebt(); - this.ghostDrawnShares += userGhostDrawnSharesDelta; - this.offset += userOffsetDelta; - this.realisedPremium += userRealisedPremiumDelta; - Utils.checkBounds(this); - Utils.checkTotalDebt(totalDebtBefore, this); + this.hub.accrue(); + const newRiskPremium = randomRiskPremium(); + user.riskPremium = newRiskPremium; + const drawnIndex = this.hub.drawnIndex; + + const premiumDelta = this.calculatePremiumDelta(user, 0n, drawnIndex, newRiskPremium, 0n); - this.hub.refresh(userGhostDrawnSharesDelta, userOffsetDelta, userRealisedPremiumDelta, this); + this.hub.refreshPremium(premiumDelta, this); + this.applyLocalPremiumDelta(premiumDelta); + this.applyUserPremiumDelta(user, premiumDelta); } - getTotalDebt() { - return Object.values(this.getDebt()).reduce((sum, debt) => sum + debt, 0n); + getTotalDebt(): bigint { + const d = this.getDebt(); + return d.drawnDebt + d.premiumDebt; } getDebt() { this.hub.accrue(); - const accruedPremium = this.hub.convertToDrawnAssets(this.ghostDrawnShares) - this.offset; - assertGeZero(accruedPremium); + const drawnIdx = this.hub.drawnIndex; return { - drawnDebt: this.hub.convertToDrawnAssets(this.drawnShares), - premiumDebt: accruedPremium + this.realisedPremium, + drawnDebt: rayMulUp(this.drawnShares, drawnIdx), + premiumDebt: fromRayUp( + calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, drawnIdx) + ), }; } getUserDebt(who: User) { this.hub.accrue(); const user = this.getUser(who); - const accruedPremium = this.hub.convertToDrawnAssets(user.ghostDrawnShares) - user.offset; - assertGeZero(accruedPremium); + const drawnIdx = this.hub.drawnIndex; return { - drawnDebt: this.hub.convertToDrawnAssets(user.drawnShares), - premiumDebt: accruedPremium + user.realisedPremium, + drawnDebt: rayMulUp(user.drawnShares, drawnIdx), + premiumDebt: fromRayUp( + calculatePremiumRay(user.premiumShares, user.premiumOffsetRay, drawnIdx) + ), }; } - getUserTotalDebt(who: User) { - return Object.values(this.getUserDebt(who)).reduce((sum, debt) => sum + debt, 0n); + getUserTotalDebt(who: User): bigint { + const d = this.getUserDebt(who); + return d.drawnDebt + d.premiumDebt; } addUser(user: User) { - // store user reference since we don't back update since it's an eoa this.users.push(user); user.assignSpoke(this); } getUser(user: User | number) { if (typeof user === 'number') return this.users[user]; - return this.users[this.idx(user)]; + return this.users[this.userIdx(user)]; } - idx(user: User) { + userIdx(user: User) { const idx = this.users.findIndex((s) => s.id === user.id); if (idx === -1) { this.addUser(user); @@ -496,17 +592,18 @@ export class Spoke { } log(hub = false, users = false) { - const ghostDebt = this.hub.convertToDrawnAssets(this.ghostDrawnShares) - this.offset; + const drawnIdx = this.hub.drawnIndex; + const premRay = calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, drawnIdx); console.log(`--- Spoke ${this.id} ---`); - console.log('spoke.drawnShares ', f(this.drawnShares)); - console.log('spoke.ghostDrawnShares ', f(this.ghostDrawnShares)); - console.log('spoke.offset ', f(this.offset)); - console.log('spoke.ghostDebt ', f(ghostDebt)); - console.log('spoke.realisedPremium ', f(this.realisedPremium)); - console.log('spoke.suppliedShares ', f(this.suppliedShares)); - console.log('spoke.getTotalDebt ', f(this.getTotalDebt())); - console.log('spoke.getDebt: drawnDebt ', f(this.getDebt().drawnDebt)); - console.log('spoke.getDebt: premiumDebt ', f(this.getDebt().premiumDebt)); + console.log('spoke.drawnShares ', f(this.drawnShares)); + console.log('spoke.premiumShares ', f(this.premiumShares)); + console.log('spoke.premiumOffsetRay ', this.premiumOffsetRay); + console.log('spoke.premiumDebt ', f(fromRayUp(premRay))); + console.log('spoke.addedShares ', f(this.addedShares)); + console.log('spoke.deficitRay ', this.deficitRay); + console.log('spoke.getTotalDebt ', f(this.getTotalDebt())); + console.log('spoke.getDebt: drawnDebt ', f(this.getDebt().drawnDebt)); + console.log('spoke.getDebt: premDebt ', f(this.getDebt().premiumDebt)); console.log(); if (hub) this.hub.log(); if (users) this.users.forEach((user) => user.log()); @@ -518,19 +615,17 @@ export class Spoke { } export class User { - public spoke: Spoke; - public hub: Hub; + public spoke!: Spoke; + public hub!: Hub; public drawnShares = 0n; - public ghostDrawnShares = 0n; - public offset = 0n; - public realisedPremium = 0n; - - public suppliedShares = 0n; + public premiumShares = 0n; + public premiumOffsetRay = 0n; // signed + public addedShares = 0n; constructor( public readonly id = ++userIdCounter, - public riskPremium = randomRiskPremium(), // don't need to store, can be derived from `ghost/base` + public riskPremium = randomRiskPremium(), spoke: Spoke | null = null ) { if (spoke) this.assignSpoke(spoke); @@ -538,16 +633,16 @@ export class User { supply(amount: bigint) { this.beforeHook('supply', amount); - const suppliedShares = this.spoke.supply(amount, this); + const shares = this.spoke.supply(amount, this); this.afterHook(); - return suppliedShares; + return shares; } withdraw(amount: bigint) { this.beforeHook('withdraw', amount); - const withdrawnShares = this.spoke.withdraw(amount, this); + const shares = this.spoke.withdraw(amount, this); this.afterHook(); - return withdrawnShares; + return shares; } borrow(amount: bigint) { @@ -566,7 +661,7 @@ export class User { updateRiskPremium() { this.beforeHook('updateRiskPremium'); - this.spoke.updateUserRiskPremium(this); + this.spoke.refreshUserPremium(this); this.afterHook(); } @@ -584,22 +679,22 @@ export class User { } getSuppliedBalance() { - return this.hub.convertToAddedAssets(this.suppliedShares); + return this.hub.convertToAddedAssets(this.addedShares); } log(spoke = false, hub = false) { - const ghostDebt = this.hub.convertToDrawnAssets(this.ghostDrawnShares) - this.offset; + const drawnIdx = this.hub.drawnIndex; + const premRay = calculatePremiumRay(this.premiumShares, this.premiumOffsetRay, drawnIdx); console.log(`--- User ${this.id} ---`); - console.log('user.drawnShares ', f(this.drawnShares)); - console.log('user.ghostDrawnShares ', f(this.ghostDrawnShares)); - console.log('user.offset ', f(this.offset)); - console.log('user.ghostDebt ', f(ghostDebt)); - console.log('user.realisedPremium ', f(this.realisedPremium)); - console.log('user.suppliedShares ', f(this.suppliedShares)); - console.log('user.riskPremium ', formatBps(this.riskPremium)); - console.log('user.getTotalDebt ', f(this.spoke.getUserTotalDebt(this))); - console.log('user.getDebt: drawnDebt ', f(this.spoke.getUserDebt(this).drawnDebt)); - console.log('user.getDebt: premiumDebt ', f(this.spoke.getUserDebt(this).premiumDebt)); + console.log('user.drawnShares ', f(this.drawnShares)); + console.log('user.premiumShares ', f(this.premiumShares)); + console.log('user.premiumOffsetRay ', this.premiumOffsetRay); + console.log('user.premiumDebt ', f(fromRayUp(premRay))); + console.log('user.addedShares ', f(this.addedShares)); + console.log('user.riskPremium ', formatBps(this.riskPremium)); + console.log('user.getTotalDebt ', f(this.spoke.getUserTotalDebt(this))); + console.log('user.getDebt: drawnDebt ', f(this.spoke.getUserDebt(this).drawnDebt)); + console.log('user.getDebt: premDebt ', f(this.spoke.getUserDebt(this).premiumDebt)); console.log(); if (spoke) this.spoke.log(); if (hub) this.hub.log(); @@ -624,7 +719,7 @@ export class System { public spokes: Spoke[]; public users: User[]; - public supplyExchangeRatio: ReturnType; + public supplyExchangeRatio!: ReturnType; constructor(numSpokes = 1, numUsers = 3) { this.hub = new Hub(); @@ -648,20 +743,18 @@ export class System { user.logAction(action, amount); console.log( 'debt ex ratio before', - formatUnits(this.hub.convertToDrawnAssets(10n ** 50n), 50) // bigint won't overflow + formatUnits(this.hub.convertToDrawnAssets(10n ** 50n), 50) ); - this.supplyExchangeRatio = this.hub.supplyExchangeRatio(); }; user.afterHook = () => { - // should always increase on an accrue this.invariant_supplyExchangeRateIsNonDecreasing(); this.runInvariants(); }; }); } - nonZeroSuppliedShares(amount: bigint) { + nonZeroAddedShares(amount: bigint) { while (this.hub.convertToAddedShares(amount) === 0n) amount = randomAmount(); return amount; } @@ -680,41 +773,43 @@ export class System { this.invariant_hubSpokeAccounting(); this.invariant_sumOfDrawnDebt(); this.invariant_sumOfPremiumDebt(); - this.invariant_sumOfSuppliedShares(); - this.invariant_hubSpokeAccounting(); - // todo invariant: both exchange ratio are always increasing with the offset fix + this.invariant_sumOfAddedShares(); + this.invariant_sumOfDeficitRay(); } invariant_valuesWithinBounds() { let fail = false; - const all = [this.hub, ...this.spokes, ...this.users]; - ['drawnShares', 'ghostDrawnShares', 'offset', 'realisedPremium', 'suppliedShares'].forEach( - (key) => { - all.forEach((who) => { - if (who[key] < 0n || who[key] > MAX_UINT) { - who.log(who instanceof User, who instanceof User); - console.error(`${who.whoami()}.${key} < 0 || > MAX_UINT`, f(who[key])); - fail = true; - } - }); - } - ); - // ghost drawn assets >= offset, always + const all: (Hub | Spoke | User)[] = [this.hub, ...this.spokes, ...this.users]; + + // Non-negative unsigned fields + ['drawnShares', 'premiumShares', 'addedShares'].forEach((key) => { + all.forEach((who) => { + if ((who as any)[key] < 0n || (who as any)[key] > MAX_UINT) { + who.log(who instanceof User, who instanceof User); + console.error(`${who.whoami()}.${key} out of bounds`, f((who as any)[key])); + fail = true; + } + }); + }); + + // Premium must be non-negative (calculatePremiumRay validates) + const drawnIdx = this.hub.drawnIndex; all.forEach((who) => { - const ghostDrawnAssets = this.hub.convertToDrawnAssets(who.ghostDrawnShares); - if (ghostDrawnAssets < who.offset) { + try { + calculatePremiumRay(who.premiumShares, who.premiumOffsetRay, drawnIdx); + } catch (e) { who.log(); - console.error( - `assets(${who.whoami()}.ghostDrawnShares) < offset, ghostDrawnShares, diff`, - f(ghostDrawnAssets), - f(who.offset), - f(who.ghostDrawnShares), - who.offset - ghostDrawnAssets - ); + console.error(`${who.whoami()} has negative premium`); fail = true; } }); + // Hub-specific bounds + if (this.hub.liquidity < 0n) { + console.error('hub.liquidity < 0'); + fail = true; + } + this.handleFailure(fail, 'invariant_valuesWithinBounds'); } @@ -734,7 +829,7 @@ export class System { } if ((diff = maxAbsDiff(hubDrawnDebt, spokeDrawn, userDrawnDebt)) > PRECISION) { console.error( - 'maxAbsDiff(hubDrawnDebt, spokeDrawn, userDrawnDebt) > PRECISION, diff', + 'maxAbsDiff(hubDrawnDebt, spokeDrawn, userDrawnDebt) > PRECISION', f(hubDrawnDebt), f(spokeDrawn), f(userDrawnDebt), @@ -746,14 +841,12 @@ export class System { if (hubDrawnDebt === 0n && spokeDrawn + userDrawnDebt !== 0n) { console.error( 'spoke & user dust drawnDebt remaining when hub drawnDebt is completely repaid', - 'spokeDrawn %d, userDrawnDebt %d', f(spokeDrawn), f(userDrawnDebt) ); fail = true; } - // this.handleFailure(fail, arguments.callee.name); this.handleFailure(fail, 'invariant_sumOfDrawnDebt'); } @@ -761,8 +854,14 @@ export class System { let fail = false, diff = 0n; const hubPremiumDebt = this.hub.getDebt().premiumDebt; - const spokePremium = this.spokes.reduce((sum, spoke) => sum + spoke.getDebt().premiumDebt, 0n); - const userPremiumDebt = this.users.reduce((sum, user) => sum + user.getDebt().premiumDebt, 0n); + const spokePremium = this.spokes.reduce( + (sum, spoke) => sum + spoke.getDebt().premiumDebt, + 0n + ); + const userPremiumDebt = this.users.reduce( + (sum, user) => sum + user.getDebt().premiumDebt, + 0n + ); if ((diff = absDiff(hubPremiumDebt, spokePremium)) > PRECISION) { console.error( 'hubPremiumDebt !== spokePremium, diff', @@ -782,25 +881,52 @@ export class System { fail = true; } - // validate internal premium vars - ['ghostDrawnShares', 'offset', 'realisedPremium'].forEach((key) => { - const hubKey = this.hub[key]; - const spokeKey = this.spokes.reduce((sum, spoke) => sum + spoke[key], 0n); - const userKey = this.users.reduce((sum, user) => sum + user[key], 0n); - if ((diff = absDiff(hubKey, spokeKey)) > PRECISION) { - console.error(`this.hub.${key} !== spoke.${key}, diff`, f(hubKey), f(spokeKey), diff); - fail = true; - } - if ((diff = absDiff(spokeKey, userKey)) > PRECISION) { - console.error(`spoke.${key} !== user.${key}, diff`, f(spokeKey), f(userKey), diff); - fail = true; - } - }); + // Validate internal premium vars sum correctly + // premiumShares: exact match + const hubPS = this.hub.premiumShares; + const spokePS = this.spokes.reduce((sum, spoke) => sum + spoke.premiumShares, 0n); + const userPS = this.users.reduce((sum, user) => sum + user.premiumShares, 0n); + if (hubPS !== spokePS) { + console.error( + 'hub.premiumShares !== sum(spoke.premiumShares)', + f(hubPS), + f(spokePS) + ); + fail = true; + } + if (spokePS !== userPS) { + console.error( + 'sum(spoke.premiumShares) !== sum(user.premiumShares)', + f(spokePS), + f(userPS) + ); + fail = true; + } + + // premiumOffsetRay: exact match (signed) + const hubPOR = this.hub.premiumOffsetRay; + const spokePOR = this.spokes.reduce((sum, spoke) => sum + spoke.premiumOffsetRay, 0n); + const userPOR = this.users.reduce((sum, user) => sum + user.premiumOffsetRay, 0n); + if (hubPOR !== spokePOR) { + console.error( + 'hub.premiumOffsetRay !== sum(spoke.premiumOffsetRay)', + hubPOR, + spokePOR + ); + fail = true; + } + if (spokePOR !== userPOR) { + console.error( + 'sum(spoke.premiumOffsetRay) !== sum(user.premiumOffsetRay)', + spokePOR, + userPOR + ); + fail = true; + } if (hubPremiumDebt === 0n && spokePremium + userPremiumDebt !== 0n) { console.error( 'spoke & user dust premiumDebt remaining when hub premiumDebt is completely repaid', - 'spokePremium %d, userPremiumDebt %d', f(spokePremium), f(userPremiumDebt) ); @@ -810,32 +936,43 @@ export class System { this.handleFailure(fail, 'invariant_sumOfPremiumDebt'); } - invariant_sumOfSuppliedShares() { - const hubSuppliedShares = this.hub.suppliedShares; - const spokeSuppliedShares = this.spokes.reduce((sum, spoke) => sum + spoke.suppliedShares, 0n); - const userSuppliedShares = this.users.reduce((sum, user) => sum + user.suppliedShares, 0n); + invariant_sumOfAddedShares() { + const hubAddedShares = this.hub.addedShares; + const spokeAddedShares = this.spokes.reduce((sum, spoke) => sum + spoke.addedShares, 0n); + const userAddedShares = this.users.reduce((sum, user) => sum + user.addedShares, 0n); let fail = false, diff = 0n; - if ((diff = absDiff(hubSuppliedShares, spokeSuppliedShares)) > PRECISION) { + if ((diff = absDiff(hubAddedShares, spokeAddedShares)) > PRECISION) { console.error( - 'hubSuppliedShares !== spokeSuppliedShares, diff', - f(hubSuppliedShares), - f(spokeSuppliedShares), + 'hubAddedShares !== spokeAddedShares, diff', + f(hubAddedShares), + f(spokeAddedShares), diff ); fail = true; } - if ((diff = absDiff(hubSuppliedShares, userSuppliedShares)) > PRECISION) { + if ((diff = absDiff(hubAddedShares, userAddedShares)) > PRECISION) { console.error( - 'hubSuppliedShares !== userSuppliedShares, diff', - f(hubSuppliedShares), - f(userSuppliedShares), + 'hubAddedShares !== userAddedShares, diff', + f(hubAddedShares), + f(userAddedShares), diff ); fail = true; } - this.handleFailure(fail, 'invariant_sumOfSuppliedShares'); + this.handleFailure(fail, 'invariant_sumOfAddedShares'); + } + + invariant_sumOfDeficitRay() { + const hubDeficit = this.hub.deficitRay; + const spokeDeficit = this.spokes.reduce((sum, spoke) => sum + spoke.deficitRay, 0n); + let fail = false; + if (hubDeficit !== spokeDeficit) { + console.error('hubDeficit !== spokeDeficit', hubDeficit, spokeDeficit); + fail = true; + } + this.handleFailure(fail, 'invariant_sumOfDeficitRay'); } invariant_hubSpokeAccounting() { @@ -843,18 +980,18 @@ export class System { this.spokes.forEach((spoke) => { const spokeOnHub = this.hub.getSpoke(spoke); - ['drawnShares', 'ghostDrawnShares', 'offset', 'realisedPremium', 'suppliedShares'].forEach( - (key) => { - if (spoke[key] !== spokeOnHub[key]) { - console.error( - `spoke(${spoke.id}).${key} ${f(spoke[key])} !== this.hub.spokes[${this.hub.idx( - spoke - )}].${key} ${f(spokeOnHub[key])}` - ); - fail = true; - } + ( + ['drawnShares', 'premiumShares', 'premiumOffsetRay', 'addedShares', 'deficitRay'] as const + ).forEach((key) => { + if ((spoke as any)[key] !== (spokeOnHub as any)[key]) { + console.error( + `spoke(${spoke.id}).${key} ${(spoke as any)[key]} !== hub.spokes[${this.hub.idx( + spoke + )}].${key} ${(spokeOnHub as any)[key]}` + ); + fail = true; } - ); + }); }); this.handleFailure(fail, 'invariant_hubSpokeAccountingMatch'); @@ -862,82 +999,70 @@ export class System { invariant_supplyExchangeRateIsNonDecreasing() { let fail = false; - const supplyExchangeRatio = this.hub.supplyExchangeRatio(); + const ratio = this.hub.supplyExchangeRatio(); + const prev = this.supplyExchangeRatio; if ( - supplyExchangeRatio.totalSuppliedAssets * this.supplyExchangeRatio.totalSuppliedShares < - this.supplyExchangeRatio.totalSuppliedAssets * supplyExchangeRatio.totalSuppliedShares + prev && + prev.totalAddedAssets > 0n && + (ratio.totalAddedAssets + VIRTUAL_ASSETS) * (prev.totalAddedShares + VIRTUAL_SHARES) < + (prev.totalAddedAssets + VIRTUAL_ASSETS) * (ratio.totalAddedShares + VIRTUAL_SHARES) ) { console.error( - 'supplyExchangeRatio < this.supplyExchangeRatio, diff', - Utils.ratio(supplyExchangeRatio), - Utils.ratio(this.supplyExchangeRatio), - Utils.diff(this.supplyExchangeRatio, supplyExchangeRatio) + 'supplyExchangeRate decreased', + Utils.ratio(ratio), + Utils.ratio(prev), + Utils.diff(prev, ratio) ); fail = true; } - this.supplyExchangeRatio = {totalSuppliedAssets: 0n, totalSuppliedShares: 0n}; // reset + this.supplyExchangeRatio = {totalAddedAssets: 0n, totalAddedShares: 0n}; // reset this.handleFailure(fail, 'invariant_supplyExchangeRateIsNonDecreasing'); } handleFailure(fail: boolean, invariant: string) { if (fail) { - // hub.log(true); - // spokes.forEach((spoke) => spoke.log()); - // users.forEach((user) => user.log()); throw new Error(`${invariant} failed`); } } } class Utils { - static checkTotalDebt(totalDebtBefore: bigint, who: Hub | Spoke | User) { - const totalDebtAfter = who.getTotalDebt(); - const diff = totalDebtAfter - totalDebtBefore; - if (totalDebtAfter > totalDebtBefore && diff > 1n) { - who.log(true); - console.error( - 'totalDebtAfter > totalDebtBefore, diff', - f(totalDebtAfter), - f(totalDebtBefore), - diff - ); - throw new Error('totalDebt increased'); - } - } - static checkBounds(who: Hub | Spoke | User) { - const fail = [ - who.drawnShares, - who.ghostDrawnShares, - who.offset, - who.realisedPremium, - ...(who instanceof Hub - ? [who.suppliedShares, who.totalSupplyAssets(), who.premiumDebt(), who.liquidity] - : []), - ].reduce((flag, v) => flag || v < 0n || v > MAX_UINT, false); + const vals = [who.drawnShares, who.premiumShares]; + if (who instanceof Hub) { + vals.push(who.addedShares, who.liquidity); + } + if (who instanceof User) { + vals.push(who.addedShares); + } + const fail = vals.reduce((flag, v) => flag || v < 0n || v > MAX_UINT, false); if (fail) { who.log(true); throw new Error('underflow/overflow'); } } - static ratio(supplyExchangeRatio: ReturnType) { + static ratio(r: {totalAddedAssets: bigint; totalAddedShares: bigint}) { const precision = 50; + const denom = r.totalAddedShares + VIRTUAL_SHARES; + if (denom === 0n) return '0'; return formatUnits( - (supplyExchangeRatio.totalSuppliedAssets * 10n ** BigInt(precision)) / - supplyExchangeRatio.totalSuppliedShares, + ((r.totalAddedAssets + VIRTUAL_ASSETS) * 10n ** BigInt(precision)) / denom, precision ); } static diff( - a: ReturnType, - b: ReturnType + a: {totalAddedAssets: bigint; totalAddedShares: bigint}, + b: {totalAddedAssets: bigint; totalAddedShares: bigint} ) { const precision = 50; + const denomA = a.totalAddedShares + VIRTUAL_SHARES; + const denomB = b.totalAddedShares + VIRTUAL_SHARES; + if (denomA === 0n || denomB === 0n) return '0'; return formatUnits( - (a.totalSuppliedAssets * 10n ** BigInt(precision)) / a.totalSuppliedShares - - (b.totalSuppliedAssets * 10n ** BigInt(precision)) / b.totalSuppliedShares, + ((a.totalAddedAssets + VIRTUAL_ASSETS) * 10n ** BigInt(precision)) / denomA - + ((b.totalAddedAssets + VIRTUAL_ASSETS) * 10n ** BigInt(precision)) / denomB, precision ); } diff --git a/tests/misc/prototype/invariant.t.ts b/tests/misc/prototype/invariant.t.ts deleted file mode 100644 index cc03ea20a..000000000 --- a/tests/misc/prototype/invariant.t.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {User, skip, System} from './core'; -import {random, randomChance, MAX_UINT, randomAmount, absDiff, f} from './utils'; - -// todo make random deterministic, cache seed, actions list for failed runs for debugging -const NUM_SPOKES = 10; -const NUM_USERS = 3000; -const DEPTH = 1000; - -const actions = ['supply', 'withdraw', 'borrow', 'repay', 'updateRiskPremium']; - -function run() { - const usersSupplied = new Map(); // without accounting for supply yield - const usersDrawn = new Map(); // without accounting for debt interest - let totalAvailable = 0n; // without accounting for supply yield - - const system = new System(NUM_SPOKES, NUM_USERS); - - for (let j = 0; j < DEPTH; j++) { - if (randomChance(0.65)) skip(); - if (randomChance(0.25)) { - system.repayAll(); - usersDrawn.clear(); - } - if (randomChance(0.25)) { - system.withdrawAll(); - usersSupplied.clear(); - } - - const action = actions[Math.floor(Math.random() * actions.length)]; - const user = system.users[Math.floor(Math.random() * system.users.length)]; - let amount = randomAmount(); - - switch (action) { - case 'supply': { - user.supply((amount = system.nonZeroSuppliedShares(amount))); - usersSupplied.set(user, (usersSupplied.get(user) || 0n) + amount); - totalAvailable += amount; - break; - } - case 'withdraw': { - const supplied = usersSupplied.get(user) || 0n; - if (amount > supplied) { - const balanceBefore = user.getSuppliedBalance(); - user.supply((amount = system.nonZeroSuppliedShares(amount))); - const balanceAfter = user.getSuppliedBalance() - amount; - // can have amount - 1 (or dust) supplied balance right after if debt in system due to index - // if (sum(usersDrawn) > 0n) skip(); - // else amount -= 1n; - amount = user.getSuppliedBalance(); - if (sum(usersDrawn) > 0 && randomChance(0.5)) skip(); - if (absDiff(balanceBefore, balanceAfter) > 1n) { - console.log('diff > 1', f(balanceBefore), f(balanceAfter), 'sys debt', sum(usersDrawn)); - } - } else { - usersSupplied.set(user, supplied - amount); - totalAvailable -= amount; - } - console.log('user balance', f(user.getSuppliedBalance()), 'trying to withdraw', f(amount)); - user.withdraw(amount); - break; - } - case 'borrow': { - if (amount > totalAvailable) { - if (totalAvailable < 10n ** 18n) { - user.supply((amount = system.nonZeroSuppliedShares(amount))); - totalAvailable += amount; - if (randomChance(0.5)) skip(); - } else amount = random(1n, totalAvailable); - } - const drawn = usersDrawn.get(user) || 0n; - user.borrow(amount); - usersDrawn.set(user, drawn + amount); - totalAvailable -= amount; - break; - } - case 'repay': { - let drawn = usersDrawn.get(user) || 0n; - if (drawn < amount) { - user.supply((amount = system.nonZeroSuppliedShares(amount))); - user.borrow(amount); - drawn += amount; - amount = random(1n, user.getTotalDebt()); - if (randomChance(0.5)) skip(); - } - user.repay(amount); - usersDrawn.set(user, drawn - amount); - totalAvailable += amount; - break; - } - case 'updateRiskPremium': { - user.updateRiskPremium(); - break; - } - } - - system.runInvariants(); - } - - system.hub.log(); - - system.repayAll(); - system.withdrawAll(); - - system.hub.log(); - - console.log(`ran ${DEPTH} iterations with ${NUM_SPOKES} spokes and ${NUM_USERS} users`); -} - -run(); - -function sum(map: Map) { - return Array.from(map.values()).reduce((a, b) => a + b, 0n); -} diff --git a/tests/misc/prototype/invariant.test.ts b/tests/misc/prototype/invariant.test.ts new file mode 100644 index 000000000..2e25489f0 --- /dev/null +++ b/tests/misc/prototype/invariant.test.ts @@ -0,0 +1,121 @@ +import {test} from 'bun:test'; +import {User, skip, System} from './core'; +import {random, randomChance, MAX_UINT, randomAmount, absDiff, f} from './utils'; + +const NUM_SPOKES = 3; +const NUM_USERS = 10; +const DEPTH = 200; + +const actions = ['supply', 'withdraw', 'borrow', 'repay', 'updateRiskPremium']; + +test( + `invariant fuzz (${DEPTH} iterations, ${NUM_SPOKES} spokes, ${NUM_USERS} users)`, + () => { + const usersSupplied = new Map(); + const usersDrawn = new Map(); + let totalAvailable = 0n; + + const system = new System(NUM_SPOKES, NUM_USERS); + + for (let j = 0; j < DEPTH; j++) { + if (randomChance(0.65)) skip(); + if (randomChance(0.25)) { + system.repayAll(); + usersDrawn.clear(); + } + if (randomChance(0.25)) { + system.withdrawAll(); + usersSupplied.clear(); + } + + const action = actions[Math.floor(Math.random() * actions.length)]; + const user = system.users[Math.floor(Math.random() * system.users.length)]; + let amount = randomAmount(); + + switch (action) { + case 'supply': { + user.supply((amount = system.nonZeroAddedShares(amount))); + usersSupplied.set(user, (usersSupplied.get(user) || 0n) + amount); + totalAvailable += amount; + break; + } + case 'withdraw': { + const supplied = usersSupplied.get(user) || 0n; + if (amount > supplied) { + const balanceBefore = user.getSuppliedBalance(); + user.supply((amount = system.nonZeroAddedShares(amount))); + const balanceAfter = user.getSuppliedBalance() - amount; + amount = user.getSuppliedBalance(); + if (sum(usersDrawn) > 0 && randomChance(0.5)) skip(); + if (absDiff(balanceBefore, balanceAfter) > 1n) { + console.log( + 'diff > 1', + f(balanceBefore), + f(balanceAfter), + 'sys debt', + sum(usersDrawn) + ); + } + } else { + usersSupplied.set(user, supplied - amount); + totalAvailable -= amount; + } + console.log( + 'user balance', + f(user.getSuppliedBalance()), + 'trying to withdraw', + f(amount) + ); + user.withdraw(amount); + break; + } + case 'borrow': { + if (amount > totalAvailable) { + if (totalAvailable < 10n ** 18n) { + user.supply((amount = system.nonZeroAddedShares(amount))); + totalAvailable += amount; + if (randomChance(0.5)) skip(); + } else amount = random(1n, totalAvailable); + } + if (amount > system.hub.liquidity) { + if (system.hub.liquidity === 0n) break; + amount = random(1n, system.hub.liquidity); + } + const drawn = usersDrawn.get(user) || 0n; + user.borrow(amount); + usersDrawn.set(user, drawn + amount); + totalAvailable -= amount; + break; + } + case 'repay': { + let drawn = usersDrawn.get(user) || 0n; + if (drawn < amount) { + user.supply((amount = system.nonZeroAddedShares(amount))); + user.borrow(amount); + drawn += amount; + amount = random(1n, user.getTotalDebt()); + if (randomChance(0.5)) skip(); + } + user.repay(amount); + usersDrawn.set(user, drawn - amount); + totalAvailable += amount; + break; + } + case 'updateRiskPremium': { + user.updateRiskPremium(); + break; + } + } + + system.runInvariants(); + } + + system.repayAll(); + system.withdrawAll(); + }, + 120_000 +); + +function sum(map: Map) { + return Array.from(map.values()).reduce((a, b) => a + b, 0n); +} diff --git a/tests/misc/prototype/scenario.t.ts b/tests/misc/prototype/scenario.t.ts deleted file mode 100644 index e06c4e6a7..000000000 --- a/tests/misc/prototype/scenario.t.ts +++ /dev/null @@ -1,261 +0,0 @@ -import {skip} from './core'; -import { - f, - MAX_UINT, - p, - it, - runScenarios, - randomIndex, - rayDiv, - rayMul, - Rounding, - absDiff, -} from './utils'; - -it()((ctx) => { - const [alice, bob, charlie] = ctx.users; - const amount1 = p('10000'); - const amount2 = p('200'); - const amount3 = p('500'); - - alice.supply(amount1); - alice.borrow(amount1); - skip(); - alice.repay(amount2); - bob.borrow(amount2); - skip(); - alice.repay(amount3); - charlie.borrow(amount3); - alice.repay(amount3); - skip(); - charlie.borrow(amount3); - skip(); - alice.repay(MAX_UINT); - skip(); - charlie.repay(MAX_UINT); - skip(); - bob.repay(MAX_UINT); - skip(); - alice.withdraw(amount2); - skip(); - alice.withdraw(alice.getSuppliedBalance()); - - alice.log(true, true); -}); - -it()((ctx) => { - const [alice] = ctx.users; - const amount = p(1000); - - alice.supply(amount); - alice.borrow(amount); - - alice.log(true, true); - - alice.repay(amount); - alice.log(true, true); - - alice.repay(MAX_UINT); -}); - -it()((ctx) => { - const [alice, bob, charlie] = ctx.users; - - const amount1 = p('1000'); - alice.supply(amount1); - alice.borrow(amount1); - - skip(); - - alice.log(true, true); - alice.repay(MAX_UINT); - alice.log(true, true); - - const amount2 = p('1000'); - bob.borrow(amount2); - skip(); - - bob.repay(MAX_UINT); - - skip(); - const amount4 = p('700'); - charlie.borrow(amount4); - - skip(); - charlie.repay(amount4); - charlie.log(true, true); - - skip(); - // charlie.log(true, true); - charlie.repay(MAX_UINT); - // charlie.log(true, true); -}); - -it()((ctx) => { - const [alice, bob, charlie] = ctx.users; - - const amount1 = p('10000'); - const amount2 = p('200'); - const amount3 = p('500'); - - alice.supply(amount1); - alice.borrow(amount1); - - skip(); - alice.repay(amount2); - bob.borrow(amount2); - - alice.updateRiskPremium(); - - skip(); - alice.repay(amount3); - charlie.borrow(amount3); - alice.repay(amount3); - - skip(); - charlie.borrow(amount3); - - skip(); - alice.repay(MAX_UINT); - - skip(); - charlie.repay(MAX_UINT); - - skip(); - bob.repay(MAX_UINT); -}); - -it()((ctx) => { - const [alice, bob, charlie] = ctx.users; - - const amount1 = p('10000'); - const amount2 = p('200'); - const amount3 = p('500'); - - alice.supply(amount1); - - skip(); - bob.borrow(amount2); - bob.supply(amount3); - - skip(); - charlie.supply(amount3); - charlie.borrow(amount2); - - skip(); - charlie.repay(MAX_UINT); - bob.repay(MAX_UINT); - - skip(); - charlie.withdraw(MAX_UINT); - bob.withdraw(MAX_UINT); - alice.withdraw(MAX_UINT); - - alice.supply(amount1); - - skip(); - bob.borrow(amount2); - bob.supply(amount3); - - skip(); - charlie.supply(amount3); - charlie.borrow(amount2); - - skip(); - charlie.repay(MAX_UINT); - bob.repay(MAX_UINT); - - skip(); - charlie.withdraw(MAX_UINT); - bob.withdraw(MAX_UINT); - alice.withdraw(MAX_UINT); -}); - -it('6 supply yields -1 bc of index').skip((ctx) => { - const [alice, bob] = ctx.users; - const amount = p(100); - const amount2 = p(500); - - bob.supply(amount2); - // skip(); - bob.withdraw(amount2 / 2n); - // skip(); - bob.borrow(amount2 / 2n); - - alice.supply(amount); - // skip(); - console.log('alice supplied amount', f(alice.getSuppliedBalance()), f(amount)); - try { - alice.withdraw(amount); - } catch (e) { - if (!e.message.includes('suppliedShares < 0 || > MAX_UINT')) throw e; - } - // alice.withdraw(alice.getSuppliedBalance()); -}); - -it('7 underflow bc sum of scaled may not to equate to individual scaled when all are unscaled')( - (ctx) => { - const [alice, bob, carol] = ctx.users; - alice.supply(47168n); - - bob.borrow(22592n); - alice.borrow(12739n); - - carol.borrow(11837n); - - skip(); - - bob.repay(1714n); - alice.repay(9n); - - carol.repay(1255n); - } -); - -it('index')((ctx) => { - const index = randomIndex(); // 1645169034437660970422632448n, 1370571970449003121502846976n - console.log('index', index); - const scale = (amount: bigint) => rayDiv(amount, index, Rounding.CEIL); - const unscale = (scaled: bigint) => rayMul(scaled, index, Rounding.CEIL); // toggle - - const amountA = 23232n; - const scaledA = scale(amountA); - console.log('unscaled A ', unscale(scaledA), amountA); - - const amountB = 3243n; - const scaledB = scale(amountB); - console.log('unscaled B ', unscale(scaledB), amountB); - - console.log('unscaled global', unscale(scaledA + scaledB), amountA + amountB); - console.log('unscaled sum ', unscale(scaledA) + unscale(scaledB), amountA + amountB); -}); - -it().skip((ctx) => { - const [alice, bob] = ctx.users; - const amount = p('0.176772459072625441'); - alice.supply(amount); - alice.borrow(amount); - - skip(); - - alice.repay(p('0.021185397759087569')); - - console.log('alice balance', f(alice.getSuppliedBalance())); - alice.withdraw(p('0.437902789221420415')); -}); - -it('repay deduction')((ctx) => { - const [alice] = ctx.users; - const amount = p('0.000000001620580722'); - alice.supply(amount); - alice.borrow(amount); - - skip(); - - const aliceDebtBefore = alice.getTotalDebt(); - alice.repay(amount / 2n); - const delta = aliceDebtBefore - alice.getTotalDebt(); - console.log('restored actual', f(delta), 'expected', f(amount / 2n), 'diff', delta - amount / 2n); -}); - -runScenarios(); diff --git a/tests/misc/prototype/scenario.test.ts b/tests/misc/prototype/scenario.test.ts new file mode 100644 index 000000000..086463cb2 --- /dev/null +++ b/tests/misc/prototype/scenario.test.ts @@ -0,0 +1,233 @@ +import {describe, test} from 'bun:test'; +import {System, skip} from './core'; +import { + f, + MAX_UINT, + p, + randomIndex, + rayDiv, + rayMul, + Rounding, + absDiff, +} from './utils'; + +function scenario(name: string, numSpokes = 1, numUsers = 3) { + return (fn: (ctx: System) => void) => { + test(name, () => { + const ctx = new System(numSpokes, numUsers); + fn(ctx); + ctx.runInvariants(); + }); + }; +} + +describe('scenarios', () => { + scenario('supply borrow repay withdraw multi-user')((ctx) => { + const [alice, bob, charlie] = ctx.users; + const amount1 = p('10000'); + const amount2 = p('200'); + const amount3 = p('500'); + + alice.supply(amount1); + alice.borrow(amount1); + skip(); + alice.repay(amount2); + bob.borrow(amount2); + skip(); + alice.repay(amount3); + charlie.borrow(amount3); + alice.repay(amount3); + skip(); + charlie.borrow(amount3); + skip(); + alice.repay(MAX_UINT); + skip(); + charlie.repay(MAX_UINT); + skip(); + bob.repay(MAX_UINT); + skip(); + alice.withdraw(amount2); + skip(); + alice.withdraw(alice.getSuppliedBalance()); + }); + + scenario('supply borrow repay single user')((ctx) => { + const [alice] = ctx.users; + const amount = p(1000); + + alice.supply(amount); + alice.borrow(amount); + alice.repay(amount); + alice.repay(MAX_UINT); + }); + + scenario('sequential borrow repay across users')((ctx) => { + const [alice, bob, charlie] = ctx.users; + + const amount1 = p('1000'); + alice.supply(amount1); + alice.borrow(amount1); + + skip(); + + alice.repay(MAX_UINT); + + const amount2 = p('1000'); + bob.borrow(amount2); + skip(); + + bob.repay(MAX_UINT); + + skip(); + const amount4 = p('700'); + charlie.borrow(amount4); + + skip(); + charlie.repay(amount4); + + skip(); + charlie.repay(MAX_UINT); + }); + + scenario('risk premium update mid-flow')((ctx) => { + const [alice, bob, charlie] = ctx.users; + + const amount1 = p('10000'); + const amount2 = p('200'); + const amount3 = p('500'); + + alice.supply(amount1); + alice.borrow(amount1); + + skip(); + alice.repay(amount2); + bob.borrow(amount2); + + alice.updateRiskPremium(); + + skip(); + alice.repay(amount3); + charlie.borrow(amount3); + alice.repay(amount3); + + skip(); + charlie.borrow(amount3); + + skip(); + alice.repay(MAX_UINT); + + skip(); + charlie.repay(MAX_UINT); + + skip(); + bob.repay(MAX_UINT); + }); + + scenario('full cycle supply borrow repay withdraw twice')((ctx) => { + const [alice, bob, charlie] = ctx.users; + + const amount1 = p('10000'); + const amount2 = p('200'); + const amount3 = p('500'); + + for (let i = 0; i < 2; i++) { + alice.supply(amount1); + + skip(); + bob.borrow(amount2); + bob.supply(amount3); + + skip(); + charlie.supply(amount3); + charlie.borrow(amount2); + + skip(); + charlie.repay(MAX_UINT); + bob.repay(MAX_UINT); + + skip(); + charlie.withdraw(MAX_UINT); + bob.withdraw(MAX_UINT); + alice.withdraw(MAX_UINT); + } + }); + + test.skip('supply yields -1 bc of index', () => { + const ctx = new System(1, 3); + const [alice, bob] = ctx.users; + const amount = p(100); + const amount2 = p(500); + + bob.supply(amount2); + bob.withdraw(amount2 / 2n); + bob.borrow(amount2 / 2n); + + alice.supply(amount); + try { + alice.withdraw(amount); + } catch (e: any) { + if (!e.message.includes('addedShares') && !e.message.includes('underflow')) throw e; + } + }); + + scenario('underflow bc sum of scaled may not equate to individual scaled')((ctx) => { + const [alice, bob, carol] = ctx.users; + alice.supply(47168n); + + bob.borrow(22592n); + alice.borrow(12739n); + + carol.borrow(11837n); + + skip(); + + bob.repay(1714n); + alice.repay(9n); + + carol.repay(1255n); + }); + + test('index scaling roundtrip', () => { + const index = randomIndex(); + const scale = (amount: bigint) => rayDiv(amount, index, Rounding.CEIL); + const unscale = (scaled: bigint) => rayMul(scaled, index, Rounding.CEIL); + + const amountA = 23232n; + const scaledA = scale(amountA); + // ceil(ceil(a / idx) * idx) >= a + if (unscale(scaledA) < amountA) throw new Error('unscale(scale(a)) < a'); + + const amountB = 3243n; + const scaledB = scale(amountB); + if (unscale(scaledB) < amountB) throw new Error('unscale(scale(b)) < b'); + }); + + test.skip('withdraw more than supplied', () => { + const ctx = new System(1, 3); + const [alice] = ctx.users; + const amount = p('0.176772459072625441'); + alice.supply(amount); + alice.borrow(amount); + + skip(); + + alice.repay(p('0.021185397759087569')); + alice.withdraw(p('0.437902789221420415')); + }); + + scenario('repay deduction accuracy')((ctx) => { + const [alice] = ctx.users; + const amount = p('0.000000001620580722'); + alice.supply(amount); + alice.borrow(amount); + + skip(); + + const aliceDebtBefore = alice.getTotalDebt(); + alice.repay(amount / 2n); + const delta = aliceDebtBefore - alice.getTotalDebt(); + if (absDiff(delta, amount / 2n) > 1n) { + throw new Error(`repay deduction off by ${delta - amount / 2n}`); + } + }); +}); diff --git a/tests/misc/prototype/utils.ts b/tests/misc/prototype/utils.ts index 4eec2cc23..d6e4b9204 100644 --- a/tests/misc/prototype/utils.ts +++ b/tests/misc/prototype/utils.ts @@ -1,4 +1,4 @@ -import {User, Spoke, Hub, System} from './core.ts'; +import {User, Spoke, Hub} from './core.ts'; export const DEBUG = true; const SEED = 4333; @@ -15,6 +15,9 @@ export const WAD = 10n ** 18n; export const PERCENTAGE_FACTOR = 100_00n; export const MAX_UINT = 2n ** 256n - 1n; +export const VIRTUAL_ASSETS = 10n ** 6n; +export const VIRTUAL_SHARES = 10n ** 6n; + export const MIN_RP = 0n; export const MAX_RP = 1000_00n; export const MIN_INDEX = parseRay(1.01); // 1% interest @@ -22,19 +25,117 @@ export const MAX_INDEX = parseRay(1.99); // 99% interest export const PRECISION = 3000n; // max abs delta allowed + +export interface PremiumDelta { + sharesDelta: bigint; // signed + offsetRayDelta: bigint; // signed + restoredPremiumRay: bigint; // unsigned +} + + +export function rayMulDown(a: bigint, b: bigint): bigint { + return (a * b) / RAY; +} + +export function rayMulUp(a: bigint, b: bigint): bigint { + const product = a * b; + return product / RAY + (product % RAY > 0n ? 1n : 0n); +} + +export function rayDivDown(a: bigint, b: bigint): bigint { + return (a * RAY) / b; +} + +export function rayDivUp(a: bigint, b: bigint): bigint { + const product = a * RAY; + return product / b + (product % b > 0n ? 1n : 0n); +} + +export function fromRayUp(a: bigint): bigint { + if (a === 0n) return 0n; + return a / RAY + (a % RAY > 0n ? 1n : 0n); +} + +export function toRay(a: bigint): bigint { + return a * RAY; +} + +export function percentMulDown(a: bigint, b: bigint): bigint { + return (a * b) / PERCENTAGE_FACTOR; +} + +export function percentMulUp(a: bigint, b: bigint): bigint { + const product = a * b; + return product / PERCENTAGE_FACTOR + (product % PERCENTAGE_FACTOR > 0n ? 1n : 0n); +} + +export function signedSub(a: bigint, b: bigint): bigint { + return a - b; // JS bigint handles sign naturally +} + +export function addSigned(a: bigint, b: bigint): bigint { + const result = a + b; + if (result < 0n) throw new Error('addSigned: underflow'); + return result; +} + + +export function toSharesDown(assets: bigint, totalAssets: bigint, totalShares: bigint): bigint { + return mulDiv(assets, totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS, Rounding.FLOOR); +} + +export function toSharesUp(assets: bigint, totalAssets: bigint, totalShares: bigint): bigint { + return mulDiv(assets, totalShares + VIRTUAL_SHARES, totalAssets + VIRTUAL_ASSETS, Rounding.CEIL); +} + +export function toAssetsDown(shares: bigint, totalAssets: bigint, totalShares: bigint): bigint { + return mulDiv(shares, totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES, Rounding.FLOOR); +} + +export function toAssetsUp(shares: bigint, totalAssets: bigint, totalShares: bigint): bigint { + return mulDiv(shares, totalAssets + VIRTUAL_ASSETS, totalShares + VIRTUAL_SHARES, Rounding.CEIL); +} + + +export function calculatePremiumRay( + premiumShares: bigint, + premiumOffsetRay: bigint, // signed + drawnIndex: bigint +): bigint { + const result = premiumShares * drawnIndex - premiumOffsetRay; + if (result < 0n) throw new Error(`calculatePremiumRay: negative premium (${result})`); + return result; +} + + +export function rayMul(a: bigint, b: bigint, rounding = Rounding.FLOOR) { + return mulDiv(a, b, RAY, rounding); +} +export function rayDiv(a: bigint, b: bigint, rounding = Rounding.FLOOR) { + return mulDiv(a, RAY, b, rounding); +} + +export function percentMul(a: bigint, b: bigint, rounding = Rounding.FLOOR) { + return mulDiv(a, b, PERCENTAGE_FACTOR, rounding); +} + + export function logDebt(who: User | Spoke | Hub) { const hub = who instanceof Hub ? who : who.hub; + const drawnIndex = hub.drawnIndex; + const premiumRay = calculatePremiumRay(who.premiumShares, who.premiumOffsetRay, drawnIndex); console.log( - 'debt: base %d + premium %d (ghost %d, offset %d, unrealised %d) = %d', + 'debt: base %d + premium %d (premiumShares %d, premiumOffsetRay %d, premiumRay %d) = %d', f(who.getDebt().drawnDebt), f(who.getDebt().premiumDebt), - f(hub.toDrawnAssets(who.ghostDrawnShares)), - f(who.offset), - f(who.realisedPremium), + f(who.premiumShares), + who.premiumOffsetRay, + premiumRay, f(who.getTotalDebt()) ); } + export function assertNonZero(a: bigint) { if (a === 0n) throw new Error('got zero'); } @@ -42,6 +143,7 @@ export function assertGeZero(a: bigint) { if (a < 0n) throw new Error('got negative'); } + export function random(min: bigint, max: bigint) { return BigInt(Math.floor(Math.random() * Number(max - min))) + min; } @@ -91,6 +193,7 @@ export function inverse(rounding: Rounding) { throw new Error('cannot inverse rounding'); } + export function parseEther(ether: string | bigint | number) { return parseUnits(ether, 18); } @@ -119,17 +222,6 @@ export function p(ether: string | bigint | number) { return parseEther(ether); } -export function percentMul(a: bigint, b: bigint, rounding = Rounding.FLOOR) { - return mulDiv(a, b, PERCENTAGE_FACTOR, rounding); -} - -export function rayMul(a: bigint, b: bigint, rounding = Rounding.FLOOR) { - return mulDiv(a, b, RAY, rounding); -} -export function rayDiv(a: bigint, b: bigint, rounding = Rounding.FLOOR) { - return mulDiv(a, RAY, b, rounding); -} - export function formatUnits(wei: bigint, index = 18): string { const abs = wei < 0n ? -wei : wei; const UNITS = 10n ** BigInt(index); @@ -145,7 +237,6 @@ export function parseUnits(units: string | bigint | number, index = 18): bigint return BigInt(whole) * 10n ** BigInt(index) + BigInt(paddedFractional.slice(0, index)); } -// @dev Calculates (a * b) / c, with specified rounding direction export function mulDiv(a: bigint, b: bigint, c: bigint, rounding: Rounding) { const prod = a * b; const quotient = prod / c; @@ -172,69 +263,6 @@ export function mulDiv(a: bigint, b: bigint, c: bigint, rounding: Rounding) { } } -// scenario engine -let scenarioId = 1; -let skipped = 0; -type Runner = (ctx: System) => void; -interface Scenario { - name: string; - ctx: System; - runInvariants: boolean; - fn: Runner; -} - -export const scenarios: Array = []; -export function it( - name = `Scenario ${scenarioId}`, - runInvariants = true, - numSpokes = 1, - numUsers = 3 -) { - const ctx = new System(numSpokes, numUsers); - const runner = (fn: Runner) => { - scenarios.push({name, fn, ctx, runInvariants}); - scenarioId++; - }; - runner.skip = (_: Runner) => { - skipped++; - }; - return runner; -} -export function runScenarios() { - const filter = parseFilter(); - let passed = 0, - failed = 0; - scenarios - .filter((s) => filter.test(s.name)) - .forEach(({name, fn, ctx, runInvariants}) => { - console.log(`\t\t running scenario ${name} \t\t\n`); - try { - fn(ctx); - if (runInvariants) ctx.runInvariants(); - passed++; - } catch (e) { - failed++; - console.error(`\t\t scenario ${name} failed \t\t`); - console.error(e); - } - console.log(); - }); - console.log(`scenario run finished: ${passed} passed, ${failed} failed, ${skipped} skipped`); -} - -function parseFilter() { - let filter = ''; - // @ts-ignore - const args = process.argv.slice(2); - for (let i = 0; i < args.length; ++i) { - switch (args[i]) { - case '--mt': - filter = args[i + 1] || ''; - } - } - return new RegExp(filter, 'gi'); -} - export function info(...args: any[]) { if (DEBUG) console.info(...args.filter((a) => !!a)); }