-
Notifications
You must be signed in to change notification settings - Fork 212
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
46889ac
commit 3150e78
Showing
5 changed files
with
392 additions
and
0 deletions.
There are no files selected for viewing
176 changes: 176 additions & 0 deletions
176
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,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 */ |
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,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
119
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,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'); | ||
}); |
27 changes: 27 additions & 0 deletions
27
packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md
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,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 added
BIN
+482 Bytes
packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap
Binary file not shown.