Skip to content

Commit

Permalink
feat: add portfolio-holder-kit.js
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
0xpatrickdev committed Jul 17, 2024
1 parent d04d396 commit 6e485ac
Show file tree
Hide file tree
Showing 5 changed files with 392 additions and 0 deletions.
176 changes: 176 additions & 0 deletions packages/orchestration/src/exos/portfolio-holder-kit.js
Original file line number Diff line number Diff line change
@@ -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<string, OrchestrationAccount<any>>;
* publicTopics: MapStore<string, ResolvedPublicTopic<unknown>>;
* }} 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<any>]>} accountEntries
* @param {Iterable<[string, ResolvedPublicTopic<unknown>]>} 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<Invitation<unknown, IA>>}
*/
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<OrchestrationAccountI['asContinuingOffer']>}
asContinuingOffer() {
return asVow(() => {
const { invitationMakers } = this.facets;
const { publicTopics } = this.state;
return harden({
publicSubscribers: fromEntries(publicTopics.entries()),
invitationMakers,
});
});
},
/** @type {HostOf<OrchestrationAccountI['getPublicTopics']>} */
getPublicTopics() {
return asVow(() => {
const { publicTopics } = this.state;
return harden(fromEntries(publicTopics.entries()));
});
},
/**
* @param {string} chainName key where the account is stored
* @param {OrchestrationAccount<any>} account
* @param {ResolvedPublicTopic<unknown>} 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<typeof preparePortfolioHolderKit>>
* ) => ReturnType<ReturnType<typeof preparePortfolioHolderKit>>['holder']}
*/
export const preparePortfolioHolder = (zone, vowTools) => {
const makeKit = preparePortfolioHolderKit(zone, vowTools);
return (...args) => makeKit(...args).holder;
};
/** @typedef {ReturnType<typeof preparePortfolioHolder>} MakePortfolioHolder */
/** @typedef {ReturnType<MakePortfolioHolder>} PortfolioHolder */
70 changes: 70 additions & 0 deletions packages/orchestration/test/exos/make-test-coa-kit.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof commonSetup>>['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;
};
};
119 changes: 119 additions & 0 deletions packages/orchestration/test/exos/portfolio-holder-kit.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>'
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');
});
Original file line number Diff line number Diff line change
@@ -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 {},
},
}
Binary file not shown.

0 comments on commit 6e485ac

Please sign in to comment.