From 99fe4a4da207372cd70a6ec291dc36002974484f Mon Sep 17 00:00:00 2001 From: yashpatel5400 Date: Fri, 13 Aug 2021 00:21:16 -0400 Subject: [PATCH] Added Chainlink aggregator with tests --- .../zoe/src/contractSupport/priceAuthority.js | 2 +- .../src/contracts/priceAggregatorChainlink.js | 763 ++++++++++++++++++ .../test-priceAggregatorChainlink.js | 724 +++++++++++++++++ 3 files changed, 1488 insertions(+), 1 deletion(-) create mode 100644 packages/zoe/src/contracts/priceAggregatorChainlink.js create mode 100644 packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js diff --git a/packages/zoe/src/contractSupport/priceAuthority.js b/packages/zoe/src/contractSupport/priceAuthority.js index 9b931d8a64c..3354495bc50 100644 --- a/packages/zoe/src/contractSupport/priceAuthority.js +++ b/packages/zoe/src/contractSupport/priceAuthority.js @@ -33,7 +33,7 @@ const isGT = (amount, amountLimit) => !AmountMath.isGTE(amountLimit, amount); * @typedef {Object} OnewayPriceAuthorityOptions * @property {Issuer} quoteIssuer * @property {ERef>} notifier - * @property {TimerService} timer + * @property {ERef} timer * @property {PriceQuoteCreate} createQuote * @property {Brand} actualBrandIn * @property {Brand} actualBrandOut diff --git a/packages/zoe/src/contracts/priceAggregatorChainlink.js b/packages/zoe/src/contracts/priceAggregatorChainlink.js new file mode 100644 index 00000000000..61a79d1f8c4 --- /dev/null +++ b/packages/zoe/src/contracts/priceAggregatorChainlink.js @@ -0,0 +1,763 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; +import { makeNotifierKit } from '@agoric/notifier'; +import makeStore from '@agoric/store'; +import { Nat, isNat } from '@agoric/nat'; +import { AmountMath } from '@agoric/ertp'; +import { assert, details as X } from '@agoric/assert'; +import { + calculateMedian, + natSafeMath, + makeOnewayPriceAuthorityKit, +} from '../contractSupport'; + +import '../../tools/types'; + +const { add, subtract, multiply, floorDivide, ceilDivide, isGTE } = natSafeMath; + +/** + * This contract aggregates price values from a set of oracleStatuses and provides a + * PriceAuthority for their median. + * + * @type {ContractStartFn} + */ +const start = async zcf => { + const { + timer: rawTimer, + brands: { In: brandIn, Out: brandOut }, + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + minSubmissionValue, + maxSubmissionValue, + + unitAmountIn = AmountMath.make(1n, brandIn), + } = zcf.getTerms(); + + const unitIn = AmountMath.getValue(unitAmountIn, brandIn); + + /** @type {ERef} */ + const timer = rawTimer; + + // Get the timer's identity. + const timerPresence = await timer; + + /** @type {IssuerRecord & { mint: ERef }} */ + let quoteKit; + + /** @type {PriceAuthority} */ + let priceAuthority; + + /** @type {bigint} */ + let lastValueOutForUnitIn; + + // --- [begin] Chainlink specific values + /** @type {bigint} */ + let reportingRoundId = 0n; + + /** @type {Store>} */ + const oracleStatuses = makeStore('oracleStatus'); + + /** @type {Store>} */ + const rounds = makeStore('rounds'); + + /** @type {Store>} */ + const details = makeStore('details'); + + /** @type {bigint} */ + const ROUND_MAX = BigInt(2 ** 32 - 1); + + /** @type {string} */ + const V3_NO_DATA_ERROR = 'No data present'; + // --- [end] Chainlink specific values + + /** + * @param {number} answer + * @param {bigint} startedAt + * @param {bigint} updatedAt + * @param {bigint} answeredInRound + */ + const makeRound = (answer, startedAt, updatedAt, answeredInRound) => { + return { + answer, + startedAt, + updatedAt, + answeredInRound, + }; + }; + + /** + * @param {bigint[]} submissions + * @param {number} maxSubmissions + * @param {number} minSubmissions + * @param {number} roundTimeout + */ + const makeRoundDetails = ( + submissions, + maxSubmissions, + minSubmissions, + roundTimeout, + ) => { + return { + submissions, + maxSubmissions, + minSubmissions, + roundTimeout, + }; + }; + + /** + * @param {bigint} startingRound + * @param {bigint} endingRound + * @param {bigint} lastReportedRound + * @param {bigint} lastStartedRound + * @param {bigint} latestSubmission + * @param {number} index + */ + const makeOracleStatus = ( + startingRound, + endingRound, + lastReportedRound, + lastStartedRound, + latestSubmission, + index, + ) => { + return { + startingRound, + endingRound, + lastReportedRound, + lastStartedRound, + latestSubmission, + index, + }; + }; + + /** + * + * @param {PriceQuoteValue} quote + */ + const authenticateQuote = async quote => { + const quoteAmount = AmountMath.make(quote, quoteKit.brand); + const quotePayment = await E(quoteKit.mint).mintPayment(quoteAmount); + return harden({ quoteAmount, quotePayment }); + }; + + const { notifier } = makeNotifierKit(); + const zoe = zcf.getZoeService(); + + /** + * @typedef {Object} OracleRecord + * @property {(timestamp: Timestamp) => Promise=} querier + * @property {number} lastSample + */ + + /** @type {Store>} */ + const instanceToRecords = makeStore('oracleInstance'); + + /** + * @param {Object} param0 + * @param {number} [param0.overrideValueOut] + * @param {Timestamp} [param0.timestamp] + */ + const makeCreateQuote = ({ overrideValueOut, timestamp } = {}) => + /** + * @param {PriceQuery} priceQuery + */ + function createQuote(priceQuery) { + // Sniff the current baseValueOut. + const valueOutForUnitIn = + overrideValueOut === undefined + ? lastValueOutForUnitIn // Use the latest value. + : overrideValueOut; // Override the value. + if (valueOutForUnitIn === undefined) { + // We don't have a quote, so abort. + return undefined; + } + + /** + * @param {Amount} amountIn the given amountIn + */ + const calcAmountOut = amountIn => { + const valueIn = AmountMath.getValue(amountIn, brandIn); + return AmountMath.make( + floorDivide(multiply(valueIn, valueOutForUnitIn), unitIn), + brandOut, + ); + }; + + /** + * @param {Amount} amountOut the wanted amountOut + */ + const calcAmountIn = amountOut => { + const valueOut = AmountMath.getValue(amountOut, brandOut); + return AmountMath.make( + ceilDivide(multiply(valueOut, unitIn), valueOutForUnitIn), + brandIn, + ); + }; + + // Calculate the quote. + const quote = priceQuery(calcAmountOut, calcAmountIn); + if (!quote) { + return undefined; + } + + const { + amountIn, + amountOut, + timestamp: theirTimestamp = timestamp, + } = quote; + AmountMath.coerce(amountIn, brandIn); + AmountMath.coerce(amountOut, brandOut); + if (theirTimestamp !== undefined) { + return authenticateQuote([ + { + amountIn, + amountOut, + timer: timerPresence, + timestamp: theirTimestamp, + }, + ]); + } + return E(timer) + .getCurrentTimestamp() + .then(now => + authenticateQuote([ + { amountIn, amountOut, timer: timerPresence, timestamp: now }, + ]), + ); + }; + + /** + * @param {bigint} _roundId + * @param {bigint} blockTimestamp + */ + const timedOut = (_roundId, blockTimestamp) => { + if (!details.has(_roundId) || !rounds.has(_roundId)) { + return false; + } + + const startedAt = rounds.get(_roundId).startedAt; + const roundTimeout = details.get(_roundId).roundTimeout; + const roundTimedOut = + startedAt > 0 && + roundTimeout > 0 && + add(startedAt, roundTimeout) < blockTimestamp; + + return roundTimedOut; + }; + + /** + * @param {bigint} _roundId + * @param {bigint} blockTimestamp + */ + const updateTimedOutRoundInfo = (_roundId, blockTimestamp) => { + // round 0 is non-existent, so we avoid that case -- round 1 is ignored + // because we can't copy from round 0 in that case + if (_roundId === 0n || _roundId === 1n) { + return; + } + + const roundTimedOut = timedOut(_roundId, blockTimestamp); + if (!roundTimedOut) return; + + const prevId = subtract(_roundId, 1); + + const round = rounds.get(_roundId); + round.answer = rounds.get(prevId).answer; + round.answeredInRound = rounds.get(prevId).answeredInRound; + round.updatedAt = blockTimestamp; + + details.delete(_roundId); + }; + + /** + * @param {bigint} _roundId + */ + const newRound = _roundId => { + return _roundId === add(reportingRoundId, 1); + }; + + /** + * @param {bigint} _roundId + * @param {bigint} blockTimestamp + */ + const initializeNewRound = (_roundId, blockTimestamp) => { + updateTimedOutRoundInfo(subtract(_roundId, 1), blockTimestamp); + + reportingRoundId = _roundId; + + details.init( + _roundId, + makeRoundDetails([], maxSubmissionCount, minSubmissionCount, timeout), + ); + + rounds.init( + _roundId, + makeRound( + /* answer = */ 0, + /* startedAt = */ 0n, + /* updatedAt = */ 0n, + /* answeredInRound = */ 0n, + ), + ); + + rounds.get(_roundId).startedAt = blockTimestamp; + }; + + /** + * @param {bigint} _roundId + * @param {Instance} _oracle + * @param {bigint} blockTimestamp + */ + const oracleInitializeNewRound = (_roundId, _oracle, blockTimestamp) => { + if (!newRound(_roundId)) return; + const lastStarted = oracleStatuses.get(_oracle).lastStartedRound; // cache storage reads + if (_roundId <= add(lastStarted, restartDelay) && lastStarted !== 0n) + return; + initializeNewRound(_roundId, blockTimestamp); + + oracleStatuses.get(_oracle).lastStartedRound = _roundId; + }; + + /** + * @param {bigint} _roundId + */ + const acceptingSubmissions = _roundId => { + return details.has(_roundId) && details.get(_roundId).maxSubmissions !== 0; + }; + + /** + * @param {bigint} _submission + * @param {bigint} _roundId + * @param {Instance} _oracle + */ + const recordSubmission = (_submission, _roundId, _oracle) => { + if (!acceptingSubmissions(_roundId)) { + console.error('round not accepting submissions'); + return false; + } + + details.get(_roundId).submissions.push(_submission); + oracleStatuses.get(_oracle).lastReportedRound = _roundId; + oracleStatuses.get(_oracle).latestSubmission = _submission; + return true; + }; + + /** + * @param {bigint} _roundId + * @param {bigint} blockTimestamp + */ + const updateRoundAnswer = (_roundId, blockTimestamp) => { + if ( + details.get(_roundId).submissions.length < + details.get(_roundId).minSubmissions + ) { + return [false, 0]; + } + + const newAnswer = calculateMedian( + details + .get(_roundId) + .submissions.filter(sample => isNat(sample) && sample > 0n), + { add, divide: floorDivide, isGTE }, + ); + + rounds.get(_roundId).answer = newAnswer; + rounds.get(_roundId).updatedAt = blockTimestamp; + rounds.get(_roundId).answeredInRound = _roundId; + + return [true, newAnswer]; + }; + + /** + * @param {bigint} _roundId + */ + const deleteRoundDetails = _roundId => { + if ( + details.get(_roundId).submissions.length < + details.get(_roundId).maxSubmissions + ) + return; + details.delete(_roundId); + }; + + /** + * @param {bigint} _roundId + */ + const validRoundId = _roundId => { + return _roundId <= ROUND_MAX; + }; + + /** + */ + const oracleCount = () => { + return oracleStatuses.keys().length; + }; + + /** + * @param {bigint} _roundId + * @param {bigint} blockTimestamp + */ + const supersedable = (_roundId, blockTimestamp) => { + return ( + rounds.has(_roundId) && + (rounds.get(_roundId).updatedAt > 0 || timedOut(_roundId, blockTimestamp)) + ); + }; + + /** + * @param {bigint} _roundId + * @param {bigint} _rrId + */ + const previousAndCurrentUnanswered = (_roundId, _rrId) => { + return add(_roundId, 1) === _rrId && rounds.get(_rrId).updatedAt === 0n; + }; + + /** + * @param {Instance} _oracle + * @param {bigint} _roundId + * @param {bigint} blockTimestamp + */ + const validateOracleRound = (_oracle, _roundId, blockTimestamp) => { + // cache storage reads + const startingRound = oracleStatuses.get(_oracle).startingRound; + const rrId = reportingRoundId; + + let canSupersede = true; + if (_roundId !== 1n) { + canSupersede = supersedable(subtract(_roundId, 1), blockTimestamp); + } + + if (startingRound === 0n) return 'not enabled oracle'; + if (startingRound > _roundId) return 'not yet enabled oracle'; + if (oracleStatuses.get(_oracle).endingRound < _roundId) + return 'no longer allowed oracle'; + if (oracleStatuses.get(_oracle).lastReportedRound >= _roundId) + return 'cannot report on previous rounds'; + if ( + _roundId !== rrId && + _roundId !== add(rrId, 1) && + !previousAndCurrentUnanswered(_roundId, rrId) + ) + return 'invalid round to report'; + if (_roundId !== 1n && !canSupersede) + return 'previous round not supersedable'; + return ''; + }; + + /** + * @param {Instance} _oracle + * @param {bigint} _roundId + */ + const delayed = (_oracle, _roundId) => { + const lastStarted = oracleStatuses.get(_oracle).lastStartedRound; + return _roundId > add(lastStarted, restartDelay) || lastStarted === 0n; + }; + + /** + * 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. + * + * @param {Instance} _oracle + * @param {bigint} blockTimestamp + */ + const oracleRoundStateSuggestRound = (_oracle, blockTimestamp) => { + const oracle = oracleStatuses.get(_oracle); + + const shouldSupersede = + oracle.lastReportedRound === reportingRoundId || + !acceptingSubmissions(reportingRoundId); + // Instead of nudging oracleStatuses to submit to the next round, the inclusion of + // the shouldSupersede Boolean in the if condition pushes them towards + // submitting in a currently open round. + const canSupersede = supersedable(reportingRoundId, blockTimestamp); + + let roundId; + let eligibleToSubmit; + if (canSupersede && shouldSupersede) { + roundId = add(reportingRoundId, 1); + eligibleToSubmit = delayed(_oracle, roundId); + } else { + roundId = reportingRoundId; + eligibleToSubmit = acceptingSubmissions(roundId); + } + + let round; + let startedAt; + let roundTimeout; + if (rounds.has(roundId)) { + round = rounds.get(roundId); + startedAt = round.startedAt; + roundTimeout = details.get(roundId).roundTimeout; + } else { + startedAt = 0n; + roundTimeout = 0; + } + + const error = validateOracleRound(_oracle, roundId, blockTimestamp); + if (error.length !== 0) { + eligibleToSubmit = false; + } + + return { + eligibleForSpecificRound: eligibleToSubmit, + queriedRoundId: roundId, + oracleStatus: oracle.latestSubmission, + startedAt, + roundTimeout, + oracleCount: oracleCount(), + }; + }; + + /** + * @param {Instance} _oracle + * @param {bigint} _queriedRoundId + * @param {bigint} blockTimestamp + */ + const eligibleForSpecificRound = ( + _oracle, + _queriedRoundId, + blockTimestamp, + ) => { + const error = validateOracleRound(_oracle, _queriedRoundId, blockTimestamp); + if (rounds.get(_queriedRoundId).startedAt > 0) { + return acceptingSubmissions(_queriedRoundId) && error.length === 0; + } else { + return delayed(_oracle, _queriedRoundId) && error.length === 0; + } + }; + + /** + * @param {Instance} _oracle + */ + const getStartingRound = _oracle => { + const currentRound = reportingRoundId; + if ( + currentRound !== 0n && + currentRound === oracleStatuses.get(_oracle).endingRound + ) { + return currentRound; + } + return add(currentRound, 1); + }; + + /** @type {PriceAggregatorCreatorFacet} */ + const creatorFacet = Far('PriceAggregatorChainlinkCreatorFacet', { + async initializeQuoteMint(quoteMint) { + const quoteIssuerRecord = await zcf.saveIssuer( + E(quoteMint).getIssuer(), + 'Quote', + ); + quoteKit = { + ...quoteIssuerRecord, + mint: quoteMint, + }; + + const paKit = makeOnewayPriceAuthorityKit({ + createQuote: makeCreateQuote(), + notifier, + quoteIssuer: quoteKit.issuer, + timer, + actualBrandIn: brandIn, + actualBrandOut: brandOut, + }); + ({ priceAuthority } = paKit); + }, + + // unlike the median case, no query argument is passed, since polling behavior is undesired + async initOracle(oracleInstance) { + assert(quoteKit, X`Must initializeQuoteMint before adding an oracle`); + + /** @type {OracleRecord} */ + const record = { querier: undefined, lastSample: 0 }; + + /** @type {Set} */ + let records; + if (instanceToRecords.has(oracleInstance)) { + records = instanceToRecords.get(oracleInstance); + } else { + records = new Set(); + instanceToRecords.init(oracleInstance, records); + + const oracleStatus = makeOracleStatus( + /* startingRound = */ getStartingRound(oracleInstance), + /* endingRound = */ ROUND_MAX, + /* lastReportedRound = */ 0n, + /* lastStartedRound = */ 0n, + /* latestSubmission = */ 0n, + /* index = */ oracleStatuses.keys().length, + ); + oracleStatuses.init(oracleInstance, oracleStatus); + } + records.add(record); + + const pushResult = async ({ + roundId: _roundIdRaw = undefined, + data: _submissionRaw, + }) => { + const parsedSubmission = Nat(parseInt(_submissionRaw, 10)); + const blockTimestamp = await E(timer).getCurrentTimestamp(); + + let roundId; + if (_roundIdRaw === undefined) { + const suggestedRound = oracleRoundStateSuggestRound( + oracleInstance, + blockTimestamp, + ); + roundId = suggestedRound.queriedRoundId; + } else { + roundId = Nat(_roundIdRaw); + } + + const error = validateOracleRound( + oracleInstance, + roundId, + blockTimestamp, + ); + if (!(parsedSubmission >= minSubmissionValue)) { + console.error('value below minSubmissionValue'); + return; + } + + if (!(parsedSubmission <= maxSubmissionValue)) { + console.error('value above maxSubmissionValue'); + return; + } + + if (!(error.length === 0)) { + console.error(error); + return; + } + + oracleInitializeNewRound(roundId, oracleInstance, blockTimestamp); + const recorded = recordSubmission( + parsedSubmission, + roundId, + oracleInstance, + ); + if (!recorded) { + return; + } + + updateRoundAnswer(roundId, blockTimestamp); + deleteRoundDetails(roundId); + }; + + // Obtain the oracle's publicFacet. + await E(zoe).getPublicFacet(oracleInstance); + assert(records.has(record), X`Oracle record is already deleted`); + + /** @type {OracleAdmin} */ + const oracleAdmin = { + async delete() { + assert(records.has(record), X`Oracle record is already deleted`); + + // The actual deletion is synchronous. + oracleStatuses.delete(oracleInstance); + records.delete(record); + + if ( + records.size === 0 && + instanceToRecords.has(oracleInstance) && + instanceToRecords.get(oracleInstance) === records + ) { + // We should remove the entry entirely, as it is empty. + instanceToRecords.delete(oracleInstance); + } + }, + async pushResult({ + roundId: _roundIdRaw = undefined, + data: _submissionRaw, + }) { + // Sample of NaN, 0, or negative numbers get culled in + // the median calculation. + pushResult({ + roundId: _roundIdRaw, + data: _submissionRaw, + }); + }, + }; + + return harden(oracleAdmin); + }, + + /** + * consumers are encouraged to check + * that they're receiving fresh data by inspecting the updatedAt and + * answeredInRound return values. + * return is: [roundId, answer, startedAt, updatedAt, answeredInRound], where + * roundId is the round ID for which data was retrieved + * answer is the answer for the given round + * startedAt is the timestamp when the round was started. This is 0 + * if the round hasn't been started yet. + * updatedAt is the timestamp when the round last was updated (i.e. + * answer was last computed) + * answeredInRound is the round ID of the round in which the answer + * was computed. answeredInRound may be smaller than roundId when the round + * timed out. answeredInRound is equal to roundId when the round didn't time out + * and was completed regularly. + * + * @param {bigint} _roundIdRaw + */ + async getRoundData(_roundIdRaw) { + const roundId = Nat(_roundIdRaw); + + assert(rounds.has(roundId), V3_NO_DATA_ERROR); + + const r = rounds.get(roundId); + + assert(r.answeredInRound > 0 && validRoundId(roundId), V3_NO_DATA_ERROR); + + return { + roundId, + answer: r.answer, + startedAt: r.startedAt, + updatedAt: r.updatedAt, + answeredInRound: r.answeredInRound, + }; + }, + + /** + * 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. + * + * @param {Instance} _oracle + * @param {bigint} _queriedRoundId + */ + async oracleRoundState(_oracle, _queriedRoundId) { + const blockTimestamp = await E(timer).getCurrentTimestamp(); + if (_queriedRoundId > 0) { + const round = rounds.get(_queriedRoundId); + const detail = details.get(_queriedRoundId); + return { + eligibleForSpecificRound: eligibleForSpecificRound( + _oracle, + _queriedRoundId, + blockTimestamp, + ), + queriedRoundId: _queriedRoundId, + oracleStatus: oracleStatuses.get(_oracle).latestSubmission, + startedAt: round.startedAt, + roundTimeout: detail.roundTimeout, + oracleCount: oracleCount(), + }; + } else { + return oracleRoundStateSuggestRound(_oracle, blockTimestamp); + } + }, + }); + + const publicFacet = Far('publicFacet', { + getPriceAuthority() { + return priceAuthority; + }, + }); + + return harden({ creatorFacet, publicFacet }); +}; + +export { start }; diff --git a/packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js b/packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js new file mode 100644 index 00000000000..cadd148bd33 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js @@ -0,0 +1,724 @@ +// @ts-check +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import path from 'path'; + +import bundleSource from '@agoric/bundle-source'; + +import { E } from '@agoric/eventual-send'; +import { Far } from '@agoric/marshal'; +import { makeIssuerKit, AssetKind, AmountMath } from '@agoric/ertp'; + +import { makeFakeVatAdmin } from '../../../tools/fakeVatAdmin.js'; +import { makeZoe } from '../../../src/zoeService/zoe.js'; +import buildManualTimer from '../../../tools/manualTimer.js'; + +import '../../../exported.js'; +import '../../../src/contracts/exported.js'; + +/** + * @callback MakeFakePriceOracle + * @param {ExecutionContext} t + * @param {bigint} [valueOut] + * @returns {Promise} + */ + +/** + * @typedef {Object} TestContext + * @property {ZoeService} zoe + * @property {MakeFakePriceOracle} makeFakePriceOracle + * @property {(POLL_INTERVAL: bigint) => Promise} makeMedianAggregator + * @property {Amount} feeAmount + * @property {IssuerKit} link + * + * @typedef {import('ava').ExecutionContext} ExecutionContext + */ + +const filename = new URL(import.meta.url).pathname; +const dirname = path.dirname(filename); + +const oraclePath = `${dirname}/../../../src/contracts/oracle.js`; +const aggregatorPath = `${dirname}/../../../src/contracts/priceAggregatorChainlink.js`; + +test.before( + 'setup aggregator and oracles', + /** @param {ExecutionContext} ot */ async ot => { + // Outside of tests, we should use the long-lived Zoe on the + // testnet. In this test, we must create a new Zoe. + const zoe = makeZoe(makeFakeVatAdmin().admin); + + // Pack the contracts. + const oracleBundle = await bundleSource(oraclePath); + const aggregatorBundle = await bundleSource(aggregatorPath); + + // Install the contract on Zoe, getting an installation. We can + // use this installation to look up the code we installed. Outside + // of tests, we can also send the installation to someone + // else, and they can use it to create a new contract instance + // using the same code. + const oracleInstallation = await E(zoe).install(oracleBundle); + const aggregatorInstallation = await E(zoe).install(aggregatorBundle); + + const link = makeIssuerKit('$LINK', AssetKind.NAT); + const usd = makeIssuerKit('$USD', AssetKind.NAT); + + /** @type {MakeFakePriceOracle} */ + const makeFakePriceOracle = async (t, valueOut = undefined) => { + /** @type {OracleHandler} */ + const oracleHandler = Far('OracleHandler', { + async onQuery({ increment }, _fee) { + valueOut += increment; + return harden({ + reply: `${valueOut}`, + requiredFee: AmountMath.makeEmpty(link.brand), + }); + }, + onError(query, reason) { + console.error('query', query, 'failed with', reason); + }, + onReply(_query, _reply) {}, + }); + + /** @type {OracleStartFnResult} */ + const startResult = await E(zoe).startInstance( + oracleInstallation, + { Fee: link.issuer }, + { oracleDescription: 'myOracle' }, + ); + const creatorFacet = await E(startResult.creatorFacet).initialize({ + oracleHandler, + }); + + return harden({ + ...startResult, + creatorFacet, + }); + }; + + const quote = makeIssuerKit('quote', AssetKind.SET); + /** + * @param {RelativeTime} POLL_INTERVAL + */ + + const makeChainlinkAggregator = async ( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ) => { + const timer = buildManualTimer(() => {}); + + const aggregator = await E(zoe).startInstance( + aggregatorInstallation, + { In: link.issuer, Out: usd.issuer }, + { + timer, + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + }, + ); + await E(aggregator.creatorFacet).initializeQuoteMint(quote.mint); + return aggregator; + }; + ot.context.zoe = zoe; + ot.context.makeFakePriceOracle = makeFakePriceOracle; + ot.context.makeChainlinkAggregator = makeChainlinkAggregator; + }, +); + +test('basic', /** @param {ExecutionContext} t */ async t => { + const { makeFakePriceOracle, zoe } = t.context; + + const maxSubmissionCount = 1000; + const minSubmissionCount = 2; + const restartDelay = 5; + const timeout = 10; + const description = 'Chainlink oracles'; + const minSubmissionValue = 100; + const maxSubmissionValue = 10000; + + const aggregator = await t.context.makeChainlinkAggregator( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ); + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const priceOracleA = await makeFakePriceOracle(t); + const priceOracleB = await makeFakePriceOracle(t); + const priceOracleC = await makeFakePriceOracle(t); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + priceOracleA.instance, + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + priceOracleB.instance, + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + priceOracleC.instance, + ); + + // ----- round 1: basic consensus + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); + await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await oracleTimer.tick(); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt1.roundId, 1n); + t.deepEqual(round1Attempt1.answer, 200n); + + // ----- 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 E(pricePushAdminA).pushResult({ roundId: 2, data: '1000' }); + await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '3000' }); + await oracleTimer.tick(); + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt2.answer, 200n); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.deepEqual(round2Attempt1.answer, 2500n); + + // ----- round 3: check oracle submission order + // unlike the previus test, if C initializes, all submissions should be recorded, + // which means the median will be the expected 5000 here + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 3, data: '5000' }); + await E(pricePushAdminA).pushResult({ roundId: 3, data: '4000' }); + await E(pricePushAdminB).pushResult({ roundId: 3, data: '6000' }); + await oracleTimer.tick(); + + const round1Attempt3 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt3.answer, 200n); + const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + t.deepEqual(round3Attempt1.answer, 5000n); +}); + +test('timeout', /** @param {ExecutionContext} t */ async t => { + const { makeFakePriceOracle, zoe } = t.context; + + const maxSubmissionCount = 1000; + const minSubmissionCount = 2; + const restartDelay = 2; + const timeout = 5; + const description = 'Chainlink oracles'; + const minSubmissionValue = 100; + const maxSubmissionValue = 10000; + + const aggregator = await t.context.makeChainlinkAggregator( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ); + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const priceOracleA = await makeFakePriceOracle(t); + const priceOracleB = await makeFakePriceOracle(t); + const priceOracleC = await makeFakePriceOracle(t); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + priceOracleA.instance, + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + priceOracleB.instance, + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + priceOracleC.instance, + ); + + // ----- round 1: basic consensus w/ ticking: should work EXACTLY the same + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt1.roundId, 1n); + t.deepEqual(round1Attempt1.answer, 200n); + + // ----- round 2: check restartDelay implementation + // timeout behavior is, if more ticks pass than the timeout param (5 here), the round is + // considered "timedOut," at which point, the values are simply copied from the previous round + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); // --- should time out here + await E(pricePushAdminC).pushResult({ roundId: 3, data: '1000' }); + await E(pricePushAdminA).pushResult({ roundId: 3, data: '3000' }); + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt2.answer, 200n); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.deepEqual(round2Attempt1.answer, 200n); + const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + t.deepEqual(round3Attempt1.answer, 2000n); +}); + +test('issue check', /** @param {ExecutionContext} t */ async t => { + const { makeFakePriceOracle, zoe } = t.context; + + const maxSubmissionCount = 1000; + const minSubmissionCount = 2; + const restartDelay = 2; + const timeout = 5; + const description = 'Chainlink oracles'; + const minSubmissionValue = 100; + const maxSubmissionValue = 10000; + + const aggregator = await t.context.makeChainlinkAggregator( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ); + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const priceOracleA = await makeFakePriceOracle(t); + const priceOracleB = await makeFakePriceOracle(t); + const priceOracleC = await makeFakePriceOracle(t); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + priceOracleA.instance, + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + priceOracleB.instance, + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + priceOracleC.instance, + ); + + // ----- round 1: ignore too low valyes + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 1, data: '50' }); // should be IGNORED + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt1.answer, 250n); + + // ----- round 2: ignore too high values + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 2, data: '20000' }); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '1000' }); + await E(pricePushAdminA).pushResult({ roundId: 2, data: '3000' }); + await oracleTimer.tick(); + + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.deepEqual(round2Attempt1.answer, 2000n); +}); + +test('supersede', /** @param {ExecutionContext} t */ async t => { + const { makeFakePriceOracle, zoe } = t.context; + + const maxSubmissionCount = 1000; + const minSubmissionCount = 2; + const restartDelay = 1; + const timeout = 5; + const description = 'Chainlink oracles'; + const minSubmissionValue = 100; + const maxSubmissionValue = 10000; + + const aggregator = await t.context.makeChainlinkAggregator( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ); + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const priceOracleA = await makeFakePriceOracle(t); + const priceOracleB = await makeFakePriceOracle(t); + const priceOracleC = await makeFakePriceOracle(t); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + priceOracleA.instance, + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + priceOracleB.instance, + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + priceOracleC.instance, + ); + + // ----- round 1: round 1 is NOT supersedable when 3 submits, meaning it will be ignored + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '300' }); + await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await oracleTimer.tick(); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt1.answer, 150n); + + // ----- round 2: oracle C's value from before should have been IGNORED + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); + await E(pricePushAdminA).pushResult({ roundId: 2, data: '1000' }); + await oracleTimer.tick(); + + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.deepEqual(round2Attempt1.answer, 1500n); + + // ----- round 3: oracle C should NOT be able to supersede round 3 + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 4, data: '1000' }); + + try { + await E(aggregator.creatorFacet).getRoundData(4); + } catch (error) { + t.deepEqual(error.message, 'No data present'); + } +}); + +test('interleaved', /** @param {ExecutionContext} t */ async t => { + const { makeFakePriceOracle, zoe } = t.context; + + const maxSubmissionCount = 3; + const minSubmissionCount = 3; // requires ALL the oracles for consensus in this case + const restartDelay = 1; + const timeout = 5; + const description = 'Chainlink oracles'; + const minSubmissionValue = 100; + const maxSubmissionValue = 10000; + + const aggregator = await t.context.makeChainlinkAggregator( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ); + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const priceOracleA = await makeFakePriceOracle(t); + const priceOracleB = await makeFakePriceOracle(t); + const priceOracleC = await makeFakePriceOracle(t); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + priceOracleA.instance, + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + priceOracleB.instance, + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + priceOracleC.instance, + ); + + // ----- round 1: we now need unanimous submission for a round for it to have consensus + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '300' }); + await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await oracleTimer.tick(); + + try { + await E(aggregator.creatorFacet).getRoundData(1); + } catch (error) { + t.deepEqual(error.message, 'No data present'); + } + + // ----- round 2: interleaved round submission -- just making sure this works + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); + await E(pricePushAdminA).pushResult({ roundId: 2, data: '1000' }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 3, data: '9000' }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '3000' }); // assumes oracle C is going for a resubmission + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 3, data: '5000' }); + await oracleTimer.tick(); + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + + t.deepEqual(round1Attempt2.answer, 200n); + t.deepEqual(round2Attempt1.answer, 2000n); + + try { + await E(aggregator.creatorFacet).getRoundData(3); + } catch (error) { + t.deepEqual(error.message, 'No data present'); + } + + // ----- round 3/4: complicated supersedable case + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + // round 3 is NOT yet supersedeable (since no value present and not yet timed out), so these should fail + await E(pricePushAdminA).pushResult({ roundId: 4, data: '4000' }); + await E(pricePushAdminB).pushResult({ roundId: 4, data: '5000' }); + await E(pricePushAdminC).pushResult({ roundId: 4, data: '6000' }); + await oracleTimer.tick(); // --- round 3 has NOW timed out, meaning it is now supersedable + + try { + await E(aggregator.creatorFacet).getRoundData(3); + } catch (error) { + t.deepEqual(error.message, 'No data present'); + } + + try { + await E(aggregator.creatorFacet).getRoundData(4); + } catch (error) { + t.deepEqual(error.message, 'No data present'); + } + + // so NOW we should be able to submit round 4, and round 3 should just be copied from round 2 + await E(pricePushAdminA).pushResult({ roundId: 4, data: '4000' }); + await E(pricePushAdminB).pushResult({ roundId: 4, data: '5000' }); + await E(pricePushAdminC).pushResult({ roundId: 4, data: '6000' }); + await oracleTimer.tick(); + + const round3Attempt3 = await E(aggregator.creatorFacet).getRoundData(3); + const round4Attempt2 = await E(aggregator.creatorFacet).getRoundData(4); + + t.deepEqual(round3Attempt3.answer, 2000n); + t.deepEqual(round4Attempt2.answer, 5000n); + + // ----- round 5: ping-ponging should be possible (although this is an unlikely pernicious case) + await E(pricePushAdminC).pushResult({ roundId: 5, data: '1000' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 6, data: '1000' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 7, data: '1000' }); + + const round5Attempt1 = await E(aggregator.creatorFacet).getRoundData(5); + const round6Attempt1 = await E(aggregator.creatorFacet).getRoundData(6); + + t.deepEqual(round5Attempt1.answer, 5000n); + t.deepEqual(round6Attempt1.answer, 5000n); +}); + +test('larger', /** @param {ExecutionContext} t */ async t => { + const { makeFakePriceOracle, zoe } = t.context; + + const maxSubmissionCount = 1000; + const minSubmissionCount = 3; + const restartDelay = 1; + const timeout = 5; + const description = 'Chainlink oracles'; + const minSubmissionValue = 100; + const maxSubmissionValue = 10000; + + const aggregator = await t.context.makeChainlinkAggregator( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ); + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const priceOracleA = await makeFakePriceOracle(t); + const priceOracleB = await makeFakePriceOracle(t); + const priceOracleC = await makeFakePriceOracle(t); + const priceOracleD = await makeFakePriceOracle(t); + const priceOracleE = await makeFakePriceOracle(t); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + priceOracleA.instance, + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + priceOracleB.instance, + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + priceOracleC.instance, + ); + const pricePushAdminD = await E(aggregator.creatorFacet).initOracle( + priceOracleD.instance, + ); + const pricePushAdminE = await E(aggregator.creatorFacet).initOracle( + priceOracleE.instance, + ); + + // ----- round 1: usual case + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); + await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '1000' }); + await oracleTimer.tick(); + await E(pricePushAdminD).pushResult({ roundId: 3, data: '3000' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminE).pushResult({ roundId: 1, data: '300' }); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt1.answer, 200n); + + // ----- round 2: ignore late arrival + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 2, data: '600' }); + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 2, data: '500' }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 3, data: '1000' }); + await oracleTimer.tick(); + await E(pricePushAdminD).pushResult({ roundId: 1, data: '500' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '1000' }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 1, data: '700' }); // this should be IGNORED since oracle C has already sent round 2 + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.deepEqual(round1Attempt2.answer, 250n); + t.deepEqual(round2Attempt1.answer, 600n); +}); + +test('suggest', /** @param {ExecutionContext} t */ async t => { + const { makeFakePriceOracle, zoe } = t.context; + + const maxSubmissionCount = 1000; + const minSubmissionCount = 3; + const restartDelay = 1; + const timeout = 5; + const description = 'Chainlink oracles'; + const minSubmissionValue = 100; + const maxSubmissionValue = 10000; + + const aggregator = await t.context.makeChainlinkAggregator( + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + description, + minSubmissionValue, + maxSubmissionValue, + ); + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const priceOracleA = await makeFakePriceOracle(t); + const priceOracleB = await makeFakePriceOracle(t); + const priceOracleC = await makeFakePriceOracle(t); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + priceOracleA.instance, + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + priceOracleB.instance, + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + priceOracleC.instance, + ); + + // ----- round 1: basic consensus + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); + await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await oracleTimer.tick(); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.deepEqual(round1Attempt1.roundId, 1n); + t.deepEqual(round1Attempt1.answer, 200n); + + // ----- round 2: add a new oracle and confirm the suggested round is correct + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: 2, data: '1000' }); + const oracleCSuggestion = await E(aggregator.creatorFacet).oracleRoundState( + priceOracleC.instance, + 1n, + ); + + t.deepEqual(oracleCSuggestion.eligibleForSpecificRound, false); + t.deepEqual(oracleCSuggestion.queriedRoundId, 1n); + t.deepEqual(oracleCSuggestion.oracleCount, 3); + + const oracleBSuggestion = await E(aggregator.creatorFacet).oracleRoundState( + priceOracleB.instance, + 0n, + ); + + t.deepEqual(oracleBSuggestion.eligibleForSpecificRound, false); + t.deepEqual(oracleBSuggestion.queriedRoundId, 2n); + t.deepEqual(oracleBSuggestion.oracleCount, 3); + + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: 2, data: '2000' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminC).pushResult({ roundId: 2, data: '3000' }); + + const oracleASuggestion = await E(aggregator.creatorFacet).oracleRoundState( + priceOracleA.instance, + 0n, + ); + + t.deepEqual(oracleASuggestion.eligibleForSpecificRound, true); + t.deepEqual(oracleASuggestion.queriedRoundId, 3n); + t.deepEqual(oracleASuggestion.startedAt, 0n); // round 3 hasn't yet started, so it should be zeroed + + // ----- round 3: try using suggested round + await E(pricePushAdminC).pushResult({ roundId: 3, data: '100' }); + await oracleTimer.tick(); + await E(pricePushAdminA).pushResult({ roundId: undefined, data: '200' }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminB).pushResult({ roundId: undefined, data: '300' }); + + const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + t.deepEqual(round3Attempt1.roundId, 3n); + t.deepEqual(round3Attempt1.answer, 200n); +});