UNPKG

raiden-ts

Version:

Raiden Light Client Typescript/Javascript SDK

362 lines 16 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import { BigNumber } from '@ethersproject/bignumber'; import { AddressZero, Zero } from '@ethersproject/constants'; import { Decimal as RootDecimal } from 'decimal.js'; import * as t from 'io-ts'; import { channelAmounts } from '../../channels/utils'; import { Fee } from '../../services/types'; import { assert } from '../../utils'; import { Address, decode, isntNil } from '../../utils/types'; const Decimal = RootDecimal.clone({ precision: 40, rounding: RootDecimal.ROUND_HALF_EVEN }); const ZeroDec = new Decimal(0); const OneDec = new Decimal(1); /** * Parses a number and throws if it isn't a number or isn't an integer * * @param value - Something which can be parsed as a number * @returns integer */ function toInteger(value) { const parsed = parseInt(value); assert(parsed !== undefined && parsed == value); return parsed; } /** * Creates a FeeFunc which can calculate the optimal fee based on the output capacity and input * and output fee functions * * @param outCapacity - Oown capacity of output channel * @param feeFuncs - Functions tuple * @param feeFuncs."0" - Input channel's fee function * @param feeFuncs."1" - Output channel's fee function * @returns FeeFunction which calculates best fee from input amounts */ function makeFeeFunctionFromAmountIn(outCapacity, [inFeeFunc, outFeeFunc]) { assert(outCapacity.gt(0), 'no output channel capacity available'); return (amountIn_) => { const amountIn = new Decimal(amountIn_.toHexString()); const feeIn = inFeeFunc(amountIn); // https://raiden-network-specification.readthedocs.io/en/latest/mediation_fees.html#fee-calculation const func = (amountOut) => feeIn.add(outFeeFunc(amountOut.neg())).sub(amountIn).add(amountOut); let xA = ZeroDec; let fA = func(xA); assert(fA.lt(0), 'input transfer not enough to pay minimum fees'); let xB = outCapacity; let fB = func(xB); assert(fB.gte(0), 'output capacity not enough to mediate transfer'); // find interval containing root of monotonic func while (true) { const x = xB.add(xA).div(2); const fX = func(x); const slopeAX = fX.sub(fA).div(x.sub(xA)); const slopeXB = fB.sub(fX).div(xB.sub(x)); // once slope of both stretches are close enough (because Δx→dx or because it's // a piecewise linear function), we break and calculate the linear root of segment if (slopeXB.sub(slopeAX).abs().lt(0.000001)) break; // else, we contine with the stretch which contains the root (y of opposite signals) else if (fA.isNegative() !== fX.isNegative()) { xB = x; fB = fX; } else { xA = x; fA = fX; } } // calculate x where line [(xA, fA), (xB, fB)] crosses y=0: // x₀ = xA - yA/Δ, Δ=(yB-yA)/(xB-xA) const amountOut = xA.sub(fA.mul(xB.sub(xA)).div(fB.sub(fA))); // return fee = amountIn - amountOut return decode(Fee, amountIn.sub(amountOut).toFixed(0, Decimal.ROUND_HALF_EVEN)); }; } export const flatFee = { name: 'flat', emptySchedule: { flat: Zero }, decodeConfig(config, defaultConfig) { // flat config uses 'half' the per-token config, one half for each channel [in, out] return decode(Fee, config ?? defaultConfig).div(2); }, channelFee(flat) { const flatFee = new Decimal(flat.toHexString()); return () => flatFee; }, fee(flat) { // flat fee just sums once for each channel const res = decode(Fee, flat.mul(2)); return () => res; }, schedule(flat) { return { flat }; }, }; export const proportionalFee = { name: 'proportional', emptySchedule: { proportional: Zero }, decodeConfig(config, defaultConfig) { // https://raiden-network-specification.readthedocs.io/en/latest/mediation_fees.html#converting-per-hop-proportional-fees-in-per-channel-proportional-fees // 1M is because config is received and returned as parts-per-million integers const perHopRatio = new Decimal(toInteger(config ?? defaultConfig)).div(1e6); const perChannelPPM = perHopRatio.div(perHopRatio.add(2)).mul(1e6); return perChannelPPM.toDecimalPlaces(0, Decimal.ROUND_HALF_EVEN).toNumber(); }, channelFee(perChannelPPM) { const perChannelRatio = new Decimal(perChannelPPM).div(1e6); return (amount) => amount.mul(perChannelRatio).abs(); }, fee(perChannelPPM) { const perChannelRatio = new Decimal(perChannelPPM).div(1e6); return (amountIn_) => { const amountIn = new Decimal(amountIn_.toHexString()); // for proportional fees only: xout = xin*(1-q)/(1+q) const amountOut = amountIn.mul(OneDec.sub(perChannelRatio).div(OneDec.add(perChannelRatio))); const fee = amountIn.sub(amountOut); return decode(Fee, fee.toFixed(0, Decimal.ROUND_HALF_EVEN)); }; }, schedule(perChannelPPM) { return { proportional: BigNumber.from(perChannelPPM) }; }, }; /** * Creates an array of [count] BNs, starting with [start], ending with [end], and * intermediary values being the left edges for stretches (splits) of length differing at most 1. * Count must be greater than or equal 2 (start and end), and must be smaller or equal range width * * @param start - Start value * @param end - End value * @param count - Values count, which will divide range into [count - 1] stretches * @returns Sorted array of BNs of length [count] */ function linspace(start, end, count) { const width = end.sub(start); assert(count >= 2 && width.gte(count - 1), 'invalid linspace params'); const ranges = count - 1; const step = new Decimal(width.toString()).div(ranges); const result = [start]; for (let i = 1; i < ranges; i++) { result.push(start.add(step.mul(i).toFixed(0, Decimal.ROUND_HALF_EVEN))); } return [...result, end]; } /** * Finds the rightmost index for which arr[i] <= x < arr[i+1] (<= arr[arr.length-1]) * Performs a binary search, with complexity O(log(N)) * It first estimates a "good bet" of index if the func is equally spaced, then offsets it back or * forth until x is in between range * * @param x - Point to find index for * @param func - Discrete func * @returns Found index */ function findRangeIndex(x, func) { assert(func.length >= 2, 'invalid linspace'); const x0 = func[0][0]; const xL = func[func.length - 1][0]; // x_last // special-case where x is exactly over xLast, make it part of last stretch instead of beyond it if (x.eq(xL.toHexString())) return func.length - 2; const width = new Decimal(xL.sub(x0).toHexString()); const step = width.div(func.length - 1); let index = Math.floor(x.sub(x0.toHexString()).div(step).toNumber()); let offs = 0; do { if (index < 0) return -1; else if (index >= func.length - 1) return func.length - 1; if (x.gte(func[index + 1][0].toHexString())) offs = -1; else if (x.lt(func[index][0].toHexString())) offs = 1; else offs = 0; index += offs; } while (offs); return index; } function interpolate(x, [x0, y0], [x1, y1]) { // y = f(x) = (x-x0)*Δy/Δx + y0 return x .sub(x0.toHexString()) .mul(y1.sub(y0).toHexString()) .div(x1.sub(x0).toHexString()) .add(y0.toHexString()); } /** * Get the value of the piecewise linear curve described by the pairs in [arr] at point [x] * * @param x - Point where to get the value for * @param arr - function described as array of [x, y] pairs containing points linked by lines * @returns function value at point x */ function interpolateFunc(x, arr) { let index = findRangeIndex(x, arr); // in case index before|beyond arr limits, use first|last stretches if (index < 0) index = 0; else if (index >= arr.length - 1) index = arr.length - 2; return interpolate(x, arr[index], arr[index + 1]); } /** * Calculates pair of points discretizing a U-shaped curve which describes channel's imbalance fees * for a given total capacity and config. * * @param channelCapacity - Channel's total capacity (sum of deposits from both ends) * @param proportionalImbalanceFee - Fee config, in PPM (1% = 10,000) * @returns array of [x, y] pairs, where x is the old/new channel capacity and y is the fee */ function calculatePenaltyFunction(channelCapacity, proportionalImbalanceFee) { const NUM_DISCRETISATION_POINTS = 21; const MAX_SLOPE = new Decimal('0.1'); assert(channelCapacity.gt(0), 'not enough capacity'); const channelCapacityDec = new Decimal(channelCapacity.toHexString()); const numBasePoints = Decimal.min(NUM_DISCRETISATION_POINTS, channelCapacityDec.add(1)).toNumber(); const xValues = linspace(Zero, channelCapacity, numBasePoints); if (proportionalImbalanceFee === 0) { return [ [Zero, Zero], [channelCapacity, Zero], ]; } const proportionalImbalanceFeeDec = new Decimal(proportionalImbalanceFee).div(1e6); assert(proportionalImbalanceFeeDec.lte(MAX_SLOPE.div(2)), 'Too high imbalance fee'); const maxImbalanceFee = channelCapacityDec.mul(proportionalImbalanceFeeDec); const s = MAX_SLOPE; const c = maxImbalanceFee; const o = channelCapacityDec.div(2); const b = Decimal.min(10, s.mul(o).div(c)); const func = (x) => BigNumber.from(new Decimal(x.toHexString()) .sub(o) .abs() .pow(b) .mul(c) .div(o.pow(b)) .toFixed(0, Decimal.ROUND_HALF_EVEN)); // TS can't properly type the map to tuple, even if the input has the correct type return xValues.map((x) => [x, func(x)]); } export const imbalancePenaltyFee = { name: 'imbalance', emptySchedule: { imbalance_penalty: null }, decodeConfig(config, defaultConfig) { const imbalancePPM = toInteger(config ?? defaultConfig); assert(imbalancePPM >= 0 && imbalancePPM <= 50000, 'Too high imbalance fee'); return imbalancePPM; }, channelFee(imbalancePPM, channel) { const { totalCapacity, ownCapacity } = channelAmounts(channel); if (!imbalancePPM || !totalCapacity.gt(0)) return () => ZeroDec; const discreteFunc = calculatePenaltyFunction(totalCapacity, imbalancePPM); const ipAtCurCapacity = interpolateFunc(new Decimal(ownCapacity.toHexString()), discreteFunc); return (amount) => interpolateFunc(amount.add(ownCapacity.toHexString()), discreteFunc).sub(ipAtCurCapacity); }, fee(imbalancePPM, channelIn, channelOut) { const channelInFeeFunc = this.channelFee(imbalancePPM, channelIn); const channelOutFeeFunc = this.channelFee(imbalancePPM, channelOut); const outOwnCapacity = new Decimal(channelAmounts(channelOut).ownCapacity.toHexString()); return makeFeeFunctionFromAmountIn(outOwnCapacity, [channelInFeeFunc, channelOutFeeFunc]); }, schedule(imbalancePPM, channel) { const { totalCapacity } = channelAmounts(channel); if (!imbalancePPM || !totalCapacity.gt(0)) return { imbalance_penalty: null }; const discreteFunc = calculatePenaltyFunction(totalCapacity, imbalancePPM); return { imbalance_penalty: discreteFunc }; }, }; /** * Returns a standard mediation FeeModel which translates a per-token mapping of feeModels to a * model which validates each per-key config and calculates the fees sum * * Config is expected and decoded as being a indexed mapping object where keys are token addresses, * and values are objects containing optional config properties where keys are the models keys * passed as parameter and values are each FeeModel expected and validated config. * e.g.: * - models={ flat: flatFee<number> }; * - expectedConfig={ [token: Address]: { flat: number } } * * Additionally, if an [AddressZero] token config is present, it'll be used as fallback/default * config in case the requested token isn't set. * * @param models - Models dict where [key] is that model's config name on the per-token config obj * @returns Standard Fee calculator */ export function getStandardFeeCalculator(models) { const emptySchedule = Object.assign({ cap_fees: true }, ...Object.values(models).map((model) => model.emptySchedule)); const mapCodec = t.union([t.undefined, t.record(t.string, t.record(t.string, t.unknown))]); const standardCalculator = { name: 'standard', emptySchedule, decodeConfig(config, defaultConfig) { const tokenConfigMap = { ...decode(mapCodec, config), ...decode(mapCodec, defaultConfig), }; for (const [token_, config] of Object.entries(tokenConfigMap)) { const token = decode(Address, token_); const perTokenConfig = { cap: config['cap'] ?? true }; for (const [key_, model] of Object.entries(models)) { const key = key_; if (!(key in config)) continue; perTokenConfig[key] = model.decodeConfig(config[key_]); } tokenConfigMap[token] = perTokenConfig; } return tokenConfigMap; }, channelFee(config, channel) { const tokenAddr = channel.token; const perTokenConfig = config[tokenAddr] ?? config[AddressZero]; const channelFeeFuncs = Object.entries(models) .map(([key, model]) => { const modelConfig = perTokenConfig?.[key]; if (modelConfig) return model.channelFee(modelConfig, channel); }) .filter(isntNil); return (amount) => channelFeeFuncs.reduce((fee, func) => fee.add(func(amount)), ZeroDec); }, fee(config, channelIn, channelOut) { const channelInFeeFunc = this.channelFee(config, channelIn); const channelOutFeeFunc = this.channelFee(config, channelOut); const outOwnCapacity = new Decimal(channelAmounts(channelOut).ownCapacity.toHexString()); const capFees = (config[channelIn.token] ?? config[AddressZero])?.cap ?? emptySchedule.cap_fees; const feeFunction = makeFeeFunctionFromAmountIn(outOwnCapacity, [ channelInFeeFunc, channelOutFeeFunc, ]); return (amountIn) => { let fee = feeFunction(amountIn); if (capFees && fee.lt(0)) fee = Zero; // cap fee return fee; }; }, schedule(config, channel) { const tokenAddr = channel.token; const perTokenConfig = config[tokenAddr] ?? config[AddressZero]; return Object.assign({}, emptySchedule, perTokenConfig?.cap !== undefined ? { cap_fees: perTokenConfig.cap } : undefined, ...Object.entries(models).map(([key_, model]) => { const key = key_; const modelConfig = perTokenConfig?.[key]; if (!modelConfig) return {}; return model.schedule(modelConfig, channel); })); }, }; return standardCalculator; } export const standardCalculator = getStandardFeeCalculator({ flat: flatFee, proportional: proportionalFee, imbalance: imbalancePenaltyFee, }); // type StandardConfig = ConfigOf<typeof standardCalculator>; // type StandardPerTokenConfig = StandardConfig[string]; // type StandardSchedule = ScheduleOf<typeof standardCalculator>; //# sourceMappingURL=types.js.map