Skip to content

Commit

Permalink
chore: fusdc recovery paths (#10659)
Browse files Browse the repository at this point in the history
closes: #10624

## Description
- handles potential failure in `zoeTools.localTransfer` during FUSDC Advance flow by returning funds to LP
- documents why it's OK to fail during a potential `zoeTools.withdrawToSeat` failure in `.disperse()`
- removes `zcf.shutdownWithFailure()` in catch blocks around `zcf.atomicRearrange`. This is only necessary when commits are not atomic.

### Security Considerations
- Improves handling of Payments during unexpected, but potential, failure paths.
- Ensures FUSDC contract will not shutdown during unexpected failures.

### Scaling Considerations
N/A

### Documentation Considerations
N/A

### Testing Considerations
Includes a unit test that ensures the advancer performs the expected calls after `zoeTools.localTransfer` fails.

Does not include unit tests for the new liquidity pool `borrower.returnToPool` as those have been deemed too expensive. The risk here seems low as the call is effectively passed through to `repayer.repay`, which has tests, modulo the KWR arrangement which is protected by the interface guard.

### Upgrade Considerations
None, FUSDC is unreleased.
  • Loading branch information
mergify[bot] authored Dec 12, 2024
2 parents 059601c + 50c1bd1 commit 4feddb0
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 106 deletions.
46 changes: 28 additions & 18 deletions packages/fast-usdc/src/exos/advancer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { assertAllDefined, makeTracer } from '@agoric/internal';
import { AnyNatAmountShape, ChainAddressShape } from '@agoric/orchestration';
import { pickFacet } from '@agoric/vat-data';
import { VowShape } from '@agoric/vow';
import { q } from '@endo/errors';
import { E } from '@endo/far';
import { M } from '@endo/patterns';
import {
Expand Down Expand Up @@ -153,6 +152,7 @@ export const prepareAdvancerKit = (
recipientAddress,
EudParamShape,
);
log(`decoded EUD: ${EUD}`);
// throws if the bech32 prefix is not found
const destination = chainHub.makeChainAddress(EUD);

Expand All @@ -161,9 +161,8 @@ export const prepareAdvancerKit = (
const advanceAmount = feeTools.calculateAdvance(fullAmount);

const { zcfSeat: tmpSeat } = zcf.makeEmptySeatKit();
const amountKWR = harden({ USDC: advanceAmount });
// throws if the pool has insufficient funds
borrowerFacet.borrow(tmpSeat, amountKWR);
borrowerFacet.borrow(tmpSeat, advanceAmount);

// this cannot throw since `.isSeen()` is called in the same turn
statusManager.advance(evidence);
Expand All @@ -172,7 +171,7 @@ export const prepareAdvancerKit = (
tmpSeat,
// @ts-expect-error LocalAccountMethods vs OrchestrationAccount
poolAccount,
amountKWR,
harden({ USDC: advanceAmount }),
);
void watch(depositV, this.facets.depositHandler, {
fullAmount,
Expand All @@ -182,8 +181,8 @@ export const prepareAdvancerKit = (
tmpSeat,
txHash: evidence.txHash,
});
} catch (e) {
log('Advancer error:', q(e).toString());
} catch (error) {
log('Advancer error:', error);
statusManager.observe(evidence);
}
},
Expand Down Expand Up @@ -212,18 +211,28 @@ export const prepareAdvancerKit = (
});
},
/**
* We do not expect this to be a common failure. it should only occur
* if USDC is not registered in vbank or the tmpSeat has less than
* `advanceAmount`.
*
* If we do hit this path, we return funds to the Liquidity Pool and
* notify of Advancing failure.
*
* @param {Error} error
* @param {AdvancerVowCtx & { tmpSeat: ZCFSeat }} ctx
*/
onRejected(error, { tmpSeat }) {
// TODO return seat allocation from ctx to LP?
log('🚨 advance deposit failed', q(error).toString());
// TODO #10510 (comprehensive error testing) determine
// course of action here
onRejected(error, { tmpSeat, advanceAmount, ...restCtx }) {
log(
'TODO live payment on seat to return to LP',
q(tmpSeat).toString(),
'⚠️ deposit to localOrchAccount failed, attempting to return payment to LP',
error,
);
try {
const { borrowerFacet, notifyFacet } = this.state;
notifyFacet.notifyAdvancingResult(restCtx, false);
borrowerFacet.returnToPool(tmpSeat, advanceAmount);
} catch (e) {
log('🚨 deposit to localOrchAccount failure recovery failed', e);
}
},
},
transferHandler: {
Expand All @@ -234,10 +243,11 @@ export const prepareAdvancerKit = (
onFulfilled(result, ctx) {
const { notifyFacet } = this.state;
const { advanceAmount, destination, ...detail } = ctx;
log(
'Advance transfer fulfilled',
q({ advanceAmount, destination, result }).toString(),
);
log('Advance transfer fulfilled', {
advanceAmount,
destination,
result,
});
// During development, due to a bug, this call threw.
// The failure was silent (no diagnostics) due to:
// - #10576 Vows do not report unhandled rejections
Expand All @@ -252,7 +262,7 @@ export const prepareAdvancerKit = (
*/
onRejected(error, ctx) {
const { notifyFacet } = this.state;
log('Advance transfer rejected', q(error).toString());
log('Advance transfer rejected', error);
notifyFacet.notifyAdvancingResult(ctx, false);
},
},
Expand Down
95 changes: 51 additions & 44 deletions packages/fast-usdc/src/exos/liquidity-pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
* @import {PoolStats} from '../types.js';
*/

const { add, isEqual, makeEmpty } = AmountMath;
const { add, isEqual, isGTE, makeEmpty } = AmountMath;

/** @param {Brand} brand */
const makeDust = brand => AmountMath.make(brand, 1n);
Expand Down Expand Up @@ -84,10 +84,8 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
'Liquidity Pool',
{
borrower: M.interface('borrower', {
borrow: M.call(
SeatShape,
harden({ USDC: makeNatAmountShape(USDC, 1n) }),
).returns(),
borrow: M.call(SeatShape, makeNatAmountShape(USDC, 1n)).returns(),
returnToPool: M.call(SeatShape, makeNatAmountShape(USDC, 1n)).returns(),
}),
repayer: M.interface('repayer', {
repay: M.call(
Expand Down Expand Up @@ -153,32 +151,48 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
borrower: {
/**
* @param {ZCFSeat} toSeat
* @param {{ USDC: Amount<'nat'>}} amountKWR
* @param {Amount<'nat'>} amount
*/
borrow(toSeat, amountKWR) {
borrow(toSeat, amount) {
const { encumberedBalance, poolSeat, poolStats } = this.state;

// Validate amount is available in pool
const post = borrowCalc(
amountKWR.USDC,
amount,
poolSeat.getAmountAllocated('USDC', USDC),
encumberedBalance,
poolStats,
);

// COMMIT POINT
try {
zcf.atomicRearrange(harden([[poolSeat, toSeat, amountKWR]]));
} catch (cause) {
const reason = Error('🚨 cannot commit borrow', { cause });
console.error(reason.message, cause);
zcf.shutdownWithFailure(reason);
}
// UNTIL #10684: ability to terminate an incarnation w/o terminating the contract
zcf.atomicRearrange(harden([[poolSeat, toSeat, { USDC: amount }]]));

Object.assign(this.state, post);
this.facets.external.publishPoolMetrics();
},
// TODO method to repay failed `LOA.deposit()`
/**
* If something fails during advance, return funds to the pool.
*
* @param {ZCFSeat} borrowSeat
* @param {Amount<'nat'>} amount
*/
returnToPool(borrowSeat, amount) {
const { zcfSeat: repaySeat } = zcf.makeEmptySeatKit();
const returnAmounts = harden({
Principal: amount,
PoolFee: makeEmpty(USDC),
ContractFee: makeEmpty(USDC),
});
const borrowSeatAllocation = borrowSeat.getCurrentAllocation();
isGTE(borrowSeatAllocation.USDC, amount) ||
Fail`⚠️ borrowSeatAllocation ${q(borrowSeatAllocation)} less than amountKWR ${q(amount)}`;
// arrange payments in a format repay is expecting
zcf.atomicRearrange(
harden([[borrowSeat, repaySeat, { USDC: amount }, returnAmounts]]),
);
return this.facets.repayer.repay(repaySeat, returnAmounts);
},
},
repayer: {
/**
Expand Down Expand Up @@ -208,23 +222,18 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
const { ContractFee, ...rest } = amounts;

// COMMIT POINT
try {
zcf.atomicRearrange(
harden([
[
fromSeat,
poolSeat,
rest,
{ USDC: add(amounts.PoolFee, amounts.Principal) },
],
[fromSeat, feeSeat, { ContractFee }, { USDC: ContractFee }],
]),
);
} catch (cause) {
const reason = Error('🚨 cannot commit repay', { cause });
console.error(reason.message, cause);
zcf.shutdownWithFailure(reason);
}
// UNTIL #10684: ability to terminate an incarnation w/o terminating the contract
zcf.atomicRearrange(
harden([
[
fromSeat,
poolSeat,
rest,
{ USDC: add(amounts.PoolFee, amounts.Principal) },
],
[fromSeat, feeSeat, { ContractFee }, { USDC: ContractFee }],
]),
);

Object.assign(this.state, post);
this.facets.external.publishPoolMetrics();
Expand Down Expand Up @@ -259,9 +268,8 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
const post = depositCalc(shareWorth, proposal);

// COMMIT POINT

const mint = shareMint.mintGains(post.payouts);
try {
const mint = shareMint.mintGains(post.payouts);
this.state.shareWorth = post.shareWorth;
zcf.atomicRearrange(
harden([
Expand All @@ -271,12 +279,12 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
[mint, lp, post.payouts],
]),
);
} catch (cause) {
// UNTIL #10684: ability to terminate an incarnation w/o terminating the contract
throw new Error('🚨 cannot commit deposit', { cause });
} finally {
lp.exit();
mint.exit();
} catch (cause) {
const reason = Error('🚨 cannot commit deposit', { cause });
console.error(reason.message, cause);
zcf.shutdownWithFailure(reason);
}
external.publishPoolMetrics();
},
Expand All @@ -296,7 +304,6 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
const post = withdrawCalc(shareWorth, proposal);

// COMMIT POINT

try {
this.state.shareWorth = post.shareWorth;
zcf.atomicRearrange(
Expand All @@ -308,12 +315,12 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
]),
);
shareMint.burnLosses(proposal.give, burn);
} catch (cause) {
// UNTIL #10684: ability to terminate an incarnation w/o terminating the contract
throw new Error('🚨 cannot commit withdraw', { cause });
} finally {
lp.exit();
burn.exit();
} catch (cause) {
const reason = Error('🚨 cannot commit withdraw', { cause });
console.error(reason.message, cause);
zcf.shutdownWithFailure(reason);
}
external.publishPoolMetrics();
},
Expand Down
10 changes: 5 additions & 5 deletions packages/fast-usdc/src/exos/settler.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,13 @@ export const prepareSettler = (
const split = calculateSplit(received);
log('disbursing', split);

// TODO: what if this throws?
// arguably, it cannot. Even if deposits
// and notifications get out of order,
// we don't ever withdraw more than has been deposited.
// If this throws, which arguably can't occur since we don't ever
// withdraw more than has been deposited (as denoted by
// `FungibleTokenPacketData`), funds will remain in the
// `settlementAccount`. A remediation can occur in a future upgrade.
await vowTools.when(
withdrawToSeat(
// @ts-expect-error Vow vs. Promise stuff. TODO: is this OK???
// @ts-expect-error LocalAccountMethods vs OrchestrationAccount
settlementAccount,
settlingSeat,
harden({ In: received }),
Expand Down
Loading

0 comments on commit 4feddb0

Please sign in to comment.