diff --git a/package.json b/package.json index a93d8f714..1efcb257e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@across-protocol/sdk-v2", "author": "UMA Team", - "version": "0.1.12", + "version": "0.1.13", "license": "AGPL-3.0", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/src/relayFeeCalculator/relayFeeCalculator.test.ts b/src/relayFeeCalculator/relayFeeCalculator.test.ts index 8d573793e..365382f78 100644 --- a/src/relayFeeCalculator/relayFeeCalculator.test.ts +++ b/src/relayFeeCalculator/relayFeeCalculator.test.ts @@ -1,10 +1,25 @@ import assert from "assert"; import dotenv from "dotenv"; import { RelayFeeCalculator, QueryInterface } from "./relayFeeCalculator"; -import { gasCost, BigNumberish } from "../utils"; +import { gasCost, BigNumberish, toBNWei } from "../utils"; dotenv.config({ path: ".env" }); +const testCapitalCostsConfig: { [token: string]: any } = { + WBTC: { + lowerBound: toBNWei("0.0003").toString(), + upperBound: toBNWei("0.002").toString(), + cutoff: toBNWei("15").toString(), + decimals: 8, + }, + DAI: { + lowerBound: toBNWei("0.0003").toString(), + upperBound: toBNWei("0.0015").toString(), + cutoff: toBNWei("500000").toString(), + decimals: 18, + }, +}; + // Example of how to write this query class class ExampleQueries implements QueryInterface { constructor(private defaultGas = "305572") {} @@ -20,12 +35,101 @@ class ExampleQueries implements QueryInterface { } describe("RelayFeeCalculator", () => { let client: RelayFeeCalculator; + let queries: ExampleQueries; beforeAll(() => { - const queries = new ExampleQueries(); - client = new RelayFeeCalculator({ queries }); + queries = new ExampleQueries(); }); it("relayerFeeDetails", async () => { + client = new RelayFeeCalculator({ queries }); const result = await client.relayerFeeDetails(100000000, "usdc"); assert.ok(result); }); + it("capitalFeePercent", async () => { + // Invalid capital cost configs throws on construction: + assert.throws( + () => + new RelayFeeCalculator({ + queries, + capitalCostsConfig: { + WBTC: { ...testCapitalCostsConfig["WBTC"], upperBound: toBNWei("0.01").toString() }, + }, + }), + /upper bound must be + RelayFeeCalculator.validateCapitalCostsConfig({ + ...testCapitalCostsConfig["WBTC"], + upperBound: toBNWei("0.01").toString(), + }), + /upper bound must be + new RelayFeeCalculator({ + queries, + capitalCostsConfig: { + WBTC: { + ...testCapitalCostsConfig["WBTC"], + upperBound: toBNWei("0.001").toString(), + lowerBound: toBNWei("0.002").toString(), + }, + }, + }), + /lower bound must be <= upper bound/ + ); + assert.throws( + () => + RelayFeeCalculator.validateCapitalCostsConfig({ + ...testCapitalCostsConfig["WBTC"], + upperBound: toBNWei("0.001").toString(), + lowerBound: toBNWei("0.002").toString(), + }), + /lower bound must be <= upper bound/ + ); + assert.throws( + () => + new RelayFeeCalculator({ + queries, + capitalCostsConfig: { WBTC: { ...testCapitalCostsConfig["WBTC"], decimals: 0 } }, + }), + /invalid decimals/ + ); + assert.throws( + () => RelayFeeCalculator.validateCapitalCostsConfig({ ...testCapitalCostsConfig["WBTC"], decimals: 0 }), + /invalid decimals/ + ); + assert.throws( + () => + new RelayFeeCalculator({ + queries, + capitalCostsConfig: { WBTC: { ...testCapitalCostsConfig["WBTC"], decimals: 19 } }, + }), + /invalid decimals/ + ); + assert.throws( + () => RelayFeeCalculator.validateCapitalCostsConfig({ ...testCapitalCostsConfig["WBTC"], decimals: 19 }), + /invalid decimals/ + ); + const client = new RelayFeeCalculator({ + queries, + capitalCostsConfig: testCapitalCostsConfig, + capitalCostsPercent: 0.01, + }); + + // If token doesn't have a config set, then returns default fixed fee %: + assert.equal((await client.capitalFeePercent(toBNWei("1"), "UNKNOWN")).toString(), toBNWei("0.0001").toString()); + + // Test with different decimals: + + // Amount near zero should charge slightly more than lower bound + assert.equal((await client.capitalFeePercent(toBNWei("0.001", 8), "WBTC")).toString(), "300056666666000"); + assert.equal((await client.capitalFeePercent(toBNWei("1"), "DAI")).toString(), "300001200000000"); + // Amount right below cutoff should charge slightly below 1/2 of (lower bound + upper bound) + assert.equal((await client.capitalFeePercent(toBNWei("14.999", 8), "WBTC")).toString(), "1149943333333330"); + assert.equal((await client.capitalFeePercent(toBNWei("499999"), "DAI")).toString(), "899998800000000"); + // Amount >>> than cutoff should charge slightly below upper bound + assert.equal((await client.capitalFeePercent(toBNWei("600", 8), "WBTC")).toString(), "1978749999999999"); + assert.equal((await client.capitalFeePercent(toBNWei("20000000"), "DAI")).toString(), "1485000000000000"); + }); }); diff --git a/src/relayFeeCalculator/relayFeeCalculator.ts b/src/relayFeeCalculator/relayFeeCalculator.ts index 0d2242f66..80bd7925e 100644 --- a/src/relayFeeCalculator/relayFeeCalculator.ts +++ b/src/relayFeeCalculator/relayFeeCalculator.ts @@ -1,7 +1,7 @@ import assert from "assert"; import * as uma from "@uma/sdk"; import { BigNumber } from "ethers"; -import { BigNumberish, toBNWei, nativeToToken } from "../utils"; +import { BigNumberish, toBNWei, nativeToToken, toBN, min, max } from "../utils"; const { percent, fixedPointAdjustment } = uma.across.utils; // This needs to be implemented for every chain and passed into RelayFeeCalculator @@ -11,12 +11,20 @@ export interface QueryInterface { getTokenDecimals: (tokenSymbol: string) => Promise; } +export const expectedCapitalCostsKeys = ["lowerBound", "upperBound", "cutoff", "decimals"]; +export interface CapitalCostConfig { + lowerBound: string; + upperBound: string; + cutoff: string; + decimals: number; +} export interface RelayFeeCalculatorConfig { nativeTokenDecimals?: number; gasDiscountPercent?: number; capitalDiscountPercent?: number; feeLimitPercent?: number; capitalCostsPercent?: number; + capitalCostsConfig?: { [token: string]: CapitalCostConfig }; queries: QueryInterface; } @@ -27,10 +35,11 @@ export class RelayFeeCalculator { private feeLimitPercent: Required["feeLimitPercent"]; private nativeTokenDecimals: Required["nativeTokenDecimals"]; private capitalCostsPercent: Required["capitalCostsPercent"]; + private capitalCostsConfig: Required["capitalCostsConfig"]; constructor(config: RelayFeeCalculatorConfig) { this.queries = config.queries; this.gasDiscountPercent = config.gasDiscountPercent || 0; - this.capitalDiscountPercent = config.capitalCostsPercent || 0; + this.capitalDiscountPercent = config.capitalDiscountPercent || 0; this.feeLimitPercent = config.feeLimitPercent || 0; this.nativeTokenDecimals = config.nativeTokenDecimals || 18; this.capitalCostsPercent = config.capitalCostsPercent || 0; @@ -50,7 +59,18 @@ export class RelayFeeCalculator { this.capitalCostsPercent >= 0 && this.capitalCostsPercent <= 100, "capitalCostsPercent must be between 0 and 100 percent" ); + this.capitalCostsConfig = config.capitalCostsConfig || {}; + for (const token of Object.keys(this.capitalCostsConfig)) { + RelayFeeCalculator.validateCapitalCostsConfig(this.capitalCostsConfig[token]); + } + } + + static validateCapitalCostsConfig(capitalCosts: CapitalCostConfig) { + assert(toBN(capitalCosts.upperBound).lt(toBNWei("0.01")), "upper bound must be < 1%"); + assert(toBN(capitalCosts.lowerBound).lte(capitalCosts.upperBound), "lower bound must be <= upper bound"); + assert(capitalCosts.decimals > 0 && capitalCosts.decimals <= 18, "invalid decimals"); } + async gasFeePercent(amountToRelay: BigNumberish, tokenSymbol: string): Promise { const [gasCosts, tokenPrice, decimals] = await Promise.all([ this.queries.getGasCosts(tokenSymbol), @@ -64,7 +84,39 @@ export class RelayFeeCalculator { // Note: these variables are unused now, but may be needed in future versions of this function that are more complex. // eslint-disable-next-line @typescript-eslint/no-unused-vars async capitalFeePercent(_amountToRelay: BigNumberish, _tokenSymbol: string): Promise { - return toBNWei(this.capitalCostsPercent / 100); + // V0: Charge fixed capital fee + const defaultFee = toBNWei(this.capitalCostsPercent / 100); + + // V1: Charge fee that scales with size. This will charge a fee % based on a linear fee curve with a "kink" at a + // cutoff in the same units as _amountToRelay. Before the kink, the fee % will increase linearly from a lower + // bound to an upper bound. After the kink, the fee % increase will be fixed, and slowly approach the upper bound + // for very large amount inputs. + if (this.capitalCostsConfig[_tokenSymbol]) { + const config = this.capitalCostsConfig[_tokenSymbol]; + // Scale amount "y" to 18 decimals + const y = toBN(_amountToRelay).mul(toBNWei("1", 18 - config.decimals)); + // At a minimum, the fee will be equal to lower bound fee * y + const minCharge = toBN(config.lowerBound).mul(y).div(fixedPointAdjustment); + + // Charge an increasing marginal fee % up to min(cutoff, y). If y is very close to the cutoff, the fee % + // will be equal to half the sum of (upper bound + lower bound). + const yTriangle = min(config.cutoff, y); + + // triangleSlope is slope of fee curve from lower bound to upper bound. + // triangleCharge is interval of curve from 0 to y for curve = triangleSlope * y + const triangleSlope = toBN(config.upperBound).sub(config.lowerBound).mul(fixedPointAdjustment).div(config.cutoff); + const triangleHeight = triangleSlope.mul(yTriangle).div(fixedPointAdjustment); + const triangleCharge = triangleHeight.mul(yTriangle).div(toBNWei(2)); + + // For any amounts above the cutoff, the marginal fee % will not increase but will be fixed at the upper bound + // value. + const yRemainder = max(toBN(0), y.sub(config.cutoff)); + const remainderCharge = yRemainder.mul(toBN(config.upperBound).sub(config.lowerBound)).div(fixedPointAdjustment); + + return minCharge.add(triangleCharge).add(remainderCharge).mul(fixedPointAdjustment).div(y); + } + + return defaultFee; } async relayerFeeDetails(amountToRelay: BigNumberish, tokenSymbol: string) { let isAmountTooLow = false;