@unicitylabs/sphere-sdk 0.5.1 → 0.5.3

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.
Files changed (43) hide show
  1. package/dist/connect/index.cjs +3 -1
  2. package/dist/connect/index.cjs.map +1 -1
  3. package/dist/connect/index.js +3 -1
  4. package/dist/connect/index.js.map +1 -1
  5. package/dist/core/index.cjs +669 -277
  6. package/dist/core/index.cjs.map +1 -1
  7. package/dist/core/index.d.cts +57 -2
  8. package/dist/core/index.d.ts +57 -2
  9. package/dist/core/index.js +669 -277
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/impl/browser/connect/index.cjs +3 -1
  12. package/dist/impl/browser/connect/index.cjs.map +1 -1
  13. package/dist/impl/browser/connect/index.js +3 -1
  14. package/dist/impl/browser/connect/index.js.map +1 -1
  15. package/dist/impl/browser/index.cjs +11 -3
  16. package/dist/impl/browser/index.cjs.map +1 -1
  17. package/dist/impl/browser/index.js +11 -3
  18. package/dist/impl/browser/index.js.map +1 -1
  19. package/dist/impl/browser/ipfs.cjs +9 -2
  20. package/dist/impl/browser/ipfs.cjs.map +1 -1
  21. package/dist/impl/browser/ipfs.js +9 -2
  22. package/dist/impl/browser/ipfs.js.map +1 -1
  23. package/dist/impl/nodejs/connect/index.cjs +3 -1
  24. package/dist/impl/nodejs/connect/index.cjs.map +1 -1
  25. package/dist/impl/nodejs/connect/index.js +3 -1
  26. package/dist/impl/nodejs/connect/index.js.map +1 -1
  27. package/dist/impl/nodejs/index.cjs +11 -3
  28. package/dist/impl/nodejs/index.cjs.map +1 -1
  29. package/dist/impl/nodejs/index.d.cts +7 -0
  30. package/dist/impl/nodejs/index.d.ts +7 -0
  31. package/dist/impl/nodejs/index.js +11 -3
  32. package/dist/impl/nodejs/index.js.map +1 -1
  33. package/dist/index.cjs +671 -277
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +128 -3
  36. package/dist/index.d.ts +128 -3
  37. package/dist/index.js +670 -277
  38. package/dist/index.js.map +1 -1
  39. package/dist/l1/index.cjs +3 -1
  40. package/dist/l1/index.cjs.map +1 -1
  41. package/dist/l1/index.js +3 -1
  42. package/dist/l1/index.js.map +1 -1
  43. package/package.json +1 -1
