diff --git a/packages/zoe/src/contracts/constantProduct/calcFees.js b/packages/zoe/src/contracts/constantProduct/calcFees.js index 9e3a9fc52dd..40f504dadcb 100644 --- a/packages/zoe/src/contracts/constantProduct/calcFees.js +++ b/packages/zoe/src/contracts/constantProduct/calcFees.js @@ -8,11 +8,9 @@ import { BASIS_POINTS } from './defaults.js'; const { details: X } = assert; /** - * Make a ratio given a nat representing basis points + * Make a ratio given a nat representing basis points and a brand. * - * @param {NatValue} feeBP - * @param {Brand} brandOfFee - * @returns {Ratio} + * @type {MakeFeeRatio} */ const makeFeeRatio = (feeBP, brandOfFee) => { return makeRatio(feeBP, brandOfFee, BASIS_POINTS); diff --git a/packages/zoe/src/contracts/constantProduct/types.js b/packages/zoe/src/contracts/constantProduct/types.js index fc2bba0cb07..7fe86720da6 100644 --- a/packages/zoe/src/contracts/constantProduct/types.js +++ b/packages/zoe/src/contracts/constantProduct/types.js @@ -7,6 +7,23 @@ * @returns {Amount} */ +/** + * A bigint representing a number that will be divided by 10,0000. Financial + * ratios are often represented in basis points. + * + * @typedef {bigint} BASIS_POINTS + */ + +/** + * Make a Ratio representing a fee expressed in Basis Points. (hundredths of a + * percent) + * + * @callback MakeFeeRatio + * @param {BASIS_POINTS} feeBP + * @param {Brand} brandOfFee + * @returns {Ratio} + */ + /** * @callback AmountGT * @param {Amount} left diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/addLiquidity.js b/packages/zoe/src/contracts/vpool-xyk-amm/addLiquidity.js new file mode 100644 index 00000000000..3544b3c7e11 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/addLiquidity.js @@ -0,0 +1,30 @@ +// @ts-check + +import { assertProposalShape } from '../../contractSupport/index.js'; + +import '../../../exported.js'; + +/** + * @param {ContractFacet} zcf + * @param {(brand: Brand) => XYKPool} getPool + */ +export const makeMakeAddLiquidityInvitation = (zcf, getPool) => { + const addLiquidity = seat => { + assertProposalShape(seat, { + give: { + Central: null, + Secondary: null, + }, + want: { Liquidity: null }, + }); + // Get the brand of the secondary token so we can identify the liquidity pool. + const secondaryBrand = seat.getProposal().give.Secondary.brand; + const pool = getPool(secondaryBrand); + return pool.addLiquidity(seat); + }; + + const makeAddLiquidityInvitation = () => + zcf.makeInvitation(addLiquidity, 'multipool autoswap add liquidity'); + + return makeAddLiquidityInvitation; +}; diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/doublePool.js b/packages/zoe/src/contracts/vpool-xyk-amm/doublePool.js new file mode 100644 index 00000000000..e75b49f8148 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/doublePool.js @@ -0,0 +1,193 @@ +// @ts-check + +import { AmountMath } from '@agoric/ertp'; +import { assert, details as X } from '@agoric/assert'; +import { makeFeeRatio } from '../constantProduct/calcFees'; +import { + pricesForStatedInput, + pricesForStatedOutput, +} from '../constantProduct/calcSwapPrices.js'; + +// Price calculations and swap using a pair of pools. Both pools map between RUN +// and some collateral. We arrange the trades so collateralInPool will have +// collateral added and collateralOutPool subtracted. When traders specify an +// input price, that brand will be the inPool; when they specify the output +// price that brand is the outPool. + +const publicPrices = prices => { + return { amountIn: prices.swapperGives, amountOut: prices.swapperGets }; +}; + +/** + * @param {ContractFacet} zcf + * @param {XYKPool} collateralInPool + * @param {XYKPool} collateralOutPool + * @param {BASIS_POINTS} protocolFeeBP + * @param {BASIS_POINTS} poolFeeBP + * @param {ZCFSeat} feeSeat + * @returns {VPool} + */ +export const makeDoublePool = ( + zcf, + collateralInPool, + collateralOutPool, + protocolFeeBP, + poolFeeBP, + feeSeat, +) => { + const inCentral = collateralInPool.getCentralAmount(); + const inSecondary = collateralInPool.getSecondaryAmount(); + + const outCentral = collateralOutPool.getCentralAmount(); + const outSecondary = collateralOutPool.getSecondaryAmount(); + + const inAllocation = { Central: inCentral, Secondary: inSecondary }; + const outAllocation = { Central: outCentral, Secondary: outSecondary }; + + const centralBrand = inCentral.brand; + const centralFeeRatio = makeFeeRatio(poolFeeBP, centralBrand); + const emptyCentralAmount = AmountMath.makeEmpty(centralBrand); + const protocolFeeRatio = makeFeeRatio(protocolFeeBP, centralBrand); + assert( + centralBrand === outCentral.brand, + X`The central brands on the two pools must match: ${centralBrand}, ${outCentral.brand}`, + ); + + const allocateGainsAndLosses = (seat, prices) => { + const inPoolSeat = collateralInPool.getPoolSeat(); + const outPoolSeat = collateralOutPool.getPoolSeat(); + + seat.decrementBy({ In: prices.swapperGives }); + seat.incrementBy({ Out: prices.swapperGets }); + feeSeat.incrementBy({ RUN: prices.protocolFee }); + inPoolSeat.incrementBy({ Secondary: prices.inPoolIncrement }); + inPoolSeat.decrementBy({ Central: prices.inPoolDecrement }); + outPoolSeat.incrementBy({ Central: prices.outPoolIncrement }); + outPoolSeat.decrementBy({ Secondary: prices.outpoolDecrement }); + + zcf.reallocate(outPoolSeat, inPoolSeat, feeSeat, seat); + seat.exit(); + collateralInPool.updateState(); + collateralOutPool.updateState(); + return `Swap successfully completed.`; + }; + + const getPriceForInput = (amountIn, amountOut) => { + // We must do two consecutive swapInPrice() calls, + // followed by a call to swapOutPrice(). + // 1) from amountIn to the central token, which tells us how much central + // would be provided for amountIn, + // 2) from that amount of central to brandOut, which tells us how much of + // brandOut will be provided as well as the minimum price in central + // tokens, then finally + // 3) call swapOutPrice() to see if the same proceeds can be purchased for + // less. + // Notice that in the second call, the original amountOut is used, and in + // the third call, the original amountIn is used. + const interimInpoolPrices = pricesForStatedInput( + amountIn, + inAllocation, + emptyCentralAmount, + protocolFeeRatio, + centralFeeRatio, + ); + const outPoolPrices = pricesForStatedInput( + interimInpoolPrices.swapperGets, + outAllocation, + amountOut, + protocolFeeRatio, + makeFeeRatio(poolFeeBP, amountOut.brand), + ); + const finalInPoolPrices = pricesForStatedOutput( + amountIn, + inAllocation, + outPoolPrices.swapperGives, + protocolFeeRatio, + centralFeeRatio, + ); + return harden({ + swapperGives: finalInPoolPrices.swapperGives, + swapperGets: outPoolPrices.swapperGets, + inPoolIncrement: finalInPoolPrices.xIncrement, + inPoolDecrement: finalInPoolPrices.yDecrement, + outPoolIncrement: outPoolPrices.xIncrement, + outpoolDecrement: outPoolPrices.yDecrement, + protocolFee: AmountMath.add( + finalInPoolPrices.protocolFee, + outPoolPrices.protocolFee, + ), + }); + }; + + const getInputPrice = (amountIn, amountOut) => { + return publicPrices(getPriceForInput(amountIn, amountOut)); + }; + + const swapIn = (seat, amountIn, amountOut) => { + const prices = getPriceForInput(amountIn, amountOut); + return allocateGainsAndLosses(seat, prices); + }; + + const getPriceForOutput = (amountIn, amountOut) => { + // We must do two consecutive swapOutPrice() calls, followed by a call to + // swapInPrice(). + // 1) from amountOut to the central token, which tells us how much central + // is required to obtain amountOut, + // 2) from that amount of central to brandIn, which tells us how much of + // brandIn is required as well as the max proceeds in central tokens, then + // finally + // 3) call swapInPrice() to see if those central proceeds could purchase + // larger amount + // Notice that the amountIn parameter to the first call to swapOutPrice + // specifies an empty amount. This is interpreted as "no limit", which is + // necessary since we can't guess a reasonable maximum of the central token. + const interimOutpoolPrices = pricesForStatedOutput( + emptyCentralAmount, + outAllocation, + amountOut, + protocolFeeRatio, + centralFeeRatio, + ); + const inpoolPrices = pricesForStatedOutput( + amountIn, + inAllocation, + interimOutpoolPrices.swapperGets, + protocolFeeRatio, + makeFeeRatio(poolFeeBP, amountIn.brand), + ); + const finalOutpoolPrices = pricesForStatedInput( + inpoolPrices.swapperGives, + outAllocation, + amountOut, + protocolFeeRatio, + centralFeeRatio, + ); + return harden({ + swapperGives: inpoolPrices.swapperGives, + swapperGets: finalOutpoolPrices.swapperGets, + inPoolIncrement: inpoolPrices.xIncrement, + inPoolDecrement: inpoolPrices.yDecrement, + outPoolIncrement: finalOutpoolPrices.xIncrement, + outpoolDecrement: finalOutpoolPrices.yDecrement, + protocolFee: AmountMath.add( + finalOutpoolPrices.protocolFee, + inpoolPrices.protocolFee, + ), + }); + }; + + const getOutputPrice = (amountIn, amountOut) => { + return publicPrices(getPriceForOutput(amountIn, amountOut)); + }; + const swapOut = (seat, amountIn, amountOut) => { + const prices = getPriceForOutput(amountIn, amountOut); + return allocateGainsAndLosses(seat, prices); + }; + + return { + getInputPrice, + getOutputPrice, + swapIn, + swapOut, + }; +}; diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/multipoolMarketMaker.js b/packages/zoe/src/contracts/vpool-xyk-amm/multipoolMarketMaker.js new file mode 100644 index 00000000000..90d3634fb49 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/multipoolMarketMaker.js @@ -0,0 +1,200 @@ +// @ts-check + +import { assert, details as X } from '@agoric/assert'; +import { makeWeakStore } from '@agoric/store'; +import { Far } from '@agoric/marshal'; + +import { AssetKind, makeIssuerKit } from '@agoric/ertp'; +import { assertIssuerKeywords } from '../../contractSupport'; +import { makeAddPool } from './pool.js'; +import { makeMakeAddLiquidityInvitation } from './addLiquidity.js'; +import { makeMakeRemoveLiquidityInvitation } from './removeLiquidity.js'; + +import '../../../exported.js'; +import { makeMakeCollectFeesInvitation } from '../newSwap/collectFees.js'; +import { makeMakeSwapInvitation } from './swap'; +import { makeDoublePool } from './doublePool'; + +/** + * Multipool AMM is a rewrite of Uniswap that supports multiple liquidity pools, + * and direct exchanges across pools. Please see the documentation for more: + * https://agoric.com/documentation/zoe/guide/contracts/multipoolAMM.html It + * also uses a unique approach to charging fees. Each pool grows on every trade, + * and a protocolFee is also extracted. + * + * We expect that this contract will have tens to hundreds of issuers. Each + * liquidity pool is between the central token and a secondary token. Secondary + * tokens can be exchanged with each other, but only through the central + * token. For example, if X and Y are two token types and C is the central + * token, a swap giving X and wanting Y would first use the pool (X, C) then the + * pool (Y, C). There are no liquidity pools directly between two secondary + * tokens. + * + * There should only need to be one instance of this contract, so liquidity can + * be shared as much as possible. + * + * When the contract is instantiated, the central token is specified in the + * terms. Separate invitations are available by calling methods on the + * publicFacet for adding and removing liquidity and for making trades. Other + * publicFacet operations support querying prices and the sizes of pools. New + * Pools can be created with addPool(). + * + * When making trades or requesting prices, the caller must specify either a + * maximum input amount (swapIn, getInputPrice) or a minimum output amount + * (swapOut, getOutPutPrice) or both. For swaps, the required keywords are `In` + * for the trader's `give` amount, and `Out` for the trader's `want` amount. + * getInputPrice and getOutputPrice each take two Amounts. The price functions + * return both amountIn (which may be lower than the original amount) and + * amountOut (which may be higher). When both prices are specified, no swap will + * be made (and no price provided) if both restrictions can't be honored. + * + * When adding and removing liquidity, the keywords are Central, Secondary, and + * Liquidity. adding liquidity has Central and Secondary in the `give` section, + * while removing liquidity has `want` and `give` swapped. + * + * Transactions that don't require an invitation include addPool and the + * queries: getInputPrice, getOutputPrice, getPoolAllocation, + * getLiquidityIssuer, and getLiquiditySupply. + * + * @type {ContractStartFn} + */ +const start = zcf => { + /** + * This contract must have a "Central" keyword and issuer in the + * IssuerKeywordRecord. + * + * @typedef {{ + * brands: { Central: Brand }, + * timer: TimerService, + * poolFeeBP: BasisPoints, // portion of the fees that go into the pool + * protocolFeeBP: BasisPoints, // portion of the fees that are shared with validators + * }} AMMTerms + * + * @typedef { bigint } BasisPoints -- hundredths of a percent + */ + const { + brands: { Central: centralBrand }, + timer, + poolFeeBP, + protocolFeeBP, + } = /** @type { Terms & AMMTerms } */ (zcf.getTerms()); + assertIssuerKeywords(zcf, ['Central']); + assert(centralBrand !== undefined, X`centralBrand must be present`); + + /** @type {WeakStore} */ + const secondaryBrandToPool = makeWeakStore('secondaryBrand'); + const getPool = secondaryBrandToPool.get; + const initPool = secondaryBrandToPool.init; + const isSecondary = secondaryBrandToPool.has; + + const quoteIssuerKit = makeIssuerKit('Quote', AssetKind.SET); + + // For now, this seat collects protocol fees. It needs to be connected to + // something that will extract the fees. + const { zcfSeat: protocolSeat } = zcf.makeEmptySeatKit(); + + const getLiquiditySupply = brand => getPool(brand).getLiquiditySupply(); + const getLiquidityIssuer = brand => getPool(brand).getLiquidityIssuer(); + const addPool = makeAddPool( + zcf, + isSecondary, + initPool, + centralBrand, + timer, + quoteIssuerKit, + protocolFeeBP, + poolFeeBP, + protocolSeat, + ); + const getPoolAllocation = brand => { + return getPool(brand) + .getPoolSeat() + .getCurrentAllocation(); + }; + + const getPriceAuthorities = brand => { + const pool = getPool(brand); + return { + toCentral: pool.getToCentralPriceAuthority(), + fromCentral: pool.getFromCentralPriceAuthority(), + }; + }; + + /** + * @param {Brand} brandIn + * @param {Brand} brandOut + * @returns {VPool} + */ + const provideVPool = (brandIn, brandOut) => { + if (isSecondary(brandIn) && isSecondary(brandOut)) { + return makeDoublePool( + zcf, + getPool(brandIn), + getPool(brandOut), + protocolFeeBP, + poolFeeBP, + protocolSeat, + ); + } + + const pool = isSecondary(brandOut) ? getPool(brandOut) : getPool(brandIn); + return pool.getVPool(); + }; + + const getInputPrice = (amountIn, amountOut) => { + const pool = provideVPool(amountIn.brand, amountOut.brand); + return pool.getInputPrice(amountIn, amountOut); + }; + const getOutputPrice = (amountIn, amountOut) => { + const pool = provideVPool(amountIn.brand, amountOut.brand); + return pool.getOutputPrice(amountIn, amountOut); + }; + + const { + makeSwapInInvitation, + makeSwapOutInvitation, + } = makeMakeSwapInvitation(zcf, provideVPool, poolFeeBP); + const makeAddLiquidityInvitation = makeMakeAddLiquidityInvitation( + zcf, + getPool, + ); + + const makeRemoveLiquidityInvitation = makeMakeRemoveLiquidityInvitation( + zcf, + getPool, + ); + + const { makeCollectFeesInvitation } = makeMakeCollectFeesInvitation( + zcf, + protocolSeat, + centralBrand, + ); + const creatorFacet = Far('Creator Facet', { + makeCollectFeesInvitation, + }); + + /** @type {XYKAMMPublicFacet} */ + const publicFacet = Far('MultipoolAutoswapPublicFacet', { + addPool, + getPoolAllocation, + getLiquidityIssuer, + getLiquiditySupply, + getInputPrice, + getOutputPrice, + makeSwapInvitation: makeSwapInInvitation, + makeSwapInInvitation, + makeSwapOutInvitation, + makeAddLiquidityInvitation, + makeRemoveLiquidityInvitation, + getQuoteIssuer: () => quoteIssuerKit.issuer, + getPriceAuthorities, + getAllPoolBrands: () => + Object.values(zcf.getTerms().brands).filter(isSecondary), + getProtocolPoolBalance: () => protocolSeat.getCurrentAllocation(), + }); + + return harden({ publicFacet, creatorFacet }); +}; + +harden(start); +export { start }; diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/pool.js b/packages/zoe/src/contracts/vpool-xyk-amm/pool.js new file mode 100644 index 00000000000..a2b87dd23e1 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/pool.js @@ -0,0 +1,264 @@ +// @ts-check + +import { E } from '@agoric/eventual-send'; +import { assert, details as X } from '@agoric/assert'; +import { AssetKind, AmountMath, isNatValue } from '@agoric/ertp'; +import { makeNotifierKit } from '@agoric/notifier'; + +import { + calcLiqValueToMint, + calcValueToRemove, + calcSecondaryRequired, +} from '../../contractSupport/index.js'; + +import '../../../exported.js'; +import { makePriceAuthority } from '../multipoolAutoswap/priceAuthority.js'; +import { makeSinglePool } from './singlePool'; + +// Pools represent a single pool of liquidity. Price calculations and trading +// happen in a wrapper class that knows whether the proposed trade involves a +// single pool or multiple hops. + +/** + * @param {ContractFacet} zcf + * @param {(brand: Brand) => boolean} isInSecondaries + * @param {(brand: Brand, pool: XYKPool) => void} initPool + * @param {Brand} centralBrand + * @param {ERef} timer + * @param {IssuerKit} quoteIssuerKit + * @param {BASIS_POINTS} protocolFeeBP - soon to be replaced with governed value + * @param {BASIS_POINTS} poolFeeBP - soon to be replaced with governed value + * @param {ZCFSeat} protocolSeat + */ +export const makeAddPool = ( + zcf, + isInSecondaries, + initPool, + centralBrand, + timer, + quoteIssuerKit, + protocolFeeBP, + poolFeeBP, + protocolSeat, +) => { + const makePool = (liquidityZcfMint, poolSeat, secondaryBrand) => { + let liqTokenSupply = 0n; + + const { + brand: liquidityBrand, + issuer: liquidityIssuer, + } = liquidityZcfMint.getIssuerRecord(); + const { notifier, updater } = makeNotifierKit(); + + const updateState = pool => + // TODO: when governance can change the interest rate, include it here + updater.updateState({ + central: pool.getCentralAmount(), + secondary: pool.getSecondaryAmount(), + }); + + const addLiquidityActual = (pool, zcfSeat, secondaryAmount) => { + // addLiquidity can't be called until the pool has been created. We verify + // that the asset is NAT before creating a pool. + + const liquidityValueOut = calcLiqValueToMint( + liqTokenSupply, + zcfSeat.getAmountAllocated('Central').value, + pool.getCentralAmount().value, + ); + + const liquidityAmountOut = AmountMath.make( + liquidityValueOut, + liquidityBrand, + ); + liquidityZcfMint.mintGains({ Liquidity: liquidityAmountOut }, poolSeat); + liqTokenSupply += liquidityValueOut; + + poolSeat.incrementBy( + zcfSeat.decrementBy({ + Central: zcfSeat.getCurrentAllocation().Central, + Secondary: secondaryAmount, + }), + ); + + zcfSeat.incrementBy( + poolSeat.decrementBy({ Liquidity: liquidityAmountOut }), + ); + zcf.reallocate(poolSeat, zcfSeat); + zcfSeat.exit(); + updateState(pool); + return 'Added liquidity.'; + }; + + /** @type {XYKPool} */ + const pool = { + getLiquiditySupply: () => liqTokenSupply, + getLiquidityIssuer: () => liquidityIssuer, + getPoolSeat: () => poolSeat, + getCentralAmount: () => + poolSeat.getAmountAllocated('Central', centralBrand), + getSecondaryAmount: () => + poolSeat.getAmountAllocated('Secondary', secondaryBrand), + + addLiquidity: zcfSeat => { + if (liqTokenSupply === 0n) { + const userAllocation = zcfSeat.getCurrentAllocation(); + return addLiquidityActual(pool, zcfSeat, userAllocation.Secondary); + } + + const userAllocation = zcfSeat.getCurrentAllocation(); + const secondaryIn = userAllocation.Secondary; + const centralAmount = pool.getCentralAmount(); + const secondaryAmount = pool.getSecondaryAmount(); + assert(isNatValue(userAllocation.Central.value)); + assert(isNatValue(centralAmount.value)); + assert(isNatValue(secondaryAmount.value)); + assert(isNatValue(secondaryIn.value)); + + // To calculate liquidity, we'll need to calculate alpha from the primary + // token's value before, and the value that will be added to the pool + const secondaryOut = AmountMath.make( + calcSecondaryRequired( + userAllocation.Central.value, + centralAmount.value, + secondaryAmount.value, + secondaryIn.value, + ), + secondaryBrand, + ); + + // Central was specified precisely so offer must provide enough secondary. + assert( + AmountMath.isGTE(secondaryIn, secondaryOut), + 'insufficient Secondary deposited', + ); + + return addLiquidityActual(pool, zcfSeat, secondaryOut); + }, + removeLiquidity: userSeat => { + const liquidityIn = userSeat.getAmountAllocated( + 'Liquidity', + liquidityBrand, + ); + const liquidityValueIn = liquidityIn.value; + assert(isNatValue(liquidityValueIn)); + const centralTokenAmountOut = AmountMath.make( + calcValueToRemove( + liqTokenSupply, + pool.getCentralAmount().value, + liquidityValueIn, + ), + centralBrand, + ); + + const tokenKeywordAmountOut = AmountMath.make( + calcValueToRemove( + liqTokenSupply, + pool.getSecondaryAmount().value, + liquidityValueIn, + ), + secondaryBrand, + ); + + liqTokenSupply -= liquidityValueIn; + + poolSeat.incrementBy(userSeat.decrementBy({ Liquidity: liquidityIn })); + userSeat.incrementBy( + poolSeat.decrementBy({ + Central: centralTokenAmountOut, + Secondary: tokenKeywordAmountOut, + }), + ); + zcf.reallocate(userSeat, poolSeat); + + userSeat.exit(); + updateState(pool); + return 'Liquidity successfully removed.'; + }, + getNotifier: () => notifier, + updateState: () => updateState(pool), + // eslint-disable-next-line no-use-before-define + getToCentralPriceAuthority: () => toCentralPriceAuthority, + // eslint-disable-next-line no-use-before-define + getFromCentralPriceAuthority: () => fromCentralPriceAuthority, + // eslint-disable-next-line no-use-before-define + getVPool: () => vPool, + }; + + const vPool = makeSinglePool( + zcf, + pool, + protocolFeeBP, + poolFeeBP, + protocolSeat, + ); + + const getInputPriceForPA = (amountIn, brandOut) => + vPool.getInputPrice(amountIn, AmountMath.makeEmpty(brandOut)); + const getOutputPriceForPA = (brandIn, amountout) => + vPool.getInputPrice(AmountMath.makeEmpty(brandIn), amountout); + + const toCentralPriceAuthority = makePriceAuthority( + getInputPriceForPA, + getOutputPriceForPA, + secondaryBrand, + centralBrand, + timer, + zcf, + notifier, + quoteIssuerKit, + ); + const fromCentralPriceAuthority = makePriceAuthority( + getInputPriceForPA, + getOutputPriceForPA, + centralBrand, + secondaryBrand, + timer, + zcf, + notifier, + quoteIssuerKit, + ); + + return pool; + }; + + /** + * Allows users to add new liquidity pools. `secondaryIssuer` and + * its keyword must not have been already used + * + * @param {Issuer} secondaryIssuer + * @param {Keyword} keyword - will be used in the + * terms.issuers for the contract, but not used otherwise + */ + const addPool = async (secondaryIssuer, keyword) => { + const liquidityKeyword = `${keyword}Liquidity`; + zcf.assertUniqueKeyword(liquidityKeyword); + + const [secondaryAssetKind, secondaryBrand] = await Promise.all([ + E(secondaryIssuer).getAssetKind(), + E(secondaryIssuer).getBrand(), + ]); + + assert( + !isInSecondaries(secondaryBrand), + X`issuer ${secondaryIssuer} already has a pool`, + ); + assert( + secondaryAssetKind === AssetKind.NAT, + X`${keyword} asset not fungible (must use NAT math)`, + ); + + // COMMIT POINT + // We've checked all the foreseeable exceptions (except + // zcf.assertUniqueKeyword(keyword), which will be checked by saveIssuer() + // before proceeding), so we can do the work now. + await zcf.saveIssuer(secondaryIssuer, keyword); + const liquidityZCFMint = await zcf.makeZCFMint(liquidityKeyword); + const { zcfSeat: poolSeat } = zcf.makeEmptySeatKit(); + const pool = makePool(liquidityZCFMint, poolSeat, secondaryBrand); + initPool(secondaryBrand, pool); + return liquidityZCFMint.getIssuerRecord().issuer; + }; + + return addPool; +}; diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/removeLiquidity.js b/packages/zoe/src/contracts/vpool-xyk-amm/removeLiquidity.js new file mode 100644 index 00000000000..766a4266a54 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/removeLiquidity.js @@ -0,0 +1,31 @@ +// @ts-check + +import { assertProposalShape } from '../../contractSupport/index.js'; + +import '../../../exported.js'; + +/** + * @param {ContractFacet} zcf + * @param {(brand: Brand) => XYKPool} getPool + */ +export const makeMakeRemoveLiquidityInvitation = (zcf, getPool) => { + const removeLiquidity = seat => { + assertProposalShape(seat, { + want: { + Central: null, + Secondary: null, + }, + give: { + Liquidity: null, + }, + }); + // Get the brand of the secondary token so we can identify the liquidity pool. + const secondaryBrand = seat.getProposal().want.Secondary.brand; + const pool = getPool(secondaryBrand); + return pool.removeLiquidity(seat); + }; + + const makeRemoveLiquidityInvitation = () => + zcf.makeInvitation(removeLiquidity, 'autoswap remove liquidity'); + return makeRemoveLiquidityInvitation; +}; diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/singlePool.js b/packages/zoe/src/contracts/vpool-xyk-amm/singlePool.js new file mode 100644 index 00000000000..8165f15f102 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/singlePool.js @@ -0,0 +1,93 @@ +// @ts-check + +import { makeFeeRatio } from '../constantProduct/calcFees'; +import { + pricesForStatedInput, + pricesForStatedOutput, +} from '../constantProduct/calcSwapPrices.js'; + +/** + * @param {ContractFacet} zcf + * @param {XYKPool} pool + * @param {BASIS_POINTS} protocolFeeBP - Ratio, soon to be replaced with governed value + * @param {BASIS_POINTS} poolFeeBP - Ratio, soon to be replaced with governed value + * @param {ZCFSeat} feeSeat + * @returns {VPool} + */ +export const makeSinglePool = ( + zcf, + pool, + protocolFeeBP, + poolFeeBP, + feeSeat, +) => { + const secondaryBrand = pool.getSecondaryAmount().brand; + const centralBrand = pool.getCentralAmount().brand; + const protocolFeeRatio = makeFeeRatio(protocolFeeBP, centralBrand); + const getPools = () => ({ + Central: pool.getCentralAmount(), + Secondary: pool.getSecondaryAmount(), + }); + const publicPrices = prices => { + return { amountIn: prices.swapperGives, amountOut: prices.swapperGets }; + }; + + const allocateGainsAndLosses = (inBrand, prices, seat) => { + const poolSeat = pool.getPoolSeat(); + seat.decrementBy({ In: prices.swapperGives }); + seat.incrementBy({ Out: prices.swapperGets }); + feeSeat.incrementBy({ RUN: prices.protocolFee }); + + if (inBrand === secondaryBrand) { + poolSeat.incrementBy({ Secondary: prices.xIncrement }); + poolSeat.decrementBy({ Central: prices.yDecrement }); + } else { + poolSeat.incrementBy({ Central: prices.xIncrement }); + poolSeat.decrementBy({ Secondary: prices.yDecrement }); + } + + zcf.reallocate(poolSeat, seat, feeSeat); + seat.exit(); + pool.updateState(); + return `Swap successfully completed.`; + }; + + const getPriceForInput = (amountIn, amountOut) => { + return pricesForStatedInput( + amountIn, + getPools(), + amountOut, + protocolFeeRatio, + makeFeeRatio(poolFeeBP, amountOut.brand), + ); + }; + + const swapIn = (seat, amountIn, amountOut) => { + const prices = getPriceForInput(amountIn, amountOut); + return allocateGainsAndLosses(amountIn.brand, prices, seat); + }; + + const getPriceForOutput = (amountIn, amountOut) => { + return pricesForStatedOutput( + amountIn, + getPools(), + amountOut, + protocolFeeRatio, + makeFeeRatio(poolFeeBP, amountIn.brand), + ); + }; + const swapOut = (seat, amountIn, amountOut) => { + const prices = getPriceForOutput(amountIn, amountOut); + return allocateGainsAndLosses(amountIn.brand, prices, seat); + }; + + /** @type {VPool} */ + return { + getInputPrice: (amountIn, amountOut) => + publicPrices(getPriceForInput(amountIn, amountOut)), + getOutputPrice: (amountIn, amountOut) => + publicPrices(getPriceForOutput(amountIn, amountOut)), + swapIn, + swapOut, + }; +}; diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/swap.js b/packages/zoe/src/contracts/vpool-xyk-amm/swap.js new file mode 100644 index 00000000000..70f7d6fc2e1 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/swap.js @@ -0,0 +1,62 @@ +// @ts-check + +import { assertProposalShape } from '../../contractSupport/index.js'; + +import '../../../exported.js'; +import { LOW_FEE, SHORT_EXP } from '../../constants.js'; + +/** + * @param {ContractFacet} zcf + * @param {(brandIn: Brand, brandOut: Brand, fee: bigint) => VPool} provideVPool + * @param {BASIS_POINTS} poolFeeBP + */ +export const makeMakeSwapInvitation = (zcf, provideVPool, poolFeeBP) => { + // trade with a stated amountIn. + const swapIn = seat => { + assertProposalShape(seat, { + give: { In: null }, + want: { Out: null }, + }); + const { + give: { In: amountIn }, + want: { Out: amountOut }, + } = seat.getProposal(); + const pool = provideVPool(amountIn.brand, amountOut.brand, poolFeeBP); + return pool.swapIn(seat, amountIn, amountOut); + }; + + // trade with a stated amount out. + const swapOut = seat => { + assertProposalShape(seat, { + give: { In: null }, + want: { Out: null }, + }); + // The offer's amountOut is a minimum; the offeredAmountIn is a max. + const { + give: { In: amountIn }, + want: { Out: amountOut }, + } = seat.getProposal(); + const pool = provideVPool(amountIn.brand, amountOut.brand, poolFeeBP); + return pool.swapOut(seat, amountIn, amountOut); + }; + + const makeSwapInInvitation = () => + zcf.makeInvitation( + swapIn, + 'autoswap swapIn', + undefined, + LOW_FEE, + SHORT_EXP, + ); + + const makeSwapOutInvitation = () => + zcf.makeInvitation( + swapOut, + 'autoswap swapOut', + undefined, + LOW_FEE, + SHORT_EXP, + ); + + return { makeSwapInInvitation, makeSwapOutInvitation }; +}; diff --git a/packages/zoe/src/contracts/vpool-xyk-amm/types.js b/packages/zoe/src/contracts/vpool-xyk-amm/types.js new file mode 100644 index 00000000000..4978cde7aa9 --- /dev/null +++ b/packages/zoe/src/contracts/vpool-xyk-amm/types.js @@ -0,0 +1,65 @@ +// @ts-check + +/** + * @typedef {Object} VPoolPriceQuote + * @property {Amount} amountIn + * @property {Amount} amountOut + */ + +/** + * @typedef {Object} VPool - virtual pool for price quotes and trading + * @property {(amountIn: Amount, amountOut: Amount) => VPoolPriceQuote} getInputPrice + * @property {(amountIn: Amount, amountOut: Amount) => VPoolPriceQuote} getOutputPrice + * @property {(seat: ZCFSeat, amountIn: Amount, amountOut: Amount) => string} swapIn + * @property {(seat: ZCFSeat, amountIn: Amount, amountOut: Amount) => string} swapOut + */ + +/** + * @typedef {Object} XYKPool + * @property {() => bigint} getLiquiditySupply + * @property {() => Issuer} getLiquidityIssuer + * @property {(seat: ZCFSeat) => string} addLiquidity + * @property {(seat: ZCFSeat) => string} removeLiquidity + * @property {() => ZCFSeat} getPoolSeat + * @property {() => Amount} getSecondaryAmount + * @property {() => Amount} getCentralAmount + * @property {() => Notifier>} getNotifier + * @property {() => void} updateState + * @property {() => PriceAuthority} getToCentralPriceAuthority + * @property {() => PriceAuthority} getFromCentralPriceAuthority + * @property {() => VPool} getVPool + */ + +/** + * @typedef {Object} XYKAMMPublicFacet + * @property {(issuer: Issuer, keyword: Keyword) => Promise} addPool + * add a new liquidity pool + * @property {() => Promise} makeSwapInvitation synonym for + * makeSwapInInvitation + * @property {() => Promise} makeSwapInInvitation make an invitation + * that allows one to do a swap in which the In amount is specified and the Out + * amount is calculated + * @property {() => Promise} makeSwapOutInvitation make an invitation + * that allows one to do a swap in which the Out amount is specified and the In + * amount is calculated + * @property {() => Promise} makeAddLiquidityInvitation make an + * invitation that allows one to add liquidity to the pool. + * @property {() => Promise} makeRemoveLiquidityInvitation make an + * invitation that allows one to remove liquidity from the pool. + * @property {(brand: Brand) => Issuer} getLiquidityIssuer + * @property {(brand: Brand) => bigint} getLiquiditySupply get the current value of + * liquidity in the pool for brand held by investors. + * @property {(amountIn: Amount, brandOut: Brand) => VPoolPriceQuote} getInputPrice + * calculate the amount of brandOut that will be returned if the amountIn is + * offered using makeSwapInInvitation at the current price. + * @property {(amountOut: Amount, brandIn: Brand) => VPoolPriceQuote} getOutputPrice + * calculate the amount of brandIn that is required in order to get amountOut + * using makeSwapOutInvitation at the current price + * @property {(brand: Brand) => Record} getPoolAllocation get an + * AmountKeywordRecord showing the current balances in the pool for brand. + * @property {() => Issuer} getQuoteIssuer - get the Issuer that attests to + * the prices in the priceQuotes issued by the PriceAuthorities + * @property {(brand: Brand) => {toCentral: PriceAuthority, fromCentral: PriceAuthority}} getPriceAuthorities + * get a pair of PriceAuthorities { toCentral, fromCentral } for requesting + * Prices and notifications about changing prices. + */ diff --git a/packages/zoe/test/autoswapJig.js b/packages/zoe/test/autoswapJig.js index 96ae96377e5..4732d102dde 100644 --- a/packages/zoe/test/autoswapJig.js +++ b/packages/zoe/test/autoswapJig.js @@ -7,7 +7,7 @@ import { AmountMath } from '@agoric/ertp'; import { natSafeMath } from '../src/contractSupport/index.js'; import { assertOfferResult, assertPayoutAmount } from './zoeTestHelpers.js'; -const { add, subtract, multiply, floorDivide } = natSafeMath; +const { add, subtract, multiply, floorDivide, ceilDivide } = natSafeMath; // A test Harness that simplifies tests of autoswap and multipoolAutoswap. The // main component is the Trader, which can be instructed to make various offers @@ -43,11 +43,9 @@ export const outputFromInputPrice = (xPre, yPre, deltaX, fee) => { // deltaX = (deltaY * xPre * 10000 / (yPre - deltaY ) * gammaNum)) + 1 export const priceFromTargetOutput = (deltaY, yPre, xPre, fee) => { const gammaNumerator = 10000n - fee; - return ( - floorDivide( - multiply(multiply(deltaY, xPre), 10000n), - multiply(subtract(yPre, deltaY), gammaNumerator), - ) + 1n + return ceilDivide( + multiply(multiply(deltaY, xPre), 10000n), + multiply(subtract(yPre, deltaY), gammaNumerator), ); }; diff --git a/packages/zoe/test/unitTests/contracts/vpool-xyk-amm/test-xyk-amm-swap.js b/packages/zoe/test/unitTests/contracts/vpool-xyk-amm/test-xyk-amm-swap.js new file mode 100644 index 00000000000..90d229581b8 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/vpool-xyk-amm/test-xyk-amm-swap.js @@ -0,0 +1,1100 @@ +// @ts-check +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import path from 'path'; + +import bundleSource from '@agoric/bundle-source'; +import { makeIssuerKit, AmountMath } from '@agoric/ertp'; +import { E } from '@agoric/eventual-send'; +import { assert, q } from '@agoric/assert'; +import fakeVatAdmin from '../../../../tools/fakeVatAdmin.js'; + +// noinspection ES6PreferShortImport +import { makeZoeKit } from '../../../../src/zoeService/zoe.js'; +import { setup } from '../../setupBasicMints.js'; +import { + makeTrader, + updatePoolState, + priceFromTargetOutput, + outputFromInputPrice, +} from '../../../autoswapJig.js'; +import buildManualTimer from '../../../../tools/manualTimer.js'; +import { + getAmountOut, + makeRatio, + ceilMultiplyBy, + natSafeMath, +} from '../../../../src/contractSupport/index.js'; +import { + assertAmountsEqual, + assertPayoutAmount, +} from '../../../zoeTestHelpers.js'; + +const { ceilDivide } = natSafeMath; + +const filename = new URL(import.meta.url).pathname; +const dirname = path.dirname(filename); + +const ammRoot = `${dirname}/../../../../src/contracts/vpool-xyk-amm/multipoolMarketMaker.js`; + +test('amm with valid offers', async t => { + const { moolaR, simoleanR, moola, simoleans } = setup(); + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + const invitationBrand = await E(invitationIssuer).getBrand(); + + // Set up central token + const centralR = makeIssuerKit('central'); + const centralTokens = value => AmountMath.make(value, centralR.brand); + + // Setup Alice + const aliceMoolaPayment = moolaR.mint.mintPayment(moola(100000)); + // Let's assume that central tokens are worth 2x as much as moola + const aliceCentralPayment = centralR.mint.mintPayment(centralTokens(50000)); + const aliceSimoleanPayment = simoleanR.mint.mintPayment(simoleans(398)); + + // Setup Bob + const bobMoolaPayment = moolaR.mint.mintPayment(moola(17000)); + + // Alice creates an autoswap instance + + // Pack the contract. + const bundle = await bundleSource(ammRoot); + + const installation = await E(zoe).install(bundle); + // This timer is only used to build quotes. Let's make it non-zero + const fakeTimer = buildManualTimer(console.log, 30n); + const { instance, publicFacet } = await E(zoe).startInstance( + installation, + harden({ Central: centralR.issuer }), + { timer: fakeTimer, poolFeeBP: 24n, protocolFeeBP: 6n }, + ); + const aliceAddLiquidityInvitation = E( + publicFacet, + ).makeAddLiquidityInvitation(); + + const aliceInvitationAmount = await E(invitationIssuer).getAmountOf( + aliceAddLiquidityInvitation, + ); + t.deepEqual( + aliceInvitationAmount, + AmountMath.make( + [ + { + description: 'multipool autoswap add liquidity', + instance, + installation, + handle: aliceInvitationAmount.value[0].handle, + fee: undefined, + expiry: undefined, + zoeTimeAuthority: undefined, + }, + ], + invitationBrand, + ), + `invitation value is as expected`, + ); + + const moolaLiquidityIssuer = await E(publicFacet).addPool( + moolaR.issuer, + 'Moola', + ); + const moolaLiquidityBrand = await E(moolaLiquidityIssuer).getBrand(); + const moolaLiquidity = value => AmountMath.make(value, moolaLiquidityBrand); + + const simoleanLiquidityIssuer = await E(publicFacet).addPool( + simoleanR.issuer, + 'Simoleans', + ); + + const simoleanLiquidityBrand = await E(simoleanLiquidityIssuer).getBrand(); + const simoleanLiquidity = value => + AmountMath.make(value, simoleanLiquidityBrand); + + const { toCentral: priceAuthority } = await E( + publicFacet, + ).getPriceAuthorities(moolaR.brand); + + const issuerKeywordRecord = await E(zoe).getIssuers(instance); + t.deepEqual( + issuerKeywordRecord, + harden({ + Central: centralR.issuer, + Moola: moolaR.issuer, + MoolaLiquidity: moolaLiquidityIssuer, + Simoleans: simoleanR.issuer, + SimoleansLiquidity: simoleanLiquidityIssuer, + }), + `There are keywords for central token and two additional tokens and liquidity`, + ); + t.deepEqual( + await E(publicFacet).getPoolAllocation(moolaR.brand), + {}, + `The poolAllocation object values for moola should be empty`, + ); + t.deepEqual( + await E(publicFacet).getPoolAllocation(simoleanR.brand), + {}, + `The poolAllocation object values for simoleans should be empty`, + ); + + // Alice adds liquidity + // 10 moola = 5 central tokens at the time of the liquidity adding + // aka 2 moola = 1 central token + const aliceProposal = harden({ + want: { Liquidity: moolaLiquidity(50000) }, + give: { Secondary: moola(100000), Central: centralTokens(50000) }, + }); + const alicePayments = { + Secondary: aliceMoolaPayment, + Central: aliceCentralPayment, + }; + + const addLiquiditySeat = await E(zoe).offer( + aliceAddLiquidityInvitation, + aliceProposal, + alicePayments, + ); + + t.is( + await E(addLiquiditySeat).getOfferResult(), + 'Added liquidity.', + `Alice added moola and central liquidity`, + ); + + const liquidityPayout = await addLiquiditySeat.getPayout('Liquidity'); + + t.deepEqual( + await moolaLiquidityIssuer.getAmountOf(liquidityPayout), + moolaLiquidity(50000), + ); + t.deepEqual( + await E(publicFacet).getPoolAllocation(moolaR.brand), + harden({ + Secondary: moola(100000), + Central: centralTokens(50000), + Liquidity: moolaLiquidity(0), + }), + `The poolAmounts record should contain the new liquidity`, + ); + + // Bob creates a swap invitation for himself + const bobSwapInvitation1 = await E(publicFacet).makeSwapInInvitation(); + + const { value } = await E(invitationIssuer).getAmountOf(bobSwapInvitation1); + assert(Array.isArray(value)); + const [bobInvitationValue] = value; + const bobPublicFacet = await E(zoe).getPublicFacet( + bobInvitationValue.instance, + ); + + t.is( + bobInvitationValue.installation, + installation, + `installation is as expected`, + ); + + // Bob looks up the price of 17000 moola in central tokens + const { amountOut: priceInCentrals } = await E(bobPublicFacet).getInputPrice( + moola(17000), + AmountMath.makeEmpty(centralR.brand), + ); + + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), {}); + + const bobMoolaForCentralProposal = harden({ + want: { Out: priceInCentrals }, + give: { In: moola(17000) }, + }); + const bobMoolaForCentralPayments = harden({ In: bobMoolaPayment }); + + // Bob swaps + const bobSeat = await E(zoe).offer( + bobSwapInvitation1, + bobMoolaForCentralProposal, + bobMoolaForCentralPayments, + ); + + const protocolFeeRatio = makeRatio(6n, centralR.brand, 10000); + /** @type {Amount} */ + let runningFees = ceilMultiplyBy(priceInCentrals, protocolFeeRatio); + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: runningFees, + }); + + const quoteGivenBob = await E(priceAuthority).quoteGiven( + moola(5000), + centralR.brand, + ); + assertAmountsEqual( + t, + getAmountOut(quoteGivenBob), + AmountMath.make(centralR.brand, 1745n), + `expected amount of 1747, but saw ${q(getAmountOut(quoteGivenBob))}`, + ); + + t.is(await E(bobSeat).getOfferResult(), 'Swap successfully completed.'); + + const bobMoolaPayout1 = await bobSeat.getPayout('In'); + const bobCentralPayout1 = await bobSeat.getPayout('Out'); + + assertAmountsEqual( + t, + await moolaR.issuer.getAmountOf(bobMoolaPayout1), + moola(2n), + `bob gets 2 moola back`, + ); + assertAmountsEqual( + t, + await centralR.issuer.getAmountOf(bobCentralPayout1), + centralTokens(7241), + `bob gets the same price as when he called the getInputPrice method`, + ); + t.deepEqual( + await E(bobPublicFacet).getPoolAllocation(moolaR.brand), + { + Secondary: moola(117000 - 2), + Central: centralTokens(42750 + 4), + Liquidity: moolaLiquidity(0), + }, + `pool allocation added the moola and subtracted the central tokens`, + ); + + const bobCentralPurse = await E(centralR.issuer).makeEmptyPurse(); + await E(bobCentralPurse).deposit(bobCentralPayout1); + + // Bob looks up the price of 700 central tokens in moola + const priceFor700 = await E(bobPublicFacet).getInputPrice( + centralTokens(700), + AmountMath.makeEmpty(moolaR.brand), + ); + t.deepEqual( + priceFor700, + { + amountOut: moola(1877), + amountIn: centralTokens(700), + }, + `the fee was one moola over the two trades`, + ); + + // Bob makes another offer and swaps + const bobSwapInvitation2 = await E(bobPublicFacet).makeSwapInInvitation(); + const bobCentralForMoolaProposal = harden({ + want: { Out: moola(1877) }, + give: { In: centralTokens(700) }, + }); + const centralForMoolaPayments = harden({ + In: await E(bobCentralPurse).withdraw(centralTokens(700)), + }); + + const bobSeat2 = await E(zoe).offer( + bobSwapInvitation2, + bobCentralForMoolaProposal, + centralForMoolaPayments, + ); + + runningFees = AmountMath.add( + runningFees, + ceilMultiplyBy(centralTokens(700), protocolFeeRatio), + ); + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: runningFees, + }); + + t.is( + await bobSeat2.getOfferResult(), + 'Swap successfully completed.', + `second swap successful`, + ); + + const quoteBob2 = await E(priceAuthority).quoteGiven( + moola(5000), + centralR.brand, + ); + assertAmountsEqual( + t, + getAmountOut(quoteBob2), + AmountMath.make(centralR.brand, 1801n), + ); + + const bobMoolaPayout2 = await bobSeat2.getPayout('Out'); + const bobCentralPayout2 = await bobSeat2.getPayout('In'); + + t.deepEqual( + await moolaR.issuer.getAmountOf(bobMoolaPayout2), + moola(1877), + `bob gets 1877 moola back`, + ); + t.deepEqual( + await centralR.issuer.getAmountOf(bobCentralPayout2), + centralTokens(0), + `bob gets no central tokens back`, + ); + t.deepEqual( + await E(bobPublicFacet).getPoolAllocation(moolaR.brand), + { + Secondary: moola(115121), + Central: centralTokens(43453), + Liquidity: moolaLiquidity(0), + }, + `fee added to liquidity pool`, + ); + + // Alice adds simoleans and central tokens to the simolean + // liquidity pool. 398 simoleans = 43 central tokens at the time of + // the liquidity adding + // + const aliceSimCentralLiquidityInvitation = E( + publicFacet, + ).makeAddLiquidityInvitation(); + const aliceSimCentralProposal = harden({ + want: { Liquidity: simoleanLiquidity(43) }, + give: { Secondary: simoleans(398), Central: centralTokens(43) }, + }); + const aliceCentralPayment2 = await centralR.mint.mintPayment( + centralTokens(43), + ); + const aliceSimCentralPayments = { + Secondary: aliceSimoleanPayment, + Central: aliceCentralPayment2, + }; + + const aliceSeat2 = await E(zoe).offer( + aliceSimCentralLiquidityInvitation, + aliceSimCentralProposal, + aliceSimCentralPayments, + ); + + const quoteLiquidation2 = await E(priceAuthority).quoteGiven( + moola(5000), + centralR.brand, + ); + // a simolean trade had no effect on moola prices + assertAmountsEqual( + t, + getAmountOut(quoteLiquidation2), + AmountMath.make(centralR.brand, 1801n), + ); + t.is( + await aliceSeat2.getOfferResult(), + 'Added liquidity.', + `Alice added simoleans and central liquidity`, + ); + + const simoleanLiquidityPayout = await aliceSeat2.getPayout('Liquidity'); + + t.deepEqual( + await simoleanLiquidityIssuer.getAmountOf(simoleanLiquidityPayout), + simoleanLiquidity(43), + `simoleanLiquidity minted was equal to the amount of central tokens added to pool`, + ); + t.deepEqual( + await E(publicFacet).getPoolAllocation(simoleanR.brand), + harden({ + Secondary: simoleans(398), + Central: centralTokens(43), + Liquidity: simoleanLiquidity(0), + }), + `The poolAmounts record should contain the new liquidity`, + ); +}); + +test('amm doubleSwap', async t => { + const { moolaR, simoleanR, moola, simoleans } = setup(); + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + + // Set up central token + const centralR = makeIssuerKit('central'); + const centralTokens = value => AmountMath.make(value, centralR.brand); + + // Setup Alice + const aliceMoolaPayment = moolaR.mint.mintPayment(moola(100000)); + // Let's assume that central tokens are worth 2x as much as moola + const aliceCentralPayment = centralR.mint.mintPayment(centralTokens(50000)); + const aliceSimoleanPayment = simoleanR.mint.mintPayment(simoleans(39800)); + + // Setup Bob + const bobSimoleanPayment = simoleanR.mint.mintPayment(simoleans(4000)); + const bobMoolaPayment = moolaR.mint.mintPayment(moola(5000)); + + // Alice creates an autoswap instance + const bundle = await bundleSource(ammRoot); + + const installation = await E(zoe).install(bundle); + // This timer is only used to build quotes. Let's make it non-zero + const fakeTimer = buildManualTimer(console.log, 30n); + const { instance, publicFacet, creatorFacet } = await E(zoe).startInstance( + installation, + harden({ Central: centralR.issuer }), + { + timer: fakeTimer, + poolFeeBP: 24n, + protocolFeeBP: 6n, + }, + ); + const aliceAddLiquidityInvitation = E( + publicFacet, + ).makeAddLiquidityInvitation(); + + const moolaLiquidityIssuer = await E(publicFacet).addPool( + moolaR.issuer, + 'Moola', + ); + const moolaLiquidityBrand = await E(moolaLiquidityIssuer).getBrand(); + const moolaLiquidity = value => AmountMath.make(value, moolaLiquidityBrand); + + const simoleanLiquidityIssuer = await E(publicFacet).addPool( + simoleanR.issuer, + 'Simoleans', + ); + + const simoleanLiquidityBrand = await E(simoleanLiquidityIssuer).getBrand(); + const simoleanLiquidity = value => + AmountMath.make(value, simoleanLiquidityBrand); + + const issuerKeywordRecord = await E(zoe).getIssuers(instance); + t.deepEqual( + issuerKeywordRecord, + harden({ + Central: centralR.issuer, + Moola: moolaR.issuer, + MoolaLiquidity: moolaLiquidityIssuer, + Simoleans: simoleanR.issuer, + SimoleansLiquidity: simoleanLiquidityIssuer, + }), + `There are keywords for central token and two additional tokens and liquidity`, + ); + t.deepEqual( + await E(publicFacet).getPoolAllocation(moolaR.brand), + {}, + `The poolAllocation object values for moola should be empty`, + ); + t.deepEqual( + await E(publicFacet).getPoolAllocation(simoleanR.brand), + {}, + `The poolAllocation object values for simoleans should be empty`, + ); + + // Alice adds liquidity for moola + // 10 moola = 5 central tokens at the time of the liquidity adding + // aka 2 moola = 1 central token + const aliceProposal = harden({ + want: { Liquidity: moolaLiquidity(50000) }, + give: { Secondary: moola(100000), Central: centralTokens(50000) }, + }); + const alicePayments = { + Secondary: aliceMoolaPayment, + Central: aliceCentralPayment, + }; + + const addLiquiditySeat = await E(zoe).offer( + aliceAddLiquidityInvitation, + aliceProposal, + alicePayments, + ); + + t.is( + await E(addLiquiditySeat).getOfferResult(), + 'Added liquidity.', + `Alice added moola and central liquidity`, + ); + await addLiquiditySeat.getPayout('Liquidity'); + + // Alice adds simoleans and central tokens to the simolean + // liquidity pool. 398 simoleans = 43 central tokens at the time of + // the liquidity adding + // + const aliceSimLiquidityInvitation = E( + publicFacet, + ).makeAddLiquidityInvitation(); + const aliceSimCentralProposal = harden({ + want: { Liquidity: simoleanLiquidity(430) }, + give: { Secondary: simoleans(39800), Central: centralTokens(43000) }, + }); + const aliceCentralPayment2 = await centralR.mint.mintPayment( + centralTokens(43000), + ); + const aliceSimCentralPayments = { + Secondary: aliceSimoleanPayment, + Central: aliceCentralPayment2, + }; + + const aliceSeat2 = await E(zoe).offer( + aliceSimLiquidityInvitation, + aliceSimCentralProposal, + aliceSimCentralPayments, + ); + + t.is( + await aliceSeat2.getOfferResult(), + 'Added liquidity.', + `Alice added simoleans and central liquidity`, + ); + + // Bob swaps moola for simoleans + + // Bob looks up the value of 4000 simoleans in moola + const { amountOut: priceInMoola } = await E(publicFacet).getInputPrice( + simoleans(4000), + AmountMath.makeEmpty(moolaR.brand), + ); + + const bobInvitation = await E(publicFacet).makeSwapInInvitation(); + const bobSimsForMoolaProposal = harden({ + want: { Out: priceInMoola }, + give: { In: simoleans(4000) }, + }); + const simsForMoolaPayments = harden({ + In: bobSimoleanPayment, + }); + + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), {}); + + const bobSeat1 = await E(zoe).offer( + bobInvitation, + bobSimsForMoolaProposal, + simsForMoolaPayments, + ); + const bobMoolaPayout = await bobSeat1.getPayout('Out'); + + t.deepEqual( + await moolaR.issuer.getAmountOf(bobMoolaPayout), + moola(7234), + `bob gets 7234 moola`, + ); + + let runningFees = AmountMath.make(centralR.brand, 6n); + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: runningFees, + }); + + // Bob swaps simoleans for moola + + // Bob looks up the value of 5000 moola in simoleans + const { amountOut: priceInSimoleans } = await E(publicFacet).getInputPrice( + moola(5000), + AmountMath.makeEmpty(simoleanR.brand), + ); + + const bobInvitation2 = await E(publicFacet).makeSwapInInvitation(); + const bobMoolaForSimsProposal = harden({ + want: { Out: priceInSimoleans }, + give: { In: moola(5000) }, + }); + const moolaForSimsPayments = harden({ + In: bobMoolaPayment, + }); + + const bobSeat2 = await E(zoe).offer( + bobInvitation2, + bobMoolaForSimsProposal, + moolaForSimsPayments, + ); + const bobSimoleanPayout = await bobSeat2.getPayout('Out'); + + t.deepEqual( + await simoleanR.issuer.getAmountOf(bobSimoleanPayout), + simoleans(2868), + `bob gets 2880 simoleans`, + ); + + runningFees = AmountMath.add( + runningFees, + AmountMath.make(centralR.brand, 4n), + ); + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: runningFees, + }); + + const collectFeesInvitation = E(creatorFacet).makeCollectFeesInvitation(); + const collectFeesSeat = await E(zoe).offer( + collectFeesInvitation, + undefined, + undefined, + ); + + const payout = await E(collectFeesSeat).getPayout('RUN'); + + await assertPayoutAmount(t, centralR.issuer, payout, runningFees); + + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: AmountMath.makeEmpty(centralR.brand), + }); +}); + +test('amm with some invalid offers', async t => { + const { moolaR, moola } = setup(); + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + const invitationIssuer = await E(zoe).getInvitationIssuer(); + + // Set up central token + const centralR = makeIssuerKit('central'); + const centralTokens = value => AmountMath.make(value, centralR.brand); + + // Setup Bob + const bobMoolaPayment = moolaR.mint.mintPayment(moola(17)); + + // Alice creates an autoswap instance + + // Pack the contract. + const bundle = await bundleSource(ammRoot); + + const fakeTimer = buildManualTimer(console.log); + const installation = await E(zoe).install(bundle); + const { publicFacet } = await E(zoe).startInstance( + installation, + harden({ Central: centralR.issuer }), + { timer: fakeTimer, poolFeeBP: 24n, protocolFeeBP: 6n }, + ); + + await E(publicFacet).addPool(moolaR.issuer, 'Moola'); + // Bob creates a swap invitation for himself + const bobSwapInvitation1 = await E(publicFacet).makeSwapInInvitation(); + + const { value } = await E(invitationIssuer).getAmountOf(bobSwapInvitation1); + assert(Array.isArray(value)); + const [bobInvitationValue] = value; + const bobPublicFacet = E(zoe).getPublicFacet(bobInvitationValue.instance); + + // Bob tries to look up prices, but the pool isn't initiailzed + await t.throwsAsync( + () => + E(bobPublicFacet).getInputPrice( + moola(5), + AmountMath.makeEmpty(centralR.brand), + ), + { + message: + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: central brand]","value":"[0n]"}', + }, + 'pool not initialized', + ); + + // Bob tries to trade anyway. + const bobMoolaForCentralProposal = harden({ + want: { Out: centralTokens(7) }, + give: { In: moola(17) }, + }); + const bobMoolaForCentralPayments = harden({ In: bobMoolaPayment }); + + // Bob swaps + const failedSeat = await E(zoe).offer( + bobSwapInvitation1, + bobMoolaForCentralProposal, + bobMoolaForCentralPayments, + ); + await t.throwsAsync( + () => failedSeat.getOfferResult(), + { + message: + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: central brand]","value":"[0n]"}', + }, + 'pool not initialized', + ); + + t.deepEqual(await E(publicFacet).getAllPoolBrands(), [moolaR.brand]); +}); + +test('amm jig - swapOut uneven', async t => { + const { moolaR, moola, simoleanR, simoleans } = setup(); + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + + // Pack the contract. + const bundle = await bundleSource(ammRoot); + const installation = await E(zoe).install(bundle); + + // Set up central token + const centralR = makeIssuerKit('central'); + const centralTokens = value => AmountMath.make(value, centralR.brand); + + // set up purses + const centralPayment = centralR.mint.mintPayment(centralTokens(30000000)); + const centralPurse = centralR.issuer.makeEmptyPurse(); + await centralPurse.deposit(centralPayment); + const moolaPurse = moolaR.issuer.makeEmptyPurse(); + moolaPurse.deposit(moolaR.mint.mintPayment(moola(20000000))); + const simoleanPurse = simoleanR.issuer.makeEmptyPurse(); + simoleanPurse.deposit(simoleanR.mint.mintPayment(simoleans(20000000))); + + const fakeTimer = buildManualTimer(console.log); + const startRecord = await E(zoe).startInstance( + installation, + harden({ Central: centralR.issuer }), + { timer: fakeTimer, poolFeeBP: 24n, protocolFeeBP: 6n }, + ); + + const { + /** @type {MultipoolAutoswapPublicFacet} */ publicFacet, + creatorFacet, + } = startRecord; + const moolaLiquidityIssuer = await E(publicFacet).addPool( + moolaR.issuer, + 'Moola', + ); + const moolaLiquidityBrand = await E(moolaLiquidityIssuer).getBrand(); + const moolaLiquidity = value => AmountMath.make(value, moolaLiquidityBrand); + + const simoleanLiquidityIssuer = await E(publicFacet).addPool( + simoleanR.issuer, + 'Simoleans', + ); + + const simoleanLiquidityBrand = await E(simoleanLiquidityIssuer).getBrand(); + const simoleanLiquidity = value => + AmountMath.make(value, simoleanLiquidityBrand); + const mIssuerKeywordRecord = { + Secondary: moolaR.issuer, + Liquidity: moolaLiquidityIssuer, + }; + const purses = [ + moolaPurse, + moolaLiquidityIssuer.makeEmptyPurse(), + centralPurse, + simoleanPurse, + simoleanLiquidityIssuer.makeEmptyPurse(), + ]; + const alice = await makeTrader(purses, zoe, publicFacet, centralR.issuer); + + let mPoolState = { + c: 0n, + s: 0n, + l: 0n, + k: 0n, + }; + + // this test uses twice as much Central as Moola to make the price difference + // more visible. + const initMoolaLiquidityDetails = { + cAmount: centralTokens(10000000), + sAmount: moola(5000000), + lAmount: moolaLiquidity(10000000), + }; + const initMoolaLiquidityExpected = { + c: 10000000n, + s: 5000000n, + l: 10000000n, + k: 50000000000000n, + payoutC: 0n, + payoutS: 0n, + payoutL: 10000000n, + }; + await alice.initLiquidityAndCheck( + t, + mPoolState, + initMoolaLiquidityDetails, + initMoolaLiquidityExpected, + mIssuerKeywordRecord, + ); + mPoolState = updatePoolState(mPoolState, initMoolaLiquidityExpected); + + let sPoolState = { + c: 0n, + s: 0n, + l: 0n, + k: 0n, + }; + const initSimoleanLiquidityDetails = { + cAmount: centralTokens(10000000), + sAmount: simoleans(10000000), + lAmount: simoleanLiquidity(10000000), + }; + const initSimLiqExpected = { + c: 10000000n, + s: 10000000n, + l: 10000000n, + k: 100000000000000n, + payoutC: 0n, + payoutS: 0n, + payoutL: 10000000n, + }; + const sIssuerKeywordRecord = { + Secondary: simoleanR.issuer, + Liquidity: simoleanLiquidityIssuer, + }; + + await alice.initLiquidityAndCheck( + t, + sPoolState, + initSimoleanLiquidityDetails, + initSimLiqExpected, + sIssuerKeywordRecord, + ); + sPoolState = updatePoolState(sPoolState, initSimLiqExpected); + + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), {}); + + // trade for central specifying 30000 output: moola price 15092 + // Notice that it takes ~ half as much moola as the desired Central + const cGain = 30000n; + // The pool will be charged the protocol fee on top of deltaY + const protocolFee1 = ceilDivide(cGain * 6n, 10000n - 6n); + const deltaY = priceFromTargetOutput( + cGain + protocolFee1, + mPoolState.c, + mPoolState.s, + 0n, // no fee calculation + ); + const poolFee1 = ceilDivide(deltaY * 24n, 10000n); + const yChange1 = deltaY + poolFee1; + const deltaX = outputFromInputPrice(mPoolState.s, mPoolState.c, deltaY, 0n); + + // overpay + const moolaIn = 16000n; + const tradeDetailsB = { + inAmount: moola(moolaIn), + outAmount: centralTokens(deltaX - protocolFee1), + }; + + const expectedB = { + c: mPoolState.c - deltaX, + s: mPoolState.s + yChange1, + l: 10000000n, + k: (mPoolState.c - deltaX) * (mPoolState.s + yChange1), + out: deltaX - protocolFee1, + in: moolaIn - yChange1, + }; + + await alice.tradeAndCheck( + t, + false, + mPoolState, + tradeDetailsB, + expectedB, + mIssuerKeywordRecord, + ); + mPoolState = updatePoolState(mPoolState, expectedB); + + let expectedPoolBalance = protocolFee1; + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: AmountMath.make(centralR.brand, expectedPoolBalance), + }); + + // trade to get 25000 moola: central price: 49949, approximately double. + const gainM = 25000n; + const mPriceC = priceFromTargetOutput(gainM, mPoolState.s, mPoolState.c, 0n); + + t.is(mPriceC, 49949n); + const actualGainM = outputFromInputPrice( + mPoolState.c, + mPoolState.s, + mPriceC, + 0n, + ); + const poolFee2 = ceilDivide(mPriceC * 24n, 10000n); + + // The price will be deltaX + protocolFee. The user will pay this to the pool + const expectedProtocolCharge2 = ceilDivide(mPriceC * 6n, 10000n); + const alicePays = mPriceC + expectedProtocolCharge2 + poolFee2; + + const tradeDetailsC = { + inAmount: centralTokens(alicePays), + outAmount: moola(gainM), + }; + + const expectedC = { + c: mPoolState.c + mPriceC + poolFee2, + s: mPoolState.s - actualGainM, + l: 10000000n, + k: (mPoolState.c + mPriceC + poolFee2) * (mPoolState.s - actualGainM), + out: actualGainM, + in: gainM - actualGainM, + }; + await alice.tradeAndCheck( + t, + false, + mPoolState, + tradeDetailsC, + expectedC, + mIssuerKeywordRecord, + ); + + expectedPoolBalance += expectedProtocolCharge2; + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: AmountMath.make(centralR.brand, expectedPoolBalance), + }); + + mPoolState = updatePoolState(mPoolState, expectedC); + + const collectFeesInvitation = E(creatorFacet).makeCollectFeesInvitation(); + const collectFeesSeat = await E(zoe).offer( + collectFeesInvitation, + undefined, + undefined, + ); + + const payout = await E(collectFeesSeat).getPayout('RUN'); + + await assertPayoutAmount( + t, + centralR.issuer, + payout, + AmountMath.make(centralR.brand, expectedPoolBalance), + ); + + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), { + RUN: AmountMath.makeEmpty(centralR.brand), + }); +}); + +test('amm jig - breaking scenario', async t => { + const { moolaR, moola, simoleanR } = setup(); + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + + // Pack the contract. + const bundle = await bundleSource(ammRoot); + const installation = await E(zoe).install(bundle); + + // Set up central token + const centralR = makeIssuerKit('central'); + const centralTokens = value => AmountMath.make(value, centralR.brand); + + // set up purses + const centralPayment = centralR.mint.mintPayment( + centralTokens(55825056949339n), + ); + const centralPurse = centralR.issuer.makeEmptyPurse(); + await centralPurse.deposit(centralPayment); + const moolaPurse = moolaR.issuer.makeEmptyPurse(); + moolaPurse.deposit(moolaR.mint.mintPayment(moola(2396247730468n + 4145005n))); + + const fakeTimer = buildManualTimer(console.log); + const startRecord = await E(zoe).startInstance( + installation, + harden({ Central: centralR.issuer }), + { timer: fakeTimer, poolFeeBP: 24n, protocolFeeBP: 6n }, + ); + + const { + /** @type {MultipoolAutoswapPublicFacet} */ publicFacet, + } = startRecord; + const moolaLiquidityIssuer = await E(publicFacet).addPool( + moolaR.issuer, + 'Moola', + ); + const moolaLiquidityBrand = await E(moolaLiquidityIssuer).getBrand(); + const moolaLiquidity = value => AmountMath.make(value, moolaLiquidityBrand); + + const simoleanLiquidityIssuer = await E(publicFacet).addPool( + simoleanR.issuer, + 'Simoleans', + ); + + const mIssuerKeywordRecord = { + Secondary: moolaR.issuer, + Liquidity: moolaLiquidityIssuer, + }; + const purses = [ + moolaPurse, + moolaLiquidityIssuer.makeEmptyPurse(), + centralPurse, + simoleanLiquidityIssuer.makeEmptyPurse(), + ]; + const alice = await makeTrader(purses, zoe, publicFacet, centralR.issuer); + + let mPoolState = { + c: 0n, + s: 0n, + l: 0n, + k: 0n, + }; + + const initMoolaLiquidityDetails = { + cAmount: centralTokens(50825056949339n), + sAmount: moola(2196247730468n), + lAmount: moolaLiquidity(50825056949339n), + }; + const initMoolaLiquidityExpected = { + c: 50825056949339n, + s: 2196247730468n, + l: 50825056949339n, + k: 50825056949339n * 2196247730468n, + payoutC: 0n, + payoutS: 0n, + payoutL: 50825056949339n, + }; + await alice.initLiquidityAndCheck( + t, + mPoolState, + initMoolaLiquidityDetails, + initMoolaLiquidityExpected, + mIssuerKeywordRecord, + ); + mPoolState = updatePoolState(mPoolState, initMoolaLiquidityExpected); + + t.deepEqual(await E(publicFacet).getProtocolPoolBalance(), {}); + + const quoteFromRun = await E(publicFacet).getInputPrice( + centralTokens(73000000n), + AmountMath.makeEmpty(moolaR.brand), + ); + t.deepEqual(quoteFromRun, { + amountIn: centralTokens(72999997n), + amountOut: moola(3145001n), + }); + + const newQuoteFromRun = await E(publicFacet).getInputPrice( + quoteFromRun.amountIn, + AmountMath.makeEmpty(moolaR.brand), + ); + + t.truthy(AmountMath.isGTE(quoteFromRun.amountIn, newQuoteFromRun.amountIn)); + t.truthy(AmountMath.isGTE(newQuoteFromRun.amountOut, quoteFromRun.amountOut)); + + const quoteToRun = await E(publicFacet).getOutputPrice( + AmountMath.makeEmpty(moolaR.brand), + centralTokens(370000000n), + ); + const newQuoteToRun = await E(publicFacet).getOutputPrice( + AmountMath.makeEmpty(moolaR.brand), + quoteToRun.amountOut, + ); + t.deepEqual(quoteToRun.amountIn, newQuoteToRun.amountIn); + t.deepEqual(newQuoteToRun.amountOut, quoteToRun.amountOut); +}); + +// This demonstrates that Zoe can reallocate empty amounts. i.e. that +// https://github.com/Agoric/agoric-sdk/issues/3033 stays fixed +test('zoe allow empty reallocations', async t => { + const { zoeService } = makeZoeKit(fakeVatAdmin); + const feePurse = E(zoeService).makeFeePurse(); + const zoe = E(zoeService).bindDefaultFeePurse(feePurse); + + // Set up central token + const { issuer, brand } = makeIssuerKit('central'); + + // Alice creates an autoswap instance + const bundle = await bundleSource(ammRoot); + + const installation = await E(zoe).install(bundle); + // This timer is only used to build quotes. Let's make it non-zero + const fakeTimer = buildManualTimer(console.log, 30n); + const { creatorFacet } = await E(zoe).startInstance( + installation, + harden({ Central: issuer }), + { timer: fakeTimer, poolFeeBP: 24n, protocolFeeBP: 6n }, + ); + + const collectFeesInvitation2 = E(creatorFacet).makeCollectFeesInvitation(); + const collectFeesSeat2 = await E(zoe).offer( + collectFeesInvitation2, + undefined, + undefined, + ); + + const payout = await E(collectFeesSeat2).getPayout('RUN'); + const result = await E(collectFeesSeat2).getOfferResult(); + + t.deepEqual(result, 'paid out 0'); + await assertPayoutAmount(t, issuer, payout, AmountMath.makeEmpty(brand)); +});