diff --git a/.changeset/wide-chicken-happen.md b/.changeset/wide-chicken-happen.md new file mode 100644 index 00000000..21848513 --- /dev/null +++ b/.changeset/wide-chicken-happen.md @@ -0,0 +1,7 @@ +--- +'@galacticcouncil/xc-core': minor +'@galacticcouncil/xc-cfg': minor +'@galacticcouncil/xc-sdk': minor +--- + +Snowbridge migration from V1 to V2 diff --git a/packages/xc-cfg/src/builders/ContractBuilder.ts b/packages/xc-cfg/src/builders/ContractBuilder.ts index c6324c9b..c08438bd 100644 --- a/packages/xc-cfg/src/builders/ContractBuilder.ts +++ b/packages/xc-cfg/src/builders/ContractBuilder.ts @@ -1,7 +1,7 @@ import { Batch } from './contracts/Batch'; import { Erc20 } from './contracts/Erc20'; import { PolkadotXcm } from './contracts/PolkadotXcm'; -import { Snowbridge } from './contracts/Snowbridge'; +import { Snowbridge } from './contracts/snowbridge'; import { Wormhole } from './contracts/Wormhole'; export function ContractBuilder() { diff --git a/packages/xc-cfg/src/builders/FeeAmountBuilder.ts b/packages/xc-cfg/src/builders/FeeAmountBuilder.ts index 2c4e7e18..32459cde 100644 --- a/packages/xc-cfg/src/builders/FeeAmountBuilder.ts +++ b/packages/xc-cfg/src/builders/FeeAmountBuilder.ts @@ -4,23 +4,30 @@ import { FeeAmount, FeeAmountConfigBuilder, Parachain, - Snowbridge as Sb, Wormhole as Wh, } from '@galacticcouncil/xc-core'; import { BRIDGE_HUB_ID, + ETHEREUM_CHAIN_ID, buildERC20TransferFromPara, buildParaERC20Received, + buildParaERC20ReceivedV5, buildReserveTransfer, buildNestedReserveTransfer, buildMultiHopReserveTransfer, + buildSnowbridgeOutboundXcm, + etherLocation, } from './extrinsics/xcm'; import { validateReserveChain } from './extrinsics/xcm/utils'; import { padFeeByPercentage } from './utils'; import { dot } from '../assets'; -import { BaseClient, AssethubClient } from '../clients'; +import { BaseClient, AssethubClient, HydrationClient } from '../clients'; + +// Snowbridge Gateway contract gas limits +const SNOWBRIDGE_DELIVERY_GAS = 80_000n; +const SNOWBRIDGE_BASE_DELIVERY_GAS = 120_000n; function TokenRelayer() { return { @@ -63,43 +70,106 @@ type SendFeeOpts = { function Snowbridge() { return { calculateInboundFee: (opts: SendFeeOpts): FeeAmountConfigBuilder => ({ - build: async ({ feeAsset, destination, source }) => { - const ctx = source as EvmChain; + build: async ({ feeAsset, destination }) => { const rcv = destination as Parachain; - const ctxSb = Sb.fromChain(ctx); - - const paraClient = new BaseClient(rcv); + const paraClient = new HydrationClient(rcv); const hubClient = new AssethubClient(opts.hub); const xcm = buildParaERC20Received(feeAsset, rcv); + const xcmV5 = buildParaERC20ReceivedV5(feeAsset, rcv); + const etherLoc = etherLocation(ETHEREUM_CHAIN_ID); - const [destinationFee, deliveryFee] = await Promise.all([ - paraClient.calculateDestinationFee(xcm, dot), + // Hardcoded BridgeHub extrinsic fee + const extrinsicFeeDot = 250_000_000n; + + // 1. Query all DOT-denominated fees + const [ + destinationExecutionFeeDot, + destinationDeliveryFeeDot, + assetHubDeliveryFeeDot, + assetHubExecutionFeeDotRaw, + ] = await Promise.all([ + paraClient.calculateDestinationFeeV5(xcmV5, dot), hubClient.calculateDeliveryFee(xcm, rcv.parachainId), + hubClient.calculateDeliveryFee(xcm, BRIDGE_HUB_ID), + hubClient.calculateDestinationFee(xcm, dot), ]); - const bridgeFeeInDot = - deliveryFee + padFeeByPercentage(destinationFee, 25n); + const paddedDestExecutionDot = padFeeByPercentage( + destinationExecutionFeeDot, + 33n + ); + const paddedDestDeliveryDot = padFeeByPercentage( + destinationDeliveryFeeDot, + 33n + ); + const paddedAssetHubDeliveryDot = padFeeByPercentage( + assetHubDeliveryFeeDot, + 33n + ); + const assetHubExecutionFeeDot = padFeeByPercentage( + assetHubExecutionFeeDotRaw, + 33n + ); + + // 2. Convert all DOT fees to Ether via AssetHub DEX + const [ + assetHubDeliveryFeeEther, + destinationDeliveryFeeEther, + destinationExecutionFeeEther, + extrinsicFeeEther, + assetHubExecutionFeeEther, + ] = await Promise.all([ + hubClient.quoteDotToEther(etherLoc, paddedAssetHubDeliveryDot), + hubClient.quoteDotToEther(etherLoc, paddedDestDeliveryDot), + hubClient.quoteDotToEther(etherLoc, paddedDestExecutionDot), + hubClient.quoteDotToEther(etherLoc, extrinsicFeeDot), + hubClient.quoteDotToEther(etherLoc, assetHubExecutionFeeDot), + ]); + + // 3. Compute V2 fee params + const executionFee = + assetHubExecutionFeeEther + destinationDeliveryFeeEther; + + const relayerFee = padFeeByPercentage( + extrinsicFeeEther + assetHubDeliveryFeeEther, + 30n + ); + + // Remote DOT fee: DOT needed on the destination parachain + const remoteDotFee = paddedDestExecutionDot; + + // Remote ether fee: Ether needed to buy DOT on AssetHub + const remoteEtherFee = padFeeByPercentage( + await hubClient.quoteEtherForDot(etherLoc, remoteDotFee), + 50n + ); + + const totalFeeInWei = executionFee + relayerFee + remoteEtherFee; - const feeAssetId = ctx.getAssetId(feeAsset); - const bridgeFeeInWei = await ctx.evmClient.getProvider().readContract({ - abi: Abi.Snowbridge, - address: ctxSb.getGateway() as `0x${string}`, - args: [feeAssetId as `0x${string}`, rcv.parachainId, bridgeFeeInDot], - functionName: 'quoteSendTokenFee', - }); return { - amount: bridgeFeeInWei, - breakdown: { bridgeFeeInDot: bridgeFeeInDot }, + amount: totalFeeInWei, + breakdown: { + executionFee, + relayerFee, + remoteEtherFee, + remoteDotFee, + assetHubDeliveryFeeEther, + assetHubExecutionFeeEther, + destinationDeliveryFeeEther, + destinationExecutionFeeEther, + }, } as FeeAmount; }, }), calculateOutboundFee: (opts: SendFeeOpts): FeeAmountConfigBuilder => ({ - build: async ({ transferAsset, feeAsset, source }) => { + build: async ({ transferAsset, feeAsset, source, destination }) => { const ctx = source as Parachain; + const dest = destination as EvmChain; - const client = new AssethubClient(opts.hub); + const hubClient = new AssethubClient(opts.hub); + const etherLoc = etherLocation(ETHEREUM_CHAIN_ID); const xcm = buildERC20TransferFromPara(transferAsset, ctx); const returnToSenderXcm = buildParaERC20Received(transferAsset, ctx); @@ -111,28 +181,62 @@ function Snowbridge() { returnToSenderDeliveryFee, returnToSenderDestinationFee, ] = await Promise.all([ - client.getBridgeDeliveryFee(), - client.calculateDeliveryFee(xcm, BRIDGE_HUB_ID), - client.calculateDestinationFee(xcm, feeAsset), - client.calculateDeliveryFee(returnToSenderXcm, ctx.parachainId), - client.calculateDestinationFee(returnToSenderXcm, feeAsset), + hubClient.getBridgeDeliveryFee(), + hubClient.calculateDeliveryFee(xcm, BRIDGE_HUB_ID), + hubClient.calculateDestinationFee(xcm, feeAsset), + hubClient.calculateDeliveryFee(returnToSenderXcm, ctx.parachainId), + hubClient.calculateDestinationFee(returnToSenderXcm, feeAsset), ]); - const bridgeFeeInDot = + const gasPrice = await dest.evmClient.getProvider().getGasPrice(); + const etherFeeAmount = padFeeByPercentage( + gasPrice * (SNOWBRIDGE_DELIVERY_GAS + SNOWBRIDGE_BASE_DELIVERY_GAS), + 33n + ); + + // DOT for AssetHub execution + downstream deliveries (remote_fees) + const dotRemoteFee = bridgeDeliveryFee + bridgeHubDeliveryFee + padFeeByPercentage(assetHubDestinationFee, 25n) + returnToSenderDeliveryFee + padFeeByPercentage(returnToSenderDestinationFee, 25n); + // DOT to swap for Ether on AssetHub (padded for AMM slippage) + const dotToEtherSwapAmount = padFeeByPercentage( + await hubClient.quoteDotForExactEther(etherLoc, etherFeeAmount), + 20n + ); + + // Query source chain XCM execution fee dynamically + const sourceClient = new HydrationClient(ctx); + const dummyOutboundXcm = buildSnowbridgeOutboundXcm({ + tokenAddress: '0x0000000000000000000000000000000000000000', + senderPubKey: '0x' + '00'.repeat(32), + beneficiaryHex: '0x' + '00'.repeat(20), + tokenAmount: 1_000_000_000_000n, + sourceExecutionFee: 1_000_000_000n, + dotRemoteFee: 1_000_000_000n, + dotToEtherSwapAmount: 1_000_000_000n, + etherFeeAmount: 1_000_000_000n, + topic: '0x' + '00'.repeat(32), + }); + + const sourceExecutionFee = padFeeByPercentage( + await sourceClient.calculateDestinationFeeV5(dummyOutboundXcm, dot), + 33n + ); + + const totalDotFee = + dotRemoteFee + dotToEtherSwapAmount + sourceExecutionFee; + return { - amount: bridgeFeeInDot, + amount: totalDotFee, breakdown: { - bridgeDeliveryFee: bridgeDeliveryFee, - bridgeHubDeliveryFee: bridgeHubDeliveryFee, - assetHubDestinationFee: assetHubDestinationFee, - returnToSenderDeliveryFee: returnToSenderDeliveryFee, - returnToSenderDestinationFee: returnToSenderDestinationFee, + dotRemoteFee, + dotToEtherSwapAmount, + etherFeeAmount, + sourceExecutionFee, }, } as FeeAmount; }, diff --git a/packages/xc-cfg/src/builders/contracts/Snowbridge.ts b/packages/xc-cfg/src/builders/contracts/Snowbridge.ts deleted file mode 100644 index 3d9ac2f7..00000000 --- a/packages/xc-cfg/src/builders/contracts/Snowbridge.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - addr, - Abi, - ContractConfig, - ContractConfigBuilder, - EvmChain, - Parachain, - Snowbridge as Sb, -} from '@galacticcouncil/xc-core'; - -import { parseAssetId } from '../utils'; - -const { Ss58Addr } = addr; - -const sendToken = (): ContractConfigBuilder => ({ - build: async (params) => { - const { address, amount, asset, source, destination } = params; - const ctx = source.chain as EvmChain; - const rcv = destination.chain as Parachain; - - const ctxSb = Sb.fromChain(ctx); - - const assetId = ctx.getAssetId(asset); - const parsedAssetId = parseAssetId(assetId); - - const isNativeTransfer = asset.originSymbol === 'ETH'; - - const bridgeFeeInDot = destination.feeBreakdown['bridgeFeeInDot']; - const bridgeFeeInWei = destination.fee.amount; - - return new ContractConfig({ - abi: Abi.Snowbridge, - address: ctxSb.getGateway(), - args: [ - parsedAssetId, - rcv.parachainId, - [1, Ss58Addr.getPubKey(address) as `0x${string}`], - bridgeFeeInDot, - amount, - ], - value: isNativeTransfer ? bridgeFeeInWei + amount : bridgeFeeInWei, - func: 'sendToken', - module: 'Snowbridge', - }); - }, -}); - -export const Snowbridge = () => { - return { - sendToken, - }; -}; diff --git a/packages/xc-cfg/src/builders/contracts/snowbridge/codec.ts b/packages/xc-cfg/src/builders/contracts/snowbridge/codec.ts new file mode 100644 index 00000000..90bbfe35 --- /dev/null +++ b/packages/xc-cfg/src/builders/contracts/snowbridge/codec.ts @@ -0,0 +1,23 @@ +import { Codec } from 'polkadot-api'; +import { + XcmVersionedXcm, + XcmVersionedLocation, +} from '@galacticcouncil/descriptors'; +import { codec } from '@galacticcouncil/xc-core'; + +interface XcmCodecs { + message: Codec; + location: Codec; +} + +let cached: XcmCodecs | null = null; + +export async function getXcmCodecs(): Promise { + if (cached) return cached; + const codecs = await codec.getHubCodecs(); + cached = { + message: codecs.tx.PolkadotXcm.send.inner.message, + location: codecs.tx.PolkadotXcm.send.inner.dest, + }; + return cached; +} diff --git a/packages/xc-cfg/src/builders/contracts/snowbridge/index.ts b/packages/xc-cfg/src/builders/contracts/snowbridge/index.ts new file mode 100644 index 00000000..619df261 --- /dev/null +++ b/packages/xc-cfg/src/builders/contracts/snowbridge/index.ts @@ -0,0 +1,113 @@ +import { + addr, + Abi, + ContractConfig, + ContractConfigBuilder, + EvmChain, + Parachain, + Snowbridge as Sb, +} from '@galacticcouncil/xc-core'; + +import { + XcmV5Junctions, + XcmV5Junction, + XcmVersionedXcm, + XcmVersionedLocation, +} from '@galacticcouncil/descriptors'; + +import { FixedSizeBinary } from 'polkadot-api'; +import { Blake2256 } from '@polkadot-api/substrate-bindings'; +import { toHex } from '@polkadot-api/utils'; +import { encodeAbiParameters } from 'viem'; + +import { getXcmCodecs } from './codec'; +import { buildSnowbridgeInboundXcm } from '../../extrinsics/xcm/builder/Snowbridge'; +import { ETHEREUM_CHAIN_ID } from '../../extrinsics/xcm/builder/const'; + +import { parseAssetId } from '../../utils'; + +const { Ss58Addr } = addr; + +const ETHER_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const v2SendMessage = (): ContractConfigBuilder => ({ + build: async (params) => { + const { address, amount, asset, sender, source, destination } = params; + const ctx = source.chain as EvmChain; + const rcv = destination.chain as Parachain; + + const ctxSb = Sb.fromChain(ctx); + + const assetId = ctx.getAssetId(asset); + const tokenAddress = parseAssetId(assetId) as string; + + const isNativeTransfer = asset.originSymbol === 'ETH'; + + const executionFee = destination.feeBreakdown['executionFee']; + const relayerFee = destination.feeBreakdown['relayerFee']; + const remoteEtherFee = destination.feeBreakdown['remoteEtherFee']; + const remoteDotFee = destination.feeBreakdown['remoteDotFee']; + const bridgeFeeInWei = destination.fee.amount; + + const beneficiaryHex = Ss58Addr.getPubKey(address) as string; + const entropy = new TextEncoder().encode( + `${rcv.parachainId}${sender}${tokenAddress}${beneficiaryHex}${amount}${Date.now()}` + ); + const topic = toHex(Blake2256(entropy)); + + const xcmInstructions = buildSnowbridgeInboundXcm({ + ethChainId: ETHEREUM_CHAIN_ID, + destinationParaId: rcv.parachainId, + tokenAddress: isNativeTransfer ? ETHER_TOKEN_ADDRESS : tokenAddress, + beneficiaryHex, + tokenAmount: amount, + remoteEtherFeeAmount: remoteEtherFee, + remoteDotFeeAmount: remoteDotFee, + topic, + }); + + // SCALE-encode XCM as VersionedXcm.V5 bytes + const codecs = await getXcmCodecs(); + const xcmBytes = toHex( + codecs.message.enc(XcmVersionedXcm.V5(xcmInstructions)) + ); + const assets: `0x${string}`[] = []; + + if (!isNativeTransfer) { + assets.push( + encodeAbiParameters( + [{ type: 'uint8' }, { type: 'address' }, { type: 'uint128' }], + [0, tokenAddress as `0x${string}`, amount] + ) + ); + } + + const claimerVersioned = codecs.location.enc( + XcmVersionedLocation.V5({ + parents: 0, + interior: XcmV5Junctions.X1( + XcmV5Junction.AccountId32({ + id: FixedSizeBinary.fromHex(beneficiaryHex), + network: undefined, + }) + ), + }) + ); + const claimerBytes = toHex(claimerVersioned.slice(1)); + + return new ContractConfig({ + abi: Abi.Snowbridge, + address: ctxSb.getGateway(), + args: [xcmBytes, assets, claimerBytes, executionFee, relayerFee], + value: isNativeTransfer ? bridgeFeeInWei + amount : bridgeFeeInWei, + func: 'v2_sendMessage', + module: 'Snowbridge', + }); + }, +}); + +export const Snowbridge = () => { + return { + v2SendMessage, + }; +}; diff --git a/packages/xc-cfg/src/builders/extrinsics/xcm/builder/Snowbridge.ts b/packages/xc-cfg/src/builders/extrinsics/xcm/builder/Snowbridge.ts new file mode 100644 index 00000000..abe90a13 --- /dev/null +++ b/packages/xc-cfg/src/builders/extrinsics/xcm/builder/Snowbridge.ts @@ -0,0 +1,527 @@ +import { + ExtrinsicConfigBuilderParams, + multiloc, + Parachain, +} from '@galacticcouncil/xc-core'; + +import { + XcmV3MultiassetFungibility, + XcmV5AssetFilter, + XcmV5Instruction, + XcmV5Junctions, + XcmV5Junction, + XcmV5NetworkId, + XcmV5WildAsset, + XcmVersionedXcm, +} from '@galacticcouncil/descriptors'; + +import { FixedSizeBinary } from 'polkadot-api'; +import { getSs58AddressInfo } from '@polkadot-api/substrate-bindings'; +import { toHex } from '@polkadot-api/utils'; + +import { DOT_LOCATION, ASSET_HUB_ID, ETHEREUM_CHAIN_ID } from './const'; + +const ETHER_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; + +/** + * Location helpers for Snowbridge V2 XCM + */ +export const etherLocation = (ethChainId: number) => ({ + parents: 2, + interior: XcmV5Junctions.X1( + XcmV5Junction.GlobalConsensus( + XcmV5NetworkId.Ethereum({ chain_id: BigInt(ethChainId) }) + ) + ), +}); + +export const erc20Location = (ethChainId: number, tokenAddress: string) => ({ + parents: 2, + interior: XcmV5Junctions.X2([ + XcmV5Junction.GlobalConsensus( + XcmV5NetworkId.Ethereum({ chain_id: BigInt(ethChainId) }) + ), + XcmV5Junction.AccountKey20({ + key: FixedSizeBinary.fromHex(tokenAddress), + network: undefined, + }), + ]), +}); + +// --------------------------------------------------------------------------- +// Inbound: Ethereum → Polkadot (used by Snowbridge Gateway v2_sendMessage) +// --------------------------------------------------------------------------- + +export type SnowbridgeInboundXcmParams = { + ethChainId: number; + destinationParaId: number; + tokenAddress: string; + beneficiaryHex: string; + tokenAmount: bigint; + remoteEtherFeeAmount: bigint; + remoteDotFeeAmount?: bigint; + topic: string; +}; + +/** + * Builds the XCM V5 program passed to `v2_sendMessage` for + * transferring ERC20/ETH from Ethereum to a Polkadot parachain. + * + * If `remoteDotFeeAmount` is provided, an ExchangeAsset instruction + * is prepended to swap Ether for DOT on AssetHub (for parachains + * that require DOT as the fee asset). + * + * Flow: + * 1. (Optional) ExchangeAsset: swap Ether for DOT on AssetHub + * 2. InitiateTransfer to destination parachain + * - remote_fees: ReserveDeposit(ether or DOT) for destination execution + * - assets: ReserveDeposit(token) for the actual transfer + * - remote_xcm: RefundSurplus + DepositAsset to beneficiary + * 3. RefundSurplus (reclaim unused ether on Asset Hub) + * 4. DepositAsset remaining ether to beneficiary on Asset Hub + * 5. SetTopic for tracking + */ +export function buildSnowbridgeInboundXcm( + params: SnowbridgeInboundXcmParams +): XcmV5Instruction[] { + const { + ethChainId, + destinationParaId, + tokenAddress, + beneficiaryHex, + tokenAmount, + remoteEtherFeeAmount, + remoteDotFeeAmount, + topic, + } = params; + + const ether = etherLocation(ethChainId); + const token = + tokenAddress === ETHER_TOKEN_ADDRESS + ? ether + : erc20Location(ethChainId, tokenAddress); + + const isEthAddress = + beneficiaryHex.length === 42 && beneficiaryHex.startsWith('0x'); + const beneficiaryJunction = isEthAddress + ? XcmV5Junction.AccountKey20({ + key: FixedSizeBinary.fromHex(beneficiaryHex), + network: undefined, + }) + : XcmV5Junction.AccountId32({ + id: FixedSizeBinary.fromHex(beneficiaryHex), + network: undefined, + }); + + const beneficiary = { + parents: 0, + interior: XcmV5Junctions.X1(beneficiaryJunction), + }; + + const useDotFees = + remoteDotFeeAmount !== undefined && remoteDotFeeAmount > 0n; + + const remoteFeeFilter = useDotFees + ? XcmV5AssetFilter.Wild( + XcmV5WildAsset.AllOf({ + id: DOT_LOCATION, + fun: { type: 'Fungible' as const, value: undefined }, + }) + ) + : XcmV5AssetFilter.Definite([ + { + id: ether, + fun: XcmV3MultiassetFungibility.Fungible(remoteEtherFeeAmount), + }, + ]); + + const instructions: XcmV5Instruction[] = []; + + // Swap Ether for DOT on AssetHub if destination needs DOT fees + if (useDotFees) { + instructions.push( + XcmV5Instruction.ExchangeAsset({ + give: XcmV5AssetFilter.Definite([ + { + id: ether, + fun: XcmV3MultiassetFungibility.Fungible(remoteEtherFeeAmount), + }, + ]), + want: [ + { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(remoteDotFeeAmount), + }, + ], + maximal: true, + }) + ); + } + + instructions.push( + XcmV5Instruction.InitiateTransfer({ + destination: { + parents: 1, + interior: XcmV5Junctions.X1(XcmV5Junction.Parachain(destinationParaId)), + }, + remote_fees: { + type: 'ReserveDeposit', + value: remoteFeeFilter, + }, + preserve_origin: false, + assets: [ + { + type: 'ReserveDeposit', + value: XcmV5AssetFilter.Definite([ + { + id: token, + fun: XcmV3MultiassetFungibility.Fungible(tokenAmount), + }, + ]), + }, + ], + remote_xcm: [ + XcmV5Instruction.RefundSurplus(), + XcmV5Instruction.DepositAsset({ + assets: XcmV5AssetFilter.Wild(XcmV5WildAsset.AllCounted(3)), + beneficiary, + }), + XcmV5Instruction.SetTopic(FixedSizeBinary.fromHex(topic)), + ], + }), + XcmV5Instruction.RefundSurplus(), + XcmV5Instruction.DepositAsset({ + assets: XcmV5AssetFilter.Wild( + XcmV5WildAsset.AllOf({ + id: ether, + fun: { type: 'Fungible' as const, value: undefined }, + }) + ), + beneficiary, + }), + XcmV5Instruction.SetTopic(FixedSizeBinary.fromHex(topic)) + ); + + return instructions; +} + +// --------------------------------------------------------------------------- +// Outbound: Polkadot → Ethereum (used by polkadotXcm.execute) +// --------------------------------------------------------------------------- + +export type SnowbridgeOutboundXcmParams = { + tokenAddress: string; + senderPubKey: string; + beneficiaryHex: string; + tokenAmount: bigint; + sourceExecutionFee: bigint; + dotRemoteFee: bigint; + dotToEtherSwapAmount: bigint; + etherFeeAmount: bigint; + topic: string; +}; + +/** + * Builds the V5 XCM program for `polkadotXcm.execute` to transfer + * ERC20/ETH from a Polkadot parachain to Ethereum via Snowbridge V2. + * + * 3-leg program: + * + * Leg 1 — Source parachain: + * WithdrawAsset, PayFees, SetAppendix (error recovery), InitiateTransfer → AssetHub + * + * Leg 2 — AssetHub (remote_xcm): + * SetAppendix (error recovery), ExchangeAsset (DOT→Ether), InitiateTransfer → Ethereum + * + * Leg 3 — Ethereum (innermost remote_xcm): + * DepositAsset to beneficiary, SetTopic + */ +export function buildSnowbridgeOutboundXcm( + params: SnowbridgeOutboundXcmParams +): XcmV5Instruction[] { + const { + tokenAddress, + senderPubKey, + beneficiaryHex, + tokenAmount, + sourceExecutionFee, + dotRemoteFee, + dotToEtherSwapAmount, + etherFeeAmount, + topic, + } = params; + + const ether = etherLocation(ETHEREUM_CHAIN_ID); + const isNativeEther = tokenAddress === ETHER_TOKEN_ADDRESS; + const token = isNativeEther + ? ether + : erc20Location(ETHEREUM_CHAIN_ID, tokenAddress); + + const topicBin = FixedSizeBinary.fromHex(topic); + + const sender = { + parents: 0, + interior: XcmV5Junctions.X1( + XcmV5Junction.AccountId32({ + id: FixedSizeBinary.fromHex(senderPubKey), + network: undefined, + }) + ), + }; + + const ethBeneficiary = { + parents: 0, + interior: XcmV5Junctions.X1( + XcmV5Junction.AccountKey20({ + key: FixedSizeBinary.fromHex(beneficiaryHex), + network: undefined, + }) + ), + }; + + const ethereumDest = { + parents: 2, + interior: XcmV5Junctions.X1( + XcmV5Junction.GlobalConsensus( + XcmV5NetworkId.Ethereum({ chain_id: BigInt(ETHEREUM_CHAIN_ID) }) + ) + ), + }; + + // --- Leg 3: Ethereum --- + const ethereumXcm = [ + XcmV5Instruction.DepositAsset({ + assets: XcmV5AssetFilter.Wild(XcmV5WildAsset.AllCounted(3)), + beneficiary: ethBeneficiary, + }), + XcmV5Instruction.SetTopic(topicBin), + ]; + + // --- Leg 2: AssetHub --- + const assetHubXcm: XcmV5Instruction[] = [ + // Error recovery on AssetHub: return assets to sender + XcmV5Instruction.SetAppendix([ + XcmV5Instruction.SetHints({ + hints: [{ type: 'AssetClaimer', value: { location: sender } }], + }), + XcmV5Instruction.RefundSurplus(), + XcmV5Instruction.DepositAsset({ + assets: XcmV5AssetFilter.Wild(XcmV5WildAsset.All()), + beneficiary: sender, + }), + ]), + ]; + + // Swap DOT for Ether on AssetHub DEX + if (!isNativeEther) { + assetHubXcm.push( + XcmV5Instruction.ExchangeAsset({ + give: XcmV5AssetFilter.Definite([ + { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(dotToEtherSwapAmount), + }, + ]), + want: [ + { + id: ether, + fun: XcmV3MultiassetFungibility.Fungible(etherFeeAmount), + }, + ], + maximal: false, + }) + ); + } + + const bridgeAssets = isNativeEther + ? [ + { + type: 'ReserveWithdraw' as const, + value: XcmV5AssetFilter.Wild( + XcmV5WildAsset.AllOf({ + id: ether, + fun: { type: 'Fungible' as const, value: undefined }, + }) + ), + }, + ] + : [ + { + type: 'ReserveWithdraw' as const, + value: XcmV5AssetFilter.Wild( + XcmV5WildAsset.AllOf({ + id: token, + fun: { type: 'Fungible' as const, value: undefined }, + }) + ), + }, + ]; + + const bridgeRemoteFees = isNativeEther + ? XcmV5AssetFilter.Definite([ + { + id: ether, + fun: XcmV3MultiassetFungibility.Fungible(etherFeeAmount), + }, + ]) + : XcmV5AssetFilter.Wild( + XcmV5WildAsset.AllOf({ + id: ether, + fun: { type: 'Fungible' as const, value: undefined }, + }) + ); + + assetHubXcm.push( + // Bridge to Ethereum + XcmV5Instruction.InitiateTransfer({ + destination: ethereumDest, + remote_fees: { + type: 'ReserveWithdraw', + value: bridgeRemoteFees, + }, + preserve_origin: true, + assets: bridgeAssets, + remote_xcm: ethereumXcm, + }), + XcmV5Instruction.SetTopic(topicBin) + ); + + // Total DOT withdrawn: PayFees budget + remote_fees on AH + swap DOT + const totalDot = sourceExecutionFee + dotRemoteFee + dotToEtherSwapAmount; + + const withdrawAssets = [ + { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(totalDot), + }, + { + id: token, + fun: XcmV3MultiassetFungibility.Fungible(tokenAmount), + }, + ]; + + const sourceXcmFeeAssets: typeof withdrawAssets = [ + { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(sourceExecutionFee), + }, + ]; + + // Assets forwarded to AssetHub via InitiateTransfer: + const tokenAsset = { + type: 'ReserveWithdraw' as const, + value: XcmV5AssetFilter.Wild( + XcmV5WildAsset.AllOf({ + id: token, + fun: { type: 'Fungible' as const, value: undefined }, + }) + ), + }; + + // For ERC20 transfers, include DOT for the swap as a separate asset. + // This DOT lands in holding on AssetHub (not the fee register), + // so ExchangeAsset can swap it for Ether. + const initiateAssets = + !isNativeEther && dotToEtherSwapAmount > 0n + ? [ + { + type: 'ReserveWithdraw' as const, + value: XcmV5AssetFilter.Definite([ + { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(dotToEtherSwapAmount), + }, + ]), + }, + tokenAsset, + ] + : [tokenAsset]; + + return [ + XcmV5Instruction.WithdrawAsset(withdrawAssets), + XcmV5Instruction.PayFees({ + asset: sourceXcmFeeAssets[0], + }), + // Error recovery on source: return assets to sender + XcmV5Instruction.SetAppendix([ + XcmV5Instruction.SetHints({ + hints: [{ type: 'AssetClaimer', value: { location: sender } }], + }), + XcmV5Instruction.RefundSurplus(), + XcmV5Instruction.DepositAsset({ + assets: XcmV5AssetFilter.Wild(XcmV5WildAsset.All()), + beneficiary: sender, + }), + ]), + // Transfer to AssetHub + XcmV5Instruction.InitiateTransfer({ + destination: { + parents: 1, + interior: XcmV5Junctions.X1(XcmV5Junction.Parachain(ASSET_HUB_ID)), + }, + remote_fees: { + type: 'ReserveWithdraw', + value: XcmV5AssetFilter.Definite([ + { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(dotRemoteFee), + }, + ]), + }, + preserve_origin: true, + assets: initiateAssets, + remote_xcm: assetHubXcm, + }), + XcmV5Instruction.SetTopic(topicBin), + ]; +} + +// --------------------------------------------------------------------------- +// Message builder for polkadotXcm.execute (used in route templates) +// --------------------------------------------------------------------------- + +export function snowbridgeOutboundMessage( + params: ExtrinsicConfigBuilderParams +) { + const { address, amount, asset, sender, source, destination, messageId } = + params; + const ctx = source.chain as Parachain; + + const senderInfo = getSs58AddressInfo(sender); + if (!senderInfo.isValid) { + throw new Error(`Invalid SS58 address: ${sender}`); + } + const senderPubKey = toHex(senderInfo.publicKey); + + const assetLocation = ctx.getAssetXcmLocation(asset); + const erc20KeyObj = assetLocation + ? multiloc.findNestedKey(assetLocation, 'key') + : undefined; + const tokenAddress = erc20KeyObj + ? (erc20KeyObj.key as string) + : ETHER_TOKEN_ADDRESS; + + const feeBreakdown = destination.feeBreakdown; + const sourceExecutionFee = feeBreakdown['sourceExecutionFee'] ?? 0n; + const dotRemoteFee = feeBreakdown['dotRemoteFee'] ?? 0n; + const dotToEtherSwapAmount = feeBreakdown['dotToEtherSwapAmount'] ?? 0n; + const etherFeeAmount = feeBreakdown['etherFeeAmount'] ?? 0n; + + const topic = + messageId ?? + '0x0000000000000000000000000000000000000000000000000000000000000000'; + + const xcmInstructions = buildSnowbridgeOutboundXcm({ + tokenAddress, + senderPubKey, + beneficiaryHex: address, + tokenAmount: amount, + sourceExecutionFee, + dotRemoteFee, + dotToEtherSwapAmount, + etherFeeAmount, + topic, + }); + + return XcmVersionedXcm.V5(xcmInstructions); +} diff --git a/packages/xc-cfg/src/builders/extrinsics/xcm/builder/buildParaERC20ReceivedV5.ts b/packages/xc-cfg/src/builders/extrinsics/xcm/builder/buildParaERC20ReceivedV5.ts new file mode 100644 index 00000000..952fd263 --- /dev/null +++ b/packages/xc-cfg/src/builders/extrinsics/xcm/builder/buildParaERC20ReceivedV5.ts @@ -0,0 +1,79 @@ +import { + XcmV3MultiassetFungibility, + XcmV5AssetFilter, + XcmV5Instruction, + XcmV5Junctions, + XcmV5Junction, + XcmV5WildAsset, +} from '@galacticcouncil/descriptors'; +import { Asset, Parachain } from '@galacticcouncil/xc-core'; + +import { FixedSizeBinary } from 'polkadot-api'; + +import { ACCOUNT_ID_32, AMOUNT_MAX, DOT_LOCATION, TOPIC } from './const'; + +import { getExtrinsicAssetLocation, locationOrError } from '../utils'; +import { XcmVersion } from '../types'; + +/** + * Builds the V5 XCM program that InitiateTransfer generates on the + * destination parachain (e.g. Hydration). Used for weight/fee estimation. + * + * This matches what actually executes on the destination: + * 1. ReserveAssetDeposited(DOT) + * 2. PayFees(DOT) + * 3. ReserveAssetDeposited(token) + * 4. ClearOrigin + * 5. RefundSurplus + * 6. DepositAsset + * 7. SetTopic + */ +export function buildParaERC20ReceivedV5( + asset: Asset, + chain: Parachain +): XcmV5Instruction[] { + const version = XcmVersion.v5; + const transferAssetLocation = getExtrinsicAssetLocation( + locationOrError(chain, asset), + version + ); + + const topic = FixedSizeBinary.fromHex(TOPIC); + const beneficiary = { + parents: 0, + interior: XcmV5Junctions.X1( + XcmV5Junction.AccountId32({ + id: FixedSizeBinary.fromHex(ACCOUNT_ID_32), + network: undefined, + }) + ), + }; + + return [ + XcmV5Instruction.ReserveAssetDeposited([ + { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(AMOUNT_MAX), + }, + ]), + XcmV5Instruction.PayFees({ + asset: { + id: DOT_LOCATION, + fun: XcmV3MultiassetFungibility.Fungible(AMOUNT_MAX), + }, + }), + XcmV5Instruction.ReserveAssetDeposited([ + { + id: transferAssetLocation, + fun: XcmV3MultiassetFungibility.Fungible(AMOUNT_MAX), + }, + ]), + XcmV5Instruction.ClearOrigin(), + XcmV5Instruction.RefundSurplus(), + XcmV5Instruction.DepositAsset({ + assets: XcmV5AssetFilter.Wild(XcmV5WildAsset.AllCounted(3)), + beneficiary, + }), + XcmV5Instruction.SetTopic(topic), + ]; +} \ No newline at end of file diff --git a/packages/xc-cfg/src/builders/extrinsics/xcm/builder/index.ts b/packages/xc-cfg/src/builders/extrinsics/xcm/builder/index.ts index 21c987d4..3837bd1f 100644 --- a/packages/xc-cfg/src/builders/extrinsics/xcm/builder/index.ts +++ b/packages/xc-cfg/src/builders/extrinsics/xcm/builder/index.ts @@ -3,3 +3,5 @@ export * from './const'; export * from './buildParaERC20Received'; export * from './buildERC20TransferFromPara'; export * from './buildLimitedReserveTransfer'; +export * from './buildParaERC20ReceivedV5'; +export * from './Snowbridge'; diff --git a/packages/xc-cfg/src/builders/extrinsics/xcm/polkadotXcm.ts b/packages/xc-cfg/src/builders/extrinsics/xcm/polkadotXcm.ts index 98198e2d..44f55225 100644 --- a/packages/xc-cfg/src/builders/extrinsics/xcm/polkadotXcm.ts +++ b/packages/xc-cfg/src/builders/extrinsics/xcm/polkadotXcm.ts @@ -1,10 +1,13 @@ import { ExtrinsicConfig, ExtrinsicConfigBuilder, + ExtrinsicConfigBuilderParams, Parachain, } from '@galacticcouncil/xc-core'; import { big } from '@galacticcouncil/common'; +export type XcmMessageBuilder = (params: ExtrinsicConfigBuilderParams) => any; + import { isSnowbridgeTransfer, toAsset, @@ -26,6 +29,8 @@ import { } from './utils'; import { XcmTransferType, XcmVersion } from './types'; +import { snowbridgeOutboundMessage } from './builder/Snowbridge'; + const pallet = 'PolkadotXcm'; const limitedReserveTransferAssets = (): ExtrinsicConfigBuilder => ({ @@ -436,6 +441,44 @@ const send = () => { }; }; +function execute( + messageBuilder: XcmMessageBuilder +): ExtrinsicConfigBuilder; +function execute(): { viaSnowbridge: () => ExtrinsicConfigBuilder }; +function execute(messageBuilder?: XcmMessageBuilder) { + const buildExecute = ( + msgBuilder: XcmMessageBuilder + ): ExtrinsicConfigBuilder => ({ + build: async (params) => { + const message = msgBuilder(params); + + const func = 'execute'; + return new ExtrinsicConfig({ + module: pallet, + func, + getTx: (client) => { + return client.getUnsafeApi().tx[pallet][func]({ + message, + max_weight: { + ref_time: 20_000_000_000n, + proof_size: 500_000n, + }, + }); + }, + }); + }, + }); + + if (messageBuilder) { + return buildExecute(messageBuilder); + } + + return { + viaSnowbridge: (): ExtrinsicConfigBuilder => + buildExecute(snowbridgeOutboundMessage), + }; +}; + export const polkadotXcm = () => { return { limitedReserveTransferAssets, @@ -443,6 +486,7 @@ export const polkadotXcm = () => { reserveTransferAssets, transferAssets, transferAssetsUsingTypeAndThen, + execute, send, }; }; diff --git a/packages/xc-cfg/src/clients/base.ts b/packages/xc-cfg/src/clients/base.ts index 4968dade..a4a32327 100644 --- a/packages/xc-cfg/src/clients/base.ts +++ b/packages/xc-cfg/src/clients/base.ts @@ -5,6 +5,7 @@ import { encodeLocation } from '@galacticcouncil/common'; import { hub, XcmV4Instruction, + XcmV5Instruction, XcmVersionedAssetId, } from '@galacticcouncil/descriptors'; @@ -68,4 +69,37 @@ export class BaseClient { return BigInt(feeInAsset.value); } + + async calculateDestinationFeeV5( + xcm: XcmV5Instruction[], + asset: Asset + ): Promise { + const weight = await this.refApi.apis.XcmPaymentApi.query_xcm_weight({ + type: 'V5', + value: xcm, + }); + + if (!weight.success) { + throw Error(`Can't query XCM weight (V5).`); + } + + const feeAssetLocation = this.chain.getAssetXcmLocation(asset); + if (!feeAssetLocation) { + throw Error(`Can't get XCM location for asset ${asset.originSymbol}`); + } + + const encodedLocation = encodeLocation(feeAssetLocation); + + const feeInAsset = + await this.refApi.apis.XcmPaymentApi.query_weight_to_asset_fee( + weight.value, + XcmVersionedAssetId.V4(encodedLocation) + ); + + if (!feeInAsset.success) { + throw Error(`Can't convert weight to fee in ${asset.originSymbol}`); + } + + return BigInt(feeInAsset.value); + } } diff --git a/packages/xc-cfg/src/clients/chain/assethub.ts b/packages/xc-cfg/src/clients/chain/assethub.ts index 5481e0db..29eb5e82 100644 --- a/packages/xc-cfg/src/clients/chain/assethub.ts +++ b/packages/xc-cfg/src/clients/chain/assethub.ts @@ -12,6 +12,8 @@ import { encodeLocation } from '@galacticcouncil/common'; import { Twox128, u128 } from '@polkadot-api/substrate-bindings'; import { toHex } from '@polkadot-api/utils'; +type AssetLocation = { parents: number; interior: any }; + import { BaseClient } from '../base'; export class AssethubClient extends BaseClient { @@ -69,10 +71,10 @@ export class AssethubClient extends BaseClient { async getBridgeDeliveryFee( options = { - defaultFee: 2_750_872_500_000n, + defaultFee: 150_000_000_000n, } ): Promise { - const keyBytes = new TextEncoder().encode(':BridgeHubEthereumBaseFee:'); + const keyBytes = new TextEncoder().encode(':BridgeHubEthereumBaseFeeV2:'); const feeStorageKey = toHex(Twox128(keyBytes)); const feeStorageItem = await this.client._request( 'state_getStorage', @@ -130,4 +132,58 @@ export class AssethubClient extends BaseClient { } throw Error(`Can't parse delivery fee.`); } + + async quoteDotToEther( + etherLocation: AssetLocation, + dotAmount: bigint + ): Promise { + const dotLocation = { parents: 1, interior: XcmV5Junctions.Here() }; + const result = + await this.api().apis.AssetConversionApi.quote_price_exact_tokens_for_tokens( + dotLocation, + etherLocation, + dotAmount, + true + ); + if (result === undefined) { + throw Error(`Can't quote DOT to Ether conversion.`); + } + return result; + } + + async quoteEtherForDot( + etherLocation: AssetLocation, + dotAmount: bigint + ): Promise { + const dotLocation = { parents: 1, interior: XcmV5Junctions.Here() }; + const result = + await this.api().apis.AssetConversionApi.quote_price_tokens_for_exact_tokens( + etherLocation, + dotLocation, + dotAmount, + true + ); + if (result === undefined) { + throw Error(`Can't quote Ether for DOT conversion.`); + } + return result; + } + + async quoteDotForExactEther( + etherLocation: AssetLocation, + etherAmount: bigint + ): Promise { + const dotLocation = { parents: 1, interior: XcmV5Junctions.Here() }; + const result = + await this.api().apis.AssetConversionApi.quote_price_tokens_for_exact_tokens( + dotLocation, + etherLocation, + etherAmount, + true + ); + if (result === undefined) { + throw Error(`Can't quote DOT for Ether conversion.`); + } + return result; + } } diff --git a/packages/xc-cfg/src/configs/evm/ethereum/index.ts b/packages/xc-cfg/src/configs/evm/ethereum/index.ts index 98e0b1a4..8d86c097 100644 --- a/packages/xc-cfg/src/configs/evm/ethereum/index.ts +++ b/packages/xc-cfg/src/configs/evm/ethereum/index.ts @@ -96,7 +96,7 @@ const toHydrationViaSnowbridge: AssetRoute[] = [ asset: eth, }, }, - contract: ContractBuilder().Snowbridge().sendToken(), + contract: ContractBuilder().Snowbridge().v2SendMessage(), tags: [Tag.Snowbridge], }), toHydrationViaSnowbridgeTemplate(aave, aave), diff --git a/packages/xc-cfg/src/configs/evm/ethereum/templates.ts b/packages/xc-cfg/src/configs/evm/ethereum/templates.ts index 04dd7ea4..698d4512 100644 --- a/packages/xc-cfg/src/configs/evm/ethereum/templates.ts +++ b/packages/xc-cfg/src/configs/evm/ethereum/templates.ts @@ -69,7 +69,7 @@ export function toHydrationViaSnowbridgeTemplate( asset: eth, }, }, - contract: ContractBuilder().Snowbridge().sendToken(), + contract: ContractBuilder().Snowbridge().v2SendMessage(), tags: [Tag.Snowbridge], }); } diff --git a/packages/xc-cfg/src/configs/polkadot/hydration/templates.ts b/packages/xc-cfg/src/configs/polkadot/hydration/templates.ts index 2577b0f7..5676eab8 100644 --- a/packages/xc-cfg/src/configs/polkadot/hydration/templates.ts +++ b/packages/xc-cfg/src/configs/polkadot/hydration/templates.ts @@ -280,9 +280,7 @@ export function viaSnowbridgeTemplate( isDestinationFeeSwapSupported, swapExtrinsicBuilder ).prior( - ExtrinsicBuilder().polkadotXcm().transferAssetsUsingTypeAndThen({ - transferType: XcmTransferType.DestinationReserve, - }) + ExtrinsicBuilder().polkadotXcm().execute().viaSnowbridge() ), tags: [Tag.Snowbridge], }); diff --git a/packages/xc-core/src/evm/abi/Snowbridge.ts b/packages/xc-core/src/evm/abi/Snowbridge.ts index e642d555..5071f326 100644 --- a/packages/xc-core/src/evm/abi/Snowbridge.ts +++ b/packages/xc-core/src/evm/abi/Snowbridge.ts @@ -1,69 +1,27 @@ export const SNOWBRIDGE = [ + { inputs: [], name: 'AgentAlreadyExists', type: 'error' }, + { inputs: [], name: 'ShouldNotReachHere', type: 'error' }, + { inputs: [], name: 'InvalidNetwork', type: 'error' }, + { inputs: [], name: 'InvalidAsset', type: 'error' }, + { inputs: [], name: 'InsufficientGasLimit', type: 'error' }, + { inputs: [], name: 'InvalidCommand', type: 'error' }, + { inputs: [], name: 'InsufficientValue', type: 'error' }, + { inputs: [], name: 'ExceededMaximumValue', type: 'error' }, + { inputs: [], name: 'TooManyAssets', type: 'error' }, { - inputs: [ - { internalType: 'address', name: 'beefyClient', type: 'address' }, - { internalType: 'address', name: 'agentExecutor', type: 'address' }, - { internalType: 'ParaID', name: 'bridgeHubParaID', type: 'uint32' }, - { internalType: 'bytes32', name: 'bridgeHubAgentID', type: 'bytes32' }, - { internalType: 'uint8', name: 'foreignTokenDecimals', type: 'uint8' }, - { - internalType: 'uint128', - name: 'destinationMaxTransferFee', - type: 'uint128', - }, - ], - stateMutability: 'nonpayable', - type: 'constructor', - }, - { inputs: [], name: 'AgentAlreadyCreated', type: 'error' }, - { inputs: [], name: 'AgentDoesNotExist', type: 'error' }, - { inputs: [], name: 'ChannelAlreadyCreated', type: 'error' }, - { inputs: [], name: 'ChannelDoesNotExist', type: 'error' }, - { inputs: [], name: 'Disabled', type: 'error' }, - { inputs: [], name: 'InsufficientEther', type: 'error' }, - { inputs: [], name: 'InvalidAgentExecutionPayload', type: 'error' }, - { inputs: [], name: 'InvalidChannelUpdate', type: 'error' }, - { inputs: [], name: 'InvalidCodeHash', type: 'error' }, - { inputs: [], name: 'InvalidConstructorParams', type: 'error' }, - { inputs: [], name: 'InvalidContract', type: 'error' }, - { inputs: [], name: 'InvalidNonce', type: 'error' }, - { inputs: [], name: 'InvalidProof', type: 'error' }, - { inputs: [], name: 'NativeTransferFailed', type: 'error' }, - { inputs: [], name: 'NotEnoughGas', type: 'error' }, - { - inputs: [ - { internalType: 'uint256', name: 'x', type: 'uint256' }, - { internalType: 'uint256', name: 'y', type: 'uint256' }, - ], - name: 'PRBMath_MulDiv18_Overflow', - type: 'error', - }, - { - inputs: [ - { internalType: 'uint256', name: 'x', type: 'uint256' }, - { internalType: 'uint256', name: 'y', type: 'uint256' }, - { internalType: 'uint256', name: 'denominator', type: 'uint256' }, - ], - name: 'PRBMath_MulDiv_Overflow', - type: 'error', - }, - { - inputs: [{ internalType: 'uint256', name: 'x', type: 'uint256' }], - name: 'PRBMath_UD60x18_Convert_Overflow', - type: 'error', - }, - { - inputs: [{ internalType: 'UD60x18', name: 'x', type: 'uint256' }], - name: 'PRBMath_UD60x18_Exp2_InputTooBig', - type: 'error', + inputs: [], + name: 'operatingMode', + outputs: [{ internalType: 'enum OperatingMode', name: '', type: 'uint8' }], + stateMutability: 'view', + type: 'function', }, { - inputs: [{ internalType: 'UD60x18', name: 'x', type: 'uint256' }], - name: 'PRBMath_UD60x18_Log_InputTooSmall', - type: 'error', + inputs: [{ internalType: 'bytes32', name: 'agentID', type: 'bytes32' }], + name: 'agentOf', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', }, - { inputs: [], name: 'TokenNotRegistered', type: 'error' }, - { inputs: [], name: 'Unauthorized', type: 'error' }, { anonymous: false, inputs: [ @@ -88,406 +46,127 @@ export const SNOWBRIDGE = [ inputs: [ { indexed: true, - internalType: 'ChannelID', - name: 'channelID', - type: 'bytes32', - }, - ], - name: 'ChannelCreated', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'ChannelID', - name: 'channelID', - type: 'bytes32', - }, - ], - name: 'ChannelUpdated', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: false, - internalType: 'address', - name: 'sender', - type: 'address', + internalType: 'uint64', + name: 'nonce', + type: 'uint64', }, { indexed: false, - internalType: 'uint256', - name: 'amount', - type: 'uint256', - }, - ], - name: 'Deposited', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, internalType: 'bytes32', - name: 'tokenID', + name: 'topic', type: 'bytes32', }, + { indexed: false, internalType: 'bool', name: 'success', type: 'bool' }, { indexed: false, - internalType: 'address', - name: 'token', - type: 'address', - }, - ], - name: 'ForeignTokenRegistered', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'ChannelID', - name: 'channelID', - type: 'bytes32', - }, - { indexed: false, internalType: 'uint64', name: 'nonce', type: 'uint64' }, - { - indexed: true, internalType: 'bytes32', - name: 'messageID', + name: 'rewardAddress', type: 'bytes32', }, - { indexed: false, internalType: 'bool', name: 'success', type: 'bool' }, ], name: 'InboundMessageDispatched', type: 'event', }, - { - anonymous: false, - inputs: [ - { - indexed: false, - internalType: 'enum OperatingMode', - name: 'mode', - type: 'uint8', - }, - ], - name: 'OperatingModeChanged', - type: 'event', - }, { anonymous: false, inputs: [ { indexed: true, - internalType: 'ChannelID', - name: 'channelID', - type: 'bytes32', + internalType: 'uint64', + name: 'nonce', + type: 'uint64', }, - { indexed: false, internalType: 'uint64', name: 'nonce', type: 'uint64' }, - { - indexed: true, - internalType: 'bytes32', - name: 'messageID', - type: 'bytes32', - }, - { indexed: false, internalType: 'bytes', name: 'payload', type: 'bytes' }, - ], - name: 'OutboundMessageAccepted', - type: 'event', - }, - { - anonymous: false, - inputs: [], - name: 'PricingParametersChanged', - type: 'event', - }, - { - anonymous: false, - inputs: [ { indexed: false, - internalType: 'address', - name: 'token', - type: 'address', + internalType: 'uint256', + name: 'index', + type: 'uint256', }, ], - name: 'TokenRegistrationSent', + name: 'CommandFailed', type: 'event', }, { anonymous: false, inputs: [ { - indexed: true, - internalType: 'address', - name: 'token', - type: 'address', - }, - { - indexed: true, - internalType: 'address', - name: 'sender', - type: 'address', - }, - { - indexed: true, - internalType: 'ParaID', - name: 'destinationChain', - type: 'uint32', + indexed: false, + internalType: 'uint64', + name: 'nonce', + type: 'uint64', }, { components: [ - { internalType: 'enum Kind', name: 'kind', type: 'uint8' }, - { internalType: 'bytes', name: 'data', type: 'bytes' }, + { + internalType: 'address', + name: 'origin', + type: 'address', + }, + { + components: [ + { internalType: 'uint8', name: 'kind', type: 'uint8' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + internalType: 'struct Asset[]', + name: 'assets', + type: 'tuple[]', + }, + { + components: [ + { internalType: 'uint8', name: 'kind', type: 'uint8' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + ], + internalType: 'struct Xcm', + name: 'xcm', + type: 'tuple', + }, + { + internalType: 'bytes', + name: 'claimer', + type: 'bytes', + }, + { + internalType: 'uint128', + name: 'value', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'executionFee', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'relayerFee', + type: 'uint128', + }, ], indexed: false, - internalType: 'struct MultiAddress', - name: 'destinationAddress', + internalType: 'struct Payload', + name: 'payload', type: 'tuple', }, - { - indexed: false, - internalType: 'uint128', - name: 'amount', - type: 'uint128', - }, ], - name: 'TokenSent', - type: 'event', - }, - { - anonymous: false, - inputs: [], - name: 'TokenTransferFeesChanged', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'address', - name: 'implementation', - type: 'address', - }, - ], - name: 'Upgraded', + name: 'OutboundMessageAccepted', type: 'event', }, - { - inputs: [], - name: 'AGENT_EXECUTOR', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'BEEFY_CLIENT', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'agentExecute', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes32', name: 'agentID', type: 'bytes32' }], - name: 'agentOf', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'ChannelID', name: 'channelID', type: 'bytes32' }], - name: 'channelNoncesOf', - outputs: [ - { internalType: 'uint64', name: '', type: 'uint64' }, - { internalType: 'uint64', name: '', type: 'uint64' }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'ChannelID', name: 'channelID', type: 'bytes32' }], - name: 'channelOperatingModeOf', - outputs: [{ internalType: 'enum OperatingMode', name: '', type: 'uint8' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'createAgent', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'createChannel', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [], - name: 'depositEther', - outputs: [], - stateMutability: 'payable', - type: 'function', - }, - { - inputs: [], - name: 'implementation', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: '', type: 'bytes' }], - name: 'initialize', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [{ internalType: 'address', name: 'token', type: 'address' }], - name: 'isTokenRegistered', - outputs: [{ internalType: 'bool', name: '', type: 'bool' }], - stateMutability: 'view', - type: 'function', - }, { inputs: [ - { internalType: 'ChannelID', name: 'channelID', type: 'bytes32' }, - { internalType: 'bytes', name: 'data', type: 'bytes' }, - ], - name: 'mintForeignToken', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [], - name: 'operatingMode', - outputs: [{ internalType: 'enum OperatingMode', name: '', type: 'uint8' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'pricingParameters', - outputs: [ - { internalType: 'UD60x18', name: '', type: 'uint256' }, - { internalType: 'uint128', name: '', type: 'uint128' }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'address', name: 'token', type: 'address' }], - name: 'queryForeignTokenID', - outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'quoteRegisterTokenFee', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { internalType: 'address', name: 'token', type: 'address' }, - { internalType: 'ParaID', name: 'destinationChain', type: 'uint32' }, - { internalType: 'uint128', name: 'destinationFee', type: 'uint128' }, - ], - name: 'quoteSendTokenFee', - outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'registerForeignToken', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [{ internalType: 'address', name: 'token', type: 'address' }], - name: 'registerToken', - outputs: [], - stateMutability: 'payable', - type: 'function', - }, - { - inputs: [ - { internalType: 'address', name: 'token', type: 'address' }, - { internalType: 'ParaID', name: 'destinationChain', type: 'uint32' }, { components: [ - { internalType: 'enum Kind', name: 'kind', type: 'uint8' }, - { internalType: 'bytes', name: 'data', type: 'bytes' }, - ], - internalType: 'struct MultiAddress', - name: 'destinationAddress', - type: 'tuple', - }, - { internalType: 'uint128', name: 'destinationFee', type: 'uint128' }, - { internalType: 'uint128', name: 'amount', type: 'uint128' }, - ], - name: 'sendToken', - outputs: [], - stateMutability: 'payable', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'setOperatingMode', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'setPricingParameters', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'setTokenTransferFees', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - components: [ - { internalType: 'ChannelID', name: 'channelID', type: 'bytes32' }, + { internalType: 'bytes32', name: 'origin', type: 'bytes32' }, { internalType: 'uint64', name: 'nonce', type: 'uint64' }, - { internalType: 'enum Command', name: 'command', type: 'uint8' }, - { internalType: 'bytes', name: 'params', type: 'bytes' }, - { internalType: 'uint64', name: 'maxDispatchGas', type: 'uint64' }, - { internalType: 'uint256', name: 'maxFeePerGas', type: 'uint256' }, - { internalType: 'uint256', name: 'reward', type: 'uint256' }, - { internalType: 'bytes32', name: 'id', type: 'bytes32' }, + { internalType: 'bytes32', name: 'topic', type: 'bytes32' }, + { + components: [ + { internalType: 'uint8', name: 'kind', type: 'uint8' }, + { internalType: 'uint64', name: 'gas', type: 'uint64' }, + { internalType: 'bytes', name: 'payload', type: 'bytes' }, + ], + internalType: 'struct Command[]', + name: 'commands', + type: 'tuple[]', + }, ], internalType: 'struct InboundMessage', name: 'message', @@ -567,38 +246,64 @@ export const SNOWBRIDGE = [ name: 'headerProof', type: 'tuple', }, + { internalType: 'bytes32', name: 'rewardAddress', type: 'bytes32' }, ], - name: 'submitV1', + name: 'v2_submit', outputs: [], stateMutability: 'nonpayable', type: 'function', }, { - inputs: [{ internalType: 'bytes32', name: 'tokenID', type: 'bytes32' }], - name: 'tokenAddressOf', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', + inputs: [ + { internalType: 'bytes', name: 'xcm', type: 'bytes' }, + { internalType: 'bytes[]', name: 'assets', type: 'bytes[]' }, + { internalType: 'bytes', name: 'claimer', type: 'bytes' }, + { internalType: 'uint128', name: 'executionFee', type: 'uint128' }, + { internalType: 'uint128', name: 'relayerFee', type: 'uint128' }, + ], + name: 'v2_sendMessage', + outputs: [], + stateMutability: 'payable', type: 'function', }, { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'transferNativeToken', + inputs: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'uint8', name: 'network', type: 'uint8' }, + { internalType: 'uint128', name: 'executionFee', type: 'uint128' }, + { internalType: 'uint128', name: 'relayerFee', type: 'uint128' }, + ], + name: 'v2_registerToken', outputs: [], - stateMutability: 'nonpayable', + stateMutability: 'payable', type: 'function', }, { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'updateChannel', + inputs: [{ internalType: 'bytes32', name: 'id', type: 'bytes32' }], + name: 'v2_createAgent', outputs: [], stateMutability: 'nonpayable', type: 'function', }, { - inputs: [{ internalType: 'bytes', name: 'data', type: 'bytes' }], - name: 'upgrade', - outputs: [], - stateMutability: 'nonpayable', + inputs: [], + name: 'v2_outboundNonce', + outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint64', name: 'nonce', type: 'uint64' }], + name: 'v2_isDispatched', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'isTokenRegistered', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', type: 'function', }, ] as const; diff --git a/packages/xc-core/src/utils/codec.ts b/packages/xc-core/src/utils/codec.ts new file mode 100644 index 00000000..5d961608 --- /dev/null +++ b/packages/xc-core/src/utils/codec.ts @@ -0,0 +1,12 @@ +import { getTypedCodecs } from 'polkadot-api'; +import { hub } from '@galacticcouncil/descriptors'; + +type HubCodecs = Awaited>>; + +let cached: HubCodecs | null = null; + +export async function getHubCodecs(): Promise { + if (cached) return cached; + cached = await getTypedCodecs(hub); + return cached; +} diff --git a/packages/xc-core/src/utils/index.ts b/packages/xc-core/src/utils/index.ts index 6c72f6cd..bc43df86 100644 --- a/packages/xc-core/src/utils/index.ts +++ b/packages/xc-core/src/utils/index.ts @@ -1,3 +1,4 @@ export * as addr from './address'; +export * as codec from './codec'; export * as mrl from './mrl'; export * as multiloc from './multilocation'; diff --git a/packages/xc-core/src/utils/mrl.ts b/packages/xc-core/src/utils/mrl.ts index ad3d776f..f66b0d82 100644 --- a/packages/xc-core/src/utils/mrl.ts +++ b/packages/xc-core/src/utils/mrl.ts @@ -1,9 +1,4 @@ -import { - Codec, - getTypedCodecs, - FixedSizeBinary, - AccountId, -} from 'polkadot-api'; +import { Codec, FixedSizeBinary, AccountId } from 'polkadot-api'; import { Struct, Enum } from 'scale-ts'; @@ -12,9 +7,10 @@ import { XcmVersionedLocation, XcmV5Junction, XcmV5Junctions, - hub, } from '@galacticcouncil/descriptors'; +import { getHubCodecs } from './codec'; + import { Parachain } from '../chain'; type XcmRoutingUserAction = { @@ -35,7 +31,7 @@ interface EncodedPayload { async function getVersionedUserActionCodec(): Promise< Codec > { - const codecs = await getTypedCodecs(hub); + const codecs = await getHubCodecs(); const destCodec = codecs.tx.PolkadotXcm.send.inner.dest; const XcmRoutingUserActionCodec = Struct({ diff --git a/packages/xc-sdk/src/platforms/evm/EvmPlatform.ts b/packages/xc-sdk/src/platforms/evm/EvmPlatform.ts index afb55979..ab03ebee 100644 --- a/packages/xc-sdk/src/platforms/evm/EvmPlatform.ts +++ b/packages/xc-sdk/src/platforms/evm/EvmPlatform.ts @@ -19,7 +19,12 @@ import { import { EvmBalanceFactory } from './balance'; import { EvmTransferFactory } from './transfer'; -import { isNativeEthBridge, isPrecompile } from './transfer/utils'; +import { + isNativeEthBridge, + isPrecompile, + isSnowbridgeV2, + getSnowbridgeV2TokenAddress, +} from './transfer/utils'; import { EvmCall, EvmDryRunResult } from './types'; import { Platform } from '../types'; @@ -61,7 +66,11 @@ export class EvmPlatform implements Platform { return transferCall; } - const erc20 = new Erc20Client(this.#client, asset); + const tokenAddress = isSnowbridgeV2(config) + ? getSnowbridgeV2TokenAddress(config)! + : asset; + + const erc20 = new Erc20Client(this.#client, tokenAddress); const allowance = await erc20.allowance(account, config.address); if (allowance >= amount) { return transferCall; @@ -73,7 +82,7 @@ export class EvmPlatform implements Platform { allowance: allowance, data: approve as `0x${string}`, from: account as `0x${string}`, - to: asset as `0x${string}`, + to: tokenAddress as `0x${string}`, type: CallType.Evm, dryRun: () => {}, } as EvmCall; diff --git a/packages/xc-sdk/src/platforms/evm/transfer/utils.ts b/packages/xc-sdk/src/platforms/evm/transfer/utils.ts index 5c734fe0..55b27c6e 100644 --- a/packages/xc-sdk/src/platforms/evm/transfer/utils.ts +++ b/packages/xc-sdk/src/platforms/evm/transfer/utils.ts @@ -1,10 +1,32 @@ import { ContractConfig, Precompile } from '@galacticcouncil/xc-core'; +import { decodeAbiParameters } from 'viem'; + +export function isSnowbridgeV2(config: ContractConfig): boolean { + return config.module === 'Snowbridge' && config.func === 'v2_sendMessage'; +} + +/** + * Extract the ERC20 token address from Snowbridge V2 assets arg. +s */ +export function getSnowbridgeV2TokenAddress( + config: ContractConfig +): string | undefined { + if (!isSnowbridgeV2(config)) return undefined; + const assets = config.args[1] as string[]; + if (!assets || assets.length === 0) return undefined; + const [_kind, tokenAddress] = decodeAbiParameters( + [{ type: 'uint8' }, { type: 'address' }, { type: 'uint128' }], + assets[0] as `0x${string}` + ); + return tokenAddress as string; +} export function isNativeEthBridge(config: ContractConfig): boolean { const isSnowbridgeNative = config.module === 'Snowbridge' && - config.func === 'sendToken' && - config.args[0] === '0x0000000000000000000000000000000000000000'; + config.func === 'v2_sendMessage' && + Array.isArray(config.args[1]) && + config.args[1].length === 0; const isWormholeNative = config.module === 'TokenBridge' &&