diff --git a/multichain-testing/package.json b/multichain-testing/package.json index 94be045eb20..7088cc34fb2 100644 --- a/multichain-testing/package.json +++ b/multichain-testing/package.json @@ -42,7 +42,8 @@ "typescript": "^5.3.3" }, "resolutions": { - "node-fetch": "2.6.12" + "node-fetch": "2.6.12", + "axios": "1.6.7" }, "ava": { "extensions": { diff --git a/multichain-testing/patches/axios+1.6.7.patch b/multichain-testing/patches/axios+1.6.7.patch new file mode 100644 index 00000000000..3373cf41c42 --- /dev/null +++ b/multichain-testing/patches/axios+1.6.7.patch @@ -0,0 +1,44 @@ +diff --git a/node_modules/axios/dist/node/axios.cjs b/node_modules/axios/dist/node/axios.cjs +index 9099d87..7104f6e 100644 +--- a/node_modules/axios/dist/node/axios.cjs ++++ b/node_modules/axios/dist/node/axios.cjs +@@ -370,9 +370,9 @@ function merge(/* obj1, obj2, obj3, ... */) { + const extend = (a, b, thisArg, {allOwnKeys}= {}) => { + forEach(b, (val, key) => { + if (thisArg && isFunction(val)) { +- a[key] = bind(val, thisArg); ++ Object.defineProperty(a, key, {value: bind(val, thisArg)}); + } else { +- a[key] = val; ++ Object.defineProperty(a, key, {value: val}); + } + }, {allOwnKeys}); + return a; +@@ -403,7 +403,9 @@ const stripBOM = (content) => { + */ + const inherits = (constructor, superConstructor, props, descriptors) => { + constructor.prototype = Object.create(superConstructor.prototype, descriptors); +- constructor.prototype.constructor = constructor; ++ Object.defineProperty(constructor, 'constructor', { ++ value: constructor ++ }); + Object.defineProperty(constructor, 'super', { + value: superConstructor.prototype + }); +@@ -565,12 +567,14 @@ const isRegExp = kindOfTest('RegExp'); + + const reduceDescriptors = (obj, reducer) => { + const descriptors = Object.getOwnPropertyDescriptors(obj); +- const reducedDescriptors = {}; ++ let reducedDescriptors = {}; + + forEach(descriptors, (descriptor, name) => { + let ret; + if ((ret = reducer(descriptor, name, obj)) !== false) { +- reducedDescriptors[name] = ret || descriptor; ++ reducedDescriptors = {...reducedDescriptors, ++ [name]: ret || descriptor ++ }; + } + }); + diff --git a/multichain-testing/patches/protobufjs+6.11.4.patch b/multichain-testing/patches/protobufjs+6.11.4.patch new file mode 100644 index 00000000000..9c6d1ff5093 --- /dev/null +++ b/multichain-testing/patches/protobufjs+6.11.4.patch @@ -0,0 +1,56 @@ +diff --git a/node_modules/protobufjs/src/util/minimal.js b/node_modules/protobufjs/src/util/minimal.js +index 7f62daa..8d60657 100644 +--- a/node_modules/protobufjs/src/util/minimal.js ++++ b/node_modules/protobufjs/src/util/minimal.js +@@ -259,14 +259,9 @@ util.newError = newError; + * @returns {Constructor} Custom error constructor + */ + function newError(name) { +- + function CustomError(message, properties) { +- + if (!(this instanceof CustomError)) + return new CustomError(message, properties); +- +- // Error.call(this, message); +- // ^ just returns a new error instance because the ctor can be called as a function + + Object.defineProperty(this, "message", { get: function() { return message; } }); + +@@ -280,13 +275,31 @@ function newError(name) { + merge(this, properties); + } + +- (CustomError.prototype = Object.create(Error.prototype)).constructor = CustomError; ++ // Create a new object with Error.prototype as its prototype ++ const proto = Object.create(Error.prototype); + +- Object.defineProperty(CustomError.prototype, "name", { get: function() { return name; } }); ++ // Define properties on the prototype ++ Object.defineProperties(proto, { ++ constructor: { ++ value: CustomError, ++ writable: true, ++ configurable: true ++ }, ++ name: { ++ get: function() { return name; }, ++ configurable: true ++ }, ++ toString: { ++ value: function toString() { ++ return this.name + ": " + this.message; ++ }, ++ writable: true, ++ configurable: true ++ } ++ }); + +- CustomError.prototype.toString = function toString() { +- return this.name + ": " + this.message; +- }; ++ // Set the prototype of CustomError ++ CustomError.prototype = proto; + + return CustomError; + } diff --git a/multichain-testing/scripts/fetch-starship-chain-info.ts b/multichain-testing/scripts/fetch-starship-chain-info.ts index 564384340ec..f809e031e16 100755 --- a/multichain-testing/scripts/fetch-starship-chain-info.ts +++ b/multichain-testing/scripts/fetch-starship-chain-info.ts @@ -50,7 +50,7 @@ const chainInfo = await convertChainInfo({ }); const record = JSON.stringify(chainInfo, null, 2); -const src = `/** @file Generated by fetch-starship-chain-info.ts */\nexport default /** @type {const} } */ (${record});`; +const src = `/** @file Generated by fetch-starship-chain-info.ts */\nexport default /** @type {const} */ (${record});`; const prettySrc = await prettier.format(src, { parser: 'babel', // 'typescript' fails to preserve parens for typecast singleQuote: true, diff --git a/multichain-testing/starship-chain-info.js b/multichain-testing/starship-chain-info.js index d194cdb0d23..d756d2f058f 100644 --- a/multichain-testing/starship-chain-info.js +++ b/multichain-testing/starship-chain-info.js @@ -1,5 +1,5 @@ /** @file Generated by fetch-starship-chain-info.ts */ -export default /** @type {const} } */ ({ +export default /** @type {const} */ ({ agoric: { chainId: 'agoriclocal', stakingTokens: [ diff --git a/multichain-testing/test/auto-stake-it.test.ts b/multichain-testing/test/auto-stake-it.test.ts new file mode 100644 index 00000000000..89dcead678d --- /dev/null +++ b/multichain-testing/test/auto-stake-it.test.ts @@ -0,0 +1,239 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { ExecutionContext, TestFn } from 'ava'; +import { useChain } from 'starshipjs'; +import type { CosmosChainInfo, IBCConnectionInfo } from '@agoric/orchestration'; +import type { SetupContextWithWallets } from './support.js'; +import { chainConfig, commonSetup } from './support.js'; +import { makeQueryClient } from '../tools/query.js'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import chainInfo from '../starship-chain-info.js'; +import { + createFundedWalletAndClient, + makeIBCTransferMsg, +} from '../tools/ibc-transfer.js'; + +const test = anyTest as TestFn; + +const accounts = ['agoricAdmin', 'cosmoshub', 'osmosis']; + +const contractName = 'autoAutoStakeIt'; +const contractBuilder = + '../packages/builders/scripts/testing/start-auto-stake-it.js'; + +test.before(async t => { + const { deleteTestKeys, setupTestKeys, ...rest } = await commonSetup(t); + deleteTestKeys(accounts).catch(); + const wallets = await setupTestKeys(accounts); + t.context = { ...rest, wallets, deleteTestKeys }; + + t.log('bundle and install contract', contractName); + await t.context.deployBuilder(contractBuilder); + const vstorageClient = t.context.makeQueryTool(); + await t.context.retryUntilCondition( + () => vstorageClient.queryData(`published.agoricNames.instance`), + res => contractName in Object.fromEntries(res), + `${contractName} instance is available`, + ); +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +const makeFundAndTransfer = (t: ExecutionContext) => { + const { retryUntilCondition } = t.context; + return async (chainName: string, agoricAddr: string, amount = 100n) => { + const { staking } = useChain(chainName).chainInfo.chain; + const denom = staking?.staking_tokens?.[0].denom; + if (!denom) throw Error(`no denom for ${chainName}`); + + const { client, address, wallet } = await createFundedWalletAndClient( + t, + chainName, + ); + const balancesResult = await retryUntilCondition( + () => client.getAllBalances(address), + coins => !!coins?.length, + `Faucet balances found for ${address}`, + ); + + console.log('Balances:', balancesResult); + + const transferArgs = makeIBCTransferMsg( + { denom, value: amount }, + { address: agoricAddr, chainName: 'agoric' }, + { address: address, chainName }, + Date.now(), + ); + console.log('Transfer Args:', transferArgs); + // TODO #9200 `sendIbcTokens` does not support `memo` + // @ts-expect-error spread argument for concise code + const txRes = await client.sendIbcTokens(...transferArgs); + if (txRes && txRes.code !== 0) { + console.error(txRes); + throw Error(`failed to ibc transfer funds to ${chainName}`); + } + const { events: _events, ...txRest } = txRes; + console.log(txRest); + t.is(txRes.code, 0, `Transaction succeeded`); + t.log(`Funds transferred to ${agoricAddr}`); + return { + client, + address, + wallet, + }; + }; +}; + +const autoStakeItScenario = test.macro({ + title: (_, chainName: string) => `auto-stake-it on ${chainName}`, + exec: async (t, chainName: string) => { + const { + wallets, + makeQueryTool, + provisionSmartWallet, + retryUntilCondition, + } = t.context; + + const fundAndTransfer = makeFundAndTransfer(t); + + // 1. Send initial tokens so denom is available (debatably necessary, but + // allows us to trace the denom until we have ibc denoms in chainInfo) + const agAdminAddr = wallets['agoricAdmin']; + console.log('Sending tokens to', agAdminAddr, `from ${chainName}`); + await fundAndTransfer(chainName, agAdminAddr); + + // 2. Find 'stakingDenom' denom on agoric + const agoricConns = chainInfo['agoric'].connections as Record< + string, + IBCConnectionInfo + >; + const remoteChainInfo = (chainInfo as Record)[ + chainName + ]; + // const remoteChainId = remoteChainInfo.chain.chain_id; + // const agoricToRemoteConn = agoricConns[remoteChainId]; + const { portId, channelId } = + agoricConns[remoteChainInfo.chainId].transferChannel; + const agoricQueryClient = makeQueryClient( + useChain('agoric').getRestEndpoint(), + ); + const stakingDenom = remoteChainInfo?.stakingTokens?.[0].denom; + if (!stakingDenom) throw Error(`staking denom found for ${chainName}`); + const { hash } = await retryUntilCondition( + () => + agoricQueryClient.queryDenom(`/${portId}/${channelId}`, stakingDenom), + denomTrace => !!denomTrace.hash, + `local denom hash for ${stakingDenom} found`, + ); + t.log(`found ibc denom hash for ${stakingDenom}:`, hash); + + // 3. Find a remoteChain validator to delegate to + const remoteQueryClient = makeQueryClient( + useChain(chainName).getRestEndpoint(), + ); + const { validators } = await remoteQueryClient.queryValidators(); + const validatorAddress = validators[0]?.operator_address; + t.truthy( + validatorAddress, + `found a validator on ${chainName} to delegate to`, + ); + t.log( + { validatorAddress }, + `found a validator on ${chainName} to delegate to`, + ); + + // 4. Send an Offer to make the accounts and set up the transfer tap + const agoricUserAddr = wallets[chainName]; + const wdUser = await provisionSmartWallet(agoricUserAddr, { + BLD: 100n, + IST: 100n, + }); + const doOffer = makeDoOffer(wdUser); + t.log(`${chainName} makeAccount offer`); + const offerId = `${chainName}-makeAccountsInvitation-${Date.now()}`; + + await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeAccountsInvitation']], + }, + offerArgs: { + chainName, + validator: { + value: validatorAddress, + encoding: 'bech32', + chainId: remoteChainInfo.chainId, + }, + localDenom: `ibc/${hash}`, + }, + proposal: {}, + }); + + // FIXME https://github.com/Agoric/agoric-sdk/issues/9643 + const vstorageClient = makeQueryTool(); + const currentWalletRecord = await retryUntilCondition( + () => + vstorageClient.queryData(`published.wallet.${agoricUserAddr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[offerId], + `${offerId} continuing invitation is in vstorage`, + ); + + const offerToPublicSubscriberMap = Object.fromEntries( + currentWalletRecord.offerToPublicSubscriberPaths, + ); + + // 5. look up LOA address in vstorage + console.log('offerToPublicSubscriberMap', offerToPublicSubscriberMap); + const lcaAddress = offerToPublicSubscriberMap[offerId]?.agoric + .split('.') + .pop(); + const icaAddress = offerToPublicSubscriberMap[offerId]?.[chainName] + .split('.') + .pop(); + console.log({ lcaAddress, icaAddress }); + t.regex(lcaAddress, /^agoric1/, 'LOA address is valid'); + t.regex( + icaAddress, + new RegExp(`^${chainConfig[chainName].expectedAddressPrefix}1`), + 'COA address is valid', + ); + + // 6. transfer in some tokens over IBC + const transferAmount = 99n; + await fundAndTransfer(chainName, lcaAddress, transferAmount); + + // 7. verify the COA has active delegations + if (chainName === 'cosmoshub') { + // FIXME: delegations are not visible on cosmoshub + return t.pass('skipping verifying delegations on cosmoshub'); + } + const { delegation_responses } = await retryUntilCondition( + () => remoteQueryClient.queryDelegations(icaAddress), + ({ delegation_responses }) => !!delegation_responses.length, + `delegations visible on ${chainName}`, + ); + t.log('delegation balance', delegation_responses[0]?.balance); + t.like( + delegation_responses[0].balance, + { denom: stakingDenom, amount: String(transferAmount) }, + 'delegations balance', + ); + t.log( + `Orchestration Account Delegations on ${chainName}`, + delegation_responses, + ); + + // XXX consider using PortfolioHolder continuing inv to undelegate + + // XXX how to test other tokens do not result in an attempted MsgTransfer or MsgDelegate? + // query tx history of the LOA via an rpc node? + }, +}); + +test.serial(autoStakeItScenario, 'osmosis'); +test.serial(autoStakeItScenario, 'cosmoshub'); diff --git a/multichain-testing/test/basic-flows.test.ts b/multichain-testing/test/basic-flows.test.ts index 40a037a5045..f6649e0100b 100644 --- a/multichain-testing/test/basic-flows.test.ts +++ b/multichain-testing/test/basic-flows.test.ts @@ -1,12 +1,15 @@ import anyTest from '@endo/ses-ava/prepare-endo.js'; import type { TestFn } from 'ava'; -import { commonSetup, SetupContextWithWallets } from './support.js'; import { makeDoOffer } from '../tools/e2e-tools.js'; -import { chainConfig, chainNames } from './support.js'; +import { + commonSetup, + SetupContextWithWallets, + chainConfig, +} from './support.js'; const test = anyTest as TestFn; -const accounts = ['user1', 'user2', 'user3']; // one account for each scenario +const accounts = ['agoric', 'cosmoshub', 'osmosis']; // one account for each scenario const contractName = 'basicFlows'; const contractBuilder = @@ -48,21 +51,18 @@ const makeAccountScenario = test.macro({ const vstorageClient = makeQueryTool(); - const wallet = accounts[chainNames.indexOf(chainName)]; - const wdUser1 = await provisionSmartWallet(wallets[wallet], { + const agoricAddr = wallets[chainName]; + const wdUser1 = await provisionSmartWallet(agoricAddr, { BLD: 100n, IST: 100n, }); - t.log(`provisioning agoric smart wallet for ${wallets[wallet]}`); + t.log(`provisioning agoric smart wallet for ${agoricAddr}`); const doOffer = makeDoOffer(wdUser1); t.log(`${chainName} makeAccount offer`); const offerId = `${chainName}-makeAccount-${Date.now()}`; - // FIXME we get payouts but not an offer result; it times out - // https://github.com/Agoric/agoric-sdk/issues/9643 - // chain logs shows an UNPUBLISHED result - const _offerResult = await doOffer({ + await doOffer({ id: offerId, invitationSpec: { source: 'agoricContract', @@ -72,14 +72,10 @@ const makeAccountScenario = test.macro({ offerArgs: { chainName }, proposal: {}, }); - t.true(_offerResult); - // t.is(await _offerResult, 'UNPUBLISHED', 'representation of continuing offer'); - // TODO fix above so we don't have to poll for the offer result to be published // https://github.com/Agoric/agoric-sdk/issues/9643 const currentWalletRecord = await retryUntilCondition( - () => - vstorageClient.queryData(`published.wallet.${wallets[wallet]}.current`), + () => vstorageClient.queryData(`published.wallet.${agoricAddr}.current`), ({ offerToPublicSubscriberPaths }) => Object.fromEntries(offerToPublicSubscriberPaths)[offerId], `${offerId} continuing invitation is in vstorage`, @@ -100,7 +96,7 @@ const makeAccountScenario = test.macro({ ); const latestWalletUpdate = await vstorageClient.queryData( - `published.wallet.${wallets[wallet]}`, + `published.wallet.${agoricAddr}`, ); t.log('latest wallet update', latestWalletUpdate); t.like( diff --git a/multichain-testing/test/stake-ica.test.ts b/multichain-testing/test/stake-ica.test.ts index 06e36bbc611..78f520e6045 100644 --- a/multichain-testing/test/stake-ica.test.ts +++ b/multichain-testing/test/stake-ica.test.ts @@ -66,7 +66,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { // FIXME we get payouts but not an offer result; it times out // chain logs shows an UNPUBLISHED result - const _offerResult = await doOffer({ + await doOffer({ id: makeAccountofferId, invitationSpec: { source: 'agoricContract', @@ -75,7 +75,6 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - t.true(_offerResult); // t.is(await _offerResult, 'UNPUBLISHED', 'representation of continuing offer'); // XXX fix above so we don't have to wait for the offer result to be published @@ -113,14 +112,14 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { const { creditFromFaucet, getRestEndpoint } = useChain(scenario.chain); const queryClient = makeQueryClient(getRestEndpoint()); - t.log('Requesting faucet funds'); + t.log(`Requesting faucet funds for ${address}`); // XXX fails intermittently until https://github.com/cosmology-tech/starship/issues/417 await creditFromFaucet(address); const { balances } = await retryUntilCondition( () => queryClient.queryBalances(address), ({ balances }) => !!balances.length, - 'faucet funds available', + `${scenario.chain} faucet funds available`, ); t.log('Updated balances:', balances); t.like( @@ -140,7 +139,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { chainId: scenario.chainId, encoding: 'bech32', }; - const _delegateOfferResult = await doOffer({ + await doOffer({ id: delegateOfferId, invitationSpec: { source: 'continuing', @@ -153,7 +152,6 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - t.true(_delegateOfferResult, 'delegate payouts (none) returned'); const latestWalletUpdate = await vstorageClient.queryData( `published.wallet.${wallets[scenario.wallet]}`, @@ -192,7 +190,8 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { t.log('reward:', total[0]); t.log('WithrawReward offer from continuing inv'); const withdrawRewardOfferId = `reward-${Date.now()}`; - const _withdrawRewardOfferResult = await doOffer({ + // funds are withdrawn to ICA, not the seat + await doOffer({ id: withdrawRewardOfferId, invitationSpec: { source: 'continuing', @@ -202,11 +201,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - // funds are withdrawn to ICA, not the seat - t.true( - _withdrawRewardOfferResult, - 'withdraw rewards (empty) payouts returned', - ); + const { balances: rewards } = await retryUntilCondition( () => queryClient.queryBalances(address), ({ balances }) => @@ -218,7 +213,7 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { const SHARES = 50; t.log('Undelegate offer from continuing inv'); const undelegateOfferId = `undelegate-${Date.now()}`; - const _undelegateOfferResult = await doOffer({ + await doOffer({ id: undelegateOfferId, invitationSpec: { source: 'continuing', @@ -235,7 +230,6 @@ const stakeScenario = test.macro(async (t, scenario: StakeIcaScenario) => { }, proposal: {}, }); - t.true(_undelegateOfferResult, 'undelegate payouts returned'); const { unbonding_responses } = await retryUntilCondition( () => queryClient.queryUnbonding(address), diff --git a/multichain-testing/test/support.ts b/multichain-testing/test/support.ts index d54f3f226bc..d7caf44e9a4 100644 --- a/multichain-testing/test/support.ts +++ b/multichain-testing/test/support.ts @@ -12,25 +12,18 @@ import { makeDeployBuilder } from '../tools/deploy.js'; const setupRegistry = makeSetupRegistry(makeGetFile({ dirname, join })); -export const chainConfig = { +// XXX consider including bech32Prefix in `ChainInfo` +export const chainConfig: Record = { cosmoshub: { - chainId: 'gaialocal', - denom: 'uatom', expectedAddressPrefix: 'cosmos', }, osmosis: { - chainId: 'osmosislocal', - denom: 'uosmo', expectedAddressPrefix: 'osmo', }, agoric: { - chainId: 'agoriclocal', - denom: 'ubld', expectedAddressPrefix: 'agoric', }, -}; - -export const chainNames = Object.keys(chainConfig); +} as const; const makeKeyring = async ( e2eTools: Pick, diff --git a/multichain-testing/test/tools/ibc-transfer.ts b/multichain-testing/test/tools/ibc-transfer.ts new file mode 100644 index 00000000000..bd11ef43914 --- /dev/null +++ b/multichain-testing/test/tools/ibc-transfer.ts @@ -0,0 +1,50 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { DEFAULT_TIMEOUT_NS, getTimeout } from '../../tools/ibc-transfer.js'; +import { + NANOSECONDS_PER_MILLISECOND, + SECONDS_PER_MINUTE, + MILLISECONDS_PER_SECOND, +} from '@agoric/orchestration/src/utils/time.js'; + +const test = anyTest as TestFn>; + +const minutesInFuture = (now: bigint, minutes = 5n) => + now + minutes * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND; + +test.skip('getTimeout returns nanoseconds 5 minutes in the future', async t => { + const now = Date.now(); + const fiveMinutesInFuture = minutesInFuture(BigInt(now)); + + const timeout = getTimeout(now); + const timeoutInMS = timeout / NANOSECONDS_PER_MILLISECOND; + t.is(fiveMinutesInFuture, timeoutInMS); +}); + +test.skip('getTimeout accepts minutes in future for 2nd arg', async t => { + const now = Date.now(); + const twoMinutesInFuture = minutesInFuture(BigInt(now), 2n); + + const timeout = getTimeout(now, 2n); + const timeoutInMS = timeout / NANOSECONDS_PER_MILLISECOND; + t.is(twoMinutesInFuture, timeoutInMS); +}); + +test('hardcoded placeholder is in the future', async t => { + // Mon Dec 31 2029 19:00:00 GMT-0500 + const futureMs = 1893456000000; + + t.true( + new Date(futureMs).getTime() > Date.now(), + 'futureMs is in the future', + ); + + const scaledDown = DEFAULT_TIMEOUT_NS / NANOSECONDS_PER_MILLISECOND; + t.is(BigInt(futureMs), scaledDown, 'DEFAULT_TIMEOUT_NS is properly scaled'); + + t.is( + getTimeout(), + DEFAULT_TIMEOUT_NS, + 'getTimeout returns DEFAULT_TIMEOUT_NS', + ); +}); diff --git a/multichain-testing/tools/e2e-tools.js b/multichain-testing/tools/e2e-tools.js index 67ddec73f29..d7d7d2c6620 100644 --- a/multichain-testing/tools/e2e-tools.js +++ b/multichain-testing/tools/e2e-tools.js @@ -579,7 +579,6 @@ export const makeDoOffer = wallet => { // const result = await seat.getOfferResult(); await seatLike(updates).getPayoutAmounts(); // return result; - return true; }; return doOffer; diff --git a/multichain-testing/tools/ibc-transfer.ts b/multichain-testing/tools/ibc-transfer.ts new file mode 100644 index 00000000000..b4384f13313 --- /dev/null +++ b/multichain-testing/tools/ibc-transfer.ts @@ -0,0 +1,133 @@ +import { ExecutionContext } from 'ava'; +import type { StdFee } from '@cosmjs/amino'; +import { coins } from '@cosmjs/proto-signing'; +import { SigningStargateClient } from '@cosmjs/stargate'; +import { useChain } from 'starshipjs'; +import type { + CosmosChainInfo, + DenomAmount, + IBCMsgTransferOptions, +} from '@agoric/orchestration'; +import { + MILLISECONDS_PER_SECOND, + NANOSECONDS_PER_MILLISECOND, + SECONDS_PER_MINUTE, +} from '@agoric/orchestration/src/utils/time.js'; +import { MsgTransfer } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/tx.js'; +import { createWallet } from './wallet.js'; +import chainInfo from '../starship-chain-info.js'; + +interface MakeFeeObjectArgs { + denom?: string; + gas: number; + gasPrice: number; +} + +export const makeFeeObject = ({ + denom, + gas, + gasPrice, +}: MakeFeeObjectArgs): StdFee => ({ + amount: coins(gas * gasPrice, denom || 'uist'), + gas: String(gas), +}); + +type SimpleChainAddress = { + address: string; + chainName: string; +}; + +export const DEFAULT_TIMEOUT_NS = 1893456000000000000n; + +/** + * @param {number} [ms] current time in ms (e.g. Date.now()) + * @param {bigint} [minutes=5n] number of minutes in the future + * @returns {bigint} nanosecond timestamp 5 mins in the future */ +export const getTimeout = (ms: number = 0, minutes = 5n) => { + // UNTIL #9200. timestamps are getting clobbered somewhere along the way + // and we are observing failed transfers with timeouts years in the past. + // see https://github.com/Agoric/agoric-sdk/actions/runs/9967903776/job/27542288963#step:12:336 + return DEFAULT_TIMEOUT_NS; + const timeoutMS = + BigInt(ms) + MILLISECONDS_PER_SECOND * SECONDS_PER_MINUTE * minutes; + const timeoutNS = timeoutMS * NANOSECONDS_PER_MILLISECOND; + return timeoutNS; +}; + +export const makeIBCTransferMsg = ( + amount: DenomAmount, + destination: SimpleChainAddress, + sender: SimpleChainAddress, + currentTime: number, + opts: IBCMsgTransferOptions = {}, +) => { + const { timeoutHeight, timeoutTimestamp, memo = '' } = opts; + + const destChainInfo = (chainInfo as Record)[ + destination.chainName + ]; + if (!destChainInfo) throw Error(`No chain info for ${destination.chainName}`); + const senderChainInfo = useChain(sender.chainName).chainInfo; + const connection = + destChainInfo.connections?.[senderChainInfo.chain.chain_id]; + if (!connection) + throw Error( + `No connection found between ${sender.chainName} and ${destination.chainName}`, + ); + const { counterPartyPortId, counterPartyChannelId } = + connection.transferChannel; + + const msgTransfer = MsgTransfer.fromPartial({ + sender: sender.address, + receiver: destination.address, + token: { denom: amount.denom, amount: String(amount.value) }, + sourcePort: counterPartyPortId, + sourceChannel: counterPartyChannelId, + timeoutHeight, + timeoutTimestamp: timeoutHeight + ? undefined + : timeoutTimestamp ?? getTimeout(currentTime), + memo, + }); + const { fee_tokens } = senderChainInfo.chain.fees ?? {}; + if (!fee_tokens || !fee_tokens.length) { + throw Error('no fee tokens in chain config for' + sender.chainName); + } + const { high_gas_price, denom } = fee_tokens[0]; + if (!high_gas_price) throw Error('no high gas price in chain config'); + const fee = makeFeeObject({ + denom: denom, + gas: 150000, + gasPrice: high_gas_price, + }); + + return [ + msgTransfer.sender, + msgTransfer.receiver, + msgTransfer.token, + msgTransfer.sourcePort, + msgTransfer.sourceChannel, + msgTransfer.timeoutHeight, + Number(msgTransfer.timeoutTimestamp), + fee, + msgTransfer.memo, + ]; +}; + +export const createFundedWalletAndClient = async ( + t: ExecutionContext, + chainName: string, +) => { + const { chain, creditFromFaucet, getRpcEndpoint } = useChain(chainName); + const wallet = await createWallet(chain.bech32_prefix); + const address = (await wallet.getAccounts())[0].address; + t.log(`Requesting faucet funds for ${address}`); + await creditFromFaucet(address); + // TODO use telescope generated rpc client from @agoric/cosmic-proto + // https://github.com/Agoric/agoric-sdk/issues/9200 + const client = await SigningStargateClient.connectWithSigner( + getRpcEndpoint(), + wallet, + ); + return { client, wallet, address }; +}; diff --git a/multichain-testing/tools/query.ts b/multichain-testing/tools/query.ts index 722960110ff..5f1d6d18f2e 100644 --- a/multichain-testing/tools/query.ts +++ b/multichain-testing/tools/query.ts @@ -6,8 +6,10 @@ import type { QueryDelegationTotalRewardsResponseSDKType } from '@agoric/cosmic- import type { QueryValidatorsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; import type { QueryDelegatorDelegationsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; import type { QueryDelegatorUnbondingDelegationsResponseSDKType } from '@agoric/cosmic-proto/cosmos/staking/v1beta1/query.js'; +import type { QueryDenomHashResponseSDKType } from '@agoric/cosmic-proto/ibc/applications/transfer/v1/query.js'; // TODO use telescope generated query client from @agoric/cosmic-proto +// https://github.com/Agoric/agoric-sdk/issues/9200 export function makeQueryClient(apiUrl: string) { const query = async (path: string): Promise => { try { @@ -46,5 +48,9 @@ export function makeQueryClient(apiUrl: string) { query( `/cosmos/distribution/v1beta1/delegators/${delegatorAdddr}/rewards`, ), + queryDenom: (path: string, baseDenom: string) => + query( + `/ibc/apps/transfer/v1/denom_hashes/${path}/${baseDenom}`, + ), }; } diff --git a/multichain-testing/tools/sleep.ts b/multichain-testing/tools/sleep.ts index 67cb589d36b..6cb375893d6 100644 --- a/multichain-testing/tools/sleep.ts +++ b/multichain-testing/tools/sleep.ts @@ -14,7 +14,7 @@ const retryUntilCondition = async ( retryIntervalMs: number, log: Log, ): Promise => { - console.log({ maxRetries, retryIntervalMs }); + console.log({ maxRetries, retryIntervalMs, message }); let retries = 0; while (retries < maxRetries) { @@ -32,7 +32,9 @@ const retryUntilCondition = async ( } retries++; - log(`Retry ${retries}/${maxRetries} - Waiting for ${retryIntervalMs}ms...`); + console.log( + `Retry ${retries}/${maxRetries} - Waiting for ${retryIntervalMs}ms for ${message}...`, + ); await sleep(retryIntervalMs, log); } diff --git a/multichain-testing/yarn.lock b/multichain-testing/yarn.lock index e89595030d6..7c87c33808a 100644 --- a/multichain-testing/yarn.lock +++ b/multichain-testing/yarn.lock @@ -1264,14 +1264,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.6.0": - version: 1.7.2 - resolution: "axios@npm:1.7.2" +"axios@npm:1.6.7": + version: 1.6.7 + resolution: "axios@npm:1.6.7" dependencies: - follow-redirects: "npm:^1.15.6" + follow-redirects: "npm:^1.15.4" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/cbd47ce380fe045313364e740bb03b936420b8b5558c7ea36a4563db1258c658f05e40feb5ddd41f6633fdd96d37ac2a76f884dad599c5b0224b4c451b3fa7ae + checksum: 10c0/131bf8e62eee48ca4bd84e6101f211961bf6a21a33b95e5dfb3983d5a2fe50d9fffde0b57668d7ce6f65063d3dc10f2212cbcb554f75cfca99da1c73b210358d languageName: node linkType: hard @@ -2220,7 +2220,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.6": +"follow-redirects@npm:^1.15.4": version: 1.15.6 resolution: "follow-redirects@npm:1.15.6" peerDependenciesMeta: diff --git a/packages/async-flow/src/types.d.ts b/packages/async-flow/src/types.d.ts index 77ec495194e..85907144acd 100644 --- a/packages/async-flow/src/types.d.ts +++ b/packages/async-flow/src/types.d.ts @@ -54,8 +54,14 @@ type HostInterface = { /** * Convert an entire Host interface into what the Guest will receive. */ -type GuestInterface = { - [K in keyof T]: GuestOf; +export type GuestInterface = { + [K in keyof T]: T[K] extends (...args: any[]) => Vow + ? (...args: Parameters) => Promise + : T[K] extends HostAsyncFuncWrapper + ? GuestOf + : T[K] extends object + ? GuestInterface + : T[K]; }; /** diff --git a/packages/boot/test/bootstrapTests/orchestration.test.ts b/packages/boot/test/bootstrapTests/orchestration.test.ts index 0c65770cc23..c73b3073b87 100644 --- a/packages/boot/test/bootstrapTests/orchestration.test.ts +++ b/packages/boot/test/bootstrapTests/orchestration.test.ts @@ -13,6 +13,12 @@ import { const test: TestFn = anyTest; +const validatorAddress: CosmosValidatorAddress = { + value: 'cosmosvaloper1test', + chainId: 'gaiatest', + encoding: 'bech32', +}; + test.before(async t => { t.context = await makeWalletFactoryContext( t, @@ -163,11 +169,6 @@ test.serial('stakeAtom - smart wallet', async t => { const { ATOM } = agoricNamesRemotes.brand; ATOM || Fail`ATOM missing from agoricNames`; - const validatorAddress: CosmosValidatorAddress = { - value: 'cosmosvaloper1test', - chainId: 'gaiatest', - encoding: 'bech32', - }; await t.notThrowsAsync( wd.executeOffer({ @@ -310,3 +311,138 @@ test('basic-flows', async t => { }); t.is(readLatest('published.basicFlows.agoric1mockVlocalchainAddress'), ''); }); + +test.serial('auto-stake-it - proposal', async t => { + const { buildProposal, evalProposal } = t.context; + + await t.notThrowsAsync( + evalProposal( + buildProposal('@agoric/builders/scripts/testing/start-auto-stake-it.js'), + ), + ); +}); + +test.serial('basic-flows - portfolio holder', async t => { + const { buildProposal, evalProposal, readLatest, agoricNamesRemotes } = + t.context; + + await evalProposal( + buildProposal('@agoric/builders/scripts/orchestration/init-basic-flows.js'), + ); + + const wd = + await t.context.walletFactoryDriver.provideSmartWallet('agoric1test2'); + + // create a cosmos orchestration account + await wd.executeOffer({ + id: 'request-portfolio-acct', + invitationSpec: { + source: 'agoricContract', + instancePath: ['basicFlows'], + callPipe: [['makePortfolioAccountInvitation']], + }, + offerArgs: { + chainNames: ['agoric', 'cosmoshub', 'osmosis'], + }, + proposal: {}, + }); + t.like(wd.getCurrentWalletRecord(), { + offerToPublicSubscriberPaths: [ + [ + 'request-portfolio-acct', + { + agoric: 'published.basicFlows.agoric1mockVlocalchainAddress', + cosmoshub: 'published.basicFlows.cosmos1test', + // XXX support multiple chain addresses in ibc mocks + osmosis: 'published.basicFlows.cosmos1test', + }, + ], + ], + }); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'request-portfolio-acct', numWantsSatisfied: 1 }, + }); + // XXX this overrides a previous account, since mocks only provide one address + t.is(readLatest('published.basicFlows.cosmos1test'), ''); + // XXX this overrides a previous account, since mocks only provide one address + t.is(readLatest('published.basicFlows.agoric1mockVlocalchainAddress'), ''); + + const { ATOM, BLD } = agoricNamesRemotes.brand; + ATOM || Fail`ATOM missing from agoricNames`; + BLD || Fail`BLD missing from agoricNames`; + + await t.notThrowsAsync( + wd.executeOffer({ + id: 'delegate-cosmoshub', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'cosmoshub', + 'Delegate', + [validatorAddress, { brand: ATOM, value: 10n }], + ], + }, + proposal: {}, + }), + ); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'delegate-cosmoshub', numWantsSatisfied: 1 }, + }); + + await t.notThrowsAsync( + wd.executeOffer({ + id: 'delegate-agoric', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'agoric', + 'Delegate', + // XXX use ChainAddress for LocalOrchAccount + ['agoric1validator1', { brand: BLD, value: 10n }], + ], + }, + proposal: {}, + }), + ); + t.like(wd.getLatestUpdateRecord(), { + status: { id: 'delegate-agoric', numWantsSatisfied: 1 }, + }); + + await t.throwsAsync( + wd.executeOffer({ + id: 'delegate-2-cosmoshub', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'cosmoshub', + 'Delegate', + [validatorAddress, { brand: ATOM, value: 504n }], + ], + }, + proposal: {}, + }), + ); + + await t.throwsAsync( + wd.executeOffer({ + id: 'delegate-2-agoric', + invitationSpec: { + source: 'continuing', + previousOffer: 'request-portfolio-acct', + invitationMakerName: 'MakeInvitation', + invitationArgs: [ + 'agoric', + 'Delegate', + ['agoric1validator1', { brand: BLD, value: 504n }], + ], + }, + proposal: {}, + }), + ); +}); 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 00000000000..38e810dfcb9 --- /dev/null +++ b/packages/builders/scripts/testing/start-auto-stake-it.js @@ -0,0 +1,126 @@ +/** + * @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 { deeplyFulfilled } from '@endo/marshal'; + +/** + * @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: await deeplyFulfilled( + harden({ + agoricNames, + orchestrationService: cosmosInterchainService, + localchain, + storageNode, + marshaller, + timerService: 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/cosmos-api.ts b/packages/orchestration/src/cosmos-api.ts index f70977fd13c..6880bb5939f 100644 --- a/packages/orchestration/src/cosmos-api.ts +++ b/packages/orchestration/src/cosmos-api.ts @@ -14,6 +14,10 @@ import type { State as IBCConnectionState } from '@agoric/cosmic-proto/ibc/core/ import type { Brand, Purse, Payment, Amount } from '@agoric/ertp/src/types.js'; import type { Port } from '@agoric/network'; import type { IBCChannelID, IBCConnectionID } from '@agoric/vats'; +import type { + TargetApp, + TargetRegistration, +} from '@agoric/vats/src/bridge-target.js'; import type { LocalIbcAddress, RemoteIbcAddress, @@ -208,6 +212,17 @@ export type LocalAccountMethods = { deposit: (payment: Payment<'nat'>) => Promise; /** withdraw a Payment from the account */ withdraw: (amount: Amount<'nat'>) => Promise>; + /** + * Register a handler that receives an event each time ICS-20 transfers are + * sent or received by the underlying account. Each account may be associated + * with at most one handler at a given time. + * Does not grant the handler the ability to intercept a transfer. For a + * blocking handler, aka 'IBC Hooks', leverage `registerActiveTap` from + * `transferMiddleware` directly. + * + * @param tap + */ + monitorTransfers: (tap: TargetApp) => Promise; }; export type IBCMsgTransferOptions = { 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 00000000000..7092c704343 --- /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'; + * @import {TypedPattern} from '@agoric/internal'; + */ + +/** + * @typedef {{ + * stakingAccount: ERef & StakingAccountActions>; + * localAccount: ERef>; + * validator: CosmosValidatorAddress; + * localChainAddress: ChainAddress; + * remoteChainAddress: ChainAddress; + * sourceChannel: IBCChannelID; + * remoteDenom: Denom; + * localDenom: Denom; + * }} StakingTapState + */ + +/** @type {TypedPattern} */ +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 prepareStakingTapKit = (zone, { watch }) => { + return zone.exoClassKit( + 'StakingTapKit', + { + 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 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, + }), + ); + }, + }, + }, + ); +}; + +/** + * 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> + * ) => ReturnType>['tap']} + */ +export const prepareStakingTap = (zone, vowTools) => { + const makeKit = prepareStakingTapKit(zone, vowTools); + return (...args) => makeKit(...args).tap; +}; + +/** @typedef {ReturnType} MakeStakingTap */ +/** @typedef {ReturnType} 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 00000000000..1098b24011a --- /dev/null +++ b/packages/orchestration/src/examples/auto-stake-it.contract.js @@ -0,0 +1,188 @@ +import { + EmptyProposalShape, + InvitationShape, +} from '@agoric/zoe/src/typeGuards.js'; +import { Fail } from '@endo/errors'; +import { M } from '@endo/patterns'; +import { withOrchestration } from '../utils/start-helper.js'; +import { prepareChainHubAdmin } from '../exos/chain-hub-admin.js'; +import { prepareStakingTap } from './auto-stake-it-tap-kit.js'; +import { preparePortfolioHolder } from '../exos/portfolio-holder-kit.js'; + +/** + * @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 {Zone} from '@agoric/zone'; + * @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'; + * @import {OrchestrationTools} from '../utils/start-helper.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 publicTopicEntries = 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, + publicTopicEntries, + ); + return portfolioHolder.asContinuingOffer(); +}; + +/** + * AutoStakeIt allows users to to create an auto-forwarding address that + * transfers and stakes tokens on a remote chain when received. + * + * To be wrapped with `withOrchestration`. + * + * @param {ZCF} zcf + * @param {OrchestrationPowers & { + * marshaller: Marshaller; + * }} _privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools + */ +const contract = async ( + zcf, + _privateArgs, + zone, + { chainHub, orchestrate, vowTools }, +) => { + const makeStakingTap = prepareStakingTap( + zone.subZone('stakingTap'), + vowTools, + ); + const makePortfolioHolder = preparePortfolioHolder( + zone.subZone('portfolio'), + vowTools, + ); + + 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 }; +}; + +export const start = withOrchestration(contract); + +/** @typedef {typeof start} AutoStakeItSF */ diff --git a/packages/orchestration/src/examples/basic-flows.contract.js b/packages/orchestration/src/examples/basic-flows.contract.js index d3b8a60d71e..78928843cba 100644 --- a/packages/orchestration/src/examples/basic-flows.contract.js +++ b/packages/orchestration/src/examples/basic-flows.contract.js @@ -4,13 +4,16 @@ */ import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; import { M, mustMatch } from '@endo/patterns'; -import { provideOrchestration } from '../utils/start-helper.js'; +import { withOrchestration } from '../utils/start-helper.js'; +import { preparePortfolioHolder } from '../exos/portfolio-holder-kit.js'; /** - * @import {Baggage} from '@agoric/vat-data'; - * @import {Orchestrator} from '@agoric/orchestration'; - * @import {Vow, VowTools} from '@agoric/vow'; + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationAccount, Orchestrator} from '@agoric/orchestration'; + * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; * @import {OrchestrationPowers} from '../utils/start-helper.js'; + * @import {MakePortfolioHolder} from '../exos/portfolio-holder-kit.js'; + * @import {OrchestrationTools} from '../utils/start-helper.js'; */ /** @@ -30,19 +33,63 @@ const makeOrchAccountHandler = async (orch, _ctx, seat, { chainName }) => { return cosmosAccount.asContinuingOffer(); }; +/** + * Create accounts on multiple chains and return them in a single continuing + * offer with invitations makers for Delegate, WithdrawRewards, Transfer, etc. + * Calls to the underlying invitationMakers are proxied through the + * `MakeInvitation` invitation maker. + * + * @param {Orchestrator} orch + * @param {MakePortfolioHolder} makePortfolioHolder + * @param {ZCFSeat} seat + * @param {{ chainNames: string[] }} offerArgs + */ +const makePortfolioAcctHandler = async ( + orch, + makePortfolioHolder, + seat, + { chainNames }, +) => { + seat.exit(); // no funds exchanged + mustMatch(chainNames, M.arrayOf(M.string())); + const allChains = await Promise.all(chainNames.map(n => orch.getChain(n))); + const allAccounts = await Promise.all(allChains.map(c => c.makeAccount())); + + const accountEntries = harden( + /** @type {[string, OrchestrationAccount][]} */ ( + chainNames.map((chainName, index) => [chainName, allAccounts[index]]) + ), + ); + const publicTopicEntries = 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, + publicTopicEntries, + ); + + return portfolioHolder.asContinuingOffer(); +}; + /** * @param {ZCF} zcf * @param {OrchestrationPowers & { * marshaller: Marshaller; - * }} privateArgs - * @param {Baggage} baggage + * }} _privateArgs + * @param {Zone} zone + * @param {OrchestrationTools} tools */ -export const start = async (zcf, privateArgs, baggage) => { - const { orchestrate, zone } = provideOrchestration( - zcf, - baggage, - privateArgs, - privateArgs.marshaller, +const contract = async (zcf, _privateArgs, zone, { orchestrate, vowTools }) => { + const makePortfolioHolder = preparePortfolioHolder( + zone.subZone('portfolio'), + vowTools, ); const makeOrchAccount = orchestrate( @@ -51,10 +98,17 @@ export const start = async (zcf, privateArgs, baggage) => { makeOrchAccountHandler, ); + const makePortfolioAccount = orchestrate( + 'makePortfolioAccount', + makePortfolioHolder, + makePortfolioAcctHandler, + ); + const publicFacet = zone.exo( 'Basic Flows Public Facet', M.interface('Basic Flows PF', { makeOrchAccountInvitation: M.callWhen().returns(InvitationShape), + makePortfolioAccountInvitation: M.callWhen().returns(InvitationShape), }), { makeOrchAccountInvitation() { @@ -63,10 +117,18 @@ export const start = async (zcf, privateArgs, baggage) => { 'Make an Orchestration Account', ); }, + makePortfolioAccountInvitation() { + return zcf.makeInvitation( + makePortfolioAccount, + 'Make an Orchestration Account', + ); + }, }, ); return { publicFacet }; }; +export const start = withOrchestration(contract); + /** @typedef {typeof start} BasicFlowsSF */ diff --git a/packages/orchestration/src/examples/sendAnywhere.contract.js b/packages/orchestration/src/examples/sendAnywhere.contract.js index ea5269e7129..709e4818c41 100644 --- a/packages/orchestration/src/examples/sendAnywhere.contract.js +++ b/packages/orchestration/src/examples/sendAnywhere.contract.js @@ -1,13 +1,12 @@ import { makeStateRecord } from '@agoric/async-flow'; import { AmountShape } from '@agoric/ertp'; -import { heapVowE } from '@agoric/vow/vat.js'; import { InvitationShape } from '@agoric/zoe/src/typeGuards.js'; import { Fail } from '@endo/errors'; import { E } from '@endo/far'; import { M } from '@endo/patterns'; -import { CosmosChainInfoShape } from '../typeGuards.js'; import { withOrchestration } from '../utils/start-helper.js'; import { orchestrationFns } from './sendAnywhereFlows.js'; +import { prepareChainHubAdmin } from '../exos/chain-hub-admin.js'; /** * @import {TimerService} from '@agoric/time'; @@ -16,7 +15,6 @@ import { orchestrationFns } from './sendAnywhereFlows.js'; * @import {Remote, Vow} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; * @import {VBankAssetDetail} from '@agoric/vats/tools/board-utils.js'; - * @import {CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api'; * @import {CosmosInterchainService} from '../exos/cosmos-interchain-service.js'; * @import {OrchestrationTools} from '../utils/start-helper.js'; */ @@ -60,6 +58,8 @@ const contract = async ( }, ); + const creatorFacet = prepareChainHubAdmin(zone, chainHub); + // TODO should be a provided helper /** @type {(brand: Brand) => Vow} */ const findBrandInVBank = vowTools.retriable( @@ -100,38 +100,6 @@ const contract = async ( }, ); - let nonce = 0n; - const ConnectionInfoShape = M.record(); // TODO - const creatorFacet = zone.exo( - 'Send CF', - M.interface('Send CF', { - addChain: M.callWhen(CosmosChainInfoShape, ConnectionInfoShape).returns( - M.scalar(), - ), - }), - { - /** - * @param {CosmosChainInfo} chainInfo - * @param {IBCConnectionInfo} connectionInfo - */ - async addChain(chainInfo, connectionInfo) { - const chainKey = `${chainInfo.chainId}-${(nonce += 1n)}`; - // when() because chainHub methods return vows. If this were inside - // orchestrate() the membrane would wrap/unwrap automatically. - const agoricChainInfo = await heapVowE.when( - chainHub.getChainInfo('agoric'), - ); - chainHub.registerChain(chainKey, chainInfo); - chainHub.registerConnection( - agoricChainInfo.chainId, - chainInfo.chainId, - connectionInfo, - ); - return chainKey; - }, - }, - ); - return { publicFacet, creatorFacet }; }; diff --git a/packages/orchestration/src/exos/chain-hub-admin.js b/packages/orchestration/src/exos/chain-hub-admin.js new file mode 100644 index 00000000000..527c7a70433 --- /dev/null +++ b/packages/orchestration/src/exos/chain-hub-admin.js @@ -0,0 +1,56 @@ +/* we expect promises to resolved promptly, */ +/* eslint-disable no-restricted-syntax */ +import { M } from '@endo/patterns'; +import { heapVowE } from '@agoric/vow/vat.js'; +import { CosmosChainInfoShape } from '../typeGuards.js'; + +/** + * @import {Zone} from '@agoric/zone'; + * @import {CosmosChainInfo, IBCConnectionInfo} from '@agoric/orchestration'; + * @import {ChainHub} from './chain-hub.js'; + */ + +/** + * For use with async-flow contracts: can be used as a creator facet that allows + * developers to add new chain configurations to a local chainHub, in the event + * the information is not available widely in `agoricNames`. + * + * @param {Zone} zone + * @param {ChainHub} chainHub + */ +export const prepareChainHubAdmin = (zone, chainHub) => { + const ConnectionInfoShape = M.record(); // TODO + const makeCreatorFacet = zone.exo( + 'ChainHub Admin', + M.interface('ChainHub Admin', { + initChain: M.callWhen( + M.string(), + CosmosChainInfoShape, + ConnectionInfoShape, + ).returns(M.undefined()), + }), + { + /** + * @param {string} chainName + * @param {CosmosChainInfo} chainInfo + * @param {IBCConnectionInfo} connectionInfo + */ + async initChain(chainName, chainInfo, connectionInfo) { + // when() because chainHub methods return vows. If this were inside + // orchestrate() the membrane would wrap/unwrap automatically. + const agoricChainInfo = await heapVowE.when( + chainHub.getChainInfo('agoric'), + ); + chainHub.registerChain(chainName, chainInfo); + chainHub.registerConnection( + agoricChainInfo.chainId, + chainInfo.chainId, + connectionInfo, + ); + }, + }, + ); + return makeCreatorFacet; +}; + +/** @typedef {ReturnType} ChainHubAdmin */ diff --git a/packages/orchestration/src/exos/local-orchestration-account.js b/packages/orchestration/src/exos/local-orchestration-account.js index 2a21a905368..c6f729bcbbb 100644 --- a/packages/orchestration/src/exos/local-orchestration-account.js +++ b/packages/orchestration/src/exos/local-orchestration-account.js @@ -20,13 +20,13 @@ import { makeTimestampHelper } from '../utils/time.js'; /** * @import {HostOf} from '@agoric/async-flow'; * @import {LocalChainAccount} from '@agoric/vats/src/localchain.js'; - * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, OrchestrationAccount, ChainInfo, IBCConnectionInfo, OrchestrationAccountI} from '@agoric/orchestration'; + * @import {AmountArg, ChainAddress, DenomAmount, IBCMsgTransferOptions, ChainInfo, IBCConnectionInfo, OrchestrationAccountI} from '@agoric/orchestration'; * @import {RecorderKit, MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js'. * @import {Zone} from '@agoric/zone'; * @import {Remote} from '@agoric/internal'; * @import {InvitationMakers} from '@agoric/smart-wallet/src/types.js'; - * @import {TimerService, TimerBrand, TimestampRecord} from '@agoric/time'; - * @import {PromiseVow, Vow, VowTools} from '@agoric/vow'; + * @import {TimerService, TimestampRecord} from '@agoric/time'; + * @import {Vow, VowTools} from '@agoric/vow'; * @import {TypedJson, JsonSafe} from '@agoric/cosmic-proto'; * @import {Matcher} from '@endo/patterns'; * @import {ChainHub} from './chain-hub.js'; @@ -57,6 +57,9 @@ const HolderI = M.interface('holder', { deposit: M.call(PaymentShape).returns(VowShape), withdraw: M.call(AmountShape).returns(Vow$(PaymentShape)), executeTx: M.call(M.arrayOf(M.record())).returns(Vow$(M.record())), + monitorTransfers: M.call(M.remotable('TransferTap')).returns( + Vow$(M.remotable('TargetRegistration')), + ), }); /** @type {{ [name: string]: [description: string, valueShape: Matcher] }} */ @@ -475,6 +478,10 @@ export const prepareLocalOrchestrationAccountKit = ( throw Fail`not yet implemented`; }); }, + /** @type {HostOf} */ + monitorTransfers(tap) { + return watch(E(this.state.account).monitorTransfers(tap)); + }, }, }, ); diff --git a/packages/orchestration/src/exos/portfolio-holder-kit.js b/packages/orchestration/src/exos/portfolio-holder-kit.js new file mode 100644 index 00000000000..3177cdece3b --- /dev/null +++ b/packages/orchestration/src/exos/portfolio-holder-kit.js @@ -0,0 +1,174 @@ +import { M, mustMatch } from '@endo/patterns'; +import { E } from '@endo/far'; +import { Fail } from '@endo/errors'; +import { PublicTopicShape } from '@agoric/zoe/src/contractSupport/topics.js'; +import { makeScalarBigMapStore } from '@agoric/vat-data'; +import { VowShape } from '@agoric/vow'; + +const { fromEntries } = Object; + +/** + * @import {HostOf} from '@agoric/async-flow'; + * @import {MapStore} from '@agoric/store'; + * @import {VowTools} from '@agoric/vow'; + * @import {ResolvedPublicTopic} from '@agoric/zoe/src/contractSupport/topics.js'; + * @import {Zone} from '@agoric/zone'; + * @import {OrchestrationAccount, OrchestrationAccountI} from '@agoric/orchestration'; + */ + +/** + * @typedef {{ + * accounts: MapStore>; + * publicTopics: MapStore>; + * }} PortfolioHolderState + */ + +const ChainNameShape = M.string(); + +const AccountEntriesShape = M.arrayOf([ + M.string(), + M.remotable('OrchestrationAccount'), +]); +const PublicTopicEntriesShape = M.arrayOf([M.string(), PublicTopicShape]); + +/** + * Kit that holds several OrchestrationAccountKits and returns a invitation + * makers. + * + * @param {Zone} zone + * @param {VowTools} vowTools + */ +const preparePortfolioHolderKit = (zone, { asVow, when }) => { + return zone.exoClassKit( + 'PortfolioHolderKit', + { + invitationMakers: M.interface('InvitationMakers', { + MakeInvitation: M.call( + ChainNameShape, + M.string(), + M.arrayOf(M.any()), + ).returns(M.promise()), + }), + holder: M.interface('Holder', { + asContinuingOffer: M.call().returns(VowShape), + getPublicTopics: M.call().returns(VowShape), + getAccount: M.call(ChainNameShape).returns(VowShape), + addAccount: M.call( + ChainNameShape, + M.remotable(), + PublicTopicShape, + ).returns(VowShape), + }), + }, + /** + * @param {Iterable<[string, OrchestrationAccount]>} accountEntries + * @param {Iterable<[string, ResolvedPublicTopic]>} publicTopicEntries + */ + (accountEntries, publicTopicEntries) => { + mustMatch(accountEntries, AccountEntriesShape, 'must provide accounts'); + mustMatch( + publicTopicEntries, + PublicTopicEntriesShape, + 'must provide public topics', + ); + const accounts = harden( + makeScalarBigMapStore('accounts', { durable: true }), + ); + const publicTopics = harden( + makeScalarBigMapStore('publicTopics', { durable: true }), + ); + accounts.addAll(accountEntries); + publicTopics.addAll(publicTopicEntries); + return /** @type {PortfolioHolderState} */ ( + harden({ + accounts, + publicTopics, + }) + ); + }, + { + invitationMakers: { + /** + * @template {unknown[]} IA + * @param {string} chainName key where the account is stored + * @param {string} action invitation maker name, e.g. 'Delegate' + * @param {IA} invitationArgs + * @returns {Promise>} + */ + MakeInvitation(chainName, action, invitationArgs) { + const { accounts } = this.state; + accounts.has(chainName) || Fail`no account found for ${chainName}`; + const account = accounts.get(chainName); + return when(E(account).asContinuingOffer(), ({ invitationMakers }) => + E(invitationMakers)[action](...invitationArgs), + ); + }, + }, + holder: { + // FIXME /** @type {HostOf} */ + asContinuingOffer() { + return asVow(() => { + const { invitationMakers } = this.facets; + const { publicTopics } = this.state; + return harden({ + publicSubscribers: fromEntries(publicTopics.entries()), + invitationMakers, + }); + }); + }, + // FIXME /** @type {HostOf} */ + getPublicTopics() { + return asVow(() => { + const { publicTopics } = this.state; + return harden(fromEntries(publicTopics.entries())); + }); + }, + /** + * @param {string} chainName key where the account is stored + * @param {OrchestrationAccount} account + * @param {ResolvedPublicTopic} publicTopic + */ + addAccount(chainName, account, publicTopic) { + return asVow(() => { + if (this.state.accounts.has(chainName)) { + throw Fail`account already exists for ${chainName}`; + } + zone.isStorable(account) || + Fail`account for ${chainName} must be storable`; + zone.isStorable(publicTopic) || + Fail`publicTopic for ${chainName} must be storable`; + + this.state.publicTopics.init(chainName, publicTopic); + this.state.accounts.init(chainName, account); + }); + }, + /** + * @param {string} chainName key where the account is stored + */ + getAccount(chainName) { + return asVow(() => this.state.accounts.get(chainName)); + }, + }, + }, + ); +}; + +/** + * A portfolio holder stores two or more OrchestrationAccounts and combines + * ContinuingOfferResult's from each into a single result. + * + * XXX consider an interface for extending the exo maker with additional + * invitation makers. + * + * @param {Zone} zone + * @param {VowTools} vowTools + * @returns {( + * ...args: Parameters> + * ) => ReturnType>['holder']} + */ +export const preparePortfolioHolder = (zone, vowTools) => { + const makeKit = preparePortfolioHolderKit(zone, vowTools); + return (...args) => makeKit(...args).holder; +}; +/** @typedef {ReturnType} MakePortfolioHolder */ +/** @typedef {ReturnType} PortfolioHolder */ diff --git a/packages/orchestration/src/utils/time.js b/packages/orchestration/src/utils/time.js index 2ddfcbc9cb8..a45848ad1ec 100644 --- a/packages/orchestration/src/utils/time.js +++ b/packages/orchestration/src/utils/time.js @@ -8,6 +8,7 @@ import { TimeMath } from '@agoric/time'; export const SECONDS_PER_MINUTE = 60n; export const MILLISECONDS_PER_SECOND = 1000n; +export const NANOSECONDS_PER_MILLISECOND = 1_000_000n; export const NANOSECONDS_PER_SECOND = 1_000_000_000n; /** 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 00000000000..261fb9605fb --- /dev/null +++ b/packages/orchestration/test/examples/auto-stake-it.contract.test.ts @@ -0,0 +1,149 @@ +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 { heapVowE } from '@agoric/vow/vat.js'; +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 { 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', + }); + const result = await heapVowE(userSeat).getOfferResult(); + + 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', + }); + const { publicSubscribers: pubSubs2 } = + await heapVowE(userSeat2).getOfferResult(); + + t.regex(pubSubs2.agoric.storagePath.split('.').pop()!, /^agoric/); + t.regex(pubSubs2.cosmoshub.storagePath.split('.').pop()!, /^cosmos/); +}); diff --git a/packages/orchestration/test/examples/sendAnywhere.test.ts b/packages/orchestration/test/examples/sendAnywhere.test.ts index e7e9221805c..1bcf4fd2771 100644 --- a/packages/orchestration/test/examples/sendAnywhere.test.ts +++ b/packages/orchestration/test/examples/sendAnywhere.test.ts @@ -102,7 +102,9 @@ test('send using arbitrary chain info', async t => { ...txChannelDefaults, }, } as IBCConnectionInfo; - const chainName = await E(sendKit.creatorFacet).addChain( + const chainName = 'hot'; + await E(sendKit.creatorFacet).initChain( + chainName, hotChainInfo, agoricToHotConnection, ); diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md index 6cca6b7d1a2..95beb6676c2 100644 --- a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md +++ b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.md @@ -25,8 +25,8 @@ Generated by [AVA](https://avajs.dev). unwrapMap: 'Alleged: weakMapStore', }, contract: { - 'Send CF_kindHandle': 'Alleged: kind', - 'Send CF_singleton': 'Alleged: Send CF', + 'ChainHub Admin_kindHandle': 'Alleged: kind', + 'ChainHub Admin_singleton': 'Alleged: ChainHub Admin', 'Send PF_kindHandle': 'Alleged: kind', 'Send PF_singleton': 'Alleged: Send PF', }, diff --git a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap index 3f4defa91ec..785dda987fc 100644 Binary files a/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap and b/packages/orchestration/test/examples/snapshots/sendAnywhere.test.ts.snap differ diff --git a/packages/orchestration/test/examples/stake-ica.contract.test.ts b/packages/orchestration/test/examples/stake-ica.contract.test.ts index 195ffe42e57..b7cb9f09bbd 100644 --- a/packages/orchestration/test/examples/stake-ica.contract.test.ts +++ b/packages/orchestration/test/examples/stake-ica.contract.test.ts @@ -9,7 +9,6 @@ import { QueryBalanceRequest, QueryBalanceResponse, } from '@agoric/cosmic-proto/cosmos/bank/v1beta1/query.js'; -import { TimeMath } from '@agoric/time'; import { commonSetup } from '../supports.js'; import { type StakeIcaTerms } from '../../src/examples/stakeIca.contract.js'; import fetchedChainInfo from '../../src/fetched-chain-info.js'; @@ -78,7 +77,7 @@ const startContract = async ({ }; test('makeAccount, getAddress, getBalances, getBalance', async t => { - const { bootstrap } = await commonSetup(t); + const { bootstrap, mocks } = await commonSetup(t); { // stakeAtom const { publicFacet } = await startContract(bootstrap); @@ -105,7 +104,7 @@ test('makeAccount, getAddress, getBalances, getBalance', async t => { } { // stakeOsmo - const { ibcBridge } = bootstrap; + const { ibcBridge } = mocks; await E(ibcBridge).setAddressPrefix('osmo'); const { publicFacet } = await startContract({ ...bootstrap, diff --git a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts index f604aaeac2b..aee995de057 100644 --- a/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts +++ b/packages/orchestration/test/exos/local-orchestration-account-kit.test.ts @@ -3,56 +3,24 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { AmountMath } from '@agoric/ertp'; import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; import { heapVowE as E } from '@agoric/vow/vat.js'; -import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; -import { Far } from '@endo/far'; -import { TimeMath } from '@agoric/time'; -import { prepareLocalOrchestrationAccountKit } from '../../src/exos/local-orchestration-account.js'; +import { TargetApp } from '@agoric/vats/src/bridge-target.js'; import { ChainAddress } from '../../src/orchestration-api.js'; -import { makeChainHub } from '../../src/exos/chain-hub.js'; import { NANOSECONDS_PER_SECOND } from '../../src/utils/time.js'; import { commonSetup } from '../supports.js'; import { UNBOND_PERIOD_SECONDS } from '../ibc-mocks.js'; import { maxClockSkew } from '../../src/utils/cosmos.js'; +import { prepareMakeTestLOAKit } from './make-test-loa-kit.js'; +import { buildVTransferEvent } from '../../tools/ibc-mocks.js'; test('deposit, withdraw', async t => { - const { bootstrap, brands, utils } = await commonSetup(t); + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); - const { bld: stake } = brands; - - const { timer, localchain, marshaller, rootZone, storage, vowTools } = - bootstrap; - - t.log('chainInfo mocked via `prepareMockChainInfo` until #8879'); - - t.log('exo setup - prepareLocalChainAccountKit'); - const { makeRecorderKit } = prepareRecorderKitMakers( - rootZone.mapStore('recorder'), - marshaller, - ); - const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( - rootZone, - makeRecorderKit, - // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer - Far('MockZCF', {}), - timer, - vowTools, - makeChainHub(bootstrap.agoricNames, vowTools), - ); - - t.log('request account from vat-localchain'); - const lca = await E(localchain).makeAccount(); - const address = await E(lca).getAddress(); - - t.log('make a LocalChainAccountKit'); - const { holder: account } = makeLocalOrchestrationAccountKit({ - account: lca, - address: harden({ - value: address, - chainId: 'agoric-n', - encoding: 'bech32', - }), - storageNode: storage.rootNode.makeChildNode('lcaKit'), - }); + const { + brands: { bld: stake }, + utils, + } = common; const oneHundredStakePmt = await utils.pourPayment(stake.units(100)); @@ -88,42 +56,15 @@ test('deposit, withdraw', async t => { }); test('delegate, undelegate', async t => { - const { bootstrap, brands, utils } = await commonSetup(t); + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); - const { bld } = brands; - - const { timer, localchain, marshaller, rootZone, storage, vowTools } = - bootstrap; - - t.log('exo setup - prepareLocalChainAccountKit'); - const { makeRecorderKit } = prepareRecorderKitMakers( - rootZone.mapStore('recorder'), - marshaller, - ); - const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( - rootZone, - makeRecorderKit, - // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer - Far('MockZCF', {}), - timer, - vowTools, - makeChainHub(bootstrap.agoricNames, vowTools), - ); - - t.log('request account from vat-localchain'); - const lca = await E(localchain).makeAccount(); - const address = await E(lca).getAddress(); - - t.log('make a LocalChainAccountKit'); - const { holder: account } = makeLocalOrchestrationAccountKit({ - account: lca, - address: harden({ - value: address, - chainId: 'agoric-n', - encoding: 'bech32', - }), - storageNode: storage.rootNode.makeChildNode('lcaKit'), - }); + const { + bootstrap: { timer }, + brands: { bld }, + utils, + } = common; await E(account).deposit(await utils.pourPayment(bld.units(100))); @@ -151,42 +92,14 @@ test('delegate, undelegate', async t => { }); test('transfer', async t => { - const { bootstrap, brands, utils } = await commonSetup(t); - - const { bld: stake } = brands; + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); - const { timer, localchain, marshaller, rootZone, storage, vowTools } = - bootstrap; - - t.log('exo setup - prepareLocalChainAccountKit'); - const { makeRecorderKit } = prepareRecorderKitMakers( - rootZone.mapStore('recorder'), - marshaller, - ); - const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( - rootZone, - makeRecorderKit, - // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer - Far('MockZCF', {}), - timer, - vowTools, - makeChainHub(bootstrap.agoricNames, vowTools), - ); - - t.log('request account from vat-localchain'); - const lca = await E(localchain).makeAccount(); - const address = await E(lca).getAddress(); - - t.log('make a LocalChainAccountKit'); - const { holder: account } = makeLocalOrchestrationAccountKit({ - account: lca, - address: harden({ - value: address, - chainId: 'agoric-n', - encoding: 'bech32', - }), - storageNode: storage.rootNode.makeChildNode('lcaKit'), - }); + const { + brands: { bld: stake }, + utils, + } = common; t.truthy(account, 'account is returned'); @@ -260,3 +173,43 @@ test('transfer', async t => { 'accepts custom timeoutHeight', ); }); + +test('monitor transfers', async t => { + const common = await commonSetup(t); + const makeTestLOAKit = prepareMakeTestLOAKit(t, common.bootstrap); + const account = await makeTestLOAKit(); + const { + mocks: { transferBridge }, + bootstrap: { rootZone }, + } = common; + + let upcallCount = 0; + const zone = rootZone.subZone('tap'); + const tap: TargetApp = zone.exo('tap', undefined, { + receiveUpcall: (obj: unknown) => { + upcallCount += 1; + t.log('receiveUpcall', obj); + return Promise.resolve(); + }, + }); + + const { value: target } = await E(account).getAddress(); + const appRegistration = await E(account).monitorTransfers(tap); + + // simulate upcall from golang to VM + const simulateIncomingTransfer = async () => + E(transferBridge).fromBridge( + buildVTransferEvent({ + receiver: target, + }), + ); + + await simulateIncomingTransfer(); + t.is(upcallCount, 1, 'first upcall received'); + await simulateIncomingTransfer(); + t.is(upcallCount, 2, 'second upcall received'); + + await appRegistration.revoke(); + await t.throwsAsync(simulateIncomingTransfer()); + t.is(upcallCount, 2, 'no more events after app is revoked'); +}); diff --git a/packages/orchestration/test/exos/make-test-coa-kit.ts b/packages/orchestration/test/exos/make-test-coa-kit.ts new file mode 100644 index 00000000000..99da8b9880d --- /dev/null +++ b/packages/orchestration/test/exos/make-test-coa-kit.ts @@ -0,0 +1,70 @@ +import { Far } from '@endo/far'; +import { heapVowE as E } from '@agoric/vow/vat.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import type { ExecutionContext } from 'ava'; +import { commonSetup } from '../supports.js'; +import { prepareCosmosOrchestrationAccount } from '../../src/exos/cosmos-orchestration-account.js'; + +/** + * A testing utility that creates a (Cosmos)ChainAccount and makes a + * CosmosOrchestrationAccount with necessary endowments like: recorderKit, + * storageNode, mock ZCF, mock TimerService, and a ChainHub. + * + * Helps reduce boilerplate in test files, and retains testing context through + * parameterized endowments. + * + * @param t + * @param bootstrap + * @param opts + * @param opts.zcf + */ +export const prepareMakeTestCOAKit = ( + t: ExecutionContext, + bootstrap: Awaited>['bootstrap'], + { zcf = Far('MockZCF', {}) } = {}, +) => { + const { cosmosInterchainService, marshaller, rootZone, timer, vowTools } = + bootstrap; + + const { makeRecorderKit } = prepareRecorderKitMakers( + rootZone.mapStore('CosmosOrchAccountRecorder'), + marshaller, + ); + + const makeCosmosOrchestrationAccount = prepareCosmosOrchestrationAccount( + rootZone.subZone('CosmosOrchAccount'), + makeRecorderKit, + vowTools, + // @ts-expect-error mocked zcf + zcf, + ); + + return async ({ + storageNode = bootstrap.storage.rootNode.makeChildNode('accounts'), + chainId = 'cosmoshub-99', + hostConnectionId = 'connection-0' as const, + controllerConnectionId = 'connection-1' as const, + bondDenom = 'uatom', + } = {}) => { + t.log('exo setup - prepareCosmosOrchestrationAccount'); + + t.log('request account from orchestration service'); + const cosmosOrchAccount = await E(cosmosInterchainService).makeAccount( + chainId, + hostConnectionId, + controllerConnectionId, + ); + + const accountAddress = await E(cosmosOrchAccount).getAddress(); + + t.log('make a CosmosOrchestrationAccount'); + const holder = makeCosmosOrchestrationAccount(accountAddress, bondDenom, { + account: cosmosOrchAccount, + storageNode: storageNode.makeChildNode(accountAddress.value), + icqConnection: undefined, + timer, + }); + + return holder; + }; +}; diff --git a/packages/orchestration/test/exos/make-test-loa-kit.ts b/packages/orchestration/test/exos/make-test-loa-kit.ts new file mode 100644 index 00000000000..765e7d48f58 --- /dev/null +++ b/packages/orchestration/test/exos/make-test-loa-kit.ts @@ -0,0 +1,66 @@ +import { heapVowE as E } from '@agoric/vow/vat.js'; +import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; +import { Far } from '@endo/far'; +import { ExecutionContext } from 'ava'; +import { prepareLocalOrchestrationAccountKit } from '../../src/exos/local-orchestration-account.js'; +import { makeChainHub } from '../../src/exos/chain-hub.js'; +import { commonSetup } from '../supports.js'; + +/** + * A testing utility that creates a LocalChainAccount and makes a + * LocalOrchestrationAccount with necessary endowments like: recorderKit, + * storageNode, mock ZCF, mock TimerService, and a ChainHub. + * + * Helps reduce boilerplate in test files, and retains testing context through + * parameterized endowments. + * + * @param t + * @param bootstrap + * @param opts + * @param opts.zcf + */ +export const prepareMakeTestLOAKit = ( + t: ExecutionContext, + bootstrap: Awaited>['bootstrap'], + { zcf = Far('MockZCF', {}) } = {}, +) => { + const { timer, localchain, marshaller, rootZone, vowTools, agoricNames } = + bootstrap; + + const { makeRecorderKit } = prepareRecorderKitMakers( + rootZone.mapStore('recorder'), + marshaller, + ); + + const makeLocalOrchestrationAccountKit = prepareLocalOrchestrationAccountKit( + rootZone, + makeRecorderKit, + // @ts-expect-error mocked zcf. use `stake-bld.contract.test.ts` to test LCA with offer + zcf, + timer, + vowTools, + makeChainHub(agoricNames, vowTools), + ); + + return async ({ + storageNode = bootstrap.storage.rootNode.makeChildNode('accounts'), + } = {}) => { + t.log('exo setup - prepareLocalChainAccountKit'); + + t.log('request account from vat-localchain'); + const lca = await E(localchain).makeAccount(); + const address = await E(lca).getAddress(); + + t.log('make a LocalChainAccountKit'); + const { holder: account } = makeLocalOrchestrationAccountKit({ + account: lca, + address: harden({ + value: address, + chainId: 'agoric-n', + encoding: 'bech32', + }), + storageNode: storageNode.makeChildNode(address), + }); + return account; + }; +}; diff --git a/packages/orchestration/test/exos/portfolio-holder-kit.test.ts b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts new file mode 100644 index 00000000000..216764b64e1 --- /dev/null +++ b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts @@ -0,0 +1,123 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { Far } from '@endo/far'; +import { heapVowE as E } from '@agoric/vow/vat.js'; +import { commonSetup } from '../supports.js'; +import { preparePortfolioHolder } from '../../src/exos/portfolio-holder-kit.js'; +import { prepareMakeTestLOAKit } from './make-test-loa-kit.js'; +import { prepareMakeTestCOAKit } from './make-test-coa-kit.js'; + +test('portfolio holder kit behaviors', async t => { + const { bootstrap } = await commonSetup(t); + const { rootZone, storage, vowTools } = bootstrap; + const storageNode = storage.rootNode.makeChildNode('accounts'); + + /** + * mock zcf that echos back the offer description + */ + const mockZcf = Far('MockZCF', { + /** @type {ZCF['makeInvitation']} */ + makeInvitation: (offerHandler, description, ..._rest) => { + t.is(typeof offerHandler, 'function'); + const p = new Promise(resolve => resolve(description)); + return p; + }, + }); + + const makeTestCOAKit = prepareMakeTestCOAKit(t, bootstrap, { zcf: mockZcf }); + const makeTestLOAKit = prepareMakeTestLOAKit(t, bootstrap, { zcf: mockZcf }); + const makeCosmosAccount = async ({ + chainId, + hostConnectionId, + controllerConnectionId, + }) => { + return makeTestCOAKit({ + storageNode, + chainId, + hostConnectionId, + controllerConnectionId, + }); + }; + + const makeLocalAccount = async () => { + return makeTestLOAKit({ storageNode }); + }; + + const accounts = { + cosmoshub: await makeCosmosAccount({ + chainId: 'cosmoshub-99', + hostConnectionId: 'connection-0' as const, + controllerConnectionId: 'connection-1' as const, + }), + agoric: await makeLocalAccount(), + }; + const accountEntries = harden(Object.entries(accounts)); + + const makePortfolioHolder = preparePortfolioHolder( + rootZone.subZone('portfolio'), + vowTools, + ); + const publicTopicEntries = harden( + await Promise.all( + Object.entries(accounts).map(async ([chainName, holder]) => { + const { account } = await E(holder).getPublicTopics(); + return [chainName, account]; + }), + ), + ); + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + const holder = makePortfolioHolder(accountEntries, publicTopicEntries); + + const cosmosAccount = await E(holder).getAccount('cosmoshub'); + t.is( + cosmosAccount, + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + accounts.cosmoshub, + 'same account holder kit provided is returned', + ); + + const { invitationMakers } = await E(holder).asContinuingOffer(); + + const delegateInv = await E(invitationMakers).MakeInvitation( + 'cosmoshub', + 'Delegate', + [ + { + value: 'cosmos1valoper', + chainId: 'cosmoshub-99', + encoding: 'bech32', + }, + { + denom: 'uatom', + value: 10n, + }, + ], + ); + + t.is( + delegateInv, + // note: mocked zcf (we are not in a contract) returns inv description + // @ts-expect-error Argument of type 'string' is not assignable to parameter of type 'Vow' + 'Delegate', + 'any invitation maker accessible via MakeInvitation', + ); + + const osmosisAccount = await makeCosmosAccount({ + chainId: 'osmosis-99', + hostConnectionId: 'connection-2' as const, + controllerConnectionId: 'connection-3' as const, + }); + + const osmosisTopic = (await E(osmosisAccount).getPublicTopics()).account; + + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + await E(holder).addAccount('osmosis', osmosisAccount, osmosisTopic); + + t.is( + await E(holder).getAccount('osmosis'), + // @ts-expect-error type mismatch between kit and OrchestrationAccountI + osmosisAccount, + 'new accounts can be added', + ); + + t.snapshot(await E(holder).getPublicTopics(), 'public topics'); +}); diff --git a/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md new file mode 100644 index 00000000000..3c2ec14d147 --- /dev/null +++ b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.md @@ -0,0 +1,27 @@ +# Snapshot report for `test/exos/portfolio-holder-kit.test.ts` + +The actual snapshot is saved in `portfolio-holder-kit.test.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## portfolio holder kit behaviors + +> public topics + + { + agoric: { + description: 'Account holder status', + storagePath: 'mockChainStorageRoot.accounts.agoric1fakeLCAAddress', + subscriber: Object @Alleged: Durable Publish Kit subscriber {}, + }, + cosmoshub: { + description: 'Staking Account holder status', + storagePath: 'mockChainStorageRoot.accounts.cosmos1test', + subscriber: Object @Alleged: Durable Publish Kit subscriber {}, + }, + osmosis: { + description: 'Staking Account holder status', + storagePath: 'mockChainStorageRoot.accounts.cosmos1test1', + subscriber: Object @Alleged: Durable Publish Kit subscriber {}, + }, + } diff --git a/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap new file mode 100644 index 00000000000..1b57da06351 Binary files /dev/null and b/packages/orchestration/test/exos/snapshots/portfolio-holder-kit.test.ts.snap differ diff --git a/packages/orchestration/test/network-fakes.ts b/packages/orchestration/test/network-fakes.ts index 3f481a72e08..18cbddc081a 100644 --- a/packages/orchestration/test/network-fakes.ts +++ b/packages/orchestration/test/network-fakes.ts @@ -118,6 +118,10 @@ export const ibcBridgeMocks: { }, }; +type BridgeEvents = Array< + IBCEvent<'channelOpenAck'> | IBCEvent<'acknowledgementPacket'> +>; + /** * Make a fake IBC Bridge, extended from the dibc ScopedBridgeManager. * @@ -131,6 +135,7 @@ export const makeFakeIBCBridge = ( ScopedBridgeManagerMethods<'dibc'> & { setMockAck: (mockAckMap: Record) => void; setAddressPrefix: (addressPrefix: string) => void; + inspectDibcBridge: () => BridgeEvents; } > => { let bridgeHandler: any; @@ -155,27 +160,30 @@ export const makeFakeIBCBridge = ( * @type {Record} */ let mockAckMap = defaultMockAckMap; + let bridgeEvents: BridgeEvents = []; return zone.exo('Fake IBC Bridge Manager', undefined, { getBridgeId: () => BridgeId.DIBC, toBridge: async obj => { if (obj.type === 'IBC_METHOD') { switch (obj.method) { - case 'startChannelOpenInit': - bridgeHandler?.fromBridge( - ibcBridgeMocks.channelOpenAck(obj, { - bech32Prefix, - sequence: channelCount, - }), - ); + case 'startChannelOpenInit': { + const ackEvent = ibcBridgeMocks.channelOpenAck(obj, { + bech32Prefix, + sequence: channelCount, + }); + bridgeHandler?.fromBridge(ackEvent); + bridgeEvents = bridgeEvents.concat(ackEvent); channelCount += 1; return undefined; + } case 'sendPacket': { const ackEvent = ibcBridgeMocks.acknowledgementPacket(obj, { sequence: ibcSequenceNonce, acknowledgement: mockAckMap?.[obj.packet.data] || errorAcknowledgments.error5, }); + bridgeEvents = bridgeEvents.concat(ackEvent); ibcSequenceNonce += 1; bridgeHandler?.fromBridge(ackEvent); return ackEvent.packet; @@ -215,6 +223,12 @@ export const makeFakeIBCBridge = ( setAddressPrefix: (newPrefix: typeof bech32Prefix) => { bech32Prefix = newPrefix; }, + /** + * for debugging and testing + */ + inspectDibcBridge() { + return bridgeEvents; + }, }); }; diff --git a/packages/orchestration/test/supports.ts b/packages/orchestration/test/supports.ts index 5e80d37c34a..b3754320e28 100644 --- a/packages/orchestration/test/supports.ts +++ b/packages/orchestration/test/supports.ts @@ -88,6 +88,7 @@ export const commonSetup = async (t: ExecutionContext) => { interceptorFactory, ); finisher.useRegistry(bridgeTargetKit.targetRegistry); + await E(transferBridge).initHandler(bridgeTargetKit.bridgeHandler); const localBrigeMessages = [] as any[]; const localchainBridge = makeFakeLocalchainBridge(rootZone, obj => @@ -136,12 +137,15 @@ export const commonSetup = async (t: ExecutionContext) => { rootZone: rootZone.subZone('contract'), storage, vowTools, - ibcBridge, }, brands: { bld: bldSansMint, ist: istSansMint, }, + mocks: { + ibcBridge, + transferBridge, + }, commonPrivateArgs: { agoricNames, localchain, @@ -159,6 +163,7 @@ export const commonSetup = async (t: ExecutionContext) => { utils: { pourPayment, inspectLocalBridge: () => harden([...localBrigeMessages]), + inspectDibcBridge: () => E(ibcBridge).inspectDibcBridge(), }, }; }; diff --git a/packages/orchestration/tools/ibc-mocks.ts b/packages/orchestration/tools/ibc-mocks.ts index c7887228377..cc056eccd37 100644 --- a/packages/orchestration/tools/ibc-mocks.ts +++ b/packages/orchestration/tools/ibc-mocks.ts @@ -6,7 +6,11 @@ import { } from '@agoric/cosmic-proto/tendermint/abci/types.js'; import { encodeBase64, btoa } from '@endo/base64'; import { toRequestQueryJson } from '@agoric/cosmic-proto'; +import { IBCChannelID, VTransferIBCEvent } from '@agoric/vats'; +import { VTRANSFER_IBC_EVENT } from '@agoric/internal/src/action-types.js'; +import { FungibleTokenPacketData } from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; import { makeQueryPacket, makeTxPacket } from '../src/utils/packet.js'; +import { ChainAddress } from '../src/orchestration-api.js'; interface EncoderI { encode: (message: T) => { @@ -125,3 +129,74 @@ export function buildQueryPacketString( ): string { return btoa(makeQueryPacket(msgs.map(msg => toRequestQueryJson(msg, opts)))); } + +type BuildVTransferEventParams = { + event?: VTransferIBCEvent['event']; + /* defaults to cosmos1AccAddress. set to `agoric1fakeLCAAddress` to simulate an outgoing transfer event */ + sender?: ChainAddress['value']; + /** defaults to agoric1fakeLCAAddress. set to a different value to simulate an outgoing transfer event */ + receiver?: ChainAddress['value']; + amount?: bigint; + denom?: string; + destinationChannel?: IBCChannelID; + sourceChannel?: IBCChannelID; +}; + +/** + * `buildVTransferEvent` can be used with `transferBridge` to simulate incoming + * and outgoing IBC fungible tokens transfers to a LocalChain account. + * + * It defaults to simulating incoming transfers. To simulate an outgoing one, + * ensure `sender=agoric1fakeLCAAddress` and this after LocalChainBridge + * receives the outgoing MsgTransfer, + * + * @example + * ```js + * const { mocks: { transferBridge } = await commonSetup(t); + * await E(transferBridge).fromBridge( + * buildVTransferEvent({ + * receiver: 'agoric1fakeLCAAddress', + * amount: 10n, + * denom: 'uatom', + * }), + * ); + * ``` + * + * XXX integrate vlocalchain and vtransfer ScopedBridgeManagers + * in test supports. + * + * @param {{BuildVTransferEventParams}} args + */ +export const buildVTransferEvent = ({ + event = 'acknowledgementPacket' as const, + sender = 'cosmos1AccAddress', + receiver = 'agoric1fakeLCAAddress', + amount = 10n, + denom = 'uatom', + destinationChannel = 'channel-0' as IBCChannelID, + sourceChannel = 'channel-405' as IBCChannelID, +}: BuildVTransferEventParams = {}): VTransferIBCEvent => ({ + type: VTRANSFER_IBC_EVENT, + blockHeight: 0, + blockTime: 0, + event, + acknowledgement: btoa(JSON.stringify({ result: 'AQ==' })), + relayer: 'agoric123', + target: receiver, + packet: { + data: btoa( + JSON.stringify( + FungibleTokenPacketData.fromPartial({ + amount: String(amount), + denom, + sender, + receiver, + }), + ), + ), + destination_channel: destinationChannel, + source_channel: sourceChannel, + destination_port: 'transfer', + source_port: 'transfer', + }, +}); diff --git a/packages/vats/src/transfer.js b/packages/vats/src/transfer.js index 244b8e50de6..2e75ebc8540 100644 --- a/packages/vats/src/transfer.js +++ b/packages/vats/src/transfer.js @@ -8,6 +8,7 @@ import { TargetAppI, AppTransformerI } from './bridge-target.js'; /** * @import {TargetApp, TargetHost} from './bridge-target.js' + * @import {VTransferIBCEvent} from './types.js'; */ /** @@ -49,6 +50,7 @@ const prepareTransferInterceptor = (zone, vowTools) => { }), { public: { + /** @param {VTransferIBCEvent} obj */ async receiveUpcall(obj) { const { isActiveTap, tap } = this.state; diff --git a/packages/vats/src/types.d.ts b/packages/vats/src/types.d.ts index 61b344e4a0b..5931569378d 100644 --- a/packages/vats/src/types.d.ts +++ b/packages/vats/src/types.d.ts @@ -1,6 +1,9 @@ +import type { FungibleTokenPacketData } from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; import type { BridgeIdValue, Remote } from '@agoric/internal'; import type { Bytes } from '@agoric/network'; import type { Guarded } from '@endo/exo'; +import type { LocalChainAccount } from './localchain.js'; +import type { TargetApp } from './bridge-target.js'; export type Board = ReturnType< ReturnType @@ -264,3 +267,29 @@ type SendPacketDownCall = { packet: IBCPacket; relativeTimeoutNs: bigint; }; + +/** + * This event is emitted when a FungibleTokenPacket is sent or received + * by a target (e.g. a {@link LocalChainAccount}) that has a registered + * {@link TargetApp}. It is passed through the `receiveUpcall` handler. + */ +export type VTransferIBCEvent = { + type: 'VTRANSFER_IBC_EVENT'; + blockHeight: number; + blockTime: number; + /** + * Indicates the type of IBC packet event: + * - 'acknowledgementPacket': passive tap that communicates the result of an acknowledged packet + * - 'writeAcknowledgement': active tap where the receiver can return a write acknowledgement + */ + event: 'acknowledgementPacket' | 'writeAcknowledgement'; + acknowledgement: Bytes; + /** + * Use `JSON.parse(atob(packet.data))` to get a + * {@link FungibleTokenPacketData} object. + */ + packet: IBCPacket; + relayer: string; + /** e.g. the chain address of the LocalChainAccount */ + target: string; +}; diff --git a/packages/vats/tools/fake-bridge.js b/packages/vats/tools/fake-bridge.js index 692748de30f..d1736f85953 100644 --- a/packages/vats/tools/fake-bridge.js +++ b/packages/vats/tools/fake-bridge.js @@ -166,6 +166,7 @@ export const makeFakeLocalchainBridge = (zone, onToBridge = () => {}) => { /** @type {Remote} */ let hndlr; let lcaExecuteTxSequence = 0; + let accountsCreated = 0; return zone.exo('Fake Localchain Bridge Manager', undefined, { getBridgeId: () => 'vlocalchain', toBridge: async obj => { @@ -173,8 +174,11 @@ export const makeFakeLocalchainBridge = (zone, onToBridge = () => {}) => { const { method, type, ...params } = obj; trace('toBridge', type, method, params); switch (type) { - case 'VLOCALCHAIN_ALLOCATE_ADDRESS': - return LOCALCHAIN_DEFAULT_ADDRESS; + case 'VLOCALCHAIN_ALLOCATE_ADDRESS': { + const address = `${LOCALCHAIN_DEFAULT_ADDRESS}${accountsCreated || ''}`; + accountsCreated += 1; + return address; + } case 'VLOCALCHAIN_EXECUTE_TX': { lcaExecuteTxSequence += 1; return obj.messages.map(message => { diff --git a/packages/zoe/src/contractSupport/topics.js b/packages/zoe/src/contractSupport/topics.js index a0f9ce226d9..b2a93d83bb9 100644 --- a/packages/zoe/src/contractSupport/topics.js +++ b/packages/zoe/src/contractSupport/topics.js @@ -42,6 +42,12 @@ export const TopicsRecordShape = M.recordOf(M.string(), PublicTopicShape); * }} TopicsRecord */ +/** + * @typedef {{ + * [topicName: string]: ResolvedPublicTopic, + * }} ResolvedTopicsRecord + */ + /** * @template T * @param {string} description