aboutsummaryrefslogtreecommitdiffstats
path: root/lib/buildliquidtx.js
blob: ab2c597e52cf9a94e8717f40b8eccb4c6ec93243 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
pre { line-height: 125%; }
td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }
td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.highlight .hll { 
import store from "./store";
import { prod } from "./utils";
import axios from "axios";
// import liquid from 'liquidjs-lib';
import coinselect from "coinselect";
import split from "coinselect/split";
const api = prod
  ? "https://blockstream.info/liquid/api"
  : "http://localhost:3002";
const SATS = 100000000;
const network = prod ? liquid.networks["liquid"] : liquid.networks["regtest"];
import { Op } from "@sequelize/core";

export default async ({
  address,
  asset,
  amount,
  feeRate,
  replaceable,
  user
}) => {
  const { btcasset: btc } = config.liquid;

  if (!feeRate) {
    let { feerate } = await lq.estimateSmartFee(6);
    if (!feerate) feerate = 0.00002;
    feeRate = feerate * SATS;
  }
  feeRate = Math.round(feeRate / 100);
  if (asset !== btc) feeRate = 0;

  let payments;
  if (asset === btc) {
    payments = await db.Payment.findAll({
      where: {
        received: true,
        account_id: user.account_id,
        network: "liquid"
      }
    });
  } else {
    payments = await db.Payment.findAll({
      where: {
        "$account.pubkey": { [Op.ne]: null },
        received: true,
        user_id: user.id,
        network: "liquid"
      },
      include: {
        model: db.Account,
        as: "account"
      }
    });
  }

  let utxos = [];
  for (let i = 0; i < payments.length; i++) {
    let url = `${api}/address/${payments[i].address}/utxo`;
    try {
      utxos.push(...(await axios.get(url)).data);
    } catch (e) {
      err("problem fetching utxos", e.message, url);
    }
  }

  let feeInput = utxos.find(utxo => utxo.asset === btc && utxo.value > 1000);
  if (!feeInput)
    throw new Error(
      "Insufficient balance, couldn't find an input to cover the fee"
    );

  utxos = utxos.filter(utxo => utxo.asset === asset);

  let feeChange = feeInput.value - 1000;

  let targets = [
    {
      address,
      value: amount
    }
  ];

  let { inputs, outputs, fee } = coinselect(utxos, targets, feeRate);
  if (!inputs || !outputs) {
    l(
      "utxos",
      amount,
      feeRate,
      utxos.map(u => u.value),
      inputs,
      outputs,
      fee
    );

    throw new Error(
      "Couldn't find inputs for transaction, check your balance/fee"
    );
  }

  let psbt = new liquid.Psbt({ network });
  let total = 0;

  let { address: changeAddress } = payments[payments.length - 1];

  outputs.map(({ address, value }) => {
    total += value;
    if (!address) {
      address = changeAddress;
      store.change.push(address);
    }

    psbt = psbt.addOutput({
      address,
      asset,
      value
    });
  });

  for (let i = 0; i < inputs.length; i++) {
    let { txid, vout } = inputs[i];
    psbt = psbt.addInput({
      hash: txid,
      index: vout,
      nonWitnessUtxo: Buffer.from((await lq.getTransaction(txid)).hex, "hex")
    });
  }

  if (asset !== btc) {
    psbt.addInput({
      hash: feeInput.txid,
      index: feeInput.vout,
      nonWitnessUtxo: Buffer.from(
        await lq.getTransaction(feeInput.txid).hex,
        "hex"
      )
    });

    psbt.addOutput({
      changeAddress,
      asset: btc,
      value: feeChange
    });
  }

  psbt.addOutput({
    asset: btc,
    script: Buffer.alloc(0),
    value: fee,
    nonce: Buffer.alloc(1, 0)
  });

  if (replaceable) psbt.setInputSequence(0, 0xffffffff - 2);

  feeRate = Math.round(feeRate * 1000);
  psbt = psbt.toBase64();

  return { tx: psbt, feeRate };
};
class="w"> serializeTxW(it.tx, [witness]); if (sizeOnly) return { vsize }; const idInternal = txid(it.tx); return { txid: hex.encode(idInternal.slice().reverse()), txidInternal: idInternal, hex: hex.encode(raw), vsize, anchorVout: it.tx.outputs.length - 1, anchorValue: it.tx.outputs[it.tx.outputs.length - 1].valueSat, }; }); } // CPFP child: spends [parent P2A anchor (keyless, empty witness), one wallet // p2wpkh coin] into a single change output. v3 like its parent (TRUC). // coin: { txid (display hex), vout, value, pubkey (33B), privkey }. export function buildBumpChild({ parentTxidInternal, anchorVout, anchorValue, coin, changeScript, feeSat }) { const changeSat = coin.value + anchorValue - feeSat; if (changeSat < 294) throw new Error('fee coin too small for the exit bump'); const coinPrevout = concatBytes(hex.decode(coin.txid).reverse(), u32le(coin.vout)); const tx = { version: 3, locktime: 0, inputs: [ { prevout: concatBytes(parentTxidInternal, u32le(anchorVout)), sequence: 0xfffffffd }, { prevout: coinPrevout, sequence: 0xfffffffd }, ], outputs: [{ valueSat: changeSat, scriptPubKey: changeScript }], }; // BIP143 sighash (ALL) for the p2wpkh input const hashPrevouts = sha256d(concatBytes(...tx.inputs.map((i) => i.prevout))); const hashSequence = sha256d(concatBytes(...tx.inputs.map((i) => u32le(i.sequence)))); const hashOutputs = sha256d(concatBytes(...tx.outputs.map((o) => concatBytes(u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey)))); const pkh = ripemd160(sha256(coin.pubkey)); const scriptCode = concatBytes(hex.decode('1976a914'), pkh, hex.decode('88ac')); const preimage = concatBytes( u32le(tx.version), hashPrevouts, hashSequence, coinPrevout, scriptCode, u64le(coin.value), u32le(tx.inputs[1].sequence), hashOutputs, u32le(tx.locktime), u32le(1), ); const sig = secp256k1.sign(sha256d(preimage), coin.privkey, { lowS: true }); const der = concatBytes(sig.toDERRawBytes ? sig.toDERRawBytes() : sig.toBytes('der'), Uint8Array.of(0x01)); const { raw, vsize } = serializeTxW(tx, [[], [der, coin.pubkey]]); return { hex: hex.encode(raw), txid: hex.encode(txid(tx).slice().reverse()), vsize, changeSat }; } // The claim: spend the confirmed vtxo output through the delayed_sign leaf // (CSV = exitDelta) with only the user key, paying destScript. export function buildExitClaim({ vtxo, keys, serverPub, destScript, feeRate }) { const tap = pubkeyPolicyTaproot(keys.pubkey, serverPub, vtxo.exitDelta); const cb = concatBytes(Uint8Array.of(0xc0 | tap.outputParity), tap.internalXOnly); const prevouts = [{ valueSat: vtxo.amountSat, scriptPubKey: tap.scriptPubKey }]; const build = (feeSat) => { const tx = { version: 2, locktime: 0, inputs: [{ prevout: vtxo.point.raw, sequence: vtxo.exitDelta }], outputs: [{ valueSat: vtxo.amountSat - feeSat, scriptPubKey: destScript }], }; const sighash = taprootScriptSighash(tx, prevouts, tapLeafHash(tap.leafScript)); const sig = schnorr.sign(sighash, keys.privkey); return { tx, witness: [[sig, tap.leafScript, cb]] }; }; // two-pass: measure with a real-size witness, then set the fee const probe = build(0); const vsize = serializeTxW(probe.tx, probe.witness).vsize; const feeSat = Math.max(200, Math.ceil(vsize * Math.max(1, feeRate))); if (vtxo.amountSat - feeSat < 294) throw new Error('vtxo too small to claim at this fee rate'); const fin = build(feeSat); const { raw } = serializeTxW(fin.tx, fin.witness); return { hex: hex.encode(raw), txid: hex.encode(txid(fin.tx).slice().reverse()), feeSat, amountSat: vtxo.amountSat - feeSat, vsize }; } // Submit a [parent, child] (or single-tx) package via esplora -> bitcoind // submitpackage. Treats already-known txs as success so retries are safe. // Esploras without the endpoint (regtest electrs) fall back to sequential // /tx posts — which relays only if the node accepts zero-fee v3 parents // (regtest with -minrelaytxfee=0); otherwise the node's error surfaces. export async function submitPackage(esploraBase, hexes) { const r = await fetch(`${esploraBase}/txs/package`, { method: 'POST', body: JSON.stringify(hexes) }); const body = await r.text(); if (r.ok) { // submitpackage answers 200 even when it REJECTS the txs — the verdict is // inside package_msg / per-tx errors. Treat anything but success (or // already-known) as a real failure or the exit waits forever in silence. let parsed = null; try { parsed = JSON.parse(body); } catch {} if (parsed && parsed.package_msg && parsed.package_msg !== 'success') { const errs = Object.values(parsed['tx-results'] || {}).map((t) => t.error).filter(Boolean).join('; '); if (!/already/i.test(errs)) { throw new Error(`package rejected: ${(errs || parsed.package_msg).slice(0, 160)}`); } } return body; } if (/already|duplicate/i.test(body)) return body; if (r.status === 404 || /endpoint does not exist/i.test(body)) { for (const hx of hexes) { const br = await fetch(`${esploraBase}/tx`, { method: 'POST', body: hx }); const bb = await br.text(); if (!br.ok && !/already|duplicate/i.test(bb)) { throw new Error(`broadcast failed: ${bb.slice(0, 160)}`); } } return 'sequential'; } throw new Error(`package submit failed: ${body.slice(0, 160)}`); }