diff --git a/multichain-testing/test/deposit-withdraw-lca.test.ts b/multichain-testing/test/deposit-withdraw-lca.test.ts new file mode 100644 index 00000000000..604b967dfff --- /dev/null +++ b/multichain-testing/test/deposit-withdraw-lca.test.ts @@ -0,0 +1,223 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { AmountMath } from '@agoric/ertp'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import { makeQueryClient } from '../tools/query.js'; +import { commonSetup, SetupContextWithWallets } from './support.js'; + +const test = anyTest as TestFn; + +const accounts = ['user1']; + +const contractName = 'basicFlows'; +const contractBuilder = + '../packages/builders/scripts/orchestration/init-basic-flows.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 }; + const { startContract } = rest; + await startContract(contractName, contractBuilder); +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +test('Deposit IST to orchAccount and then withdraw', async t => { + const { + wallets, + provisionSmartWallet, + vstorageClient, + retryUntilCondition, + useChain, + } = t.context; + + // Provision the Agoric smart wallet + const agoricAddr = wallets.user1; + const wdUser = await provisionSmartWallet(agoricAddr, { + BLD: 100n, + IST: 1000n, + }); + t.log(`Provisioned Agoric smart wallet for ${agoricAddr}`); + + const doOffer = makeDoOffer(wdUser); + + // Create orchAccount + const makeAccountOfferId = `makeAccount-${Date.now()}`; + await doOffer({ + id: makeAccountOfferId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeOrchAccountInvitation']], + }, + offerArgs: { chainName: 'agoric' }, + proposal: {}, + }); + + // Wait for the orchAccount to be created + const { offerToPublicSubscriberPaths } = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[makeAccountOfferId], + 'makeAccount offer result is in vstorage', + ); + + // TODO type `offerToPublicSubscriberPaths` #10214 (OrchAccount) + const accountStoragePath = Object.fromEntries(offerToPublicSubscriberPaths)[ + makeAccountOfferId + ]!.account; + const lcaAddress = accountStoragePath.split('.').at(-1); + t.truthy(lcaAddress, 'Account address is in storage path'); + + // Get IST brand + const brands = await vstorageClient.queryData('published.agoricNames.brand'); + const istBrand = Object.fromEntries(brands).IST; + + // Deposit IST to orchAccount + const depositAmount = AmountMath.make(istBrand, 500n); + const depositOfferId = `deposit-${Date.now()}`; + await doOffer({ + id: depositOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: makeAccountOfferId, + invitationMakerName: 'Deposit', + }, + offerArgs: {}, + proposal: { + give: { Asset: depositAmount }, + }, + }); + + // Verify deposit + const apiUrl = await useChain('agoric').getRestEndpoint(); + const queryClient = makeQueryClient(apiUrl); + + const lcaBalanceAfterDeposit = await retryUntilCondition( + () => queryClient.queryBalance(lcaAddress, 'uist'), + ({ balance }) => balance?.denom === 'uist' && balance?.amount === '500', + 'Deposit reflected in localOrchAccount balance', + ); + t.deepEqual(lcaBalanceAfterDeposit.balance, { denom: 'uist', amount: '500' }); + + // Withdraw IST from orchAccount + const withdrawAmount = AmountMath.make(istBrand, 300n); + const withdrawOfferId = `withdraw-${Date.now()}`; + await doOffer({ + id: withdrawOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: makeAccountOfferId, + invitationMakerName: 'Withdraw', + }, + offerArgs: {}, + proposal: { + want: { Asset: withdrawAmount }, + }, + }); + + // Verify withdrawal + const lcaBalanceAfterWithdraw = await retryUntilCondition( + () => queryClient.queryBalance(lcaAddress, 'uist'), + ({ balance }) => balance?.denom === 'uist' && balance?.amount === '200', + 'Withdraw reflected in localOrchAccount balance', + ); + t.deepEqual(lcaBalanceAfterWithdraw.balance, { + denom: 'uist', + amount: '200', + }); + + // faucet - provision rebate - deposit + withdraw + const driverExpectedBalance = 1_000_000_000n + 250_000n - 500n + 300n; + const driverBalanceAfterWithdraw = await retryUntilCondition( + () => queryClient.queryBalance(agoricAddr, 'uist'), + ({ balance }) => + balance?.denom === 'uist' && + balance?.amount === String(driverExpectedBalance), + 'Withdraw reflected in driverAccount balance', + ); + t.deepEqual(driverBalanceAfterWithdraw.balance, { + denom: 'uist', + amount: String(driverExpectedBalance), + }); +}); + +test.todo('Deposit and Withdraw ATOM/OSMO to localOrchAccount via offer #9966'); + +test('Attempt to withdraw more than available balance', async t => { + const { wallets, provisionSmartWallet, vstorageClient, retryUntilCondition } = + t.context; + + // Provision the Agoric smart wallet + const agoricAddr = wallets.user1; + const wdUser = await provisionSmartWallet(agoricAddr, { + BLD: 100n, + IST: 1000n, + }); + t.log(`Provisioned Agoric smart wallet for ${agoricAddr}`); + + const doOffer = makeDoOffer(wdUser); + + // Create orchAccount + const makeAccountOfferId = `makeLocalAccount-${Date.now()}`; + await doOffer({ + id: makeAccountOfferId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makeOrchAccountInvitation']], + }, + offerArgs: { chainName: 'agoric' }, + proposal: {}, + }); + + // Wait for the orchAccount to be created + const { offerToPublicSubscriberPaths } = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[makeAccountOfferId], + `${makeAccountOfferId} offer result is in vstorage`, + ); + + const accountStoragePath = Object.fromEntries(offerToPublicSubscriberPaths)[ + makeAccountOfferId + ]?.account; + const lcaAddress = accountStoragePath.split('.').at(-1); + t.truthy(lcaAddress, 'Account address is in storage path'); + + // Get IST brand + const brands = await vstorageClient.queryData('published.agoricNames.brand'); + const istBrand = Object.fromEntries(brands).IST; + + // Attempt to withdraw more than available balance + const excessiveWithdrawAmount = AmountMath.make(istBrand, 200n); + const withdrawOfferId = `withdraw-error-${Date.now()}`; + await doOffer({ + id: withdrawOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: makeAccountOfferId, + invitationMakerName: 'Withdraw', + }, + offerArgs: {}, + proposal: { + want: { Asset: excessiveWithdrawAmount }, + }, + }); + + // Verify that the withdrawal failed + const offerResult = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}`), + ({ status }) => status.id === withdrawOfferId && status.error !== undefined, + 'Withdrawal offer error is in vstorage', + ); + t.is( + offerResult.status.error, + 'Error: One or more withdrawals failed ["[Error: cannot grab 200uist coins: 0uist is smaller than 200uist: insufficient funds]"]', + ); +}); diff --git a/multichain-testing/test/deposit-withdraw-portfolio.test.ts b/multichain-testing/test/deposit-withdraw-portfolio.test.ts new file mode 100644 index 00000000000..abeef178984 --- /dev/null +++ b/multichain-testing/test/deposit-withdraw-portfolio.test.ts @@ -0,0 +1,248 @@ +import anyTest from '@endo/ses-ava/prepare-endo.js'; +import type { TestFn } from 'ava'; +import { AmountMath } from '@agoric/ertp'; +import { makeDoOffer } from '../tools/e2e-tools.js'; +import { makeQueryClient } from '../tools/query.js'; +import { commonSetup, SetupContextWithWallets } from './support.js'; + +const test = anyTest as TestFn; + +const accounts = ['cosmoshub', 'osmosis']; + +const contractName = 'basicFlows'; +const contractBuilder = + '../packages/builders/scripts/orchestration/init-basic-flows.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 }; + const { startContract } = rest; + await startContract(contractName, contractBuilder); +}); + +test.after(async t => { + const { deleteTestKeys } = t.context; + deleteTestKeys(accounts); +}); + +const portfolioAccountScenario = test.macro({ + title: (_, remoteChain: string) => + `Deposit and withdraw to ICA on ${remoteChain} via Portfolio Account`, + exec: async (t, chainName: string) => { + const { + wallets, + provisionSmartWallet, + vstorageClient, + retryUntilCondition, + useChain, + } = t.context; + + const agoricAddr = wallets[chainName]; + const wdUser = await provisionSmartWallet(agoricAddr, { + BLD: 100n, + IST: 1000n, + }); + t.log(`Provisioned Agoric smart wallet for ${agoricAddr}`); + + const doOffer = makeDoOffer(wdUser); + + // Create portfolio holder account for agoric and remoteChain + const makePortfolioAcctOfferId = `makePortfolioAccount-${chainName}-${Date.now()}`; + await doOffer({ + id: makePortfolioAcctOfferId, + invitationSpec: { + source: 'agoricContract', + instancePath: [contractName], + callPipe: [['makePortfolioAccountInvitation']], + }, + offerArgs: { + chainNames: ['agoric', chainName], + }, + proposal: {}, + }); + + const { offerToPublicSubscriberPaths } = await retryUntilCondition( + () => vstorageClient.queryData(`published.wallet.${agoricAddr}.current`), + ({ offerToPublicSubscriberPaths }) => + Object.fromEntries(offerToPublicSubscriberPaths)[ + makePortfolioAcctOfferId + ], + 'Portfolio account creation offer result is in vstorage', + ); + + // TODO type `offerToPublicSubscriberPaths` #10214 (PortfolioHolder) + const accountPaths = Object.fromEntries(offerToPublicSubscriberPaths)[ + makePortfolioAcctOfferId + ]; + t.truthy(accountPaths.agoric, 'Agoric account path returned'); + t.truthy(accountPaths[chainName], `${chainName} account path returned`); + + const agoricLcaAddress = accountPaths.agoric.split('.').at(-1); + const remoteIcaAddress = accountPaths[chainName].split('.').at(-1); + t.truthy(agoricLcaAddress, 'Agoric LCA address is in storage path'); + t.truthy(remoteIcaAddress, `${chainName} ICA address is in storage path`); + + // Get IST brand + const brands = await vstorageClient.queryData( + 'published.agoricNames.brand', + ); + const istBrand = Object.fromEntries(brands).IST; + + // Setup query clients + const agoricApiUrl = await useChain('agoric').getRestEndpoint(); + const agoricQueryClient = makeQueryClient(agoricApiUrl); + const remoteChainInfo = useChain(chainName); + const remoteQueryClient = makeQueryClient( + await remoteChainInfo.getRestEndpoint(), + ); + + // Deposit funds to Agoric account + const depositAmount = AmountMath.make(istBrand, 500n); + const depositOfferId = `deposit-portfolio-${chainName}-${Date.now()}`; + await doOffer({ + id: depositOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: makePortfolioAcctOfferId, + invitationMakerName: 'Proxying', + invitationArgs: ['agoric', 'Deposit'], + }, + offerArgs: {}, + proposal: { + give: { Asset: depositAmount }, + }, + }); + + // Verify deposit + const agoricAccountBalance = await retryUntilCondition( + () => agoricQueryClient.queryBalance(agoricLcaAddress, 'uist'), + ({ balance }) => balance?.denom === 'uist' && balance?.amount === '500', + 'Deposit reflected in Agoric account balance', + ); + t.deepEqual( + agoricAccountBalance.balance, + { denom: 'uist', amount: '500' }, + 'Correct amount deposited to Agoric account', + ); + + // IBC Transfer funds to remoteChain account + const remoteChainId = remoteChainInfo.chain.chain_id; + const ibcTransferOfferId = `transfer-to-${chainName}-${Date.now()}`; + await doOffer({ + id: ibcTransferOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: makePortfolioAcctOfferId, + invitationMakerName: 'Proxying', + invitationArgs: ['agoric', 'Transfer'], + }, + offerArgs: { + amount: { denom: 'uist', value: 500n }, + destination: { + chainId: remoteChainId, + value: remoteIcaAddress, + encoding: 'bech32,', + }, + }, + proposal: {}, + }); + + const remoteBalances = await retryUntilCondition( + () => remoteQueryClient.queryBalances(remoteIcaAddress), + ({ balances }) => balances.length > 0, + `IBC transfer reflected in ${chainName} account balance`, + ); + t.log(`${remoteIcaAddress} Balances`, remoteBalances.balances); + // there are no other funds in the account, so we can safely assume its IST + // consider looking up the expected denom + t.like(remoteBalances, { + balances: [ + { + amount: '500', + }, + ], + }); + + // Transfer funds back to Agoric + // TODO #9966 use IST brand and let contract perform denom lookup + const istRemoteDenom = remoteBalances.balances[0].denom; + const transferBackOfferId = `transfer-back-${chainName}-${Date.now()}`; + await doOffer({ + id: transferBackOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: makePortfolioAcctOfferId, + invitationMakerName: 'Proxying', + invitationArgs: [chainName, 'Transfer'], + }, + offerArgs: { + // TODO #9966 use IST brand and let contract perform denom lookup + amount: { denom: istRemoteDenom, value: 500n }, + destination: { + chainId: 'agoriclocal', + value: agoricLcaAddress, + encoding: 'bech32,', + }, + }, + proposal: {}, + }); + + // Verify funds are back in Agoric account + const updatedAgoricAccountBalance = await retryUntilCondition( + () => agoricQueryClient.queryBalance(agoricLcaAddress, 'uist'), + ({ balance }) => balance?.denom === 'uist' && balance?.amount === '500', + 'IBC transfer back reflected in Agoric account balance', + ); + t.deepEqual( + updatedAgoricAccountBalance.balance, + { denom: 'uist', amount: '500' }, + 'Correct amount transferred back to Agoric account', + ); + + // Withdraw funds from Agoric account + const withdrawAmount = AmountMath.make(istBrand, 500n); + const withdrawOfferId = `withdraw-${chainName}-${Date.now()}`; + await doOffer({ + id: withdrawOfferId, + invitationSpec: { + source: 'continuing', + previousOffer: makePortfolioAcctOfferId, + invitationMakerName: 'Proxying', + invitationArgs: ['agoric', 'Withdraw'], + }, + offerArgs: {}, + proposal: { + want: { Asset: withdrawAmount }, + }, + }); + + // Verify withdrawal + const finalAgoricAccountBalance = await agoricQueryClient.queryBalance( + agoricLcaAddress, + 'uist', + ); + t.deepEqual( + finalAgoricAccountBalance.balance, + { denom: 'uist', amount: '0' }, + 'All funds withdrawn from Agoric account', + ); + + // Verify smart wallet balance + // faucet - provision rebate - deposit + withdraw + const driverExpectedBalance = 1_000_000_000n + 250_000n - 500n + 500n; + const driverBalanceAfterWithdraw = await agoricQueryClient.queryBalance( + agoricAddr, + 'uist', + ); + t.deepEqual( + driverBalanceAfterWithdraw.balance, + { denom: 'uist', amount: String(driverExpectedBalance) }, + 'All funds returned to smart wallet', + ); + }, +}); + +test(portfolioAccountScenario, 'osmosis'); +test(portfolioAccountScenario, 'cosmoshub'); diff --git a/packages/orchestration/src/exos/portfolio-holder-kit.js b/packages/orchestration/src/exos/portfolio-holder-kit.js index 059eebe03e1..a163dab1981 100644 --- a/packages/orchestration/src/exos/portfolio-holder-kit.js +++ b/packages/orchestration/src/exos/portfolio-holder-kit.js @@ -43,11 +43,9 @@ const preparePortfolioHolderKit = (zone, { asVow, when }) => { 'PortfolioHolderKit', { invitationMakers: M.interface('InvitationMakers', { - Proxying: M.call( - ChainNameShape, - M.string(), - M.arrayOf(M.any()), - ).returns(M.promise()), + Proxying: M.call(ChainNameShape, M.string()) + .optional(M.arrayOf(M.any())) + .returns(M.promise()), }), holder: M.interface('Holder', { asContinuingOffer: M.call().returns(VowShape), @@ -92,7 +90,7 @@ const preparePortfolioHolderKit = (zone, { asVow, when }) => { * @template {unknown[]} IA * @param {string} chainName key where the account is stored * @param {string} action invitation maker name, e.g. 'Delegate' - * @param {IA} invitationArgs + * @param {IA} [invitationArgs] * @returns {Promise>} */ Proxying(chainName, action, invitationArgs) { @@ -101,7 +99,7 @@ const preparePortfolioHolderKit = (zone, { asVow, when }) => { const account = accounts.get(chainName); // @ts-expect-error XXX invitationMakers return when(E(account).asContinuingOffer(), ({ invitationMakers }) => - E(invitationMakers)[action](...invitationArgs), + E(invitationMakers)[action](...(invitationArgs || [])), ); }, }, diff --git a/packages/orchestration/test/exos/portfolio-holder-kit.test.ts b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts index 62f48203f25..a498b9b0c2c 100644 --- a/packages/orchestration/test/exos/portfolio-holder-kit.test.ts +++ b/packages/orchestration/test/exos/portfolio-holder-kit.test.ts @@ -102,6 +102,19 @@ test('portfolio holder kit behaviors', async t => { 'any invitation maker accessible via Proxying', ); + // scenario with optional invitationArgs + const transferInv = await E(invitationMakers).Proxying( + 'cosmoshub', + 'Transfer', + ); + t.is( + transferInv, + // 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' + 'Transfer', + 'invitationArgs are optional', + ); + const osmosisAccount = await makeCosmosAccount({ chainId: 'osmosis-99', hostConnectionId: 'connection-2' as const,