diff --git a/a3p-integration/proposals/z:acceptance/package.json b/a3p-integration/proposals/z:acceptance/package.json index a5910e5ded6..425de5fd527 100644 --- a/a3p-integration/proposals/z:acceptance/package.json +++ b/a3p-integration/proposals/z:acceptance/package.json @@ -15,6 +15,7 @@ "@endo/far": "^1.1.5", "@endo/init": "^1.1.4", "ava": "^6.1.2", + "better-sqlite3": "11.5.0", "execa": "^9.3.1", "tsx": "^4.17.0" }, diff --git a/a3p-integration/proposals/z:acceptance/priceFeed.test.js b/a3p-integration/proposals/z:acceptance/priceFeed.test.js new file mode 100644 index 00000000000..37c5ddfef7b --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/priceFeed.test.js @@ -0,0 +1,210 @@ +/* eslint-env node */ + +/** + * @file The purpose of this test is to make sure; + * - Old priceFeed and scaledPriceAuthority vats that are replaced with new ones are truly quiescent. + * The method we use for this is to check if those vats received any deliveries from swingset that + * are of type "message" or "notify" (We give delivery types related to GC a pass since GC cycles + * aren't in our control). + * - Make sure new price feeds can produce quotes + * - Make sure vaults receive quotes + */ + +import test from 'ava'; +import '@endo/init'; +import { + agd, + agoric, + generateOracleMap, + getPriceQuote, + GOV1ADDR, + GOV2ADDR, + GOV3ADDR, + pushPrices, + registerOraclesForBrand, +} from '@agoric/synthetic-chain'; +import { snapshotVat } from './test-lib/vat-helpers.js'; +import { + bankSend, + ensureGCDeliveryOnly, + getQuoteFromVault, + pollRoundIdAndPushPrice, + scale6, +} from './test-lib/priceFeed-lib.js'; +import { + retryUntilCondition, + waitUntilOfferResult, +} from './test-lib/sync-tools.js'; + +const ambientAuthority = { + query: agd.query, + follow: agoric.follow, + setTimeout, +}; + +const config = { + vatNames: [ + '-scaledPriceAuthority-stATOM', + '-scaledPriceAuthority-ATOM', + '-stATOM-USD_price_feed', + '-ATOM-USD_price_feed', + ], + snapshots: { before: {}, after: {} }, // Will be filled in the runtime + priceFeeds: { + ATOM: { + price: 29, + managerIndex: 0, + name: 'ATOM', + }, + stATOM: { + price: 25, + managerIndex: 1, + name: 'stATOM', + }, + }, +}; + +/** + * https://github.com/Agoric/agoric-sdk/pull/10074 introduced new price feeds to the system. + * However, `f:replace-price-feeds` does not activate oracles for future layers of the build. + * Meaning, proposals running after `f:replace-price-feeds` will not have oracles that received + * invitationMakers for new price feeds and there will not be quotes published by new + * price feeds. There are conflicting work to fix this issue, see; + * - https://github.com/Agoric/agoric-sdk/pull/10296 + * - https://github.com/Agoric/agoric-sdk/pull/10317 + * - https://github.com/Agoric/agoric-sdk/pull/10296#pullrequestreview-2389390624 + * + * The purpose of init() is to unblock testing new price feeds from the situation above. We can remove + * this when it resolves. + * + * @param {Map>} oraclesByBrand + */ +const init = async oraclesByBrand => { + const retryOptions = { + log: console.log, + maxRetries: 5, + retryIntervalMs: 3000, + }; + + const atomInviteOffers = []; + registerOraclesForBrand('ATOM', oraclesByBrand); + // @ts-expect-error we expect oraclesByBrand.get('ATOM') will not return undefined + for (const { address, offerId } of oraclesByBrand.get('ATOM')) { + const offerP = waitUntilOfferResult( + address, + offerId, + false, + ambientAuthority, + { + errorMessage: `ERROR: ${address} could not accept invite, offerID: ${offerId}`, + ...retryOptions, + }, + ); + atomInviteOffers.push(offerP); + } + await Promise.all(atomInviteOffers); + + const stAtomInviteOffers = []; + registerOraclesForBrand('stATOM', oraclesByBrand); + // @ts-expect-error we expect oraclesByBrand.get('ATOM') will not return undefined + for (const { address, offerId } of oraclesByBrand.get('stATOM')) { + const offerP = waitUntilOfferResult( + address, + offerId, + false, + ambientAuthority, + { + errorMessage: `ERROR: ${address} could not accept invite, offerID: ${offerId}`, + ...retryOptions, + }, + ); + + stAtomInviteOffers.push(offerP); + } + await Promise.all(stAtomInviteOffers); + + await pushPrices(1, 'ATOM', oraclesByBrand, 1); + // await waitForBlock(3); + await retryUntilCondition( + () => getPriceQuote('ATOM'), + res => res === '+1000000', + 'ATOM quote not received', + { ...retryOptions, setTimeout }, + ); + await pushPrices(1, 'stATOM', oraclesByBrand, 1); + await retryUntilCondition( + () => getPriceQuote('stATOM'), + res => res === '+1000000', + 'stATOM quote not received', + { ...retryOptions, setTimeout }, + ); +}; + +/** + * @typedef {Map>} OraclesByBrand + */ + +test.before(async t => { + // Fund each oracle members with 10IST incase we hit batch limit here https://github.com/Agoric/agoric-sdk/issues/6525 + await bankSend(GOV2ADDR, '10000000uist', GOV1ADDR); + await bankSend(GOV3ADDR, '10000000uist', GOV1ADDR); + + const oraclesByBrand = generateOracleMap('z-acc', ['ATOM', 'stATOM']); + t.log(oraclesByBrand); + + await init(oraclesByBrand); + t.context = { + oraclesByBrand, + }; +}); + +test.serial('snapshot state', t => { + config.vatNames.forEach(name => { + config.snapshots.before[name] = snapshotVat(name); + }); + console.dir(config.snapshots, { depth: null }); + t.pass(); +}); + +test.serial('push-price', async t => { + // @ts-expect-error casting + const { oraclesByBrand } = t.context; + const { + priceFeeds: { ATOM, stATOM }, + } = config; + + await pollRoundIdAndPushPrice(ATOM.name, ATOM.price, oraclesByBrand); + await pollRoundIdAndPushPrice(stATOM.name, stATOM.price, oraclesByBrand); + + const atomOut = await getPriceQuote(ATOM.name); + t.is(atomOut, `+${scale6(ATOM.price)}`); + const stAtomOut = await getPriceQuote(stATOM.name); + t.is(stAtomOut, `+${scale6(stATOM.price)}`); + t.pass(); +}); + +test.serial('snapshot state after price pushed', t => { + config.vatNames.forEach(name => { + config.snapshots.after[name] = snapshotVat(name); + }); + console.dir(config.snapshots, { depth: null }); + t.pass(); +}); + +test.serial('ensure only gc', t => { + ensureGCDeliveryOnly(config.snapshots); + t.pass(); +}); + +test.serial('make sure vaults got the prices', async t => { + const { + priceFeeds: { ATOM, stATOM }, + } = config; + const [atomVaultQuote, stAtomVaultQuote] = await Promise.all([ + getQuoteFromVault(ATOM.managerIndex), + getQuoteFromVault(stATOM.managerIndex), + ]); + + t.is(atomVaultQuote, scale6(ATOM.price).toString()); + t.is(stAtomVaultQuote, scale6(stATOM.price).toString()); +}); diff --git a/a3p-integration/proposals/z:acceptance/test-lib/priceFeed-lib.js b/a3p-integration/proposals/z:acceptance/test-lib/priceFeed-lib.js new file mode 100644 index 00000000000..a7f98d26b8e --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/priceFeed-lib.js @@ -0,0 +1,143 @@ +import { + agd, + CHAINID, + VALIDATORADDR, + agoric as agoricAmbient, + pushPrices, +} from '@agoric/synthetic-chain'; +import { Fail, q } from '@endo/errors'; +import { getTranscriptItemsForVat } from './vat-helpers.js'; + +/** + * By the time we push prices to the new price feed vat, the old one might receive + * some deliveries related to GC events. These delivery types might be; 'dropExports', + * 'retireExports', 'retireImports', 'bringOutYourDead'. + * + * Even though we don't expect to receive all these types of deliveries at once; + * choosing MAX_DELIVERIES_ALLOWED = 5 seems reasonable. + */ +const MAX_DELIVERIES_ALLOWED = 5; + +export const scale6 = x => BigInt(x * 1000000); + +/** + * @typedef {Record< + * string, + * Record + * >} SnapshotItem + * + * @typedef {Record} Snapshots + */ + +/** + * Import from synthetic-chain once it is updated + * + * @param {string} addr + * @param {string} wanted + * @param {string} [from] + */ +export const bankSend = (addr, wanted, from = VALIDATORADDR) => { + const chain = ['--chain-id', CHAINID]; + const fromArg = ['--from', from]; + const testKeyring = ['--keyring-backend', 'test']; + const noise = [...fromArg, ...chain, ...testKeyring, '--yes']; + + return agd.tx('bank', 'send', from, addr, wanted, ...noise); +}; + +/** + * Import from synthetic-chain when https://github.com/Agoric/agoric-3-proposals/pull/183 is in + * + * @param {string} price + * @param {{ + * agoric?: { follow: () => Promise}; + * prefix?: string + * }} io + * @returns + */ +export const getRoundId = async (price, io = {}) => { + const { agoric = { follow: agoricAmbient.follow }, prefix = 'published.' } = + io; + const path = `:${prefix}priceFeed.${price}-USD_price_feed.latestRound`; + const round = await agoric.follow('-lF', path); + return parseInt(round.roundId, 10); +}; + +/** + * + * @param {string} brandIn + * @param {number} price + * @param {import('../priceFeed.test.js').OraclesByBrand} oraclesByBrand + */ +export const pollRoundIdAndPushPrice = async ( + brandIn, + price, + oraclesByBrand, +) => { + const roundId = await getRoundId(brandIn); + await pushPrices(price, brandIn, oraclesByBrand, roundId + 1); +}; + +/** + * @param {SnapshotItem} snapShotItem + */ +export const getQuiescentVats = snapShotItem => { + const quiescentVats = {}; + [...Object.values(snapShotItem)].forEach(vats => { + const keyOne = Object.keys(vats)[0]; + const keyTwo = Object.keys(vats)[1]; + + return parseInt(keyOne.substring(1), 10) > parseInt(keyTwo.substring(1), 10) + ? (quiescentVats[keyTwo] = vats[keyTwo]) + : (quiescentVats[keyOne] = vats[keyOne]); + }); + + return quiescentVats; +}; + +/** + * + * @param {Snapshots} snapshots + * @param {{ getTranscriptItems?: () => Array}} io + */ +export const ensureGCDeliveryOnly = (snapshots, io = {}) => { + const { getTranscriptItems = getTranscriptItemsForVat } = io; + + const { after, before } = snapshots; + const quiescentVatsBefore = getQuiescentVats(before); + const quiescentVatsAfter = getQuiescentVats(after); + + console.dir(quiescentVatsBefore, { depth: null }); + console.dir(quiescentVatsAfter, { depth: null }); + + [...Object.entries(quiescentVatsBefore)].forEach(([vatId, position]) => { + const afterPosition = quiescentVatsAfter[vatId]; + const messageDiff = afterPosition - position; + console.log(vatId, messageDiff); + + if (messageDiff > MAX_DELIVERIES_ALLOWED) + Fail`${q(messageDiff)} deliveries is greater than maximum allowed: ${q(MAX_DELIVERIES_ALLOWED)}`; + else if (messageDiff === 0) return; + + const transcripts = getTranscriptItems(vatId, messageDiff); + console.log('TRANSCRIPTS', transcripts); + + transcripts.forEach(({ item }) => { + const deliveryType = JSON.parse(item).d[0]; + console.log('DELIVERY TYPE', deliveryType); + if (deliveryType === 'notify' || deliveryType === 'message') + Fail`DeliveryType ${q(deliveryType)} is not GC delivery`; + }); + }); +}; + +/** + * @param {number} managerIndex + */ +export const getQuoteFromVault = async managerIndex => { + const res = await agoricAmbient.follow( + '-lF', + `:published.vaultFactory.managers.manager${managerIndex}.quotes`, + ); + return res.quoteAmount.value[0].amountOut.value; +}; diff --git a/a3p-integration/proposals/z:acceptance/test-lib/priceFeed-lib.test.js b/a3p-integration/proposals/z:acceptance/test-lib/priceFeed-lib.test.js new file mode 100644 index 00000000000..5d2adad73ee --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/priceFeed-lib.test.js @@ -0,0 +1,103 @@ +import test from 'ava'; +import '@endo/init'; +import { ensureGCDeliveryOnly } from './priceFeed-lib.js'; + +const testConfig = { + before: { + '-scaledPriceAuthority-stATOM': { v58: 13, v74: 119 }, + '-scaledPriceAuthority-ATOM': { v46: 77, v73: 178 }, + '-stATOM-USD_price_feed': { v57: 40, v72: 192 }, + '-ATOM-USD_price_feed': { v29: 100, v70: 247 }, + }, + after: { + '-scaledPriceAuthority-stATOM': { v58: 15, v74: 119 }, + '-scaledPriceAuthority-ATOM': { v46: 79, v73: 178 }, + '-stATOM-USD_price_feed': { v57: 42, v72: 192 }, + '-ATOM-USD_price_feed': { v29: 102, v70: 247 }, + }, +}; + +const makeFakeGetTranscriptItemsForVat = ( + deliveryType, + maximumAllowedDeliveries, +) => { + const fakeGetTranscriptItemsForVat = (_, number) => { + const fakeTranscriptItems = []; + for (let i = 0; i < number; i += 1) { + const item = { d: [deliveryType] }; + fakeTranscriptItems.push({ item: JSON.stringify(item) }); + } + return fakeTranscriptItems; + }; + + const tooManyTranscriptItemsForVat = () => { + const fakeTranscriptItems = []; + for (let i = 0; i <= maximumAllowedDeliveries; i += 1) { + const item = { d: [deliveryType] }; + fakeTranscriptItems.push({ item: JSON.stringify(item) }); + } + return fakeTranscriptItems; + }; + + return { fakeGetTranscriptItemsForVat, tooManyTranscriptItemsForVat }; +}; + +test('should not throw', t => { + const { fakeGetTranscriptItemsForVat } = + makeFakeGetTranscriptItemsForVat('dropExports'); + + t.notThrows(() => + ensureGCDeliveryOnly(testConfig, { + getTranscriptItems: fakeGetTranscriptItemsForVat, + }), + ); +}); + +test('should throw for "notify"', t => { + const { fakeGetTranscriptItemsForVat } = + makeFakeGetTranscriptItemsForVat('notify'); + + t.throws( + () => + ensureGCDeliveryOnly(testConfig, { + getTranscriptItems: fakeGetTranscriptItemsForVat, + }), + { message: 'DeliveryType "notify" is not GC delivery' }, + ); +}); + +test('should throw for "message"', t => { + const { fakeGetTranscriptItemsForVat } = + makeFakeGetTranscriptItemsForVat('message'); + + t.throws( + () => + ensureGCDeliveryOnly(testConfig, { + getTranscriptItems: fakeGetTranscriptItemsForVat, + }), + { message: 'DeliveryType "message" is not GC delivery' }, + ); +}); + +test('should throw too many deliveries', t => { + const { fakeGetTranscriptItemsForVat } = makeFakeGetTranscriptItemsForVat( + 'dropExports', + 5, + ); + + const config = { + ...testConfig, + after: { + ...testConfig.after, + '-scaledPriceAuthority-stATOM': { v58: 20, v74: 119 }, + }, + }; + + t.throws( + () => + ensureGCDeliveryOnly(config, { + getTranscriptItems: fakeGetTranscriptItemsForVat, + }), + { message: '7 deliveries is greater than maximum allowed: 5' }, + ); +}); diff --git a/a3p-integration/proposals/z:acceptance/test-lib/vat-helpers.js b/a3p-integration/proposals/z:acceptance/test-lib/vat-helpers.js new file mode 100644 index 00000000000..b37af097248 --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/test-lib/vat-helpers.js @@ -0,0 +1,102 @@ +import dbOpenAmbient from 'better-sqlite3'; +import { HOME, dbTool } from '@agoric/synthetic-chain'; + +/** + * @typedef {{position: number; item: string; vatID: string; incarnation: number}} TranscriptItem + */ + +const swingstorePath = '~/.agoric/data/agoric/swingstore.sqlite'; + +/** + * Initially from https://github.com/Agoric/agoric-3-proposals/blob/93bb953db209433499db08ae563942d1bf7eeb46/proposals/76%3Avaults-auctions/vatDetails.js#L36 + * but with small modifications + * + * @param {import('better-sqlite3').Database} db + */ +const makeSwingstore = db => { + const sql = dbTool(db); + + /** @param {string} key */ + // @ts-expect-error sqlite typedefs + const kvGet = key => sql.get`select * from kvStore where key = ${key}`.value; + /** @param {string} key */ + const kvGetJSON = key => JSON.parse(kvGet(key)); + + /** @param {string} vatID */ + const lookupVat = vatID => { + return Object.freeze({ + source: () => kvGetJSON(`${vatID}.source`), + options: () => kvGetJSON(`${vatID}.options`), + currentSpan: () => + sql.get`select * from transcriptSpans where isCurrent = 1 and vatID = ${vatID}`, + }); + }; + + return Object.freeze({ + /** @param {string} vatName */ + findVat: vatName => { + /** @type {string[]} */ + const dynamicIDs = kvGetJSON('vat.dynamicIDs'); + const targetVat = dynamicIDs.find(vatID => + lookupVat(vatID).options().name.includes(vatName), + ); + if (!targetVat) throw Error(`vat not found: ${vatName}`); + return targetVat; + }, + /** @param {string} string a substring to search for within the vat name. */ + findVatsExact: string => { + /** @type {string[]} */ + const dynamicIDs = kvGetJSON('vat.dynamicIDs'); + return dynamicIDs.filter(vatID => + lookupVat(vatID).options().name.endsWith(string), + ); + }, + findVatsAll: string => { + /** @type {string[]} */ + const dynamicIDs = kvGetJSON('vat.dynamicIDs'); + return dynamicIDs.filter(vatID => + lookupVat(vatID).options().name.includes(string), + ); + }, + lookupVat, + db, + }); +}; + +const initSwingstore = () => { + const fullPath = swingstorePath.replace(/^~/, HOME); + return makeSwingstore(dbOpenAmbient(fullPath, { readonly: true })); +}; + +/** + * + * @param {string} vatId + * @param {number} n + * @returns {Array} + */ +export const getTranscriptItemsForVat = (vatId, n = 10) => { + const { db } = initSwingstore(); + + const items = db + .prepare( + 'select * from transcriptItems where vatId = ? order by position desc limit ?', + ) + .all(vatId, n); + + // @ts-expect-error casting problem when assigning values coming from db + return items; +}; + +export const snapshotVat = vatName => { + const { findVatsExact } = initSwingstore(); + + const snapshots = {}; + const vatIdsWithExactName = findVatsExact(vatName); + vatIdsWithExactName.forEach(id => { + const element = getTranscriptItemsForVat(id, 1)[0]; + + snapshots[id] = element.position; + }); + + return snapshots; +}; diff --git a/a3p-integration/proposals/z:acceptance/test.sh b/a3p-integration/proposals/z:acceptance/test.sh index f9982b31916..a83c2f51b53 100755 --- a/a3p-integration/proposals/z:acceptance/test.sh +++ b/a3p-integration/proposals/z:acceptance/test.sh @@ -26,5 +26,8 @@ echo ACCEPTANCE TESTING state sync echo ACCEPTANCE TESTING wallet yarn ava wallet.test.js +echo ACCEPTANCE TESTING replaced price feeds +yarn ava priceFeed.test.js + echo ACCEPTANCE TESTING vaults yarn ava vaults.test.js diff --git a/a3p-integration/proposals/z:acceptance/vaults.test.js b/a3p-integration/proposals/z:acceptance/vaults.test.js index 5545a9381be..1b21a049367 100644 --- a/a3p-integration/proposals/z:acceptance/vaults.test.js +++ b/a3p-integration/proposals/z:acceptance/vaults.test.js @@ -14,7 +14,6 @@ import { ATOM_DENOM, USER1ADDR, waitForBlock, - registerOraclesForBrand, generateOracleMap, } from '@agoric/synthetic-chain'; import { getBalances, agopsVaults } from './test-lib/utils.js'; @@ -30,13 +29,12 @@ test.before(async t => { retryIntervalMs: 5000, // in ms }; t.context = { - roundId: 1, + roundId: 3, retryOpts: { pushPriceRetryOpts, }, }; const oraclesByBrand = generateOracleMap('z-acc', ['ATOM']); - await registerOraclesForBrand('ATOM', oraclesByBrand); const price = 15.2; // @ts-expect-error t.context is fine diff --git a/a3p-integration/proposals/z:acceptance/yarn.lock b/a3p-integration/proposals/z:acceptance/yarn.lock index 72977f2a884..8741b14d421 100644 --- a/a3p-integration/proposals/z:acceptance/yarn.lock +++ b/a3p-integration/proposals/z:acceptance/yarn.lock @@ -784,6 +784,17 @@ __metadata: languageName: node linkType: hard +"better-sqlite3@npm:11.5.0": + version: 11.5.0 + resolution: "better-sqlite3@npm:11.5.0" + dependencies: + bindings: "npm:^1.5.0" + node-gyp: "npm:latest" + prebuild-install: "npm:^7.1.1" + checksum: 10c0/c24200972e11f6f99c4e6538122bd7ec8b31b92b2fa095f4b595cc39fedf924cb0a93fd326f0900415eccdf634367f7bba2ba4eaa4d164edd7352f4cfaaaec51 + languageName: node + linkType: hard + "better-sqlite3@npm:^9.6.0": version: 9.6.0 resolution: "better-sqlite3@npm:9.6.0" @@ -2517,6 +2528,7 @@ __metadata: "@endo/far": "npm:^1.1.5" "@endo/init": "npm:^1.1.4" ava: "npm:^6.1.2" + better-sqlite3: "npm:11.5.0" execa: "npm:^9.3.1" tsx: "npm:^4.17.0" typescript: "npm:^5.5.4"