@unicitylabs/sphere-sdk 0.5.1 → 0.5.2

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 +615 -275
  6. package/dist/core/index.cjs.map +1 -1
  7. package/dist/core/index.d.cts +49 -2
  8. package/dist/core/index.d.ts +49 -2
  9. package/dist/core/index.js +615 -275
  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 +5 -2
  16. package/dist/impl/browser/index.cjs.map +1 -1
  17. package/dist/impl/browser/index.js +5 -2
  18. package/dist/impl/browser/index.js.map +1 -1
  19. package/dist/impl/browser/ipfs.cjs +3 -1
  20. package/dist/impl/browser/ipfs.cjs.map +1 -1
  21. package/dist/impl/browser/ipfs.js +3 -1
  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 +5 -2
  28. package/dist/impl/nodejs/index.cjs.map +1 -1
  29. package/dist/impl/nodejs/index.d.cts +6 -0
  30. package/dist/impl/nodejs/index.d.ts +6 -0
  31. package/dist/impl/nodejs/index.js +5 -2
  32. package/dist/impl/nodejs/index.js.map +1 -1
  33. package/dist/index.cjs +617 -275
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +118 -3
  36. package/dist/index.d.ts +118 -3
  37. package/dist/index.js +616 -275
  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,
@@ -3742,14 +3745,149 @@ var InstantSplitExecutor = class {
3742
3745
  this.devMode = config.devMode ?? false;
3743
3746
  }
3744
3747
  /**
3745
- * Execute an instant split transfer with V5 optimized flow.
3748
+ * Build a V5 split bundle WITHOUT sending it via transport.
3746
3749
  *
3747
- * Critical path (~2.3s):
3750
+ * Steps 1-5 of the V5 flow:
3748
3751
  * 1. Create and submit burn commitment
3749
3752
  * 2. Wait for burn proof
3750
3753
  * 3. Create mint commitments with SplitMintReason
3751
3754
  * 4. Create transfer commitment (no mint proof needed)
3752
- * 5. Send bundle via transport
3755
+ * 5. Package V5 bundle
3756
+ *
3757
+ * The caller is responsible for sending the bundle and then calling
3758
+ * `startBackground()` on the result to begin mint proof + change token creation.
3759
+ */
3760
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3761
+ const splitGroupId = crypto.randomUUID();
3762
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3763
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3764
+ const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3765
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3766
+ const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3767
+ const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3768
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3769
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3770
+ const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3771
+ tokenToSplit.type,
3772
+ this.signingService.algorithm,
3773
+ this.signingService.publicKey,
3774
+ import_HashAlgorithm3.HashAlgorithm.SHA256
3775
+ );
3776
+ const senderAddress = await senderAddressRef.toAddress();
3777
+ const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3778
+ const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3779
+ builder.createToken(
3780
+ recipientTokenId,
3781
+ tokenToSplit.type,
3782
+ new Uint8Array(0),
3783
+ coinDataA,
3784
+ senderAddress,
3785
+ // Mint to sender first, then transfer
3786
+ recipientSalt,
3787
+ null
3788
+ );
3789
+ const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3790
+ builder.createToken(
3791
+ senderTokenId,
3792
+ tokenToSplit.type,
3793
+ new Uint8Array(0),
3794
+ coinDataB,
3795
+ senderAddress,
3796
+ senderSalt,
3797
+ null
3798
+ );
3799
+ const split = await builder.build(tokenToSplit);
3800
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3801
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3802
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3803
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3804
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3805
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3806
+ }
3807
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3808
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3809
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3810
+ console.log(`[InstantSplit] Burn proof received`);
3811
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3812
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3813
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3814
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3815
+ const senderIdHex = toHex2(senderTokenId.bytes);
3816
+ const recipientMintCommitment = mintCommitments.find(
3817
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3818
+ );
3819
+ const senderMintCommitment = mintCommitments.find(
3820
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3821
+ );
3822
+ if (!recipientMintCommitment || !senderMintCommitment) {
3823
+ throw new Error("Failed to find expected mint commitments");
3824
+ }
3825
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3826
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3827
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3828
+ recipientMintCommitment.transactionData,
3829
+ recipientAddress,
3830
+ transferSalt,
3831
+ this.signingService
3832
+ );
3833
+ const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3834
+ recipientTokenId,
3835
+ tokenToSplit.type,
3836
+ this.signingService,
3837
+ import_HashAlgorithm3.HashAlgorithm.SHA256,
3838
+ recipientSalt
3839
+ );
3840
+ const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3841
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3842
+ const senderPubkey = toHex2(this.signingService.publicKey);
3843
+ let nametagTokenJson;
3844
+ const recipientAddressStr = recipientAddress.toString();
3845
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3846
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3847
+ }
3848
+ const bundle = {
3849
+ version: "5.0",
3850
+ type: "INSTANT_SPLIT",
3851
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3852
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3853
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3854
+ amount: splitAmount.toString(),
3855
+ coinId: coinIdHex,
3856
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3857
+ splitGroupId,
3858
+ senderPubkey,
3859
+ recipientSaltHex: toHex2(recipientSalt),
3860
+ transferSaltHex: toHex2(transferSalt),
3861
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3862
+ finalRecipientStateJson: "",
3863
+ // Recipient creates their own
3864
+ recipientAddressJson: recipientAddressStr,
3865
+ nametagTokenJson
3866
+ };
3867
+ return {
3868
+ bundle,
3869
+ splitGroupId,
3870
+ startBackground: async () => {
3871
+ if (!options?.skipBackground) {
3872
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3873
+ signingService: this.signingService,
3874
+ tokenType: tokenToSplit.type,
3875
+ coinId,
3876
+ senderTokenId,
3877
+ senderSalt,
3878
+ onProgress: options?.onBackgroundProgress,
3879
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3880
+ onStorageSync: options?.onStorageSync
3881
+ });
3882
+ }
3883
+ }
3884
+ };
3885
+ }
3886
+ /**
3887
+ * Execute an instant split transfer with V5 optimized flow.
3888
+ *
3889
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3890
+ * and starts background processing.
3753
3891
  *
3754
3892
  * @param tokenToSplit - The SDK token to split
3755
3893
  * @param splitAmount - Amount to send to recipient
@@ -3763,117 +3901,19 @@ var InstantSplitExecutor = class {
3763
3901
  */
