diff --git a/packages/boot/test/bootstrapTests/test-addAssets.ts b/packages/boot/test/bootstrapTests/test-addAssets.ts new file mode 100644 index 00000000000..12510dd810d --- /dev/null +++ b/packages/boot/test/bootstrapTests/test-addAssets.ts @@ -0,0 +1,187 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import type { TestFn } from 'ava'; + +import { TimeMath } from '@agoric/time'; +import { + LiquidationTestContext, + makeLiquidationTestContext, +} from './liquidation.ts'; +import { makeProposalExtractor } from './supports.ts'; + +const test = anyTest as TestFn< + LiquidationTestContext & { + getCollateralProposal: ( + name: string, + id: string, + ) => Awaited>>; + } +>; + +const auctioneerPath = 'published.auction'; + +test.before(async t => { + const context = await makeLiquidationTestContext(t); + const proposal = await context.buildProposal({ + package: 'builders', + packageScriptName: 'build:add-STARS-proposal', + }); + + t.log('installing proposal'); + // share a single proposal so tests don't stomp on each other's files; It has + // to be edited by each so as not to re-use keywords. + for await (const bundle of proposal.bundles) { + await context.controller.validateAndInstallBundle(bundle); + } + t.log('installed', proposal.bundles.length, 'bundles'); + + const getCollateralProposal = (name, id) => { + // stringify, modify and parse because modifying a deep copy was fragile. + const proposalJSON = JSON.stringify(proposal); + const proposalMod = proposalJSON + .replaceAll('STARS', name) + .replaceAll('ibc/987C17B1', `ibc/987C17B1${id}`); + return JSON.parse(proposalMod); + }; + t.context = { + ...context, + getCollateralProposal, + }; + t.log('installed', proposal.bundles.length, 'bundles'); +}); + +test.after.always(t => { + // This will fail if a subset of tests are run. It detects that three + // collaterals were added to the auction after ATOM. + t.like(t.context.readLatest(`${auctioneerPath}.book3`), { + currentPriceLevel: null, + }); + return t.context.shutdown && t.context.shutdown(); +}); + +test('addAsset to quiescent auction', async t => { + const { advanceTimeTo, readLatest } = t.context; + + const proposal = t.context.getCollateralProposal('COMETS', 'A'); + const bridgeMessage = { + type: 'CORE_EVAL', + evals: proposal.evals, + }; + + const { EV } = t.context.runUtils; + + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const schedules = await EV(auctioneerKit.creatorFacet).getSchedule(); + const { liveAuctionSchedule, nextAuctionSchedule } = schedules; + const nextEndTime = liveAuctionSchedule + ? liveAuctionSchedule.endTime + : nextAuctionSchedule.endTime; + const fiveMinutes = harden({ + relValue: 5n * 60n, + timerBrand: nextEndTime.timerBrand, + }); + const nextQuiescentTime = TimeMath.addAbsRel(nextEndTime, fiveMinutes); + await advanceTimeTo(nextQuiescentTime); + + const coreEvalBridgeHandler = await EV.vat('bootstrap').consumeItem( + 'coreEvalBridgeHandler', + ); + await EV(coreEvalBridgeHandler).fromBridge(bridgeMessage); + t.log('proposal executed'); + + t.like(readLatest(`${auctioneerPath}.book1`), { + currentPriceLevel: null, + }); +}); + +test('addAsset to active auction', async t => { + const { advanceTimeTo, readLatest } = t.context; + const { EV } = t.context.runUtils; + + t.like(readLatest(`${auctioneerPath}.book0`), { startPrice: null }); + + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const schedules = await EV(auctioneerKit.creatorFacet).getSchedule(); + const { nextAuctionSchedule } = schedules; + t.truthy(nextAuctionSchedule); + const nextStartTime = nextAuctionSchedule.startTime; + const fiveMinutes = harden({ + relValue: 5n * 60n, + timerBrand: nextStartTime.timerBrand, + }); + const futureBusyTime = TimeMath.addAbsRel(nextStartTime, fiveMinutes); + + await advanceTimeTo(futureBusyTime); + + t.log('launching proposal'); + + const proposal = t.context.getCollateralProposal('PLANETS', 'B'); + const bridgeMessage = { + type: 'CORE_EVAL', + evals: proposal.evals, + }; + t.log({ bridgeMessage }); + + const coreEvalBridgeHandler = await EV.vat('bootstrap').consumeItem( + 'coreEvalBridgeHandler', + ); + EV(coreEvalBridgeHandler).fromBridge(bridgeMessage); + + const nextEndTime = nextAuctionSchedule.endTime; + const afterEndTime = TimeMath.addAbsRel(nextEndTime, fiveMinutes); + await advanceTimeTo(afterEndTime); + t.log('proposal executed'); + + const schedulesAfter = await EV(auctioneerKit.creatorFacet).getSchedule(); + // TimeMath.compareAbs() can't handle brands processed by kmarshall + t.truthy( + schedules.nextAuctionSchedule.endTime.absValue < + schedulesAfter.nextAuctionSchedule.endTime.absValue, + ); + + t.like(readLatest(`${auctioneerPath}.book1`), { currentPriceLevel: null }); +}); + +test('addAsset to auction starting soon', async t => { + const { advanceTimeTo, readLatest } = t.context; + const { EV } = t.context.runUtils; + + const auctioneerKit = await EV.vat('bootstrap').consumeItem('auctioneerKit'); + const schedules = await EV(auctioneerKit.creatorFacet).getSchedule(); + const { nextAuctionSchedule } = schedules; + t.truthy(nextAuctionSchedule); + const nextStartTime = nextAuctionSchedule.startTime; + const fiveMinutes = harden({ + relValue: 5n * 60n, + timerBrand: nextStartTime.timerBrand, + }); + const tooCloseTime = TimeMath.subtractAbsRel(nextStartTime, fiveMinutes); + + await advanceTimeTo(tooCloseTime); + + const proposal = t.context.getCollateralProposal('MOONS', 'C'); + t.log('launching proposal'); + const bridgeMessage = { + type: 'CORE_EVAL', + evals: proposal.evals, + }; + t.log({ bridgeMessage }); + + const coreEvalBridgeHandler = await EV.vat('bootstrap').consumeItem( + 'coreEvalBridgeHandler', + ); + EV(coreEvalBridgeHandler).fromBridge(bridgeMessage); + + const nextEndTime = nextAuctionSchedule.endTime; + const afterEndTime = TimeMath.addAbsRel(nextEndTime, fiveMinutes); + await advanceTimeTo(afterEndTime); + + t.log('proposal executed'); + + const schedulesAfter = await EV(auctioneerKit.creatorFacet).getSchedule(); + t.truthy( + schedules.nextAuctionSchedule.endTime.absValue < + schedulesAfter.nextAuctionSchedule.endTime.absValue, + ); + t.like(readLatest(`${auctioneerPath}.book1`), { currentPriceLevel: null }); +}); diff --git a/packages/inter-protocol/package.json b/packages/inter-protocol/package.json index 36c428ed53e..5d88e536104 100644 --- a/packages/inter-protocol/package.json +++ b/packages/inter-protocol/package.json @@ -46,6 +46,7 @@ "@endo/far": "^0.2.21", "@endo/marshal": "^0.8.8", "@endo/nat": "^4.1.30", + "@endo/promise-kit": "^0.2.59", "jessie.js": "^0.3.2" }, "devDependencies": { diff --git a/packages/inter-protocol/src/proposals/addAssetToVault.js b/packages/inter-protocol/src/proposals/addAssetToVault.js index 3f15fc73dba..fce52bb9fde 100644 --- a/packages/inter-protocol/src/proposals/addAssetToVault.js +++ b/packages/inter-protocol/src/proposals/addAssetToVault.js @@ -1,16 +1,24 @@ // @jessie-check +// @ts-check +import { ToFarFunction } from '@endo/captp'; +import { Far } from '@endo/marshal'; import { AmountMath, AssetKind } from '@agoric/ertp'; import { deeplyFulfilledObject } from '@agoric/internal'; import { makeRatio } from '@agoric/zoe/src/contractSupport/index.js'; import { parseRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; import { E } from '@endo/far'; import { Stable } from '@agoric/internal/src/tokens.js'; +import { TimeMath } from '@agoric/time/src/timeMath.js'; +import { makePromiseKit } from '@endo/promise-kit'; + import { instanceNameFor } from './price-feed-proposal.js'; import { reserveThenGetNames } from './utils.js'; export * from './startPSM.js'; +const { quote: q } = assert; + /** * @typedef {object} InterchainAssetOptions * @property {string} [issuerBoardId] @@ -24,8 +32,10 @@ export * from './startPSM.js'; * @property {number} [initialPrice] */ +/** @typedef {import('./econ-behaviors.js').EconomyBootstrapPowers} EconomyBootstrapPowers */ + /** - * @param {EconomyBootstrapPowers} powers + * @param {BootstrapPowers} powers * @param {object} config * @param {object} config.options * @param {InterchainAssetOptions} config.options.interchainAssetOptions @@ -216,7 +226,74 @@ export const registerScaledPriceAuthority = async ( ); }; -/** @typedef {import('./econ-behaviors.js').EconomyBootstrapPowers} EconomyBootstrapPowers */ +// wait a short while after end to allow things to settle +const BUFFER = 5n * 60n; +// let's insist on 20 minutes leeway for running the scripts +const COMPLETION = 20n * 60n; + +/** + * This function works around an issue identified in #8307 and #8296, and fixed + * in #8301. The fix is needed until #8301 makes it into production. + * + * If there is a liveSchedule, 1) run now if start is far enough away, + * otherwise, 2) run after endTime. If neither liveSchedule nor nextSchedule is + * defined, 3) run now. If there is only a nextSchedule, 4) run now if startTime + * is far enough away, else 5) run after endTime + * + * @param {import('../auction/scheduler.js').FullSchedule} schedules + * @param {ERef} timer + * @param {() => void} thunk + */ +const whenQuiescent = async (schedules, timer, thunk) => { + const { nextAuctionSchedule, liveAuctionSchedule } = schedules; + const now = await E(timer).getCurrentTimestamp(); + + const waker = Far('addAssetWaker', { wake: () => thunk() }); + + if (liveAuctionSchedule) { + const safeStart = TimeMath.subtractAbsRel( + liveAuctionSchedule.startTime, + COMPLETION, + ); + + if (TimeMath.compareAbs(safeStart, now) < 0) { + // case 2 + console.warn( + `Add Asset after live schedule's endtime: ${q( + liveAuctionSchedule.endTime, + )}`, + ); + + return E(timer).setWakeup( + TimeMath.addAbsRel(liveAuctionSchedule.endTime, BUFFER), + waker, + ); + } + } + + if (!liveAuctionSchedule && nextAuctionSchedule) { + const safeStart = TimeMath.subtractAbsRel( + nextAuctionSchedule.startTime, + COMPLETION, + ); + if (TimeMath.compareAbs(safeStart, now) < 0) { + // case 5 + console.warn( + `Add Asset after next schedule's endtime: ${q( + nextAuctionSchedule.endTime, + )}`, + ); + return E(timer).setWakeup( + TimeMath.addAbsRel(nextAuctionSchedule.endTime, BUFFER), + waker, + ); + } + } + + // cases 1, 3, and 4 fall through to here. + console.warn(`Add Asset immediately`, thunk); + return thunk(); +}; /** * @param {EconomyBootstrapPowers} powers @@ -228,7 +305,12 @@ export const registerScaledPriceAuthority = async ( */ export const addAssetToVault = async ( { - consume: { vaultFactoryKit, agoricNamesAdmin, auctioneerKit }, + consume: { + vaultFactoryKit, + agoricNamesAdmin, + auctioneerKit, + chainTimerService, + }, brand: { consume: { [Stable.symbol]: stableP }, }, @@ -263,6 +345,20 @@ export const addAssetToVault = async ( // eslint-disable-next-line no-restricted-syntax -- allow this computed property await consumeInstance[oracleInstanceName]; + const auctioneerCreator = E.get(auctioneerKit).creatorFacet; + const schedules = await E(auctioneerCreator).getSchedule(); + + const finishPromiseKit = makePromiseKit(); + const addBrandThenResolve = ToFarFunction('addBrandThenResolve', async () => { + await E(auctioneerCreator).addBrand(interchainIssuer, keyword); + finishPromiseKit.resolve(undefined); + }); + + // schedules actions on a timer (or does it immediately). + // finishPromiseKit signals completion. + void whenQuiescent(schedules, chainTimerService, addBrandThenResolve); + await finishPromiseKit.promise; + const stable = await stableP; const vaultFactoryCreator = E.get(vaultFactoryKit).creatorFacet; await E(vaultFactoryCreator).addVaultType(interchainIssuer, keyword, { @@ -277,8 +373,6 @@ export const addAssetToVault = async ( mintFee: makeRatio(50n, stable, 10_000n), liquidationPenalty: makeRatio(1n, stable), }); - const auctioneerCreator = E.get(auctioneerKit).creatorFacet; - await E(auctioneerCreator).addBrand(interchainIssuer, keyword); }; export const getManifestForAddAssetToVault = ( @@ -338,6 +432,7 @@ export const getManifestForAddAssetToVault = ( auctioneerKit: 'auctioneer', vaultFactoryKit: 'vaultFactory', agoricNamesAdmin: true, + chainTimerService: true, }, brand: { consume: { [Stable.symbol]: true },