Overview
Cardano uses the Ed25519 elliptic curve — the same as Solana. You derive a Cardano enterprise address by hashing the Solana wallet’s public key with Blake2b-224 and encoding with bech32. Transactions use CBOR encoding and are submitted via the Koios API (no API key required).
| Property | Value |
|---|
| Curve | Ed25519 |
| Root Wallet | Solana |
| Address Format | bech32 (addr_test for preprod) |
| Hashing | Blake2b-224 |
| Serialization | CBOR |
| Smallest Unit | Lovelace (1 ADA = 10^6 lovelace) |
Dependencies
npm install @noble/curves @noble/hashes bs58 bech32
Derive Address
Decode the Solana base58 address to a 32-byte Ed25519 public key, hash with Blake2b-224, prepend the enterprise address header byte, and bech32-encode:
import { blake2b } from "@noble/hashes/blake2";
import { bech32 } from "bech32";
import bs58 from "bs58";
function deriveCardanoAddress(solanaAddress: string): string {
const pubkey = bs58.decode(solanaAddress);
const keyHash = blake2b(pubkey, { dkLen: 28 });
const payload = new Uint8Array(29);
payload[0] = 0x60; // enterprise address, preprod testnet
payload.set(keyHash, 1);
return bech32.encode("addr_test", bech32.toWords(payload), 108);
}
The header byte 0x60 indicates an enterprise address on preprod testnet. For mainnet, use 0x61 with the addr bech32 prefix.
Sign a Message
Sign the raw UTF-8 bytes of the message using the Solana wallet:
async function signCardanoMessage(message: string): Promise<string> {
const wallet = dynamicClient.wallets.userWallets.find(w => w.chain === "SOL")!;
const messageBytes = new TextEncoder().encode(message);
const { signedMessage } = await dynamicClient.wallets.signMessage({
wallet,
message: bytesToHex(messageBytes),
});
return signedMessage;
}
Sign a Transaction
Build a CBOR-encoded transaction body, hash with Blake2b-256, sign, and submit via Koios:
import { blake2b } from "@noble/hashes/blake2";
import { bech32 } from "bech32";
import bs58 from "bs58";
const KOIOS = "https://preprod.koios.rest/api/v1";
const FEE = 200_000n;
const MIN_UTXO = 1_000_000n;
async function sendCardanoTransfer(
to: string,
amountAda: number,
cardanoAddress: string,
): Promise<string> {
const wallet = dynamicClient.wallets.userWallets.find(w => w.chain === "SOL")!;
const lovelace = BigInt(Math.round(amountAda * 1_000_000));
// Fetch UTxOs and current slot in parallel
const [utxoRes, tipRes] = await Promise.all([
fetch(`${KOIOS}/address_utxos`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ _addresses: [cardanoAddress], _extended: false }),
}),
fetch(`${KOIOS}/tip`, { headers: { Accept: "application/json" } }),
]);
type Utxo = { tx_hash: string; tx_index: number; value: string };
const utxos = (await utxoRes.json()) as Utxo[];
const tip = (await tipRes.json()) as [{ abs_slot: number }];
if (!utxos?.length) throw new Error("No UTxOs available — fund your address first");
const ttl = tip[0].abs_slot + 7200;
// Greedy UTxO selection
const needed = lovelace + FEE;
let total = 0n;
const selected: Utxo[] = [];
for (const utxo of utxos) {
selected.push(utxo);
total += BigInt(utxo.value);
if (total >= needed) break;
}
if (total < needed) throw new Error(`Insufficient balance`);
// Decode bech32 addresses to raw bytes
const toAddrBytes = new Uint8Array(bech32.fromWords(bech32.decode(to, 108).words));
const fromAddrBytes = new Uint8Array(bech32.fromWords(bech32.decode(cardanoAddress, 108).words));
// Sort inputs canonically
selected.sort((a, b) => a.tx_hash < b.tx_hash ? -1 : a.tx_hash > b.tx_hash ? 1 : a.tx_index - b.tx_index);
const encodedInputs = selected.map(u => cborArray([cborBytes(hexToBytes(u.tx_hash)), cborUint(u.tx_index)]));
const changeLovelace = total - lovelace - FEE;
const actualFee = changeLovelace >= MIN_UTXO ? FEE : total - lovelace;
const outputs: Uint8Array[] = [cborArray([cborBytes(toAddrBytes), cborUint(lovelace)])];
if (changeLovelace >= MIN_UTXO) {
outputs.push(cborArray([cborBytes(fromAddrBytes), cborUint(changeLovelace)]));
}
const txBody = cborMap([
[cborUint(0), cborSet(encodedInputs)],
[cborUint(1), cborArray(outputs)],
[cborUint(2), cborUint(actualFee)],
[cborUint(3), cborUint(ttl)],
]);
const txBodyHash = blake2b(txBody, { dkLen: 32 }) as Uint8Array;
const { signedMessage } = await dynamicClient.wallets.signMessage({
wallet,
message: bytesToHex(txBodyHash),
});
const sigBytes = decodeSig(signedMessage);
const pubkey = bs58.decode(wallet.address);
const vkeyWitness = cborArray([cborBytes(pubkey), cborBytes(sigBytes)]);
const witnessSet = cborMap([[cborUint(0), cborArray([vkeyWitness])]]);
const tx = cborArray([txBody, witnessSet, CBOR_TRUE, CBOR_NULL]);
const submitRes = await fetch(`${KOIOS}/submittx`, {
method: "POST",
headers: { "Content-Type": "application/cbor" },
body: tx,
});
if (!submitRes.ok) throw new Error(`Submit failed: ${submitRes.status}`);
const result = await submitRes.text();
return result.replace(/^"|"$/g, "");
}
For the CBOR encoding primitives (cborUint, cborBytes, cborArray, cborMap, cborSet, CBOR_TRUE, CBOR_NULL) see the JavaScript Cardano guide.
Verify a Signature
import { ed25519 } from "@noble/curves/ed25519";
import bs58 from "bs58";
function verifyCardanoSignature(
message: string,
signature: string,
solanaAddress: string,
): boolean {
const pubkey = bs58.decode(solanaAddress);
const messageBytes = new TextEncoder().encode(message);
const sigBytes = decodeSig(signature);
return ed25519.verify(sigBytes, messageBytes, pubkey);
}
Check Balance
async function getCardanoBalance(cardanoAddress: string): Promise<string> {
try {
const res = await fetch("https://preprod.koios.rest/api/v1/address_info", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ _addresses: [cardanoAddress] }),
});
if (!res.ok) return "0";
const data = (await res.json()) as { balance?: string }[];
if (!data?.length) return "0";
return (Number(BigInt(data[0]?.balance ?? "0")) / 1_000_000).toString();
} catch {
return "0";
}
}