@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
package/dist/index.js CHANGED
@@ -93,7 +93,9 @@ var init_constants = __esm({
93
93
  /** Group chat: processed event IDs for deduplication */
94
94
  GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
95
95
  /** Processed V5 split group IDs for Nostr re-delivery dedup */
96
- PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids"
96
+ PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids",
97
+ /** Processed V6 combined transfer IDs for Nostr re-delivery dedup */
98
+ PROCESSED_COMBINED_TRANSFER_IDS: "processed_combined_transfer_ids"
97
99
  };
98
100
  STORAGE_KEYS = {
99
101
  ...STORAGE_KEYS_GLOBAL,
@@ -2711,7 +2713,7 @@ init_constants();
2711
2713
  // types/txf.ts
2712
2714
  var ARCHIVED_PREFIX = "archived-";
2713
2715
  var FORKED_PREFIX = "_forked_";
2714
- var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2716
+ var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity", "_history"];
2715
2717
  function isTokenKey(key) {
2716
2718
  return key.startsWith("_") && !key.startsWith(ARCHIVED_PREFIX) && !key.startsWith(FORKED_PREFIX) && !RESERVED_KEYS.includes(key);
2717
2719
  }
@@ -3333,6 +3335,9 @@ async function buildTxfStorageData(tokens, meta, options) {
3333
3335
  if (options?.invalidatedNametags && options.invalidatedNametags.length > 0) {
3334
3336
  storageData._invalidatedNametags = options.invalidatedNametags;
3335
3337
  }
3338
+ if (options?.historyEntries && options.historyEntries.length > 0) {
3339
+ storageData._history = options.historyEntries;
3340
+ }
3336
3341
  for (const token of tokens) {
3337
3342
  const txf = tokenToTxf(token);
3338
3343
  if (txf) {
@@ -3366,6 +3371,7 @@ function parseTxfStorageData(data) {
3366
3371
  outboxEntries: [],
3367
3372
  mintOutboxEntries: [],
3368
3373
  invalidatedNametags: [],
3374
+ historyEntries: [],
3369
3375
  validationErrors: []
3370
3376
  };
3371
3377
  if (!data || typeof data !== "object") {
@@ -3419,6 +3425,13 @@ function parseTxfStorageData(data) {
3419
3425
  }
3420
3426
  }
3421
3427
  }
3428
+ if (Array.isArray(storageData._history)) {
3429
+ for (const entry of storageData._history) {
3430
+ if (typeof entry === "object" && entry !== null && typeof entry.dedupKey === "string" && typeof entry.type === "string") {
3431
+ result.historyEntries.push(entry);
3432
+ }
3433
+ }
3434
+ }
3422
3435
  for (const key of Object.keys(storageData)) {
3423
3436
  if (isTokenKey(key)) {
3424
3437
  const tokenId = tokenIdFromKey(key);
@@ -3581,14 +3594,149 @@ var InstantSplitExecutor = class {
3581
3594
  this.devMode = config.devMode ?? false;
3582
3595
  }
3583
3596
  /**
3584
- * Execute an instant split transfer with V5 optimized flow.
3597
+ * Build a V5 split bundle WITHOUT sending it via transport.
3585
3598
  *
3586
- * Critical path (~2.3s):
3599
+ * Steps 1-5 of the V5 flow:
3587
3600
  * 1. Create and submit burn commitment
3588
3601
  * 2. Wait for burn proof
3589
3602
  * 3. Create mint commitments with SplitMintReason
3590
3603
  * 4. Create transfer commitment (no mint proof needed)
3591
- * 5. Send bundle via transport
3604
+ * 5. Package V5 bundle
3605
+ *
3606
+ * The caller is responsible for sending the bundle and then calling
3607
+ * `startBackground()` on the result to begin mint proof + change token creation.
3608
+ */
3609
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3610
+ const splitGroupId = crypto.randomUUID();
3611
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3612
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3613
+ const coinId = new CoinId3(fromHex2(coinIdHex));
3614
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3615
+ const recipientTokenId = new TokenId3(await sha2563(seedString));
3616
+ const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3617
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3618
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3619
+ const senderAddressRef = await UnmaskedPredicateReference2.create(
3620
+ tokenToSplit.type,
3621
+ this.signingService.algorithm,
3622
+ this.signingService.publicKey,
3623
+ HashAlgorithm3.SHA256
3624
+ );
3625
+ const senderAddress = await senderAddressRef.toAddress();
3626
+ const builder = new TokenSplitBuilder2();
3627
+ const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3628
+ builder.createToken(
3629
+ recipientTokenId,
3630
+ tokenToSplit.type,
3631
+ new Uint8Array(0),
3632
+ coinDataA,
3633
+ senderAddress,
3634
+ // Mint to sender first, then transfer
3635
+ recipientSalt,
3636
+ null
3637
+ );
3638
+ const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3639
+ builder.createToken(
3640
+ senderTokenId,
3641
+ tokenToSplit.type,
3642
+ new Uint8Array(0),
3643
+ coinDataB,
3644
+ senderAddress,
3645
+ senderSalt,
3646
+ null
3647
+ );
3648
+ const split = await builder.build(tokenToSplit);
3649
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3650
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3651
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3652
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3653
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3654
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3655
+ }
3656
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3657
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3658
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3659
+ console.log(`[InstantSplit] Burn proof received`);
3660
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3661
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3662
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3663
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3664
+ const senderIdHex = toHex2(senderTokenId.bytes);
3665
+ const recipientMintCommitment = mintCommitments.find(
3666
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3667
+ );
3668
+ const senderMintCommitment = mintCommitments.find(
3669
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3670
+ );
3671
+ if (!recipientMintCommitment || !senderMintCommitment) {
3672
+ throw new Error("Failed to find expected mint commitments");
3673
+ }
3674
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3675
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3676
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3677
+ recipientMintCommitment.transactionData,
3678
+ recipientAddress,
3679
+ transferSalt,
3680
+ this.signingService
3681
+ );
3682
+ const mintedPredicate = await UnmaskedPredicate3.create(
3683
+ recipientTokenId,
3684
+ tokenToSplit.type,
3685
+ this.signingService,
3686
+ HashAlgorithm3.SHA256,
3687
+ recipientSalt
3688
+ );
3689
+ const mintedState = new TokenState3(mintedPredicate, null);
3690
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3691
+ const senderPubkey = toHex2(this.signingService.publicKey);
3692
+ let nametagTokenJson;
3693
+ const recipientAddressStr = recipientAddress.toString();
3694
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3695
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3696
+ }
3697
+ const bundle = {
3698
+ version: "5.0",
3699
+ type: "INSTANT_SPLIT",
3700
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3701
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3702
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3703
+ amount: splitAmount.toString(),
3704
+ coinId: coinIdHex,
3705
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3706
+ splitGroupId,
3707
+ senderPubkey,
3708
+ recipientSaltHex: toHex2(recipientSalt),
3709
+ transferSaltHex: toHex2(transferSalt),
3710
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3711
+ finalRecipientStateJson: "",
3712
+ // Recipient creates their own
3713
+ recipientAddressJson: recipientAddressStr,
3714
+ nametagTokenJson
3715
+ };
3716
+ return {
3717
+ bundle,
3718
+ splitGroupId,
3719
+ startBackground: async () => {
3720
+ if (!options?.skipBackground) {
3721
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3722
+ signingService: this.signingService,
3723
+ tokenType: tokenToSplit.type,
3724
+ coinId,
3725
+ senderTokenId,
3726
+ senderSalt,
3727
+ onProgress: options?.onBackgroundProgress,
3728
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3729
+ onStorageSync: options?.onStorageSync
3730
+ });
3731
+ }
3732
+ }
3733
+ };
3734
+ }
3735
+ /**
3736
+ * Execute an instant split transfer with V5 optimized flow.
3737
+ *
3738
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3739
+ * and starts background processing.
3592
3740
  *
3593
3741
  * @param tokenToSplit - The SDK token to split
3594
3742
  * @param splitAmount - Amount to send to recipient
@@ -3602,117 +3750,19 @@ var InstantSplitExecutor = class {
3602
3750
  */
