@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.cjs CHANGED
@@ -109,7 +109,9 @@ var init_constants = __esm({
109
109
  /** Group chat: processed event IDs for deduplication */
110
110
  GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
111
111
  /** Processed V5 split group IDs for Nostr re-delivery dedup */
112
- PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids"
112
+ PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids",
113
+ /** Processed V6 combined transfer IDs for Nostr re-delivery dedup */
114
+ PROCESSED_COMBINED_TRANSFER_IDS: "processed_combined_transfer_ids"
113
115
  };
114
116
  STORAGE_KEYS = {
115
117
  ...STORAGE_KEYS_GLOBAL,
@@ -792,6 +794,7 @@ __export(index_exports, {
792
794
  identityFromMnemonicSync: () => identityFromMnemonicSync,
793
795
  initSphere: () => initSphere,
794
796
  isArchivedKey: () => isArchivedKey,
797
+ isCombinedTransferBundleV6: () => isCombinedTransferBundleV6,
795
798
  isForkedKey: () => isForkedKey,
796
799
  isInstantSplitBundle: () => isInstantSplitBundle,
797
800
  isInstantSplitBundleV4: () => isInstantSplitBundleV4,
@@ -2872,7 +2875,7 @@ init_constants();
2872
2875
  // types/txf.ts
2873
2876
  var ARCHIVED_PREFIX = "archived-";
2874
2877
  var FORKED_PREFIX = "_forked_";
2875
- var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2878
+ var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity", "_history"];
2876
2879
  function isTokenKey(key) {
2877
2880
  return key.startsWith("_") && !key.startsWith(ARCHIVED_PREFIX) && !key.startsWith(FORKED_PREFIX) && !RESERVED_KEYS.includes(key);
2878
2881
  }
@@ -3494,6 +3497,9 @@ async function buildTxfStorageData(tokens, meta, options) {
3494
3497
  if (options?.invalidatedNametags && options.invalidatedNametags.length > 0) {
3495
3498
  storageData._invalidatedNametags = options.invalidatedNametags;
3496
3499
  }
3500
+ if (options?.historyEntries && options.historyEntries.length > 0) {
3501
+ storageData._history = options.historyEntries;
3502
+ }
3497
3503
  for (const token of tokens) {
3498
3504
  const txf = tokenToTxf(token);
3499
3505
  if (txf) {
@@ -3527,6 +3533,7 @@ function parseTxfStorageData(data) {
3527
3533
  outboxEntries: [],
3528
3534
  mintOutboxEntries: [],
3529
3535
  invalidatedNametags: [],
3536
+ historyEntries: [],
3530
3537
  validationErrors: []
3531
3538
  };
3532
3539
  if (!data || typeof data !== "object") {
@@ -3580,6 +3587,13 @@ function parseTxfStorageData(data) {
3580
3587
  }
3581
3588
  }
3582
3589
  }
3590
+ if (Array.isArray(storageData._history)) {
3591
+ for (const entry of storageData._history) {
3592
+ if (typeof entry === "object" && entry !== null && typeof entry.dedupKey === "string" && typeof entry.type === "string") {
3593
+ result.historyEntries.push(entry);
3594
+ }
3595
+ }
3596
+ }
3583
3597
  for (const key of Object.keys(storageData)) {
3584
3598
  if (isTokenKey(key)) {
3585
3599
  const tokenId = tokenIdFromKey(key);
@@ -3742,14 +3756,149 @@ var InstantSplitExecutor = class {
3742
3756
  this.devMode = config.devMode ?? false;
3743
3757
  }
3744
3758
  /**
3745
- * Execute an instant split transfer with V5 optimized flow.
3759
+ * Build a V5 split bundle WITHOUT sending it via transport.
3746
3760
  *
3747
- * Critical path (~2.3s):
3761
+ * Steps 1-5 of the V5 flow:
3748
3762
  * 1. Create and submit burn commitment
3749
3763
  * 2. Wait for burn proof
3750
3764
  * 3. Create mint commitments with SplitMintReason
3751
3765
  * 4. Create transfer commitment (no mint proof needed)
3752
- * 5. Send bundle via transport
3766
+ * 5. Package V5 bundle
3767
+ *
3768
+ * The caller is responsible for sending the bundle and then calling
3769
+ * `startBackground()` on the result to begin mint proof + change token creation.
3770
+ */
3771
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3772
+ const splitGroupId = crypto.randomUUID();
3773
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3774
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3775
+ const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3776
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3777
+ const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3778
+ const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3779
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3780
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3781
+ const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3782
+ tokenToSplit.type,
3783
+ this.signingService.algorithm,
3784
+ this.signingService.publicKey,
3785
+ import_HashAlgorithm3.HashAlgorithm.SHA256
3786
+ );
3787
+ const senderAddress = await senderAddressRef.toAddress();
3788
+ const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3789
+ const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3790
+ builder.createToken(
3791
+ recipientTokenId,
3792
+ tokenToSplit.type,
3793
+ new Uint8Array(0),
3794
+ coinDataA,
3795
+ senderAddress,
3796
+ // Mint to sender first, then transfer
3797
+ recipientSalt,
3798
+ null
3799
+ );
3800
+ const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3801
+ builder.createToken(
3802
+ senderTokenId,
3803
+ tokenToSplit.type,
3804
+ new Uint8Array(0),
3805
+ coinDataB,
3806
+ senderAddress,
3807
+ senderSalt,
3808
+ null
3809
+ );
3810
+ const split = await builder.build(tokenToSplit);
3811
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3812
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3813
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3814
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3815
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3816
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3817
+ }
3818
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3819
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3820
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3821
+ console.log(`[InstantSplit] Burn proof received`);
3822
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3823
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3824
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3825
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3826
+ const senderIdHex = toHex2(senderTokenId.bytes);
3827
+ const recipientMintCommitment = mintCommitments.find(
3828
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3829
+ );
3830
+ const senderMintCommitment = mintCommitments.find(
3831
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3832
+ );
3833
+ if (!recipientMintCommitment || !senderMintCommitment) {
3834
+ throw new Error("Failed to find expected mint commitments");
3835
+ }
3836
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3837
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3838
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3839
+ recipientMintCommitment.transactionData,
3840
+ recipientAddress,
3841
+ transferSalt,
3842
+ this.signingService
3843
+ );
3844
+ const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3845
+ recipientTokenId,
3846
+ tokenToSplit.type,
3847
+ this.signingService,
3848
+ import_HashAlgorithm3.HashAlgorithm.SHA256,
3849
+ recipientSalt
3850
+ );
3851
+ const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3852
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3853
+ const senderPubkey = toHex2(this.signingService.publicKey);
3854
+ let nametagTokenJson;
3855
+ const recipientAddressStr = recipientAddress.toString();
3856
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3857
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3858
+ }
3859
+ const bundle = {
3860
+ version: "5.0",
3861
+ type: "INSTANT_SPLIT",
3862
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3863
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3864
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3865
+ amount: splitAmount.toString(),
3866
+ coinId: coinIdHex,
3867
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3868
+ splitGroupId,
3869
+ senderPubkey,
3870
+ recipientSaltHex: toHex2(recipientSalt),
3871
+ transferSaltHex: toHex2(transferSalt),
3872
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3873
+ finalRecipientStateJson: "",
3874
+ // Recipient creates their own
3875
+ recipientAddressJson: recipientAddressStr,
3876
+ nametagTokenJson
3877
+ };
3878
+ return {
3879
+ bundle,
3880
+ splitGroupId,
3881
+ startBackground: async () => {
3882
+ if (!options?.skipBackground) {
3883
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3884
+ signingService: this.signingService,
3885
+ tokenType: tokenToSplit.type,
3886
+ coinId,
3887
+ senderTokenId,
3888
+ senderSalt,
3889
+ onProgress: options?.onBackgroundProgress,
3890
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3891
+ onStorageSync: options?.onStorageSync
3892
+ });
3893
+ }
3894
+ }
3895
+ };
3896
+ }
3897
+ /**
3898
+ * Execute an instant split transfer with V5 optimized flow.
3899
+ *
3900
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3901
+ * and starts background processing.
3753
3902
  *
3754
3903
  * @param tokenToSplit - The SDK token to split
3755
3904
  * @param splitAmount - Amount to send to recipient
@@ -3763,117 +3912,19 @@ var InstantSplitExecutor = class {
3763
3912
  */
