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' }, + ]); +});