diff --git a/multichain-testing/.gitignore b/multichain-testing/.gitignore index 353e7ea6469..ee0e3626500 100644 --- a/multichain-testing/.gitignore +++ b/multichain-testing/.gitignore @@ -2,4 +2,4 @@ .yarn/* !.yarn/patches/* revise-chain-info* -start-* +start* diff --git a/multichain-testing/test/basic-flows.test.ts b/multichain-testing/test/basic-flows.test.ts new file mode 100644 index 00000000000..40a037a5045 --- /dev/null +++ b/multichain-testing/test/basic-flows.test.ts @@ -0,0 +1,121 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { commonSetup, SetupContextWithWallets } from './support.js'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import { chainConfig, chainNames } from './support.js'; + +const test = anyTest as TestFn; + +const accounts = ['user1', 'user2', 'user3']; // one account for each scenario + +const contractName = 'basicFlows'; +const contractBuilder = + '../packages/builders/scripts/orchestration/init-basic-flows.js'; + +test.before(async t => { + const { deleteTestKeys, setupTestKeys, ...rest } = await commonSetup(t); + deleteTestKeys(accounts).catch(); + const wallets = await setupTestKeys(accounts); + t.context = { ...rest, wallets, deleteTestKeys }; + + t.log('bundle and install contract', contractName); + await t.context.deployBuilder(contractBuilder); + const vstorageClient = t.context.makeQueryTool(); + await t.context.retryUntilCondition( + () => vstorageClient.queryData(`published.agoricNames.instance`), + res => contractName in Object.fromEntries(res), + `${contractName} instance is available`, + ); +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +const makeAccountScenario = test.macro({ + title: (_, chainName: string) => `Create account on ${chainName}`, + exec: async (t, chainName: string) => { + const config = chainConfig[chainName]; + if (!config) return t.fail(`Unknown chain: ${chainName}`); + + const { + wallets, + provisionSmartWallet, + makeQueryTool, + retryUntilCondition, + } = t.context; + + const vstorageClient = makeQueryTool(); + + const wallet = accounts[chainNames.indexOf(chainName)]; + const wdUser1 = await provisionSmartWallet(wallets[wallet], { + BLD: 100n, + IST: 100n, + }); + t.log(`provisioning agoric smart wallet for ${wallets[wallet]}`); + + const doOffer = makeDoOffer(wdUser1); + t.log(`${chainName} makeAccount offer`); + const offerId = `${chainName}-makeAccount-${Date.now()}`; + + // FIXME we get payouts but not an offer result; it times out + // https://github.com/Agoric/agoric-sdk/issues/9643 + // chain logs shows an UNPUBLISHED result + const _offerResult = await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeOrchAccountInvitation']], + }, + offerArgs: { chainName }, + proposal: {}, + }); + t.true(_offerResult); + // t.is(await _offerResult, 'UNPUBLISHED', 'representation of continuing offer'); + + // TODO fix above so we don't have to poll for the offer result to be published + // https://github.com/Agoric/agoric-sdk/issues/9643 + const currentWalletRecord = await retryUntilCondition( + () => + vstorageClient.queryData(`published.wallet.${wallets[wallet]}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[offerId], + `${offerId} continuing invitation is in vstorage`, + ); + + const offerToPublicSubscriberMap = Object.fromEntries( + currentWalletRecord.offerToPublicSubscriberPaths, + ); + + const address = offerToPublicSubscriberMap[offerId]?.account + .split('.') + .pop(); + t.log('Got address:', address); + t.regex( + address, + new RegExp(`^${config.expectedAddressPrefix}1`), + `address for ${chainName} is valid`, + ); + + const latestWalletUpdate = await vstorageClient.queryData( + `published.wallet.${wallets[wallet]}`, + ); + t.log('latest wallet update', latestWalletUpdate); + t.like( + latestWalletUpdate.status, + { + id: offerId, + numWantsSatisfied: 1, + result: 'UNPUBLISHED', + error: undefined, + }, + 'wallet offer satisfied without errors', + ); + }, +}); + +test.serial(makeAccountScenario, 'agoric'); +test.serial(makeAccountScenario, 'cosmoshub'); +test.serial(makeAccountScenario, 'osmosis'); diff --git a/multichain-testing/test/stake-ica.test.ts b/multichain-testing/test/stake-ica.test.ts index 12f0e0bffe7..06e36bbc611 100644 --- a/multichain-testing/test/stake-ica.test.ts +++ b/multichain-testing/test/stake-ica.test.ts @@ -114,7 +114,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { const queryClient = makeQueryClient(getRestEndpoint()); t.log('Requesting faucet funds'); - // XXX fails intermitently until https://github.com/cosmology-tech/starship/issues/417 + // XXX fails intermittently until https://github.com/cosmology-tech/starship/issues/417 await creditFromFaucet(address); const { balances } = await retryUntilCondition( @@ -136,7 +136,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { t.truthy(validatorAddress, 'found a validator to delegate to'); t.log({ validatorAddress }, 'found a validator to delegate to'); const validatorChainAddress = { - address: validatorAddress, + value: validatorAddress, chainId: scenario.chainId, encoding: 'bech32', }; @@ -155,6 +155,19 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }); t.true(_delegateOfferResult, 'delegate payouts (none) returned'); + const latestWalletUpdate = await vstorageClient.queryData( + `published.wallet.${wallets[scenario.wallet]}`, + ); + t.log('latest wallet update', latestWalletUpdate); + t.like( + latestWalletUpdate.status, + { + id: delegateOfferId, + error: undefined, + numWantsSatisfied: 1, + }, + `${scenario.chain} delegate offer satisfied without errors`, + ); // query remote chain to verify delegations const { delegation_responses } = await retryUntilCondition( () => queryClient.queryDelegations(address), diff --git a/multichain-testing/test/support.ts b/multichain-testing/test/support.ts index ef095ead9d1..d54f3f226bc 100644 --- a/multichain-testing/test/support.ts +++ b/multichain-testing/test/support.ts @@ -12,6 +12,26 @@ import { makeDeployBuilder } from '../tools/deploy.js'; const setupRegistry = makeSetupRegistry(makeGetFile({ dirname, join })); +export const chainConfig = { + cosmoshub: { + chainId: 'gaialocal', + denom: 'uatom', + expectedAddressPrefix: 'cosmos', + }, + osmosis: { + chainId: 'osmosislocal', + denom: 'uosmo', + expectedAddressPrefix: 'osmo', + }, + agoric: { + chainId: 'agoriclocal', + denom: 'ubld', + expectedAddressPrefix: 'agoric', + }, +}; + +export const chainNames = Object.keys(chainConfig); + const makeKeyring = async ( e2eTools: Pick, ) => { diff --git a/multichain-testing/tools/agd-tools.ts b/multichain-testing/tools/agd-tools.ts index a7b62f5380a..5b87be3e1ea 100644 --- a/multichain-testing/tools/agd-tools.ts +++ b/multichain-testing/tools/agd-tools.ts @@ -8,7 +8,7 @@ export const makeAgdTools = async ( execFileSync, }: Pick, ) => { - const bundleCache = unsafeMakeBundleCache('bundles'); + const bundleCache = await unsafeMakeBundleCache('bundles'); const tools = await makeE2ETools(log, bundleCache, { execFileSync, execFile, diff --git a/multichain-testing/yarn.lock b/multichain-testing/yarn.lock index ed32fa1fb67..e89595030d6 100644 --- a/multichain-testing/yarn.lock +++ b/multichain-testing/yarn.lock @@ -671,13 +671,6 @@ __metadata: languageName: node linkType: hard -"@pkgr/core@npm:^0.1.0": - version: 0.1.1 - resolution: "@pkgr/core@npm:0.1.1" - checksum: 10c0/3f7536bc7f57320ab2cf96f8973664bef624710c403357429fbf680a5c3b4843c1dbd389bb43daa6b1f6f1f007bb082f5abcb76bb2b5dc9f421647743b71d3d8 - languageName: node - linkType: hard - "@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/aspromise@npm:1.1.2" @@ -1938,26 +1931,6 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-prettier@npm:^5.1.3": - version: 5.1.3 - resolution: "eslint-plugin-prettier@npm:5.1.3" - dependencies: - prettier-linter-helpers: "npm:^1.0.0" - synckit: "npm:^0.8.6" - peerDependencies: - "@types/eslint": ">=8.0.0" - eslint: ">=8.0.0" - eslint-config-prettier: "*" - prettier: ">=3.0.0" - peerDependenciesMeta: - "@types/eslint": - optional: true - eslint-config-prettier: - optional: true - checksum: 10c0/f45d5fc1fcfec6b0cf038a7a65ddd10a25df4fe3f9e1f6b7f0d5100e66f046a26a2492e69ee765dddf461b93c114cf2e1eb18d4970aafa6f385448985c136e09 - languageName: node - linkType: hard - "eslint-scope@npm:^7.2.2": version: 7.2.2 resolution: "eslint-scope@npm:7.2.2" @@ -2126,7 +2099,7 @@ __metadata: languageName: node linkType: hard -"fast-diff@npm:^1.1.2, fast-diff@npm:^1.2.0": +"fast-diff@npm:^1.2.0": version: 1.3.0 resolution: "fast-diff@npm:1.3.0" checksum: 10c0/5c19af237edb5d5effda008c891a18a585f74bf12953be57923f17a3a4d0979565fc64dbc73b9e20926b9d895f5b690c618cbb969af0cf022e3222471220ad29 @@ -3653,24 +3626,6 @@ __metadata: languageName: node linkType: hard -"prettier-linter-helpers@npm:^1.0.0": - version: 1.0.0 - resolution: "prettier-linter-helpers@npm:1.0.0" - dependencies: - fast-diff: "npm:^1.1.2" - checksum: 10c0/81e0027d731b7b3697ccd2129470ed9913ecb111e4ec175a12f0fcfab0096516373bf0af2fef132af50cafb0a905b74ff57996d615f59512bb9ac7378fcc64ab - languageName: node - linkType: hard - -"prettier@npm:^3.2.4": - version: 3.3.1 - resolution: "prettier@npm:3.3.1" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/c25a709c9f0be670dc6bcb190b622347e1dbeb6c3e7df8b0711724cb64d8647c60b839937a4df4df18e9cfb556c2b08ca9d24d9645eb5488a7fc032a2c4d5cb3 - languageName: node - linkType: hard - "pretty-ms@npm:^9.0.0": version: 9.0.0 resolution: "pretty-ms@npm:9.0.0" @@ -3885,11 +3840,9 @@ __metadata: ava: "npm:^6.1.3" eslint: "npm:^8.56.0" eslint-config-prettier: "npm:^9.1.0" - eslint-plugin-prettier: "npm:^5.1.3" execa: "npm:^9.2.0" fs-extra: "npm:^11.2.0" patch-package: "npm:^8.0.0" - prettier: "npm:^3.2.4" starshipjs: "npm:2.0.0" tsimp: "npm:^2.0.10" tsx: "npm:^4.15.6" @@ -4241,16 +4194,6 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.8.6": - version: 0.8.8 - resolution: "synckit@npm:0.8.8" - dependencies: - "@pkgr/core": "npm:^0.1.0" - tslib: "npm:^2.6.2" - checksum: 10c0/c3d3aa8e284f3f84f2f868b960c9f49239b364e35f6d20825a448449a3e9c8f49fe36cdd5196b30615682f007830d46f2ea354003954c7336723cb821e4b6519 - languageName: node - linkType: hard - "tar@npm:^6.1.11, tar@npm:^6.1.2": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -4341,13 +4284,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.6.2": - version: 2.6.3 - resolution: "tslib@npm:2.6.3" - checksum: 10c0/2598aef53d9dbe711af75522464b2104724d6467b26a60f2bdac8297d2b5f1f6b86a71f61717384aa8fd897240467aaa7bcc36a0700a0faf751293d1331db39a - languageName: node - linkType: hard - "tsx@npm:^4.15.6": version: 4.15.6 resolution: "tsx@npm:4.15.6" diff --git a/packages/boot/test/bootstrapTests/orchestration.test.ts b/packages/boot/test/bootstrapTests/orchestration.test.ts index 841fb290fc5..0c5e196323d 100644 --- a/packages/boot/test/bootstrapTests/orchestration.test.ts +++ b/packages/boot/test/bootstrapTests/orchestration.test.ts @@ -227,3 +227,68 @@ test.serial('revise chain info', async t => { client_id: '07-tendermint-3', }); }); + +test('basic-flows', async t => { + const { buildProposal, evalProposal, agoricNamesRemotes, readLatest } = + t.context; + + await evalProposal( + buildProposal('@agoric/builders/scripts/orchestration/init-basic-flows.js'), + ); + + const wd = + await t.context.walletFactoryDriver.provideSmartWallet('agoric1test'); + + // create a cosmos orchestration account + await wd.executeOffer({ + id: 'request-coa', + invitationSpec: { + source: 'agoricContract', + instancePath: ['basicFlows'], + callPipe: [['makeOrchAccountInvitation']], + }, + offerArgs: { + chainName: 'cosmoshub', + }, + proposal: {}, + }); + t.like(wd.getCurrentWalletRecord(), { + offerToPublicSubscriberPaths: [ + [ + 'request-coa', + { + account: 'published.basicFlows.cosmos1test', + }, + ], + ], + }); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'request-coa', numWantsSatisfied: 1 }, + }); + t.is(readLatest('published.basicFlows.cosmos1test'), ''); + + // create a local orchestration account + await wd.executeOffer({ + id: 'request-loa', + invitationSpec: { + source: 'agoricContract', + instancePath: ['basicFlows'], + callPipe: [['makeOrchAccountInvitation']], + }, + offerArgs: { + chainName: 'agoric', + }, + proposal: {}, + }); + + const publicSubscriberPaths = Object.fromEntries( + wd.getCurrentWalletRecord().offerToPublicSubscriberPaths, + ); + t.deepEqual(publicSubscriberPaths['request-loa'], { + account: 'published.basicFlows.agoric1mockVlocalchainAddress', + }); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'request-loa', numWantsSatisfied: 1 }, + }); + t.is(readLatest('published.basicFlows.agoric1mockVlocalchainAddress'), ''); +}); diff --git a/packages/builders/package.json b/packages/builders/package.json index b3743b4052a..6d633969182 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -45,6 +45,7 @@ "@agoric/deploy-script-support": "^0.10.3", "@agoric/governance": "^0.10.3", "@agoric/inter-protocol": "^0.16.1", + "@agoric/orchestration": "^0.1.0", "@agoric/store": "^0.9.2", "@agoric/swing-store": "^0.9.1", "@agoric/swingset-liveslots": "^0.10.2", diff --git a/packages/builders/scripts/orchestration/init-basic-flows.js b/packages/builders/scripts/orchestration/init-basic-flows.js new file mode 100644 index 00000000000..2ca1873ef83 --- /dev/null +++ b/packages/builders/scripts/orchestration/init-basic-flows.js @@ -0,0 +1,26 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; +import { startBasicFlows } from '@agoric/orchestration/src/proposals/start-basic-flows.js'; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').CoreEvalBuilder} */ +export const defaultProposalBuilder = async ({ publishRef, install }) => { + return harden({ + sourceSpec: '@agoric/orchestration/src/proposals/start-basic-flows.js', + getManifestCall: [ + 'getManifestForContract', + { + installKeys: { + basicFlows: publishRef( + install( + '@agoric/orchestration/src/examples/basic-flows.contract.js', + ), + ), + }, + }, + ], + }); +}; + +export default async (homeP, endowments) => { + const { writeCoreEval } = await makeHelpers(homeP, endowments); + await writeCoreEval(startBasicFlows.name, defaultProposalBuilder); +}; diff --git a/packages/orchestration/src/examples/basic-flows.contract.js b/packages/orchestration/src/examples/basic-flows.contract.js new file mode 100644 index 00000000000..1771f9e3f88 --- /dev/null +++ b/packages/orchestration/src/examples/basic-flows.contract.js @@ -0,0 +1,72 @@ +/** + * @file Primarily a testing fixture, but also serves as an example of how to + * leverage basic functionality of the Orchestration API with async-flow. + */ +import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; +import { M, mustMatch } from '@endo/patterns'; +import { provideOrchestration } from '../utils/start-helper.js'; + +/** + * @import {Baggage} from '@agoric/vat-data'; + * @import {Orchestrator} from '@agoric/orchestration'; + * @import {OrchestrationPowers} from '../utils/start-helper.js'; + */ + +/** + * Create an account on a Cosmos chain and return a continuing offer with + * invitations makers for Delegate, WithdrawRewards, Transfer, etc. + * + * @param {Orchestrator} orch + * @param {undefined} _ctx + * @param {ZCFSeat} seat + * @param {{ chainName: string }} offerArgs + */ +const makeOrchAccountHandler = async (orch, _ctx, seat, { chainName }) => { + seat.exit(); // no funds exchanged + mustMatch(chainName, M.string()); + const remoteChain = await orch.getChain(chainName); + const cosmosAccount = await remoteChain.makeAccount(); + return cosmosAccount.asContinuingOffer(); +}; + +/** + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} privateArgs + * @param {Baggage} baggage + */ +export const start = async (zcf, privateArgs, baggage) => { + const { orchestrate, zone } = provideOrchestration( + zcf, + baggage, + privateArgs, + privateArgs.marshaller, + ); + + /** @type {OfferHandler} */ + const makeOrchAccount = orchestrate( + 'makeOrchAccount', + undefined, + makeOrchAccountHandler, + ); + + const publicFacet = zone.exo( + 'Basic Flows Public Facet', + M.interface('Basic Flows PF', { + makeOrchAccountInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeOrchAccountInvitation() { + return zcf.makeInvitation( + makeOrchAccount, + 'Make an Orchestration Account', + ); + }, + }, + ); + + return { publicFacet }; +}; + +/** @typedef {typeof start} BasicFlowsSF */ diff --git a/packages/orchestration/src/examples/stakeBld.contract.js b/packages/orchestration/src/examples/stakeBld.contract.js index ef0729b3d7f..935c72b9226 100644 --- a/packages/orchestration/src/examples/stakeBld.contract.js +++ b/packages/orchestration/src/examples/stakeBld.contract.js @@ -87,17 +87,13 @@ export const start = async (zcf, privateArgs, baggage) => { async seat => { const { give } = seat.getProposal(); trace('makeStakeBldInvitation', give); - const { holder, invitationMakers } = await makeLocalAccountKit(); + const { holder } = await makeLocalAccountKit(); const { In } = await deeplyFulfilled( withdrawFromSeat(zcf, seat, give), ); await E(holder).deposit(In); seat.exit(); - return harden({ - publicSubscribers: holder.getPublicTopics(), - invitationMakers, - account: holder, - }); + return holder.asContinuingOffer(); }, 'wantStake', undefined, @@ -118,12 +114,8 @@ export const start = async (zcf, privateArgs, baggage) => { trace('makeCreateAccountInvitation'); return zcf.makeInvitation(async seat => { seat.exit(); - const { holder, invitationMakers } = await makeLocalAccountKit(); - return harden({ - publicSubscribers: holder.getPublicTopics(), - invitationMakers, - account: holder, - }); + const { holder } = await makeLocalAccountKit(); + return holder.asContinuingOffer(); }, 'wantLocalChainAccount'); }, }, diff --git a/packages/orchestration/src/exos/cosmos-orchestration-account.js b/packages/orchestration/src/exos/cosmos-orchestration-account.js index 48432d10782..fdb160e02de 100644 --- a/packages/orchestration/src/exos/cosmos-orchestration-account.js +++ b/packages/orchestration/src/exos/cosmos-orchestration-account.js @@ -22,7 +22,6 @@ import { makeTracer } from '@agoric/internal'; import { Shape as NetworkShape } from '@agoric/network'; import { M } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; -import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/index.js'; import { AmountArgShape, ChainAddressShape, @@ -69,12 +68,6 @@ const { Vow$ } = NetworkShape; // TODO #9611 /** @see {OrchestrationAccountI} */ export const IcaAccountHolderI = M.interface('IcaAccountHolder', { ...orchestrationAccountMethods, - asContinuingOffer: M.call().returns({ - publicSubscribers: M.any(), - invitationMakers: M.any(), - holder: M.any(), - }), - getPublicTopics: M.call().returns(TopicsRecordShape), delegate: M.call(ChainAddressShape, AmountArgShape).returns(VowShape), redelegate: M.call( ChainAddressShape, @@ -105,11 +98,11 @@ const toDenomAmount = c => ({ denom: c.denom, value: BigInt(c.amount) }); export const prepareCosmosOrchestrationAccountKit = ( zone, makeRecorderKit, - { watch, asVow }, + { watch, asVow, when }, zcf, ) => { const makeCosmosOrchestrationAccountKit = zone.exoClassKit( - 'Staking Account Holder', + 'Cosmos Orchestration Account Holder', { helper: M.interface('helper', { owned: M.call().returns(M.remotable()), @@ -317,21 +310,34 @@ export const prepareCosmosOrchestrationAccountKit = ( }, holder: { asContinuingOffer() { - const { holder, invitationMakers } = this.facets; - return harden({ - publicSubscribers: holder.getPublicTopics(), - invitationMakers, - holder, + // getPublicTopics resolves promptly (same run), so we don't need a watcher + // eslint-disable-next-line no-restricted-syntax + return asVow(async () => { + await null; + const { holder, invitationMakers } = this.facets; + return harden({ + // getPublicTopics returns a vow, for membrane compatibility. + // it's safe to unwrap to a promise and get the result as we + // expect this complete in the same run + publicSubscribers: await when(holder.getPublicTopics()), + invitationMakers, + holder, + }); }); }, getPublicTopics() { - const { topicKit } = this.state; - return harden({ - account: { - description: PUBLIC_TOPICS.account[0], - subscriber: topicKit.subscriber, - storagePath: topicKit.recorder.getStoragePath(), - }, + // getStoragePath resolves promptly (same run), so we don't need a watcher + // eslint-disable-next-line no-restricted-syntax + return asVow(async () => { + await null; + const { topicKit } = this.state; + return harden({ + account: { + description: PUBLIC_TOPICS.account[0], + subscriber: topicKit.subscriber, + storagePath: await topicKit.recorder.getStoragePath(), + }, + }); }); }, diff --git a/packages/orchestration/src/exos/local-chain-facade.js b/packages/orchestration/src/exos/local-chain-facade.js index ddada8f15f4..357ebcb3120 100644 --- a/packages/orchestration/src/exos/local-chain-facade.js +++ b/packages/orchestration/src/exos/local-chain-facade.js @@ -2,6 +2,7 @@ import { E } from '@endo/far'; import { M } from '@endo/patterns'; import { pickFacet } from '@agoric/vat-data'; +import { VowShape } from '@agoric/vow'; import { ChainFacadeI } from '../typeGuards.js'; @@ -36,6 +37,8 @@ const prepareLocalChainFacadeKit = ( { makeLocalOrchestrationAccountKit, localchain, + // TODO vstorage design https://github.com/Agoric/agoric-sdk/issues/9066 + // consider making an `accounts` childNode storageNode, vowTools: { allVows, watch }, }, @@ -44,9 +47,14 @@ const prepareLocalChainFacadeKit = ( 'LocalChainFacade', { public: ChainFacadeI, - makeAccountWatcher: M.interface('undelegateWatcher', { + makeAccountWatcher: M.interface('makeAccountWatcher', { onFulfilled: M.call([M.remotable('LCA Account'), M.string()]) .optional(M.arrayOf(M.undefined())) // empty context + .returns(VowShape), + }), + makeChildNodeWatcher: M.interface('makeChildNodeWatcher', { + onFulfilled: M.call(M.remotable()) + .optional({ account: M.remotable(), address: M.string() }) // empty context .returns(M.remotable()), }), }, @@ -82,6 +90,22 @@ const prepareLocalChainFacadeKit = ( * @param {[LocalChainAccount, ChainAddress['value']]} results */ onFulfilled([account, address]) { + return watch( + E(storageNode).makeChildNode(address), + this.facets.makeChildNodeWatcher, + { account, address }, + ); + }, + }, + makeChildNodeWatcher: { + /** + * @param {Remote} childNode + * @param {{ + * account: LocalChainAccount; + * address: ChainAddress['value']; + * }} ctx + */ + onFulfilled(childNode, { account, address }) { const { localChainInfo } = this.state; const { holder } = makeLocalOrchestrationAccountKit({ account, @@ -91,7 +115,7 @@ const prepareLocalChainFacadeKit = ( chainId: localChainInfo.chainId, }), // FIXME storage path https://github.com/Agoric/agoric-sdk/issues/9066 - storageNode, + storageNode: childNode, }); return holder; }, diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index fa084b837ff..55deed6e58b 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -5,7 +5,6 @@ import { makeTracer } from '@agoric/internal'; import { Shape as NetworkShape } from '@agoric/network'; import { M } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; -import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/index.js'; import { E } from '@endo/far'; import { ChainAddressShape, @@ -51,7 +50,6 @@ const { Vow$ } = NetworkShape; // TODO #9611 const HolderI = M.interface('holder', { ...orchestrationAccountMethods, - getPublicTopics: M.call().returns(TopicsRecordShape), delegate: M.call(M.string(), AmountShape).returns(VowShape), undelegate: M.call(M.string(), AmountShape).returns(VowShape), deposit: M.call(PaymentShape).returns(VowShape), @@ -77,7 +75,7 @@ export const prepareLocalOrchestrationAccountKit = ( makeRecorderKit, zcf, timerService, - { watch, allVows, asVow }, + { watch, allVows, asVow, when }, chainHub, ) => { const timestampHelper = makeTimestampHelper(timerService); @@ -140,6 +138,8 @@ export const prepareLocalOrchestrationAccountKit = ( // must be the fully synchronous maker because the kit is held in durable state // @ts-expect-error XXX Patterns const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.account[1]); + // TODO determine what goes in vstorage https://github.com/Agoric/agoric-sdk/issues/9066 + void E(topicKit.recorder).write(''); return { account, address, topicKit }; }, @@ -287,6 +287,22 @@ export const prepareLocalOrchestrationAccountKit = ( }, }, holder: { + asContinuingOffer() { + // getPublicTopics resolves promptly (same run), so we don't need a watcher + // eslint-disable-next-line no-restricted-syntax + return asVow(async () => { + await null; + const { holder, invitationMakers } = this.facets; + return harden({ + // getPublicTopics returns a vow, for membrane compatibility. + // it's safe to unwrap to a promise and get the result as we + // expect this complete in the same run + publicSubscribers: await when(holder.getPublicTopics()), + invitationMakers, + holder, + }); + }); + }, /** * TODO: balance lookups for non-vbank assets * @@ -312,13 +328,18 @@ export const prepareLocalOrchestrationAccountKit = ( }, getPublicTopics() { - const { topicKit } = this.state; - return harden({ - account: { - description: PUBLIC_TOPICS.account[0], - subscriber: topicKit.subscriber, - storagePath: topicKit.recorder.getStoragePath(), - }, + // getStoragePath resolves promptly (same run), so we don't need a watcher + // eslint-disable-next-line no-restricted-syntax + return asVow(async () => { + await null; + const { topicKit } = this.state; + return harden({ + account: { + description: PUBLIC_TOPICS.account[0], + subscriber: topicKit.subscriber, + storagePath: await topicKit.recorder.getStoragePath(), + }, + }); }); }, /** diff --git a/packages/orchestration/src/exos/remote-chain-facade.js b/packages/orchestration/src/exos/remote-chain-facade.js index 3b87240c39d..762d23c8f61 100644 --- a/packages/orchestration/src/exos/remote-chain-facade.js +++ b/packages/orchestration/src/exos/remote-chain-facade.js @@ -4,7 +4,7 @@ import { E } from '@endo/far'; import { M } from '@endo/patterns'; import { pickFacet } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; -import { ChainFacadeI } from '../typeGuards.js'; +import { ChainAddressShape, ChainFacadeI } from '../typeGuards.js'; /** * @import {Zone} from '@agoric/base-zone'; @@ -43,6 +43,8 @@ const prepareRemoteChainFacadeKit = ( { makeCosmosOrchestrationAccount, orchestration, + // TODO vstorage design https://github.com/Agoric/agoric-sdk/issues/9066 + // consider making an `accounts` childNode storageNode, timer, vowTools: { asVow, watch }, @@ -54,12 +56,20 @@ const prepareRemoteChainFacadeKit = ( public: ChainFacadeI, makeAccountWatcher: M.interface('makeAccountWatcher', { onFulfilled: M.call(M.remotable()) - .optional(M.string()) + .optional(M.arrayOf(M.undefined())) // empty context .returns(VowShape), }), - getAddressWatcher: M.interface('makeAccountWatcher', { + getAddressWatcher: M.interface('getAddressWatcher', { onFulfilled: M.call(M.record()) - .optional({ stakingDenom: M.string(), account: M.remotable() }) + .optional(M.remotable()) + .returns(VowShape), + }), + makeChildNodeWatcher: M.interface('makeChildNodeWatcher', { + onFulfilled: M.call(M.remotable()) + .optional({ + account: M.remotable(), + chainAddress: ChainAddressShape, + }) .returns(M.remotable()), }), }, @@ -95,32 +105,55 @@ const prepareRemoteChainFacadeKit = ( connectionInfo.counterparty.connection_id, ), this.facets.makeAccountWatcher, - stakingDenom, ); }); }, }, makeAccountWatcher: { /** + * XXX Pipeline vows allVows and E + * * @param {IcaAccount} account - * @param {Denom} stakingDenom */ - onFulfilled(account, stakingDenom) { - return watch(E(account).getAddress(), this.facets.getAddressWatcher, { - stakingDenom, + onFulfilled(account) { + return watch( + E(account).getAddress(), + this.facets.getAddressWatcher, account, - }); + ); }, }, getAddressWatcher: { /** * @param {ChainAddress} chainAddress - * @param {{ stakingDenom: Denom; account: IcaAccount }} ctx + * @param {IcaAccount} account + */ + onFulfilled(chainAddress, account) { + return watch( + E(storageNode).makeChildNode(chainAddress.value), + this.facets.makeChildNodeWatcher, + { account, chainAddress }, + ); + }, + }, + makeChildNodeWatcher: { + /** + * @param {Remote} childNode + * @param {{ + * account: IcaAccount; + * chainAddress: ChainAddress; + * }} ctx */ - onFulfilled(chainAddress, { account, stakingDenom }) { + onFulfilled(childNode, { account, chainAddress }) { + const { remoteChainInfo } = this.state; + const stakingDenom = remoteChainInfo.stakingTokens?.[0]?.denom; + if (!stakingDenom) { + throw Fail`chain info lacks staking denom`; + } return makeCosmosOrchestrationAccount(chainAddress, stakingDenom, { account, - storageNode, + // FIXME storage path https://github.com/Agoric/agoric-sdk/issues/9066 + storageNode: childNode, // FIXME provide real ICQ connection // FIXME make Query Connection available via chain, not orchestrationAccount icqConnection: anyVal, diff --git a/packages/orchestration/src/orchestration-api.ts b/packages/orchestration/src/orchestration-api.ts index 5da7f2c7b9d..5d6da28a9ad 100644 --- a/packages/orchestration/src/orchestration-api.ts +++ b/packages/orchestration/src/orchestration-api.ts @@ -4,14 +4,12 @@ * - must not have chain-specific types without runtime narrowing by chain id * - should remain relatively stable. */ -import type { - Amount, - Brand, - NatAmount, - Payment, -} from '@agoric/ertp/src/types.js'; +import type { Amount, Brand, NatAmount } from '@agoric/ertp/src/types.js'; import type { LocalChainAccount } from '@agoric/vats/src/localchain.js'; import type { Timestamp } from '@agoric/time'; +import type { ContinuingOfferResult } from '@agoric/smart-wallet/src/types.js'; +import type { TopicsRecord } from '@agoric/zoe/src/contractSupport/topics.js'; +import type { CurrentWalletRecord } from '@agoric/smart-wallet/src/smartWallet.js'; import type { ChainInfo, CosmosChainAccountMethods, @@ -172,6 +170,20 @@ export interface OrchestrationAccountI { * @returns void */ transferSteps: (amount: AmountArg, msg: TransferMsg) => Promise; + + /** + * Returns `invitationMakers` and `publicSubscribers` to the account + * holder's smart wallet so they can continue interacting with the account + * and read account state in vstorage if published. + */ + asContinuingOffer: () => Promise; + /** + * Public topics are a map to different vstorage paths and subscribers that + * can be shared with on or offchain clients. + * When returned as part of a continuing invitation, it will appear + * in the {@link CurrentWalletRecord} in vstorage. + */ + getPublicTopics: () => Promise; } /** diff --git a/packages/orchestration/src/proposals/start-basic-flows.js b/packages/orchestration/src/proposals/start-basic-flows.js new file mode 100644 index 00000000000..e19b242ff90 --- /dev/null +++ b/packages/orchestration/src/proposals/start-basic-flows.js @@ -0,0 +1,97 @@ +/** + * @file A proposal to start the basic flows contract. + */ +import { makeTracer } from '@agoric/internal'; +import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; +import { E } from '@endo/far'; + +/** + * @import {BasicFlowsSF} from '../examples/basic-flows.contract.js'; + */ + +const trace = makeTracer('StartBasicFlows', true); +const contractName = 'basicFlows'; + +/** + * See `@agoric/builders/builders/scripts/orchestration/init-basic-flows.js` for + * the accompanying proposal builder. Run `agoric run + * packages/builders/scripts/orchestration/init-basic-flows.js` to build the + * contract and proposal files. + * + * @param {BootstrapPowers} powers + */ +export const startBasicFlows = async ({ + consume: { + agoricNames, + board, + chainStorage, + chainTimerService, + cosmosInterchainService, + localchain, + startUpgradable, + }, + installation: { + // @ts-expect-error not a WellKnownName + consume: { [contractName]: installation }, + }, + instance: { + // @ts-expect-error not a WellKnownName + produce: { [contractName]: produceInstance }, + }, +}) => { + trace(`start ${contractName}`); + await null; + + const storageNode = await makeStorageNodeChild(chainStorage, contractName); + const marshaller = await E(board).getPublishingMarshaller(); + + /** @type {StartUpgradableOpts} */ + const startOpts = { + label: 'basicFlows', + installation, + terms: undefined, + privateArgs: { + agoricNames: await agoricNames, + orchestrationService: await cosmosInterchainService, + localchain: await localchain, + storageNode, + marshaller, + timerService: await chainTimerService, + }, + }; + + const { instance } = await E(startUpgradable)(startOpts); + produceInstance.resolve(instance); +}; +harden(startBasicFlows); + +export const getManifestForContract = ( + { restoreRef }, + { installKeys, ...options }, +) => { + return { + manifest: { + [startBasicFlows.name]: { + consume: { + agoricNames: true, + board: true, + chainStorage: true, + chainTimerService: true, + cosmosInterchainService: true, + localchain: true, + startUpgradable: true, + }, + installation: { + consume: { [contractName]: true }, + }, + instance: { + produce: { [contractName]: true }, + }, + }, + }, + installations: { + [contractName]: restoreRef(installKeys[contractName]), + }, + options, + }; +}; diff --git a/packages/orchestration/src/utils/orchestrationAccount.js b/packages/orchestration/src/utils/orchestrationAccount.js index 90d8ac788da..67045a59114 100644 --- a/packages/orchestration/src/utils/orchestrationAccount.js +++ b/packages/orchestration/src/utils/orchestrationAccount.js @@ -1,6 +1,7 @@ import { M } from '@endo/patterns'; import { Shape as NetworkShape } from '@agoric/network'; import { VowShape } from '@agoric/vow'; +import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/topics.js'; import { AmountArgShape, ChainAddressShape, CoinShape } from '../typeGuards.js'; /** @import {OrchestrationAccountI} from '../orchestration-api.js'; */ @@ -17,4 +18,12 @@ export const orchestrationAccountMethods = { .optional(M.record()) .returns(VowShape), transferSteps: M.call(AmountArgShape, M.any()).returns(VowShape), + asContinuingOffer: M.call().returns( + Vow$({ + publicSubscribers: TopicsRecordShape, + invitationMakers: M.any(), + holder: M.remotable(), + }), + ), + getPublicTopics: M.call().returns(Vow$(TopicsRecordShape)), }; diff --git a/packages/orchestration/test/examples/basic-flows.contract.test.ts b/packages/orchestration/test/examples/basic-flows.contract.test.ts new file mode 100644 index 00000000000..c95f4e7a117 --- /dev/null +++ b/packages/orchestration/test/examples/basic-flows.contract.test.ts @@ -0,0 +1,86 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import type { TestFn } from 'ava'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; +import { E, getInterfaceOf } from '@endo/far'; +import path from 'path'; +import { commonSetup } from '../supports.js'; + +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractName = 'basic-flows'; +const contractFile = `${dirname}/../../src/examples/${contractName}.contract.js`; +type StartFn = + typeof import('../../src/examples/basic-flows.contract.js').start; + +type TestContext = Awaited> & { + zoe: ZoeService; + instance: Instance; +}; + +const test = anyTest as TestFn; + +test.before(async t => { + const setupContext = await commonSetup(t); + const { + bootstrap: { storage }, + commonPrivateArgs, + } = setupContext; + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + const installation = await bundleAndInstall(contractFile); + + const storageNode = await E(storage.rootNode).makeChildNode(contractName); + const { instance } = await E(zoe).startInstance( + installation, + undefined, + {}, + { ...commonPrivateArgs, storageNode }, + ); + + t.context = { + ...setupContext, + zoe, + instance, + }; +}); + +const chainConfigs = { + agoric: { addressPrefix: 'agoric1fakeLCAAddress' }, + cosmoshub: { addressPrefix: 'cosmos1test' }, +}; + +const orchestrationAccountScenario = test.macro({ + title: (_, chainName: string) => + `orchestrate - ${chainName} makeOrchAccount returns a ContinuingOfferResult`, + exec: async (t, chainName: string) => { + const config = chainConfigs[chainName as keyof typeof chainConfigs]; + if (!config) { + return t.fail(`Unknown chain: ${chainName}`); + } + + const { zoe, instance } = t.context; + const publicFacet = await E(zoe).getPublicFacet(instance); + const inv = E(publicFacet).makeOrchAccountInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { chainName }); + // @ts-expect-error TODO: type expected offer result + const { holder, invitationMakers, publicSubscribers } = + await E(userSeat).getOfferResult(); + + t.regex(getInterfaceOf(holder)!, /Orchestration (.*) holder/); + t.regex(getInterfaceOf(invitationMakers)!, /invitationMakers/); + + const { description, storagePath, subscriber } = publicSubscribers.account; + t.regex(description, /Account holder/); + + const expectedStoragePath = `mockChainStorageRoot.basic-flows.${config.addressPrefix}`; + t.is(storagePath, expectedStoragePath); + + t.regex(getInterfaceOf(subscriber)!, /Durable Publish Kit subscriber/); + }, +}); + +test(orchestrationAccountScenario, 'agoric'); +test(orchestrationAccountScenario, 'cosmoshub'); diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md index a63bd488ab3..6cca6b7d1a2 100644 --- a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md @@ -31,11 +31,11 @@ Generated by [AVA](https://avajs.dev). 'Send PF_singleton': 'Alleged: Send PF', }, orchestration: { + 'Cosmos Orchestration Account Holder_kindHandle': 'Alleged: kind', 'Local Orchestration Account Kit_kindHandle': 'Alleged: kind', LocalChainFacade_kindHandle: 'Alleged: kind', Orchestrator_kindHandle: 'Alleged: kind', RemoteChainFacade_kindHandle: 'Alleged: kind', - 'Staking Account Holder_kindHandle': 'Alleged: kind', sendIt: { asyncFlow_kindHandle: 'Alleged: kind', endowments: { diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap index 923f7c29cf8..3f4defa91ec 100644 Binary files a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap and b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap differ diff --git a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md index 6d3674e4d49..9c684aaa5e0 100644 --- a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.md @@ -29,6 +29,7 @@ Generated by [AVA](https://avajs.dev). publicFacet_singleton: 'Alleged: publicFacet', }, orchestration: { + 'Cosmos Orchestration Account Holder_kindHandle': 'Alleged: kind', LSTTia: { asyncFlow_kindHandle: 'Alleged: kind', }, @@ -36,7 +37,6 @@ Generated by [AVA](https://avajs.dev). LocalChainFacade_kindHandle: 'Alleged: kind', Orchestrator_kindHandle: 'Alleged: kind', RemoteChainFacade_kindHandle: 'Alleged: kind', - 'Staking Account Holder_kindHandle': 'Alleged: kind', }, vows: { PromiseWatcher_kindHandle: 'Alleged: kind', diff --git a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap index 68062a520ee..90dff347a73 100644 Binary files a/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap and b/packages/orchestration/test/examples/snapshots/unbondExample.test.ts.snap differ diff --git a/packages/orchestration/test/examples/stake-bld.contract.test.ts b/packages/orchestration/test/examples/stake-bld.contract.test.ts index b6053e47f70..ec409824dc0 100644 --- a/packages/orchestration/test/examples/stake-bld.contract.test.ts +++ b/packages/orchestration/test/examples/stake-bld.contract.test.ts @@ -142,6 +142,12 @@ test('makeAccountInvitationMaker', async t => { const userSeat = await E(zoe).offer(inv); const offerResult = await E(userSeat).getOfferResult(); - t.true('account' in offerResult, 'received account'); + t.true('holder' in offerResult, 'received account holder'); t.truthy('invitationMakers' in offerResult, 'received continuing invitation'); + t.like(offerResult.publicSubscribers, { + account: { + description: 'Account holder status', + storagePath: 'mockChainStorageRoot', + }, + }); }); diff --git a/packages/orchestration/test/staking-ops.test.ts b/packages/orchestration/test/staking-ops.test.ts index 9cea5c57825..0a975f4e290 100644 --- a/packages/orchestration/test/staking-ops.test.ts +++ b/packages/orchestration/test/staking-ops.test.ts @@ -237,7 +237,7 @@ test('makeAccount() writes to storage', async t => { icqConnection, timer, }); - const { publicSubscribers } = holder.asContinuingOffer(); + const { publicSubscribers } = await E.when(holder.asContinuingOffer()); const accountNotifier = makeNotifierFromSubscriber( publicSubscribers.account.subscriber, ); diff --git a/packages/zoe/src/contractSupport/topics.js b/packages/zoe/src/contractSupport/topics.js index 350b378e1a1..8f1f15283e1 100644 --- a/packages/zoe/src/contractSupport/topics.js +++ b/packages/zoe/src/contractSupport/topics.js @@ -7,7 +7,7 @@ export { SubscriberShape }; export const PublicTopicShape = M.splitRecord( { subscriber: SubscriberShape, - storagePath: M.promise(/* string */), + storagePath: M.or(M.promise(/* string */), M.string()), }, { description: M.string() }, );