3603
3751
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3604
3752
  const startTime = performance.now();
3605
- const splitGroupId = crypto.randomUUID();
3606
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3607
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3608
3753
  try {
3609
- const coinId = new CoinId3(fromHex2(coinIdHex));
3610
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3611
- const recipientTokenId = new TokenId3(await sha2563(seedString));
3612
- const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3613
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3614
- const senderSalt = await sha2563(seedString + "_sender_salt");
3615
- const senderAddressRef = await UnmaskedPredicateReference2.create(
3616
- tokenToSplit.type,
3617
- this.signingService.algorithm,
3618
- this.signingService.publicKey,
3619
- HashAlgorithm3.SHA256
3620
- );
3621
- const senderAddress = await senderAddressRef.toAddress();
3622
- const builder = new TokenSplitBuilder2();
3623
- const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3624
- builder.createToken(
3625
- recipientTokenId,
3626
- tokenToSplit.type,
3627
- new Uint8Array(0),
3628
- coinDataA,
3629
- senderAddress,
3630
- // Mint to sender first, then transfer
3631
- recipientSalt,
3632
- null
3633
- );
3634
- const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3635
- builder.createToken(
3636
- senderTokenId,
3637
- tokenToSplit.type,
3638
- new Uint8Array(0),
3639
- coinDataB,
3640
- senderAddress,
3641
- senderSalt,
3642
- null
3643
- );
3644
- const split = await builder.build(tokenToSplit);
3645
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3646
- const burnSalt = await sha2563(seedString + "_burn_salt");
3647
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3648
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3649
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3650
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3651
- }
3652
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3653
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3654
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3655
- const burnDuration = performance.now() - startTime;
3656
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3657
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3658
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3659
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3660
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3661
- const senderIdHex = toHex2(senderTokenId.bytes);
3662
- const recipientMintCommitment = mintCommitments.find(
3663
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3664
- );
3665
- const senderMintCommitment = mintCommitments.find(
3666
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3667
- );
3668
- if (!recipientMintCommitment || !senderMintCommitment) {
3669
- throw new Error("Failed to find expected mint commitments");
3670
- }
3671
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3672
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3673
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3674
- recipientMintCommitment.transactionData,
3754
+ const buildResult = await this.buildSplitBundle(
3755
+ tokenToSplit,
3756
+ splitAmount,
3757
+ remainderAmount,
3758
+ coinIdHex,
3675
3759
  recipientAddress,
3676
- transferSalt,
3677
- this.signingService
3678
- );
3679
- const mintedPredicate = await UnmaskedPredicate3.create(
3680
- recipientTokenId,
3681
- tokenToSplit.type,
3682
- this.signingService,
3683
- HashAlgorithm3.SHA256,
3684
- recipientSalt
3760
+ options
3685
3761
  );
3686
- const mintedState = new TokenState3(mintedPredicate, null);
3687
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3762
+ console.log("[InstantSplit] Sending via transport...");
3688
3763
  const senderPubkey = toHex2(this.signingService.publicKey);
3689
- let nametagTokenJson;
3690
- const recipientAddressStr = recipientAddress.toString();
3691
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3692
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3693
- }
3694
- const bundle = {
3695
- version: "5.0",
3696
- type: "INSTANT_SPLIT",
3697
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3698
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3699
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3700
- amount: splitAmount.toString(),
3701
- coinId: coinIdHex,
3702
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3703
- splitGroupId,
3704
- senderPubkey,
3705
- recipientSaltHex: toHex2(recipientSalt),
3706
- transferSaltHex: toHex2(transferSalt),
3707
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3708
- finalRecipientStateJson: "",
3709
- // Recipient creates their own
3710
- recipientAddressJson: recipientAddressStr,
3711
- nametagTokenJson
3712
- };
3713
- console.log("[InstantSplit] Step 6: Sending via transport...");
3714
3764
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3715
- token: JSON.stringify(bundle),
3765
+ token: JSON.stringify(buildResult.bundle),
3716
3766
  proof: null,
