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