Skip to content

Commit 0632425

Browse files
Add Fees (#1764)
* fee update * add fee onto delivery fee * fee refactor * remove dynamic imports * removed useless imports * fee refactor * better summaries * breakdowns * better breakdowns * update l2s * removing older fee fields that are unused * remove older fields * review fixes * add back mock fees * check reserves * fixes * fixes for v2 from parachain * more balance checks * remove hydration from v2 chains
1 parent 749ef09 commit 0632425

67 files changed

Lines changed: 2096 additions & 518 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
type FeeBand = {
2+
lowerUsd: bigint
3+
upperUsd: bigint
4+
numerator: bigint
5+
denominator: bigint
6+
}
7+
8+
const MAX_USD = 999_999_999_999n
9+
10+
const FEE_SCHEDULE: FeeBand[] = [
11+
{ lowerUsd: 0n, upperUsd: 100n, numerator: 16n, denominator: 10_000n },
12+
{ lowerUsd: 100n, upperUsd: 1_000n, numerator: 14n, denominator: 10_000n },
13+
{ lowerUsd: 1_000n, upperUsd: 10_000n, numerator: 12n, denominator: 10_000n },
14+
{ lowerUsd: 10_000n, upperUsd: 100_000n, numerator: 10n, denominator: 10_000n },
15+
{ lowerUsd: 100_000n, upperUsd: 1_000_000n, numerator: 8n, denominator: 10_000n },
16+
{ lowerUsd: 1_000_000n, upperUsd: MAX_USD, numerator: 6n, denominator: 10_000n },
17+
]
18+
19+
export type VolumeFeeParams = {
20+
txValueUsd: bigint
21+
ethToUsdNumerator: bigint
22+
ethToUsdDenominator: bigint
23+
}
24+
25+
export function lookupFeeRatio(txValueUsd: bigint): { numerator: bigint; denominator: bigint } {
26+
for (const band of FEE_SCHEDULE) {
27+
if (txValueUsd >= band.lowerUsd && txValueUsd < band.upperUsd) {
28+
return { numerator: band.numerator, denominator: band.denominator }
29+
}
30+
}
31+
return { numerator: 6n, denominator: 10_000n }
32+
}
33+
34+
export function calculateVolumeTipInWei(params: VolumeFeeParams): bigint {
35+
if (params.txValueUsd < 0n) throw new Error("txValueUsd must be >= 0")
36+
if (params.ethToUsdNumerator <= 0n || params.ethToUsdDenominator <= 0n) {
37+
throw new Error("ethToUsdNumerator and ethToUsdDenominator must be > 0")
38+
}
39+
const { numerator: feeNum, denominator: feeDen } = lookupFeeRatio(params.txValueUsd)
40+
const WEI = 1_000_000_000_000_000_000n
41+
return (
42+
(params.txValueUsd * feeNum * WEI * params.ethToUsdDenominator) /
43+
(feeDen * params.ethToUsdNumerator)
44+
)
45+
}

web/packages/api/src/fees.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { DeliveryFee, FeeAsset, FeeItem } from "./types/fee"
2+
3+
export function addBreakdown<K extends string>(
4+
breakdown: DeliveryFee<K>["breakdown"],
5+
key: K,
6+
asset: FeeAsset,
7+
): void {
8+
if (asset.amount === 0n) return
9+
const bucket = (breakdown as Record<string, FeeAsset[]>)[key] ?? []
10+
;(breakdown as Record<string, FeeAsset[]>)[key] = bucket
11+
bucket.push(asset)
12+
}
13+
14+
export function computeTotals(summary: FeeItem[]): FeeAsset[] {
15+
const totals = new Map<string, bigint>()
16+
for (const item of summary) {
17+
totals.set(item.symbol, (totals.get(item.symbol) ?? 0n) + item.amount)
18+
}
19+
return [...totals.entries()].map(([symbol, amount]) => ({ symbol, amount }))
20+
}
21+
22+
export function findInBreakdown<K extends string>(
23+
breakdown: DeliveryFee<K>["breakdown"],
24+
key: K,
25+
symbol: string,
26+
): bigint {
27+
const entries = ((breakdown as Record<string, FeeAsset[]>)[key] ?? []).filter(
28+
(a) => a.symbol === symbol,
29+
)
30+
if (entries.length === 0) {
31+
throw new Error(`Fee breakdown missing entry: key="${key}" symbol="${symbol}"`)
32+
}
33+
return entries.reduce((acc, a) => acc + a.amount, 0n)
34+
}
35+
36+
export function findInBreakdownOrZero<K extends string>(
37+
breakdown: DeliveryFee<K>["breakdown"],
38+
key: K,
39+
symbol: string,
40+
): bigint {
41+
return ((breakdown as Record<string, FeeAsset[]>)[key] ?? [])
42+
.filter((a) => a.symbol === symbol)
43+
.reduce((acc, a) => acc + a.amount, 0n)
44+
}
45+
46+
export function findTotal<K extends string>(fee: DeliveryFee<K>, symbol: string): bigint {
47+
const total = fee.totals.find((t) => t.symbol === symbol)
48+
if (total === undefined) {
49+
throw new Error(`Fee totals missing symbol: "${symbol}"`)
50+
}
51+
return total.amount
52+
}
53+
54+
export function findTotalOrUndefined<K extends string>(
55+
fee: DeliveryFee<K>,
56+
symbol: string,
57+
): bigint | undefined {
58+
return fee.totals.find((t) => t.symbol === symbol)?.amount
59+
}
60+
61+
export function findTotalOrZero<K extends string>(
62+
fee: DeliveryFee<K>,
63+
symbol: string,
64+
): bigint {
65+
return fee.totals.find((t) => t.symbol === symbol)?.amount ?? 0n
66+
}

web/packages/api/src/forInterParachain.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
TransferRoute,
2020
} from "@snowbridge/base-types"
2121
import { ensureValidationSuccess, padFeeByPercentage } from "./utils"
22+
import { addBreakdown, computeTotals, findTotal } from "./fees"
2223
import { resolveBeneficiary } from "./crypto"
2324
import { Context } from "."
2425
import { buildMessageId } from "./toEthereum_v2"
@@ -152,11 +153,19 @@ export class InterParachainTransfer<T extends EthereumProviderTypes>
152153
options?.padFeeByPercentage ?? 33n,
153154
)
154155