3717
3767
  // Proof is included in the bundle
3718
3768
  memo: options?.memo,
@@ -3723,25 +3773,13 @@ var InstantSplitExecutor = class {
3723
3773
  const criticalPathDuration = performance.now() - startTime;
3724
3774
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3725
3775
  options?.onNostrDelivered?.(nostrEventId);
3726
- let backgroundPromise;
3727
- if (!options?.skipBackground) {
3728
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3729
- signingService: this.signingService,
3730
- tokenType: tokenToSplit.type,
3731
- coinId,
3732
- senderTokenId,
3733
- senderSalt,
3734
- onProgress: options?.onBackgroundProgress,
3735
- onChangeTokenCreated: options?.onChangeTokenCreated,
3736
- onStorageSync: options?.onStorageSync
3737
- });
3738
- }
3776
+ const backgroundPromise = buildResult.startBackground();
3739
3777
  return {
3740
3778
  success: true,
3741
3779
  nostrEventId,
3742
- splitGroupId,
3780
+ splitGroupId: buildResult.splitGroupId,
3743
3781
  criticalPathDurationMs: criticalPathDuration,
3744
- backgroundStarted: !options?.skipBackground,
3782
+ backgroundStarted: true,
3745
3783
  backgroundPromise
3746
3784
  };