3764
3913
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3765
3914
  const startTime = performance.now();
3766
- const splitGroupId = crypto.randomUUID();
3767
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3768
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3769
3915
  try {
3770
- const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3771
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3772
- const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3773
- const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3774
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3775
- const senderSalt = await sha2563(seedString + "_sender_salt");
3776
- const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3777
- tokenToSplit.type,
3778
- this.signingService.algorithm,
3779
- this.signingService.publicKey,
3780
- import_HashAlgorithm3.HashAlgorithm.SHA256
3781
- );
3782
- const senderAddress = await senderAddressRef.toAddress();
3783
- const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3784
- const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3785
- builder.createToken(
3786
- recipientTokenId,
3787
- tokenToSplit.type,
3788
- new Uint8Array(0),
3789
- coinDataA,
3790
- senderAddress,
3791
- // Mint to sender first, then transfer
3792
- recipientSalt,
3793
- null
3794
- );
3795
- const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3796
- builder.createToken(
3797
- senderTokenId,
3798
- tokenToSplit.type,
3799
- new Uint8Array(0),
3800
- coinDataB,
3801
- senderAddress,
3802
- senderSalt,
3803
- null
3804
- );
3805
- const split = await builder.build(tokenToSplit);
3806
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3807
- const burnSalt = await sha2563(seedString + "_burn_salt");
3808
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3809
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3810
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3811
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3812
- }
3813
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3814
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3815
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3816
- const burnDuration = performance.now() - startTime;
3817
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3818
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3819
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3820
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3821
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3822
- const senderIdHex = toHex2(senderTokenId.bytes);
3823
- const recipientMintCommitment = mintCommitments.find(
3824
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3825
- );
3826
- const senderMintCommitment = mintCommitments.find(
3827
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3828
- );
3829
- if (!recipientMintCommitment || !senderMintCommitment) {
3830
- throw new Error("Failed to find expected mint commitments");
3831
- }
3832
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3833
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3834
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3835
- recipientMintCommitment.transactionData,
3916
+ const buildResult = await this.buildSplitBundle(
3917
+ tokenToSplit,
3918
+ splitAmount,
3919
+ remainderAmount,
3920
+ coinIdHex,
3836
3921
  recipientAddress,
3837
- transferSalt,
3838
- this.signingService
3839
- );
3840
- const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3841
- recipientTokenId,
3842
- tokenToSplit.type,
3843
- this.signingService,
3844
- import_HashAlgorithm3.HashAlgorithm.SHA256,
3845
- recipientSalt
3922
+ options
3846
3923
  );
3847
- const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3848
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3924
+ console.log("[InstantSplit] Sending via transport...");
3849
3925
  const senderPubkey = toHex2(this.signingService.publicKey);
3850
- let nametagTokenJson;
3851
- const recipientAddressStr = recipientAddress.toString();
3852
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3853
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3854
- }
3855
- const bundle = {
3856
- version: "5.0",
3857
- type: "INSTANT_SPLIT",
3858
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3859
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3860
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3861
- amount: splitAmount.toString(),
3862
- coinId: coinIdHex,
3863
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3864
- splitGroupId,
3865
- senderPubkey,
3866
- recipientSaltHex: toHex2(recipientSalt),
3867
- transferSaltHex: toHex2(transferSalt),
3868
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3869
- finalRecipientStateJson: "",
3870
- // Recipient creates their own
3871
- recipientAddressJson: recipientAddressStr,
3872
- nametagTokenJson
3873
- };
3874
- console.log("[InstantSplit] Step 6: Sending via transport...");
3875
3926
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3876
- token: JSON.stringify(bundle),
3927
+ token: JSON.stringify(buildResult.bundle),
3877
3928
  proof: null,
