@sorandomains/owner 0.2.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,69 @@
1
+ # @sorandomains/owner
2
+
3
+ Run your Soran namespace on Stellar, programmatically. Issue names to your
4
+ users, manage their lifecycle, transfer the namespace, and — when you are
5
+ ready — walk the one-way permanence door. Every operation is a transaction
6
+ your own key signs and any Soroban RPC node submits: no Soran account, no
7
+ hosted API in the path.
8
+
9
+ ```bash
10
+ npm install @sorandomains/owner @stellar/stellar-sdk
11
+ ```
12
+
13
+ ```ts
14
+ import { SoranOwner, keypairSigner } from "@sorandomains/owner";
15
+
16
+ const owner = new SoranOwner({ signer: keypairSigner(process.env.OWNER_SECRET!) });
17
+
18
+ // alice.acme now resolves — one call, owner-signed, on chain
19
+ await owner.issue("acme", "alice", "GDHN…USER");
20
+
21
+ // or up to 23 names in one transaction, with per-label outcomes
22
+ const batch = await owner.issueBatch("acme", rows);
23
+ for (const o of batch.outcomes) if (!o.issued) console.warn(o.label, o.reason);
24
+ ```
25
+
26
+ ## What's in the box
27
+
28
+ | Operation | What it does |
29
+ | --- | --- |
30
+ | `issue` / `issueBatch` | Issue `label.namespace` to a holder (batch: ≤23/tx, per-label outcome report) |
31
+ | `reclaim` | Take a name back — only where the namespace policy allows it |
32
+ | `renew` | Extend a finite-term name; returns the new expiry |
33
+ | `setTreasury` | Route reclaim custody to a treasury address |
34
+ | `makePermanent` | The one-way door — irreversible, guarded by `{ confirmIrreversible: true }` |
35
+ | `proposeNamespaceTransfer` / `acceptNamespaceTransfer` / `cancelNamespaceTransfer` | Two-step, accept-to-move namespace transfer |
36
+ | `setResolver` | Point the namespace at a resolver (frozen once permanent) |
37
+ | `policy` / `isPermanent` / `nameState` / `pendingNamespaceTransfer` / `namespaceOwner` | Reads that support the write flows |
38
+
39
+ Name-**holder** powers (transferring an individual name, re-pointing it,
40
+ electing a primary name) are deliberately absent: the contracts grant them to
41
+ holders, not owners, and they will ship in a holder-side module.
42
+
43
+ ## Signing
44
+
45
+ Backends use `keypairSigner(secret)`. Browser apps pass the wallet itself —
46
+ Freighter and Stellar Wallets Kit already match the `TxSigner` shape:
47
+
48
+ ```ts
49
+ const owner = new SoranOwner({
50
+ signer: {
51
+ publicKey: () => walletAddress,
52
+ signTransaction: (xdr, opts) => kit.signTransaction(xdr, opts),
53
+ },
54
+ });
55
+ ```
56
+
57
+ The SDK simulates each call first, so policy violations and permission errors
58
+ surface as typed `OwnerError`s (`code`, `codeName`) before anything is signed
59
+ or any fee is spent. Failed operations that reached the network carry
60
+ `txHash` — always re-check a hash before retrying.
61
+
62
+ ## Resolution is the other package
63
+
64
+ Wallets and apps that only read names should depend on
65
+ [`@sorandomains/lookup`](https://www.npmjs.com/package/@sorandomains/lookup) —
66
+ trustless resolve, reverse lookup, and ownership assurance, with no signing
67
+ code at all.
68
+
69
+ Docs: <https://github.com/SoranDomains/docs> · License: MIT
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @sorandomains/owner — the write-side SDK for Soran namespace owners.
3
+ *
4
+ * Everything a namespace owner does after winning a namespace, programmatically:
5
+ *
6
+ * import { SoranOwner, keypairSigner } from "@sorandomains/owner";
7
+ *
8
+ * const owner = new SoranOwner({ signer: keypairSigner(process.env.OWNER_SECRET!) });
9
+ * await owner.issue("acme", "alice", "GDHN…"); // alice.acme now resolves
10
+ * await owner.issueBatch("acme", csvRows); // up to 23 per transaction
11
+ *
12
+ * TRUST MODEL. This SDK talks to the contracts directly over any Soroban RPC
13
+ * node — no Soran account, no hosted API, no third party in the path. Every
14
+ * operation here is authorized on chain: the Registrar and Registry check
15
+ * `require_auth` against the namespace owner's address, so the only credential
16
+ * that matters is the signer you supply. Soran's servers cannot perform any of
17
+ * these operations for you or against you.
18
+ *
19
+ * WHO CAN CALL WHAT. All operations below are namespace-OWNER powers except
20
+ * `acceptNamespaceTransfer`, which the proposed NEW owner signs. Name-holder
21
+ * powers (transferring an individual name, pointing it at a new address,
22
+ * electing a primary name) are the holder's alone — they live in the contracts'
23
+ * holder-authorized entry points and will ship in a holder-side module; this
24
+ * package deliberately cannot exercise them.
25
+ *
26
+ * SIGNING. Supply a `TxSigner`: `keypairSigner(secret)` for backends, or wrap a
27
+ * browser wallet (Freighter / Stellar Wallets Kit expose exactly this
28
+ * `signTransaction(xdr, { networkPassphrase })` shape). The SDK builds and
29
+ * simulates each transaction, hands you the envelope to sign, and submits it.
30
+ * Operations on one SoranOwner instance are serialized so concurrent calls
31
+ * cannot race the account sequence number.
32
+ *
33
+ * READS. For resolution, reverse lookup, and assurance checks use the read-only
34
+ * companion package `@sorandomains/lookup` — wallets and apps should depend on
35
+ * that one only. The few reads here (policy, pending transfers, records) exist
36
+ * to support write flows.
37
+ */
38
+ /** Known public deployments. Pass explicit options for anything else. */
39
+ export declare const DEPLOYMENTS: {
40
+ readonly testnet: {
41
+ readonly rpcUrl: "https://soroban-testnet.stellar.org";
42
+ readonly passphrase: string;
43
+ readonly registryId: "CAUEHYVLLNNDZ4H5QWCPBDWEONRI44SI3XYSEACB4U3HYILIVQGQAMNI";
44
+ };
45
+ };
46
+ /**
47
+ * Anything that can sign a base64 transaction envelope. Browser wallets
48
+ * match `signTransaction`'s shape (the signed XDR string and `{ signedTxXdr }`
49
+ * object returns are both accepted); wrap their address getter as `publicKey`:
50
+ *
51
+ * { publicKey: () => walletAddress, signTransaction: (x, o) => kit.signTransaction(x, o) }
52
+ */
53
+ export type TxSigner = {
54
+ publicKey(): string | Promise<string>;
55
+ signTransaction(xdrBase64: string, opts: {
56
+ networkPassphrase: string;
57
+ }): Promise<string | {
58
+ signedTxXdr: string;
59
+ }>;
60
+ };
61
+ /** A TxSigner over a raw secret key — for backends and scripts. */
62
+ export declare function keypairSigner(secret: string): TxSigner;
63
+ /**
64
+ * A failed owner operation. When the contract itself rejected the call,
65
+ * `code`/`codeName` carry its typed error (e.g. code 9 / "NotNamespaceOwner"
66
+ * from the Registrar); both are null for transport-level failures. `txHash` is
67
+ * set when a transaction reached the network before failing or timing out —
68
+ * always re-check an operation with a hash before retrying it.
69
+ */
70
+ export declare class OwnerError extends Error {
71
+ readonly contractId: string | null;
72
+ readonly fn: string | null;
73
+ readonly code: number | null;
74
+ readonly codeName: string | null;
75
+ readonly txHash: string | null;
76
+ constructor(message: string, contractId?: string | null, fn?: string | null, code?: number | null, codeName?: string | null, txHash?: string | null);
77
+ }
78
+ /** One transaction issues at most this many names (contract MAX_BATCH). */
79
+ export declare const MAX_BATCH = 23;
80
+ export type Submitted = {
81
+ hash: string;
82
+ ledger: number;
83
+ };
84
+ export type IssueResult = Submitted & {
85
+ /** The issued name's 32-byte node, hex — what resolvers key records by. */
86
+ node: string;
87
+ };
88
+ export type IssueOutcome = {
89
+ label: string;
90
+ holder: string;
91
+ issued: boolean;
92
+ /** Best-effort reason a name was not issued: already held by someone else
93
+ * ("taken") or refused in-batch ("skipped" — e.g. a duplicate label earlier
94
+ * in the same batch won). Absent when `issued` is true. */
95
+ reason?: "taken" | "skipped";
96
+ };
97
+ export type IssueBatchResult = Submitted & {
98
+ /** How many names the contract reports it issued in this transaction. */
99
+ issuedCount: number;
100
+ /** Per-label outcome. With `outcomeSource: "events"` (the normal case) each
101
+ * outcome is read from the transaction's own `issued` events — exact, no
102
+ * clocks, no re-reads. */
103
+ outcomes: IssueOutcome[];
104
+ /** Where the outcomes came from: "events" (the transaction's own contract
105
+ * events — authoritative), or "reread" (post-transaction state reads —
106
+ * correct unless a concurrent writer touched the same labels). */
107
+ outcomeSource: "events" | "reread";
108
+ /** False if `issuedCount` and the outcomes disagree — with "events" this
109
+ * cannot happen short of a node serving damaged meta; with "reread" it
110
+ * usually means a concurrent writer. `issuedCount` is the contract's own
111
+ * answer; investigate before assuming either side. */
112
+ countMatches: boolean;
113
+ };
114
+ export type NamespacePolicy = {
115
+ reclaimable: boolean;
116
+ transferable: boolean;
117
+ tradeable: boolean;
118
+ /** Seconds per issued term. 0n = names never expire. */
119
+ defaultTermSecs: bigint;
120
+ tradeFeeBps: number;
121
+ };
122
+ export type NameState = {
123
+ holder: string;
124
+ /** Where the name currently resolves (holder-controlled; may differ). */
125
+ address: string;
126
+ /** 0n = never expires. */
127
+ expiresAt: bigint;
128
+ generation: bigint;
129
+ };
130
+ export type OwnerOptions = {
131
+ /** Signs every transaction. The namespace owner's account — or, for
132
+ * `acceptNamespaceTransfer`, the proposed new owner's. */
133
+ signer: TxSigner;
134
+ /** Named deployment preset (rpcUrl + passphrase + registryId together).
135
+ * Defaults to "testnet". Explicit fields below override preset pieces —
136
+ * but override rpcUrl/passphrase/registryId as a SET for a custom
137
+ * deployment; mixing one network's registry with another's passphrase
138
+ * cannot work. */
139
+ network?: keyof typeof DEPLOYMENTS;
140
+ rpcUrl?: string;
141
+ passphrase?: string;
142
+ registryId?: string;
143
+ /** Allow http:// RPC (local dev only). */
144
+ allowHttp?: boolean;
145
+ /** Transaction time bound, seconds (default 60). */
146
+ timeoutSecs?: number;
147
+ /** Base fee bid, stroops (default 100; resource fees are added on top). */
148
+ fee?: string;
149
+ };
150
+ export declare class SoranOwner {
151
+ private server;
152
+ private passphrase;
153
+ private registryId;
154
+ private signer;
155
+ private timeoutSecs;
156
+ private fee;
157
+ private queue;
158
+ private registrars;
159
+ private static REGISTRAR_TTL_MS;
160
+ constructor(opts: OwnerOptions);
161
+ /** The namespace's on-chain attested Registrar id. Cached per instance. */
162
+ registrarOf(namespace: string): Promise<string>;
163
+ /** The namespace's current owner on the Registry, or null if unallocated. */
164
+ namespaceOwner(namespace: string): Promise<string | null>;
165
+ /**
166
+ * Throw unless the configured signer is the namespace's current owner.
167
+ * Cheap preflight for scripts — write operations are owner-checked on chain
168
+ * regardless, so skipping this only trades a clearer error for one read.
169
+ */
170
+ assertOwner(namespace: string): Promise<void>;
171
+ /** Issue `label.namespace` to `holder`. Owner-signed; term set by policy. */
172
+ issue(namespace: string, label: string, holder: string): Promise<IssueResult>;
173
+ /**
174
+ * Issue up to {@link MAX_BATCH} names in ONE owner-signed transaction.
175
+ *
176
+ * The contract skips (rather than aborts on) labels that are already held,
177
+ * so a batch is safe to re-run after a partial failure. The SDK re-reads
178
+ * every record after inclusion and reports a per-label outcome — always
179
+ * check `outcomes`, not just the count.
180
+ */
181
+ issueBatch(namespace: string, entries: ReadonlyArray<{
182
+ label: string;
183
+ holder: string;
184
+ }>): Promise<IssueBatchResult>;
185
+ /**
186
+ * Take `label.namespace` back from its holder. Only on namespaces whose
187
+ * policy is reclaimable — the contract answers NotReclaimable both when the
188
+ * policy never allowed it and after `make_permanent` removed it forever.
189
+ */
190
+ reclaim(namespace: string, label: string): Promise<Submitted>;
191
+ /** Extend a finite-term name. Returns the new expiry (unix seconds). */
192
+ renew(namespace: string, label: string, extendSecs: number | bigint): Promise<Submitted & {
193
+ expiresAt: bigint;
194
+ }>;
195
+ /** Route future reclaims' custody to a treasury address (or back to you). */
196
+ setTreasury(namespace: string, newTreasury: string): Promise<Submitted>;
197
+ /**
198
+ * The one-way door. Locks reclaim off and freezes the Registrar's code —
199
+ * every name in the namespace becomes permanently its holder's, and there is
200
+ * NO PATH BACK, for you or anyone. The contract additionally requires that
201
+ * the policy has always issued permanent terms (FiniteTermPolicy otherwise)
202
+ * and that the Registrar/Resolver provenance is clean.
203
+ */
204
+ makePermanent(namespace: string, opts: {
205
+ confirmIrreversible: boolean;
206
+ }): Promise<Submitted>;
207
+ /**
208
+ * Offer the whole namespace to `to`. Nothing moves until the recipient
209
+ * accepts; re-proposing replaces the pending offer, cancel withdraws it.
210
+ */
211
+ proposeNamespaceTransfer(namespace: string, to: string): Promise<Submitted>;
212
+ /** Accept a namespace offered to you. Signer must be the PROPOSED owner. */
213
+ acceptNamespaceTransfer(namespace: string): Promise<Submitted>;
214
+ /** Withdraw a pending namespace transfer. Current owner only. */
215
+ cancelNamespaceTransfer(namespace: string): Promise<Submitted>;
216
+ /**
217
+ * Point the namespace at a resolver contract, or clear it with null.
218
+ * Refused (ResolverFrozen) once the namespace is permanent.
219
+ */
220
+ setResolver(namespace: string, resolver: string | null): Promise<Submitted>;
221
+ /** The namespace's immutable issuance policy. */
222
+ policy(namespace: string): Promise<NamespacePolicy>;
223
+ /** Has the namespace passed the one-way door? */
224
+ isPermanent(namespace: string): Promise<boolean>;
225
+ /** Current state of `label.namespace`, or null if never issued. */
226
+ nameState(namespace: string, label: string): Promise<NameState | null>;
227
+ /** The pending namespace transfer on the Registry, or null. */
228
+ pendingNamespaceTransfer(namespace: string): Promise<unknown | null>;
229
+ private readRecord;
230
+ private readRecords;
231
+ /** Simulation-only contract read — no signature, no fee, no state change. */
232
+ private read;
233
+ private signEnvelope;
234
+ private serialize;
235
+ /** The signer's live account (sequence source) — with an actionable error
236
+ * when the account has never been funded on this network. */
237
+ private sourceAccount;
238
+ private invoke;
239
+ /**
240
+ * Simulation RECORDS auth requirements without verifying them: a call the
241
+ * signer is not authorized for still simulates cleanly, then fails on
242
+ * chain — after spending the fee. Refuse before signing when the recorded
243
+ * auth demands an address the envelope signature cannot satisfy.
244
+ */
245
+ private assertSatisfiableAuth;
246
+ private attempt;
247
+ private restore;
248
+ private confirm;
249
+ /**
250
+ * A transaction that simulated cleanly but FAILED at inclusion (a race: the
251
+ * state changed between simulation and apply). The contract's typed error
252
+ * travels in the diagnostic events, not the result XDR — recover it so the
253
+ * race cases report the same typed codes the simulation path does.
254
+ */
255
+ private decodeFailure;
256
+ }
package/dist/index.js ADDED
@@ -0,0 +1,738 @@
1
+ /**
2
+ * @sorandomains/owner — the write-side SDK for Soran namespace owners.
3
+ *
4
+ * Everything a namespace owner does after winning a namespace, programmatically:
5
+ *
6
+ * import { SoranOwner, keypairSigner } from "@sorandomains/owner";
7
+ *
8
+ * const owner = new SoranOwner({ signer: keypairSigner(process.env.OWNER_SECRET!) });
9
+ * await owner.issue("acme", "alice", "GDHN…"); // alice.acme now resolves
10
+ * await owner.issueBatch("acme", csvRows); // up to 23 per transaction
11
+ *
12
+ * TRUST MODEL. This SDK talks to the contracts directly over any Soroban RPC
13
+ * node — no Soran account, no hosted API, no third party in the path. Every
14
+ * operation here is authorized on chain: the Registrar and Registry check
15
+ * `require_auth` against the namespace owner's address, so the only credential
16
+ * that matters is the signer you supply. Soran's servers cannot perform any of
17
+ * these operations for you or against you.
18
+ *
19
+ * WHO CAN CALL WHAT. All operations below are namespace-OWNER powers except
20
+ * `acceptNamespaceTransfer`, which the proposed NEW owner signs. Name-holder
21
+ * powers (transferring an individual name, pointing it at a new address,
22
+ * electing a primary name) are the holder's alone — they live in the contracts'
23
+ * holder-authorized entry points and will ship in a holder-side module; this
24
+ * package deliberately cannot exercise them.
25
+ *
26
+ * SIGNING. Supply a `TxSigner`: `keypairSigner(secret)` for backends, or wrap a
27
+ * browser wallet (Freighter / Stellar Wallets Kit expose exactly this
28
+ * `signTransaction(xdr, { networkPassphrase })` shape). The SDK builds and
29
+ * simulates each transaction, hands you the envelope to sign, and submits it.
30
+ * Operations on one SoranOwner instance are serialized so concurrent calls
31
+ * cannot race the account sequence number.
32
+ *
33
+ * READS. For resolution, reverse lookup, and assurance checks use the read-only
34
+ * companion package `@sorandomains/lookup` — wallets and apps should depend on
35
+ * that one only. The few reads here (policy, pending transfers, records) exist
36
+ * to support write flows.
37
+ */
38
+ import { Account, Address, BASE_FEE, Contract, Keypair, Networks, Operation, TransactionBuilder, hash, nativeToScVal, rpc, scValToNative, xdr, } from "@stellar/stellar-sdk";
39
+ /** Contract events from a transaction's meta, across meta versions (v3 puts
40
+ * them on sorobanMeta; v4 nests them per-operation and in tx events). */
41
+ function extractContractEvents(meta) {
42
+ try {
43
+ if (!meta)
44
+ return [];
45
+ if (meta.switch() === 3)
46
+ return meta.v3().sorobanMeta()?.events() ?? [];
47
+ if (meta.switch() === 4) {
48
+ const v4 = meta.v4();
49
+ return [
50
+ ...v4.operations().flatMap((op) => op.events()),
51
+ ...v4.events().map((te) => te.event()),
52
+ ];
53
+ }
54
+ return [];
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ /** The (label, holder) pairs from a transaction's Registrar `issued` events. */
61
+ function decodeIssuedEvents(events, registrarId) {
62
+ const out = [];
63
+ for (const ev of events) {
64
+ try {
65
+ const cid = ev.contractId();
66
+ if (!cid)
67
+ continue;
68
+ if (Address.contract(cid).toString() !== registrarId)
69
+ continue;
70
+ const body = ev.body().v0();
71
+ const topics = body.topics();
72
+ if (topics.length < 1 || scValToNative(topics[0]) !== "issued")
73
+ continue;
74
+ const data = scValToNative(body.data());
75
+ out.push({ label: Buffer.from(data[0]).toString("utf8"), holder: String(data[1]) });
76
+ }
77
+ catch {
78
+ /* not an issued event — skip */
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Deployments
85
+ // ---------------------------------------------------------------------------
86
+ /** Known public deployments. Pass explicit options for anything else. */
87
+ export const DEPLOYMENTS = {
88
+ testnet: {
89
+ rpcUrl: "https://soroban-testnet.stellar.org",
90
+ passphrase: Networks.TESTNET,
91
+ // The immutable Registry. The Registrar for a namespace is discovered on
92
+ // chain via `registrar_of(node)` — never configured by hand.
93
+ registryId: "CAUEHYVLLNNDZ4H5QWCPBDWEONRI44SI3XYSEACB4U3HYILIVQGQAMNI",
94
+ },
95
+ };
96
+ /** A TxSigner over a raw secret key — for backends and scripts. */
97
+ export function keypairSigner(secret) {
98
+ const kp = Keypair.fromSecret(secret);
99
+ return {
100
+ publicKey: () => kp.publicKey(),
101
+ signTransaction: async (xdrBase64, { networkPassphrase }) => {
102
+ const tx = TransactionBuilder.fromXDR(xdrBase64, networkPassphrase);
103
+ tx.sign(kp);
104
+ return tx.toXDR();
105
+ },
106
+ };
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Errors
110
+ // ---------------------------------------------------------------------------
111
+ /** Registrar contract error codes, by number. */
112
+ const REGISTRAR_ERRORS = {
113
+ 1: "AlreadyInitialized",
114
+ 2: "NotInitialized",
115
+ 3: "NameTaken",
116
+ 4: "NameNotFound",
117
+ 5: "NotReclaimable",
118
+ 6: "NotTransferable",
119
+ 7: "AlreadyPermanent",
120
+ 8: "BatchTooLarge",
121
+ 9: "NotNamespaceOwner",
122
+ 10: "BadLabel",
123
+ 11: "PermanentName",
124
+ 12: "FiniteTermPolicy",
125
+ 13: "PermanentlyLocked",
126
+ 14: "NameExpired",
127
+ 15: "NoPendingTransfer",
128
+ 16: "TransferExpired",
129
+ 17: "StaleTransfer",
130
+ 18: "InvalidPolicy",
131
+ 19: "ExpiryOverflow",
132
+ 20: "InvalidRegistry",
133
+ };
134
+ /** Registry contract error codes, by number. */
135
+ const REGISTRY_ERRORS = {
136
+ 1: "AlreadyInitialized",
137
+ 2: "NotInitialized",
138
+ 3: "NodeTaken",
139
+ 4: "NodeNotFound",
140
+ 5: "BadProof",
141
+ 6: "NotOwner",
142
+ 7: "ReservedLabel",
143
+ 8: "BadLabel",
144
+ 9: "ReservationActive",
145
+ 10: "ReservationLapsed",
146
+ 11: "NoPendingTransfer",
147
+ 12: "TransferExpired",
148
+ 13: "ResolverFrozen",
149
+ 14: "ResolverRequired",
150
+ 15: "UnapprovedResolver",
151
+ 16: "UnapprovedRegistrar",
152
+ 17: "ResolverAuthorityMismatch",
153
+ 18: "RegistrarAlreadyDeployed",
154
+ 19: "UnattestedRegistrar",
155
+ 20: "RegistrarTainted",
156
+ 21: "UnattestedResolver",
157
+ 22: "ResolverTainted",
158
+ 23: "ResolverAlreadyDeployed",
159
+ 24: "InvalidPolicy",
160
+ 25: "ProvenanceMismatch",
161
+ 26: "AnchorMismatch",
162
+ 27: "InvalidAllocator",
163
+ 28: "StaleEra",
164
+ };
165
+ /**
166
+ * A failed owner operation. When the contract itself rejected the call,
167
+ * `code`/`codeName` carry its typed error (e.g. code 9 / "NotNamespaceOwner"
168
+ * from the Registrar); both are null for transport-level failures. `txHash` is
169
+ * set when a transaction reached the network before failing or timing out —
170
+ * always re-check an operation with a hash before retrying it.
171
+ */
172
+ export class OwnerError extends Error {
173
+ contractId;
174
+ fn;
175
+ code;
176
+ codeName;
177
+ txHash;
178
+ constructor(message, contractId = null, fn = null, code = null, codeName = null, txHash = null) {
179
+ super(message);
180
+ this.contractId = contractId;
181
+ this.fn = fn;
182
+ this.code = code;
183
+ this.codeName = codeName;
184
+ this.txHash = txHash;
185
+ this.name = "OwnerError";
186
+ }
187
+ }
188
+ /** `Error(Contract, #N)` in a simulation/result message → N, else null. */
189
+ function parseContractCode(message) {
190
+ const m = /Error\(Contract, #(\d+)\)/.exec(message);
191
+ return m ? Number(m[1]) : null;
192
+ }
193
+ function typedError(contractId, fn, raw, names, txHash = null) {
194
+ const code = parseContractCode(raw);
195
+ const codeName = code !== null ? (names[code] ?? null) : null;
196
+ const label = codeName ? ` (${codeName})` : "";
197
+ return new OwnerError(`${fn} failed${code !== null ? `: contract error #${code}${label}` : `: ${raw}`}`, contractId, fn, code, codeName, txHash);
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // Hashing + arguments (must mirror the on-chain scheme exactly)
201
+ // ---------------------------------------------------------------------------
202
+ const LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
203
+ /** Contract label rules: 1–63 bytes of a-z 0-9, `-` neither first nor last. */
204
+ function assertLabel(label) {
205
+ if (label.length < 1 || label.length > 63 || !LABEL_RE.test(label)) {
206
+ throw new OwnerError(`invalid label "${label}" — 1-63 chars of a-z, 0-9, and non-edge hyphens`);
207
+ }
208
+ }
209
+ /** Registry namehash of a top-level namespace: sha256(ZERO32 ‖ sha256(ns)). */
210
+ function namehash(namespace) {
211
+ return hash(Buffer.concat([Buffer.alloc(32), hash(Buffer.from(namespace, "utf8"))]));
212
+ }
213
+ const labelArg = (label) => nativeToScVal(Buffer.from(label, "utf8"), { type: "bytes" });
214
+ const addrArg = (address) => nativeToScVal(address, { type: "address" });
215
+ const nodeArg = (node) => nativeToScVal(node, { type: "bytes" });
216
+ const toHex = (v) => Buffer.from(v).toString("hex");
217
+ // Simulation-only reads need a well-formed source account that never signs.
218
+ const SIM_SOURCE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
219
+ /** One transaction issues at most this many names (contract MAX_BATCH). */
220
+ export const MAX_BATCH = 23;
221
+ // ---------------------------------------------------------------------------
222
+ // The client
223
+ // ---------------------------------------------------------------------------
224
+ export class SoranOwner {
225
+ server;
226
+ passphrase;
227
+ registryId;
228
+ signer;
229
+ timeoutSecs;
230
+ fee;
231
+ // One in-flight write at a time per instance: concurrent builds would fetch
232
+ // the same account sequence and the loser fails txBAD_SEQ.
233
+ queue = Promise.resolve();
234
+ // Registrar pointers are re-read after a short TTL: the attestation is
235
+ // one-per-node, but "forever" caching would survive process-lifetime edge
236
+ // cases (archival + redeploy) that a cheap re-read absorbs.
237
+ registrars = new Map();
238
+ static REGISTRAR_TTL_MS = 30_000;
239
+ constructor(opts) {
240
+ if (!opts?.signer)
241
+ throw new OwnerError("OwnerOptions.signer is required");
242
+ const d = DEPLOYMENTS[opts.network ?? "testnet"];
243
+ if (!d)
244
+ throw new OwnerError(`unknown network "${opts.network}"`);
245
+ const rpcUrl = opts.rpcUrl ?? d.rpcUrl;
246
+ this.server = new rpc.Server(rpcUrl, { allowHttp: opts.allowHttp ?? false });
247
+ this.passphrase = opts.passphrase ?? d.passphrase;
248
+ this.registryId = opts.registryId ?? d.registryId;
249
+ this.signer = opts.signer;
250
+ const t = opts.timeoutSecs ?? 60;
251
+ if (!Number.isInteger(t) || t < 1 || t > 300) {
252
+ throw new OwnerError(`timeoutSecs must be an integer between 1 and 300 (got ${t}) — 0/unbounded would leave transactions valid forever while confirmation polling gives up`);
253
+ }
254
+ this.timeoutSecs = t;
255
+ this.fee = opts.fee ?? BASE_FEE;
256
+ }
257
+ // ---- discovery -----------------------------------------------------------
258
+ /** The namespace's on-chain attested Registrar id. Cached per instance. */
259
+ async registrarOf(namespace) {
260
+ assertLabel(namespace);
261
+ const hit = this.registrars.get(namespace);
262
+ if (hit && Date.now() - hit.at < SoranOwner.REGISTRAR_TTL_MS)
263
+ return hit.value;
264
+ const id = (await this.read(this.registryId, "registrar_of", [
265
+ nodeArg(namehash(namespace)),
266
+ ]));
267
+ if (!id) {
268
+ throw new OwnerError(`namespace "${namespace}" has no attested Registrar yet — deploy one (console, or Registry.deploy_registrar) before issuing`, this.registryId, "registrar_of");
269
+ }
270
+ this.registrars.set(namespace, { value: id, at: Date.now() });
271
+ return id;
272
+ }
273
+ /** The namespace's current owner on the Registry, or null if unallocated. */
274
+ async namespaceOwner(namespace) {
275
+ assertLabel(namespace);
276
+ return (await this.read(this.registryId, "owner_of", [
277
+ nodeArg(namehash(namespace)),
278
+ ]));
279
+ }
280
+ /**
281
+ * Throw unless the configured signer is the namespace's current owner.
282
+ * Cheap preflight for scripts — write operations are owner-checked on chain
283
+ * regardless, so skipping this only trades a clearer error for one read.
284
+ */
285
+ async assertOwner(namespace) {
286
+ const [owner, pub] = await Promise.all([
287
+ this.namespaceOwner(namespace),
288
+ Promise.resolve(this.signer.publicKey()),
289
+ ]);
290
+ if (owner !== pub) {
291
+ throw new OwnerError(owner
292
+ ? `signer ${pub} is not the owner of "${namespace}" (owner is ${owner})`
293
+ : `namespace "${namespace}" is not allocated on this Registry`);
294
+ }
295
+ }
296
+ // ---- registrar: issuance -------------------------------------------------
297
+ /** Issue `label.namespace` to `holder`. Owner-signed; term set by policy. */
298
+ async issue(namespace, label, holder) {
299
+ assertLabel(label);
300
+ const registrarId = await this.registrarOf(namespace);
301
+ const r = await this.invoke(registrarId, "issue", [labelArg(label), addrArg(holder)], REGISTRAR_ERRORS);
302
+ return { hash: r.hash, ledger: r.ledger, node: toHex(r.returnValue) };
303
+ }
304
+ /**
305
+ * Issue up to {@link MAX_BATCH} names in ONE owner-signed transaction.
306
+ *
307
+ * The contract skips (rather than aborts on) labels that are already held,
308
+ * so a batch is safe to re-run after a partial failure. The SDK re-reads
309
+ * every record after inclusion and reports a per-label outcome — always
310
+ * check `outcomes`, not just the count.
311
+ */
312
+ async issueBatch(namespace, entries) {
313
+ if (entries.length === 0)
314
+ throw new OwnerError("issueBatch: empty batch");
315
+ if (entries.length > MAX_BATCH) {
316
+ throw new OwnerError(`issueBatch: ${entries.length} entries exceeds the contract cap of ${MAX_BATCH} per transaction — split the batch`);
317
+ }
318
+ for (const e of entries)
319
+ assertLabel(e.label);
320
+ const registrarId = await this.registrarOf(namespace);
321
+ const r = await this.invoke(registrarId, "issue_batch", [
322
+ xdr.ScVal.scvVec(entries.map((e) => labelArg(e.label))),
323
+ xdr.ScVal.scvVec(entries.map((e) => addrArg(e.holder))),
324
+ ], REGISTRAR_ERRORS);
325
+ const issuedCount = Number(r.returnValue ?? 0);
326
+ // Ground truth: the contract publishes one `issued` event per name it
327
+ // actually issued IN THIS transaction. Matching events to entries needs no
328
+ // clock, no re-read, and cannot be confused by concurrent writers.
329
+ const issuedEvents = decodeIssuedEvents(r.events, registrarId);
330
+ // Empty events with a nonzero contract count means the node's meta was
331
+ // unusable — fall back to re-reads. Zero-issued batches legitimately have
332
+ // zero events, and the events path handles them exactly.
333
+ if (issuedEvents.length > 0 || issuedCount === 0) {
334
+ const unconsumed = [...issuedEvents];
335
+ const outcomes = entries.map((e) => {
336
+ const at = unconsumed.findIndex((ev) => ev.label === e.label && ev.holder === e.holder);
337
+ if (at >= 0) {
338
+ unconsumed.splice(at, 1);
339
+ return { label: e.label, holder: e.holder, issued: true };
340
+ }
341
+ return { label: e.label, holder: e.holder, issued: false, reason: "skipped" };
342
+ });
343
+ // Best-effort reasons: distinguish "someone else holds it" from
344
+ // in-batch skips. Purely informational — never let it fail the call.
345
+ try {
346
+ const post = await this.readRecords(registrarId, outcomes.filter((o) => !o.issued));
347
+ let j = 0;
348
+ for (const o of outcomes) {
349
+ if (o.issued)
350
+ continue;
351
+ const rec = post[j++];
352
+ if (rec && rec.holder !== o.holder)
353
+ o.reason = "taken";
354
+ }
355
+ }
356
+ catch {
357
+ /* reasons stay "skipped" */
358
+ }
359
+ return {
360
+ hash: r.hash,
361
+ ledger: r.ledger,
362
+ issuedCount,
363
+ outcomes,
364
+ outcomeSource: "events",
365
+ countMatches: outcomes.filter((o) => o.issued).length === issuedCount,
366
+ };
367
+ }
368
+ // Fallback (a node serving no usable meta): infer from post-state reads.
369
+ // Correct unless a concurrent writer touched the same labels; reads that
370
+ // fail leave the entry marked not-issued rather than failing the batch —
371
+ // the transaction itself SUCCEEDED and its result must survive.
372
+ let post = entries.map(() => null);
373
+ try {
374
+ post = await this.readRecords(registrarId, entries);
375
+ }
376
+ catch {
377
+ /* keep nulls */
378
+ }
379
+ const seen = new Set();
380
+ const outcomes = entries.map((e, i) => {
381
+ const after = post[i];
382
+ const firstForLabel = !seen.has(e.label);
383
+ seen.add(e.label);
384
+ const issued = firstForLabel && after !== null && after.holder === e.holder;
385
+ const reason = after !== null && after.holder !== e.holder ? "taken" : "skipped";
386
+ return { label: e.label, holder: e.holder, issued, ...(issued ? {} : { reason }) };
387
+ });
388
+ return {
389
+ hash: r.hash,
390
+ ledger: r.ledger,
391
+ issuedCount,
392
+ outcomes,
393
+ outcomeSource: "reread",
394
+ countMatches: outcomes.filter((o) => o.issued).length === issuedCount,
395
+ };
396
+ }
397
+ // ---- registrar: lifecycle ------------------------------------------------
398
+ /**
399
+ * Take `label.namespace` back from its holder. Only on namespaces whose
400
+ * policy is reclaimable — the contract answers NotReclaimable both when the
401
+ * policy never allowed it and after `make_permanent` removed it forever.
402
+ */
403
+ async reclaim(namespace, label) {
404
+ assertLabel(label);
405
+ const registrarId = await this.registrarOf(namespace);
406
+ const r = await this.invoke(registrarId, "reclaim", [labelArg(label)], REGISTRAR_ERRORS);
407
+ return { hash: r.hash, ledger: r.ledger };
408
+ }
409
+ /** Extend a finite-term name. Returns the new expiry (unix seconds). */
410
+ async renew(namespace, label, extendSecs) {
411
+ assertLabel(label);
412
+ const registrarId = await this.registrarOf(namespace);
413
+ const r = await this.invoke(registrarId, "renew", [labelArg(label), nativeToScVal(extendSecs, { type: "u64" })], REGISTRAR_ERRORS);
414
+ return { hash: r.hash, ledger: r.ledger, expiresAt: BigInt(r.returnValue) };
415
+ }
416
+ /** Route future reclaims' custody to a treasury address (or back to you). */
417
+ async setTreasury(namespace, newTreasury) {
418
+ const registrarId = await this.registrarOf(namespace);
419
+ const r = await this.invoke(registrarId, "set_treasury", [addrArg(newTreasury)], REGISTRAR_ERRORS);
420
+ return { hash: r.hash, ledger: r.ledger };
421
+ }
422
+ /**
423
+ * The one-way door. Locks reclaim off and freezes the Registrar's code —
424
+ * every name in the namespace becomes permanently its holder's, and there is
425
+ * NO PATH BACK, for you or anyone. The contract additionally requires that
426
+ * the policy has always issued permanent terms (FiniteTermPolicy otherwise)
427
+ * and that the Registrar/Resolver provenance is clean.
428
+ */
429
+ async makePermanent(namespace, opts) {
430
+ if (opts?.confirmIrreversible !== true) {
431
+ throw new OwnerError("makePermanent is IRREVERSIBLE — pass { confirmIrreversible: true } to proceed");
432
+ }
433
+ const registrarId = await this.registrarOf(namespace);
434
+ const r = await this.invoke(registrarId, "make_permanent", [], REGISTRAR_ERRORS);
435
+ return { hash: r.hash, ledger: r.ledger };
436
+ }
437
+ // ---- registry: namespace transfer + resolver -----------------------------
438
+ /**
439
+ * Offer the whole namespace to `to`. Nothing moves until the recipient
440
+ * accepts; re-proposing replaces the pending offer, cancel withdraws it.
441
+ */
442
+ async proposeNamespaceTransfer(namespace, to) {
443
+ assertLabel(namespace);
444
+ const r = await this.invoke(this.registryId, "propose_transfer", [nodeArg(namehash(namespace)), addrArg(to)], REGISTRY_ERRORS);
445
+ return { hash: r.hash, ledger: r.ledger };
446
+ }
447
+ /** Accept a namespace offered to you. Signer must be the PROPOSED owner. */
448
+ async acceptNamespaceTransfer(namespace) {
449
+ assertLabel(namespace);
450
+ const r = await this.invoke(this.registryId, "accept_transfer", [nodeArg(namehash(namespace))], REGISTRY_ERRORS);
451
+ return { hash: r.hash, ledger: r.ledger };
452
+ }
453
+ /** Withdraw a pending namespace transfer. Current owner only. */
454
+ async cancelNamespaceTransfer(namespace) {
455
+ assertLabel(namespace);
456
+ const r = await this.invoke(this.registryId, "cancel_transfer", [nodeArg(namehash(namespace))], REGISTRY_ERRORS);
457
+ return { hash: r.hash, ledger: r.ledger };
458
+ }
459
+ /**
460
+ * Point the namespace at a resolver contract, or clear it with null.
461
+ * Refused (ResolverFrozen) once the namespace is permanent.
462
+ */
463
+ async setResolver(namespace, resolver) {
464
+ assertLabel(namespace);
465
+ const r = await this.invoke(this.registryId, "set_resolver", [nodeArg(namehash(namespace)), resolver === null ? xdr.ScVal.scvVoid() : addrArg(resolver)], REGISTRY_ERRORS);
466
+ return { hash: r.hash, ledger: r.ledger };
467
+ }
468
+ // ---- reads that support write flows --------------------------------------
469
+ /** The namespace's immutable issuance policy. */
470
+ async policy(namespace) {
471
+ const registrarId = await this.registrarOf(namespace);
472
+ const p = (await this.read(registrarId, "policy", []));
473
+ return {
474
+ reclaimable: p.reclaimable,
475
+ transferable: p.transferable,
476
+ tradeable: p.tradeable,
477
+ defaultTermSecs: BigInt(p.default_term_secs),
478
+ tradeFeeBps: Number(p.trade_fee_bps),
479
+ };
480
+ }
481
+ /** Has the namespace passed the one-way door? */
482
+ async isPermanent(namespace) {
483
+ const registrarId = await this.registrarOf(namespace);
484
+ return (await this.read(registrarId, "is_permanent", []));
485
+ }
486
+ /** Current state of `label.namespace`, or null if never issued. */
487
+ async nameState(namespace, label) {
488
+ assertLabel(label);
489
+ const registrarId = await this.registrarOf(namespace);
490
+ return this.readRecord(registrarId, label);
491
+ }
492
+ /** The pending namespace transfer on the Registry, or null. */
493
+ async pendingNamespaceTransfer(namespace) {
494
+ assertLabel(namespace);
495
+ return this.read(this.registryId, "pending_transfer", [nodeArg(namehash(namespace))]);
496
+ }
497
+ // ---- internals -----------------------------------------------------------
498
+ async readRecord(registrarId, label) {
499
+ const rec = (await this.read(registrarId, "record_of", [labelArg(label)]));
500
+ if (!rec)
501
+ return null;
502
+ return {
503
+ holder: String(rec.holder),
504
+ address: String(rec.address),
505
+ expiresAt: BigInt(rec.expires_at),
506
+ generation: BigInt(rec.generation),
507
+ };
508
+ }
509
+ readRecords(registrarId, entries) {
510
+ return Promise.all(entries.map((e) => this.readRecord(registrarId, e.label)));
511
+ }
512
+ /** Simulation-only contract read — no signature, no fee, no state change. */
513
+ async read(contractId, fn, args) {
514
+ const tx = new TransactionBuilder(new Account(SIM_SOURCE, "0"), {
515
+ fee: BASE_FEE,
516
+ networkPassphrase: this.passphrase,
517
+ })
518
+ .addOperation(new Contract(contractId).call(fn, ...args))
519
+ .setTimeout(30)
520
+ .build();
521
+ const sim = await this.server.simulateTransaction(tx);
522
+ if (rpc.Api.isSimulationError(sim)) {
523
+ throw typedError(contractId, fn, sim.error, {});
524
+ }
525
+ // An archived entry is NOT absence: reads cannot restore (no signer),
526
+ // so surface the state honestly instead of reporting "does not exist".
527
+ if (rpc.Api.isSimulationRestore(sim)) {
528
+ throw new OwnerError(`${fn}: the on-chain entry is archived (rent lapsed) — any write operation restores it automatically, or restore/touch it first`, contractId, fn);
529
+ }
530
+ if (!rpc.Api.isSimulationSuccess(sim) || !sim.result?.retval)
531
+ return null;
532
+ const v = scValToNative(sim.result.retval);
533
+ return v === undefined ? null : v;
534
+ }
535
+ async signEnvelope(xdrBase64) {
536
+ const signed = await this.signer.signTransaction(xdrBase64, {
537
+ networkPassphrase: this.passphrase,
538
+ });
539
+ return typeof signed === "string" ? signed : signed.signedTxXdr;
540
+ }
541
+ serialize(work) {
542
+ const run = this.queue.then(work, work);
543
+ this.queue = run.then(() => undefined, () => undefined);
544
+ return run;
545
+ }
546
+ /** The signer's live account (sequence source) — with an actionable error
547
+ * when the account has never been funded on this network. */
548
+ async sourceAccount(pub, fn) {
549
+ try {
550
+ return await this.server.getAccount(pub);
551
+ }
552
+ catch {
553
+ throw new OwnerError(`${fn}: signer account ${pub} does not exist on this network — fund it (testnet: friendbot) before writing`, null, fn);
554
+ }
555
+ }
556
+ invoke(contractId, fn, args, errNames) {
557
+ return this.serialize(async () => {
558
+ try {
559
+ return await this.attempt(contractId, fn, args, errNames);
560
+ }
561
+ catch (e) {
562
+ // A stale sequence (another process moved the account) is safe to
563
+ // retry once with a fresh sequence — nothing was included.
564
+ if (/txBadSeq|bad_seq/i.test(String(e))) {
565
+ return await this.attempt(contractId, fn, args, errNames);
566
+ }
567
+ throw e;
568
+ }
569
+ });
570
+ }
571
+ /**
572
+ * Simulation RECORDS auth requirements without verifying them: a call the
573
+ * signer is not authorized for still simulates cleanly, then fails on
574
+ * chain — after spending the fee. Refuse before signing when the recorded
575
+ * auth demands an address the envelope signature cannot satisfy.
576
+ */
577
+ assertSatisfiableAuth(prepared, pub, contractId, fn) {
578
+ const op = prepared.operations[0];
579
+ for (const entry of op?.auth ?? []) {
580
+ const cred = entry.credentials();
581
+ if (cred.switch() !== xdr.SorobanCredentialsType.sorobanCredentialsAddress())
582
+ continue;
583
+ let required;
584
+ try {
585
+ required = Address.fromScAddress(cred.address().address()).toString();
586
+ }
587
+ catch {
588
+ continue; // unreadable credential — let the chain be the judge
589
+ }
590
+ throw new OwnerError(required === pub
591
+ ? `${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`
592
+ : `${fn}: this operation must be authorized by ${required}, but the signer is ${pub} — use that account's signer`, contractId, fn);
593
+ }
594
+ }
595
+ async attempt(contractId, fn, args, errNames) {
596
+ const pub = await this.signer.publicKey();
597
+ const contract = new Contract(contractId);
598
+ const build = (source) => new TransactionBuilder(source, { fee: this.fee, networkPassphrase: this.passphrase })
599
+ .addOperation(contract.call(fn, ...args))
600
+ .setTimeout(this.timeoutSecs)
601
+ .build();
602
+ let tx = build(await this.sourceAccount(pub, fn));
603
+ let sim = await this.server.simulateTransaction(tx);
604
+ // Archived entries (e.g. a dormant Registrar's instance) need restoring
605
+ // before the call can run. Each restore is its own owner-signed
606
+ // transaction; two rounds cover an entry archiving mid-flow, and beyond
607
+ // that something is wrong enough to surface instead of looping.
608
+ for (let round = 0; rpc.Api.isSimulationRestore(sim); round++) {
609
+ if (round >= 2) {
610
+ throw new OwnerError(`${fn}: entries still need restoring after ${round} restore transactions — retry later`, contractId, fn);
611
+ }
612
+ await this.restore(sim, pub);
613
+ tx = build(await this.sourceAccount(pub, fn));
614
+ sim = await this.server.simulateTransaction(tx);
615
+ }
616
+ if (rpc.Api.isSimulationError(sim)) {
617
+ throw typedError(contractId, fn, sim.error, errNames);
618
+ }
619
+ const prepared = rpc.assembleTransaction(tx, sim).build();
620
+ this.assertSatisfiableAuth(prepared, pub, contractId, fn);
621
+ // The hash is fixed before signatures — compute it now so every failure
622
+ // past this point can carry it (the "re-check before retrying" contract).
623
+ const txHash = prepared.hash().toString("hex");
624
+ const signed = await this.signEnvelope(prepared.toXDR());
625
+ const envelope = TransactionBuilder.fromXDR(signed, this.passphrase);
626
+ let sent;
627
+ try {
628
+ sent = await this.server.sendTransaction(envelope);
629
+ }
630
+ catch (e) {
631
+ throw new OwnerError(`${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);
632
+ }
633
+ if (sent.status === "ERROR") {
634
+ throw new OwnerError(`${fn}: submit rejected: ${JSON.stringify(sent.errorResult ?? sent.status)}`, contractId, fn, null, null, txHash);
635
+ }
636
+ if (sent.status === "TRY_AGAIN_LATER") {
637
+ throw new OwnerError(`${fn}: the network did not accept the transaction (TRY_AGAIN_LATER) — it was NOT queued; safe to retry shortly`, contractId, fn, null, null, txHash);
638
+ }
639
+ // PENDING queues it; DUPLICATE means this exact transaction is already
640
+ // in flight — either way, confirmation is the same wait.
641
+ try {
642
+ const { ledger, returnValue, events } = await this.confirm(txHash, contractId, fn, errNames);
643
+ return { hash: txHash, ledger, returnValue, events };
644
+ }
645
+ catch (e) {
646
+ if (e instanceof OwnerError)
647
+ throw e;
648
+ throw new OwnerError(`${fn}: confirmation interrupted (${String(e)}) — transaction ${txHash} may still be included; check the hash before retrying`, contractId, fn, null, null, txHash);
649
+ }
650
+ }
651
+ async restore(sim, pub) {
652
+ const pre = sim.restorePreamble;
653
+ const fee = (Number(this.fee) + Number(pre.minResourceFee)).toString();
654
+ const tx = new TransactionBuilder(await this.sourceAccount(pub, "restore_footprint"), {
655
+ fee,
656
+ networkPassphrase: this.passphrase,
657
+ })
658
+ .setSorobanData(pre.transactionData.build())
659
+ .addOperation(Operation.restoreFootprint({}))
660
+ .setTimeout(this.timeoutSecs)
661
+ .build();
662
+ const signed = await this.signEnvelope(tx.toXDR());
663
+ const sent = await this.server.sendTransaction(TransactionBuilder.fromXDR(signed, this.passphrase));
664
+ if (sent.status === "ERROR") {
665
+ throw new OwnerError(`restore_footprint: submit rejected: ${JSON.stringify(sent.errorResult ?? sent.status)}`, null, "restore_footprint", null, null, sent.hash);
666
+ }
667
+ await this.confirm(sent.hash, "", "restore_footprint", {});
668
+ }
669
+ // Poll past the tx time bound (timeoutSecs) so we never declare failure
670
+ // while the transaction is still valid and pending inclusion.
671
+ async confirm(hash, contractId, fn, errNames) {
672
+ const tries = this.timeoutSecs + 5;
673
+ let got = await this.server.getTransaction(hash);
674
+ for (let i = 0; i < tries && got.status === "NOT_FOUND"; i++) {
675
+ await new Promise((r) => setTimeout(r, 1000));
676
+ got = await this.server.getTransaction(hash);
677
+ }
678
+ if (got.status === "NOT_FOUND") {
679
+ throw new OwnerError(`${fn}: transaction ${hash} not confirmed within ${tries}s — it may still be included; check the hash before retrying`, contractId || null, fn, null, null, hash);
680
+ }
681
+ if (got.status !== "SUCCESS") {
682
+ throw this.decodeFailure(got, contractId, fn, errNames, hash);
683
+ }
684
+ let returnValue = null;
685
+ try {
686
+ if (got.returnValue)
687
+ returnValue = scValToNative(got.returnValue);
688
+ }
689
+ catch {
690
+ /* void return */
691
+ }
692
+ return { ledger: got.ledger, returnValue, events: extractContractEvents(got.resultMetaXdr) };
693
+ }
694
+ /**
695
+ * A transaction that simulated cleanly but FAILED at inclusion (a race: the
696
+ * state changed between simulation and apply). The contract's typed error
697
+ * travels in the diagnostic events, not the result XDR — recover it so the
698
+ * race cases report the same typed codes the simulation path does.
699
+ */
700
+ decodeFailure(got, contractId, fn, errNames, hash) {
701
+ let code = null;
702
+ try {
703
+ const meta = got.resultMetaXdr;
704
+ const diags = got.diagnosticEventsXdr ?? (meta && meta.switch() === 3
705
+ ? (meta.v3().sorobanMeta()?.diagnosticEvents() ?? [])
706
+ : meta && meta.switch() === 4
707
+ ? meta.v4().diagnosticEvents()
708
+ : []);
709
+ outer: for (const d of diags) {
710
+ const body = d.event().body().v0();
711
+ for (const v of [...body.topics(), body.data()]) {
712
+ if (v.switch() === xdr.ScValType.scvError()) {
713
+ const err = v.error();
714
+ if (err.switch() === xdr.ScErrorType.sceContract()) {
715
+ code = err.contractCode();
716
+ break outer;
717
+ }
718
+ }
719
+ }
720
+ }
721
+ }
722
+ catch {
723
+ /* diagnostics unavailable — fall through to the result code */
724
+ }
725
+ let resultCode = `tx status ${got.status}`;
726
+ try {
727
+ resultCode = got.resultXdr?.result().switch().name ?? resultCode;
728
+ }
729
+ catch {
730
+ /* keep the plain status */
731
+ }
732
+ if (code !== null) {
733
+ const codeName = errNames[code] ?? null;
734
+ return new OwnerError(`${fn} failed at inclusion: contract error #${code}${codeName ? ` (${codeName})` : ""} (${resultCode}) — the state changed between simulation and inclusion`, contractId, fn, code, codeName, hash);
735
+ }
736
+ return new OwnerError(`${fn} failed at inclusion (${resultCode})`, contractId, fn, null, null, hash);
737
+ }
738
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@sorandomains/owner",
3
+ "version": "0.2.0",
4
+ "description": "Run your Soran namespace on Stellar — issue names, manage lifecycle, transfer, and walk the one-way permanence door.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "typecheck": "tsc --noEmit",
14
+ "prepublishOnly": "tsc"
15
+ },
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/SoranDomains/sdk.git",
20
+ "directory": "packages/owner"
21
+ },
22
+ "homepage": "https://github.com/SoranDomains/sdk/tree/main/packages/owner#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/SoranDomains/sdk/issues"
25
+ },
26
+ "keywords": [
27
+ "stellar",
28
+ "soroban",
29
+ "soran",
30
+ "name-service",
31
+ "namespace",
32
+ "issuance"
33
+ ],
34
+ "peerDependencies": {
35
+ "@stellar/stellar-sdk": ">=13 <17"
36
+ },
37
+ "devDependencies": {
38
+ "@stellar/stellar-sdk": "^16.2.0",
39
+ "@types/node": "^22.20.1",
40
+ "tsx": "^4.19.2",
41
+ "typescript": "^5.7.3"
42
+ }
43
+ }