@sorandomains/holder 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soran (SoranDomains)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @sorandomains/holder
2
+
3
+ Your Soran name, managed with your own key. The third piece of the SDK
4
+ trilogy: [`@sorandomains/lookup`](https://www.npmjs.com/package/@sorandomains/lookup)
5
+ reads names, [`@sorandomains/owner`](https://www.npmjs.com/package/@sorandomains/owner)
6
+ runs a namespace — this package is for the person who **holds** a name.
7
+
8
+ ```bash
9
+ npm install @sorandomains/holder @stellar/stellar-sdk
10
+ ```
11
+
12
+ ```ts
13
+ import { SoranHolder, keypairSigner } from "@sorandomains/holder";
14
+
15
+ const me = new SoranHolder({ signer: keypairSigner(process.env.MY_SECRET!) });
16
+
17
+ await me.setReverse("alice.nova"); // your address shows as alice.nova
18
+ await me.setPrimary("alice.nova"); // ...across every namespace
19
+ await me.setProfile("alice.nova", { // the standard keys every wallet reads
20
+ org: "Alice Co",
21
+ url: "https://alice.dev",
22
+ });
23
+ ```
24
+
25
+ ## What's in the box
26
+
27
+ | Operation | What it does |
28
+ | --- | --- |
29
+ | `setRecord` | Point your name's explicit resolver record at any address (generation-gated — stops resolving the moment the name changes hands) |
30
+ | `setAddress` | Re-point the built-in (Registrar) resolution target |
31
+ | `setText` / `setProfile` | Publish text records; `setProfile` writes the standard `PROFILE_KEYS` (one transaction per key) |
32
+ | `setReverse` / `clearReverse` | Claim your address→name reverse record — the contract refuses names that don't already resolve to you (`ForwardMismatch`) |
33
+ | `setPrimary` / `clearPrimary` | Your one cross-namespace display name, re-verified on chain at every read |
34
+ | `proposeNameTransfer` / `acceptNameTransfer` / `cancelNameTransfer` | Two-step, accept-to-move name transfers (policy-gated) |
35
+ | `pendingNameTransfer` | Read the pending proposal |
36
+
37
+ Everything is holder-authorized **on chain** — the Resolver checks you hold
38
+ the name right now, reverse and primary claims are authorized by the address
39
+ itself, and transfers move only when the recipient accepts. No Soran account,
40
+ no hosted API in the path.
41
+
42
+ ## Signing
43
+
44
+ Same `TxSigner` contract as the owner SDK — `keypairSigner(secret)` for
45
+ scripts, or wrap a browser wallet:
46
+
47
+ ```ts
48
+ const me = new SoranHolder({
49
+ signer: {
50
+ publicKey: () => walletAddress,
51
+ signTransaction: (xdr, opts) => kit.signTransaction(xdr, opts),
52
+ },
53
+ });
54
+ ```
55
+
56
+ Calls are simulated before signing, so contract rejections surface as typed
57
+ `HolderError`s (`code`, `codeName` — e.g. `NotHolder`, `ForwardMismatch`,
58
+ `NotTransferable`) before any fee is spent; failures that reached the network
59
+ carry `txHash`.
60
+
61
+ Works in browsers, Node, and workers out of the box — zero Node built-ins of
62
+ its own (enforced in CI), with `@stellar/stellar-sdk` as the only peer
63
+ dependency.
64
+
65
+ Docs: <https://github.com/SoranDomains/docs> · License: MIT
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @sorandomains/holder — the write-side SDK for people who HOLD a Soran name.
3
+ *
4
+ * The third persona: `@sorandomains/lookup` reads names, `@sorandomains/owner`
5
+ * runs a namespace — this package manages YOUR name with YOUR key:
6
+ *
7
+ * import { SoranHolder, keypairSigner } from "@sorandomains/holder";
8
+ *
9
+ * const me = new SoranHolder({ signer: keypairSigner(process.env.MY_SECRET!) });
10
+ * await me.setReverse("alice.nova"); // addresses show as alice.nova
11
+ * await me.setPrimary("alice.nova"); // ...across every namespace
12
+ * await me.setProfile("alice.nova", { url: "https://alice.dev", org: "Alice Co" });
13
+ *
14
+ * TRUST MODEL. Every operation is a transaction your own key signs against
15
+ * the contracts over any Soroban RPC — no Soran account, no hosted API in
16
+ * the path. All powers here are HOLDER-authorized on chain: the Resolver
17
+ * checks you hold the name (generation-gated) before accepting records, the
18
+ * reverse and primary claims are authorized by the ADDRESS itself, and name
19
+ * transfers move only when the recipient accepts. Namespace-owner powers
20
+ * (issue, reclaim, renew, permanence) live in `@sorandomains/owner` and this
21
+ * package deliberately cannot exercise them.
22
+ *
23
+ * SIGNING. Same `TxSigner` contract as the owner SDK: `keypairSigner(secret)`
24
+ * for scripts, or wrap a browser wallet:
25
+ *
26
+ * { publicKey: () => walletAddress, signTransaction: (x, o) => kit.signTransaction(x, o) }
27
+ *
28
+ * Each call is simulated first (typed contract errors before any fee), the
29
+ * envelope is handed to your signer, and unsatisfiable auth is refused
30
+ * before signing. Operations on one instance are serialized so concurrent
31
+ * calls cannot race the account sequence number.
32
+ */
33
+ /** Known public deployments. Pass explicit options for anything else. */
34
+ export declare const DEPLOYMENTS: {
35
+ readonly testnet: {
36
+ readonly rpcUrl: "https://soroban-testnet.stellar.org";
37
+ readonly passphrase: string;
38
+ readonly registryId: "CAUEHYVLLNNDZ4H5QWCPBDWEONRI44SI3XYSEACB4U3HYILIVQGQAMNI";
39
+ readonly primaryId: "CAZMXB6UBXKL4DGC2GUC5VKHIZMF47CIZXZFAZPYLM2RP6ZJZNSIIYS2";
40
+ };
41
+ };
42
+ /** Same shape as the owner SDK's signer — wallet-kit compatible. */
43
+ export type TxSigner = {
44
+ publicKey(): string | Promise<string>;
45
+ signTransaction(xdrBase64: string, opts: {
46
+ networkPassphrase: string;
47
+ }): Promise<string | {
48
+ signedTxXdr: string;
49
+ }>;
50
+ };
51
+ /** A TxSigner over a raw secret key — for scripts and backends. */
52
+ export declare function keypairSigner(secret: string): TxSigner;
53
+ /**
54
+ * A failed holder operation. `code`/`codeName` carry the contract's typed
55
+ * error (e.g. 3/"NotHolder" from the Resolver, 5/"ForwardMismatch" on a
56
+ * reverse claim for a name that doesn't point back at you); null for
57
+ * transport failures. `txHash` is set when a transaction reached the network
58
+ * — always re-check a hash before retrying.
59
+ */
60
+ export declare class HolderError extends Error {
61
+ readonly contractId: string | null;
62
+ readonly fn: string | null;
63
+ readonly code: number | null;
64
+ readonly codeName: string | null;
65
+ readonly txHash: string | null;
66
+ constructor(message: string, contractId?: string | null, fn?: string | null, code?: number | null, codeName?: string | null, txHash?: string | null);
67
+ }
68
+ /** Split and validate `label.namespace`, lowercasing first. Throws
69
+ * HolderError — @sorandomains/lookup exports the same helper throwing its
70
+ * own SoranError; import from the package whose errors you handle. */
71
+ export declare function parseName(name: string): {
72
+ label: string;
73
+ namespace: string;
74
+ };
75
+ export type Submitted = {
76
+ hash: string;
77
+ ledger: number;
78
+ };
79
+ /** The standard profile keys shared with @sorandomains/lookup's profile(). */
80
+ export declare const PROFILE_KEYS: readonly ["org", "url", "email", "description", "avatar", "location", "twitter", "github"];
81
+ export type HolderOptions = {
82
+ /** Signs every transaction: the name HOLDER's account (or, for
83
+ * `acceptNameTransfer`, the proposed new holder's). */
84
+ signer: TxSigner;
85
+ /** Named deployment preset; defaults to "testnet". Override the individual
86
+ * fields below as a SET for custom deployments. */
87
+ network?: keyof typeof DEPLOYMENTS;
88
+ rpcUrl?: string;
89
+ passphrase?: string;
90
+ registryId?: string;
91
+ /** PrimaryName contract; the preset supplies one. `null` disables
92
+ * setPrimary/clearPrimary. */
93
+ primaryId?: string | null;
94
+ allowHttp?: boolean;
95
+ /** Transaction time bound, seconds (integer 1-300, default 60). */
96
+ timeoutSecs?: number;
97
+ /** Base fee bid, stroops (default 100; resource fees are added on top). */
98
+ fee?: string;
99
+ };
100
+ export declare class SoranHolder {
101
+ private server;
102
+ private passphrase;
103
+ private registryId;
104
+ private primaryId;
105
+ private signer;
106
+ private timeoutSecs;
107
+ private fee;
108
+ private queue;
109
+ private registrars;
110
+ private resolvers;
111
+ private static POINTER_TTL_MS;
112
+ constructor(opts: HolderOptions);
113
+ /**
114
+ * Re-point where YOUR name pays to on its BUILT-IN path (the Registrar's
115
+ * record — what resolvers fall back to when no explicit record is set).
116
+ * Holder-authorized, live names only; deliberately never blocked by
117
+ * namespace permanence — where your own name resolves is always yours.
118
+ */
119
+ setAddress(name: string, address: string): Promise<Submitted>;
120
+ /**
121
+ * Set YOUR name's explicit record on the namespace resolver — the answer
122
+ * `lookup.resolve()` prefers over the built-in target. Generation-gated:
123
+ * the Resolver verifies you hold the name right now (NotHolder otherwise),
124
+ * and your record stops resolving the moment the name changes hands.
125
+ */
126
+ setRecord(name: string, address: string): Promise<Submitted>;
127
+ /**
128
+ * A text record on YOUR name (`url`, `avatar`, any Symbol-legal key ≤32
129
+ * chars of [A-Za-z0-9_]). Use {@link PROFILE_KEYS} for records every
130
+ * Soran-aware wallet knows to look for.
131
+ *
132
+ * PERMANENCE NOTE: the contract stores text records overwrite-only — there
133
+ * is no on-chain delete. The retraction convention is an EMPTY VALUE
134
+ * ({@link clearText}): the entry remains on chain (empty), and standard
135
+ * readers (lookup's `profile()`) treat empty as unset. Publish
136
+ * accordingly — treat every value as permanent-ish public data.
137
+ */
138
+ setText(name: string, key: string, value: string): Promise<Submitted>;
139
+ /**
140
+ * Retract a text record by overwriting it with the empty string — the
141
+ * chain keeps the (empty) entry, and standard readers treat empty as
142
+ * unset. There is no true on-chain delete for text records.
143
+ */
144
+ clearText(name: string, key: string): Promise<Submitted>;
145
+ /**
146
+ * Publish several profile records in one call. Soroban allows one contract
147
+ * invocation per transaction, so this signs and submits ONE TRANSACTION
148
+ * PER KEY, sequentially, and returns the per-key results. On a mid-batch
149
+ * failure it throws with the already-set keys named in the message —
150
+ * records already written stay written (they are individually valid).
151
+ */
152
+ setProfile(name: string, profile: Record<string, string>): Promise<Array<Submitted & {
153
+ key: string;
154
+ }>>;
155
+ /**
156
+ * Claim `name` as YOUR ADDRESS's reverse record on its namespace resolver
157
+ * — what `lookup.reverseLookup` finds. Authorized by the address itself,
158
+ * and the contract enforces that the name's forward record already
159
+ * resolves to you (ForwardMismatch otherwise): a reverse claim on a name
160
+ * that doesn't point back at you is spoofing, and the chain refuses it.
161
+ */
162
+ setReverse(name: string): Promise<Submitted>;
163
+ /** Remove your reverse record on a namespace's resolver. */
164
+ clearReverse(namespace: string): Promise<Submitted>;
165
+ /**
166
+ * Declare `name` as YOUR one cross-namespace primary display name. The
167
+ * PrimaryName contract verifies the claim against the namespace resolver
168
+ * on every read, so a primary can never outlive the name it points at.
169
+ * Requires a reverse record first in practice (NotDisplayName otherwise).
170
+ */
171
+ setPrimary(name: string): Promise<Submitted>;
172
+ /** Withdraw your primary-name declaration. */
173
+ clearPrimary(): Promise<Submitted>;
174
+ /**
175
+ * Offer YOUR name to `to`. Two-step by contract design: nothing moves
176
+ * until the recipient accepts, so a typo'd address cannot burn the name.
177
+ * Policy-gated (NotTransferable on namespaces that forbid it) and live
178
+ * names only.
179
+ */
180
+ proposeNameTransfer(name: string, to: string): Promise<Submitted>;
181
+ /** Accept a name offered to you. Signer must be the PROPOSED holder. */
182
+ acceptNameTransfer(name: string): Promise<Submitted>;
183
+ /** Withdraw a pending transfer of your name. */
184
+ cancelNameTransfer(name: string): Promise<Submitted>;
185
+ /** The pending transfer proposal on a name, or null. */
186
+ pendingNameTransfer(name: string): Promise<{
187
+ from: string;
188
+ to: string;
189
+ expiresAt: bigint;
190
+ } | null>;
191
+ /** The namespace's Registry-attested Registrar. Cached briefly. */
192
+ registrarOf(namespace: string): Promise<string>;
193
+ /** The namespace's resolver pointer. Cached briefly. */
194
+ resolverOf(namespace: string): Promise<string>;
195
+ private read;
196
+ private signEnvelope;
197
+ private serialize;
198
+ private sourceAccount;
199
+ private assertSatisfiableAuth;
200
+ private invoke;
201
+ private attempt;
202
+ private restore;
203
+ private confirm;
204
+ private decodeFailure;
205
+ }
package/dist/index.js ADDED
@@ -0,0 +1,632 @@
1
+ /**
2
+ * @sorandomains/holder — the write-side SDK for people who HOLD a Soran name.
3
+ *
4
+ * The third persona: `@sorandomains/lookup` reads names, `@sorandomains/owner`
5
+ * runs a namespace — this package manages YOUR name with YOUR key:
6
+ *
7
+ * import { SoranHolder, keypairSigner } from "@sorandomains/holder";
8
+ *
9
+ * const me = new SoranHolder({ signer: keypairSigner(process.env.MY_SECRET!) });
10
+ * await me.setReverse("alice.nova"); // addresses show as alice.nova
11
+ * await me.setPrimary("alice.nova"); // ...across every namespace
12
+ * await me.setProfile("alice.nova", { url: "https://alice.dev", org: "Alice Co" });
13
+ *
14
+ * TRUST MODEL. Every operation is a transaction your own key signs against
15
+ * the contracts over any Soroban RPC — no Soran account, no hosted API in
16
+ * the path. All powers here are HOLDER-authorized on chain: the Resolver
17
+ * checks you hold the name (generation-gated) before accepting records, the
18
+ * reverse and primary claims are authorized by the ADDRESS itself, and name
19
+ * transfers move only when the recipient accepts. Namespace-owner powers
20
+ * (issue, reclaim, renew, permanence) live in `@sorandomains/owner` and this
21
+ * package deliberately cannot exercise them.
22
+ *
23
+ * SIGNING. Same `TxSigner` contract as the owner SDK: `keypairSigner(secret)`
24
+ * for scripts, or wrap a browser wallet:
25
+ *
26
+ * { publicKey: () => walletAddress, signTransaction: (x, o) => kit.signTransaction(x, o) }
27
+ *
28
+ * Each call is simulated first (typed contract errors before any fee), the
29
+ * envelope is handed to your signer, and unsatisfiable auth is refused
30
+ * before signing. Operations on one instance are serialized so concurrent
31
+ * calls cannot race the account sequence number.
32
+ */
33
+ import { Account, Address, BASE_FEE, Contract, Keypair, Networks, Operation, TransactionBuilder, hash, nativeToScVal, rpc, scValToNative, xdr, } from "@stellar/stellar-sdk";
34
+ // ---------------------------------------------------------------------------
35
+ // Deployments
36
+ // ---------------------------------------------------------------------------
37
+ /** Known public deployments. Pass explicit options for anything else. */
38
+ export const DEPLOYMENTS = {
39
+ testnet: {
40
+ rpcUrl: "https://soroban-testnet.stellar.org",
41
+ passphrase: Networks.TESTNET,
42
+ registryId: "CAUEHYVLLNNDZ4H5QWCPBDWEONRI44SI3XYSEACB4U3HYILIVQGQAMNI",
43
+ primaryId: "CAZMXB6UBXKL4DGC2GUC5VKHIZMF47CIZXZFAZPYLM2RP6ZJZNSIIYS2",
44
+ },
45
+ };
46
+ /** A TxSigner over a raw secret key — for scripts and backends. */
47
+ export function keypairSigner(secret) {
48
+ const kp = Keypair.fromSecret(secret);
49
+ return {
50
+ publicKey: () => kp.publicKey(),
51
+ signTransaction: async (xdrBase64, { networkPassphrase }) => {
52
+ const tx = TransactionBuilder.fromXDR(xdrBase64, networkPassphrase);
53
+ tx.sign(kp);
54
+ return tx.toXDR();
55
+ },
56
+ };
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Errors
60
+ // ---------------------------------------------------------------------------
61
+ const REGISTRAR_ERRORS = {
62
+ 1: "AlreadyInitialized",
63
+ 2: "NotInitialized",
64
+ 3: "NameTaken",
65
+ 4: "NameNotFound",
66
+ 5: "NotReclaimable",
67
+ 6: "NotTransferable",
68
+ 7: "AlreadyPermanent",
69
+ 8: "BatchTooLarge",
70
+ 9: "NotNamespaceOwner",
71
+ 10: "BadLabel",
72
+ 11: "PermanentName",
73
+ 12: "FiniteTermPolicy",
74
+ 13: "PermanentlyLocked",
75
+ 14: "NameExpired",
76
+ 15: "NoPendingTransfer",
77
+ 16: "TransferExpired",
78
+ 17: "StaleTransfer",
79
+ 18: "InvalidPolicy",
80
+ 19: "ExpiryOverflow",
81
+ 20: "InvalidRegistry",
82
+ };
83
+ const RESOLVER_ERRORS = {
84
+ 1: "AlreadyInitialized",
85
+ 2: "NotInitialized",
86
+ 3: "NotHolder",
87
+ 4: "NameInactive",
88
+ 5: "ForwardMismatch",
89
+ 6: "Frozen",
90
+ 7: "InvalidAuthority",
91
+ 8: "ProvenanceMismatch",
92
+ 9: "ProvenanceAlreadyBound",
93
+ 10: "InvalidRegistry",
94
+ 11: "UpgradeTaintFailed",
95
+ 12: "MalformedName",
96
+ };
97
+ const PRIMARY_ERRORS = {
98
+ 1: "MalformedName",
99
+ 2: "NoResolver",
100
+ 3: "NotDisplayName",
101
+ 4: "ResolverUnavailable",
102
+ 5: "InvalidRegistry",
103
+ };
104
+ /**
105
+ * A failed holder operation. `code`/`codeName` carry the contract's typed
106
+ * error (e.g. 3/"NotHolder" from the Resolver, 5/"ForwardMismatch" on a
107
+ * reverse claim for a name that doesn't point back at you); null for
108
+ * transport failures. `txHash` is set when a transaction reached the network
109
+ * — always re-check a hash before retrying.
110
+ */
111
+ export class HolderError extends Error {
112
+ contractId;
113
+ fn;
114
+ code;
115
+ codeName;
116
+ txHash;
117
+ constructor(message, contractId = null, fn = null, code = null, codeName = null, txHash = null) {
118
+ super(message);
119
+ this.contractId = contractId;
120
+ this.fn = fn;
121
+ this.code = code;
122
+ this.codeName = codeName;
123
+ this.txHash = txHash;
124
+ this.name = "HolderError";
125
+ }
126
+ }
127
+ function parseContractCode(message) {
128
+ const m = /Error\(Contract, #(\d+)\)/.exec(message);
129
+ return m ? Number(m[1]) : null;
130
+ }
131
+ function typedError(contractId, fn, raw, names, txHash = null) {
132
+ const code = parseContractCode(raw);
133
+ const codeName = code !== null ? (names[code] ?? null) : null;
134
+ const label = codeName ? ` (${codeName})` : "";
135
+ return new HolderError(`${fn} failed${code !== null ? `: contract error #${code}${label}` : `: ${raw}`}`, contractId, fn, code, codeName, txHash);
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Hashing + arguments (mirror the on-chain scheme exactly)
139
+ // ---------------------------------------------------------------------------
140
+ const LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
141
+ // Soroban Symbol constraint — text-record keys live in this alphabet.
142
+ const SYMBOL_RE = /^[A-Za-z0-9_]{1,32}$/;
143
+ function assertLabel(label) {
144
+ if (label.length < 1 || label.length > 63 || !LABEL_RE.test(label)) {
145
+ throw new HolderError(`invalid label "${label}" — 1-63 chars of a-z, 0-9, and non-edge hyphens`);
146
+ }
147
+ }
148
+ /** Split and validate `label.namespace`, lowercasing first. Throws
149
+ * HolderError — @sorandomains/lookup exports the same helper throwing its
150
+ * own SoranError; import from the package whose errors you handle. */
151
+ export function parseName(name) {
152
+ const parts = name.toLowerCase().split(".");
153
+ if (parts.length !== 2)
154
+ throw new HolderError(`expected "label.namespace", got "${name}"`);
155
+ const [label, namespace] = parts;
156
+ assertLabel(label);
157
+ assertLabel(namespace);
158
+ return { label, namespace };
159
+ }
160
+ const utf8 = (str) => new TextEncoder().encode(str);
161
+ function concatBytes(...parts) {
162
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
163
+ let offset = 0;
164
+ for (const part of parts) {
165
+ out.set(part, offset);
166
+ offset += part.length;
167
+ }
168
+ return out;
169
+ }
170
+ function namehash(namespace) {
171
+ const labelHash = new Uint8Array(hash(utf8(namespace)));
172
+ return new Uint8Array(hash(concatBytes(new Uint8Array(32), labelHash)));
173
+ }
174
+ function nameNode(label, namespace) {
175
+ const labelHash = new Uint8Array(hash(utf8(label)));
176
+ return new Uint8Array(hash(concatBytes(namehash(namespace), labelHash)));
177
+ }
178
+ const labelArg = (label) => nativeToScVal(utf8(label), { type: "bytes" });
179
+ const addrArg = (address) => nativeToScVal(address, { type: "address" });
180
+ const bytesArg = (b) => nativeToScVal(b, { type: "bytes" });
181
+ const SIM_SOURCE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
182
+ /** The standard profile keys shared with @sorandomains/lookup's profile(). */
183
+ export const PROFILE_KEYS = [
184
+ "org",
185
+ "url",
186
+ "email",
187
+ "description",
188
+ "avatar",
189
+ "location",
190
+ "twitter",
191
+ "github",
192
+ ];
193
+ // ---------------------------------------------------------------------------
194
+ // The client
195
+ // ---------------------------------------------------------------------------
196
+ export class SoranHolder {
197
+ server;
198
+ passphrase;
199
+ registryId;
200
+ primaryId;
201
+ signer;
202
+ timeoutSecs;
203
+ fee;
204
+ queue = Promise.resolve();
205
+ registrars = new Map();
206
+ resolvers = new Map();
207
+ static POINTER_TTL_MS = 30_000;
208
+ constructor(opts) {
209
+ if (!opts?.signer)
210
+ throw new HolderError("HolderOptions.signer is required");
211
+ const d = DEPLOYMENTS[opts.network ?? "testnet"];
212
+ if (!d)
213
+ throw new HolderError(`unknown network "${opts.network}"`);
214
+ this.server = new rpc.Server(opts.rpcUrl ?? d.rpcUrl, { allowHttp: opts.allowHttp ?? false });
215
+ this.passphrase = opts.passphrase ?? d.passphrase;
216
+ this.registryId = opts.registryId ?? d.registryId;
217
+ // The preset PrimaryName is anchored to the preset Registry — never let
218
+ // it leak onto a custom registryId, where it could only mis-verify.
219
+ const presetPrimary = opts.registryId && opts.registryId !== d.registryId ? null : d.primaryId;
220
+ this.primaryId = opts.primaryId === null ? null : (opts.primaryId ?? presetPrimary ?? null);
221
+ this.signer = opts.signer;
222
+ const t = opts.timeoutSecs ?? 60;
223
+ if (!Number.isInteger(t) || t < 1 || t > 300) {
224
+ throw new HolderError(`timeoutSecs must be an integer between 1 and 300 (got ${t})`);
225
+ }
226
+ this.timeoutSecs = t;
227
+ this.fee = opts.fee ?? BASE_FEE;
228
+ }
229
+ // ---- resolution targets --------------------------------------------------
230
+ /**
231
+ * Re-point where YOUR name pays to on its BUILT-IN path (the Registrar's
232
+ * record — what resolvers fall back to when no explicit record is set).
233
+ * Holder-authorized, live names only; deliberately never blocked by
234
+ * namespace permanence — where your own name resolves is always yours.
235
+ */
236
+ async setAddress(name, address) {
237
+ const { label, namespace } = parseName(name);
238
+ const registrarId = await this.registrarOf(namespace);
239
+ const r = await this.invoke(registrarId, "set_address", [labelArg(label), addrArg(address)], REGISTRAR_ERRORS);
240
+ return { hash: r.hash, ledger: r.ledger };
241
+ }
242
+ /**
243
+ * Set YOUR name's explicit record on the namespace resolver — the answer
244
+ * `lookup.resolve()` prefers over the built-in target. Generation-gated:
245
+ * the Resolver verifies you hold the name right now (NotHolder otherwise),
246
+ * and your record stops resolving the moment the name changes hands.
247
+ */
248
+ async setRecord(name, address) {
249
+ const { label, namespace } = parseName(name);
250
+ const resolverId = await this.resolverOf(namespace);
251
+ const pub = await this.signer.publicKey();
252
+ const r = await this.invoke(resolverId, "set_addr", [bytesArg(nameNode(label, namespace)), addrArg(pub), addrArg(address)], RESOLVER_ERRORS);
253
+ return { hash: r.hash, ledger: r.ledger };
254
+ }
255
+ /**
256
+ * A text record on YOUR name (`url`, `avatar`, any Symbol-legal key ≤32
257
+ * chars of [A-Za-z0-9_]). Use {@link PROFILE_KEYS} for records every
258
+ * Soran-aware wallet knows to look for.
259
+ *
260
+ * PERMANENCE NOTE: the contract stores text records overwrite-only — there
261
+ * is no on-chain delete. The retraction convention is an EMPTY VALUE
262
+ * ({@link clearText}): the entry remains on chain (empty), and standard
263
+ * readers (lookup's `profile()`) treat empty as unset. Publish
264
+ * accordingly — treat every value as permanent-ish public data.
265
+ */
266
+ async setText(name, key, value) {
267
+ const { label, namespace } = parseName(name);
268
+ if (!SYMBOL_RE.test(key)) {
269
+ throw new HolderError(`invalid text-record key "${key}" — 1-32 chars of A-Za-z0-9_`);
270
+ }
271
+ const resolverId = await this.resolverOf(namespace);
272
+ const pub = await this.signer.publicKey();
273
+ const r = await this.invoke(resolverId, "set_text", [
274
+ bytesArg(nameNode(label, namespace)),
275
+ addrArg(pub),
276
+ nativeToScVal(key, { type: "symbol" }),
277
+ nativeToScVal(value, { type: "string" }),
278
+ ], RESOLVER_ERRORS);
279
+ return { hash: r.hash, ledger: r.ledger };
280
+ }
281
+ /**
282
+ * Retract a text record by overwriting it with the empty string — the
283
+ * chain keeps the (empty) entry, and standard readers treat empty as
284
+ * unset. There is no true on-chain delete for text records.
285
+ */
286
+ async clearText(name, key) {
287
+ return this.setText(name, key, "");
288
+ }
289
+ /**
290
+ * Publish several profile records in one call. Soroban allows one contract
291
+ * invocation per transaction, so this signs and submits ONE TRANSACTION
292
+ * PER KEY, sequentially, and returns the per-key results. On a mid-batch
293
+ * failure it throws with the already-set keys named in the message —
294
+ * records already written stay written (they are individually valid).
295
+ */
296
+ async setProfile(name, profile) {
297
+ const entries = Object.entries(profile);
298
+ if (entries.length === 0)
299
+ throw new HolderError("setProfile: empty profile");
300
+ for (const [key] of entries) {
301
+ if (!SYMBOL_RE.test(key)) {
302
+ throw new HolderError(`invalid text-record key "${key}" — 1-32 chars of A-Za-z0-9_`);
303
+ }
304
+ }
305
+ const done = [];
306
+ for (const [key, value] of entries) {
307
+ try {
308
+ const r = await this.setText(name, key, value);
309
+ done.push({ key, ...r });
310
+ }
311
+ catch (e) {
312
+ const set = done.map((d) => d.key).join(", ") || "none";
313
+ throw new HolderError(`setProfile stopped at "${key}" (${e instanceof Error ? e.message : String(e)}); keys already set: ${set}`, e instanceof HolderError ? e.contractId : null, e instanceof HolderError && e.fn ? e.fn : "set_text", e instanceof HolderError ? e.code : null, e instanceof HolderError ? e.codeName : null, e instanceof HolderError ? e.txHash : null);
314
+ }
315
+ }
316
+ return done;
317
+ }
318
+ // ---- reverse + primary ---------------------------------------------------
319
+ /**
320
+ * Claim `name` as YOUR ADDRESS's reverse record on its namespace resolver
321
+ * — what `lookup.reverseLookup` finds. Authorized by the address itself,
322
+ * and the contract enforces that the name's forward record already
323
+ * resolves to you (ForwardMismatch otherwise): a reverse claim on a name
324
+ * that doesn't point back at you is spoofing, and the chain refuses it.
325
+ */
326
+ async setReverse(name) {
327
+ const { namespace } = parseName(name);
328
+ const resolverId = await this.resolverOf(namespace);
329
+ const pub = await this.signer.publicKey();
330
+ const r = await this.invoke(resolverId, "set_reverse", [addrArg(pub), nativeToScVal(name.toLowerCase(), { type: "string" })], RESOLVER_ERRORS);
331
+ return { hash: r.hash, ledger: r.ledger };
332
+ }
333
+ /** Remove your reverse record on a namespace's resolver. */
334
+ async clearReverse(namespace) {
335
+ assertLabel(namespace.toLowerCase());
336
+ const resolverId = await this.resolverOf(namespace.toLowerCase());
337
+ const pub = await this.signer.publicKey();
338
+ const r = await this.invoke(resolverId, "clear_reverse", [addrArg(pub)], RESOLVER_ERRORS);
339
+ return { hash: r.hash, ledger: r.ledger };
340
+ }
341
+ /**
342
+ * Declare `name` as YOUR one cross-namespace primary display name. The
343
+ * PrimaryName contract verifies the claim against the namespace resolver
344
+ * on every read, so a primary can never outlive the name it points at.
345
+ * Requires a reverse record first in practice (NotDisplayName otherwise).
346
+ */
347
+ async setPrimary(name) {
348
+ if (!this.primaryId) {
349
+ throw new HolderError("setPrimary needs the PrimaryName contract — configure primaryId (the testnet preset supplies one)");
350
+ }
351
+ parseName(name); // validate shape before spending anything
352
+ const pub = await this.signer.publicKey();
353
+ const r = await this.invoke(this.primaryId, "set_primary", [addrArg(pub), nativeToScVal(name.toLowerCase(), { type: "string" })], PRIMARY_ERRORS);
354
+ return { hash: r.hash, ledger: r.ledger };
355
+ }
356
+ /** Withdraw your primary-name declaration. */
357
+ async clearPrimary() {
358
+ if (!this.primaryId) {
359
+ throw new HolderError("clearPrimary needs the PrimaryName contract — configure primaryId (the testnet preset supplies one)");
360
+ }
361
+ const pub = await this.signer.publicKey();
362
+ const r = await this.invoke(this.primaryId, "clear_primary", [addrArg(pub)], PRIMARY_ERRORS);
363
+ return { hash: r.hash, ledger: r.ledger };
364
+ }
365
+ // ---- name transfers ------------------------------------------------------
366
+ /**
367
+ * Offer YOUR name to `to`. Two-step by contract design: nothing moves
368
+ * until the recipient accepts, so a typo'd address cannot burn the name.
369
+ * Policy-gated (NotTransferable on namespaces that forbid it) and live
370
+ * names only.
371
+ */
372
+ async proposeNameTransfer(name, to) {
373
+ const { label, namespace } = parseName(name);
374
+ const registrarId = await this.registrarOf(namespace);
375
+ const r = await this.invoke(registrarId, "propose_transfer", [labelArg(label), addrArg(to)], REGISTRAR_ERRORS);
376
+ return { hash: r.hash, ledger: r.ledger };
377
+ }
378
+ /** Accept a name offered to you. Signer must be the PROPOSED holder. */
379
+ async acceptNameTransfer(name) {
380
+ const { label, namespace } = parseName(name);
381
+ const registrarId = await this.registrarOf(namespace);
382
+ const r = await this.invoke(registrarId, "accept_transfer", [labelArg(label)], REGISTRAR_ERRORS);
383
+ return { hash: r.hash, ledger: r.ledger };
384
+ }
385
+ /** Withdraw a pending transfer of your name. */
386
+ async cancelNameTransfer(name) {
387
+ const { label, namespace } = parseName(name);
388
+ const registrarId = await this.registrarOf(namespace);
389
+ const r = await this.invoke(registrarId, "cancel_transfer", [labelArg(label)], REGISTRAR_ERRORS);
390
+ return { hash: r.hash, ledger: r.ledger };
391
+ }
392
+ /** The pending transfer proposal on a name, or null. */
393
+ async pendingNameTransfer(name) {
394
+ const { label, namespace } = parseName(name);
395
+ const registrarId = await this.registrarOf(namespace);
396
+ const raw = (await this.read(registrarId, "pending_transfer", [labelArg(label)]));
397
+ if (!raw)
398
+ return null;
399
+ return { from: String(raw.from), to: String(raw.to), expiresAt: BigInt(raw.expires) };
400
+ }
401
+ // ---- discovery -----------------------------------------------------------
402
+ /** The namespace's Registry-attested Registrar. Cached briefly. */
403
+ async registrarOf(namespace) {
404
+ namespace = namespace.toLowerCase();
405
+ assertLabel(namespace);
406
+ const hit = this.registrars.get(namespace);
407
+ if (hit && Date.now() - hit.at < SoranHolder.POINTER_TTL_MS)
408
+ return hit.value;
409
+ const id = (await this.read(this.registryId, "registrar_of", [
410
+ bytesArg(namehash(namespace)),
411
+ ]));
412
+ if (!id) {
413
+ throw new HolderError(`namespace "${namespace}" has no attested Registrar on this Registry`, this.registryId, "registrar_of");
414
+ }
415
+ this.registrars.set(namespace, { value: id, at: Date.now() });
416
+ return id;
417
+ }
418
+ /** The namespace's resolver pointer. Cached briefly. */
419
+ async resolverOf(namespace) {
420
+ namespace = namespace.toLowerCase();
421
+ assertLabel(namespace);
422
+ const hit = this.resolvers.get(namespace);
423
+ if (hit && Date.now() - hit.at < SoranHolder.POINTER_TTL_MS)
424
+ return hit.value;
425
+ const id = (await this.read(this.registryId, "resolver_of", [
426
+ bytesArg(namehash(namespace)),
427
+ ]));
428
+ if (!id) {
429
+ throw new HolderError(`namespace "${namespace}" has no public resolver — records/reverse are unavailable (setAddress still works where the namespace has an attested Registrar)`, this.registryId, "resolver_of");
430
+ }
431
+ this.resolvers.set(namespace, { value: id, at: Date.now() });
432
+ return id;
433
+ }
434
+ // ---- internals (same pipeline discipline as @sorandomains/owner) --------
435
+ async read(contractId, fn, args) {
436
+ const tx = new TransactionBuilder(new Account(SIM_SOURCE, "0"), {
437
+ fee: BASE_FEE,
438
+ networkPassphrase: this.passphrase,
439
+ })
440
+ .addOperation(new Contract(contractId).call(fn, ...args))
441
+ .setTimeout(30)
442
+ .build();
443
+ const sim = await this.server.simulateTransaction(tx);
444
+ if (rpc.Api.isSimulationError(sim))
445
+ throw typedError(contractId, fn, sim.error, {});
446
+ if (rpc.Api.isSimulationRestore(sim)) {
447
+ throw new HolderError(`${fn}: the on-chain entry is archived (rent lapsed) — any write restores it automatically`, contractId, fn);
448
+ }
449
+ if (!rpc.Api.isSimulationSuccess(sim) || !sim.result?.retval)
450
+ return null;
451
+ const v = scValToNative(sim.result.retval);
452
+ return v === undefined ? null : v;
453
+ }
454
+ async signEnvelope(xdrBase64) {
455
+ const signed = await this.signer.signTransaction(xdrBase64, {
456
+ networkPassphrase: this.passphrase,
457
+ });
458
+ return typeof signed === "string" ? signed : signed.signedTxXdr;
459
+ }
460
+ serialize(work) {
461
+ const run = this.queue.then(work, work);
462
+ this.queue = run.then(() => undefined, () => undefined);
463
+ return run;
464
+ }
465
+ async sourceAccount(pub, fn) {
466
+ try {
467
+ return await this.server.getAccount(pub);
468
+ }
469
+ catch {
470
+ throw new HolderError(`${fn}: signer account ${pub} does not exist on this network — fund it (testnet: friendbot) before writing`, null, fn);
471
+ }
472
+ }
473
+ assertSatisfiableAuth(prepared, pub, contractId, fn) {
474
+ const op = prepared.operations[0];
475
+ for (const entry of op?.auth ?? []) {
476
+ const cred = entry.credentials();
477
+ if (cred.switch() !== xdr.SorobanCredentialsType.sorobanCredentialsAddress())
478
+ continue;
479
+ let required;
480
+ try {
481
+ required = Address.fromScAddress(cred.address().address()).toString();
482
+ }
483
+ catch {
484
+ continue;
485
+ }
486
+ throw new HolderError(required === pub
487
+ ? `${fn}: the network requires a separate auth-entry signature from ${pub}, which this SDK does not produce yet — make the authorized account the transaction source`
488
+ : `${fn}: this operation must be authorized by ${required}, but the signer is ${pub} — use that account's signer`, contractId, fn);
489
+ }
490
+ }
491
+ invoke(contractId, fn, args, errNames) {
492
+ return this.serialize(async () => {
493
+ try {
494
+ return await this.attempt(contractId, fn, args, errNames);
495
+ }
496
+ catch (e) {
497
+ if (/txBadSeq|bad_seq/i.test(String(e))) {
498
+ return await this.attempt(contractId, fn, args, errNames);
499
+ }
500
+ throw e;
501
+ }
502
+ });
503
+ }
504
+ async attempt(contractId, fn, args, errNames) {
505
+ const pub = await this.signer.publicKey();
506
+ const contract = new Contract(contractId);
507
+ const build = (source) => new TransactionBuilder(source, { fee: this.fee, networkPassphrase: this.passphrase })
508
+ .addOperation(contract.call(fn, ...args))
509
+ .setTimeout(this.timeoutSecs)
510
+ .build();
511
+ let tx = build(await this.sourceAccount(pub, fn));
512
+ let sim = await this.server.simulateTransaction(tx);
513
+ for (let round = 0; rpc.Api.isSimulationRestore(sim); round++) {
514
+ if (round >= 2) {
515
+ throw new HolderError(`${fn}: entries still need restoring after ${round} restore transactions — retry later`, contractId, fn);
516
+ }
517
+ await this.restore(sim, pub);
518
+ tx = build(await this.sourceAccount(pub, fn));
519
+ sim = await this.server.simulateTransaction(tx);
520
+ }
521
+ if (rpc.Api.isSimulationError(sim)) {
522
+ throw typedError(contractId, fn, sim.error, errNames);
523
+ }
524
+ const prepared = rpc.assembleTransaction(tx, sim).build();
525
+ this.assertSatisfiableAuth(prepared, pub, contractId, fn);
526
+ const txHash = prepared.hash().toString("hex");
527
+ const signed = await this.signEnvelope(prepared.toXDR());
528
+ const envelope = TransactionBuilder.fromXDR(signed, this.passphrase);
529
+ let sent;
530
+ try {
531
+ sent = await this.server.sendTransaction(envelope);
532
+ }
533
+ catch (e) {
534
+ throw new HolderError(`${fn}: submit failed after signing (${String(e)}) — transaction ${txHash} may or may not have reached the network; check the hash before retrying`, contractId, fn, null, null, txHash);
535
+ }
536
+ if (sent.status === "ERROR") {
537
+ throw new HolderError(`${fn}: submit rejected: ${JSON.stringify(sent.errorResult ?? sent.status)}`, contractId, fn, null, null, txHash);
538
+ }
539
+ if (sent.status === "TRY_AGAIN_LATER") {
540
+ throw new HolderError(`${fn}: the network did not accept the transaction (TRY_AGAIN_LATER) — it was NOT queued; safe to retry shortly`, contractId, fn, null, null, txHash);
541
+ }
542
+ try {
543
+ const { ledger, returnValue } = await this.confirm(txHash, contractId, fn, errNames);
544
+ return { hash: txHash, ledger, returnValue };
545
+ }
546
+ catch (e) {
547
+ if (e instanceof HolderError)
548
+ throw e;
549
+ throw new HolderError(`${fn}: confirmation interrupted (${String(e)}) — transaction ${txHash} may still be included; check the hash before retrying`, contractId, fn, null, null, txHash);
550
+ }
551
+ }
552
+ async restore(sim, pub) {
553
+ const pre = sim.restorePreamble;
554
+ const fee = (Number(this.fee) + Number(pre.minResourceFee)).toString();
555
+ const tx = new TransactionBuilder(await this.sourceAccount(pub, "restore_footprint"), {
556
+ fee,
557
+ networkPassphrase: this.passphrase,
558
+ })
559
+ .setSorobanData(pre.transactionData.build())
560
+ .addOperation(Operation.restoreFootprint({}))
561
+ .setTimeout(this.timeoutSecs)
562
+ .build();
563
+ const signed = await this.signEnvelope(tx.toXDR());
564
+ const sent = await this.server.sendTransaction(TransactionBuilder.fromXDR(signed, this.passphrase));
565
+ if (sent.status === "ERROR") {
566
+ throw new HolderError(`restore_footprint: submit rejected: ${JSON.stringify(sent.errorResult ?? sent.status)}`, null, "restore_footprint", null, null, sent.hash);
567
+ }
568
+ await this.confirm(sent.hash, "", "restore_footprint", {});
569
+ }
570
+ async confirm(hash_, contractId, fn, errNames) {
571
+ const tries = this.timeoutSecs + 5;
572
+ let got = await this.server.getTransaction(hash_);
573
+ for (let i = 0; i < tries && got.status === "NOT_FOUND"; i++) {
574
+ await new Promise((r) => setTimeout(r, 1000));
575
+ got = await this.server.getTransaction(hash_);
576
+ }
577
+ if (got.status === "NOT_FOUND") {
578
+ throw new HolderError(`${fn}: transaction ${hash_} not confirmed within ${tries}s — it may still be included; check the hash before retrying`, contractId || null, fn, null, null, hash_);
579
+ }
580
+ if (got.status !== "SUCCESS") {
581
+ throw this.decodeFailure(got, contractId, fn, errNames, hash_);
582
+ }
583
+ let returnValue = null;
584
+ try {
585
+ if (got.returnValue)
586
+ returnValue = scValToNative(got.returnValue);
587
+ }
588
+ catch {
589
+ /* void return */
590
+ }
591
+ return { ledger: got.ledger, returnValue };
592
+ }
593
+ decodeFailure(got, contractId, fn, errNames, hash_) {
594
+ let code = null;
595
+ try {
596
+ const meta = got.resultMetaXdr;
597
+ const diags = got.diagnosticEventsXdr ??
598
+ (meta && meta.switch() === 3
599
+ ? (meta.v3().sorobanMeta()?.diagnosticEvents() ?? [])
600
+ : meta && meta.switch() === 4
601
+ ? meta.v4().diagnosticEvents()
602
+ : []);
603
+ outer: for (const d of diags) {
604
+ const body = d.event().body().v0();
605
+ for (const v of [...body.topics(), body.data()]) {
606
+ if (v.switch() === xdr.ScValType.scvError()) {
607
+ const err = v.error();
608
+ if (err.switch() === xdr.ScErrorType.sceContract()) {
609
+ code = err.contractCode();
610
+ break outer;
611
+ }
612
+ }
613
+ }
614
+ }
615
+ }
616
+ catch {
617
+ /* diagnostics unavailable */
618
+ }
619
+ let resultCode = `tx status ${got.status}`;
620
+ try {
621
+ resultCode = got.resultXdr?.result().switch().name ?? resultCode;
622
+ }
623
+ catch {
624
+ /* keep plain status */
625
+ }
626
+ if (code !== null) {
627
+ const codeName = errNames[code] ?? null;
628
+ return new HolderError(`${fn} failed at inclusion: contract error #${code}${codeName ? ` (${codeName})` : ""} (${resultCode})`, contractId, fn, code, codeName, hash_);
629
+ }
630
+ return new HolderError(`${fn} failed at inclusion (${resultCode})`, contractId, fn, null, null, hash_);
631
+ }
632
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@sorandomains/holder",
3
+ "version": "0.1.0",
4
+ "description": "Manage your own Soran name on Stellar — records, profile, reverse, primary, and transfers, signed by your key.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "tsc",
21
+ "check:browser": "esbuild dist/index.js --bundle --platform=browser --external:@stellar/stellar-sdk --outfile=browser-check.js --legal-comments=none --log-level=error && node -e \"const fs=require('fs');const s=fs.readFileSync('browser-check.js','utf8');fs.unlinkSync('browser-check.js');if(/\\bBuffer\\b/.test(s)){console.error('FAIL: bare Buffer reference in browser bundle');process.exit(1)}console.log('browser bundle clean')\""
22
+ },
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/SoranDomains/sdk.git",
27
+ "directory": "packages/holder"
28
+ },
29
+ "homepage": "https://github.com/SoranDomains/sdk/tree/main/packages/holder#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/SoranDomains/sdk/issues"
32
+ },
33
+ "keywords": [
34
+ "stellar",
35
+ "soroban",
36
+ "soran",
37
+ "name-service",
38
+ "profile",
39
+ "reverse-lookup"
40
+ ],
41
+ "peerDependencies": {
42
+ "@stellar/stellar-sdk": ">=13 <17"
43
+ },
44
+ "devDependencies": {
45
+ "@stellar/stellar-sdk": "^16.2.0",
46
+ "@types/node": "^22.20.1",
47
+ "esbuild": "^0.25.0",
48
+ "tsx": "^4.19.2",
49
+ "typescript": "^5.7.3"
50
+ }
51
+ }