diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index ceb4bc09ef8..902cc890283 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -6,12 +6,14 @@ import { VowShape } from '@agoric/vow'; import { q } from '@endo/errors'; import { E } from '@endo/far'; import { M } from '@endo/patterns'; -import { CctpTxEvidenceShape, EudParamShape } from '../type-guards.js'; +import { + CctpTxEvidenceShape, + EudParamShape, + EvmHashShape, +} from '../type-guards.js'; import { addressTools } from '../utils/address.js'; import { makeFeeTools } from '../utils/fees.js'; -const { isGTE } = AmountMath; - /** * @import {HostInterface} from '@agoric/async-flow'; * @import {NatAmount} from '@agoric/ertp'; @@ -19,7 +21,7 @@ const { isGTE } = AmountMath; * @import {ZoeTools} from '@agoric/orchestration/src/utils/zoe-tools.js'; * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; - * @import {CctpTxEvidence, FeeConfig, LogFn} from '../types.js'; + * @import {CctpTxEvidence, EvmHash, FeeConfig, LogFn, NobleAddress} from '../types.js'; * @import {StatusManager} from './status-manager.js'; * @import {LiquidityPoolKit} from './liquidity-pool.js'; */ @@ -46,12 +48,16 @@ const AdvancerKitI = harden({ onFulfilled: M.call(M.undefined(), { amount: AmountShape, destination: ChainAddressShape, + forwardingAddress: M.string(), tmpSeat: M.remotable(), + txHash: EvmHashShape, }).returns(VowShape), onRejected: M.call(M.error(), { amount: AmountShape, destination: ChainAddressShape, + forwardingAddress: M.string(), tmpSeat: M.remotable(), + txHash: EvmHashShape, }).returns(), }), transferHandler: M.interface('TransferHandlerI', { @@ -59,14 +65,27 @@ const AdvancerKitI = harden({ onFulfilled: M.call(M.undefined(), { amount: AmountShape, destination: ChainAddressShape, + forwardingAddress: M.string(), + txHash: EvmHashShape, }).returns(M.undefined()), onRejected: M.call(M.error(), { amount: AmountShape, destination: ChainAddressShape, + forwardingAddress: M.string(), + txHash: EvmHashShape, }).returns(M.undefined()), }), }); +/** + * @typedef {{ + * amount: NatAmount; + * destination: ChainAddress; + * forwardingAddress: NobleAddress; + * txHash: EvmHash; + * }} AdvancerVowCtx + */ + /** * @param {Zone} zone * @param {AdvancerKitPowers} caps @@ -100,6 +119,7 @@ export const prepareAdvancerKit = ( AdvancerKitI, /** * @param {{ + * notifyFacet: import('./settler.js').SettlerKit['notify']; * borrowerFacet: LiquidityPoolKit['borrower']; * poolAccount: HostInterface>; * }} config @@ -120,51 +140,32 @@ export const prepareAdvancerKit = ( async handleTransactionEvent(evidence) { await null; try { + if (statusManager.hasBeenObserved(evidence)) { + log('txHash already seen:', evidence.txHash); + return; + } + const { borrowerFacet, poolAccount } = this.state; const { recipientAddress } = evidence.aux; + // throws if EUD is not found const { EUD } = addressTools.getQueryParams( recipientAddress, EudParamShape, ); - - // this will throw if the bech32 prefix is not found, but is handled by the catch + // throws if the bech32 prefix is not found const destination = chainHub.makeChainAddress(EUD); + const requestedAmount = toAmount(evidence.tx.amount); + // throws if requested does not exceed fees const advanceAmount = feeTools.calculateAdvance(requestedAmount); - // TODO: consider skipping and using `borrow()`s internal balance check - const poolBalance = borrowerFacet.getBalance(); - if (!isGTE(poolBalance, requestedAmount)) { - log( - `Insufficient pool funds`, - `Requested ${q(advanceAmount)} but only have ${q(poolBalance)}`, - ); - statusManager.observe(evidence); - return; - } - - 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; - } - const { zcfSeat: tmpSeat } = zcf.makeEmptySeatKit(); const amountKWR = harden({ USDC: advanceAmount }); - try { - borrowerFacet.borrow(tmpSeat, amountKWR); - } catch (e) { - // We do not expect this to fail since there are no turn boundaries - // between .getBalance() and .borrow(). - // We catch to report outside of the normal error flow since this is - // not expected. - log('🚨 advance borrow failed', q(e).toString()); - } + // throws if the pool has insufficient funds + borrowerFacet.borrow(tmpSeat, amountKWR); + + // this cannot throw since `.isSeen()` is called in the same turn + statusManager.advance(evidence); const depositV = localTransfer( tmpSeat, @@ -175,7 +176,9 @@ export const prepareAdvancerKit = ( void watch(depositV, this.facets.depositHandler, { amount: advanceAmount, destination, + forwardingAddress: evidence.tx.forwardingAddress, tmpSeat, + txHash: evidence.txHash, }); } catch (e) { log('Advancer error:', q(e).toString()); @@ -186,10 +189,11 @@ export const prepareAdvancerKit = ( depositHandler: { /** * @param {undefined} result - * @param {{ amount: Amount<'nat'>; destination: ChainAddress; tmpSeat: ZCFSeat }} ctx + * @param {AdvancerVowCtx & { tmpSeat: ZCFSeat }} ctx */ - onFulfilled(result, { amount, destination }) { + onFulfilled(result, ctx) { const { poolAccount } = this.state; + const { amount, destination, forwardingAddress, txHash } = ctx; const transferV = E(poolAccount).transfer(destination, { denom: usdc.denom, value: amount.value, @@ -197,11 +201,13 @@ export const prepareAdvancerKit = ( return watch(transferV, this.facets.transferHandler, { destination, amount, + forwardingAddress, + txHash, }); }, /** * @param {Error} error - * @param {{ amount: Amount<'nat'>; destination: ChainAddress; tmpSeat: ZCFSeat }} ctx + * @param {AdvancerVowCtx & { tmpSeat: ZCFSeat }} ctx */ onRejected(error, { tmpSeat }) { // TODO return seat allocation from ctx to LP? @@ -217,25 +223,44 @@ export const prepareAdvancerKit = ( transferHandler: { /** * @param {undefined} result TODO confirm this is not a bigint (sequence) - * @param {{ destination: ChainAddress; amount: NatAmount; }} ctx + * @param {AdvancerVowCtx} ctx */ - onFulfilled(result, { destination, amount }) { - // TODO vstorage update? We don't currently have a status for - // Advanced + transferV settled + onFulfilled(result, ctx) { + const { notifyFacet } = this.state; + const { amount, destination, forwardingAddress, txHash } = ctx; log( 'Advance transfer fulfilled', q({ amount, destination, result }).toString(), ); + notifyFacet.notifyAdvancingResult( + txHash, + forwardingAddress, + amount.value, + destination.value, + true, + ); }, - onRejected(error) { - // TODO #10510 (comprehensive error testing) determine - // course of action here. This might fail due to timeout. + /** + * @param {Error} error + * @param {AdvancerVowCtx} ctx + */ + onRejected(error, ctx) { + const { notifyFacet } = this.state; + const { amount, destination, forwardingAddress, txHash } = ctx; log('Advance transfer rejected', q(error).toString()); + notifyFacet.notifyAdvancingResult( + txHash, + forwardingAddress, + amount.value, + destination.value, + false, + ); }, }, }, { stateShape: harden({ + notifyFacet: M.remotable(), borrowerFacet: M.remotable(), poolAccount: M.remotable(), }), diff --git a/packages/fast-usdc/src/exos/liquidity-pool.js b/packages/fast-usdc/src/exos/liquidity-pool.js index b280ab08412..d8b2991a4df 100644 --- a/packages/fast-usdc/src/exos/liquidity-pool.js +++ b/packages/fast-usdc/src/exos/liquidity-pool.js @@ -1,4 +1,4 @@ -import { AmountMath, AmountShape } from '@agoric/ertp'; +import { AmountMath } from '@agoric/ertp'; import { makeRecorderTopic, TopicsRecordShape, @@ -84,7 +84,6 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { 'Liquidity Pool', { borrower: M.interface('borrower', { - getBalance: M.call().returns(AmountShape), borrow: M.call( SeatShape, harden({ USDC: makeNatAmountShape(USDC, 1n) }), @@ -152,10 +151,6 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { }, { borrower: { - getBalance() { - const { poolSeat } = this.state; - return poolSeat.getAmountAllocated('USDC', USDC); - }, /** * @param {ZCFSeat} toSeat * @param {{ USDC: Amount<'nat'>}} amountKWR diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index fb21deabf86..fcfbe85c7ef 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -8,10 +8,13 @@ import { Far } from '@endo/pass-style'; import { makePromiseKit } from '@endo/promise-kit'; import type { NatAmount } from '@agoric/ertp'; import { type ZoeTools } from '@agoric/orchestration/src/utils/zoe-tools.js'; +import { q } from '@endo/errors'; import { PendingTxStatus } from '../../src/constants.js'; import { prepareAdvancer } from '../../src/exos/advancer.js'; +import type { SettlerKit } from '../../src/exos/settler.js'; import { prepareStatusManager } from '../../src/exos/status-manager.js'; - +import { makeFeeTools } from '../../src/utils/fees.js'; +import { addressTools } from '../../src/utils/address.js'; import { commonSetup } from '../supports.js'; import { MockCctpTxEvidences } from '../fixtures.js'; import { @@ -33,7 +36,6 @@ const createTestExtensions = (t, common: CommonSetup) => { bootstrap: { rootZone, vowTools }, facadeServices: { chainHub }, brands: { usdc }, - utils: { pourPayment }, } = common; const { log, inspectLogs } = makeTestLogger(t.log); @@ -83,33 +85,33 @@ const createTestExtensions = (t, common: CommonSetup) => { zcf: mockZCF, }); - /** 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); - }; + type NotifyArgs = Parameters; + const notifyAdvancingResultCalls: NotifyArgs[] = []; + const mockNotifyF = Far('Settler Notify Facet', { + notifyAdvancingResult: (...args: NotifyArgs) => { + console.log('Settler.notifyAdvancingResult called with', args); + notifyAdvancingResultCalls.push(args); + }, + }); - const borrowUnderlyingPK = makePromiseKit(); - const resolveBorrowUnderlyingP = () => { - // pretend funds are allocated to tmpSeat provided to borrow - return borrowUnderlyingPK.resolve(); - }; - const rejectBorrowUnderlyingP = () => - borrowUnderlyingPK.reject('Mock unable to borrow.'); + const mockBorrowerF = Far('LiquidityPool Borrow Facet', { + borrow: (seat: ZCFSeat, amounts: { USDC: NatAmount }) => { + console.log('LP.borrow called with', amounts); + }, + }); + + const mockBorrowerErrorF = Far('LiquidityPool Borrow Facet', { + borrow: (seat: ZCFSeat, amounts: { USDC: NatAmount }) => { + console.log('LP.borrow called with', amounts); + throw new Error( + `Cannot borrow. Requested ${q(amounts.USDC)} must be less than pool balance ${q(usdc.make(1n))}.`, + ); + }, + }); const advancer = makeAdvancer({ - borrowerFacet: Far('LiquidityPool Borrow Facet', { - getBalance: () => mockPoolBalance, - borrow: (seat: ZCFSeat, amounts: { USDC: NatAmount }) => { - t.log('borrowUnderlying called with', amounts); - return borrowUnderlyingPK.promise; - }, - repay: () => Promise.resolve(), - }), + borrowerFacet: mockBorrowerF, + notifyFacet: mockNotifyF, poolAccount: mockAccounts.mockPoolAccount.account, }); @@ -120,18 +122,19 @@ const createTestExtensions = (t, common: CommonSetup) => { }, helpers: { inspectLogs, + inspectNotifyCalls: () => harden(notifyAdvancingResultCalls), }, mocks: { ...mockAccounts, - setMockPoolBalance, - resolveBorrowUnderlyingP, - rejectBorrowUnderlyingP, + mockBorrowerErrorF, + mockNotifyF, resolveLocalTransferV, }, services: { advancer, makeAdvancer, statusManager, + feeTools: makeFeeTools(feeConfig), }, } as const; }; @@ -150,29 +153,24 @@ test.beforeEach(async t => { }; }); -test('updates status to ADVANCED in happy path', async t => { +test('updates status to ADVANCING in happy path', async t => { const { extensions: { - services: { advancer, statusManager }, - helpers: { inspectLogs }, - mocks: { - mockPoolAccount, - resolveBorrowUnderlyingP, - resolveLocalTransferV, - }, + services: { advancer, feeTools, statusManager }, + helpers: { inspectLogs, inspectNotifyCalls }, + mocks: { mockPoolAccount, resolveLocalTransferV }, }, brands: { usdc }, } = t.context; const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); - const handleTxP = advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent(mockEvidence); - resolveBorrowUnderlyingP(); + // pretend borrow succeeded and funds were depositing to the LCA resolveLocalTransferV(); - await eventLoopIteration(); + // pretend the IBC Transfer settled mockPoolAccount.transferVResolver.resolve(); - - await handleTxP; + // wait for handleTransactionEvent to do work await eventLoopIteration(); const entries = statusManager.lookupPending( @@ -182,7 +180,7 @@ test('updates status to ADVANCED in happy path', async t => { t.deepEqual( entries, - [{ ...mockEvidence, status: PendingTxStatus.Advanced }], + [{ ...mockEvidence, status: PendingTxStatus.Advancing }], 'ADVANCED status in happy path', ); @@ -190,63 +188,39 @@ test('updates status to ADVANCED in happy path', async t => { 'Advance transfer fulfilled', '{"amount":{"brand":"[Alleged: USDC brand]","value":"[146999999n]"},"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 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":"[294999999n]"} but only have {"brand":"[Alleged: USDC brand]","value":"[1n]"}', + // We expect to see an `Advanced` update, but that is now Settler's job. + // but we can ensure it's called + t.deepEqual(inspectNotifyCalls(), [ + [ + mockEvidence.txHash, + mockEvidence.tx.forwardingAddress, + feeTools.calculateAdvance(usdc.make(mockEvidence.tx.amount)).value, + addressTools.getQueryParams(mockEvidence.aux.recipientAddress).EUD, + true, // indicates transfer succeeded + ], ]); }); -test('updates status to OBSERVED if balance query fails', async t => { +test('updates status to OBSERVED on insufficient pool funds', async t => { const { extensions: { services: { makeAdvancer, statusManager }, helpers: { inspectLogs }, - mocks: { mockPoolAccount }, + mocks: { mockPoolAccount, mockBorrowerErrorF, mockNotifyF }, }, - brands: { usdc }, } = t.context; // make a new advancer that intentionally throws const advancer = makeAdvancer({ - // @ts-expect-error mock - borrowerFacet: Far('LiquidityPool Borrow Facet', { - getBalance: () => { - throw new Error('lookupBalance failed'); - }, - }), + borrowerFacet: mockBorrowerErrorF, + notifyFacet: mockNotifyF, poolAccount: mockPoolAccount.account, }); const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); - await advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent(mockEvidence); + await eventLoopIteration(); const entries = statusManager.lookupPending( mockEvidence.tx.forwardingAddress, @@ -256,12 +230,12 @@ test('updates status to OBSERVED if balance query fails', async t => { t.deepEqual( entries, [{ ...mockEvidence, status: PendingTxStatus.Observed }], - 'OBSERVED status on balance query failure', + 'OBSERVED status on insufficient pool funds', ); t.deepEqual(inspectLogs(0), [ 'Advancer error:', - '"[Error: lookupBalance failed]"', + '"[Error: Cannot borrow. Requested {\\"brand\\":\\"[Alleged: USDC brand]\\",\\"value\\":\\"[294999999n]\\"} must be less than pool balance {\\"brand\\":\\"[Alleged: USDC brand]\\",\\"value\\":\\"[1n]\\"}.]"', ]); }); @@ -293,29 +267,21 @@ test('updates status to OBSERVED if makeChainAddress fails', async t => { ]); }); -// TODO #10510 this failure should be handled differently -test('does not update status on failed transfer', async t => { +test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t => { const { extensions: { - services: { advancer, statusManager }, - helpers: { inspectLogs }, - mocks: { - mockPoolAccount, - resolveBorrowUnderlyingP, - resolveLocalTransferV, - }, + services: { advancer, feeTools, statusManager }, + helpers: { inspectLogs, inspectNotifyCalls }, + mocks: { mockPoolAccount, resolveLocalTransferV }, }, brands: { usdc }, } = t.context; const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); - const handleTxP = advancer.handleTransactionEvent(mockEvidence); + void advancer.handleTransactionEvent(mockEvidence); - resolveBorrowUnderlyingP(); + // pretend borrow and deposit to LCA succeed resolveLocalTransferV(); - mockPoolAccount.transferVResolver.reject(new Error('simulated error')); - - await handleTxP; await eventLoopIteration(); const entries = statusManager.lookupPending( @@ -323,20 +289,33 @@ test('does not update status on failed transfer', async t => { mockEvidence.tx.amount, ); - // TODO, this failure should be handled differently t.deepEqual( entries, - [{ ...mockEvidence, status: PendingTxStatus.Advanced }], - 'tx status is still ADVANCED even though advance failed', + [{ ...mockEvidence, status: PendingTxStatus.Advancing }], + 'tx is Advancing', ); + mockPoolAccount.transferVResolver.reject(new Error('simulated error')); + await eventLoopIteration(); + t.deepEqual(inspectLogs(0), [ 'Advance transfer rejected', '"[Error: simulated error]"', ]); + + // We expect to see an `AdvancedFailed` update, but that is now Settler's job. + // but we can ensure it's called + t.deepEqual(inspectNotifyCalls(), [ + [ + mockEvidence.txHash, + mockEvidence.tx.forwardingAddress, + feeTools.calculateAdvance(usdc.make(mockEvidence.tx.amount)).value, + addressTools.getQueryParams(mockEvidence.aux.recipientAddress).EUD, + false, // this indicates transfer succeeded + ], + ]); }); -// TODO: might be consideration of `EventFeed` test('updates status to OBSERVED if pre-condition checks fail', async t => { const { extensions: { @@ -371,22 +350,16 @@ test('will not advance same txHash:chainId evidence twice', async t => { extensions: { services: { advancer }, helpers: { inspectLogs }, - mocks: { - mockPoolAccount, - resolveBorrowUnderlyingP, - resolveLocalTransferV, - }, + mocks: { mockPoolAccount, resolveLocalTransferV }, }, } = t.context; const mockEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); // First attempt - const handleTxP = advancer.handleTransactionEvent(mockEvidence); - resolveBorrowUnderlyingP(); + void advancer.handleTransactionEvent(mockEvidence); resolveLocalTransferV(); mockPoolAccount.transferVResolver.resolve(); - await handleTxP; await eventLoopIteration(); t.deepEqual(inspectLogs(0), [ @@ -395,12 +368,14 @@ test('will not advance same txHash:chainId evidence twice', async t => { ]); // Second attempt - await advancer.handleTransactionEvent(mockEvidence); - + void advancer.handleTransactionEvent(mockEvidence); + await eventLoopIteration(); t.deepEqual(inspectLogs(1), [ - 'Advancer error:', - '"[Error: Transaction already seen: \\"seenTx:[\\\\\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\\\\\",1]\\"]"', + 'txHash already seen:', + '0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702', ]); }); -test.todo('zoeTools.localTransfer fails to deposit borrowed USDC to LOA'); +test.todo( + '#10510 zoeTools.localTransfer fails to deposit borrowed USDC to LOA', +);