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 16, 2024
1 parent 75e5483 commit a1ceb50
Show file tree
Hide file tree
Showing 5 changed files with 404 additions and 0 deletions.
200 changes: 200 additions & 0 deletions packages/orchestration/src/exos/portfolio-holder-kit.js
Original file line number Diff line number Diff line change
@@ -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<string, OrchestrationAccount<any>>;
* publicTopics: MapStore<string, ResolvedPublicTopic<unknown>>;
* }} 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<any>]>
* | CopyMap<string, import('@endo/marshal').Passable>} accountEntires
* @param {Iterable<[string, ResolvedPublicTopic<unknown>]>
* | CopyMap<string, import('@endo/marshal').Passable>} 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<Invitation> instead of a Promise<Invitation> 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<any>} account
* @param {ResolvedPublicTopic<unknown>} 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<Invitation>}
*/
onFulfilled({ invitationMakers }, { action, invitationArgs }) {
return E(invitationMakers)[action](...invitationArgs);
},
},
},
);
};

/** @typedef {ReturnType<typeof preparePortfolioHolderKit>} MakePortfolioHolderKit */
/** @typedef {ReturnType<MakePortfolioHolderKit>} PortfolioHolderKit */

/**
* @param {Zone} zone
* @param {VowTools} vowTools
* @returns {(
* ...args: Parameters<ReturnType<typeof preparePortfolioHolderKit>>
* ) => PortfolioHolderKit['holder']}
*/
export const preparePortfolioHolder = (zone, vowTools) => {
const makeKit = preparePortfolioHolderKit(zone, vowTools);
return (...args) => makeKit(...args).holder;
};
/** @typedef {ReturnType<typeof preparePortfolioHolder>} MakePortfolioHolder */
/** @typedef {PortfolioHolderKit['holder']} PortfolioHolder */
57 changes: 57 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,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<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;
};
};
120 changes: 120 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,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<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');
});
Loading

0 comments on commit a1ceb50

Please sign in to comment.