Skip to content

Commit 6504dc6

Browse files
authored
return funds to LP on AdvanceFailed (#10980)
closes: #10969 ## Description Adds tests to reproduce #10969 and a fix. ### Security Considerations none ### Scaling Considerations none ### Documentation Considerations none ### Testing Considerations per se ### Upgrade Considerations not yet deployed
2 parents fb6a3e0 + e81404e commit 6504dc6

File tree

4 files changed

+225
-19
lines changed

4 files changed

+225
-19
lines changed

packages/fast-usdc/src/exos/advancer.js

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ import { makeFeeTools } from '../utils/fees.js';
3232
* @typedef {{
3333
* chainHub: ChainHub;
3434
* feeConfig: FeeConfig;
35-
* localTransfer: ZoeTools['localTransfer'];
3635
* log?: LogFn;
3736
* statusManager: StatusManager;
3837
* usdc: { brand: Brand<'nat'>; denom: Denom; };
3938
* vowTools: VowTools;
4039
* zcf: ZCF;
40+
* zoeTools: ZoeTools;
4141
* }} AdvancerKitPowers
4242
*/
4343

@@ -69,6 +69,16 @@ const AdvancerKitI = harden({
6969
),
7070
onRejected: M.call(M.error(), AdvancerVowCtxShape).returns(M.undefined()),
7171
}),
72+
withdrawHandler: M.interface('WithdrawHandlerI', {
73+
onFulfilled: M.call(M.undefined(), {
74+
advanceAmount: AnyNatAmountShape,
75+
tmpReturnSeat: M.remotable(),
76+
}).returns(M.undefined()),
77+
onRejected: M.call(M.error(), {
78+
advanceAmount: AnyNatAmountShape,
79+
tmpReturnSeat: M.remotable(),
80+
}).returns(M.undefined()),
81+
}),
7282
});
7383

7484
/**
@@ -98,12 +108,12 @@ export const prepareAdvancerKit = (
98108
{
99109
chainHub,
100110
feeConfig,
101-
localTransfer,
102111
log = makeTracer('Advancer', true),
103112
statusManager,
104113
usdc,
105114
vowTools: { watch, when },
106115
zcf,
116+
zoeTools: { localTransfer, withdrawToSeat },
107117
},
108118
) => {
109119
assertAllDefined({
@@ -289,10 +299,55 @@ export const prepareAdvancerKit = (
289299
* @param {AdvancerVowCtx} ctx
290300
*/
291301
onRejected(error, ctx) {
292-
const { notifier } = this.state;
302+
const { notifier, poolAccount } = this.state;
293303
log('Advance failed', error);
294-
const { advanceAmount: _, ...restCtx } = ctx;
304+
const { advanceAmount, ...restCtx } = ctx;
295305
notifier.notifyAdvancingResult(restCtx, false);
306+
const { zcfSeat: tmpReturnSeat } = zcf.makeEmptySeatKit();
307+
const withdrawV = withdrawToSeat(
308+
// @ts-expect-error LocalAccountMethods vs OrchestrationAccount
309+
poolAccount,
310+
tmpReturnSeat,
311+
harden({ USDC: advanceAmount }),
312+
);
313+
void watch(withdrawV, this.facets.withdrawHandler, {
314+
advanceAmount,
315+
tmpReturnSeat,
316+
});
317+
},
318+
},
319+
withdrawHandler: {
320+
/**
321+
*
322+
* @param {undefined} result
323+
* @param {{ advanceAmount: Amount<'nat'>; tmpReturnSeat: ZCFSeat; }} ctx
324+
*/
325+
onFulfilled(result, { advanceAmount, tmpReturnSeat }) {
326+
const { borrower } = this.state;
327+
try {
328+
borrower.returnToPool(tmpReturnSeat, advanceAmount);
329+
} catch (e) {
330+
// If we reach here, the unused advance funds will remain in `tmpReturnSeat`
331+
// and must be retrieved from recovery sets.
332+
log(
333+
`🚨 return ${q(advanceAmount)} to pool failed. funds remain on "tmpReturnSeat"`,
334+
e,
335+
);
336+
}
337+
tmpReturnSeat.exit();
338+
},
339+
/**
340+
* @param {Error} error
341+
* @param {{ advanceAmount: Amount<'nat'>; tmpReturnSeat: ZCFSeat; }} ctx
342+
*/
343+
onRejected(error, { advanceAmount, tmpReturnSeat }) {
344+
log(
345+
`🚨 withdraw ${q(advanceAmount)} from "poolAccount" to return to pool failed`,
346+
error,
347+
);
348+
// If we reach here, the unused advance funds will remain in the `poolAccount`.
349+
// A contract update will be required to return them to the LiquidityPool.
350+
tmpReturnSeat.exit();
296351
},
297352
},
298353
},

