Repository Public
1import { Address, OutScript, Transaction } from '@scure/btc-signer';2import { getNetwork, type NetworkDef } from './networks.ts';3import { hex } from '@scure/base';4import { baseUnits } from './amounts.ts';5import { normalizeAddress } from './address.ts';6import type { Intent } from './intent.ts';78export const BTC_DUST = 546n;9/** Address/script parameters of a Bitcoin-style network, in the shape @scure/btc-signer expects. */10export function btcParams(n: NetworkDef) {11 if (!n.utxo) throw new Error(`${n.id} is not a UTXO network`);12 return { bech32: n.utxo.bech32 ?? 'none', pubKeyHash: n.utxo.pubKeyHash, scriptHash: n.utxo.scriptHash, wif: 0x80 };13}14export const dustOf = (n: NetworkDef) => BigInt(n.utxo?.dust ?? 546);1516/** Rebuild the unsigned PSBT-style transaction. All inputs are P2WPKH outputs of `from`; change returns to `from`. */17export function buildBtcTx(i: Intent, aux?: Record<string, unknown>): Transaction {18 const n = getNetwork(i.network);19 if (n.family !== 'btc' || !n.utxo || !i.btc) throw new Error('not a UTXO intent');20 if (i.xswap) throw new Error('this is a cross-chain swap intent, not a transfer');21 if (i.chainRef !== n.chainRef) throw new Error('chain identity mismatch');22 if (i.asset !== 'native' || i.decimals !== n.native.decimals || i.symbol !== n.native.symbol) throw new Error('asset mismatch');23 const P = btcParams(n), DUST = dustOf(n);24 if (normalizeAddress(i.network, i.to) !== i.to) throw new Error('destination not normalized');25 const fromDecoded = Address(P).decode(i.from);26 if (fromDecoded.type !== n.utxo.kind) throw new Error(n.utxo.kind === 'wpkh' ? 'source must be native SegWit (P2WPKH)' : 'source must be P2PKH');27 const script = OutScript.encode(fromDecoded);28 const amount = baseUnits(i.amount), change = baseUnits(i.btc.change), fee = baseUnits(i.btc.fee);29 if (amount < DUST) throw new Error('amount below dust');30 if (change !== 0n && change < DUST) throw new Error('change below dust');31 if (fee > baseUnits(i.maxFee)) throw new Error('fee exceeds approved maximum');32 const prev = (aux?.prevTx ?? {}) as Record<string, string>;33 const seen = new Set<string>();34 let total = 0n;35 const tx = new Transaction();36 for (const u of i.btc.inputs) {37 const k = `${u.txid}:${u.vout}`;38 if (seen.has(k)) throw new Error('duplicate input');39 seen.add(k);40 const v = baseUnits(u.value);41 total += v;42 if (n.utxo.kind === 'wpkh') tx.addInput({ txid: hex.decode(u.txid), index: u.vout, witnessUtxo: { script, amount: v }, sequence: 0xfffffffd });43 else {44 // legacy signatures do not commit to the input value: the full previous transaction is required and is checked45 // against the input's txid, script and value, so a lying API cannot inflate the fee46 const raw = prev[u.txid];47 if (!raw) throw new Error('missing previous transaction for a legacy input');48 const pt = Transaction.fromRaw(hex.decode(raw), { allowUnknownOutputs: true });49 if (pt.id !== u.txid) throw new Error('previous transaction does not match input txid');50 const o = pt.getOutput(u.vout);51 if (o.amount !== v || !o.script || hex.encode(o.script) !== hex.encode(script)) throw new Error('previous output does not match the approved input');52 tx.addInput({ txid: hex.decode(u.txid), index: u.vout, nonWitnessUtxo: hex.decode(raw), sequence: 0xfffffffd });53 }54 }55 if (total !== amount + change + fee) throw new Error('inputs != outputs + fee');56 tx.addOutputAddress(i.to, amount, P);57 if (change > 0n) tx.addOutputAddress(i.from, change, P);58 return tx;59}6061/** Conservative vsize estimate for P2WPKH inputs and up to two outputs. */62export function estimateBtcVsize(nInputs: number, toAddr: string, hasChange: boolean, n: NetworkDef = getNetwork('bitcoin')): number {63 const t = Address(btcParams(n)).decode(toAddr).type;64 const outSize = t === 'wpkh' ? 31 : t === 'tr' || t === 'wsh' ? 43 : t === 'sh' ? 32 : 34;65 const legacy = n.utxo?.kind === 'pkh';66 return Math.ceil(10.5 + (legacy ? 148 : 68) * nInputs + outSize + (hasChange ? (legacy ? 34 : 31) : 0)) + 1;67}68