3878
3929
  // Proof is included in the bundle
3879
3930
  memo: options?.memo,
@@ -3884,25 +3935,13 @@ var InstantSplitExecutor = class {
3884
3935
  const criticalPathDuration = performance.now() - startTime;
3885
3936
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3886
3937
  options?.onNostrDelivered?.(nostrEventId);
3887
- let backgroundPromise;
3888
- if (!options?.skipBackground) {
3889
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3890
- signingService: this.signingService,
3891
- tokenType: tokenToSplit.type,
3892
- coinId,
3893
- senderTokenId,
3894
- senderSalt,
3895
- onProgress: options?.onBackgroundProgress,
3896
- onChangeTokenCreated: options?.onChangeTokenCreated,
3897
- onStorageSync: options?.onStorageSync
3898
- });
3899
- }
3938
+ const backgroundPromise = buildResult.startBackground();
3900
3939
  return {
3901
3940
  success: true,
3902
3941
  nostrEventId,
3903
- splitGroupId,
3942
+ splitGroupId: buildResult.splitGroupId,
3904
3943
  criticalPathDurationMs: criticalPathDuration,
3905
- backgroundStarted: !options?.skipBackground,
3944
+ backgroundStarted: true,
3906
3945
  backgroundPromise
3907
3946
  };
