diff --git a/packages/orchestration/package.json b/packages/orchestration/package.json index 655e32a1688..91fffe2fc3f 100644 --- a/packages/orchestration/package.json +++ b/packages/orchestration/package.json @@ -14,6 +14,7 @@ "prepack": "tsc --build tsconfig.build.json", "postpack": "git clean -f '*.d.ts*'", "test": "ava", + "test:c8": "c8 $C8_OPTIONS ava --config=ava-nesm.config.js", "test:xs": "exit 0", "lint": "run-s --continue-on-error lint:*", "lint:types": "tsc", @@ -57,6 +58,7 @@ "@cosmjs/proto-signing": "^0.32.3", "@endo/ses-ava": "^1.2.2", "ava": "^5.3.1", + "c8": "^9.1.0", "prettier": "^3.3.2" }, "ava": { diff --git a/packages/orchestration/src/examples/stakeBld.contract.js b/packages/orchestration/src/examples/stakeBld.contract.js index 76f2f411540..009094e802e 100644 --- a/packages/orchestration/src/examples/stakeBld.contract.js +++ b/packages/orchestration/src/examples/stakeBld.contract.js @@ -6,7 +6,7 @@ import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/record import { withdrawFromSeat } from '@agoric/zoe/src/contractSupport/zoeHelpers.js'; import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; import { makeDurableZone } from '@agoric/zone/durable.js'; -import { V } from '@agoric/vow/vat.js'; +import { prepareVowTools, V } from '@agoric/vow/vat.js'; import { E } from '@endo/far'; import { deeplyFulfilled } from '@endo/marshal'; import { M } from '@endo/patterns'; @@ -40,12 +40,14 @@ export const start = async (zcf, privateArgs, baggage) => { baggage, privateArgs.marshaller, ); + const vowTools = prepareVowTools(zone.subZone('vows')); const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( zone, makeRecorderKit, zcf, privateArgs.timerService, + vowTools, makeChainHub(privateArgs.agoricNames), ); diff --git a/packages/orchestration/src/examples/stakeIca.contract.js b/packages/orchestration/src/examples/stakeIca.contract.js index e700f275468..e257764d914 100644 --- a/packages/orchestration/src/examples/stakeIca.contract.js +++ b/packages/orchestration/src/examples/stakeIca.contract.js @@ -2,7 +2,7 @@ import { makeTracer, StorageNodeShape } from '@agoric/internal'; import { TimerServiceShape } from '@agoric/time'; -import { V as E } from '@agoric/vow/vat.js'; +import { V as E, prepareVowTools } from '@agoric/vow/vat.js'; import { prepareRecorderKitMakers, provideAll, @@ -76,9 +76,12 @@ export const start = async (zcf, privateArgs, baggage) => { const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); + const vowTools = prepareVowTools(zone.subZone('vows')); + const makeCosmosOrchestrationAccount = prepareCosmosOrchestrationAccount( zone, makeRecorderKit, + vowTools, zcf, ); diff --git a/packages/orchestration/src/exos/chain-account-kit.js b/packages/orchestration/src/exos/chain-account-kit.js index 1189055a5cb..4d14e1410eb 100644 --- a/packages/orchestration/src/exos/chain-account-kit.js +++ b/packages/orchestration/src/exos/chain-account-kit.js @@ -2,7 +2,7 @@ import { NonNullish } from '@agoric/assert'; import { PurseShape } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; -import { V as E } from '@agoric/vow/vat.js'; +import { E } from '@endo/far'; import { M } from '@endo/patterns'; import { ChainAddressShape, @@ -15,7 +15,7 @@ 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 {Remote, VowTools} from '@agoric/vow'; * @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'; @@ -55,11 +55,22 @@ export const ChainAccountI = M.interface('ChainAccount', { * }} State */ -/** @param {Zone} zone */ -export const prepareChainAccountKit = zone => +/** + * @param {Zone} zone + * @param {VowTools} vowTools + */ +export const prepareChainAccountKit = (zone, { watch, when }) => zone.exoClassKit( 'ChainAccountKit', - { account: ChainAccountI, connectionHandler: ConnectionHandlerI }, + { + account: ChainAccountI, + connectionHandler: ConnectionHandlerI, + parseTxPacketWatcher: M.interface('ParseTxPacketWatcher', { + onFulfilled: M.call(M.string()) + .optional(M.arrayOf(M.undefined())) // does not need watcherContext + .returns(M.string()), + }), + }, /** * @param {string} chainId * @param {Port} port @@ -76,6 +87,12 @@ export const prepareChainAccountKit = zone => localAddress: undefined, }), { + parseTxPacketWatcher: { + /** @param {string} ack */ + onFulfilled(ack) { + return parseTxPacket(ack); + }, + }, account: { /** @returns {ChainAddress} */ getAddress() { @@ -120,23 +137,28 @@ export const prepareChainAccountKit = zone => * decoded using the corresponding `Msg*Response` object. * @throws {Error} if packet fails to send or an error is returned */ - executeEncodedTx(msgs, opts) { + async executeEncodedTx(msgs, opts) { const { connection } = this.state; + // TODO #9281 do not throw synchronously when returning a promise; return a rejected Vow + /// see https://github.com/Agoric/agoric-sdk/pull/9454#discussion_r1626898694 if (!connection) throw Fail`connection not available`; - return E.when( - E(connection).send(makeTxPacket(msgs, opts)), - // if parseTxPacket cannot find a `result` key, it throws - ack => parseTxPacket(ack), + return when( + watch( + E(connection).send(makeTxPacket(msgs, opts)), + this.facets.parseTxPacketWatcher, + ), ); }, /** Close the remote account */ async close() { - // FIXME what should the behavior be here? and `onClose`? + /// TODO #9192 what should the behavior be here? and `onClose`? // - retrieve assets? // - revoke the port? const { connection } = this.state; + // TODO #9281 do not throw synchronously when returning a promise; return a rejected Vow + /// see https://github.com/Agoric/agoric-sdk/pull/9454#discussion_r1626898694 if (!connection) throw Fail`connection not available`; - await E(connection).close(); + return when(watch(E(connection).close())); }, /** * get Purse for a brand to .withdraw() a Payment from the account @@ -169,7 +191,7 @@ export const prepareChainAccountKit = zone => }, async onClose(_connection, reason) { trace(`ICA Channel closed. Reason: ${reason}`); - // FIXME handle connection closing + // FIXME handle connection closing https://github.com/Agoric/agoric-sdk/issues/9192 // XXX is there a scenario where a connection will unexpectedly close? _I think yes_ }, async onReceive(connection, bytes) { diff --git a/packages/orchestration/src/exos/cosmos-orchestration-account.js b/packages/orchestration/src/exos/cosmos-orchestration-account.js index 529f35c77d4..46ddd1e6bcb 100644 --- a/packages/orchestration/src/exos/cosmos-orchestration-account.js +++ b/packages/orchestration/src/exos/cosmos-orchestration-account.js @@ -11,7 +11,6 @@ import { import { MsgBeginRedelegate, MsgDelegate, - MsgDelegateResponse, MsgUndelegate, MsgUndelegateResponse, } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; @@ -26,13 +25,10 @@ import { AmountArgShape, ChainAddressShape, ChainAmountShape, + CoinShape, DelegationShape, } from '../typeGuards.js'; -import { - encodeTxResponse, - maxClockSkew, - tryDecodeResponse, -} from '../utils/cosmos.js'; +import { maxClockSkew, tryDecodeResponse } from '../utils/cosmos.js'; import { orchestrationAccountMethods } from '../utils/orchestrationAccount.js'; import { dateInSeconds } from '../utils/time.js'; @@ -43,7 +39,10 @@ import { dateInSeconds } from '../utils/time.js'; * @import {Delegation} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; * @import {Remote} from '@agoric/internal'; * @import {TimerService} from '@agoric/time'; + * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; + * @import {ResponseQuery} from '@agoric/cosmic-proto/tendermint/abci/types.js'; + * @import {JsonSafe} from '@agoric/cosmic-proto'; */ const trace = makeTracer('ComosOrchestrationAccountHolder'); @@ -94,28 +93,19 @@ const PUBLIC_TOPICS = { account: ['Staking Account holder status', M.any()], }; -export const trivialDelegateResponse = encodeTxResponse( - {}, - MsgDelegateResponse.toProtoMsg, -); - -const expect = (actual, expected, message) => { - if (actual !== expected) { - console.log(message, { actual, expected }); - } -}; - /** @type {(c: { denom: string; amount: string }) => DenomAmount} */ const toDenomAmount = c => ({ denom: c.denom, value: BigInt(c.amount) }); /** * @param {Zone} zone * @param {MakeRecorderKit} makeRecorderKit + * @param {VowTools} vowTools * @param {ZCF} zcf */ export const prepareCosmosOrchestrationAccountKit = ( zone, makeRecorderKit, + { when, watch }, zcf, ) => { const makeCosmosOrchestrationAccountKit = zone.exoClassKit( @@ -126,6 +116,26 @@ export const prepareCosmosOrchestrationAccountKit = ( getUpdater: M.call().returns(M.remotable()), amountToCoin: M.call(AmountArgShape).returns(M.record()), }), + returnVoidWatcher: M.interface('returnVoidWatcher', { + onFulfilled: M.call(M.or(M.string(), M.record())) + .optional(M.arrayOf(M.undefined())) + .returns(M.undefined()), + }), + balanceQueryWatcher: M.interface('balanceQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())) + .optional(M.arrayOf(M.undefined())) // empty context + .returns(M.or(M.record(), M.undefined())), + }), + undelegateWatcher: M.interface('undelegateWatcher', { + onFulfilled: M.call(M.string()) + .optional(M.arrayOf(M.undefined())) // empty context + .returns(M.promise()), + }), + withdrawRewardWatcher: M.interface('withdrawRewardWatcher', { + onFulfilled: M.call(M.string()) + .optional(M.arrayOf(M.undefined())) // empty context + .returns(M.arrayOf(CoinShape)), + }), holder: IcaAccountHolderI, invitationMakers: M.interface('invitationMakers', { Delegate: M.callWhen(ChainAddressShape, AmountArgShape).returns( @@ -195,6 +205,59 @@ export const prepareCosmosOrchestrationAccountKit = ( }); }, }, + balanceQueryWatcher: { + /** + * @param {JsonSafe[]} results + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { balance } = QueryBalanceResponse.decode( + decodeBase64(result.key), + ); + if (!balance) throw Fail`Result lacked balance key: ${result}`; + return harden(toDenomAmount(balance)); + }, + }, + undelegateWatcher: { + /** + * @param {string} result + */ + onFulfilled(result) { + const response = tryDecodeResponse( + result, + MsgUndelegateResponse.fromProtoMsg, + ); + trace('undelegate response', response); + const { completionTime } = response; + completionTime || Fail`No completion time result ${result}`; + return E(this.state.timer).wakeAt( + dateInSeconds(completionTime) + maxClockSkew, + ); + }, + }, + /** + * takes an array of results (from `executeEncodedTx`) and returns void + * since we are not interested in the result + */ + returnVoidWatcher: { + /** @param {string | Record} result */ + onFulfilled(result) { + trace('Result', result); + return undefined; + }, + }, + withdrawRewardWatcher: { + /** @param {string} result */ + onFulfilled(result) { + const response = tryDecodeResponse( + result, + MsgWithdrawDelegatorRewardResponse.fromProtoMsg, + ); + trace('withdrawReward response', response); + const { amount: coins } = response; + return harden(coins.map(toDenomAmount)); + }, + }, invitationMakers: { /** * @param {CosmosValidatorAddress} validator @@ -292,7 +355,7 @@ export const prepareCosmosOrchestrationAccountKit = ( const { helper } = this.facets; const { chainAddress } = this.state; - const result = await E(helper.owned()).executeEncodedTx([ + const results = E(helper.owned()).executeEncodedTx([ Any.toJSON( MsgDelegate.toProtoMsg({ delegatorAddress: chainAddress.address, @@ -301,8 +364,7 @@ export const prepareCosmosOrchestrationAccountKit = ( }), ), ]); - - expect(result, trivialDelegateResponse, 'MsgDelegateResponse'); + return when(watch(results, this.facets.returnVoidWatcher)); }, async deposit(payment) { trace('deposit', payment); @@ -325,8 +387,7 @@ export const prepareCosmosOrchestrationAccountKit = ( const { helper } = this.facets; const { chainAddress } = this.state; - // NOTE: response, including completionTime, is currently discarded. - await E(helper.owned()).executeEncodedTx([ + const results = E(helper.owned()).executeEncodedTx([ Any.toJSON( MsgBeginRedelegate.toProtoMsg({ delegatorAddress: chainAddress.address, @@ -336,8 +397,15 @@ export const prepareCosmosOrchestrationAccountKit = ( }), ), ]); - }, + return when( + watch( + results, + // NOTE: response, including completionTime, is currently discarded. + this.facets.returnVoidWatcher, + ), + ); + }, /** * @param {CosmosValidatorAddress} validator * @returns {Promise} @@ -351,14 +419,9 @@ export const prepareCosmosOrchestrationAccountKit = ( validatorAddress: validator.address, }); const account = helper.owned(); - const result = await E(account).executeEncodedTx([Any.toJSON(msg)]); - const response = tryDecodeResponse( - result, - MsgWithdrawDelegatorRewardResponse.fromProtoMsg, - ); - trace('withdrawReward response', response); - const { amount: coins } = response; - return harden(coins.map(toDenomAmount)); + + const results = E(account).executeEncodedTx([Any.toJSON(msg)]); + return when(watch(results, this.facets.withdrawRewardWatcher)); }, /** * @param {DenomArg} denom @@ -372,7 +435,7 @@ export const prepareCosmosOrchestrationAccountKit = ( // TODO #9211 lookup denom from brand assert.typeof(denom, 'string'); - const [result] = await E(icqConnection).query([ + const results = E(icqConnection).query([ toRequestQueryJson( QueryBalanceRequest.toProtoMsg({ address: chainAddress.address, @@ -380,12 +443,7 @@ export const prepareCosmosOrchestrationAccountKit = ( }), ), ]); - if (!result?.key) throw Fail`Error parsing result ${result}`; - const { balance } = QueryBalanceResponse.decode( - decodeBase64(result.key), - ); - if (!balance) throw Fail`Result lacked balance key: ${result}`; - return harden(toDenomAmount(balance)); + return when(watch(results, this.facets.balanceQueryWatcher)); }, send(toAccount, amount) { @@ -411,28 +469,23 @@ export const prepareCosmosOrchestrationAccountKit = ( async undelegate(delegations) { trace('undelegate', delegations); const { helper } = this.facets; - const { chainAddress, bondDenom, timer } = this.state; - - const result = await E(helper.owned()).executeEncodedTx( - delegations.map(d => - Any.toJSON( - MsgUndelegate.toProtoMsg({ - delegatorAddress: chainAddress.address, - validatorAddress: d.validatorAddress, - amount: { denom: bondDenom, amount: d.shares }, - }), + const { chainAddress, bondDenom } = this.state; + + const undelegateV = watch( + E(helper.owned()).executeEncodedTx( + delegations.map(d => + Any.toJSON( + MsgUndelegate.toProtoMsg({ + delegatorAddress: chainAddress.address, + validatorAddress: d.validatorAddress, + amount: { denom: bondDenom, amount: d.shares }, + }), + ), ), ), + this.facets.undelegateWatcher, ); - - const response = tryDecodeResponse( - result, - MsgUndelegateResponse.fromProtoMsg, - ); - trace('undelegate response', response); - const { completionTime } = response; - - await E(timer).wakeAt(dateInSeconds(completionTime) + maxClockSkew); + return when(watch(undelegateV, this.facets.returnVoidWatcher)); }, }, }, @@ -450,6 +503,7 @@ export const prepareCosmosOrchestrationAccountKit = ( /** * @param {Zone} zone * @param {MakeRecorderKit} makeRecorderKit + * @param {VowTools} vowTools * @param {ZCF} zcf * @returns {( * ...args: Parameters< @@ -460,11 +514,13 @@ export const prepareCosmosOrchestrationAccountKit = ( export const prepareCosmosOrchestrationAccount = ( zone, makeRecorderKit, + vowTools, zcf, ) => { const makeKit = prepareCosmosOrchestrationAccountKit( zone, makeRecorderKit, + vowTools, zcf, ); return (...args) => makeKit(...args).holder; diff --git a/packages/orchestration/src/exos/icq-connection-kit.js b/packages/orchestration/src/exos/icq-connection-kit.js index 6c29d491ce3..e54d4599652 100644 --- a/packages/orchestration/src/exos/icq-connection-kit.js +++ b/packages/orchestration/src/exos/icq-connection-kit.js @@ -1,7 +1,7 @@ /** @file ICQConnection Exo */ import { NonNullish } from '@agoric/assert'; import { makeTracer } from '@agoric/internal'; -import { V as E } from '@agoric/vow/vat.js'; +import { E } from '@endo/far'; import { M } from '@endo/patterns'; import { makeQueryPacket, parseQueryPacket } from '../utils/packet.js'; import { ConnectionHandlerI } from '../typeGuards.js'; @@ -9,7 +9,7 @@ import { ConnectionHandlerI } from '../typeGuards.js'; /** * @import {Zone} from '@agoric/base-zone'; * @import {Connection, Port} from '@agoric/network'; - * @import {Remote} from '@agoric/vow'; + * @import {Remote, VowTools} from '@agoric/vow'; * @import {JsonSafe} from '@agoric/cosmic-proto'; * @import {RequestQuery, ResponseQuery} from '@agoric/cosmic-proto/tendermint/abci/types.js'; * @import {LocalIbcAddress, RemoteIbcAddress} from '@agoric/vats/tools/ibc-utils.js'; @@ -52,14 +52,21 @@ export const ICQConnectionI = M.interface('ICQConnection', { * sending queries and handling connection events. * * @param {Zone} zone + * @param {VowTools} vowTools */ -export const prepareICQConnectionKit = zone => +export const prepareICQConnectionKit = (zone, { watch, when }) => zone.exoClassKit( 'ICQConnectionKit', - { connection: ICQConnectionI, connectionHandler: ConnectionHandlerI }, - /** - * @param {Port} port - */ + { + connection: ICQConnectionI, + connectionHandler: ConnectionHandlerI, + parseQueryPacketWatcher: M.interface('ParseQueryPacketWatcher', { + onFulfilled: M.call(M.string()) + .optional(M.arrayOf(M.undefined())) // does not need watcherContext + .returns(M.arrayOf(M.record())), + }), + }, + /** @param {Port} port */ port => /** @type {ICQConnectionKitState} */ ({ port, @@ -88,14 +95,23 @@ export const prepareICQConnectionKit = zone => */ query(msgs) { const { connection } = this.state; + // TODO #9281 do not throw synchronously when returning a promise; return a rejected Vow + /// see https://github.com/Agoric/agoric-sdk/pull/9454#discussion_r1626898694 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), + return when( + watch( + E(connection).send(makeQueryPacket(msgs)), + this.facets.parseQueryPacketWatcher, + ), ); }, }, + parseQueryPacketWatcher: { + /** @param {string} ack packet acknowledgement string */ + onFulfilled(ack) { + return parseQueryPacket(ack); + }, + }, connectionHandler: { /** * @param {Remote} connection diff --git a/packages/orchestration/src/exos/local-chain-facade.js b/packages/orchestration/src/exos/local-chain-facade.js index 870493c35c8..1c0afc12597 100644 --- a/packages/orchestration/src/exos/local-chain-facade.js +++ b/packages/orchestration/src/exos/local-chain-facade.js @@ -55,7 +55,7 @@ export const prepareLocalChainFacade = ( chainId: localChainInfo.chainId, addressEncoding: 'bech32', }), - // FIXME storage path + // FIXME storage path https://github.com/Agoric/agoric-sdk/issues/9066 storageNode, }); diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index 045c176a4c2..15e28d830a1 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -7,17 +7,24 @@ import { V } from '@agoric/vow/vat.js'; import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/index.js'; import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; import { E } from '@endo/far'; +import { + ChainAddressShape, + ChainAmountShape, + IBCTransferOptionsShape, +} from '../typeGuards.js'; import { maxClockSkew } from '../utils/cosmos.js'; import { orchestrationAccountMethods } from '../utils/orchestrationAccount.js'; import { dateInSeconds, makeTimestampHelper } from '../utils/time.js'; /** * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; - * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, OrchestrationAccount, OrchestrationAccountI} from '@agoric/orchestration'; + * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, OrchestrationAccount, ChainInfo, IBCConnectionInfo} from '@agoric/orchestration'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; * @import {Remote} from '@agoric/internal'; - * @import {TimerService, TimerBrand} from '@agoric/time'; + * @import {TimerService, TimerBrand, TimestampRecord} from '@agoric/time'; + * @import {PromiseVow, VowTools} from '@agoric/vow'; + * @import {TypedJson} from '@agoric/cosmic-proto'; * @import {ChainHub} from '../utils/chainHub.js'; */ @@ -56,6 +63,7 @@ const PUBLIC_TOPICS = { * @param {MakeRecorderKit} makeRecorderKit * @param {ZCF} zcf * @param {Remote} timerService + * @param {VowTools} vowTools * @param {ChainHub} chainHub */ export const prepareLocalOrchestrationAccountKit = ( @@ -63,6 +71,7 @@ export const prepareLocalOrchestrationAccountKit = ( makeRecorderKit, zcf, timerService, + { watch, when, allVows }, chainHub, ) => { const timestampHelper = makeTimestampHelper(timerService); @@ -72,6 +81,40 @@ export const prepareLocalOrchestrationAccountKit = ( 'Local Orchestration Account Kit', { holder: HolderI, + undelegateWatcher: M.interface('undelegateWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())) // XXX consider specifying `completionTime` + .optional(M.arrayOf(M.undefined())) // empty context + .returns(M.promise()), + }), + getChainInfoWatcher: M.interface('getChainInfoWatcher', { + onFulfilled: M.call(M.record()) // agoric chain info + .optional({ destination: ChainAddressShape }) // empty context + .returns(M.promise()), // transfer channel + }), + getTimeoutTimestampWatcher: M.interface('getTimeoutTimestampWatcher', { + onFulfilled: M.call(M.bigint()) + .optional(IBCTransferOptionsShape) + .returns(M.bigint()), + }), + transferWatcher: M.interface('transferWatcher', { + onFulfilled: M.call(M.any()) + .optional({ + destination: ChainAddressShape, + opts: M.or(M.undefined(), IBCTransferOptionsShape), + amount: ChainAmountShape, + }) + .returns(M.promise()), + }), + extractFirstResultWatcher: M.interface('extractFirstResultWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())) + .optional(M.arrayOf(M.undefined())) + .returns(M.record()), + }), + returnVoidWatcher: M.interface('returnVoidWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())) + .optional(M.arrayOf(M.undefined())) + .returns(M.undefined()), + }), invitationMakers: M.interface('invitationMakers', { Delegate: M.callWhen(M.string(), AmountShape).returns(InvitationShape), Undelegate: M.callWhen(M.string(), AmountShape).returns( @@ -127,6 +170,110 @@ export const prepareLocalOrchestrationAccountKit = ( throw Error('not yet implemented'); }, }, + undelegateWatcher: { + /** + * @param {[ + * TypedJson<'/cosmos.staking.v1beta1.MsgUndelegateResponse'>, + * ]} response + */ + onFulfilled(response) { + const { completionTime } = response[0]; + return E(timerService).wakeAt( + // TODO clean up date handling once we have real data + dateInSeconds(new Date(completionTime)) + maxClockSkew, + ); + }, + }, + getChainInfoWatcher: { + /** + * @param {ChainInfo} agoricChainInfo + * @param {{ destination: ChainAddress }} ctx + */ + onFulfilled(agoricChainInfo, { destination }) { + return chainHub.getConnectionInfo( + agoricChainInfo.chainId, + destination.chainId, + ); + }, + }, + getTimeoutTimestampWatcher: { + /** + * @param {bigint} timeoutTimestamp + * @param {{ opts: IBCMsgTransferOptions }} ctx + */ + onFulfilled(timeoutTimestamp, { opts }) { + // FIXME: do not call `getTimeoutTimestampNS` if `opts.timeoutTimestamp` or `opts.timeoutHeight` is provided + return ( + opts?.timeoutTimestamp ?? + (opts?.timeoutHeight ? 0n : timeoutTimestamp) + ); + }, + }, + transferWatcher: { + /** + * @param {[ + * { transferChannel: IBCConnectionInfo['transferChannel'] }, + * bigint, + * ]} params + * @param {{ + * destination: ChainAddress; + * opts: IBCMsgTransferOptions; + * amount: DenomAmount; + * }} ctx + */ + onFulfilled( + [{ transferChannel }, timeoutTimestamp], + { opts, amount, destination }, + ) { + return E(this.state.account).executeTx([ + typedJson('/ibc.applications.transfer.v1.MsgTransfer', { + sourcePort: transferChannel.portId, + sourceChannel: transferChannel.channelId, + token: { + amount: String(amount.value), + denom: amount.denom, + }, + sender: this.state.address.address, + receiver: destination.address, + timeoutHeight: opts?.timeoutHeight ?? { + revisionHeight: 0n, + revisionNumber: 0n, + }, + timeoutTimestamp, + memo: opts?.memo ?? '', + }), + ]); + }, + }, + /** + * takes an array of results (from `executeEncodedTx`) and returns the + * first result + */ + extractFirstResultWatcher: { + /** + * @param {Record[]} results + */ + onFulfilled(results) { + results.length === 1 || + Fail`expected exactly one result; got ${results}`; + return results[0]; + }, + }, + /** + * takes an array of results (from `executeEncodedTx`) and returns void + * since we are not interested in the result + */ + returnVoidWatcher: { + /** + * @param {Record[]} results + */ + onFulfilled(results) { + results.length === 1 || + Fail`expected exactly one result; got ${results}`; + trace('Result', results[0]); + return undefined; + }, + }, holder: { /** @type {OrchestrationAccount['getBalance']} */ async getBalance(denomArg) { @@ -167,23 +314,21 @@ export const prepareLocalOrchestrationAccountKit = ( denom: 'ubld', }; const { account: lca } = this.state; - trace('lca', lca); - const delegatorAddress = await V(lca).getAddress(); - trace('delegatorAddress', delegatorAddress); - const [result] = await V(lca).executeTx([ + + const results = E(lca).executeTx([ typedJson('/cosmos.staking.v1beta1.MsgDelegate', { amount, validatorAddress, - delegatorAddress, + delegatorAddress: this.state.address.address, }), ]); - trace('got result', result); - return result; + + return when(watch(results, this.facets.extractFirstResultWatcher)); }, /** * @param {string} validatorAddress * @param {Amount<'nat'>} ertpAmount - * @returns {Promise} + * @returns {PromiseVow} */ async undelegate(validatorAddress, ertpAmount) { // TODO #9211 lookup denom from brand @@ -192,23 +337,15 @@ export const prepareLocalOrchestrationAccountKit = ( denom: 'ubld', }; const { account: lca } = this.state; - trace('lca', lca); - const delegatorAddress = await V(lca).getAddress(); - trace('delegatorAddress', delegatorAddress); - const [response] = await V(lca).executeTx([ + const results = E(lca).executeTx([ typedJson('/cosmos.staking.v1beta1.MsgUndelegate', { amount, validatorAddress, - delegatorAddress, + delegatorAddress: this.state.address.address, }), ]); - trace('undelegate response', response); - const { completionTime } = response; - - await E(timerService).wakeAt( - // TODO clean up date handling once we have real data - dateInSeconds(new Date(completionTime)) + maxClockSkew, - ); + // @ts-expect-error Type 'JsonSafe' is not assignable to type 'MsgUndelegateResponse'. + return when(watch(results, this.facets.undelegateWatcher)); }, /** * Starting a transfer revokes the account holder. The associated @@ -217,16 +354,21 @@ export const prepareLocalOrchestrationAccountKit = ( */ /** @type {OrchestrationAccount['deposit']} */ async deposit(payment) { - await V(this.state.account).deposit(payment); + return when( + watch( + E(this.state.account) + .deposit(payment) + .then(() => {}), + ), + ); }, /** @type {LocalChainAccount['withdraw']} */ async withdraw(amount) { - return V(this.state.account).withdraw(amount); + return when(watch(E(this.state.account).withdraw(amount))); }, /** @type {LocalChainAccount['executeTx']} */ async executeTx(messages) { - // @ts-expect-error subtype - return V(this.state.account).executeTx(messages); + return when(watch(E(this.state.account).executeTx(messages))); }, /** @returns {ChainAddress} */ getAddress() { @@ -250,40 +392,27 @@ export const prepareLocalOrchestrationAccountKit = ( // TODO #9211 lookup denom from brand if ('brand' in amount) throw Fail`ERTP Amounts not yet supported`; - const agoricChainInfo = await chainHub.getChainInfo('agoric'); - const { transferChannel } = await chainHub.getConnectionInfo( - agoricChainInfo.chainId, - destination.chainId, + const connectionInfoV = watch( + chainHub.getChainInfo('agoric'), + this.facets.getChainInfoWatcher, + { destination }, ); - await null; // set a `timeoutTimestamp` if caller does not supply either `timeoutHeight` or `timeoutTimestamp` // TODO #9324 what's a reasonable default? currently 5 minutes - const timeoutTimestamp = - opts?.timeoutTimestamp ?? - (opts?.timeoutHeight - ? 0n - : await timestampHelper.getTimeoutTimestampNS()); + // FIXME: do not call `getTimeoutTimestampNS` if `opts.timeoutTimestamp` or `opts.timeoutHeight` is provided + const timeoutTimestampV = watch( + timestampHelper.getTimeoutTimestampNS(), + this.facets.getTimeoutTimestampWatcher, + { opts }, + ); - const [result] = await V(this.state.account).executeTx([ - typedJson('/ibc.applications.transfer.v1.MsgTransfer', { - sourcePort: transferChannel.portId, - sourceChannel: transferChannel.channelId, - token: { - amount: String(amount.value), - denom: amount.denom, - }, - sender: this.state.address.address, - receiver: destination.address, - timeoutHeight: opts?.timeoutHeight ?? { - revisionHeight: 0n, - revisionNumber: 0n, - }, - timeoutTimestamp, - memo: opts?.memo ?? '', - }), - ]); - trace('MsgTransfer result', result); + const transferV = watch( + allVows([connectionInfoV, timeoutTimestampV]), + this.facets.transferWatcher, + { opts, amount, destination }, + ); + return when(watch(transferV, this.facets.returnVoidWatcher)); }, /** @type {OrchestrationAccount['transferSteps']} */ transferSteps(amount, msg) { diff --git a/packages/orchestration/src/facade.js b/packages/orchestration/src/facade.js index 2043346b14f..6de79caa23f 100644 --- a/packages/orchestration/src/facade.js +++ b/packages/orchestration/src/facade.js @@ -7,7 +7,7 @@ import { prepareOrchestrator } from './exos/orchestrator.js'; /** * @import {AsyncFlowTools} from '@agoric/async-flow'; * @import {Zone} from '@agoric/zone'; - * @import {Vow} from '@agoric/vow'; + * @import {Vow, VowTools} from '@agoric/vow'; * @import {TimerService} from '@agoric/time'; * @import {IBCConnectionID} from '@agoric/vats'; * @import {LocalChain} from '@agoric/vats/src/localchain.js'; diff --git a/packages/orchestration/src/proposals/start-stakeAtom.js b/packages/orchestration/src/proposals/start-stakeAtom.js index f0150404470..470236c54f8 100644 --- a/packages/orchestration/src/proposals/start-stakeAtom.js +++ b/packages/orchestration/src/proposals/start-stakeAtom.js @@ -1,7 +1,7 @@ import { makeTracer } from '@agoric/internal'; import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; import { E } from '@endo/far'; -import { makeChainHub } from '../utils/chainHub.js'; +import { getChainsAndConnection, makeChainHub } from '../utils/chainHub.js'; /** * @import {IBCConnectionID} from '@agoric/vats'; @@ -46,11 +46,10 @@ export const startStakeAtom = async ({ const chainHub = makeChainHub(await agoricNames); - const agoric = await chainHub.getChainInfo('agoric'); - const cosmoshub = await chainHub.getChainInfo('cosmoshub'); - const connectionInfo = await chainHub.getConnectionInfo( - agoric.chainId, - cosmoshub.chainId, + const [_, cosmoshub, connectionInfo] = await getChainsAndConnection( + chainHub, + 'agoric', + 'cosmoshub', ); /** @type {StartUpgradableOpts} */ diff --git a/packages/orchestration/src/proposals/start-stakeOsmo.js b/packages/orchestration/src/proposals/start-stakeOsmo.js index 96c8ea69fbe..d61b73b5ae6 100644 --- a/packages/orchestration/src/proposals/start-stakeOsmo.js +++ b/packages/orchestration/src/proposals/start-stakeOsmo.js @@ -1,7 +1,7 @@ import { makeTracer } from '@agoric/internal'; import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; import { E } from '@endo/far'; -import { makeChainHub } from '../utils/chainHub.js'; +import { getChainsAndConnection, makeChainHub } from '../utils/chainHub.js'; /** * @import {IBCConnectionID} from '@agoric/vats'; @@ -47,11 +47,10 @@ export const startStakeOsmo = async ({ const chainHub = makeChainHub(await agoricNames); - const agoric = await chainHub.getChainInfo('agoric'); - const osmosis = await chainHub.getChainInfo('osmosis'); - const connectionInfo = await chainHub.getConnectionInfo( - agoric.chainId, - osmosis.chainId, + const [_, osmosis, connectionInfo] = await getChainsAndConnection( + chainHub, + 'agoric', + 'osmosis', ); /** @type {StartUpgradableOpts} */ diff --git a/packages/orchestration/src/service.js b/packages/orchestration/src/service.js index 39fc4fa9c41..88d7bc8ccff 100644 --- a/packages/orchestration/src/service.js +++ b/packages/orchestration/src/service.js @@ -1,6 +1,6 @@ /** @file Orchestration service */ -import { V as E } from '@agoric/vow/vat.js'; +import { E } from '@endo/far'; import { M } from '@endo/patterns'; import { Shape as NetworkShape } from '@agoric/network'; import { prepareChainAccountKit } from './exos/chain-account-kit.js'; @@ -13,9 +13,11 @@ import { /** * @import {Zone} from '@agoric/base-zone'; * @import {Remote} from '@agoric/internal'; - * @import {Port, PortAllocator} from '@agoric/network'; + * @import {Connection, Port, PortAllocator} from '@agoric/network'; * @import {IBCConnectionID} from '@agoric/vats'; - * @import {ICQConnection, IcaAccount, ICQConnectionKit} from './types.js'; + * @import {RemoteIbcAddress} from '@agoric/vats/tools/ibc-utils.js'; + * @import {VowTools} from '@agoric/vow'; + * @import {ICQConnection, IcaAccount, ICQConnectionKit, ChainAccountKit} from './types.js'; */ const { Fail, bare } = assert; @@ -36,9 +38,9 @@ const { Fail, bare } = assert; * >} PowerStore */ -/** - * @typedef {MapStore} ICQConnectionStore - */ +/** @typedef {MapStore} ICQConnectionStore */ + +/** @typedef {ChainAccountKit | ICQConnectionKit} ConnectionKit */ /** * @template {keyof OrchestrationPowers} K @@ -50,39 +52,54 @@ const getPower = (powers, name) => { return /** @type {OrchestrationPowers[K]} */ (powers.get(name)); }; -export const OrchestrationI = M.interface('Orchestration', { - makeAccount: M.callWhen(M.string(), 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 {VowTools} vowTools * @param {ReturnType} makeChainAccountKit * @param {ReturnType} makeICQConnectionKit */ const prepareOrchestrationKit = ( zone, + { when, watch }, makeChainAccountKit, makeICQConnectionKit, ) => zone.exoClassKit( 'Orchestration', { - self: M.interface('OrchestrationSelf', { - allocateICAControllerPort: M.callWhen().returns( - NetworkShape.Vow$(NetworkShape.Port), + requestICAChannelWatcher: M.interface('RequestICAChannelWatcher', { + onFulfilled: M.call(M.remotable('Port')) + .optional({ chainId: M.string(), remoteConnAddr: M.string() }) + .returns(NetworkShape.Vow$(NetworkShape.Connection)), + }), + requestICQChannelWatcher: M.interface('RequestICQChannelWatcher', { + onFulfilled: M.call(M.remotable('Port')) + .optional({ + remoteConnAddr: M.string(), + controllerConnectionId: M.string(), + }) + .returns(NetworkShape.Vow$(NetworkShape.Connection)), + }), + channelOpenWatcher: M.interface('ChannelOpenWatcher', { + onFulfilled: M.call(M.remotable('Connection')) + .optional( + M.splitRecord( + { connectionKit: M.record(), returnFacet: M.string() }, + { saveICQConnection: M.string() }, + ), + ) + .returns(M.remotable('ConnectionKit Holder facet')), + }), + public: M.interface('OrchestrationService', { + makeAccount: M.callWhen(M.string(), M.string(), M.string()).returns( + M.remotable('ChainAccountKit'), ), - allocateICQControllerPort: M.callWhen().returns( - NetworkShape.Vow$(NetworkShape.Port), + provideICQConnection: M.callWhen(M.string()).returns( + M.remotable('ICQConnection'), ), }), - public: OrchestrationI, }, /** @param {Partial} [initialPowers] */ initialPowers => { @@ -97,16 +114,78 @@ const prepareOrchestrationKit = ( return /** @type {OrchestrationState} */ ({ powers, icqConnections }); }, { - self: { - async allocateICAControllerPort() { - const portAllocator = getPower(this.state.powers, 'portAllocator'); - return E(portAllocator).allocateICAControllerPort(); + requestICAChannelWatcher: { + /** + * @param {Port} port + * @param {{ + * chainId: string; + * remoteConnAddr: RemoteIbcAddress; + * }} watchContext + */ + onFulfilled(port, { chainId, remoteConnAddr }) { + const chainAccountKit = makeChainAccountKit( + chainId, + port, + remoteConnAddr, + ); + return watch( + E(port).connect(remoteConnAddr, chainAccountKit.connectionHandler), + this.facets.channelOpenWatcher, + { returnFacet: 'account', connectionKit: chainAccountKit }, + ); }, - async allocateICQControllerPort() { - const portAllocator = getPower(this.state.powers, 'portAllocator'); - return E(portAllocator).allocateICAControllerPort(); + }, + requestICQChannelWatcher: { + /** + * @param {Port} port + * @param {{ + * remoteConnAddr: RemoteIbcAddress; + * controllerConnectionId: IBCConnectionID; + * }} watchContext + */ + onFulfilled(port, { remoteConnAddr, controllerConnectionId }) { + const connectionKit = makeICQConnectionKit(port); + /** @param {ICQConnectionKit} kit */ + return watch( + E(port).connect(remoteConnAddr, connectionKit.connectionHandler), + this.facets.channelOpenWatcher, + { + connectionKit, + returnFacet: 'connection', + saveICQConnection: controllerConnectionId, + }, + ); }, }, + /** + * Waits for a channel (ICA, ICQ) to open and returns the consumer-facing + * facet of the ConnectionKit, specified by `returnFacet`. Saves the + * ConnectionKit if `saveICQConnection` is provided. + */ + channelOpenWatcher: { + /** + * @param {Connection} _connection + * @param {{ + * connectionKit: ConnectionKit; + * returnFacet: string; + * saveICQConnection?: IBCConnectionID; + * }} watchContext + */ + onFulfilled( + _connection, + { connectionKit, returnFacet, saveICQConnection }, + ) { + if (saveICQConnection) { + this.state.icqConnections.init( + saveICQConnection, + /** @type {ICQConnectionKit} */ (connectionKit), + ); + } + return connectionKit[returnFacet]; + }, + // TODO #9317 if we fail, should we revoke the port (if it was created in this flow)? + // onRejected() {} + }, public: { /** * @param {string} chainId @@ -115,65 +194,63 @@ const prepareOrchestrationKit = ( * @param {IBCConnectionID} controllerConnectionId self connection_id * @returns {Promise} */ - async makeAccount(chainId, hostConnectionId, controllerConnectionId) { - const port = await this.facets.self.allocateICAControllerPort(); - + makeAccount(chainId, hostConnectionId, controllerConnectionId) { const remoteConnAddr = makeICAChannelAddress( hostConnectionId, controllerConnectionId, ); - const chainAccountKit = makeChainAccountKit( - chainId, - port, - remoteConnAddr, - ); - - // await so we do not return a ChainAccount before it successfully instantiates - await E(port).connect( - remoteConnAddr, - chainAccountKit.connectionHandler, + const portAllocator = getPower(this.state.powers, 'portAllocator'); + return when( + watch( + E(portAllocator).allocateICAControllerPort(), + this.facets.requestICAChannelWatcher, + { + chainId, + remoteConnAddr, + }, + ), ); - // FIXME 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) { + provideICQConnection(controllerConnectionId) { if (this.state.icqConnections.has(controllerConnectionId)) { - return this.state.icqConnections.get(controllerConnectionId) - .connection; + // TODO #9281 do not return synchronously. see https://github.com/Agoric/agoric-sdk/pull/9454#discussion_r1626898694 + return when( + this.state.icqConnections.get(controllerConnectionId).connection, + ); } - // allocate a new Port for every Connection - // TODO #9317 optimize ICQ port allocation - 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, + const portAllocator = getPower(this.state.powers, 'portAllocator'); + return when( + watch( + // allocate a new Port for every Connection + // TODO #9317 optimize ICQ port allocation + E(portAllocator).allocateICQControllerPort(), + this.facets.requestICQChannelWatcher, + { + remoteConnAddr, + controllerConnectionId, + }, + ), ); - - return icqConnectionKit.connection; }, }, }, ); -/** @param {Zone} zone */ -export const prepareOrchestrationTools = zone => { - const makeChainAccountKit = prepareChainAccountKit(zone); - const makeICQConnectionKit = prepareICQConnectionKit(zone); +/** + * @param {Zone} zone + * @param {VowTools} vowTools + */ +export const prepareOrchestrationTools = (zone, vowTools) => { + const makeChainAccountKit = prepareChainAccountKit(zone, vowTools); + const makeICQConnectionKit = prepareICQConnectionKit(zone, vowTools); const makeOrchestrationKit = prepareOrchestrationKit( zone, + vowTools, makeChainAccountKit, makeICQConnectionKit, ); diff --git a/packages/orchestration/src/utils/chainHub.js b/packages/orchestration/src/utils/chainHub.js index 11323e4c090..6e7a0387239 100644 --- a/packages/orchestration/src/utils/chainHub.js +++ b/packages/orchestration/src/utils/chainHub.js @@ -13,6 +13,13 @@ const { Fail } = assert; * @import {Zone} from '@agoric/zone'; */ +/** + * @template {string} K + * @typedef {K extends keyof KnownChains + * ? Omit + * : ChainInfo} ActualChainInfo + */ + /** agoricNames key for ChainInfo hub */ export const CHAIN_KEY = 'chain'; /** namehub for connection info */ @@ -82,11 +89,7 @@ export const makeChainHub = (agoricNames, zone = makeHeapZone()) => { /** * @template {string} K * @param {K} chainName - * @returns {Promise< - * K extends keyof KnownChains - * ? Omit - * : ChainInfo - * >} + * @returns {Promise>} */ async getChainInfo(chainName) { // Either from registerChain or memoized remote lookup() @@ -114,11 +117,13 @@ export const makeChainHub = (agoricNames, zone = makeHeapZone()) => { }, /** - * @param {string} chainId1 - * @param {string} chainId2 + * @param {string | { chainId: string }} chain1 + * @param {string | { chainId: string }} chain2 * @returns {Promise} */ - async getConnectionInfo(chainId1, chainId2) { + async getConnectionInfo(chain1, chain2) { + const chainId1 = typeof chain1 === 'string' ? chain1 : chain1.chainId; + const chainId2 = typeof chain2 === 'string' ? chain2 : chain2.chainId; const key = connectionKey(chainId1, chainId2); if (connectionInfos.has(key)) { return connectionInfos.get(key); @@ -175,3 +180,27 @@ export const registerChain = async ( // Bundle to pipeline IO await Promise.all(promises); }; + +/** + * @template {string} C1 + * @template {string} C2 + * @param {ChainHub} chainHub + * @param {C1} chainName1 + * @param {C2} chainName2 + * @returns {Promise< + * [ActualChainInfo, ActualChainInfo, IBCConnectionInfo] + * >} + */ +export const getChainsAndConnection = async ( + chainHub, + chainName1, + chainName2, +) => { + const [chain1, chain2] = await Promise.all([ + chainHub.getChainInfo(chainName1), + chainHub.getChainInfo(chainName2), + ]); + const connectionInfo = await chainHub.getConnectionInfo(chain2, chain1); + + return [chain1, chain2, connectionInfo]; +}; diff --git a/packages/orchestration/src/utils/start-helper.js b/packages/orchestration/src/utils/start-helper.js index 7b3f2c6fb74..8ec73eb8bea 100644 --- a/packages/orchestration/src/utils/start-helper.js +++ b/packages/orchestration/src/utils/start-helper.js @@ -48,16 +48,18 @@ export const provideOrchestration = ( const chainHub = makeChainHub(remotePowers.agoricNames); + const vowTools = prepareVowTools(zone.subZone('vows')); + const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( zone, makeRecorderKit, zcf, remotePowers.timerService, + vowTools, chainHub, ); - const vowTools = prepareVowTools(zone.subZone('vows')); const asyncFlowTools = prepareAsyncFlowTools(zone.subZone('asyncFlow'), { vowTools, }); @@ -66,6 +68,7 @@ export const provideOrchestration = ( // FIXME what zone? zone, makeRecorderKit, + vowTools, zcf, ); diff --git a/packages/orchestration/src/utils/time.js b/packages/orchestration/src/utils/time.js index 975069f4efa..a7e63504a12 100644 --- a/packages/orchestration/src/utils/time.js +++ b/packages/orchestration/src/utils/time.js @@ -21,6 +21,8 @@ export function makeTimestampHelper(timer) { return harden({ /** + * XXX do this need to be resumable / use Vows? + * * Takes the current time from ChainTimerService and adds a relative time to * determine a timeout timestamp in nanoseconds. Useful for * {@link MsgTransfer.timeoutTimestamp}. diff --git a/packages/orchestration/src/vat-orchestration.js b/packages/orchestration/src/vat-orchestration.js index 675911ab7c5..8f3de0b1cc2 100644 --- a/packages/orchestration/src/vat-orchestration.js +++ b/packages/orchestration/src/vat-orchestration.js @@ -1,4 +1,5 @@ import { Far } from '@endo/far'; +import { prepareVowTools } from '@agoric/vow/vat.js'; import { makeDurableZone } from '@agoric/zone/durable.js'; import { prepareOrchestrationTools } from './service.js'; @@ -6,8 +7,10 @@ import { prepareOrchestrationTools } from './service.js'; export const buildRootObject = (_vatPowers, _args, baggage) => { const zone = makeDurableZone(baggage); + const vowTools = prepareVowTools(zone.subZone('VowTools')); const { makeOrchestrationKit } = prepareOrchestrationTools( zone.subZone('orchestration'), + vowTools, ); return Far('OrchestrationVat', { diff --git a/packages/orchestration/test/examples/stake-bld.contract.test.ts b/packages/orchestration/test/examples/stake-bld.contract.test.ts index db6ef562ab4..4c056007d8a 100644 --- a/packages/orchestration/test/examples/stake-bld.contract.test.ts +++ b/packages/orchestration/test/examples/stake-bld.contract.test.ts @@ -1,8 +1,9 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { AmountMath } from '@agoric/ertp'; -import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; import { V } from '@agoric/vow/vat.js'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; import { E } from '@endo/far'; import path from 'path'; import { commonSetup } from '../supports.js'; @@ -58,6 +59,8 @@ test('makeAccount, deposit, withdraw', async t => { ); // FIXME #9211 // t.deepEqual(await E(account).getBalance('ubld'), bld.units(100)); + // XXX races in the bridge + await eventLoopIteration(); t.log('withdraw bld from account'); const withdrawResp = await V(account).withdraw(bld.units(100)); diff --git a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts index b3e91a264b3..dfff8afc620 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -1,21 +1,23 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { AmountMath } from '@agoric/ertp'; -import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; import { V as E } from '@agoric/vow/vat.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; import { Far } from '@endo/far'; -import { commonSetup } from '../supports.js'; import { prepareLocalOrchestrationAccountKit } from '../../src/exos/local-orchestration-account.js'; import { ChainAddress } from '../../src/orchestration-api.js'; -import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; import { makeChainHub } from '../../src/utils/chainHub.js'; +import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; +import { commonSetup } from '../supports.js'; test('deposit, withdraw', async t => { const { bootstrap, brands, utils } = await commonSetup(t); const { bld: stake } = brands; - const { timer, localchain, marshaller, rootZone, storage } = bootstrap; + const { timer, localchain, marshaller, rootZone, storage, vowTools } = + bootstrap; t.log('chainInfo mocked via `prepareMockChainInfo` until #8879'); @@ -30,6 +32,7 @@ test('deposit, withdraw', async t => { // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer Far('MockZCF', {}), timer, + vowTools, makeChainHub(bootstrap.agoricNames), ); @@ -51,10 +54,12 @@ test('deposit, withdraw', async t => { const oneHundredStakePmt = await utils.pourPayment(stake.units(100)); t.log('deposit 100 bld to account'); - const depositResp = await E(account).deposit(oneHundredStakePmt); + await E(account).deposit(oneHundredStakePmt); // FIXME #9211 // t.deepEqual(await E(account).getBalance('ubld'), stake.units(100)); + // XXX races in the bridge + await eventLoopIteration(); const withdrawal1 = await E(account).withdraw(stake.units(50)); t.true( AmountMath.isEqual( @@ -84,7 +89,8 @@ test('delegate, undelegate', async t => { const { bld } = brands; - const { timer, localchain, marshaller, rootZone, storage } = bootstrap; + const { timer, localchain, marshaller, rootZone, storage, vowTools } = + bootstrap; t.log('exo setup - prepareLocalChainAccountKit'); const { makeRecorderKit } = prepareRecorderKitMakers( @@ -97,6 +103,7 @@ test('delegate, undelegate', async t => { // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer Far('MockZCF', {}), timer, + vowTools, makeChainHub(bootstrap.agoricNames), ); @@ -134,7 +141,8 @@ test('transfer', async t => { const { bld: stake } = brands; - const { timer, localchain, marshaller, rootZone, storage } = bootstrap; + const { timer, localchain, marshaller, rootZone, storage, vowTools } = + bootstrap; t.log('exo setup - prepareLocalChainAccountKit'); const { makeRecorderKit } = prepareRecorderKitMakers( @@ -147,6 +155,7 @@ test('transfer', async t => { // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer Far('MockZCF', {}), timer, + vowTools, makeChainHub(bootstrap.agoricNames), ); diff --git a/packages/orchestration/test/network-fakes.ts b/packages/orchestration/test/network-fakes.ts index 92a2f77ac68..62d0d7a5011 100644 --- a/packages/orchestration/test/network-fakes.ts +++ b/packages/orchestration/test/network-fakes.ts @@ -92,26 +92,25 @@ export const prepareProtocolHandler = ( }; export const fakeNetworkEchoStuff = (zone: Zone) => { - const powers = prepareVowTools(zone); - const { makeVowKit, when } = powers; + const vowTools = prepareVowTools(zone); + const { makeVowKit, when } = vowTools; - const makeNetworkProtocol = prepareNetworkProtocol(zone, powers); + const makeNetworkProtocol = prepareNetworkProtocol(zone, vowTools); const makeEchoConnectionHandler = prepareEchoConnectionKit(zone); const makeProtocolHandler = prepareProtocolHandler( zone, makeEchoConnectionHandler, - powers, + vowTools, ); const protocol = makeNetworkProtocol(makeProtocolHandler()); - const makePortAllocator = preparePortAllocator(zone, powers); + const makePortAllocator = preparePortAllocator(zone, vowTools); const portAllocator = makePortAllocator({ protocol }); return { makeEchoConnectionHandler, - makeVowKit, portAllocator, protocol, - when, + vowTools, }; }; diff --git a/packages/orchestration/test/service.test.ts b/packages/orchestration/test/service.test.ts new file mode 100644 index 00000000000..b8ac167eeaf --- /dev/null +++ b/packages/orchestration/test/service.test.ts @@ -0,0 +1,130 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { E } from '@endo/far'; +import { toRequestQueryJson } from '@agoric/cosmic-proto'; +import { QueryBalanceRequest } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; +import { MsgDelegate } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; +import { matches } from '@endo/patterns'; +import { commonSetup } from './supports.js'; +import { ChainAddressShape } from '../src/typeGuards.js'; + +test('makeICQConnection returns an ICQConnection', async t => { + const { + bootstrap: { orchestration }, + } = await commonSetup(t); + + const CONTROLLER_CONNECTION_ID = 'connection-0'; + + const icqConnection = await E(orchestration).provideICQConnection( + CONTROLLER_CONNECTION_ID, + ); + const [localAddr, remoteAddr] = await Promise.all([ + E(icqConnection).getLocalAddress(), + E(icqConnection).getRemoteAddress(), + ]); + t.log(icqConnection, { + localAddr, + remoteAddr, + }); + t.regex(localAddr, /ibc-port\/icqcontroller-\d+/); + t.regex( + remoteAddr, + new RegExp(`/ibc-hop/${CONTROLLER_CONNECTION_ID}`), + 'remote address contains provided connectionId', + ); + t.regex( + remoteAddr, + /icqhost\/unordered\/icq-1/, + 'remote address contains icqhost port, unordered ordering, and icq-1 version string', + ); + + const icqConnection2 = await E(orchestration).provideICQConnection( + CONTROLLER_CONNECTION_ID, + ); + const localAddr2 = await E(icqConnection2).getLocalAddress(); + t.is(localAddr, localAddr2, 'provideICQConnection is idempotent'); + + await t.throwsAsync( + E(icqConnection).query([ + toRequestQueryJson( + QueryBalanceRequest.toProtoMsg({ + address: 'cosmos1test', + denom: 'uatom', + }), + ), + ]), + { message: /"data":"(.*)"memo":""/ }, + 'TODO do not use echo connection', + ); +}); + +test('makeAccount returns a ChainAccount', async t => { + const { + bootstrap: { orchestration }, + } = await commonSetup(t); + + const CHAIN_ID = 'cosmoshub-99'; + const HOST_CONNECTION_ID = 'connection-0'; + const CONTROLLER_CONNECTION_ID = 'connection-1'; + + const account = await E(orchestration).makeAccount( + CHAIN_ID, + HOST_CONNECTION_ID, + CONTROLLER_CONNECTION_ID, + ); + const [localAddr, remoteAddr, chainAddr] = await Promise.all([ + E(account).getLocalAddress(), + E(account).getRemoteAddress(), + E(account).getAddress(), + ]); + t.log(account, { + localAddr, + remoteAddr, + chainAddr, + }); + t.regex(localAddr, /ibc-port\/icacontroller-\d+/); + t.regex( + remoteAddr, + new RegExp(`/ibc-hop/${CONTROLLER_CONNECTION_ID}`), + 'remote address contains provided connectionId', + ); + t.regex( + remoteAddr, + /icahost\/ordered/, + 'remote address contains icahost port, ordered ordering', + ); + t.regex( + remoteAddr, + /"version":"ics27-1"(.*)"encoding":"proto3"/, + 'remote address contains version and encoding in version string', + ); + + t.true(matches(chainAddr, ChainAddressShape)); + t.regex( + chainAddr.address, + /UNPARSABLE_CHAIN_ADDRESS/, + 'TODO use mocked ibc connection instead of echo connection', + ); + + const delegateMsg = Any.toJSON( + MsgDelegate.toProtoMsg({ + delegatorAddress: 'cosmos1test', + validatorAddress: 'cosmosvaloper1test', + amount: { denom: 'uatom', amount: '10' }, + }), + ); + await t.throwsAsync( + E(account).executeEncodedTx([delegateMsg]), + { message: /"type":1(.*)"data":"(.*)"memo":""/ }, + 'TODO do not use echo connection', + ); + + await E(account).close(); + await t.throwsAsync( + E(account).executeEncodedTx([delegateMsg]), + { + message: 'Connection closed', + }, + 'cannot execute transaction if connection is closed', + ); +}); diff --git a/packages/orchestration/test/staking-ops.test.ts b/packages/orchestration/test/staking-ops.test.ts index 8bbe71c6388..ed8f99868c4 100644 --- a/packages/orchestration/test/staking-ops.test.ts +++ b/packages/orchestration/test/staking-ops.test.ts @@ -1,5 +1,7 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import type { AnyJson } from '@agoric/cosmic-proto'; +import type { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; import { MsgWithdrawDelegatorRewardResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; import { MsgBeginRedelegateResponse, @@ -7,28 +9,29 @@ import { MsgDelegateResponse, MsgUndelegateResponse, } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +import { makeFakeStorageKit } from '@agoric/internal/src/storage-test-utils.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { makeNotifierFromSubscriber } from '@agoric/notifier'; +import type { TimestampRecord, TimestampValue } from '@agoric/time'; import { makeScalarBigMapStore, type Baggage } from '@agoric/vat-data'; import { makeFakeBoard } from '@agoric/vats/tools/board-utils.js'; -import { decodeBase64 } from '@endo/base64'; -import { E, Far } from '@endo/far'; +import { prepareVowTools } from '@agoric/vow/vat.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; import { buildZoeManualTimer } from '@agoric/zoe/tools/manualTimer.js'; -import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; -import type { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; -import type { TimestampRecord, TimestampValue } from '@agoric/time'; -import type { AnyJson } from '@agoric/cosmic-proto'; import { makeDurableZone } from '@agoric/zone/durable.js'; -import { makeFakeStorageKit } from '@agoric/internal/src/storage-test-utils.js'; -import { makeNotifierFromSubscriber } from '@agoric/notifier'; -import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; -import { - prepareCosmosOrchestrationAccountKit, - trivialDelegateResponse, -} from '../src/exos/cosmos-orchestration-account.js'; +import { decodeBase64 } from '@endo/base64'; +import { E, Far } from '@endo/far'; +import { prepareCosmosOrchestrationAccountKit } from '../src/exos/cosmos-orchestration-account.js'; +import type { ChainAddress, IcaAccount, ICQConnection } from '../src/types.js'; import { encodeTxResponse } from '../src/utils/cosmos.js'; -import type { IcaAccount, ChainAddress, ICQConnection } from '../src/types.js'; const { Fail } = assert; +const trivialDelegateResponse = encodeTxResponse( + {}, + MsgDelegateResponse.toProtoMsg, +); + test('MsgDelegateResponse trivial response', t => { t.is( trivialDelegateResponse, @@ -189,6 +192,8 @@ const makeScenario = () => { sequence: false, }); + const vowTools = prepareVowTools(zone.subZone('VowTools')); + const icqConnection = Far('ICQConnection', {}) as ICQConnection; const timer = buildZoeManualTimer(undefined, time.parse(startTime), { @@ -203,6 +208,7 @@ const makeScenario = () => { storageNode: rootNode, timer, icqConnection, + vowTools, ...mockZCF(), }; }; @@ -210,8 +216,14 @@ const makeScenario = () => { test('makeAccount() writes to storage', async t => { const s = makeScenario(); const { account, timer } = s; - const { makeRecorderKit, storageNode, zcf, icqConnection, zone } = s; - const make = prepareCosmosOrchestrationAccountKit(zone, makeRecorderKit, zcf); + const { makeRecorderKit, storageNode, zcf, icqConnection, vowTools, zone } = + s; + const make = prepareCosmosOrchestrationAccountKit( + zone, + makeRecorderKit, + vowTools, + zcf, + ); const { holder } = make(account.getAddress(), 'uatom', { account, @@ -233,8 +245,14 @@ test('makeAccount() writes to storage', async t => { test('withdrawRewards() on StakingAccountHolder formats message correctly', async t => { const s = makeScenario(); const { account, calls, timer } = s; - const { makeRecorderKit, storageNode, zcf, icqConnection, zone } = s; - const make = prepareCosmosOrchestrationAccountKit(zone, makeRecorderKit, zcf); + const { makeRecorderKit, storageNode, zcf, icqConnection, vowTools, zone } = + s; + const make = prepareCosmosOrchestrationAccountKit( + zone, + makeRecorderKit, + vowTools, + zcf, + ); // Higher fidelity tests below use invitationMakers. const { holder } = make(account.getAddress(), 'uatom', { @@ -256,11 +274,20 @@ test('withdrawRewards() on StakingAccountHolder formats message correctly', asyn test(`delegate; redelegate using invitationMakers`, async t => { const s = makeScenario(); const { account, calls, timer } = s; - const { makeRecorderKit, storageNode, zcf, zoe, icqConnection, zone } = s; + const { + makeRecorderKit, + storageNode, + zcf, + zoe, + icqConnection, + vowTools, + zone, + } = s; const aBrand = Far('Token') as Brand<'nat'>; const makeAccountKit = prepareCosmosOrchestrationAccountKit( zone, makeRecorderKit, + vowTools, zcf, ); @@ -329,10 +356,19 @@ test(`delegate; redelegate using invitationMakers`, async t => { test(`withdraw rewards using invitationMakers`, async t => { const s = makeScenario(); const { account, calls, timer } = s; - const { makeRecorderKit, storageNode, zcf, zoe, icqConnection, zone } = s; + const { + makeRecorderKit, + storageNode, + zcf, + zoe, + icqConnection, + vowTools, + zone, + } = s; const makeAccountKit = prepareCosmosOrchestrationAccountKit( zone, makeRecorderKit, + vowTools, zcf, ); @@ -359,10 +395,19 @@ test(`withdraw rewards using invitationMakers`, async t => { test(`undelegate waits for unbonding period`, async t => { const s = makeScenario(); const { account, calls, timer } = s; - const { makeRecorderKit, storageNode, zcf, zoe, icqConnection, zone } = s; + const { + makeRecorderKit, + storageNode, + zcf, + zoe, + icqConnection, + vowTools, + zone, + } = s; const makeAccountKit = prepareCosmosOrchestrationAccountKit( zone, makeRecorderKit, + vowTools, zcf, ); diff --git a/packages/orchestration/test/supports.ts b/packages/orchestration/test/supports.ts index b89b482ee19..ed6b5756099 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -104,11 +104,12 @@ export const commonSetup = async t => { sequence: false, }); + const { portAllocator } = fakeNetworkEchoStuff(rootZone.subZone('network')); + const { makeOrchestrationKit } = prepareOrchestrationTools( rootZone.subZone('orchestration'), + vowTools, ); - - const { portAllocator } = fakeNetworkEchoStuff(rootZone.subZone('network')); const { public: orchestration } = makeOrchestrationKit({ portAllocator }); await registerChainNamespace(agoricNamesAdmin, () => {}); @@ -125,6 +126,7 @@ export const commonSetup = async t => { // TODO remove; bootstrap doesn't have a zone rootZone: rootZone.subZone('contract'), storage, + vowTools, }, brands: { bld: bldSansMint, diff --git a/packages/orchestration/test/types.test-d.ts b/packages/orchestration/test/types.test-d.ts index cfc5989588b..a880f300038 100644 --- a/packages/orchestration/test/types.test-d.ts +++ b/packages/orchestration/test/types.test-d.ts @@ -60,6 +60,7 @@ expectNotType(chainAddr); anyVal, anyVal, anyVal, + anyVal, ); makeCosmosOrchestrationAccount( anyVal, diff --git a/packages/orchestration/test/utils/chainHub.test.ts b/packages/orchestration/test/utils/chainHub.test.ts new file mode 100644 index 00000000000..4f02ad81afa --- /dev/null +++ b/packages/orchestration/test/utils/chainHub.test.ts @@ -0,0 +1,44 @@ +/* eslint-disable @jessie.js/safe-await-separator -- XXX irrelevant for tests */ +import test from '@endo/ses-ava/prepare-endo.js'; + +import { makeNameHubKit } from '@agoric/vats'; +import { makeChainHub } from '../../src/utils/chainHub.js'; + +const connection = { + id: 'connection-1', + client_id: '07-tendermint-3', + counterparty: { + client_id: '07-tendermint-2', + connection_id: 'connection-1', + prefix: { + key_prefix: '', + }, + }, + state: 3 /* IBCConnectionState.STATE_OPEN */, + transferChannel: { + portId: 'transfer', + channelId: 'channel-1', + counterPartyChannelId: 'channel-1', + counterPartyPortId: 'transfer', + ordering: 1 /* Order.ORDER_UNORDERED */, + state: 3 /* IBCConnectionState.STATE_OPEN */, + version: 'ics20-1', + }, +} as const; + +test('getConnectionInfo', async t => { + const { nameHub } = makeNameHubKit(); + const chainHub = makeChainHub(nameHub); + + const aChain = { chainId: 'a-1' }; + const bChain = { chainId: 'b-2' }; + + chainHub.registerConnection(aChain.chainId, bChain.chainId, connection); + + // Look up by string or info object + t.deepEqual( + await chainHub.getConnectionInfo(aChain.chainId, bChain.chainId), + connection, + ); + t.deepEqual(await chainHub.getConnectionInfo(aChain, bChain), connection); +}); diff --git a/packages/vats/tools/fake-bridge.js b/packages/vats/tools/fake-bridge.js index 9976648508e..307cfae3dab 100644 --- a/packages/vats/tools/fake-bridge.js +++ b/packages/vats/tools/fake-bridge.js @@ -71,6 +71,8 @@ export const makeFakeBankBridge = (zone, opts = { balances: {} }) => { case 'VBANK_GIVE': { const { amount, denom } = obj; const address = type === 'VBANK_GRAB' ? obj.sender : obj.recipient; + (address && typeof address === 'string') || + Fail`invalid address ${address}`; balances[address] ||= {}; balances[address][denom] ||= 0n; diff --git a/packages/vow/src/tools.js b/packages/vow/src/tools.js index 7351457b6a5..a632dda4c81 100644 --- a/packages/vow/src/tools.js +++ b/packages/vow/src/tools.js @@ -31,3 +31,5 @@ export const prepareVowTools = (zone, powers = {}) => { return harden({ when, watch, makeVowKit, allVows }); }; harden(prepareVowTools); + +/** @typedef {ReturnType} VowTools */ diff --git a/packages/vow/src/types.js b/packages/vow/src/types.js index f76fa76a3b1..2a381f174d7 100644 --- a/packages/vow/src/types.js +++ b/packages/vow/src/types.js @@ -86,5 +86,3 @@ export {}; * @property {(value: T, context?: C) => Vow | PromiseVow | TResult1} [onFulfilled] * @property {(reason: any) => Vow | PromiseVow | TResult2} [onRejected] */ - -/** @typedef {ReturnType} VowTools */