diff --git a/packages/orchestration/src/examples/send-anywhere.contract.js b/packages/orchestration/src/examples/send-anywhere.contract.js index ebe77f1af7d..fc7bfc1891b 100644 --- a/packages/orchestration/src/examples/send-anywhere.contract.js +++ b/packages/orchestration/src/examples/send-anywhere.contract.js @@ -47,7 +47,7 @@ const contract = async ( const orchFns = orchestrateAll(flows, { zcf, contractState, - localTransfer: zoeTools.localTransfer, + zoeTools, }); const publicFacet = zone.exo( diff --git a/packages/orchestration/src/examples/send-anywhere.flows.js b/packages/orchestration/src/examples/send-anywhere.flows.js index 8573dea7b13..c8231d915b0 100644 --- a/packages/orchestration/src/examples/send-anywhere.flows.js +++ b/packages/orchestration/src/examples/send-anywhere.flows.js @@ -1,8 +1,9 @@ import { NonNullish } from '@agoric/internal'; +import { makeError, q } from '@endo/errors'; import { M, mustMatch } from '@endo/patterns'; /** - * @import {GuestOf} from '@agoric/async-flow'; + * @import {GuestInterface} from '@agoric/async-flow'; * @import {ZoeTools} from '../utils/zoe-tools.js'; * @import {Orchestrator, LocalAccountMethods, OrchestrationAccountI, OrchestrationFlow} from '../types.js'; */ @@ -17,13 +18,13 @@ const { entries } = Object; * @param {Orchestrator} orch * @param {object} ctx * @param {{ localAccount?: OrchestrationAccountI & LocalAccountMethods }} ctx.contractState - * @param {GuestOf} ctx.localTransfer + * @param {GuestInterface} ctx.zoeTools * @param {ZCFSeat} seat * @param {{ chainName: string; destAddr: string }} offerArgs */ export const sendIt = async ( orch, - { contractState, localTransfer }, + { contractState, zoeTools: { localTransfer, withdrawToSeat } }, seat, offerArgs, ) => { @@ -51,14 +52,22 @@ export const sendIt = async ( await localTransfer(seat, contractState.localAccount, give); - await contractState.localAccount.transfer( - { denom, value: amt.value }, - { - value: destAddr, - encoding: 'bech32', - chainId, - }, - ); + try { + await contractState.localAccount.transfer( + { denom, value: amt.value }, + { + value: destAddr, + encoding: 'bech32', + chainId, + }, + ); + } catch (e) { + await withdrawToSeat(contractState.localAccount, seat, give); + const errorMsg = `IBC Transfer failed ${q(e)}`; + seat.exit(errorMsg); + throw makeError(errorMsg); + } + seat.exit(); }; harden(sendIt); diff --git a/packages/orchestration/src/examples/staking-combinations.flows.js b/packages/orchestration/src/examples/staking-combinations.flows.js index 2961ee96dac..37712097096 100644 --- a/packages/orchestration/src/examples/staking-combinations.flows.js +++ b/packages/orchestration/src/examples/staking-combinations.flows.js @@ -8,7 +8,7 @@ */ import { mustMatch } from '@endo/patterns'; -import { makeError } from '@endo/errors'; +import { makeError, q } from '@endo/errors'; import { makeTracer } from '@agoric/internal'; import { ChainAddressShape } from '../typeGuards.js'; @@ -76,9 +76,10 @@ export const depositAndDelegate = async ( try { await contractState.localAccount.transfer(give.Stake, address); } catch (cause) { - // TODO, put funds back on user seat and exit - // https://github.com/Agoric/agoric-sdk/issues/9925 - throw makeError('ibc transfer failed', undefined, { cause }); + await zoeTools.withdrawToSeat(contractState.localAccount, seat, give); + const errMsg = makeError(`ibc transfer failed ${q(cause)}`); + seat.exit(errMsg); + throw errMsg; } seat.exit(); await account.delegate(validator, give.Stake); diff --git a/packages/orchestration/src/utils/start-helper.js b/packages/orchestration/src/utils/start-helper.js index f11da8db32c..e86cb6e6b0a 100644 --- a/packages/orchestration/src/utils/start-helper.js +++ b/packages/orchestration/src/utils/start-helper.js @@ -63,8 +63,6 @@ export const provideOrchestration = ( orchestration: zone.subZone('orchestration'), /** system names for vows */ vows: zone.subZone('vows'), - /** system names for zoe */ - zoe: zone.subZone('zoe'), /** contract-provided names, and subzones */ contract: zone.subZone('contract'), }; @@ -76,7 +74,7 @@ export const provideOrchestration = ( const chainHub = makeChainHub(agoricNames, vowTools); - const zoeTools = makeZoeTools(zones.zoe, { zcf, vowTools }); + const zoeTools = makeZoeTools(zcf, vowTools); const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( diff --git a/packages/orchestration/src/utils/zoe-tools.js b/packages/orchestration/src/utils/zoe-tools.js index abf7f5bb683..e81a1136e3c 100644 --- a/packages/orchestration/src/utils/zoe-tools.js +++ b/packages/orchestration/src/utils/zoe-tools.js @@ -1,13 +1,31 @@ -import { Fail } from '@endo/errors'; -import { atomicTransfer } from '@agoric/zoe/src/contractSupport/index.js'; +/** + * @file Helper functions for transferring payments between a LocalChainAccount + * and a ZCFSeat. + * + * Maintainers: This exists as an endowment for orchestrated async-flows so we + * can make use of E and promises. The logic for recovering partial failures + * is also an added convenience for developers. + * + * Functions are written using `asVow` and non-resumable promises as we expect + * each invocation to resolve promptly - there are no timers or interchain + * network calls. + * + * A promise resolved promptly is currently safe from being severed by an + * upgrade because we only trigger vat upgrades as the result of network + * input. + */ + +import { makeError, q, Fail } from '@endo/errors'; +import { depositToSeat } from '@agoric/zoe/src/contractSupport/index.js'; import { E } from '@endo/far'; +const { assign, keys, values } = Object; + /** + * @import {HostOf} from '@agoric/async-flow'; * @import {InvitationMakers} from '@agoric/smart-wallet/src/types.js'; * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; - * @import {Vow, VowTools} from '@agoric/vow'; - * @import {Zone} from '@agoric/zone'; - * @import {OrchestrationAccount} from '../orchestration-api.js' + * @import {VowTools} from '@agoric/vow'; * @import {LocalAccountMethods} from '../types.js'; */ @@ -24,31 +42,38 @@ import { E } from '@endo/far'; * @typedef {( * srcSeat: ZCFSeat, * localAccount: LocalAccountMethods, - * give: AmountKeywordRecord, + * amounts: AmountKeywordRecord, * ) => Promise} LocalTransfer */ /** - * @param {Zone} zone - * @param {{ zcf: ZCF; vowTools: VowTools }} io + * @typedef {( + * localAccount: LocalAccountMethods, + * destSeat: ZCFSeat, + * amounts: AmountKeywordRecord, + * ) => Promise} WithdrawToSeat + */ + +/** + * @param {ZCF} zcf + * @param {VowTools} vowTools */ -export const makeZoeTools = (zone, { zcf, vowTools }) => { +export const makeZoeTools = (zcf, { when, allVows, allSettled, asVow }) => { /** - * Transfer the `give` a seat to a local account. + * Transfer the `amounts` from `srcSeat` to `localAccount`. If any of the + * deposits fail, everything will be rolled back to the `srcSeat`. Supports + * multiple items in the `amounts` {@link AmountKeywordRecord}. + * + * @type {HostOf} */ - const localTransfer = vowTools.retriable( - zone, - 'localTransfer', - /** - * @type {LocalTransfer} - */ - async (srcSeat, localAccount, give) => { + const localTransfer = (srcSeat, localAccount, amounts) => + asVow(async () => { !srcSeat.hasExited() || Fail`The seat cannot have exited.`; const { zcfSeat: tempSeat, userSeat: userSeatP } = zcf.makeEmptySeatKit(); const userSeat = await userSeatP; - atomicTransfer(zcf, srcSeat, tempSeat, give); + zcf.atomicRearrange(harden([[srcSeat, tempSeat, amounts]])); tempSeat.exit(); - // TODO get the userSeat into baggage so it's at least recoverable + // TODO (#9541) get the userSeat into baggage so it's at least recoverable // const userSeat = await subzone.makeOnce( // 'localTransferHelper', // async () => { @@ -56,28 +81,106 @@ export const makeZoeTools = (zone, { zcf, vowTools }) => { // zcf.makeEmptySeatKit(); // const uSeat = await userSeatP; // // TODO how do I store in the place for this retriable? - // atomicTransfer(zcf, srcSeat, tempSeat, give); + // atomicTransfer(zcf, srcSeat, tempSeat, amounts); // tempSeat.exit(); // return uSeat; // }, // ); - // Now all the `give` are accessible, so we can move them to the localAccount` + // Now all the `amounts` are accessible, so we can move them to the localAccount + const payments = await Promise.all( + keys(amounts).map(kw => E(userSeat).getPayout(kw)), + ); + const settleDeposits = await when( + allSettled(payments.map(pmt => E(localAccount).deposit(pmt))), + ); + // if any of the deposits to localAccount failed, unwind all of the allocations + if (settleDeposits.find(x => x.status === 'rejected')) { + const amts = values(amounts); + const errors = []; + // withdraw the successfully deposited payments + const paymentsOrWithdrawVs = settleDeposits.map((x, i) => { + if (x.status === 'rejected') { + errors.push(x.reason); + return payments[i]; + } + return E(localAccount).withdraw(amts[i]); + }); - const depositVs = Object.entries(give).map(async ([kw, _amount]) => { - const pmt = await E(userSeat).getPayout(kw); - // TODO arrange recovery on upgrade of pmt? - return localAccount.deposit(pmt); - }); - await vowTools.when(vowTools.allVows(depositVs)); - // TODO remove userSeat from baggage - // TODO reject non-vbank issuers - // TODO recover failed deposits - }, - ); + // return all payments to the srcSeat + const paymentsToReturn = await when(allVows(paymentsOrWithdrawVs)); + const paymentKwr = harden( + keys(amounts).reduce( + (kwr, kw, i) => assign(kwr, { [kw]: paymentsToReturn[i] }), + {}, + ), + ); + const depositResponse = await depositToSeat( + zcf, + srcSeat, + amounts, + paymentKwr, + ); + console.debug(depositResponse); + throw makeError(`One or more deposits failed ${q(errors)}`); + } + // TODO #9541 remove userSeat from baggage + }); + + /** + * Transfer the `amounts` from a `localAccount` to the `recipientSeat`. If any + * of the withdrawals fail, everything will be rolled back to the + * `srcLocalAccount`. Supports multiple items in the `amounts` + * {@link PaymentKeywordRecord} + * + * @type {HostOf} + */ + const withdrawToSeat = (localAccount, destSeat, amounts) => + asVow(async () => { + !destSeat.hasExited() || Fail`The seat cannot have exited.`; + + const settledWithdrawals = await when( + allSettled(values(amounts).map(amt => E(localAccount).withdraw(amt))), + ); + + // if any of the withdrawals were rejected, unwind the successful ones + if (settledWithdrawals.find(x => x.status === 'rejected')) { + const returnPaymentVs = []; + const errors = []; + for (const result of settledWithdrawals) { + if (result.status === 'fulfilled') { + returnPaymentVs.push(E(localAccount).deposit(result.value)); + } else { + errors.push(result.reason); + } + } + await when(allVows(returnPaymentVs)); + throw makeError(`One or more withdrawals failed ${q(errors)}`); + } + // successfully withdrew payments from srcLocalAccount, deposit to recipientSeat + const paymentKwr = harden( + keys(amounts).reduce( + (acc, kw, i) => + assign(acc, { + [kw]: /** @type {{ value: Amount }[]} */ (settledWithdrawals)[i] + .value, + }), + {}, + ), + ); + const depositResponse = await depositToSeat( + zcf, + destSeat, + amounts, + paymentKwr, + ); + console.debug(depositResponse); + }); return harden({ localTransfer, + withdrawToSeat, }); }; + /** @typedef {ReturnType} ZoeTools */ diff --git a/packages/orchestration/test/examples/send-anywhere.test.ts b/packages/orchestration/test/examples/send-anywhere.test.ts index b350c53678e..3d2177a6aa8 100644 --- a/packages/orchestration/test/examples/send-anywhere.test.ts +++ b/packages/orchestration/test/examples/send-anywhere.test.ts @@ -4,17 +4,17 @@ import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; import { E } from '@endo/far'; import path from 'path'; import { mustMatch } from '@endo/patterns'; -import { makeIssuerKit } from '@agoric/ertp'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; import { eventLoopIteration, inspectMapStore, } from '@agoric/internal/src/testing-utils.js'; -import { inspect } from 'util'; +import { SIMULATED_ERRORS } from '@agoric/vats/tools/fake-bridge.js'; +import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; import { CosmosChainInfo, IBCConnectionInfo } from '../../src/cosmos-api.js'; import { commonSetup } from '../supports.js'; import { SingleAmountRecord } from '../../src/examples/send-anywhere.contract.js'; import { registerChain } from '../../src/chain-info.js'; -import { buildVTransferEvent } from '../../tools/ibc-mocks.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); @@ -226,10 +226,8 @@ test('send using arbitrary chain info', async t => { test('baggage', async t => { const { - bootstrap, commonPrivateArgs, brands: { ist }, - utils: { inspectLocalBridge, pourPayment }, } = await commonSetup(t); let contractBaggage; @@ -250,3 +248,189 @@ test('baggage', async t => { const tree = inspectMapStore(contractBaggage); t.snapshot(tree, 'contract baggage after start'); }); + +test('failed ibc transfer returns give', async t => { + t.log('bootstrap, orchestration core-eval'); + const { + bootstrap, + commonPrivateArgs, + brands: { ist }, + utils: { inspectLocalBridge, pourPayment, inspectBankBridge }, + } = await commonSetup(t); + const vt = bootstrap.vowTools; + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + + const installation: Installation = + await bundleAndInstall(contractFile); + + const storageNode = await E(bootstrap.storage.rootNode).makeChildNode( + contractName, + ); + const sendKit = await E(zoe).startInstance( + installation, + { Stable: ist.issuer }, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + t.log('client sends an ibc transfer we expect will timeout'); + + const publicFacet = await E(zoe).getPublicFacet(sendKit.instance); + const inv = E(publicFacet).makeSendInvitation(); + const amt = await E(zoe).getInvitationDetails(inv); + t.is(amt.description, 'send'); + + const anAmt = ist.make(SIMULATED_ERRORS.TIMEOUT); + const Send = await pourPayment(anAmt); + const userSeat = await E(zoe).offer( + inv, + { give: { Send: anAmt } }, + { Send }, + { destAddr: 'cosmos1destAddr', chainName: 'cosmoshub' }, + ); + + await eventLoopIteration(); + await E(userSeat).hasExited(); + const payouts = await E(userSeat).getPayouts(); + t.log('Failed offer payouts', payouts); + const amountReturned = await ist.issuer.getAmountOf(payouts.Send); + t.log('Failed offer Send amount', amountReturned); + t.deepEqual(anAmt, amountReturned, 'give is returned'); + + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: + 'IBC Transfer failed "[Error: simulated unexpected MsgTransfer packet timeout]"', + }); + + t.log('ibc MsgTransfer was attempted from a local chain account'); + const history = inspectLocalBridge(); + t.like(history, [ + { type: 'VLOCALCHAIN_ALLOCATE_ADDRESS' }, + { type: 'VLOCALCHAIN_EXECUTE_TX' }, + ]); + const [_alloc, { messages, address: execAddr }] = history; + t.is(messages.length, 1); + const [txfr] = messages; + t.log('local bridge', txfr); + t.like(txfr, { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + sender: execAddr, + sourcePort: 'transfer', + token: { amount: '504', denom: 'uist' }, + }); + + t.log('deposit to and withdrawal from LCA is observed in bank bridge'); + const bankHistory = inspectBankBridge(); + t.log('bank bridge', bankHistory); + t.deepEqual( + bankHistory[bankHistory.length - 2], + { + type: 'VBANK_GIVE', + recipient: 'agoric1fakeLCAAddress', + denom: 'uist', + amount: '504', + }, + 'funds sent to LCA', + ); + t.deepEqual( + bankHistory[bankHistory.length - 1], + { + type: 'VBANK_GRAB', + sender: 'agoric1fakeLCAAddress', + denom: 'uist', + amount: '504', + }, + 'funds withdrawn from LCA in catch block', + ); +}); + +test('non-vbank asset presented is returned', async t => { + t.log('bootstrap, orchestration core-eval'); + const { bootstrap, commonPrivateArgs } = await commonSetup(t); + const vt = bootstrap.vowTools; + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + const moolah = withAmountUtils(makeIssuerKit('MOO')); + + const installation: Installation = + await bundleAndInstall(contractFile); + const storageNode = await E(bootstrap.storage.rootNode).makeChildNode( + contractName, + ); + const sendKit = await E(zoe).startInstance( + installation, + { MOO: moolah.issuer }, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + const publicFacet = await E(zoe).getPublicFacet(sendKit.instance); + const inv = E(publicFacet).makeSendInvitation(); + + const anAmt = moolah.make(10n); + const Moo = moolah.mint.mintPayment(anAmt); + const userSeat = await E(zoe).offer( + inv, + { give: { Moo: anAmt } }, + { Moo }, + { destAddr: 'cosmos1destAddr', chainName: 'cosmoshub' }, + ); + + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: + '[object Alleged: MOO brand guest wrapper] not registered in vbank', + }); + + await E(userSeat).tryExit(); + const payouts = await E(userSeat).getPayouts(); + const amountReturned = await moolah.issuer.getAmountOf(payouts.Moo); + t.deepEqual(anAmt, amountReturned, 'give is returned'); +}); + +test('rejects multi-asset send', async t => { + t.log('bootstrap, orchestration core-eval'); + const { + bootstrap, + commonPrivateArgs, + brands: { ist, bld }, + utils: { pourPayment }, + } = await commonSetup(t); + const vt = bootstrap.vowTools; + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + const installation: Installation = + await bundleAndInstall(contractFile); + const storageNode = await E(bootstrap.storage.rootNode).makeChildNode( + contractName, + ); + const sendKit = await E(zoe).startInstance( + installation, + { BLD: bld.issuer, IST: ist.issuer }, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + const publicFacet = await E(zoe).getPublicFacet(sendKit.instance); + const inv = E(publicFacet).makeSendInvitation(); + + const tenBLD = bld.make(10n); + const tenIST = ist.make(10n); + + await t.throwsAsync( + E(zoe).offer( + inv, + { give: { BLD: tenBLD, IST: tenIST } }, + { BLD: await pourPayment(tenBLD), IST: await pourPayment(tenIST) }, + { destAddr: 'cosmos1destAddr', chainName: 'cosmoshub' }, + ), + { + message: + '"send" proposal: give: Must not have more than 1 properties: {"BLD":{"brand":"[Alleged: BLD brand]","value":"[10n]"},"IST":{"brand":"[Alleged: IST brand]","value":"[10n]"}}', + }, + ); +}); diff --git a/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md b/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md index 506844dfddd..11710ddf60d 100644 --- a/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.md @@ -36,8 +36,12 @@ Generated by [AVA](https://avajs.dev). 0: { contractState_kindHandle: 'Alleged: kind', contractState_singleton: 'Alleged: contractState', - localTransfer_kindHandle: 'Alleged: kind', - localTransfer_singleton: 'Alleged: localTransfer', + zoeTools: { + localTransfer_kindHandle: 'Alleged: kind', + localTransfer_singleton: 'Alleged: localTransfer', + withdrawToSeat_kindHandle: 'Alleged: kind', + withdrawToSeat_singleton: 'Alleged: withdrawToSeat', + }, }, }, }, @@ -64,5 +68,4 @@ Generated by [AVA](https://avajs.dev). VowInternalsKit_kindHandle: 'Alleged: kind', WatchUtils_kindHandle: 'Alleged: kind', }, - zoe: {}, } diff --git a/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.snap b/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.snap index 1017ab57dbc..bbf9e53542d 100644 Binary files a/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.snap and b/packages/orchestration/test/examples/snapshots/send-anywhere.test.ts.snap differ diff --git a/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md b/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md index 8fa76401b61..926a50924a3 100644 --- a/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.md @@ -51,6 +51,8 @@ Generated by [AVA](https://avajs.dev). zoeTools: { localTransfer_kindHandle: 'Alleged: kind', localTransfer_singleton: 'Alleged: localTransfer', + withdrawToSeat_kindHandle: 'Alleged: kind', + withdrawToSeat_singleton: 'Alleged: withdrawToSeat', }, }, }, @@ -76,6 +78,8 @@ Generated by [AVA](https://avajs.dev). zoeTools: { localTransfer_kindHandle: 'Alleged: kind', localTransfer_singleton: 'Alleged: localTransfer', + withdrawToSeat_kindHandle: 'Alleged: kind', + withdrawToSeat_singleton: 'Alleged: withdrawToSeat', }, }, }, @@ -101,6 +105,8 @@ Generated by [AVA](https://avajs.dev). zoeTools: { localTransfer_kindHandle: 'Alleged: kind', localTransfer_singleton: 'Alleged: localTransfer', + withdrawToSeat_kindHandle: 'Alleged: kind', + withdrawToSeat_singleton: 'Alleged: withdrawToSeat', }, }, }, @@ -133,5 +139,4 @@ Generated by [AVA](https://avajs.dev). VowInternalsKit_kindHandle: 'Alleged: kind', WatchUtils_kindHandle: 'Alleged: kind', }, - zoe: {}, } diff --git a/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.snap b/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.snap index 0419690848f..7a30cfbebf4 100644 Binary files a/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.snap and b/packages/orchestration/test/examples/snapshots/staking-combinations.test.ts.snap differ diff --git a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md index 82a7b2f488e..1a7163c5994 100644 --- a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md @@ -57,5 +57,4 @@ Generated by [AVA](https://avajs.dev). VowInternalsKit_kindHandle: 'Alleged: kind', WatchUtils_kindHandle: 'Alleged: kind', }, - zoe: {}, } diff --git a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap index 90478398c1d..fbf713b1764 100644 Binary files a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap and b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap differ diff --git a/packages/orchestration/test/examples/staking-combinations.test.ts b/packages/orchestration/test/examples/staking-combinations.test.ts index a7540dd8a0c..830f11a4042 100644 --- a/packages/orchestration/test/examples/staking-combinations.test.ts +++ b/packages/orchestration/test/examples/staking-combinations.test.ts @@ -12,6 +12,7 @@ import { MsgTransferResponse, } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; import { IBCMethod } from '@agoric/vats'; +import { SIMULATED_ERRORS } from '@agoric/vats/tools/fake-bridge.js'; import { protoMsgMocks, UNBOND_PERIOD_SECONDS } from '../ibc-mocks.js'; import { commonSetup } from '../supports.js'; import { @@ -205,4 +206,38 @@ test('start', async t => { // snapshot the resulting contract baggage const tree = inspectMapStore(contractBaggage); t.snapshot(tree, 'contract baggage after start'); + + { + t.log('payments from failed transfers are returned'); + const bldAmt = bld.make(SIMULATED_ERRORS.TIMEOUT); + + const seat = await E(zoe).offer( + await E(result.invitationMakers).DepositAndDelegate(), + { + give: { Stake: bldAmt }, + exit: { waived: null }, + }, + { + Stake: await pourPayment(bldAmt), + }, + { + validator: { + chainId: 'cosmoshub', + value: 'cosmosvaloper1test', + encoding: 'bech32', + }, + }, + ); + await t.throwsAsync(vt.when(E(seat).getOfferResult()), { + message: + 'ibc transfer failed "[Error: simulated unexpected MsgTransfer packet timeout]"', + }); + await vt.when(E(seat).hasExited()); + const payouts = await E(seat).getPayouts(); + t.deepEqual( + await bld.issuer.getAmountOf(payouts.Stake), + bldAmt, + 'Stake is returned', + ); + } }); diff --git a/packages/orchestration/test/fixtures/zoe-tools.contract.js b/packages/orchestration/test/fixtures/zoe-tools.contract.js new file mode 100644 index 00000000000..67e3ae5268c --- /dev/null +++ b/packages/orchestration/test/fixtures/zoe-tools.contract.js @@ -0,0 +1,112 @@ +/** + * @file Testing fixture that takes shortcuts to ensure we hit error paths + * around `zoeTools.localTransfer` and `zoeTools.withdrawToSeat` + */ + +import { makeSharedStateRecord } from '@agoric/async-flow'; +import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; +import { E } from '@endo/far'; +import { M } from '@endo/patterns'; +import { withOrchestration } from '../../src/utils/start-helper.js'; +import { prepareChainHubAdmin } from '../../src/exos/chain-hub-admin.js'; +import * as flows from './zoe-tools.flows.js'; +import fetchedChainInfo from '../../src/fetched-chain-info.js'; + +const { values } = Object; + +/** + * @import {TimerService} from '@agoric/time'; + * @import {LocalChain} from '@agoric/vats/src/localchain.js'; + * @import {NameHub} from '@agoric/vats'; + * @import {Remote} from '@agoric/vow'; + * @import {Zone} from '@agoric/zone'; + * @import {AssetInfo} from '@agoric/vats/src/vat-bank.js'; + * @import {CosmosInterchainService} from '@agoric/orchestration'; + * @import {OrchestrationTools} from '../../src/utils/start-helper.js'; + */ + +/** + * @typedef {{ + * localchain: Remote; + * orchestrationService: Remote; + * storageNode: Remote; + * timerService: Remote; + * agoricNames: Remote; + * }} OrchestrationPowers + */ + +/** + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools + */ +const contract = async ( + zcf, + privateArgs, + zone, + { chainHub, orchestrateAll, zoeTools }, +) => { + const contractState = makeSharedStateRecord( + /** @type {{ account: OrchestrationAccount | undefined }} */ { + localAccount: undefined, + }, + ); + + const creatorFacet = prepareChainHubAdmin(zone, chainHub); + + const orchFns = orchestrateAll(flows, { + zcf, + contractState, + zoeTools, + }); + + // register assets in ChainHub ourselves, + // UNTIL https://github.com/Agoric/agoric-sdk/issues/9752 + const assets = /** @type {AssetInfo[]} */ ( + await E(E(privateArgs.agoricNames).lookup('vbankAsset')).values() + ); + for (const chainName of ['agoric', 'cosmoshub']) { + chainHub.registerChain(chainName, fetchedChainInfo[chainName]); + } + for (const brand of values(zcf.getTerms().brands)) { + const info = assets.find(a => a.brand === brand); + if (info) { + chainHub.registerAsset(info.denom, { + // we are only registering agoric assets, so safe to use denom and + // hardcode chainName + baseDenom: info.denom, + baseName: 'agoric', + chainName: 'agoric', + brand, + }); + } + } + + const publicFacet = zone.exo( + 'Zoe Tools Test PF', + M.interface('Zoe Tools Test PF', { + makeDepositSendInvitation: M.callWhen().returns(InvitationShape), + makeDepositInvitation: M.callWhen().returns(InvitationShape), + makeWithdrawInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeDepositSendInvitation() { + return zcf.makeInvitation(orchFns.depositSend, 'depositSend'); + }, + makeDepositInvitation() { + return zcf.makeInvitation(orchFns.deposit, 'deposit'); + }, + makeWithdrawInvitation() { + return zcf.makeInvitation(orchFns.withdraw, 'withdraw'); + }, + }, + ); + + return { publicFacet, creatorFacet }; +}; + +export const start = withOrchestration(contract); +harden(start); diff --git a/packages/orchestration/test/fixtures/zoe-tools.flows.js b/packages/orchestration/test/fixtures/zoe-tools.flows.js new file mode 100644 index 00000000000..15338b63d2a --- /dev/null +++ b/packages/orchestration/test/fixtures/zoe-tools.flows.js @@ -0,0 +1,128 @@ +/** + * @file Testing fixture that takes shortcuts to ensure we hit error paths + * around `zoeTools.localTransfer` and `zoeTools.withdrawToSeat`. + */ + +import { makeError, q } from '@endo/errors'; +import { mustMatch } from '@endo/patterns'; +import { ChainAddressShape } from '../../src/typeGuards.js'; + +const { values } = Object; + +/** + * @import {GuestInterface} from '@agoric/async-flow'; + * @import {Orchestrator, LocalAccountMethods, OrchestrationAccountI, OrchestrationFlow, ChainAddress} from '@agoric/orchestration'; + * @import {ZoeTools} from '../../src/utils/zoe-tools.js'; + */ + +/** + * Accept one or more deposits and send them to an account on the local chain + * using MsgSend. Intentionally skips a check to ensure an asset is in vbank to + * facilitate failure path testing of ZoeTools. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {object} ctx + * @param {{ localAccount?: OrchestrationAccountI & LocalAccountMethods }} ctx.contractState + * @param {GuestInterface} ctx.zoeTools + * @param {ZCFSeat} seat + * @param {{ destAddr: ChainAddress }} offerArgs + */ +export const depositSend = async ( + orch, + { contractState, zoeTools: { localTransfer, withdrawToSeat } }, + seat, + offerArgs, +) => { + mustMatch(offerArgs, harden({ destAddr: ChainAddressShape })); + const { destAddr } = offerArgs; + assert(destAddr.value.startsWith('agoric1'), 'must send to a local address'); + + const { give } = seat.getProposal(); + + await null; + if (!contractState.localAccount) { + const agoricChain = await orch.getChain('agoric'); + contractState.localAccount = await agoricChain.makeAccount(); + } + + await localTransfer(seat, contractState.localAccount, give); + + try { + await contractState.localAccount.sendAll(destAddr, values(give)); + } catch (error) { + await withdrawToSeat(contractState.localAccount, seat, give); + const errMsg = makeError(`SendAll failed ${q(error)}`); + seat.exit(errMsg); + throw errMsg; + } + seat.exit(); +}; +harden(depositSend); + +/** + * Accept one or more deposits and transfer them to the contract's local + * account. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {object} ctx + * @param {{ localAccount?: OrchestrationAccountI & LocalAccountMethods }} ctx.contractState + * @param {GuestInterface} ctx.zoeTools + * @param {ZCFSeat} seat + */ +export const deposit = async ( + orch, + { contractState, zoeTools: { localTransfer } }, + seat, +) => { + const { give } = seat.getProposal(); + + await null; + if (!contractState.localAccount) { + const agoricChain = await orch.getChain('agoric'); + contractState.localAccount = await agoricChain.makeAccount(); + } + + try { + await localTransfer(seat, contractState.localAccount, give); + } catch (e) { + seat.exit(e); + throw e; + } + seat.exit(); +}; +harden(deposit); + +/** + * Withdraw funds from the contract's local account to the offer's seat. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {object} ctx + * @param {{ localAccount?: OrchestrationAccountI & LocalAccountMethods }} ctx.contractState + * @param {GuestInterface} ctx.zoeTools + * @param {ZCFSeat} seat + */ +export const withdraw = async ( + orch, + { contractState, zoeTools: { withdrawToSeat } }, + seat, +) => { + const { want } = seat.getProposal(); + + await null; + if (!contractState.localAccount) { + const agoricChain = await orch.getChain('agoric'); + contractState.localAccount = await agoricChain.makeAccount(); + } + + try { + await withdrawToSeat(contractState.localAccount, seat, want); + } catch (e) { + seat.exit(e); + throw e; + } + seat.exit(); +}; +harden(withdraw); diff --git a/packages/orchestration/test/supports.ts b/packages/orchestration/test/supports.ts index e5832ebe2a5..21603240711 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -47,7 +47,10 @@ export const commonSetup = async (t: ExecutionContext) => { const bld = withAmountUtils(makeIssuerKit('BLD')); const ist = withAmountUtils(makeIssuerKit('IST')); - const { bankManager, pourPayment } = await makeFakeBankManagerKit(); + const bankBridgeMessages = [] as any[]; + const { bankManager, pourPayment } = await makeFakeBankManagerKit({ + onToBridge: obj => bankBridgeMessages.push(obj), + }); await E(bankManager).addAsset('ubld', 'BLD', 'Staking Token', bld.issuerKit); await E(bankManager).addAsset( 'uist', @@ -60,6 +63,7 @@ export const commonSetup = async (t: ExecutionContext) => { const { mint: _b, ...bldSansMint } = bld; const { mint: _i, ...istSansMint } = ist; // XXX real bankManager does this. fake should too? + // TODO https://github.com/Agoric/agoric-sdk/issues/9966 await makeWellKnownSpaces(agoricNamesAdmin, t.log, ['vbankAsset']); await E(E(agoricNamesAdmin).lookupAdmin('vbankAsset')).update( 'uist', @@ -72,6 +76,17 @@ export const commonSetup = async (t: ExecutionContext) => { displayInfo: { IOU: true }, }), ); + await E(E(agoricNamesAdmin).lookupAdmin('vbankAsset')).update( + 'ubld', + /** @type {AssetInfo} */ harden({ + brand: bld.brand, + issuer: bld.issuer, + issuerName: 'BLD', + denom: 'ubld', + proposedName: 'BLD', + displayInfo: { IOU: true }, + }), + ); const vowTools = prepareSwingsetVowTools(rootZone.subZone('vows')); @@ -211,6 +226,7 @@ export const commonSetup = async (t: ExecutionContext) => { pourPayment, inspectLocalBridge: () => harden([...localBridgeMessages]), inspectDibcBridge: () => E(ibcBridge).inspectDibcBridge(), + inspectBankBridge: () => harden([...bankBridgeMessages]), registerAgoricBld, transmitTransferAck, }, diff --git a/packages/orchestration/test/utils/zoe-tools.test.ts b/packages/orchestration/test/utils/zoe-tools.test.ts new file mode 100644 index 00000000000..e9535ea919f --- /dev/null +++ b/packages/orchestration/test/utils/zoe-tools.test.ts @@ -0,0 +1,410 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import type { TestFn } from 'ava'; + +import path from 'path'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { makeIssuerKit } from '@agoric/ertp'; +import { AmountUtils, withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; +import { Issuer } from '@agoric/ertp/src/types.js'; +import { E } from '@endo/far'; +import { + LOCALCHAIN_DEFAULT_ADDRESS, + SIMULATED_ERRORS, +} from '@agoric/vats/tools/fake-bridge.js'; +import { commonSetup } from '../supports.js'; + +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractName = 'zoeTools'; +const contractFile = `${dirname}/../../test/fixtures/zoe-tools.contract.js`; +type StartFn = typeof import('../../test/fixtures/zoe-tools.contract.js').start; + +type TestContext = Awaited> & { + brands: Awaited>['brands'] & { + moolah: AmountUtils; + }; + zoe: ZoeService; + contractKit: StartedInstanceKit; + getIssuer: (keyword: string) => Issuer<'nat'>; +}; + +const test = anyTest as TestFn; + +test.beforeEach(async t => { + t.log('bootstrap, orchestration core-eval'); + const common = await commonSetup(t); + const { + bootstrap, + commonPrivateArgs, + brands: { ist, bld }, + } = common; + + const moolah = withAmountUtils(makeIssuerKit('MOO')); + t.log('Making Moolah issuer kit', moolah); + + t.log('contract coreEval', contractName); + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + const installation: Installation = + await bundleAndInstall(contractFile); + + const issuerKeywordRecord = harden({ + IST: ist.issuer, + BLD: bld.issuer, + MOO: moolah.issuer, + }); + t.log('issuerKeywordRecord', issuerKeywordRecord); + + const storageNode = await E(bootstrap.storage.rootNode).makeChildNode( + contractName, + ); + const contractKit = await E(zoe).startInstance( + installation, + issuerKeywordRecord, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + const getIssuer = (key: string) => issuerKeywordRecord[key] as Issuer<'nat'>; + + t.context = { + ...common, + brands: { + ...common.brands, + moolah, + }, + contractKit, + getIssuer, + zoe, + }; +}); + +/** + * Tests to ensure `localTransfer` recovers when presented non-vbank asset(s) + * Also tests withdrawSeat, as it's used in the recovery path. + */ +test('zoeTool.localTransfer error paths', async t => { + const { + bootstrap, + brands: { ist, bld, moolah }, + utils: { pourPayment }, + contractKit, + zoe, + getIssuer, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(contractKit.instance); + const vt = bootstrap.vowTools; + + const destAddr = { + chainId: 'agoriclocal', + value: 'agoric1testrecipient', + encoding: 'bech32', + }; + + t.log('localTransfer recovers when presented non-vbank asset'); + { + const tenMoolah = moolah.make(10n); + const MOO = await E(moolah.mint).mintPayment(tenMoolah); + const userSeat = await E(zoe).offer( + E(publicFacet).makeDepositSendInvitation(), + { give: { MOO: tenMoolah } }, + { MOO }, + { + destAddr, + }, + ); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: + 'One or more deposits failed ["[Error: key \\"[Alleged: MOO brand]\\" not found in collection \\"brandToAssetRecord\\"]"]', + }); + await E(userSeat).tryExit(); + const payouts = await E(userSeat).getPayouts(); + const payoutEntries = Object.entries(payouts); + t.is(payoutEntries.length, 1, 'expecting 1 MOO payout'); + for (const [kw, pmt] of payoutEntries) { + const payment = await getIssuer(kw).getAmountOf(pmt); + t.is(payment.value, 10n, `${kw} payment returned`); + } + } + + t.log('localTransfer recovers from: give: { IST, MOO }'); + { + const tenMoolah = moolah.make(10n); + const MOO = await E(moolah.mint).mintPayment(tenMoolah); + const tenStable = ist.make(10n); + const IST = await pourPayment(tenStable); + const userSeat = await E(zoe).offer( + E(publicFacet).makeDepositSendInvitation(), + { give: { IST: tenStable, MOO: tenMoolah } }, + { IST, MOO }, + { + destAddr, + }, + ); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: + 'One or more deposits failed ["[Error: key \\"[Alleged: MOO brand]\\" not found in collection \\"brandToAssetRecord\\"]"]', + }); + await E(userSeat).tryExit(); + const payouts = await E(userSeat).getPayouts(); + const payoutEntries = Object.entries(payouts); + t.is(payoutEntries.length, 2, 'expecting IST, MOO payouts'); + for (const [kw, pmt] of payoutEntries) { + const payment = await getIssuer(kw).getAmountOf(pmt); + t.is(payment.value, 10n, `${kw} payment returned`); + } + } + + t.log('localTransfer recovers from: give: { BLD, MOO, IST } '); + { + const tenMoolah = moolah.make(10n); + const MOO = await E(moolah.mint).mintPayment(tenMoolah); + const tenStable = ist.make(10n); + const IST = await pourPayment(tenStable); + const tenStake = bld.make(10n); + const BLD = await pourPayment(tenStake); + const userSeat = await E(zoe).offer( + E(publicFacet).makeDepositSendInvitation(), + { give: { BLD: tenStake, MOO: tenMoolah, IST: tenStable } }, + { BLD, MOO, IST }, + { + destAddr, + }, + ); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: + 'One or more deposits failed ["[Error: key \\"[Alleged: MOO brand]\\" not found in collection \\"brandToAssetRecord\\"]"]', + }); + await E(userSeat).tryExit(); + const payouts = await E(userSeat).getPayouts(); + const payoutEntries = Object.entries(payouts); + t.is(payoutEntries.length, 3, 'expecting BLD, IST, MOO payouts'); + for (const [kw, pmt] of payoutEntries) { + const payment = await getIssuer(kw).getAmountOf(pmt); + t.is(payment.value, 10n, `${kw} payment returned`); + } + } + t.log('withdrawToSeat recovers from: simulated sendAll failure '); + { + const tenStable = ist.make(SIMULATED_ERRORS.BAD_REQUEST); + const IST = await pourPayment(tenStable); + const tenStake = bld.make(SIMULATED_ERRORS.BAD_REQUEST); + const BLD = await pourPayment(tenStake); + const userSeat = await E(zoe).offer( + E(publicFacet).makeDepositSendInvitation(), + { give: { BLD: tenStake, IST: tenStable } }, + { BLD, IST }, + { + destAddr, + }, + ); + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: 'SendAll failed "[Error: simulated error]"', + }); + await E(userSeat).hasExited(); + const payouts = await E(userSeat).getPayouts(); + const payoutEntries = Object.entries(payouts); + t.is(payoutEntries.length, 2, 'expecting BLD, IST payouts'); + for (const [kw, pmt] of payoutEntries) { + const payment = await getIssuer(kw).getAmountOf(pmt); + t.is( + payment.value, + SIMULATED_ERRORS.BAD_REQUEST, + `${kw} payment returned`, + ); + } + } +}); + +test('localTransfer happy path', async t => { + const { + bootstrap, + brands: { ist, bld }, + utils: { pourPayment, inspectBankBridge, inspectLocalBridge }, + contractKit, + zoe, + getIssuer, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(contractKit.instance); + const vt = bootstrap.vowTools; + + const destAddr = { + chainId: 'agoriclocal', + value: 'agoric1testrecipient', + encoding: 'bech32', + }; + + const tenStable = ist.make(10n); + const tenStake = bld.make(10n); + + const expectedAmounts = [ + { + denom: 'ubld', + amount: '10', + }, + { + denom: 'uist', + amount: '10', + }, + ]; + + { + t.log('localTransfer happy path via depositAndSend'); + const IST = await pourPayment(tenStable); + const BLD = await pourPayment(tenStake); + + const userSeat = await E(zoe).offer( + E(publicFacet).makeDepositSendInvitation(), + { give: { BLD: tenStake, IST: tenStable } }, + { BLD, IST }, + { + destAddr, + }, + ); + + await vt.when(E(userSeat).getOfferResult()); + const payouts = await E(userSeat).getPayouts(); + const payoutEntries = Object.entries(payouts); + t.is(payoutEntries.length, 2, 'expecting BLD, IST payouts'); + for (const [kw, pmt] of payoutEntries) { + const payment = await getIssuer(kw).getAmountOf(pmt); + t.is(payment.value, 0n, `no payout for ${kw}`); + } + + t.like( + inspectBankBridge().filter(m => m.type === 'VBANK_GIVE'), + expectedAmounts.map(x => ({ + ...x, + recipient: LOCALCHAIN_DEFAULT_ADDRESS, + })), + 'funds deposited to contract LCA', + ); + t.like( + inspectLocalBridge().find(x => x.type === 'VLOCALCHAIN_EXECUTE_TX') + ?.messages?.[0], + { + '@type': '/cosmos.bank.v1beta1.MsgSend', + fromAddress: LOCALCHAIN_DEFAULT_ADDRESS, + toAddress: destAddr.value, + amount: expectedAmounts, + }, + 'sendAll sent', + ); + } + { + t.log('localTransfer happy path via deposit'); + const IST = await pourPayment(tenStable); + const BLD = await pourPayment(tenStake); + + const userSeat = await E(zoe).offer( + E(publicFacet).makeDepositInvitation(), + { give: { BLD: tenStake, IST: tenStable } }, + { BLD, IST }, + ); + + await vt.when(E(userSeat).getOfferResult()); + + const payouts = await E(userSeat).getPayouts(); + const payoutEntries = Object.entries(payouts); + for (const [kw, pmt] of payoutEntries) { + const payment = await getIssuer(kw).getAmountOf(pmt); + t.is(payment.value, 0n, `no payout for ${kw}`); + } + + t.like( + inspectBankBridge().filter(m => m.type === 'VBANK_GIVE'), + expectedAmounts.map(x => ({ + ...x, + recipient: LOCALCHAIN_DEFAULT_ADDRESS, + })), + 'funds deposited to contract LCA', + ); + } +}); + +test('withdraw (withdrawToSeat) from LCA with insufficient balance', async t => { + const { + bootstrap, + brands: { ist, bld }, + contractKit, + zoe, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(contractKit.instance); + const vt = bootstrap.vowTools; + + const tenStable = ist.make(10n); + const tenStake = bld.make(10n); + + const userSeat = await E(zoe).offer(E(publicFacet).makeWithdrawInvitation(), { + want: { BLD: tenStake, IST: tenStable }, + }); + + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: + 'One or more withdrawals failed ["[RangeError: -10 is negative]","[RangeError: -10 is negative]"]', + }); +}); + +test('withdraw (withdrawToSeat) happy path', async t => { + const { + bootstrap, + brands: { ist, bld }, + contractKit, + zoe, + getIssuer, + utils: { pourPayment }, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(contractKit.instance); + const vt = bootstrap.vowTools; + + const tenStable = ist.make(10n); + const tenStake = bld.make(10n); + + t.log('ensure contract LCA has funds to withdraw'); + const IST = await pourPayment(tenStable); + const BLD = await pourPayment(tenStake); + + const depositSeat = await E(zoe).offer( + E(publicFacet).makeDepositInvitation(), + { give: { BLD: tenStake, IST: tenStable } }, + { BLD, IST }, + ); + await vt.when(E(depositSeat).getOfferResult()); + + const withdrawSeat = await E(zoe).offer( + E(publicFacet).makeWithdrawInvitation(), + { + want: { BLD: tenStake, IST: tenStable }, + }, + ); + await vt.when(E(withdrawSeat).getOfferResult()); + + const payouts = await E(withdrawSeat).getPayouts(); + const payoutEntries = Object.entries(payouts); + t.is(payoutEntries.length, 2, 'expecting BLD, IST payouts'); + for (const [kw, pmt] of payoutEntries) { + const payment = await getIssuer(kw).getAmountOf(pmt); + t.is(payment.value, 10n, `${kw} payment given`); + } +}); + +test('withdrawToSeat, unknown brand', async t => { + const { + bootstrap, + brands: { moolah }, + contractKit, + zoe, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(contractKit.instance); + const vt = bootstrap.vowTools; + + const tenMoolah = moolah.make(10n); + + const userSeat = await E(zoe).offer(E(publicFacet).makeWithdrawInvitation(), { + want: { MOO: tenMoolah }, + }); + + await t.throwsAsync(vt.when(E(userSeat).getOfferResult()), { + message: + 'One or more withdrawals failed ["[Error: key \\"[Alleged: MOO brand]\\" not found in collection \\"brandToAssetRecord\\"]"]', + }); +}); diff --git a/packages/vats/tools/bank-utils.js b/packages/vats/tools/bank-utils.js index cf077e6807d..eb0ec53dc11 100644 --- a/packages/vats/tools/bank-utils.js +++ b/packages/vats/tools/bank-utils.js @@ -64,7 +64,8 @@ export const makeFakeBankKit = issuerKits => { /** * @param {object} [opts] - * @param {import('./fake-bridge.js').Balances} opts.balances initial balances + * @param {import('./fake-bridge.js').Balances} [opts.balances] initial balances + * @param {(obj) => unknown} [opts.onToBridge] handler for toBridge messages */ export const makeFakeBankManagerKit = async opts => { const baggage = makeScalarBigMapStore('baggage'); diff --git a/packages/vats/tools/fake-bridge.js b/packages/vats/tools/fake-bridge.js index 36c39af8d37..d8e1585d6a7 100644 --- a/packages/vats/tools/fake-bridge.js +++ b/packages/vats/tools/fake-bridge.js @@ -27,14 +27,16 @@ const INFINITE_AMOUNT = 99999999999n; * transaction outside the Agoric VM. (Similarly for deposits.) * * @param {import('@agoric/zone').Zone} zone - * @param {object} opts - * @param {Balances} opts.balances initial balances + * @param {object} [opts] + * @param {Balances} [opts.balances] initial balances + * @param {(obj) => void} [opts.onToBridge] * @returns {ScopedBridgeManager<'bank'>} * @see {makeFakeBankManagerKit} and its `pourPayment` for a helper */ -export const makeFakeBankBridge = (zone, opts = { balances: {} }) => { - const { balances } = opts; - +export const makeFakeBankBridge = ( + zone, + { balances = {}, onToBridge = () => {} } = {}, +) => { const currentBalance = ({ address, denom }) => address === FAUCET_ADDRESS ? INFINITE_AMOUNT @@ -46,6 +48,7 @@ export const makeFakeBankBridge = (zone, opts = { balances: {} }) => { return zone.exo('Fake Bank Bridge Manager', undefined, { getBridgeId: () => 'bank', toBridge: async obj => { + onToBridge(obj); const { method, type, ...params } = obj; trace('toBridge', type, method, params); switch (obj.type) {