Skip to content

Commit 39db9dd

Browse files
NateIsernclaude
andcommitted
refactor: consensus constants from core, header repair in the storage layer
Closes the two layering findings left open by the cleanup review. Consensus constants (bumps @fairco.in/core to 0.5.0): `nLastPOWBlock` and `ProofOfWorkLimit` were the only chainparams values living in this consumer rather than on `NetworkConfig`, and the 10000 boundary had spread to five hardcoded places across two repos — including core's own checkpoint and vector tests, which assumed it while being unable to import it. The wallet now reads `network.lastPowBlock` / `network.powLimit`, and `compactToTarget`, `isValidTargetBits`, `hashToUint256` and `meetsProofOfWork` come from core instead of being defined here. 101 lines of duplicated consensus math deleted; core's tests and this gate now evaluate one implementation. Header-store repair: `discardCorruptHeaderStore` sat in the SPV client, hardcoding knowledge of which upstream package versions were broken, with no version key — so it re-hashed the tip on every launch forever with no way to retire it, and it routed a stored record through the wire-message adapter with a fake `txCount: 0` to do it. It moves to the storage layer beside the blob migration, keyed on a persisted `header_hash_version` in a new `schema_meta` table. An already-checked wallet now costs one small SELECT at startup instead of a Quark hash, the check can be retired by bumping the version, and the SPV client starts against a store it can assume is coherent. A wipe also clears `rescan_state`, which describes heights of a chain that no longer exists. `planHeaderRepair` is pure and tested against a real mainnet header, the regressed hash that 0.2.0–0.3.1 would have written, and the genesis-only case that must never trigger a wipe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 333a054 commit 39db9dd

10 files changed

