@x1id/resolve 0.15.0 → 0.16.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/gate.d.ts CHANGED
@@ -7,8 +7,12 @@
7
7
  * The gate operates on the attestations {@link fetchAttestations} returns; the
8
8
  * staleness rule (an attestation only counts for the CURRENT owner —
9
9
  * `attested_at >= handle.registered_at`) is already baked into `HandleAttestation.stale`,
10
- * and this module always ignores stale ones. Nothing here trusts an off-chain
11
- * claim: every input is an on-chain, attestor-signed `Attestation` account.
10
+ * and this module always ignores stale ones. It likewise ignores any attestation
11
+ * flagged `HandleAttestation.holderMismatch` (H5 — a tokenized handle whose pNFT
12
+ * moved to a holder it was not issued to), so a gate caller that overlays
13
+ * {@link markHolderMismatch} onto the fetched set never admits an inherited
14
+ * badge. Nothing here trusts an off-chain claim: every input is an on-chain,
15
+ * attestor-signed `Attestation` account.
12
16
  */
13
17
  import { type HandleAttestation } from "./attestation.js";
14
18
  import type { RpcFn } from "./accounts.js";
@@ -31,13 +35,16 @@ export declare const GateKind: {
31
35
  readonly telegram: 5;
32
36
  };
33
37
  /** Pure check: do these (already-fetched) attestations satisfy the gate?
34
- * Stale attestations never count. */
38
+ * Stale and holder-mismatched (H5) attestations never count. */
35
39
  export declare function attestationsPassGate(attestations: readonly HandleAttestation[], gate: Gate): boolean;
36
40
  /** Fetch a handle's attestations and test the gate in one call. `handleAccount`,
37
- * `registeredAt` and `recordsClearedAt` come from the handle you resolved
38
- * (name → `deriveHandleAccount` → decode). Returns the pass/fail plus the
41
+ * `registeredAt`, `recordsClearedAt` and `holderEpoch` come from the handle you
42
+ * resolved (name → `deriveHandleAccount` → decode). `holderEpoch` (H5 / PR
43
+ * #466, 0 if absent) folds into the staleness epoch so the gate matches the
44
+ * on-chain rule `attested_at >= max(registered_at, holder_epoch)`; it defaults
45
+ * to `0n` for backward compatibility. Returns the pass/fail plus the
39
46
  * attestations so a caller can also render WHICH signals matched. */
40
- export declare function handlePassesGate(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, gate: Gate): Promise<{
47
+ export declare function handlePassesGate(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, gate: Gate, holderEpoch?: bigint): Promise<{
41
48
  readonly passed: boolean;
42
49
  readonly attestations: HandleAttestation[];
43
50
  }>;
package/dist/gate.js CHANGED
@@ -7,8 +7,12 @@
7
7
  * The gate operates on the attestations {@link fetchAttestations} returns; the
8
8
  * staleness rule (an attestation only counts for the CURRENT owner —
9
9
  * `attested_at >= handle.registered_at`) is already baked into `HandleAttestation.stale`,
10
- * and this module always ignores stale ones. Nothing here trusts an off-chain
11
- * claim: every input is an on-chain, attestor-signed `Attestation` account.
10
+ * and this module always ignores stale ones. It likewise ignores any attestation
11
+ * flagged `HandleAttestation.holderMismatch` (H5 — a tokenized handle whose pNFT
12
+ * moved to a holder it was not issued to), so a gate caller that overlays
13
+ * {@link markHolderMismatch} onto the fetched set never admits an inherited
14
+ * badge. Nothing here trusts an off-chain claim: every input is an on-chain,
15
+ * attestor-signed `Attestation` account.
12
16
  */
13
17
  import { fetchAttestations, isHandleVerified, ATTESTATION_KIND_DNS, ATTESTATION_KIND_SOCIAL, ATTESTATION_KIND_X, ATTESTATION_KIND_DISCORD, ATTESTATION_KIND_GITHUB, ATTESTATION_KIND_TELEGRAM, } from "./attestation.js";
14
18
  /** Named platform kinds, re-exported so callers gate without magic numbers. */
@@ -21,18 +25,21 @@ export const GateKind = {
21
25
  telegram: ATTESTATION_KIND_TELEGRAM,
22
26
  };
23
27
  /** Pure check: do these (already-fetched) attestations satisfy the gate?
24
- * Stale attestations never count. */
28
+ * Stale and holder-mismatched (H5) attestations never count. */
25
29
  export function attestationsPassGate(attestations, gate) {
26
30
  if (gate === "any-verified")
27
31
  return isHandleVerified(attestations);
28
32
  const kinds = "kind" in gate ? [gate.kind] : gate.anyOf;
29
- return attestations.some((a) => !a.stale && kinds.includes(a.kind));
33
+ return attestations.some((a) => !a.stale && !a.holderMismatch && kinds.includes(a.kind));
30
34
  }
31
35
  /** Fetch a handle's attestations and test the gate in one call. `handleAccount`,
32
- * `registeredAt` and `recordsClearedAt` come from the handle you resolved
33
- * (name → `deriveHandleAccount` → decode). Returns the pass/fail plus the
36
+ * `registeredAt`, `recordsClearedAt` and `holderEpoch` come from the handle you
37
+ * resolved (name → `deriveHandleAccount` → decode). `holderEpoch` (H5 / PR
38
+ * #466, 0 if absent) folds into the staleness epoch so the gate matches the
39
+ * on-chain rule `attested_at >= max(registered_at, holder_epoch)`; it defaults
40
+ * to `0n` for backward compatibility. Returns the pass/fail plus the
34
41
  * attestations so a caller can also render WHICH signals matched. */
35
- export async function handlePassesGate(rpc, programId, handleAccount, registeredAt, recordsClearedAt, gate) {
36
- const attestations = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt);
42
+ export async function handlePassesGate(rpc, programId, handleAccount, registeredAt, recordsClearedAt, gate, holderEpoch = 0n) {
43
+ const attestations = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt, holderEpoch);
37
44
  return { passed: attestationsPassGate(attestations, gate), attestations };
38
45
  }
