diff --git a/packages/run-protocol/src/vpool-xyk-amm/addPool.js b/packages/run-protocol/src/vpool-xyk-amm/addPool.js index cb8423affd1..9006c7fbffb 100644 --- a/packages/run-protocol/src/vpool-xyk-amm/addPool.js +++ b/packages/run-protocol/src/vpool-xyk-amm/addPool.js @@ -62,6 +62,7 @@ export const makeAddIssuer = (zcf, isInSecondaries, brandToLiquidityMint) => { * @param {ZCFSeat} reserveLiquidityTokenSeat seat that holds liquidity tokens * from adding pool liquidity. It is expected to be collected by the Reserve. * @param {WeakStore} brandToLiquidityMint + * @param {() => void} updateMetrics */ export const makeAddPoolInvitation = ( zcf, @@ -73,6 +74,7 @@ export const makeAddPoolInvitation = ( protocolSeat, reserveLiquidityTokenSeat, brandToLiquidityMint, + updateMetrics, ) => { const makePool = definePoolKind( zcf, @@ -92,6 +94,7 @@ export const makeAddPoolInvitation = ( const poolFacets = makePool(liquidityZcfMint, poolSeat, secondaryBrand); initPool(secondaryBrand, poolFacets); + updateMetrics(); return { liquidityZcfMint, poolFacets }; }; diff --git a/packages/run-protocol/src/vpool-xyk-amm/constantProduct/calcFees.js b/packages/run-protocol/src/vpool-xyk-amm/constantProduct/calcFees.js index 54b74a14f1f..4a78431fe44 100644 --- a/packages/run-protocol/src/vpool-xyk-amm/constantProduct/calcFees.js +++ b/packages/run-protocol/src/vpool-xyk-amm/constantProduct/calcFees.js @@ -6,6 +6,9 @@ import { makeRatio, } from '@agoric/zoe/src/contractSupport/ratio.js'; +import './types.js'; +import './internal-types.js'; + import { BASIS_POINTS } from './defaults.js'; const { details: X } = assert; diff --git a/packages/run-protocol/src/vpool-xyk-amm/multipoolMarketMaker.js b/packages/run-protocol/src/vpool-xyk-amm/multipoolMarketMaker.js index 976bac0f4c4..01c66e36a1e 100644 --- a/packages/run-protocol/src/vpool-xyk-amm/multipoolMarketMaker.js +++ b/packages/run-protocol/src/vpool-xyk-amm/multipoolMarketMaker.js @@ -1,18 +1,19 @@ // @ts-check -import { makeWeakStore } from '@agoric/store'; +import { makeStore, makeWeakStore } from '@agoric/store'; import { Far } from '@endo/marshal'; import { AssetKind, makeIssuerKit } from '@agoric/ertp'; import { handleParamGovernance, ParamTypes } from '@agoric/governance'; +import { makeSubscriptionKit } from '@agoric/notifier'; import { assertIssuerKeywords } from '@agoric/zoe/src/contractSupport/index.js'; import { E } from '@endo/far'; import { makeAddIssuer, makeAddPoolInvitation } from './addPool.js'; import { publicPrices } from './pool.js'; import { - makeMakeAddLiquidityInvitation, makeMakeAddLiquidityAtRateInvitation, + makeMakeAddLiquidityInvitation, } from './addLiquidity.js'; import { makeMakeRemoveLiquidityInvitation } from './removeLiquidity.js'; @@ -28,6 +29,11 @@ import { const { quote: q, details: X } = assert; +/** + * @typedef {object} MetricsNotification + * @property {Brand[]} XYK brands of pools that use an X*Y=K pricing policy + */ + /** * Multipool AMM is a rewrite of Uniswap that supports multiple liquidity pools, * and direct exchanges across pools. Please see the documentation for more: @@ -133,8 +139,8 @@ const start = async (zcf, privateArgs) => { )}`, ); - /** @type {WeakStore} */ - const secondaryBrandToPool = makeWeakStore('secondaryBrand'); + /** @type {Store} */ + const secondaryBrandToPool = makeStore('secondaryBrand'); const getPool = brand => secondaryBrandToPool.get(brand).pool; const getPoolHelper = brand => secondaryBrandToPool.get(brand).helper; const initPool = secondaryBrandToPool.init; @@ -146,6 +152,16 @@ const start = async (zcf, privateArgs) => { const quoteIssuerKit = makeIssuerKit('Quote', AssetKind.SET); + /** @type {SubscriptionRecord} */ + const { publication: metricsPublication, subscription: metricsSubscription } = + makeSubscriptionKit(); + const updateMetrics = () => { + metricsPublication.updateState( + harden({ XYK: Array.from(secondaryBrandToPool.keys()) }), + ); + }; + updateMetrics(); + // For now, this seat collects protocol fees. It needs to be connected to // something that will extract the fees. const { zcfSeat: protocolSeat } = zcf.makeEmptySeatKit(); @@ -165,6 +181,7 @@ const start = async (zcf, privateArgs) => { protocolSeat, reserveLiquidityTokenSeat, secondaryBrandToLiquidityMint, + updateMetrics, ); const addIssuer = makeAddIssuer( zcf, @@ -184,6 +201,9 @@ const start = async (zcf, privateArgs) => { }; }; + /** @param {Brand} brand */ + const getPoolMetrics = brand => getPool(brand).getMetrics(); + /** * @param {Brand} brandIn * @param {Brand} [brandOut] @@ -266,6 +286,8 @@ const start = async (zcf, privateArgs) => { getAllPoolBrands: () => Object.values(zcf.getTerms().brands).filter(isSecondary), getProtocolPoolBalance: () => protocolSeat.getCurrentAllocation(), + getMetrics: () => metricsSubscription, + getPoolMetrics, }), ); diff --git a/packages/run-protocol/src/vpool-xyk-amm/pool.js b/packages/run-protocol/src/vpool-xyk-amm/pool.js index 5226c50fe6a..8ed77516ac4 100644 --- a/packages/run-protocol/src/vpool-xyk-amm/pool.js +++ b/packages/run-protocol/src/vpool-xyk-amm/pool.js @@ -1,7 +1,7 @@ // @ts-check import { AmountMath, isNatValue } from '@agoric/ertp'; -import { makeNotifierKit } from '@agoric/notifier'; +import { makeNotifierKit, makeSubscriptionKit } from '@agoric/notifier'; import { calcLiqValueToMint, @@ -42,6 +42,8 @@ export const publicPrices = prices => { * @typedef {{ * updater: IterationObserver, * notifier: Notifier, + * metricsPublication: IterationObserver, + * metricsSubscription: Subscription * poolSeat: ZCFSeat, * liqTokenSupply: bigint, * }} MutableState @@ -54,6 +56,11 @@ export const publicPrices = prices => { * singlePool: VirtualPool, * }, * }} MethodContext + * + * @typedef {object} PoolMetricsNotification + * @property {Amount} centralAmount + * @property {Amount} secondaryAmount + * @property {NatValue} liquidityTokens - outstanding tokens */ export const updateUpdaterState = (updater, pool) => @@ -131,8 +138,20 @@ const helperBehavior = { ); zcfSeat.exit(); updateUpdaterState(updater, pool); + facets.helper.updateMetrics(); return 'Added liquidity.'; }, + /** @param {MethodContext} context */ + updateMetrics: context => { + const { state, facets } = context; + const payload = harden({ + centralAmount: facets.pool.getCentralAmount(), + secondaryAmount: facets.pool.getSecondaryAmount(), + liquidityTokens: state.liqTokenSupply, + }); + + state.metricsPublication.updateState(payload); + }, }; const poolBehavior = { @@ -233,6 +252,7 @@ const poolBehavior = { userSeat.exit(); updateUpdaterState(state.updater, facets.pool); + facets.helper.updateMetrics(); return 'Liquidity successfully removed.'; }, getNotifier: ({ state: { notifier } }) => notifier, @@ -242,6 +262,7 @@ const poolBehavior = { getToCentralPriceAuthority: ({ state }) => state.toCentralPriceAuthority, getFromCentralPriceAuthority: ({ state }) => state.fromCentralPriceAuthority, getVPool: ({ facets }) => facets.singlePool, + getMetrics: ({ state }) => state.metricsSubscription, }; /** @param {MethodContext} context */ @@ -290,6 +311,7 @@ const finish = context => { context.state.toCentralPriceAuthority = toCentralPriceAuthority; // @ts-expect-error declared read-only, set value once context.state.fromCentralPriceAuthority = fromCentralPriceAuthority; + context.facets.helper.updateMetrics(); }; /** @@ -312,6 +334,10 @@ export const definePoolKind = ( const { brand: liquidityBrand, issuer: liquidityIssuer } = liquidityZcfMint.getIssuerRecord(); const { notifier, updater } = makeNotifierKit(); + const { + publication: metricsPublication, + subscription: metricsSubscription, + } = makeSubscriptionKit(); // XXX why does the paramAccessor have to be repackaged as a Far object? const params = Far('pool param accessor', { @@ -335,6 +361,8 @@ export const definePoolKind = ( quoteIssuerKit, timer, paramAccessor: params, + metricsPublication, + metricsSubscription, }; }; diff --git a/packages/run-protocol/src/vpool-xyk-amm/singlePool.js b/packages/run-protocol/src/vpool-xyk-amm/singlePool.js index 8ab7f4aca6d..d8cd3b5b31c 100644 --- a/packages/run-protocol/src/vpool-xyk-amm/singlePool.js +++ b/packages/run-protocol/src/vpool-xyk-amm/singlePool.js @@ -16,7 +16,7 @@ const getPools = pool => ({ export const singlePool = { allocateGainsAndLosses: (context, seat, prices) => { - const { pool } = context.facets; + const { pool, helper } = context.facets; const { poolSeat, zcf, protocolSeat } = context.state; seat.decrementBy(harden({ In: prices.swapperGives })); seat.incrementBy(harden({ Out: prices.swapperGets })); @@ -34,6 +34,7 @@ export const singlePool = { zcf.reallocate(poolSeat, seat, protocolSeat); seat.exit(); pool.updateState(); + helper.updateMetrics(); return `Swap successfully completed.`; }, diff --git a/packages/run-protocol/src/vpool-xyk-amm/types.js b/packages/run-protocol/src/vpool-xyk-amm/types.js index f2aa68704ff..10a685aaab2 100644 --- a/packages/run-protocol/src/vpool-xyk-amm/types.js +++ b/packages/run-protocol/src/vpool-xyk-amm/types.js @@ -19,6 +19,11 @@ * @property {Amount<'nat'>} newX */ +/** + * @typedef {import('./multipoolMarketMaker.js').MetricsNotification} MetricsNotification + * @typedef {import('./pool.js').PoolMetricsNotification} PoolMetricsNotification + */ + /** * @typedef {object} DoublePoolSwapResult * @property {Amount<'nat'>} swapperGives @@ -77,6 +82,7 @@ * @property {() => PriceAuthority} getToCentralPriceAuthority * @property {() => PriceAuthority} getFromCentralPriceAuthority * @property {() => VirtualPool} getVPool + * @property {() => Subscription} getMetrics */ /** @@ -128,6 +134,8 @@ * Prices and notifications about changing prices. * @property {() => Brand[]} getAllPoolBrands * @property {() => Allocation} getProtocolPoolBalance + * @property {() => Subscription} getMetrics + * @property {(brand: Brand) => Subscription} getPoolMetrics */ /** diff --git a/packages/run-protocol/test/amm/vpool-xyk-amm/test-xyk-amm-swap.js b/packages/run-protocol/test/amm/vpool-xyk-amm/test-xyk-amm-swap.js index 974724e4267..081a5ef2ada 100644 --- a/packages/run-protocol/test/amm/vpool-xyk-amm/test-xyk-amm-swap.js +++ b/packages/run-protocol/test/amm/vpool-xyk-amm/test-xyk-amm-swap.js @@ -26,6 +26,7 @@ import { import { BASIS_POINTS } from '../../../src/vpool-xyk-amm/constantProduct/defaults.js'; import { setupAmmServices } from './setup.js'; import { unsafeMakeBundleCache } from '../../bundleTool.js'; +import { subscriptionTracker } from '../../metrics.js'; const { quote: q } = assert; const { ceilDivide } = natSafeMath; @@ -388,6 +389,10 @@ test('amm doubleSwap', async t => { const ammInstance = await amm.instance; + const metricsSub = await E(amm.ammPublicFacet).getMetrics(); + const m = await subscriptionTracker(t, metricsSub); + m.assertInitial({ XYK: [] }); + const aliceAddLiquidityInvitation = E( amm.ammPublicFacet, ).makeAddLiquidityInvitation(); @@ -401,6 +406,8 @@ test('amm doubleSwap', async t => { 'Moola', ); + await m.assertChange({ XYK: { 0: moolaR.brand } }); + const moolaLiquidityBrand = await E(moolaLiquidityIssuer).getBrand(); const moolaLiquidity = value => AmountMath.make(moolaLiquidityBrand, value); @@ -411,6 +418,8 @@ test('amm doubleSwap', async t => { const simoleanLiquidity = value => AmountMath.make(simoleanLiquidityBrand, value); + await m.assertChange({ XYK: { 1: simoleanR.brand } }); + const issuerKeywordRecord = await E(zoe).getIssuers(ammInstance); t.deepEqual( issuerKeywordRecord, @@ -777,8 +786,6 @@ test('amm jig - swapOut uneven', async t => { RUN: AmountMath.make(centralR.brand, expectedPoolBalance), }); - mPoolState = updatePoolState(mPoolState, expectedC); - const collectFeesInvitation = E(creatorFacet).makeCollectFeesInvitation(); const collectFeesSeat = await E(zoe).offer( collectFeesInvitation, @@ -1007,7 +1014,6 @@ test('amm adding liquidity', async t => { const electorateTerms = { committeeName: 'EnBancPanel', committeeSize: 3 }; // This timer is only used to build quotes. Let's make it non-zero const timer = buildManualTimer(console.log, 30n); - const { zoe, amm } = await setupAmmServices( t, electorateTerms, @@ -1015,13 +1021,9 @@ test('amm adding liquidity', async t => { timer, ); - const addInitialLiquidity = makeAddInitialLiquidity( - t, - zoe, - amm, - moolaR, - centralR, - ); + const metricsSub = await E(amm.ammPublicFacet).getMetrics(); + const m = await subscriptionTracker(t, metricsSub); + await m.assertInitial({ XYK: [] }); await t.throwsAsync( () => E(amm.ammPublicFacet).getPoolAllocation(moolaR.brand), @@ -1029,17 +1031,28 @@ test('amm adding liquidity', async t => { "The pool hasn't been created yet", ); - // add initial liquidity at 10000:50000 - const liquidityIssuer = await addInitialLiquidity(10000n, 50000n); - const addLiquidity = makeAddLiquidity( + const addInitialLiquidity = await makeAddInitialLiquidity( t, zoe, amm, moolaR, centralR, - liquidityIssuer, ); + // add initial liquidity at 10000:50000 + const liquidityIssuer = await addInitialLiquidity(10000n, 50000n); const liquidityBrand = await E(liquidityIssuer).getBrand(); + await m.assertChange({ XYK: { 0: moolaR.brand } }); + + const poolMetricsSub = await E(amm.ammPublicFacet).getPoolMetrics( + moolaR.brand, + ); + const p = await subscriptionTracker(t, poolMetricsSub); + await p.assertInitial({ + centralAmount: AmountMath.makeEmpty(centralR.brand), + secondaryAmount: moola(0n), + liquidityTokens: 0n, + }); + const allocation = (c, l, s) => ({ Central: AmountMath.make(centralR.brand, c), Liquidity: AmountMath.make(liquidityBrand, l), @@ -1067,10 +1080,25 @@ test('amm adding liquidity', async t => { payoutC: 0n, payoutS: 11n, }; + + const addLiquidity = makeAddLiquidity( + t, + zoe, + amm, + moolaR, + centralR, + liquidityIssuer, + ); // Add liquidity. Offer 20_000:70_000. await addLiquidity(20_000n, 70_000n, poolState1, expected1); // After the trade, this will increase the pool by about 150% + await p.assertState({ + centralAmount: AmountMath.make(centralR.brand, expected1.c), + secondaryAmount: moola(expected1.s), + liquidityTokens: expected1.l, + }); + // The pool will have 10K + 20K and 50K + 70K after t.deepEqual( await E(amm.ammPublicFacet).getPoolAllocation(moolaR.brand), @@ -1094,8 +1122,14 @@ test('amm adding liquidity', async t => { payoutC: 0n, payoutS: 16n, }; + // Add liquidity. Offer 12_000:100_000. await addLiquidity(12_000n, 100_000n, poolState2, expected2); + await p.assertState({ + centralAmount: AmountMath.make(centralR.brand, expected2.c), + secondaryAmount: moola(expected2.s), + liquidityTokens: expected2.l, + }); // The pool should now have just under 50K + 70K + 60K and 10K + 14K + 12K t.deepEqual( diff --git a/packages/run-protocol/test/metrics.js b/packages/run-protocol/test/metrics.js index 208a76be14d..440f3d94d47 100644 --- a/packages/run-protocol/test/metrics.js +++ b/packages/run-protocol/test/metrics.js @@ -4,7 +4,6 @@ import { E } from '@endo/eventual-send'; import { diff } from 'deep-object-diff'; /** - * * @param {import('ava').ExecutionContext} t * @param {Subscription} subscription */ @@ -15,17 +14,18 @@ export const subscriptionTracker = async (t, subscription) => { notif = await metrics.getUpdateSince(); t.deepEqual(notif.value, expectedValue); }; - /** - * - * @param {Record} expectedDelta - */ + /** @param {Record} expectedDelta */ const assertChange = async expectedDelta => { const prevNotif = notif; notif = await metrics.getUpdateSince(notif.updateCount); const actualDelta = diff(prevNotif.value, notif.value); t.deepEqual(actualDelta, expectedDelta, 'Unexpected delta'); }; - return { assertChange, assertInitial }; + const assertState = async expectedState => { + notif = await metrics.getUpdateSince(notif.updateCount); + t.deepEqual(notif.value, expectedState, 'Unexpected state'); + }; + return { assertChange, assertInitial, assertState }; }; /** diff --git a/packages/run-protocol/test/test-voPool.js b/packages/run-protocol/test/test-voPool.js index bd0510bb06c..d7dbd6da4e8 100644 --- a/packages/run-protocol/test/test-voPool.js +++ b/packages/run-protocol/test/test-voPool.js @@ -12,7 +12,7 @@ const voPoolTest = async (t, mutation, postTest) => { let makePool; const { zoe, zcf } = await setupZCFTest(); const invitation = await zcf.makeInvitation(() => {}, 'fake invitation'); - const { brand: centralBrand } = makeIssuerKit('central'); + const { brand: centralBrand, issuer: centralI } = makeIssuerKit('central'); const paramManager = await makeAmmParamManager( zoe, 25n, @@ -20,16 +20,21 @@ const voPoolTest = async (t, mutation, postTest) => { AmountMath.make(centralBrand, 100n), invitation, ); - const { brand: secondaryBrand } = makeIssuerKit('secondary'); + + const { brand: secondaryBrand, issuer: secondaryI } = + makeIssuerKit('secondary'); const quoteIssuerKit = makeIssuerKit('Quotes'); const liquidityZcfMint = await zcf.makeZCFMint('Liquidity'); - const { userSeat: poolSeatP } = zcf.makeEmptySeatKit(); + const { zcfSeat: poolSeatP } = zcf.makeEmptySeatKit(); const { zcfSeat: protocolSeatP } = zcf.makeEmptySeatKit(); const [poolSeat, protocolSeat] = await Promise.all([ poolSeatP, protocolSeatP, ]); + await zcf.saveIssuer(centralI, 'Central'); + await zcf.saveIssuer(secondaryI, 'Secondary'); + const defineVirtualPoolKind = () => { const timer = buildManualTimer(t.log);