@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.
package/dist/index.js CHANGED
@@ -28,9 +28,34 @@ export * from "./types.js";
28
28
  export { normalizeHandle, parseName, looksLikeName } from "./parse.js";
29
29
  export { WasmResolver } from "./wasm.js";
30
30
  export { encodeBase58, decodeBase58_32 } from "./base58.js";
31
- import { ResolveError } from "./types.js";
31
+ export { decodeRecord, liveRecords, chainForCoinType, valueToAddress, fetchRecords, RECORD_LEN, RECORD_DISC, } from "./records.js";
32
+ export { buildControlChallenge, parseControlChallenge, generateControlNonce, createControlChallenge, verifyControlProof, verifyEd25519Strict, CONTROL_CHALLENGE_PREFIX, } from "./control.js";
33
+ export * from "./delegate.js";
34
+ export * from "./subname.js";
35
+ export * from "./recordCount.js";
36
+ export * from "./attestation.js";
37
+ export * from "./textRecords.js";
38
+ export * from "./lock.js";
39
+ export * from "./integrator.js";
40
+ export * from "./voucher.js";
41
+ export * from "./agent.js";
42
+ export * from "./register.js";
43
+ // Was previously imported here for internal use only, never re-exported —
44
+ // promoted to public API 2026-09-22 because a second real package (mcp/)
45
+ // now needs `parseHandleAccount`/`RpcFn` directly rather than duplicating
46
+ // this decoder (the Handle account's `Option<Pubkey>` fields are
47
+ // variable-width Borsh — a hand-rolled offset guess here would be exactly
48
+ // the kind of drift docs/record-trust.md's "one implementation" rule
49
+ // exists to prevent).
50
+ export * from "./accounts.js";
51
+ export * from "./x402.js";
52
+ export * from "./recordWrite.js";
53
+ export * from "./clearRecords.js";
54
+ import { ResolveError, CHAIN_COIN_TYPE } from "./types.js";
32
55
  import { parseName } from "./parse.js";
33
56
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
57
+ import { DEFAULT_HANDLE_PROGRAM, TOKEN_ACCOUNT_MIN_LEN, bytesEqual, makeAccountReader, nftHolder, parseHandleAccount, readI64, readU64, } from "./accounts.js";
58
+ import { fetchRecords, liveRecords } from "./records.js";
34
59
  /** Root authority for each X1NS TLD — used to verify a fetched account really
35
60
  * belongs to the TLD it claims. Without this check a caller handed an
36
61
  * arbitrary account would read an owner straight out of it. */
@@ -40,93 +65,16 @@ const TLD_ROOT = Object.freeze({
40
65
  xen: "3SUwpSz33AsyJwf6B48cKZuDTswuUEdUhcXszZrFWPqo",
41
66
  });
42
67
  const SPL_NAME_HEADER_LEN = 96;
43
- /** The @handle registry program on X1. Deployed on testnet today; the same id
44
- * is used on mainnet once deployed. Override via `handleProgramId` to point at
45
- * a different deployment. */
46
- const DEFAULT_HANDLE_PROGRAM = "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P";
47
- // Handle account layout, mirrored from the on-chain program:
48
- // discriminator(8) name(32) name_len(1) owner(32) ...
49
- // `owner` is the address an UNTOKENIZED handle resolves to. Once the handle
50
- // is tokenized (NFT extension present, see `parseHandleAccount`) the program
51
- // treats `owner` as informational only — authority is whoever holds the NFT —
52
- // and so must every reader.
53
- const HANDLE_OWNER_OFFSET = 41;
54
- // `Handle`'s fixed base allocation (`space = 8 + INIT_SPACE`). The NFT
55
- // extension, when present, is appended at this boundary regardless of the
56
- // compact Borsh length of the (variable, `Option`-bearing) struct content —
57
- // mirrors `Handle::NFT_EXT_OFFSET` in the program and `HANDLE_BASE_LEN` in
58
- // tools/api.
59
- const HANDLE_BASE_LEN = 8 + 149;
60
68
  // `Primary` pointer account (`["primary", owner]` under the registry):
61
69
  // disc(8) | owner(32) | handle(32) | set_at(i64 LE, 8) | bump(1) = 81 bytes
62
70
  const PRIMARY_LEN = 81;
63
71
  const PRIMARY_HANDLE_OFFSET = 40;
64
72
  const PRIMARY_SET_AT_OFFSET = 72;