@@ -105,7 +105,9 @@ var init_constants = __esm({
105
105
  /** Group chat: processed event IDs for deduplication */
106
106
  GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
107
107
  /** Processed V5 split group IDs for Nostr re-delivery dedup */
108
- PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids"
108
+ PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids",
109
+ /** Processed V6 combined transfer IDs for Nostr re-delivery dedup */
110
+ PROCESSED_COMBINED_TRANSFER_IDS: "processed_combined_transfer_ids"
109
111
  };
110
112
  STORAGE_KEYS = {
111
113
  ...STORAGE_KEYS_GLOBAL,
@@ -2599,7 +2601,7 @@ init_constants();
2599
2601
  // types/txf.ts
2600
2602
  var ARCHIVED_PREFIX = "archived-";
2601
2603
  var FORKED_PREFIX = "_forked_";
2602
- var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2604
+ var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity", "_history"];
2603
2605
  function isTokenKey(key) {
2604
2606
  return key.startsWith("_") && !key.startsWith(ARCHIVED_PREFIX) && !key.startsWith(FORKED_PREFIX) && !RESERVED_KEYS.includes(key);
2605
2607
  }
@@ -3184,6 +3186,9 @@ async function buildTxfStorageData(tokens, meta, options) {
3184
3186
  if (options?.invalidatedNametags && options.invalidatedNametags.length > 0) {
3185
3187
  storageData._invalidatedNametags = options.invalidatedNametags;
3186
3188
  }
3189
+ if (options?.historyEntries && options.historyEntries.length > 0) {
3190
+ storageData._history = options.historyEntries;
3191
+ }
3187
3192
  for (const token of tokens) {
3188
3193
  const txf = tokenToTxf(token);
3189
3194
  if (txf) {
@@ -3217,6 +3222,7 @@ function parseTxfStorageData(data) {
3217
3222
  outboxEntries: [],
3218
3223
  mintOutboxEntries: [],
3219
3224
  invalidatedNametags: [],
3225
+ historyEntries: [],
3220
3226
  validationErrors: []
3221
3227
  };
3222
3228
  if (!data || typeof data !== "object") {
@@ -3270,6 +3276,13 @@ function parseTxfStorageData(data) {
3270
3276
  }
3271
3277
  }
3272
3278
  }
3279
+ if (Array.isArray(storageData._history)) {
3280
+ for (const entry of storageData._history) {
3281
+ if (typeof entry === "object" && entry !== null && typeof entry.dedupKey === "string" && typeof entry.type === "string") {
3282
+ result.historyEntries.push(entry);
3283
+ }
3284
+ }
3285
+ }
3273
3286
  for (const key of Object.keys(storageData)) {
3274
3287
  if (isTokenKey(key)) {
3275
3288
  const tokenId = tokenIdFromKey(key);
@@ -3381,14 +3394,149 @@ var InstantSplitExecutor = class {
3381
3394
  this.devMode = config.devMode ?? false;
3382
3395
  }
3383
3396
  /**
3384
- * Execute an instant split transfer with V5 optimized flow.
3397
+ * Build a V5 split bundle WITHOUT sending it via transport.
3385
3398
  *
3386
- * Critical path (~2.3s):
3399
+ * Steps 1-5 of the V5 flow:
3387
3400
  * 1. Create and submit burn commitment
3388
3401
  * 2. Wait for burn proof
3389
3402
  * 3. Create mint commitments with SplitMintReason
3390
3403
  * 4. Create transfer commitment (no mint proof needed)
3391
- * 5. Send bundle via transport
3404
+ * 5. Package V5 bundle
3405
+ *
3406
+ * The caller is responsible for sending the bundle and then calling
3407
+ * `startBackground()` on the result to begin mint proof + change token creation.
3408
+ */
3409
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3410
+ const splitGroupId = crypto.randomUUID();
3411
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3412
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3413
+ const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3414
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3415
+ const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3416
+ const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3417
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3418
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3419
+ const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3420
+ tokenToSplit.type,
3421
+ this.signingService.algorithm,
3422
+ this.signingService.publicKey,
3423
+ import_HashAlgorithm3.HashAlgorithm.SHA256
3424
+ );
3425
+ const senderAddress = await senderAddressRef.toAddress();
3426
+ const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3427
+ const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3428
+ builder.createToken(
3429
+ recipientTokenId,
3430
+ tokenToSplit.type,
3431
+ new Uint8Array(0),
3432
+ coinDataA,
3433
+ senderAddress,
3434
+ // Mint to sender first, then transfer
3435
+ recipientSalt,
3436
+ null
3437
+ );
3438
+ const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3439
+ builder.createToken(
3440
+ senderTokenId,
3441
+ tokenToSplit.type,
3442
+ new Uint8Array(0),
3443
+ coinDataB,
3444
+ senderAddress,
3445
+ senderSalt,
3446
+ null
3447
+ );
3448
+ const split = await builder.build(tokenToSplit);
3449
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3450
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3451
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3452
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3453
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3454
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3455
+ }
3456
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3457
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3458
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3459
+ console.log(`[InstantSplit] Burn proof received`);
3460
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3461
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3462
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3463
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3464
+ const senderIdHex = toHex2(senderTokenId.bytes);
3465
+ const recipientMintCommitment = mintCommitments.find(
3466
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3467
+ );
3468
+ const senderMintCommitment = mintCommitments.find(
3469
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3470
+ );
3471
+ if (!recipientMintCommitment || !senderMintCommitment) {
3472
+ throw new Error("Failed to find expected mint commitments");
3473
+ }
3474
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3475
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3476
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3477
+ recipientMintCommitment.transactionData,
3478
+ recipientAddress,
3479
+ transferSalt,
3480
+ this.signingService
3481
+ );
3482
+ const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3483
+ recipientTokenId,
3484
+ tokenToSplit.type,
3485
+ this.signingService,
3486
+ import_HashAlgorithm3.HashAlgorithm.SHA256,
3487
+ recipientSalt
3488
+ );
3489
+ const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3490
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3491
+ const senderPubkey = toHex2(this.signingService.publicKey);
3492
+ let nametagTokenJson;
3493
+ const recipientAddressStr = recipientAddress.toString();
3494
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3495
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3496
+ }
3497
+ const bundle = {
3498
+ version: "5.0",
3499
+ type: "INSTANT_SPLIT",
3500
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3501
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3502
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3503
+ amount: splitAmount.toString(),
3504
+ coinId: coinIdHex,
3505
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3506
+ splitGroupId,
3507
+ senderPubkey,
3508
+ recipientSaltHex: toHex2(recipientSalt),
3509
+ transferSaltHex: toHex2(transferSalt),
3510
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3511
+ finalRecipientStateJson: "",
3512
+ // Recipient creates their own
3513
+ recipientAddressJson: recipientAddressStr,
3514
+ nametagTokenJson
3515
+ };
3516
+ return {
3517
+ bundle,
3518
+ splitGroupId,
3519
+ startBackground: async () => {
3520
+ if (!options?.skipBackground) {
3521
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3522
+ signingService: this.signingService,
3523
+ tokenType: tokenToSplit.type,
3524
+ coinId,
3525
+ senderTokenId,
3526
+ senderSalt,
3527
+ onProgress: options?.onBackgroundProgress,
3528
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3529
+ onStorageSync: options?.onStorageSync
3530
+ });
3531
+ }
3532
+ }
3533
+ };
3534
+ }
3535
+ /**
3536
+ * Execute an instant split transfer with V5 optimized flow.
3537
+ *
3538
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3539
+ * and starts background processing.
3392
3540
  *
3393
3541
  * @param tokenToSplit - The SDK token to split
3394
3542
  * @param splitAmount - Amount to send to recipient
@@ -3402,117 +3550,19 @@ var InstantSplitExecutor = class {
3402
3550
  */
3403
3551
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3404
3552
  const startTime = performance.now();
3405
- const splitGroupId = crypto.randomUUID();
3406
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3407
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3408
3553
  try {
3409
- const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3410
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3411
- const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3412
- const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3413
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3414
- const senderSalt = await sha2563(seedString + "_sender_salt");
3415
- const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3416
- tokenToSplit.type,
3417
- this.signingService.algorithm,
3418
- this.signingService.publicKey,
3419
- import_HashAlgorithm3.HashAlgorithm.SHA256
3420
- );
3421
- const senderAddress = await senderAddressRef.toAddress();
3422
- const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3423
- const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3424
- builder.createToken(
3425
- recipientTokenId,
3426
- tokenToSplit.type,
3427
- new Uint8Array(0),
3428
- coinDataA,
3429
- senderAddress,
3430
- // Mint to sender first, then transfer
3431
- recipientSalt,
3432
- null
3433
- );
3434
- const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3435
- builder.createToken(
3436
- senderTokenId,
3437
- tokenToSplit.type,
3438
- new Uint8Array(0),
3439
- coinDataB,
3440
- senderAddress,
3441
- senderSalt,
3442
- null
3443
- );
3444
- const split = await builder.build(tokenToSplit);
3445
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3446
- const burnSalt = await sha2563(seedString + "_burn_salt");
3447
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3448
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3449
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3450
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3451
- }
3452
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3453
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3454
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3455
- const burnDuration = performance.now() - startTime;
3456
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3457
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3458
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3459
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3460
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3461
- const senderIdHex = toHex2(senderTokenId.bytes);
3462
- const recipientMintCommitment = mintCommitments.find(
3463
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3464
- );
3465
- const senderMintCommitment = mintCommitments.find(
3466
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3467
- );
3468
- if (!recipientMintCommitment || !senderMintCommitment) {
3469
- throw new Error("Failed to find expected mint commitments");
3470
- }
3471
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3472
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3473
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3474
- recipientMintCommitment.transactionData,
3554
+ const buildResult = await this.buildSplitBundle(
3555
+ tokenToSplit,
3556
+ splitAmount,
3557
+ remainderAmount,
3558
+ coinIdHex,
3475
3559
  recipientAddress,
3476
- transferSalt,
3477
- this.signingService
3478
- );
3479
- const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3480
- recipientTokenId,
3481
- tokenToSplit.type,
3482
- this.signingService,
3483
- import_HashAlgorithm3.HashAlgorithm.SHA256,
3484
- recipientSalt
3560
+ options
3485
3561
  );
