@x1id/resolve 0.3.0 → 0.6.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.
@@ -50,6 +50,15 @@
50
50
  * documented blind spot is shared with `Handle.owner`/`Primary`/
51
51
  * `RecordDelegate`: a bearer-NFT marketplace trade bumps no epoch, so the
52
52
  * attestation keeps reading live until the attestor re-reviews or revokes.)
53
+ *
54
+ * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
55
+ *
56
+ * `clear_records` lets an owner bulk-invalidate every record RIGHT NOW,
57
+ * without a transfer — its own doc comment (lib.rs) names attestations
58
+ * explicitly alongside records/text-records as sharing this epoch rule. The
59
+ * same functions therefore also demand the handle's `recordsClearedAt` (0 if
60
+ * never cleared, from `ParsedHandle`), and an attestation is `stale` when
61
+ * EITHER `attested_at < registeredAt` OR `attested_at < recordsClearedAt`.
53
62
  */
54
63
  import { encodeBase58 } from "./base58.js";
55
64
  import { decodeBase58_32 } from "./base58.js";
@@ -123,13 +132,15 @@ export function attestationKindName(kind) {
123
132
  * Decode one Attestation account.
124
133
  *
125
134
  * `registeredAt` is the owning Handle's `registered_at`, decoded by the
126
- * caller from the Handle account — it decides `stale`. There is
127
- * intentionally no overload without it (see the module docs).
135
+ * caller from the Handle account — it decides `stale`. `recordsClearedAt` is
136
+ * that same Handle's `recordsClearedAt` (0 if never cleared, #8139), a
137
+ * SECOND independent staleness anchor. There is intentionally no overload
138
+ * without either (see the module docs).
128
139
  *
129
140
  * Returns null for anything that is not an Attestation: wrong length or
130
141
  * wrong discriminator.
131
142
  */
132
- export function decodeAttestation(raw, account, registeredAt) {
143
+ export function decodeAttestation(raw, account, registeredAt, recordsClearedAt) {
133
144
  if (raw.length !== ATTESTATION_LEN)
134
145
  return null;
135
146
  for (let i = 0; i < 8; i++) {
@@ -155,7 +166,7 @@ export function decodeAttestation(raw, account, registeredAt) {
155
166
  evidenceHash,
156
167
  attestedAt,
157
168
  attestor,
158
- stale: attestedAt < registeredAt,
169
+ stale: attestedAt < registeredAt || attestedAt < recordsClearedAt,
159
170
  };
160
171
  }
161
172
  /** The attestations that vouch for the CURRENT owner — `stale` ones
@@ -181,12 +192,13 @@ export function isHandleVerified(attestations) {
181
192
  *
182
193
  * `registeredAt` is `Handle.registered_at` as decoded from the Handle
183
194
  * account the caller already has — the staleness rule needs it, and there
184
- * is no variant of this function without it. Every attestation is returned,
185
- * stale ones flagged, so an owner surface can show what a previous
186
- * registration left behind; anything that renders a verified badge takes
187
- * {@link isHandleVerified} / {@link liveAttestations}.
195
+ * is no variant of this function without it. `recordsClearedAt` is that same
196
+ * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it through.
197
+ * Every attestation is returned, stale ones flagged, so an owner surface can
198
+ * show what a previous registration left behind; anything that renders a
199
+ * verified badge takes {@link isHandleVerified} / {@link liveAttestations}.
188
200
  */
189
- export async function fetchAttestations(rpc, programId, handleAccount, registeredAt) {
201
+ export async function fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt) {
190
202
  const res = (await rpc("getProgramAccounts", [
191
203
  programId,
192
204
  {
@@ -205,7 +217,7 @@ export async function fetchAttestations(rpc, programId, handleAccount, registere
205
217
  const raw = new Uint8Array(bin.length);
206
218
  for (let i = 0; i < bin.length; i++)
207
219
  raw[i] = bin.charCodeAt(i);
208
- const decoded = decodeAttestation(raw, a.pubkey, registeredAt);
220
+ const decoded = decodeAttestation(raw, a.pubkey, registeredAt, recordsClearedAt);
209
221
  // Defence in depth: the memcmp filter should guarantee the handle
210
222
  // match, but a wrong offset would silently attribute someone else's
211
223
  // attestation to this handle. A malformed account is skipped, not fatal.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `clear_records` (#8139): bulk-invalidate every record/text-record/
3
+ * attestation on a handle RIGHT NOW, without a transfer, by bumping
4
+ * `Handle.records_cleared_at` to the current time. The accounts
5
+ * (`accounts.ts` `ParsedHandle.recordsClearedAt`) and read-side staleness
6
+ * rule (every decoder in `records.ts` / `textRecords.ts` / `attestation.ts`:
7
+ * `updated_at < recordsClearedAt` is stale, alongside the `registeredAt`
8
+ * epoch rule) have existed since #8139 shipped; this module is the one
9
+ * missing piece — the instruction builder to actually trigger a clear
10
+ * (WP #8212).
11
+ *
12
+ * Hand-rolled like the rest of this package — no Anchor client, no
13
+ * `@solana/web3.js` import (it stays an optional peer). The builder returns
14
+ * the same transport-neutral {@link BuiltInstruction} `delegate.ts` uses.
15
+ *
16
+ * # Authority: STRICT current-authority, no delegate
17
+ *
18
+ * `clear_records` is gated by `require_current_authority` in lib.rs — the
19
+ * SAME strict check `lock_handle`/`initiate_unlock`/`complete_unlock` use
20
+ * (see `lock.ts`'s module docs), not the record-editing delegate model
21
+ * (`require_record_edit_authority`, `recordWrite.ts`'s
22
+ * `RecordEditAuthorityParams`). A handle's active `RecordDelegate` can add,
23
+ * edit, and verify individual records, but CANNOT bulk-invalidate all of
24
+ * them — that stays an owner/NFT-holder-only action, on purpose: nuking
25
+ * every record is a much bigger blast radius than editing one. There is
26
+ * deliberately no `recordDelegate` param here.
27
+ *
28
+ * A TOKENIZED handle's current holder appends their ATA for the handle's
29
+ * NFT mint as the sole remaining account (`holderTokenAccount`), exactly
30
+ * like `lock.ts`'s `LockParams`/`UnlockParams`; omit it for an untokenized
31
+ * handle.
32
+ */
33
+ import type { AddressLike, BuiltInstruction } from "./delegate.js";
34
+ /** Anchor instruction discriminator: `sha256("global:clear_records")[0..8]`.
35
+ * Pinned (this SDK is zero-dependency and cannot assume WebCrypto SHA-256
36
+ * everywhere it runs); asserted against a re-derivation in the test suite
37
+ * so a typo can never silently pass. */
38
+ export declare const CLEAR_RECORDS_DISCRIMINATOR: Uint8Array;
39
+ export interface ClearRecordsParams {
40
+ /** The registry program id. */
41
+ readonly programId: AddressLike;
42
+ /** The handle's CURRENT authority (untokenized owner, or NFT holder).
43
+ * Signer, writable — also pays the one-time account grow through
44
+ * `records_cleared_at`'s extension region on a handle that has never
45
+ * been grown that far. */
46
+ readonly owner: AddressLike;
47
+ /** The `["handle", name]` PDA. */
48
+ readonly handle: AddressLike;
49
+ /** TOKENIZED handles only: the holder's associated token account for the
50
+ * handle's NFT mint, appended as the strict check's remaining-account
51
+ * proof. Omit for an untokenized handle. */
52
+ readonly holderTokenAccount?: AddressLike;
53
+ }
54
+ /**
55
+ * Build `clear_records` — bump `Handle.records_cleared_at` to now, so every
56
+ * record/text-record/attestation with an `updated_at` before this instant
57
+ * reads as stale everywhere in this SDK, without touching any of those
58
+ * accounts individually. Idempotent (re-clearing just re-stamps the clock).
59
+ */
60
+ export declare function buildClearRecordsIx(p: ClearRecordsParams): BuiltInstruction;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `clear_records` (#8139): bulk-invalidate every record/text-record/
3
+ * attestation on a handle RIGHT NOW, without a transfer, by bumping
4
+ * `Handle.records_cleared_at` to the current time. The accounts
5
+ * (`accounts.ts` `ParsedHandle.recordsClearedAt`) and read-side staleness
6
+ * rule (every decoder in `records.ts` / `textRecords.ts` / `attestation.ts`:
7
+ * `updated_at < recordsClearedAt` is stale, alongside the `registeredAt`
8
+ * epoch rule) have existed since #8139 shipped; this module is the one
9
+ * missing piece — the instruction builder to actually trigger a clear
10
+ * (WP #8212).
11
+ *
12
+ * Hand-rolled like the rest of this package — no Anchor client, no
13
+ * `@solana/web3.js` import (it stays an optional peer). The builder returns
14
+ * the same transport-neutral {@link BuiltInstruction} `delegate.ts` uses.
15
+ *
16
+ * # Authority: STRICT current-authority, no delegate
17
+ *
18
+ * `clear_records` is gated by `require_current_authority` in lib.rs — the
19
+ * SAME strict check `lock_handle`/`initiate_unlock`/`complete_unlock` use
20
+ * (see `lock.ts`'s module docs), not the record-editing delegate model
21
+ * (`require_record_edit_authority`, `recordWrite.ts`'s
22
+ * `RecordEditAuthorityParams`). A handle's active `RecordDelegate` can add,
23
+ * edit, and verify individual records, but CANNOT bulk-invalidate all of
24
+ * them — that stays an owner/NFT-holder-only action, on purpose: nuking
25
+ * every record is a much bigger blast radius than editing one. There is
26
+ * deliberately no `recordDelegate` param here.
27
+ *
28
+ * A TOKENIZED handle's current holder appends their ATA for the handle's
29
+ * NFT mint as the sole remaining account (`holderTokenAccount`), exactly
30
+ * like `lock.ts`'s `LockParams`/`UnlockParams`; omit it for an untokenized
31
+ * handle.
32
+ */
33
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
34
+ /** Anchor instruction discriminator: `sha256("global:clear_records")[0..8]`.
35
+ * Pinned (this SDK is zero-dependency and cannot assume WebCrypto SHA-256
36
+ * everywhere it runs); asserted against a re-derivation in the test suite
37
+ * so a typo can never silently pass. */
38
+ export const CLEAR_RECORDS_DISCRIMINATOR = Uint8Array.from([
39
+ 150, 232, 238, 45, 8, 105, 50, 153,
40
+ ]);
41
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
42
+ function toBase58(v, what) {
43
+ if (typeof v === "string") {
44
+ const b = decodeBase58_32(v);
45
+ if (!b)
46
+ throw new Error(`${what} is not a valid base58 address`);
47
+ return encodeBase58(b);
48
+ }
49
+ if (v.length !== 32)
50
+ throw new Error(`${what} must be exactly 32 bytes`);
51
+ return encodeBase58(v);
52
+ }
53
+ /**
54
+ * Build `clear_records` — bump `Handle.records_cleared_at` to now, so every
55
+ * record/text-record/attestation with an `updated_at` before this instant
56
+ * reads as stale everywhere in this SDK, without touching any of those
57
+ * accounts individually. Idempotent (re-clearing just re-stamps the clock).
58
+ */
59
+ export function buildClearRecordsIx(p) {
60
+ const keys = [
61
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: true },
62
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
63
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
64
+ ];
65
+ if (p.holderTokenAccount !== undefined) {
66
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
67
+ }
68
+ return { programId: toBase58(p.programId, "programId"), keys, data: CLEAR_RECORDS_DISCRIMINATOR.slice() };
69
+ }
package/dist/index.d.ts CHANGED
@@ -38,6 +38,12 @@ export * from "./textRecords.js";
38
38
  export * from "./lock.js";
39
39
  export * from "./integrator.js";
40
40
  export * from "./voucher.js";
41
+ export * from "./agent.js";
42
+ export * from "./register.js";
43
+ export * from "./accounts.js";
44
+ export * from "./x402.js";
45
+ export * from "./recordWrite.js";
46
+ export * from "./clearRecords.js";
41
47
  import { type Chain, type Resolved } from "./types.js";
42
48
  import { WasmResolver } from "./wasm.js";
43
49
  import { type HandleRecord } from "./records.js";
package/dist/index.js CHANGED
@@ -38,6 +38,19 @@ export * from "./textRecords.js";
38
38
  export * from "./lock.js";
39
39
  export * from "./integrator.js";
40
40
  export * from "./voucher.js";
41
+ export * from "./agent.js";
42
+ export * from "./register.js";
43
+ // Was previously imported here for internal use only, never re-exported —
44
+ // promoted to public API 2026-09-22 because a second real package (mcp/)
45
+ // now needs `parseHandleAccount`/`RpcFn` directly rather than duplicating
46
+ // this decoder (the Handle account's `Option<Pubkey>` fields are
47
+ // variable-width Borsh — a hand-rolled offset guess here would be exactly
48
+ // the kind of drift docs/record-trust.md's "one implementation" rule
49
+ // exists to prevent).
50
+ export * from "./accounts.js";
51
+ export * from "./x402.js";
52
+ export * from "./recordWrite.js";
53
+ export * from "./clearRecords.js";
41
54
  import { ResolveError, CHAIN_COIN_TYPE } from "./types.js";
42
55
  import { parseName } from "./parse.js";
43
56
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
@@ -136,7 +149,7 @@ export function createResolver(config) {
136
149
  // behind by a previous registration of the same name is never resolved,
137
150
  // and a stale `verified: true` is never surfaced.
138
151
  if (chain !== "X1" && chain !== "SOL") {
139
- const live = liveRecords(await fetchRecords(rpc, programBase58, pda, handle.registeredAt));
152
+ const live = liveRecords(await fetchRecords(rpc, programBase58, pda, handle.registeredAt, handle.recordsClearedAt));
140
153
  const record = live.find((r) => r.coinType === CHAIN_COIN_TYPE[chain]);
141
154
  if (!record) {
142
155
  throw new ResolveError("no-record-for-chain", `@${canonical} has no ${chain} record`, input);
@@ -166,7 +179,7 @@ export function createResolver(config) {
166
179
  throw new ResolveError("unrecognized", `per-chain records exist only for @handles, not .${parsed.namespace} domains`, input);
167
180
  }
168
181
  const { pda, handle } = await fetchHandle(parsed.canonical, input);
169
- return fetchRecords(rpc, programBase58, pda, handle.registeredAt);
182
+ return fetchRecords(rpc, programBase58, pda, handle.registeredAt, handle.recordsClearedAt);
170
183
  }
171
184
  async function resolve(input, opts) {
172
185
  const chain = opts?.chain ?? "X1";
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Write builders for the address `Record` family: `create_record` and the
3
+ * three `verify_record_*` proof-of-control instructions (ETH/secp256k1,
4
+ * SVM/ed25519, BTC/BIP-137). `records.ts` was read-only until now — every
5
+ * other record family (text records, attestations, vouchers, subnames...)
6
+ * already had write builders; this was the one gap (WP #8142).
7
+ *
8
+ * Hand-rolled like the rest of this package — no Anchor client, no
9
+ * `@solana/web3.js` import. PDAs are NOT derived here (see delegate.ts's
10
+ * module docs for why) — derive `["record", handlePda, coinTypeLE(4)]`
11
+ * with your runtime's canonical `findProgramAddress`.
12
+ *
13
+ * # What this SDK does and does not do for you
14
+ *
15
+ * This does NOT sign anything, derive an address from a signature, or
16
+ * verify a signature client-side — that's the on-chain program's job
17
+ * (`verify_record_eth`/`svm`/`btc` recover the signer's address from the
18
+ * signature itself and compare it to the record's claimed value; a client
19
+ * proving nothing false cannot fabricate a match). What this module DOES do:
20
+ *
21
+ * 1. {@link buildRecordChallenge} — build the exact bytes a wallet must
22
+ * sign, byte-identical to `handle-normalize`'s Rust `challenge()`
23
+ * (verified: `crates/handle-normalize/src/challenge.rs`). Unlike the
24
+ * off-chain control-proof flow (`control.ts`), this challenge's nonce
25
+ * does NOT need to be server-issued — the on-chain transaction landing
26
+ * IS the single-use event, so any ASCII-alphanumeric nonce (1..=64
27
+ * chars) the caller picks is fine.
28
+ * 2. {@link splitEthSignature} / {@link splitBtcSignature} — repackage what
29
+ * a wallet's `personal_sign` (ETH) or `signmessage` (BTC) actually
30
+ * returns into the exact args each verify instruction expects.
31
+ * 3. The four instruction builders themselves.
32
+ *
33
+ * # ETH: EIP-191 `personal_sign`, not EIP-712
34
+ *
35
+ * `verify_record_eth` (lib.rs) hashes the challenge with EIP-191's
36
+ * `"\x19Ethereum Signed Message:\n" + len + message` framing — the exact
37
+ * framing every wallet's `personal_sign` / `eth_sign` RPC method produces.
38
+ * There is no EIP-712 typed-data variant on-chain to build a client for.
39
+ *
40
+ * # BTC: mainnet-only, compressed P2PKH or bech32/P2WPKH only
41
+ *
42
+ * `verify_record_btc` derives the address FROM the recovered pubkey (never
43
+ * trusts a client-supplied address) and only accepts the header-byte ranges
44
+ * for compressed P2PKH (31-34) and bech32/P2WPKH (39-42) — see `btc.rs`'s
45
+ * module doc for exactly why uncompressed P2PKH and P2SH-segwit are refused
46
+ * rather than guessed. {@link splitBtcSignature} passes the header byte
47
+ * through unchanged; it is the wallet's job to produce one of the two
48
+ * supported ranges (most current wallets do).
49
+ */
50
+ import type { AddressLike, BuiltInstruction } from "./delegate.js";
51
+ export declare const CREATE_RECORD_DISCRIMINATOR: Uint8Array;
52
+ export declare const VERIFY_RECORD_ETH_DISCRIMINATOR: Uint8Array;
53
+ export declare const VERIFY_RECORD_SVM_DISCRIMINATOR: Uint8Array;
54
+ export declare const VERIFY_RECORD_BTC_DISCRIMINATOR: Uint8Array;
55
+ /** The delegate-aware trailing account every verify builder shares — same
56
+ * rule as `textRecords.ts`'s identically-named private helper (not shared
57
+ * across files by import: each write-builder module owns its own copy,
58
+ * matching this SDK's existing convention). */
59
+ interface RecordEditAuthorityParams {
60
+ readonly recordDelegate?: AddressLike;
61
+ readonly holderTokenAccount?: AddressLike;
62
+ }
63
+ /**
64
+ * `x1-handles:v1:<handle>:<coinType>:<registeredAt>:<nonce>` — byte-identical
65
+ * to `handle_normalize::challenge` (Rust). `handle` must already be
66
+ * canonical (pass it through {@link import("./parse.js").normalizeHandle}
67
+ * first if it might have a leading `@` or mixed case).
68
+ */
69
+ export declare function buildRecordChallenge(handle: string, coinType: number, registeredAt: bigint, nonce: string): string;
70
+ /** Split a 65-byte ETH `personal_sign` output (`r(32) || s(32) || v(1)`)
71
+ * into `verify_record_eth`'s `{signature: [u8;64], recoveryId: u8}`,
72
+ * normalizing `v`'s two common conventions (27/28, matching most wallets'
73
+ * raw RPC output, or already 0/1). */
74
+ export declare function splitEthSignature(sig65: Uint8Array): {
75
+ signature: Uint8Array;
76
+ recoveryId: number;
77
+ };
78
+ /** Split a 65-byte BTC BIP-137 `signmessage` output (`header(1) || r(32) ||
79
+ * s(32)`) into `verify_record_btc`'s `{header, signature: [u8;64]}`. The
80
+ * header byte is passed through unchanged — see the module docs for which
81
+ * ranges `verify_record_btc` accepts. */
82
+ export declare function splitBtcSignature(sig65: Uint8Array): {
83
+ header: number;
84
+ signature: Uint8Array;
85
+ };
86
+ export interface CreateRecordParams {
87
+ readonly programId: AddressLike;
88
+ readonly payer: AddressLike;
89
+ readonly owner: AddressLike;
90
+ readonly handle: AddressLike;
91
+ /** The `["record", handle, coinTypeLE(4)]` PDA. */
92
+ readonly record: AddressLike;
93
+ readonly coinType: number;
94
+ /** Raw address bytes (1..=64), NOT verified yet — `verified` starts
95
+ * false until a matching `verify_record_*` call succeeds. */
96
+ readonly value: Uint8Array;
97
+ }
98
+ export declare function buildCreateRecordIx(p: CreateRecordParams): BuiltInstruction;
99
+ export interface VerifyRecordEthParams extends RecordEditAuthorityParams {
100
+ readonly programId: AddressLike;
101
+ readonly owner: AddressLike;
102
+ readonly handle: AddressLike;
103
+ readonly record: AddressLike;
104
+ readonly nonce: string;
105
+ /** The 64-byte `r || s` half — use {@link splitEthSignature} on a
106
+ * wallet's raw 65-byte `personal_sign` output. */
107
+ readonly signature: Uint8Array;
108
+ readonly recoveryId: number;
109
+ }
110
+ export declare function buildVerifyRecordEthIx(p: VerifyRecordEthParams): BuiltInstruction;
111
+ export interface VerifyRecordSvmParams extends RecordEditAuthorityParams {
112
+ readonly programId: AddressLike;
113
+ readonly owner: AddressLike;
114
+ /** The address being claimed — MUST co-sign this transaction; its
115
+ * signature IS the proof, no challenge/nonce needed (X1 and Solana
116
+ * share ed25519). */
117
+ readonly claimed: AddressLike;
118
+ readonly handle: AddressLike;
119
+ readonly record: AddressLike;
120
+ }
121
+ export declare function buildVerifyRecordSvmIx(p: VerifyRecordSvmParams): BuiltInstruction;
122
+ export interface VerifyRecordBtcParams extends RecordEditAuthorityParams {
123
+ readonly programId: AddressLike;
124
+ readonly owner: AddressLike;
125
+ readonly handle: AddressLike;
126
+ readonly record: AddressLike;
127
+ readonly nonce: string;
128
+ /** BIP-137 header byte — use {@link splitBtcSignature} on a wallet's raw
129
+ * 65-byte `signmessage` output. Only 31-34 (compressed P2PKH) and 39-42
130
+ * (bech32/P2WPKH) are accepted on-chain; see the module docs. */
131
+ readonly header: number;
132
+ /** The 64-byte `r || s` half. */
133
+ readonly signature: Uint8Array;
134
+ }
135
+ export declare function buildVerifyRecordBtcIx(p: VerifyRecordBtcParams): BuiltInstruction;
136
+ export {};
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Write builders for the address `Record` family: `create_record` and the
3
+ * three `verify_record_*` proof-of-control instructions (ETH/secp256k1,
4
+ * SVM/ed25519, BTC/BIP-137). `records.ts` was read-only until now — every
5
+ * other record family (text records, attestations, vouchers, subnames...)
6
+ * already had write builders; this was the one gap (WP #8142).
7
+ *
8
+ * Hand-rolled like the rest of this package — no Anchor client, no
9
+ * `@solana/web3.js` import. PDAs are NOT derived here (see delegate.ts's
10
+ * module docs for why) — derive `["record", handlePda, coinTypeLE(4)]`
11
+ * with your runtime's canonical `findProgramAddress`.
12
+ *
13
+ * # What this SDK does and does not do for you
14
+ *
15
+ * This does NOT sign anything, derive an address from a signature, or
16
+ * verify a signature client-side — that's the on-chain program's job
17
+ * (`verify_record_eth`/`svm`/`btc` recover the signer's address from the
18
+ * signature itself and compare it to the record's claimed value; a client
19
+ * proving nothing false cannot fabricate a match). What this module DOES do:
20
+ *
21
+ * 1. {@link buildRecordChallenge} — build the exact bytes a wallet must
22
+ * sign, byte-identical to `handle-normalize`'s Rust `challenge()`
23
+ * (verified: `crates/handle-normalize/src/challenge.rs`). Unlike the
24
+ * off-chain control-proof flow (`control.ts`), this challenge's nonce
25
+ * does NOT need to be server-issued — the on-chain transaction landing
26
+ * IS the single-use event, so any ASCII-alphanumeric nonce (1..=64
27
+ * chars) the caller picks is fine.
28
+ * 2. {@link splitEthSignature} / {@link splitBtcSignature} — repackage what
29
+ * a wallet's `personal_sign` (ETH) or `signmessage` (BTC) actually
30
+ * returns into the exact args each verify instruction expects.
31
+ * 3. The four instruction builders themselves.
32
+ *
33
+ * # ETH: EIP-191 `personal_sign`, not EIP-712
34
+ *
35
+ * `verify_record_eth` (lib.rs) hashes the challenge with EIP-191's
36
+ * `"\x19Ethereum Signed Message:\n" + len + message` framing — the exact
37
+ * framing every wallet's `personal_sign` / `eth_sign` RPC method produces.
38
+ * There is no EIP-712 typed-data variant on-chain to build a client for.
39
+ *
40
+ * # BTC: mainnet-only, compressed P2PKH or bech32/P2WPKH only
41
+ *
42
+ * `verify_record_btc` derives the address FROM the recovered pubkey (never
43
+ * trusts a client-supplied address) and only accepts the header-byte ranges
44
+ * for compressed P2PKH (31-34) and bech32/P2WPKH (39-42) — see `btc.rs`'s
45
+ * module doc for exactly why uncompressed P2PKH and P2SH-segwit are refused
46
+ * rather than guessed. {@link splitBtcSignature} passes the header byte
47
+ * through unchanged; it is the wallet's job to produce one of the two
48
+ * supported ranges (most current wallets do).
49
+ */
50
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
51
+ import { recordDelegateNonePlaceholder, recordDelegateSomeSlot } from "./delegate.js";
52
+ import { normalizeHandle } from "./parse.js";
53
+ import { ResolveError } from "./types.js";
54
+ /** Matches `control.ts`'s identically-named private helper: STRICT
55
+ * canonical-form check (reject, never silently transform), mirroring the
56
+ * Rust `is_canonical` the challenge builder must match exactly —
57
+ * `normalizeHandle` alone would accept `"@Alice"` by transforming it,
58
+ * which `handle_normalize::challenge` (Rust) does NOT: it demands
59
+ * already-canonical input and rejects anything else outright. */
60
+ function isCanonicalHandle(handle) {
61
+ try {
62
+ return normalizeHandle(handle) === handle;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ export const CREATE_RECORD_DISCRIMINATOR = Uint8Array.from([
69
+ 116, 124, 63, 58, 126, 204, 178, 10,
70
+ ]);
71
+ export const VERIFY_RECORD_ETH_DISCRIMINATOR = Uint8Array.from([
72
+ 57, 157, 98, 0, 160, 210, 94, 234,
73
+ ]);
74
+ export const VERIFY_RECORD_SVM_DISCRIMINATOR = Uint8Array.from([
75
+ 234, 151, 82, 212, 185, 112, 234, 212,
76
+ ]);
77
+ export const VERIFY_RECORD_BTC_DISCRIMINATOR = Uint8Array.from([
78
+ 54, 50, 183, 232, 122, 200, 194, 64,
79
+ ]);
80
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
81
+ const NONCE_RE = /^[0-9A-Za-z]{1,64}$/;
82
+ function toBytes32(v, what) {
83
+ if (typeof v === "string") {
84
+ const b = decodeBase58_32(v);
85
+ if (!b)
86
+ throw new Error(`${what} is not a valid base58 address`);
87
+ return b;
88
+ }
89
+ if (v.length !== 32)
90
+ throw new Error(`${what} must be exactly 32 bytes`);
91
+ return v;
92
+ }
93
+ function toBase58(v, what) {
94
+ return encodeBase58(toBytes32(v, what));
95
+ }
96
+ function encVec(bytes) {
97
+ const out = new Uint8Array(4 + bytes.length);
98
+ new DataView(out.buffer).setUint32(0, bytes.length, true);
99
+ out.set(bytes, 4);
100
+ return out;
101
+ }
102
+ function encString(s) {
103
+ return encVec(new TextEncoder().encode(s));
104
+ }
105
+ function pushAuthorityTail(keys, programId, p) {
106
+ if (p.recordDelegate !== undefined) {
107
+ keys.push(recordDelegateSomeSlot(p.recordDelegate));
108
+ }
109
+ else if (p.holderTokenAccount !== undefined) {
110
+ keys.push(recordDelegateNonePlaceholder(programId));
111
+ }
112
+ if (p.holderTokenAccount !== undefined) {
113
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
114
+ }
115
+ }
116
+ // ---------------------------------------------------------------------------
117
+ // Challenge
118
+ // ---------------------------------------------------------------------------
119
+ /**
120
+ * `x1-handles:v1:<handle>:<coinType>:<registeredAt>:<nonce>` — byte-identical
121
+ * to `handle_normalize::challenge` (Rust). `handle` must already be
122
+ * canonical (pass it through {@link import("./parse.js").normalizeHandle}
123
+ * first if it might have a leading `@` or mixed case).
124
+ */
125
+ export function buildRecordChallenge(handle, coinType, registeredAt, nonce) {
126
+ if (!isCanonicalHandle(handle)) {
127
+ throw new ResolveError("invalid-handle", `"${handle}" is not a canonical handle`, handle);
128
+ }
129
+ if (!NONCE_RE.test(nonce)) {
130
+ throw new Error("nonce must be 1..=64 ASCII alphanumeric characters");
131
+ }
132
+ return `x1-handles:v1:${handle}:${coinType}:${registeredAt}:${nonce}`;
133
+ }
134
+ /** Split a 65-byte ETH `personal_sign` output (`r(32) || s(32) || v(1)`)
135
+ * into `verify_record_eth`'s `{signature: [u8;64], recoveryId: u8}`,
136
+ * normalizing `v`'s two common conventions (27/28, matching most wallets'
137
+ * raw RPC output, or already 0/1). */
138
+ export function splitEthSignature(sig65) {
139
+ if (sig65.length !== 65)
140
+ throw new Error("ETH signature must be exactly 65 bytes (r || s || v)");
141
+ const v = sig65[64];
142
+ const recoveryId = v >= 27 ? v - 27 : v;
143
+ if (recoveryId !== 0 && recoveryId !== 1) {
144
+ throw new Error(`unrecognized recovery id byte: ${v}`);
145
+ }
146
+ return { signature: sig65.slice(0, 64), recoveryId };
147
+ }
148
+ /** Split a 65-byte BTC BIP-137 `signmessage` output (`header(1) || r(32) ||
149
+ * s(32)`) into `verify_record_btc`'s `{header, signature: [u8;64]}`. The
150
+ * header byte is passed through unchanged — see the module docs for which
151
+ * ranges `verify_record_btc` accepts. */
152
+ export function splitBtcSignature(sig65) {
153
+ if (sig65.length !== 65)
154
+ throw new Error("BTC signature must be exactly 65 bytes (header || r || s)");
155
+ return { header: sig65[0], signature: sig65.slice(1) };
156
+ }
157
+ export function buildCreateRecordIx(p) {
158
+ if (p.value.length === 0 || p.value.length > 64) {
159
+ throw new Error("value must be 1..=64 bytes");
160
+ }
161
+ const coinTypeBytes = new Uint8Array(4);
162
+ new DataView(coinTypeBytes.buffer).setUint32(0, p.coinType, true);
163
+ const data = new Uint8Array(8 + 4 + 4 + p.value.length);
164
+ data.set(CREATE_RECORD_DISCRIMINATOR, 0);
165
+ data.set(coinTypeBytes, 8);
166
+ data.set(encVec(p.value), 12);
167
+ const keys = [
168
+ { pubkey: toBase58(p.payer, "payer"), isSigner: true, isWritable: true },
169
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
170
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
171
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
172
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
173
+ ];
174
+ return { programId: toBase58(p.programId, "programId"), keys, data };
175
+ }
176
+ export function buildVerifyRecordEthIx(p) {
177
+ if (p.signature.length !== 64)
178
+ throw new Error("signature must be exactly 64 bytes (r || s)");
179
+ if (p.recoveryId !== 0 && p.recoveryId !== 1)
180
+ throw new Error("recoveryId must be 0 or 1");
181
+ if (!NONCE_RE.test(p.nonce))
182
+ throw new Error("nonce must be 1..=64 ASCII alphanumeric characters");
183
+ const nonceEnc = encString(p.nonce);
184
+ const data = new Uint8Array(8 + nonceEnc.length + 64 + 1);
185
+ data.set(VERIFY_RECORD_ETH_DISCRIMINATOR, 0);
186
+ data.set(nonceEnc, 8);
187
+ data.set(p.signature, 8 + nonceEnc.length);
188
+ data[8 + nonceEnc.length + 64] = p.recoveryId;
189
+ const keys = [
190
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
191
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
192
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
193
+ ];
194
+ pushAuthorityTail(keys, p.programId, p);
195
+ return { programId: toBase58(p.programId, "programId"), keys, data };
196
+ }
197
+ export function buildVerifyRecordSvmIx(p) {
198
+ const keys = [
199
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
200
+ { pubkey: toBase58(p.claimed, "claimed"), isSigner: true, isWritable: false },
201
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
202
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
203
+ ];
204
+ pushAuthorityTail(keys, p.programId, p);
205
+ return { programId: toBase58(p.programId, "programId"), keys, data: VERIFY_RECORD_SVM_DISCRIMINATOR.slice() };
206
+ }
207
+ export function buildVerifyRecordBtcIx(p) {
208
+ if (p.signature.length !== 64)
209
+ throw new Error("signature must be exactly 64 bytes (r || s)");
210
+ if (!Number.isInteger(p.header) || p.header < 0 || p.header > 255) {
211
+ throw new Error("header must be a single byte (0-255)");
212
+ }
213
+ if (!NONCE_RE.test(p.nonce))
214
+ throw new Error("nonce must be 1..=64 ASCII alphanumeric characters");
215
+ const nonceEnc = encString(p.nonce);
216
+ const data = new Uint8Array(8 + nonceEnc.length + 65);
217
+ data.set(VERIFY_RECORD_BTC_DISCRIMINATOR, 0);
218
+ data.set(nonceEnc, 8);
219
+ data[8 + nonceEnc.length] = p.header;
220
+ data.set(p.signature, 8 + nonceEnc.length + 1);
221
+ const keys = [
222
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
223
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
224
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
225
+ ];
226
+ pushAuthorityTail(keys, p.programId, p);
227
+ return { programId: toBase58(p.programId, "programId"), keys, data };
228
+ }