diff --git a/packages/orchestration/src/examples/stakeBld.contract.js b/packages/orchestration/src/examples/stakeBld.contract.js index d55f82618f6..82454422525 100644 --- a/packages/orchestration/src/examples/stakeBld.contract.js +++ b/packages/orchestration/src/examples/stakeBld.contract.js @@ -12,6 +12,7 @@ import { M } from '@endo/patterns'; import { makeChainHub } from '../exos/chain-hub.js'; import { prepareLocalOrchestrationAccountKit } from '../exos/local-orchestration-account.js'; import fetchedChainInfo from '../fetched-chain-info.js'; +import { makeZoeTools } from '../utils/zoe-tools.js'; /** * @import {NameHub} from '@agoric/vats'; @@ -43,6 +44,7 @@ export const start = async (zcf, privateArgs, baggage) => { const vowTools = prepareVowTools(zone.subZone('vows')); const chainHub = makeChainHub(privateArgs.agoricNames, vowTools); + const zoeTools = makeZoeTools(zcf, vowTools); const { localchain, timerService } = privateArgs; const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( @@ -54,6 +56,7 @@ export const start = async (zcf, privateArgs, baggage) => { vowTools, chainHub, localchain, + zoeTools, }, ); diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index 7fda8a8733b..aab60847cd1 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -10,6 +10,7 @@ import { Fail, q } from '@endo/errors'; import { AmountArgShape, + AnyNatAmountsRecord, ChainAddressShape, DenomAmountShape, DenomShape, @@ -39,6 +40,7 @@ import { coerceCoin, coerceDenomAmount } from '../utils/amounts.js'; * @import {Matcher} from '@endo/patterns'; * @import {ChainHub} from './chain-hub.js'; * @import {PacketTools} from './packet-tools.js'; + * @import {ZoeTools} from '../utils/zoe-tools.js'; */ const trace = makeTracer('LOA'); @@ -91,10 +93,19 @@ const PUBLIC_TOPICS = { * @param {VowTools} powers.vowTools * @param {ChainHub} powers.chainHub * @param {Remote} powers.localchain + * @param {ZoeTools} powers.zoeTools */ export const prepareLocalOrchestrationAccountKit = ( zone, - { makeRecorderKit, zcf, timerService, vowTools, chainHub, localchain }, + { + makeRecorderKit, + zcf, + timerService, + vowTools, + chainHub, + localchain, + zoeTools, + }, ) => { const { watch, allVows, asVow, when } = vowTools; const { makeIBCTransferSender } = prepareIBCTools( @@ -139,6 +150,12 @@ export const prepareLocalOrchestrationAccountKit = ( returnVoidWatcher: M.interface('returnVoidWatcher', { onFulfilled: M.call(M.any()).optional(M.any()).returns(M.undefined()), }), + seatExiterHandler: M.interface('seatExiterHandler', { + onFulfilled: M.call(M.undefined(), M.remotable()).returns( + M.undefined(), + ), + onRejected: M.call(M.error(), M.remotable()).returns(M.undefined()), + }), getBalanceWatcher: M.interface('getBalanceWatcher', { onFulfilled: M.call(AmountShape, DenomShape).returns(DenomAmountShape), }), @@ -151,12 +168,14 @@ export const prepareLocalOrchestrationAccountKit = ( ), }), invitationMakers: M.interface('invitationMakers', { - Delegate: M.call(M.string(), AmountShape).returns(M.promise()), - Undelegate: M.call(M.string(), AmountShape).returns(M.promise()), CloseAccount: M.call().returns(M.promise()), + Delegate: M.call(M.string(), AmountShape).returns(M.promise()), + Deposit: M.call().returns(M.promise()), Send: M.call().returns(M.promise()), SendAll: M.call().returns(M.promise()), Transfer: M.call().returns(M.promise()), + Undelegate: M.call(M.string(), AmountShape).returns(M.promise()), + Withdraw: M.call().returns(M.promise()), }), }, /** @@ -200,6 +219,27 @@ export const prepareLocalOrchestrationAccountKit = ( ); }, 'Delegate'); }, + Deposit() { + trace('Deposit'); + return zcf.makeInvitation( + seat => { + const { give } = seat.getProposal(); + return watch( + zoeTools.localTransfer( + seat, + // @ts-expect-error LocalAccount vs LocalAccountMethods + this.state.account, + give, + ), + this.facets.seatExiterHandler, + seat, + ); + }, + 'Deposit', + undefined, + M.splitRecord({ give: AnyNatAmountsRecord, want: {} }), + ); + }, /** * @param {string} validatorAddress * @param {Amount<'nat'>} ertpAmount @@ -262,6 +302,27 @@ export const prepareLocalOrchestrationAccountKit = ( }; return zcf.makeInvitation(offerHandler, 'Transfer'); }, + Withdraw() { + trace('Withdraw'); + return zcf.makeInvitation( + seat => { + const { want } = seat.getProposal(); + return watch( + zoeTools.withdrawToSeat( + // @ts-expect-error LocalAccount vs LocalAccountMethods + this.state.account, + seat, + want, + ), + this.facets.seatExiterHandler, + seat, + ); + }, + 'Withdraw', + undefined, + M.splitRecord({ give: {}, want: AnyNatAmountsRecord }), + ); + }, }, undelegateWatcher: { /** @@ -362,6 +423,24 @@ export const prepareLocalOrchestrationAccountKit = ( return harden({ denom, value: natAmount.value }); }, }, + /** exits or fails a seat depending the outcome */ + seatExiterHandler: { + /** + * @param {undefined} _ + * @param {ZCFSeat} seat + */ + onFulfilled(_, seat) { + seat.exit(); + }, + /** + * @param {Error} reason + * @param {ZCFSeat} seat + */ + onRejected(reason, seat) { + seat.exit(reason); + throw reason; + }, + }, /** * handles a QueryBalanceRequest from localchain.query and returns the * balance as a DenomAmount diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index a115b841e82..1d9bcbe44dc 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -180,3 +180,13 @@ export const TxBodyOptsShape = M.splitRecord( nonCriticalExtensionOptions: M.arrayOf(M.any()), }, ); + +/** + * Ensures at least one {@link AmountKeywordRecord} entry is present and only + * permits Nat (fungible) amounts. + */ +export const AnyNatAmountsRecord = M.and( + M.recordOf(M.string(), AnyNatAmountShape), + M.not(harden({})), +); +harden(AnyNatAmountsRecord); diff --git a/packages/orchestration/src/utils/start-helper.js b/packages/orchestration/src/utils/start-helper.js index e86cb6e6b0a..e3b2cd7aa9d 100644 --- a/packages/orchestration/src/utils/start-helper.js +++ b/packages/orchestration/src/utils/start-helper.js @@ -79,7 +79,15 @@ export const provideOrchestration = ( const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( zones.orchestration, - { makeRecorderKit, zcf, timerService, vowTools, chainHub, localchain }, + { + makeRecorderKit, + zcf, + timerService, + vowTools, + chainHub, + localchain, + zoeTools, + }, ); const asyncFlowTools = prepareAsyncFlowTools(zones.asyncFlow, { diff --git a/packages/orchestration/test/examples/basic-flows.contract.test.ts b/packages/orchestration/test/examples/basic-flows.contract.test.ts index 037e8de3494..ef8aea70719 100644 --- a/packages/orchestration/test/examples/basic-flows.contract.test.ts +++ b/packages/orchestration/test/examples/basic-flows.contract.test.ts @@ -4,6 +4,11 @@ import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; import { E, getInterfaceOf } from '@endo/far'; import path from 'path'; +import { makeIssuerKit } from '@agoric/ertp'; +import { + type AmountUtils, + withAmountUtils, +} from '@agoric/zoe/tools/test-utils.js'; import { commonSetup } from '../supports.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); @@ -16,6 +21,9 @@ type StartFn = type TestContext = Awaited> & { zoe: ZoeService; instance: Instance; + brands: Awaited>['brands'] & { + moolah: AmountUtils; + }; }; const test = anyTest as TestFn; @@ -23,10 +31,13 @@ const test = anyTest as TestFn; test.beforeEach(async t => { const setupContext = await commonSetup(t); const { + brands: { bld, ist }, bootstrap: { storage }, commonPrivateArgs, } = setupContext; + const moolah = withAmountUtils(makeIssuerKit('MOO')); + const { zoe, bundleAndInstall } = await setUpZoeForTest(); t.log('contract coreEval', contractName); @@ -35,7 +46,7 @@ test.beforeEach(async t => { const storageNode = await E(storage.rootNode).makeChildNode(contractName); const { instance } = await E(zoe).startInstance( installation, - undefined, + { Stable: ist.issuer, Stake: bld.issuer, Moo: moolah.issuer }, {}, { ...commonPrivateArgs, storageNode }, ); @@ -44,6 +55,7 @@ test.beforeEach(async t => { ...setupContext, zoe, instance, + brands: { ...setupContext.brands, moolah }, }; }); @@ -88,3 +100,137 @@ const orchestrationAccountScenario = test.macro({ test(orchestrationAccountScenario, 'agoric'); test(orchestrationAccountScenario, 'cosmoshub'); + +test('Deposit, Withdraw - LocalOrchAccount', async t => { + const { + brands: { bld, ist }, + bootstrap: { vowTools: vt }, + zoe, + instance, + utils: { inspectBankBridge, pourPayment }, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(instance); + const inv = E(publicFacet).makeOrchAccountInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { chainName: 'agoric' }); + const { invitationMakers } = await vt.when(E(userSeat).getOfferResult()); + + const twentyIST = ist.make(20n); + const tenBLD = bld.make(10n); + const Stable = await pourPayment(twentyIST); + const Stake = await pourPayment(tenBLD); + + const depositInv = await E(invitationMakers).Deposit(); + + const depositSeat = E(zoe).offer( + depositInv, + { + give: { Stable: twentyIST, Stake: tenBLD }, + want: {}, + }, + { Stable, Stake }, + ); + const depositRes = await vt.when(E(depositSeat).getOfferResult()); + t.is(depositRes, undefined, 'undefined on success'); + + t.deepEqual( + inspectBankBridge().slice(-2), + [ + { + type: 'VBANK_GIVE', + recipient: 'agoric1fakeLCAAddress', + denom: 'uist', + amount: '20', + }, + { + type: 'VBANK_GIVE', + recipient: 'agoric1fakeLCAAddress', + denom: 'ubld', + amount: '10', + }, + ], + 'funds deposited to LCA', + ); + + const depositPayouts = await E(depositSeat).getPayouts(); + t.is((await ist.issuer.getAmountOf(depositPayouts.Stable)).value, 0n); + t.is((await bld.issuer.getAmountOf(depositPayouts.Stake)).value, 0n); + + // withdraw the payments we just deposited + const withdrawInv = await E(invitationMakers).Withdraw(); + const withdrawSeat = E(zoe).offer(withdrawInv, { + give: {}, + want: { Stable: twentyIST, Stake: tenBLD }, + }); + const withdrawRes = await vt.when(E(withdrawSeat).getOfferResult()); + t.is(withdrawRes, undefined, 'undefined on success'); + + const withdrawPayouts = await E(withdrawSeat).getPayouts(); + t.deepEqual(await ist.issuer.getAmountOf(withdrawPayouts.Stable), twentyIST); + t.deepEqual(await bld.issuer.getAmountOf(withdrawPayouts.Stake), tenBLD); +}); + +test('Deposit, Withdraw errors - LocalOrchAccount', async t => { + const { + brands: { ist, moolah }, + bootstrap: { vowTools: vt }, + zoe, + instance, + } = t.context; + const publicFacet = await E(zoe).getPublicFacet(instance); + const inv = E(publicFacet).makeOrchAccountInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { chainName: 'agoric' }); + const { invitationMakers } = await vt.when(E(userSeat).getOfferResult()); + + // deposit non-vbank asset (not supported) + const tenMoolah = moolah.make(10n); + const Moo = await E(moolah.mint).mintPayment(tenMoolah); + const depositInv = await E(invitationMakers).Deposit(); + const depositSeat = E(zoe).offer( + depositInv, + { + give: { Moo: tenMoolah }, + want: {}, + }, + { Moo }, + ); + await t.throwsAsync(vt.when(E(depositSeat).getOfferResult()), { + message: + 'One or more deposits failed ["[Error: key \\"[Alleged: MOO brand]\\" not found in collection \\"brandToAssetRecord\\"]"]', + }); + const depositPayouts = await E(depositSeat).getPayouts(); + t.deepEqual( + await moolah.issuer.getAmountOf(depositPayouts.Moo), + tenMoolah, + 'deposit returned on failure', + ); + + { + // withdraw more than balance (insufficient funds) + const tenIST = ist.make(10n); + const withdrawInv = await E(invitationMakers).Withdraw(); + const withdrawSeat = E(zoe).offer(withdrawInv, { + give: {}, + want: { Stable: tenIST }, + }); + await t.throwsAsync(vt.when(E(withdrawSeat).getOfferResult()), { + message: + 'One or more withdrawals failed ["[RangeError: -10 is negative]"]', + }); + const payouts = await E(withdrawSeat).getPayouts(); + t.deepEqual((await ist.issuer.getAmountOf(payouts.Stable)).value, 0n); + } + { + // withdraw non-vbank asset + const withdrawInv = await E(invitationMakers).Withdraw(); + const withdrawSeat = E(zoe).offer(withdrawInv, { + give: {}, + want: { Moo: tenMoolah }, + }); + await t.throwsAsync(vt.when(E(withdrawSeat).getOfferResult()), { + message: + 'One or more withdrawals failed ["[Error: key \\"[Alleged: MOO brand]\\" not found in collection \\"brandToAssetRecord\\"]"]', + }); + const payouts = await E(withdrawSeat).getPayouts(); + t.deepEqual((await moolah.issuer.getAmountOf(payouts.Moo)).value, 0n); + } +});