diff --git a/packages/ERTP/src/typeGuards.js b/packages/ERTP/src/typeGuards.js index 1b2ec30fcea..6042383c760 100644 --- a/packages/ERTP/src/typeGuards.js +++ b/packages/ERTP/src/typeGuards.js @@ -77,6 +77,11 @@ export const AmountShape = harden({ value: AmountValueShape, }); +export const NatAmountShape = harden({ + brand: BrandShape, + value: NatValueShape, +}); + export const RatioShape = harden({ numerator: AmountShape, denominator: AmountShape, diff --git a/packages/boot/test/bootstrapTests/test-orchestration.ts b/packages/boot/test/bootstrapTests/test-orchestration.ts index fa1721afd41..e77348d22fe 100644 --- a/packages/boot/test/bootstrapTests/test-orchestration.ts +++ b/packages/boot/test/bootstrapTests/test-orchestration.ts @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import type { TestFn } from 'ava'; @@ -150,6 +151,7 @@ test.serial('stakeAtom - smart wallet', async t => { 'agoric1testStakAtom', ); + // 1. Make Account await wd.executeOffer({ id: 'request-account', invitationSpec: { @@ -167,9 +169,131 @@ test.serial('stakeAtom - smart wallet', async t => { t.like(wd.getLatestUpdateRecord(), { status: { id: 'request-account', numWantsSatisfied: 1 }, }); - - const { ATOM } = agoricNamesRemotes.brand; + const { ATOM, DAI_axl, BLD } = agoricNamesRemotes.brand; ATOM || Fail`ATOM missing from agoricNames`; + DAI_axl || Fail`DAI_axl missing from agoricNames`; + BLD || Fail`BLD missing from agoricNames`; + + // 2. Deposit to Account + await wd.executeOffer({ + id: 'request-deposit-success', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-account', + invitationMakerName: 'Deposit', + }, + proposal: { + give: { + // @ts-expect-error BoardRemote is not assignable to Brand + ATOM: { brand: ATOM, value: 100n }, + }, + exit: { waived: null }, + }, + }); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'request-deposit-success', numWantsSatisfied: 1 }, + }); + + await t.throwsAsync( + wd.executeOffer({ + id: 'request-deposit-failure-no-want-allowed', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-account', + invitationMakerName: 'Deposit', + }, + proposal: { + give: { + // @ts-expect-error BoardRemote is not assignable to Brand + ATOM: { brand: ATOM, value: 100n }, + }, + want: { + // @ts-expect-error BoardRemote is not assignable to Brand + BLD: { brand: BLD, value: 100n }, + }, + exit: { waived: null }, + }, + }), + { + message: /proposal: want(.*?)Must be: {}/, + }, + ); + + await t.throwsAsync( + wd.executeOffer({ + id: 'request-deposit-failure-two-give-amounts', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-account', + invitationMakerName: 'Deposit', + }, + proposal: { + give: { + // @ts-expect-error BoardRemote is not assignable to Brand + ATOM: { brand: ATOM, value: 100n }, + // @ts-expect-error BoardRemote is not assignable to Brand + BLD: { brand: BLD, value: 100n }, + }, + exit: { waived: null }, + }, + }), + { + message: /proposal: give: Must not have more than 1 properties/, + }, + ); + + await t.throwsAsync( + wd.executeOffer({ + id: 'request-deposit-failure-unknown-issuer', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-account', + invitationMakerName: 'Deposit', + }, + proposal: { + give: { + // @ts-expect-error BoardRemote is not assignable to Brand + DAI_axl: { brand: DAI_axl, value: 100n }, + }, + exit: { waived: null }, + }, + }), + { + message: /brand(.*?)not registered/, + }, + ); + + await t.throwsAsync( + wd.executeOffer({ + id: 'request-deposit-failure-transfer-packet-timeout', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-account', + invitationMakerName: 'Deposit', + }, + proposal: { + give: { + // @ts-expect-error BoardRemote is not assignable to Brand + ATOM: { brand: ATOM, value: 504n }, + }, + exit: { waived: null }, + }, + }), + { + message: 'Deposit failed, payment returned.', + }, + ); + t.like(wd.getLatestUpdateRecord(), { + status: { + id: 'request-deposit-failure-transfer-packet-timeout', + numWantsSatisfied: 1, + payouts: { + ATOM: { value: 504n }, + }, + }, + }); + + // 3. Delegate from Account to Validator const validatorAddress: CosmosValidatorAddress = { address: 'cosmosvaloper1test', chainId: 'gaiatest', @@ -192,12 +316,12 @@ test.serial('stakeAtom - smart wallet', async t => { status: { id: 'request-delegate-success', numWantsSatisfied: 1 }, }); + // 4. Delegate Failure (invalid validator address or amount) const validatorAddressFail: CosmosValidatorAddress = { address: 'cosmosvaloper1fail', chainId: 'gaiatest', addressEncoding: 'bech32', }; - await t.throwsAsync( wd.executeOffer({ id: 'request-delegate-fail', @@ -215,3 +339,5 @@ test.serial('stakeAtom - smart wallet', async t => { 'delegate fails with invalid validator', ); }); + +test.todo('deposit to LCA fails, payment should be returned'); diff --git a/packages/boot/tools/supports.ts b/packages/boot/tools/supports.ts index 45ca480db63..c8b4d722570 100644 --- a/packages/boot/tools/supports.ts +++ b/packages/boot/tools/supports.ts @@ -295,6 +295,7 @@ export const makeSwingsetTestKit = async ( let inbound; let ibcSequenceNonce = 0; + let icaExecuteTxSequence = 0; const makeAckEvent = (obj: IBCMethod<'sendPacket'>, ack: string) => { ibcSequenceNonce += 1; @@ -429,9 +430,25 @@ export const makeSwingsetTestKit = async ( switch (obj.type) { case 'VLOCALCHAIN_ALLOCATE_ADDRESS': return 'agoric1mockVlocalchainAddress'; - case 'VLOCALCHAIN_EXECUTE_TX': - // returns one empty object per message - return obj.messages.map(() => ({})); + case 'VLOCALCHAIN_EXECUTE_TX': { + icaExecuteTxSequence += 1; + // returns one empty object per message unless specified + return obj.messages.map(message => { + switch (message['@type']) { + case '/ibc.applications.transfer.v1.MsgTransfer': { + if (message.token.amount === '504') { + throw Error('simulated MsgTransfer packet timeout'); + } + // like `JsonSafe`, but bigints are converted to numbers + return { + sequence: icaExecuteTxSequence, + }; + } + default: + return {}; + } + }); + } default: throw Error(`VLOCALCHAIN message of unknown type ${obj.type}`); } diff --git a/packages/builders/scripts/orchestration/init-stakeAtom.js b/packages/builders/scripts/orchestration/init-stakeAtom.js index 3ab10b627e2..0b8dfd2e99b 100644 --- a/packages/builders/scripts/orchestration/init-stakeAtom.js +++ b/packages/builders/scripts/orchestration/init-stakeAtom.js @@ -7,8 +7,16 @@ export const defaultProposalBuilder = async ( ) => { const { hostConnectionId = 'connection-1', - controllerConnectionId = 'connection-0', + controllerConnectionId = 'connection-1', bondDenom = 'uatom', + bondDenomLocal = 'ibc/C4CFF46FD6DE35CA4CF4CE031E643C8FDC9BA4B99AE598E9B0ED98FE3A2319F9', + transferChannel = { + counterpartyChannelId: 'channel-1', + counterpartyPortId: 'transfer', + sourceChannelId: 'channel-1', + sourcePortId: 'transfer', + }, + icqEnabled = true, } = options; return harden({ sourceSpec: '@agoric/orchestration/src/proposals/start-stakeAtom.js', @@ -23,6 +31,9 @@ export const defaultProposalBuilder = async ( hostConnectionId, controllerConnectionId, bondDenom, + bondDenomLocal, + transferChannel, + icqEnabled, }, ], }); diff --git a/packages/cosmic-proto/src/helpers.ts b/packages/cosmic-proto/src/helpers.ts index 070081bed8e..910465926fa 100644 --- a/packages/cosmic-proto/src/helpers.ts +++ b/packages/cosmic-proto/src/helpers.ts @@ -3,6 +3,7 @@ import type { MsgSend } from './codegen/cosmos/bank/v1beta1/tx.js'; import type { MsgDelegate } 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 } from './codegen/ibc/applications/transfer/v1/tx.js'; /** * The result of Any.toJSON(). The type in cosms-types says it returns @@ -16,6 +17,7 @@ export type Proto3Shape = { '/cosmos.bank.v1beta1.MsgSend': MsgSend; '/cosmos.bank.v1beta1.QueryAllBalancesRequest': QueryAllBalancesRequest; '/cosmos.staking.v1beta1.MsgDelegate': MsgDelegate; + '/ibc.applications.transfer.v1.MsgTransfer': MsgTransfer; }; /** diff --git a/packages/orchestration/package.json b/packages/orchestration/package.json index f37d3b47b17..b6787ce05dc 100644 --- a/packages/orchestration/package.json +++ b/packages/orchestration/package.json @@ -48,6 +48,7 @@ "@endo/patterns": "^1.3.1" }, "devDependencies": { + "@agoric/swingset-vat": "^0.32.2", "@cosmjs/amino": "^0.32.3", "@cosmjs/proto-signing": "^0.32.3", "@endo/ses-ava": "^1.2.1", @@ -60,7 +61,8 @@ }, "files": [ "test/**/*.test.js", - "test/**/*.test.ts" + "test/**/*.test.ts", + "test/**/test-*.js" ], "nodeArguments": [ "--loader=tsx", diff --git a/packages/orchestration/src/examples/stakeAtom.contract.js b/packages/orchestration/src/examples/stakeAtom.contract.js index d6cfd8dd94d..df7f3060af0 100644 --- a/packages/orchestration/src/examples/stakeAtom.contract.js +++ b/packages/orchestration/src/examples/stakeAtom.contract.js @@ -11,9 +11,11 @@ import { prepareStakingAccountKit } from '../exos/stakingAccountKit.js'; const trace = makeTracer('StakeAtom'); /** - * @import { Baggage } from '@agoric/vat-data'; - * @import { IBCConnectionID } from '@agoric/vats'; - * @import { ICQConnection, OrchestrationService } from '../types.js'; + * @import {Baggage} from '@agoric/vat-data'; + * @import {IBCConnectionID} from '@agoric/vats'; + * @import {LocalChain} from '@agoric/vats/src/localchain.js'; + * @import {IBCChannelInfo, OrchestrationService, BrandToIssuer} from '@agoric/orchestration'; + * @import {TimerBrand, TimerService} from '@agoric/time' */ /** @@ -21,6 +23,10 @@ const trace = makeTracer('StakeAtom'); * hostConnectionId: IBCConnectionID; * controllerConnectionId: IBCConnectionID; * bondDenom: string; + * bondDenomLocal: string; + * transferChannel: IBCChannelInfo; + * icqEnabled: boolean; + * chainTimerBrand: TimerBrand; * }} StakeAtomTerms */ @@ -28,17 +34,34 @@ const trace = makeTracer('StakeAtom'); * * @param {ZCF} zcf * @param {{ + * localchain: LocalChain; * orchestration: OrchestrationService; * storageNode: StorageNode; * marshaller: Marshaller; + * chainTimerService: TimerService; * }} privateArgs * @param {Baggage} baggage */ export const start = async (zcf, privateArgs, baggage) => { // TODO #9063 this roughly matches what we'll get from Chain.getChainInfo() - const { hostConnectionId, controllerConnectionId, bondDenom } = - zcf.getTerms(); - const { orchestration, marshaller, storageNode } = privateArgs; + const { + hostConnectionId, + controllerConnectionId, + bondDenom, + bondDenomLocal, + transferChannel, + issuers, + brands, + icqEnabled, + chainTimerBrand, + } = zcf.getTerms(); + const { + localchain, + orchestration, + marshaller, + storageNode, + chainTimerService, + } = privateArgs; const zone = makeDurableZone(baggage); @@ -50,25 +73,40 @@ export const start = async (zcf, privateArgs, baggage) => { zcf, ); + /** @type {BrandToIssuer} */ + const brandToIssuer = zone.mapStore('brandToIssuer'); + for (const [keyword, brand] of Object.entries(brands)) { + brandToIssuer.init(brand, issuers[keyword]); + } + async function makeAccount() { const account = await E(orchestration).makeAccount( hostConnectionId, controllerConnectionId, ); - // #9212 TODO do not fail if host does not have `async-icq` module; - // communicate to OrchestrationAccount that it can't send queries - const icqConnection = await E(orchestration).provideICQConnection( - controllerConnectionId, - ); - const accountAddress = await E(account).getAddress(); - trace('account address', accountAddress); - const { holder, invitationMakers } = makeStakingAccountKit( + + // TODO #9063, #9212 this should come from Chain object + const icqConnection = icqEnabled + ? await E(orchestration).provideICQConnection(controllerConnectionId) + : undefined; + + const localAccount = await E(localchain).makeAccount(); + const localAccountAddress = await E(localAccount).getAddress(); + const chainAddress = await E(account).getAddress(); + const { holder, invitationMakers } = makeStakingAccountKit({ account, + localAccount, storageNode, - accountAddress, + chainAddress, + localAccountAddress, icqConnection, bondDenom, - ); + bondDenomLocal, + transferChannel, + brandToIssuer, + chainTimerService, + chainTimerBrand, + }); return { publicSubscribers: holder.getPublicTopics(), invitationMakers, diff --git a/packages/orchestration/src/exos/chainAccountKit.js b/packages/orchestration/src/exos/chainAccountKit.js index 6d3f4acb305..a8dc058fa36 100644 --- a/packages/orchestration/src/exos/chainAccountKit.js +++ b/packages/orchestration/src/exos/chainAccountKit.js @@ -136,6 +136,10 @@ export const prepareChainAccountKit = zone => if (!connection) throw Fail`connection not available`; await E(connection).close(); }, + /** + * see stakingAccountKit.js for an example until #9212 + * @param {Payment} payment + */ async deposit(payment) { console.log('deposit got', payment); throw new Error('not yet implemented'); diff --git a/packages/orchestration/src/exos/stakingAccountKit.js b/packages/orchestration/src/exos/stakingAccountKit.js index ba0096e496a..7c0a552ff8f 100644 --- a/packages/orchestration/src/exos/stakingAccountKit.js +++ b/packages/orchestration/src/exos/stakingAccountKit.js @@ -13,26 +13,49 @@ import { QueryBalanceResponse, } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; -import { AmountShape } from '@agoric/ertp'; +import { AmountShape, PaymentShape, AmountMath } 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 { + depositToSeat, + withdrawFromSeat, +} from '@agoric/zoe/src/contractSupport/zoeHelpers.js'; import { decodeBase64 } from '@endo/base64'; import { E } from '@endo/far'; -import { toRequestQueryJson } from '@agoric/cosmic-proto'; -import { ChainAddressShape, CoinShape } from '../typeGuards.js'; +import { toRequestQueryJson, typedJson } from '@agoric/cosmic-proto'; +import { TimeMath } from '@agoric/time'; +import { + ChainAddressShape, + CoinShape, + DepositProposalShape, +} from '../typeGuards.js'; /** * @import {ChainAccount, ChainAddress, ChainAmount, CosmosValidatorAddress, ICQConnection} from '../types.js'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'; * @import {Baggage} from '@agoric/swingset-liveslots'; * @import {AnyJson} from '@agoric/cosmic-proto'; + * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; + * @import {Payment} from '@agoric/ertp/exported.js'; + * @import {TimerService, RelativeTimeRecord, TimerBrand} from '@agoric/time'; + * @import {IBCChannelInfo} from '../types.js'; */ const trace = makeTracer('StakingAccountHolder'); const { Fail } = assert; + +const FIVE_MINUTES_IN_SECONDS = 300n; +const SECONDS_TO_NANOSECONDS = 1_000_000_000n; + +/** + * Utility to help verify Payments presented to the contract are + * from known issuers. + * @typedef {MapStore} BrandToIssuer + */ + /** * @typedef {object} StakingAccountNotification * @property {ChainAddress} chainAddress @@ -42,9 +65,16 @@ const { Fail } = assert; * @typedef {{ * topicKit: RecorderKit; * account: ChainAccount; + * localAccount: LocalChainAccount; * chainAddress: ChainAddress; - * icqConnection: ICQConnection; + * localAccountAddress: ChainAddress['address']; + * icqConnection: ICQConnection | undefined; * bondDenom: string; + * bondDenomLocal: string; + * transferChannel: IBCChannelInfo; + * brandToIssuer: BrandToIssuer; + * chainTimerService: TimerService; + * chainTimerBrand: TimerBrand; * }} State */ @@ -53,6 +83,11 @@ export const ChainAccountHolderI = M.interface('ChainAccountHolder', { getAddress: M.call().returns(ChainAddressShape), getBalance: M.callWhen().optional(M.string()).returns(CoinShape), delegate: M.callWhen(ChainAddressShape, AmountShape).returns(M.record()), + deposit: M.callWhen(PaymentShape) + .optional({ + timeoutTimestamp: M.bigint(), + }) + .returns({ sequence: M.number() }), withdrawReward: M.callWhen(ChainAddressShape).returns(M.arrayOf(CoinShape)), }); @@ -102,24 +137,60 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { holder: ChainAccountHolderI, invitationMakers: M.interface('invitationMakers', { Delegate: M.call(ChainAddressShape, AmountShape).returns(M.promise()), + Deposit: M.call().returns(M.promise()), WithdrawReward: M.call(ChainAddressShape).returns(M.promise()), CloseAccount: M.call().returns(M.promise()), TransferAccount: M.call().returns(M.promise()), }), }, /** - * @param {ChainAccount} account - * @param {StorageNode} storageNode - * @param {ChainAddress} chainAddress - * @param {ICQConnection} icqConnection - * @param {string} bondDenom e.g. 'uatom' + * @param {{ + * account: ChainAccount; + * localAccount: LocalChainAccount; + * storageNode: StorageNode; + * chainAddress: ChainAddress; + * localAccountAddress: ChainAddress['address']; + * icqConnection: ICQConnection | undefined; + * bondDenom: string; + * bondDenomLocal: string; + * transferChannel: IBCChannelInfo; + * brandToIssuer: BrandToIssuer; + * chainTimerService: TimerService; + * chainTimerBrand: TimerBrand; + * }} initState * @returns {State} */ - (account, storageNode, chainAddress, icqConnection, bondDenom) => { + ({ + account, + localAccount, + storageNode, + chainAddress, + localAccountAddress, + icqConnection, + bondDenom, + bondDenomLocal, + transferChannel, + brandToIssuer, + chainTimerService, + chainTimerBrand, + }) => { // must be the fully synchronous maker because the kit is held in durable state const topicKit = makeRecorderKit(storageNode, PUBLIC_TOPICS.account[1]); - return { account, chainAddress, topicKit, icqConnection, bondDenom }; + return harden({ + account, + localAccount, + chainAddress, + localAccountAddress, + topicKit, + icqConnection, + bondDenom, + bondDenomLocal, + transferChannel, + brandToIssuer, + chainTimerService, + chainTimerBrand, + }); }, { helper: { @@ -134,6 +205,26 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { getUpdater() { return this.state.topicKit.recorder; }, + /** + * Takes the current time from ChainTimerService and adds a relative + * time to determine a timeout timestamp in nanoseconds. + * @param {RelativeTimeRecord} [relativeTime] defaults to 5 minutes + * @returns {Promise} Timeout timestamp in absolute nanoseconds since unix epoch + */ + async getTimeoutTimestamp(relativeTime) { + const { chainTimerService, chainTimerBrand } = this.state; + const currentTime = await E(chainTimerService).getCurrentTimestamp(); + return ( + TimeMath.addAbsRel( + currentTime, + relativeTime || + TimeMath.coerceRelativeTimeRecord( + FIVE_MINUTES_IN_SECONDS, + chainTimerBrand, + ), + ).absValue * SECONDS_TO_NANOSECONDS + ); + }, }, invitationMakers: { /** @@ -149,6 +240,49 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { return this.facets.holder.delegate(validator, amount); }, 'Delegate'); }, + Deposit() { + trace('Deposit'); + // TODO consider adding timeoutTimestamp as a parameter. current + // default is `FIVE_MINUTES_IN_SECONDS` + return zcf.makeInvitation( + async seat => { + const { give } = seat.getProposal(); + // only one entry permitted by proposal shape + const [keyword, giveAmount] = Object.entries(give)[0]; + this.state.brandToIssuer.has(giveAmount.brand) || + Fail`${giveAmount.brand} not registered`; + + const payments = await withdrawFromSeat(zcf, seat, give); + const payment = await Object.values(payments)[0]; + try { + await this.facets.holder.deposit(payment); + } catch (_depositOrTransferError) { + try { + await depositToSeat(zcf, seat, give, payments); + throw Fail`Deposit failed, payment returned.`; + } catch (error) { + if (error.message.includes('not a live paymen')) { + const pmt = await E(this.state.localAccount).withdraw( + giveAmount, + ); + // @ts-expect-error VirtualPurse vs Purse? + await depositToSeat(zcf, seat, give, { + [keyword]: pmt, + }); + throw Fail`Deposit failed, payment returned.`; + } else { + throw error; + } + } + } finally { + seat.exit(); + } + }, + 'Deposit', + undefined, + DepositProposalShape, + ); + }, /** @param {CosmosValidatorAddress} validator */ WithdrawReward(validator) { trace('WithdrawReward', validator); @@ -180,21 +314,20 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { }, }); }, - // TODO move this beneath the Orchestration abstraction, + // #9212 TODO move this beneath the Orchestration abstraction, // to the OrchestrationAccount provided by makeAccount() /** @returns {ChainAddress} */ getAddress() { return this.state.chainAddress; }, /** - * _Assumes users has already sent funds to their ICA, until #9193 * @param {CosmosValidatorAddress} validator * @param {Amount<'nat'>} ertpAmount */ async delegate(validator, ertpAmount) { trace('delegate', validator, ertpAmount); - // FIXME brand handling and amount scaling #9211 + // FIXME brand handling #9211 trace('TODO: handle brand', ertpAmount); const amount = { amount: String(ertpAmount.value), @@ -217,7 +350,60 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { if (!result) throw Fail`Failed to delegate.`; return tryDecodeResponse(result, MsgDelegateResponse.fromProtoMsg); }, + /** + * Only `bondDenom` deposits accepted until #9211, #9063 + * @param {Payment} payment + * @param {{ timeoutTimestamp: bigint }} [opts] + * @returns {Promise<{ sequence: number }>} + */ + async deposit(payment, opts) { + const brand = await E(payment).getAllegedBrand(); + const issuer = this.state.brandToIssuer.get(brand); + issuer || Fail`Unknown Issuer for Brand ${brand}.`; + + const amount = await E(issuer).getAmountOf(payment); + !AmountMath.isEmpty(amount) || + Fail`Payment amount must be greater than 0.`; + const { localAccount } = this.state; + trace('Depositing funds to LCA'); + // XXX consider adding exposing an interface to withdraw / send + // messages from the LCA (e.g., withdraw funds) + await E(localAccount).deposit(payment); + + const timeoutTimestamp = + opts?.timeoutTimestamp ?? + (await this.facets.helper.getTimeoutTimestamp()); + + trace('Transferring funds to ICA'); + /** + * // TODO can we infer `/ibc.applications.transfer.v1.MsgTransferResponse`? + * @type {unknown[]} + */ + const [result] = await E(localAccount).executeTx([ + typedJson('/ibc.applications.transfer.v1.MsgTransfer', { + sourcePort: this.state.transferChannel.sourcePortId, + sourceChannel: this.state.transferChannel.sourceChannelId, + token: { + amount: String(amount.value), + // TODO use Amount (of Payment) to determine denom #9211, #9063 (`ibc/toyatom` won't work here) + denom: this.state.bondDenom, + }, + sender: this.state.localAccountAddress, + receiver: this.state.chainAddress.address, + timeoutHeight: { + revisionHeight: 0n, + revisionNumber: 0n, + }, + // #9324 what's a reasonable timeout? currently using `FIVE_MINUTES_IN_SECONDS` + timeoutTimestamp, + memo: '', + }), + ]); + trace('MsgTransfer result', result); + + return /** @type {{ sequence: number }} */ (result); + }, /** * @param {CosmosValidatorAddress} validator * @returns {Promise} @@ -246,6 +432,8 @@ export const prepareStakingAccountKit = (baggage, makeRecorderKit, zcf) => { denom ||= bondDenom; assert.typeof(denom, 'string'); + if (!icqConnection) throw Fail`Queries not enabled`; + const [result] = await E(icqConnection).query([ toRequestQueryJson( QueryBalanceRequest.toProtoMsg({ diff --git a/packages/orchestration/src/proposals/start-stakeAtom.js b/packages/orchestration/src/proposals/start-stakeAtom.js index 888765fd18e..ad5be404688 100644 --- a/packages/orchestration/src/proposals/start-stakeAtom.js +++ b/packages/orchestration/src/proposals/start-stakeAtom.js @@ -3,7 +3,10 @@ import { makeTracer } from '@agoric/internal'; import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; import { E } from '@endo/far'; -/** @import { StakeAtomSF, StakeAtomTerms} from '../examples/stakeAtom.contract' */ +/** + * @import {Issuer} from '@agoric/ertp/exported.js'; + * @import {StakeAtomSF, StakeAtomTerms} from '../examples/stakeAtom.contract.js'; + */ const trace = makeTracer('StartStakeAtom', true); @@ -17,6 +20,8 @@ export const startStakeAtom = async ( agoricNames, board, chainStorage, + chainTimerService: chainTimerServiceP, + localchain, orchestration, startUpgradable, }, @@ -27,32 +32,62 @@ export const startStakeAtom = async ( produce: { stakeAtom: produceInstance }, }, }, - { options: { hostConnectionId, controllerConnectionId, bondDenom } }, + { + options: { + hostConnectionId, + controllerConnectionId, + bondDenom, + bondDenomLocal, + transferChannel, + icqEnabled, + }, + }, ) => { const VSTORAGE_PATH = 'stakeAtom'; trace('startStakeAtom', { hostConnectionId, controllerConnectionId, bondDenom, + bondDenomLocal, + transferChannel, + icqEnabled, }); await null; const storageNode = await makeStorageNodeChild(chainStorage, VSTORAGE_PATH); const marshaller = await E(board).getPublishingMarshaller(); - const atomIssuer = await E(agoricNames).lookup('issuer', 'ATOM'); - trace('ATOM Issuer', atomIssuer); + + /** @type {Issuer[]} */ + const [ATOM, BLD, IST] = await Promise.all([ + E(agoricNames).lookup('issuer', 'ATOM'), + E(agoricNames).lookup('issuer', 'BLD'), + E(agoricNames).lookup('issuer', 'IST'), + ]); + + const chainTimerService = await chainTimerServiceP; + const chainTimerBrand = await E(chainTimerService).getTimerBrand(); /** @type {StartUpgradableOpts} */ const startOpts = { label: 'stakeAtom', installation: stakeAtom, - issuerKeywordRecord: harden({ ATOM: atomIssuer }), + issuerKeywordRecord: harden({ + ATOM, + BLD, + IST, + }), terms: { hostConnectionId, controllerConnectionId, bondDenom, + bondDenomLocal, + transferChannel, + icqEnabled, + chainTimerBrand, }, privateArgs: { + chainTimerService, + localchain: await localchain, orchestration: await orchestration, storageNode, marshaller, @@ -75,6 +110,8 @@ export const getManifestForStakeAtom = ( agoricNames: true, board: true, chainStorage: true, + chainTimerService: true, + localchain: true, orchestration: true, startUpgradable: true, }, diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index bab1629a032..ba489b05252 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -1,3 +1,4 @@ +import { NatAmountShape } from '@agoric/ertp'; import { M } from '@endo/patterns'; export const ConnectionHandlerI = M.interface('ConnectionHandler', { @@ -18,3 +19,19 @@ export const Proto3Shape = { }; export const CoinShape = { value: M.bigint(), denom: M.string() }; + +/** + * - `give` allows any `Nat` `issuerKeyword` record. Must be exactly one entry. + * - `exit` must be `{ waived: null }` + * - `want` must be empty + */ +export const DepositProposalShape = M.splitRecord( + { + give: M.recordOf(M.string(), NatAmountShape, { + numPropertiesLimit: 1, + }), + + exit: { waived: M.null() }, + }, + { want: {} }, +); diff --git a/packages/orchestration/src/types.d.ts b/packages/orchestration/src/types.d.ts index 13e457f29d3..183efe7da3d 100644 --- a/packages/orchestration/src/types.d.ts +++ b/packages/orchestration/src/types.d.ts @@ -26,6 +26,7 @@ export type * from './service.js'; export type * from './vat-orchestration.js'; export type * from './exos/chainAccountKit.js'; export type * from './exos/icqConnectionKit.js'; +export type * from './exos/stakingAccountKit.js'; /** * static declaration of known chain types will allow type support for @@ -266,7 +267,7 @@ export interface ChainAccount { opts?: Partial>, ) => Promise; /** deposit payment from zoe to the account*/ - deposit: (payment: Payment) => Promise; + deposit: (payment: Payment) => Promise<{ sequence: number }>; /** get Purse for a brand to .withdraw() a Payment from the account */ getPurse: (brand: Brand) => Promise; /** @@ -432,7 +433,7 @@ export interface BaseOrchestrationAccount { * deposit payment from zoe to the account. For remote accounts, * an IBC Transfer will be executed to transfer funds there. */ - deposit: (payment: Payment) => Promise; + deposit: (payment: Payment) => Promise<{ sequence: number }>; } export type OrchestrationAccount = @@ -480,3 +481,11 @@ export type SwapMaxSlippage = { brandOut: Brand; slippage: number; }; + +export type IBCChannelInfo = { + counterpartyChannelId: IBCChannelID; + counterpartyPortId: string; + sourceChannelId: IBCChannelID; + sourcePortId: string; + // consider including `order`, `version`, `state` +}; diff --git a/packages/orchestration/test/test-withdraw-reward.js b/packages/orchestration/test/test-withdraw-reward.js index 656f624f76f..8967ad760da 100644 --- a/packages/orchestration/test/test-withdraw-reward.js +++ b/packages/orchestration/test/test-withdraw-reward.js @@ -4,14 +4,17 @@ import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { MsgWithdrawDelegatorRewardResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; import { MsgDelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; -import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { makeScalarBigMapStore, makeScalarMapStore } from '@agoric/vat-data'; import { encodeBase64 } from '@endo/base64'; import { E, Far } from '@endo/far'; +import { AssetKind, makeIssuerKit } from '@agoric/ertp'; +import buildManualTimer from '@agoric/zoe/tools/manualTimer.js'; import { prepareStakingAccountKit } from '../src/exos/stakingAccountKit.js'; /** - * @import {ChainAccount, ChainAddress, CosmosValidatorAddress, ICQConnection} from '../src/types.js'; - * @import { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; + * @import {BrandToIssuer, ChainAccount, ChainAddress, CosmosValidatorAddress, ICQConnection} from '../src/types.js'; + * @import {Coin} from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; + * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; */ const test = anyTest; @@ -33,7 +36,14 @@ const scenario1 = { }, }; -const makeScenario = () => { +const mockTransferChannelInfo = { + counterpartyChannelId: 'channel-0', + counterpartyPortId: 'transfer', + sourceChannelId: 'channel-1', + sourcePortId: 'transfer', +}; + +const makeScenario = t => { const txEncode = (response, toProtoMsg) => { const protoMsg = toProtoMsg(response); const any1 = Any.fromPartial(protoMsg); @@ -151,30 +161,67 @@ const makeScenario = () => { // @ts-expect-error mock const icqConnection = Far('ICQConnection', {}); + /** @type {LocalChainAccount} */ + // @ts-expect-error mock + const localAccount = Far('LocalAccount', { + getAddress: () => 'agoric1234', + }); + + /** @type {BrandToIssuer} */ + const brandToIssuer = makeScalarMapStore('brandToIssuer'); + const { brand, issuer } = makeIssuerKit('ATOM', AssetKind.NAT, { + decimalPlaces: 6, + }); + brandToIssuer.init(brand, issuer); + + const mockTimerService = buildManualTimer(t.log); + const mockTimerBrand = mockTimerService.getTimerBrand(); + return { baggage, makeRecorderKit, ...mockAccount(undefined, delegations), storageNode, icqConnection, + localAccount, + brandToIssuer, + mockTimerService, + mockTimerBrand, ...mockZCF(), }; }; test('withdraw rewards from staking account holder', async t => { - const s = makeScenario(); + const s = makeScenario(t); const { account, calls } = s; - const { baggage, makeRecorderKit, storageNode, zcf, icqConnection } = s; + const { + baggage, + makeRecorderKit, + storageNode, + zcf, + icqConnection, + localAccount, + brandToIssuer, + mockTimerService, + mockTimerBrand, + } = s; const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); // Higher fidelity tests below use invitationMakers. - const { holder } = make( + const { holder } = make({ account, + localAccount, storageNode, - account.getAddress(), + chainAddress: account.getAddress(), + localAccountAddress: await localAccount.getAddress(), icqConnection, - 'uatom', - ); + bondDenom: 'uatom', + bondDenomLocal: 'ibc/12345', + transferChannel: mockTransferChannelInfo, + brandToIssuer, + chainTimerService: mockTimerService, + chainTimerBrand: mockTimerBrand, + }); const { validator } = scenario1; const actual = await E(holder).withdrawReward(validator); t.deepEqual(actual, [{ denom: 'uatom', value: 2n }]); @@ -186,18 +233,36 @@ test('withdraw rewards from staking account holder', async t => { }); test(`delegate; withdraw rewards`, async t => { - const s = makeScenario(); + const s = makeScenario(t); const { account, calls } = s; - const { baggage, makeRecorderKit, storageNode, zcf, zoe, icqConnection } = s; + const { + baggage, + makeRecorderKit, + storageNode, + zcf, + zoe, + icqConnection, + localAccount, + brandToIssuer, + mockTimerService, + mockTimerBrand, + } = s; const make = prepareStakingAccountKit(baggage, makeRecorderKit, zcf); - const { invitationMakers } = make( + const { invitationMakers } = make({ account, + localAccount, storageNode, - account.getAddress(), + chainAddress: account.getAddress(), + localAccountAddress: await localAccount.getAddress(), icqConnection, - 'uatom', - ); + bondDenom: 'uatom', + bondDenomLocal: 'ibc/12345', + transferChannel: mockTransferChannelInfo, + brandToIssuer, + chainTimerService: mockTimerService, + chainTimerBrand: mockTimerBrand, + }); const { validator, delegations } = scenario1; { diff --git a/packages/orchestration/test/typeGuards.test.js b/packages/orchestration/test/typeGuards.test.js new file mode 100644 index 00000000000..7e329d86a1e --- /dev/null +++ b/packages/orchestration/test/typeGuards.test.js @@ -0,0 +1,62 @@ +// @ts-check + +import { test } from '@agoric/swingset-vat/tools/prepare-test-env-ava.js'; + +import { makeCopyBagFromElements, matches } from '@endo/patterns'; +import { AmountMath, AssetKind, makeIssuerKit } from '@agoric/ertp'; +import { DepositProposalShape } from '../src/typeGuards.js'; + +test('DepositProposalShape', t => { + const { brand: natBrand } = makeIssuerKit('myToken', AssetKind.NAT); + const anyNatAmount = AmountMath.make(natBrand, 1n); + const { brand: copyBagBrand } = makeIssuerKit('myNft', AssetKind.COPY_BAG); + const anyCopyBagAmount = AmountMath.make( + copyBagBrand, + makeCopyBagFromElements([1]), + ); + + t.true( + matches( + harden({ give: { ANY_KEYWORD: anyNatAmount }, exit: { waived: null } }), + DepositProposalShape, + ), + ); + t.false( + matches( + harden({ + give: { ANY_KEYWORD: anyCopyBagAmount }, + exit: { waived: null }, + }), + DepositProposalShape, + ), + 'give entry value must be a Nat amount', + ); + t.false( + matches( + harden({ + give: { ONE: anyNatAmount, TWO: anyNatAmount }, + exit: { waived: null }, + }), + DepositProposalShape, + ), + 'only one entry allowed for give', + ); + t.false( + matches( + harden({ + give: { ANY_KEYWORD: anyNatAmount }, + want: { ANY_KEYWORD: anyNatAmount }, + exit: { waived: null }, + }), + DepositProposalShape, + ), + 'want must be empty', + ); + t.false( + matches( + harden({ give: { ANY_KEYWORD: anyNatAmount } }), + DepositProposalShape, + ), + 'exit waived: null required', + ); +}); diff --git a/packages/vats/src/localchain.js b/packages/vats/src/localchain.js index ade92745c56..450246bb119 100644 --- a/packages/vats/src/localchain.js +++ b/packages/vats/src/localchain.js @@ -5,6 +5,8 @@ import { AmountShape } from '@agoric/ertp'; const { Fail, bare } = assert; +/** @import {TypedJson, ResponseType, Proto3Shape} from '@agoric/cosmic-proto'; */ + /** * @typedef {{ * system: import('./types.js').ScopedBridgeManager; @@ -32,6 +34,7 @@ export const LocalChainAccountI = M.interface('LocalChainAccount', { deposit: M.callWhen(M.remotable('Payment')) .optional(M.pattern()) .returns(AmountShape), + withdraw: M.callWhen(AmountShape).returns(M.remotable('Payment')), executeTx: M.callWhen(M.arrayOf(M.record())).returns(M.arrayOf(M.record())), }); @@ -63,9 +66,22 @@ const prepareLocalChainAccount = zone => const bankManager = getPower(powers, 'bankManager'); const allegedBrand = await E(payment).getAllegedBrand(); - const bankAcct = E(bankManager).getBankForAddress(address); - const allegedPurse = E(bankAcct).getPurse(allegedBrand); - return E(allegedPurse).deposit(payment); + const acctsBank = E(bankManager).getBankForAddress(address); + const purse = E(acctsBank).getPurse(allegedBrand); + return E(purse).deposit(payment); + }, + /** + * Withdraw a payment from the account's bank purse. + * + * @param {Amount} amount + */ + async withdraw(amount) { + const { address, powers } = this.state; + const bankManager = getPower(powers, 'bankManager'); + + const acctsBank = E(bankManager).getBankForAddress(address); + const purse = E(acctsBank).getPurse(amount.brand); + return E(purse).withdraw(amount); }, /** * @param {import('@agoric/cosmic-proto').TypedJson[]} messages @@ -164,8 +180,8 @@ const prepareLocalChain = (zone, makeAccount) => * the query fails. Otherwise, return the response as a JSON-compatible * object. * - * @param {import('@agoric/cosmic-proto').TypedJson} request - * @returns {Promise} + * @param {TypedJson} request + * @returns {Promise} */ async query(request) { const requests = harden([request]); @@ -183,11 +199,11 @@ const prepareLocalChain = (zone, makeAccount) => * system error, will return all results to indicate their success or * failure. * - * @param {import('@agoric/cosmic-proto').TypedJson[]} requests + * @param {TypedJson[]} requests * @returns {Promise< * { * error?: string; - * reply: import('@agoric/cosmic-proto').TypedJson; + * reply: TypedJson; * }[] * >} */ diff --git a/packages/vm-config/decentral-devnet-config.json b/packages/vm-config/decentral-devnet-config.json index 2bf94fdd6f4..3458cca663c 100644 --- a/packages/vm-config/decentral-devnet-config.json +++ b/packages/vm-config/decentral-devnet-config.json @@ -172,8 +172,16 @@ "args": [ { "hostConnectionId": "connection-1", - "controllerConnectionId": "connection-0", - "bondDenom": "uatom" + "controllerConnectionId": "connection-1", + "bondDenom": "uatom", + "bondDenomLocal": "ibc/C4CFF46FD6DE35CA4CF4CE031E643C8FDC9BA4B99AE598E9B0ED98FE3A2319F9", + "transferChannel": { + "counterpartyChannelId": "channel-1", + "counterpartyPortId": "transfer", + "sourceChannelId": "channel-1", + "sourcePortId": "transfer" + }, + "icqEnabled": false } ] }