3764
3902
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3765
3903
  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
3904
  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,
3905
+ const buildResult = await this.buildSplitBundle(
3906
+ tokenToSplit,
3907
+ splitAmount,
3908
+ remainderAmount,
3909
+ coinIdHex,
3836
3910
  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
3911
+ options
3846
3912
  );
3847
- const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3848
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3913
+ console.log("[InstantSplit] Sending via transport...");
3849
3914
  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
3915
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3876
- token: JSON.stringify(bundle),
3916
+ token: JSON.stringify(buildResult.bundle),
3877
3917
  proof: null,
3878
3918
  // Proof is included in the bundle
3879
3919
  memo: options?.memo,
@@ -3884,25 +3924,13 @@ var InstantSplitExecutor = class {
3884
3924
  const criticalPathDuration = performance.now() - startTime;
3885
3925
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3886
3926
  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
- }
3927
+ const backgroundPromise = buildResult.startBackground();
3900
3928
  return {
3901
3929
  success: true,
3902
3930
  nostrEventId,
3903
- splitGroupId,
3931
+ splitGroupId: buildResult.splitGroupId,
3904
3932
  criticalPathDurationMs: criticalPathDuration,
3905
- backgroundStarted: !options?.skipBackground,
3933
+ backgroundStarted: true,
3906
3934
  backgroundPromise
3907
3935
  };
