diff --git a/packages/boot/test/bootstrapTests/test-orchestration.ts b/packages/boot/test/bootstrapTests/test-orchestration.ts index c443ff9ba94..92ff7b65abf 100644 --- a/packages/boot/test/bootstrapTests/test-orchestration.ts +++ b/packages/boot/test/bootstrapTests/test-orchestration.ts @@ -5,6 +5,7 @@ import type { TestFn } from 'ava'; import { Fail } from '@agoric/assert'; import type { start as stakeBldStart } from '@agoric/orchestration/src/contracts/stakeBld.contract.js'; import type { Instance } from '@agoric/zoe/src/zoeService/utils.js'; +import { M, matches } from '@endo/patterns'; import { makeWalletFactoryContext } from './walletFactory.ts'; type DefaultTestContext = Awaited>; @@ -14,7 +15,7 @@ const test: TestFn = anyTest; test.before(async t => (t.context = await makeWalletFactoryContext(t))); test.after.always(t => t.context.shutdown?.()); -test('stakeBld', async t => { +test.serial('stakeBld', async t => { const { agoricNamesRemotes, buildProposal, @@ -86,3 +87,37 @@ test('stakeBld', async t => { }, }); }); + +test.serial('stakeAtom', async t => { + const { + buildProposal, + evalProposal, + runUtils: { EV }, + } = t.context; + // TODO move into a vm-config for u15 + await evalProposal( + buildProposal('@agoric/builders/scripts/vats/init-network.js'), + ); + await evalProposal( + buildProposal('@agoric/builders/scripts/vats/init-orchestration.js'), + ); + await evalProposal( + buildProposal('@agoric/builders/scripts/orchestration/init-stakeAtom.js'), + ); + + const agoricNames = await EV.vat('bootstrap').consumeItem('agoricNames'); + const instance = await EV(agoricNames).lookup('instance', 'stakeAtom'); + t.truthy(instance, 'stakeAtom instance is available'); + + const zoe = await EV.vat('bootstrap').consumeItem('zoe'); + const publicFacet = await EV(zoe).getPublicFacet(instance); + t.truthy(publicFacet, 'stakeAtom publicFacet is available'); + + const account = await EV(publicFacet).createAccount(); + t.log('account', account); + t.truthy(account, 'createAccount returns an account on ATOM connection'); + t.truthy( + matches(account, M.remotable('ChainAccount')), + 'account is a remotable', + ); +}); diff --git a/packages/boot/test/bootstrapTests/test-vat-orchestration.ts b/packages/boot/test/bootstrapTests/test-vat-orchestration.ts new file mode 100644 index 00000000000..e89e48c8c30 --- /dev/null +++ b/packages/boot/test/bootstrapTests/test-vat-orchestration.ts @@ -0,0 +1,89 @@ +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import type { ExecutionContext, TestFn } from 'ava'; +import { M, matches } from '@endo/patterns'; +import { makeWalletFactoryContext } from './walletFactory.ts'; + +const makeTestContext = async (t: ExecutionContext) => + makeWalletFactoryContext(t); + +type DefaultTestContext = Awaited>; +const test: TestFn = anyTest; + +test.before(async t => { + t.context = await makeTestContext(t); + + async function setupDeps() { + const { + buildProposal, + evalProposal, + runUtils: { EV }, + } = t.context; + /** ensure network, ibc, and orchestration are available */ + await evalProposal( + buildProposal('@agoric/builders/scripts/vats/init-network.js'), + ); + await evalProposal( + buildProposal('@agoric/builders/scripts/vats/init-orchestration.js'), + ); + const vatStore = await EV.vat('bootstrap').consumeItem('vatStore'); + t.true(await EV(vatStore).has('ibc'), 'ibc'); + t.true(await EV(vatStore).has('network'), 'network'); + t.true(await EV(vatStore).has('orchestration'), 'orchestration'); + } + + await setupDeps(); +}); + +test.after.always(t => t.context.shutdown?.()); + +test('createAccount returns an ICA connection', async t => { + const { + runUtils: { EV }, + } = t.context; + + const orchestration = await EV.vat('bootstrap').consumeItem('orchestration'); + + const account = await EV(orchestration).createAccount( + 'connection-0', + 'connection-0', + ); + t.truthy(account, 'createAccount returns an account'); + t.truthy( + matches(account, M.remotable('ChainAccount')), + 'account is a remotable', + ); + const [remoteAddress, localAddress, accountAddress, port] = await Promise.all( + [ + EV(account).getRemoteAddress(), + EV(account).getLocalAddress(), + EV(account).getAccountAddress(), + EV(account).getPort(), + ], + ); + t.regex(remoteAddress, /icahost/); + t.regex(localAddress, /icacontroller/); + t.regex(accountAddress, /osmo1/); + t.truthy(matches(port, M.remotable('Port'))); + t.log('ICA Account Addresses', { + remoteAddress, + localAddress, + accountAddress, + }); +}); + +test('ICA connection can be closed', async t => { + const { + runUtils: { EV }, + } = t.context; + + const orchestration = await EV.vat('bootstrap').consumeItem('orchestration'); + + const account = await EV(orchestration).createAccount( + 'connection-0', + 'connection-0', + ); + t.truthy(account, 'createAccount returns an account'); + + const res = await EV(account).close(); + t.is(res, 'Connection closed'); +}); diff --git a/packages/boot/tools/ibc/README.md b/packages/boot/tools/ibc/README.md new file mode 100644 index 00000000000..23119e8acf1 --- /dev/null +++ b/packages/boot/tools/ibc/README.md @@ -0,0 +1,113 @@ + +# ICS-27 Interchain Accounts + +Sequence diagrams for Interchain Accounts based on [ics-027-interchain-accounts/README.md#9ffb1d2](https://github.com/cosmos/ibc/blob/9ffb1d26d3018b6efda546189ec7e43d56d23da3/spec/app/ics-027-interchain-accounts/README.md). + +### IBC Connection Creation + +_Prerequisite to creating a channel._ + +```mermaid +sequenceDiagram + participant CC as Chain A + participant R as Relayer + participant HC as Chain B + + CC->>R: ConnOpenInit(ClientId, CounterpartyClientId, Version) + R->>HC: ConnOpenTry(ClientId, CounterpartyClientId, Version, CounterpartyVersion) + HC-->>R: ConnOpenTry(Version) + R->>CC: ConnOpenAck(ConnectionId, Version) + CC-->>R: ConnOpenAck + R->>HC: ConnOpenConfirm(ConnectionId) +``` + +Mock Testing: + - on `ConnOpenInit`, return `ConnOpenAck` + +### ICA Channel Creation + +```mermaid +sequenceDiagram + participant CC as Controller Chain + participant R as Relayer + participant HC as Host Chain + + CC->>CC: RegisterInterchainAccount() + CC->>R: ChannelOpenInit(Order, ConnectionHops, PortIdentifier, ChannelIdentifier, Version) + R->>HC: ChannelOpenTry(Order, ConnectionHops, PortIdentifier, ChannelIdentifier, Version) + HC->>HC: RegisterInterchainAccount(CounterpartyPortIdentifier) + HC-->>R: ChannelOpenTry(Version) + R->>CC: ChannelOpenAck(PortIdentifier, ChannelIdentifier, CounterpartyVersion) + CC->>CC: SetInterchainAccountAddress(PortID, Address) + CC->>CC: SetActiveChannelID(PortIdentifier, ConnectionID, ChannelIdentifier) + CC-->>R: ChannelOpenAck + R->>HC: ChannelOpenConfirm(PortIdentifier, ChannelIdentifier) + HC->>HC: SetActiveChannelID(CounterpartyPortIdentifier, ConnectionID, ChannelIdentifier) +``` + +Mock Testing: + - on `ChannelOpenInit`, return `ChannelOpenAck` + +### ICA Transaction + +```mermaid +sequenceDiagram + participant CC as Controller Chain + participant R as Relayer + participant HC as Host Chain + + CC->>R: SendPacket(PacketData) + R->>HC: onRecvPacket(Packet) + HC->>HC: Deserialize and validate packet data + alt Successful deserialization and validation + HC->>HC: AuthenticateTx(msgs) + HC->>HC: ExecuteTx(msgs) + HC-->>R: Acknowledgement(result) + else Error + HC-->>R: ErrorAcknowledgement(error) + end + R->>CC: onAcknowledgePacket(Packet, Acknowledgement) + CC->>CC: Handle acknowledgement +``` + +Mock Testing: + - on `SendPacket`, return `onAcknowledgePacket` + + +### ICA Channel Reactivation + +```mermaid +sequenceDiagram + participant CC as Controller Chain + participant R as Relayer + participant HC as Host Chain + + Note over CC,HC: Existing ICA channel expires or closes + CC->>CC: Channel closes or times out + CC->>R: ChannelOpenInit(Order, ConnectionHops, PortIdentifier, ChannelIdentifier, Version) + Note right of CC: Reusing the same PortIdentifier and ConnectionID + R->>HC: ChannelOpenTry(Order, ConnectionHops, PortIdentifier, ChannelIdentifier, Version) + HC->>HC: Verify PortIdentifier and ConnectionID match the previous active channel + HC->>HC: Verify the channel is in CLOSED state + HC->>HC: Verify the new channel has the same order and version as the previous channel + HC-->>R: ChannelOpenTry(Version) + R->>CC: ChannelOpenAck(PortIdentifier, ChannelIdentifier, CounterpartyVersion) + CC->>CC: Verify the CounterpartyVersion matches the previous active channel + CC->>CC: SetActiveChannelID(PortIdentifier, ConnectionID, ChannelIdentifier) + CC-->>R: ChannelOpenAck + R->>HC: ChannelOpenConfirm(PortIdentifier, ChannelIdentifier) + HC->>HC: SetActiveChannelID(CounterpartyPortIdentifier, ConnectionID, ChannelIdentifier) +``` + +Mock Testing: + - on `ChannelOpenInit`, return `ChannelOpenAck` + - n.b. testing should verify `SetActiveChannelID` flow on CC side + + +### Testing Mocks Summary + + - IBC Connection Creation: on ConnOpenInit, return ConnOpenAck + - ICA Channel Creation: on ChannelOpenInit, return ChannelOpenAck + - ICA Transaction: on SendPacket, return onAcknowledgePacket + - ICA Channel Reactivation: on ChannelOpenInit, return ChannelOpenAck + - testing should verify SetActiveChannelID flow on CC side diff --git a/packages/boot/tools/ibc/mocks.js b/packages/boot/tools/ibc/mocks.js new file mode 100644 index 00000000000..9517d0c63de --- /dev/null +++ b/packages/boot/tools/ibc/mocks.js @@ -0,0 +1,28 @@ +/** + * mock bridgeInbound events for ICA (ICS-27) flow + * see [./ics27-1.md](./ics27-1.md) for more details + * + * sourced from [@agoric/vats/test/test-network.js](https://github.com/Agoric/agoric-sdk/blob/4601a1ab65a0c36fbfbfcc1fa59e83ee10a1c996/packages/vats/test/test-network.js) + * and end e2e testing with sim chains (v16: IBC fromBridge logs) + */ +export const icaMocks = { + startChannelOpenInit: { + // ICA Channel Creation + channelOpenAck: obj => ({ + type: 'IBC_EVENT', + blockHeight: 99, + blockTime: 1711571357, + event: 'channelOpenAck', + portID: obj.packet.source_port, + channelID: 'channel-0', + counterparty: { + port_id: obj.packet.destination_port, + channel_id: 'channel-1', + }, + counterpartyVersion: + '{"version":"ics27-1","controllerConnectionId":"connection-0","hostConnectionId":"connection-0","address":"osmo1234","encoding":"proto3","txType":"sdk_multi_msg"}', + connectionHops: obj.hops, + }), + // XXX channelOpenAckFailure + }, +}; diff --git a/packages/boot/tools/supports.ts b/packages/boot/tools/supports.ts index bf38c1bd5f9..8957d7c3011 100644 --- a/packages/boot/tools/supports.ts +++ b/packages/boot/tools/supports.ts @@ -28,6 +28,7 @@ import type { ExecutionContext as AvaT } from 'ava'; import { makeRunUtils } from '@agoric/swingset-vat/tools/run-utils.js'; import type { CoreEvalSDKType } from '@agoric/cosmic-proto/swingset/swingset.js'; import type { BridgeHandler } from '@agoric/vats'; +import { icaMocks } from './ibc/mocks.js'; const trace = makeTracer('BSTSupport', false); @@ -289,6 +290,7 @@ export const makeSwingsetTestKit = async ( const outboundMessages = new Map(); + let inbound; /** * Mock the bridge outbound handler. The real one is implemented in Golang so * changes there will sometimes require changes here. @@ -356,6 +358,21 @@ export const makeSwingsetTestKit = async ( } case BridgeId.CORE: case BridgeId.DIBC: + switch (obj.type) { + case 'IBC_METHOD': + switch (obj.method) { + case 'startChannelOpenInit': + inbound( + BridgeId.DIBC, + icaMocks.startChannelOpenInit.channelOpenAck(obj), + ); + return undefined; + default: + return undefined; + } + default: + return undefined; + } case BridgeId.PROVISION: case BridgeId.PROVISION_SMART_WALLET: case BridgeId.VTRANSFER: @@ -390,7 +407,7 @@ export const makeSwingsetTestKit = async ( }, }); } - const { controller, timer } = await buildSwingset( + const { controller, timer, bridgeInbound } = await buildSwingset( new Map(), bridgeOutbound, kernelStorage, @@ -406,6 +423,8 @@ export const makeSwingsetTestKit = async ( debugVats, }, ); + inbound = bridgeInbound; + console.timeLog('makeBaseSwingsetTestKit', 'buildSwingset'); const runUtils = makeRunUtils(controller); @@ -491,6 +510,7 @@ export const makeSwingsetTestKit = async ( advanceTimeBy, advanceTimeTo, buildProposal, + bridgeInbound, controller, evalProposal, getCrankNumber, diff --git a/packages/builders/scripts/orchestration/init-stakeAtom.js b/packages/builders/scripts/orchestration/init-stakeAtom.js new file mode 100644 index 00000000000..306834f34ab --- /dev/null +++ b/packages/builders/scripts/orchestration/init-stakeAtom.js @@ -0,0 +1,34 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ +export const defaultProposalBuilder = async ( + { publishRef, install }, + options = {}, +) => { + const { + hostConnectionId = 'connection-1', + controllerConnectionId = 'connection-0', + } = options; + return harden({ + sourceSpec: '@agoric/orchestration/src/proposals/start-stakeAtom.js', + getManifestCall: [ + 'getManifestForStakeAtom', + { + installKeys: { + stakeAtom: publishRef( + install( + '@agoric/orchestration/src/contracts/stakeAtom.contract.js', + ), + ), + }, + hostConnectionId, + controllerConnectionId, + }, + ], + }); +}; + +export default async (homeP, endowments) => { + const { writeCoreProposal } = await makeHelpers(homeP, endowments); + await writeCoreProposal('start-stakeAtom', defaultProposalBuilder); +}; diff --git a/packages/builders/scripts/vats/init-orchestration.js b/packages/builders/scripts/vats/init-orchestration.js new file mode 100644 index 00000000000..371e9976a1b --- /dev/null +++ b/packages/builders/scripts/vats/init-orchestration.js @@ -0,0 +1,20 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ +export const defaultProposalBuilder = async ({ publishRef, install }) => + harden({ + sourceSpec: '@agoric/orchestration/src/proposals/orchestration-proposal.js', + getManifestCall: [ + 'getManifestForOrchestration', + { + orchestrationRef: publishRef( + install('@agoric/orchestration/src/vat-orchestration.js'), + ), + }, + ], + }); + +export default async (homeP, endowments) => { + const { writeCoreProposal } = await makeHelpers(homeP, endowments); + await writeCoreProposal('gov-orchestration', defaultProposalBuilder); +}; diff --git a/packages/orchestration/index.js b/packages/orchestration/index.js index 7036eb8397a..b758b04c2dd 100644 --- a/packages/orchestration/index.js +++ b/packages/orchestration/index.js @@ -1,2 +1,4 @@ // eslint-disable-next-line import/export export * from './src/types.js'; +export * from './src/utils/address.js'; +export * from './src/orchestration.js'; diff --git a/packages/orchestration/package.json b/packages/orchestration/package.json index a45996bf4e2..69e1790eb4d 100644 --- a/packages/orchestration/package.json +++ b/packages/orchestration/package.json @@ -29,9 +29,12 @@ }, "homepage": "https://github.com/Agoric/agoric-sdk#readme", "dependencies": { + "@agoric/assert": "^0.6.0", "@agoric/ertp": "^0.16.2", "@agoric/internal": "^0.3.2", + "@agoric/network": "^0.1.0", "@agoric/notifier": "^0.6.2", + "@agoric/store": "^0.9.2", "@agoric/vat-data": "^0.5.2", "@agoric/vats": "^0.15.1", "@agoric/zoe": "^0.26.2", @@ -41,9 +44,10 @@ "@endo/patterns": "^1.3.0" }, "devDependencies": { + "@endo/ses-ava": "^1.2.0", "@cosmjs/amino": "^0.32.3", "@cosmjs/proto-signing": "^0.32.3", - "ava": "^5.3.0", + "ava": "^5.3.1", "cosmjs-types": "^0.9.0" }, "ava": { diff --git a/packages/orchestration/src/contracts/stakeAtom.contract.js b/packages/orchestration/src/contracts/stakeAtom.contract.js new file mode 100644 index 00000000000..f730c54451b --- /dev/null +++ b/packages/orchestration/src/contracts/stakeAtom.contract.js @@ -0,0 +1,52 @@ +// @ts-check +/** + * @file Example contract that uses orchestration + */ + +import { makeDurableZone } from '@agoric/zone/durable.js'; +import { V as E } from '@agoric/vat-data/vow.js'; +import { M } from '@endo/patterns'; + +/** + * @import * as orchestration from '../types' + * @import * as vatData from '@agoric/vat-data' + */ + +/** + * @typedef {{ + * hostConnectionId: orchestration.ConnectionId; + * controllerConnectionId: orchestration.ConnectionId; + * }} StakeAtomTerms + */ + +/** + * + * @param {ZCF} zcf + * @param {{ + * orchestration: orchestration.Orchestration; + * }} privateArgs + * @param {vatData.Baggage} baggage + */ +export const start = async (zcf, privateArgs, baggage) => { + const { hostConnectionId, controllerConnectionId } = zcf.getTerms(); + const { orchestration } = privateArgs; + + const zone = makeDurableZone(baggage); + + const publicFacet = zone.exo( + 'StakeAtom', + M.interface('StakeAtomI', { + createAccount: M.callWhen().returns(M.remotable('ChainAccount')), + }), + { + async createAccount() { + return E(orchestration).createAccount( + hostConnectionId, + controllerConnectionId, + ); + }, + }, + ); + + return { publicFacet }; +}; diff --git a/packages/orchestration/src/orchestration.js b/packages/orchestration/src/orchestration.js new file mode 100644 index 00000000000..37f4d83ac22 --- /dev/null +++ b/packages/orchestration/src/orchestration.js @@ -0,0 +1,240 @@ +// @ts-check +/** @file Orchestration service */ +import { NonNullish } from '@agoric/assert'; +import { makeTracer } from '@agoric/internal'; +import { V as E } from '@agoric/vat-data/vow.js'; +import { M } from '@endo/patterns'; +import { makeICAConnectionAddress, parseAddress } from './utils/address.js'; +import '@agoric/network/exported.js'; + +/** + * @import { ConnectionId } from './types'; + * @import { Zone } from '@agoric/base-zone'; + */ + +const { Fail, bare } = assert; +const trace = makeTracer('Orchestration'); + +// TODO improve me +/** @typedef {string} ChainAddress */ + +/** + * @typedef {object} OrchestrationPowers + * @property {ERef< + * import('@agoric/orchestration/src/types').AttenuatedNetwork + * >} network + */ + +/** + * PowerStore is used so additional powers can be added on upgrade. See + * [#7337](https://github.com/Agoric/agoric-sdk/issues/7337) for tracking on Exo + * state migrations. + * + * @typedef {MapStore< + * keyof OrchestrationPowers, + * OrchestrationPowers[keyof OrchestrationPowers] + * >} PowerStore + */ + +/** + * @template {keyof OrchestrationPowers} K + * @param {PowerStore} powers + * @param {K} name + */ +const getPower = (powers, name) => { + powers.has(name) || Fail`need powers.${bare(name)} for this method`; + return /** @type {OrchestrationPowers[K]} */ (powers.get(name)); +}; + +export const ChainAccountI = M.interface('ChainAccount', { + getAccountAddress: M.call().returns(M.string()), + getLocalAddress: M.call().returns(M.string()), + getRemoteAddress: M.call().returns(M.string()), + getPort: M.call().returns(M.remotable('Port')), + close: M.callWhen().returns(M.string()), +}); + +export const ConnectionHandlerI = M.interface('ConnectionHandler', { + onOpen: M.callWhen(M.any(), M.string(), M.string(), M.any()).returns(M.any()), + onClose: M.callWhen(M.any(), M.any(), M.any()).returns(M.any()), + onReceive: M.callWhen(M.any(), M.string()).returns(M.any()), +}); + +/** @param {Zone} zone */ +const prepareChainAccount = zone => + zone.exoClassKit( + 'ChainAccount', + { account: ChainAccountI, connectionHandler: ConnectionHandlerI }, + /** + * @param {Port} port + * @param {string} requestedRemoteAddress + */ + (port, requestedRemoteAddress) => + /** + * @type {{ + * port: Port; + * connection: Connection | undefined; + * localAddress: string | undefined; + * requestedRemoteAddress: string; + * remoteAddress: string | undefined; + * accountAddress: ChainAddress | undefined; + * }} + */ ( + harden({ + port, + connection: undefined, + requestedRemoteAddress, + remoteAddress: undefined, + accountAddress: undefined, + localAddress: undefined, + }) + ), + { + account: { + getAccountAddress() { + return NonNullish( + this.state.accountAddress, + 'Error parsing account address from remote address', + ); + }, + getLocalAddress() { + return NonNullish( + this.state.localAddress, + 'local address not available', + ); + }, + getRemoteAddress() { + return NonNullish( + this.state.remoteAddress, + 'remote address not available', + ); + }, + getPort() { + return this.state.port; + }, + async close() { + /// XXX what should the behavior be here? and `onClose`? + // - retrieve assets? + // - revoke the port? + const { connection } = this.state; + if (!connection) throw Fail`connection not available`; + await null; + try { + await E(connection).close(); + } catch (e) { + throw Fail`Failed to close connection: ${e}`; + } + return 'Connection closed'; + }, + }, + connectionHandler: { + /** + * @param {Connection} connection + * @param {string} localAddr + * @param {string} remoteAddr + */ + async onOpen(connection, localAddr, remoteAddr) { + trace(`ICA Channel Opened for ${localAddr} at ${remoteAddr}`); + this.state.connection = connection; + this.state.remoteAddress = remoteAddr; + this.state.localAddress = localAddr; + // XXX parseAddress currently throws, should it return '' instead? + this.state.accountAddress = parseAddress(remoteAddr); + }, + async onClose(_connection, reason) { + trace(`ICA Channel closed. Reason: ${reason}`); + // XXX handle connection closing + // XXX is there a scenario where a connection will unexpectedly close? _I think yes_ + }, + async onReceive(connection, bytes) { + trace(`ICA Channel onReceive`, connection, bytes); + return ''; + }, + }, + }, + ); + +export const OrchestrationI = M.interface('Orchestration', { + createAccount: M.callWhen(M.string(), M.string()).returns( + M.remotable('ChainAccount'), + ), +}); + +/** + * @param {Zone} zone + * @param {ReturnType} createChainAccount + */ +const prepareOrchestration = (zone, createChainAccount) => + zone.exoClassKit( + 'Orchestration', + { + self: M.interface('OrchestrationSelf', { + bindPort: M.callWhen().returns(M.remotable()), + }), + public: OrchestrationI, + }, + /** @param {Partial} [initialPowers] */ + initialPowers => { + /** @type {PowerStore} */ + const powers = zone.detached().mapStore('PowerStore'); + if (initialPowers) { + for (const [name, power] of Object.entries(initialPowers)) { + powers.init(/** @type {keyof OrchestrationPowers} */ (name), power); + } + } + return { powers, icaControllerNonce: 0 }; + }, + { + self: { + async bindPort() { + const network = getPower(this.state.powers, 'network'); + const port = await E(network) + .bind(`/ibc-port/icacontroller-${this.state.icaControllerNonce}`) + .catch(e => Fail`Failed to bind port: ${e}`); + this.state.icaControllerNonce += 1; + return port; + }, + }, + public: { + /** + * @param {ConnectionId} hostConnectionId + * the counterparty connection_id + * @param {ConnectionId} controllerConnectionId + * self connection_id + * @returns {Promise} + */ + async createAccount(hostConnectionId, controllerConnectionId) { + const port = await this.facets.self.bindPort(); + + const remoteConnAddr = makeICAConnectionAddress( + hostConnectionId, + controllerConnectionId, + ); + const chainAccount = createChainAccount(port, remoteConnAddr); + + // await so we do not return a ChainAccount before it successfully instantiates + await E(port) + .connect(remoteConnAddr, chainAccount.connectionHandler) + // XXX if we fail, should we close the port (if it was created in this flow)? + .catch(e => Fail`Failed to create ICA connection: ${bare(e)}`); + + return chainAccount.account; + }, + }, + }, + ); + +/** @param {Zone} zone */ +export const prepareOrchestrationTools = zone => { + const createChainAccount = prepareChainAccount(zone); + const makeOrchestration = prepareOrchestration(zone, createChainAccount); + + return harden({ makeOrchestration }); +}; +harden(prepareOrchestrationTools); + +/** @typedef {ReturnType>} ChainAccountKit */ +/** @typedef {ChainAccountKit['account']} ChainAccount */ +/** @typedef {ReturnType} OrchestrationTools */ +/** @typedef {ReturnType} OrchestrationKit */ +/** @typedef {OrchestrationKit['public']} Orchestration */ diff --git a/packages/orchestration/src/proposals/orchestration-proposal.js b/packages/orchestration/src/proposals/orchestration-proposal.js new file mode 100644 index 00000000000..cb81d47b95d --- /dev/null +++ b/packages/orchestration/src/proposals/orchestration-proposal.js @@ -0,0 +1,99 @@ +// @ts-check +import { V as E } from '@agoric/vat-data/vow.js'; +import { Far } from '@endo/far'; + +/** @import { AttenuatedNetwork, Orchestration, OrchestrationVat } from '../types' */ + +/** + * @param {BootstrapPowers & { + * consume: { + * loadCriticalVat: VatLoader; + * networkVat: NetworkVat; + * }; + * produce: { + * orchestration: Producer; + * orchestrationKit: Producer; + * orchestrationVat: Producer; + * }; + * }} powers + * @param {object} options + * @param {{ orchestrationRef: VatSourceRef }} options.options + * + * @typedef {{ + * orchestration: ERef; + * }} OrchestrationVats + */ +export const setupOrchestrationVat = async ( + { + consume: { loadCriticalVat, networkVat }, + produce: { + orchestrationVat, + orchestration, + orchestrationKit: orchestrationKitP, + }, + }, + options, +) => { + const { orchestrationRef } = options.options; + /** @type {OrchestrationVats} */ + const vats = { + orchestration: E(loadCriticalVat)('orchestration', orchestrationRef), + }; + // don't proceed if loadCriticalVat fails + await Promise.all(Object.values(vats)); + + orchestrationVat.reset(); + orchestrationVat.resolve(vats.orchestration); + + await networkVat; + /** @type {AttenuatedNetwork} */ + const network = Far('Attenuated Network', { + /** @param {string} localAddr */ + async bind(localAddr) { + return E(networkVat).bind(localAddr); + }, + }); + + const newOrchestrationKit = await E(vats.orchestration).makeOrchestration({ + network, + }); + + orchestration.reset(); + orchestration.resolve(newOrchestrationKit.public); + orchestrationKitP.reset(); + orchestrationKitP.resolve(newOrchestrationKit); +}; + +/** + * @param {BootstrapPowers & { + * consume: { + * orchestration: Orchestration; + * }; + * }} powers + * @param {object} _options + */ +export const addOrchestrationToClient = async ( + { consume: { client, orchestration } }, + _options, +) => { + return E(client).assignBundle([_a => ({ orchestration })]); +}; + +export const getManifestForOrchestration = (_powers, { orchestrationRef }) => ({ + manifest: { + [setupOrchestrationVat.name]: { + consume: { + loadCriticalVat: true, + networkVat: true, + }, + produce: { + orchestration: 'orchestration', + orchestrationKit: 'orchestration', + orchestrationVat: 'orchestration', + }, + }, + }, + options: { + orchestrationRef, + }, +}); diff --git a/packages/orchestration/src/proposals/start-stakeAtom.js b/packages/orchestration/src/proposals/start-stakeAtom.js new file mode 100644 index 00000000000..46dbb9c37ee --- /dev/null +++ b/packages/orchestration/src/proposals/start-stakeAtom.js @@ -0,0 +1,68 @@ +// @ts-check +import { makeTracer } from '@agoric/internal'; +import { E } from '@endo/far'; + +const trace = makeTracer('StartStakeAtom', true); + +/** + * @param {BootstrapPowers & { installation: {consume: {stakeAtom: Installation}}}} powers + * @param {{options: import('../contracts/stakeAtom.contract.js').StakeAtomTerms}} options + */ +export const startStakeAtom = async ( + { + consume: { orchestration, startUpgradable }, + installation: { + consume: { stakeAtom }, + }, + instance: { + produce: { stakeAtom: produceInstance }, + }, + }, + { options: { hostConnectionId, controllerConnectionId } }, +) => { + trace('startStakeAtom', { hostConnectionId, controllerConnectionId }); + await null; + + /** @type {StartUpgradableOpts} */ + const startOpts = { + label: 'stakeAtom', + installation: stakeAtom, + terms: { + hostConnectionId, + controllerConnectionId, + }, + privateArgs: { + orchestration: await orchestration, + }, + }; + + const { instance } = await E(startUpgradable)(startOpts); + produceInstance.resolve(instance); +}; +harden(startStakeAtom); + +export const getManifestForStakeAtom = ( + { restoreRef }, + { installKeys, ...options }, +) => { + return { + manifest: { + [startStakeAtom.name]: { + consume: { + orchestration: true, + startUpgradable: true, + }, + installation: { + consume: { stakeAtom: true }, + }, + instance: { + produce: { stakeAtom: true }, + }, + }, + }, + installations: { + stakeAtom: restoreRef(installKeys.stakeAtom), + }, + options, + }; +}; diff --git a/packages/orchestration/src/types.d.ts b/packages/orchestration/src/types.d.ts new file mode 100644 index 00000000000..d9f0991a639 --- /dev/null +++ b/packages/orchestration/src/types.d.ts @@ -0,0 +1,8 @@ +import type { RouterProtocol } from '@agoric/network/src/router'; + +export type ConnectionId = `connection-${number}`; + +export type AttenuatedNetwork = Pick; + +export type * from './orchestration.js'; +export type * from './vat-orchestration.js'; diff --git a/packages/orchestration/src/types.js b/packages/orchestration/src/types.js index 0ccc8192955..0ef9dda392a 100644 --- a/packages/orchestration/src/types.js +++ b/packages/orchestration/src/types.js @@ -1,4 +1,5 @@ // @ts-check +import '@agoric/network/exported.js'; import '@agoric/vats/exported.js'; import '@agoric/zoe/exported.js'; diff --git a/packages/orchestration/src/utils/address.js b/packages/orchestration/src/utils/address.js new file mode 100644 index 00000000000..43493af989b --- /dev/null +++ b/packages/orchestration/src/utils/address.js @@ -0,0 +1,54 @@ +// @ts-check +import { Fail } from '@agoric/assert'; + +/** @import { ConnectionId } from '../types'; */ + +/** + * @param {ConnectionId} hostConnectionId Counterpart Connection ID + * @param {ConnectionId} controllerConnectionId Self Connection ID + * @param {object} [opts] + * @param {string} [opts.encoding] - message encoding format for the channel. default is `proto3` + * @param {'ordered' | 'unordered'} [opts.ordering] - channel ordering. currently only `ordered` is supported for ics27-1 + * @param {string} [opts.txType] - default is `sdk_multi_msg` + * @param {string} [opts.version] - default is `ics27-1` + */ +export const makeICAConnectionAddress = ( + hostConnectionId, + controllerConnectionId, + { + version = 'ics27-1', + encoding = 'proto3', + ordering = 'ordered', + txType = 'sdk_multi_msg', + } = {}, +) => { + hostConnectionId || Fail`hostConnectionId is required`; + controllerConnectionId || Fail`controllerConnectionId is required`; + const connString = JSON.stringify({ + version, + controllerConnectionId, + hostConnectionId, + address: '', // will be provided by the counterparty after channelOpenAck + encoding, + txType, + }); + return `/ibc-hop/${controllerConnectionId}/ibc-port/icahost/${ordering}/${connString}`; +}; + +/** + * Parse a chain address from a remote address string. + * Assumes the address string is in a JSON format and contains an "address" field. + * This function is designed to be safe against malformed inputs and unexpected data types, and will return `undefined` in those cases. + * @param {string} remoteAddressString - remote address string, including version + * @returns {string | undefined} returns undefined on error + */ +export const parseAddress = remoteAddressString => { + try { + // Extract JSON version string assuming it's always surrounded by {} + const jsonStr = remoteAddressString?.match(/{.*?}/)?.[0]; + const jsonObj = jsonStr ? JSON.parse(jsonStr) : undefined; + return jsonObj?.address ?? undefined; + } catch (error) { + return undefined; + } +}; diff --git a/packages/orchestration/src/vat-orchestration.js b/packages/orchestration/src/vat-orchestration.js new file mode 100644 index 00000000000..1b61029ffd5 --- /dev/null +++ b/packages/orchestration/src/vat-orchestration.js @@ -0,0 +1,22 @@ +// @ts-check +import { Far } from '@endo/far'; +import { makeDurableZone } from '@agoric/zone/durable.js'; +import { prepareOrchestrationTools } from './orchestration.js'; + +/** @import { OrchestrationPowers } from './types.js' */ + +export const buildRootObject = (_vatPowers, _args, baggage) => { + const zone = makeDurableZone(baggage); + const { makeOrchestration } = prepareOrchestrationTools( + zone.subZone('orchestration'), + ); + + return Far('OrchestrationVat', { + /** @param {Partial} [initialPowers] */ + makeOrchestration(initialPowers = {}) { + return makeOrchestration(initialPowers); + }, + }); +}; + +/** @typedef {ReturnType} OrchestrationVat */ diff --git a/packages/orchestration/test/utils/address.test.js b/packages/orchestration/test/utils/address.test.js new file mode 100644 index 00000000000..9f30dfeb221 --- /dev/null +++ b/packages/orchestration/test/utils/address.test.js @@ -0,0 +1,69 @@ +import test from '@endo/ses-ava/prepare-endo.js'; +import { + makeICAConnectionAddress, + parseAddress, +} from '../../src/utils/address.js'; + +test('makeICAConnectionAddress', t => { + t.throws(() => makeICAConnectionAddress(), { + message: 'hostConnectionId is required', + }); + t.throws(() => makeICAConnectionAddress('connection-0'), { + message: 'controllerConnectionId is required', + }); + t.is( + makeICAConnectionAddress('connection-1', 'connection-0'), + '/ibc-hop/connection-0/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-0","hostConnectionId":"connection-1","address":"","encoding":"proto3","txType":"sdk_multi_msg"}', + 'returns connection string when controllerConnectionId and hostConnectionId are provided', + ); + t.is( + makeICAConnectionAddress('connection-1', 'connection-0', { + version: 'ics27-0', + }), + '/ibc-hop/connection-0/ibc-port/icahost/ordered/{"version":"ics27-0","controllerConnectionId":"connection-0","hostConnectionId":"connection-1","address":"","encoding":"proto3","txType":"sdk_multi_msg"}', + 'accepts custom version', + ); + t.is( + makeICAConnectionAddress('connection-1', 'connection-0', { + encoding: 'test', + }), + '/ibc-hop/connection-0/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-0","hostConnectionId":"connection-1","address":"","encoding":"test","txType":"sdk_multi_msg"}', + 'accepts custom encoding', + ); + t.is( + makeICAConnectionAddress('connection-1', 'connection-0', { + ordering: 'unordered', + }), + '/ibc-hop/connection-0/ibc-port/icahost/unordered/{"version":"ics27-1","controllerConnectionId":"connection-0","hostConnectionId":"connection-1","address":"","encoding":"proto3","txType":"sdk_multi_msg"}', + 'accepts custom ordering', + ); +}); + +test('parseAddress', t => { + t.is( + parseAddress('/ibc-hop/'), + undefined, + 'returns undefined when version json is missing', + ); + t.is( + parseAddress( + '/ibc-hop/connection-0/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-0","hostConnectionId":"connection-1","address":"","encoding":"proto3","txType":"sdk_multi_msg"}', + ), + '', + 'returns empty string if address is an empty string', + ); + t.is( + parseAddress( + '/ibc-hop/connection-0/ibc-port/icahost/ordered/{"version":"ics27-1","controllerConnectionId":"connection-0","hostConnectionId":"connection-1","address":"osmo1m30khedzqy9msu4502u74ugmep30v69pzee370jkas57xhmjfgjqe67ayq","encoding":"proto3","txType":"sdk_multi_msg"}', + ), + 'osmo1m30khedzqy9msu4502u74ugmep30v69pzee370jkas57xhmjfgjqe67ayq', + 'returns address', + ); + t.is( + parseAddress( + '/ibc-hop/connection-0/ibc-port/icahost/ordered/{"version":"ics27-1","controller_connection_id":"connection-0","host_connection_id":"connection-1","address":"osmo1m30khedzqy9msu4502u74ugmep30v69pzee370jkas57xhmjfgjqe67ayq","encoding":"proto3","tx_type":"sdk_multi_msg"}/ibc-channel/channel-1', + ), + 'osmo1m30khedzqy9msu4502u74ugmep30v69pzee370jkas57xhmjfgjqe67ayq', + 'returns address when localAddrr is appended to version string', + ); +}); diff --git a/packages/vats/src/core/types-ambient.d.ts b/packages/vats/src/core/types-ambient.d.ts index 65d4e18edc7..f3bee5d95ed 100644 --- a/packages/vats/src/core/types-ambient.d.ts +++ b/packages/vats/src/core/types-ambient.d.ts @@ -179,6 +179,7 @@ type WellKnownName = { | 'reserve' | 'psm' | 'scaledPriceAuthority' + | 'stakeAtom' // test contract | 'stakeBld' // test contract | 'econCommitteeCharter' | 'priceAggregator'; @@ -193,6 +194,7 @@ type WellKnownName = { | 'provisionPool' | 'reserve' | 'reserveGovernor' + | 'stakeAtom' // test contract | 'stakeBld' // test contract | 'Pegasus'; oracleBrand: 'USD'; @@ -341,9 +343,12 @@ type ChainBootstrapSpaceT = { * Vault Factory. ONLY FOR DISASTER RECOVERY */ instancePrivateArgs: Map; + localchain: import('@agoric/vats/src/localchain.js').LocalChain; mints?: MintsVat; namesByAddress: import('../types.js').NameHub; namesByAddressAdmin: import('../types.js').NamesByAddressAdmin; + networkVat: NetworkVat; + orchestration: import('@agoric/orchestration/src/orchestration.js').Orchestration; pegasusConnections: import('@agoric/vats').NameHubKit; pegasusConnectionsAdmin: import('@agoric/vats').NameAdmin; priceAuthorityVat: Awaited; diff --git a/packages/vats/src/core/utils.js b/packages/vats/src/core/utils.js index 25c364b5e46..4256abf1d13 100644 --- a/packages/vats/src/core/utils.js +++ b/packages/vats/src/core/utils.js @@ -56,6 +56,7 @@ export const agoricNamesReserved = harden({ econCommitteeCharter: 'Charter for Econ Governance questions', priceAggregator: 'simple price aggregator', scaledPriceAuthority: 'scaled price authority', + stakeAtom: 'example ATOM staking contract', stakeBld: 'example BLD staking contract', }, instance: { @@ -70,6 +71,7 @@ export const agoricNamesReserved = harden({ econCommitteeCharter: 'Charter for Econ Governance questions', provisionPool: 'Account Provision Pool', walletFactory: 'Smart Wallet Factory', + stakeAtom: 'example ATOM staking contract', stakeBld: 'example BLD staking contract', }, oracleBrand: {