@@ -80,14 +80,16 @@ export interface GuardResult {
80
80
  * silently admitting an unverified caller. `onError: "deny"` instead resolves to
81
81
  * a clean `allowed: false` (fail-closed without a throw).
82
82
  *
83
- * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
84
- * timeout, 5xx, auth failure, a malformed response) it fabricates a
85
- * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
86
- * real access gate — anyone who can make your gate call fail (trivial: induce a
87
- * timeout) then bypasses it completely. It exists ONLY for soft, non-security
88
- * personalization where a missing verification must not degrade UX (e.g.
89
- * optionally showing a "verified" flourish). If the gate protects anything —
90
- * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
83
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN, and is NEVER acceptable on a real
84
+ * gate. On ANY error (network blip, timeout, 5xx, auth failure, a malformed
85
+ * response) it fabricates a `passed: true` decision and admits the caller
86
+ * UNVERIFIED — so anyone who can make your gate call fail (trivial: induce a
87
+ * timeout) bypasses it completely. That makes it strictly WORSE than no gate for
88
+ * anything it is supposed to protect: a route, a mint, an airdrop, a write, an
89
+ * allowlist. It exists ONLY for soft, non-security personalization where a
90
+ * missing verification must not degrade UX (e.g. optionally showing a "verified"
91
+ * flourish). Constructing a guard with it logs a one-time `console.warn`. If the
92
+ * decision gates ANYTHING, use the default `"throw"` or `"deny"` (fail-closed).
91
93
  *
92
94
  * @example
93
95
  * ```ts
@@ -81,14 +81,16 @@ export async function checkGate(cfg, subject, policy) {
81
81
  * silently admitting an unverified caller. `onError: "deny"` instead resolves to
82
82
  * a clean `allowed: false` (fail-closed without a throw).
83
83
  *
84
- * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
85
- * timeout, 5xx, auth failure, a malformed response) it fabricates a
86
- * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
87
- * real access gate — anyone who can make your gate call fail (trivial: induce a
88
- * timeout) then bypasses it completely. It exists ONLY for soft, non-security
89
- * personalization where a missing verification must not degrade UX (e.g.
90
- * optionally showing a "verified" flourish). If the gate protects anything —
91
- * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
84
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN, and is NEVER acceptable on a real
85
+ * gate. On ANY error (network blip, timeout, 5xx, auth failure, a malformed
86
+ * response) it fabricates a `passed: true` decision and admits the caller
87
+ * UNVERIFIED — so anyone who can make your gate call fail (trivial: induce a
88
+ * timeout) bypasses it completely. That makes it strictly WORSE than no gate for
89
+ * anything it is supposed to protect: a route, a mint, an airdrop, a write, an
90
+ * allowlist. It exists ONLY for soft, non-security personalization where a
91
+ * missing verification must not degrade UX (e.g. optionally showing a "verified"
92
+ * flourish). Constructing a guard with it logs a one-time `console.warn`. If the
93
+ * decision gates ANYTHING, use the default `"throw"` or `"deny"` (fail-closed).
92
94
  *
93
95
  * @example
94
96
  * ```ts