3908
3936
  } catch (error) {
@@ -3911,7 +3939,6 @@ var InstantSplitExecutor = class {
3911
3939
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3912
3940
  return {
3913
3941
  success: false,
3914
- splitGroupId,
3915
3942
  criticalPathDurationMs: duration,
3916
3943
  error: errorMessage,
3917
3944
  backgroundStarted: false
@@ -4116,6 +4143,11 @@ function isInstantSplitBundleV4(obj) {
4116
4143
  function isInstantSplitBundleV5(obj) {
4117
4144
  return isInstantSplitBundle(obj) && obj.version === "5.0";
4118
4145
  }
4146
+ function isCombinedTransferBundleV6(obj) {
4147
+ if (typeof obj !== "object" || obj === null) return false;
4148
+ const b = obj;
4149
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
4150
+ }
4119
4151
 
4120
4152
  // modules/payments/InstantSplitProcessor.ts
4121
4153
  function fromHex3(hex) {
@@ -4769,6 +4801,8 @@ var PaymentsModule = class _PaymentsModule {
4769
4801
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4770
4802
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4771
4803
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4804
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4805
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4772
4806
  // Storage event subscriptions (push-based sync)
4773
4807
  storageEventUnsubscribers = [];
4774
4808
  syncDebounceTimer = null;
@@ -4869,10 +4903,23 @@ var PaymentsModule = class _PaymentsModule {
4869
4903
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4870
4904
  }
4871
4905
  }
4906
+ for (const [id, token] of this.tokens) {
4907
+ try {
4908
+ if (token.sdkData) {
4909
+ const data = JSON.parse(token.sdkData);
4910
+ if (data?._placeholder) {
4911
+ this.tokens.delete(id);
4912
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4913
+ }
4914
+ }
4915
+ } catch {
4916
+ }
4917
+ }
4872
4918
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4873
4919
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4874
4920
  await this.loadPendingV5Tokens();
4875
4921
  await this.loadProcessedSplitGroupIds();
4922
+ await this.loadProcessedCombinedTransferIds();
4876
4923
  await this.loadHistory();
4877
4924
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4878
4925
  if (pending2) {
@@ -4964,12 +5011,13 @@ var PaymentsModule = class _PaymentsModule {
4964
5011
  token.status = "transferring";
4965
5012
  this.tokens.set(token.id, token);
4966
5013
  }
5014
+ await this.save();
4967
5015
  await this.saveToOutbox(result, recipientPubkey);
4968
5016
  result.status = "submitted";
4969
5017
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4970
5018
  const transferMode = request.transferMode ?? "instant";
4971
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4972
- if (transferMode === "conservative") {
5019
+ if (transferMode === "conservative") {
5020
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4973
5021
  this.log("Executing conservative split...");
4974
5022
  const splitExecutor = new TokenSplitExecutor({
4975
5023
  stateTransitionClient: stClient,
@@ -5013,27 +5061,59 @@ var PaymentsModule = class _PaymentsModule {
5013
5061
  requestIdHex: splitRequestIdHex
5014
5062
  });
5015
5063
  this.log(`Conservative split transfer completed`);
5016
- } else {
5017
- this.log("Executing instant split...");
5018
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
5064
+ }
5065
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
5066
+ const token = tokenWithAmount.uiToken;
5067
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
5068
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
5069
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
5070
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
5071
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
5072
+ }
5073
+ const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
5074
+ const transferTx = commitment.toTransaction(inclusionProof);
5075
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5076
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
5077
+ transferTx: JSON.stringify(transferTx.toJSON()),
5078
+ memo: request.memo
5079
+ });
5080
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
5081
+ const requestIdBytes = commitment.requestId;
5082
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5083
+ result.tokenTransfers.push({
5084
+ sourceTokenId: token.id,
5085
+ method: "direct",
5086
+ requestIdHex
5087
+ });
5088
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
5089
+ await this.removeToken(token.id);
5090
+ }
5091
+ } else {
5092
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
5093
+ const senderPubkey = this.deps.identity.chainPubkey;
5094
+ let changeTokenPlaceholderId = null;
5095
+ let builtSplit = null;
5096
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
5097
+ this.log("Building instant split bundle...");
5019
5098
  const executor = new InstantSplitExecutor({
5020
5099
  stateTransitionClient: stClient,
5021
5100
  trustBase,
5022
5101
  signingService,
5023
5102
  devMode
5024
5103
  });
5025
- const instantResult = await executor.executeSplitInstant(
5104
+ builtSplit = await executor.buildSplitBundle(
5026
5105
  splitPlan.tokenToSplit.sdkToken,
5027
5106
  splitPlan.splitAmount,
5028
5107
  splitPlan.remainderAmount,
5029
5108
  splitPlan.coinId,
5030
5109
  recipientAddress,
5031
- this.deps.transport,
5032
- recipientPubkey,
5033
5110
  {
5034
5111
  memo: request.memo,
5035
5112
  onChangeTokenCreated: async (changeToken) => {
5036
5113
  const changeTokenData = changeToken.toJSON();
5114
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
5115
+ this.tokens.delete(changeTokenPlaceholderId);
5116
+ }
5037
5117
  const uiToken = {
5038
5118
  id: crypto.randomUUID(),
5039
5119
  coinId: request.coinId,
@@ -5056,65 +5136,103 @@ var PaymentsModule = class _PaymentsModule {
5056
5136
  }
5057
5137
  }
5058
5138
  );
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
- }
5139
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
5140
+ }
5141
+ const directCommitments = await Promise.all(
5142
+ splitPlan.tokensToTransferDirectly.map(
5143
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
5144
+ )
5145
+ );
5146
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
5147
+ (tw, i) => ({
5148
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
5149
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
5150
+ amount: tw.uiToken.amount,
5151
+ coinId: tw.uiToken.coinId,
5152
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
5153
+ })
5154
+ );
5155
+ const combinedBundle = {
5156
+ version: "6.0",
5157
+ type: "COMBINED_TRANSFER",
5158
+ transferId: result.id,
5159
+ splitBundle: builtSplit?.bundle ?? null,
5160
+ directTokens: directTokenEntries,
5161
+ totalAmount: request.amount.toString(),
5162
+ coinId: request.coinId,
5163
+ senderPubkey,
5164
+ memo: request.memo
5165
+ };
5166
+ console.log(
5167
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
5168
+ );
5169
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5170
+ token: JSON.stringify(combinedBundle),
5171
+ proof: null,
5172
+ memo: request.memo,
5173
+ sender: { transportPubkey: senderPubkey }
5174
+ });
5175
+ console.log(`[Payments] V6 combined bundle sent successfully`);
5176
+ if (builtSplit) {
5177
+ const bgPromise = builtSplit.startBackground();
5178
+ this.pendingBackgroundTasks.push(bgPromise);
5179
+ }
5180
+ if (builtSplit && splitPlan.remainderAmount) {
5181
+ changeTokenPlaceholderId = crypto.randomUUID();
5182
+ const placeholder = {
5183
+ id: changeTokenPlaceholderId,
5184
+ coinId: request.coinId,
5185
+ symbol: this.getCoinSymbol(request.coinId),
5186
+ name: this.getCoinName(request.coinId),
5187
+ decimals: this.getCoinDecimals(request.coinId),
5188
+ iconUrl: this.getCoinIconUrl(request.coinId),
5189
+ amount: splitPlan.remainderAmount.toString(),
5190
+ status: "transferring",
5191
+ createdAt: Date.now(),
5192
+ updatedAt: Date.now(),
5193
+ sdkData: JSON.stringify({ _placeholder: true })
5194
+ };
5195
+ this.tokens.set(placeholder.id, placeholder);
5196
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
5197
+ }
5198
+ for (const commitment of directCommitments) {
5199
+ stClient.submitTransferCommitment(commitment).catch(
5200
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
5201
+ );
5202
+ }
5203
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
5065
5204
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
5066
5205
  result.tokenTransfers.push({
5067
5206
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
5068
5207
  method: "split",
5069
- splitGroupId: instantResult.splitGroupId,
5070
- nostrEventId: instantResult.nostrEventId
5208
+ splitGroupId: builtSplit.splitGroupId
5071
5209
  });
5072
- this.log(`Instant split transfer completed`);
5073
5210
  }
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
5211
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
5212
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
5213
+ const commitment = directCommitments[i];
5214
+ const requestIdBytes = commitment.requestId;
5215
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5216
+ result.tokenTransfers.push({
5217
+ sourceTokenId: token.id,
5218
+ method: "direct",
5219
+ requestIdHex
5098
5220
  });
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
- );
5221
+ await this.removeToken(token.id);
5103
5222
  }
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);
5223
+ this.log(`V6 combined transfer completed`);
5113
5224
  }
