diff --git a/multichain-testing/test/authz-example.test.ts b/multichain-testing/test/authz-example.test.ts new file mode 100644 index 00000000000..f42cad0e6ef --- /dev/null +++ b/multichain-testing/test/authz-example.test.ts @@ -0,0 +1,153 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import { commonSetup, type SetupContextWithWallets } from './support.js'; +import { + createFundedWalletAndClient, + DEFAULT_TIMEOUT_NS, +} from '../tools/ibc-transfer.js'; +import { createWallet } from '../tools/wallet.js'; +import type { ChainAddress } from '@agoric/orchestration'; +import { MsgGrant } from '@agoric/cosmic-proto/cosmos/authz/v1beta1/tx.js'; +import { SendAuthorization } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/authz.js'; +import { TxBody } from '@agoric/cosmic-proto/cosmos/tx/v1beta1/tx.js'; + +const test = anyTest as TestFn; + +const accounts = ['alice']; + +const contractName = 'authzExample'; +const contractBuilder = + '../packages/builders/scripts/orchestration/init-authz-example.js'; + +test.before(async t => { + const { setupTestKeys, ...common } = await commonSetup(t); + const { commonBuilderOpts, deleteTestKeys, startContract } = common; + deleteTestKeys(accounts).catch(); + const wallets = await setupTestKeys(accounts); + t.context = { ...common, wallets }; + await startContract(contractName, contractBuilder, commonBuilderOpts); +}); + +test('authz-example', async t => { + const { + provisionSmartWallet, + retryUntilCondition, + useChain, + vstorageClient, + wallets, + } = t.context; + + const toChainAddress = ( + value: string, + chainName = 'cosmoshub', + ): ChainAddress => + harden({ + encoding: 'bech32', + value, + chainId: useChain(chainName).chain.chain_id, + }); + + // set up "existing account", which will issue MsgGrant + const keplrAccount = await createFundedWalletAndClient( + t.log, + 'cosmoshub', + useChain, + ); + const keplrAddress = toChainAddress(keplrAccount.address); + t.log('existing account address', keplrAccount.address); + + // provision agoric smart wallet to submit offers + // unrelated to wallet above, but we can consider them the same entity + const wdUser = await provisionSmartWallet(wallets['alice'], { + BLD: 100n, + IST: 100n, + }); + const doOffer = makeDoOffer(wdUser); + + // request an orchestration account + const offerId = `cosmoshub-makeAccount-${Date.now()}`; + doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeAccount']], + }, + offerArgs: { chainName: 'cosmoshub' }, + proposal: {}, + }); + const currentWalletRecord = await retryUntilCondition( + () => + vstorageClient.queryData(`published.wallet.${wallets['alice']}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[offerId], + `${offerId} continuing invitation is in vstorage`, + ); + const offerToPublicSubscriberMap = Object.fromEntries( + currentWalletRecord.offerToPublicSubscriberPaths, + ); + const address = offerToPublicSubscriberMap[offerId]?.account.split('.').pop(); + t.log('Got orch account address:', address); + + // generate MsgGrant with SendAuthorization for orch account address and broadcast + const grantMsg = MsgGrant.toProtoMsg({ + grantee: address, + granter: keplrAddress.value, + grant: { + // @ts-expect-error the types don't like this, but i think its right + authorization: SendAuthorization.toProtoMsg({ + spendLimit: [ + { + denom: 'uatom', + amount: '10', + }, + ], + }), + expiration: { + nanos: 0, + // TODO calculate something more realistic; this is really far in the future + seconds: DEFAULT_TIMEOUT_NS, + }, + }, + }); + + const msg = TxBody.encode( + TxBody.fromPartial({ + messages: [grantMsg], + // todo, use actual block height + // timeoutHeight: 9999999999n, + }), + ).finish(); + + // FIXME, not working. suspicions: + // 1. MsgExec is not registered in the stargate clients proto registry + // 2. grantMsg, TxBody, are not formed correctly + const res = await keplrAccount.client.broadcastTx(msg); + console.log('res', res); + t.is(res.code, 0); + + // create a wallet to be our recipient + const exchangeWallet = await createWallet('cosmos'); + const exchangeAddress = toChainAddress( + (await exchangeWallet.getAccounts())[0].address, + ); + + // submit MsgExec([MsgSend]) + await doOffer({ + id: `exec-send-${Date.now()}`, + invitationSpec: { + source: 'continuing', + previousOffer: offerId, + invitationMakerName: 'ExecSend', + invitationArgs: [ + [{ amount: 10n, denom: 'uatom' }], + exchangeAddress, + keplrAddress, + ], + }, + proposal: {}, + }); + + // TODO verify exchangeAddress balance increases by 10 uatom +}); diff --git a/packages/builders/scripts/orchestration/init-authz-example.js b/packages/builders/scripts/orchestration/init-authz-example.js new file mode 100644 index 00000000000..09bd1c568aa --- /dev/null +++ b/packages/builders/scripts/orchestration/init-authz-example.js @@ -0,0 +1,67 @@ +import { makeHelpers } from '@agoric/deploy-script-support'; +import { startAuthzExample } from '@agoric/orchestration/src/proposals/start-authz-example.js'; +import { parseArgs } from 'node:util'; + +/** + * @import {ParseArgsConfig} from 'node:util' + */ + +/** @type {ParseArgsConfig['options']} */ +const parserOpts = { + chainInfo: { type: 'string' }, + assetInfo: { type: 'string' }, +}; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').CoreEvalBuilder} */ +export const defaultProposalBuilder = async ( + { publishRef, install }, + options, +) => { + return harden({ + sourceSpec: '@agoric/orchestration/src/proposals/start-authz-example.js', + getManifestCall: [ + 'getManifestForContract', + { + installKeys: { + authzExample: publishRef( + install( + '@agoric/orchestration/src/examples/authz-example.contract.js', + ), + ), + }, + options, + }, + ], + }); +}; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').DeployScriptFunction} */ +export default async (homeP, endowments) => { + const { scriptArgs } = endowments; + + const { + values: { chainInfo, assetInfo }, + } = parseArgs({ + args: scriptArgs, + options: parserOpts, + }); + + const parseChainInfo = () => { + if (typeof chainInfo !== 'string') return undefined; + return JSON.parse(chainInfo); + }; + const parseAssetInfo = () => { + if (typeof assetInfo !== 'string') return undefined; + return JSON.parse(assetInfo); + }; + const opts = harden({ + chainInfo: parseChainInfo(), + assetInfo: parseAssetInfo(), + }); + + const { writeCoreEval } = await makeHelpers(homeP, endowments); + + await writeCoreEval(startAuthzExample.name, utils => + defaultProposalBuilder(utils, opts), + ); +}; diff --git a/packages/cosmic-proto/package.json b/packages/cosmic-proto/package.json index 6e0081a39d1..d2863d663d4 100644 --- a/packages/cosmic-proto/package.json +++ b/packages/cosmic-proto/package.json @@ -28,6 +28,14 @@ "types": "./dist/codegen/cosmos/*.d.ts", "default": "./dist/codegen/cosmos/*.js" }, + "./cosmos/authz/v1beta1/query.js": { + "types": "./dist/codegen/cosmos/authz/v1beta1/query.d.ts", + "default": "./dist/codegen/cosmos/authz/v1beta1/query.js" + }, + "./cosmos/authz/v1beta1/tx.js": { + "types": "./dist/codegen/cosmos/authz/v1beta1/tx.d.ts", + "default": "./dist/codegen/cosmos/authz/v1beta1/tx.js" + }, "./cosmos/bank/v1beta1/query.js": { "types": "./dist/codegen/cosmos/bank/v1beta1/query.d.ts", "default": "./dist/codegen/cosmos/bank/v1beta1/query.js" diff --git a/packages/orchestration/src/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index a4934c2006c..d0c74993caf 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -36,6 +36,7 @@ import type { } from '@agoric/vats/tools/ibc-utils.js'; import type { QueryDelegationTotalRewardsResponse } from '@agoric/cosmic-proto/cosmos/distribution/v1beta1/query.js'; import type { Coin } from '@agoric/cosmic-proto/cosmos/base/v1beta1/coin.js'; +import type { Any } from '@agoric/cosmic-proto/google/protobuf/any.js'; import type { AmountArg, ChainAddress, Denom, DenomAmount } from './types.js'; import { PFM_RECEIVER } from './exos/chain-hub.js'; @@ -299,6 +300,11 @@ export interface LiquidStakingMethods { liquidStake: (amount: AmountArg) => Promise; } +export interface AuthzMethods { + /** use `MsgExec` to submit transactions on behalf of another account */ + exec: (msgs: Any[], grantee: ChainAddress) => Promise; +} + // TODO support StakingAccountQueries /** Methods supported only on Agoric chain accounts */ export interface LocalAccountMethods extends StakingAccountActions { diff --git a/packages/orchestration/src/examples/authz-example.contract.js b/packages/orchestration/src/examples/authz-example.contract.js new file mode 100644 index 00000000000..46007e9447a --- /dev/null +++ b/packages/orchestration/src/examples/authz-example.contract.js @@ -0,0 +1,134 @@ +/** + * @file This contract demonstrates using AuthZ to control an existing account + * with an orchestration account. + * + * `MsgExec` is only part of the story, please see + * {@link ../../../../multichain-testing/test/auth-z-example.test.ts} for an + * example of client usage which involves `MsgGrant` txs from the grantee. + */ +import { M } from '@endo/patterns'; +import { prepareCombineInvitationMakers } from '../exos/combine-invitation-makers.js'; +import { CosmosOrchestrationInvitationMakersI } from '../exos/cosmos-orchestration-account.js'; +import { AmountArgShape, ChainAddressShape } from '../typeGuards.js'; +import { withOrchestration } from '../utils/start-helper.js'; +import * as flows from './authz-example.flows.js'; +import { prepareChainHubAdmin } from '../exos/chain-hub-admin.js'; + +/** + * @import {GuestInterface} from '@agoric/async-flow'; + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationTools, OrchestrationPowers} from '../utils/start-helper.js'; + * @import {CosmosOrchestrationAccount} from '../exos/cosmos-orchestration-account.js'; + * @import {AmountArg, ChainAddress, CosmosChainInfo, Denom, DenomDetail} from '../types.js'; + */ + +const emptyOfferShape = harden({ + // Nothing to give; the funds are deposited offline + give: {}, + want: {}, // UNTIL https://github.com/Agoric/agoric-sdk/issues/2230 + exit: M.any(), +}); + +/** + * Orchestration contract to be wrapped by withOrchestration for Zoe. + * + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * chainInfo: Record; + * assetInfo: [Denom, DenomDetail & { brandKey?: string }][]; + * }} privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools + */ +const contract = async ( + zcf, + privateArgs, + zone, + { orchestrateAll, zoeTools, chainHub }, +) => { + const AuthzExampleInvitationMakersI = M.interface( + 'AuthzExampleInvitationMakersI', + { + ExecSend: M.call( + M.arrayOf(AmountArgShape), + ChainAddressShape, + ChainAddressShape, + ).returns(M.promise()), + }, + ); + + /** @type {any} XXX async membrane */ + const makeExtraInvitationMaker = zone.exoClass( + 'AuthzExampleInvitationMakers', + AuthzExampleInvitationMakersI, + /** + * @param {GuestInterface} account + * @param {string} chainName + */ + (account, chainName) => { + return { account, chainName }; + }, + { + /** + * @param {AmountArg[]} amounts + * @param {ChainAddress} destination + * @param {ChainAddress} grantee + */ + ExecSend(amounts, destination, grantee) { + const { account } = this.state; + + return zcf.makeInvitation( + seat => + orchFns.execSend(account, seat, { + amounts, + destination, + grantee, + }), + 'Exec Send', + undefined, + emptyOfferShape, + ); + }, + }, + ); + + /** @type {any} XXX async membrane */ + const makeCombineInvitationMakers = prepareCombineInvitationMakers( + zone, + CosmosOrchestrationInvitationMakersI, + AuthzExampleInvitationMakersI, + ); + + const orchFns = orchestrateAll(flows, { + chainHub, + makeCombineInvitationMakers, + makeExtraInvitationMaker, + flows, + zoeTools, + }); + + /** + * Provide invitations to contract deployer for registering assets and chains + * in the local ChainHub for this contract. + */ + const creatorFacet = prepareChainHubAdmin(zone, chainHub); + + const publicFacet = zone.exo('publicFacet', undefined, { + makeAccount() { + return zcf.makeInvitation( + orchFns.makeAccount, + 'Make an Orchestration account', + undefined, + emptyOfferShape, + ); + }, + }); + + return harden({ publicFacet, creatorFacet }); +}; + +export const start = withOrchestration(contract); +harden(start); + +/** @typedef {typeof start} AuthzExampleSF */ diff --git a/packages/orchestration/src/examples/authz-example.flows.js b/packages/orchestration/src/examples/authz-example.flows.js new file mode 100644 index 00000000000..c0bdecfa036 --- /dev/null +++ b/packages/orchestration/src/examples/authz-example.flows.js @@ -0,0 +1,102 @@ +import { makeTracer } from '@agoric/internal'; +import { MsgSend } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/tx.js'; +import { Fail } from '@endo/errors'; +import { coerceCoin } from '../utils/amounts.js'; + +const trace = makeTracer('AuthzExampleFlows'); + +/** + * @import {GuestInterface} from '@agoric/async-flow'; + * @import {InvitationMakers} from '@agoric/smart-wallet/src/types.js'; + * @import {Orchestrator, OrchestrationFlow, AmountArg, ChainAddress, ChainHub} from '@agoric/orchestration'; + * @import {MakeCombineInvitationMakers} from '../exos/combine-invitation-makers.js'; + * @import {CosmosOrchestrationAccount} from '../exos/cosmos-orchestration-account.js'; + * @import {ResolvedContinuingOfferResult} from '../utils/zoe-tools.js'; + */ + +/** + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {{ + * makeCombineInvitationMakers: MakeCombineInvitationMakers; + * makeExtraInvitationMaker: (account: any) => InvitationMakers; + * }} ctx + * @param {ZCFSeat} seat + * @param {{ chainName: string }} offerArgs + * @returns {Promise} + */ +export const makeAccount = async (orch, ctx, seat, { chainName }) => { + seat.exit(); // no exchange of funds + + const chain = await orch.getChain(chainName); + const account = await chain.makeAccount(); + + const extraMakers = ctx.makeExtraInvitationMaker(account); + + const result = await account.asContinuingOffer(); + + return { + ...result, + invitationMakers: ctx.makeCombineInvitationMakers( + extraMakers, + result.invitationMakers, + ), + }; +}; +harden(makeAccount); + +/** + * Performs a MsgSend (bank/send) via MsgExec assuming an authorization from the + * `grantee` was posted via `MsgGrant`. + * + * Can we combined with a `timerService` waker to support scheduling in the + * future, like after an unbonding request. + * + * @satisfies {OrchestrationFlow} + * @param {Orchestrator} orch + * @param {{ chainHub: GuestInterface }} ctx + * @param {GuestInterface} account + * @param {ZCFSeat} seat + * @param {{ + * amounts: AmountArg[]; + * destination: ChainAddress; + * grantee: ChainAddress; + * }} offerArgs + * @returns {Promise} + */ +export const execSend = async ( + orch, + { chainHub }, + account, + seat, + { amounts, destination, grantee }, +) => { + await null; + trace('execSend', amounts, destination, grantee); + + // todo, support MsgTransfer using chainHub.getTransferRoute() + destination.chainId === grantee.chainId || + Fail`destination must be the same chain`; + + const msgs = [ + MsgSend.toProtoMsg({ + fromAddress: grantee.value, + toAddress: destination.value, + amount: amounts.map(a => + coerceCoin( + // @ts-expect-error HostInterface vs GuestInterface + chainHub, + a, + ), + ), + }), + ]; + try { + await account.exec(msgs, grantee); + seat.exit(); + } catch (e) { + console.error(e); + seat.fail(e); + } +}; +harden(execSend); diff --git a/packages/orchestration/src/exos/cosmos-orchestration-account.js b/packages/orchestration/src/exos/cosmos-orchestration-account.js index a787ff8e39d..9141cc11cc3 100644 --- a/packages/orchestration/src/exos/cosmos-orchestration-account.js +++ b/packages/orchestration/src/exos/cosmos-orchestration-account.js @@ -44,6 +44,7 @@ import { VowShape } from '@agoric/vow'; import { decodeBase64 } from '@endo/base64'; import { Fail, makeError, q } from '@endo/errors'; import { E } from '@endo/far'; +import { MsgExec } from '@agoric/cosmic-proto/cosmos/authz/v1beta1/tx.js'; import { AmountArgShape, ChainAddressShape, @@ -148,6 +149,7 @@ export const IcaAccountHolderI = M.interface('IcaAccountHolder', { executeEncodedTx: M.call(M.arrayOf(Proto3Shape)) .optional(TxBodyOptsShape) .returns(VowShape), + exec: M.call(M.arrayOf(M.record()), ChainAddressShape).returns(VowShape), }); /** @type {{ [name: string]: [description: string, valueShape: Matcher] }} */ @@ -845,6 +847,28 @@ export const prepareCosmosOrchestrationAccountKit = ( }); }, + /** + * Requires an existing authorization from the `grantee` via `MsgGrant`. + * + * @param {Any[]} msgs + * @param {ChainAddress} grantee + * @returns {Vow} + */ + exec(msgs, grantee) { + return asVow(() => { + const { helper } = this.facets; + const results = E(helper.owned()).executeEncodedTx([ + Any.toJSON( + MsgExec.toProtoMsg({ + grantee: grantee.value, + msgs, + }), + ), + ]); + return watch(results, this.facets.returnVoidWatcher); + }); + }, + /** @type {HostOf} */ send(toAccount, amount) { return asVow(() => { diff --git a/packages/orchestration/src/proposals/start-authz-example.js b/packages/orchestration/src/proposals/start-authz-example.js new file mode 100644 index 00000000000..68d5d110622 --- /dev/null +++ b/packages/orchestration/src/proposals/start-authz-example.js @@ -0,0 +1,119 @@ +/** + * @file A proposal to start the authz examples contract. + */ +import { makeTracer } from '@agoric/internal'; +import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; +import { E } from '@endo/far'; + +/** + * @import {CosmosChainInfo, Denom, DenomDetail} from '@agoric/orchestration'; + * @import {AuthzExampleSF} from '../examples/authz-example.contract.js'; + */ + +const trace = makeTracer('StartAuthzExample', true); +const contractName = 'authzExample'; + +/** + * See `@agoric/builders/builders/scripts/orchestration/init-authz-example.js` + * for the accompanying proposal builder. Run `agoric run + * packages/builders/scripts/orchestration/init-authz-example.js --chainInfo + * 'chainName:CosmosChainInfo' --assetInfo 'denom:DenomDetail'` to build the + * contract and proposal files. + * + * @param {BootstrapPowers & { + * installation: { + * consume: { + * authzExample: Installation; + * }; + * }; + * instance: { + * produce: { + * authzExample: Producer; + * }; + * }; + * }} powers + * @param {{ + * options: { + * chainInfo: Record; + * assetInfo: [Denom, DenomDetail & { brandKey?: string }][]; + * }; + * }} config + */ +export const startAuthzExample = async ( + { + consume: { + agoricNames, + board, + chainStorage, + chainTimerService, + cosmosInterchainService, + localchain, + startUpgradable, + }, + installation: { + consume: { [contractName]: installation }, + }, + instance: { + produce: { [contractName]: produceInstance }, + }, + }, + { options: { chainInfo, assetInfo } }, +) => { + trace(`start ${contractName}`); + await null; + + const storageNode = await makeStorageNodeChild(chainStorage, contractName); + const marshaller = await E(board).getPublishingMarshaller(); + + /** @type {StartUpgradableOpts} */ + const startOpts = { + label: contractName, + installation, + terms: undefined, + privateArgs: { + agoricNames: await agoricNames, + orchestrationService: await cosmosInterchainService, + localchain: await localchain, + storageNode, + marshaller, + timerService: await chainTimerService, + chainInfo, + assetInfo, + }, + }; + + const { instance } = await E(startUpgradable)(startOpts); + produceInstance.resolve(instance); +}; +harden(startAuthzExample); + +export const getManifestForContract = ( + { restoreRef }, + { installKeys, options }, +) => { + return { + manifest: { + [startAuthzExample.name]: { + consume: { + agoricNames: true, + board: true, + chainStorage: true, + chainTimerService: true, + cosmosInterchainService: true, + localchain: true, + startUpgradable: true, + }, + installation: { + consume: { [contractName]: true }, + }, + instance: { + produce: { [contractName]: true }, + }, + }, + }, + installations: { + [contractName]: restoreRef(installKeys[contractName]), + }, + options, + }; +};