diff --git a/packages/cosmic-proto/package.json b/packages/cosmic-proto/package.json index 66fcf7a1c81..5d5cb8391ab 100644 --- a/packages/cosmic-proto/package.json +++ b/packages/cosmic-proto/package.json @@ -64,6 +64,14 @@ "types": "./dist/codegen/ibc/applications/interchain_accounts/v1/packet.d.ts", "default": "./dist/codegen/ibc/applications/interchain_accounts/v1/packet.js" }, + "./ibc/core/channel/v1/channel.js": { + "types": "./dist/codegen/ibc/core/channel/v1/channel.d.ts", + "default": "./dist/codegen/ibc/core/channel/v1/channel.js" + }, + "./ibc/core/connection/v1/connection.js": { + "types": "./dist/codegen/ibc/core/connection/v1/connection.d.ts", + "default": "./dist/codegen/ibc/core/connection/v1/connection.js" + }, "./icq/*.js": { "types": "./dist/codegen/icq/*.d.ts", "default": "./dist/codegen/icq/v1/*.js" diff --git a/packages/cosmic-proto/src/helpers.ts b/packages/cosmic-proto/src/helpers.ts index 242d164bc34..636c08e5f71 100644 --- a/packages/cosmic-proto/src/helpers.ts +++ b/packages/cosmic-proto/src/helpers.ts @@ -12,6 +12,10 @@ import type { } from './codegen/cosmos/staking/v1beta1/tx.js'; import { RequestQuery } from './codegen/tendermint/abci/types.js'; import type { Any } from './codegen/google/protobuf/any.js'; +import { + MsgTransfer, + MsgTransferResponse, +} from './codegen/ibc/applications/transfer/v1/tx.js'; /** * The result of Any.toJSON(). The type in cosms-types says it returns @@ -28,6 +32,8 @@ export type Proto3Shape = { '/cosmos.bank.v1beta1.QueryAllBalancesResponse': QueryAllBalancesResponse; '/cosmos.staking.v1beta1.MsgDelegate': MsgDelegate; '/cosmos.staking.v1beta1.MsgDelegateResponse': MsgDelegateResponse; + '/ibc.applications.transfer.v1.MsgTransfer': MsgTransfer; + '/ibc.applications.transfer.v1.MsgTransferResponse': MsgTransferResponse; }; // Often s/Request$/Response/ but not always @@ -35,6 +41,7 @@ type ResponseMap = { '/cosmos.bank.v1beta1.MsgSend': '/cosmos.bank.v1beta1.MsgSendResponse'; '/cosmos.bank.v1beta1.QueryAllBalancesRequest': '/cosmos.bank.v1beta1.QueryAllBalancesResponse'; '/cosmos.staking.v1beta1.MsgDelegate': '/cosmos.staking.v1beta1.MsgDelegateResponse'; + '/ibc.applications.transfer.v1.MsgTransfer': '/ibc.applications.transfer.v1.MsgTransferResponse'; }; /** diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index 559a1e18c72..9843f6a2bfb 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -12,7 +12,12 @@ import type { RemoteIbcAddress, } from '@agoric/vats/tools/ibc-utils.js'; import { MsgTransfer } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; -import { IBCChannelID } from '@agoric/vats'; +import type { State as IBCConnectionState } from '@agoric/cosmic-proto/ibc/core/connection/v1/connection.js'; +import type { + Order, + State as IBCChannelState, +} from '@agoric/cosmic-proto/ibc/core/channel/v1/channel.js'; +import { IBCChannelID, IBCConnectionID } from '@agoric/vats'; import { MapStore } from '@agoric/store'; import type { AmountArg, ChainAddress, DenomAmount } from './types.js'; @@ -32,14 +37,14 @@ export type CosmosValidatorAddress = ChainAddress & { addressEncoding: 'bech32'; }; -/** Info for an IBC Connection (Chain:Chain relationship, that can contain multiple Channels) */ +/** Represents an IBC Connection between two chains, which can contain multiple Channels. */ export type IBCConnectionInfo = { - id: string; // e.g. connection-0 + id: IBCConnectionID; // e.g. connection-0 client_id: string; // '07-tendermint-0' - state: 'OPEN' | 'TRYOPEN' | 'INIT' | 'CLOSED'; + state: IBCConnectionState; counterparty: { client_id: string; - connection_id: string; + connection_id: IBCConnectionID; prefix: { key_prefix: string; }; @@ -51,6 +56,9 @@ export type IBCConnectionInfo = { channelId: IBCChannelID; counterPartyPortId: string; counterPartyChannelId: IBCChannelID; + ordering: Order; + state: IBCChannelState; + version: string; // e.eg. 'ics20-1' }; }; diff --git a/packages/orchestration/src/examples/stakeAtom.contract.js b/packages/orchestration/src/examples/stakeAtom.contract.js index d2e879b84b7..4c87f855f74 100644 --- a/packages/orchestration/src/examples/stakeAtom.contract.js +++ b/packages/orchestration/src/examples/stakeAtom.contract.js @@ -1,11 +1,13 @@ /** * @file Example contract that uses orchestration */ + import { makeTracer, StorageNodeShape } from '@agoric/internal'; -import { makeDurableZone } from '@agoric/zone/durable.js'; import { V as E } from '@agoric/vow/vat.js'; -import { M } from '@endo/patterns'; import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport'; +import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; +import { makeDurableZone } from '@agoric/zone/durable.js'; +import { M } from '@endo/patterns'; import { prepareStakingAccountKit } from '../exos/stakingAccountKit.js'; const trace = makeTracer('StakeAtom'); @@ -61,7 +63,7 @@ export const start = async (zcf, privateArgs, baggage) => { zcf, ); - async function makeAccount() { + async function makeAccountKit() { const account = await E(orchestration).makeAccount( hostConnectionId, controllerConnectionId, @@ -94,19 +96,20 @@ export const start = async (zcf, privateArgs, baggage) => { 'StakeAtom', M.interface('StakeAtomI', { makeAccount: M.callWhen().returns(M.remotable('ChainAccount')), - makeAcountInvitationMaker: M.call().returns(M.promise()), + makeAcountInvitationMaker: M.callWhen().returns(InvitationShape), }), { async makeAccount() { trace('makeAccount'); - return makeAccount().then(({ account }) => account); + const { account } = await makeAccountKit(); + return account; }, makeAcountInvitationMaker() { trace('makeCreateAccountInvitation'); return zcf.makeInvitation( async seat => { seat.exit(); - return makeAccount(); + return makeAccountKit(); }, 'wantStakingAccount', undefined, diff --git a/packages/orchestration/src/examples/stakeBld.contract.js b/packages/orchestration/src/examples/stakeBld.contract.js index d9477ae5df6..1214dd3fd5b 100644 --- a/packages/orchestration/src/examples/stakeBld.contract.js +++ b/packages/orchestration/src/examples/stakeBld.contract.js @@ -1,15 +1,20 @@ /** * @file Stake BLD contract - * */ - import { makeTracer } from '@agoric/internal'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +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 { M } from '@endo/patterns'; import { E } from '@endo/far'; -import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; -import { atomicTransfer } from '@agoric/zoe/src/contractSupport/atomicTransfer.js'; +import { deeplyFulfilled } from '@endo/marshal'; +import { M } from '@endo/patterns'; import { prepareLocalChainAccountKit } from '../exos/local-chain-account-kit.js'; +import { prepareMockChainInfo } from '../utils/mockChainInfo.js'; + +/** + * @import {TimerBrand, TimerService} from '@agoric/time'; + */ const trace = makeTracer('StakeBld'); @@ -20,12 +25,15 @@ const trace = makeTracer('StakeBld'); * localchain: import('@agoric/vats/src/localchain.js').LocalChain; * marshaller: Marshaller; * storageNode: StorageNode; + * timerService: TimerService; + * timerBrand: TimerBrand; * }} privateArgs * @param {import("@agoric/vat-data").Baggage} baggage */ export const start = async (zcf, privateArgs, baggage) => { - const { BLD } = zcf.getTerms().brands; + const BLD = zcf.getTerms().brands.In; + // XXX is this safe to call before prepare statements are completed? const bldAmountShape = await E(BLD).getAmountShape(); const zone = makeDurableZone(baggage); @@ -34,48 +42,81 @@ export const start = async (zcf, privateArgs, baggage) => { baggage, privateArgs.marshaller, ); + + // Mocked until #8879 + // Would expect this to be instantiated elsewhere, and passed in as a reference + const agoricChainInfo = prepareMockChainInfo(zone); + const makeLocalChainAccountKit = prepareLocalChainAccountKit( baggage, makeRecorderKit, zcf, + privateArgs.timerService, + privateArgs.timerBrand, + agoricChainInfo, ); - const publicFacet = zone.exo('StakeBld', undefined, { - makeStakeBldInvitation() { - return zcf.makeInvitation( - async seat => { - const { give } = seat.getProposal(); - trace('makeStakeBldInvitation', give); - // XXX type appears local but it's remote - const account = await E(privateArgs.localchain).makeAccount(); - const lcaSeatKit = zcf.makeEmptySeatKit(); - atomicTransfer(zcf, seat, lcaSeatKit.zcfSeat, give); - seat.exit(); - trace('makeStakeBldInvitation tryExit lca userSeat'); - await E(lcaSeatKit.userSeat).tryExit(); - trace('awaiting payouts'); - const payouts = await E(lcaSeatKit.userSeat).getPayouts(); - const { holder, invitationMakers } = makeLocalChainAccountKit( - account, - privateArgs.storageNode, - ); - trace('awaiting deposit'); - await E(account).deposit(await payouts.In); + async function makeLocalAccountKit() { + const account = await E(privateArgs.localchain).makeAccount(); + const address = await E(account).getAddress(); + return makeLocalChainAccountKit({ + account, + address, + storageNode: privateArgs.storageNode, + }); + } - return { + const publicFacet = zone.exo( + 'StakeBld', + M.interface('StakeBldI', { + makeAccount: M.callWhen().returns(M.remotable('LocalChainAccountHolder')), + makeAcountInvitationMaker: M.callWhen().returns(InvitationShape), + makeStakeBldInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeStakeBldInvitation() { + return zcf.makeInvitation( + async seat => { + const { give } = seat.getProposal(); + trace('makeStakeBldInvitation', give); + const { holder, invitationMakers } = await makeLocalAccountKit(); + const { In } = await deeplyFulfilled( + withdrawFromSeat(zcf, seat, give), + ); + await E(holder).deposit(In); + seat.exit(); + return harden({ + publicSubscribers: holder.getPublicTopics(), + invitationMakers, + account: holder, + }); + }, + 'wantStake', + undefined, + M.splitRecord({ + give: { In: bldAmountShape }, + }), + ); + }, + async makeAccount() { + trace('makeAccount'); + const { holder } = await makeLocalAccountKit(); + return holder; + }, + makeAcountInvitationMaker() { + trace('makeCreateAccountInvitation'); + return zcf.makeInvitation(async seat => { + seat.exit(); + const { holder, invitationMakers } = await makeLocalAccountKit(); + return harden({ publicSubscribers: holder.getPublicTopics(), invitationMakers, account: holder, - }; - }, - 'wantStake', - undefined, - M.splitRecord({ - give: { In: bldAmountShape }, - }), - ); + }); + }, 'wantLocalChainAccount'); + }, }, - }); + ); return { publicFacet }; }; diff --git a/packages/orchestration/src/examples/swapExample.contract.js b/packages/orchestration/src/examples/swapExample.contract.js index 985a836852a..8fca312ecd3 100644 --- a/packages/orchestration/src/examples/swapExample.contract.js +++ b/packages/orchestration/src/examples/swapExample.contract.js @@ -84,6 +84,7 @@ export const start = async (zcf, privateArgs) => { // deposit funds from user seat to LocalChainAccount const payments = await withdrawFromSeat(zcf, seat, give); await deeplyFulfilled(objectMap(payments, localAccount.deposit)); + seat.exit(); // build swap instructions with orcUtils library const transferMsg = orcUtils.makeOsmosisSwap({ diff --git a/packages/orchestration/src/exos/chainAccountKit.js b/packages/orchestration/src/exos/chainAccountKit.js index 1781cfdc65a..605b9504612 100644 --- a/packages/orchestration/src/exos/chainAccountKit.js +++ b/packages/orchestration/src/exos/chainAccountKit.js @@ -1,5 +1,4 @@ /** @file ChainAccount exo */ - import { NonNullish } from '@agoric/assert'; import { makeTracer } from '@agoric/internal'; import { V as E } from '@agoric/vow/vat.js'; diff --git a/packages/orchestration/src/exos/local-chain-account-kit.js b/packages/orchestration/src/exos/local-chain-account-kit.js index 6d70d4fd8e0..e6953579e30 100644 --- a/packages/orchestration/src/exos/local-chain-account-kit.js +++ b/packages/orchestration/src/exos/local-chain-account-kit.js @@ -1,11 +1,30 @@ /** @file Use-object for the owner of a localchain account */ +import { NonNullish } from '@agoric/assert'; import { typedJson } from '@agoric/cosmic-proto/vatsafe'; -import { AmountShape } from '@agoric/ertp'; +import { AmountShape, PaymentShape } from '@agoric/ertp'; import { makeTracer } from '@agoric/internal'; import { UnguardedHelperI } from '@agoric/internal/src/typeGuards.js'; import { M, prepareExoClassKit } from '@agoric/vat-data'; import { TopicsRecordShape } from '@agoric/zoe/src/contractSupport/index.js'; import { E } from '@endo/far'; +import { + AmountArgShape, + ChainAddressShape, + IBCTransferOptionsShape, +} from '../typeGuards.js'; +import { makeTimestampHelper } from '../utils/time.js'; + +/** + * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; + * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, CosmosChainInfo} from '@agoric/orchestration'; + * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. + * @import {Baggage} from '@agoric/vat-data'; + * @import {TimerService, TimerBrand} from '@agoric/time'; + * @import {TimestampHelper} from '../utils/time.js'; + */ + +// partial until #8879 +/** @typedef {Pick} AgoricChainInfo */ const trace = makeTracer('LCAH'); @@ -17,8 +36,9 @@ const { Fail } = assert; /** * @typedef {{ - * topicKit: import('@agoric/zoe/src/contractSupport/recorder.js').RecorderKit; - * account: import('@agoric/vats/src/localchain.js').LocalChainAccount | null; + * topicKit: RecorderKit; + * account: LocalChainAccount | null; + * address: ChainAddress['address']; * }} State */ @@ -27,6 +47,12 @@ const HolderI = M.interface('holder', { makeDelegateInvitation: M.call(M.string(), AmountShape).returns(M.promise()), makeCloseAccountInvitation: M.call().returns(M.promise()), makeTransferAccountInvitation: M.call().returns(M.promise()), + deposit: M.callWhen(PaymentShape).returns(AmountShape), + withdraw: M.callWhen(AmountShape).returns(PaymentShape), + transfer: M.call(AmountArgShape, ChainAddressShape) + .optional(IBCTransferOptionsShape) + .returns(M.promise()), + getAddress: M.call().returns(M.string()), }); /** @type {{ [name: string]: [description: string, valueShape: Pattern] }} */ @@ -35,11 +61,22 @@ const PUBLIC_TOPICS = { }; /** - * @param {import('@agoric/swingset-liveslots').Baggage} baggage - * @param {import('@agoric/zoe/src/contractSupport/recorder.js').MakeRecorderKit} makeRecorderKit + * @param {Baggage} baggage + * @param {MakeRecorderKit} makeRecorderKit * @param {ZCF} zcf + * @param {TimerService} timerService + * @param {TimerBrand} timerBrand + * @param {AgoricChainInfo} agoricChainInfo */ -export const prepareLocalChainAccountKit = (baggage, makeRecorderKit, zcf) => { +export const prepareLocalChainAccountKit = ( + baggage, + makeRecorderKit, + zcf, + timerService, + timerBrand, + agoricChainInfo, +) => { + const timestampHelper = makeTimestampHelper(timerService, timerBrand); const makeAccountHolderKit = prepareExoClassKit( baggage, 'Account Holder', @@ -54,16 +91,19 @@ export const prepareLocalChainAccountKit = (baggage, makeRecorderKit, zcf) => { }), }, /** - * @param {import('@agoric/vats/src/localchain.js').LocalChainAccount} account - * @param {StorageNode} storageNode + * @param {object} initState + * @param {LocalChainAccount} initState.account + * @param {ChainAddress['address']} initState.address + * @param {StorageNode} initState.storageNode * @returns {State} */ - (account, storageNode) => { + ({ account, address, storageNode }) => { // must be the fully synchronous maker because the kit is held in durable state // @ts-expect-error XXX Patterns const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.account[1]); - return { account, topicKit }; + // #9162 use ChainAddress object instead of `address` string + return { account, address, topicKit }; }, { helper: { @@ -112,8 +152,7 @@ export const prepareLocalChainAccountKit = (baggage, makeRecorderKit, zcf) => { async makeDelegateInvitation(validatorAddress, ertpAmount) { trace('makeDelegateInvitation', validatorAddress, ertpAmount); - // FIXME get values from proposal or args - // FIXME brand handling and amount scaling + // TODO #9211 lookup denom from brand const amount = { amount: String(ertpAmount.value), denom: 'ubld', @@ -126,7 +165,7 @@ export const prepareLocalChainAccountKit = (baggage, makeRecorderKit, zcf) => { trace('lca', lca); const delegatorAddress = await E(lca).getAddress(); trace('delegatorAddress', delegatorAddress); - const result = await E(lca).executeTx([ + const [result] = await E(lca).executeTx([ typedJson('/cosmos.staking.v1beta1.MsgDelegate', { amount, validatorAddress, @@ -147,6 +186,65 @@ export const prepareLocalChainAccountKit = (baggage, makeRecorderKit, zcf) => { makeTransferAccountInvitation() { throw Error('not yet implemented'); }, + /** @type {LocalChainAccount['deposit']} */ + async deposit(payment, optAmountShape) { + return E(this.facets.helper.owned()).deposit(payment, optAmountShape); + }, + /** @type {LocalChainAccount['withdraw']} */ + async withdraw(amount) { + return E(this.facets.helper.owned()).withdraw(amount); + }, + /** + * @returns {ChainAddress['address']} + */ + getAddress() { + return NonNullish(this.state.address, 'Chain address not available.'); + }, + /** + * @param {AmountArg} amount an ERTP {@link Amount} or a {@link DenomAmount} + * @param {ChainAddress} destination + * @param {IBCMsgTransferOptions} [opts] if either timeoutHeight or timeoutTimestamp are not supplied, a default timeoutTimestamp will be set for 5 minutes in the future + * @returns {Promise} + */ + async transfer(amount, destination, opts) { + trace('Transferring funds from LCA over IBC'); + // TODO #9211 lookup denom from brand + if ('brand' in amount) throw Fail`ERTP Amounts not yet supported`; + + // TODO #8879 chainInfo and #9063 well-known chains + const { transferChannel } = agoricChainInfo.connections.get( + destination.chainId, + ); + + 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()); + + const [result] = await E(this.facets.helper.owned()).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, + receiver: destination.address, + timeoutHeight: opts?.timeoutHeight ?? { + revisionHeight: 0n, + revisionNumber: 0n, + }, + timeoutTimestamp, + memo: opts?.memo ?? '', + }), + ]); + trace('MsgTransfer result', result); + }, }, }, ); diff --git a/packages/orchestration/src/orchestration-api.ts b/packages/orchestration/src/orchestration-api.ts index f426299d5cd..3ee02806bb0 100644 --- a/packages/orchestration/src/orchestration-api.ts +++ b/packages/orchestration/src/orchestration-api.ts @@ -45,7 +45,7 @@ export type DenomAmount = { value: bigint; // Nat }; -/** Amounts can be provided as pure data using denoms or as native Amounts */ +/** Amounts can be provided as pure data using denoms or as ERTP Amounts */ export type AmountArg = DenomAmount | Amount; /** An address on some blockchain, e.g., cosmos, eth, etc. */ @@ -145,9 +145,9 @@ export interface OrchestrationAccountI { /** * Transfer an amount to another account, typically on another chain. * The promise settles when the transfer is complete. - * @param {AmountArg} amount - the amount to transfer. - * @param {ChainAddress} destination - the account to transfer the amount to. - * @param {IBCMsgTransferOptions} [opts] - an optional memo to include with the transfer, which could drive custom PFM behavior, and timeout parameters + * @param amount - the amount to transfer. Can be provided as pure data using denoms or as ERTP Amounts. + * @param destination - the account to transfer the amount to. + * @param [opts] - an optional memo to include with the transfer, which could drive custom PFM behavior, and timeout parameters * @returns void * * TODO document the mapping from the address to the destination chain. diff --git a/packages/orchestration/src/proposals/start-stakeBld.js b/packages/orchestration/src/proposals/start-stakeBld.js index 3168886a88b..036ce51624d 100644 --- a/packages/orchestration/src/proposals/start-stakeBld.js +++ b/packages/orchestration/src/proposals/start-stakeBld.js @@ -9,7 +9,13 @@ const trace = makeTracer('StartStakeBld', true); * @param {BootstrapPowers & {installation: {consume: {stakeBld: Installation}}}} powers */ export const startStakeBld = async ({ - consume: { board, chainStorage, localchain, startUpgradable }, + consume: { + board, + chainStorage, + chainTimerService: chainTimerServiceP, + localchain, + startUpgradable, + }, installation: { consume: { stakeBld }, }, @@ -28,15 +34,21 @@ export const startStakeBld = async ({ // NB: committee must only publish what it intended to be public const marshaller = await E(board).getPublishingMarshaller(); - // FIXME this isn't detecting missing privateArgs + const [timerService, timerBrand] = await Promise.all([ + chainTimerServiceP, + chainTimerServiceP.then(ts => E(ts).getTimerBrand()), + ]); + /** @type {StartUpgradableOpts} */ const startOpts = { label: 'stakeBld', installation: stakeBld, - issuerKeywordRecord: harden({ BLD: await stakeIssuer }), + issuerKeywordRecord: harden({ In: await stakeIssuer }), terms: {}, privateArgs: { localchain: await localchain, + timerService, + timerBrand, storageNode, marshaller, }, @@ -54,6 +66,7 @@ export const getManifestForStakeBld = ({ restoreRef }, { installKeys }) => { consume: { board: true, chainStorage: true, + chainTimerService: true, localchain: true, startUpgradable: true, }, diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index d84928dcf31..79d48ad3f1c 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -1,4 +1,3 @@ -// @ts-check import { AmountShape } from '@agoric/ertp'; import { M } from '@endo/patterns'; @@ -26,3 +25,15 @@ export const ChainAmountShape = harden({ denom: M.string(), value: M.nat() }); export const AmountArgShape = M.or(AmountShape, ChainAmountShape); export const DelegationShape = M.record(); // TODO: DelegationShape fields + +export const IBCTransferOptionsShape = M.splitRecord( + {}, + { + timeoutTimestamp: M.bigint(), + timeoutHeight: { + revisionHeight: M.bigint(), + revisionNumber: M.bigint(), + }, + memo: M.string(), + }, +); diff --git a/packages/orchestration/src/utils/mockChainInfo.js b/packages/orchestration/src/utils/mockChainInfo.js new file mode 100644 index 00000000000..61d153b1cf0 --- /dev/null +++ b/packages/orchestration/src/utils/mockChainInfo.js @@ -0,0 +1,86 @@ +/** + * @file Mocked Chain Info object until #8879 + */ +import { + Order, + State as IBCChannelState, +} from '@agoric/cosmic-proto/ibc/core/channel/v1/channel.js'; +import { State as IBCConnectionState } from '@agoric/cosmic-proto/ibc/core/connection/v1/connection.js'; + +/** + * @import {Zone} from '@agoric/zone'; + * @import {CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api.js'; + */ + +/** + * currently keyed by ChainId, as this is what we have + * available in ChainAddress to determine the correct IBCChannelID's + * for a .transfer() msg. + * @type {Record} + */ +const connectionEntries = harden({ + cosmoslocal: { + id: 'connection-1', + client_id: '07-tendermint-3', + counterparty: { + client_id: '07-tendermint-2', + connection_id: 'connection-1', + prefix: { + key_prefix: '', + }, + }, + state: IBCConnectionState.STATE_OPEN, + transferChannel: { + portId: 'transfer', + channelId: 'channel-1', + counterPartyChannelId: 'channel-1', + counterPartyPortId: 'transfer', + ordering: Order.ORDER_UNORDERED, + state: IBCChannelState.STATE_OPEN, + version: 'ics20-1', + }, + versions: [{ identifier: '', features: ['', ''] }], + delay_period: 0n, + }, + osmosislocal: { + id: 'connection-0', + client_id: '07-tendermint-2', + counterparty: { + client_id: '07-tendermint-2', + connection_id: 'connection-1', + prefix: { + key_prefix: '', + }, + }, + state: IBCConnectionState.STATE_OPEN, + transferChannel: { + portId: 'transfer', + channelId: 'channel-0', + counterPartyChannelId: 'channel-1', + counterPartyPortId: 'transfer', + ordering: Order.ORDER_UNORDERED, + state: IBCChannelState.STATE_OPEN, + version: 'ics20-1', + }, + versions: [{ identifier: '', features: ['', ''] }], + delay_period: 0n, + }, +}); + +/** + * @param {Zone} zone + * @returns {Pick} + */ +export const prepareMockChainInfo = zone => { + const agoricConnections = + /** @type {import('@agoric/store').MapStore} */ ( + zone.mapStore('ibcConnections') + ); + + agoricConnections.addAll(Object.entries(connectionEntries)); + + return harden({ + chainId: 'agoriclocal', + connections: agoricConnections, + }); +}; diff --git a/packages/orchestration/src/utils/time.js b/packages/orchestration/src/utils/time.js new file mode 100644 index 00000000000..91f491d700e --- /dev/null +++ b/packages/orchestration/src/utils/time.js @@ -0,0 +1,38 @@ +import { E } from '@endo/far'; +import { TimeMath } from '@agoric/time'; + +/** + * @import {RelativeTimeRecord, TimerBrand, TimerService} from '@agoric/time'; + * @import {MsgTransfer} from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; + */ + +export const SECONDS_PER_MINUTE = 60n; +export const NANOSECONDS_PER_SECOND = 1_000_000_000n; + +/** + * @param {TimerService} timer + * @param {TimerBrand} timerBrand + */ +export function makeTimestampHelper(timer, timerBrand) { + return harden({ + /** + * Takes the current time from ChainTimerService and adds a relative + * time to determine a timeout timestamp in nanoseconds. + * Useful for {@link MsgTransfer.timeoutTimestamp}. + * @param {RelativeTimeRecord} [relativeTime] defaults to 5 minutes + * @returns {Promise} Timeout timestamp in absolute nanoseconds since unix epoch + */ + async getTimeoutTimestampNS(relativeTime) { + const currentTime = await E(timer).getCurrentTimestamp(); + const timeout = + relativeTime || + TimeMath.coerceRelativeTimeRecord(SECONDS_PER_MINUTE * 5n, timerBrand); + return ( + TimeMath.addAbsRel(currentTime, timeout).absValue * + NANOSECONDS_PER_SECOND + ); + }, + }); +} + +/** @typedef {Awaited>} TimestampHelper */ diff --git a/packages/orchestration/test/examples/stake-bld.contract.test.ts b/packages/orchestration/test/examples/stake-bld.contract.test.ts new file mode 100644 index 00000000000..f314e486d8c --- /dev/null +++ b/packages/orchestration/test/examples/stake-bld.contract.test.ts @@ -0,0 +1,171 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { makeFakeStorageKit } from '@agoric/internal/src/storage-test-utils.js'; +import { prepareLocalChainTools } from '@agoric/vats/src/localchain.js'; +import { makeFakeBoard } from '@agoric/vats/tools/board-utils.js'; +import { buildRootObject as buildBankVatRoot } from '@agoric/vats/src/vat-bank.js'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { makeHeapZone } from '@agoric/zone'; +import { E } from '@endo/far'; +import path from 'path'; +import { makeFakeLocalchainBridge } from '../supports.js'; + +const { keys } = Object; +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractFile = `${dirname}/../../src/examples/stakeBld.contract.js`; +type StartFn = + typeof import('@agoric/orchestration/src/examples/stakeBld.contract.js').start; + +const bootstrap = async (t, { issuerKit }) => { + t.log('bootstrap vat dependencies'); + const zone = makeHeapZone(); + const bankManager = await buildBankVatRoot( + undefined, + undefined, + zone.mapStore('bankManager'), + ).makeBankManager(); + await E(bankManager).addAsset('ubld', 'BLD', 'Staking Token', issuerKit); + + const localchainBridge = makeFakeLocalchainBridge(zone); + const localchain = prepareLocalChainTools( + zone.subZone('localchain'), + ).makeLocalChain({ + bankManager, + system: localchainBridge, + }); + const timer = buildManualTimer(t.log); + const marshaller = makeFakeBoard().getReadonlyMarshaller(); + const storage = makeFakeStorageKit('mockChainStorageRoot', { + sequence: false, + }); + return { + timer, + localchain, + marshaller, + storage, + }; +}; + +const coreEval = async ( + t, + { timer, localchain, marshaller, storage, stake }, +) => { + t.log('install stakeBld contract'); + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + const installation: Installation = + await bundleAndInstall(contractFile); + + const { publicFacet } = await E(zoe).startInstance( + installation, + { In: stake.issuer }, + {}, + { + localchain, + marshaller, + storageNode: storage.rootNode, + timerService: timer, + timerBrand: timer.getTimerBrand(), + }, + ); + return { publicFacet, zoe }; +}; + +test('stakeBld contract - makeAccount, deposit, withdraw', async t => { + const issuerKit = makeIssuerKit('BLD'); + const stake = withAmountUtils(issuerKit); + + const bootstrapSpace = await bootstrap(t, { issuerKit }); + const { publicFacet } = await coreEval(t, { ...bootstrapSpace, stake }); + + t.log('make a LocalChainAccount'); + const account = await E(publicFacet).makeAccount(); + t.truthy(account, 'account is returned'); + t.regex(await E(account).getAddress(), /agoric1/); + + const oneHundredStakeAmt = stake.make(1_000_000_000n); + const oneHundredStakePmt = issuerKit.mint.mintPayment(oneHundredStakeAmt); + + t.log('deposit 100 bld to account'); + const depositResp = await E(account).deposit(oneHundredStakePmt); + t.true(AmountMath.isEqual(depositResp, oneHundredStakeAmt), 'deposit'); + + // TODO validate balance, .getBalance() + + t.log('withdraw 1 bld from account'); + const withdrawResp = await E(account).withdraw(oneHundredStakeAmt); + // @ts-expect-error Argument of type 'Payment' is not assignable to parameter of type 'ERef>'. + const withdrawAmt = await stake.issuer.getAmountOf(withdrawResp); + t.true(AmountMath.isEqual(withdrawAmt, oneHundredStakeAmt), 'withdraw'); + + t.log('cannot withdraw more than balance'); + await t.throwsAsync( + () => E(account).withdraw(oneHundredStakeAmt), + { + message: /Withdrawal of {.*} failed/, + }, + 'cannot withdraw more than balance', + ); +}); + +test('stakeBld contract - makeStakeBldInvitation', async t => { + const issuerKit = makeIssuerKit('BLD'); + const stake = withAmountUtils(issuerKit); + + const bootstrapSpace = await bootstrap(t, { issuerKit }); + const { publicFacet, zoe } = await coreEval(t, { ...bootstrapSpace, stake }); + + t.log('call makeStakeBldInvitation'); + const inv = await E(publicFacet).makeStakeBldInvitation(); + + const hundred = stake.make(1_000_000_000n); + + t.log('make an offer for an account'); + // Want empty until (at least) #9087 + const userSeat = await E(zoe).offer( + inv, + { give: { In: hundred } }, + { In: stake.mint.mintPayment(hundred) }, + ); + const { invitationMakers } = await E(userSeat).getOfferResult(); + t.truthy(invitationMakers, 'received continuing invitation'); + + t.log('make Delegate offer using invitationMakers'); + const delegateInv = await E(invitationMakers).Delegate('agoric1validator1', { + brand: stake.brand, + value: 1_000_000_000n, + }); + const delegateOffer = await E(zoe).offer( + delegateInv, + { give: { In: hundred } }, + { In: stake.mint.mintPayment(hundred) }, + ); + const res = await E(delegateOffer).getOfferResult(); + t.deepEqual(res, {}); + t.log('Successfully delegated'); + + await t.throwsAsync(() => E(invitationMakers).TransferAccount(), { + message: 'not yet implemented', + }); + await t.throwsAsync(() => E(invitationMakers).CloseAccount(), { + message: 'not yet implemented', + }); +}); + +test('stakeBld contract - makeAccountInvitationMaker', async t => { + const issuerKit = makeIssuerKit('BLD'); + const stake = withAmountUtils(issuerKit); + + const bootstrapSpace = await bootstrap(t, { issuerKit }); + const { publicFacet, zoe } = await coreEval(t, { ...bootstrapSpace, stake }); + + t.log('call makeAcountInvitationMaker'); + const inv = await E(publicFacet).makeAcountInvitationMaker(); + + const userSeat = await E(zoe).offer(inv); + const offerResult = await E(userSeat).getOfferResult(); + t.true('account' in offerResult, 'received account'); + t.truthy('invitationMakers' in offerResult, 'received continuing invitation'); +}); diff --git a/packages/orchestration/test/exos/local-chain-account-kit.test.ts b/packages/orchestration/test/exos/local-chain-account-kit.test.ts new file mode 100644 index 00000000000..0aaf9f3f717 --- /dev/null +++ b/packages/orchestration/test/exos/local-chain-account-kit.test.ts @@ -0,0 +1,159 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { makeMockChainStorageRoot } from '@agoric/internal/src/storage-test-utils.js'; +import { M, makeScalarBigMapStore } from '@agoric/vat-data'; +import { prepareLocalChainTools } from '@agoric/vats/src/localchain.js'; +import { makeFakeBoard } from '@agoric/vats/tools/board-utils.js'; +import { buildRootObject as buildBankVatRoot } from '@agoric/vats/src/vat-bank.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import { withAmountUtils } from '@agoric/zoe/tools/test-utils.js'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { makeHeapZone } from '@agoric/zone'; +import { E, Far } from '@endo/far'; +import { makeFakeLocalchainBridge } from '../supports.js'; +import { prepareLocalChainAccountKit } from '../../src/exos/local-chain-account-kit.js'; +import { prepareMockChainInfo } from '../../src/utils/mockChainInfo.js'; +import { ChainAddress } from '../../src/orchestration-api.js'; +import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; + +test('localChainAccountKit - transfer', async t => { + const bootstrap = async () => { + const zone = makeHeapZone(); + const issuerKit = makeIssuerKit('BLD'); + const stake = withAmountUtils(issuerKit); + + const bankManager = await buildBankVatRoot( + undefined, + undefined, + zone.mapStore('bankManager'), + ).makeBankManager(); + + await E(bankManager).addAsset('ubld', 'BLD', 'Staking Token', issuerKit); + const localchainBridge = makeFakeLocalchainBridge(zone); + const localchain = prepareLocalChainTools( + zone.subZone('localchain'), + ).makeLocalChain({ + bankManager, + system: localchainBridge, + }); + const timer = buildManualTimer(t.log); + const marshaller = makeFakeBoard().getReadonlyMarshaller(); + + return { + timer, + localchain, + marshaller, + stake, + issuerKit, + rootZone: zone, + }; + }; + + const { timer, localchain, stake, marshaller, issuerKit, rootZone } = + await bootstrap(); + + t.log('chainInfo mocked via `prepareMockChainInfo` until #8879'); + const agoricChainInfo = prepareMockChainInfo(rootZone.subZone('chainInfo')); + + t.log('exo setup - prepareLocalChainAccountKit'); + const baggage = makeScalarBigMapStore('baggage', { + durable: true, + }); + const { makeRecorderKit } = prepareRecorderKitMakers(baggage, marshaller); + const makeLocalChainAccountKit = prepareLocalChainAccountKit( + baggage, + makeRecorderKit, + // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer + Far('MockZCF', {}), + timer, + timer.getTimerBrand(), + agoricChainInfo, + ); + + t.log('request account from vat-localchain'); + const lca = await E(localchain).makeAccount(); + const address = await E(lca).getAddress(); + + t.log('make a LocalChainAccountKit'); + const { holder: account } = makeLocalChainAccountKit({ + account: lca, + address, + storageNode: makeMockChainStorageRoot().makeChildNode('lcaKit'), + }); + + t.truthy(account, 'account is returned'); + t.regex(await E(account).getAddress(), /agoric1/); + + const oneHundredStakeAmt = stake.make(1_000_000_000n); + const oneHundredStakePmt = issuerKit.mint.mintPayment(oneHundredStakeAmt); + const oneStakeAmt = stake.make(1_000_000n); + + t.log('deposit 100 bld to account'); + const depositResp = await E(account).deposit(oneHundredStakePmt); + t.true(AmountMath.isEqual(depositResp, oneHundredStakeAmt), 'deposit'); + + const destination: ChainAddress = { + chainId: 'cosmoslocal', + address: 'cosmos1pleab', + addressEncoding: 'bech32', + }; + + // TODO #9211, support ERTP amounts + t.log('ERTP Amounts not yet supported for AmountArg'); + await t.throwsAsync(() => E(account).transfer(oneStakeAmt, destination), { + message: 'ERTP Amounts not yet supported', + }); + + t.log('.transfer() 1 bld to cosmos using DenomAmount'); + const transferResp = await E(account).transfer( + { denom: 'ubld', value: 1_000_000n }, + destination, + ); + t.is(transferResp, undefined, 'Successful transfer returns Promise.'); + + await t.throwsAsync( + () => E(account).transfer({ denom: 'ubld', value: 504n }, destination), + { + message: 'simulated unexpected MsgTransfer packet timeout', + }, + ); + + const unknownDestination: ChainAddress = { + chainId: 'fakenet', + address: 'fakenet1pleab', + addressEncoding: 'bech32', + }; + await t.throwsAsync( + () => E(account).transfer({ denom: 'ubld', value: 1n }, unknownDestination), + { + message: /not found(.*)fakenet/, + }, + 'cannot create transfer msg with unknown chainId', + ); + + await t.notThrowsAsync( + () => + E(account).transfer({ denom: 'ubld', value: 10n }, destination, { + memo: 'hello', + }), + 'can create transfer msg with memo', + ); + // TODO, intercept/spy the bridge message to see that it has a memo + + await t.notThrowsAsync( + () => + E(account).transfer({ denom: 'ubld', value: 10n }, destination, { + // sets to current time, which shouldn't work in a real env + timeoutTimestamp: BigInt(new Date().getTime()) * NANOSECONDS_PER_SECOND, + }), + 'accepts custom timeoutTimestamp', + ); + + await t.notThrowsAsync( + () => + E(account).transfer({ denom: 'ubld', value: 10n }, destination, { + timeoutHeight: { revisionHeight: 100n, revisionNumber: 1n }, + }), + 'accepts custom timeoutHeight', + ); +}); diff --git a/packages/orchestration/test/staking-ops.test.ts b/packages/orchestration/test/staking-ops.test.ts index 52cd9564e2e..159563f308f 100644 --- a/packages/orchestration/test/staking-ops.test.ts +++ b/packages/orchestration/test/staking-ops.test.ts @@ -1,4 +1,3 @@ -// @ts-check import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { MsgWithdrawDelegatorRewardResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; diff --git a/packages/orchestration/test/utils/time.test.ts b/packages/orchestration/test/utils/time.test.ts new file mode 100644 index 00000000000..5c2fb015929 --- /dev/null +++ b/packages/orchestration/test/utils/time.test.ts @@ -0,0 +1,40 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; +import { TimeMath } from '@agoric/time'; +import { + makeTimestampHelper, + NANOSECONDS_PER_SECOND, + SECONDS_PER_MINUTE, +} from '../../src/utils/time.js'; + +test('makeTimestampHelper - getCurrentTimestamp', async t => { + const timer = buildManualTimer(t.log); + const timerBrand = timer.getTimerBrand(); + t.is(timer.getCurrentTimestamp().absValue, 0n, 'current time is 0n'); + + const { getTimeoutTimestampNS } = makeTimestampHelper(timer, timerBrand); + await null; + t.is( + await getTimeoutTimestampNS(), + 5n * SECONDS_PER_MINUTE * NANOSECONDS_PER_SECOND, + 'default timestamp is 5 minutes from current time, in nanoseconds', + ); + + t.is( + await getTimeoutTimestampNS( + TimeMath.coerceRelativeTimeRecord(1n, timerBrand), + ), + 1n * NANOSECONDS_PER_SECOND, + 'timestamp is 1 second since unix epoch, in nanoseconds', + ); + + // advance timer by 3 seconds + await timer.tickN(3); + t.is( + await getTimeoutTimestampNS( + TimeMath.coerceRelativeTimeRecord(1n, timerBrand), + ), + (1n + 3n) * NANOSECONDS_PER_SECOND, + 'timestamp is 4 seconds since unix epoch, in nanoseconds', + ); +}); diff --git a/packages/vats/test/localchain.test.js b/packages/vats/test/localchain.test.js index b960aac08cd..c8542ad8da7 100644 --- a/packages/vats/test/localchain.test.js +++ b/packages/vats/test/localchain.test.js @@ -46,9 +46,7 @@ const makeTestContext = async _t => { /** @param {LocalChainPowers} powers */ const makeLocalChain = async powers => { const zone = makeDurableZone(provideBaggage('localchain')); - return prepareLocalChainTools(zone.subZone('localchain')).makeLocalChain( - powers, - ); + return prepareLocalChainTools(zone).makeLocalChain(powers); }; const localchain = await makeLocalChain({ @@ -94,7 +92,7 @@ test('localchain - deposit and withdraw', async t => { t.is(getInterfaceOf(lca), 'Alleged: LocalChainAccount'); const address = await E(lca).getAddress(); - t.is(address, 'agoric1fakeBridgeAddress'); + t.is(address, 'agoric1fakeLCAAddress'); contractsLca = lca; }, deposit: async () => { diff --git a/packages/vats/tools/fake-bridge.js b/packages/vats/tools/fake-bridge.js index be4d9afbf26..e191e72c880 100644 --- a/packages/vats/tools/fake-bridge.js +++ b/packages/vats/tools/fake-bridge.js @@ -1,7 +1,10 @@ import { Fail } from '@agoric/assert'; import assert from 'node:assert/strict'; -/** @import {ScopedBridgeManager} from '../src/types.js'; */ +/** + * @import {MsgDelegateResponse} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; + * @import {ScopedBridgeManager} from '../src/types.js'; + */ /** * @param {import('@agoric/zone').Zone} zone @@ -50,6 +53,7 @@ export const makeFakeLocalchainBridge = ( onFromBridge = () => {}, ) => { let hndlr; + let lcaExecuteTxSequence = 0; return zone.exo('Fake Localchain Bridge Manager', undefined, { getBridgeId: () => 'vlocalchain', toBridge: async obj => { @@ -58,7 +62,33 @@ export const makeFakeLocalchainBridge = ( console.info('toBridge', type, method, params); switch (type) { case 'VLOCALCHAIN_ALLOCATE_ADDRESS': - return 'agoric1fakeBridgeAddress'; + return 'agoric1fakeLCAAddress'; + case 'VLOCALCHAIN_EXECUTE_TX': { + lcaExecuteTxSequence += 1; + return obj.messages.map(message => { + switch (message['@type']) { + // TODO #9402 reference bank to ensure caller has tokens they are transferring + case '/ibc.applications.transfer.v1.MsgTransfer': { + if (message.token.amount === '504') { + throw Error( + 'simulated unexpected MsgTransfer packet timeout', + ); + } + // like `JsonSafe`, but bigints are converted to numbers + // XXX should vlocalchain return a string instead of number for bigint? + return { + sequence: lcaExecuteTxSequence, + }; + } + case '/cosmos.staking.v1beta1.MsgDelegate': { + return /** @type {MsgDelegateResponse} */ {}; + } + // returns one empty object per message unless specified + default: + return {}; + } + }); + } default: Fail`unknown type ${type}`; }