5114
5225
  result.status = "delivered";
5115
5226
  await this.save();
5116
5227
  await this.removeFromOutbox(result.id);
5117
5228
  result.status = "completed";
5229
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
5230
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
5231
+ id: tt.sourceTokenId,
5232
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
5233
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
5234
+ source: tt.method === "split" ? "split" : "direct"
5235
+ }));
5118
5236
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
5119
5237
  await this.addToHistory({
5120
5238
  type: "SENT",
@@ -5127,7 +5245,8 @@ var PaymentsModule = class _PaymentsModule {
5127
5245
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
5128
5246
  memo: request.memo,
5129
5247
  transferId: result.id,
5130
- tokenId: sentTokenId || void 0
5248
+ tokenId: sentTokenId || void 0,
5249
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
5131
5250
  });
5132
5251
  this.deps.emitEvent("transfer:confirmed", result);
5133
5252
  return result;
@@ -5297,6 +5416,267 @@ var PaymentsModule = class _PaymentsModule {
5297
5416
  };
5298
5417
  }
5299
5418
  }
5419
+ // ===========================================================================
5420
+ // Shared Helpers for V5 and V6 Receiver Processing
5421
+ // ===========================================================================
5422
+ /**
5423
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
5424
+ * Returns the created UI token, or null if deduped.
5425
+ *
5426
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
5427
+ * The token is still added to the in-memory map for dedup; caller must call save().
5428
+ */
5429
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
5430
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
5431
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5432
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5433
+ return null;
5434
+ }
5435
+ const registry = TokenRegistry.getInstance();
5436
+ const pendingData = {
5437
+ type: "v5_bundle",
5438
+ stage: "RECEIVED",
5439
+ bundleJson: JSON.stringify(bundle),
5440
+ senderPubkey,
5441
+ savedAt: Date.now(),
5442
+ attemptCount: 0
5443
+ };
5444
+ const uiToken = {
5445
+ id: deterministicId,
5446
+ coinId: bundle.coinId,
5447
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5448
+ name: registry.getName(bundle.coinId) || bundle.coinId,
5449
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
5450
+ amount: bundle.amount,
5451
+ status: "submitted",
5452
+ // UNCONFIRMED
5453
+ createdAt: Date.now(),
5454
+ updatedAt: Date.now(),
5455
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5456
+ };
5457
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5458
+ if (deferPersistence) {
5459
+ this.tokens.set(uiToken.id, uiToken);
5460
+ } else {
5461
+ await this.addToken(uiToken);
5462
+ await this.saveProcessedSplitGroupIds();
5463
+ }
5464
+ return uiToken;
5465
+ }
5466
+ /**
5467
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5468
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5469
+ * Returns the created UI token, or null if deduped/tombstoned.
5470
+ *
5471
+ * @param deferPersistence - If true, skip save() and commitment submission
5472
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5473
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5474
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5475
+ */
5476
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5477
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5478
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5479
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5480
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5481
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5482
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5483
+ return null;
5484
+ }
5485
+ if (nostrTokenId) {
5486
+ for (const existing of this.tokens.values()) {
5487
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5488
+ if (existingTokenId !== nostrTokenId) continue;
5489
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5490
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5491
+ console.log(
5492
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5493
+ );
5494
+ return null;
5495
+ }
5496
+ if (!skipGenesisDedup) {
5497
+ console.log(
5498
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5499
+ );
5500
+ return null;
5501
+ }
5502
+ }
5503
+ }
5504
+ const token = {
5505
+ id: crypto.randomUUID(),
5506
+ coinId: tokenInfo.coinId,
5507
+ symbol: tokenInfo.symbol,
5508
+ name: tokenInfo.name,
5509
+ decimals: tokenInfo.decimals,
5510
+ iconUrl: tokenInfo.iconUrl,
5511
+ amount: tokenInfo.amount,
5512
+ status: "submitted",
5513
+ // NOSTR-FIRST: unconfirmed until proof
5514
+ createdAt: Date.now(),
5515
+ updatedAt: Date.now(),
5516
+ sdkData
5517
+ };
5518
+ this.tokens.set(token.id, token);
5519
+ if (!deferPersistence) {
5520
+ await this.save();
5521
+ }
5522
+ try {
5523
+ const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
5524
+ const requestIdBytes = commitment.requestId;
5525
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5526
+ if (!deferPersistence) {
5527
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5528
+ if (stClient) {
5529
+ const response = await stClient.submitTransferCommitment(commitment);
5530
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5531
+ }
5532
+ }
5533
+ this.addProofPollingJob({
5534
+ tokenId: token.id,
5535
+ requestIdHex,
5536
+ commitmentJson: JSON.stringify(commitmentInput),
5537
+ startedAt: Date.now(),
5538
+ attemptCount: 0,
5539
+ lastAttemptAt: 0,
5540
+ onProofReceived: async (tokenId) => {
5541
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5542
+ }
5543
+ });
5544
+ } catch (err) {
5545
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5546
+ }
5547
+ return token;
5548
+ }
5549
+ // ===========================================================================
5550
+ // Combined Transfer V6 — Receiver
5551
+ // ===========================================================================
5552
+ /**
5553
+ * Process a received COMBINED_TRANSFER V6 bundle.
5554
+ *
5555
+ * Unpacks a single Nostr message into its component tokens:
5556
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5557
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5558
+ *
5559
+ * Emits ONE transfer:incoming event and records ONE history entry.
5560
+ */
5561
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5562
+ this.ensureInitialized();
5563
+ if (!this.loaded && this.loadedPromise) {
5564
+ await this.loadedPromise;
5565
+ }
5566
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5567
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5568
+ return;
5569
+ }
5570
+ console.log(
5571
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5572
+ );
5573
+ const allTokens = [];
5574
+ const tokenBreakdown = [];
5575
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5576
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5577
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5578
+ }));
5579
+ if (bundle.splitBundle) {
5580
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5581
+ if (splitToken) {
5582
+ allTokens.push(splitToken);
5583
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5584
+ } else {
5585
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5586
+ }
5587
+ }
5588
+ const directResults = await Promise.all(
5589
+ parsedDirectEntries.map(
5590
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5591
+ )
5592
+ );
5593
+ for (let i = 0; i < directResults.length; i++) {
5594
+ const token = directResults[i];
5595
+ if (token) {
5596
+ allTokens.push(token);
5597
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5598
+ } else {
5599
+ const entry = bundle.directTokens[i];
5600
+ console.warn(
5601
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5602
+ );
5603
+ }
5604
+ }
5605
+ if (allTokens.length === 0) {
5606
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5607
+ return;
5608
+ }
5609
+ this.processedCombinedTransferIds.add(bundle.transferId);
5610
+ const [senderInfo] = await Promise.all([
5611
+ this.resolveSenderInfo(senderPubkey),
5612
+ this.save(),
5613
+ this.saveProcessedCombinedTransferIds(),
5614
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5615
+ ]);
5616
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5617
+ if (stClient) {
5618
+ for (const { commitment } of parsedDirectEntries) {
5619
+ import_TransferCommitment4.TransferCommitment.fromJSON(commitment).then(
5620
+ (c) => stClient.submitTransferCommitment(c)
5621
+ ).catch(
5622
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5623
+ );
5624
+ }
5625
+ }
5626
+ this.deps.emitEvent("transfer:incoming", {
5627
+ id: bundle.transferId,
5628
+ senderPubkey,
5629
+ senderNametag: senderInfo.senderNametag,
5630
+ tokens: allTokens,
5631
+ memo: bundle.memo,
5632
+ receivedAt: Date.now()
5633
+ });
5634
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5635
+ await this.addToHistory({
5636
+ type: "RECEIVED",
5637
+ amount: actualAmount,
5638
+ coinId: bundle.coinId,
5639
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5640
+ timestamp: Date.now(),
5641
+ senderPubkey,
5642
+ ...senderInfo,
5643
+ memo: bundle.memo,
5644
+ transferId: bundle.transferId,
5645
+ tokenId: allTokens[0]?.id,
5646
+ tokenIds: tokenBreakdown
5647
+ });
5648
+ if (bundle.splitBundle) {
5649
+ this.resolveUnconfirmed().catch(() => {
5650
+ });
5651
+ this.scheduleResolveUnconfirmed();
5652
+ }
5653
+ }
5654
+ /**
5655
+ * Persist processed combined transfer IDs to KV storage.
5656
+ */
5657
+ async saveProcessedCombinedTransferIds() {
5658
+ const ids = Array.from(this.processedCombinedTransferIds);
5659
+ if (ids.length > 0) {
5660
+ await this.deps.storage.set(
5661
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5662
+ JSON.stringify(ids)
5663
+ );
5664
+ }
5665
+ }
5666
+ /**
5667
+ * Load processed combined transfer IDs from KV storage.
5668
+ */
5669
+ async loadProcessedCombinedTransferIds() {
5670
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5671
+ if (!data) return;
5672
+ try {
5673
+ const ids = JSON.parse(data);
5674
+ for (const id of ids) {
5675
+ this.processedCombinedTransferIds.add(id);
5676
+ }
5677
+ } catch {
5678
+ }
5679
+ }
5300
5680
  /**
5301
5681
  * Process a received INSTANT_SPLIT bundle.
5302
5682
  *
@@ -5320,36 +5700,10 @@ var PaymentsModule = class _PaymentsModule {
5320
5700
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
5321
5701
  }
5322
5702
  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`);
5703
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5704
+ if (!uiToken) {
5326
5705
  return { success: true, durationMs: 0 };
5327
5706
  }
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
5707
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
5354
5708
  await this.addToHistory({
5355
5709
  type: "RECEIVED",
@@ -5360,7 +5714,7 @@ var PaymentsModule = class _PaymentsModule {
5360
5714
  senderPubkey,
5361
5715
  ...senderInfo,
5362
5716
  memo,
5363
- tokenId: deterministicId
5717
+ tokenId: uiToken.id
5364
5718
  });
5365
5719
  this.deps.emitEvent("transfer:incoming", {
5366
5720
  id: bundle.splitGroupId,
@@ -6010,16 +6364,18 @@ var PaymentsModule = class _PaymentsModule {
6010
6364
  }
6011
6365
  /**
6012
6366
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
6013
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
6367
+ * Excludes tokens with status 'spent' or 'invalid'.
6368
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
6014
6369
  */
