diff --git a/packages/boot/test/bootstrapTests/price-feed-replace.test.ts b/packages/boot/test/bootstrapTests/price-feed-replace.test.ts index 4518bb519ef..1b3b136f6d9 100644 --- a/packages/boot/test/bootstrapTests/price-feed-replace.test.ts +++ b/packages/boot/test/bootstrapTests/price-feed-replace.test.ts @@ -63,12 +63,23 @@ test.serial('setupVaults; run updatePriceFeeds proposals', async t => { refreshAgoricNamesRemotes, setupVaults, governanceDriver: gd, + readLatest, } = t.context; await setupVaults(collateralBrandKey, managerIndex, setup); const instancePre = agoricNamesRemotes.instance['ATOM-USD price feed']; + t.like(readLatest('published.priceFeed.ATOM-USD_price_feed.latestRound'), { + roundId: 1n, + }); + + await priceFeedDrivers[collateralBrandKey].setPrice(15.99); + + t.like(readLatest('published.priceFeed.ATOM-USD_price_feed.latestRound'), { + roundId: 2n, + }); + const priceFeedBuilder = '@agoric/builders/scripts/inter-protocol/updatePriceFeeds.js'; t.log('building', priceFeedBuilder); @@ -112,6 +123,11 @@ test.serial('setupVaults; run updatePriceFeeds proposals', async t => { 'VaultFactory', ); + // after the coreEval, the roundId will reset to 1. + t.like(readLatest('published.priceFeed.ATOM-USD_price_feed.latestRound'), { + roundId: 1n, + }); + t.notDeepEqual( newVaultInstallation.getKref(), oldVaultInstallation.getKref(), @@ -130,14 +146,20 @@ test.serial('1. place bid', async t => { test.serial('2. trigger liquidation by changing price', async t => { const { priceFeedDrivers, readLatest } = t.context; + // the current roundId is still 1. Round 1 is special, and you can't get to + // round 2 until roundTimeout (10s) has elapsed. await priceFeedDrivers[collateralBrandKey].setPrice(9.99); - t.log(readLatest('published.priceFeed.ATOM-USD_price_feed'), { + t.like(readLatest('published.priceFeed.ATOM-USD_price_feed'), { // aka 9.99 amountIn: { value: 1000000n }, amountOut: { value: 9990000n }, }); + t.like(readLatest('published.priceFeed.ATOM-USD_price_feed.latestRound'), { + roundId: 1n, + }); + // check nothing liquidating yet const liveSchedule: ScheduleNotification = readLatest( 'published.auction.schedule', diff --git a/packages/inter-protocol/src/price/roundsManager.js b/packages/inter-protocol/src/price/roundsManager.js index 0e47ce4cc38..90cb2280b0a 100644 --- a/packages/inter-protocol/src/price/roundsManager.js +++ b/packages/inter-protocol/src/price/roundsManager.js @@ -26,7 +26,7 @@ const V3_NO_DATA_ERROR = 'No data present'; /** @type {bigint} */ export const ROUND_MAX = BigInt(2 ** 32 - 1); -const trace = makeTracer('RoundsM', false); +const trace = makeTracer('RoundsM', true); /** @param {bigint} roundId */ const validRoundId = roundId => { @@ -172,10 +172,13 @@ export const prepareRoundsManagerKit = baggage => rounds, unitIn, }; + + const roundId = 0n; + return { ...immutable, lastValueOutForUnitIn: null, - reportingRoundId: 0n, + reportingRoundId: roundId, }; }, { @@ -600,8 +603,8 @@ export const prepareRoundsManagerKit = baggage => /** * a method to provide all current info oracleStatuses need. Intended - * only only to be callable by oracleStatuses. Not for use by contracts - * to read state. + * only to be callable by oracleStatuses. Not for use by contracts to + * read state. * * @param {OracleStatus} status * @param {Timestamp} blockTimestamp @@ -728,4 +731,50 @@ export const prepareRoundsManagerKit = baggage => }, }, }, + { + finish: ({ state }) => { + const { details, rounds, timerPresence } = state; + // Zero is treated as special as roundId and in times. It's hard to + // avoid on restart and in tests, so make 1 the minimum + + const firstRound = 1n; + state.reportingRoundId = firstRound; + details.init( + firstRound, + harden({ + submissions: [], + maxSubmissions: state.maxSubmissionCount, + minSubmissions: state.minSubmissionCount, + roundTimeout: state.timeout, + }), + ); + + // Cannot await in first crank. Fail if no timestamp available + void E.when( + E(timerPresence).getCurrentTimestamp(), + nowMaybe => { + const now = + TimeMath.compareAbs(nowMaybe, 1n) < 0 + ? TimeMath.coerceTimestampRecord(1n, nowMaybe.timerBrand) + : nowMaybe; + + const round = harden({ + answer: 0n, + startedAt: now, + updatedAt: 0n, + answeredInRound: 0n, + }); + rounds.init(firstRound, round); + + // In case this is a replacement priceFeed, set roundId in vstorage. + void state.latestRoundPublisher.write({ + roundId: firstRound, + startedAt: round.startedAt, + startedBy: 'uninitialized', + }); + }, + reason => Fail`need a timestamp to start roundsManager ${reason}`, + ); + }, + }, ); diff --git a/packages/inter-protocol/test/price/fluxAggregatorKit.test.js b/packages/inter-protocol/test/price/fluxAggregatorKit.test.js index c25b5033647..7dbf4c3fc78 100644 --- a/packages/inter-protocol/test/price/fluxAggregatorKit.test.js +++ b/packages/inter-protocol/test/price/fluxAggregatorKit.test.js @@ -94,46 +94,53 @@ test('basic, with snapshot', async t => { t.log('----- round 1: basic consensus'); await oracleTimer.tick(); - await E(oracleA).pushPrice({ roundId: 1, unitPrice: 100n }); - await E(oracleB).pushPrice({ roundId: 1, unitPrice: 200n }); - await E(oracleC).pushPrice({ roundId: 1, unitPrice: 300n }); + await E(oracleC).pushPrice({ roundId: 1, unitPrice: 130n }); + await E(oracleA).pushPrice({ roundId: 1, unitPrice: 110n }); + await E(oracleB).pushPrice({ roundId: 1, unitPrice: 120n }); + await oracleTimer.tick(); + + t.log('----- round 2: more prices'); + await oracleTimer.tick(); + await E(oracleA).pushPrice({ roundId: 2, unitPrice: 100n }); + await E(oracleC).pushPrice({ roundId: 2, unitPrice: 300n }); + await E(oracleB).pushPrice({ roundId: 2, unitPrice: 200n }); await oracleTimer.tick(); const round1Attempt1 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt1.roundId, 1n); - t.is(round1Attempt1.answer, 200n); + t.is(round1Attempt1.answer, 120n); t.log('----- round 2: check restartDelay implementation'); // since oracle A initialized the last round, it CANNOT start another round before // the restartDelay, which means its submission will be IGNORED. this means the median // should ONLY be between the OracleB and C values, which is why it is 25000 await oracleTimer.tick(); - await t.throwsAsync(E(oracleA).pushPrice({ roundId: 2, unitPrice: 1000n }), { + await t.throwsAsync(E(oracleA).pushPrice({ roundId: 3, unitPrice: 1000n }), { message: - 'round 2 not accepting submissions from oracle "agorice1priceOracleA"', + 'round 3 not accepting submissions from oracle "agorice1priceOracleA"', }); - await E(oracleB).pushPrice({ roundId: 2, unitPrice: 2000n }); - await E(oracleC).pushPrice({ roundId: 2, unitPrice: 3000n }); + await E(oracleB).pushPrice({ roundId: 3, unitPrice: 2000n }); + await E(oracleC).pushPrice({ roundId: 3, unitPrice: 3000n }); await oracleTimer.tick(); - const round1Attempt2 = await E(aggregator.creator).getRoundData(1); - t.is(round1Attempt2.answer, 200n); const round2Attempt1 = await E(aggregator.creator).getRoundData(2); - t.is(round2Attempt1.answer, 2500n); + t.is(round2Attempt1.answer, 200n); + const round3Attempt1 = await E(aggregator.creator).getRoundData(3); + t.is(round3Attempt1.answer, 2500n); - t.log('----- round 3: check oracle submission order'); + t.log('----- round 4: check oracle submission order'); // unlike the previous test, if C initializes, all submissions should be recorded, // which means the median will be the expected 5000 here await oracleTimer.tick(); - await E(oracleC).pushPrice({ roundId: 3, unitPrice: 5000n }); - await E(oracleA).pushPrice({ roundId: 3, unitPrice: 4000n }); - await E(oracleB).pushPrice({ roundId: 3, unitPrice: 6000n }); + await E(oracleC).pushPrice({ roundId: 4, unitPrice: 5000n }); + await E(oracleA).pushPrice({ roundId: 4, unitPrice: 4000n }); + await E(oracleB).pushPrice({ roundId: 4, unitPrice: 6000n }); await oracleTimer.tick(); - const round1Attempt3 = await E(aggregator.creator).getRoundData(1); - t.is(round1Attempt3.answer, 200n); - const round3Attempt1 = await E(aggregator.creator).getRoundData(3); - t.is(round3Attempt1.answer, 5000n); + const round2Attempt2 = await E(aggregator.creator).getRoundData(2); + t.is(round2Attempt2.answer, 200n); + const round4Attempt1 = await E(aggregator.creator).getRoundData(4); + t.is(round4Attempt1.answer, 5000n); const doc = { node: 'priceAggregator', @@ -526,6 +533,18 @@ test('suggest', async t => { t.is(round1Attempt1.roundId, 1n); t.is(round1Attempt1.answer, 200n); + t.deepEqual( + await E(aggregator.creator).oracleRoundState('agorice1priceOracleC', 1n), + { + eligibleForSpecificRound: false, + oracleCount: 3, + latestSubmission: 300n, + queriedRoundId: 1n, + roundTimeout: 5, + startedAt: toTS(1n), + }, + ); + // ----- round 2: add a new oracle and confirm the suggested round is correct await oracleTimer.tick(); await E(oracleB).pushPrice({ roundId: 2, unitPrice: 1000n }); @@ -607,22 +626,41 @@ test('notifications', async t => { const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); + const { oracle: oracleC } = await E(aggregator.creator).initOracle( + 'agorice1priceOracleC', + ); + + // Rounds 0 and 1 are weird. Let's jump to round 2 + await oracleTimer.tick(); + await E(oracleA).pushPrice({ roundId: 1, unitPrice: 350n }); + await E(oracleB).pushPrice({ roundId: 1, unitPrice: 250n }); + await E(oracleC).pushPrice({ roundId: 1, unitPrice: 150n }); + await oracleTimer.tick(); + await eventLoopIteration(); + + await E(oracleB).pushPrice({ roundId: 2, unitPrice: 450n }); + await E(oracleC).pushPrice({ roundId: 2, unitPrice: 650n }); + await E(oracleA).pushPrice({ roundId: 2, unitPrice: 550n }); + await oracleTimer.tick(); + await eventLoopIteration(); const publicTopics = await E(aggregator.public).getPublicTopics(); const eachLatestRound = subscribeEach(publicTopics.latestRound.subscriber)[ Symbol.asyncIterator ](); - - await oracleTimer.tick(); - await E(oracleA).pushPrice({ roundId: 1, unitPrice: 100n }); t.deepEqual((await eachLatestRound.next()).value, { - roundId: 1n, - startedAt: toTS(1n), - startedBy: 'agorice1priceOracleA', + roundId: 2n, + startedAt: toTS(2n), + startedBy: 'agorice1priceOracleB', }); - await E(oracleB).pushPrice({ roundId: 1, unitPrice: 200n }); + await oracleTimer.tick(); + await E(oracleA).pushPrice({ roundId: 3, unitPrice: 100n }); + + await oracleTimer.tick(); + await E(oracleB).pushPrice({ roundId: 3, unitPrice: 200n }); await eventLoopIteration(); + t.deepEqual( aggregator.mockStorageRoot.getBody( 'mockChainStorageRoot.priceAggregator.LINK-USD_price_feed', @@ -634,40 +672,48 @@ test('notifications', async t => { value: 150n, // AVG(100, 200) }, timer: Far('ManualTimer'), - timestamp: toMockTS(1n), + timestamp: toMockTS(5n), }, ); - await t.throwsAsync(E(oracleA).pushPrice({ roundId: 2, unitPrice: 1000n }), { + await t.throwsAsync(E(oracleA).pushPrice({ roundId: 4, unitPrice: 1000n }), { message: - 'round 2 not accepting submissions from oracle "agorice1priceOracleA"', + 'round 4 not accepting submissions from oracle "agorice1priceOracleA"', }); // A started last round so fails to start next round t.deepEqual( // subscribe fresh because the iterator won't advance yet (await publicTopics.latestRound.subscriber.subscribeAfter()).head.value, { - roundId: 1n, - startedAt: toTS(1n), + roundId: 3n, + startedAt: toTS(4n), startedBy: 'agorice1priceOracleA', }, ); + + t.deepEqual((await eachLatestRound.next()).value, { + roundId: 3n, + startedAt: toTS(4n), + startedBy: 'agorice1priceOracleA', + }); + // B gets to start it - await E(oracleB).pushPrice({ roundId: 2, unitPrice: 1000n }); + await E(oracleB).pushPrice({ roundId: 4, unitPrice: 1000n }); t.deepEqual((await eachLatestRound.next()).value, { - roundId: 2n, - startedAt: toTS(1n), + roundId: 4n, + startedAt: toTS(5n), startedBy: 'agorice1priceOracleB', }); // A joins in - await E(oracleA).pushPrice({ roundId: 2, unitPrice: 1000n }); + await E(oracleA).pushPrice({ roundId: 4, unitPrice: 1000n }); // writes to storage t.deepEqual( aggregator.mockStorageRoot.getBody( 'mockChainStorageRoot.priceAggregator.LINK-USD_price_feed.latestRound', ), - { roundId: 2n, startedAt: toMockTS(1n), startedBy: 'agorice1priceOracleB' }, + { roundId: 4n, startedAt: toMockTS(5n), startedBy: 'agorice1priceOracleB' }, ); + await oracleTimer.tick(); await eventLoopIteration(); t.deepEqual( @@ -681,15 +727,15 @@ test('notifications', async t => { value: 1000n, // AVG(1000, 1000) }, timer: Far('ManualTimer'), - timestamp: toMockTS(1n), + timestamp: toMockTS(6n), }, ); // A can start again - await E(oracleA).pushPrice({ roundId: 3, unitPrice: 1000n }); + await E(oracleA).pushPrice({ roundId: 5, unitPrice: 1000n }); t.deepEqual((await eachLatestRound.next()).value, { - roundId: 3n, - startedAt: toTS(1n), + roundId: 5n, + startedAt: toTS(6n), startedBy: 'agorice1priceOracleA', }); // no new price yet publishable diff --git a/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.md b/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.md index 65fdda37f8a..93bb8ba553e 100644 --- a/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.md +++ b/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.md @@ -25,7 +25,7 @@ Generated by [AVA](https://avajs.dev). }, timer: Object @Alleged: ManualTimer {}, timestamp: { - absValue: 6n, + absValue: 8n, timerBrand: Object @Alleged: timerBrand {}, }, }, @@ -33,9 +33,9 @@ Generated by [AVA](https://avajs.dev). [ 'published.priceAggregator.LINK-USD_price_feed.latestRound', { - roundId: 3n, + roundId: 4n, startedAt: { - absValue: 5n, + absValue: 7n, timerBrand: Object @Alleged: timerBrand {}, }, startedBy: 'agorice1priceOracleC', diff --git a/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.snap b/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.snap index 81041e3406b..8f52fe3b7ca 100644 Binary files a/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.snap and b/packages/inter-protocol/test/price/snapshots/fluxAggregatorKit.test.js.snap differ diff --git a/packages/inter-protocol/test/smartWallet/oracle-integration.test.js b/packages/inter-protocol/test/smartWallet/oracle-integration.test.js index e896b16ff88..18fc178e9a9 100644 --- a/packages/inter-protocol/test/smartWallet/oracle-integration.test.js +++ b/packages/inter-protocol/test/smartWallet/oracle-integration.test.js @@ -280,6 +280,11 @@ test.serial('admin price', async t => { const timerBrand = await E(manualTimer).getTimerBrand(); const toTS = val => TimeMath.coerceTimestampRecord(val, timerBrand); // trigger an aggregation (POLL_INTERVAL=1n in context) + // Can only get out of round 1 after more than roundTimeoutDuration (10s) + await E(manualTimer).tickN(12); + + const result2 = { roundId: 2, unitPrice: 123n }; + await pushPrice(wallet, adminOfferId, result2); await E(manualTimer).tickN(1); const paPublicFacet = E(zoe).getPublicFacet(governedPriceAggregator); @@ -288,8 +293,8 @@ test.serial('admin price', async t => { const latestRoundSubscriber = publicTopics.latestRound.subscriber; t.deepEqual((await latestRoundSubscriber.subscribeAfter()).head.value, { - roundId: 1n, - startedAt: toTS(0n), + roundId: 2n, + startedAt: toTS(12n), startedBy: 'adminPriceAddress', }); }); @@ -487,7 +492,7 @@ test.serial('govern oracles list', async t => { source: 'continuing', previousOffer: 'acceptEcInvitationOID', invitationMakerName: 'VoteOnApiCall', - invitationArgs: harden([feed, 'addOracles', [[newOracle]], 2n]), + invitationArgs: harden([feed, 'addOracles', [[newOracle]], 15n]), }; await offersFacet.executeOffer({ @@ -544,7 +549,7 @@ test.serial('govern oracles list', async t => { previousOffer: 'acceptEcInvitationOID', invitationMakerName: 'VoteOnApiCall', // XXX deadline 20n >> 2n before - invitationArgs: harden([feed, 'removeOracles', [[newOracle]], 20n]), + invitationArgs: harden([feed, 'removeOracles', [[newOracle]], 25n]), }; await offersFacet.executeOffer({