65
- // SPL Token account: mint(32) | owner(32) | amount(u64 LE, 8) | ...
66
- const TOKEN_ACCOUNT_MIN_LEN = 72;
67
- // SPL Token mint: mint_authority COption(4+32) | supply(u64 LE, 8) @36 | ...
68
- const MINT_SUPPLY_OFFSET = 36;
69
- const MINT_MIN_LEN = MINT_SUPPLY_OFFSET + 8;
70
- // The registry mints handle NFTs with the legacy SPL Token program, so every
71
- // token account for a handle mint is owned by it.
72
- const SPL_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
73
- function readI64(data, offset) {
74
- return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(offset, true);
75
- }
76
- function readU64(data, offset) {
77
- return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(offset, true);
78
- }
79
- function bytesEqual(a, b) {
80
- if (a.length !== b.length)
81
- return false;
82
- for (let i = 0; i < a.length; i++)
83
- if (a[i] !== b[i])
84
- return false;
85
- return true;
86
- }
87
- /**
88
- * Parse the `Handle` fields the reverse rule needs. Port of `parse_handle` in
89
- * tools/api — sequential, because `recovery` / `recovery_target` are
90
- * `Option<Pubkey>` (Borsh: tag byte, then 32 bytes when `Some`) and shift
91
- * `registered_at`. The NFT mint is read at the fixed base boundary, not the
92
- * sequential position.
93
- *
94
- * disc(8) name(32) name_len(1) owner(32) handle_type(1)
95
- * recovery: Option<Pubkey> recovery_initiated_at: i64
96
- * recovery_target: Option<Pubkey> registered_at: i64 bump(1)
97
- * [at 8+149: nft tag(1) mint(32)]
98
- */
99
- function parseHandleAccount(data) {
100
- if (data.length < HANDLE_BASE_LEN)
101
- return null;
102
- const nameLen = Math.min(data[40], 32);
103
- const name = new TextDecoder().decode(data.slice(8, 8 + nameLen));
104
- const owner = data.slice(HANDLE_OWNER_OFFSET, HANDLE_OWNER_OFFSET + 32);
105
- let pos = 73 + 1; // owner end + handle_type(1)
106
- // recovery: Option<Pubkey>
107
- if (pos >= data.length)
108
- return null;
109
- pos += 1 + (data[pos] === 1 ? 32 : 0);
110
- pos += 8; // recovery_initiated_at
111
- // recovery_target: Option<Pubkey>
112
- if (pos >= data.length)
113
- return null;
114
- pos += 1 + (data[pos] === 1 ? 32 : 0);
115
- if (pos + 8 > data.length)
116
- return null;
117
- const registeredAt = readI64(data, pos);
118
- const nftMint = data.length >= HANDLE_BASE_LEN + 33 && data[HANDLE_BASE_LEN] === 1
119
- ? data.slice(HANDLE_BASE_LEN + 1, HANDLE_BASE_LEN + 33)
120
- : null;
121
- return { name, owner, registeredAt, nftMint };
122
- }
123
73
  export function createResolver(config) {
124
74
  const ttl = config.cacheTtlMs ?? 30_000;
125
75
  const cache = new Map();
126
- const doFetch = config.fetchImpl ?? globalThis.fetch;
127
- if (typeof doFetch !== "function") {
128
- throw new Error("No fetch available; pass fetchImpl in ResolverConfig");
129
- }
76
+ const reader = makeAccountReader(config.rpcUrl, config.fetchImpl);
77
+ const { rpc, accountInfo, accountData } = reader;
130
78
  const decodedProgram = decodeBase58_32(config.handleProgramId ?? DEFAULT_HANDLE_PROGRAM);
131
79
  if (!decodedProgram) {
132
80
  throw new Error("handleProgramId is not a valid base58 address");
@@ -137,48 +85,6 @@ export function createResolver(config) {
137
85
  // Re-encoded (not the caller's string) so a non-canonical base58 spelling of
138
86
  // the same key still compares equal to the RPC's `owner` field.
139
87
  const programBase58 = encodeBase58(handleProgram);
140
- async function rpc(method, params) {
141
- let res;
142
- try {
143
- res = await doFetch(config.rpcUrl, {
144
- method: "POST",
145
- headers: { "content-type": "application/json" },
146
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
147
- });
148
- }
149
- catch (e) {
150
- throw new ResolveError("rpc-error", `RPC request failed: ${String(e)}`);
151
- }
152
- if (!res.ok) {
153
- throw new ResolveError("rpc-error", `RPC returned HTTP ${res.status}`);
154
- }
155
- const body = (await res.json());
156
- if (body.error) {
157
- throw new ResolveError("rpc-error", body.error.message ?? "RPC error");
158
- }
159
- return body.result;
160
- }
161
- /** Fetch raw account data plus the owning program, or null when the account
162
- * does not exist. */
163
- async function accountInfo(address) {
164
- const result = (await rpc("getAccountInfo", [
165
- address,
166
- { encoding: "base64", commitment: "confirmed" },
167
- ]));
168
- const value = result?.value;
169
- if (!value)
170
- return null;
171
- const b64 = value.data[0];
172
- const bin = atob(b64);
173
- const out = new Uint8Array(bin.length);
174
- for (let i = 0; i < bin.length; i++)
175
- out[i] = bin.charCodeAt(i);
176
- return { data: out, owner: value.owner };
177
- }
178
- /** Fetch raw account data, or null when the account does not exist. */
179
- async function accountData(address) {
180
- return (await accountInfo(address))?.data ?? null;
181
- }
182
88
  async function resolveX1ns(canonical, label, tld, chain, input) {
183
89
  const account = config.wasm.deriveX1nsAccount(label, tld);
184
90
  if (!account) {
@@ -215,62 +121,15 @@ export function createResolver(config) {
215
121
  verification: "unverified",
216
122
  };
217
123
  }
218
- /**
219
- * Current holder of a tokenized handle: the owner of the single token
220
- * account holding the handle's NFT (supply 1, decimals 0). Never falls back
221
- * to `Handle.owner` — after the NFT changes hands that field names the
222
- * previous owner, and paying it is the exact failure this SDK exists to
223
- * prevent.
224
- *
225
- * Parity with the program's `require_current_authority` (and this
226
- * resolver's `reverse()`): the program recognises the holder as the name's
227
- * authority only when the NFT sits in the holder's **associated token
228
- * account** for the mint. A holder whose NFT is parked elsewhere (an
229
- * auxiliary account, a program escrow) still controls the token, so the
230
- * address is returned — but as `unverified`, because the registry will not
231
- * let that address act for the name until the NFT is back in its ATA.
232
- *
233
- * A burned NFT (mint supply 0) is `not-found` with reason `nft-burned`: the
234
- * name has no holder, and no one — least of all the stale `owner` — may be
235
- * paid for it. Any other failure to find the holder is an `rpc-error`.
236
- */
237
- async function nftHolder(canonical, mint, input) {
238
- const mintKey = encodeBase58(mint);
239
- const largest = (await rpc("getTokenLargestAccounts", [
240
- mintKey,
241
- { commitment: "confirmed" },
242
- ]));
243
- const holders = (largest?.value ?? []).filter((a) => a.amount === "1");
244
- if (holders.length !== 1) {
245
- const m = await accountInfo(mintKey);
246
- if (m &&
247
- m.owner === SPL_TOKEN_PROGRAM &&
248
- m.data.length >= MINT_MIN_LEN &&
249
- readU64(m.data, MINT_SUPPLY_OFFSET) === 0n) {
250
- throw new ResolveError("not-found", `@${canonical}'s NFT has been burned — the name has no holder`, input, "nft-burned");
251
- }
252
- throw new ResolveError("rpc-error", `@${canonical} is tokenized but its NFT has no single holder`, input);
253
- }
254
- const holdingAccount = holders[0].address;
255
- const t = await accountInfo(holdingAccount);
256
- if (!t ||
257
- t.owner !== SPL_TOKEN_PROGRAM ||
258
- t.data.length < TOKEN_ACCOUNT_MIN_LEN ||
259
- !bytesEqual(t.data.slice(0, 32), mint) ||
260
- readU64(t.data, 64) !== 1n) {
261
- throw new ResolveError("rpc-error", `@${canonical} is tokenized but its NFT holder account is malformed`, input);
262
- }
263
- const holder = t.data.slice(32, 64);
264
- const ata = config.wasm.deriveAssociatedTokenAccount(holder, mint);
265
- const inAta = ata !== null && encodeBase58(ata) === holdingAccount;
266
- return { address: encodeBase58(holder), verification: inAta ? "verified" : "unverified" };
267
- }
268
- async function resolveHandle(canonical, chain, input) {
124
+ /** Fetch and parse a handle's registry account, with the ownership check
125
+ * every read path shares. */
126
+ async function fetchHandle(canonical, input) {
269
127
  const account = config.wasm.deriveHandleAccount(canonical, handleProgram);
270
128
  if (!account) {
271
129
  throw new ResolveError("invalid-handle", `"${input}" is not a valid handle`, input);
272
130
  }
273
- const h = await accountInfo(encodeBase58(account));
131
+ const pda = encodeBase58(account);
132
+ const h = await accountInfo(pda);
274
133
  // An account at the PDA that the registry does not own is not a handle
275
134
  // (anyone can fund an address into existence) — the name is unregistered.
276
135
  if (!h || h.owner !== programBase58) {
@@ -280,11 +139,29 @@ export function createResolver(config) {
280
139
  if (!handle) {
281
140
  throw new ResolveError("rpc-error", `@${canonical} returned a malformed account`, input);
282
141
  }
283
- // The address is an X1/SVM address. Per-chain records (ETH/BTC) live in
284
- // separate record accounts the resolver does not read yet, so a request for
285
- // another chain is an explicit "no record" rather than a wrong address.
142
+ return { pda, handle };
143
+ }
144
+ async function resolveHandle(canonical, chain, input) {
145
+ const { pda, handle } = await fetchHandle(canonical, input);
146
+ // ETH/BTC addresses live in per-chain `Record` accounts. Resolution goes
147
+ // through `fetchRecords`, which structurally applies the staleness rule of
148
+ // docs/record-trust.md (`updated_at >= registered_at`): a record left
149
+ // behind by a previous registration of the same name is never resolved,
150
+ // and a stale `verified: true` is never surfaced.
286
151
  if (chain !== "X1" && chain !== "SOL") {
287
- throw new ResolveError("no-record-for-chain", `@${canonical} has no ${chain} record`, input);
152
+ const live = liveRecords(await fetchRecords(rpc, programBase58, pda, handle.registeredAt, handle.recordsClearedAt));
153
+ const record = live.find((r) => r.coinType === CHAIN_COIN_TYPE[chain]);
154
+ if (!record) {
155
+ throw new ResolveError("no-record-for-chain", `@${canonical} has no ${chain} record`, input);
156
+ }
157
+ return {
158
+ input,
159
+ name: canonical,
160
+ namespace: "handle",
161
+ address: record.address,
162
+ chain,
163
+ verification: record.verified ? "verified" : "unverified",
164
+ };
288
165
  }
289
166
  // Same authority rule as the program's `require_current_authority` and
290
167
  // this resolver's `reverse()`: untokenized → `Handle.owner` (verified: it
@@ -292,9 +169,18 @@ export function createResolver(config) {
292
169
  // now, verified only when it sits in that wallet's ATA (see `nftHolder`).
293
170
  const { address, verification } = handle.nftMint === null
294
171
  ? { address: encodeBase58(handle.owner), verification: "verified" }
295
- : await nftHolder(canonical, handle.nftMint, input);
172
+ : await nftHolder(reader, config.wasm, canonical, handle.nftMint, input);
296
173
  return { input, name: canonical, namespace: "handle", address, chain, verification };
297
174
  }
175
+ /** See `Resolver.records`. */
176
+ async function records(input) {
177
+ const parsed = parseName(input); // throws with a specific code
178
+ if (parsed.namespace !== "handle") {
179
+ throw new ResolveError("unrecognized", `per-chain records exist only for @handles, not .${parsed.namespace} domains`, input);
180
+ }
181
+ const { pda, handle } = await fetchHandle(parsed.canonical, input);
182
+ return fetchRecords(rpc, programBase58, pda, handle.registeredAt, handle.recordsClearedAt);
183
+ }
298
184
  async function resolve(input, opts) {
299
185
  const chain = opts?.chain ?? "X1";
300
186
  const parsed = parseName(input); // throws with a specific code
@@ -408,6 +294,7 @@ export function createResolver(config) {
408
294
  return {
409
295
  resolve,
410
296
  reverse,
297
+ records,
411
298
  clearCache: () => cache.clear(),
412
299
  };
413
300
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Integrator revenue-share (`AllowlistEntry`, #7397): instruction builders and
3
+ * account decoding for the admin allowlist (`add_integrator` /
4
+ * `set_integrator_rate` / `remove_integrator`), plus the two OPTIONAL trailing
5
+ * accounts `register` (and `create_voucher`) grew so a mint fee can be split
6
+ * on-chain with an allowlisted integrator.
7
+ *
8
+ * Hand-rolled like the rest of this package — no Anchor client, no
9
+ * `@solana/web3.js` import. Builders return a transport-neutral
10
+ * {@link BuiltInstruction}; adapt to web3.js with the two-line snippet in
11
+ * `delegate.ts`.
12
+ *
13
+ * # Deriving the PDA
14
+ *
15
+ * The allowlist entry lives at `["integrator", integratorWallet]` under the
16
+ * registry program (seed constant {@link INTEGRATOR_SEED}). This module does
17
+ * NOT derive PDAs (see `wasm.ts`'s one rule); derive with web3.js:
18
+ *
19
+ * ```ts
20
+ * PublicKey.findProgramAddressSync(
21
+ * [Buffer.from(INTEGRATOR_SEED), integratorWallet.toBytes()],
22
+ * programId,
23
+ * );
24
+ * ```
25
+ *
26
+ * # The wire rules the program enforces (mirror of lib.rs)
27
+ *
28
+ * - `register` / `create_voucher` end in TWO optional, trailing accounts:
29
+ * the integrator's fee-recipient WALLET (writable in `register`, read-only
30
+ * in `create_voucher`) then its `["integrator", wallet]` `AllowlistEntry`.
31
+ * Supply BOTH to split, or NEITHER to route 100% to treasury (the
32
+ * pre-feature account list, byte-for-byte). Supplying exactly one fails
33
+ * closed (`IntegratorNotAllowed`, code 6054). Use
34
+ * {@link integratorAccountsForRegister} / {@link integratorAccountsForCreateVoucher}
35
+ * to build the pair.
36
+ * - The rate is read from the on-chain `AllowlistEntry`, never trusted from
37
+ * the transaction. It is capped at {@link MAX_INTEGRATOR_RATE_BPS} (40%);
38
+ * {@link DEFAULT_INTEGRATOR_RATE_BPS} (20%) applies when `add_integrator`
39
+ * omits a rate.
40
+ */
41
+ import type { AddressLike, BuiltInstruction, InstructionKey } from "./delegate.js";
42
+ /** Seed prefix of the allowlist PDA: `["integrator", integratorWallet]`. */
43
+ export declare const INTEGRATOR_SEED = "integrator";
44
+ /** Default integrator share when `add_integrator` omits a rate: 20%. */
45
+ export declare const DEFAULT_INTEGRATOR_RATE_BPS = 2000;
46
+ /** Hard cap on an integrator's share of a mint fee: 40%. */
47
+ export declare const MAX_INTEGRATOR_RATE_BPS = 4000;
48
+ /** Anchor instruction discriminator `sha256("global:add_integrator")[0..8]`. */
49
+ export declare const ADD_INTEGRATOR_DISCRIMINATOR: Uint8Array;
50
+ /** Anchor instruction discriminator `sha256("global:set_integrator_rate")[0..8]`. */
51
+ export declare const SET_INTEGRATOR_RATE_DISCRIMINATOR: Uint8Array;
52
+ /** Anchor instruction discriminator `sha256("global:remove_integrator")[0..8]`. */
53
+ export declare const REMOVE_INTEGRATOR_DISCRIMINATOR: Uint8Array;
54
+ /** Anchor account discriminator `sha256("account:AllowlistEntry")[0..8]`. */
55
+ export declare const ALLOWLIST_ENTRY_DISCRIMINATOR: Uint8Array;
56
+ /** `AllowlistEntry` account size: disc(8) integrator(32) rate_bps(u16, 2) bump(1). */
57
+ export declare const ALLOWLIST_ENTRY_LEN = 43;
58
+ /** Decoded `AllowlistEntry` account. */
59
+ export interface AllowlistEntry {
60
+ /** The integrator's fee-recipient wallet (base58); equals the PDA seed. */
61
+ readonly integrator: string;
62
+ /** Share of the mint fee, in basis points (`<= MAX_INTEGRATOR_RATE_BPS`). */
63
+ readonly rateBps: number;
64
+ readonly bump: number;
65
+ }
66
+ /** Decode an `AllowlistEntry` account's raw data, or null if not one. */
67
+ export declare function decodeAllowlistEntry(data: Uint8Array): AllowlistEntry | null;
68
+ export interface AddIntegratorParams {
69
+ readonly programId: AddressLike;
70
+ /** `Config.admin`. Signer + rent payer for the new entry. */
71
+ readonly admin: AddressLike;
72
+ /** The `["config"]` PDA. */
73
+ readonly config: AddressLike;
74
+ /** The integrator's fee-recipient wallet (also the entry's seed). */
75
+ readonly integrator: AddressLike;
76
+ /** The `["integrator", integrator]` PDA — derive per the module docs. */
77
+ readonly integratorAllowlist: AddressLike;
78
+ /** Basis points (`<= MAX_INTEGRATOR_RATE_BPS`). Omit for the 20% default. */
79
+ readonly rateBps?: number;
80
+ }
81
+ /** Build `add_integrator` — allowlist an integrator at `rateBps` (or the 20%
82
+ * default when omitted). Admin-only. */
83
+ export declare function buildAddIntegratorIx(p: AddIntegratorParams): BuiltInstruction;
84
+ export interface SetIntegratorRateParams {
85
+ readonly programId: AddressLike;
86
+ readonly admin: AddressLike;
87
+ readonly config: AddressLike;
88
+ /** The `["integrator", integrator]` PDA being re-rated. */
89
+ readonly integratorAllowlist: AddressLike;
90
+ readonly rateBps: number;
91
+ }
92
+ /** Build `set_integrator_rate` — change an allowlisted integrator's rate.
93
+ * Admin-only; `rateBps <= MAX_INTEGRATOR_RATE_BPS`. */
94
+ export declare function buildSetIntegratorRateIx(p: SetIntegratorRateParams): BuiltInstruction;
95
+ export interface RemoveIntegratorParams {
96
+ readonly programId: AddressLike;
97
+ readonly admin: AddressLike;
98
+ readonly config: AddressLike;
99
+ /** The `["integrator", integrator]` PDA being closed (rent to `admin`). */
100
+ readonly integratorAllowlist: AddressLike;
101
+ }
102
+ /** Build `remove_integrator` — close the allowlist entry, rent to the admin.
103
+ * Admin-only. */
104
+ export declare function buildRemoveIntegratorIx(p: RemoveIntegratorParams): BuiltInstruction;
105
+ /**
106
+ * The two trailing optional accounts to append to a `register` instruction so
107
+ * an allowlisted integrator earns its split: `[integratorWallet (writable),
108
+ * ["integrator", wallet] entry (read-only)]`. Append BOTH or NEITHER — see the
109
+ * module docs.
110
+ */
111
+ export declare function integratorAccountsForRegister(integrator: AddressLike, integratorAllowlist: AddressLike): InstructionKey[];
112
+ /**
113
+ * Same as {@link integratorAccountsForRegister} but for `create_voucher`,
114
+ * where the wallet is READ-ONLY (nothing is paid at create; the rate is
115
+ * snapshotted onto the voucher).
116
+ */
117
+ export declare function integratorAccountsForCreateVoucher(integrator: AddressLike, integratorAllowlist: AddressLike): InstructionKey[];
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Integrator revenue-share (`AllowlistEntry`, #7397): instruction builders and
3
+ * account decoding for the admin allowlist (`add_integrator` /
4
+ * `set_integrator_rate` / `remove_integrator`), plus the two OPTIONAL trailing
5
+ * accounts `register` (and `create_voucher`) grew so a mint fee can be split
6
+ * on-chain with an allowlisted integrator.
7
+ *
8
+ * Hand-rolled like the rest of this package — no Anchor client, no
9
+ * `@solana/web3.js` import. Builders return a transport-neutral
10
+ * {@link BuiltInstruction}; adapt to web3.js with the two-line snippet in
11
+ * `delegate.ts`.
12
+ *
13
+ * # Deriving the PDA
14
+ *
15
+ * The allowlist entry lives at `["integrator", integratorWallet]` under the
16
+ * registry program (seed constant {@link INTEGRATOR_SEED}). This module does
17
+ * NOT derive PDAs (see `wasm.ts`'s one rule); derive with web3.js:
18
+ *
19
+ * ```ts
20
+ * PublicKey.findProgramAddressSync(
21
+ * [Buffer.from(INTEGRATOR_SEED), integratorWallet.toBytes()],
22
+ * programId,
23
+ * );
24
+ * ```
25
+ *
26
+ * # The wire rules the program enforces (mirror of lib.rs)
27
+ *
28
+ * - `register` / `create_voucher` end in TWO optional, trailing accounts:
29
+ * the integrator's fee-recipient WALLET (writable in `register`, read-only
30
+ * in `create_voucher`) then its `["integrator", wallet]` `AllowlistEntry`.
31
+ * Supply BOTH to split, or NEITHER to route 100% to treasury (the
32
+ * pre-feature account list, byte-for-byte). Supplying exactly one fails
33
+ * closed (`IntegratorNotAllowed`, code 6054). Use
34
+ * {@link integratorAccountsForRegister} / {@link integratorAccountsForCreateVoucher}
35
+ * to build the pair.
36
+ * - The rate is read from the on-chain `AllowlistEntry`, never trusted from
37
+ * the transaction. It is capped at {@link MAX_INTEGRATOR_RATE_BPS} (40%);
38
+ * {@link DEFAULT_INTEGRATOR_RATE_BPS} (20%) applies when `add_integrator`
39
+ * omits a rate.
40
+ */
41
+ import { encodeBase58, decodeBase58_32 } from "./base58.js";
42
+ /** Seed prefix of the allowlist PDA: `["integrator", integratorWallet]`. */
43
+ export const INTEGRATOR_SEED = "integrator";
44
+ /** Default integrator share when `add_integrator` omits a rate: 20%. */
45
+ export const DEFAULT_INTEGRATOR_RATE_BPS = 2_000;
46
+ /** Hard cap on an integrator's share of a mint fee: 40%. */
47
+ export const MAX_INTEGRATOR_RATE_BPS = 4_000;
48
+ /** Anchor instruction discriminator `sha256("global:add_integrator")[0..8]`. */
49
+ export const ADD_INTEGRATOR_DISCRIMINATOR = Uint8Array.from([85, 249, 72, 201, 17, 187, 227, 38]);
50
+ /** Anchor instruction discriminator `sha256("global:set_integrator_rate")[0..8]`. */
51
+ export const SET_INTEGRATOR_RATE_DISCRIMINATOR = Uint8Array.from([104, 23, 19, 77, 35, 8, 161, 101]);
52
+ /** Anchor instruction discriminator `sha256("global:remove_integrator")[0..8]`. */
53
+ export const REMOVE_INTEGRATOR_DISCRIMINATOR = Uint8Array.from([162, 208, 67, 99, 105, 234, 199, 31]);
54
+ /** Anchor account discriminator `sha256("account:AllowlistEntry")[0..8]`. */
55
+ export const ALLOWLIST_ENTRY_DISCRIMINATOR = Uint8Array.from([42, 59, 88, 1, 124, 138, 92, 236]);
56
+ /** `AllowlistEntry` account size: disc(8) integrator(32) rate_bps(u16, 2) bump(1). */
57
+ export const ALLOWLIST_ENTRY_LEN = 43;
58
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
59
+ function toBytes32(v, what) {
60
+ if (typeof v === "string") {
61
+ const b = decodeBase58_32(v);
62
+ if (!b)
63
+ throw new Error(`${what} is not a valid base58 address`);
64
+ return b;
65
+ }
66
+ if (v.length !== 32)
67
+ throw new Error(`${what} must be exactly 32 bytes`);
68
+ return v;
69
+ }
70
+ function toBase58(v, what) {
71
+ return encodeBase58(toBytes32(v, what));
72
+ }
73
+ /** Decode an `AllowlistEntry` account's raw data, or null if not one. */
74
+ export function decodeAllowlistEntry(data) {
75
+ if (data.length !== ALLOWLIST_ENTRY_LEN)
76
+ return null;
77
+ for (let i = 0; i < 8; i++)
78
+ if (data[i] !== ALLOWLIST_ENTRY_DISCRIMINATOR[i])
79
+ return null;
80
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
81
+ return {
82
+ integrator: encodeBase58(data.slice(8, 40)),
83
+ rateBps: view.getUint16(40, true),
84
+ bump: data[42],
85
+ };
86
+ }
87
+ /** Build `add_integrator` — allowlist an integrator at `rateBps` (or the 20%
88
+ * default when omitted). Admin-only. */
89
+ export function buildAddIntegratorIx(p) {
90
+ if (p.rateBps !== undefined && (p.rateBps < 0 || p.rateBps > MAX_INTEGRATOR_RATE_BPS || !Number.isInteger(p.rateBps))) {
91
+ throw new Error(`rateBps must be an integer in [0, ${MAX_INTEGRATOR_RATE_BPS}]`);
92
+ }
93
+ // disc(8) + integrator(32) + Option<u16> (tag[+ u16 LE]).
94
+ const hasRate = p.rateBps !== undefined;
95
+ const data = new Uint8Array(8 + 32 + 1 + (hasRate ? 2 : 0));
96
+ data.set(ADD_INTEGRATOR_DISCRIMINATOR, 0);
97
+ data.set(toBytes32(p.integrator, "integrator"), 8);
98
+ data[40] = hasRate ? 1 : 0;
99
+ if (hasRate)
100
+ new DataView(data.buffer).setUint16(41, p.rateBps, true);
101
+ return {
102
+ programId: toBase58(p.programId, "programId"),
103
+ keys: [
104
+ { pubkey: toBase58(p.admin, "admin"), isSigner: true, isWritable: true },
105
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: false },
106
+ { pubkey: toBase58(p.integratorAllowlist, "integratorAllowlist"), isSigner: false, isWritable: true },
107
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
108
+ ],
109
+ data,
110
+ };
111
+ }
112
+ /** Build `set_integrator_rate` — change an allowlisted integrator's rate.
113
+ * Admin-only; `rateBps <= MAX_INTEGRATOR_RATE_BPS`. */
114
+ export function buildSetIntegratorRateIx(p) {
115
+ if (p.rateBps < 0 || p.rateBps > MAX_INTEGRATOR_RATE_BPS || !Number.isInteger(p.rateBps)) {
116
+ throw new Error(`rateBps must be an integer in [0, ${MAX_INTEGRATOR_RATE_BPS}]`);
117
+ }
118
+ const data = new Uint8Array(8 + 2);
119
+ data.set(SET_INTEGRATOR_RATE_DISCRIMINATOR, 0);
120
+ new DataView(data.buffer).setUint16(8, p.rateBps, true);
121
+ return {
122
+ programId: toBase58(p.programId, "programId"),
123
+ keys: [
124
+ { pubkey: toBase58(p.admin, "admin"), isSigner: true, isWritable: false },
125
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: false },
126
+ { pubkey: toBase58(p.integratorAllowlist, "integratorAllowlist"), isSigner: false, isWritable: true },
127
+ ],
128
+ data,
129
+ };
130
+ }
131
+ /** Build `remove_integrator` — close the allowlist entry, rent to the admin.
132
+ * Admin-only. */
133
+ export function buildRemoveIntegratorIx(p) {
134
+ return {
135
+ programId: toBase58(p.programId, "programId"),
136
+ keys: [
137
+ { pubkey: toBase58(p.admin, "admin"), isSigner: true, isWritable: true },
138
+ { pubkey: toBase58(p.config, "config"), isSigner: false, isWritable: false },
139
+ { pubkey: toBase58(p.integratorAllowlist, "integratorAllowlist"), isSigner: false, isWritable: true },
140
+ ],
141
+ data: REMOVE_INTEGRATOR_DISCRIMINATOR.slice(),
142
+ };
143
+ }
144
+ /**
145
+ * The two trailing optional accounts to append to a `register` instruction so
146
+ * an allowlisted integrator earns its split: `[integratorWallet (writable),
147
+ * ["integrator", wallet] entry (read-only)]`. Append BOTH or NEITHER — see the
148
+ * module docs.
149
+ */
150
+ export function integratorAccountsForRegister(integrator, integratorAllowlist) {
151
+ return [
152
+ { pubkey: toBase58(integrator, "integrator"), isSigner: false, isWritable: true },
153
+ { pubkey: toBase58(integratorAllowlist, "integratorAllowlist"), isSigner: false, isWritable: false },
154
+ ];
155
+ }
156
+ /**
157
+ * Same as {@link integratorAccountsForRegister} but for `create_voucher`,
158
+ * where the wallet is READ-ONLY (nothing is paid at create; the rate is
159
+ * snapshotted onto the voucher).
160
+ */
161
+ export function integratorAccountsForCreateVoucher(integrator, integratorAllowlist) {
162
+ return [
163
+ { pubkey: toBase58(integrator, "integrator"), isSigner: false, isWritable: false },
164
+ { pubkey: toBase58(integratorAllowlist, "integratorAllowlist"), isSigner: false, isWritable: false },
165
+ ];
166
+ }