summaryrefslogtreecommitdiffstats
path: root/src/ark/validate.js
blob: fae3465ac5406a7d3d50a29304c6819dff8f1f2f (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
// Full receive-side VTXO validation: reconstruct the entire off-chain
// transaction chain from genesis data, pin it to a confirmed onchain anchor,
// and verify every transition signature. Mirrors bark's
// lib/src/vtxo/validation.rs.
//
// A VTXO that passes here is cryptographically spendable by us alone (given
// the exit-path delays), assuming the anchor doesn't get reorged and no
// prior arkoor double-spend was cosigned by the server before ours.

import { hex } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import { schnorr } from '@noble/curves/secp256k1';

import { taprootSighash, txid } from './send.js';
import {
  vtxoTransactions, vtxoAnchorAmount, transitionInputTxout,
  harkLeafTaproot, taprootScriptSighash, tapLeafHash, parseTx,
} from './refresh.js';

export class VtxoValidationError extends Error {}
const fail = (msg) => { throw new VtxoValidationError(msg); };

// chain: an object with getTxHex(txid) -> hex|null and tipHeight() -> number,
// plus getTxStatus(txid) -> {confirmed, block_height} (esplora semantics)
// allowPolicies: acceptable policy types (default plain pubkey only; lightning
// flows pass ['serverHtlcSend'] / ['serverHtlcRecv'] for their HTLC vtxos)
export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'] }) {
  const serverHex = hex.encode(serverPubkey);

  // -- structural --
  if (!allowPolicies.includes(vtxo.policy.type)) fail(`unsupported policy: ${vtxo.policy.type}`);
  if (vtxo.serverPubkey !== serverHex) fail('vtxo server pubkey does not match ours');
  if (expectPubkeys && !expectPubkeys.includes(vtxo.policy.userPubkey)) {
    fail('vtxo not paid to one of our keys');
  }
  if (!vtxo.genesis.length) fail('vtxo has no genesis transitions');

  // -- expiry --
  // Sub-dust vtxos outlive their expiryHeight: they have no unilateral exit
  // to protect (below dust there is no on-chain output they could become),
  // and the coinos ASP keeps honoring them off-chain after sweeping their
  // round — an explicit IOU where the trustless path never existed anyway.
  const tip = await chain.tipHeight();
  if (vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) {
    fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`);
  }

  // -- anchor: onchain, confirmed, and its output matches what the first
  //    transition claims to spend --
  const status = await chain.getTxStatus(vtxo.anchorPoint.txid);
  if (!status?.confirmed) fail(`anchor tx ${vtxo.anchorPoint.txid} not confirmed`);
  const anchorTx = parseTx(hex.decode(await chain.getTxHex(vtxo.anchorPoint.txid)));
  const anchorOut = anchorTx.outputs[vtxo.anchorPoint.vout];
  if (!anchorOut) fail('anchor vout does not exist');
  const expected = transitionInputTxout(
    vtxo.genesis[0].transition, vtxoAnchorAmount(vtxo), serverPubkey, vtxo.expiryHeight,
  );
  if (anchorOut.valueSat !== expected.valueSat
      || hex.encode(anchorOut.scriptPubKey) !== hex.encode(expected.scriptPubKey)) {
    fail('anchor output does not match the genesis chain');
  }

  // -- chain reconstruction must land exactly on the vtxo's outpoint --
  const items = vtxoTransactions(vtxo, serverPubkey);
  const last = items[items.length - 1];
  const lastTxid = hex.encode(txid(last.tx).slice().reverse());
  if (lastTxid !== vtxo.point.txid || last.outputIdx !== vtxo.point.vout) {
    fail(`reconstructed chain tip ${lastTxid}:${last.outputIdx} != vtxo point ${vtxo.id}`);
  }

  // -- every transition signature --
  let prevout = anchorOut;
  for (let i = 0; i < items.length; i++) {
    const t = vtxo.genesis[i].transition;
    const tx = items[i].tx;
    if (!t.signature) fail(`transition ${i} (${t.type}) is unsigned`);
    const sig = hex.decode(t.signature);

    let msg, key;
    if (t.type === 'hashLockedCosigned' || t.type === 'hashLockedCosigned_v0') {
      // script-spend through the unlock clause; signed by the raw agg key
      const hash = t.unlock.hash ? hex.decode(t.unlock.hash) : sha256(hex.decode(t.unlock.preimage));
      const gen = t.type === 'hashLockedCosigned' ? 1 : 0;
      const tp = harkLeafTaproot(hex.decode(t.userPubkey), serverPubkey, vtxo.expiryHeight, hash, gen);
      msg = taprootScriptSighash(tx, [prevout], tapLeafHash(tp.unlockScript));
      key = tp.internalXOnly;
      if (!t.unlock.preimage) fail(`transition ${i} has no unlock preimage`);
      if (hex.encode(sha256(hex.decode(t.unlock.preimage))) !== hex.encode(hash)) {
        fail(`transition ${i} preimage does not match its hash`);
      }
    } else {
      // keyspend of the previous output — the prevout script itself carries
      // the taproot output key the signature must verify against
      msg = taprootSighash(tx, [prevout]);
      key = prevout.scriptPubKey.slice(2); // 5120 || xonly
    }
    if (!schnorr.verify(sig, msg, key)) fail(`transition ${i} (${t.type}) signature invalid`);
    prevout = tx.outputs[items[i].outputIdx];
  }

  return true;
}