diff --git a/multichain-testing/package.json b/multichain-testing/package.json index 94be045eb20c..7088cc34fb28 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 000000000000..3373cf41c426 --- /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 000000000000..9c6d1ff50933 --- /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/test/auto-stake-it.test.ts b/multichain-testing/test/auto-stake-it.test.ts new file mode 100644 index 000000000000..e73b0805c13f --- /dev/null +++ b/multichain-testing/test/auto-stake-it.test.ts @@ -0,0 +1,293 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { ExecutionContext, TestFn } from 'ava'; +import type { StdFee } from '@cosmjs/amino'; +import { coins } from '@cosmjs/proto-signing'; +import { SigningStargateClient } from '@cosmjs/stargate'; +import { useChain } from 'starshipjs'; +import { DenomAmount, IBCMsgTransferOptions } from '@agoric/orchestration'; +import { + 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 { + chainConfig, + ChainName, + commonSetup, + SetupContextWithWallets, +} from './support.js'; +import { createWallet } from '../tools/wallet.js'; +import { makeQueryClient } from '../tools/query.js'; +import { sleep } from '../tools/sleep.js'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import chainInfo from '../starship-chain-info.js'; + +const test = anyTest as TestFn; + +const accounts = ['admin1', 'user1']; + +const contractName = 'autoAutoStakeIt'; +const contractBuilder = + '../packages/builders/scripts/orchestration/init-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); +}); + +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: ChainName; +}; + +const makeIBCTransferMsg = ( + amount: DenomAmount, + destination: SimpleChainAddress, + sender: SimpleChainAddress, + opts: IBCMsgTransferOptions = {}, +) => { + const { timeoutHeight, timeoutTimestamp, memo = '' } = opts; + + const senderChainInfo = useChain(sender.chainName).chainInfo; + const { portId, channelId } = + chainInfo[destination.chainName].connections[senderChainInfo.chain.chain_id] + .transferChannel; + + /** @returns {bigint} nanosecond timestamp 5 mins in the future */ + const getTimeoutTimestampNs = () => + (BigInt(new Date().getTime()) + SECONDS_PER_MINUTE * 5n) * + NANOSECONDS_PER_MILLISECOND; + + const msgTransfer = MsgTransfer.fromPartial({ + sender: sender.address, + receiver: destination.address, + token: { denom: amount.denom, amount: String(amount.value) }, + sourcePort: portId, + sourceChannel: channelId, + timeoutHeight, + timeoutTimestamp: timeoutHeight + ? undefined + : timeoutTimestamp ?? getTimeoutTimestampNs(), + 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, + ]; +}; + +const createFundedWalletAndClient = async ( + t: ExecutionContext, + chainName: ChainName, +) => { + 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); + const client = await SigningStargateClient.connectWithSigner( + getRpcEndpoint(), + wallet, + ); + return { client, wallet, address }; +}; + +test('auto-stake-it: creates accts, register tap, transfer to auto-delegate', async t => { + const { wallets, makeQueryTool, provisionSmartWallet, retryUntilCondition } = + t.context; + + const fundAndTransfer = async ( + chainName: ChainName, + 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, + ); + await sleep(1500); // wait for wallet to instantiate + console.log('Balances:', await client.getAllBalances(address)); + + const transferArgs = makeIBCTransferMsg( + { denom, value: amount }, + { address: agoricAddr, chainName: 'agoric' }, + { address: address, chainName }, + ); + // @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 send 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, + }; + }; + + // 1. Set initial tokens so denom is available + const agAdminAddr = wallets['admin1']; + console.log('Sending tokens to', agAdminAddr, 'from osmosis'); + await fundAndTransfer('osmosis', agAdminAddr); + + // 2. Find 'uosmo' denom on agoric + const osmosisChainInfo = useChain('osmosis').chainInfo; + const { portId, channelId } = + chainInfo['agoric'].connections[osmosisChainInfo.chain.chain_id] + .transferChannel; + const agoricQueryClient = makeQueryClient( + useChain('agoric').getRestEndpoint(), + ); + const { hash } = await agoricQueryClient.queryDenom( + `/${portId}/${channelId}`, + chainConfig['osmosis'].denom, + ); + t.truthy(hash.length, 'ibc denom hash found'); + t.log('ibc denom hash found', hash); + + // 3. Find a osmosis validator to delegate to + const osmosisQueryClient = makeQueryClient( + useChain('osmosis').getRestEndpoint(), + ); + const { validators } = await osmosisQueryClient.queryValidators(); + const validatorAddress = validators[0]?.operator_address; + t.truthy(validatorAddress, 'found a validator to delegate to'); + t.log({ validatorAddress }, 'found a validator to delegate to'); + + // 4. Send an Offer to make the accounts and set up the tap + const user1Addr = wallets['user1']; + const wdUser1 = await provisionSmartWallet(user1Addr, { + BLD: 100n, + IST: 100n, + }); + const doOffer = makeDoOffer(wdUser1); + t.log('osmosis makeAccount offer'); + const offerId = `osmosis-makeAccountsInvitation-${Date.now()}`; + + const _offerResult = await doOffer({ + id: offerId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeAccountsInvitation']], + }, + offerArgs: { + chainName: 'osmosis', + validator: { + value: validatorAddress, + encoding: 'bech32', + chainId: osmosisChainInfo.chain.chain_id, + }, + localDenom: `ibc/${hash}`, + }, + proposal: {}, + }); + t.true(_offerResult); + + // FIXME https://github.com/Agoric/agoric-sdk/issues/9643 + const vstorageClient = makeQueryTool(); + const currentWalletRecord = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${user1Addr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[offerId], + `${offerId} continuing invitation is in vstorage`, + ); + + const offerToPublicSubscriberMap = Object.fromEntries( + currentWalletRecord.offerToPublicSubscriberPaths, + ); + + // 5. look up LOA address in vstroage + console.log('offerToPublicSubscriberMap', offerToPublicSubscriberMap); + const lcaAddress = offerToPublicSubscriberMap[offerId]?.agoric + .split('.') + .pop(); + const icaAddress = offerToPublicSubscriberMap[offerId]?.osmosis + .split('.') + .pop(); + console.log({ lcaAddress, icaAddress }); + t.regex(lcaAddress, /^agoric1/, 'LOA address is valid'); + t.regex(icaAddress, /^osmo1/, 'COA address is valid'); + + // 6. transfer in some tokens over IBC + const transferAmount = 99n; + await fundAndTransfer('osmosis', lcaAddress, transferAmount); + + // 7. verify the COA has active delegations + const { delegation_responses } = await retryUntilCondition( + () => osmosisQueryClient.queryDelegations(icaAddress), + ({ delegation_responses }) => !!delegation_responses.length, + `delegations visible on osmosis`, + ); + t.log('delegation balance', delegation_responses[0]?.balance); + t.like( + delegation_responses[0].balance, + // TODO, read amount in contract tap + { denom: chainConfig['osmosis'].denom, amount: String(transferAmount) }, + 'delegations balance', + ); + t.log('Orchestration Account Delegations', 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? +}); diff --git a/multichain-testing/tools/query.ts b/multichain-testing/tools/query.ts index 722960110ff8..e461771924e8 100644 --- a/multichain-testing/tools/query.ts +++ b/multichain-testing/tools/query.ts @@ -6,6 +6,7 @@ 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 export function makeQueryClient(apiUrl: string) { @@ -46,5 +47,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/yarn.lock b/multichain-testing/yarn.lock index e89595030d61..7c87c33808a5 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/orchestration/src/utils/time.js b/packages/orchestration/src/utils/time.js index 2ddfcbc9cb82..a45848ad1ec1 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; /**