@sorandomains/holder 0.5.0 → 0.5.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
@@ -143,7 +143,7 @@ supply the matching `primaryId` explicitly.
143
143
 
144
144
  ## Native username claiming
145
145
 
146
- Native username claiming uses `claimQuote`, `createClaimIntent`, `buildClaim` and `claim`. The current namespace owner must first enable the public policy. The claimant's G wallet authorizes the exact username, complete receiving destination, owner price, policy version and deadline. `recoverClaim` reads the original immutable receipt; it never silently retries an uncertain transaction. `acceptNameTransferWithDestination` and `renewName` cover separately authorized holder lifecycle actions.
146
+ Native username claiming uses `claimQuote`, `createClaimIntent`, `buildClaim` and `claim`. The current namespace owner must first enable the public policy. The claimant's G wallet authorizes the exact username, complete receiving destination, owner price, policy version and deadline. `recoverClaim` reads the original immutable receipt; it never silently retries an uncertain transaction. Receipt reads, receipt absence and confirmed transaction results are accepted only after a clean Registrar attestation and executable check with RPC ledger context at least as recent as the receipt read or transaction inclusion. Missing or older context stops recovery; retain the original request and transaction hash. This check trusts the configured RPC to report its state and ledger honestly. Later namespace-owner, claim-policy or Resolver changes alone do not invalidate a historical receipt, while a tainted or upgraded Registrar cannot supply authoritative history. `acceptNameTransferWithDestination` and `renewName` cover separately authorized holder lifecycle actions.
147
147
 
