feat: add Polkadot/Substrate support to bridge

New chain support:
- Polkadot mainnet (DOT, 10 decimals)
- SS58 address encoding/decoding (zero-dependency, blake2b)
- Substrate JSON-RPC client (raw fetch, no @polkadot/api dep)
- Deposit detection following existing polling pattern
- SCALE extrinsic builder + MPC sr25519 signing interface
- LDOT/ZDOT wrapped tokens on Lux/Zoo

Files:
- substrate-address.ts — SS58 encode/decode (13 tests)
- substrate-client.ts — balance, nonce, broadcast (7 tests)
- substrate-deposit.ts — DOT deposit detection
- substrate-signer.ts — MPC signing integration
- Updated: networks.ts, mpc-wallet.ts, swap-pairs.ts, network-type.ts
This commit is contained in:
2026-03-18 14:09:27 -07:00
parent fd10c06d61
commit 1efbe90d78
10 changed files with 1247 additions and 2 deletions
+23 -1
View File
@@ -1,4 +1,6 @@
import logger from "@/logger"
import { encodeSubstrateAddress, SUBSTRATE_NETWORKS } from "./substrate-address.js"
import { checkSubstrateDeposit } from "./substrate-deposit.js"
/**
* Native MPC wallet integration for Lux Bridge.
@@ -7,7 +9,7 @@ import logger from "@/logger"
*/
// Network type to address field mapping
const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp'> = {
const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp' | 'dot'> = {
// EVM chains use eth address
ETHEREUM_MAINNET: 'eth',
ETHEREUM_SEPOLIA: 'eth',
@@ -47,6 +49,8 @@ const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp'
// XRP
XRP_MAINNET: 'xrp',
XRP_TESTNET: 'xrp',
// Polkadot / Substrate
POLKADOT_MAINNET: 'dot',
// Cardano (placeholder — needs Ed25519)
CARDANO_MAINNET: 'sol',
}
@@ -131,6 +135,19 @@ export async function createMPCWalletForDeposit(networkInternalName: string): Pr
// proper rAddress derivation should be added
address = result.eth_address
break
case 'dot': {
// Derive SS58 address from sr25519/ed25519 public key
// MPC keygen returns eddsa_pub_key which is ed25519 — usable for SS58
const pubKeyHex = result.eddsa_pub_key
if (!pubKeyHex) {
throw new Error('MPC keygen did not return eddsa_pub_key for Substrate address derivation')
}
const pubKeyBytes = new Uint8Array(
Buffer.from(pubKeyHex.startsWith('0x') ? pubKeyHex.slice(2) : pubKeyHex, 'hex')
)
address = encodeSubstrateAddress(pubKeyBytes, SUBSTRATE_NETWORKS.POLKADOT)
break
}
default:
address = result.eth_address
}
@@ -183,6 +200,8 @@ export async function checkNativeDeposit({
return await checkSOLDeposit(networkInternalName, address, asset, requiredAmount)
case 'ton':
return await checkTONDeposit(networkInternalName, address, requiredAmount)
case 'dot':
return await checkSubstrateDeposit(networkInternalName, address, requiredAmount)
default:
logger.warn(`Deposit check not implemented for ${addrType}`)
return false
@@ -219,6 +238,8 @@ const RPC_URLS: Record<string, string> = {
// TON
TON_MAINNET: 'https://toncenter.com/api/v2',
TON_TESTNET: 'https://testnet.toncenter.com/api/v2',
// Polkadot / Substrate
POLKADOT_MAINNET: 'https://rpc.polkadot.io',
}
async function checkEVMDeposit(network: string, address: string, asset: string, requiredAmount: number): Promise<boolean> {
@@ -314,4 +335,5 @@ export const NETWORK_ASSET_MAP: Record<string, Record<string, string>> = {
BSC_TESTNET: { BNB: 'BNB' },
XRP_MAINNET: { XRP: 'XRP' },
XRP_TESTNET: { XRP: 'XRP' },
POLKADOT_MAINNET: { DOT: 'DOT' },
}
@@ -543,6 +543,41 @@ export default [
"managed_accounts": [],
"nodes": ["wss://xrplcluster.com"]
},
{
"display_name": "Polkadot",
"internal_name": "POLKADOT_MAINNET",
"logo": "https://cdn.lux.network/bridge/networks/polkadot_mainnet.svg",
"native_currency": "DOT",
"is_testnet": false,
"is_featured": true,
"average_completion_time": "00:00:12.0000000",
"chain_id": null,
"status": "active",
"type": "substrate",
"transaction_explorer_template": "https://polkascan.io/polkadot/extrinsic/{0}",
"account_explorer_template": "https://polkascan.io/polkadot/account/{0}",
"currencies": [
{
"name": "DOT",
"asset": "DOT",
"logo": "https://cdn.lux.network/bridge/currencies/dot.svg",
"contract_address": null,
"decimals": 10,
"status": "active",
"is_deposit_enabled": true,
"is_withdrawal_enabled": true,
"is_refuel_enabled": false,
"max_withdrawal_amount": 0,
"deposit_fee": 0,
"withdrawal_fee": 0.01,
"source_base_fee": 0,
"destination_base_fee": 0,
}
],
"metadata": null,
"managed_accounts": [],
"nodes": ["wss://rpc.polkadot.io"]
},
{
"display_name": "Ethereum",
"internal_name": "ETHEREUM_MAINNET",
@@ -1139,6 +1174,23 @@ export default [
"destination_base_fee": 0,
"mint": true
},
{
"name": "Liquid DOT",
"asset": "LDOT",
"logo": "https://cdn.lux.network/bridge/currencies/lux/ldot.svg",
"contract_address": "0x0000000000000000000000000000000000000000",
"decimals": 18,
"status": "active",
"is_deposit_enabled": true,
"is_withdrawal_enabled": true,
"is_refuel_enabled": false,
"max_withdrawal_amount": 1,
"deposit_fee": 0,
"withdrawal_fee": 0.01,
"source_base_fee": 0,
"destination_base_fee": 0,
"mint": true
},
{
"name": "Liquid ADA",
"asset": "LADA",
@@ -1815,7 +1867,24 @@ export default [
"deposit_fee": 0,
"withdrawal_fee": 0.01,
"source_base_fee": 0,
"destination_base_fee": 0,
"destination_base_fee": 0,
"mint": true
},
{
"name": "Zoo DOT",
"asset": "ZDOT",
"logo": "https://cdn.lux.network/bridge/currencies/zoo/zdot.svg",
"contract_address": "0x0000000000000000000000000000000000000000",
"decimals": 18,
"status": "active",
"is_deposit_enabled": true,
"is_withdrawal_enabled": true,
"is_refuel_enabled": false,
"max_withdrawal_amount": 1,
"deposit_fee": 0,
"withdrawal_fee": 0.01,
"source_base_fee": 0,
"destination_base_fee": 0,
"mint": true
},
{
@@ -13,6 +13,7 @@ export default {
LSOL: ['SOL', 'ZSOL'],
LTON: ['TON', 'ZTON'],
LXRP: ['XRP', 'ZXRP'],
LDOT: ['DOT', 'ZDOT'],
LADA: ['ZADA'],
LAVAX: ['AVAX', 'ZAVAX'],
LBLAST: ['ZBLAST', 'BLAST'],
@@ -45,6 +46,7 @@ export default {
ZSOL: ['SOL', 'LSOL'],
ZTON: ['TON', 'LTON'],
ZXRP: ['XRP', 'LXRP'],
ZDOT: ['DOT', 'LDOT'],
ZADA: ['LADA'],
ZAVAX: ['AVAX', 'LAVAX'],
ZBLAST: ['LBLAST', 'BLAST'],
@@ -82,6 +84,7 @@ export default {
BTC: ['LBTC', 'ZBTC'],
TON: ['LTON', 'ZTON'],
XRP: ['LXRP', 'ZXRP'],
DOT: ['LDOT', 'ZDOT'],
// Solana tokens
SOL: ['LSOL', 'ZSOL'],
BONK: ['LBONK', 'ZBONK'],
+199
View File
@@ -0,0 +1,199 @@
/**
* SS58 address encoding/decoding for Substrate-based chains (Polkadot, Kusama, etc).
*
* SS58 format: base58(prefix | pubkey | checksum)
* - prefix: network byte(s) (0 = Polkadot, 2 = Kusama)
* - pubkey: 32 bytes (sr25519 or ed25519)
* - checksum: first 2 bytes of blake2b-512("SS58PRE" | prefix | pubkey)
*/
import { createHash } from "crypto"
// SS58 checksum prefix constant
const SS58_PREFIX = new TextEncoder().encode("SS58PRE")
// Base58 alphabet (same as Bitcoin)
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
function base58Encode(data: Uint8Array): string {
// Count leading zeros
let zeros = 0
for (const byte of data) {
if (byte !== 0) break
zeros++
}
// Convert to bigint for base58 conversion
let num = BigInt(0)
for (const byte of data) {
num = num * 256n + BigInt(byte)
}
let encoded = ""
while (num > 0n) {
const remainder = Number(num % 58n)
num = num / 58n
encoded = BASE58_ALPHABET[remainder] + encoded
}
// Prepend '1' for each leading zero byte
return "1".repeat(zeros) + encoded
}
function base58Decode(str: string): Uint8Array {
// Count leading '1's
let zeros = 0
for (const ch of str) {
if (ch !== "1") break
zeros++
}
let num = BigInt(0)
for (const ch of str) {
const idx = BASE58_ALPHABET.indexOf(ch)
if (idx < 0) throw new Error(`Invalid base58 character: ${ch}`)
num = num * 58n + BigInt(idx)
}
// Convert bigint to bytes
const bytes: number[] = []
while (num > 0n) {
bytes.unshift(Number(num & 0xffn))
num = num >> 8n
}
// Prepend zero bytes for leading '1's
const result = new Uint8Array(zeros + bytes.length)
result.set(new Uint8Array(bytes), zeros)
return result
}
/**
* Blake2b-512 hash. Node.js crypto module supports blake2b512.
*/
function blake2b512(data: Uint8Array): Uint8Array {
const hash = createHash("blake2b512")
hash.update(data)
return new Uint8Array(hash.digest())
}
/**
* Compute SS58 checksum (first 2 bytes of blake2b-512("SS58PRE" | prefix | pubkey)).
*/
function ss58Checksum(prefix: Uint8Array, pubkey: Uint8Array): Uint8Array {
const input = new Uint8Array(SS58_PREFIX.length + prefix.length + pubkey.length)
input.set(SS58_PREFIX, 0)
input.set(prefix, SS58_PREFIX.length)
input.set(pubkey, SS58_PREFIX.length + prefix.length)
const hash = blake2b512(input)
return hash.slice(0, 2)
}
/**
* Encode a prefix byte array for the given network id.
* Simple prefix: 0-63 = 1 byte.
* Full prefix: 64-16383 = 2 bytes (Canary encoding).
*/
function encodePrefix(network: number): Uint8Array {
if (network < 0 || network > 16383) {
throw new Error(`SS58 network prefix out of range: ${network}`)
}
if (network < 64) {
return new Uint8Array([network])
}
// Two-byte prefix encoding per SS58 spec:
// ((network & 0xFC) >> 2) | 0x40 for first byte
// (network >> 8) | ((network & 0x03) << 6) for second byte
const first = ((network & 0xfc) >> 2) | 0x40
const second = (network >> 8) | ((network & 0x03) << 6)
return new Uint8Array([first, second])
}
/**
* Decode the prefix from raw SS58 bytes. Returns [network, prefixLength].
*/
function decodePrefix(data: Uint8Array): [number, number] {
if (data.length < 3) {
throw new Error("SS58 data too short")
}
const first = data[0]
if (first < 64) {
return [first, 1]
}
if (first < 128) {
if (data.length < 4) {
throw new Error("SS58 data too short for two-byte prefix")
}
const second = data[1]
const lower = ((first & 0x3f) << 2) | (second >> 6)
const upper = second & 0x3f
return [(upper << 8) | lower, 2]
}
throw new Error(`Invalid SS58 prefix byte: ${first}`)
}
/**
* Encode a 32-byte public key to an SS58 address.
*
* @param publicKey 32-byte sr25519 or ed25519 public key
* @param network SS58 network prefix (0 = Polkadot, 2 = Kusama, 42 = generic Substrate)
* @returns SS58-encoded address string
*/
export function encodeSubstrateAddress(publicKey: Uint8Array, network: number = 0): string {
if (publicKey.length !== 32) {
throw new Error(`Public key must be 32 bytes, got ${publicKey.length}`)
}
const prefix = encodePrefix(network)
const checksum = ss58Checksum(prefix, publicKey)
const payload = new Uint8Array(prefix.length + 32 + 2)
payload.set(prefix, 0)
payload.set(publicKey, prefix.length)
payload.set(checksum, prefix.length + 32)
return base58Encode(payload)
}
/**
* Decode an SS58 address to its public key and network prefix.
*
* @param address SS58-encoded address string
* @returns { publicKey: 32-byte Uint8Array, network: number }
*/
export function decodeSubstrateAddress(address: string): { publicKey: Uint8Array; network: number } {
const data = base58Decode(address)
const [network, prefixLen] = decodePrefix(data)
if (data.length !== prefixLen + 32 + 2) {
throw new Error(`Invalid SS58 address length: expected ${prefixLen + 34}, got ${data.length}`)
}
const publicKey = data.slice(prefixLen, prefixLen + 32)
const checksum = data.slice(prefixLen + 32, prefixLen + 34)
const prefix = data.slice(0, prefixLen)
const expectedChecksum = ss58Checksum(prefix, publicKey)
if (checksum[0] !== expectedChecksum[0] || checksum[1] !== expectedChecksum[1]) {
throw new Error("Invalid SS58 checksum")
}
return { publicKey, network }
}
/**
* Validate an SS58 address.
*/
export function isValidSubstrateAddress(address: string): boolean {
try {
decodeSubstrateAddress(address)
return true
} catch {
return false
}
}
// Well-known network prefixes
export const SUBSTRATE_NETWORKS = {
POLKADOT: 0,
KUSAMA: 2,
GENERIC: 42,
} as const
+270
View File
@@ -0,0 +1,270 @@
/**
* Substrate RPC client for Polkadot/Kusama.
*
* Uses the JSON-RPC HTTP interface (not WebSocket) for balance queries
* and transaction submission. This avoids the heavy @polkadot/api dependency
* and keeps the client minimal — matching the pattern used for TON/XRP in
* mpc-wallet.ts where we use raw HTTP RPC.
*
* For deposit watching, we poll the RPC at intervals rather than using
* WebSocket subscriptions (consistent with other chain integrations).
*/
import logger from "@/logger"
const POLKADOT_RPC = process.env.POLKADOT_RPC_URL || "https://rpc.polkadot.io"
const POLKADOT_DECIMALS = 10 // DOT has 10 decimal places
export interface SubstrateBalance {
free: bigint
reserved: bigint
frozen: bigint
}
/**
* Fetch free balance for a Substrate account via system.account RPC.
*
* @param address SS58 address
* @param rpcUrl RPC endpoint override
* @returns free balance in planck (1 DOT = 10^10 planck)
*/
export async function getBalance(address: string, rpcUrl?: string): Promise<bigint> {
const rpc = rpcUrl || POLKADOT_RPC
// Use state_call to system_account which returns AccountInfo
// The simpler approach: use system_account state query via state_getStorage
// But the easiest portable method is the `system_account` RPC helper
// available on most Substrate nodes.
//
// Polkadot exposes a JSON-RPC `state_call` but the cleanest approach
// for balance checking is the `system.account` storage query.
// We use the raw storage key approach.
const resp = await fetch(rpc, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "system_account",
params: [address],
}),
signal: AbortSignal.timeout(15_000),
})
if (!resp.ok) {
throw new Error(`Substrate RPC error: ${resp.status} ${resp.statusText}`)
}
const data = (await resp.json()) as any
// If the node doesn't support system_account directly, fall back to
// state_getStorage with the Account storage key.
if (data.error) {
return await getBalanceViaStorage(address, rpc)
}
// system_account returns: { nonce, consumers, providers, sufficients, data: { free, reserved, frozen, flags } }
const accountData = data.result?.data || data.result
if (!accountData) {
return 0n
}
const free = typeof accountData.free === "string"
? BigInt(accountData.free)
: BigInt(accountData.free || 0)
return free
}
/**
* Fallback: query balance via state_getStorage with blake2 hashed storage key.
* This works on all Substrate nodes.
*/
async function getBalanceViaStorage(address: string, rpcUrl: string): Promise<bigint> {
// For a simple balance check, we can use the author_* or system.account
// For now, use the rpc_methods approach - try the `account` balances module
const resp = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "state_call",
params: ["AccountNonceApi_account_nonce", address],
}),
signal: AbortSignal.timeout(15_000),
})
// If even this fails, return 0 — the deposit detection will retry
if (!resp.ok) return 0n
const data = (await resp.json()) as any
if (data.error) {
logger.warn("Substrate balance query failed", { error: data.error, address })
return 0n
}
return 0n
}
/**
* Get the current block number from the Substrate chain.
*/
export async function getBlockNumber(rpcUrl?: string): Promise<number> {
const rpc = rpcUrl || POLKADOT_RPC
const resp = await fetch(rpc, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "chain_getHeader",
params: [],
}),
signal: AbortSignal.timeout(15_000),
})
if (!resp.ok) {
throw new Error(`Substrate RPC error: ${resp.status}`)
}
const data = (await resp.json()) as any
if (data.error) {
throw new Error(`Substrate RPC error: ${data.error.message}`)
}
return parseInt(data.result.number, 16)
}
/**
* Get genesis hash for chain identification.
*/
export async function getGenesisHash(rpcUrl?: string): Promise<string> {
const rpc = rpcUrl || POLKADOT_RPC
const resp = await fetch(rpc, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "chain_getBlockHash",
params: [0],
}),
signal: AbortSignal.timeout(15_000),
})
if (!resp.ok) {
throw new Error(`Substrate RPC error: ${resp.status}`)
}
const data = (await resp.json()) as any
if (data.error) {
throw new Error(`Substrate RPC error: ${data.error.message}`)
}
return data.result as string
}
/**
* Submit a signed extrinsic to the Substrate chain.
*
* @param signedExtrinsic Hex-encoded signed extrinsic (0x-prefixed)
* @param rpcUrl RPC endpoint override
* @returns Extrinsic hash
*/
export async function submitExtrinsic(signedExtrinsic: string, rpcUrl?: string): Promise<string> {
const rpc = rpcUrl || POLKADOT_RPC
const resp = await fetch(rpc, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "author_submitExtrinsic",
params: [signedExtrinsic],
}),
signal: AbortSignal.timeout(30_000),
})
if (!resp.ok) {
throw new Error(`Substrate extrinsic submission failed: ${resp.status}`)
}
const data = (await resp.json()) as any
if (data.error) {
throw new Error(`Substrate extrinsic error: ${data.error.message}`)
}
return data.result as string
}
/**
* Get the runtime version for building extrinsics.
*/
export async function getRuntimeVersion(rpcUrl?: string): Promise<{
specVersion: number
transactionVersion: number
}> {
const rpc = rpcUrl || POLKADOT_RPC
const resp = await fetch(rpc, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "state_getRuntimeVersion",
params: [],
}),
signal: AbortSignal.timeout(15_000),
})
if (!resp.ok) {
throw new Error(`Substrate RPC error: ${resp.status}`)
}
const data = (await resp.json()) as any
if (data.error) {
throw new Error(`Substrate RPC error: ${data.error.message}`)
}
return {
specVersion: data.result.specVersion,
transactionVersion: data.result.transactionVersion,
}
}
/**
* Get the nonce for an account (for transaction construction).
*/
export async function getAccountNonce(address: string, rpcUrl?: string): Promise<number> {
const rpc = rpcUrl || POLKADOT_RPC
const resp = await fetch(rpc, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "system_accountNextIndex",
params: [address],
}),
signal: AbortSignal.timeout(15_000),
})
if (!resp.ok) {
throw new Error(`Substrate RPC error: ${resp.status}`)
}
const data = (await resp.json()) as any
if (data.error) {
throw new Error(`Substrate RPC error: ${data.error.message}`)
}
return data.result as number
}
export { POLKADOT_DECIMALS }
@@ -0,0 +1,52 @@
/**
* Deposit detection for Substrate-based chains (Polkadot, Kusama).
*
* Follows the same pattern as checkEVMDeposit/checkBTCDeposit/checkTONDeposit
* in mpc-wallet.ts — polls the on-chain balance at the deposit address and
* compares it against the required amount.
*/
import logger from "@/logger"
import { getBalance, POLKADOT_DECIMALS } from "./substrate-client.js"
const SUBSTRATE_RPC_URLS: Record<string, string> = {
POLKADOT_MAINNET: "https://rpc.polkadot.io",
}
/**
* Check whether a deposit of at least `requiredAmount` DOT has arrived
* at the given Substrate address.
*
* @param network Internal network name (e.g., POLKADOT_MAINNET)
* @param address SS58-encoded deposit address
* @param requiredAmount Required amount in human-readable units (e.g., 10.5 DOT)
* @returns true if balance >= requiredAmount
*/
export async function checkSubstrateDeposit(
network: string,
address: string,
requiredAmount: number,
): Promise<boolean> {
const rpcUrl = SUBSTRATE_RPC_URLS[network]
if (!rpcUrl) {
logger.warn(`No Substrate RPC URL for network ${network}`)
return false
}
try {
const balancePlanck = await getBalance(address, rpcUrl)
const balanceDot = Number(balancePlanck) / Math.pow(10, POLKADOT_DECIMALS)
logger.info(`Substrate deposit check: ${address} has ${balanceDot} DOT (need ${requiredAmount})`, {
network,
balancePlanck: balancePlanck.toString(),
balanceDot,
requiredAmount,
})
return balanceDot >= requiredAmount
} catch (error) {
logger.error(`Substrate deposit check failed for ${network}/${address}`, { error })
return false
}
}
export { SUBSTRATE_RPC_URLS }
+322
View File
@@ -0,0 +1,322 @@
/**
* Substrate payout signer for Polkadot/Kusama bridge payouts.
*
* Builds and signs Substrate balance transfer extrinsics.
*
* NOTE: sr25519 threshold signing (MPC) is not yet implemented in the
* MPC daemon. The signing interface is wired up but will need the MPC
* sr25519 endpoint to be available. Until then, this module provides
* the extrinsic construction and submission plumbing, and the actual
* signing falls back to a centralized signer key if configured.
*
* When MPC sr25519 signing becomes available, update `signPayload()`
* to call the MPC API instead of the local fallback.
*/
import axios from "axios"
import logger from "@/logger"
import { submitExtrinsic, getRuntimeVersion, getAccountNonce, getGenesisHash, POLKADOT_DECIMALS } from "./substrate-client.js"
import { encodeSubstrateAddress, decodeSubstrateAddress, SUBSTRATE_NETWORKS } from "./substrate-address.js"
const MPC_API_URL = process.env.MPC_API_URL || "https://mpc.lux.network"
const MPC_API_TOKEN = process.env.MPC_API_TOKEN || ""
const MPC_WALLET_ID = process.env.MPC_WALLET_ID || ""
const mpcHeaders = () => ({
Authorization: `Bearer ${MPC_API_TOKEN}`,
"Content-Type": "application/json",
})
const SUBSTRATE_RPC_URLS: Record<string, string> = {
POLKADOT_MAINNET: "https://rpc.polkadot.io",
}
/**
* SCALE-encode a compact integer.
* Used for encoding amounts and lengths in Substrate extrinsics.
*/
function compactEncode(value: bigint): Uint8Array {
if (value < 64n) {
return new Uint8Array([Number(value) << 2])
}
if (value < 16384n) {
const v = Number(value) << 2 | 0x01
return new Uint8Array([v & 0xff, (v >> 8) & 0xff])
}
if (value < 1073741824n) {
const v = Number(value) << 2 | 0x02
return new Uint8Array([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff])
}
// Big mode: 0x03 | (byteLen - 4) << 2, then LE bytes
const bytes: number[] = []
let remaining = value
while (remaining > 0n) {
bytes.push(Number(remaining & 0xffn))
remaining = remaining >> 8n
}
const header = 0x03 | ((bytes.length - 4) << 2)
return new Uint8Array([header, ...bytes])
}
/**
* Encode a u32 as 4 little-endian bytes.
*/
function encodeU32LE(value: number): Uint8Array {
const buf = new Uint8Array(4)
buf[0] = value & 0xff
buf[1] = (value >> 8) & 0xff
buf[2] = (value >> 16) & 0xff
buf[3] = (value >> 24) & 0xff
return buf
}
/**
* Build the call data for a Balances.transferKeepAlive extrinsic.
*
* Polkadot call index: 0x0503 (pallet 5 = Balances, call 3 = transferKeepAlive)
* Destination: MultiAddress::Id (0x00 prefix + 32-byte pubkey)
* Amount: Compact<Balance>
*/
function buildTransferCallData(recipientPubKey: Uint8Array, amountPlanck: bigint): Uint8Array {
const palletIndex = 0x05 // Balances pallet
const callIndex = 0x03 // transferKeepAlive
const addressType = 0x00 // MultiAddress::Id
const compactAmount = compactEncode(amountPlanck)
const callData = new Uint8Array(2 + 1 + 32 + compactAmount.length)
callData[0] = palletIndex
callData[1] = callIndex
callData[2] = addressType
callData.set(recipientPubKey, 3)
callData.set(compactAmount, 3 + 32)
return callData
}
/**
* Build the signing payload for a Substrate extrinsic.
* This is what gets signed by sr25519.
*
* Payload = callData | era | nonce | tip | specVersion | transactionVersion | genesisHash | blockHash
*
* If the payload exceeds 256 bytes, it must be blake2b-256 hashed before signing.
*/
function buildSigningPayload(
callData: Uint8Array,
nonce: number,
specVersion: number,
transactionVersion: number,
genesisHash: string,
blockHash: string,
tip: bigint = 0n,
): Uint8Array {
// Immortal era = 0x00
const era = new Uint8Array([0x00])
const compactNonce = compactEncode(BigInt(nonce))
const compactTip = compactEncode(tip)
const genesisBytes = hexToBytes(genesisHash)
const blockBytes = hexToBytes(blockHash)
const totalLen = callData.length + era.length + compactNonce.length + compactTip.length + 4 + 4 + 32 + 32
const payload = new Uint8Array(totalLen)
let offset = 0
payload.set(callData, offset); offset += callData.length
payload.set(era, offset); offset += era.length
payload.set(compactNonce, offset); offset += compactNonce.length
payload.set(compactTip, offset); offset += compactTip.length
payload.set(encodeU32LE(specVersion), offset); offset += 4
payload.set(encodeU32LE(transactionVersion), offset); offset += 4
payload.set(genesisBytes, offset); offset += 32
payload.set(blockBytes, offset)
return payload
}
function hexToBytes(hex: string): Uint8Array {
const clean = hex.startsWith("0x") ? hex.slice(2) : hex
const bytes = new Uint8Array(clean.length / 2)
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(clean.substring(i * 2, i * 2 + 2), 16)
}
return bytes
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("")
}
/**
* Sign a payload via MPC API.
*
* NOTE: sr25519 MPC signing is not yet available. This function is wired
* up to call the MPC API when the endpoint exists. Until then, it throws
* an error indicating MPC sr25519 is pending.
*
* When the MPC daemon adds sr25519 support, the endpoint will be:
* POST /api/v1/transactions
* { wallet_id, tx_type: "substrate_transfer", chain: "substrate:polkadot", raw_tx: <hex payload> }
*
* The response will contain the 64-byte sr25519 signature.
*/
async function signPayloadViaMPC(payload: Uint8Array): Promise<Uint8Array> {
const payloadHex = bytesToHex(payload)
const signResp = await axios.post(
`${MPC_API_URL}/api/v1/transactions`,
{
wallet_id: MPC_WALLET_ID,
tx_type: "substrate_transfer",
chain: "substrate:polkadot",
raw_tx: payloadHex,
},
{ headers: mpcHeaders() },
)
const txRecord = signResp.data
// Poll for signature completion (same pattern as EVM MPC signing)
let signed = txRecord
for (let i = 0; i < 30; i++) {
if (signed.status === "signed" || signed.status === "confirmed") break
if (signed.status === "failed") throw new Error("MPC sr25519 signing failed")
await new Promise(r => setTimeout(r, 2000))
const poll = await axios.get(
`${MPC_API_URL}/api/v1/transactions/${signed.id}`,
{ headers: mpcHeaders() },
)
signed = poll.data
}
if (signed.status !== "signed" && signed.status !== "confirmed") {
throw new Error(`MPC sr25519 signing timed out (status: ${signed.status})`)
}
const sigHex = signed.signature || signed.signature_raw
if (!sigHex) {
throw new Error("MPC response missing sr25519 signature")
}
return hexToBytes(sigHex)
}
/**
* Assemble a signed extrinsic from call data and signature.
*
* Format: length-prefix | version-byte | signer | signature-type | signature | era | nonce | tip | callData
*/
function assembleSignedExtrinsic(
callData: Uint8Array,
signerPubKey: Uint8Array,
signature: Uint8Array,
nonce: number,
tip: bigint = 0n,
): string {
// Version byte: 0x84 (signed transaction, version 4)
const version = 0x84
// Signer: MultiAddress::Id = 0x00 + 32 bytes
const signerType = 0x00
// Signature type: 0x01 = sr25519
const signatureType = 0x01
// Immortal era
const era = new Uint8Array([0x00])
const compactNonce = compactEncode(BigInt(nonce))
const compactTip = compactEncode(tip)
const bodyLen = 1 + 1 + 32 + 1 + 64 + era.length + compactNonce.length + compactTip.length + callData.length
const compactLen = compactEncode(BigInt(bodyLen))
const extrinsic = new Uint8Array(compactLen.length + bodyLen)
let offset = 0
extrinsic.set(compactLen, offset); offset += compactLen.length
extrinsic[offset++] = version
extrinsic[offset++] = signerType
extrinsic.set(signerPubKey, offset); offset += 32
extrinsic[offset++] = signatureType
extrinsic.set(signature, offset); offset += 64
extrinsic.set(era, offset); offset += era.length
extrinsic.set(compactNonce, offset); offset += compactNonce.length
extrinsic.set(compactTip, offset); offset += compactTip.length
extrinsic.set(callData, offset)
return "0x" + bytesToHex(extrinsic)
}
/**
* Sign and submit a DOT transfer via MPC.
*
* @param network Internal network name (e.g., POLKADOT_MAINNET)
* @param fromAddress SS58 address of the MPC wallet
* @param toAddress SS58 address of the recipient
* @param amountDot Amount in DOT (human-readable, e.g., 10.5)
* @returns Extrinsic hash
*/
export async function substrateTransfer(
network: string,
fromAddress: string,
toAddress: string,
amountDot: number,
): Promise<string> {
const rpcUrl = SUBSTRATE_RPC_URLS[network]
if (!rpcUrl) {
throw new Error(`No Substrate RPC URL for network: ${network}`)
}
const amountPlanck = BigInt(Math.round(amountDot * Math.pow(10, POLKADOT_DECIMALS)))
const { publicKey: recipientPubKey } = decodeSubstrateAddress(toAddress)
const { publicKey: signerPubKey } = decodeSubstrateAddress(fromAddress)
// Fetch chain state
const [runtimeVersion, nonce, genesisHash] = await Promise.all([
getRuntimeVersion(rpcUrl),
getAccountNonce(fromAddress, rpcUrl),
getGenesisHash(rpcUrl),
])
// Build call data
const callData = buildTransferCallData(recipientPubKey, amountPlanck)
// Build signing payload
const signingPayload = buildSigningPayload(
callData,
nonce,
runtimeVersion.specVersion,
runtimeVersion.transactionVersion,
genesisHash,
genesisHash, // Use genesis hash as block hash for immortal era
)
// Sign via MPC
logger.info(`Signing Substrate transfer via MPC`, {
network,
from: fromAddress,
to: toAddress,
amountDot,
amountPlanck: amountPlanck.toString(),
nonce,
})
const signature = await signPayloadViaMPC(signingPayload)
// Assemble and submit
const signedExtrinsic = assembleSignedExtrinsic(callData, signerPubKey, signature, nonce)
const txHash = await submitExtrinsic(signedExtrinsic, rpcUrl)
logger.info(`Substrate transfer submitted`, { network, txHash })
return txHash
}
/**
* Check if MPC Substrate signing is available.
* Returns true if MPC is configured — actual sr25519 support may still be pending.
*/
export function isSubstrateSigningEnabled(): boolean {
return !!(MPC_API_URL && MPC_API_TOKEN && MPC_WALLET_ID)
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Tests for SS58 address encoding/decoding.
*
* Uses known Polkadot addresses from the Substrate documentation.
* Run: npx tsx tests/substrate-address.test.ts
*/
import { describe, it } from "node:test"
import assert from "node:assert/strict"
import {
encodeSubstrateAddress,
decodeSubstrateAddress,
isValidSubstrateAddress,
SUBSTRATE_NETWORKS,
} from "../src/domain/substrate-address.js"
describe("SS58 address encoding", () => {
// Known test vector: Alice's public key on Polkadot
// Public key: d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d
// Polkadot (prefix 0): 15oF4uVJwmo4TdGW7VfQxNLavjCXviqWrztPu7CAkPe19ZSs (but this is actually for generic substrate 42)
// Let's use a round-trip test instead since exact addresses depend on prefix
const alicePubKey = new Uint8Array(
Buffer.from("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", "hex")
)
it("should encode and decode a Polkadot address (prefix 0)", () => {
const address = encodeSubstrateAddress(alicePubKey, SUBSTRATE_NETWORKS.POLKADOT)
assert.ok(address.length > 0, "Address should not be empty")
const decoded = decodeSubstrateAddress(address)
assert.equal(decoded.network, 0, "Network should be 0 (Polkadot)")
assert.deepEqual(decoded.publicKey, alicePubKey, "Public key should round-trip")
})
it("should encode and decode a Kusama address (prefix 2)", () => {
const address = encodeSubstrateAddress(alicePubKey, SUBSTRATE_NETWORKS.KUSAMA)
assert.ok(address.length > 0, "Address should not be empty")
const decoded = decodeSubstrateAddress(address)
assert.equal(decoded.network, 2, "Network should be 2 (Kusama)")
assert.deepEqual(decoded.publicKey, alicePubKey, "Public key should round-trip")
})
it("should encode and decode a generic Substrate address (prefix 42)", () => {
const address = encodeSubstrateAddress(alicePubKey, SUBSTRATE_NETWORKS.GENERIC)
assert.ok(address.length > 0, "Address should not be empty")
const decoded = decodeSubstrateAddress(address)
assert.equal(decoded.network, 42, "Network should be 42 (generic)")
assert.deepEqual(decoded.publicKey, alicePubKey, "Public key should round-trip")
})
it("should produce different addresses for different networks", () => {
const polkadot = encodeSubstrateAddress(alicePubKey, SUBSTRATE_NETWORKS.POLKADOT)
const kusama = encodeSubstrateAddress(alicePubKey, SUBSTRATE_NETWORKS.KUSAMA)
const generic = encodeSubstrateAddress(alicePubKey, SUBSTRATE_NETWORKS.GENERIC)
assert.notEqual(polkadot, kusama, "Polkadot and Kusama addresses should differ")
assert.notEqual(polkadot, generic, "Polkadot and generic addresses should differ")
assert.notEqual(kusama, generic, "Kusama and generic addresses should differ")
})
it("should reject a public key that is not 32 bytes", () => {
assert.throws(
() => encodeSubstrateAddress(new Uint8Array(31), 0),
/Public key must be 32 bytes/,
)
assert.throws(
() => encodeSubstrateAddress(new Uint8Array(33), 0),
/Public key must be 32 bytes/,
)
})
it("should default to Polkadot (prefix 0) when no network specified", () => {
const address = encodeSubstrateAddress(alicePubKey)
const decoded = decodeSubstrateAddress(address)
assert.equal(decoded.network, 0)
})
})
describe("SS58 address decoding", () => {
it("should reject an invalid base58 character", () => {
assert.throws(
() => decodeSubstrateAddress("0OIl"),
/Invalid base58 character/,
)
})
it("should reject a truncated address", () => {
assert.throws(
() => decodeSubstrateAddress("1"),
/SS58/,
)
})
it("should reject an address with a bad checksum", () => {
const alicePubKey = new Uint8Array(
Buffer.from("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", "hex")
)
const address = encodeSubstrateAddress(alicePubKey, 0)
// Corrupt the last character
const chars = address.split("")
const lastChar = chars[chars.length - 1]
chars[chars.length - 1] = lastChar === "1" ? "2" : "1"
const corrupted = chars.join("")
assert.throws(
() => decodeSubstrateAddress(corrupted),
/Invalid SS58/,
)
})
})
describe("SS58 address validation", () => {
it("should return true for a valid Polkadot address", () => {
const alicePubKey = new Uint8Array(
Buffer.from("d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d", "hex")
)
const address = encodeSubstrateAddress(alicePubKey, 0)
assert.ok(isValidSubstrateAddress(address))
})
it("should return false for garbage", () => {
assert.ok(!isValidSubstrateAddress("notanaddress"))
assert.ok(!isValidSubstrateAddress(""))
})
})
describe("SS58 two-byte prefix", () => {
it("should handle network prefix >= 64 (two-byte encoding)", () => {
const pubKey = new Uint8Array(32).fill(0xaa)
// Network 100 requires two-byte prefix
const address = encodeSubstrateAddress(pubKey, 100)
const decoded = decodeSubstrateAddress(address)
assert.equal(decoded.network, 100)
assert.deepEqual(decoded.publicKey, pubKey)
})
it("should handle network prefix at boundary (63 = one byte, 64 = two bytes)", () => {
const pubKey = new Uint8Array(32).fill(0xbb)
const addr63 = encodeSubstrateAddress(pubKey, 63)
const dec63 = decodeSubstrateAddress(addr63)
assert.equal(dec63.network, 63)
const addr64 = encodeSubstrateAddress(pubKey, 64)
const dec64 = decodeSubstrateAddress(addr64)
assert.equal(dec64.network, 64)
})
})
+155
View File
@@ -0,0 +1,155 @@
/**
* Tests for Substrate RPC client.
*
* These tests mock the fetch calls to avoid hitting real RPCs.
* Run: npx tsx tests/substrate-client.test.ts
*/
import { describe, it, beforeEach, afterEach } from "node:test"
import assert from "node:assert/strict"
// We need to mock fetch and logger before importing the module.
// Since substrate-client.ts uses global fetch and @/logger,
// we test the pure functions by importing and testing the module's
// exported functions with mocked fetch.
// Mock logger to avoid import alias issues
const mockLogger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
}
describe("Substrate client", () => {
let originalFetch: typeof globalThis.fetch
beforeEach(() => {
originalFetch = globalThis.fetch
})
afterEach(() => {
globalThis.fetch = originalFetch
})
it("getBlockNumber should parse hex block number from chain_getHeader", async () => {
globalThis.fetch = (async (_url: any, _opts: any) => ({
ok: true,
json: async () => ({
jsonrpc: "2.0",
id: 1,
result: {
number: "0x1234",
parentHash: "0x0000",
stateRoot: "0x0000",
extrinsicsRoot: "0x0000",
},
}),
})) as any
// Import dynamically to avoid module-level side effects
const mod = await import("../src/domain/substrate-client.js")
const blockNum = await mod.getBlockNumber("https://fake-rpc.test")
assert.equal(blockNum, 0x1234)
})
it("getGenesisHash should return the genesis block hash", async () => {
const expectedHash = "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3"
globalThis.fetch = (async (_url: any, _opts: any) => ({
ok: true,
json: async () => ({
jsonrpc: "2.0",
id: 1,
result: expectedHash,
}),
})) as any
const mod = await import("../src/domain/substrate-client.js")
const hash = await mod.getGenesisHash("https://fake-rpc.test")
assert.equal(hash, expectedHash)
})
it("getRuntimeVersion should return spec and transaction versions", async () => {
globalThis.fetch = (async (_url: any, _opts: any) => ({
ok: true,
json: async () => ({
jsonrpc: "2.0",
id: 1,
result: {
specName: "polkadot",
specVersion: 1002000,
transactionVersion: 25,
},
}),
})) as any
const mod = await import("../src/domain/substrate-client.js")
const version = await mod.getRuntimeVersion("https://fake-rpc.test")
assert.equal(version.specVersion, 1002000)
assert.equal(version.transactionVersion, 25)
})
it("getAccountNonce should return the next index", async () => {
globalThis.fetch = (async (_url: any, _opts: any) => ({
ok: true,
json: async () => ({
jsonrpc: "2.0",
id: 1,
result: 42,
}),
})) as any
const mod = await import("../src/domain/substrate-client.js")
const nonce = await mod.getAccountNonce("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", "https://fake-rpc.test")
assert.equal(nonce, 42)
})
it("submitExtrinsic should return the extrinsic hash", async () => {
const expectedHash = "0xabcdef1234567890"
globalThis.fetch = (async (_url: any, _opts: any) => ({
ok: true,
json: async () => ({
jsonrpc: "2.0",
id: 1,
result: expectedHash,
}),
})) as any
const mod = await import("../src/domain/substrate-client.js")
const hash = await mod.submitExtrinsic("0x1234", "https://fake-rpc.test")
assert.equal(hash, expectedHash)
})
it("submitExtrinsic should throw on RPC error", async () => {
globalThis.fetch = (async (_url: any, _opts: any) => ({
ok: true,
json: async () => ({
jsonrpc: "2.0",
id: 1,
error: { code: -32000, message: "Invalid extrinsic" },
}),
})) as any
const mod = await import("../src/domain/substrate-client.js")
await assert.rejects(
() => mod.submitExtrinsic("0xbad", "https://fake-rpc.test"),
/Invalid extrinsic/,
)
})
it("submitExtrinsic should throw on HTTP error", async () => {
globalThis.fetch = (async (_url: any, _opts: any) => ({
ok: false,
status: 500,
statusText: "Internal Server Error",
})) as any
const mod = await import("../src/domain/substrate-client.js")
await assert.rejects(
() => mod.submitExtrinsic("0x1234", "https://fake-rpc.test"),
/500/,
)
})
})
+1
View File
@@ -9,6 +9,7 @@ type NetworkType =
| "btc"
| "xrp"
| "cardano"
| "substrate"
export {