diff --git a/packages/builders/scripts/testing/start-auto-stake-it.js b/packages/builders/scripts/testing/start-auto-stake-it.js new file mode 100644 index 000000000000..99310c38f349 --- /dev/null +++ b/packages/builders/scripts/testing/start-auto-stake-it.js @@ -0,0 +1,123 @@ +/** + * @file A proposal to start the auto-stake-it contract. + * + * AutoStakeIt allows users to to create an auto-forwarding address that + * transfers and stakes tokens on a remote chain when received. + */ +import { makeTracer } from '@agoric/internal'; +import { makeStorageNodeChild } from '@agoric/internal/src/lib-chainStorage.js'; +import { E } from '@endo/far'; + +/** + * @import {AutoStakeItSF} from '@agoric/orchestration/src/examples/auto-stake-it.contract.js'; + */ + +const contractName = 'autoAutoStakeIt'; +const trace = makeTracer(contractName, true); + +/** + * @param {BootstrapPowers} powers + */ +export const startAutoStakeIt = async ({ + consume: { + agoricNames, + board, + chainStorage, + chainTimerService, + cosmosInterchainService, + localchain, + startUpgradable, + }, + installation: { + // @ts-expect-error not a WellKnownName + consume: { [contractName]: installation }, + }, + instance: { + // @ts-expect-error not a WellKnownName + produce: { [contractName]: produceInstance }, + }, +}) => { + trace(`start ${contractName}`); + await null; + + const storageNode = await makeStorageNodeChild(chainStorage, contractName); + const marshaller = await E(board).getPublishingMarshaller(); + + /** @type {StartUpgradableOpts} */ + const startOpts = { + label: 'autoAutoStakeIt', + installation, + terms: undefined, + privateArgs: { + agoricNames: await agoricNames, + orchestrationService: await cosmosInterchainService, + localchain: await localchain, + storageNode, + marshaller, + timerService: await chainTimerService, + }, + }; + + const { instance } = await E(startUpgradable)(startOpts); + produceInstance.resolve(instance); +}; +harden(startAutoStakeIt); + +export const getManifestForContract = ( + { restoreRef }, + { installKeys, ...options }, +) => { + return { + manifest: { + [startAutoStakeIt.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, + }; +}; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').CoreEvalBuilder} */ +export const defaultProposalBuilder = async ({ publishRef, install }) => { + return harden({ + // Somewhat unorthodox, source the exports from this builder module + sourceSpec: '@agoric/builders/scripts/testing/start-auto-stake-it.js', + getManifestCall: [ + 'getManifestForContract', + { + installKeys: { + autoAutoStakeIt: publishRef( + install( + '@agoric/orchestration/src/examples/auto-stake-it.contract.js', + ), + ), + }, + }, + ], + }); +}; + +export default async (homeP, endowments) => { + // import dynamically so the module can work in CoreEval environment + const dspModule = await import('@agoric/deploy-script-support'); + const { makeHelpers } = dspModule; + const { writeCoreEval } = await makeHelpers(homeP, endowments); + await writeCoreEval(startAutoStakeIt.name, defaultProposalBuilder); +}; diff --git a/packages/orchestration/src/examples/auto-stake-it-tap-kit.js b/packages/orchestration/src/examples/auto-stake-it-tap-kit.js new file mode 100644 index 000000000000..f56478829334 --- /dev/null +++ b/packages/orchestration/src/examples/auto-stake-it-tap-kit.js @@ -0,0 +1,157 @@ +import { M, mustMatch } from '@endo/patterns'; +import { E } from '@endo/far'; +import { VowShape } from '@agoric/vow'; +import { makeTracer } from '@agoric/internal'; +import { atob } from '@endo/base64'; +import { ChainAddressShape } from '../typeGuards.js'; + +const trace = makeTracer('AutoStakeItTap'); + +/** + * @import {IBCChannelID, VTransferIBCEvent} from '@agoric/vats'; + * @import {VowTools} from '@agoric/vow'; + * @import {Zone} from '@agoric/zone'; + * @import {TargetApp} from '@agoric/vats/src/bridge-target.js'; + * @import {ChainAddress, CosmosValidatorAddress, Denom, OrchestrationAccount, StakingAccountActions} from '@agoric/orchestration'; + * @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; + */ + +/** + * @typedef {{ + * stakingAccount: ERef & StakingAccountActions>; + * localAccount: ERef>; + * validator: CosmosValidatorAddress; + * localChainAddress: ChainAddress; + * remoteChainAddress: ChainAddress; + * sourceChannel: IBCChannelID; + * remoteDenom: Denom; + * localDenom: Denom; + * }} StakingTapState + */ + +const StakingTapStateShape = { + stakingAccount: M.remotable('CosmosOrchestrationAccount'), + localAccount: M.remotable('LocalOrchestrationAccount'), + validator: ChainAddressShape, + localChainAddress: ChainAddressShape, + remoteChainAddress: ChainAddressShape, + sourceChannel: M.string(), + remoteDenom: M.string(), + localDenom: M.string(), +}; +harden(StakingTapStateShape); + +/** + * @param {Zone} zone + * @param {VowTools} vowTools + */ +const prepareMakeStakingTapKit = (zone, { watch }) => { + return zone.exoClassKit( + 'StakingTap', + { + tap: M.interface('AutoStakeItTap', { + receiveUpcall: M.call(M.record()).returns( + M.or(VowShape, M.undefined()), + ), + }), + transferWatcher: M.interface('TransferWatcher', { + onFulfilled: M.call(M.undefined()) + .optional(M.bigint()) + .returns(VowShape), + }), + }, + /** @param {StakingTapState} initialState */ + initialState => { + mustMatch(initialState, StakingTapStateShape); + return /** @type {StakingTapState} */ (harden(initialState)); + }, + { + tap: { + /** + * Transfers from localAccount to stakingAccount, then delegates from + * the stakingAccount to `validator` if the expected token (remoteDenom) + * is received. + * + * @param {VTransferIBCEvent} event + */ + receiveUpcall(event) { + trace('receiveUpcall', event); + + // ignore packets from unknown channels + if (event.packet.source_channel !== this.state.sourceChannel) { + return; + } + + const tx = /** @type {FungibleTokenPacketData} */ ( + JSON.parse(atob(event.packet.data)) + ); + trace('receiveUpcall packet data', tx); + + const { remoteDenom, localChainAddress } = this.state; + // ignore outgoing transfers + if (tx.receiver !== localChainAddress.value) { + return; + } + // only interested in transfers of `remoteDenom` + if (tx.denom !== remoteDenom) { + return; + } + + const { localAccount, localDenom, remoteChainAddress } = this.state; + return watch( + E(localAccount).transfer( + { + denom: localDenom, + value: BigInt(tx.amount), + }, + remoteChainAddress, + ), + this.facets.transferWatcher, + BigInt(tx.amount), + ); + }, + }, + transferWatcher: { + /** + * @param {void} _result + * @param {bigint} value the qty of uatom to delegate + */ + onFulfilled(_result, value) { + const { stakingAccount, validator, remoteDenom } = this.state; + return watch( + E(stakingAccount).delegate(validator, { + denom: remoteDenom, + value, + }), + ); + }, + }, + }, + ); +}; + +/** @typedef {ReturnType} MakeStakingTapKit */ +/** @typedef {ReturnType} StakingTapKit */ + +/** + * Provides a {@link TargetApp} that reacts to an incoming IBC transfer by: + * + * 1. transferring the funds to the staking account specified at initialization + * 2. delegating the funds to the validator specified at initialization + * + * XXX consider a facet with a method for changing the validator + * + * XXX consider logic for multiple stakingAccounts + denoms + * + * @param {Zone} zone + * @param {VowTools} vowTools + * @returns {( + * ...args: Parameters> + * ) => StakingTapKit['tap']} + */ +export const prepareMakeStakingTap = (zone, vowTools) => { + const makeKit = prepareMakeStakingTapKit(zone, vowTools); + return (...args) => makeKit(...args).tap; +}; +/** @typedef {ReturnType} MakeStakingTap */ +/** @typedef {StakingTapKit['tap']} StakingTap */ diff --git a/packages/orchestration/src/examples/auto-stake-it.contract.js b/packages/orchestration/src/examples/auto-stake-it.contract.js new file mode 100644 index 000000000000..af54eb7ad4af --- /dev/null +++ b/packages/orchestration/src/examples/auto-stake-it.contract.js @@ -0,0 +1,181 @@ +import { + EmptyProposalShape, + InvitationShape, +} from '@agoric/zoe/src/typeGuards.js'; +import { Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { provideOrchestration } from '../utils/start-helper.js'; +import { prepareChainHubAdmin } from './shared/chain-hub-cf.js'; +import { prepareMakeStakingTap } from './auto-stake-it-tap-kit.js'; +import { preparePortfolioHolder } from '../exos/portfolio-holder-kit.js'; + +/** + * @import {Baggage} from '@agoric/vat-data'; + * @import {TimerService} from '@agoric/time'; + * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; + * @import {LocalChain} from '@agoric/vats/src/localchain.js'; + * @import {NameHub} from '@agoric/vats'; + * @import {Remote} from '@agoric/vow'; + * @import {GuestInterface} from '@agoric/async-flow'; + * @import {CosmosValidatorAddress, Orchestrator, CosmosInterchainService, Denom, OrchestrationAccount, StakingAccountActions} from '@agoric/orchestration'; + * @import {MakeStakingTap} from './auto-stake-it-tap-kit.js'; + * @import {MakePortfolioHolder} from '../exos/portfolio-holder-kit.js'; + * @import {ChainHub} from '../exos/chain-hub.js'; + */ + +/** + * @typedef {{ + * localchain: Remote; + * orchestrationService: Remote; + * storageNode: Remote; + * timerService: Remote; + * agoricNames: Remote; + * }} OrchestrationPowers + */ + +/** + * @param {Orchestrator} orch + * @param {{ + * makeStakingTap: MakeStakingTap; + * makePortfolioHolder: MakePortfolioHolder; + * chainHub: GuestInterface; + * }} ctx + * @param {ZCFSeat} seat + * @param {{ + * chainName: string; + * validator: CosmosValidatorAddress; + * localDenom: Denom; + * }} offerArgs + */ +const makeAccountsHandler = async ( + orch, + { makeStakingTap, makePortfolioHolder, chainHub }, + seat, + { + chainName, + validator, + // TODO localDenom is user supplied, until #9211 + localDenom, + }, +) => { + seat.exit(); // no funds exchanged + const [agoric, remoteChain] = await Promise.all([ + orch.getChain('agoric'), + orch.getChain(chainName), + ]); + const { chainId, stakingTokens } = await remoteChain.getChainInfo(); + const remoteDenom = stakingTokens[0].denom; + remoteDenom || + Fail`${chainId || chainName} does not have stakingTokens in config`; + if (chainId !== validator.chainId) { + Fail`validator chainId ${validator.chainId} does not match remote chainId ${chainId}`; + } + const [localAccount, stakingAccount] = await Promise.all([ + agoric.makeAccount(), + /** @type {Promise & StakingAccountActions>} */ ( + remoteChain.makeAccount() + ), + ]); + + const [localChainAddress, remoteChainAddress] = await Promise.all([ + localAccount.getAddress(), + stakingAccount.getAddress(), + ]); + const agoricChainId = (await agoric.getChainInfo()).chainId; + const { transferChannel } = await chainHub.getConnectionInfo( + agoricChainId, + chainId, + ); + assert(transferChannel.counterPartyChannelId, 'unable to find sourceChannel'); + + // Every time the `localAccount` receives `remoteDenom` over IBC, delegate it. + const tap = makeStakingTap({ + localAccount, + stakingAccount, + validator, + localChainAddress, + remoteChainAddress, + sourceChannel: transferChannel.counterPartyChannelId, + remoteDenom, + localDenom, + }); + // XXX consider storing appRegistration, so we can .revoke() or .updateTargetApp() + // @ts-expect-error tap.receiveUpcall: 'Vow | undefined' not assignable to 'Promise' + await localAccount.monitorTransfers(tap); + + const accountEntries = harden( + /** @type {[string, OrchestrationAccount][]} */ ([ + ['agoric', localAccount], + [chainName, stakingAccount], + ]), + ); + const publicTopics = harden( + /** @type {[string, ResolvedPublicTopic][]} */ ( + await Promise.all( + accountEntries.map(async ([name, account]) => { + const { account: topicRecord } = await account.getPublicTopics(); + return [name, topicRecord]; + }), + ) + ), + ); + const portfolioHolder = makePortfolioHolder(accountEntries, publicTopics); + return portfolioHolder.asContinuingOffer(); +}; + +/** + * AutoStakeIt allows users to to create an auto-forwarding address that + * transfers and stakes tokens on a remote chain when received. + * + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} privateArgs + * @param {Baggage} baggage + */ +export const start = async (zcf, privateArgs, baggage) => { + const { chainHub, orchestrate, vowTools, zone } = provideOrchestration( + zcf, + baggage, + privateArgs, + privateArgs.marshaller, + ); + const makeStakingTap = prepareMakeStakingTap( + zone.subZone('stakingTap'), + vowTools, + ); + const makePortfolioHolder = preparePortfolioHolder( + zone.subZone('portfolio'), + vowTools, + ); + + /** @type {OfferHandler} */ + const makeAccounts = orchestrate( + 'makeAccounts', + { makeStakingTap, makePortfolioHolder, chainHub }, + makeAccountsHandler, + ); + + const publicFacet = zone.exo( + 'AutoStakeIt Public Facet', + M.interface('AutoStakeIt Public Facet', { + makeAccountsInvitation: M.callWhen().returns(InvitationShape), + }), + { + makeAccountsInvitation() { + return zcf.makeInvitation( + makeAccounts, + 'Make Accounts', + undefined, + EmptyProposalShape, + ); + }, + }, + ); + + const creatorFacet = prepareChainHubAdmin(zone, chainHub); + + return { publicFacet, creatorFacet }; +}; + +/** @typedef {typeof start} AutoStakeItSF */ diff --git a/packages/orchestration/test/examples/auto-stake-it.contract.test.ts b/packages/orchestration/test/examples/auto-stake-it.contract.test.ts new file mode 100644 index 000000000000..df7ea0d3a3bf --- /dev/null +++ b/packages/orchestration/test/examples/auto-stake-it.contract.test.ts @@ -0,0 +1,151 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; +import { E } from '@endo/far'; +import path from 'path'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import { MsgDelegateResponse } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/tx.js'; +import { ContinuingOfferResult } from '@agoric/smart-wallet/src/types.js'; +import { IBCEvent } from '@agoric/vats'; +import { commonSetup } from '../supports.js'; +import { + buildMsgResponseString, + buildVTransferEvent, +} from '../../tools/ibc-mocks.js'; + +const dirname = path.dirname(new URL(import.meta.url).pathname); + +const contractName = 'auto-stake-it'; +const contractFile = `${dirname}/../../src/examples/${contractName}.contract.js`; +type StartFn = + typeof import('../../src/examples/auto-stake-it.contract.js').start; + +test('auto-stake-it - make accounts, register tap, return invitationMakers', async t => { + t.log('bootstrap, orchestration core-eval'); + const { + bootstrap: { storage }, + commonPrivateArgs, + mocks: { transferBridge }, + utils: { inspectLocalBridge, inspectDibcBridge }, + } = await commonSetup(t); + + const { zoe, bundleAndInstall } = await setUpZoeForTest(); + + t.log('contract coreEval', contractName); + const installation: Installation = + await bundleAndInstall(contractFile); + const storageNode = await E(storage.rootNode).makeChildNode(contractName); + const autoAutoStakeItKit = await E(zoe).startInstance( + installation, + undefined, + {}, + { ...commonPrivateArgs, storageNode }, + ); + const publicFacet = await E(zoe).getPublicFacet(autoAutoStakeItKit.instance); + + // make an offer to create an LocalOrchAcct and a CosmosOrchAccount + const inv = E(publicFacet).makeAccountsInvitation(); + const userSeat = E(zoe).offer(inv, {}, undefined, { + chainName: 'cosmoshub', + validator: { + chainId: 'cosmoshub-4', + value: 'cosmosvaloper1test', + encoding: 'bech32', + }, + // TODO user supplied until #9211 + localDenom: 'ibc/fakeuatomhash', + remoteDenom: 'uatom', + }); + const result = (await E(userSeat).getOfferResult()) as ContinuingOfferResult; + const { + publicSubscribers: { agoric, cosmoshub }, + } = result; + + const loaAddress = agoric.storagePath.split('.').pop(); + const icaAddress = cosmoshub.storagePath.split('.').pop(); + t.regex(loaAddress, /^agoric/); + t.regex(icaAddress, /^cosmos/); + + // simulate incoming transfers with upcall from golang to VM to initiate the + // incoming transfer Tap + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: 'agoric1fakeLCAAddress', + amount: 10n, + denom: 'unknown-token', + }), + ); + await eventLoopIteration(); + { + const { messages } = inspectLocalBridge().at(-1); + // we do not expect to see MsgTransfer for unknown tokens + t.not(messages?.length, 1, 'unknown-token is ignored'); + } + + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: 'agoric1fakeLCAAddress', + amount: 10n, + denom: 'unknown-token', + sourceChannel: 'channel-0', + }), + ); + await eventLoopIteration(); + { + const { messages } = inspectLocalBridge().at(-1); + // we do not expect to see MsgTransfer for an sourceChannel + t.not(messages?.length, 1, 'unknown sourceChannel is ignored'); + } + + await E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: 'agoric1fakeLCAAddress', + amount: 10n, + denom: 'uatom', + }), + ); + await eventLoopIteration(); + + const { messages, address: execAddr } = inspectLocalBridge().at(-1); + t.is(messages?.length, 1, 'transfer message sent'); + t.like( + messages[0], + { + '@type': '/ibc.applications.transfer.v1.MsgTransfer', + receiver: 'cosmos1test', + sender: execAddr, + sourceChannel: 'channel-5', + token: { amount: '10', denom: 'ibc/fakeuatomhash' }, + }, + 'tokens transferred from LOA to COA', + ); + const { acknowledgement } = (await inspectDibcBridge()).at( + -1, + ) as IBCEvent<'acknowledgementPacket'>; + // XXX consider checking ICA (dest|source)_channel, to verify the sender of + // MsgDelegate, once available in vstorage + t.is( + acknowledgement, + buildMsgResponseString(MsgDelegateResponse, {}), + 'COA delegated the received funds', + ); + + // second user can make an account + const inv2 = E(publicFacet).makeAccountsInvitation(); + const userSeat2 = E(zoe).offer(inv2, {}, undefined, { + chainName: 'cosmoshub', + validator: { + chainId: 'cosmoshub-4', + value: 'cosmosvaloper1test', + encoding: 'bech32', + }, + // TODO user supplied until #9211 + localDenom: 'ibc/fakeuatomhash', + remoteDenom: 'uatom', + }); + const { publicSubscribers: pubSubs2 } = (await E( + userSeat2, + ).getOfferResult()) as ContinuingOfferResult; + + t.regex(pubSubs2.agoric.storagePath.split('.').pop(), /^agoric/); + t.regex(pubSubs2.cosmoshub.storagePath.split('.').pop(), /^cosmos/); +});