148
148
  Read the [native claim APIs, security boundaries and complete signup flow](https://github.com/SoranDomains/sdk/blob/main/NATIVE-CLAIMS.md). G/no memo, G with ID/Text/Hash, full M/no separate memo and C/no memo remain supported payment destinations. Current transaction-signing adapters use classic G accounts.
149
149
 
package/dist/index.d.ts CHANGED
@@ -35,7 +35,7 @@ import { type PaymentDestination } from "./payment.js";
35
35
  export { encodeMuxedAddress, decodeMuxedAddress, PAYMENT_RECORD_KEY, encodePaymentRecord, parsePaymentRecord, validatePaymentDestination, type PaymentMemo, type PaymentDestination } from "./payment.js";
36
36
  import { type ClaimSubmitOptions } from "./native-holder.js";
37
37
  import type { ClaimIntent, TransferIntent, RenewIntent } from "./native-types.js";
38
- import type { NativeWriteOptions } from "./native-transport.js";
38
+ import { type NativeWriteOptions } from "./native-transport.js";
39
39
  export * from "./native-types.js";
40
40
  export * from "./native-allowlist.js";
41
41
  export * from "./native-approver.js";
@@ -244,6 +244,8 @@ export declare class SoranHolder {
244
244
  private paymentResolverOf;
245
245
  private assertMemoFree;
246
246
  private read;
247
+ private readWithLedger;
248
+ private simulateRead;
247
249
  private signEnvelope;
248
250
  private serialize;
249
251
  private sourceAccount;
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ import { decodeMuxedAddress, destinationFromNative, paymentFromNative, paymentMe
36
36
  export { encodeMuxedAddress, decodeMuxedAddress, PAYMENT_RECORD_KEY, encodePaymentRecord, parsePaymentRecord, validatePaymentDestination } from "./payment.js";
37
37
  import { NativeHolderClient } from "./native-holder.js";
38
38
  import { NativeClaimError } from "./native-codec.js";
39
+ import { requireReadLedger } from "./native-transport.js";
39
40
  export * from "./native-types.js";
40
41
  export * from "./native-allowlist.js";
41
42
  export * from "./native-approver.js";
@@ -280,7 +281,8 @@ export class SoranHolder {
280
281
  }
281
282
  nativeClient() {
282
283
  return new NativeHolderClient({ registryId: this.registryId, passphrase: this.passphrase, server: this.server, signer: this.signer, fee: this.fee, timeoutSecs: this.timeoutSecs, maxFeeStroops: this.maxNativeFeeStroops,
283
- read: (id, method, args) => this.read(id, method, args), serialize: work => this.serialize(work) });
284
+ read: (id, method, args) => this.read(id, method, args),
285
+ readWithLedger: (id, method, args) => this.readWithLedger(id, method, args), serialize: work => this.serialize(work) });
284
286
  }
285
287
  nativeClaimCapability(namespace) { return this.nativeClient().nativeClaimCapability(namespace); }
286
288
  claimQuote(name, claimant) { return this.nativeClient().claimQuote(name, claimant); }
@@ -603,6 +605,13 @@ export class SoranHolder {
603
605
  return registrar;
604
606
  }
605
607
  async read(contractId, fn, args) {
608
+ return (await this.simulateRead(contractId, fn, args)).value;
609
+ }
610
+ async readWithLedger(contractId, fn, args) {
611
+ const result = await this.simulateRead(contractId, fn, args);
612
+ return { value: result.value, ledger: requireReadLedger(result.ledger) };
613
+ }
614
+ async simulateRead(contractId, fn, args) {
606
615
  const tx = new TransactionBuilder(new Account(SIM_SOURCE, "0"), {
607
616
  fee: BASE_FEE,
608
617
  networkPassphrase: this.passphrase,
@@ -619,7 +628,7 @@ export class SoranHolder {
619
628
  if (!rpc.Api.isSimulationSuccess(sim) || !sim.result?.retval)
620
629
  throw new HolderError(`${fn}: missing simulation return value`, contractId, fn);
621
630
  const v = scValToNative(sim.result.retval);
622
- return v === undefined ? null : v;
631
+ return { value: v === undefined ? null : v, ledger: sim.latestLedger };
623
632
  }
624
633
  async signEnvelope(xdrBase64) {
625
634
  const signed = await this.signer.signTransaction(xdrBase64, {
@@ -31,7 +31,7 @@ export declare class NativeHolderClient {
31
31
  private checkQuote;
32
32
  claimReceipt(namespace: string, claimant: string, requestId: string): Promise<ClaimReceipt | null>;
33
33
  private receiptAt;
34
- recoverClaim(intent: ClaimIntent): Promise<ClaimReceipt | null>;
34
+ recoverClaim(input: ClaimIntent): Promise<ClaimReceipt | null>;
35
35
  private planClaim;
36
36
  buildClaim(intent: ClaimIntent, options?: ClaimSubmitOptions): Promise<PreparedClaim>;
37
37
  claim(intent: ClaimIntent, options?: ClaimSubmitOptions): Promise<NativeClaimSubmission>;
@@ -1,6 +1,6 @@
1
1
  import { hash, scValToNative, xdr } from "@stellar/stellar-sdk";
2
2
  import { NativeClaimError, address, bytes32, exactObject, hex, hex32, label, namespaceNode, sc, u64, unhex, utf8 } from "./native-codec.js";
3
- import { nativeCapability, requireNative, verifyRegistrarProvenance, prepareNative, sendNative } from "./native-transport.js";
3
+ import { nativeCapability, requireNative, requireReadLedger, verifyRegistrarProvenance, prepareNative, sendNative } from "./native-transport.js";
4
4
  import { claimIntentToScVal, claimIntentFromNative, claimQuoteFromNative, claimReceiptFromNative, claimResultFromNative, destinationPreviewFromNative, transferIntentToScVal, renewIntentToScVal, nativeIntentHash, claimLabelToScVal } from "./native-types.js";
5
5
  import { paymentDestinationToScVal } from "./native-codec.js";
6
6
  import { validatePaymentDestination } from "./payment.js";
@@ -27,6 +27,8 @@ function receiptMatches(receipt, method, intent, holder) {
27
27
  if (receipt.intentHash !== nativeIntentHash(method, intent) || receipt.holder !== holder)
28
28
  throw new NativeClaimError("request ID already belongs to a different immutable intent");
29
29
  const raw = scValToNative(intent), expectedOperation = { claim: "claim", issue_reserved_with_destination: "reserved", accept_transfer_with_destination: "transfer", renew_holder: "renew" }[method];
30
+ if (receipt.timestamp < raw.context.valid_after || receipt.timestamp > raw.context.deadline)
31
+ throw new NativeClaimError("receipt timestamp is outside the original request window");
30
32
  const nodeBytes = new Uint8Array(64);
31
33
  nodeBytes.set(raw.context.namespace);
32
34
  nodeBytes.set(hash(raw.label), 32);
@@ -44,20 +46,34 @@ function receiptMatches(receipt, method, intent, holder) {
44
46
  else {
45
47
  if (receipt.feeAmount !== 0n || receipt.feeRecipient !== null)
46
48
  throw new NativeClaimError("lifecycle receipt unexpectedly contains a username fee");
47
- if (method === "accept_transfer_with_destination" && receipt.expiresAt !== raw.expected_expiry)
48
- throw new NativeClaimError("transfer receipt changed the lease");
49
+ if (method === "accept_transfer_with_destination" && (receipt.expiresAt !== raw.expected_expiry || receipt.timestamp > raw.proposal_expires))
50
+ throw new NativeClaimError("transfer receipt changed the lease or exceeded the proposal window");
49
51
  if (method === "renew_holder" && (receipt.expiresAt < raw.min_new_expiry || receipt.expiresAt > raw.max_new_expiry || receipt.expiresAt !== (receipt.timestamp > raw.expected_expiry ? receipt.timestamp : raw.expected_expiry) + raw.term_secs))
50
52
  throw new NativeClaimError("renewal receipt differs from signed lease bounds");
51
53
  }
52
54
  }
53
- function confirmedResult(result, method, encoded, holder) { try {
54
- const decoded = claimResultFromNative(result.value);
55
- receiptMatches(decoded.receipt, method, encoded, holder);
56
- return { ...decoded, transaction: { hash: result.hash, ledger: result.ledger } };
55
+ function receiptReadMatches(receipt, holder, observedLedger) {
56
+ requireReadLedger(receipt.ledger);
57
+ if (receipt.holder !== holder || receipt.ledger > observedLedger)
58
+ throw new NativeClaimError("receipt holder or inclusion ledger differs from the requested snapshot", "unavailable");
59
+ }
60
+ async function confirmedResult(context, result, method, encoded, holder) {
61
+ try {
62
+ // Inclusion proves the reviewed transaction ran, not that an owner left the
63
+ // Registrar on its trusted implementation until it ran. Recheck after it.
64
+ const raw = scValToNative(encoded);
65
+ await verifyRegistrarProvenance(context, raw.context.registrar, hex(raw.context.namespace), result.ledger);
66
+ const decoded = claimResultFromNative(result.value);
67
+ receiptReadMatches(decoded.receipt, holder, result.ledger);
68
+ if (decoded.status === "fresh" && decoded.receipt.ledger !== result.ledger)
69
+ throw new NativeClaimError("fresh receipt does not belong to the confirmed ledger");
70
+ receiptMatches(decoded.receipt, method, encoded, holder);
71
+ return { ...decoded, transaction: { hash: result.hash, ledger: result.ledger } };
72
+ }
73
+ catch (error) {
74
+ throw new NativeClaimError(`transaction confirmed but result could not be verified: ${String(error)}; recover the original request`, "pending", result.hash);
75
+ }
57
76
  }
58
- catch (error) {
59
- throw new NativeClaimError(`transaction confirmed but result could not be verified: ${String(error)}; recover the original request`, "pending", result.hash);
60
- } }
61
77
  export class NativeHolderClient {
62
78
  context;
63
79
  constructor(context) {
@@ -100,14 +116,31 @@ export class NativeHolderClient {
100
116
  if (quote.node !== hex(hash(bytes)))
101
117
  throw new NativeClaimError("quote name node is invalid", "unavailable");
102
118
  }
103
- async claimReceipt(namespace, claimant, requestId) { return this.receiptAt(await this.registrar(canonicalNamespace(namespace)), claimant, requestId); }
104
- async receiptAt(registrar, claimant, requestId) {
105
- const raw = await this.context.read(registrar, "claim_receipt", [sc.address(address(claimant, "identity", "receipt holder")), sc.bytes(unhex(requestId))]);
106
- return raw === null ? null : claimReceiptFromNative(raw);
119
+ async claimReceipt(namespace, claimant, requestId) {
120
+ const canonical = canonicalNamespace(namespace);
121
+ return this.receiptAt(await this.registrar(canonical), hex(namespaceNode(canonical)), claimant, requestId);
122
+ }
123
+ async receiptAt(registrar, namespace, claimant, requestId) {
124
+ const holder = address(claimant, "identity", "receipt holder");
125
+ const snapshot = await this.context.readWithLedger(registrar, "claim_receipt", [sc.address(holder), sc.bytes(unhex(hex32(requestId, "request ID")))]);
126
+ const ledger = requireReadLedger(snapshot?.ledger);
127
+ // Validate both Some and None before they can drive a recovery/replay decision.
128
+ await verifyRegistrarProvenance(this.context, registrar, namespace, ledger);
129
+ if (snapshot.value === null)
130
+ return null;
131
+ const receipt = claimReceiptFromNative(snapshot.value);
132
+ receiptReadMatches(receipt, holder, ledger);
133
+ return receipt;
134
+ }
135
+ async recoverClaim(input) {
136
+ const encoded = claimIntentToScVal(input), intent = claimIntentFromNative(scValToNative(encoded));
137
+ scoped(this.context, intent.context);
138
+ await verifyRegistrarProvenance(this.context, intent.context.registrar, intent.context.namespace);
139
+ const receipt = await this.receiptAt(intent.context.registrar, intent.context.namespace, intent.claimant, intent.context.requestId);
140
+ if (receipt)
141
+ receiptMatches(receipt, "claim", encoded, intent.claimant);
142
+ return receipt;
107
143
  }
108
- async recoverClaim(intent) { const encoded = claimIntentToScVal(intent); scoped(this.context, intent.context); await verifyRegistrarProvenance(this.context, intent.context.registrar, intent.context.namespace); if (await this.context.read(this.context.registryId, "registrar_of", [sc.bytes(unhex(intent.context.namespace))]) !== intent.context.registrar)
109
- throw new NativeClaimError("recovery Registrar is not the Registry attestation", "unavailable"); const receipt = await this.receiptAt(intent.context.registrar, intent.claimant, intent.context.requestId); if (receipt)
110
- receiptMatches(receipt, "claim", encoded, intent.claimant); return receipt; }
111
144
  async planClaim(input, proof = []) {
112
145
  const encoded = claimIntentToScVal(input);
113
146
  const intent = claimIntentFromNative(scValToNative(encoded));
@@ -177,7 +210,7 @@ export class NativeHolderClient {
177
210
  // Snapshot before any awaits: callers cannot mutate reviewed destination/amount mid-flow.
178
211
  const snapshot = claimIntentFromNative(scValToNative(claimIntentToScVal(intent)));
179
212
  return this.context.serialize(async () => { const old = await this.recoverClaim(snapshot); if (old)
180
- return { status: "replayed", receipt: old, transaction: null }; const { plan } = await this.planClaim(snapshot, options.proof); const result = await sendNative(this.context, plan, options); return confirmedResult(result, "claim", claimIntentToScVal(snapshot), snapshot.claimant); });
213
+ return { status: "replayed", receipt: old, transaction: null }; const { plan } = await this.planClaim(snapshot, options.proof); const result = await sendNative(this.context, plan, options); return confirmedResult(this.context, result, "claim", claimIntentToScVal(snapshot), snapshot.claimant); });
181
214
  }
182
215
  async renewalPreview(name) {
183
216
  const parsed = names(name), registrar = await this.registrar(parsed.namespace);
@@ -198,9 +231,7 @@ export class NativeHolderClient {
198
231
  scoped(this.context, request);
199
232
  return this.context.serialize(async () => {
200
233
  await verifyRegistrarProvenance(this.context, request.registrar, request.namespace);
201
- if (await this.context.read(this.context.registryId, "registrar_of", [sc.bytes(unhex(request.namespace))]) !== request.registrar)
202
- throw new NativeClaimError("recovery Registrar is not the Registry attestation", "unavailable");
203
- const old = await this.receiptAt(request.registrar, holder, request.requestId);
234
+ const old = await this.receiptAt(request.registrar, request.namespace, holder, request.requestId);
204
235
  if (old) {
205
236
  receiptMatches(old, method, intent, holder);
206
237
  return { status: "replayed", receipt: old, transaction: null };
@@ -215,7 +246,7 @@ export class NativeHolderClient {
215
246
  if (source !== holder)
216
247
  throw new NativeClaimError("connected wallet is not the intended holder/recipient", "authorization");
217
248
  const result = await sendNative(this.context, { source, contract: request.registrar, method, args: [intent], sourceInvocation: { contract: request.registrar, method, args: [intent], children: child ? [child] : [] }, maxFeeStroops: this.context.maxFeeStroops }, options);
218
- return confirmedResult(result, method, intent, holder);
249
+ return confirmedResult(this.context, result, method, intent, holder);
219
250
  });
220
251
  }
221
252
  }
@@ -8,6 +8,10 @@ export type NativeSigner = {
8
8
  signedTxXdr: string;
9
9
  }>;
10
10
  };
11
+ export type NativeRead = {
12
+ value: unknown;
13
+ ledger: number;
14
+ };
11
15
  export type NativeContext = {
12
16
  registryId: string;
13
17
  passphrase: string;
@@ -17,6 +21,7 @@ export type NativeContext = {
17
21
  timeoutSecs: number;
18
22
  maxFeeStroops: bigint;
19
23
  read(contract: string, method: string, args: xdr.ScVal[]): Promise<unknown>;
24
+ readWithLedger(contract: string, method: string, args: xdr.ScVal[]): Promise<NativeRead>;
20
25
  serialize<T>(work: () => Promise<T>): Promise<T>;
21
26
  };
22
27
  export type NativeCapability = {
@@ -33,8 +38,10 @@ export type NativeCapability = {
33
38
  owner: string;
34
39
  ownerEpoch: bigint;
35
40
  };
41
+ /** A missing context is not evidence that an RPC observation is fresh. */
42
+ export declare function requireReadLedger(value: unknown, minimum?: number): number;
36
43
  /** Historical reads must not trust an upgraded Registrar that can fabricate receipts. Resolver changes do not block this Registrar-only check. */
37
- export declare function verifyRegistrarProvenance(context: NativeContext, registrar: string, namespace: string): Promise<void>;
44
+ export declare function verifyRegistrarProvenance(context: NativeContext, registrar: string, namespace: string, minimumLedger?: number): Promise<void>;
38
45
  export declare function nativeCapability(context: NativeContext, namespace: string): Promise<NativeCapability>;
39
46
  export declare function requireNative(context: NativeContext, namespace: string): Promise<Extract<NativeCapability, {
40
47
  supported: true;
@@ -3,8 +3,46 @@ import { NativeClaimError, address, bytes32, hex, namespaceNode, sc, unhex } fro
3
3
  import { assertSignedBodyUnchanged, validateEligibilityAuthorization, validateNativeTransaction } from "./native-auth.js";
4
4
  /** This published legacy code has owner-only issuance. Missing RPC data is never legacy detection. */
5
5
  const LEGACY_REGISTRAR_HASH = "ed06b3374ff4342b4a2316fc546505132cd8fd35be6666571cf088710af9bbc6";
6
+ /** A missing context is not evidence that an RPC observation is fresh. */
7
+ export function requireReadLedger(value, minimum = 1) {
8
+ if (typeof value !== "number" || !Number.isInteger(value) || value < minimum || value < 1 || value > 0xffff_ffff)
9
+ throw new NativeClaimError("RPC read has missing, invalid or stale ledger context", "unavailable");
10
+ return value;
11
+ }
12
+ /** Every post-receipt observation must be at least as new as that simulation.
13
+ * Registry taint is irreversible: clean at/after the receipt rules out an
14
+ * upgrade before it, even when these truthful RPC snapshots differ in ledger.
15
+ * The RPC remains trusted to report its ledger and returned state honestly.
16
+ */
17
+ async function verifyProvenanceAfter(context, registrar, namespace, minimumLedger) {
18
+ requireReadLedger(minimumLedger);
19
+ const node = sc.bytes(unhex(namespace));
20
+ const key = new Contract(registrar).getFootprint();
21
+ const [attestation, taint, templates, executable] = await Promise.all([
22
+ context.readWithLedger(context.registryId, "registrar_of", [node]),
23
+ context.readWithLedger(context.registryId, "registrar_tainted", [node]),
24
+ context.readWithLedger(context.registryId, "template_hashes", []),
25
+ context.server.getLedgerEntries(key),
26
+ ]);
27
+ for (const observation of [attestation, taint, templates])
28
+ requireReadLedger(observation?.ledger, minimumLedger);
29
+ requireReadLedger(executable?.latestLedger, minimumLedger);
30
+ if (attestation.value !== registrar || taint.value !== false || !Array.isArray(templates.value) || templates.value.length !== 2)
31
+ throw new NativeClaimError("Registrar provenance changed or is unavailable after receipt read", "unavailable");
32
+ // getContractInstance discards latestLedger; read and bind the exact entry.
33
+ const entry = executable.entries?.[0];
34
+ if (executable.entries?.length !== 1 || !entry || entry.key.toXDR("base64") !== key.toXDR("base64") || entry.val.type !== "contractData")
35
+ throw new NativeClaimError("Registrar executable proof is missing or mismatched", "unavailable");
36
+ const data = entry.val.value;
37
+ if (data.contract.toXDR("base64") !== new Contract(registrar).address().toScAddress().toXDR("base64") || data.key.type !== "scvLedgerKeyContractInstance" || data.durability !== xdr.ContractDataDurability.persistent || data.val.type !== "scvContractInstance" || data.val.value.executable.type !== "contractExecutableWasm")
38
+ throw new NativeClaimError("Registrar executable proof is not the requested Wasm instance", "unavailable");
39
+ if (hex(bytes32(templates.value[0], "Registry Registrar template")) !== hex(data.val.value.executable.wasmHash.value))
40
+ throw new NativeClaimError("Registrar executable differs from immutable Registry template after receipt read", "unavailable");
41
+ }
6
42
  /** Historical reads must not trust an upgraded Registrar that can fabricate receipts. Resolver changes do not block this Registrar-only check. */
7
- export async function verifyRegistrarProvenance(context, registrar, namespace) {
43
+ export async function verifyRegistrarProvenance(context, registrar, namespace, minimumLedger) {
44
+ if (minimumLedger !== undefined)
45
+ return verifyProvenanceAfter(context, registrar, namespace, minimumLedger);
8
46
  const node = sc.bytes(unhex(namespace));
9
47
  const [attested, tainted, templates, instance] = await Promise.all([
10
48
  context.read(context.registryId, "registrar_of", [node]), context.read(context.registryId, "registrar_tainted", [node]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorandomains/holder",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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",