diff --git a/a3p-integration/proposals/z:acceptance/invitation-test-submission/send-script-permit.json b/a3p-integration/proposals/z:acceptance/invitation-test-submission/send-script-permit.json new file mode 100644 index 000000000000..27ba77ddaf61 --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/invitation-test-submission/send-script-permit.json @@ -0,0 +1 @@ +true diff --git a/a3p-integration/proposals/z:acceptance/invitation-test-submission/send-script.tjs b/a3p-integration/proposals/z:acceptance/invitation-test-submission/send-script.tjs new file mode 100644 index 000000000000..f27af8cccd5a --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/invitation-test-submission/send-script.tjs @@ -0,0 +1,28 @@ +#! false node --ignore-this-line +/* global E */ + +/// +/// + +/** + * Send a payment by looking up deposit facet via namesByAddress. + * + * see ./post.test.js + * + * @param {BootstrapPowers} powers + */ +const sendIt = async powers => { + const addr = '{{ADDRESS}}'; + const { + consume: { namesByAddress, zoe }, + instance: { + consume: { reserve }, + }, + } = powers; + const pf = E(zoe).getPublicFacet(reserve); + const anInvitation = await E(pf).makeAddCollateralInvitation(); + const addressDepositFacet = E(namesByAddress).lookup(addr, 'depositFacet'); + await E(addressDepositFacet).receive(anInvitation); +}; + +sendIt; diff --git a/a3p-integration/proposals/z:acceptance/scripts/exitOffer.js b/a3p-integration/proposals/z:acceptance/scripts/exitOffer.js new file mode 100644 index 000000000000..fb6d2242b855 --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/scripts/exitOffer.js @@ -0,0 +1,97 @@ +// Note: limit imports to node modules for portability +import { parseArgs, promisify } from 'node:util'; +import { execFile } from 'node:child_process'; +import { writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +const options = /** @type {const} */ ({ + id: { type: 'string' }, + from: { type: 'string' }, + bin: { type: 'string', default: '/usr/src/agoric-sdk/node_modules/.bin' }, +}); + +const Usage = ` +Try to exit an offer, reclaiming any associated payments. + + node exitOffer.js --id ID --from FROM [--bin PATH] + +Options: + --id + --from
+ + --bin default: ${options.bin.default} +`; + +const badUsage = () => { + const reason = new Error(Usage); + reason.name = 'USAGE'; + throw reason; +}; + +const { stringify: q } = JSON; +// limited to JSON data: no remotables/promises; no undefined. +const toCapData = data => ({ body: `#${q(data)}`, slots: [] }); + +const { entries } = Object; +/** + * @param {Record} obj - e.g. { color: 'blue' } + * @returns {string[]} - e.g. ['--color', 'blue'] + */ +const flags = obj => + entries(obj) + .map(([k, v]) => [`--${k}`, v]) + .flat(); + +const execP = promisify(execFile); + +const showAndRun = (file, args) => { + console.log('$', file, ...args); + return execP(file, args); +}; + +const withTempFile = async (tail, fn) => { + const tmpDir = await mkdtemp('offers-'); + const tmpFile = join(tmpDir, tail); + try { + const result = await fn(tmpFile); + return result; + } finally { + await rm(tmpDir, { recursive: true, force: true }).catch(err => + console.error(err), + ); + } +}; + +const doAction = async (action, from) => { + await withTempFile('offer.json', async tmpOffer => { + await writeFile(tmpOffer, q(toCapData(action))); + + const out = await showAndRun('agoric', [ + 'wallet', + ...flags({ 'keyring-backend': 'test' }), + 'send', + ...flags({ offer: tmpOffer, from }), + ]); + return out.stdout; + }); +}; + +const main = async (argv, env) => { + const { values } = parseArgs({ args: argv.slice(2), options }); + const { id: offerId, from, bin } = values; + (offerId && from) || badUsage(); + + env.PATH = `${bin}:${env.PATH}`; + const action = { method: 'tryExitOffer', offerId }; + const out = await doAction(action, from); + console.log(out); +}; + +main(process.argv, process.env).catch(e => { + if (e.name === 'USAGE' || e.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') { + console.error(e.message); + } else { + console.error(e); + } + process.exit(1); +}); diff --git a/a3p-integration/proposals/z:acceptance/test.sh b/a3p-integration/proposals/z:acceptance/test.sh index 0eb07f8af7f8..3e264f2b996b 100755 --- a/a3p-integration/proposals/z:acceptance/test.sh +++ b/a3p-integration/proposals/z:acceptance/test.sh @@ -22,3 +22,6 @@ yarn ava valueVow.test.js echo ACCEPTANCE TESTING state sync ./state-sync-snapshots-test.sh ./genesis-test.sh + +echo ACCEPTANCE TESTING wallet +yarn ava wallet.test.js \ No newline at end of file diff --git a/a3p-integration/proposals/z:acceptance/wallet.test.js b/a3p-integration/proposals/z:acceptance/wallet.test.js new file mode 100644 index 000000000000..955791e76bf9 --- /dev/null +++ b/a3p-integration/proposals/z:acceptance/wallet.test.js @@ -0,0 +1,78 @@ +import test from 'ava'; +import { $ } from 'execa'; +import { execFileSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { + makeAgd, + waitForBlock, + getUser, + evalBundles, + agoric, +} from '@agoric/synthetic-chain'; + +const SUBMISSION_DIR = 'invitation-test-submission'; + +/** + * @param {string} fileName base file name without .tjs extension + * @param {Record} replacements + */ +const replaceTemplateValuesInFile = async (fileName, replacements) => { + let script = await readFile(`${fileName}.tjs`, 'utf-8'); + for (const [template, value] of Object.entries(replacements)) { + script = script.replaceAll(`{{${template}}}`, value); + } + await writeFile(`${fileName}.js`, script); +}; + +test.serial(`send invitation via namesByAddress`, async t => { + const addr = await getUser('gov1'); + + await replaceTemplateValuesInFile(`${SUBMISSION_DIR}/send-script`, { + ADDRESS: addr, + }); + + await evalBundles(SUBMISSION_DIR); + + await waitForBlock(2); // enough time for invitation to arrive? + const update = await agoric.follow('-lF', `:published.wallet.${addr}`); + t.is(update.updated, 'balance'); + t.notDeepEqual(update.currentAmount.value, []); + t.log('balance value', update.currentAmount.value); + t.log('balance brand', update.currentAmount.brand); + // XXX agoric follow returns brands as strings + t.regex(update.currentAmount.brand, /Invitation/); +}); + +test.serial('exitOffer tool reclaims stuck payment', async t => { + const offerId = 'bad-invitation-15'; + const from = 'gov1'; + + const showAndExec = (file, args, opts) => { + console.log('$', file, ...args); + return execFileSync(file, args, opts); + }; + + // @ts-expect-error string is not assignable to Buffer + const agd = makeAgd({ execFileSync: showAndExec }).withOpts({ + keyringBackend: 'test', + }); + + const addr = await agd.lookup(from); + t.log(from, 'addr', addr); + + const getBalance = async target => { + const { balances } = await agd.query(['bank', 'balances', addr]); + const { amount } = balances.find(({ denom }) => denom === target); + return Number(amount); + }; + + const before = await getBalance('uist'); + t.log('uist balance before:', before); + + await $`node ./scripts/exitOffer.js --id ${offerId} --from ${from}`; + + await waitForBlock(2); + const after = await getBalance('uist'); + t.log('uist balance after:', after); + t.true(after > before); +});