156+
const totalFeeInDot = deliveryFee + executionFee
157+
158+
const breakdown: DeliveryFee["breakdown"] = {}
159+
addBreakdown(breakdown, "assetHubDelivery", { amount: deliveryFee, symbol: "DOT" })
160+
addBreakdown(breakdown, "destinationExecution", { amount: executionFee, symbol: "DOT" })
161+
162+
const summary = [{ description: "Transfer fee", amount: totalFeeInDot, symbol: "DOT" }]
163+
155164
return {
156165
kind: "polkadot->polkadot",
157-
deliveryFee,
158-
executionFee,
159-
totalFeeInDot: deliveryFee + executionFee,
166+
breakdown,
167+
summary,
168+
totals: computeTotals(summary),
160169
}
161170
}
162171

@@ -197,7 +206,7 @@ export class InterParachainTransfer<T extends EthereumProviderTypes>
197206
beneficiaryAccount,
198207
messageId,
199208
amount,
200-
fee.totalFeeInDot,
209+
findTotal(fee, "DOT"),
201210
source.parachainId === this.info.registry.assetHubParaId
202211
? "LocalReserve"
203212
: "DestinationReserve",

web/packages/api/src/forKusama.ts

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import {
3535
import { CallDryRunEffects, XcmDryRunApiError, XcmDryRunEffects } from "@polkadot/types/interfaces"
3636
import { Result } from "@polkadot/types"
3737
import { ensureValidationSuccess, padFeeByPercentage, u32ToLeBytes } from "./utils"
38+
import { addBreakdown, computeTotals, findInBreakdown, findInBreakdownOrZero, findTotal } from "./fees"
39+
import {
40+
checkDotKsmPoolLiquidityForKusamaToPolkadot,
41+
checkKsmDotPoolLiquidityForPolkadotToKusama,
42+
} from "./poolReserves"
3843
import { resolveBeneficiary } from "./crypto"
3944
import { TransferInterface as KusamaTransferInterface } from "./transfers/forKusama/transferInterface"
4045
import { Context } from "."
@@ -264,13 +269,33 @@ export class KusamaTransfer<T extends EthereumProviderTypes> implements KusamaTr
264269
totalXcmBridgeFee = padFeeByPercentage(totalXcmBridgeFee, 33n)
265270

266271
let totalFee = totalXcmBridgeFee + bridgeHubDeliveryFee + destinationFee
272+
const sourceSymbol = this.#direction() === Direction.ToPolkadot ? "KSM" : "DOT"
273+
// destNativeSymbol is the asset coming OUT of the destination AH swap
274+
// (DOT for kusama→polkadot, KSM for polkadot→kusama).
275+
const destNativeSymbol = this.#direction() === Direction.ToPolkadot ? "DOT" : "KSM"
276+
277+
const breakdown: DeliveryFee["breakdown"] = {}
278+
addBreakdown(breakdown, "xcmBridge", { amount: totalXcmBridgeFee, symbol: sourceSymbol })
279+
addBreakdown(breakdown, "bridgeHubDelivery", {
280+
amount: bridgeHubDeliveryFee,
281+
symbol: sourceSymbol,
282+
})
283+
addBreakdown(breakdown, "destinationExecution", {
284+
amount: destinationFee,
285+
symbol: sourceSymbol,
286+
})
287+
addBreakdown(breakdown, "destinationExecution", {
288+
amount: destinationFeeInDestNative,
289+
symbol: destNativeSymbol,
290+
})
291+
292+
const summary = [{ description: "Bridge fee", amount: totalFee, symbol: sourceSymbol }]
267293

268294
return {
269295
kind: this.from.kind === "kusama" ? "kusama->polkadot" : "polkadot->kusama",
270-
xcmBridgeFee: totalXcmBridgeFee,
271-
destinationFee,
272-
bridgeHubDeliveryFee,
273-
totalFeeInNative: totalFee,
296+
breakdown,
297+
summary,
298+
totals: computeTotals(summary),
274299
}
275300
}
276301

