diff --git a/packages/boot/test/bootstrapTests/test-vat-orchestration.ts b/packages/boot/test/bootstrapTests/test-vat-orchestration.ts index f4151cd6e10..339075c8791 100644 --- a/packages/boot/test/bootstrapTests/test-vat-orchestration.ts +++ b/packages/boot/test/bootstrapTests/test-vat-orchestration.ts @@ -1,13 +1,21 @@ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import type { ExecutionContext, TestFn } from 'ava'; -import type { AnyJson } from '@agoric/cosmic-proto'; +import type { AnyJson, RequestQueryJson } from '@agoric/cosmic-proto'; +import { + QueryBalanceRequest, + QueryBalanceResponse, +} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; import { MsgDelegate, MsgDelegateResponse, } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; -import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; -import type { OrchestrationService } from '@agoric/orchestration'; +import { RequestQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; +import type { + OrchestrationService, + ICQConnection, +} from '@agoric/orchestration'; import { decodeBase64 } from '@endo/base64'; import { M, matches } from '@endo/patterns'; import { makeWalletFactoryContext } from './walletFactory.ts'; @@ -19,9 +27,9 @@ type DefaultTestContext = Awaited>; const test: TestFn = anyTest; /** - * To update, pass the message into `makeTxPacket` from `@agoric/orchestration`, - * and paste the resulting `data` key into `protoMsgMocks` in - * [mocks.js](../../tools/ibc/mocks.js). + * To update, pass the message into `makeTxPacket` or `makeQueryPacket` from + * `@agoric/orchestration`, and paste the resulting `data` key into `protoMsgMocks` + * in [mocks.js](../../tools/ibc/mocks.js). * If adding a new msg, reference the mock in the `sendPacket` switch statement * in [supports.ts](../../tools/supports.ts). */ @@ -32,6 +40,17 @@ const delegateMsgSuccess = Any.toJSON( amount: { denom: 'uatom', amount: '10' }, }), ) as AnyJson; +const balanceQuery = RequestQuery.toJSON( + RequestQuery.fromPartial({ + path: '/cosmos.bank.v1beta1.Query/Balance', + data: QueryBalanceRequest.encode( + QueryBalanceRequest.fromPartial({ + address: 'cosmos1test', + denom: 'uatom', + }), + ).finish(), + }), +) as RequestQueryJson; test.before(async t => { t.context = await makeTestContext(t); @@ -133,7 +152,7 @@ test('ICA connection can send msg with proto3', async t => { // @ts-expect-error intentional await t.throwsAsync(EV(account).executeEncodedTx('malformed'), { message: - 'In "executeEncodedTx" method of (ChainAccount account): arg 0: string "malformed" - Must be a copyArray', + 'In "executeEncodedTx" method of (ChainAccountKit account): arg 0: string "malformed" - Must be a copyArray', }); const txSuccess = await EV(account).executeEncodedTx([delegateMsgSuccess]); @@ -173,3 +192,107 @@ test('ICA connection can send msg with proto3', async t => { message: 'ABCI code: 5: error handling packet: see events for details', }); }); + +test('Query connection can be created', async t => { + const { + runUtils: { EV }, + } = t.context; + + type Powers = { orchestration: OrchestrationService }; + const contract = async ({ orchestration }: Powers) => { + const connection = + await EV(orchestration).provideICQConnection('connection-0'); + t.log('Query Connection', connection); + t.truthy(connection, 'provideICQConnection returns a connection'); + t.truthy( + matches(connection, M.remotable('ICQConnection')), + 'ICQConnection is a remotable', + ); + }; + + // core eval context + { + const orchestration: OrchestrationService = + await EV.vat('bootstrap').consumeItem('orchestration'); + await contract({ orchestration }); + } +}); + +test('Query connection can send a query', async t => { + const { + runUtils: { EV }, + } = t.context; + + type Powers = { orchestration: OrchestrationService }; + const contract = async ({ orchestration }: Powers) => { + const queryConnection: ICQConnection = + await EV(orchestration).provideICQConnection('connection-0'); + + const [result] = await EV(queryConnection).query([balanceQuery]); + t.is(result.code, 0); + t.is(typeof result.height, 'bigint'); + t.deepEqual(QueryBalanceResponse.decode(decodeBase64(result.key)), { + balance: { + amount: '0', + denom: 'uatom', + }, + }); + + const results = await EV(queryConnection).query([ + balanceQuery, + balanceQuery, + ]); + t.is(results.length, 2); + for (const { key } of results) { + t.deepEqual(QueryBalanceResponse.decode(decodeBase64(key)), { + balance: { + amount: '0', + denom: 'uatom', + }, + }); + } + + await t.throwsAsync( + EV(queryConnection).query([ + { ...balanceQuery, path: '/cosmos.bank.v1beta1.QueryBalanceRequest' }, + ]), + { + message: 'ABCI code: 4: error handling packet: see events for details', + }, + 'Use gRPC method to query, not protobuf typeUrl', + ); + }; + + // core eval context + { + const orchestration: OrchestrationService = + await EV.vat('bootstrap').consumeItem('orchestration'); + await contract({ orchestration }); + } +}); + +test('provideICQConnection is idempotent', async t => { + const { + runUtils: { EV }, + } = t.context; + const orchestration: OrchestrationService = + await EV.vat('bootstrap').consumeItem('orchestration'); + + const queryConn0 = + await EV(orchestration).provideICQConnection('connection-0'); + const queryConn1 = + await EV(orchestration).provideICQConnection('connection-1'); + const queryConn02 = + await EV(orchestration).provideICQConnection('connection-0'); + + const [addr0, addr1, addr02] = await Promise.all([ + EV(queryConn0).getRemoteAddress(), + EV(queryConn1).getRemoteAddress(), + EV(queryConn02).getRemoteAddress(), + ]); + t.is(addr0, addr02); + t.not(addr0, addr1); + + const [result] = await EV(queryConn02).query([balanceQuery]); + t.is(result.code, 0, 'ICQConnectionKit from MapStore state can send queries'); +}); diff --git a/packages/boot/tools/ibc/mocks.js b/packages/boot/tools/ibc/mocks.js index 87bfdbc1c09..9c83971cf8b 100644 --- a/packages/boot/tools/ibc/mocks.js +++ b/packages/boot/tools/ibc/mocks.js @@ -6,9 +6,22 @@ const responses = { // {"result":"+/cosmos.staking.v1beta1.MsgDelegateResponse"} delegate: 'eyJyZXN1bHQiOiJFaTBLS3k5amIzTnRiM011YzNSaGEybHVaeTUyTVdKbGRHRXhMazF6WjBSbGJHVm5ZWFJsVW1WemNHOXVjMlU9In0=', - // XXX what does code 5 mean? are there other codes? + // '{"result":{"data":{"balance":{"amount":"0","denom":"uatom"}}}}' + queryBalance: + 'eyJyZXN1bHQiOiJleUprWVhSaElqb2lRMmMwZVVSQmIwdERaMVl4V1ZoU2RtSlNTVUpOUVQwOUluMD0ifQ==', + // {"result":{"data":[{"balance":{"amount":"0","denom":"uatom"}},{"balance":{"amount":"0","denom":"uatom"}}]}} + queryBalanceMulti: + 'eyJyZXN1bHQiOiJleUprWVhSaElqb2lRMmMwZVVSQmIwdERaMVl4V1ZoU2RtSlNTVUpOUVc5UFRXZDNTME5uYjBaa1YwWXdZakl3VTBGVVFUMGlmUT09In0=', + // '{"result":{"data":{"balance":{"amount":"0","denom":"some-invalid-denom"}}}}' (does not result in an error) + // eyJkYXRhIjoiQ2hzeUdRb1hDaEp6YjIxbExXbHVkbUZzYVdRdFpHVnViMjBTQVRBPSJ9 + queryBalanceUnknownDenom: + 'eyJyZXN1bHQiOiJleUprWVhSaElqb2lRMmh6ZVVkUmIxaERhRXA2WWpJeGJFeFhiSFZrYlVaellWZFJkRnBIVm5WaU1qQlRRVlJCUFNKOSJ9', + // {"error":"ABCI code: 4: error handling packet: see events for details"} + error4: + 'eyJlcnJvciI6IkFCQ0kgY29kZTogNDogZXJyb3IgaGFuZGxpbmcgcGFja2V0OiBzZWUgZXZlbnRzIGZvciBkZXRhaWxzIn0=', + // XXX what does code 5 mean? are there other codes? I have observed 1, 4, 5, 7 // {"error":"ABCI code: 5: error handling packet: see events for details"} - error: + error5: 'eyJlcnJvciI6IkFCQ0kgY29kZTogNTogZXJyb3IgaGFuZGxpbmcgcGFja2V0OiBzZWUgZXZlbnRzIGZvciBkZXRhaWxzIn0=', }; @@ -18,13 +31,33 @@ export const protoMsgMocks = { msg: 'eyJ0eXBlIjoxLCJkYXRhIjoiQ2xVS0l5OWpiM050YjNNdWMzUmhhMmx1Wnk1Mk1XSmxkR0V4TGsxelowUmxiR1ZuWVhSbEVpNEtDMk52YzIxdmN6RjBaWE4wRWhKamIzTnRiM04yWVd4dmNHVnlNWFJsYzNRYUN3b0ZkV0YwYjIwU0FqRXciLCJtZW1vIjoiIn0=', ack: responses.delegate, }, + // QueryBalanceRequest (/cosmos.bank.v1beta1.Query/Balance) of uatom for cosmos1test + queryBalance: { + msg: 'eyJ0eXBlIjoxLCJkYXRhIjoiQ2pvS0ZBb0xZMjl6Ylc5ek1YUmxjM1FTQlhWaGRHOXRFaUl2WTI5emJXOXpMbUpoYm1zdWRqRmlaWFJoTVM1UmRXVnllUzlDWVd4aGJtTmwiLCJtZW1vIjoiIn0=', + ack: responses.queryBalance, + }, + // QueryBalanceRequest of uatom for cosmos1test, repeated twice + queryBalanceMulti: { + msg: 'eyJ0eXBlIjoxLCJkYXRhIjoiQ2pvS0ZBb0xZMjl6Ylc5ek1YUmxjM1FTQlhWaGRHOXRFaUl2WTI5emJXOXpMbUpoYm1zdWRqRmlaWFJoTVM1UmRXVnllUzlDWVd4aGJtTmxDam9LRkFvTFkyOXpiVzl6TVhSbGMzUVNCWFZoZEc5dEVpSXZZMjl6Ylc5ekxtSmhibXN1ZGpGaVpYUmhNUzVSZFdWeWVTOUNZV3hoYm1ObCIsIm1lbW8iOiIifQ==', + ack: responses.queryBalanceMulti, + }, + // QueryBalanceRequest of 'some-invalid-denom' for cosmos1test + queryBalanceUnknownDenom: { + msg: 'eyJ0eXBlIjoxLCJkYXRhIjoiQ2tjS0lRb0xZMjl6Ylc5ek1YUmxjM1FTRW5OdmJXVXRhVzUyWVd4cFpDMWtaVzV2YlJJaUwyTnZjMjF2Y3k1aVlXNXJMbll4WW1WMFlURXVVWFZsY25rdlFtRnNZVzVqWlE9PSIsIm1lbW8iOiIifQ==', + ack: responses.queryBalanceUnknownDenom, + }, + // Query for /cosmos.bank.v1beta1.QueryBalanceRequest + queryUnknownPath: { + msg: 'eyJ0eXBlIjoxLCJkYXRhIjoiQ2tBS0ZBb0xZMjl6Ylc5ek1YUmxjM1FTQlhWaGRHOXRFaWd2WTI5emJXOXpMbUpoYm1zdWRqRmlaWFJoTVM1UmRXVnllVUpoYkdGdVkyVlNaWEYxWlhOMCIsIm1lbW8iOiIifQ==', + ack: responses.error4, + }, // MsgDelegate 10uatom from cosmos1test to cosmosvaloper1test with memo: 'TESTING' and timeoutHeight: 1_000_000_000n delegateWithOpts: { msg: 'eyJ0eXBlIjoxLCJkYXRhIjoiQ2xVS0l5OWpiM050YjNNdWMzUmhhMmx1Wnk1Mk1XSmxkR0V4TGsxelowUmxiR1ZuWVhSbEVpNEtDMk52YzIxdmN6RjBaWE4wRWhKamIzTnRiM04yWVd4dmNHVnlNWFJsYzNRYUN3b0ZkV0YwYjIwU0FqRXdFZ2RVUlZOVVNVNUhHSUNVNjl3RCIsIm1lbW8iOiIifQ==', ack: responses.delegate, }, error: { - ack: responses.error, + ack: responses.error5, }, }; @@ -60,15 +93,13 @@ export const icaMocks = { * @returns {IBCEvent<'channelOpenAck'>} */ channelOpenAck: obj => { - // Fake a channel IDs from port suffixes. _Ports have no relation to channels._ + // Fake a channel IDs from port suffixes. _Ports have no relation to channels, and hosts + // and controllers will likely have different channel IDs for the same channel._ + const mocklID = Number(obj.packet.source_port.split('-').at(-1)); /** @type {IBCChannelID} */ - const mockLocalChannelID = `channel-${Number( - obj?.packet?.source_port?.split('-')?.at(-1), - )}`; + const mockLocalChannelID = `channel-${mocklID}`; /** @type {IBCChannelID} */ - const mockRemoteChannelID = `channel-${Number( - obj?.packet?.destination_port?.split('-')?.at(-1), - )}`; + const mockRemoteChannelID = `channel-${mocklID}`; return { type: 'IBC_EVENT', diff --git a/packages/boot/tools/supports.ts b/packages/boot/tools/supports.ts index 0939fad575e..45ca480db63 100644 --- a/packages/boot/tools/supports.ts +++ b/packages/boot/tools/supports.ts @@ -298,7 +298,9 @@ export const makeSwingsetTestKit = async ( const makeAckEvent = (obj: IBCMethod<'sendPacket'>, ack: string) => { ibcSequenceNonce += 1; - return icaMocks.ackPacket(obj, ibcSequenceNonce, ack); + const msg = icaMocks.ackPacket(obj, ibcSequenceNonce, ack); + inbound(BridgeId.DIBC, msg); + return msg.packet; }; /** * Mock the bridge outbound handler. The real one is implemented in Golang so @@ -376,22 +378,37 @@ export const makeSwingsetTestKit = async ( case 'sendPacket': switch (obj.packet.data) { case protoMsgMocks.delegate.msg: { - const msg = makeAckEvent(obj, protoMsgMocks.delegate.ack); - inbound(BridgeId.DIBC, msg); - return msg.packet; + return makeAckEvent(obj, protoMsgMocks.delegate.ack); } case protoMsgMocks.delegateWithOpts.msg: { - const msg = makeAckEvent( + return makeAckEvent( obj, protoMsgMocks.delegateWithOpts.ack, ); - inbound(BridgeId.DIBC, msg); - return msg.packet; + } + case protoMsgMocks.queryBalance.msg: { + return makeAckEvent(obj, protoMsgMocks.queryBalance.ack); + } + case protoMsgMocks.queryUnknownPath.msg: { + return makeAckEvent( + obj, + protoMsgMocks.queryUnknownPath.ack, + ); + } + case protoMsgMocks.queryBalanceMulti.msg: { + return makeAckEvent( + obj, + protoMsgMocks.queryBalanceMulti.ack, + ); + } + case protoMsgMocks.queryBalanceUnknownDenom.msg: { + return makeAckEvent( + obj, + protoMsgMocks.queryBalanceUnknownDenom.ack, + ); } default: { - const msg = makeAckEvent(obj, protoMsgMocks.error.ack); - inbound(BridgeId.DIBC, msg); - return msg.packet; + return makeAckEvent(obj, protoMsgMocks.error.ack); } } default: diff --git a/packages/cosmic-proto/package.json b/packages/cosmic-proto/package.json index e65d9364974..72c31bd94fd 100644 --- a/packages/cosmic-proto/package.json +++ b/packages/cosmic-proto/package.json @@ -32,14 +32,18 @@ "types": "./dist/codegen/cosmos/*.d.ts", "default": "./dist/codegen/cosmos/*.js" }, - "./cosmos/tx/v1beta1/tx.js": { - "types": "./dist/codegen/cosmos/tx/v1beta1/tx.d.ts", - "default": "./dist/codegen/cosmos/tx/v1beta1/tx.js" + "./cosmos/bank/v1beta1/query.js": { + "types": "./dist/codegen/cosmos/bank/v1beta1/query.d.ts", + "default": "./dist/codegen/cosmos/bank/v1beta1/query.js" }, "./cosmos/staking/v1beta1/tx.js": { "types": "./dist/codegen/cosmos/staking/v1beta1/tx.d.ts", "default": "./dist/codegen/cosmos/staking/v1beta1/tx.js" }, + "./cosmos/tx/v1beta1/tx.js": { + "types": "./dist/codegen/cosmos/tx/v1beta1/tx.d.ts", + "default": "./dist/codegen/cosmos/tx/v1beta1/tx.js" + }, "./google/*.js": { "types": "./dist/codegen/google/*.d.ts", "default": "./dist/codegen/google/*.js" @@ -52,6 +56,14 @@ "types": "./dist/codegen/ibc/*.d.ts", "default": "./dist/codegen/ibc/*.js" }, + "./icq/v1/*.js": { + "types": "./dist/codegen/icq/v1/*.d.ts", + "default": "./dist/codegen/icq/v1/*.js" + }, + "./icq/v1/packet.js": { + "types": "./dist/codegen/icq/v1/packet.d.ts", + "default": "./dist/codegen/icq/v1/packet.js" + }, "./swingset/msgs.js": { "types": "./dist/codegen/agoric/swingset/msgs.d.ts", "default": "./dist/codegen/agoric/swingset/msgs.js" @@ -64,6 +76,14 @@ "types": "./dist/codegen/agoric/swingset/swingset.d.ts", "default": "./dist/codegen/agoric/swingset/swingset.js" }, + "./tendermint/abci/*.js": { + "types": "./dist/codegen/tendermint/abci/*.d.ts", + "default": "./dist/codegen/tendermint/abci/*.js" + }, + "./tendermint/abci/types.js": { + "types": "./dist/codegen/tendermint/abci/types.d.ts", + "default": "./dist/codegen/tendermint/abci/types.js" + }, "./vstorage/query.js": { "types": "./dist/codegen/agoric/vstorage/query.d.ts", "default": "./dist/codegen/agoric/vstorage/query.js" diff --git a/packages/orchestration/index.js b/packages/orchestration/index.js index 29a434ab341..4db591ddc0b 100644 --- a/packages/orchestration/index.js +++ b/packages/orchestration/index.js @@ -1,5 +1,4 @@ // eslint-disable-next-line import/export export * from './src/types.js'; -export * from './src/utils/address.js'; -export * from './src/utils/packet.js'; export * from './src/service.js'; +export * from './src/typeGuards.js'; diff --git a/packages/orchestration/src/exos/chainAccountKit.js b/packages/orchestration/src/exos/chainAccountKit.js index 2ec5178aea8..c62ce68a658 100644 --- a/packages/orchestration/src/exos/chainAccountKit.js +++ b/packages/orchestration/src/exos/chainAccountKit.js @@ -1,22 +1,27 @@ // @ts-check -/** @file Orchestration service */ -import { NonNullish } from '@agoric/assert'; -import { makeTracer } from '@agoric/internal'; +/** @file ChainAccount exo */ // XXX ambient types runtime imports until https://github.com/Agoric/agoric-sdk/issues/6512 import '@agoric/network/exported.js'; +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 { PaymentShape, PurseShape } from '@agoric/ertp'; import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; import { findAddressField } from '../utils/address.js'; +import { + ConnectionHandlerI, + ChainAddressShape, + Proto3Shape, +} from '../typeGuards.js'; import { makeTxPacket, parseTxPacket } from '../utils/packet.js'; /** + * @import { Zone } from '@agoric/base-zone'; * @import { Connection, Port } from '@agoric/network'; * @import { Remote } from '@agoric/vow'; - * @import { Zone } from '@agoric/base-zone'; * @import { AnyJson } from '@agoric/cosmic-proto'; * @import { TxBody } from '@agoric/cosmic-proto/cosmos/tx/v1beta1/tx.js'; * @import { LocalIbcAddress, RemoteIbcAddress } from '@agoric/vats/tools/ibc-utils.js'; @@ -24,18 +29,7 @@ import { makeTxPacket, parseTxPacket } from '../utils/packet.js'; */ const { Fail } = assert; -const trace = makeTracer('ChainAccount'); - -export const Proto3Shape = { - typeUrl: M.string(), - value: M.string(), -}; - -export const ChainAddressShape = { - address: M.string(), - chainId: M.string(), - addressEncoding: M.string(), -}; +const trace = makeTracer('ChainAccountKit'); /** @typedef {'UNPARSABLE_CHAIN_ADDRESS'} UnparsableChainAddress */ const UNPARSABLE_CHAIN_ADDRESS = 'UNPARSABLE_CHAIN_ADDRESS'; @@ -55,16 +49,10 @@ export const ChainAccountI = M.interface('ChainAccount', { prepareTransfer: M.callWhen().returns(InvitationShape), }); -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 */ export const prepareChainAccountKit = zone => zone.exoClassKit( - 'ChainAccount', + 'ChainAccountKit', { account: ChainAccountI, connectionHandler: ConnectionHandlerI }, /** * @param {Port} port @@ -175,7 +163,6 @@ export const prepareChainAccountKit = zone => this.state.connection = connection; this.state.remoteAddress = remoteAddr; this.state.localAddress = localAddr; - // XXX findAddressField currently throws, should it return '' instead? this.state.chainAddress = harden({ address: findAddressField(remoteAddr) || UNPARSABLE_CHAIN_ADDRESS, // TODO get this from `Chain` object #9063 diff --git a/packages/orchestration/src/exos/icqConnectionKit.js b/packages/orchestration/src/exos/icqConnectionKit.js new file mode 100644 index 00000000000..1ee9c3d077d --- /dev/null +++ b/packages/orchestration/src/exos/icqConnectionKit.js @@ -0,0 +1,110 @@ +// @ts-check +/** @file ICQConnection Exo */ +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 { makeQueryPacket, parseQueryPacket } from '../utils/packet.js'; +import { ConnectionHandlerI } from '../typeGuards.js'; + +/** + * @import { Zone } from '@agoric/base-zone'; + * @import { Connection, Port } from '@agoric/network'; + * @import { Remote } from '@agoric/vow'; + * @import { Base64Any, RequestQueryJson } from '@agoric/cosmic-proto'; + * @import { ResponseQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; + * @import { LocalIbcAddress, RemoteIbcAddress } from '@agoric/vats/tools/ibc-utils.js'; + */ + +const { Fail } = assert; +const trace = makeTracer('Orchestration:ICQConnection'); + +export const ICQMsgShape = M.splitRecord( + { path: M.string(), data: M.string() }, + { height: M.string(), prove: M.boolean() }, +); + +export const ICQConnectionI = M.interface('ICQConnection', { + getLocalAddress: M.call().returns(M.string()), + getRemoteAddress: M.call().returns(M.string()), + query: M.call(M.arrayOf(ICQMsgShape)).returns(M.promise()), +}); + +/** @param {Zone} zone */ +export const prepareICQConnectionKit = zone => + zone.exoClassKit( + 'ICQConnectionKit', + { connection: ICQConnectionI, connectionHandler: ConnectionHandlerI }, + /** + * @param {Port} port + */ + port => + /** + * @type {{ + * port: Port; + * connection: Remote | undefined; + * localAddress: LocalIbcAddress | undefined; + * remoteAddress: RemoteIbcAddress | undefined; + * }} + */ ( + harden({ + port, + connection: undefined, + remoteAddress: undefined, + localAddress: undefined, + }) + ), + { + connection: { + getLocalAddress() { + return NonNullish( + this.state.localAddress, + 'local address not available', + ); + }, + getRemoteAddress() { + return NonNullish( + this.state.remoteAddress, + 'remote address not available', + ); + }, + /** + * @param {RequestQueryJson[]} msgs + * @returns {Promise[]>} + * @throws {Error} if packet fails to send or an error is returned + */ + query(msgs) { + const { connection } = this.state; + if (!connection) throw Fail`connection not available`; + return E.when( + E(connection).send(makeQueryPacket(msgs)), + // if parseTxPacket cannot find a `result` key, it throws + ack => parseQueryPacket(ack), + ); + }, + }, + connectionHandler: { + /** + * @param {Remote} connection + * @param {LocalIbcAddress} localAddr + * @param {RemoteIbcAddress} remoteAddr + */ + async onOpen(connection, localAddr, remoteAddr) { + trace(`ICQ Channel Opened for ${localAddr} at ${remoteAddr}`); + this.state.connection = connection; + this.state.remoteAddress = remoteAddr; + this.state.localAddress = localAddr; + }, + async onClose(_connection, reason) { + trace(`ICQ Channel closed. Reason: ${reason}`); + }, + async onReceive(connection, bytes) { + trace(`ICQ Channel onReceive`, connection, bytes); + return ''; + }, + }, + }, + ); + +/** @typedef {ReturnType>} ICQConnectionKit */ +/** @typedef {ICQConnectionKit['connection']} ICQConnection */ diff --git a/packages/orchestration/src/proposals/orchestration-proposal.js b/packages/orchestration/src/proposals/orchestration-proposal.js index c48eb0a4ef6..b064a167a19 100644 --- a/packages/orchestration/src/proposals/orchestration-proposal.js +++ b/packages/orchestration/src/proposals/orchestration-proposal.js @@ -50,7 +50,7 @@ export const setupOrchestrationVat = async ( const portAllocator = await portAllocatorP; - const newOrchestrationKit = await E(vats.orchestration).makeOrchestration({ + const newOrchestrationKit = await E(vats.orchestration).makeOrchestrationKit({ portAllocator, }); diff --git a/packages/orchestration/src/service.js b/packages/orchestration/src/service.js index 7ac940699b7..5dc813406c3 100644 --- a/packages/orchestration/src/service.js +++ b/packages/orchestration/src/service.js @@ -6,14 +6,19 @@ import '@agoric/network/exported.js'; import { V as E } from '@agoric/vat-data/vow.js'; import { M } from '@endo/patterns'; +import { Shape as NetworkShape } from '@agoric/network'; import { prepareChainAccountKit } from './exos/chainAccountKit.js'; -import { makeICAChannelAddress } from './utils/address.js'; +import { prepareICQConnectionKit } from './exos/icqConnectionKit.js'; +import { + makeICAChannelAddress, + makeICQChannelAddress, +} from './utils/address.js'; /** - * @import { PortAllocator} from '@agoric/network'; - * @import { IBCConnectionID } from '@agoric/vats'; * @import { Zone } from '@agoric/base-zone'; - * @import { ChainAccount } from './types.js'; + * @import { Port, PortAllocator } from '@agoric/network'; + * @import { IBCConnectionID } from '@agoric/vats'; + * @import { ICQConnection, ChainAccount, ICQConnectionKit } from './types.js'; */ const { Fail, bare } = assert; @@ -34,6 +39,10 @@ const { Fail, bare } = assert; * >} PowerStore */ +/** + * @typedef {MapStore} ICQConnectionStore + */ + /** * @template {keyof OrchestrationPowers} K * @param {PowerStore} powers @@ -48,18 +57,33 @@ export const OrchestrationI = M.interface('Orchestration', { makeAccount: M.callWhen(M.string(), M.string()).returns( M.remotable('ChainAccount'), ), + provideICQConnection: M.callWhen(M.string()).returns( + M.remotable('Connection'), + ), }); +/** @typedef {{ powers: PowerStore; icqConnections: ICQConnectionStore } } OrchestrationState */ + /** * @param {Zone} zone - * @param {ReturnType} makeChainAccountKit + * @param {ReturnType} makeChainAccountKit + * @param {ReturnType} makeICQConnectionKit */ -const prepareOrchestration = (zone, makeChainAccountKit) => +const prepareOrchestrationKit = ( + zone, + makeChainAccountKit, + makeICQConnectionKit, +) => zone.exoClassKit( 'Orchestration', { self: M.interface('OrchestrationSelf', { - bindPort: M.callWhen().returns(M.remotable()), + allocateICAControllerPort: M.callWhen().returns( + NetworkShape.Vow$(NetworkShape.Port), + ), + allocateICQControllerPort: M.callWhen().returns( + NetworkShape.Vow$(NetworkShape.Port), + ), }), public: OrchestrationI, }, @@ -72,11 +96,16 @@ const prepareOrchestration = (zone, makeChainAccountKit) => powers.init(/** @type {keyof OrchestrationPowers} */ (name), power); } } - return { powers }; + const icqConnections = zone.detached().mapStore('ICQConnections'); + return /** @type {OrchestrationState} */ ({ powers, icqConnections }); }, { self: { - async bindPort() { + async allocateICAControllerPort() { + const portAllocator = getPower(this.state.powers, 'portAllocator'); + return E(portAllocator).allocateICAControllerPort(); + }, + async allocateICQControllerPort() { const portAllocator = getPower(this.state.powers, 'portAllocator'); return E(portAllocator).allocateICAControllerPort(); }, @@ -90,7 +119,7 @@ const prepareOrchestration = (zone, makeChainAccountKit) => * @returns {Promise} */ async makeAccount(hostConnectionId, controllerConnectionId) { - const port = await this.facets.self.bindPort(); + const port = await this.facets.self.allocateICAControllerPort(); const remoteConnAddr = makeICAChannelAddress( hostConnectionId, @@ -104,9 +133,35 @@ const prepareOrchestration = (zone, makeChainAccountKit) => chainAccountKit.connectionHandler, ); // XXX if we fail, should we close the port (if it was created in this flow)? - return chainAccountKit.account; }, + /** + * @param {IBCConnectionID} controllerConnectionId + * @returns {Promise} + */ + async provideICQConnection(controllerConnectionId) { + if (this.state.icqConnections.has(controllerConnectionId)) { + return this.state.icqConnections.get(controllerConnectionId) + .connection; + } + // allocate a new Port for every Connection + const port = await this.facets.self.allocateICQControllerPort(); + const remoteConnAddr = makeICQChannelAddress(controllerConnectionId); + const icqConnectionKit = makeICQConnectionKit(port); + + // await so we do not return/save a ICQConnection before it successfully instantiates + await E(port).connect( + remoteConnAddr, + icqConnectionKit.connectionHandler, + ); + + this.state.icqConnections.init( + controllerConnectionId, + icqConnectionKit, + ); + + return icqConnectionKit.connection; + }, }, }, ); @@ -114,12 +169,17 @@ const prepareOrchestration = (zone, makeChainAccountKit) => /** @param {Zone} zone */ export const prepareOrchestrationTools = zone => { const makeChainAccountKit = prepareChainAccountKit(zone); - const makeOrchestration = prepareOrchestration(zone, makeChainAccountKit); + const makeICQConnectionKit = prepareICQConnectionKit(zone); + const makeOrchestrationKit = prepareOrchestrationKit( + zone, + makeChainAccountKit, + makeICQConnectionKit, + ); - return harden({ makeOrchestration }); + return harden({ makeOrchestrationKit }); }; harden(prepareOrchestrationTools); /** @typedef {ReturnType} OrchestrationTools */ -/** @typedef {ReturnType} OrchestrationKit */ +/** @typedef {ReturnType} OrchestrationKit */ /** @typedef {OrchestrationKit['public']} OrchestrationService */ diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js new file mode 100644 index 00000000000..e7d4ce8c471 --- /dev/null +++ b/packages/orchestration/src/typeGuards.js @@ -0,0 +1,18 @@ +import { M } from '@endo/patterns'; + +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()), +}); + +export const ChainAddressShape = { + address: M.string(), + chainId: M.string(), + addressEncoding: M.string(), +}; + +export const Proto3Shape = { + typeUrl: M.string(), + value: M.string(), +}; diff --git a/packages/orchestration/src/types.d.ts b/packages/orchestration/src/types.d.ts index bb37e16ff72..13e457f29d3 100644 --- a/packages/orchestration/src/types.d.ts +++ b/packages/orchestration/src/types.d.ts @@ -19,6 +19,13 @@ import type { } from '@agoric/vats/tools/ibc-utils.js'; import type { Port } from '@agoric/network'; import { MsgTransferResponse } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; +import type { IBCConnectionID } from '@agoric/vats'; +import type { ICQConnection } from './exos/icqConnectionKit.js'; + +export type * from './service.js'; +export type * from './vat-orchestration.js'; +export type * from './exos/chainAccountKit.js'; +export type * from './exos/icqConnectionKit.js'; /** * static declaration of known chain types will allow type support for @@ -114,6 +121,10 @@ export interface Orchestrator { getChain: (chainName: C) => Promise>; makeLocalAccount: () => Promise; + /** Send queries to ibc chains unknown to KnownChains */ + provideICQConnection: ( + controllerConnectionId: IBCConnectionID, + ) => ICQConnection; /** * For a denom, return information about a denom including the equivalent diff --git a/packages/orchestration/src/utils/address.js b/packages/orchestration/src/utils/address.js index a7abd969524..7a65dfbf3a5 100644 --- a/packages/orchestration/src/utils/address.js +++ b/packages/orchestration/src/utils/address.js @@ -3,8 +3,8 @@ import { Fail } from '@agoric/assert'; /** * @import { IBCConnectionID } from '@agoric/vats'; + * @import { ChainAddress } from '../types.js'; * @import { RemoteIbcAddress } from '@agoric/vats/tools/ibc-utils.js'; - * @import { ChainAddress, CosmosValidatorAddress } from '../types.js'; */ /** @@ -15,6 +15,7 @@ import { Fail } from '@agoric/assert'; * @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` + * @returns {RemoteIbcAddress} */ export const makeICAChannelAddress = ( hostConnectionId, @@ -38,6 +39,20 @@ export const makeICAChannelAddress = ( }); return `/ibc-hop/${controllerConnectionId}/ibc-port/icahost/${ordering}/${connString}`; }; +harden(makeICAChannelAddress); + +/** + * @param {IBCConnectionID} controllerConnectionId + * @param {{ version?: string }} [opts] + */ +export const makeICQChannelAddress = ( + controllerConnectionId, + { version = 'icq-1' } = {}, +) => { + controllerConnectionId || Fail`controllerConnectionId is required`; + return `/ibc-hop/${controllerConnectionId}/ibc-port/icqhost/unordered/${version}`; +}; +harden(makeICQChannelAddress); /** * Parse a chain address from a remote address string. diff --git a/packages/orchestration/src/utils/packet.js b/packages/orchestration/src/utils/packet.js index 9d75f186b10..7a4a0ad370f 100644 --- a/packages/orchestration/src/utils/packet.js +++ b/packages/orchestration/src/utils/packet.js @@ -1,15 +1,24 @@ // @ts-check import { TxBody } from '@agoric/cosmic-proto/cosmos/tx/v1beta1/tx.js'; -import { encodeBase64 } from '@endo/base64'; import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; +import { RequestQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; +import { atob, decodeBase64, encodeBase64 } from '@endo/base64'; +import { + CosmosQuery, + CosmosResponse, +} from '@agoric/cosmic-proto/icq/v1/packet.js'; /** - * Makes an IBC packet from an array of messages. Expects the `value` of each message + * @import { AnyJson, RequestQueryJson, Base64Any } from '@agoric/cosmic-proto'; + * @import { ResponseQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; + */ + +/** + * Makes an IBC transaction packet from an array of messages. Expects the `value` of each message * to be base64 encoded bytes. * Skips checks for malformed messages in favor of interface guards. - * @param {import('@agoric/cosmic-proto').AnyJson[]} msgs - * // XXX intellisense does not seem to infer well here - * @param {Omit} [opts] + * @param {AnyJson[]} msgs + * @param {Partial>} [opts] * @returns {string} - IBC TX packet * @throws {Error} if malformed messages are provided */ @@ -30,6 +39,29 @@ export function makeTxPacket(msgs, opts) { } harden(makeTxPacket); +/** + * Makes an IBC query packet from an array of query messages. Expects the `data` of each message + * to be base64 encoded bytes. + * Skips checks for malformed messages in favor of interface guards. + * @param {RequestQueryJson[]} msgs + * @returns {string} - IBC Query packet + * @throws {Error} if malformed messages are provided + */ +export function makeQueryPacket(msgs) { + const bytes = CosmosQuery.encode( + CosmosQuery.fromPartial({ + requests: msgs.map(RequestQuery.fromJSON), + }), + ).finish(); + + return JSON.stringify({ + type: 1, + data: encodeBase64(bytes), + memo: '', + }); +} +harden(makeQueryPacket); + /** * Looks for a result or error key in the response string, and returns * a Base64Bytes string. This string can be decoded using the corresponding @@ -46,3 +78,27 @@ export function parseTxPacket(response) { else throw Error(response); } harden(parseTxPacket); + +/** + * Looks for a result or error key in the response string. If a result is found, + * `responses` is decoded via `CosmosResponse`. The `key` and `value` fields on the + * resulting entries are base64 encoded for inter-vat communication. These can be + * decoded using the corresponding Query*Response objects. + * Error strings seem to be plain text and do not need decoding. + * @param {string} response + * @returns {Base64Any[]} + * @throws {Error} if error key is detected in response string, or result key is not found + */ +export function parseQueryPacket(response) { + const result = parseTxPacket(response); + const { data } = JSON.parse(atob(result)); + const { responses = [] } = CosmosResponse.decode(decodeBase64(data)); + return harden( + responses.map(resp => ({ + ...resp, + key: encodeBase64(resp.key), + value: encodeBase64(resp.value), + })), + ); +} +harden(parseQueryPacket); diff --git a/packages/orchestration/src/vat-orchestration.js b/packages/orchestration/src/vat-orchestration.js index bd6a6219685..1f50b7d23a9 100644 --- a/packages/orchestration/src/vat-orchestration.js +++ b/packages/orchestration/src/vat-orchestration.js @@ -7,14 +7,14 @@ import { prepareOrchestrationTools } from './service.js'; export const buildRootObject = (_vatPowers, _args, baggage) => { const zone = makeDurableZone(baggage); - const { makeOrchestration } = prepareOrchestrationTools( + const { makeOrchestrationKit } = prepareOrchestrationTools( zone.subZone('orchestration'), ); return Far('OrchestrationVat', { /** @param {Partial} [initialPowers] */ - makeOrchestration(initialPowers = {}) { - return makeOrchestration(initialPowers); + makeOrchestrationKit(initialPowers = {}) { + return makeOrchestrationKit(initialPowers); }, }); }; diff --git a/packages/orchestration/test/utils/address.test.js b/packages/orchestration/test/utils/address.test.js index 618ee16e407..22b186dc632 100644 --- a/packages/orchestration/test/utils/address.test.js +++ b/packages/orchestration/test/utils/address.test.js @@ -1,13 +1,19 @@ +// @ts-check + import test from '@endo/ses-ava/prepare-endo.js'; +import { validateRemoteIbcAddress } from '@agoric/vats/tools/ibc-utils.js'; import { makeICAChannelAddress, + makeICQChannelAddress, findAddressField, } from '../../src/utils/address.js'; test('makeICAChannelAddress', t => { + // @ts-expect-error expected two arguments t.throws(() => makeICAChannelAddress(), { message: 'hostConnectionId is required', }); + // @ts-expect-error expected two arguments t.throws(() => makeICAChannelAddress('connection-0'), { message: 'controllerConnectionId is required', }); @@ -41,6 +47,7 @@ test('makeICAChannelAddress', t => { test('findAddressField', t => { t.is( + // @ts-expect-error intentional findAddressField('/ibc-hop/'), undefined, 'returns undefined when version json is missing', @@ -67,3 +74,35 @@ test('findAddressField', t => { 'returns address when localAddrr is appended to version string', ); }); + +test('makeICQChannelAddress', t => { + // @ts-expect-error expected 1 argument + t.throws(() => makeICQChannelAddress(), { + message: 'controllerConnectionId is required', + }); + t.is( + makeICQChannelAddress('connection-0'), + '/ibc-hop/connection-0/ibc-port/icqhost/unordered/icq-1', + 'returns connection string when controllerConnectionId is provided', + ); + t.is( + makeICQChannelAddress('connection-0', { + version: 'icq-2', + }), + '/ibc-hop/connection-0/ibc-port/icqhost/unordered/icq-2', + 'accepts custom version', + ); + t.throws( + () => + validateRemoteIbcAddress( + makeICQChannelAddress('connection-0', { + version: 'ic/q-/2', + }), + ), + { + message: + /must be '\(\/ibc-hop\/CONNECTION\)\*\/ibc-port\/PORT\/\(ordered\|unordered\)\/VERSION'/, + }, + 'makeICQChannelAddress not hardened against malformed version. use `validateRemoteIbcAddress` to detect this, or expect IBC ProtocolImpl to throw', + ); +}); diff --git a/packages/orchestration/test/utils/packet.test.js b/packages/orchestration/test/utils/packet.test.js index 76dae957585..c9d0d0ae2bd 100644 --- a/packages/orchestration/test/utils/packet.test.js +++ b/packages/orchestration/test/utils/packet.test.js @@ -1,6 +1,21 @@ +// @ts-check + import test from '@endo/ses-ava/prepare-endo.js'; import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; -import { makeTxPacket, parseTxPacket } from '../../src/utils/packet.js'; +import { + QueryBalanceRequest, + QueryBalanceResponse, +} from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import { RequestQuery } from '@agoric/cosmic-proto/tendermint/abci/types.js'; +import { decodeBase64 } from '@endo/base64'; +import { + makeTxPacket, + parseTxPacket, + parseQueryPacket, + makeQueryPacket, +} from '../../src/utils/packet.js'; + +/** @import { RequestQueryJson } from '@agoric/cosmic-proto'; */ test('makeTxPacket', t => { const mockMsg = { @@ -20,19 +35,25 @@ test('makeTxPacket', t => { ); t.deepEqual( makeTxPacket([ + // @ts-expect-error missing value field { typeUrl: mockMsg.typeUrl, }, ]), '{"type":1,"data":"Cg0KCy9mb28uYmFyLnYx","memo":""}', + 'Any.fromJSON() silently ignores missing `value` field', ); - t.throws(() => - makeTxPacket([ - { - typeUrl: mockMsg.typeUrl, - value: new Uint8Array([1, 2, 3]), - }, - ]), + t.throws( + () => + makeTxPacket([ + { + typeUrl: mockMsg.typeUrl, + // @ts-expect-error testing bad input + value: new Uint8Array([1, 2, 3]), + }, + ]), + undefined, + 'value cannot be Uint8Array', ); }); @@ -73,3 +94,107 @@ test('parseTxPacket', t => { 'returns original string as error if `result` is not found', ); }); + +test('makeQueryPacket', t => { + const mockQuery = /** @type {RequestQueryJson} */ ( + RequestQuery.toJSON( + RequestQuery.fromPartial({ + path: '/cosmos.bank.v1beta1.Query/Balance', + data: QueryBalanceRequest.encode( + QueryBalanceRequest.fromPartial({ + address: 'cosmos1test', + denom: 'uatom', + }), + ).finish(), + }), + ) + ); + t.is( + makeQueryPacket([mockQuery]), + '{"type":1,"data":"CjoKFAoLY29zbW9zMXRlc3QSBXVhdG9tEiIvY29zbW9zLmJhbmsudjFiZXRhMS5RdWVyeS9CYWxhbmNl","memo":""}', + 'makes a query packet from messages', + ); + const requiredFields = { + path: '/foo.bar.v1', + data: new Uint8Array([1, 2, 3]), + }; + t.deepEqual( + RequestQuery.fromPartial(requiredFields), + { + ...requiredFields, + height: 0n, + prove: false, + }, + 'RequestQuery defaults to `height: 0n` and `prove: false`', + ); +}); + +test('queryToBase64', t => { + t.deepEqual( + RequestQuery.toJSON( + RequestQuery.fromPartial({ + path: '/cosmos.bank.v1beta1.Query/Balance', + data: new Uint8Array([1, 2, 3]), + }), + ), + { + path: '/cosmos.bank.v1beta1.Query/Balance', + data: 'AQID', + height: '0', + prove: false, + }, + ); +}); + +test('parseQueryPacket', t => { + const response = `{"result":"eyJkYXRhIjoiQ2c0eURBb0tDZ1YxWVhSdmJSSUJNQT09In0="}`; + const expectedOutput = [ + { + code: 0, + log: '', + info: '', + index: 0n, + key: 'CgoKBXVhdG9tEgEw', // base64 encoded Uint8Array + value: '', + proofOps: undefined, + height: 0n, + codespace: '', + }, + ]; + + t.deepEqual( + parseQueryPacket(response), + expectedOutput, + 'parses a query response packet', + ); + + const multiResponse = `{"result":"eyJkYXRhIjoiQ2c0eURBb0tDZ1YxWVhSdmJSSUJNQW9PTWd3S0Nnb0ZkV0YwYjIwU0FUQT0ifQ=="}`; + const multiParsed = parseQueryPacket(multiResponse); + t.is(multiParsed.length, 2); + for (const { key } of multiParsed) { + t.deepEqual(QueryBalanceResponse.decode(decodeBase64(key)), { + balance: { + amount: '0', + denom: 'uatom', + }, + }); + } + + t.throws( + () => + parseQueryPacket( + `{"error":"ABCI code: 4: error handling packet: see events for details"}`, + ), + { + message: 'ABCI code: 4: error handling packet: see events for details', + }, + ); + + t.throws( + () => parseQueryPacket('{"foo":"bar"}'), + { + message: '{"foo":"bar"}', + }, + 'throws an error if `result` is not found', + ); +});