@freedomofpress/cometbft 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commit.ts ADDED
@@ -0,0 +1,210 @@
1
+ // src/commit.ts
2
+ import { base64ToUint8Array, hexToUint8Array } from "./encoding";
3
+ import {
4
+ BlockID,
5
+ Commit,
6
+ CommitSig,
7
+ Header,
8
+ SignedHeader,
9
+ } from "./proto/cometbft/types/v1/types";
10
+ import { BlockIDFlag } from "./proto/cometbft/types/v1/validator";
11
+ import { Consensus } from "./proto/cometbft/version/v1/types";
12
+ import { Timestamp as PbTimestamp } from "./proto/google/protobuf/timestamp";
13
+ import type { CommitJson } from "./types";
14
+
15
+ // ---- helpers ----
16
+ function assertLen(name: string, u8: Uint8Array, expect: number) {
17
+ if (u8.length !== expect) {
18
+ throw new Error(`${name} must be ${expect} bytes, got ${u8.length}`);
19
+ }
20
+ }
21
+
22
+ function parseRFC3339ToTimestamp(s: string): PbTimestamp {
23
+ const d = new Date(s);
24
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid RFC3339 time: ${s}`);
25
+
26
+ // extract fractional seconds (up to 9 digits for nanos)
27
+ const fracMatch = s.match(/\.(\d+)Z$/i);
28
+ const frac = fracMatch ? fracMatch[1] : "";
29
+ const n = Math.min(frac.length, 9);
30
+ const nanos = n === 0 ? 0 : Number((frac + "0".repeat(9 - n)).slice(0, 9));
31
+ const seconds = BigInt(Math.floor(d.getTime() / 1000));
32
+
33
+ return { seconds, nanos };
34
+ }
35
+
36
+ /**
37
+ * Parse and validate a /commit JSON and return a ts-proto SignedHeader
38
+ * (cometbft.types.v1.SignedHeader).
39
+ */
40
+ export function importCommit(resp: CommitJson): SignedHeader {
41
+ const sh = resp.signed_header;
42
+
43
+ if (!sh) throw new Error("Missing signed_header");
44
+
45
+ const h = sh.header;
46
+ const c = sh.commit;
47
+
48
+ if (!h) throw new Error("Missing header");
49
+ if (!c) throw new Error("Missing commit");
50
+
51
+ // Heights
52
+ if (!h.height) throw new Error("Missing header.height");
53
+ if (c.height == null || c.height === "")
54
+ throw new Error("Missing commit.height");
55
+ const headerHeight = BigInt(h.height);
56
+ const commitHeight = BigInt(c.height);
57
+ if (headerHeight !== commitHeight) {
58
+ throw new Error(`height mismatch header=${h.height} commit=${c.height}`);
59
+ }
60
+
61
+ // Round
62
+ if (
63
+ typeof c.round !== "number" ||
64
+ c.round < 0 ||
65
+ !Number.isInteger(c.round)
66
+ ) {
67
+ throw new Error("Invalid commit.round");
68
+ }
69
+
70
+ // version (optional)
71
+ const version: Consensus = {
72
+ block: h.version?.block ? BigInt(h.version.block) : 0n,
73
+ app: h.version?.app ? BigInt(h.version.app) : 0n,
74
+ };
75
+
76
+ // last_block_id
77
+ if (!h.last_block_id || !h.last_block_id.hash || !h.last_block_id.parts) {
78
+ throw new Error("Invalid last_block_id");
79
+ }
80
+ const lastBlockIdHash = hexToUint8Array(h.last_block_id.hash);
81
+ assertLen("last_block_id.hash", lastBlockIdHash, 32);
82
+ const lastBlockPartsHash = hexToUint8Array(h.last_block_id.parts.hash);
83
+ assertLen("last_block_id.parts.hash", lastBlockPartsHash, 32);
84
+ const lastBlockId: BlockID = {
85
+ hash: lastBlockIdHash,
86
+ partSetHeader: {
87
+ total: Number(h.last_block_id.parts.total),
88
+ hash: lastBlockPartsHash,
89
+ },
90
+ };
91
+ if (
92
+ !lastBlockId.partSetHeader ||
93
+ !lastBlockId.partSetHeader.total ||
94
+ !Number.isInteger(lastBlockId.partSetHeader.total) ||
95
+ lastBlockId.partSetHeader.total < 0
96
+ ) {
97
+ throw new Error("Invalid last_block_id.parts.total");
98
+ }
99
+
100
+ // Hash fields (32 bytes unless app hash, which is app-defined length)
101
+ const lastCommitHash = hexToUint8Array(h.last_commit_hash);
102
+ assertLen("last_commit_hash", lastCommitHash, 32);
103
+ const dataHash = hexToUint8Array(h.data_hash);
104
+ assertLen("data_hash", dataHash, 32);
105
+ const validatorsHash = hexToUint8Array(h.validators_hash);
106
+ assertLen("validators_hash", validatorsHash, 32);
107
+ const nextValidatorsHash = hexToUint8Array(h.next_validators_hash);
108
+ assertLen("next_validators_hash", nextValidatorsHash, 32);
109
+ const consensusHash = hexToUint8Array(h.consensus_hash);
110
+ assertLen("consensus_hash", consensusHash, 32);
111
+ const appHash = hexToUint8Array(h.app_hash); // variable length accepted
112
+ const lastResultsHash = hexToUint8Array(h.last_results_hash);
113
+ assertLen("last_results_hash", lastResultsHash, 32);
114
+ const evidenceHash = hexToUint8Array(h.evidence_hash);
115
+ assertLen("evidence_hash", evidenceHash, 32);
116
+
117
+ // proposer_address (20 bytes)
118
+ if (!h.proposer_address) throw new Error("Missing proposer_address");
119
+ const proposerAddress = hexToUint8Array(h.proposer_address);
120
+ assertLen("proposer_address", proposerAddress, 20);
121
+
122
+ // time
123
+ if (!h.time) throw new Error("Missing header.time");
124
+ const time = parseRFC3339ToTimestamp(h.time);
125
+
126
+ // Commit BlockID
127
+ if (!c.block_id || !c.block_id.hash || !c.block_id.parts) {
128
+ throw new Error("Invalid commit.block_id");
129
+ }
130
+ const commitBlockHash = hexToUint8Array(c.block_id.hash);
131
+ assertLen("commit.block_id.hash", commitBlockHash, 32);
132
+ const commitPartsHash = hexToUint8Array(c.block_id.parts.hash);
133
+ assertLen("commit.block_id.parts.hash", commitPartsHash, 32);
134
+ const commitBlockId: BlockID = {
135
+ hash: commitBlockHash,
136
+ partSetHeader: {
137
+ total: Number(c.block_id.parts.total),
138
+ hash: commitPartsHash,
139
+ },
140
+ };
141
+ if (
142
+ !commitBlockId ||
143
+ !commitBlockId.partSetHeader ||
144
+ !Number.isInteger(commitBlockId.partSetHeader.total) ||
145
+ commitBlockId.partSetHeader.total < 0
146
+ ) {
147
+ throw new Error("Invalid commit.block_id.parts.total");
148
+ }
149
+
150
+ // Signatures
151
+ if (!Array.isArray(c.signatures) || c.signatures.length === 0) {
152
+ throw new Error("Commit has no signatures");
153
+ }
154
+ const signatures: CommitSig[] = c.signatures.map((s, i) => {
155
+ if (typeof s.block_id_flag !== "number") {
156
+ throw new Error(`signatures[${i}].block_id_flag must be a number`);
157
+ }
158
+ if (!s.validator_address) {
159
+ throw new Error(`signatures[${i}].validator_address missing`);
160
+ }
161
+ const validatorAddress = hexToUint8Array(s.validator_address);
162
+ assertLen(`signatures[${i}].validator_address`, validatorAddress, 20);
163
+
164
+ // bytes fields in proto3 are NOT optional -> use empty Uint8Array when absent
165
+ const sigBytes = s.signature
166
+ ? base64ToUint8Array(s.signature)
167
+ : new Uint8Array(0);
168
+ if (sigBytes.length !== 0) {
169
+ assertLen(`signatures[${i}].signature`, sigBytes, 64); // Ed25519
170
+ }
171
+
172
+ const ts = s.timestamp ? parseRFC3339ToTimestamp(s.timestamp) : undefined;
173
+
174
+ return {
175
+ blockIdFlag: s.block_id_flag as BlockIDFlag,
176
+ validatorAddress,
177
+ timestamp: ts, // PbTimestamp | undefined (useDate=false)
178
+ signature: sigBytes, // always Uint8Array (maybe length 0)
179
+ };
180
+ });
181
+
182
+ const header: Header = {
183
+ version,
184
+ chainId: h.chain_id,
185
+ height: headerHeight,
186
+ time,
187
+
188
+ lastBlockId,
189
+
190
+ lastCommitHash,
191
+ dataHash,
192
+ validatorsHash,
193
+ nextValidatorsHash,
194
+ consensusHash,
195
+ appHash,
196
+ lastResultsHash,
197
+ evidenceHash,
198
+
199
+ proposerAddress,
200
+ };
201
+
202
+ const commit: Commit = {
203
+ height: headerHeight,
204
+ round: c.round,
205
+ blockId: commitBlockId,
206
+ signatures,
207
+ };
208
+
209
+ return { header, commit };
210
+ }
@@ -0,0 +1,37 @@
1
+ export function Uint8ArrayToBase64(data: Uint8Array | ArrayBuffer): string {
2
+ const uint8 = data instanceof Uint8Array ? data : new Uint8Array(data);
3
+ const binary = String.fromCharCode(...uint8);
4
+ return btoa(binary);
5
+ }
6
+
7
+ export function hexToUint8Array(hex: string): Uint8Array {
8
+ if (!/^[0-9a-fA-F]*$/.test(hex)) {
9
+ throw new Error("Hex string contains invalid characters");
10
+ }
11
+
12
+ if (hex.length % 2 !== 0) {
13
+ throw new Error("Hex string must have an even length");
14
+ }
15
+
16
+ const uint8Array = new Uint8Array(hex.length / 2);
17
+ for (let i = 0; i < uint8Array.length; i++) {
18
+ uint8Array[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
19
+ }
20
+ return uint8Array;
21
+ }
22
+
23
+ export function Uint8ArrayToHex(uint8Array: Uint8Array): string {
24
+ return [...uint8Array].map((b) => b.toString(16).padStart(2, "0")).join("");
25
+ }
26
+
27
+ export function base64ToUint8Array(base64: string): Uint8Array {
28
+ const binaryString = atob(base64);
29
+ const length = binaryString.length;
30
+ const bytes = new Uint8Array(length);
31
+
32
+ for (let i = 0; i < length; i++) {
33
+ bytes[i] = binaryString.charCodeAt(i); // Convert binary string to byte array
34
+ }
35
+
36
+ return new Uint8Array(bytes);
37
+ }
@@ -0,0 +1,214 @@
1
+ // src/lightclient.ts
2
+ import { Uint8ArrayToHex } from "./encoding";
3
+ import {
4
+ CanonicalBlockID,
5
+ CanonicalPartSetHeader,
6
+ CanonicalVote,
7
+ } from "./proto/cometbft/types/v1/canonical";
8
+ import { SignedHeader, SignedMsgType } from "./proto/cometbft/types/v1/types";
9
+ import {
10
+ Validator as ProtoValidator,
11
+ ValidatorSet as ProtoValidatorSet,
12
+ } from "./proto/cometbft/types/v1/validator";
13
+ import { Timestamp as PbTimestamp } from "./proto/google/protobuf/timestamp";
14
+
15
+ export type CryptoIndex = Map<string, CryptoKey>;
16
+
17
+ export interface VerifyOutcome {
18
+ ok: boolean;
19
+ quorum: boolean;
20
+ signedPower: bigint;
21
+ totalPower: bigint;
22
+ headerTime?: PbTimestamp;
23
+ appHash: Uint8Array;
24
+ blockIdHash: Uint8Array;
25
+ unknownValidators: string[];
26
+ invalidSignatures: string[];
27
+ countedSignatures: number;
28
+ }
29
+
30
+ function encodeUvarint(value: number): Uint8Array {
31
+ if (!Number.isSafeInteger(value) || value < 0)
32
+ throw new Error("encodeUvarint expects a non-negative safe integer");
33
+
34
+ const bytes: number[] = [];
35
+ let v = value;
36
+ while (v >= 0x80) {
37
+ bytes.push((v & 0x7f) | 0x80);
38
+ v >>>= 7;
39
+ }
40
+ bytes.push(v);
41
+ return new Uint8Array(bytes);
42
+ }
43
+
44
+ function makePrecommitSignBytesProto(
45
+ chainId: string,
46
+ height: bigint,
47
+ round: bigint,
48
+ blockIdHash: Uint8Array,
49
+ partsTotal: number,
50
+ partsHash: Uint8Array,
51
+ timestamp?: PbTimestamp,
52
+ ): Uint8Array {
53
+ const psh: CanonicalPartSetHeader = { total: partsTotal, hash: partsHash };
54
+ const bid: CanonicalBlockID = { hash: blockIdHash, partSetHeader: psh };
55
+
56
+ const vote: CanonicalVote = {
57
+ type: SignedMsgType.SIGNED_MSG_TYPE_PRECOMMIT,
58
+ height, // fixed64
59
+ round, // fixed64 (encoder omits 0)
60
+ blockId: bid,
61
+ timestamp, // omitted if undefined
62
+ chainId,
63
+ };
64
+
65
+ const body = CanonicalVote.encode(vote).finish();
66
+ // Go's protoio.MarshalDelimited length-prefixes the canonical vote. Using
67
+ // the same varint prefix keeps signatures compatible with both v0.34.x and
68
+ // v1.0.x chains.
69
+ const prefix = encodeUvarint(body.length);
70
+ const out = new Uint8Array(prefix.length + body.length);
71
+ out.set(prefix, 0);
72
+ out.set(body, prefix.length);
73
+ return out;
74
+ }
75
+
76
+ function hasTwoThirds(signed: bigint, total: bigint): boolean {
77
+ return signed * 3n > total * 2n;
78
+ }
79
+
80
+ export async function verifyCommit(
81
+ sh: SignedHeader,
82
+ vset: ProtoValidatorSet,
83
+ cryptoIndex: CryptoIndex,
84
+ ): Promise<VerifyOutcome> {
85
+ if (!sh?.header || !sh?.commit) {
86
+ throw new Error("SignedHeader missing header/commit");
87
+ }
88
+ const header = sh.header;
89
+ const commit = sh.commit;
90
+
91
+ if (header.height !== commit.height) {
92
+ throw new Error(
93
+ `Header/commit height mismatch: ${header.height} vs ${commit.height}`,
94
+ );
95
+ }
96
+
97
+ const totalPower = vset?.totalVotingPower ?? 0n;
98
+ if (!Array.isArray(vset?.validators) || vset.validators.length === 0) {
99
+ throw new Error("ValidatorSet has no validators");
100
+ }
101
+ if (totalPower <= 0n) {
102
+ throw new Error("ValidatorSet total power must be positive");
103
+ }
104
+
105
+ // Build address -> validator map
106
+ const setByAddrHex = new Map<string, ProtoValidator>();
107
+ for (const v of vset.validators) {
108
+ const hex = Uint8ArrayToHex(v.address).toUpperCase();
109
+ if (setByAddrHex.has(hex))
110
+ throw new Error(`Duplicate validator address in set: ${hex}`);
111
+ setByAddrHex.set(hex, v);
112
+ }
113
+
114
+ if (!commit.blockId) throw new Error("Commit missing BlockID");
115
+ const bid = commit.blockId;
116
+ if (!bid.hash || bid.hash.length === 0)
117
+ throw new Error("Commit BlockID hash is missing");
118
+ if (!bid.partSetHeader) throw new Error("Commit PartSetHeader is missing");
119
+ if (!bid.partSetHeader.hash || bid.partSetHeader.hash.length === 0) {
120
+ throw new Error("Commit PartSetHeader hash is missing");
121
+ }
122
+ if (
123
+ !Number.isInteger(bid.partSetHeader.total) ||
124
+ bid.partSetHeader.total < 0
125
+ ) {
126
+ throw new Error("Commit PartSetHeader total is invalid");
127
+ }
128
+
129
+ const chainId: string = header.chainId;
130
+ const heightBig: bigint = header.height;
131
+ const roundBig: bigint = BigInt(commit.round);
132
+ const blockIdHash: Uint8Array = bid.hash;
133
+ const partsHash: Uint8Array = bid.partSetHeader.hash;
134
+ const partsTotal: number = bid.partSetHeader.total;
135
+
136
+ let signedPower = 0n;
137
+ const unknown: string[] = [];
138
+ const invalid: string[] = [];
139
+ let counted = 0;
140
+
141
+ for (let idx = 0; idx < commit.signatures.length; idx++) {
142
+ const s = commit.signatures[idx];
143
+
144
+ // Only COMMIT votes (BLOCK_ID_FLAG_COMMIT == 2)
145
+ if (s.blockIdFlag !== 2) continue;
146
+
147
+ const addrHex = Uint8ArrayToHex(s.validatorAddress).toUpperCase();
148
+ const v = setByAddrHex.get(addrHex);
149
+ if (!v) {
150
+ unknown.push(addrHex);
151
+ continue;
152
+ }
153
+
154
+ if (!s.signature || s.signature.length === 0) {
155
+ invalid.push(addrHex);
156
+ continue;
157
+ }
158
+
159
+ // Count this COMMIT vote (known validator + non-empty signature)
160
+ counted++;
161
+
162
+ // Canonical sign-bytes
163
+ const signBytes = makePrecommitSignBytesProto(
164
+ chainId,
165
+ heightBig,
166
+ roundBig,
167
+ blockIdHash,
168
+ partsTotal,
169
+ partsHash,
170
+ s.timestamp,
171
+ );
172
+
173
+ const key = cryptoIndex.get(addrHex);
174
+ if (!key) {
175
+ invalid.push(addrHex);
176
+ continue;
177
+ }
178
+
179
+ // Verify signature
180
+ let ok = false;
181
+ try {
182
+ ok = await crypto.subtle.verify(
183
+ { name: "Ed25519" },
184
+ key,
185
+ new Uint8Array(s.signature),
186
+ new Uint8Array(signBytes),
187
+ );
188
+ } catch {
189
+ ok = false;
190
+ }
191
+
192
+ if (!ok) {
193
+ invalid.push(addrHex);
194
+ continue;
195
+ }
196
+
197
+ signedPower += v.votingPower ?? 0n;
198
+ }
199
+
200
+ const quorum = hasTwoThirds(signedPower, totalPower);
201
+
202
+ return {
203
+ ok: quorum,
204
+ quorum,
205
+ signedPower,
206
+ totalPower,
207
+ headerTime: header.time,
208
+ appHash: header.appHash,
209
+ blockIdHash,
210
+ unknownValidators: unknown,
211
+ invalidSignatures: invalid,
212
+ countedSignatures: counted,
213
+ };
214
+ }