3747
3785
  } catch (error) {
@@ -3750,7 +3788,6 @@ var InstantSplitExecutor = class {
3750
3788
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3751
3789
  return {
3752
3790
  success: false,
3753
- splitGroupId,
3754
3791
  criticalPathDurationMs: duration,
3755
3792
  error: errorMessage,
3756
3793
  backgroundStarted: false
@@ -3955,6 +3992,11 @@ function isInstantSplitBundleV4(obj) {
3955
3992
  function isInstantSplitBundleV5(obj) {
3956
3993
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3957
3994
  }
3995
+ function isCombinedTransferBundleV6(obj) {
3996
+ if (typeof obj !== "object" || obj === null) return false;
3997
+ const b = obj;
3998
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
3999
+ }
3958
4000
 
3959
4001
  // modules/payments/InstantSplitProcessor.ts
3960
4002
  function fromHex3(hex) {
@@ -4279,6 +4321,7 @@ function computeHistoryDedupKey(type, tokenId, transferId) {
4279
4321
  if (tokenId) return `${type}_${tokenId}`;
4280
4322
  return `${type}_${crypto.randomUUID()}`;
4281
4323
  }
4324
+ var MAX_SYNCED_HISTORY_ENTRIES = 5e3;
4282
4325
  function enrichWithRegistry(info) {
4283
4326
  const registry = TokenRegistry.getInstance();
4284
4327
  const def = registry.getDefinition(info.coinId);
@@ -4608,6 +4651,8 @@ var PaymentsModule = class _PaymentsModule {
4608
4651
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4609
4652
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4610
4653
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4654
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4655
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4611
4656
  // Storage event subscriptions (push-based sync)
4612
4657
  storageEventUnsubscribers = [];
4613
4658
  syncDebounceTimer = null;
@@ -4701,6 +4746,10 @@ var PaymentsModule = class _PaymentsModule {
4701
4746
  const result = await provider.load();
4702
4747
  if (result.success && result.data) {
4703
4748
  this.loadFromStorageData(result.data);
4749
+ const txfData = result.data;
4750
+ if (txfData._history && txfData._history.length > 0) {
4751
+ await this.importRemoteHistoryEntries(txfData._history);
4752
+ }
4704
4753
  this.log(`Loaded metadata from provider ${id}`);
4705
4754
  break;
4706
4755
  }
@@ -4708,10 +4757,23 @@ var PaymentsModule = class _PaymentsModule {
4708
4757
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4709
4758
  }
4710
4759
  }
4760
+ for (const [id, token] of this.tokens) {
4761
+ try {
4762
+ if (token.sdkData) {
4763
+ const data = JSON.parse(token.sdkData);
4764
+ if (data?._placeholder) {
4765
+ this.tokens.delete(id);
4766
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4767
+ }
4768
+ }
4769
+ } catch {
4770
+ }
4771
+ }
4711
4772
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4712
4773
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4713
4774
  await this.loadPendingV5Tokens();
4714
4775
  await this.loadProcessedSplitGroupIds();
4776
+ await this.loadProcessedCombinedTransferIds();
4715
4777
  await this.loadHistory();
4716
4778
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4717
4779
  if (pending2) {
@@ -4803,12 +4865,13 @@ var PaymentsModule = class _PaymentsModule {
4803
4865
  token.status = "transferring";
4804
4866
  this.tokens.set(token.id, token);
4805
4867
  }
4868
+ await this.save();
4806
4869
  await this.saveToOutbox(result, recipientPubkey);
4807
4870
  result.status = "submitted";
4808
4871
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4809
4872
  const transferMode = request.transferMode ?? "instant";
4810
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4811
- if (transferMode === "conservative") {
4873
+ if (transferMode === "conservative") {
4874
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4812
4875
  this.log("Executing conservative split...");
4813
4876
  const splitExecutor = new TokenSplitExecutor({
4814
4877
  stateTransitionClient: stClient,
@@ -4852,27 +4915,59 @@ var PaymentsModule = class _PaymentsModule {
4852
4915
  requestIdHex: splitRequestIdHex
4853
4916
  });
4854
4917
  this.log(`Conservative split transfer completed`);
4855
- } else {
4856
- this.log("Executing instant split...");
4857
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
4918
+ }
4919
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4920
+ const token = tokenWithAmount.uiToken;
4921
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4922
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4923
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4924
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4925
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4926
+ }
4927
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4928
+ const transferTx = commitment.toTransaction(inclusionProof);
4929
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4930
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4931
+ transferTx: JSON.stringify(transferTx.toJSON()),
4932
+ memo: request.memo
4933
+ });
4934
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4935
+ const requestIdBytes = commitment.requestId;
4936
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4937
+ result.tokenTransfers.push({
4938
+ sourceTokenId: token.id,
4939
+ method: "direct",
4940
+ requestIdHex
4941
+ });
4942
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
4943
+ await this.removeToken(token.id);
4944
+ }
4945
+ } else {
4946
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4947
+ const senderPubkey = this.deps.identity.chainPubkey;
4948
+ let changeTokenPlaceholderId = null;
4949
+ let builtSplit = null;
4950
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4951
+ this.log("Building instant split bundle...");
4858
4952
  const executor = new InstantSplitExecutor({
4859
4953
  stateTransitionClient: stClient,
4860
4954
  trustBase,
4861
4955
  signingService,
4862
4956
  devMode
4863
4957
  });
4864
- const instantResult = await executor.executeSplitInstant(
4958
+ builtSplit = await executor.buildSplitBundle(
4865
4959
  splitPlan.tokenToSplit.sdkToken,
4866
4960
  splitPlan.splitAmount,
4867
4961
  splitPlan.remainderAmount,
4868
4962
  splitPlan.coinId,
4869
4963
  recipientAddress,
4870
- this.deps.transport,
4871
- recipientPubkey,
4872
4964
  {
4873
4965
  memo: request.memo,
4874
4966
  onChangeTokenCreated: async (changeToken) => {
4875
4967
  const changeTokenData = changeToken.toJSON();
4968
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4969
+ this.tokens.delete(changeTokenPlaceholderId);
4970
+ }
4876
4971
  const uiToken = {
4877
4972
  id: crypto.randomUUID(),
4878
4973
  coinId: request.coinId,
@@ -4895,65 +4990,103 @@ var PaymentsModule = class _PaymentsModule {
4895
4990
  }
4896
4991
  }
4897
4992
  );
4898
- if (!instantResult.success) {
4899
- throw new Error(instantResult.error || "Instant split failed");
4900
- }
4901
- if (instantResult.backgroundPromise) {
4902
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4903
- }
4993
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
4994
+ }
4995
+ const directCommitments = await Promise.all(
4996
+ splitPlan.tokensToTransferDirectly.map(
4997
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
4998
+ )
4999
+ );
5000
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
5001
+ (tw, i) => ({
5002
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
5003
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
5004
+ amount: tw.uiToken.amount,
5005
+ coinId: tw.uiToken.coinId,
5006
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
5007
+ })
5008
+ );
5009
+ const combinedBundle = {
5010
+ version: "6.0",
5011
+ type: "COMBINED_TRANSFER",
5012
+ transferId: result.id,
5013
+ splitBundle: builtSplit?.bundle ?? null,
5014
+ directTokens: directTokenEntries,
5015
+ totalAmount: request.amount.toString(),
5016
+ coinId: request.coinId,
5017
+ senderPubkey,
5018
+ memo: request.memo
5019
+ };
5020
+ console.log(
5021
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
5022
+ );
5023
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5024
+ token: JSON.stringify(combinedBundle),
5025
+ proof: null,
5026
+ memo: request.memo,
5027
+ sender: { transportPubkey: senderPubkey }
5028
+ });
5029
+ console.log(`[Payments] V6 combined bundle sent successfully`);
5030
+ if (builtSplit) {
5031
+ const bgPromise = builtSplit.startBackground();
5032
+ this.pendingBackgroundTasks.push(bgPromise);
5033
+ }
5034
+ if (builtSplit && splitPlan.remainderAmount) {
5035
+ changeTokenPlaceholderId = crypto.randomUUID();
5036
+ const placeholder = {
5037
+ id: changeTokenPlaceholderId,
5038
+ coinId: request.coinId,
5039
+ symbol: this.getCoinSymbol(request.coinId),
5040
+ name: this.getCoinName(request.coinId),
5041
+ decimals: this.getCoinDecimals(request.coinId),
5042
+ iconUrl: this.getCoinIconUrl(request.coinId),
5043
+ amount: splitPlan.remainderAmount.toString(),
5044
+ status: "transferring",
5045
+ createdAt: Date.now(),
5046
+ updatedAt: Date.now(),
5047
+ sdkData: JSON.stringify({ _placeholder: true })
5048
+ };
5049
+ this.tokens.set(placeholder.id, placeholder);
5050
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
5051
+ }
5052
+ for (const commitment of directCommitments) {
5053
+ stClient.submitTransferCommitment(commitment).catch(
5054
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
5055
+ );
5056
+ }
5057
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4904
5058
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4905
5059
  result.tokenTransfers.push({
4906
5060
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4907
5061
  method: "split",
4908
- splitGroupId: instantResult.splitGroupId,
4909
- nostrEventId: instantResult.nostrEventId
5062
+ splitGroupId: builtSplit.splitGroupId
4910
5063
  });
4911
- this.log(`Instant split transfer completed`);
4912
5064
  }
4913
- }
4914
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4915
- const token = tokenWithAmount.uiToken;
4916
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4917
- if (transferMode === "conservative") {
4918
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4919
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4920
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4921
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4922
- }
4923
- const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4924
- const transferTx = commitment.toTransaction(inclusionProof);
4925
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4926
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4927
- transferTx: JSON.stringify(transferTx.toJSON()),
4928
- memo: request.memo
4929
- });
4930
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4931
- } else {
4932
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4933
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4934
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4935
- commitmentData: JSON.stringify(commitment.toJSON()),
4936
- memo: request.memo
5065
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
5066
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
5067
+ const commitment = directCommitments[i];
5068
+ const requestIdBytes = commitment.requestId;
5069
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5070
+ result.tokenTransfers.push({
5071
+ sourceTokenId: token.id,
5072
+ method: "direct",
5073
+ requestIdHex
4937
5074
  });
4938
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4939
- stClient.submitTransferCommitment(commitment).catch(
4940
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4941
- );
5075
+ await this.removeToken(token.id);
4942
5076
  }
