From e972759da438cbe8a12725935d40d4b50ca476e6 Mon Sep 17 00:00:00 2001 From: Turadg Aleahmad Date: Thu, 9 Feb 2023 19:42:29 -0800 Subject: [PATCH] feat(price): addOracles by EC --- packages/governance/src/contractGovernor.js | 12 + .../src/price/fluxAggregator.contract.js | 66 +- .../src/proposals/price-feed-proposal.js | 116 ++- .../price/test-fluxAggregator.contract.js | 794 ++++++++++++++++++ .../smartWallet/test-oracle-integration.js | 265 +++++- 5 files changed, 1193 insertions(+), 60 deletions(-) create mode 100644 packages/inter-protocol/test/price/test-fluxAggregator.contract.js diff --git a/packages/governance/src/contractGovernor.js b/packages/governance/src/contractGovernor.js index b19c042f1f3a..28d2ae302b3d 100644 --- a/packages/governance/src/contractGovernor.js +++ b/packages/governance/src/contractGovernor.js @@ -2,6 +2,7 @@ import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; import { mustMatch } from '@agoric/store'; +import { makeTracer } from '@agoric/internal'; import { CONTRACT_ELECTORATE, setupParamGovernance, @@ -12,6 +13,8 @@ import { ParamChangesQuestionDetailsShape } from './typeGuards.js'; const { Fail } = assert; +const trace = makeTracer('CGov', false); + /** * Validate that the question details correspond to a parameter change question * that the electorate hosts, and that the voteCounter and other details are @@ -125,6 +128,8 @@ const validateQuestionFromCounter = async (zoe, electorate, voteCounter) => { */ /** + * Start an instance of a governor, governing a "governed" contract specified in terms. + * * @template {GovernableStartFn} SF Start function of governed contract * @param {ZCF<{ * timer: import('@agoric/time/src/types').TimerService, @@ -139,7 +144,9 @@ const validateQuestionFromCounter = async (zoe, electorate, voteCounter) => { * }} privateArgs */ const start = async (zcf, privateArgs) => { + trace('start'); const zoe = zcf.getZoeService(); + trace('getTerms', zcf.getTerms()); const { timer, governedContractInstallation, @@ -148,6 +155,7 @@ const start = async (zcf, privateArgs) => { terms: contractTerms, }, } = zcf.getTerms(); + trace('contractTerms', contractTerms); contractTerms.governedParams[CONTRACT_ELECTORATE] || Fail`Contract must declare ${CONTRACT_ELECTORATE} as a governed parameter`; @@ -156,6 +164,7 @@ const start = async (zcf, privateArgs) => { electionManager: zcf.getInstance(), }); + trace('starting governedContractInstallation'); const { creatorFacet: governedCF, instance: governedInstance, @@ -195,9 +204,11 @@ const start = async (zcf, privateArgs) => { } return poserFacet; }; + trace('awaiting getUpdatedPoserFacet'); await getUpdatedPoserFacet(); assert(poserFacet, 'question poser facet must be initialized'); + trace('awaiting setupParamGovernance'); // All governed contracts have at least a governed electorate const { voteOnParamChanges, createdQuestion: createdParamQuestion } = await setupParamGovernance( @@ -208,6 +219,7 @@ const start = async (zcf, privateArgs) => { getUpdatedPoserFacet, ); + trace('awaiting setupFilterGovernance'); const { voteOnFilter, createdFilterQuestion } = await setupFilterGovernance( zoe, governedInstance, diff --git a/packages/inter-protocol/src/price/fluxAggregator.contract.js b/packages/inter-protocol/src/price/fluxAggregator.contract.js index 52a73a0132c3..9f0954945b5d 100644 --- a/packages/inter-protocol/src/price/fluxAggregator.contract.js +++ b/packages/inter-protocol/src/price/fluxAggregator.contract.js @@ -1,8 +1,11 @@ import { AssetKind, makeIssuerKit } from '@agoric/ertp'; -import { assertAllDefined } from '@agoric/internal'; +import { handleParamGovernance } from '@agoric/governance'; +import { assertAllDefined, makeTracer } from '@agoric/internal'; import { E } from '@endo/eventual-send'; +import { reserveThenDeposit } from '../proposals/utils.js'; import { provideFluxAggregator } from './fluxAggregator.js'; +const trace = makeTracer('FluxAgg'); /** * @typedef {import('@agoric/vat-data').Baggage} Baggage * @typedef {import('@agoric/time/src/types').TimerService} TimerService @@ -20,13 +23,16 @@ import { provideFluxAggregator } from './fluxAggregator.js'; * unitAmountIn?: Amount<'nat'>, * }>} zcf * @param {{ + * initialPoserInvitation: Invitation, * marshaller: Marshaller, + * namesByAddressAdmin: ERef, * quoteMint?: ERef>, * storageNode: ERef, * }} privateArgs * @param {Baggage} baggage */ export const start = async (zcf, privateArgs, baggage) => { + trace('start'); const { timer: timerP } = zcf.getTerms(); const quoteMintP = @@ -40,12 +46,19 @@ export const start = async (zcf, privateArgs, baggage) => { mint: quoteMint, }; - const { marshaller, storageNode: storageNodeP } = privateArgs; - assertAllDefined({ marshaller, storageNodeP }); + const { + initialPoserInvitation, + marshaller, + namesByAddressAdmin, + storageNode: storageNodeP, + } = privateArgs; + assertAllDefined({ initialPoserInvitation, marshaller, storageNodeP }); const timer = await timerP; const storageNode = await storageNodeP; + trace('awaited args'); + const fa = provideFluxAggregator( baggage, zcf, @@ -54,10 +67,53 @@ export const start = async (zcf, privateArgs, baggage) => { storageNode, marshaller, ); + trace('got fa', fa); + + const { augmentPublicFacet, makeGovernorFacet } = await handleParamGovernance( + // @ts-expect-error FIXME include Governance params + zcf, + initialPoserInvitation, + { + // No governed parameters. Governance just for API methods. + }, + storageNode, + marshaller, + ); + + /** + * Initialize a new oracle and send an invitation to administer it. + * + * @param {string} addr + */ + const addOracle = async addr => { + const invitation = await E(fa.creatorFacet).makeOracleInvitation(addr); + // XXX imported from 'proposals' path + await reserveThenDeposit( + `fluxAggregator oracle ${addr}`, + namesByAddressAdmin, + addr, + [invitation], + ); + return `added ${addr}`; + }; + + const governedApis = { + /** + * Add the specified oracles. May partially fail, such that some oracles are added and others aren't. + * + * @param {string[]} oracleIds + * @returns {Promise>>} + */ + addOracles: oracleIds => { + return Promise.allSettled(oracleIds.map(addOracle)); + }, + }; + const governorFacet = makeGovernorFacet(fa.creatorFacet, governedApis); return harden({ - creatorFacet: fa.creatorFacet, - publicFacet: fa.publicFacet, + creatorFacet: governorFacet, + // XXX this is a lot of API to put on every public facet + publicFacet: augmentPublicFacet(fa.publicFacet), }); }; harden(start); diff --git a/packages/inter-protocol/src/proposals/price-feed-proposal.js b/packages/inter-protocol/src/proposals/price-feed-proposal.js index 09ccf233851a..74474db0cd43 100644 --- a/packages/inter-protocol/src/proposals/price-feed-proposal.js +++ b/packages/inter-protocol/src/proposals/price-feed-proposal.js @@ -8,9 +8,10 @@ import { import { deeplyFulfilledObject, makeTracer } from '@agoric/internal'; import { unitAmount } from '@agoric/zoe/src/contractSupport/priceQuote.js'; +import { CONTRACT_ELECTORATE, ParamTypes } from '@agoric/governance'; import { reserveThenDeposit, reserveThenGetNames } from './utils.js'; -const trace = makeTracer('RunPriceFeed'); +const trace = makeTracer('RunPriceFeed', false); /** @type {(name: string) => string} */ const sanitizePathSegment = name => { @@ -96,6 +97,8 @@ export const createPriceFeed = async ( chainStorage, chainTimerService, client, + econCharterKit, + economicCommitteeCreatorFacet, namesByAddressAdmin, priceAuthority, priceAuthorityAdmin, @@ -124,55 +127,107 @@ 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]]} + * @type {[[Brand<'nat'>, Brand<'nat'>], [Installation, Installation]]} */ - const [[brandIn, brandOut], [priceAggregator]] = await Promise.all([ - reserveThenGetNames(E(agoricNamesAdmin).lookupAdmin('oracleBrand'), [ - IN_BRAND_NAME, - OUT_BRAND_NAME, - ]), - reserveThenGetNames(E(agoricNamesAdmin).lookupAdmin('installation'), [ - 'priceAggregator', - ]), - ]); + const [[brandIn, brandOut], [contractGovernor, priceAggregator]] = + await Promise.all([ + reserveThenGetNames(E(agoricNamesAdmin).lookupAdmin('oracleBrand'), [ + IN_BRAND_NAME, + OUT_BRAND_NAME, + ]), + reserveThenGetNames(E(agoricNamesAdmin).lookupAdmin('installation'), [ + 'contractGovernor', + 'priceAggregator', + ]), + ]); + + trace('getPoserInvitation'); + const poserInvitationP = E( + economicCommitteeCreatorFacet, + ).getPoserInvitation(); + const [initialPoserInvitation, electorateInvitationAmount] = + await Promise.all([ + poserInvitationP, + E(E(zoe).getInvitationIssuer()).getAmountOf(poserInvitationP), + ]); + trace('got initialPoserInvitation'); const unitAmountIn = await unitAmount(brandIn); - const terms = await deeplyFulfilledObject( + const terms = harden({ + ...contractTerms, + description: AGORIC_INSTANCE_NAME, + brandIn, + brandOut, + timer, + unitAmountIn, + governedParams: { + [CONTRACT_ELECTORATE]: { + type: ParamTypes.INVITATION, + value: electorateInvitationAmount, + }, + }, + }); + trace('got terms'); + + const governorTerms = await deeplyFulfilledObject( harden({ - ...contractTerms, - description: AGORIC_INSTANCE_NAME, - brandIn, - brandOut, - timer, - unitAmountIn, + timer: chainTimerService, + governedContractInstallation: priceAggregator, + governed: { + terms, + }, }), ); + trace('got governorTerms', governorTerms); const storageNode = await makeStorageNodeChild(chainStorage, STORAGE_PATH); const marshaller = E(board).getReadonlyMarshaller(); + trace('got contractGovernor', contractGovernor); + + trace('awaiting startInstance'); // Create the price feed. - const aggregator = await E(zoe).startInstance( - priceAggregator, + /** @type {{ creatorFacet: import('@agoric/governance/src/contractGovernor.js').GovernedContractFnFacetAccess, publicFacet: GovernorPublic, instance: Instance, adminFacet: AdminFacet }} */ + const aggregatorGovernor = await E(zoe).startInstance( + contractGovernor, undefined, - terms, + governorTerms, { - storageNode: E(storageNode).makeChildNode( - sanitizePathSegment(AGORIC_INSTANCE_NAME), - ), - marshaller, + governed: { + initialPoserInvitation, + marshaller, + namesByAddressAdmin, + storageNode: E(storageNode).makeChildNode( + sanitizePathSegment(AGORIC_INSTANCE_NAME), + ), + }, }, ); + const faCreatorFacet = await E( + aggregatorGovernor.creatorFacet, + ).getCreatorFacet(); + trace('got aggregator', faCreatorFacet); + + const faPublic = await E(aggregatorGovernor.creatorFacet).getPublicFacet(); + const faInstance = await E(aggregatorGovernor.creatorFacet).getInstance(); + trace('got', { faInstance, faPublic }); E(E(agoricNamesAdmin).lookupAdmin('instance')).update( AGORIC_INSTANCE_NAME, - aggregator.instance, + faInstance, ); + E(E.get(econCharterKit).creatorFacet).addInstance( + faInstance, + aggregatorGovernor.creatorFacet, + AGORIC_INSTANCE_NAME, + ); + trace('registered', AGORIC_INSTANCE_NAME, faInstance); + // Publish price feed in home.priceAuthority. const forceReplace = true; void E(priceAuthorityAdmin).registerPriceAuthority( - E(aggregator.publicFacet).getPriceAuthority(), + E(faPublic).getPriceAuthority(), brandIn, brandOut, forceReplace, @@ -184,9 +239,7 @@ export const createPriceFeed = async ( * @param {string} addr */ const addOracle = async addr => { - const invitation = await E(aggregator.creatorFacet).makeOracleInvitation( - addr, - ); + const invitation = await E(faCreatorFacet).makeOracleInvitation(addr); await reserveThenDeposit( `${AGORIC_INSTANCE_NAME} member ${addr}`, namesByAddressAdmin, @@ -220,6 +273,9 @@ export const getManifestForPriceFeed = async ( chainStorage: t, chainTimerService: t, client: t, + contractGovernor: t, + econCharterKit: t, + economicCommitteeCreatorFacet: t, namesByAddressAdmin: t, priceAuthority: t, priceAuthorityAdmin: t, diff --git a/packages/inter-protocol/test/price/test-fluxAggregator.contract.js b/packages/inter-protocol/test/price/test-fluxAggregator.contract.js new file mode 100644 index 000000000000..c0b4c67bd845 --- /dev/null +++ b/packages/inter-protocol/test/price/test-fluxAggregator.contract.js @@ -0,0 +1,794 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { test as unknownTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import path from 'path'; + +import bundleSource from '@endo/bundle-source'; + +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; +import { makeIssuerKit, AssetKind } from '@agoric/ertp'; +import { setUpGovernedContract } from '@agoric/governance/tools/puppetGovernance.js'; + +import { + eventLoopIteration, + makeFakeMarshaller, +} from '@agoric/notifier/tools/testSupports.js'; +import { makeMockChainStorageRoot } from '@agoric/internal/src/storage-test-utils.js'; +import { subscribeEach } from '@agoric/notifier'; +import { makeFakeVatAdmin } from '@agoric/zoe/tools/fakeVatAdmin.js'; +import { makeZoeKit } from '@agoric/zoe/src/zoeService/zoe.js'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { topicPath } from '../supports.js'; + +/** @type {import('ava').TestFn>>} */ +const test = unknownTest; + +const filename = new URL(import.meta.url).pathname; +const dirname = path.dirname(filename); + +// Pack the contracts. +/** @type {EndoZipBase64Bundle} */ +const aggregatorBundle = await bundleSource( + `${dirname}/../../src/price/fluxAggregator.contract.js`, +); + +const defaultConfig = { + maxSubmissionCount: 1000, + minSubmissionCount: 2, + restartDelay: 5, + timeout: 10, + minSubmissionValue: 100, + maxSubmissionValue: 10000, +}; + +const makeContext = async () => { + // Outside of tests, we should use the long-lived Zoe on the + // testnet. In this test, we must create a new Zoe. + const { admin, vatAdminState } = makeFakeVatAdmin(); + const { zoeService: zoe } = makeZoeKit(admin); + + // 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. + vatAdminState.installBundle('b1-aggregator', aggregatorBundle); + /** @type {Installation} */ + const aggregatorInstallation = await E(zoe).installBundleID('b1-aggregator'); + + const link = makeIssuerKit('$LINK', AssetKind.NAT); + const usd = makeIssuerKit('$USD', AssetKind.NAT); + + /** + * @param {Record} config + */ + async function makeChainlinkAggregator(config) { + const { + maxSubmissionCount, + maxSubmissionValue, + minSubmissionCount, + minSubmissionValue, + restartDelay, + timeout, + } = config; + + // ??? why do we need the Far here and not in VaultFactory tests? + const marshaller = Far('fake marshaller', { ...makeFakeMarshaller() }); + const mockStorageRoot = makeMockChainStorageRoot(); + const storageNode = E(mockStorageRoot).makeChildNode('priceAggregator'); + + const timer = buildManualTimer(() => {}); + + const { governorFacets } = await setUpGovernedContract( + zoe, + // @ts-expect-error XXX governance types + aggregatorInstallation, + timer, + { + timer, + brandIn: link.brand, + brandOut: usd.brand, + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + minSubmissionValue, + maxSubmissionValue, + governedApis: ['initOracle'], + }, + { + marshaller, + storageNode: E(storageNode).makeChildNode('LINK-USD_price_feed'), + }, + ); + + return { + governor: governorFacets.creatorFacet, + /** @type {import('../../src/price/fluxAggregator.js').FluxAggregator['creatorFacet']} */ + // @ts-expect-error XXX types + creatorFacet: await E(governorFacets.creatorFacet).getCreatorFacet(), + /** @type {import('../../src/price/fluxAggregator.js').FluxAggregator['publicFacet']} */ + // @ts-expect-error XXX types + publicFacet: await E(governorFacets.creatorFacet).getPublicFacet(), + instance: E(governorFacets.creatorFacet).getInstance(), + mockStorageRoot, + }; + } + + return { makeChainlinkAggregator, zoe }; +}; + +test.before('setup aggregator and oracles', async t => { + t.context = await makeContext(); +}); + +test('basic', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator(defaultConfig); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await aggregator.creatorFacet.initOracle( + 'agoric1priceOracleA', + ); + const pricePushAdminB = await aggregator.creatorFacet.initOracle( + 'agoric1priceOracleB', + ); + const pricePushAdminC = await aggregator.creatorFacet.initOracle( + 'agoric1priceOracleC', + ); + + t.log('----- round 1: basic consensus'); + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + await E(pricePushAdminC).pushPrice({ roundId: 1, unitPrice: 300n }); + await oracleTimer.tick(); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt1.roundId, 1n); + t.is(round1Attempt1.answer, 200n); + + t.log('----- round 2: check restartDelay implementation'); + // since oracle A initialized the last round, it CANNOT start another round before + // the restartDelay, which means its submission will be IGNORED. this means the median + // should ONLY be between the OracleB and C values, which is why it is 25000 + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 1000n }), + { message: 'round not accepting submissions' }, + ); + await E(pricePushAdminB).pushPrice({ roundId: 2, unitPrice: 2000n }); + await E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 3000n }); + await oracleTimer.tick(); + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt2.answer, 200n); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.is(round2Attempt1.answer, 2500n); + + t.log('----- round 3: check oracle submission order'); + // unlike the previous test, if C initializes, all submissions should be recorded, + // which means the median will be the expected 5000 here + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 3, unitPrice: 5000n }); + await E(pricePushAdminA).pushPrice({ roundId: 3, unitPrice: 4000n }); + await E(pricePushAdminB).pushPrice({ roundId: 3, unitPrice: 6000n }); + await oracleTimer.tick(); + + const round1Attempt3 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt3.answer, 200n); + const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + t.is(round3Attempt1.answer, 5000n); +}); + +test('timeout', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator({ + ...defaultConfig, + restartDelay: 2, + timeout: 5, + }); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleA', + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleB', + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleC', + ); + + // ----- round 1: basic consensus w/ ticking: should work EXACTLY the same + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 100n }); + await oracleTimer.tick(); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 1, unitPrice: 300n }); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt1.roundId, 1n); + t.is(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).pushPrice({ roundId: 2, unitPrice: 2000n }); + 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).pushPrice({ roundId: 3, unitPrice: 1000n }); + await E(pricePushAdminA).pushPrice({ roundId: 3, unitPrice: 3000n }); + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt2.answer, 200n); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.is(round2Attempt1.answer, 200n); + const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + t.is(round3Attempt1.answer, 2000n); +}); + +test('issue check', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator({ + ...defaultConfig, + restartDelay: 2, + }); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleA', + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleB', + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleC', + ); + + // ----- round 1: ignore too low values + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 50n }), + { + message: 'value below minSubmissionValue 100', + }, + ); + await oracleTimer.tick(); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 1, unitPrice: 300n }); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt1.answer, 250n); + + // ----- round 2: ignore too high values + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminB).pushPrice({ roundId: 2, unitPrice: 20000n }), + { message: 'value above maxSubmissionValue 10000' }, + ); + await E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 1000n }); + await E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 3000n }); + await oracleTimer.tick(); + + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.is(round2Attempt1.answer, 2000n); +}); + +test('supersede', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator({ + ...defaultConfig, + restartDelay: 1, + }); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleA', + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleB', + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleC', + ); + + // ----- round 1: round 1 is NOT supersedable when 3 submits, meaning it will be ignored + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 100n }); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 300n }), + { + message: 'previous round not supersedable', + }, + ); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + await oracleTimer.tick(); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt1.answer, 150n); + + // ----- round 2: oracle C's value from before should have been IGNORED + await oracleTimer.tick(); + await E(pricePushAdminB).pushPrice({ roundId: 2, unitPrice: 2000n }); + await E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 1000n }); + await oracleTimer.tick(); + + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.is(round2Attempt1.answer, 1500n); + + // ----- round 3: oracle C should NOT be able to supersede round 3 + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 4, unitPrice: 1000n }), + { message: 'invalid round to report' }, + ); + + try { + await E(aggregator.creatorFacet).getRoundData(4); + } catch (error) { + t.is(error.message, 'No data present'); + } +}); + +test('interleaved', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator({ + ...defaultConfig, + maxSubmissionCount: 3, + minSubmissionCount: 3, // requires ALL the oracles for consensus in this case + restartDelay: 1, + timeout: 5, + }); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleA', + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleB', + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleC', + ); + + // ----- round 1: we now need unanimous submission for a round for it to have consensus + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 100n }); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 300n }), + { + message: 'previous round not supersedable', + }, + ); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + await oracleTimer.tick(); + + try { + await E(aggregator.creatorFacet).getRoundData(1); + } catch (error) { + t.is(error.message, 'No data present'); + } + + // ----- round 2: interleaved round submission -- just making sure this works + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 1, unitPrice: 300n }); + await oracleTimer.tick(); + await E(pricePushAdminB).pushPrice({ roundId: 2, unitPrice: 2000n }); + await E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 1000n }); + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 3, unitPrice: 9000n }), + { message: 'previous round not supersedable' }, + ); + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 3000n }); // assumes oracle C is going for a resubmission + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 3, unitPrice: 5000n }); + await oracleTimer.tick(); + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + + t.is(round1Attempt2.answer, 200n); + t.is(round2Attempt1.answer, 2000n); + + try { + await E(aggregator.creatorFacet).getRoundData(3); + } catch (error) { + t.is(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 t.throwsAsync( + E(pricePushAdminA).pushPrice({ roundId: 4, unitPrice: 4000n }), + { message: 'round not accepting submissions' }, + ); + await E(pricePushAdminB).pushPrice({ roundId: 4, unitPrice: 5000n }); + await E(pricePushAdminC).pushPrice({ roundId: 4, unitPrice: 6000n }); + await oracleTimer.tick(); // --- round 3 has NOW timed out, meaning it is now supersedable + + try { + await E(aggregator.creatorFacet).getRoundData(3); + } catch (error) { + t.is(error.message, 'No data present'); + } + + try { + await E(aggregator.creatorFacet).getRoundData(4); + } catch (error) { + t.is(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).pushPrice({ roundId: 4, unitPrice: 4000n }); + await t.throwsAsync( + E(pricePushAdminB).pushPrice({ roundId: 4, unitPrice: 5000n }), + { message: /cannot report on previous rounds/ }, + ); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 4, unitPrice: 6000n }), + { message: /cannot report on previous rounds/ }, + ); + await oracleTimer.tick(); + + const round3Attempt3 = await E(aggregator.creatorFacet).getRoundData(3); + const round4Attempt2 = await E(aggregator.creatorFacet).getRoundData(4); + + t.is(round3Attempt3.answer, 2000n); + t.is(round4Attempt2.answer, 5000n); + + // ----- round 5: ping-ponging should be possible (although this is an unlikely pernicious case) + await E(pricePushAdminC).pushPrice({ roundId: 5, unitPrice: 1000n }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 6, unitPrice: 1000n }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 7, unitPrice: 1000n }); + + const round5Attempt1 = await E(aggregator.creatorFacet).getRoundData(5); + const round6Attempt1 = await E(aggregator.creatorFacet).getRoundData(6); + + t.is(round5Attempt1.answer, 5000n); + t.is(round6Attempt1.answer, 5000n); +}); + +test('larger', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator({ + ...defaultConfig, + minSubmissionCount: 3, + restartDelay: 1, + timeout: 5, + }); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleA', + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleB', + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleC', + ); + const pricePushAdminD = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleD', + ); + const pricePushAdminE = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleE', + ); + + // ----- round 1: usual case + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 1000n }), + { message: 'previous round not supersedable' }, + ); + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminD).pushPrice({ roundId: 3, unitPrice: 3000n }), + { message: 'invalid round to report' }, + ); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminE).pushPrice({ roundId: 1, unitPrice: 300n }); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt1.answer, 200n); + + // ----- round 2: ignore late arrival + await oracleTimer.tick(); + await E(pricePushAdminB).pushPrice({ roundId: 2, unitPrice: 600n }); + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 500n }); + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 3, unitPrice: 1000n }), + { message: 'previous round not supersedable' }, + ); + await oracleTimer.tick(); + await E(pricePushAdminD).pushPrice({ roundId: 1, unitPrice: 500n }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 1000n }); + await oracleTimer.tick(); + await t.throwsAsync( + E(pricePushAdminC).pushPrice({ roundId: 1, unitPrice: 700n }), + // oracle C has already sent round 2 + { message: 'cannot report on previous rounds' }, + ); + + const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); + const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); + t.is(round1Attempt2.answer, 250n); + t.is(round2Attempt1.answer, 600n); +}); + +test('suggest', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator({ + ...defaultConfig, + minSubmissionCount: 3, + restartDelay: 1, + timeout: 5, + }); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleA', + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleB', + ); + const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleC', + ); + + // ----- round 1: basic consensus + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + await E(pricePushAdminC).pushPrice({ roundId: 1, unitPrice: 300n }); + await oracleTimer.tick(); + + const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); + t.is(round1Attempt1.roundId, 1n); + t.is(round1Attempt1.answer, 200n); + + // ----- round 2: add a new oracle and confirm the suggested round is correct + await oracleTimer.tick(); + await E(pricePushAdminB).pushPrice({ roundId: 2, unitPrice: 1000n }); + + t.deepEqual( + await E(aggregator.creatorFacet).oracleRoundState( + 'agorice1priceOracleC', + 1n, + ), + { + eligibleForSpecificRound: false, + oracleCount: 3, + latestSubmission: 300n, + queriedRoundId: 1n, + roundTimeout: 5, + startedAt: 1n, + }, + ); + + t.deepEqual( + await E(aggregator.creatorFacet).oracleRoundState( + 'agorice1priceOracleB', + 0n, + ), + { + eligibleForSpecificRound: false, + oracleCount: 3, + latestSubmission: 1000n, + queriedRoundId: 2n, + roundTimeout: 5, + startedAt: 3n, + }, + ); + + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 2000n }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminC).pushPrice({ roundId: 2, unitPrice: 3000n }); + + t.deepEqual( + await E(aggregator.creatorFacet).oracleRoundState( + 'agorice1priceOracleA', + 0n, + ), + { + eligibleForSpecificRound: true, + oracleCount: 3, + latestSubmission: 2000n, + queriedRoundId: 3n, + roundTimeout: 0, + startedAt: 0n, // round 3 hasn't yet started, so it should be zeroed + }, + ); + + // ----- round 3: try using suggested round + await E(pricePushAdminC).pushPrice({ roundId: 3, unitPrice: 100n }); + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: undefined, unitPrice: 200n }); + await oracleTimer.tick(); + await oracleTimer.tick(); + await E(pricePushAdminB).pushPrice({ roundId: undefined, unitPrice: 300n }); + + const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); + t.is(round3Attempt1.roundId, 3n); + t.is(round3Attempt1.answer, 200n); +}); + +test('notifications', async t => { + const { zoe } = t.context; + + const aggregator = await t.context.makeChainlinkAggregator({ + ...defaultConfig, + maxSubmissionCount: 1000, + restartDelay: 1, // have to alternate to start rounds + }); + /** @type {{ timer: ManualTimer }} */ + // @ts-expect-error cast + const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + + const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleA', + ); + const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( + 'agorice1priceOracleB', + ); + + const latestRoundSubscriber = await E( + aggregator.publicFacet, + ).getRoundStartNotifier(); + const eachLatestRound = subscribeEach(latestRoundSubscriber)[ + Symbol.asyncIterator + ](); + + await oracleTimer.tick(); + await E(pricePushAdminA).pushPrice({ roundId: 1, unitPrice: 100n }); + t.deepEqual((await eachLatestRound.next()).value, { + roundId: 1n, + startedAt: 1n, + }); + await E(pricePushAdminB).pushPrice({ roundId: 1, unitPrice: 200n }); + + await eventLoopIteration(); + t.deepEqual( + aggregator.mockStorageRoot.getBody( + 'mockChainStorageRoot.priceAggregator.LINK-USD_price_feed', + ), + { + amountIn: { brand: { iface: 'Alleged: $LINK brand' }, value: 1n }, + amountOut: { + brand: { iface: 'Alleged: $USD brand' }, + value: 150n, // AVG(100, 200) + }, + timer: { iface: 'Alleged: ManualTimer' }, + timestamp: 1n, + }, + ); + + await t.throwsAsync( + E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 1000n }), + { message: 'round not accepting submissions' }, + ); + // A started last round so fails to start next round + t.deepEqual( + // subscribe fresh because the iterator won't advance yet + (await latestRoundSubscriber.subscribeAfter()).head.value, + { + roundId: 1n, + startedAt: 1n, + }, + ); + // B gets to start it + await E(pricePushAdminB).pushPrice({ roundId: 2, unitPrice: 1000n }); + // now it's roundId=2 + t.deepEqual((await eachLatestRound.next()).value, { + roundId: 2n, + startedAt: 1n, + }); + // A joins in + await E(pricePushAdminA).pushPrice({ roundId: 2, unitPrice: 1000n }); + // writes to storage + t.deepEqual( + aggregator.mockStorageRoot.getBody( + 'mockChainStorageRoot.priceAggregator.LINK-USD_price_feed.latestRound', + ), + { roundId: 2n, startedAt: 1n }, + ); + + await eventLoopIteration(); + t.deepEqual( + aggregator.mockStorageRoot.getBody( + 'mockChainStorageRoot.priceAggregator.LINK-USD_price_feed', + ), + { + amountIn: { brand: { iface: 'Alleged: $LINK brand' }, value: 1n }, + amountOut: { + brand: { iface: 'Alleged: $USD brand' }, + value: 1000n, // AVG(1000, 1000) + }, + timer: { iface: 'Alleged: ManualTimer' }, + timestamp: 1n, + }, + ); + + // A can start again + await E(pricePushAdminA).pushPrice({ roundId: 3, unitPrice: 1000n }); + t.deepEqual((await eachLatestRound.next()).value, { + roundId: 3n, + startedAt: 1n, + }); + // no new price yet publishable +}); + +test('storage keys', async t => { + const { publicFacet } = await t.context.makeChainlinkAggregator( + defaultConfig, + ); + + t.is( + await topicPath(publicFacet, 'quotes'), + 'mockChainStorageRoot.priceAggregator.LINK-USD_price_feed', + ); +}); diff --git a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js index 706e7df76f46..aca9530e6bfb 100644 --- a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js +++ b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js @@ -1,6 +1,7 @@ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { NonNullish } from '@agoric/assert'; +import { coalesceUpdates } from '@agoric/smart-wallet/src/utils.js'; import { buildRootObject } from '@agoric/vats/src/core/boot-psm.js'; import '@agoric/vats/src/core/types.js'; import { @@ -8,15 +9,14 @@ import { mockPsmBootstrapArgs, } from '@agoric/vats/tools/boot-test-utils.js'; import { eventLoopIteration } from '@agoric/zoe/tools/eventLoopIteration.js'; -import { E } from '@endo/far'; - -import { coalesceUpdates } from '@agoric/smart-wallet/src/utils.js'; import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; -import { INVITATION_MAKERS_DESC } from '../../src/price/fluxAggregator.js'; +import { E } from '@endo/far'; +import { zip } from '../../src/collect.js'; +import { INVITATION_MAKERS_DESC as EC_INVITATION_MAKERS_DESC } from '../../src/econCommitteeCharter.js'; +import { INVITATION_MAKERS_DESC as ORACLE_INVITATION_MAKERS_DESC } from '../../src/price/fluxAggregator.js'; import { ensureOracleBrands } from '../../src/proposals/price-feed-proposal.js'; import { headValue } from '../supports.js'; -import { makeDefaultTestContext } from './contexts.js'; -import { zip } from '../../src/collect.js'; +import { currentPurseBalance, makeDefaultTestContext } from './contexts.js'; /** * @type {import('ava').TestFn> @@ -25,11 +25,13 @@ import { zip } from '../../src/collect.js'; */ const test = anyTest; +const committeeAddress = 'econCommitteeMemberA'; + const makeTestSpace = async log => { const psmParams = { anchorAssets: [{ denom: 'ibc/usdc1234', keyword: 'AUSD' }], economicCommitteeAddresses: { - /* empty */ + aMember: committeeAddress, }, argv: { bootMsg: {} }, }; @@ -77,6 +79,11 @@ test.before(async t => { t.context = await makeDefaultTestContext(t, makeTestSpace); }); +/** + * + * @param {import('ava').ExecutionContext<*>} t + * @param {string[]} oracleAddresses + */ const setupFeedWithWallets = async (t, oracleAddresses) => { const { agoricNames } = t.context.consume; @@ -89,12 +96,12 @@ const setupFeedWithWallets = async (t, oracleAddresses) => { await t.context.simpleCreatePriceFeed(oracleAddresses, 'ATOM', 'USD'); /** @type {import('@agoric/zoe/src/zoeService/utils.js').Instance} */ - const priceAggregator = await E(agoricNames).lookup( + const governedPriceAggregator = await E(agoricNames).lookup( 'instance', 'ATOM-USD price feed', ); - return { oracleWallets, priceAggregator }; + return { oracleWallets, governedPriceAggregator }; }; let acceptInvitationCounter = 0; @@ -105,7 +112,7 @@ const acceptInvitation = async (wallet, priceAggregator) => { const getInvMakersSpec = { source: 'purse', instance: priceAggregator, - description: INVITATION_MAKERS_DESC, + description: ORACLE_INVITATION_MAKERS_DESC, }; /** @type {import('@agoric/smart-wallet/src/offers').OfferSpec} */ @@ -153,7 +160,9 @@ test.serial('invitations', async t => { // this returns wallets, but we need the updates subscriber to start before the price feed starts // so we provision the wallet earlier above - const { priceAggregator } = await setupFeedWithWallets(t, [operatorAddress]); + const { governedPriceAggregator } = await setupFeedWithWallets(t, [ + operatorAddress, + ]); /** * get invitation details the way a user would @@ -174,15 +183,15 @@ test.serial('invitations', async t => { }; const proposeInvitationDetails = await getInvitationFor( - INVITATION_MAKERS_DESC, + ORACLE_INVITATION_MAKERS_DESC, 1, computedState.balances, ); - t.is(proposeInvitationDetails[0].description, INVITATION_MAKERS_DESC); + t.is(proposeInvitationDetails[0].description, ORACLE_INVITATION_MAKERS_DESC); t.is( proposeInvitationDetails[0].instance, - priceAggregator, + governedPriceAggregator, 'priceAggregator', ); @@ -191,8 +200,8 @@ test.serial('invitations', async t => { /** @type {import('@agoric/smart-wallet/src/invitations.js').PurseInvitationSpec} */ const getInvMakersSpec = { source: 'purse', - instance: priceAggregator, - description: INVITATION_MAKERS_DESC, + instance: governedPriceAggregator, + description: ORACLE_INVITATION_MAKERS_DESC, }; const id = '33'; @@ -210,7 +219,7 @@ test.serial('invitations', async t => { t.deepEqual(Object.keys(currentState.offerToUsedInvitation), [id]); t.is( currentState.offerToUsedInvitation[id].value[0].description, - INVITATION_MAKERS_DESC, + ORACLE_INVITATION_MAKERS_DESC, ); }); @@ -218,11 +227,12 @@ test.serial('admin price', async t => { const operatorAddress = 'adminPriceAddress'; const { zoe } = t.context.consume; - const { oracleWallets, priceAggregator } = await setupFeedWithWallets(t, [ - operatorAddress, - ]); + const { oracleWallets, governedPriceAggregator } = await setupFeedWithWallets( + t, + [operatorAddress], + ); const wallet = oracleWallets[operatorAddress]; - const adminOfferId = await acceptInvitation(wallet, priceAggregator); + const adminOfferId = await acceptInvitation(wallet, governedPriceAggregator); // Push a new price result ///////////////////////// @@ -239,7 +249,7 @@ test.serial('admin price', async t => { // trigger an aggregation (POLL_INTERVAL=1n in context) await E(manualTimer).tickN(1); - const paPublicFacet = await E(zoe).getPublicFacet(priceAggregator); + const paPublicFacet = E(zoe).getPublicFacet(governedPriceAggregator); const latestRoundSubscriber = await E(paPublicFacet).getRoundStartNotifier(); @@ -252,9 +262,8 @@ test.serial('admin price', async t => { test.serial('errors', async t => { const operatorAddress = 'badInputsAddress'; - const { oracleWallets, priceAggregator } = await setupFeedWithWallets(t, [ - operatorAddress, - ]); + const { oracleWallets, governedPriceAggregator: priceAggregator } = + await setupFeedWithWallets(t, [operatorAddress]); const wallet = oracleWallets[operatorAddress]; const adminOfferId = await acceptInvitation(wallet, priceAggregator); @@ -306,3 +315,209 @@ test.serial('errors', async t => { }, ); }); + +test.serial('govern addOracle', async t => { + const { invitationBrand } = t.context; + + const newOracle = 'agoric1OracleB'; + + const { agoricNames, zoe } = await E.get(t.context.consume); + const wallet = await t.context.simpleProvideWallet(committeeAddress); + const computedState = coalesceUpdates(E(wallet).getUpdatesSubscriber()); + const currentSub = E(wallet).getCurrentSubscriber(); + + const offersFacet = wallet.getOffersFacet(); + + const econCharter = await E(agoricNames).lookup( + 'instance', + 'econCommitteeCharter', + ); + const economicCommittee = await E(agoricNames).lookup( + 'instance', + 'economicCommittee', + ); + await eventLoopIteration(); + + /** + * get invitation details the way a user would + * + * @param {string} desc + * @param {number} len + * @param {{get: (b: Brand) => Amount | undefined}} balances + * @returns {Promise<[{description: string, instance: Instance}]>} + */ + const getInvitationFor = async (desc, len, balances) => + E(E(zoe).getInvitationIssuer()) + .getBrand() + .then(brand => { + /** @type {any} */ + const invitationsAmount = balances.get(brand); + t.is(invitationsAmount?.value.length, len); + return invitationsAmount.value.filter(i => i.description === desc); + }); + + const proposeInvitationDetails = await getInvitationFor( + EC_INVITATION_MAKERS_DESC, + 2, + computedState.balances, + ); + + t.is(proposeInvitationDetails[0].description, EC_INVITATION_MAKERS_DESC); + t.is(proposeInvitationDetails[0].instance, econCharter, 'econCharter'); + t.is( + // @ts-expect-error cast amount kind + currentPurseBalance(await headValue(currentSub), invitationBrand).length, + 2, + 'two invitations deposited', + ); + + // The purse has the invitation to get the makers /////////// + + /** @type {import('@agoric/smart-wallet/src/invitations').PurseInvitationSpec} */ + const getInvMakersSpec = { + source: 'purse', + instance: econCharter, + description: EC_INVITATION_MAKERS_DESC, + }; + + /** @type {import('@agoric/smart-wallet/src/offers').OfferSpec} */ + const invMakersOffer = { + id: 44, + invitationSpec: getInvMakersSpec, + proposal: {}, + }; + + await offersFacet.executeOffer(invMakersOffer); + + /** @type {import('@agoric/smart-wallet/src/smartWallet.js').CurrentWalletRecord} */ + let currentState = await headValue(currentSub); + t.is( + // @ts-expect-error cast amount kind + currentPurseBalance(currentState, invitationBrand).length, + 1, + 'one invitation consumed, one left', + ); + t.deepEqual(Object.keys(currentState.offerToUsedInvitation), ['44']); + t.is( + currentState.offerToUsedInvitation[44].value[0].description, + 'charter member invitation', + ); + + // Call for a vote //////////////////////////////// + + const feed = await E(agoricNames).lookup('instance', 'ATOM-USD price feed'); + t.assert(feed); + + /** @type {import('@agoric/smart-wallet/src/invitations').ContinuingInvitationSpec} */ + const proposeInvitationSpec = { + source: 'continuing', + previousOffer: 44, + invitationMakerName: 'VoteOnApiCall', + invitationArgs: harden([feed, 'addOracles', [[newOracle]], 2n]), + }; + + /** @type {import('@agoric/smart-wallet/src/offers').OfferSpec} */ + const proposalOfferSpec = { + id: 45, + invitationSpec: proposeInvitationSpec, + proposal: {}, + }; + + await offersFacet.executeOffer(proposalOfferSpec); + await eventLoopIteration(); + + // vote ///////////////////////// + + const committeePublic = E(zoe).getPublicFacet(economicCommittee); + const questions = await E(committeePublic).getOpenQuestions(); + const question = E(committeePublic).getQuestion(questions[0]); + const { positions, issue, electionType, questionHandle } = await E( + question, + ).getDetails(); + t.is(electionType, 'api_invocation'); + const yesPosition = harden([positions[0]]); + t.deepEqual(issue, { + apiMethodName: 'addOracles', + methodArgs: [[newOracle]], + }); + t.deepEqual(yesPosition, [ + { apiMethodName: 'addOracles', methodArgs: [[newOracle]] }, + ]); + + const voteInvitationDetails = await getInvitationFor( + 'Voter0', + 1, + computedState.balances, + ); + t.is(voteInvitationDetails.length, 1); + const voteInvitationDetail = voteInvitationDetails[0]; + t.is(voteInvitationDetail.description, 'Voter0'); + t.is(voteInvitationDetail.instance, economicCommittee); + + /** @type {import('@agoric/smart-wallet/src/invitations').PurseInvitationSpec} */ + const getCommitteeInvMakersSpec = { + source: 'purse', + instance: economicCommittee, + description: 'Voter0', + }; + + /** @type {import('@agoric/smart-wallet/src/offers').OfferSpec} */ + const committeeInvMakersOffer = { + id: 46, + invitationSpec: getCommitteeInvMakersSpec, + proposal: {}, + }; + + await offersFacet.executeOffer(committeeInvMakersOffer); + currentState = await headValue(currentSub); + t.is( + // @ts-expect-error cast amount kind + currentPurseBalance(currentState, invitationBrand).length, + 0, + 'last invitation consumed, none left', + ); + t.deepEqual(Object.keys(currentState.offerToUsedInvitation), ['44', '46']); + // 44 tested above + t.is(currentState.offerToUsedInvitation[46].value[0].description, 'Voter0'); + + /** @type {import('@agoric/smart-wallet/src/invitations').ContinuingInvitationSpec} */ + const getVoteSpec = { + source: 'continuing', + previousOffer: 46, + invitationMakerName: 'makeVoteInvitation', + invitationArgs: harden([yesPosition, questionHandle]), + }; + + /** @type {import('@agoric/smart-wallet/src/offers').OfferSpec} */ + const voteOffer = { + id: 47, + invitationSpec: getVoteSpec, + proposal: {}, + }; + + await offersFacet.executeOffer(voteOffer); + + // pass time to exceed the voting deadline + /** @type {ERef} */ + // @ts-expect-error cast mock + const timer = t.context.consume.chainTimerService; + await E(timer).tickN(10); + + // confirm deposit ///////////////////////// + + const oracleWallet = await t.context.simpleProvideWallet(newOracle); + const oracleWalletComputedState = coalesceUpdates( + E(oracleWallet).getUpdatesSubscriber(), + ); + await eventLoopIteration(); + + const oracleInvitationDetails = await getInvitationFor( + ORACLE_INVITATION_MAKERS_DESC, + 1, + oracleWalletComputedState.balances, + ); + t.log(oracleInvitationDetails); + + t.is(oracleInvitationDetails[0].description, ORACLE_INVITATION_MAKERS_DESC); + t.is(oracleInvitationDetails[0].instance, feed, 'matches feed instance'); +});