@collectorcrypt/vrf-client 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.
@@ -0,0 +1,305 @@
1
+ import { verifyVRF, vrfProofToHash, bytesToHex } from "@collectorcrypt/ecvrf";
2
+ import { sha256 } from "@noble/hashes/sha2.js";
3
+ import { PublicKey } from "@solana/web3.js";
4
+ import { deriveAuthorityAddress } from "./addresses";
5
+ import { CC_VRF_PROGRAM_ID, SUITE_EDWARDS25519_SHA512_TAI } from "./constants";
6
+
7
+ export interface OnChainAuthority {
8
+ authorityAddress?: PublicKey;
9
+ owner: PublicKey;
10
+ pk: Uint8Array;
11
+ suite: number;
12
+ frozen: boolean;
13
+ revoked: boolean;
14
+ label: Uint8Array;
15
+ }
16
+
17
+ /**
18
+ * On-chain commitment record fetched from a VrfProofCommit compressed PDA.
19
+ * Either the consumer fetches this themselves via fetchProofCommit() and
20
+ * passes it in, or they construct it from raw fields if validating offline.
21
+ */
22
+ export interface OnChainCommit {
23
+ authority?: PublicKey;
24
+ memoHash: Uint8Array;
25
+ proofHash: Uint8Array;
26
+ alphaHash: Uint8Array;
27
+ committedSlot: bigint | number;
28
+ }
29
+
30
+ export interface VerifyEndToEndInput {
31
+ /** The operator's published VRF public key (32 bytes Ed25519). */
32
+ pk: Uint8Array;
33
+ /** The exact alpha bytes the operator hashed and signed over. */
34
+ alpha: Uint8Array;
35
+ /** The 80-byte VRF proof the operator produced. */
36
+ proof: Uint8Array;
37
+ /** The on-chain commitment row, fetched from the program. */
38
+ onChainCommit: OnChainCommit;
39
+ /** Original memo string/bytes, used to verify the memo hash. */
40
+ memo: string | Uint8Array;
41
+ /** VRF suite. Omitted means RFC 9381 Ed25519-SHA512-TAI. */
42
+ suite?: number;
43
+ }
44
+
45
+ export interface VerifyEndToEndResult {
46
+ /**
47
+ * True iff every check passed: ECVRF math, on-chain proof hash, on-chain
48
+ * alpha hash, on-chain memo hash. Any failure flips this false.
49
+ */
50
+ valid: boolean;
51
+ ecvrfValid: boolean;
52
+ suiteSupported: boolean;
53
+ proofHashMatches: boolean;
54
+ alphaHashMatches: boolean;
55
+ memoHashMatches: boolean;
56
+ /** SHA-512 (64-byte) beta derived from the proof, present iff `ecvrfValid`. */
57
+ beta: Uint8Array | null;
58
+ reasons: string[];
59
+ }
60
+
61
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
62
+ if (a.length !== b.length) return false;
63
+ let diff = 0;
64
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
65
+ return diff === 0;
66
+ }
67
+
68
+ /**
69
+ * Single-call full verification for a VRF outcome that's been committed
70
+ * on-chain. Validates:
71
+ *
72
+ * 1. ECVRF math: `verifyVRF(pk, alpha, proof)` → true
73
+ * 2. sha256(proof) == on-chain commit.proof_hash
74
+ * 3. sha256(alpha) == on-chain commit.alpha_hash
75
+ * 4. sha256(memo) == on-chain commit.memo_hash
76
+ *
77
+ * If all four hold, the operator cannot have substituted the proof, alpha,
78
+ * or memo after the fact — the result is provably tied to the on-chain
79
+ * commitment.
80
+ */
81
+ /**
82
+ * Result of picking the canonical commit from a list of events for the same
83
+ * `(authority, memo)`. The "canonical" commit is the unique event whose
84
+ * `proof_hash` matches the SHA-256 of a proof that verifies under ECVRF.
85
+ *
86
+ * Because ECVRF proofs are deterministic for a given (pk, alpha), at most one
87
+ * candidate can have a valid `proof_hash`. Extra events (which the chain
88
+ * allows in event-mode) are simply garbage payloads — detectable, not a
89
+ * successful forgery.
90
+ */
91
+ export interface PickCanonicalResult {
92
+ /** The single event whose committed hash matches the verifying proof, or null if none verify. */
93
+ canonical: OnChainCommit | null;
94
+ /** All candidates inspected, in the order supplied. */
95
+ candidates: OnChainCommit[];
96
+ /** Whether more than one event was found for the same memo. Informational. */
97
+ duplicateMemoEvents: boolean;
98
+ /** Whether more than one candidate's `proof_hash` matched a verifying proof (should be impossible if ECVRF is sound). */
99
+ multipleVerifying: boolean;
100
+ }
101
+
102
+ export interface VerifyAuthorityCommitEndToEndInput extends Omit<
103
+ VerifyEndToEndInput,
104
+ "pk" | "suite"
105
+ > {
106
+ /** Authority state fetched from the VrfAuthority compressed PDA. */
107
+ authority: OnChainAuthority;
108
+ /** Expected owner for the authority, usually the operator wallet. */
109
+ expectedOwner?: PublicKey;
110
+ /** Expected 32-byte authority label. */
111
+ expectedLabel?: Uint8Array;
112
+ /**
113
+ * Optional sanity check: redundant against the address rederived from
114
+ * `(owner, label)` inside the verifier. If provided, it must match the
115
+ * derived address or `valid` flips false.
116
+ */
117
+ expectedAuthorityAddress?: PublicKey;
118
+ /**
119
+ * Optional program ID override. Defaults to the canonical cc-vrf program
120
+ * ID and only needs to be set for forked deployments.
121
+ */
122
+ programId?: PublicKey;
123
+ /** 64-byte beta fetched from a VrfProofCommitWithBeta account. */
124
+ onChainBeta?: Uint8Array;
125
+ }
126
+
127
+ export interface VerifyAuthorityCommitEndToEndResult extends VerifyEndToEndResult {
128
+ authorityFrozen: boolean;
129
+ authorityNotRevoked: boolean;
130
+ authorityOwnerMatches: boolean;
131
+ authorityLabelMatches: boolean;
132
+ commitAuthorityMatches: boolean;
133
+ betaMatches: boolean | null;
134
+ }
135
+
136
+ /**
137
+ * Resolve which committed event corresponds to a known-valid proof. Use this
138
+ * when fetching event-mode commits where the chain doesn't enforce
139
+ * one-commit-per-memo. Pass in all candidates from `fetchProofCommitEvents`
140
+ * plus the proof bytes the operator gave you; you get back the unique
141
+ * canonical row (or null if none match).
142
+ */
143
+ export function pickCanonicalCommit(
144
+ candidates: OnChainCommit[],
145
+ proof: Uint8Array,
146
+ ): PickCanonicalResult {
147
+ const proofHash = sha256(proof);
148
+ const matching = candidates.filter((c) => bytesEqual(proofHash, c.proofHash));
149
+ return {
150
+ canonical: matching[0] ?? null,
151
+ candidates,
152
+ duplicateMemoEvents: candidates.length > 1,
153
+ multipleVerifying: matching.length > 1,
154
+ };
155
+ }
156
+
157
+ export function verifyEndToEnd(
158
+ input: VerifyEndToEndInput,
159
+ ): VerifyEndToEndResult {
160
+ const reasons: string[] = [];
161
+
162
+ const suite = input.suite ?? SUITE_EDWARDS25519_SHA512_TAI;
163
+ const suiteSupported = suite === SUITE_EDWARDS25519_SHA512_TAI;
164
+ if (!suiteSupported) reasons.push(`unsupported-suite-${suite}`);
165
+
166
+ const ecvrfValid =
167
+ suiteSupported && verifyVRF(input.pk, input.alpha, input.proof);
168
+ if (!ecvrfValid) reasons.push("ecvrf-verify-failed");
169
+
170
+ const computedProofHash = sha256(input.proof);
171
+ const proofHashMatches = bytesEqual(
172
+ computedProofHash,
173
+ input.onChainCommit.proofHash,
174
+ );
175
+ if (!proofHashMatches) {
176
+ reasons.push(
177
+ `proof-hash-mismatch (computed=${bytesToHex(computedProofHash)} onchain=${bytesToHex(input.onChainCommit.proofHash)})`,
178
+ );
179
+ }
180
+
181
+ const computedAlphaHash = sha256(input.alpha);
182
+ const alphaHashMatches = bytesEqual(
183
+ computedAlphaHash,
184
+ input.onChainCommit.alphaHash,
185
+ );
186
+ if (!alphaHashMatches) reasons.push("alpha-hash-mismatch");
187
+
188
+ const memoBytes =
189
+ typeof input.memo === "string"
190
+ ? new TextEncoder().encode(input.memo)
191
+ : input.memo;
192
+ const computedMemoHash = sha256(memoBytes);
193
+ const memoHashMatches = bytesEqual(
194
+ computedMemoHash,
195
+ input.onChainCommit.memoHash,
196
+ );
197
+ if (!memoHashMatches) reasons.push("memo-hash-mismatch");
198
+
199
+ const valid =
200
+ suiteSupported &&
201
+ ecvrfValid &&
202
+ proofHashMatches &&
203
+ alphaHashMatches &&
204
+ memoHashMatches;
205
+ const beta = ecvrfValid ? vrfProofToHash(input.proof) : null;
206
+
207
+ return {
208
+ valid,
209
+ ecvrfValid,
210
+ suiteSupported,
211
+ proofHashMatches,
212
+ alphaHashMatches,
213
+ memoHashMatches,
214
+ beta,
215
+ reasons,
216
+ };
217
+ }
218
+
219
+ export function verifyAuthorityCommitEndToEnd(
220
+ input: VerifyAuthorityCommitEndToEndInput,
221
+ ): VerifyAuthorityCommitEndToEndResult {
222
+ const base = verifyEndToEnd({
223
+ pk: input.authority.pk,
224
+ suite: input.authority.suite,
225
+ alpha: input.alpha,
226
+ proof: input.proof,
227
+ onChainCommit: input.onChainCommit,
228
+ memo: input.memo,
229
+ });
230
+ const reasons = [...base.reasons];
231
+
232
+ const authorityFrozen = input.authority.frozen;
233
+ if (!authorityFrozen) reasons.push("authority-not-frozen");
234
+
235
+ const authorityNotRevoked = !input.authority.revoked;
236
+ if (!authorityNotRevoked) reasons.push("authority-revoked");
237
+
238
+ const authorityOwnerMatches = input.expectedOwner
239
+ ? input.authority.owner.equals(input.expectedOwner)
240
+ : true;
241
+ if (!authorityOwnerMatches) reasons.push("authority-owner-mismatch");
242
+
243
+ const authorityLabelMatches = input.expectedLabel
244
+ ? bytesEqual(input.authority.label, input.expectedLabel)
245
+ : true;
246
+ if (!authorityLabelMatches) reasons.push("authority-label-mismatch");
247
+
248
+ // Always rederive the canonical authority address from (owner, label) and
249
+ // require the commit to point at it. This closes the prior footgun where a
250
+ // hand-built OnChainAuthority + missing expectedAuthorityAddress silently
251
+ // skipped the commit-to-authority binding.
252
+ const programId = input.programId ?? CC_VRF_PROGRAM_ID;
253
+ const derivedAuthorityAddress = deriveAuthorityAddress(
254
+ input.authority.owner,
255
+ input.authority.label,
256
+ programId,
257
+ );
258
+ const commitAuthority = input.onChainCommit.authority;
259
+ const commitAuthorityMatches =
260
+ commitAuthority?.equals(derivedAuthorityAddress) === true;
261
+ if (!commitAuthorityMatches) {
262
+ reasons.push(
263
+ commitAuthority
264
+ ? "commit-authority-mismatch"
265
+ : "commit-authority-missing",
266
+ );
267
+ }
268
+
269
+ // If the caller passed their own expectedAuthorityAddress, it must also
270
+ // match the derived one — otherwise the caller's expectation contradicts
271
+ // the (owner, label) they supplied.
272
+ const expectedAuthorityMatchesDerived = input.expectedAuthorityAddress
273
+ ? input.expectedAuthorityAddress.equals(derivedAuthorityAddress)
274
+ : true;
275
+ if (!expectedAuthorityMatchesDerived) {
276
+ reasons.push("expected-authority-address-mismatch");
277
+ }
278
+
279
+ const betaMatches = input.onChainBeta
280
+ ? base.beta !== null && bytesEqual(input.onChainBeta, base.beta)
281
+ : null;
282
+ if (betaMatches === false) reasons.push("beta-mismatch");
283
+
284
+ const valid =
285
+ base.valid &&
286
+ authorityFrozen &&
287
+ authorityNotRevoked &&
288
+ authorityOwnerMatches &&
289
+ authorityLabelMatches &&
290
+ commitAuthorityMatches &&
291
+ expectedAuthorityMatchesDerived &&
292
+ betaMatches !== false;
293
+
294
+ return {
295
+ ...base,
296
+ valid,
297
+ reasons,
298
+ authorityFrozen,
299
+ authorityNotRevoked,
300
+ authorityOwnerMatches,
301
+ authorityLabelMatches,
302
+ commitAuthorityMatches,
303
+ betaMatches,
304
+ };
305
+ }