From e0e0c36d9636e54e40caad069778206b0317a21e Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Thu, 5 Jan 2023 17:05:43 -0600 Subject: [PATCH] test(cosmic-swingset): pay 10 BLD for acct with 0.25 IST (SKIP) The new provision test is skipped because it's not reliable enough to run in CI, but it's useful and we'd like to keep the scenario2 refactoring work. - Makefile - note deep stack debug options - parameterize $(BLOCKS_TO_RUN) in scenario2-run-chain-to-halt - announce end of run-to-halt Makefile (SQUASHME) - factor out scenario2 shared state - only use ambient authority in test.before() - don't try to fund the provision pool with more than the PSM IST minting limit - clarify large numerals: '1234000000uist' -> `${1234e6}uist` - give names to constants such as initialHeight = 17 - document vbank/provision module address --- packages/cosmic-swingset/Makefile | 12 +- packages/cosmic-swingset/test/scenario2.js | 153 ++++++++++++++++++ packages/cosmic-swingset/test/test-make.js | 74 ++++----- .../test/test-provision-smartwallet.js | 105 ++++++++++++ 4 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 packages/cosmic-swingset/test/scenario2.js create mode 100644 packages/cosmic-swingset/test/test-provision-smartwallet.js diff --git a/packages/cosmic-swingset/Makefile b/packages/cosmic-swingset/Makefile index 7293544eb07..d3f4821851f 100644 --- a/packages/cosmic-swingset/Makefile +++ b/packages/cosmic-swingset/Makefile @@ -19,6 +19,8 @@ VOTE_PROPOSAL = 1 VOTE_OPTION = yes GOSRC = ../../golang/cosmos +# For deep (cross-vat) stacks, try... +# DEBUG ?= SwingSet:ls,SwingSet:vat,track-turns DEBUG ?= SwingSet:ls,SwingSet:vat AG_SOLO = DEBUG=$(DEBUG) $(shell cd ../solo/bin && pwd)/ag-solo AGC = DEBUG=$(DEBUG) PATH="$$PWD/bin:$$PATH" $(GOSRC)/build/agd @@ -150,10 +152,12 @@ scenario2-run-chain: ../vats/decentral-devnet-config.json $(AGC) --home=t1/n0 start --log_level=warn $(AGC_START_ARGS) # Run a chain with an explicit halt. +BLOCKS_TO_RUN=3 scenario2-run-chain-to-halt: t1/decentral-economy-config.json CHAIN_BOOTSTRAP_VAT_CONFIG="$$PWD/t1/decentral-economy-config.json" \ - $(AGC) --home=t1/n0 start --log_level=warn --halt-height=$$(($(INITIAL_HEIGHT) + 3)); \ + $(AGC) --home=t1/n0 start --log_level=warn --halt-height=$$(($(INITIAL_HEIGHT) + $(BLOCKS_TO_RUN))); \ test "$$?" -eq 98 + echo ran to $(INITIAL_HEIGHT) + $(BLOCKS_TO_RUN) # Blow away all client state to try again without resetting the chain. scenario2-reset-client: @@ -227,6 +231,12 @@ provision-acct: tx swingset provision-one t1/$(BASE_PORT) $(ACCT_ADDR) SMART_WALLET \ --gas=auto --gas-adjustment=$(GAS_ADJUSTMENT) --broadcast-mode=block --yes +provision-my-acct: + $(AGCH) --chain-id=$(CHAIN_ID) \ + --home t1/8000/ag-cosmos-helper-statedir --keyring-backend=test --from=ag-solo \ + tx swingset provision-one t1/$(BASE_PORT) $(ACCT_ADDR) SMART_WALLET \ + --gas=auto --gas-adjustment=$(GAS_ADJUSTMENT) --broadcast-mode=block --yes + FROM_KEY=bootstrap SIGN_MODE= wallet-action: wait-for-cosmos diff --git a/packages/cosmic-swingset/test/scenario2.js b/packages/cosmic-swingset/test/scenario2.js new file mode 100644 index 00000000000..5a62acad806 --- /dev/null +++ b/packages/cosmic-swingset/test/scenario2.js @@ -0,0 +1,153 @@ +/* eslint-disable no-await-in-loop */ +const { freeze, entries } = Object; + +const onlyStderr = ['ignore', 'ignore', 'inherit']; +const noOutput = ['ignore', 'ignore', 'ignore']; +// const noisyDebug = ['ignore', 'inherit', 'inherit']; + +export const pspawn = + (bin, { spawn, cwd }) => + (args = [], opts = {}) => { + let child; + const exit = new Promise((resolve, reject) => { + // console.debug('spawn', bin, args, { cwd: makefileDir, ...opts }); + child = spawn(bin, args, { cwd, ...opts }); + child.addListener('exit', code => { + if (code !== 0) { + reject(Error(`exit ${code} from: ${bin} ${args}`)); + return; + } + resolve(0); + }); + }); + return { child, exit }; + }; + +/** + * Shared state for tests using scenario2 chain in ../ + * + * @param {object} io + * @param {*} io.pspawnMake promise-style spawn of 'make' with cwd set + * @param {*} io.pspawnAgd promise-style spawn of 'ag-chain-cosmos' with cwd set + * @param {typeof console.log} io.log + */ +export const makeScenario2 = ({ pspawnMake, pspawnAgd, log }) => { + const runMake = (args, opts = { stdio: onlyStderr }) => { + // console.debug('make', ...args); + log('make', ...args); + + return pspawnMake(args, opts).exit; + }; + + // {X: 1} => ['X=1'], using JSON.stringify to mitigate injection risks. + const bind = obj => + entries(obj) + .filter(([_n, v]) => typeof v !== 'undefined') + .map(([n, v]) => [`${n}=${JSON.stringify(v)}`]) + .flat(); + + return freeze({ + runMake, + setup: () => runMake(['scenario2-setup'], { stdio: noOutput }), + runToHalt: ({ + BLOCKS_TO_RUN = undefined, + INITIAL_HEIGHT = undefined, + } = {}) => + runMake([ + 'scenario2-run-chain-to-halt', + ...bind({ BLOCKS_TO_RUN, INITIAL_HEIGHT }), + ]), + export: () => + pspawnAgd(['export', '--home=t1/n0'], { stdio: onlyStderr }).exit, + }); +}; + +/** + * Wallet utilities for scenario2. + * + * @param {object} io + * @param {*} io.runMake from makeScenario2 above + * @param {*} io.pspawnAgd as to makeScenario2 above + * @param {(ms: number) => Promise} io.delay + * @param {typeof console.log} io.log + */ +export const makeWalletTool = ({ runMake, pspawnAgd, delay, log }) => { + /** + * @param {string[]} args + * @returns {Promise} JSON.parse of stdout of `ag-chain-cosmos query <...args>` + * @throws if agd exits non-0 or gives empty output + */ + const query = async args => { + const parts = []; + const cmd = pspawnAgd(['query', ...args]); + cmd.child.stdout.on('data', chunk => parts.push(chunk)); + await cmd.exit; + const txt = parts.join('').trim(); + if (txt === '') { + throw Error(`empty output from: query ${args}`); + } + return JSON.parse(txt); + }; + + const queryBalance = addr => + query(['bank', 'balances', addr, '--output', 'json']).then(b => { + console.log(addr, b); + return b; + }); + + let currentHeight; + const waitForBlock = async (why, targetHeight, relative = false) => { + if (relative) { + if (typeof currentHeight === 'undefined') { + throw Error('cannot use relative before starting'); + } + targetHeight += currentHeight; + } + for (;;) { + try { + const info = await query(['block']); + currentHeight = Number(info?.block?.header?.height); + if (currentHeight >= targetHeight) { + log(info?.block?.header?.time, ' block ', currentHeight); + return currentHeight; + } + console.log(why, ':', currentHeight, '<', targetHeight, '5 sec...'); + } catch (reason) { + console.warn(why, '2:', reason?.message, '5sec...'); + } + await delay(5000); + } + }; + + // one tx per block per address + const myTurn = new Map(); // addr -> blockHeight + const waitMyTurn = async (why, addr) => { + const lastTurn = myTurn.get(addr) || 0; + const nextHeight = await waitForBlock(why, lastTurn + 1); + myTurn.set(addr, nextHeight); + }; + + // {X: 1} => ['X=1'], using JSON.stringify to mitigate injection risks. + const bind = obj => + entries(obj) + .filter(([_n, v]) => typeof v !== 'undefined') + .map(([n, v]) => [`${n}=${JSON.stringify(v)}`]) + .flat(); + + return freeze({ + query, + queryBalance, + waitForBlock, + fundAccount: async (ACCT_ADDR, FUNDS) => { + const a4 = ACCT_ADDR.slice(-4); + await waitMyTurn(`fund ${a4}`, 'bootstrap'); + await runMake([...bind({ ACCT_ADDR, FUNDS }), 'fund-acct']); + await waitForBlock(`${a4}'s funds to appear`, 2, true); + return queryBalance(ACCT_ADDR); + }, + provisionMine: ACCT_ADDR => + waitMyTurn('provision', ACCT_ADDR).then(() => + runMake([...bind({ ACCT_ADDR }), 'provision-my-acct']), + ), + }); +}; diff --git a/packages/cosmic-swingset/test/test-make.js b/packages/cosmic-swingset/test/test-make.js index a7e456bee60..e02528f814d 100644 --- a/packages/cosmic-swingset/test/test-make.js +++ b/packages/cosmic-swingset/test/test-make.js @@ -1,45 +1,41 @@ import test from 'ava'; -import { spawn } from 'child_process'; -import path from 'path'; -const filename = new URL(import.meta.url).pathname; -const dirname = path.dirname(filename); +// Use ambient authority only in test.before() +import { spawn as ambientSpawn } from 'child_process'; +import * as ambientPath from 'path'; -test('make and exec', async t => { - await new Promise(resolve => - spawn('make', ['scenario2-setup'], { - cwd: `${dirname}/..`, - stdio: ['ignore', 'ignore', 'inherit'], - }).addListener('exit', code => { - t.is(code, 0, 'make scenario2-setup exits successfully'); - resolve(); - }), - ); - await new Promise(resolve => - spawn('bin/ag-chain-cosmos', { - cwd: `${dirname}/..`, - stdio: ['ignore', 'ignore', 'inherit'], - }).addListener('exit', code => { - t.is(code, 0, 'exec exits successfully'); - resolve(); - }), - ); - await new Promise(resolve => - spawn('make', ['scenario2-run-chain-to-halt'], { - cwd: `${dirname}/..`, - stdio: ['ignore', 'ignore', 'inherit'], - }).addListener('exit', code => { - t.is(code, 0, 'make scenario2-run-chain-to-halt is successful'); - resolve(); - }), +import { makeScenario2, pspawn } from './scenario2.js'; + +test.before(async t => { + const filename = new URL(import.meta.url).pathname; + const dirname = ambientPath.dirname(filename); + const makefileDir = ambientPath.join(dirname, '..'); + + const io = { spawn: ambientSpawn, cwd: makefileDir }; + const pspawnMake = pspawn('make', io); + const pspawnAgd = pspawn('bin/ag-chain-cosmos', io); + const scenario2 = makeScenario2({ pspawnMake, pspawnAgd, log: t.log }); + await scenario2.setup(); + + t.context = { scenario2, pspawnAgd }; +}); + +test.serial('make and exec', async t => { + const { pspawnAgd, scenario2 } = t.context; + t.log('exec agd'); + t.is(await pspawnAgd([]).exit, 0, 'exec agd exits successfully'); + t.log('run chain to halt'); + t.is( + await scenario2.runToHalt(), + 0, + 'make scenario2-run-chain-to-halt is successful', ); - await new Promise(resolve => - spawn('bin/ag-chain-cosmos', ['export', '--home=t1/n0'], { - cwd: `${dirname}/..`, - stdio: ['ignore', 'ignore', 'inherit'], - }).addListener('exit', code => { - t.is(code, 0, 'export exits successfully'); - resolve(); - }), + t.log('resume chain and halt'); + t.is( + await scenario2.runToHalt(), + 0, + 'make scenario2-run-chain-to-halt succeeds again', ); + t.log('export'); + t.is(await scenario2.export(), 0, 'export exits successfully'); }); diff --git a/packages/cosmic-swingset/test/test-provision-smartwallet.js b/packages/cosmic-swingset/test/test-provision-smartwallet.js new file mode 100644 index 00000000000..038fce17ddc --- /dev/null +++ b/packages/cosmic-swingset/test/test-provision-smartwallet.js @@ -0,0 +1,105 @@ +/* global setTimeout */ +import test from 'ava'; + +// Use ambient authority only in test.before() +import { spawn as ambientSpawn } from 'child_process'; +import * as ambientPath from 'path'; +import * as ambientFs from 'fs'; + +import { makeScenario2, makeWalletTool, pspawn } from './scenario2.js'; + +// module account address for 'vbank/provision'; aka "megz" +// +// It seems to be some sort of hash of the name, 'vbank/provision'. +// Lack of documentation is a known issue: +// https://github.com/cosmos/cosmos-sdk/issues/8411 +// +// In `startWalletFactory.js` we have: +// `E(bankManager).getModuleAccountAddress('vbank/provision')` +// Then in `vat-bank.js` we have a `VBANK_GET_MODULE_ACCOUNT_ADDRESS` +// call across the bridge to golang; `vbank.go` handles it +// by way of `GetModuleAccountAddress` which calls into the cosmos-sdk +// `x/auth` module... over hill and dale, we seem to end up +// with `crypto.AddressHash([]byte(name))` at +// https://github.com/cosmos/cosmos-sdk/blob/512953cd689fd96ef454e424c81c1a0da5782074/x/auth/types/account.go#L158 +// +// Whether this implementation is even correct seems to be +// at issue: +// ModuleAccount addresses don't follow ADR-028 +// https://github.com/cosmos/cosmos-sdk/issues/13782 Nov 2022 +const provisionPoolModuleAccount = + 'agoric1megzytg65cyrgzs6fvzxgrcqvwwl7ugpt62346'; + +test.before(async t => { + const filename = new URL(import.meta.url).pathname; + const dirname = ambientPath.dirname(filename); + const makefileDir = ambientPath.join(dirname, '..'); + + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + + const io = { spawn: ambientSpawn, cwd: makefileDir }; + const pspawnMake = pspawn('make', io); + const pspawnAgd = pspawn('bin/ag-chain-cosmos', io); + const scenario2 = makeScenario2({ pspawnMake, pspawnAgd, delay, log: t.log }); + const walletTool = makeWalletTool({ + runMake: scenario2.runMake, + pspawnAgd, + delay, + log: t.log, + }); + await scenario2.setup(); + + const { readFile } = ambientFs.promises; + const readItem = f => readFile(f, 'utf-8').then(line => line.trim()); + const soloAddr = await readItem('./t1/8000/ag-cosmos-helper-address'); + const bootstrapAddr = await readItem('./t1/bootstrap-address'); + // console.debug('scenario2 addresses', { soloAddr, bootstrapAddr }); + + t.context = { scenario2, walletTool, pspawnAgd, bootstrapAddr, soloAddr }; +}); + +// SKIP: struggling with timing issues resulting in one of... +// Error: cannot grab 250000uist coins: 0uist is smaller than 250000uist: insufficient funds +// error: code = NotFound desc = account agoric1mhu... not found +// Sometimes I can get this test to work alone, but not +// if run with the test above. +// TODO: https://github.com/Agoric/agoric-sdk/issues/6766 +test.skip('integration test: smart wallet provision', async t => { + const { scenario2, walletTool, soloAddr } = t.context; + + const enoughBlocksToProvision = 7; + const provision = async () => { + // idea: scenario2.waitForBlock(2, true); // let bootstrap account settle down. + // idea: console.log('bootstrap ready', scenario2.queryBalance(bootstrapAddr)); + t.log('Fund pool with USDC'); + await walletTool.fundAccount( + provisionPoolModuleAccount, + `${234e6}ibc/usdc1234`, + ); + t.log('Fund user account with some BLD'); + await walletTool.fundAccount(soloAddr, `${123e6}ubld`); + t.log('Provision smart wallet'); + await walletTool.provisionMine(soloAddr, soloAddr); + + await walletTool.waitForBlock( + 'provision to finish', + enoughBlocksToProvision, + true, + ); + return walletTool.queryBalance(soloAddr); + }; + + const queryGrace = 6; // time to query state before shutting down + const [_run, addrQ] = await Promise.all([ + scenario2.runToHalt({ + BLOCKS_TO_RUN: enoughBlocksToProvision + queryGrace, + }), + provision(), + ]); + + t.log('verify 10BLD spent, 0.25 IST received'); + t.deepEqual(addrQ.balances, [ + { amount: `${(123 - 10) * 1e6}`, denom: 'ubld' }, + { amount: `${0.25 * 1e6}`, denom: 'uist' }, + ]); +});