From 6a43cf15729d7822f1e03f39f7cc35200062a731 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 11 Jul 2024 10:06:47 -0400 Subject: [PATCH 01/17] refactor: chain-hub creatorFacet - pulls out chaib-hub creatorFacet for reuse with other contracts --- .../src/examples/sendAnywhere.contract.js | 38 +----------- .../orchestration/src/exos/chain-hub-admin.js | 56 ++++++++++++++++++ .../test/examples/sendAnywhere.test.ts | 4 +- .../snapshots/sendAnywhere.test.ts.md | 4 +- .../snapshots/sendAnywhere.test.ts.snap | Bin 946 -> 954 bytes 5 files changed, 64 insertions(+), 38 deletions(-) create mode 100644 packages/orchestration/src/exos/chain-hub-admin.js diff --git a/packages/orchestration/src/examples/sendAnywhere.contract.js b/packages/orchestration/src/examples/sendAnywhere.contract.js index ea5269e7129..709e4818c41 100644 --- a/packages/orchestration/src/examples/sendAnywhere.contract.js +++ b/packages/orchestration/src/examples/sendAnywhere.contract.js @@ -1,13 +1,12 @@ import { makeStateRecord } from '@agoric/async-flow'; import { AmountShape } from '@agoric/ertp'; -import { heapVowE } from '@agoric/vow/vat.js'; import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; import { Fail } from '@endo/errors'; import { E } from '@endo/far'; import { M } from '@endo/patterns'; -import { CosmosChainInfoShape } from '../typeGuards.js'; import { withOrchestration } from '../utils/start-helper.js'; import { orchestrationFns } from './sendAnywhereFlows.js'; +import { prepareChainHubAdmin } from '../exos/chain-hub-admin.js'; /** * @import {TimerService} from '@agoric/time'; @@ -16,7 +15,6 @@ import { orchestrationFns } from './sendAnywhereFlows.js'; * @import {Remote, Vow} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; * @import {VBankAssetDetail} from '@agoric/vats/tools/board-utils.js'; - * @import {CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api'; * @import {CosmosInterchainService} from '../exos/cosmos-interchain-service.js'; * @import {OrchestrationTools} from '../utils/start-helper.js'; */ @@ -60,6 +58,8 @@ const contract = async ( }, ); + const creatorFacet = prepareChainHubAdmin(zone, chainHub); + // TODO should be a provided helper /** @type {(brand: Brand) => Vow} */ const findBrandInVBank = vowTools.retriable( @@ -100,38 +100,6 @@ const contract = async ( }, ); - let nonce = 0n; - const ConnectionInfoShape = M.record(); // TODO - const creatorFacet = zone.exo( - 'Send CF', - M.interface('Send CF', { - addChain: M.callWhen(CosmosChainInfoShape, ConnectionInfoShape).returns( - M.scalar(), - ), - }), - { - /** - * @param {CosmosChainInfo} chainInfo - * @param {IBCConnectionInfo} connectionInfo - */ - async addChain(chainInfo, connectionInfo) { - const chainKey = `${chainInfo.chainId}-${(nonce += 1n)}`; - // when() because chainHub methods return vows. If this were inside - // orchestrate() the membrane would wrap/unwrap automatically. - const agoricChainInfo = await heapVowE.when( - chainHub.getChainInfo('agoric'), - ); - chainHub.registerChain(chainKey, chainInfo); - chainHub.registerConnection( - agoricChainInfo.chainId, - chainInfo.chainId, - connectionInfo, - ); - return chainKey; - }, - }, - ); - return { publicFacet, creatorFacet }; }; diff --git a/packages/orchestration/src/exos/chain-hub-admin.js b/packages/orchestration/src/exos/chain-hub-admin.js new file mode 100644 index 00000000000..527c7a70433 --- /dev/null +++ b/packages/orchestration/src/exos/chain-hub-admin.js @@ -0,0 +1,56 @@ +/* we expect promises to resolved promptly, */ +/* eslint-disable no-restricted-syntax */ +import { M } from '@endo/patterns'; +import { heapVowE } from '@agoric/vow/vat.js'; +import { CosmosChainInfoShape } from '../typeGuards.js'; + +/** + * @import {Zone} from '@agoric/zone'; + * @import {CosmosChainInfo, IBCConnectionInfo} from '@agoric/orchestration'; + * @import {ChainHub} from './chain-hub.js'; + */ + +/** + * For use with async-flow contracts: can be used as a creator facet that allows + * developers to add new chain configurations to a local chainHub, in the event + * the information is not available widely in `agoricNames`. + * + * @param {Zone} zone + * @param {ChainHub} chainHub + */ +export const prepareChainHubAdmin = (zone, chainHub) => { + const ConnectionInfoShape = M.record(); // TODO + const makeCreatorFacet = zone.exo( + 'ChainHub Admin', + M.interface('ChainHub Admin', { + initChain: M.callWhen( + M.string(), + CosmosChainInfoShape, + ConnectionInfoShape, + ).returns(M.undefined()), + }), + { + /** + * @param {string} chainName + * @param {CosmosChainInfo} chainInfo + * @param {IBCConnectionInfo} connectionInfo + */ + async initChain(chainName, chainInfo, connectionInfo) { + // when() because chainHub methods return vows. If this were inside + // orchestrate() the membrane would wrap/unwrap automatically. + const agoricChainInfo = await heapVowE.when( + chainHub.getChainInfo('agoric'), + ); + chainHub.registerChain(chainName, chainInfo); + chainHub.registerConnection( + agoricChainInfo.chainId, + chainInfo.chainId, + connectionInfo, + ); + }, + }, + ); + return makeCreatorFacet; +}; + +/** @typedef {ReturnType} ChainHubAdmin */ diff --git a/packages/orchestration/test/examples/sendAnywhere.test.ts b/packages/orchestration/test/examples/sendAnywhere.test.ts index e7e9221805c..1bcf4fd2771 100644 --- a/packages/orchestration/test/examples/sendAnywhere.test.ts +++ b/packages/orchestration/test/examples/sendAnywhere.test.ts @@ -102,7 +102,9 @@ test('send using arbitrary chain info', async t => { ...txChannelDefaults, }, } as IBCConnectionInfo; - const chainName = await E(sendKit.creatorFacet).addChain( + const chainName = 'hot'; + await E(sendKit.creatorFacet).initChain( + chainName, hotChainInfo, agoricToHotConnection, ); diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md index 6cca6b7d1a2..95beb6676c2 100644 --- a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md @@ -25,8 +25,8 @@ Generated by [AVA](https://avajs.dev). unwrapMap: 'Alleged: weakMapStore', }, contract: { - 'Send CF_kindHandle': 'Alleged: kind', - 'Send CF_singleton': 'Alleged: Send CF', + 'ChainHub Admin_kindHandle': 'Alleged: kind', + 'ChainHub Admin_singleton': 'Alleged: ChainHub Admin', 'Send PF_kindHandle': 'Alleged: kind', 'Send PF_singleton': 'Alleged: Send PF', }, diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap index 3f4defa91ecbc654267ea5adc62635602f5952cf..785dda987fc272fc94708bed534a963109e78ead 100644 GIT binary patch literal 954 zcmV;r14aBnRzV?K<+(NO9>B>t6 zZm#wv$RCRc00000000A>R!wi*L>PX?Uu$ogk0mK-+7!7Gr%E6pIB`Ln(j`$-B_uR0 zLa3UxXLsH8&a7s}UCOODKmu{=nR4R7iGKhGnm>VrdO`dH4hXFiZ`NZG#r4VZ`#jIQ z^Um|W;~#q6R76Ad^f}XsPFcT8`+eGHiQ;su212W+&*PYC`f2kY8x2e!;uF6EU>Csm z0A2ui3E*D<*9q`00al6EBHmv25sNe-v)7fC6_7=c2FNm2iM2*-5Vu9%_%M^So3d~} z>!yhsgm)7CaG3CToANki1#GdMrmWB6_rqz-&{#cSk&rQy7a%SXvQ2;@98mRyN1aqm z^3b!nkRJ)IC5?0*Ii8Cw3K#@I35VXJO~Dn5g{gdvjmyF9n+x@X~<=Jg|Ug z7Vw7!Y}mkOHt>@T{AmL>9N;4dc;o=zIz~aQazTCK{^kJ34iLD&$1d=-YfwCs()iT{ zUbw(@4;XpC&mQo%2fWoVs11|q+-?AO8o)OV;MjyO81QoRQpF1k#yM`Z<59xf#fj9l z!4-q965dal7Q6tg&)8+8p|iG?OtVY`ubaVUC*wKs9-l}$9$(0jC53BMg~~Rr&IBr8 zvoUr>|4<9b>ZY-AsHtXkb}{V%|IF=cCw-Oi=r-*$d7loMJn4xWbEwt~)uBn6Np=GH zU>?#-#iS$TUZx{4Vn>zJ3bH)QyL4Q9xjaj4!szfW9p@#;9p{+)yfvT+-_E)xj;QT8 zgMNg?ynG!R(YUsk*UwTBwRf|5z(zu|ddn{n^7)*FJ;ivuqw^E6 zO#|*rF-jD>Pjxh?U+k+#VzR?ElbojN6^eT~U-zC)Qgs39=|FeeQqm{HF5Z@xmn*~G zIrl1X&3ncE$_Kvlf!}@OPVp}Ks5F)OKiva&2uce801img5C8xG literal 946 zcmV;j15NxvRzVR!wgkMHqf&zr1#nI3Wp1+JKg84;2W+q3Xp6m;_SAN@&`O z0~+s+?XA}{*31|aZv6ouZWSj)Z(O(l|9}g$YjPOS>#qoDNk_X!Y=M6j4p@*Z#6m!Q>%6@tXh+0DKAH zX8?Z!cn07V0=!9pRpPA>@38%Vg_@A*>+fc$tq~iJrDTG1Ag;>&5A*-m{jNW3h;IX7*&8DP52c9p0_R(YT=5J$E`**hF<>W!Y*k08m$&IK`?5ZDZOmx@HXWuRNGr}U$S)l;9tFG2vhi8T zc!lIFPig0n|2(mE$rD>IKC#tIZ|WKR8@oacgbEJlM_14YLowny*b~Xc#%E6R`hf^( z@;{Q7nB&({z1<#-d6R}TDqXf4Ggn0E+iV}PfzYhn@so8jSFN66JlfZ325hsZT2c&R z#qLra_R0tQ>WLWdbIl~DiMm8^*VA+F=r~aqpq|few;?4xC3f~!uh$E6-FfcRcg+=I zD<3%Zf$x1|efWh1VVRSeme>tTg#0p>ni}Jg7!MfNYT}0Vj}JUGU8ojZAgNHjIujyq zq)YVuD$uF|UsQo#tH62i!-1s Date: Thu, 11 Jul 2024 10:13:18 -0400 Subject: [PATCH 02/17] refactor(test): factor common LOAKit setup --- .../local-orchestration-account-kit.test.ts | 138 ++++-------------- .../test/exos/make-test-loa-kit.ts | 66 +++++++++ 2 files changed, 91 insertions(+), 113 deletions(-) create mode 100644 packages/orchestration/test/exos/make-test-loa-kit.ts diff --git a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts index f604aaeac2b..ed6ddae2fa6 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -3,56 +3,23 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { AmountMath } from '@agoric/ertp'; import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; import { heapVowE as E } from '@agoric/vow/vat.js'; -import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; -import { Far } from '@endo/far'; -import { TimeMath } from '@agoric/time'; -import { prepareLocalOrchestrationAccountKit } from '../../src/exos/local-orchestration-account.js'; +import { TargetApp } from '@agoric/vats/src/bridge-target.js'; import { ChainAddress } from '../../src/orchestration-api.js'; -import { makeChainHub } from '../../src/exos/chain-hub.js'; import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; import { commonSetup } from '../supports.js'; import { UNBOND_PERIOD_SECONDS } from '../ibc-mocks.js'; import { maxClockSkew } from '../../src/utils/cosmos.js'; +import { prepareMakeTestLOAKit } from './make-test-loa-kit.js'; test('deposit, withdraw', async t => { - const { bootstrap, brands, utils } = await commonSetup(t); + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); - const { bld: stake } = brands; - - const { timer, localchain, marshaller, rootZone, storage, vowTools } = - bootstrap; - - t.log('chainInfo mocked via `prepareMockChainInfo` until #8879'); - - t.log('exo setup - prepareLocalChainAccountKit'); - const { makeRecorderKit } = prepareRecorderKitMakers( - rootZone.mapStore('recorder'), - marshaller, - ); - const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( - rootZone, - makeRecorderKit, - // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer - Far('MockZCF', {}), - timer, - vowTools, - makeChainHub(bootstrap.agoricNames, vowTools), - ); - - t.log('request account from vat-localchain'); - const lca = await E(localchain).makeAccount(); - const address = await E(lca).getAddress(); - - t.log('make a LocalChainAccountKit'); - const { holder: account } = makeLocalOrchestrationAccountKit({ - account: lca, - address: harden({ - value: address, - chainId: 'agoric-n', - encoding: 'bech32', - }), - storageNode: storage.rootNode.makeChildNode('lcaKit'), - }); + const { + brands: { bld: stake }, + utils, + } = common; const oneHundredStakePmt = await utils.pourPayment(stake.units(100)); @@ -88,42 +55,15 @@ test('deposit, withdraw', async t => { }); test('delegate, undelegate', async t => { - const { bootstrap, brands, utils } = await commonSetup(t); - - const { bld } = brands; + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); - const { timer, localchain, marshaller, rootZone, storage, vowTools } = - bootstrap; - - t.log('exo setup - prepareLocalChainAccountKit'); - const { makeRecorderKit } = prepareRecorderKitMakers( - rootZone.mapStore('recorder'), - marshaller, - ); - const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( - rootZone, - makeRecorderKit, - // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer - Far('MockZCF', {}), - timer, - vowTools, - makeChainHub(bootstrap.agoricNames, vowTools), - ); - - t.log('request account from vat-localchain'); - const lca = await E(localchain).makeAccount(); - const address = await E(lca).getAddress(); - - t.log('make a LocalChainAccountKit'); - const { holder: account } = makeLocalOrchestrationAccountKit({ - account: lca, - address: harden({ - value: address, - chainId: 'agoric-n', - encoding: 'bech32', - }), - storageNode: storage.rootNode.makeChildNode('lcaKit'), - }); + const { + bootstrap: { timer }, + brands: { bld }, + utils, + } = common; await E(account).deposit(await utils.pourPayment(bld.units(100))); @@ -151,42 +91,14 @@ test('delegate, undelegate', async t => { }); test('transfer', async t => { - const { bootstrap, brands, utils } = await commonSetup(t); - - const { bld: stake } = brands; - - const { timer, localchain, marshaller, rootZone, storage, vowTools } = - bootstrap; - - t.log('exo setup - prepareLocalChainAccountKit'); - const { makeRecorderKit } = prepareRecorderKitMakers( - rootZone.mapStore('recorder'), - marshaller, - ); - const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( - rootZone, - makeRecorderKit, - // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer - Far('MockZCF', {}), - timer, - vowTools, - makeChainHub(bootstrap.agoricNames, vowTools), - ); - - t.log('request account from vat-localchain'); - const lca = await E(localchain).makeAccount(); - const address = await E(lca).getAddress(); - - t.log('make a LocalChainAccountKit'); - const { holder: account } = makeLocalOrchestrationAccountKit({ - account: lca, - address: harden({ - value: address, - chainId: 'agoric-n', - encoding: 'bech32', - }), - storageNode: storage.rootNode.makeChildNode('lcaKit'), - }); + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); + + const { + brands: { bld: stake }, + utils, + } = common; t.truthy(account, 'account is returned'); diff --git a/packages/orchestration/test/exos/make-test-loa-kit.ts b/packages/orchestration/test/exos/make-test-loa-kit.ts new file mode 100644 index 00000000000..765e7d48f58 --- /dev/null +++ b/packages/orchestration/test/exos/make-test-loa-kit.ts @@ -0,0 +1,66 @@ +import { heapVowE as E } from '@agoric/vow/vat.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import { Far } from '@endo/far'; +import { ExecutionContext } from 'ava'; +import { prepareLocalOrchestrationAccountKit } from '../../src/exos/local-orchestration-account.js'; +import { makeChainHub } from '../../src/exos/chain-hub.js'; +import { commonSetup } from '../supports.js'; + +/** + * A testing utility that creates a LocalChainAccount and makes a + * LocalOrchestrationAccount with necessary endowments like: recorderKit, + * storageNode, mock ZCF, mock TimerService, and a ChainHub. + * + * Helps reduce boilerplate in test files, and retains testing context through + * parameterized endowments. + * + * @param t + * @param bootstrap + * @param opts + * @param opts.zcf + */ +export const prepareMakeTestLOAKit = ( + t: ExecutionContext, + bootstrap: Awaited>['bootstrap'], + { zcf = Far('MockZCF', {}) } = {}, +) => { + const { timer, localchain, marshaller, rootZone, vowTools, agoricNames } = + bootstrap; + + const { makeRecorderKit } = prepareRecorderKitMakers( + rootZone.mapStore('recorder'), + marshaller, + ); + + const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( + rootZone, + makeRecorderKit, + // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer + zcf, + timer, + vowTools, + makeChainHub(agoricNames, vowTools), + ); + + return async ({ + storageNode = bootstrap.storage.rootNode.makeChildNode('accounts'), + } = {}) => { + t.log('exo setup - prepareLocalChainAccountKit'); + + t.log('request account from vat-localchain'); + const lca = await E(localchain).makeAccount(); + const address = await E(lca).getAddress(); + + t.log('make a LocalChainAccountKit'); + const { holder: account } = makeLocalOrchestrationAccountKit({ + account: lca, + address: harden({ + value: address, + chainId: 'agoric-n', + encoding: 'bech32', + }), + storageNode: storageNode.makeChildNode(address), + }); + return account; + }; +}; From ee5080c20c8958f5e9c7b3d15d24e03c89482d43 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 21:21:12 -0400 Subject: [PATCH 03/17] types: ResolvedTopicsRecord --- packages/zoe/src/contractSupport/topics.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/zoe/src/contractSupport/topics.js b/packages/zoe/src/contractSupport/topics.js index a0f9ce226d9..b2a93d83bb9 100644 --- a/packages/zoe/src/contractSupport/topics.js +++ b/packages/zoe/src/contractSupport/topics.js @@ -42,6 +42,12 @@ export const TopicsRecordShape = M.recordOf(M.string(), PublicTopicShape); * }} TopicsRecord */ +/** + * @typedef {{ + * [topicName: string]: ResolvedPublicTopic, + * }} ResolvedTopicsRecord + */ + /** * @template T * @param {string} description From d1b8a45e79383e0a1b393caf8a559c1ae30a71b3 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 11 Jul 2024 10:35:41 -0400 Subject: [PATCH 04/17] feat: add portfolio-holder-kit.js - PortfolioHolder is a kit that holds multiple OrchestrationAccounts and returns a single invitationMaker and TopicRecord - the Action invitationMaker is designed to pass through calls to invitationMakers from sub-accounts, keyed by chainName - refs #9042, which requires multiple accounts in a single user offer flow --- .../src/exos/portfolio-holder-kit.js | 174 ++++++++++++++++++ .../test/exos/make-test-coa-kit.ts | 70 +++++++ .../test/exos/portfolio-holder-kit.test.ts | 123 +++++++++++++ .../snapshots/portfolio-holder-kit.test.ts.md | 27 +++ .../portfolio-holder-kit.test.ts.snap | Bin 0 -> 482 bytes 5 files changed, 394 insertions(+) create mode 100644 packages/orchestration/src/exos/portfolio-holder-kit.js create mode 100644 packages/orchestration/test/exos/make-test-coa-kit.ts create mode 100644 packages/orchestration/test/exos/portfolio-holder-kit.test.ts create mode 100644 packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md create mode 100644 packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap diff --git a/packages/orchestration/src/exos/portfolio-holder-kit.js b/packages/orchestration/src/exos/portfolio-holder-kit.js new file mode 100644 index 00000000000..3177cdece3b --- /dev/null +++ b/packages/orchestration/src/exos/portfolio-holder-kit.js @@ -0,0 +1,174 @@ +import { M, mustMatch } from '@endo/patterns'; +import { E } from '@endo/far'; +import { Fail } from '@endo/errors'; +import { PublicTopicShape } from '@agoric/zoe/src/contractSupport/topics.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { VowShape } from '@agoric/vow'; + +const { fromEntries } = Object; + +/** + * @import {HostOf} from '@agoric/async-flow'; + * @import {MapStore} from '@agoric/store'; + * @import {VowTools} from '@agoric/vow'; + * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationAccount, OrchestrationAccountI} from '@agoric/orchestration'; + */ + +/** + * @typedef {{ + * accounts: MapStore>; + * publicTopics: MapStore>; + * }} PortfolioHolderState + */ + +const ChainNameShape = M.string(); + +const AccountEntriesShape = M.arrayOf([ + M.string(), + M.remotable('OrchestrationAccount'), +]); +const PublicTopicEntriesShape = M.arrayOf([M.string(), PublicTopicShape]); + +/** + * Kit that holds several OrchestrationAccountKits and returns a invitation + * makers. + * + * @param {Zone} zone + * @param {VowTools} vowTools + */ +const preparePortfolioHolderKit = (zone, { asVow, when }) => { + return zone.exoClassKit( + 'PortfolioHolderKit', + { + invitationMakers: M.interface('InvitationMakers', { + MakeInvitation: M.call( + ChainNameShape, + M.string(), + M.arrayOf(M.any()), + ).returns(M.promise()), + }), + holder: M.interface('Holder', { + asContinuingOffer: M.call().returns(VowShape), + getPublicTopics: M.call().returns(VowShape), + getAccount: M.call(ChainNameShape).returns(VowShape), + addAccount: M.call( + ChainNameShape, + M.remotable(), + PublicTopicShape, + ).returns(VowShape), + }), + }, + /** + * @param {Iterable<[string, OrchestrationAccount]>} accountEntries + * @param {Iterable<[string, ResolvedPublicTopic]>} publicTopicEntries + */ + (accountEntries, publicTopicEntries) => { + mustMatch(accountEntries, AccountEntriesShape, 'must provide accounts'); + mustMatch( + publicTopicEntries, + PublicTopicEntriesShape, + 'must provide public topics', + ); + const accounts = harden( + makeScalarBigMapStore('accounts', { durable: true }), + ); + const publicTopics = harden( + makeScalarBigMapStore('publicTopics', { durable: true }), + ); + accounts.addAll(accountEntries); + publicTopics.addAll(publicTopicEntries); + return /** @type {PortfolioHolderState} */ ( + harden({ + accounts, + publicTopics, + }) + ); + }, + { + invitationMakers: { + /** + * @template {unknown[]} IA + * @param {string} chainName key where the account is stored + * @param {string} action invitation maker name, e.g. 'Delegate' + * @param {IA} invitationArgs + * @returns {Promise>} + */ + MakeInvitation(chainName, action, invitationArgs) { + const { accounts } = this.state; + accounts.has(chainName) || Fail`no account found for ${chainName}`; + const account = accounts.get(chainName); + return when(E(account).asContinuingOffer(), ({ invitationMakers }) => + E(invitationMakers)[action](...invitationArgs), + ); + }, + }, + holder: { + // FIXME /** @type {HostOf} */ + asContinuingOffer() { + return asVow(() => { + const { invitationMakers } = this.facets; + const { publicTopics } = this.state; + return harden({ + publicSubscribers: fromEntries(publicTopics.entries()), + invitationMakers, + }); + }); + }, + // FIXME /** @type {HostOf} */ + getPublicTopics() { + return asVow(() => { + const { publicTopics } = this.state; + return harden(fromEntries(publicTopics.entries())); + }); + }, + /** + * @param {string} chainName key where the account is stored + * @param {OrchestrationAccount} account + * @param {ResolvedPublicTopic} publicTopic + */ + addAccount(chainName, account, publicTopic) { + return asVow(() => { + if (this.state.accounts.has(chainName)) { + throw Fail`account already exists for ${chainName}`; + } + zone.isStorable(account) || + Fail`account for ${chainName} must be storable`; + zone.isStorable(publicTopic) || + Fail`publicTopic for ${chainName} must be storable`; + + this.state.publicTopics.init(chainName, publicTopic); + this.state.accounts.init(chainName, account); + }); + }, + /** + * @param {string} chainName key where the account is stored + */ + getAccount(chainName) { + return asVow(() => this.state.accounts.get(chainName)); + }, + }, + }, + ); +}; + +/** + * A portfolio holder stores two or more OrchestrationAccounts and combines + * ContinuingOfferResult's from each into a single result. + * + * XXX consider an interface for extending the exo maker with additional + * invitation makers. + * + * @param {Zone} zone + * @param {VowTools} vowTools + * @returns {( + * ...args: Parameters> + * ) => ReturnType>['holder']} + */ +export const preparePortfolioHolder = (zone, vowTools) => { + const makeKit = preparePortfolioHolderKit(zone, vowTools); + return (...args) => makeKit(...args).holder; +}; +/** @typedef {ReturnType} MakePortfolioHolder */ +/** @typedef {ReturnType} PortfolioHolder */ diff --git a/packages/orchestration/test/exos/make-test-coa-kit.ts b/packages/orchestration/test/exos/make-test-coa-kit.ts new file mode 100644 index 00000000000..99da8b9880d --- /dev/null +++ b/packages/orchestration/test/exos/make-test-coa-kit.ts @@ -0,0 +1,70 @@ +import { Far } from '@endo/far'; +import { heapVowE as E } from '@agoric/vow/vat.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import type { ExecutionContext } from 'ava'; +import { commonSetup } from '../supports.js'; +import { prepareCosmosOrchestrationAccount } from '../../src/exos/cosmos-orchestration-account.js'; + +/** + * A testing utility that creates a (Cosmos)ChainAccount and makes a + * CosmosOrchestrationAccount with necessary endowments like: recorderKit, + * storageNode, mock ZCF, mock TimerService, and a ChainHub. + * + * Helps reduce boilerplate in test files, and retains testing context through + * parameterized endowments. + * + * @param t + * @param bootstrap + * @param opts + * @param opts.zcf + */ +export const prepareMakeTestCOAKit = ( + t: ExecutionContext, + bootstrap: Awaited>['bootstrap'], + { zcf = Far('MockZCF', {}) } = {}, +) => { + const { cosmosInterchainService, marshaller, rootZone, timer, vowTools } = + bootstrap; + + const { makeRecorderKit } = prepareRecorderKitMakers( + rootZone.mapStore('CosmosOrchAccountRecorder'), + marshaller, + ); + + const makeCosmosOrchestrationAccount = prepareCosmosOrchestrationAccount( + rootZone.subZone('CosmosOrchAccount'), + makeRecorderKit, + vowTools, + // @ts-expect-error mocked zcf + zcf, + ); + + return async ({ + storageNode = bootstrap.storage.rootNode.makeChildNode('accounts'), + chainId = 'cosmoshub-99', + hostConnectionId = 'connection-0' as const, + controllerConnectionId = 'connection-1' as const, + bondDenom = 'uatom', + } = {}) => { + t.log('exo setup - prepareCosmosOrchestrationAccount'); + + t.log('request account from orchestration service'); + const cosmosOrchAccount = await E(cosmosInterchainService).makeAccount( + chainId, + hostConnectionId, + controllerConnectionId, + ); + + const accountAddress = await E(cosmosOrchAccount).getAddress(); + + t.log('make a CosmosOrchestrationAccount'); + const holder = makeCosmosOrchestrationAccount(accountAddress, bondDenom, { + account: cosmosOrchAccount, + storageNode: storageNode.makeChildNode(accountAddress.value), + icqConnection: undefined, + timer, + }); + + return holder; + }; +}; diff --git a/packages/orchestration/test/exos/portfolio-holder-kit.test.ts b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts new file mode 100644 index 00000000000..216764b64e1 --- /dev/null +++ b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts @@ -0,0 +1,123 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { Far } from '@endo/far'; +import { heapVowE as E } from '@agoric/vow/vat.js'; +import { commonSetup } from '../supports.js'; +import { preparePortfolioHolder } from '../../src/exos/portfolio-holder-kit.js'; +import { prepareMakeTestLOAKit } from './make-test-loa-kit.js'; +import { prepareMakeTestCOAKit } from './make-test-coa-kit.js'; + +test('portfolio holder kit behaviors', async t => { + const { bootstrap } = await commonSetup(t); + const { rootZone, storage, vowTools } = bootstrap; + const storageNode = storage.rootNode.makeChildNode('accounts'); + + /** + * mock zcf that echos back the offer description + */ + const mockZcf = Far('MockZCF', { + /** @type {ZCF['makeInvitation']} */ + makeInvitation: (offerHandler, description, ..._rest) => { + t.is(typeof offerHandler, 'function'); + const p = new Promise(resolve => resolve(description)); + return p; + }, + }); + + const makeTestCOAKit = prepareMakeTestCOAKit(t, bootstrap, { zcf: mockZcf }); + const makeTestLOAKit = prepareMakeTestLOAKit(t, bootstrap, { zcf: mockZcf }); + const makeCosmosAccount = async ({ + chainId, + hostConnectionId, + controllerConnectionId, + }) => { + return makeTestCOAKit({ + storageNode, + chainId, + hostConnectionId, + controllerConnectionId, + }); + }; + + const makeLocalAccount = async () => { + return makeTestLOAKit({ storageNode }); + }; + + const accounts = { + cosmoshub: await makeCosmosAccount({ + chainId: 'cosmoshub-99', + hostConnectionId: 'connection-0' as const, + controllerConnectionId: 'connection-1' as const, + }), + agoric: await makeLocalAccount(), + }; + const accountEntries = harden(Object.entries(accounts)); + + const makePortfolioHolder = preparePortfolioHolder( + rootZone.subZone('portfolio'), + vowTools, + ); + const publicTopicEntries = harden( + await Promise.all( + Object.entries(accounts).map(async ([chainName, holder]) => { + const { account } = await E(holder).getPublicTopics(); + return [chainName, account]; + }), + ), + ); + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + const holder = makePortfolioHolder(accountEntries, publicTopicEntries); + + const cosmosAccount = await E(holder).getAccount('cosmoshub'); + t.is( + cosmosAccount, + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + accounts.cosmoshub, + 'same account holder kit provided is returned', + ); + + const { invitationMakers } = await E(holder).asContinuingOffer(); + + const delegateInv = await E(invitationMakers).MakeInvitation( + 'cosmoshub', + 'Delegate', + [ + { + value: 'cosmos1valoper', + chainId: 'cosmoshub-99', + encoding: 'bech32', + }, + { + denom: 'uatom', + value: 10n, + }, + ], + ); + + t.is( + delegateInv, + // note: mocked zcf (we are not in a contract) returns inv description + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'Vow' + 'Delegate', + 'any invitation maker accessible via MakeInvitation', + ); + + const osmosisAccount = await makeCosmosAccount({ + chainId: 'osmosis-99', + hostConnectionId: 'connection-2' as const, + controllerConnectionId: 'connection-3' as const, + }); + + const osmosisTopic = (await E(osmosisAccount).getPublicTopics()).account; + + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + await E(holder).addAccount('osmosis', osmosisAccount, osmosisTopic); + + t.is( + await E(holder).getAccount('osmosis'), + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + osmosisAccount, + 'new accounts can be added', + ); + + t.snapshot(await E(holder).getPublicTopics(), 'public topics'); +}); diff --git a/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md new file mode 100644 index 00000000000..3c2ec14d147 --- /dev/null +++ b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md @@ -0,0 +1,27 @@ +# Snapshot report for `test/exos/portfolio-holder-kit.test.ts` + +The actual snapshot is saved in `portfolio-holder-kit.test.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## portfolio holder kit behaviors + +> public topics + + { + agoric: { + description: 'Account holder status', + storagePath: 'mockChainStorageRoot.accounts.agoric1fakeLCAAddress', + subscriber: Object @Alleged: Durable Publish Kit subscriber {}, + }, + cosmoshub: { + description: 'Staking Account holder status', + storagePath: 'mockChainStorageRoot.accounts.cosmos1test', + subscriber: Object @Alleged: Durable Publish Kit subscriber {}, + }, + osmosis: { + description: 'Staking Account holder status', + storagePath: 'mockChainStorageRoot.accounts.cosmos1test1', + subscriber: Object @Alleged: Durable Publish Kit subscriber {}, + }, + } diff --git a/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..1b57da06351975e25f1830ce8e70ce5bf33789b3 GIT binary patch literal 482 zcmV<80UiE9RzVG*m!r`LbOjWHHi_Nlta# zDvn!#Z33>o`G^H%#W>M1cx{)Q+Dzp1a@@4PtJeXoHSFSwh#HS(1uIg^% z-7d$;OcnO=-gKI#wPkLzyA_{icaa;tK+?88H}TGasKS+5^0(x-e^1{1^h7L6%bWdg z-aF^!owUI3eg!;SU6^=GH>cj)%%=B~hfS@tx5<8^z2iK2 Date: Fri, 12 Jul 2024 00:16:43 -0400 Subject: [PATCH 05/17] chore: VTransferIBCEvent type - adds a VTransferIBCEvent type, for acknowledgementPacket and writeAcknowledgement events --- packages/vats/src/transfer.js | 2 ++ packages/vats/src/types.d.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/vats/src/transfer.js b/packages/vats/src/transfer.js index 244b8e50de6..2e75ebc8540 100644 --- a/packages/vats/src/transfer.js +++ b/packages/vats/src/transfer.js @@ -8,6 +8,7 @@ import { TargetAppI, AppTransformerI } from './bridge-target.js'; /** * @import {TargetApp, TargetHost} from './bridge-target.js' + * @import {VTransferIBCEvent} from './types.js'; */ /** @@ -49,6 +50,7 @@ const prepareTransferInterceptor = (zone, vowTools) => { }), { public: { + /** @param {VTransferIBCEvent} obj */ async receiveUpcall(obj) { const { isActiveTap, tap } = this.state; diff --git a/packages/vats/src/types.d.ts b/packages/vats/src/types.d.ts index 61b344e4a0b..5931569378d 100644 --- a/packages/vats/src/types.d.ts +++ b/packages/vats/src/types.d.ts @@ -1,6 +1,9 @@ +import type { FungibleTokenPacketData } from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; import type { BridgeIdValue, Remote } from '@agoric/internal'; import type { Bytes } from '@agoric/network'; import type { Guarded } from '@endo/exo'; +import type { LocalChainAccount } from './localchain.js'; +import type { TargetApp } from './bridge-target.js'; export type Board = ReturnType< ReturnType @@ -264,3 +267,29 @@ type SendPacketDownCall = { packet: IBCPacket; relativeTimeoutNs: bigint; }; + +/** + * This event is emitted when a FungibleTokenPacket is sent or received + * by a target (e.g. a {@link LocalChainAccount}) that has a registered + * {@link TargetApp}. It is passed through the `receiveUpcall` handler. + */ +export type VTransferIBCEvent = { + type: 'VTRANSFER_IBC_EVENT'; + blockHeight: number; + blockTime: number; + /** + * Indicates the type of IBC packet event: + * - 'acknowledgementPacket': passive tap that communicates the result of an acknowledged packet + * - 'writeAcknowledgement': active tap where the receiver can return a write acknowledgement + */ + event: 'acknowledgementPacket' | 'writeAcknowledgement'; + acknowledgement: Bytes; + /** + * Use `JSON.parse(atob(packet.data))` to get a + * {@link FungibleTokenPacketData} object. + */ + packet: IBCPacket; + relayer: string; + /** e.g. the chain address of the LocalChainAccount */ + target: string; +}; From b64c6719a5a74d9606eb4e7800e399fa15f40c73 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 11 Jul 2024 10:44:42 -0400 Subject: [PATCH 06/17] feat: expose monitorTransfers on LocalOrchestrationAccount - monitorTransfers allows calls to register a handler to react to incoming and outgoing IBC ics20 transfers - includes VTRANSFER_IBC_EVENT<'acknowledgementPacket'> fixture from observed values in a multichain testing environment (see PR comments for logs) - refs: #9042 --- packages/orchestration/src/cosmos-api.ts | 15 ++++ .../src/exos/local-orchestration-account.js | 13 +++- .../test/examples/stake-ica.contract.test.ts | 5 +- .../local-orchestration-account-kit.test.ts | 41 ++++++++++ packages/orchestration/test/supports.ts | 6 +- packages/orchestration/tools/ibc-mocks.ts | 75 +++++++++++++++++++ 6 files changed, 148 insertions(+), 7 deletions(-) diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index f70977fd13c..6880bb5939f 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -14,6 +14,10 @@ import type { State as IBCConnectionState } from '@agoric/cosmic-proto/ibc/core/ import type { Brand, Purse, Payment, Amount } from '@agoric/ertp/src/types.js'; import type { Port } from '@agoric/network'; import type { IBCChannelID, IBCConnectionID } from '@agoric/vats'; +import type { + TargetApp, + TargetRegistration, +} from '@agoric/vats/src/bridge-target.js'; import type { LocalIbcAddress, RemoteIbcAddress, @@ -208,6 +212,17 @@ export type LocalAccountMethods = { deposit: (payment: Payment<'nat'>) => Promise; /** withdraw a Payment from the account */ withdraw: (amount: Amount<'nat'>) => Promise>; + /** + * Register a handler that receives an event each time ICS-20 transfers are + * sent or received by the underlying account. Each account may be associated + * with at most one handler at a given time. + * Does not grant the handler the ability to intercept a transfer. For a + * blocking handler, aka 'IBC Hooks', leverage `registerActiveTap` from + * `transferMiddleware` directly. + * + * @param tap + */ + monitorTransfers: (tap: TargetApp) => Promise; }; export type IBCMsgTransferOptions = { diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index 2a21a905368..c6f729bcbbb 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -20,13 +20,13 @@ import { makeTimestampHelper } from '../utils/time.js'; /** * @import {HostOf} from '@agoric/async-flow'; * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; - * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, OrchestrationAccount, ChainInfo, IBCConnectionInfo, OrchestrationAccountI} from '@agoric/orchestration'; + * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, ChainInfo, IBCConnectionInfo, OrchestrationAccountI} from '@agoric/orchestration'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; * @import {Remote} from '@agoric/internal'; * @import {InvitationMakers} from '@agoric/smart-wallet/src/types.js'; - * @import {TimerService, TimerBrand, TimestampRecord} from '@agoric/time'; - * @import {PromiseVow, Vow, VowTools} from '@agoric/vow'; + * @import {TimerService, TimestampRecord} from '@agoric/time'; + * @import {Vow, VowTools} from '@agoric/vow'; * @import {TypedJson, JsonSafe} from '@agoric/cosmic-proto'; * @import {Matcher} from '@endo/patterns'; * @import {ChainHub} from './chain-hub.js'; @@ -57,6 +57,9 @@ const HolderI = M.interface('holder', { deposit: M.call(PaymentShape).returns(VowShape), withdraw: M.call(AmountShape).returns(Vow$(PaymentShape)), executeTx: M.call(M.arrayOf(M.record())).returns(Vow$(M.record())), + monitorTransfers: M.call(M.remotable('TransferTap')).returns( + Vow$(M.remotable('TargetRegistration')), + ), }); /** @type {{ [name: string]: [description: string, valueShape: Matcher] }} */ @@ -475,6 +478,10 @@ export const prepareLocalOrchestrationAccountKit = ( throw Fail`not yet implemented`; }); }, + /** @type {HostOf} */ + monitorTransfers(tap) { + return watch(E(this.state.account).monitorTransfers(tap)); + }, }, }, ); diff --git a/packages/orchestration/test/examples/stake-ica.contract.test.ts b/packages/orchestration/test/examples/stake-ica.contract.test.ts index 195ffe42e57..b7cb9f09bbd 100644 --- a/packages/orchestration/test/examples/stake-ica.contract.test.ts +++ b/packages/orchestration/test/examples/stake-ica.contract.test.ts @@ -9,7 +9,6 @@ import { QueryBalanceRequest, QueryBalanceResponse, } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; -import { TimeMath } from '@agoric/time'; import { commonSetup } from '../supports.js'; import { type StakeIcaTerms } from '../../src/examples/stakeIca.contract.js'; import fetchedChainInfo from '../../src/fetched-chain-info.js'; @@ -78,7 +77,7 @@ const startContract = async ({ }; test('makeAccount, getAddress, getBalances, getBalance', async t => { - const { bootstrap } = await commonSetup(t); + const { bootstrap, mocks } = await commonSetup(t); { // stakeAtom const { publicFacet } = await startContract(bootstrap); @@ -105,7 +104,7 @@ test('makeAccount, getAddress, getBalances, getBalance', async t => { } { // stakeOsmo - const { ibcBridge } = bootstrap; + const { ibcBridge } = mocks; await E(ibcBridge).setAddressPrefix('osmo'); const { publicFacet } = await startContract({ ...bootstrap, diff --git a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts index ed6ddae2fa6..aee995de057 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -10,6 +10,7 @@ import { commonSetup } from '../supports.js'; import { UNBOND_PERIOD_SECONDS } from '../ibc-mocks.js'; import { maxClockSkew } from '../../src/utils/cosmos.js'; import { prepareMakeTestLOAKit } from './make-test-loa-kit.js'; +import { buildVTransferEvent } from '../../tools/ibc-mocks.js'; test('deposit, withdraw', async t => { const common = await commonSetup(t); @@ -172,3 +173,43 @@ test('transfer', async t => { 'accepts custom timeoutHeight', ); }); + +test('monitor transfers', async t => { + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); + const { + mocks: { transferBridge }, + bootstrap: { rootZone }, + } = common; + + let upcallCount = 0; + const zone = rootZone.subZone('tap'); + const tap: TargetApp = zone.exo('tap', undefined, { + receiveUpcall: (obj: unknown) => { + upcallCount += 1; + t.log('receiveUpcall', obj); + return Promise.resolve(); + }, + }); + + const { value: target } = await E(account).getAddress(); + const appRegistration = await E(account).monitorTransfers(tap); + + // simulate upcall from golang to VM + const simulateIncomingTransfer = async () => + E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: target, + }), + ); + + await simulateIncomingTransfer(); + t.is(upcallCount, 1, 'first upcall received'); + await simulateIncomingTransfer(); + t.is(upcallCount, 2, 'second upcall received'); + + await appRegistration.revoke(); + await t.throwsAsync(simulateIncomingTransfer()); + t.is(upcallCount, 2, 'no more events after app is revoked'); +}); diff --git a/packages/orchestration/test/supports.ts b/packages/orchestration/test/supports.ts index 5e80d37c34a..f15e0e63e5c 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -88,6 +88,7 @@ export const commonSetup = async (t: ExecutionContext) => { interceptorFactory, ); finisher.useRegistry(bridgeTargetKit.targetRegistry); + await E(transferBridge).initHandler(bridgeTargetKit.bridgeHandler); const localBrigeMessages = [] as any[]; const localchainBridge = makeFakeLocalchainBridge(rootZone, obj => @@ -136,12 +137,15 @@ export const commonSetup = async (t: ExecutionContext) => { rootZone: rootZone.subZone('contract'), storage, vowTools, - ibcBridge, }, brands: { bld: bldSansMint, ist: istSansMint, }, + mocks: { + ibcBridge, + transferBridge, + }, commonPrivateArgs: { agoricNames, localchain, diff --git a/packages/orchestration/tools/ibc-mocks.ts b/packages/orchestration/tools/ibc-mocks.ts index c7887228377..cc056eccd37 100644 --- a/packages/orchestration/tools/ibc-mocks.ts +++ b/packages/orchestration/tools/ibc-mocks.ts @@ -6,7 +6,11 @@ import { } from '@agoric/cosmic-proto/tendermint/abci/types.js'; import { encodeBase64, btoa } from '@endo/base64'; import { toRequestQueryJson } from '@agoric/cosmic-proto'; +import { IBCChannelID, VTransferIBCEvent } from '@agoric/vats'; +import { VTRANSFER_IBC_EVENT } from '@agoric/internal/src/action-types.js'; +import { FungibleTokenPacketData } from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; import { makeQueryPacket, makeTxPacket } from '../src/utils/packet.js'; +import { ChainAddress } from '../src/orchestration-api.js'; interface EncoderI { encode: (message: T) => { @@ -125,3 +129,74 @@ export function buildQueryPacketString( ): string { return btoa(makeQueryPacket(msgs.map(msg => toRequestQueryJson(msg, opts)))); } + +type BuildVTransferEventParams = { + event?: VTransferIBCEvent['event']; + /* defaults to cosmos1AccAddress. set to `agoric1fakeLCAAddress` to simulate an outgoing transfer event */ + sender?: ChainAddress['value']; + /** defaults to agoric1fakeLCAAddress. set to a different value to simulate an outgoing transfer event */ + receiver?: ChainAddress['value']; + amount?: bigint; + denom?: string; + destinationChannel?: IBCChannelID; + sourceChannel?: IBCChannelID; +}; + +/** + * `buildVTransferEvent` can be used with `transferBridge` to simulate incoming + * and outgoing IBC fungible tokens transfers to a LocalChain account. + * + * It defaults to simulating incoming transfers. To simulate an outgoing one, + * ensure `sender=agoric1fakeLCAAddress` and this after LocalChainBridge + * receives the outgoing MsgTransfer, + * + * @example + * ```js + * const { mocks: { transferBridge } = await commonSetup(t); + * await E(transferBridge).fromBridge( + * buildVTransferEvent({ + * receiver: 'agoric1fakeLCAAddress', + * amount: 10n, + * denom: 'uatom', + * }), + * ); + * ``` + * + * XXX integrate vlocalchain and vtransfer ScopedBridgeManagers + * in test supports. + * + * @param {{BuildVTransferEventParams}} args + */ +export const buildVTransferEvent = ({ + event = 'acknowledgementPacket' as const, + sender = 'cosmos1AccAddress', + receiver = 'agoric1fakeLCAAddress', + amount = 10n, + denom = 'uatom', + destinationChannel = 'channel-0' as IBCChannelID, + sourceChannel = 'channel-405' as IBCChannelID, +}: BuildVTransferEventParams = {}): VTransferIBCEvent => ({ + type: VTRANSFER_IBC_EVENT, + blockHeight: 0, + blockTime: 0, + event, + acknowledgement: btoa(JSON.stringify({ result: 'AQ==' })), + relayer: 'agoric123', + target: receiver, + packet: { + data: btoa( + JSON.stringify( + FungibleTokenPacketData.fromPartial({ + amount: String(amount), + denom, + sender, + receiver, + }), + ), + ), + destination_channel: destinationChannel, + source_channel: sourceChannel, + destination_port: 'transfer', + source_port: 'transfer', + }, +}); From b57c2d51325ba75713fbc6d94f9a2318646808c1 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 13:39:53 -0400 Subject: [PATCH 07/17] types: GuestInterface --- packages/async-flow/src/types.d.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/async-flow/src/types.d.ts b/packages/async-flow/src/types.d.ts index 77ec495194e..85907144acd 100644 --- a/packages/async-flow/src/types.d.ts +++ b/packages/async-flow/src/types.d.ts @@ -54,8 +54,14 @@ type HostInterface = { /** * Convert an entire Host interface into what the Guest will receive. */ -type GuestInterface = { - [K in keyof T]: GuestOf; +export type GuestInterface = { + [K in keyof T]: T[K] extends (...args: any[]) => Vow + ? (...args: Parameters) => Promise + : T[K] extends HostAsyncFuncWrapper + ? GuestOf + : T[K] extends object + ? GuestInterface + : T[K]; }; /** From 2c9df2b4c766c9f640fd01de283bef8bf41f366f Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 14:08:44 -0400 Subject: [PATCH 08/17] test(fake-bridge): support multiple LCAs at once --- packages/vats/tools/fake-bridge.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/vats/tools/fake-bridge.js b/packages/vats/tools/fake-bridge.js index 692748de30f..d1736f85953 100644 --- a/packages/vats/tools/fake-bridge.js +++ b/packages/vats/tools/fake-bridge.js @@ -166,6 +166,7 @@ export const makeFakeLocalchainBridge = (zone, onToBridge = () => {}) => { /** @type {Remote} */ let hndlr; let lcaExecuteTxSequence = 0; + let accountsCreated = 0; return zone.exo('Fake Localchain Bridge Manager', undefined, { getBridgeId: () => 'vlocalchain', toBridge: async obj => { @@ -173,8 +174,11 @@ export const makeFakeLocalchainBridge = (zone, onToBridge = () => {}) => { const { method, type, ...params } = obj; trace('toBridge', type, method, params); switch (type) { - case 'VLOCALCHAIN_ALLOCATE_ADDRESS': - return LOCALCHAIN_DEFAULT_ADDRESS; + case 'VLOCALCHAIN_ALLOCATE_ADDRESS': { + const address = `${LOCALCHAIN_DEFAULT_ADDRESS}${accountsCreated || ''}`; + accountsCreated += 1; + return address; + } case 'VLOCALCHAIN_EXECUTE_TX': { lcaExecuteTxSequence += 1; return obj.messages.map(message => { From aee13b7bf7a143f417b2f737eba69a8a84e8e8e7 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 21:27:26 -0400 Subject: [PATCH 09/17] test: inspect fake ibc bridge events --- packages/orchestration/test/network-fakes.ts | 28 +++++++++++++++----- packages/orchestration/test/supports.ts | 1 + 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/orchestration/test/network-fakes.ts b/packages/orchestration/test/network-fakes.ts index 3f481a72e08..18cbddc081a 100644 --- a/packages/orchestration/test/network-fakes.ts +++ b/packages/orchestration/test/network-fakes.ts @@ -118,6 +118,10 @@ export const ibcBridgeMocks: { }, }; +type BridgeEvents = Array< + IBCEvent<'channelOpenAck'> | IBCEvent<'acknowledgementPacket'> +>; + /** * Make a fake IBC Bridge, extended from the dibc ScopedBridgeManager. * @@ -131,6 +135,7 @@ export const makeFakeIBCBridge = ( ScopedBridgeManagerMethods<'dibc'> & { setMockAck: (mockAckMap: Record) => void; setAddressPrefix: (addressPrefix: string) => void; + inspectDibcBridge: () => BridgeEvents; } > => { let bridgeHandler: any; @@ -155,27 +160,30 @@ export const makeFakeIBCBridge = ( * @type {Record} */ let mockAckMap = defaultMockAckMap; + let bridgeEvents: BridgeEvents = []; return zone.exo('Fake IBC Bridge Manager', undefined, { getBridgeId: () => BridgeId.DIBC, toBridge: async obj => { if (obj.type === 'IBC_METHOD') { switch (obj.method) { - case 'startChannelOpenInit': - bridgeHandler?.fromBridge( - ibcBridgeMocks.channelOpenAck(obj, { - bech32Prefix, - sequence: channelCount, - }), - ); + case 'startChannelOpenInit': { + const ackEvent = ibcBridgeMocks.channelOpenAck(obj, { + bech32Prefix, + sequence: channelCount, + }); + bridgeHandler?.fromBridge(ackEvent); + bridgeEvents = bridgeEvents.concat(ackEvent); channelCount += 1; return undefined; + } case 'sendPacket': { const ackEvent = ibcBridgeMocks.acknowledgementPacket(obj, { sequence: ibcSequenceNonce, acknowledgement: mockAckMap?.[obj.packet.data] || errorAcknowledgments.error5, }); + bridgeEvents = bridgeEvents.concat(ackEvent); ibcSequenceNonce += 1; bridgeHandler?.fromBridge(ackEvent); return ackEvent.packet; @@ -215,6 +223,12 @@ export const makeFakeIBCBridge = ( setAddressPrefix: (newPrefix: typeof bech32Prefix) => { bech32Prefix = newPrefix; }, + /** + * for debugging and testing + */ + inspectDibcBridge() { + return bridgeEvents; + }, }); }; diff --git a/packages/orchestration/test/supports.ts b/packages/orchestration/test/supports.ts index f15e0e63e5c..b3754320e28 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -163,6 +163,7 @@ export const commonSetup = async (t: ExecutionContext) => { utils: { pourPayment, inspectLocalBridge: () => harden([...localBrigeMessages]), + inspectDibcBridge: () => E(ibcBridge).inspectDibcBridge(), }, }; }; From b87ecba0ea41f1397dbd513d8e4c541f1299fd3f Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 11 Jul 2024 19:46:59 -0400 Subject: [PATCH 10/17] feat: examples/auto-stake-it.contract.js - creates an example contract that user .monitorTransfers to react to an incoming IBC transfer. when the transfer is received, it's sent to an ICA then delegated. Both accounts are put in a PortfolioHolder kit, which combines ContinuingOfferResults into a single record - includes logic to ignore outgoing transfers, uknown denoms, and unkown sourceChannels - does not include logic to look for a specific value in the transfer memo field, but this could be added - refs: #9042 --- .../test/bootstrapTests/orchestration.test.ts | 10 + .../scripts/testing/start-auto-stake-it.js | 126 ++++++++++++ .../src/examples/auto-stake-it-tap-kit.js | 157 +++++++++++++++ .../src/examples/auto-stake-it.contract.js | 188 ++++++++++++++++++ .../examples/auto-stake-it.contract.test.ts | 149 ++++++++++++++ 5 files changed, 630 insertions(+) create mode 100644 packages/builders/scripts/testing/start-auto-stake-it.js create mode 100644 packages/orchestration/src/examples/auto-stake-it-tap-kit.js create mode 100644 packages/orchestration/src/examples/auto-stake-it.contract.js create mode 100644 packages/orchestration/test/examples/auto-stake-it.contract.test.ts diff --git a/packages/boot/test/bootstrapTests/orchestration.test.ts b/packages/boot/test/bootstrapTests/orchestration.test.ts index 0c65770cc23..2817b8a8c77 100644 --- a/packages/boot/test/bootstrapTests/orchestration.test.ts +++ b/packages/boot/test/bootstrapTests/orchestration.test.ts @@ -310,3 +310,13 @@ test('basic-flows', async t => { }); t.is(readLatest('published.basicFlows.agoric1mockVlocalchainAddress'), ''); }); + +test.serial('auto-stake-it - proposal', async t => { + const { buildProposal, evalProposal } = t.context; + + await t.notThrowsAsync( + evalProposal( + buildProposal('@agoric/builders/scripts/testing/start-auto-stake-it.js'), + ), + ); +}); diff --git a/packages/builders/scripts/testing/start-auto-stake-it.js b/packages/builders/scripts/testing/start-auto-stake-it.js new file mode 100644 index 00000000000..38e810dfcb9 --- /dev/null +++ b/packages/builders/scripts/testing/start-auto-stake-it.js @@ -0,0 +1,126 @@ +/** + * @file A proposal to start the auto-stake-it contract. + * + * AutoStakeIt allows users to to create an auto-forwarding address that + * transfers and stakes tokens on a remote chain when received. + */ +import { makeTracer } from '@agoric/internal'; +import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; +import { E } from '@endo/far'; +import { deeplyFulfilled } from '@endo/marshal'; + +/** + * @import {AutoStakeItSF} from '@agoric/orchestration/src/examples/auto-stake-it.contract.js'; + */ + +const contractName = 'autoAutoStakeIt'; +const trace = makeTracer(contractName, true); + +/** + * @param {BootstrapPowers} powers + */ +export const startAutoStakeIt = async ({ + consume: { + agoricNames, + board, + chainStorage, + chainTimerService, + cosmosInterchainService, + localchain, + startUpgradable, + }, + installation: { + // @ts-expect-error not a WellKnownName + consume: { [contractName]: installation }, + }, + instance: { + // @ts-expect-error not a WellKnownName + produce: { [contractName]: produceInstance }, + }, +}) => { + trace(`start ${contractName}`); + await null; + + const storageNode = await makeStorageNodeChild(chainStorage, contractName); + const marshaller = await E(board).getPublishingMarshaller(); + + /** @type {StartUpgradableOpts} */ + const startOpts = { + label: 'autoAutoStakeIt', + installation, + terms: undefined, + privateArgs: await deeplyFulfilled( + harden({ + agoricNames, + orchestrationService: cosmosInterchainService, + localchain, + storageNode, + marshaller, + timerService: chainTimerService, + }), + ), + }; + + const { instance } = await E(startUpgradable)(startOpts); + produceInstance.resolve(instance); +}; +harden(startAutoStakeIt); + +export const getManifestForContract = ( + { restoreRef }, + { installKeys, ...options }, +) => { + return { + manifest: { + [startAutoStakeIt.name]: { + consume: { + agoricNames: true, + board: true, + chainStorage: true, + chainTimerService: true, + cosmosInterchainService: true, + localchain: true, + startUpgradable: true, + }, + installation: { + consume: { [contractName]: true }, + }, + instance: { + produce: { [contractName]: true }, + }, + }, + }, + installations: { + [contractName]: restoreRef(installKeys[contractName]), + }, + options, + }; +}; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').CoreEvalBuilder} */ +export const defaultProposalBuilder = async ({ publishRef, install }) => { + return harden({ + // Somewhat unorthodox, source the exports from this builder module + sourceSpec: '@agoric/builders/scripts/testing/start-auto-stake-it.js', + getManifestCall: [ + 'getManifestForContract', + { + installKeys: { + autoAutoStakeIt: publishRef( + install( + '@agoric/orchestration/src/examples/auto-stake-it.contract.js', + ), + ), + }, + }, + ], + }); +}; + +export default async (homeP, endowments) => { + // import dynamically so the module can work in CoreEval environment + const dspModule = await import('@agoric/deploy-script-support'); + const { makeHelpers } = dspModule; + const { writeCoreEval } = await makeHelpers(homeP, endowments); + await writeCoreEval(startAutoStakeIt.name, defaultProposalBuilder); +}; diff --git a/packages/orchestration/src/examples/auto-stake-it-tap-kit.js b/packages/orchestration/src/examples/auto-stake-it-tap-kit.js new file mode 100644 index 00000000000..7092c704343 --- /dev/null +++ b/packages/orchestration/src/examples/auto-stake-it-tap-kit.js @@ -0,0 +1,157 @@ +import { M, mustMatch } from '@endo/patterns'; +import { E } from '@endo/far'; +import { VowShape } from '@agoric/vow'; +import { makeTracer } from '@agoric/internal'; +import { atob } from '@endo/base64'; +import { ChainAddressShape } from '../typeGuards.js'; + +const trace = makeTracer('AutoStakeItTap'); + +/** + * @import {IBCChannelID, VTransferIBCEvent} from '@agoric/vats'; + * @import {VowTools} from '@agoric/vow'; + * @import {Zone} from '@agoric/zone'; + * @import {TargetApp} from '@agoric/vats/src/bridge-target.js'; + * @import {ChainAddress, CosmosValidatorAddress, Denom, OrchestrationAccount, StakingAccountActions} from '@agoric/orchestration'; + * @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; + * @import {TypedPattern} from '@agoric/internal'; + */ + +/** + * @typedef {{ + * stakingAccount: ERef & StakingAccountActions>; + * localAccount: ERef>; + * validator: CosmosValidatorAddress; + * localChainAddress: ChainAddress; + * remoteChainAddress: ChainAddress; + * sourceChannel: IBCChannelID; + * remoteDenom: Denom; + * localDenom: Denom; + * }} StakingTapState + */ + +/** @type {TypedPattern} */ +const StakingTapStateShape = { + stakingAccount: M.remotable('CosmosOrchestrationAccount'), + localAccount: M.remotable('LocalOrchestrationAccount'), + validator: ChainAddressShape, + localChainAddress: ChainAddressShape, + remoteChainAddress: ChainAddressShape, + sourceChannel: M.string(), + remoteDenom: M.string(), + localDenom: M.string(), +}; +harden(StakingTapStateShape); + +/** + * @param {Zone} zone + * @param {VowTools} vowTools + */ +const prepareStakingTapKit = (zone, { watch }) => { + return zone.exoClassKit( + 'StakingTapKit', + { + tap: M.interface('AutoStakeItTap', { + receiveUpcall: M.call(M.record()).returns( + M.or(VowShape, M.undefined()), + ), + }), + transferWatcher: M.interface('TransferWatcher', { + onFulfilled: M.call(M.undefined()) + .optional(M.bigint()) + .returns(VowShape), + }), + }, + /** @param {StakingTapState} initialState */ + initialState => { + mustMatch(initialState, StakingTapStateShape); + return harden(initialState); + }, + { + tap: { + /** + * Transfers from localAccount to stakingAccount, then delegates from + * the stakingAccount to `validator` if the expected token (remoteDenom) + * is received. + * + * @param {VTransferIBCEvent} event + */ + receiveUpcall(event) { + trace('receiveUpcall', event); + + // ignore packets from unknown channels + if (event.packet.source_channel !== this.state.sourceChannel) { + return; + } + + const tx = /** @type {FungibleTokenPacketData} */ ( + JSON.parse(atob(event.packet.data)) + ); + trace('receiveUpcall packet data', tx); + + const { remoteDenom, localChainAddress } = this.state; + // ignore outgoing transfers + if (tx.receiver !== localChainAddress.value) { + return; + } + // only interested in transfers of `remoteDenom` + if (tx.denom !== remoteDenom) { + return; + } + + const { localAccount, localDenom, remoteChainAddress } = this.state; + return watch( + E(localAccount).transfer( + { + denom: localDenom, + value: BigInt(tx.amount), + }, + remoteChainAddress, + ), + this.facets.transferWatcher, + BigInt(tx.amount), + ); + }, + }, + transferWatcher: { + /** + * @param {void} _result + * @param {bigint} value the qty of uatom to delegate + */ + onFulfilled(_result, value) { + const { stakingAccount, validator, remoteDenom } = this.state; + return watch( + E(stakingAccount).delegate(validator, { + denom: remoteDenom, + value, + }), + ); + }, + }, + }, + ); +}; + +/** + * Provides a {@link TargetApp} that reacts to an incoming IBC transfer by: + * + * 1. transferring the funds to the staking account specified at initialization + * 2. delegating the funds to the validator specified at initialization + * + * XXX consider a facet with a method for changing the validator + * + * XXX consider logic for multiple stakingAccounts + denoms + * + * @param {Zone} zone + * @param {VowTools} vowTools + * @returns {( + * ...args: Parameters> + * ) => ReturnType>['tap']} + */ +export const prepareStakingTap = (zone, vowTools) => { + const makeKit = prepareStakingTapKit(zone, vowTools); + return (...args) => makeKit(...args).tap; +}; + +/** @typedef {ReturnType} MakeStakingTap */ +/** @typedef {ReturnType} StakingTap */ diff --git a/packages/orchestration/src/examples/auto-stake-it.contract.js b/packages/orchestration/src/examples/auto-stake-it.contract.js new file mode 100644 index 00000000000..1098b24011a --- /dev/null +++ b/packages/orchestration/src/examples/auto-stake-it.contract.js @@ -0,0 +1,188 @@ +import { + EmptyProposalShape, + InvitationShape, +} from '@agoric/zoe/src/typeGuards.js'; +import { Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { withOrchestration } from '../utils/start-helper.js'; +import { prepareChainHubAdmin } from '../exos/chain-hub-admin.js'; +import { prepareStakingTap } from './auto-stake-it-tap-kit.js'; +import { preparePortfolioHolder } from '../exos/portfolio-holder-kit.js'; + +/** + * @import {TimerService} from '@agoric/time'; + * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; + * @import {LocalChain} from '@agoric/vats/src/localchain.js'; + * @import {NameHub} from '@agoric/vats'; + * @import {Remote} from '@agoric/vow'; + * @import {Zone} from '@agoric/zone'; + * @import {GuestInterface} from '@agoric/async-flow'; + * @import {CosmosValidatorAddress, Orchestrator, CosmosInterchainService, Denom, OrchestrationAccount, StakingAccountActions} from '@agoric/orchestration'; + * @import {MakeStakingTap} from './auto-stake-it-tap-kit.js'; + * @import {MakePortfolioHolder} from '../exos/portfolio-holder-kit.js'; + * @import {ChainHub} from '../exos/chain-hub.js'; + * @import {OrchestrationTools} from '../utils/start-helper.js'; + */ + +/** + * @typedef {{ + * localchain: Remote; + * orchestrationService: Remote; + * storageNode: Remote; + * timerService: Remote; + * agoricNames: Remote; + * }} OrchestrationPowers + */ + +/** + * @param {Orchestrator} orch + * @param {{ + * makeStakingTap: MakeStakingTap; + * makePortfolioHolder: MakePortfolioHolder; + * chainHub: GuestInterface; + * }} ctx + * @param {ZCFSeat} seat + * @param {{ + * chainName: string; + * validator: CosmosValidatorAddress; + * localDenom: Denom; + * }} offerArgs + */ +const makeAccountsHandler = async ( + orch, + { makeStakingTap, makePortfolioHolder, chainHub }, + seat, + { + chainName, + validator, + // TODO localDenom is user supplied, until #9211 + localDenom, + }, +) => { + seat.exit(); // no funds exchanged + const [agoric, remoteChain] = await Promise.all([ + orch.getChain('agoric'), + orch.getChain(chainName), + ]); + const { chainId, stakingTokens } = await remoteChain.getChainInfo(); + const remoteDenom = stakingTokens[0].denom; + remoteDenom || + Fail`${chainId || chainName} does not have stakingTokens in config`; + if (chainId !== validator.chainId) { + Fail`validator chainId ${validator.chainId} does not match remote chainId ${chainId}`; + } + const [localAccount, stakingAccount] = await Promise.all([ + agoric.makeAccount(), + /** @type {Promise & StakingAccountActions>} */ ( + remoteChain.makeAccount() + ), + ]); + + const [localChainAddress, remoteChainAddress] = await Promise.all([ + localAccount.getAddress(), + stakingAccount.getAddress(), + ]); + const agoricChainId = (await agoric.getChainInfo()).chainId; + const { transferChannel } = await chainHub.getConnectionInfo( + agoricChainId, + chainId, + ); + assert(transferChannel.counterPartyChannelId, 'unable to find sourceChannel'); + + // Every time the `localAccount` receives `remoteDenom` over IBC, delegate it. + const tap = makeStakingTap({ + localAccount, + stakingAccount, + validator, + localChainAddress, + remoteChainAddress, + sourceChannel: transferChannel.counterPartyChannelId, + remoteDenom, + localDenom, + }); + // XXX consider storing appRegistration, so we can .revoke() or .updateTargetApp() + // @ts-expect-error tap.receiveUpcall: 'Vow | undefined' not assignable to 'Promise' + await localAccount.monitorTransfers(tap); + + const accountEntries = harden( + /** @type {[string, OrchestrationAccount][]} */ ([ + ['agoric', localAccount], + [chainName, stakingAccount], + ]), + ); + const publicTopicEntries = harden( + /** @type {[string, ResolvedPublicTopic][]} */ ( + await Promise.all( + accountEntries.map(async ([name, account]) => { + const { account: topicRecord } = await account.getPublicTopics(); + return [name, topicRecord]; + }), + ) + ), + ); + const portfolioHolder = makePortfolioHolder( + accountEntries, + publicTopicEntries, + ); + return portfolioHolder.asContinuingOffer(); +}; + +/** + * AutoStakeIt allows users to to create an auto-forwarding address that + * transfers and stakes tokens on a remote chain when received. + * + * To be wrapped with `withOrchestration`. + * + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} _privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools + */ +const contract = async ( + zcf, + _privateArgs, + zone, + { chainHub, orchestrate, vowTools }, +) => { + const makeStakingTap = prepareStakingTap( + zone.subZone('stakingTap'), + vowTools, + ); + const makePortfolioHolder = preparePortfolioHolder( + zone.subZone('portfolio'), + vowTools, + ); + + const makeAccounts = orchestrate( + 'makeAccounts', + { makeStakingTap, makePortfolioHolder, chainHub }, + makeAccountsHandler, + ); + + const publicFacet = zone.exo( + 'AutoStakeIt Public Facet', + M.interface('AutoStakeIt Public Facet', { + makeAccountsInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeAccountsInvitation() { + return zcf.makeInvitation( + makeAccounts, + 'Make Accounts', + undefined, + EmptyProposalShape, + ); + }, + }, + ); + + const creatorFacet = prepareChainHubAdmin(zone, chainHub); + + return { publicFacet, creatorFacet }; +}; + +export const start = withOrchestration(contract); + +/** @typedef {typeof start} AutoStakeItSF */ diff --git a/packages/orchestration/test/examples/auto-stake-it.contract.test.ts b/packages/orchestration/test/examples/auto-stake-it.contract.test.ts new file mode 100644 index 00000000000..261fb9605fb --- /dev/null +++ b/packages/orchestration/test/examples/auto-stake-it.contract.test.ts @@ -0,0 +1,149 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { E } from '@endo/far'; +import { heapVowE } from '@agoric/vow/vat.js'; +import path from 'path'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { MsgDelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +import { IBCEvent } from '@agoric/vats'; +import { commonSetup } from '../supports.js'; +import { + buildMsgResponseString, + buildVTransferEvent, +} from '../../tools/ibc-mocks.js'; + +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractName = 'auto-stake-it'; +const contractFile = `${dirname}/../../src/examples/${contractName}.contract.js`; +type StartFn = + typeof import('../../src/examples/auto-stake-it.contract.js').start; + +test('auto-stake-it - make accounts, register tap, return invitationMakers', async t => { + t.log('bootstrap, orchestration core-eval'); + const { + bootstrap: { storage }, + commonPrivateArgs, + mocks: { transferBridge }, + utils: { inspectLocalBridge, inspectDibcBridge }, + } = await commonSetup(t); + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + const installation: Installation = + await bundleAndInstall(contractFile); + const storageNode = await E(storage.rootNode).makeChildNode(contractName); + const autoAutoStakeItKit = await E(zoe).startInstance( + installation, + undefined, + {}, + { ...commonPrivateArgs, storageNode }, + ); + const publicFacet = await E(zoe).getPublicFacet(autoAutoStakeItKit.instance); + + // make an offer to create an LocalOrchAcct and a CosmosOrchAccount + const inv = E(publicFacet).makeAccountsInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'cosmoshub', + validator: { + chainId: 'cosmoshub-4', + value: 'cosmosvaloper1test', + encoding: 'bech32', + }, + // TODO user supplied until #9211 + localDenom: 'ibc/fakeuatomhash', + }); + const result = await heapVowE(userSeat).getOfferResult(); + + const { + publicSubscribers: { agoric, cosmoshub }, + } = result; + + const loaAddress = agoric.storagePath.split('.').pop()!; + const icaAddress = cosmoshub.storagePath.split('.').pop()!; + t.regex(loaAddress, /^agoric/); + t.regex(icaAddress, /^cosmos/); + + // simulate incoming transfers with upcall from golang to VM to initiate the + // incoming transfer Tap + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: 'agoric1fakeLCAAddress', + amount: 10n, + denom: 'unknown-token', + }), + ); + await eventLoopIteration(); + { + const { messages } = inspectLocalBridge().at(-1); + // we do not expect to see MsgTransfer for unknown tokens + t.not(messages?.length, 1, 'unknown-token is ignored'); + } + + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: 'agoric1fakeLCAAddress', + amount: 10n, + denom: 'unknown-token', + sourceChannel: 'channel-0', + }), + ); + await eventLoopIteration(); + { + const { messages } = inspectLocalBridge().at(-1); + // we do not expect to see MsgTransfer for an sourceChannel + t.not(messages?.length, 1, 'unknown sourceChannel is ignored'); + } + + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: 'agoric1fakeLCAAddress', + amount: 10n, + denom: 'uatom', + }), + ); + await eventLoopIteration(); + + const { messages, address: execAddr } = inspectLocalBridge().at(-1); + t.is(messages?.length, 1, 'transfer message sent'); + t.like( + messages[0], + { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + receiver: 'cosmos1test', + sender: execAddr, + sourceChannel: 'channel-5', + token: { amount: '10', denom: 'ibc/fakeuatomhash' }, + }, + 'tokens transferred from LOA to COA', + ); + const { acknowledgement } = (await inspectDibcBridge()).at( + -1, + ) as IBCEvent<'acknowledgementPacket'>; + // XXX consider checking ICA (dest|source)_channel, to verify the sender of + // MsgDelegate, once available in vstorage + t.is( + acknowledgement, + buildMsgResponseString(MsgDelegateResponse, {}), + 'COA delegated the received funds', + ); + + // second user can make an account + const inv2 = E(publicFacet).makeAccountsInvitation(); + const userSeat2 = E(zoe).offer(inv2, {}, undefined, { + chainName: 'cosmoshub', + validator: { + chainId: 'cosmoshub-4', + value: 'cosmosvaloper1test', + encoding: 'bech32', + }, + // TODO user supplied until #9211 + localDenom: 'ibc/fakeuatomhash', + }); + const { publicSubscribers: pubSubs2 } = + await heapVowE(userSeat2).getOfferResult(); + + t.regex(pubSubs2.agoric.storagePath.split('.').pop()!, /^agoric/); + t.regex(pubSubs2.cosmoshub.storagePath.split('.').pop()!, /^cosmos/); +}); From d809d654a497d0130b19e398290af93054e82085 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 11 Jul 2024 17:01:37 -0400 Subject: [PATCH 11/17] chore: improve chainConfig types --- .../scripts/fetch-starship-chain-info.ts | 2 +- multichain-testing/starship-chain-info.js | 2 +- multichain-testing/test/basic-flows.test.ts | 20 ++++++++++--------- multichain-testing/test/support.ts | 13 +++--------- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/multichain-testing/scripts/fetch-starship-chain-info.ts b/multichain-testing/scripts/fetch-starship-chain-info.ts index 564384340ec..f809e031e16 100755 --- a/multichain-testing/scripts/fetch-starship-chain-info.ts +++ b/multichain-testing/scripts/fetch-starship-chain-info.ts @@ -50,7 +50,7 @@ const chainInfo = await convertChainInfo({ }); const record = JSON.stringify(chainInfo, null, 2); -const src = `/** @file Generated by fetch-starship-chain-info.ts */\nexport default /** @type {const} } */ (${record});`; +const src = `/** @file Generated by fetch-starship-chain-info.ts */\nexport default /** @type {const} */ (${record});`; const prettySrc = await prettier.format(src, { parser: 'babel', // 'typescript' fails to preserve parens for typecast singleQuote: true, diff --git a/multichain-testing/starship-chain-info.js b/multichain-testing/starship-chain-info.js index d194cdb0d23..d756d2f058f 100644 --- a/multichain-testing/starship-chain-info.js +++ b/multichain-testing/starship-chain-info.js @@ -1,5 +1,5 @@ /** @file Generated by fetch-starship-chain-info.ts */ -export default /** @type {const} } */ ({ +export default /** @type {const} */ ({ agoric: { chainId: 'agoriclocal', stakingTokens: [ diff --git a/multichain-testing/test/basic-flows.test.ts b/multichain-testing/test/basic-flows.test.ts index 40a037a5045..6a05d7c8dfa 100644 --- a/multichain-testing/test/basic-flows.test.ts +++ b/multichain-testing/test/basic-flows.test.ts @@ -1,12 +1,15 @@ import anyTest from '@endo/ses-ava/prepare-endo.js'; import type { TestFn } from 'ava'; -import { commonSetup, SetupContextWithWallets } from './support.js'; import { makeDoOffer } from '../tools/e2e-tools.js'; -import { chainConfig, chainNames } from './support.js'; +import { + commonSetup, + SetupContextWithWallets, + chainConfig, +} from './support.js'; const test = anyTest as TestFn; -const accounts = ['user1', 'user2', 'user3']; // one account for each scenario +const accounts = ['agoric', 'cosmoshub', 'osmosis']; // one account for each scenario const contractName = 'basicFlows'; const contractBuilder = @@ -48,12 +51,12 @@ const makeAccountScenario = test.macro({ const vstorageClient = makeQueryTool(); - const wallet = accounts[chainNames.indexOf(chainName)]; - const wdUser1 = await provisionSmartWallet(wallets[wallet], { + const agoricAddr = wallets[chainName]; + const wdUser1 = await provisionSmartWallet(agoricAddr, { BLD: 100n, IST: 100n, }); - t.log(`provisioning agoric smart wallet for ${wallets[wallet]}`); + t.log(`provisioning agoric smart wallet for ${agoricAddr}`); const doOffer = makeDoOffer(wdUser1); t.log(`${chainName} makeAccount offer`); @@ -78,8 +81,7 @@ const makeAccountScenario = test.macro({ // TODO fix above so we don't have to poll for the offer result to be published // https://github.com/Agoric/agoric-sdk/issues/9643 const currentWalletRecord = await retryUntilCondition( - () => - vstorageClient.queryData(`published.wallet.${wallets[wallet]}.current`), + () => vstorageClient.queryData(`published.wallet.${agoricAddr}.current`), ({ offerToPublicSubscriberPaths }) => Object.fromEntries(offerToPublicSubscriberPaths)[offerId], `${offerId} continuing invitation is in vstorage`, @@ -100,7 +102,7 @@ const makeAccountScenario = test.macro({ ); const latestWalletUpdate = await vstorageClient.queryData( - `published.wallet.${wallets[wallet]}`, + `published.wallet.${agoricAddr}`, ); t.log('latest wallet update', latestWalletUpdate); t.like( diff --git a/multichain-testing/test/support.ts b/multichain-testing/test/support.ts index d54f3f226bc..d7caf44e9a4 100644 --- a/multichain-testing/test/support.ts +++ b/multichain-testing/test/support.ts @@ -12,25 +12,18 @@ import { makeDeployBuilder } from '../tools/deploy.js'; const setupRegistry = makeSetupRegistry(makeGetFile({ dirname, join })); -export const chainConfig = { +// XXX consider including bech32Prefix in `ChainInfo` +export const chainConfig: Record = { cosmoshub: { - chainId: 'gaialocal', - denom: 'uatom', expectedAddressPrefix: 'cosmos', }, osmosis: { - chainId: 'osmosislocal', - denom: 'uosmo', expectedAddressPrefix: 'osmo', }, agoric: { - chainId: 'agoriclocal', - denom: 'ubld', expectedAddressPrefix: 'agoric', }, -}; - -export const chainNames = Object.keys(chainConfig); +} as const; const makeKeyring = async ( e2eTools: Pick, From cacd4ea24dfb020de01c68f176e58b6108fda7da Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 21:37:06 -0400 Subject: [PATCH 12/17] refactor: doOffer returns Promise - until #9643, `doOffer` should return a Promise. Then, it can return an offerResult --- multichain-testing/test/basic-flows.test.ts | 8 +------- multichain-testing/test/stake-ica.test.ts | 22 ++++++++------------- multichain-testing/tools/e2e-tools.js | 1 - 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/multichain-testing/test/basic-flows.test.ts b/multichain-testing/test/basic-flows.test.ts index 6a05d7c8dfa..f6649e0100b 100644 --- a/multichain-testing/test/basic-flows.test.ts +++ b/multichain-testing/test/basic-flows.test.ts @@ -62,10 +62,7 @@ const makeAccountScenario = test.macro({ t.log(`${chainName} makeAccount offer`); const offerId = `${chainName}-makeAccount-${Date.now()}`; - // FIXME we get payouts but not an offer result; it times out - // https://github.com/Agoric/agoric-sdk/issues/9643 - // chain logs shows an UNPUBLISHED result - const _offerResult = await doOffer({ + await doOffer({ id: offerId, invitationSpec: { source: 'agoricContract', @@ -75,9 +72,6 @@ const makeAccountScenario = test.macro({ offerArgs: { chainName }, proposal: {}, }); - t.true(_offerResult); - // t.is(await _offerResult, 'UNPUBLISHED', 'representation of continuing offer'); - // TODO fix above so we don't have to poll for the offer result to be published // https://github.com/Agoric/agoric-sdk/issues/9643 const currentWalletRecord = await retryUntilCondition( diff --git a/multichain-testing/test/stake-ica.test.ts b/multichain-testing/test/stake-ica.test.ts index 06e36bbc611..78f520e6045 100644 --- a/multichain-testing/test/stake-ica.test.ts +++ b/multichain-testing/test/stake-ica.test.ts @@ -66,7 +66,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { // FIXME we get payouts but not an offer result; it times out // chain logs shows an UNPUBLISHED result - const _offerResult = await doOffer({ + await doOffer({ id: makeAccountofferId, invitationSpec: { source: 'agoricContract', @@ -75,7 +75,6 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - t.true(_offerResult); // t.is(await _offerResult, 'UNPUBLISHED', 'representation of continuing offer'); // XXX fix above so we don't have to wait for the offer result to be published @@ -113,14 +112,14 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { const { creditFromFaucet, getRestEndpoint } = useChain(scenario.chain); const queryClient = makeQueryClient(getRestEndpoint()); - t.log('Requesting faucet funds'); + t.log(`Requesting faucet funds for ${address}`); // XXX fails intermittently until https://github.com/cosmology-tech/starship/issues/417 await creditFromFaucet(address); const { balances } = await retryUntilCondition( () => queryClient.queryBalances(address), ({ balances }) => !!balances.length, - 'faucet funds available', + `${scenario.chain} faucet funds available`, ); t.log('Updated balances:', balances); t.like( @@ -140,7 +139,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { chainId: scenario.chainId, encoding: 'bech32', }; - const _delegateOfferResult = await doOffer({ + await doOffer({ id: delegateOfferId, invitationSpec: { source: 'continuing', @@ -153,7 +152,6 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - t.true(_delegateOfferResult, 'delegate payouts (none) returned'); const latestWalletUpdate = await vstorageClient.queryData( `published.wallet.${wallets[scenario.wallet]}`, @@ -192,7 +190,8 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { t.log('reward:', total[0]); t.log('WithrawReward offer from continuing inv'); const withdrawRewardOfferId = `reward-${Date.now()}`; - const _withdrawRewardOfferResult = await doOffer({ + // funds are withdrawn to ICA, not the seat + await doOffer({ id: withdrawRewardOfferId, invitationSpec: { source: 'continuing', @@ -202,11 +201,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - // funds are withdrawn to ICA, not the seat - t.true( - _withdrawRewardOfferResult, - 'withdraw rewards (empty) payouts returned', - ); + const { balances: rewards } = await retryUntilCondition( () => queryClient.queryBalances(address), ({ balances }) => @@ -218,7 +213,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { const SHARES = 50; t.log('Undelegate offer from continuing inv'); const undelegateOfferId = `undelegate-${Date.now()}`; - const _undelegateOfferResult = await doOffer({ + await doOffer({ id: undelegateOfferId, invitationSpec: { source: 'continuing', @@ -235,7 +230,6 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - t.true(_undelegateOfferResult, 'undelegate payouts returned'); const { unbonding_responses } = await retryUntilCondition( () => queryClient.queryUnbonding(address), diff --git a/multichain-testing/tools/e2e-tools.js b/multichain-testing/tools/e2e-tools.js index 67ddec73f29..d7d7d2c6620 100644 --- a/multichain-testing/tools/e2e-tools.js +++ b/multichain-testing/tools/e2e-tools.js @@ -579,7 +579,6 @@ export const makeDoOffer = wallet => { // const result = await seat.getOfferResult(); await seatLike(updates).getPayoutAmounts(); // return result; - return true; }; return doOffer; From 8f1618bfe2fc9c25bac255475343cdd77ae3909a Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 21:42:24 -0400 Subject: [PATCH 13/17] refactor: log immediately during retryUntilCondition - ava logs do not appear until the end of a test. this is a scenarios where it's more useful to see the logs as they happen --- multichain-testing/tools/sleep.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/multichain-testing/tools/sleep.ts b/multichain-testing/tools/sleep.ts index 67cb589d36b..6cb375893d6 100644 --- a/multichain-testing/tools/sleep.ts +++ b/multichain-testing/tools/sleep.ts @@ -14,7 +14,7 @@ const retryUntilCondition = async ( retryIntervalMs: number, log: Log, ): Promise => { - console.log({ maxRetries, retryIntervalMs }); + console.log({ maxRetries, retryIntervalMs, message }); let retries = 0; while (retries < maxRetries) { @@ -32,7 +32,9 @@ const retryUntilCondition = async ( } retries++; - log(`Retry ${retries}/${maxRetries} - Waiting for ${retryIntervalMs}ms...`); + console.log( + `Retry ${retries}/${maxRetries} - Waiting for ${retryIntervalMs}ms for ${message}...`, + ); await sleep(retryIntervalMs, log); } From f66c60eda3551245a4ee141fc18ff12166bd397e Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 21:34:10 -0400 Subject: [PATCH 14/17] chore: @cosmjs/stargate endo compatability --- multichain-testing/package.json | 3 +- multichain-testing/patches/axios+1.6.7.patch | 44 +++++++++++++++ .../patches/protobufjs+6.11.4.patch | 56 +++++++++++++++++++ multichain-testing/yarn.lock | 12 ++-- 4 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 multichain-testing/patches/axios+1.6.7.patch create mode 100644 multichain-testing/patches/protobufjs+6.11.4.patch diff --git a/multichain-testing/package.json b/multichain-testing/package.json index 94be045eb20..7088cc34fb2 100644 --- a/multichain-testing/package.json +++ b/multichain-testing/package.json @@ -42,7 +42,8 @@ "typescript": "^5.3.3" }, "resolutions": { - "node-fetch": "2.6.12" + "node-fetch": "2.6.12", + "axios": "1.6.7" }, "ava": { "extensions": { diff --git a/multichain-testing/patches/axios+1.6.7.patch b/multichain-testing/patches/axios+1.6.7.patch new file mode 100644 index 00000000000..3373cf41c42 --- /dev/null +++ b/multichain-testing/patches/axios+1.6.7.patch @@ -0,0 +1,44 @@ +diff --git a/node_modules/axios/dist/node/axios.cjs b/node_modules/axios/dist/node/axios.cjs +index 9099d87..7104f6e 100644 +--- a/node_modules/axios/dist/node/axios.cjs ++++ b/node_modules/axios/dist/node/axios.cjs +@@ -370,9 +370,9 @@ function merge(/* obj1, obj2, obj3, ... */) { + const extend = (a, b, thisArg, {allOwnKeys}= {}) => { + forEach(b, (val, key) => { + if (thisArg && isFunction(val)) { +- a[key] = bind(val, thisArg); ++ Object.defineProperty(a, key, {value: bind(val, thisArg)}); + } else { +- a[key] = val; ++ Object.defineProperty(a, key, {value: val}); + } + }, {allOwnKeys}); + return a; +@@ -403,7 +403,9 @@ const stripBOM = (content) => { + */ + const inherits = (constructor, superConstructor, props, descriptors) => { + constructor.prototype = Object.create(superConstructor.prototype, descriptors); +- constructor.prototype.constructor = constructor; ++ Object.defineProperty(constructor, 'constructor', { ++ value: constructor ++ }); + Object.defineProperty(constructor, 'super', { + value: superConstructor.prototype + }); +@@ -565,12 +567,14 @@ const isRegExp = kindOfTest('RegExp'); + + const reduceDescriptors = (obj, reducer) => { + const descriptors = Object.getOwnPropertyDescriptors(obj); +- const reducedDescriptors = {}; ++ let reducedDescriptors = {}; + + forEach(descriptors, (descriptor, name) => { + let ret; + if ((ret = reducer(descriptor, name, obj)) !== false) { +- reducedDescriptors[name] = ret || descriptor; ++ reducedDescriptors = {...reducedDescriptors, ++ [name]: ret || descriptor ++ }; + } + }); + diff --git a/multichain-testing/patches/protobufjs+6.11.4.patch b/multichain-testing/patches/protobufjs+6.11.4.patch new file mode 100644 index 00000000000..9c6d1ff5093 --- /dev/null +++ b/multichain-testing/patches/protobufjs+6.11.4.patch @@ -0,0 +1,56 @@ +diff --git a/node_modules/protobufjs/src/util/minimal.js b/node_modules/protobufjs/src/util/minimal.js +index 7f62daa..8d60657 100644 +--- a/node_modules/protobufjs/src/util/minimal.js ++++ b/node_modules/protobufjs/src/util/minimal.js +@@ -259,14 +259,9 @@ util.newError = newError; + * @returns {Constructor} Custom error constructor + */ + function newError(name) { +- + function CustomError(message, properties) { +- + if (!(this instanceof CustomError)) + return new CustomError(message, properties); +- +- // Error.call(this, message); +- // ^ just returns a new error instance because the ctor can be called as a function + + Object.defineProperty(this, "message", { get: function() { return message; } }); + +@@ -280,13 +275,31 @@ function newError(name) { + merge(this, properties); + } + +- (CustomError.prototype = Object.create(Error.prototype)).constructor = CustomError; ++ // Create a new object with Error.prototype as its prototype ++ const proto = Object.create(Error.prototype); + +- Object.defineProperty(CustomError.prototype, "name", { get: function() { return name; } }); ++ // Define properties on the prototype ++ Object.defineProperties(proto, { ++ constructor: { ++ value: CustomError, ++ writable: true, ++ configurable: true ++ }, ++ name: { ++ get: function() { return name; }, ++ configurable: true ++ }, ++ toString: { ++ value: function toString() { ++ return this.name + ": " + this.message; ++ }, ++ writable: true, ++ configurable: true ++ } ++ }); + +- CustomError.prototype.toString = function toString() { +- return this.name + ": " + this.message; +- }; ++ // Set the prototype of CustomError ++ CustomError.prototype = proto; + + return CustomError; + } diff --git a/multichain-testing/yarn.lock b/multichain-testing/yarn.lock index e89595030d6..7c87c33808a 100644 --- a/multichain-testing/yarn.lock +++ b/multichain-testing/yarn.lock @@ -1264,14 +1264,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.6.0": - version: 1.7.2 - resolution: "axios@npm:1.7.2" +"axios@npm:1.6.7": + version: 1.6.7 + resolution: "axios@npm:1.6.7" dependencies: - follow-redirects: "npm:^1.15.6" + follow-redirects: "npm:^1.15.4" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/cbd47ce380fe045313364e740bb03b936420b8b5558c7ea36a4563db1258c658f05e40feb5ddd41f6633fdd96d37ac2a76f884dad599c5b0224b4c451b3fa7ae + checksum: 10c0/131bf8e62eee48ca4bd84e6101f211961bf6a21a33b95e5dfb3983d5a2fe50d9fffde0b57668d7ce6f65063d3dc10f2212cbcb554f75cfca99da1c73b210358d languageName: node linkType: hard @@ -2220,7 +2220,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.6": +"follow-redirects@npm:^1.15.4": version: 1.15.6 resolution: "follow-redirects@npm:1.15.6" peerDependenciesMeta: From 8f0a06777433c6ce3f4e646dfa1ec60a14b2d294 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 21:39:51 -0400 Subject: [PATCH 15/17] tools: makeIBCTransferMsg - helper for building an ibc transfer message with @cosmjs/stargate signing client - hardcodes timeoutTimestamp until #9200, as we experienced weird flakes in CI --- multichain-testing/test/tools/ibc-transfer.ts | 50 +++++++ multichain-testing/tools/ibc-transfer.ts | 133 ++++++++++++++++++ packages/orchestration/src/utils/time.js | 1 + 3 files changed, 184 insertions(+) create mode 100644 multichain-testing/test/tools/ibc-transfer.ts create mode 100644 multichain-testing/tools/ibc-transfer.ts diff --git a/multichain-testing/test/tools/ibc-transfer.ts b/multichain-testing/test/tools/ibc-transfer.ts new file mode 100644 index 00000000000..bd11ef43914 --- /dev/null +++ b/multichain-testing/test/tools/ibc-transfer.ts @@ -0,0 +1,50 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { DEFAULT_TIMEOUT_NS, getTimeout } from '../../tools/ibc-transfer.js'; +import { + NANOSECONDS_PER_MILLISECOND, + SECONDS_PER_MINUTE, + MILLISECONDS_PER_SECOND, +} from '@agoric/orchestration/src/utils/time.js'; + +const test = anyTest as TestFn>; + +const minutesInFuture = (now: bigint, minutes = 5n) => + now + minutes * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND; + +test.skip('getTimeout returns nanoseconds 5 minutes in the future', async t => { + const now = Date.now(); + const fiveMinutesInFuture = minutesInFuture(BigInt(now)); + + const timeout = getTimeout(now); + const timeoutInMS = timeout / NANOSECONDS_PER_MILLISECOND; + t.is(fiveMinutesInFuture, timeoutInMS); +}); + +test.skip('getTimeout accepts minutes in future for 2nd arg', async t => { + const now = Date.now(); + const twoMinutesInFuture = minutesInFuture(BigInt(now), 2n); + + const timeout = getTimeout(now, 2n); + const timeoutInMS = timeout / NANOSECONDS_PER_MILLISECOND; + t.is(twoMinutesInFuture, timeoutInMS); +}); + +test('hardcoded placeholder is in the future', async t => { + // Mon Dec 31 2029 19:00:00 GMT-0500 + const futureMs = 1893456000000; + + t.true( + new Date(futureMs).getTime() > Date.now(), + 'futureMs is in the future', + ); + + const scaledDown = DEFAULT_TIMEOUT_NS / NANOSECONDS_PER_MILLISECOND; + t.is(BigInt(futureMs), scaledDown, 'DEFAULT_TIMEOUT_NS is properly scaled'); + + t.is( + getTimeout(), + DEFAULT_TIMEOUT_NS, + 'getTimeout returns DEFAULT_TIMEOUT_NS', + ); +}); diff --git a/multichain-testing/tools/ibc-transfer.ts b/multichain-testing/tools/ibc-transfer.ts new file mode 100644 index 00000000000..b4384f13313 --- /dev/null +++ b/multichain-testing/tools/ibc-transfer.ts @@ -0,0 +1,133 @@ +import { ExecutionContext } from 'ava'; +import type { StdFee } from '@cosmjs/amino'; +import { coins } from '@cosmjs/proto-signing'; +import { SigningStargateClient } from '@cosmjs/stargate'; +import { useChain } from 'starshipjs'; +import type { + CosmosChainInfo, + DenomAmount, + IBCMsgTransferOptions, +} from '@agoric/orchestration'; +import { + MILLISECONDS_PER_SECOND, + NANOSECONDS_PER_MILLISECOND, + SECONDS_PER_MINUTE, +} from '@agoric/orchestration/src/utils/time.js'; +import { MsgTransfer } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; +import { createWallet } from './wallet.js'; +import chainInfo from '../starship-chain-info.js'; + +interface MakeFeeObjectArgs { + denom?: string; + gas: number; + gasPrice: number; +} + +export const makeFeeObject = ({ + denom, + gas, + gasPrice, +}: MakeFeeObjectArgs): StdFee => ({ + amount: coins(gas * gasPrice, denom || 'uist'), + gas: String(gas), +}); + +type SimpleChainAddress = { + address: string; + chainName: string; +}; + +export const DEFAULT_TIMEOUT_NS = 1893456000000000000n; + +/** + * @param {number} [ms] current time in ms (e.g. Date.now()) + * @param {bigint} [minutes=5n] number of minutes in the future + * @returns {bigint} nanosecond timestamp 5 mins in the future */ +export const getTimeout = (ms: number = 0, minutes = 5n) => { + // UNTIL #9200. timestamps are getting clobbered somewhere along the way + // and we are observing failed transfers with timeouts years in the past. + // see https://github.com/Agoric/agoric-sdk/actions/runs/9967903776/job/27542288963#step:12:336 + return DEFAULT_TIMEOUT_NS; + const timeoutMS = + BigInt(ms) + MILLISECONDS_PER_SECOND * SECONDS_PER_MINUTE * minutes; + const timeoutNS = timeoutMS * NANOSECONDS_PER_MILLISECOND; + return timeoutNS; +}; + +export const makeIBCTransferMsg = ( + amount: DenomAmount, + destination: SimpleChainAddress, + sender: SimpleChainAddress, + currentTime: number, + opts: IBCMsgTransferOptions = {}, +) => { + const { timeoutHeight, timeoutTimestamp, memo = '' } = opts; + + const destChainInfo = (chainInfo as Record)[ + destination.chainName + ]; + if (!destChainInfo) throw Error(`No chain info for ${destination.chainName}`); + const senderChainInfo = useChain(sender.chainName).chainInfo; + const connection = + destChainInfo.connections?.[senderChainInfo.chain.chain_id]; + if (!connection) + throw Error( + `No connection found between ${sender.chainName} and ${destination.chainName}`, + ); + const { counterPartyPortId, counterPartyChannelId } = + connection.transferChannel; + + const msgTransfer = MsgTransfer.fromPartial({ + sender: sender.address, + receiver: destination.address, + token: { denom: amount.denom, amount: String(amount.value) }, + sourcePort: counterPartyPortId, + sourceChannel: counterPartyChannelId, + timeoutHeight, + timeoutTimestamp: timeoutHeight + ? undefined + : timeoutTimestamp ?? getTimeout(currentTime), + memo, + }); + const { fee_tokens } = senderChainInfo.chain.fees ?? {}; + if (!fee_tokens || !fee_tokens.length) { + throw Error('no fee tokens in chain config for' + sender.chainName); + } + const { high_gas_price, denom } = fee_tokens[0]; + if (!high_gas_price) throw Error('no high gas price in chain config'); + const fee = makeFeeObject({ + denom: denom, + gas: 150000, + gasPrice: high_gas_price, + }); + + return [ + msgTransfer.sender, + msgTransfer.receiver, + msgTransfer.token, + msgTransfer.sourcePort, + msgTransfer.sourceChannel, + msgTransfer.timeoutHeight, + Number(msgTransfer.timeoutTimestamp), + fee, + msgTransfer.memo, + ]; +}; + +export const createFundedWalletAndClient = async ( + t: ExecutionContext, + chainName: string, +) => { + const { chain, creditFromFaucet, getRpcEndpoint } = useChain(chainName); + const wallet = await createWallet(chain.bech32_prefix); + const address = (await wallet.getAccounts())[0].address; + t.log(`Requesting faucet funds for ${address}`); + await creditFromFaucet(address); + // TODO use telescope generated rpc client from @agoric/cosmic-proto + // https://github.com/Agoric/agoric-sdk/issues/9200 + const client = await SigningStargateClient.connectWithSigner( + getRpcEndpoint(), + wallet, + ); + return { client, wallet, address }; +}; diff --git a/packages/orchestration/src/utils/time.js b/packages/orchestration/src/utils/time.js index 2ddfcbc9cb8..a45848ad1ec 100644 --- a/packages/orchestration/src/utils/time.js +++ b/packages/orchestration/src/utils/time.js @@ -8,6 +8,7 @@ import { TimeMath } from '@agoric/time'; export const SECONDS_PER_MINUTE = 60n; export const MILLISECONDS_PER_SECOND = 1000n; +export const NANOSECONDS_PER_MILLISECOND = 1_000_000n; export const NANOSECONDS_PER_SECOND = 1_000_000_000n; /** From fd3d14b664ef0c8e64c2630e288eca15c39bf423 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 15 Jul 2024 21:42:39 -0400 Subject: [PATCH 16/17] test: multichain test of auto-stake-it --- multichain-testing/test/auto-stake-it.test.ts | 239 ++++++++++++++++++ multichain-testing/tools/query.ts | 6 + 2 files changed, 245 insertions(+) create mode 100644 multichain-testing/test/auto-stake-it.test.ts diff --git a/multichain-testing/test/auto-stake-it.test.ts b/multichain-testing/test/auto-stake-it.test.ts new file mode 100644 index 00000000000..89dcead678d --- /dev/null +++ b/multichain-testing/test/auto-stake-it.test.ts @@ -0,0 +1,239 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { ExecutionContext, TestFn } from 'ava'; +import { useChain } from 'starshipjs'; +import type { CosmosChainInfo, IBCConnectionInfo } from '@agoric/orchestration'; +import type { SetupContextWithWallets } from './support.js'; +import { chainConfig, commonSetup } from './support.js'; +import { makeQueryClient } from '../tools/query.js'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import chainInfo from '../starship-chain-info.js'; +import { + createFundedWalletAndClient, + makeIBCTransferMsg, +} from '../tools/ibc-transfer.js'; + +const test = anyTest as TestFn; + +const accounts = ['agoricAdmin', 'cosmoshub', 'osmosis']; + +const contractName = 'autoAutoStakeIt'; +const contractBuilder = + '../packages/builders/scripts/testing/start-auto-stake-it.js'; + +test.before(async t => { + const { deleteTestKeys, setupTestKeys, ...rest } = await commonSetup(t); + deleteTestKeys(accounts).catch(); + const wallets = await setupTestKeys(accounts); + t.context = { ...rest, wallets, deleteTestKeys }; + + t.log('bundle and install contract', contractName); + await t.context.deployBuilder(contractBuilder); + const vstorageClient = t.context.makeQueryTool(); + await t.context.retryUntilCondition( + () => vstorageClient.queryData(`published.agoricNames.instance`), + res => contractName in Object.fromEntries(res), + `${contractName} instance is available`, + ); +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +const makeFundAndTransfer = (t: ExecutionContext) => { + const { retryUntilCondition } = t.context; + return async (chainName: string, agoricAddr: string, amount = 100n) => { + const { staking } = useChain(chainName).chainInfo.chain; + const denom = staking?.staking_tokens?.[0].denom; + if (!denom) throw Error(`no denom for ${chainName}`); + + const { client, address, wallet } = await createFundedWalletAndClient( + t, + chainName, + ); + const balancesResult = await retryUntilCondition( + () => client.getAllBalances(address), + coins => !!coins?.length, + `Faucet balances found for ${address}`, + ); + + console.log('Balances:', balancesResult); + + const transferArgs = makeIBCTransferMsg( + { denom, value: amount }, + { address: agoricAddr, chainName: 'agoric' }, + { address: address, chainName }, + Date.now(), + ); + console.log('Transfer Args:', transferArgs); + // TODO #9200 `sendIbcTokens` does not support `memo` + // @ts-expect-error spread argument for concise code + const txRes = await client.sendIbcTokens(...transferArgs); + if (txRes && txRes.code !== 0) { + console.error(txRes); + throw Error(`failed to ibc transfer funds to ${chainName}`); + } + const { events: _events, ...txRest } = txRes; + console.log(txRest); + t.is(txRes.code, 0, `Transaction succeeded`); + t.log(`Funds transferred to ${agoricAddr}`); + return { + client, + address, + wallet, + }; + }; +}; + +const autoStakeItScenario = test.macro({ + title: (_, chainName: string) => `auto-stake-it on ${chainName}`, + exec: async (t, chainName: string) => { + const { + wallets, + makeQueryTool, + provisionSmartWallet, + retryUntilCondition, + } = t.context; + + const fundAndTransfer = makeFundAndTransfer(t); + + // 1. Send initial tokens so denom is available (debatably necessary, but + // allows us to trace the denom until we have ibc denoms in chainInfo) + const agAdminAddr = wallets['agoricAdmin']; + console.log('Sending tokens to', agAdminAddr, `from ${chainName}`); + await fundAndTransfer(chainName, agAdminAddr); + + // 2. Find 'stakingDenom' denom on agoric + const agoricConns = chainInfo['agoric'].connections as Record< + string, + IBCConnectionInfo + >; + const remoteChainInfo = (chainInfo as Record)[ + chainName + ]; + // const remoteChainId = remoteChainInfo.chain.chain_id; + // const agoricToRemoteConn = agoricConns[remoteChainId]; + const { portId, channelId } = + agoricConns[remoteChainInfo.chainId].transferChannel; + const agoricQueryClient = makeQueryClient( + useChain('agoric').getRestEndpoint(), + ); + const stakingDenom = remoteChainInfo?.stakingTokens?.[0].denom; + if (!stakingDenom) throw Error(`staking denom found for ${chainName}`); + const { hash } = await retryUntilCondition( + () => + agoricQueryClient.queryDenom(`/${portId}/${channelId}`, stakingDenom), + denomTrace => !!denomTrace.hash, + `local denom hash for ${stakingDenom} found`, + ); + t.log(`found ibc denom hash for ${stakingDenom}:`, hash); + + // 3. Find a remoteChain validator to delegate to + const remoteQueryClient = makeQueryClient( + useChain(chainName).getRestEndpoint(), + ); + const { validators } = await remoteQueryClient.queryValidators(); + const validatorAddress = validators[0]?.operator_address; + t.truthy( + validatorAddress, + `found a validator on ${chainName} to delegate to`, + ); + t.log( + { validatorAddress }, + `found a validator on ${chainName} to delegate to`, + ); + + // 4. Send an Offer to make the accounts and set up the transfer tap + const agoricUserAddr = wallets[chainName]; + const wdUser = await provisionSmartWallet(agoricUserAddr, { + BLD: 100n, + IST: 100n, + }); + const doOffer = makeDoOffer(wdUser); + t.log(`${chainName} makeAccount offer`); + const offerId = `${chainName}-makeAccountsInvitation-${Date.now()}`; + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeAccountsInvitation']], + }, + offerArgs: { + chainName, + validator: { + value: validatorAddress, + encoding: 'bech32', + chainId: remoteChainInfo.chainId, + }, + localDenom: `ibc/${hash}`, + }, + proposal: {}, + }); + + // FIXME https://github.com/Agoric/agoric-sdk/issues/9643 + const vstorageClient = makeQueryTool(); + const currentWalletRecord = await retryUntilCondition( + () => + vstorageClient.queryData(`published.wallet.${agoricUserAddr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[offerId], + `${offerId} continuing invitation is in vstorage`, + ); + + const offerToPublicSubscriberMap = Object.fromEntries( + currentWalletRecord.offerToPublicSubscriberPaths, + ); + + // 5. look up LOA address in vstorage + console.log('offerToPublicSubscriberMap', offerToPublicSubscriberMap); + const lcaAddress = offerToPublicSubscriberMap[offerId]?.agoric + .split('.') + .pop(); + const icaAddress = offerToPublicSubscriberMap[offerId]?.[chainName] + .split('.') + .pop(); + console.log({ lcaAddress, icaAddress }); + t.regex(lcaAddress, /^agoric1/, 'LOA address is valid'); + t.regex( + icaAddress, + new RegExp(`^${chainConfig[chainName].expectedAddressPrefix}1`), + 'COA address is valid', + ); + + // 6. transfer in some tokens over IBC + const transferAmount = 99n; + await fundAndTransfer(chainName, lcaAddress, transferAmount); + + // 7. verify the COA has active delegations + if (chainName === 'cosmoshub') { + // FIXME: delegations are not visible on cosmoshub + return t.pass('skipping verifying delegations on cosmoshub'); + } + const { delegation_responses } = await retryUntilCondition( + () => remoteQueryClient.queryDelegations(icaAddress), + ({ delegation_responses }) => !!delegation_responses.length, + `delegations visible on ${chainName}`, + ); + t.log('delegation balance', delegation_responses[0]?.balance); + t.like( + delegation_responses[0].balance, + { denom: stakingDenom, amount: String(transferAmount) }, + 'delegations balance', + ); + t.log( + `Orchestration Account Delegations on ${chainName}`, + delegation_responses, + ); + + // XXX consider using PortfolioHolder continuing inv to undelegate + + // XXX how to test other tokens do not result in an attempted MsgTransfer or MsgDelegate? + // query tx history of the LOA via an rpc node? + }, +}); + +test.serial(autoStakeItScenario, 'osmosis'); +test.serial(autoStakeItScenario, 'cosmoshub'); diff --git a/multichain-testing/tools/query.ts b/multichain-testing/tools/query.ts index 722960110ff..5f1d6d18f2e 100644 --- a/multichain-testing/tools/query.ts +++ b/multichain-testing/tools/query.ts @@ -6,8 +6,10 @@ import type { QueryDelegationTotalRewardsResponseSDKType } from '@agoric/cosmic- import type { QueryValidatorsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; import type { QueryDelegatorDelegationsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; import type { QueryDelegatorUnbondingDelegationsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; +import type { QueryDenomHashResponseSDKType } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/query.js'; // TODO use telescope generated query client from @agoric/cosmic-proto +// https://github.com/Agoric/agoric-sdk/issues/9200 export function makeQueryClient(apiUrl: string) { const query = async (path: string): Promise => { try { @@ -46,5 +48,9 @@ export function makeQueryClient(apiUrl: string) { query( `/cosmos/distribution/v1beta1/delegators/${delegatorAdddr}/rewards`, ), + queryDenom: (path: string, baseDenom: string) => + query( + `/ibc/apps/transfer/v1/denom_hashes/${path}/${baseDenom}`, + ), }; } From 350ff5ea2ab90d622331971c056cd595e552e5d7 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 16 Jul 2024 17:57:35 -0400 Subject: [PATCH 17/17] test: bootstrap test for portfolio holder - adds portofolio holder to basic-flows.contract.js and tests wallet offers in bootstrap environment --- .../test/bootstrapTests/orchestration.test.ts | 136 +++++++++++++++++- .../src/examples/basic-flows.contract.js | 86 +++++++++-- 2 files changed, 205 insertions(+), 17 deletions(-) diff --git a/packages/boot/test/bootstrapTests/orchestration.test.ts b/packages/boot/test/bootstrapTests/orchestration.test.ts index 2817b8a8c77..c73b3073b87 100644 --- a/packages/boot/test/bootstrapTests/orchestration.test.ts +++ b/packages/boot/test/bootstrapTests/orchestration.test.ts @@ -13,6 +13,12 @@ import { const test: TestFn = anyTest; +const validatorAddress: CosmosValidatorAddress = { + value: 'cosmosvaloper1test', + chainId: 'gaiatest', + encoding: 'bech32', +}; + test.before(async t => { t.context = await makeWalletFactoryContext( t, @@ -163,11 +169,6 @@ test.serial('stakeAtom - smart wallet', async t => { const { ATOM } = agoricNamesRemotes.brand; ATOM || Fail`ATOM missing from agoricNames`; - const validatorAddress: CosmosValidatorAddress = { - value: 'cosmosvaloper1test', - chainId: 'gaiatest', - encoding: 'bech32', - }; await t.notThrowsAsync( wd.executeOffer({ @@ -320,3 +321,128 @@ test.serial('auto-stake-it - proposal', async t => { ), ); }); + +test.serial('basic-flows - portfolio holder', async t => { + const { buildProposal, evalProposal, readLatest, agoricNamesRemotes } = + t.context; + + await evalProposal( + buildProposal('@agoric/builders/scripts/orchestration/init-basic-flows.js'), + ); + + const wd = + await t.context.walletFactoryDriver.provideSmartWallet('agoric1test2'); + + // create a cosmos orchestration account + await wd.executeOffer({ + id: 'request-portfolio-acct', + invitationSpec: { + source: 'agoricContract', + instancePath: ['basicFlows'], + callPipe: [['makePortfolioAccountInvitation']], + }, + offerArgs: { + chainNames: ['agoric', 'cosmoshub', 'osmosis'], + }, + proposal: {}, + }); + t.like(wd.getCurrentWalletRecord(), { + offerToPublicSubscriberPaths: [ + [ + 'request-portfolio-acct', + { + agoric: 'published.basicFlows.agoric1mockVlocalchainAddress', + cosmoshub: 'published.basicFlows.cosmos1test', + // XXX support multiple chain addresses in ibc mocks + osmosis: 'published.basicFlows.cosmos1test', + }, + ], + ], + }); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'request-portfolio-acct', numWantsSatisfied: 1 }, + }); + // XXX this overrides a previous account, since mocks only provide one address + t.is(readLatest('published.basicFlows.cosmos1test'), ''); + // XXX this overrides a previous account, since mocks only provide one address + t.is(readLatest('published.basicFlows.agoric1mockVlocalchainAddress'), ''); + + const { ATOM, BLD } = agoricNamesRemotes.brand; + ATOM || Fail`ATOM missing from agoricNames`; + BLD || Fail`BLD missing from agoricNames`; + + await t.notThrowsAsync( + wd.executeOffer({ + id: 'delegate-cosmoshub', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'cosmoshub', + 'Delegate', + [validatorAddress, { brand: ATOM, value: 10n }], + ], + }, + proposal: {}, + }), + ); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'delegate-cosmoshub', numWantsSatisfied: 1 }, + }); + + await t.notThrowsAsync( + wd.executeOffer({ + id: 'delegate-agoric', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'agoric', + 'Delegate', + // XXX use ChainAddress for LocalOrchAccount + ['agoric1validator1', { brand: BLD, value: 10n }], + ], + }, + proposal: {}, + }), + ); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'delegate-agoric', numWantsSatisfied: 1 }, + }); + + await t.throwsAsync( + wd.executeOffer({ + id: 'delegate-2-cosmoshub', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'cosmoshub', + 'Delegate', + [validatorAddress, { brand: ATOM, value: 504n }], + ], + }, + proposal: {}, + }), + ); + + await t.throwsAsync( + wd.executeOffer({ + id: 'delegate-2-agoric', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'agoric', + 'Delegate', + ['agoric1validator1', { brand: BLD, value: 504n }], + ], + }, + proposal: {}, + }), + ); +}); diff --git a/packages/orchestration/src/examples/basic-flows.contract.js b/packages/orchestration/src/examples/basic-flows.contract.js index d3b8a60d71e..78928843cba 100644 --- a/packages/orchestration/src/examples/basic-flows.contract.js +++ b/packages/orchestration/src/examples/basic-flows.contract.js @@ -4,13 +4,16 @@ */ import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; import { M, mustMatch } from '@endo/patterns'; -import { provideOrchestration } from '../utils/start-helper.js'; +import { withOrchestration } from '../utils/start-helper.js'; +import { preparePortfolioHolder } from '../exos/portfolio-holder-kit.js'; /** - * @import {Baggage} from '@agoric/vat-data'; - * @import {Orchestrator} from '@agoric/orchestration'; - * @import {Vow, VowTools} from '@agoric/vow'; + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationAccount, Orchestrator} from '@agoric/orchestration'; + * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; * @import {OrchestrationPowers} from '../utils/start-helper.js'; + * @import {MakePortfolioHolder} from '../exos/portfolio-holder-kit.js'; + * @import {OrchestrationTools} from '../utils/start-helper.js'; */ /** @@ -30,19 +33,63 @@ const makeOrchAccountHandler = async (orch, _ctx, seat, { chainName }) => { return cosmosAccount.asContinuingOffer(); }; +/** + * Create accounts on multiple chains and return them in a single continuing + * offer with invitations makers for Delegate, WithdrawRewards, Transfer, etc. + * Calls to the underlying invitationMakers are proxied through the + * `MakeInvitation` invitation maker. + * + * @param {Orchestrator} orch + * @param {MakePortfolioHolder} makePortfolioHolder + * @param {ZCFSeat} seat + * @param {{ chainNames: string[] }} offerArgs + */ +const makePortfolioAcctHandler = async ( + orch, + makePortfolioHolder, + seat, + { chainNames }, +) => { + seat.exit(); // no funds exchanged + mustMatch(chainNames, M.arrayOf(M.string())); + const allChains = await Promise.all(chainNames.map(n => orch.getChain(n))); + const allAccounts = await Promise.all(allChains.map(c => c.makeAccount())); + + const accountEntries = harden( + /** @type {[string, OrchestrationAccount][]} */ ( + chainNames.map((chainName, index) => [chainName, allAccounts[index]]) + ), + ); + const publicTopicEntries = harden( + /** @type {[string, ResolvedPublicTopic][]} */ ( + await Promise.all( + accountEntries.map(async ([name, account]) => { + const { account: topicRecord } = await account.getPublicTopics(); + return [name, topicRecord]; + }), + ) + ), + ); + const portfolioHolder = makePortfolioHolder( + accountEntries, + publicTopicEntries, + ); + + return portfolioHolder.asContinuingOffer(); +}; + /** * @param {ZCF} zcf * @param {OrchestrationPowers & { * marshaller: Marshaller; - * }} privateArgs - * @param {Baggage} baggage + * }} _privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools */ -export const start = async (zcf, privateArgs, baggage) => { - const { orchestrate, zone } = provideOrchestration( - zcf, - baggage, - privateArgs, - privateArgs.marshaller, +const contract = async (zcf, _privateArgs, zone, { orchestrate, vowTools }) => { + const makePortfolioHolder = preparePortfolioHolder( + zone.subZone('portfolio'), + vowTools, ); const makeOrchAccount = orchestrate( @@ -51,10 +98,17 @@ export const start = async (zcf, privateArgs, baggage) => { makeOrchAccountHandler, ); + const makePortfolioAccount = orchestrate( + 'makePortfolioAccount', + makePortfolioHolder, + makePortfolioAcctHandler, + ); + const publicFacet = zone.exo( 'Basic Flows Public Facet', M.interface('Basic Flows PF', { makeOrchAccountInvitation: M.callWhen().returns(InvitationShape), + makePortfolioAccountInvitation: M.callWhen().returns(InvitationShape), }), { makeOrchAccountInvitation() { @@ -63,10 +117,18 @@ export const start = async (zcf, privateArgs, baggage) => { 'Make an Orchestration Account', ); }, + makePortfolioAccountInvitation() { + return zcf.makeInvitation( + makePortfolioAccount, + 'Make an Orchestration Account', + ); + }, }, ); return { publicFacet }; }; +export const start = withOrchestration(contract); + /** @typedef {typeof start} BasicFlowsSF */