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..319832e7988f --- /dev/null +++ b/packages/orchestration/src/exos/portfolio-holder-kit.js @@ -0,0 +1,200 @@ +import { M, mustMatch } from '@endo/patterns'; +import { E } from '@endo/far'; +import { VowShape } from '@agoric/vow'; +import { Fail } from '@endo/errors'; +import { + TopicsRecordShape, + PublicTopicShape, +} from '@agoric/zoe/src/contractSupport/topics.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; + +const { fromEntries } = Object; + +/** + * @import {MapStore} from '@agoric/store'; + * @import {VowTools} from '@agoric/vow'; + * @import {ResolvedPublicTopic, ResolvedTopicsRecord} from '@agoric/zoe/src/contractSupport/topics.js'; + * @import {Zone} from '@agoric/zone'; + * @import {CopyMap} from '@endo/patterns'; + * @import {OrchestrationAccount} from '@agoric/orchestration'; + * @import {ResolvedContinuingOfferResult} from '../utils/zoe-tools.js'; + */ + +/** + * @typedef {{ + * accounts: MapStore>; + * publicTopics: MapStore>; + * }} PortfolioHolderState + */ + +const ChainNameM = M.string(); + +const AccountEntriesShape = M.or( + M.arrayOf([M.string(), M.remotable('OrchestrationAccount')]), + M.mapOf(M.string(), M.remotable('OrchestrationAccount')), +); +const PublicTopicsShape = M.or( + M.arrayOf([M.string(), PublicTopicShape]), + M.mapOf(M.string(), PublicTopicShape), +); + +/** + * A kit that holds several OrchestrationAccountKits and returns a invitation + * makers. + * + * @param {Zone} zone + * @param {VowTools} vowTools + */ +const preparePortfolioHolderKit = (zone, { watch }) => { + return zone.exoClassKit( + 'PortfolioHolderKit', + { + invitationMakers: M.interface('InvitationMakers', { + Action: M.call(ChainNameM, M.string(), M.arrayOf(M.any())).returns( + VowShape, + ), + }), + holder: M.interface('Holder', { + asContinuingOffer: M.call().returns({ + publicSubscribers: TopicsRecordShape, + invitationMakers: M.any(), + }), + getPublicTopics: M.call().returns(TopicsRecordShape), + getAccount: M.call(ChainNameM).returns(M.remotable()), + addAccount: M.call( + ChainNameM, + M.remotable(), + PublicTopicShape, + ).returns(), + }), + getInvitationMakersWatcher: M.interface('GetInvitationMakersWatcher', { + onFulfilled: M.call( + M.splitRecord({ + invitationMakers: M.any(), + publicSubscribers: M.any(), + }), + ) + .optional({ + action: M.string(), + invitationArgs: M.arrayOf(M.any()), + }) + .returns(M.promise()), + }), + }, + /** + * @param {Iterable<[string, OrchestrationAccount]> + * | CopyMap} accountEntires + * @param {Iterable<[string, ResolvedPublicTopic]> + * | CopyMap} publicTopicEntires + */ + (accountEntires, publicTopicEntires) => { + mustMatch(accountEntires, AccountEntriesShape, 'must provide accounts'); + mustMatch( + publicTopicEntires, + PublicTopicsShape, + 'must provide public topics', + ); + const accounts = harden( + makeScalarBigMapStore('accounts', { durable: true }), + ); + const publicTopics = harden( + makeScalarBigMapStore('publicTopics', { durable: true }), + ); + accounts.addAll(accountEntires); + publicTopics.addAll(publicTopicEntires); + return /** @type {PortfolioHolderState} */ ( + harden({ + accounts, + publicTopics, + }) + ); + }, + { + invitationMakers: { + /** + * @param {string} chainName key where the account is stored + * @param {string} action invitation maker name, e.g. 'Delegate' + * @param {unknown[]} invitationArgs + */ + Action(chainName, action, invitationArgs) { + const { accounts } = this.state; + accounts.has(chainName) || Fail`no account found for ${chainName}`; + const account = accounts.get(chainName); + return watch( + E(account).asContinuingOffer(), + this.facets.getInvitationMakersWatcher, + { action, invitationArgs }, + ); + }, + }, + holder: { + // XXX type. like `OrchestrationAccountI['asContinuingOffer']` but + // returns a Vow instead of a Promise as the + // accounts are remote + asContinuingOffer() { + const { invitationMakers } = this.facets; + const { publicTopics } = this.state; + return harden({ + publicSubscribers: fromEntries(publicTopics.entries()), + invitationMakers, + }); + }, + /** @returns {ResolvedTopicsRecord} */ + getPublicTopics() { + 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) { + 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 this.state.accounts.get(chainName); + }, + }, + getInvitationMakersWatcher: { + /** + * @param {ResolvedContinuingOfferResult} result + * @param {{ action: string; invitationArgs: unknown[] }} ctx + * @returns {Promise} + */ + onFulfilled({ invitationMakers }, { action, invitationArgs }) { + return E(invitationMakers)[action](...invitationArgs); + }, + }, + }, + ); +}; + +/** @typedef {ReturnType} MakePortfolioHolderKit */ +/** @typedef {ReturnType} PortfolioHolderKit */ + +/** + * @param {Zone} zone + * @param {VowTools} vowTools + * @returns {( + * ...args: Parameters> + * ) => PortfolioHolderKit['holder']} + */ +export const preparePortfolioHolder = (zone, vowTools) => { + const makeKit = preparePortfolioHolderKit(zone, vowTools); + return (...args) => makeKit(...args).holder; +}; +/** @typedef {ReturnType} MakePortfolioHolder */ +/** @typedef {PortfolioHolderKit['holder']} 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..40d7f643c16d --- /dev/null +++ b/packages/orchestration/test/exos/make-test-coa-kit.ts @@ -0,0 +1,57 @@ +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'; + +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..084b136b4052 --- /dev/null +++ b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts @@ -0,0 +1,120 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { makeCopyMap } from '@endo/patterns'; +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 accountMap = makeCopyMap(Object.entries(accounts)); + + const makePortfolioHolder = preparePortfolioHolder( + rootZone.subZone('portfolio'), + vowTools, + ); + const publicTopics = 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(accountMap, publicTopics); + + 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 000000000000..1b57da063519 Binary files /dev/null and b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap differ