@@ -327,7 +352,11 @@ export class KusamaTransfer<T extends EthereumProviderTypes> implements KusamaTr
327352
tokenLocationOnSource,
328353
beneficiaryAddressHex,
329354
amount,
330-
fee.destinationFee,
355+
findInBreakdown(
356+
fee.breakdown,
357+
"destinationExecution",
358+
fee.kind === "kusama->polkadot" ? "KSM" : "DOT",
359+
),
331360
messageId,
332361
)
333362
} else {
@@ -337,7 +366,11 @@ export class KusamaTransfer<T extends EthereumProviderTypes> implements KusamaTr
337366
tokenLocationOnSource,
338367
beneficiaryAddressHex,
339368
amount,
340-
fee.destinationFee,
369+
findInBreakdown(
370+
fee.breakdown,
371+
"destinationExecution",
372+
fee.kind === "kusama->polkadot" ? "KSM" : "DOT",
373+
),
341374
messageId,
342375
)
343376
}
@@ -440,7 +473,10 @@ export class KusamaTransfer<T extends EthereumProviderTypes> implements KusamaTr
440473
const paymentInfo = await tx.paymentInfo(sourceAccountHex)
441474
const sourceExecutionFee = paymentInfo["partialFee"].toBigInt()
442475

443-
if (sourceExecutionFee + fee.totalFeeInNative > nativeBalance) {
476+
if (
477+
sourceExecutionFee + findTotal(fee, fee.kind === "kusama->polkadot" ? "KSM" : "DOT") >
478+
nativeBalance
479+
) {
444480
logs.push({
445481
kind: ValidationKind.Error,
446482
reason: ValidationReason.InsufficientFee,
@@ -455,7 +491,11 @@ export class KusamaTransfer<T extends EthereumProviderTypes> implements KusamaTr
455491
if (this.#direction() == Direction.ToPolkadot) {
456492
destAssetHubXCM = buildKusamaToPolkadotDestAssetHubXCM(
457493
destAssetHub.registry,
458-
fee.destinationFee,
494+
findInBreakdown(
495+
fee.breakdown,
496+
"destinationExecution",
497+
fee.kind === "kusama->polkadot" ? "KSM" : "DOT",
498+
),
459499
registry.assetHubParaId,
460500
tokenLocation,
461501
transfer.input.amount,
@@ -465,7 +505,11 @@ export class KusamaTransfer<T extends EthereumProviderTypes> implements KusamaTr
465505
} else {
466506
destAssetHubXCM = buildPolkadotToKusamaDestAssetHubXCM(
467507
destAssetHub.registry,
468-
fee.destinationFee,
508+
findInBreakdown(
509+
fee.breakdown,
510+
"destinationExecution",
511+
fee.kind === "kusama->polkadot" ? "KSM" : "DOT",
512+
),
469513
registry.assetHubParaId,
470514
tokenLocation,
471515
transfer.input.amount,
@@ -512,6 +556,53 @@ export class KusamaTransfer<T extends EthereumProviderTypes> implements KusamaTr
512556
}
513557
}
514558

559+
const destAssetHubImpl = await this.context.paraImplementation(destAssetHub)
560+
if (this.#direction() === Direction.ToPolkadot) {
561+
const requiredDotOut = findInBreakdownOrZero(
562+
fee.breakdown,
563+
"destinationExecution",
564+
"DOT",
565+
)
566+
if (requiredDotOut > 0n) {
567+
const reserveCheck = await checkDotKsmPoolLiquidityForKusamaToPolkadot(
568+
destAssetHubImpl,
569+
requiredDotOut,
570+
)
571+
if (!reserveCheck.ok) {
572+
logs.push({
573+
kind: ValidationKind.Error,
574+
reason: ValidationReason.InsufficientPoolReserves,
575+
message:
576+
reserveCheck.reason === "pool-missing"
577+
? `${reserveCheck.pool} pool does not exist on Asset Hub.`
578+
: `${reserveCheck.pool} pool on Asset Hub has insufficient liquidity (need ${reserveCheck.requiredOut}, have ${reserveCheck.reserveOut}).`,
579+
})
580+
}
581+
}
582+
} else {
583+
const requiredKsmOut = findInBreakdownOrZero(
584+
fee.breakdown,
585+
"destinationExecution",
586+
"KSM",
587+
)
588+
if (requiredKsmOut > 0n) {
589+
const reserveCheck = await checkKsmDotPoolLiquidityForPolkadotToKusama(
590+
destAssetHubImpl,
591+
requiredKsmOut,
592+
)
593+
if (!reserveCheck.ok) {
594+
logs.push({
595+
kind: ValidationKind.Error,
596+
reason: ValidationReason.InsufficientPoolReserves,
597+
message:
598+
reserveCheck.reason === "pool-missing"
599+
? `${reserveCheck.pool} pool does not exist on Asset Hub.`
600+
: `${reserveCheck.pool} pool on Asset Hub has insufficient liquidity (need ${reserveCheck.requiredOut}, have ${reserveCheck.reserveOut}).`,
601+
})
602+
}
603+
}
604+
}
605+
515606
const success = logs.find((l) => l.kind === ValidationKind.Error) === undefined
516607

517608
return {

web/packages/api/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ export * as toEthereumV2 from "./types/toEthereum"
5858
export * as toEthereumFromEVMV2 from "./types/toEthereumEvm"
5959
export * as forInterParachain from "./types/forInterParachain"
6060
export * as forKusama from "./types/forKusama"
61+
export type { VolumeFeeParams } from "./feeSchedule"
62+
export type {
63+
FeeAsset,
64+
FeeItem,
65+
ToPolkadotFeeKey,
66+
L2ToPolkadotFeeKey,
67+
ToEthereumFeeKey,
68+
InterParachainFeeKey,
69+
KusamaFeeKey,
70+
V1ToPolkadotFeeKey,
71+
} from "./types/fee"
6172
export * as utils from "./utils"
6273
export * as status from "./status"
6374
export * as assetsV2 from "./assets_v2"

web/packages/api/src/parachains/acala.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,11 @@ export class AcalaParachain extends ParachainBase {
7777
): Promise<bigint> {
7878
throw Error(`${this.specName} does not support.`)
7979
}
80+
81+
getAssetHubPoolReserves(
82+
_asset1: any,
83+
_asset2: any,
84+
): Promise<{ reserve1: bigint; reserve2: bigint } | null> {
85+
return Promise.resolve(null)
86+
}
8087
}

web/packages/api/src/parachains/assethub.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AssetMap, PNAMap } from "@snowbridge/base-types"
1+
import { AssetMap, FeeEstimateError, PNAMap } from "@snowbridge/base-types"
22
import { ParachainBase } from "./parachainBase"
33
import { getTokenFromLocation, ROCOCO_GENESIS, WESTEND_GENESIS } from "../xcmBuilder"
44
import { DOT_LOCATION } from "../assets_v2"
@@ -197,11 +197,7 @@ export class AssetHubParachain extends ParachainBase {
197197
)
198198
const asset2Balance = result.toPrimitive() as any
199199
if (asset2Balance == null) {
200-
throw Error(
201-
`No pool set up in asset conversion pallet for '${JSON.stringify(
202-
asset1,
203-
)}' and '${JSON.stringify(asset2)}'.`,
204-
)
200+
throw FeeEstimateError.couldNotSwap(asset1, asset2)
205201
}
206202
return BigInt(asset2Balance)
207203
}
@@ -219,14 +215,20 @@ export class AssetHubParachain extends ParachainBase {
219215
)
220216
const asset1Balance = result.toPrimitive() as any
221217
if (asset1Balance == null) {
222-
throw Error(
223-
`No pool set up in asset conversion pallet for '${JSON.stringify(
224-
asset1,
225-
)}' and '${JSON.stringify(asset2)}'.`,
226-
)
218+
throw FeeEstimateError.couldNotSwap(asset1, asset2)
227219
}
228220
return BigInt(asset1Balance)
229221
}
222+
223+
async getAssetHubPoolReserves(
224+
asset1: any,
225+
asset2: any,
226+
): Promise<{ reserve1: bigint; reserve2: bigint } | null> {
227+
const result = await this.provider.call.assetConversionApi.getReserves(asset1, asset2)
228+
const reserves = result.toPrimitive() as [string | number, string | number] | null
229+
if (reserves == null) return null
230+
return { reserve1: BigInt(reserves[0]), reserve2: BigInt(reserves[1]) }
231+
}
230232
}
231233

232234
// Currently, the bridgeable assets are limited to KSM, DOT, native assets on AH

0 commit comments

Comments
 (0)