@x1id/resolve 0.3.0 → 0.7.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Write builders for the address `Record` family: `create_record` and the
3
+ * three `verify_record_*` proof-of-control instructions (ETH/secp256k1,
4
+ * SVM/ed25519, BTC/BIP-137). `records.ts` was read-only until now — every
5
+ * other record family (text records, attestations, vouchers, subnames...)
6
+ * already had write builders; this was the one gap (WP #8142).
7
+ *
8
+ * Hand-rolled like the rest of this package — no Anchor client, no
9
+ * `@solana/web3.js` import. PDAs are NOT derived here (see delegate.ts's
10
+ * module docs for why) — derive `["record", handlePda, coinTypeLE(4)]`
11
+ * with your runtime's canonical `findProgramAddress`.
12
+ *
13
+ * # What this SDK does and does not do for you
14
+ *
15
+ * This does NOT sign anything, derive an address from a signature, or
16
+ * verify a signature client-side — that's the on-chain program's job
17
+ * (`verify_record_eth`/`svm`/`btc` recover the signer's address from the
18
+ * signature itself and compare it to the record's claimed value; a client
19
+ * proving nothing false cannot fabricate a match). What this module DOES do:
20
+ *
21
+ * 1. {@link buildRecordChallenge} — build the exact bytes a wallet must
22
+ * sign, byte-identical to `handle-normalize`'s Rust `challenge()`
23
+ * (verified: `crates/handle-normalize/src/challenge.rs`). Unlike the
24
+ * off-chain control-proof flow (`control.ts`), this challenge's nonce
25
+ * does NOT need to be server-issued — the on-chain transaction landing
26
+ * IS the single-use event, so any ASCII-alphanumeric nonce (1..=64
27
+ * chars) the caller picks is fine.
28
+ * 2. {@link splitEthSignature} / {@link splitBtcSignature} — repackage what
29
+ * a wallet's `personal_sign` (ETH) or `signmessage` (BTC) actually
30
+ * returns into the exact args each verify instruction expects.
31
+ * 3. The four instruction builders themselves.
32
+ *
33
+ * # ETH: EIP-191 `personal_sign`, not EIP-712
34
+ *
35
+ * `verify_record_eth` (lib.rs) hashes the challenge with EIP-191's
36
+ * `"\x19Ethereum Signed Message:\n" + len + message` framing — the exact
37
+ * framing every wallet's `personal_sign` / `eth_sign` RPC method produces.
38
+ * There is no EIP-712 typed-data variant on-chain to build a client for.
39
+ *
40
+ * # BTC: mainnet-only, compressed P2PKH or bech32/P2WPKH only
41
+ *
42
+ * `verify_record_btc` derives the address FROM the recovered pubkey (never
43
+ * trusts a client-supplied address) and only accepts the header-byte ranges
44
+ * for compressed P2PKH (31-34) and bech32/P2WPKH (39-42) — see `btc.rs`'s
45
+ * module doc for exactly why uncompressed P2PKH and P2SH-segwit are refused
46
+ * rather than guessed. {@link splitBtcSignature} passes the header byte
47
+ * through unchanged; it is the wallet's job to produce one of the two
48
+ * supported ranges (most current wallets do).
49
+ */
50
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
51
+ import { recordDelegateNonePlaceholder, recordDelegateSomeSlot } from "./delegate.js";
52
+ import { normalizeHandle } from "./parse.js";
53
+ import { ResolveError } from "./types.js";
54
+ /** Matches `control.ts`'s identically-named private helper: STRICT
55
+ * canonical-form check (reject, never silently transform), mirroring the
56
+ * Rust `is_canonical` the challenge builder must match exactly —
57
+ * `normalizeHandle` alone would accept `"@Alice"` by transforming it,
58
+ * which `handle_normalize::challenge` (Rust) does NOT: it demands
59
+ * already-canonical input and rejects anything else outright. */
60
+ function isCanonicalHandle(handle) {
61
+ try {
62
+ return normalizeHandle(handle) === handle;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ export const CREATE_RECORD_DISCRIMINATOR = Uint8Array.from([
69
+ 116, 124, 63, 58, 126, 204, 178, 10,
70
+ ]);
71
+ export const VERIFY_RECORD_ETH_DISCRIMINATOR = Uint8Array.from([
72
+ 57, 157, 98, 0, 160, 210, 94, 234,
73
+ ]);
74
+ export const VERIFY_RECORD_SVM_DISCRIMINATOR = Uint8Array.from([
75
+ 234, 151, 82, 212, 185, 112, 234, 212,
76
+ ]);
77
+ export const VERIFY_RECORD_BTC_DISCRIMINATOR = Uint8Array.from([
78
+ 54, 50, 183, 232, 122, 200, 194, 64,
79
+ ]);
80
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
81
+ const NONCE_RE = /^[0-9A-Za-z]{1,64}$/;
82
+ function toBytes32(v, what) {
83
+ if (typeof v === "string") {
84
+ const b = decodeBase58_32(v);
85
+ if (!b)
86
+ throw new Error(`${what} is not a valid base58 address`);
87
+ return b;
88
+ }
89
+ if (v.length !== 32)
90
+ throw new Error(`${what} must be exactly 32 bytes`);
91
+ return v;
92
+ }
93
+ function toBase58(v, what) {
94
+ return encodeBase58(toBytes32(v, what));
95
+ }
96
+ function encVec(bytes) {
97
+ const out = new Uint8Array(4 + bytes.length);
98
+ new DataView(out.buffer).setUint32(0, bytes.length, true);
99
+ out.set(bytes, 4);
100
+ return out;
101
+ }
102
+ function encString(s) {
103
+ return encVec(new TextEncoder().encode(s));
104
+ }
105
+ function pushAuthorityTail(keys, programId, p) {
106
+ if (p.recordDelegate !== undefined) {
107
+ keys.push(recordDelegateSomeSlot(p.recordDelegate));
108
+ }
109
+ else if (p.holderTokenAccount !== undefined) {
110
+ keys.push(recordDelegateNonePlaceholder(programId));
111
+ }
112
+ if (p.holderTokenAccount !== undefined) {
113
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
114
+ }
115
+ }
116
+ // ---------------------------------------------------------------------------
117
+ // Challenge
118
+ // ---------------------------------------------------------------------------
119
+ /**
120
+ * `x1-handles:v1:<handle>:<coinType>:<registeredAt>:<nonce>` — byte-identical
121
+ * to `handle_normalize::challenge` (Rust). `handle` must already be
122
+ * canonical (pass it through {@link import("./parse.js").normalizeHandle}
123
+ * first if it might have a leading `@` or mixed case).
124
+ */
125
+ export function buildRecordChallenge(handle, coinType, registeredAt, nonce) {
126
+ if (!isCanonicalHandle(handle)) {
127
+ throw new ResolveError("invalid-handle", `"${handle}" is not a canonical handle`, handle);
128
+ }
129
+ if (!NONCE_RE.test(nonce)) {
130
+ throw new Error("nonce must be 1..=64 ASCII alphanumeric characters");
131
+ }
132
+ return `x1-handles:v1:${handle}:${coinType}:${registeredAt}:${nonce}`;
133
+ }
134
+ /** Split a 65-byte ETH `personal_sign` output (`r(32) || s(32) || v(1)`)
135
+ * into `verify_record_eth`'s `{signature: [u8;64], recoveryId: u8}`,
136
+ * normalizing `v`'s two common conventions (27/28, matching most wallets'
137
+ * raw RPC output, or already 0/1). */
138
+ export function splitEthSignature(sig65) {
139
+ if (sig65.length !== 65)
140
+ throw new Error("ETH signature must be exactly 65 bytes (r || s || v)");
141
+ const v = sig65[64];
142
+ const recoveryId = v >= 27 ? v - 27 : v;
143
+ if (recoveryId !== 0 && recoveryId !== 1) {
144
+ throw new Error(`unrecognized recovery id byte: ${v}`);
145
+ }
146
+ return { signature: sig65.slice(0, 64), recoveryId };
147
+ }
148
+ /** Split a 65-byte BTC BIP-137 `signmessage` output (`header(1) || r(32) ||
149
+ * s(32)`) into `verify_record_btc`'s `{header, signature: [u8;64]}`. The
150
+ * header byte is passed through unchanged — see the module docs for which
151
+ * ranges `verify_record_btc` accepts. */
152
+ export function splitBtcSignature(sig65) {
153
+ if (sig65.length !== 65)
154
+ throw new Error("BTC signature must be exactly 65 bytes (header || r || s)");
155
+ return { header: sig65[0], signature: sig65.slice(1) };
156
+ }
157
+ export function buildCreateRecordIx(p) {
158
+ if (p.value.length === 0 || p.value.length > 64) {
159
+ throw new Error("value must be 1..=64 bytes");
160
+ }
161
+ const coinTypeBytes = new Uint8Array(4);
162
+ new DataView(coinTypeBytes.buffer).setUint32(0, p.coinType, true);
163
+ const data = new Uint8Array(8 + 4 + 4 + p.value.length);
164
+ data.set(CREATE_RECORD_DISCRIMINATOR, 0);
165
+ data.set(coinTypeBytes, 8);
166
+ data.set(encVec(p.value), 12);
167
+ const keys = [
168
+ { pubkey: toBase58(p.payer, "payer"), isSigner: true, isWritable: true },
169
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
170
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: true },
171
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
172
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
173
+ ];
174
+ return { programId: toBase58(p.programId, "programId"), keys, data };
175
+ }
176
+ export function buildVerifyRecordEthIx(p) {
177
+ if (p.signature.length !== 64)
178
+ throw new Error("signature must be exactly 64 bytes (r || s)");
179
+ if (p.recoveryId !== 0 && p.recoveryId !== 1)
180
+ throw new Error("recoveryId must be 0 or 1");
181
+ if (!NONCE_RE.test(p.nonce))
182
+ throw new Error("nonce must be 1..=64 ASCII alphanumeric characters");
183
+ const nonceEnc = encString(p.nonce);
184
+ const data = new Uint8Array(8 + nonceEnc.length + 64 + 1);
185
+ data.set(VERIFY_RECORD_ETH_DISCRIMINATOR, 0);
186
+ data.set(nonceEnc, 8);
187
+ data.set(p.signature, 8 + nonceEnc.length);
188
+ data[8 + nonceEnc.length + 64] = p.recoveryId;
189
+ const keys = [
190
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
191
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
192
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
193
+ ];
194
+ pushAuthorityTail(keys, p.programId, p);
195
+ return { programId: toBase58(p.programId, "programId"), keys, data };
196
+ }
197
+ export function buildVerifyRecordSvmIx(p) {
198
+ const keys = [
199
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
200
+ { pubkey: toBase58(p.claimed, "claimed"), isSigner: true, isWritable: false },
201
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
202
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
203
+ ];
204
+ pushAuthorityTail(keys, p.programId, p);
205
+ return { programId: toBase58(p.programId, "programId"), keys, data: VERIFY_RECORD_SVM_DISCRIMINATOR.slice() };
206
+ }
207
+ export function buildVerifyRecordBtcIx(p) {
208
+ if (p.signature.length !== 64)
209
+ throw new Error("signature must be exactly 64 bytes (r || s)");
210
+ if (!Number.isInteger(p.header) || p.header < 0 || p.header > 255) {
211
+ throw new Error("header must be a single byte (0-255)");
212
+ }
213
+ if (!NONCE_RE.test(p.nonce))
214
+ throw new Error("nonce must be 1..=64 ASCII alphanumeric characters");
215
+ const nonceEnc = encString(p.nonce);
216
+ const data = new Uint8Array(8 + nonceEnc.length + 65);
217
+ data.set(VERIFY_RECORD_BTC_DISCRIMINATOR, 0);
218
+ data.set(nonceEnc, 8);
219
+ data[8 + nonceEnc.length] = p.header;
220
+ data.set(p.signature, 8 + nonceEnc.length + 1);
221
+ const keys = [
222
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: false },
223
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
224
+ { pubkey: toBase58(p.record, "record"), isSigner: false, isWritable: true },
225
+ ];
226
+ pushAuthorityTail(keys, p.programId, p);
227
+ return { programId: toBase58(p.programId, "programId"), keys, data };
228
+ }
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
+ }