Skip to main content

Overview

Cardano uses the Ed25519 elliptic curve — the same as Solana. Enterprise addresses are derived by taking the Blake2b-224 (28-byte) hash of the Ed25519 public key, prepending a network/type header byte, and bech32-encoding with the addr_test (testnet) or addr (mainnet) prefix.
PropertyValue
CurveEd25519
Root WalletSolana
Address Formatbech32 (addr_test testnet / addr mainnet)
HashingBlake2b-224 (address), Blake2b-256 (tx body)
SerializationCBOR
Smallest Unitlovelace (1 ADA = 1,000,000 lovelace)

Dependencies

Add to pubspec.yaml:
dependencies:
  hashlib: ^1.19.2       # Blake2b with configurable output size
  bech32: ^0.2.2         # Bech32 encoding
  bs58: ^1.0.2           # Base58 (Solana address decoding)
  http: ^1.2.2           # HTTP client

Derive Address

Cardano enterprise addresses use Blake2b-224 of the public key with a network prefix byte:
import 'dart:typed_data';
import 'package:bech32/bech32.dart';
import 'package:bs58/bs58.dart';
import 'package:hashlib/hashlib.dart' as hashlib;

/// Derive a Cardano enterprise address (preprod testnet) from a Solana address.
String deriveCardanoAddress(String solanaAddress) {
  final pubkey = Uint8List.fromList(base58.decode(solanaAddress));
  final keyHash = Uint8List.fromList(
    hashlib.Blake2b(28).convert(pubkey).bytes,
  );
  final payload = Uint8List(29);
  payload[0] = 0x60; // enterprise address, preprod testnet (0xe0 for mainnet)
  payload.setAll(1, keyHash);
  final words = _convertBits(payload, 8, 5, true);
  return Bech32Codec().encode(Bech32('addr_test', words), 108);
}

List<int> _convertBits(Uint8List data, int fromBits, int toBits, bool pad) {
  var acc = 0;
  var bits = 0;
  final result = <int>[];
  final maxv = (1 << toBits) - 1;
  for (final value in data) {
    acc = (acc << fromBits) | value;
    bits += fromBits;
    while (bits >= toBits) {
      bits -= toBits;
      result.add((acc >> bits) & maxv);
    }
  }
  if (pad && bits > 0) result.add((acc << (toBits - bits)) & maxv);
  return result;
}

// Usage
final solWallet = DynamicSDK.instance.wallets.userWallets
    .firstWhere((w) => w.chain == 'SOL');
final cardanoAddress = deriveCardanoAddress(solWallet.address);
// → "addr_test1vp..." (testnet)

Sign a Message

Cardano message signing uses Blake2b-256 of the raw message bytes:
import 'dart:convert';
import 'dart:typed_data';
import 'package:hashlib/hashlib.dart' as hashlib;

/// Compute the Cardano message digest: blake2b-256(messageBytes)
String cardanoMessageDigest(String message) {
  final msgBytes = Uint8List.fromList(utf8.encode(message));
  return bytesToHex(
    Uint8List.fromList(hashlib.blake2b256.convert(msgBytes).bytes),
  );
}

Future<String> signCardanoMessage(String message) async {
  final solWallet = DynamicSDK.instance.wallets.userWallets
      .firstWhere((w) => w.chain == 'SOL');

  final hexDigest = cardanoMessageDigest(message);

  final signer = DynamicSDK.instance.solana.createSigner(wallet: solWallet);
  final signature = await signer.signMessage(message: hexDigest);

  return signature;
}

Sign a Transaction

Cardano transactions are CBOR-serialized. The signing payload is the Blake2b-256 hash of the serialized transaction body:
import 'dart:convert';
import 'dart:typed_data';
import 'package:bech32/bech32.dart';
import 'package:bs58/bs58.dart';
import 'package:hashlib/hashlib.dart' as hashlib;
import 'package:http/http.dart' as http;

const _koios = 'https://preprod.koios.rest/api/v1'; // testnet

