@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 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
- | `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 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: "CAUEHYVLLNNDZ4H5QWCPBDWEONRI44SI3XYSEACB4U3HYILIVQGQAMNI";
39
- readonly primaryId: "CAZMXB6UBXKL4DGC2GUC5VKHIZMF47CIZXZFAZPYLM2RP6ZJZNSIIYS2";
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, xdr, } 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: "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.registrarOf(namespace);
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.resolverOf(namespace);
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.toLowerCase());
350
- const resolverId = await this.resolverOf(namespace.toLowerCase());
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.toLowerCase();
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.toLowerCase();
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 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");
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 null;
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.switch() !== xdr.SorobanCredentialsType.sorobanCredentialsAddress())
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().address()).toString();
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
- let sim = await this.server.simulateTransaction(tx);
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().toString("hex");
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.switch() === 3
613
- ? (meta.v3().sorobanMeta()?.diagnosticEvents() ?? [])
614
- : meta && meta.switch() === 4
615
- ? meta.v4().diagnosticEvents()
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().body().v0();
619
- for (const v of [...body.topics(), body.data()]) {
620
- if (v.switch() === xdr.ScValType.scvError()) {
621
- const err = v.error();
622
- if (err.switch() === xdr.ScErrorType.sceContract()) {
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().switch().name ?? resultCode;
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;
@@ -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.1.1",
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": ">=13 <17"
43
+ "@stellar/stellar-sdk": ">=17 <18"
43
44
  },
44
45
  "devDependencies": {
45
- "@stellar/stellar-sdk": "^16.2.0",
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",