From 0fe68296382a320be7462cadbf88a4ad0b4f98af Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Thu, 23 Jul 2026 12:31:25 +0200 Subject: [PATCH 1/7] P<>K sending: $1 service fee (both directions) + destination min-balance validation - forKusama.validate(): reject amount <= destination token minimum balance (ED guard) - $1 service fee deposited on Polkadot AH both directions: DOT via batchAll (p->k source), KSM skimmed in the PAH-dest customXcm (k->p); constants in forKusama.ts - faithful k->p dest dry-run reconstruction (models the fee skim) - transfer_for_kusama.ts: pin reliable Kusama-AH RPC for signAndSend --- web/packages/api/src/forKusama.ts | 84 +++++++++++++++++-- web/packages/api/src/types/fee.ts | 6 +- web/packages/api/src/types/forKusama.ts | 1 + web/packages/api/src/xcmBuilder.ts | 57 ++++++++++--- web/packages/api/src/xcmBuilderKusama.ts | 24 +++++- .../operations/src/transfer_for_kusama.ts | 31 +++++-- 6 files changed, 177 insertions(+), 26 deletions(-) diff --git a/web/packages/api/src/forKusama.ts b/web/packages/api/src/forKusama.ts index 52772b0a14..4476cc6322 100644 --- a/web/packages/api/src/forKusama.ts +++ b/web/packages/api/src/forKusama.ts @@ -35,7 +35,13 @@ import { import { CallDryRunEffects, XcmDryRunApiError, XcmDryRunEffects } from "@polkadot/types/interfaces" import { Result } from "@polkadot/types" import { ensureValidationSuccess, padFeeByPercentage, u32ToLeBytes } from "./utils" -import { addBreakdown, computeTotals, findInBreakdown, findInBreakdownOrZero, findTotal } from "./fees" +import { + addBreakdown, + computeTotals, + findInBreakdown, + findInBreakdownOrZero, + findTotal, +} from "./fees" import { checkDotKsmPoolLiquidityForKusamaToPolkadot, checkKsmDotPoolLiquidityForPolkadotToKusama, @@ -63,6 +69,21 @@ const KUSAMA_FEE_PER_BYTE = 1000000n // 0.000001 KSM const POLKADOT_BASE_FEE = 333_794_429n // 0.033 DOT const POLKADOT_FEE_PER_BYTE = 16666n // 0.0000016666 DOT +// Service fee (~$1) deposited on Polkadot AH for both directions, on top of the +// bridge/execution fees. TEST CONFIG: recipient is Clara's own account so the fee is +// claimable back during testing; replace for production. Denominated in the source +// chain's native asset (DOT for polkadot->kusama, KSM for kusama->polkadot), which is +// what each direction has in Polkadot-AH holding. Fixed native amounts for now, so the +// dollar value drifts with price (DOT ~$0.80, KSM ~$3.17 as of 2026-07-20). +const SERVICE_FEE_RECIPIENT = + "0x6cd8840ea69d18a2f1dde746df70629df118b870ec228367d6bcf3348ca3b10b" +const SERVICE_FEE_DOT = 12_400_000_000n // ~1.24 DOT (10 decimals) ~= $1 +const SERVICE_FEE_KSM = 315_000_000_000n // ~0.315 KSM (12 decimals) ~= $1 + +function serviceFeeAmount(direction: Direction): bigint { + return direction === Direction.ToPolkadot ? SERVICE_FEE_KSM : SERVICE_FEE_DOT +} + function resolveInputs( registry: AssetRegistry, tokenAddress: string, @@ -268,6 +289,9 @@ export class KusamaTransfer implements KusamaTr destinationFee = destinationFee + BigInt(minBalanceFeeDest) totalXcmBridgeFee = padFeeByPercentage(totalXcmBridgeFee, 33n) + // Service fee (~$1) charged in the source native asset, deposited on Polkadot AH. + const serviceFee = serviceFeeAmount(this.#direction()) + let totalFee = totalXcmBridgeFee + bridgeHubDeliveryFee + destinationFee const sourceSymbol = this.#direction() === Direction.ToPolkadot ? "KSM" : "DOT" // destNativeSymbol is the asset coming OUT of the destination AH swap @@ -288,8 +312,12 @@ export class KusamaTransfer implements KusamaTr amount: destinationFeeInDestNative, symbol: destNativeSymbol, }) + addBreakdown(breakdown, "serviceFee", { amount: serviceFee, symbol: sourceSymbol }) - const summary = [{ description: "Bridge fee", amount: totalFee, symbol: sourceSymbol }] + const summary = [ + { description: "Bridge fee", amount: totalFee, symbol: sourceSymbol }, + { description: "Service fee", amount: serviceFee, symbol: sourceSymbol }, + ] return { kind: this.from.kind === "kusama" ? "kusama->polkadot" : "polkadot->kusama", @@ -344,6 +372,7 @@ export class KusamaTransfer implements KusamaTr this.#direction(), tokenAddress, ) + const serviceFee = serviceFeeAmount(this.#direction()) let tx if (this.#direction() == Direction.ToPolkadot) { tx = createERC20ToPolkadotTx( @@ -357,6 +386,7 @@ export class KusamaTransfer implements KusamaTr "destinationExecution", fee.kind === "kusama->polkadot" ? "KSM" : "DOT", ), + serviceFee, messageId, ) } else { @@ -371,6 +401,7 @@ export class KusamaTransfer implements KusamaTr "destinationExecution", fee.kind === "kusama->polkadot" ? "KSM" : "DOT", ), + serviceFee, messageId, ) } @@ -419,6 +450,7 @@ export class KusamaTransfer implements KusamaTr sourceAccountHex, sourceParachain: _source, beneficiaryAddressHex, + sourceAssetMetadata, destAssetMetadata, } = transfer.computed const { tx } = transfer @@ -452,6 +484,26 @@ export class KusamaTransfer implements KusamaTr }) } + // The transferred token is deposited in full on the destination asset hub (the fee is paid + // separately in the native asset), so the amount must clear the token's minimum balance + // there or the deposit fails and the funds trap under the message origin. An amount exactly + // equal to the minimum balance also traps (observed with 0.01 USDC == its min balance), so + // require headroom above it. Mirror the other transfer impls (e.g. toKusama/erc20ToKusamaAH) + // and take the larger of the two sides' minimum balances so the check is correct regardless + // of direction. + const destTokenMinimumBalance = + sourceAssetMetadata.minimumBalance > destAssetMetadata.minimumBalance + ? sourceAssetMetadata.minimumBalance + : destAssetMetadata.minimumBalance + if (amount <= destTokenMinimumBalance) { + logs.push({ + kind: ValidationKind.Error, + reason: ValidationReason.MinimumAmountValidation, + message: + "The amount transferred is at or below the minimum balance of the token on the destination chain.", + }) + } + let assetHubDryRunError const dryRunSource = await dryRunSourceAssetHub( @@ -489,6 +541,8 @@ export class KusamaTransfer implements KusamaTr let destAssetHubXCM: any if (this.#direction() == Direction.ToPolkadot) { + // Model the service-fee skim so the dest dry-run is faithful to the real message + // (kusama->polkadot deposits the KSM service fee on Polkadot AH). destAssetHubXCM = buildKusamaToPolkadotDestAssetHubXCM( destAssetHub.registry, findInBreakdown( @@ -501,6 +555,8 @@ export class KusamaTransfer implements KusamaTr transfer.input.amount, transfer.computed.beneficiaryAddressHex, "0x0000000000000000000000000000000000000000000000000000000000000000", + serviceFeeAmount(this.#direction()), + SERVICE_FEE_RECIPIENT, ) } else { destAssetHubXCM = buildPolkadotToKusamaDestAssetHubXCM( @@ -680,6 +736,7 @@ function createERC20ToKusamaTx( beneficiaryAccount: string, amount: bigint, destFeeInSourceNative: bigint, + serviceFee: bigint, topic: string, ): SubmittableExtrinsic<"promise", ISubmittableResult> { let assets: any @@ -722,7 +779,7 @@ function createERC20ToKusamaTx( beneficiaryAccount, topic, ) - return parachain.tx.polkadotXcm.transferAssetsUsingTypeAndThen( + const transfer = parachain.tx.polkadotXcm.transferAssetsUsingTypeAndThen( destination, assets, reserveTypeAsset, @@ -731,6 +788,14 @@ function createERC20ToKusamaTx( customXcm, "Unlimited", ) + // polkadot->kusama: source IS Polkadot AH, so pay the service fee here as a local DOT + // transfer to the recipient, atomically with the bridge transfer via batchAll. (The + // customXcm runs on Kusama AH for this direction, so it cannot place a PAH-side fee.) + const serviceFeeTransfer = parachain.tx.balances.transferKeepAlive( + SERVICE_FEE_RECIPIENT, + serviceFee, + ) + return parachain.tx.utility.batchAll([serviceFeeTransfer, transfer]) } function createERC20ToPolkadotTx( @@ -740,17 +805,22 @@ function createERC20ToPolkadotTx( beneficiaryAccount: string, amount: bigint, destFeeInSourceNative: bigint, + serviceFee: bigint, topic: string, ): SubmittableExtrinsic<"promise", ISubmittableResult> { let assets: any let reserveTypeAsset = "DestinationReserve" + // kusama->polkadot: the custom XCM runs on Polkadot AH (dest) holding KSM, so the service + // fee is skimmed there. Send it from Kusama AH as extra KSM headroom on top of the + // destination execution fee (`destFeeInSourceNative`), so it survives BuyExecution and is + // present in Polkadot-AH holding for the fee deposit. // is KSM if (isRelaychainLocation(tokenLocation)) { assets = { v4: [ { id: NATIVE_TOKEN_LOCATION, - fun: { Fungible: destFeeInSourceNative + amount }, + fun: { Fungible: destFeeInSourceNative + amount + serviceFee }, }, ], } @@ -760,7 +830,7 @@ function createERC20ToPolkadotTx( v4: [ { id: NATIVE_TOKEN_LOCATION, - fun: { Fungible: destFeeInSourceNative }, + fun: { Fungible: destFeeInSourceNative + serviceFee }, }, { id: tokenLocation, @@ -779,6 +849,8 @@ function createERC20ToPolkadotTx( parachain.registry, beneficiaryAccount, topic, + serviceFee, + SERVICE_FEE_RECIPIENT, ) return parachain.tx.polkadotXcm.transferAssetsUsingTypeAndThen( destination, @@ -852,7 +924,7 @@ async function dryRunSourceAssetHub( } } -async function dryRunDestAssetHub(assetHub: ApiPromise, parachainId: number, xcm: any) { +export async function dryRunDestAssetHub(assetHub: ApiPromise, parachainId: number, xcm: any) { const sourceParachain = { v4: { parents: 1, interior: { x1: [{ parachain: parachainId }] } } } const result = await assetHub.call.dryRunApi.dryRunXcm< Result diff --git a/web/packages/api/src/types/fee.ts b/web/packages/api/src/types/fee.ts index a1c4fc35b3..53c74a9ecd 100644 --- a/web/packages/api/src/types/fee.ts +++ b/web/packages/api/src/types/fee.ts @@ -35,7 +35,11 @@ export type ToEthereumFeeKey = export type InterParachainFeeKey = "assetHubDelivery" | "destinationExecution" // kusama ↔ polkadot -export type KusamaFeeKey = "xcmBridge" | "bridgeHubDelivery" | "destinationExecution" +export type KusamaFeeKey = + | "xcmBridge" + | "bridgeHubDelivery" + | "destinationExecution" + | "serviceFee" // v1 ethereum → polkadot (legacy adapter) export type V1ToPolkadotFeeKey = "destinationDelivery" | "destinationExecution" diff --git a/web/packages/api/src/types/forKusama.ts b/web/packages/api/src/types/forKusama.ts index 32ee375108..3e73d49472 100644 --- a/web/packages/api/src/types/forKusama.ts +++ b/web/packages/api/src/types/forKusama.ts @@ -45,6 +45,7 @@ export enum ValidationReason { MaxConsumersReached, AccountDoesNotExist, InsufficientPoolReserves, + MinimumAmountValidation, } export type ValidationLog = { diff --git a/web/packages/api/src/xcmBuilder.ts b/web/packages/api/src/xcmBuilder.ts index 8836cf582c..efcec8059d 100644 --- a/web/packages/api/src/xcmBuilder.ts +++ b/web/packages/api/src/xcmBuilder.ts @@ -397,25 +397,51 @@ export function buildParachainERC20ReceivedXcmOnAssetHub( }) } -function buildAssetHubXcmFromParachainKusama(beneficiary: string, topic: string) { - return [ - { +function buildAssetHubXcmFromParachainKusama( + beneficiary: string, + topic: string, + // Optional service fee: when set, skim a fixed KSM amount to feeRecipient on + // Polkadot AH before depositing the remainder to the beneficiary. Only used on + // the kusama->polkadot direction, whose custom XCM executes on Polkadot AH and + // arrives holding KSM. The KSM sent from Kusama AH must include this amount as + // headroom on top of the destination execution fee. + serviceFeeKsm?: bigint, + feeRecipient?: string, +) { + const instructions: any[] = [] + if (serviceFeeKsm && serviceFeeKsm > 0n && feeRecipient) { + instructions.push({ depositAsset: { assets: { - Wild: { - AllCounted: 2, - }, + Definite: [ + { + id: ksmLocationOnPolkadotAssetHub, + fun: { Fungible: serviceFeeKsm }, + }, + ], }, beneficiary: { parents: 0, - interior: { x1: [{ AccountId32: { id: beneficiary } }] }, + interior: { x1: [{ AccountId32: { id: feeRecipient } }] }, }, }, + }) + } + instructions.push({ + depositAsset: { + assets: { + Wild: { + AllCounted: 2, + }, + }, + beneficiary: { + parents: 0, + interior: { x1: [{ AccountId32: { id: beneficiary } }] }, + }, }, - { - setTopic: topic, - }, - ] + }) + instructions.push({ setTopic: topic }) + return instructions } function buildAssetHubXcmFromParachain( @@ -531,9 +557,16 @@ export function buildAssetHubERC20TransferToKusama( registry: Registry, beneficiary: string, topic: string, + serviceFeeKsm?: bigint, + feeRecipient?: string, ) { return registry.createType("XcmVersionedXcm", { - v4: buildAssetHubXcmFromParachainKusama(beneficiary, topic), + v4: buildAssetHubXcmFromParachainKusama( + beneficiary, + topic, + serviceFeeKsm, + feeRecipient, + ), }) } diff --git a/web/packages/api/src/xcmBuilderKusama.ts b/web/packages/api/src/xcmBuilderKusama.ts index acb2acd0f7..5a214f9ce4 100644 --- a/web/packages/api/src/xcmBuilderKusama.ts +++ b/web/packages/api/src/xcmBuilderKusama.ts @@ -241,13 +241,18 @@ export function buildKusamaToPolkadotDestAssetHubXCM( transferAmount: bigint, beneficiary: string, topic: string, + // Optional service fee: the real kusama->polkadot message carries `serviceFeeKsm` extra KSM + // and skims it to `feeRecipient` on Polkadot AH before the beneficiary deposit. Modelling it + // here keeps validate()'s dest dry-run faithful so a broken fee skim is caught. + serviceFeeKsm: bigint = 0n, + feeRecipient?: string, ) { let withdrawAssets: any[] = [] let reserveAssetsDeposited = [ { id: ksmLocationOnPolkadotAssetHub, fun: { - Fungible: totalFeeInNative, + Fungible: totalFeeInNative + serviceFeeKsm, }, }, ] @@ -306,6 +311,23 @@ export function buildKusamaToPolkadotDestAssetHubXCM( withdrawAsset: withdrawAssets, }, { clearOrigin: null }, + ...(serviceFeeKsm > 0n && feeRecipient + ? [ + { + depositAsset: { + assets: { + Definite: [ + { + id: ksmLocationOnPolkadotAssetHub, + fun: { Fungible: serviceFeeKsm }, + }, + ], + }, + beneficiary: accountId32Location(feeRecipient), + }, + }, + ] + : []), { depositAsset: { assets: { diff --git a/web/packages/operations/src/transfer_for_kusama.ts b/web/packages/operations/src/transfer_for_kusama.ts index 7ad39084ac..e045ecdad0 100644 --- a/web/packages/operations/src/transfer_for_kusama.ts +++ b/web/packages/operations/src/transfer_for_kusama.ts @@ -16,6 +16,17 @@ export const transferForKusama = async ( env = process.env.NODE_ENV } const info = bridgeInfoFor(env) + // polkadot.io RPCs are flaky; prefer dwellir for Polkadot AH connectivity. + if (env === "polkadot_mainnet" && (info as any).environment?.parachains) { + ;(info as any).environment.parachains["1000"] = + "wss://asset-hub-polkadot-rpc.n.dwellir.com" + } + // The default Kusama AH RPC (dwellir) drops mid-subscription, so signAndSend on the + // kusama->polkadot (source = Kusama AH) path never finalizes. Pin the polkadot.io one. + if (env === "polkadot_mainnet" && (info as any).environment?.kusama?.parachains) { + ;(info as any).environment.kusama.parachains["1000"] = + "wss://kusama-asset-hub-rpc.polkadot.io" + } const { registry, environment: snowbridgeEnv } = info if (snowbridgeEnv === undefined) { throw Error(`Unknown environment '${env}'`) @@ -26,12 +37,11 @@ export const transferForKusama = async ( const polkadot_keyring = new Keyring({ type: "sr25519" }) - const SOURCE_ACCOUNT = process.env["SOURCE_SUBSTRATE_KEY"] - ? polkadot_keyring.addFromUri(process.env["SOURCE_SUBSTRATE_KEY"]) - : polkadot_keyring.addFromUri("//Ferdie") - const DEST_ACCOUNT = process.env["DEST_SUBSTRATE_KEY"] - ? polkadot_keyring.addFromUri(process.env["DEST_SUBSTRATE_KEY"]) - : polkadot_keyring.addFromUri("//Ferdie") + const sourceUri = + process.env["SOURCE_SUBSTRATE_KEY"] ?? process.env["KUSAMA_SUBSTRATE_KEY"] ?? "//Ferdie" + const destUri = process.env["DEST_SUBSTRATE_KEY"] ?? sourceUri + const SOURCE_ACCOUNT = polkadot_keyring.addFromUri(sourceUri) + const DEST_ACCOUNT = polkadot_keyring.addFromUri(destUri) const SOURCE_ACCOUNT_PUBLIC = SOURCE_ACCOUNT.address const DEST_ACCOUNT_PUBLIC = DEST_ACCOUNT.address @@ -97,6 +107,15 @@ export const transferForKusama = async ( throw Error(`validation has one of more errors.`) } + console.log("fee:", fee) + console.log("validation:", validation) + + if (process.env["DRY_RUN"] == "true") { + console.log("DRY_RUN=true: validation passed, skipping signAndSend") + await context.destroyContext() + return + } + // Step 5. Submit transaction and get receipt for tracking const response = await transferImpl.signAndSend(transfer, SOURCE_ACCOUNT, { withSignedTransaction: true, From 4312de62cdf2d183c7ed76de3ceab7cb4aa698cd Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Thu, 23 Jul 2026 16:37:48 +0200 Subject: [PATCH 2/7] Make P<>K service fee caller-injected (opt-in) instead of hardcoded - fee(tokenAddress, options?: { serviceFee: { recipient, amount } }) + build() options - recipient/amount injected per-call, resolved onto DeliveryFee.serviceFee, read by tx()/validate() - off by default (no fee unless injected); recipient accepts SS58 or hex (normalised) - removed hardcoded SERVICE_FEE_RECIPIENT/DOT/KSM constants - runner: opt-in via SERVICE_FEE_RECIPIENT + SERVICE_FEE_AMOUNT env --- web/packages/api/src/forKusama.ts | 90 +++++++++---------- .../transfers/forKusama/transferInterface.ts | 4 +- web/packages/api/src/types/forKusama.ts | 13 +++ web/packages/api/src/xcmBuilder.ts | 6 +- web/packages/api/src/xcmBuilderKusama.ts | 4 +- .../operations/src/transfer_for_kusama.ts | 19 +++- 6 files changed, 74 insertions(+), 62 deletions(-) diff --git a/web/packages/api/src/forKusama.ts b/web/packages/api/src/forKusama.ts index 4476cc6322..eca27056d9 100644 --- a/web/packages/api/src/forKusama.ts +++ b/web/packages/api/src/forKusama.ts @@ -53,6 +53,7 @@ import type { DeliveryFee, MessageReceipt, Transfer, + TransferOptions, ValidatedTransfer, ValidationLog, } from "./types/forKusama" @@ -69,21 +70,6 @@ const KUSAMA_FEE_PER_BYTE = 1000000n // 0.000001 KSM const POLKADOT_BASE_FEE = 333_794_429n // 0.033 DOT const POLKADOT_FEE_PER_BYTE = 16666n // 0.0000016666 DOT -// Service fee (~$1) deposited on Polkadot AH for both directions, on top of the -// bridge/execution fees. TEST CONFIG: recipient is Clara's own account so the fee is -// claimable back during testing; replace for production. Denominated in the source -// chain's native asset (DOT for polkadot->kusama, KSM for kusama->polkadot), which is -// what each direction has in Polkadot-AH holding. Fixed native amounts for now, so the -// dollar value drifts with price (DOT ~$0.80, KSM ~$3.17 as of 2026-07-20). -const SERVICE_FEE_RECIPIENT = - "0x6cd8840ea69d18a2f1dde746df70629df118b870ec228367d6bcf3348ca3b10b" -const SERVICE_FEE_DOT = 12_400_000_000n // ~1.24 DOT (10 decimals) ~= $1 -const SERVICE_FEE_KSM = 315_000_000_000n // ~0.315 KSM (12 decimals) ~= $1 - -function serviceFeeAmount(direction: Direction): bigint { - return direction === Direction.ToPolkadot ? SERVICE_FEE_KSM : SERVICE_FEE_DOT -} - function resolveInputs( registry: AssetRegistry, tokenAddress: string, @@ -155,7 +141,7 @@ export class KusamaTransfer implements KusamaTr return { sourceAssetHub: polkadotAssetHub, destAssetHub: kusamaAssetHub } } - async fee(tokenAddress: string): Promise { + async fee(tokenAddress: string, options?: TransferOptions): Promise { const { sourceAssetHub, destAssetHub } = await this.#connections() let baseFeeInStorage = await getStorageItem(sourceAssetHub, ":XcmBridgeHubRouterBaseFee:") let xcmBridgeBaseFee: bigint @@ -289,10 +275,18 @@ export class KusamaTransfer implements KusamaTr destinationFee = destinationFee + BigInt(minBalanceFeeDest) totalXcmBridgeFee = padFeeByPercentage(totalXcmBridgeFee, 33n) - // Service fee (~$1) charged in the source native asset, deposited on Polkadot AH. - const serviceFee = serviceFeeAmount(this.#direction()) + // Optional service fee, normalising the recipient to a hex account id (accepts SS58 or hex). + const serviceFee = + options?.serviceFee && options.serviceFee.amount > 0n + ? { + amount: options.serviceFee.amount, + recipient: isHex(options.serviceFee.recipient) + ? options.serviceFee.recipient + : u8aToHex(decodeAddress(options.serviceFee.recipient)), + } + : undefined - let totalFee = totalXcmBridgeFee + bridgeHubDeliveryFee + destinationFee + const totalFee = totalXcmBridgeFee + bridgeHubDeliveryFee + destinationFee const sourceSymbol = this.#direction() === Direction.ToPolkadot ? "KSM" : "DOT" // destNativeSymbol is the asset coming OUT of the destination AH swap // (DOT for kusama→polkadot, KSM for polkadot→kusama). @@ -312,18 +306,18 @@ export class KusamaTransfer implements KusamaTr amount: destinationFeeInDestNative, symbol: destNativeSymbol, }) - addBreakdown(breakdown, "serviceFee", { amount: serviceFee, symbol: sourceSymbol }) - - const summary = [ - { description: "Bridge fee", amount: totalFee, symbol: sourceSymbol }, - { description: "Service fee", amount: serviceFee, symbol: sourceSymbol }, - ] + const summary = [{ description: "Bridge fee", amount: totalFee, symbol: sourceSymbol }] + if (serviceFee) { + addBreakdown(breakdown, "serviceFee", { amount: serviceFee.amount, symbol: sourceSymbol }) + summary.push({ description: "Service fee", amount: serviceFee.amount, symbol: sourceSymbol }) + } return { kind: this.from.kind === "kusama" ? "kusama->polkadot" : "polkadot->kusama", breakdown, summary, totals: computeTotals(summary), + serviceFee, } } @@ -372,7 +366,8 @@ export class KusamaTransfer implements KusamaTr this.#direction(), tokenAddress, ) - const serviceFee = serviceFeeAmount(this.#direction()) + const serviceFeeAmount = fee.serviceFee?.amount ?? 0n + const serviceFeeRecipient = fee.serviceFee?.recipient let tx if (this.#direction() == Direction.ToPolkadot) { tx = createERC20ToPolkadotTx( @@ -386,7 +381,8 @@ export class KusamaTransfer implements KusamaTr "destinationExecution", fee.kind === "kusama->polkadot" ? "KSM" : "DOT", ), - serviceFee, + serviceFeeAmount, + serviceFeeRecipient, messageId, ) } else { @@ -401,7 +397,8 @@ export class KusamaTransfer implements KusamaTr "destinationExecution", fee.kind === "kusama->polkadot" ? "KSM" : "DOT", ), - serviceFee, + serviceFeeAmount, + serviceFeeRecipient, messageId, ) } @@ -434,8 +431,9 @@ export class KusamaTransfer implements KusamaTr beneficiaryAccount: string, tokenAddress: string, amount: bigint, + options?: TransferOptions, ): Promise { - const fee = await this.fee(tokenAddress) + const fee = await this.fee(tokenAddress, options) const transfer = await this.tx(sourceAccount, beneficiaryAccount, tokenAddress, amount, fee) return ensureValidationSuccess(await this.validate(transfer)) } @@ -484,13 +482,8 @@ export class KusamaTransfer implements KusamaTr }) } - // The transferred token is deposited in full on the destination asset hub (the fee is paid - // separately in the native asset), so the amount must clear the token's minimum balance - // there or the deposit fails and the funds trap under the message origin. An amount exactly - // equal to the minimum balance also traps (observed with 0.01 USDC == its min balance), so - // require headroom above it. Mirror the other transfer impls (e.g. toKusama/erc20ToKusamaAH) - // and take the larger of the two sides' minimum balances so the check is correct regardless - // of direction. + // The transferred amount must exceed the destination token's minimum balance, else the + // deposit fails and the funds trap. Use the larger of the two sides' minimum balances. const destTokenMinimumBalance = sourceAssetMetadata.minimumBalance > destAssetMetadata.minimumBalance ? sourceAssetMetadata.minimumBalance @@ -541,8 +534,7 @@ export class KusamaTransfer implements KusamaTr let destAssetHubXCM: any if (this.#direction() == Direction.ToPolkadot) { - // Model the service-fee skim so the dest dry-run is faithful to the real message - // (kusama->polkadot deposits the KSM service fee on Polkadot AH). + // Include the service-fee skim so the dry-run matches the real message. destAssetHubXCM = buildKusamaToPolkadotDestAssetHubXCM( destAssetHub.registry, findInBreakdown( @@ -555,8 +547,8 @@ export class KusamaTransfer implements KusamaTr transfer.input.amount, transfer.computed.beneficiaryAddressHex, "0x0000000000000000000000000000000000000000000000000000000000000000", - serviceFeeAmount(this.#direction()), - SERVICE_FEE_RECIPIENT, + fee.serviceFee?.amount ?? 0n, + fee.serviceFee?.recipient, ) } else { destAssetHubXCM = buildPolkadotToKusamaDestAssetHubXCM( @@ -737,6 +729,7 @@ function createERC20ToKusamaTx( amount: bigint, destFeeInSourceNative: bigint, serviceFee: bigint, + serviceFeeRecipient: string | undefined, topic: string, ): SubmittableExtrinsic<"promise", ISubmittableResult> { let assets: any @@ -788,11 +781,12 @@ function createERC20ToKusamaTx( customXcm, "Unlimited", ) - // polkadot->kusama: source IS Polkadot AH, so pay the service fee here as a local DOT - // transfer to the recipient, atomically with the bridge transfer via batchAll. (The - // customXcm runs on Kusama AH for this direction, so it cannot place a PAH-side fee.) + if (serviceFee <= 0n || !serviceFeeRecipient) { + return transfer + } + // Pay the service fee as a local DOT transfer on Polkadot AH, atomic with the bridge transfer. const serviceFeeTransfer = parachain.tx.balances.transferKeepAlive( - SERVICE_FEE_RECIPIENT, + serviceFeeRecipient, serviceFee, ) return parachain.tx.utility.batchAll([serviceFeeTransfer, transfer]) @@ -806,14 +800,12 @@ function createERC20ToPolkadotTx( amount: bigint, destFeeInSourceNative: bigint, serviceFee: bigint, + serviceFeeRecipient: string | undefined, topic: string, ): SubmittableExtrinsic<"promise", ISubmittableResult> { let assets: any let reserveTypeAsset = "DestinationReserve" - // kusama->polkadot: the custom XCM runs on Polkadot AH (dest) holding KSM, so the service - // fee is skimmed there. Send it from Kusama AH as extra KSM headroom on top of the - // destination execution fee (`destFeeInSourceNative`), so it survives BuyExecution and is - // present in Polkadot-AH holding for the fee deposit. + // Send the service fee as extra KSM; it is skimmed on Polkadot AH by the custom XCM. // is KSM if (isRelaychainLocation(tokenLocation)) { assets = { @@ -850,7 +842,7 @@ function createERC20ToPolkadotTx( beneficiaryAccount, topic, serviceFee, - SERVICE_FEE_RECIPIENT, + serviceFeeRecipient, ) return parachain.tx.polkadotXcm.transferAssetsUsingTypeAndThen( destination, diff --git a/web/packages/api/src/transfers/forKusama/transferInterface.ts b/web/packages/api/src/transfers/forKusama/transferInterface.ts index 82ceeabd34..3ac19b0cd1 100644 --- a/web/packages/api/src/transfers/forKusama/transferInterface.ts +++ b/web/packages/api/src/transfers/forKusama/transferInterface.ts @@ -5,13 +5,14 @@ import type { DeliveryFee, MessageReceipt, Transfer, + TransferOptions, ValidatedTransfer, } from "../../types/forKusama" export interface TransferInterface { readonly context: Context - fee(tokenAddress: string): Promise + fee(tokenAddress: string, options?: TransferOptions): Promise tx( sourceAccount: string, @@ -28,6 +29,7 @@ export interface TransferInterface { beneficiaryAccount: string, tokenAddress: string, amount: bigint, + options?: TransferOptions, ): Promise signAndSend( diff --git a/web/packages/api/src/types/forKusama.ts b/web/packages/api/src/types/forKusama.ts index 3e73d49472..c45fc5e180 100644 --- a/web/packages/api/src/types/forKusama.ts +++ b/web/packages/api/src/types/forKusama.ts @@ -26,11 +26,24 @@ export type Transfer = { tx: SubmittableExtrinsic<"promise", ISubmittableResult> } +// Optional service fee deposited on Polkadot AH. `amount` is in the source chain's native asset +// (DOT for polkadot->kusama, KSM for kusama->polkadot). +export type ServiceFeeConfig = { + recipient: string + amount: bigint +} + +export type TransferOptions = { + serviceFee?: ServiceFeeConfig +} + export type DeliveryFee = { kind: "kusama->polkadot" | "polkadot->kusama" breakdown: { [P in KusamaFeeKey]?: FeeAsset[] } summary: FeeItem[] totals: FeeAsset[] + // Resolved service fee, read by tx()/validate(). + serviceFee?: ServiceFeeConfig } export enum ValidationKind { diff --git a/web/packages/api/src/xcmBuilder.ts b/web/packages/api/src/xcmBuilder.ts index efcec8059d..6d55f58b0a 100644 --- a/web/packages/api/src/xcmBuilder.ts +++ b/web/packages/api/src/xcmBuilder.ts @@ -400,11 +400,7 @@ export function buildParachainERC20ReceivedXcmOnAssetHub( function buildAssetHubXcmFromParachainKusama( beneficiary: string, topic: string, - // Optional service fee: when set, skim a fixed KSM amount to feeRecipient on - // Polkadot AH before depositing the remainder to the beneficiary. Only used on - // the kusama->polkadot direction, whose custom XCM executes on Polkadot AH and - // arrives holding KSM. The KSM sent from Kusama AH must include this amount as - // headroom on top of the destination execution fee. + // When set, skim this KSM amount to feeRecipient before depositing the remainder to the beneficiary. serviceFeeKsm?: bigint, feeRecipient?: string, ) { diff --git a/web/packages/api/src/xcmBuilderKusama.ts b/web/packages/api/src/xcmBuilderKusama.ts index 5a214f9ce4..216c3b186e 100644 --- a/web/packages/api/src/xcmBuilderKusama.ts +++ b/web/packages/api/src/xcmBuilderKusama.ts @@ -241,9 +241,7 @@ export function buildKusamaToPolkadotDestAssetHubXCM( transferAmount: bigint, beneficiary: string, topic: string, - // Optional service fee: the real kusama->polkadot message carries `serviceFeeKsm` extra KSM - // and skims it to `feeRecipient` on Polkadot AH before the beneficiary deposit. Modelling it - // here keeps validate()'s dest dry-run faithful so a broken fee skim is caught. + // When set, skim this KSM amount to feeRecipient before the beneficiary deposit. serviceFeeKsm: bigint = 0n, feeRecipient?: string, ) { diff --git a/web/packages/operations/src/transfer_for_kusama.ts b/web/packages/operations/src/transfer_for_kusama.ts index e045ecdad0..73eaa93f74 100644 --- a/web/packages/operations/src/transfer_for_kusama.ts +++ b/web/packages/operations/src/transfer_for_kusama.ts @@ -21,8 +21,7 @@ export const transferForKusama = async ( ;(info as any).environment.parachains["1000"] = "wss://asset-hub-polkadot-rpc.n.dwellir.com" } - // The default Kusama AH RPC (dwellir) drops mid-subscription, so signAndSend on the - // kusama->polkadot (source = Kusama AH) path never finalizes. Pin the polkadot.io one. + // Pin a reliable Kusama AH RPC; the default drops mid-subscription. if (env === "polkadot_mainnet" && (info as any).environment?.kusama?.parachains) { ;(info as any).environment.kusama.parachains["1000"] = "wss://kusama-asset-hub-rpc.polkadot.io" @@ -86,8 +85,20 @@ export const transferForKusama = async ( { kind: "kusama", id: registry.kusama!.assetHubParaId }, ) - // Step 1. Get the delivery fee for the transaction - const fee = await transferImpl.fee(tokenAddress) + // Step 1. Get the delivery fee. Optional service fee via SERVICE_FEE_RECIPIENT + + // SERVICE_FEE_AMOUNT (source native base units: DOT for p->k, KSM for k->p). + const serviceFeeRecipient = process.env["SERVICE_FEE_RECIPIENT"] + const serviceFeeAmount = process.env["SERVICE_FEE_AMOUNT"] + const options = + serviceFeeRecipient && serviceFeeAmount + ? { + serviceFee: { + recipient: serviceFeeRecipient, + amount: BigInt(serviceFeeAmount), + }, + } + : undefined + const fee = await transferImpl.fee(tokenAddress, options) // Step 2. Create a transfer tx const transfer = await transferImpl.tx( From 0a073a6332d82d2d58ce37a60a6523e278d54422 Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Fri, 24 Jul 2026 12:16:50 +0200 Subject: [PATCH 3/7] comment cleanups --- web/packages/api/src/forKusama.ts | 7 +++---- web/packages/api/src/types/forKusama.ts | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/web/packages/api/src/forKusama.ts b/web/packages/api/src/forKusama.ts index eca27056d9..5b0923355b 100644 --- a/web/packages/api/src/forKusama.ts +++ b/web/packages/api/src/forKusama.ts @@ -483,9 +483,10 @@ export class KusamaTransfer implements KusamaTr } // The transferred amount must exceed the destination token's minimum balance, else the - // deposit fails and the funds trap. Use the larger of the two sides' minimum balances. + // deposit fails and the funds trap. resolveInputs labels metadata by chain (source = + // Polkadot AH, dest = Kusama AH), not by transfer direction, so pick the true destination. const destTokenMinimumBalance = - sourceAssetMetadata.minimumBalance > destAssetMetadata.minimumBalance + this.#direction() === Direction.ToPolkadot ? sourceAssetMetadata.minimumBalance : destAssetMetadata.minimumBalance if (amount <= destTokenMinimumBalance) { @@ -534,7 +535,6 @@ export class KusamaTransfer implements KusamaTr let destAssetHubXCM: any if (this.#direction() == Direction.ToPolkadot) { - // Include the service-fee skim so the dry-run matches the real message. destAssetHubXCM = buildKusamaToPolkadotDestAssetHubXCM( destAssetHub.registry, findInBreakdown( @@ -805,7 +805,6 @@ function createERC20ToPolkadotTx( ): SubmittableExtrinsic<"promise", ISubmittableResult> { let assets: any let reserveTypeAsset = "DestinationReserve" - // Send the service fee as extra KSM; it is skimmed on Polkadot AH by the custom XCM. // is KSM if (isRelaychainLocation(tokenLocation)) { assets = { diff --git a/web/packages/api/src/types/forKusama.ts b/web/packages/api/src/types/forKusama.ts index c45fc5e180..d837c9ba07 100644 --- a/web/packages/api/src/types/forKusama.ts +++ b/web/packages/api/src/types/forKusama.ts @@ -42,7 +42,6 @@ export type DeliveryFee = { breakdown: { [P in KusamaFeeKey]?: FeeAsset[] } summary: FeeItem[] totals: FeeAsset[] - // Resolved service fee, read by tx()/validate(). serviceFee?: ServiceFeeConfig } From efa528eb7a756ee0533993bd9539338a01fcec31 Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Fri, 24 Jul 2026 12:38:53 +0200 Subject: [PATCH 4/7] registry --- .../operations/src/transfer_for_kusama.ts | 10 --- .../registry/scripts/buildRegistry.ts | 29 +++++++ .../src/polkadot_mainnet_bridge_info.g.ts | 84 +++++++++++++++++-- 3 files changed, 108 insertions(+), 15 deletions(-) diff --git a/web/packages/operations/src/transfer_for_kusama.ts b/web/packages/operations/src/transfer_for_kusama.ts index 73eaa93f74..5650114e77 100644 --- a/web/packages/operations/src/transfer_for_kusama.ts +++ b/web/packages/operations/src/transfer_for_kusama.ts @@ -16,16 +16,6 @@ export const transferForKusama = async ( env = process.env.NODE_ENV } const info = bridgeInfoFor(env) - // polkadot.io RPCs are flaky; prefer dwellir for Polkadot AH connectivity. - if (env === "polkadot_mainnet" && (info as any).environment?.parachains) { - ;(info as any).environment.parachains["1000"] = - "wss://asset-hub-polkadot-rpc.n.dwellir.com" - } - // Pin a reliable Kusama AH RPC; the default drops mid-subscription. - if (env === "polkadot_mainnet" && (info as any).environment?.kusama?.parachains) { - ;(info as any).environment.kusama.parachains["1000"] = - "wss://kusama-asset-hub-rpc.polkadot.io" - } const { registry, environment: snowbridgeEnv } = info if (snowbridgeEnv === undefined) { throw Error(`Unknown environment '${env}'`) diff --git a/web/packages/registry/scripts/buildRegistry.ts b/web/packages/registry/scripts/buildRegistry.ts index af90ba0394..2790733490 100644 --- a/web/packages/registry/scripts/buildRegistry.ts +++ b/web/packages/registry/scripts/buildRegistry.ts @@ -460,6 +460,35 @@ function buildTransferLocations( } } + // Polkadot AH ↔ Kusama AH paths (assets common to both) + if (registry.kusama) { + const assetHubAssets = Object.keys(assetHub.assets) + for (const kusamaPara of Object.values(registry.kusama.parachains)) { + const kusamaAssets = Object.keys(kusamaPara.assets) + const pahKahCommon = new Set( + assetHubAssets.filter((pa) => kusamaAssets.find((ka) => ka === pa)), + ) + for (const asset of pahKahCommon) { + const p1: Path = { + source: { kind: assetHub.kind, id: assetHub.id }, + destination: { kind: kusamaPara.kind, id: kusamaPara.id }, + asset, + } + if (pathFilter(p1)) { + locations.push(p1) + } + const p2: Path = { + source: p1.destination, + destination: p1.source, + asset, + } + if (pathFilter(p2)) { + locations.push(p2) + } + } + } + } + // L2 paths if (environment.l2Bridge) { const assetHubAssets = Object.keys(assetHub.assets) diff --git a/web/packages/registry/src/polkadot_mainnet_bridge_info.g.ts b/web/packages/registry/src/polkadot_mainnet_bridge_info.g.ts index 5ab53e1732..d3a93d0b05 100644 --- a/web/packages/registry/src/polkadot_mainnet_bridge_info.g.ts +++ b/web/packages/registry/src/polkadot_mainnet_bridge_info.g.ts @@ -460,6 +460,80 @@ const registry = { "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9", ], }, + { + from: { + kind: "polkadot", + id: 1000, + }, + to: { + kind: "kusama", + id: 1000, + }, + assets: [ + "0x9d39a5de30e57443bff2a8307a4256c8797a3497", + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "0x6982508145454ce325ddbe47a25d4ec3d2311933", + "0x5a98fcbea516cf06857215779fd812ca3bef1b32", + "0xa3931d71877c0e7a3148cb7eb4463524fec27fbd", + "0x8236a87084f8b84306f72007f36f2618a5634494", + "0x1abaea1f7c830bd89acc67ec4af516284b1bc33c", + "0x56072c95faa701256059aa122697b133aded9279", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "0x0e186357c323c806c1efdad36d217f7a54b63d18", + "0x18084fba666a33d37592fa2633fd49a74dd93a88", + "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", + "0x582d872a1b094fc48f5de31d3b73f2d9be47def1", + "0x6b175474e89094c44da98b954eedeac495271d0f", + "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "0x8daebade922df735c38c80c7ebd708af50815faa", + "0x0000000000000000000000000000000000000000", + "0x5d3d01fd6d2ad1169b17918eb4f153c6616288eb", + "0xdac17f958d2ee523a2206206994597c13d831ec7", + "0x514910771af9ca656af840dff83e8264ecf986ca", + "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9", + "0x196c20da81fbc324ecdf55501e95ce9f0bd84d14", + "0x12bbfdc9e813614eef8dc8a2560b0efbeaf7c2ab", + "0x5fdcd48f09fb67de3d202cd854b372aec1100ed5", + ], + }, + { + from: { + kind: "kusama", + id: 1000, + }, + to: { + kind: "polkadot", + id: 1000, + }, + assets: [ + "0x9d39a5de30e57443bff2a8307a4256c8797a3497", + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "0x6982508145454ce325ddbe47a25d4ec3d2311933", + "0x5a98fcbea516cf06857215779fd812ca3bef1b32", + "0xa3931d71877c0e7a3148cb7eb4463524fec27fbd", + "0x8236a87084f8b84306f72007f36f2618a5634494", + "0x1abaea1f7c830bd89acc67ec4af516284b1bc33c", + "0x56072c95faa701256059aa122697b133aded9279", + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "0x0e186357c323c806c1efdad36d217f7a54b63d18", + "0x18084fba666a33d37592fa2633fd49a74dd93a88", + "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", + "0x582d872a1b094fc48f5de31d3b73f2d9be47def1", + "0x6b175474e89094c44da98b954eedeac495271d0f", + "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "0x8daebade922df735c38c80c7ebd708af50815faa", + "0x0000000000000000000000000000000000000000", + "0x5d3d01fd6d2ad1169b17918eb4f153c6616288eb", + "0xdac17f958d2ee523a2206206994597c13d831ec7", + "0x514910771af9ca656af840dff83e8264ecf986ca", + "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9", + "0x196c20da81fbc324ecdf55501e95ce9f0bd84d14", + "0x12bbfdc9e813614eef8dc8a2560b0efbeaf7c2ab", + "0x5fdcd48f09fb67de3d202cd854b372aec1100ed5", + ], + }, { from: { kind: "polkadot", @@ -636,7 +710,7 @@ const registry = { }, ], registry: { - timestamp: "2026-06-28T06:07:00.628Z", + timestamp: "2026-07-24T10:32:42.264Z", environment: "polkadot_mainnet", ethChainId: 1, gatewayAddress: "0x27ca963c279c93801941e1eb8799c23f407d68e7", @@ -1906,7 +1980,7 @@ const registry = { evmChainId: 1284, name: "Moonbeam", specName: "moonbeam", - specVersion: 4303, + specVersion: 4401, }, xcDOT: "0xffffffff1fcacbd218edc0eba20fc2308c778080", assets: { @@ -1974,7 +2048,7 @@ const registry = { xc20: "0xffffffff7bc304425217b49e9598415c514ae81b", }, }, - estimatedExecutionFeeDOT: 49192809n, + estimatedExecutionFeeDOT: 49271805n, estimatedDeliveryFeeDOT: 306500000n, }, polkadot_2030: { @@ -2002,7 +2076,7 @@ const registry = { evmChainId: 996, name: "Bifrost Polkadot", specName: "bifrost_polkadot", - specVersion: 25000, + specVersion: 25002, }, assets: { "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": { @@ -2174,7 +2248,7 @@ const registry = { isSufficient: true, }, }, - estimatedExecutionFeeDOT: 3618870n, + estimatedExecutionFeeDOT: 5606528n, estimatedDeliveryFeeDOT: 307100000n, }, polkadot_2043: { From 1572945205c9c26cd2a690ea29db855e23fe5593 Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Fri, 24 Jul 2026 12:50:51 +0200 Subject: [PATCH 5/7] rename files --- web/packages/api/src/index.ts | 12 +++++------ .../src/{forKusama.ts => polkadotKusama.ts} | 10 +++++----- .../transferInterface.ts | 2 +- .../types/{forKusama.ts => polkadotKusama.ts} | 0 web/packages/operations/package.json | 20 +++++++++---------- ..._kusama.ts => transfer_polkadot_kusama.ts} | 4 ++-- ...y.ts => transfer_polkadot_kusama_entry.ts} | 6 +++--- 7 files changed, 27 insertions(+), 27 deletions(-) rename web/packages/api/src/{forKusama.ts => polkadotKusama.ts} (98%) rename web/packages/api/src/transfers/{forKusama => polkadotKusama}/transferInterface.ts (96%) rename web/packages/api/src/types/{forKusama.ts => polkadotKusama.ts} (100%) rename web/packages/operations/src/{transfer_for_kusama.ts => transfer_polkadot_kusama.ts} (97%) rename web/packages/operations/src/{transfer_for_kusama_entry.ts => transfer_polkadot_kusama_entry.ts} (73%) diff --git a/web/packages/api/src/index.ts b/web/packages/api/src/index.ts index d5cac36b10..32a140e033 100644 --- a/web/packages/api/src/index.ts +++ b/web/packages/api/src/index.ts @@ -24,7 +24,7 @@ import type { AddTipInterface } from "./addTip/addTipInterface" import type { AgentCreationInterface } from "./types/registration/agent" import type { RegistrationInterface } from "./types/registration/toPolkadot" import type { TransferInterface as ForInterParachainTransferInterface } from "./transfers/forInterParachain/transferInterface" -import type { TransferInterface as ForKusamaTransferInterface } from "./transfers/forKusama/transferInterface" +import type { TransferInterface as PolkadotKusamaTransferInterface } from "./transfers/polkadotKusama/transferInterface" import type { TransferInterface as ToPolkadotTransferInterface } from "./transfers/toPolkadot/transferInterface" import type { TransferInterface as ToPolkadotL2TransferInterface } from "./transfers/l2ToPolkadot/transferInterface" import type { TransferInterface as ToEthereumTransferInterface } from "./transfers/toEthereum/transferInterface" @@ -60,7 +60,7 @@ export * as toPolkadotSnowbridgeV2 from "./types/toPolkadotSnowbridgeV2" export * as toEthereumV2 from "./types/toEthereum" export * as toEthereumFromEVMV2 from "./types/toEthereumEvm" export * as forInterParachain from "./types/forInterParachain" -export * as forKusama from "./types/forKusama" +export * as polkadotKusama from "./types/polkadotKusama" export type { VolumeFeeParams } from "./feeSchedule" export type { FeeAsset, @@ -419,8 +419,8 @@ export type ApiOptions

> = { export type TransferImplementation = | ({ kind: "polkadot->polkadot" } & ForInterParachainTransferInterface) - | ({ kind: "kusama->polkadot" } & ForKusamaTransferInterface) - | ({ kind: "polkadot->kusama" } & ForKusamaTransferInterface) + | ({ kind: "kusama->polkadot" } & PolkadotKusamaTransferInterface) + | ({ kind: "polkadot->kusama" } & PolkadotKusamaTransferInterface) | ({ kind: "polkadot->ethereum" } & ToEthereumTransferInterface) | ({ kind: "ethereum->polkadot" } & ToPolkadotTransferInterface) | ({ kind: "ethereum->ethereum" } & ToEthereumEvmTransferInterface) @@ -564,9 +564,9 @@ export class SnowbridgeApi

> { case "polkadot->kusama": { const sourceParachain = sourceChain as Parachain const destinationParachain = destinationChain as Parachain - const { KusamaTransfer } = require("./forKusama") + const { PolkadotKusamaTransfer } = require("./polkadotKusama") return withKind( - new KusamaTransfer( + new PolkadotKusamaTransfer( this.info, this.context, route, diff --git a/web/packages/api/src/forKusama.ts b/web/packages/api/src/polkadotKusama.ts similarity index 98% rename from web/packages/api/src/forKusama.ts rename to web/packages/api/src/polkadotKusama.ts index 5b0923355b..79f55cb92b 100644 --- a/web/packages/api/src/forKusama.ts +++ b/web/packages/api/src/polkadotKusama.ts @@ -47,7 +47,7 @@ import { checkKsmDotPoolLiquidityForPolkadotToKusama, } from "./poolReserves" import { resolveBeneficiary } from "./crypto" -import { TransferInterface as KusamaTransferInterface } from "./transfers/forKusama/transferInterface" +import { TransferInterface as PolkadotKusamaTransferInterface } from "./transfers/polkadotKusama/transferInterface" import { Context } from "." import type { DeliveryFee, @@ -56,9 +56,9 @@ import type { TransferOptions, ValidatedTransfer, ValidationLog, -} from "./types/forKusama" -import { ValidationKind, ValidationReason } from "./types/forKusama" -export { ValidationKind, ValidationReason } from "./types/forKusama" +} from "./types/polkadotKusama" +import { ValidationKind, ValidationReason } from "./types/polkadotKusama" +export { ValidationKind, ValidationReason } from "./types/polkadotKusama" export enum Direction { ToKusama, @@ -97,7 +97,7 @@ function resolveInputs( return { sourceAssetMetadata, destAssetMetadata, sourceParachain } } -export class KusamaTransfer implements KusamaTransferInterface { +export class PolkadotKusamaTransfer implements PolkadotKusamaTransferInterface { readonly info: BridgeInfo readonly context: Context readonly route: TransferRoute diff --git a/web/packages/api/src/transfers/forKusama/transferInterface.ts b/web/packages/api/src/transfers/polkadotKusama/transferInterface.ts similarity index 96% rename from web/packages/api/src/transfers/forKusama/transferInterface.ts rename to web/packages/api/src/transfers/polkadotKusama/transferInterface.ts index 3ac19b0cd1..dd02d95098 100644 --- a/web/packages/api/src/transfers/forKusama/transferInterface.ts +++ b/web/packages/api/src/transfers/polkadotKusama/transferInterface.ts @@ -7,7 +7,7 @@ import type { Transfer, TransferOptions, ValidatedTransfer, -} from "../../types/forKusama" +} from "../../types/polkadotKusama" export interface TransferInterface { readonly context: Context diff --git a/web/packages/api/src/types/forKusama.ts b/web/packages/api/src/types/polkadotKusama.ts similarity index 100% rename from web/packages/api/src/types/forKusama.ts rename to web/packages/api/src/types/polkadotKusama.ts diff --git a/web/packages/operations/package.json b/web/packages/operations/package.json index 87957f7ae4..def3f13268 100644 --- a/web/packages/operations/package.json +++ b/web/packages/operations/package.json @@ -19,16 +19,16 @@ "historyV2": "npx ts-node src/global_transfer_history_v2.ts", "buildTokenRegistry": "npx ts-node src/build_asset_registry.ts", "generateReferencePreimages": "npx ts-node src/generate_reference_preimages.ts", - "transferWethFromPolkadotToKusama": "npx ts-node src/transfer_for_kusama_entry.ts WEthPToK 0 WETH 200000000000000", - "transferEthFromPolkadotToKusama": "npx ts-node src/transfer_for_kusama_entry.ts EthPToK 0 ETH 200000000000000", - "transferDotFromPolkadotToKusama": "npx ts-node src/transfer_for_kusama_entry.ts DotPToK 0 DOT 200000000000000", - "transferKsmFromPolkadotToKusama": "npx ts-node src/transfer_for_kusama_entry.ts KsmPToK 0 KSM 200000000000000", - "transferWethFromKusamaToPolkadot": "npx ts-node src/transfer_for_kusama_entry.ts WEthKToP 1 WETH 200000000000000", - "transferEthFromKusamaToPolkadot": "npx ts-node src/transfer_for_kusama_entry.ts EthKToP 1 ETH 200000000000000", - "transferDotFromKusamaToPolkadot": "npx ts-node src/transfer_for_kusama_entry.ts DotKToP 1 DOT 200000000000000", - "transferKsmFromKusamaToPolkadot": "npx ts-node src/transfer_for_kusama_entry.ts KsmKToP 1 KSM 200000000000000", - "transferWudFromPolkadotToKusama": "npx ts-node src/transfer_for_kusama_entry.ts WudPToK 0 WUD 200000000000000", - "transferWudFromKusamaToPolkadot": "npx ts-node src/transfer_for_kusama_entry.ts WudKToP 1 WUD 200000000000000", + "transferWethFromPolkadotToKusama": "npx ts-node src/transfer_polkadot_kusama_entry.ts WEthPToK 0 WETH 200000000000000", + "transferEthFromPolkadotToKusama": "npx ts-node src/transfer_polkadot_kusama_entry.ts EthPToK 0 ETH 200000000000000", + "transferDotFromPolkadotToKusama": "npx ts-node src/transfer_polkadot_kusama_entry.ts DotPToK 0 DOT 200000000000000", + "transferKsmFromPolkadotToKusama": "npx ts-node src/transfer_polkadot_kusama_entry.ts KsmPToK 0 KSM 200000000000000", + "transferWethFromKusamaToPolkadot": "npx ts-node src/transfer_polkadot_kusama_entry.ts WEthKToP 1 WETH 200000000000000", + "transferEthFromKusamaToPolkadot": "npx ts-node src/transfer_polkadot_kusama_entry.ts EthKToP 1 ETH 200000000000000", + "transferDotFromKusamaToPolkadot": "npx ts-node src/transfer_polkadot_kusama_entry.ts DotKToP 1 DOT 200000000000000", + "transferKsmFromKusamaToPolkadot": "npx ts-node src/transfer_polkadot_kusama_entry.ts KsmKToP 1 KSM 200000000000000", + "transferWudFromPolkadotToKusama": "npx ts-node src/transfer_polkadot_kusama_entry.ts WudPToK 0 WUD 200000000000000", + "transferWudFromKusamaToPolkadot": "npx ts-node src/transfer_polkadot_kusama_entry.ts WudKToP 1 WUD 200000000000000", "transferEtherToAH": "npx ts-node src/transfer_from_e2p.ts 1000 Eth 200000000000000", "transferEtherFromAH": "npx ts-node src/transfer_from_p2e.ts 1000 Eth 10000000000", "transferEtherFromHydration": "npx ts-node src/transfer_from_p2e.ts 2034 Eth 10000000000", diff --git a/web/packages/operations/src/transfer_for_kusama.ts b/web/packages/operations/src/transfer_polkadot_kusama.ts similarity index 97% rename from web/packages/operations/src/transfer_for_kusama.ts rename to web/packages/operations/src/transfer_polkadot_kusama.ts index 5650114e77..21fdc1250b 100644 --- a/web/packages/operations/src/transfer_for_kusama.ts +++ b/web/packages/operations/src/transfer_polkadot_kusama.ts @@ -2,10 +2,10 @@ import "dotenv/config" import { Keyring } from "@polkadot/keyring" import { createApi } from "@snowbridge/api" import { EthersEthereumProvider } from "@snowbridge/provider-ethers" -import { Direction } from "@snowbridge/api/dist/forKusama" +import { Direction } from "@snowbridge/api/dist/polkadotKusama" import { bridgeInfoFor } from "@snowbridge/registry" -export const transferForKusama = async ( +export const transferPolkadotKusama = async ( transferName: string, direction: Direction, amount: bigint, diff --git a/web/packages/operations/src/transfer_for_kusama_entry.ts b/web/packages/operations/src/transfer_polkadot_kusama_entry.ts similarity index 73% rename from web/packages/operations/src/transfer_for_kusama_entry.ts rename to web/packages/operations/src/transfer_polkadot_kusama_entry.ts index 8ea541ee08..3cc8f2d989 100644 --- a/web/packages/operations/src/transfer_for_kusama_entry.ts +++ b/web/packages/operations/src/transfer_polkadot_kusama_entry.ts @@ -1,6 +1,6 @@ import "dotenv/config" -import { Direction } from "@snowbridge/api/dist/forKusama" -import { transferForKusama } from "./transfer_for_kusama" +import { Direction } from "@snowbridge/api/dist/polkadotKusama" +import { transferPolkadotKusama } from "./transfer_polkadot_kusama" const transfer = async ( transferName: string, @@ -9,7 +9,7 @@ const transfer = async ( amount: bigint, ) => { const directionEnum = direction === 1 ? Direction.ToPolkadot : Direction.ToKusama - await transferForKusama(transferName, directionEnum, amount, symbol) + await transferPolkadotKusama(transferName, directionEnum, amount, symbol) } if (process.argv.length != 6) { From d5a81326cbefc3454f0b7494d172297274ac8566 Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Fri, 24 Jul 2026 13:19:20 +0200 Subject: [PATCH 6/7] more cleanup --- web/packages/api/src/polkadotKusama.ts | 4 +- .../registry/scripts/buildRegistry.ts | 49 ++++++++++--------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/web/packages/api/src/polkadotKusama.ts b/web/packages/api/src/polkadotKusama.ts index 79f55cb92b..d14f03c3af 100644 --- a/web/packages/api/src/polkadotKusama.ts +++ b/web/packages/api/src/polkadotKusama.ts @@ -280,9 +280,7 @@ export class PolkadotKusamaTransfer implements options?.serviceFee && options.serviceFee.amount > 0n ? { amount: options.serviceFee.amount, - recipient: isHex(options.serviceFee.recipient) - ? options.serviceFee.recipient - : u8aToHex(decodeAddress(options.serviceFee.recipient)), + recipient: resolveBeneficiary(options.serviceFee.recipient).hexAddress, } : undefined diff --git a/web/packages/registry/scripts/buildRegistry.ts b/web/packages/registry/scripts/buildRegistry.ts index 2790733490..915378c974 100644 --- a/web/packages/registry/scripts/buildRegistry.ts +++ b/web/packages/registry/scripts/buildRegistry.ts @@ -460,31 +460,32 @@ function buildTransferLocations( } } - // Polkadot AH ↔ Kusama AH paths (assets common to both) - if (registry.kusama) { + // Polkadot AH ↔ Kusama AH paths (assets common to both). Only the Kusama Asset Hub is a + // supported destination; the transfer impl always resolves to kusama.assetHubParaId. + const kusamaAssetHub = + registry.kusama?.parachains[`kusama_${registry.kusama.assetHubParaId}`] + if (kusamaAssetHub) { const assetHubAssets = Object.keys(assetHub.assets) - for (const kusamaPara of Object.values(registry.kusama.parachains)) { - const kusamaAssets = Object.keys(kusamaPara.assets) - const pahKahCommon = new Set( - assetHubAssets.filter((pa) => kusamaAssets.find((ka) => ka === pa)), - ) - for (const asset of pahKahCommon) { - const p1: Path = { - source: { kind: assetHub.kind, id: assetHub.id }, - destination: { kind: kusamaPara.kind, id: kusamaPara.id }, - asset, - } - if (pathFilter(p1)) { - locations.push(p1) - } - const p2: Path = { - source: p1.destination, - destination: p1.source, - asset, - } - if (pathFilter(p2)) { - locations.push(p2) - } + const kusamaAssets = Object.keys(kusamaAssetHub.assets) + const pahKahCommon = new Set( + assetHubAssets.filter((pa) => kusamaAssets.find((ka) => ka === pa)), + ) + for (const asset of pahKahCommon) { + const p1: Path = { + source: { kind: assetHub.kind, id: assetHub.id }, + destination: { kind: kusamaAssetHub.kind, id: kusamaAssetHub.id }, + asset, + } + if (pathFilter(p1)) { + locations.push(p1) + } + const p2: Path = { + source: p1.destination, + destination: p1.source, + asset, + } + if (pathFilter(p2)) { + locations.push(p2) } } } From 4d44ab01282d073aa8659968c5d8d9a0ed9d1951 Mon Sep 17 00:00:00 2001 From: claravanstaden Date: Fri, 24 Jul 2026 19:10:59 +0200 Subject: [PATCH 7/7] better errors --- web/packages/api/src/polkadotKusama.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/web/packages/api/src/polkadotKusama.ts b/web/packages/api/src/polkadotKusama.ts index d14f03c3af..17ba0d57e1 100644 --- a/web/packages/api/src/polkadotKusama.ts +++ b/web/packages/api/src/polkadotKusama.ts @@ -675,9 +675,24 @@ export class PolkadotKusamaTransfer implements transfer.tx.signAndSend(account, options, (c) => { if (c.isError) { console.error(c) - reject(c.internalError || c.dispatchError || c) + if (c.internalError instanceof Error) { + reject(c.internalError) + } else if (c.dispatchError?.isModule) { + const decoded = sourceAssetHub.registry.findMetaError( + c.dispatchError.asModule, + ) + reject(new Error(`Transaction failed: ${decoded.section}.${decoded.name}`)) + } else if (c.dispatchError) { + reject(new Error(`Transaction failed: ${c.dispatchError.toString()}`)) + } else { + reject(new Error(`Transaction failed with status: ${c.status.type}`)) + } + return } - if (c.isFinalized) { + // messageId is computed off-chain (SetTopic we control), so re-orgs can't + // change it, resolve on isInBlock to avoid the finalization wait. + const resolveOnInBlock = transfer.computed.messageId !== undefined + if ((resolveOnInBlock && c.isInBlock) || c.isFinalized) { const result = { txHash: u8aToHex(c.txHash), txIndex: c.txIndex || 0,