diff --git a/packages/fast-usdc/package.json b/packages/fast-usdc/package.json index f4922c64da0f..7c2ce37f2e82 100644 --- a/packages/fast-usdc/package.json +++ b/packages/fast-usdc/package.json @@ -36,6 +36,7 @@ "@agoric/notifier": "^0.6.2", "@agoric/orchestration": "^0.1.0", "@agoric/store": "^0.9.2", + "@agoric/vat-data": "^0.5.2", "@agoric/vow": "^0.1.0", "@agoric/zoe": "^0.26.2", "@endo/base64": "^1.0.8", diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index 13a73cf7f9ec..f72f74815d15 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -1,5 +1,7 @@ +import { AmountMath, AmountShape, BrandShape } from '@agoric/ertp'; import { assertAllDefined } from '@agoric/internal'; import { ChainAddressShape } from '@agoric/orchestration'; +import { pickFacet } from '@agoric/vat-data'; import { VowShape } from '@agoric/vow'; import { makeError, q } from '@endo/errors'; import { E } from '@endo/far'; @@ -9,116 +11,220 @@ import { addressTools } from '../utils/address.js'; /** * @import {HostInterface} from '@agoric/async-flow'; + * @import {NatAmount} from '@agoric/ertp'; * @import {ChainAddress, ChainHub, Denom, DenomAmount, OrchestrationAccount} from '@agoric/orchestration'; * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; * @import {CctpTxEvidence, LogFn} from '../types.js'; * @import {StatusManager} from './status-manager.js'; - * @import {TransactionFeedKit} from './transaction-feed.js'; */ +/** + * Expected interface from LiquidityPool + * + * @typedef {{ + * lookupBalance(): NatAmount; + * borrowUnderlying(amount: Amount<"nat">): Promise; + * returnUnderlying(principalPayment: Payment<"nat">, feePayment: Payment<"nat">): Promise + * }} AssetManagerFacet + */ + +/** + * @typedef {{ + * chainHub: ChainHub; + * log: LogFn; + * statusManager: StatusManager; + * vowTools: VowTools; + * }} AdvancerKitCaps + */ + +/** type guards internal to the AdvancerKit */ +const WatcherHandlersShape = { + depositHandler: M.interface('DepositHandlerI', { + onFulfilled: M.call(AmountShape, ChainAddressShape).returns(VowShape), + }), + transferHandler: M.interface('TransferHandlerI', { + // TODO confirm undefined, and not bigint (sequence) + onFulfilled: M.call(M.undefined(), { + amount: AmountShape, + destination: ChainAddressShape, + }).returns(M.undefined()), + onRejected: M.call(M.error(), { + amount: AmountShape, + destination: ChainAddressShape, + }).returns(M.undefined()), + }), +}; + /** * @param {Zone} zone - * @param {object} caps - * @param {ChainHub} caps.chainHub - * @param {LogFn} caps.log - * @param {StatusManager} caps.statusManager - * @param {VowTools} caps.vowTools + * @param {AdvancerKitCaps} caps */ -export const prepareAdvancer = ( +export const prepareAdvancerKit = ( zone, - { chainHub, log, statusManager, vowTools: { watch } }, + { chainHub, log, statusManager, vowTools: { watch, when } }, ) => { - assertAllDefined({ statusManager, watch }); + assertAllDefined({ + chainHub, + statusManager, + watch, + when, + }); - const transferHandler = zone.exo( - 'Fast USDC Advance Transfer Handler', - M.interface('TransferHandlerI', { - // TODO confirm undefined, and not bigint (sequence) - onFulfilled: M.call(M.undefined(), { - amount: M.bigint(), - destination: ChainAddressShape, - }).returns(M.undefined()), - onRejected: M.call(M.error(), { - amount: M.bigint(), - destination: ChainAddressShape, - }).returns(M.undefined()), - }), + return zone.exoClassKit( + 'Fast USDC Advancer', { - /** - * @param {undefined} result TODO confirm this is not a bigint (sequence) - * @param {{ destination: ChainAddress; amount: bigint; }} ctx - */ - onFulfilled(result, { destination, amount }) { - log( - 'Advance transfer fulfilled', - q({ amount, destination, result }).toString(), - ); - }, - onRejected(error) { - // XXX retry logic? - // What do we do if we fail, should we keep a Status? - log('Advance transfer rejected', q(error).toString()); - }, + advancer: M.interface('AdvancerI', { + handleTransactionEvent: M.callWhen(CctpTxEvidenceShape).returns( + M.or(M.undefined(), VowShape), + ), + }), + ...WatcherHandlersShape, }, - ); - - return zone.exoClass( - 'Fast USDC Advancer', - M.interface('AdvancerI', { - handleTransactionEvent: M.call(CctpTxEvidenceShape).returns(VowShape), - }), /** * @param {{ + * assetManagerFacet: AssetManagerFacet; * localDenom: Denom; - * poolAccount: HostInterface>; + * poolAccount: ERef>> + * 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)}`, - ); - } + advancer: { + /** + * Returns a Promise for a Vow in the happy path, or undefined + * when conditions are not met. Aims to perform a status update for + * every observed transaction. + * + * 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 { assetManagerFacet, usdcBrand } = this.state; + // XXX better way? + const poolAccount = await when(this.state.poolAccount); + 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), - }); + const requestedValue = BigInt(evidence.tx.amount); + const requestedAmount = AmountMath.make(usdcBrand, requestedValue); + const poolBalance = assetManagerFacet.lookupBalance(); - // TODO #10391 ensure there's enough funds in poolAccount + if (!AmountMath.isGTE(poolBalance, requestedAmount)) { + 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); + try { + // should LiquidityPool return a vow here? + const { USDC: advancePmtP } = + await assetManagerFacet.borrowUnderlying(requestedAmount); - return watch(transferV, transferHandler, { - destination, - amount: requestedAmount.value, - }); + // do we actually need to await here? + const advancePmt = await advancePmtP; + const depositV = E(poolAccount).deposit(advancePmt); + return watch(depositV, this.facets.depositHandler, destination); + } catch (e) { + // TODO how should we think about failure here? + log('Ruh roh', q(e).toString()); + throw e; + } + } catch (e) { + log('Advancer error:', q(e).toString()); + statusManager.observe(evidence); + } + }, + }, + depositHandler: { + /** + * @param {NatAmount} amount amount returned from deposit + * @param {ChainAddress} destination + */ + onFulfilled(amount, destination) { + const { localDenom, poolAccount } = this.state; + const transferV = E(poolAccount).transfer( + destination, + /** @type {DenomAmount} */ ({ + denom: localDenom, + value: amount.value, + }), + ); + return watch(transferV, this.facets.transferHandler, { + destination, + amount, + }); + }, + // xxx return payment on rejected + }, + transferHandler: { + /** + * @param {undefined} result TODO confirm this is not a bigint (sequence) + * @param {{ destination: ChainAddress; amount: NatAmount; }} ctx + */ + onFulfilled(result, { destination, amount }) { + // TODO vstorage update? + log( + 'Advance transfer fulfilled', + q({ amount, destination, result }).toString(), + ); + }, + onRejected(error) { + // XXX retry logic? + // What do we do if we fail, should we keep a Status? + log('Advance transfer rejected', q(error).toString()); + }, }, }, { stateShape: harden({ + assetManagerFacet: M.remotable(), localDenom: M.string(), - poolAccount: M.remotable(), + poolAccount: M.or(VowShape, M.remotable()), + usdcBrand: BrandShape, }), }, ); }; +harden(prepareAdvancerKit); + +/** + * @param {Zone} zone + * @param {AdvancerKitCaps} caps + */ +export const prepareAdvancer = (zone, caps) => { + const makeAdvancerKit = prepareAdvancerKit(zone, caps); + return pickFacet(makeAdvancerKit, 'advancer'); +}; harden(prepareAdvancer); diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index 78bfd03f48ff..3de8f8737761 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -72,11 +72,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => { // Connect evidence stream to advancer void observeIteration(subscribeEach(feedKit.public.getEvidenceStream()), { updateState(evidence) { - try { - advancer.handleTransactionEvent(evidence); - } catch (err) { - trace('🚨 Error handling transaction event', err); - } + void advancer.handleTransactionEvent(evidence); }, }); const makeLiquidityPoolKit = prepareLiquidityPoolKit( diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index 4597253b657e..a1c7bf2808d9 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -1,38 +1,34 @@ 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 } 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 { Far } from '@endo/pass-style'; +import { makePromiseKit } from '@endo/promise-kit'; +import type { NatAmount } from '@agoric/ertp'; +import { PendingTxStatus } from '../../src/constants.js'; import { prepareAdvancer } from '../../src/exos/advancer.js'; import { prepareStatusManager } from '../../src/exos/status-manager.js'; -import { prepareTransactionFeedKit } 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); +type CommonSetup = Awaited>; + +const createTestExtensions = (t, common: CommonSetup) => { const { bootstrap: { rootZone, vowTools }, facadeServices: { chainHub }, + brands: { usdc }, + utils: { pourPayment }, } = common; const { log, inspectLogs } = makeTestLogger(t.log); @@ -43,140 +39,324 @@ test.beforeEach(async t => { const statusManager = prepareStatusManager( rootZone.subZone('status-manager'), ); + + const mockAccounts = prepareMockOrchAccounts(rootZone.subZone('accounts'), { + vowTools, + log: t.log, + usdc, + }); + const makeAdvancer = prepareAdvancer(rootZone.subZone('advancer'), { chainHub, statusManager, vowTools, log, }); - const localDenom = `ibc/${denomHash({ - denom: 'uusdc', - channelId: - fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, - })}`; + /** pretend we have 1M USDC in pool deposits */ + let mockPoolBalance = usdc.units(1_000_000); + /** + * adjust balance from 1M default to test insufficient funds + * @param value + */ + const setMockPoolBalance = (value: bigint) => { + mockPoolBalance = usdc.make(value); + }; + + const borrowUnderlyingPK = makePromiseKit(); + const resolveBorrowUnderlyingP = async (amount: Amount<'nat'>) => { + const pmt = await pourPayment(amount); + return borrowUnderlyingPK.resolve({ USDC: pmt }); + }; + const rejectBorrowUnderlyingP = () => + borrowUnderlyingPK.reject('Mock unable to borrow.'); + + const advancer = makeAdvancer({ + assetManagerFacet: Far('AssetManager', { + lookupBalance: () => mockPoolBalance, + borrowUnderlying: (amount: NatAmount) => { + t.log('borrowUnderlying called with', amount); + return borrowUnderlyingPK.promise; + }, + returnUnderlying: () => Promise.resolve(), + }), + poolAccount: mockAccounts.mockPoolAccount.account, + localDenom: LOCAL_DENOM, + usdcBrand: usdc.brand, + }); + + return { + constants: { + localDenom: LOCAL_DENOM, + }, + helpers: { + inspectLogs, + }, + mocks: { + ...mockAccounts, + setMockPoolBalance, + resolveBorrowUnderlyingP, + rejectBorrowUnderlyingP, + }, + services: { + advancer, + makeAdvancer, + 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 }, + mocks: { mockPoolAccount, resolveBorrowUnderlyingP }, + }, + brands: { usdc }, + } = t.context; + + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + + await resolveBorrowUnderlyingP(usdc.make(mockEvidence.tx.amount)); + await eventLoopIteration(); + mockPoolAccount.transferVResolver.resolve(); + + await handleTxP; + await eventLoopIteration(); + + const entries = statusManager.lookupPending( + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, + ); + + t.deepEqual( + entries, + [{ ...mockEvidence, status: PendingTxStatus.Advanced }], + 'ADVANCED status in happy path', + ); + + t.deepEqual(inspectLogs(0), [ + 'Advance transfer fulfilled', + '{"amount":{"brand":"[Alleged: USDC brand]","value":"[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 }, + mocks: { setMockPoolBalance }, + }, } = t.context; - const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( - rootZone.subZone('poolAcct'), - { vowTools, log: t.log }, + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + + setMockPoolBalance(1n); + await handleTxP; + + const entries = statusManager.lookupPending( + mockEvidence.tx.forwardingAddress, + mockEvidence.tx.amount, + ); + + t.deepEqual( + entries, + [{ ...mockEvidence, status: PendingTxStatus.Observed }], + 'OBSERVED status on insufficient pool funds', ); + t.deepEqual(inspectLogs(0), [ + 'Insufficient pool funds', + 'Requested {"brand":"[Alleged: USDC brand]","value":"[200000000n]"} but only have {"brand":"[Alleged: USDC brand]","value":"[1n]"}', + ]); +}); + +test('updates status to OBSERVED if balance query fails', async t => { + const { + extensions: { + services: { makeAdvancer, statusManager }, + helpers: { inspectLogs }, + mocks: { mockPoolAccount }, + }, + brands: { usdc }, + } = t.context; + + // make a new advancer that intentionally throws const advancer = makeAdvancer({ - poolAccount, - localDenom, + // @ts-expect-error mock + assetManagerFacet: Far('AssetManager', { + lookupBalance: () => { + throw new Error('lookupBalance failed'); + }, + }), + poolAccount: mockPoolAccount.account, + localDenom: LOCAL_DENOM, + usdcBrand: usdc.brand, }); - 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 mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + 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.Advanced }], - 'tx status updated to ADVANCED', + [{ ...mockEvidence, status: PendingTxStatus.Observed }], + 'OBSERVED status on balance query failure', + ); + + t.deepEqual(inspectLogs(0), [ + 'Advancer error:', + '"[Error: lookupBalance failed]"', + ]); +}); + +test('updates status to OBSERVED if makeChainAddress fails', async t => { + const { + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + }, + } = t.context; + + const mockEvidence = MockCctpTxEvidences.AGORIC_UNKNOWN_EUD(); + await advancer.handleTransactionEvent(mockEvidence); + + 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 }], + 'OBSERVED status on makeChainAddress failure', ); + + t.deepEqual(inspectLogs(0), [ + 'Advancer error:', + '"[Error: Chain info not found for bech32Prefix \\"random\\"]"', + ]); }); -test('advancer does not update status on failed transfer', async t => { +// TODO, this failure should be handled differently +test('does not update status on failed transfer', async t => { const { - inspectLogs, - localDenom, - makeAdvancer, - statusManager, - rootZone, - vowTools, + extensions: { + services: { advancer, statusManager }, + helpers: { inspectLogs }, + mocks: { mockPoolAccount, resolveBorrowUnderlyingP }, + }, + brands: { usdc }, } = t.context; - const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( - rootZone.subZone('poolAcct2'), - { vowTools, log: t.log }, - ); + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + + await resolveBorrowUnderlyingP(usdc.make(mockEvidence.tx.amount)); + mockPoolAccount.transferVResolver.reject(new Error('simulated error')); - const advancer = makeAdvancer({ poolAccount, localDenom }); - t.truthy(advancer, 'advancer instantiates'); + 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, ); + + // TODO, this failure should be handled differently 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 }, - ); +// TODO: might be consideration of `EventFeed` +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 }, + mocks: { mockPoolAccount, resolveBorrowUnderlyingP }, + }, + brands: { usdc }, + } = t.context; + + const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + + // First attempt + const handleTxP = advancer.handleTransactionEvent(mockEvidence); + await resolveBorrowUnderlyingP(usdc.make(mockEvidence.tx.amount)); + mockPoolAccount.transferVResolver.resolve(); + await handleTxP; + await eventLoopIteration(); + + t.deepEqual(inspectLogs(0), [ + 'Advance transfer fulfilled', + '{"amount":{"brand":"[Alleged: USDC brand]","value":"[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 69363c988c75..fff1f942d967 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 ba2ba51d3721..bb3d533f437e 100644 --- a/packages/fast-usdc/test/mocks.ts +++ b/packages/fast-usdc/test/mocks.ts @@ -1,3 +1,4 @@ +import type { HostInterface } from '@agoric/async-flow'; import type { ChainAddress, DenomAmount, @@ -5,35 +6,44 @@ import type { } from '@agoric/orchestration'; import type { Zone } from '@agoric/zone'; import type { VowTools } from '@agoric/vow'; -import type { HostInterface } from '@agoric/async-flow'; import type { LogFn } from '../src/types.js'; export const prepareMockOrchAccounts = ( zone: Zone, { - vowTools: { makeVowKit }, + vowTools: { makeVowKit, asVow }, log, - }: { vowTools: VowTools; log: (...args: any[]) => void }, + usdc, + }: { + vowTools: VowTools; + log: (...args: any[]) => void; + usdc: { brand: Brand<'nat'>; issuer: Issuer<'nat'> }; + }, ) => { - // can only be called once per test - const poolAccountTransferVK = makeVowKit(); + // each can only be resolved/rejected once per test + const poolAccountTransferVK = makeVowKit(); - const mockedPoolAccount = zone.exo('Pool LocalOrchAccount', undefined, { + const mockedPoolAccount = zone.exo('Mock Pool LocalOrchAccount', undefined, { transfer(destination: ChainAddress, amount: DenomAmount) { log('PoolAccount.transfer() called with', destination, amount); return poolAccountTransferVK.vow; }, + deposit(payment: Payment<'nat'>) { + log('PoolAccount.deposit() called with', payment); + // XXX consider a mock for deposit failure + return asVow(async () => usdc.issuer.getAmountOf(payment)); + }, }); const poolAccount = mockedPoolAccount as unknown as HostInterface< - OrchestrationAccount<{ - chainId: 'agoric'; - }> + OrchestrationAccount<{ chainId: 'agoric' }> >; return { - poolAccount, - poolAccountTransferVResolver: poolAccountTransferVK.resolver, + mockPoolAccount: { + account: poolAccount, + transferVResolver: poolAccountTransferVK.resolver, + }, }; }; diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md index 8a13071ad97a..427ddfb750f2 100644 --- a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md +++ b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md @@ -40,8 +40,6 @@ Generated by [AVA](https://avajs.dev). lookupConnectionInfo_kindHandle: 'Alleged: kind', }, contract: { - 'Fast USDC Advance Transfer Handler_kindHandle': 'Alleged: kind', - 'Fast USDC Advance Transfer Handler_singleton': 'Alleged: Fast USDC Advance Transfer Handler', 'Fast USDC Advancer_kindHandle': 'Alleged: kind', 'Fast USDC Creator_kindHandle': 'Alleged: kind', 'Fast USDC Creator_singleton': 'Alleged: Fast USDC Creator', diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap index 996a38d4eb98..2ba9b651784a 100644 Binary files a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap and b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap differ