@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 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
- | `setRecord` | Point your name's explicit resolver record at any address (generation-gated stops resolving the moment the name changes hands) |
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: "CAUEHYVLLNNDZ4H5QWCPBDWEONRI44SI3XYSEACB4U3HYILIVQGQAMNI";
39
- readonly primaryId: "CAZMXB6UBXKL4DGC2GUC5VKHIZMF47CIZXZFAZPYLM2RP6ZJZNSIIYS2";
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: "CAUEHYVLLNNDZ4H5QWCPBDWEONRI44SI3XYSEACB4U3HYILIVQGQAMNI",
43
- primaryId: "CAZMXB6UBXKL4DGC2GUC5VKHIZMF47CIZXZFAZPYLM2RP6ZJZNSIIYS2",
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.registrarOf(namespace);
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.resolverOf(namespace);
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.toLowerCase());
355
- const resolverId = await this.resolverOf(namespace.toLowerCase());
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.toLowerCase();
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.toLowerCase();
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 are unavailable (setAddress still works where the namespace has an attested Registrar)`, this.registryId, "resolver_of");
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 null;
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;
@@ -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.2.0",
4
- "description": "Manage your own Soran name on Stellar records, profile, reverse, primary, and transfers, signed by your key.",
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": {