@@ -106,6 +108,10 @@ export async function checkGate(cfg, subject, policy) {
106
108
  */
107
109
  export function requireGate(cfg, policy, opts = {}) {
108
110
  const onError = opts.onError ?? "throw";
111
+ // Warn HARD (once) the moment a fail-open guard is built — `onError: "allow"`
112
+ // admits unverified callers on any error and must never guard anything real.
113
+ if (onError === "allow")
114
+ warnFailOpenOnce();
109
115
  return async (subject) => {
110
116
  try {
111
117
  const decision = await checkGate(cfg, subject, policy);
@@ -126,3 +132,16 @@ export function requireGate(cfg, policy, opts = {}) {
126
132
  }
127
133
  };
128
134
  }
135
+ /** Process-wide latch so the fail-open warning fires at most once, not on every
136
+ * guard construction. */
137
+ let failOpenWarned = false;
138
+ function warnFailOpenOnce() {
139
+ if (failOpenWarned)
140
+ return;
141
+ failOpenWarned = true;
142
+ // eslint-disable-next-line no-console
143
+ console.warn("[@x1id/resolve] requireGate({ onError: \"allow\" }) FAILS OPEN: on any gate-call " +
144
+ "error it admits the caller UNVERIFIED. Never use it to protect a route, mint, " +
145
+ "airdrop, write, or allowlist — use the default \"throw\" or \"deny\" instead. " +
146
+ "(This warning is for soft personalization-only uses; it logs once.)");
147
+ }
package/dist/index.d.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * // bytes of @x1id/resolve/wasm/x1_resolve_wasm.wasm — see the README for loading
8
8
  * const wasm = await WasmResolver.fromBytes(wasmBytes);
9
- * // The @handle registry is on X1 testnet today; X1NS domains are on mainnet.
10
- * const r = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
9
+ * // The @handle registry and X1NS domains are on X1 mainnet (testnet works the same).
10
+ * const r = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz", wasm });
11
11
  * const res = await r.resolve("@jack", { chain: "X1" });
12
12
  * // { name: "jack", namespace: "handle", address: "...", verification: "verified" }
13
13
  * ```
@@ -45,11 +45,13 @@ export * from "./register.js";
45
45
  export * from "./adminConfig.js";
46
46
  export * from "./lease.js";
47
47
  export * from "./accounts.js";
48
+ export * from "./pause.js";
48
49
  export * from "./x402.js";
49
50
  export * from "./recordWrite.js";
50
51
  export * from "./clearRecords.js";
51
52
  export * from "./commitReveal.js";
52
53
  export * from "./pnftTransfer.js";
54
+ export * from "./payment.js";
53
55
  export * from "./signin.js";
54
56
  export * from "./connect.js";
55
57
  export * from "./domainProof.js";
package/dist/index.js CHANGED
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * // bytes of @x1id/resolve/wasm/x1_resolve_wasm.wasm — see the README for loading
8
8
  * const wasm = await WasmResolver.fromBytes(wasmBytes);
9
- * // The @handle registry is on X1 testnet today; X1NS domains are on mainnet.
10
- * const r = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
9
+ * // The @handle registry and X1NS domains are on X1 mainnet (testnet works the same).
10
+ * const r = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz", wasm });
11
11
  * const res = await r.resolve("@jack", { chain: "X1" });
12
12
  * // { name: "jack", namespace: "handle", address: "...", verification: "verified" }
13
13
  * ```
@@ -52,11 +52,13 @@ export * from "./lease.js";
52
52
  // the kind of drift docs/record-trust.md's "one implementation" rule
53
53
  // exists to prevent).
54
54
  export * from "./accounts.js";
55
+ export * from "./pause.js";
55
56
  export * from "./x402.js";
56
57
  export * from "./recordWrite.js";
57
58
  export * from "./clearRecords.js";
58
59
  export * from "./commitReveal.js";
59
60
  export * from "./pnftTransfer.js";
61
+ export * from "./payment.js";
60
62
  export * from "./signin.js";
61
63
  export * from "./connect.js";
62
64
  export * from "./domainProof.js";
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Emergency lockdown (global pause) — the off-chain READER for the registry's
3
+ * trailing `Config` pause region, plus transport-neutral builders for the two
4
+ * instructions that write it. The on-chain side lives in
5
+ * `programs/x1-handles/src/{state,lib}.rs` (PR feat/emergency-pause); this
6
+ * module only READS the region and BUILDS the instructions — it never changes
7
+ * the on-chain authority rules (pause = admin OR any guardian; unpause = admin
8
+ * only; both enforced by signature on-chain).
9
+ *
10
+ * # The pause region (raw byte layout, mirrored from `Config` in state.rs)
11
+ *
12
+ * It is a TRAILING region appended past the base `Config` struct at a FIXED raw
13
+ * byte offset — NOT a Borsh field, so it is read by absolute offset, never by
14
+ * walking the struct. Relative to the registry program's `Config::LEN` (445):
15
+ *
16
+ * [445] paused: u8 (1 = locked down, 0 = open)
17
+ * [446..454] paused_at: i64 LE (unix secs the lockdown last turned ON; 0 = never)
18
+ * [454..614] pause_guardians: 5 × Pubkey (32 bytes each; all-zero = unused slot)
19
+ *
20
+ * Region length 169; the account grows to 614 bytes on first write. A `Config`
21
+ * created before this region existed (or any account shorter than 614 bytes)
22
+ * reads as NOT paused with NO guardians — exactly as the program's
23
+ * `require_not_locked` treats it. This reader therefore NEVER throws on a short
24
+ * account; it returns the open state.
25
+ *
26
+ * Hand-rolled like the rest of this package — no Anchor client, no
27
+ * `@solana/web3.js` import (it stays an optional peer). Builders return a
28
+ * transport-neutral {@link BuiltInstruction}; adapt to web3.js with the
29
+ * two-line snippet in `delegate.ts`'s module docs.
30
+ */
31
+ import type { AddressLike, BuiltInstruction } from "./delegate.js";
32
+ /** Raw byte offset of the pause region — the registry program's `Config::LEN`.
33
+ * The region is read by this ABSOLUTE offset, never by walking Borsh fields. */
34
+ export declare const PAUSE_EXT_OFFSET = 445;
35
+ /** Number of guardian slots in the region (mirrors `Config::PAUSE_GUARDIAN_SLOTS`). */
36
+ export declare const PAUSE_GUARDIAN_SLOTS = 5;
37
+ /** Length of the region: paused(1) + paused_at(8) + 5 × Pubkey(32) = 169. */
38
+ export declare const PAUSE_EXT_LEN: number;
39
+ /** First byte PAST the region. An account shorter than this has NO region. */
40
+ export declare const PAUSE_EXT_END: number;
41
+ /** Decoded pause state of the registry `Config` account. */
42
+ export interface PauseState {
43
+ /** `true` iff the lockdown flag is set (byte 445 === 1). */
44
+ readonly paused: boolean;
45
+ /** Unix seconds the lockdown last turned on; `0n` when never / short account. */
46
+ readonly pausedAt: bigint;
47
+ /** The configured guardian pubkeys (base58), with all-zero (unused) slots dropped. */
48
+ readonly guardians: string[];
49
+ }
50
+ /**
51
+ * Decode the pause region from a registry `Config` account's raw data.
52
+ *
53
+ * Robust by contract: an account whose data is SHORTER than {@link PAUSE_EXT_END}
54
+ * (614) has no region and reads as `{ paused: false, pausedAt: 0n, guardians: [] }`
55
+ * — the same "absent region ⇒ open" rule the on-chain `require_not_locked`
56
+ * enforces. Never throws on a short or malformed-length account.
57
+ */
58
+ export declare function readPauseState(data: Uint8Array): PauseState;
59
+ /** `set_lockdown(paused: bool)` — Anchor discriminator (final, program author). */
60
+ export declare const SET_LOCKDOWN_DISCRIMINATOR: Uint8Array;
61
+ /** `set_pause_guardians(guardians: [Pubkey;5])` — Anchor discriminator (final). */
62
+ export declare const SET_PAUSE_GUARDIANS_DISCRIMINATOR: Uint8Array;
63
+ export interface SetLockdownParams {
64
+ readonly programId: AddressLike;
65
+ /** The signer — any guardian OR `Config.admin` for a PAUSE; `Config.admin`
66
+ * ONLY for an UNPAUSE (the program enforces this by signature — the builder
67
+ * does not, since it cannot know the account's admin/guardians). */
68
+ readonly caller: AddressLike;
69
+ /** The `["config"]` PDA (writable). */
70
+ readonly config: AddressLike;
71
+ /** The `["market_config"]` PDA (writable) — `set_lockdown` mirrors the flag
72
+ * onto MarketConfig so listings/offers/auctions stop together. */
73
+ readonly marketConfig: AddressLike;
74
+ /** `true` = lock down, `false` = reopen. */
75
+ readonly paused: boolean;
76
+ }
77
+ /**
78
+ * Build `set_lockdown(paused)`. Accounts IN ORDER (mirrors lib.rs):
79
+ * 0 caller (signer, writable)
80
+ * 1 config (writable) — ["config"]
81
+ * 2 market_config (writable) — ["market_config"]
82
+ * 3 system_program
83
+ * data = disc(8) ‖ paused(1 byte: 1 = lock / 0 = unlock).
84
+ */
85
+ export declare function buildSetLockdownIx(p: SetLockdownParams): BuiltInstruction;
86
+ export interface SetPauseGuardiansParams {
87
+ readonly programId: AddressLike;
88
+ /** `Config.admin` — the ONLY key the program accepts (admin-only). Signs; writable. */
89
+ readonly admin: AddressLike;
90
+ /** The `["config"]` PDA (writable). */
91
+ readonly config: AddressLike;
92
+ /** Exactly 5 guardian pubkeys in slot order; a zero address (or the system
93
+ * program) marks an unused slot. */
94
+ readonly guardians: ReadonlyArray<AddressLike>;
95
+ }
96
+ /**
97
+ * Build `set_pause_guardians([Pubkey;5])`. Admin-only. Accounts IN ORDER:
98
+ * 0 admin (signer, writable)
99
+ * 1 config (writable) — ["config"]
100
+ * 2 system_program
101
+ * data = disc(8) ‖ 5 × 32 raw pubkey bytes in order (fixed `[Pubkey;5]`, NO
102
+ * length prefix). Pass fewer than 5 to zero-fill the trailing slots.
103
+ */
104
+ export declare function buildSetPauseGuardiansIx(p: SetPauseGuardiansParams): BuiltInstruction;
package/dist/pause.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Emergency lockdown (global pause) — the off-chain READER for the registry's
3
+ * trailing `Config` pause region, plus transport-neutral builders for the two
4
+ * instructions that write it. The on-chain side lives in
5
+ * `programs/x1-handles/src/{state,lib}.rs` (PR feat/emergency-pause); this
6
+ * module only READS the region and BUILDS the instructions — it never changes
7
+ * the on-chain authority rules (pause = admin OR any guardian; unpause = admin
8
+ * only; both enforced by signature on-chain).
9
+ *
10
+ * # The pause region (raw byte layout, mirrored from `Config` in state.rs)
11
+ *
12
+ * It is a TRAILING region appended past the base `Config` struct at a FIXED raw
13
+ * byte offset — NOT a Borsh field, so it is read by absolute offset, never by
14
+ * walking the struct. Relative to the registry program's `Config::LEN` (445):
15
+ *
16
+ * [445] paused: u8 (1 = locked down, 0 = open)
17
+ * [446..454] paused_at: i64 LE (unix secs the lockdown last turned ON; 0 = never)
18
+ * [454..614] pause_guardians: 5 × Pubkey (32 bytes each; all-zero = unused slot)
19
+ *
20
+ * Region length 169; the account grows to 614 bytes on first write. A `Config`
21
+ * created before this region existed (or any account shorter than 614 bytes)
22
+ * reads as NOT paused with NO guardians — exactly as the program's
23
+ * `require_not_locked` treats it. This reader therefore NEVER throws on a short
24
+ * account; it returns the open state.
25
+ *
26
+ * Hand-rolled like the rest of this package — no Anchor client, no
27
+ * `@solana/web3.js` import (it stays an optional peer). Builders return a
28
+ * transport-neutral {@link BuiltInstruction}; adapt to web3.js with the
29
+ * two-line snippet in `delegate.ts`'s module docs.
30
+ */
31
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
32
+ /** Raw byte offset of the pause region — the registry program's `Config::LEN`.
33
+ * The region is read by this ABSOLUTE offset, never by walking Borsh fields. */
34
+ export const PAUSE_EXT_OFFSET = 445;
35
+ /** Number of guardian slots in the region (mirrors `Config::PAUSE_GUARDIAN_SLOTS`). */
36
+ export const PAUSE_GUARDIAN_SLOTS = 5;
37
+ /** Length of the region: paused(1) + paused_at(8) + 5 × Pubkey(32) = 169. */
38
+ export const PAUSE_EXT_LEN = 1 + 8 + 32 * PAUSE_GUARDIAN_SLOTS;
39
+ /** First byte PAST the region. An account shorter than this has NO region. */
40
+ export const PAUSE_EXT_END = PAUSE_EXT_OFFSET + PAUSE_EXT_LEN; // 614
41
+ /**
42
+ * Decode the pause region from a registry `Config` account's raw data.
43
+ *
44
+ * Robust by contract: an account whose data is SHORTER than {@link PAUSE_EXT_END}
45
+ * (614) has no region and reads as `{ paused: false, pausedAt: 0n, guardians: [] }`
46
+ * — the same "absent region ⇒ open" rule the on-chain `require_not_locked`
47
+ * enforces. Never throws on a short or malformed-length account.
48
+ */
49
+ export function readPauseState(data) {
50
+ if (data.length < PAUSE_EXT_END) {
51
+ return { paused: false, pausedAt: 0n, guardians: [] };
52
+ }
53
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
54
+ const paused = data[PAUSE_EXT_OFFSET] === 1;
55
+ // paused_at: i64 LE at offset 446.
56
+ const pausedAt = dv.getBigInt64(PAUSE_EXT_OFFSET + 1, true);
57
+ // Five 32-byte pubkeys from offset 454; drop all-zero (unused) slots.
58
+ const guardians = [];
59
+ for (let i = 0; i < PAUSE_GUARDIAN_SLOTS; i++) {
60
+ const o = PAUSE_EXT_OFFSET + 9 + 32 * i;
61
+ const slot = data.subarray(o, o + 32);
62
+ let allZero = true;
63
+ for (let j = 0; j < slot.length; j++) {
64
+ if (slot[j] !== 0) {
65
+ allZero = false;
66
+ break;
67
+ }
68
+ }
69
+ if (allZero)
70
+ continue;
71
+ guardians.push(encodeBase58(slot));
72
+ }
73
+ return { paused, pausedAt, guardians };
74
+ }
75
+ // ===== Instruction builders ================================================
76
+ //
77
+ // Anchor instruction data = 8-byte discriminator ‖ Borsh-encoded args. The
78
+ // discriminators are the program author's final values (pinned by a byte test
79
+ // in sdk/test/pause.test.js), NOT re-derived here, so this module needs no
80
+ // sha256. Derive the PDAs with your runtime's `findProgramAddress`:
81
+ // config = ["config"] under the registry program
82
+ // market_config = ["market_config"] under the registry program
83
+ /** `set_lockdown(paused: bool)` — Anchor discriminator (final, program author). */
84
+ export const SET_LOCKDOWN_DISCRIMINATOR = Uint8Array.from([
85
+ 50, 46, 87, 85, 162, 169, 232, 61,
86
+ ]);
87
+ /** `set_pause_guardians(guardians: [Pubkey;5])` — Anchor discriminator (final). */
88
+ export const SET_PAUSE_GUARDIANS_DISCRIMINATOR = Uint8Array.from([
89
+ 6, 7, 64, 90, 220, 50, 172, 166,
90
+ ]);
91
+ /** The System Program id — the final account on both instructions. Not
92
+ * re-exported (adminMarket.ts already owns that public name); internal here. */
93
+ const SYSTEM_PROGRAM_ID = "11111111111111111111111111111111";
94
+ function toBytes32(v, what) {
95
+ if (typeof v === "string") {
96
+ const b = decodeBase58_32(v);
97
+ if (!b)
98
+ throw new Error(`${what} is not a valid base58 address`);
99
+ return b;
100
+ }
101
+ if (v.length !== 32)
102
+ throw new Error(`${what} must be exactly 32 bytes`);
103
+ return v;
104
+ }
105
+ function toBase58(v, what) {
106
+ return encodeBase58(toBytes32(v, what));
107
+ }
108
+ /**
109
+ * Build `set_lockdown(paused)`. Accounts IN ORDER (mirrors lib.rs):
110
+ * 0 caller (signer, writable)
111
+ * 1 config (writable) — ["config"]
112
+ * 2 market_config (writable) — ["market_config"]
113
+ * 3 system_program
114
+ * data = disc(8) ‖ paused(1 byte: 1 = lock / 0 = unlock).
115
+ */
116
+ export function buildSetLockdownIx(p) {
117
+ const data = new Uint8Array(8 + 1);
118
+ data.set(SET_LOCKDOWN_DISCRIMINATOR, 0);
119
+ data[8] = p.paused ? 1 : 0;
120
+ const keys = [
121
+ { pubkey: toBase58(p.caller, "caller"), isSigner: true, isWritable: true },
122
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: true },
123
+ { pubkey: toBase58(p.marketConfig, "marketConfig"), isSigner: false, isWritable: true },
124
+ { pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false },
125
+ ];
126
+ return { programId: toBase58(p.programId, "programId"), keys, data };
127
+ }
128
+ /**
129
+ * Build `set_pause_guardians([Pubkey;5])`. Admin-only. Accounts IN ORDER:
130
+ * 0 admin (signer, writable)
131
+ * 1 config (writable) — ["config"]
132
+ * 2 system_program
133
+ * data = disc(8) ‖ 5 × 32 raw pubkey bytes in order (fixed `[Pubkey;5]`, NO
134
+ * length prefix). Pass fewer than 5 to zero-fill the trailing slots.
135
+ */
136
+ export function buildSetPauseGuardiansIx(p) {
137
+ if (p.guardians.length > PAUSE_GUARDIAN_SLOTS) {
138
+ throw new Error(`at most ${PAUSE_GUARDIAN_SLOTS} guardians, got ${p.guardians.length}`);
139
+ }
140
+ const data = new Uint8Array(8 + 32 * PAUSE_GUARDIAN_SLOTS);
141
+ data.set(SET_PAUSE_GUARDIANS_DISCRIMINATOR, 0);
142
+ for (let i = 0; i < p.guardians.length; i++) {
143
+ data.set(toBytes32(p.guardians[i], `guardians[${i}]`), 8 + 32 * i);
144
+ }
145
+ // Slots past guardians.length stay zero = unused.
146
+ const keys = [
147
+ { pubkey: toBase58(p.admin, "admin"), isSigner: true, isWritable: true },
148
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: true },
149
+ { pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false },
150
+ ];
151
+ return { programId: toBase58(p.programId, "programId"), keys, data };
152
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The ONE payment-resolution rule (W16, owner ruling 2026-10-07):
3
+ * **verified-record-else-owner, uniform on every payment surface.**
4
+ *
5
+ * A handle's per-chain address `Record` can be written by a record-editing
6
+ * DELEGATE, not just the owner — so an UNVERIFIED record must never receive
7
+ * money: a malicious delegate could silently redirect payments. On-chain
8
+ * VERIFICATION (`verify_record_svm` — the recorded address itself co-signed)
9
+ * is the guard, so the rule every pay surface shares is:
10
+ *
11
+ * 1. a LIVE, VERIFIED record for the payment chain → that record's address
12
+ * (`basis: "verified-record"`). Staleness is judged first: a record from
13
+ * a previous registration epoch or before a `clear_records` wipe never
14
+ * counts, verified bit or not (docs/record-trust.md — the `stale` flag
15
+ * records.ts already computes).
16
+ * 2. otherwise → the handle's CURRENT AUTHORITY (`basis: "owner"`):
17
+ * `Handle.owner` untokenized, the pNFT holder once tokenized. An
18
+ * unverified record is IGNORED, not an error.
19
+ * 3. no authority known either (e.g. a tokenized name whose holder could
20
+ * not be located) → `null`: there is no honest payment address.
21
+ *
22
+ * Every surface that shows a "pay @name" flow must also SHOW the basis next
23
+ * to the resolved address before the user confirms ("verified X1 record" vs
24
+ * "owner wallet") — the rule is only safe when it is visible.
25
+ */
26
+ import type { Chain } from "./types.js";
27
+ /** Why `address` is the one to pay: a live on-chain-verified record for the
28
+ * chain, or the handle's current authority (owner / NFT holder). */
29
+ export type PaymentBasis = "verified-record" | "owner";
30
+ export interface PaymentResolution {
31
+ /** The address a payment to this name must target. */
32
+ readonly address: string;
33
+ readonly basis: PaymentBasis;
34
+ }
35
+ /** The slice of a decoded record the rule needs — satisfied structurally by
36
+ * the SDK's `HandleRecord` (records.ts) and the app's `DecodedRecord`. */
37
+ export interface PaymentRecordLike {
38
+ /** Which chain the record's coin_type maps to (null = unknown coin type). */
39
+ readonly chain: Chain | null;
40
+ /** Human-readable address stored in the record. */
41
+ readonly address: string;
42
+ /** `Record.verified` as decoded — already forced false for stale records by
43
+ * both decoders, but `stale` is re-checked here regardless. */
44
+ readonly verified: boolean;
45
+ /** The staleness verdict (updated_at vs registered_at / records_cleared_at). */
46
+ readonly stale: boolean;
47
+ }
48
+ /**
49
+ * Apply the rule. `records` may be the FULL decoded list (stale ones
50
+ * included — they are re-excluded here, never trusted on the `verified` bit
51
+ * alone). `authority` is the handle's CURRENT authority — `Handle.owner`
52
+ * untokenized, the pNFT holder tokenized — or `null` when it is unknown
53
+ * (tokenized, holder not found): then a missing verified record yields
54
+ * `null`, the honest "no payment address exists".
55
+ */
56
+ export declare function paymentAddressFor(records: readonly PaymentRecordLike[], authority: string | null, chain?: Chain): PaymentResolution | null;
57
+ /** The user-facing basis line, so every surface words it identically. */
58
+ export declare function paymentBasisLabel(basis: PaymentBasis, chain?: Chain): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The ONE payment-resolution rule (W16, owner ruling 2026-10-07):
3
+ * **verified-record-else-owner, uniform on every payment surface.**
4
+ *
5
+ * A handle's per-chain address `Record` can be written by a record-editing
6
+ * DELEGATE, not just the owner — so an UNVERIFIED record must never receive
7
+ * money: a malicious delegate could silently redirect payments. On-chain
8
+ * VERIFICATION (`verify_record_svm` — the recorded address itself co-signed)
9
+ * is the guard, so the rule every pay surface shares is:
10
+ *
11
+ * 1. a LIVE, VERIFIED record for the payment chain → that record's address
12
+ * (`basis: "verified-record"`). Staleness is judged first: a record from
13
+ * a previous registration epoch or before a `clear_records` wipe never
14
+ * counts, verified bit or not (docs/record-trust.md — the `stale` flag
15
+ * records.ts already computes).
16
+ * 2. otherwise → the handle's CURRENT AUTHORITY (`basis: "owner"`):
17
+ * `Handle.owner` untokenized, the pNFT holder once tokenized. An
18
+ * unverified record is IGNORED, not an error.
19
+ * 3. no authority known either (e.g. a tokenized name whose holder could
20
+ * not be located) → `null`: there is no honest payment address.
21
+ *
22
+ * Every surface that shows a "pay @name" flow must also SHOW the basis next
23
+ * to the resolved address before the user confirms ("verified X1 record" vs
24
+ * "owner wallet") — the rule is only safe when it is visible.
25
+ */
26
+ /**
27
+ * Apply the rule. `records` may be the FULL decoded list (stale ones
28
+ * included — they are re-excluded here, never trusted on the `verified` bit
29
+ * alone). `authority` is the handle's CURRENT authority — `Handle.owner`
30
+ * untokenized, the pNFT holder tokenized — or `null` when it is unknown
31
+ * (tokenized, holder not found): then a missing verified record yields
32
+ * `null`, the honest "no payment address exists".
33
+ */
34
+ export function paymentAddressFor(records, authority, chain = "X1") {
35
+ const rec = records.find((r) => r.chain === chain && !r.stale && r.verified);
36
+ if (rec)
37
+ return { address: rec.address, basis: "verified-record" };
38
+ if (authority !== null)
39
+ return { address: authority, basis: "owner" };
40
+ return null;
41
+ }
42
+ /** The user-facing basis line, so every surface words it identically. */
43
+ export function paymentBasisLabel(basis, chain = "X1") {
44
+ return basis === "verified-record" ? `verified ${chain} record` : "owner wallet";
45
+ }
package/dist/scoped.d.ts CHANGED
@@ -68,16 +68,21 @@ export declare function namespaceIsActive(data: Uint8Array): boolean;
68
68
  * dependency), and so tests can supply a deterministic fake.