packages/fast-usdc/src/exos/settler.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,9 @@ export const prepareSettler = (
362362
* @param {EvmHash} txHash
363363
*/
364364
onRejected(reason, txHash) {
365-
log('⚠️ forward transfer rejected!', reason, txHash);
365+
// funds remain in `settlementAccount` and must be recovered via a
366+
// contract upgrade
367+
log('🚨 forward transfer rejected!', reason, txHash);
366368
// update status manager, flagging a terminal state that needs to be
367369
// manual intervention or a code update to remediate
368370
statusManager.forwarded(txHash, false);

packages/fast-usdc/src/fast-usdc.contract.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,18 +125,18 @@ export const contract = async (zcf, privateArgs, zone, tools) => {
125125
chainHub,
126126
});
127127

128-
const { localTransfer } = makeZoeTools(zcf, vowTools);
128+
const zoeTools = makeZoeTools(zcf, vowTools);
129129
const makeAdvancer = prepareAdvancer(zone, {
130130
chainHub,
131131
feeConfig,
132-
localTransfer,
133132
usdc: harden({
134133
brand: terms.brands.USDC,
135134
denom: terms.usdcDenom,
136135
}),
137136
statusManager,
138137
vowTools,
139138
zcf,
139+
zoeTools,
140140
});
141141

142142
const makeFeedKit = prepareTransactionFeedKit(zone, zcf);

packages/fast-usdc/test/exos/advancer.test.ts

Lines changed: 161 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,27 +85,33 @@ const createTestExtensions = (t, common: CommonSetup) => {
8585
});
8686

8787
const localTransferVK = vowTools.makeVowKit<void>();
88-
const resolveLocalTransferV = () => {
89-
// pretend funds move from tmpSeat to poolAccount
90-
localTransferVK.resolver.resolve();
91-
};
92-
const rejectLocalTransfeferV = () => {
88+
// pretend funds move from tmpSeat to poolAccount
89+
const resolveLocalTransferV = () => localTransferVK.resolver.resolve();
90+
const rejectLocalTransfeferV = () =>
9391
localTransferVK.resolver.reject(
9492
new Error('One or more deposits failed: simulated error'),
9593
);
96-
};
94+
const withdrawToSeatVK = vowTools.makeVowKit<void>();
95+
const resolveWithdrawToSeatV = () => withdrawToSeatVK.resolver.resolve();
96+
const rejectWithdrawToSeatV = () =>
97+
withdrawToSeatVK.resolver.reject(
98+
new Error('One or more deposits failed: simulated error'),
99+
);
97100
const mockZoeTools = Far('MockZoeTools', {
98101
localTransfer(...args: Parameters<ZoeTools['localTransfer']>) {
99102
trace('ZoeTools.localTransfer called with', args);
100103
return localTransferVK.vow;
101104
},
105+
withdrawToSeat(...args: Parameters<ZoeTools['withdrawToSeat']>) {
106+
trace('ZoeTools.withdrawToSeat called with', args);
107+
return withdrawToSeatVK.vow;
108+
},
102109
});
103110

