diff --git a/docs/threat_models/vaultFactory/vaultFactory.puml b/docs/threat_models/vaultFactory/vaultFactory.puml index 5015871d45b..068e98fad38 100644 --- a/docs/threat_models/vaultFactory/vaultFactory.puml +++ b/docs/threat_models/vaultFactory/vaultFactory.puml @@ -41,8 +41,6 @@ node "Vat" { circle getDebtAmount circle getLiquidationSeat getLiquidationSeat -u-> LiquidationSeat - circle getLiquidationPromise - getLiquidationPromise -u-> LiquidationPromise } } Borrower -> makeLoanInvitation: open vault and transfer collateral diff --git a/packages/run-protocol/README.md b/packages/run-protocol/README.md new file mode 100644 index 00000000000..c30e1321801 --- /dev/null +++ b/packages/run-protocol/README.md @@ -0,0 +1,27 @@ +# RUN protocol + +## Overview + +RUN is a stable token that enables the core of the Agoric economy. + +By convention there is one well-known **VaultFactory**. By governance it creates a **VaultManager** for each type of asset that can serve as collateral to mint RUN. + +Anyone can make a **Vault** by putting up collateral with the appropriate VaultManager. Then +they can request RUN that is backed by that collateral. + +In any vault, when the ratio of the debt to the collateral exceeds a governed threshold, it is +deemed undercollateralized. If the result of a price check shows that a vault is +undercollateralized, the VaultManager liquidates it. +## Persistence + +The above states are robust to system restarts and upgrades. This is accomplished using the Agoric (Endo?) Collections API. + +## Debts + +Debts are denominated in µRUN. (1 million µRUN = 1 RUN) + +Each interest charging period (say daily) the actual debts in all vaults are affected. Materializing that across all vaults would be O(n) writes. Instead, to make charging interest O(1) we virtualize the debt that a vault owes to be a function of stable vault attributes and values that change in the vault manager when it charges interest. Specifically, +- a compoundedInterest value on the manager that keeps track of interest accrual since its launch +- a debtSnapshot on the vault by which one can calculate the actual debt + +To maintain that the keys of vaults to liquidate are stable requires that its keys are also time-independent so they're recorded as a "normalized collateralization ratio", with the actual collateral divided by the normalized debt. diff --git a/packages/run-protocol/src/vaultFactory/interest.js b/packages/run-protocol/src/vaultFactory/interest.js index 02039391729..9866976dc65 100644 --- a/packages/run-protocol/src/vaultFactory/interest.js +++ b/packages/run-protocol/src/vaultFactory/interest.js @@ -2,18 +2,13 @@ import '@agoric/zoe/exported.js'; import '@agoric/zoe/src/contracts/callSpread/types.js'; -import './types.js'; +import { natSafeMath } from '@agoric/zoe/src/contractSupport/index.js'; import { - ceilMultiplyBy, makeRatio, + multiplyRatios, + quantize, } from '@agoric/zoe/src/contractSupport/ratio.js'; -import { AmountMath } from '@agoric/ertp'; - -const makeResult = (latestInterestUpdate, interest, newDebt) => ({ - latestInterestUpdate, - interest, - newDebt, -}); +import './types.js'; export const SECONDS_PER_YEAR = 60n * 60n * 24n * 365n; const BASIS_POINTS = 10000; @@ -21,14 +16,17 @@ const BASIS_POINTS = 10000; const LARGE_DENOMINATOR = BASIS_POINTS * BASIS_POINTS; /** - * @param {Brand} brand + * Number chosen from 6 digits for a basis point, doubled for multiplication. + */ +const COMPOUNDED_INTEREST_DENOMINATOR = 10n ** 20n; + +/** * @param {Ratio} annualRate * @param {RelativeTime} chargingPeriod * @param {RelativeTime} recordingPeriod * @returns {CalculatorKit} */ export const makeInterestCalculator = ( - brand, annualRate, chargingPeriod, recordingPeriod, @@ -47,8 +45,11 @@ export const makeInterestCalculator = ( BigInt(LARGE_DENOMINATOR), ); - // Calculate new debt for charging periods up to the present. - /** @type {Calculate} */ + /** + * Calculate new debt for charging periods up to the present. + * + * @type {Calculate} + */ const calculate = (debtStatus, currentTime) => { const { newDebt, latestInterestUpdate } = debtStatus; let newRecent = latestInterestUpdate; @@ -56,18 +57,29 @@ export const makeInterestCalculator = ( let growingDebt = newDebt; while (newRecent + chargingPeriod <= currentTime) { newRecent += chargingPeriod; - const newInterest = ceilMultiplyBy(growingDebt, ratePerChargingPeriod); - growingInterest = AmountMath.add(growingInterest, newInterest); - growingDebt = AmountMath.add(growingDebt, newInterest, brand); + // The `ceil` implies that a vault with any debt will accrue at least one µRUN. + const newInterest = natSafeMath.ceilDivide( + growingDebt * ratePerChargingPeriod.numerator.value, + ratePerChargingPeriod.denominator.value, + ); + growingInterest += newInterest; + growingDebt += newInterest; } - return makeResult(newRecent, growingInterest, growingDebt); + return { + latestInterestUpdate: newRecent, + interest: growingInterest, + newDebt: growingDebt, + }; }; - // Calculate new debt for reporting periods up to the present. If some - // charging periods have elapsed that don't constitute whole reporting - // periods, the time is not updated past them and interest is not accumulated - // for them. - /** @type {Calculate} */ + /** + * Calculate new debt for reporting periods up to the present. If some + * charging periods have elapsed that don't constitute whole reporting + * periods, the time is not updated past them and interest is not accumulated + * for them. + * + * @type {Calculate} + */ const calculateReportingPeriod = (debtStatus, currentTime) => { const { latestInterestUpdate } = debtStatus; const overshoot = (currentTime - latestInterestUpdate) % recordingPeriod; @@ -79,3 +91,23 @@ export const makeInterestCalculator = ( calculateReportingPeriod, }); }; + +/** + * compoundedInterest *= (new debt) / (prior total debt) + * + * @param {Ratio} priorCompoundedInterest + * @param {NatValue} priorDebt + * @param {NatValue} newDebt + */ +export const calculateCompoundedInterest = ( + priorCompoundedInterest, + priorDebt, + newDebt, +) => { + const brand = priorCompoundedInterest.numerator.brand; + const compounded = multiplyRatios( + priorCompoundedInterest, + makeRatio(newDebt, brand, priorDebt, brand), + ); + return quantize(compounded, COMPOUNDED_INTEREST_DENOMINATOR); +}; diff --git a/packages/run-protocol/src/vaultFactory/liquidation.js b/packages/run-protocol/src/vaultFactory/liquidation.js index 48bebef894b..90e9972d963 100644 --- a/packages/run-protocol/src/vaultFactory/liquidation.js +++ b/packages/run-protocol/src/vaultFactory/liquidation.js @@ -15,7 +15,14 @@ const trace = makeTracer('LIQ'); * Once collateral has been sold using the contract, we burn the amount * necessary to cover the debt and return the remainder. * - * @type {VaultFactoryLiquidate} + * @param {ContractFacet} zcf + * @param {VaultKit} vaultKit + * @param {(losses: AmountKeywordRecord, + * zcfSeat: ZCFSeat + * ) => void} burnLosses + * @param {LiquidationStrategy} strategy + * @param {Brand} collateralBrand + * @returns {Promise} */ const liquidate = async ( zcf, @@ -24,7 +31,7 @@ const liquidate = async ( strategy, collateralBrand, ) => { - vaultKit.liquidating(); + vaultKit.actions.liquidating(); const runDebt = vaultKit.vault.getDebtAmount(); const { brand: runBrand } = runDebt; const { vaultSeat, liquidationZcfSeat: liquidationSeat } = vaultKit; @@ -57,12 +64,13 @@ const liquidate = async ( const isUnderwater = !AmountMath.isGTE(runProceedsAmount, runDebt); const runToBurn = isUnderwater ? runProceedsAmount : runDebt; burnLosses(harden({ RUN: runToBurn }), liquidationSeat); - vaultKit.liquidated(AmountMath.subtract(runDebt, runToBurn)); + vaultKit.actions.liquidated(AmountMath.subtract(runDebt, runToBurn)); // any remaining RUN plus anything else leftover from the sale are refunded vaultSeat.exit(); liquidationSeat.exit(); - vaultKit.liquidationPromiseKit.resolve('Liquidated'); + + return vaultKit.vault; }; /** diff --git a/packages/run-protocol/src/vaultFactory/orderedVaultStore.js b/packages/run-protocol/src/vaultFactory/orderedVaultStore.js new file mode 100644 index 00000000000..edd2aed0dfa --- /dev/null +++ b/packages/run-protocol/src/vaultFactory/orderedVaultStore.js @@ -0,0 +1,71 @@ +// @ts-check +// XXX avoid deep imports https://github.com/Agoric/agoric-sdk/issues/4255#issuecomment-1032117527 +import { makeScalarBigMapStore } from '@agoric/swingset-vat/src/storeModule.js'; +import { fromVaultKey, toVaultKey } from './storeUtils.js'; + +/** + * Used by prioritizedVaults to wrap the Collections API for this use case. + * + * Designed to be replaceable by naked Collections API when composite keys are available. + * + * In this module debts are encoded as the inverse quotient (collateral over debt) so that + * greater collaterization sorts after lower. (Higher debt-to-collateral come + * first.) + */ + +/** @typedef {import('./vault').VaultKit} VaultKit */ +/** @typedef {import('./storeUtils').CompositeKey} CompositeKey */ + +export const makeOrderedVaultStore = () => { + // TODO make it work durably https://github.com/Agoric/agoric-sdk/issues/4550 + /** @type {MapStore} */ + const store = makeScalarBigMapStore('orderedVaultStore', { durable: false }); + + /** + * + * @param {string} vaultId + * @param {VaultKit} vaultKit + */ + const addVaultKit = (vaultId, vaultKit) => { + const { vault } = vaultKit; + const debt = vault.getDebtAmount(); + const collateral = vault.getCollateralAmount(); + const key = toVaultKey(debt, collateral, vaultId); + store.init(key, vaultKit); + return key; + }; + + /** + * + * @param {string} key + * @returns {VaultKit} + */ + const removeByKey = key => { + try { + const vaultKit = store.get(key); + assert(vaultKit); + store.delete(key); + return vaultKit; + } catch (e) { + const keys = Array.from(store.keys()); + console.error( + 'removeByKey failed to remove', + key, + 'parts:', + fromVaultKey(key), + ); + console.error(' key literals:', keys); + console.error(' key parts:', keys.map(fromVaultKey)); + throw e; + } + }; + + return harden({ + addVaultKit, + removeByKey, + keys: store.keys, + entries: store.entries, + getSize: store.getSize, + values: store.values, + }); +}; diff --git a/packages/run-protocol/src/vaultFactory/prioritizedVaults.js b/packages/run-protocol/src/vaultFactory/prioritizedVaults.js index 72ba2934952..3e0cf0cb22c 100644 --- a/packages/run-protocol/src/vaultFactory/prioritizedVaults.js +++ b/packages/run-protocol/src/vaultFactory/prioritizedVaults.js @@ -1,33 +1,19 @@ // @ts-check -import { observeNotifier } from '@agoric/notifier'; -import { - natSafeMath, - makeRatioFromAmounts, -} from '@agoric/zoe/src/contractSupport/index.js'; -import { assert } from '@agoric/assert'; +import { makeRatioFromAmounts } from '@agoric/zoe/src/contractSupport/index.js'; import { AmountMath } from '@agoric/ertp'; - -const { multiply, isGTE } = natSafeMath; - -// Stores a collection of Vaults, pretending to be indexed by ratio of -// debt to collateral. Once performance is an issue, this should use Virtual -// Objects. For now, it uses a Map (Vault->debtToCollateral). -// debtToCollateral (which is not the collateralizationRatio) is updated using -// an observer on the UIState. - -const ratioGTE = (left, right) => { - assert( - left.numerator.brand === right.numerator.brand && - left.denominator.brand === right.denominator.brand, - `brands must match`, - ); - return isGTE( - multiply(left.numerator.value, right.denominator.value), - multiply(right.numerator.value, left.denominator.value), - ); -}; - +import { ratioGTE } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { makeOrderedVaultStore } from './orderedVaultStore.js'; +import { toVaultKey } from './storeUtils.js'; + +/** @typedef {import('./vault').VaultKit} VaultKit */ + +/** + * + * @param {Amount} debtAmount + * @param {Amount} collateralAmount + * @returns {Ratio} + */ const calculateDebtToCollateral = (debtAmount, collateralAmount) => { if (AmountMath.isEmpty(collateralAmount)) { return makeRatioFromAmounts( @@ -38,36 +24,24 @@ const calculateDebtToCollateral = (debtAmount, collateralAmount) => { return makeRatioFromAmounts(debtAmount, collateralAmount); }; -const currentDebtToCollateral = vaultKit => - calculateDebtToCollateral( - vaultKit.vault.getDebtAmount(), - vaultKit.vault.getCollateralAmount(), - ); - -const compareVaultKits = (leftVaultPair, rightVaultPair) => { - const leftVaultRatio = leftVaultPair.debtToCollateral; - const rightVaultRatio = rightVaultPair.debtToCollateral; - const leftGTERight = ratioGTE(leftVaultRatio, rightVaultRatio); - const rightGTEleft = ratioGTE(rightVaultRatio, leftVaultRatio); - if (leftGTERight && rightGTEleft) { - return 0; - } else if (leftGTERight) { - return -1; - } else if (rightGTEleft) { - return 1; - } - throw Error("The vault's collateral ratios are not comparable"); -}; - -// makePrioritizedVaults() takes a function parameter, which will be called when -// there is a new least-collateralized vault. - +/** + * + * @param {Vault} vault + * @returns {Ratio} + */ +export const currentDebtToCollateral = vault => + calculateDebtToCollateral(vault.getDebtAmount(), vault.getCollateralAmount()); + +/** @typedef {{debtToCollateral: Ratio, vaultKit: VaultKit}} VaultKitRecord */ + +/** + * Really a prioritization of vault *kits*. + * + * @param {() => void} reschedulePriceCheck called when there is a new + * least-collateralized vault + */ export const makePrioritizedVaults = reschedulePriceCheck => { - // Each entry is [Vault, debtToCollateralRatio]. The array must be resorted on - // every insert, and whenever any vault's ratio changes. We can remove an - // arbitrary number of vaults from the front of the list without resorting. We - // delete single entries using filter(), which leaves the array sorted. - let vaultsWithDebtRatio = []; + const vaults = makeOrderedVaultStore(); // To deal with fluctuating prices and varying collateralization, we schedule a // new request to the priceAuthority when some vault's debtToCollateral ratio @@ -75,113 +49,130 @@ export const makePrioritizedVaults = reschedulePriceCheck => { // current high-water mark fires, we reschedule at the new highest ratio // (which should be lower, as we will have liquidated any that were at least // as high.) - let highestDebtToCollateral; + // Without this we'd be calling reschedulePriceCheck() unnecessarily + /** @type {Ratio=} */ + let oracleQueryThreshold; // Check if this ratio of debt to collateral would be the highest known. If // so, reset our highest and invoke the callback. This can be called on new // vaults and when we get a state update for a vault changing balances. + /** @param {Ratio} collateralToDebt */ + + // Caches and reschedules const rescheduleIfHighest = collateralToDebt => { if ( - !highestDebtToCollateral || - !ratioGTE(highestDebtToCollateral, collateralToDebt) + !oracleQueryThreshold || + !ratioGTE(oracleQueryThreshold, collateralToDebt) ) { - highestDebtToCollateral = collateralToDebt; + oracleQueryThreshold = collateralToDebt; reschedulePriceCheck(); } }; - const highestRatio = () => { - const mostIndebted = vaultsWithDebtRatio[0]; - return mostIndebted ? mostIndebted.debtToCollateral : undefined; - }; + /** + * + * @returns {Ratio=} actual debt over collateral + */ + const firstDebtRatio = () => { + if (vaults.getSize() === 0) { + return undefined; + } - const removeVault = vaultKit => { - vaultsWithDebtRatio = vaultsWithDebtRatio.filter( - v => v.vaultKit !== vaultKit, - ); - // don't call reschedulePriceCheck, but do reset the highest. - highestDebtToCollateral = highestRatio(); + const [[_, vaultKit]] = vaults.entries(); + const { vault } = vaultKit; + const collateralAmount = vault.getCollateralAmount(); + if (AmountMath.isEmpty(collateralAmount)) { + // Would be an infinite ratio + return undefined; + } + const actualDebtAmount = vault.getDebtAmount(); + return makeRatioFromAmounts(actualDebtAmount, vault.getCollateralAmount()); }; - const updateDebtRatio = (vaultKit, debtRatio) => { - vaultsWithDebtRatio.forEach((vaultPair, index) => { - if (vaultPair.vaultKit === vaultKit) { - vaultsWithDebtRatio[index].debtToCollateral = debtRatio; - } - }); + /** + * @param {string} key + * @returns {VaultKit} + */ + const removeVault = key => { + const vk = vaults.removeByKey(key); + const debtToCollateral = currentDebtToCollateral(vk.vault); + if ( + !oracleQueryThreshold || + ratioGTE(debtToCollateral, oracleQueryThreshold) + ) { + // don't call reschedulePriceCheck, but do reset the highest. + // This could be expensive if we delete individual entries in order. Will know once we have perf data. + oracleQueryThreshold = firstDebtRatio(); + } + return vk; }; - // called after charging interest, which changes debts without affecting sort - const updateAllDebts = () => { - vaultsWithDebtRatio.forEach((vaultPair, index) => { - const debtToCollateral = currentDebtToCollateral(vaultPair.vaultKit); - vaultsWithDebtRatio[index].debtToCollateral = debtToCollateral; - }); - highestDebtToCollateral = highestRatio(); + /** + * + * @param {Amount} oldDebt + * @param {Amount} oldCollateral + * @param {string} vaultId + */ + const removeVaultByAttributes = (oldDebt, oldCollateral, vaultId) => { + const key = toVaultKey(oldDebt, oldCollateral, vaultId); + return removeVault(key); }; - const makeObserver = vaultKit => ({ - updateState: state => { - if (AmountMath.isEmpty(state.locked)) { - return; - } - const debtToCollateral = currentDebtToCollateral(vaultKit); - updateDebtRatio(vaultKit, debtToCollateral); - vaultsWithDebtRatio.sort(compareVaultKits); - rescheduleIfHighest(debtToCollateral); - }, - finish: _ => { - removeVault(vaultKit); - }, - fail: _ => { - removeVault(vaultKit); - }, - }); + /** + * + * @param {VaultId} vaultId + * @param {VaultKit} vaultKit + */ + const addVaultKit = (vaultId, vaultKit) => { + const key = vaults.addVaultKit(vaultId, vaultKit); - const addVaultKit = (vaultKit, notifier) => { - const debtToCollateral = currentDebtToCollateral(vaultKit); - vaultsWithDebtRatio.push({ vaultKit, debtToCollateral }); - vaultsWithDebtRatio.sort(compareVaultKits); - observeNotifier(notifier, makeObserver(vaultKit)); + const debtToCollateral = currentDebtToCollateral(vaultKit.vault); rescheduleIfHighest(debtToCollateral); + return key; }; - // Invoke a function for vaults with debt to collateral at or above the ratio - const forEachRatioGTE = (ratio, func) => { - // vaults are sorted with highest ratios first - let index; - for (index = 0; index < vaultsWithDebtRatio.length; index += 1) { - const vaultPair = vaultsWithDebtRatio[index]; - if (ratioGTE(vaultPair.debtToCollateral, ratio)) { - func(vaultPair); + /** + * Invoke a function for vaults with debt to collateral at or above the ratio. + * + * Results are returned in order of priority, with highest debt to collateral first. + * + * Redundant tags until https://github.com/Microsoft/TypeScript/issues/23857 + * + * @param {Ratio} ratio + * @yields {[string, VaultKit]} + * @returns {IterableIterator<[string, VaultKit]>} + */ + // eslint-disable-next-line func-names + function* entriesPrioritizedGTE(ratio) { + // TODO use a Pattern to limit the query https://github.com/Agoric/agoric-sdk/issues/4550 + for (const [key, vk] of vaults.entries()) { + const debtToCollateral = currentDebtToCollateral(vk.vault); + if (ratioGTE(debtToCollateral, ratio)) { + yield [key, vk]; } else { // stop once we are below the target ratio break; } } + } - if (index > 0) { - vaultsWithDebtRatio = vaultsWithDebtRatio.slice(index); - const highest = highestRatio(); - if (highest) { - reschedulePriceCheck(); - } - } - highestDebtToCollateral = highestRatio(); + /** + * @param {Amount} oldDebt + * @param {Amount} oldCollateral + * @param {string} vaultId + */ + const refreshVaultPriority = (oldDebt, oldCollateral, vaultId) => { + const vaultKit = removeVaultByAttributes(oldDebt, oldCollateral, vaultId); + addVaultKit(vaultId, vaultKit); }; - const map = func => vaultsWithDebtRatio.map(func); - - const reduce = (func, init = undefined) => - vaultsWithDebtRatio.reduce(func, init); - return harden({ addVaultKit, + entries: vaults.entries, + entriesPrioritizedGTE, + highestRatio: () => oracleQueryThreshold, + refreshVaultPriority, removeVault, - map, - reduce, - forEachRatioGTE, - highestRatio: () => highestDebtToCollateral, - updateAllDebts, + removeVaultByAttributes, }); }; diff --git a/packages/run-protocol/src/vaultFactory/storeUtils.js b/packages/run-protocol/src/vaultFactory/storeUtils.js new file mode 100644 index 00000000000..4782ac52c2e --- /dev/null +++ b/packages/run-protocol/src/vaultFactory/storeUtils.js @@ -0,0 +1,113 @@ +// @ts-check +/** + * Module to improvise composite keys for orderedVaultStore until Collections API supports them. + * + */ +// TODO remove after release of https://github.com/endojs/endo/pull/1071 +/* global BigUint64Array */ + +/** @typedef {[normalizedCollateralization: number, vaultId: VaultId]} CompositeKey */ + +const asNumber = new Float64Array(1); +const asBits = new BigUint64Array(asNumber.buffer); + +/** + * + * @param {string} nStr + * @param {number} size + * @returns {string} + */ +const zeroPad = (nStr, size) => { + assert(nStr.length <= size); + const str = `00000000000000000000${nStr}`; + const result = str.substring(str.length - size); + assert(result.length === size); + return result; +}; + +/** + * @param {number} n + */ +const numberToDBEntryKey = n => { + asNumber[0] = n; + let bits = asBits[0]; + if (n < 0) { + // eslint-disable-next-line no-bitwise + bits ^= 0xffffffffffffffffn; + } else { + // eslint-disable-next-line no-bitwise + bits ^= 0x8000000000000000n; + } + return `f${zeroPad(bits.toString(16), 16)}`; +}; + +/** + * @param {string} k + */ +const dbEntryKeyToNumber = k => { + let bits = BigInt(`0x${k.substring(1)}`); + if (k[1] < '8') { + // eslint-disable-next-line no-bitwise + bits ^= 0xffffffffffffffffn; + } else { + // eslint-disable-next-line no-bitwise + bits ^= 0x8000000000000000n; + } + asBits[0] = bits; + const result = asNumber[0]; + if (Object.is(result, -0)) { + return 0; + } + return result; +}; + +/** + * Overcollateralized are greater than one. + * The more undercollaterized the smaller in [0-1]. + * + * @param {Amount} normalizedDebt normalized (not actual) total debt + * @param {Amount} collateral + * @returns {number} + */ +const collateralizationRatio = (normalizedDebt, collateral) => { + const c = Number(collateral.value); + const d = normalizedDebt.value + ? Number(normalizedDebt.value) + : Number.EPSILON; + return c / d; +}; + +/** + * Sorts by ratio in descending debt. Ordering of vault id is undefined. + * + * @param {Amount} normalizedDebt normalized (not actual) total debt + * @param {Amount} collateral + * @param {VaultId} vaultId + * @returns {string} lexically sortable string in which highest debt-to-collateral is earliest + */ +const toVaultKey = (normalizedDebt, collateral, vaultId) => { + assert(normalizedDebt); + assert(collateral); + assert(vaultId); + // until DB supports composite keys, copy its method for turning numbers to DB entry keys + const numberPart = numberToDBEntryKey( + collateralizationRatio(normalizedDebt, collateral), + ); + return `${numberPart}:${vaultId}`; +}; + +/** + * @param {string} key + * @returns {[normalizedCollateralization: number, vaultId: VaultId]} + */ +const fromVaultKey = key => { + const [numberPart, vaultIdPart] = key.split(':'); + return [dbEntryKeyToNumber(numberPart), vaultIdPart]; +}; + +harden(dbEntryKeyToNumber); +harden(fromVaultKey); +harden(numberToDBEntryKey); +harden(toVaultKey); + +export { dbEntryKeyToNumber, fromVaultKey, numberToDBEntryKey, toVaultKey }; diff --git a/packages/run-protocol/src/vaultFactory/types.js b/packages/run-protocol/src/vaultFactory/types.js index 0650bfa51e8..e0953a6ba37 100644 --- a/packages/run-protocol/src/vaultFactory/types.js +++ b/packages/run-protocol/src/vaultFactory/types.js @@ -56,17 +56,18 @@ /** * @typedef {Object} BaseUIState - * @property {Amount} locked Amount of Collateral locked - * @property {Amount} debt Amount of Loan (including accrued interest) + * @property {Amount} locked Amount of Collateral locked + * @property {Amount} debt Amount of Loan (including accrued interest) * @property {Ratio} collateralizationRatio */ /** - * @typedef {BaseUIState & LiquidationUIMixin} UIState + * @typedef {BaseUIState & LiquidationUIMixin} VaultUIState * @typedef {Object} LiquidationUIMixin * @property {Ratio} interestRate Annual interest rate charge * @property {Ratio} liquidationRatio * @property {boolean} liquidated boolean showing whether liquidation occurred + * @property {'active' | 'liquidating' | 'closed'} vaultState */ /** @@ -95,13 +96,7 @@ */ /** - * @typedef {Object} InnerVaultManagerBase - * @property {() => Brand} getCollateralBrand - * @property {ReallocateReward} reallocateReward - */ - -/** - * @typedef {InnerVaultManagerBase & GetVaultParams} InnerVaultManager + * @typedef {string} VaultId */ /** @@ -116,21 +111,21 @@ /** * @typedef {Object} OpenLoanKit - * @property {Notifier} notifier + * @property {Notifier} notifier * @property {Promise} collateralPayoutP */ /** * @typedef {Object} BaseVault - * @property {() => Amount} getCollateralAmount - * @property {() => Amount} getDebtAmount + * @property {() => Amount} getCollateralAmount + * @property {() => Amount} getDebtAmount + * @property {() => Amount} getNormalizedDebt * * @typedef {BaseVault & VaultMixin} Vault * @typedef {Object} VaultMixin * @property {() => Promise} makeAdjustBalancesInvitation * @property {() => Promise} makeCloseInvitation * @property {() => ERef} getLiquidationSeat - * @property {() => Promise} getLiquidationPromise */ /** @@ -146,19 +141,7 @@ /** * @typedef {Object} LoanKit * @property {Vault} vault - * @property {Notifier} uiNotifier - */ - -/** - * @typedef {Object} VaultKit - * @property {Vault} vault - * @property {(seat: ZCFSeat) => Promise} openLoan - * @property {(timestamp: Timestamp) => Amount} accrueInterestAndAddToPool - * @property {ZCFSeat} vaultSeat - * @property {PromiseRecord} liquidationPromiseKit - * @property {ZCFSeat} liquidationZcfSeat - * @property {() => void} liquidating - * @property {(newDebt: Amount) => void} liquidated + * @property {Notifier} uiNotifier */ /** @@ -194,8 +177,8 @@ /** * @typedef {Object} DebtStatus * @property {Timestamp} latestInterestUpdate - * @property {Amount} interest - * @property {Amount} newDebt + * @property {NatValue} interest interest accrued since latestInterestUpdate + * @property {NatValue} newDebt total including principal and interest */ /** @@ -218,9 +201,9 @@ /** * @typedef {Object} VaultParamManager * @property {() => Record & { - * 'InterestRate': ParamRecord<'ratio'> & { value: Ratio }, - * 'LiquidationMargin': ParamRecord<'ratio'> & { value: Ratio }, - * 'LoanFee': ParamRecord<'ratio'> & { value: Ratio }, + * InterestRate: ParamRecord<'ratio'> & { value: Ratio }, + * LiquidationMargin: ParamRecord<'ratio'> & { value: Ratio }, + * LoanFee: ParamRecord<'ratio'> & { value: Ratio }, * }} getParams * @property {(name: string) => bigint} getNat * @property {(name: string) => Ratio} getRatio @@ -238,13 +221,4 @@ * }} */ -/** - * @callback VaultFactoryLiquidate - * @param {ContractFacet} zcf - * @param {VaultKit} vaultKit - * @param {(losses: AmountKeywordRecord, - * zcfSeat: ZCFSeat - * ) => void} burnLosses - * @param {LiquidationStrategy} strategy - * @param {Brand} collateralBrand - */ +/** @typedef {import('./vault').VaultKit} VaultKit */ diff --git a/packages/run-protocol/src/vaultFactory/vault.js b/packages/run-protocol/src/vaultFactory/vault.js index fe553fbaf2a..ebc5e3b9181 100644 --- a/packages/run-protocol/src/vaultFactory/vault.js +++ b/packages/run-protocol/src/vaultFactory/vault.js @@ -10,16 +10,21 @@ import { floorMultiplyBy, floorDivideBy, } from '@agoric/zoe/src/contractSupport/index.js'; -import { makeNotifierKit } from '@agoric/notifier'; +import { makeNotifierKit, observeNotifier } from '@agoric/notifier'; -import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { + invertRatio, + makeRatio, + multiplyRatios, +} from '@agoric/zoe/src/contractSupport/ratio.js'; import { AmountMath } from '@agoric/ertp'; import { Far } from '@endo/marshal'; -import { makePromiseKit } from '@agoric/promise-kit'; -import { makeInterestCalculator } from './interest.js'; +import { makeTracer } from '../makeTracer.js'; const { details: X, quote: q } = assert; +const trace = makeTracer('Vault'); + // a Vault is an individual loan, using some collateralType as the // collateral, and lending RUN to the borrower @@ -36,25 +41,34 @@ export const VaultState = { CLOSED: 'closed', }; +/** + * @typedef {Object} InnerVaultManagerBase + * @property {(oldDebt: Amount, newDebt: Amount) => void} applyDebtDelta + * @property {() => Brand} getCollateralBrand + * @property {ReallocateReward} reallocateReward + * @property {() => Ratio} getCompoundedInterest - coefficient on existing debt to calculate new debt + * @property {(oldDebt: Amount, oldCollateral: Amount, vaultId: VaultId) => void} updateVaultPriority + */ + /** * @param {ContractFacet} zcf - * @param {InnerVaultManager} manager + * @param {InnerVaultManagerBase & GetVaultParams} manager + * @param {Notifier} managerNotifier + * @param {VaultId} idInManager * @param {ZCFMint} runMint * @param {ERef} priceAuthority - * @param {Timestamp} startTimeStamp - * @returns {VaultKit} */ export const makeVaultKit = ( zcf, manager, + managerNotifier, + idInManager, // will go in state runMint, priceAuthority, - startTimeStamp, ) => { const { updater: uiUpdater, notifier } = makeNotifierKit(); const { zcfSeat: liquidationZcfSeat, userSeat: liquidationSeat } = zcf.makeEmptySeatKit(undefined); - const liquidationPromiseKit = makePromiseKit(); /** @type {VAULT_STATE} */ let vaultState = VaultState.ACTIVE; @@ -64,8 +78,6 @@ export const makeVaultKit = ( }; const collateralBrand = manager.getCollateralBrand(); - // timestamp of most recent update to interest - let latestInterestUpdate = startTimeStamp; // vaultSeat will hold the collateral until the loan is retired. The // payout from it will be handed to the user: if the vault dies early @@ -75,7 +87,83 @@ export const makeVaultKit = ( const { zcfSeat: vaultSeat } = zcf.makeEmptySeatKit(); const { brand: runBrand } = runMint.getIssuerRecord(); - let runDebt = AmountMath.makeEmpty(runBrand); + + /** + * Snapshot of the debt and compounded interest when the principal was last changed + * + * @type {{run: Amount, interest: Ratio}} + */ + let debtSnapshot = { + run: AmountMath.makeEmpty(runBrand, 'nat'), + interest: manager.getCompoundedInterest(), + }; + + /** + * Called whenever the debt is paid or created through a transaction, + * but not for interest accrual. + * + * @param {Amount} newDebt - principal and all accrued interest + */ + const updateDebtSnapshot = newDebt => { + // update local state + debtSnapshot = { run: newDebt, interest: manager.getCompoundedInterest() }; + + trace(`${idInManager} updateDebtSnapshot`, newDebt.value, debtSnapshot); + }; + + /** + * @param {Amount} oldDebt - prior principal and all accrued interest + * @param {Amount} oldCollateral - actual collateral + * @param {Amount} newDebt - actual principal and all accrued interest + */ + const refreshLoanTracking = (oldDebt, oldCollateral, newDebt) => { + updateDebtSnapshot(newDebt); + // update vault manager which tracks total debt + manager.applyDebtDelta(oldDebt, newDebt); + // update position of this vault in liquidation priority queue + manager.updateVaultPriority(oldDebt, oldCollateral, idInManager); + }; + + /** + * The actual current debt, including accrued interest. + * + * This looks like a simple getter but it does a lot of the heavy lifting for + * interest accrual. Rather than updating all records when interest accrues, + * the vault manager updates just its rolling compounded interest. Here we + * calculate what the current debt is given what's recorded in this vault and + * what interest has compounded since this vault record was written. + * + * @see getNormalizedDebt + * @returns {Amount} + */ + // TODO rename to getActualDebtAmount throughout codebase https://github.com/Agoric/agoric-sdk/issues/4540 + const getDebtAmount = () => { + // divide compounded interest by the snapshot + const interestSinceSnapshot = multiplyRatios( + manager.getCompoundedInterest(), + invertRatio(debtSnapshot.interest), + ); + + return floorMultiplyBy(debtSnapshot.run, interestSinceSnapshot); + }; + + /** + * The normalization puts all debts on a common time-independent scale since + * the launch of this vault manager. This allows the manager to order vaults + * by their debt-to-collateral ratios without having to mutate the debts as + * the interest accrues. + * + * @see getActualDebAmount + * @returns {Amount} as if the vault was open at the launch of this manager, before any interest accrued + */ + // Not in use until https://github.com/Agoric/agoric-sdk/issues/4540 + const getNormalizedDebt = () => { + assert(debtSnapshot); + return floorMultiplyBy( + debtSnapshot.run, + invertRatio(debtSnapshot.interest), + ); + }; const getCollateralAllocated = seat => seat.getAmountAllocated('Collateral', collateralBrand); @@ -101,14 +189,21 @@ export const makeVaultKit = ( ); }; - const assertSufficientCollateral = async (collateralAmount, wantedRun) => { + const assertSufficientCollateral = async ( + collateralAmount, + proposedRunDebt, + ) => { const maxRun = await maxDebtFor(collateralAmount); assert( - AmountMath.isGTE(maxRun, wantedRun, runBrand), - X`Requested ${q(wantedRun)} exceeds max ${q(maxRun)}`, + AmountMath.isGTE(maxRun, proposedRunDebt, runBrand), + X`Requested ${q(proposedRunDebt)} exceeds max ${q(maxRun)}`, ); }; + /** + * + * @returns {Amount} + */ const getCollateralAmount = () => { // getCollateralAllocated would return final allocations return vaultSeat.hasExited() @@ -116,6 +211,9 @@ export const makeVaultKit = ( : getCollateralAllocated(vaultSeat); }; + /** + * @returns {Promise} Collateral over actual debt + */ const getCollateralizationRatio = async () => { const collateralAmount = getCollateralAmount(); @@ -125,11 +223,11 @@ export const makeVaultKit = ( ); // TODO: allow Ratios to represent X/0. - if (AmountMath.isEmpty(runDebt)) { + if (AmountMath.isEmpty(debtSnapshot.run)) { return makeRatio(collateralAmount.value, runBrand, 1n); } const collateralValueInRun = getAmountOut(quoteAmount); - return makeRatioFromAmounts(collateralValueInRun, runDebt); + return makeRatioFromAmounts(collateralValueInRun, getDebtAmount()); }; // call this whenever anything changes! @@ -139,13 +237,16 @@ export const makeVaultKit = ( // await quoteGiven() here // [https://github.com/Agoric/dapp-token-economy/issues/123] const collateralizationRatio = await getCollateralizationRatio(); - /** @type {UIState} */ + /** @type {VaultUIState} */ const uiState = harden({ + // TODO move manager state to a separate notifer https://github.com/Agoric/agoric-sdk/issues/4540 interestRate: manager.getInterestRate(), liquidationRatio: manager.getLiquidationMargin(), + debtSnapshot, locked: getCollateralAmount(), - debt: runDebt, + debt: getDebtAmount(), collateralizationRatio, + // TODO state distinct from CLOSED https://github.com/Agoric/agoric-sdk/issues/4539 liquidated: vaultState === VaultState.CLOSED, vaultState, }); @@ -162,17 +263,32 @@ export const makeVaultKit = ( throw Error(`unreachable vaultState: ${vaultState}`); } }; + // XXX Echo notifications from the manager through all vaults + // TODO move manager state to a separate notifer https://github.com/Agoric/agoric-sdk/issues/4540 + observeNotifier(managerNotifier, { + updateState: () => { + if (vaultState !== VaultState.CLOSED) { + updateUiState(); + } + }, + }); /** + * Call must check for and remember shortfall + * * @param {Amount} newDebt */ const liquidated = newDebt => { - runDebt = newDebt; + updateDebtSnapshot(newDebt); + vaultState = VaultState.CLOSED; updateUiState(); }; const liquidating = () => { + if (vaultState === VaultState.LIQUIDATING) { + throw new Error('Vault already liquidating'); + } vaultState = VaultState.LIQUIDATING; updateUiState(); }; @@ -195,6 +311,7 @@ export const makeVaultKit = ( // you must pay off the entire remainder but if you offer too much, we won't // take more than you owe + const runDebt = getDebtAmount(); assert( AmountMath.isGTE(runReturned, runDebt), X`You must pay off the entire debt ${runReturned} > ${runDebt}`, @@ -214,13 +331,12 @@ export const makeVaultKit = ( seat.exit(); burnSeat.exit(); vaultState = VaultState.CLOSED; + updateDebtSnapshot(AmountMath.makeEmpty(runBrand)); updateUiState(); - runDebt = AmountMath.makeEmpty(runBrand); assertVaultHoldsNoRun(); vaultSeat.exit(); liquidationZcfSeat.exit(); - liquidationPromiseKit.resolve('Closed'); return 'your loan is closed, thank you for your business'; }; @@ -288,13 +404,18 @@ export const makeVaultKit = ( } }; - // Calculate the target RUN level for the vaultSeat and clientSeat implied - // by the proposal. If the proposal wants collateral, transfer that amount - // from vault to client. If the proposal gives collateral, transfer the - // opposite direction. Otherwise, return the current level. - // - // Since we don't allow the debt to go negative, we will reduce the amount we - // accept when the proposal says to give more RUN than are owed. + /** + * Calculate the target RUN level for the vaultSeat and clientSeat implied + * by the proposal. If the proposal wants collateral, transfer that amount + * from vault to client. If the proposal gives collateral, transfer the + * opposite direction. Otherwise, return the current level. + * + * Since we don't allow the debt to go negative, we will reduce the amount we + * accept when the proposal says to give more RUN than are owed. + * + * @param {ZCFSeat} seat + * @returns {{vault: Amount, client: Amount}} + */ const targetRunLevels = seat => { const clientAllocation = getRunAllocated(seat); const proposal = seat.getProposal(); @@ -305,8 +426,8 @@ export const makeVaultKit = ( }; } else if (proposal.give.RUN) { // We don't allow runDebt to be negative, so we'll refund overpayments - const acceptedRun = AmountMath.isGTE(proposal.give.RUN, runDebt) - ? runDebt + const acceptedRun = AmountMath.isGTE(proposal.give.RUN, getDebtAmount()) + ? getDebtAmount() : proposal.give.RUN; return { @@ -329,35 +450,47 @@ export const makeVaultKit = ( ); } else if (proposal.give.RUN) { // We don't allow runDebt to be negative, so we'll refund overpayments - const acceptedRun = AmountMath.isGTE(proposal.give.RUN, runDebt) - ? runDebt + const acceptedRun = AmountMath.isGTE(proposal.give.RUN, getDebtAmount()) + ? getDebtAmount() : proposal.give.RUN; vaultSeat.incrementBy(seat.decrementBy(harden({ RUN: acceptedRun }))); } }; - // Calculate the fee, the amount to mint and the resulting debt. + /** + * Calculate the fee, the amount to mint and the resulting debt + * + * @param {ProposalRecord} proposal + * @param {{vault: Amount, client: Amount}} runAfter + */ const loanFee = (proposal, runAfter) => { let newDebt; + const currentDebt = getDebtAmount(); let toMint = AmountMath.makeEmpty(runBrand); let fee = AmountMath.makeEmpty(runBrand); if (proposal.want.RUN) { fee = ceilMultiplyBy(proposal.want.RUN, manager.getLoanFee()); toMint = AmountMath.add(proposal.want.RUN, fee); - newDebt = AmountMath.add(runDebt, toMint); + newDebt = AmountMath.add(currentDebt, toMint); } else if (proposal.give.RUN) { - newDebt = AmountMath.subtract(runDebt, runAfter.vault); + newDebt = AmountMath.subtract(currentDebt, runAfter.vault); } else { - newDebt = runDebt; + newDebt = currentDebt; } return { newDebt, toMint, fee }; }; - /** @param {ZCFSeat} clientSeat */ + /** + * Adjust principal and collateral (atomically for offer safety) + * + * @param {ZCFSeat} clientSeat + */ const adjustBalancesHook = async clientSeat => { assertVaultIsOpen(); const proposal = clientSeat.getProposal(); + const oldDebt = getDebtAmount(); + const oldCollateral = getCollateralAmount(); assertOnlyKeys(proposal, ['Collateral', 'RUN']); @@ -384,6 +517,14 @@ export const makeVaultKit = ( const vaultCollateral = collateralAfter.vault || AmountMath.makeEmpty(collateralBrand); + trace('adjustBalancesHook', { + targetCollateralAmount, + vaultCollateral, + fee, + toMint, + newDebt, + }); + // If the collateral decreased, we pro-rate maxDebt if (AmountMath.isGTE(targetCollateralAmount, vaultCollateral)) { // We can pro-rate maxDebt because the quote is either linear (price is @@ -415,7 +556,9 @@ export const makeVaultKit = ( transferRun(clientSeat); manager.reallocateReward(fee, vaultSeat, clientSeat); - runDebt = newDebt; + // parent needs to know about the change in debt + refreshLoanTracking(oldDebt, oldCollateral, newDebt); + runMint.burnLosses(harden({ RUN: runAfter.vault }), vaultSeat); assertVaultHoldsNoRun(); @@ -433,7 +576,13 @@ export const makeVaultKit = ( /** @type {OfferHandler} */ const openLoan = async seat => { - assert(AmountMath.isEmpty(runDebt), X`vault must be empty initially`); + assert( + AmountMath.isEmpty(debtSnapshot.run), + X`vault must be empty initially`, + ); + const oldDebt = getDebtAmount(); + const oldCollateral = getCollateralAmount(); + // get the payout to provide access to the collateral if the // contract abandons const { @@ -450,10 +599,10 @@ export const makeVaultKit = ( ); } - runDebt = AmountMath.add(wantedRun, fee); - await assertSufficientCollateral(collateralAmount, runDebt); + const stagedDebt = AmountMath.add(wantedRun, fee); + await assertSufficientCollateral(collateralAmount, stagedDebt); - runMint.mintGains(harden({ RUN: runDebt }), vaultSeat); + runMint.mintGains(harden({ RUN: stagedDebt }), vaultSeat); seat.incrementBy(vaultSeat.decrementBy(harden({ RUN: wantedRun }))); vaultSeat.incrementBy( @@ -461,43 +610,13 @@ export const makeVaultKit = ( ); manager.reallocateReward(fee, vaultSeat, seat); + refreshLoanTracking(oldDebt, oldCollateral, stagedDebt); + updateUiState(); return { notifier }; }; - /** - * @param {bigint} currentTime - * @returns {Amount} rate of interest used for accrual period - */ - const accrueInterestAndAddToPool = currentTime => { - const interestCalculator = makeInterestCalculator( - runBrand, - manager.getInterestRate(), - manager.getChargingPeriod(), - manager.getRecordingPeriod(), - ); - - const debtStatus = interestCalculator.calculateReportingPeriod( - { - latestInterestUpdate, - newDebt: runDebt, - interest: AmountMath.makeEmpty(runBrand), - }, - currentTime, - ); - - if (debtStatus.latestInterestUpdate === latestInterestUpdate) { - return AmountMath.makeEmpty(runBrand); - } - - ({ latestInterestUpdate, newDebt: runDebt } = debtStatus); - updateUiState(); - return debtStatus.interest; - }; - - const getDebtAmount = () => runDebt; - /** @type {Vault} */ const vault = Far('vault', { makeAdjustBalancesInvitation, @@ -506,18 +625,24 @@ export const makeVaultKit = ( // for status/debugging getCollateralAmount, getDebtAmount, + getNormalizedDebt, getLiquidationSeat: () => liquidationSeat, - getLiquidationPromise: () => liquidationPromiseKit.promise, }); - return harden({ - vault, + const actions = Far('vaultAdmin', { openLoan, - accrueInterestAndAddToPool, - vaultSeat, liquidating, liquidated, - liquidationPromiseKit, + }); + + return harden({ + vault, + actions, liquidationZcfSeat, + vaultSeat, }); }; + +/** + * @typedef {ReturnType} VaultKit + */ diff --git a/packages/run-protocol/src/vaultFactory/vaultFactory.js b/packages/run-protocol/src/vaultFactory/vaultFactory.js index ec1269c5ee4..e85b0631598 100644 --- a/packages/run-protocol/src/vaultFactory/vaultFactory.js +++ b/packages/run-protocol/src/vaultFactory/vaultFactory.js @@ -127,6 +127,8 @@ export const start = async (zcf, privateArgs) => { ); const liquidationStrategy = makeLiquidationStrategy(liquidationFacet); + const startTimeStamp = await E(timerService).getCurrentTimestamp(); + const vm = makeVaultManager( zcf, runMint, @@ -137,6 +139,7 @@ export const start = async (zcf, privateArgs) => { reallocateReward, timerService, liquidationStrategy, + startTimeStamp, ); collateralTypes.init(collateralBrand, vm); return vm; diff --git a/packages/run-protocol/src/vaultFactory/vaultManager.js b/packages/run-protocol/src/vaultFactory/vaultManager.js index 309abc51f20..e3716265871 100644 --- a/packages/run-protocol/src/vaultFactory/vaultManager.js +++ b/packages/run-protocol/src/vaultFactory/vaultManager.js @@ -10,8 +10,9 @@ import { getAmountIn, ceilMultiplyBy, ceilDivideBy, + makeRatio, } from '@agoric/zoe/src/contractSupport/index.js'; -import { observeNotifier } from '@agoric/notifier'; +import { makeNotifierKit, observeNotifier } from '@agoric/notifier'; import { AmountMath } from '@agoric/ertp'; import { Far } from '@endo/marshal'; @@ -26,29 +27,34 @@ import { INTEREST_RATE_KEY, CHARGING_PERIOD_KEY, } from './params.js'; +import { + calculateCompoundedInterest, + makeInterestCalculator, +} from './interest.js'; const { details: X } = assert; -const trace = makeTracer(' VM '); - -// Each VaultManager manages a single collateralType. It owns an autoswap -// instance which trades this collateralType against RUN. It also manages -// some number of outstanding loans, each called a Vault, for which the -// collateral is provided in exchange for borrowed RUN. +const trace = makeTracer('VM'); /** + * Each VaultManager manages a single collateral type. + * + * It manages some number of outstanding loans, each called a Vault, for which + * the collateral is provided in exchange for borrowed RUN. + * * @param {ContractFacet} zcf * @param {ZCFMint} runMint * @param {Brand} collateralBrand * @param {ERef} priceAuthority * @param {{ - * 'ChargingPeriod': ParamRecord<'relativeTime'> & { value: RelativeTime }, - * 'RecordingPeriod': ParamRecord<'relativeTime'> & { value: RelativeTime }, + * ChargingPeriod: ParamRecord<'relativeTime'> & { value: RelativeTime }, + * RecordingPeriod: ParamRecord<'relativeTime'> & { value: RelativeTime }, * }} timingParams * @param {GetGovernedVaultParams} getLoanParams * @param {ReallocateReward} reallocateReward * @param {ERef} timerService * @param {LiquidationStrategy} liquidationStrategy + * @param {Timestamp} startTimeStamp * @returns {VaultManager} */ export const makeVaultManager = ( @@ -61,6 +67,7 @@ export const makeVaultManager = ( reallocateReward, timerService, liquidationStrategy, + startTimeStamp, ) => { const { brand: runBrand } = runMint.getIssuerRecord(); @@ -76,7 +83,7 @@ export const makeVaultManager = ( async getCollateralQuote() { // get a quote for one unit of the collateral const displayInfo = await E(collateralBrand).getDisplayInfo(); - const decimalPlaces = (displayInfo && displayInfo.decimalPlaces) || 0n; + const decimalPlaces = displayInfo?.decimalPlaces || 0n; return E(priceAuthority).quoteGiven( AmountMath.make(collateralBrand, 10n ** Nat(decimalPlaces)), runBrand, @@ -84,14 +91,58 @@ export const makeVaultManager = ( }, }; - // A Map from vaultKits to their most recent ratio of debt to - // collateralization. (This representation won't be optimized; when we need - // better performance, use virtual objects.) + let vaultCounter = 0; + + // A store for vaultKits prioritized by their collaterization ratio. // - // sortedVaultKits should only be set once, but can't be set until after the + // It should be set only once but it's a `let` because it can't be set until after the // definition of reschedulePriceCheck, which refers to sortedVaultKits - let sortedVaultKits; + // XXX misleading mutability and confusing flow control; could be refactored with a listener + /** @type {ReturnType=} */ + let prioritizedVaults; + /** @type {MutableQuote=} */ let outstandingQuote; + /** @type {Amount} */ + let totalDebt = AmountMath.make(runBrand, 0n); + /** @type {Ratio}} */ + let compoundedInterest = makeRatio(100n, runBrand); // starts at 1.0, no interest + + // timestamp of most recent update to interest + /** @type {bigint} */ + let latestInterestUpdate = startTimeStamp; + + const { updater, notifier } = makeNotifierKit( + harden({ + compoundedInterest, + latestInterestUpdate, + totalDebt, + }), + ); + + /** + * + * @param {[key: string, vaultKit: VaultKit]} record + */ + const liquidateAndRemove = async ([key, vaultKit]) => { + assert(prioritizedVaults); + trace('liquidating', vaultKit.vaultSeat.getProposal()); + + try { + // Start liquidation (vaultState: LIQUIDATING) + await liquidate( + zcf, + vaultKit, + runMint.burnLosses, + liquidationStrategy, + collateralBrand, + ); + + await prioritizedVaults.removeVault(key); + } catch (e) { + // XXX should notify interested parties + console.error('liquidateAndRemove failed with', e); + } + }; // When any Vault's debt ratio is higher than the current high-water level, // call reschedulePriceCheck() to request a fresh notification from the @@ -102,7 +153,8 @@ export const makeVaultManager = ( // when a priceQuote is received, we'll only reschedule if the high-water // level when the request was made matches the current high-water level. const reschedulePriceCheck = async () => { - const highestDebtRatio = sortedVaultKits.highestRatio(); + assert(prioritizedVaults); + const highestDebtRatio = prioritizedVaults.highestRatio(); if (!highestDebtRatio) { // if there aren't any open vaults, we don't need an outstanding RFQ. return; @@ -114,7 +166,7 @@ export const makeVaultManager = ( // with the highest debt to collateral ratio will no longer be valued at the // liquidationMargin above its debt. const triggerPoint = ceilMultiplyBy( - highestDebtRatio.numerator, + highestDebtRatio.numerator, // debt liquidationMargin, ); @@ -123,15 +175,16 @@ export const makeVaultManager = ( // then make a new request to the priceAuthority, and when it resolves, // liquidate anything that's above the price level. if (outstandingQuote) { + // Safe to call extraneously (lightweight and idempotent) E(outstandingQuote).updateLevel( - highestDebtRatio.denominator, + highestDebtRatio.denominator, // collateral triggerPoint, ); return; } outstandingQuote = await E(priceAuthority).mutableQuoteWhenLT( - highestDebtRatio.denominator, + highestDebtRatio.denominator, // collateral triggerPoint, ); @@ -149,48 +202,121 @@ export const makeVaultManager = ( getAmountIn(quote), ); - sortedVaultKits.forEachRatioGTE(quoteRatioPlusMargin, ({ vaultKit }) => { - trace('liquidating', vaultKit.vaultSeat.getProposal()); + /** @type {Array>} */ + const toLiquidate = Array.from( + prioritizedVaults.entriesPrioritizedGTE(quoteRatioPlusMargin), + ).map(liquidateAndRemove); - liquidate( - zcf, - vaultKit, - runMint.burnLosses, - liquidationStrategy, - collateralBrand, - ); - }); outstandingQuote = undefined; + // Ensure all vaults complete + await Promise.all(toLiquidate); + reschedulePriceCheck(); }; - sortedVaultKits = makePrioritizedVaults(reschedulePriceCheck); + prioritizedVaults = makePrioritizedVaults(reschedulePriceCheck); - const liquidateAll = () => { - const promises = sortedVaultKits.map(({ vaultKit }) => - liquidate( - zcf, - vaultKit, - runMint.burnLosses, - liquidationStrategy, - collateralBrand, - ), + // In extreme situations, system health may require liquidating all vaults. + const liquidateAll = async () => { + assert(prioritizedVaults); + const toLiquidate = Array.from(prioritizedVaults.entries()).map( + liquidateAndRemove, ); - return Promise.all(promises); + await Promise.all(toLiquidate); }; + /** + * + * @param {bigint} updateTime + * @param {ZCFSeat} poolIncrementSeat + */ const chargeAllVaults = async (updateTime, poolIncrementSeat) => { - const poolIncrement = sortedVaultKits.reduce( - (total, vaultPair) => - AmountMath.add( - total, - vaultPair.vaultKit.accrueInterestAndAddToPool(updateTime), - ), - AmountMath.makeEmpty(runBrand), + trace('chargeAllVault', { updateTime }); + const interestCalculator = makeInterestCalculator( + shared.getInterestRate(), + shared.getChargingPeriod(), + shared.getRecordingPeriod(), + ); + + // calculate delta of accrued debt + const debtStatus = interestCalculator.calculateReportingPeriod( + { + latestInterestUpdate, + newDebt: totalDebt.value, + interest: 0n, // XXX this is always zero, doesn't need to be an option + }, + updateTime, + ); + const interestAccrued = debtStatus.interest; + + // done if none + if (interestAccrued === 0n) { + return; + } + + // NB: This method of inferring the compounded rate from the ratio of debts + // acrrued suffers slightly from the integer nature of debts. However in + // testing with small numbers there's 5 digits of precision, and with large + // numbers the ratios tend towards ample precision. Because this calculation + // is over all debts of the vault the numbers will be reliably large. + compoundedInterest = calculateCompoundedInterest( + compoundedInterest, + totalDebt.value, + debtStatus.newDebt, ); - sortedVaultKits.updateAllDebts(); + // totalDebt += interestAccrued + totalDebt = AmountMath.add( + totalDebt, + AmountMath.make(runBrand, interestAccrued), + ); + + // mint that much RUN for the reward pool + const rewarded = AmountMath.make(runBrand, interestAccrued); + runMint.mintGains(harden({ RUN: rewarded }), poolIncrementSeat); + reallocateReward(rewarded, poolIncrementSeat); + + // update running tally of total debt against this collateral + ({ latestInterestUpdate } = debtStatus); + + const payload = harden({ + compoundedInterest, + latestInterestUpdate, + totalDebt, + }); + updater.updateState(payload); + + trace('chargeAllVaults complete', payload); + reschedulePriceCheck(); - runMint.mintGains(harden({ RUN: poolIncrement }), poolIncrementSeat); - reallocateReward(poolIncrement, poolIncrementSeat); + }; + + /** + * Update total debt of this manager given the change in debt on a vault + * + * @param {Amount} oldDebtOnVault + * @param {Amount} newDebtOnVault + */ + // TODO https://github.com/Agoric/agoric-sdk/issues/4599 + const applyDebtDelta = (oldDebtOnVault, newDebtOnVault) => { + const delta = newDebtOnVault.value - oldDebtOnVault.value; + trace(`updating total debt ${totalDebt} by ${delta}`); + if (delta === 0n) { + // nothing to do + return; + } + + // totalDebt += delta (Amount type ensures natural value) + totalDebt = AmountMath.make(runBrand, totalDebt.value + delta); + }; + + /** + * @param {Amount} oldDebt + * @param {Amount} oldCollateral + * @param {VaultId} vaultId + */ + const updateVaultPriority = (oldDebt, oldCollateral, vaultId) => { + assert(prioritizedVaults); + prioritizedVaults.refreshVaultPriority(oldDebt, oldCollateral, vaultId); + trace('updateVaultPriority complete', { totalDebt }); }; const periodNotifier = E(timerService).makeNotifier( @@ -201,7 +327,7 @@ export const makeVaultManager = ( const timeObserver = { updateState: updateTime => - chargeAllVaults(updateTime, poolIncrementSeat).catch(_ => {}), + chargeAllVaults(updateTime, poolIncrementSeat).catch(console.error), fail: reason => { zcf.shutdownWithFailure( assert.error(X`Unable to continue without a timer: ${reason}`), @@ -216,11 +342,14 @@ export const makeVaultManager = ( observeNotifier(periodNotifier, timeObserver); - /** @type {InnerVaultManager} */ - const innerFacet = harden({ + /** @type {Parameters[1]} */ + const managerFacet = harden({ ...shared, + applyDebtDelta, reallocateReward, getCollateralBrand: () => collateralBrand, + getCompoundedInterest: () => compoundedInterest, + updateVaultPriority, }); /** @param {ZCFSeat} seat */ @@ -230,29 +359,43 @@ export const makeVaultManager = ( want: { RUN: null }, }); - const startTimeStamp = await E(timerService).getCurrentTimestamp(); + vaultCounter += 1; + const vaultId = String(vaultCounter); + const vaultKit = makeVaultKit( zcf, - innerFacet, + managerFacet, + notifier, + vaultId, runMint, priceAuthority, - startTimeStamp, ); + const { + vault, + actions: { openLoan }, + } = vaultKit; + assert(prioritizedVaults); + const addedVaultKey = prioritizedVaults.addVaultKit(vaultId, vaultKit); - const { vault, openLoan } = vaultKit; - const { notifier } = await openLoan(seat); - sortedVaultKits.addVaultKit(vaultKit, notifier); + try { + const vaultResult = await openLoan(seat); - seat.exit(); + seat.exit(); - return harden({ - uiNotifier: notifier, - invitationMakers: Far('invitation makers', { - AdjustBalances: vault.makeAdjustBalancesInvitation, - CloseVault: vault.makeCloseInvitation, - }), - vault, - }); + return harden({ + uiNotifier: vaultResult.notifier, + invitationMakers: Far('invitation makers', { + AdjustBalances: vault.makeAdjustBalancesInvitation, + CloseVault: vault.makeCloseInvitation, + }), + vault, + }); + } catch (err) { + // remove it from prioritizedVaults + // XXX openLoan shouldn't assume it's already in the prioritizedVaults + prioritizedVaults.removeVault(addedVaultKey); + throw err; + } }; /** @type {VaultManager} */ diff --git a/packages/run-protocol/test/supports.js b/packages/run-protocol/test/supports.js new file mode 100644 index 00000000000..2211f8829d9 --- /dev/null +++ b/packages/run-protocol/test/supports.js @@ -0,0 +1,34 @@ +import { AmountMath } from '@agoric/ertp'; +import { Far } from '@endo/marshal'; + +/** + * + * @param {VaultId} vaultId + * @param {Amount} initDebt + * @param {Amount} initCollateral + * @returns {VaultKit & {vault: {setDebt: (Amount) => void}}} + */ +export function makeFakeVaultKit( + vaultId, + initDebt, + initCollateral = AmountMath.make(initDebt.brand, 100n), +) { + let debt = initDebt; + let collateral = initCollateral; + const vault = Far('Vault', { + getCollateralAmount: () => collateral, + getNormalizedDebt: () => debt, + getDebtAmount: () => debt, + setDebt: newDebt => (debt = newDebt), + setCollateral: newCollateral => (collateral = newCollateral), + }); + const admin = Far('vaultAdmin', { + getIdInManager: () => vaultId, + liquidate: () => {}, + }); + // @ts-expect-error pretend this is compatible with VaultKit + return harden({ + vault, + admin, + }); +} diff --git a/packages/run-protocol/test/vaultFactory/swingsetTests/governance/test-governance.js b/packages/run-protocol/test/vaultFactory/swingsetTests/governance/test-governance.js index 0199c2728de..806a9c1ea43 100644 --- a/packages/run-protocol/test/vaultFactory/swingsetTests/governance/test-governance.js +++ b/packages/run-protocol/test/vaultFactory/swingsetTests/governance/test-governance.js @@ -86,8 +86,8 @@ const expectedVaultFactoryLog = [ 'after vote on (InterestRate), InterestRate numerator is 4321', 'at 3 days: vote closed', 'at 3 days: Alice owes {"brand":"[Alleged: RUN brand]","value":"[510105n]"}', - 'at 3 days: 1 day after votes cast, uiNotifier update #4 has interestRate.numerator 250', - 'at 4 days: 2 days after votes cast, uiNotifier update #5 has interestRate.numerator 4321', + 'at 3 days: 1 day after votes cast, uiNotifier update #5 has interestRate.numerator 250', + 'at 4 days: 2 days after votes cast, uiNotifier update #6 has interestRate.numerator 4321', 'at 4 days: Alice owes {"brand":"[Alleged: RUN brand]","value":"[510608n]"}', ]; diff --git a/packages/run-protocol/test/vaultFactory/test-interest.js b/packages/run-protocol/test/vaultFactory/test-interest.js index 5fd1a75eba2..f18918c87d8 100644 --- a/packages/run-protocol/test/vaultFactory/test-interest.js +++ b/packages/run-protocol/test/vaultFactory/test-interest.js @@ -2,10 +2,14 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import '@agoric/zoe/exported.js'; -import { makeIssuerKit, AmountMath } from '@agoric/ertp'; -import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; - +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { + ceilMultiplyBy, + makeRatio, +} from '@agoric/zoe/src/contractSupport/ratio.js'; +import { Far } from '@endo/marshal'; import { + calculateCompoundedInterest, makeInterestCalculator, SECONDS_PER_YEAR, } from '../../src/vaultFactory/interest.js'; @@ -20,119 +24,98 @@ const TEN_MILLION = 10000000n; test('too soon', async t => { const { brand } = makeIssuerKit('ducats'); const calculator = makeInterestCalculator( - brand, makeRatio(1n * SECONDS_PER_YEAR, brand), 3n, 6n, ); const debtStatus = { - newDebt: AmountMath.make(brand, 1000n), + newDebt: 1000n, latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // no interest because the charging period hasn't elapsed t.deepEqual(calculator.calculate(debtStatus, 12n), { latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, 1000n), + interest: 0n, + newDebt: 1000n, }); }); test('basic charge 1 period', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); const debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: 0n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // 7n is daily interest of 2.5% APR on 100k. Compounding is in the noise. t.deepEqual(calculator.calculate(debtStatus, ONE_DAY), { latestInterestUpdate: ONE_DAY, - interest: AmountMath.make(brand, 7n), - newDebt: AmountMath.make(brand, 100007n), + interest: 7n, + newDebt: 100007n, }); }); test('basic 2 charge periods', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); const debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: ONE_DAY, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // 14n is 2x daily (from day 1 to day 3) interest of 2.5% APR on 100k. // Compounding is in the noise. t.deepEqual(calculator.calculate(debtStatus, ONE_DAY * 3n), { latestInterestUpdate: ONE_DAY * 3n, - interest: AmountMath.make(brand, 14n), - newDebt: AmountMath.make(brand, 100014n), + interest: 14n, + newDebt: 100014n, }); }); test('partial periods', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); const debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // just less than three days gets two days of interest (7n/day) t.deepEqual(calculator.calculate(debtStatus, ONE_DAY * 3n - 1n), { latestInterestUpdate: 10n + ONE_DAY * 2n, - interest: AmountMath.make(brand, 14n), - newDebt: AmountMath.make(brand, 100014n), + interest: 14n, + newDebt: 100014n, }); }); test('reportingPeriod: partial', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); const debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // charge at reporting period intervals t.deepEqual(calculator.calculateReportingPeriod(debtStatus, ONE_MONTH), { latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + interest: 0n, + newDebt: HUNDRED_THOUSAND, }); // charge daily, record monthly. After a month, charge 30 * 7n t.deepEqual( calculator.calculateReportingPeriod(debtStatus, ONE_DAY + ONE_MONTH), { latestInterestUpdate: 10n + ONE_MONTH, - interest: AmountMath.make(brand, 210n), - newDebt: AmountMath.make(brand, 100210n), + interest: 210n, + newDebt: 100210n, }, ); }); @@ -140,16 +123,11 @@ test('reportingPeriod: partial', async t => { test('reportingPeriod: longer', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_MONTH, - ONE_DAY, - ); + const calculator = makeInterestCalculator(annualRate, ONE_MONTH, ONE_DAY); const debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // charge monthly, record daily. 2.5% APR compounded monthly rate is 204 BP. // charge at reporting period intervals @@ -157,8 +135,8 @@ test('reportingPeriod: longer', async t => { calculator.calculateReportingPeriod(debtStatus, ONE_MONTH + ONE_DAY), { latestInterestUpdate: ONE_MONTH + 10n, - interest: AmountMath.make(brand, 204n), - newDebt: AmountMath.make(brand, 100204n), + interest: 204n, + newDebt: 100204n, }, ); }); @@ -166,44 +144,34 @@ test('reportingPeriod: longer', async t => { test('start charging later', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); const debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: 16n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // from a baseline of 16n, we don't charge interest until the timer gets to // ONE_DAY plus 16n. t.deepEqual(calculator.calculate(debtStatus, ONE_DAY), { latestInterestUpdate: 16n, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + interest: 0n, + newDebt: HUNDRED_THOUSAND, }); t.deepEqual(calculator.calculate(debtStatus, ONE_DAY + 16n), { latestInterestUpdate: ONE_DAY + 16n, - interest: AmountMath.make(brand, 7n), - newDebt: AmountMath.make(brand, 100007n), + interest: 7n, + newDebt: 100007n, }); }); test('simple compounding', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); const debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; // 30 days of 7n interest per day. Compounding is in the noise. // charge at reporting period intervals @@ -211,8 +179,8 @@ test('simple compounding', async t => { calculator.calculateReportingPeriod(debtStatus, ONE_MONTH + ONE_DAY), { latestInterestUpdate: ONE_MONTH + 10n, - interest: AmountMath.make(brand, 210n), - newDebt: AmountMath.make(brand, 100210n), + interest: 210n, + newDebt: 100210n, }, ); }); @@ -220,21 +188,16 @@ test('simple compounding', async t => { test('reportingPeriod shorter than charging', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_MONTH, - ONE_DAY, - ); + const calculator = makeInterestCalculator(annualRate, ONE_MONTH, ONE_DAY); let debtStatus = { - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + newDebt: HUNDRED_THOUSAND, latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), + interest: 0n, }; const afterOneMonth = { latestInterestUpdate: 10n, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + interest: 0n, + newDebt: HUNDRED_THOUSAND, }; // charging period is 30 days. interest isn't charged until then. t.deepEqual(calculator.calculate(debtStatus, ONE_DAY), afterOneMonth); @@ -244,19 +207,19 @@ test('reportingPeriod shorter than charging', async t => { t.deepEqual(calculator.calculate(debtStatus, 29n * ONE_DAY), afterOneMonth); t.deepEqual(calculator.calculate(debtStatus, ONE_MONTH + 10n), { latestInterestUpdate: ONE_MONTH + 10n, - interest: AmountMath.make(brand, 204n), - newDebt: AmountMath.make(brand, 100204n), + interest: 204n, + newDebt: 100204n, }); debtStatus = { - newDebt: AmountMath.make(brand, 100204n), - interest: AmountMath.make(brand, 204n), + newDebt: 100204n, + interest: 204n, latestInterestUpdate: ONE_MONTH, }; const afterTwoMonths = { latestInterestUpdate: ONE_MONTH, - interest: AmountMath.make(brand, 204n), - newDebt: AmountMath.make(brand, 100204n), + interest: 204n, + newDebt: 100204n, }; // charging period is 30 days. 2nd interest isn't charged until 60 days. t.deepEqual(calculator.calculate(debtStatus, 32n * ONE_DAY), afterTwoMonths); @@ -265,29 +228,24 @@ test('reportingPeriod shorter than charging', async t => { t.deepEqual(calculator.calculate(debtStatus, 59n * ONE_DAY), afterTwoMonths); t.deepEqual(calculator.calculate(debtStatus, 60n * ONE_DAY), { latestInterestUpdate: 2n * ONE_MONTH, - interest: AmountMath.make(brand, 408n), - newDebt: AmountMath.make(brand, 100408n), + interest: 408n, + newDebt: 100408n, }); }); test('reportingPeriod shorter than charging; start day boundary', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_MONTH, - ONE_DAY, - ); + const calculator = makeInterestCalculator(annualRate, ONE_MONTH, ONE_DAY); const startOneDay = { latestInterestUpdate: ONE_DAY, - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), - interest: AmountMath.makeEmpty(brand), + newDebt: HUNDRED_THOUSAND, + interest: 0n, }; const afterOneDay = { latestInterestUpdate: ONE_DAY, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, HUNDRED_THOUSAND), + interest: 0n, + newDebt: HUNDRED_THOUSAND, }; // no interest charged before a month elapses t.deepEqual(calculator.calculate(startOneDay, 4n * ONE_DAY), afterOneDay); @@ -298,8 +256,8 @@ test('reportingPeriod shorter than charging; start day boundary', async t => { const afterAMonth = { latestInterestUpdate: ONE_MONTH + ONE_DAY, - interest: AmountMath.make(brand, 204n), - newDebt: AmountMath.make(brand, 100204n), + interest: 204n, + newDebt: 100204n, }; // 204n is 2.5% APR charged monthly t.deepEqual( @@ -311,21 +269,16 @@ test('reportingPeriod shorter than charging; start day boundary', async t => { test('reportingPeriod shorter than charging; start not even days', async t => { const { brand } = makeIssuerKit('ducats'); const annualRate = makeRatio(250n, brand, BASIS_POINTS); - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_MONTH, - ONE_DAY, - ); + const calculator = makeInterestCalculator(annualRate, ONE_MONTH, ONE_DAY); const startPartialDay = { latestInterestUpdate: 20n, - newDebt: AmountMath.make(brand, 101000n), - interest: AmountMath.makeEmpty(brand), + newDebt: 101000n, + interest: 0n, }; const afterOneMonth = { latestInterestUpdate: 20n, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, 101000n), + interest: 0n, + newDebt: 101000n, }; t.deepEqual(calculator.calculate(startPartialDay, ONE_MONTH), afterOneMonth); t.deepEqual( @@ -335,8 +288,8 @@ test('reportingPeriod shorter than charging; start not even days', async t => { // interest not charged until ONE_MONTH + 20n t.deepEqual(calculator.calculate(startPartialDay, ONE_MONTH + 20n), { latestInterestUpdate: 20n + ONE_MONTH, - interest: AmountMath.make(brand, 206n), - newDebt: AmountMath.make(brand, 101206n), + interest: 206n, + newDebt: 101206n, }); }); @@ -346,48 +299,43 @@ test('basic charge large numbers, compounding', async t => { const annualRate = makeRatio(250n, brand, BASIS_POINTS); // Unix epoch time: Tuesday April 6th 2021 at 11:45am PT const START_TIME = 1617734746n; - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); // TEN_MILLION is enough to observe compounding const debtStatus = { - newDebt: AmountMath.make(brand, TEN_MILLION), - interest: AmountMath.makeEmpty(brand), + newDebt: TEN_MILLION, + interest: 0n, latestInterestUpdate: START_TIME, }; t.deepEqual(calculator.calculate(debtStatus, START_TIME), { latestInterestUpdate: START_TIME, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, TEN_MILLION), + interest: 0n, + newDebt: TEN_MILLION, }); t.deepEqual(calculator.calculate(debtStatus, START_TIME + 1n), { latestInterestUpdate: START_TIME, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, TEN_MILLION), + interest: 0n, + newDebt: TEN_MILLION, }); // 677n is one day's interest on TEN_MILLION at 2.5% APR, rounded up. t.deepEqual(calculator.calculate(debtStatus, START_TIME + ONE_DAY), { latestInterestUpdate: START_TIME + ONE_DAY, - interest: AmountMath.make(brand, 677n), - newDebt: AmountMath.make(brand, 10000677n), + interest: 677n, + newDebt: 10000677n, }); // two days interest. compounding not visible t.deepEqual( calculator.calculate(debtStatus, START_TIME + ONE_DAY + ONE_DAY), { latestInterestUpdate: START_TIME + ONE_DAY + ONE_DAY, - interest: AmountMath.make(brand, 1354n), - newDebt: AmountMath.make(brand, 10001354n), + interest: 1354n, + newDebt: 10001354n, }, ); // Notice that interest compounds 30 days * 677 = 20310 < 20329 t.deepEqual(calculator.calculate(debtStatus, START_TIME + ONE_MONTH), { latestInterestUpdate: START_TIME + ONE_MONTH, - interest: AmountMath.make(brand, 20329n), - newDebt: AmountMath.make(brand, 10020329n), + interest: 20329n, + newDebt: 10020329n, }); }); @@ -398,38 +346,33 @@ test('basic charge reasonable numbers monthly', async t => { const annualRate = makeRatio(250n, brand, BASIS_POINTS); // Unix epoch time: Tuesday April 6th 2021 at 11:45am PT const START_TIME = 1617734746n; - const calculator = makeInterestCalculator( - brand, - annualRate, - ONE_DAY, - ONE_MONTH, - ); + const calculator = makeInterestCalculator(annualRate, ONE_DAY, ONE_MONTH); // TEN_MILLION is enough to observe compounding const debtStatus = { - newDebt: AmountMath.make(brand, TEN_MILLION), - interest: AmountMath.makeEmpty(brand), + newDebt: TEN_MILLION, + interest: 0n, latestInterestUpdate: START_TIME, }; // don't charge, since a month hasn't elapsed t.deepEqual(calculator.calculateReportingPeriod(debtStatus, START_TIME), { latestInterestUpdate: START_TIME, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, TEN_MILLION), + interest: 0n, + newDebt: TEN_MILLION, }); t.deepEqual( calculator.calculateReportingPeriod(debtStatus, START_TIME + 1n), { latestInterestUpdate: START_TIME, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, TEN_MILLION), + interest: 0n, + newDebt: TEN_MILLION, }, ); t.deepEqual( calculator.calculateReportingPeriod(debtStatus, START_TIME + ONE_DAY), { latestInterestUpdate: START_TIME, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, TEN_MILLION), + interest: 0n, + newDebt: TEN_MILLION, }, ); t.deepEqual( @@ -439,8 +382,8 @@ test('basic charge reasonable numbers monthly', async t => { ), { latestInterestUpdate: START_TIME, - interest: AmountMath.makeEmpty(brand), - newDebt: AmountMath.make(brand, TEN_MILLION), + interest: 0n, + newDebt: TEN_MILLION, }, ); @@ -449,8 +392,8 @@ test('basic charge reasonable numbers monthly', async t => { calculator.calculateReportingPeriod(debtStatus, START_TIME + ONE_MONTH), { latestInterestUpdate: START_TIME + ONE_MONTH, - interest: AmountMath.make(brand, 20329n), - newDebt: AmountMath.make(brand, 10020329n), + interest: 20329n, + newDebt: 10020329n, }, ); const HALF_YEAR = 6n * ONE_MONTH; @@ -460,8 +403,8 @@ test('basic charge reasonable numbers monthly', async t => { calculator.calculateReportingPeriod(debtStatus, START_TIME + HALF_YEAR), { latestInterestUpdate: START_TIME + HALF_YEAR, - interest: AmountMath.make(brand, 122601n), - newDebt: AmountMath.make(brand, 10122601n), + interest: 122601n, + newDebt: 10122601n, }, ); // compounding: 360 days * 677 = 243720 < 246705 @@ -469,8 +412,74 @@ test('basic charge reasonable numbers monthly', async t => { calculator.calculateReportingPeriod(debtStatus, START_TIME + ONE_YEAR), { latestInterestUpdate: START_TIME + ONE_YEAR, - interest: AmountMath.make(brand, 246705n), - newDebt: AmountMath.make(brand, 10246705n), + interest: 246705n, + newDebt: 10246705n, }, ); }); + +test('calculateCompoundedInterest on zero debt', t => { + const brand = Far('brand'); + t.throws(() => + calculateCompoundedInterest(makeRatio(0n, brand, 1n, brand), 0n, 100n), + ); +}); + +// -illions +const M = 1_000_000n; + +test('calculateCompoundedInterest', t => { + const brand = Far('brand'); + /** @type {Array<[bigint, bigint, bigint, number, bigint, number]>} */ + const cases = [ + [250n, BASIS_POINTS, M, 1, 1025000n, 10], // 2.5% APR over 1 year yields 2.5% + [250n, BASIS_POINTS, M, 10, 1280090n, 5], // 2.5% APR over 10 year yields 28% + // XXX resolution was 12 with banker's rounding https://github.com/Agoric/agoric-sdk/issues/4573 + [250n, BASIS_POINTS, M * M, 10, 1280084544199n, 8], // 2.5% APR over 10 year yields 28% + [250n, BASIS_POINTS, M, 100, 11813903n, 5], // 2.5% APR over 100 year yields 1181% + ]; + for (const [ + rateNum, + rateDen, + startingDebt, + charges, + expected, + floatMatch, + ] of cases) { + const apr = makeRatio(rateNum, brand, rateDen, brand); + const aprf = Number(rateNum) / Number(rateDen); + + let compoundedInterest = makeRatio(1n, brand, 1n, brand); + let compoundedFloat = 1.0; + let totalDebt = startingDebt; + + for (let i = 0; i < charges; i += 1) { + compoundedFloat *= 1 + aprf; + const delta = ceilMultiplyBy(AmountMath.make(brand, totalDebt), apr); + compoundedInterest = calculateCompoundedInterest( + compoundedInterest, + totalDebt, + totalDebt + delta.value, + ); + totalDebt += delta.value; + } + t.is( + compoundedFloat.toPrecision(floatMatch), + ( + Number(compoundedInterest.numerator.value) / + Number(compoundedInterest.denominator.value) + ).toPrecision(floatMatch), + `For ${startingDebt} at (${rateNum}/${rateDen})^${charges}, expected compounded ratio to match ${compoundedFloat}`, + ); + t.is( + (compoundedFloat * Number(startingDebt)).toPrecision(floatMatch), + Number(totalDebt).toPrecision(floatMatch), + `For ${startingDebt} at (${rateNum}/${rateDen})^${charges}, expected compounded float ${compoundedFloat} to match debt`, + ); + t.is( + totalDebt, + expected, + `For ${startingDebt} at (${rateNum}/${rateDen})^${charges}, expected ${expected}`, + ); + } +}); diff --git a/packages/run-protocol/test/vaultFactory/test-orderedVaultStore.js b/packages/run-protocol/test/vaultFactory/test-orderedVaultStore.js new file mode 100644 index 00000000000..6feee609456 --- /dev/null +++ b/packages/run-protocol/test/vaultFactory/test-orderedVaultStore.js @@ -0,0 +1,74 @@ +// @ts-check +// Must be first to set up globals +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { AmountMath } from '@agoric/ertp'; +import { Far } from '@endo/marshal'; +import { makeOrderedVaultStore } from '../../src/vaultFactory/orderedVaultStore.js'; +import { fromVaultKey } from '../../src/vaultFactory/storeUtils.js'; + +const brand = Far('brand'); + +const mockVault = (runCount, collateralCount) => { + const debtAmount = AmountMath.make(brand, runCount); + const collateralAmount = AmountMath.make(brand, collateralCount); + + return Far('vault', { + getDebtAmount: () => debtAmount, + getCollateralAmount: () => collateralAmount, + }); +}; + +const BIGGER_INT = BigInt(Number.MAX_VALUE) + 1n; +/** + * Records to be inserted in this order. Jumbled to verify insertion order invariance. + * + * @type {Array<[string, bigint, bigint]>} + */ +const fixture = [ + ['vault-E', 40n, 100n], + ['vault-F', 50n, 100n], + ['vault-M', 1n, 1000n], + ['vault-Y', BIGGER_INT, BIGGER_INT], + ['vault-Z-withoutdebt', 0n, 100n], + ['vault-A-underwater', 1000n, 100n], + ['vault-B', 101n, 1000n], + // because the C vaults all have same ratio, order among them is not defined + ['vault-C1', 100n, 1000n], + ['vault-C2', 200n, 2000n], + ['vault-C3', 300n, 3000n], + ['vault-D', 30n, 100n], +]; + +test('ordering', t => { + const vaults = makeOrderedVaultStore(); + + for (const [vaultId, runCount, collateralCount] of fixture) { + const mockVaultKit = harden({ + vault: mockVault(runCount, collateralCount), + }); + // @ts-expect-error mock + vaults.addVaultKit(vaultId, mockVaultKit); + } + const contents = Array.from(vaults.entries()); + const vaultIds = contents.map(([k, _v]) => fromVaultKey(k)[1]); + // keys were ordered matching the fixture's ordering of vaultId + t.deepEqual(vaultIds, vaultIds.sort()); +}); + +test('uniqueness', t => { + const vaults = makeOrderedVaultStore(); + + for (const [vaultId, runCount, collateralCount] of fixture) { + const mockVaultKit = harden({ + vault: mockVault(runCount, collateralCount), + }); + // @ts-expect-error mock + vaults.addVaultKit(vaultId, mockVaultKit); + } + const numberParts = Array.from(vaults.entries()).map( + ([k, _v]) => k.split(':')[0], + ); + const uniqueNumberParts = Array.from(new Set(numberParts)); + t.is(uniqueNumberParts.length, 9); // of 11, three have the same ratio +}); diff --git a/packages/run-protocol/test/vaultFactory/test-prioritizedVaults.js b/packages/run-protocol/test/vaultFactory/test-prioritizedVaults.js index 470c0ecb7b6..14f68169e91 100644 --- a/packages/run-protocol/test/vaultFactory/test-prioritizedVaults.js +++ b/packages/run-protocol/test/vaultFactory/test-prioritizedVaults.js @@ -6,12 +6,15 @@ import '@agoric/zoe/exported.js'; import { makeIssuerKit, AmountMath } from '@agoric/ertp'; import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; -import { makeNotifierKit } from '@agoric/notifier'; -import { Far } from '@endo/marshal'; import { makePromiseKit } from '@agoric/promise-kit'; -import { makeRatioFromAmounts } from '@agoric/zoe/src/contractSupport/index.js'; -import { makePrioritizedVaults } from '../../src/vaultFactory/prioritizedVaults.js'; +import { + currentDebtToCollateral, + makePrioritizedVaults, +} from '../../src/vaultFactory/prioritizedVaults.js'; +import { makeFakeVaultKit } from '../supports.js'; + +/** @typedef {import('../../src/vaultFactory/vault.js').VaultKit} VaultKit */ // Some notifier updates aren't propogating sufficiently quickly for the tests. // This invocation (thanks to Warner) waits for all promises that can fire to @@ -23,15 +26,20 @@ async function waitForPromisesToSettle() { } function makeCollector() { + /** @type {Ratio[]} */ const ratios = []; - function lookForRatio(vaultPair) { - ratios.push(vaultPair.debtToCollateral); + /** + * + * @param {[string, VaultKit]} record + */ + function lookForRatio([_, vaultKit]) { + ratios.push(currentDebtToCollateral(vaultKit.vault)); } return { lookForRatio, - getRates: () => ratios, + getPercentages: () => ratios.map(r => Number(r.numerator.value)), }; } @@ -50,271 +58,195 @@ function makeRescheduler() { }; } -function makeFakeVaultKit( - initDebt, - initCollateral = AmountMath.make(initDebt.brand, 100n), -) { - let debt = initDebt; - let collateral = initCollateral; - const vault = Far('Vault', { - getCollateralAmount: () => collateral, - getDebtAmount: () => debt, - setDebt: newDebt => (debt = newDebt), - setCollateral: newCollateral => (collateral = newCollateral), - }); - return harden({ - vault, - liquidate: () => {}, - }); -} +const { brand } = makeIssuerKit('ducats'); +const percent = n => makeRatio(BigInt(n), brand); test('add to vault', async t => { - const { brand } = makeIssuerKit('ducats'); - const rescheduler = makeRescheduler(); const vaults = makePrioritizedVaults(rescheduler.fakeReschedule); - const fakeVaultKit = makeFakeVaultKit(AmountMath.make(brand, 130n)); - const { notifier } = makeNotifierKit(); - vaults.addVaultKit(fakeVaultKit, notifier); + vaults.addVaultKit( + 'id-fakeVaultKit', + makeFakeVaultKit('id-fakeVaultKit', AmountMath.make(brand, 130n)), + ); const collector = makeCollector(); - vaults.forEachRatioGTE(makeRatio(1n, brand, 10n), collector.lookForRatio); + // ??? why doesn't this work? + // mapIterable( + // vaults.entriesPrioritizedGTE(makeRatio(1n, brand, 10n)), + // collector.lookForRatio, + // ); + Array.from(vaults.entriesPrioritizedGTE(makeRatio(1n, brand, 10n))).map( + collector.lookForRatio, + ); - const rates = collector.getRates(); - const ratio130 = makeRatio(130n, brand, 100n); - t.deepEqual(rates, [ratio130], 'expected vault'); + t.deepEqual(collector.getPercentages(), [130], 'expected vault'); t.truthy(rescheduler.called(), 'should call reschedule()'); - t.deepEqual(vaults.highestRatio(), undefined); }); test('updates', async t => { - const { brand } = makeIssuerKit('ducats'); - const rescheduler = makeRescheduler(); const vaults = makePrioritizedVaults(rescheduler.fakeReschedule); - const fakeVault1 = makeFakeVaultKit(AmountMath.make(brand, 120n)); - const { updater: updater1, notifier: notifier1 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault1, notifier1); + vaults.addVaultKit( + 'id-fakeVault1', + makeFakeVaultKit('id-fakeVault1', AmountMath.make(brand, 20n)), + ); - const fakeVault2 = makeFakeVaultKit(AmountMath.make(brand, 180n)); - const { updater: updater2, notifier: notifier2 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault2, notifier2); + vaults.addVaultKit( + 'id-fakeVault2', + makeFakeVaultKit('id-fakeVault2', AmountMath.make(brand, 80n)), + ); - // prioritizedVaults doesn't care what the update contains - updater1.updateState({ locked: AmountMath.make(brand, 300n) }); - updater2.updateState({ locked: AmountMath.make(brand, 300n) }); await waitForPromisesToSettle(); const collector = makeCollector(); rescheduler.resetCalled(); - vaults.forEachRatioGTE(makeRatio(1n, brand, 10n), collector.lookForRatio); - const rates = collector.getRates(); - const ratio180 = makeRatio(180n, brand, 100n); - const ratio120 = makeRatio(120n, brand, 100n); - t.deepEqual(rates, [ratio180, ratio120]); + Array.from(vaults.entriesPrioritizedGTE(makeRatio(1n, brand, 10n))).map( + collector.lookForRatio, + ); + t.deepEqual(collector.getPercentages(), [80, 20]); t.falsy(rescheduler.called(), 'second vault did not call reschedule()'); - t.deepEqual(vaults.highestRatio(), undefined); }); test('update changes ratio', async t => { - const { brand } = makeIssuerKit('ducats'); - const rescheduler = makeRescheduler(); const vaults = makePrioritizedVaults(rescheduler.fakeReschedule); - const fakeVault1 = makeFakeVaultKit(AmountMath.make(brand, 120n)); - const { updater: updater1, notifier: notifier1 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault1, notifier1); + const fakeVault1 = makeFakeVaultKit( + 'id-fakeVault1', + AmountMath.make(brand, 20n), + ); + vaults.addVaultKit('id-fakeVault1', fakeVault1); - const fakeVault2 = makeFakeVaultKit(AmountMath.make(brand, 180n)); - const { updater: updater2, notifier: notifier2 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault2, notifier2); + vaults.addVaultKit( + 'id-fakeVault2', + makeFakeVaultKit('id-fakeVault2', AmountMath.make(brand, 80n)), + ); - // prioritizedVaults doesn't care what the update contains - updater1.updateState({ locked: AmountMath.make(brand, 300n) }); - updater2.updateState({ locked: AmountMath.make(brand, 300n) }); await waitForPromisesToSettle(); - const ratio180 = makeRatio(180n, brand, 100n); - t.deepEqual(vaults.highestRatio(), ratio180); + t.deepEqual(Array.from(Array.from(vaults.entries()).map(([k, _v]) => k)), [ + 'fbff4000000000000:id-fakeVault2', + 'fc014000000000000:id-fakeVault1', + ]); + + t.deepEqual(vaults.highestRatio(), percent(80)); + + // update the fake debt of the vault and then refresh priority queue + fakeVault1.vault.setDebt(AmountMath.make(brand, 95n)); + vaults.refreshVaultPriority( + AmountMath.make(brand, 20n), + AmountMath.make(brand, 100n), // default collateral of makeFakeVaultKit + 'id-fakeVault1', + ); - fakeVault1.vault.setDebt(AmountMath.make(brand, 200n)); - updater1.updateState({ locked: AmountMath.make(brand, 300n) }); await waitForPromisesToSettle(); - t.deepEqual(vaults.highestRatio(), makeRatio(200n, brand, 100n)); + t.deepEqual(vaults.highestRatio(), percent(95)); const newCollector = makeCollector(); rescheduler.resetCalled(); - vaults.forEachRatioGTE(makeRatio(190n, brand), newCollector.lookForRatio); - const newRates = newCollector.getRates(); - const ratio200 = makeRatio(200n, brand, 100n); - t.deepEqual(newRates, [ratio200], 'only one is higher than 190'); - t.deepEqual(vaults.highestRatio(), makeRatio(180n, brand, 100n)); - t.truthy(rescheduler.called(), 'called rescheduler when foreach found vault'); + Array.from(vaults.entriesPrioritizedGTE(percent(90))).map( + newCollector.lookForRatio, + ); + t.deepEqual( + newCollector.getPercentages(), + [95], + 'only one is higher than 90%', + ); + t.deepEqual(vaults.highestRatio(), percent(95)); + t.falsy(rescheduler.called(), 'foreach does not trigger rescheduler'); // change from previous implementation }); test('removals', async t => { - const { brand } = makeIssuerKit('ducats'); - const rescheduler = makeRescheduler(); const vaults = makePrioritizedVaults(rescheduler.fakeReschedule); - const fakeVault1 = makeFakeVaultKit(AmountMath.make(brand, 150n)); - const { notifier: notifier1 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault1, notifier1); - const ratio150 = makeRatio(150n, brand); - - const fakeVault2 = makeFakeVaultKit(AmountMath.make(brand, 130n)); - const { notifier: notifier2 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault2, notifier2); - const ratio130 = makeRatio(130n, brand); - - const fakeVault3 = makeFakeVaultKit(AmountMath.make(brand, 140n)); - const { notifier: notifier3 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault3, notifier3); + // Add fakes 1,2,3 + vaults.addVaultKit( + 'id-fakeVault1', + makeFakeVaultKit('id-fakeVault1', AmountMath.make(brand, 150n)), + ); + vaults.addVaultKit( + 'id-fakeVault2', + makeFakeVaultKit('id-fakeVault2', AmountMath.make(brand, 130n)), + ); + vaults.addVaultKit( + 'id-fakeVault3', + makeFakeVaultKit('id-fakeVault3', AmountMath.make(brand, 140n)), + ); + // remove fake 3 rescheduler.resetCalled(); - vaults.removeVault(fakeVault3); + vaults.removeVaultByAttributes( + AmountMath.make(brand, 140n), + AmountMath.make(brand, 100n), // default collateral of makeFakeVaultKit + 'id-fakeVault3', + ); t.falsy(rescheduler.called()); - t.deepEqual(vaults.highestRatio(), ratio150, 'should be 150'); + t.deepEqual(vaults.highestRatio(), percent(150), 'should be 150'); + // remove fake 1 rescheduler.resetCalled(); - vaults.removeVault(fakeVault1); + vaults.removeVaultByAttributes( + AmountMath.make(brand, 150n), + AmountMath.make(brand, 100n), // default collateral of makeFakeVaultKit + 'id-fakeVault1', + ); t.falsy(rescheduler.called(), 'should not call reschedule on removal'); - t.deepEqual(vaults.highestRatio(), ratio130, 'should be 130'); -}); - -test('chargeInterest', async t => { - const { brand } = makeIssuerKit('ducats'); - const rescheduler = makeRescheduler(); - const vaults = makePrioritizedVaults(rescheduler.fakeReschedule); - - const fakeVault1 = makeFakeVaultKit(AmountMath.make(brand, 130n)); - const { notifier: notifier1 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault1, notifier1); - - const fakeVault2 = makeFakeVaultKit(AmountMath.make(brand, 150n)); - const { notifier: notifier2 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault2, notifier2); - - const touchedVaults = []; - vaults.forEachRatioGTE(makeRatio(1n, brand, 10n), vaultPair => - touchedVaults.push([ - vaultPair.vaultKit, - makeRatioFromAmounts( - vaultPair.vaultKit.vault.getDebtAmount(), - vaultPair.vaultKit.vault.getCollateralAmount(), - ), - ]), + t.deepEqual(vaults.highestRatio(), percent(130), 'should be 130'); + + t.throws(() => + vaults.removeVaultByAttributes( + AmountMath.make(brand, 150n), + AmountMath.make(brand, 100n), + 'id-fakeVault1', + ), ); - const cr1 = makeRatio(130n, brand); - const cr2 = makeRatio(150n, brand); - t.deepEqual(touchedVaults, [ - [fakeVault2, cr2], - [fakeVault1, cr1], - ]); }); -test('liquidation', async t => { - const { brand } = makeIssuerKit('ducats'); +test('highestRatio', async t => { const reschedulePriceCheck = makeRescheduler(); const vaults = makePrioritizedVaults(reschedulePriceCheck.fakeReschedule); - const fakeVault1 = makeFakeVaultKit(AmountMath.make(brand, 130n)); - const { notifier: notifier1 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault1, notifier1); - - const fakeVault2 = makeFakeVaultKit(AmountMath.make(brand, 150n)); - const { notifier: notifier2 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault2, notifier2); - const cr2 = makeRatio(150n, brand); - - const fakeVault3 = makeFakeVaultKit(AmountMath.make(brand, 140n)); - const { notifier: notifier3 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault3, notifier3); - const cr3 = makeRatio(140n, brand); - - const touchedVaults = []; - vaults.forEachRatioGTE(makeRatio(135n, brand), vaultPair => - touchedVaults.push(vaultPair), + vaults.addVaultKit( + 'id-fakeVault1', + makeFakeVaultKit('id-fakeVault1', AmountMath.make(brand, 30n)), ); - - t.deepEqual(touchedVaults, [ - { vaultKit: fakeVault2, debtToCollateral: cr2 }, - { vaultKit: fakeVault3, debtToCollateral: cr3 }, - ]); -}); - -test('highestRatio ', async t => { - const { brand } = makeIssuerKit('ducats'); - const reschedulePriceCheck = makeRescheduler(); - const vaults = makePrioritizedVaults(reschedulePriceCheck.fakeReschedule); - - const fakeVault1 = makeFakeVaultKit(AmountMath.make(brand, 130n)); - const { notifier: notifier2 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault1, notifier2); - const cr1 = makeRatio(130n, brand); + const cr1 = percent(30); t.deepEqual(vaults.highestRatio(), cr1); - const fakeVault6 = makeFakeVaultKit(AmountMath.make(brand, 150n)); - const { notifier: notifier1 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault6, notifier1); - const cr6 = makeRatio(150n, brand); + const fakeVault6 = makeFakeVaultKit( + 'id-fakeVault6', + AmountMath.make(brand, 50n), + ); + vaults.addVaultKit('id-fakeVault6', fakeVault6); + const cr6 = percent(50); t.deepEqual(vaults.highestRatio(), cr6); - const fakeVault3 = makeFakeVaultKit(AmountMath.make(brand, 140n)); - const { notifier: notifier3 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault3, notifier3); - const cr3 = makeRatio(140n, brand); - - const touchedVaults = []; - vaults.forEachRatioGTE(makeRatio(145n, brand), vaultPair => - touchedVaults.push(vaultPair), + vaults.addVaultKit( + 'id-fakeVault3', + makeFakeVaultKit('id-fakeVault3', AmountMath.make(brand, 40n)), ); + // sanity check ordering t.deepEqual( - touchedVaults, - [{ vaultKit: fakeVault6, debtToCollateral: cr6 }], - 'expected 150 to be highest', + Array.from(vaults.entries()).map(([k, _v]) => k), + [ + 'fc000000000000000:id-fakeVault6', + 'fc004000000000000:id-fakeVault3', + 'fc00aaaaaaaaaaaab:id-fakeVault1', + ], ); - t.deepEqual(vaults.highestRatio(), cr3); -}); - -test('removal by notification', async t => { - const { brand } = makeIssuerKit('ducats'); - const reschedulePriceCheck = makeRescheduler(); - const vaults = makePrioritizedVaults(reschedulePriceCheck.fakeReschedule); - - const fakeVault1 = makeFakeVaultKit(AmountMath.make(brand, 150n)); - const { updater: updater1, notifier: notifier1 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault1, notifier1); - const cr1 = makeRatio(150n, brand); - t.deepEqual(vaults.highestRatio(), cr1); - - const fakeVault2 = makeFakeVaultKit(AmountMath.make(brand, 130n)); - const { notifier: notifier2 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault2, notifier2); - t.deepEqual(vaults.highestRatio(), cr1, 'should be new highest'); - - const fakeVault3 = makeFakeVaultKit(AmountMath.make(brand, 140n)); - const { notifier: notifier3 } = makeNotifierKit(); - vaults.addVaultKit(fakeVault3, notifier3); - const cr3 = makeRatio(140n, brand); - t.deepEqual(vaults.highestRatio(), cr1, '130 expected'); - updater1.finish('done'); - await waitForPromisesToSettle(); - - t.deepEqual(vaults.highestRatio(), cr3, 'should have removed 150'); - - const touchedVaults = []; - vaults.forEachRatioGTE(makeRatio(135n, brand), vaultPair => - touchedVaults.push(vaultPair), + const debtsOverThreshold = []; + Array.from(vaults.entriesPrioritizedGTE(percent(45))).map(([_key, vk]) => + debtsOverThreshold.push([ + vk.vault.getDebtAmount(), + vk.vault.getCollateralAmount(), + ]), ); - t.deepEqual( - touchedVaults, - [{ vaultKit: fakeVault3, debtToCollateral: cr3 }], - 'should be only one', - ); + t.deepEqual(debtsOverThreshold, [ + [fakeVault6.vault.getDebtAmount(), fakeVault6.vault.getCollateralAmount()], + ]); + t.deepEqual(vaults.highestRatio(), percent(50), 'expected 50% to be highest'); }); diff --git a/packages/run-protocol/test/vaultFactory/test-storeUtils.js b/packages/run-protocol/test/vaultFactory/test-storeUtils.js new file mode 100644 index 00000000000..a9003f422df --- /dev/null +++ b/packages/run-protocol/test/vaultFactory/test-storeUtils.js @@ -0,0 +1,68 @@ +// @ts-check +// Must be first to set up globals +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { AmountMath } from '@agoric/ertp'; +import { Far } from '@endo/marshal'; +import * as StoreUtils from '../../src/vaultFactory/storeUtils.js'; + +export const mockBrand = Far('brand'); + +for (const [before, after] of [ + // matches + [1, 1], + [-1, -1], + [123 / 456, 123 / 456], + [Infinity, Infinity], + [-Infinity, -Infinity], + [NaN, NaN], + [Number.MAX_VALUE, Number.MAX_VALUE], + // changes + [-0, NaN], +]) { + test(`cycle number from DB entry key function: ${before} => ${after}`, t => { + t.is( + StoreUtils.dbEntryKeyToNumber(StoreUtils.numberToDBEntryKey(before)), + after, + ); + }); +} + +for (const [debt, collat, vaultId, expectedKey, numberOut] of [ + [0, 100, 'vault-A', 'fc399000000000000:vault-A', 450359962737049600], // Infinity collateralized but we treat 0 as epsilon for safer serialization + [1, 100, 'vault-B', 'fc059000000000000:vault-B', 100.0], + [1000, 100, 'vault-C', 'fbfb999999999999a:vault-C', 0.1], + [1000, 101, 'vault-D', 'fbfb9db22d0e56042:vault-D', 0.101], + [ + 100, + Number.MAX_SAFE_INTEGER, + 'vault-MAX-COLLATERAL', + 'fc2d47ae147ae147a:vault-MAX-COLLATERAL', + 90071992547409.9, + ], + [ + Number.MAX_SAFE_INTEGER, + 100, + 'vault-MAX-DEBT', + 'fbd09000000000001:vault-MAX-DEBT', + 1.1102230246251567e-14, + ], + [ + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + 'vault-MAX-EVEN', + 'fbff0000000000000:vault-MAX-EVEN', + 1, + ], + [1, 0, 'vault-NOCOLLATERAL', 'f8000000000000000:vault-NOCOLLATERAL', 0], +]) { + test(`vault keys: (${debt}/${collat}, ${vaultId}) => ${expectedKey} ==> ${numberOut}, ${vaultId}`, t => { + const key = StoreUtils.toVaultKey( + AmountMath.make(mockBrand, BigInt(debt)), + AmountMath.make(mockBrand, BigInt(collat)), + String(vaultId), + ); + t.is(key, expectedKey); + t.deepEqual(StoreUtils.fromVaultKey(key), [numberOut, vaultId]); + }); +} diff --git a/packages/run-protocol/test/vaultFactory/test-vault-interest.js b/packages/run-protocol/test/vaultFactory/test-vault-interest.js index 23c7a382a80..5a1536191ef 100644 --- a/packages/run-protocol/test/vaultFactory/test-vault-interest.js +++ b/packages/run-protocol/test/vaultFactory/test-vault-interest.js @@ -1,4 +1,5 @@ // @ts-check + import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import '@agoric/zoe/exported.js'; @@ -9,8 +10,8 @@ import { makeZoeKit } from '@agoric/zoe'; import bundleSource from '@endo/bundle-source'; import { resolve as importMetaResolve } from 'import-meta-resolve'; -import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; import { AmountMath } from '@agoric/ertp'; + import { assert } from '@agoric/assert'; import { makeTracer } from '../../src/makeTracer.js'; @@ -25,13 +26,9 @@ const trace = makeTracer('TestVault'); * @property {ZCFMint} runMint * @property {IssuerKit} collateralKit * @property {Vault} vault - * @property {TimerService} timer + * @property {Function} advanceRecordingPeriod + * @property {Function} setInterestRate */ - -// There's one copy of Vault shared across each test file, so this test runs in -// a separate context from test-vault.js - -/* @type {TestContext} */ let testJig; const setJig = jig => { testJig = jig; @@ -71,10 +68,10 @@ async function launch(zoeP, sourceRoot) { } = testJig; const { brand: runBrand } = runMint.getIssuerRecord(); - const collateral50 = AmountMath.make(collaterlBrand, 50000n); + const collateral50 = AmountMath.make(collaterlBrand, 50n); const proposal = harden({ give: { Collateral: collateral50 }, - want: { RUN: AmountMath.make(runBrand, 70000n) }, + want: { RUN: AmountMath.make(runBrand, 70n) }, }); const payments = harden({ Collateral: collateralMint.mintPayment(collateral50), @@ -87,62 +84,80 @@ async function launch(zoeP, sourceRoot) { }; } -const helperContract = launch(zoe, vaultRoot); - -test('interest', async t => { - const { creatorSeat } = await helperContract; +test('charges', async t => { + const { creatorSeat, creatorFacet } = await launch(zoe, vaultRoot); // Our wrapper gives us a Vault which holds 50 Collateral, has lent out 70 // RUN (charging 3 RUN fee), which uses an automatic market maker that // presents a fixed price of 4 RUN per Collateral. - const { notifier, actions } = await E(creatorSeat).getOfferResult(); - const { - runMint, - collateralKit: { brand: collateralBrand }, - vault, - timer, - } = testJig; + await E(creatorSeat).getOfferResult(); + const { runMint, collateralKit, vault } = testJig; const { brand: runBrand } = runMint.getIssuerRecord(); - const { value: v1, updateCount: c1 } = await E(notifier).getUpdateSince(); - t.deepEqual(v1.debt, AmountMath.make(runBrand, 73500n)); - t.deepEqual(v1.locked, AmountMath.make(collateralBrand, 50000n)); - t.is(c1, 2); + const { brand: cBrand } = collateralKit; + const startingDebt = 74n; t.deepEqual( vault.getDebtAmount(), - AmountMath.make(runBrand, 73_500n), - 'borrower owes 73,500 RUN', + AmountMath.make(runBrand, startingDebt), + 'borrower owes 74 RUN', ); t.deepEqual( vault.getCollateralAmount(), - AmountMath.make(collateralBrand, 50_000n), - 'vault holds 50,000 Collateral', + AmountMath.make(cBrand, 50n), + 'vault holds 50 Collateral', ); - - timer.tick(); - const noInterest = actions.accrueInterestAndAddToPool(1n); - t.truthy(AmountMath.isEqual(noInterest, AmountMath.makeEmpty(runBrand))); - - // { chargingPeriod: 3, recordingPeriod: 9 } charge 2% 3 times - for (let i = 0; i < 12; i += 1) { - timer.tick(); + t.deepEqual(vault.getNormalizedDebt().value, startingDebt); + + let interest = 0n; + for (const [i, charge] of [3n, 4n, 4n, 4n].entries()) { + testJig.advanceRecordingPeriod(); + interest += charge; + t.is( + vault.getDebtAmount().value, + startingDebt + interest, + `interest charge ${i} should have been ${charge}`, + ); + t.is(vault.getNormalizedDebt().value, startingDebt); } - const nextInterest = actions.accrueInterestAndAddToPool( - timer.getCurrentTimestamp(), + // partially payback + const paybackValue = 3n; + const collateralWanted = AmountMath.make(cBrand, 1n); + const paybackAmount = AmountMath.make(runBrand, paybackValue); + const payback = await E(creatorFacet).mintRun(paybackAmount); + const paybackSeat = E(zoe).offer( + vault.makeAdjustBalancesInvitation(), + harden({ + give: { RUN: paybackAmount }, + want: { Collateral: collateralWanted }, + }), + harden({ RUN: payback }), ); - t.truthy( - AmountMath.isEqual(nextInterest, AmountMath.make(runBrand, 70n)), - `interest should be 70, was ${nextInterest.value}`, + await E(paybackSeat).getOfferResult(); + t.deepEqual( + vault.getDebtAmount(), + AmountMath.make(runBrand, startingDebt + interest - paybackValue), ); - const { value: v2, updateCount: c2 } = await E(notifier).getUpdateSince(c1); - t.deepEqual(v2.debt, AmountMath.make(runBrand, 73500n + 70n)); - t.deepEqual(v2.interestRate, makeRatio(5n, runBrand, 100n)); - t.deepEqual(v2.liquidationRatio, makeRatio(105n, runBrand)); - const collateralization = v2.collateralizationRatio; - t.truthy( - collateralization.numerator.value > collateralization.denominator.value, + const normalizedPaybackValue = paybackValue + 1n; + t.deepEqual( + vault.getNormalizedDebt(), + AmountMath.make(runBrand, startingDebt - normalizedPaybackValue), ); - t.is(c2, 3); + + testJig.setInterestRate(25n); + + for (const [i, charge] of [21n, 27n, 33n].entries()) { + testJig.advanceRecordingPeriod(); + interest += charge; + t.is( + vault.getDebtAmount().value, + startingDebt + interest - paybackValue, + `interest charge ${i} should have been ${charge}`, + ); + t.is( + vault.getNormalizedDebt().value, + startingDebt - normalizedPaybackValue, + ); + } }); diff --git a/packages/run-protocol/test/vaultFactory/test-vault.js b/packages/run-protocol/test/vaultFactory/test-vault.js index d1af8e139ec..46ebe8385d8 100644 --- a/packages/run-protocol/test/vaultFactory/test-vault.js +++ b/packages/run-protocol/test/vaultFactory/test-vault.js @@ -13,6 +13,7 @@ import { resolve as importMetaResolve } from 'import-meta-resolve'; import { makeIssuerKit, AmountMath } from '@agoric/ertp'; import { assert } from '@agoric/assert'; +import { makeFakeLiveSlotsStuff } from '@agoric/swingset-vat/tools/fakeVirtualSupport.js'; import { makeTracer } from '../../src/makeTracer.js'; const vaultRoot = './vault-contract-wrapper.js'; @@ -26,10 +27,9 @@ const trace = makeTracer('TestVault'); * @property {ZCFMint} runMint * @property {IssuerKit} collateralKit * @property {Vault} vault - * @property {TimerService} timer + * @property {Function} advanceRecordingPeriod + * @property {Function} setInterestRate */ - -/** @type {TestContext} */ let testJig; const setJig = jig => { testJig = jig; @@ -219,3 +219,12 @@ test('bad collateral', async t => { // t.rejects(p, / /, 'addCollateral requires the right kind', {}); // t.throws(async () => { await p; }, /was not a live payment/); }); + +test('serializable with collectionManager', async t => { + // Necessary to initialize the testjig + await helperContract; + + const { vault } = testJig; + const stuff = makeFakeLiveSlotsStuff(); + t.notThrows(() => stuff.marshal.serialize(vault)); +}); diff --git a/packages/run-protocol/test/vaultFactory/test-vaultFactory.js b/packages/run-protocol/test/vaultFactory/test-vaultFactory.js index a30fbe66bac..c6bdc19bbb6 100644 --- a/packages/run-protocol/test/vaultFactory/test-vaultFactory.js +++ b/packages/run-protocol/test/vaultFactory/test-vaultFactory.js @@ -427,7 +427,6 @@ test('first', async t => { 'vault is cleared', ); - t.is(await E(vault).getLiquidationPromise(), 'Liquidated'); const liquidations = await E( E(vault).getLiquidationSeat(), ).getCurrentAllocation(); @@ -504,10 +503,12 @@ test('price drop', async t => { 'borrower RUN amount does not match', ); - const notification1 = await E(uiNotifier).getUpdateSince(); - t.falsy(notification1.value.liquidated); + /** @type {UpdateRecord} */ + let notification = await E(uiNotifier).getUpdateSince(); + + t.falsy(notification.value.liquidated); t.deepEqual( - await notification1.value.collateralizationRatio, + await notification.value.collateralizationRatio, makeRatio(444n, runBrand, 284n), ); const { RUN: lentAmount } = await E(loanSeat).getCurrentAllocation(); @@ -519,25 +520,18 @@ test('price drop', async t => { ); await manualTimer.tick(); - const notification2 = await E(uiNotifier).getUpdateSince(); - t.is(notification2.updateCount, 2); - t.falsy(notification2.value.liquidated); - - await manualTimer.tick(); - const notification3 = await E(uiNotifier).getUpdateSince(); - t.is(notification3.updateCount, 2); - t.falsy(notification3.value.liquidated); + notification = await E(uiNotifier).getUpdateSince(); + t.falsy(notification.value.liquidated); await manualTimer.tick(); - const notification4 = await E(uiNotifier).getUpdateSince(2); - t.falsy(notification4.value.liquidated); - t.is(notification4.value.vaultState, VaultState.LIQUIDATING); + notification = await E(uiNotifier).getUpdateSince(notification.updateCount); + t.falsy(notification.value.liquidated); + t.is(notification.value.vaultState, VaultState.LIQUIDATING); await manualTimer.tick(); - const notification5 = await E(uiNotifier).getUpdateSince(3); - - t.falsy(notification5.updateCount); - t.truthy(notification5.value.liquidated); + notification = await E(uiNotifier).getUpdateSince(notification.updateCount); + t.falsy(notification.updateCount); + t.truthy(notification.value.liquidated); const debtAmountAfter = await E(vault).getDebtAmount(); const finalNotification = await E(uiNotifier).getUpdateSince(); @@ -552,7 +546,6 @@ test('price drop', async t => { RUN: AmountMath.make(runBrand, 14n), }); - t.is(await E(vault).getLiquidationPromise(), 'Liquidated'); const liquidations = await E( E(vault).getLiquidationSeat(), ).getCurrentAllocation(); @@ -619,6 +612,7 @@ test('price falls precipitously', async t => { }), ); + /** @type {{vault: Vault}} */ const { vault } = await E(loanSeat).getOfferResult(); const debtAmount = await E(vault).getDebtAmount(); const fee = ceilMultiplyBy(AmountMath.make(runBrand, 370n), rates.loanFee); @@ -653,20 +647,32 @@ test('price falls precipitously', async t => { }), ); + async function assertDebtIs(value) { + const debt = await E(vault).getDebtAmount(); + t.is( + debt.value, + BigInt(value), + `Expected debt ${debt.value} to be ${value}`, + ); + } + await manualTimer.tick(); - t.falsy(AmountMath.isEmpty(await E(vault).getDebtAmount())); - await manualTimer.tick(); - t.falsy(AmountMath.isEmpty(await E(vault).getDebtAmount())); - await manualTimer.tick(); - t.falsy(AmountMath.isEmpty(await E(vault).getDebtAmount())); + await assertDebtIs(debtAmount.value); + await manualTimer.tick(); + await assertDebtIs(debtAmount.value); - t.is(await E(vault).getLiquidationPromise(), 'Liquidated'); + await manualTimer.tick(); + await assertDebtIs(debtAmount.value); + await manualTimer.tick(); + await waitForPromisesToSettle(); // An emergency liquidation got less than full value const newDebtAmount = await E(vault).getDebtAmount(); - - t.truthy(AmountMath.isGTE(AmountMath.make(runBrand, 70n), newDebtAmount)); + t.truthy( + AmountMath.isGTE(AmountMath.make(runBrand, 70n), newDebtAmount), + `Expected ${newDebtAmount.value} to be less than 70`, + ); t.deepEqual(await E(vaultFactory).getRewardAllocation(), { RUN: AmountMath.make(runBrand, 19n), @@ -850,6 +856,7 @@ test('interest on multiple vaults', async t => { ); // { chargingPeriod: weekly, recordingPeriod: weekly } + // Advance 8 days, past one charging and recording period for (let i = 0; i < 8; i += 1) { manualTimer.tick(); } @@ -858,7 +865,7 @@ test('interest on multiple vaults', async t => { const aliceUpdate = await E(aliceNotifier).getUpdateSince(); const bobUpdate = await E(bobNotifier).getUpdateSince(); // 160 is initial fee. interest is 3n/week. compounding is in the noise. - const bobAddedDebt = 160n + 4n; + const bobAddedDebt = 160n + 3n; t.deepEqual( bobUpdate.value.debt, AmountMath.make(runBrand, 3200n + bobAddedDebt), @@ -874,13 +881,18 @@ test('interest on multiple vaults', async t => { bobCollateralization.denominator.value, ); - // 236 is the initial fee. Interest is 4n/week - const aliceAddedDebt = 236n + 4n; + // 236 is the initial fee. Interest is ~3n/week + const aliceAddedDebt = 236n + 3n; t.deepEqual( aliceUpdate.value.debt, AmountMath.make(runBrand, 4700n + aliceAddedDebt), `should have collected ${aliceAddedDebt}`, ); + // but no change to the snapshot + t.deepEqual(aliceUpdate.value.debtSnapshot, { + run: AmountMath.make(runBrand, 4935n), + interest: makeRatio(100n, runBrand, 100n), + }); t.deepEqual(aliceUpdate.value.interestRate, interestRate); t.deepEqual(aliceUpdate.value.liquidationRatio, makeRatio(105n, runBrand)); const aliceCollateralization = aliceUpdate.value.collateralizationRatio; @@ -888,16 +900,70 @@ test('interest on multiple vaults', async t => { aliceCollateralization.numerator.value > aliceCollateralization.denominator.value, ); + t.is( + aliceCollateralization.denominator.value, + aliceUpdate.value.debt.value, + `Debt in collateralizationRatio should match actual debt`, + ); const rewardAllocation = await E(vaultFactory).getRewardAllocation(); + const rewardRunCount = aliceAddedDebt + bobAddedDebt + 1n; // +1 due to rounding t.truthy( AmountMath.isEqual( rewardAllocation.RUN, - AmountMath.make(runBrand, aliceAddedDebt + bobAddedDebt), + AmountMath.make(runBrand, rewardRunCount), ), // reward includes 5% fees on two loans plus 1% interest three times on each - `Should be ${aliceAddedDebt + bobAddedDebt}, was ${rewardAllocation.RUN}`, + `Should be ${rewardRunCount}, was ${rewardAllocation.RUN.value}`, ); + + // try opening a vault that can't cover fees + const caroleLoanSeat = await E(zoe).offer( + E(lender).makeLoanInvitation(), + harden({ + give: { Collateral: AmountMath.make(aethBrand, 200n) }, + want: { RUN: AmountMath.make(runBrand, 0n) }, // no debt + }), + harden({ + Collateral: aethMint.mintPayment(AmountMath.make(aethBrand, 200n)), + }), + ); + await t.throwsAsync(E(caroleLoanSeat).getOfferResult()); + + // Advance another 7 days, past one charging and recording period + for (let i = 0; i < 8; i += 1) { + manualTimer.tick(); + } + await waitForPromisesToSettle(); + + // open a vault when manager's interest already compounded + const wantedRun = 1_000n; + const danLoanSeat = await E(zoe).offer( + E(lender).makeLoanInvitation(), + harden({ + give: { Collateral: AmountMath.make(aethBrand, 2_000n) }, + want: { RUN: AmountMath.make(runBrand, wantedRun) }, + }), + harden({ + Collateral: aethMint.mintPayment(AmountMath.make(aethBrand, 2_000n)), + }), + ); + /** @type {{vault: Vault, uiNotifier: Notifier<*>}} */ + const { vault: danVault, uiNotifier: danNotifier } = await E( + danLoanSeat, + ).getOfferResult(); + const danActualDebt = wantedRun + 50n; // includes fees + t.is((await E(danVault).getDebtAmount()).value, danActualDebt); + const normalizedDebt = (await E(danVault).getNormalizedDebt()).value; + t.true( + normalizedDebt < danActualDebt, + `Normalized debt ${normalizedDebt} must be less than actual ${danActualDebt} (after any time elapsed)`, + ); + t.is((await E(danVault).getNormalizedDebt()).value, 1_047n); + const danUpdate = await E(danNotifier).getUpdateSince(); + // snapshot should equal actual since no additional time has elapsed + const { debtSnapshot: danSnap } = danUpdate.value; + t.is(danSnap.run.value, danActualDebt); }); test('adjust balances', async t => { @@ -979,6 +1045,10 @@ test('adjust balances', async t => { const aliceCollateralization1 = aliceUpdate.value.collateralizationRatio; t.deepEqual(aliceCollateralization1.numerator.value, 15000n); t.deepEqual(aliceCollateralization1.denominator.value, runDebtLevel.value); + t.deepEqual(aliceUpdate.value.debtSnapshot, { + run: AmountMath.make(runBrand, 5250n), + interest: makeRatio(100n, runBrand), + }); // increase collateral 1 ///////////////////////////////////// (give both) @@ -1083,6 +1153,10 @@ test('adjust balances', async t => { ceilMultiplyBy(collateralLevel, priceConversion), ); t.deepEqual(aliceCollateralization3.denominator, runDebtLevel); + t.deepEqual(aliceUpdate.value.debtSnapshot, { + run: AmountMath.make(runBrand, 5253n), + interest: makeRatio(100n, runBrand), + }); // reduce collateral ///////////////////////////////////// (want both) @@ -1778,7 +1852,6 @@ test('close loan', async t => { AmountMath.makeEmpty(aethBrand), ); - t.is(await E(aliceVault).getLiquidationPromise(), 'Closed'); t.deepEqual( await E(E(aliceVault).getLiquidationSeat()).getCurrentAllocation(), {}, diff --git a/packages/run-protocol/test/vaultFactory/vault-contract-wrapper.js b/packages/run-protocol/test/vaultFactory/vault-contract-wrapper.js index 3e71d114e37..3480591ad3f 100644 --- a/packages/run-protocol/test/vaultFactory/vault-contract-wrapper.js +++ b/packages/run-protocol/test/vaultFactory/vault-contract-wrapper.js @@ -1,20 +1,25 @@ -// @ts-nocheck +// @ts-check import '@agoric/zoe/src/types.js'; -import { makeIssuerKit, AssetKind } from '@agoric/ertp'; +import { makeIssuerKit, AssetKind, AmountMath } from '@agoric/ertp'; import { assert } from '@agoric/assert'; import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; import { makeFakePriceAuthority } from '@agoric/zoe/tools/fakePriceAuthority.js'; -import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { + makeRatio, + multiplyRatios, +} from '@agoric/zoe/src/contractSupport/ratio.js'; import { Far } from '@endo/marshal'; +import { makeNotifierKit } from '@agoric/notifier'; import { makeVaultKit } from '../../src/vaultFactory/vault.js'; import { paymentFromZCFMint } from '../../src/vaultFactory/burn.js'; const BASIS_POINTS = 10000n; const SECONDS_PER_HOUR = 60n * 60n; +const DAY = SECONDS_PER_HOUR * 24n; /** @type {ContractStartFn} */ export async function start(zcf, privateArgs) { @@ -30,6 +35,11 @@ export async function start(zcf, privateArgs) { const { zcfSeat: vaultFactorySeat } = zcf.makeEmptySeatKit(); + let vaultCounter = 0; + + let currentInterest = makeRatio(5n, runBrand); // 5% + let compoundedInterest = makeRatio(100n, runBrand); // starts at 1.0, no interest + function reallocateReward(amount, fromSeat, otherSeat) { vaultFactorySeat.incrementBy( fromSeat.decrementBy( @@ -45,7 +55,7 @@ export async function start(zcf, privateArgs) { } } - /** @type {InnerVaultManager} */ + /** @type {Parameters[1]} */ const managerMock = Far('vault manager mock', { getLiquidationMargin() { return makeRatio(105n, runBrand); @@ -54,21 +64,32 @@ export async function start(zcf, privateArgs) { return makeRatio(500n, runBrand, BASIS_POINTS); }, getInterestRate() { - return makeRatio(5n, runBrand); + return currentInterest; }, getCollateralBrand() { return collateralBrand; }, getChargingPeriod() { - return SECONDS_PER_HOUR * 24n; + return DAY; }, getRecordingPeriod() { - return SECONDS_PER_HOUR * 24n * 7n; + return DAY; }, reallocateReward, + applyDebtDelta() {}, + getCollateralQuote() { + return Promise.resolve({ + quoteAmount: AmountMath.make(runBrand, 0n), + quotePayment: null, + }); + }, + getCompoundedInterest: () => compoundedInterest, + updateVaultPriority: () => { + // noop + }, }); - const timer = buildManualTimer(console.log, 0n, SECONDS_PER_HOUR * 24n); + const timer = buildManualTimer(console.log, 0n, DAY); const options = { actualBrandIn: collateralBrand, actualBrandOut: runBrand, @@ -79,15 +100,46 @@ export async function start(zcf, privateArgs) { }; const priceAuthority = makeFakePriceAuthority(options); - const { vault, openLoan, accrueInterestAndAddToPool } = await makeVaultKit( + const { notifier: managerNotifier } = makeNotifierKit(); + + const { + vault, + actions: { openLoan }, + } = await makeVaultKit( zcf, managerMock, + managerNotifier, + // eslint-disable-next-line no-plusplus + String(vaultCounter++), runMint, priceAuthority, - timer.getCurrentTimestamp(), ); - zcf.setTestJig(() => ({ collateralKit, runMint, vault, timer })); + const advanceRecordingPeriod = () => { + timer.tick(); + + // skip the debt calculation for this mock manager + const currentInterestAsMultiplicand = makeRatio( + 100n + currentInterest.numerator.value, + currentInterest.numerator.brand, + ); + compoundedInterest = multiplyRatios( + compoundedInterest, + currentInterestAsMultiplicand, + ); + }; + + const setInterestRate = percent => { + currentInterest = makeRatio(percent, runBrand); + }; + + zcf.setTestJig(() => ({ + advanceRecordingPeriod, + collateralKit, + runMint, + setInterestRate, + vault, + })); async function makeHook(seat) { const { notifier } = await openLoan(seat); @@ -100,7 +152,6 @@ export async function start(zcf, privateArgs) { add() { return vault.makeAdjustBalancesInvitation(); }, - accrueInterestAndAddToPool, }), notifier, }; diff --git a/packages/zoe/src/contractSupport/ratio.js b/packages/zoe/src/contractSupport/ratio.js index 200dc7e6d04..d8f70180221 100644 --- a/packages/zoe/src/contractSupport/ratio.js +++ b/packages/zoe/src/contractSupport/ratio.js @@ -59,7 +59,13 @@ export const assertIsRatio = ratio => { ); }; -/** @type {MakeRatio} */ +/** + * @param {bigint} numerator + * @param {Brand} numeratorBrand + * @param {bigint=} denominator The default denominator is 100 + * @param {Brand=} denominatorBrand The default is to reuse the numeratorBrand + * @returns {Ratio} + */ export const makeRatio = ( numerator, numeratorBrand, @@ -77,14 +83,19 @@ export const makeRatio = ( }); }; -/** @type {MakeRatioFromAmounts} */ +/** + * @param {Amount} numeratorAmount + * @param {Amount} denominatorAmount + * @returns {Ratio} + */ export const makeRatioFromAmounts = (numeratorAmount, denominatorAmount) => { AmountMath.coerce(numeratorAmount.brand, numeratorAmount); AmountMath.coerce(denominatorAmount.brand, denominatorAmount); return makeRatio( - /** @type {NatValue} */ (numeratorAmount.value), + // @ts-ignore value can be any AmountValue but makeRatio() supports only bigint + numeratorAmount.value, numeratorAmount.brand, - /** @type {NatValue} */ (denominatorAmount.value), + denominatorAmount.value, denominatorAmount.brand, ); }; @@ -108,13 +119,15 @@ const multiplyHelper = (amount, ratio, divideOp) => { ); }; -/** @type {FloorMultiplyBy} */ +/** @type {ScaleAmount} */ export const floorMultiplyBy = (amount, ratio) => { + // @ts-ignore cast return multiplyHelper(amount, ratio, floorDivide); }; -/** @type {CeilMultiplyBy} */ +/** @type {ScaleAmount} */ export const ceilMultiplyBy = (amount, ratio) => { + // @ts-ignore cast return multiplyHelper(amount, ratio, ceilDivide); }; @@ -137,17 +150,23 @@ const divideHelper = (amount, ratio, divideOp) => { ); }; -/** @type {FloorDivideBy} */ +/** @type {ScaleAmount} */ export const floorDivideBy = (amount, ratio) => { + // @ts-ignore cast return divideHelper(amount, ratio, floorDivide); }; -/** @type {CeilDivideBy} */ +/** @type {ScaleAmount} */ export const ceilDivideBy = (amount, ratio) => { + // @ts-ignore cast return divideHelper(amount, ratio, ceilDivide); }; -/** @type {InvertRatio} */ +/** + * + * @param {Ratio} ratio + * @returns {Ratio} + */ export const invertRatio = ratio => { assertIsRatio(ratio); @@ -159,7 +178,11 @@ export const invertRatio = ratio => { ); }; -/** @type {AddRatios} */ +/** + * @param {Ratio} left + * @param {Ratio} right + * @returns {Ratio} + */ export const addRatios = (left, right) => { assertIsRatio(right); assertIsRatio(left); @@ -180,7 +203,11 @@ export const addRatios = (left, right) => { ); }; -/** @type {MultiplyRatios} */ +/** + * @param {Ratio} left + * @param {Ratio} right + * @returns {Ratio} + */ export const multiplyRatios = (left, right) => { assertIsRatio(right); assertIsRatio(left); @@ -198,8 +225,12 @@ export const multiplyRatios = (left, right) => { ); }; -// If ratio is between 0 and 1, subtract from 1. -/** @type {OneMinus} */ +/** + * If ratio is between 0 and 1, subtract from 1. + * + * @param {Ratio} ratio + * @returns {Ratio} + */ export const oneMinus = ratio => { assertIsRatio(ratio); assert( @@ -213,8 +244,47 @@ export const oneMinus = ratio => { return makeRatio( subtract(ratio.denominator.value, ratio.numerator.value), ratio.numerator.brand, - // @ts-ignore asserts ensure values are Nats + // @ts-ignore value can be any AmountValue but makeRatio() supports only bigint ratio.denominator.value, ratio.numerator.brand, ); }; + +/** + * + * @param {Ratio} left + * @param {Ratio} right + * @returns {boolean} + */ +export const ratioGTE = (left, right) => { + assert( + left.numerator.brand === right.numerator.brand && + left.denominator.brand === right.denominator.brand, + `brands must match`, + ); + return natSafeMath.isGTE( + multiply(left.numerator.value, right.denominator.value), + multiply(right.numerator.value, left.denominator.value), + ); +}; + +/** + * Make an equivalant ratio with a new denominator + * + * @param {Ratio} ratio + * @param {bigint} newDen + * @returns {Ratio} + */ +export const quantize = (ratio, newDen) => { + const oldDen = ratio.denominator.value; + const oldNum = ratio.numerator.value; + const newNum = + // TODO adopt banker's rounding https://github.com/Agoric/agoric-sdk/issues/4573 + newDen === oldDen ? oldNum : ceilDivide(oldNum * newDen, oldDen); + return makeRatio( + newNum, + ratio.numerator.brand, + newDen, + ratio.denominator.brand, + ); +}; diff --git a/packages/zoe/src/contractSupport/types.js b/packages/zoe/src/contractSupport/types.js index 3e8a350a93b..0bc5d839928 100644 --- a/packages/zoe/src/contractSupport/types.js +++ b/packages/zoe/src/contractSupport/types.js @@ -123,69 +123,13 @@ /** * @typedef {Object} Ratio - * @property {Amount} numerator - * @property {Amount} denominator + * @property {Amount} numerator + * @property {Amount} denominator */ /** - * @callback MakeRatio - * @param {bigint} numerator - * @param {Brand} numeratorBrand - * @param {bigint=} denominator The default denominator is 100 - * @param {Brand=} denominatorBrand The default is to reuse the numeratorBrand - * @returns {Ratio} - */ - -/** - * @callback MakeRatioFromAmounts - * @param {Amount} numerator - * @param {Amount} denominator - * @returns {Ratio} - */ - -/** - * @callback MultiplyBy - * @param {Amount} amount - * @param {Ratio} ratio - * @returns {Amount} - */ - -/** - * @callback DivideBy + * @callback ScaleAmount * @param {Amount} amount * @param {Ratio} ratio - * @returns {Amount} - */ - -/** - * @typedef {MultiplyBy} CeilMultiplyBy - * @typedef {MultiplyBy} FloorMultiplyBy - * @typedef {DivideBy} FloorDivideBy - * @typedef {DivideBy} CeilDivideBy - */ - -/** - * @callback InvertRatio - * @param {Ratio} ratio - * @returns {Ratio} - */ - -/** - * @callback OneMinus - * @param {Ratio} ratio - * @returns {Ratio} - */ - -/** - * @callback AddRatios - * @param {Ratio} left - * @param {Ratio} right - * @returns {Ratio} - */ - -/** - * @callback MultiplyRatios - * @param {Ratio} left - * @param {Ratio} right - * @returns {Ratio} + * @returns {Amount} */ diff --git a/packages/zoe/test/unitTests/contractSupport/test-ratio.js b/packages/zoe/test/unitTests/contractSupport/test-ratio.js index 59fdf625755..65fdba5d638 100644 --- a/packages/zoe/test/unitTests/contractSupport/test-ratio.js +++ b/packages/zoe/test/unitTests/contractSupport/test-ratio.js @@ -16,7 +16,8 @@ import { oneMinus, multiplyRatios, addRatios, -} from '../../../src/contractSupport/index.js'; + quantize, +} from '../../../src/contractSupport/ratio.js'; function amountsEqual(t, a1, a2, brand) { const brandEqual = a1.brand === a2.brand; @@ -334,3 +335,37 @@ test('ratio - complement', t => { message: /Parameter must be less than or equal to 1: .*/, }); }); + +// Rounding +const { brand } = makeIssuerKit('moe'); + +test('ratio - quantize', t => { + /** @type {Array<[numBefore: bigint, denBefore: bigint, numAfter: bigint, denAfter: bigint]>} */ + const cases = /** @type {const} */ [ + [1n, 1n, 1n, 1n], + [10n, 10n, 10n, 10n], + [2n * 10n ** 9n, 1n * 10n ** 9n, 20n, 10n], + + [12345n, 12345n, 100n, 100n], + [12345n, 12345n, 100000n, 100000n], + [12345n, 12345n, 10n ** 15n, 10n ** 15n], + + [12345n, 123n, 100365854n, 10n ** 6n], + [12345n, 123n, 10036586n, 10n ** 5n], + [12345n, 123n, 1003659n, 10n ** 4n], + [12345n, 123n, 100366n, 10n ** 3n], + [12345n, 123n, 10037n, 10n ** 2n], + [12345n, 123n, 1004n, 10n ** 1n], + [12345n, 123n, 101n, 10n ** 0n], + ]; + + for (const [numBefore, denBefore, numAfter, denAfter] of cases) { + const before = makeRatio(numBefore, brand, denBefore, brand); + const after = makeRatio(numAfter, brand, denAfter, brand); + t.deepEqual( + quantize(before, denAfter), + after, + `${numBefore}/${denBefore} quantized to ${denAfter} should be ${numAfter}/${denAfter}`, + ); + } +});