diff --git a/packages/zoe/src/contracts/constantProduct/README.md b/packages/zoe/src/contracts/constantProduct/README.md new file mode 100644 index 00000000000..dff568951c0 --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/README.md @@ -0,0 +1,164 @@ +# Constant Product AMM + +A constant product automatic market maker based on our Ratio library. It charges +two kinds of fees: a pool fee remains in the pool to reward the liquidity +providers and a protocol fee is extracted to fund the economy. The external +entry point is a call to `pricesForStatedInput()` or `pricesForStatedOutput()`. + +This algorithm uses the x*y=k formula directly, without fees. Briefly, there are +two kinds of assets, whose values are kept roughly in balance through the +actions of arbitrageurs. At any time a trader can trade with the pool by +offering to deposit one of the two assets. They will receive an amount +of the complementary asset that will maintain the invariant that the product of +the balances doesn't decrease. (Rounding is done in favor of the +pool.) A fee is charged on the swap to reward the liquidity providers. + +The user can specify a maximum amount they want to pay or a minimum amount they +want to receive. Unlike Uniswap, this approach will charge less than the user +offered or pay more than they asked for when appropriate. By analogy, if a user +is willing to pay up to $20 when the price of soda is $3 per bottle, it would +give 6 bottles and only charge $18. Uniswap doesn't adjust the provided price, +so it charges $20. This matters whenever the values of the smallest unit of the +currencies are significantly different, which is common in DeFi. (We refer to +these as "improved" prices.) + +The rules that drive the design include + +* When the user names an input (or output) price, they shouldn't pay more + (or receive less) than they said. +* The pool fee is charged against the side not specified by the user (the + "computed side"). +* The protocol fee is always charged in RUN. +* The fees should be calculated based on the pool balances before a transaction. +* Computations are rounded in favor of the pool. + +We start by estimating the exchange rate, and calculate fees based on that. Once +we know the fees, we add or subtract them directly to the amounts added to and +extracted from the pools to adhere to those rules. + +## Calculating fees + +In these tables BLD represents any collateral. The user can specify how much +they want or how much they're willing to pay. We'll call the value they +specified **sGive** or **sGet** and bold it. We'll always refer to the currency +being added as X (regardless of whether it's what they pay or what they receive) +and the currency the user gets as Y. This table shows which brands the +amounts each have, as well as what is computed vs. given. The PoolFee is +computed based on the calculated amount (BLD in rows 1 and 2; RUN in rows 3 and +4). The Protocol fee is always in RUN. + +| | In (X) | Out (Y) | PoolFee | Protocol Fee | Specified | Computed | +|---------|-----|-----|--------|-----|------|-----| +| **RUN in** | RUN | BLD | BLD | RUN | **sGive** | sGet | +| **RUN out** | BLD | RUN | BLD | RUN | **sGet** | sGive | +| **BLD in** | BLD | RUN | RUN | RUN | **sGive** | sGet | +| **BLD out** | RUN | BLD | RUN | RUN | **sGet** | sGive | + +We'll estimate how much the pool balances would change in the no-fee, improved +price case using the constant product formulas. We call these estimates +δX, and δY. The fees are based on δX, and δY. ρ is +the poolFee (e.g. .003). + +The pool fee will be ρ times whichever of δX and δY was +calculated. The protocol fee will be ρ * δX when RUN is paid in, and +ρ * δY when BLD is paid in. + +| | δX | δY | PoolFee | Protocol Fee | +|---------|-----|-----|--------|-----| +| **RUN in** | **sGive** | calc | ρ × δY | ρ × **sGive** (= ρ × δX) | +| **RUN out** | calc | **sGet** | ρ × δY | ρ × **sGet** (= ρ × δY) | +| **BLD in** | **sGive** | calc | ρ × δX | ρ × δY | +| **BLD out** | calc | **sGet** | ρ × δX | ρ × δX | + +In rows 1 and 3, **sGive** was specified and sGet will be calculated. In rows 2 +and 4, **sGet** was specified and sGive will be calculated. Once we know the +fees, we can add or subtract the fees and calculate the pool changes. + +Notice that the ProtocolFee always affects the inputs to the constant product +calculation (because it is collected outside the pool). The PoolFee is visible +in the formulas in this table when the input to the calculation is in RUN. + +| | input estimate | output estimate | +|---------|-----|-----| +| **RUN in** | **sGive** - ProtocolFee | | +| **RUN out** | | **sGet** + ProtocolFee + PoolFee | +| **BLD in** | **sGive** - ProtocolFee - PoolFee | | +| **BLD out** | | **sGet** + ProtocolFee | + +We use the estimate of the amount in or out to calculate improved values of +ΔX and ΔY. These values tell us how much the trader will pay, the +changes in pool balances, and what the trader will receive. As before, ΔX +reflects a balance that will be growing, and ΔY one that will be +shrinking. If **sGive** is known, we subtract fees to get ΔX and calculate +ΔY. If **sGet** is known, we add fees to get ΔY and calculate +ΔX. ΔY and ΔX are the values that maintain the constant +product invariant. The amount paid and received by the trader and changes to the +pool are calculated relative to ΔX and ΔY so that the pool grows by +the poolFee and the protocolFee can be paid from the proceeds. + +| | xIncr | yDecr | pay In | pay Out | +|---------|-----|-----|-----|-----| +| **RUN in** | ΔX | ΔY - PoolFee | ΔX + protocolFee | ΔY - PoolFee | +| **RUN out** | ΔX | ΔY - PoolFee | ΔX + protocolFee | ΔY - PoolFee | +| **BLD in** | ΔX + PoolFee | ΔY | ΔX + PoolFee + ProtocolFee | ΔY | +| **BLD out** | ΔX + PoolFee | ΔY | ΔX + PoolFee + ProtocolFee | ΔY | + +In the two right columns the protocolFee is either added to the amount the +trader pays, or subtracted from the proceeds. The poolFee does the same on the +left side, and it is either added to the amount deposited in the pool (xIncr) +or deducted from the amout removed from the pool (yDecr). + +## Example + +For example, let's say the pool has 40,000,000 RUN and 3,000,000 BLD. Alice +requests a swapIn with inputAmount of 30,000 RUN, and outputAmount of 2000 BLD. +(SwapIn means the inputValue is the basis of the computation, while outputAmount +is treated as a minimum). To make the numbers concrete, we'll say the pool fee +is 25 Basis Points, and the protocol fee is 5 Basis Points. + +The first step is to compute the trade that would take place with no fees. 30K +will be added to 40M RUN. To keep the product just above 120MM, the BLD will be +reduced to 2,997,752. + +``` +40,030,000 * 2,997,752 > 40,000,000 * 3,000,000 > 40,030,000 * 2,997,751 + 120000012560000 > 120000000000000 > 119999972530000 +``` + +But we get an even tighter bound by reducing the amount Alice has to spend + +``` +40,029,996 * 2,997,752 > 40,000,000 * 3,000,000 > 40,029,995 * 2,997,752 + 120000000568992 > 120000000000000 > 119999997571240 +``` + +The initial price estimate is that 29,996 RUN would get 2248 BLD in a no-fee +pool. We base fees on this estimate, so the **protocol Fee will be 15 RUN** +(always in RUN) and the **pool fee will be 6 BLD**. The pool fee is calculated +on the output for `swapIn` and the input for `swapOut`. + +Now we calculate the actual ΔX and ΔY, since the fees affect the +size of the changes to the pool. From the first row of the third table we see +that the calculation starts from ΔX of +`sGive - ProtocolFee (i.e. 30,000 - 15 = 29,985)` + +``` +40,029,985 * 2,997,7752 > 40,000,000 * 3,000,000 > 40,029,985 * 2,997,753 +``` + +and re-checking how much is required to produce 2,997,753, we get + +``` +40_029_982 * 2,997,753 > 40,000,000 * 3,000,000 > 40,029,983 * 2,997,753 +``` + +**ΔX is 29,983, and ΔY is 2247**. + + * Alice pays ΔX + protocolFee, which is 29,983 + 15 (29998 RUN) + * Alice will receive ΔY - PoolFee which is 2247 - 6 (2241 BLD) + * The RUN in the pool will increase by ΔX (29983 RUN) + * The BLD in the pool will decrease by ΔY (2247 BLD) + +The Pool grew by 6 BLD more than was required to maintain the constant product +invariant. 15 RUN were extracted for the protocol fee. + diff --git a/packages/zoe/src/contracts/constantProduct/calcFees.js b/packages/zoe/src/contracts/constantProduct/calcFees.js new file mode 100644 index 00000000000..9e3a9fc52dd --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/calcFees.js @@ -0,0 +1,99 @@ +// @ts-check + +import { AmountMath } from '@agoric/ertp'; +import { ceilMultiplyBy, makeRatio } from '../../contractSupport/ratio.js'; + +import { BASIS_POINTS } from './defaults.js'; + +const { details: X } = assert; + +/** + * Make a ratio given a nat representing basis points + * + * @param {NatValue} feeBP + * @param {Brand} brandOfFee + * @returns {Ratio} + */ +const makeFeeRatio = (feeBP, brandOfFee) => { + return makeRatio(feeBP, brandOfFee, BASIS_POINTS); +}; + +/** @type {Maximum} */ +const maximum = (left, right) => { + // If left is greater or equal, return left. Otherwise return right. + return AmountMath.isGTE(left, right) ? left : right; +}; + +/** @type {AmountGT} */ +const amountGT = (left, right) => + AmountMath.isGTE(left, right) && !AmountMath.isEqual(left, right); + +/** + * Apply the feeRatio to the amount that has a matching brand. This used to + * calculate fees in the single pool case. + * + * @param {{ amountIn: Amount, amountOut: Amount}} amounts - a record with two + * amounts in different brands. + * @param {Ratio} feeRatio + * @returns {Amount} + */ +const calcFee = ({ amountIn, amountOut }, feeRatio) => { + assert( + feeRatio.numerator.brand === feeRatio.denominator.brand, + X`feeRatio numerator and denominator must use the same brand ${feeRatio}`, + ); + + let sameBrandAmount; + if (amountIn.brand === feeRatio.numerator.brand) { + sameBrandAmount = amountIn; + } else if (amountOut.brand === feeRatio.numerator.brand) { + sameBrandAmount = amountOut; + } else { + assert( + false, + X`feeRatio's brand (${feeRatio.numerator.brand}) must match one of the amounts [${amountIn}, ${amountOut}].`, + ); + } + + // Always round fees up + const fee = ceilMultiplyBy(sameBrandAmount, feeRatio); + + // Fee cannot exceed the amount on which it is levied + assert( + AmountMath.isGTE(sameBrandAmount, fee), + X`The feeRatio can't be greater than 1 ${feeRatio}`, + ); + + return fee; +}; + +/** + * Estimate the swap values, then calculate fees. The swapFn provided by the + * caller will be swapInNoFees or swapOutNoFees. + * SwapOut. + * + * @type {CalculateFees} + */ +const calculateFees = ( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + swapFn, +) => { + // Get a rough estimation in both brands of the amount to be swapped + const estimation = swapFn({ amountGiven, poolAllocation, amountWanted }); + + const protocolFee = calcFee(estimation, protocolFeeRatio); + const poolFee = calcFee(estimation, poolFeeRatio); + + return harden({ protocolFee, poolFee, ...estimation }); +}; + +harden(amountGT); +harden(maximum); +harden(makeFeeRatio); +harden(calculateFees); + +export { amountGT, maximum, makeFeeRatio, calculateFees }; diff --git a/packages/zoe/src/contracts/constantProduct/calcSwapPrices.js b/packages/zoe/src/contracts/constantProduct/calcSwapPrices.js new file mode 100644 index 00000000000..fd3e4db1192 --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/calcSwapPrices.js @@ -0,0 +1,85 @@ +// @ts-check + +import { Far } from '@agoric/marshal'; + +import { swap } from './swap.js'; +import { assertKInvariantSellingX } from './invariants.js'; +import { getXY } from './getXY.js'; +import { swapInNoFees, swapOutNoFees } from './core.js'; + +// pricesForStatedOutput() and pricesForStatedInput are the external entrypoints +// to the constantProduct module. The amountWanted is optional for +// pricesForStatedInput and amountgiven is optional for pricesForStatedOutput. + +// The two methods call swap, passing in different functions for noFeeSwap. +// pricesForStatedInput uses swapInNoFees, while pricesForStatedOutput uses +// swapOutNoFees. the noFeesSwap functions +const makeCalcSwapPrices = noFeesSwap => { + return Far( + 'calcSwapPrices', + ( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ) => { + const result = swap( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + noFeesSwap, + ); + const { x, y } = getXY({ + amountGiven, + poolAllocation, + amountWanted, + }); + assertKInvariantSellingX(x, y, result.xIncrement, result.yDecrement); + return result; + }, + ); +}; + +/** @type {CalcSwapInPrices} */ +const pricesForStatedInput = ( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, +) => { + const calcSwapPrices = makeCalcSwapPrices(swapInNoFees); + return calcSwapPrices( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ); +}; + +/** @type {CalcSwapOutPrices} */ +const pricesForStatedOutput = ( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, +) => { + const calcSwapPrices = makeCalcSwapPrices(swapOutNoFees); + return calcSwapPrices( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ); +}; + +harden(pricesForStatedInput); +harden(pricesForStatedOutput); + +export { pricesForStatedOutput, pricesForStatedInput }; diff --git a/packages/zoe/src/contracts/constantProduct/core.js b/packages/zoe/src/contracts/constantProduct/core.js new file mode 100644 index 00000000000..1b43f88d04e --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/core.js @@ -0,0 +1,155 @@ +// @ts-check + +import { AmountMath } from '@agoric/ertp'; + +import { natSafeMath } from '../../contractSupport/index.js'; +import { makeRatioFromAmounts } from '../../contractSupport/ratio.js'; +import { getXY } from './getXY.js'; + +const { details: X } = assert; + +const assertSingleBrand = ratio => { + assert( + ratio.numerator.brand === ratio.denominator.brand, + X`Ratio was expected to have same brand in numerator ${ratio.numerator.brand} and denominator ${ratio.denominator.brand}`, + ); +}; + +/** + * Multiply an amount by a ratio using floorDivide, ignoring the ratio's brands + * in favor of the amount brand. This is necessary because the ratio is produced + * by dividing assets of the opposite brand. + * + * @param {Amount} amount + * @param {Ratio} ratio + * @returns {Amount} + */ +const floorMultiplyKeepBrand = (amount, ratio) => { + assertSingleBrand(ratio); + const value = natSafeMath.floorDivide( + natSafeMath.multiply(amount.value, ratio.numerator.value), + ratio.denominator.value, + ); + return AmountMath.make(amount.brand, value); +}; + +/** + * Multiply an amount and a ratio using ceilDivide, ignoring the ratio's brands + * in favor of the amount brand. This is necessary because the ratio is produced + * by dividing assets of the opposite brand. + * + * @param {Amount} amount + * @param {Ratio} ratio + * @returns {Amount} + */ +const ceilMultiplyKeepBrand = (amount, ratio) => { + assertSingleBrand(ratio); + const value = natSafeMath.ceilDivide( + natSafeMath.multiply(amount.value, ratio.numerator.value), + ratio.denominator.value, + ); + return AmountMath.make(amount.brand, value); +}; + +/** + * Calculate deltaY when the user is selling brand X. That is, whichever asset + * the user is selling, this function is used to calculate the change in the + * other asset, i.e. how much of brand Y to give the user in return. + * swapOutImproved calls this function with the calculated amountIn to find out + * if more than the wantedAmountOut can be gained for the necessary amountIn. + * + * deltaY = (deltaXOverX/(1 + deltaXOverX))*y + * Equivalently: (deltaX / (deltaX + x)) * y + * + * @param {Amount} x - the amount of Brand X in pool + * @param {Amount} y - the amount of Brand Y the pool + * @param {Amount} deltaX - the amount of Brand X to be added + * @returns {Amount} deltaY - the amount of Brand Y to be taken out + */ +export const calcDeltaYSellingX = (x, y, deltaX) => { + const deltaXPlusX = AmountMath.add(deltaX, x); + const xRatio = makeRatioFromAmounts(deltaX, deltaXPlusX); + // We want to err on the side of the pool, so this will use floorDivide to + // round down the amount paid out. + return floorMultiplyKeepBrand(y, xRatio); +}; + +/** + * Calculate deltaX when the user is selling brand X. That is, whichever asset + * the user is selling, this function is used to calculate the change to the + * pool for that asset. swapInReduced calls this with the calculated amountOut + * to find out if less than the offeredAmountIn would be sufficient. + * + * deltaX = (deltaYOverY/(1 - deltaYOverY))*x + * Equivalently: (deltaY / (Y - deltaY )) * x + * + * @param {Amount} x - the amount of Brand X in the pool + * @param {Amount} y - the amount of Brand Y in the pool + * @param {Amount} deltaY - the amount of Brand Y to be taken out + * @returns {Amount} deltaX - the amount of Brand X to be added + */ +export const calcDeltaXSellingX = (x, y, deltaY) => { + const yMinusDeltaY = AmountMath.subtract(y, deltaY); + const yRatio = makeRatioFromAmounts(deltaY, yMinusDeltaY); + // We want to err on the side of the pool, so this will use ceilMultiply to + // round up the amount required. + return ceilMultiplyKeepBrand(x, yRatio); +}; + +/** + * The input contains the amounts in the pool and a maximum amount offered. + * Calculate the most beneficial trade that satisfies the constant product + * invariant. + * + * @param {GetXYResultDeltaX} obj + * @returns {ImprovedNoFeeSwapResult} + */ +const swapInReduced = ({ x, y, deltaX: offeredAmountIn }) => { + const amountOut = calcDeltaYSellingX(x, y, offeredAmountIn); + const reducedAmountIn = calcDeltaXSellingX(x, y, amountOut); + + assert( + AmountMath.isGTE(offeredAmountIn, reducedAmountIn), + X`The trade would have required ${reducedAmountIn} more than was offered ${offeredAmountIn}`, + ); + + return harden({ + amountIn: reducedAmountIn, + amountOut, + }); +}; + +/** + * The input contains the amounts in the pool and the minimum amount requested. + * Calculate the most beneficial trade that satisfies the constant product + * invariant. + * + * @param {GetXYResultDeltaY} obj + * @returns {ImprovedNoFeeSwapResult} + */ +const swapOutImproved = ({ x, y, deltaY: wantedAmountOut }) => { + const amountIn = calcDeltaXSellingX(x, y, wantedAmountOut); + const improvedAmountOut = calcDeltaYSellingX(x, y, amountIn); + + assert( + AmountMath.isGTE(improvedAmountOut, wantedAmountOut), + X`The trade would have returned ${improvedAmountOut} less than was wanted ${wantedAmountOut}`, + ); + + return harden({ + amountIn, + amountOut: improvedAmountOut, + }); +}; + +/** @type {NoFeeSwapFn} */ +export const swapInNoFees = ({ amountGiven, poolAllocation }) => { + const XY = getXY({ amountGiven, poolAllocation }); + return swapInReduced(XY); +}; + +/** @type {NoFeeSwapFn} */ +export const swapOutNoFees = ({ poolAllocation, amountWanted }) => { + const XY = getXY({ poolAllocation, amountWanted }); + return swapOutImproved(XY); +}; diff --git a/packages/zoe/src/contracts/constantProduct/defaults.js b/packages/zoe/src/contracts/constantProduct/defaults.js new file mode 100644 index 00000000000..4cddc39f4c1 --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/defaults.js @@ -0,0 +1,5 @@ +// @ts-check + +export const BASIS_POINTS = 10000n; +export const DEFAULT_POOL_FEE = 24n; // 0.0024 or .24% +export const DEFAULT_PROTOCOL_FEE = 6n; // .0006 or .06% diff --git a/packages/zoe/src/contracts/constantProduct/getXY.js b/packages/zoe/src/contracts/constantProduct/getXY.js new file mode 100644 index 00000000000..3f13827a1fb --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/getXY.js @@ -0,0 +1,51 @@ +// @ts-check + +// This does not support secondary to secondary. That has to happen at +// a higher abstraction + +const { details: X } = assert; +/** + * The caller provides poolAllocation, which has balances for both Central and + * Secondary, and at least one of amountGiven and amountWanted. getXY treats + * the amountGiven as the X pool, and amountWanted as Y. It figures out which + * way to pair up X and Y with Central and Secondary, and returns the pool + * balances as X and Y and given and wanted as deltaX and deltaY + * { X, Y, deltaX, deltaY }. + * + * @type {GetXY} + */ +export const getXY = ({ amountGiven, poolAllocation, amountWanted }) => { + // Regardless of whether we are specifying the amountIn or the + // amountOut, the xBrand is the brand of the amountIn. + const xBrand = amountGiven && amountGiven.brand; + const yBrand = amountWanted && amountWanted.brand; + const secondaryBrand = poolAllocation.Secondary.brand; + const centralBrand = poolAllocation.Central.brand; + assert( + amountGiven || amountWanted, + X`At least one of ${amountGiven} and ${amountWanted} must be specified`, + ); + + const deltas = { + deltaX: amountGiven, + deltaY: amountWanted, + }; + + if (secondaryBrand === xBrand || centralBrand === yBrand) { + // @ts-ignore at least one of amountGiven and amountWanted is non-null + return harden({ + x: poolAllocation.Secondary, + y: poolAllocation.Central, + ...deltas, + }); + } + if (centralBrand === xBrand || secondaryBrand === yBrand) { + // @ts-ignore at least one of amountGiven and amountWanted is non-null + return harden({ + x: poolAllocation.Central, + y: poolAllocation.Secondary, + ...deltas, + }); + } + assert.fail(`brand ${xBrand} was not recognized as Central or Secondary`); +}; diff --git a/packages/zoe/src/contracts/constantProduct/internal-types.js b/packages/zoe/src/contracts/constantProduct/internal-types.js new file mode 100644 index 00000000000..2fa767d17d3 --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/internal-types.js @@ -0,0 +1,129 @@ +// @ts-check + +/** + * @typedef {Object} ImprovedNoFeeSwapResult + * @property {Amount} amountIn + * @property {Amount} amountOut + */ + +/** + * @typedef {Object} FeePair + * + * @property {Amount} poolFee + * @property {Amount} protocolFee + */ + +/** + * @typedef {Object} PoolAllocation + * + * @property {Amount} Central + * @property {Amount} Secondary + */ + +/** + * @typedef {Object} NoFeeSwapFnInput + * @property {Amount} amountGiven + * @property {Amount} amountWanted + * @property {Brand=} brand + * @property {PoolAllocation} poolAllocation + */ + +/** + * @typedef {Object} SwapResult + * + * @property {Amount} xIncrement + * @property {Amount} swapperGives + * @property {Amount} yDecrement + * @property {Amount} swapperGets + * @property {Amount} protocolFee + * @property {Amount} poolFee + * @property {Amount} newY + * @property {Amount} newX + */ + +/** + * This is the type for swapInNoFees and swapOutNoFees. pricesForStatedInput() + * uses swapInNoFees, while pricesForStatedOutput() uses swapOutNoFees. + * + * @callback NoFeeSwapFn + * @param {NoFeeSwapFnInput} input + * @returns {ImprovedNoFeeSwapResult} + */ + +/** + * @typedef {FeePair & ImprovedNoFeeSwapResult} FeeEstimate + */ + +/** + * @callback CalculateFees + * @param {Amount} amountGiven + * @param {PoolAllocation} poolAllocation + * @param {Amount} amountWanted + * @param {Ratio} protocolFeeRatio + * @param {Ratio} poolFeeRatio + * @param {NoFeeSwapFn} swapFn + * @returns {FeeEstimate} + */ + +/** + * @callback InternalSwap + * @param {Amount} amountGiven + * @param {PoolAllocation} poolAllocation + * @param {Amount} amountWanted + * @param {Ratio} protocolFeeRatio + * @param {Ratio} poolFeeRatio + * @param {NoFeeSwapFn} swapFn + * @returns {SwapResult} + */ + +/** + * @callback CalcSwapInPrices + * @param {Amount} amountGiven + * @param {PoolAllocation} poolAllocation + * @param {Amount=} amountWanted + * @param {Ratio} protocolFeeRatio + * @param {Ratio} poolFeeRatio + * @returns {SwapResult} + */ +/** + * @callback CalcSwapOutPrices + * @param {Amount=} amountGiven + * @param {PoolAllocation} poolAllocation + * @param {Amount} amountWanted + * @param {Ratio} protocolFeeRatio + * @param {Ratio} poolFeeRatio + * @returns {SwapResult} + */ + +/** + * @typedef {Object} GetXYParam + * @property {Amount=} amountGiven + * @property {PoolAllocation} poolAllocation + * @property {Amount=} amountWanted + */ + +/** + * @typedef {Object} GetXYResultDeltaX + * @property {Amount} x + * @property {Amount} y + * @property {Amount} deltaX + * @property {Amount|undefined} deltaY + */ + +/** + * @typedef {Object} GetXYResultDeltaY + * @property {Amount} x + * @property {Amount} y + * @property {Amount} deltaY + * @property {Amount|undefined} deltaX + */ + +/** + * @typedef {GetXYResultDeltaX & GetXYResultDeltaY} GetXYResult + */ + +/** + * @callback GetXY + * @param {GetXYParam} obj + * @returns {GetXYResult} + */ diff --git a/packages/zoe/src/contracts/constantProduct/invariants.js b/packages/zoe/src/contracts/constantProduct/invariants.js new file mode 100644 index 00000000000..af85ff16065 --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/invariants.js @@ -0,0 +1,43 @@ +// @ts-check + +import { assert, details as X } from '@agoric/assert'; +import { AmountMath } from '@agoric/ertp'; + +import { natSafeMath } from '../../contractSupport/index.js'; + +/** + * xy <= (x + deltaX)(y - deltaY) + * + * @param {Amount} x - the amount of Brand X in pool, xPoolAllocation + * @param {Amount} y - the amount of Brand Y in pool, yPoolAllocation + * @param {Amount} deltaX - the amount of Brand X to be added + * @param {Amount} deltaY - the amount of Brand Y to be taken out + */ +export const checkKInvariantSellingX = (x, y, deltaX, deltaY) => { + const oldK = natSafeMath.multiply(x.value, y.value); + const newX = AmountMath.add(x, deltaX); + const newY = AmountMath.subtract(y, deltaY); + const newK = natSafeMath.multiply(newX.value, newY.value); + return oldK <= newK; +}; + +/** + * xy <= (x + deltaX)(y - deltaY) + * + * @param {Amount} x - the amount of Brand X in pool, xPoolAllocation + * @param {Amount} y - the amount of Brand Y in pool, yPoolAllocation + * @param {Amount} deltaX - the amount of Brand X to be added + * @param {Amount} deltaY - the amount of Brand Y to be taken out + */ +export const assertKInvariantSellingX = (x, y, deltaX, deltaY) => { + assert( + checkKInvariantSellingX(x, y, deltaX, deltaY), + X`the constant product invariant was violated, with x=${x}, y=${y}, deltaX=${deltaX}, deltaY=${deltaY}, oldK=${natSafeMath.multiply( + x.value, + y.value, + )}, newK=${natSafeMath.multiply( + AmountMath.add(x, deltaX).value, + AmountMath.subtract(y, deltaY).value, + )}`, + ); +}; diff --git a/packages/zoe/src/contracts/constantProduct/swap.js b/packages/zoe/src/contracts/constantProduct/swap.js new file mode 100644 index 00000000000..64111e0b7d1 --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/swap.js @@ -0,0 +1,274 @@ +// @ts-check + +import { AmountMath } from '@agoric/ertp'; +import { calculateFees, amountGT } from './calcFees.js'; + +const { details: X } = assert; + +/** + * The fee might not be in the same brand as the amount. If they are the same, + * subtract the fee from the amount. Otherwise return the unadjusted amount. + * + * We return empty if the fee is larger because an empty amount indicates that + * the trader didn't place a limit on the inputAmount. + * + * @param {Amount} amount + * @param {Amount} fee + * @returns {Amount} + */ +const subtractRelevantFees = (amount, fee) => { + if (amount.brand === fee.brand) { + if (AmountMath.isGTE(fee, amount)) { + return AmountMath.makeEmptyFromAmount(amount); + } + + return AmountMath.subtract(amount, fee); + } + return amount; +}; + +/** + * PoolFee and ProtocolFee each identify their brand. If either (or both) match + * the brand of the Amount, subtract it/them from the amount. + * + * @param {Amount} amount + * @param {FeePair} fee + * @returns {Amount} + */ +const subtractFees = (amount, { poolFee, protocolFee }) => { + return subtractRelevantFees( + subtractRelevantFees(amount, protocolFee), + poolFee, + ); +}; + +/** + * The fee might not be in the same brand as the amount. If they are the same, + * add the fee to the amount. Otherwise return the unadjusted amount. + * + * @param {Amount} amount + * @param {Amount} fee + * @returns {Amount} + */ +const addRelevantFees = (amount, fee) => { + if (amount.brand === fee.brand) { + return AmountMath.add(amount, fee); + } + return amount; +}; + +/** + * PoolFee and ProtocolFee each identify their brand. If either (or both) match + * the brand of the Amount, add it/them to the amount. + * + * @param {Amount} amount + * @param {FeePair} fee + * @returns {Amount} + */ +const addFees = (amount, { poolFee, protocolFee }) => { + return addRelevantFees(addRelevantFees(amount, protocolFee), poolFee); +}; + +/** + * Increment or decrement a pool balance by an amount. The amount's brand might + * match the Central or Secondary balance of the pool. Return the adjusted + * balance. The caller knows which amount they provided, so they're expecting a + * single Amount whose brand matches the amount parameter. + * + * The first parameter specifies whether we're incrementing or decrementing from the pool + * + * @param {(amountLeft: Amount, amountRight: Amount, brand?: Brand) => Amount} addOrSub + * @param {PoolAllocation} poolAllocation + * @param {Amount} amount + * @returns {Amount} + */ +const addOrSubtractFromPool = (addOrSub, poolAllocation, amount) => { + if (poolAllocation.Central.brand === amount.brand) { + return addOrSub(poolAllocation.Central, amount); + } else { + return addOrSub(poolAllocation.Secondary, amount); + } +}; + +const isGreaterThanZero = amount => { + return amount && amountGT(amount, AmountMath.makeEmptyFromAmount(amount)); +}; + +const assertGreaterThanZero = (amount, name) => { + assert( + amount && isGreaterThanZero(amount), + X`${name} must be greater than 0: ${amount}`, + ); +}; + +const isWantedAvailable = (poolAllocation, amountWanted) => { + // The question is about a poolAllocation. If it has exactly amountWanted, + // that's not sufficient, since it would leave the pool empty. + return amountWanted.brand === poolAllocation.Central.brand + ? !AmountMath.isGTE(amountWanted, poolAllocation.Central) + : !AmountMath.isGTE(amountWanted, poolAllocation.Secondary); +}; + +/** + * We've identified a violation of constraints that means we won't be able to + * satisfy the user's request. (Not enough funds in the pool, too much was + * requested, the proceeds would be empty, etc.) Return a result that says no + * trade will take place and the pool balances won't change. + * + * @param {Amount} amountGiven + * @param {Amount} amountWanted + * @param {PoolAllocation} poolAllocation + * @param {Ratio} poolFee + */ +const makeNoTransaction = ( + amountGiven, + amountWanted, + poolAllocation, + poolFee, +) => { + const emptyGive = AmountMath.makeEmptyFromAmount(amountGiven); + const emptyWant = AmountMath.makeEmptyFromAmount(amountWanted); + + let newX; + let newY; + if (poolAllocation.Central.brand === amountGiven.brand) { + newX = poolAllocation.Central; + newY = poolAllocation.Secondary; + } else { + newX = poolAllocation.Secondary; + newY = poolAllocation.Central; + } + + const result = harden({ + protocolFee: AmountMath.makeEmpty(poolAllocation.Central.brand), + poolFee: AmountMath.makeEmpty(poolFee.numerator.brand), + swapperGives: emptyGive, + swapperGets: emptyWant, + xIncrement: emptyGive, + yDecrement: emptyWant, + newX, + newY, + }); + return result; +}; + +/** + * This is the heart of the calculation. See README.md for the long explanation. + * calculate how much should be added to and removed from the pool, the fees, + * and what the user will pay and receive. + * + * As soon as we detect that we won't be able to satisfy the request, we return + * noTransaction, indicating that no trade should take place. This can be due to + * a request for more assets than the pool holds, a specified price the current + * assets won't support, or the trade would requre more than the trader allowed, + * or provide less, or fees would eat up all the trader's proceeds. + * + * We start by calculating the amounts that would be traded if no fees were + * charged. The actual fees are based on these amounts. Once we know the actual + * fees, we calculate the deltaX and deltaY that will best maintain the + * constant product invariant. + * + * The amounts by which the pool will be adjusted, that the trader will pay and + * receive, and the fees are then computed based on deltaX and deltaY. + * + * @type {InternalSwap} + */ +export const swap = ( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + swapFn, +) => { + const noTransaction = makeNoTransaction( + amountGiven, + amountWanted, + poolAllocation, + poolFeeRatio, + ); + assertGreaterThanZero(poolAllocation.Central, 'poolAllocation.Central'); + assertGreaterThanZero(poolAllocation.Secondary, 'poolAllocation.Secondary'); + assert( + isGreaterThanZero(amountGiven) || isGreaterThanZero(amountWanted), + X`amountGiven or amountWanted must be greater than 0: ${amountWanted} ${amountGiven}`, + ); + + if (!isWantedAvailable(poolAllocation, amountWanted)) { + return noTransaction; + } + + // The protocol fee must always be collected in RUN, but the pool + // fee is collected in the amount opposite of what is specified. + // This call gives us improved amountIn or amountOut + const fees = calculateFees( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + swapFn, + ); + + if (!isWantedAvailable(poolAllocation, addFees(amountWanted, fees))) { + return noTransaction; + } + + // Calculate no-fee amounts. swapFn will only pay attention to the `specified` + // value. The pool fee is always charged on the unspecified side, so it is an + // output of the calculation. When BLD was specified, we add the protocol fee + // to amountWanted. When the specified value is in RUN, the protocol fee will + // be deducted from amountGiven before adding to the pool or added to + // amountWanted to calculate amoutOut. + const { amountIn, amountOut } = swapFn({ + amountGiven: subtractFees(amountGiven, fees), + poolAllocation, + amountWanted: addFees(amountWanted, fees), + }); + + if (AmountMath.isEmpty(amountOut)) { + return noTransaction; + } + + // The swapper pays extra or receives less to cover the fees. + const swapperGives = addFees(amountIn, fees); + const swapperGets = subtractFees(amountOut, fees); + + // return noTransaction if fees would eat up all the trader's proceeds, + // the trader specified an amountGiven, and the trade would require more, or + // the trade would require them to give more than they specified. + if ( + AmountMath.isEmpty(swapperGets) || + (!AmountMath.isEmpty(amountGiven) && amountGT(swapperGives, amountGiven)) || + amountGT(amountWanted, swapperGets) + ) { + return noTransaction; + } + + const xIncrement = addRelevantFees(amountIn, fees.poolFee); + const yDecrement = subtractRelevantFees(amountOut, fees.poolFee); + + // poolFee is the amount the pool will grow over the no-fee calculation. + // protocolFee is to be separated and sent to an external purse. + // The swapper amounts are what will be paid and received. + // xIncrement and yDecrement are what will be added and removed from the pools. + // Either xIncrement will be increased by the pool fee or yDecrement will be + // reduced by it in order to compensate the pool. + // newX and newY are the new pool balances, for comparison with start values. + const result = harden({ + protocolFee: fees.protocolFee, + poolFee: fees.poolFee, + swapperGives, + swapperGets, + xIncrement, + yDecrement, + newX: addOrSubtractFromPool(AmountMath.add, poolAllocation, xIncrement), + newY: addOrSubtractFromPool( + AmountMath.subtract, + poolAllocation, + yDecrement, + ), + }); + + return result; +}; diff --git a/packages/zoe/src/contracts/constantProduct/types.js b/packages/zoe/src/contracts/constantProduct/types.js new file mode 100644 index 00000000000..fc2bba0cb07 --- /dev/null +++ b/packages/zoe/src/contracts/constantProduct/types.js @@ -0,0 +1,15 @@ +// @ts-check + +/** + * @callback Maximum + * @param {Amount} left + * @param {Amount} right + * @returns {Amount} + */ + +/** + * @callback AmountGT + * @param {Amount} left + * @param {Amount} right + * @returns {boolean} + */ diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/setupMints.js b/packages/zoe/test/unitTests/contracts/constantProduct/setupMints.js new file mode 100644 index 00000000000..5b0d5ee5246 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/setupMints.js @@ -0,0 +1,19 @@ +// @ts-check + +import { AmountMath, makeIssuerKit, AssetKind } from '@agoric/ertp'; + +export const setupMintKits = () => { + const runKit = makeIssuerKit( + 'RUN', + AssetKind.NAT, + harden({ decimalPlaces: 6 }), + ); + const bldKit = makeIssuerKit( + 'BLD', + AssetKind.NAT, + harden({ decimalPlaces: 6 }), + ); + const run = value => AmountMath.make(runKit.brand, value); + const bld = value => AmountMath.make(bldKit.brand, value); + return { runKit, bldKit, run, bld }; +}; diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/test-calcDeltaX.js b/packages/zoe/test/unitTests/contracts/constantProduct/test-calcDeltaX.js new file mode 100644 index 00000000000..9e3f2b0db18 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/test-calcDeltaX.js @@ -0,0 +1,82 @@ +// @ts-check + +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { AmountMath } from '@agoric/ertp'; + +import { calcDeltaXSellingX } from '../../../../src/contracts/constantProduct/core.js'; +import { setupMintKits } from './setupMints.js'; + +const doTest = (t, x, y, deltaY, expectedDeltaX) => { + const { run, bld } = setupMintKits(); + const result = calcDeltaXSellingX(run(x), bld(y), bld(deltaY)); + t.true( + AmountMath.isEqual(result, run(expectedDeltaX)), + `${result.value} should equal ${expectedDeltaX}`, + ); +}; + +test('0, 0, 0, 0', t => { + t.throws(() => doTest(t, 0, 0, 0, 0), { + message: 'No infinite ratios! Denominator was 0/"[Alleged: BLD brand]"', + }); +}); + +test('0, 0, 1, 0', t => { + t.throws(() => doTest(t, 0, 0, 1, 0), { + message: '-1 is negative', + }); +}); + +test('1, 0, 0, 0', t => { + t.throws(() => doTest(t, 1, 0, 0, 0), { + message: 'No infinite ratios! Denominator was 0/"[Alleged: BLD brand]"', + }); +}); + +test('0, 1, 0, 0', t => { + doTest(t, 0, 1, 0, 0); +}); + +test('1, 1, 0, 0', t => { + doTest(t, 1, 1, 0, 0); +}); + +test('1, 1, 1, 0', t => { + t.throws(() => doTest(t, 1, 1, 1, 0), { + message: 'No infinite ratios! Denominator was 0/"[Alleged: BLD brand]"', + }); +}); + +test('1, 2, 1, 1', t => { + doTest(t, 1, 2, 1, 1); +}); + +test('2, 3, 1, 1', t => { + doTest(t, 2, 3, 1, 1); +}); + +test('928861206, 130870247, 746353662, 158115257', t => { + doTest(t, 928_861_206n, 5_130_870_247n, 746_353_662n, 1_581_152_57n); +}); + +test('9, 17, 3, 2', t => { + doTest(t, 9, 17, 3, 2); +}); + +test('10000, 5000, 209, 437', t => { + doTest(t, 10000, 5000, 209, 437); +}); + +test('1000000, 5000, 209, 1', t => { + doTest(t, 1_000_000, 5000, 209, 43624); +}); + +test('5000, 1000000, 209, 2', t => { + doTest(t, 5000, 1000000, 209, 2); +}); + +test('500_000, 1000_000, 209 or 210', t => { + doTest(t, 500_000, 1000_000, 209, 105); + doTest(t, 500_000, 1000_000, 210, 106); +}); diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/test-calcDeltaY.js b/packages/zoe/test/unitTests/contracts/constantProduct/test-calcDeltaY.js new file mode 100644 index 00000000000..12e0cfb501b --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/test-calcDeltaY.js @@ -0,0 +1,75 @@ +// @ts-check + +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { AmountMath } from '@agoric/ertp'; + +import { calcDeltaYSellingX } from '../../../../src/contracts/constantProduct/core.js'; +import { setupMintKits } from './setupMints.js'; + +const doTest = (t, x, y, deltaX, expectedDeltaY) => { + const { run, bld } = setupMintKits(); + const result = calcDeltaYSellingX(run(x), bld(y), run(deltaX)); + t.true( + AmountMath.isEqual(result, bld(expectedDeltaY)), + `${result.value} equals ${expectedDeltaY}`, + ); +}; + +// deltaXPlusX is 0 +test('0, 0, 0, 0', t => { + t.throws(() => doTest(t, 0, 0, 0, 0), { + message: 'No infinite ratios! Denominator was 0/"[Alleged: RUN brand]"', + }); +}); + +test('0, 0, 1, 0', t => { + doTest(t, 0, 0, 1, 0); +}); + +test('1, 0, 0, 0', t => { + doTest(t, 1, 0, 0, 0); +}); + +// deltaXPlusX is 0 +test('0, 1, 0, 0', t => { + t.throws(() => doTest(t, 0, 1, 0, 0), { + message: 'No infinite ratios! Denominator was 0/"[Alleged: RUN brand]"', + }); +}); + +test('1, 1, 0, 0', t => { + doTest(t, 1, 1, 0, 0); +}); + +test('1, 1, 1, 0', t => { + doTest(t, 1, 1, 1, 0); +}); + +test('1, 2, 1, 1', t => { + doTest(t, 1, 2, 1, 1); +}); + +test('2, 3, 4, 2', t => { + doTest(t, 2, 3, 4, 2); +}); + +test('928861206, 130870247, 746353662, 58306244', t => { + doTest(t, 928861206n, 130870247n, 746353662n, 58306244n); +}); + +test('9, 3, 17, 1', t => { + doTest(t, 9, 3, 17, 1); +}); + +test('10000, 5000, 209, 102', t => { + doTest(t, 10000, 5000, 209, 102); +}); + +test('1000000, 5000, 209, 1', t => { + doTest(t, 1000000, 5000, 209, 1); +}); + +test('5000, 1000000, 209, 40122', t => { + doTest(t, 5000, 1000000, 209, 40122); +}); diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/test-checkInvariants.js b/packages/zoe/test/unitTests/contracts/constantProduct/test-checkInvariants.js new file mode 100644 index 00000000000..26ec61c890d --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/test-checkInvariants.js @@ -0,0 +1,421 @@ +// @ts-check +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { AmountMath } from '@agoric/ertp'; +import { BASIS_POINTS } from '../../../../src/contracts/constantProduct/defaults.js'; +import { setupMintKits } from './setupMints.js'; +import { makeRatio } from '../../../../src/contractSupport/index.js'; +import { + pricesForStatedInput, + pricesForStatedOutput, +} from '../../../../src/contracts/constantProduct/calcSwapPrices.js'; +import { checkKInvariantSellingX } from '../../../../src/contracts/constantProduct/invariants.js'; +import { getXY } from '../../../../src/contracts/constantProduct/getXY.js'; + +const prepareRUNInTest = ({ + inputReserve, + outputReserve, + inputValue, + outputValue, +}) => { + const { run, bld, runKit, bldKit } = setupMintKits(); + const amountGiven = run(inputValue || 0n); + const poolAllocation = harden({ + Central: run(inputReserve), + Secondary: bld(outputReserve), + }); + const amountWanted = bld(outputValue || 0n); + const protocolFeeRatio = makeRatio(5n, runKit.brand, BASIS_POINTS); + const poolFeeRatio = makeRatio(25n, bldKit.brand, BASIS_POINTS); + + return harden([ + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ]); +}; + +const prepareRUNOutTest = ({ + inputReserve, + outputReserve, + inputValue, + outputValue, +}) => { + const { run, bld, runKit, bldKit } = setupMintKits(); + const amountGiven = bld(inputValue || 0n); + const poolAllocation = harden({ + Central: run(inputReserve), + Secondary: bld(outputReserve), + }); + const amountWanted = run(outputValue || 0n); + const protocolFeeRatio = makeRatio(5n, bldKit.brand, BASIS_POINTS); + const poolFeeRatio = makeRatio(25n, runKit.brand, BASIS_POINTS); + + return harden([ + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ]); +}; + +function checkGetInput(t, args, result) { + t.falsy(AmountMath.isEmpty(result.swapperGets)); + t.truthy(AmountMath.isGTE(args[0], result.swapperGives)); + t.truthy(AmountMath.isGTE(result.swapperGets, args[2])); + t.falsy(AmountMath.isEmpty(result.poolFee)); + t.falsy(AmountMath.isEmpty(result.protocolFee)); + + t.deepEqual( + AmountMath.add(result.xIncrement, result.protocolFee), + result.swapperGives, + ); + t.deepEqual(result.yDecrement, result.swapperGets); + + const xyArgs = { + amountGiven: args[0], + poolAllocation: args[1], + amountWanted: args[2], + }; + const { x, y } = getXY(xyArgs); + t.truthy(checkKInvariantSellingX(x, y, result.xIncrement, result.yDecrement)); +} + +function checkGetOutput(t, args, result) { + t.falsy(AmountMath.isEmpty(result.swapperGets)); + if (!AmountMath.isEmpty(args[0])) { + t.truthy(AmountMath.isGTE(args[0], result.swapperGives)); + } + t.truthy(AmountMath.isGTE(result.swapperGets, args[2])); + t.falsy(AmountMath.isEmpty(result.poolFee)); + t.falsy(AmountMath.isEmpty(result.protocolFee)); + + t.deepEqual( + AmountMath.add(result.xIncrement, result.protocolFee), + result.swapperGives, + ); + t.deepEqual(result.yDecrement, result.swapperGets); + + const xyArgs = { + amountGiven: args[0], + poolAllocation: args[1], + amountWanted: args[2], + }; + const { x, y } = getXY(xyArgs); + t.truthy(checkKInvariantSellingX(x, y, result.xIncrement, result.yDecrement)); +} + +const testGetInputPrice = (t, inputs, runIn) => { + const args = runIn ? prepareRUNInTest(inputs) : prepareRUNOutTest(inputs); + const result = pricesForStatedInput(...args); + checkGetInput(t, args, result); +}; + +const testGetInputPriceThrows = (t, inputs, message, runIn) => { + t.throws( + _ => { + const args = runIn ? prepareRUNInTest(inputs) : prepareRUNOutTest(inputs); + return pricesForStatedInput(...args); + }, + { + message, + }, + ); +}; + +const testGetInputPriceNoTrade = (t, inputs, runIn) => { + const args = runIn ? prepareRUNInTest(inputs) : prepareRUNOutTest(inputs); + const result = pricesForStatedInput(...args); + t.truthy(AmountMath.isEmpty(result.swapperGets)); + t.truthy(AmountMath.isEmpty(result.swapperGives)); +}; + +const testGetOutputPrice = (t, inputs, runIn) => { + const args = runIn ? prepareRUNInTest(inputs) : prepareRUNOutTest(inputs); + const result = pricesForStatedOutput(...args); + checkGetOutput(t, args, result); +}; + +const getOutputPriceThrows = (t, inputs, message, runIn) => { + t.throws( + _ => { + const args = runIn ? prepareRUNInTest(inputs) : prepareRUNOutTest(inputs); + return pricesForStatedOutput(...args); + }, + { + message, + }, + ); +}; + +const testGetOutputPriceNoTrade = (t, inputs, runIn) => { + const args = runIn ? prepareRUNInTest(inputs) : prepareRUNOutTest(inputs); + const result = pricesForStatedOutput(...args); + t.truthy(AmountMath.isEmpty(result.swapperGets)); + t.truthy(AmountMath.isEmpty(result.swapperGives)); +}; + +test('getInputPrice no reserves', t => { + const input = { + inputReserve: 0n, + outputReserve: 0n, + inputValue: 1n, + }; + const message = + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + testGetInputPriceThrows(t, input, message, true); + testGetInputPriceThrows(t, input, message, false); +}); + +test('getInputPrice ok 2', t => { + const input = { + inputReserve: 5984n, + outputReserve: 3028n, + inputValue: 1398n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice ok 2w/output', t => { + const input = { + inputReserve: 5984n, + outputReserve: 3028n, + inputValue: 1398n, + outputValue: 572n, + }; + testGetInputPriceNoTrade(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice outLimit', t => { + const input = { + inputReserve: 9348n, + outputReserve: 2983n, + inputValue: 828n, + outputValue: 350n, + }; + testGetInputPriceNoTrade(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice ok 3', t => { + const input = { + inputReserve: 8160n, + outputReserve: 7743n, + inputValue: 6635n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice ok 4', t => { + const input = { + inputReserve: 10n, + outputReserve: 10n, + inputValue: 1000n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice ok 5', t => { + const input = { + inputReserve: 100n, + outputReserve: 50n, + inputValue: 17n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice zero outputValue', t => { + const input = { + inputReserve: 100n, + outputReserve: 50n, + inputValue: 17n, + outputValue: 0n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice ok 6', t => { + const input = { + inputReserve: 43n, + outputReserve: 117n, + inputValue: 7n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice negative', t => { + const input = { + inputReserve: 43n, + outputReserve: 117n, + inputValue: -7n, + }; + const message = 'value "[-7n]" must be a Nat or an array'; + testGetInputPriceThrows(t, input, message, true); + testGetInputPriceThrows(t, input, message, false); +}); + +test('getInputPrice bad reserve 1', t => { + const input = { + inputReserve: 43n, + outputReserve: 0n, + inputValue: 347n, + }; + const message = + '"poolAllocation.Secondary" must be greater than 0: {"brand":"[Alleged: BLD brand]","value":"[0n]"}'; + testGetInputPriceThrows(t, input, message, true); + testGetInputPriceThrows(t, input, message, false); +}); + +test('getInputPrice bad reserve 2', t => { + const input = { + inputReserve: 0n, + outputReserve: 50n, + inputValue: 828n, + }; + const message = + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + testGetInputPriceThrows(t, input, message, true); + testGetInputPriceThrows(t, input, message, false); +}); + +test('getInputPrice zero input', t => { + const input = { + inputReserve: 320n, + outputReserve: 50n, + inputValue: 0n, + }; + const messageA = + 'amountGiven or amountWanted must be greater than 0: {"brand":"[Alleged: BLD brand]","value":"[0n]"} {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + testGetInputPriceThrows(t, input, messageA, true); + const messageB = + 'amountGiven or amountWanted must be greater than 0: {"brand":"[Alleged: RUN brand]","value":"[0n]"} {"brand":"[Alleged: BLD brand]","value":"[0n]"}'; + testGetInputPriceThrows(t, input, messageB, false); +}); + +test('getInputPrice big product', t => { + const input = { + inputReserve: 100_000_000n, + outputReserve: 100_000_000n, + inputValue: 1000n, + outputValue: 50n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getInputPrice README example', t => { + const input = { + inputReserve: 40_000_000n, + outputReserve: 3_000_000n, + inputValue: 30_000n, + outputValue: 2000n, + }; + testGetInputPrice(t, input, true); + testGetInputPrice(t, input, false); +}); + +test('getOutputPrice ok', t => { + const input = { + inputReserve: 43n, + outputReserve: 117n, + outputValue: 37n, + }; + testGetOutputPrice(t, input, true); + testGetOutputPrice(t, input, false); +}); + +test('getOutputPrice zero output reserve', t => { + const input = { + inputReserve: 43n, + outputReserve: 0n, + outputValue: 37n, + }; + const message = + '"poolAllocation.Secondary" must be greater than 0: {"brand":"[Alleged: BLD brand]","value":"[0n]"}'; + getOutputPriceThrows(t, input, message, true); + getOutputPriceThrows(t, input, message, false); +}); + +test('getOutputPrice zero input reserve', t => { + const input = { + inputReserve: 0n, + outputReserve: 92n, + outputValue: 37n, + }; + const message = + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + getOutputPriceThrows(t, input, message, true); + getOutputPriceThrows(t, input, message, false); +}); + +test('getOutputPrice too much output', t => { + const input = { + inputReserve: 1132n, + outputReserve: 1024n, + outputValue: 20923n, + }; + testGetOutputPriceNoTrade(t, input, true); + testGetOutputPriceNoTrade(t, input, false); +}); + +test('getOutputPrice too much output 2', t => { + const input = { + inputReserve: 1132n, + outputReserve: 345n, + outputValue: 345n, + }; + testGetOutputPriceNoTrade(t, input, true); + testGetOutputPrice(t, input, false); +}); + +test('getOutputPrice zero inputValue', t => { + const input = { + inputReserve: 1132n, + outputReserve: 3145n, + inputValue: 0n, + outputValue: 345n, + }; + testGetOutputPrice(t, input, true); + testGetOutputPrice(t, input, false); +}); + +test('getOutputPrice big product', t => { + const input = { + inputReserve: 100_000_000n, + outputReserve: 100_000_000n, + outputValue: 1000n, + }; + testGetOutputPrice(t, input, true); + testGetOutputPrice(t, input, false); +}); + +test('getOutputPrice minimum price', t => { + const input = { + inputReserve: 1n, + outputReserve: 10n, + outputValue: 1n, + }; + testGetOutputPrice(t, input, true); + testGetOutputPriceNoTrade(t, input, false); +}); + +test('getOutputPrice large values, in/out', t => { + const input = { + inputReserve: 1_192_432n, + outputReserve: 3_298_045n, + inputValue: 13_435n, + outputValue: 3_435n, + }; + testGetOutputPrice(t, input, true); + testGetOutputPrice(t, input, false); +}); diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/test-compareBondingCurves.js b/packages/zoe/test/unitTests/contracts/constantProduct/test-compareBondingCurves.js new file mode 100644 index 00000000000..8e0801ef83a --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/test-compareBondingCurves.js @@ -0,0 +1,281 @@ +// @ts-check +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { BASIS_POINTS } from '../../../../src/contracts/constantProduct/defaults.js'; +import { setupMintKits } from './setupMints.js'; +import { makeRatio } from '../../../../src/contractSupport/index.js'; +import { + pricesForStatedInput, + pricesForStatedOutput, +} from '../../../../src/contracts/constantProduct/calcSwapPrices.js'; + +// This assumes run is swapped in. The test should function the same +// regardless of what brand is the amountIn, because no run fee is +// charged. +const prepareSwapInTest = ({ inputReserve, outputReserve, inputValue }) => { + const { run, bld, runKit, bldKit } = setupMintKits(); + const amountGiven = run(inputValue); + const poolAllocation = harden({ + Central: run(inputReserve), + Secondary: bld(outputReserve), + }); + const amountWanted = bld(0n); + const protocolFeeRatio = makeRatio(0n, runKit.brand, BASIS_POINTS); + const poolFeeRatio = makeRatio(3n, bldKit.brand, BASIS_POINTS); + + const args = [ + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ]; + return harden({ + args, + run, + bld, + }); +}; + +const testInputGetPrice = (t, inputs, expectedOutput) => { + const { args, bld } = prepareSwapInTest(inputs); + const result = pricesForStatedInput(...args); + t.deepEqual(result.swapperGets, bld(expectedOutput)); +}; + +const getInputPriceThrows = (t, inputs, message) => { + t.throws( + _ => { + const { args } = prepareSwapInTest(inputs); + return pricesForStatedInput(...args); + }, + { + message, + }, + ); +}; + +// This assumes run is swapped in. The test should function the same +// regardless of what brand is the amountIn, because no run fee is +// charged. +const prepareSwapOutTest = ({ inputReserve, outputReserve, outputValue }) => { + const { run, bld, runKit } = setupMintKits(); + const amountGiven = run(0n); + const poolAllocation = harden({ + Central: run(inputReserve), + Secondary: bld(outputReserve), + }); + const amountWanted = bld(outputValue); + const protocolFeeRatio = makeRatio(0n, runKit.brand, BASIS_POINTS); + const poolFeeRatio = makeRatio(30n, runKit.brand, BASIS_POINTS); + + const args = [ + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ]; + return harden({ + args, + run, + bld, + }); +}; + +const testGetOutputPrice = (t, inputs, expectedInput) => { + const { args, run } = prepareSwapOutTest(inputs); + const result = pricesForStatedOutput(...args); + t.deepEqual(result.swapperGives, run(expectedInput)); +}; + +const getOutputPriceThrows = (t, inputs, message) => { + const { args } = prepareSwapOutTest(inputs); + t.throws(_ => pricesForStatedOutput(...args), { + message, + }); +}; + +test('getInputPrice no reserves', t => { + const input = { + inputReserve: 0n, + outputReserve: 0n, + inputValue: 1n, + }; + const message = + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + getInputPriceThrows(t, input, message); +}); + +test('getInputPrice ok 2', t => { + const input = { + inputReserve: 5984n, + outputReserve: 3028n, + inputValue: 1398n, + }; + const expectedOutput = 572n; + testInputGetPrice(t, input, expectedOutput); +}); + +test('getInputPrice ok 3', t => { + const input = { + inputReserve: 8160n, + outputReserve: 7743n, + inputValue: 6635n, + }; + const expectedOutput = 3470n; + testInputGetPrice(t, input, expectedOutput); +}); + +test('getInputPrice ok 4', t => { + const input = { + inputReserve: 10n, + outputReserve: 10n, + inputValue: 1000n, + }; + const expectedOutput = 8n; + testInputGetPrice(t, input, expectedOutput); +}); + +test('getInputPrice ok 5', t => { + const input = { + inputReserve: 100n, + outputReserve: 50n, + inputValue: 17n, + }; + const expectedOutput = 6n; + testInputGetPrice(t, input, expectedOutput); +}); + +test('getInputPrice ok 6', t => { + const input = { + inputReserve: 43n, + outputReserve: 117n, + inputValue: 7n, + }; + const expectedOutput = 15n; + testInputGetPrice(t, input, expectedOutput); +}); + +test('getInputPrice negative', t => { + const input = { + inputReserve: 43n, + outputReserve: 117n, + inputValue: -7n, + }; + const message = 'value "[-7n]" must be a Nat or an array'; + getInputPriceThrows(t, input, message); +}); + +test('getInputPrice bad reserve 1', t => { + const input = { + inputReserve: 43n, + outputReserve: 0n, + inputValue: 347n, + }; + const message = + '"poolAllocation.Secondary" must be greater than 0: {"brand":"[Alleged: BLD brand]","value":"[0n]"}'; + getInputPriceThrows(t, input, message); +}); + +test('getInputPrice bad reserve 2', t => { + const input = { + inputReserve: 0n, + outputReserve: 50n, + inputValue: 828n, + }; + const message = + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + getInputPriceThrows(t, input, message); +}); + +test('getInputPrice zero input', t => { + const input = { + inputReserve: 320n, + outputReserve: 50n, + inputValue: 0n, + }; + const message = + 'amountGiven or amountWanted must be greater than 0: {"brand":"[Alleged: BLD brand]","value":"[0n]"} {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + getInputPriceThrows(t, input, message); +}); + +test('getInputPrice big product', t => { + const input = { + inputReserve: 100_000_000n, + outputReserve: 100_000_000n, + inputValue: 1000n, + }; + const expectedOutput = 998n; + testInputGetPrice(t, input, expectedOutput); +}); + +test('getOutputPrice ok', t => { + const input = { + inputReserve: 43n, + outputReserve: 117n, + outputValue: 37n, + }; + const expectedOutput = 21n; + testGetOutputPrice(t, input, expectedOutput); +}); + +test('getOutputPrice zero output reserve', t => { + const input = { + inputReserve: 43n, + outputReserve: 0n, + outputValue: 37n, + }; + const message = + '"poolAllocation.Secondary" must be greater than 0: {"brand":"[Alleged: BLD brand]","value":"[0n]"}'; + getOutputPriceThrows(t, input, message); +}); + +test('getOutputPrice zero input reserve', t => { + const input = { + inputReserve: 0n, + outputReserve: 92n, + outputValue: 37n, + }; + const message = + '"poolAllocation.Central" must be greater than 0: {"brand":"[Alleged: RUN brand]","value":"[0n]"}'; + getOutputPriceThrows(t, input, message); +}); + +test('getOutputPrice too much output', t => { + const input = { + inputReserve: 1132n, + outputReserve: 1024n, + outputValue: 20923n, + }; + testGetOutputPrice(t, input, 0n); +}); + +test('getOutputPrice too much output 2', t => { + const input = { + inputReserve: 1132n, + outputReserve: 345n, + outputValue: 345n, + }; + testGetOutputPrice(t, input, 0n); +}); + +test('getOutputPrice big product', t => { + const input = { + inputReserve: 100_000_000n, + outputReserve: 100_000_000n, + outputValue: 1000n, + }; + const expectedOutput = 1005n; + testGetOutputPrice(t, input, expectedOutput); +}); + +test('getOutputPrice minimum price', t => { + const input = { + inputReserve: 1n, + outputReserve: 10n, + outputValue: 1n, + }; + const expectedOutput = 2n; + testGetOutputPrice(t, input, expectedOutput); +}); diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/test-compareNewSwapPrice.js b/packages/zoe/test/unitTests/contracts/constantProduct/test-compareNewSwapPrice.js new file mode 100644 index 00000000000..ed754588d80 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/test-compareNewSwapPrice.js @@ -0,0 +1,168 @@ +// @ts-check + +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { AmountMath, makeIssuerKit } from '@agoric/ertp'; +import { + calcDeltaXSellingX, + calcDeltaYSellingX, + swapInNoFees, +} from '../../../../src/contracts/constantProduct/core.js'; +import { makeRatio } from '../../../../src/contractSupport/index.js'; +import { pricesForStatedInput } from '../../../../src/contracts/constantProduct/calcSwapPrices.js'; + +const BASIS_POINTS = 10000n; +const POOL_FEE = 24n; +const PROTOCOL_FEE = 6n; + +// Moola is central + +const setupMints = () => { + const moolaKit = makeIssuerKit('moola'); + const bucksKit = makeIssuerKit('bucks'); + const simoleanKit = makeIssuerKit('simolean'); + + const moola = value => AmountMath.make(moolaKit.brand, value); + const bucks = value => AmountMath.make(bucksKit.brand, value); + const simoleans = value => AmountMath.make(simoleanKit.brand, value); + + return { + moolaKit, + bucksKit, + simoleanKit, + moola, + bucks, + simoleans, + }; +}; + +test('pricesForStatedInput specify central', async t => { + const { moola, bucks, moolaKit, bucksKit } = setupMints(); + const poolAllocation = { + Central: moola(800000n), + Secondary: bucks(300000n), + }; + const amountGiven = moola(10000n); + const amountWanted = bucks(1n); + + const protocolFeeRatio = makeRatio( + PROTOCOL_FEE, + moolaKit.brand, + BASIS_POINTS, + ); + const poolFeeRatio = makeRatio(POOL_FEE, bucksKit.brand, BASIS_POINTS); + + // This is reduced, if any reduction occurs. + const noFeesResult = swapInNoFees({ amountGiven, poolAllocation }); + t.deepEqual(noFeesResult.amountIn, moola(9999n)); + t.deepEqual(noFeesResult.amountOut, bucks(3703n)); + + const noReductionResult = calcDeltaYSellingX( + poolAllocation.Central, + poolAllocation.Secondary, + amountGiven, + ); + t.deepEqual(noReductionResult, bucks(3703n)); + + const reduced = calcDeltaXSellingX( + poolAllocation.Central, + poolAllocation.Secondary, + noReductionResult, + ); + t.deepEqual(reduced, moola(9999n)); + + const result = pricesForStatedInput( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ); + // swapperGives is 9999n + // t.deepEqual(result.swapperGives, moola(9997n)); + // Same + t.deepEqual(result.swapperGets, bucks(3692n)); + // Protocol fee is 6n + // t.deepEqual(result.protocolFee, moola(5n)); +}); + +test('pricesForStatedInput secondary', async t => { + const { moola, bucks, moolaKit } = setupMints(); + const poolAllocation = { + Central: moola(800000n), + Secondary: bucks(500000n), + }; + const amountGiven = bucks(10000n); + const amountWanted = moola(1n); + + const protocolFeeRatio = makeRatio( + PROTOCOL_FEE, + moolaKit.brand, + BASIS_POINTS, + ); + const poolFeeRatio = makeRatio(POOL_FEE, moolaKit.brand, BASIS_POINTS); + + const result = pricesForStatedInput( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ); + + const newSwapResult = { + amountIn: bucks(10000n), + amountOut: moola(15640n), + protocolFee: moola(9n), + }; + + // same + t.deepEqual(result.swapperGives, newSwapResult.amountIn); + // SwapperGets one less: 15639n + // t.deepEqual(result.swapperGets, newSwapResult.amountOut); + // Swapper pays one more: 10n + // t.deepEqual(result.protocolFee, newSwapResult.protocolFee); +}); + +test('pricesForStatedInput README example', async t => { + const { moola, bucks, moolaKit, bucksKit } = setupMints(); + const poolAllocation = { + Central: moola(40_000_000n), + Secondary: bucks(3_000_000n), + }; + const amountGiven = moola(30_000n); + const amountWanted = bucks(2_000n); + + const protocolFeeRatio = makeRatio(5n, moolaKit.brand, BASIS_POINTS); + const poolFeeRatio = makeRatio(25n, bucksKit.brand, BASIS_POINTS); + + const noFeesResult = swapInNoFees({ amountGiven, poolAllocation }); + t.deepEqual(noFeesResult.amountIn, moola(29996n)); + t.deepEqual(noFeesResult.amountOut, bucks(2248n)); + + const noReductionResult = calcDeltaYSellingX( + poolAllocation.Central, + poolAllocation.Secondary, + amountGiven, + ); + t.deepEqual(noReductionResult, bucks(2248n)); + + const reduced = calcDeltaXSellingX( + poolAllocation.Central, + poolAllocation.Secondary, + noReductionResult, + ); + t.deepEqual(reduced, moola(29996n)); + + const result = pricesForStatedInput( + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ); + t.deepEqual(result.swapperGives, moola(29998n)); + t.deepEqual(result.swapperGets, bucks(2241n)); + t.deepEqual(result.protocolFee, moola(15n)); + t.deepEqual(result.poolFee, bucks(6n)); +}); diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/test-getXY.js b/packages/zoe/test/unitTests/contracts/constantProduct/test-getXY.js new file mode 100644 index 00000000000..33a630c46ed --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/test-getXY.js @@ -0,0 +1,133 @@ +// @ts-check + +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { getXY } from '../../../../src/contracts/constantProduct/getXY.js'; +import { setupMintKits } from './setupMints.js'; + +test('swap Central for Secondary', t => { + const { run, bld } = setupMintKits(); + + const amountGiven = run(2000n); + const poolAllocation = { + Central: run(102902920n), + Secondary: bld(203838393n), + }; + const amountWanted = bld(2819n); + const { x, y, deltaX, deltaY } = getXY({ + amountGiven, + poolAllocation, + amountWanted, + }); + + t.deepEqual(x, poolAllocation.Central); + t.deepEqual(y, poolAllocation.Secondary); + t.deepEqual(deltaX, amountGiven); + t.deepEqual(deltaY, amountWanted); +}); + +test('swap Central for Secondary no Give', t => { + const { run, bld } = setupMintKits(); + + const amountGiven = undefined; + const poolAllocation = { + Central: run(102902920n), + Secondary: bld(203838393n), + }; + const amountWanted = bld(2819n); + const { x, y, deltaX, deltaY } = getXY({ + amountGiven, + poolAllocation, + amountWanted, + }); + + t.deepEqual(x, poolAllocation.Central); + t.deepEqual(y, poolAllocation.Secondary); + t.deepEqual(deltaX, amountGiven); + t.deepEqual(deltaY, amountWanted); +}); + +test('swap Central for Secondary no want', t => { + const { run, bld } = setupMintKits(); + + const amountGiven = run(3000); + const poolAllocation = { + Central: run(102902920n), + Secondary: bld(203838393n), + }; + const amountWanted = undefined; + const { x, y, deltaX, deltaY } = getXY({ + amountGiven, + poolAllocation, + amountWanted, + }); + + t.deepEqual(x, poolAllocation.Central); + t.deepEqual(y, poolAllocation.Secondary); + t.deepEqual(deltaX, amountGiven); + t.deepEqual(deltaY, amountWanted); +}); + +test('swap Secondary for Central', t => { + const { run, bld } = setupMintKits(); + + const amountGiven = bld(2000n); + const poolAllocation = { + Central: run(102902920n), + Secondary: bld(203838393n), + }; + const amountWanted = run(2819n); + const { x, y, deltaX, deltaY } = getXY({ + amountGiven, + poolAllocation, + amountWanted, + }); + + t.deepEqual(x, poolAllocation.Secondary); + t.deepEqual(y, poolAllocation.Central); + t.deepEqual(deltaX, amountGiven); + t.deepEqual(deltaY, amountWanted); +}); + +test('swap Secondary for Central no want', t => { + const { run, bld } = setupMintKits(); + + const amountGiven = bld(2000n); + const poolAllocation = { + Central: run(102902920n), + Secondary: bld(203838393n), + }; + const amountWanted = undefined; + const { x, y, deltaX, deltaY } = getXY({ + amountGiven, + poolAllocation, + amountWanted, + }); + + t.deepEqual(x, poolAllocation.Secondary); + t.deepEqual(y, poolAllocation.Central); + t.deepEqual(deltaX, amountGiven); + t.deepEqual(deltaY, amountWanted); +}); + +test('swap Secondary for Central no give', t => { + const { run, bld } = setupMintKits(); + + const amountGiven = undefined; + const poolAllocation = { + Central: run(102902920n), + Secondary: bld(203838393n), + }; + const amountWanted = run(9342193); + const { x, y, deltaX, deltaY } = getXY({ + amountGiven, + poolAllocation, + amountWanted, + }); + + t.deepEqual(x, poolAllocation.Secondary); + t.deepEqual(y, poolAllocation.Central); + t.deepEqual(deltaX, amountGiven); + t.deepEqual(deltaY, amountWanted); +}); diff --git a/packages/zoe/test/unitTests/contracts/constantProduct/test-swapScenarios.js b/packages/zoe/test/unitTests/contracts/constantProduct/test-swapScenarios.js new file mode 100644 index 00000000000..9cf082cad28 --- /dev/null +++ b/packages/zoe/test/unitTests/contracts/constantProduct/test-swapScenarios.js @@ -0,0 +1,177 @@ +// @ts-check +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { + BASIS_POINTS, + DEFAULT_PROTOCOL_FEE, + DEFAULT_POOL_FEE, +} from '../../../../src/contracts/constantProduct/defaults.js'; +import { setupMintKits } from './setupMints.js'; +import { + makeRatio, + natSafeMath, +} from '../../../../src/contractSupport/index.js'; +import { pricesForStatedInput } from '../../../../src/contracts/constantProduct/calcSwapPrices.js'; + +const { multiply, ceilDivide } = natSafeMath; + +// This assumes run is swapped in. The test should function the same +// regardless of what brand is the amountIn, because no run fee is +// charged. +const prepareSwapInTest = ({ + inputPool, + outputPool, + inputValue, + outputValue, +}) => { + const { run, bld, runKit, bldKit } = setupMintKits(); + const amountGiven = run(inputValue); + const poolAllocation = harden({ + Central: run(inputPool), + Secondary: bld(outputPool), + }); + const amountWanted = bld(outputValue); + const protocolFeeRatio = makeRatio( + DEFAULT_PROTOCOL_FEE, + runKit.brand, + BASIS_POINTS, + ); + const poolFeeRatio = makeRatio(DEFAULT_POOL_FEE, bldKit.brand, BASIS_POINTS); + + const args = [ + amountGiven, + poolAllocation, + amountWanted, + protocolFeeRatio, + poolFeeRatio, + ]; + + return { args, bld, run }; +}; + +const testGetPrice = (t, inputs, expectedOutput) => { + const { args, run, bld } = prepareSwapInTest(inputs); + const result = pricesForStatedInput(...args); + const expected = harden({ + protocolFee: run(expectedOutput.protocolFee), + poolFee: bld(expectedOutput.poolFee), + swapperGives: run(expectedOutput.swapperGives), + swapperGets: bld(expectedOutput.swapperGets), + xIncrement: run(expectedOutput.xIncrement), + yDecrement: bld(expectedOutput.yDecrement), + newX: run(expectedOutput.newX), + newY: bld(expectedOutput.newY), + }); + t.deepEqual(result, expected); +}; + +// This uses the values that provoked a bug in newSwap. +test('getInputPrice newSwap bug scenario', t => { + const input = { + inputPool: 50825056949339n, + outputPool: 2196247730468n, + inputValue: 73000000n, + outputValue: 100n, + }; + + const firstDeltaY = + (input.outputPool * input.inputValue) / + (input.inputPool + input.inputValue); + const firstImprovedDeltaX = + (input.inputPool * firstDeltaY) / (input.outputPool - firstDeltaY); + const poolFee = ceilDivide( + multiply(DEFAULT_POOL_FEE, firstDeltaY), + BASIS_POINTS, + ); + const protocolFee = ceilDivide( + multiply(DEFAULT_PROTOCOL_FEE, firstImprovedDeltaX), + BASIS_POINTS, + ); + + const secondDeltaY = + (input.outputPool * (input.inputValue - protocolFee)) / + (input.inputPool + (input.inputValue - protocolFee)); + const secondImprovedDeltaX = + (input.inputPool * secondDeltaY) / (input.outputPool - secondDeltaY); + const yDecrement = secondDeltaY - poolFee; + const improvement = 3n; + const xIncrement = input.inputValue - protocolFee - improvement; + t.is(secondImprovedDeltaX + 1n, xIncrement); + const expectedOutput = harden({ + poolFee, + protocolFee, + swapperGives: input.inputValue - improvement, + swapperGets: yDecrement, + xIncrement, + yDecrement, + newX: input.inputPool + xIncrement, + newY: input.outputPool - yDecrement, + }); + testGetPrice(t, input, expectedOutput); +}); + +test('getInputPrice xy=k example', t => { + const input = { + inputPool: 40000n, + outputPool: 3000n, + inputValue: 300n, + outputValue: 20n, + }; + + const poolFee = 1n; + const protocolFee = 1n; + + const secondDeltaY = + (input.outputPool * (input.inputValue - protocolFee)) / + (input.inputPool + (input.inputValue - protocolFee)); + const secondImprovedDeltaX = + (input.inputPool * secondDeltaY) / (input.outputPool - secondDeltaY); + const yDecrement = secondDeltaY - poolFee; + const improvement = 3n; + const xIncrement = input.inputValue - protocolFee - improvement; + t.is(secondImprovedDeltaX + 1n, xIncrement); + const expectedOutput = harden({ + poolFee, + protocolFee, + swapperGives: input.inputValue - improvement, + swapperGets: yDecrement, + xIncrement, + yDecrement, + newX: input.inputPool + xIncrement, + newY: input.outputPool - yDecrement, + }); + testGetPrice(t, input, expectedOutput); +}); + +test('getInputPrice xy=k bigger numbers', t => { + const input = { + inputPool: 40000000n, + outputPool: 3000000n, + inputValue: 30000n, + outputValue: 2000n, + }; + + const poolFee = 6n; + const protocolFee = 18n; + + const secondDeltaY = + (input.outputPool * (input.inputValue - protocolFee)) / + (input.inputPool + (input.inputValue - protocolFee)); + const secondImprovedDeltaX = + (input.inputPool * secondDeltaY) / (input.outputPool - secondDeltaY) + 1n; + const yDecrement = secondDeltaY - poolFee; + const improvement = 12n; + const xIncrement = input.inputValue - protocolFee - improvement; + t.is(secondImprovedDeltaX, xIncrement); + const expectedOutput = harden({ + poolFee, + protocolFee, + swapperGives: input.inputValue - improvement, + swapperGets: yDecrement, + xIncrement, + yDecrement, + newX: input.inputPool + xIncrement, + newY: input.outputPool - yDecrement, + }); + testGetPrice(t, input, expectedOutput); +});