3486
- const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3487
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3562
+ console.log("[InstantSplit] Sending via transport...");
3488
3563
  const senderPubkey = toHex2(this.signingService.publicKey);
3489
- let nametagTokenJson;
3490
- const recipientAddressStr = recipientAddress.toString();
3491
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3492
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3493
- }
3494
- const bundle = {
3495
- version: "5.0",
3496
- type: "INSTANT_SPLIT",
3497
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3498
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3499
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3500
- amount: splitAmount.toString(),
3501
- coinId: coinIdHex,
3502
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3503
- splitGroupId,
3504
- senderPubkey,
3505
- recipientSaltHex: toHex2(recipientSalt),
3506
- transferSaltHex: toHex2(transferSalt),
3507
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3508
- finalRecipientStateJson: "",
3509
- // Recipient creates their own
3510
- recipientAddressJson: recipientAddressStr,
3511
- nametagTokenJson
3512
- };
3513
- console.log("[InstantSplit] Step 6: Sending via transport...");
3514
3564
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3515
- token: JSON.stringify(bundle),
3565
+ token: JSON.stringify(buildResult.bundle),
3516
3566
  proof: null,
3517
3567
  // Proof is included in the bundle
3518
3568
  memo: options?.memo,
@@ -3523,25 +3573,13 @@ var InstantSplitExecutor = class {
3523
3573
  const criticalPathDuration = performance.now() - startTime;
3524
3574
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3525
3575
  options?.onNostrDelivered?.(nostrEventId);
3526
- let backgroundPromise;
3527
- if (!options?.skipBackground) {
3528
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3529
- signingService: this.signingService,
3530
- tokenType: tokenToSplit.type,
3531
- coinId,
3532
- senderTokenId,
3533
- senderSalt,
3534
- onProgress: options?.onBackgroundProgress,
3535
- onChangeTokenCreated: options?.onChangeTokenCreated,
3536
- onStorageSync: options?.onStorageSync
3537
- });
3538
- }
3576
+ const backgroundPromise = buildResult.startBackground();
3539
3577
  return {
3540
3578
  success: true,
3541
3579
  nostrEventId,
3542
- splitGroupId,
3580
+ splitGroupId: buildResult.splitGroupId,
3543
3581
  criticalPathDurationMs: criticalPathDuration,
3544
- backgroundStarted: !options?.skipBackground,
3582
+ backgroundStarted: true,
3545
3583
  backgroundPromise
3546
3584
  };