4943
- const requestIdBytes = commitment.requestId;
4944
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4945
- result.tokenTransfers.push({
4946
- sourceTokenId: token.id,
4947
- method: "direct",
4948
- requestIdHex
4949
- });
4950
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4951
- await this.removeToken(token.id);
5077
+ this.log(`V6 combined transfer completed`);
4952
5078
  }
4953
5079
  result.status = "delivered";
4954
5080
  await this.save();
4955
5081
  await this.removeFromOutbox(result.id);
4956
5082
  result.status = "completed";
5083
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
5084
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
5085
+ id: tt.sourceTokenId,
5086
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
5087
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
5088
+ source: tt.method === "split" ? "split" : "direct"
5089
+ }));
4957
5090
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4958
5091
  await this.addToHistory({
4959
5092
  type: "SENT",
@@ -4966,7 +5099,8 @@ var PaymentsModule = class _PaymentsModule {
4966
5099
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4967
5100
  memo: request.memo,
4968
5101
  transferId: result.id,
4969
- tokenId: sentTokenId || void 0
5102
+ tokenId: sentTokenId || void 0,
5103
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4970
5104
  });
4971
5105
  this.deps.emitEvent("transfer:confirmed", result);
4972
5106
  return result;
@@ -5136,6 +5270,267 @@ var PaymentsModule = class _PaymentsModule {
5136
5270
  };
5137
5271
  }
5138
5272
  }
