@x1id/resolve 0.2.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Per-chain payment records (`Record` accounts) — read WITH the universal
3
+ * staleness rule from docs/record-trust.md structurally enforced.
4
+ *
5
+ * # Why every function here demands `registeredAt`
6
+ *
7
+ * A `Handle`'s address is `["handle", name]` — a pure function of the name.
8
+ * Release + re-register lands the new registration at the SAME pubkey, and
9
+ * `Record` PDAs (`["record", handle_pubkey, coin_type]`) hang off that pubkey,
10
+ * so a record a PREVIOUS owner created (possibly `verified: true` for THEIR
11
+ * address) is physically attached to the new owner's name with no action by
12
+ * anyone. The program cannot prevent this (state.rs, `Handle` doc comment);
13
+ * the mandatory read-side rule is:
14
+ *
15
+ * record.updated_at >= handle.registered_at
16
+ *
17
+ * A record that fails it belongs to a previous, unrelated owner and must be
18
+ * treated as unset/unverified — for PAYMENT resolution, not just badges.
19
+ * There is deliberately no way to decode or fetch a record through this
20
+ * module without the handle's `registered_at` in hand, mirroring
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.
31
+ */
32
+ import { encodeBase58 } from "./base58.js";
33
+ import { CHAIN_COIN_TYPE } from "./types.js";
34
+ /** `8 + Record::INIT_SPACE` — the program allocates the full 64-byte `value`
35
+ * capacity, so every Record account is exactly this long:
36
+ * 8 + 32 + 4 + (4 + 64) + 1 + 8 + 1. */
37
+ export const RECORD_LEN = 122;
38
+ /** `sha256("account:Record")[..8]`, hex — the tag every Record account's
39
+ * data starts with. Pinned (this SDK is zero-dependency and cannot assume
40
+ * WebCrypto SHA-256 everywhere it runs); the value is asserted against a
41
+ * re-derivation in the test suite so a typo can never silently pass. */
42
+ export const RECORD_DISC = "fee975fc4ca6928b";
43
+ /** Byte offset of `handle` within a Record account: the 8-byte discriminator. */
44
+ const RECORD_HANDLE_OFFSET = 8;
45
+ /** Reverse of CHAIN_COIN_TYPE — which chain a stored coin_type belongs to, or
46
+ * null for a coin_type this SDK does not know how to render. */
47
+ export function chainForCoinType(coinType) {
48
+ for (const c of Object.keys(CHAIN_COIN_TYPE)) {
49
+ if (CHAIN_COIN_TYPE[c] === coinType)
50
+ return c;
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Decode one Record account.
56
+ *
57
+ * Record account layout, after the 8-byte Anchor discriminator:
58
+ *
59
+ * handle: Pubkey(32) coin_type: u32(4) value: Vec<u8> (u32 len + bytes, max 64)
60
+ * verified: bool(1) updated_at: i64(8) bump: u8(1)
61
+ *
62
+ * `registeredAt` is the owning Handle's `registered_at`, decoded by the
63
+ * caller from the Handle account — it decides `stale` (and so `verified`).
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.
68
+ *
69
+ * Returns null for anything that is not a Record: wrong length, wrong
70
+ * discriminator, or a `value` length that does not fit. The Listing / Offer /
71
+ * Auction PDAs of the same handle also carry the handle pubkey at offset 8,
72
+ * so a handle-only memcmp scan DOES return them — they are rejected here by
73
+ * tag and size, not by luck.
74
+ */
75
+ export function decodeRecord(raw, account, registeredAt, recordsClearedAt) {
76
+ if (raw.length !== RECORD_LEN)
77
+ return null;
78
+ for (let i = 0; i < 8; i++) {
79
+ if (raw[i] !== parseInt(RECORD_DISC.slice(i * 2, i * 2 + 2), 16))
80
+ return null;
81
+ }
82
+ const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
83
+ let o = 8;
84
+ const handle = encodeBase58(raw.slice(o, o + 32));
85
+ o += 32;
86
+ const coinType = dv.getUint32(o, true);
87
+ o += 4;
88
+ const len = dv.getUint32(o, true);
89
+ o += 4;
90
+ // `#[max_len(64)]` — and the fixed fields after `value` must still fit.
91
+ if (len > 64 || o + len + 1 + 8 + 1 > raw.length)
92
+ return null;
93
+ const value = raw.slice(o, o + len);
94
+ o += len;
95
+ const verified = raw[o] === 1;
96
+ o += 1;
97
+ const updatedAt = dv.getBigInt64(o, true);
98
+ const stale = updatedAt < registeredAt || updatedAt < recordsClearedAt;
99
+ const chain = chainForCoinType(coinType);
100
+ return {
101
+ account,
102
+ handle,
103
+ coinType,
104
+ chain,
105
+ value,
106
+ address: valueToAddress(chain, value),
107
+ verified: verified && !stale,
108
+ updatedAt,
109
+ stale,
110
+ };
111
+ }
112
+ /** The records the CURRENT owner actually has — `stale` ones excluded. This
113
+ * is the list to resolve against, display to a visitor, and count. */
114
+ export function liveRecords(records) {
115
+ return records.filter((r) => !r.stale);
116
+ }
117
+ /**
118
+ * Render stored record bytes for display / payment, per the conventions
119
+ * app/src/lib/x1/records.ts writes them with: X1/SOL are 32-byte pubkeys
120
+ * (base58), ETH is 20 raw bytes (0x-hex), BTC is the UTF-8 bytes of the
121
+ * canonical address string. Unknown coin types render as hex so a value is
122
+ * never silently hidden.
123
+ */
124
+ export function valueToAddress(chain, value) {
125
+ const hex = () => `0x${Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("")}`;
126
+ if (chain === "X1" || chain === "SOL") {
127
+ return value.length === 32 ? encodeBase58(value) : hex();
128
+ }
129
+ if (chain === "ETH")
130
+ return hex();
131
+ if (chain === "BTC") {
132
+ try {
133
+ return new TextDecoder("utf-8", { fatal: true }).decode(value);
134
+ }
135
+ catch {
136
+ return hex();
137
+ }
138
+ }
139
+ return hex();
140
+ }
141
+ function hexToBytes(hex) {
142
+ const out = new Uint8Array(hex.length / 2);
143
+ for (let i = 0; i < out.length; i++)
144
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
145
+ return out;
146
+ }
147
+ /**
148
+ * Fetch every Record account of a handle — one `getProgramAccounts` call,
149
+ * filtered by the RPC on size (122), the Record discriminator at offset 0
150
+ * and the handle pubkey at offset 8, then every byte re-checked locally
151
+ * (the node's filters are an optimisation, never the guarantee). Scoping the
152
+ * scan to the registry program id also IS the ownership check: a foreign
153
+ * account cannot appear in it.
154
+ *
155
+ * `registeredAt` is `Handle.registered_at` as decoded from the Handle
156
+ * account the caller already has — the staleness rule needs it, and there
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(...)`.
166
+ */
167
+ export async function fetchRecords(rpc, programId, handleAccount, registeredAt, recordsClearedAt) {
168
+ const res = (await rpc("getProgramAccounts", [
169
+ programId,
170
+ {
171
+ encoding: "base64",
172
+ commitment: "confirmed",
173
+ filters: [
174
+ { dataSize: RECORD_LEN },
175
+ { memcmp: { offset: 0, bytes: encodeBase58(hexToBytes(RECORD_DISC)) } },
176
+ { memcmp: { offset: RECORD_HANDLE_OFFSET, bytes: handleAccount } },
177
+ ],
178
+ },
179
+ ]));
180
+ const out = [];
181
+ for (const a of res ?? []) {
182
+ const bin = atob(a.account.data[0]);
183
+ const raw = new Uint8Array(bin.length);
184
+ for (let i = 0; i < bin.length; i++)
185
+ raw[i] = bin.charCodeAt(i);
186
+ const r = decodeRecord(raw, a.pubkey, registeredAt, recordsClearedAt);
187
+ // Defence in depth: the memcmp filter should guarantee the handle match,
188
+ // but a wrong offset would silently attribute someone else's record to
189
+ // this handle. A malformed account is skipped, not fatal to the list.
190
+ if (r && r.handle === handleAccount)
191
+ out.push(r);
192
+ }
193
+ out.sort((x, y) => (x.chain ?? "").localeCompare(y.chain ?? "") || x.coinType - y.coinType);
194
+ return out;
195
+ }
@@ -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
+ }