From 9c9a7a5aaeecf18b6bd74618bac66905e7df8b7f Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Thu, 14 Nov 2024 19:34:39 -0500 Subject: [PATCH] feat: advancer with fees --- .../scripts/fast-usdc/init-fast-usdc.js | 41 +++- packages/fast-usdc/src/exos/advancer.js | 16 +- packages/fast-usdc/src/fast-usdc.contract.js | 28 ++- packages/fast-usdc/src/fast-usdc.start.js | 9 +- packages/fast-usdc/src/type-guards.js | 19 +- packages/fast-usdc/src/types.ts | 7 + packages/fast-usdc/src/utils/fees.js | 57 ++++++ .../fast-usdc/test/config-marshal.test.js | 39 +++- packages/fast-usdc/test/exos/advancer.test.ts | 11 +- .../test/exos/status-manager.test.ts | 2 + .../fast-usdc/test/fast-usdc.contract.test.ts | 4 - packages/fast-usdc/test/mocks.ts | 15 +- packages/fast-usdc/test/supports.ts | 2 + packages/fast-usdc/test/utils/fees.test.ts | 176 ++++++++++++++++++ 14 files changed, 379 insertions(+), 47 deletions(-) create mode 100644 packages/fast-usdc/src/utils/fees.js create mode 100644 packages/fast-usdc/test/utils/fees.test.ts diff --git a/packages/builders/scripts/fast-usdc/init-fast-usdc.js b/packages/builders/scripts/fast-usdc/init-fast-usdc.js index 2fb5b048d47b..bfa527bcc387 100644 --- a/packages/builders/scripts/fast-usdc/init-fast-usdc.js +++ b/packages/builders/scripts/fast-usdc/init-fast-usdc.js @@ -6,7 +6,6 @@ import { getManifestForFastUSDC, } from '@agoric/fast-usdc/src/fast-usdc.start.js'; import { toExternalConfig } from '@agoric/fast-usdc/src/utils/config-marshal.js'; -import { objectMap } from '@agoric/internal'; import { multiplyBy, parseRatio, @@ -22,16 +21,26 @@ import { parseArgs } from 'node:util'; /** @type {ParseArgsConfig['options']} */ const options = { - contractFee: { type: 'string', default: '0.01' }, - poolFee: { type: 'string', default: '0.01' }, + flatFee: { type: 'string', default: '0.01' }, + variableRate: { type: 'string', default: '0.01' }, + maxVariableFee: { type: 'string', default: '5' }, + contractRate: { type: 'string', default: '0.2' }, oracle: { type: 'string', multiple: true }, + usdcDenom: { + type: 'string', + default: + 'ibc/FE98AAD68F02F03565E9FA39A5E627946699B2B07115889ED812D8BA639576A9', + }, }; const oraclesRequiredUsage = 'use --oracle name:address ...'; /** * @typedef {{ - * contractFee: string; - * poolFee: string; + * flatFee: string; + * variableRate: string; + * maxVariableFee: string; + * contractRate: string; * oracle?: string[]; + * usdcDenom: string; * }} FastUSDCOpts */ @@ -73,7 +82,7 @@ export default async (homeP, endowments) => { /** @type {{ values: FastUSDCOpts }} */ // @ts-expect-error ensured by options const { - values: { oracle: oracleArgs, ...fees }, + values: { oracle: oracleArgs, usdcDenom, ...fees }, } = parseArgs({ args: scriptArgs, options }); const parseOracleArgs = () => { @@ -88,15 +97,27 @@ export default async (homeP, endowments) => { ); }; + /** @param {string} numeral */ + const toAmount = numeral => multiplyBy(unit, parseRatio(numeral, USDC)); + /** @param {string} numeral */ + const toRatio = numeral => parseRatio(numeral, USDC); + const parseFeeConfigArgs = () => { + const { flatFee, variableRate, maxVariableFee, contractRate } = fees; + return { + flat: toAmount(flatFee), + variableRate: toRatio(variableRate), + maxVariable: toAmount(maxVariableFee), + contractRate: toRatio(contractRate), + }; + }; + /** @type {FastUSDCConfig} */ const config = harden({ oracles: parseOracleArgs(), terms: { - ...objectMap(fees, numeral => - multiplyBy(unit, parseRatio(numeral, USDC)), - ), - usdcDenom: 'ibc/usdconagoric', + usdcDenom, }, + feeConfig: parseFeeConfigArgs(), }); await writeCoreEval('start-fast-usdc', utils => diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index b6c91510902d..e772f2ecb4aa 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -8,6 +8,7 @@ import { E } from '@endo/far'; import { M } from '@endo/patterns'; import { CctpTxEvidenceShape, EudParamShape } from '../type-guards.js'; import { addressTools } from '../utils/address.js'; +import { makeFeeTools } from '../utils/fees.js'; const { isGTE } = AmountMath; @@ -17,7 +18,7 @@ const { isGTE } = AmountMath; * @import {ChainAddress, ChainHub, Denom, DenomAmount, OrchestrationAccount} from '@agoric/orchestration'; * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; - * @import {CctpTxEvidence, LogFn} from '../types.js'; + * @import {CctpTxEvidence, FeeConfig, LogFn} from '../types.js'; * @import {StatusManager} from './status-manager.js'; */ @@ -34,6 +35,7 @@ const { isGTE } = AmountMath; /** * @typedef {{ * chainHub: ChainHub; + * feeConfig: FeeConfig; * log: LogFn; * statusManager: StatusManager; * usdc: { brand: Brand<'nat'>; denom: Denom; }; @@ -75,15 +77,16 @@ const AdvancerKitI = harden({ */ export const prepareAdvancerKit = ( zone, - { chainHub, log, statusManager, usdc, vowTools: { watch, when } }, + { chainHub, feeConfig, log, statusManager, usdc, vowTools: { watch, when } }, ) => { assertAllDefined({ chainHub, + feeConfig, statusManager, watch, when, }); - + const feeTools = makeFeeTools(feeConfig); /** @param {bigint} value */ const toAmount = value => AmountMath.make(usdc.brand, value); @@ -123,14 +126,17 @@ export const prepareAdvancerKit = ( // this will throw if the bech32 prefix is not found, but is handled by the catch const destination = chainHub.makeChainAddress(EUD); const requestedAmount = toAmount(evidence.tx.amount); + const advanceAmount = feeTools.calculateAdvance(requestedAmount); // TODO: consider skipping and using `borrow()`s internal balance check const poolBalance = assetManagerFacet.lookupBalance(); if (!isGTE(poolBalance, requestedAmount)) { log( `Insufficient pool funds`, - `Requested ${q(requestedAmount)} but only have ${q(poolBalance)}`, + `Requested ${q(advanceAmount)} but only have ${q(poolBalance)}`, ); + // report `requestedAmount`, not `advancedAmount`... do we need to + // communicate net to `StatusManger` in case fees change in between? statusManager.observe(evidence); return; } @@ -147,7 +153,7 @@ export const prepareAdvancerKit = ( } try { - const payment = await assetManagerFacet.borrow(requestedAmount); + const payment = await assetManagerFacet.borrow(advanceAmount); const depositV = E(poolAccount).deposit(payment); void watch(depositV, this.facets.depositHandler, { destination, diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index dff3b89d4483..144cb0c614c6 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -1,16 +1,20 @@ import { AssetKind } from '@agoric/ertp'; import { assertAllDefined, makeTracer } from '@agoric/internal'; import { observeIteration, subscribeEach } from '@agoric/notifier'; -import { withOrchestration } from '@agoric/orchestration'; +import { + OrchestrationPowersShape, + withOrchestration, +} 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 { prepareAdvancer } from './exos/advancer.js'; import { prepareLiquidityPoolKit } from './exos/liquidity-pool.js'; import { prepareSettler } from './exos/settler.js'; import { prepareStatusManager } from './exos/status-manager.js'; import { prepareTransactionFeedKit } from './exos/transaction-feed.js'; import { defineInertInvitation } from './utils/zoe.js'; -import { FastUSDCTermsShape } from './type-guards.js'; +import { FastUSDCTermsShape, FeeConfigShape } from './type-guards.js'; const trace = makeTracer('FastUsdc'); @@ -19,18 +23,25 @@ const trace = makeTracer('FastUsdc'); * @import {OrchestrationPowers, OrchestrationTools} from '@agoric/orchestration/src/utils/start-helper.js'; * @import {Zone} from '@agoric/zone'; * @import {OperatorKit} from './exos/operator-kit.js'; - * @import {CctpTxEvidence} from './types.js'; + * @import {CctpTxEvidence, FeeConfig} from './types.js'; */ /** * @typedef {{ - * poolFee: Amount<'nat'>; - * contractFee: Amount<'nat'>; * usdcDenom: Denom; * }} FastUsdcTerms */ + +/** @type {ContractMeta} */ export const meta = { + // @ts-expect-error TypedPattern not recognized as record customTermsShape: FastUSDCTermsShape, + privateArgsShape: { + // @ts-expect-error TypedPattern not recognized as record + ...OrchestrationPowersShape, + feeConfig: FeeConfigShape, + marshaller: M.remotable(), + }, }; harden(meta); @@ -38,6 +49,7 @@ harden(meta); * @param {ZCF} zcf * @param {OrchestrationPowers & { * marshaller: Marshaller; + * feeConfig: FeeConfig; * }} privateArgs * @param {Zone} zone * @param {OrchestrationTools} tools @@ -47,17 +59,17 @@ export const contract = async (zcf, privateArgs, zone, tools) => { const terms = zcf.getTerms(); assert('USDC' in terms.brands, 'no USDC brand'); assert('usdcDenom' in terms, 'no usdcDenom'); - + const { feeConfig, marshaller } = privateArgs; const { makeRecorderKit } = prepareRecorderKitMakers( zone.mapStore('vstorage'), - privateArgs.marshaller, + marshaller, ); - const statusManager = prepareStatusManager(zone); const makeSettler = prepareSettler(zone, { statusManager }); const { chainHub, vowTools } = tools; const makeAdvancer = prepareAdvancer(zone, { chainHub, + feeConfig, log: trace, usdc: harden({ brand: terms.brands.USDC, diff --git a/packages/fast-usdc/src/fast-usdc.start.js b/packages/fast-usdc/src/fast-usdc.start.js index ad7b0ae58971..a074bd709645 100644 --- a/packages/fast-usdc/src/fast-usdc.start.js +++ b/packages/fast-usdc/src/fast-usdc.start.js @@ -3,7 +3,7 @@ import { Fail } from '@endo/errors'; import { E } from '@endo/far'; import { makeMarshal } from '@endo/marshal'; import { M } from '@endo/patterns'; -import { FastUSDCTermsShape } from './type-guards.js'; +import { FastUSDCTermsShape, FeeConfigShape } from './type-guards.js'; import { fromExternalConfig } from './utils/config-marshal.js'; /** @@ -15,6 +15,7 @@ import { fromExternalConfig } from './utils/config-marshal.js'; * @import {BootstrapManifest} from '@agoric/vats/src/core/lib-boot.js' * @import {LegibleCapData} from './utils/config-marshal.js' * @import {FastUsdcSF, FastUsdcTerms} from './fast-usdc.contract.js' + * @import {FeeConfig} from './types.js' */ const trace = makeTracer('FUSD-Start', true); @@ -25,12 +26,14 @@ const contractName = 'fastUsdc'; * @typedef {{ * terms: FastUsdcTerms; * oracles: Record; + * feeConfig: FeeConfig; * }} FastUSDCConfig */ /** @type {TypedPattern} */ export const FastUSDCConfigShape = M.splitRecord({ terms: FastUSDCTermsShape, oracles: M.recordOf(M.string(), M.string()), + feeConfig: FeeConfigShape, }); /** @@ -128,12 +131,13 @@ export const startFastUSDC = async ( USDC: await E(USDCissuer).getBrand(), }); - const { terms, oracles } = fromExternalConfig( + const { terms, oracles, feeConfig } = fromExternalConfig( config?.options, // just in case config is missing somehow brands, FastUSDCConfigShape, ); trace('using terms', terms); + trace('using fee config', feeConfig); trace('look up oracle deposit facets'); const oracleDepositFacets = await deeplyFulfilledObject( @@ -159,6 +163,7 @@ export const startFastUSDC = async ( const privateArgs = await deeplyFulfilledObject( harden({ agoricNames, + feeConfig, localchain, orchestrationService: cosmosInterchainService, storageNode, diff --git a/packages/fast-usdc/src/type-guards.js b/packages/fast-usdc/src/type-guards.js index c3ced6c695e8..f4aa9db982df 100644 --- a/packages/fast-usdc/src/type-guards.js +++ b/packages/fast-usdc/src/type-guards.js @@ -1,4 +1,4 @@ -import { BrandShape } from '@agoric/ertp'; +import { 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, PendingTx} from './types.js'; + * @import {CctpTxEvidence, FeeConfig, PendingTx} from './types.js'; */ /** @@ -31,11 +31,8 @@ export const makeProposalShapes = ({ PoolShares, USDC }) => { return harden({ deposit, withdraw }); }; -const NatAmountShape = { brand: BrandShape, value: M.nat() }; /** @type {TypedPattern} */ export const FastUSDCTermsShape = harden({ - contractFee: NatAmountShape, - poolFee: NatAmountShape, usdcDenom: M.string(), }); @@ -64,7 +61,7 @@ export const CctpTxEvidenceShape = { harden(CctpTxEvidenceShape); /** @type {TypedPattern} */ -// @ts-expect-error TypedPattern to support spreading shapes +// @ts-expect-error TypedPattern not recognized as record export const PendingTxShape = { ...CctpTxEvidenceShape, status: M.or(...Object.values(PendingTxStatus)), @@ -75,3 +72,13 @@ export const EudParamShape = { EUD: M.string(), }; harden(EudParamShape); + +const NatAmountShape = { brand: BrandShape, value: M.nat() }; +/** @type {TypedPattern} */ +export const FeeConfigShape = { + flat: NatAmountShape, + variableRate: RatioShape, + maxVariable: NatAmountShape, + contractRate: RatioShape, +}; +harden(FeeConfigShape); diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts index ed6249f2ff4b..6cb3a94cd624 100644 --- a/packages/fast-usdc/src/types.ts +++ b/packages/fast-usdc/src/types.ts @@ -35,4 +35,11 @@ export type PendingTxKey = `pendingTx:${string}`; /** internal key for `StatusManager` exo */ export type SeenTxKey = `seenTx:${string}`; +export type FeeConfig = { + flat: Amount<'nat'>; + variableRate: Ratio; + maxVariable: Amount<'nat'>; + contractRate: Ratio; +}; + export type * from './constants.js'; diff --git a/packages/fast-usdc/src/utils/fees.js b/packages/fast-usdc/src/utils/fees.js new file mode 100644 index 000000000000..56a920919157 --- /dev/null +++ b/packages/fast-usdc/src/utils/fees.js @@ -0,0 +1,57 @@ +import { AmountMath } from '@agoric/ertp'; +import { multiplyBy } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { Fail } from '@endo/errors'; +import { mustMatch } from '@endo/patterns'; +import { FeeConfigShape } from '../type-guards.js'; + +const { add, isGTE, min, subtract } = AmountMath; + +/** + * @import {Amount} from '@agoric/ertp'; + * @import {FeeConfig} from '../types.js'; + */ + +/** @param {FeeConfig} feeConfig */ +export const makeFeeTools = feeConfig => { + mustMatch(feeConfig, FeeConfigShape, 'Must provide feeConfig'); + const { flat, variableRate, maxVariable } = feeConfig; + const feeTools = harden({ + /** + * Calculate the net amount to advance after withholding fees. + * + * @param {Amount<'nat'>} requested + * @throws {Error} if request must exceed fees + */ + calculateAdvance(requested) { + const fee = feeTools.calculateAdvanceFee(requested); + return subtract(requested, fee); + }, + /** + * Calculate the total fee to charge for the advance. + * + * @param {Amount<'nat'>} requested + * @throws {Error} if request must exceed fees + */ + calculateAdvanceFee(requested) { + const variable = min(multiplyBy(requested, variableRate), maxVariable); + const fee = add(variable, flat); + !isGTE(fee, requested) || Fail`Request must exceed fees.`; + return fee; + }, + /** + * 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} + * @throws {Error} if request must exceed fees + */ + calculateSplit(requested) { + const fee = feeTools.calculateAdvanceFee(requested); + const Principal = subtract(requested, fee); + const ContractFee = multiplyBy(fee, feeConfig.contractRate); + const PoolFee = subtract(fee, ContractFee); + return harden({ Principal, PoolFee, ContractFee }); + }, + }); + return feeTools; +}; diff --git a/packages/fast-usdc/test/config-marshal.test.js b/packages/fast-usdc/test/config-marshal.test.js index 7aa5731a73f0..dbb7bfaf40f5 100644 --- a/packages/fast-usdc/test/config-marshal.test.js +++ b/packages/fast-usdc/test/config-marshal.test.js @@ -2,7 +2,8 @@ import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; import { Far } from '@endo/pass-style'; import { mustMatch } from '@endo/patterns'; import { AmountMath } from '@agoric/ertp'; -import { FastUSDCTermsShape } from '../src/type-guards.js'; +import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { FeeConfigShape } from '../src/type-guards.js'; import { fromLegible, makeMarshalFromRecord, @@ -15,7 +16,7 @@ const testMatches = (t, specimen, pattern) => { t.notThrows(() => mustMatch(specimen, pattern)); }; -test('cross-vat configuration of Fast USDC terms', t => { +test('cross-vat configuration of Fast USDC FeeConfig', t => { const context = /** @type {const} */ ({ /** @type {Brand<'nat'>} */ USDC: Far('USDC Brand'), @@ -24,11 +25,12 @@ test('cross-vat configuration of Fast USDC terms', t => { const { USDC } = context; const { make } = AmountMath; const config = harden({ - poolFee: make(USDC, 150n), - contractFee: make(USDC, 200n), - usdcDenom: 'ibc/usdconagoric', + flat: make(USDC, 100n), + variableRate: makeRatio(1n, USDC), + maxVariable: make(USDC, 100_000n), + contractRate: makeRatio(20n, USDC), }); - testMatches(t, config, FastUSDCTermsShape); + testMatches(t, config, FeeConfigShape); const m = makeMarshalFromRecord(context); /** @type {any} */ // XXX struggling with recursive type @@ -36,9 +38,28 @@ test('cross-vat configuration of Fast USDC terms', t => { t.deepEqual(legible, { structure: { - contractFee: { brand: '$0.Alleged: USDC Brand', value: '+200' }, - poolFee: { brand: '$0', value: '+150' }, - usdcDenom: 'ibc/usdconagoric', + contractRate: { + denominator: { + brand: '$0.Alleged: USDC Brand', + value: '+100', + }, + numerator: { + brand: '$0', + value: '+20', + }, + }, + flat: { brand: '$0', value: '+100' }, + maxVariable: { brand: '$0', value: '+100000' }, + variableRate: { + denominator: { + brand: '$0', + value: '+100', + }, + numerator: { + brand: '$0', + value: '+1', + }, + }, }, slots: ['USDC'], }); diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts index e525d32dadc1..0e1c6a6c6dfd 100644 --- a/packages/fast-usdc/test/exos/advancer.test.ts +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -13,7 +13,11 @@ import { prepareStatusManager } from '../../src/exos/status-manager.js'; import { commonSetup } from '../supports.js'; import { MockCctpTxEvidences } from '../fixtures.js'; -import { makeTestLogger, prepareMockOrchAccounts } from '../mocks.js'; +import { + makeTestFeeConfig, + makeTestLogger, + prepareMockOrchAccounts, +} from '../mocks.js'; const LOCAL_DENOM = `ibc/${denomHash({ denom: 'uusdc', @@ -46,8 +50,10 @@ const createTestExtensions = (t, common: CommonSetup) => { usdc, }); + const feeConfig = makeTestFeeConfig(usdc); const makeAdvancer = prepareAdvancer(rootZone.subZone('advancer'), { chainHub, + feeConfig, log, statusManager, usdc: harden({ @@ -90,6 +96,7 @@ const createTestExtensions = (t, common: CommonSetup) => { return { constants: { localDenom: LOCAL_DENOM, + feeConfig, }, helpers: { inspectLogs, @@ -187,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":"[200000000n]"} but only have {"brand":"[Alleged: USDC brand]","value":"[1n]"}', + 'Requested {"brand":"[Alleged: USDC brand]","value":"[195000000n]"} but only have {"brand":"[Alleged: USDC brand]","value":"[1n]"}', ]); }); diff --git a/packages/fast-usdc/test/exos/status-manager.test.ts b/packages/fast-usdc/test/exos/status-manager.test.ts index ee892fee00d9..8b87fe23ddbe 100644 --- a/packages/fast-usdc/test/exos/status-manager.test.ts +++ b/packages/fast-usdc/test/exos/status-manager.test.ts @@ -19,6 +19,7 @@ test('advance creates new entry with ADVANCED status', t => { t.is(entries[0]?.status, PendingTxStatus.Advanced); }); +test.todo('ADVANCED transactions are published to vstorage'); test('observe creates new entry with OBSERVED status', t => { const zone = provideDurableZone('status-test'); @@ -34,6 +35,7 @@ test('observe creates new entry with OBSERVED status', t => { t.is(entries[0]?.status, PendingTxStatus.Observed); }); +test.todo('OBSERVED transactions are published to vstorage'); test('cannot process same tx twice', t => { const zone = provideDurableZone('status-test'); diff --git a/packages/fast-usdc/test/fast-usdc.contract.test.ts b/packages/fast-usdc/test/fast-usdc.contract.test.ts index 118ff57b00c2..80d3a0b1d3f2 100644 --- a/packages/fast-usdc/test/fast-usdc.contract.test.ts +++ b/packages/fast-usdc/test/fast-usdc.contract.test.ts @@ -51,8 +51,6 @@ const startContract = async ( installation, { USDC: usdc.issuer }, { - poolFee: usdc.make(1n), - contractFee: usdc.make(1n), usdcDenom: 'ibc/usdconagoric', }, commonPrivateArgs, @@ -243,8 +241,6 @@ test('baggage', async t => { installation, { USDC: usdc.issuer }, { - poolFee: usdc.make(1n), - contractFee: usdc.make(1n), usdcDenom: 'ibc/usdconagoric', }, commonPrivateArgs, diff --git a/packages/fast-usdc/test/mocks.ts b/packages/fast-usdc/test/mocks.ts index bb3d533f437e..244a3adf2e7b 100644 --- a/packages/fast-usdc/test/mocks.ts +++ b/packages/fast-usdc/test/mocks.ts @@ -6,7 +6,12 @@ import type { } from '@agoric/orchestration'; import type { Zone } from '@agoric/zone'; import type { VowTools } from '@agoric/vow'; -import type { LogFn } from '../src/types.js'; +import { makeRatio } from '@agoric/zoe/src/contractSupport/ratio.js'; +import type { + AmountUtils, + withAmountUtils, +} from '@agoric/zoe/tools/test-utils.js'; +import type { FeeConfig, LogFn } from '../src/types.js'; export const prepareMockOrchAccounts = ( zone: Zone, @@ -59,3 +64,11 @@ export const makeTestLogger = (logger: LogFn) => { }; export type TestLogger = ReturnType; + +export const makeTestFeeConfig = (usdc: Omit): FeeConfig => + harden({ + flat: usdc.units(1), + variableRate: makeRatio(2n, usdc.brand), + maxVariable: usdc.units(100), + contractRate: makeRatio(20n, usdc.brand), + }); diff --git a/packages/fast-usdc/test/supports.ts b/packages/fast-usdc/test/supports.ts index 80dd15180d51..82e8955c4bcc 100644 --- a/packages/fast-usdc/test/supports.ts +++ b/packages/fast-usdc/test/supports.ts @@ -28,6 +28,7 @@ import { makeHeapZone, type Zone } from '@agoric/zone'; import { makeDurableZone } from '@agoric/zone/durable.js'; import { E } from '@endo/far'; import type { ExecutionContext } from 'ava'; +import { makeTestFeeConfig } from './mocks.js'; export { makeFakeLocalchainBridge, @@ -184,6 +185,7 @@ export const commonSetup = async (t: ExecutionContext) => { storageNode: storage.rootNode, marshaller, timerService: timer, + feeConfig: makeTestFeeConfig(usdc), }, facadeServices: { agoricNames, diff --git a/packages/fast-usdc/test/utils/fees.test.ts b/packages/fast-usdc/test/utils/fees.test.ts new file mode 100644 index 000000000000..5da214a8e26f --- /dev/null +++ b/packages/fast-usdc/test/utils/fees.test.ts @@ -0,0 +1,176 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { makeIssuerKit, AmountMath } from '@agoric/ertp'; +import { makeRatioFromAmounts } from '@agoric/zoe/src/contractSupport/ratio.js'; +import { makeFeeTools } from '../../src/utils/fees.js'; +import type { FeeConfig } from '../../src/types.js'; + +const { add, isEqual } = AmountMath; + +const issuerKits = { + USDC: makeIssuerKit<'nat'>('USDC'), +}; +const { brand: usdcBrand } = issuerKits.USDC; + +const USDC = (value: bigint) => AmountMath.make(usdcBrand, value); +const USDCRatio = (numerator: bigint, denominator: bigint = 100n) => + makeRatioFromAmounts(USDC(numerator), USDC(denominator)); + +const aFeeConfig: FeeConfig = { + flat: USDC(10n), + variableRate: USDCRatio(2n), + maxVariable: USDC(100n), + contractRate: USDCRatio(20n), +}; +harden(aFeeConfig); + +type FeelToolsScenario = { + name: string; + config?: FeeConfig; + requested: Amount<'nat'>; + expected: { + totalFee: Amount<'nat'>; + advance: Amount<'nat'>; + split: { + ContractFee: Amount<'nat'>; + PoolFee: Amount<'nat'>; + }; + }; +}; + +const feeToolsScenario = test.macro({ + title: (_, { name }: FeelToolsScenario) => `fee calcs: ${name}`, + exec: ( + t, + { config = aFeeConfig, requested, expected }: FeelToolsScenario, + ) => { + const { totalFee, advance, split } = expected; + + t.true( + isEqual(totalFee, add(split.ContractFee, split.PoolFee)), + 'sanity check: total fee equals sum of splits', + ); + t.true( + isEqual(requested, add(totalFee, advance)), + 'sanity check: requested equals advance plus fee', + ); + + const feeTools = makeFeeTools(harden(config)); + t.deepEqual(feeTools.calculateAdvanceFee(requested), totalFee); + t.deepEqual(feeTools.calculateAdvance(requested), advance); + t.deepEqual(feeTools.calculateSplit(requested), { + ...split, + Principal: advance, + }); + }, +}); + +test(feeToolsScenario, { + name: 'below max variable fee', + requested: USDC(1000n), + expected: { + totalFee: USDC(30n), // 10 flat + 20 variable + advance: USDC(970n), + split: { + ContractFee: USDC(6n), + PoolFee: USDC(24n), + }, + }, +}); + +test(feeToolsScenario, { + name: 'above max variable fee', + requested: USDC(10000n), + expected: { + totalFee: USDC(110n), // 10 flat + 100 max + advance: USDC(9890n), + split: { + ContractFee: USDC(22n), + PoolFee: USDC(88n), + }, + }, +}); + +test(feeToolsScenario, { + name: 'zero variable fee', + requested: USDC(1000n), + config: { + ...aFeeConfig, + variableRate: USDCRatio(0n), + }, + expected: { + totalFee: USDC(10n), // only flat + advance: USDC(990n), + split: { + ContractFee: USDC(2n), + PoolFee: USDC(8n), + }, + }, +}); + +test(feeToolsScenario, { + name: 'zero flat fee', + requested: USDC(1000n), + config: { + ...aFeeConfig, + flat: USDC(0n), + }, + expected: { + totalFee: USDC(20n), // only variable + advance: USDC(980n), + split: { + ContractFee: USDC(4n), + PoolFee: USDC(16n), + }, + }, +}); + +test(feeToolsScenario, { + name: 'zero fees (free)', + requested: USDC(100n), + config: { + ...aFeeConfig, + flat: USDC(0n), + variableRate: USDCRatio(0n), + }, + expected: { + totalFee: USDC(0n), // no fee charged + advance: USDC(100n), + split: { + ContractFee: USDC(0n), + PoolFee: USDC(0n), + }, + }, +}); + +test(feeToolsScenario, { + // TODO consider behavior where 0 or undefined means "no fee cap" + name: 'only flat is charged if `maxVariable: 0`', + requested: USDC(10000n), + config: { + ...aFeeConfig, + variableRate: USDCRatio(20n), + maxVariable: USDC(0n), + }, + expected: { + totalFee: USDC(10n), // only flat + advance: USDC(9990n), + split: { + ContractFee: USDC(2n), + PoolFee: USDC(8n), + }, + }, +}); + +test.only('request must exceed fees', t => { + const feeTools = makeFeeTools(aFeeConfig); + + const expectedError = { message: 'Request must exceed fees.' }; + + for (const requested of [0n, 2n, 10n].map(USDC)) { + t.throws(() => feeTools.calculateAdvanceFee(requested), expectedError); + t.throws(() => feeTools.calculateAdvance(requested), expectedError); + t.throws(() => feeTools.calculateSplit(requested), expectedError); + } + // advance can be smaller than fee + t.is(feeTools.calculateAdvanceFee(USDC(11n)).value, 10n); +});