diff --git a/packages/run-protocol/src/runStake/runStakeKit.js b/packages/run-protocol/src/runStake/runStakeKit.js index d21d513d03d..5b44f9aff57 100644 --- a/packages/run-protocol/src/runStake/runStakeKit.js +++ b/packages/run-protocol/src/runStake/runStakeKit.js @@ -166,7 +166,7 @@ const helperBehavior = { snapshotState: ({ state, facets }, newActive) => { const { debtSnapshot: debt, interestSnapshot: interest, manager } = state; const { helper } = facets; - /** @type {VaultUIState} */ + /** @type {VaultNotification} */ const result = harden({ // TODO move manager state to a separate notifer https://github.com/Agoric/agoric-sdk/issues/4540 interestRate: manager.getInterestRate(), diff --git a/packages/run-protocol/src/vaultFactory/prioritizedVaults.js b/packages/run-protocol/src/vaultFactory/prioritizedVaults.js index f38074536e7..4baad985d2c 100644 --- a/packages/run-protocol/src/vaultFactory/prioritizedVaults.js +++ b/packages/run-protocol/src/vaultFactory/prioritizedVaults.js @@ -174,6 +174,7 @@ export const makePrioritizedVaults = (reschedulePriceCheck = () => {}) => { addVault, entries: vaults.entries, entriesPrioritizedGTE, + getCount: vaults.getSize, highestRatio: firstDebtRatio, refreshVaultPriority, removeVault, diff --git a/packages/run-protocol/src/vaultFactory/types.js b/packages/run-protocol/src/vaultFactory/types.js index 22a8cad0392..5540751c92a 100644 --- a/packages/run-protocol/src/vaultFactory/types.js +++ b/packages/run-protocol/src/vaultFactory/types.js @@ -1,7 +1,7 @@ // @ts-check /** - * @typedef {import('./vault').VaultUIState} VaultUIState + * @typedef {import('./vault').VaultNotification} VaultNotification * @typedef {import('./vault').Vault} Vault * @typedef {import('./vaultKit').VaultKit} VaultKit * @typedef {import('./vaultManager').VaultManager} VaultManager @@ -51,6 +51,7 @@ * @property {() => Allocation} getRewardAllocation * @property {() => Instance} getContractGovernor * @property {() => Promise} makeCollectFeesInvitation + * @property {() => void} updateMetrics */ /** diff --git a/packages/run-protocol/src/vaultFactory/vault.js b/packages/run-protocol/src/vaultFactory/vault.js index fd74b0d2c62..65ca8db0ee1 100644 --- a/packages/run-protocol/src/vaultFactory/vault.js +++ b/packages/run-protocol/src/vaultFactory/vault.js @@ -66,7 +66,7 @@ const validTransitions = { /** * @typedef {Phase[keyof typeof Phase]} TitlePhase * - * @typedef {object} VaultUIState + * @typedef {object} VaultNotification * @property {Amount<'nat'>} locked Amount of Collateral locked * @property {{debt: Amount<'nat'>, interest: Ratio}} debtSnapshot 'debt' at the point the compounded interest was 'interest' * @property {Ratio} interestRate Annual interest rate charge @@ -74,6 +74,7 @@ const validTransitions = { * @property {TitlePhase} vaultState */ +// XXX masks typedef from types.js, but using that causes circular def problems /** * @typedef {object} VaultManager * @property {() => Notifier} getNotifier @@ -101,7 +102,7 @@ const validTransitions = { * * @typedef {{ * interestSnapshot: Ratio, - * outerUpdater: IterationObserver | null, + * outerUpdater: IterationObserver | null, * phase: VaultPhase, * debtSnapshot: Amount<'nat'>, * }} MutableState @@ -302,7 +303,7 @@ const helperBehavior = { */ getStateSnapshot: ({ state, facets }, newPhase) => { const { debtSnapshot: debt, interestSnapshot: interest } = state; - /** @type {VaultUIState} */ + /** @type {VaultNotification} */ return harden({ // TODO move manager state to a separate notifer https://github.com/Agoric/agoric-sdk/issues/4540 interestRate: state.manager.getGovernedParams().getInterestRate(), diff --git a/packages/run-protocol/src/vaultFactory/vaultDirector.js b/packages/run-protocol/src/vaultFactory/vaultDirector.js index 0ee764c7d9a..9acd8971973 100644 --- a/packages/run-protocol/src/vaultFactory/vaultDirector.js +++ b/packages/run-protocol/src/vaultFactory/vaultDirector.js @@ -18,7 +18,7 @@ import { Far } from '@endo/marshal'; import { AmountMath } from '@agoric/ertp'; import { assertKeywordName } from '@agoric/zoe/src/cleanProposal.js'; import { defineKindMulti } from '@agoric/vat-data'; -import { observeIteration } from '@agoric/notifier'; +import { makeSubscriptionKit, observeIteration } from '@agoric/notifier'; import { makeVaultManager } from './vaultManager.js'; import { makeMakeCollectFeesInvitation } from '../collectFees.js'; import { @@ -31,11 +31,18 @@ import { const { details: X } = assert; /** + * @typedef {{ + * collaterals: Brand[], + * rewardPoolAllocation: AmountKeywordRecord, + * }} MetricsNotification + * * @typedef {Readonly<{ * debtMint: ZCFMint<'nat'>, * collateralTypes: Store, * electionManager: Instance, * directorParamManager: import('@agoric/governance/src/contractGovernance/typedParamManager').TypedParamManager, + * metricsPublication: IterationObserver + * metricsSubscription: Subscription * mintSeat: ZCFSeat, * rewardPoolSeat: ZCFSeat, * vaultParamManagers: Store, @@ -68,10 +75,15 @@ const initState = (zcf, directorParamManager, debtMint) => { const vaultParamManagers = makeScalarMap('brand'); + const { publication: metricsPublication, subscription: metricsSubscription } = + makeSubscriptionKit(); + return { collateralTypes, debtMint, directorParamManager, + metricsSubscription, + metricsPublication, mintSeat, rewardPoolSeat, vaultParamManagers, @@ -149,9 +161,8 @@ const getCollaterals = async ({ state }) => { ), ); }; - /** - * @param {import('@agoric/governance/src/contractGovernance/typedParamManager').TypedParamManager} directorParamManager + * @param {ImmutableState['directorParamManager']} directorParamManager */ const getLiquidationConfig = directorParamManager => ({ install: directorParamManager.getLiquidationInstall(), @@ -160,7 +171,7 @@ const getLiquidationConfig = directorParamManager => ({ /** * - * @param {*} govParams + * @param {ImmutableState['directorParamManager']} govParams * @param {VaultManager} vaultManager * @param {*} oldInstall * @param {*} oldTerms @@ -192,7 +203,7 @@ const machineBehavior = { * @param {VaultManagerParamValues} initialParamValues */ addVaultType: async ( - { state }, + { state, facets }, collateralIssuer, collateralKeyword, initialParamValues, @@ -256,6 +267,7 @@ const machineBehavior = { } // TODO add aggregate debt tracking at the vaultFactory level #4482 // totalDebt = AmountMath.add(totalDebt, toMint); + facets.machine.updateMetrics(); }; /** @@ -291,6 +303,7 @@ const machineBehavior = { const { install, terms } = getLiquidationConfig(directorParamManager); await vm.setupLiquidator(install, terms); watchGovernance(directorParamManager, vm, install, terms); + facets.machine.updateMetrics(); return vm; }, getCollaterals, @@ -306,6 +319,15 @@ const machineBehavior = { }, /** @param {MethodContext} context */ getContractGovernor: ({ state }) => state.zcf.getTerms().electionManager, + /** @param {MethodContext} context */ + updateMetrics: ({ state }) => { + /** @type {MetricsNotification} */ + const metrics = harden({ + collaterals: Array.from(state.collateralTypes.keys()), + rewardPoolAllocation: state.rewardPoolSeat.getCurrentAllocation(), + }); + state.metricsPublication.updateState(metrics); + }, // XXX accessors for tests /** @param {MethodContext} context */ @@ -356,6 +378,11 @@ const publicBehavior = { /** @type {VaultManager} */ return collateralTypes.get(brandIn).getPublicFacet(); }, + /** + * @param {MethodContext} context + */ + getMetrics: ({ state }) => state.metricsSubscription, + /** @deprecated use getCollateralManager and then makeVaultInvitation instead */ makeLoanInvitation: makeVaultInvitation, /** @deprecated use getCollateralManager and then makeVaultInvitation instead */ diff --git a/packages/run-protocol/src/vaultFactory/vaultManager.js b/packages/run-protocol/src/vaultFactory/vaultManager.js index 1e75b4a9c8f..262b7634f6c 100644 --- a/packages/run-protocol/src/vaultFactory/vaultManager.js +++ b/packages/run-protocol/src/vaultFactory/vaultManager.js @@ -14,7 +14,11 @@ import { makeRatio, floorDivideBy, } from '@agoric/zoe/src/contractSupport/index.js'; -import { makeNotifierKit, observeNotifier } from '@agoric/notifier'; +import { + makeNotifierKit, + makeSubscriptionKit, + observeNotifier, +} from '@agoric/notifier'; import { AmountMath } from '@agoric/ertp'; import { defineKindMulti, pickFacet } from '@agoric/vat-data'; @@ -34,11 +38,15 @@ const trace = makeTracer('VM', false); * compoundedInterest: Ratio, * interestRate: Ratio, * latestInterestUpdate: bigint, - * totalDebt: Amount<'nat'>, * liquidatorInstance?: Instance, - * }} AssetState */ - -/** + * }} AssetState + * + * @typedef {{ + * numVaults: number, + * totalCollateral: Amount<'nat'>, + * totalDebt: Amount<'nat'>, + * }} MetricsNotification + * * @typedef {{ * getChargingPeriod: () => bigint, * getRecordingPeriod: () => bigint, @@ -56,6 +64,8 @@ const trace = makeTracer('VM', false); * debtBrand: Brand<'nat'>, * debtMint: ZCFMint<'nat'>, * factoryPowers: import('./vaultDirector.js').FactoryPowersFacet, + * metricsPublication: IterationObserver, + * metricsSubscription: Subscription, * periodNotifier: ERef>, * poolIncrementSeat: ZCFSeat, * priceAuthority: ERef, @@ -72,6 +82,7 @@ const trace = makeTracer('VM', false); * latestInterestUpdate: bigint, * liquidator?: Liquidator * liquidatorInstance?: Instance + * totalCollateral: Amount<'nat'>, * totalDebt: Amount<'nat'>, * vaultCounter: number, * }} MutableState @@ -118,12 +129,28 @@ const initState = ( factoryPowers.getGovernedParams().getChargingPeriod(), ); + const debtBrand = debtMint.getIssuerRecord().brand; + const totalCollateral = AmountMath.makeEmpty(collateralBrand, 'nat'); + const totalDebt = AmountMath.makeEmpty(debtBrand, 'nat'); + + const { publication: metricsPublication, subscription: metricsSubscription } = + makeSubscriptionKit(); + metricsPublication.updateState( + harden({ + numVaults: 0, + totalCollateral, + totalDebt, + }), + ); + /** @type {ImmutableState} */ const fixed = { collateralBrand, - debtBrand: debtMint.getIssuerRecord().brand, + debtBrand, debtMint, factoryPowers, + metricsSubscription, + metricsPublication, periodNotifier, poolIncrementSeat: zcf.makeEmptySeatKit().zcfSeat, priceAuthority, @@ -134,7 +161,6 @@ const initState = ( zcf, }; - const totalDebt = AmountMath.makeEmpty(fixed.debtBrand, 'nat'); const compoundedInterest = makeRatio(100n, fixed.debtBrand); // starts at 1.0, no interest // timestamp of most recent update to interest const latestInterestUpdate = startTimeStamp; @@ -144,8 +170,6 @@ const initState = ( compoundedInterest, interestRate: fixed.factoryPowers.getGovernedParams().getInterestRate(), latestInterestUpdate, - totalDebt, - liquidationInstance: undefined, }), ); @@ -158,6 +182,7 @@ const initState = ( vaultCounter: 0, liquidator: undefined, liquidatorInstance: undefined, + totalCollateral, totalDebt, compoundedInterest, latestInterestUpdate, @@ -219,12 +244,13 @@ const helperBehavior = { updateTime, ); Object.assign(state, stateUpdates); - facets.helper.notify(); + facets.helper.assetNotify(); trace('chargeAllVaults complete'); facets.helper.reschedulePriceCheck(); }, - notify: ({ state }) => { + /** @param {MethodContext} context */ + assetNotify: ({ state }) => { const interestRate = state.factoryPowers .getGovernedParams() .getInterestRate(); @@ -233,12 +259,23 @@ const helperBehavior = { compoundedInterest: state.compoundedInterest, interestRate, latestInterestUpdate: state.latestInterestUpdate, - totalDebt: state.totalDebt, + // XXX move to governance and type as present with null liquidatorInstance: state.liquidatorInstance, }); state.assetUpdater.updateState(payload); }, + /** @param {MethodContext} context */ + updateMetrics: ({ state }) => { + /** @type {MetricsNotification} */ + const payload = harden({ + numVaults: state.prioritizedVaults.getCount(), + totalCollateral: state.totalCollateral, + totalDebt: state.totalDebt, + }); + state.metricsPublication.updateState(payload); + }, + /** * When any Vault's debt ratio is higher than the current high-water level, * call `reschedulePriceCheck()` to request a fresh notification from the @@ -295,6 +332,9 @@ const helperBehavior = { trace('update quote', highestDebtRatio); }, + /** + * @param {MethodContext} context + */ processLiquidations: async ({ state, facets }) => { const { prioritizedVaults, priceAuthority } = state; const govParams = state.factoryPowers.getGovernedParams(); @@ -368,6 +408,7 @@ const helperBehavior = { .then(() => { prioritizedVaults.removeVault(key); trace('liquidated'); + facets.helper.updateMetrics(); }) .catch(e => { // XXX should notify interested parties @@ -425,6 +466,7 @@ const managerBehavior = { * @param {ZCFSeat} seat */ burnAndRecord: ({ state }, toBurn, seat) => { + trace('burnAndRecord', { toBurn, totalDebt: state.totalDebt }); const { burnDebt } = state.factoryPowers; burnDebt(toBurn, seat); state.totalDebt = AmountMath.subtract(state.totalDebt, toBurn); @@ -461,6 +503,8 @@ const collateralBehavior = { /** @param {MethodContext} context */ getNotifier: ({ state }) => state.assetNotifier, /** @param {MethodContext} context */ + getMetrics: ({ state }) => state.metricsSubscription, + /** @param {MethodContext} context */ getCompoundedInterest: ({ state }) => state.compoundedInterest, }; @@ -486,7 +530,7 @@ const selfBehavior = { * @param {MethodContext} context * @param {ZCFSeat} seat */ - makeVaultKit: async ({ state, facets: { manager } }, seat) => { + makeVaultKit: async ({ state, facets: { helper, manager } }, seat) => { const { prioritizedVaults, zcf } = state; assertProposalShape(seat, { give: { Collateral: null }, @@ -505,7 +549,12 @@ const selfBehavior = { // TODO `await` is allowed until the above ordering is fixed // eslint-disable-next-line @jessie.js/no-nested-await const vaultKit = await vault.initVaultKit(seat); + state.totalCollateral = AmountMath.add( + state.totalCollateral, + vaultKit.vault.getCollateralAmount(), + ); seat.exit(); + helper.updateMetrics(); return vaultKit; } catch (err) { // remove it from prioritizedVaults @@ -557,7 +606,7 @@ const selfBehavior = { }); state.liquidatorInstance = instance; state.liquidator = creatorFacet; - facets.helper.notify(); + facets.helper.assetNotify(); }, /** @param {MethodContext} context */ @@ -632,5 +681,6 @@ const makeVaultManagerKit = defineKindMulti( */ export const makeVaultManager = pickFacet(makeVaultManagerKit, 'self'); +/** @typedef {ReturnType['manager']} VaultKitManager */ /** @typedef {ReturnType} VaultManager */ /** @typedef {ReturnType} CollateralManager */ diff --git a/packages/run-protocol/src/vaultFactory/vaultTitle.js b/packages/run-protocol/src/vaultFactory/vaultTitle.js index 43b541351ae..c51da929168 100644 --- a/packages/run-protocol/src/vaultFactory/vaultTitle.js +++ b/packages/run-protocol/src/vaultFactory/vaultTitle.js @@ -10,8 +10,8 @@ const { details: X } = assert; /** * @typedef {{ - * notifier: NotifierRecord['notifier'], - * updater: NotifierRecord['updater'], + * notifier: NotifierRecord['notifier'], + * updater: NotifierRecord['updater'], * vault: Vault | null, * }} State * @typedef {Readonly<{ @@ -29,7 +29,7 @@ const { details: X } = assert; * @returns {State} */ const initState = vault => { - /** @type {NotifierRecord} */ + /** @type {NotifierRecord} */ const { updater, notifier } = makeNotifierKit(); return { notifier, updater, vault }; diff --git a/packages/run-protocol/test/vaultFactory/test-vaultFactory.js b/packages/run-protocol/test/vaultFactory/test-vaultFactory.js index 338be019dbc..9848692e79c 100644 --- a/packages/run-protocol/test/vaultFactory/test-vaultFactory.js +++ b/packages/run-protocol/test/vaultFactory/test-vaultFactory.js @@ -7,6 +7,7 @@ import { E } from '@endo/eventual-send'; import { deeplyFulfilled } from '@endo/marshal'; import { makeIssuerKit, AssetKind, AmountMath } from '@agoric/ertp'; +import { makeNotifierFromAsyncIterable } from '@agoric/notifier'; import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; import { makeRatio, @@ -304,14 +305,12 @@ async function setupServices( await startVaultFactory(space, { loanParams: loanTiming }, minInitialDebt); const governorCreatorFacet = consume.vaultFactoryGovernorCreator; - /** @type {Promise>} */ - const vaultFactoryCreatorFacet = /** @type { any } */ ( - E(governorCreatorFacet).getCreatorFacet() - ); + /** @type {Promise>} */ + const vaultFactoryCreatorFacetP = E(governorCreatorFacet).getCreatorFacet(); // Add a vault that will lend on aeth collateral /** @type {Promise} */ - const aethVaultManagerP = E(vaultFactoryCreatorFacet).addVaultType( + const aethVaultManagerP = E(vaultFactoryCreatorFacetP).addVaultType( aethIssuer, 'AEth', rates, @@ -320,18 +319,23 @@ async function setupServices( // @ts-expect-error cast const [ governorInstance, - vaultFactory, + vaultFactory, // creator lender, aethVaultManager, priceAuthority, ] = await Promise.all([ E(consume.agoricNames).lookup('instance', 'VaultFactoryGovernor'), - vaultFactoryCreatorFacet, + vaultFactoryCreatorFacetP, E(governorCreatorFacet).getPublicFacet(), aethVaultManagerP, pa, ]); - trace(t, 'pa', { governorInstance, vaultFactory, lender, priceAuthority }); + trace(t, 'pa', { + governorInstance, + vaultFactory, + lender, + priceAuthority, + }); const { g, v } = { g: { @@ -2350,3 +2354,104 @@ test('addVaultType: extra, unexpected params', async t => { ); t.true(matches(actual, M.remotable()), 'unexpected params are ignored'); }); + +test('director notifiers', async t => { + const { + aethKit: { brand: aethBrand }, + } = t.context; + const services = await setupServices( + t, + [500n, 15n], + AmountMath.make(aethBrand, 900n), + undefined, + undefined, + 500n, + ); + + const { lender, vaultFactory } = services.vaultFactory; + + const metricsSub = await E(lender).getMetrics(); + const metrics = makeNotifierFromAsyncIterable(metricsSub); + + let state = await E(metrics).getUpdateSince(); + t.deepEqual(state.value, { + collaterals: [aethBrand], + rewardPoolAllocation: {}, + }); + + // add a vault type + const chit = makeIssuerKit('chit'); + await E(vaultFactory).addVaultType( + chit.issuer, + 'Chit', + defaultParamValues(chit.brand), + ); + state = await E(metrics).getUpdateSince(state.updateCount); + t.deepEqual(state.value, { + collaterals: [aethBrand, chit.brand], + rewardPoolAllocation: {}, + }); + + // Not testing rewardPoolAllocation contents because those are simply those values. + // We could refactor the tests of those allocations to use the data now exposed by a notifier. +}); + +test('manager notifiers', async t => { + const LOAN = 450n; + const DEBT = 473n; // with penalty + const AMPLE = 100_000n; + + const { aethKit, runKit } = t.context; + const services = await setupServices( + t, + [10n], + AmountMath.make(aethKit.brand, 900n), + undefined, + undefined, + AMPLE, + ); + + const { aethVaultManager, lender } = services.vaultFactory; + const cm = await E(aethVaultManager).getPublicFacet(); + + const metricsSub = await E(cm).getMetrics(); + const metrics = makeNotifierFromAsyncIterable(metricsSub); + let state = await E(metrics).getUpdateSince(); + t.deepEqual(state.value, { + numVaults: 0, + totalCollateral: AmountMath.makeEmpty(aethKit.brand), + totalDebt: AmountMath.makeEmpty(t.context.runKit.brand), + }); + + // Create a loan with ample collateral + const collateralAmount = AmountMath.make(aethKit.brand, AMPLE); + const loanAmount = AmountMath.make(runKit.brand, LOAN); + /** @type {UserSeat} */ + const vaultSeat = await E(services.zoe).offer( + await E(lender).makeVaultInvitation(), + harden({ + give: { Collateral: collateralAmount }, + want: { RUN: loanAmount }, + }), + harden({ + Collateral: t.context.aethKit.mint.mintPayment(collateralAmount), + }), + ); + + await E(vaultSeat).getOfferResult(); + + state = await E(metrics).getUpdateSince(state.updateCount); + t.deepEqual(state.value, { + numVaults: 1, + totalCollateral: collateralAmount, + totalDebt: AmountMath.make(runKit.brand, DEBT), + }); + + await E(aethVaultManager).liquidateAll(); + state = await E(metrics).getUpdateSince(state.updateCount); + t.deepEqual(state.value, { + numVaults: 0, + totalCollateral: collateralAmount, + totalDebt: AmountMath.make(runKit.brand, 0n), + }); +}); diff --git a/packages/zoe/test/unitTests/zcf/setupZcfTest.js b/packages/zoe/test/unitTests/zcf/setupZcfTest.js index 3a02315d7ae..e7d85d5bbaf 100644 --- a/packages/zoe/test/unitTests/zcf/setupZcfTest.js +++ b/packages/zoe/test/unitTests/zcf/setupZcfTest.js @@ -14,6 +14,12 @@ const dirname = path.dirname(filename); const contractRoot = `${dirname}/zcfTesterContract.js`; +/** + * Test setup utility + * + * @param {IssuerKeywordRecord} [issuerKeywordRecord] + * @param {Record} [terms] + */ export const setupZCFTest = async (issuerKeywordRecord, terms) => { /** @type {ZCF} */ let zcf;