69
69
  */
70
70
  export interface ScopedHandleDeriver {
71
- /** The `["handle", label, name]` PDA under `programId`, base58. */
71
+ /** The `["handle", [label_len], label, name]` PDA under `programId`, base58
72
+ * (H1: the label is length-prefixed so a scoped name never collides with a
73
+ * bare `@<label><name>`). */
72
74
  scopedHandleKey(label: string, name: string, programId: string): Promise<string> | string;
73
75
  }
74
76
  /**
75
77
  * Build the default scoped-handle deriver using `@solana/web3.js`.
76
78
  *
77
- * Seeds are `["handle", label, name]` under the registry program — exactly the
78
- * order `tools/api`'s `scoped_handle_pda` and the program's
79
- * `register_under_namespace` use, so the derived PDA is the account the name
80
- * actually lives at. Prefers an INJECTED web3 module (robust under `file:` /
79
+ * Seeds are `["handle", [label_len], label, name]` under the registry program
80
+ * (H1, 2026-10-06: the label is LENGTH-PREFIXED with a single byte = its UTF-8
81
+ * byte length, so a scoped `name.tld` never derives the same address as a bare
82
+ * `@<label><name>` — e.g. `ck.ja` ≠ `@jack`). This MUST match byte-for-byte the
83
+ * program's `verify_handle_pda_core` / `register_under_namespace` and
84
+ * `tools/api`'s `scoped_handle_pda`, or resolution breaks. Prefers an INJECTED
85
+ * web3 module (robust under `file:` /
81
86
  * `npm link`, where a bare specifier resolves from THIS package's realpath, not
82
87
  * the consumer's); falls back to a dynamic import for a normal install.
83
88
  */
