@sorandomains/holder 0.2.0 → 0.3.1
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 +107 -12
- package/dist/payment.d.ts +28 -0
- package/dist/payment.js +95 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @sorandomains/holder
|
|
2
2
|
|
|
3
|
+
Version 0.3.1 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.1.
|
|
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 4520986. Network passphrase: `Test SDF Network ; September 2015`.
|
|
119
|
+
|
|
120
|
+
| Contract | Address |
|
|
121
|
+
|---|---|
|
|
122
|
+
| Registry | `CDSORANCV3IFF3MKHJ7KI4MKEJOJZFMTDVAZCD5XFOR4WTGNXJJNOKQE` |
|
|
123
|
+
| Primary | `CCSORANOADXKLSW5CUANBW5WZFVCNZ5KZ4KNMIUWOZOES3LXYRUYZ56X` |
|
|
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: "CDSORANCV3IFF3MKHJ7KI4MKEJOJZFMTDVAZCD5XFOR4WTGNXJJNOKQE";
|
|
41
|
+
readonly primaryId: "CCSORANOADXKLSW5CUANBW5WZFVCNZ5KZ4KNMIUWOZOES3LXYRUYZ56X";
|
|
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, } from "@stellar/stellar-sdk";
|
|
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: "CDSORANCV3IFF3MKHJ7KI4MKEJOJZFMTDVAZCD5XFOR4WTGNXJJNOKQE",
|
|
45
|
+
primaryId: "CCSORANOADXKLSW5CUANBW5WZFVCNZ5KZ4KNMIUWOZOES3LXYRUYZ56X",
|
|
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}"`);
|
|
@@ -221,7 +240,7 @@ export class SoranHolder {
|
|
|
221
240
|
this.registryId = opts.registryId ?? d.registryId;
|
|
222
241
|
// The preset PrimaryName is anchored to the preset Registry — never let
|
|
223
242
|
// it leak onto a custom registryId, where it could only mis-verify.
|
|
224
|
-
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;
|
|
225
244
|
this.primaryId = opts.primaryId === null ? null : (opts.primaryId ?? presetPrimary ?? null);
|
|
226
245
|
this.signer = opts.signer;
|
|
227
246
|
const t = opts.timeoutSecs ?? 60;
|
|
@@ -232,6 +251,27 @@ export class SoranHolder {
|
|
|
232
251
|
this.fee = opts.fee ?? BASE_FEE;
|
|
233
252
|
}
|
|
234
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
|
+
}
|
|
235
275
|
/**
|
|
236
276
|
* Re-point where YOUR name pays to on its BUILT-IN path (the Registrar's
|
|
237
277
|
* record — what resolvers fall back to when no explicit record is set).
|
|
@@ -240,7 +280,7 @@ export class SoranHolder {
|
|
|
240
280
|
*/
|
|
241
281
|
async setAddress(name, address) {
|
|
242
282
|
const { label, namespace } = parseName(name);
|
|
243
|
-
const registrarId = await this.
|
|
283
|
+
const registrarId = await this.assertMemoFree(name);
|
|
244
284
|
const r = await this.invoke(registrarId, "set_address", [labelArg(label), addrArg(address)], REGISTRAR_ERRORS);
|
|
245
285
|
return { hash: r.hash, ledger: r.ledger };
|
|
246
286
|
}
|
|
@@ -249,10 +289,13 @@ export class SoranHolder {
|
|
|
249
289
|
* `lookup.resolve()` prefers over the built-in target. Generation-gated:
|
|
250
290
|
* the Resolver verifies you hold the name right now (NotHolder otherwise),
|
|
251
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).
|
|
252
295
|
*/
|
|
253
296
|
async setRecord(name, address) {
|
|
254
297
|
const { label, namespace } = parseName(name);
|
|
255
|
-
const resolverId = await this.
|
|
298
|
+
const { resolver: resolverId } = await this.paymentResolverOf(namespace);
|
|
256
299
|
const pub = await this.signer.publicKey();
|
|
257
300
|
const r = await this.invoke(resolverId, "set_addr", [bytesArg(nameNode(label, namespace)), addrArg(pub), addrArg(address)], RESOLVER_ERRORS);
|
|
258
301
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -270,6 +313,8 @@ export class SoranHolder {
|
|
|
270
313
|
*/
|
|
271
314
|
async setText(name, key, value) {
|
|
272
315
|
const { label, namespace } = parseName(name);
|
|
316
|
+
if (key === "payment")
|
|
317
|
+
throw new HolderError("payment records must be written atomically with setPayment");
|
|
273
318
|
if (!SYMBOL_RE.test(key)) {
|
|
274
319
|
throw new HolderError(`invalid text-record key "${key}" — 1-32 chars of A-Za-z0-9_`);
|
|
275
320
|
}
|
|
@@ -351,8 +396,8 @@ export class SoranHolder {
|
|
|
351
396
|
}
|
|
352
397
|
/** Remove your reverse record on a namespace's resolver. */
|
|
353
398
|
async clearReverse(namespace) {
|
|
354
|
-
assertLabel(namespace
|
|
355
|
-
const resolverId = await this.resolverOf(namespace
|
|
399
|
+
assertLabel(normalizeLabel(namespace));
|
|
400
|
+
const resolverId = await this.resolverOf(normalizeLabel(namespace));
|
|
356
401
|
const pub = await this.signer.publicKey();
|
|
357
402
|
const r = await this.invoke(resolverId, "clear_reverse", [addrArg(pub)], RESOLVER_ERRORS);
|
|
358
403
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -368,6 +413,8 @@ export class SoranHolder {
|
|
|
368
413
|
throw new HolderError("setPrimary needs the PrimaryName contract — configure primaryId (the testnet preset supplies one)");
|
|
369
414
|
}
|
|
370
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");
|
|
371
418
|
const pub = await this.signer.publicKey();
|
|
372
419
|
const r = await this.invoke(this.primaryId, "set_primary", [addrArg(pub), nativeToScVal(name.toLowerCase(), { type: "string" })], PRIMARY_ERRORS);
|
|
373
420
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -377,6 +424,8 @@ export class SoranHolder {
|
|
|
377
424
|
if (!this.primaryId) {
|
|
378
425
|
throw new HolderError("clearPrimary needs the PrimaryName contract — configure primaryId (the testnet preset supplies one)");
|
|
379
426
|
}
|
|
427
|
+
if (await this.read(this.primaryId, "registry", []) !== this.registryId)
|
|
428
|
+
throw new HolderError("Primary is anchored to a different Registry");
|
|
380
429
|
const pub = await this.signer.publicKey();
|
|
381
430
|
const r = await this.invoke(this.primaryId, "clear_primary", [addrArg(pub)], PRIMARY_ERRORS);
|
|
382
431
|
return { hash: r.hash, ledger: r.ledger };
|
|
@@ -420,7 +469,7 @@ export class SoranHolder {
|
|
|
420
469
|
// ---- discovery -----------------------------------------------------------
|
|
421
470
|
/** The namespace's Registry-attested Registrar. Cached briefly. */
|
|
422
471
|
async registrarOf(namespace) {
|
|
423
|
-
namespace = namespace
|
|
472
|
+
namespace = normalizeLabel(namespace);
|
|
424
473
|
assertLabel(namespace);
|
|
425
474
|
const hit = this.registrars.get(namespace);
|
|
426
475
|
if (hit && Date.now() - hit.at < SoranHolder.POINTER_TTL_MS)
|
|
@@ -436,7 +485,7 @@ export class SoranHolder {
|
|
|
436
485
|
}
|
|
437
486
|
/** The namespace's resolver pointer. Cached briefly. */
|
|
438
487
|
async resolverOf(namespace) {
|
|
439
|
-
namespace = namespace
|
|
488
|
+
namespace = normalizeLabel(namespace);
|
|
440
489
|
assertLabel(namespace);
|
|
441
490
|
const hit = this.resolvers.get(namespace);
|
|
442
491
|
if (hit && Date.now() - hit.at < SoranHolder.POINTER_TTL_MS)
|
|
@@ -445,12 +494,58 @@ export class SoranHolder {
|
|
|
445
494
|
bytesArg(namehash(namespace)),
|
|
446
495
|
]));
|
|
447
496
|
if (!id) {
|
|
448
|
-
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");
|
|
449
498
|
}
|
|
450
499
|
this.resolvers.set(namespace, { value: id, at: Date.now() });
|
|
451
500
|
return id;
|
|
452
501
|
}
|
|
453
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
|
+
}
|
|
454
549
|
async read(contractId, fn, args) {
|
|
455
550
|
const tx = new TransactionBuilder(new Account(SIM_SOURCE, "0"), {
|
|
456
551
|
fee: BASE_FEE,
|
|
@@ -466,7 +561,7 @@ export class SoranHolder {
|
|
|
466
561
|
throw new HolderError(`${fn}: the on-chain entry is archived (rent lapsed) — any write restores it automatically`, contractId, fn);
|
|
467
562
|
}
|
|
468
563
|
if (!rpc.Api.isSimulationSuccess(sim) || !sim.result?.retval)
|
|
469
|
-
return
|
|
564
|
+
throw new HolderError(`${fn}: missing simulation return value`, contractId, fn);
|
|
470
565
|
const v = scValToNative(sim.result.retval);
|
|
471
566
|
return v === undefined ? null : v;
|
|
472
567
|
}
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorandomains/holder",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Manage your own Soran name on Stellar
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -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": {
|