diff --git a/packages/fast-usdc/src/exos/liquidity-pool.js b/packages/fast-usdc/src/exos/liquidity-pool.js index 4d43d7dfe71..b280ab08412 100644 --- a/packages/fast-usdc/src/exos/liquidity-pool.js +++ b/packages/fast-usdc/src/exos/liquidity-pool.js @@ -1,58 +1,76 @@ -import { - AmountMath, - AmountShape, - PaymentShape, - RatioShape, -} from '@agoric/ertp'; +import { AmountMath, AmountShape } from '@agoric/ertp'; import { makeRecorderTopic, TopicsRecordShape, } from '@agoric/zoe/src/contractSupport/topics.js'; -import { depositToSeat } from '@agoric/zoe/src/contractSupport/zoeHelpers.js'; import { SeatShape } from '@agoric/zoe/src/typeGuards.js'; import { M } from '@endo/patterns'; import { Fail, q } from '@endo/errors'; import { + borrowCalc, depositCalc, makeParity, + repayCalc, withdrawCalc, - withFees, } from '../pool-share-math.js'; -import { makeProposalShapes } from '../type-guards.js'; +import { + makeNatAmountShape, + makeProposalShapes, + PoolMetricsShape, +} from '../type-guards.js'; /** * @import {Zone} from '@agoric/zone'; - * @import {Remote, TypedPattern} from '@agoric/internal' + * @import {Remote} from '@agoric/internal' * @import {StorageNode} from '@agoric/internal/src/lib-chainStorage.js' - * @import {MakeRecorderKit, RecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js' + * @import {MakeRecorderKit} from '@agoric/zoe/src/contractSupport/recorder.js' * @import {USDCProposalShapes, ShareWorth} from '../pool-share-math.js' + * @import {PoolStats} from '../types.js'; */ -const { add, isEqual } = AmountMath; +const { add, isEqual, makeEmpty } = AmountMath; /** @param {Brand} brand */ const makeDust = brand => AmountMath.make(brand, 1n); /** - * Use of pool-share-math in offer handlers below assumes that - * the pool balance represented by the USDC allocation in poolSeat - * is the same as the pool balance represented by the numerator - * of shareWorth. + * 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 * - * Well, almost: they're the same modulo the dust used - * to initialize shareWorth with a non-zero denominator. + * 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) => { - const available = poolSeat.getAmountAllocated('USDC', USDC); +const checkPoolBalance = (poolSeat, shareWorth, USDC, encumberedBalance) => { + const unencumberedBalance = poolSeat.getAmountAllocated('USDC', USDC); const dust = makeDust(USDC); - isEqual(add(available, dust), shareWorth.numerator) || - Fail`🚨 pool balance ${q(available)} inconsistent with shareWorth ${q(shareWorth)}`; + 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)}`; }; +/** + * @typedef {{ + * Principal: Amount<'nat'>; + * PoolFee: Amount<'nat'>; + * ContractFee: Amount<'nat'>; + * }} RepayAmountKWR + */ + +/** + * @typedef {{ + * Principal: Payment<'nat'>; + * PoolFee: Payment<'nat'>; + * ContractFee: Payment<'nat'>; + * }} RepayPaymentKWR + */ + /** * @param {Zone} zone * @param {ZCF} zcf @@ -65,11 +83,25 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { return zone.exoClassKit( 'Liquidity Pool', { - feeSink: M.interface('feeSink', { - receive: M.call(AmountShape, PaymentShape).returns(M.promise()), + borrower: M.interface('borrower', { + getBalance: M.call().returns(AmountShape), + borrow: M.call( + SeatShape, + harden({ USDC: makeNatAmountShape(USDC, 1n) }), + ).returns(), + }), + repayer: M.interface('repayer', { + repay: M.call( + SeatShape, + harden({ + Principal: makeNatAmountShape(USDC, 1n), + PoolFee: makeNatAmountShape(USDC, 0n), + ContractFee: makeNatAmountShape(USDC, 0n), + }), + ).returns(), }), external: M.interface('external', { - publishShareWorth: M.call().returns(), + publishPoolMetrics: M.call().returns(), }), depositHandler: M.interface('depositHandler', { handle: M.call(SeatShape, M.any()).returns(M.promise()), @@ -92,57 +124,143 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { const proposalShapes = makeProposalShapes({ USDC, PoolShares }); const shareWorth = makeParity(makeDust(USDC), PoolShares); const { zcfSeat: poolSeat } = zcf.makeEmptySeatKit(); - const shareWorthRecorderKit = tools.makeRecorderKit(node, RatioShape); + const { zcfSeat: feeSeat } = zcf.makeEmptySeatKit(); + const poolMetricsRecorderKit = tools.makeRecorderKit( + node, + PoolMetricsShape, + ); + const encumberedBalance = makeEmpty(USDC); + /** @type {PoolStats} */ + const poolStats = harden({ + totalBorrows: makeEmpty(USDC), + totalContractFees: makeEmpty(USDC), + totalPoolFees: makeEmpty(USDC), + totalRepays: makeEmpty(USDC), + }); return { - shareMint, - shareWorth, + /** used for `checkPoolBalance` invariant. aka 'outstanding borrows' */ + encumberedBalance, + feeSeat, + poolStats, + poolMetricsRecorderKit, poolSeat, PoolShares, proposalShapes, - shareWorthRecorderKit, + shareMint, + shareWorth, }; }, { - feeSink: { + borrower: { + getBalance() { + const { poolSeat } = this.state; + return poolSeat.getAmountAllocated('USDC', USDC); + }, /** - * @param {Amount<'nat'>} amount - * @param {Payment<'nat'>} payment + * @param {ZCFSeat} toSeat + * @param {{ USDC: Amount<'nat'>}} amountKWR */ - async receive(amount, payment) { - const { poolSeat, shareWorth } = this.state; - const { external } = this.facets; - await depositToSeat( - zcf, - poolSeat, - harden({ USDC: amount }), - harden({ USDC: payment }), + borrow(toSeat, amountKWR) { + const { encumberedBalance, poolSeat, poolStats } = this.state; + + // Validate amount is available in pool + const post = borrowCalc( + amountKWR.USDC, + poolSeat.getAmountAllocated('USDC', USDC), + encumberedBalance, + poolStats, ); - this.state.shareWorth = withFees(shareWorth, amount); - external.publishShareWorth(); + + // 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); + } + + Object.assign(this.state, post); + this.facets.external.publishPoolMetrics(); }, + // TODO method to repay failed `LOA.deposit()` }, + repayer: { + /** + * @param {ZCFSeat} fromSeat + * @param {RepayAmountKWR} amounts + */ + repay(fromSeat, amounts) { + const { + encumberedBalance, + feeSeat, + poolSeat, + poolStats, + shareWorth, + } = this.state; + checkPoolBalance(poolSeat, shareWorth, USDC, encumberedBalance); + + const fromSeatAllocation = fromSeat.getCurrentAllocation(); + // Validate allocation equals amounts and Principal <= encumberedBalance + const post = repayCalc( + shareWorth, + fromSeatAllocation, + amounts, + encumberedBalance, + poolStats, + ); + 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); + } + + Object.assign(this.state, post); + this.facets.external.publishPoolMetrics(); + }, + }, external: { - publishShareWorth() { - const { shareWorth } = this.state; - const { recorder } = this.state.shareWorthRecorderKit; + publishPoolMetrics() { + const { poolStats, shareWorth, encumberedBalance } = this.state; + const { recorder } = this.state.poolMetricsRecorderKit; // Consumers of this .write() are off-chain / outside the VM. // And there's no way to recover from a failed write. // So don't await. - void recorder.write(shareWorth); + void recorder.write({ + encumberedBalance, + shareWorth, + ...poolStats, + }); }, }, depositHandler: { /** @param {ZCFSeat} lp */ async handle(lp) { - const { shareWorth, shareMint, poolSeat } = this.state; + const { shareWorth, shareMint, poolSeat, encumberedBalance } = + this.state; const { external } = this.facets; /** @type {USDCProposalShapes['deposit']} */ // @ts-expect-error ensured by proposalShape const proposal = lp.getProposal(); - checkPoolBalance(poolSeat, shareWorth, USDC); + checkPoolBalance(poolSeat, shareWorth, USDC, encumberedBalance); const post = depositCalc(shareWorth, proposal); // COMMIT POINT @@ -165,20 +283,21 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { console.error(reason.message, cause); zcf.shutdownWithFailure(reason); } - external.publishShareWorth(); + external.publishPoolMetrics(); }, }, withdrawHandler: { /** @param {ZCFSeat} lp */ async handle(lp) { - const { shareWorth, shareMint, poolSeat } = this.state; + const { shareWorth, shareMint, poolSeat, encumberedBalance } = + this.state; const { external } = this.facets; /** @type {USDCProposalShapes['withdraw']} */ // @ts-expect-error ensured by proposalShape const proposal = lp.getProposal(); const { zcfSeat: burn } = zcf.makeEmptySeatKit(); - checkPoolBalance(poolSeat, shareWorth, USDC); + checkPoolBalance(poolSeat, shareWorth, USDC, encumberedBalance); const post = withdrawCalc(shareWorth, proposal); // COMMIT POINT @@ -201,7 +320,7 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { console.error(reason.message, cause); zcf.shutdownWithFailure(reason); } - external.publishShareWorth(); + external.publishPoolMetrics(); }, }, public: { @@ -222,18 +341,25 @@ export const prepareLiquidityPoolKit = (zone, zcf, USDC, tools) => { ); }, getPublicTopics() { - const { shareWorthRecorderKit } = this.state; + const { poolMetricsRecorderKit } = this.state; return { - shareWorth: makeRecorderTopic('shareWorth', shareWorthRecorderKit), + poolMetrics: makeRecorderTopic( + 'poolMetrics', + poolMetricsRecorderKit, + ), }; }, }, }, { finish: ({ facets: { external } }) => { - void external.publishShareWorth(); + void external.publishPoolMetrics(); }, }, ); }; harden(prepareLiquidityPoolKit); + +/** + * @typedef {ReturnType>} LiquidityPoolKit + */ diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index 144cb0c614c..80b6602e35c 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -1,5 +1,9 @@ import { AssetKind } from '@agoric/ertp'; -import { assertAllDefined, makeTracer } from '@agoric/internal'; +import { + assertAllDefined, + deeplyFulfilledObject, + makeTracer, +} from '@agoric/internal'; import { observeIteration, subscribeEach } from '@agoric/notifier'; import { OrchestrationPowersShape, @@ -7,7 +11,9 @@ import { } from '@agoric/orchestration'; import { provideSingleton } from '@agoric/zoe/src/contractSupport/durability.js'; import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js'; -import { M } from '@endo/patterns'; +import { E } from '@endo/far'; +import { M, objectMap } from '@endo/patterns'; +import { depositToSeat } from '@agoric/zoe/src/contractSupport/zoeHelpers.js'; import { prepareAdvancer } from './exos/advancer.js'; import { prepareLiquidityPoolKit } from './exos/liquidity-pool.js'; import { prepareSettler } from './exos/settler.js'; @@ -24,6 +30,7 @@ const trace = makeTracer('FastUsdc'); * @import {Zone} from '@agoric/zone'; * @import {OperatorKit} from './exos/operator-kit.js'; * @import {CctpTxEvidence, FeeConfig} from './types.js'; + * @import {RepayAmountKWR, RepayPaymentKWR} from './exos/liquidity-pool.js'; */ /** @@ -112,10 +119,36 @@ export const contract = async (zcf, privateArgs, zone, tools) => { async makeOperatorInvitation(operatorId) { return feedKit.creator.makeOperatorInvitation(operatorId); }, - simulateFeesFromAdvance(amount, payment) { - console.log('🚧🚧 UNTIL: advance fees are implemented 🚧🚧'); + /** + * @param {{ USDC: Amount<'nat'>}} amounts + */ + testBorrow(amounts) { + console.log('🚧🚧 UNTIL: borrow is integrated 🚧🚧', amounts); + const { zcfSeat: tmpAssetManagerSeat } = zcf.makeEmptySeatKit(); + // eslint-disable-next-line no-use-before-define + poolKit.borrower.borrow(tmpAssetManagerSeat, amounts); + return tmpAssetManagerSeat.getCurrentAllocation(); + }, + /** + * + * @param {RepayAmountKWR} amounts + * @param {RepayPaymentKWR} payments + * @returns {Promise} + */ + async testRepay(amounts, payments) { + console.log('🚧🚧 UNTIL: repay is integrated 🚧🚧', amounts); + const { zcfSeat: tmpAssetManagerSeat } = zcf.makeEmptySeatKit(); + await depositToSeat( + zcf, + tmpAssetManagerSeat, + await deeplyFulfilledObject( + objectMap(payments, pmt => E(terms.issuers.USDC).getAmountOf(pmt)), + ), + payments, + ); // eslint-disable-next-line no-use-before-define - return poolKit.feeSink.receive(amount, payment); + poolKit.repayer.repay(tmpAssetManagerSeat, amounts); + return tmpAssetManagerSeat.getCurrentAllocation(); }, }); diff --git a/packages/fast-usdc/src/pool-share-math.js b/packages/fast-usdc/src/pool-share-math.js index 01b41816933..7c5df36f567 100644 --- a/packages/fast-usdc/src/pool-share-math.js +++ b/packages/fast-usdc/src/pool-share-math.js @@ -7,7 +7,12 @@ import { } from '@agoric/zoe/src/contractSupport/ratio.js'; import { Fail, q } from '@endo/errors'; -const { getValue, add, isEmpty, isGTE, subtract } = AmountMath; +const { getValue, add, isEmpty, isEqual, isGTE, subtract } = AmountMath; + +/** + * @import {PoolStats} from './types'; + * @import {RepayAmountKWR} from './exos/liquidity-pool'; + */ /** * Invariant: shareWorth is the pool balance divided by shares outstanding. @@ -120,3 +125,65 @@ export const withFees = (shareWorth, fees) => { const balancePost = add(shareWorth.numerator, fees); return makeRatioFromAmounts(balancePost, shareWorth.denominator); }; + +/** + * + * @param {Amount<'nat'>} requested + * @param {Amount<'nat'>} poolSeatAllocation + * @param {Amount<'nat'>} encumberedBalance + * @param {PoolStats} poolStats + * @throws {Error} if requested is not less than poolSeatAllocation + */ +export const borrowCalc = ( + requested, + poolSeatAllocation, + encumberedBalance, + poolStats, +) => { + // pool must never go empty + !isGTE(requested, poolSeatAllocation) || + Fail`Cannot borrow. Requested ${q(requested)} must be less than pool balance ${q(poolSeatAllocation)}.`; + + return harden({ + encumberedBalance: add(encumberedBalance, requested), + poolStats: { + ...poolStats, + totalBorrows: add(poolStats.totalBorrows, requested), + }, + }); +}; + +/** + * @param {ShareWorth} shareWorth + * @param {Allocation} fromSeatAllocation + * @param {RepayAmountKWR} amounts + * @param {Amount<'nat'>} encumberedBalance aka 'outstanding borrows' + * @param {PoolStats} poolStats + * @throws {Error} if allocations do not match amounts or Principal exceeds encumberedBalance + */ +export const repayCalc = ( + shareWorth, + fromSeatAllocation, + amounts, + encumberedBalance, + poolStats, +) => { + (isEqual(fromSeatAllocation.Principal, amounts.Principal) && + isEqual(fromSeatAllocation.PoolFee, amounts.PoolFee) && + isEqual(fromSeatAllocation.ContractFee, amounts.ContractFee)) || + Fail`Cannot repay. From seat allocation ${q(fromSeatAllocation)} does not equal amounts ${q(amounts)}.`; + + isGTE(encumberedBalance, amounts.Principal) || + Fail`Cannot repay. Principal ${q(amounts.Principal)} exceeds encumberedBalance ${q(encumberedBalance)}.`; + + return harden({ + shareWorth: withFees(shareWorth, amounts.PoolFee), + encumberedBalance: subtract(encumberedBalance, amounts.Principal), + poolStats: { + ...poolStats, + totalRepays: add(poolStats.totalRepays, amounts.Principal), + totalPoolFees: add(poolStats.totalPoolFees, amounts.PoolFee), + totalContractFees: add(poolStats.totalContractFees, amounts.ContractFee), + }, + }); +}; diff --git a/packages/fast-usdc/src/type-guards.js b/packages/fast-usdc/src/type-guards.js index f4aa9db982d..677144eeffe 100644 --- a/packages/fast-usdc/src/type-guards.js +++ b/packages/fast-usdc/src/type-guards.js @@ -1,4 +1,4 @@ -import { BrandShape, RatioShape } from '@agoric/ertp'; +import { AmountShape, BrandShape, RatioShape } from '@agoric/ertp'; import { M } from '@endo/patterns'; import { PendingTxStatus } from './constants.js'; @@ -6,7 +6,7 @@ import { PendingTxStatus } from './constants.js'; * @import {TypedPattern} from '@agoric/internal'; * @import {FastUsdcTerms} from './fast-usdc.contract.js'; * @import {USDCProposalShapes} from './pool-share-math.js'; - * @import {CctpTxEvidence, FeeConfig, PendingTx} from './types.js'; + * @import {CctpTxEvidence, FeeConfig, PendingTx, PoolMetrics} from './types.js'; */ /** @@ -82,3 +82,14 @@ export const FeeConfigShape = { contractRate: RatioShape, }; harden(FeeConfigShape); + +/** @type {TypedPattern} */ +export const PoolMetricsShape = { + encumberedBalance: AmountShape, + shareWorth: RatioShape, + totalContractFees: AmountShape, + totalPoolFees: AmountShape, + totalBorrows: AmountShape, + totalRepays: AmountShape, +}; +harden(PoolMetricsShape); diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts index 6cb3a94cd62..c4b7820d4c8 100644 --- a/packages/fast-usdc/src/types.ts +++ b/packages/fast-usdc/src/types.ts @@ -1,5 +1,6 @@ import type { ChainAddress } from '@agoric/orchestration'; import type { IBCChannelID } from '@agoric/vats'; +import type { Amount } from '@agoric/ertp'; import type { PendingTxStatus } from './constants.js'; export type EvmHash = `0x${string}`; @@ -42,4 +43,16 @@ export type FeeConfig = { contractRate: Ratio; }; +export interface PoolStats { + totalBorrows: Amount<'nat'>; + totalContractFees: Amount<'nat'>; + totalPoolFees: Amount<'nat'>; + totalRepays: Amount<'nat'>; +} + +export interface PoolMetrics extends PoolStats { + encumberedBalance: Amount<'nat'>; + shareWorth: Ratio; +} + export type * from './constants.js'; diff --git a/packages/fast-usdc/src/utils/fees.js b/packages/fast-usdc/src/utils/fees.js index 9019a54f8b4..08b14c81991 100644 --- a/packages/fast-usdc/src/utils/fees.js +++ b/packages/fast-usdc/src/utils/fees.js @@ -9,6 +9,7 @@ const { add, isGTE, min, subtract } = AmountMath; /** * @import {Amount} from '@agoric/ertp'; * @import {FeeConfig} from '../types.js'; + * @import {RepayAmountKWR} from '../exos/liquidity-pool.js'; */ /** @param {FeeConfig} feeConfig */ @@ -42,7 +43,7 @@ export const makeFeeTools = feeConfig => { * Calculate the split of fees between pool and contract. * * @param {Amount<'nat'>} requested - * @returns {{ Principal: Amount<'nat'>, PoolFee: Amount<'nat'>, ContractFee: Amount<'nat'> }} an {@link AmountKeywordRecord} + * @returns {RepayAmountKWR} an {@link AmountKeywordRecord} * @throws {Error} if requested does not exceed fees */ calculateSplit(requested) { diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index 0e1c6a6c6df..dc23436af50 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -194,7 +194,7 @@ test('updates status to OBSERVED on insufficient pool funds', async t => { t.deepEqual(inspectLogs(0), [ 'Insufficient pool funds', - 'Requested {"brand":"[Alleged: USDC brand]","value":"[195000000n]"} but only have {"brand":"[Alleged: USDC brand]","value":"[1n]"}', + 'Requested {"brand":"[Alleged: USDC brand]","value":"[199999899n]"} but only have {"brand":"[Alleged: USDC brand]","value":"[1n]"}', ]); }); diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index 80d3a0b1d3f..427ca2cf9d2 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -1,7 +1,11 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import type { ExecutionContext } from 'ava'; import { AmountMath } from '@agoric/ertp/src/amountMath.js'; -import { inspectMapStore } from '@agoric/internal/src/testing-utils.js'; +import { + eventLoopIteration, + inspectMapStore, +} from '@agoric/internal/src/testing-utils.js'; import { divideBy, multiplyBy, @@ -11,14 +15,22 @@ import { setUpZoeForTest } from '@agoric/zoe/tools/setup-zoe.js'; import { E } from '@endo/far'; import path from 'path'; import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; +import { objectMap } from '@endo/patterns'; +import { deeplyFulfilledObject } from '@agoric/internal'; +import type { Subscriber } from '@agoric/notifier'; import { MockCctpTxEvidences } from './fixtures.js'; import { commonSetup } from './supports.js'; +import type { FastUsdcTerms } from '../src/fast-usdc.contract.js'; +import { makeFeeTools } from '../src/utils/fees.js'; +import type { PoolMetrics } from '../src/types.js'; const dirname = path.dirname(new URL(import.meta.url).pathname); const contractFile = `${dirname}/../src/fast-usdc.contract.js`; type StartFn = typeof import('../src/fast-usdc.contract.js').start; +const { add, isGTE, subtract } = AmountMath; + const getInvitationProperties = async ( zoe: ZoeService, invitation: Invitation, @@ -28,11 +40,9 @@ const getInvitationProperties = async ( return amount.value[0]; }; +type CommonSetup = Awaited>; const startContract = async ( - common: Pick< - Awaited>, - 'brands' | 'commonPrivateArgs' - >, + common: Pick, ) => { const { brands: { usdc }, @@ -128,29 +138,33 @@ const scaleAmount = (frac: number, amount: Amount<'nat'>) => { return multiplyBy(amount, asRatio); }; -test('LP deposits, earns fees, withdraws', async t => { - const common = await commonSetup(t); +const makeLpTools = ( + t: ExecutionContext, + common: Pick, + { + zoe, + terms, + subscriber, + publicFacet, + }: { + zoe: ZoeService; + subscriber: ERef>; + publicFacet: StartedInstanceKit['publicFacet']; + terms: StandardTerms & FastUsdcTerms; + }, +) => { const { brands: { usdc }, utils, } = common; - - const { instance, creatorFacet, publicFacet, zoe } = - await startContract(common); - const terms = await E(zoe).getTerms(instance); - - const { add, isGTE, subtract } = AmountMath; - - const { subscriber } = E.get( - E.get(E(publicFacet).getPublicTopics()).shareWorth, - ); - const makeLP = (name, usdcPurse: ERef) => { const sharePurse = E(terms.issuers.PoolShares).makeEmptyPurse(); let deposited = AmountMath.makeEmpty(usdc.brand); const me = harden({ deposit: async (qty: bigint) => { - const { value: shareWorth } = await E(subscriber).getUpdateSince(); + const { + value: { shareWorth }, + } = await E(subscriber).getUpdateSince(); const give = { USDC: usdc.make(qty) }; const proposal = harden({ give, @@ -174,7 +188,9 @@ test('LP deposits, earns fees, withdraws', async t => { .getCurrentAmount() .then(a => a as Amount<'nat'>); const give = { PoolShare: scaleAmount(portion, myShares) }; - const { value: shareWorth } = await E(subscriber).getUpdateSince(); + const { + value: { shareWorth }, + } = await E(subscriber).getUpdateSince(); const myUSDC = multiplyBy(myShares, shareWorth); const myFees = subtract(myUSDC, deposited); t.log(name, 'sees fees earned', ...logAmt(myFees)); @@ -190,13 +206,12 @@ test('LP deposits, earns fees, withdraws', async t => { .then(pmt => E(zoe).offer(toWithdraw, proposal, { PoolShare: pmt })) .then(seat => E(seat).getPayout('USDC')); const amt = await E(usdcPurse).deposit(usdcPmt); - t.log(name, 'withdaw payout', ...logAmt(amt)); + t.log(name, 'withdraw payout', ...logAmt(amt)); t.true(isGTE(amt, proposal.want.USDC)); }, }); return me; }; - const purseOf = (value: bigint) => E(terms.issuers.USDC) .makeEmptyPurse() @@ -205,23 +220,310 @@ test('LP deposits, earns fees, withdraws', async t => { await p.deposit(pmt); return p; }); + return { makeLP, purseOf }; +}; + +test('LP deposits, earns fees, withdraws', async t => { + const common = await commonSetup(t); + const { + commonPrivateArgs, + brands: { usdc }, + utils, + } = common; + + const { instance, creatorFacet, publicFacet, zoe } = + await startContract(common); + const terms = await E(zoe).getTerms(instance); + + const { subscriber } = E.get( + E.get(E(publicFacet).getPublicTopics()).poolMetrics, + ); + const { makeLP, purseOf } = makeLpTools(t, common, { + publicFacet, + subscriber, + terms, + zoe, + }); const lps = { alice: makeLP('Alice', purseOf(60n)), bob: makeLP('Bob', purseOf(50n)), }; await Promise.all([lps.alice.deposit(60n), lps.bob.deposit(40n)]); + { - const feeAmt = usdc.make(25n); - t.log('contract accrues some amount of fees:', ...logAmt(feeAmt)); - const feePmt = await utils.pourPayment(feeAmt); - await E(creatorFacet).simulateFeesFromAdvance(feeAmt, feePmt); + t.log('simulate borrow and repay so pool accrues fees'); + const feeTools = makeFeeTools(commonPrivateArgs.feeConfig); + const requestedAmount = usdc.make(50n); + const splits = feeTools.calculateSplit(requestedAmount); + + const amt = await E(creatorFacet).testBorrow({ USDC: splits.Principal }); + t.deepEqual( + amt.USDC, + splits.Principal, + 'testBorrow returns requested amount', + ); + const repayPayments = await deeplyFulfilledObject( + objectMap(splits, utils.pourPayment), + ); + const remaining = await E(creatorFacet).testRepay(splits, repayPayments); + for (const r of Object.values(remaining)) { + t.is(r.value, 0n, 'testRepay consumes all payments'); + } } - await Promise.all([lps.alice.withdraw(0.2), lps.bob.withdraw(0.8)]); }); +test('LP borrow', async t => { + const common = await commonSetup(t); + const { + brands: { usdc }, + } = common; + + const { instance, creatorFacet, publicFacet, zoe } = + await startContract(common); + const terms = await E(zoe).getTerms(instance); + + const { subscriber } = E.get( + E.get(E(publicFacet).getPublicTopics()).poolMetrics, + ); + + const { makeLP, purseOf } = makeLpTools(t, common, { + publicFacet, + subscriber, + terms, + zoe, + }); + const lps = { + alice: makeLP('Alice', purseOf(100n)), + }; + // seed pool with funds + await lps.alice.deposit(100n); + + const { value } = await E(subscriber).getUpdateSince(); + const { shareWorth, encumberedBalance } = value; + const poolSeatAllocation = subtract( + subtract(shareWorth.numerator, encumberedBalance), + usdc.make(1n), + ); + t.log('Attempting to borrow entire pool seat allocation', poolSeatAllocation); + await t.throwsAsync( + E(creatorFacet).testBorrow({ USDC: poolSeatAllocation }), + { + message: /Cannot borrow/, + }, + 'borrow fails when requested equals pool seat allocation', + ); + + await t.throwsAsync( + E(creatorFacet).testBorrow({ USDC: usdc.make(200n) }), + { + message: /Cannot borrow/, + }, + 'borrow fails when requested exceeds pool seat allocation', + ); + + await t.throwsAsync(E(creatorFacet).testBorrow({ USDC: usdc.make(0n) }), { + message: /arg 1: USDC: value: "\[0n\]" - Must be >= "\[1n\]"/, + }); + + await t.throwsAsync( + E(creatorFacet).testBorrow( + // @ts-expect-error intentionally incorrect KW + { Fee: usdc.make(1n) }, + ), + { + message: /Must have missing properties \["USDC"\]/, + }, + ); + + // LPs can still withdraw (contract did not shutdown) + await lps.alice.withdraw(0.5); + + const amt = await E(creatorFacet).testBorrow({ USDC: usdc.make(30n) }); + t.deepEqual(amt, { USDC: usdc.make(30n) }, 'borrow succeeds'); + + await eventLoopIteration(); + t.like(await E(subscriber).getUpdateSince(), { + value: { + encumberedBalance: { + value: 30n, + }, + totalBorrows: { + value: 30n, + }, + totalRepays: { + value: 0n, + }, + }, + }); +}); + +test('LP repay', async t => { + const common = await commonSetup(t); + const { + commonPrivateArgs, + brands: { usdc }, + utils, + } = common; + + const { instance, creatorFacet, publicFacet, zoe } = + await startContract(common); + const terms = await E(zoe).getTerms(instance); + + const { subscriber } = E.get( + E.get(E(publicFacet).getPublicTopics()).poolMetrics, + ); + const feeTools = makeFeeTools(commonPrivateArgs.feeConfig); + const { makeLP, purseOf } = makeLpTools(t, common, { + publicFacet, + subscriber, + terms, + zoe, + }); + const lps = { + alice: makeLP('Alice', purseOf(100n)), + }; + // seed pool with funds + await lps.alice.deposit(100n); + + // borrow funds from pool to increase encumbered balance + await E(creatorFacet).testBorrow({ USDC: usdc.make(50n) }); + + { + t.log('cannot repay more than encumbered balance'); + const repayAmounts = feeTools.calculateSplit(usdc.make(100n)); + const repayPayments = await deeplyFulfilledObject( + objectMap(repayAmounts, utils.pourPayment), + ); + await t.throwsAsync( + E(creatorFacet).testRepay(repayAmounts, repayPayments), + { + message: /Cannot repay. Principal .* exceeds encumberedBalance/, + }, + ); + } + + { + const pmt = utils.pourPayment(usdc.make(50n)); + await t.throwsAsync( + E(creatorFacet).testRepay( + // @ts-expect-error intentionally incorrect KWR + { USDC: usdc.make(50n) }, + { USDC: pmt }, + ), + { + message: + /Must have missing properties \["Principal","PoolFee","ContractFee"\]/, + }, + ); + } + { + const pmt = utils.pourPayment(usdc.make(50n)); + await t.throwsAsync( + E(creatorFacet).testRepay( + // @ts-expect-error intentionally incorrect KWR + { Principal: usdc.make(50n) }, + { Principal: pmt }, + ), + { + message: /Must have missing properties \["PoolFee","ContractFee"\]/, + }, + ); + } + { + const amts = { + Principal: usdc.make(0n), + ContractFee: usdc.make(0n), + PoolFee: usdc.make(0n), + }; + const pmts = await deeplyFulfilledObject( + objectMap(amts, utils.pourPayment), + ); + await t.throwsAsync(E(creatorFacet).testRepay(amts, pmts), { + message: /arg 1: Principal: value: "\[0n\]" - Must be >= "\[1n\]"/, + }); + } + + { + t.log('repay fails when amounts do not match seat allocation'); + const amts = { + Principal: usdc.make(25n), + ContractFee: usdc.make(1n), + PoolFee: usdc.make(2n), + }; + const pmts = await deeplyFulfilledObject( + harden({ + Principal: utils.pourPayment(usdc.make(24n)), + ContractFee: utils.pourPayment(usdc.make(1n)), + PoolFee: utils.pourPayment(usdc.make(2n)), + }), + ); + await t.throwsAsync(E(creatorFacet).testRepay(amts, pmts), { + message: /Cannot repay. From seat allocation .* does not equal amounts/, + }); + } + + { + t.log('repay succeeds with no Pool or Contract Fee'); + const amts = { + Principal: usdc.make(25n), + ContractFee: usdc.make(0n), + PoolFee: usdc.make(0n), + }; + const pmts = await deeplyFulfilledObject( + objectMap(amts, utils.pourPayment), + ); + const repayResult = await E(creatorFacet).testRepay(amts, pmts); + + for (const r of Object.values(repayResult)) { + t.is(r.value, 0n, 'testRepay consumes all payments'); + } + } + + const amts = { + Principal: usdc.make(25n), + ContractFee: usdc.make(1n), + PoolFee: usdc.make(2n), + }; + const pmts = await deeplyFulfilledObject(objectMap(amts, utils.pourPayment)); + const repayResult = await E(creatorFacet).testRepay(amts, pmts); + + for (const r of Object.values(repayResult)) { + t.is(r.value, 0n, 'testRepay consumes all payments'); + } + + await eventLoopIteration(); + t.like(await E(subscriber).getUpdateSince(), { + value: { + encumberedBalance: { + value: 0n, + }, + totalBorrows: { + value: 50n, + }, + totalRepays: { + value: 50n, + }, + totalContractFees: { + value: 1n, + }, + totalPoolFees: { + value: 2n, + }, + shareWorth: { + numerator: { + value: 103n, // 100n (alice lp) + 1n (dust) + 2n (pool fees) + }, + }, + }, + }); + + // LPs can still withdraw (contract did not shutdown) + await lps.alice.withdraw(1); +}); + test('baggage', async t => { const { brands: { usdc }, diff --git a/packages/fast-usdc/test/mocks.ts b/packages/fast-usdc/test/mocks.ts index 244a3adf2e7..16db2a5d125 100644 --- a/packages/fast-usdc/test/mocks.ts +++ b/packages/fast-usdc/test/mocks.ts @@ -67,8 +67,8 @@ export type TestLogger = ReturnType; export const makeTestFeeConfig = (usdc: Omit): FeeConfig => harden({ - flat: usdc.units(1), + flat: usdc.make(1n), variableRate: makeRatio(2n, usdc.brand), - maxVariable: usdc.units(100), + maxVariable: usdc.make(100n), contractRate: makeRatio(20n, usdc.brand), }); diff --git a/packages/fast-usdc/test/pool-share-math.test.ts b/packages/fast-usdc/test/pool-share-math.test.ts index 9451c92e266..65870aaecfa 100644 --- a/packages/fast-usdc/test/pool-share-math.test.ts +++ b/packages/fast-usdc/test/pool-share-math.test.ts @@ -7,8 +7,10 @@ import { } from '@agoric/zoe/src/contractSupport/ratio.js'; import { mustMatch } from '@endo/patterns'; import { + borrowCalc, depositCalc, makeParity, + repayCalc, withdrawCalc, withFees, } from '../src/pool-share-math.js'; @@ -295,3 +297,200 @@ testProp( ); }, ); + +const makeInitialPoolStats = () => ({ + totalBorrows: makeEmpty(brands.USDC), + totalRepays: makeEmpty(brands.USDC), + totalPoolFees: makeEmpty(brands.USDC), + totalContractFees: makeEmpty(brands.USDC), +}); + +test('basic borrow calculation', t => { + const { USDC } = brands; + const requested = make(USDC, 100n); + const poolSeatAllocation = make(USDC, 200n); + const encumberedBalance = make(USDC, 50n); + const poolStats = makeInitialPoolStats(); + + const result = borrowCalc( + requested, + poolSeatAllocation, + encumberedBalance, + poolStats, + ); + + t.deepEqual( + result.encumberedBalance, + make(USDC, 150n), + 'Outstanding lends should increase by borrowed amount', + ); + t.deepEqual( + result.poolStats.totalBorrows, + make(USDC, 100n), + 'Total borrows should increase by borrowed amount', + ); + t.deepEqual( + Object.keys(result.poolStats), + Object.keys(poolStats), + 'borrowCalc returns all poolStats fields', + ); +}); + +test('borrow fails when requested exceeds or equals pool seat allocation', t => { + const { USDC } = brands; + const requested = make(USDC, 200n); + const poolSeatAllocation = make(USDC, 100n); + const encumberedBalance = make(USDC, 0n); + const poolStats = makeInitialPoolStats(); + + t.throws( + () => + borrowCalc(requested, poolSeatAllocation, encumberedBalance, poolStats), + { + message: /Cannot borrow/, + }, + ); + t.throws( + () => borrowCalc(requested, make(USDC, 200n), encumberedBalance, poolStats), + { + message: /Cannot borrow/, + }, + 'throw when request equals pool seat allocation', + ); + t.notThrows(() => + borrowCalc(requested, make(USDC, 201n), encumberedBalance, poolStats), + ); +}); + +test('basic repay calculation', t => { + const { USDC } = brands; + const shareWorth = makeParity(make(USDC, 1n), brands.PoolShares); + const amounts = { + Principal: make(USDC, 100n), + PoolFee: make(USDC, 10n), + ContractFee: make(USDC, 5n), + }; + const encumberedBalance = make(USDC, 200n); + const poolStats = makeInitialPoolStats(); + const fromSeatAllocation = amounts; + + const result = repayCalc( + shareWorth, + fromSeatAllocation, + amounts, + encumberedBalance, + poolStats, + ); + + t.deepEqual( + result.encumberedBalance, + make(USDC, 100n), + 'Outstanding lends should decrease by principal', + ); + t.deepEqual( + result.poolStats.totalRepays, + amounts.Principal, + 'Total repays should increase by principal', + ); + t.deepEqual( + result.poolStats.totalPoolFees, + amounts.PoolFee, + 'Total pool fees should increase by pool fee', + ); + t.deepEqual( + result.poolStats.totalContractFees, + amounts.ContractFee, + 'Total contract fees should increase by contract fee', + ); + t.deepEqual( + result.poolStats.totalBorrows, + poolStats.totalBorrows, + 'Total borrows should remain unchanged', + ); + t.deepEqual( + result.shareWorth.numerator, + make(USDC, 11n), + 'Share worth numerator should increase by pool fee', + ); + t.deepEqual( + Object.keys(result.poolStats), + Object.keys(poolStats), + 'repayCalc returns all poolStats fields', + ); +}); + +test('repay fails when principal exceeds encumbered balance', t => { + const { USDC } = brands; + + const shareWorth = makeParity(make(USDC, 1n), brands.PoolShares); + const amounts = { + Principal: make(USDC, 200n), + PoolFee: make(USDC, 10n), + ContractFee: make(USDC, 5n), + }; + const encumberedBalance = make(USDC, 100n); + const poolStats = { + ...makeInitialPoolStats(), + totalBorrows: make(USDC, 100n), + }; + + const fromSeatAllocation = amounts; + + t.throws( + () => + repayCalc( + shareWorth, + fromSeatAllocation, + amounts, + encumberedBalance, + poolStats, + ), + { + message: /Cannot repay. Principal .* exceeds encumberedBalance/, + }, + ); + + t.notThrows( + () => + repayCalc(shareWorth, fromSeatAllocation, amounts, make(USDC, 200n), { + ...makeInitialPoolStats(), + totalBorrows: make(USDC, 200n), + }), + 'repay succeeds when principal equals encumbered balance', + ); +}); + +test('repay fails when seat allocation does not equal amounts', t => { + const { USDC } = brands; + + const shareWorth = makeParity(make(USDC, 1n), brands.PoolShares); + const amounts = { + Principal: make(USDC, 200n), + PoolFee: make(USDC, 10n), + ContractFee: make(USDC, 5n), + }; + const encumberedBalance = make(USDC, 100n); + const poolStats = { + ...makeInitialPoolStats(), + totalBorrows: make(USDC, 100n), + }; + + const fromSeatAllocation = { + ...amounts, + ContractFee: make(USDC, 1n), + }; + + t.throws( + () => + repayCalc( + shareWorth, + fromSeatAllocation, + amounts, + encumberedBalance, + poolStats, + ), + { + message: /Cannot repay. From seat allocation .* does not equal amounts/, + }, + ); +}); diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md index 16063f38224..8421aa9cc3c 100644 --- a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md +++ b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.md @@ -53,10 +53,11 @@ Generated by [AVA](https://avajs.dev). 'Transaction Feed_kindHandle': 'Alleged: kind', }, 'Liquidity Pool kit': { + borrower: Object @Alleged: Liquidity Pool borrower {}, depositHandler: Object @Alleged: Liquidity Pool depositHandler {}, external: Object @Alleged: Liquidity Pool external {}, - feeSink: Object @Alleged: Liquidity Pool feeSink {}, public: Object @Alleged: Liquidity Pool public {}, + repayer: Object @Alleged: Liquidity Pool repayer {}, withdrawHandler: Object @Alleged: Liquidity Pool withdrawHandler {}, }, 'Liquidity Pool_kindHandle': 'Alleged: kind', diff --git a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap index 1aaf6a60fc1..7658bda5e41 100644 Binary files a/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap and b/packages/fast-usdc/test/snapshots/fast-usdc.contract.test.ts.snap differ