mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
bridge: Cardano gets a Cardano address
You were right that the crypto is already there. Cardano signs with the same Ed25519 key Solana does, through FROST, which luxfi/threshold and luxfi/mpc support natively. The key was never the problem. The address was. Solana's address IS the public key, base58. Cardano's is blake2b-224 of that key inside a typed, network-tagged bech32 envelope. NETWORK_ADDRESS_TYPE mapped CARDANO_MAINNET to 'sol' — the comment beside it said "placeholder" — so the bridge handed out a Solana-shaped string and told people to send ADA to it. That is offered on bridge-api.lux.network today. cardano-address.ts builds the CIP-19 enterprise address: payment credential only, no staking part, which is the right shape for custody — a base address would commit to a staking credential the committee does not hold. blake2b-224 is NOT blake2b-512 truncated. Blake2b takes its digest length into the parameter block, so they differ from the first byte, and Node's fixed-length blake2b512 cannot be used. There is a test asserting exactly that, so anyone substituting a truncating implementation finds out immediately rather than after the funds move. Also asserted: the address is not the public key (the bug this replaces), the network tag distinguishes mainnet from testnet, a key that is not 32 bytes is refused rather than hashed into a plausible address for a key that does not exist, and encode/decode agree. The type union gained 'ada' rather than the map being widened to string, so the next family added has to be handled in the switch instead of falling through to 'eth' — which is how Cardano would have got an Ethereum address if the default had caught it. 25 networks in the config now. Nineteen EVM, all assemblable; Bitcoin, Solana, TON, XRP, Polkadot and Cardano correctly excluded from the EVM assembler. NOT VERIFIED against a live Cardano node — the encoder is self-consistent and matches CIP-19 as written, but no address produced here has been used on chain. Worth one testnet deposit before mainnet funds move through it.
This commit is contained in:
@@ -66,7 +66,9 @@
|
||||
"@luxfi/core": "workspace:*",
|
||||
"@luxfi/utila": "workspace:^",
|
||||
"@noble/ed25519": "^3.1.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@scure/base": "^2.4.0",
|
||||
"@solana/addresses": "6.9.0",
|
||||
"axios": "^1.16.1",
|
||||
"cors": "^2.8.6",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import {
|
||||
CardanoNetwork,
|
||||
decodeCardanoAddress,
|
||||
encodeCardanoAddress,
|
||||
paymentCredential,
|
||||
} from "./cardano-address.js"
|
||||
|
||||
/** A fixed key, so every expectation below is about the encoder. */
|
||||
const key = new Uint8Array(32).fill(0xab)
|
||||
|
||||
test("blake2b-224 is not blake2b-512 truncated", async () => {
|
||||
// The distinction the whole file rests on. Blake2b takes its digest length
|
||||
// into the parameter block, so the two differ from the first byte — and
|
||||
// Node's fixed-length blake2b512 is therefore unusable here. If this ever
|
||||
// passes, someone has substituted a truncating implementation.
|
||||
const { blake2b } = await import("@noble/hashes/blake2.js")
|
||||
const short = blake2b(key, { dkLen: 28 })
|
||||
const long = blake2b(key, { dkLen: 64 }).slice(0, 28)
|
||||
assert.equal(short.length, 28)
|
||||
assert.notDeepEqual(short, long, "blake2b-224 equalled truncated blake2b-512")
|
||||
})
|
||||
|
||||
test("a mainnet address is an enterprise address on mainnet", () => {
|
||||
const addr = encodeCardanoAddress(key, CardanoNetwork.Mainnet)
|
||||
assert.ok(addr.startsWith("addr1"), `mainnet addresses start addr1, got ${addr.slice(0, 12)}`)
|
||||
|
||||
const got = decodeCardanoAddress(addr)
|
||||
assert.equal(got.type, 0b0110, "type 6 is payment-only, with no staking part")
|
||||
assert.equal(got.network, 1, "mainnet")
|
||||
assert.deepEqual(got.payment, paymentCredential(key))
|
||||
})
|
||||
|
||||
test("a testnet address is not a mainnet one", () => {
|
||||
// The network tag lives in the header, so an address built for one network
|
||||
// is refused by the other rather than silently accepted.
|
||||
const test_ = encodeCardanoAddress(key, CardanoNetwork.Testnet)
|
||||
assert.ok(test_.startsWith("addr_test1"), test_.slice(0, 12))
|
||||
assert.equal(decodeCardanoAddress(test_).network, 0)
|
||||
assert.notEqual(test_, encodeCardanoAddress(key, CardanoNetwork.Mainnet))
|
||||
})
|
||||
|
||||
test("the address is not the public key", () => {
|
||||
// The bug this replaces: Solana's address IS the key, Cardano's is a hash
|
||||
// of it inside an envelope. Any encoder that passes the key through would
|
||||
// reproduce exactly the failure that lost the funds.
|
||||
const hex = Buffer.from(key).toString("hex")
|
||||
const addr = encodeCardanoAddress(key)
|
||||
assert.ok(!addr.includes(hex), "the raw key appears in the address")
|
||||
assert.notDeepEqual(paymentCredential(key), key)
|
||||
})
|
||||
|
||||
test("a different key gives a different address", () => {
|
||||
const other = new Uint8Array(32).fill(0xcd)
|
||||
assert.notEqual(encodeCardanoAddress(key), encodeCardanoAddress(other))
|
||||
})
|
||||
|
||||
test("a key that is not 32 bytes is refused", () => {
|
||||
// Rather than hashing whatever it was handed and producing a plausible
|
||||
// address for a key that does not exist.
|
||||
for (const n of [0, 31, 33, 64]) {
|
||||
assert.throws(() => encodeCardanoAddress(new Uint8Array(n)), /32 bytes/)
|
||||
}
|
||||
})
|
||||
|
||||
test("encode and decode agree", () => {
|
||||
const addr = encodeCardanoAddress(key)
|
||||
const back = decodeCardanoAddress(addr)
|
||||
assert.equal(back.payment.length, 28)
|
||||
assert.equal(encodeCardanoAddress(key), addr)
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { blake2b } from "@noble/hashes/blake2.js"
|
||||
import { bech32 } from "@scure/base"
|
||||
|
||||
/**
|
||||
* Cardano addresses from an Ed25519 public key, per CIP-19.
|
||||
*
|
||||
* The key is the same one Solana uses — Ed25519, signed by FROST, which
|
||||
* luxfi/threshold supports natively. Only the address differs: Solana's is the
|
||||
* base58 public key itself, Cardano's is a hash of it inside a typed,
|
||||
* network-tagged, bech32 envelope. Mapping Cardano to Solana's address type
|
||||
* therefore produced a well-formed string that meant nothing on Cardano.
|
||||
*
|
||||
* An enterprise address is built here — payment credential only, no staking
|
||||
* part. It is the right shape for a bridge custody address: funds can be
|
||||
* received and spent, and nothing is delegated. A base address would commit to
|
||||
* a staking credential the committee does not have.
|
||||
*
|
||||
* header 1 byte type in the high nibble, network in the low
|
||||
* payment 28 bytes blake2b-224 of the public key
|
||||
* bech32 hrp "addr" on mainnet, "addr_test" elsewhere
|
||||
*
|
||||
* blake2b-224 is not blake2b-512 truncated. Blake2b takes its digest length
|
||||
* into the parameter block, so the two differ from the first byte — which is
|
||||
* why Node's fixed-length blake2b512 cannot be used here.
|
||||
*/
|
||||
|
||||
/** Address type 6: payment credential, no staking part. */
|
||||
const ENTERPRISE = 0b0110
|
||||
|
||||
/** Network tag: the low nibble of the header byte. */
|
||||
export const CardanoNetwork = {
|
||||
Mainnet: 1,
|
||||
Testnet: 0,
|
||||
} as const
|
||||
|
||||
export type CardanoNetworkID = (typeof CardanoNetwork)[keyof typeof CardanoNetwork]
|
||||
|
||||
/** The 28-byte payment credential: blake2b-224 of the public key. */
|
||||
export function paymentCredential(pubKey: Uint8Array): Uint8Array {
|
||||
if (pubKey.length !== 32) {
|
||||
throw new Error(`cardano: an Ed25519 public key is 32 bytes, got ${pubKey.length}`)
|
||||
}
|
||||
return blake2b(pubKey, { dkLen: 28 })
|
||||
}
|
||||
|
||||
/** An enterprise address for this key on this network. */
|
||||
export function encodeCardanoAddress(
|
||||
pubKey: Uint8Array,
|
||||
network: CardanoNetworkID = CardanoNetwork.Mainnet,
|
||||
): string {
|
||||
const header = (ENTERPRISE << 4) | network
|
||||
const payload = new Uint8Array(29)
|
||||
payload[0] = header
|
||||
payload.set(paymentCredential(pubKey), 1)
|
||||
|
||||
const hrp = network === CardanoNetwork.Mainnet ? "addr" : "addr_test"
|
||||
// Cardano addresses exceed bech32's default 90-character limit, and the
|
||||
// spec says to ignore it rather than truncate.
|
||||
return bech32.encode(hrp, bech32.toWords(payload), 200)
|
||||
}
|
||||
|
||||
/** The bytes behind an address, for checking what was produced. */
|
||||
export function decodeCardanoAddress(address: string): {
|
||||
network: number
|
||||
type: number
|
||||
payment: Uint8Array
|
||||
} {
|
||||
const { words } = bech32.decode(address as `${string}1${string}`, 200)
|
||||
const bytes = bech32.fromWords(words)
|
||||
if (bytes.length !== 29) {
|
||||
throw new Error(`cardano: an enterprise address is 29 bytes, got ${bytes.length}`)
|
||||
}
|
||||
return {
|
||||
network: bytes[0]! & 0x0f,
|
||||
type: (bytes[0]! >> 4) & 0x0f,
|
||||
payment: Uint8Array.from(bytes.slice(1)),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import logger from "@/logger"
|
||||
import { IAMClient } from "@/clients/iam"
|
||||
import { MPCClient } from "@/clients/mpc"
|
||||
import { CardanoNetwork, encodeCardanoAddress } from "./cardano-address.js"
|
||||
import { encodeSubstrateAddress, SUBSTRATE_NETWORKS } from "./substrate-address.js"
|
||||
import { checkSubstrateDeposit } from "./substrate-deposit.js"
|
||||
|
||||
@@ -36,7 +37,7 @@ export function _resetMpcWalletClientForTests(): void {
|
||||
}
|
||||
|
||||
// Network type to address field mapping
|
||||
const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp' | 'dot'> = {
|
||||
const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp' | 'dot' | 'ada'> = {
|
||||
// EVM chains use eth address
|
||||
ETHEREUM_MAINNET: 'eth',
|
||||
ETHEREUM_SEPOLIA: 'eth',
|
||||
@@ -78,8 +79,11 @@ const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp'
|
||||
XRP_TESTNET: 'xrp',
|
||||
// Polkadot / Substrate
|
||||
POLKADOT_MAINNET: 'dot',
|
||||
// Cardano (placeholder — needs Ed25519)
|
||||
CARDANO_MAINNET: 'sol',
|
||||
// Cardano. Same curve and same FROST signing as Solana — the key is not
|
||||
// the problem. The ADDRESS is: Cardano is bech32 addr1... over
|
||||
// blake2b-224, Solana is the base58 public key. Mapped to 'sol' this
|
||||
// issued Solana-shaped deposit addresses for ADA.
|
||||
CARDANO_MAINNET: 'ada',
|
||||
}
|
||||
|
||||
// The MPCClient.keygen / getWallet response shapes are defined in
|
||||
@@ -97,6 +101,7 @@ const NETWORK_ADDRESS_TYPE: Record<string, 'eth' | 'btc' | 'sol' | 'ton' | 'xrp'
|
||||
* btc → secp256k1 derived (P2WPKH default)
|
||||
* sol/ton → ed25519 derived
|
||||
* dot → SS58-encoded ed25519 public key
|
||||
* ada → CIP-19 bech32 over blake2b-224 of the ed25519 public key
|
||||
*/
|
||||
export async function createMPCWalletForDeposit(
|
||||
networkInternalName: string,
|
||||
@@ -146,6 +151,29 @@ export async function createMPCWalletForDeposit(
|
||||
case "btc":
|
||||
address = wallet.btcAddress
|
||||
break
|
||||
case "ada": {
|
||||
// Same key as Solana — Ed25519, signed by FROST, which
|
||||
// luxfi/threshold supports natively. Only the address differs:
|
||||
// Solana's IS the public key, Cardano's is blake2b-224 of it inside a
|
||||
// typed, network-tagged bech32 envelope. Mapped to "sol" this handed
|
||||
// out a Solana-shaped string to send ADA to.
|
||||
const pubKeyHex = wallet.eddsaPubKey
|
||||
if (!pubKeyHex) {
|
||||
throw new Error(
|
||||
"MPC keygen did not return an eddsa public key for the Cardano address",
|
||||
)
|
||||
}
|
||||
address = encodeCardanoAddress(
|
||||
new Uint8Array(
|
||||
Buffer.from(
|
||||
pubKeyHex.startsWith("0x") ? pubKeyHex.slice(2) : pubKeyHex,
|
||||
"hex",
|
||||
),
|
||||
),
|
||||
CardanoNetwork.Mainnet,
|
||||
)
|
||||
break
|
||||
}
|
||||
case "sol":
|
||||
case "ton":
|
||||
address = wallet.solAddress
|
||||
|
||||
@@ -264,12 +264,14 @@ networks:
|
||||
txExplorerTpl: https://polkascan.io/polkadot/extrinsic/{addr}
|
||||
addrExplorerTpl: https://polkascan.io/polkadot/account/{addr}
|
||||
|
||||
# CARDANO is deliberately absent. Its address type in the MPC wallet is
|
||||
# `sol` — the comment beside it says "placeholder" — and Cardano is not
|
||||
# Solana. Both are Ed25519 so the KEY is fine, but a Cardano address is
|
||||
# bech32 `addr1...` and a Solana one is a base58 public key. Offering it
|
||||
# would hand a user a Solana-shaped address to send ADA to, and the funds
|
||||
# would be gone. It goes in when it has an encoder, not before.
|
||||
- internalName: CARDANO_MAINNET
|
||||
displayName: Cardano
|
||||
nativeCurrency: ADA
|
||||
type: cardano
|
||||
isFeatured: false
|
||||
avgCompletion: 2m
|
||||
txExplorerTpl: https://cardanoscan.io/transaction/{tx}
|
||||
addrExplorerTpl: https://cardanoscan.io/address/{addr}
|
||||
|
||||
tokens:
|
||||
# The chains Lux runs — native coin only. Liquid tokens get added per
|
||||
@@ -474,6 +476,10 @@ tokens:
|
||||
name: Polkadot
|
||||
decimals: 10
|
||||
network: POLKADOT_MAINNET
|
||||
- asset: ADA
|
||||
name: Cardano
|
||||
decimals: 6
|
||||
network: CARDANO_MAINNET
|
||||
|
||||
limits:
|
||||
minUSD: 10
|
||||
|
||||
Reference in New Issue
Block a user