-
Notifications
You must be signed in to change notification settings - Fork 208
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- 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
1 parent
75e5483
commit a1ceb50
Showing
5 changed files
with
404 additions
and
0 deletions.
There are no files selected for viewing
200 changes: 200 additions & 0 deletions
200
packages/orchestration/src/exos/portfolio-holder-kit.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 */ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
120
packages/orchestration/test/exos/portfolio-holder-kit.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); |
Oops, something went wrong.