Files
sdk/packages/tx/src/cardano-select.ts
zeekay d25e88bba2 feat(tx): promote polkadot + cardano partial -> ready; add cardano coin-selection
Both builders already emit complete signable payloads when the intent
carries the standard chain-state (same contract as EVM nonce/gas or
Solana blockhash) — the 'partial' label was conservative. Now:

- polkadot: ready. New test proves the builder emits the COMPLETE
  GenericExtrinsicPayload signing bytes byte-for-byte vs an
  independently-built payload (method+era+nonce+tip+specVersion+
  transactionVersion+genesisHash+blockHash), plus a mortal-era+tip test.
- cardano: ready. selectCardanoInputs(utxos, outputs, params, change, ttl)
  does largest-first coin-selection with the EXACT min-fee via CSL
  min_fee — iterates the fee/change fixpoint (TransactionBody is immutable
  + the fee field's own CBOR length feeds back into the fee). Test asserts
  the fee equals an independent CSL min_fee of the produced tx, conserves
  value, drops dust change, and feeds end-to-end into buildCardanoUnsignedTx.

BUILDER_STATUS: polkadot/cardano -> ready (Lux P/X/Q/Z still todo).
45 tests green. Libs: @polkadot/types (Apache-2.0),
@emurgo/cardano-serialization-lib-nodejs (MIT/Apache) — zero copyleft.
2026-06-24 16:05:02 -07:00

230 lines
8.5 KiB
TypeScript

/**
* Cardano coin-selection + min-fee helper — REAL, async (WASM), permissive.
*
* Lib: @emurgo/cardano-serialization-lib-nodejs (MIT/Apache) — lazily
* `import()`ed, same as the builder, so the WASM stays off the sync path.
* Browser/native consumers swap the `-browser`/`-asmjs` build.
*
* Turns a UTXO set + recipient outputs + protocol params into a complete
* `{ inputs, outputs(+change), fee, ttl }` that feeds straight into
* `buildCardanoUnsignedTx`. The EXACT min-fee is computed by CSL's
* `min_fee(tx, linearFee)` over a candidate transaction whose witness
* set is padded with `signerCount` dummy vkey witnesses (each a fixed
* 32B vkey + 64B sig) — this is how Cardano sizes fees: the fee is a
* linear function of the fully-witnessed tx byte length, and the witness
* bytes are deterministic for a known key count. We never sign here; the
* dummy witnesses exist only to size the fee.
*
* Strategy: accumulative largest-first (deterministic, minimal inputs).
* Change handling: if the leftover after target + fee covers the
* minimum-ADA-per-UTXO for a change output, append change and recompute
* the fee with it; otherwise drop the leftover to fee (no change output).
*/
import type {
CardanoOutput,
CardanoProtocolParams,
CardanoSelection,
CardanoUtxo,
} from "./types.js";
type CSL = typeof import("@emurgo/cardano-serialization-lib-nodejs");
let cslPromise: Promise<CSL> | undefined;
async function loadCsl(): Promise<CSL> {
if (!cslPromise) cslPromise = import("@emurgo/cardano-serialization-lib-nodejs");
return cslPromise;
}
function hexDecode(hexStr: string): Uint8Array {
const clean = hexStr.startsWith("0x") ? hexStr.slice(2) : hexStr;
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
return out;
}
/** Build the inputs collection from the chosen UTXOs. */
function buildInputs(CSL: CSL, chosen: CardanoUtxo[]) {
const inputs = CSL.TransactionInputs.new();
for (const u of chosen) {
inputs.add(CSL.TransactionInput.new(CSL.TransactionHash.from_bytes(hexDecode(u.txid)), u.index));
}
return inputs;
}
/** Build the outputs collection (recipients, optionally + change). */
function buildOutputs(CSL: CSL, outs: CardanoOutput[]) {
const outputs = CSL.TransactionOutputs.new();
for (const o of outs) {
outputs.add(
CSL.TransactionOutput.new(
CSL.Address.from_bech32(o.address),
CSL.Value.new(CSL.BigNum.from_str(o.lovelace)),
),
);
}
return outputs;
}
/** A witness set with `n` dummy vkey witnesses — for fee SIZING only. */
function dummyWitnessSet(CSL: CSL, n: number) {
const ws = CSL.TransactionWitnessSet.new();
if (n <= 0) return ws;
const vkeys = CSL.Vkeywitnesses.new();
const vk = CSL.Vkey.new(CSL.PublicKey.from_bytes(new Uint8Array(32)));
const sig = CSL.Ed25519Signature.from_bytes(new Uint8Array(64));
for (let i = 0; i < n; i++) vkeys.add(CSL.Vkeywitness.new(vk, sig));
ws.set_vkeys(vkeys);
return ws;
}
type TxBody = import("@emurgo/cardano-serialization-lib-nodejs").TransactionBody;
/**
* Exact min-fee (lovelace) for the body produced by `makeBody(fee)` with
* `signerCount` witnesses. Cardano's fee is a linear function of the
* FULLY-witnessed serialized size, and the fee field's own CBOR length
* grows with its value — so the fee depends on itself. `TransactionBody`
* is immutable (fee is a constructor arg), so we iterate to a fixpoint by
* rebuilding the body with the current fee estimate each round (converges
* in ≤2 rounds). The result equals `min_fee` of the final broadcastable
* tx, which is exactly what `buildCardanoUnsignedTx` will encode with this
* fee — no underpay, no overpay.
*/
function minFee(
CSL: CSL,
makeBody: (fee: bigint) => TxBody,
signerCount: number,
params: CardanoProtocolParams,
): bigint {
const ws = dummyWitnessSet(CSL, signerCount);
const linearFee = CSL.LinearFee.new(
CSL.BigNum.from_str(String(params.minFeeA)),
CSL.BigNum.from_str(String(params.minFeeB)),
);
let fee = 0n;
for (let i = 0; i < 4; i++) {
const body = makeBody(fee);
const tx = CSL.Transaction.new(body, ws);
const next = BigInt(CSL.min_fee(tx, linearFee).to_str());
tx.free();
body.free();
if (next === fee) break;
fee = next;
}
ws.free();
linearFee.free();
return fee;
}
/** Minimum lovelace a UTXO (here: the change output) must hold. */
function minChangeAda(
CSL: CSL,
changeAddress: string,
params: CardanoProtocolParams,
): bigint {
const dummy = CSL.TransactionOutput.new(
CSL.Address.from_bech32(changeAddress),
CSL.Value.new(CSL.BigNum.from_str("1000000")),
);
const dataCost = CSL.DataCost.new_coins_per_byte(CSL.BigNum.from_str(String(params.coinsPerUtxoByte)));
const min = BigInt(CSL.min_ada_for_output(dummy, dataCost).to_str());
dummy.free();
dataCost.free();
return min;
}
function sum(items: { lovelace: string }[]): bigint {
return items.reduce((a, x) => a + BigInt(x.lovelace), 0n);
}
/**
* Select inputs from `utxos` to fund `outputs` at the network's min-fee,
* appending a change output to `changeAddress` when it clears the
* minimum-ADA threshold. `signerCount` is the number of DISTINCT signing
* keys (default 1 — a single-account wallet signs every input with one
* key); it drives the witness-set size used for the fee.
*
* @throws if the candidate set cannot cover outputs + fee.
*/
export async function selectCardanoInputs(
utxos: CardanoUtxo[],
outputs: CardanoOutput[],
params: CardanoProtocolParams,
changeAddress: string,
ttl: number,
signerCount = 1,
): Promise<CardanoSelection> {
if (utxos.length === 0) throw new Error("@luxwallet/tx: cardano selection needs >= 1 utxo");
if (outputs.length === 0) throw new Error("@luxwallet/tx: cardano selection needs >= 1 output");
const CSL = await loadCsl();
try {
const target = sum(outputs);
const minChange = minChangeAda(CSL, changeAddress, params);
const sorted = [...utxos].sort((a, b) => (BigInt(b.lovelace) > BigInt(a.lovelace) ? 1 : -1));
const chosen: CardanoUtxo[] = [];
let inTotal = 0n;
// Build a tx body for the given output set at the given fee. The fee
// (and change) varies between fixpoint rounds, so this is a closure.
const bodyFor = (outs: CardanoOutput[]) => (fee: bigint): TxBody => {
const inputs = buildInputs(CSL, chosen);
const outputsColl = buildOutputs(CSL, outs);
const body = CSL.TransactionBody.new_tx_body(inputs, outputsColl, CSL.BigNum.from_str(fee.toString()));
body.set_ttl(CSL.BigNum.from_str(String(ttl)));
inputs.free();
outputsColl.free();
return body;
};
for (const u of sorted) {
chosen.push(u);
inTotal += BigInt(u.lovelace);
// Fee WITHOUT change (recipients only).
const feeNoChange = minFee(CSL, bodyFor(outputs), signerCount, params);
if (inTotal < target + feeNoChange) continue;
// Fee + change are coupled: the change output's lovelace value
// changes its CBOR length, which changes the fee, which changes the
// change. Solve the fixpoint with the ACTUAL change value in the
// body (seed with min-ADA), so the reported fee equals min_fee of
// the exact tx buildCardanoUnsignedTx will encode.
let change = minChange;
let feeWithChange = 0n;
for (let i = 0; i < 4; i++) {
const changeOut: CardanoOutput = { address: changeAddress, lovelace: change.toString() };
feeWithChange = minFee(CSL, bodyFor([...outputs, changeOut]), signerCount, params);
const nextChange = inTotal - target - feeWithChange;
if (nextChange === change) break;
change = nextChange < 0n ? 0n : nextChange;
}
if (change >= minChange) {
return {
inputs: chosen.map((u) => ({ txid: u.txid, index: u.index })),
outputs: [...outputs, { address: changeAddress, lovelace: change.toString() }],
fee: feeWithChange.toString(),
ttl,
change: change.toString(),
};
}
// Change below min-ADA → drop to fee, recipients only.
return {
inputs: chosen.map((u) => ({ txid: u.txid, index: u.index })),
outputs: [...outputs],
fee: (inTotal - target).toString(),
ttl,
change: "0",
};
}
throw new Error(
`@luxwallet/tx: insufficient funds — have ${inTotal} lovelace, need ${target} + fee`,
);
} finally {
// CSL objects above are freed inline; nothing module-level to release.
}
}