@orbinum/sdk 1.4.0 → 2.0.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/dist/index.mjs CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  bytesToBjjScalar,
30
30
  canPairWith,
31
31
  clearKnownEphWindow,
32
+ collectOutgoingFacts,
32
33
  commitmentHexOf,
33
34
  computeNoteCommitment,
34
35
  computeNullifier,
@@ -59,6 +60,7 @@ import {
59
60
  hexToBigint,
60
61
  hexToNumber,
61
62
  isAbortError,
63
+ isHexOfLength,
62
64
  isSpendable,
63
65
  isValidLeafIndex,
64
66
  leHexToBigint,
@@ -77,7 +79,7 @@ import {
77
79
  tryDecryptNote,
78
80
  tryDecryptNoteVerbose,
79
81
  tryRecoverOutgoing
80
- } from "./chunk-A2ZRMEYW.mjs";
82
+ } from "./chunk-VYKKBXOE.mjs";
81
83
 
82
84
  // src/foundation/encoding/base64.ts
83
85
  var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -382,6 +384,7 @@ var SLIP_DOMAIN = new TextEncoder().encode("orbinum-payment-slip-v1");
382
384
  var NONCE_PREFIX = new TextEncoder().encode("SLP1");
383
385
  var EPH_PK_SIZE = 32;
384
386
  var NONCE_SUFFIX_SIZE = 8;
387
+ var MAX_SLIP_ENVELOPE_SIZE = 4096;
385
388
  function deriveSlipKey(sharedSecret) {
386
389
  return hkdf(sha256, sharedSecret, void 0, SLIP_DOMAIN, 32);
387
390
  }
@@ -416,6 +419,7 @@ function sealPaymentSlip(recipientIvkPacked, fields) {
416
419
  }