5273
+ // ===========================================================================
5274
+ // Shared Helpers for V5 and V6 Receiver Processing
5275
+ // ===========================================================================
5276
+ /**
5277
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
5278
+ * Returns the created UI token, or null if deduped.
5279
+ *
5280
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
5281
+ * The token is still added to the in-memory map for dedup; caller must call save().
5282
+ */
5283
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
5284
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
5285
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5286
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5287
+ return null;
5288
+ }
5289
+ const registry = TokenRegistry.getInstance();
5290
+ const pendingData = {
5291
+ type: "v5_bundle",
5292
+ stage: "RECEIVED",
5293
+ bundleJson: JSON.stringify(bundle),
5294
+ senderPubkey,
5295
+ savedAt: Date.now(),
5296
+ attemptCount: 0
5297
+ };
5298
+ const uiToken = {
5299
+ id: deterministicId,
5300
+ coinId: bundle.coinId,
5301
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5302
+ name: registry.getName(bundle.coinId) || bundle.coinId,
5303
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
5304
+ amount: bundle.amount,
5305
+ status: "submitted",
5306
+ // UNCONFIRMED
5307
+ createdAt: Date.now(),
5308
+ updatedAt: Date.now(),
5309
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5310
+ };
5311
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5312
+ if (deferPersistence) {
5313
+ this.tokens.set(uiToken.id, uiToken);
5314
+ } else {
5315
+ await this.addToken(uiToken);
5316
+ await this.saveProcessedSplitGroupIds();
5317
+ }
5318
+ return uiToken;
5319
+ }
5320
+ /**
5321
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5322
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5323
+ * Returns the created UI token, or null if deduped/tombstoned.
5324
+ *
5325
+ * @param deferPersistence - If true, skip save() and commitment submission
5326
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5327
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5328
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5329
+ */
5330
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5331
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5332
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5333
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5334
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5335
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5336
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5337
+ return null;
5338
+ }
5339
+ if (nostrTokenId) {
5340
+ for (const existing of this.tokens.values()) {
5341
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5342
+ if (existingTokenId !== nostrTokenId) continue;
5343
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5344
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5345
+ console.log(
5346
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5347
+ );
5348
+ return null;
5349
+ }
5350
+ if (!skipGenesisDedup) {
5351
+ console.log(
5352
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5353
+ );
5354
+ return null;
5355
+ }
5356
+ }
5357
+ }
5358
+ const token = {
5359
+ id: crypto.randomUUID(),
5360
+ coinId: tokenInfo.coinId,
5361
+ symbol: tokenInfo.symbol,
5362
+ name: tokenInfo.name,
5363
+ decimals: tokenInfo.decimals,
5364
+ iconUrl: tokenInfo.iconUrl,
5365
+ amount: tokenInfo.amount,
5366
+ status: "submitted",
5367
+ // NOSTR-FIRST: unconfirmed until proof
5368
+ createdAt: Date.now(),
5369
+ updatedAt: Date.now(),
5370
+ sdkData
5371
+ };
5372
+ this.tokens.set(token.id, token);
5373
+ if (!deferPersistence) {
5374
+ await this.save();
5375
+ }
5376
+ try {
5377
+ const commitment = await TransferCommitment4.fromJSON(commitmentInput);
5378
+ const requestIdBytes = commitment.requestId;
5379
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5380
+ if (!deferPersistence) {
5381
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5382
+ if (stClient) {
5383
+ const response = await stClient.submitTransferCommitment(commitment);
5384
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5385
+ }
5386
+ }
5387
+ this.addProofPollingJob({
5388
+ tokenId: token.id,
5389
+ requestIdHex,
5390
+ commitmentJson: JSON.stringify(commitmentInput),
5391
+ startedAt: Date.now(),
5392
+ attemptCount: 0,
5393
+ lastAttemptAt: 0,
5394
+ onProofReceived: async (tokenId) => {
5395
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5396
+ }
5397
+ });
5398
+ } catch (err) {
5399
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5400
+ }
5401
+ return token;
5402
+ }
5403
+ // ===========================================================================
5404
+ // Combined Transfer V6 — Receiver
5405
+ // ===========================================================================
5406
+ /**
5407
+ * Process a received COMBINED_TRANSFER V6 bundle.
5408
+ *
5409
+ * Unpacks a single Nostr message into its component tokens:
5410
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5411
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5412
+ *
5413
+ * Emits ONE transfer:incoming event and records ONE history entry.
5414
+ */
5415
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5416
+ this.ensureInitialized();
5417
+ if (!this.loaded && this.loadedPromise) {
5418
+ await this.loadedPromise;
5419
+ }
5420
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5421
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5422
+ return;
5423
+ }
5424
+ console.log(
5425
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5426
+ );
5427
+ const allTokens = [];
5428
+ const tokenBreakdown = [];
5429
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5430
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5431
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5432
+ }));
5433
+ if (bundle.splitBundle) {
5434
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5435
+ if (splitToken) {
5436
+ allTokens.push(splitToken);
5437
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5438
+ } else {
5439
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5440
+ }
5441
+ }
5442
+ const directResults = await Promise.all(
5443
+ parsedDirectEntries.map(
5444
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5445
+ )
5446
+ );
5447
+ for (let i = 0; i < directResults.length; i++) {
5448
+ const token = directResults[i];
5449
+ if (token) {
5450
+ allTokens.push(token);
5451
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5452
+ } else {
5453
+ const entry = bundle.directTokens[i];
5454
+ console.warn(
5455
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5456
+ );
5457
+ }
5458
+ }
5459
+ if (allTokens.length === 0) {
5460
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5461
+ return;
5462
+ }
5463
+ this.processedCombinedTransferIds.add(bundle.transferId);
5464
+ const [senderInfo] = await Promise.all([
5465
+ this.resolveSenderInfo(senderPubkey),
5466
+ this.save(),
5467
+ this.saveProcessedCombinedTransferIds(),
5468
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5469
+ ]);
5470
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5471
+ if (stClient) {
5472
+ for (const { commitment } of parsedDirectEntries) {
5473
+ TransferCommitment4.fromJSON(commitment).then(
5474
+ (c) => stClient.submitTransferCommitment(c)
5475
+ ).catch(
5476
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5477
+ );
5478
+ }
5479
+ }
5480
+ this.deps.emitEvent("transfer:incoming", {
5481
+ id: bundle.transferId,
5482
+ senderPubkey,
5483
+ senderNametag: senderInfo.senderNametag,
5484
+ tokens: allTokens,
5485
+ memo: bundle.memo,
5486
+ receivedAt: Date.now()
5487
+ });
5488
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5489
+ await this.addToHistory({
5490
+ type: "RECEIVED",
5491
+ amount: actualAmount,
5492
+ coinId: bundle.coinId,
5493
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5494
+ timestamp: Date.now(),
5495
+ senderPubkey,
5496
+ ...senderInfo,
5497
+ memo: bundle.memo,
5498
+ transferId: bundle.transferId,
5499
+ tokenId: allTokens[0]?.id,
5500
+ tokenIds: tokenBreakdown
5501
+ });
5502
+ if (bundle.splitBundle) {
5503
+ this.resolveUnconfirmed().catch(() => {
5504
+ });
5505
+ this.scheduleResolveUnconfirmed();
5506
+ }
5507
+ }
5508
+ /**
5509
+ * Persist processed combined transfer IDs to KV storage.
5510
+ */
5511
+ async saveProcessedCombinedTransferIds() {
5512
+ const ids = Array.from(this.processedCombinedTransferIds);
5513
+ if (ids.length > 0) {
5514
+ await this.deps.storage.set(
5515
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5516
+ JSON.stringify(ids)
5517
+ );
5518
+ }
5519
+ }
5520
+ /**
5521
+ * Load processed combined transfer IDs from KV storage.
5522
+ */
5523
+ async loadProcessedCombinedTransferIds() {
5524
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5525
+ if (!data) return;
5526
+ try {
5527
+ const ids = JSON.parse(data);
5528
+ for (const id of ids) {
5529
+ this.processedCombinedTransferIds.add(id);
5530
+ }
5531
+ } catch {
5532
+ }
5533
+ }
5139
5534
  /**
5140
5535
  * Process a received INSTANT_SPLIT bundle.
5141
5536
  *
@@ -5159,36 +5554,10 @@ var PaymentsModule = class _PaymentsModule {
5159
5554
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
5160
5555
  }
5161
5556
  try {
5162
- const deterministicId = `v5split_${bundle.splitGroupId}`;
5163
- if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5164
- console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5557
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5558
+ if (!uiToken) {
5165
5559
  return { success: true, durationMs: 0 };
5166
5560
  }
5167
- const registry = TokenRegistry.getInstance();
5168
- const pendingData = {
5169
- type: "v5_bundle",
5170
- stage: "RECEIVED",
5171
- bundleJson: JSON.stringify(bundle),
5172
- senderPubkey,
5173
- savedAt: Date.now(),
5174
- attemptCount: 0
5175
- };
5176
- const uiToken = {
5177
- id: deterministicId,
5178
- coinId: bundle.coinId,
5179
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5180
- name: registry.getName(bundle.coinId) || bundle.coinId,
5181
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
5182
- amount: bundle.amount,
5183
- status: "submitted",
5184
- // UNCONFIRMED
5185
- createdAt: Date.now(),
5186
- updatedAt: Date.now(),
5187
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5188
- };
5189
- await this.addToken(uiToken);
5190
- this.processedSplitGroupIds.add(bundle.splitGroupId);
5191
- await this.saveProcessedSplitGroupIds();
5192
5561
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
5193
5562
  await this.addToHistory({
5194
5563
  type: "RECEIVED",
@@ -5199,7 +5568,7 @@ var PaymentsModule = class _PaymentsModule {
5199
5568
  senderPubkey,
5200
5569
  ...senderInfo,
5201
5570
  memo,
5202
- tokenId: deterministicId
5571
+ tokenId: uiToken.id
5203
5572
  });
5204
5573
  this.deps.emitEvent("transfer:incoming", {
5205
5574
  id: bundle.splitGroupId,
@@ -5849,16 +6218,18 @@ var PaymentsModule = class _PaymentsModule {
5849
6218
  }
5850
6219
  /**
5851
6220
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5852
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
6221
+ * Excludes tokens with status 'spent' or 'invalid'.
6222
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
5853
6223
  */