3547
3585
  } catch (error) {
@@ -3550,7 +3588,6 @@ var InstantSplitExecutor = class {
3550
3588
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3551
3589
  return {
3552
3590
  success: false,
3553
- splitGroupId,
3554
3591
  criticalPathDurationMs: duration,
3555
3592
  error: errorMessage,
3556
3593
  backgroundStarted: false
@@ -3755,6 +3792,11 @@ function isInstantSplitBundleV4(obj) {
3755
3792
  function isInstantSplitBundleV5(obj) {
3756
3793
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3757
3794
  }
3795
+ function isCombinedTransferBundleV6(obj) {
3796
+ if (typeof obj !== "object" || obj === null) return false;
3797
+ const b = obj;
3798
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
3799
+ }
3758
3800
 
3759
3801
  // modules/payments/InstantSplitProcessor.ts
3760
3802
  function fromHex3(hex) {
@@ -4079,6 +4121,7 @@ function computeHistoryDedupKey(type, tokenId, transferId) {
4079
4121
  if (tokenId) return `${type}_${tokenId}`;
4080
4122
  return `${type}_${crypto.randomUUID()}`;
4081
4123
  }
4124
+ var MAX_SYNCED_HISTORY_ENTRIES = 5e3;
4082
4125
  function enrichWithRegistry(info) {
4083
4126
  const registry = TokenRegistry.getInstance();
4084
4127
  const def = registry.getDefinition(info.coinId);
@@ -4408,6 +4451,8 @@ var PaymentsModule = class _PaymentsModule {
4408
4451
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4409
4452
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4410
4453
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4454
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4455
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4411
4456
  // Storage event subscriptions (push-based sync)
4412
4457
  storageEventUnsubscribers = [];
4413
4458
  syncDebounceTimer = null;
@@ -4501,6 +4546,10 @@ var PaymentsModule = class _PaymentsModule {
4501
4546
  const result = await provider.load();
4502
4547
  if (result.success && result.data) {
4503
4548
  this.loadFromStorageData(result.data);
4549
+ const txfData = result.data;
4550
+ if (txfData._history && txfData._history.length > 0) {
4551
+ await this.importRemoteHistoryEntries(txfData._history);
4552
+ }
4504
4553
  this.log(`Loaded metadata from provider ${id}`);
4505
4554
  break;
4506
4555
  }
@@ -4508,10 +4557,23 @@ var PaymentsModule = class _PaymentsModule {
4508
4557
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4509
4558
  }
4510
4559
  }
4560
+ for (const [id, token] of this.tokens) {
4561
+ try {
4562
+ if (token.sdkData) {
4563
+ const data = JSON.parse(token.sdkData);
4564
+ if (data?._placeholder) {
4565
+ this.tokens.delete(id);
4566
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4567
+ }
4568
+ }
4569
+ } catch {
4570
+ }
4571
+ }
4511
4572
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4512
4573
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4513
4574
  await this.loadPendingV5Tokens();
4514
4575
  await this.loadProcessedSplitGroupIds();
4576
+ await this.loadProcessedCombinedTransferIds();
4515
4577
  await this.loadHistory();
4516
4578
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4517
4579
  if (pending2) {
@@ -4603,12 +4665,13 @@ var PaymentsModule = class _PaymentsModule {
4603
4665
  token.status = "transferring";
4604
4666
  this.tokens.set(token.id, token);
4605
4667
  }
4668
+ await this.save();
4606
4669
  await this.saveToOutbox(result, recipientPubkey);
4607
4670
  result.status = "submitted";
4608
4671
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4609
4672
  const transferMode = request.transferMode ?? "instant";
4610
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4611
- if (transferMode === "conservative") {
4673
+ if (transferMode === "conservative") {
4674
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4612
4675
  this.log("Executing conservative split...");
4613
4676
  const splitExecutor = new TokenSplitExecutor({
4614
4677
  stateTransitionClient: stClient,
@@ -4652,27 +4715,59 @@ var PaymentsModule = class _PaymentsModule {
4652
4715
  requestIdHex: splitRequestIdHex
4653
4716
  });
4654
4717
  this.log(`Conservative split transfer completed`);
4655
- } else {
4656
- this.log("Executing instant split...");
4657
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
4718
+ }
4719
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4720
+ const token = tokenWithAmount.uiToken;
4721
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4722
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4723
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4724
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4725
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4726
+ }
4727
+ const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
4728
+ const transferTx = commitment.toTransaction(inclusionProof);
4729
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4730
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4731
+ transferTx: JSON.stringify(transferTx.toJSON()),
4732
+ memo: request.memo
4733
+ });
4734
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4735
+ const requestIdBytes = commitment.requestId;
4736
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4737
+ result.tokenTransfers.push({
4738
+ sourceTokenId: token.id,
4739
+ method: "direct",
4740
+ requestIdHex
4741
+ });
4742
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
4743
+ await this.removeToken(token.id);
4744
+ }
4745
+ } else {
4746
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4747
+ const senderPubkey = this.deps.identity.chainPubkey;
4748
+ let changeTokenPlaceholderId = null;
4749
+ let builtSplit = null;
4750
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4751
+ this.log("Building instant split bundle...");
4658
4752
  const executor = new InstantSplitExecutor({
4659
4753
  stateTransitionClient: stClient,
4660
4754
  trustBase,
4661
4755
  signingService,
4662
4756
  devMode
4663
4757
  });
4664
- const instantResult = await executor.executeSplitInstant(
4758
+ builtSplit = await executor.buildSplitBundle(
4665
4759
  splitPlan.tokenToSplit.sdkToken,
4666
4760
  splitPlan.splitAmount,
4667
4761
  splitPlan.remainderAmount,
4668
4762
  splitPlan.coinId,
4669
4763
  recipientAddress,
4670
- this.deps.transport,
4671
- recipientPubkey,
4672
4764
  {
4673
4765
  memo: request.memo,
4674
4766
  onChangeTokenCreated: async (changeToken) => {
4675
4767
  const changeTokenData = changeToken.toJSON();
4768
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4769
+ this.tokens.delete(changeTokenPlaceholderId);
4770
+ }
4676
4771
  const uiToken = {
4677
4772
  id: crypto.randomUUID(),
4678
4773
  coinId: request.coinId,
@@ -4695,65 +4790,103 @@ var PaymentsModule = class _PaymentsModule {
4695
4790
  }
4696
4791
  }
4697
4792
  );
4698
- if (!instantResult.success) {
4699
- throw new Error(instantResult.error || "Instant split failed");
4700
- }
4701
- if (instantResult.backgroundPromise) {
4702
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4703
- }
4793
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
4794
+ }
4795
+ const directCommitments = await Promise.all(
4796
+ splitPlan.tokensToTransferDirectly.map(
4797
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
4798
+ )
4799
+ );
4800
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
4801
+ (tw, i) => ({
4802
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
4803
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
4804
+ amount: tw.uiToken.amount,
4805
+ coinId: tw.uiToken.coinId,
4806
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
4807
+ })
4808
+ );
4809
+ const combinedBundle = {
4810
+ version: "6.0",
4811
+ type: "COMBINED_TRANSFER",
4812
+ transferId: result.id,
4813
+ splitBundle: builtSplit?.bundle ?? null,
4814
+ directTokens: directTokenEntries,
4815
+ totalAmount: request.amount.toString(),
4816
+ coinId: request.coinId,
4817
+ senderPubkey,
4818
+ memo: request.memo
4819
+ };
4820
+ console.log(
4821
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
4822
+ );
4823
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4824
+ token: JSON.stringify(combinedBundle),
4825
+ proof: null,
4826
+ memo: request.memo,
4827
+ sender: { transportPubkey: senderPubkey }
4828
+ });
4829
+ console.log(`[Payments] V6 combined bundle sent successfully`);
4830
+ if (builtSplit) {
4831
+ const bgPromise = builtSplit.startBackground();
4832
+ this.pendingBackgroundTasks.push(bgPromise);
4833
+ }
4834
+ if (builtSplit && splitPlan.remainderAmount) {
4835
+ changeTokenPlaceholderId = crypto.randomUUID();
4836
+ const placeholder = {
4837
+ id: changeTokenPlaceholderId,
4838
+ coinId: request.coinId,
4839
+ symbol: this.getCoinSymbol(request.coinId),
4840
+ name: this.getCoinName(request.coinId),
4841
+ decimals: this.getCoinDecimals(request.coinId),
4842
+ iconUrl: this.getCoinIconUrl(request.coinId),
4843
+ amount: splitPlan.remainderAmount.toString(),
4844
+ status: "transferring",
4845
+ createdAt: Date.now(),
4846
+ updatedAt: Date.now(),
4847
+ sdkData: JSON.stringify({ _placeholder: true })
4848
+ };
4849
+ this.tokens.set(placeholder.id, placeholder);
4850
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
4851
+ }
4852
+ for (const commitment of directCommitments) {
4853
+ stClient.submitTransferCommitment(commitment).catch(
4854
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4855
+ );
4856
+ }
4857
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4704
4858
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4705
4859
  result.tokenTransfers.push({
4706
4860
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4707
4861
  method: "split",
4708
- splitGroupId: instantResult.splitGroupId,
4709
- nostrEventId: instantResult.nostrEventId
4862
+ splitGroupId: builtSplit.splitGroupId
4710
4863
  });
4711
- this.log(`Instant split transfer completed`);
4712
4864
  }
4713
- }
4714
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4715
- const token = tokenWithAmount.uiToken;
4716
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4717
- if (transferMode === "conservative") {
4718
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4719
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4720
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4721
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4722
- }
4723
- const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
4724
- const transferTx = commitment.toTransaction(inclusionProof);
4725
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4726
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4727
- transferTx: JSON.stringify(transferTx.toJSON()),
4728
- memo: request.memo
4729
- });
4730
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4731
- } else {
4732
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4733
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4734
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4735
- commitmentData: JSON.stringify(commitment.toJSON()),
4736
- memo: request.memo
4865
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
4866
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
4867
+ const commitment = directCommitments[i];
4868
+ const requestIdBytes = commitment.requestId;
4869
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4870
+ result.tokenTransfers.push({
4871
+ sourceTokenId: token.id,
4872
+ method: "direct",
4873
+ requestIdHex
4737
4874
  });
4738
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4739
- stClient.submitTransferCommitment(commitment).catch(
4740
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4741
- );
4875
+ await this.removeToken(token.id);
4742
4876
  }