104111
const feeConfig = makeTestFeeConfig(usdc);
105112
const makeAdvancer = prepareAdvancer(contractZone.subZone('advancer'), {
106113
chainHub,
107114
feeConfig,
108-
localTransfer: mockZoeTools.localTransfer,
109115
log,
110116
statusManager,
111117
usdc: harden({
@@ -115,6 +121,7 @@ const createTestExtensions = (t, common: CommonSetup) => {
115121
vowTools,
116122
// @ts-expect-error mocked zcf
117123
zcf: mockZCF,
124+
zoeTools: mockZoeTools,
118125
});
119126

120127
type NotifyArgs = Parameters<SettlerKit['notifier']['notifyAdvancingResult']>;
@@ -173,6 +180,8 @@ const createTestExtensions = (t, common: CommonSetup) => {
173180
mockNotifyF,
174181
resolveLocalTransferV,
175182
rejectLocalTransfeferV,
183+
resolveWithdrawToSeatV,
184+
rejectWithdrawToSeatV,
176185
},
177186
services: {
178187
advancer,
@@ -344,13 +353,13 @@ test('updates status to OBSERVED if makeChainAddress fails', async t => {
344353
]);
345354
});
346355

347-
test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t => {
356+
test('recovery behavior if Advance Fails (ADVANCE_FAILED)', async t => {
348357
const {
349358
bootstrap: { storage },
350359
extensions: {
351360
services: { advancer, feeTools },
352-
helpers: { inspectLogs, inspectNotifyCalls },
353-
mocks: { mockPoolAccount, resolveLocalTransferV },
361+
helpers: { inspectBorrowerFacetCalls, inspectLogs, inspectNotifyCalls },
362+
mocks: { mockPoolAccount, resolveLocalTransferV, resolveWithdrawToSeatV },
354363
},
355364
brands: { usdc },
356365
} = t.context;
@@ -394,6 +403,132 @@ test('calls notifyAdvancingResult (AdvancedFailed) on failed transfer', async t
394403
false, // this indicates transfer failed
395404
],
396405
]);
406+
407+
// simulate withdrawing `advanceAmount` from PoolAccount to tmpReturnSeat
408+
resolveWithdrawToSeatV();
409+
await eventLoopIteration();
410+
const { returnToPool } = inspectBorrowerFacetCalls();
411+
t.is(
412+
returnToPool.length,
413+
1,
414+
'returnToPool is called after ibc transfer fails',
415+
);
416+
t.deepEqual(
417+
returnToPool[0],
418+
[
419+
Far('MockZCFSeat', { exit: theExit }),
420+
usdc.make(293999999n), // 300000000n net of fees
421+
],
422+
'same amount borrowed is returned to LP',
423+
);
424+
});
425+
426+
// unexpected, terminal state. test that log('🚨') is called
427+
test('logs error if withdrawToSeat fails during AdvanceFailed recovery', async t => {
428+
const {
429+
extensions: {
430+
services: { advancer },
431+
helpers: { inspectLogs, inspectNotifyCalls },
432+
mocks: { mockPoolAccount, resolveLocalTransferV, rejectWithdrawToSeatV },
433+
},
434+
brands: { usdc },
435+
} = t.context;
436+
437+
const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO();
438+
void advancer.handleTransactionEvent({ evidence, risk: {} });
439+
440+
// pretend borrow succeeded and funds were depositing to the LCA
441+
resolveLocalTransferV();
442+
// pretend the IBC Transfer failed
443+
mockPoolAccount.transferVResolver.reject(new Error('transfer failed'));
444+
// pretend withdrawToSeat failed
445+
rejectWithdrawToSeatV();
446+
await eventLoopIteration();
447+
448+
t.deepEqual(inspectLogs(), [
449+
['decoded EUD: osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men'],
450+
['Advance failed', Error('transfer failed')],
451+
[
452+
'🚨 withdraw {"brand":"[Alleged: USDC brand]","value":"[146999999n]"} from "poolAccount" to return to pool failed',
453+
Error('One or more deposits failed: simulated error'),
454+
],
455+
]);
456+
457+
// ensure Settler is notified of failed advance
458+
t.like(inspectNotifyCalls(), [
459+
[
460+
{
461+
txHash: evidence.txHash,
462+
forwardingAddress: evidence.tx.forwardingAddress,
463+
},
464+
false, // indicates transfer failed
465+
],
466+
]);
467+
});
468+
469+
test('logs error if returnToPool fails during AdvanceFailed recovery', async t => {
470+
const {
471+
brands: { usdc },
472+
extensions: {
473+
services: { makeAdvancer },
474+
helpers: { inspectLogs, inspectNotifyCalls },
475+
mocks: {
476+
mockPoolAccount,
477+
mockNotifyF,
478+
resolveLocalTransferV,
479+
resolveWithdrawToSeatV,
480+
},
481+
},
482+
} = t.context;
483+
484+
const mockBorrowerFacet = Far('LiquidityPool Borrow Facet', {
485+
borrow: (seat: ZCFSeat, amount: NatAmount) => {
486+
// note: will not be tracked by `inspectBorrowerFacetCalls`
487+
},
488+
returnToPool: (seat: ZCFSeat, amount: NatAmount) => {
489+
throw new Error('returnToPool failed');
490+
},
491+
});
492+
493+
// make a new advancer that intentionally throws during returnToPool
494+
const advancer = makeAdvancer({
495+
borrower: mockBorrowerFacet,
496+
notifier: mockNotifyF,
497+
poolAccount: mockPoolAccount.account,
498+
intermediateRecipient,
499+
settlementAddress,
500+
});
501+
502+
const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO();
503+
void advancer.handleTransactionEvent({ evidence, risk: {} });
504+
505+
// pretend borrow succeeded and funds were depositing to the LCA
506+
resolveLocalTransferV();
507+
// pretend the IBC Transfer failed
508+
mockPoolAccount.transferVResolver.reject(new Error('transfer failed'));
509+
// pretend withdrawToSeat succeeded
510+
resolveWithdrawToSeatV();
511+
await eventLoopIteration();
512+
513+
t.deepEqual(inspectLogs(), [
514+
['decoded EUD: osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men'],
515+
['Advance failed', Error('transfer failed')],
516+
[
517+
'🚨 return {"brand":"[Alleged: USDC brand]","value":"[146999999n]"} to pool failed. funds remain on "tmpReturnSeat"',
518+
Error('returnToPool failed'),
519+
],
520+
]);
521+
522+
// ensure Settler is notified of failed advance
523+
t.like(inspectNotifyCalls(), [
524+
[
525+
{
526+
txHash: evidence.txHash,
527+
forwardingAddress: evidence.tx.forwardingAddress,
528+
},
529+
false, // indicates transfer failed
530+
],
531+
]);
397532
});
398533

399534
test('updates status to OBSERVED if pre-condition checks fail', async t => {
@@ -785,8 +920,8 @@ test('notifies of advance failure if bank send fails', async t => {
785920
const {
786921
extensions: {
787922
services: { advancer },
788-
helpers: { inspectLogs, inspectNotifyCalls },
789-
mocks: { mockPoolAccount, resolveLocalTransferV },
923+
helpers: { inspectLogs, inspectBorrowerFacetCalls, inspectNotifyCalls },
924+
mocks: { mockPoolAccount, resolveLocalTransferV, resolveWithdrawToSeatV },
790925
},
791926
brands: { usdc },
792927
} = t.context;
@@ -820,4 +955,18 @@ test('notifies of advance failure if bank send fails', async t => {
820955
false, // indicates send failed
821956
],
822957
]);
958+
959+
// verify funds are returned to pool
960+
resolveWithdrawToSeatV();
961+
await eventLoopIteration();
962+
const { returnToPool } = inspectBorrowerFacetCalls();
963+
t.is(returnToPool.length, 1, 'returnToPool is called after bank send fails');
964+
t.deepEqual(
965+
returnToPool[0],
966+
[
967+
Far('MockZCFSeat', { exit: theExit }),
968+
usdc.make(244999999n), // 250000000n net of fees
969+
],
970+
'same amount borrowed is returned to LP',
971+
);
823972
});

0 commit comments

Comments
 (0)