417
420
  function openPaymentSlip(recipientIvsk, envelope) {
418
421
  if (envelope.length < EPH_PK_SIZE + NONCE_SUFFIX_SIZE + 16) return null;
422
+ if (envelope.length > MAX_SLIP_ENVELOPE_SIZE) return null;
419
423
  try {
420
424
  const ephPkPacked = envelope.subarray(0, EPH_PK_SIZE);
421
425
  const ephPkPoint = unpackPoint(bytesToBigintLE(ephPkPacked));
@@ -427,11 +431,19 @@ function openPaymentSlip(recipientIvsk, envelope) {
427
431
  const sealed = envelope.subarray(EPH_PK_SIZE + NONCE_SUFFIX_SIZE);
428
432
  const cipher = chacha20poly1305(slipKey, buildNonce(suffix));
429
433
  const plaintext = cipher.decrypt(sealed);
430
- const fields = JSON.parse(new TextDecoder().decode(plaintext));
431
- if (typeof fields.commitmentHex !== "string" || typeof fields.encryptedMemo !== "string") {
432
- return null;
433
- }
434
- return fields;
434
+ const parsed = JSON.parse(new TextDecoder().decode(plaintext));
435
+ if (!isHexOfLength(parsed["commitmentHex"], 32)) return null;
436
+ if (!isHexOfLength(parsed["encryptedMemo"], ENCRYPTED_MEMO_SIZE)) return null;
437
+ const leafIndex = parsed["leafIndex"];
438
+ if (leafIndex !== void 0 && !isValidLeafIndex(leafIndex)) return null;
439
+ const rawTxHash = parsed["txHash"];
440
+ const txHash = isHexOfLength(rawTxHash, 32) ? rawTxHash : void 0;
441
+ return {
442
+ commitmentHex: parsed["commitmentHex"],
443
+ encryptedMemo: parsed["encryptedMemo"],
444
+ ...leafIndex !== void 0 ? { leafIndex } : {},
445
+ ...txHash !== void 0 ? { txHash } : {}
446
+ };
435
447
  } catch {
436
448
  return null;
437
449
  }
@@ -1536,11 +1548,17 @@ var SubstrateClient = class _SubstrateClient {
1536
1548
 
1537
1549
  // src/chain/evm/EvmClient.ts
1538
1550
  var EvmClient = class {
1539
- /** @param rpcUrl - HTTP URL of the EVM JSON-RPC endpoint (e.g. `"http://localhost:9933"`). */
1540
- constructor(rpcUrl) {
1551
+ /**
1552
+ * @param rpcUrl - HTTP URL of the EVM JSON-RPC endpoint (e.g. `"http://localhost:9933"`).
1553
+ * @param peerRpcUrl - Optional second endpoint, used only to tell a genuinely
1554
+ * pending transaction from one stranded on `rpcUrl` alone. See `waitForReceipt`.
1555
+ */
1556
+ constructor(rpcUrl, peerRpcUrl) {
1541
1557
  this.rpcUrl = rpcUrl;
1558
+ this.peerRpcUrl = peerRpcUrl;
1542
1559
  }
1543
1560
  rpcUrl;
1561
+ peerRpcUrl;
1544
1562
  /**
1545
1563
  * Performs a single JSON-RPC call and returns the typed result.
1546
1564
  * Throws on HTTP errors, RPC-level errors, or a `null` result.
@@ -1598,10 +1616,18 @@ var EvmClient = class {
1598
1616
  const hex = await this.request("eth_getTransactionCount", [address, "latest"]);
1599
1617
  return hexToNumber(hex);
1600
1618
  }
1601
- /** Returns the current gas price in wei. */
1602
- async getGasPrice() {
1619
+ /**
1620
+ * Returns the current gas price in wei, padded by `bumpPercent`.
1621
+ *
1622
+ * `eth_gasPrice` reports the base fee exactly, and the base fee moves
1623
+ * between signing and the pool's next revalidation. A transaction priced at
1624
+ * the bare minimum is evicted as `GasPriceTooLow` the moment it rises, which
1625
+ * leaves every later nonce from that account stranded in the future queue.
1626
+ * The default 25% pad absorbs the usual movement.
1627
+ */
1628
+ async getGasPrice(bumpPercent = 25) {
1603
1629
  const hex = await this.request("eth_gasPrice", []);
1604
- return hexToBigint(hex);
1630
+ return hexToBigint(hex) * BigInt(100 + bumpPercent) / 100n;
1605
1631
  }
1606
1632
  /** Submits a signed raw transaction. Returns the transaction hash. */
1607
1633
  async sendRawTransaction(signedHex) {
@@ -1719,10 +1745,41 @@ var EvmClient = class {
1719
1745
  deadline = Math.min(deadline + timeoutMs, hardDeadline);
1720
1746
  }
1721
1747
  }
1748
+ if (await this.isStrandedOnThisNode(txHash)) {
1749
+ throw new Error(
1750
+ `Transaction dropped from the tx pool (never propagated beyond the submitting node, ${Date.now() - start}ms): ${txHash}`
1751
+ );
1752
+ }
1722
1753
  throw new Error(
1723
1754
  `Transaction still pending after ${Date.now() - start}ms: ${txHash} \u2014 it may still confirm; check the hash on the explorer before retrying`
1724
1755
  );
1725
1756
  }
1757
+ /**
1758
+ * True when `rpcUrl` knows the transaction but the configured peer does not.
1759
+ *
1760
+ * Returns false without a peer configured, and on any peer error — an
1761
+ * unreachable peer is not evidence that a live transaction is stranded.
1762
+ */
1763
+ async isStrandedOnThisNode(txHash) {
1764
+ if (!this.peerRpcUrl) return false;
1765
+ try {
1766
+ const res = await postJsonWithRetry(
1767
+ this.peerRpcUrl,
1768
+ JSON.stringify({
1769
+ id: 1,
1770
+ jsonrpc: "2.0",
1771
+ method: "eth_getTransactionByHash",
1772
+ params: [txHash]
1773
+ })
1774
+ );
1775
+ if (!res.ok) return false;
1776
+ const json = await res.json();
1777
+ if (json.error) return false;
1778
+ return json.result === null;
1779
+ } catch {
1780
+ return false;
1781
+ }
1782
+ }
1726
1783
  };
1727
1784
 
1728
1785
  // src/chain/evm/explorer/EvmExplorer.ts
@@ -2441,6 +2498,23 @@ function padTo32Multiple(data) {
2441
2498
  padded.set(data);
2442
2499
  return padded;
2443
2500
  }
2501
+ function bytes32Slot(value) {
2502
+ if (value.length !== 32) {
2503
+ throw new Error(`encodeAbi: bytes32 needs 32 bytes, got ${value.length}`);
2504
+ }
2505
+ const slot = new Uint8Array(32);
2506
+ slot.set(value);
2507
+ return slot;
2508
+ }
2509
+ function addressSlot(address) {
2510
+ const clean = address.startsWith("0x") ? address.slice(2) : address;
2511
+ if (clean.length > 40) {
2512
+ throw new Error(`encodeAbi: address needs at most 20 bytes, got ${clean.length / 2}`);
2513
+ }
2514
+ const slot = new Uint8Array(32);
2515
+ slot.set(fromHex("0x" + clean.padStart(40, "0")), 12);
2516
+ return slot;
2517
+ }
2444
2518
  function encodeStaticParam(param) {
2445
2519
  const buf = new Uint8Array(32);
2446
2520
  switch (param.type) {
@@ -2448,14 +2522,10 @@ function encodeStaticParam(param) {
2448
2522
  return bigintTo32Be(param.value);
2449
2523
  }
2450
2524
  case "bytes32": {
2451
- buf.set(param.value.slice(0, 32));
2452
- return buf;
2525
+ return bytes32Slot(param.value);
2453
2526
  }
2454
2527
  case "address": {
2455
- const clean = param.value.startsWith("0x") ? param.value.slice(2) : param.value;
2456
- const bytes = fromHex("0x" + clean.padStart(40, "0"));
2457
- buf.set(bytes, 12);
2458
- return buf;
2528
+ return addressSlot(param.value);
2459
2529
  }
2460
2530
  case "bool": {
2461
2531
  buf[31] = param.value ? 1 : 0;
@@ -2478,25 +2548,13 @@ function encodeDynamicParam(param) {
2478
2548
  return concat([bigintTo32Be(BigInt(data.length)), padTo32Multiple(data)]);
2479
2549
  }
2480
2550
  case "bytes32[]": {
2481
- const n = param.value.length;
2482
- const parts = [bigintTo32Be(BigInt(n))];
2483
- for (const b32 of param.value) {
2484
- const slot = new Uint8Array(32);
2485
- slot.set(b32.slice(0, 32));
2486
- parts.push(slot);
2487
- }
2551
+ const parts = [bigintTo32Be(BigInt(param.value.length))];
2552
+ for (const b32 of param.value) parts.push(bytes32Slot(b32));
2488
2553
  return concat(parts);
2489
2554
  }
2490
2555
  case "address[]": {
2491
- const n = param.value.length;
2492
- const parts = [bigintTo32Be(BigInt(n))];
2493
- for (const addr of param.value) {
2494
- const slot = new Uint8Array(32);
2495
- const clean = addr.startsWith("0x") ? addr.slice(2) : addr;
2496
- const bytes = fromHex("0x" + clean.padStart(40, "0"));
2497
- slot.set(bytes, 12);
2498
- parts.push(slot);
2499
- }
2556
+ const parts = [bigintTo32Be(BigInt(param.value.length))];
2557
+ for (const addr of param.value) parts.push(addressSlot(addr));
2500
2558
  return concat(parts);
2501
2559
  }
2502
2560
  case "bytes[]": {
@@ -2552,6 +2610,122 @@ function decodeUint(data, offset = 0) {
2552
2610
  return result;
2553
2611
  }
2554
2612
 
2613
+ // src/chain/evm/precompiles/shieldedPoolCalldata.ts
2614
+ var CLAIM_PUBLIC_SIGNALS_SIZE = 76;
2615
+ function bytes32(hex, field) {
2616
+ if (!isHexOfLength(hex, 32)) {
2617
+ throw new Error(`${field}: expected a 0x-prefixed 32-byte hex string, got ${hex}`);
2618
+ }
2619
+ return fromHex(hex);
2620
+ }
2621
+ function uint32(value, field) {
2622
+ if (!Number.isInteger(value) || value < 0 || value > 4294967295) {
2623
+ throw new Error(`${field}: expected a uint32 (0..4294967295), got ${value}`);
2624
+ }
2625
+ return BigInt(value);
2626
+ }
2627
+ function accountId32(address, field) {
2628
+ const raw = address.startsWith("0x") ? address.slice(2) : address;
2629
+ if (raw.length > 64) {
2630
+ throw new Error(`${field}: expected at most 32 bytes, got ${raw.length / 2}`);
2631
+ }
2632
+ return bytes32("0x" + raw.padEnd(64, "0"), field);
2633
+ }
2634
+ function buildShieldCalldata(params) {
2635
+ EncryptedMemo.validate(params.encryptedMemo, "buildShieldCalldata.encryptedMemo");
2636
+ const commitment = bytes32(params.commitment, "buildShieldCalldata.commitment");
2637
+ return encodeHex(
2638
+ SP_SEL.SHIELD,
2639
+ { type: "uint", value: uint32(params.assetId, "buildShieldCalldata.assetId") },
2640
+ { type: "bytes32", value: commitment },
2641
+ { type: "bytes", value: params.encryptedMemo }
2642
+ );
2643
+ }
2644
+ function buildPrivateTransferCalldata(params) {
2645
+ const nullifiers = params.inputs.map(
2646
+ (input, i) => bytes32(input.nullifier, `buildPrivateTransferCalldata.inputs[${i}].nullifier`)
2647
+ );
2648
+ const commitments = params.outputs.map(
2649
+ (output, i) => bytes32(output.commitment, `buildPrivateTransferCalldata.outputs[${i}].commitment`)
2650
+ );
2651
+ const memos = params.outputs.map((output, i) => {
2652
+ EncryptedMemo.validate(
2653
+ output.encryptedMemo,
2654
+ `buildPrivateTransferCalldata.outputs[${i}].encryptedMemo`
2655
+ );
2656
+ return output.encryptedMemo;
2657
+ });
2658
+ const root = bytes32(params.merkleRoot, "buildPrivateTransferCalldata.merkleRoot");
2659
+ return encodeHex(
2660
+ SP_SEL.PRIVATE_TRANSFER,
2661
+ { type: "bytes", value: params.proof },
2662
+ { type: "bytes32", value: root },
2663
+ { type: "bytes32[]", value: nullifiers },
2664
+ { type: "bytes32[]", value: commitments },
2665
+ { type: "bytes[]", value: memos },
2666
+ { type: "uint", value: uint32(params.assetId, "buildPrivateTransferCalldata.assetId") },
2667
+ { type: "uint", value: params.fee ?? 0n },
2668
+ {
2669
+ type: "uint",
2670
+ value: uint32(params.circuitVersion, "buildPrivateTransferCalldata.circuitVersion")
2671
+ }
2672
+ );
2673
+ }
2674
+ function buildUnshieldCalldata(params) {
2675
+ const root = bytes32(params.merkleRoot, "buildUnshieldCalldata.merkleRoot");
2676
+ const nullifier = bytes32(params.nullifier, "buildUnshieldCalldata.nullifier");
2677
+ const recipient = accountId32(
2678
+ params.recipientAddress,
2679
+ "buildUnshieldCalldata.recipientAddress"
2680
+ );
2681
+ const changeCommitment = bytes32(
2682
+ params.changeCommitment ?? "0x" + "00".repeat(32),
2683
+ "buildUnshieldCalldata.changeCommitment"
2684
+ );
2685
+ const changeEncryptedMemo = params.changeEncryptedMemo ?? new Uint8Array();
2686
+ return encodeHex(
2687
+ SP_SEL.UNSHIELD,
2688
+ { type: "bytes", value: params.proof },
2689
+ { type: "bytes32", value: root },
2690
+ { type: "bytes32", value: nullifier },
2691
+ { type: "uint", value: uint32(params.assetId, "buildUnshieldCalldata.assetId") },
2692
+ { type: "uint", value: params.amount },
2693
+ { type: "bytes32", value: recipient },
2694
+ { type: "uint", value: params.fee ?? 0n },
2695
+ { type: "bytes32", value: changeCommitment },
2696
+ { type: "bytes", value: changeEncryptedMemo },
2697
+ {
2698
+ type: "uint",
2699
+ value: uint32(params.circuitVersion, "buildUnshieldCalldata.circuitVersion")
2700
+ }
2701
+ );
2702
+ }
2703
+ function buildClaimShieldedFeesCalldata(params) {
2704
+ EncryptedMemo.validate(params.encryptedMemo, "buildClaimShieldedFeesCalldata.encryptedMemo");
2705
+ if (params.proof.length === 0) {
2706
+ throw new Error("claimShieldedFees: proof must not be empty");
2707
+ }
2708
+ if (params.publicSignals.length !== CLAIM_PUBLIC_SIGNALS_SIZE) {
2709
+ throw new Error(
2710
+ `claimShieldedFees: publicSignals must be ${CLAIM_PUBLIC_SIGNALS_SIZE} bytes, got ${params.publicSignals.length}`
2711
+ );
2712
+ }
2713
+ const commitment = bytes32(params.commitment, "buildClaimShieldedFeesCalldata.commitment");
2714
+ return encodeHex(
2715
+ SP_SEL.CLAIM_SHIELDED_FEES,
2716
+ { type: "bytes32", value: commitment },
2717
+ { type: "uint", value: params.amount },
2718
+ { type: "uint", value: uint32(params.assetId, "buildClaimShieldedFeesCalldata.assetId") },
2719
+ { type: "bytes", value: params.encryptedMemo },
2720
+ { type: "bytes", value: params.proof },
2721
+ { type: "bytes", value: params.publicSignals },
2722
+ {
2723
+ type: "uint",
2724
+ value: uint32(params.circuitVersion, "buildClaimShieldedFeesCalldata.circuitVersion")
2725
+ }
2726
+ );
2727
+ }
2728
+
2555
2729
  // src/chain/evm/precompiles/ShieldedPoolPrecompile.ts
2556
2730
  var ShieldedPoolPrecompile = class {
2557
2731
  constructor(evm) {
@@ -2559,228 +2733,111 @@ var ShieldedPoolPrecompile = class {
2559
2733
  }
2560
2734
  evm;
2561
2735
  addr = PRECOMPILE_ADDR.SHIELDED_POOL;
2562
- // ─── shield ────────────────────────────────────────────────────────────────
2563
- /**
2564
- * Returns the ABI-encoded calldata for `shield(uint32, bytes32, bytes)`.
2565
- * The token amount must be sent as `msg.value` (the `value` field of the EVM
2566
- * transaction) this is what MetaMask and other wallets display to the user.
2567
- */
2736
+ // ─── Calldata ────────────────────────────────────────────────────────────
2737
+ //
2738
+ // Thin delegates to `shieldedPoolCalldata`, kept because they are public
2739
+ // API. New code should import those functions directly: they are pure, so
2740
+ // using them needs no `EvmClient` to construct.
2568
2741
  buildShieldCalldata(params) {
2569
- EncryptedMemo.validate(params.encryptedMemo, "buildShieldCalldata.encryptedMemo");
2570
- const commitment = fromHex(params.commitment);
2571
- return encodeHex(
2572
- SP_SEL.SHIELD,
2573
- { type: "uint", value: BigInt(params.assetId) },
2574
- { type: "bytes32", value: commitment },
2575
- { type: "bytes", value: params.encryptedMemo }
2576
- );
2742
+ return buildShieldCalldata(params);
2743
+ }
2744
+ buildPrivateTransferCalldata(params) {
2745
+ return buildPrivateTransferCalldata(params);
2746
+ }
2747
+ buildUnshieldCalldata(params) {
2748
+ return buildUnshieldCalldata(params);
2749
+ }
2750
+ buildClaimShieldedFeesCalldata(params) {
2751
+ return buildClaimShieldedFeesCalldata(params);
2577
2752
  }
2753
+ // ─── shield ──────────────────────────────────────────────────────────────
2578
2754
  /**
2579
2755
  * Deposits tokens into the shielded pool from a payable EVM transaction.
2580
2756
  *
2581
- * The token amount is sent as `msg.value` so EVM wallets (MetaMask, etc.) display
2582
- * the correct amount on the confirmation screen. The precompile dispatches
2583
- * `shieldedPool.shield` with its own address as origin, so the funds flow:
2584
- * caller precompile (via msg.value, handled by EVM)
2585
- * precompile → pool (via pallet transfer)
2586
- * This avoids double-deduction while keeping the displayed amount accurate.
2757
+ * The amount rides as `msg.value` so EVM wallets show the correct figure on
2758
+ * the confirmation screen. The precompile then dispatches with its OWN
2759
+ * address as origin, so funds flow caller → precompile → pool. That avoids
2760
+ * a double deduction while keeping the displayed amount accurate.
2587
2761
  *
2588
2762
  * Extrinsic: `shieldedPool.shield(assetId, amount, commitment, encryptedMemo)`
2589
2763
  */
2590
2764
  async shield(params, signer) {
2591
2765
  return signer({
2592
2766
  to: this.addr,
2593
- data: this.buildShieldCalldata(params),
2767
+ data: buildShieldCalldata(params),
2594
2768
  value: params.amount
2595
2769
  });
2596
2770
  }
2597
- // ─── privateTransfer ───────────────────────────────────────────────────────
2598
- /**
2599
- * Returns the ABI-encoded calldata for
2600
- * `privateTransfer(bytes, bytes32, bytes32[], bytes32[], bytes[], uint32, uint256, uint32)`.
2601
- * The trailing `uint32` is the circuit version the input notes were created under.
2602
- */
2603
- buildPrivateTransferCalldata(params) {
2604
- const nullifiers = params.inputs.map((i) => fromHex(i.nullifier));
2605
- const commitments = params.outputs.map((o) => fromHex(o.commitment));
2606
- const memos = params.outputs.map((o, i) => {
2607
- EncryptedMemo.validate(
2608
- o.encryptedMemo,
2609
- `buildPrivateTransferCalldata.outputs[${i}].encryptedMemo`
2610
- );
2611
- return o.encryptedMemo;
2612
- });
2613
- const root = fromHex(params.merkleRoot);
2614
- return encodeHex(
2615
- SP_SEL.PRIVATE_TRANSFER,
2616
- { type: "bytes", value: params.proof },
2617
- { type: "bytes32", value: root },
2618
- { type: "bytes32[]", value: nullifiers },
2619
- { type: "bytes32[]", value: commitments },
2620
- { type: "bytes[]", value: memos },
2621
- { type: "uint", value: BigInt(params.assetId) },
2622
- { type: "uint", value: params.fee ?? 0n },
2623
- { type: "uint", value: BigInt(params.circuitVersion) }
2624
- );
2625
- }
2771
+ // ─── privateTransfer ─────────────────────────────────────────────────────
2626
2772
  /**
2627
- * Submits a private transfer within the shielded pool from an EVM transaction.
2773
+ * Submits a private transfer within the shielded pool.
2628
2774
  *
2629
- * The EVM caller identity is **irrelevant to the ZK proof** — the sender is
2630
- * hidden by design. Any EVM address (including a relayer) can submit a valid proof.
2775
+ * The EVM caller identity is IRRELEVANT to the ZK proof — the sender is
2776
+ * hidden by design, so any address (a relayer included) can submit a valid
2777
+ * proof.
2631
2778
  *
2632
2779
  * Extrinsic: `shieldedPool.privateTransfer(proof, merkleRoot, nullifiers, commitments, memos)`
2633
2780
  */
2634
2781
  async privateTransfer(params, signer) {
2635
- return signer({ to: this.addr, data: this.buildPrivateTransferCalldata(params) });
2636
- }
2637
- // ─── unshield ──────────────────────────────────────────────────────────────
2638
- /**
2639
- * Params for an `unshield` call via the EVM precompile.
2640
- * The `recipient` is a full 32-byte AccountId32 (Substrate account or
2641
- * EeSuffix-derived: `H160 ++ [0x00; 12]`).
2642
- */
2643
- buildUnshieldCalldata(params) {
2644
- const proof = params.proof;
2645
- const root = fromHex(params.merkleRoot);
2646
- const nullifier = fromHex(params.nullifier);
2647
- const recipientRaw = params.recipientAddress.startsWith("0x") ? params.recipientAddress.slice(2) : params.recipientAddress;
2648
- const recipientBytes = fromHex(
2649
- "0x" + (recipientRaw.length === 64 ? recipientRaw : recipientRaw.padEnd(64, "0"))
2650
- );
2651
- const changeCommitmentHex = params.changeCommitment ?? "0x" + "00".repeat(32);
2652
- const changeCommitment = fromHex(changeCommitmentHex);
2653
- const changeEncryptedMemo = params.changeEncryptedMemo ?? new Uint8Array();
2654
- return encodeHex(
2655
- SP_SEL.UNSHIELD,
2656
- { type: "bytes", value: proof },
2657
- { type: "bytes32", value: root },
2658
- { type: "bytes32", value: nullifier },
2659
- { type: "uint", value: BigInt(params.assetId) },
2660
- { type: "uint", value: params.amount },
2661
- { type: "bytes32", value: recipientBytes },
2662
- { type: "uint", value: params.fee ?? 0n },
2663
- { type: "bytes32", value: changeCommitment },
2664
- { type: "bytes", value: changeEncryptedMemo },
2665
- { type: "uint", value: BigInt(params.circuitVersion) }
2666
- );
2782
+ return signer({ to: this.addr, data: buildPrivateTransferCalldata(params) });
2667
2783
  }
2784
+ // ─── unshield ────────────────────────────────────────────────────────────
2668
2785
  /**
2669
2786
  * Withdraws tokens from the shielded pool to a recipient account.
2670
2787
  *
2671
- * `params.recipientAddress` must be a 0x-prefixed 64-hex-char AccountId32.
2672
- * To send to an EVM address, use `evmToImplicitSubstrate(evmAddr)` from
2673
- * `@orbinum/sdk` to derive the AccountId32 first.
2788
+ * `params.recipientAddress` must be a 0x-prefixed AccountId32. To send to an
2789
+ * EVM address, derive it first with `evmToImplicitSubstrate(evmAddr)`.
2674
2790
  *
2675
2791
  * Extrinsic: `shieldedPool.unshield(proof, merkleRoot, nullifier, assetId, amount, recipient)`
2676
2792
  */
2677
2793
  async unshield(params, signer) {
2678
- return signer({ to: this.addr, data: this.buildUnshieldCalldata(params) });
2794
+ return signer({ to: this.addr, data: buildUnshieldCalldata(params) });
2679
2795
  }
2680
- // ─── Gas estimation ────────────────────────────────────────────────────────
2796
+ // ─── claimShieldedFees ───────────────────────────────────────────────────
2681
2797
  /**
2682
- * Estimates the EVM gas for a `shield` call without submitting.
2683
- * Requires `from` to be set to the actual sender address.
2798
+ * Claims accrued relay fees as a private shielded note.
2799
+ *
2800
+ * For validators/relayers holding fees in `pallet-relayer` who want them
2801
+ * paid privately into the shielded pool rather than as a public balance
2802
+ * credit. The ZK `value_proof` binds `commitment` to
2803
+ * `(amount, assetId, ownerPk, blinding)`, so the runtime can verify the note
2804
+ * encodes exactly the claimed amount and a malicious relayer cannot inflate
2805
+ * the withdrawal.
2806
+ *
2807
+ * The `msg.sender` address is the validator identity, and must match the
2808
+ * one with pending fees.
2809
+ *
2810
+ * Extrinsic: `shieldedPool.claim_shielded_fees(commitment, amount, assetId, memo, proof, publicSignals)`
2684
2811
  */
2685
- async estimateShieldGas(params, from) {
2686
- return this.evm.estimateGas({
2687
- from,
2812
+ async claimShieldedFees(params, signer) {
2813
+ return signer({
2688
2814
  to: this.addr,
2689
- data: this.buildShieldCalldata(params)
2815
+ data: buildClaimShieldedFeesCalldata(params)
2690
2816
  });
2691
2817
  }
2692
- /**
2693
- * Estimates the EVM gas for a `privateTransfer` call.
2694
- */
2818
+ // ─── Gas estimation ──────────────────────────────────────────────────────
2819
+ //
2820
+ // `from` must be the real sender: the precompile resolves it to an
2821
+ // AccountId32, so estimating from a different address measures a different
2822
+ // call.
2823
+ async estimateShieldGas(params, from) {
2824
+ return this.evm.estimateGas({ from, to: this.addr, data: buildShieldCalldata(params) });
2825
+ }
2695
2826
  async estimatePrivateTransferGas(params, from) {
2696
2827
  return this.evm.estimateGas({
2697
2828
  from,
2698
2829
  to: this.addr,
2699
- data: this.buildPrivateTransferCalldata(params)
2830
+ data: buildPrivateTransferCalldata(params)
2700
2831
  });
2701
2832
  }
2702
- /**
2703
- * Estimates the EVM gas for an `unshield` call.
2704
- */
2705
2833
  async estimateUnshieldGas(params, from) {
2706
- return this.evm.estimateGas({
2707
- from,
2708
- to: this.addr,
2709
- data: this.buildUnshieldCalldata(params)
2710
- });
2711
- }
2712
- // ─── claimShieldedFees ───────────────────────────────────────────────────────────────────
2713
- /**
2714
- * Returns the ABI-encoded calldata for
2715
- * `claimShieldedFees(bytes32,uint256,uint32,bytes,bytes,bytes,uint32)`.
2716
- *
2717
- * ABI layout (params after selector):
2718
- * - `commitment` — bytes32 (fixed)
2719
- * - `amount` — uint256 (fixed)
2720
- * - `asset_id` — uint32 (fixed, right-aligned)
2721
- * - `memo` — bytes (dynamic)
2722
- * - `proof` — bytes (dynamic, 128 bytes Groth16)
2723
- * - `publicSignals` — bytes (dynamic, 76 bytes)
2724
- * - `circuitVersion` — uint32 (fixed, right-aligned)
2725
- *
2726
- * The validator identity is derived from `msg.sender` in the precompile —
2727
- * do NOT include it in the calldata.
2728
- */
2729
- buildClaimShieldedFeesCalldata(params) {
2730
- EncryptedMemo.validate(
2731
- params.encryptedMemo,
2732
- "buildClaimShieldedFeesCalldata.encryptedMemo"
2733
- );
2734
- if (params.proof.length === 0) {
2735
- throw new Error("claimShieldedFees: proof must not be empty");
2736
- }
2737
- if (params.publicSignals.length !== 76) {
2738
- throw new Error(
2739
- `claimShieldedFees: publicSignals must be 76 bytes, got ${params.publicSignals.length}`
2740
- );
2741
- }
2742
- const commitment = fromHex(params.commitment);
2743
- return encodeHex(
2744
- SP_SEL.CLAIM_SHIELDED_FEES,
2745
- { type: "bytes32", value: commitment },
2746
- { type: "uint", value: params.amount },
2747
- { type: "uint", value: BigInt(params.assetId) },
2748
- { type: "bytes", value: params.encryptedMemo },
2749
- { type: "bytes", value: params.proof },
2750
- { type: "bytes", value: params.publicSignals },
2751
- { type: "uint", value: BigInt(params.circuitVersion) }
2752
- );
2753
- }
2754
- /**
2755
- * Claims accumulated relay fees as a private shielded note.
2756
- *
2757
- * This extrinsic is for **validators/relayers** who have accrued fees in
2758
- * `pallet-relayer` and want to receive them privately inside the shielded pool
2759
- * instead of as a public balance credit.
2760
- *
2761
- * The ZK `value_proof` binds `commitment` to `(amount, assetId, ownerPk, blinding)`
2762
- * so the runtime can verify the note encodes exactly the claimed fee amount,
2763
- * preventing a malicious relayer from inflating the withdrawal.
2764
- *
2765
- * The `msg.sender` EVM address is used as the validator identity; it must match
2766
- * the address that has pending relay fees in `pallet-relayer`.
2767
- *
2768
- * Extrinsic: `shieldedPool.claim_shielded_fees(commitment, amount, assetId, memo, proof, publicSignals)`
2769
- */
2770
- async claimShieldedFees(params, signer) {
2771
- return signer({
2772
- to: this.addr,
2773
- data: this.buildClaimShieldedFeesCalldata(params)
2774
- });
2834
+ return this.evm.estimateGas({ from, to: this.addr, data: buildUnshieldCalldata(params) });
2775
2835
  }
2776
- /**
2777
- * Estimates the EVM gas for a `claimShieldedFees` call.
2778
- */
2779
2836
  async estimateClaimShieldedFeesGas(params, from) {
2780
2837
  return this.evm.estimateGas({
2781
2838
  from,
2782
2839
  to: this.addr,
2783
- data: this.buildClaimShieldedFeesCalldata(params)
2840
+ data: buildClaimShieldedFeesCalldata(params)
2784
2841
  });
2785
2842
  }
2786
2843
  };
@@ -2952,7 +3009,7 @@ var OrbinumClient = class _OrbinumClient {
2952
3009
  */
2953
3010
  static async connect(config) {
2954
3011
  const substrate = config.papi ? SubstrateClient.adopt(config.papi, config.substrateHttp) : await SubstrateClient.connect(config.substrateWs, config.connectTimeoutMs ?? 15e3);
2955
- const evm = config.evmRpc ? new EvmClient(config.evmRpc) : null;
3012
+ const evm = config.evmRpc ? new EvmClient(config.evmRpc, config.evmRpcPeer) : null;
2956
3013
  return new _OrbinumClient(substrate, evm, config.circuitsBaseUrl);
2957
3014
  }
2958
3015
  /**
@@ -3092,6 +3149,7 @@ var OrbinumClientProvider = class _OrbinumClientProvider {
3092
3149
  connectTimeoutMs: this.connectTimeoutMs
3093
3150
  };
3094
3151
  if (this.config.evmRpc) connectConfig.evmRpc = this.config.evmRpc;
3152
+ if (this.config.evmRpcPeer) connectConfig.evmRpcPeer = this.config.evmRpcPeer;
3095
3153
  if (this.config.circuitsBaseUrl)
3096
3154
  connectConfig.circuitsBaseUrl = this.config.circuitsBaseUrl;
3097
3155
  clientPromise = OrbinumClient.connect(connectConfig);
@@ -3880,6 +3938,9 @@ function methodOf(fnSig) {
3880
3938
  if (fnSig.startsWith("shield(")) return "shield";
3881
3939
  return null;
3882
3940
  }
3941
+ function hasFullHead(data, slots) {
3942
+ return data.length >= slots * 32;
3943
+ }
3883
3944
  function decodePrecompileCalldata(address, input) {
3884
3945
  const info = KNOWN_PRECOMPILES[address.toLowerCase()];
3885
3946
  if (!info || !input || input.length < 10) return null;
@@ -3889,6 +3950,7 @@ function decodePrecompileCalldata(address, input) {
3889
3950
  if (fnSig.startsWith("shield(")) {
3890
3951
  try {
3891
3952
  const data = fromHex(input.slice(10));
3953
+ if (!hasFullHead(data, 3)) return { fnSig, method: methodOf(fnSig), args: {} };
3892
3954
  const assetId = decodeUint(data, 0);
3893
3955
  const commitment = toHex(data.slice(32, 64));
3894
3956
  return { fnSig, method: methodOf(fnSig), args: { assetId, commitment } };
@@ -3899,6 +3961,7 @@ function decodePrecompileCalldata(address, input) {
3899
3961
  if (fnSig.startsWith("unshield(")) {
3900
3962
  try {
3901
3963
  const data = fromHex(input.slice(10));
3964
+ if (!hasFullHead(data, 10)) return { fnSig, method: methodOf(fnSig), args: {} };
3902
3965
  const root = toHex(data.slice(32, 64));
3903
3966
  const nullifier = toHex(data.slice(64, 96));
3904
3967
  const assetId = decodeUint(data, 96);
@@ -3928,18 +3991,25 @@ function decodePrecompileCalldata(address, input) {
3928
3991
  if (fnSig.startsWith("privateTransfer(")) {
3929
3992
  try {
3930
3993
  const data = fromHex(input.slice(10));
3994
+ if (!hasFullHead(data, 8)) return { fnSig, method: methodOf(fnSig), args: {} };
3931
3995
  const root = toHex(data.slice(32, 64));
3932
- const nullOffset = Number(decodeUint(data, 64));
3933
- const commOffset = Number(decodeUint(data, 96));
3934
- const nullifiers = Number(decodeUint(data, nullOffset));
3935
- const commitments = Number(decodeUint(data, commOffset));
3936
3996
  const assetId = decodeUint(data, 160);
3937
3997
  const fee = decodeUint(data, 192);
3938
3998
  const circuitVersion = decodeUint(data, 224);
3999
+ const counts = {};
4000
+ for (const [name, slot] of [
4001
+ ["nullifiers", 64],
4002
+ ["commitments", 96]
4003
+ ]) {
4004
+ const offset = decodeUint(data, slot);
4005
+ if (offset <= BigInt(data.length - 32)) {
4006
+ counts[name] = Number(decodeUint(data, Number(offset)));
4007
+ }
4008
+ }
3939
4009
  return {
3940
4010
  fnSig,
3941
4011
  method: methodOf(fnSig),
3942
- args: { root, nullifiers, commitments, assetId, fee, circuitVersion }
4012
+ args: { root, ...counts, assetId, fee, circuitVersion }
3943
4013
  };
3944
4014
  } catch {
3945
4015
  return { fnSig, method: methodOf(fnSig), args: {} };
@@ -3948,6 +4018,7 @@ function decodePrecompileCalldata(address, input) {
3948
4018
  if (fnSig.startsWith("claimShieldedFees(")) {
3949
4019
  try {
3950
4020
  const data = fromHex(input.slice(10));
4021
+ if (!hasFullHead(data, 7)) return { fnSig, method: methodOf(fnSig), args: {} };
3951
4022
  const commitment = toHex(data.slice(0, 32));
3952
4023
  const amount = decodeUint(data, 32);
3953
4024
  const assetId = decodeUint(data, 64);
@@ -4154,7 +4225,7 @@ var MemoryVaultStorage = class {
4154
4225
  };
4155
4226
 
4156
4227
  // src/wallet/vault/storage/config.ts
4157
- var VAULT_SCHEMA_VERSION = 4;
4228
+ var VAULT_SCHEMA_VERSION = 5;
4158
4229
  function normalizeChainFingerprint(chainFingerprint) {
4159
4230
  return chainFingerprint ? chainFingerprint.toLowerCase() : void 0;
4160
4231
  }
@@ -4303,7 +4374,7 @@ async function noteBlindTag(blindKey, hex) {
4303
4374
 
4304
4375
  // src/wallet/vault/notes/meta.ts
4305
4376
  function noteOrigin(note) {
4306
- return note.counterpartyPk === 0n ? "shield" : "private-transfer";
4377
+ return note.sourcePk === 0n ? "shield" : "private-transfer";
4307
4378
  }
4308
4379
  function noteCreatedAt(note) {
4309
4380
  return note.createdAt ?? null;
@@ -4372,9 +4443,9 @@ var NOTE_BIGINT_FIELDS = [
4372
4443
  "spendingKey",
4373
4444
  "commitment",
4374
4445
  "nullifier",
4375
- "counterpartyPk"
4446
+ "sourcePk"
4376
4447
  ];
4377
- var ABSENT_MEANS_ZERO = /* @__PURE__ */ new Set(["counterpartyPk"]);
4448
+ var ABSENT_MEANS_ZERO = /* @__PURE__ */ new Set(["sourcePk"]);
4378
4449
  function normalizeNote(note) {
4379
4450
  let patch = null;
4380
4451
  for (const field of NOTE_BIGINT_FIELDS) {
@@ -4627,12 +4698,12 @@ var VaultStore = class {
4627
4698
  const { blindKey } = this.keys();
4628
4699
  if (commitmentHexes.length === 0) return 0;
4629
4700
  const toRemove = new Set(commitmentHexes);
4630
- const present = this.deps.notes.get().filter((n) => toRemove.has(n.commitmentHex));
4631
- if (present.length === 0) return 0;
4632
- const tags = await Promise.all(present.map((n) => noteBlindTag(blindKey, n.commitmentHex)));
4701
+ const present2 = this.deps.notes.get().filter((n) => toRemove.has(n.commitmentHex));
4702
+ if (present2.length === 0) return 0;
4703
+ const tags = await Promise.all(present2.map((n) => noteBlindTag(blindKey, n.commitmentHex)));
4633
4704
  await this.deps.storage.deleteNotes(tags);
4634
4705
  this.deps.notes.set(removeByCommitment(this.deps.notes.get(), toRemove));
4635
- return present.length;
4706
+ return present2.length;
4636
4707
  }
4637
4708
  /**
4638
4709
  * Stores an outgoing transaction, encrypted.
@@ -5223,26 +5294,147 @@ function parseFeeArg(argsJson) {
5223
5294
  }
5224
5295
  }
5225
5296
 
5297
+ // src/wallet/provenance/selectDescribingNote.ts
5298
+ function hasSourcePk(note) {
5299
+ return typeof note.sourcePk === "bigint" && note.sourcePk !== 0n;
5300
+ }
5301
+ function selectDescribingNote(candidates) {
5302
+ return candidates.find(hasSourcePk) ?? candidates[0];
5303
+ }
5304
+ function selectDescribingNoteByCommitment(commitments, noteByCommitment) {
5305
+ const owned = commitments.map((hex) => noteByCommitment.get(hex)).filter((note) => note !== void 0);
5306
+ return selectDescribingNote(owned);
5307
+ }
5308
+
5309
+ // src/wallet/provenance/merge.ts
5310
+ var SOURCE_RANK = {
5311
+ witnessed: 3,
5312
+ memo: 2,
5313
+ chain: 1,
5314
+ inferred: 0
5315
+ };
5316
+ function rankOf(source) {
5317
+ return SOURCE_RANK[source] ?? -1;
5318
+ }
5319
+ function outranks(a, b) {
5320
+ return rankOf(a) > rankOf(b);
5321
+ }
5322
+ function mergeProvenance(existing, incoming) {
5323
+ if (existing.id !== incoming.id) {
5324
+ throw new Error(
5325
+ `mergeProvenance: id mismatch \u2014 refusing to merge ${existing.id} with ${incoming.id}`
5326
+ );
5327
+ }
5328
+ const incomingWins = outranks(incoming.source, existing.source);
5329
+ const base = incomingWins ? incoming : existing;
5330
+ const other = incomingWins ? existing : incoming;
5331
+ const fee = base.feePlanck ?? other.feePlanck;
5332
+ const publicRecipient = firstPresent(base.publicRecipient, other.publicRecipient);
5333
+ const slip = present(base.slip?.encoded) ? base.slip : other.slip ?? base.slip;
5334
+ const note = base.note ?? other.note;
5335
+ return {
5336
+ // The loser is spread FIRST so a field only it carries survives. A host
5337
+ // stores its own record type through this — `ReconstructedTxRecord` has
5338
+ // `amountApproximate`, which marks an amount derived without subtracting
5339
+ // the fee — and dropping such a field turns an approximate figure into
5340
+ // one that merely looks exact. Every key the winner knows about is
5341
+ // overwritten below, so rank still decides every shared fact.
5342
+ ...other,
5343
+ ...base,
5344
+ // A known peer beats an unknown one even when the winner is silent:
5345
+ // backfilling the recipient is the whole point of re-running this.
5346
+ // `scope: 'none'` is "this operation has no counterparty", so it is a
5347
+ // gap too — treating it as known would block the backfill it exists for.
5348
+ peer: copyPeer(knownPeer(base.peer) ?? knownPeer(other.peer) ?? base.peer ?? other.peer),
5349
+ // A number that stands for "not known yet" must not win by rank. Zero is
5350
+ // exactly that here: `RECOVERED_TX_RESULT` and a failed submission both
5351
+ // report block 0, and their own comment says a caller who needs the
5352
+ // value must look it up rather than trust the field.
5353
+ blockNumber: firstPositive(base.blockNumber, other.blockNumber),
5354
+ timestampMs: firstPositive(base.timestampMs, other.timestampMs),
5355
+ // Reconstruction writes an empty hash when the extrinsic was not decoded.
5356
+ hash: firstPresent(base.hash, other.hash) ?? base.hash,
5357
+ // `exact` is a property of the FIGURE, not of the source. A `witnessed`
5358
+ // row whose amount was marked approximate is not better data than an
5359
+ // exact one from a weaker source, so rank only breaks a tie between two
5360
+ // figures of equal standing.
5361
+ amount: { ...betterAmount(base.amount, other.amount) },
5362
+ // The chain's outcome is not something one source knows better than
5363
+ // another — anyone who looks sees the same thing. Letting rank decide
5364
+ // would let a row written at submit time mark a transaction failed that
5365
+ // the chain went on to accept.
5366
+ status: base.status === "success" || other.status === "success" ? "success" : "failed",
5367
+ ...fee !== void 0 && { feePlanck: fee },
5368
+ ...publicRecipient !== void 0 && { publicRecipient },
5369
+ ...slip !== void 0 && { slip: { ...slip } },
5370
+ ...note !== void 0 && { note: { ...note } }
5371
+ };
5372
+ }
5373
+ function betterAmount(base, other) {
5374
+ if (base.exact === other.exact) return base;
5375
+ return base.exact ? base : other;
5376
+ }
5377
+ function present(value) {
5378
+ return value !== void 0 && value.length > 0;
5379
+ }
5380
+ function firstPresent(base, other) {
5381
+ return present(base) ? base : present(other) ? other : void 0;
5382
+ }
5383
+ function firstPositive(base, other) {
5384
+ return base > 0 ? base : other > 0 ? other : base;
5385
+ }
5386
+ function copyPeer(peer) {
5387
+ return peer ? { ...peer } : null;
5388
+ }
5389
+ function knownPeer(peer) {
5390
+ return peer && peer.scope !== "none" ? peer : null;
5391
+ }
5392
+
5393
+ // src/wallet/provenance/regenerateSlip.ts
5394
+ function regeneratePaymentSlip(facts, recipientIvkPacked, txHash) {
5395
+ if (!isHexOfLength(facts.commitmentHex, 32)) {
5396
+ throw new Error("regeneratePaymentSlip: commitmentHex must be 32 bytes of hex");
5397
+ }
5398
+ if (!isHexOfLength(facts.encryptedMemo, ENCRYPTED_MEMO_SIZE)) {
5399
+ throw new Error(
5400
+ `regeneratePaymentSlip: encryptedMemo must be ${ENCRYPTED_MEMO_SIZE} bytes of hex`
5401
+ );
5402
+ }
5403
+ if (facts.leafIndex !== void 0 && !isValidLeafIndex(facts.leafIndex)) {
5404
+ throw new Error("regeneratePaymentSlip: leafIndex must be a real tree position");
5405
+ }
5406
+ const envelope = sealPaymentSlip(recipientIvkPacked, {
5407
+ commitmentHex: facts.commitmentHex,
5408
+ encryptedMemo: facts.encryptedMemo,
5409
+ ...facts.leafIndex !== void 0 ? { leafIndex: facts.leafIndex } : {},
5410
+ // Rendered as an explorer link by the recipient, where an unconstrained
5411
+ // string is a URL injection wearing the authority of a decrypted slip.
5412
+ // Dropped rather than fatal: the slip still rebuilds the note, which is
5413
+ // the part that matters.
5414
+ ...isHexOfLength(txHash, 32) ? { txHash } : {}
5415
+ });
5416
+ return encodePaymentSlip(envelope);
5417
+ }
5418
+
5226
5419
  // src/wallet/scanner/history/reconstruct.ts
5227
5420
  var ZERO_PK = "0x" + "00".repeat(32);
5228
5421
  function extrinsicKey(row) {
5229
5422
  return `${row.blockNumber}:${row.extrinsicIndex ?? "null"}`;
5230
5423
  }
5231
- function toPkHex(counterpartyPk) {
5232
- return counterpartyPk != null && counterpartyPk !== 0n ? scalarToHex(counterpartyPk) : ZERO_PK;
5233
- }
5234
- function findChangeNote(commitments, noteByCommitment) {
5235
- const candidates = commitments.map((h) => noteByCommitment.get(h)).filter((n) => n !== void 0);
5236
- return candidates.find((n) => n.counterpartyPk != null && n.counterpartyPk !== 0n) ?? candidates[0];
5424
+ function toPkHex(sourcePk) {
5425
+ return typeof sourcePk === "bigint" && hasSourcePk({ sourcePk }) ? scalarToHex(sourcePk) : ZERO_PK;
5237
5426
  }
5238
5427
  async function loadExistingRecords(vault) {
5239
5428
  try {
5240
5429
  const records = await vault.getTxRecords();
5241
- return new Map(records.map((r) => [r.hash, r]));
5430
+ return new Map(records.map((r) => [r.id, r]));
5242
5431
  } catch {
5243
5432
  return /* @__PURE__ */ new Map();
5244
5433
  }
5245
5434
  }
5435
+ function recordKey(transfer) {
5436
+ return transfer.hash ?? `${transfer.blockNumber}-${transfer.extrinsicIndex ?? 0}`;
5437
+ }
5246
5438
  async function reconstructOutgoingTxRecords(deps) {
5247
5439
  const { vault, transfers } = deps;
5248
5440
  const now = deps.now ?? Date.now;
@@ -5259,17 +5451,17 @@ async function reconstructOutgoingTxRecords(deps) {
5259
5451
  const commitmentsByExtrinsic = new Map(
5260
5452
  commitmentTransfers.map((ct) => [extrinsicKey(ct), ct.matchedCommitments ?? []])
5261
5453
  );
5262
- const existingByHash = await loadExistingRecords(vault);
5454
+ const existingByKey = await loadExistingRecords(vault);
5263
5455
  for (const transfer of outgoingTransfers) {
5264
- const existing = transfer.hash ? existingByHash.get(transfer.hash) : void 0;
5456
+ const existing = existingByKey.get(recordKey(transfer));
5265
5457
  if (existing?.recipientPkHex && existing.recipientPkHex !== ZERO_PK) continue;
5266
5458
  const inputNotes = (transfer.matchedNullifiers ?? []).map((h) => noteByNullifier.get(h)).filter((n) => n !== void 0);
5267
5459
  if (inputNotes.length === 0) continue;
5268
- const changeNote = findChangeNote(
5460
+ const changeNote = selectDescribingNoteByCommitment(
5269
5461
  commitmentsByExtrinsic.get(extrinsicKey(transfer)) ?? [],
5270
5462
  noteByCommitment
5271
5463
  );
5272
- const recipientPkHex = toPkHex(changeNote?.counterpartyPk);
5464
+ const recipientPkHex = toPkHex(changeNote?.sourcePk);
5273
5465
  if (existing) {
5274
5466
  if (recipientPkHex === ZERO_PK) continue;
5275
5467
  await vault.saveTxRecord({ ...existing, recipientPkHex });
@@ -5280,7 +5472,7 @@ async function reconstructOutgoingTxRecords(deps) {
5280
5472
  const transferAmount = totalInputValue - (changeNote?.value ?? 0n) - (fee ?? 0n);
5281
5473
  if (transferAmount <= 0n) continue;
5282
5474
  const record = {
5283
- id: transfer.hash ?? `${transfer.blockNumber}-${transfer.extrinsicIndex ?? 0}`,
5475
+ id: recordKey(transfer),
5284
5476
  type: "private_transfer",
5285
5477
  blockNumber: transfer.blockNumber,
5286
5478
  hash: transfer.hash ?? "",
@@ -5331,7 +5523,7 @@ async function buildZkNote(params, deps) {
5331
5523
  circuitVersion: params.circuitVersion,
5332
5524
  // Omitted rather than passed as undefined: the builder distinguishes an
5333
5525
  // absent recipient (a self note) from one explicitly set.
5334
- ...params.counterpartyPk !== void 0 && { counterpartyPk: params.counterpartyPk },
5526
+ ...params.sourcePk !== void 0 && { sourcePk: params.sourcePk },
5335
5527
  ...params.recipientOwnerPk !== void 0 && {
5336
5528
  recipientOwnerPk: params.recipientOwnerPk
5337
5529
  },
@@ -5650,7 +5842,7 @@ async function transferNotes(deps, params, onProgress) {
5650
5842
  value: transferAmount,
5651
5843
  assetId: noteA.assetId,
5652
5844
  ownerPk: recipientPk,
5653
- counterpartyPk: effectiveSenderPk,
5845
+ sourcePk: effectiveSenderPk,
5654
5846
  // Undefined → dummy memo; the recipient finds the note by scanning.
5655
5847
  ...recipientViewingPublicKey !== void 0 ? { viewingPublicKey: recipientViewingPublicKey } : {},
5656
5848
  // With a viewing key present this activates stealth derivation.
@@ -5661,7 +5853,7 @@ async function transferNotes(deps, params, onProgress) {
5661
5853
  assetId: noteA.assetId,
5662
5854
  ownerPk: effectiveSenderPk,
5663
5855
  spendingKey: noteA.spendingKey,
5664
- counterpartyPk: recipientNote.ownerPk,
5856
+ sourcePk: recipientNote.ownerPk,
5665
5857
  viewingPublicKey: senderViewingPublicKey
5666
5858
  });
5667
5859
  onProgress?.("generating-zk");
@@ -6415,6 +6607,7 @@ export {
6415
6607
  clearKnownEphWindow,
6416
6608
  clearSession,
6417
6609
  collectNullifiersToQuery,
6610
+ collectOutgoingFacts,
6418
6611
  collectScanEntries,
6419
6612
  commitmentHexOf,
6420
6613
  computeNoteCommitment,
@@ -6490,6 +6683,7 @@ export {
6490
6683
  getPolkadotSignerFromPjs as getSubstrateSignerFromExtension,
6491
6684
  hasCachedSession,
6492
6685
  hasInjectedExtensions,
6686
+ hasSourcePk,
6493
6687
  hexToBigint,
6494
6688
  hexToNumber,
6495
6689
  implicitSubstrateToEvm,
@@ -6501,6 +6695,7 @@ export {
6501
6695
  isConnectionLossError,
6502
6696
  isEvmAddress,
6503
6697
  isGhostNoteError,
6698
+ isHexOfLength,
6504
6699
  isImplicitEvmAccount,
6505
6700
  isNativeAsset,
6506
6701
  isNoteSelfConsistent,
@@ -6513,6 +6708,7 @@ export {
6513
6708
  mapExtrinsicArgs,
6514
6709
  mapZkEventData,
6515
6710
  markInputsSpent,
6711
+ mergeProvenance,
6516
6712
  normalizeChainFingerprint,
6517
6713
  normalizeEvmAddress,
6518
6714
  normalizeNote,
@@ -6527,6 +6723,7 @@ export {
6527
6723
  noteTxKind,
6528
6724
  openOutgoingBlob,
6529
6725
  openPaymentSlip,
6726
+ outranks,
6530
6727
  pairwiseEphWindow,
6531
6728
  palletErrorKind,
6532
6729
  parseAmount,
@@ -6541,6 +6738,7 @@ export {
6541
6738
  recoverOwnerPkPoint,
6542
6739
  recoverSelfStealthNote,
6543
6740
  refuseIfAlreadySpent,
6741
+ regeneratePaymentSlip,
6544
6742
  removeByCommitment,
6545
6743
  requireSessionKeys,
6546
6744
  reservePairwiseIndex,
@@ -6554,6 +6752,8 @@ export {
6554
6752
  scanAbortError,
6555
6753
  sealOutgoingBlob,
6556
6754
  sealPaymentSlip,
6755
+ selectDescribingNote,
6756
+ selectDescribingNoteByCommitment,
6557
6757
  selectGhosts,
6558
6758
  selectNotes,
6559
6759
  selfEphWindow,