Skip to main content

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).
PropertyValue
CurveEd25519
Root WalletSolana
Address Formatbech32 (addr_test for preprod)
HashingBlake2b-224
SerializationCBOR
Smallest UnitLovelace (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";
  }
}