Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion apps/api/src/recovery/classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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<string, number>();

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);
}
49 changes: 24 additions & 25 deletions apps/api/src/recovery/import.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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`;

Expand Down Expand Up @@ -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");
Expand All @@ -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(
Expand All @@ -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,
Expand Down
37 changes: 34 additions & 3 deletions apps/api/src/recovery/reclassify.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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,
Expand All @@ -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<ExistingSpendRow>(),
]);
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 },
Expand Down
27 changes: 27 additions & 0 deletions apps/api/tests/recovery-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { secp256k1 } from "@noble/curves/secp256k1.js";
import {
classifyRecovery,
classifyRecoveryTransaction,
isCurvePublicKey,
p2pkhAddress,
parseBareMultisig,
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions apps/api/tests/recovery-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 23 additions & 1 deletion apps/api/tests/recovery-reclassify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:");
Expand Down Expand Up @@ -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,
},
],
);
});
49 changes: 49 additions & 0 deletions apps/api/tests/recovery-scanner.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
Expand All @@ -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",
},
],
);
});
Loading