diff --git a/packages/fast-usdc/src/exos/liquidity-pool.js b/packages/fast-usdc/src/exos/liquidity-pool.js index 34b16ce807c..e2bff5ec5df 100644 --- a/packages/fast-usdc/src/exos/liquidity-pool.js +++ b/packages/fast-usdc/src/exos/liquidity-pool.js @@ -8,6 +8,7 @@ import { M } from '@endo/patterns'; import { Fail, q } from '@endo/errors'; import { borrowCalc, + checkPoolBalance, depositCalc, makeParity, repayCalc, @@ -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 {{ @@ -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( @@ -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 @@ -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 @@ -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 { diff --git a/packages/fast-usdc/src/pool-share-math.js b/packages/fast-usdc/src/pool-share-math.js index 1d9994a985d..c3ddb57feee 100644 --- a/packages/fast-usdc/src/pool-share-math.js +++ b/packages/fast-usdc/src/pool-share-math.js @@ -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'; @@ -18,8 +19,7 @@ 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 */ @@ -27,12 +27,12 @@ const { getValue, add, isEmpty, isEqual, isGTE, subtract } = AmountMath; /** * 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); }; /** @@ -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)); @@ -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 diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index 1ad44bac49d..8012508e775 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -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)); @@ -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 }, diff --git a/packages/fast-usdc/test/pool-share-math.test.ts b/packages/fast-usdc/test/pool-share-math.test.ts index 534915c5b1b..6fbb1922590 100644 --- a/packages/fast-usdc/test/pool-share-math.test.ts +++ b/packages/fast-usdc/test/pool-share-math.test.ts @@ -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'; @@ -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 => { @@ -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), + ), }); }); @@ -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 = { @@ -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) }, @@ -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) }, @@ -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) }, @@ -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) }, @@ -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) }, @@ -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); @@ -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> = {}; const myShares: Record> = {}; @@ -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 } = @@ -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), @@ -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), @@ -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), @@ -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),