3908
3947
  } catch (error) {
@@ -3911,7 +3950,6 @@ var InstantSplitExecutor = class {
3911
3950
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3912
3951
  return {
3913
3952
  success: false,
3914
- splitGroupId,
3915
3953
  criticalPathDurationMs: duration,
3916
3954
  error: errorMessage,
3917
3955
  backgroundStarted: false
@@ -4116,6 +4154,11 @@ function isInstantSplitBundleV4(obj) {
4116
4154
  function isInstantSplitBundleV5(obj) {
4117
4155
  return isInstantSplitBundle(obj) && obj.version === "5.0";
4118
4156
  }
4157
+ function isCombinedTransferBundleV6(obj) {
4158
+ if (typeof obj !== "object" || obj === null) return false;
4159
+ const b = obj;
4160
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
4161
+ }
4119
4162
 
4120
4163
  // modules/payments/InstantSplitProcessor.ts
4121
4164
  function fromHex3(hex) {
@@ -4440,6 +4483,7 @@ function computeHistoryDedupKey(type, tokenId, transferId) {
4440
4483
  if (tokenId) return `${type}_${tokenId}`;
4441
4484
  return `${type}_${crypto.randomUUID()}`;
4442
4485
  }
4486
+ var MAX_SYNCED_HISTORY_ENTRIES = 5e3;
4443
4487
  function enrichWithRegistry(info) {
4444
4488
  const registry = TokenRegistry.getInstance();
4445
4489
  const def = registry.getDefinition(info.coinId);
@@ -4769,6 +4813,8 @@ var PaymentsModule = class _PaymentsModule {
4769
4813
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4770
4814
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4771
4815
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4816
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4817
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4772
4818
  // Storage event subscriptions (push-based sync)
4773
4819
  storageEventUnsubscribers = [];
4774
4820
  syncDebounceTimer = null;
@@ -4862,6 +4908,10 @@ var PaymentsModule = class _PaymentsModule {
4862
4908
  const result = await provider.load();
4863
4909
  if (result.success && result.data) {
4864
4910
  this.loadFromStorageData(result.data);
4911
+ const txfData = result.data;
4912
+ if (txfData._history && txfData._history.length > 0) {
4913
+ await this.importRemoteHistoryEntries(txfData._history);
4914
+ }
4865
4915
  this.log(`Loaded metadata from provider ${id}`);
4866
4916
  break;
4867
4917
  }
@@ -4869,10 +4919,23 @@ var PaymentsModule = class _PaymentsModule {
4869
4919
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4870
4920
  }
4871
4921
  }
4922
+ for (const [id, token] of this.tokens) {
4923
+ try {
4924
+ if (token.sdkData) {
4925
+ const data = JSON.parse(token.sdkData);
4926
+ if (data?._placeholder) {
4927
+ this.tokens.delete(id);
4928
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4929
+ }
4930
+ }
4931
+ } catch {
4932
+ }
4933
+ }
4872
4934
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4873
4935
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4874
4936
  await this.loadPendingV5Tokens();
4875
4937
  await this.loadProcessedSplitGroupIds();
4938
+ await this.loadProcessedCombinedTransferIds();
4876
4939
  await this.loadHistory();
4877
4940
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4878
4941
  if (pending2) {
@@ -4964,12 +5027,13 @@ var PaymentsModule = class _PaymentsModule {
4964
5027
  token.status = "transferring";
4965
5028
  this.tokens.set(token.id, token);
4966
5029
  }
5030
+ await this.save();
4967
5031
  await this.saveToOutbox(result, recipientPubkey);
4968
5032
  result.status = "submitted";
4969
5033
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4970
5034
  const transferMode = request.transferMode ?? "instant";
4971
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4972
- if (transferMode === "conservative") {
5035
+ if (transferMode === "conservative") {
5036
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4973
5037
  this.log("Executing conservative split...");
4974
5038
  const splitExecutor = new TokenSplitExecutor({
4975
5039
  stateTransitionClient: stClient,
@@ -5013,27 +5077,59 @@ var PaymentsModule = class _PaymentsModule {
5013
5077
  requestIdHex: splitRequestIdHex
5014
5078
  });
5015
5079
  this.log(`Conservative split transfer completed`);
5016
- } else {
5017
- this.log("Executing instant split...");
5018
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
5080
+ }
5081
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
5082
+ const token = tokenWithAmount.uiToken;
5083
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
5084
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
5085
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
5086
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
5087
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
5088
+ }
5089
+ const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
5090
+ const transferTx = commitment.toTransaction(inclusionProof);
5091
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5092
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
5093
+ transferTx: JSON.stringify(transferTx.toJSON()),
5094
+ memo: request.memo
5095
+ });
5096
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
5097
+ const requestIdBytes = commitment.requestId;
5098
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5099
+ result.tokenTransfers.push({
5100
+ sourceTokenId: token.id,
5101
+ method: "direct",
5102
+ requestIdHex
5103
+ });
5104
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
5105
+ await this.removeToken(token.id);
5106
+ }
5107
+ } else {
5108
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
5109
+ const senderPubkey = this.deps.identity.chainPubkey;
5110
+ let changeTokenPlaceholderId = null;
5111
+ let builtSplit = null;
5112
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
5113
+ this.log("Building instant split bundle...");
5019
5114
  const executor = new InstantSplitExecutor({
5020
5115
  stateTransitionClient: stClient,
5021
5116
  trustBase,
5022
5117
  signingService,
5023
5118
  devMode
5024
5119
  });
5025
- const instantResult = await executor.executeSplitInstant(
5120
+ builtSplit = await executor.buildSplitBundle(
5026
5121
  splitPlan.tokenToSplit.sdkToken,
5027
5122
  splitPlan.splitAmount,
5028
5123
  splitPlan.remainderAmount,
5029
5124
  splitPlan.coinId,
5030
5125
  recipientAddress,
5031
- this.deps.transport,
5032
- recipientPubkey,
5033
5126
  {
5034
5127
  memo: request.memo,
5035
5128
  onChangeTokenCreated: async (changeToken) => {
5036
5129
  const changeTokenData = changeToken.toJSON();
5130
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
5131
+ this.tokens.delete(changeTokenPlaceholderId);
5132
+ }
5037
5133
  const uiToken = {
5038
5134
  id: crypto.randomUUID(),
5039
5135
  coinId: request.coinId,
@@ -5056,65 +5152,103 @@ var PaymentsModule = class _PaymentsModule {
5056
5152
  }
5057
5153
  }
5058
5154
  );
5059
- if (!instantResult.success) {
5060
- throw new Error(instantResult.error || "Instant split failed");
5061
- }
5062
- if (instantResult.backgroundPromise) {
5063
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
5064
- }
5155
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
5156
+ }
5157
+ const directCommitments = await Promise.all(
5158
+ splitPlan.tokensToTransferDirectly.map(
5159
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
5160
+ )
5161
+ );
5162
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
5163
+ (tw, i) => ({
5164
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
5165
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
5166
+ amount: tw.uiToken.amount,
5167
+ coinId: tw.uiToken.coinId,
5168
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
5169
+ })
5170
+ );
5171
+ const combinedBundle = {
5172
+ version: "6.0",
5173
+ type: "COMBINED_TRANSFER",
5174
+ transferId: result.id,
5175
+ splitBundle: builtSplit?.bundle ?? null,
5176
+ directTokens: directTokenEntries,
5177
+ totalAmount: request.amount.toString(),
5178
+ coinId: request.coinId,
5179
+ senderPubkey,
5180
+ memo: request.memo
5181
+ };
5182
+ console.log(
5183
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
5184
+ );
5185
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5186
+ token: JSON.stringify(combinedBundle),
5187
+ proof: null,
5188
+ memo: request.memo,
5189
+ sender: { transportPubkey: senderPubkey }
5190
+ });
5191
+ console.log(`[Payments] V6 combined bundle sent successfully`);
5192
+ if (builtSplit) {
5193
+ const bgPromise = builtSplit.startBackground();
5194
+ this.pendingBackgroundTasks.push(bgPromise);
5195
+ }
5196
+ if (builtSplit && splitPlan.remainderAmount) {
5197
+ changeTokenPlaceholderId = crypto.randomUUID();
5198
+ const placeholder = {
5199
+ id: changeTokenPlaceholderId,
5200
+ coinId: request.coinId,
5201
+ symbol: this.getCoinSymbol(request.coinId),
5202
+ name: this.getCoinName(request.coinId),
5203
+ decimals: this.getCoinDecimals(request.coinId),
5204
+ iconUrl: this.getCoinIconUrl(request.coinId),
5205
+ amount: splitPlan.remainderAmount.toString(),
5206
+ status: "transferring",
5207
+ createdAt: Date.now(),
5208
+ updatedAt: Date.now(),
5209
+ sdkData: JSON.stringify({ _placeholder: true })
5210
+ };
5211
+ this.tokens.set(placeholder.id, placeholder);
5212
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
5213
+ }
5214
+ for (const commitment of directCommitments) {
5215
+ stClient.submitTransferCommitment(commitment).catch(
5216
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
5217
+ );
5218
+ }
5219
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
5065
5220
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
5066
5221
  result.tokenTransfers.push({
5067
5222
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
5068
5223
  method: "split",
5069
- splitGroupId: instantResult.splitGroupId,
5070
- nostrEventId: instantResult.nostrEventId
5224
+ splitGroupId: builtSplit.splitGroupId
5071
5225
  });
5072
- this.log(`Instant split transfer completed`);
5073
5226
  }
5074
- }
5075
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
5076
- const token = tokenWithAmount.uiToken;
5077
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
5078
- if (transferMode === "conservative") {
5079
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
5080
- const submitResponse = await stClient.submitTransferCommitment(commitment);
5081
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
5082
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
5083
- }
5084
- const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
5085
- const transferTx = commitment.toTransaction(inclusionProof);
5086
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5087
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
5088
- transferTx: JSON.stringify(transferTx.toJSON()),
5089
- memo: request.memo
5090
- });
5091
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
5092
- } else {
5093
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
5094
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5095
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
5096
- commitmentData: JSON.stringify(commitment.toJSON()),
5097
- memo: request.memo
5227
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
5228
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
5229
+ const commitment = directCommitments[i];
5230
+ const requestIdBytes = commitment.requestId;
5231
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5232
+ result.tokenTransfers.push({
5233
+ sourceTokenId: token.id,
5234
+ method: "direct",
5235
+ requestIdHex
5098
5236
  });
5099
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
5100
- stClient.submitTransferCommitment(commitment).catch(
5101
- (err) => console.error("[Payments] Background commitment submit failed:", err)
5102
- );
5237
+ await this.removeToken(token.id);
5103
5238
  }
