@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 +21 -0
- package/README.md +65 -0
- package/dist/index.d.ts +205 -0
- package/dist/index.js +632 -0
- package/package.json +51 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|