SPB Git

Repository Public

build-btc.ts 4.0 KB 68 lines typescript

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
Files main
Menu

Theme