5104
- const requestIdBytes = commitment.requestId;
5105
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5106
- result.tokenTransfers.push({
5107
- sourceTokenId: token.id,
5108
- method: "direct",
5109
- requestIdHex
5110
- });
5111
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
5112
- await this.removeToken(token.id);
5239
+ this.log(`V6 combined transfer completed`);
5113
5240
  }
5114
5241
  result.status = "delivered";
5115
5242
  await this.save();
5116
5243
  await this.removeFromOutbox(result.id);
5117
5244
  result.status = "completed";
5245
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
5246
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
5247
+ id: tt.sourceTokenId,
5248
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
5249
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
5250
+ source: tt.method === "split" ? "split" : "direct"
5251
+ }));
5118
5252
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
5119
5253
  await this.addToHistory({
5120
5254
  type: "SENT",
@@ -5127,7 +5261,8 @@ var PaymentsModule = class _PaymentsModule {
5127
5261
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
5128
5262
  memo: request.memo,
5129
5263
  transferId: result.id,
5130
- tokenId: sentTokenId || void 0
5264
+ tokenId: sentTokenId || void 0,
5265
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
5131
5266
  });
5132
5267
  this.deps.emitEvent("transfer:confirmed", result);
5133
5268
  return result;
@@ -5297,6 +5432,267 @@ var PaymentsModule = class _PaymentsModule {
5297
5432
  };
5298
5433
  }
5299
5434
  }