4743
- const requestIdBytes = commitment.requestId;
4744
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4745
- result.tokenTransfers.push({
4746
- sourceTokenId: token.id,
4747
- method: "direct",
4748
- requestIdHex
4749
- });
4750
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4751
- await this.removeToken(token.id);
4877
+ this.log(`V6 combined transfer completed`);
4752
4878
  }
4753
4879
  result.status = "delivered";
4754
4880
  await this.save();
4755
4881
  await this.removeFromOutbox(result.id);
4756
4882
  result.status = "completed";
4883
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
4884
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
4885
+ id: tt.sourceTokenId,
4886
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
4887
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
4888
+ source: tt.method === "split" ? "split" : "direct"
4889
+ }));
4757
4890
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4758
4891
  await this.addToHistory({
4759
4892
  type: "SENT",
@@ -4766,7 +4899,8 @@ var PaymentsModule = class _PaymentsModule {
4766
4899
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4767
4900
  memo: request.memo,
4768
4901
  transferId: result.id,
4769
- tokenId: sentTokenId || void 0
4902
+ tokenId: sentTokenId || void 0,
4903
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4770
4904
  });
4771
4905
  this.deps.emitEvent("transfer:confirmed", result);
4772
4906
  return result;
@@ -4936,6 +5070,267 @@ var PaymentsModule = class _PaymentsModule {
4936
5070
  };
4937
5071
  }
4938
5072
  }
