From 3c9d85974b4c1a4c65d37b9852be4a8fe8dd01a3 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 10 Feb 2025 13:32:46 -0500 Subject: [PATCH] chore: return to pool on advance failure --- packages/fast-usdc/src/exos/advancer.js | 63 +++++++++++++++++-- packages/fast-usdc/src/fast-usdc.contract.js | 4 +- packages/fast-usdc/test/exos/advancer.test.ts | 54 +++++++++++++--- 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index 1708b0193d8..dc2ce103002 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -32,12 +32,12 @@ import { makeFeeTools } from '../utils/fees.js'; * @typedef {{ * chainHub: ChainHub; * feeConfig: FeeConfig; - * localTransfer: ZoeTools['localTransfer']; * log?: LogFn; * statusManager: StatusManager; * usdc: { brand: Brand<'nat'>; denom: Denom; }; * vowTools: VowTools; * zcf: ZCF; + * zoeTools: ZoeTools; * }} AdvancerKitPowers */ @@ -69,6 +69,16 @@ const AdvancerKitI = harden({ ), onRejected: M.call(M.error(), AdvancerVowCtxShape).returns(M.undefined()), }), + withdrawHandler: M.interface('WithdrawHandlerI', { + onFulfilled: M.call(M.undefined(), { + advanceAmount: AnyNatAmountShape, + tmpReturnSeat: M.remotable(), + }).returns(M.undefined()), + onRejected: M.call(M.error(), { + advanceAmount: AnyNatAmountShape, + tmpReturnSeat: M.remotable(), + }).returns(M.undefined()), + }), }); /** @@ -98,12 +108,12 @@ export const prepareAdvancerKit = ( { chainHub, feeConfig, - localTransfer, log = makeTracer('Advancer', true), statusManager, usdc, vowTools: { watch, when }, zcf, + zoeTools: { localTransfer, withdrawToSeat }, }, ) => { assertAllDefined({ @@ -287,10 +297,55 @@ export const prepareAdvancerKit = ( * @param {AdvancerVowCtx} ctx */ onRejected(error, ctx) { - const { notifier } = this.state; + const { notifier, poolAccount } = this.state; log('Advance failed', error); - const { advanceAmount: _, ...restCtx } = ctx; + const { advanceAmount, ...restCtx } = ctx; notifier.notifyAdvancingResult(restCtx, false); + const { zcfSeat: tmpReturnSeat } = zcf.makeEmptySeatKit(); + const withdrawV = withdrawToSeat( + // @ts-expect-error LocalAccountMethods vs OrchestrationAccount + poolAccount, + tmpReturnSeat, + harden({ USDC: advanceAmount }), + ); + void watch(withdrawV, this.facets.withdrawHandler, { + advanceAmount, + tmpReturnSeat, + }); + }, + }, + withdrawHandler: { + /** + * + * @param {undefined} result + * @param {{ advanceAmount: Amount<'nat'>; tmpReturnSeat: ZCFSeat; }} ctx + */ + onFulfilled(result, { advanceAmount, tmpReturnSeat }) { + const { borrower } = this.state; + try { + borrower.returnToPool(tmpReturnSeat, advanceAmount); + } catch (e) { + // If we reach here, the unused advance funds will remain in `tmpReturnSeat` + // and must be retrieved from recovery sets. + log( + `🚨 return ${q(advanceAmount)} to pool failed. funds remain on "tmpReturnSeat"`, + e, + ); + } + tmpReturnSeat.exit(); + }, + /** + * @param {Error} error + * @param {{ advanceAmount: Amount<'nat'>; tmpReturnSeat: ZCFSeat; }} ctx + */ + onRejected(error, { advanceAmount, tmpReturnSeat }) { + log( + `🚨 withdraw ${q(advanceAmount)} from "poolAccount" to return to pool failed`, + error, + ); + // If we reach here, the unused advance funds will remain in the `poolAccount`. + // A contract update will be required to return them to the LiquidityPool. + tmpReturnSeat.exit(); }, }, }, diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index 7c24f601523..a6ef4a05cfd 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -125,11 +125,10 @@ export const contract = async (zcf, privateArgs, zone, tools) => { chainHub, }); - const { localTransfer } = makeZoeTools(zcf, vowTools); + const zoeTools = makeZoeTools(zcf, vowTools); const makeAdvancer = prepareAdvancer(zone, { chainHub, feeConfig, - localTransfer, usdc: harden({ brand: terms.brands.USDC, denom: terms.usdcDenom, @@ -137,6 +136,7 @@ export const contract = async (zcf, privateArgs, zone, tools) => { statusManager, vowTools, zcf, + zoeTools, }); const makeFeedKit = prepareTransactionFeedKit(zone, zcf); diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index ccb97d3c58b..aacacaecd6f 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -81,27 +81,33 @@ const createTestExtensions = (t, common: CommonSetup) => { }); const localTransferVK = vowTools.makeVowKit(); - const resolveLocalTransferV = () => { - // pretend funds move from tmpSeat to poolAccount - localTransferVK.resolver.resolve(); - }; - const rejectLocalTransfeferV = () => { + // pretend funds move from tmpSeat to poolAccount + const resolveLocalTransferV = () => localTransferVK.resolver.resolve(); + const rejectLocalTransfeferV = () => localTransferVK.resolver.reject( new Error('One or more deposits failed: simulated error'), ); - }; + const withdrawToSeatVK = vowTools.makeVowKit(); + const resolveWithdrawToSeatV = () => withdrawToSeatVK.resolver.resolve(); + const rejectWithdrawToSeatV = () => + withdrawToSeatVK.resolver.reject( + new Error('One or more deposits failed: simulated error'), + ); const mockZoeTools = Far('MockZoeTools', { localTransfer(...args: Parameters) { trace('ZoeTools.localTransfer called with', args); return localTransferVK.vow; }, + withdrawToSeat(...args: Parameters) { + trace('ZoeTools.withdrawToSeat called with', args); + return withdrawToSeatVK.vow; + }, }); const feeConfig = makeTestFeeConfig(usdc); const makeAdvancer = prepareAdvancer(contractZone.subZone('advancer'), { chainHub, feeConfig, - localTransfer: mockZoeTools.localTransfer, log, statusManager, usdc: harden({ @@ -111,6 +117,7 @@ const createTestExtensions = (t, common: CommonSetup) => { vowTools, // @ts-expect-error mocked zcf zcf: mockZCF, + zoeTools: mockZoeTools, }); type NotifyArgs = Parameters; @@ -169,6 +176,8 @@ const createTestExtensions = (t, common: CommonSetup) => { mockNotifyF, resolveLocalTransferV, rejectLocalTransfeferV, + resolveWithdrawToSeatV, + rejectWithdrawToSeatV, }, services: { advancer, @@ -340,13 +349,13 @@ test('updates status to OBSERVED if makeChainAddress fails', async t => { ]); }); -test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t => { +test('recovery behavior if Advance Fails (ADVANCE_FAILED)', async t => { const { bootstrap: { storage }, extensions: { services: { advancer, feeTools }, - helpers: { inspectLogs, inspectNotifyCalls }, - mocks: { mockPoolAccount, resolveLocalTransferV }, + helpers: { inspectBorrowerFacetCalls, inspectLogs, inspectNotifyCalls }, + mocks: { mockPoolAccount, resolveLocalTransferV, resolveWithdrawToSeatV }, }, brands: { usdc }, } = t.context; @@ -390,8 +399,31 @@ test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t false, // this indicates transfer failed ], ]); + + // simulate withdrawing `advanceAmount` from PoolAccount to tmpReturnSeat + resolveWithdrawToSeatV(); + await eventLoopIteration(); + const { returnToPool } = inspectBorrowerFacetCalls(); + t.is( + returnToPool.length, + 1, + 'returnToPool is called after ibc transfer fails', + ); + t.deepEqual( + returnToPool[0], + [ + Far('MockZCFSeat', { exit: theExit }), + usdc.make(293999999n), // 300000000n net of fees + ], + 'same amount borrowed is returned to LP', + ); }); +// unexpected, terminal state. test that log('🚨') is called +test.todo('witdrawToSeat fails during AdvanceFailed recovery'); +// unexpected, terminal state. test that log('🚨') is called +test.todo('returnToPool fails during AdvanceFailed recovery'); + test('updates status to OBSERVED if pre-condition checks fail', async t => { const { bootstrap: { storage }, @@ -816,4 +848,6 @@ test('notifies of advance failure if bank send fails', async t => { false, // indicates send failed ], ]); + + // TODO: returnToPool is called });