5435
+ // ===========================================================================
5436
+ // Shared Helpers for V5 and V6 Receiver Processing
5437
+ // ===========================================================================
5438
+ /**
5439
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
5440
+ * Returns the created UI token, or null if deduped.
5441
+ *
5442
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
5443
+ * The token is still added to the in-memory map for dedup; caller must call save().
5444
+ */
5445
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
5446
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
5447
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5448
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5449
+ return null;
5450
+ }
5451
+ const registry = TokenRegistry.getInstance();
5452
+ const pendingData = {
5453
+ type: "v5_bundle",
5454
+ stage: "RECEIVED",
5455
+ bundleJson: JSON.stringify(bundle),
5456
+ senderPubkey,
5457
+ savedAt: Date.now(),
5458
+ attemptCount: 0
5459
+ };
5460
+ const uiToken = {
5461
+ id: deterministicId,
5462
+ coinId: bundle.coinId,
5463
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5464
+ name: registry.getName(bundle.coinId) || bundle.coinId,
5465
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
5466
+ amount: bundle.amount,
5467
+ status: "submitted",
5468
+ // UNCONFIRMED
5469
+ createdAt: Date.now(),
5470
+ updatedAt: Date.now(),
5471
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5472
+ };
5473
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5474
+ if (deferPersistence) {
5475
+ this.tokens.set(uiToken.id, uiToken);
5476
+ } else {
5477
+ await this.addToken(uiToken);
5478
+ await this.saveProcessedSplitGroupIds();
5479
+ }
5480
+ return uiToken;
5481
+ }
5482
+ /**
5483
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5484
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5485
+ * Returns the created UI token, or null if deduped/tombstoned.
5486
+ *
5487
+ * @param deferPersistence - If true, skip save() and commitment submission
5488
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5489
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5490
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5491
+ */
5492
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5493
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5494
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5495
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5496
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5497
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5498
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5499
+ return null;
5500
+ }
5501
+ if (nostrTokenId) {
5502
+ for (const existing of this.tokens.values()) {
5503
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5504
+ if (existingTokenId !== nostrTokenId) continue;
5505
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5506
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5507
+ console.log(
5508
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5509
+ );
5510
+ return null;
5511
+ }
5512
+ if (!skipGenesisDedup) {
5513
+ console.log(
5514
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5515
+ );
5516
+ return null;
5517
+ }
5518
+ }
5519
+ }
5520
+ const token = {
5521
+ id: crypto.randomUUID(),
5522
+ coinId: tokenInfo.coinId,
5523
+ symbol: tokenInfo.symbol,
5524
+ name: tokenInfo.name,
5525
+ decimals: tokenInfo.decimals,
5526
+ iconUrl: tokenInfo.iconUrl,
5527
+ amount: tokenInfo.amount,
5528
+ status: "submitted",
5529
+ // NOSTR-FIRST: unconfirmed until proof
5530
+ createdAt: Date.now(),
5531
+ updatedAt: Date.now(),
5532
+ sdkData
5533
+ };
5534
+ this.tokens.set(token.id, token);
5535
+ if (!deferPersistence) {
5536
+ await this.save();
5537
+ }
5538
+ try {
5539
+ const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
5540
+ const requestIdBytes = commitment.requestId;
5541
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5542
+ if (!deferPersistence) {
5543
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5544
+ if (stClient) {
5545
+ const response = await stClient.submitTransferCommitment(commitment);
5546
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5547
+ }
5548
+ }
5549
+ this.addProofPollingJob({
5550
+ tokenId: token.id,
5551
+ requestIdHex,
5552
+ commitmentJson: JSON.stringify(commitmentInput),
5553
+ startedAt: Date.now(),
5554
+ attemptCount: 0,
5555
+ lastAttemptAt: 0,
5556
+ onProofReceived: async (tokenId) => {
5557
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5558
+ }
5559
+ });
5560
+ } catch (err) {
5561
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5562
+ }
5563
+ return token;
5564
+ }
5565
+ // ===========================================================================
5566
+ // Combined Transfer V6 — Receiver
5567
+ // ===========================================================================
5568
+ /**
5569
+ * Process a received COMBINED_TRANSFER V6 bundle.
5570
+ *
5571
+ * Unpacks a single Nostr message into its component tokens:
5572
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5573
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5574
+ *
5575
+ * Emits ONE transfer:incoming event and records ONE history entry.
5576
+ */
5577
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5578
+ this.ensureInitialized();
5579
+ if (!this.loaded && this.loadedPromise) {
5580
+ await this.loadedPromise;
5581
+ }
5582
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5583
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5584
+ return;
5585
+ }
5586
+ console.log(
5587
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5588
+ );
5589
+ const allTokens = [];
5590
+ const tokenBreakdown = [];
5591
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5592
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5593
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5594
+ }));
5595
+ if (bundle.splitBundle) {
5596
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5597
+ if (splitToken) {
5598
+ allTokens.push(splitToken);
5599
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5600
+ } else {
5601
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5602
+ }
5603
+ }
5604
+ const directResults = await Promise.all(
5605
+ parsedDirectEntries.map(
5606
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5607
+ )
5608
+ );
5609
+ for (let i = 0; i < directResults.length; i++) {
5610
+ const token = directResults[i];
5611
+ if (token) {
5612
+ allTokens.push(token);
5613
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5614
+ } else {
5615
+ const entry = bundle.directTokens[i];
5616
+ console.warn(
5617
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5618
+ );
5619
+ }
5620
+ }
5621
+ if (allTokens.length === 0) {
5622
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5623
+ return;
5624
+ }
5625
+ this.processedCombinedTransferIds.add(bundle.transferId);
5626
+ const [senderInfo] = await Promise.all([
5627
+ this.resolveSenderInfo(senderPubkey),
5628
+ this.save(),
5629
+ this.saveProcessedCombinedTransferIds(),
5630
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5631
+ ]);
5632
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5633
+ if (stClient) {
5634
+ for (const { commitment } of parsedDirectEntries) {
5635
+ import_TransferCommitment4.TransferCommitment.fromJSON(commitment).then(
5636
+ (c) => stClient.submitTransferCommitment(c)
5637
+ ).catch(
5638
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5639
+ );
5640
+ }
5641
+ }
5642
+ this.deps.emitEvent("transfer:incoming", {
5643
+ id: bundle.transferId,
5644
+ senderPubkey,
5645
+ senderNametag: senderInfo.senderNametag,
5646
+ tokens: allTokens,
5647
+ memo: bundle.memo,
5648
+ receivedAt: Date.now()
5649
+ });
5650
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5651
+ await this.addToHistory({
5652
+ type: "RECEIVED",
5653
+ amount: actualAmount,
5654
+ coinId: bundle.coinId,
5655
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5656
+ timestamp: Date.now(),
5657
+ senderPubkey,
5658
+ ...senderInfo,
5659
+ memo: bundle.memo,
5660
+ transferId: bundle.transferId,
5661
+ tokenId: allTokens[0]?.id,
5662
+ tokenIds: tokenBreakdown
5663
+ });
5664
+ if (bundle.splitBundle) {
5665
+ this.resolveUnconfirmed().catch(() => {
5666
+ });
5667
+ this.scheduleResolveUnconfirmed();
5668
+ }
5669
+ }
5670
+ /**
5671
+ * Persist processed combined transfer IDs to KV storage.
5672
+ */
5673
+ async saveProcessedCombinedTransferIds() {
5674
+ const ids = Array.from(this.processedCombinedTransferIds);
5675
+ if (ids.length > 0) {
5676
+ await this.deps.storage.set(
5677
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5678
+ JSON.stringify(ids)
5679
+ );
5680
+ }
5681
+ }
5682
+ /**
5683
+ * Load processed combined transfer IDs from KV storage.
5684
+ */
5685
+ async loadProcessedCombinedTransferIds() {
5686
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5687
+ if (!data) return;
5688
+ try {
5689
+ const ids = JSON.parse(data);
5690
+ for (const id of ids) {
5691
+ this.processedCombinedTransferIds.add(id);
5692
+ }
5693
+ } catch {
5694
+ }
5695
+ }
5300
5696
  /**
5301
5697
  * Process a received INSTANT_SPLIT bundle.
5302
5698
  *
@@ -5320,36 +5716,10 @@ var PaymentsModule = class _PaymentsModule {
5320
5716
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
5321
5717
  }
5322
5718
  try {
5323
- const deterministicId = `v5split_${bundle.splitGroupId}`;
5324
- if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5325
- console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5719
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5720
+ if (!uiToken) {
5326
5721
  return { success: true, durationMs: 0 };
5327
5722
  }
5328
- const registry = TokenRegistry.getInstance();
5329
- const pendingData = {
5330
- type: "v5_bundle",
5331
- stage: "RECEIVED",
5332
- bundleJson: JSON.stringify(bundle),
5333
- senderPubkey,
5334
- savedAt: Date.now(),
5335
- attemptCount: 0
5336
- };
5337
- const uiToken = {
5338
- id: deterministicId,
5339
- coinId: bundle.coinId,
5340
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5341
- name: registry.getName(bundle.coinId) || bundle.coinId,
5342
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
5343
- amount: bundle.amount,
5344
- status: "submitted",
5345
- // UNCONFIRMED
5346
- createdAt: Date.now(),
5347
- updatedAt: Date.now(),
5348
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5349
- };
5350
- await this.addToken(uiToken);
5351
- this.processedSplitGroupIds.add(bundle.splitGroupId);
5352
- await this.saveProcessedSplitGroupIds();
5353
5723
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
5354
5724
  await this.addToHistory({
5355
5725
  type: "RECEIVED",
@@ -5360,7 +5730,7 @@ var PaymentsModule = class _PaymentsModule {
5360
5730
  senderPubkey,
5361
5731
  ...senderInfo,
5362
5732
  memo,
5363
- tokenId: deterministicId
5733
+ tokenId: uiToken.id
5364
5734
  });
5365
5735
  this.deps.emitEvent("transfer:incoming", {
5366
5736
  id: bundle.splitGroupId,
@@ -6010,16 +6380,18 @@ var PaymentsModule = class _PaymentsModule {
6010
6380
  }
6011
6381
  /**
6012
6382
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
6013
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
6383
+ * Excludes tokens with status 'spent' or 'invalid'.
6384
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
6014
6385
  */