5073
+ // ===========================================================================
5074
+ // Shared Helpers for V5 and V6 Receiver Processing
5075
+ // ===========================================================================
5076
+ /**
5077
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
5078
+ * Returns the created UI token, or null if deduped.
5079
+ *
5080
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
5081
+ * The token is still added to the in-memory map for dedup; caller must call save().
5082
+ */
5083
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
5084
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
5085
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5086
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5087
+ return null;
5088
+ }
5089
+ const registry = TokenRegistry.getInstance();
5090
+ const pendingData = {
5091
+ type: "v5_bundle",
5092
+ stage: "RECEIVED",
5093
+ bundleJson: JSON.stringify(bundle),
5094
+ senderPubkey,
5095
+ savedAt: Date.now(),
5096
+ attemptCount: 0
5097
+ };
5098
+ const uiToken = {
5099
+ id: deterministicId,
5100
+ coinId: bundle.coinId,
5101
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5102
+ name: registry.getName(bundle.coinId) || bundle.coinId,
5103
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
5104
+ amount: bundle.amount,
5105
+ status: "submitted",
5106
+ // UNCONFIRMED
5107
+ createdAt: Date.now(),
5108
+ updatedAt: Date.now(),
5109
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5110
+ };
5111
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5112
+ if (deferPersistence) {
5113
+ this.tokens.set(uiToken.id, uiToken);
5114
+ } else {
5115
+ await this.addToken(uiToken);
5116
+ await this.saveProcessedSplitGroupIds();
5117
+ }
5118
+ return uiToken;
5119
+ }
5120
+ /**
5121
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5122
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5123
+ * Returns the created UI token, or null if deduped/tombstoned.
5124
+ *
5125
+ * @param deferPersistence - If true, skip save() and commitment submission
5126
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5127
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5128
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5129
+ */
5130
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5131
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5132
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5133
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5134
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5135
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5136
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5137
+ return null;
5138
+ }
5139
+ if (nostrTokenId) {
5140
+ for (const existing of this.tokens.values()) {
5141
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5142
+ if (existingTokenId !== nostrTokenId) continue;
5143
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5144
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5145
+ console.log(
5146
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5147
+ );
5148
+ return null;
5149
+ }
5150
+ if (!skipGenesisDedup) {
5151
+ console.log(
5152
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5153
+ );
5154
+ return null;
5155
+ }
5156
+ }
5157
+ }
5158
+ const token = {
5159
+ id: crypto.randomUUID(),
5160
+ coinId: tokenInfo.coinId,
5161
+ symbol: tokenInfo.symbol,
5162
+ name: tokenInfo.name,
5163
+ decimals: tokenInfo.decimals,
5164
+ iconUrl: tokenInfo.iconUrl,
5165
+ amount: tokenInfo.amount,
5166
+ status: "submitted",
5167
+ // NOSTR-FIRST: unconfirmed until proof
5168
+ createdAt: Date.now(),
5169
+ updatedAt: Date.now(),
5170
+ sdkData
5171
+ };
5172
+ this.tokens.set(token.id, token);
5173
+ if (!deferPersistence) {
5174
+ await this.save();
5175
+ }
5176
+ try {
5177
+ const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
5178
+ const requestIdBytes = commitment.requestId;
5179
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5180
+ if (!deferPersistence) {
5181
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5182
+ if (stClient) {
5183
+ const response = await stClient.submitTransferCommitment(commitment);
5184
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5185
+ }
5186
+ }
5187
+ this.addProofPollingJob({
5188
+ tokenId: token.id,
5189
+ requestIdHex,
5190
+ commitmentJson: JSON.stringify(commitmentInput),
5191
+ startedAt: Date.now(),
5192
+ attemptCount: 0,
5193
+ lastAttemptAt: 0,
5194
+ onProofReceived: async (tokenId) => {
5195
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5196
+ }
5197
+ });
5198
+ } catch (err) {
5199
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5200
+ }
5201
+ return token;
5202
+ }
5203
+ // ===========================================================================
5204
+ // Combined Transfer V6 — Receiver
5205
+ // ===========================================================================
5206
+ /**
5207
+ * Process a received COMBINED_TRANSFER V6 bundle.
5208
+ *
5209
+ * Unpacks a single Nostr message into its component tokens:
5210
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5211
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5212
+ *
5213
+ * Emits ONE transfer:incoming event and records ONE history entry.
5214
+ */
5215
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5216
+ this.ensureInitialized();
5217
+ if (!this.loaded && this.loadedPromise) {
5218
+ await this.loadedPromise;
5219
+ }
5220
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5221
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5222
+ return;
5223
+ }
5224
+ console.log(
5225
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5226
+ );
5227
+ const allTokens = [];
5228
+ const tokenBreakdown = [];
5229
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5230
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5231
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5232
+ }));
5233
+ if (bundle.splitBundle) {
5234
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5235
+ if (splitToken) {
5236
+ allTokens.push(splitToken);
5237
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5238
+ } else {
5239
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5240
+ }
5241
+ }
5242
+ const directResults = await Promise.all(
5243
+ parsedDirectEntries.map(
5244
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5245
+ )
5246
+ );
5247
+ for (let i = 0; i < directResults.length; i++) {
5248
+ const token = directResults[i];
5249
+ if (token) {
5250
+ allTokens.push(token);
5251
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5252
+ } else {
5253
+ const entry = bundle.directTokens[i];
5254
+ console.warn(
5255
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5256
+ );
5257
+ }
5258
+ }
5259
+ if (allTokens.length === 0) {
5260
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5261
+ return;
5262
+ }
5263
+ this.processedCombinedTransferIds.add(bundle.transferId);
5264
+ const [senderInfo] = await Promise.all([
5265
+ this.resolveSenderInfo(senderPubkey),
5266
+ this.save(),
5267
+ this.saveProcessedCombinedTransferIds(),
5268
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5269
+ ]);
5270
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5271
+ if (stClient) {
5272
+ for (const { commitment } of parsedDirectEntries) {
5273
+ import_TransferCommitment4.TransferCommitment.fromJSON(commitment).then(
5274
+ (c) => stClient.submitTransferCommitment(c)
5275
+ ).catch(
5276
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5277
+ );
5278
+ }
5279
+ }
5280
+ this.deps.emitEvent("transfer:incoming", {
5281
+ id: bundle.transferId,
5282
+ senderPubkey,
5283
+ senderNametag: senderInfo.senderNametag,
5284
+ tokens: allTokens,
5285
+ memo: bundle.memo,
5286
+ receivedAt: Date.now()
5287
+ });
5288
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5289
+ await this.addToHistory({
5290
+ type: "RECEIVED",
5291
+ amount: actualAmount,
5292
+ coinId: bundle.coinId,
5293
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5294
+ timestamp: Date.now(),
5295
+ senderPubkey,
5296
+ ...senderInfo,
5297
+ memo: bundle.memo,
5298
+ transferId: bundle.transferId,
5299
+ tokenId: allTokens[0]?.id,
5300
+ tokenIds: tokenBreakdown
5301
+ });
5302
+ if (bundle.splitBundle) {
5303
+ this.resolveUnconfirmed().catch(() => {
5304
+ });
5305
+ this.scheduleResolveUnconfirmed();
5306
+ }
5307
+ }
5308
+ /**
5309
+ * Persist processed combined transfer IDs to KV storage.
5310
+ */
5311
+ async saveProcessedCombinedTransferIds() {
5312
+ const ids = Array.from(this.processedCombinedTransferIds);
5313
+ if (ids.length > 0) {
5314
+ await this.deps.storage.set(
5315
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5316
+ JSON.stringify(ids)
5317
+ );
5318
+ }
5319
+ }
5320
+ /**
5321
+ * Load processed combined transfer IDs from KV storage.
5322
+ */
5323
+ async loadProcessedCombinedTransferIds() {
5324
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5325
+ if (!data) return;
5326
+ try {
5327
+ const ids = JSON.parse(data);
5328
+ for (const id of ids) {
5329
+ this.processedCombinedTransferIds.add(id);
5330
+ }
5331
+ } catch {
5332
+ }
5333
+ }
4939
5334
  /**
4940
5335
  * Process a received INSTANT_SPLIT bundle.
4941
5336
  *
@@ -4959,36 +5354,10 @@ var PaymentsModule = class _PaymentsModule {
4959
5354
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
4960
5355
  }
4961
5356
  try {
4962
- const deterministicId = `v5split_${bundle.splitGroupId}`;
4963
- if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
4964
- console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5357
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5358
+ if (!uiToken) {
4965
5359
  return { success: true, durationMs: 0 };
4966
5360
  }
4967
- const registry = TokenRegistry.getInstance();
4968
- const pendingData = {
4969
- type: "v5_bundle",
4970
- stage: "RECEIVED",
4971
- bundleJson: JSON.stringify(bundle),
4972
- senderPubkey,
4973
- savedAt: Date.now(),
4974
- attemptCount: 0
4975
- };
4976
- const uiToken = {
4977
- id: deterministicId,
4978
- coinId: bundle.coinId,
4979
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4980
- name: registry.getName(bundle.coinId) || bundle.coinId,
4981
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
4982
- amount: bundle.amount,
4983
- status: "submitted",
4984
- // UNCONFIRMED
4985
- createdAt: Date.now(),
4986
- updatedAt: Date.now(),
4987
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4988
- };
4989
- await this.addToken(uiToken);
4990
- this.processedSplitGroupIds.add(bundle.splitGroupId);
4991
- await this.saveProcessedSplitGroupIds();
4992
5361
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
4993
5362
  await this.addToHistory({
4994
5363
  type: "RECEIVED",
@@ -4999,7 +5368,7 @@ var PaymentsModule = class _PaymentsModule {
4999
5368
  senderPubkey,
5000
5369
  ...senderInfo,
5001
5370
  memo,
5002
- tokenId: deterministicId
5371
+ tokenId: uiToken.id
5003
5372
  });
5004
5373
  this.deps.emitEvent("transfer:incoming", {
5005
5374
  id: bundle.splitGroupId,
@@ -5649,16 +6018,18 @@ var PaymentsModule = class _PaymentsModule {
5649
6018
  }
5650
6019
  /**
5651
6020
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5652
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
6021
+ * Excludes tokens with status 'spent' or 'invalid'.
6022
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
5653
6023
  */
