Skip to content

Commit

Permalink
fix(fast-usdc): consider encumberedBalance in withdrawCalc (#10870)
Browse files Browse the repository at this point in the history
closes: #10856

## Description

 - [x] add contract test to confirm the bug
 - [x] fix pool-share-math; update tests
 - [x] update liquidity-pool exo

### Security Considerations

Prevents contract from becoming unavailable due to an inconsistent state.

### Scaling / Documentation Considerations

n/a

### Testing Considerations

 - added a contract test
 - added a pool-share-math unit test

### Upgrade Considerations

not yet deployed
  • Loading branch information
mergify[bot] authored Jan 23, 2025
2 parents c9a663b + daa6c38 commit 2af926f
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 59 deletions.
50 changes: 19 additions & 31 deletions packages/fast-usdc/src/exos/liquidity-pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { M } from '@endo/patterns';
import { Fail, q } from '@endo/errors';
import {
borrowCalc,
checkPoolBalance,
depositCalc,
makeParity,
repayCalc,
Expand All @@ -29,32 +30,7 @@ import {
* @import {PoolStats} from '../types.js';
*/

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

/** @param {Brand} brand */
const makeDust = brand => AmountMath.make(brand, 1n);

/**
* Verifies that the total pool balance (unencumbered + encumbered) matches the
* shareWorth numerator. The total pool balance consists of:
* 1. unencumbered balance - USDC available in the pool for borrowing
* 2. encumbered balance - USDC currently lent out
*
* A negligible `dust` amount is used to initialize shareWorth with a non-zero
* denominator. It must remain in the pool at all times.
*
* @param {ZCFSeat} poolSeat
* @param {ShareWorth} shareWorth
* @param {Brand} USDC
* @param {Amount<'nat'>} encumberedBalance
*/
const checkPoolBalance = (poolSeat, shareWorth, USDC, encumberedBalance) => {
const unencumberedBalance = poolSeat.getAmountAllocated('USDC', USDC);
const dust = makeDust(USDC);
const grossBalance = add(add(unencumberedBalance, dust), encumberedBalance);
isEqual(grossBalance, shareWorth.numerator) ||
Fail`🚨 pool balance ${q(unencumberedBalance)} and encumbered balance ${q(encumberedBalance)} inconsistent with shareWorth ${q(shareWorth)}`;
};
const { add, isGTE, makeEmpty } = AmountMath;

/**
* @typedef {{
Expand Down Expand Up @@ -127,7 +103,7 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
(shareMint, node) => {
const { brand: PoolShares } = shareMint.getIssuerRecord();
const proposalShapes = makeProposalShapes({ USDC, PoolShares });
const shareWorth = makeParity(makeDust(USDC), PoolShares);
const shareWorth = makeParity(USDC, PoolShares);
const { zcfSeat: poolSeat } = zcf.makeEmptySeatKit();
const { zcfSeat: feeSeat } = zcf.makeEmptySeatKit();
const poolMetricsRecorderKit = tools.makeRecorderKit(
Expand Down Expand Up @@ -215,7 +191,11 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
poolStats,
shareWorth,
} = this.state;
checkPoolBalance(poolSeat, shareWorth, USDC, encumberedBalance);
checkPoolBalance(
poolSeat.getCurrentAllocation(),
shareWorth,
encumberedBalance,
);

const fromSeatAllocation = fromSeat.getCurrentAllocation();
// Validate allocation equals amounts and Principal <= encumberedBalance
Expand Down Expand Up @@ -272,7 +252,11 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
/** @type {USDCProposalShapes['deposit']} */
// @ts-expect-error ensured by proposalShape
const proposal = lp.getProposal();
checkPoolBalance(poolSeat, shareWorth, USDC, encumberedBalance);
checkPoolBalance(
poolSeat.getCurrentAllocation(),
shareWorth,
encumberedBalance,
);
const post = depositCalc(shareWorth, proposal);

// COMMIT POINT
Expand Down Expand Up @@ -308,8 +292,12 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => {
// @ts-expect-error ensured by proposalShape
const proposal = lp.getProposal();
const { zcfSeat: burn } = zcf.makeEmptySeatKit();
checkPoolBalance(poolSeat, shareWorth, USDC, encumberedBalance);
const post = withdrawCalc(shareWorth, proposal);
const post = withdrawCalc(
shareWorth,
proposal,
poolSeat.getCurrentAllocation(),
encumberedBalance,
);

// COMMIT POINT
try {
Expand Down
58 changes: 50 additions & 8 deletions packages/fast-usdc/src/pool-share-math.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
} from '@agoric/zoe/src/contractSupport/ratio.js';
import { Fail, q } from '@endo/errors';

const { getValue, add, isEmpty, isEqual, isGTE, subtract } = AmountMath;
const { keys } = Object;
const { add, isEmpty, isEqual, isGTE, make, makeEmpty, subtract } = AmountMath;

/**
* @import {Amount, Brand, DepositFacet, NatValue, Payment} from '@agoric/ertp';
Expand All @@ -18,21 +19,20 @@ const { getValue, add, isEmpty, isEqual, isGTE, subtract } = AmountMath;
/**
* Invariant: shareWorth is the pool balance divided by shares outstanding.
*
* Use `makeParity(make(USDC, epsilon), PoolShares)` for an initial
* value, for some negligible `epsilon` such as 1n.
* Use `makeParity(USDC, PoolShares)` for an initial value.
*
* @typedef {Ratio} ShareWorth
*/

/**
* Make a 1-to-1 ratio between amounts of 2 brands.
*
* @param {Amount<'nat'>} numerator
* @param {Brand<'nat'>} numeratorBrand
* @param {Brand<'nat'>} denominatorBrand
*/
export const makeParity = (numerator, denominatorBrand) => {
const value = getValue(numerator.brand, numerator);
return makeRatio(value, numerator.brand, value, denominatorBrand);
export const makeParity = (numeratorBrand, denominatorBrand) => {
const dust = 1n;
return makeRatio(dust, numeratorBrand, dust, denominatorBrand);
};

/**
Expand Down Expand Up @@ -95,14 +95,54 @@ export const depositCalc = (shareWorth, { give, want }) => {
});
};

/**
* Verifies that the total pool balance (unencumbered + encumbered) matches the
* shareWorth numerator. The total pool balance consists of:
* 1. unencumbered balance - USDC available in the pool for borrowing
* 2. encumbered balance - USDC currently lent out
*
* A negligible `dust` amount is used to initialize shareWorth with a non-zero
* denominator. It must remain in the pool at all times.
*
* @param {Allocation} poolAlloc
* @param {ShareWorth} shareWorth
* @param {Amount<'nat'>} encumberedBalance
*/
export const checkPoolBalance = (poolAlloc, shareWorth, encumberedBalance) => {
const { brand: usdcBrand } = encumberedBalance;
const unencumberedBalance = poolAlloc.USDC || makeEmpty(usdcBrand);
const kwds = keys(poolAlloc);
kwds.length === 0 ||
(kwds.length === 1 && kwds[0] === 'USDC') ||
Fail`unexpected pool allocations: ${poolAlloc}`;
const dust = make(usdcBrand, 1n);
const grossBalance = add(add(unencumberedBalance, dust), encumberedBalance);
isEqual(grossBalance, shareWorth.numerator) ||
Fail`🚨 pool balance ${q(unencumberedBalance)} and encumbered balance ${q(encumberedBalance)} inconsistent with shareWorth ${q(shareWorth)}`;
return harden({ unencumberedBalance, grossBalance });
};

/**
* Compute payout from a withdraw proposal, along with updated shareWorth
*
* @param {ShareWorth} shareWorth
* @param {USDCProposalShapes['withdraw']} proposal
* @param {Allocation} poolAlloc
* @param {Amount<'nat'>} [encumberedBalance]
* @returns {{ shareWorth: ShareWorth, payouts: { USDC: Amount<'nat'> }}}
*/
export const withdrawCalc = (shareWorth, { give, want }) => {
export const withdrawCalc = (
shareWorth,
{ give, want },
poolAlloc,
encumberedBalance = makeEmpty(shareWorth.numerator.brand),
) => {
const { unencumberedBalance } = checkPoolBalance(
poolAlloc,
shareWorth,
encumberedBalance,
);

assert(!isEmpty(give.PoolShare));
assert(!isEmpty(want.USDC));

Expand All @@ -112,6 +152,8 @@ export const withdrawCalc = (shareWorth, { give, want }) => {
const { denominator: sharesOutstanding, numerator: poolBalance } = shareWorth;
!isGTE(want.USDC, poolBalance) ||
Fail`cannot withdraw ${q(want.USDC)}; only ${q(poolBalance)} in pool`;
isGTE(unencumberedBalance, want.USDC) ||
Fail`cannot withdraw ${q(want.USDC)}; ${q(encumberedBalance)} is in use; stand by for pool to return to ${q(poolBalance)}`;
const balancePost = subtract(poolBalance, payout);
// giving more shares than are outstanding is impossible,
// so it's not worth a custom diagnostic. subtract will fail
Expand Down
53 changes: 52 additions & 1 deletion packages/fast-usdc/test/fast-usdc.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,12 @@ const makeLP = async (
const usdcPmt = await E(sharePurse)
.withdraw(proposal.give.PoolShare)
.then(pmt => E(zoe).offer(toWithdraw, proposal, { PoolShare: pmt }))
.then(seat => E(seat).getPayout('USDC'));
.then(async seat => {
// be sure to collect refund
void E(sharePurse).deposit(await E(seat).getPayout('PoolShare'));
t.log(await E(seat).getOfferResult());
return E(seat).getPayout('USDC');
});
const amt = await E(usdcPurse).deposit(usdcPmt);
t.log(name, 'withdraw payout', ...logAmt(amt));
t.true(isGTE(amt, proposal.want.USDC));
Expand Down Expand Up @@ -780,6 +785,52 @@ test.serial('STORY05(cont): LPs withdraw all liquidity', async t => {
t.truthy(b);
});

test.serial('withdraw all liquidity while ADVANCING', async t => {
const {
bridges: { snapshot, since },
common: {
commonPrivateArgs: { feeConfig },
utils,
brands: { usdc },
bootstrap: { storage },
},
evm: { cctp, txPub },
mint,
startKit: { zoe, instance, metricsSub },
} = t.context;

const usdcPurse = purseOf(usdc.issuer, utils);
// 1. Alice deposits 10 USDC for 10 FastLP
const alice = makeLP('Alice', usdcPurse(10_000_000n), zoe, instance);
await E(alice).deposit(t, 10_000_000n);

// 2. Bob initiates an advance of 6, reducing the pool to 4
const bob = makeCustomer('Bob', cctp, txPub.publisher, feeConfig);
const bridgePos = snapshot();
const sent = await bob.sendFast(t, 6_000_000n, 'osmo123bob5');
await eventLoopIteration();
bob.checkSent(t, since(bridgePos));

// 3. Alice proposes to withdraw 7 USDC
await t.throwsAsync(E(alice).withdraw(t, 0.7), {
message:
'cannot withdraw {"brand":"[Alleged: USDC brand]","value":"[7000000n]"}; {"brand":"[Alleged: USDC brand]","value":"[5879999n]"} is in use; stand by for pool to return to {"brand":"[Alleged: USDC brand]","value":"[10000001n]"}',
});

// 4. Bob's advance is settled
await mint(sent);
await utils.transmitTransferAck();
t.like(storage.getDeserialized(`fun.txns.${sent.txHash}`), [
{ evidence: sent, status: 'OBSERVED' },
{ status: 'ADVANCING' },
{ status: 'ADVANCED' },
{ status: 'DISBURSED' },
]);

// Now Alice can withdraw all her liquidity.
await E(alice).withdraw(t, 1);
});

test.serial('withdraw fees using creatorFacet', async t => {
const {
startKit: { zoe, creatorFacet },
Expand Down
Loading

0 comments on commit 2af926f

Please sign in to comment.