diff --git a/.changeset/olive-worms-cut.md b/.changeset/olive-worms-cut.md new file mode 100644 index 00000000..c36e0329 --- /dev/null +++ b/.changeset/olive-worms-cut.md @@ -0,0 +1,7 @@ +--- +'@galacticcouncil/xc-core': minor +'@galacticcouncil/xc-cfg': minor +'@galacticcouncil/xc-sdk': patch +--- + +basejump base eurc diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 4420b9af..683d7a89 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -19,7 +19,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22.21.1' - cache: 'npm' - name: 🔐 Authenticate with NPM run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc diff --git a/packages/xc-cfg/src/builders/ContractBuilder.ts b/packages/xc-cfg/src/builders/ContractBuilder.ts index c6324c9b..200b9b34 100644 --- a/packages/xc-cfg/src/builders/ContractBuilder.ts +++ b/packages/xc-cfg/src/builders/ContractBuilder.ts @@ -1,5 +1,6 @@ import { Batch } from './contracts/Batch'; import { Erc20 } from './contracts/Erc20'; +import { Basejump } from './contracts/Basejump'; import { PolkadotXcm } from './contracts/PolkadotXcm'; import { Snowbridge } from './contracts/Snowbridge'; import { Wormhole } from './contracts/Wormhole'; @@ -8,6 +9,7 @@ export function ContractBuilder() { return { Batch, Erc20, + Basejump, PolkadotXcm, Snowbridge, Wormhole, diff --git a/packages/xc-cfg/src/builders/FeeAmountBuilder.ts b/packages/xc-cfg/src/builders/FeeAmountBuilder.ts index 2c4e7e18..4ae3699d 100644 --- a/packages/xc-cfg/src/builders/FeeAmountBuilder.ts +++ b/packages/xc-cfg/src/builders/FeeAmountBuilder.ts @@ -203,8 +203,32 @@ function XcmPaymentApi() { }; } +function Basejump() { + return { + quoteFee: (): FeeAmountConfigBuilder => ({ + build: async ({ feeAsset, source }) => { + const ctx = source as EvmChain; + const basejumpAddress = ctx.getBasejump(); + if (!basejumpAddress) { + throw new Error(`Basejump not configured for ${ctx.name}`); + } + + const feeAssetId = ctx.getAssetId(feeAsset); + const fee = await ctx.evmClient.getProvider().readContract({ + abi: Abi.Basejump, + address: basejumpAddress as `0x${string}`, + args: [feeAssetId as `0x${string}`], + functionName: 'quoteFee', + }); + return { amount: fee } as FeeAmount; + }, + }), + }; +} + export function FeeAmountBuilder() { return { + Basejump, XcmPaymentApi, Snowbridge, Wormhole, diff --git a/packages/xc-cfg/src/builders/contracts/Basejump.spec.ts b/packages/xc-cfg/src/builders/contracts/Basejump.spec.ts new file mode 100644 index 00000000..93c73dbe --- /dev/null +++ b/packages/xc-cfg/src/builders/contracts/Basejump.spec.ts @@ -0,0 +1,112 @@ +import { Abi, ContractConfigBuilderParams } from '@galacticcouncil/xc-core'; + +import { eurc } from '../../assets'; +import { base, hydration } from '../../chains'; + +import { Basejump } from './Basejump'; + +const buildCtx = (address: string) => { + return { + address, + amount: 1000000n, + asset: eurc, + sender: '0x71FeB8b2849101a6E62e3369eaAfDc6154CD0Bc0', + source: { chain: base }, + destination: { chain: hydration }, + } as ContractConfigBuilderParams; +}; + +describe('Basejump contract builder', () => { + describe('bridgeViaWormhole', () => { + it('should encode EVM H160 address with ETH\\0 prefix', async () => { + const h160 = '0x71FeB8b2849101a6E62e3369eaAfDc6154CD0Bc0'; + const ctx = buildCtx(h160); + const config = await Basejump().bridgeViaWormhole().build(ctx); + + const recipient = config.args[2] as string; + // ETH\0 = 0x45544800, then H160 lowercase, then 16 zero hex chars + expect(recipient.toLowerCase()).toBe( + '0x45544800' + + '71feb8b2849101a6e62e3369eaafdc6154cd0bc0' + + '0000000000000000' + ); + }); + + it('should encode SS58 EVM account with ETH\\0 prefix', async () => { + // This SS58 address is the Hydration mapping of 0x71FeB8b2849101a6E62e3369eaAfDc6154CD0Bc0 + const { h160 } = await import('@galacticcouncil/common'); + const ss58 = h160.H160.toAccount('0x71FeB8b2849101a6E62e3369eaAfDc6154CD0Bc0'); + const ctx = buildCtx(ss58); + const config = await Basejump().bridgeViaWormhole().build(ctx); + + const recipient = config.args[2] as string; + expect(recipient.toLowerCase()).toBe( + '0x45544800' + + '71feb8b2849101a6e62e3369eaafdc6154cd0bc0' + + '0000000000000000' + ); + }); + + it('should encode native SS58 substrate account as raw AccountId32', async () => { + // Alice's well-known AccountId32 + const alice = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; + const ctx = buildCtx(alice); + const config = await Basejump().bridgeViaWormhole().build(ctx); + + const recipient = config.args[2] as string; + // Alice's raw AccountId32 + expect(recipient.toLowerCase()).toBe( + '0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d' + ); + }); + + it('should produce correct contract config', async () => { + const ctx = buildCtx('0x71FeB8b2849101a6E62e3369eaAfDc6154CD0Bc0'); + const config = await Basejump().bridgeViaWormhole().build(ctx); + + expect(config.func).toBe('bridgeViaWormhole'); + expect(config.module).toBe('Basejump'); + expect(config.args).toHaveLength(3); + }); + + it('should pass asset address as first arg', async () => { + const ctx = buildCtx('0x71FeB8b2849101a6E62e3369eaAfDc6154CD0Bc0'); + const config = await Basejump().bridgeViaWormhole().build(ctx); + + // First arg is the asset ERC20 address on the source chain + const assetArg = (config.args[0] as string).toLowerCase(); + const expectedAssetId = base.getAssetId(eurc)?.toString().toLowerCase(); + expect(assetArg).toBe(expectedAssetId); + }); + }); + + describe('ABI', () => { + it('quoteFee should accept address param (not uint256)', () => { + const abi = Abi.Basejump as readonly Record[]; + const quoteFee = abi.find( + (e) => e.type === 'function' && e.name === 'quoteFee' + ) as Record | undefined; + + expect(quoteFee).toBeDefined(); + const inputs = quoteFee!.inputs as { type: string; name: string }[]; + expect(inputs).toHaveLength(1); + expect(inputs[0].type).toBe('address'); + expect(inputs[0].name).toBe('asset'); + }); + + it('bridgeViaWormhole should accept (address, uint256, bytes32)', () => { + const abi = Abi.Basejump as readonly Record[]; + const fn = abi.find( + (e) => e.type === 'function' && e.name === 'bridgeViaWormhole' + ) as Record | undefined; + + expect(fn).toBeDefined(); + const inputs = fn!.inputs as { type: string }[]; + expect(inputs.map((i) => i.type)).toEqual([ + 'address', + 'uint256', + 'bytes32', + ]); + }); + }); +}); diff --git a/packages/xc-cfg/src/builders/contracts/Basejump.ts b/packages/xc-cfg/src/builders/contracts/Basejump.ts new file mode 100644 index 00000000..b2aa8c85 --- /dev/null +++ b/packages/xc-cfg/src/builders/contracts/Basejump.ts @@ -0,0 +1,53 @@ +import { + Abi, + ContractConfig, + ContractConfigBuilder, + EvmChain, +} from '@galacticcouncil/xc-core'; + +import { parseAssetId } from '../utils'; +import { h160 } from '@galacticcouncil/common'; +import { AccountId } from 'polkadot-api'; +import { toHex } from '@polkadot-api/utils'; + +const { H160, isEvmAddress } = h160; + +/** + * Convert any address (H160 or SS58) to bytes32 AccountId. + */ +function toAccountId32(address: string): `0x${string}` { + const ss58 = isEvmAddress(address) ? H160.toAccount(address) : address; + return toHex(AccountId().enc(ss58)) as `0x${string}`; +} + +const bridgeViaWormhole = (): ContractConfigBuilder => ({ + build: async (params) => { + const { address, amount, asset, source } = params; + const ctx = source.chain as EvmChain; + + const assetId = ctx.getAssetId(asset); + + const basejumpAddress = ctx.getBasejump(); + if (!basejumpAddress) { + throw new Error(`Basejump not configured for ${ctx.name}`); + } + + return new ContractConfig({ + abi: Abi.Basejump, + address: basejumpAddress, + args: [ + parseAssetId(assetId), + amount, + toAccountId32(address), + ], + func: 'bridgeViaWormhole', + module: 'Basejump', + }); + }, +}); + +export const Basejump = () => { + return { + bridgeViaWormhole, + }; +}; diff --git a/packages/xc-cfg/src/chains/evm/base.ts b/packages/xc-cfg/src/chains/evm/base.ts index 2ae4547a..0e8dae89 100644 --- a/packages/xc-cfg/src/chains/evm/base.ts +++ b/packages/xc-cfg/src/chains/evm/base.ts @@ -30,6 +30,9 @@ export const base = new EvmChain({ ecosystem: Ecosystem.Ethereum, evmChain: evmChain, explorer: 'https://basescan.org/', + basejump: { + address: '0xf5b9334e44f800382cb47fc19669401d694e529b', + }, rpcs: ['https://stylish-quick-firefly.base-mainnet.quiknode.pro/'], wormhole: { id: 30, diff --git a/packages/xc-cfg/src/configs/evm/base/index.ts b/packages/xc-cfg/src/configs/evm/base/index.ts index d077665e..30e5ac2a 100644 --- a/packages/xc-cfg/src/configs/evm/base/index.ts +++ b/packages/xc-cfg/src/configs/evm/base/index.ts @@ -2,13 +2,20 @@ import { AssetRoute, ChainRoutes } from '@galacticcouncil/xc-core'; import { eurc, eurc_mwh } from '../../../assets'; import { base } from '../../../chains'; -import { toHydrationViaWormholeTemplate } from './templates'; +import { + toHydrationViaBasejumpTemplate, + toHydrationViaWormholeTemplate, +} from './templates'; const toHydrationViaWormhole: AssetRoute[] = [ toHydrationViaWormholeTemplate(eurc, eurc_mwh), ]; +const toHydrationViaBasejump: AssetRoute[] = [ + toHydrationViaBasejumpTemplate(eurc, eurc_mwh), +]; + export const baseConfig = new ChainRoutes({ chain: base, - routes: [...toHydrationViaWormhole], + routes: [...toHydrationViaWormhole, ...toHydrationViaBasejump], }); diff --git a/packages/xc-cfg/src/configs/evm/base/templates.ts b/packages/xc-cfg/src/configs/evm/base/templates.ts index dff24db8..d39ba5d4 100644 --- a/packages/xc-cfg/src/configs/evm/base/templates.ts +++ b/packages/xc-cfg/src/configs/evm/base/templates.ts @@ -1,10 +1,7 @@ import { Asset, AssetRoute } from '@galacticcouncil/xc-core'; import { eth } from '../../../assets'; -import { - BalanceBuilder, - ContractBuilder, -} from '../../../builders'; +import { BalanceBuilder, ContractBuilder, FeeAmountBuilder } from '../../../builders'; import { hydration, moonbeam } from '../../../chains'; import { Tag } from '../../../tags'; @@ -41,3 +38,35 @@ export function toHydrationViaWormholeTemplate( tags: [Tag.Mrl, Tag.Wormhole], }); } + +export function toHydrationViaBasejumpTemplate( + assetIn: Asset, + assetOut: Asset +): AssetRoute { + return new AssetRoute({ + source: { + asset: assetIn, + balance: BalanceBuilder().evm().erc20(), + fee: { + asset: eth, + balance: BalanceBuilder().evm().native(), + }, + destinationFee: { + asset: assetIn, + balance: BalanceBuilder().evm().erc20(), + }, + }, + destination: { + chain: hydration, + asset: assetOut, + fee: { + amount: FeeAmountBuilder().Basejump().quoteFee(), + asset: assetIn, + }, + }, + contract: ContractBuilder() + .Basejump() + .bridgeViaWormhole(), + tags: [Tag.Basejump], + }); +} diff --git a/packages/xc-cfg/src/tags.ts b/packages/xc-cfg/src/tags.ts index f93bf07e..eca03959 100644 --- a/packages/xc-cfg/src/tags.ts +++ b/packages/xc-cfg/src/tags.ts @@ -1,4 +1,5 @@ export enum Tag { + Basejump = 'Basejump', Wormhole = 'Wormhole', Relayer = 'Relayer', Snowbridge = 'Snowbridge', diff --git a/packages/xc-core/src/chain/EvmChain.ts b/packages/xc-core/src/chain/EvmChain.ts index 1c150913..ec592317 100644 --- a/packages/xc-core/src/chain/EvmChain.ts +++ b/packages/xc-core/src/chain/EvmChain.ts @@ -11,10 +11,15 @@ import { import { Snowbridge, SnowbridgeDef, Wormhole, WormholeDef } from '../bridge'; import { EvmClient } from '../evm'; +export type BasejumpDef = { + address: string; +}; + export interface EvmChainParams extends ChainParams { evmChain: EvmChainDef; id: number; rpcs?: string[]; + basejump?: BasejumpDef; snowbridge?: SnowbridgeDef; wormhole?: WormholeDef; } @@ -23,6 +28,7 @@ export class EvmChain extends Chain { readonly evmChain: EvmChainDef; readonly id: number; readonly rpcs?: string[]; + readonly basejump?: BasejumpDef; readonly snowbridge?: Snowbridge; readonly wormhole?: Wormhole; @@ -30,6 +36,7 @@ export class EvmChain extends Chain { evmChain, id, rpcs, + basejump, snowbridge, wormhole, ...others @@ -38,10 +45,15 @@ export class EvmChain extends Chain { this.evmChain = evmChain; this.id = id; this.rpcs = rpcs; + this.basejump = basejump; this.snowbridge = snowbridge && new Snowbridge(snowbridge); this.wormhole = wormhole && new Wormhole(wormhole); } + getBasejump(): string | undefined { + return this.basejump?.address; + } + get evmClient(): EvmClient { return new EvmClient(this.evmChain, this.rpcs); } diff --git a/packages/xc-core/src/evm/abi/Basejump.ts b/packages/xc-core/src/evm/abi/Basejump.ts new file mode 100644 index 00000000..5faf5ccd --- /dev/null +++ b/packages/xc-core/src/evm/abi/Basejump.ts @@ -0,0 +1,30 @@ +export const BASEJUMP = [ + { + inputs: [ + { internalType: 'address', name: 'asset', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'bytes32', name: 'recipient', type: 'bytes32' }, + ], + name: 'bridgeViaWormhole', + outputs: [ + { internalType: 'uint64', name: 'transferSequence', type: 'uint64' }, + { internalType: 'uint64', name: 'messageSequence', type: 'uint64' }, + ], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes', name: 'vaa', type: 'bytes' }], + name: 'completeTransfer', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'asset', type: 'address' }], + name: 'quoteFee', + outputs: [{ internalType: 'uint256', name: 'fee', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/packages/xc-core/src/evm/abi/index.ts b/packages/xc-core/src/evm/abi/index.ts index ea9d97f8..2d6ab456 100644 --- a/packages/xc-core/src/evm/abi/index.ts +++ b/packages/xc-core/src/evm/abi/index.ts @@ -3,6 +3,7 @@ import type { Abi as TAbi } from 'viem'; import { BATCH } from './Batch'; import { ERC20 } from './Erc20'; import { GMP } from './Gmp'; +import { BASEJUMP } from './Basejump'; import { META } from './Meta'; import { POLKADOT_XCM } from './PolkadotXcm'; import { SNOWBRIDGE } from './Snowbridge'; @@ -13,6 +14,7 @@ export const Abi: Record = { Batch: BATCH, Erc20: ERC20, Gmp: GMP, + Basejump: BASEJUMP, Meta: META, PolkadotXcm: POLKADOT_XCM, Snowbridge: SNOWBRIDGE, diff --git a/packages/xc-sdk/src/Wallet.ts b/packages/xc-sdk/src/Wallet.ts index 87a6f2f7..59871687 100644 --- a/packages/xc-sdk/src/Wallet.ts +++ b/packages/xc-sdk/src/Wallet.ts @@ -157,9 +157,13 @@ export class Wallet { const dstFee = srcDestinationFee.fee.copyWith(destination.fee.asset); const dstFeeBreakdown = srcDestinationFee.feeBreakdown; + const sameAssetDestFee = source.asset.isEqual(dstFee); + const initAmount = + sameAssetDestFee && dstFee.amount > 10n ? dstFee.amount + 1n : 10n; + const ctx: TransferCtx = { address: dstAddress, - amount: 10n, // Use 10 satoshi as init amount + amount: initAmount, asset: source.asset, destination: { balance: dstBalance,