6015
6386
  aggregateTokens(coinId) {
6016
6387
  const assetsMap = /* @__PURE__ */ new Map();
6017
6388
  for (const token of this.tokens.values()) {
6018
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6389
+ if (token.status === "spent" || token.status === "invalid") continue;
6019
6390
  if (coinId && token.coinId !== coinId) continue;
6020
6391
  const key = token.coinId;
6021
6392
  const amount = BigInt(token.amount);
6022
6393
  const isConfirmed = token.status === "confirmed";
6394
+ const isTransferring = token.status === "transferring";
6023
6395
  const existing = assetsMap.get(key);
6024
6396
  if (existing) {
6025
6397
  if (isConfirmed) {
@@ -6029,6 +6401,7 @@ var PaymentsModule = class _PaymentsModule {
6029
6401
  existing.unconfirmedAmount += amount;
6030
6402
  existing.unconfirmedTokenCount++;
6031
6403
  }
6404
+ if (isTransferring) existing.transferringTokenCount++;
6032
6405
  } else {
6033
6406
  assetsMap.set(key, {
6034
6407
  coinId: token.coinId,
@@ -6039,7 +6412,8 @@ var PaymentsModule = class _PaymentsModule {
6039
6412
  confirmedAmount: isConfirmed ? amount : 0n,
6040
6413
  unconfirmedAmount: isConfirmed ? 0n : amount,
6041
6414
  confirmedTokenCount: isConfirmed ? 1 : 0,
6042
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6415
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6416
+ transferringTokenCount: isTransferring ? 1 : 0
6043
6417
  });
6044
6418
  }
6045
6419
  }
@@ -6057,6 +6431,7 @@ var PaymentsModule = class _PaymentsModule {
6057
6431
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
6058
6432
  confirmedTokenCount: raw.confirmedTokenCount,
6059
6433
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6434
+ transferringTokenCount: raw.transferringTokenCount,
6060
6435
  priceUsd: null,
6061
6436
  priceEur: null,
6062
6437
  change24h: null,
@@ -6909,6 +7284,33 @@ var PaymentsModule = class _PaymentsModule {
6909
7284
  }
6910
7285
  }
6911
7286
  }
7287
+ /**
7288
+ * Import history entries from remote TXF data into local store.
7289
+ * Delegates to the local TokenStorageProvider's importHistoryEntries() for
7290
+ * persistent storage, with in-memory fallback.
7291
+ * Reused by both load() (initial IPFS fetch) and _doSync() (merge result).
7292
+ */
7293
+ async importRemoteHistoryEntries(entries) {
7294
+ if (entries.length === 0) return 0;
7295
+ const provider = this.getLocalTokenStorageProvider();
7296
+ if (provider?.importHistoryEntries) {
7297
+ const imported2 = await provider.importHistoryEntries(entries);
7298
+ if (imported2 > 0) {
7299
+ this._historyCache = await provider.getHistoryEntries();
7300
+ }
7301
+ return imported2;
7302
+ }
7303
+ const existingKeys = new Set(this._historyCache.map((e) => e.dedupKey));
7304
+ let imported = 0;
7305
+ for (const entry of entries) {
7306
+ if (!existingKeys.has(entry.dedupKey)) {
7307
+ this._historyCache.push(entry);
7308
+ existingKeys.add(entry.dedupKey);
7309
+ imported++;
7310
+ }
7311
+ }
7312
+ return imported;
7313
+ }
6912
7314
  /**
6913
7315
  * Get the first local token storage provider (for history operations).
6914
7316
  */
@@ -7156,6 +7558,13 @@ var PaymentsModule = class _PaymentsModule {
7156
7558
  if (this.nametags.length === 0 && savedNametags.length > 0) {
7157
7559
  this.nametags = savedNametags;
7158
7560
  }
7561
+ const txfData = result.merged;
7562
+ if (txfData._history && txfData._history.length > 0) {
7563
+ const imported = await this.importRemoteHistoryEntries(txfData._history);
7564
+ if (imported > 0) {
7565
+ this.log(`Imported ${imported} history entries from IPFS sync`);
7566
+ }
7567
+ }
7159
7568
  totalAdded += result.added;
7160
7569
  totalRemoved += result.removed;
7161
7570
  }
@@ -7454,7 +7863,7 @@ var PaymentsModule = class _PaymentsModule {
7454
7863
  /**
7455
7864
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7456
7865
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7457
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7866
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7458
7867
  */
7459
7868
  async handleCommitmentOnlyTransfer(transfer, payload) {
7460
7869
  try {
@@ -7464,41 +7873,22 @@ var PaymentsModule = class _PaymentsModule {
7464
7873
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7465
7874
  return;
7466
7875
  }
7467
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7468
- const token = {
7469
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7470
- coinId: tokenInfo.coinId,
7471
- symbol: tokenInfo.symbol,
7472
- name: tokenInfo.name,
7473
- decimals: tokenInfo.decimals,
7474
- iconUrl: tokenInfo.iconUrl,
7475
- amount: tokenInfo.amount,
7476
- status: "submitted",
7477
- // NOSTR-FIRST: unconfirmed until proof
7478
- createdAt: Date.now(),
7479
- updatedAt: Date.now(),
7480
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7481
- };
7482
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7483
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7484
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7485
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7486
- return;
7487
- }
7488
- this.tokens.set(token.id, token);
7489
- console.log(`[Payments][DEBUG] NOSTR-FIRST: saving token id=${token.id.slice(0, 16)} status=${token.status} sdkData.length=${token.sdkData?.length}`);
7490
- await this.save();
7491
- console.log(`[Payments][DEBUG] NOSTR-FIRST: save() completed, tokens.size=${this.tokens.size}`);
7876
+ const token = await this.saveCommitmentOnlyToken(
7877
+ sourceTokenInput,
7878
+ commitmentInput,
7879
+ transfer.senderTransportPubkey
7880
+ );
7881
+ if (!token) return;
7492
7882
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7493
- const incomingTransfer = {
7883
+ this.deps.emitEvent("transfer:incoming", {
7494
7884
  id: transfer.id,
7495
7885
  senderPubkey: transfer.senderTransportPubkey,
7496
7886
  senderNametag: senderInfo.senderNametag,
7497
7887
  tokens: [token],
7498
7888
  memo: payload.memo,
7499
7889
  receivedAt: transfer.timestamp
7500
- };
7501
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7890
+ });
7891
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7502
7892
  await this.addToHistory({
7503
7893
  type: "RECEIVED",
7504
7894
  amount: token.amount,
@@ -7510,29 +7900,6 @@ var PaymentsModule = class _PaymentsModule {
7510
7900
  memo: payload.memo,
7511
7901
  tokenId: nostrTokenId || token.id
7512
7902
  });
7513
- try {
7514
- const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
7515
- const requestIdBytes = commitment.requestId;
7516
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7517
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7518
- if (stClient) {
7519
- const response = await stClient.submitTransferCommitment(commitment);
7520
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7521
- }
7522
- this.addProofPollingJob({
7523
- tokenId: token.id,
7524
- requestIdHex,
7525
- commitmentJson: JSON.stringify(commitmentInput),
7526
- startedAt: Date.now(),
7527
- attemptCount: 0,
7528
- lastAttemptAt: 0,
7529
- onProofReceived: async (tokenId) => {
7530
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7531
- }
7532
- });
7533
- } catch (err) {
7534
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7535
- }
7536
7903
  } catch (error) {
7537
7904
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7538
7905
  }
@@ -7651,6 +8018,28 @@ var PaymentsModule = class _PaymentsModule {
7651
8018
  try {
7652
8019
  const payload = transfer.payload;
7653
8020
  console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
8021
+ let combinedBundle = null;
8022
+ if (isCombinedTransferBundleV6(payload)) {
8023
+ combinedBundle = payload;
8024
+ } else if (payload.token) {
8025
+ try {
8026
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
8027
+ if (isCombinedTransferBundleV6(inner)) {
8028
+ combinedBundle = inner;
8029
+ }
8030
+ } catch {
8031
+ }
8032
+ }
8033
+ if (combinedBundle) {
8034
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
8035
+ try {
8036
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
8037
+ this.log("COMBINED_TRANSFER V6 processed successfully");
8038
+ } catch (err) {
8039
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
8040
+ }
8041
+ return;
8042
+ }
7654
8043
  let instantBundle = null;
7655
8044
  if (isInstantSplitBundle(payload)) {
7656
8045
  instantBundle = payload;
@@ -7802,17 +8191,19 @@ var PaymentsModule = class _PaymentsModule {
7802
8191
  memo: payload.memo,
7803
8192
  tokenId: incomingTokenId || token.id
7804
8193
  });
8194
+ const incomingTransfer = {
8195
+ id: transfer.id,
8196
+ senderPubkey: transfer.senderTransportPubkey,
8197
+ senderNametag: senderInfo.senderNametag,
8198
+ tokens: [token],
8199
+ memo: payload.memo,
8200
+ receivedAt: transfer.timestamp
8201
+ };
8202
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
8203
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
8204
+ } else {
8205
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7805
8206
  }
7806
- const incomingTransfer = {
7807
- id: transfer.id,
7808
- senderPubkey: transfer.senderTransportPubkey,
7809
- senderNametag: senderInfo.senderNametag,
7810
- tokens: [token],
7811
- memo: payload.memo,
7812
- receivedAt: transfer.timestamp
7813
- };
7814
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7815
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7816
8207
  } catch (error) {
7817
8208
  console.error("[Payments] Failed to process incoming transfer:", error);
7818
8209
  }
@@ -7881,6 +8272,7 @@ var PaymentsModule = class _PaymentsModule {
7881
8272
  return data ? JSON.parse(data) : [];
7882
8273
  }
7883
8274
  async createStorageData() {
8275
+ const sorted = [...this._historyCache].sort((a, b) => b.timestamp - a.timestamp);
7884
8276
  return await buildTxfStorageData(
7885
8277
  Array.from(this.tokens.values()),
7886
8278
  {
@@ -7892,7 +8284,8 @@ var PaymentsModule = class _PaymentsModule {
7892
8284
  nametags: this.nametags,
7893
8285
  tombstones: this.tombstones,
7894
8286
  archivedTokens: this.archivedTokens,
7895
- forkedTokens: this.forkedTokens
8287
+ forkedTokens: this.forkedTokens,
8288
+ historyEntries: sorted.slice(0, MAX_SYNCED_HISTORY_ENTRIES)
7896
8289
  }
7897
8290
  );
7898
8291
  }
@@ -16925,6 +17318,7 @@ function createPriceProvider(config) {
16925
17318
  identityFromMnemonicSync,
16926
17319
  initSphere,
16927
17320
  isArchivedKey,
17321
+ isCombinedTransferBundleV6,
16928
17322
  isForkedKey,
16929
17323
  isInstantSplitBundle,
16930
17324
  isInstantSplitBundleV4,