diff --git a/a3p-integration/proposals/n:upgrade-next/initial.test.js b/a3p-integration/proposals/n:upgrade-next/initial.test.js index a0b54b3316d..c3e26d68692 100644 --- a/a3p-integration/proposals/n:upgrade-next/initial.test.js +++ b/a3p-integration/proposals/n:upgrade-next/initial.test.js @@ -11,6 +11,8 @@ const vats = { transfer: { incarnation: 2 }, walletFactory: { incarnation: 5 }, zoe: { incarnation: 3 }, + // Terminated in a future proposal. + '-ATOM-USD_price_feed-governor': { incarnation: 0 }, }; test(`vat details`, async t => { diff --git a/a3p-integration/proposals/n:upgrade-next/priceFeedUpdate.test.js b/a3p-integration/proposals/n:upgrade-next/priceFeedUpdate.test.js index e27264cff04..557a837be9e 100644 --- a/a3p-integration/proposals/n:upgrade-next/priceFeedUpdate.test.js +++ b/a3p-integration/proposals/n:upgrade-next/priceFeedUpdate.test.js @@ -112,9 +112,10 @@ const checkNewAuctionVat = async t => { }; const countPriceFeedVats = async t => { - // price_feed and governor, old and new for two tokens + // price_feed and governor, old and new for two tokens, + // minus governor v110 (terminated by core-eval) const priceFeedDetails = await getDetailsMatchingVats('price_feed'); - t.is(Object.keys(priceFeedDetails).length, 8); + t.is(Object.keys(priceFeedDetails).length, 7); // Two old SPAs, and two new ones const details = await getDetailsMatchingVats('scaledPriceAuthority'); diff --git a/a3p-integration/proposals/n:upgrade-next/test.sh b/a3p-integration/proposals/n:upgrade-next/test.sh index a3bc4a2d845..c7b42dda2d0 100755 --- a/a3p-integration/proposals/n:upgrade-next/test.sh +++ b/a3p-integration/proposals/n:upgrade-next/test.sh @@ -1,8 +1,15 @@ #!/bin/bash +set -ueo pipefail +source /usr/src/upgrade-test-scripts/env_setup.sh # Place here any test that should be executed using the executed proposal. # The effects of this step are not persisted in further proposal layers. +test_val \ + "$(agd q swingset params -o json | jq -Sc .vat_cleanup_budget)" \ + '[{"key":"default","value":"5"},{"key":"kv","value":"50"}]' \ + 'vat cleanup budget' + # suppress file names from glob that run earlier GLOBIGNORE=initial.test.js diff --git a/a3p-integration/proposals/p:upgrade-19/.gitignore b/a3p-integration/proposals/p:upgrade-19/.gitignore index 29940cd1eb4..ffa9f22d144 100644 --- a/a3p-integration/proposals/p:upgrade-19/.gitignore +++ b/a3p-integration/proposals/p:upgrade-19/.gitignore @@ -6,3 +6,4 @@ upgradeAgoricNames/ publishTestInfo/ upgrade-mintHolder/ upgradeAssetReserve/ +terminate-governor/ diff --git a/a3p-integration/proposals/p:upgrade-19/package.json b/a3p-integration/proposals/p:upgrade-19/package.json index 97ec3e2679d..638d622ecf4 100644 --- a/a3p-integration/proposals/p:upgrade-19/package.json +++ b/a3p-integration/proposals/p:upgrade-19/package.json @@ -10,7 +10,8 @@ "vats/upgrade-agoricNames.js agoricNamesCoreEvals/upgradeAgoricNames", "testing/add-USD-OLIVES.js agoricNamesCoreEvals/addUsdOlives", "testing/publish-test-info.js agoricNamesCoreEvals/publishTestInfo", - "vats/upgrade-mintHolder.js upgrade-mintHolder A3P_INTEGRATION" + "vats/upgrade-mintHolder.js upgrade-mintHolder A3P_INTEGRATION", + "vats/terminate-governor-instance.js terminate-governor board02963:ATOM-USD_price_feed" ] }, "type": "module", diff --git a/a3p-integration/proposals/p:upgrade-19/terminateGovernor.test.js b/a3p-integration/proposals/p:upgrade-19/terminateGovernor.test.js new file mode 100644 index 00000000000..b5f29159b7d --- /dev/null +++ b/a3p-integration/proposals/p:upgrade-19/terminateGovernor.test.js @@ -0,0 +1,33 @@ +/* eslint-env node */ + +import test from 'ava'; +import '@endo/init/debug.js'; + +import { retryUntilCondition } from '@agoric/client-utils'; +import { evalBundles } from '@agoric/synthetic-chain'; +import { getDetailsMatchingVats } from './vatDetails.js'; + +test('verify governor termination', async t => { + const getVats = () => + getDetailsMatchingVats('-ATOM-USD_price_feed-governor', true); + const vatIsAlive = vat => !vat.terminated; + + const initialVats = await getVats(); + t.log('initial instances', initialVats); + + const initialLiveVats = initialVats.filter(vatIsAlive); + t.true(initialLiveVats.length > 0); + + await evalBundles('terminate-governor'); + const checkForTermination = vats => { + t.log(vats); + return vats.filter(vatIsAlive).length < initialLiveVats.length; + }; + await retryUntilCondition( + getVats, + checkForTermination, + 'ATOM-USD price feed governor termination', + { setTimeout, retryIntervalMs: 5000, maxRetries: 15 }, + ); + t.pass(); +}); diff --git a/a3p-integration/proposals/p:upgrade-19/test.sh b/a3p-integration/proposals/p:upgrade-19/test.sh index cc1611e9017..9b175d8e6e0 100644 --- a/a3p-integration/proposals/p:upgrade-19/test.sh +++ b/a3p-integration/proposals/p:upgrade-19/test.sh @@ -1,5 +1,6 @@ #!/bin/bash +yarn ava terminateGovernor.test.js yarn ava replaceFeeDistributor.test.js yarn ava mintHolder.test.js yarn ava provisionPool.test.js diff --git a/a3p-integration/proposals/p:upgrade-19/vatDetails.js b/a3p-integration/proposals/p:upgrade-19/vatDetails.js new file mode 100644 index 00000000000..9eeafdf5949 --- /dev/null +++ b/a3p-integration/proposals/p:upgrade-19/vatDetails.js @@ -0,0 +1,129 @@ +// Temporary fork of +// https://github.com/Agoric/agoric-3-proposals/blob/main/packages/synthetic-chain/src/lib/vat-status.js +// for deleted vat information. +// See https://github.com/Agoric/agoric-3-proposals/issues/208 +/* eslint-env node */ + +import dbOpenAmbient from 'better-sqlite3'; + +const HOME = process.env.HOME; + +/** @type {(val: T | undefined) => T} */ +export const NonNullish = val => { + if (!val) throw Error('required'); + return val; +}; + +/** + * @file look up vat incarnation from kernel DB + * @see {getIncarnation} + */ + +const swingstorePath = `${HOME}/.agoric/data/agoric/swingstore.sqlite`; + +/** + * SQL short-hand + * + * @param {import('better-sqlite3').Database} db + */ +export const dbTool = db => { + const prepare = (strings, ...params) => { + const dml = strings.join('?'); + return { stmt: db.prepare(dml), params }; + }; + const sql = (strings, ...args) => { + const { stmt, params } = prepare(strings, ...args); + return stmt.all(...params); + }; + sql.get = (strings, ...args) => { + const { stmt, params } = prepare(strings, ...args); + return stmt.get(...params); + }; + return sql; +}; + +/** + * @param {import('better-sqlite3').Database} db + */ +const makeSwingstore = db => { + const sql = dbTool(db); + + /** @param {string} key */ + // @ts-expect-error cast + 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}`, + getTerminated: () => kvGetJSON('vats.terminated').includes(vatID), + }); + }; + + /** + * @param {string} vatName + * @param {boolean} [includeTerminated] + * @returns {string[]} + */ + const findDynamicVatIDs = (vatName, includeTerminated = false) => { + /** @type {string[]} */ + const terminatedVatIDs = kvGetJSON('vats.terminated'); + /** @type {string[]} */ + const allDynamicIDs = kvGetJSON('vat.dynamicIDs'); + const dynamicIDs = includeTerminated + ? allDynamicIDs + : allDynamicIDs.filter(vatID => !terminatedVatIDs.includes(vatID)); + const matchingIDs = dynamicIDs.filter(vatID => + lookupVat(vatID).options().name.includes(vatName), + ); + return matchingIDs; + }; + + return Object.freeze({ + /** + * @param {string} vatName + * @param {boolean} [includeTerminated] + * @returns {string} + */ + findVat: (vatName, includeTerminated = false) => { + /** @type {string[]} */ + const matchingIDs = findDynamicVatIDs(vatName, includeTerminated); + if (matchingIDs.length === 0) throw Error(`vat not found: ${vatName}`); + return matchingIDs[0]; + }, + findVats: findDynamicVatIDs, + lookupVat, + }); +}; + +/** + * @param {string} vatName + * @param {boolean} [includeTerminated] + */ +export const getDetailsMatchingVats = async ( + vatName, + includeTerminated = false, +) => { + const kStore = makeSwingstore( + dbOpenAmbient(swingstorePath, { readonly: true }), + ); + + const vatIDs = kStore.findVats(vatName, includeTerminated); + const infos = []; + for (const vatID of vatIDs) { + const vatInfo = kStore.lookupVat(vatID); + const name = vatInfo.options().name; + const source = vatInfo.source(); + const terminated = includeTerminated && vatInfo.getTerminated(); + // @ts-expect-error cast + const { incarnation } = vatInfo.currentSpan(); + infos.push({ vatName: name, vatID, incarnation, terminated, ...source }); + } + + return infos; +}; diff --git a/golang/cosmos/app/upgrade.go b/golang/cosmos/app/upgrade.go index 49d35c071d4..7f682710986 100644 --- a/golang/cosmos/app/upgrade.go +++ b/golang/cosmos/app/upgrade.go @@ -171,6 +171,26 @@ func replacePriceFeedsCoreProposal(upgradeName string) (vm.CoreProposalStep, err ) } +func terminateGovernorCoreProposal(upgradeName string) (vm.CoreProposalStep, error) { + // targets is a slice of "$boardID:$instanceKitLabel" strings. + var targets []string + switch getVariantFromUpgradeName(upgradeName) { + case "MAINNET": + targets = []string{"board052184:stkATOM-USD_price_feed"} + case "A3P_INTEGRATION": + targets = []string{"board04091:stATOM-USD_price_feed"} + default: + return nil, nil + } + + return buildProposalStepWithArgs( + "@agoric/builders/scripts/vats/terminate-governor-instance.js", + // Request `defaultProposalBuilder(powers, targets)`. + "defaultProposalBuilder", + []any{targets}, + ) +} + // func upgradeMintHolderCoreProposal(upgradeName string) (vm.CoreProposalStep, error) { // variant := getVariantFromUpgradeName(upgradeName) @@ -285,6 +305,13 @@ func unreleasedUpgradeHandler(app *GaiaApp, targetUpgrade string) func(sdk.Conte // "@agoric/builders/scripts/vats/upgrade-asset-reserve.js", // ), // ) + + terminateOldGovernor, err := terminateGovernorCoreProposal(targetUpgrade) + if err != nil { + return nil, err + } else if terminateOldGovernor != nil { + CoreProposalSteps = append(CoreProposalSteps, terminateOldGovernor) + } } app.upgradeDetails = &upgradeDetails{ diff --git a/packages/builders/scripts/vats/terminate-governor-instance.js b/packages/builders/scripts/vats/terminate-governor-instance.js new file mode 100644 index 00000000000..8712a484f64 --- /dev/null +++ b/packages/builders/scripts/vats/terminate-governor-instance.js @@ -0,0 +1,134 @@ +/** + * @file Terminate price-feed governor instances such as mainnet v110. + * Functions as both an off-chain builder and an on-chain core-eval. + */ + +/// + +import { E } from '@endo/far'; + +const SELF = '@agoric/builders/scripts/vats/terminate-governor-instance.js'; +const USAGE = `Usage: agoric run /path/to/terminate-governor-instance.js \\ + <$governorInstanceHandleBoardID:$instanceKitLabel>...`; + +const repr = val => + typeof val === 'string' || (typeof val === 'object' && val !== null) + ? JSON.stringify(val) + : String(val); +const defaultMakeError = (strings, ...subs) => + Error( + strings.map((s, i) => `${i === 0 ? '' : repr(subs[i - 1])}${s}`).join(''), + ); +const makeUsageError = (strings, ...subs) => { + const err = defaultMakeError(strings, ...subs); + console.error(err.message); + console.error(USAGE); + return err; +}; + +const rtarget = /^(?board[0-9]+):(?.+)$/; +/** + * @param {string[]} args + * @param {(strings: TemplateStringsArray | string[], ...subs: unknown[]) => Error} [makeError] + * @returns {Array<{boardID: string, instanceKitLabel: string}>} + */ +const parseTargets = (args = [], makeError = defaultMakeError) => { + if (!Array.isArray(args)) throw makeError`invalid targets: ${args}`; + /** @type {Array<{boardID: string, instanceKitLabel: string}>} */ + const targets = []; + const badTargets = []; + for (const arg of args) { + const m = typeof arg === 'string' && arg.match(rtarget); + if (!m) { + badTargets.push(arg); + } else { + // @ts-expect-error cast + targets.push(m.groups); + } + } + if (badTargets.length !== 0) { + throw makeError`malformed target(s): ${badTargets}`; + } else if (targets.length === 0) { + throw makeError`no target(s)`; + } + return targets; +}; + +/** + * @param {BootstrapPowers} powers + * @param {{ options: { targetSpecifiers: string[] } }} config + */ +export const terminateGovernors = async ( + { consume: { board, governedContractKits } }, + { options: { targetSpecifiers } }, +) => { + const { Fail, quote: q } = assert; + const targets = parseTargets(targetSpecifiers, Fail); + const doneP = Promise.allSettled( + targets.map(async ({ boardID, instanceKitLabel }) => { + const logLabel = [boardID, instanceKitLabel]; + const contractInstanceHandle = await E(board).getValue(boardID); + const instanceKit = await E(governedContractKits).get( + // @ts-expect-error TS2345 Property '[tag]' is missing + contractInstanceHandle, + ); + console.log( + `${q(logLabel)} alleged governor contract instance kit`, + instanceKit, + ); + const { label, governorAdminFacet, adminFacet } = instanceKit; + label === instanceKitLabel || + Fail`${q(logLabel)} unexpected instanceKit label, got ${label} but wanted ${q(instanceKitLabel)}`; + (adminFacet && adminFacet !== governorAdminFacet) || + Fail`${q(logLabel)} instanceKit adminFacet should have been present and different from governorAdminFacet but was ${adminFacet}`; + const reason = harden(Error(`core-eval terminating ${label} governor`)); + await E(governorAdminFacet).terminateContract(reason); + console.log(`${q(logLabel)} terminated governor`); + }), + ); + const results = await doneP; + const problems = targets.flatMap(({ boardID, instanceKitLabel }, i) => { + if (results[i].status === 'fulfilled') return []; + return [[boardID, instanceKitLabel, results[i].reason]]; + }); + if (problems.length !== 0) { + console.error('governor termination(s) failed', problems); + Fail`governor termination(s) failed: ${problems}`; + } +}; +harden(terminateGovernors); + +export const getManifest = (_powers, targetSpecifiers) => { + parseTargets(targetSpecifiers); + return { + manifest: { + [terminateGovernors.name]: { + consume: { board: true, governedContractKits: true }, + }, + }, + // Provide `terminateGovernors` a second argument like + // `{ options: { targetSpecifiers } }`. + options: { targetSpecifiers }, + }; +}; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').CoreEvalBuilder} */ +export const defaultProposalBuilder = async (_utils, targetSpecifiers) => { + parseTargets(targetSpecifiers); + return harden({ + sourceSpec: SELF, + getManifestCall: ['getManifest', targetSpecifiers], + }); +}; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').DeployScriptFunction} */ +export default async (homeP, endowments) => { + const { scriptArgs } = endowments; + parseTargets(scriptArgs, makeUsageError); + const dspModule = await import('@agoric/deploy-script-support'); + const { makeHelpers } = dspModule; + const { writeCoreEval } = await makeHelpers(homeP, endowments); + await writeCoreEval(terminateGovernors.name, utils => + defaultProposalBuilder(utils, scriptArgs), + ); +};