@x1id/resolve 0.2.0 → 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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Control-verification primitive — prove to a third party that a wallet
3
+ * currently controls a `@handle`. Companion to docs/control-proof.md, which
4
+ * is normative; this file implements it.
5
+ *
6
+ * The flow has exactly two SDK entry points, one per side:
7
+ *
8
+ * - **Verifier** calls {@link createControlChallenge}: it generates a fresh
9
+ * nonce, reads the handle's CURRENT ownership epoch
10
+ * (`Handle.registered_at`) from chain, and returns the challenge the
11
+ * prover must sign. The verifier stores the returned object (nonce is
12
+ * single-use, proofs expire — see the spec).
13
+ * - The prover signs the raw UTF-8 bytes of `challenge` with the wallet key
14
+ * that controls the handle (no wallet prefix — see the spec's envelope
15
+ * section) and returns `(signer, signature)`.
16
+ * - **Verifier** calls {@link verifyControlProof} with the ISSUED challenge
17
+ * object (never a challenge string received from the prover) and the
18
+ * proof. It re-reads the handle, requires the epoch to still match,
19
+ * resolves who currently holds authority (`require_current_authority`
20
+ * semantics: untokenized → `Handle.owner`; tokenized → the NFT holder,
21
+ * NFT in that holder's associated token account), and verifies the
22
+ * signature RFC-8032-strictly.
23
+ *
24
+ * Challenge format (built by `handle_normalize::control_challenge` in Rust
25
+ * and {@link buildControlChallenge} here — byte-identical by test):
26
+ *
27
+ * x1-handles:ctl:v1:<handle>:<registered_at>:<nonce>
28
+ *
29
+ * The `ctl` purpose tag makes this space disjoint from the record
30
+ * verification challenges (`x1-handles:v1:...`): a signature obtained for a
31
+ * record verification can never validate as a control proof, and vice versa.
32
+ */
33
+ import type { WasmResolver } from "./wasm.js";
34
+ /** `<protocol>:<purpose>:<version>:` — everything a control challenge starts
35
+ * with, and nothing a record-verification challenge ever starts with (their
36
+ * second segment is the literal `v1`, and neither a canonical handle nor a
37
+ * nonce may contain `:`). */
38
+ export declare const CONTROL_CHALLENGE_PREFIX = "x1-handles:ctl:v1:";
39
+ export interface ControlConfig {
40
+ /** X1 RPC endpoint. */
41
+ readonly rpcUrl: string;
42
+ /** WASM module for account derivation — same instance the resolver uses. */
43
+ readonly wasm: WasmResolver;
44
+ /** Optional fetch override for testing or custom transport. */
45
+ readonly fetchImpl?: typeof fetch;
46
+ /** @handle registry program id. Defaults to the canonical X1 deployment. */
47
+ readonly handleProgramId?: string;
48
+ /**
49
+ * Override for the ed25519 verifier — for runtimes whose WebCrypto lacks
50
+ * Ed25519. Any replacement MUST be RFC-8032 strict (reject `s >= L`);
51
+ * docs/control-proof.md names acceptable libraries. Defaults to
52
+ * {@link verifyEd25519Strict}.
53
+ */
54
+ readonly verifyEd25519?: (publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => Promise<boolean>;
55
+ }
56
+ /**
57
+ * A challenge the VERIFIER issued and must keep. `challenge` is what the
58
+ * prover signs; the other fields are the verifier's evidence of what it
59
+ * asked for. `verifyControlProof` takes this object — not a string handed
60
+ * back by the prover — so a prover can never substitute a challenge of its
61
+ * own choosing.
62
+ */
63
+ export interface ControlChallenge {
64
+ /** The exact string whose raw UTF-8 bytes the prover must sign. */
65
+ readonly challenge: string;
66
+ /** Canonical handle (no `@`). */
67
+ readonly handle: string;
68
+ /** `Handle.registered_at` at issuance — the ownership epoch this
69
+ * challenge is bound to. */
70
+ readonly registeredAt: bigint;
71
+ /** The verifier-issued single-use nonce embedded in `challenge`. */
72
+ readonly nonce: string;
73
+ /** `Date.now()` at issuance, ms — enforce the max proof age against it. */
74
+ readonly issuedAt: number;
75
+ }
76
+ /** What the prover hands back. */
77
+ export interface ControlProof {
78
+ /** The wallet claiming control, base58. */
79
+ readonly signer: string;
80
+ /** 64-byte ed25519 signature over the raw UTF-8 bytes of the challenge. */
81
+ readonly signature: Uint8Array;
82
+ }
83
+ export type ControlFailureReason =
84
+ /** The handle account no longer exists or is not owned by the registry. */
85
+ "handle-not-registered"
86
+ /** `Handle.registered_at` changed since the challenge was issued — the
87
+ * name was sold, released + re-registered, or otherwise changed epoch.
88
+ * The proof is a previous owner's; issue a fresh challenge. */
89
+ | "epoch-changed"
90
+ /** Tokenized handle whose NFT was burned — nobody controls the name. */
91
+ | "nft-burned"
92
+ /** The signer is not the current authority (untokenized: not
93
+ * `Handle.owner`; tokenized: not the NFT holder). */
94
+ | "signer-not-authority"
95
+ /** Tokenized handle whose NFT sits outside the holder's associated token
96
+ * account — the registry does not recognise that wallet as able to act
97
+ * for the name (`require_current_authority` parity), so neither does a
98
+ * control proof. */
99
+ | "nft-not-in-authority-ata"
100
+ /** The signature does not verify (strictly) over the challenge bytes. */
101
+ | "bad-signature";
102
+ export type ControlVerification = {
103
+ readonly valid: true;
104
+ /** Canonical handle the proof establishes control of. */
105
+ readonly handle: string;
106
+ /** The verified controller, base58. */
107
+ readonly signer: string;
108
+ /** The ownership epoch the proof is valid for. */
109
+ readonly registeredAt: bigint;
110
+ /** Whether authority came from holding the NFT (true) or from
111
+ * `Handle.owner` (false). */
112
+ readonly tokenized: boolean;
113
+ } | {
114
+ readonly valid: false;
115
+ readonly reason: ControlFailureReason;
116
+ readonly message: string;
117
+ };
118
+ /**
119
+ * Build the exact challenge string — byte-identical to the Rust
120
+ * `handle_normalize::control_challenge`. Throws {@link ResolveError}
121
+ * (`invalid-handle`) for a non-canonical handle and `RangeError` for a
122
+ * nonce outside the shared charset, rather than silently producing a string
123
+ * the Rust side would refuse.
124
+ *
125
+ * Verifiers normally never call this directly — {@link
126
+ * createControlChallenge} does, after reading `registeredAt` from chain, so
127
+ * a challenge cannot be built against a guessed or stale epoch.
128
+ */
129
+ export declare function buildControlChallenge(handle: string, registeredAt: bigint, nonce: string): string;
130
+ /**
131
+ * Parse a control challenge string, or null for anything that is not one —
132
+ * including every record-verification challenge (`x1-handles:v1:...`), whose
133
+ * signatures must never be accepted as control proofs.
134
+ */
135
+ export declare function parseControlChallenge(challenge: string): {
136
+ readonly handle: string;
137
+ readonly registeredAt: bigint;
138
+ readonly nonce: string;
139
+ } | null;
140
+ /**
141
+ * Generate a verifier-issued nonce: `bytes` bytes (>= 16, the spec's entropy
142
+ * floor) from the platform CSPRNG, hex-encoded to stay inside the shared
143
+ * nonce charset. The nonce is SINGLE-USE — store it with the issued
144
+ * challenge and delete it the moment a proof against it is checked, pass or
145
+ * fail. A prover-chosen nonce is not a nonce; see docs/control-proof.md.
146
+ */
147
+ export declare function generateControlNonce(bytes?: number): string;
148
+ /**
149
+ * Verifier side, step 1: issue a challenge for a handle.
150
+ *
151
+ * Reads the handle's CURRENT `registered_at` from chain and binds the
152
+ * challenge to it — there is no way to obtain a challenge through this API
153
+ * without the live epoch in hand, which is what makes a past owner's proof
154
+ * worthless the moment the name changes hands.
155
+ *
156
+ * Store the returned object server-side (it is the only thing
157
+ * {@link verifyControlProof} accepts), enforce single use of `nonce`, and
158
+ * enforce a max proof age against `issuedAt` (the spec recommends 5 minutes).
159
+ *
160
+ * @throws {ResolveError} `invalid-handle` for bad input, `not-found` for an
161
+ * unregistered handle, `rpc-error` for transport problems.
162
+ */
163
+ export declare function createControlChallenge(cfg: ControlConfig, handleInput: string): Promise<ControlChallenge>;
164
+ /**
165
+ * Verifier side, step 2: check a proof against the challenge YOU issued.
166
+ *
167
+ * Takes the stored {@link ControlChallenge} — never a challenge string
168
+ * received from the prover — and re-derives the signed bytes from its
169
+ * fields, so neither side can substitute a different string. Then:
170
+ *
171
+ * 1. re-reads the handle and requires `registered_at` to still equal the
172
+ * epoch the challenge was issued under (`epoch-changed` otherwise —
173
+ * the name changed hands after issuance);
174
+ * 2. resolves the CURRENT authority with `require_current_authority`
175
+ * semantics — untokenized → `Handle.owner`; tokenized → the NFT holder,
176
+ * and only with the NFT in that holder's associated token account
177
+ * (`Handle.owner` is stale once tokenized and is never consulted);
178
+ * 3. requires `proof.signer` to be that authority;
179
+ * 4. verifies the 64-byte ed25519 signature over the raw UTF-8 challenge
180
+ * bytes, RFC-8032-strictly.
181
+ *
182
+ * Verification outcomes come back as `{ valid: false, reason }` — only
183
+ * transport failures throw (`ResolveError` `rpc-error`). Nonce single-use
184
+ * and max proof age are the CALLER's responsibility: delete the stored
185
+ * challenge the moment this returns, whatever the outcome, and refuse
186
+ * challenges older than your proof-age limit before calling.
187
+ */
188
+ export declare function verifyControlProof(cfg: ControlConfig, issued: ControlChallenge, proof: ControlProof): Promise<ControlVerification>;
189
+ /**
190
+ * RFC-8032-strict ed25519 verification via WebCrypto (`Ed25519`), available
191
+ * in Node 20+ and current browsers. Both are backed by implementations that
192
+ * reject a non-canonical `s >= L` (the malleability the spec's strictness
193
+ * requirement exists for) — the test suite pins this with known-answer
194
+ * vectors, including a rejected `s + L` forgery of a valid signature.
195
+ *
196
+ * Returns false for anything malformed (wrong lengths, off-curve key,
197
+ * invalid signature); throws only when the runtime has no Ed25519 WebCrypto
198
+ * at all — pass `verifyEd25519` in {@link ControlConfig} there, using a
199
+ * strict library named in docs/control-proof.md.
200
+ */
201
+ export declare function verifyEd25519Strict(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): Promise<boolean>;
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Control-verification primitive — prove to a third party that a wallet
3
+ * currently controls a `@handle`. Companion to docs/control-proof.md, which
4
+ * is normative; this file implements it.
5
+ *
6
+ * The flow has exactly two SDK entry points, one per side:
7
+ *
8
+ * - **Verifier** calls {@link createControlChallenge}: it generates a fresh
9
+ * nonce, reads the handle's CURRENT ownership epoch
10
+ * (`Handle.registered_at`) from chain, and returns the challenge the
11
+ * prover must sign. The verifier stores the returned object (nonce is
12
+ * single-use, proofs expire — see the spec).
13
+ * - The prover signs the raw UTF-8 bytes of `challenge` with the wallet key
14
+ * that controls the handle (no wallet prefix — see the spec's envelope
15
+ * section) and returns `(signer, signature)`.
16
+ * - **Verifier** calls {@link verifyControlProof} with the ISSUED challenge
17
+ * object (never a challenge string received from the prover) and the
18
+ * proof. It re-reads the handle, requires the epoch to still match,
19
+ * resolves who currently holds authority (`require_current_authority`
20
+ * semantics: untokenized → `Handle.owner`; tokenized → the NFT holder,
21
+ * NFT in that holder's associated token account), and verifies the
22
+ * signature RFC-8032-strictly.
23
+ *
24
+ * Challenge format (built by `handle_normalize::control_challenge` in Rust
25
+ * and {@link buildControlChallenge} here — byte-identical by test):
26
+ *
27
+ * x1-handles:ctl:v1:<handle>:<registered_at>:<nonce>
28
+ *
29
+ * The `ctl` purpose tag makes this space disjoint from the record
30
+ * verification challenges (`x1-handles:v1:...`): a signature obtained for a
31
+ * record verification can never validate as a control proof, and vice versa.
32
+ */
33
+ import { ResolveError } from "./types.js";
34
+ import { normalizeHandle } from "./parse.js";
35
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
36
+ import { DEFAULT_HANDLE_PROGRAM, makeAccountReader, nftHolder, parseHandleAccount, } from "./accounts.js";
37
+ /** `<protocol>:<purpose>:<version>:` — everything a control challenge starts
38
+ * with, and nothing a record-verification challenge ever starts with (their
39
+ * second segment is the literal `v1`, and neither a canonical handle nor a
40
+ * nonce may contain `:`). */
41
+ export const CONTROL_CHALLENGE_PREFIX = "x1-handles:ctl:v1:";
42
+ /** Nonce charset shared with the Rust crate: ASCII alphanumeric, 1..=64
43
+ * chars — anything else could smuggle a `:` separator (or be refused by
44
+ * the Rust side, which must stay byte-identical). */
45
+ const NONCE_RE = /^[0-9A-Za-z]{1,64}$/;
46
+ function isCanonicalHandle(handle) {
47
+ try {
48
+ return normalizeHandle(handle) === handle;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Build the exact challenge string — byte-identical to the Rust
56
+ * `handle_normalize::control_challenge`. Throws {@link ResolveError}
57
+ * (`invalid-handle`) for a non-canonical handle and `RangeError` for a
58
+ * nonce outside the shared charset, rather than silently producing a string
59
+ * the Rust side would refuse.
60
+ *
61
+ * Verifiers normally never call this directly — {@link
62
+ * createControlChallenge} does, after reading `registeredAt` from chain, so
63
+ * a challenge cannot be built against a guessed or stale epoch.
64
+ */
65
+ export function buildControlChallenge(handle, registeredAt, nonce) {
66
+ if (!isCanonicalHandle(handle)) {
67
+ throw new ResolveError("invalid-handle", `"${handle}" is not a canonical handle`, handle);
68
+ }
69
+ if (!NONCE_RE.test(nonce)) {
70
+ throw new RangeError("nonce must be 1-64 ASCII alphanumeric characters");
71
+ }
72
+ return `${CONTROL_CHALLENGE_PREFIX}${handle}:${registeredAt}:${nonce}`;
73
+ }
74
+ /**
75
+ * Parse a control challenge string, or null for anything that is not one —
76
+ * including every record-verification challenge (`x1-handles:v1:...`), whose
77
+ * signatures must never be accepted as control proofs.
78
+ */
79
+ export function parseControlChallenge(challenge) {
80
+ const parts = challenge.split(":");
81
+ if (parts.length !== 6)
82
+ return null;
83
+ const [protocol, purpose, version, handle, epoch, nonce] = parts;
84
+ if (protocol !== "x1-handles" || purpose !== "ctl" || version !== "v1")
85
+ return null;
86
+ if (!isCanonicalHandle(handle))
87
+ return null;
88
+ if (!/^-?[0-9]+$/.test(epoch))
89
+ return null;
90
+ if (!NONCE_RE.test(nonce))
91
+ return null;
92
+ return { handle, registeredAt: BigInt(epoch), nonce };
93
+ }
94
+ /**
95
+ * Generate a verifier-issued nonce: `bytes` bytes (>= 16, the spec's entropy
96
+ * floor) from the platform CSPRNG, hex-encoded to stay inside the shared
97
+ * nonce charset. The nonce is SINGLE-USE — store it with the issued
98
+ * challenge and delete it the moment a proof against it is checked, pass or
99
+ * fail. A prover-chosen nonce is not a nonce; see docs/control-proof.md.
100
+ */
101
+ export function generateControlNonce(bytes = 16) {
102
+ if (!Number.isInteger(bytes) || bytes < 16 || bytes > 32) {
103
+ throw new RangeError("nonce entropy must be 16-32 bytes");
104
+ }
105
+ const cryptoObj = globalThis.crypto;
106
+ if (!cryptoObj?.getRandomValues) {
107
+ throw new Error("No CSPRNG available (globalThis.crypto.getRandomValues)");
108
+ }
109
+ const raw = cryptoObj.getRandomValues(new Uint8Array(bytes));
110
+ return Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join("");
111
+ }
112
+ /** Shared handle fetch: derive the PDA, require registry ownership, parse. */
113
+ async function fetchHandle(cfg, reader, canonical) {
114
+ const decodedProgram = decodeBase58_32(cfg.handleProgramId ?? DEFAULT_HANDLE_PROGRAM);
115
+ if (!decodedProgram) {
116
+ throw new Error("handleProgramId is not a valid base58 address");
117
+ }
118
+ const account = cfg.wasm.deriveHandleAccount(canonical, decodedProgram);
119
+ if (!account) {
120
+ throw new ResolveError("invalid-handle", `"${canonical}" is not a valid handle`, canonical);
121
+ }
122
+ const h = await reader.accountInfo(encodeBase58(account));
123
+ // An account at the PDA that the registry does not own is not a handle.
124
+ if (!h || h.owner !== encodeBase58(decodedProgram))
125
+ return null;
126
+ const handle = parseHandleAccount(h.data);
127
+ if (!handle) {
128
+ throw new ResolveError("rpc-error", `@${canonical} returned a malformed account`, canonical);
129
+ }
130
+ return handle;
131
+ }
132
+ /**
133
+ * Verifier side, step 1: issue a challenge for a handle.
134
+ *
135
+ * Reads the handle's CURRENT `registered_at` from chain and binds the
136
+ * challenge to it — there is no way to obtain a challenge through this API
137
+ * without the live epoch in hand, which is what makes a past owner's proof
138
+ * worthless the moment the name changes hands.
139
+ *
140
+ * Store the returned object server-side (it is the only thing
141
+ * {@link verifyControlProof} accepts), enforce single use of `nonce`, and
142
+ * enforce a max proof age against `issuedAt` (the spec recommends 5 minutes).
143
+ *
144
+ * @throws {ResolveError} `invalid-handle` for bad input, `not-found` for an
145
+ * unregistered handle, `rpc-error` for transport problems.
146
+ */
147
+ export async function createControlChallenge(cfg, handleInput) {
148
+ const canonical = normalizeHandle(handleInput); // throws invalid-handle
149
+ const reader = makeAccountReader(cfg.rpcUrl, cfg.fetchImpl);
150
+ const handle = await fetchHandle(cfg, reader, canonical);
151
+ if (!handle) {
152
+ throw new ResolveError("not-found", `@${canonical} is not registered`, handleInput);
153
+ }
154
+ const nonce = generateControlNonce();
155
+ return {
156
+ challenge: buildControlChallenge(canonical, handle.registeredAt, nonce),
157
+ handle: canonical,
158
+ registeredAt: handle.registeredAt,
159
+ nonce,
160
+ issuedAt: Date.now(),
161
+ };
162
+ }
163
+ /**
164
+ * Verifier side, step 2: check a proof against the challenge YOU issued.
165
+ *
166
+ * Takes the stored {@link ControlChallenge} — never a challenge string
167
+ * received from the prover — and re-derives the signed bytes from its
168
+ * fields, so neither side can substitute a different string. Then:
169
+ *
170
+ * 1. re-reads the handle and requires `registered_at` to still equal the
171
+ * epoch the challenge was issued under (`epoch-changed` otherwise —
172
+ * the name changed hands after issuance);
173
+ * 2. resolves the CURRENT authority with `require_current_authority`
174
+ * semantics — untokenized → `Handle.owner`; tokenized → the NFT holder,
175
+ * and only with the NFT in that holder's associated token account
176
+ * (`Handle.owner` is stale once tokenized and is never consulted);
177
+ * 3. requires `proof.signer` to be that authority;
178
+ * 4. verifies the 64-byte ed25519 signature over the raw UTF-8 challenge
179
+ * bytes, RFC-8032-strictly.
180
+ *
181
+ * Verification outcomes come back as `{ valid: false, reason }` — only
182
+ * transport failures throw (`ResolveError` `rpc-error`). Nonce single-use
183
+ * and max proof age are the CALLER's responsibility: delete the stored
184
+ * challenge the moment this returns, whatever the outcome, and refuse
185
+ * challenges older than your proof-age limit before calling.
186
+ */
187
+ export async function verifyControlProof(cfg, issued, proof) {
188
+ // Re-derive the signed bytes from the issued fields; also revalidates them.
189
+ const challenge = buildControlChallenge(issued.handle, issued.registeredAt, issued.nonce);
190
+ const reader = makeAccountReader(cfg.rpcUrl, cfg.fetchImpl);
191
+ const handle = await fetchHandle(cfg, reader, issued.handle);
192
+ if (!handle) {
193
+ return {
194
+ valid: false,
195
+ reason: "handle-not-registered",
196
+ message: `@${issued.handle} is not registered`,
197
+ };
198
+ }
199
+ if (handle.registeredAt !== issued.registeredAt) {
200
+ return {
201
+ valid: false,
202
+ reason: "epoch-changed",
203
+ message: `@${issued.handle} changed ownership epoch after the challenge was issued (${issued.registeredAt} -> ${handle.registeredAt}); issue a fresh challenge`,
204
+ };
205
+ }
206
+ // Current authority, require_current_authority semantics.
207
+ let authority;
208
+ const tokenized = handle.nftMint !== null;
209
+ if (handle.nftMint === null) {
210
+ authority = encodeBase58(handle.owner);
211
+ }
212
+ else {
213
+ let holder;
214
+ try {
215
+ holder = await nftHolder(reader, cfg.wasm, issued.handle, handle.nftMint, issued.handle);
216
+ }
217
+ catch (e) {
218
+ if (e instanceof ResolveError && e.reason === "nft-burned") {
219
+ return {
220
+ valid: false,
221
+ reason: "nft-burned",
222
+ message: `@${issued.handle}'s NFT has been burned — nobody controls the name`,
223
+ };
224
+ }
225
+ throw e; // rpc-error: infrastructure, not a verdict on the proof
226
+ }
227
+ if (holder.verification !== "verified") {
228
+ return {
229
+ valid: false,
230
+ reason: "nft-not-in-authority-ata",
231
+ message: `@${issued.handle}'s NFT is not in its holder's associated token account; the registry does not recognise that wallet as the name's authority`,
232
+ };
233
+ }
234
+ authority = holder.address;
235
+ }
236
+ if (proof.signer !== authority) {
237
+ return {
238
+ valid: false,
239
+ reason: "signer-not-authority",
240
+ message: `${proof.signer} does not currently control @${issued.handle}`,
241
+ };
242
+ }
243
+ const publicKey = decodeBase58_32(proof.signer);
244
+ if (!publicKey) {
245
+ return {
246
+ valid: false,
247
+ reason: "signer-not-authority",
248
+ message: `"${proof.signer}" is not a valid base58 address`,
249
+ };
250
+ }
251
+ const verify = cfg.verifyEd25519 ?? verifyEd25519Strict;
252
+ const ok = await verify(publicKey, new TextEncoder().encode(challenge), proof.signature);
253
+ if (!ok) {
254
+ return {
255
+ valid: false,
256
+ reason: "bad-signature",
257
+ message: "signature does not verify over the challenge bytes",
258
+ };
259
+ }
260
+ return {
261
+ valid: true,
262
+ handle: issued.handle,
263
+ signer: proof.signer,
264
+ registeredAt: issued.registeredAt,
265
+ tokenized,
266
+ };
267
+ }
268
+ /**
269
+ * RFC-8032-strict ed25519 verification via WebCrypto (`Ed25519`), available
270
+ * in Node 20+ and current browsers. Both are backed by implementations that
271
+ * reject a non-canonical `s >= L` (the malleability the spec's strictness
272
+ * requirement exists for) — the test suite pins this with known-answer
273
+ * vectors, including a rejected `s + L` forgery of a valid signature.
274
+ *
275
+ * Returns false for anything malformed (wrong lengths, off-curve key,
276
+ * invalid signature); throws only when the runtime has no Ed25519 WebCrypto
277
+ * at all — pass `verifyEd25519` in {@link ControlConfig} there, using a
278
+ * strict library named in docs/control-proof.md.
279
+ */
280
+ export async function verifyEd25519Strict(publicKey, message, signature) {
281
+ if (publicKey.length !== 32 || signature.length !== 64)
282
+ return false;
283
+ const subtle = globalThis.crypto?.subtle;
284
+ if (!subtle) {
285
+ throw new Error("WebCrypto is unavailable in this runtime; pass an RFC-8032-strict verifyEd25519 in ControlConfig");
286
+ }
287
+ let key;
288
+ try {
289
+ key = await subtle.importKey("raw", publicKey, { name: "Ed25519" }, false, [
290
+ "verify",
291
+ ]);
292
+ }
293
+ catch {
294
+ // Either the runtime has no Ed25519, or it refused THESE key bytes. Probe
295
+ // with a known-good key (RFC 8032 TEST 1) to tell the two apart: a
296
+ // missing algorithm must be loud, a bad key is just an invalid proof.
297
+ const probe = new Uint8Array([
298
+ 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07,
299
+ 0x3a, 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07,
300
+ 0x51, 0x1a,
301
+ ]);
302
+ try {
303
+ await subtle.importKey("raw", probe, { name: "Ed25519" }, false, ["verify"]);
304
+ }
305
+ catch {
306
+ throw new Error("This runtime's WebCrypto lacks Ed25519; pass an RFC-8032-strict verifyEd25519 in ControlConfig");
307
+ }
308
+ return false;
309
+ }
310
+ try {
311
+ return await subtle.verify("Ed25519", key, signature, message);
312
+ }
313
+ catch {
314
+ return false;
315
+ }
316
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Delegated record-editing authority (`RecordDelegate`, #7374): instruction
3
+ * builders and account decoding for `set_record_delegate` /
4
+ * `revoke_record_delegate`, plus the optional-delegation account slot the
5
+ * record-editing instructions grew.
6
+ *
7
+ * Hand-rolled like the rest of this package — no Anchor client, no
8
+ * `@solana/web3.js` import (it stays an optional peer). Builders return a
9
+ * transport-neutral {@link BuiltInstruction}; adapt to web3.js with:
10
+ *
11
+ * ```ts
12
+ * new TransactionInstruction({
13
+ * programId: new PublicKey(ix.programId),
14
+ * keys: ix.keys.map((k) => ({ pubkey: new PublicKey(k.pubkey), isSigner: k.isSigner, isWritable: k.isWritable })),
15
+ * data: Buffer.from(ix.data),
16
+ * });
17
+ * ```
18
+ *
19
+ * # Deriving the PDA
20
+ *
21
+ * The delegation account lives at `["delegate", handlePda]` under the
22
+ * registry program (seed constant: {@link RECORD_DELEGATE_SEED}). This module
23
+ * deliberately does NOT derive PDAs — `find_program_address` needs an
24
+ * ed25519 on-curve check, and this package's one rule about that is to never
25
+ * hand-roll it in TypeScript (see `wasm.ts`). Derive it with your runtime's
26
+ * canonical implementation, e.g. web3.js:
27
+ *
28
+ * ```ts
29
+ * PublicKey.findProgramAddressSync(
30
+ * [Buffer.from(RECORD_DELEGATE_SEED), handlePda.toBytes()],
31
+ * programId,
32
+ * );
33
+ * ```
34
+ *
35
+ * # The wire rules the on-chain program enforces (mirror of lib.rs)
36
+ *
37
+ * - `set_record_delegate` / `revoke_record_delegate` are gated by the STRICT
38
+ * authority check: for a TOKENIZED handle append the holder's ATA for the
39
+ * handle's NFT mint as the first extra (remaining) account.
40
+ * - The record-editing instructions (`create_record`, `update_record`,
41
+ * `verify_record_*`, `close_record`) end in a named OPTIONAL
42
+ * `record_delegate` account:
43
+ * - a DELEGATE caller passes the delegation PDA there (and never any ATA);
44
+ * - an untokenized OWNER simply omits the slot (pre-#7374 account list);
45
+ * - a tokenized OWNER must pass the "explicitly None" placeholder — the
46
+ * program id itself — in that slot, ahead of the ATA remaining account.
47
+ * {@link recordDelegateNonePlaceholder} names that convention.
48
+ * - A delegation only authorizes while `delegatedAt >=
49
+ * handle.registered_at`; any transfer/sale/recovery/re-registration bumps
50
+ * the epoch and strands it (`StaleRecordDelegate`, code 6043). A bearer-NFT
51
+ * marketplace trade does NOT bump the epoch — an NFT buyer should check
52
+ * for, and revoke, an existing delegation.
53
+ */
54
+ /** Seed prefix of the delegation PDA: `["delegate", handlePda]`. */
55
+ export declare const RECORD_DELEGATE_SEED = "delegate";
56
+ /** Anchor account discriminator: `sha256("account:RecordDelegate")[0..8]`. */
57
+ export declare const RECORD_DELEGATE_DISCRIMINATOR: Uint8Array;
58
+ /** Anchor instruction discriminator: `sha256("global:set_record_delegate")[0..8]`. */
59
+ export declare const SET_RECORD_DELEGATE_DISCRIMINATOR: Uint8Array;
60
+ /** Anchor instruction discriminator: `sha256("global:revoke_record_delegate")[0..8]`. */
61
+ export declare const REVOKE_RECORD_DELEGATE_DISCRIMINATOR: Uint8Array;
62
+ /** `RecordDelegate` account size: disc(8) handle(32) delegate(32) i64(8) bump(1). */
63
+ export declare const RECORD_DELEGATE_LEN = 81;
64
+ /** A 32-byte address, or its base58 spelling. */
65
+ export type AddressLike = Uint8Array | string;
66
+ /** One account in a {@link BuiltInstruction}, base58 like the rest of this
67
+ * package's public API. */
68
+ export interface InstructionKey {
69
+ readonly pubkey: string;
70
+ readonly isSigner: boolean;
71
+ readonly isWritable: boolean;
72
+ }
73
+ /** A transport-neutral instruction — see the module docs for the two-line
74
+ * web3.js adapter. */
75
+ export interface BuiltInstruction {
76
+ readonly programId: string;
77
+ readonly keys: readonly InstructionKey[];
78
+ readonly data: Uint8Array;
79
+ }
80
+ /** Decoded `RecordDelegate` account. */
81
+ export interface RecordDelegate {
82
+ /** The `Handle` PDA this delegation is for (base58). */
83
+ readonly handle: string;
84
+ /** The wallet allowed to edit the handle's records (base58). */
85
+ readonly delegate: string;
86
+ /** Unix seconds the delegation was (last) granted. Trust it only while
87
+ * `delegatedAt >= handle.registered_at` — see the module docs. */
88
+ readonly delegatedAt: bigint;
89
+ readonly bump: number;
90
+ }
91
+ /**
92
+ * Decode a `RecordDelegate` account's raw data (exactly what an RPC
93
+ * `getAccountInfo` returns for the `["delegate", handlePda]` PDA), or null if
94
+ * the bytes are not a `RecordDelegate`.
95
+ */
96
+ export declare function decodeRecordDelegate(data: Uint8Array): RecordDelegate | null;
97
+ export interface SetRecordDelegateParams {
98
+ /** The registry program id. */
99
+ readonly programId: AddressLike;
100
+ /** Pays the delegation account's rent on first grant. Signer. */
101
+ readonly payer: AddressLike;
102
+ /** The handle's CURRENT authority (owner, or NFT holder). Signer. */
103
+ readonly owner: AddressLike;
104
+ /** The `["handle", name]` PDA. */
105
+ readonly handle: AddressLike;
106
+ /** The `["delegate", handle]` PDA — derive per the module docs. */
107
+ readonly recordDelegate: AddressLike;
108
+ /** The wallet being granted record-editing rights. */
109
+ readonly delegate: AddressLike;
110
+ /** TOKENIZED handles only: the owner's associated token account for the
111
+ * handle's NFT mint, appended as the strict check's remaining-account
112
+ * proof. Omit for an untokenized handle. */
113
+ readonly holderTokenAccount?: AddressLike;
114
+ }
115
+ /**
116
+ * Build `set_record_delegate` — grant (or overwrite, re-stamping
117
+ * `delegatedAt`) the handle's single record-editing delegation.
118
+ */
119
+ export declare function buildSetRecordDelegateIx(p: SetRecordDelegateParams): BuiltInstruction;
120
+ export interface RevokeRecordDelegateParams {
121
+ /** The registry program id. */
122
+ readonly programId: AddressLike;
123
+ /** The handle's CURRENT authority. Signer. */
124
+ readonly owner: AddressLike;
125
+ /** The `["handle", name]` PDA. */
126
+ readonly handle: AddressLike;
127
+ /** The `["delegate", handle]` PDA being closed. */
128
+ readonly recordDelegate: AddressLike;
129
+ /** Receives the closed account's rent — any account the caller chooses. */
130
+ readonly recipient: AddressLike;
131
+ /** TOKENIZED handles only — same rule as {@link SetRecordDelegateParams}. */
132
+ readonly holderTokenAccount?: AddressLike;
133
+ }
134
+ /**
135
+ * Build `revoke_record_delegate` — close the delegation, rent to
136
+ * `recipient`. Also how a NEW owner/NFT-holder sweeps a previous owner's
137
+ * stale delegation.
138
+ */
139
+ export declare function buildRevokeRecordDelegateIx(p: RevokeRecordDelegateParams): BuiltInstruction;
140
+ /**
141
+ * The named-optional-account "explicitly None" entry for a record-editing
142
+ * instruction's trailing `record_delegate` slot: Anchor's convention is the
143
+ * program's own id, read-only, non-signer. A TOKENIZED owner must place this
144
+ * ahead of their ATA remaining-account; an untokenized owner simply omits
145
+ * the slot instead.
146
+ */
147
+ export declare function recordDelegateNonePlaceholder(programId: AddressLike): InstructionKey;
148
+ /**
149
+ * The populated `record_delegate` slot a DELEGATE appends to a
150
+ * record-editing instruction's account list (read-only — the record
151
+ * instructions never mutate the delegation).
152
+ */
153
+ export declare function recordDelegateSomeSlot(recordDelegate: AddressLike): InstructionKey;