Lines changed: 219 additions & 172 deletions

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
},
2222
"dependencies": {
2323
"@expo/vector-icons": "^15.1.1",
24-
"@fairco.in/core": "0.4.0",
24+
"@fairco.in/core": "0.5.0",
2525
"@gorhom/bottom-sheet": "^5.2.9",
2626
"@maplibre/maplibre-react-native": "^10.4.2",
2727
"@noble/hashes": "1.8.0",

src/p2p/header-validation.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414

1515
import { describe, test, expect } from "bun:test";
1616
import {
17-
validateHeaderChain,
18-
planChainUpdate,
1917
compactToTarget,
2018
isValidTargetBits,
21-
proofOfWorkLimit,
19+
getNetwork,
20+
} from "@fairco.in/core";
21+
import {
22+
validateHeaderChain,
23+
planChainUpdate,
2224
HeaderValidationError,
2325
type HeaderChainAnchor,
2426
} from "./header-validation";
@@ -64,7 +66,7 @@ function buildChain(
6466
return headers;
6567
}
6668

67-
const POW_LIMIT = proofOfWorkLimit();
69+
const POW_LIMIT = getNetwork("mainnet").powLimit;
6870

6971
// ---------------------------------------------------------------------------
7072
// Compact ("nBits") target decoding

src/p2p/header-validation.ts

Lines changed: 7 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -42,113 +42,17 @@
4242
* that is sound to enforce header-only.
4343
*/
4444

45-
import type { BlockHeader, NetworkType } from "@fairco.in/core";
46-
import { bytesEqual, hashBlockHeader } from "@fairco.in/core";
45+
import type { BlockHeader } from "@fairco.in/core";
46+
import {
47+
bytesEqual,
48+
hashBlockHeader,
49+
isValidTargetBits,
50+
meetsProofOfWork,
51+
} from "@fairco.in/core";
4752
import type { BlockHeaderMsg } from "./messages";
4853

4954
// ---------------------------------------------------------------------------
5055
// Compact ("nBits") target encoding — Bitcoin/FairCoin `uint256::SetCompact`.
51-
// ---------------------------------------------------------------------------
52-
53-
export interface CompactTarget {
54-
/** The decoded 256-bit target value. */
55-
readonly target: bigint;
56-
/** True if the compact encoding had its sign bit set (an invalid target). */
57-
readonly negative: boolean;
58-
/** True if the mantissa/exponent combination overflows 256 bits. */
59-
readonly overflow: boolean;
60-
}
61-
62-
const U256_MASK = (1n << 256n) - 1n;
63-
64-
/**
65-
* Decode a compact difficulty target ("nBits") into a 256-bit value, faithfully
66-
* reproducing FairCoin's `uint256::SetCompact` including its sign/overflow flags.
67-
*/
68-
export function compactToTarget(bits: number): CompactTarget {
69-
const nSize = (bits >>> 24) & 0xff;
70-
const nWord = bits & 0x007fffff;
71-
72-
let target: bigint;
73-
if (nSize <= 3) {
74-
target = BigInt(nWord >>> (8 * (3 - nSize)));
75-
} else {
76-
target = (BigInt(nWord) << BigInt(8 * (nSize - 3))) & U256_MASK;
77-
}
78-
79-
const negative = nWord !== 0 && (bits & 0x00800000) !== 0;
80-
const overflow =
81-
nWord !== 0 &&
82-
(nSize > 34 ||
83-
(nWord > 0xff && nSize > 33) ||
84-
(nWord > 0xffff && nSize > 32));
85-
86-
return { target, negative, overflow };
87-
}
88-
89-
/**
90-
* The proof-of-work limit (easiest allowed target) for a network, as a 256-bit
91-
* value. Both FairCoin mainnet and testnet use `~uint256(0) >> 20`
92-
* (`CTestNetParams` inherits it from `CMainParams`). Only regtest differs, and
93-
* this wallet never targets regtest.
94-
*/
95-
export function proofOfWorkLimit(): bigint {
96-
return U256_MASK >> 20n;
97-
}
98-
99-
/**
100-
* Whether a header's `nBits` encodes a valid, in-range difficulty target.
101-
*
102-
* The range half of FairCoin's `CheckProofOfWork`: reject negative, zero,
103-
* overflowing, or above-limit targets.
104-
*/
105-
export function isValidTargetBits(bits: number, powLimit: bigint): boolean {
106-
const { target, negative, overflow } = compactToTarget(bits);
107-
if (negative || overflow) return false;
108-
if (target === 0n) return false;
109-
if (target > powLimit) return false;
110-
return true;
111-
}
112-
113-
/**
114-
* Read a block hash as the 256-bit number FairCoin compares against the target.
115-
*
116-
* `hashBlockHeader` returns bytes in internal (`uint256` serialisation) order,
117-
* which is little-endian: byte 0 is the least significant.
118-
*/
119-
export function hashToUint256(hash: Uint8Array): bigint {
120-
let value = 0n;
121-
for (let i = hash.length - 1; i >= 0; i--) {
122-
value = (value << 8n) | BigInt(hash[i]);
123-
}
124-
return value;
125-
}
126-
127-
/**
128-
* The work half of FairCoin's `CheckProofOfWork`: `hash > bnTarget` is a
129-
* failure, so equality passes.
130-
*
131-
* Only meaningful for PoW-era headers — see rule 3 in the module docblock.
132-
*/
133-
export function meetsProofOfWork(hash: Uint8Array, bits: number): boolean {
134-
const { target, negative, overflow } = compactToTarget(bits);
135-
if (negative || overflow || target === 0n) return false;
136-
return hashToUint256(hash) <= target;
137-
}
138-
139-
/**
140-
* `Params().LAST_POW_BLOCK()` from `chainparams.cpp`. Above this height the
141-
* chain is proof-of-stake and header-only proof-of-work verification is not
142-
* applicable; at or below it, `main.cpp` rejects PoS blocks outright, so every
143-
* header is provably PoW.
144-
*
145-
* Kept here beside {@link proofOfWorkLimit} — the other consensus constant the
146-
* SPV validator needs that is not carried in `NetworkConfig`.
147-
*/
148-
export function lastPowBlock(network: NetworkType): number {
149-
return network === "mainnet" ? 10_000 : 200;
150-
}
151-
15256
// ---------------------------------------------------------------------------
15357
// Header chain validation
15458
// ---------------------------------------------------------------------------

src/p2p/proof-of-work.test.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,9 @@
2020
*/
2121

2222
import { describe, test, expect } from "bun:test";
23-
import { hashBlockHeader } from "@fairco.in/core";
23+
import { getNetwork, hashBlockHeader, meetsProofOfWork } from "@fairco.in/core";
2424
import {
25-
meetsProofOfWork,
26-
lastPowBlock,
2725
validateHeaderChain,
28-
proofOfWorkLimit,
2926
HeaderValidationError,
3027
type HeaderChainAnchor,
3128
} from "./header-validation";
@@ -169,13 +166,13 @@ describe("meetsProofOfWork", () => {
169166

170167
describe("lastPowBlock", () => {
171168
test("mirrors chainparams.cpp nLastPOWBlock", () => {
172-
expect(lastPowBlock("mainnet")).toBe(10_000);
173-
expect(lastPowBlock("testnet")).toBe(200);
169+
expect(getNetwork("mainnet").lastPowBlock).toBe(10_000);
170+
expect(getNetwork("testnet").lastPowBlock).toBe(200);
174171
});
175172
});
176173

177174
describe("validateHeaderChain enforces PoW only in the PoW range", () => {
178-
const powLimit = proofOfWorkLimit();
175+
const powLimit = getNetwork("mainnet").powLimit;
179176

180177
test("accepts a real PoW header at height 5000", () => {
181178
const anchor: HeaderChainAnchor = {
@@ -186,7 +183,7 @@ describe("validateHeaderChain enforces PoW only in the PoW range", () => {
186183
headers: [POW_5000.header],
187184
anchor,
188185
powLimit,
189-
lastPowBlockHeight: lastPowBlock("mainnet"),
186+
lastPowBlockHeight: getNetwork("mainnet").lastPowBlock,
190187
});
191188
expect(result[0].height).toBe(5000);
192189
});
@@ -205,7 +202,7 @@ describe("validateHeaderChain enforces PoW only in the PoW range", () => {
205202
headers: [tampered],
206203
anchor,
207204
powLimit,
208-
lastPowBlockHeight: lastPowBlock("mainnet"),
205+
lastPowBlockHeight: getNetwork("mainnet").lastPowBlock,
209206
}),
210207
).toThrow(/proof-of-work/i);
211208
});
@@ -219,7 +216,7 @@ describe("validateHeaderChain enforces PoW only in the PoW range", () => {
219216
headers: [POS_10001.header],
220217
anchor,
221218
powLimit,
222-
lastPowBlockHeight: lastPowBlock("mainnet"),
219+
lastPowBlockHeight: getNetwork("mainnet").lastPowBlock,
223220
});
224221
expect(result[0].height).toBe(10_001);
225222
});

src/p2p/spv-client.ts

Lines changed: 6 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import { validateMerkleProof } from "./merkle-proof";
1313
import {
1414
validateHeaderChain,
1515
planChainUpdate,
16-
proofOfWorkLimit,
17-
lastPowBlock,
1816
HeaderValidationError,
1917
type HeaderChainAnchor,
2018
type ValidatedHeader,
@@ -273,7 +271,7 @@ export class SPVClient {
273271
constructor(config: SPVClientConfig) {
274272
this.headerStore = config.headerStore;
275273
this.network = config.network;
276-
this.powLimit = proofOfWorkLimit();
274+
this.powLimit = config.network.powLimit;
277275
this.startFromCheckpoint = config.startFromCheckpoint ?? false;
278276

279277
const peerManagerConfig: PeerManagerConfig = {
@@ -308,58 +306,16 @@ export class SPVClient {
308306
// `prevBlock`; without genesis in the store there is no anchor and header
309307
// validation rejects block 1, so sync (and therefore receiving) never
310308
// starts.
311-
// One read of the tip serves all three startup steps: the corruption check,
312-
// the genesis/anchor seed, and the initial height. Each used to issue its
313-
// own `SELECT ... ORDER BY height DESC LIMIT 1` on the wallet-start path.
314-
const tip = await this.discardCorruptHeaderStore();
309+
// The store is repaired and migrated by the storage layer before it is
310+
// handed over, so the client can assume it is coherent. One read of the tip
311+
// serves both remaining startup steps.
312+
const tip = await this.headerStore.getLatestHeader();
315313
const seeded = await this.ensureStartHeader(tip);
316314
this.chainHeight = seeded?.height ?? 0;
317315

318316
await this.peerManager.start();
319317
}
320318

321-
/**
322-
* Drop the whole header store if its tip does not re-hash to the id recorded
323-
* alongside it.
324-
*
325-
* `@fairco.in/core` 0.2.0–0.3.1 shipped a regressed Quark implementation that
326-
* computed the wrong id for every block. A wallet that synced against one of
327-
* those builds holds headers keyed by bogus hashes: once the correct hash is
328-
* restored, no incoming header's `prevBlock` can ever match the stored tip,
329-
* so `processHeadersResponse` rejects every batch as unconnected and sync
330-
* stalls silently and permanently.
331-
*
332-
* One hash of the tip is enough to detect it, and re-syncing headers is
333-
* cheap next to a wallet that never confirms another payment. Wallet data
334-
* (UTXOs, transactions, notes) is untouched: the rescan that follows the
335-
* re-sync re-derives confirmations from the rebuilt chain.
336-
*/
337-
private async discardCorruptHeaderStore(): Promise<
338-
StoredBlockHeader | undefined
339-
> {
340-
const tip = await this.headerStore.getLatestHeader();
341-
// Genesis is seeded from network config rather than hashed, so it proves
342-
// nothing either way.
343-
if (!tip || tip.height === 0) return tip;
344-
345-
const recomputed = hashBlockHeader({
346-
version: tip.version,
347-
prevBlock: tip.prevBlock,
348-
merkleRoot: tip.merkleRoot,
349-
timestamp: tip.timestamp,
350-
bits: tip.bits,
351-
nonce: tip.nonce,
352-
// Not part of the hashed 80 bytes; only the `headers` wire message
353-
// carries it.
354-
txCount: 0,
355-
});
356-
if (bytesEqual(recomputed, tip.hash)) return tip;
357-
358-
await this.headerStore.deleteHeadersAboveHeight(-1);
359-
this.chainHeight = 0;
360-
return undefined;
361-
}
362-
363319
/**
364320
* Persist the genesis header at height 0 if the store is empty. Its hash and
365321
* fields come straight from the network config; the stored hash is in the
@@ -754,7 +710,7 @@ export class SPVClient {
754710
checkpointHashHex: (height) =>
755711
getCheckpointHash(height, this.network.name),
756712
genesisHashHex: this.network.genesisHash,
757-
lastPowBlockHeight: lastPowBlock(this.network.name),
713+
lastPowBlockHeight: this.network.lastPowBlock,
758714
});
759715
} catch (err) {
760716
if (err instanceof HeaderValidationError) {

src/p2p/sync-anchor.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import {
1414
hashBlockHeader,
1515
getCheckpointHash,
1616
bytesToHex,
17+
getNetwork,
18+
meetsProofOfWork,
1719
} from "@fairco.in/core";
1820
import { getSyncAnchor } from "./sync-anchor";
19-
import { lastPowBlock, meetsProofOfWork } from "./header-validation";
21+
2022

2123
/** Display order — the convention the checkpoint table uses. */
2224
function toDisplayHex(bytes: Uint8Array): string {
@@ -55,7 +57,7 @@ describe("mainnet sync anchor", () => {
5557

5658
test("sits above the proof-of-work era, so it is a PoS header", () => {
5759
if (!anchor) throw new Error("no mainnet anchor");
58-
expect(anchor.height).toBeGreaterThan(lastPowBlock("mainnet"));
60+
expect(anchor.height).toBeGreaterThan(getNetwork("mainnet").lastPowBlock);
5961
// PoS headers carry no work and must never be PoW-checked.
6062
expect(anchor.nonce).toBe(0);
6163
expect(meetsProofOfWork(anchor.hash, anchor.bits)).toBe(false);

0 commit comments

Comments
 (0)