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 +21 -0
- package/README.md +169 -2
- package/dist/src/anchor.d.ts +84 -0
- package/dist/src/anchor.js +249 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +551 -0
- package/dist/src/client.d.ts +137 -0
- package/dist/src/client.js +281 -0
- package/dist/src/equivocation.d.ts +36 -0
- package/dist/src/equivocation.js +99 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +29 -0
- package/dist/src/keydir.d.ts +41 -0
- package/dist/src/keydir.js +153 -0
- package/dist/src/merkle.d.ts +29 -0
- package/dist/src/merkle.js +211 -0
- package/dist/src/pinning.d.ts +43 -0
- package/dist/src/pinning.js +166 -0
- package/dist/src/sth.d.ts +49 -0
- package/dist/src/sth.js +170 -0
- package/dist/src/util.d.ts +19 -0
- package/dist/src/util.js +55 -0
- package/package.json +54 -4
- package/src/anchor.ts +319 -0
- package/src/cli.ts +717 -0
- package/src/client.ts +420 -0
- package/src/equivocation.ts +142 -0
- package/src/index.ts +115 -0
- package/src/keydir.ts +196 -0
- package/src/merkle.ts +219 -0
- package/src/pinning.ts +232 -0
- package/src/sth.ts +228 -0
- package/src/util.ts +72 -0
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
|
-
#
|
|
1
|
+
# twzrd-log-verifier
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|