twzrd-log-verifier 0.0.0-stage → 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TWZRD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,170 @@
1
- # Temporary Holding Version
1
+ # twzrd-log-verifier
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Offline verifier for the **TWZRD Receipt Transparency log** — the append-only,
4
+ Solana-anchored Merkle log of issued trust receipts (V7 today; the first leaves are V6). Spec:
5
+ [`docs/transparency-log.md`](../docs/transparency-log.md).
6
+
7
+ Verifies, with **no trust in TWZRD's servers or code**, that:
8
+
9
+ - a receipt leaf is included in a signed tree head (**inclusion proof**);
10
+ - a newer tree head only appended to an older one (**consistency proof**);
11
+ - a tree head was **anchored on Solana** by the published anchor authority (the `anchor_head` program form the live log uses, or the memo form of the spec draft);
12
+ - heads stay verifiable **across key rotations**, via a pinned key directory;
13
+ - two contradictory signed tree heads are a portable **misbehavior proof**
14
+ (**equivocation detection**) — including when different keys signed them.
15
+
16
+ Full source ships in this repo and in the npm tarball. Crypto comes from the
17
+ same audited libraries as
18
+ [`twzrd-receipt-verifier`](https://github.com/twzrd-sol/twzrd-receipt-verifier)
19
+ (`tweetnacl` = ref Ed25519, `js-sha3` = Keccak, `bs58`); the RFC 6962/9162
20
+ Merkle algorithms and the TWZRD byte layouts are the only logic here.
21
+
22
+ ## Why this exists
23
+
24
+ A valid V6 or V7 receipt proves TWZRD authored it. It does not stop TWZRD from
25
+ answering differently to different buyers (equivocation) or issuing receipts
26
+ after the fact (backdating). The transparency log makes both **provable**: every
27
+ receipt leaf is appended to one Merkle tree, heads are signed and anchored on
28
+ Solana, and any relying party can hold the log to its own history.
29
+
30
+ ## Usage
31
+
32
+ On npm from 0.3.0 (the first published version). Run it without installing:
33
+
34
+ ```bash
35
+ npx -y twzrd-log-verifier selftest
36
+ ```
37
+
38
+ or install it (`npm i -g twzrd-log-verifier`) and drop the `npx` prefix, as the
39
+ lines below do. To run the source in this repo instead:
40
+
41
+ ```bash
42
+ npm --prefix twzrd-log-verifier install && npm --prefix twzrd-log-verifier run build
43
+ alias twzrd-log-verifier='node "$PWD/twzrd-log-verifier/dist/src/cli.js"'
44
+ ```
45
+
46
+ ```bash
47
+ twzrd-log-verifier selftest # prove the checker checks (no files, no network)
48
+
49
+ # Is my paid receipt in the log the current signed head commits to?
50
+ twzrd-log-verifier inclusion --receipt receipt.json --proof proof.json --sth sth.json
51
+
52
+ # A paid response already carries its proof — verify it directly, no separate
53
+ # --leaf/--sth files (log_inclusion or the whole response as --proof):
54
+ twzrd-log-verifier inclusion --proof paid-response.json
55
+
56
+ # Did the log only append between two heads I saw at different times?
57
+ twzrd-log-verifier consistency --old sth-old.json --new sth-new.json --proof proof.json
58
+
59
+ # Was this head anchored on Solana by the published authority? Reads the transaction
60
+ # (use a mainnet tx_signature from /v1/log/anchors), requires it to have succeeded, and
61
+ # matches an anchor_head instruction of the evidence-ledger program (--program overrides
62
+ # it; --ledger also pins the ledger account, anchor_ledger_pda) or an anchor memo.
63
+ twzrd-log-verifier anchor --sth sth.json --tx <tx-signature> --authority <b58> [--program <id>] [--ledger <b58>] [--rpc <url>]
64
+
65
+ # Do two heads contradict each other? (a positive result is publishable proof)
66
+ twzrd-log-verifier equivocation --a sth-a.json --b sth-b.json [--proof consistency.json]
67
+
68
+ # Watch a live log: prove it only appended since the head you last pinned.
69
+ twzrd-log-verifier monitor --base-url https://intel.twzrd.xyz --state ./pin.json
70
+ ```
71
+
72
+ `monitor` is the split-view detector. It fetches the log's current head, demands
73
+ a consistency proof against the head you last pinned, and persists the new pin.
74
+ It reports one of `pinned` / `unchanged` / `advanced` / `lagging` /
75
+ `equivocation` / `error`, and **never advances the pin on an unproven step** — a
76
+ missing or failing proof leaves the old pin in place. A head *behind* your pin is
77
+ treated as replica lag and proven consistent in the other direction, not
78
+ misreported as an attack. Run it on a schedule; on `equivocation` it writes a
79
+ publishable proof bundle (`--proof-out`) and exits 2.
80
+
81
+ Exit code 0 = the requested verification succeeded; 1 = it failed or errored;
82
+ 2 = `monitor` proved equivocation. Inclusion, consistency, and equivocation
83
+ checks are fully offline. The anchor check needs any Solana RPC endpoint — the
84
+ RPC is a data source only and cannot forge a signer or a memo binding.
85
+
86
+ ## Pinning keys
87
+
88
+ Every command takes either form:
89
+
90
+ - `--pubkey KEY` — pin one base58 Ed25519 key.
91
+ - `--keys FILE` — pin a **key directory**, which is what you want once the log
92
+ has rotated a signing key. Each head names the `key_id` that signed it, bound
93
+ into the signature, and the directory says which key was entitled to sign
94
+ when. Retiring a key does not invalidate the heads it already signed, and a
95
+ retired key cannot be used to backdate a head into a window it never held.
96
+
97
+ The default is the built-in single key
98
+ (`Ak5SQwHpuQAqU7ty7ZWX7qgF39A9yi72c22KNn8sHzvS`, the current receipt issuer key,
99
+ also published at `intel.twzrd.xyz/.well-known/twzrd-receipt-pubkey`).
100
+
101
+ The log's own descriptor is **not** a root of trust — a compromised log would
102
+ advertise the attacker's keys. A pin you supply always wins over the descriptor;
103
+ to accept the log's self-advertised keys you must opt in with
104
+ `--trust-descriptor`, and the output labels that run as TOFU.
105
+
106
+ ## Library
107
+
108
+ ```typescript
109
+ import {
110
+ // proofs
111
+ verifyInclusion, verifyConsistency, verifySth, verifyAnchor, checkEquivocation,
112
+ merkleRoot, inclusionProof, consistencyProof,
113
+ // keys
114
+ validateLogKeyDirectory, resolveLogKey, currentSigningKey,
115
+ // live log
116
+ fetchLogDescriptor, fetchSth, fetchInclusionProof, fetchConsistencyProof,
117
+ verifyReceiptInLog, resolveTrust,
118
+ // split-view detection
119
+ createSthPinStore,
120
+ DEFAULT_STH_PUBKEY,
121
+ } from "twzrd-log-verifier";
122
+
123
+ // Is this paid receipt actually in the log, under a head my pinned keys signed?
124
+ const res = await verifyReceiptInLog({
125
+ baseUrl: "https://intel.twzrd.xyz",
126
+ receipt, // or: leaf: "<64 hex>"
127
+ trusted: myPinnedKeyDirectory,
128
+ });
129
+ // res.valid === true => inclusion proven against a signed head
130
+ ```
131
+
132
+ Generation functions (`merkleRoot`, `inclusionProof`, `consistencyProof`,
133
+ `signSth`) are exported so proofs are reproducible from an entry list; relying
134
+ parties only need the `verify*` half.
135
+
136
+ ## File shapes
137
+
138
+ ```jsonc
139
+ // Signed Tree Head (GET /v1/log/sth)
140
+ {
141
+ "domain": "TWZRD:RECEIPT_LOG_STH_V2",
142
+ "log_id": "intel.twzrd.xyz/v6",
143
+ "key_id": "twzrd-log-ed25519-v1",
144
+ "tree_size": 48213,
145
+ "timestamp_unix": 1755072000,
146
+ "root": "0x…64 hex…",
147
+ "signature": "<base58 Ed25519>",
148
+ "signing_pubkey": "<base58>"
149
+ }
150
+
151
+ // Key directory (--keys), also served inside the log descriptor
152
+ {
153
+ "version": 1,
154
+ "log_id": "intel.twzrd.xyz/v6",
155
+ "keys": [
156
+ { "key_id": "twzrd-log-ed25519-v1", "public_key": "<base58>",
157
+ "mode": "sign", "not_before_unix": 1756000000, "not_after_unix": null }
158
+ ]
159
+ }
160
+
161
+ // Inclusion proof (GET /v1/log/proof/inclusion?leaf=…)
162
+ { "leaf_index": 17, "tree_size": 48213, "audit_path": ["0x…", "…"], "sth": { … } }
163
+
164
+ // Consistency proof (GET /v1/log/proof/consistency?old_size=…&new_size=…)
165
+ { "path": ["0x…", "…"] }
166
+ ```
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,84 @@
1
+ import { type SignedTreeHead } from "./sth.js";
2
+ import { type FetchLike } from "./util.js";
3
+ import type { LogKeyDirectory } from "./keydir.js";
4
+ export declare const ANCHOR_MEMO_PREFIX = "twzrd-log-anchor:v1:";
5
+ export declare const MEMO_PROGRAM_IDS: Set<string>;
6
+ export declare const DEFAULT_RPC_URL = "https://api.mainnet-beta.solana.com";
7
+ /** The evidence-ledger program the live log anchors through (`anchor_program_id`). */
8
+ export declare const EVIDENCE_LEDGER_PROGRAM_ID = "BzBAYJxUtJp6mUkJPjEYjd8vdb2FUGnAfB5X9LqrQ72W";
9
+ /** sha256("global:anchor_head")[0..8], the program's instruction discriminator. */
10
+ export declare const ANCHOR_HEAD_DISCRIMINATOR: Uint8Array<ArrayBuffer>;
11
+ export interface AnchorPayload {
12
+ log_id: string;
13
+ tree_size: number;
14
+ root: string;
15
+ }
16
+ /**
17
+ * Parse `twzrd-log-anchor:v1:<log_id>:<tree_size>:<root_hex>`.
18
+ * Parsed from the end so a log_id containing ':' cannot shift fields.
19
+ * Returns null for anything that is not a well-formed anchor memo.
20
+ */
21
+ export declare function parseAnchorMemo(memo: string): AnchorPayload | null;
22
+ export interface AnchorHeadPayload extends AnchorPayload {
23
+ timestamp_unix: number;
24
+ }
25
+ /**
26
+ * Decode `anchor_head` instruction data: the discriminator, then
27
+ * `[tree_size u64 LE][timestamp_unix u64 LE][root 32][log_id_len u8][log_id UTF-8]`.
28
+ * Returns null for anything that is not exactly that (trailing bytes included).
29
+ */
30
+ export declare function parseAnchorHeadData(data: Uint8Array): AnchorHeadPayload | null;
31
+ export declare function formatAnchorMemo(payload: AnchorPayload): string;
32
+ /** Field mismatches between an anchor and a head; `timestamp_unix` is compared when the anchor carries it. */
33
+ export declare function anchorMatchesSth(payload: AnchorPayload | AnchorHeadPayload, sth: SignedTreeHead): string[];
34
+ export interface FetchedAnchorHead {
35
+ program_id: string;
36
+ /** [authority, payer, ledger, root entry, system program, instructions sysvar] */
37
+ accounts: string[];
38
+ payload: AnchorHeadPayload;
39
+ }
40
+ export interface FetchedAnchorTx {
41
+ memos: string[];
42
+ anchorHeads: FetchedAnchorHead[];
43
+ signers: string[];
44
+ /** false when the transaction failed (`meta.err` set) or carried no status: it anchored nothing. */
45
+ succeeded: boolean;
46
+ slot: number | null;
47
+ blockTime: number | null;
48
+ }
49
+ /** Fetch a transaction over plain JSON-RPC and extract memos, anchor_head instructions and signers. */
50
+ export declare function fetchAnchorTransaction(txSignature: string, rpcUrl?: string, fetchImpl?: FetchLike): Promise<FetchedAnchorTx>;
51
+ export interface AnchorVerifyResult {
52
+ valid: boolean;
53
+ errors: string[];
54
+ sth_valid: boolean;
55
+ /** Which binding matched the head: the program's anchor_head instruction, or a memo. */
56
+ anchor_kind: "anchor_head" | "memo" | null;
57
+ anchor_head_found: boolean;
58
+ memo_found: boolean;
59
+ tx_succeeded: boolean;
60
+ authority_signed: boolean;
61
+ slot: number | null;
62
+ block_time: number | null;
63
+ }
64
+ /**
65
+ * Full anchor check: STH signature, a successful transaction, a binding to
66
+ * this head (an `anchor_head` instruction of the pinned program whose
67
+ * authority account is the pinned authority, or an anchor memo), and the
68
+ * authority among the signers. A valid result proves the STH's
69
+ * (log_id, tree_size, root) existed on Solana no later than `block_time`,
70
+ * published by the pinned anchor authority.
71
+ */
72
+ export declare function verifyAnchor(opts: {
73
+ sth: SignedTreeHead;
74
+ txSignature: string;
75
+ /** Pinned key, or a pinned key directory when heads carry `key_id`. */
76
+ sthPubkey: string | LogKeyDirectory;
77
+ anchorAuthority: string;
78
+ /** The program an anchor_head must belong to (default: the evidence-ledger program). */
79
+ anchorProgramId?: string;
80
+ /** Optional ledger account pin (`anchor_ledger_pda`); checked against the instruction's ledger account. */
81
+ ledger?: string;
82
+ rpcUrl?: string;
83
+ fetchImpl?: FetchLike;
84
+ }): Promise<AnchorVerifyResult>;
@@ -0,0 +1,249 @@
1
+ /*
2
+ * Solana anchor verification: a Signed Tree Head is anchored by a successful
3
+ * mainnet transaction that commits to (log_id, tree_size, root) and is signed
4
+ * by the published anchor authority.
5
+ *
6
+ * The live log anchors through the evidence-ledger program: one
7
+ * `anchor_head` instruction carrying (tree_size, timestamp_unix, root,
8
+ * log_id), which the program accepts only from the ledger's authority and
9
+ * only after the runtime's ed25519 precompile has checked the head's
10
+ * signature in the same transaction. The SPL Memo form
11
+ * (`twzrd-log-anchor:v1:...`) of the v0.2 spec draft is still accepted.
12
+ *
13
+ * The RPC endpoint is only a data source — the binding is checked locally
14
+ * and the authority must appear among the transaction's signers, so a
15
+ * malicious RPC can hide an anchor but cannot forge one.
16
+ */
17
+ import { verifySth } from "./sth.js";
18
+ import { hexToBytes, bytesToHex, b58decode } from "./util.js";
19
+ import { HASH_LEN } from "./merkle.js";
20
+ export const ANCHOR_MEMO_PREFIX = "twzrd-log-anchor:v1:";
21
+ export const MEMO_PROGRAM_IDS = new Set([
22
+ "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", // Memo v2
23
+ "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo", // Memo v1
24
+ ]);
25
+ export const DEFAULT_RPC_URL = "https://api.mainnet-beta.solana.com";
26
+ /** The evidence-ledger program the live log anchors through (`anchor_program_id`). */
27
+ export const EVIDENCE_LEDGER_PROGRAM_ID = "BzBAYJxUtJp6mUkJPjEYjd8vdb2FUGnAfB5X9LqrQ72W";
28
+ /** sha256("global:anchor_head")[0..8], the program's instruction discriminator. */
29
+ export const ANCHOR_HEAD_DISCRIMINATOR = Uint8Array.of(0x6d, 0xab, 0x20, 0x3d, 0xc5, 0x98, 0xeb, 0x71);
30
+ const MAX_LOG_ID = 64;
31
+ /**
32
+ * Parse `twzrd-log-anchor:v1:<log_id>:<tree_size>:<root_hex>`.
33
+ * Parsed from the end so a log_id containing ':' cannot shift fields.
34
+ * Returns null for anything that is not a well-formed anchor memo.
35
+ */
36
+ export function parseAnchorMemo(memo) {
37
+ if (typeof memo !== "string" || !memo.startsWith(ANCHOR_MEMO_PREFIX))
38
+ return null;
39
+ const rest = memo.slice(ANCHOR_MEMO_PREFIX.length);
40
+ const lastColon = rest.lastIndexOf(":");
41
+ if (lastColon < 0)
42
+ return null;
43
+ const secondLastColon = rest.lastIndexOf(":", lastColon - 1);
44
+ if (secondLastColon < 0)
45
+ return null;
46
+ const logId = rest.slice(0, secondLastColon);
47
+ const sizeStr = rest.slice(secondLastColon + 1, lastColon);
48
+ const rootHex = rest.slice(lastColon + 1).toLowerCase();
49
+ if (logId.length === 0)
50
+ return null;
51
+ if (!/^[0-9]+$/.test(sizeStr))
52
+ return null;
53
+ const treeSize = Number(sizeStr);
54
+ if (!Number.isSafeInteger(treeSize))
55
+ return null;
56
+ if (!/^[0-9a-f]{64}$/.test(rootHex))
57
+ return null;
58
+ return { log_id: logId, tree_size: treeSize, root: rootHex };
59
+ }
60
+ function u64At(view, offset) {
61
+ const v = view.getBigUint64(offset, true);
62
+ return v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : null;
63
+ }
64
+ /**
65
+ * Decode `anchor_head` instruction data: the discriminator, then
66
+ * `[tree_size u64 LE][timestamp_unix u64 LE][root 32][log_id_len u8][log_id UTF-8]`.
67
+ * Returns null for anything that is not exactly that (trailing bytes included).
68
+ */
69
+ export function parseAnchorHeadData(data) {
70
+ if (data.length < 58)
71
+ return null;
72
+ if (ANCHOR_HEAD_DISCRIMINATOR.some((b, i) => data[i] !== b))
73
+ return null;
74
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
75
+ const treeSize = u64At(view, 8);
76
+ const timestamp = u64At(view, 16);
77
+ const len = data[56];
78
+ if (!treeSize || timestamp === null || len === 0 || len > MAX_LOG_ID || data.length !== 57 + len)
79
+ return null;
80
+ let logId;
81
+ try {
82
+ logId = new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(57));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ return { log_id: logId, tree_size: treeSize, timestamp_unix: timestamp, root: bytesToHex(data.subarray(24, 56)) };
88
+ }
89
+ export function formatAnchorMemo(payload) {
90
+ const root = hexToBytes(payload.root);
91
+ if (root.length !== HASH_LEN)
92
+ throw new Error("anchor root must be 32 bytes");
93
+ return `${ANCHOR_MEMO_PREFIX}${payload.log_id}:${payload.tree_size}:${bytesToHex(root)}`;
94
+ }
95
+ /** Field mismatches between an anchor and a head; `timestamp_unix` is compared when the anchor carries it. */
96
+ export function anchorMatchesSth(payload, sth) {
97
+ const errors = [];
98
+ if (payload.log_id !== String(sth.log_id)) {
99
+ errors.push(`anchor log_id ${JSON.stringify(payload.log_id)} != STH log_id ${JSON.stringify(String(sth.log_id))}`);
100
+ }
101
+ if (payload.tree_size !== Number(sth.tree_size)) {
102
+ errors.push(`anchor tree_size ${payload.tree_size} != STH tree_size ${sth.tree_size}`);
103
+ }
104
+ const sthRoot = String(sth.root || "").toLowerCase().replace(/^0x/, "");
105
+ if (payload.root !== sthRoot) {
106
+ errors.push(`anchor root ${payload.root} != STH root ${sthRoot}`);
107
+ }
108
+ if ("timestamp_unix" in payload && payload.timestamp_unix !== Number(sth.timestamp_unix)) {
109
+ errors.push(`anchor timestamp_unix ${payload.timestamp_unix} != STH timestamp_unix ${sth.timestamp_unix}`);
110
+ }
111
+ return errors;
112
+ }
113
+ /** Fetch a transaction over plain JSON-RPC and extract memos, anchor_head instructions and signers. */
114
+ export async function fetchAnchorTransaction(txSignature, rpcUrl = DEFAULT_RPC_URL, fetchImpl = fetch) {
115
+ const res = await fetchImpl(rpcUrl, {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: JSON.stringify({
119
+ jsonrpc: "2.0",
120
+ id: 1,
121
+ method: "getTransaction",
122
+ params: [
123
+ txSignature,
124
+ { encoding: "jsonParsed", maxSupportedTransactionVersion: 0, commitment: "finalized" },
125
+ ],
126
+ }),
127
+ });
128
+ if (!res.ok)
129
+ throw new Error(`RPC HTTP ${res.status}`);
130
+ const body = (await res.json());
131
+ if (body.error)
132
+ throw new Error(`RPC error: ${body.error.message || "unknown"}`);
133
+ if (!body.result)
134
+ throw new Error("transaction not found (or RPC pruned it)");
135
+ const msg = body.result.transaction?.message || {};
136
+ const signers = (msg.accountKeys || [])
137
+ .filter((k) => k && k.signer === true && typeof k.pubkey === "string")
138
+ .map((k) => k.pubkey);
139
+ const memos = [];
140
+ const anchorHeads = [];
141
+ for (const ix of msg.instructions || []) {
142
+ const isMemo = ix.program === "spl-memo" || MEMO_PROGRAM_IDS.has(String(ix.programId || ""));
143
+ if (isMemo && typeof ix.parsed === "string")
144
+ memos.push(ix.parsed);
145
+ if (typeof ix.data !== "string")
146
+ continue;
147
+ let payload = null;
148
+ try {
149
+ payload = parseAnchorHeadData(b58decode(ix.data));
150
+ }
151
+ catch {
152
+ continue; // not base58: some other program's data
153
+ }
154
+ if (payload) {
155
+ const accounts = (ix.accounts || []).filter((a) => typeof a === "string");
156
+ anchorHeads.push({ program_id: String(ix.programId || ""), accounts, payload });
157
+ }
158
+ }
159
+ const meta = body.result.meta;
160
+ return {
161
+ memos,
162
+ anchorHeads,
163
+ signers,
164
+ succeeded: meta !== undefined && meta !== null && meta.err === null,
165
+ slot: typeof body.result.slot === "number" ? body.result.slot : null,
166
+ blockTime: typeof body.result.blockTime === "number" ? body.result.blockTime : null,
167
+ };
168
+ }
169
+ /**
170
+ * Full anchor check: STH signature, a successful transaction, a binding to
171
+ * this head (an `anchor_head` instruction of the pinned program whose
172
+ * authority account is the pinned authority, or an anchor memo), and the
173
+ * authority among the signers. A valid result proves the STH's
174
+ * (log_id, tree_size, root) existed on Solana no later than `block_time`,
175
+ * published by the pinned anchor authority.
176
+ */
177
+ export async function verifyAnchor(opts) {
178
+ const out = {
179
+ valid: false,
180
+ errors: [],
181
+ sth_valid: false,
182
+ anchor_kind: null,
183
+ anchor_head_found: false,
184
+ memo_found: false,
185
+ tx_succeeded: false,
186
+ authority_signed: false,
187
+ slot: null,
188
+ block_time: null,
189
+ };
190
+ const sthRes = verifySth(opts.sth, opts.sthPubkey);
191
+ out.sth_valid = sthRes.valid;
192
+ if (!sthRes.valid)
193
+ out.errors.push(...sthRes.errors.map((e) => `sth: ${e}`));
194
+ let tx;
195
+ try {
196
+ tx = await fetchAnchorTransaction(opts.txSignature, opts.rpcUrl, opts.fetchImpl);
197
+ }
198
+ catch (e) {
199
+ out.errors.push(`fetch: ${e.message}`);
200
+ return out;
201
+ }
202
+ out.slot = tx.slot;
203
+ out.block_time = tx.blockTime;
204
+ out.tx_succeeded = tx.succeeded;
205
+ if (!tx.succeeded)
206
+ out.errors.push("transaction failed or has no status, so it anchored nothing");
207
+ const program = opts.anchorProgramId ?? EVIDENCE_LEDGER_PROGRAM_ID;
208
+ const heads = tx.anchorHeads.filter((h) => h.program_id === program);
209
+ for (const h of heads) {
210
+ const mismatches = anchorMatchesSth(h.payload, opts.sth);
211
+ if (h.accounts[0] !== opts.anchorAuthority) {
212
+ mismatches.push(`anchor_head authority ${h.accounts[0]} != pinned authority ${opts.anchorAuthority}`);
213
+ }
214
+ if (opts.ledger !== undefined && h.accounts[2] !== opts.ledger) {
215
+ mismatches.push(`anchor_head ledger ${h.accounts[2]} != pinned ledger ${opts.ledger}`);
216
+ }
217
+ if (mismatches.length === 0) {
218
+ out.anchor_head_found = true;
219
+ break;
220
+ }
221
+ out.errors.push(...mismatches);
222
+ }
223
+ if (!out.anchor_head_found) {
224
+ for (const memo of tx.memos) {
225
+ const parsed = parseAnchorMemo(memo);
226
+ if (!parsed)
227
+ continue;
228
+ const mismatches = anchorMatchesSth(parsed, opts.sth);
229
+ if (mismatches.length === 0) {
230
+ out.memo_found = true;
231
+ break;
232
+ }
233
+ out.errors.push(...mismatches);
234
+ }
235
+ }
236
+ out.anchor_kind = out.anchor_head_found ? "anchor_head" : out.memo_found ? "memo" : null;
237
+ if (out.anchor_kind === null && heads.length === 0 && tx.memos.length === 0) {
238
+ out.errors.push(`transaction contains no anchor_head instruction of ${program} and no memo instruction`);
239
+ }
240
+ else if (out.anchor_kind === null) {
241
+ out.errors.push("no anchor_head instruction or memo in the transaction matches this STH");
242
+ }
243
+ out.authority_signed = tx.signers.includes(opts.anchorAuthority);
244
+ if (!out.authority_signed) {
245
+ out.errors.push(`anchor authority ${opts.anchorAuthority} is not a signer of the transaction (signers: ${tx.signers.join(", ") || "none"})`);
246
+ }
247
+ out.valid = out.sth_valid && out.tx_succeeded && out.anchor_kind !== null && out.authority_signed;
248
+ return out;
249
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};