@orbinum/sdk 1.4.0 → 2.1.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.js CHANGED
@@ -109,6 +109,7 @@ __export(index_exports, {
109
109
  clearKnownEphWindow: () => clearKnownEphWindow,
110
110
  clearSession: () => clearSession,
111
111
  collectNullifiersToQuery: () => collectNullifiersToQuery,
112
+ collectOutgoingFacts: () => collectOutgoingFacts,
112
113
  collectScanEntries: () => collectScanEntries,
113
114
  commitmentHexOf: () => commitmentHexOf,
114
115
  computeNoteCommitment: () => computeNoteCommitment,
@@ -184,6 +185,7 @@ __export(index_exports, {
184
185
  getSubstrateSignerFromExtension: () => import_pjs_signer.getPolkadotSignerFromPjs,
185
186
  hasCachedSession: () => hasCachedSession,
186
187
  hasInjectedExtensions: () => hasInjectedExtensions,
188
+ hasSourcePk: () => hasSourcePk,
187
189
  hexToBigint: () => hexToBigint,
188
190
  hexToNumber: () => hexToNumber,
189
191
  implicitSubstrateToEvm: () => implicitSubstrateToEvm,
@@ -195,6 +197,7 @@ __export(index_exports, {
195
197
  isConnectionLossError: () => isConnectionLossError,
196
198
  isEvmAddress: () => isEvmAddress,
197
199
  isGhostNoteError: () => isGhostNoteError,
200
+ isHexOfLength: () => isHexOfLength,
198
201
  isImplicitEvmAccount: () => isImplicitEvmAccount,
199
202
  isNativeAsset: () => isNativeAsset,
200
203
  isNoteSelfConsistent: () => isNoteSelfConsistent,
@@ -207,6 +210,7 @@ __export(index_exports, {
207
210
  mapExtrinsicArgs: () => mapExtrinsicArgs,
208
211
  mapZkEventData: () => mapZkEventData,
209
212
  markInputsSpent: () => markInputsSpent,
213
+ mergeProvenance: () => mergeProvenance,
210
214
  normalizeChainFingerprint: () => normalizeChainFingerprint,
211
215
  normalizeEvmAddress: () => normalizeEvmAddress,
212
216
  normalizeNote: () => normalizeNote,
@@ -221,6 +225,7 @@ __export(index_exports, {
221
225
  noteTxKind: () => noteTxKind,
222
226
  openOutgoingBlob: () => openOutgoingBlob,
223
227
  openPaymentSlip: () => openPaymentSlip,
228
+ outranks: () => outranks,
224
229
  pairwiseEphWindow: () => pairwiseEphWindow,
225
230
  palletErrorKind: () => palletErrorKind,
226
231
  parseAmount: () => parseAmount,
@@ -235,6 +240,7 @@ __export(index_exports, {
235
240
  recoverOwnerPkPoint: () => recoverOwnerPkPoint,
236
241
  recoverSelfStealthNote: () => recoverSelfStealthNote,
237
242
  refuseIfAlreadySpent: () => refuseIfAlreadySpent,
243
+ regeneratePaymentSlip: () => regeneratePaymentSlip,
238
244
  removeByCommitment: () => removeByCommitment,
239
245
  requireSessionKeys: () => requireSessionKeys,
240
246
  reservePairwiseIndex: () => reservePairwiseIndex,
@@ -248,6 +254,8 @@ __export(index_exports, {
248
254
  scanAbortError: () => scanAbortError,
249
255
  sealOutgoingBlob: () => sealOutgoingBlob,
250
256
  sealPaymentSlip: () => sealPaymentSlip,
257
+ selectDescribingNote: () => selectDescribingNote,
258
+ selectDescribingNoteByCommitment: () => selectDescribingNoteByCommitment,
251
259
  selectGhosts: () => selectGhosts,
252
260
  selectNotes: () => selectNotes,
253
261
  selfEphWindow: () => selfEphWindow,
@@ -285,6 +293,9 @@ __export(index_exports, {
285
293
  module.exports = __toCommonJS(index_exports);
286
294
 
287
295
  // src/foundation/encoding/hex.ts
296
+ function isHexOfLength(value, byteLen) {
297
+ return typeof value === "string" && new RegExp(`^0x[0-9a-fA-F]{${byteLen * 2}}$`).test(value);
298
+ }
288
299
  function toHex(bytes) {
289
300
  return "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
290
301
  }
@@ -293,11 +304,12 @@ function fromHex(hex) {
293
304
  if (clean.length % 2 !== 0) {
294
305
  throw new Error(`Invalid hex string \u2014 odd length: "${hex}"`);
295
306
  }
307
+ if (!/^[0-9a-fA-F]*$/.test(clean)) {
308
+ throw new Error("Invalid hex string \u2014 non-hex characters");
309
+ }
296
310
  const bytes = new Uint8Array(clean.length / 2);
297
311
  for (let i = 0; i < bytes.length; i++) {
298
- const byte = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
299
- if (isNaN(byte)) throw new Error(`Invalid hex character at position ${i * 2}`);
300
- bytes[i] = byte;
312
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
301
313
  }
302
314
  return bytes;
303
315
  }
@@ -315,7 +327,13 @@ function scalarToHex(value) {
315
327
  }
316
328
 
317
329
  // src/foundation/encoding/bytes.ts
330
+ function assert32ByteRange(n, fn) {
331
+ if (n < 0n || n >= 1n << 256n) {
332
+ throw new Error(`${fn}: value does not fit in 32 bytes: ${n}`);
333
+ }
334
+ }
318
335
  function bigintTo32Le(n) {
336
+ assert32ByteRange(n, "bigintTo32Le");
319
337
  const buf = new Uint8Array(32);
320
338
  let v = n;
321
339
  for (let i = 0; i < 32; i++) {
@@ -332,6 +350,7 @@ function bytesToBigintLE(bytes) {
332
350
  return result;
333
351
  }
334
352
  function bigintTo32Be(n) {
353
+ assert32ByteRange(n, "bigintTo32Be");
335
354
  const buf = new Uint8Array(32);
336
355
  let v = n;
337
356
  for (let i = 31; i >= 0 && v > 0n; i--) {
@@ -341,6 +360,7 @@ function bigintTo32Be(n) {
341
360
  return buf;
342
361
  }
343
362
  function bigintTo32LeArr(n) {
363
+ assert32ByteRange(n, "bigintTo32LeArr");
344
364
  const out = new Array(32).fill(0);
345
365
  let v = n;
346
366
  for (let i = 0; i < 32; i++) {
@@ -827,7 +847,7 @@ var import_sha22 = require("@noble/hashes/sha2.js");
827
847
  var KEY_DOMAIN = new TextEncoder().encode("orbinum-note-encryption-v1");
828
848
  var VIEW_TAG_DOMAIN = new TextEncoder().encode("orbinum-view-tag-v1");
829
849
  var MEMO_PLAINTEXT_SIZE = 120;
830
- function serializeMemo(value, ownerPk, blinding, assetId, counterpartyPk, circuitVersion) {
850
+ function serializeMemo(value, ownerPk, blinding, assetId, sourcePk, circuitVersion) {
831
851
  const buf = new Uint8Array(MEMO_PLAINTEXT_SIZE);
832
852
  const view = new DataView(buf.buffer);
833
853
  view.setBigUint64(0, value & 0xffffffffffffffffn, true);
@@ -835,7 +855,7 @@ function serializeMemo(value, ownerPk, blinding, assetId, counterpartyPk, circui
835
855
  buf.set(ownerPk.slice(0, 32), 16);
836
856
  buf.set(blinding.slice(0, 32), 48);
837
857
  view.setUint32(80, assetId >>> 0, true);
838
- buf.set(counterpartyPk.slice(0, 32), 84);
858
+ buf.set(sourcePk.slice(0, 32), 84);
839
859
  view.setUint32(116, circuitVersion >>> 0, true);
840
860
  return buf;
841
861
  }
@@ -873,9 +893,9 @@ function parsePlaintext(nonce, ciphertextWithMac, encKey) {
873
893
  const ownerPk = bytesToBigintLE(plaintext.slice(16, 48));
874
894
  const blinding = bytesToBigintLE(plaintext.slice(48, 80));
875
895
  const assetId = BigInt(view.getUint32(80, true));
876
- const counterpartyPk = bytesToBigintLE(plaintext.slice(84, 116));
896
+ const sourcePk = bytesToBigintLE(plaintext.slice(84, 116));
877
897
  const circuitVersion = view.getUint32(116, true);
878
- return { value, ownerPk, blinding, assetId, counterpartyPk, circuitVersion };
898
+ return { value, ownerPk, blinding, assetId, sourcePk, circuitVersion };
879
899
  } catch {
880
900
  return null;
881
901
  }
@@ -893,19 +913,19 @@ var EncryptedMemo = {
893
913
  * (from PrivacyKeyManager.getViewingPublicKeyPacked() or
894
914
  * decoded from a privacy address).
895
915
  * Pass `new Uint8Array(32)` (all zeros) for a publicly-readable memo.
896
- * @param counterpartyPk 32-byte counterparty BJJ Ax. Default: all zeros.
916
+ * @param sourcePk 32-byte counterparty BJJ Ax. Default: all zeros.
897
917
  * @param circuitVersion ZK circuit version the note is spent under. Default: 0.
898
918
  * @param ephSkOverride 32-byte ephemeral secret key (stealth coordination). Optional.
899
919
  * @returns 180-byte encrypted memo: nonce(12) || ciphertext+MAC(136) || ephPk(32).
900
920
  */
901
- encrypt(value, ownerPk, blinding, assetId, commitment, recipientIvkPacked, counterpartyPk = new Uint8Array(32), circuitVersion = 0, ephSkOverride) {
921
+ encrypt(value, ownerPk, blinding, assetId, commitment, recipientIvkPacked, sourcePk = new Uint8Array(32), circuitVersion = 0, ephSkOverride) {
902
922
  const nonce = (0, import_utils.randomBytes)(NONCE_SIZE);
903
923
  const plaintext = serializeMemo(
904
924
  value,
905
925
  ownerPk,
906
926
  blinding,
907
927
  assetId,
908
- counterpartyPk,
928
+ sourcePk,
909
929
  circuitVersion
910
930
  );
911
931
  const isZeroKey = recipientIvkPacked.every((b) => b === 0);
@@ -1117,10 +1137,72 @@ var import_utils3 = require("@noble/ciphers/utils.js");
1117
1137
  var import_hkdf3 = require("@noble/hashes/hkdf.js");
1118
1138
  var import_sha24 = require("@noble/hashes/sha2.js");
1119
1139
  var import_baby_jubjub4 = require("@zk-kit/baby-jubjub");
1140
+
1141
+ // src/protocol/spend/coinSelection.ts
1142
+ var TRANSFER_TREE_DEPTH = 20;
1143
+ var LEAVES_PER_TREE = 1 << TRANSFER_TREE_DEPTH;
1144
+ function isValidLeafIndex(leafIndex) {
1145
+ return leafIndex !== null && leafIndex !== void 0 && Number.isSafeInteger(leafIndex) && leafIndex >= 0 && leafIndex < 2 ** 32;
1146
+ }
1147
+ function treeIdOf(note) {
1148
+ const idx = note.leafIndex;
1149
+ return isValidLeafIndex(idx) ? Math.floor(idx / LEAVES_PER_TREE) : 0;
1150
+ }
1151
+ function isSpendable(note) {
1152
+ return !note.spent && note.value > 0n;
1153
+ }
1154
+ function canPairWith(a, b) {
1155
+ return a.circuitVersion === b.circuitVersion && treeIdOf(a) === treeIdOf(b);
1156
+ }
1157
+ function selectNotes(notes, needed) {
1158
+ const unspent = notes.filter(isSpendable);
1159
+ const sorted = [...unspent].sort((a, b) => a.value < b.value ? -1 : 1);
1160
+ const single = sorted.find((n) => n.value >= needed);
1161
+ if (single) return [single, null];
1162
+ for (let i = 0; i < sorted.length; i++) {
1163
+ for (let j = i + 1; j < sorted.length; j++) {
1164
+ const a = sorted[i];
1165
+ const b = sorted[j];
1166
+ if (a !== void 0 && b !== void 0 && canPairWith(a, b) && a.value + b.value >= needed) {
1167
+ return [a, b];
1168
+ }
1169
+ }
1170
+ }
1171
+ for (let i = 0; i < sorted.length; i++) {
1172
+ for (let j = i + 1; j < sorted.length; j++) {
1173
+ const a = sorted[i];
1174
+ const b = sorted[j];
1175
+ if (a !== void 0 && b !== void 0 && a.circuitVersion === b.circuitVersion && a.value + b.value >= needed) {
1176
+ return { needsConsolidation: true };
1177
+ }
1178
+ }
1179
+ }
1180
+ return null;
1181
+ }
1182
+ function buildDummyTransferInput(assetId) {
1183
+ const zeroSibling = "0x" + "00".repeat(32);
1184
+ return {
1185
+ nullifier: 0n,
1186
+ // Constraint 9: nullifier * is_dummy.out === 0 → must be 0
1187
+ value: 0n,
1188
+ // triggers is_dummy[i].out = 1 in the circuit
1189
+ assetId,
1190
+ // must match real note (Constraint 7)
1191
+ ownerPk: 0n,
1192
+ blinding: 0n,
1193
+ spendingKey: 1n,
1194
+ // arbitrary; EdDSA is disabled (enabled = 0) for dummy inputs
1195
+ pathSiblings: Array(TRANSFER_TREE_DEPTH).fill(zeroSibling),
1196
+ leafIndex: 0
1197
+ };
1198
+ }
1199
+
1200
+ // src/protocol/memo/PaymentSlip.ts
1120
1201
  var SLIP_DOMAIN = new TextEncoder().encode("orbinum-payment-slip-v1");
1121
1202
  var NONCE_PREFIX2 = new TextEncoder().encode("SLP1");
1122
1203
  var EPH_PK_SIZE2 = 32;
1123
1204
  var NONCE_SUFFIX_SIZE2 = 8;
1205
+ var MAX_SLIP_ENVELOPE_SIZE = 4096;
1124
1206
  function deriveSlipKey(sharedSecret) {
1125
1207
  return (0, import_hkdf3.hkdf)(import_sha24.sha256, sharedSecret, void 0, SLIP_DOMAIN, 32);
1126
1208
  }
@@ -1155,6 +1237,7 @@ function sealPaymentSlip(recipientIvkPacked, fields) {
1155
1237
  }
1156
1238
  function openPaymentSlip(recipientIvsk, envelope) {
1157
1239
  if (envelope.length < EPH_PK_SIZE2 + NONCE_SUFFIX_SIZE2 + 16) return null;
1240
+ if (envelope.length > MAX_SLIP_ENVELOPE_SIZE) return null;
1158
1241
  try {
1159
1242
  const ephPkPacked = envelope.subarray(0, EPH_PK_SIZE2);
1160
1243
  const ephPkPoint = (0, import_baby_jubjub4.unpackPoint)(bytesToBigintLE(ephPkPacked));
@@ -1166,11 +1249,19 @@ function openPaymentSlip(recipientIvsk, envelope) {
1166
1249
  const sealed = envelope.subarray(EPH_PK_SIZE2 + NONCE_SUFFIX_SIZE2);
1167
1250
  const cipher = (0, import_chacha3.chacha20poly1305)(slipKey, buildNonce2(suffix));
1168
1251
  const plaintext = cipher.decrypt(sealed);
1169
- const fields = JSON.parse(new TextDecoder().decode(plaintext));
1170
- if (typeof fields.commitmentHex !== "string" || typeof fields.encryptedMemo !== "string") {
1171
- return null;
1172
- }
1173
- return fields;
1252
+ const parsed = JSON.parse(new TextDecoder().decode(plaintext));
1253
+ if (!isHexOfLength(parsed["commitmentHex"], 32)) return null;
1254
+ if (!isHexOfLength(parsed["encryptedMemo"], ENCRYPTED_MEMO_SIZE)) return null;
1255
+ const leafIndex = parsed["leafIndex"];
1256
+ if (leafIndex !== void 0 && !isValidLeafIndex(leafIndex)) return null;
1257
+ const rawTxHash = parsed["txHash"];
1258
+ const txHash = isHexOfLength(rawTxHash, 32) ? rawTxHash : void 0;
1259
+ return {
1260
+ commitmentHex: parsed["commitmentHex"],
1261
+ encryptedMemo: parsed["encryptedMemo"],
1262
+ ...leafIndex !== void 0 ? { leafIndex } : {},
1263
+ ...txHash !== void 0 ? { txHash } : {}
1264
+ };
1174
1265
  } catch {
1175
1266
  return null;
1176
1267
  }
@@ -1299,14 +1390,14 @@ var NoteBuilder = class {
1299
1390
  const ownerPk = input.ownerPk ?? 0n;
1300
1391
  const blinding = input.blinding ?? BigInt(Date.now());
1301
1392
  const spendingKey = input.spendingKey ?? 0n;
1302
- const counterpartyPk = input.counterpartyPk ?? 0n;
1393
+ const sourcePk = input.sourcePk ?? 0n;
1303
1394
  const circuitVersion = input.circuitVersion ?? CURRENT_CIRCUIT_VERSION;
1304
1395
  const useStealth = input.viewingPublicKey !== void 0 && input.recipientOwnerPk !== void 0;
1305
1396
  let memo;
1306
1397
  if (useStealth) {
1307
1398
  const recipientOwnerPk = input.recipientOwnerPk;
1308
1399
  const recipientIvkPacked = input.viewingPublicKey;
1309
- const ephSk = (0, import_utils4.randomBytes)(32);
1400
+ const ephSk = input.ephSkOverride ?? (0, import_utils4.randomBytes)(32);
1310
1401
  const ivkPackedBigint = bytesToBigintLE(recipientIvkPacked);
1311
1402
  const ivkPoint = (0, import_baby_jubjub7.unpackPoint)(ivkPackedBigint);
1312
1403
  if (!ivkPoint)
@@ -1334,7 +1425,7 @@ var NoteBuilder = class {
1334
1425
  Number(assetId),
1335
1426
  stealthCommitmentBytes,
1336
1427
  recipientIvkPacked,
1337
- bigintTo32Le(counterpartyPk),
1428
+ bigintTo32Le(sourcePk),
1338
1429
  circuitVersion,
1339
1430
  ephSk
1340
1431
  )
@@ -1376,7 +1467,7 @@ var NoteBuilder = class {
1376
1467
  commitmentHex: toHex(commitmentBytes2),
1377
1468
  nullifierHex: toHex(nullifierBytes2),
1378
1469
  memo,
1379
- counterpartyPk,
1470
+ sourcePk,
1380
1471
  ...ovkBlob ? { ovkBlob } : {}
1381
1472
  };
1382
1473
  }
@@ -1392,7 +1483,7 @@ var NoteBuilder = class {
1392
1483
  Number(assetId),
1393
1484
  commitmentBytes,
1394
1485
  input.viewingPublicKey,
1395
- bigintTo32Le(counterpartyPk),
1486
+ bigintTo32Le(sourcePk),
1396
1487
  circuitVersion,
1397
1488
  input.ephSkOverride
1398
1489
  )
@@ -1415,7 +1506,7 @@ var NoteBuilder = class {
1415
1506
  commitmentHex: toHex(commitmentBytes),
1416
1507
  nullifierHex: toHex(nullifierBytes),
1417
1508
  memo,
1418
- counterpartyPk
1509
+ sourcePk
1419
1510
  };
1420
1511
  }
1421
1512
  /**
@@ -1427,10 +1518,10 @@ var NoteBuilder = class {
1427
1518
  * @param note The ZkNote whose fields populate the plaintext.
1428
1519
  * @param recipientIvkPacked 32-byte LE packed BJJ viewing public key of the recipient.
1429
1520
  * Pass `new Uint8Array(32)` (default) for a public/dummy memo.
1430
- * @param counterpartyPk 32-byte counterparty BabyJubJub Ax.
1521
+ * @param sourcePk 32-byte counterparty BabyJubJub Ax.
1431
1522
  * Pass `new Uint8Array(32)` (default) for no counterparty.
1432
1523
  */
1433
- static buildMemo(note, recipientIvkPacked, counterpartyPk) {
1524
+ static buildMemo(note, recipientIvkPacked, sourcePk) {
1434
1525
  return EncryptedMemo.encrypt(
1435
1526
  note.value,
1436
1527
  bigintTo32Le(note.ownerPk),
@@ -1438,7 +1529,7 @@ var NoteBuilder = class {
1438
1529
  Number(note.assetId),
1439
1530
  bigintTo32Le(note.commitment),
1440
1531
  recipientIvkPacked ?? new Uint8Array(32),
1441
- counterpartyPk ?? bigintTo32Le(note.counterpartyPk ?? 0n),
1532
+ sourcePk ?? bigintTo32Le(note.sourcePk ?? 0n),
1442
1533
  note.circuitVersion
1443
1534
  );
1444
1535
  }
@@ -1446,67 +1537,6 @@ var NoteBuilder = class {
1446
1537
 
1447
1538
  // src/protocol/note/NoteDecryptor.ts
1448
1539
  var import_poseidon_lite2 = require("poseidon-lite");
1449
-
1450
- // src/protocol/spend/coinSelection.ts
1451
- var TRANSFER_TREE_DEPTH = 20;
1452
- var LEAVES_PER_TREE = 1 << TRANSFER_TREE_DEPTH;
1453
- function isValidLeafIndex(leafIndex) {
1454
- return leafIndex !== null && leafIndex !== void 0 && Number.isSafeInteger(leafIndex) && leafIndex >= 0 && leafIndex < 2 ** 32;
1455
- }
1456
- function treeIdOf(note) {
1457
- const idx = note.leafIndex;
1458
- return isValidLeafIndex(idx) ? Math.floor(idx / LEAVES_PER_TREE) : 0;
1459
- }
1460
- function isSpendable(note) {
1461
- return !note.spent && note.value > 0n;
1462
- }
1463
- function canPairWith(a, b) {
1464
- return a.circuitVersion === b.circuitVersion && treeIdOf(a) === treeIdOf(b);
1465
- }
1466
- function selectNotes(notes, needed) {
1467
- const unspent = notes.filter(isSpendable);
1468
- const sorted = [...unspent].sort((a, b) => a.value < b.value ? -1 : 1);
1469
- const single = sorted.find((n) => n.value >= needed);
1470
- if (single) return [single, null];
1471
- for (let i = 0; i < sorted.length; i++) {
1472
- for (let j = i + 1; j < sorted.length; j++) {
1473
- const a = sorted[i];
1474
- const b = sorted[j];
1475
- if (a !== void 0 && b !== void 0 && canPairWith(a, b) && a.value + b.value >= needed) {
1476
- return [a, b];
1477
- }
1478
- }
1479
- }
1480
- for (let i = 0; i < sorted.length; i++) {
1481
- for (let j = i + 1; j < sorted.length; j++) {
1482
- const a = sorted[i];
1483
- const b = sorted[j];
1484
- if (a !== void 0 && b !== void 0 && a.circuitVersion === b.circuitVersion && a.value + b.value >= needed) {
1485
- return { needsConsolidation: true };
1486
- }
1487
- }
1488
- }
1489
- return null;
1490
- }
1491
- function buildDummyTransferInput(assetId) {
1492
- const zeroSibling = "0x" + "00".repeat(32);
1493
- return {
1494
- nullifier: 0n,
1495
- // Constraint 9: nullifier * is_dummy.out === 0 → must be 0
1496
- value: 0n,
1497
- // triggers is_dummy[i].out = 1 in the circuit
1498
- assetId,
1499
- // must match real note (Constraint 7)
1500
- ownerPk: 0n,
1501
- blinding: 0n,
1502
- spendingKey: 1n,
1503
- // arbitrary; EdDSA is disabled (enabled = 0) for dummy inputs
1504
- pathSiblings: Array(TRANSFER_TREE_DEPTH).fill(zeroSibling),
1505
- leafIndex: 0
1506
- };
1507
- }
1508
-
1509
- // src/protocol/note/NoteDecryptor.ts
1510
1540
  function decryptAndVerifyPlaintext(memoBytes, commitmentBytes, sharedSecret, effectiveOwnerPk) {
1511
1541
  const plaintext = EncryptedMemo.decryptWithSharedSecret(
1512
1542
  memoBytes,
@@ -1597,7 +1627,7 @@ function tryDecryptNoteVerbose(commitment, viewingSecretKey, spendingKey, ownOwn
1597
1627
  commitmentHex: toHex(bigintTo32Le(recomputed)),
1598
1628
  nullifierHex: toHex(bigintTo32Le(nullifier)),
1599
1629
  memo: Array.from(memoBytes),
1600
- counterpartyPk: plaintext.counterpartyPk
1630
+ sourcePk: plaintext.sourcePk
1601
1631
  }
1602
1632
  };
1603
1633
  }
@@ -1631,10 +1661,19 @@ function tryRecoverOutgoing(hint, ovk, opts) {
1631
1661
  assetId: plaintext.assetId,
1632
1662
  recipientStealthPk: plaintext.ownerPk,
1633
1663
  blinding: plaintext.blinding,
1634
- counterpartyPk: plaintext.counterpartyPk,
1664
+ sourcePk: plaintext.sourcePk,
1635
1665
  circuitVersion: plaintext.circuitVersion
1636
1666
  };
1637
1667
  }
1668
+ function collectOutgoingFacts(hint) {
1669
+ if (!isHexOfLength(hint.commitmentHex, 32)) return null;
1670
+ if (!isHexOfLength(hint.encryptedMemo, ENCRYPTED_MEMO_SIZE)) return null;
1671
+ return {
1672
+ commitmentHex: hint.commitmentHex,
1673
+ ...isValidLeafIndex(hint.leafIndex) ? { leafIndex: hint.leafIndex } : {},
1674
+ encryptedMemo: hint.encryptedMemo
1675
+ };
1676
+ }
1638
1677
 
1639
1678
  // src/protocol/note/NoteDisclosure.ts
1640
1679
  var import_poseidon_lite3 = require("poseidon-lite");
@@ -2769,11 +2808,17 @@ var SubstrateClient = class _SubstrateClient {
2769
2808
 
2770
2809
  // src/chain/evm/EvmClient.ts
2771
2810
  var EvmClient = class {
2772
- /** @param rpcUrl - HTTP URL of the EVM JSON-RPC endpoint (e.g. `"http://localhost:9933"`). */
2773
- constructor(rpcUrl) {
2811
+ /**
2812
+ * @param rpcUrl - HTTP URL of the EVM JSON-RPC endpoint (e.g. `"http://localhost:9933"`).
2813
+ * @param peerRpcUrl - Optional second endpoint, used only to tell a genuinely
2814
+ * pending transaction from one stranded on `rpcUrl` alone. See `waitForReceipt`.
2815
+ */
2816
+ constructor(rpcUrl, peerRpcUrl) {
2774
2817
  this.rpcUrl = rpcUrl;
2818
+ this.peerRpcUrl = peerRpcUrl;
2775
2819
  }
2776
2820
  rpcUrl;
2821
+ peerRpcUrl;
2777
2822
  /**
2778
2823
  * Performs a single JSON-RPC call and returns the typed result.
2779
2824
  * Throws on HTTP errors, RPC-level errors, or a `null` result.
@@ -2831,10 +2876,18 @@ var EvmClient = class {
2831
2876
  const hex = await this.request("eth_getTransactionCount", [address, "latest"]);
2832
2877
  return hexToNumber(hex);
2833
2878
  }
2834
- /** Returns the current gas price in wei. */
2835
- async getGasPrice() {
2879
+ /**
2880
+ * Returns the current gas price in wei, padded by `bumpPercent`.
2881
+ *
2882
+ * `eth_gasPrice` reports the base fee exactly, and the base fee moves
2883
+ * between signing and the pool's next revalidation. A transaction priced at
2884
+ * the bare minimum is evicted as `GasPriceTooLow` the moment it rises, which
2885
+ * leaves every later nonce from that account stranded in the future queue.
2886
+ * The default 25% pad absorbs the usual movement.
2887
+ */
2888
+ async getGasPrice(bumpPercent = 25) {
2836
2889
  const hex = await this.request("eth_gasPrice", []);
2837
- return hexToBigint(hex);
2890
+ return hexToBigint(hex) * BigInt(100 + bumpPercent) / 100n;
2838
2891
  }
2839
2892
  /** Submits a signed raw transaction. Returns the transaction hash. */
2840
2893
  async sendRawTransaction(signedHex) {
@@ -2952,10 +3005,41 @@ var EvmClient = class {
2952
3005
  deadline = Math.min(deadline + timeoutMs, hardDeadline);
2953
3006
  }
2954
3007
  }
3008
+ if (await this.isStrandedOnThisNode(txHash)) {
3009
+ throw new Error(
3010
+ `Transaction dropped from the tx pool (never propagated beyond the submitting node, ${Date.now() - start}ms): ${txHash}`
3011
+ );
3012
+ }
2955
3013
  throw new Error(
2956
3014
  `Transaction still pending after ${Date.now() - start}ms: ${txHash} \u2014 it may still confirm; check the hash on the explorer before retrying`
2957
3015
  );
2958
3016
  }
3017
+ /**
3018
+ * True when `rpcUrl` knows the transaction but the configured peer does not.
3019
+ *
3020
+ * Returns false without a peer configured, and on any peer error — an
3021
+ * unreachable peer is not evidence that a live transaction is stranded.
3022
+ */
3023
+ async isStrandedOnThisNode(txHash) {
3024
+ if (!this.peerRpcUrl) return false;
3025
+ try {
3026
+ const res = await postJsonWithRetry(
3027
+ this.peerRpcUrl,
3028
+ JSON.stringify({
3029
+ id: 1,
3030
+ jsonrpc: "2.0",
3031
+ method: "eth_getTransactionByHash",
3032
+ params: [txHash]
3033
+ })
3034
+ );
3035
+ if (!res.ok) return false;
3036
+ const json = await res.json();
3037
+ if (json.error) return false;
3038
+ return json.result === null;
3039
+ } catch {
3040
+ return false;
3041
+ }
3042
+ }
2959
3043
  };
2960
3044
 
2961
3045
  // src/chain/evm/explorer/EvmExplorer.ts
@@ -3376,7 +3460,11 @@ var ShieldedPoolModule = class {
3376
3460
  * Withdraws tokens from the shielded pool to a public address.
3377
3461
  * Submits as an UNSIGNED (gasless) transaction — fee is embedded in the ZK proof.
3378
3462
  * Pass a `signer` to fall back to signed submission (e.g. for testing).
3379
- * Extrinsic: shieldedPool.unshield(proof, merkleRoot, nullifier, assetId, amount, recipient, fee, changeCommitment, changeEncryptedMemo, relayer, circuitVersion)
3463
+ * Extrinsic: shieldedPool.unshield(proof, merkleRoot, nullifier, assetId, amount, recipient, fee, changeCommitment, changeEncryptedMemo, circuitVersion)
3464
+ *
3465
+ * The relay fee recipient is NOT a parameter: the chain takes it from the
3466
+ * dispatch origin. Submitting unsigned credits the block author; submitting
3467
+ * through the EVM precompile credits whoever signed that transaction.
3380
3468
  */
3381
3469
  async unshield(params, signer, options) {
3382
3470
  const entry = resolveTx(this.substrate.unsafe, "ShieldedPool", "unshield");
@@ -3400,8 +3488,6 @@ var ShieldedPoolModule = class {
3400
3488
  fee: params.fee ?? 0n,
3401
3489
  change_commitment: changeCommitment,
3402
3490
  change_encrypted_memo: changeEncryptedMemo,
3403
- relayer: void 0,
3404
- // Option<H160> — None for direct Substrate submissions
3405
3491
  circuit_version: params.circuitVersion
3406
3492
  });
3407
3493
  if (signer) {
@@ -3413,7 +3499,11 @@ var ShieldedPoolModule = class {
3413
3499
  * Performs a private (shielded) transfer between two notes.
3414
3500
  * Submits as an UNSIGNED (gasless) transaction — fee is embedded in the ZK proof.
3415
3501
  * Pass a `signer` to fall back to signed submission (e.g. for testing).
3416
- * Extrinsic: shieldedPool.privateTransfer(proof, merkleRoot, nullifiers, commitments, memos, assetId, fee, relayer, circuitVersion)
3502
+ * Extrinsic: shieldedPool.privateTransfer(proof, merkleRoot, nullifiers, commitments, memos, assetId, fee, circuitVersion)
3503
+ *
3504
+ * The relay fee recipient is NOT a parameter: the chain takes it from the
3505
+ * dispatch origin. Submitting unsigned credits the block author; submitting
3506
+ * through the EVM precompile credits whoever signed that transaction.
3417
3507
  */
3418
3508
  async privateTransfer(params, signer, options) {
3419
3509
  const nullifiers = params.inputs.map((inp) => inp.nullifier);
@@ -3434,8 +3524,6 @@ var ShieldedPoolModule = class {
3434
3524
  encrypted_memos: memos,
3435
3525
  asset_id: params.assetId,
3436
3526
  fee: params.fee ?? 0n,
3437
- relayer: void 0,
3438
- // Option<H160> — None for direct Substrate submissions
3439
3527
  circuit_version: params.circuitVersion
3440
3528
  });
3441
3529
  if (signer) {
@@ -3674,6 +3762,23 @@ function padTo32Multiple(data) {
3674
3762
  padded.set(data);
3675
3763
  return padded;
3676
3764
  }
3765
+ function bytes32Slot(value) {
3766
+ if (value.length !== 32) {
3767
+ throw new Error(`encodeAbi: bytes32 needs 32 bytes, got ${value.length}`);
3768
+ }
3769
+ const slot = new Uint8Array(32);
3770
+ slot.set(value);
3771
+ return slot;
3772
+ }
3773
+ function addressSlot(address) {
3774
+ const clean = address.startsWith("0x") ? address.slice(2) : address;
3775
+ if (clean.length > 40) {
3776
+ throw new Error(`encodeAbi: address needs at most 20 bytes, got ${clean.length / 2}`);
3777
+ }
3778
+ const slot = new Uint8Array(32);
3779
+ slot.set(fromHex("0x" + clean.padStart(40, "0")), 12);
3780
+ return slot;
3781
+ }
3677
3782
  function encodeStaticParam(param) {
3678
3783
  const buf = new Uint8Array(32);
3679
3784
  switch (param.type) {
@@ -3681,14 +3786,10 @@ function encodeStaticParam(param) {
3681
3786
  return bigintTo32Be(param.value);
3682
3787
  }
3683
3788
  case "bytes32": {
3684
- buf.set(param.value.slice(0, 32));
3685
- return buf;
3789
+ return bytes32Slot(param.value);
3686
3790
  }
3687
3791
  case "address": {
3688
- const clean = param.value.startsWith("0x") ? param.value.slice(2) : param.value;
3689
- const bytes = fromHex("0x" + clean.padStart(40, "0"));
3690
- buf.set(bytes, 12);
3691
- return buf;
3792
+ return addressSlot(param.value);
3692
3793
  }
3693
3794
  case "bool": {
3694
3795
  buf[31] = param.value ? 1 : 0;
@@ -3711,25 +3812,13 @@ function encodeDynamicParam(param) {
3711
3812
  return concat([bigintTo32Be(BigInt(data.length)), padTo32Multiple(data)]);
3712
3813
  }
3713
3814
  case "bytes32[]": {
3714
- const n = param.value.length;
3715
- const parts = [bigintTo32Be(BigInt(n))];
3716
- for (const b32 of param.value) {
3717
- const slot = new Uint8Array(32);
3718
- slot.set(b32.slice(0, 32));
3719
- parts.push(slot);
3720
- }
3815
+ const parts = [bigintTo32Be(BigInt(param.value.length))];
3816
+ for (const b32 of param.value) parts.push(bytes32Slot(b32));
3721
3817
  return concat(parts);
3722
3818
  }
3723
3819
  case "address[]": {
3724
- const n = param.value.length;
3725
- const parts = [bigintTo32Be(BigInt(n))];
3726
- for (const addr of param.value) {
3727
- const slot = new Uint8Array(32);
3728
- const clean = addr.startsWith("0x") ? addr.slice(2) : addr;
3729
- const bytes = fromHex("0x" + clean.padStart(40, "0"));
3730
- slot.set(bytes, 12);
3731
- parts.push(slot);
3732
- }
3820
+ const parts = [bigintTo32Be(BigInt(param.value.length))];
3821
+ for (const addr of param.value) parts.push(addressSlot(addr));
3733
3822
  return concat(parts);
3734
3823
  }
3735
3824
  case "bytes[]": {
@@ -3785,6 +3874,122 @@ function decodeUint(data, offset = 0) {
3785
3874
  return result;
3786
3875
  }
3787
3876
 
3877
+ // src/chain/evm/precompiles/shieldedPoolCalldata.ts
3878
+ var CLAIM_PUBLIC_SIGNALS_SIZE = 76;
3879
+ function bytes32(hex, field) {
3880
+ if (!isHexOfLength(hex, 32)) {
3881
+ throw new Error(`${field}: expected a 0x-prefixed 32-byte hex string, got ${hex}`);
3882
+ }
3883
+ return fromHex(hex);
3884
+ }
3885
+ function uint32(value, field) {
3886
+ if (!Number.isInteger(value) || value < 0 || value > 4294967295) {
3887
+ throw new Error(`${field}: expected a uint32 (0..4294967295), got ${value}`);
3888
+ }
3889
+ return BigInt(value);
3890
+ }
3891
+ function accountId32(address, field) {
3892
+ const raw = address.startsWith("0x") ? address.slice(2) : address;
3893
+ if (raw.length > 64) {
3894
+ throw new Error(`${field}: expected at most 32 bytes, got ${raw.length / 2}`);
3895
+ }
3896
+ return bytes32("0x" + raw.padEnd(64, "0"), field);
3897
+ }
3898
+ function buildShieldCalldata(params) {
3899
+ EncryptedMemo.validate(params.encryptedMemo, "buildShieldCalldata.encryptedMemo");
3900
+ const commitment = bytes32(params.commitment, "buildShieldCalldata.commitment");
3901
+ return encodeHex(
3902
+ SP_SEL.SHIELD,
3903
+ { type: "uint", value: uint32(params.assetId, "buildShieldCalldata.assetId") },
3904
+ { type: "bytes32", value: commitment },
3905
+ { type: "bytes", value: params.encryptedMemo }
3906
+ );
3907
+ }
3908
+ function buildPrivateTransferCalldata(params) {
3909
+ const nullifiers = params.inputs.map(
3910
+ (input, i) => bytes32(input.nullifier, `buildPrivateTransferCalldata.inputs[${i}].nullifier`)
3911
+ );
3912
+ const commitments = params.outputs.map(
3913
+ (output, i) => bytes32(output.commitment, `buildPrivateTransferCalldata.outputs[${i}].commitment`)
3914
+ );
3915
+ const memos = params.outputs.map((output, i) => {
3916
+ EncryptedMemo.validate(
3917
+ output.encryptedMemo,
3918
+ `buildPrivateTransferCalldata.outputs[${i}].encryptedMemo`
3919
+ );
3920
+ return output.encryptedMemo;
3921
+ });
3922
+ const root = bytes32(params.merkleRoot, "buildPrivateTransferCalldata.merkleRoot");
3923
+ return encodeHex(
3924
+ SP_SEL.PRIVATE_TRANSFER,
3925
+ { type: "bytes", value: params.proof },
3926
+ { type: "bytes32", value: root },
3927
+ { type: "bytes32[]", value: nullifiers },
3928
+ { type: "bytes32[]", value: commitments },
3929
+ { type: "bytes[]", value: memos },
3930
+ { type: "uint", value: uint32(params.assetId, "buildPrivateTransferCalldata.assetId") },
3931
+ { type: "uint", value: params.fee ?? 0n },
3932
+ {
3933
+ type: "uint",
3934
+ value: uint32(params.circuitVersion, "buildPrivateTransferCalldata.circuitVersion")
3935
+ }
3936
+ );
3937
+ }
3938
+ function buildUnshieldCalldata(params) {
3939
+ const root = bytes32(params.merkleRoot, "buildUnshieldCalldata.merkleRoot");
3940
+ const nullifier = bytes32(params.nullifier, "buildUnshieldCalldata.nullifier");
3941
+ const recipient = accountId32(
3942
+ params.recipientAddress,
3943
+ "buildUnshieldCalldata.recipientAddress"
3944
+ );
3945
+ const changeCommitment = bytes32(
3946
+ params.changeCommitment ?? "0x" + "00".repeat(32),
3947
+ "buildUnshieldCalldata.changeCommitment"
3948
+ );
3949
+ const changeEncryptedMemo = params.changeEncryptedMemo ?? new Uint8Array();
3950
+ return encodeHex(
3951
+ SP_SEL.UNSHIELD,
3952
+ { type: "bytes", value: params.proof },
3953
+ { type: "bytes32", value: root },
3954
+ { type: "bytes32", value: nullifier },
3955
+ { type: "uint", value: uint32(params.assetId, "buildUnshieldCalldata.assetId") },
3956
+ { type: "uint", value: params.amount },
3957
+ { type: "bytes32", value: recipient },
3958
+ { type: "uint", value: params.fee ?? 0n },
3959
+ { type: "bytes32", value: changeCommitment },
3960
+ { type: "bytes", value: changeEncryptedMemo },
3961
+ {
3962
+ type: "uint",
3963
+ value: uint32(params.circuitVersion, "buildUnshieldCalldata.circuitVersion")
3964
+ }
3965
+ );
3966
+ }
3967
+ function buildClaimShieldedFeesCalldata(params) {
3968
+ EncryptedMemo.validate(params.encryptedMemo, "buildClaimShieldedFeesCalldata.encryptedMemo");
3969
+ if (params.proof.length === 0) {
3970
+ throw new Error("claimShieldedFees: proof must not be empty");
3971
+ }
3972
+ if (params.publicSignals.length !== CLAIM_PUBLIC_SIGNALS_SIZE) {
3973
+ throw new Error(
3974
+ `claimShieldedFees: publicSignals must be ${CLAIM_PUBLIC_SIGNALS_SIZE} bytes, got ${params.publicSignals.length}`
3975
+ );
3976
+ }
3977
+ const commitment = bytes32(params.commitment, "buildClaimShieldedFeesCalldata.commitment");
3978
+ return encodeHex(
3979
+ SP_SEL.CLAIM_SHIELDED_FEES,
3980
+ { type: "bytes32", value: commitment },
3981
+ { type: "uint", value: params.amount },
3982
+ { type: "uint", value: uint32(params.assetId, "buildClaimShieldedFeesCalldata.assetId") },
3983
+ { type: "bytes", value: params.encryptedMemo },
3984
+ { type: "bytes", value: params.proof },
3985
+ { type: "bytes", value: params.publicSignals },
3986
+ {
3987
+ type: "uint",
3988
+ value: uint32(params.circuitVersion, "buildClaimShieldedFeesCalldata.circuitVersion")
3989
+ }
3990
+ );
3991
+ }
3992
+
3788
3993
  // src/chain/evm/precompiles/ShieldedPoolPrecompile.ts
3789
3994
  var ShieldedPoolPrecompile = class {
3790
3995
  constructor(evm) {
@@ -3792,228 +3997,127 @@ var ShieldedPoolPrecompile = class {
3792
3997
  }
3793
3998
  evm;
3794
3999
  addr = PRECOMPILE_ADDR.SHIELDED_POOL;
3795
- // ─── shield ────────────────────────────────────────────────────────────────
3796
- /**
3797
- * Returns the ABI-encoded calldata for `shield(uint32, bytes32, bytes)`.
3798
- * The token amount must be sent as `msg.value` (the `value` field of the EVM
3799
- * transaction) this is what MetaMask and other wallets display to the user.
3800
- */
4000
+ // ─── Calldata ────────────────────────────────────────────────────────────
4001
+ //
4002
+ // Thin delegates to `shieldedPoolCalldata`, kept because they are public
4003
+ // API. New code should import those functions directly: they are pure, so
4004
+ // using them needs no `EvmClient` to construct.
3801
4005
  buildShieldCalldata(params) {
3802
- EncryptedMemo.validate(params.encryptedMemo, "buildShieldCalldata.encryptedMemo");
3803
- const commitment = fromHex(params.commitment);
3804
- return encodeHex(
3805
- SP_SEL.SHIELD,
3806
- { type: "uint", value: BigInt(params.assetId) },
3807
- { type: "bytes32", value: commitment },
3808
- { type: "bytes", value: params.encryptedMemo }
3809
- );
4006
+ return buildShieldCalldata(params);
4007
+ }
4008
+ buildPrivateTransferCalldata(params) {
4009
+ return buildPrivateTransferCalldata(params);
4010
+ }
4011
+ buildUnshieldCalldata(params) {
4012
+ return buildUnshieldCalldata(params);
4013
+ }
4014
+ buildClaimShieldedFeesCalldata(params) {
4015
+ return buildClaimShieldedFeesCalldata(params);
3810
4016
  }
4017
+ // ─── shield ──────────────────────────────────────────────────────────────
3811
4018
  /**
3812
4019
  * Deposits tokens into the shielded pool from a payable EVM transaction.
3813
4020
  *
3814
- * The token amount is sent as `msg.value` so EVM wallets (MetaMask, etc.) display
3815
- * the correct amount on the confirmation screen. The precompile dispatches
3816
- * `shieldedPool.shield` with its own address as origin, so the funds flow:
3817
- * caller precompile (via msg.value, handled by EVM)
3818
- * precompile → pool (via pallet transfer)
3819
- * This avoids double-deduction while keeping the displayed amount accurate.
4021
+ * The amount rides as `msg.value` so EVM wallets show the correct figure on
4022
+ * the confirmation screen. The precompile then dispatches with its OWN
4023
+ * address as origin, so funds flow caller → precompile → pool. That avoids
4024
+ * a double deduction while keeping the displayed amount accurate.
3820
4025
  *
3821
4026
  * Extrinsic: `shieldedPool.shield(assetId, amount, commitment, encryptedMemo)`
3822
4027
  */
3823
4028
  async shield(params, signer) {
3824
4029
  return signer({
3825
4030
  to: this.addr,
3826
- data: this.buildShieldCalldata(params),
4031
+ data: buildShieldCalldata(params),
3827
4032
  value: params.amount
3828
4033
  });
3829
4034
  }
3830
- // ─── privateTransfer ───────────────────────────────────────────────────────
3831
- /**
3832
- * Returns the ABI-encoded calldata for
3833
- * `privateTransfer(bytes, bytes32, bytes32[], bytes32[], bytes[], uint32, uint256, uint32)`.
3834
- * The trailing `uint32` is the circuit version the input notes were created under.
3835
- */
3836
- buildPrivateTransferCalldata(params) {
3837
- const nullifiers = params.inputs.map((i) => fromHex(i.nullifier));
3838
- const commitments = params.outputs.map((o) => fromHex(o.commitment));
3839
- const memos = params.outputs.map((o, i) => {
3840
- EncryptedMemo.validate(
3841
- o.encryptedMemo,
3842
- `buildPrivateTransferCalldata.outputs[${i}].encryptedMemo`
3843
- );
3844
- return o.encryptedMemo;
3845
- });
3846
- const root = fromHex(params.merkleRoot);
3847
- return encodeHex(
3848
- SP_SEL.PRIVATE_TRANSFER,
3849
- { type: "bytes", value: params.proof },
3850
- { type: "bytes32", value: root },
3851
- { type: "bytes32[]", value: nullifiers },
3852
- { type: "bytes32[]", value: commitments },
3853
- { type: "bytes[]", value: memos },
3854
- { type: "uint", value: BigInt(params.assetId) },
3855
- { type: "uint", value: params.fee ?? 0n },
3856
- { type: "uint", value: BigInt(params.circuitVersion) }
3857
- );
3858
- }
4035
+ // ─── privateTransfer ─────────────────────────────────────────────────────
3859
4036
  /**
3860
- * Submits a private transfer within the shielded pool from an EVM transaction.
4037
+ * Submits a private transfer within the shielded pool.
3861
4038
  *
3862
- * The EVM caller identity is **irrelevant to the ZK proof** — the sender is
3863
- * hidden by design. Any EVM address (including a relayer) can submit a valid proof.
4039
+ * The EVM caller identity is IRRELEVANT to the ZK proof — the sender is
4040
+ * hidden by design, so any address (a relayer included) can submit a valid
4041
+ * proof.
3864
4042
  *
3865
- * Extrinsic: `shieldedPool.privateTransfer(proof, merkleRoot, nullifiers, commitments, memos)`
4043
+ * Extrinsic: `shieldedPool.privateTransfer(proof, merkleRoot, nullifiers,
4044
+ * commitments, memos, assetId, fee, circuitVersion)` — eight arguments; see
4045
+ * `buildPrivateTransferCalldata` for the encoding order.
3866
4046
  */
3867
4047
  async privateTransfer(params, signer) {
3868
- return signer({ to: this.addr, data: this.buildPrivateTransferCalldata(params) });
3869
- }
3870
- // ─── unshield ──────────────────────────────────────────────────────────────
3871
- /**
3872
- * Params for an `unshield` call via the EVM precompile.
3873
- * The `recipient` is a full 32-byte AccountId32 (Substrate account or
3874
- * EeSuffix-derived: `H160 ++ [0x00; 12]`).
3875
- */
3876
- buildUnshieldCalldata(params) {
3877
- const proof = params.proof;
3878
- const root = fromHex(params.merkleRoot);
3879
- const nullifier = fromHex(params.nullifier);
3880
- const recipientRaw = params.recipientAddress.startsWith("0x") ? params.recipientAddress.slice(2) : params.recipientAddress;
3881
- const recipientBytes = fromHex(
3882
- "0x" + (recipientRaw.length === 64 ? recipientRaw : recipientRaw.padEnd(64, "0"))
3883
- );
3884
- const changeCommitmentHex = params.changeCommitment ?? "0x" + "00".repeat(32);
3885
- const changeCommitment = fromHex(changeCommitmentHex);
3886
- const changeEncryptedMemo = params.changeEncryptedMemo ?? new Uint8Array();
3887
- return encodeHex(
3888
- SP_SEL.UNSHIELD,
3889
- { type: "bytes", value: proof },
3890
- { type: "bytes32", value: root },
3891
- { type: "bytes32", value: nullifier },
3892
- { type: "uint", value: BigInt(params.assetId) },
3893
- { type: "uint", value: params.amount },
3894
- { type: "bytes32", value: recipientBytes },
3895
- { type: "uint", value: params.fee ?? 0n },
3896
- { type: "bytes32", value: changeCommitment },
3897
- { type: "bytes", value: changeEncryptedMemo },
3898
- { type: "uint", value: BigInt(params.circuitVersion) }
3899
- );
4048
+ return signer({ to: this.addr, data: buildPrivateTransferCalldata(params) });
3900
4049
  }
4050
+ // ─── unshield ────────────────────────────────────────────────────────────
3901
4051
  /**
3902
4052
  * Withdraws tokens from the shielded pool to a recipient account.
3903
4053
  *
3904
- * `params.recipientAddress` must be a 0x-prefixed 64-hex-char AccountId32.
3905
- * To send to an EVM address, use `evmToImplicitSubstrate(evmAddr)` from
3906
- * `@orbinum/sdk` to derive the AccountId32 first.
4054
+ * `params.recipientAddress` must be a 0x-prefixed AccountId32. To send to an
4055
+ * EVM address, derive it first with `evmToImplicitSubstrate(evmAddr)`.
4056
+ *
4057
+ * Extrinsic: `shieldedPool.unshield(proof, merkleRoot, nullifier, assetId,
4058
+ * amount, recipient, fee, changeCommitment, changeEncryptedMemo,
4059
+ * circuitVersion)` — ten arguments; see `buildUnshieldCalldata`.
3907
4060
  *
3908
- * Extrinsic: `shieldedPool.unshield(proof, merkleRoot, nullifier, assetId, amount, recipient)`
4061
+ * **The relay fee goes to whoever `signer` is.** The chain takes the recipient
4062
+ * from `msg.sender`, not from calldata, so the account behind this signer is
4063
+ * the one credited — and it is also the one paying gas. Relaying on someone
4064
+ * else's behalf and being paid for it is the same act here.
3909
4065
  */
3910
4066
  async unshield(params, signer) {
3911
- return signer({ to: this.addr, data: this.buildUnshieldCalldata(params) });
4067
+ return signer({ to: this.addr, data: buildUnshieldCalldata(params) });
3912
4068
  }
3913
- // ─── Gas estimation ────────────────────────────────────────────────────────
4069
+ // ─── claimShieldedFees ───────────────────────────────────────────────────
3914
4070
  /**
3915
- * Estimates the EVM gas for a `shield` call without submitting.
3916
- * Requires `from` to be set to the actual sender address.
4071
+ * Claims accrued relay fees as a private shielded note.
4072
+ *
4073
+ * For validators/relayers holding fees in `pallet-relayer` who want them
4074
+ * paid privately into the shielded pool rather than as a public balance
4075
+ * credit. The ZK `value_proof` binds `commitment` to
4076
+ * `(amount, assetId, ownerPk, blinding)`, so the runtime can verify the note
4077
+ * encodes exactly the claimed amount and a malicious relayer cannot inflate
4078
+ * the withdrawal.
4079
+ *
4080
+ * The `msg.sender` address is the validator identity, and must match the
4081
+ * one with pending fees.
4082
+ *
4083
+ * Extrinsic: `shieldedPool.claim_shielded_fees(commitment, amount, assetId,
4084
+ * memo, proof, publicSignals, circuitVersion)` — seven arguments; see
4085
+ * `buildClaimShieldedFeesCalldata`.
3917
4086
  */
3918
- async estimateShieldGas(params, from) {
3919
- return this.evm.estimateGas({
3920
- from,
4087
+ async claimShieldedFees(params, signer) {
4088
+ return signer({
3921
4089
  to: this.addr,
3922
- data: this.buildShieldCalldata(params)
4090
+ data: buildClaimShieldedFeesCalldata(params)
3923
4091
  });
3924
4092
  }
3925
- /**
3926
- * Estimates the EVM gas for a `privateTransfer` call.
3927
- */
3928
- async estimatePrivateTransferGas(params, from) {
4093
+ // ─── Gas estimation ──────────────────────────────────────────────────────
4094
+ //
4095
+ // `from` must be the real sender: the precompile resolves it to an
4096
+ // AccountId32, so estimating from a different address measures a different
4097
+ // call.
4098
+ async estimateShieldGas(params, from) {
3929
4099
  return this.evm.estimateGas({
3930
4100
  from,
3931
4101
  to: this.addr,
3932
- data: this.buildPrivateTransferCalldata(params)
4102
+ data: buildShieldCalldata(params),
4103
+ value: `0x${params.amount.toString(16)}`
3933
4104
  });
3934
4105
  }
3935
- /**
3936
- * Estimates the EVM gas for an `unshield` call.
3937
- */
3938
- async estimateUnshieldGas(params, from) {
4106
+ async estimatePrivateTransferGas(params, from) {
3939
4107
  return this.evm.estimateGas({
3940
4108
  from,
3941
4109
  to: this.addr,
3942
- data: this.buildUnshieldCalldata(params)
4110
+ data: buildPrivateTransferCalldata(params)
3943
4111
  });
3944
4112
  }
3945
- // ─── claimShieldedFees ───────────────────────────────────────────────────────────────────
3946
- /**
3947
- * Returns the ABI-encoded calldata for
3948
- * `claimShieldedFees(bytes32,uint256,uint32,bytes,bytes,bytes,uint32)`.
3949
- *
3950
- * ABI layout (params after selector):
3951
- * - `commitment` — bytes32 (fixed)
3952
- * - `amount` — uint256 (fixed)
3953
- * - `asset_id` — uint32 (fixed, right-aligned)
3954
- * - `memo` — bytes (dynamic)
3955
- * - `proof` — bytes (dynamic, 128 bytes Groth16)
3956
- * - `publicSignals` — bytes (dynamic, 76 bytes)
3957
- * - `circuitVersion` — uint32 (fixed, right-aligned)
3958
- *
3959
- * The validator identity is derived from `msg.sender` in the precompile —
3960
- * do NOT include it in the calldata.
3961
- */
3962
- buildClaimShieldedFeesCalldata(params) {
3963
- EncryptedMemo.validate(
3964
- params.encryptedMemo,
3965
- "buildClaimShieldedFeesCalldata.encryptedMemo"
3966
- );
3967
- if (params.proof.length === 0) {
3968
- throw new Error("claimShieldedFees: proof must not be empty");
3969
- }
3970
- if (params.publicSignals.length !== 76) {
3971
- throw new Error(
3972
- `claimShieldedFees: publicSignals must be 76 bytes, got ${params.publicSignals.length}`
3973
- );
3974
- }
3975
- const commitment = fromHex(params.commitment);
3976
- return encodeHex(
3977
- SP_SEL.CLAIM_SHIELDED_FEES,
3978
- { type: "bytes32", value: commitment },
3979
- { type: "uint", value: params.amount },
3980
- { type: "uint", value: BigInt(params.assetId) },
3981
- { type: "bytes", value: params.encryptedMemo },
3982
- { type: "bytes", value: params.proof },
3983
- { type: "bytes", value: params.publicSignals },
3984
- { type: "uint", value: BigInt(params.circuitVersion) }
3985
- );
3986
- }
3987
- /**
3988
- * Claims accumulated relay fees as a private shielded note.
3989
- *
3990
- * This extrinsic is for **validators/relayers** who have accrued fees in
3991
- * `pallet-relayer` and want to receive them privately inside the shielded pool
3992
- * instead of as a public balance credit.
3993
- *
3994
- * The ZK `value_proof` binds `commitment` to `(amount, assetId, ownerPk, blinding)`
3995
- * so the runtime can verify the note encodes exactly the claimed fee amount,
3996
- * preventing a malicious relayer from inflating the withdrawal.
3997
- *
3998
- * The `msg.sender` EVM address is used as the validator identity; it must match
3999
- * the address that has pending relay fees in `pallet-relayer`.
4000
- *
4001
- * Extrinsic: `shieldedPool.claim_shielded_fees(commitment, amount, assetId, memo, proof, publicSignals)`
4002
- */
4003
- async claimShieldedFees(params, signer) {
4004
- return signer({
4005
- to: this.addr,
4006
- data: this.buildClaimShieldedFeesCalldata(params)
4007
- });
4113
+ async estimateUnshieldGas(params, from) {
4114
+ return this.evm.estimateGas({ from, to: this.addr, data: buildUnshieldCalldata(params) });
4008
4115
  }
4009
- /**
4010
- * Estimates the EVM gas for a `claimShieldedFees` call.
4011
- */
4012
4116
  async estimateClaimShieldedFeesGas(params, from) {
4013
4117
  return this.evm.estimateGas({
4014
4118
  from,
4015
4119
  to: this.addr,
4016
- data: this.buildClaimShieldedFeesCalldata(params)
4120
+ data: buildClaimShieldedFeesCalldata(params)
4017
4121
  });
4018
4122
  }
4019
4123
  };
@@ -4185,7 +4289,7 @@ var OrbinumClient = class _OrbinumClient {
4185
4289
  */
4186
4290
  static async connect(config) {
4187
4291
  const substrate = config.papi ? SubstrateClient.adopt(config.papi, config.substrateHttp) : await SubstrateClient.connect(config.substrateWs, config.connectTimeoutMs ?? 15e3);
4188
- const evm = config.evmRpc ? new EvmClient(config.evmRpc) : null;
4292
+ const evm = config.evmRpc ? new EvmClient(config.evmRpc, config.evmRpcPeer) : null;
4189
4293
  return new _OrbinumClient(substrate, evm, config.circuitsBaseUrl);
4190
4294
  }
4191
4295
  /**
@@ -4325,6 +4429,7 @@ var OrbinumClientProvider = class _OrbinumClientProvider {
4325
4429
  connectTimeoutMs: this.connectTimeoutMs
4326
4430
  };
4327
4431
  if (this.config.evmRpc) connectConfig.evmRpc = this.config.evmRpc;
4432
+ if (this.config.evmRpcPeer) connectConfig.evmRpcPeer = this.config.evmRpcPeer;
4328
4433
  if (this.config.circuitsBaseUrl)
4329
4434
  connectConfig.circuitsBaseUrl = this.config.circuitsBaseUrl;
4330
4435
  clientPromise = OrbinumClient.connect(connectConfig);
@@ -5103,9 +5208,13 @@ function methodOf(fnSig) {
5103
5208
  if (fnSig.startsWith("unshield(")) return "unshield";
5104
5209
  if (fnSig.startsWith("privateTransfer(")) return "privateTransfer";
5105
5210
  if (fnSig.startsWith("shieldBatch(")) return "shieldBatch";
5211
+ if (fnSig.startsWith("claimShieldedFees(")) return "claimShieldedFees";
5106
5212
  if (fnSig.startsWith("shield(")) return "shield";
5107
5213
  return null;
5108
5214
  }
5215
+ function hasFullHead(data, slots) {
5216
+ return data.length >= slots * 32;
5217
+ }
5109
5218
  function decodePrecompileCalldata(address, input) {
5110
5219
  const info = KNOWN_PRECOMPILES[address.toLowerCase()];
5111
5220
  if (!info || !input || input.length < 10) return null;
@@ -5115,6 +5224,7 @@ function decodePrecompileCalldata(address, input) {
5115
5224
  if (fnSig.startsWith("shield(")) {
5116
5225
  try {
5117
5226
  const data = fromHex(input.slice(10));
5227
+ if (!hasFullHead(data, 3)) return { fnSig, method: methodOf(fnSig), args: {} };
5118
5228
  const assetId = decodeUint(data, 0);
5119
5229
  const commitment = toHex(data.slice(32, 64));
5120
5230
  return { fnSig, method: methodOf(fnSig), args: { assetId, commitment } };
@@ -5125,6 +5235,7 @@ function decodePrecompileCalldata(address, input) {
5125
5235
  if (fnSig.startsWith("unshield(")) {
5126
5236
  try {
5127
5237
  const data = fromHex(input.slice(10));
5238
+ if (!hasFullHead(data, 10)) return { fnSig, method: methodOf(fnSig), args: {} };
5128
5239
  const root = toHex(data.slice(32, 64));
5129
5240
  const nullifier = toHex(data.slice(64, 96));
5130
5241
  const assetId = decodeUint(data, 96);
@@ -5154,18 +5265,25 @@ function decodePrecompileCalldata(address, input) {
5154
5265
  if (fnSig.startsWith("privateTransfer(")) {
5155
5266
  try {
5156
5267
  const data = fromHex(input.slice(10));
5268
+ if (!hasFullHead(data, 8)) return { fnSig, method: methodOf(fnSig), args: {} };
5157
5269
  const root = toHex(data.slice(32, 64));
5158
- const nullOffset = Number(decodeUint(data, 64));
5159
- const commOffset = Number(decodeUint(data, 96));
5160
- const nullifiers = Number(decodeUint(data, nullOffset));
5161
- const commitments = Number(decodeUint(data, commOffset));
5162
5270
  const assetId = decodeUint(data, 160);
5163
5271
  const fee = decodeUint(data, 192);
5164
5272
  const circuitVersion = decodeUint(data, 224);
5273
+ const counts = {};
5274
+ for (const [name, slot] of [
5275
+ ["nullifiers", 64],
5276
+ ["commitments", 96]
5277
+ ]) {
5278
+ const offset = decodeUint(data, slot);
5279
+ if (offset <= BigInt(data.length - 32)) {
5280
+ counts[name] = Number(decodeUint(data, Number(offset)));
5281
+ }
5282
+ }
5165
5283
  return {
5166
5284
  fnSig,
5167
5285
  method: methodOf(fnSig),
5168
- args: { root, nullifiers, commitments, assetId, fee, circuitVersion }
5286
+ args: { root, ...counts, assetId, fee, circuitVersion }
5169
5287
  };
5170
5288
  } catch {
5171
5289
  return { fnSig, method: methodOf(fnSig), args: {} };
@@ -5174,6 +5292,7 @@ function decodePrecompileCalldata(address, input) {
5174
5292
  if (fnSig.startsWith("claimShieldedFees(")) {
5175
5293
  try {
5176
5294
  const data = fromHex(input.slice(10));
5295
+ if (!hasFullHead(data, 7)) return { fnSig, method: methodOf(fnSig), args: {} };
5177
5296
  const commitment = toHex(data.slice(0, 32));
5178
5297
  const amount = decodeUint(data, 32);
5179
5298
  const assetId = decodeUint(data, 64);
@@ -5380,7 +5499,7 @@ var MemoryVaultStorage = class {
5380
5499
  };
5381
5500
 
5382
5501
  // src/wallet/vault/storage/config.ts
5383
- var VAULT_SCHEMA_VERSION = 4;
5502
+ var VAULT_SCHEMA_VERSION = 5;
5384
5503
  function normalizeChainFingerprint(chainFingerprint) {
5385
5504
  return chainFingerprint ? chainFingerprint.toLowerCase() : void 0;
5386
5505
  }
@@ -5529,7 +5648,7 @@ async function noteBlindTag(blindKey, hex) {
5529
5648
 
5530
5649
  // src/wallet/vault/notes/meta.ts
5531
5650
  function noteOrigin(note) {
5532
- return note.counterpartyPk === 0n ? "shield" : "private-transfer";
5651
+ return note.sourcePk === 0n ? "shield" : "private-transfer";
5533
5652
  }
5534
5653
  function noteCreatedAt(note) {
5535
5654
  return note.createdAt ?? null;
@@ -5598,9 +5717,9 @@ var NOTE_BIGINT_FIELDS = [
5598
5717
  "spendingKey",
5599
5718
  "commitment",
5600
5719
  "nullifier",
5601
- "counterpartyPk"
5720
+ "sourcePk"
5602
5721
  ];
5603
- var ABSENT_MEANS_ZERO = /* @__PURE__ */ new Set(["counterpartyPk"]);
5722
+ var ABSENT_MEANS_ZERO = /* @__PURE__ */ new Set(["sourcePk"]);
5604
5723
  function normalizeNote(note) {
5605
5724
  let patch = null;
5606
5725
  for (const field of NOTE_BIGINT_FIELDS) {
@@ -5853,12 +5972,12 @@ var VaultStore = class {
5853
5972
  const { blindKey } = this.keys();
5854
5973
  if (commitmentHexes.length === 0) return 0;
5855
5974
  const toRemove = new Set(commitmentHexes);
5856
- const present = this.deps.notes.get().filter((n) => toRemove.has(n.commitmentHex));
5857
- if (present.length === 0) return 0;
5858
- const tags = await Promise.all(present.map((n) => noteBlindTag(blindKey, n.commitmentHex)));
5975
+ const present2 = this.deps.notes.get().filter((n) => toRemove.has(n.commitmentHex));
5976
+ if (present2.length === 0) return 0;
5977
+ const tags = await Promise.all(present2.map((n) => noteBlindTag(blindKey, n.commitmentHex)));
5859
5978
  await this.deps.storage.deleteNotes(tags);
5860
5979
  this.deps.notes.set(removeByCommitment(this.deps.notes.get(), toRemove));
5861
- return present.length;
5980
+ return present2.length;
5862
5981
  }
5863
5982
  /**
5864
5983
  * Stores an outgoing transaction, encrypted.
@@ -6460,26 +6579,147 @@ function parseFeeArg(argsJson) {
6460
6579
  }
6461
6580
  }
6462
6581
 
6582
+ // src/wallet/provenance/selectDescribingNote.ts
6583
+ function hasSourcePk(note) {
6584
+ return typeof note.sourcePk === "bigint" && note.sourcePk !== 0n;
6585
+ }
6586
+ function selectDescribingNote(candidates) {
6587
+ return candidates.find(hasSourcePk) ?? candidates[0];
6588
+ }
6589
+ function selectDescribingNoteByCommitment(commitments, noteByCommitment) {
6590
+ const owned = commitments.map((hex) => noteByCommitment.get(hex)).filter((note) => note !== void 0);
6591
+ return selectDescribingNote(owned);
6592
+ }
6593
+
6594
+ // src/wallet/provenance/merge.ts
6595
+ var SOURCE_RANK = {
6596
+ witnessed: 3,
6597
+ memo: 2,
6598
+ chain: 1,
6599
+ inferred: 0
6600
+ };
6601
+ function rankOf(source) {
6602
+ return SOURCE_RANK[source] ?? -1;
6603
+ }
6604
+ function outranks(a, b) {
6605
+ return rankOf(a) > rankOf(b);
6606
+ }
6607
+ function mergeProvenance(existing, incoming) {
6608
+ if (existing.id !== incoming.id) {
6609
+ throw new Error(
6610
+ `mergeProvenance: id mismatch \u2014 refusing to merge ${existing.id} with ${incoming.id}`
6611
+ );
6612
+ }
6613
+ const incomingWins = outranks(incoming.source, existing.source);
6614
+ const base = incomingWins ? incoming : existing;
6615
+ const other = incomingWins ? existing : incoming;
6616
+ const fee = base.feePlanck ?? other.feePlanck;
6617
+ const publicRecipient = firstPresent(base.publicRecipient, other.publicRecipient);
6618
+ const slip = present(base.slip?.encoded) ? base.slip : other.slip ?? base.slip;
6619
+ const note = base.note ?? other.note;
6620
+ return {
6621
+ // The loser is spread FIRST so a field only it carries survives. A host
6622
+ // stores its own record type through this — `ReconstructedTxRecord` has
6623
+ // `amountApproximate`, which marks an amount derived without subtracting
6624
+ // the fee — and dropping such a field turns an approximate figure into
6625
+ // one that merely looks exact. Every key the winner knows about is
6626
+ // overwritten below, so rank still decides every shared fact.
6627
+ ...other,
6628
+ ...base,
6629
+ // A known peer beats an unknown one even when the winner is silent:
6630
+ // backfilling the recipient is the whole point of re-running this.
6631
+ // `scope: 'none'` is "this operation has no counterparty", so it is a
6632
+ // gap too — treating it as known would block the backfill it exists for.
6633
+ peer: copyPeer(knownPeer(base.peer) ?? knownPeer(other.peer) ?? base.peer ?? other.peer),
6634
+ // A number that stands for "not known yet" must not win by rank. Zero is
6635
+ // exactly that here: `RECOVERED_TX_RESULT` and a failed submission both
6636
+ // report block 0, and their own comment says a caller who needs the
6637
+ // value must look it up rather than trust the field.
6638
+ blockNumber: firstPositive(base.blockNumber, other.blockNumber),
6639
+ timestampMs: firstPositive(base.timestampMs, other.timestampMs),
6640
+ // Reconstruction writes an empty hash when the extrinsic was not decoded.
6641
+ hash: firstPresent(base.hash, other.hash) ?? base.hash,
6642
+ // `exact` is a property of the FIGURE, not of the source. A `witnessed`
6643
+ // row whose amount was marked approximate is not better data than an
6644
+ // exact one from a weaker source, so rank only breaks a tie between two
6645
+ // figures of equal standing.
6646
+ amount: { ...betterAmount(base.amount, other.amount) },
6647
+ // The chain's outcome is not something one source knows better than
6648
+ // another — anyone who looks sees the same thing. Letting rank decide
6649
+ // would let a row written at submit time mark a transaction failed that
6650
+ // the chain went on to accept.
6651
+ status: base.status === "success" || other.status === "success" ? "success" : "failed",
6652
+ ...fee !== void 0 && { feePlanck: fee },
6653
+ ...publicRecipient !== void 0 && { publicRecipient },
6654
+ ...slip !== void 0 && { slip: { ...slip } },
6655
+ ...note !== void 0 && { note: { ...note } }
6656
+ };
6657
+ }
6658
+ function betterAmount(base, other) {
6659
+ if (base.exact === other.exact) return base;
6660
+ return base.exact ? base : other;
6661
+ }
6662
+ function present(value) {
6663
+ return value !== void 0 && value.length > 0;
6664
+ }
6665
+ function firstPresent(base, other) {
6666
+ return present(base) ? base : present(other) ? other : void 0;
6667
+ }
6668
+ function firstPositive(base, other) {
6669
+ return base > 0 ? base : other > 0 ? other : base;
6670
+ }
6671
+ function copyPeer(peer) {
6672
+ return peer ? { ...peer } : null;
6673
+ }
6674
+ function knownPeer(peer) {
6675
+ return peer && peer.scope !== "none" ? peer : null;
6676
+ }
6677
+
6678
+ // src/wallet/provenance/regenerateSlip.ts
6679
+ function regeneratePaymentSlip(facts, recipientIvkPacked, txHash) {
6680
+ if (!isHexOfLength(facts.commitmentHex, 32)) {
6681
+ throw new Error("regeneratePaymentSlip: commitmentHex must be 32 bytes of hex");
6682
+ }
6683
+ if (!isHexOfLength(facts.encryptedMemo, ENCRYPTED_MEMO_SIZE)) {
6684
+ throw new Error(
6685
+ `regeneratePaymentSlip: encryptedMemo must be ${ENCRYPTED_MEMO_SIZE} bytes of hex`
6686
+ );
6687
+ }
6688
+ if (facts.leafIndex !== void 0 && !isValidLeafIndex(facts.leafIndex)) {
6689
+ throw new Error("regeneratePaymentSlip: leafIndex must be a real tree position");
6690
+ }
6691
+ const envelope = sealPaymentSlip(recipientIvkPacked, {
6692
+ commitmentHex: facts.commitmentHex,
6693
+ encryptedMemo: facts.encryptedMemo,
6694
+ ...facts.leafIndex !== void 0 ? { leafIndex: facts.leafIndex } : {},
6695
+ // Rendered as an explorer link by the recipient, where an unconstrained
6696
+ // string is a URL injection wearing the authority of a decrypted slip.
6697
+ // Dropped rather than fatal: the slip still rebuilds the note, which is
6698
+ // the part that matters.
6699
+ ...isHexOfLength(txHash, 32) ? { txHash } : {}
6700
+ });
6701
+ return encodePaymentSlip(envelope);
6702
+ }
6703
+
6463
6704
  // src/wallet/scanner/history/reconstruct.ts
6464
6705
  var ZERO_PK = "0x" + "00".repeat(32);
6465
6706
  function extrinsicKey(row) {
6466
6707
  return `${row.blockNumber}:${row.extrinsicIndex ?? "null"}`;
6467
6708
  }
6468
- function toPkHex(counterpartyPk) {
6469
- return counterpartyPk != null && counterpartyPk !== 0n ? scalarToHex(counterpartyPk) : ZERO_PK;
6470
- }
6471
- function findChangeNote(commitments, noteByCommitment) {
6472
- const candidates = commitments.map((h) => noteByCommitment.get(h)).filter((n) => n !== void 0);
6473
- return candidates.find((n) => n.counterpartyPk != null && n.counterpartyPk !== 0n) ?? candidates[0];
6709
+ function toPkHex(sourcePk) {
6710
+ return typeof sourcePk === "bigint" && hasSourcePk({ sourcePk }) ? scalarToHex(sourcePk) : ZERO_PK;
6474
6711
  }
6475
6712
  async function loadExistingRecords(vault) {
6476
6713
  try {
6477
6714
  const records = await vault.getTxRecords();
6478
- return new Map(records.map((r) => [r.hash, r]));
6715
+ return new Map(records.map((r) => [r.id, r]));
6479
6716
  } catch {
6480
6717
  return /* @__PURE__ */ new Map();
6481
6718
  }
6482
6719
  }
6720
+ function recordKey(transfer) {
6721
+ return transfer.hash ?? `${transfer.blockNumber}-${transfer.extrinsicIndex ?? 0}`;
6722
+ }
6483
6723
  async function reconstructOutgoingTxRecords(deps) {
6484
6724
  const { vault, transfers } = deps;
6485
6725
  const now = deps.now ?? Date.now;
@@ -6496,17 +6736,17 @@ async function reconstructOutgoingTxRecords(deps) {
6496
6736
  const commitmentsByExtrinsic = new Map(
6497
6737
  commitmentTransfers.map((ct) => [extrinsicKey(ct), ct.matchedCommitments ?? []])
6498
6738
  );
6499
- const existingByHash = await loadExistingRecords(vault);
6739
+ const existingByKey = await loadExistingRecords(vault);
6500
6740
  for (const transfer of outgoingTransfers) {
6501
- const existing = transfer.hash ? existingByHash.get(transfer.hash) : void 0;
6741
+ const existing = existingByKey.get(recordKey(transfer));
6502
6742
  if (existing?.recipientPkHex && existing.recipientPkHex !== ZERO_PK) continue;
6503
6743
  const inputNotes = (transfer.matchedNullifiers ?? []).map((h) => noteByNullifier.get(h)).filter((n) => n !== void 0);
6504
6744
  if (inputNotes.length === 0) continue;
6505
- const changeNote = findChangeNote(
6745
+ const changeNote = selectDescribingNoteByCommitment(
6506
6746
  commitmentsByExtrinsic.get(extrinsicKey(transfer)) ?? [],
6507
6747
  noteByCommitment
6508
6748
  );
6509
- const recipientPkHex = toPkHex(changeNote?.counterpartyPk);
6749
+ const recipientPkHex = toPkHex(changeNote?.sourcePk);
6510
6750
  if (existing) {
6511
6751
  if (recipientPkHex === ZERO_PK) continue;
6512
6752
  await vault.saveTxRecord({ ...existing, recipientPkHex });
@@ -6517,7 +6757,7 @@ async function reconstructOutgoingTxRecords(deps) {
6517
6757
  const transferAmount = totalInputValue - (changeNote?.value ?? 0n) - (fee ?? 0n);
6518
6758
  if (transferAmount <= 0n) continue;
6519
6759
  const record = {
6520
- id: transfer.hash ?? `${transfer.blockNumber}-${transfer.extrinsicIndex ?? 0}`,
6760
+ id: recordKey(transfer),
6521
6761
  type: "private_transfer",
6522
6762
  blockNumber: transfer.blockNumber,
6523
6763
  hash: transfer.hash ?? "",
@@ -6568,7 +6808,7 @@ async function buildZkNote(params, deps) {
6568
6808
  circuitVersion: params.circuitVersion,
6569
6809
  // Omitted rather than passed as undefined: the builder distinguishes an
6570
6810
  // absent recipient (a self note) from one explicitly set.
6571
- ...params.counterpartyPk !== void 0 && { counterpartyPk: params.counterpartyPk },
6811
+ ...params.sourcePk !== void 0 && { sourcePk: params.sourcePk },
6572
6812
  ...params.recipientOwnerPk !== void 0 && {
6573
6813
  recipientOwnerPk: params.recipientOwnerPk
6574
6814
  },
@@ -6887,7 +7127,7 @@ async function transferNotes(deps, params, onProgress) {
6887
7127
  value: transferAmount,
6888
7128
  assetId: noteA.assetId,
6889
7129
  ownerPk: recipientPk,
6890
- counterpartyPk: effectiveSenderPk,
7130
+ sourcePk: effectiveSenderPk,
6891
7131
  // Undefined → dummy memo; the recipient finds the note by scanning.
6892
7132
  ...recipientViewingPublicKey !== void 0 ? { viewingPublicKey: recipientViewingPublicKey } : {},
6893
7133
  // With a viewing key present this activates stealth derivation.
@@ -6898,7 +7138,7 @@ async function transferNotes(deps, params, onProgress) {
6898
7138
  assetId: noteA.assetId,
6899
7139
  ownerPk: effectiveSenderPk,
6900
7140
  spendingKey: noteA.spendingKey,
6901
- counterpartyPk: recipientNote.ownerPk,
7141
+ sourcePk: recipientNote.ownerPk,
6902
7142
  viewingPublicKey: senderViewingPublicKey
6903
7143
  });
6904
7144
  onProgress?.("generating-zk");
@@ -7083,7 +7323,7 @@ async function unshieldNote(deps, params, onProgress) {
7083
7323
  var import_proof_generator8 = require("@orbinum/proof-generator");
7084
7324
  async function claimFees(deps, { assetId, amount, signer }, onStep) {
7085
7325
  onStep?.("building-note");
7086
- const note = await deps.buildNote({ value: amount, assetId: BigInt(assetId) });
7326
+ const { note } = await deps.buildNote({ value: amount, assetId: BigInt(assetId) });
7087
7327
  const { provider } = await deps.resolver.resolve(import_proof_generator8.CircuitType.ValueProof, note.circuitVersion);
7088
7328
  onStep?.("generating-proof");
7089
7329
  const proofOutput = await generateFeeClaimProof(
@@ -7930,6 +8170,7 @@ var OrbinumWallet = class {
7930
8170
  clearKnownEphWindow,
7931
8171
  clearSession,
7932
8172
  collectNullifiersToQuery,
8173
+ collectOutgoingFacts,
7933
8174
  collectScanEntries,
7934
8175
  commitmentHexOf,
7935
8176
  computeNoteCommitment,
@@ -8005,6 +8246,7 @@ var OrbinumWallet = class {
8005
8246
  getSubstrateSignerFromExtension,
8006
8247
  hasCachedSession,
8007
8248
  hasInjectedExtensions,
8249
+ hasSourcePk,
8008
8250
  hexToBigint,
8009
8251
  hexToNumber,
8010
8252
  implicitSubstrateToEvm,
@@ -8016,6 +8258,7 @@ var OrbinumWallet = class {
8016
8258
  isConnectionLossError,
8017
8259
  isEvmAddress,
8018
8260
  isGhostNoteError,
8261
+ isHexOfLength,
8019
8262
  isImplicitEvmAccount,
8020
8263
  isNativeAsset,
8021
8264
  isNoteSelfConsistent,
@@ -8028,6 +8271,7 @@ var OrbinumWallet = class {
8028
8271
  mapExtrinsicArgs,
8029
8272
  mapZkEventData,
8030
8273
  markInputsSpent,
8274
+ mergeProvenance,
8031
8275
  normalizeChainFingerprint,
8032
8276
  normalizeEvmAddress,
8033
8277
  normalizeNote,
@@ -8042,6 +8286,7 @@ var OrbinumWallet = class {
8042
8286
  noteTxKind,
8043
8287
  openOutgoingBlob,
8044
8288
  openPaymentSlip,
8289
+ outranks,
8045
8290
  pairwiseEphWindow,
8046
8291
  palletErrorKind,
8047
8292
  parseAmount,
@@ -8056,6 +8301,7 @@ var OrbinumWallet = class {
8056
8301
  recoverOwnerPkPoint,
8057
8302
  recoverSelfStealthNote,
8058
8303
  refuseIfAlreadySpent,
8304
+ regeneratePaymentSlip,
8059
8305
  removeByCommitment,
8060
8306
  requireSessionKeys,
8061
8307
  reservePairwiseIndex,
@@ -8069,6 +8315,8 @@ var OrbinumWallet = class {
8069
8315
  scanAbortError,
8070
8316
  sealOutgoingBlob,
8071
8317
  sealPaymentSlip,
8318
+ selectDescribingNote,
8319
+ selectDescribingNoteByCommitment,
8072
8320
  selectGhosts,
8073
8321
  selectNotes,
8074
8322
  selfEphWindow,