Skip to content

Commit

Permalink
fix(fast-usdc): consider encumberedBalance in withdrawCalc
Browse files Browse the repository at this point in the history
 - advise caller to stand by for pool to be replenished
 - pass pool allocation, encumbered balance to withdrawCalc
   - push checkPoolBalance down into pool-share-math
     - take allocation rather than stateful seat
   - push dust calculations down to pool-share-math
  • Loading branch information
dckc committed Jan 23, 2025
1 parent afda5a3 commit daa6c38
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 58 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
67 changes: 48 additions & 19 deletions packages/fast-usdc/test/pool-share-math.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js';
import { testProp, fc } from '@fast-check/ava';
import { AmountMath, makeIssuerKit, type Amount } from '@agoric/ertp';
import {
makeRatioFromAmounts,
multiplyBy,
parseRatio,
} from '@agoric/zoe/src/contractSupport/ratio.js';
Expand All @@ -26,7 +27,7 @@ const brands = harden({
PoolShares: issuerKits.PoolShare.brand,
USDC: issuerKits.USDC.brand,
});
const parity = makeParity(make(brands.USDC, 1n), brands.PoolShares);
const parity = makeParity(brands.USDC, brands.PoolShares);
const shapes = makeProposalShapes(brands);

test('initial deposit to pool', t => {
Expand All @@ -40,7 +41,10 @@ test('initial deposit to pool', t => {
const actual = depositCalc(parity, proposal);
t.deepEqual(actual, {
payouts: { PoolShare: make(PoolShares, 100n) },
shareWorth: makeParity(actual.shareWorth.numerator, PoolShares),
shareWorth: makeRatioFromAmounts(
actual.shareWorth.numerator,
make(PoolShares, actual.shareWorth.numerator.value),
),
});
});

Expand All @@ -50,14 +54,14 @@ test('initial withdrawal fails', t => {
give: { PoolShare: make(PoolShares, 100n) },
want: { USDC: make(USDC, 100n) },
});
t.throws(() => withdrawCalc(parity, proposal), {
t.throws(() => withdrawCalc(parity, proposal, harden({})), {
message: /cannot withdraw/,
});
});

test('withdrawal after deposit OK', t => {
const { PoolShares, USDC } = brands;
const state0 = makeParity(make(USDC, 1n), PoolShares);
const state0 = makeParity(USDC, PoolShares);
const emptyShares = makeEmpty(PoolShares);

const pDep = {
Expand All @@ -72,7 +76,7 @@ test('withdrawal after deposit OK', t => {
});
mustMatch(proposal, shapes.withdraw);

const actual = withdrawCalc(state1, proposal);
const actual = withdrawCalc(state1, proposal, pDep.give);

t.deepEqual(actual, {
payouts: { USDC: make(USDC, 50n) },
Expand Down Expand Up @@ -107,7 +111,7 @@ test('deposit offer underestimates value of share', t => {

test('deposit offer overestimates value of share', t => {
const { PoolShares, USDC } = brands;
const state0 = makeParity(make(USDC, 1n), PoolShares);
const state0 = makeParity(USDC, PoolShares);

const proposal = harden({
give: { USDC: make(USDC, 10n) },
Expand All @@ -128,7 +132,7 @@ test('deposit offer overestimates value of share', t => {
test('withdrawal offer underestimates value of share', t => {
const { PoolShares, USDC } = brands;
const emptyShares = makeEmpty(PoolShares);
const state0 = makeParity(make(USDC, 1n), PoolShares);
const state0 = makeParity(USDC, PoolShares);

const proposal1 = harden({
give: { USDC: make(USDC, 100n) },
Expand All @@ -142,7 +146,7 @@ test('withdrawal offer underestimates value of share', t => {
});
mustMatch(proposal, shapes.withdraw);

const actual = withdrawCalc(state1, proposal);
const actual = withdrawCalc(state1, proposal, proposal1.give);

t.deepEqual(actual, {
payouts: { USDC: make(USDC, 60n) },
Expand All @@ -156,7 +160,7 @@ test('withdrawal offer underestimates value of share', t => {
test('withdrawal offer overestimates value of share', t => {
const { PoolShares, USDC } = brands;
const emptyShares = makeEmpty(PoolShares);
const state0 = makeParity(make(USDC, 1n), PoolShares);
const state0 = makeParity(USDC, PoolShares);

const d100 = {
give: { USDC: make(USDC, 100n) },
Expand All @@ -170,11 +174,35 @@ test('withdrawal offer overestimates value of share', t => {
});
mustMatch(proposal, shapes.withdraw);

t.throws(() => withdrawCalc(state1, proposal), {
t.throws(() => withdrawCalc(state1, proposal, d100.give), {
message: /cannot withdraw/,
});
});

test('withdrawal during advance can fail', t => {
const { PoolShares, USDC } = brands;
const state0 = makeParity(USDC, PoolShares);
const emptyShares = makeEmpty(PoolShares);

const pDep = {
give: { USDC: make(USDC, 100n) },
want: { PoolShare: make(PoolShares, 10n) },
};
const { shareWorth: state1 } = depositCalc(state0, pDep);

const proposal = harden({
give: { PoolShare: make(PoolShares, 70n) },
want: { USDC: make(USDC, 70n) },
});
mustMatch(proposal, shapes.withdraw);

const encumbered = make(USDC, 40n);
const alloc = harden({ USDC: subtract(pDep.give.USDC, encumbered) });
t.throws(() => withdrawCalc(state1, proposal, alloc, encumbered), {
message: /cannot withdraw .* stand by/,
});
});

const scaleAmount = (frac: number, amount: Amount<'nat'>) => {
const asRatio = parseRatio(frac, amount.brand);
return multiplyBy(amount, asRatio);
Expand Down Expand Up @@ -249,7 +277,7 @@ testProp(
const { PoolShares, USDC } = brands;
const emptyShares = makeEmpty(PoolShares);
const emptyUSDC = makeEmpty(USDC);
let shareWorth = makeParity(make(USDC, 1n), PoolShares);
let shareWorth = makeParity(USDC, PoolShares);
const myDeposits: Record<number, Amount<'nat'>> = {};
const myShares: Record<number, Amount<'nat'>> = {};

Expand Down Expand Up @@ -280,10 +308,11 @@ testProp(
const toGive = scaleAmount(action.Part, myShares[party]);
if (isEmpty(toGive)) continue;
const toGet = scaleAmount(action.Slip, multiplyBy(toGive, shareWorth));
const s = withdrawCalc(shareWorth, {
give: { PoolShare: toGive },
want: { USDC: toGet },
});
const s = withdrawCalc(
shareWorth,
{ give: { PoolShare: toGive }, want: { USDC: toGet } },
harden({ USDC: subtract(shareWorth.numerator, make(USDC, 1n)) }),
);
myShares[party] = subtract(myShares[party], toGive);
myDeposits[party] = subtract(myDeposits[party], s.payouts.USDC);
const { numerator: poolAmount, denominator: sharesOutstanding } =
Expand Down Expand Up @@ -388,7 +417,7 @@ test('borrow fails when requested exceeds or equals pool seat allocation', t =>

test('basic repay calculation', t => {
const { USDC } = brands;
const shareWorth = makeParity(make(USDC, 1n), brands.PoolShares);
const shareWorth = makeParity(USDC, brands.PoolShares);
const amounts = {
Principal: make(USDC, 100n),
PoolFee: make(USDC, 10n),
Expand Down Expand Up @@ -446,7 +475,7 @@ test('basic repay calculation', t => {
test('repay fails when principal exceeds encumbered balance', t => {
const { USDC } = brands;

const shareWorth = makeParity(make(USDC, 1n), brands.PoolShares);
const shareWorth = makeParity(USDC, brands.PoolShares);
const amounts = {
Principal: make(USDC, 200n),
PoolFee: make(USDC, 10n),
Expand Down Expand Up @@ -487,7 +516,7 @@ test('repay fails when principal exceeds encumbered balance', t => {
test('repay fails when seat allocation does not equal amounts', t => {
const { USDC } = brands;

const shareWorth = makeParity(make(USDC, 1n), brands.PoolShares);
const shareWorth = makeParity(USDC, brands.PoolShares);
const amounts = {
Principal: make(USDC, 200n),
PoolFee: make(USDC, 10n),
Expand Down Expand Up @@ -522,7 +551,7 @@ test('repay fails when seat allocation does not equal amounts', t => {
test('repay succeeds with no Pool or Contract Fee', t => {
const { USDC } = brands;
const encumberedBalance = make(USDC, 100n);
const shareWorth = makeParity(make(USDC, 1n), brands.PoolShares);
const shareWorth = makeParity(USDC, brands.PoolShares);

const amounts = {
Principal: make(USDC, 25n),
Expand Down

0 comments on commit daa6c38

Please sign in to comment.