6015
6370
  aggregateTokens(coinId) {
6016
6371
  const assetsMap = /* @__PURE__ */ new Map();
6017
6372
  for (const token of this.tokens.values()) {
6018
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6373
+ if (token.status === "spent" || token.status === "invalid") continue;
6019
6374
  if (coinId && token.coinId !== coinId) continue;
6020
6375
  const key = token.coinId;
6021
6376
  const amount = BigInt(token.amount);
6022
6377
  const isConfirmed = token.status === "confirmed";
6378
+ const isTransferring = token.status === "transferring";
6023
6379
  const existing = assetsMap.get(key);
6024
6380
  if (existing) {
6025
6381
  if (isConfirmed) {
@@ -6029,6 +6385,7 @@ var PaymentsModule = class _PaymentsModule {
6029
6385
  existing.unconfirmedAmount += amount;
6030
6386
  existing.unconfirmedTokenCount++;
6031
6387
  }
6388
+ if (isTransferring) existing.transferringTokenCount++;
6032
6389
  } else {
6033
6390
  assetsMap.set(key, {
6034
6391
  coinId: token.coinId,
@@ -6039,7 +6396,8 @@ var PaymentsModule = class _PaymentsModule {
6039
6396
  confirmedAmount: isConfirmed ? amount : 0n,
6040
6397
  unconfirmedAmount: isConfirmed ? 0n : amount,
6041
6398
  confirmedTokenCount: isConfirmed ? 1 : 0,
6042
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6399
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6400
+ transferringTokenCount: isTransferring ? 1 : 0
6043
6401
  });
6044
6402
  }
6045
6403
  }
@@ -6057,6 +6415,7 @@ var PaymentsModule = class _PaymentsModule {
6057
6415
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
6058
6416
  confirmedTokenCount: raw.confirmedTokenCount,
6059
6417
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6418
+ transferringTokenCount: raw.transferringTokenCount,
6060
6419
  priceUsd: null,
6061
6420
  priceEur: null,
6062
6421
  change24h: null,
@@ -7454,7 +7813,7 @@ var PaymentsModule = class _PaymentsModule {
7454
7813
  /**
7455
7814
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7456
7815
  * 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.
7816
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7458
7817
  */
7459
7818
  async handleCommitmentOnlyTransfer(transfer, payload) {
7460
7819
  try {
@@ -7464,41 +7823,22 @@ var PaymentsModule = class _PaymentsModule {
7464
7823
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7465
7824
  return;
7466
7825
  }
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}`);
7826
+ const token = await this.saveCommitmentOnlyToken(
7827
+ sourceTokenInput,
7828
+ commitmentInput,
7829
+ transfer.senderTransportPubkey
7830
+ );
7831
+ if (!token) return;
7492
7832
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7493
- const incomingTransfer = {
7833
+ this.deps.emitEvent("transfer:incoming", {
7494
7834
  id: transfer.id,
7495
7835
  senderPubkey: transfer.senderTransportPubkey,
7496
7836
  senderNametag: senderInfo.senderNametag,
7497
7837
  tokens: [token],
7498
7838
  memo: payload.memo,
7499
7839
  receivedAt: transfer.timestamp
7500
- };
7501
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7840
+ });
7841
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7502
7842
  await this.addToHistory({
7503
7843
  type: "RECEIVED",
7504
7844
  amount: token.amount,
@@ -7510,29 +7850,6 @@ var PaymentsModule = class _PaymentsModule {
7510
7850
  memo: payload.memo,
7511
7851
  tokenId: nostrTokenId || token.id
7512
7852
  });
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
7853
  } catch (error) {
7537
7854
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7538
7855
  }
@@ -7651,6 +7968,28 @@ var PaymentsModule = class _PaymentsModule {
7651
7968
  try {
7652
7969
  const payload = transfer.payload;
7653
7970
  console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
7971
+ let combinedBundle = null;
7972
+ if (isCombinedTransferBundleV6(payload)) {
7973
+ combinedBundle = payload;
7974
+ } else if (payload.token) {
7975
+ try {
7976
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7977
+ if (isCombinedTransferBundleV6(inner)) {
7978
+ combinedBundle = inner;
7979
+ }
7980
+ } catch {
7981
+ }
7982
+ }
7983
+ if (combinedBundle) {
7984
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
7985
+ try {
7986
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
7987
+ this.log("COMBINED_TRANSFER V6 processed successfully");
7988
+ } catch (err) {
7989
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
7990
+ }
7991
+ return;
7992
+ }
7654
7993
  let instantBundle = null;
7655
7994
  if (isInstantSplitBundle(payload)) {
7656
7995
  instantBundle = payload;
@@ -7802,17 +8141,19 @@ var PaymentsModule = class _PaymentsModule {
7802
8141
  memo: payload.memo,
7803
8142
  tokenId: incomingTokenId || token.id
7804
8143
  });
8144
+ const incomingTransfer = {
8145
+ id: transfer.id,
8146
+ senderPubkey: transfer.senderTransportPubkey,
8147
+ senderNametag: senderInfo.senderNametag,
8148
+ tokens: [token],
8149
+ memo: payload.memo,
8150
+ receivedAt: transfer.timestamp
8151
+ };
8152
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
8153
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
8154
+ } else {
8155
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7805
8156
  }
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
8157
  } catch (error) {
7817
8158
  console.error("[Payments] Failed to process incoming transfer:", error);
7818
8159
  }
@@ -16925,6 +17266,7 @@ function createPriceProvider(config) {
16925
17266
  identityFromMnemonicSync,
16926
17267
  initSphere,
16927
17268
  isArchivedKey,
17269
+ isCombinedTransferBundleV6,
16928
17270
  isForkedKey,
16929
17271
  isInstantSplitBundle,
16930
17272
  isInstantSplitBundleV4,