diff --git a/apps/api/src/recovery/classifier.ts b/apps/api/src/recovery/classifier.ts index cf45ed4..e80b476 100644 --- a/apps/api/src/recovery/classifier.ts +++ b/apps/api/src/recovery/classifier.ts @@ -6,7 +6,7 @@ import { base58check } from "@scure/base"; export type CounterpartyMultisigLayout = "historical-1-of-2" | "current-1-of-3"; export type RecoveryClassification = "recoverable" | "spent" | "ambiguous" | "unsupported" | "invalid" | "unverified"; -export const RECOVERY_CLASSIFIER_VERSION = 1; +export const RECOVERY_CLASSIFIER_VERSION = 2; export interface ParsedBareMultisig { requiredSignatures: number; @@ -32,6 +32,12 @@ export interface RecoveryDecision { parsed?: ParsedBareMultisig; } +export interface RecoveryTransactionOutputEvidence { + vout: number; + scriptPubkeyHex: string; + spent?: boolean; +} + const hexPattern = /^[0-9a-f]+$/i; const check = base58check(sha256); const defaultCounterpartyPrefix = new TextEncoder().encode("CNTRPRTY"); @@ -192,3 +198,75 @@ export function classifyRecovery(evidence: RecoveryEvidence): RecoveryDecision { parsed, }; } + +function classifyVerifiedOwner(output: RecoveryTransactionOutputEvidence, firstInputTxid?: string): RecoveryDecision { + const evidence = { + scriptPubkeyHex: output.scriptPubkeyHex, + firstInputTxid, + spent: output.spent, + }; + const provisional = classifyRecovery(evidence); + if (provisional.classification !== "ambiguous" || !provisional.derivedAddress) return provisional; + return classifyRecovery({ ...evidence, expectedAddress: provisional.derivedAddress }); +} + +/** + * Classify every candidate in one Bitcoin transaction. + * + * Legacy Counterparty decoded 1-of-2 data by concatenating every data-shaped output and checking + * `CNTRPRTY` once at the start of the combined payload. Consequently only the first output carries + * independently verifiable provenance. A later output may inherit that proof only when an earlier, + * independently verified historical output contains the exact same real recovery pubkey and every + * inherited output is the next vout in that uninterrupted same-key run. The script remains + * independently signable: it is still 1-of-2 and contains that curve-valid key. + * + * Decisions are returned in the same order as `outputs`; vout ordering is used only while proving + * that an anchor precedes a continuation. + */ +export function classifyRecoveryTransaction( + outputs: RecoveryTransactionOutputEvidence[], + firstInputTxid?: string, +): RecoveryDecision[] { + const entries = outputs.map((output, index) => ({ + index, + output, + decision: classifyVerifiedOwner(output, firstInputTxid), + })); + const historicalRunEndByKey = new Map(); + + for (const entry of [...entries].sort((left, right) => left.output.vout - right.output.vout)) { + const { decision, output } = entry; + if ( + decision.layout === "historical-1-of-2" && + decision.recoveryKeyHex && + (decision.classification === "recoverable" || decision.classification === "spent") + ) { + historicalRunEndByKey.set(decision.recoveryKeyHex, output.vout); + continue; + } + + const parsed = decision.parsed; + if (decision.classification !== "unverified" || parsed?.requiredSignatures !== 1 || parsed.publicKeyCount !== 2) { + continue; + } + const recoveryKeyHex = parsed.keyDataHex[0]; + if (historicalRunEndByKey.get(recoveryKeyHex) !== output.vout - 1) continue; + const derivedAddress = p2pkhAddress(recoveryKeyHex); + if (!derivedAddress) continue; + + entry.decision = { + classification: output.spent ? "spent" : "recoverable", + reason: output.spent + ? "verified-historical-continuation-output-already-spent" + : "verified-historical-continuation-output", + layout: "historical-1-of-2", + recoveryKeyHex, + recoveryKeyPosition: 0, + derivedAddress, + parsed, + }; + historicalRunEndByKey.set(recoveryKeyHex, output.vout); + } + + return entries.sort((left, right) => left.index - right.index).map((entry) => entry.decision); +} diff --git a/apps/api/src/recovery/import.ts b/apps/api/src/recovery/import.ts index db57e4e..bc39e76 100644 --- a/apps/api/src/recovery/import.ts +++ b/apps/api/src/recovery/import.ts @@ -1,11 +1,5 @@ import type { Env } from "#api/env"; -import { - RECOVERY_CLASSIFIER_VERSION, - classifyRecovery, - p2pkhAddress, - parseBareMultisig, - verifyCounterpartyLayout, -} from "#api/recovery/classifier"; +import { RECOVERY_CLASSIFIER_VERSION, classifyRecoveryTransaction, parseBareMultisig } from "#api/recovery/classifier"; import { parseRecoveryTransaction } from "#api/recovery/raw-transaction"; export interface RecoveryImportOutput { @@ -38,7 +32,7 @@ export async function batchRecoveryStatements( // about spends, so its rows always say classification='recoverable', spent_by_txid=NULL. Preserve a // settled spend rather than letting a cursor rewind resurrect an output its owner already recovered. // An importer that does supply spend evidence still wins, which only the explicit import path does. -const SPEND_ALREADY_SETTLED = `recovery_outputs.classification='spent' AND excluded.spent_by_txid IS NULL`; +const SPEND_ALREADY_SETTLED = `recovery_outputs.spent_by_txid IS NOT NULL AND excluded.spent_by_txid IS NULL`; const keepSpend = (column: string) => `${column}=CASE WHEN ${SPEND_ALREADY_SETTLED} THEN recovery_outputs.${column} ELSE excluded.${column} END`; @@ -76,7 +70,7 @@ export async function importRecoveryTransactions(env: Env, rows: RecoveryImportT }), ); - for (const candidate of row.outputs) { + const validated = row.outputs.map((candidate) => { if (!Number.isSafeInteger(candidate.vout) || candidate.vout < 0) throw new Error("invalid output index"); if (!Number.isSafeInteger(candidate.value_sats) || candidate.value_sats < 0) throw new Error("invalid output value"); @@ -91,19 +85,24 @@ export async function importRecoveryTransactions(env: Env, rows: RecoveryImportT const parsed = parseBareMultisig(scriptPubkeyHex); if (!parsed || parsed.requiredSignatures !== 1 || (parsed.publicKeyCount !== 2 && parsed.publicKeyCount !== 3)) throw new Error(`unsupported recovery candidate: ${expectedTxid}:${candidate.vout}`); - const structuralLayout = parsed.publicKeyCount === 2 ? "historical-1-of-2" : "current-1-of-3"; - const verifiedLayout = verifyCounterpartyLayout(parsed, transaction.firstInputTxid); - const recoveryKeyPosition = verifiedLayout ? (verifiedLayout === "historical-1-of-2" ? 0 : 2) : null; - const recoveryKeyHex = recoveryKeyPosition == null ? null : parsed.keyDataHex[recoveryKeyPosition]; - const recoveryAddress = recoveryKeyHex ? p2pkhAddress(recoveryKeyHex) : null; - const decision = recoveryAddress - ? classifyRecovery({ - scriptPubkeyHex, - firstInputTxid: transaction.firstInputTxid, - expectedAddress: recoveryAddress, - spent: !!candidate.spent_by_txid, - }) - : { classification: "unverified" as const, reason: "counterparty-provenance-not-verified" }; + return { + candidate, + scriptPubkeyHex, + structuralLayout: parsed.publicKeyCount === 2 ? ("historical-1-of-2" as const) : ("current-1-of-3" as const), + }; + }); + const decisions = classifyRecoveryTransaction( + validated.map(({ candidate, scriptPubkeyHex }) => ({ + vout: candidate.vout, + scriptPubkeyHex, + spent: !!candidate.spent_by_txid, + })), + transaction.firstInputTxid, + ); + + for (let index = 0; index < validated.length; index++) { + const { candidate, scriptPubkeyHex, structuralLayout } = validated[index]!; + const decision = decisions[index]!; statements.push( upsert.bind( @@ -112,9 +111,9 @@ export async function importRecoveryTransactions(env: Env, rows: RecoveryImportT candidate.value_sats, scriptPubkeyHex, structuralLayout, - recoveryKeyHex, - recoveryKeyPosition, - recoveryAddress, + decision.recoveryKeyHex ?? null, + decision.recoveryKeyPosition ?? null, + decision.derivedAddress ?? null, decision.classification, decision.reason, candidate.block_height ?? null, diff --git a/apps/api/src/recovery/reclassify.ts b/apps/api/src/recovery/reclassify.ts index 452096f..7313ffb 100644 --- a/apps/api/src/recovery/reclassify.ts +++ b/apps/api/src/recovery/reclassify.ts @@ -1,6 +1,6 @@ import type { Env } from "#api/env"; import { RECOVERY_CLASSIFIER_VERSION } from "#api/recovery/classifier"; -import { importRecoveryTransactions } from "#api/recovery/import"; +import { importRecoveryTransactions, type RecoveryImportOutput } from "#api/recovery/import"; import { recoveryCandidates } from "#api/recovery/scanner"; /** @@ -27,6 +27,26 @@ interface OutdatedTransactionRow { block_time: number | null; } +interface ExistingSpendRow { + vout: number; + spent_by_txid: string; + spent_height: number | null; +} + +export function retainRecoverySpendEvidence( + candidates: RecoveryImportOutput[], + existingSpends: ExistingSpendRow[], +): RecoveryImportOutput[] { + const byVout = new Map(existingSpends.map((row) => [row.vout, row])); + return candidates.map((candidate) => { + if (candidate.spent_by_txid) return candidate; + const existing = byVout.get(candidate.vout); + return existing + ? { ...candidate, spent_by_txid: existing.spent_by_txid, spent_height: existing.spent_height } + : candidate; + }); +} + export async function reclassifyRecoveryOutputs( env: Env, transactionLimit = 25, @@ -50,8 +70,19 @@ export async function reclassifyRecoveryOutputs( missing++; continue; } - const rawHex = await object.text(); - const candidates = recoveryCandidates(rawHex, row.block_height ?? 0, row.block_time); + const [rawHex, existingSpends] = await Promise.all([ + object.text(), + env.RECOVERY_DB.prepare( + `SELECT vout,spent_by_txid,spent_height FROM recovery_outputs + WHERE txid=? AND spent_by_txid IS NOT NULL`, + ) + .bind(row.txid) + .all(), + ]); + const candidates = retainRecoverySpendEvidence( + recoveryCandidates(rawHex, row.block_height ?? 0, row.block_time), + existingSpends.results, + ); if (candidates.length === 0) continue; outputs += await importRecoveryTransactions(env, [ { txid: row.txid, raw_transaction_hex: rawHex, outputs: candidates }, diff --git a/apps/api/tests/recovery-classifier.test.ts b/apps/api/tests/recovery-classifier.test.ts index ebe5172..29861bd 100644 --- a/apps/api/tests/recovery-classifier.test.ts +++ b/apps/api/tests/recovery-classifier.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { secp256k1 } from "@noble/curves/secp256k1.js"; import { classifyRecovery, + classifyRecoveryTransaction, isCurvePublicKey, p2pkhAddress, parseBareMultisig, @@ -78,6 +79,32 @@ test("parses exact historical Counterparty 1-of-2 layout", () => { assert.equal(result.recoveryKeyPosition, 0); }); +test("inherits historical provenance only for later 1-of-2 outputs with the exact recovery key", () => { + const historicalData = `${prefix.length.toString(16).padStart(2, "0")}${hex(prefix)}${"00".repeat(24)}`; + const continuationData = `04deadbeef${"00".repeat(28)}`; + const otherRecoveryKey = dataKeyA; + const decisions = classifyRecoveryTransaction([ + { vout: 2, scriptPubkeyHex: script(1, [recoveryKey, continuationData]) }, + { vout: 0, scriptPubkeyHex: script(1, [recoveryKey, continuationData]) }, + { vout: 1, scriptPubkeyHex: script(1, [recoveryKey, historicalData]) }, + { vout: 4, scriptPubkeyHex: script(1, [otherRecoveryKey, continuationData]) }, + { vout: 3, scriptPubkeyHex: script(1, [recoveryKey, continuationData]), spent: true }, + { vout: 6, scriptPubkeyHex: script(1, [recoveryKey, continuationData]) }, + ]); + + assert.equal(decisions[0]?.classification, "recoverable"); + assert.equal(decisions[0]?.reason, "verified-historical-continuation-output"); + assert.equal(decisions[0]?.recoveryKeyHex, recoveryKey); + assert.equal(decisions[0]?.recoveryKeyPosition, 0); + assert.equal(decisions[0]?.derivedAddress, address); + assert.equal(decisions[1]?.classification, "unverified", "an output before the anchor cannot inherit proof"); + assert.equal(decisions[2]?.classification, "recoverable", "the prefixed output is independently verified"); + assert.equal(decisions[3]?.classification, "unverified", "a different recovery key cannot inherit proof"); + assert.equal(decisions[4]?.classification, "spent"); + assert.equal(decisions[4]?.reason, "verified-historical-continuation-output-already-spent"); + assert.equal(decisions[5]?.classification, "unverified", "a vout gap ends the inherited historical run"); +}); + test("does not mistake modern curve-valid data keys for the recovery key", () => { const firstInputTxid = "11".repeat(32); const [encodedA, encodedB] = currentCounterpartyKeys(firstInputTxid); diff --git a/apps/api/tests/recovery-import.test.ts b/apps/api/tests/recovery-import.test.ts index d4fb1cf..359bde6 100644 --- a/apps/api/tests/recovery-import.test.ts +++ b/apps/api/tests/recovery-import.test.ts @@ -76,6 +76,16 @@ test("re-importing a settled output never resurrects it as recoverable", () => { assert.deepEqual(rescan(db), { classification: "spent", spent_by_txid: "c2", chain_checked_at: 1_700 }); }); +test("re-importing never clears spend evidence from a previously unverified output", () => { + const db = outputsDatabase(); + rescan(db); + db.prepare( + `UPDATE recovery_outputs SET classification='unverified',spent_by_txid='c2',spent_height=960262,chain_checked_at=1700`, + ).run(); + + assert.deepEqual(rescan(db), { classification: "unverified", spent_by_txid: "c2", chain_checked_at: 1_700 }); +}); + test("an importer that supplies its own spend evidence still wins", () => { const db = outputsDatabase(); rescan(db); diff --git a/apps/api/tests/recovery-reclassify.test.ts b/apps/api/tests/recovery-reclassify.test.ts index 251f0bc..1a1b8bd 100644 --- a/apps/api/tests/recovery-reclassify.test.ts +++ b/apps/api/tests/recovery-reclassify.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { DatabaseSync } from "node:sqlite"; import { RECOVERY_CLASSIFIER_VERSION } from "#api/recovery/classifier"; -import { RECOVERY_RECLASSIFY_QUEUE_SQL } from "#api/recovery/reclassify"; +import { RECOVERY_RECLASSIFY_QUEUE_SQL, retainRecoverySpendEvidence } from "#api/recovery/reclassify"; function database(): DatabaseSync { const db = new DatabaseSync(":memory:"); @@ -58,3 +58,25 @@ test("an index already on the current classifier has nothing to do", () => { assert.deepEqual(queued(db), []); }); + +test("reclassification carries settled spend evidence into the new verdict", () => { + assert.deepEqual( + retainRecoverySpendEvidence( + [ + { vout: 0, value_sats: 1_000, script_pubkey_hex: "51" }, + { vout: 1, value_sats: 2_000, script_pubkey_hex: "51" }, + ], + [{ vout: 1, spent_by_txid: "cc", spent_height: 900_000 }], + ), + [ + { vout: 0, value_sats: 1_000, script_pubkey_hex: "51" }, + { + vout: 1, + value_sats: 2_000, + script_pubkey_hex: "51", + spent_by_txid: "cc", + spent_height: 900_000, + }, + ], + ); +}); diff --git a/apps/api/tests/recovery-scanner.test.ts b/apps/api/tests/recovery-scanner.test.ts index d88a81c..7f433be 100644 --- a/apps/api/tests/recovery-scanner.test.ts +++ b/apps/api/tests/recovery-scanner.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { classifyRecoveryTransaction } from "#api/recovery/classifier"; import { recoveryCandidates } from "#api/recovery/scanner"; const hex = (bytes: Uint8Array) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -23,3 +24,51 @@ test("forward recovery scanner selects supported bare-multisig outputs with chai }, ]); }); + +test("a mainnet historical transaction proves its same-key continuation output", () => { + // 00020d9f233711d6bbb3e0911e040c34333dc3350f506631dda4140b88136445, block 309517. + const mainnetRaw = [ + "0100000003a7ceb25da17704a9d59492d5afedca96cd92cb5384a76a7facaa6ed841dcfb55000000006b483045022100", + "8259b8fbde0efb3476d5da3f85a7d2bf1bb3cef8d9360732105216dab308195f02207f9f044ee481d9bb520c09b4380a", + "0dd614afab72b4e852fac745bf158b8d0bfe012103dd0a32844c876e518ca979ae88bc6b6461b6920292570a20e73ba25", + "e26353744ffffffff7397054a9c8f4c9d064937e67166bfa4eb2cc9c0b10b28ade978bc63abda9aa5000000006a473044", + "02207d0d62d5082c21ad2c71830a7a7cb8498494eb9bf59ab0936ab84b122bcf57600220195dfb25a5d1673b42e1db45", + "4908182ab5f46efe66071dc4b36f4622e3027442012103dd0a32844c876e518ca979ae88bc6b6461b6920292570a20e73", + "ba25e26353744ffffffff06823da927be1f104ad942f1e90b90335e9811bfed8ae31341efd0e8b4af2b15020000006c49", + "3046022100893bc7c92ab4f2a4d8e9741399ec541ed6e22eb34dbe8bab92c59b157a3fd45c022100f9ebe22aa2678d4b", + "2a79ab532eed06dfcd2595985db69fb1a9f2b198937af127012103dd0a32844c876e518ca979ae88bc6b6461b692029257", + "0a20e73ba25e26353744ffffffff03781e00000000000047512103dd0a32844c876e518ca979ae88bc6b6461b692029257", + "0a20e73ba25e263537442120434e54525052545900000046152bafb4e8d0ef4113e38aedbf11985e33900be952ae781e00", + "000000000047512103dd0a32844c876e518ca979ae88bc6b6461b6920292570a20e73ba25e26353744210cf142d94a101", + "fbe27a93d8206000000000000000000000000000000000000000052aee4220d00000000001976a914861fb119c717ea89", + "dd06002cb84ba69f775738bd88ac00000000", + ].join(""); + const candidates = recoveryCandidates(mainnetRaw, 309_517, 1_404_730_309); + const decisions = classifyRecoveryTransaction( + candidates.map((candidate) => ({ + vout: candidate.vout, + scriptPubkeyHex: candidate.script_pubkey_hex, + })), + ); + + assert.equal(candidates.length, 2); + assert.deepEqual( + decisions.map((decision) => ({ + classification: decision.classification, + reason: decision.reason, + address: decision.derivedAddress, + })), + [ + { + classification: "recoverable", + reason: "verified-counterparty-recovery-output", + address: "1DEBWh2YhUFPZBNvJjxZ9a4fixHn8ZMk4b", + }, + { + classification: "recoverable", + reason: "verified-historical-continuation-output", + address: "1DEBWh2YhUFPZBNvJjxZ9a4fixHn8ZMk4b", + }, + ], + ); +});