@routedock/nulth-sdk 0.2.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
@@ -2,16 +2,135 @@
2
2
 
3
3
  **Status: STUB — not ready for production.**
4
4
 
5
- This package provides an **insecure mock prover** (`insecureMockProof`) that computes a
6
- SHA-512 digest as a placeholder. No real zero-knowledge proof is generated.
7
-
8
- - The mock prover is only safe for **testnet** development.
9
- - **`NulthClient` construction throws unless `network` is `'testnet'` and the prover
10
- is `'mock'`.** A missing or misspelled network (for example `'pubnet'`) and any
11
- other prover backend are rejected, because this package does not currently
12
- implement a production prover backend.
13
- - The `mock` backend is deliberately not a cryptographic proof and must never be
14
- used for custody or policy enforcement.
15
-
16
- See [Issue #143](https://github.com/winsznx/routedock/issues/143) for context.
5
+ This package provides an **insecure mock prover** (`insecureMockProof`). It produces a
6
+ SHA-512 digest as a placeholder; no real zero-knowledge proof is generated. Use it
7
+ only for **testnet development**, never for custody or production policy enforcement.
8
+ `NulthClient` rejects every network except `testnet` and every prover except `mock`.
9
+ See [Issue #143](https://github.com/winsznx/routedock/issues/143) for production prover work.
10
+
11
+ ## Requirements
12
+
13
+ Node.js **22 or newer**, matching the supported runtime of the RouteDock packages.
14
+ TypeScript examples use ES2022 or later for native `bigint` support.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @routedock/nulth-sdk
20
+ ```
21
+
22
+ ## Example: initialize a testnet client
23
+
24
+ Set `NULTH_ACCOUNT` to your testnet smart-account contract address (`C...`) and
25
+ `TESTNET_PAYEE` to an allowed testnet recipient (`G...` or `C...`). This example
26
+ initializes local policy state and prints commitments; it does not submit a payment.
27
+ Persist the witness secret securely if you need to recreate the same commitments.
28
+
29
+ ```ts
30
+ import { randomBytes } from 'node:crypto'
31
+ import {
32
+ createPolicyState,
33
+ NulthClient,
34
+ stroopsToUsdc,
35
+ } from '@routedock/nulth-sdk'
36
+
37
+ const nulthAccount = process.env.NULTH_ACCOUNT
38
+ const payee = process.env.TESTNET_PAYEE
39
+ if (!nulthAccount || !payee) {
40
+ throw new Error('Set NULTH_ACCOUNT and TESTNET_PAYEE to testnet addresses')
41
+ }
42
+
43
+ const policy = createPolicyState({
44
+ dailyCapUsdc: '1.00',
45
+ allowedPayees: [payee],
46
+ witnessSecret: randomBytes(32).toString('hex'),
47
+ })
48
+ const client = new NulthClient({
49
+ nulthAccount,
50
+ network: 'testnet',
51
+ prover: 'mock',
52
+ policy,
53
+ })
54
+
55
+ console.log(client.allowlistCommitment, client.capCommitment)
56
+ console.log(stroopsToUsdc(client.dailySpendStroops)) // '0'
57
+ ```
58
+
59
+ Before using the signing adapter, supply the payment's real ledger sequence, asset,
60
+ payee, and amount through `setNulthPaymentContext`. `createNulthSigner` checks the
61
+ actual Soroban authorization preimage against that context. A missing context or
62
+ mismatched transfer is rejected. These client-side checks do not make the mock proof secure.
63
+
64
+ ## Exports
65
+
66
+ | Export | Purpose |
67
+ | --- | --- |
68
+ | `NulthClient` | Testnet-only client with policy checks, mock proof construction, encoding, and spend/commitment getters. |
69
+ | `createPolicyState` | Initialize off-chain policy from a decimal USDC cap, payees, witness secret, and optional ledger/expiry. |
70
+ | `createNulthSigner` | Create an x402-compatible signer for a Nulth account using the mock client. |
71
+ | `setNulthPaymentContext` | Set the next payment context on the configuration used by the signer. |
72
+ | `paymentContextFromManifest` | Extract asset, payee, amount, and ledger context from manifest pricing. |
73
+ | `assertAuthEntryMatchesContext` | Reject an authorization preimage whose transfer does not match the expected payer, payee, asset, or amount. |
74
+ | `usdcToStroops`, `stroopsToUsdc`, `USDC_DECIMALS` | Convert decimal USDC strings and integer units using seven decimal places. |
75
+ | `commitAllowlist`, `commitDailyCap` | Compute salted SHA-256 commitments to policy values. |
76
+ | `dayBucket` | Convert a ledger sequence to its 17,280-ledger day bucket. |
77
+ | `hashPayee`, `authDigestFromEntry`, `sha256Hex` | Hash payees, authorization preimages, and byte/string inputs. |
78
+ | `encodeAuthSignature`, `decodeAuthSignature` | Encode or decode the mock signature envelope as base64 JSON; decoding is not proof verification. |
79
+ | `insecureMockProof`, `DEFAULT_PROVER` | Produce deterministic SHA-512 placeholder bytes and identify the only supported backend (`mock`). |
80
+ | `NulthPolicyError` | Typed policy rejection with a `code` such as `daily_cap_exceeded` or `auth_entry_mismatch`. |
81
+ | `NulthPolicyState`, `NulthClientConfig`, `PaymentAuthContext` | Types for policy state, client configuration, and payment authorization inputs. |
82
+ | `NulthSignerConfig`, `NulthStellarSigner` | Types for mutable signer configuration and its signing interface. |
83
+ | `NulthProof`, `NulthAuthSignature`, `ProverBackend` | Types for the mock proof, signature envelope, and prover selection. |
84
+
85
+ ## What the mock prover does and does not prove
86
+
87
+ `NulthClient` checks the local allowlist, daily cap, and optional session expiry,
88
+ then hashes public inputs into deterministic placeholder bytes. Building a proof
89
+ updates the client's in-memory daily spend; it is not evidence of on-chain settlement
90
+ and does not provide durable accounting across process restarts.
91
+
92
+ The digest is **not a zero-knowledge proof** and proves neither knowledge of a secret
93
+ witness nor satisfaction of the policy to an on-chain verifier. Anyone can reproduce
94
+ it from the public preimage. Commitments alone do not provide a production security
95
+ boundary, and the public inputs expose payment amounts, day buckets, and hashes.
96
+ A real prover/verifier integration is still required before this package can protect funds.
97
+
98
+ For contributors, `pnpm --filter @routedock/nulth-sdk check:readme` typechecks every
99
+ TypeScript example against the built package exports. It also runs during `pnpm verify`.
100
+ ## Diagnostics and configuration errors
101
+
102
+ Diagnostics are silent by default. Pass a `logger(level, message, fields?)` in
103
+ `NulthClientConfig` (also accepted by `createNulthSigner`) to receive the mock-prover
104
+ warning. The sink is compatible with RouteDock's `RouteDockLogger`; the RouteDock
105
+ SDK adapter retains its console-backed default and accepts the same override.
106
+
107
+ Invalid networks and prover backends throw `NulthConfigError`, with code
108
+ `unsupported_network` or `unsupported_prover`. The RouteDock adapter maps these
109
+ to `RouteDockManifestError`; policy failures remain `NulthPolicyError`.
110
+
111
+ ## Paying through x402 with a Nulth account
112
+
113
+ `src/scheme.ts` carries the pieces the SDK needs to let a Nulth ZK account sign an
114
+ x402 payment:
115
+
116
+ - `NULTH_SIGNER_MARKER` — a `Symbol.for` key set on every signer from
117
+ `createNulthSigner()`. `@routedock/routedock` uses it to pick the Nulth payment
118
+ scheme instead of the plain ed25519 one, so no caller has to opt in.
119
+ - `nulthProofScVal` / `decodeNulthProofScVal` — the `zk-v1` envelope, a
120
+ `scvVec` of one map with `nulth`, `version`, `proof`, `auth_digest`,
121
+ `allowlist_commitment`, `cap_commitment`, `payee_hash`, `amount_stroops` and
122
+ `day_bucket`. The proof and its public inputs go into
123
+ `SorobanAddressCredentials.signature`; the allowlist and the daily cap never
124
+ leave the agent.
125
+ - `nulthAuthorizeEntry` — the `authorizeEntry` replacement. stellar-sdk's
126
+ default writes `scvVec([nativeToScVal({public_key, signature})])`, and for a
127
+ `C...` contract account that path throws
128
+ `invalid version byte. expected 48, got 16` (see
129
+ [Issue #356](https://github.com/winsznx/routedock/issues/356)), so a Nulth
130
+ payment can never be signed with it.
131
+
132
+ **What still is not here:** the contract side. There is no Nulth account contract
133
+ in this repository that implements `__check_auth` against these public inputs, and
134
+ no Groth16 verifier is deployed, so a proof produced by the mock prover is not
135
+ settled on-chain. Testnet builds are for developing the client path only.
17
136
 
package/dist/index.cjs CHANGED
@@ -21,7 +21,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DEFAULT_PROVER: () => DEFAULT_PROVER,
24
+ NULTH_SIGNATURE_VERSION: () => NULTH_SIGNATURE_VERSION,
25
+ NULTH_SIGNER_MARKER: () => NULTH_SIGNER_MARKER,
24
26
  NulthClient: () => NulthClient,
27
+ NulthConfigError: () => NulthConfigError,
25
28
  NulthPolicyError: () => NulthPolicyError,
26
29
  USDC_DECIMALS: () => USDC_DECIMALS,
27
30
  assertAuthEntryMatchesContext: () => assertAuthEntryMatchesContext,
@@ -32,9 +35,13 @@ __export(index_exports, {
32
35
  createPolicyState: () => createPolicyState,
33
36
  dayBucket: () => dayBucket,
34
37
  decodeAuthSignature: () => decodeAuthSignature,
38
+ decodeNulthProofScVal: () => decodeNulthProofScVal,
35
39
  encodeAuthSignature: () => encodeAuthSignature,
36
40
  hashPayee: () => hashPayee,
37
41
  insecureMockProof: () => insecureMockProof,
42
+ isNulthStellarSigner: () => isNulthStellarSigner,
43
+ nulthAuthorizeEntry: () => nulthAuthorizeEntry,
44
+ nulthProofScVal: () => nulthProofScVal,
38
45
  paymentContextFromManifest: () => paymentContextFromManifest,
39
46
  setNulthPaymentContext: () => setNulthPaymentContext,
40
47
  sha256Hex: () => sha256Hex,
@@ -44,6 +51,14 @@ __export(index_exports, {
44
51
  module.exports = __toCommonJS(index_exports);
45
52
 
46
53
  // src/types.ts
54
+ var NulthConfigError = class extends Error {
55
+ code;
56
+ constructor(code, message) {
57
+ super(message);
58
+ this.name = "NulthConfigError";
59
+ this.code = code;
60
+ }
61
+ };
47
62
  var NulthPolicyError = class extends Error {
48
63
  code;
49
64
  constructor(code, message) {
@@ -54,9 +69,51 @@ var NulthPolicyError = class extends Error {
54
69
  };
55
70
 
56
71
  // src/proof.ts
57
- var import_node_crypto = require("crypto");
58
72
  var import_stellar_sdk = require("@stellar/stellar-sdk");
59
73
 
74
+ // src/encoding.ts
75
+ var import_sha2 = require("@noble/hashes/sha2.js");
76
+ var encoder = new TextEncoder();
77
+ var decoder = new TextDecoder();
78
+ function utf8ToBytes(text) {
79
+ return encoder.encode(text);
80
+ }
81
+ function bytesToUtf8(bytes) {
82
+ return decoder.decode(bytes);
83
+ }
84
+ function bytesToBase64(bytes) {
85
+ const chunk = 32768;
86
+ let binary = "";
87
+ for (let i = 0; i < bytes.length; i += chunk) {
88
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
89
+ }
90
+ return btoa(binary);
91
+ }
92
+ function base64ToBytes(b64) {
93
+ const end = b64.indexOf("=");
94
+ let clean = (end === -1 ? b64 : b64.slice(0, end)).replace(/-/g, "+").replace(/_/g, "/").replace(/[^A-Za-z0-9+/]/g, "");
95
+ if (clean.length % 4 === 1) clean = clean.slice(0, -1);
96
+ const binary = atob(clean);
97
+ const bytes = new Uint8Array(binary.length);
98
+ for (let i = 0; i < binary.length; i++) {
99
+ bytes[i] = binary.charCodeAt(i);
100
+ }
101
+ return bytes;
102
+ }
103
+ function bytesToHex(bytes) {
104
+ let out = "";
105
+ for (let i = 0; i < bytes.length; i++) {
106
+ out += bytes[i].toString(16).padStart(2, "0");
107
+ }
108
+ return out;
109
+ }
110
+ function sha256Bytes(data) {
111
+ return (0, import_sha2.sha256)(typeof data === "string" ? utf8ToBytes(data) : data);
112
+ }
113
+ function sha512Bytes(data) {
114
+ return (0, import_sha2.sha512)(typeof data === "string" ? utf8ToBytes(data) : data);
115
+ }
116
+
60
117
  // src/usdc.ts
61
118
  var USDC_DECIMALS = 7;
62
119
  var USDC_SCALE = 10n ** BigInt(USDC_DECIMALS);
@@ -92,8 +149,7 @@ function stroopsToUsdc(units) {
92
149
 
93
150
  // src/proof.ts
94
151
  function sha256Hex(data) {
95
- const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
96
- return (0, import_node_crypto.createHash)("sha256").update(buf).digest("hex");
152
+ return bytesToHex(sha256Bytes(data));
97
153
  }
98
154
  function commitDailyCap(dailyCapStroops, witnessSecret) {
99
155
  return sha256Hex(`${witnessSecret}:cap:${dailyCapStroops.toString()}`);
@@ -109,7 +165,7 @@ function dayBucket(ledgerSequence) {
109
165
  return Math.floor(ledgerSequence / 17280);
110
166
  }
111
167
  function authDigestFromEntry(authEntryBase64) {
112
- const entryHash = (0, import_stellar_sdk.hash)(Buffer.from(authEntryBase64, "base64"));
168
+ const entryHash = (0, import_stellar_sdk.hash)(base64ToBytes(authEntryBase64));
113
169
  return sha256Hex(entryHash);
114
170
  }
115
171
  function assertAuthEntryMatchesContext(authEntry, from, ctx) {
@@ -175,17 +231,17 @@ function assertAuthEntryMatchesContext(authEntry, from, ctx) {
175
231
  }
176
232
  var DEFAULT_PROVER = "mock";
177
233
  function insecureMockProof(preimage) {
178
- return (0, import_node_crypto.createHash)("sha512").update(`nulth:${preimage}`).digest("base64");
234
+ return bytesToBase64(sha512Bytes(`nulth:${preimage}`));
179
235
  }
180
236
  function encodeAuthSignature(proof) {
181
237
  const payload = {
182
238
  nulth: "zk-v1",
183
239
  proof
184
240
  };
185
- return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
241
+ return bytesToBase64(utf8ToBytes(JSON.stringify(payload)));
186
242
  }
187
243
  function decodeAuthSignature(encoded) {
188
- const raw = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
244
+ const raw = JSON.parse(bytesToUtf8(base64ToBytes(encoded)));
189
245
  if (typeof raw !== "object" || raw === null || raw.nulth !== "zk-v1") {
190
246
  throw new Error("Invalid Nulth ZK auth signature");
191
247
  }
@@ -199,16 +255,21 @@ var NulthClient = class {
199
255
  this.policy = { ...config.policy };
200
256
  this.prover = config.prover ?? DEFAULT_PROVER;
201
257
  if (this.prover !== "mock") {
202
- throw new Error(
258
+ throw new NulthConfigError(
259
+ "unsupported_prover",
203
260
  `NulthClient: unknown prover backend ${String(config.prover)}; only 'mock' is implemented`
204
261
  );
205
262
  }
206
263
  if (config.network !== "testnet") {
207
- throw new Error(
264
+ throw new NulthConfigError(
265
+ "unsupported_network",
208
266
  `NulthClient cannot use the insecure mock prover on mainnet; a production prover is required (network must be 'testnet', got ${String(config.network)})`
209
267
  );
210
268
  }
211
- console.warn("NulthClient is using the insecure mock prover; proofs are not cryptographically sound");
269
+ config.logger?.(
270
+ "warn",
271
+ "NulthClient is using the insecure mock prover; proofs are not cryptographically sound"
272
+ );
212
273
  }
213
274
  config;
214
275
  policy;
@@ -297,12 +358,144 @@ var NulthClient = class {
297
358
  }
298
359
  };
299
360
 
361
+ // src/scheme.ts
362
+ var import_stellar_sdk2 = require("@stellar/stellar-sdk");
363
+ var NULTH_SIGNATURE_VERSION = "zk-v1";
364
+ var NULTH_SIGNER_MARKER = /* @__PURE__ */ Symbol.for("routedock.nulth.signer");
365
+ function isNulthStellarSigner(signer) {
366
+ return signer[NULTH_SIGNER_MARKER] === NULTH_SIGNATURE_VERSION;
367
+ }
368
+ var SCVAL_TYPES = {
369
+ nulth: ["symbol", "string"],
370
+ version: ["symbol", "u32"],
371
+ proof: ["symbol", "bytes"],
372
+ auth_digest: ["symbol", "string"],
373
+ allowlist_commitment: ["symbol", "string"],
374
+ cap_commitment: ["symbol", "string"],
375
+ payee_hash: ["symbol", "string"],
376
+ amount_stroops: ["symbol", "i128"],
377
+ day_bucket: ["symbol", "u64"]
378
+ };
379
+ function nulthProofScVal(signature) {
380
+ if (signature.nulth !== NULTH_SIGNATURE_VERSION) {
381
+ throw new RangeError(`unsupported Nulth signature format ${String(signature.nulth)}`);
382
+ }
383
+ const { proof } = signature;
384
+ const inputs = proof.publicInputs;
385
+ return import_stellar_sdk2.xdr.ScVal.scvVec([
386
+ (0, import_stellar_sdk2.nativeToScVal)(
387
+ {
388
+ nulth: signature.nulth,
389
+ version: proof.version,
390
+ proof: base64ToBytes(proof.proof),
391
+ auth_digest: inputs.authDigest,
392
+ allowlist_commitment: inputs.allowlistCommitment,
393
+ cap_commitment: inputs.capCommitment,
394
+ payee_hash: inputs.payeeHash,
395
+ amount_stroops: BigInt(inputs.amountStroops),
396
+ day_bucket: BigInt(inputs.dayBucket)
397
+ },
398
+ { type: SCVAL_TYPES }
399
+ )
400
+ ]);
401
+ }
402
+ function requiredString(value, field) {
403
+ if (typeof value !== "string") {
404
+ throw new TypeError(`Nulth signature field ${field} is not a string`);
405
+ }
406
+ return value;
407
+ }
408
+ function requiredBytes(value, field) {
409
+ if (!(value instanceof Uint8Array)) {
410
+ throw new TypeError(`Nulth signature field ${field} is not bytes`);
411
+ }
412
+ return bytesToBase64(value);
413
+ }
414
+ function requiredNumber(value, field) {
415
+ const num = Number(value);
416
+ if (!Number.isInteger(num)) {
417
+ throw new TypeError(`Nulth signature field ${field} is not an integer`);
418
+ }
419
+ return num;
420
+ }
421
+ function requiredDecimal(value, field) {
422
+ if (typeof value === "string") return value;
423
+ if (typeof value === "bigint" || typeof value === "number") return value.toString();
424
+ throw new TypeError(`Nulth signature field ${field} is not a decimal value`);
425
+ }
426
+ function decodeNulthProofScVal(signature) {
427
+ if (signature.switch() !== import_stellar_sdk2.xdr.ScValType.scvVec()) {
428
+ throw new TypeError("Nulth signature is not a vec");
429
+ }
430
+ const entries = signature.vec();
431
+ if (!entries || entries.length !== 1) {
432
+ throw new TypeError(`Nulth signature vec must hold exactly one map, got ${entries?.length ?? 0}`);
433
+ }
434
+ const fields = (0, import_stellar_sdk2.scValToNative)(entries[0]);
435
+ const nulth = requiredString(fields.nulth, "nulth");
436
+ if (nulth !== NULTH_SIGNATURE_VERSION) {
437
+ throw new TypeError(`unsupported Nulth signature format ${nulth}`);
438
+ }
439
+ const proof = {
440
+ version: requiredNumber(fields.version, "version"),
441
+ proof: requiredBytes(fields.proof, "proof"),
442
+ publicInputs: {
443
+ authDigest: requiredString(fields.auth_digest, "auth_digest"),
444
+ allowlistCommitment: requiredString(fields.allowlist_commitment, "allowlist_commitment"),
445
+ capCommitment: requiredString(fields.cap_commitment, "cap_commitment"),
446
+ payeeHash: requiredString(fields.payee_hash, "payee_hash"),
447
+ amountStroops: requiredDecimal(fields.amount_stroops, "amount_stroops"),
448
+ dayBucket: requiredDecimal(fields.day_bucket, "day_bucket")
449
+ }
450
+ };
451
+ return { nulth: NULTH_SIGNATURE_VERSION, proof };
452
+ }
453
+ async function nulthAuthorizeEntry(entry, signer, validUntilLedgerSeq, networkPassphrase) {
454
+ if (entry.credentials().switch() !== import_stellar_sdk2.xdr.SorobanCredentialsType.sorobanCredentialsAddress()) {
455
+ return entry;
456
+ }
457
+ if (typeof signer !== "function") {
458
+ throw new TypeError(
459
+ "nulthAuthorizeEntry needs a callback that returns the zk-v1 proof envelope; a Keypair can only produce an ed25519 signature"
460
+ );
461
+ }
462
+ if (!networkPassphrase) {
463
+ throw new TypeError("nulthAuthorizeEntry needs the network passphrase to build the preimage");
464
+ }
465
+ const clone = import_stellar_sdk2.xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR());
466
+ const credentials = clone.credentials().address();
467
+ const address = import_stellar_sdk2.Address.fromScAddress(credentials.address()).toString();
468
+ if (!address.startsWith("C")) {
469
+ throw new TypeError(
470
+ `nulthAuthorizeEntry signs Nulth contract accounts (C...) only, got ${address}`
471
+ );
472
+ }
473
+ credentials.signatureExpirationLedger(validUntilLedgerSeq);
474
+ const preimage = import_stellar_sdk2.xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(
475
+ new import_stellar_sdk2.xdr.HashIdPreimageSorobanAuthorization({
476
+ networkId: (0, import_stellar_sdk2.hash)(utf8ToBytes(networkPassphrase)),
477
+ nonce: credentials.nonce(),
478
+ invocation: clone.rootInvocation(),
479
+ signatureExpirationLedger: credentials.signatureExpirationLedger()
480
+ })
481
+ );
482
+ const signed = await signer(preimage);
483
+ credentials.signature(nulthProofScVal(decodeAuthSignature(envelopeBase64(signed))));
484
+ return clone;
485
+ }
486
+ function envelopeBase64(signed) {
487
+ if (signed instanceof Uint8Array) return bytesToBase64(signed);
488
+ if (signed instanceof ArrayBuffer) return bytesToBase64(new Uint8Array(signed));
489
+ throw new TypeError("Nulth signing callback must return the zk-v1 envelope as bytes");
490
+ }
491
+
300
492
  // src/signer.ts
301
493
  function createNulthSigner(config) {
302
494
  const client = new NulthClient(config);
303
495
  const nulthAccount = config.nulthAccount;
304
496
  return {
305
497
  address: nulthAccount,
498
+ [NULTH_SIGNER_MARKER]: NULTH_SIGNATURE_VERSION,
306
499
  signAuthEntry: async (authEntry) => {
307
500
  const ctx = config.paymentContext;
308
501
  if (!ctx) {
@@ -331,7 +524,7 @@ function paymentContextFromManifest(manifest, mode, ledgerSequence) {
331
524
  throw new Error(`manifest.pricing.${mode} missing`);
332
525
  }
333
526
  return {
334
- payee: manifest.payee,
527
+ payee: pricing.payee ?? manifest.payee,
335
528
  amountStroops: usdcToStroops(pricing.amount),
336
529
  assetContract: manifest.asset_contract,
337
530
  ledgerSequence
@@ -353,7 +546,10 @@ function createPolicyState(input) {
353
546
  // Annotate the CommonJS export names for ESM import in node:
354
547
  0 && (module.exports = {
355
548
  DEFAULT_PROVER,
549
+ NULTH_SIGNATURE_VERSION,
550
+ NULTH_SIGNER_MARKER,
356
551
  NulthClient,
552
+ NulthConfigError,
357
553
  NulthPolicyError,
358
554
  USDC_DECIMALS,
359
555
  assertAuthEntryMatchesContext,
@@ -364,9 +560,13 @@ function createPolicyState(input) {
364
560
  createPolicyState,
365
561
  dayBucket,
366
562
  decodeAuthSignature,
563
+ decodeNulthProofScVal,
367
564
  encodeAuthSignature,
368
565
  hashPayee,
369
566
  insecureMockProof,
567
+ isNulthStellarSigner,
568
+ nulthAuthorizeEntry,
569
+ nulthProofScVal,
370
570
  paymentContextFromManifest,
371
571
  setNulthPaymentContext,
372
572
  sha256Hex,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SignAuthEntry } from '@stellar/stellar-sdk/contract';
2
+ import { xdr, SigningCallback, Keypair } from '@stellar/stellar-sdk';
2
3
 
3
4
  /**
4
5
  * Off-chain policy state for a Nulth ZK account.
@@ -53,6 +54,8 @@ interface NulthAuthSignature {
53
54
  nulth: 'zk-v1';
54
55
  proof: NulthProof;
55
56
  }
57
+ /** Consumer-supplied diagnostic sink, compatible with RouteDockLogger. */
58
+ type NulthLogger = (level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>) => void;
56
59
  interface NulthClientConfig {
57
60
  /** Nulth smart account contract address (C...) — the payer */
58
61
  nulthAccount: string;
@@ -66,6 +69,13 @@ interface NulthClientConfig {
66
69
  policy: NulthPolicyState;
67
70
  /** Optional verifier contract deployed alongside the Nulth account */
68
71
  verifierContract?: string;
72
+ /** Log sink for the mock-prover warning. Defaults to no-op (silent). */
73
+ logger?: NulthLogger;
74
+ }
75
+ /** Invalid network or prover configuration, distinct from policy failures. */
76
+ declare class NulthConfigError extends Error {
77
+ readonly code: 'unsupported_prover' | 'unsupported_network';
78
+ constructor(code: NulthConfigError['code'], message: string);
69
79
  }
70
80
  declare class NulthPolicyError extends Error {
71
81
  readonly code: 'payee_not_allowed' | 'daily_cap_exceeded' | 'session_expired' | 'auth_entry_mismatch';
@@ -106,7 +116,7 @@ declare function usdcToStroops(amount: string): bigint;
106
116
  declare function stroopsToUsdc(units: bigint): string;
107
117
 
108
118
  /** SHA-256 hex digest */
109
- declare function sha256Hex(data: Buffer | string): string;
119
+ declare function sha256Hex(data: Uint8Array | string): string;
110
120
  /** Commitment to the daily cap — cap value never appears on-chain */
111
121
  declare function commitDailyCap(dailyCapStroops: bigint, witnessSecret: string): string;
112
122
  /** Commitment to sorted allowlist — individual payees never published */
@@ -165,10 +175,60 @@ declare class NulthClient {
165
175
  private enforcePolicy;
166
176
  }
167
177
 
178
+ /**
179
+ * A Nulth proof travels in the Soroban auth entry's signature field instead of
180
+ * an ed25519 signature, so stellar-sdk's default `authorizeEntry` (which decodes
181
+ * the credential address as an ed25519 key and therefore throws
182
+ * `invalid version byte. expected 48, got 16` for a `C...` payer) cannot be used.
183
+ */
184
+ /** Tag identifying the `zk-v1` signature layout, matching {@link NulthAuthSignature.nulth}. */
185
+ declare const NULTH_SIGNATURE_VERSION = "zk-v1";
186
+ /**
187
+ * Global-registry symbol set on signers produced by {@link createNulthSigner}, so
188
+ * an x402 client can pick the Nulth scheme without importing the signer factory.
189
+ * Registered globally because `@routedock/routedock` ships its own inlined copy of
190
+ * the signer (see #575); two copies of the same symbol must still compare equal.
191
+ */
192
+ declare const NULTH_SIGNER_MARKER: unique symbol;
193
+ /** Anything carrying the marker is payer-side Nulth: contract account + proof signature. */
194
+ interface NulthSignerMarker {
195
+ readonly [NULTH_SIGNER_MARKER]: typeof NULTH_SIGNATURE_VERSION;
196
+ }
197
+ declare function isNulthStellarSigner(signer: object): boolean;
198
+ /**
199
+ * Encode a Nulth proof as the `SorobanAddressCredentials.signature` a Nulth
200
+ * account contract's `__check_auth` reads: `vec[map]`, the same shape Stellar uses
201
+ * for account signatures (`vec[{public_key, signature}]`), with the proof fields
202
+ * flattened into the map so the contract can map each one to a public input
203
+ * without parsing JSON.
204
+ */
205
+ declare function nulthProofScVal(signature: NulthAuthSignature): xdr.ScVal;
206
+ /**
207
+ * Decode a signature ScVal written by {@link nulthProofScVal}. A Nulth account
208
+ * contract reads the same map; this is the client/provider-side mirror of that
209
+ * read, used to prove a signed entry round-trips.
210
+ */
211
+ declare function decodeNulthProofScVal(signature: xdr.ScVal): NulthAuthSignature;
212
+ /**
213
+ * Build the signature the Nulth account authorises: sign the authorization
214
+ * preimage through the Nulth signer, which runs the off-chain policy checks and
215
+ * returns the `zk-v1` envelope, then write the proof into the credentials.
216
+ *
217
+ * Mirrors stellar-base's `authorizeEntry` in the parts that matter — the preimage
218
+ * is built identically, so the digest the proof binds is the one the network
219
+ * recomputes — and replaces the ed25519 verification and `{public_key, signature}`
220
+ * vec with {@link nulthProofScVal}. The parameter list matches
221
+ * `typeof authorizeEntry` deliberately, so it can be handed to
222
+ * `AssembledTransaction.signAuthEntries` as its `authorizeEntry` override.
223
+ */
224
+ declare function nulthAuthorizeEntry(entry: xdr.SorobanAuthorizationEntry, signer: SigningCallback | Keypair, validUntilLedgerSeq: number, networkPassphrase?: string): Promise<xdr.SorobanAuthorizationEntry>;
225
+
168
226
  /** Compatible with @x402/stellar ClientStellarSigner */
169
227
  interface NulthStellarSigner {
170
228
  address: string;
171
229
  signAuthEntry: SignAuthEntry;
230
+ /** Marks this signer as Nulth so the x402 client selects the proof scheme. */
231
+ readonly [NULTH_SIGNER_MARKER]: typeof NULTH_SIGNATURE_VERSION;
172
232
  }
173
233
  interface NulthSignerConfig extends NulthClientConfig {
174
234
  /** Pending payment context set before x402 signs auth entries */
@@ -188,9 +248,11 @@ declare function paymentContextFromManifest(manifest: {
188
248
  pricing: {
189
249
  x402?: {
190
250
  amount: string;
251
+ payee?: string;
191
252
  };
192
253
  'mpp-charge'?: {
193
254
  amount: string;
255
+ payee?: string;
194
256
  };
195
257
  };
196
258
  }, mode: 'x402' | 'mpp-charge', ledgerSequence: number): Omit<PaymentAuthContext, 'authEntry'>;
@@ -203,4 +265,4 @@ declare function createPolicyState(input: {
203
265
  ledgerSequence?: number;
204
266
  }): NulthPolicyState;
205
267
 
206
- export { DEFAULT_PROVER, type NulthAuthSignature, NulthClient, type NulthClientConfig, NulthPolicyError, type NulthPolicyState, type NulthProof, type NulthSignerConfig, type NulthStellarSigner, type PaymentAuthContext, type ProverBackend, USDC_DECIMALS, assertAuthEntryMatchesContext, authDigestFromEntry, commitAllowlist, commitDailyCap, createNulthSigner, createPolicyState, dayBucket, decodeAuthSignature, encodeAuthSignature, hashPayee, insecureMockProof, paymentContextFromManifest, setNulthPaymentContext, sha256Hex, stroopsToUsdc, usdcToStroops };
268
+ export { DEFAULT_PROVER, NULTH_SIGNATURE_VERSION, NULTH_SIGNER_MARKER, type NulthAuthSignature, NulthClient, type NulthClientConfig, NulthConfigError, type NulthLogger, NulthPolicyError, type NulthPolicyState, type NulthProof, type NulthSignerConfig, type NulthSignerMarker, type NulthStellarSigner, type PaymentAuthContext, type ProverBackend, USDC_DECIMALS, assertAuthEntryMatchesContext, authDigestFromEntry, commitAllowlist, commitDailyCap, createNulthSigner, createPolicyState, dayBucket, decodeAuthSignature, decodeNulthProofScVal, encodeAuthSignature, hashPayee, insecureMockProof, isNulthStellarSigner, nulthAuthorizeEntry, nulthProofScVal, paymentContextFromManifest, setNulthPaymentContext, sha256Hex, stroopsToUsdc, usdcToStroops };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SignAuthEntry } from '@stellar/stellar-sdk/contract';
2
+ import { xdr, SigningCallback, Keypair } from '@stellar/stellar-sdk';
2
3
 
3
4
  /**
4
5
  * Off-chain policy state for a Nulth ZK account.
@@ -53,6 +54,8 @@ interface NulthAuthSignature {
53
54
  nulth: 'zk-v1';
54
55
  proof: NulthProof;
55
56
  }
57
+ /** Consumer-supplied diagnostic sink, compatible with RouteDockLogger. */
58
+ type NulthLogger = (level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>) => void;
56
59
  interface NulthClientConfig {
57
60
  /** Nulth smart account contract address (C...) — the payer */
58
61
  nulthAccount: string;
@@ -66,6 +69,13 @@ interface NulthClientConfig {
66
69
  policy: NulthPolicyState;
67
70
  /** Optional verifier contract deployed alongside the Nulth account */
68
71
  verifierContract?: string;
72
+ /** Log sink for the mock-prover warning. Defaults to no-op (silent). */
73
+ logger?: NulthLogger;
74
+ }
75
+ /** Invalid network or prover configuration, distinct from policy failures. */
76
+ declare class NulthConfigError extends Error {
77
+ readonly code: 'unsupported_prover' | 'unsupported_network';
78
+ constructor(code: NulthConfigError['code'], message: string);
69
79
  }
70
80
  declare class NulthPolicyError extends Error {
71
81
  readonly code: 'payee_not_allowed' | 'daily_cap_exceeded' | 'session_expired' | 'auth_entry_mismatch';
@@ -106,7 +116,7 @@ declare function usdcToStroops(amount: string): bigint;
106
116
  declare function stroopsToUsdc(units: bigint): string;
107
117
 
108
118
  /** SHA-256 hex digest */
109
- declare function sha256Hex(data: Buffer | string): string;
119
+ declare function sha256Hex(data: Uint8Array | string): string;
110
120
  /** Commitment to the daily cap — cap value never appears on-chain */
111
121
  declare function commitDailyCap(dailyCapStroops: bigint, witnessSecret: string): string;
112
122
  /** Commitment to sorted allowlist — individual payees never published */
@@ -165,10 +175,60 @@ declare class NulthClient {
165
175
  private enforcePolicy;
166
176
  }
167
177
 
178
+ /**
179
+ * A Nulth proof travels in the Soroban auth entry's signature field instead of
180
+ * an ed25519 signature, so stellar-sdk's default `authorizeEntry` (which decodes
181
+ * the credential address as an ed25519 key and therefore throws
182
+ * `invalid version byte. expected 48, got 16` for a `C...` payer) cannot be used.
183
+ */
184
+ /** Tag identifying the `zk-v1` signature layout, matching {@link NulthAuthSignature.nulth}. */
185
+ declare const NULTH_SIGNATURE_VERSION = "zk-v1";
186
+ /**
187
+ * Global-registry symbol set on signers produced by {@link createNulthSigner}, so
188
+ * an x402 client can pick the Nulth scheme without importing the signer factory.
189
+ * Registered globally because `@routedock/routedock` ships its own inlined copy of
190
+ * the signer (see #575); two copies of the same symbol must still compare equal.
191
+ */
192
+ declare const NULTH_SIGNER_MARKER: unique symbol;
193
+ /** Anything carrying the marker is payer-side Nulth: contract account + proof signature. */
194
+ interface NulthSignerMarker {
195
+ readonly [NULTH_SIGNER_MARKER]: typeof NULTH_SIGNATURE_VERSION;
196
+ }
197
+ declare function isNulthStellarSigner(signer: object): boolean;
198
+ /**
199
+ * Encode a Nulth proof as the `SorobanAddressCredentials.signature` a Nulth
200
+ * account contract's `__check_auth` reads: `vec[map]`, the same shape Stellar uses
201
+ * for account signatures (`vec[{public_key, signature}]`), with the proof fields
202
+ * flattened into the map so the contract can map each one to a public input
203
+ * without parsing JSON.
204
+ */
205
+ declare function nulthProofScVal(signature: NulthAuthSignature): xdr.ScVal;
206
+ /**
207
+ * Decode a signature ScVal written by {@link nulthProofScVal}. A Nulth account
208
+ * contract reads the same map; this is the client/provider-side mirror of that
209
+ * read, used to prove a signed entry round-trips.
210
+ */
211
+ declare function decodeNulthProofScVal(signature: xdr.ScVal): NulthAuthSignature;
212
+ /**
213
+ * Build the signature the Nulth account authorises: sign the authorization
214
+ * preimage through the Nulth signer, which runs the off-chain policy checks and
215
+ * returns the `zk-v1` envelope, then write the proof into the credentials.
216
+ *
217
+ * Mirrors stellar-base's `authorizeEntry` in the parts that matter — the preimage
218
+ * is built identically, so the digest the proof binds is the one the network
219
+ * recomputes — and replaces the ed25519 verification and `{public_key, signature}`
220
+ * vec with {@link nulthProofScVal}. The parameter list matches
221
+ * `typeof authorizeEntry` deliberately, so it can be handed to
222
+ * `AssembledTransaction.signAuthEntries` as its `authorizeEntry` override.
223
+ */
224
+ declare function nulthAuthorizeEntry(entry: xdr.SorobanAuthorizationEntry, signer: SigningCallback | Keypair, validUntilLedgerSeq: number, networkPassphrase?: string): Promise<xdr.SorobanAuthorizationEntry>;
225
+
168
226
  /** Compatible with @x402/stellar ClientStellarSigner */
169
227
  interface NulthStellarSigner {
170
228
  address: string;
171
229
  signAuthEntry: SignAuthEntry;
230
+ /** Marks this signer as Nulth so the x402 client selects the proof scheme. */
231
+ readonly [NULTH_SIGNER_MARKER]: typeof NULTH_SIGNATURE_VERSION;
172
232
  }
173
233
  interface NulthSignerConfig extends NulthClientConfig {
174
234
  /** Pending payment context set before x402 signs auth entries */
@@ -188,9 +248,11 @@ declare function paymentContextFromManifest(manifest: {
188
248
  pricing: {
189
249
  x402?: {
190
250
  amount: string;
251
+ payee?: string;
191
252
  };
192
253
  'mpp-charge'?: {
193
254
  amount: string;
255
+ payee?: string;
194
256
  };
195
257
  };
196
258
  }, mode: 'x402' | 'mpp-charge', ledgerSequence: number): Omit<PaymentAuthContext, 'authEntry'>;
@@ -203,4 +265,4 @@ declare function createPolicyState(input: {
203
265
  ledgerSequence?: number;
204
266
  }): NulthPolicyState;
205
267
 
206
- export { DEFAULT_PROVER, type NulthAuthSignature, NulthClient, type NulthClientConfig, NulthPolicyError, type NulthPolicyState, type NulthProof, type NulthSignerConfig, type NulthStellarSigner, type PaymentAuthContext, type ProverBackend, USDC_DECIMALS, assertAuthEntryMatchesContext, authDigestFromEntry, commitAllowlist, commitDailyCap, createNulthSigner, createPolicyState, dayBucket, decodeAuthSignature, encodeAuthSignature, hashPayee, insecureMockProof, paymentContextFromManifest, setNulthPaymentContext, sha256Hex, stroopsToUsdc, usdcToStroops };
268
+ export { DEFAULT_PROVER, NULTH_SIGNATURE_VERSION, NULTH_SIGNER_MARKER, type NulthAuthSignature, NulthClient, type NulthClientConfig, NulthConfigError, type NulthLogger, NulthPolicyError, type NulthPolicyState, type NulthProof, type NulthSignerConfig, type NulthSignerMarker, type NulthStellarSigner, type PaymentAuthContext, type ProverBackend, USDC_DECIMALS, assertAuthEntryMatchesContext, authDigestFromEntry, commitAllowlist, commitDailyCap, createNulthSigner, createPolicyState, dayBucket, decodeAuthSignature, decodeNulthProofScVal, encodeAuthSignature, hashPayee, insecureMockProof, isNulthStellarSigner, nulthAuthorizeEntry, nulthProofScVal, paymentContextFromManifest, setNulthPaymentContext, sha256Hex, stroopsToUsdc, usdcToStroops };
package/dist/index.js CHANGED
@@ -1,4 +1,12 @@
1
1
  // src/types.ts
2
+ var NulthConfigError = class extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "NulthConfigError";
7
+ this.code = code;
8
+ }
9
+ };
2
10
  var NulthPolicyError = class extends Error {
3
11
  code;
4
12
  constructor(code, message) {
@@ -9,9 +17,51 @@ var NulthPolicyError = class extends Error {
9
17
  };
10
18
 
11
19
  // src/proof.ts
12
- import { createHash } from "crypto";
13
20
  import { Address, hash, scValToNative, xdr } from "@stellar/stellar-sdk";
14
21
 
22
+ // src/encoding.ts
23
+ import { sha256 as nobleSha256, sha512 as nobleSha512 } from "@noble/hashes/sha2.js";
24
+ var encoder = new TextEncoder();
25
+ var decoder = new TextDecoder();
26
+ function utf8ToBytes(text) {
27
+ return encoder.encode(text);
28
+ }
29
+ function bytesToUtf8(bytes) {
30
+ return decoder.decode(bytes);
31
+ }
32
+ function bytesToBase64(bytes) {
33
+ const chunk = 32768;
34
+ let binary = "";
35
+ for (let i = 0; i < bytes.length; i += chunk) {
36
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
37
+ }
38
+ return btoa(binary);
39
+ }
40
+ function base64ToBytes(b64) {
41
+ const end = b64.indexOf("=");
42
+ let clean = (end === -1 ? b64 : b64.slice(0, end)).replace(/-/g, "+").replace(/_/g, "/").replace(/[^A-Za-z0-9+/]/g, "");
43
+ if (clean.length % 4 === 1) clean = clean.slice(0, -1);
44
+ const binary = atob(clean);
45
+ const bytes = new Uint8Array(binary.length);
46
+ for (let i = 0; i < binary.length; i++) {
47
+ bytes[i] = binary.charCodeAt(i);
48
+ }
49
+ return bytes;
50
+ }
51
+ function bytesToHex(bytes) {
52
+ let out = "";
53
+ for (let i = 0; i < bytes.length; i++) {
54
+ out += bytes[i].toString(16).padStart(2, "0");
55
+ }
56
+ return out;
57
+ }
58
+ function sha256Bytes(data) {
59
+ return nobleSha256(typeof data === "string" ? utf8ToBytes(data) : data);
60
+ }
61
+ function sha512Bytes(data) {
62
+ return nobleSha512(typeof data === "string" ? utf8ToBytes(data) : data);
63
+ }
64
+
15
65
  // src/usdc.ts
16
66
  var USDC_DECIMALS = 7;
17
67
  var USDC_SCALE = 10n ** BigInt(USDC_DECIMALS);
@@ -47,8 +97,7 @@ function stroopsToUsdc(units) {
47
97
 
48
98
  // src/proof.ts
49
99
  function sha256Hex(data) {
50
- const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
51
- return createHash("sha256").update(buf).digest("hex");
100
+ return bytesToHex(sha256Bytes(data));
52
101
  }
53
102
  function commitDailyCap(dailyCapStroops, witnessSecret) {
54
103
  return sha256Hex(`${witnessSecret}:cap:${dailyCapStroops.toString()}`);
@@ -64,7 +113,7 @@ function dayBucket(ledgerSequence) {
64
113
  return Math.floor(ledgerSequence / 17280);
65
114
  }
66
115
  function authDigestFromEntry(authEntryBase64) {
67
- const entryHash = hash(Buffer.from(authEntryBase64, "base64"));
116
+ const entryHash = hash(base64ToBytes(authEntryBase64));
68
117
  return sha256Hex(entryHash);
69
118
  }
70
119
  function assertAuthEntryMatchesContext(authEntry, from, ctx) {
@@ -130,17 +179,17 @@ function assertAuthEntryMatchesContext(authEntry, from, ctx) {
130
179
  }
131
180
  var DEFAULT_PROVER = "mock";
132
181
  function insecureMockProof(preimage) {
133
- return createHash("sha512").update(`nulth:${preimage}`).digest("base64");
182
+ return bytesToBase64(sha512Bytes(`nulth:${preimage}`));
134
183
  }
135
184
  function encodeAuthSignature(proof) {
136
185
  const payload = {
137
186
  nulth: "zk-v1",
138
187
  proof
139
188
  };
140
- return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
189
+ return bytesToBase64(utf8ToBytes(JSON.stringify(payload)));
141
190
  }
142
191
  function decodeAuthSignature(encoded) {
143
- const raw = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
192
+ const raw = JSON.parse(bytesToUtf8(base64ToBytes(encoded)));
144
193
  if (typeof raw !== "object" || raw === null || raw.nulth !== "zk-v1") {
145
194
  throw new Error("Invalid Nulth ZK auth signature");
146
195
  }
@@ -154,16 +203,21 @@ var NulthClient = class {
154
203
  this.policy = { ...config.policy };
155
204
  this.prover = config.prover ?? DEFAULT_PROVER;
156
205
  if (this.prover !== "mock") {
157
- throw new Error(
206
+ throw new NulthConfigError(
207
+ "unsupported_prover",
158
208
  `NulthClient: unknown prover backend ${String(config.prover)}; only 'mock' is implemented`
159
209
  );
160
210
  }
161
211
  if (config.network !== "testnet") {
162
- throw new Error(
212
+ throw new NulthConfigError(
213
+ "unsupported_network",
163
214
  `NulthClient cannot use the insecure mock prover on mainnet; a production prover is required (network must be 'testnet', got ${String(config.network)})`
164
215
  );
165
216
  }
166
- console.warn("NulthClient is using the insecure mock prover; proofs are not cryptographically sound");
217
+ config.logger?.(
218
+ "warn",
219
+ "NulthClient is using the insecure mock prover; proofs are not cryptographically sound"
220
+ );
167
221
  }
168
222
  config;
169
223
  policy;
@@ -252,12 +306,144 @@ var NulthClient = class {
252
306
  }
253
307
  };
254
308
 
309
+ // src/scheme.ts
310
+ import { Address as Address2, hash as hash2, nativeToScVal, scValToNative as scValToNative2, xdr as xdr2 } from "@stellar/stellar-sdk";
311
+ var NULTH_SIGNATURE_VERSION = "zk-v1";
312
+ var NULTH_SIGNER_MARKER = /* @__PURE__ */ Symbol.for("routedock.nulth.signer");
313
+ function isNulthStellarSigner(signer) {
314
+ return signer[NULTH_SIGNER_MARKER] === NULTH_SIGNATURE_VERSION;
315
+ }
316
+ var SCVAL_TYPES = {
317
+ nulth: ["symbol", "string"],
318
+ version: ["symbol", "u32"],
319
+ proof: ["symbol", "bytes"],
320
+ auth_digest: ["symbol", "string"],
321
+ allowlist_commitment: ["symbol", "string"],
322
+ cap_commitment: ["symbol", "string"],
323
+ payee_hash: ["symbol", "string"],
324
+ amount_stroops: ["symbol", "i128"],
325
+ day_bucket: ["symbol", "u64"]
326
+ };
327
+ function nulthProofScVal(signature) {
328
+ if (signature.nulth !== NULTH_SIGNATURE_VERSION) {
329
+ throw new RangeError(`unsupported Nulth signature format ${String(signature.nulth)}`);
330
+ }
331
+ const { proof } = signature;
332
+ const inputs = proof.publicInputs;
333
+ return xdr2.ScVal.scvVec([
334
+ nativeToScVal(
335
+ {
336
+ nulth: signature.nulth,
337
+ version: proof.version,
338
+ proof: base64ToBytes(proof.proof),
339
+ auth_digest: inputs.authDigest,
340
+ allowlist_commitment: inputs.allowlistCommitment,
341
+ cap_commitment: inputs.capCommitment,
342
+ payee_hash: inputs.payeeHash,
343
+ amount_stroops: BigInt(inputs.amountStroops),
344
+ day_bucket: BigInt(inputs.dayBucket)
345
+ },
346
+ { type: SCVAL_TYPES }
347
+ )
348
+ ]);
349
+ }
350
+ function requiredString(value, field) {
351
+ if (typeof value !== "string") {
352
+ throw new TypeError(`Nulth signature field ${field} is not a string`);
353
+ }
354
+ return value;
355
+ }
356
+ function requiredBytes(value, field) {
357
+ if (!(value instanceof Uint8Array)) {
358
+ throw new TypeError(`Nulth signature field ${field} is not bytes`);
359
+ }
360
+ return bytesToBase64(value);
361
+ }
362
+ function requiredNumber(value, field) {
363
+ const num = Number(value);
364
+ if (!Number.isInteger(num)) {
365
+ throw new TypeError(`Nulth signature field ${field} is not an integer`);
366
+ }
367
+ return num;
368
+ }
369
+ function requiredDecimal(value, field) {
370
+ if (typeof value === "string") return value;
371
+ if (typeof value === "bigint" || typeof value === "number") return value.toString();
372
+ throw new TypeError(`Nulth signature field ${field} is not a decimal value`);
373
+ }
374
+ function decodeNulthProofScVal(signature) {
375
+ if (signature.switch() !== xdr2.ScValType.scvVec()) {
376
+ throw new TypeError("Nulth signature is not a vec");
377
+ }
378
+ const entries = signature.vec();
379
+ if (!entries || entries.length !== 1) {
380
+ throw new TypeError(`Nulth signature vec must hold exactly one map, got ${entries?.length ?? 0}`);
381
+ }
382
+ const fields = scValToNative2(entries[0]);
383
+ const nulth = requiredString(fields.nulth, "nulth");
384
+ if (nulth !== NULTH_SIGNATURE_VERSION) {
385
+ throw new TypeError(`unsupported Nulth signature format ${nulth}`);
386
+ }
387
+ const proof = {
388
+ version: requiredNumber(fields.version, "version"),
389
+ proof: requiredBytes(fields.proof, "proof"),
390
+ publicInputs: {
391
+ authDigest: requiredString(fields.auth_digest, "auth_digest"),
392
+ allowlistCommitment: requiredString(fields.allowlist_commitment, "allowlist_commitment"),
393
+ capCommitment: requiredString(fields.cap_commitment, "cap_commitment"),
394
+ payeeHash: requiredString(fields.payee_hash, "payee_hash"),
395
+ amountStroops: requiredDecimal(fields.amount_stroops, "amount_stroops"),
396
+ dayBucket: requiredDecimal(fields.day_bucket, "day_bucket")
397
+ }
398
+ };
399
+ return { nulth: NULTH_SIGNATURE_VERSION, proof };
400
+ }
401
+ async function nulthAuthorizeEntry(entry, signer, validUntilLedgerSeq, networkPassphrase) {
402
+ if (entry.credentials().switch() !== xdr2.SorobanCredentialsType.sorobanCredentialsAddress()) {
403
+ return entry;
404
+ }
405
+ if (typeof signer !== "function") {
406
+ throw new TypeError(
407
+ "nulthAuthorizeEntry needs a callback that returns the zk-v1 proof envelope; a Keypair can only produce an ed25519 signature"
408
+ );
409
+ }
410
+ if (!networkPassphrase) {
411
+ throw new TypeError("nulthAuthorizeEntry needs the network passphrase to build the preimage");
412
+ }
413
+ const clone = xdr2.SorobanAuthorizationEntry.fromXDR(entry.toXDR());
414
+ const credentials = clone.credentials().address();
415
+ const address = Address2.fromScAddress(credentials.address()).toString();
416
+ if (!address.startsWith("C")) {
417
+ throw new TypeError(
418
+ `nulthAuthorizeEntry signs Nulth contract accounts (C...) only, got ${address}`
419
+ );
420
+ }
421
+ credentials.signatureExpirationLedger(validUntilLedgerSeq);
422
+ const preimage = xdr2.HashIdPreimage.envelopeTypeSorobanAuthorization(
423
+ new xdr2.HashIdPreimageSorobanAuthorization({
424
+ networkId: hash2(utf8ToBytes(networkPassphrase)),
425
+ nonce: credentials.nonce(),
426
+ invocation: clone.rootInvocation(),
427
+ signatureExpirationLedger: credentials.signatureExpirationLedger()
428
+ })
429
+ );
430
+ const signed = await signer(preimage);
431
+ credentials.signature(nulthProofScVal(decodeAuthSignature(envelopeBase64(signed))));
432
+ return clone;
433
+ }
434
+ function envelopeBase64(signed) {
435
+ if (signed instanceof Uint8Array) return bytesToBase64(signed);
436
+ if (signed instanceof ArrayBuffer) return bytesToBase64(new Uint8Array(signed));
437
+ throw new TypeError("Nulth signing callback must return the zk-v1 envelope as bytes");
438
+ }
439
+
255
440
  // src/signer.ts
256
441
  function createNulthSigner(config) {
257
442
  const client = new NulthClient(config);
258
443
  const nulthAccount = config.nulthAccount;
259
444
  return {
260
445
  address: nulthAccount,
446
+ [NULTH_SIGNER_MARKER]: NULTH_SIGNATURE_VERSION,
261
447
  signAuthEntry: async (authEntry) => {
262
448
  const ctx = config.paymentContext;
263
449
  if (!ctx) {
@@ -286,7 +472,7 @@ function paymentContextFromManifest(manifest, mode, ledgerSequence) {
286
472
  throw new Error(`manifest.pricing.${mode} missing`);
287
473
  }
288
474
  return {
289
- payee: manifest.payee,
475
+ payee: pricing.payee ?? manifest.payee,
290
476
  amountStroops: usdcToStroops(pricing.amount),
291
477
  assetContract: manifest.asset_contract,
292
478
  ledgerSequence
@@ -307,7 +493,10 @@ function createPolicyState(input) {
307
493
  }
308
494
  export {
309
495
  DEFAULT_PROVER,
496
+ NULTH_SIGNATURE_VERSION,
497
+ NULTH_SIGNER_MARKER,
310
498
  NulthClient,
499
+ NulthConfigError,
311
500
  NulthPolicyError,
312
501
  USDC_DECIMALS,
313
502
  assertAuthEntryMatchesContext,
@@ -318,9 +507,13 @@ export {
318
507
  createPolicyState,
319
508
  dayBucket,
320
509
  decodeAuthSignature,
510
+ decodeNulthProofScVal,
321
511
  encodeAuthSignature,
322
512
  hashPayee,
323
513
  insecureMockProof,
514
+ isNulthStellarSigner,
515
+ nulthAuthorizeEntry,
516
+ nulthProofScVal,
324
517
  paymentContextFromManifest,
325
518
  setNulthPaymentContext,
326
519
  sha256Hex,
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "@routedock/nulth-sdk",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Off-chain ZK proof builder for Nulth smart accounts on Stellar",
5
5
  "license": "MIT",
6
+ "homepage": "https://github.com/winsznx/routedock/tree/main/packages/nulth-sdk#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/winsznx/routedock/issues"
9
+ },
10
+ "publishConfig": {
11
+ "provenance": true
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/winsznx/routedock.git",
16
+ "directory": "packages/nulth-sdk"
17
+ },
6
18
  "type": "module",
7
19
  "main": "dist/index.cjs",
8
20
  "module": "dist/index.js",
@@ -24,6 +36,7 @@
24
36
  }
25
37
  },
26
38
  "dependencies": {
39
+ "@noble/hashes": "^1.8.0",
27
40
  "@stellar/stellar-sdk": "^14.6.1"
28
41
  },
29
42
  "devDependencies": {
@@ -32,9 +45,23 @@
32
45
  "tsx": "^4.22.3",
33
46
  "typescript": "^5.4.5"
34
47
  },
48
+ "engines": {
49
+ "node": ">=22"
50
+ },
51
+ "keywords": [
52
+ "stellar",
53
+ "soroban",
54
+ "usdc",
55
+ "x402",
56
+ "mpp",
57
+ "payments",
58
+ "ai-agents",
59
+ "zk"
60
+ ],
35
61
  "scripts": {
36
62
  "build": "tsup",
37
63
  "typecheck": "tsc --noEmit",
38
- "test": "tsx --test src/__tests__/nulth.test.ts"
64
+ "test": "tsx --test src/__tests__/*.test.ts",
65
+ "check:readme": "node ../../scripts/check-nulth-readme.mjs"
39
66
  }
40
67
  }