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]);