@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/README.md CHANGED
@@ -12,7 +12,7 @@ answered, and a mixed shape is refused rather than guessed.
12
12
  import { createResolver, WasmResolver } from "@x1id/resolve";
13
13
 
14
14
  const wasm = await WasmResolver.fromBytes(/* the module bytes, see below */);
15
- const x1id = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
15
+ const x1id = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz", wasm });
16
16
 
17
17
  const r = await x1id.resolve("@jack");
18
18
  // { name: "jack", namespace: "handle", address: "H8Fs…", chain: "X1",
@@ -23,11 +23,11 @@ await x1id.resolve("@jack.x1"); // throws ResolveError { code: "ambiguous" }
23
23
  await x1id.reverse("H8Fs…"); // "nike" — the address's primary @handle, or null
24
24
  ```
25
25
 
26
- > **Which RPC?** The `@handle` registry is deployed on **X1 testnet** today
27
- > (`https://rpc.testnet.x1.xyz`). X1NS domains live on **mainnet**
28
- > (`https://rpc.mainnet.x1.xyz`). Point the resolver at the chain that holds
29
- > the names you want; on an RPC where the registry is not deployed a handle is
30
- > an honest `not-found`, never a fabricated address.
26
+ > **Which RPC?** The `@handle` registry and X1NS domains are both on **X1
27
+ > mainnet** (`https://rpc.mainnet.x1.xyz`) — point the resolver there. X1
28
+ > testnet (`https://rpc.testnet.x1.xyz`) runs the same program and the SDK
29
+ > works against it identically for development. On an RPC where the registry
30
+ > is not deployed a handle is an honest `not-found`, never a fabricated address.
31
31
 
32
32
  ## Where this code lives
33
33
 
@@ -64,7 +64,7 @@ const require = createRequire(import.meta.url);
64
64
  const wasmPath = require.resolve("@x1id/resolve/wasm/x1_resolve_wasm.wasm");
65
65
  const wasm = await WasmResolver.fromBytes(readFileSync(wasmPath));
66
66
 
67
- const x1id = createResolver({ rpcUrl: "https://rpc.testnet.x1.xyz", wasm });
67
+ const x1id = createResolver({ rpcUrl: "https://rpc.mainnet.x1.xyz", wasm });
68
68
  ```
69
69
 
70
70
  Node ≥ 20.6 can use `import.meta.resolve` instead of `createRequire`:
@@ -139,7 +139,7 @@ const wasm = await WasmResolver.fromBytes(/* module bytes */);
139
139
  const scopedHandleDeriver = await makeScopedHandleDeriver(web3);
140
140
 
141
141
  const x1id = createResolver({
142
- rpcUrl: "https://rpc.testnet.x1.xyz",
142
+ rpcUrl: "https://rpc.mainnet.x1.xyz",
143
143
  wasm,
144
144
  scopedHandleDeriver,
145
145
  });
@@ -366,18 +366,30 @@ export async function POST(req: Request) { // any server runtime with
366
366
  }
367
367
  ```
368
368
 
