From 250599afe1f7a8fe0c2fc0ecac825c03192d3edd Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Fri, 24 Mar 2023 15:40:52 -0700 Subject: [PATCH] feat(fluxAggregator): upgradable --- .../src/price/fluxAggregatorContract.js | 76 +- .../src/price/fluxAggregatorKit.js | 417 +++--- .../src/price/priceOracleKit.js | 110 +- .../inter-protocol/src/price/roundsManager.js | 1169 ++++++++--------- .../src/proposals/price-feed-proposal.js | 2 +- .../test/price/test-fluxAggregatorKit.js | 169 ++- .../test/smartWallet/contexts.js | 2 +- .../smartWallet/test-oracle-integration.js | 2 +- ...ootstrap-fluxAggregator-service-upgrade.js | 277 ++++ .../test-fluxAggregator-service-upgrade.js | 90 ++ .../vaultFactory/vault-contract-wrapper.js | 3 +- 11 files changed, 1362 insertions(+), 955 deletions(-) create mode 100644 packages/inter-protocol/test/swingsetTests/fluxAggregator/bootstrap-fluxAggregator-service-upgrade.js create mode 100644 packages/inter-protocol/test/swingsetTests/fluxAggregator/test-fluxAggregator-service-upgrade.js diff --git a/packages/inter-protocol/src/price/fluxAggregatorContract.js b/packages/inter-protocol/src/price/fluxAggregatorContract.js index 45d427409611..9bf24d874da7 100644 --- a/packages/inter-protocol/src/price/fluxAggregatorContract.js +++ b/packages/inter-protocol/src/price/fluxAggregatorContract.js @@ -1,13 +1,18 @@ -import { AssetKind, makeIssuerKit } from '@agoric/ertp'; +import { + hasIssuer, + makeDurableIssuerKit, + prepareIssuerKit, +} from '@agoric/ertp'; import { handleParamGovernance } from '@agoric/governance'; import { assertAllDefined, makeTracer } from '@agoric/internal'; import { prepareDurablePublishKit } from '@agoric/notifier'; +import { provideAll } from '@agoric/zoe/src/contractSupport/durability.js'; import { prepareRecorder } from '@agoric/zoe/src/contractSupport/recorder.js'; import { E } from '@endo/eventual-send'; import { reserveThenDeposit } from '../proposals/utils.js'; -import { makeFluxAggregator } from './fluxAggregatorKit.js'; +import { prepareFluxAggregatorKit } from './fluxAggregatorKit.js'; -const trace = makeTracer('FluxAgg', false); +const trace = makeTracer('FluxAgg'); /** * @typedef {import('@agoric/vat-data').Baggage} Baggage * @typedef {import('@agoric/time/src/types').TimerService} TimerService @@ -28,36 +33,28 @@ const trace = makeTracer('FluxAgg', false); * initialPoserInvitation: Invitation, * marshaller: Marshaller, * namesByAddressAdmin: ERef, - * quoteMint?: ERef>, - * storageNode: ERef, + * storageNode: StorageNode, * }} privateArgs * @param {Baggage} baggage */ -export const start = async (zcf, privateArgs, baggage) => { - trace('start'); - const { timer: timerP } = zcf.getTerms(); - - const quoteMintP = - privateArgs.quoteMint || makeIssuerKit('quote', AssetKind.SET).mint; - const [quoteMint, quoteIssuerRecord] = await Promise.all([ - quoteMintP, - zcf.saveIssuer(E(quoteMintP).getIssuer(), 'Quote'), - ]); - const quoteKit = { - ...quoteIssuerRecord, - mint: quoteMint, - }; +export const prepare = async (zcf, privateArgs, baggage) => { + trace('prepare with baggage keys', [...baggage.keys()]); + + // xxx uses contract baggage as issuerBagage, assumes one issuer in this contract + /** @type {import('./roundsManager.js').QuoteKit} */ + const quoteIssuerKit = hasIssuer(baggage) + ? prepareIssuerKit(baggage) + : makeDurableIssuerKit(baggage, 'quote', 'set'); const { initialPoserInvitation, marshaller, namesByAddressAdmin, - storageNode: storageNodeP, + storageNode, } = privateArgs; - assertAllDefined({ initialPoserInvitation, marshaller, storageNodeP }); + assertAllDefined({ initialPoserInvitation, marshaller, storageNode }); - const timer = await timerP; - const storageNode = await storageNodeP; + const { timer } = zcf.getTerms(); trace('awaited args'); @@ -67,17 +64,24 @@ export const start = async (zcf, privateArgs, baggage) => { ); const makeRecorder = prepareRecorder(baggage, marshaller); - const fa = await makeFluxAggregator( + const makeFluxAggregatorKit = await prepareFluxAggregatorKit( + baggage, zcf, timer, - quoteKit, + quoteIssuerKit, storageNode, makeDurablePublishKit, makeRecorder, ); - trace('got fa', fa); - const { makeGovernorFacet } = await handleParamGovernance( + const { faKit } = await provideAll(baggage, { + faKit: () => makeFluxAggregatorKit(), + }); + trace('got faKit', faKit); + + // cannot be stored in baggage because not durable + // UNTIL https://github.com/Agoric/agoric-sdk/issues/4343 + const { makeDurableGovernorFacet } = handleParamGovernance( // @ts-expect-error FIXME include Governance params zcf, initialPoserInvitation, @@ -88,6 +92,8 @@ export const start = async (zcf, privateArgs, baggage) => { marshaller, ); + trace('got makeDurableGovernorFacet', makeDurableGovernorFacet); + /** * Initialize a new oracle and send an invitation to control it. * @@ -95,7 +101,7 @@ export const start = async (zcf, privateArgs, baggage) => { */ const addOracle = async addr => { trace('addOracle', addr); - const invitation = await E(fa.creatorFacet).makeOracleInvitation(addr); + const invitation = await E(faKit.creator).makeOracleInvitation(addr); // XXX imported from 'proposals' path await reserveThenDeposit( `fluxAggregator oracle ${addr}`, @@ -113,7 +119,7 @@ export const start = async (zcf, privateArgs, baggage) => { */ const removeOracle = async oracleId => { trace('removeOracle', oracleId); - await E(fa.creatorFacet).removeOracle(oracleId); + await E(faKit.creator).removeOracle(oracleId); return `removed ${oracleId}`; }; @@ -139,10 +145,16 @@ export const start = async (zcf, privateArgs, baggage) => { }, }; - const governorFacet = makeGovernorFacet(fa.creatorFacet, governedApis); + const { governorFacet } = makeDurableGovernorFacet( + baggage, + faKit.creator, + governedApis, + ); + trace('made governorFacet', governorFacet); + return harden({ creatorFacet: governorFacet, - publicFacet: fa.publicFacet, + publicFacet: faKit.public, }); }; -harden(start); +harden(prepare); diff --git a/packages/inter-protocol/src/price/fluxAggregatorKit.js b/packages/inter-protocol/src/price/fluxAggregatorKit.js index f8e4173d55ae..7d7e778ee88d 100644 --- a/packages/inter-protocol/src/price/fluxAggregatorKit.js +++ b/packages/inter-protocol/src/price/fluxAggregatorKit.js @@ -5,16 +5,22 @@ */ import { AmountMath } from '@agoric/ertp'; import { assertAllDefined, makeTracer } from '@agoric/internal'; -import { makeNotifierFromSubscriber, observeNotifier } from '@agoric/notifier'; -import { M, makeScalarBigMapStore } from '@agoric/vat-data'; +import { + makeNotifierFromSubscriber, + observeNotifier, + SubscriberShape, +} from '@agoric/notifier'; +import { M, makeScalarBigMapStore, prepareExoClassKit } from '@agoric/vat-data'; import { defineRecorderKit, makeOnewayPriceAuthorityKit, + makeRecorderTopic, + provideAll, } from '@agoric/zoe/src/contractSupport/index.js'; import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; -import { makeOracleAdminKit } from './priceOracleKit.js'; -import { makeRoundsManagerKit } from './roundsManager.js'; +import { prepareOracleAdminKit } from './priceOracleKit.js'; +import { prepareRoundsManagerKit } from './roundsManager.js'; const trace = makeTracer('FlxAgg', false); @@ -57,10 +63,14 @@ const priceDescriptionFromQuote = quote => quote.quoteAmount.value[0]; */ /** - * PriceAuthority for their median. Unlike the simpler `priceAggregator.js`, this approximates - * the *Node Operator Aggregation* logic of [Chainlink price + * Returns a maker for a single durable FluxAggregatorKit, closed over the prepare() arguments. + * + * The kit aggregates price inputs to produce a PriceAuthority. Unlike the + * simpler `priceAggregator.js`, this approximates the *Node Operator + * Aggregation* logic of [Chainlink price * feeds](https://blog.chain.link/levels-of-data-aggregation-in-chainlink-price-feeds/). * + * @param {Baggage} baggage * @param {ZCF, @@ -68,12 +78,14 @@ const priceDescriptionFromQuote = quote => quote.quoteAmount.value[0]; * unitAmountIn?: Amount<'nat'>, * }>} zcf * @param {TimerService} timerPresence - * @param {IssuerRecord<'set'> & { mint: Mint<'set'> }} quoteKit + * @param {import('./roundsManager.js').QuoteKit} quoteKit * @param {StorageNode} storageNode - * @param {*} makeDurablePublishKit + * @param {() => PublishKit} makeDurablePublishKit * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorder} makeRecorder + * @returns a method to call once to create the prepared kit */ -export const makeFluxAggregator = async ( +export const prepareFluxAggregatorKit = async ( + baggage, zcf, timerPresence, quoteKit, @@ -109,64 +121,64 @@ export const makeFluxAggregator = async ( unitAmountIn, }); + const makeRoundsManagerKit = prepareRoundsManagerKit(baggage); + const makeOracleAdminKit = prepareOracleAdminKit(baggage); + const makeRecorderKit = defineRecorderKit({ + // @ts-expect-error XXX makeDurablePublishKit, makeRecorder, }); - // For publishing priceAuthority values to off-chain storage - const { recorder: priceRecorder, subscriber: quoteSubscriber } = - makeRecorderKit( - storageNode, - /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( - M.any() - ), - ); + // End makers - const { recorder: latestRoundPublisher, subscriber: latestRoundSubscriber } = - makeRecorderKit( - await E(storageNode).makeChildNode('latestRound'), - /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( - M.any() + const { answerKit, latestRoundKit, priceKit } = await provideAll(baggage, { + /** This is just a signal that there's a new answer, which is read from `lastValueOutForUnitIn` */ + answerKit: () => makeDurablePublishKit(), + /** For publishing priceAuthority values to off-chain storage */ + priceKit: () => + makeRecorderKit( + storageNode, + /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( + M.any() + ), + ), + latestRoundKit: () => + E.when(E(storageNode).makeChildNode('latestRound'), node => + makeRecorderKit( + node, + /** @type {import('@agoric/zoe/src/contractSupport/recorder.js').TypedMatcher} */ ( + M.any() + ), + ), ), - ); - - /** @type {MapStore} */ - const oracles = makeScalarBigMapStore('oracles', { - durable: true, }); - // --- [end] Chainlink specific values - - /** - * This is just a signal that there's a new answer, which is read from `lastValueOutForUnitIn` - * - * @type {PublishKit} - */ - const { publisher: answerPublisher, subscriber: answerSubscriber } = - makeDurablePublishKit(); - - const roundsManagerKit = makeRoundsManagerKit( - harden({ - answerPublisher, - brandIn, - brandOut, - latestRoundPublisher, - minSubmissionCount, - maxSubmissionCount, - minSubmissionValue, - maxSubmissionValue, - quoteKit, - restartDelay, - timerPresence, - timeout, - unitAmountIn, - }), - ); + const { roundsManagerKit } = await provideAll(baggage, { + roundsManagerKit: () => + makeRoundsManagerKit( + harden({ + answerPublisher: answerKit.publisher, + brandIn, + brandOut, + latestRoundPublisher: latestRoundKit.recorder, + minSubmissionCount, + maxSubmissionCount, + minSubmissionValue, + maxSubmissionValue, + quoteKit, + restartDelay, + timerPresence, + timeout, + unitAmountIn, + }), + ), + }); + // not durable, held in closure and remade in every call of enclosing const { priceAuthority } = makeOnewayPriceAuthorityKit({ createQuote: roundsManagerKit.contract.makeCreateQuote(), - notifier: makeNotifierFromSubscriber(answerSubscriber), + notifier: makeNotifierFromSubscriber(answerKit.subscriber), quoteIssuer: quoteKit.issuer, timer: timerPresence, actualBrandIn: brandIn, @@ -177,8 +189,9 @@ export const makeFluxAggregator = async ( void observeNotifier( priceAuthority.makeQuoteNotifier(unitAmountIn, brandOut), { - updateState: quote => - priceRecorder.write(priceDescriptionFromQuote(quote)), + updateState: quote => { + priceKit.recorder.write(priceDescriptionFromQuote(quote)); + }, fail: reason => { throw Error(`priceAuthority observer failed: ${reason}`); }, @@ -188,150 +201,176 @@ export const makeFluxAggregator = async ( }, ); - const creatorFacet = Far('PriceAggregatorChainlinkCreatorFacet', { - /** - * An "oracle invitation" is an invitation to be able to submit data to - * include in the priceAggregator's results. - * - * The offer result from this invitation is a OracleAdmin, which can be used - * directly to manage the price submissions as well as to terminate the - * relationship. - * - * @param {string} oracleId unique per contract instance - */ - makeOracleInvitation: async oracleId => { - trace('makeOracleInvitation', oracleId); - /** - * If custom arguments are supplied to the `zoe.offer` call, they can - * indicate an OraclePriceSubmission notifier and a corresponding - * `shiftValueOut` that should be adapted as part of the priceAuthority's - * reported data. - * - * @param {ZCFSeat} seat - */ - const offerHandler = async seat => { - const { oracle } = await creatorFacet.initOracle(oracleId); - const invitationMakers = Far('invitation makers', { - /** @param {import('./roundsManager.js').PriceRound} result */ - PushPrice(result) { - return zcf.makeInvitation( - /** @param {ZCFSeat} cSeat */ - async cSeat => { - cSeat.exit(); - await oracle.pushPrice(result); - }, - 'PushPrice', - ); - }, - }); - seat.exit(); - - return harden({ - invitationMakers, - oracle, - }); - }; - - return zcf.makeInvitation(offerHandler, INVITATION_MAKERS_DESC); + const makeFluxAggregatorKit = prepareExoClassKit( + baggage, + 'fluxAggregator', + { + creator: M.interface('fluxAggregator creatorFacet', {}, { sloppy: true }), + public: M.interface('fluxAggregator publicFacet', { + getPriceAuthority: M.call().returns(M.any()), + getSubscriber: M.call().returns(SubscriberShape), + getRoundStartNotifier: M.call().returns(SubscriberShape), + getPublicTopics: M.call().returns({ + quotes: M.any(), + latestRound: M.any(), + }), + }), }, - /** @param {string} oracleId */ - removeOracle: async oracleId => { - trace('deleteOracle', oracleId); - const kit = oracles.get(oracleId); - kit.admin.disable(); - oracles.delete(oracleId); + () => { + /** @type {MapStore} */ + const oracles = makeScalarBigMapStore('oracles', { + durable: true, + }); + return { oracles }; }, + { + creator: { + /** + * An "oracle invitation" is an invitation to be able to submit data to + * include in the priceAggregator's results. + * + * The offer result from this invitation is a OracleAdmin, which can be used + * directly to manage the price submissions as well as to terminate the + * relationship. + * + * @param {string} oracleId unique per contract instance + */ + async makeOracleInvitation(oracleId) { + const { facets } = this; + trace('makeOracleInvitation', oracleId); + /** + * If custom arguments are supplied to the `zoe.offer` call, they can + * indicate an OraclePriceSubmission notifier and a corresponding + * `shiftValueOut` that should be adapted as part of the priceAuthority's + * reported data. + * + * @param {ZCFSeat} seat + */ + const offerHandler = async seat => { + const { oracle } = await facets.creator.initOracle(oracleId); + const invitationMakers = Far('invitation makers', { + /** @param {import('./roundsManager.js').PriceRound} result */ + PushPrice(result) { + return zcf.makeInvitation( + /** @param {ZCFSeat} cSeat */ + async cSeat => { + cSeat.exit(); + await oracle.pushPrice(result); + }, + 'PushPrice', + ); + }, + }); + seat.exit(); - getRoundData: roundIdRaw => { - return roundsManagerKit.contract.getRoundData(roundIdRaw); - }, + return harden({ + invitationMakers, + oracle, + }); + }; - /** @param {string} oracleId */ - async initOracle(oracleId) { - trace('initOracle', oracleId); - assert.typeof(oracleId, 'string'); + return zcf.makeInvitation(offerHandler, INVITATION_MAKERS_DESC); + }, + /** @param {string} oracleId */ + async removeOracle(oracleId) { + const { oracles } = this.state; + trace('deleteOracle', oracleId); + const kit = oracles.get(oracleId); + kit.admin.disable(); + oracles.delete(oracleId); + }, - const oracleKit = makeOracleAdminKit( - harden({ - minSubmissionValue, - maxSubmissionValue, - oracleId, // must be unique per vat - roundPowers: roundsManagerKit.oracle, - timer: timerPresence, - }), - ); - oracles.init(oracleId, oracleKit); + getRoundData: roundIdRaw => { + return roundsManagerKit.contract.getRoundData(roundIdRaw); + }, - return oracleKit; - }, + /** @param {string} oracleId */ + async initOracle(oracleId) { + const { oracles } = this.state; + trace('initOracle', oracleId); + assert.typeof(oracleId, 'string'); - /** - * 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 {string} oracleId - * @param {bigint} queriedRoundId - * @returns {Promise} - */ - async oracleRoundState(oracleId, queriedRoundId) { - const blockTimestamp = await E(timerPresence).getCurrentTimestamp(); - const status = await E(oracles.get(oracleId).oracle).getStatus(); + const oracleKit = makeOracleAdminKit( + harden({ + minSubmissionValue, + maxSubmissionValue, + oracleId, // must be unique per vat + roundPowers: roundsManagerKit.oracle, + timer: timerPresence, + }), + ); + oracles.init(oracleId, oracleKit); - const oracleCount = oracles.getSize(); + return oracleKit; + }, - const { contract } = roundsManagerKit; - if (queriedRoundId > 0) { - const roundStatus = contract.getRoundStatus(queriedRoundId); - return { - eligibleForSpecificRound: contract.eligibleForSpecificRound( - status, - queriedRoundId, - blockTimestamp, - ), - queriedRoundId, - latestSubmission: status.latestSubmission, - startedAt: roundStatus.startedAt, - roundTimeout: roundStatus.roundTimeout, - oracleCount, - }; - } else { - return { - ...contract.oracleRoundStateSuggestRound(status, blockTimestamp), - oracleCount, - }; - } - }, - }); + /** + * 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 {string} oracleId + * @param {bigint} queriedRoundId + * @returns {Promise} + */ + async oracleRoundState(oracleId, queriedRoundId) { + const { oracles } = this.state; + const blockTimestamp = await E(timerPresence).getCurrentTimestamp(); + const status = await E(oracles.get(oracleId).oracle).getStatus(); - const publicFacet = Far('publicFacet', { - getPriceAuthority() { - return priceAuthority; - }, - /** @deprecated use getPublicTopics */ - getSubscriber: () => { - return quoteSubscriber; - }, - /** @deprecated use getPublicTopics */ - getRoundStartNotifier() { - return latestRoundSubscriber; - }, - getPublicTopics() { - return { - quotes: { - description: 'Quotes from this price aggregator', - subscriber: quoteSubscriber, - storagePath: E(priceRecorder).getStoragePath(), + const oracleCount = oracles.getSize(); + + const { contract } = roundsManagerKit; + if (queriedRoundId > 0) { + const roundStatus = contract.getRoundStatus(queriedRoundId); + return { + eligibleForSpecificRound: contract.eligibleForSpecificRound( + status, + queriedRoundId, + blockTimestamp, + ), + queriedRoundId, + latestSubmission: status.latestSubmission, + startedAt: roundStatus.startedAt, + roundTimeout: roundStatus.roundTimeout, + oracleCount, + }; + } else { + return { + ...contract.oracleRoundStateSuggestRound(status, blockTimestamp), + oracleCount, + }; + } + }, + }, + public: { + getPriceAuthority() { + return priceAuthority; + }, + /** @deprecated use getPublicTopics */ + getSubscriber: () => { + return priceKit.subscriber; + }, + /** @deprecated use getPublicTopics */ + getRoundStartNotifier() { + return latestRoundKit.subscriber; }, - latestRound: { - description: 'Notification of each round', - subscriber: latestRoundSubscriber, - storagePath: E(latestRoundPublisher).getStoragePath(), + getPublicTopics() { + return { + quotes: makeRecorderTopic( + 'Quotes from this price aggregator', + priceKit, + ), + latestRound: makeRecorderTopic( + 'Notification of each round', + latestRoundKit, + ), + }; }, - }; + }, }, - }); + ); - return harden({ creatorFacet, publicFacet }); + return makeFluxAggregatorKit; }; -harden(makeFluxAggregator); -/** @typedef {ReturnType} FluxAggregator */ +harden(prepareFluxAggregatorKit); +/** @typedef {ReturnType>>} FluxAggregatorKit */ diff --git a/packages/inter-protocol/src/price/priceOracleKit.js b/packages/inter-protocol/src/price/priceOracleKit.js index 65bbca9d0ff4..8bd65753c1fa 100644 --- a/packages/inter-protocol/src/price/priceOracleKit.js +++ b/packages/inter-protocol/src/price/priceOracleKit.js @@ -1,6 +1,6 @@ import { Fail } from '@agoric/assert'; import { makeTracer } from '@agoric/internal'; -import { defineDurableExoClassKit, M, makeKindHandle } from '@agoric/vat-data'; +import { M, prepareExoClassKit } from '@agoric/vat-data'; const trace = makeTracer('OrKit', false); @@ -34,8 +34,6 @@ export const INVITATION_MAKERS_DESC = 'oracle invitation'; */ /** @typedef {ImmutableState & MutableState} State */ -const oracleKitKind = makeKindHandle('OracleKit'); - /** * @param {HeldParams} heldParams * @returns {State} @@ -65,63 +63,65 @@ const OracleI = M.interface('Oracle', { getStatus: M.call().returns(M.record()), }); -export const makeOracleAdminKit = defineDurableExoClassKit( - oracleKitKind, - { admin: AdminI, oracle: OracleI }, - initState, - { - admin: { - disable() { - trace(`oracle ${this.state.oracleId} disabled`); - this.state.disabled = true; +export const prepareOracleAdminKit = baggage => + prepareExoClassKit( + baggage, + 'OracleKit', + { admin: AdminI, oracle: OracleI }, + initState, + { + admin: { + disable() { + trace(`oracle ${this.state.oracleId} disabled`); + this.state.disabled = true; + }, }, - }, - oracle: { - /** - * push a unitPrice result from this oracle - * - * @param {PriceDatum} datum - */ - async pushPrice({ - roundId: roundIdRaw = undefined, - unitPrice: valueRaw, - }) { - const { state } = this; - !state.disabled || Fail`pushPrice for disabled oracle`; - const { roundPowers } = state; - const result = await roundPowers.handlePush( - { + oracle: { + /** + * push a unitPrice result from this oracle + * + * @param {PriceDatum} datum + */ + async pushPrice({ + roundId: roundIdRaw = undefined, + unitPrice: valueRaw, + }) { + const { state } = this; + !state.disabled || Fail`pushPrice for disabled oracle`; + const { roundPowers } = state; + const result = await roundPowers.handlePush( + { + oracleId: state.oracleId, + lastReportedRound: state.lastReportedRound, + lastStartedRound: state.lastStartedRound, + latestSubmission: state.latestSubmission, + }, + { + roundId: roundIdRaw, + unitPrice: valueRaw, + }, + ); + + state.lastReportedRound = result.lastReportedRound; + state.lastStartedRound = result.lastStartedRound; + state.latestSubmission = result.latestSubmission; + }, + /** + * + * @returns {OracleStatus} + */ + getStatus() { + const { state } = this; + return { oracleId: state.oracleId, + disabled: state.disabled, lastReportedRound: state.lastReportedRound, lastStartedRound: state.lastStartedRound, latestSubmission: state.latestSubmission, - }, - { - roundId: roundIdRaw, - unitPrice: valueRaw, - }, - ); - - state.lastReportedRound = result.lastReportedRound; - state.lastStartedRound = result.lastStartedRound; - state.latestSubmission = result.latestSubmission; - }, - /** - * - * @returns {OracleStatus} - */ - getStatus() { - const { state } = this; - return { - oracleId: state.oracleId, - disabled: state.disabled, - lastReportedRound: state.lastReportedRound, - lastStartedRound: state.lastStartedRound, - latestSubmission: state.latestSubmission, - }; + }; + }, }, }, - }, -); + ); -/** @typedef {ReturnType} OracleKit */ +/** @typedef {ReturnType>} OracleKit */ diff --git a/packages/inter-protocol/src/price/roundsManager.js b/packages/inter-protocol/src/price/roundsManager.js index 171dfab63d0f..4ca72868301f 100644 --- a/packages/inter-protocol/src/price/roundsManager.js +++ b/packages/inter-protocol/src/price/roundsManager.js @@ -1,19 +1,14 @@ import { Fail, q } from '@agoric/assert'; import { AmountMath } from '@agoric/ertp'; -import { isNat, Nat } from '@endo/nat'; import { TimeMath } from '@agoric/time'; -import { - defineDurableExoClassKit, - M, - makeKindHandle, - makeScalarBigMapStore, -} from '@agoric/vat-data'; +import { M, makeScalarBigMapStore, prepareExoClassKit } from '@agoric/vat-data'; import { calculateMedian, natSafeMath, } from '@agoric/zoe/src/contractSupport/index.js'; import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; +import { isNat, Nat } from '@endo/nat'; import { UnguardedHelperI } from '../typeGuards.js'; const { add, subtract, multiply, floorDivide, ceilDivide, isGTE } = natSafeMath; @@ -27,8 +22,6 @@ const { add, subtract, multiply, floorDivide, ceilDivide, isGTE } = natSafeMath; /** @type {string} */ const V3_NO_DATA_ERROR = 'No data present'; -const roundsManagerKind = makeKindHandle('RoundsManager'); - /** @type {bigint} */ export const ROUND_MAX = BigInt(2 ** 32 - 1); @@ -71,7 +64,7 @@ const validRoundId = roundId => { */ /** - * @typedef {IssuerRecord<'set'> & { mint: Mint<'set'> }} QuoteKit + * @typedef {IssuerKit<'set'>} QuoteKit */ /** @@ -97,54 +90,31 @@ const validRoundId = roundId => { */ /** @typedef {ImmutableState & MutableState} State */ -export const makeRoundsManagerKit = defineDurableExoClassKit( - roundsManagerKind, - { - helper: UnguardedHelperI, - contract: M.interface( - 'contract', - { - authenticateQuote: M.call(M.any()).returns(M.any()), - makeCreateQuote: M.call().optional(M.any()).returns(M.any()), - eligibleForSpecificRound: M.call(M.any()).returns(M.boolean()), - getRoundData: M.call(M.any()).returns(M.promise()), - getRoundStatus: M.call(M.any()).returns(M.record()), - oracleRoundStateSuggestRound: M.call(M.any()).returns(M.record()), - }, - // TODO(6571) stop sloppy - { sloppy: true }, - ), - oracle: M.interface('oracle', { - handlePush: M.call(M.record(), M.record()).returns(M.promise()), - }), - }, - /** @type {(opts: HeldParams & { unitAmountIn: Amount<'nat'> }) => State} */ - ({ - // ChainlinkConfig - maxSubmissionCount, - minSubmissionCount, - restartDelay, - minSubmissionValue, - maxSubmissionValue, - timeout, - // other HeldParams - quoteKit, - answerPublisher, - brandIn, - brandOut, - latestRoundPublisher, - timerPresence, - // other option - unitAmountIn, - }) => { - const unitIn = AmountMath.getValue(brandIn, unitAmountIn); - - const rounds = makeScalarBigMapStore('rounds', { durable: true }); - - const details = makeScalarBigMapStore('details', { durable: true }); - - /** @type {ImmutableState} */ - const immutable = { +export const prepareRoundsManagerKit = baggage => + prepareExoClassKit( + baggage, + 'RoundsManager', + { + helper: UnguardedHelperI, + contract: M.interface( + 'contract', + { + authenticateQuote: M.call(M.any()).returns(M.any()), + makeCreateQuote: M.call().optional(M.any()).returns(M.any()), + eligibleForSpecificRound: M.call(M.any()).returns(M.boolean()), + getRoundData: M.call(M.any()).returns(M.promise()), + getRoundStatus: M.call(M.any()).returns(M.record()), + oracleRoundStateSuggestRound: M.call(M.any()).returns(M.record()), + }, + // TODO(6571) stop sloppy + { sloppy: true }, + ), + oracle: M.interface('oracle', { + handlePush: M.call(M.record(), M.record()).returns(M.promise()), + }), + }, + /** @type {(opts: HeldParams & { unitAmountIn: Amount<'nat'> }) => State} */ + ({ // ChainlinkConfig maxSubmissionCount, minSubmissionCount, @@ -159,571 +129,598 @@ export const makeRoundsManagerKit = defineDurableExoClassKit( brandOut, latestRoundPublisher, timerPresence, - // computed - details, - rounds, - unitIn, - }; - return { - ...immutable, - lastValueOutForUnitIn: null, - reportingRoundId: 0n, - }; - }, - { - helper: { - /** - * @param {bigint} roundId - */ - acceptingSubmissions(roundId) { - const { details } = this.state; - return ( - details.has(roundId) && details.get(roundId).maxSubmissions !== 0 - ); - }, + // other option + unitAmountIn, + }) => { + const unitIn = AmountMath.getValue(brandIn, unitAmountIn); + + const rounds = makeScalarBigMapStore('rounds', { durable: true }); + + const details = makeScalarBigMapStore('details', { durable: true }); + + /** @type {ImmutableState} */ + const immutable = { + // ChainlinkConfig + maxSubmissionCount, + minSubmissionCount, + restartDelay, + minSubmissionValue, + maxSubmissionValue, + timeout, + // other HeldParams + quoteKit, + answerPublisher, + brandIn, + brandOut, + latestRoundPublisher, + timerPresence, + // computed + details, + rounds, + unitIn, + }; + return { + ...immutable, + lastValueOutForUnitIn: null, + reportingRoundId: 0n, + }; + }, + { + helper: { + /** + * @param {bigint} roundId + */ + acceptingSubmissions(roundId) { + const { details } = this.state; + return ( + details.has(roundId) && details.get(roundId).maxSubmissions !== 0 + ); + }, - /** - * @param {OracleStatus} status - * @param {bigint} roundId - */ - delayed(status, roundId) { - const { restartDelay } = this.state; - const lastStarted = status.lastStartedRound; - return roundId > add(lastStarted, restartDelay) || lastStarted === 0n; - }, + /** + * @param {OracleStatus} status + * @param {bigint} roundId + */ + delayed(status, roundId) { + const { restartDelay } = this.state; + const lastStarted = status.lastStartedRound; + return roundId > add(lastStarted, restartDelay) || lastStarted === 0n; + }, - /** - * @param {bigint} roundId - */ - deleteRoundDetails(roundId) { - const { details } = this.state; - const roundDetails = details.get(roundId); - if (roundDetails.submissions.length < roundDetails.maxSubmissions) - return; - details.delete(roundId); - }, + /** + * @param {bigint} roundId + */ + deleteRoundDetails(roundId) { + const { details } = this.state; + const roundDetails = details.get(roundId); + if (roundDetails.submissions.length < roundDetails.maxSubmissions) + return; + details.delete(roundId); + }, - /** - * @param {bigint} roundId - */ - isNextRound(roundId) { - const { reportingRoundId } = this.state; - return roundId === add(reportingRoundId, 1); - }, + /** + * @param {bigint} roundId + */ + isNextRound(roundId) { + const { reportingRoundId } = this.state; + return roundId === add(reportingRoundId, 1); + }, - /** - * @param {bigint} roundId - * @param {Timestamp} blockTimestamp - * @param {string} oracleId - */ - initializeNewRound(roundId, blockTimestamp, oracleId) { - const { - details, - latestRoundPublisher, - minSubmissionCount, - maxSubmissionCount, - rounds, - timeout, - } = this.state; - const { helper } = this.facets; - helper.isNextRound(roundId) || Fail`Round ${roundId} already started`; - - helper.updateTimedOutRoundInfo(subtract(roundId, 1), blockTimestamp); - - this.state.reportingRoundId = roundId; - - details.init( - roundId, - harden({ - submissions: [], - maxSubmissions: maxSubmissionCount, - minSubmissions: minSubmissionCount, - roundTimeout: timeout, - }), - ); - - const round = harden({ - answer: 0n, - startedAt: blockTimestamp, - updatedAt: 0n, - answeredInRound: 0n, - }); - rounds.init(roundId, round); - // assume it succeeds. if not, expect the next to. - // if it fails continuously it'll be apparent in the unhandled rejection logging. - void E(latestRoundPublisher).write({ - roundId, - startedAt: round.startedAt, - startedBy: oracleId, - }); - }, + /** + * @param {bigint} roundId + * @param {Timestamp} blockTimestamp + * @param {string} oracleId + */ + initializeNewRound(roundId, blockTimestamp, oracleId) { + const { + details, + latestRoundPublisher, + minSubmissionCount, + maxSubmissionCount, + rounds, + timeout, + } = this.state; + const { helper } = this.facets; + helper.isNextRound(roundId) || Fail`Round ${roundId} already started`; + + helper.updateTimedOutRoundInfo(subtract(roundId, 1), blockTimestamp); + + this.state.reportingRoundId = roundId; + + details.init( + roundId, + harden({ + submissions: [], + maxSubmissions: maxSubmissionCount, + minSubmissions: minSubmissionCount, + roundTimeout: timeout, + }), + ); - /** - * @param {bigint} roundId - * @param {bigint} rrId reporting round ID - */ - previousAndCurrentUnanswered(roundId, rrId) { - const { rounds } = this.state; - return add(roundId, 1) === rrId && rounds.get(rrId).updatedAt === 0n; - }, + const round = harden({ + answer: 0n, + startedAt: blockTimestamp, + updatedAt: 0n, + answeredInRound: 0n, + }); + rounds.init(roundId, round); + // assume it succeeds. if not, expect the next to. + // if it fails continuously it'll be apparent in the unhandled rejection logging. + void latestRoundPublisher.write({ + roundId, + startedAt: round.startedAt, + startedBy: oracleId, + }); + }, - /** - * @param {bigint} roundId - * @param {OracleStatus} status - * @param {Timestamp} blockTimestamp - * @returns {OracleStatus | undefined} the new status - */ - proposeNewRound(roundId, status, blockTimestamp) { - const { helper } = this.facets; - if (!helper.isNextRound(roundId)) return undefined; - const { restartDelay } = this.state; - const lastStarted = status.lastStartedRound; // cache storage reads - if (roundId <= add(lastStarted, restartDelay) && lastStarted !== 0n) - return undefined; - helper.initializeNewRound(roundId, blockTimestamp, status.oracleId); - - return harden({ - ...status, - lastStartedRound: roundId, - }); - }, + /** + * @param {bigint} roundId + * @param {bigint} rrId reporting round ID + */ + previousAndCurrentUnanswered(roundId, rrId) { + const { rounds } = this.state; + return add(roundId, 1) === rrId && rounds.get(rrId).updatedAt === 0n; + }, - /** - * @param {bigint} submission - * @param {bigint} roundId - * @param {OracleStatus} status - * @returns {OracleStatus} the new status - */ - recordSubmission(submission, roundId, status) { - const { helper } = this.facets; - const { details } = this.state; - helper.acceptingSubmissions(roundId) || - Fail`round ${q( - Number(roundId), - )} not accepting submissions from oracle ${q(status.oracleId)}`; - - const lastRoundDetails = details.get(roundId); - details.set(roundId, { - ...lastRoundDetails, - submissions: [...lastRoundDetails.submissions, submission], - }); - - return { - ...status, - lastReportedRound: roundId, - latestSubmission: submission, - }; - }, + /** + * @param {bigint} roundId + * @param {OracleStatus} status + * @param {Timestamp} blockTimestamp + * @returns {OracleStatus | undefined} the new status + */ + proposeNewRound(roundId, status, blockTimestamp) { + const { helper } = this.facets; + if (!helper.isNextRound(roundId)) return undefined; + const { restartDelay } = this.state; + const lastStarted = status.lastStartedRound; // cache storage reads + if (roundId <= add(lastStarted, restartDelay) && lastStarted !== 0n) + return undefined; + helper.initializeNewRound(roundId, blockTimestamp, status.oracleId); - /** - * @param {bigint} roundId - * @param {Timestamp} blockTimestamp - */ - supersedable(roundId, blockTimestamp) { - const { rounds } = this.state; - const { helper } = this.facets; - return ( - rounds.has(roundId) && - (TimeMath.absValue(rounds.get(roundId).updatedAt) > 0n || - helper.timedOut(roundId, blockTimestamp)) - ); - }, - /** - * @param {bigint} roundId - * @param {Timestamp} blockTimestamp - */ - timedOut(roundId, blockTimestamp) { - const { details, rounds } = this.state; - if (!details.has(roundId) || !rounds.has(roundId)) { - return false; - } - - const startedAt = rounds.get(roundId).startedAt; - const roundTimeout = details.get(roundId).roundTimeout; - // TODO Better would be to make `roundTimeout` a `RelativeTime` - // everywhere, and to rename it to a name that does not - // mistakenly imply that it is an absolute time. - const roundTimeoutDuration = TimeMath.toRel(roundTimeout); - const roundTimedOut = - TimeMath.absValue(startedAt) > 0n && - TimeMath.relValue(roundTimeoutDuration) > 0n && - TimeMath.compareAbs( - TimeMath.addAbsRel(startedAt, roundTimeoutDuration), - blockTimestamp, - ) < 0; + return harden({ + ...status, + lastStartedRound: roundId, + }); + }, - return roundTimedOut; - }, + /** + * @param {bigint} submission + * @param {bigint} roundId + * @param {OracleStatus} status + * @returns {OracleStatus} the new status + */ + recordSubmission(submission, roundId, status) { + const { helper } = this.facets; + const { details } = this.state; + helper.acceptingSubmissions(roundId) || + Fail`round ${q( + Number(roundId), + )} not accepting submissions from oracle ${q(status.oracleId)}`; + + const lastRoundDetails = details.get(roundId); + details.set(roundId, { + ...lastRoundDetails, + submissions: [...lastRoundDetails.submissions, submission], + }); + + return { + ...status, + lastReportedRound: roundId, + latestSubmission: submission, + }; + }, - /** - * @param {bigint} roundId - * @param {Timestamp} blockTimestamp - */ - updateRoundAnswer(roundId, blockTimestamp) { - const { answerPublisher, details, rounds } = this.state; - const roundDetails = details.get(roundId); - if (roundDetails.submissions.length < roundDetails.minSubmissions) { - return [false, 0]; - } - - /** @type {bigint | undefined} */ - // @ts-expect-error faulty inference - const newAnswer = calculateMedian( - details - .get(roundId) - .submissions.filter(sample => isNat(sample) && sample > 0n), - { add, divide: floorDivide, isGTE }, - ); - - assert(newAnswer, 'insufficient samples'); - - rounds.set(roundId, { - ...rounds.get(roundId), - answer: newAnswer, - updatedAt: blockTimestamp, - answeredInRound: roundId, - }); - - this.state.lastValueOutForUnitIn = newAnswer; - answerPublisher.publish(undefined); - - return [true, newAnswer]; - }, - /** - * @param {bigint} roundId - * @param {Timestamp} blockTimestamp - */ - updateTimedOutRoundInfo(roundId, blockTimestamp) { - const { details, rounds } = this.state; - const { helper } = this.facets; - - // 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 = helper.timedOut(roundId, blockTimestamp); - if (!roundTimedOut) return; - - const prevRound = rounds.get(subtract(roundId, 1)); - - rounds.set(roundId, { - ...rounds.get(roundId), - answer: prevRound.answer, - answeredInRound: prevRound.answeredInRound, - updatedAt: blockTimestamp, - }); - - details.delete(roundId); - }, + /** + * @param {bigint} roundId + * @param {Timestamp} blockTimestamp + */ + supersedable(roundId, blockTimestamp) { + const { rounds } = this.state; + const { helper } = this.facets; + return ( + rounds.has(roundId) && + (TimeMath.absValue(rounds.get(roundId).updatedAt) > 0n || + helper.timedOut(roundId, blockTimestamp)) + ); + }, + /** + * @param {bigint} roundId + * @param {Timestamp} blockTimestamp + */ + timedOut(roundId, blockTimestamp) { + const { details, rounds } = this.state; + if (!details.has(roundId) || !rounds.has(roundId)) { + return false; + } - /** - * @param {OracleStatus} status - * @param {bigint} roundId - * @param {Timestamp} blockTimestamp - * @returns {string?} error message, if there is one - */ - validateOracleRound(status, roundId, blockTimestamp) { - const { reportingRoundId } = this.state; - const { helper } = this.facets; - - let canSupersede = true; - if (roundId > 1n) { - canSupersede = helper.supersedable( - subtract(roundId, 1), - blockTimestamp, + const startedAt = rounds.get(roundId).startedAt; + const roundTimeout = details.get(roundId).roundTimeout; + // TODO Better would be to make `roundTimeout` a `RelativeTime` + // everywhere, and to rename it to a name that does not + // mistakenly imply that it is an absolute time. + const roundTimeoutDuration = TimeMath.toRel(roundTimeout); + const roundTimedOut = + TimeMath.absValue(startedAt) > 0n && + TimeMath.relValue(roundTimeoutDuration) > 0n && + TimeMath.compareAbs( + TimeMath.addAbsRel(startedAt, roundTimeoutDuration), + blockTimestamp, + ) < 0; + + return roundTimedOut; + }, + + /** + * @param {bigint} roundId + * @param {Timestamp} blockTimestamp + */ + updateRoundAnswer(roundId, blockTimestamp) { + const { answerPublisher, details, rounds } = this.state; + const roundDetails = details.get(roundId); + if (roundDetails.submissions.length < roundDetails.minSubmissions) { + return [false, 0]; + } + + /** @type {bigint | undefined} */ + // @ts-expect-error faulty inference + const newAnswer = calculateMedian( + roundDetails.submissions.filter( + sample => isNat(sample) && sample > 0n, + ), + { add, divide: floorDivide, isGTE }, ); - } - - if (status.lastReportedRound >= roundId) - return 'cannot report on previous rounds'; - if ( - roundId !== reportingRoundId && - roundId !== add(reportingRoundId, 1) && - !helper.previousAndCurrentUnanswered(roundId, reportingRoundId) - ) - return 'invalid round to report'; - if (roundId !== 1n && !canSupersede) - return 'previous round not supersedable'; - return null; - }, - }, - contract: { - /** - * - * @param {PriceQuoteValue} quote - */ - async authenticateQuote(quote) { - const { quoteKit } = this.state; - const quoteAmount = AmountMath.make(quoteKit.brand, harden(quote)); - const quotePayment = await E(quoteKit.mint).mintPayment(quoteAmount); - return harden({ quoteAmount, quotePayment }); - }, - /** - * @param {object} param0 - * @param {number} [param0.overrideValueOut] - * @param {Timestamp} [param0.timestamp] - */ - makeCreateQuote({ overrideValueOut, timestamp } = {}) { - const { state } = this; - const { brandIn, brandOut, timerPresence } = state; - const { contract } = this.facets; + assert(newAnswer, 'insufficient samples'); + rounds.set(roundId, { + ...rounds.get(roundId), + answer: newAnswer, + updatedAt: blockTimestamp, + answeredInRound: roundId, + }); + + this.state.lastValueOutForUnitIn = newAnswer; + answerPublisher.publish(undefined); + + return [true, newAnswer]; + }, /** - * @param {PriceQuery} priceQuery + * @param {bigint} roundId + * @param {Timestamp} blockTimestamp */ - return Far('createQuote', priceQuery => { - const { lastValueOutForUnitIn, unitIn } = state; - - // Sniff the current baseValueOut. - const valueOutForUnitIn = - overrideValueOut === undefined - ? lastValueOutForUnitIn // Use the latest value. - : overrideValueOut; // Override the value. - if (valueOutForUnitIn === null) { - // We don't have a quote, so abort. - return undefined; + updateTimedOutRoundInfo(roundId, blockTimestamp) { + const { details, rounds } = this.state; + const { helper } = this.facets; + + // 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; } - /** - * @param {Amount<'nat'>} amountIn the given amountIn - */ - const calcAmountOut = amountIn => { - const valueIn = AmountMath.getValue(brandIn, amountIn); - return AmountMath.make( - brandOut, - floorDivide(multiply(valueIn, valueOutForUnitIn), unitIn), + const roundTimedOut = helper.timedOut(roundId, blockTimestamp); + if (!roundTimedOut) return; + + const prevRound = rounds.get(subtract(roundId, 1)); + + rounds.set(roundId, { + ...rounds.get(roundId), + answer: prevRound.answer, + answeredInRound: prevRound.answeredInRound, + updatedAt: blockTimestamp, + }); + + details.delete(roundId); + }, + + /** + * @param {OracleStatus} status + * @param {bigint} roundId + * @param {Timestamp} blockTimestamp + * @returns {string?} error message, if there is one + */ + validateOracleRound(status, roundId, blockTimestamp) { + const { reportingRoundId } = this.state; + const { helper } = this.facets; + + let canSupersede = true; + if (roundId > 1n) { + canSupersede = helper.supersedable( + subtract(roundId, 1), + blockTimestamp, ); - }; + } + + if (status.lastReportedRound >= roundId) + return 'cannot report on previous rounds'; + if ( + roundId !== reportingRoundId && + roundId !== add(reportingRoundId, 1) && + !helper.previousAndCurrentUnanswered(roundId, reportingRoundId) + ) + return 'invalid round to report'; + if (roundId !== 1n && !canSupersede) + return 'previous round not supersedable'; + return null; + }, + }, + contract: { + /** + * + * @param {PriceQuoteValue} quote + */ + async authenticateQuote(quote) { + const { quoteKit } = this.state; + const quoteAmount = AmountMath.make(quoteKit.brand, harden(quote)); + const quotePayment = await E(quoteKit.mint).mintPayment(quoteAmount); + return harden({ quoteAmount, quotePayment }); + }, + + /** + * @param {object} param0 + * @param {number} [param0.overrideValueOut] + * @param {Timestamp} [param0.timestamp] + */ + makeCreateQuote({ overrideValueOut, timestamp } = {}) { + const { state } = this; + const { brandIn, brandOut, timerPresence } = state; + const { contract } = this.facets; /** - * @param {Amount<'nat'>} amountOut the wanted amountOut + * @param {PriceQuery} priceQuery */ - const calcAmountIn = amountOut => { - const valueOut = AmountMath.getValue(brandOut, amountOut); - return AmountMath.make( - brandIn, - ceilDivide(multiply(valueOut, unitIn), valueOutForUnitIn), + return Far('createQuote', priceQuery => { + const { lastValueOutForUnitIn, unitIn } = state; + + // Sniff the current baseValueOut. + const valueOutForUnitIn = + overrideValueOut === undefined + ? lastValueOutForUnitIn // Use the latest value. + : overrideValueOut; // Override the value. + if (valueOutForUnitIn === null) { + // We don't have a quote, so abort. + return undefined; + } + + /** + * @param {Amount<'nat'>} amountIn the given amountIn + */ + const calcAmountOut = amountIn => { + const valueIn = AmountMath.getValue(brandIn, amountIn); + return AmountMath.make( + brandOut, + floorDivide(multiply(valueIn, valueOutForUnitIn), unitIn), + ); + }; + + /** + * @param {Amount<'nat'>} amountOut the wanted amountOut + */ + const calcAmountIn = amountOut => { + const valueOut = AmountMath.getValue(brandOut, amountOut); + return AmountMath.make( + brandIn, + ceilDivide(multiply(valueOut, unitIn), valueOutForUnitIn), + ); + }; + + // Calculate the quote. + const quote = priceQuery(calcAmountOut, calcAmountIn); + if (!quote) { + return undefined; + } + + const { + amountIn, + amountOut, + timestamp: theirTimestamp = timestamp, + } = quote; + AmountMath.coerce(brandIn, amountIn); + AmountMath.coerce(brandOut, amountOut); + if (theirTimestamp !== undefined) { + return contract.authenticateQuote([ + { + amountIn, + amountOut, + timer: timerPresence, + timestamp: theirTimestamp, + }, + ]); + } + return E(timerPresence) + .getCurrentTimestamp() + .then(now => + contract.authenticateQuote([ + { amountIn, amountOut, timer: timerPresence, timestamp: now }, + ]), + ); + }); + }, + + /** + * @param {OracleStatus} status + * @param {bigint} queriedRoundId + * @param {Timestamp} blockTimestamp + */ + eligibleForSpecificRound(status, queriedRoundId, blockTimestamp) { + const { rounds } = this.state; + const { helper } = this.facets; + const error = helper.validateOracleRound( + status, + queriedRoundId, + blockTimestamp, + ); + if (TimeMath.absValue(rounds.get(queriedRoundId).startedAt) > 0n) { + return ( + helper.acceptingSubmissions(queriedRoundId) && error === null ); + } else { + return helper.delayed(status, queriedRoundId) && error === null; + } + }, + + /** + * consumers are encouraged to check + * that they're receiving fresh data by inspecting the updatedAt and + * answeredInRound return values. + * + * @param {bigint | number} roundIdRaw + * @returns {Promise} + */ + async getRoundData(roundIdRaw) { + const roundId = Nat(roundIdRaw); + const { rounds } = this.state; + + rounds.has(roundId) || assert.fail(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, }; + }, - // Calculate the quote. - const quote = priceQuery(calcAmountOut, calcAmountIn); - if (!quote) { - return undefined; + /** @type {(roundId: bigint) => Readonly} */ + getRoundStatus(roundId) { + const { details, rounds } = this.state; + rounds.has(roundId) || Fail`V3_NO_DATA_ERROR`; + const detail = details.get(roundId); + const round = rounds.get(roundId); + return harden({ ...detail, ...round }); + }, + + /** + * 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 {OracleStatus} status + * @param {Timestamp} blockTimestamp + */ + oracleRoundStateSuggestRound(status, blockTimestamp) { + const { helper } = this.facets; + const { details, reportingRoundId, rounds } = this.state; + const shouldSupersede = + status.lastReportedRound === reportingRoundId || + !helper.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 = helper.supersedable( + reportingRoundId, + blockTimestamp, + ); + + let roundId; + let eligibleToSubmit; + if (canSupersede && shouldSupersede) { + roundId = add(reportingRoundId, 1); + eligibleToSubmit = helper.delayed(status, roundId); + } else { + roundId = reportingRoundId; + eligibleToSubmit = helper.acceptingSubmissions(roundId); } - const { - amountIn, - amountOut, - timestamp: theirTimestamp = timestamp, - } = quote; - AmountMath.coerce(brandIn, amountIn); - AmountMath.coerce(brandOut, amountOut); - if (theirTimestamp !== undefined) { - return contract.authenticateQuote([ - { - amountIn, - amountOut, - timer: timerPresence, - timestamp: theirTimestamp, - }, - ]); + 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; } - return E(timerPresence) - .getCurrentTimestamp() - .then(now => - contract.authenticateQuote([ - { amountIn, amountOut, timer: timerPresence, timestamp: now }, - ]), - ); - }); - }, - /** - * @param {OracleStatus} status - * @param {bigint} queriedRoundId - * @param {Timestamp} blockTimestamp - */ - eligibleForSpecificRound(status, queriedRoundId, blockTimestamp) { - const { rounds } = this.state; - const { helper } = this.facets; - const error = helper.validateOracleRound( - status, - queriedRoundId, - blockTimestamp, - ); - if (TimeMath.absValue(rounds.get(queriedRoundId).startedAt) > 0n) { - return helper.acceptingSubmissions(queriedRoundId) && error === null; - } else { - return helper.delayed(status, queriedRoundId) && error === null; - } - }, + const error = helper.validateOracleRound( + status, + roundId, + blockTimestamp, + ); + if (error !== null) { + eligibleToSubmit = false; + } - /** - * consumers are encouraged to check - * that they're receiving fresh data by inspecting the updatedAt and - * answeredInRound return values. - * - * @param {bigint | number} roundIdRaw - * @returns {Promise} - */ - async getRoundData(roundIdRaw) { - const roundId = Nat(roundIdRaw); - const { rounds } = this.state; - - rounds.has(roundId) || assert.fail(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, - }; + return { + eligibleForSpecificRound: eligibleToSubmit, + queriedRoundId: roundId, + latestSubmission: status.latestSubmission, + startedAt, + roundTimeout, + }; + }, }, + oracle: { + /** + * push a unitPrice result from this oracle + * + * @param {OracleStatus} status + * @param {PriceRound} result + */ + async handlePush( + status, + { roundId: roundIdRaw = undefined, unitPrice: valueRaw }, + ) { + const value = Nat(valueRaw); + const { minSubmissionValue, maxSubmissionValue, timerPresence } = + this.state; + + const { contract, helper } = this.facets; + value >= minSubmissionValue || + Fail`value below minSubmissionValue ${q(minSubmissionValue)}`; + value <= maxSubmissionValue || + Fail`value above maxSubmissionValue ${q(maxSubmissionValue)}`; + + const blockTimestamp = await E(timerPresence).getCurrentTimestamp(); + + let roundId; + if (roundIdRaw === undefined) { + const suggestedRound = contract.oracleRoundStateSuggestRound( + status, + blockTimestamp, + ); + roundId = suggestedRound.eligibleForSpecificRound + ? suggestedRound.queriedRoundId + : add(suggestedRound.queriedRoundId, 1); + } else { + roundId = Nat(roundIdRaw); + } - /** @type {(roundId: bigint) => Readonly} */ - getRoundStatus(roundId) { - const { details, rounds } = this.state; - rounds.has(roundId) || Fail`V3_NO_DATA_ERROR`; - const detail = details.get(roundId); - const round = rounds.get(roundId); - return harden({ ...detail, ...round }); - }, + const errorMsg = helper.validateOracleRound( + status, + roundId, + blockTimestamp, + ); - /** - * 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 {OracleStatus} status - * @param {Timestamp} blockTimestamp - */ - oracleRoundStateSuggestRound(status, blockTimestamp) { - const { helper } = this.facets; - const { details, reportingRoundId, rounds } = this.state; - const shouldSupersede = - status.lastReportedRound === reportingRoundId || - !helper.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 = helper.supersedable( - reportingRoundId, - blockTimestamp, - ); - - let roundId; - let eligibleToSubmit; - if (canSupersede && shouldSupersede) { - roundId = add(reportingRoundId, 1); - eligibleToSubmit = helper.delayed(status, roundId); - } else { - roundId = reportingRoundId; - eligibleToSubmit = helper.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 = helper.validateOracleRound( - status, - roundId, - blockTimestamp, - ); - if (error !== null) { - eligibleToSubmit = false; - } - - return { - eligibleForSpecificRound: eligibleToSubmit, - queriedRoundId: roundId, - latestSubmission: status.latestSubmission, - startedAt, - roundTimeout, - }; - }, - }, - oracle: { - /** - * push a unitPrice result from this oracle - * - * @param {OracleStatus} status - * @param {PriceRound} result - */ - async handlePush( - status, - { roundId: roundIdRaw = undefined, unitPrice: valueRaw }, - ) { - const value = Nat(valueRaw); - const { minSubmissionValue, maxSubmissionValue, timerPresence } = - this.state; - - const { contract, helper } = this.facets; - value >= minSubmissionValue || - Fail`value below minSubmissionValue ${q(minSubmissionValue)}`; - value <= maxSubmissionValue || - Fail`value above maxSubmissionValue ${q(maxSubmissionValue)}`; - - const blockTimestamp = await E(timerPresence).getCurrentTimestamp(); - - let roundId; - if (roundIdRaw === undefined) { - const suggestedRound = contract.oracleRoundStateSuggestRound( + if (!(errorMsg === null)) { + assert.fail(errorMsg); + } + + const proposedStatus = helper.proposeNewRound( + roundId, status, blockTimestamp, ); - roundId = suggestedRound.eligibleForSpecificRound - ? suggestedRound.queriedRoundId - : add(suggestedRound.queriedRoundId, 1); - } else { - roundId = Nat(roundIdRaw); - } - - const errorMsg = helper.validateOracleRound( - status, - roundId, - blockTimestamp, - ); + const settledStatus = helper.recordSubmission( + value, + roundId, + proposedStatus || status, + ); - if (!(errorMsg === null)) { - assert.fail(errorMsg); - } + helper.updateRoundAnswer(roundId, blockTimestamp); + helper.deleteRoundDetails(roundId); - const proposedStatus = helper.proposeNewRound( - roundId, - status, - blockTimestamp, - ); - const settledStatus = helper.recordSubmission( - value, - roundId, - proposedStatus || status, - ); - - helper.updateRoundAnswer(roundId, blockTimestamp); - helper.deleteRoundDetails(roundId); - - return settledStatus; + return settledStatus; + }, }, }, - }, -); + ); diff --git a/packages/inter-protocol/src/proposals/price-feed-proposal.js b/packages/inter-protocol/src/proposals/price-feed-proposal.js index c393eb694789..3846d5600981 100644 --- a/packages/inter-protocol/src/proposals/price-feed-proposal.js +++ b/packages/inter-protocol/src/proposals/price-feed-proposal.js @@ -127,7 +127,7 @@ export const createPriceFeed = async ( /** * Values come from economy-template.json, which at this writing had IN:ATOM, OUT:USD * - * @type {[[Brand<'nat'>, Brand<'nat'>], [Installation, Installation]]} + * @type {[[Brand<'nat'>, Brand<'nat'>], [Installation, Installation]]} */ const [[brandIn, brandOut], [contractGovernor, priceAggregator]] = await Promise.all([ diff --git a/packages/inter-protocol/test/price/test-fluxAggregatorKit.js b/packages/inter-protocol/test/price/test-fluxAggregatorKit.js index 5e55cffbeb43..6b28a8fa635f 100644 --- a/packages/inter-protocol/test/price/test-fluxAggregatorKit.js +++ b/packages/inter-protocol/test/price/test-fluxAggregatorKit.js @@ -16,7 +16,7 @@ import { setupZCFTest } from '@agoric/zoe/test/unitTests/zcf/setupZcfTest.js'; import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; import { documentStorageSchema } from '@agoric/governance/tools/storageDoc.js'; -import { makeFluxAggregator } from '../../src/price/fluxAggregatorKit.js'; +import { prepareFluxAggregatorKit } from '../../src/price/fluxAggregatorKit.js'; import { topicPath } from '../supports.js'; /** @type {import('ava').TestFn>>} */ @@ -35,7 +35,7 @@ const makeContext = async () => { const link = makeIssuerKit('$LINK', AssetKind.NAT); const usd = makeIssuerKit('$USD', AssetKind.NAT); - async function makeChainlinkAggregator(config) { + async function makeTestFluxAggregator(config) { const terms = { ...config, brandIn: link.brand, brandOut: usd.brand }; const zcfTestKit = await setupZCFTest(undefined, terms); @@ -54,19 +54,22 @@ const makeContext = async () => { marshaller, ); - const aggregator = await makeFluxAggregator( + const makeFluxAggregator = await prepareFluxAggregatorKit( + baggage, zcfTestKit.zcf, manualTimer, - { ...quoteIssuerKit, assetKind: 'set', displayInfo: undefined }, + { ...quoteIssuerKit, displayInfo: { assetKind: 'set' } }, await E(storageNode).makeChildNode('LINK-USD_price_feed'), makeDurablePublishKit, makeRecorder, ); + const aggregator = makeFluxAggregator(); + return { ...aggregator, manualTimer, mockStorageRoot }; } - return { makeChainlinkAggregator }; + return { makeTestFluxAggregator }; }; test.before('setup aggregator and oracles', async t => { @@ -74,16 +77,16 @@ test.before('setup aggregator and oracles', async t => { }); test('basic, with snapshot', async t => { - const aggregator = await t.context.makeChainlinkAggregator(defaultConfig); + const aggregator = await t.context.makeTestFluxAggregator(defaultConfig); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); - const { oracle: oracleC } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleC } = await E(aggregator.creator).initOracle( 'agorice1priceOracleC', ); @@ -94,7 +97,7 @@ test('basic, with snapshot', async t => { await E(oracleC).pushPrice({ roundId: 1, unitPrice: 300n }); await oracleTimer.tick(); - const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt1 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt1.roundId, 1n); t.is(round1Attempt1.answer, 200n); @@ -111,9 +114,9 @@ test('basic, with snapshot', async t => { await E(oracleC).pushPrice({ roundId: 2, unitPrice: 3000n }); await oracleTimer.tick(); - const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt2 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt2.answer, 200n); - const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + const round2Attempt1 = await E(aggregator.creator).getRoundData(2); t.is(round2Attempt1.answer, 2500n); t.log('----- round 3: check oracle submission order'); @@ -125,9 +128,9 @@ test('basic, with snapshot', async t => { await E(oracleB).pushPrice({ roundId: 3, unitPrice: 6000n }); await oracleTimer.tick(); - const round1Attempt3 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt3 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt3.answer, 200n); - const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + const round3Attempt1 = await E(aggregator.creator).getRoundData(3); t.is(round3Attempt1.answer, 5000n); const doc = { @@ -138,20 +141,20 @@ test('basic, with snapshot', async t => { }); test('timeout', async t => { - const aggregator = await t.context.makeChainlinkAggregator({ + const aggregator = await t.context.makeTestFluxAggregator({ ...defaultConfig, restartDelay: 2, timeout: 5, }); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); - const { oracle: oracleC } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleC } = await E(aggregator.creator).initOracle( 'agorice1priceOracleC', ); @@ -163,7 +166,7 @@ test('timeout', async t => { await oracleTimer.tick(); await E(oracleC).pushPrice({ roundId: 1, unitPrice: 300n }); - const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt1 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt1.roundId, 1n); t.is(round1Attempt1.answer, 200n); @@ -181,28 +184,28 @@ test('timeout', async t => { await E(oracleC).pushPrice({ roundId: 3, unitPrice: 1000n }); await E(oracleA).pushPrice({ roundId: 3, unitPrice: 3000n }); - const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt2 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt2.answer, 200n); - const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + const round2Attempt1 = await E(aggregator.creator).getRoundData(2); t.is(round2Attempt1.answer, 200n); - const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + const round3Attempt1 = await E(aggregator.creator).getRoundData(3); t.is(round3Attempt1.answer, 2000n); }); test('issue check', async t => { - const aggregator = await t.context.makeChainlinkAggregator({ + const aggregator = await t.context.makeTestFluxAggregator({ ...defaultConfig, restartDelay: 2, }); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); - const { oracle: oracleC } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleC } = await E(aggregator.creator).initOracle( 'agorice1priceOracleC', ); @@ -216,7 +219,7 @@ test('issue check', async t => { await oracleTimer.tick(); await E(oracleC).pushPrice({ roundId: 1, unitPrice: 300n }); - const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt1 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt1.answer, 250n); // ----- round 2: ignore too high values @@ -228,24 +231,24 @@ test('issue check', async t => { await E(oracleA).pushPrice({ roundId: 2, unitPrice: 3000n }); await oracleTimer.tick(); - const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + const round2Attempt1 = await E(aggregator.creator).getRoundData(2); t.is(round2Attempt1.answer, 2000n); }); test('supersede', async t => { - const aggregator = await t.context.makeChainlinkAggregator({ + const aggregator = await t.context.makeTestFluxAggregator({ ...defaultConfig, restartDelay: 1, }); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); - const { oracle: oracleC } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleC } = await E(aggregator.creator).initOracle( 'agorice1priceOracleC', ); @@ -258,7 +261,7 @@ test('supersede', async t => { await E(oracleB).pushPrice({ roundId: 1, unitPrice: 200n }); await oracleTimer.tick(); - const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt1 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt1.answer, 150n); // ----- round 2: oracle C's value from before should have been IGNORED @@ -267,7 +270,7 @@ test('supersede', async t => { await E(oracleA).pushPrice({ roundId: 2, unitPrice: 1000n }); await oracleTimer.tick(); - const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + const round2Attempt1 = await E(aggregator.creator).getRoundData(2); t.is(round2Attempt1.answer, 1500n); // ----- round 3: oracle C should NOT be able to supersede round 3 @@ -277,14 +280,14 @@ test('supersede', async t => { }); try { - await E(aggregator.creatorFacet).getRoundData(4); + await E(aggregator.creator).getRoundData(4); } catch (error) { t.is(error.message, 'No data present'); } }); test('interleaved', async t => { - const aggregator = await t.context.makeChainlinkAggregator({ + const aggregator = await t.context.makeTestFluxAggregator({ ...defaultConfig, maxSubmissionCount: 3, minSubmissionCount: 3, // requires ALL the oracles for consensus in this case @@ -293,13 +296,13 @@ test('interleaved', async t => { }); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); - const { oracle: oracleC } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleC } = await E(aggregator.creator).initOracle( 'agorice1priceOracleC', ); @@ -313,7 +316,7 @@ test('interleaved', async t => { await oracleTimer.tick(); try { - await E(aggregator.creatorFacet).getRoundData(1); + await E(aggregator.creator).getRoundData(1); } catch (error) { t.is(error.message, 'No data present'); } @@ -335,14 +338,14 @@ test('interleaved', async t => { await E(oracleA).pushPrice({ roundId: 3, unitPrice: 5000n }); await oracleTimer.tick(); - const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); - const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + const round1Attempt2 = await E(aggregator.creator).getRoundData(1); + const round2Attempt1 = await E(aggregator.creator).getRoundData(2); t.is(round1Attempt2.answer, 200n); t.is(round2Attempt1.answer, 2000n); try { - await E(aggregator.creatorFacet).getRoundData(3); + await E(aggregator.creator).getRoundData(3); } catch (error) { t.is(error.message, 'No data present'); } @@ -364,13 +367,13 @@ test('interleaved', async t => { await oracleTimer.tick(); // --- round 3 has NOW timed out, meaning it is now supersedable try { - await E(aggregator.creatorFacet).getRoundData(3); + await E(aggregator.creator).getRoundData(3); } catch (error) { t.is(error.message, 'No data present'); } try { - await E(aggregator.creatorFacet).getRoundData(4); + await E(aggregator.creator).getRoundData(4); } catch (error) { t.is(error.message, 'No data present'); } @@ -385,8 +388,8 @@ test('interleaved', async t => { }); await oracleTimer.tick(); - const round3Attempt3 = await E(aggregator.creatorFacet).getRoundData(3); - const round4Attempt2 = await E(aggregator.creatorFacet).getRoundData(4); + const round3Attempt3 = await E(aggregator.creator).getRoundData(3); + const round4Attempt2 = await E(aggregator.creator).getRoundData(4); t.is(round3Attempt3.answer, 2000n); t.is(round4Attempt2.answer, 5000n); @@ -409,15 +412,15 @@ test('interleaved', async t => { await oracleTimer.tick(); await E(oracleC).pushPrice({ roundId: 7, unitPrice: 1000n }); - const round5Attempt1 = await E(aggregator.creatorFacet).getRoundData(5); - const round6Attempt1 = await E(aggregator.creatorFacet).getRoundData(6); + const round5Attempt1 = await E(aggregator.creator).getRoundData(5); + const round6Attempt1 = await E(aggregator.creator).getRoundData(6); t.is(round5Attempt1.answer, 5000n); t.is(round6Attempt1.answer, 5000n); }); test('larger', async t => { - const aggregator = await t.context.makeChainlinkAggregator({ + const aggregator = await t.context.makeTestFluxAggregator({ ...defaultConfig, minSubmissionCount: 3, restartDelay: 1, @@ -425,19 +428,19 @@ test('larger', async t => { }); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); - const { oracle: oracleC } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleC } = await E(aggregator.creator).initOracle( 'agorice1priceOracleC', ); - const { oracle: oracleD } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleD } = await E(aggregator.creator).initOracle( 'agorice1priceOracleD', ); - const { oracle: oracleE } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleE } = await E(aggregator.creator).initOracle( 'agorice1priceOracleE', ); @@ -459,7 +462,7 @@ test('larger', async t => { await oracleTimer.tick(); await E(oracleE).pushPrice({ roundId: 1, unitPrice: 300n }); - const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt1 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt1.answer, 200n); // ----- round 2: ignore late arrival @@ -484,14 +487,14 @@ test('larger', async t => { { message: 'cannot report on previous rounds' }, ); - const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); - const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + const round1Attempt2 = await E(aggregator.creator).getRoundData(1); + const round2Attempt1 = await E(aggregator.creator).getRoundData(2); t.is(round1Attempt2.answer, 250n); t.is(round2Attempt1.answer, 600n); }); test('suggest', async t => { - const aggregator = await t.context.makeChainlinkAggregator({ + const aggregator = await t.context.makeTestFluxAggregator({ ...defaultConfig, minSubmissionCount: 3, restartDelay: 1, @@ -499,13 +502,13 @@ test('suggest', async t => { }); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); - const { oracle: oracleC } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleC } = await E(aggregator.creator).initOracle( 'agorice1priceOracleC', ); @@ -516,7 +519,7 @@ test('suggest', async t => { await E(oracleC).pushPrice({ roundId: 1, unitPrice: 300n }); await oracleTimer.tick(); - const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + const round1Attempt1 = await E(aggregator.creator).getRoundData(1); t.is(round1Attempt1.roundId, 1n); t.is(round1Attempt1.answer, 200n); @@ -525,10 +528,7 @@ test('suggest', async t => { await E(oracleB).pushPrice({ roundId: 2, unitPrice: 1000n }); t.deepEqual( - await E(aggregator.creatorFacet).oracleRoundState( - 'agorice1priceOracleC', - 1n, - ), + await E(aggregator.creator).oracleRoundState('agorice1priceOracleC', 1n), { eligibleForSpecificRound: false, oracleCount: 3, @@ -540,10 +540,7 @@ test('suggest', async t => { ); t.deepEqual( - await E(aggregator.creatorFacet).oracleRoundState( - 'agorice1priceOracleB', - 0n, - ), + await E(aggregator.creator).oracleRoundState('agorice1priceOracleB', 0n), { eligibleForSpecificRound: false, oracleCount: 3, @@ -561,10 +558,7 @@ test('suggest', async t => { await E(oracleC).pushPrice({ roundId: 2, unitPrice: 3000n }); t.deepEqual( - await E(aggregator.creatorFacet).oracleRoundState( - 'agorice1priceOracleA', - 0n, - ), + await E(aggregator.creator).oracleRoundState('agorice1priceOracleA', 0n), { eligibleForSpecificRound: true, oracleCount: 3, @@ -583,28 +577,28 @@ test('suggest', async t => { await oracleTimer.tick(); await E(oracleB).pushPrice({ roundId: undefined, unitPrice: 300n }); - const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + const round3Attempt1 = await E(aggregator.creator).getRoundData(3); t.is(round3Attempt1.roundId, 3n); t.is(round3Attempt1.answer, 200n); }); test('notifications', async t => { - const aggregator = await t.context.makeChainlinkAggregator({ + const aggregator = await t.context.makeTestFluxAggregator({ ...defaultConfig, maxSubmissionCount: 1000, restartDelay: 1, // have to alternate to start rounds }); const oracleTimer = aggregator.manualTimer; - const { oracle: oracleA } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleA } = await E(aggregator.creator).initOracle( 'agorice1priceOracleA', ); - const { oracle: oracleB } = await E(aggregator.creatorFacet).initOracle( + const { oracle: oracleB } = await E(aggregator.creator).initOracle( 'agorice1priceOracleB', ); const latestRoundSubscriber = await E( - aggregator.publicFacet, + aggregator.public, ).getRoundStartNotifier(); const eachLatestRound = subscribeEach(latestRoundSubscriber)[ Symbol.asyncIterator @@ -651,7 +645,6 @@ test('notifications', async t => { ); // B gets to start it await E(oracleB).pushPrice({ roundId: 2, unitPrice: 1000n }); - // now it's roundId=2 t.deepEqual((await eachLatestRound.next()).value, { roundId: 2n, startedAt: 1n, @@ -694,7 +687,7 @@ test('notifications', async t => { }); test('storage keys', async t => { - const { publicFacet } = await t.context.makeChainlinkAggregator( + const { public: publicFacet } = await t.context.makeTestFluxAggregator( defaultConfig, ); @@ -705,13 +698,13 @@ test('storage keys', async t => { }); test('disabling', async t => { - const { creatorFacet, manualTimer } = await t.context.makeChainlinkAggregator( + const { creator, manualTimer } = await t.context.makeTestFluxAggregator( defaultConfig, ); - const kitA = await creatorFacet.initOracle('agoric1priceOracleA'); - const kitB = await creatorFacet.initOracle('agoric1priceOracleB'); - const kitC = await creatorFacet.initOracle('agoric1priceOracleC'); + const kitA = await creator.initOracle('agoric1priceOracleA'); + const kitB = await creator.initOracle('agoric1priceOracleB'); + const kitC = await creator.initOracle('agoric1priceOracleC'); // ----- pushes drive a price await manualTimer.tick(); @@ -720,7 +713,7 @@ test('disabling', async t => { await E(kitC.oracle).pushPrice({ roundId: 1, unitPrice: 300n }); await manualTimer.tick(); - t.like(await E(creatorFacet).getRoundData(1), { + t.like(await E(creator).getRoundData(1), { roundId: 1n, // median of three answer: 200n, @@ -740,7 +733,7 @@ test('disabling', async t => { await E(kitB.oracle).pushPrice({ roundId: 2, unitPrice: 200n }); await E(kitC.oracle).pushPrice({ roundId: 2, unitPrice: 300n }); await manualTimer.tick(); - t.like(await E(creatorFacet).getRoundData(2), { + t.like(await E(creator).getRoundData(2), { roundId: 2n, // median of two is their average answer: 250n, diff --git a/packages/inter-protocol/test/smartWallet/contexts.js b/packages/inter-protocol/test/smartWallet/contexts.js index 2ceef66844b0..f208b9ea617c 100644 --- a/packages/inter-protocol/test/smartWallet/contexts.js +++ b/packages/inter-protocol/test/smartWallet/contexts.js @@ -119,7 +119,7 @@ export const makeDefaultTestContext = async (t, makeSpace) => { '../inter-protocol/src/price/fluxAggregatorContract.js', 'priceAggregator', ); - /** @type {Promise>} */ + /** @type {Promise>} */ const paInstallation = E(zoe).install(paBundle); await E(installAdmin).update('priceAggregator', paInstallation); diff --git a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js index 40a9437debb2..96f77ba90a04 100644 --- a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js +++ b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js @@ -101,7 +101,7 @@ const setupFeedWithWallets = async (t, oracleAddresses) => { await t.context.simpleCreatePriceFeed(oracleAddresses, 'ATOM', 'USD'); - /** @type {import('@agoric/zoe/src/zoeService/utils.js').Instance} */ + /** @type {import('@agoric/zoe/src/zoeService/utils.js').Instance} */ const governedPriceAggregator = await E(agoricNames).lookup( 'instance', 'ATOM-USD price feed', diff --git a/packages/inter-protocol/test/swingsetTests/fluxAggregator/bootstrap-fluxAggregator-service-upgrade.js b/packages/inter-protocol/test/swingsetTests/fluxAggregator/bootstrap-fluxAggregator-service-upgrade.js new file mode 100644 index 000000000000..896c799e68ba --- /dev/null +++ b/packages/inter-protocol/test/swingsetTests/fluxAggregator/bootstrap-fluxAggregator-service-upgrade.js @@ -0,0 +1,277 @@ +// @ts-check + +import { Fail, NonNullish } from '@agoric/assert'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { CONTRACT_ELECTORATE, ParamTypes } from '@agoric/governance'; +import { deeplyFulfilledObject, makeTracer } from '@agoric/internal'; +import { makeFakeStorageKit } from '@agoric/internal/src/storage-test-utils.js'; +import { makeNotifierFromSubscriber } from '@agoric/notifier'; +import { makeNameHubKit } from '@agoric/vats'; +import { makeBoard } from '@agoric/vats/src/lib-board.js'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; +import { makePromiseKit } from '@endo/promise-kit'; + +const trace = makeTracer('BootWFUpg'); + +export const faV1BundleName = 'fluxAggregatorV1'; + +const inKit = makeIssuerKit('bucks'); +const outKit = makeIssuerKit('moola'); + +export const buildRootObject = async () => { + const storageKit = makeFakeStorageKit('fluxAggregatorUpgradeTest'); + const { nameAdmin: namesByAddressAdmin } = makeNameHubKit(); + const timer = buildManualTimer(); + const marshaller = makeBoard().getReadonlyMarshaller(); + + /** @type {PromiseKit} */ + const { promise: zoe, ...zoePK } = makePromiseKit(); + const { promise: committeeCreator, ...ccPK } = makePromiseKit(); + + /** @type {VatAdminSvc} */ + let vatAdmin; + + // for startInstance + /** + * @type {{ + * committee?: Installation, + * fluxAggregatorV1?: Installation, + * puppetContractGovernor?: Installation, + * }} + */ + const installations = {}; + + /** @type {import('@agoric/governance/tools/puppetContractGovernor').PuppetContractGovernorKit} */ + let governorFacets; + /** @type {ReturnType>['creatorFacet']['getLimitedCreatorFacet']>} */ + let faLimitedFacet; + + /** @type {import('../../../src/price/priceOracleKit.js').OracleKit} */ + let oracleA; + /** @type {Subscriber} */ + let quoteSubscriber1; + /** @type {UpdateRecord} */ + let lastQuote; + + /** @type {Omit['terms'], 'issuers' | 'brands'>} */ + const faTerms = { + // driven by one oracle + maxSubmissionCount: 1, + minSubmissionCount: 1, + restartDelay: 0n, + timeout: 5, + maxSubmissionValue: 1_000_000, + minSubmissionValue: 1, + + brandIn: inKit.brand, + brandOut: outKit.brand, + timer, + unitAmountIn: AmountMath.make(inKit.brand, 1_000_000n), + + // @ts-expect-error xxx + governedParams: { + [CONTRACT_ELECTORATE]: { + type: ParamTypes.INVITATION, + }, + }, + }; + const staticPrivateArgs = { + storageNode: storageKit.rootNode, + marshaller, + namesByAddressAdmin, + }; + + return Far('root', { + bootstrap: async (vats, devices) => { + vatAdmin = await E(vats.vatAdmin).createVatAdminService(devices.vatAdmin); + const { zoeService } = await E(vats.zoe).buildZoe( + vatAdmin, + undefined, + 'zcf', + ); + zoePK.resolve(zoeService); + + const v1BundleId = await E(vatAdmin).getBundleIDByName(faV1BundleName); + v1BundleId || Fail`bundleId must not be empty`; + installations.fluxAggregatorV1 = await E(zoe).installBundleID(v1BundleId); + + installations.puppetContractGovernor = await E(zoe).installBundleID( + await E(vatAdmin).getBundleIDByName('puppetContractGovernor'), + ); + + installations.committee = await E(zoe).installBundleID( + await E(vatAdmin).getBundleIDByName('committee'), + ); + const ccStartResult = await E(zoe).startInstance( + installations.committee, + harden({}), + { + committeeName: 'Demos', + committeeSize: 1, + }, + { + storageNode: storageKit.rootNode.makeChildNode('thisCommittee'), + marshaller, + }, + ); + ccPK.resolve(ccStartResult.creatorFacet); + }, + + buildV1: async () => { + trace(`BOOT buildV1 start`); + // build the contract vat from ZCF and the contract bundlecap + + const poserInvitationP = E(committeeCreator).getPoserInvitation(); + const [initialPoserInvitation, poserInvitationAmount] = await Promise.all( + [ + poserInvitationP, + E(E(zoe).getInvitationIssuer()).getAmountOf(poserInvitationP), + ], + ); + // @ts-expect-error xxx + faTerms.governedParams[CONTRACT_ELECTORATE].value = poserInvitationAmount; + + const governorTerms = await deeplyFulfilledObject( + harden({ + timer, + governedContractInstallation: NonNullish( + installations.fluxAggregatorV1, + ), + governed: { + terms: faTerms, + label: 'fluxAggregatorV1', + }, + }), + ); + trace('got governorTerms', governorTerms); + + // Complete round-trip without upgrade + trace(`BOOT buildV1 startInstance`); + // @ts-expect-error + governorFacets = await E(zoe).startInstance( + NonNullish(installations.puppetContractGovernor), + undefined, + // @ts-expect-error + governorTerms, + { + governed: { + ...staticPrivateArgs, + initialPoserInvitation, + }, + }, + ); + trace('BOOT buildV1 started instance'); + + // @ts-expect-error XXX governance types https://github.com/Agoric/agoric-sdk/issues/7178 + faLimitedFacet = await E(governorFacets.creatorFacet).getCreatorFacet(); + + oracleA = await E(faLimitedFacet).initOracle('oracleA'); + + trace('BOOT buildV1 made oracleA'); + + return true; + }, + + testFunctionality1: async () => { + const faPublicFacet = await E( + governorFacets.creatorFacet, + ).getPublicFacet(); + + const publicTopics = await E(faPublicFacet).getPublicTopics(); + assert.equal( + publicTopics.latestRound.description, + 'Notification of each round', + ); + assert.equal( + publicTopics.quotes.description, + 'Quotes from this price aggregator', + ); + + trace('testFunctionality pushing price'); + await timer.tickN(1); + const unitPrice = 123n; + await E(oracleA.oracle).pushPrice({ roundId: 1, unitPrice }); + quoteSubscriber1 = await publicTopics.quotes.subscriber; + + const quoteNotifier = makeNotifierFromSubscriber(quoteSubscriber1); + + trace('testFunctionality awaiting quoteNotifier1'); + lastQuote = await quoteNotifier.getUpdateSince(); + assert.equal(lastQuote.updateCount, 1n); + assert.equal(lastQuote.value.amountOut.value, unitPrice); + + // XXX t.throwsAsync sure would be nice + await E(governorFacets.creatorFacet) + .invokeAPI('removeOracles', [['oracleB']]) + .then(() => Error('this should have errored')) + .catch(reason => { + assert.equal( + reason.message, + 'key oracleB not found in collection oracles', + ); + }); + await timer.tickN(1); + }, + + nullUpgradeV1: async () => { + trace(`BOOT nullUpgradeV1 start`); + + const poserInvitationP = E(committeeCreator).getPoserInvitation(); + const [initialPoserInvitation] = await Promise.all([ + poserInvitationP, + E(E(zoe).getInvitationIssuer()).getAmountOf(poserInvitationP), + ]); + + const bundleId = await E(vatAdmin).getBundleIDByName(faV1BundleName); + + trace(`BOOT nullUpgradeV1 upgradeContract`); + const faAdminFacet = await E(governorFacets.creatorFacet).getAdminFacet(); + const upgradeResult = await E(faAdminFacet).upgradeContract(bundleId, { + ...staticPrivateArgs, + initialPoserInvitation, + }); + assert.equal(upgradeResult.incarnationNumber, 2); + trace(`BOOT nullUpgradeV1 upgradeContract completed`); + + await timer.tickN(1); + return true; + }, + + testFunctionality2: async () => { + const faPublicFacet = await E( + governorFacets.creatorFacet, + ).getPublicFacet(); + + const publicTopics = await E(faPublicFacet).getPublicTopics(); + const quoteSubscriber2 = await publicTopics.quotes.subscriber; + assert( + quoteSubscriber2 === quoteSubscriber1, + 'same subscriber object after upgrade', + ); + + trace('testFunctionality2 pushing price'); + // advance time to allow new round + await timer.tickN(1); + const unitPrice = 234n; + await E(oracleA.oracle).pushPrice({ roundId: 2, unitPrice }); + await null; // xxx + + trace('testFunctionality awaiting quotes'); + const quoteNotifier = makeNotifierFromSubscriber( + publicTopics.quotes.subscriber, + ); + lastQuote = await quoteNotifier.getUpdateSince(lastQuote.updateCount); + assert.equal(lastQuote.updateCount, 2n); // incremented durable subscriber + + trace('testFunctionality awaiting quote again'); + lastQuote = await quoteNotifier.getUpdateSince(lastQuote.updateCount); + assert.equal(lastQuote.updateCount, 3n); + assert.equal(lastQuote.value.amountOut.value, unitPrice); + }, + + // this test doesn't upgrade to a new bundle because we have coverage elsewhere that + // a new bundle will replace the behavior of the prior bundle + }); +}; diff --git a/packages/inter-protocol/test/swingsetTests/fluxAggregator/test-fluxAggregator-service-upgrade.js b/packages/inter-protocol/test/swingsetTests/fluxAggregator/test-fluxAggregator-service-upgrade.js new file mode 100644 index 000000000000..a5839eb55f45 --- /dev/null +++ b/packages/inter-protocol/test/swingsetTests/fluxAggregator/test-fluxAggregator-service-upgrade.js @@ -0,0 +1,90 @@ +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; + +import { assert } from '@agoric/assert'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { resolve as importMetaResolve } from 'import-meta-resolve'; + +import { buildVatController } from '@agoric/swingset-vat'; +import { faV1BundleName } from './bootstrap-fluxAggregator-service-upgrade.js'; + +// so paths can be expresssed relative to this file and made absolute +const bfile = name => new URL(name, import.meta.url).pathname; + +test('fluxAggregator service upgrade', async t => { + /** @type {SwingSetConfig} */ + const config = { + includeDevDependencies: true, // test's bootstrap has some deps not needed in production + defaultManagerType: 'local', + bundleCachePath: 'bundles/', + bootstrap: 'bootstrap', + vats: { + bootstrap: { + // TODO refactor to use bootstrap-relay.js + sourceSpec: bfile('bootstrap-fluxAggregator-service-upgrade.js'), + }, + zoe: { + sourceSpec: await importMetaResolve( + '@agoric/vats/src/vat-zoe.js', + import.meta.url, + ).then(href => new URL(href).pathname), + }, + }, + bundles: { + zcf: { + sourceSpec: await importMetaResolve( + '@agoric/zoe/src/contractFacet/vatRoot.js', + import.meta.url, + ).then(href => new URL(href).pathname), + }, + committee: { + sourceSpec: await importMetaResolve( + '@agoric/governance/src/committee.js', + import.meta.url, + ).then(href => new URL(href).pathname), + }, + puppetContractGovernor: { + sourceSpec: await importMetaResolve( + '@agoric/governance/tools/puppetContractGovernor.js', + import.meta.url, + ).then(href => new URL(href).pathname), + }, + [faV1BundleName]: { + sourceSpec: await importMetaResolve( + '@agoric/inter-protocol/src/price/fluxAggregatorContract.js', + import.meta.url, + ).then(href => new URL(href).pathname), + }, + }, + }; + + t.log('buildVatController'); + const c = await buildVatController(config); + c.pinVatRoot('bootstrap'); + t.log('run controller'); + await c.run(); + + const run = async (name, args = []) => { + assert(Array.isArray(args)); + const kpid = c.queueToVatRoot('bootstrap', name, args); + await c.run(); + const status = c.kpStatus(kpid); + const capdata = c.kpResolution(kpid); + return [status, capdata]; + }; + + t.log('create initial version'); + const [buildV1] = await run('buildV1', []); + t.is(buildV1, 'fulfilled'); + + t.log('smoke test of functionality'); + const [testFunctionality1] = await run('testFunctionality1', []); + t.is(testFunctionality1, 'fulfilled'); + + t.log('perform null upgrade'); + const [nullUpgradeV1] = await run('nullUpgradeV1', []); + t.is(nullUpgradeV1, 'fulfilled'); + + t.log('smoke test of functionality'); + const [testFunctionality2] = await run('testFunctionality2', []); + t.is(testFunctionality2, 'fulfilled'); +}); diff --git a/packages/inter-protocol/test/vaultFactory/vault-contract-wrapper.js b/packages/inter-protocol/test/vaultFactory/vault-contract-wrapper.js index 007b8f0c03ba..fe3e891d9346 100644 --- a/packages/inter-protocol/test/vaultFactory/vault-contract-wrapper.js +++ b/packages/inter-protocol/test/vaultFactory/vault-contract-wrapper.js @@ -1,5 +1,5 @@ /** @file DEPRECATED use the vault test driver instead */ -import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; import { assert } from '@agoric/assert'; import { makePublishKit, observeNotifier } from '@agoric/notifier'; @@ -72,7 +72,6 @@ export async function start(zcf, privateArgs, baggage) { priceList: [80], tradeList: undefined, timer, - quoteMint: makeIssuerKit('quote', AssetKind.SET).mint, }; const priceAuthority = await makeFakePriceAuthority(options); const collateralUnit = await unitAmount(collateralBrand);