package/dist/scoped.js CHANGED
@@ -93,10 +93,13 @@ export function namespaceIsActive(data) {
93
93
  /**
94
94
  * Build the default scoped-handle deriver using `@solana/web3.js`.
95
95
  *
96
- * Seeds are `["handle", label, name]` under the registry program — exactly the
97
- * order `tools/api`'s `scoped_handle_pda` and the program's
98
- * `register_under_namespace` use, so the derived PDA is the account the name
99
- * actually lives at. Prefers an INJECTED web3 module (robust under `file:` /
96
+ * Seeds are `["handle", [label_len], label, name]` under the registry program
97
+ * (H1, 2026-10-06: the label is LENGTH-PREFIXED with a single byte = its UTF-8
98
+ * byte length, so a scoped `name.tld` never derives the same address as a bare
99
+ * `@<label><name>` — e.g. `ck.ja` ≠ `@jack`). This MUST match byte-for-byte the
100
+ * program's `verify_handle_pda_core` / `register_under_namespace` and
101
+ * `tools/api`'s `scoped_handle_pda`, or resolution breaks. Prefers an INJECTED
102
+ * web3 module (robust under `file:` /
100
103
  * `npm link`, where a bare specifier resolves from THIS package's realpath, not
101
104
  * the consumer's); falls back to a dynamic import for a normal install.
102
105
  */
@@ -119,7 +122,10 @@ export async function makeScopedHandleDeriver(web3Module) {
119
122
  return {
120
123
  scopedHandleKey(label, name, programId) {
121
124
  const program = new web3.PublicKey(programId);
122
- const [key] = web3.PublicKey.findProgramAddressSync([handleSeed, enc.encode(label), enc.encode(name)], program);
125
+ // H1: length-prefix the label with its UTF-8 BYTE length (matches Rust
126
+ // `label.len() as u8`; labels are a–z0–9- ≤ 16 bytes so a u8 suffices).
127
+ const labelBytes = enc.encode(label);
128
+ const [key] = web3.PublicKey.findProgramAddressSync([handleSeed, Uint8Array.of(labelBytes.length), labelBytes, enc.encode(name)], program);
123
129
  return key.toBase58();
124
130
  },
125
131
  };