diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index 9fa8076df8c..86b2f61d3b3 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -1,7 +1,9 @@ import type { AnyJson, TypedJson, JsonSafe } from '@agoric/cosmic-proto'; import type { Delegation, + DelegationResponse, Redelegation, + RedelegationResponse, UnbondingDelegation, } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; import type { TxBody } from '@agoric/cosmic-proto/cosmos/tx/v1beta1/tx.js'; @@ -26,6 +28,7 @@ import type { LocalIbcAddress, RemoteIbcAddress, } from '@agoric/vats/tools/ibc-utils.js'; +import type { QueryDelegationTotalRewardsResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/query.js'; import type { AmountArg, ChainAddress, Denom, DenomAmount } from './types.js'; /** An address for a validator on some blockchain, e.g., cosmos, eth, etc. */ @@ -97,6 +100,24 @@ export type CosmosChainInfo = Readonly<{ stakingTokens?: Readonly>; }>; +// #region Orchestration views on Cosmos response types +// Naming scheme: Cosmos for the chain system, Rewards b/c getRewards function, +// and Response because it's the return value. + +/** @see {QueryDelegationTotalRewardsResponse} */ +export interface CosmosRewardsResponse { + rewards: { validator: CosmosValidatorAddress; reward: DenomAmount[] }[]; + total: DenomAmount[]; +} + +/** @see {DelegationResponse} */ +export interface CosmosDelegationResponse { + delegator: ChainAddress; + validator: CosmosValidatorAddress; + amount: DenomAmount; +} +// #endregion + /** * Queries for the staking properties of an account. * @@ -107,13 +128,15 @@ export interface StakingAccountQueries { /** * @returns all active delegations from the account to any validator (or [] if none) */ - getDelegations: () => Promise; + getDelegations: () => Promise; /** * @returns the active delegation from the account to a specific validator. Return an * empty Delegation if there is no delegation. */ - getDelegation: (validator: CosmosValidatorAddress) => Promise; + getDelegation: ( + validator: CosmosValidatorAddress, + ) => Promise; /** * @returns the unbonding delegations from the account to any validator (or [] if none) @@ -127,18 +150,13 @@ export interface StakingAccountQueries { validator: CosmosValidatorAddress, ) => Promise; - getRedelegations: () => Promise; - - getRedelegation: ( - srcValidator: CosmosValidatorAddress, - dstValidator?: CosmosValidatorAddress, - ) => Promise; + getRedelegations: () => Promise; /** * Get the pending rewards for the account. * @returns the amounts of the account's rewards pending from all validators */ - getRewards: () => Promise; + getRewards: () => Promise; /** * Get the rewards pending with a specific validator. @@ -187,7 +205,11 @@ export interface StakingAccountActions { * @param delegations - the delegation to undelegate */ undelegate: ( - delegations: { amount: AmountArg; validator: CosmosValidatorAddress }[], + delegations: { + amount: AmountArg; + delegator?: ChainAddress; + validator: CosmosValidatorAddress; + }[], ) => Promise; /** @@ -307,7 +329,7 @@ export type CosmosChainAccountMethods = CCI extends { stakingTokens: {}; } - ? StakingAccountActions + ? StakingAccountActions & StakingAccountQueries : {}; export type ICQQueryFunction = ( diff --git a/packages/orchestration/src/examples/unbond.contract.js b/packages/orchestration/src/examples/unbond.contract.js index a303afddf10..e577f2c6fec 100644 --- a/packages/orchestration/src/examples/unbond.contract.js +++ b/packages/orchestration/src/examples/unbond.contract.js @@ -28,13 +28,13 @@ import * as flows from './unbond.flows.js'; * @param {OrchestrationTools} tools */ const contract = async (zcf, privateArgs, zone, { orchestrateAll }) => { - const { unbondAndLiquidStake } = orchestrateAll(flows, { zcf }); + const { unbondAndTransfer } = orchestrateAll(flows, { zcf }); const publicFacet = zone.exo('publicFacet', undefined, { - makeUnbondAndLiquidStakeInvitation() { + makeUnbondAndTransferInvitation() { return zcf.makeInvitation( - unbondAndLiquidStake, - 'Unbond and liquid stake', + unbondAndTransfer, + 'Unbond and transfer', undefined, harden({ // Nothing to give; the funds come from undelegating diff --git a/packages/orchestration/src/examples/unbond.flows.js b/packages/orchestration/src/examples/unbond.flows.js index a203dd088f1..d5b9482b63b 100644 --- a/packages/orchestration/src/examples/unbond.flows.js +++ b/packages/orchestration/src/examples/unbond.flows.js @@ -1,12 +1,10 @@ +import { makeTracer } from '@agoric/internal'; + +const trace = makeTracer('UnbondAndTransfer'); + /** - * @import {Orchestrator, OrchestrationFlow} from '../types.js' - * @import {TimerService} from '@agoric/time'; - * @import {LocalChain} from '@agoric/vats/src/localchain.js'; - * @import {NameHub} from '@agoric/vats'; - * @import {Remote} from '@agoric/internal'; - * @import {Zone} from '@agoric/zone'; - * @import {CosmosInterchainService} from '../exos/exo-interfaces.js'; - * @import {OrchestrationTools} from '../utils/start-helper.js'; + * @import {Orchestrator, OrchestrationFlow, CosmosDelegationResponse} from '../types.js' + * @import {DelegationResponse} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; */ /** @@ -14,25 +12,20 @@ * @param {Orchestrator} orch * @param {object} ctx * @param {ZCF} ctx.zcf - * @param {ZCFSeat} _seat - * @param {undefined} _offerArgs */ -export const unbondAndLiquidStake = async ( - orch, - { zcf }, - _seat, - _offerArgs, -) => { +export const unbondAndTransfer = async (orch, { zcf }) => { console.log('zcf within the membrane', zcf); // Osmosis is one of the few chains with icqEnabled const osmosis = await orch.getChain('osmosis'); // In a real world scenario, accounts would be re-used across invokations of the handler const osmoAccount = await osmosis.makeAccount(); - // TODO https://github.com/Agoric/agoric-sdk/issues/10016 - // const delegations = await celestiaAccount.getDelegations(); - // // wait for the undelegations to be complete (may take weeks) - // await celestiaAccount.undelegate(delegations); + /** @type {CosmosDelegationResponse[]} Cosmos */ + const delegations = await osmoAccount.getDelegations(); + trace('delegations', delegations); + // wait for the undelegations to be complete (may take weeks) + await osmoAccount.undelegate(delegations); + // ??? should this be synchronous? depends on how names are resolved. const stride = await orch.getChain('stride'); const strideAccount = await stride.makeAccount(); @@ -44,4 +37,4 @@ export const unbondAndLiquidStake = async ( // await strideAccount.liquidStake(tiaAmt); console.log(osmoAccount, strideAccount); }; -harden(unbondAndLiquidStake); +harden(unbondAndTransfer); diff --git a/packages/orchestration/src/exos/cosmos-orchestration-account.js b/packages/orchestration/src/exos/cosmos-orchestration-account.js index 269aeae7eb8..32d0de72121 100644 --- a/packages/orchestration/src/exos/cosmos-orchestration-account.js +++ b/packages/orchestration/src/exos/cosmos-orchestration-account.js @@ -1,16 +1,34 @@ /** @file Use-object for the owner of a staking account */ import { toRequestQueryJson } from '@agoric/cosmic-proto'; import { - QueryBalanceRequest, - QueryBalanceResponse, QueryAllBalancesRequest, QueryAllBalancesResponse, + QueryBalanceRequest, + QueryBalanceResponse, } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; import { MsgSend } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/tx.js'; +import { + QueryDelegationRewardsRequest, + QueryDelegationRewardsResponse, + QueryDelegationTotalRewardsRequest, + QueryDelegationTotalRewardsResponse, +} from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/query.js'; import { MsgWithdrawDelegatorReward, MsgWithdrawDelegatorRewardResponse, } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/tx.js'; +import { + QueryDelegationRequest, + QueryDelegationResponse, + QueryDelegatorDelegationsRequest, + QueryDelegatorDelegationsResponse, + QueryDelegatorUnbondingDelegationsRequest, + QueryDelegatorUnbondingDelegationsResponse, + QueryRedelegationsRequest, + QueryRedelegationsResponse, + QueryUnbondingDelegationRequest, + QueryUnbondingDelegationResponse, +} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; import { MsgBeginRedelegate, MsgDelegate, @@ -36,19 +54,22 @@ import { import { coerceCoin, coerceDenom } from '../utils/amounts.js'; import { maxClockSkew, - tryDecodeResponse, + toCosmosDelegationResponse, + toCosmosValidatorAddress, toDenomAmount, + toTruncatedDenomAmount, + tryDecodeResponse, } from '../utils/cosmos.js'; import { orchestrationAccountMethods } from '../utils/orchestrationAccount.js'; import { makeTimestampHelper } from '../utils/time.js'; /** * @import {HostOf} from '@agoric/async-flow'; - * @import {AmountArg, IcaAccount, ChainAddress, CosmosValidatorAddress, ICQConnection, StakingAccountActions, DenomAmount, OrchestrationAccountI, IBCConnectionInfo, IBCMsgTransferOptions, ChainHub} from '../types.js'; + * @import {AmountArg, IcaAccount, ChainAddress, CosmosValidatorAddress, ICQConnection, StakingAccountActions, StakingAccountQueries, OrchestrationAccountI, CosmosRewardsResponse, IBCConnectionInfo, IBCMsgTransferOptions, ChainHub, CosmosDelegationResponse} from '../types.js'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'; * @import {Coin} from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; - * @import {Delegation} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; * @import {Remote} from '@agoric/internal'; + * @import {DelegationResponse} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; * @import {InvitationMakers} from '@agoric/smart-wallet/src/types.js'; * @import {TimerService} from '@agoric/time'; * @import {Vow, VowTools} from '@agoric/vow'; @@ -89,20 +110,37 @@ const { Vow$ } = NetworkShape; // TODO #9611 * }} CosmosOrchestrationAccountStorageState */ -/** @see {OrchestrationAccountI} */ -export const IcaAccountHolderI = M.interface('IcaAccountHolder', { - ...orchestrationAccountMethods, +/** @see {StakingAccountActions} */ +const stakingAccountActionsMethods = { delegate: M.call(ChainAddressShape, AmountArgShape).returns(VowShape), redelegate: M.call( ChainAddressShape, ChainAddressShape, AmountArgShape, ).returns(VowShape), + undelegate: M.call(M.arrayOf(DelegationShape)).returns(VowShape), withdrawReward: M.call(ChainAddressShape).returns( Vow$(M.arrayOf(DenomAmountShape)), ), withdrawRewards: M.call().returns(Vow$(M.arrayOf(DenomAmountShape))), - undelegate: M.call(M.arrayOf(DelegationShape)).returns(VowShape), +}; + +/** @see {StakingAccountQueries} */ +const stakingAccountQueriesMethods = { + getDelegation: M.call(ChainAddressShape).returns(VowShape), + getDelegations: M.call().returns(VowShape), + getUnbondingDelegation: M.call(ChainAddressShape).returns(VowShape), + getUnbondingDelegations: M.call().returns(VowShape), + getRedelegations: M.call().returns(VowShape), + getReward: M.call(ChainAddressShape).returns(VowShape), + getRewards: M.call().returns(VowShape), +}; + +/** @see {OrchestrationAccountI} */ +export const IcaAccountHolderI = M.interface('IcaAccountHolder', { + ...orchestrationAccountMethods, + ...stakingAccountActionsMethods, + ...stakingAccountQueriesMethods, deactivate: M.call().returns(VowShape), reactivate: M.call().returns(VowShape), }); @@ -198,6 +236,46 @@ export const prepareCosmosOrchestrationAccountKit = ( }) .returns(Vow$(M.record())), }), + delegationQueryWatcher: M.interface('delegationQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())).returns(M.record()), + }), + delegationsQueryWatcher: M.interface('delegationsQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())).returns( + M.arrayOf(M.record()), + ), + }), + unbondingDelegationQueryWatcher: M.interface( + 'unbondingDelegationQueryWatcher', + { + onFulfilled: M.call(M.arrayOf(M.record())).returns(M.record()), + }, + ), + unbondingDelegationsQueryWatcher: M.interface( + 'unbondingDelegationsQueryWatcher', + { + onFulfilled: M.call(M.arrayOf(M.record())).returns( + M.arrayOf(M.record()), + ), + }, + ), + redelegationQueryWatcher: M.interface('redelegationQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())).returns( + M.arrayOf(M.record()), + ), + }), + redelegationsQueryWatcher: M.interface('redelegationsQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())).returns( + M.arrayOf(M.record()), + ), + }), + rewardQueryWatcher: M.interface('rewardQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())).returns( + M.arrayOf(M.record()), + ), + }), + rewardsQueryWatcher: M.interface('rewardsQueryWatcher', { + onFulfilled: M.call(M.arrayOf(M.record())).returns(M.record()), + }), holder: IcaAccountHolderI, invitationMakers: CosmosOrchestrationInvitationMakersI, }, @@ -273,6 +351,134 @@ export const prepareCosmosOrchestrationAccountKit = ( return harden(toDenomAmount(balance)); }, }, + delegationQueryWatcher: { + /** + * @param {JsonSafe[]} results + * @returns {CosmosDelegationResponse} + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { delegationResponse } = QueryDelegationResponse.decode( + decodeBase64(result.key), + ); + if (!delegationResponse) + throw Fail`Result lacked delegationResponse key: ${result}`; + const { chainAddress } = this.state; + return harden( + toCosmosDelegationResponse(chainAddress, delegationResponse), + ); + }, + }, + delegationsQueryWatcher: { + /** + * @param {JsonSafe[]} results + * @returns {CosmosDelegationResponse[]} + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { delegationResponses } = + QueryDelegatorDelegationsResponse.decode(decodeBase64(result.key)); + if (!delegationResponses) + throw Fail`Result lacked delegationResponses key: ${result}`; + const { chainAddress } = this.state; + return harden( + delegationResponses.map(r => + toCosmosDelegationResponse(chainAddress, r), + ), + ); + }, + }, + unbondingDelegationQueryWatcher: { + /** + * @param {JsonSafe[]} results + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { unbond } = QueryUnbondingDelegationResponse.decode( + decodeBase64(result.key), + ); + if (!unbond) throw Fail`Result lacked unbond key: ${result}`; + return harden(unbond); + }, + }, + unbondingDelegationsQueryWatcher: { + /** + * @param {JsonSafe[]} results + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { unbondingResponses } = + QueryDelegatorUnbondingDelegationsResponse.decode( + decodeBase64(result.key), + ); + if (!unbondingResponses) + throw Fail`Result lacked unbondingResponses key: ${result}`; + return harden(unbondingResponses); + }, + }, + redelegationQueryWatcher: { + /** + * @param {JsonSafe[]} results + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { redelegationResponses } = QueryRedelegationsResponse.decode( + decodeBase64(result.key), + ); + if (!redelegationResponses) + throw Fail`Result lacked redelegationResponses key: ${result}`; + return harden(redelegationResponses); + }, + }, + redelegationsQueryWatcher: { + /** + * @param {JsonSafe[]} results + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { redelegationResponses } = QueryRedelegationsResponse.decode( + decodeBase64(result.key), + ); + if (!redelegationResponses) + throw Fail`Result lacked redelegationResponses key: ${result}`; + return harden(redelegationResponses); + }, + }, + rewardQueryWatcher: { + /** + * @param {JsonSafe[]} results + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { rewards } = QueryDelegationRewardsResponse.decode( + decodeBase64(result.key), + ); + if (!rewards) throw Fail`Result lacked rewards key: ${result}`; + return harden(rewards.map(toTruncatedDenomAmount)); + }, + }, + rewardsQueryWatcher: { + /** + * @param {JsonSafe[]} results + * @returns {CosmosRewardsResponse} + */ + onFulfilled([result]) { + if (!result?.key) throw Fail`Error parsing result ${result}`; + const { rewards, total } = QueryDelegationTotalRewardsResponse.decode( + decodeBase64(result.key), + ); + if (!rewards || !total) + throw Fail`Result lacked rewards or total key: ${result}`; + const { chainAddress } = this.state; + return harden({ + rewards: rewards.map(reward => ({ + validator: toCosmosValidatorAddress(reward, chainAddress.chainId), + reward: reward.reward.map(toTruncatedDenomAmount), + })), + total: total.map(toTruncatedDenomAmount), + }); + }, + }, allBalancesQueryWatcher: { /** * @param {JsonSafe[]} results @@ -726,6 +932,10 @@ export const prepareCosmosOrchestrationAccountKit = ( const { helper } = this.facets; const { chainAddress } = this.state; + delegations.every(d => + d.delegator ? d.delegator.value === chainAddress.value : true, + ) || Fail`Some delegation record is for another delegator`; + const undelegateV = watch( E(helper.owned()).executeEncodedTx( delegations.map(({ validator, amount }) => @@ -751,6 +961,138 @@ export const prepareCosmosOrchestrationAccountKit = ( reactivate() { return watch(E(this.facets.helper.owned()).reactivate()); }, + /** @type {HostOf} */ + getDelegation(validator) { + return asVow(() => { + trace('getDelegation', validator); + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryDelegationRequest.toProtoMsg({ + delegatorAddr: chainAddress.value, + validatorAddr: validator.value, + }), + ), + ]); + return watch(results, this.facets.delegationQueryWatcher); + }); + }, + /** @type {HostOf} */ + getDelegations() { + return asVow(() => { + trace('getDelegations'); + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryDelegatorDelegationsRequest.toProtoMsg({ + delegatorAddr: chainAddress.value, + }), + ), + ]); + return watch(results, this.facets.delegationsQueryWatcher); + }); + }, + /** @type {HostOf} */ + getUnbondingDelegation(validator) { + return asVow(() => { + trace('getUnbondingDelegation', validator); + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryUnbondingDelegationRequest.toProtoMsg({ + delegatorAddr: chainAddress.value, + validatorAddr: validator.value, + }), + ), + ]); + return watch(results, this.facets.unbondingDelegationQueryWatcher); + }); + }, + /** @type {HostOf} */ + getUnbondingDelegations() { + return asVow(() => { + trace('getUnbondingDelegations'); + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryDelegatorUnbondingDelegationsRequest.toProtoMsg({ + delegatorAddr: chainAddress.value, + }), + ), + ]); + return watch(results, this.facets.unbondingDelegationsQueryWatcher); + }); + }, + /** @type {HostOf} */ + getRedelegations() { + return asVow(() => { + trace('getRedelegations'); + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryRedelegationsRequest.toProtoMsg({ + delegatorAddr: chainAddress.value, + // These are optional but the protobufs require values to be set + dstValidatorAddr: '', + srcValidatorAddr: '', + }), + ), + ]); + return watch(results, this.facets.redelegationsQueryWatcher); + }); + }, + /** @type {HostOf} */ + getReward(validator) { + return asVow(() => { + trace('getReward', validator); + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryDelegationRewardsRequest.toProtoMsg({ + delegatorAddress: chainAddress.value, + validatorAddress: validator.value, + }), + ), + ]); + return watch(results, this.facets.rewardQueryWatcher); + }); + }, + /** @type {HostOf} */ + getRewards() { + return asVow(() => { + trace('getRewards'); + const { chainAddress, icqConnection } = this.state; + if (!icqConnection) { + throw Fail`Queries not available for chain ${q(chainAddress.chainId)}`; + } + const results = E(icqConnection).query([ + toRequestQueryJson( + QueryDelegationTotalRewardsRequest.toProtoMsg({ + delegatorAddress: chainAddress.value, + }), + ), + ]); + return watch(results, this.facets.rewardsQueryWatcher); + }); + }, }, }, ); diff --git a/packages/orchestration/src/typeGuards.js b/packages/orchestration/src/typeGuards.js index deb4ea802c9..e24326e6cd3 100644 --- a/packages/orchestration/src/typeGuards.js +++ b/packages/orchestration/src/typeGuards.js @@ -137,10 +137,13 @@ export const AmountArgShape = M.or(AnyNatAmountShape, DenomAmountShape); * amount: AmountArg; * }>} */ -export const DelegationShape = harden({ - validator: ChainAddressShape, - amount: AmountArgShape, -}); +export const DelegationShape = M.splitRecord( + { + validator: ChainAddressShape, + amount: AmountArgShape, + }, + { delegator: ChainAddressShape }, +); /** Approximately @see RequestQuery */ export const ICQMsgShape = M.splitRecord( diff --git a/packages/orchestration/src/utils/cosmos.js b/packages/orchestration/src/utils/cosmos.js index c0d43f9b93c..5082a1c5ed9 100644 --- a/packages/orchestration/src/utils/cosmos.js +++ b/packages/orchestration/src/utils/cosmos.js @@ -3,8 +3,9 @@ import { decodeBase64 } from '@endo/base64'; import { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; /** - * @import {DenomAmount} from '../types.js'; + * @import {CosmosDelegationResponse, CosmosValidatorAddress, DenomAmount} from '../types.js'; * @import {Coin} from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js' + * @import {DelegationResponse} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/staking.js'; */ /** maximum clock skew, in seconds, for unbonding time reported from other chain */ @@ -31,5 +32,51 @@ export const tryDecodeResponse = (ackStr, fromProtoMsg) => { * Transform a cosmos-sdk {@link Coin} object into a {@link DenomAmount} * * @type {(c: { denom: string; amount: string }) => DenomAmount} + * @see {@link toTruncatedDenomAmount} for DecCoin */ export const toDenomAmount = c => ({ denom: c.denom, value: BigInt(c.amount) }); + +/** + * Transform a cosmos-sdk {@link DecCoin} object into a {@link DenomAmount}, by + * truncating the fractional portion. + * + * @type {(c: { denom: string; amount: string }) => DenomAmount} + */ +export const toTruncatedDenomAmount = c => ({ + denom: c.denom, + value: BigInt(c.amount.split('.')[0]), +}); + +/** + * Transform a cosmos-sdk `{validatorAddress}` object into an Orchestration + * {@link CosmosValidatorAddress} + * + * @type {( + * r: { validatorAddress: string }, + * chainId: string, + * ) => CosmosValidatorAddress} + */ +export const toCosmosValidatorAddress = (r, chainId) => ({ + encoding: 'bech32', + value: /** @type {CosmosValidatorAddress['value']} */ (r.validatorAddress), + chainId, +}); + +/** + * Transform a cosmos-sdk {@link DelegationResponse} object into an Orchestration + * {@link CosmosDelegationResponse} + * + * @type {( + * chainInfo: { chainId: string }, + * r: DelegationResponse, + * ) => CosmosDelegationResponse} + */ +export const toCosmosDelegationResponse = ({ chainId }, r) => ({ + delegator: { + chainId, + encoding: 'bech32', + value: r.delegation.delegatorAddress, + }, + validator: toCosmosValidatorAddress(r.delegation, chainId), + amount: toDenomAmount(r.balance), +}); diff --git a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md index dc4bf4aa5a3..82a7b2f488e 100644 --- a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.md @@ -26,7 +26,7 @@ Generated by [AVA](https://avajs.dev). }, contract: { orchestration: { - unbondAndLiquidStake: { + unbondAndTransfer: { asyncFlow_kindHandle: 'Alleged: kind', }, }, diff --git a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap index 26978e4b049..90478398c1d 100644 Binary files a/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap and b/packages/orchestration/test/examples/snapshots/unbond.contract.test.ts.snap differ diff --git a/packages/orchestration/test/examples/unbond.contract.test.ts b/packages/orchestration/test/examples/unbond.contract.test.ts index 74266f0b62c..ff89c24aa2b 100644 --- a/packages/orchestration/test/examples/unbond.contract.test.ts +++ b/packages/orchestration/test/examples/unbond.contract.test.ts @@ -4,7 +4,13 @@ import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; import { E } from '@endo/far'; import path from 'path'; import { inspectMapStore } from '@agoric/internal/src/testing-utils.js'; +import { QueryDelegatorDelegationsResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; +import { MsgUndelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; import { commonSetup } from '../supports.js'; +import { + buildMsgResponseString, + buildQueryResponseString, +} from '../../tools/ibc-mocks.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); @@ -14,11 +20,41 @@ type StartFn = test('start', async t => { const { - bootstrap: { vowTools: vt }, + bootstrap: { timer, vowTools: vt }, brands: { ist }, commonPrivateArgs, + mocks: { ibcBridge }, } = await commonSetup(t); + const buildMocks = () => { + const makeDelegationsResponse = () => + buildQueryResponseString(QueryDelegatorDelegationsResponse, { + delegationResponses: [ + { + delegation: { + delegatorAddress: 'cosmos1test', + validatorAddress: 'cosmosvaloper1xyz', + shares: '1000000', + }, + balance: { denom: 'uosmo', amount: '1000000' }, + }, + ], + }); + const makeUndelegateResponse = () => + buildMsgResponseString(MsgUndelegateResponse, { + completionTime: { seconds: 3600n, nanos: 0 }, + }); + + return { + 'eyJkYXRhIjoiQ2tNS0RRb0xZMjl6Ylc5ek1YUmxjM1FTTWk5amIzTnRiM011YzNSaGEybHVaeTUyTVdKbGRHRXhMbEYxWlhKNUwwUmxiR1ZuWVhSdmNrUmxiR1ZuWVhScGIyNXoiLCJtZW1vIjoiIn0=': + makeDelegationsResponse(), + 'eyJ0eXBlIjoxLCJkYXRhIjoiQ2xzS0pTOWpiM050YjNNdWMzUmhhMmx1Wnk1Mk1XSmxkR0V4TGsxeloxVnVaR1ZzWldkaGRHVVNNZ29MWTI5emJXOXpNWFJsYzNRU0VXTnZjMjF2YzNaaGJHOXdaWEl4ZUhsNkdoQUtCWFZ2YzIxdkVnY3hNREF3TURBdyIsIm1lbW8iOiIifQ==': + makeUndelegateResponse(), + }; + }; + + ibcBridge.setMockAck(buildMocks()); + let contractBaggage; const { zoe, bundleAndInstall } = await setUpZoeForTest({ setJig: ({ baggage }) => { @@ -35,11 +71,11 @@ test('start', async t => { commonPrivateArgs, ); - const inv = E(publicFacet).makeUnbondAndLiquidStakeInvitation(); + const inv = E(publicFacet).makeUnbondAndTransferInvitation(); t.is( (await E(zoe).getInvitationDetails(inv)).description, - 'Unbond and liquid stake', + 'Unbond and transfer', ); const userSeat = await E(zoe).offer( @@ -48,7 +84,13 @@ test('start', async t => { {}, { validator: 'agoric1valopsfufu' }, ); - const result = await vt.when(E(userSeat).getOfferResult()); + const resultP = vt.when(E(userSeat).getOfferResult()); + t.truthy(resultP); + + // Wait for the completionTime to pass + timer.advanceBy(3600n * 1000n); + + const result = await resultP; t.is(result, undefined); const tree = inspectMapStore(contractBaggage); diff --git a/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts b/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts index 73600224e6e..74230d14daa 100644 --- a/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts +++ b/packages/orchestration/test/exos/cosmos-orchestration-account.test.ts @@ -15,6 +15,24 @@ import { QueryBalanceResponse, } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; import { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; +import { + QueryDelegationRequest, + QueryDelegatorDelegationsRequest, + QueryUnbondingDelegationRequest, + QueryDelegatorUnbondingDelegationsRequest, + QueryRedelegationsRequest, + QueryDelegationResponse, + QueryDelegatorDelegationsResponse, + QueryUnbondingDelegationResponse, + QueryDelegatorUnbondingDelegationsResponse, + QueryRedelegationsResponse, +} from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; +import { + QueryDelegationRewardsRequest, + QueryDelegationTotalRewardsRequest, + QueryDelegationRewardsResponse, + QueryDelegationTotalRewardsResponse, +} from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/query.js'; import { commonSetup } from '../supports.js'; import type { AmountArg, @@ -29,6 +47,7 @@ import { buildTxPacketString, parseOutgoingTxPacket, } from '../../tools/ibc-mocks.js'; +import type { CosmosValidatorAddress } from '../../src/cosmos-api.js'; type TestContext = Awaited>; @@ -414,6 +433,374 @@ test('getBalance and getBalances', async t => { } }); +test('StakingAccountQueries', async t => { + const { + mocks: { ibcBridge }, + } = t.context; + const makeTestCOAKit = prepareMakeTestCOAKit(t, t.context); + + const buildMocks = () => { + const mockValidator: CosmosValidatorAddress = { + value: 'cosmosvaloper1xyz', + chainId: 'cosmoshub-4', + encoding: 'bech32', + }; + + const makeDelegationReq = () => + buildQueryPacketString([ + QueryDelegationRequest.toProtoMsg({ + delegatorAddr: 'cosmos1test', + validatorAddr: mockValidator.value, + }), + ]); + + const makeDelegationsReq = () => + buildQueryPacketString([ + QueryDelegatorDelegationsRequest.toProtoMsg({ + delegatorAddr: 'cosmos1test', + }), + ]); + + const makeUnbondingDelegationReq = () => + buildQueryPacketString([ + QueryUnbondingDelegationRequest.toProtoMsg({ + delegatorAddr: 'cosmos1test', + validatorAddr: mockValidator.value, + }), + ]); + + const makeUnbondingDelegationsReq = () => + buildQueryPacketString([ + QueryDelegatorUnbondingDelegationsRequest.toProtoMsg({ + delegatorAddr: 'cosmos1test', + }), + ]); + + const makeRedelegationReq = () => + buildQueryPacketString([ + QueryRedelegationsRequest.toProtoMsg({ + delegatorAddr: 'cosmos1test', + srcValidatorAddr: mockValidator.value, + // XXX need to provide dstValidatorAddr + dstValidatorAddr: mockValidator.value, + }), + ]); + + const makeRedelegationsReq = () => + buildQueryPacketString([ + QueryRedelegationsRequest.toProtoMsg({ + delegatorAddr: 'cosmos1test', + // Protobufs require these to be strings but they can be empty + srcValidatorAddr: '', + dstValidatorAddr: '', + }), + ]); + + const makeRewardReq = () => + buildQueryPacketString([ + QueryDelegationRewardsRequest.toProtoMsg({ + delegatorAddress: 'cosmos1test', + validatorAddress: mockValidator.value, + }), + ]); + + const makeRewardsReq = () => + buildQueryPacketString([ + QueryDelegationTotalRewardsRequest.toProtoMsg({ + delegatorAddress: 'cosmos1test', + }), + ]); + + return { + [makeDelegationReq()]: buildQueryResponseString(QueryDelegationResponse, { + delegationResponse: { + delegation: { + delegatorAddress: 'cosmos1test', + validatorAddress: mockValidator.value, + shares: '1000000', + }, + balance: { denom: 'uatom', amount: '1000000' }, + }, + }), + [makeDelegationsReq()]: buildQueryResponseString( + QueryDelegatorDelegationsResponse, + { + delegationResponses: [ + { + delegation: { + delegatorAddress: 'cosmos1test', + validatorAddress: mockValidator.value, + shares: '1000000', + }, + balance: { denom: 'uatom', amount: '1000000' }, + }, + ], + }, + ), + [makeUnbondingDelegationReq()]: buildQueryResponseString( + QueryUnbondingDelegationResponse, + { + unbond: { + delegatorAddress: 'cosmos1test', + validatorAddress: mockValidator.value, + entries: [ + { + creationHeight: 100n, + completionTime: { seconds: 1672531200n, nanos: 0 }, + initialBalance: '2000000', + balance: '1900000', + }, + ], + }, + }, + ), + [makeUnbondingDelegationsReq()]: buildQueryResponseString( + QueryDelegatorUnbondingDelegationsResponse, + { + unbondingResponses: [ + { + delegatorAddress: 'cosmos1test', + validatorAddress: mockValidator.value, + entries: [ + { + creationHeight: 100n, + completionTime: { seconds: 1672531200n, nanos: 0 }, + initialBalance: '2000000', + balance: '1900000', + }, + ], + }, + ], + }, + ), + [makeRedelegationReq()]: buildQueryResponseString( + QueryRedelegationsResponse, + { + redelegationResponses: [ + { + redelegation: { + delegatorAddress: 'cosmos1test', + validatorSrcAddress: mockValidator.value, + validatorDstAddress: 'cosmosvaloper1abc', + entries: [ + { + creationHeight: 200n, + completionTime: { seconds: 1675209600n, nanos: 0 }, + initialBalance: '3000000', + sharesDst: '2900000', + }, + ], + }, + entries: [ + { + redelegationEntry: { + creationHeight: 200n, + completionTime: { seconds: 1675209600n, nanos: 0 }, + initialBalance: '3000000', + sharesDst: '2900000', + }, + balance: '2900000', + }, + ], + }, + ], + }, + ), + [makeRedelegationsReq()]: buildQueryResponseString( + QueryRedelegationsResponse, + { + redelegationResponses: [ + { + redelegation: { + delegatorAddress: 'cosmos1test', + validatorSrcAddress: '', + validatorDstAddress: '', + entries: [ + { + creationHeight: 200n, + completionTime: { seconds: 1675209600n, nanos: 0 }, + initialBalance: '3000000', + sharesDst: '2900000', + }, + ], + }, + entries: [ + { + redelegationEntry: { + creationHeight: 200n, + completionTime: { seconds: 1675209600n, nanos: 0 }, + initialBalance: '3000000', + sharesDst: '2900000', + }, + balance: '2900000', + }, + ], + }, + ], + }, + ), + [makeRewardReq()]: buildQueryResponseString( + QueryDelegationRewardsResponse, + { + rewards: [ + { + denom: 'uatom', + // Rewards may be fractional, from operations like inflation + amount: '500000.01', + }, + ], + }, + ), + [makeRewardsReq()]: buildQueryResponseString( + QueryDelegationTotalRewardsResponse, + { + rewards: [ + { + validatorAddress: mockValidator.value, + reward: [{ denom: 'uatom', amount: '500000' }], + }, + ], + total: [{ denom: 'uatom', amount: '500000' }], + }, + ), + }; + }; + + ibcBridge.setMockAck(buildMocks()); + ibcBridge.setAddressPrefix('cosmos'); + + const account = await makeTestCOAKit({ + chainId: 'cosmoshub-4', + icqEnabled: true, + }); + t.assert(account, 'account is returned'); + + const mockValidator: CosmosValidatorAddress = { + value: 'cosmosvaloper1xyz', + chainId: 'cosmoshub-4', + encoding: 'bech32', + }; + + // Test getDelegation + const delegationResult = await E(account).getDelegation(mockValidator); + t.deepEqual(delegationResult, { + amount: { denom: 'uatom', value: 1000000n }, + delegator: { + chainId: 'cosmoshub-4', + encoding: 'bech32', + value: 'cosmos1test', + }, + validator: { + chainId: 'cosmoshub-4', + encoding: 'bech32', + value: mockValidator.value, + }, + }); + + // Test getDelegations + const delegationsResult = await E(account).getDelegations(); + t.deepEqual(delegationsResult, [ + { + amount: { denom: 'uatom', value: 1000000n }, + + delegator: { + chainId: 'cosmoshub-4', + encoding: 'bech32', + value: 'cosmos1test', + }, + validator: { + chainId: 'cosmoshub-4', + encoding: 'bech32', + value: mockValidator.value, + }, + }, + ]); + + // Test getUnbondingDelegation + const unbondingDelegationResult = + await E(account).getUnbondingDelegation(mockValidator); + t.deepEqual(unbondingDelegationResult, { + delegatorAddress: 'cosmos1test', + validatorAddress: mockValidator.value, + entries: [ + { + creationHeight: 100n, + completionTime: { seconds: 1672531200n, nanos: 0 }, + initialBalance: '2000000', + balance: '1900000', + }, + ], + }); + + // Test getUnbondingDelegations + const unbondingDelegationsResult = await E(account).getUnbondingDelegations(); + t.deepEqual(unbondingDelegationsResult, [ + { + delegatorAddress: 'cosmos1test', + validatorAddress: mockValidator.value, + entries: [ + { + creationHeight: 100n, + completionTime: { seconds: 1672531200n, nanos: 0 }, + initialBalance: '2000000', + balance: '1900000', + }, + ], + }, + ]); + + // Test getRedelegations + const redelegationsResult = await E(account).getRedelegations(); + t.deepEqual(redelegationsResult, [ + { + redelegation: { + delegatorAddress: 'cosmos1test', + validatorSrcAddress: '', + validatorDstAddress: '', + entries: [ + { + creationHeight: 200n, + completionTime: { seconds: 1675209600n, nanos: 0 }, + initialBalance: '3000000', + sharesDst: '2900000', + }, + ], + }, + entries: [ + { + redelegationEntry: { + creationHeight: 200n, + completionTime: { seconds: 1675209600n, nanos: 0 }, + initialBalance: '3000000', + sharesDst: '2900000', + }, + balance: '2900000', + }, + ], + }, + ]); + + // Test getReward + const rewardResult = await E(account).getReward(mockValidator); + t.deepEqual(rewardResult, [{ denom: 'uatom', value: 500000n }]); + + // Test getRewards + const rewardsResult = await E(account).getRewards(); + t.deepEqual(rewardsResult, { + rewards: [ + { + validator: { + encoding: 'bech32', + value: mockValidator.value, + chainId: 'cosmoshub-4', + }, + reward: [{ denom: 'uatom', value: 500000n }], + }, + ], + total: [{ denom: 'uatom', value: 500000n }], + }); +}); + test('not yet implemented', async t => { const makeTestCOAKit = prepareMakeTestCOAKit(t, t.context); const account = await makeTestCOAKit(); diff --git a/packages/orchestration/test/network-fakes.ts b/packages/orchestration/test/network-fakes.ts index 9d73594f2a3..9a948ace95d 100644 --- a/packages/orchestration/test/network-fakes.ts +++ b/packages/orchestration/test/network-fakes.ts @@ -1,3 +1,5 @@ +import { inspect } from 'node:util'; + import { VowTools } from '@agoric/vow'; import { base64ToBytes, @@ -24,6 +26,7 @@ import { BridgeId, makeTracer } from '@agoric/internal'; import { E, Far } from '@endo/far'; import type { Guarded } from '@endo/exo'; import { defaultMockAckMap, errorAcknowledgments } from './ibc-mocks.js'; +import { decodeProtobufBase64 } from '../tools/protobuf-decoder.js'; const trace = makeTracer('NetworkFakes'); @@ -252,10 +255,19 @@ export const makeFakeIBCBridge = ( const mockAckMapHasData = obj.packet.data in mockAckMap; if (!mockAckMapHasData) { trace( - 'sendPacket acking err bc no mock ack for data:', - obj.packet.data, - base64ToBytes(obj.packet.data), + `sendPacket acking err because no mock ack for b64 data key: '${obj.packet.data}'`, ); + try { + const decoded = decodeProtobufBase64( + JSON.parse(base64ToBytes(obj.packet.data)).data, + ); + trace( + 'Fix the source of this request or define a ack mapping for it:', + inspect(decoded, { depth: null }), + ); + } catch (err) { + trace('Could not decode packet data', err); + } } const ackEvent = ibcBridgeMocks.acknowledgementPacket(obj, { sequence: ibcSequenceNonce, diff --git a/packages/orchestration/test/utils/cosmos.test.ts b/packages/orchestration/test/utils/cosmos.test.ts new file mode 100644 index 00000000000..f7f1c627a89 --- /dev/null +++ b/packages/orchestration/test/utils/cosmos.test.ts @@ -0,0 +1,32 @@ +import test from '@endo/ses-ava/prepare-endo.js'; +import { + toDenomAmount, + toTruncatedDenomAmount, +} from '../../src/utils/cosmos.js'; + +const denom = 'uosmo'; + +test('convert Coin amount', t => { + const amount = '100'; + const value = 100n; + t.deepEqual(toDenomAmount({ denom, amount }), { + denom, + value, + }); + t.deepEqual(toTruncatedDenomAmount({ denom, amount }), { + denom, + value, + }); +}); + +test('convert DecCoin amount', t => { + const amount = '100.01'; + const value = 100n; + t.throws(() => toDenomAmount({ denom, amount }), { + message: 'Cannot convert 100.01 to a BigInt', + }); + t.deepEqual(toTruncatedDenomAmount({ denom, amount }), { + denom, + value, + }); +}); diff --git a/packages/orchestration/tools/protobuf-decoder.js b/packages/orchestration/tools/protobuf-decoder.js new file mode 100644 index 00000000000..a7b68896b71 --- /dev/null +++ b/packages/orchestration/tools/protobuf-decoder.js @@ -0,0 +1,153 @@ +/* eslint-env node */ +/* eslint-disable -- generated by Sonnet, easier to leave alone */ + +const WIRE_TYPES = { + VARINT: 0, + FIXED64: 1, + LENGTH_DELIMITED: 2, + START_GROUP: 3, + END_GROUP: 4, + FIXED32: 5, +}; + +function decodeVarint(buffer, offset) { + let result = 0n; + let shift = 0; + let byte; + + do { + if (offset >= buffer.length) { + throw new Error('Buffer overflow while decoding varint'); + } + byte = buffer[offset]; + result |= BigInt(byte & 0x7f) << BigInt(shift); + shift += 7; + offset++; + } while (byte & 0x80); + + return { value: result, bytesRead: shift / 7 }; +} + +function decodeFixed32(buffer, offset) { + if (offset + 4 > buffer.length) { + throw new Error('Buffer overflow while decoding fixed32'); + } + return { + value: buffer.readUInt32LE(offset), + bytesRead: 4, + }; +} + +function decodeFixed64(buffer, offset) { + if (offset + 8 > buffer.length) { + throw new Error('Buffer overflow while decoding fixed64'); + } + const low = buffer.readUInt32LE(offset); + const high = buffer.readUInt32LE(offset + 4); + return { + value: BigInt(high) * 2n ** 32n + BigInt(low), + bytesRead: 8, + }; +} + +function decodeString(buffer, offset, length) { + if (offset + length > buffer.length) { + throw new Error('Buffer overflow while decoding string'); + } + return { + value: buffer.toString('utf8', offset, offset + length), + bytesRead: length, + }; +} + +function decodeField(buffer, offset) { + const tag = decodeVarint(buffer, offset); + offset += tag.bytesRead; + + const fieldNumber = Number(tag.value >> 3n); + const wireType = Number(tag.value & 0x7n); + + let value; + let bytesRead; + + switch (wireType) { + case WIRE_TYPES.VARINT: + ({ value, bytesRead } = decodeVarint(buffer, offset)); + break; + case WIRE_TYPES.FIXED64: + ({ value, bytesRead } = decodeFixed64(buffer, offset)); + break; + case WIRE_TYPES.LENGTH_DELIMITED: + const length = decodeVarint(buffer, offset); + offset += length.bytesRead; + // Try to decode as a nested message first + try { + ({ value, bytesRead } = decodeProtobuf( + buffer.slice(offset, offset + Number(length.value)), + )); + } catch (e) { + // If it fails, decode as a string + ({ value, bytesRead } = decodeString( + buffer, + offset, + Number(length.value), + )); + } + bytesRead += length.bytesRead; + break; + case WIRE_TYPES.FIXED32: + ({ value, bytesRead } = decodeFixed32(buffer, offset)); + break; + default: + throw new Error(`Unsupported wire type: ${wireType}`); + } + + return { + fieldNumber, + wireType, + value, + bytesRead: tag.bytesRead + bytesRead, + }; +} + +function getFieldName(fieldNumber, wireType) { + const typePrefix = + wireType === WIRE_TYPES.LENGTH_DELIMITED + ? 'subMessage' + : wireType === WIRE_TYPES.VARINT + ? 'int' + : wireType === WIRE_TYPES.FIXED32 + ? 'fixed32' + : wireType === WIRE_TYPES.FIXED64 + ? 'fixed64' + : 'string'; + return `${typePrefix}_${fieldNumber}`; +} + +/** + * Decodes a protobuf message from the given buffer. + * + * @param {Buffer} buffer + */ +export function decodeProtobuf(buffer) { + let offset = 0; + const message = {}; + + while (offset < buffer.length) { + const field = decodeField(buffer, offset); + const fieldName = getFieldName(field.fieldNumber, field.wireType); + message[fieldName] = field.value; + offset += field.bytesRead; + } + + return { value: message, bytesRead: offset }; +} +/** + * Decodes a protobuf message from the given base64-encoded data. + * + * @param {string} base64String + */ +export function decodeProtobufBase64(base64String) { + const buffer = Buffer.from(base64String, 'base64'); + return decodeProtobuf(buffer); +}