@x1id/resolve 0.2.1 → 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.
package/dist/lock.d.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Name locks (#7377): decode helpers for the on-chain lock state, plus the
3
+ * instruction builders for `lock_handle` and the timelocked unlock flow
4
+ * (`initiate_unlock` / `complete_unlock` / `cancel_unlock`).
5
+ *
6
+ * Hand-rolled like the rest of this package — no Anchor client, no
7
+ * `@solana/web3.js` import (it stays an optional peer). Builders return the
8
+ * transport-neutral {@link BuiltInstruction} `delegate.ts` uses; adapt to
9
+ * web3.js exactly as that module's header documents.
10
+ *
11
+ * # The lock extension, mirrored from state.rs's EXTENSION OFFSET REGISTRY
12
+ *
13
+ * A `Handle` account's struct is frozen at 157 bytes (`8 + INIT_SPACE`);
14
+ * features append fixed-offset raw regions past it:
15
+ *
16
+ * offset size region
17
+ * 157 33 NFT capability extension (presence tag + mint pubkey)
18
+ * 190 1 RESERVED, permanently-unused gap (#7377 — see below)
19
+ * 191 5 record_count: presence tag + u32 LE live-record count
20
+ * 196 9 lock: locked(u8) + unlock_initiated_at(i64 LE) ← this module
21
+ * 205 — next free offset
22
+ *
23
+ * The lock region lives at 196, PAST record_count — NOT at the byte 190 that
24
+ * was originally reserved for it. The shipped feature needs 9 bytes (a flag
25
+ * plus an `unlock_initiated_at` timestamp for the timelocked unlock), which
26
+ * does not fit in one byte, and record_count (191–195) is deployed with live
27
+ * data whose offset cannot move. Byte 190 stays a documented, zeroed gap.
28
+ *
29
+ * # No presence tag — unlocked and uninitialized are the same value
30
+ *
31
+ * Unlike record_count (whose `0` count is distinct from "uninitialized"), the
32
+ * lock uses NO presence byte: `locked === 0` — which is also what a short or
33
+ * never-locked account reads as — IS the unlocked state. So {@link readLock}
34
+ * is total: a pre-lock handle (account too short) and a zeroed flag both read
35
+ * as `false` (unlocked), never an error and never a wrong answer.
36
+ *
37
+ * # The wire rules the on-chain program enforces (mirror of lib.rs)
38
+ *
39
+ * - All four instructions are gated by the STRICT authority check
40
+ * (`require_current_authority` in lib.rs): the untokenized owner, OR —
41
+ * for a TOKENIZED handle — the current NFT holder, who appends their ATA for
42
+ * the handle's NFT mint as the first extra (remaining) account
43
+ * ({@link LockParams.holderTokenAccount} etc.).
44
+ * - `lock_handle` refuses if a live `Listing` (`HandleListedCannotLock`, 6053)
45
+ * or `Auction` (`AuctionLive`, 6031) exists — a lock and an active market are
46
+ * mutually exclusive. It grows the handle account through the lock region on
47
+ * first lock (paid by `payer`), so `payer` must be a writable signer.
48
+ * - HARD LOCK: while locked, NOTHING about the handle can change. All 23
49
+ * mutating instructions refuse with `HandleLocked` (6049) — the 11
50
+ * name-MOVING ones (`transfer`, `release_handle`, `list_handle`, `buy_handle`,
51
+ * `accept_offer`, `start_auction`, `settle_auction`, `mint_handle_nft`,
52
+ * `set_recovery`, `initiate_recovery`, `complete_recovery`) AND the 12
53
+ * record/primary/text/delegate editors (`create_record`, `update_record`,
54
+ * `verify_record_eth`/`svm`/`btc`, `close_record`, `create_text_record`,
55
+ * `update_text_record`, `close_text_record`, `set_record_delegate`,
56
+ * `revoke_record_delegate`, `set_primary`) — so a stolen key cannot redirect a
57
+ * locked handle's addresses. Only `cancel_recovery` and `clear_primary` stay
58
+ * available (the compromised-key owner can still cancel an attacker's
59
+ * recovery; `clear_primary` cannot encumber the handle).
60
+ * - Unlock is timelocked: `initiate_unlock` stamps the clock (refused if not
61
+ * locked, `HandleNotLocked` 6050); `complete_unlock` clears the lock only
62
+ * after `Config.recovery_timelock_secs` has elapsed (`UnlockNotInitiated`
63
+ * 6051 / `UnlockTimelockNotElapsed` 6052); `cancel_unlock` clears a pending
64
+ * unlock but KEEPS the lock — the owner's defence against a stolen-key thief.
65
+ * - `lock_handle` is idempotent: re-locking re-asserts the flag and clears any
66
+ * pending unlock (same effect as `cancel_unlock`).
67
+ */
68
+ import type { AddressLike, BuiltInstruction } from "./delegate.js";
69
+ /** Byte offset of the lock extension: base(157) + NFT ext(33) + reserved gap
70
+ * byte(1) + record_count ext(5) = 196. */
71
+ export declare const LOCK_EXT_OFFSET = 196;
72
+ /** `locked: u8` (1) + `unlock_initiated_at: i64` LE (8). */
73
+ export declare const LOCK_EXT_LEN = 9;
74
+ /** Hard floor (seconds) on the UNLOCK timelock, mirrored from the program's
75
+ * `MIN_UNLOCK_TIMELOCK_SECS`. `complete_unlock` enforces
76
+ * `max(Config.recovery_timelock_secs, MIN_UNLOCK_TIMELOCK_SECS)`, so no config
77
+ * value can collapse the reaction window. A UI computing when an unlock becomes
78
+ * available MUST apply the same clamp — see {@link unlockAvailableAt}. */
79
+ export declare const MIN_UNLOCK_TIMELOCK_SECS: number;
80
+ /**
81
+ * The unix timestamp at which a pending `complete_unlock` becomes valid, given
82
+ * the handle's `unlock_initiated_at` and the config's `recovery_timelock_secs`.
83
+ * Applies the same `MIN_UNLOCK_TIMELOCK_SECS` floor the program enforces, so a
84
+ * UI never shows an ETA earlier than the program will actually allow. Returns
85
+ * `null` when no unlock is pending (`unlockInitiatedAt == 0n`).
86
+ */
87
+ export declare function unlockAvailableAt(unlockInitiatedAt: bigint, recoveryTimelockSecs: bigint): bigint | null;
88
+ /** Anchor instruction discriminators — `sha256("global:<name>")[0..8]`,
89
+ * pinned (this SDK is zero-dependency and cannot assume WebCrypto SHA-256
90
+ * everywhere it runs) and asserted against a re-derivation in the test suite
91
+ * so a typo can never silently pass. */
92
+ export declare const LOCK_HANDLE_DISCRIMINATOR: Uint8Array;
93
+ export declare const INITIATE_UNLOCK_DISCRIMINATOR: Uint8Array;
94
+ export declare const COMPLETE_UNLOCK_DISCRIMINATOR: Uint8Array;
95
+ export declare const CANCEL_UNLOCK_DISCRIMINATOR: Uint8Array;
96
+ /** The decoded lock state of a `Handle` account. */
97
+ export interface LockState {
98
+ /** Whether the handle is locked — every name-moving instruction refuses. */
99
+ readonly locked: boolean;
100
+ /** Unix timestamp of a pending `initiate_unlock` (`0n` when none is pending).
101
+ * Meaningful only while `locked`; `complete_unlock` can land once
102
+ * `Config.recovery_timelock_secs` has elapsed since this. */
103
+ readonly unlockInitiatedAt: bigint;
104
+ }
105
+ /**
106
+ * Read the lock flag from a `Handle` account's raw data (exactly what an RPC
107
+ * `getAccountInfo` returns for the `["handle", name]` PDA). Mirrors the
108
+ * program's `Handle::read_lock` tolerance exactly: a short account (a handle
109
+ * registered or grown before the lock region existed) and a zeroed flag both
110
+ * read as `false` (unlocked) — never an error, never a wrong answer.
111
+ */
112
+ export declare function readLock(data: Uint8Array): boolean;
113
+ /**
114
+ * Read the pending-unlock timestamp (`0n` == none pending). Tolerant exactly
115
+ * like {@link readLock}: a region too short to hold the i64 reads as `0n`.
116
+ * Meaningful only while {@link readLock} is `true`.
117
+ */
118
+ export declare function readUnlockInitiatedAt(data: Uint8Array): bigint;
119
+ /**
120
+ * Decode the full lock state ({@link readLock} + {@link readUnlockInitiatedAt})
121
+ * from a `Handle` account's raw data.
122
+ */
123
+ export declare function readLockState(data: Uint8Array): LockState;
124
+ export interface LockParams {
125
+ /** The registry program id. */
126
+ readonly programId: AddressLike;
127
+ /** Pays the one-time account grow through the lock region on first lock.
128
+ * Signer, writable. */
129
+ readonly payer: AddressLike;
130
+ /** The handle's CURRENT authority (untokenized owner, or NFT holder).
131
+ * Signer. */
132
+ readonly owner: AddressLike;
133
+ /** The `["handle", name]` PDA. */
134
+ readonly handle: AddressLike;
135
+ /** The `["listing", handle]` PDA — must NOT exist (mutual exclusion). Derive
136
+ * per `delegate.ts`'s module docs. */
137
+ readonly listing: AddressLike;
138
+ /** The `["auction", handle]` PDA — must NOT exist (mutual exclusion). */
139
+ readonly auction: AddressLike;
140
+ /** TOKENIZED handles only: the holder's associated token account for the
141
+ * handle's NFT mint, appended as the strict check's remaining-account proof.
142
+ * Omit for an untokenized handle. */
143
+ readonly holderTokenAccount?: AddressLike;
144
+ }
145
+ /**
146
+ * Build `lock_handle` — freeze every name-moving instruction until a
147
+ * timelocked unlock. Idempotent (re-locking clears any pending unlock).
148
+ */
149
+ export declare function buildLockHandleIx(p: LockParams): BuiltInstruction;
150
+ export interface UnlockParams {
151
+ /** The registry program id. */
152
+ readonly programId: AddressLike;
153
+ /** The handle's CURRENT authority (untokenized owner, or NFT holder).
154
+ * Signer. */
155
+ readonly owner: AddressLike;
156
+ /** The `["handle", name]` PDA. */
157
+ readonly handle: AddressLike;
158
+ /** TOKENIZED handles only — same rule as {@link LockParams.holderTokenAccount}. */
159
+ readonly holderTokenAccount?: AddressLike;
160
+ }
161
+ /**
162
+ * Build `initiate_unlock` — begin the timelocked unlock (stamps the clock;
163
+ * unlocks nothing yet). Refused if the handle is not locked.
164
+ */
165
+ export declare function buildInitiateUnlockIx(p: UnlockParams): BuiltInstruction;
166
+ /**
167
+ * Build `cancel_unlock` — cancel a pending unlock but LEAVE the handle locked
168
+ * (the owner's defence against a stolen-key thief who called `initiate_unlock`).
169
+ */
170
+ export declare function buildCancelUnlockIx(p: UnlockParams): BuiltInstruction;
171
+ export interface CompleteUnlockParams extends UnlockParams {
172
+ /** The `["config"]` PDA — the handler reads `recovery_timelock_secs` from it
173
+ * (reused as the unlock timelock). */
174
+ readonly config: AddressLike;
175
+ }
176
+ /**
177
+ * Build `complete_unlock` — finish the timelocked unlock: clears the lock once
178
+ * `Config.recovery_timelock_secs` has elapsed since `initiate_unlock`.
179
+ */
180
+ export declare function buildCompleteUnlockIx(p: CompleteUnlockParams): BuiltInstruction;
package/dist/lock.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Name locks (#7377): decode helpers for the on-chain lock state, plus the
3
+ * instruction builders for `lock_handle` and the timelocked unlock flow
4
+ * (`initiate_unlock` / `complete_unlock` / `cancel_unlock`).
5
+ *
6
+ * Hand-rolled like the rest of this package — no Anchor client, no
7
+ * `@solana/web3.js` import (it stays an optional peer). Builders return the
8
+ * transport-neutral {@link BuiltInstruction} `delegate.ts` uses; adapt to
9
+ * web3.js exactly as that module's header documents.
10
+ *
11
+ * # The lock extension, mirrored from state.rs's EXTENSION OFFSET REGISTRY
12
+ *
13
+ * A `Handle` account's struct is frozen at 157 bytes (`8 + INIT_SPACE`);
14
+ * features append fixed-offset raw regions past it:
15
+ *
16
+ * offset size region
17
+ * 157 33 NFT capability extension (presence tag + mint pubkey)
18
+ * 190 1 RESERVED, permanently-unused gap (#7377 — see below)
19
+ * 191 5 record_count: presence tag + u32 LE live-record count
20
+ * 196 9 lock: locked(u8) + unlock_initiated_at(i64 LE) ← this module
21
+ * 205 — next free offset
22
+ *
23
+ * The lock region lives at 196, PAST record_count — NOT at the byte 190 that
24
+ * was originally reserved for it. The shipped feature needs 9 bytes (a flag
25
+ * plus an `unlock_initiated_at` timestamp for the timelocked unlock), which
26
+ * does not fit in one byte, and record_count (191–195) is deployed with live
27
+ * data whose offset cannot move. Byte 190 stays a documented, zeroed gap.
28
+ *
29
+ * # No presence tag — unlocked and uninitialized are the same value
30
+ *
31
+ * Unlike record_count (whose `0` count is distinct from "uninitialized"), the
32
+ * lock uses NO presence byte: `locked === 0` — which is also what a short or
33
+ * never-locked account reads as — IS the unlocked state. So {@link readLock}
34
+ * is total: a pre-lock handle (account too short) and a zeroed flag both read
35
+ * as `false` (unlocked), never an error and never a wrong answer.
36
+ *
37
+ * # The wire rules the on-chain program enforces (mirror of lib.rs)
38
+ *
39
+ * - All four instructions are gated by the STRICT authority check
40
+ * (`require_current_authority` in lib.rs): the untokenized owner, OR —
41
+ * for a TOKENIZED handle — the current NFT holder, who appends their ATA for
42
+ * the handle's NFT mint as the first extra (remaining) account
43
+ * ({@link LockParams.holderTokenAccount} etc.).
44
+ * - `lock_handle` refuses if a live `Listing` (`HandleListedCannotLock`, 6053)
45
+ * or `Auction` (`AuctionLive`, 6031) exists — a lock and an active market are
46
+ * mutually exclusive. It grows the handle account through the lock region on
47
+ * first lock (paid by `payer`), so `payer` must be a writable signer.
48
+ * - HARD LOCK: while locked, NOTHING about the handle can change. All 23
49
+ * mutating instructions refuse with `HandleLocked` (6049) — the 11
50
+ * name-MOVING ones (`transfer`, `release_handle`, `list_handle`, `buy_handle`,
51
+ * `accept_offer`, `start_auction`, `settle_auction`, `mint_handle_nft`,
52
+ * `set_recovery`, `initiate_recovery`, `complete_recovery`) AND the 12
53
+ * record/primary/text/delegate editors (`create_record`, `update_record`,
54
+ * `verify_record_eth`/`svm`/`btc`, `close_record`, `create_text_record`,
55
+ * `update_text_record`, `close_text_record`, `set_record_delegate`,
56
+ * `revoke_record_delegate`, `set_primary`) — so a stolen key cannot redirect a
57
+ * locked handle's addresses. Only `cancel_recovery` and `clear_primary` stay
58
+ * available (the compromised-key owner can still cancel an attacker's
59
+ * recovery; `clear_primary` cannot encumber the handle).
60
+ * - Unlock is timelocked: `initiate_unlock` stamps the clock (refused if not
61
+ * locked, `HandleNotLocked` 6050); `complete_unlock` clears the lock only
62
+ * after `Config.recovery_timelock_secs` has elapsed (`UnlockNotInitiated`
63
+ * 6051 / `UnlockTimelockNotElapsed` 6052); `cancel_unlock` clears a pending
64
+ * unlock but KEEPS the lock — the owner's defence against a stolen-key thief.
65
+ * - `lock_handle` is idempotent: re-locking re-asserts the flag and clears any
66
+ * pending unlock (same effect as `cancel_unlock`).
67
+ */
68
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
69
+ /** Byte offset of the lock extension: base(157) + NFT ext(33) + reserved gap
70
+ * byte(1) + record_count ext(5) = 196. */
71
+ export const LOCK_EXT_OFFSET = 196;
72
+ /** `locked: u8` (1) + `unlock_initiated_at: i64` LE (8). */
73
+ export const LOCK_EXT_LEN = 9;
74
+ /** Hard floor (seconds) on the UNLOCK timelock, mirrored from the program's
75
+ * `MIN_UNLOCK_TIMELOCK_SECS`. `complete_unlock` enforces
76
+ * `max(Config.recovery_timelock_secs, MIN_UNLOCK_TIMELOCK_SECS)`, so no config
77
+ * value can collapse the reaction window. A UI computing when an unlock becomes
78
+ * available MUST apply the same clamp — see {@link unlockAvailableAt}. */
79
+ export const MIN_UNLOCK_TIMELOCK_SECS = 24 * 60 * 60;
80
+ /**
81
+ * The unix timestamp at which a pending `complete_unlock` becomes valid, given
82
+ * the handle's `unlock_initiated_at` and the config's `recovery_timelock_secs`.
83
+ * Applies the same `MIN_UNLOCK_TIMELOCK_SECS` floor the program enforces, so a
84
+ * UI never shows an ETA earlier than the program will actually allow. Returns
85
+ * `null` when no unlock is pending (`unlockInitiatedAt == 0n`).
86
+ */
87
+ export function unlockAvailableAt(unlockInitiatedAt, recoveryTimelockSecs) {
88
+ if (unlockInitiatedAt === 0n)
89
+ return null;
90
+ const floor = BigInt(MIN_UNLOCK_TIMELOCK_SECS);
91
+ const timelock = recoveryTimelockSecs > floor ? recoveryTimelockSecs : floor;
92
+ return unlockInitiatedAt + timelock;
93
+ }
94
+ /** Anchor instruction discriminators — `sha256("global:<name>")[0..8]`,
95
+ * pinned (this SDK is zero-dependency and cannot assume WebCrypto SHA-256
96
+ * everywhere it runs) and asserted against a re-derivation in the test suite
97
+ * so a typo can never silently pass. */
98
+ export const LOCK_HANDLE_DISCRIMINATOR = Uint8Array.from([
99
+ 104, 51, 234, 115, 96, 138, 171, 223,
100
+ ]);
101
+ export const INITIATE_UNLOCK_DISCRIMINATOR = Uint8Array.from([
102
+ 41, 118, 177, 198, 27, 237, 231, 69,
103
+ ]);
104
+ export const COMPLETE_UNLOCK_DISCRIMINATOR = Uint8Array.from([
105
+ 22, 203, 15, 189, 239, 125, 25, 132,
106
+ ]);
107
+ export const CANCEL_UNLOCK_DISCRIMINATOR = Uint8Array.from([
108
+ 116, 58, 36, 132, 24, 104, 170, 228,
109
+ ]);
110
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
111
+ function toBase58(v, what) {
112
+ if (typeof v === "string") {
113
+ const b = decodeBase58_32(v);
114
+ if (!b)
115
+ throw new Error(`${what} is not a valid base58 address`);
116
+ return encodeBase58(b);
117
+ }
118
+ if (v.length !== 32)
119
+ throw new Error(`${what} must be exactly 32 bytes`);
120
+ return encodeBase58(v);
121
+ }
122
+ /**
123
+ * Read the lock flag from a `Handle` account's raw data (exactly what an RPC
124
+ * `getAccountInfo` returns for the `["handle", name]` PDA). Mirrors the
125
+ * program's `Handle::read_lock` tolerance exactly: a short account (a handle
126
+ * registered or grown before the lock region existed) and a zeroed flag both
127
+ * read as `false` (unlocked) — never an error, never a wrong answer.
128
+ */
129
+ export function readLock(data) {
130
+ if (data.length < LOCK_EXT_OFFSET + 1)
131
+ return false;
132
+ return data[LOCK_EXT_OFFSET] !== 0;
133
+ }
134
+ /**
135
+ * Read the pending-unlock timestamp (`0n` == none pending). Tolerant exactly
136
+ * like {@link readLock}: a region too short to hold the i64 reads as `0n`.
137
+ * Meaningful only while {@link readLock} is `true`.
138
+ */
139
+ export function readUnlockInitiatedAt(data) {
140
+ const start = LOCK_EXT_OFFSET + 1;
141
+ if (data.length < start + 8)
142
+ return 0n;
143
+ return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(start, true);
144
+ }
145
+ /**
146
+ * Decode the full lock state ({@link readLock} + {@link readUnlockInitiatedAt})
147
+ * from a `Handle` account's raw data.
148
+ */
149
+ export function readLockState(data) {
150
+ return { locked: readLock(data), unlockInitiatedAt: readUnlockInitiatedAt(data) };
151
+ }
152
+ /**
153
+ * Build `lock_handle` — freeze every name-moving instruction until a
154
+ * timelocked unlock. Idempotent (re-locking clears any pending unlock).
155
+ */
156
+ export function buildLockHandleIx(p) {
157
+ const keys = [
158
+ { pubkey: toBase58(p.payer, "payer"), isSigner: true, isWritable: true },
159
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
160
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
161
+ { pubkey: toBase58(p.listing, "listing"), isSigner: false, isWritable: false },
162
+ { pubkey: toBase58(p.auction, "auction"), isSigner: false, isWritable: false },
163
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
164
+ ];
165
+ if (p.holderTokenAccount !== undefined) {
166
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
167
+ }
168
+ return { programId: toBase58(p.programId, "programId"), keys, data: LOCK_HANDLE_DISCRIMINATOR.slice() };
169
+ }
170
+ /** Shared builder for the two `UnlockHandle`-context instructions
171
+ * (`initiate_unlock`, `cancel_unlock`): identical account list, different
172
+ * discriminator. */
173
+ function buildUnlockHandleIx(disc, p) {
174
+ const keys = [
175
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
176
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
177
+ ];
178
+ if (p.holderTokenAccount !== undefined) {
179
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
180
+ }
181
+ return { programId: toBase58(p.programId, "programId"), keys, data: disc.slice() };
182
+ }
183
+ /**
184
+ * Build `initiate_unlock` — begin the timelocked unlock (stamps the clock;
185
+ * unlocks nothing yet). Refused if the handle is not locked.
186
+ */
187
+ export function buildInitiateUnlockIx(p) {
188
+ return buildUnlockHandleIx(INITIATE_UNLOCK_DISCRIMINATOR, p);
189
+ }
190
+ /**
191
+ * Build `cancel_unlock` — cancel a pending unlock but LEAVE the handle locked
192
+ * (the owner's defence against a stolen-key thief who called `initiate_unlock`).
193
+ */
194
+ export function buildCancelUnlockIx(p) {
195
+ return buildUnlockHandleIx(CANCEL_UNLOCK_DISCRIMINATOR, p);
196
+ }
197
+ /**
198
+ * Build `complete_unlock` — finish the timelocked unlock: clears the lock once
199
+ * `Config.recovery_timelock_secs` has elapsed since `initiate_unlock`.
200
+ */
201
+ export function buildCompleteUnlockIx(p) {
202
+ const keys = [
203
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
204
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
205
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: false },
206
+ ];
207
+ if (p.holderTokenAccount !== undefined) {
208
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
209
+ }
210
+ return { programId: toBase58(p.programId, "programId"), keys, data: COMPLETE_UNLOCK_DISCRIMINATOR.slice() };
211
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The record_count Handle extension (#7384): decode helper for the on-chain
3
+ * live-record counter, plus the instruction builder for the admin-only
4
+ * `backfill_record_count` migration path.
5
+ *
6
+ * Hand-rolled like the rest of this package — no Anchor client, no
7
+ * `@solana/web3.js` import (it stays an optional peer). The builder returns
8
+ * the same transport-neutral {@link BuiltInstruction} `delegate.ts` uses.
9
+ *
10
+ * # The extension, mirrored from state.rs's EXTENSION OFFSET REGISTRY
11
+ *
12
+ * A `Handle` account's struct is frozen at 157 bytes (`8 + INIT_SPACE`);
13
+ * features append fixed-offset raw regions past it:
14
+ *
15
+ * offset size region
16
+ * 157 33 NFT capability extension (presence tag + mint pubkey)
17
+ * 190 1 RESERVED lock byte (#7377, unbuilt)
18
+ * 191 5 record_count: presence tag + u32 LE live-record count
19
+ * 196 — next free offset
20
+ *
21
+ * `create_record` increments the count (initializing the extension and
22
+ * growing the account on first use — which is why the record-editing
23
+ * create/close instructions pass the HANDLE account WRITABLE as of this
24
+ * feature; the account LIST is unchanged), `close_record` decrements it
25
+ * (saturating, only when initialized), and `release_handle` refuses while an
26
+ * initialized count is nonzero (`HandleHasRecords`, code 6044).
27
+ *
28
+ * # Uninitialized is NOT zero
29
+ *
30
+ * {@link readRecordCount} returns `null` for a handle whose extension is
31
+ * uninitialized — an account too short (a pre-#7384 handle) or a presence
32
+ * byte still zero (grown for the NFT extension, never counted). Such a
33
+ * handle keeps the OLD unguarded release behaviour until the admin stamps it
34
+ * via `backfill_record_count` or a post-upgrade `create_record` initializes
35
+ * it. A `0` return, by contrast, is an initialized, trustworthy
36
+ * "provably no live records". Never conflate the two: rendering `null` as
37
+ * "0 records" would tell an owner their release is safe when the program
38
+ * cannot actually vouch for that.
39
+ */
40
+ import type { AddressLike, BuiltInstruction } from "./delegate.js";
41
+ /** `8 + Handle::INIT_SPACE` — the frozen base struct size, and the first
42
+ * extension offset. */
43
+ export declare const HANDLE_BASE_LEN = 157;
44
+ /** Byte offset of the record_count extension: base(157) + NFT ext(33) +
45
+ * reserved lock byte(1). */
46
+ export declare const RECORD_COUNT_EXT_OFFSET = 191;
47
+ /** presence tag(1) + u32 LE count(4). */
48
+ export declare const RECORD_COUNT_EXT_LEN = 5;
49
+ /** One past the last known extension region (must match the program's
50
+ * `Handle::KNOWN_EXT_END`) — the size a fully-grown Handle account has under
51
+ * this program version. Layout: base(157) + NFT(33) + reserved(1) +
52
+ * record_count(5) + lock(9) = 205. Was 196 (pre-lock); the #7377 lock extension
53
+ * (see LOCK_EXT_OFFSET=196 / LOCK_EXT_LEN=9 in lock.ts) pushed it to 205, so any
54
+ * consumer allocating/resizing to this constant must use the current value. */
55
+ export declare const HANDLE_KNOWN_EXT_END = 205;
56
+ /** Anchor instruction discriminator:
57
+ * `sha256("global:backfill_record_count")[0..8]`. Pinned (this SDK is
58
+ * zero-dependency and cannot assume WebCrypto SHA-256 everywhere it runs);
59
+ * asserted against a re-derivation in the test suite so a typo can never
60
+ * silently pass. */
61
+ export declare const BACKFILL_RECORD_COUNT_DISCRIMINATOR: Uint8Array;
62
+ /**
63
+ * Read the live-record count from a `Handle` account's raw data (exactly
64
+ * what an RPC `getAccountInfo` returns for the `["handle", name]` PDA).
65
+ *
66
+ * Returns the count when the extension is INITIALIZED, and `null` when it is
67
+ * not — see the module docs for why `null` and `0` mean different things and
68
+ * must be rendered differently. Mirrors the program's
69
+ * `Handle::read_record_count` tolerance exactly: a short account and a
70
+ * zeroed region are both `null`, never an error.
71
+ */
72
+ export declare function readRecordCount(data: Uint8Array): number | null;
73
+ export interface BackfillRecordCountParams {
74
+ /** The registry program id. */
75
+ readonly programId: AddressLike;
76
+ /** The registry admin (`Config.admin`). Signer; also pays the one-time
77
+ * account grow when the target handle's extension region isn't allocated
78
+ * yet. */
79
+ readonly admin: AddressLike;
80
+ /** The `["config"]` PDA. */
81
+ readonly config: AddressLike;
82
+ /** The `["handle", name]` PDA being stamped. */
83
+ readonly handle: AddressLike;
84
+ /** The true live-record count, computed OFF-chain — a `getProgramAccounts`
85
+ * scan filtered on the handle pubkey (what `fetchRecords` runs; count the
86
+ * full list, stale records included, since stale records are still live
87
+ * accounts that block a clean release). Overwrites whatever is stored —
88
+ * the instruction is deliberately idempotent/re-runnable. */
89
+ readonly count: number;
90
+ }
91
+ /**
92
+ * Build `backfill_record_count` — admin-only: stamp a pre-#7384 handle's
93
+ * record_count extension with the off-chain-computed truth, moving it
94
+ * permanently out of the unguarded-release window. Mainnet never needs it
95
+ * (the mainnet registry starts fresh at cutover, counted from the first
96
+ * record); this is testnet migration + permanent correction tooling.
97
+ */
98
+ export declare function buildBackfillRecordCountIx(p: BackfillRecordCountParams): BuiltInstruction;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The record_count Handle extension (#7384): decode helper for the on-chain
3
+ * live-record counter, plus the instruction builder for the admin-only
4
+ * `backfill_record_count` migration path.
5
+ *
6
+ * Hand-rolled like the rest of this package — no Anchor client, no
7
+ * `@solana/web3.js` import (it stays an optional peer). The builder returns
8
+ * the same transport-neutral {@link BuiltInstruction} `delegate.ts` uses.
9
+ *
10
+ * # The extension, mirrored from state.rs's EXTENSION OFFSET REGISTRY
11
+ *
12
+ * A `Handle` account's struct is frozen at 157 bytes (`8 + INIT_SPACE`);
13
+ * features append fixed-offset raw regions past it:
14
+ *
15
+ * offset size region
16
+ * 157 33 NFT capability extension (presence tag + mint pubkey)
17
+ * 190 1 RESERVED lock byte (#7377, unbuilt)
18
+ * 191 5 record_count: presence tag + u32 LE live-record count
19
+ * 196 — next free offset
20
+ *
21
+ * `create_record` increments the count (initializing the extension and
22
+ * growing the account on first use — which is why the record-editing
23
+ * create/close instructions pass the HANDLE account WRITABLE as of this
24
+ * feature; the account LIST is unchanged), `close_record` decrements it
25
+ * (saturating, only when initialized), and `release_handle` refuses while an
26
+ * initialized count is nonzero (`HandleHasRecords`, code 6044).
27
+ *
28
+ * # Uninitialized is NOT zero
29
+ *
30
+ * {@link readRecordCount} returns `null` for a handle whose extension is
31
+ * uninitialized — an account too short (a pre-#7384 handle) or a presence
32
+ * byte still zero (grown for the NFT extension, never counted). Such a
33
+ * handle keeps the OLD unguarded release behaviour until the admin stamps it
34
+ * via `backfill_record_count` or a post-upgrade `create_record` initializes
35
+ * it. A `0` return, by contrast, is an initialized, trustworthy
36
+ * "provably no live records". Never conflate the two: rendering `null` as
37
+ * "0 records" would tell an owner their release is safe when the program
38
+ * cannot actually vouch for that.
39
+ */
40
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
41
+ /** `8 + Handle::INIT_SPACE` — the frozen base struct size, and the first
42
+ * extension offset. */
43
+ export const HANDLE_BASE_LEN = 157;
44
+ /** Byte offset of the record_count extension: base(157) + NFT ext(33) +
45
+ * reserved lock byte(1). */
46
+ export const RECORD_COUNT_EXT_OFFSET = 191;
47
+ /** presence tag(1) + u32 LE count(4). */
48
+ export const RECORD_COUNT_EXT_LEN = 5;
49
+ /** One past the last known extension region (must match the program's
50
+ * `Handle::KNOWN_EXT_END`) — the size a fully-grown Handle account has under
51
+ * this program version. Layout: base(157) + NFT(33) + reserved(1) +
52
+ * record_count(5) + lock(9) = 205. Was 196 (pre-lock); the #7377 lock extension
53
+ * (see LOCK_EXT_OFFSET=196 / LOCK_EXT_LEN=9 in lock.ts) pushed it to 205, so any
54
+ * consumer allocating/resizing to this constant must use the current value. */
55
+ export const HANDLE_KNOWN_EXT_END = 205;
56
+ /** Anchor instruction discriminator:
57
+ * `sha256("global:backfill_record_count")[0..8]`. Pinned (this SDK is
58
+ * zero-dependency and cannot assume WebCrypto SHA-256 everywhere it runs);
59
+ * asserted against a re-derivation in the test suite so a typo can never
60
+ * silently pass. */
61
+ export const BACKFILL_RECORD_COUNT_DISCRIMINATOR = Uint8Array.from([
62
+ 151, 122, 233, 167, 188, 54, 32, 110,
63
+ ]);
64
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
65
+ function toBase58(v, what) {
66
+ if (typeof v === "string") {
67
+ const b = decodeBase58_32(v);
68
+ if (!b)
69
+ throw new Error(`${what} is not a valid base58 address`);
70
+ return encodeBase58(b);
71
+ }
72
+ if (v.length !== 32)
73
+ throw new Error(`${what} must be exactly 32 bytes`);
74
+ return encodeBase58(v);
75
+ }
76
+ /**
77
+ * Read the live-record count from a `Handle` account's raw data (exactly
78
+ * what an RPC `getAccountInfo` returns for the `["handle", name]` PDA).
79
+ *
80
+ * Returns the count when the extension is INITIALIZED, and `null` when it is
81
+ * not — see the module docs for why `null` and `0` mean different things and
82
+ * must be rendered differently. Mirrors the program's
83
+ * `Handle::read_record_count` tolerance exactly: a short account and a
84
+ * zeroed region are both `null`, never an error.
85
+ */
86
+ export function readRecordCount(data) {
87
+ if (data.length < RECORD_COUNT_EXT_OFFSET + RECORD_COUNT_EXT_LEN)
88
+ return null;
89
+ if (data[RECORD_COUNT_EXT_OFFSET] === 0)
90
+ return null;
91
+ return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(RECORD_COUNT_EXT_OFFSET + 1, true);
92
+ }
93
+ /**
94
+ * Build `backfill_record_count` — admin-only: stamp a pre-#7384 handle's
95
+ * record_count extension with the off-chain-computed truth, moving it
96
+ * permanently out of the unguarded-release window. Mainnet never needs it
97
+ * (the mainnet registry starts fresh at cutover, counted from the first
98
+ * record); this is testnet migration + permanent correction tooling.
99
+ */
100
+ export function buildBackfillRecordCountIx(p) {
101
+ if (!Number.isInteger(p.count) || p.count < 0 || p.count > 0xffffffff) {
102
+ throw new Error("count must be a u32");
103
+ }
104
+ const data = new Uint8Array(8 + 4);
105
+ data.set(BACKFILL_RECORD_COUNT_DISCRIMINATOR, 0);
106
+ new DataView(data.buffer).setUint32(8, p.count, true);
107
+ const keys = [
108
+ { pubkey: toBase58(p.admin, "admin"), isSigner: true, isWritable: true },
109
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: false },
110
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
111
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
112
+ ];
113
+ return { programId: toBase58(p.programId, "programId"), keys, data };
114
+ }