@sorandomains/holder 0.1.1 → 0.3.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/README.md +62 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +132 -29
- package/dist/payment.d.ts +28 -0
- package/dist/payment.js +95 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @sorandomains/holder
|
|
2
2
|
|
|
3
|
+
Version 0.3.0 targets Stellar SDK17 (`>=17 <18`). ASCII names
|
|
4
|
+
and labels are validated before lowercase normalization; Unicode lookalikes are
|
|
5
|
+
rejected. Writes continue to target the owning Registry/Registrar/Resolver. Universal
|
|
6
|
+
Lookup is the read entry point in `@sorandomains/lookup` 0.5.0.
|
|
7
|
+
|
|
3
8
|
Your Soran name, managed with your own key. The third piece of the SDK
|
|
4
9
|
trilogy: [`@sorandomains/lookup`](https://www.npmjs.com/package/@sorandomains/lookup)
|
|
5
10
|
reads names, [`@sorandomains/owner`](https://www.npmjs.com/package/@sorandomains/owner)
|
|
@@ -22,11 +27,50 @@ await me.setProfile("alice.nova", { // the standard keys every wallet reads
|
|
|
22
27
|
});
|
|
23
28
|
```
|
|
24
29
|
|
|
30
|
+
## Publish payment instructions
|
|
31
|
+
|
|
32
|
+
Ordinary G/C names resolve without extra setup. `setPayment` discovers the native
|
|
33
|
+
Resolver from Registry, checks its Registry and Registrar anchors and payment API
|
|
34
|
+
version, then signs one call that updates address and memo in that Resolver:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const me = new SoranHolder({ signer });
|
|
38
|
+
await me.setPayment("alice.nova", {
|
|
39
|
+
address: exchangeDepositAddress,
|
|
40
|
+
memo: { type: "id", value: "18446744073709551615" },
|
|
41
|
+
});
|
|
42
|
+
// Explicitly remove a required memo:
|
|
43
|
+
await me.setPayment("alice.nova", { address: myAddress, memo: { type: "none" } });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
ID values are canonical unsigned 64-bit decimal strings; text is exact nonempty
|
|
47
|
+
UTF-8 up to 28 bytes; hashes are 64 lowercase hex characters. Required memos work
|
|
48
|
+
only with G addresses. C addresses permit `none`. The Resolver authorizes the
|
|
49
|
+
current holder and updates its records atomically. Failures never retry as separate
|
|
50
|
+
address and text writes. Old unsupported Resolvers fail closed; no payment-specific
|
|
51
|
+
contract address is configured.
|
|
52
|
+
|
|
53
|
+
`setText` and `clearText` reserve `payment` for `setPayment`. Configured missing or
|
|
54
|
+
empty instructions remain errors. Remove a memo with explicit `none`.
|
|
55
|
+
`setRecord` invokes native `set_addr`, which atomically permits ordinary names and
|
|
56
|
+
updates an existing valid `none` tuple while rejecting required memos or broken
|
|
57
|
+
state. A concurrently added memo cannot be replaced by a client-side None rewrite.
|
|
58
|
+
`setAddress` retains its Registrar-only semantics and first requires a valid native
|
|
59
|
+
`none` result. It changes only the built-in target; an explicit Resolver payment
|
|
60
|
+
record continues to take precedence. Failed preflight reads never permit a write.
|
|
61
|
+
|
|
62
|
+
Resolver selection follows the namespace owner's Registry pointer. Compatibility
|
|
63
|
+
and anchor checks do not prove custom/upgraded code is trustworthy. Upgraded
|
|
64
|
+
Resolvers remain supported. Payment readers must use `resolvePayment` and preserve
|
|
65
|
+
the returned memo; old deployed code and direct Registrar reads cannot be upgraded
|
|
66
|
+
by installing this SDK. The verified deployment used by this release is listed below.
|
|
67
|
+
|
|
25
68
|
## What's in the box
|
|
26
69
|
|
|
27
70
|
| Operation | What it does |
|
|
28
71
|
| --- | --- |
|
|
29
|
-
| `
|
|
72
|
+
| `setPayment` | Atomically publish address and memo in the namespace native Resolver |
|
|
73
|
+
| `setRecord` | Change a memo-free native Resolver address atomically; required memos need `setPayment` |
|
|
30
74
|
| `setAddress` | Re-point the built-in (Registrar) resolution target |
|
|
31
75
|
| `setText` / `setProfile` / `clearText` | Publish text records; `setProfile` writes the standard `PROFILE_KEYS` (one transaction per key); records are overwrite-only on chain — `clearText` retracts by writing the empty value standard readers treat as unset |
|
|
32
76
|
| `setReverse` / `clearReverse` | Claim your address→name reverse record — the contract refuses names that don't already resolve to you (`ForwardMismatch`) |
|
|
@@ -64,3 +108,20 @@ its own (enforced in CI), with `@stellar/stellar-sdk` as the only peer
|
|
|
64
108
|
dependency.
|
|
65
109
|
|
|
66
110
|
Docs: <https://github.com/SoranDomains/docs> · License: MIT
|
|
111
|
+
|
|
112
|
+
Primary writes verify the Primary contract's Registry anchor before signing.
|
|
113
|
+
Custom Registry or passphrase settings do not inherit a Primary deployment pin;
|
|
114
|
+
supply the matching `primaryId` explicitly.
|
|
115
|
+
|
|
116
|
+
## Verified testnet deployment
|
|
117
|
+
|
|
118
|
+
Verified on 2026-09-05 at ledger 4515471. Network passphrase: `Test SDF Network ; September 2015`.
|
|
119
|
+
|
|
120
|
+
| Contract | Address |
|
|
121
|
+
|---|---|
|
|
122
|
+
| Registry | `CBSORANXTUFKBZK74AAM2ZM5OX2V7PIXUADM3HGP6WU3IDN7M3YEEDLU` |
|
|
123
|
+
| Primary | `CBSORANQVSWYBYGKRZ7RAUGOXDAXMXDXQWJSE42DQZOL4BK75BIEBUQK` |
|
|
124
|
+
|
|
125
|
+
Mainnet has no deployment preset. Custom networks must supply their own verified
|
|
126
|
+
addresses. Universal Lookup upgrades remain immediately executable; an address
|
|
127
|
+
and ABI version do not pin the code that will execute after a governance upgrade.
|
package/dist/index.d.ts
CHANGED
|
@@ -30,13 +30,15 @@
|
|
|
30
30
|
* before signing. Operations on one instance are serialized so concurrent
|
|
31
31
|
* calls cannot race the account sequence number.
|
|
32
32
|
*/
|
|
33
|
+
import { type PaymentDestination } from "./payment.js";
|
|
34
|
+
export { PAYMENT_RECORD_KEY, encodePaymentRecord, parsePaymentRecord, validatePaymentDestination, type PaymentMemo, type PaymentDestination } from "./payment.js";
|
|
33
35
|
/** Known public deployments. Pass explicit options for anything else. */
|
|
34
36
|
export declare const DEPLOYMENTS: {
|
|
35
37
|
readonly testnet: {
|
|
36
38
|
readonly rpcUrl: "https://soroban-testnet.stellar.org";
|
|
37
39
|
readonly passphrase: string;
|
|
38
|
-
readonly registryId: "
|
|
39
|
-
readonly primaryId: "
|
|
40
|
+
readonly registryId: "CBSORANXTUFKBZK74AAM2ZM5OX2V7PIXUADM3HGP6WU3IDN7M3YEEDLU";
|
|
41
|
+
readonly primaryId: "CBSORANQVSWYBYGKRZ7RAUGOXDAXMXDXQWJSE42DQZOL4BK75BIEBUQK";
|
|
40
42
|
};
|
|
41
43
|
};
|
|
42
44
|
/** Same shape as the owner SDK's signer — wallet-kit compatible. */
|
|
@@ -65,6 +67,7 @@ export declare class HolderError extends Error {
|
|
|
65
67
|
readonly txHash: string | null;
|
|
66
68
|
constructor(message: string, contractId?: string | null, fn?: string | null, code?: number | null, codeName?: string | null, txHash?: string | null);
|
|
67
69
|
}
|
|
70
|
+
export declare function normalizeLabel(value: string): string;
|
|
68
71
|
/** Split and validate `label.namespace`, lowercasing first. Throws
|
|
69
72
|
* HolderError — @sorandomains/lookup exports the same helper throwing its
|
|
70
73
|
* own SoranError; import from the package whose errors you handle. */
|
|
@@ -110,6 +113,11 @@ export declare class SoranHolder {
|
|
|
110
113
|
private resolvers;
|
|
111
114
|
private static POINTER_TTL_MS;
|
|
112
115
|
constructor(opts: HolderOptions);
|
|
116
|
+
/** Atomically update the forward address and complete payment instruction.
|
|
117
|
+
* Use memo {type:"none"} to explicitly publish a memo-free destination.
|
|
118
|
+
* The native Resolver updates its own records atomically. This method never
|
|
119
|
+
* retries as separate set_addr/set_text calls. */
|
|
120
|
+
setPayment(name: string, destination: PaymentDestination): Promise<Submitted>;
|
|
113
121
|
/**
|
|
114
122
|
* Re-point where YOUR name pays to on its BUILT-IN path (the Registrar's
|
|
115
123
|
* record — what resolvers fall back to when no explicit record is set).
|
|
@@ -122,6 +130,9 @@ export declare class SoranHolder {
|
|
|
122
130
|
* `lookup.resolve()` prefers over the built-in target. Generation-gated:
|
|
123
131
|
* the Resolver verifies you hold the name right now (NotHolder otherwise),
|
|
124
132
|
* and your record stops resolving the moment the name changes hands.
|
|
133
|
+
* Native set_addr checks the current payment state atomically: ordinary
|
|
134
|
+
* names and explicit None can change address; required memos require setPayment.
|
|
135
|
+
* No client preflight is converted into a later setPayment(None).
|
|
125
136
|
*/
|
|
126
137
|
setRecord(name: string, address: string): Promise<Submitted>;
|
|
127
138
|
/**
|
|
@@ -197,6 +208,8 @@ export declare class SoranHolder {
|
|
|
197
208
|
registrarOf(namespace: string): Promise<string>;
|
|
198
209
|
/** The namespace's resolver pointer. Cached briefly. */
|
|
199
210
|
resolverOf(namespace: string): Promise<string>;
|
|
211
|
+
private paymentResolverOf;
|
|
212
|
+
private assertMemoFree;
|
|
200
213
|
private read;
|
|
201
214
|
private signEnvelope;
|
|
202
215
|
private serialize;
|
package/dist/index.js
CHANGED
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
* before signing. Operations on one instance are serialized so concurrent
|
|
31
31
|
* calls cannot race the account sequence number.
|
|
32
32
|
*/
|
|
33
|
-
import { Account, Address, BASE_FEE, Contract, Keypair, Networks, Operation, TransactionBuilder, hash, nativeToScVal, rpc, scValToNative,
|
|
33
|
+
import { Account, Address, BASE_FEE, Contract, Keypair, Networks, Operation, StrKey, TransactionBuilder, hash, nativeToScVal, rpc, scValToNative, } from "@stellar/stellar-sdk";
|
|
34
|
+
import { paymentFromNative, paymentMemoToScVal, validatePaymentDestination } from "./payment.js";
|
|
35
|
+
export { PAYMENT_RECORD_KEY, encodePaymentRecord, parsePaymentRecord, validatePaymentDestination } from "./payment.js";
|
|
34
36
|
// ---------------------------------------------------------------------------
|
|
35
37
|
// Deployments
|
|
36
38
|
// ---------------------------------------------------------------------------
|
|
@@ -39,8 +41,8 @@ export const DEPLOYMENTS = {
|
|
|
39
41
|
testnet: {
|
|
40
42
|
rpcUrl: "https://soroban-testnet.stellar.org",
|
|
41
43
|
passphrase: Networks.TESTNET,
|
|
42
|
-
registryId: "
|
|
43
|
-
primaryId: "
|
|
44
|
+
registryId: "CBSORANXTUFKBZK74AAM2ZM5OX2V7PIXUADM3HGP6WU3IDN7M3YEEDLU",
|
|
45
|
+
primaryId: "CBSORANQVSWYBYGKRZ7RAUGOXDAXMXDXQWJSE42DQZOL4BK75BIEBUQK",
|
|
44
46
|
},
|
|
45
47
|
};
|
|
46
48
|
/** A TxSigner over a raw secret key — for scripts and backends. */
|
|
@@ -93,6 +95,14 @@ const RESOLVER_ERRORS = {
|
|
|
93
95
|
10: "InvalidRegistry",
|
|
94
96
|
11: "UpgradeTaintFailed",
|
|
95
97
|
12: "MalformedName",
|
|
98
|
+
13: "PaymentNotConfigured",
|
|
99
|
+
14: "MalformedPayment",
|
|
100
|
+
15: "DestinationMismatch",
|
|
101
|
+
16: "UnsupportedMemoDestination",
|
|
102
|
+
17: "InvalidMemo",
|
|
103
|
+
18: "UsePaymentMethod",
|
|
104
|
+
19: "PaymentContextMismatch",
|
|
105
|
+
20: "PaymentUnavailable",
|
|
96
106
|
};
|
|
97
107
|
const PRIMARY_ERRORS = {
|
|
98
108
|
1: "MalformedName",
|
|
@@ -140,6 +150,13 @@ function typedError(contractId, fn, raw, names, txHash = null) {
|
|
|
140
150
|
const LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
141
151
|
// Soroban Symbol constraint — text-record keys live in this alphabet.
|
|
142
152
|
const SYMBOL_RE = /^[A-Za-z0-9_]{1,32}$/;
|
|
153
|
+
export function normalizeLabel(value) {
|
|
154
|
+
if (typeof value !== "string" || /[^\x00-\x7f]/.test(value))
|
|
155
|
+
throw new HolderError("label must contain ASCII characters only");
|
|
156
|
+
const normalized = value.toLowerCase();
|
|
157
|
+
assertLabel(normalized);
|
|
158
|
+
return normalized;
|
|
159
|
+
}
|
|
143
160
|
function assertLabel(label) {
|
|
144
161
|
if (label.length < 1 || label.length > 63 || !LABEL_RE.test(label)) {
|
|
145
162
|
throw new HolderError(`invalid label "${label}" — 1-63 chars of a-z, 0-9, and non-edge hyphens`);
|
|
@@ -149,6 +166,8 @@ function assertLabel(label) {
|
|
|
149
166
|
* HolderError — @sorandomains/lookup exports the same helper throwing its
|
|
150
167
|
* own SoranError; import from the package whose errors you handle. */
|
|
151
168
|
export function parseName(name) {
|
|
169
|
+
if (typeof name !== "string" || /[^\x00-\x7f]/.test(name))
|
|
170
|
+
throw new HolderError("name must contain ASCII characters only");
|
|
152
171
|
const parts = name.toLowerCase().split(".");
|
|
153
172
|
if (parts.length !== 2)
|
|
154
173
|
throw new HolderError(`expected "label.namespace", got "${name}"`);
|
|
@@ -167,6 +186,11 @@ function concatBytes(...parts) {
|
|
|
167
186
|
}
|
|
168
187
|
return out;
|
|
169
188
|
}
|
|
189
|
+
/** Browser-safe hex (SDK17: hash()/XDR bytes are Uint8Array, whose toString()
|
|
190
|
+
* ignores a radix — and this package forbids a bare `Buffer` in the bundle). */
|
|
191
|
+
function toHex(b) {
|
|
192
|
+
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
193
|
+
}
|
|
170
194
|
function namehash(namespace) {
|
|
171
195
|
const labelHash = new Uint8Array(hash(utf8(namespace)));
|
|
172
196
|
return new Uint8Array(hash(concatBytes(new Uint8Array(32), labelHash)));
|
|
@@ -216,7 +240,7 @@ export class SoranHolder {
|
|
|
216
240
|
this.registryId = opts.registryId ?? d.registryId;
|
|
217
241
|
// The preset PrimaryName is anchored to the preset Registry — never let
|
|
218
242
|
// it leak onto a custom registryId, where it could only mis-verify.
|
|
219
|
-
const presetPrimary = opts.registryId && opts.registryId !== d.registryId ? null : d.primaryId;
|
|
243
|
+
const presetPrimary = (opts.registryId && opts.registryId !== d.registryId) || (opts.passphrase && opts.passphrase !== d.passphrase) ? null : d.primaryId;
|
|
220
244
|
this.primaryId = opts.primaryId === null ? null : (opts.primaryId ?? presetPrimary ?? null);
|
|
221
245
|
this.signer = opts.signer;
|
|
222
246
|
const t = opts.timeoutSecs ?? 60;
|
|
@@ -227,6 +251,27 @@ export class SoranHolder {
|
|
|
227
251
|
this.fee = opts.fee ?? BASE_FEE;
|
|
228
252
|
}
|
|
229
253
|
// ---- resolution targets --------------------------------------------------
|
|
254
|
+
/** Atomically update the forward address and complete payment instruction.
|
|
255
|
+
* Use memo {type:"none"} to explicitly publish a memo-free destination.
|
|
256
|
+
* The native Resolver updates its own records atomically. This method never
|
|
257
|
+
* retries as separate set_addr/set_text calls. */
|
|
258
|
+
async setPayment(name, destination) {
|
|
259
|
+
const { label, namespace } = parseName(name);
|
|
260
|
+
let payment;
|
|
261
|
+
try {
|
|
262
|
+
payment = validatePaymentDestination(destination);
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
throw new HolderError(String(e));
|
|
266
|
+
}
|
|
267
|
+
const { resolver } = await this.paymentResolverOf(namespace);
|
|
268
|
+
const holder = await this.signer.publicKey();
|
|
269
|
+
const r = await this.invoke(resolver, "set_payment", [
|
|
270
|
+
nativeToScVal(`${label}.${namespace}`, { type: "string" }), addrArg(holder),
|
|
271
|
+
addrArg(payment.address), paymentMemoToScVal(payment.memo),
|
|
272
|
+
], RESOLVER_ERRORS);
|
|
273
|
+
return { hash: r.hash, ledger: r.ledger };
|
|
274
|
+
}
|
|
230
275
|
/**
|
|
231
276
|
* Re-point where YOUR name pays to on its BUILT-IN path (the Registrar's
|
|
232
277
|
* record — what resolvers fall back to when no explicit record is set).
|
|
@@ -235,7 +280,7 @@ export class SoranHolder {
|
|
|
235
280
|
*/
|
|
236
281
|
async setAddress(name, address) {
|
|
237
282
|
const { label, namespace } = parseName(name);
|
|
238
|
-
const registrarId = await this.
|
|
283
|
+
const registrarId = await this.assertMemoFree(name);
|
|
239
284
|
const r = await this.invoke(registrarId, "set_address", [labelArg(label), addrArg(address)], REGISTRAR_ERRORS);
|
|
240
285
|
return { hash: r.hash, ledger: r.ledger };
|
|
241
286
|
}
|
|
@@ -244,10 +289,13 @@ export class SoranHolder {
|
|
|
244
289
|
* `lookup.resolve()` prefers over the built-in target. Generation-gated:
|
|
245
290
|
* the Resolver verifies you hold the name right now (NotHolder otherwise),
|
|
246
291
|
* and your record stops resolving the moment the name changes hands.
|
|
292
|
+
* Native set_addr checks the current payment state atomically: ordinary
|
|
293
|
+
* names and explicit None can change address; required memos require setPayment.
|
|
294
|
+
* No client preflight is converted into a later setPayment(None).
|
|
247
295
|
*/
|
|
248
296
|
async setRecord(name, address) {
|
|
249
297
|
const { label, namespace } = parseName(name);
|
|
250
|
-
const resolverId = await this.
|
|
298
|
+
const { resolver: resolverId } = await this.paymentResolverOf(namespace);
|
|
251
299
|
const pub = await this.signer.publicKey();
|
|
252
300
|
const r = await this.invoke(resolverId, "set_addr", [bytesArg(nameNode(label, namespace)), addrArg(pub), addrArg(address)], RESOLVER_ERRORS);
|
|
253
301
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -265,6 +313,8 @@ export class SoranHolder {
|
|
|
265
313
|
*/
|
|
266
314
|
async setText(name, key, value) {
|
|
267
315
|
const { label, namespace } = parseName(name);
|
|
316
|
+
if (key === "payment")
|
|
317
|
+
throw new HolderError("payment records must be written atomically with setPayment");
|
|
268
318
|
if (!SYMBOL_RE.test(key)) {
|
|
269
319
|
throw new HolderError(`invalid text-record key "${key}" — 1-32 chars of A-Za-z0-9_`);
|
|
270
320
|
}
|
|
@@ -346,8 +396,8 @@ export class SoranHolder {
|
|
|
346
396
|
}
|
|
347
397
|
/** Remove your reverse record on a namespace's resolver. */
|
|
348
398
|
async clearReverse(namespace) {
|
|
349
|
-
assertLabel(namespace
|
|
350
|
-
const resolverId = await this.resolverOf(namespace
|
|
399
|
+
assertLabel(normalizeLabel(namespace));
|
|
400
|
+
const resolverId = await this.resolverOf(normalizeLabel(namespace));
|
|
351
401
|
const pub = await this.signer.publicKey();
|
|
352
402
|
const r = await this.invoke(resolverId, "clear_reverse", [addrArg(pub)], RESOLVER_ERRORS);
|
|
353
403
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -363,6 +413,8 @@ export class SoranHolder {
|
|
|
363
413
|
throw new HolderError("setPrimary needs the PrimaryName contract — configure primaryId (the testnet preset supplies one)");
|
|
364
414
|
}
|
|
365
415
|
parseName(name); // validate shape before spending anything
|
|
416
|
+
if (await this.read(this.primaryId, "registry", []) !== this.registryId)
|
|
417
|
+
throw new HolderError("Primary is anchored to a different Registry");
|
|
366
418
|
const pub = await this.signer.publicKey();
|
|
367
419
|
const r = await this.invoke(this.primaryId, "set_primary", [addrArg(pub), nativeToScVal(name.toLowerCase(), { type: "string" })], PRIMARY_ERRORS);
|
|
368
420
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -372,6 +424,8 @@ export class SoranHolder {
|
|
|
372
424
|
if (!this.primaryId) {
|
|
373
425
|
throw new HolderError("clearPrimary needs the PrimaryName contract — configure primaryId (the testnet preset supplies one)");
|
|
374
426
|
}
|
|
427
|
+
if (await this.read(this.primaryId, "registry", []) !== this.registryId)
|
|
428
|
+
throw new HolderError("Primary is anchored to a different Registry");
|
|
375
429
|
const pub = await this.signer.publicKey();
|
|
376
430
|
const r = await this.invoke(this.primaryId, "clear_primary", [addrArg(pub)], PRIMARY_ERRORS);
|
|
377
431
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -415,7 +469,7 @@ export class SoranHolder {
|
|
|
415
469
|
// ---- discovery -----------------------------------------------------------
|
|
416
470
|
/** The namespace's Registry-attested Registrar. Cached briefly. */
|
|
417
471
|
async registrarOf(namespace) {
|
|
418
|
-
namespace = namespace
|
|
472
|
+
namespace = normalizeLabel(namespace);
|
|
419
473
|
assertLabel(namespace);
|
|
420
474
|
const hit = this.registrars.get(namespace);
|
|
421
475
|
if (hit && Date.now() - hit.at < SoranHolder.POINTER_TTL_MS)
|
|
@@ -431,7 +485,7 @@ export class SoranHolder {
|
|
|
431
485
|
}
|
|
432
486
|
/** The namespace's resolver pointer. Cached briefly. */
|
|
433
487
|
async resolverOf(namespace) {
|
|
434
|
-
namespace = namespace
|
|
488
|
+
namespace = normalizeLabel(namespace);
|
|
435
489
|
assertLabel(namespace);
|
|
436
490
|
const hit = this.resolvers.get(namespace);
|
|
437
491
|
if (hit && Date.now() - hit.at < SoranHolder.POINTER_TTL_MS)
|
|
@@ -440,12 +494,58 @@ export class SoranHolder {
|
|
|
440
494
|
bytesArg(namehash(namespace)),
|
|
441
495
|
]));
|
|
442
496
|
if (!id) {
|
|
443
|
-
throw new HolderError(`namespace "${namespace}" has no public resolver — records/reverse
|
|
497
|
+
throw new HolderError(`namespace "${namespace}" has no public resolver — records/reverse and SDK payment-address edits are unavailable`, this.registryId, "resolver_of");
|
|
444
498
|
}
|
|
445
499
|
this.resolvers.set(namespace, { value: id, at: Date.now() });
|
|
446
500
|
return id;
|
|
447
501
|
}
|
|
448
502
|
// ---- internals (same pipeline discipline as @sorandomains/owner) --------
|
|
503
|
+
async paymentResolverOf(namespace) {
|
|
504
|
+
const nsNode = namehash(namespace);
|
|
505
|
+
const args = [bytesArg(nsNode)];
|
|
506
|
+
const [resolver, registrar] = await Promise.all([
|
|
507
|
+
this.read(this.registryId, "resolver_of", args),
|
|
508
|
+
this.read(this.registryId, "registrar_of", args),
|
|
509
|
+
]);
|
|
510
|
+
if (typeof resolver !== "string" || !StrKey.isValidContract(resolver))
|
|
511
|
+
throw new HolderError("namespace has no valid native payment Resolver");
|
|
512
|
+
if (typeof registrar !== "string" || !StrKey.isValidContract(registrar))
|
|
513
|
+
throw new HolderError("namespace has no valid Registrar");
|
|
514
|
+
const [anchor, authority, version, anchors] = await Promise.all([
|
|
515
|
+
this.read(resolver, "registry", []),
|
|
516
|
+
this.read(resolver, "authority", []),
|
|
517
|
+
this.read(resolver, "payment_version", []),
|
|
518
|
+
this.read(registrar, "anchors", []),
|
|
519
|
+
]);
|
|
520
|
+
if (anchor !== this.registryId)
|
|
521
|
+
throw new HolderError("payment Resolver is anchored to a different Registry", resolver, "registry");
|
|
522
|
+
if (authority !== registrar)
|
|
523
|
+
throw new HolderError("payment Resolver authority does not match the namespace Registrar", resolver, "authority");
|
|
524
|
+
if (!Array.isArray(anchors) || anchors.length !== 2 || anchors[0] !== this.registryId ||
|
|
525
|
+
!(anchors[1] instanceof Uint8Array) || anchors[1].length !== nsNode.length ||
|
|
526
|
+
!nsNode.every((byte, index) => anchors[1][index] === byte))
|
|
527
|
+
throw new HolderError("Registrar anchors do not match this Registry and namespace", registrar, "anchors");
|
|
528
|
+
if (version !== 1)
|
|
529
|
+
throw new HolderError("unsupported native payment Resolver version", resolver, "payment_version");
|
|
530
|
+
return { resolver, registrar };
|
|
531
|
+
}
|
|
532
|
+
async assertMemoFree(name) {
|
|
533
|
+
const { label, namespace } = parseName(name);
|
|
534
|
+
const { resolver, registrar } = await this.paymentResolverOf(namespace);
|
|
535
|
+
const raw = await this.read(resolver, "resolve_payment", [nativeToScVal(`${label}.${namespace}`, { type: "string" })]);
|
|
536
|
+
let payment;
|
|
537
|
+
try {
|
|
538
|
+
payment = paymentFromNative(raw);
|
|
539
|
+
}
|
|
540
|
+
catch (e) {
|
|
541
|
+
throw new HolderError(`invalid payment result: ${String(e)}`, resolver, "resolve_payment");
|
|
542
|
+
}
|
|
543
|
+
if (payment.memo.type !== "none")
|
|
544
|
+
throw new HolderError("this name requires a memo; use setPayment to update address and memo together");
|
|
545
|
+
// Registrar-only write below cannot alter a Resolver's explicit addr/memo.
|
|
546
|
+
// A concurrent set_payment installs its own addr, preserving its memo routing.
|
|
547
|
+
return registrar;
|
|
548
|
+
}
|
|
449
549
|
async read(contractId, fn, args) {
|
|
450
550
|
const tx = new TransactionBuilder(new Account(SIM_SOURCE, "0"), {
|
|
451
551
|
fee: BASE_FEE,
|
|
@@ -461,7 +561,7 @@ export class SoranHolder {
|
|
|
461
561
|
throw new HolderError(`${fn}: the on-chain entry is archived (rent lapsed) — any write restores it automatically`, contractId, fn);
|
|
462
562
|
}
|
|
463
563
|
if (!rpc.Api.isSimulationSuccess(sim) || !sim.result?.retval)
|
|
464
|
-
return
|
|
564
|
+
throw new HolderError(`${fn}: missing simulation return value`, contractId, fn);
|
|
465
565
|
const v = scValToNative(sim.result.retval);
|
|
466
566
|
return v === undefined ? null : v;
|
|
467
567
|
}
|
|
@@ -487,12 +587,12 @@ export class SoranHolder {
|
|
|
487
587
|
assertSatisfiableAuth(prepared, pub, contractId, fn) {
|
|
488
588
|
const op = prepared.operations[0];
|
|
489
589
|
for (const entry of op?.auth ?? []) {
|
|
490
|
-
const cred = entry.credentials
|
|
491
|
-
if (cred.
|
|
590
|
+
const cred = entry.credentials;
|
|
591
|
+
if (cred.type !== "sorobanCredentialsAddress")
|
|
492
592
|
continue;
|
|
493
593
|
let required;
|
|
494
594
|
try {
|
|
495
|
-
required = Address.fromScAddress(cred.address
|
|
595
|
+
required = Address.fromScAddress(cred.address.address).toString();
|
|
496
596
|
}
|
|
497
597
|
catch {
|
|
498
598
|
continue;
|
|
@@ -523,21 +623,24 @@ export class SoranHolder {
|
|
|
523
623
|
.setTimeout(this.timeoutSecs)
|
|
524
624
|
.build();
|
|
525
625
|
let tx = build(await this.sourceAccount(pub, fn));
|
|
526
|
-
|
|
626
|
+
// (SDK17/P28) useUpgradedAuth=false keeps legacy V1 SorobanCredentials rather
|
|
627
|
+
// than CAP-71 address-bound V2 — valid against P27 (pre-vote) and P28 (post),
|
|
628
|
+
// and keeps assertSatisfiableAuth's `sorobanCredentialsAddress` check exact.
|
|
629
|
+
let sim = await this.server.simulateTransaction(tx, undefined, undefined, false);
|
|
527
630
|
for (let round = 0; rpc.Api.isSimulationRestore(sim); round++) {
|
|
528
631
|
if (round >= 2) {
|
|
529
632
|
throw new HolderError(`${fn}: entries still need restoring after ${round} restore transactions — retry later`, contractId, fn);
|
|
530
633
|
}
|
|
531
634
|
await this.restore(sim, pub);
|
|
532
635
|
tx = build(await this.sourceAccount(pub, fn));
|
|
533
|
-
sim = await this.server.simulateTransaction(tx);
|
|
636
|
+
sim = await this.server.simulateTransaction(tx, undefined, undefined, false);
|
|
534
637
|
}
|
|
535
638
|
if (rpc.Api.isSimulationError(sim)) {
|
|
536
639
|
throw typedError(contractId, fn, sim.error, errNames);
|
|
537
640
|
}
|
|
538
641
|
const prepared = rpc.assembleTransaction(tx, sim).build();
|
|
539
642
|
this.assertSatisfiableAuth(prepared, pub, contractId, fn);
|
|
540
|
-
const txHash = prepared.hash()
|
|
643
|
+
const txHash = toHex(prepared.hash()); // (SDK17) hash() is Uint8Array
|
|
541
644
|
const signed = await this.signEnvelope(prepared.toXDR());
|
|
542
645
|
const envelope = TransactionBuilder.fromXDR(signed, this.passphrase);
|
|
543
646
|
let sent;
|
|
@@ -609,18 +712,18 @@ export class SoranHolder {
|
|
|
609
712
|
try {
|
|
610
713
|
const meta = got.resultMetaXdr;
|
|
611
714
|
const diags = got.diagnosticEventsXdr ??
|
|
612
|
-
(meta && meta.
|
|
613
|
-
? (meta.
|
|
614
|
-
: meta && meta.
|
|
615
|
-
? meta.
|
|
715
|
+
(meta && meta.type === "v3"
|
|
716
|
+
? (meta.value.sorobanMeta?.diagnosticEvents ?? [])
|
|
717
|
+
: meta && meta.type === "v4"
|
|
718
|
+
? meta.value.diagnosticEvents
|
|
616
719
|
: []);
|
|
617
720
|
outer: for (const d of diags) {
|
|
618
|
-
const body = d.event
|
|
619
|
-
for (const v of [...body.topics
|
|
620
|
-
if (v.
|
|
621
|
-
const err = v.error
|
|
622
|
-
if (err.
|
|
623
|
-
code = err.contractCode
|
|
721
|
+
const body = d.event.body.value;
|
|
722
|
+
for (const v of [...body.topics, body.data]) {
|
|
723
|
+
if (v.type === "scvError") {
|
|
724
|
+
const err = v.error;
|
|
725
|
+
if (err.type === "sceContract") {
|
|
726
|
+
code = err.contractCode;
|
|
624
727
|
break outer;
|
|
625
728
|
}
|
|
626
729
|
}
|
|
@@ -632,7 +735,7 @@ export class SoranHolder {
|
|
|
632
735
|
}
|
|
633
736
|
let resultCode = `tx status ${got.status}`;
|
|
634
737
|
try {
|
|
635
|
-
resultCode = got.resultXdr?.result
|
|
738
|
+
resultCode = got.resultXdr?.result.type ?? resultCode;
|
|
636
739
|
}
|
|
637
740
|
catch {
|
|
638
741
|
/* keep plain status */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Payment wire format v1. Kept identical in the independently published lookup
|
|
2
|
+
* and holder packages; the conformance tests exercise both copies. */
|
|
3
|
+
import { xdr } from "@stellar/stellar-sdk";
|
|
4
|
+
export declare const PAYMENT_RECORD_KEY = "payment";
|
|
5
|
+
export type PaymentMemo = {
|
|
6
|
+
type: "none";
|
|
7
|
+
} | {
|
|
8
|
+
type: "id";
|
|
9
|
+
value: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "text";
|
|
12
|
+
value: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: "hash";
|
|
15
|
+
value: string;
|
|
16
|
+
};
|
|
17
|
+
export type PaymentDestination = {
|
|
18
|
+
address: string;
|
|
19
|
+
memo: PaymentMemo;
|
|
20
|
+
};
|
|
21
|
+
/** Reject invalid data without normalizing meaningful memo bytes. */
|
|
22
|
+
export declare function validatePaymentDestination(value: unknown): PaymentDestination;
|
|
23
|
+
export declare function encodePaymentRecord(value: PaymentDestination): string;
|
|
24
|
+
/** Empty/missing records are not explicit payment instructions. */
|
|
25
|
+
export declare function parsePaymentRecord(raw: string): PaymentDestination;
|
|
26
|
+
export declare function paymentMemoToScVal(memo: PaymentMemo): xdr.ScVal;
|
|
27
|
+
/** Decode Soroban contract enum values, checking arity and native types. */
|
|
28
|
+
export declare function paymentFromNative(raw: unknown): PaymentDestination;
|
package/dist/payment.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Payment wire format v1. Kept identical in the independently published lookup
|
|
2
|
+
* and holder packages; the conformance tests exercise both copies. */
|
|
3
|
+
import { StrKey, nativeToScVal, xdr } from "@stellar/stellar-sdk";
|
|
4
|
+
export const PAYMENT_RECORD_KEY = "payment";
|
|
5
|
+
const MAX_U64 = 18446744073709551615n;
|
|
6
|
+
const utf8 = (value) => new TextEncoder().encode(value);
|
|
7
|
+
/** Reject invalid data without normalizing meaningful memo bytes. */
|
|
8
|
+
export function validatePaymentDestination(value) {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
10
|
+
throw new Error("invalid payment destination");
|
|
11
|
+
const p = value;
|
|
12
|
+
if (Object.keys(p).sort().join(",") !== "address,memo")
|
|
13
|
+
throw new Error("unexpected payment fields");
|
|
14
|
+
if (typeof p.address !== "string" || (!StrKey.isValidEd25519PublicKey(p.address) && !StrKey.isValidContract(p.address)))
|
|
15
|
+
throw new Error("payment address must be a valid G or C address");
|
|
16
|
+
if (!p.memo || typeof p.memo !== "object" || Array.isArray(p.memo))
|
|
17
|
+
throw new Error("invalid memo");
|
|
18
|
+
const m = p.memo;
|
|
19
|
+
if (m.type === "none") {
|
|
20
|
+
if (Object.keys(m).join(",") !== "type")
|
|
21
|
+
throw new Error("none memo cannot have a value");
|
|
22
|
+
return { address: p.address, memo: { type: "none" } };
|
|
23
|
+
}
|
|
24
|
+
if (!StrKey.isValidEd25519PublicKey(p.address))
|
|
25
|
+
throw new Error("memos require a classic G address");
|
|
26
|
+
if (Object.keys(m).sort().join(",") !== "type,value" || typeof m.value !== "string")
|
|
27
|
+
throw new Error("memo value must be a string");
|
|
28
|
+
switch (m.type) {
|
|
29
|
+
case "id":
|
|
30
|
+
if (!/^(0|[1-9][0-9]{0,19})$/.test(m.value) || BigInt(m.value) > MAX_U64)
|
|
31
|
+
throw new Error("memo ID must be canonical decimal u64");
|
|
32
|
+
return { address: p.address, memo: { type: "id", value: m.value } };
|
|
33
|
+
case "text": {
|
|
34
|
+
const bytes = utf8(m.value);
|
|
35
|
+
if (!bytes.length || bytes.length > 28 || new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes) !== m.value)
|
|
36
|
+
throw new Error("memo text must be valid UTF-8, 1–28 bytes");
|
|
37
|
+
return { address: p.address, memo: { type: "text", value: m.value } };
|
|
38
|
+
}
|
|
39
|
+
case "hash":
|
|
40
|
+
if (!/^[0-9a-f]{64}$/.test(m.value))
|
|
41
|
+
throw new Error("memo hash must be 32 bytes as lowercase hex");
|
|
42
|
+
return { address: p.address, memo: { type: "hash", value: m.value } };
|
|
43
|
+
default: throw new Error("unsupported memo type");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function encodePaymentRecord(value) {
|
|
47
|
+
const p = validatePaymentDestination(value);
|
|
48
|
+
return `1|${p.address}|${p.memo.type}|${p.memo.type === "none" ? "" : p.memo.value}`;
|
|
49
|
+
}
|
|
50
|
+
/** Empty/missing records are not explicit payment instructions. */
|
|
51
|
+
export function parsePaymentRecord(raw) {
|
|
52
|
+
if (typeof raw !== "string" || utf8(raw).length > 128)
|
|
53
|
+
throw new Error("invalid payment record length");
|
|
54
|
+
const first = raw.indexOf("|");
|
|
55
|
+
const second = raw.indexOf("|", first + 1);
|
|
56
|
+
const third = raw.indexOf("|", second + 1);
|
|
57
|
+
if (first < 0 || second < 0 || third < 0 || raw.slice(0, first) !== "1")
|
|
58
|
+
throw new Error("unsupported payment record");
|
|
59
|
+
const address = raw.slice(first + 1, second);
|
|
60
|
+
const type = raw.slice(second + 1, third);
|
|
61
|
+
const value = raw.slice(third + 1);
|
|
62
|
+
if (type === "none" && value !== "")
|
|
63
|
+
throw new Error("none memo cannot have a value");
|
|
64
|
+
return validatePaymentDestination({ address, memo: type === "none" ? { type } : { type, value } });
|
|
65
|
+
}
|
|
66
|
+
export function paymentMemoToScVal(memo) {
|
|
67
|
+
const symbol = (s) => nativeToScVal(s, { type: "symbol" });
|
|
68
|
+
switch (memo.type) {
|
|
69
|
+
case "none": return xdr.ScVal.scvVec([symbol("None")]);
|
|
70
|
+
case "id": return xdr.ScVal.scvVec([symbol("Id"), nativeToScVal(BigInt(memo.value), { type: "u64" })]);
|
|
71
|
+
case "text": return xdr.ScVal.scvVec([symbol("Text"), nativeToScVal(memo.value, { type: "string" })]);
|
|
72
|
+
case "hash": return xdr.ScVal.scvVec([symbol("Hash"), nativeToScVal(Uint8Array.from(memo.value.match(/../g), (b) => parseInt(b, 16)), { type: "bytes" })]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Decode Soroban contract enum values, checking arity and native types. */
|
|
76
|
+
export function paymentFromNative(raw) {
|
|
77
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
78
|
+
throw new Error("invalid payment result");
|
|
79
|
+
const p = raw;
|
|
80
|
+
if (Object.keys(p).sort().join(",") !== "address,memo" || !Array.isArray(p.memo))
|
|
81
|
+
throw new Error("invalid payment result fields");
|
|
82
|
+
const m = p.memo;
|
|
83
|
+
let memo;
|
|
84
|
+
if (m.length === 1 && m[0] === "None")
|
|
85
|
+
memo = { type: "none" };
|
|
86
|
+
else if (m.length === 2 && m[0] === "Id" && typeof m[1] === "bigint")
|
|
87
|
+
memo = { type: "id", value: m[1].toString() };
|
|
88
|
+
else if (m.length === 2 && m[0] === "Text" && typeof m[1] === "string")
|
|
89
|
+
memo = { type: "text", value: m[1] };
|
|
90
|
+
else if (m.length === 2 && m[0] === "Hash" && m[1] instanceof Uint8Array && m[1].length === 32)
|
|
91
|
+
memo = { type: "hash", value: Array.from(m[1], (b) => b.toString(16).padStart(2, "0")).join("") };
|
|
92
|
+
else
|
|
93
|
+
throw new Error("invalid payment memo result");
|
|
94
|
+
return validatePaymentDestination({ address: p.address, memo });
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorandomains/holder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Manage your own Soran name on Stellar \u2014 records, profile, reverse, primary, and transfers, signed by your key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"build": "tsc",
|
|
19
19
|
"typecheck": "tsc --noEmit",
|
|
20
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')\""
|
|
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
|
+
"test": "node --import tsx test/decode-failure.test.mts && node --import tsx --test test/payment.test.mts test/integration.test.mts"
|
|
22
23
|
},
|
|
23
24
|
"license": "MIT",
|
|
24
25
|
"repository": {
|
|
@@ -39,10 +40,10 @@
|
|
|
39
40
|
"reverse-lookup"
|
|
40
41
|
],
|
|
41
42
|
"peerDependencies": {
|
|
42
|
-
"@stellar/stellar-sdk": ">=
|
|
43
|
+
"@stellar/stellar-sdk": ">=17 <18"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@stellar/stellar-sdk": "^
|
|
46
|
+
"@stellar/stellar-sdk": "^17.0.1",
|
|
46
47
|
"@types/node": "^22.20.1",
|
|
47
48
|
"esbuild": "^0.25.0",
|
|
48
49
|
"tsx": "^4.19.2",
|