369
- Fail-closed by default (re-throws if the decision can't be obtained; pass
370
- `{ onError: "allow" }` to fail-open). `checkGate()` is the lower-level client for
371
- the full decision; pass a stored policy's `gp_…` id as the policy to reuse a named
372
- policy. The hosted check is keyed/metered — see `services/dev-api`'s README.
369
+ Fail-closed by default (re-throws if the decision can't be obtained). `onError:
370
+ "deny"` fails closed without throwing (clean `allowed: false`). `checkGate()` is
371
+ the lower-level client for the full decision; pass a stored policy's `gp_…` id as
372
+ the policy to reuse a named policy. The hosted check is keyed/metered — see
373
+ `services/dev-api`'s README.
374
+
375
+ > **⚠️ Never use `onError: "allow"` on a real gate.** It FAILS OPEN: on *any*
376
+ > error — a network blip, a timeout, a 5xx, an auth failure, a malformed
377
+ > response — it fabricates a `passed: true` decision and admits the caller
378
+ > **unverified**. Anyone who can make your gate call fail (trivially: induce a
379
+ > timeout) then bypasses the gate completely, so it is worse than no gate at all
380
+ > for anything it protects — a route, a mint, an airdrop, a write, an allowlist.
381
+ > It exists ONLY for soft, non-security personalization where a missing
382
+ > verification must not degrade UX (e.g. optionally showing a "verified"
383
+ > flourish). Using it logs a one-time `console.warn`. If the decision gates
384
+ > anything, use the default `"throw"` or `"deny"`.
373
385
 
374
386
  ## What works today
375
387
 
376
388
  | | Status |
377
389
  |---|---|
378
390
  | X1NS resolution (`.x1/.xnt/.xen`) | ✅ mainnet |
379
- | `@handle` resolution | ✅ on any RPC where the registry is deployed — **X1 testnet today**; mainnet on deploy |
380
- | Reverse lookup | ✅ on-chain `@handle` primary (testnet today) with X1NS primary-domain fallback (mainnet); X1NS results are `<domainAccount>.<tld>`, not a label |
391
+ | `@handle` resolution | ✅ **X1 mainnet** (and any RPC where the registry is deployed, incl. testnet) |
392
+ | Reverse lookup | ✅ on-chain `@handle` primary with X1NS primary-domain fallback; X1NS results are `<domainAccount>.<tld>`, not a label |
381
393
  | Per-chain ETH/BTC records | ✅ read with the staleness rule enforced structurally |
382
394
  | Control proofs (prove you control a `@handle`) | ✅ `createControlChallenge` / `verifyControlProof` — spec: `docs/control-proof.md` |
383
395
  | Records read (per-chain, staleness-enforced) | ✅ `fetchRecords` |
@@ -508,7 +520,7 @@ if (agent.manifest) {
508
520
  const result = validateManifest(raw, {
509
521
  expectedName: "payday-bot",
510
522
  expectedHandlePda: handleAccount,
511
- expectedNetwork: "testnet",
523
+ expectedNetwork: "mainnet",
512
524
  addressRecords: liveRecords(records), // "the chain wins" cross-check
513
525
  });
514
526
  if (result.ok) {
@@ -575,7 +587,7 @@ built/verified — that needs real Solana parsing, so it lives in the
575
587
  [x1id MCP server](../mcp), not here): [`docs/x402.md`](../docs/x402.md).
576
588
 
577
589
  ```ts
578
- import { buildX402Requirements, X1_TESTNET_NETWORK } from "@x1id/resolve";
590
+ import { buildX402Requirements, X1_MAINNET_NETWORK } from "@x1id/resolve";
579
591
 
580
592
  // server-side: one accepts[] entry for a 402 response
581
593
  const requirements = buildX402Requirements({
@@ -583,6 +595,8 @@ const requirements = buildX402Requirements({
583
595
  asset: usdcMint,
584
596
  amount: "1000000", // smallest units, decimal string
585
597
  buyer: buyerAddress, // x1id v1 is self-sponsored: buyer pays their own fee, no facilitator
598
+ network: X1_MAINNET_NETWORK, // defaults to mainnet; a server on another cluster
599
+ // should pass x402NetworkForGenesis(await getGenesisHash)
586
600
  memo: "invoice-42",
587
601
  });
588
602
  ```
@@ -592,6 +606,12 @@ or verify a transaction (that needs `@solana/web3.js`, deliberately kept out
592
606
  of this zero-dependency package). Use the MCP server's `prepare_x402_payment`
593
607
  / `verify_x402_payment` tools for that.
594
608
 
609
+ > **Buyers: wait for `finalized`.** The x1id resource server verifies the
610
+ > payment transaction at **finalized** commitment, not `confirmed` — a buyer
611
+ > that submits its payment and retries the request while the tx is only
612
+ > `confirmed` gets a `402 "payment not yet confirmed; finalize it and retry"`.
613
+ > Poll the signature to `finalized` before the retry.
614
+
595
615
  ## Errors
596
616
 
597
617
  `ResolveError` carries a `code` a UI can branch on:
@@ -623,7 +643,7 @@ that is a homograph vector (`@аlice` with a Cyrillic `а` renders identically t
623
643
  createResolver(config: ResolverConfig): Resolver
624
644
 
625
645
  interface ResolverConfig {
626
- rpcUrl: string; // any X1 RPC (testnet for @handles today)
646
+ rpcUrl: string; // any X1 RPC (mainnet for @handles; testnet works the same)
627
647
  wasm: WasmResolver; // required — derivation runs in WASM, no JS fallback
628
648
  fetchImpl?: typeof fetch; // custom transport (tests, proxies)
629
649
  cacheTtlMs?: number; // default 30_000; 0 disables (resolve() only)
@@ -9,10 +9,7 @@
9
9
  */
10
10
  import { type Verification } from "./types.js";
11
11
  import type { WasmResolver } from "./wasm.js";
12
- /** The @handle registry program on X1. Deployed on testnet today; the same id
13
- * is used on mainnet once deployed. Override via `handleProgramId` to point at
14
- * a different deployment. */
15
- export declare const DEFAULT_HANDLE_PROGRAM = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
12
+ export declare const DEFAULT_HANDLE_PROGRAM: string;
16
13
  export declare const TOKEN_ACCOUNT_MIN_LEN = 72;
17
14
  export declare const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
18
15
  /** The fields of a `Handle` account readers need. */
@@ -34,6 +31,16 @@ export interface ParsedHandle {
34
31
  * of) the `registeredAt` epoch rule — see docs/record-trust.md.
35
32
  */
36
33
  readonly recordsClearedAt: bigint;
34
+ /**
35
+ * `Handle.holder_epoch` (0 if absent/never bumped) — H5 / PR #466. Bumped to
36
+ * `now + 1` ONLY by `buy_handle`'s tokenized branch (a NATIVE pNFT ownership
37
+ * change); an EXTERNAL marketplace trade of the pNFT does NOT bump it. Read
38
+ * from the fixed raw byte offset 255 (discriminator included); an account
39
+ * shorter than 263 bytes reads as 0. The on-chain attestation gate requires
40
+ * `attested_at >= max(registered_at, holder_epoch)`, so it is a THIRD
41
+ * independent staleness anchor for attestations — see attestation.ts.
42
+ */
43
+ readonly holderEpoch: bigint;
37
44
  /** Set when the handle is tokenized (NFT extension present). */
38
45
  readonly nftMint: Uint8Array | null;
39
46
  }
package/dist/accounts.js CHANGED
@@ -9,10 +9,20 @@
9
9
  */
10
10
  import { ResolveError } from "./types.js";
11
11
  import { encodeBase58 } from "./base58.js";
12
- /** The @handle registry program on X1. Deployed on testnet today; the same id
13
- * is used on mainnet once deployed. Override via `handleProgramId` to point at
14
- * a different deployment. */
15
- export const DEFAULT_HANDLE_PROGRAM = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
12
+ /** The @handle registry program on X1 — the TESTNET deployment, which is the
13
+ * default on every surface today. Deployed with the same keypair on mainnet,
14
+ * so the id is unchanged there by default; a future mainnet-only deployment
15
+ * can be pointed at per-call via `handleProgramId`, or globally (for Node /
16
+ * server consumers that construct the resolver without an explicit id) by
17
+ * setting `X1_HANDLE_PROGRAM_ID` in the environment.
18
+ *
19
+ * Read through `globalThis.process` (not a bare `process.env`) so this still
20
+ * compiles in the @types/node-free SDK build and is simply `undefined` in the
21
+ * browser — identical testnet behaviour everywhere until the env var is set at
22
+ * cutover. Same pattern as `signin.ts`. */
23
+ const TESTNET_HANDLE_PROGRAM = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
24
+ const ENV_HANDLE_PROGRAM = globalThis.process?.env?.X1_HANDLE_PROGRAM_ID;
25
+ export const DEFAULT_HANDLE_PROGRAM = ENV_HANDLE_PROGRAM || TESTNET_HANDLE_PROGRAM;
16
26
  // Handle account layout, mirrored from the on-chain program:
17
27
  // discriminator(8) name(32) name_len(1) owner(32) ...
18
28
  // `owner` is the address an UNTOKENIZED handle resolves to. Once the handle
@@ -32,6 +42,16 @@ const HANDLE_BASE_LEN = 8 + 149;
32
42
  // leniently — a short or zeroed account means "never cleared" (0), same
33
43
  // convention as every other Handle extension region.
34
44
  const RECORDS_CLEARED_EXT_OFFSET = 229;
45
+ // `holder_epoch` extension (`Handle::HOLDER_EPOCH_EXT_OFFSET`, H5 / PR #466):
46
+ // an i64 LE bumped (to now+1) ONLY by `buy_handle`'s tokenized branch — a
47
+ // NATIVE pNFT ownership change. External (off-platform, e.g. Magic Eden) pNFT
48
+ // trades do NOT bump it. The on-chain attestation gate now requires
49
+ // `attested_at >= max(registered_at, holder_epoch)`, so it is a THIRD staleness
50
+ // anchor alongside `registered_at` and `records_cleared_at`. KNOWN_EXT_END
51
+ // moved 255 -> 263 on-chain. Read leniently at the FIXED raw byte offset 255
52
+ // (discriminator included): an account SHORTER than 263 bytes never grew this
53
+ // region → read it as 0, the same convention as every other Handle extension.
54
+ const HOLDER_EPOCH_EXT_OFFSET = 255;
35
55
  // SPL Token account: mint(32) | owner(32) | amount(u64 LE, 8) | ...
36
56
  export const TOKEN_ACCOUNT_MIN_LEN = 72;
37
57
  // SPL Token mint: mint_authority COption(4+32) | supply(u64 LE, 8) @36 | ...
@@ -89,7 +109,8 @@ export function parseHandleAccount(data) {
89
109
  ? data.slice(HANDLE_BASE_LEN + 1, HANDLE_BASE_LEN + 33)
90
110
  : null;
91
111
  const recordsClearedAt = data.length >= RECORDS_CLEARED_EXT_OFFSET + 8 ? readI64(data, RECORDS_CLEARED_EXT_OFFSET) : 0n;
92
- return { name, owner, registeredAt, recordsClearedAt, nftMint };
112
+ const holderEpoch = data.length >= HOLDER_EPOCH_EXT_OFFSET + 8 ? readI64(data, HOLDER_EPOCH_EXT_OFFSET) : 0n;
113
+ return { name, owner, registeredAt, recordsClearedAt, holderEpoch, nftMint };
93
114
  }
94
115
  /** Build the RPC plumbing every reader in this package shares. */
95
116
  export function makeAccountReader(rpcUrl, fetchImpl) {
package/dist/agent.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * WP #8133. `AgentVerificationLevel` still names level 2 so every surface
24
24
  * that reports a level shares one vocabulary.
25
25
  */
26
- import { X1_TESTNET_NETWORK } from "./x402.js";
26
+ import { X1_TESTNET_NETWORK, X1_MAINNET_NETWORK } from "./x402.js";
27
27
  // ---------------------------------------------------------------------------
28
28
  // Well-known text-record keys
29
29
  // ---------------------------------------------------------------------------
@@ -188,11 +188,11 @@ export function x402AcceptsFromManifest(manifest, opts) {
188
188
  if (x402Endpoints.length === 0)
189
189
  return [];
190
190
  const x402 = manifest.payment?.x402;
191
- // X1_TESTNET_NETWORK is the real, verified x402 network id (see x402.ts).
192
- // Mainnet has no such id yet — X1 mainnet doesn't exist — so a manifest
193
- // claiming network: "mainnet" without its own explicit x402.network gets
194
- // an honestly-provisional placeholder rather than a fabricated genesis hash.
195
- const network = x402?.network ?? (manifest.x1id.network === "testnet" ? X1_TESTNET_NETWORK : "solana:mainnet-unassigned");
191
+ // The verified CAIP-30 x402 network ids (see x402.ts). A manifest's own
192
+ // explicit `x402.network` wins; otherwise map its declared cluster to the
193
+ // matching id — "testnet" → X1_TESTNET_NETWORK, anything else (mainnet, the
194
+ // production default) → X1_MAINNET_NETWORK.
195
+ const network = x402?.network ?? (manifest.x1id.network === "testnet" ? X1_TESTNET_NETWORK : X1_MAINNET_NETWORK);
196
196
  const payTo = opts?.payTo ?? manifest.payment?.addresses?.[0]?.address;
197
197
  return x402Endpoints.map((e) => {
198
198
  const hint = {
@@ -39,17 +39,22 @@
39
39
  * re-register lands the new registration at the SAME pubkey, and the
40
40
  * previous owner's attestation is physically attached to the new owner's
41
41
  * name with no action by anyone. The mandatory read-side rule
42
- * (docs/record-trust.md, the universal rule):
42
+ * (docs/record-trust.md, the universal rule), now anchored to the LATER of
43
+ * two native-ownership epochs (H5 / PR #466 added `holder_epoch`):
43
44
  *
44
- * attestation.attested_at >= handle.registered_at
45
+ * attestation.attested_at >= max(handle.registered_at, handle.holder_epoch)
45
46
  *
46
- * An attestation that fails it belongs to a previous, unrelated owner and
47
- * must never be rendered as verifying the current one. Like `records.ts`,
48
- * there is deliberately no way to decode or fetch an attestation through
49
- * this module without the handle's `registered_at` in hand. (The one
50
- * documented blind spot is shared with `Handle.owner`/`Primary`/
51
- * `RecordDelegate`: a bearer-NFT marketplace trade bumps no epoch, so the
52
- * attestation keeps reading live until the attestor re-reviews or revokes.)
47
+ * `holder_epoch` is bumped (to now+1) by `buy_handle`'s tokenized branch — a
48
+ * NATIVE pNFT sale — so a native trade now DOES move the epoch and invalidate
49
+ * the seller's attestation. An attestation that fails the rule belongs to a
50
+ * previous, unrelated owner and must never be rendered as verifying the
51
+ * current one. Like `records.ts`, there is deliberately no way to decode or
52
+ * fetch an attestation through this module without the handle's
53
+ * `registered_at` in hand. (The remaining blind spot — shared with
54
+ * `Handle.owner`/`Primary`/`RecordDelegate`: an EXTERNAL, off-platform
55
+ * bearer-NFT trade bumps no epoch, so the attestation keeps reading live
56
+ * until the attestor re-reviews or revokes — is what the separate
57
+ * `holderMismatch` overlay catches.)
53
58
  *
54
59
  * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
55
60
  *
@@ -58,7 +63,10 @@
58
63
  * explicitly alongside records/text-records as sharing this epoch rule. The
59
64
  * same functions therefore also demand the handle's `recordsClearedAt` (0 if
60
65
  * never cleared, from `ParsedHandle`), and an attestation is `stale` when
61
- * EITHER `attested_at < registeredAt` OR `attested_at < recordsClearedAt`.
66
+ * EITHER `attested_at < max(registeredAt, holderEpoch)` OR
67
+ * `attested_at < recordsClearedAt` — where `holderEpoch` (H5 / PR #466, also
68
+ * from `ParsedHandle`, 0 if absent) folds the last native tokenized-buy epoch
69
+ * into the ownership anchor.
62
70
  */
63
71
  import type { AddressLike, BuiltInstruction } from "./delegate.js";
64
72
  import type { RpcFn } from "./accounts.js";
@@ -175,12 +183,31 @@ export interface HandleAttestation {
175
183
  * not re-checked against the current `AttestorConfig`. */
176
184
  readonly attestor: string;
177
185
  /**
178
- * The rule docs/record-trust.md mandates: this attestation only vouches
179
- * for the current owner if `attested_at >= handle.registered_at`. A stale
180
- * attestation belongs to a previous, unrelated owner of the same name and
181
- * must never be rendered as verifying the current one.
186
+ * The rule docs/record-trust.md mandates: this attestation only vouches for
187
+ * the current owner if `attested_at >= max(handle.registered_at,
188
+ * handle.holder_epoch)` AND `attested_at >= handle.records_cleared_at` (H5 /
189
+ * PR #466 added the `holder_epoch` term — the last native tokenized-buy
190
+ * epoch). A stale attestation belongs to a previous, unrelated owner of the
191
+ * same name and must never be rendered as verifying the current one.
182
192
  */
183
193
  readonly stale: boolean;
194
+ /**
195
+ * H5 (the inherited-badge blind spot) — the OTHER, holder-level reason an
196
+ * attestation must not be rendered, independent of `stale`. An attestation
197
+ * records no subject wallet, only `attestedAt`; its epoch is anchored to
198
+ * `Handle.owner` (the owner stamped alongside `registered_at` at the last
199
+ * native ownership change). For a TOKENIZED handle, the real authority is the
200
+ * pNFT holder, which can diverge from `Handle.owner` with NO on-chain epoch
201
+ * bump (a bearer-NFT trade — native or external — writes no `registered_at`,
202
+ * see this struct's doc). When the current holder is no longer the mint-time
203
+ * `Handle.owner` the attestation was issued under, the attestation may have
204
+ * been inherited through a trade rather than earned, so it must NOT verify the
205
+ * current holder. `decodeAttestation` cannot know this (it sees no holder), so
206
+ * it always sets `false`; apply {@link markHolderMismatch} with the caller's
207
+ * holder-vs-owner comparison to set it. Mirrors tools/api's `holder_mismatch`
208
+ * (`build_verifications`). ALWAYS `false` for an untokenized handle.
209
+ */
210
+ readonly holderMismatch: boolean;
184
211
  }
185
212
  /**
186
213
  * Decode one Attestation account.
@@ -188,22 +215,55 @@ export interface HandleAttestation {
188
215
  * `registeredAt` is the owning Handle's `registered_at`, decoded by the
189
216
  * caller from the Handle account — it decides `stale`. `recordsClearedAt` is
190
217
  * that same Handle's `recordsClearedAt` (0 if never cleared, #8139), a
191
- * SECOND independent staleness anchor. There is intentionally no overload
192
- * without either (see the module docs).
218
+ * SECOND independent staleness anchor. `holderEpoch` (H5 / PR #466, 0 if
219
+ * absent) is the THIRD: the effective ownership epoch is
220
+ * `max(registeredAt, holderEpoch)`, so `stale` is `attestedAt <
221
+ * max(registeredAt, holderEpoch) || attestedAt < recordsClearedAt` — mirroring
222
+ * the on-chain gate `attested_at >= max(registered_at, holder_epoch)`.
223
+ * `holderEpoch` defaults to `0n` for backward compatibility (a caller that has
224
+ * not read it degrades to the pre-H5 `>= registered_at` rule). There is
225
+ * intentionally no overload without `registeredAt`/`recordsClearedAt` (see the
226
+ * module docs).
193
227
  *
194
228
  * Returns null for anything that is not an Attestation: wrong length or
195
229
  * wrong discriminator.
196
230
  */
197
- export declare function decodeAttestation(raw: Uint8Array, account: string, registeredAt: bigint, recordsClearedAt: bigint): HandleAttestation | null;
198
- /** The attestations that vouch for the CURRENT owner — `stale` ones
231
+ export declare function decodeAttestation(raw: Uint8Array, account: string, registeredAt: bigint, recordsClearedAt: bigint, holderEpoch?: bigint): HandleAttestation | null;
232
+ /** The attestations that vouch for the CURRENT holder — `stale` ones (previous
233
+ * epoch) AND `holderMismatch` ones (H5: inherited by a different pNFT holder)
199
234
  * excluded. This is the list to judge verification from. */
200
235
  export declare function liveAttestations(attestations: readonly HandleAttestation[]): HandleAttestation[];
201
236
  /**
202
237
  * The #7145 verified rule: a handle is verified when at least ONE non-stale
203
238
  * attestation of EITHER kind exists (a single strong signal is enough; the
204
- * surviving `kindName`s say which signals proved it).
239
+ * surviving `kindName`s say which signals proved it). An attestation flagged
240
+ * {@link HandleAttestation.holderMismatch} (H5 — the pNFT moved to a holder it
241
+ * was not issued to) is excluded exactly like a stale one.
205
242
  */
206
243
  export declare function isHandleVerified(attestations: readonly HandleAttestation[]): boolean;
244
+ /**
245
+ * H5 — the inherited-badge overlay. Returns `attestations` with
246
+ * {@link HandleAttestation.holderMismatch} forced `true` on every entry when a
247
+ * handle is tokenized AND its pNFT has moved away from the mint-time
248
+ * `Handle.owner` the attestation was issued under (`holderMatchesOwner ===
249
+ * false`); otherwise the list is returned unchanged. An attestation records no
250
+ * subject wallet, so this is the one honest, off-chain-derivable signal: the
251
+ * current bearer is compared to `Handle.owner` (the owner stamped alongside
252
+ * `registered_at`), not to the holder as-of each `attestedAt`.
253
+ *
254
+ * Mirrors tools/api's `build_verifications` exactly (same `tokenized &&
255
+ * !holder_matches_owner` condition, same force-not-verified result). Pure and
256
+ * synchronous: the caller resolves the current holder (e.g. via
257
+ * {@link import("./accounts.js").nftHolder} / `getTokenLargestAccounts`) and
258
+ * compares it to the handle's `owner` ({@link
259
+ * import("./accounts.js").parseHandleAccount}) to produce `holderMatchesOwner`.
260
+ *
261
+ * Interim limitation (shared with the resolver): it cannot read the NFT's
262
+ * transfer history, only its current bearer, so a legitimate re-attestation of a
263
+ * new bearer is suppressed until the on-chain H5 ownership-epoch bump lands —
264
+ * a false suppression, never a false assertion (the honest, conservative side).
265
+ */
266
+ export declare function markHolderMismatch(attestations: readonly HandleAttestation[], tokenized: boolean, holderMatchesOwner: boolean): HandleAttestation[];
207
267
  /**
208
268
  * Fetch every Attestation account of a handle — one `getProgramAccounts`
209
269
  * call, filtered by the RPC on size (114), the Attestation discriminator at
@@ -216,11 +276,15 @@ export declare function isHandleVerified(attestations: readonly HandleAttestatio
216
276
  * account the caller already has — the staleness rule needs it, and there
217
277
  * is no variant of this function without it. `recordsClearedAt` is that same
218
278
  * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it through.
219
- * Every attestation is returned, stale ones flagged, so an owner surface can
220
- * show what a previous registration left behind; anything that renders a
221
- * verified badge takes {@link isHandleVerified} / {@link liveAttestations}.
279
+ * `holderEpoch` is that same Handle's `holderEpoch` (0 if absent, H5 / PR
280
+ * #466); pass it through so the effective ownership epoch is
281
+ * `max(registeredAt, holderEpoch)`. It defaults to `0n` for backward
282
+ * compatibility. Every attestation is returned, stale ones flagged, so an
283
+ * owner surface can show what a previous registration left behind; anything
284
+ * that renders a verified badge takes {@link isHandleVerified} /
285
+ * {@link liveAttestations}.
222
286
  */
223
- export declare function fetchAttestations(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint): Promise<HandleAttestation[]>;
287
+ export declare function fetchAttestations(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, holderEpoch?: bigint): Promise<HandleAttestation[]>;
224
288
  export interface SetAttestorParams {
225
289
  /** The registry program id. */
226
290
  readonly programId: AddressLike;
@@ -39,17 +39,22 @@
39
39
  * re-register lands the new registration at the SAME pubkey, and the
40
40
  * previous owner's attestation is physically attached to the new owner's
41
41
  * name with no action by anyone. The mandatory read-side rule
42
- * (docs/record-trust.md, the universal rule):
42
+ * (docs/record-trust.md, the universal rule), now anchored to the LATER of
43
+ * two native-ownership epochs (H5 / PR #466 added `holder_epoch`):
43
44
  *
44
- * attestation.attested_at >= handle.registered_at
45
+ * attestation.attested_at >= max(handle.registered_at, handle.holder_epoch)
45
46
  *
46
- * An attestation that fails it belongs to a previous, unrelated owner and
47
- * must never be rendered as verifying the current one. Like `records.ts`,
48
- * there is deliberately no way to decode or fetch an attestation through
49
- * this module without the handle's `registered_at` in hand. (The one
50
- * documented blind spot is shared with `Handle.owner`/`Primary`/
51
- * `RecordDelegate`: a bearer-NFT marketplace trade bumps no epoch, so the
52
- * attestation keeps reading live until the attestor re-reviews or revokes.)
47
+ * `holder_epoch` is bumped (to now+1) by `buy_handle`'s tokenized branch — a
48
+ * NATIVE pNFT sale — so a native trade now DOES move the epoch and invalidate
49
+ * the seller's attestation. An attestation that fails the rule belongs to a
50
+ * previous, unrelated owner and must never be rendered as verifying the
51
+ * current one. Like `records.ts`, there is deliberately no way to decode or
52
+ * fetch an attestation through this module without the handle's
53
+ * `registered_at` in hand. (The remaining blind spot — shared with
54
+ * `Handle.owner`/`Primary`/`RecordDelegate`: an EXTERNAL, off-platform
55
+ * bearer-NFT trade bumps no epoch, so the attestation keeps reading live
56
+ * until the attestor re-reviews or revokes — is what the separate
57
+ * `holderMismatch` overlay catches.)
53
58
  *
54
59
  * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
55
60
  *
@@ -58,7 +63,10 @@
58
63
  * explicitly alongside records/text-records as sharing this epoch rule. The
59
64
  * same functions therefore also demand the handle's `recordsClearedAt` (0 if
60
65
  * never cleared, from `ParsedHandle`), and an attestation is `stale` when
61
- * EITHER `attested_at < registeredAt` OR `attested_at < recordsClearedAt`.
66
+ * EITHER `attested_at < max(registeredAt, holderEpoch)` OR
67
+ * `attested_at < recordsClearedAt` — where `holderEpoch` (H5 / PR #466, also
68
+ * from `ParsedHandle`, 0 if absent) folds the last native tokenized-buy epoch
69
+ * into the ownership anchor.
62
70
  */
63
71
  import { encodeBase58 } from "./base58.js";
64
72
  import { decodeBase58_32 } from "./base58.js";
@@ -238,13 +246,20 @@ export function textRecordKeyFor(kind) {
238
246
  * `registeredAt` is the owning Handle's `registered_at`, decoded by the
239
247
  * caller from the Handle account — it decides `stale`. `recordsClearedAt` is
240
248
  * that same Handle's `recordsClearedAt` (0 if never cleared, #8139), a
241
- * SECOND independent staleness anchor. There is intentionally no overload
242
- * without either (see the module docs).
249
+ * SECOND independent staleness anchor. `holderEpoch` (H5 / PR #466, 0 if
250
+ * absent) is the THIRD: the effective ownership epoch is
251
+ * `max(registeredAt, holderEpoch)`, so `stale` is `attestedAt <
252
+ * max(registeredAt, holderEpoch) || attestedAt < recordsClearedAt` — mirroring
253
+ * the on-chain gate `attested_at >= max(registered_at, holder_epoch)`.
254
+ * `holderEpoch` defaults to `0n` for backward compatibility (a caller that has
255
+ * not read it degrades to the pre-H5 `>= registered_at` rule). There is
256
+ * intentionally no overload without `registeredAt`/`recordsClearedAt` (see the
257
+ * module docs).
243
258
  *
244
259
  * Returns null for anything that is not an Attestation: wrong length or
245
260
  * wrong discriminator.
246
261
  */
247
- export function decodeAttestation(raw, account, registeredAt, recordsClearedAt) {
262
+ export function decodeAttestation(raw, account, registeredAt, recordsClearedAt, holderEpoch = 0n) {
248
263
  if (raw.length !== ATTESTATION_LEN)
249
264
  return null;
250
265
  for (let i = 0; i < 8; i++) {
@@ -262,6 +277,14 @@ export function decodeAttestation(raw, account, registeredAt, recordsClearedAt)
262
277
  const attestedAt = dv.getBigInt64(o, true);
263
278
  o += 8;
264
279
  const attestor = encodeBase58(raw.slice(o, o + 32));
280
+ // H5 / PR #466: the ownership epoch an attestation must post-date is the
281
+ // LATER of `registered_at` (last native register/sale) and `holder_epoch`
282
+ // (last native tokenized buy). Mirrors the on-chain gate exactly —
283
+ // `attested_at >= max(registered_at, holder_epoch)` — and staleness stays
284
+ // INCLUSIVE at the boundary so the dangerous failure (a false "verified")
285
+ // can never occur: an attestation is live only if `attested_at >=` the
286
+ // effective epoch. `records_cleared_at` is a second, independent anchor.
287
+ const ownershipEpoch = registeredAt > holderEpoch ? registeredAt : holderEpoch;
265
288
  return {
266
289
  account,
267
290
  handle,
@@ -270,21 +293,53 @@ export function decodeAttestation(raw, account, registeredAt, recordsClearedAt)
270
293
  evidenceHash,
271
294
  attestedAt,
272
295
  attestor,
273
- stale: attestedAt < registeredAt || attestedAt < recordsClearedAt,
296
+ stale: attestedAt < ownershipEpoch || attestedAt < recordsClearedAt,
297
+ // The decoder sees no holder — only epoch staleness is knowable here.
298
+ // H5 holder-mismatch is overlaid later via markHolderMismatch.
299
+ holderMismatch: false,
274
300
  };
275
301
  }
276
- /** The attestations that vouch for the CURRENT owner — `stale` ones
302
+ /** The attestations that vouch for the CURRENT holder — `stale` ones (previous
303
+ * epoch) AND `holderMismatch` ones (H5: inherited by a different pNFT holder)
277
304
  * excluded. This is the list to judge verification from. */
278
305
  export function liveAttestations(attestations) {
279
- return attestations.filter((a) => !a.stale);
306
+ return attestations.filter((a) => !a.stale && !a.holderMismatch);
280
307
  }
281
308
  /**
282
309
  * The #7145 verified rule: a handle is verified when at least ONE non-stale
283
310
  * attestation of EITHER kind exists (a single strong signal is enough; the
284
- * surviving `kindName`s say which signals proved it).
311
+ * surviving `kindName`s say which signals proved it). An attestation flagged
312
+ * {@link HandleAttestation.holderMismatch} (H5 — the pNFT moved to a holder it
313
+ * was not issued to) is excluded exactly like a stale one.
285
314
  */
286
315
  export function isHandleVerified(attestations) {
287
- return attestations.some((a) => !a.stale);
316
+ return attestations.some((a) => !a.stale && !a.holderMismatch);
317
+ }
318
+ /**
319
+ * H5 — the inherited-badge overlay. Returns `attestations` with
320
+ * {@link HandleAttestation.holderMismatch} forced `true` on every entry when a
321
+ * handle is tokenized AND its pNFT has moved away from the mint-time
322
+ * `Handle.owner` the attestation was issued under (`holderMatchesOwner ===
323
+ * false`); otherwise the list is returned unchanged. An attestation records no
324
+ * subject wallet, so this is the one honest, off-chain-derivable signal: the
325
+ * current bearer is compared to `Handle.owner` (the owner stamped alongside
326
+ * `registered_at`), not to the holder as-of each `attestedAt`.
327
+ *
328
+ * Mirrors tools/api's `build_verifications` exactly (same `tokenized &&
329
+ * !holder_matches_owner` condition, same force-not-verified result). Pure and
330
+ * synchronous: the caller resolves the current holder (e.g. via
331
+ * {@link import("./accounts.js").nftHolder} / `getTokenLargestAccounts`) and
332
+ * compares it to the handle's `owner` ({@link
333
+ * import("./accounts.js").parseHandleAccount}) to produce `holderMatchesOwner`.
334
+ *
335
+ * Interim limitation (shared with the resolver): it cannot read the NFT's
336
+ * transfer history, only its current bearer, so a legitimate re-attestation of a
337
+ * new bearer is suppressed until the on-chain H5 ownership-epoch bump lands —
338
+ * a false suppression, never a false assertion (the honest, conservative side).
339
+ */
340
+ export function markHolderMismatch(attestations, tokenized, holderMatchesOwner) {
341
+ const mismatch = tokenized && !holderMatchesOwner;
342
+ return attestations.map((a) => (mismatch ? { ...a, holderMismatch: true } : a));
288
343
  }
289
344
  /**
290
345
  * Fetch every Attestation account of a handle — one `getProgramAccounts`
@@ -298,11 +353,15 @@ export function isHandleVerified(attestations) {
298
353
  * account the caller already has — the staleness rule needs it, and there
299
354
  * is no variant of this function without it. `recordsClearedAt` is that same
300
355
  * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it through.
301
- * Every attestation is returned, stale ones flagged, so an owner surface can
302
- * show what a previous registration left behind; anything that renders a
303
- * verified badge takes {@link isHandleVerified} / {@link liveAttestations}.
356
+ * `holderEpoch` is that same Handle's `holderEpoch` (0 if absent, H5 / PR
357
+ * #466); pass it through so the effective ownership epoch is
358
+ * `max(registeredAt, holderEpoch)`. It defaults to `0n` for backward
359
+ * compatibility. Every attestation is returned, stale ones flagged, so an
360
+ * owner surface can show what a previous registration left behind; anything
361
+ * that renders a verified badge takes {@link isHandleVerified} /
362
+ * {@link liveAttestations}.
304
363
  */
305
- export async function fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt) {
364
+ export async function fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt, holderEpoch = 0n) {
306
365
  const res = (await rpc("getProgramAccounts", [
307
366
  programId,
308
367
  {
@@ -321,7 +380,7 @@ export async function fetchAttestations(rpc, programId, handleAccount, registere
321
380
  const raw = new Uint8Array(bin.length);
322
381
  for (let i = 0; i < bin.length; i++)
323
382
  raw[i] = bin.charCodeAt(i);
324
- const decoded = decodeAttestation(raw, a.pubkey, registeredAt, recordsClearedAt);
383
+ const decoded = decodeAttestation(raw, a.pubkey, registeredAt, recordsClearedAt, holderEpoch);
325
384
  // Defence in depth: the memcmp filter should guarantee the handle
326
385
  // match, but a wrong offset would silently attribute someone else's
327
386
  // attestation to this handle. A malformed account is skipped, not fatal.
@@ -154,13 +154,16 @@ export interface VerifyDomainAttestationResult {
154
154
  * `registeredAt` / `recordsClearedAt` are the owning Handle's fields the caller
155
155
  * already decoded (see {@link import("./accounts.js").parseHandleAccount}); they
156
156
  * are MANDATORY here for the same reason every attestation read demands them —
157
- * the staleness rule (`attested_at >= registered_at`, and `>= records_cleared_at`)
158
- * decides whether an attestation vouches for the *current* owner at all. The
157
+ * the staleness rule (`attested_at >= max(registered_at, holder_epoch)`, and
158
+ * `>= records_cleared_at`) decides whether an attestation vouches for the
159
+ * *current* owner at all. `holderEpoch` (H5 / PR #466, also from the decoded
160
+ * Handle, 0 if absent) is the optional trailing arg that supplies that third
161
+ * anchor; it defaults to `0n`. The
159
162
  * two halves are evaluated independently, so a caller can see which one holds:
160
163
  * a chain-read failure does not suppress the DNS answer, and vice-versa.
161
164
  *
162
165
  * Never throws: a failed chain read degrades to `attested: false` with
163
166
  * `reason: "attestation-error"`, mirroring `signin.ts`'s best-effort summary.
164
167
  */
165
- export declare function verifyDomainAttestation(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, domain: string, expectedWallet: string, opts?: DomainProofOptions): Promise<VerifyDomainAttestationResult>;
168
+ export declare function verifyDomainAttestation(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, domain: string, expectedWallet: string, opts?: DomainProofOptions, holderEpoch?: bigint): Promise<VerifyDomainAttestationResult>;
166
169
  export {};
@@ -244,21 +244,24 @@ export async function verifyDomainBinding(domain, expectedWallet, opts) {
244
244
  * `registeredAt` / `recordsClearedAt` are the owning Handle's fields the caller
245
245
  * already decoded (see {@link import("./accounts.js").parseHandleAccount}); they
246
246
  * are MANDATORY here for the same reason every attestation read demands them —
247
- * the staleness rule (`attested_at >= registered_at`, and `>= records_cleared_at`)
248
- * decides whether an attestation vouches for the *current* owner at all. The
247
+ * the staleness rule (`attested_at >= max(registered_at, holder_epoch)`, and
248
+ * `>= records_cleared_at`) decides whether an attestation vouches for the
249
+ * *current* owner at all. `holderEpoch` (H5 / PR #466, also from the decoded
250
+ * Handle, 0 if absent) is the optional trailing arg that supplies that third
251
+ * anchor; it defaults to `0n`. The
249
252
  * two halves are evaluated independently, so a caller can see which one holds:
250
253
  * a chain-read failure does not suppress the DNS answer, and vice-versa.
251
254
  *
252
255
  * Never throws: a failed chain read degrades to `attested: false` with
253
256
  * `reason: "attestation-error"`, mirroring `signin.ts`'s best-effort summary.
254
257
  */
255
- export async function verifyDomainAttestation(rpc, programId, handleAccount, registeredAt, recordsClearedAt, domain, expectedWallet, opts) {
258
+ export async function verifyDomainAttestation(rpc, programId, handleAccount, registeredAt, recordsClearedAt, domain, expectedWallet, opts, holderEpoch = 0n) {
256
259
  // Half 1 (on-chain): a non-stale dns attestation for the current owner. A
257
260
  // read failure must not sink the DNS half — degrade, never throw.
258
261
  let attested = false;
259
262
  let attestationReason;
260
263
  try {
261
- const attestations = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt);
264
+ const attestations = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt, holderEpoch);
262
265
  attested = attestationsPassGate(attestations, { kind: GateKind.dns });
263
266
  }
264
267
  catch {