Future<String> sendCardanoTransfer({
  required String recipient,
  required double amountAda,
  required String cardanoAddress,
  required String solanaAddress,
  required SignWithSolana signFn, // callback: (String hexDigest) → Future<String>
}) async {
  final pubkey = Uint8List.fromList(base58.decode(solanaAddress));

  final lovelace = BigInt.from((amountAda * 1000000).round());
  final fixedFee = BigInt.from(200000);
  final minUtxo = BigInt.from(1000000);

  // Fetch UTxOs and current slot in parallel
  final results = await Future.wait([
    _koiosPost('/address_utxos', {
      '_addresses': [cardanoAddress],
      '_extended': false,
    }),
    _koiosGet('/tip'),
  ]);

  final utxos = results[0] as List<dynamic>;
  final tip = results[1] as List<dynamic>;
  if (utxos.isEmpty) {
    throw Exception('No UTxOs available. Fund via https://docs.cardano.org/cardano-testnets/tools/faucet/');
  }

  final currentSlot = (tip[0] as Map<String, dynamic>)['abs_slot'] as int;
  final ttl = currentSlot + 7200;

  // Greedy UTxO selection
  var total = BigInt.zero;
  final selected = <Map<String, dynamic>>[];
  for (final utxo in utxos) {
    selected.add(utxo as Map<String, dynamic>);
    total += BigInt.parse((utxo['value'] ?? '0').toString());
    if (total >= lovelace + fixedFee) break;
  }
  if (total < lovelace + fixedFee) {
    throw Exception('Insufficient balance');
  }

  // Sort inputs canonically
  selected.sort((a, b) {
    final cmp = (a['tx_hash'] as String).compareTo(b['tx_hash'] as String);
    if (cmp != 0) return cmp;
    return (a['tx_index'] as int).compareTo(b['tx_index'] as int);
  });

  final toAddrBytes = _addressToBytes(recipient);
  final fromAddrBytes = _addressToBytes(cardanoAddress);
  final change = total - lovelace - fixedFee;
  final actualFee = change >= minUtxo ? fixedFee : total - lovelace;

  // Build CBOR-encoded transaction body
  final encodedInputs = selected.map((u) => _cborArray([
    _cborBytes(hexToBytes(u['tx_hash'] as String)),
    _cborUint(u['tx_index'] as int),
  ])).toList();

  final outputs = <Uint8List>[
    _cborArray([_cborBytes(toAddrBytes), _cborUint(lovelace.toInt())]),
  ];
  if (change >= minUtxo) {
    outputs.add(_cborArray([_cborBytes(fromAddrBytes), _cborUint(change.toInt())]));
  }

  final txBody = _cborMap([
    [_cborUint(0), _cborSet(encodedInputs)],
    [_cborUint(1), _cborArray(outputs)],
    [_cborUint(2), _cborUint(actualFee.toInt())],
    [_cborUint(3), _cborUint(ttl)],
  ]);

  // Sign blake2b-256 hash of the tx body
  final txBodyHash = hashlib.blake2b256.convert(txBody).bytes;
  final signResult = await signFn(bytesToHex(Uint8List.fromList(txBodyHash)));
  final sigBytes = decodeSig(signResult);

  // Build witness and full transaction
  final pubkeyBytes = Uint8List.fromList(base58.decode(solanaAddress));
  final vkeyWitness = _cborArray([_cborBytes(pubkeyBytes), _cborBytes(sigBytes)]);
  final witnessSet = _cborMap([[_cborUint(0), _cborArray([vkeyWitness])]]);
  final tx = _cborArray([txBody, witnessSet, _cborTrue, _cborNull]);

  // Submit via Koios
  final submitRes = await http.post(
    Uri.parse('$_koios/submittx'),
    headers: {'Content-Type': 'application/cbor'},
    body: tx,
  );
  if (submitRes.statusCode != 200 && submitRes.statusCode != 202) {
    throw Exception('Submit failed (${submitRes.statusCode}): ${submitRes.body}');
  }
  return submitRes.body.replaceAll('"', '');
}

Uint8List _addressToBytes(String bech32Addr) {
  final decoded = Bech32Codec().decode(bech32Addr, 108);
  // Convert from 5-bit groups back to 8-bit bytes (no padding)
  var acc = 0; var bits = 0;
  final result = <int>[];
  for (final value in decoded.data) {
    acc = (acc << 5) | value; bits += 5;
    while (bits >= 8) { bits -= 8; result.add((acc >> bits) & 0xff); }
  }
  return Uint8List.fromList(result);
}

// CBOR helpers — uses a generalized header encoder to handle all length ranges.
// Major type is encoded in the top 3 bits; value/length in the remaining bits.
Uint8List _cborHeader(int majorType, int value) {
  final mt = majorType << 5;
  if (value < 24) return Uint8List.fromList([mt | value]);
  if (value < 0x100) return Uint8List.fromList([mt | 24, value]);
  if (value < 0x10000) return Uint8List.fromList([mt | 25, (value >> 8) & 0xff, value & 0xff]);
  return Uint8List.fromList([mt | 26, (value >> 24) & 0xff, (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]);
}

Uint8List _cborUint(int n) => _cborHeader(0, n);
Uint8List _cborBytes(Uint8List data) => concatBytes([_cborHeader(2, data.length), data]);
Uint8List _cborArray(List<Uint8List> items) => concatBytes([_cborHeader(4, items.length), ...items]);
Uint8List _cborSet(List<Uint8List> items) => concatBytes([Uint8List.fromList([0xd9, 0x01, 0x02]), _cborArray(items)]);
Uint8List _cborMap(List<List<Uint8List>> entries) {
  final parts = <Uint8List>[_cborHeader(5, entries.length)];
  for (final entry in entries) parts.addAll(entry);
  return concatBytes(parts);
}

final _cborTrue = Uint8List.fromList([0xf5]);
final _cborNull = Uint8List.fromList([0xf6]);
For the full CBOR encoding helpers with proper length-prefixed byte encoding, see flutter-multichain-demo/lib/chains/cardano.dart.

Check Balance

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<String> getCardanoBalance(String cardanoAddress) async {
  try {
    final res = await http.post(
      Uri.parse('$_koios/address_info'),
      headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
      body: json.encode({'_addresses': [cardanoAddress]}),
    );
    if (res.statusCode != 200) return '0';
    final data = json.decode(res.body) as List<dynamic>;
    if (data.isEmpty) return '0';
    final lovelace = BigInt.parse(
      (data[0] as Map<String, dynamic>)['balance']?.toString() ?? '0',
    );
    return (lovelace / BigInt.from(1000000)).toString();
  } catch (_) {
    return '0';
  }
}

Future<dynamic> _koiosPost(String path, dynamic body) async {
  final res = await http.post(
    Uri.parse('$_koios$path'),
    headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
    body: json.encode(body),
  );
  if (res.statusCode < 200 || res.statusCode >= 300) {
    throw Exception('Koios error $path: ${res.statusCode}');
  }
  return json.decode(res.body);
}

Future<dynamic> _koiosGet(String path) async {
  final res = await http.get(
    Uri.parse('$_koios$path'),
    headers: {'Accept': 'application/json'},
  );
  if (res.statusCode < 200 || res.statusCode >= 300) {
    throw Exception('Koios error $path: ${res.statusCode}');
  }
  return json.decode(res.body);
}