5654
6024
  aggregateTokens(coinId) {
5655
6025
  const assetsMap = /* @__PURE__ */ new Map();
5656
6026
  for (const token of this.tokens.values()) {
5657
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6027
+ if (token.status === "spent" || token.status === "invalid") continue;
5658
6028
  if (coinId && token.coinId !== coinId) continue;
5659
6029
  const key = token.coinId;
5660
6030
  const amount = BigInt(token.amount);
5661
6031
  const isConfirmed = token.status === "confirmed";
6032
+ const isTransferring = token.status === "transferring";
5662
6033
  const existing = assetsMap.get(key);
5663
6034
  if (existing) {
5664
6035
  if (isConfirmed) {
@@ -5668,6 +6039,7 @@ var PaymentsModule = class _PaymentsModule {
5668
6039
  existing.unconfirmedAmount += amount;
5669
6040
  existing.unconfirmedTokenCount++;
5670
6041
  }
6042
+ if (isTransferring) existing.transferringTokenCount++;
5671
6043
  } else {
5672
6044
  assetsMap.set(key, {
5673
6045
  coinId: token.coinId,
@@ -5678,7 +6050,8 @@ var PaymentsModule = class _PaymentsModule {
5678
6050
  confirmedAmount: isConfirmed ? amount : 0n,
5679
6051
  unconfirmedAmount: isConfirmed ? 0n : amount,
5680
6052
  confirmedTokenCount: isConfirmed ? 1 : 0,
5681
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6053
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6054
+ transferringTokenCount: isTransferring ? 1 : 0
5682
6055
  });
5683
6056
  }
5684
6057
  }
@@ -5696,6 +6069,7 @@ var PaymentsModule = class _PaymentsModule {
5696
6069
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5697
6070
  confirmedTokenCount: raw.confirmedTokenCount,
5698
6071
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6072
+ transferringTokenCount: raw.transferringTokenCount,
5699
6073
  priceUsd: null,
5700
6074
  priceEur: null,
5701
6075
  change24h: null,
@@ -6548,6 +6922,33 @@ var PaymentsModule = class _PaymentsModule {
6548
6922
  }
6549
6923
  }
6550
6924
  }
6925
+ /**
6926
+ * Import history entries from remote TXF data into local store.
6927
+ * Delegates to the local TokenStorageProvider's importHistoryEntries() for
6928
+ * persistent storage, with in-memory fallback.
6929
+ * Reused by both load() (initial IPFS fetch) and _doSync() (merge result).
6930
+ */
6931
+ async importRemoteHistoryEntries(entries) {
6932
+ if (entries.length === 0) return 0;
6933
+ const provider = this.getLocalTokenStorageProvider();
6934
+ if (provider?.importHistoryEntries) {
6935
+ const imported2 = await provider.importHistoryEntries(entries);
6936
+ if (imported2 > 0) {
6937
+ this._historyCache = await provider.getHistoryEntries();
6938
+ }
6939
+ return imported2;
6940
+ }
6941
+ const existingKeys = new Set(this._historyCache.map((e) => e.dedupKey));
6942
+ let imported = 0;
6943
+ for (const entry of entries) {
6944
+ if (!existingKeys.has(entry.dedupKey)) {
6945
+ this._historyCache.push(entry);
6946
+ existingKeys.add(entry.dedupKey);
6947
+ imported++;
6948
+ }
6949
+ }
6950
+ return imported;
6951
+ }
6551
6952
  /**
6552
6953
  * Get the first local token storage provider (for history operations).
6553
6954
  */
@@ -6795,6 +7196,13 @@ var PaymentsModule = class _PaymentsModule {
6795
7196
  if (this.nametags.length === 0 && savedNametags.length > 0) {
6796
7197
  this.nametags = savedNametags;
6797
7198
  }
7199
+ const txfData = result.merged;
7200
+ if (txfData._history && txfData._history.length > 0) {
7201
+ const imported = await this.importRemoteHistoryEntries(txfData._history);
7202
+ if (imported > 0) {
7203
+ this.log(`Imported ${imported} history entries from IPFS sync`);
7204
+ }
7205
+ }
6798
7206
  totalAdded += result.added;
6799
7207
  totalRemoved += result.removed;
6800
7208
  }
@@ -7093,7 +7501,7 @@ var PaymentsModule = class _PaymentsModule {
7093
7501
  /**
7094
7502
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7095
7503
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7096
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7504
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7097
7505
  */
7098
7506
  async handleCommitmentOnlyTransfer(transfer, payload) {
7099
7507
  try {
@@ -7103,41 +7511,22 @@ var PaymentsModule = class _PaymentsModule {
7103
7511
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7104
7512
  return;
7105
7513
  }
7106
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7107
- const token = {
7108
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7109
- coinId: tokenInfo.coinId,
7110
- symbol: tokenInfo.symbol,
7111
- name: tokenInfo.name,
7112
- decimals: tokenInfo.decimals,
7113
- iconUrl: tokenInfo.iconUrl,
7114
- amount: tokenInfo.amount,
7115
- status: "submitted",
7116
- // NOSTR-FIRST: unconfirmed until proof
7117
- createdAt: Date.now(),
7118
- updatedAt: Date.now(),
7119
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7120
- };
7121
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7122
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7123
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7124
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7125
- return;
7126
- }
7127
- this.tokens.set(token.id, token);
7128
- console.log(`[Payments][DEBUG] NOSTR-FIRST: saving token id=${token.id.slice(0, 16)} status=${token.status} sdkData.length=${token.sdkData?.length}`);
7129
- await this.save();
7130
- console.log(`[Payments][DEBUG] NOSTR-FIRST: save() completed, tokens.size=${this.tokens.size}`);
7514
+ const token = await this.saveCommitmentOnlyToken(
7515
+ sourceTokenInput,
7516
+ commitmentInput,
7517
+ transfer.senderTransportPubkey
7518
+ );
7519
+ if (!token) return;
7131
7520
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7132
- const incomingTransfer = {
7521
+ this.deps.emitEvent("transfer:incoming", {
7133
7522
  id: transfer.id,
7134
7523
  senderPubkey: transfer.senderTransportPubkey,
7135
7524
  senderNametag: senderInfo.senderNametag,
7136
7525
  tokens: [token],
7137
7526
  memo: payload.memo,
7138
7527
  receivedAt: transfer.timestamp
7139
- };
7140
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7528
+ });
7529
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7141
7530
  await this.addToHistory({
7142
7531
  type: "RECEIVED",
7143
7532
  amount: token.amount,
@@ -7149,29 +7538,6 @@ var PaymentsModule = class _PaymentsModule {
7149
7538
  memo: payload.memo,
7150
7539
  tokenId: nostrTokenId || token.id
7151
7540
  });
7152
- try {
7153
- const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
7154
- const requestIdBytes = commitment.requestId;
7155
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7156
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7157
- if (stClient) {
7158
- const response = await stClient.submitTransferCommitment(commitment);
7159
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7160
- }
7161
- this.addProofPollingJob({
7162
- tokenId: token.id,
7163
- requestIdHex,
7164
- commitmentJson: JSON.stringify(commitmentInput),
7165
- startedAt: Date.now(),
7166
- attemptCount: 0,
7167
- lastAttemptAt: 0,
7168
- onProofReceived: async (tokenId) => {
7169
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7170
- }
7171
- });
7172
- } catch (err) {
7173
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7174
- }
7175
7541
  } catch (error) {
7176
7542
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7177
7543
  }
@@ -7290,6 +7656,28 @@ var PaymentsModule = class _PaymentsModule {
7290
7656
  try {
7291
7657
  const payload = transfer.payload;
7292
7658
  console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
7659
+ let combinedBundle = null;
7660
+ if (isCombinedTransferBundleV6(payload)) {
7661
+ combinedBundle = payload;
7662
+ } else if (payload.token) {
7663
+ try {
7664
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7665
+ if (isCombinedTransferBundleV6(inner)) {
7666
+ combinedBundle = inner;
7667
+ }
7668
+ } catch {
7669
+ }
7670
+ }
7671
+ if (combinedBundle) {
7672
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
7673
+ try {
7674
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
7675
+ this.log("COMBINED_TRANSFER V6 processed successfully");
7676
+ } catch (err) {
7677
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
7678
+ }
7679
+ return;
7680
+ }
7293
7681
  let instantBundle = null;
7294
7682
  if (isInstantSplitBundle(payload)) {
7295
7683
  instantBundle = payload;
@@ -7441,17 +7829,19 @@ var PaymentsModule = class _PaymentsModule {
7441
7829
  memo: payload.memo,
7442
7830
  tokenId: incomingTokenId || token.id
7443
7831
  });
7832
+ const incomingTransfer = {
7833
+ id: transfer.id,
7834
+ senderPubkey: transfer.senderTransportPubkey,
7835
+ senderNametag: senderInfo.senderNametag,
7836
+ tokens: [token],
7837
+ memo: payload.memo,
7838
+ receivedAt: transfer.timestamp
7839
+ };
7840
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
7841
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7842
+ } else {
7843
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7444
7844
  }
7445
- const incomingTransfer = {
7446
- id: transfer.id,
7447
- senderPubkey: transfer.senderTransportPubkey,
7448
- senderNametag: senderInfo.senderNametag,
7449
- tokens: [token],
7450
- memo: payload.memo,
7451
- receivedAt: transfer.timestamp
7452
- };
7453
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7454
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7455
7845
  } catch (error) {
7456
7846
  console.error("[Payments] Failed to process incoming transfer:", error);
7457
7847
  }
@@ -7520,6 +7910,7 @@ var PaymentsModule = class _PaymentsModule {
7520
7910
  return data ? JSON.parse(data) : [];
7521
7911
  }
7522
7912
  async createStorageData() {
7913
+ const sorted = [...this._historyCache].sort((a, b) => b.timestamp - a.timestamp);
7523
7914
  return await buildTxfStorageData(
7524
7915
  Array.from(this.tokens.values()),
7525
7916
  {
@@ -7531,7 +7922,8 @@ var PaymentsModule = class _PaymentsModule {
7531
7922
  nametags: this.nametags,
7532
7923
  tombstones: this.tombstones,
7533
7924
  archivedTokens: this.archivedTokens,
7534
- forkedTokens: this.forkedTokens
7925
+ forkedTokens: this.forkedTokens,
7926
+ historyEntries: sorted.slice(0, MAX_SYNCED_HISTORY_ENTRIES)
7535
7927
  }
7536
7928
  );
7537
7929
  }