From 3150e78713f50a1794693e4388ebe81db3eff963 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 11 Jul 2024 10:35:41 -0400 Subject: [PATCH] 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 | 176 ++++++++++++++++++ .../test/exos/make-test-coa-kit.ts | 70 +++++++ .../test/exos/portfolio-holder-kit.test.ts | 119 ++++++++++++ .../snapshots/portfolio-holder-kit.test.ts.md | 27 +++ .../portfolio-holder-kit.test.ts.snap | Bin 0 -> 482 bytes 5 files changed, 392 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 000000000000..86783ba73979 --- /dev/null +++ b/packages/orchestration/src/exos/portfolio-holder-kit.js @@ -0,0 +1,176 @@ +import { M, mustMatch } from '@endo/patterns'; +import { E } from '@endo/far'; +import { Fail } from '@endo/errors'; +import { + TopicsRecordShape, + PublicTopicShape, +} from '@agoric/zoe/src/contractSupport/topics.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { Shape as NetworkShape } from '@agoric/network'; +import { VowShape } from '@agoric/vow'; + +const { fromEntries } = Object; +const { Vow$ } = NetworkShape; + +/** + * @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', { + Action: M.call(ChainNameShape, M.string(), M.arrayOf(M.any())).returns( + M.promise(), + ), + }), + holder: M.interface('Holder', { + asContinuingOffer: M.call().returns( + Vow$({ + publicSubscribers: TopicsRecordShape, + invitationMakers: M.any(), + }), + ), + getPublicTopics: M.call().returns(Vow$(TopicsRecordShape)), + getAccount: M.call(ChainNameShape).returns(Vow$(M.remotable())), + 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>} + */ + Action(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, + }); + }); + }, + /** @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)); + }, + }, + }, + ); +}; + +/** + * @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 000000000000..99da8b9880d2 --- /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 000000000000..19d09d1f6b58 --- /dev/null +++ b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts @@ -0,0 +1,119 @@ +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).Action( + 'cosmoshub', + 'Delegate', + [ + { + value: 'cosmos1valoper', + chainId: 'cosmoshub-99', + encoding: 'bech32', + }, + { + denom: 'uatom', + value: 10n, + }, + ], + ); + + // 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' + t.is(delegateInv, 'Delegate', 'any invitation maker accessible via Action'); + + 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 000000000000..3c2ec14d147b --- /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