From 32fb01c23cb17c3f7f2b2d8b86ccb5b4980e7c66 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 7 Nov 2024 10:21:25 -0500 Subject: [PATCH] feat: `Advancer` exo behaviors - refs: #10390 --- packages/fast-usdc/src/exos/advancer.js | 119 ++++-- packages/fast-usdc/test/exos/advancer.test.ts | 342 ++++++++++++------ packages/fast-usdc/test/fixtures.ts | 29 ++ packages/fast-usdc/test/mocks.ts | 18 +- 4 files changed, 368 insertions(+), 140 deletions(-) diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index ab5443066d3..f6baaadeeb5 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -1,3 +1,4 @@ +import { AmountMath, BrandShape } from '@agoric/ertp'; import { assertAllDefined } from '@agoric/internal'; import { ChainAddressShape } from '@agoric/orchestration'; import { VowShape } from '@agoric/vow'; @@ -28,7 +29,7 @@ import { addressTools } from '../utils/address.js'; */ export const prepareAdvancer = ( zone, - { chainHub, feed, log, statusManager, vowTools: { watch } }, + { chainHub, feed, log, statusManager, vowTools: { watch, when } }, ) => { assertAllDefined({ feed, statusManager, watch }); @@ -51,6 +52,7 @@ export const prepareAdvancer = ( * @param {{ destination: ChainAddress; amount: bigint; }} ctx */ onFulfilled(result, { destination, amount }) { + // TODO vstorage update? log( 'Advance transfer fulfilled', q({ amount, destination, result }).toString(), @@ -67,57 +69,112 @@ export const prepareAdvancer = ( return zone.exoClass( 'Fast USDC Advancer', M.interface('AdvancerI', { - handleTransactionEvent: M.call(CctpTxEvidenceShape).returns(VowShape), + handleTransactionEvent: M.callWhen(CctpTxEvidenceShape).returns( + M.or(M.undefined(), VowShape), + ), }), /** * @param {{ * localDenom: Denom; * poolAccount: HostInterface>; + * usdcBrand: Brand<'nat'>; * }} config */ config => harden(config), { - /** @param {CctpTxEvidence} evidence */ - handleTransactionEvent(evidence) { - // TODO EventFeed will perform input validation checks. - const { recipientAddress } = evidence.aux; - const { EUD } = addressTools.getQueryParams(recipientAddress).params; - if (!EUD) { - statusManager.observe(evidence); - throw makeError( - `recipientAddress does not contain EUD param: ${q(recipientAddress)}`, - ); - } + /** + * Returns a Promise for a Vow , since we we `await vt.when()`the + * `poolAccount.getBalance()` call. `getBalance()` interfaces with + * `BankManager` and `ChainHub`, so we can expect the calls to resolve + * promptly. We also don't care how many time `.getBalance` is called, + * and watched vows are not dependent on its result. + * + * This also might return `undefined` (unwrapped) if precondition checks + * fail. + * + * We do not expect any callers to depend on the settlement of + * `handleTransactionEvent` - errors caught are communicated to the + * `StatusManager` - so we don't need to concern ourselves with + * preserving the vow chain for callers. + * + * @param {CctpTxEvidence} evidence + */ + async handleTransactionEvent(evidence) { + await null; + try { + const { recipientAddress } = evidence.aux; + const { EUD } = addressTools.getQueryParams(recipientAddress).params; + if (!EUD) { + throw makeError( + `recipientAddress does not contain EUD param: ${q(recipientAddress)}`, + ); + } - // TODO #10391 this can throw, and should make a status update in the catch - const destination = chainHub.makeChainAddress(EUD); + // this will throw if the bech32 prefix is not found, but is handled by the catch + const destination = chainHub.makeChainAddress(EUD); - /** @type {DenomAmount} */ - const requestedAmount = harden({ - denom: this.state.localDenom, - value: BigInt(evidence.tx.amount), - }); + /** @type {DenomAmount} */ + const requestedAmount = harden({ + denom: this.state.localDenom, + value: BigInt(evidence.tx.amount), + }); + /** + * Ensure there's enough funds in poolAccount. + * + * It's safe to await here since we don't care how many + * times we call `getBalance`. Our later Vow call - `transferV` and + * its ctx - are also not reliant on the consistency of this value. + */ + const poolBalance = await when( + E(this.state.poolAccount).getBalance(this.state.localDenom), + ); - // TODO #10391 ensure there's enough funds in poolAccount + if ( + !AmountMath.isGTE( + AmountMath.make(this.state.usdcBrand, poolBalance.value), + AmountMath.make(this.state.usdcBrand, requestedAmount.value), + ) + ) { + log( + `Insufficient pool funds`, + `Requested ${q(requestedAmount)} but only have ${q(poolBalance)}`, + ); + statusManager.observe(evidence); + return; + } - const transferV = E(this.state.poolAccount).transfer( - destination, - requestedAmount, - ); + try { + // mark as Advanced since `transferV` initiates the advance + // will throw if we've already .skipped or .advanced this evidence + statusManager.advance(evidence); + } catch (e) { + // only anticipated error is `assertNotSeen`, so + // intercept the catch so we don't call .skip which + // also performs this check + log('Advancer error:', q(e).toString()); + return; + } - // mark as Advanced since `transferV` initiates the advance - statusManager.advance(evidence); + const transferV = E(this.state.poolAccount).transfer( + destination, + requestedAmount, + ); - return watch(transferV, transferHandler, { - destination, - amount: requestedAmount.value, - }); + return watch(transferV, transferHandler, { + destination, + amount: requestedAmount.value, + }); + } catch (e) { + log(`Advancer error:`, q(e).toString()); + statusManager.observe(evidence); + } }, }, { stateShape: harden({ localDenom: M.string(), poolAccount: M.remotable(), + usdcBrand: BrandShape, }), }, ); diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index c285a46c6d4..c88d9972050 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -1,38 +1,37 @@ import type { TestFn } from 'ava'; import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; -import { denomHash, type Denom } from '@agoric/orchestration'; +import { denomHash, type DenomAmount } from '@agoric/orchestration'; import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; -import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; -import type { Zone } from '@agoric/zone'; -import type { VowTools } from '@agoric/vow'; +import { PendingTxStatus } from '../../src/constants.js'; import { prepareAdvancer } from '../../src/exos/advancer.js'; import { prepareStatusManager } from '../../src/exos/status-manager.js'; import { prepareTransactionFeed } from '../../src/exos/transaction-feed.js'; import { commonSetup } from '../supports.js'; import { MockCctpTxEvidences } from '../fixtures.js'; -import { - makeTestLogger, - prepareMockOrchAccounts, - type TestLogger, -} from '../mocks.js'; -import { PendingTxStatus } from '../../src/constants.js'; +import { makeTestLogger, prepareMockOrchAccounts } from '../mocks.js'; -const test = anyTest as TestFn<{ - localDenom: Denom; - makeAdvancer: ReturnType; - rootZone: Zone; - statusManager: ReturnType; - vowTools: VowTools; - inspectLogs: TestLogger['inspectLogs']; -}>; +const LOCAL_DENOM = `ibc/${denomHash({ + denom: 'uusdc', + channelId: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, +})}`; -test.beforeEach(async t => { - const common = await commonSetup(t); +const MOCK_POOL_BALANCE: DenomAmount = harden({ + denom: LOCAL_DENOM, + // XXX amountUtils at some point + value: 1_000_000n * 10n ** 6n, // 1M USDC +}); + +type CommonSetup = Awaited>; + +const createTestExtensions = (t, common: CommonSetup) => { const { bootstrap: { rootZone, vowTools }, facadeServices: { chainHub }, + brands: { usdc }, } = common; const { log, inspectLogs } = makeTestLogger(t.log); @@ -44,6 +43,11 @@ test.beforeEach(async t => { rootZone.subZone('status-manager'), ); const feed = prepareTransactionFeed(rootZone.subZone('feed')); + const mockAccounts = prepareMockOrchAccounts(rootZone.subZone('accounts'), { + vowTools, + log: t.log, + }); + const makeAdvancer = prepareAdvancer(rootZone.subZone('advancer'), { chainHub, feed, @@ -51,134 +55,262 @@ test.beforeEach(async t => { vowTools, log, }); - const localDenom = `ibc/${denomHash({ - denom: 'uusdc', - channelId: - fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, - })}`; + const advancer = makeAdvancer({ + poolAccount: mockAccounts.pool.account, + localDenom: LOCAL_DENOM, + usdcBrand: usdc.brand, + }); + + return { + accounts: mockAccounts, + constants: { + localDenom: LOCAL_DENOM, + }, + helpers: { + inspectLogs, + }, + services: { + advancer, + feed, + statusManager, + }, + } as const; +}; + +type TestContext = CommonSetup & { + extensions: ReturnType; +}; + +const test = anyTest as TestFn; + +test.beforeEach(async t => { + const common = await commonSetup(t); t.context = { - localDenom, - makeAdvancer, - rootZone, - statusManager, - vowTools, - inspectLogs, + ...common, + extensions: createTestExtensions(t, common), }; }); -test('advancer updated status to ADVANCED', async t => { +test('updates status to ADVANCED in happy path', async t => { const { - inspectLogs, - localDenom, - makeAdvancer, - statusManager, - rootZone, - vowTools, + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + accounts: { pool }, + }, } = t.context; - const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( - rootZone.subZone('poolAcct'), - { vowTools, log: t.log }, - ); + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + + pool.getBalanceVResolver.resolve(MOCK_POOL_BALANCE); + pool.transferVResolver.resolve(); + + await handleTxP; + await eventLoopIteration(); - const advancer = makeAdvancer({ - poolAccount, - localDenom, - }); - t.truthy(advancer, 'advancer instantiates'); - - // simulate input from EventFeed - const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); - advancer.handleTransactionEvent(mockCttpTxEvidence); - t.log('Simulate advance `.transfer()` vow fulfillment'); - poolAccountTransferVResolver.resolve(); - await eventLoopIteration(); // wait for StatusManager to receive update const entries = statusManager.lookupPending( - mockCttpTxEvidence.tx.forwardingAddress, - mockCttpTxEvidence.tx.amount, + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, ); + t.deepEqual( entries, - [{ ...mockCttpTxEvidence, status: PendingTxStatus.Advanced }], + [{ ...mockEvidence, status: PendingTxStatus.Advanced }], 'tx status updated to ADVANCED', ); + t.deepEqual(inspectLogs(0), [ + 'Advance transfer fulfilled', + '{"amount":"[150000000n]","destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', + ]); +}); + +test('updates status to OBSERVED on insufficient pool funds', async t => { + const { + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + accounts: { pool }, + }, + } = t.context; + + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + + pool.getBalanceVResolver.resolve({ ...MOCK_POOL_BALANCE, value: 1n }); + await handleTxP; + + const entries = statusManager.lookupPending( + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, + ); + t.deepEqual( - inspectLogs(0), - [ - 'Advance transfer fulfilled', - '{"amount":"[150000000n]","destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', - ], - 'contract logs advance', + entries, + [{ ...mockEvidence, status: PendingTxStatus.Observed }], + 'tx is recorded as OBSERVED', ); + + t.deepEqual(inspectLogs(0), [ + 'Insufficient pool funds', + 'Requested {"denom":"ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9","value":"[200000000n]"} but only have {"denom":"ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9","value":"[1n]"}', + ]); }); -test('advancer does not update status on failed transfer', async t => { +test('updates status to OBSERVED if balance query fails', async t => { const { - inspectLogs, - localDenom, - makeAdvancer, - statusManager, - rootZone, - vowTools, + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + accounts: { pool }, + }, + } = t.context; + + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + + pool.getBalanceVResolver.reject(new Error('Unexpected balanceQuery error')); + await handleTxP; + + const entries = statusManager.lookupPending( + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, + ); + + t.deepEqual( + entries, + [{ ...mockEvidence, status: PendingTxStatus.Observed }], + 'tx is recorded as OBSERVED', + ); + + t.deepEqual(inspectLogs(0), [ + 'Advancer error:', + '"[Error: Unexpected balanceQuery error]"', + ]); +}); + +test('updates status to OBSERVED if getChainInfoByAddress fails', async t => { + const { + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + }, } = t.context; - const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( - rootZone.subZone('poolAcct2'), - { vowTools, log: t.log }, + const mockEvidence = MockCctpTxEvidences.AGORIC_UNKNOWN_EUD(); + await advancer.handleTransactionEvent(mockEvidence); + + const entries = statusManager.lookupPending( + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, ); - const advancer = makeAdvancer({ poolAccount, localDenom }); - t.truthy(advancer, 'advancer instantiates'); + t.deepEqual( + entries, + [{ ...mockEvidence, status: PendingTxStatus.Observed }], + 'tx is recorded as OBSERVED', + ); + + t.deepEqual(inspectLogs(0), [ + 'Advancer error:', + '"[Error: Chain info not found for bech32Prefix \\"random\\"]"', + ]); +}); + +test('does not update status on failed transfer', async t => { + const { + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + accounts: { pool }, + }, + } = t.context; + + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + + pool.getBalanceVResolver.resolve(MOCK_POOL_BALANCE); + pool.transferVResolver.reject(new Error('simulated error')); + + await handleTxP; + await eventLoopIteration(); - // simulate input from EventFeed - const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); - advancer.handleTransactionEvent(mockCttpTxEvidence); - t.log('Simulate advance `.transfer()` vow rejection'); - poolAccountTransferVResolver.reject(new Error('simulated error')); - await eventLoopIteration(); // wait for StatusManager to receive update const entries = statusManager.lookupPending( - mockCttpTxEvidence.tx.forwardingAddress, - mockCttpTxEvidence.tx.amount, + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, ); + t.deepEqual( entries, - [{ ...mockCttpTxEvidence, status: PendingTxStatus.Advanced }], - 'tx status is still Advanced even though advance failed', + [{ ...mockEvidence, status: PendingTxStatus.Advanced }], + 'tx status is still ADVANCED even though advance failed', ); + t.deepEqual(inspectLogs(0), [ 'Advance transfer rejected', '"[Error: simulated error]"', ]); }); -test('advancer updated status to OBSERVED if pre-condition checks fail', async t => { - const { localDenom, makeAdvancer, statusManager, rootZone, vowTools } = - t.context; - - const { poolAccount } = prepareMockOrchAccounts( - rootZone.subZone('poolAcct2'), - { vowTools, log: t.log }, - ); +test('updates status to OBSERVED if pre-condition checks fail', async t => { + const { + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + }, + } = t.context; - const advancer = makeAdvancer({ poolAccount, localDenom }); - t.truthy(advancer, 'advancer instantiates'); + const mockEvidence = MockCctpTxEvidences.AGORIC_NO_PARAMS(); - // simulate input from EventFeed - const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_NO_PARAMS(); - t.throws(() => advancer.handleTransactionEvent(mockCttpTxEvidence), { - message: - 'recipientAddress does not contain EUD param: "agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek"', - }); + await advancer.handleTransactionEvent(mockEvidence); const entries = statusManager.lookupPending( - mockCttpTxEvidence.tx.forwardingAddress, - mockCttpTxEvidence.tx.amount, + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, ); + t.deepEqual( entries, - [{ ...mockCttpTxEvidence, status: PendingTxStatus.Observed }], - 'tx status is still OBSERVED', + [{ ...mockEvidence, status: PendingTxStatus.Observed }], + 'tx is recorded as OBSERVED', ); + + t.deepEqual(inspectLogs(0), [ + 'Advancer error:', + '"[Error: recipientAddress does not contain EUD param: \\"agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek\\"]"', + ]); +}); + +test('will not advance same txHash:chainId evidence twice', async t => { + const { + extensions: { + services: { advancer }, + helpers: { inspectLogs }, + accounts: { pool }, + }, + } = t.context; + + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + + // First attempt + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + pool.getBalanceVResolver.resolve(MOCK_POOL_BALANCE); + pool.transferVResolver.resolve(); + await handleTxP; + await eventLoopIteration(); + + t.deepEqual(inspectLogs(0), [ + 'Advance transfer fulfilled', + '{"amount":"[150000000n]","destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', + ]); + + // Second attempt + await advancer.handleTransactionEvent(mockEvidence); + + t.deepEqual(inspectLogs(1), [ + 'Advancer error:', + '"[Error: Transaction already seen: \\"seenTx:[\\\\\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\\\\\",1]\\"]"', + ]); }); diff --git a/packages/fast-usdc/test/fixtures.ts b/packages/fast-usdc/test/fixtures.ts index 69363c988c7..fff1f942d96 100644 --- a/packages/fast-usdc/test/fixtures.ts +++ b/packages/fast-usdc/test/fixtures.ts @@ -7,6 +7,7 @@ const mockScenarios = [ 'AGORIC_PLUS_OSMO', 'AGORIC_PLUS_DYDX', 'AGORIC_NO_PARAMS', + 'AGORIC_UNKNOWN_EUD', ] as const; type MockScenario = (typeof mockScenarios)[number]; @@ -72,6 +73,25 @@ export const MockCctpTxEvidences: Record< }, chainId: 1, }), + AGORIC_UNKNOWN_EUD: (receiverAddress?: string) => ({ + blockHash: + '0x70d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + blockTimestamp: 1730762099n, + txHash: + '0xa81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 200000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelyyy', + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek?EUD=random1addr', + }, + chainId: 1, + }), }; const nobleDefaultVTransferParams = { @@ -115,4 +135,13 @@ export const MockVTransferEvents: Record< recieverAddress || MockCctpTxEvidences.AGORIC_NO_PARAMS().aux.recipientAddress, }), + AGORIC_UNKNOWN_EUD: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_UNKNOWN_EUD().tx.amount, + sender: MockCctpTxEvidences.AGORIC_UNKNOWN_EUD().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_UNKNOWN_EUD().aux.recipientAddress, + }), }; diff --git a/packages/fast-usdc/test/mocks.ts b/packages/fast-usdc/test/mocks.ts index ba2ba51d372..421bbd6ebd5 100644 --- a/packages/fast-usdc/test/mocks.ts +++ b/packages/fast-usdc/test/mocks.ts @@ -1,11 +1,13 @@ import type { ChainAddress, DenomAmount, + DenomArg, OrchestrationAccount, } from '@agoric/orchestration'; import type { Zone } from '@agoric/zone'; import type { VowTools } from '@agoric/vow'; import type { HostInterface } from '@agoric/async-flow'; +import type { LocalOrchestrationAccountKit } from '@agoric/orchestration/src/exos/local-orchestration-account.js'; import type { LogFn } from '../src/types.js'; export const prepareMockOrchAccounts = ( @@ -15,14 +17,19 @@ export const prepareMockOrchAccounts = ( log, }: { vowTools: VowTools; log: (...args: any[]) => void }, ) => { - // can only be called once per test - const poolAccountTransferVK = makeVowKit(); + // each can only be resolved/rejected once per test + const poolAccountTransferVK = makeVowKit(); + const poolAccountGetBalanceVK = makeVowKit(); const mockedPoolAccount = zone.exo('Pool LocalOrchAccount', undefined, { transfer(destination: ChainAddress, amount: DenomAmount) { log('PoolAccount.transfer() called with', destination, amount); return poolAccountTransferVK.vow; }, + getBalance(denomArg: DenomArg) { + log('PoolAccount.getBalance() called with', denomArg); + return poolAccountGetBalanceVK.vow; + }, }); const poolAccount = mockedPoolAccount as unknown as HostInterface< @@ -32,8 +39,11 @@ export const prepareMockOrchAccounts = ( >; return { - poolAccount, - poolAccountTransferVResolver: poolAccountTransferVK.resolver, + pool: { + account: poolAccount, + transferVResolver: poolAccountTransferVK.resolver, + getBalanceVResolver: poolAccountGetBalanceVK.resolver, + }, }; };