@x1id/resolve 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/records.d.ts CHANGED
@@ -19,6 +19,15 @@
19
19
  * There is deliberately no way to decode or fetch a record through this
20
20
  * module without the handle's `registered_at` in hand, mirroring
21
21
  * app/src/lib/x1/records.ts. See docs/record-trust.md for the full argument.
22
+ *
23
+ * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
24
+ *
25
+ * `clear_records` lets an owner bulk-invalidate every record RIGHT NOW,
26
+ * without a transfer. The same functions therefore also demand the handle's
27
+ * `recordsClearedAt` (0 if never cleared, from `ParsedHandle`) and a record
28
+ * is `stale` when EITHER `updated_at < registeredAt` OR
29
+ * `updated_at < recordsClearedAt` — see `Handle::read_records_cleared_at`'s
30
+ * doc comment in state.rs.
22
31
  */
23
32
  import { type Chain } from "./types.js";
24
33
  import type { RpcFn } from "./accounts.js";
@@ -75,7 +84,10 @@ export interface HandleRecord {
75
84
  *
76
85
  * `registeredAt` is the owning Handle's `registered_at`, decoded by the
77
86
  * caller from the Handle account — it decides `stale` (and so `verified`).
78
- * There is intentionally no overload without it.
87
+ * `recordsClearedAt` is the same Handle's `recordsClearedAt` (0 if never
88
+ * cleared, #8139) — a SECOND, independent staleness anchor, checked
89
+ * alongside (not instead of) `registeredAt`. There is intentionally no
90
+ * overload without either.
79
91
  *
80
92
  * Returns null for anything that is not a Record: wrong length, wrong
81
93
  * discriminator, or a `value` length that does not fit. The Listing / Offer /
@@ -83,7 +95,7 @@ export interface HandleRecord {
83
95
  * so a handle-only memcmp scan DOES return them — they are rejected here by
84
96
  * tag and size, not by luck.
85
97
  */
86
- export declare function decodeRecord(raw: Uint8Array, account: string, registeredAt: bigint): HandleRecord | null;
98
+ export declare function decodeRecord(raw: Uint8Array, account: string, registeredAt: bigint, recordsClearedAt: bigint): HandleRecord | null;
87
99
  /** The records the CURRENT owner actually has — `stale` ones excluded. This
88
100
  * is the list to resolve against, display to a visitor, and count. */
89
101
  export declare function liveRecords(records: readonly HandleRecord[]): HandleRecord[];
@@ -105,9 +117,14 @@ export declare function valueToAddress(chain: Chain | null, value: Uint8Array):
105
117
  *
106
118
  * `registeredAt` is `Handle.registered_at` as decoded from the Handle
107
119
  * account the caller already has — the staleness rule needs it, and there
108
- * is no variant of this function without it. Every record is returned,
109
- * stale ones flagged (`verified` already forced false), so an owner surface
110
- * can show what a previous owner left behind; anything that resolves,
111
- * displays to a visitor, or counts takes `liveRecords(...)`.
120
+ * is no variant of this function without it. `recordsClearedAt` is that same
121
+ * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it, not a
122
+ * literal 0, unless the records being fetched genuinely hang off a DIFFERENT
123
+ * account than the one `clear_records` could ever touch (e.g. a subname's
124
+ * own records, keyed by the subname's `createdAt` instead — see
125
+ * `subname.ts`). Every record is returned, stale ones flagged (`verified`
126
+ * already forced false), so an owner surface can show what a previous owner
127
+ * left behind; anything that resolves, displays to a visitor, or counts
128
+ * takes `liveRecords(...)`.
112
129
  */
113
- export declare function fetchRecords(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint): Promise<HandleRecord[]>;
130
+ export declare function fetchRecords(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint): Promise<HandleRecord[]>;
package/dist/records.js CHANGED
@@ -19,6 +19,15 @@
19
19
  * There is deliberately no way to decode or fetch a record through this
20
20
  * module without the handle's `registered_at` in hand, mirroring
21
21
  * app/src/lib/x1/records.ts. See docs/record-trust.md for the full argument.
22
+ *
23
+ * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
24
+ *
25
+ * `clear_records` lets an owner bulk-invalidate every record RIGHT NOW,
26
+ * without a transfer. The same functions therefore also demand the handle's
27
+ * `recordsClearedAt` (0 if never cleared, from `ParsedHandle`) and a record
28
+ * is `stale` when EITHER `updated_at < registeredAt` OR
29
+ * `updated_at < recordsClearedAt` — see `Handle::read_records_cleared_at`'s
30
+ * doc comment in state.rs.
22
31
  */
23
32
  import { encodeBase58 } from "./base58.js";
24
33
  import { CHAIN_COIN_TYPE } from "./types.js";
@@ -52,7 +61,10 @@ export function chainForCoinType(coinType) {
52
61
  *
53
62
  * `registeredAt` is the owning Handle's `registered_at`, decoded by the
54
63
  * caller from the Handle account — it decides `stale` (and so `verified`).
55
- * There is intentionally no overload without it.
64
+ * `recordsClearedAt` is the same Handle's `recordsClearedAt` (0 if never
65
+ * cleared, #8139) — a SECOND, independent staleness anchor, checked
66
+ * alongside (not instead of) `registeredAt`. There is intentionally no
67
+ * overload without either.
56
68
  *
57
69
  * Returns null for anything that is not a Record: wrong length, wrong
58
70
  * discriminator, or a `value` length that does not fit. The Listing / Offer /
@@ -60,7 +72,7 @@ export function chainForCoinType(coinType) {
60
72
  * so a handle-only memcmp scan DOES return them — they are rejected here by
61
73
  * tag and size, not by luck.
62
74
  */
63
- export function decodeRecord(raw, account, registeredAt) {
75
+ export function decodeRecord(raw, account, registeredAt, recordsClearedAt) {
64
76
  if (raw.length !== RECORD_LEN)
65
77
  return null;
66
78
  for (let i = 0; i < 8; i++) {
@@ -83,7 +95,7 @@ export function decodeRecord(raw, account, registeredAt) {
83
95
  const verified = raw[o] === 1;
84
96
  o += 1;
85
97
  const updatedAt = dv.getBigInt64(o, true);
86
- const stale = updatedAt < registeredAt;
98
+ const stale = updatedAt < registeredAt || updatedAt < recordsClearedAt;
87
99
  const chain = chainForCoinType(coinType);
88
100
  return {
89
101
  account,
@@ -142,12 +154,17 @@ function hexToBytes(hex) {
142
154
  *
143
155
  * `registeredAt` is `Handle.registered_at` as decoded from the Handle
144
156
  * account the caller already has — the staleness rule needs it, and there
145
- * is no variant of this function without it. Every record is returned,
146
- * stale ones flagged (`verified` already forced false), so an owner surface
147
- * can show what a previous owner left behind; anything that resolves,
148
- * displays to a visitor, or counts takes `liveRecords(...)`.
157
+ * is no variant of this function without it. `recordsClearedAt` is that same
158
+ * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it, not a
159
+ * literal 0, unless the records being fetched genuinely hang off a DIFFERENT
160
+ * account than the one `clear_records` could ever touch (e.g. a subname's
161
+ * own records, keyed by the subname's `createdAt` instead — see
162
+ * `subname.ts`). Every record is returned, stale ones flagged (`verified`
163
+ * already forced false), so an owner surface can show what a previous owner
164
+ * left behind; anything that resolves, displays to a visitor, or counts
165
+ * takes `liveRecords(...)`.
149
166
  */
150
- export async function fetchRecords(rpc, programId, handleAccount, registeredAt) {
167
+ export async function fetchRecords(rpc, programId, handleAccount, registeredAt, recordsClearedAt) {
151
168
  const res = (await rpc("getProgramAccounts", [
152
169
  programId,
153
170
  {
@@ -166,7 +183,7 @@ export async function fetchRecords(rpc, programId, handleAccount, registeredAt)
166
183
  const raw = new Uint8Array(bin.length);
167
184
  for (let i = 0; i < bin.length; i++)
168
185
  raw[i] = bin.charCodeAt(i);
169
- const r = decodeRecord(raw, a.pubkey, registeredAt);
186
+ const r = decodeRecord(raw, a.pubkey, registeredAt, recordsClearedAt);
170
187
  // Defence in depth: the memcmp filter should guarantee the handle match,
171
188
  // but a wrong offset would silently attribute someone else's record to
172
189
  // this handle. A malformed account is skipped, not fatal to the list.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `register` — the core write instruction: pay the current ramp price and
3
+ * claim an unregistered `["handle", name]` PDA. Every other write path in
4
+ * this SDK (voucher, subname, lock, delegate, attestation, text record,
5
+ * integrator) already has a builder; this was the one gap.
6
+ *
7
+ * Hand-rolled like the rest of this package — no Anchor client, no
8
+ * `@solana/web3.js` import. PDAs are NOT derived here (see delegate.ts's
9
+ * module docs for why) — derive `["config"]` / `["handle", name]` with your
10
+ * runtime's canonical `findProgramAddress`.
11
+ *
12
+ * `register`'s price is computed ON-CHAIN at execution time (time + volume
13
+ * ramp over `Config.tier_lamports`) — there is no client-supplied price or
14
+ * slippage parameter; the payer simply pays whatever the program charges as
15
+ * of the landing slot. {@link fetchConfigTreasury} reads the one `Config`
16
+ * field this builder needs (the treasury address every registration pays).
17
+ */
18
+ import type { AddressLike, BuiltInstruction } from "./delegate.js";
19
+ import type { RpcFn } from "./accounts.js";
20
+ import type { HandleTypeValue } from "./voucher.js";
21
+ /** Anchor instruction discriminator: `sha256("global:register")[0..8]`. */
22
+ export declare const REGISTER_DISCRIMINATOR: Uint8Array;
23
+ /** Anchor instruction discriminator: `sha256("global:register_with_forti")[0..8]`. */
24
+ export declare const REGISTER_WITH_FORTI_DISCRIMINATOR: Uint8Array;
25
+ /**
26
+ * Read `Config.treasury` — the account every `register` call pays — given
27
+ * the `["config"]` PDA. Returns null if the account doesn't exist or isn't
28
+ * shaped like a `Config` (too short to hold `treasury`).
29
+ */
30
+ export declare function fetchConfigTreasury(rpc: RpcFn, configAccount: string): Promise<string | null>;
31
+ export interface RegisterParams {
32
+ /** The registry program id. */
33
+ readonly programId: AddressLike;
34
+ /** Pays the registration fee and the `Handle` account's rent. Signer. */
35
+ readonly payer: AddressLike;
36
+ /** The handle's owner. Need not sign — a handle can be registered on
37
+ * someone else's behalf (gifting, merchant onboarding). */
38
+ readonly owner: AddressLike;
39
+ /** The `["config"]` PDA. */
40
+ readonly config: AddressLike;
41
+ /** `Config.treasury` — read it fresh via {@link fetchConfigTreasury}
42
+ * (it's admin-rotatable, so don't cache it across a session). */
43
+ readonly treasury: AddressLike;
44
+ /** The `["handle", name]` PDA being claimed — must not already exist. */
45
+ readonly handle: AddressLike;
46
+ /** Canonical form (`parseName(...).canonical`), 1..=32 bytes. */
47
+ readonly name: string;
48
+ readonly handleType: HandleTypeValue;
49
+ /** OPTIONAL revenue-share pair (#7397) — supply BOTH or NEITHER; supplying
50
+ * exactly one fails on-chain (`IntegratorNotAllowed`). */
51
+ readonly integrator?: AddressLike;
52
+ /** The `["integrator", integrator]` allowlist PDA. */
53
+ readonly integratorAllowlist?: AddressLike;
54
+ }
55
+ /**
56
+ * Build `register`. Price is computed on-chain (see the module docs) — this
57
+ * builder does not know or assert it; simulate first if you need to show the
58
+ * payer a price before they sign.
59
+ */
60
+ export declare function buildRegisterIx(p: RegisterParams): BuiltInstruction;
61
+ /** The `Config` fields `register_with_forti` needs beyond what
62
+ * {@link fetchConfigTreasury} already reads. `fortiRatePerLamport === 0n`
63
+ * means FORTI payment is not enabled on this deployment — the same
64
+ * `ZeroFortiRate` gate the program itself enforces; callers should refuse
65
+ * to build the instruction rather than let it fail on-chain. */
66
+ export interface ConfigForti {
67
+ readonly fortiMint: string;
68
+ readonly fortiTreasuryAta: string;
69
+ readonly veFortiProgram: string;
70
+ readonly fortiBuybackRecipient: string;
71
+ readonly fortiRatePerLamport: bigint;
72
+ }
73
+ /** Read the `Config` fields a `register_with_forti` build needs, given the
74
+ * `["config"]` PDA. Returns null if the account doesn't exist or isn't
75
+ * shaped like a post-#8136 `Config` (too short to hold these fields —
76
+ * e.g. a `Config` that hasn't run `migrate_config_v4` yet). */
77
+ export declare function fetchConfigForti(rpc: RpcFn, configAccount: string): Promise<ConfigForti | null>;
78
+ export interface RegisterWithFortiParams {
79
+ /** The registry program id. */
80
+ readonly programId: AddressLike;
81
+ /** Pays the FORTI amount and the `Handle` account's rent. Signer. */
82
+ readonly payer: AddressLike;
83
+ /** The handle's owner. Need not sign — same as {@link RegisterParams.owner}. */
84
+ readonly owner: AddressLike;
85
+ /** The `["config"]` PDA. */
86
+ readonly config: AddressLike;
87
+ /** `Config.forti_mint` — {@link fetchConfigForti}. */
88
+ readonly fortiMint: AddressLike;
89
+ /** The PAYER's associated token account for `fortiMint` — derive with your
90
+ * runtime's canonical ATA derivation (Token-2022; the real testnet FORTI
91
+ * mint is Token-2022, not classic SPL Token). */
92
+ readonly payerFortiAccount: AddressLike;
93
+ /** `Config.forti_treasury_ata` — {@link fetchConfigForti}. */
94
+ readonly fortiTreasuryAta: AddressLike;
95
+ /** `Config.forti_buyback_recipient` — {@link fetchConfigForti}. */
96
+ readonly fortiBuybackRecipient: AddressLike;
97
+ /** The payer's veFORTI lock PDA (`["lock", payer]` under
98
+ * `Config.veforti_program`) — may not exist (payer never locked), in
99
+ * which case the program treats voting power as zero. Derive with your
100
+ * runtime's canonical `findProgramAddress` against
101
+ * `fetchConfigForti(...).veFortiProgram`. */
102
+ readonly veFortiLock: AddressLike;
103
+ /** The `["handle", name]` PDA being claimed — must not already exist. */
104
+ readonly handle: AddressLike;
105
+ /** Canonical form (`parseName(...).canonical`), 1..=32 bytes. */
106
+ readonly name: string;
107
+ readonly handleType: HandleTypeValue;
108
+ /** The token program that owns `fortiMint` — Token-2022 on testnet today;
109
+ * pass whichever `fortiMint`'s own `owner` field names, never hardcode. */
110
+ readonly tokenProgram: AddressLike;
111
+ }
112
+ /**
113
+ * Build `register_with_forti` (#8136) — a SIBLING payment path to
114
+ * `register`, not a replacement. Price is computed on-chain the same way
115
+ * `register`'s is (see that builder's docs) — this builder does not know or
116
+ * assert it; use `/quote/:name?pay_with=forti[&payer=...]` (tools/api) or
117
+ * simulate first if you need to show the payer a price before they sign.
118
+ */
119
+ export declare function buildRegisterWithFortiIx(p: RegisterWithFortiParams): BuiltInstruction;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `register` — the core write instruction: pay the current ramp price and
3
+ * claim an unregistered `["handle", name]` PDA. Every other write path in
4
+ * this SDK (voucher, subname, lock, delegate, attestation, text record,
5
+ * integrator) already has a builder; this was the one gap.
6
+ *
7
+ * Hand-rolled like the rest of this package — no Anchor client, no
8
+ * `@solana/web3.js` import. PDAs are NOT derived here (see delegate.ts's
9
+ * module docs for why) — derive `["config"]` / `["handle", name]` with your
10
+ * runtime's canonical `findProgramAddress`.
11
+ *
12
+ * `register`'s price is computed ON-CHAIN at execution time (time + volume
13
+ * ramp over `Config.tier_lamports`) — there is no client-supplied price or
14
+ * slippage parameter; the payer simply pays whatever the program charges as
15
+ * of the landing slot. {@link fetchConfigTreasury} reads the one `Config`
16
+ * field this builder needs (the treasury address every registration pays).
17
+ */
18
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
19
+ /** Anchor instruction discriminator: `sha256("global:register")[0..8]`. */
20
+ export const REGISTER_DISCRIMINATOR = Uint8Array.from([
21
+ 211, 124, 67, 15, 211, 194, 178, 240,
22
+ ]);
23
+ /** Anchor instruction discriminator: `sha256("global:register_with_forti")[0..8]`. */
24
+ export const REGISTER_WITH_FORTI_DISCRIMINATOR = Uint8Array.from([
25
+ 48, 73, 211, 175, 49, 236, 133, 82,
26
+ ]);
27
+ /** Byte offset of `treasury` within a `Config` account (after the 8-byte
28
+ * discriminator: `admin: Pubkey(32)` then `treasury: Pubkey(32)` at 40). */
29
+ const CONFIG_TREASURY_OFFSET = 40;
30
+ const PUBKEY_LEN = 32;
31
+ function toBytes32(v, what) {
32
+ if (typeof v === "string") {
33
+ const b = decodeBase58_32(v);
34
+ if (!b)
35
+ throw new Error(`${what} is not a valid base58 address`);
36
+ return b;
37
+ }
38
+ if (v.length !== 32)
39
+ throw new Error(`${what} must be exactly 32 bytes`);
40
+ return v;
41
+ }
42
+ function toBase58(v, what) {
43
+ return encodeBase58(toBytes32(v, what));
44
+ }
45
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
46
+ /**
47
+ * Read `Config.treasury` — the account every `register` call pays — given
48
+ * the `["config"]` PDA. Returns null if the account doesn't exist or isn't
49
+ * shaped like a `Config` (too short to hold `treasury`).
50
+ */
51
+ export async function fetchConfigTreasury(rpc, configAccount) {
52
+ const res = (await rpc("getAccountInfo", [
53
+ configAccount,
54
+ { encoding: "base64", commitment: "confirmed" },
55
+ ]));
56
+ const data = res?.value?.data?.[0];
57
+ if (!data)
58
+ return null;
59
+ const bin = atob(data);
60
+ if (bin.length < CONFIG_TREASURY_OFFSET + PUBKEY_LEN)
61
+ return null;
62
+ const raw = new Uint8Array(bin.length);
63
+ for (let i = 0; i < bin.length; i++)
64
+ raw[i] = bin.charCodeAt(i);
65
+ return encodeBase58(raw.slice(CONFIG_TREASURY_OFFSET, CONFIG_TREASURY_OFFSET + PUBKEY_LEN));
66
+ }
67
+ /**
68
+ * Build `register`. Price is computed on-chain (see the module docs) — this
69
+ * builder does not know or assert it; simulate first if you need to show the
70
+ * payer a price before they sign.
71
+ */
72
+ export function buildRegisterIx(p) {
73
+ if (!Number.isInteger(p.handleType) || p.handleType < 0 || p.handleType > 3) {
74
+ throw new Error("handleType must be 0 (Human), 1 (Merchant), 2 (Org) or 3 (Agent)");
75
+ }
76
+ const nameBytes = new TextEncoder().encode(p.name);
77
+ if (nameBytes.length < 1 || nameBytes.length > 32) {
78
+ throw new Error("name must be 1..=32 bytes (UTF-8)");
79
+ }
80
+ const data = new Uint8Array(8 + 4 + nameBytes.length + 1);
81
+ data.set(REGISTER_DISCRIMINATOR, 0);
82
+ new DataView(data.buffer).setUint32(8, nameBytes.length, true);
83
+ data.set(nameBytes, 12);
84
+ data[12 + nameBytes.length] = p.handleType;
85
+ const hasIntegrator = p.integrator !== undefined || p.integratorAllowlist !== undefined;
86
+ if (hasIntegrator && (p.integrator === undefined || p.integratorAllowlist === undefined)) {
87
+ throw new Error("integrator and integratorAllowlist must both be supplied, or neither");
88
+ }
89
+ const keys = [
90
+ { pubkey: toBase58(p.payer, "payer"), isSigner: true, isWritable: true },
91
+ { pubkey: toBase58(p.owner, "owner"), isSigner: false, isWritable: false },
92
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: true },
93
+ { pubkey: toBase58(p.treasury, "treasury"), isSigner: false, isWritable: true },
94
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
95
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
96
+ ];
97
+ if (hasIntegrator) {
98
+ keys.push({ pubkey: toBase58(p.integrator, "integrator"), isSigner: false, isWritable: true }, {
99
+ pubkey: toBase58(p.integratorAllowlist, "integratorAllowlist"),
100
+ isSigner: false,
101
+ isWritable: false,
102
+ });
103
+ }
104
+ return { programId: toBase58(p.programId, "programId"), keys, data };
105
+ }
106
+ // ============================= register_with_forti (#8136, WP #8200) =============================
107
+ /** Byte offsets of the `Config` fields `register_with_forti` needs, verbatim
108
+ * from state.rs's field order (confirmed against `migrate_config_v4`'s own
109
+ * write order in lib.rs) — lying after `treasury` (40) this module's own
110
+ * {@link fetchConfigTreasury} already reads:
111
+ * ...recovery_timelock_secs(i64)@256 lease_rate_divisor(u64)@264
112
+ * lease_grace_period_secs(i64)@272 forti_mint(32)@280
113
+ * forti_treasury_ata(32)@312 veforti_program(32)@344
114
+ * forti_buyback_recipient(32)@376 forti_rate_per_lamport(u64)@408
115
+ * veforti_bonus_bps(u16)@416 forti_buyback_share_bps(u16)@418 */
116
+ const CONFIG_FORTI_MINT_OFFSET = 280;
117
+ const CONFIG_FORTI_TREASURY_ATA_OFFSET = 312;
118
+ const CONFIG_VEFORTI_PROGRAM_OFFSET = 344;
119
+ const CONFIG_FORTI_BUYBACK_RECIPIENT_OFFSET = 376;
120
+ const CONFIG_FORTI_RATE_OFFSET = 408;
121
+ const CONFIG_FORTI_MIN_LEN = CONFIG_FORTI_RATE_OFFSET + 8;
122
+ /** Read the `Config` fields a `register_with_forti` build needs, given the
123
+ * `["config"]` PDA. Returns null if the account doesn't exist or isn't
124
+ * shaped like a post-#8136 `Config` (too short to hold these fields —
125
+ * e.g. a `Config` that hasn't run `migrate_config_v4` yet). */
126
+ export async function fetchConfigForti(rpc, configAccount) {
127
+ const res = (await rpc("getAccountInfo", [
128
+ configAccount,
129
+ { encoding: "base64", commitment: "confirmed" },
130
+ ]));
131
+ const data = res?.value?.data?.[0];
132
+ if (!data)
133
+ return null;
134
+ const bin = atob(data);
135
+ if (bin.length < CONFIG_FORTI_MIN_LEN)
136
+ return null;
137
+ const raw = new Uint8Array(bin.length);
138
+ for (let i = 0; i < bin.length; i++)
139
+ raw[i] = bin.charCodeAt(i);
140
+ const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
141
+ return {
142
+ fortiMint: encodeBase58(raw.slice(CONFIG_FORTI_MINT_OFFSET, CONFIG_FORTI_MINT_OFFSET + PUBKEY_LEN)),
143
+ fortiTreasuryAta: encodeBase58(raw.slice(CONFIG_FORTI_TREASURY_ATA_OFFSET, CONFIG_FORTI_TREASURY_ATA_OFFSET + PUBKEY_LEN)),
144
+ veFortiProgram: encodeBase58(raw.slice(CONFIG_VEFORTI_PROGRAM_OFFSET, CONFIG_VEFORTI_PROGRAM_OFFSET + PUBKEY_LEN)),
145
+ fortiBuybackRecipient: encodeBase58(raw.slice(CONFIG_FORTI_BUYBACK_RECIPIENT_OFFSET, CONFIG_FORTI_BUYBACK_RECIPIENT_OFFSET + PUBKEY_LEN)),
146
+ fortiRatePerLamport: dv.getBigUint64(CONFIG_FORTI_RATE_OFFSET, true),
147
+ };
148
+ }
149
+ /**
150
+ * Build `register_with_forti` (#8136) — a SIBLING payment path to
151
+ * `register`, not a replacement. Price is computed on-chain the same way
152
+ * `register`'s is (see that builder's docs) — this builder does not know or
153
+ * assert it; use `/quote/:name?pay_with=forti[&payer=...]` (tools/api) or
154
+ * simulate first if you need to show the payer a price before they sign.
155
+ */
156
+ export function buildRegisterWithFortiIx(p) {
157
+ if (!Number.isInteger(p.handleType) || p.handleType < 0 || p.handleType > 3) {
158
+ throw new Error("handleType must be 0 (Human), 1 (Merchant), 2 (Org) or 3 (Agent)");
159
+ }
160
+ const nameBytes = new TextEncoder().encode(p.name);
161
+ if (nameBytes.length < 1 || nameBytes.length > 32) {
162
+ throw new Error("name must be 1..=32 bytes (UTF-8)");
163
+ }
164
+ const data = new Uint8Array(8 + 4 + nameBytes.length + 1);
165
+ data.set(REGISTER_WITH_FORTI_DISCRIMINATOR, 0);
166
+ new DataView(data.buffer).setUint32(8, nameBytes.length, true);
167
+ data.set(nameBytes, 12);
168
+ data[12 + nameBytes.length] = p.handleType;
169
+ const keys = [
170
+ { pubkey: toBase58(p.payer, "payer"), isSigner: true, isWritable: true },
171
+ { pubkey: toBase58(p.owner, "owner"), isSigner: false, isWritable: false },
172
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: true },
173
+ { pubkey: toBase58(p.fortiMint, "fortiMint"), isSigner: false, isWritable: false },
174
+ { pubkey: toBase58(p.payerFortiAccount, "payerFortiAccount"), isSigner: false, isWritable: true },
175
+ { pubkey: toBase58(p.fortiTreasuryAta, "fortiTreasuryAta"), isSigner: false, isWritable: true },
176
+ { pubkey: toBase58(p.fortiBuybackRecipient, "fortiBuybackRecipient"), isSigner: false, isWritable: true },
177
+ { pubkey: toBase58(p.veFortiLock, "veFortiLock"), isSigner: false, isWritable: false },
178
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
179
+ { pubkey: toBase58(p.tokenProgram, "tokenProgram"), isSigner: false, isWritable: false },
180
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
181
+ ];
182
+ return { programId: toBase58(p.programId, "programId"), keys, data };
183
+ }
package/dist/subname.d.ts CHANGED
@@ -259,6 +259,11 @@ export declare function fetchSubnames(rpc: RpcFn, programId: string, parentHandl
259
259
  * This does NOT apply layer 1 (subname-vs-parent). Use
260
260
  * {@link resolveSubnameRecords} for the complete, safe resolution — or gate
261
261
  * this call on {@link subnameIsLive} yourself.
262
+ *
263
+ * `recordsClearedAt` is fixed at `0n`, not the parent handle's: `clear_records`
264
+ * (#8139) only ever writes the PARENT `Handle` account's own extension — a
265
+ * subname is a separate account with no `records_cleared_at` of its own, so
266
+ * the parent's clear has no bearing on records keyed by the subname's pubkey.
262
267
  */
263
268
  export declare function fetchSubnameRecords(rpc: RpcFn, programId: string, subnameAccount: string, subnameCreatedAt: bigint): Promise<HandleRecord[]>;
264
269
  /**
package/dist/subname.js CHANGED
@@ -339,12 +339,17 @@ export async function fetchSubnames(rpc, programId, parentHandleAccount, parentR
339
339
  * This does NOT apply layer 1 (subname-vs-parent). Use
340
340
  * {@link resolveSubnameRecords} for the complete, safe resolution — or gate
341
341
  * this call on {@link subnameIsLive} yourself.
342
+ *
343
+ * `recordsClearedAt` is fixed at `0n`, not the parent handle's: `clear_records`
344
+ * (#8139) only ever writes the PARENT `Handle` account's own extension — a
345
+ * subname is a separate account with no `records_cleared_at` of its own, so
346
+ * the parent's clear has no bearing on records keyed by the subname's pubkey.
342
347
  */
343
348
  export async function fetchSubnameRecords(rpc, programId, subnameAccount, subnameCreatedAt) {
344
349
  // The record filter already includes size + Record discriminator; the
345
350
  // handle-field memcmp is the subname pubkey here. Reuse the exact scanner so
346
351
  // subname records and handle records can never decode differently.
347
- return fetchRecords(rpc, programId, subnameAccount, subnameCreatedAt);
352
+ return fetchRecords(rpc, programId, subnameAccount, subnameCreatedAt, 0n);
348
353
  }
349
354
  /**
350
355
  * Resolve a subname's LIVE address records — the ONLY safe entry point, both
@@ -45,6 +45,14 @@
45
45
  * There is deliberately no way to decode or fetch a text record through
46
46
  * this module without the handle's `registered_at` in hand.
47
47
  *
48
+ * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
49
+ *
50
+ * `clear_records` lets an owner bulk-invalidate every record RIGHT NOW,
51
+ * without a transfer — text records too. The same functions therefore also
52
+ * demand the handle's `recordsClearedAt` (0 if never cleared, from
53
+ * `ParsedHandle`), and a text record is `stale` when EITHER
54
+ * `updated_at < registeredAt` OR `updated_at < recordsClearedAt`.
55
+ *
48
56
  * # These records count (#7384)
49
57
  *
50
58
  * `create_text_record` increments — and `close_text_record` decrements —
@@ -140,15 +148,17 @@ export declare function textValueToString(value: Uint8Array): string | null;
140
148
  * value: Vec<u8> (u32 len + bytes, max 256) updated_at: i64(8) bump: u8(1)
141
149
  *
142
150
  * `registeredAt` is the owning Handle's `registered_at`, decoded by the
143
- * caller from the Handle account — it decides `stale`. There is
144
- * intentionally no overload without it (see the module docs).
151
+ * caller from the Handle account — it decides `stale`. `recordsClearedAt` is
152
+ * that same Handle's `recordsClearedAt` (0 if never cleared, #8139), a
153
+ * SECOND independent staleness anchor. There is intentionally no overload
154
+ * without either (see the module docs).
145
155
  *
146
156
  * Returns null for anything that is not a TextRecord: wrong length, wrong
147
157
  * discriminator, lengths that do not fit, or a key that is not valid UTF-8
148
158
  * (the program's Borsh `String` guarantees it is, so that is not a
149
159
  * TextRecord).
150
160
  */
151
- export declare function decodeTextRecord(raw: Uint8Array, account: string, registeredAt: bigint): HandleTextRecord | null;
161
+ export declare function decodeTextRecord(raw: Uint8Array, account: string, registeredAt: bigint, recordsClearedAt: bigint): HandleTextRecord | null;
152
162
  /** The text records the CURRENT owner actually has — `stale` ones excluded.
153
163
  * This is the list to render on a profile and count. */
154
164
  export declare function liveTextRecords(records: readonly HandleTextRecord[]): HandleTextRecord[];
@@ -162,12 +172,13 @@ export declare function liveTextRecords(records: readonly HandleTextRecord[]): H
162
172
  *
163
173
  * `registeredAt` is `Handle.registered_at` as decoded from the Handle
164
174
  * account the caller already has — the staleness rule needs it, and there
165
- * is no variant of this function without it. Every record is returned,
166
- * stale ones flagged, so an owner surface can show what a previous owner
167
- * left behind; anything that renders a profile takes
175
+ * is no variant of this function without it. `recordsClearedAt` is that same
176
+ * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it through.
177
+ * Every record is returned, stale ones flagged, so an owner surface can show
178
+ * what a previous owner left behind; anything that renders a profile takes
168
179
  * {@link liveTextRecords}.
169
180
  */
170
- export declare function fetchTextRecords(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint): Promise<HandleTextRecord[]>;
181
+ export declare function fetchTextRecords(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint): Promise<HandleTextRecord[]>;
171
182
  /** The delegate-aware trailing accounts every text-record builder shares —
172
183
  * the exact rules of `delegate.ts`'s module docs. */
173
184
  interface RecordEditAuthorityParams {
@@ -45,6 +45,14 @@
45
45
  * There is deliberately no way to decode or fetch a text record through
46
46
  * this module without the handle's `registered_at` in hand.
47
47
  *
48
+ * # `records_cleared_at` (#8139) — the SECOND, independent staleness rule
49
+ *
50
+ * `clear_records` lets an owner bulk-invalidate every record RIGHT NOW,
51
+ * without a transfer — text records too. The same functions therefore also
52
+ * demand the handle's `recordsClearedAt` (0 if never cleared, from
53
+ * `ParsedHandle`), and a text record is `stale` when EITHER
54
+ * `updated_at < registeredAt` OR `updated_at < recordsClearedAt`.
55
+ *
48
56
  * # These records count (#7384)
49
57
  *
50
58
  * `create_text_record` increments — and `close_text_record` decrements —
@@ -164,15 +172,17 @@ export function textValueToString(value) {
164
172
  * value: Vec<u8> (u32 len + bytes, max 256) updated_at: i64(8) bump: u8(1)
165
173
  *
166
174
  * `registeredAt` is the owning Handle's `registered_at`, decoded by the
167
- * caller from the Handle account — it decides `stale`. There is
168
- * intentionally no overload without it (see the module docs).
175
+ * caller from the Handle account — it decides `stale`. `recordsClearedAt` is
176
+ * that same Handle's `recordsClearedAt` (0 if never cleared, #8139), a
177
+ * SECOND independent staleness anchor. There is intentionally no overload
178
+ * without either (see the module docs).
169
179
  *
170
180
  * Returns null for anything that is not a TextRecord: wrong length, wrong
171
181
  * discriminator, lengths that do not fit, or a key that is not valid UTF-8
172
182
  * (the program's Borsh `String` guarantees it is, so that is not a
173
183
  * TextRecord).
174
184
  */
175
- export function decodeTextRecord(raw, account, registeredAt) {
185
+ export function decodeTextRecord(raw, account, registeredAt, recordsClearedAt) {
176
186
  if (raw.length !== TEXT_RECORD_LEN)
177
187
  return null;
178
188
  for (let i = 0; i < 8; i++) {
@@ -207,7 +217,7 @@ export function decodeTextRecord(raw, account, registeredAt) {
207
217
  value,
208
218
  text: textValueToString(value),
209
219
  updatedAt,
210
- stale: updatedAt < registeredAt,
220
+ stale: updatedAt < registeredAt || updatedAt < recordsClearedAt,
211
221
  };
212
222
  }
213
223
  /** The text records the CURRENT owner actually has — `stale` ones excluded.
@@ -225,12 +235,13 @@ export function liveTextRecords(records) {
225
235
  *
226
236
  * `registeredAt` is `Handle.registered_at` as decoded from the Handle
227
237
  * account the caller already has — the staleness rule needs it, and there
228
- * is no variant of this function without it. Every record is returned,
229
- * stale ones flagged, so an owner surface can show what a previous owner
230
- * left behind; anything that renders a profile takes
238
+ * is no variant of this function without it. `recordsClearedAt` is that same
239
+ * Handle's `recordsClearedAt` (0 if never cleared, #8139) — pass it through.
240
+ * Every record is returned, stale ones flagged, so an owner surface can show
241
+ * what a previous owner left behind; anything that renders a profile takes
231
242
  * {@link liveTextRecords}.
232
243
  */
233
- export async function fetchTextRecords(rpc, programId, handleAccount, registeredAt) {
244
+ export async function fetchTextRecords(rpc, programId, handleAccount, registeredAt, recordsClearedAt) {
234
245
  const res = (await rpc("getProgramAccounts", [
235
246
  programId,
236
247
  {
@@ -249,7 +260,7 @@ export async function fetchTextRecords(rpc, programId, handleAccount, registered
249
260
  const raw = new Uint8Array(bin.length);
250
261
  for (let i = 0; i < bin.length; i++)
251
262
  raw[i] = bin.charCodeAt(i);
252
- const decoded = decodeTextRecord(raw, a.pubkey, registeredAt);
263
+ const decoded = decodeTextRecord(raw, a.pubkey, registeredAt, recordsClearedAt);
253
264
  // Defence in depth: the memcmp filter should guarantee the handle
254
265
  // match, but a wrong offset would silently attribute someone else's
255
266
  // record to this handle. A malformed account is skipped, not fatal.