diff --git a/packages/agoric-cli/src/commands/oracle.js b/packages/agoric-cli/src/commands/oracle.js index 86166d6b2ff..99009bbda53 100644 --- a/packages/agoric-cli/src/commands/oracle.js +++ b/packages/agoric-cli/src/commands/oracle.js @@ -96,7 +96,7 @@ export const makeOracleCommand = async logger => { oracle .command('pushPrice') - .description('add a current price sample') + .description('add a current price sample to a priceAggregator') .option('--offerId [number]', 'Offer id', Number, Date.now()) .requiredOption( '--oracleAdminAcceptOfferId [number]', @@ -114,7 +114,7 @@ export const makeOracleCommand = async logger => { invitationSpec: { source: 'continuing', previousOffer: opts.oracleAdminAcceptOfferId, - invitationMakerName: 'makePushPriceInvitation', + invitationMakerName: 'PushPrice', invitationArgs: harden([opts.price]), }, proposal: {}, @@ -128,6 +128,42 @@ export const makeOracleCommand = async logger => { console.warn('Now execute the prepared offer'); }); + oracle + .command('pushPriceRound') + .description('add a price for a round to a priceAggregatorChainlink') + .option('--offerId [number]', 'Offer id', Number, Date.now()) + .requiredOption( + '--oracleAdminAcceptOfferId [number]', + 'offer that had continuing invitation result', + Number, + ) + .requiredOption('--price [number]', 'price (per unitAmount)', BigInt) + .requiredOption('--roundId [number]', 'round', Number) + .action(async function () { + // @ts-expect-error this implicit any + const opts = this.opts(); + + /** @type {import('../lib/psm.js').OfferSpec} */ + const offer = { + id: Number(opts.offerId), + invitationSpec: { + source: 'continuing', + previousOffer: opts.oracleAdminAcceptOfferId, + invitationMakerName: 'PushPrice', + invitationArgs: harden([ + { unitPrice: opts.price, roundId: opts.roundId }, + ]), + }, + proposal: {}, + }; + + outputAction({ + method: 'executeOffer', + offer, + }); + + console.warn('Now execute the prepared offer'); + }); oracle .command('query') .description('return current aggregated (median) price') diff --git a/packages/agoric-cli/test/agops-oracle-smoketest.sh b/packages/agoric-cli/test/agops-oracle-smoketest.sh index f24176b3fe1..6a978a5f6d6 100644 --- a/packages/agoric-cli/test/agops-oracle-smoketest.sh +++ b/packages/agoric-cli/test/agops-oracle-smoketest.sh @@ -24,6 +24,11 @@ if [ -z "$WALLET" ]; then fi set -x +# this is in economy-template.json in the oracleAddresses list (agoric1dy0yegdsev4xvce3dx7zrz2ad9pesf5svzud6y) +# to use it run `agd keys oracle2 --interactive` and enter this mnenomic: +# dizzy scale gentle good play scene certain acquire approve alarm retreat recycle inch journey fitness grass minimum learn funny way unlock what buzz upon +WALLET2=oracle2 + # Accept invitation to admin an oracle ORACLE_OFFER=$(mktemp -t agops.XXX) bin/agops oracle accept >|"$ORACLE_OFFER" @@ -33,11 +38,18 @@ agoric wallet send --from "$WALLET" --offer "$ORACLE_OFFER" agoric wallet show --from "$WALLET" ORACLE_OFFER_ID=$(jq ".body | fromjson | .offer.id" <"$ORACLE_OFFER") -### Now we have the continuing invitationMakers saved in the wallet +# repeat for oracle2 +ORACLE_OFFER=$(mktemp -t agops.XXX) +bin/agops oracle accept >|"$ORACLE_OFFER" +jq ".body | fromjson" <"$ORACLE_OFFER" +agoric wallet send --from "$WALLET2" --offer "$ORACLE_OFFER" +ORACLE2_OFFER_ID=$(jq ".body | fromjson | .offer.id" <"$ORACLE_OFFER") + +### Now we have the continuing invitationMakers saved in the wallets # Use invitation result, with continuing invitationMakers to propose a vote PROPOSAL_OFFER=$(mktemp -t agops.XXX) -bin/agops oracle pushPrice --price 1.01 --oracleAdminAcceptOfferId "$ORACLE_OFFER_ID" >|"$PROPOSAL_OFFER" +bin/agops oracle pushPriceRound --price 101 --roundId 1 --oracleAdminAcceptOfferId "$ORACLE_OFFER_ID" >|"$PROPOSAL_OFFER" jq ".body | fromjson" <"$PROPOSAL_OFFER" agoric wallet send --from "$WALLET" --offer "$PROPOSAL_OFFER" @@ -45,5 +57,26 @@ agoric wallet send --from "$WALLET" --offer "$PROPOSAL_OFFER" echo "Offer $ORACLE_OFFER_ID should have numWantsSatisfied: 1" agoric wallet show --from "$WALLET" +# verify feed publishing +agd query vstorage keys published.priceFeed + +# verify that the round started +agoric follow :published.priceFeed.ATOM-USD_price_feed.latestRound + +# submit another price in the round from the second oracle +PROPOSAL_OFFER=$(mktemp -t agops.XXX) +bin/agops oracle pushPriceRound --price 201 --roundId 1 --oracleAdminAcceptOfferId "$ORACLE2_OFFER_ID" >|"$PROPOSAL_OFFER" +jq ".body | fromjson" <"$PROPOSAL_OFFER" +agoric wallet send --from "$WALLET2" --offer "$PROPOSAL_OFFER" + +# second round, first oracle +PROPOSAL_OFFER=$(mktemp -t agops.XXX) +bin/agops oracle pushPriceRound --price 1102 --roundId 2 --oracleAdminAcceptOfferId "$ORACLE_OFFER_ID" >|"$PROPOSAL_OFFER" +agoric wallet send --from "$WALLET" --offer "$PROPOSAL_OFFER" +# second round, second oracle +PROPOSAL_OFFER=$(mktemp -t agops.XXX) +bin/agops oracle pushPriceRound --price 1202 --roundId 2 --oracleAdminAcceptOfferId "$ORACLE2_OFFER_ID" >|"$PROPOSAL_OFFER" +agoric wallet send --from "$WALLET2" --offer "$PROPOSAL_OFFER" + # see new price agoric follow :published.priceFeed.ATOM-USD_price_feed diff --git a/packages/cosmic-swingset/economy-template.json b/packages/cosmic-swingset/economy-template.json index 26d0ae284c4..00bf970732d 100644 --- a/packages/cosmic-swingset/economy-template.json +++ b/packages/cosmic-swingset/economy-template.json @@ -110,7 +110,8 @@ { "AGORIC_INSTANCE_NAME": "ATOM-USD price feed", "oracleAddresses": [ - "@PRIMARY_ADDRESS@" + "@PRIMARY_ADDRESS@", + "agoric1dy0yegdsev4xvce3dx7zrz2ad9pesf5svzud6y" ], "IN_BRAND_LOOKUP": [ "agoricNames", diff --git a/packages/inter-protocol/scripts/price-feed-core.js b/packages/inter-protocol/scripts/price-feed-core.js index 0251ef641cb..21d6572fb19 100644 --- a/packages/inter-protocol/scripts/price-feed-core.js +++ b/packages/inter-protocol/scripts/price-feed-core.js @@ -57,8 +57,8 @@ export const defaultProposalBuilder = async ( brandOutRef: brandOut && publishRef(brandOut), priceAggregatorRef: publishRef( install( - '@agoric/zoe/src/contracts/priceAggregator.js', - '../bundles/bundle-priceAggregator.js', + '@agoric/zoe/src/contracts/priceAggregatorChainlink.js', + '../bundles/bundle-priceAggregatorChainlink.js', ), ), }, diff --git a/packages/inter-protocol/scripts/start-local-chain.sh b/packages/inter-protocol/scripts/start-local-chain.sh index cc55ce9de9c..9518e4a1dc1 100755 --- a/packages/inter-protocol/scripts/start-local-chain.sh +++ b/packages/inter-protocol/scripts/start-local-chain.sh @@ -65,3 +65,12 @@ sleep 15 # verify agoric wallet list agoric wallet show --from "$WALLET" + +echo "Repeating for oracle2 account..." +# this is in economy-template.json in the oracleAddresses list (agoric1dy0yegdsev4xvce3dx7zrz2ad9pesf5svzud6y) +# to use it run `agd keys oracle2 --interactive` and enter this mnenomic: +# dizzy scale gentle good play scene certain acquire approve alarm retreat recycle inch journey fitness grass minimum learn funny way unlock what buzz upon +WALLET2=oracle2 +WALLET2_BECH32=$(agd keys show "$WALLET2" --output json | jq -r .address) +make ACCT_ADDR="$WALLET2_BECH32" FUNDS=20000000ubld,20000000ibc/usdc1234 fund-acct +agoric wallet provision --spend --account "$WALLET2" diff --git a/packages/inter-protocol/src/proposals/price-feed-proposal.js b/packages/inter-protocol/src/proposals/price-feed-proposal.js index 761c07af4c1..d7ed2677ebc 100644 --- a/packages/inter-protocol/src/proposals/price-feed-proposal.js +++ b/packages/inter-protocol/src/proposals/price-feed-proposal.js @@ -87,7 +87,7 @@ export const ensureOracleBrands = async ( /** * @param {ChainBootstrapSpace} powers - * @param {{options: {priceFeedOptions: {AGORIC_INSTANCE_NAME: string, oracleAddresses: string[], contractTerms: unknown, IN_BRAND_NAME: string, OUT_BRAND_NAME: string}}}} config + * @param {{options: {priceFeedOptions: {AGORIC_INSTANCE_NAME: string, oracleAddresses: string[], contractTerms: import('@agoric/zoe/src/contracts/priceAggregatorChainlink.js').ChainlinkConfig, IN_BRAND_NAME: string, OUT_BRAND_NAME: string}}}} config */ export const createPriceFeed = async ( { @@ -129,7 +129,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]]} + * @type {[[Brand<'nat'>, Brand<'nat'>], [Installation]]} */ const [[brandIn, brandOut], [priceAggregator]] = await Promise.all([ reserveThenGetNames(E(agoricNamesAdmin).lookupAdmin('oracleBrand'), [ @@ -142,7 +142,6 @@ export const createPriceFeed = async ( ]); const unitAmountIn = await unitAmount(brandIn); - /** @type {import('@agoric/zoe/src/contracts/priceAggregator.js').PriceAggregatorContract['terms']} */ const terms = await deeplyFulfilledObject( harden({ ...contractTerms, @@ -188,11 +187,11 @@ export const createPriceFeed = async ( .then(deleter => E(aggregators).set(terms, { aggregator, deleter })); /** - * Send an invitation to one of the oracles. + * Initialize a new oracle and send an invitation to administer it. * * @param {string} addr */ - const distributeInvitation = async addr => { + const addOracle = async addr => { const invitation = await E(aggregator.creatorFacet).makeOracleInvitation( addr, ); @@ -205,7 +204,7 @@ export const createPriceFeed = async ( }; trace('distributing invitations', oracleAddresses); - await Promise.all(oracleAddresses.map(distributeInvitation)); + await Promise.all(oracleAddresses.map(addOracle)); trace('createPriceFeed complete'); }; @@ -306,7 +305,12 @@ export const startPriceFeeds = async ( priceFeedOptions: { AGORIC_INSTANCE_NAME: `${inBrandName}-${outBrandName} price feed`, contractTerms: { - POLL_INTERVAL: 1n, + minSubmissionCount: 2, + minSubmissionValue: 1, + maxSubmissionCount: 5, + maxSubmissionValue: 99999, + restartDelay: 1n, + timeout: 10, }, oracleAddresses: demoOracleAddresses, IN_BRAND_NAME: inBrandName, diff --git a/packages/inter-protocol/test/smartWallet/contexts.js b/packages/inter-protocol/test/smartWallet/contexts.js index 92c76aef316..47f5c78051a 100644 --- a/packages/inter-protocol/test/smartWallet/contexts.js +++ b/packages/inter-protocol/test/smartWallet/contexts.js @@ -73,10 +73,10 @@ export const makeDefaultTestContext = async (t, makeSpace) => { 'installation', ); const paBundle = await bundleCache.load( - '../zoe/src/contracts/priceAggregator.js', + '../zoe/src/contracts/priceAggregatorChainlink.js', 'priceAggregator', ); - /** @type {Promise>} */ + /** @type {Promise>} */ const paInstallation = E(zoe).install(paBundle); await E(installAdmin).update('priceAggregator', paInstallation); @@ -87,7 +87,12 @@ export const makeDefaultTestContext = async (t, makeSpace) => { priceFeedOptions: { AGORIC_INSTANCE_NAME: `${inBrandName}-${outBrandName} price feed`, contractTerms: { - POLL_INTERVAL: 1n, + minSubmissionCount: 2, + minSubmissionValue: 1, + maxSubmissionCount: 5, + maxSubmissionValue: 99999, + restartDelay: 1n, + timeout: 10, }, oracleAddresses, IN_BRAND_NAME: inBrandName, diff --git a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js index 419ee2f840e..ebfcc76695b 100644 --- a/packages/inter-protocol/test/smartWallet/test-oracle-integration.js +++ b/packages/inter-protocol/test/smartWallet/test-oracle-integration.js @@ -10,13 +10,12 @@ import { import { eventLoopIteration } from '@agoric/zoe/tools/eventLoopIteration.js'; import { E } from '@endo/far'; -import { INVITATION_MAKERS_DESC } from '@agoric/zoe/src/contracts/priceAggregator.js'; -import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; -import { AmountMath } from '@agoric/ertp'; import { coalesceUpdates } from '@agoric/smart-wallet/src/utils.js'; +import { INVITATION_MAKERS_DESC } from '@agoric/zoe/src/contracts/priceAggregatorChainlink.js'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; import { ensureOracleBrands } from '../../src/proposals/price-feed-proposal.js'; -import { makeDefaultTestContext } from './contexts.js'; import { headValue } from '../supports.js'; +import { makeDefaultTestContext } from './contexts.js'; /** * @type {import('ava').TestFn> @@ -87,18 +86,15 @@ test('admin price', async t => { const currentSub = E(wallet).getCurrentSubscriber(); await t.context.simpleCreatePriceFeed([operatorAddress], 'ATOM', 'USD'); - const atomBrand = await E(agoricNames).lookup('oracleBrand', 'ATOM'); - const usdBrand = await E(agoricNames).lookup('oracleBrand', 'USD'); const offersFacet = wallet.getOffersFacet(); + /** @type {import('@agoric/zoe/src/zoeService/utils.js').Instance} */ const priceAggregator = await E(agoricNames).lookup( 'instance', 'ATOM-USD price feed', ); - /** @type {import('@agoric/zoe/src/contracts/priceAggregator.js').PriceAggregatorContract['publicFacet']} */ const paPublicFacet = await E(zoe).getPublicFacet(priceAggregator); - const priceAuthority = await E(paPublicFacet).getPriceAuthority(); /** * get invitation details the way a user would @@ -159,12 +155,15 @@ test('admin price', async t => { // Push a new price result ///////////////////////// + /** @type {import('@agoric/zoe/src/contracts/priceAggregatorChainlink.js').PriceRound} */ + const result = { roundId: 1, unitPrice: 123n }; + /** @type {import('@agoric/smart-wallet/src/invitations.js').ContinuingInvitationSpec} */ const proposeInvitationSpec = { source: 'continuing', previousOffer: 44, - invitationMakerName: 'makePushPriceInvitation', - invitationArgs: harden([123n]), + invitationMakerName: 'PushPrice', + invitationArgs: harden([result]), }; /** @type {import('@agoric/smart-wallet/src/offers').OfferSpec} */ @@ -185,17 +184,10 @@ test('admin price', async t => { // trigger an aggregation (POLL_INTERVAL=1n in context) E(manualTimer).tickN(1); - const quote = await priceAuthority.quoteGiven( - AmountMath.make(atomBrand, 1_000n), - usdBrand, - ); + const latestRoundSubscriber = await E(paPublicFacet).getRoundStartNotifier(); - t.deepEqual(quote.quoteAmount.value[0].amountIn, { - brand: atomBrand, - value: 1_000n, - }); - t.deepEqual(quote.quoteAmount.value[0].amountOut, { - brand: usdBrand, - value: 123_000n, + t.deepEqual((await latestRoundSubscriber.subscribeAfter()).head.value, { + roundId: 1n, + startedAt: 0n, }); }); diff --git a/packages/zoe/scripts/build-bundles.js b/packages/zoe/scripts/build-bundles.js index 8b96921d756..e46be0e0af3 100644 --- a/packages/zoe/scripts/build-bundles.js +++ b/packages/zoe/scripts/build-bundles.js @@ -11,6 +11,10 @@ const sourceToBundle = [ '../src/contracts/priceAggregator.js', '../bundles/bundle-priceAggregator.js', ], + [ + '../src/contracts/priceAggregatorChainlink.js', + '../bundles/bundle-priceAggregatorChainlink.js', + ], ]; await createBundles(sourceToBundle, dirname); diff --git a/packages/zoe/src/contractSupport/priceAuthority.js b/packages/zoe/src/contractSupport/priceAuthority.js index b65a21bf033..8429a1d5536 100644 --- a/packages/zoe/src/contractSupport/priceAuthority.js +++ b/packages/zoe/src/contractSupport/priceAuthority.js @@ -256,7 +256,7 @@ export function makeOnewayPriceAuthorityKit(opts) { amountIn, amountOut: calcAmountOut(amountIn), })); - assert(quote); + assert(quote, 'createQuote returned falsey'); const value = await quote; return harden({ diff --git a/packages/zoe/src/contracts/priceAggregator.js b/packages/zoe/src/contracts/priceAggregator.js index 14082ed121c..457d9da4a31 100644 --- a/packages/zoe/src/contracts/priceAggregator.js +++ b/packages/zoe/src/contracts/priceAggregator.js @@ -1,40 +1,46 @@ /// -import { makeIssuerKit, AssetKind, AmountMath } from '@agoric/ertp'; +import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; import { assertAllDefined } from '@agoric/internal'; -import { E } from '@endo/eventual-send'; -import { Far } from '@endo/marshal'; import { makeNotifierKit, makeStoredPublishKit, observeNotifier, } from '@agoric/notifier'; import { makeLegacyMap } from '@agoric/store'; +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +import '../../tools/types.js'; import { calculateMedian, makeOnewayPriceAuthorityKit, } from '../contractSupport/index.js'; import { + addRatios, + assertParsableNumber, + ceilDivideBy, + floorMultiplyBy, makeRatio, makeRatioFromAmounts, + multiplyRatios, parseRatio, - addRatios, ratioGTE, - floorMultiplyBy, - ceilDivideBy, - multiplyRatios, ratiosSame, - assertParsableNumber, } from '../contractSupport/ratio.js'; -import '../../tools/types.js'; import '@agoric/ertp/src/types-ambient.js'; export const INVITATION_MAKERS_DESC = 'oracle invitation'; -/** @typedef {ParsableNumber | Ratio} Result */ +/** @typedef {ParsableNumber | Ratio} Price */ + +/** @type {(quote: PriceQuote) => PriceDescription} */ +export const priceDescriptionFromQuote = quote => quote.quoteAmount.value[0]; /** + * @deprecated use priceAggregatorChainlink + * * This contract aggregates price values from a set of oracles and provides a * PriceAuthority for their median. This naive method is game-able and so this module * is a stub until we complete what is now in `priceAggregatorChainlink.js`. @@ -53,6 +59,9 @@ export const INVITATION_MAKERS_DESC = 'oracle invitation'; * }} privateArgs */ const start = async (zcf, privateArgs) => { + // brands come from named terms instead of `brands` key because the latter is + // a StandardTerm that Zoe creates from the `issuerKeywordRecord` argument and + // Oracle brands are inert (without issuers or mints). const { timer, POLL_INTERVAL, brandIn, brandOut, unitAmountIn } = zcf.getTerms(); assertAllDefined({ brandIn, brandOut, POLL_INTERVAL, timer, unitAmountIn }); @@ -214,7 +223,7 @@ const start = async (zcf, privateArgs) => { // for each new quote from the priceAuthority, publish it to off-chain storage observeNotifier(priceAuthority.makeQuoteNotifier(unitAmountIn, brandOut), { - updateState: quote => publisher.publish(quote), + updateState: quote => publisher.publish(priceDescriptionFromQuote(quote)), fail: reason => { throw Error(`priceAuthority observer failed: ${reason}`); }, @@ -425,7 +434,7 @@ const start = async (zcf, privateArgs) => { * @param {object} param1 * @param {Notifier} [param1.notifier] optional notifier that produces oracle price submissions * @param {number} [param1.scaleValueOut] - * @returns {Promise<{admin: OracleAdmin, invitationMakers: {makePushPriceInvitation: (price: ParsableNumber) => Promise>} }>} + * @returns {Promise<{admin: OracleAdmin, invitationMakers: {PushPrice: (price: ParsableNumber) => Promise>} }>} */ const offerHandler = async ( seat, @@ -434,7 +443,7 @@ const start = async (zcf, privateArgs) => { const admin = await creatorFacet.initOracle(oracleKey); const invitationMakers = Far('invitation makers', { /** @param {ParsableNumber} price */ - makePushPriceInvitation(price) { + PushPrice(price) { assertParsableNumber(price); return zcf.makeInvitation(cSeat => { cSeat.exit(); @@ -483,7 +492,7 @@ const start = async (zcf, privateArgs) => { /** * @param {Instance | string} [oracleInstance] * @param {OracleQuery} [query] - * @returns {Promise>} + * @returns {Promise>} */ initOracle: async (oracleInstance, query) => { /** @type {OracleKey} */ @@ -526,7 +535,7 @@ const start = async (zcf, privateArgs) => { record.lastSample = ratio; }; - /** @type {OracleAdmin} */ + /** @type {OracleAdmin} */ const oracleAdmin = Far('OracleAdmin', { async delete() { assert(records.has(record), 'Oracle record is already deleted'); diff --git a/packages/zoe/src/contracts/priceAggregatorChainlink.js b/packages/zoe/src/contracts/priceAggregatorChainlink.js index 65f1756bfe9..7bd245e6c2b 100644 --- a/packages/zoe/src/contracts/priceAggregatorChainlink.js +++ b/packages/zoe/src/contracts/priceAggregatorChainlink.js @@ -1,66 +1,128 @@ +/** @file + * Adaptation of Chainlink algorithm to the Agoric platform. + * Modeled on https://github.com/smartcontractkit/chainlink/blob/master/contracts/src/v0.6/FluxAggregator.sol (version?) + */ import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; -import { makeNotifierKit } from '@agoric/notifier'; +import { + makeNotifierKit, + makeStoredPublishKit, + observeNotifier, +} from '@agoric/notifier'; import { makeLegacyMap } from '@agoric/store'; import { Nat, isNat } from '@agoric/nat'; import { TimeMath } from '@agoric/swingset-vat/src/vats/timer/timeMath.js'; - +import { Fail } from '@agoric/assert'; +import { assertAllDefined } from '@agoric/internal'; import { calculateMedian, natSafeMath, makeOnewayPriceAuthorityKit, -} from '../contractSupport'; +} from '../contractSupport/index.js'; + +import '../../tools/types.js'; +import { assertParsableNumber } from '../contractSupport/ratio.js'; +import { + INVITATION_MAKERS_DESC, + priceDescriptionFromQuote, +} from './priceAggregator.js'; -import '../../tools/types'; +export { INVITATION_MAKERS_DESC }; /** - * @typedef {{ roundId: number | undefined, data: string }} Result - * `data` is a string encoded integer (Number.MAX_SAFE_INTEGER) + * @typedef {{ roundId: number | undefined, unitPrice: NatValue }} PriceRound */ const { add, subtract, multiply, floorDivide, ceilDivide, isGTE } = natSafeMath; +/** + * @typedef {object} RoundData + * @property {bigint} roundId the round ID for which data was retrieved + * @property {bigint} answer the answer for the given round + * @property {Timestamp} startedAt the timestamp when the round was started. This is 0 + * if the round hasn't been started yet. + * @property {Timestamp} updatedAt the timestamp when the round last was updated (i.e. + * answer was last computed) + * @property {bigint} answeredInRound the round ID of the round in which the answer + * was computed. answeredInRound may be smaller than roundId when the round + * timed out. answeredInRound is equal to roundId when the round didn't time out + * and was completed regularly. + */ + +/** + * @typedef {Pick} LatestRound + */ + +// Partly documented at https://github.com/smartcontractkit/chainlink/blob/b045416ebca769aa69bde2da23b5109abe07a8b5/contracts/src/v0.6/FluxAggregator.sol#L153 +/** + * @typedef {object} ChainlinkConfig + * @property {number} maxSubmissionCount + * @property {number} minSubmissionCount + * @property {RelativeTime} restartDelay the number of rounds an Oracle has to wait before they can initiate a round + * @property {number} minSubmissionValue an immutable check for a lower bound of what + * submission values are accepted from an oracle + * @property {number} maxSubmissionValue an immutable check for an upper bound of what + * submission values are accepted from an oracle + * @property {number} timeout the number of seconds after the previous round that + * allowed to lapse before allowing an oracle to skip an unfinished round + */ + /** * PriceAuthority for their median. 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 {ZCF<{ + * @param {ZCF, + * brandOut: Brand<'nat'>, + * unitAmountIn?: Amount<'nat'>, * }>} zcf - * @param {object} root0 - * @param {ERef>} [root0.quoteMint] + * @param {{ + * marshaller: Marshaller, + * quoteMint?: ERef>, + * storageNode: ERef, + * }} privateArgs */ -const start = async ( - zcf, - { quoteMint = makeIssuerKit('quote', AssetKind.SET).mint } = {}, -) => { +const start = async (zcf, privateArgs) => { + // brands come from named terms instead of `brands` key because the latter is + // a StandardTerm that Zoe creates from the `issuerKeywordRecord` argument and + // Oracle brands are inert (without issuers or mints). const { - timer, - brands: { In: brandIn, Out: brandOut }, + brandIn, + brandOut, maxSubmissionCount, + maxSubmissionValue, minSubmissionCount, + minSubmissionValue, restartDelay, timeout, - minSubmissionValue, - maxSubmissionValue, + timer, unitAmountIn = AmountMath.make(brandIn, 1n), } = zcf.getTerms(); + assertAllDefined({ + brandIn, + brandOut, + maxSubmissionCount, + maxSubmissionValue, + minSubmissionCount, + minSubmissionValue, + restartDelay, + timeout, + timer, + unitAmountIn, + }); + const unitIn = AmountMath.getValue(brandIn, unitAmountIn); // Get the timer's identity. const timerPresence = await timer; + const quoteMint = + privateArgs.quoteMint || makeIssuerKit('quote', AssetKind.SET).mint; const quoteIssuerRecord = await zcf.saveIssuer( E(quoteMint).getIssuer(), 'Quote', @@ -76,13 +138,23 @@ const start = async ( // --- [begin] Chainlink specific values /** @type {bigint} */ let reportingRoundId = 0n; - const { notifier: roundStartNotifier, updater: roundStartUpdater } = - makeNotifierKit( - // Start with the first round. - add(reportingRoundId, 1), + + const { marshaller, storageNode } = privateArgs; + assertAllDefined({ marshaller, storageNode }); + + // For publishing priceAuthority values to off-chain storage + /** @type {StoredPublishKit} */ + const { publisher: pricePublisher, subscriber: quoteSubscriber } = + makeStoredPublishKit(storageNode, marshaller); + + /** @type {StoredPublishKit} */ + const { publisher: latestRoundPublisher, subscriber: latestRoundSubscriber } = + makeStoredPublishKit( + E(storageNode).makeChildNode('latestRound'), + marshaller, ); - /** @type {LegacyMap>} */ + /** @type {LegacyMap>} */ const oracleStatuses = makeLegacyMap('oracleStatus'); /** @type {LegacyMap>} */ @@ -99,7 +171,7 @@ const start = async ( // --- [end] Chainlink specific values /** - * @param {number} answer + * @param {bigint} answer * @param {Timestamp} startedAt * @param {Timestamp} updatedAt * @param {bigint} answeredInRound @@ -169,8 +241,13 @@ const start = async ( return harden({ quoteAmount, quotePayment }); }; - // FIXME: We throw away the updater but shouldn't. - const { notifier } = makeNotifierKit(); + /** + * This is just a signal that there's a new answer, which is read from `lastValueOutForUnitIn` + * + * @type {NotifierRecord} + */ + const { notifier: answerNotifier, updater: answerUpdator } = + makeNotifierKit(); /** * @typedef {object} OracleRecord @@ -178,12 +255,8 @@ const start = async ( * @property {number} lastSample */ - /** - * @typedef {{}} OracleKey - */ - - /** @type {LegacyMap>} */ - const keyToRecords = makeLegacyMap('oracleKey'); + /** @type {LegacyMap>} */ + const keyToRecords = makeLegacyMap('oracleAddr'); /** * @param {object} param0 @@ -261,24 +334,36 @@ const start = async ( const { priceAuthority } = makeOnewayPriceAuthorityKit({ createQuote: makeCreateQuote(), - notifier, + notifier: answerNotifier, quoteIssuer: quoteKit.issuer, timer, actualBrandIn: brandIn, actualBrandOut: brandOut, }); + // for each new quote from the priceAuthority, publish it to off-chain storage + observeNotifier(priceAuthority.makeQuoteNotifier(unitAmountIn, brandOut), { + updateState: quote => + pricePublisher.publish(priceDescriptionFromQuote(quote)), + fail: reason => { + throw Error(`priceAuthority observer failed: ${reason}`); + }, + finish: done => { + throw Error(`priceAuthority observer died: ${done}`); + }, + }); + /** - * @param {bigint} _roundId + * @param {bigint} roundId * @param {Timestamp} blockTimestamp */ - const timedOut = (_roundId, blockTimestamp) => { - if (!details.has(_roundId) || !rounds.has(_roundId)) { + const timedOut = (roundId, blockTimestamp) => { + if (!details.has(roundId) || !rounds.has(roundId)) { return false; } - const startedAt = rounds.get(_roundId).startedAt; - const roundTimeout = details.get(_roundId).roundTimeout; + const 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. @@ -295,146 +380,156 @@ const start = async ( }; /** - * @param {bigint} _roundId + * @param {bigint} roundId * @param {Timestamp} blockTimestamp */ - const updateTimedOutRoundInfo = (_roundId, blockTimestamp) => { + const updateTimedOutRoundInfo = (roundId, blockTimestamp) => { // round 0 is non-existent, so we avoid that case -- round 1 is ignored // because we can't copy from round 0 in that case - if (_roundId === 0n || _roundId === 1n) { + if (roundId === 0n || roundId === 1n) { return; } - const roundTimedOut = timedOut(_roundId, blockTimestamp); + const roundTimedOut = timedOut(roundId, blockTimestamp); if (!roundTimedOut) return; - const prevId = subtract(_roundId, 1); + const prevId = subtract(roundId, 1); - const round = rounds.get(_roundId); + const round = rounds.get(roundId); round.answer = rounds.get(prevId).answer; round.answeredInRound = rounds.get(prevId).answeredInRound; round.updatedAt = blockTimestamp; - details.delete(_roundId); + details.delete(roundId); }; /** - * @param {bigint} _roundId + * @param {bigint} roundId */ - const newRound = _roundId => { - return _roundId === add(reportingRoundId, 1); + const newRound = roundId => { + return roundId === add(reportingRoundId, 1); }; /** - * @param {bigint} _roundId + * @param {bigint} roundId * @param {Timestamp} blockTimestamp */ - const initializeNewRound = (_roundId, blockTimestamp) => { - updateTimedOutRoundInfo(subtract(_roundId, 1), blockTimestamp); + const initializeNewRound = (roundId, blockTimestamp) => { + newRound(roundId) || Fail`Round ${roundId} already started`; - reportingRoundId = _roundId; - roundStartUpdater.updateState(reportingRoundId); + updateTimedOutRoundInfo(subtract(roundId, 1), blockTimestamp); + + reportingRoundId = roundId; + latestRoundPublisher.publish({ + roundId: reportingRoundId, + startedAt: blockTimestamp, + }); details.init( - _roundId, + roundId, makeRoundDetails([], maxSubmissionCount, minSubmissionCount, timeout), ); rounds.init( - _roundId, + roundId, makeRound( - /* answer = */ 0, + /* answer = */ 0n, /* startedAt = */ 0n, /* updatedAt = */ 0n, /* answeredInRound = */ 0n, ), ); - rounds.get(_roundId).startedAt = blockTimestamp; + rounds.get(roundId).startedAt = blockTimestamp; }; /** - * @param {bigint} _roundId - * @param {OracleKey} _oracle + * @param {bigint} roundId + * @param {string} oracleAddr * @param {Timestamp} blockTimestamp */ - const oracleInitializeNewRound = (_roundId, _oracle, blockTimestamp) => { - if (!newRound(_roundId)) return; - const lastStarted = oracleStatuses.get(_oracle).lastStartedRound; // cache storage reads - if (_roundId <= add(lastStarted, restartDelay) && lastStarted !== 0n) - return; - initializeNewRound(_roundId, blockTimestamp); + const proposeNewRound = (roundId, oracleAddr, blockTimestamp) => { + if (!newRound(roundId)) return; + const lastStarted = oracleStatuses.get(oracleAddr).lastStartedRound; // cache storage reads + if (roundId <= add(lastStarted, restartDelay) && lastStarted !== 0n) return; + initializeNewRound(roundId, blockTimestamp); - oracleStatuses.get(_oracle).lastStartedRound = _roundId; + oracleStatuses.get(oracleAddr).lastStartedRound = roundId; }; /** - * @param {bigint} _roundId + * @param {bigint} roundId */ - const acceptingSubmissions = _roundId => { - return details.has(_roundId) && details.get(_roundId).maxSubmissions !== 0; + const acceptingSubmissions = roundId => { + return details.has(roundId) && details.get(roundId).maxSubmissions !== 0; }; /** - * @param {bigint} _submission - * @param {bigint} _roundId - * @param {OracleKey} _oracle + * @param {bigint} submission + * @param {bigint} roundId + * @param {string} oracleAddr */ - const recordSubmission = (_submission, _roundId, _oracle) => { - if (!acceptingSubmissions(_roundId)) { + const recordSubmission = (submission, roundId, oracleAddr) => { + if (!acceptingSubmissions(roundId)) { console.error('round not accepting submissions'); return false; } - details.get(_roundId).submissions.push(_submission); - oracleStatuses.get(_oracle).lastReportedRound = _roundId; - oracleStatuses.get(_oracle).latestSubmission = _submission; + details.get(roundId).submissions.push(submission); + oracleStatuses.get(oracleAddr).lastReportedRound = roundId; + oracleStatuses.get(oracleAddr).latestSubmission = submission; return true; }; /** - * @param {bigint} _roundId + * @param {bigint} roundId * @param {Timestamp} blockTimestamp */ - const updateRoundAnswer = (_roundId, blockTimestamp) => { + const updateRoundAnswer = (roundId, blockTimestamp) => { if ( - details.get(_roundId).submissions.length < - details.get(_roundId).minSubmissions + details.get(roundId).submissions.length < + details.get(roundId).minSubmissions ) { return [false, 0]; } + /** @type {bigint | undefined} */ const newAnswer = calculateMedian( details - .get(_roundId) + .get(roundId) .submissions.filter(sample => isNat(sample) && sample > 0n), { add, divide: floorDivide, isGTE }, ); - rounds.get(_roundId).answer = newAnswer; - rounds.get(_roundId).updatedAt = blockTimestamp; - rounds.get(_roundId).answeredInRound = _roundId; + assert(newAnswer, 'insufficient samples'); + + rounds.get(roundId).answer = newAnswer; + rounds.get(roundId).updatedAt = blockTimestamp; + rounds.get(roundId).answeredInRound = roundId; + + lastValueOutForUnitIn = newAnswer; + answerUpdator.updateState(undefined); return [true, newAnswer]; }; /** - * @param {bigint} _roundId + * @param {bigint} roundId */ - const deleteRoundDetails = _roundId => { + const deleteRoundDetails = roundId => { if ( - details.get(_roundId).submissions.length < - details.get(_roundId).maxSubmissions + details.get(roundId).submissions.length < + details.get(roundId).maxSubmissions ) return; - details.delete(_roundId); + details.delete(roundId); }; /** - * @param {bigint} _roundId + * @param {bigint} roundId */ - const validRoundId = _roundId => { - return _roundId <= ROUND_MAX; + const validRoundId = roundId => { + return roundId <= ROUND_MAX; }; /** @@ -444,75 +539,76 @@ const start = async ( }; /** - * @param {bigint} _roundId + * @param {bigint} roundId * @param {Timestamp} blockTimestamp */ - const supersedable = (_roundId, blockTimestamp) => { + const supersedable = (roundId, blockTimestamp) => { return ( - rounds.has(_roundId) && - (TimeMath.absValue(rounds.get(_roundId).updatedAt) > 0n || - timedOut(_roundId, blockTimestamp)) + rounds.has(roundId) && + (TimeMath.absValue(rounds.get(roundId).updatedAt) > 0n || + timedOut(roundId, blockTimestamp)) ); }; /** - * @param {bigint} _roundId - * @param {bigint} _rrId + * @param {bigint} roundId + * @param {bigint} rrId reporting round ID */ - const previousAndCurrentUnanswered = (_roundId, _rrId) => { - return add(_roundId, 1) === _rrId && rounds.get(_rrId).updatedAt === 0n; + const previousAndCurrentUnanswered = (roundId, rrId) => { + return add(roundId, 1) === rrId && rounds.get(rrId).updatedAt === 0n; }; /** - * @param {OracleKey} _oracle - * @param {bigint} _roundId + * @param {string} oracleAddr + * @param {bigint} roundId * @param {Timestamp} blockTimestamp + * @returns {string?} error message, if there is one */ - const validateOracleRound = (_oracle, _roundId, blockTimestamp) => { + const validateOracleRound = (oracleAddr, roundId, blockTimestamp) => { // cache storage reads - const startingRound = oracleStatuses.get(_oracle).startingRound; + const startingRound = oracleStatuses.get(oracleAddr).startingRound; const rrId = reportingRoundId; let canSupersede = true; - if (_roundId > 1n) { - canSupersede = supersedable(subtract(_roundId, 1), blockTimestamp); + if (roundId > 1n) { + canSupersede = supersedable(subtract(roundId, 1), blockTimestamp); } if (startingRound === 0n) return 'not enabled oracle'; - if (startingRound > _roundId) return 'not yet enabled oracle'; - if (oracleStatuses.get(_oracle).endingRound < _roundId) + if (startingRound > roundId) return 'not yet enabled oracle'; + if (oracleStatuses.get(oracleAddr).endingRound < roundId) return 'no longer allowed oracle'; - if (oracleStatuses.get(_oracle).lastReportedRound >= _roundId) + if (oracleStatuses.get(oracleAddr).lastReportedRound >= roundId) return 'cannot report on previous rounds'; if ( - _roundId !== rrId && - _roundId !== add(rrId, 1) && - !previousAndCurrentUnanswered(_roundId, rrId) + roundId !== rrId && + roundId !== add(rrId, 1) && + !previousAndCurrentUnanswered(roundId, rrId) ) return 'invalid round to report'; - if (_roundId !== 1n && !canSupersede) + if (roundId !== 1n && !canSupersede) return 'previous round not supersedable'; - return ''; + return null; }; /** - * @param {OracleKey} _oracle - * @param {bigint} _roundId + * @param {string} oracleAddr + * @param {bigint} roundId */ - const delayed = (_oracle, _roundId) => { - const lastStarted = oracleStatuses.get(_oracle).lastStartedRound; - return _roundId > add(lastStarted, restartDelay) || lastStarted === 0n; + const delayed = (oracleAddr, roundId) => { + const lastStarted = oracleStatuses.get(oracleAddr).lastStartedRound; + return roundId > add(lastStarted, restartDelay) || lastStarted === 0n; }; /** * a method to provide all current info oracleStatuses need. Intended only * only to be callable by oracleStatuses. Not for use by contracts to read state. * - * @param {OracleKey} _oracle + * @param {string} oracleAddr * @param {Timestamp} blockTimestamp */ - const oracleRoundStateSuggestRound = (_oracle, blockTimestamp) => { - const oracle = oracleStatuses.get(_oracle); + const oracleRoundStateSuggestRound = (oracleAddr, blockTimestamp) => { + const oracle = oracleStatuses.get(oracleAddr); const shouldSupersede = oracle.lastReportedRound === reportingRoundId || @@ -526,7 +622,7 @@ const start = async ( let eligibleToSubmit; if (canSupersede && shouldSupersede) { roundId = add(reportingRoundId, 1); - eligibleToSubmit = delayed(_oracle, roundId); + eligibleToSubmit = delayed(oracleAddr, roundId); } else { roundId = reportingRoundId; eligibleToSubmit = acceptingSubmissions(roundId); @@ -544,8 +640,8 @@ const start = async ( roundTimeout = 0; } - const error = validateOracleRound(_oracle, roundId, blockTimestamp); - if (error.length !== 0) { + const error = validateOracleRound(oracleAddr, roundId, blockTimestamp); + if (error !== null) { eligibleToSubmit = false; } @@ -560,124 +656,167 @@ const start = async ( }; /** - * @param {OracleKey} _oracle - * @param {bigint} _queriedRoundId + * @param {string} oracleAddr + * @param {bigint} queriedRoundId * @param {Timestamp} blockTimestamp */ const eligibleForSpecificRound = ( - _oracle, - _queriedRoundId, + oracleAddr, + queriedRoundId, blockTimestamp, ) => { - const error = validateOracleRound(_oracle, _queriedRoundId, blockTimestamp); - if (TimeMath.absValue(rounds.get(_queriedRoundId).startedAt) > 0n) { - return acceptingSubmissions(_queriedRoundId) && error.length === 0; + const error = validateOracleRound( + oracleAddr, + queriedRoundId, + blockTimestamp, + ); + if (TimeMath.absValue(rounds.get(queriedRoundId).startedAt) > 0n) { + return acceptingSubmissions(queriedRoundId) && error === null; } else { - return delayed(_oracle, _queriedRoundId) && error.length === 0; + return delayed(oracleAddr, queriedRoundId) && error === null; } }; /** - * @param {OracleKey} _oracle + * @param {string} oracleAddr */ - const getStartingRound = _oracle => { + const getStartingRound = oracleAddr => { const currentRound = reportingRoundId; if ( currentRound !== 0n && - currentRound === oracleStatuses.get(_oracle).endingRound + currentRound === oracleStatuses.get(oracleAddr).endingRound ) { return currentRound; } return add(currentRound, 1); }; - /** - * @type {Omit & { - * initOracle: (instance) => Promise>, - * getRoundData(_roundId: BigInt): Promise, - * oracleRoundState(_oracle: OracleKey, _queriedRoundId: BigInt): Promise - * }} - */ const creatorFacet = Far('PriceAggregatorChainlinkCreatorFacet', { - deleteOracle: async oracleKey => { - const records = keyToRecords.get(oracleKey); + /** + * 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} oracleAddr Bech32 of oracle operator smart wallet + */ + makeOracleInvitation: async oracleAddr => { + /** + * 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 + * @returns {Promise<{admin: OracleAdmin, invitationMakers: {PushPrice: (result: PriceRound) => Promise>} }>} + */ + const offerHandler = async seat => { + const admin = await creatorFacet.initOracle(oracleAddr); + const invitationMakers = Far('invitation makers', { + /** @param {PriceRound} result */ + PushPrice(result) { + assertParsableNumber(result.unitPrice); + return zcf.makeInvitation(cSeat => { + cSeat.exit(); + admin.pushResult(result); + }, 'PushPrice'); + }, + }); + seat.exit(); + + return harden({ + admin, + + invitationMakers, + }); + }; + + return zcf.makeInvitation(offerHandler, INVITATION_MAKERS_DESC); + }, + /** @param {string} oracleAddr */ + deleteOracle: async oracleAddr => { + const records = keyToRecords.get(oracleAddr); for (const record of records) { records.delete(record); } - oracleStatuses.delete(oracleKey); + oracleStatuses.delete(oracleAddr); // We should remove the entry entirely, as it is empty. - keyToRecords.delete(oracleKey); + keyToRecords.delete(oracleAddr); }, // unlike the median case, no query argument is passed, since polling behavior is undesired - async initOracle(oracleInstance) { - /** @type {OracleKey} */ - const oracleKey = oracleInstance || Far('oracleKey', {}); - + /** + * @param {string} oracleAddr Bech32 of oracle operator smart wallet + * @returns {Promise>} + */ + async initOracle(oracleAddr) { + assert.typeof(oracleAddr, 'string'); /** @type {OracleRecord} */ const record = { querier: undefined, lastSample: 0 }; /** @type {Set} */ let records; - if (keyToRecords.has(oracleKey)) { - records = keyToRecords.get(oracleKey); + if (keyToRecords.has(oracleAddr)) { + records = keyToRecords.get(oracleAddr); } else { records = new Set(); - keyToRecords.init(oracleKey, records); + keyToRecords.init(oracleAddr, records); const oracleStatus = makeOracleStatus( - /* startingRound = */ getStartingRound(oracleKey), + /* startingRound = */ getStartingRound(oracleAddr), /* endingRound = */ ROUND_MAX, /* lastReportedRound = */ 0n, /* lastStartedRound = */ 0n, /* latestSubmission = */ 0n, /* index = */ oracleStatuses.getSize(), ); - oracleStatuses.init(oracleKey, oracleStatus); + oracleStatuses.init(oracleAddr, oracleStatus); } records.add(record); - /** @param {Result} result */ + /** @param {PriceRound} result */ const pushResult = async ({ - roundId: _roundIdRaw = undefined, - data: _submissionRaw, + roundId: roundIdRaw = undefined, + unitPrice: valueRaw, }) => { - const parsedSubmission = Nat(parseInt(_submissionRaw, 10)); + const value = Nat(valueRaw); const blockTimestamp = await E(timer).getCurrentTimestamp(); let roundId; - if (_roundIdRaw === undefined) { + if (roundIdRaw === undefined) { const suggestedRound = oracleRoundStateSuggestRound( - oracleKey, + oracleAddr, blockTimestamp, ); roundId = suggestedRound.eligibleForSpecificRound ? suggestedRound.queriedRoundId : add(suggestedRound.queriedRoundId, 1); } else { - roundId = Nat(_roundIdRaw); + roundId = Nat(roundIdRaw); } - const error = validateOracleRound(oracleKey, roundId, blockTimestamp); - if (!(parsedSubmission >= minSubmissionValue)) { - console.error('value below minSubmissionValue'); + const error = validateOracleRound(oracleAddr, roundId, blockTimestamp); + if (!(value >= minSubmissionValue)) { + console.error('value below minSubmissionValue', minSubmissionValue); return; } - if (!(parsedSubmission <= maxSubmissionValue)) { + if (!(value <= maxSubmissionValue)) { console.error('value above maxSubmissionValue'); return; } - if (!(error.length === 0)) { + if (!(error === null)) { console.error(error); return; } - oracleInitializeNewRound(roundId, oracleKey, blockTimestamp); - const recorded = recordSubmission(parsedSubmission, roundId, oracleKey); + proposeNewRound(roundId, oracleAddr, blockTimestamp); + const recorded = recordSubmission(value, roundId, oracleAddr); if (!recorded) { return; } @@ -689,36 +828,26 @@ const start = async ( // Obtain the oracle's publicFacet. assert(records.has(record), 'Oracle record is already deleted'); - /** @type {OracleAdmin} */ - const oracleAdmin = { + /** @type {OracleAdmin} */ + const oracleAdmin = Far('OracleAdmin', { async delete() { assert(records.has(record), 'Oracle record is already deleted'); // The actual deletion is synchronous. - oracleStatuses.delete(oracleKey); + oracleStatuses.delete(oracleAddr); records.delete(record); if ( records.size === 0 && - keyToRecords.has(oracleKey) && - keyToRecords.get(oracleKey) === records + keyToRecords.has(oracleAddr) && + keyToRecords.get(oracleAddr) === records ) { // We should remove the entry entirely, as it is empty. - keyToRecords.delete(oracleKey); + keyToRecords.delete(oracleAddr); } }, - async pushResult({ - roundId: _roundIdRaw = undefined, - data: _submissionRaw, - }) { - // Sample of NaN, 0, or negative numbers get culled in - // the median calculation. - pushResult({ - roundId: _roundIdRaw, - data: _submissionRaw, - }).catch(console.error); - }, - }; + pushResult, + }); return harden(oracleAdmin); }, @@ -727,22 +856,12 @@ const start = async ( * consumers are encouraged to check * that they're receiving fresh data by inspecting the updatedAt and * answeredInRound return values. - * return is: [roundId, answer, startedAt, updatedAt, answeredInRound], where - * roundId is the round ID for which data was retrieved - * answer is the answer for the given round - * startedAt is the timestamp when the round was started. This is 0 - * if the round hasn't been started yet. - * updatedAt is the timestamp when the round last was updated (i.e. - * answer was last computed) - * answeredInRound is the round ID of the round in which the answer - * was computed. answeredInRound may be smaller than roundId when the round - * timed out. answeredInRound is equal to roundId when the round didn't time out - * and was completed regularly. * - * @param {bigint} _roundIdRaw + * @param {bigint | number} roundIdRaw + * @returns {Promise} */ - async getRoundData(_roundIdRaw) { - const roundId = Nat(_roundIdRaw); + async getRoundData(roundIdRaw) { + const roundId = Nat(roundIdRaw); assert(rounds.has(roundId), V3_NO_DATA_ERROR); @@ -763,28 +882,28 @@ const start = async ( * 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 {OracleKey} _oracle - * @param {bigint} _queriedRoundId + * @param {string} oracleAddr Bech32 of oracle operator smart wallet + * @param {bigint} queriedRoundId */ - async oracleRoundState(_oracle, _queriedRoundId) { + async oracleRoundState(oracleAddr, queriedRoundId) { const blockTimestamp = await E(timer).getCurrentTimestamp(); - if (_queriedRoundId > 0) { - const round = rounds.get(_queriedRoundId); - const detail = details.get(_queriedRoundId); + if (queriedRoundId > 0) { + const round = rounds.get(queriedRoundId); + const detail = details.get(queriedRoundId); return { eligibleForSpecificRound: eligibleForSpecificRound( - _oracle, - _queriedRoundId, + oracleAddr, + queriedRoundId, blockTimestamp, ), - queriedRoundId: _queriedRoundId, - oracleStatus: oracleStatuses.get(_oracle).latestSubmission, + queriedRoundId, + oracleStatus: oracleStatuses.get(oracleAddr).latestSubmission, startedAt: round.startedAt, roundTimeout: detail.roundTimeout, oracleCount: oracleCount(), }; } else { - return oracleRoundStateSuggestRound(_oracle, blockTimestamp); + return oracleRoundStateSuggestRound(oracleAddr, blockTimestamp); } }, }); @@ -793,8 +912,9 @@ const start = async ( getPriceAuthority() { return priceAuthority; }, + getSubscriber: () => quoteSubscriber, getRoundStartNotifier() { - return roundStartNotifier; + return latestRoundSubscriber; }, }); diff --git a/packages/zoe/test/unitTests/contracts/test-priceAggregator.js b/packages/zoe/test/unitTests/contracts/test-priceAggregator.js index bbfccb08789..e0b8a668d75 100644 --- a/packages/zoe/test/unitTests/contracts/test-priceAggregator.js +++ b/packages/zoe/test/unitTests/contracts/test-priceAggregator.js @@ -89,9 +89,8 @@ const makePublicationChecker = async (t, aggregatorPublicFacet) => { /** @param {{timestamp: bigint, amountOut: any}} spec */ async nextMatches({ timestamp, amountOut }) { const { value } = await E(publications).next(); - const quoteValue = value.quoteAmount.value[0]; - t.is(quoteValue.timestamp, timestamp, 'wrong timestamp'); - t.is(quoteValue.amountOut.value, amountOut, 'wrong amountOut value'); + t.is(value.timestamp, timestamp, 'wrong timestamp'); + t.is(value.amountOut.value, amountOut, 'wrong amountOut value'); }, }; }; @@ -560,7 +559,7 @@ test('oracle continuing invitation', async t => { const or1 = E(zoe).offer(inv1, undefined, undefined, { notifier: oracle1 }); const oracleAdmin1 = E(or1).getOfferResult(); const invitationMakers = await E.get(oracleAdmin1).invitationMakers; - t.true('makePushPriceInvitation' in invitationMakers); + t.true('PushPrice' in invitationMakers); const amountIn = AmountMath.make(brandIn, 1000000n); const makeQuoteValue = (timestamp, valueOut) => [ @@ -576,7 +575,7 @@ test('oracle continuing invitation', async t => { E(aggregator.publicFacet).getPriceAuthority(), ).makeQuoteNotifier(amountIn, brandOut); - const invPrice = await E(invitationMakers).makePushPriceInvitation('1234'); + const invPrice = await E(invitationMakers).PushPrice('1234'); const invPriceResult = await E(zoe).offer(invPrice); t.deepEqual(await E(invPriceResult).numWantsSatisfied(), 1); @@ -1096,21 +1095,13 @@ test('storage', async t => { 'mockChainStorageRoot.priceAggregator.ATOM-USD_price_feed', ), { - quoteAmount: { - brand: { iface: 'Alleged: quote brand' }, - value: [ - { - amountIn: { brand: { iface: 'Alleged: $ATOM brand' }, value: 1n }, - amountOut: { - brand: { iface: 'Alleged: $USD brand' }, - value: 1020n, - }, - timer: { iface: 'Alleged: ManualTimer' }, - timestamp: 1n, - }, - ], + amountIn: { brand: { iface: 'Alleged: $ATOM brand' }, value: 1n }, + amountOut: { + brand: { iface: 'Alleged: $USD brand' }, + value: 1020n, }, - quotePayment: { iface: 'Alleged: quote payment' }, + timer: { iface: 'Alleged: ManualTimer' }, + timestamp: 1n, }, ); }); diff --git a/packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js b/packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js index cf3f9b8088b..77d09fa8838 100644 --- a/packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js +++ b/packages/zoe/test/unitTests/contracts/test-priceAggregatorChainlink.js @@ -7,178 +7,140 @@ import bundleSource from '@endo/bundle-source'; import { E } from '@endo/eventual-send'; import { Far } from '@endo/marshal'; -import { makeIssuerKit, AssetKind, AmountMath } from '@agoric/ertp'; - +import { makeIssuerKit, AssetKind } from '@agoric/ertp'; + +import { + eventLoopIteration, + makeFakeMarshaller, +} from '@agoric/notifier/tools/testSupports.js'; +import { makeMockChainStorageRoot } from '@agoric/vats/tools/storage-test-utils.js'; +import { subscribeEach } from '@agoric/notifier'; import { makeFakeVatAdmin } from '../../../tools/fakeVatAdmin.js'; import { makeZoeKit } from '../../../src/zoeService/zoe.js'; import buildManualTimer from '../../../tools/manualTimer.js'; import '../../../src/contracts/exported.js'; -/** @type {import('ava').TestFn} */ +/** @type {import('ava').TestFn>>} */ const test = unknownTest; -/** - * @callback MakeFakePriceOracle - * @param {ExecutionContext} t - * @param {bigint} [valueOut] - * @returns {Promise} - */ - -/** - * @typedef {object} TestContext - * @property {ZoeService} zoe - * @property {MakeFakePriceOracle} makeFakePriceOracle - * @property {(POLL_INTERVAL: bigint) => Promise} makeMedianAggregator - * @property {Amount} feeAmount - * @property {IssuerKit} link - * - * @typedef {import('ava').ExecutionContext} ExecutionContext - */ - const filename = new URL(import.meta.url).pathname; const dirname = path.dirname(filename); const oraclePath = `${dirname}/../../../src/contracts/oracle.js`; const aggregatorPath = `${dirname}/../../../src/contracts/priceAggregatorChainlink.js`; -test.before( - // comment to maintain formatting for git blame - 'setup aggregator and oracles', - async ot => { - // Outside of tests, we should use the long-lived Zoe on the - // testnet. In this test, we must create a new Zoe. - const { admin, vatAdminState } = makeFakeVatAdmin(); - const { zoeService: zoe } = makeZoeKit(admin); - - // Pack the contracts. - const oracleBundle = await bundleSource(oraclePath); - const aggregatorBundle = await bundleSource(aggregatorPath); - - // Install the contract on Zoe, getting an installation. We can - // use this installation to look up the code we installed. Outside - // of tests, we can also send the installation to someone - // else, and they can use it to create a new contract instance - // using the same code. - vatAdminState.installBundle('b1-oracle', oracleBundle); - /** @type {Installation} */ - const oracleInstallation = await E(zoe).installBundleID('b1-oracle'); - vatAdminState.installBundle('b1-aggregator', aggregatorBundle); - const aggregatorInstallation = await E(zoe).installBundleID( - 'b1-aggregator', - ); +const defaultConfig = { + maxSubmissionCount: 1000, + minSubmissionCount: 2, + restartDelay: 5, + timeout: 10, + minSubmissionValue: 100, + maxSubmissionValue: 10000, +}; - const link = makeIssuerKit('$LINK', AssetKind.NAT); - const usd = makeIssuerKit('$USD', AssetKind.NAT); - - /** @type {MakeFakePriceOracle} */ - const makeFakePriceOracle = async (t, valueOut = undefined) => { - /** @type {OracleHandler} */ - const oracleHandler = Far('OracleHandler', { - async onQuery({ increment }, _fee) { - assert(valueOut); - assert(increment); - valueOut += increment; - return harden({ - reply: `${valueOut}`, - requiredFee: AmountMath.makeEmpty(link.brand), - }); - }, - onError(query, reason) { - console.error('query', query, 'failed with', reason); - }, - onReply(_query, _reply) {}, - }); - - const startResult = await E(zoe).startInstance( - oracleInstallation, - { Fee: link.issuer }, - { oracleDescription: 'myOracle' }, - ); - const creatorFacet = await E(startResult.creatorFacet).initialize({ - oracleHandler, - }); - - return harden({ - ...startResult, - creatorFacet, - }); - }; - - const makeChainlinkAggregator = async ( +/** + * + * @param {Promise>} subscriber + */ +export const subscriberSubkey = subscriber => { + return E(subscriber) + .getStoreKey() + .then(storeKey => storeKey.storeSubkey); +}; + +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); + + // Pack the contracts. + const oracleBundle = await bundleSource(oraclePath); + const aggregatorBundle = await bundleSource(aggregatorPath); + + // Install the contract on Zoe, getting an installation. We can + // use this installation to look up the code we installed. Outside + // of tests, we can also send the installation to someone + // else, and they can use it to create a new contract instance + // using the same code. + vatAdminState.installBundle('b1-oracle', oracleBundle); + 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); + + async function makeChainlinkAggregator(config) { + const { maxSubmissionCount, + maxSubmissionValue, minSubmissionCount, + minSubmissionValue, restartDelay, timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ) => { - const timer = buildManualTimer(() => {}); - - const aggregator = await E(zoe).startInstance( - aggregatorInstallation, - { In: link.issuer, Out: usd.issuer }, - { - timer, - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - }, - ); - return aggregator; - }; - ot.context.zoe = zoe; - ot.context.makeFakePriceOracle = makeFakePriceOracle; - ot.context.makeChainlinkAggregator = makeChainlinkAggregator; - }, -); + } = 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 aggregator = await E(zoe).startInstance( + aggregatorInstallation, + undefined, + { + timer, + brandIn: link.brand, + brandOut: usd.brand, + maxSubmissionCount, + minSubmissionCount, + restartDelay, + timeout, + minSubmissionValue, + maxSubmissionValue, + }, + { + marshaller, + storageNode: E(storageNode).makeChildNode('LINK-USD_price_feed'), + }, + ); + return { ...aggregator, mockStorageRoot }; + } + + return { makeChainlinkAggregator, zoe }; +}; + +test.before('setup aggregator and oracles', async t => { + t.context = await makeContext(); +}); test('basic', async t => { - const { makeFakePriceOracle, zoe } = t.context; - - const maxSubmissionCount = 1000; - const minSubmissionCount = 2; - const restartDelay = 5; - const timeout = 10; - const description = 'Chainlink oracles'; - const minSubmissionValue = 100; - const maxSubmissionValue = 10000; - - const aggregator = await t.context.makeChainlinkAggregator( - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ); - const { timer: oracleTimer } = await E(zoe).getTerms(aggregator.instance); + const { zoe } = t.context; - const priceOracleA = await makeFakePriceOracle(t); - const priceOracleB = await makeFakePriceOracle(t); - const priceOracleC = await makeFakePriceOracle(t); + 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 E(aggregator.creatorFacet).initOracle( - priceOracleA.instance, + 'agorice1priceOracleA', ); const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( - priceOracleB.instance, + 'agorice1priceOracleB', ); const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( - priceOracleC.instance, + 'agorice1priceOracleC', ); // ----- round 1: basic consensus await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); - await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); - await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await E(pricePushAdminA).pushResult({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminB).pushResult({ roundId: 1, unitPrice: 200n }); + await E(pricePushAdminC).pushResult({ roundId: 1, unitPrice: 300n }); await oracleTimer.tick(); const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); @@ -190,9 +152,9 @@ test('basic', async t => { // the restartDelay, which means its submission will be IGNORED. this means the median // should ONLY be between the OracleB and C values, which is why it is 25000 await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 2, data: '1000' }); - await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '3000' }); + await E(pricePushAdminA).pushResult({ roundId: 2, unitPrice: 1000n }); + await E(pricePushAdminB).pushResult({ roundId: 2, unitPrice: 2000n }); + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 3000n }); await oracleTimer.tick(); const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); @@ -204,9 +166,9 @@ test('basic', async t => { // 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).pushResult({ roundId: 3, data: '5000' }); - await E(pricePushAdminA).pushResult({ roundId: 3, data: '4000' }); - await E(pricePushAdminB).pushResult({ roundId: 3, data: '6000' }); + await E(pricePushAdminC).pushResult({ roundId: 3, unitPrice: 5000n }); + await E(pricePushAdminA).pushResult({ roundId: 3, unitPrice: 4000n }); + await E(pricePushAdminB).pushResult({ roundId: 3, unitPrice: 6000n }); await oracleTimer.tick(); const round1Attempt3 = await E(aggregator.creatorFacet).getRoundData(1); @@ -216,48 +178,34 @@ test('basic', async t => { }); test('timeout', async t => { - const { makeFakePriceOracle, zoe } = t.context; - - const maxSubmissionCount = 1000; - const minSubmissionCount = 2; - const restartDelay = 2; - const timeout = 5; - const description = 'Chainlink oracles'; - const minSubmissionValue = 100; - const maxSubmissionValue = 10000; - - const aggregator = await t.context.makeChainlinkAggregator( - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ); + const { 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 priceOracleA = await makeFakePriceOracle(t); - const priceOracleB = await makeFakePriceOracle(t); - const priceOracleC = await makeFakePriceOracle(t); - const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( - priceOracleA.instance, + 'agorice1priceOracleA', ); const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( - priceOracleB.instance, + 'agorice1priceOracleB', ); const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( - priceOracleC.instance, + 'agorice1priceOracleC', ); // ----- round 1: basic consensus w/ ticking: should work EXACTLY the same await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); + await E(pricePushAdminA).pushResult({ roundId: 1, unitPrice: 100n }); await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await E(pricePushAdminB).pushResult({ roundId: 1, unitPrice: 200n }); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await E(pricePushAdminC).pushResult({ roundId: 1, unitPrice: 300n }); const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); t.deepEqual(round1Attempt1.roundId, 1n); @@ -267,15 +215,15 @@ test('timeout', async t => { // timeout behavior is, if more ticks pass than the timeout param (5 here), the round is // considered "timedOut," at which point, the values are simply copied from the previous round await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); + await E(pricePushAdminB).pushResult({ 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).pushResult({ roundId: 3, data: '1000' }); - await E(pricePushAdminA).pushResult({ roundId: 3, data: '3000' }); + await E(pricePushAdminC).pushResult({ roundId: 3, unitPrice: 1000n }); + await E(pricePushAdminA).pushResult({ roundId: 3, unitPrice: 3000n }); const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); t.deepEqual(round1Attempt2.answer, 200n); @@ -286,57 +234,42 @@ test('timeout', async t => { }); test('issue check', async t => { - const { makeFakePriceOracle, zoe } = t.context; - - const maxSubmissionCount = 1000; - const minSubmissionCount = 2; - const restartDelay = 2; - const timeout = 5; - const description = 'Chainlink oracles'; - const minSubmissionValue = 100; - const maxSubmissionValue = 10000; - - const aggregator = await t.context.makeChainlinkAggregator( - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ); + const { 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 priceOracleA = await makeFakePriceOracle(t); - const priceOracleB = await makeFakePriceOracle(t); - const priceOracleC = await makeFakePriceOracle(t); - const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( - priceOracleA.instance, + 'agorice1priceOracleA', ); const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( - priceOracleB.instance, + 'agorice1priceOracleB', ); const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( - priceOracleC.instance, + 'agorice1priceOracleC', ); // ----- round 1: ignore too low values await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 1, data: '50' }); // should be IGNORED + await E(pricePushAdminA).pushResult({ roundId: 1, unitPrice: 50n }); // should be IGNORED await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await E(pricePushAdminB).pushResult({ roundId: 1, unitPrice: 200n }); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await E(pricePushAdminC).pushResult({ roundId: 1, unitPrice: 300n }); const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); t.deepEqual(round1Attempt1.answer, 250n); // ----- round 2: ignore too high values await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 2, data: '20000' }); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '1000' }); - await E(pricePushAdminA).pushResult({ roundId: 2, data: '3000' }); + await E(pricePushAdminB).pushResult({ roundId: 2, unitPrice: 20000n }); + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 1000n }); + await E(pricePushAdminA).pushResult({ roundId: 2, unitPrice: 3000n }); await oracleTimer.tick(); const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); @@ -344,46 +277,31 @@ test('issue check', async t => { }); test('supersede', async t => { - const { makeFakePriceOracle, zoe } = t.context; - - const maxSubmissionCount = 1000; - const minSubmissionCount = 2; - const restartDelay = 1; - const timeout = 5; - const description = 'Chainlink oracles'; - const minSubmissionValue = 100; - const maxSubmissionValue = 10000; - - const aggregator = await t.context.makeChainlinkAggregator( - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ); + const { 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 priceOracleA = await makeFakePriceOracle(t); - const priceOracleB = await makeFakePriceOracle(t); - const priceOracleC = await makeFakePriceOracle(t); - const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( - priceOracleA.instance, + 'agorice1priceOracleA', ); const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( - priceOracleB.instance, + 'agorice1priceOracleB', ); const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( - priceOracleC.instance, + 'agorice1priceOracleC', ); // ----- round 1: round 1 is NOT supersedable when 3 submits, meaning it will be ignored await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '300' }); - await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await E(pricePushAdminA).pushResult({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 300n }); + await E(pricePushAdminB).pushResult({ roundId: 1, unitPrice: 200n }); await oracleTimer.tick(); const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); @@ -391,8 +309,8 @@ test('supersede', async t => { // ----- round 2: oracle C's value from before should have been IGNORED await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); - await E(pricePushAdminA).pushResult({ roundId: 2, data: '1000' }); + await E(pricePushAdminB).pushResult({ roundId: 2, unitPrice: 2000n }); + await E(pricePushAdminA).pushResult({ roundId: 2, unitPrice: 1000n }); await oracleTimer.tick(); const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); @@ -400,7 +318,7 @@ test('supersede', async t => { // ----- round 3: oracle C should NOT be able to supersede round 3 await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 4, data: '1000' }); + await E(pricePushAdminC).pushResult({ roundId: 4, unitPrice: 1000n }); try { await E(aggregator.creatorFacet).getRoundData(4); @@ -410,46 +328,34 @@ test('supersede', async t => { }); test('interleaved', async t => { - const { makeFakePriceOracle, zoe } = t.context; - - const maxSubmissionCount = 3; - const minSubmissionCount = 3; // requires ALL the oracles for consensus in this case - const restartDelay = 1; - const timeout = 5; - const description = 'Chainlink oracles'; - const minSubmissionValue = 100; - const maxSubmissionValue = 10000; - - const aggregator = await t.context.makeChainlinkAggregator( - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ); + const { 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 priceOracleA = await makeFakePriceOracle(t); - const priceOracleB = await makeFakePriceOracle(t); - const priceOracleC = await makeFakePriceOracle(t); - const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( - priceOracleA.instance, + 'agorice1priceOracleA', ); const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( - priceOracleB.instance, + 'agorice1priceOracleB', ); const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( - priceOracleC.instance, + 'agorice1priceOracleC', ); // ----- round 1: we now need unanimous submission for a round for it to have consensus await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '300' }); - await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await E(pricePushAdminA).pushResult({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 300n }); + await E(pricePushAdminB).pushResult({ roundId: 1, unitPrice: 200n }); await oracleTimer.tick(); try { @@ -460,17 +366,17 @@ test('interleaved', async t => { // ----- round 2: interleaved round submission -- just making sure this works await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await E(pricePushAdminC).pushResult({ roundId: 1, unitPrice: 300n }); await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 2, data: '2000' }); - await E(pricePushAdminA).pushResult({ roundId: 2, data: '1000' }); + await E(pricePushAdminB).pushResult({ roundId: 2, unitPrice: 2000n }); + await E(pricePushAdminA).pushResult({ roundId: 2, unitPrice: 1000n }); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 3, data: '9000' }); + await E(pricePushAdminC).pushResult({ roundId: 3, unitPrice: 9000n }); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '3000' }); // assumes oracle C is going for a resubmission + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 3000n }); // assumes oracle C is going for a resubmission await oracleTimer.tick(); await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 3, data: '5000' }); + await E(pricePushAdminA).pushResult({ roundId: 3, unitPrice: 5000n }); await oracleTimer.tick(); const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); @@ -493,9 +399,9 @@ test('interleaved', async t => { await oracleTimer.tick(); await oracleTimer.tick(); // round 3 is NOT yet supersedeable (since no value present and not yet timed out), so these should fail - await E(pricePushAdminA).pushResult({ roundId: 4, data: '4000' }); - await E(pricePushAdminB).pushResult({ roundId: 4, data: '5000' }); - await E(pricePushAdminC).pushResult({ roundId: 4, data: '6000' }); + await E(pricePushAdminA).pushResult({ roundId: 4, unitPrice: 4000n }); + await E(pricePushAdminB).pushResult({ roundId: 4, unitPrice: 5000n }); + await E(pricePushAdminC).pushResult({ roundId: 4, unitPrice: 6000n }); await oracleTimer.tick(); // --- round 3 has NOW timed out, meaning it is now supersedable try { @@ -511,9 +417,9 @@ test('interleaved', async t => { } // so NOW we should be able to submit round 4, and round 3 should just be copied from round 2 - await E(pricePushAdminA).pushResult({ roundId: 4, data: '4000' }); - await E(pricePushAdminB).pushResult({ roundId: 4, data: '5000' }); - await E(pricePushAdminC).pushResult({ roundId: 4, data: '6000' }); + await E(pricePushAdminA).pushResult({ roundId: 4, unitPrice: 4000n }); + await E(pricePushAdminB).pushResult({ roundId: 4, unitPrice: 5000n }); + await E(pricePushAdminC).pushResult({ roundId: 4, unitPrice: 6000n }); await oracleTimer.tick(); const round3Attempt3 = await E(aggregator.creatorFacet).getRoundData(3); @@ -523,7 +429,7 @@ test('interleaved', async t => { t.deepEqual(round4Attempt2.answer, 5000n); // ----- round 5: ping-ponging should be possible (although this is an unlikely pernicious case) - await E(pricePushAdminC).pushResult({ roundId: 5, data: '1000' }); + await E(pricePushAdminC).pushResult({ roundId: 5, unitPrice: 1000n }); await oracleTimer.tick(); await oracleTimer.tick(); await oracleTimer.tick(); @@ -531,14 +437,14 @@ test('interleaved', async t => { await oracleTimer.tick(); await oracleTimer.tick(); await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 6, data: '1000' }); + await E(pricePushAdminA).pushResult({ 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).pushResult({ roundId: 7, data: '1000' }); + await E(pricePushAdminC).pushResult({ roundId: 7, unitPrice: 1000n }); const round5Attempt1 = await E(aggregator.creatorFacet).getRoundData(5); const round6Attempt1 = await E(aggregator.creatorFacet).getRoundData(6); @@ -548,81 +454,66 @@ test('interleaved', async t => { }); test('larger', async t => { - const { makeFakePriceOracle, zoe } = t.context; - - const maxSubmissionCount = 1000; - const minSubmissionCount = 3; - const restartDelay = 1; - const timeout = 5; - const description = 'Chainlink oracles'; - const minSubmissionValue = 100; - const maxSubmissionValue = 10000; - - const aggregator = await t.context.makeChainlinkAggregator( - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ); + const { 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 priceOracleA = await makeFakePriceOracle(t); - const priceOracleB = await makeFakePriceOracle(t); - const priceOracleC = await makeFakePriceOracle(t); - const priceOracleD = await makeFakePriceOracle(t); - const priceOracleE = await makeFakePriceOracle(t); - const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( - priceOracleA.instance, + 'agorice1priceOracleA', ); const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( - priceOracleB.instance, + 'agorice1priceOracleB', ); const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( - priceOracleC.instance, + 'agorice1priceOracleC', ); const pricePushAdminD = await E(aggregator.creatorFacet).initOracle( - priceOracleD.instance, + 'agorice1priceOracleD', ); const pricePushAdminE = await E(aggregator.creatorFacet).initOracle( - priceOracleE.instance, + 'agorice1priceOracleE', ); // ----- round 1: usual case await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); - await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); + await E(pricePushAdminA).pushResult({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminB).pushResult({ roundId: 1, unitPrice: 200n }); await oracleTimer.tick(); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '1000' }); + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 1000n }); await oracleTimer.tick(); - await E(pricePushAdminD).pushResult({ roundId: 3, data: '3000' }); + await E(pricePushAdminD).pushResult({ roundId: 3, unitPrice: 3000n }); await oracleTimer.tick(); await oracleTimer.tick(); await oracleTimer.tick(); - await E(pricePushAdminE).pushResult({ roundId: 1, data: '300' }); + await E(pricePushAdminE).pushResult({ roundId: 1, unitPrice: 300n }); const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); t.deepEqual(round1Attempt1.answer, 200n); // ----- round 2: ignore late arrival await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 2, data: '600' }); + await E(pricePushAdminB).pushResult({ roundId: 2, unitPrice: 600n }); await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 2, data: '500' }); + await E(pricePushAdminA).pushResult({ roundId: 2, unitPrice: 500n }); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 3, data: '1000' }); + await E(pricePushAdminC).pushResult({ roundId: 3, unitPrice: 1000n }); await oracleTimer.tick(); - await E(pricePushAdminD).pushResult({ roundId: 1, data: '500' }); + await E(pricePushAdminD).pushResult({ roundId: 1, unitPrice: 500n }); await oracleTimer.tick(); await oracleTimer.tick(); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '1000' }); + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 1000n }); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 1, data: '700' }); // this should be IGNORED since oracle C has already sent round 2 + await E(pricePushAdminC).pushResult({ roundId: 1, unitPrice: 700n }); // this should be IGNORED since oracle C has already sent round 2 const round1Attempt2 = await E(aggregator.creatorFacet).getRoundData(1); const round2Attempt1 = await E(aggregator.creatorFacet).getRoundData(2); @@ -631,46 +522,33 @@ test('larger', async t => { }); test('suggest', async t => { - const { makeFakePriceOracle, zoe } = t.context; - - const maxSubmissionCount = 1000; - const minSubmissionCount = 3; - const restartDelay = 1; - const timeout = 5; - const description = 'Chainlink oracles'; - const minSubmissionValue = 100; - const maxSubmissionValue = 10000; - - const aggregator = await t.context.makeChainlinkAggregator( - maxSubmissionCount, - minSubmissionCount, - restartDelay, - timeout, - description, - minSubmissionValue, - maxSubmissionValue, - ); + const { 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 priceOracleA = await makeFakePriceOracle(t); - const priceOracleB = await makeFakePriceOracle(t); - const priceOracleC = await makeFakePriceOracle(t); - const pricePushAdminA = await E(aggregator.creatorFacet).initOracle( - priceOracleA.instance, + 'agorice1priceOracleA', ); const pricePushAdminB = await E(aggregator.creatorFacet).initOracle( - priceOracleB.instance, + 'agorice1priceOracleB', ); const pricePushAdminC = await E(aggregator.creatorFacet).initOracle( - priceOracleC.instance, + 'agorice1priceOracleC', ); // ----- round 1: basic consensus await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 1, data: '100' }); - await E(pricePushAdminB).pushResult({ roundId: 1, data: '200' }); - await E(pricePushAdminC).pushResult({ roundId: 1, data: '300' }); + await E(pricePushAdminA).pushResult({ roundId: 1, unitPrice: 100n }); + await E(pricePushAdminB).pushResult({ roundId: 1, unitPrice: 200n }); + await E(pricePushAdminC).pushResult({ roundId: 1, unitPrice: 300n }); await oracleTimer.tick(); const round1Attempt1 = await E(aggregator.creatorFacet).getRoundData(1); @@ -679,9 +557,9 @@ test('suggest', async t => { // ----- round 2: add a new oracle and confirm the suggested round is correct await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: 2, data: '1000' }); + await E(pricePushAdminB).pushResult({ roundId: 2, unitPrice: 1000n }); const oracleCSuggestion = await E(aggregator.creatorFacet).oracleRoundState( - priceOracleC.instance, + 'agorice1priceOracleC', 1n, ); @@ -690,7 +568,7 @@ test('suggest', async t => { t.deepEqual(oracleCSuggestion.oracleCount, 3); const oracleBSuggestion = await E(aggregator.creatorFacet).oracleRoundState( - priceOracleB.instance, + 'agorice1priceOracleB', 0n, ); @@ -699,13 +577,13 @@ test('suggest', async t => { t.deepEqual(oracleBSuggestion.oracleCount, 3); await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: 2, data: '2000' }); + await E(pricePushAdminA).pushResult({ roundId: 2, unitPrice: 2000n }); await oracleTimer.tick(); await oracleTimer.tick(); - await E(pricePushAdminC).pushResult({ roundId: 2, data: '3000' }); + await E(pricePushAdminC).pushResult({ roundId: 2, unitPrice: 3000n }); const oracleASuggestion = await E(aggregator.creatorFacet).oracleRoundState( - priceOracleA.instance, + 'agorice1priceOracleA', 0n, ); @@ -714,14 +592,127 @@ test('suggest', async t => { t.deepEqual(oracleASuggestion.startedAt, 0n); // round 3 hasn't yet started, so it should be zeroed // ----- round 3: try using suggested round - await E(pricePushAdminC).pushResult({ roundId: 3, data: '100' }); + await E(pricePushAdminC).pushResult({ roundId: 3, unitPrice: 100n }); await oracleTimer.tick(); - await E(pricePushAdminA).pushResult({ roundId: undefined, data: '200' }); + await E(pricePushAdminA).pushResult({ roundId: undefined, unitPrice: 200n }); await oracleTimer.tick(); await oracleTimer.tick(); - await E(pricePushAdminB).pushResult({ roundId: undefined, data: '300' }); + await E(pricePushAdminB).pushResult({ roundId: undefined, unitPrice: 300n }); const round3Attempt1 = await E(aggregator.creatorFacet).getRoundData(3); t.deepEqual(round3Attempt1.roundId, 3n); t.deepEqual(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).pushResult({ roundId: 1, unitPrice: 100n }); + t.deepEqual((await eachLatestRound.next()).value, { + roundId: 1n, + startedAt: 1n, + }); + await E(pricePushAdminB).pushResult({ 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 E(pricePushAdminA).pushResult({ roundId: 2, unitPrice: 1000n }); + // 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).pushResult({ 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).pushResult({ 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).pushResult({ 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 subscriberSubkey(E(publicFacet).getSubscriber()), + 'fake:mockChainStorageRoot.priceAggregator.LINK-USD_price_feed', + ); +});