5854
6224
  aggregateTokens(coinId) {
5855
6225
  const assetsMap = /* @__PURE__ */ new Map();
5856
6226
  for (const token of this.tokens.values()) {
5857
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6227
+ if (token.status === "spent" || token.status === "invalid") continue;
5858
6228
  if (coinId && token.coinId !== coinId) continue;
5859
6229
  const key = token.coinId;
5860
6230
  const amount = BigInt(token.amount);
5861
6231
  const isConfirmed = token.status === "confirmed";
6232
+ const isTransferring = token.status === "transferring";
5862
6233
  const existing = assetsMap.get(key);
5863
6234
  if (existing) {
5864
6235
  if (isConfirmed) {
@@ -5868,6 +6239,7 @@ var PaymentsModule = class _PaymentsModule {
5868
6239
  existing.unconfirmedAmount += amount;
5869
6240
  existing.unconfirmedTokenCount++;
5870
6241
  }
6242
+ if (isTransferring) existing.transferringTokenCount++;
5871
6243
  } else {
5872
6244
  assetsMap.set(key, {
5873
6245
  coinId: token.coinId,
@@ -5878,7 +6250,8 @@ var PaymentsModule = class _PaymentsModule {
5878
6250
  confirmedAmount: isConfirmed ? amount : 0n,
5879
6251
  unconfirmedAmount: isConfirmed ? 0n : amount,
5880
6252
  confirmedTokenCount: isConfirmed ? 1 : 0,
5881
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6253
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6254
+ transferringTokenCount: isTransferring ? 1 : 0
5882
6255
  });
5883
6256
  }
5884
6257
  }
@@ -5896,6 +6269,7 @@ var PaymentsModule = class _PaymentsModule {
5896
6269
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5897
6270
  confirmedTokenCount: raw.confirmedTokenCount,
5898
6271
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6272
+ transferringTokenCount: raw.transferringTokenCount,
5899
6273
  priceUsd: null,
5900
6274
  priceEur: null,
5901
6275
  change24h: null,
@@ -6748,6 +7122,33 @@ var PaymentsModule = class _PaymentsModule {
6748
7122
  }
6749
7123
  }
6750
7124
  }
7125
+ /**
7126
+ * Import history entries from remote TXF data into local store.
7127
+ * Delegates to the local TokenStorageProvider's importHistoryEntries() for
7128
+ * persistent storage, with in-memory fallback.
7129
+ * Reused by both load() (initial IPFS fetch) and _doSync() (merge result).
7130
+ */
7131
+ async importRemoteHistoryEntries(entries) {
7132
+ if (entries.length === 0) return 0;
7133
+ const provider = this.getLocalTokenStorageProvider();
7134
+ if (provider?.importHistoryEntries) {
7135
+ const imported2 = await provider.importHistoryEntries(entries);
7136
+ if (imported2 > 0) {
7137
+ this._historyCache = await provider.getHistoryEntries();
7138
+ }
7139
+ return imported2;
7140
+ }
7141
+ const existingKeys = new Set(this._historyCache.map((e) => e.dedupKey));
7142
+ let imported = 0;
7143
+ for (const entry of entries) {
7144
+ if (!existingKeys.has(entry.dedupKey)) {
7145
+ this._historyCache.push(entry);
7146
+ existingKeys.add(entry.dedupKey);
7147
+ imported++;
7148
+ }
7149
+ }
7150
+ return imported;
7151
+ }
6751
7152
  /**
6752
7153
  * Get the first local token storage provider (for history operations).
6753
7154
  */
@@ -6995,6 +7396,13 @@ var PaymentsModule = class _PaymentsModule {
6995
7396
  if (this.nametags.length === 0 && savedNametags.length > 0) {
6996
7397
  this.nametags = savedNametags;
6997
7398
  }
7399
+ const txfData = result.merged;
7400
+ if (txfData._history && txfData._history.length > 0) {
7401
+ const imported = await this.importRemoteHistoryEntries(txfData._history);
7402
+ if (imported > 0) {
7403
+ this.log(`Imported ${imported} history entries from IPFS sync`);
7404
+ }
7405
+ }
6998
7406
  totalAdded += result.added;
6999
7407
  totalRemoved += result.removed;
7000
7408
  }
@@ -7293,7 +7701,7 @@ var PaymentsModule = class _PaymentsModule {
7293
7701
  /**
7294
7702
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7295
7703
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7296
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7704
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7297
7705
  */
7298
7706
  async handleCommitmentOnlyTransfer(transfer, payload) {
7299
7707
  try {
@@ -7303,41 +7711,22 @@ var PaymentsModule = class _PaymentsModule {
7303
7711
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7304
7712
  return;
7305
7713
  }
7306
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7307
- const token = {
7308
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7309
- coinId: tokenInfo.coinId,
7310
- symbol: tokenInfo.symbol,
7311
- name: tokenInfo.name,
7312
- decimals: tokenInfo.decimals,
7313
- iconUrl: tokenInfo.iconUrl,
7314
- amount: tokenInfo.amount,
7315
- status: "submitted",
7316
- // NOSTR-FIRST: unconfirmed until proof
7317
- createdAt: Date.now(),
7318
- updatedAt: Date.now(),
7319
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7320
- };
7321
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7322
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7323
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7324
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7325
- return;
7326
- }
7327
- this.tokens.set(token.id, token);
7328
- console.log(`[Payments][DEBUG] NOSTR-FIRST: saving token id=${token.id.slice(0, 16)} status=${token.status} sdkData.length=${token.sdkData?.length}`);
7329
- await this.save();
7330
- console.log(`[Payments][DEBUG] NOSTR-FIRST: save() completed, tokens.size=${this.tokens.size}`);
7714
+ const token = await this.saveCommitmentOnlyToken(
7715
+ sourceTokenInput,
7716
+ commitmentInput,
7717
+ transfer.senderTransportPubkey
7718
+ );
7719
+ if (!token) return;
7331
7720
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7332
- const incomingTransfer = {
7721
+ this.deps.emitEvent("transfer:incoming", {
7333
7722
  id: transfer.id,
7334
7723
  senderPubkey: transfer.senderTransportPubkey,
7335
7724
  senderNametag: senderInfo.senderNametag,
7336
7725
  tokens: [token],
7337
7726
  memo: payload.memo,
7338
7727
  receivedAt: transfer.timestamp
7339
- };
7340
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7728
+ });
7729
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7341
7730
  await this.addToHistory({
7342
7731
  type: "RECEIVED",
7343
7732
  amount: token.amount,
@@ -7349,29 +7738,6 @@ var PaymentsModule = class _PaymentsModule {
7349
7738
  memo: payload.memo,
7350
7739
  tokenId: nostrTokenId || token.id
7351
7740
  });
7352
- try {
7353
- const commitment = await TransferCommitment4.fromJSON(commitmentInput);
7354
- const requestIdBytes = commitment.requestId;
7355
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7356
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7357
- if (stClient) {
7358
- const response = await stClient.submitTransferCommitment(commitment);
7359
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7360
- }
7361
- this.addProofPollingJob({
7362
- tokenId: token.id,
7363
- requestIdHex,
7364
- commitmentJson: JSON.stringify(commitmentInput),
7365
- startedAt: Date.now(),
7366
- attemptCount: 0,
7367
- lastAttemptAt: 0,
7368
- onProofReceived: async (tokenId) => {
7369
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7370
- }
7371
- });
7372
- } catch (err) {
7373
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7374
- }
7375
7741
  } catch (error) {
7376
7742
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7377
7743
  }
@@ -7490,6 +7856,28 @@ var PaymentsModule = class _PaymentsModule {
7490
7856
  try {
7491
7857
  const payload = transfer.payload;
7492
7858
  console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
7859
+ let combinedBundle = null;
7860
+ if (isCombinedTransferBundleV6(payload)) {
7861
+ combinedBundle = payload;
7862
+ } else if (payload.token) {
7863
+ try {
7864
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7865
+ if (isCombinedTransferBundleV6(inner)) {
7866
+ combinedBundle = inner;
7867
+ }
7868
+ } catch {
7869
+ }
7870
+ }
7871
+ if (combinedBundle) {
7872
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
7873
+ try {
7874
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
7875
+ this.log("COMBINED_TRANSFER V6 processed successfully");
7876
+ } catch (err) {
7877
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
7878
+ }
7879
+ return;
7880
+ }
7493
7881
  let instantBundle = null;
7494
7882
  if (isInstantSplitBundle(payload)) {
7495
7883
  instantBundle = payload;
@@ -7641,17 +8029,19 @@ var PaymentsModule = class _PaymentsModule {
7641
8029
  memo: payload.memo,
7642
8030
  tokenId: incomingTokenId || token.id
7643
8031
  });
8032
+ const incomingTransfer = {
8033
+ id: transfer.id,
8034
+ senderPubkey: transfer.senderTransportPubkey,
8035
+ senderNametag: senderInfo.senderNametag,
8036
+ tokens: [token],
8037
+ memo: payload.memo,
8038
+ receivedAt: transfer.timestamp
8039
+ };
8040
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
8041
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
8042
+ } else {
8043
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7644
8044
  }
7645
- const incomingTransfer = {
7646
- id: transfer.id,
7647
- senderPubkey: transfer.senderTransportPubkey,
7648
- senderNametag: senderInfo.senderNametag,
7649
- tokens: [token],
7650
- memo: payload.memo,
7651
- receivedAt: transfer.timestamp
7652
- };
7653
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7654
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7655
8045
  } catch (error) {
7656
8046
  console.error("[Payments] Failed to process incoming transfer:", error);
7657
8047
  }
@@ -7720,6 +8110,7 @@ var PaymentsModule = class _PaymentsModule {
7720
8110
  return data ? JSON.parse(data) : [];
7721
8111
  }
7722
8112
  async createStorageData() {
8113
+ const sorted = [...this._historyCache].sort((a, b) => b.timestamp - a.timestamp);
7723
8114
  return await buildTxfStorageData(
7724
8115
  Array.from(this.tokens.values()),
7725
8116
  {
@@ -7731,7 +8122,8 @@ var PaymentsModule = class _PaymentsModule {
7731
8122
  nametags: this.nametags,
7732
8123
  tombstones: this.tombstones,
7733
8124
  archivedTokens: this.archivedTokens,
7734
- forkedTokens: this.forkedTokens
8125
+ forkedTokens: this.forkedTokens,
8126
+ historyEntries: sorted.slice(0, MAX_SYNCED_HISTORY_ENTRIES)
7735
8127
  }
7736
8128
  );
7737
8129
  }
@@ -16772,6 +17164,7 @@ export {
16772
17164
  identityFromMnemonicSync,
16773
17165
  initSphere,
16774
17166
  isArchivedKey,
17167
+ isCombinedTransferBundleV6,
16775
17168
  isForkedKey,
16776
17169
  isInstantSplitBundle,
16777
17170
  isInstantSplitBundleV4,