From 9af5d0df6cfb546c3304402cf47ec039836f2359 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/package.json | 1 + packages/fast-usdc/src/exos/advancer.js | 271 ++++++++---- packages/fast-usdc/src/fast-usdc.contract.js | 10 +- packages/fast-usdc/test/exos/advancer.test.ts | 392 +++++++++++++----- .../fast-usdc/test/fast-usdc.contract.test.ts | 2 + packages/fast-usdc/test/fixtures.ts | 29 ++ packages/fast-usdc/test/mocks.ts | 32 +- .../snapshots/fast-usdc.contract.test.ts.md | 2 - .../snapshots/fast-usdc.contract.test.ts.snap | Bin 1651 -> 1620 bytes 9 files changed, 540 insertions(+), 199 deletions(-) diff --git a/packages/fast-usdc/package.json b/packages/fast-usdc/package.json index 4bfb3860404b..6d1f01f217ea 100644 --- a/packages/fast-usdc/package.json +++ b/packages/fast-usdc/package.json @@ -37,6 +37,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..3c0b5982b348 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -1,5 +1,7 @@ +import { AmountMath, AmountShape, PaymentShape } 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'; @@ -7,118 +9,229 @@ import { M } from '@endo/patterns'; import { CctpTxEvidenceShape } from '../typeGuards.js'; import { addressTools } from '../utils/address.js'; +const { isGTE } = AmountMath; + /** * @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; + * borrow(amount: Amount<"nat">): Promise>; + * repay(payments: PaymentKeywordRecord): Promise + * }} AssetManagerFacet + */ + +/** + * @typedef {{ + * chainHub: ChainHub; + * log: LogFn; + * statusManager: StatusManager; + * USDC: { brand: Brand<'nat'>; denom: Denom; }; + * vowTools: VowTools; + * }} AdvancerKitPowers + */ + +/** type guards internal to the AdvancerKit */ +const AdvancerKitI = harden({ + advancer: M.interface('AdvancerI', { + handleTransactionEvent: M.callWhen(CctpTxEvidenceShape).returns(), + }), + depositHandler: M.interface('DepositHandlerI', { + onFulfilled: M.call(AmountShape, { + destination: ChainAddressShape, + payment: PaymentShape, + }).returns(VowShape), + onRejected: M.call(M.error(), { + destination: ChainAddressShape, + payment: PaymentShape, + }).returns(), + }), + 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 {AdvancerKitPowers} caps */ -export const prepareAdvancer = ( +export const prepareAdvancerKit = ( zone, - { chainHub, log, statusManager, vowTools: { watch } }, + { chainHub, log, statusManager, USDC, 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()), - }), - { - /** - * @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()); - }, - }, - ); + /** @param {bigint} value */ + const toAmount = value => AmountMath.make(USDC.brand, value); - return zone.exoClass( + return zone.exoClassKit( 'Fast USDC Advancer', - M.interface('AdvancerI', { - handleTransactionEvent: M.call(CctpTxEvidenceShape).returns(VowShape), - }), + AdvancerKitI, /** * @param {{ - * localDenom: Denom; - * poolAccount: HostInterface>; + * assetManagerFacet: AssetManagerFacet; + * poolAccount: ERef>> * }} 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)}`, - ); - } - - // TODO #10391 this can throw, and should make a status update in the catch - const destination = chainHub.makeChainAddress(EUD); - - /** @type {DenomAmount} */ - const requestedAmount = harden({ - denom: this.state.localDenom, - value: BigInt(evidence.tx.amount), - }); + advancer: { + /** + * Must 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 { + // TODO poolAccount might be a vow we need to unwrap + const { assetManagerFacet, poolAccount } = this.state; + const { recipientAddress } = evidence.aux; + const { EUD } = + addressTools.getQueryParams(recipientAddress).params; + if (!EUD) { + throw makeError( + `recipientAddress does not contain EUD param: ${q(recipientAddress)}`, + ); + } - // TODO #10391 ensure there's enough funds in poolAccount + // this will throw if the bech32 prefix is not found, but is handled by the catch + const destination = chainHub.makeChainAddress(EUD); + const requestedAmount = toAmount(evidence.tx.amount); - const transferV = E(this.state.poolAccount).transfer( - destination, - requestedAmount, - ); + // TODO: consider skipping and using `borrow()`s internal balance check + const poolBalance = assetManagerFacet.lookupBalance(); + if (!isGTE(poolBalance, requestedAmount)) { + log( + `Insufficient pool funds`, + `Requested ${q(requestedAmount)} but only have ${q(poolBalance)}`, + ); + statusManager.observe(evidence); + return; + } - // mark as Advanced since `transferV` initiates the advance - statusManager.advance(evidence); + 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; + } - return watch(transferV, transferHandler, { - destination, - amount: requestedAmount.value, - }); + try { + const payment = await assetManagerFacet.borrow(requestedAmount); + const depositV = E(poolAccount).deposit(payment); + void watch(depositV, this.facets.depositHandler, { + destination, + payment, + }); + } catch (e) { + // `.borrow()` might fail if the balance changes since we + // requested it. TODO - how to handle this? change ADVANCED -> OBSERVED? + // Note: `depositHandler` handles the `.deposit()` failure + log('🚨 advance borrow failed', q(e).toString()); + } + } catch (e) { + log('Advancer error:', q(e).toString()); + statusManager.observe(evidence); + } + }, + }, + depositHandler: { + /** + * @param {NatAmount} amount amount returned from deposit + * @param {{ destination: ChainAddress; payment: Payment<'nat'> }} ctx + */ + onFulfilled(amount, { destination }) { + const { poolAccount } = this.state; + const transferV = E(poolAccount).transfer( + destination, + /** @type {DenomAmount} */ ({ + denom: USDC.denom, + value: amount.value, + }), + ); + return watch(transferV, this.facets.transferHandler, { + destination, + amount, + }); + }, + /** + * @param {Error} error + * @param {{ destination: ChainAddress; payment: Payment<'nat'> }} ctx + */ + onRejected(error, { payment }) { + // TODO return live payment from ctx to LP + log('🚨 advance deposit failed', q(error).toString()); + log('TODO live payment to return to LP', q(payment).toString()); + }, + }, + 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({ - localDenom: M.string(), - poolAccount: M.remotable(), + assetManagerFacet: M.remotable(), + poolAccount: M.or(VowShape, M.remotable()), }), }, ); }; +harden(prepareAdvancerKit); + +/** + * @param {Zone} zone + * @param {AdvancerKitPowers} 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 82cd0e914bf4..875107579518 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -16,6 +16,7 @@ import { defineInertInvitation } from './utils/zoe.js'; const trace = makeTracer('FastUsdc'); /** + * @import {Denom} from '@agoric/orchestration'; * @import {OrchestrationPowers, OrchestrationTools} from '@agoric/orchestration/src/utils/start-helper.js'; * @import {Zone} from '@agoric/zone'; * @import {OperatorKit} from './exos/operator-kit.js'; @@ -26,6 +27,7 @@ const trace = makeTracer('FastUsdc'); * @typedef {{ * poolFee: Amount<'nat'>; * contractFee: Amount<'nat'>; + * usdcDenom: Denom; * }} FastUsdcTerms */ const NatAmountShape = { brand: BrandShape, value: M.nat() }; @@ -33,6 +35,7 @@ export const meta = { customTermsShape: { contractFee: NatAmountShape, poolFee: NatAmountShape, + usdcDenom: M.string(), }, }; harden(meta); @@ -49,6 +52,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => { assert(tools, 'no tools'); const terms = zcf.getTerms(); assert('USDC' in terms.brands, 'no USDC brand'); + assert('usdcDenom' in terms, 'no usdcDenom'); const { makeRecorderKit } = prepareRecorderKitMakers( zone.mapStore('vstorage'), @@ -61,6 +65,10 @@ export const contract = async (zcf, privateArgs, zone, tools) => { const makeAdvancer = prepareAdvancer(zone, { chainHub, log: trace, + USDC: harden({ + brand: terms.brands.USDC, + denom: terms.usdcDenom, + }), statusManager, vowTools, }); @@ -75,7 +83,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => { void observeIteration(subscribeEach(feedKit.public.getEvidenceSubscriber()), { updateState(evidence) { try { - advancer.handleTransactionEvent(evidence); + void advancer.handleTransactionEvent(evidence); } catch (err) { trace('🚨 Error handling transaction event', err); } diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index 4597253b657e..450f09a957ac 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, + log, statusManager, + USDC: harden({ + brand: usdc.brand, + denom: LOCAL_DENOM, + }), 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(pmt); + }; + const rejectBorrowUnderlyingP = () => + borrowUnderlyingPK.reject('Mock unable to borrow.'); + + const advancer = makeAdvancer({ + assetManagerFacet: Far('AssetManager', { + lookupBalance: () => mockPoolBalance, + borrow: (amount: NatAmount) => { + t.log('borrowUnderlying called with', amount); + return borrowUnderlyingPK.promise; + }, + repay: () => Promise.resolve(), + }), + poolAccount: mockAccounts.mockPoolAccount.account, + }); + + 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, }); - 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); - const advancer = makeAdvancer({ poolAccount, localDenom }); - t.truthy(advancer, 'advancer instantiates'); + await resolveBorrowUnderlyingP(usdc.make(mockEvidence.tx.amount)); + mockPoolAccount.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, ); + + // 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/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index 78385061afdd..7dabc59d2a25 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -53,6 +53,7 @@ const startContract = async ( { poolFee: usdc.make(1n), contractFee: usdc.make(1n), + usdcDenom: 'ibc/usdconagoric', }, commonPrivateArgs, ); @@ -242,6 +243,7 @@ test('baggage', async t => { { poolFee: usdc.make(1n), contractFee: usdc.make(1n), + usdcDenom: 'ibc/usdconagoric', }, commonPrivateArgs, ); 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 3b8e2137c8a4..465b6ff494fa 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 760d5257ffc2ecedb8a00a617d23ce017b312323..1617445c53200637477522d88ca173e40031d472 100644 GIT binary patch literal 1620 zcmV-a2CMl&RzV?Ep^jQryW{4~J9H$@5-#Fbc71fdHNLAww^v3YNj zyxe5MWL%h4c;9!vd(XM&oO|B;ppdtOUUn{DVbYWqE9PmjNQ=yLIITJ*A)U)t41-E~ ze(c~7B53?bltkwNEC6@|zzqOD1NaBP1OW~cAWOm;2`}YeV7erv^*5bPgCs#BASq-? zV3Z7jgfz16M9rpoi{+MUdCPQ4xihAGu55DS6y=7+nzZDsWw9bNp3F5cMI*bybYUCJ z-htr=A&UfP!ZGS>a(&(sbr*Wy2SxP~HTkJp-bH@tKm?aI)uoF(6KEqzVe2L4NSjJi za2I~p2L~^Ty3>ea^4kFLLjc%21h66Cy&>SIA>h#<@N^J(EePBQ0>1@;ObB=)q(nB* z8QCZqwnBg%0zM1@w?jZW3_KhLPK5z83|tBWUxa~pM3IW9Qlm=|U_Ao79Ra?J01rli zVidR*1@1(FLowjx7;q~F{2l}L#DVoV@MawNI1YRn2fmL3f5w6FVP(0bx?JqBVc_sE z@cJ-tYZ#bG0Ou3HI|<;^1n_l2k(gE`BEKepKkm{GC4pCx!1W{$8Uc=tD5m?nGBTlL z)8wpCG5KsWF9uD<3B}wodC_80@FrorW!=eCbViSxja{0854wYy`5Jc(*LmHh)#{Gw zqC;W2TcOL1sg^(&+0A=vq@DNa1yNj;!e)z1(ne2F3%H37y3xs1NhKS^&k;i2^6hNY ztN$9;Ptqc@&(Sio9hdcqkEOL(^VGCzHuESq{gi?w>hr>0s!3f`*x9c0jMdy-F4AiA z$yjS^bwqz*`ru_d)0UXC z8I@ueh2N#nukcl}TAgjeJY&Xx*o-?I=z|JObqRDiGugu#kGp+rSDBR7;HPt+PeD>y zb8?H6H=53%LicVf)aBkiJ>2WGaVze!zv9lA-0%wVn(uLD!Msp24O4FBmW8l#WmBrB z-N_i#tMR2Ovl~gY!wGPNkRSbBW|?ur%=a`V zwsztfJe%%2-@W96qZb`1Y+Cf1cvSK5kvZX1gp*tHYvg8iUDUYDof6j0CfE+mhZclR zt!*TCF-lLndTwW&r#dwTzLCe6VwceI(Q868Mj!L~W zc;-x<74_3xGMiJ&*+r&|x$CY=({grpNYh$7-cz>UF=cFulE4uSII1b5`;apAduV9Z zw&^AbHvgqksTS6>4E3~ZqT#-1v@aU#i^lt+!+p_&`i>KLUIPkhP)1Z*_^JkcqyaZI z;I;-J159KTvsB-;$NEKjPiLe_;7A5IsxC0zcY%q%=%nh?(1i?8&j6pP%S`nZno)&< zcQU|Vjs0eUOIby1Z&%2_$^zf0w0(Wo%yqqB5~XtDMaG%es@+FH^rTI%*_1of9V>S_ zKP5pzZn0^j?P41-SXDTt^h~`Qf60eO*@o8!T;x?B5-BjYYVxuReC`9n)vXz+(MNsH zQ*~3844c+lYxQs})))^~OVn<;k#4#1qFz{Z@8l#%;D7#9ltcsQocB*3pL36;!0c7V z4QB7=jy3c83Ug|f+->Xb66iD6s@$GOGP&Ho|90k)lJ#kgJkVk&qhe?J^{N?7Bf0IY zo5|VBE~AnC+u3cUZZET}M)qxI<|TG7t5J;{*v?8x`*b?ZEShK5#!T~5VQ%a5YK|5p SvvZE5w)__v=sk6V6#xLbKqdzO literal 1651 zcmV-(28{VZRzVJ*Fs6;`kgy4{-gq9{GBx$Q2 z3L5W@?ZxXEcXwPTz4e0NR`o(9ganmZ5GMo@;sQM&q#`aLapb^HRfISo!GQ~^sCQ#~ zJ$8&84)|p4_q}i4d-LY)n|-&Cw}f7HE?r~Nlol)IX|YI)%yc-dIwc{UOVhI{$c(482Bv7_)|f7AgV_TZ zCJ9+4KocIN&KB1fEm3!&cYIJ(FHw^pujO6jw+=*bX;WRgNIuX;62jI?%#k*grr<7o z-3JFRh`Q6rV&aDYa3=sfI095gfcHj#Uq^r=LEvly173&$H)6nNG2q7-pv8f6abPPBT!{nk$AK^6z)x{y*@U|6=-+W5 zFb13+18$4~zl;F~5^vp$9`$|=R%F?rEqQt&2WvSr=LRCLCVnhh^a!H4x=Zn4H)!&AI&(`t2~ z^U(@UbbE``mOnWT;0q8{WXKCGkD>yk<~Oq@wVUiNu5 z?)8Bh*Po+BW}l&DW;-tHqK}nnu@ zqFk#kG>9~(fjt1J$-&-K4rE9>p;vKZRsxwgcD&8QT+=v`e3{d$*5RvS#4u*jJ4 zADnUL9DP`UnJ$5@V5WNn<8gP+?K+ck7X|fzPeD>ybF^j38+~(Fq5F3f>I(0^9^rM` zxJ_fp-!x8|-0*sd<9nQ4GSAmc!<1Xvst}e|Hl@1Tos40lnp~+eyHP{~4zo!@KJfV$ zTV>oZd2wUY^Zk|&i>x!oo4{=!5UiNon@8gr-*_vwJ@E`4$@CpnpZCGh3yu^vEqd>v zs1o6!1>sbLqpkQgw0T_@H7>Q|!WwKC@6dd7N$AwtL2?(jf>~G3ZSIRyr^e7j#bn!+ z;oHyL8mkD&hFkvL8`Jjhp5F1Iz3kr}Gp}z5VR`#{(}%>XR4+5R4JJUsYIk98|Y>ZRc;XXdo1pWu?&oLbH z_Vry;>$)Qo<>bVQj5DuQyN`nCNt<;CM(R$+gU(M0kdS-Wz8`NNwxq$T!ZBrQ?o1Lg zGvRBCY}0E4F0$rBA_d0QO5@8%tA=Jhq^)GWE%*4-u0XRcMb zJ-1|fwf+7ZEFz`q(;4zmi=m8)o#of-%w#e|+sV3FoW1O_8M1#TyY15LWtPj3eLI