@unicitylabs/sphere-sdk 0.5.0 → 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 +5 -1
  2. package/dist/connect/index.cjs.map +1 -1
  3. package/dist/connect/index.js +5 -1
  4. package/dist/connect/index.js.map +1 -1
  5. package/dist/core/index.cjs +813 -309
  6. package/dist/core/index.cjs.map +1 -1
  7. package/dist/core/index.d.cts +71 -2
  8. package/dist/core/index.d.ts +71 -2
  9. package/dist/core/index.js +813 -309
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/impl/browser/connect/index.cjs +5 -1
  12. package/dist/impl/browser/connect/index.cjs.map +1 -1
  13. package/dist/impl/browser/connect/index.js +5 -1
  14. package/dist/impl/browser/connect/index.js.map +1 -1
  15. package/dist/impl/browser/index.cjs +7 -2
  16. package/dist/impl/browser/index.cjs.map +1 -1
  17. package/dist/impl/browser/index.js +7 -2
  18. package/dist/impl/browser/index.js.map +1 -1
  19. package/dist/impl/browser/ipfs.cjs +5 -1
  20. package/dist/impl/browser/ipfs.cjs.map +1 -1
  21. package/dist/impl/browser/ipfs.js +5 -1
  22. package/dist/impl/browser/ipfs.js.map +1 -1
  23. package/dist/impl/nodejs/connect/index.cjs +5 -1
  24. package/dist/impl/nodejs/connect/index.cjs.map +1 -1
  25. package/dist/impl/nodejs/connect/index.js +5 -1
  26. package/dist/impl/nodejs/connect/index.js.map +1 -1
  27. package/dist/impl/nodejs/index.cjs +7 -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 +7 -2
  32. package/dist/impl/nodejs/index.js.map +1 -1
  33. package/dist/index.cjs +815 -309
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +144 -3
  36. package/dist/index.d.ts +144 -3
  37. package/dist/index.js +814 -309
  38. package/dist/index.js.map +1 -1
  39. package/dist/l1/index.cjs +5 -1
  40. package/dist/l1/index.cjs.map +1 -1
  41. package/dist/l1/index.js +5 -1
  42. package/dist/l1/index.js.map +1 -1
  43. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -107,7 +107,11 @@ var init_constants = __esm({
107
107
  /** Group chat: members for this address */
108
108
  GROUP_CHAT_MEMBERS: "group_chat_members",
109
109
  /** Group chat: processed event IDs for deduplication */
110
- GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events"
110
+ GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
111
+ /** Processed V5 split group IDs for Nostr re-delivery dedup */
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"
111
115
  };
112
116
  STORAGE_KEYS = {
113
117
  ...STORAGE_KEYS_GLOBAL,
@@ -790,6 +794,7 @@ __export(index_exports, {
790
794
  identityFromMnemonicSync: () => identityFromMnemonicSync,
791
795
  initSphere: () => initSphere,
792
796
  isArchivedKey: () => isArchivedKey,
797
+ isCombinedTransferBundleV6: () => isCombinedTransferBundleV6,
793
798
  isForkedKey: () => isForkedKey,
794
799
  isInstantSplitBundle: () => isInstantSplitBundle,
795
800
  isInstantSplitBundleV4: () => isInstantSplitBundleV4,
@@ -3740,14 +3745,149 @@ var InstantSplitExecutor = class {
3740
3745
  this.devMode = config.devMode ?? false;
3741
3746
  }
3742
3747
  /**
3743
- * Execute an instant split transfer with V5 optimized flow.
3748
+ * Build a V5 split bundle WITHOUT sending it via transport.
3744
3749
  *
3745
- * Critical path (~2.3s):
3750
+ * Steps 1-5 of the V5 flow:
3746
3751
  * 1. Create and submit burn commitment
3747
3752
  * 2. Wait for burn proof
3748
3753
  * 3. Create mint commitments with SplitMintReason
3749
3754
  * 4. Create transfer commitment (no mint proof needed)
3750
- * 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.
3751
3891
  *
3752
3892
  * @param tokenToSplit - The SDK token to split
3753
3893
  * @param splitAmount - Amount to send to recipient
@@ -3761,117 +3901,19 @@ var InstantSplitExecutor = class {
3761
3901
  */
3762
3902
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3763
3903
  const startTime = performance.now();
3764
- const splitGroupId = crypto.randomUUID();
3765
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3766
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3767
3904
  try {
3768
- const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3769
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3770
- const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3771
- const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3772
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3773
- const senderSalt = await sha2563(seedString + "_sender_salt");
3774
- const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3775
- tokenToSplit.type,
3776
- this.signingService.algorithm,
3777
- this.signingService.publicKey,
3778
- import_HashAlgorithm3.HashAlgorithm.SHA256
3779
- );
3780
- const senderAddress = await senderAddressRef.toAddress();
3781
- const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3782
- const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3783
- builder.createToken(
3784
- recipientTokenId,
3785
- tokenToSplit.type,
3786
- new Uint8Array(0),
3787
- coinDataA,
3788
- senderAddress,
3789
- // Mint to sender first, then transfer
3790
- recipientSalt,
3791
- null
3792
- );
3793
- const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3794
- builder.createToken(
3795
- senderTokenId,
3796
- tokenToSplit.type,
3797
- new Uint8Array(0),
3798
- coinDataB,
3799
- senderAddress,
3800
- senderSalt,
3801
- null
3802
- );
3803
- const split = await builder.build(tokenToSplit);
3804
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3805
- const burnSalt = await sha2563(seedString + "_burn_salt");
3806
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3807
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3808
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3809
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3810
- }
3811
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3812
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3813
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3814
- const burnDuration = performance.now() - startTime;
3815
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3816
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3817
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3818
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3819
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3820
- const senderIdHex = toHex2(senderTokenId.bytes);
3821
- const recipientMintCommitment = mintCommitments.find(
3822
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3823
- );
3824
- const senderMintCommitment = mintCommitments.find(
3825
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3826
- );
3827
- if (!recipientMintCommitment || !senderMintCommitment) {
3828
- throw new Error("Failed to find expected mint commitments");
3829
- }
3830
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3831
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3832
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3833
- recipientMintCommitment.transactionData,
3905
+ const buildResult = await this.buildSplitBundle(
3906
+ tokenToSplit,
3907
+ splitAmount,
3908
+ remainderAmount,
3909
+ coinIdHex,
3834
3910
  recipientAddress,
3835
- transferSalt,
3836
- this.signingService
3911
+ options
3837
3912
  );
3838
- const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3839
- recipientTokenId,
3840
- tokenToSplit.type,
3841
- this.signingService,
3842
- import_HashAlgorithm3.HashAlgorithm.SHA256,
3843
- recipientSalt
3844
- );
3845
- const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3846
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3913
+ console.log("[InstantSplit] Sending via transport...");
3847
3914
  const senderPubkey = toHex2(this.signingService.publicKey);
3848
- let nametagTokenJson;
3849
- const recipientAddressStr = recipientAddress.toString();
3850
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3851
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3852
- }
3853
- const bundle = {
3854
- version: "5.0",
3855
- type: "INSTANT_SPLIT",
3856
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3857
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3858
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3859
- amount: splitAmount.toString(),
3860
- coinId: coinIdHex,
3861
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3862
- splitGroupId,
3863
- senderPubkey,
3864
- recipientSaltHex: toHex2(recipientSalt),
3865
- transferSaltHex: toHex2(transferSalt),
3866
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3867
- finalRecipientStateJson: "",
3868
- // Recipient creates their own
3869
- recipientAddressJson: recipientAddressStr,
3870
- nametagTokenJson
3871
- };
3872
- console.log("[InstantSplit] Step 6: Sending via transport...");
3873
3915
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3874
- token: JSON.stringify(bundle),
3916
+ token: JSON.stringify(buildResult.bundle),
3875
3917
  proof: null,
3876
3918
  // Proof is included in the bundle
3877
3919
  memo: options?.memo,
@@ -3882,25 +3924,13 @@ var InstantSplitExecutor = class {
3882
3924
  const criticalPathDuration = performance.now() - startTime;
3883
3925
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3884
3926
  options?.onNostrDelivered?.(nostrEventId);
3885
- let backgroundPromise;
3886
- if (!options?.skipBackground) {
3887
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3888
- signingService: this.signingService,
3889
- tokenType: tokenToSplit.type,
3890
- coinId,
3891
- senderTokenId,
3892
- senderSalt,
3893
- onProgress: options?.onBackgroundProgress,
3894
- onChangeTokenCreated: options?.onChangeTokenCreated,
3895
- onStorageSync: options?.onStorageSync
3896
- });
3897
- }
3927
+ const backgroundPromise = buildResult.startBackground();
3898
3928
  return {
3899
3929
  success: true,
3900
3930
  nostrEventId,
3901
- splitGroupId,
3931
+ splitGroupId: buildResult.splitGroupId,
3902
3932
  criticalPathDurationMs: criticalPathDuration,
3903
- backgroundStarted: !options?.skipBackground,
3933
+ backgroundStarted: true,
3904
3934
  backgroundPromise
3905
3935
  };
3906
3936
  } catch (error) {
@@ -3909,7 +3939,6 @@ var InstantSplitExecutor = class {
3909
3939
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3910
3940
  return {
3911
3941
  success: false,
3912
- splitGroupId,
3913
3942
  criticalPathDurationMs: duration,
3914
3943
  error: errorMessage,
3915
3944
  backgroundStarted: false
@@ -4114,6 +4143,11 @@ function isInstantSplitBundleV4(obj) {
4114
4143
  function isInstantSplitBundleV5(obj) {
4115
4144
  return isInstantSplitBundle(obj) && obj.version === "5.0";
4116
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
+ }
4117
4151
 
4118
4152
  // modules/payments/InstantSplitProcessor.ts
4119
4153
  function fromHex3(hex) {
@@ -4756,6 +4790,19 @@ var PaymentsModule = class _PaymentsModule {
4756
4790
  // Poll every 2s
4757
4791
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
4758
4792
  // Max 30 attempts (~60s)
4793
+ // Periodic retry for resolveUnconfirmed (V5 lazy finalization)
4794
+ resolveUnconfirmedTimer = null;
4795
+ static RESOLVE_UNCONFIRMED_INTERVAL_MS = 1e4;
4796
+ // Retry every 10s
4797
+ // Guard: ensure load() completes before processing incoming bundles
4798
+ loadedPromise = null;
4799
+ loaded = false;
4800
+ // Persistent dedup: tracks splitGroupIds that have been fully processed.
4801
+ // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4802
+ // even when the confirmed token's in-memory ID differs from v5split_{id}.
4803
+ processedSplitGroupIds = /* @__PURE__ */ new Set();
4804
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4805
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4759
4806
  // Storage event subscriptions (push-based sync)
4760
4807
  storageEventUnsubscribers = [];
4761
4808
  syncDebounceTimer = null;
@@ -4841,31 +4888,53 @@ var PaymentsModule = class _PaymentsModule {
4841
4888
  */
4842
4889
  async load() {
4843
4890
  this.ensureInitialized();
4844
- await TokenRegistry.waitForReady();
4845
- const providers = this.getTokenStorageProviders();
4846
- for (const [id, provider] of providers) {
4847
- try {
4848
- const result = await provider.load();
4849
- if (result.success && result.data) {
4850
- this.loadFromStorageData(result.data);
4851
- this.log(`Loaded metadata from provider ${id}`);
4852
- break;
4891
+ const doLoad = async () => {
4892
+ await TokenRegistry.waitForReady();
4893
+ const providers = this.getTokenStorageProviders();
4894
+ for (const [id, provider] of providers) {
4895
+ try {
4896
+ const result = await provider.load();
4897
+ if (result.success && result.data) {
4898
+ this.loadFromStorageData(result.data);
4899
+ this.log(`Loaded metadata from provider ${id}`);
4900
+ break;
4901
+ }
4902
+ } catch (err) {
4903
+ console.error(`[Payments] Failed to load from provider ${id}:`, err);
4853
4904
  }
4854
- } catch (err) {
4855
- console.error(`[Payments] Failed to load from provider ${id}:`, err);
4856
4905
  }
4857
- }
4858
- await this.loadPendingV5Tokens();
4859
- await this.loadHistory();
4860
- const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4861
- if (pending2) {
4862
- const transfers = JSON.parse(pending2);
4863
- for (const transfer of transfers) {
4864
- this.pendingTransfers.set(transfer.id, transfer);
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
+ }
4865
4917
  }
4866
- }
4918
+ const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4919
+ console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4920
+ await this.loadPendingV5Tokens();
4921
+ await this.loadProcessedSplitGroupIds();
4922
+ await this.loadProcessedCombinedTransferIds();
4923
+ await this.loadHistory();
4924
+ const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4925
+ if (pending2) {
4926
+ const transfers = JSON.parse(pending2);
4927
+ for (const transfer of transfers) {
4928
+ this.pendingTransfers.set(transfer.id, transfer);
4929
+ }
4930
+ }
4931
+ this.loaded = true;
4932
+ };
4933
+ this.loadedPromise = doLoad();
4934
+ await this.loadedPromise;
4867
4935
  this.resolveUnconfirmed().catch(() => {
4868
4936
  });
4937
+ this.scheduleResolveUnconfirmed();
4869
4938
  }
4870
4939
  /**
4871
4940
  * Cleanup all subscriptions, polling jobs, and pending resolvers.
@@ -4884,6 +4953,7 @@ var PaymentsModule = class _PaymentsModule {
4884
4953
  this.paymentRequestResponseHandlers.clear();
4885
4954
  this.stopProofPolling();
4886
4955
  this.proofPollingJobs.clear();
4956
+ this.stopResolveUnconfirmedPolling();
4887
4957
  for (const [, resolver] of this.pendingResponseResolvers) {
4888
4958
  clearTimeout(resolver.timeout);
4889
4959
  resolver.reject(new Error("Module destroyed"));
@@ -4941,12 +5011,13 @@ var PaymentsModule = class _PaymentsModule {
4941
5011
  token.status = "transferring";
4942
5012
  this.tokens.set(token.id, token);
4943
5013
  }
5014
+ await this.save();
4944
5015
  await this.saveToOutbox(result, recipientPubkey);
4945
5016
  result.status = "submitted";
4946
5017
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4947
5018
  const transferMode = request.transferMode ?? "instant";
4948
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4949
- if (transferMode === "conservative") {
5019
+ if (transferMode === "conservative") {
5020
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4950
5021
  this.log("Executing conservative split...");
4951
5022
  const splitExecutor = new TokenSplitExecutor({
4952
5023
  stateTransitionClient: stClient,
@@ -4990,27 +5061,59 @@ var PaymentsModule = class _PaymentsModule {
4990
5061
  requestIdHex: splitRequestIdHex
4991
5062
  });
4992
5063
  this.log(`Conservative split transfer completed`);
4993
- } else {
4994
- this.log("Executing instant split...");
4995
- 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...");
4996
5098
  const executor = new InstantSplitExecutor({
4997
5099
  stateTransitionClient: stClient,
4998
5100
  trustBase,
4999
5101
  signingService,
5000
5102
  devMode
5001
5103
  });
5002
- const instantResult = await executor.executeSplitInstant(
5104
+ builtSplit = await executor.buildSplitBundle(
5003
5105
  splitPlan.tokenToSplit.sdkToken,
5004
5106
  splitPlan.splitAmount,
5005
5107
  splitPlan.remainderAmount,
5006
5108
  splitPlan.coinId,
5007
5109
  recipientAddress,
5008
- this.deps.transport,
5009
- recipientPubkey,
5010
5110
  {
5011
5111
  memo: request.memo,
5012
5112
  onChangeTokenCreated: async (changeToken) => {
5013
5113
  const changeTokenData = changeToken.toJSON();
5114
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
5115
+ this.tokens.delete(changeTokenPlaceholderId);
5116
+ }
5014
5117
  const uiToken = {
5015
5118
  id: crypto.randomUUID(),
5016
5119
  coinId: request.coinId,
@@ -5033,65 +5136,103 @@ var PaymentsModule = class _PaymentsModule {
5033
5136
  }
5034
5137
  }
5035
5138
  );
5036
- if (!instantResult.success) {
5037
- throw new Error(instantResult.error || "Instant split failed");
5038
- }
5039
- if (instantResult.backgroundPromise) {
5040
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
5041
- }
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) {
5042
5204
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
5043
5205
  result.tokenTransfers.push({
5044
5206
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
5045
5207
  method: "split",
5046
- splitGroupId: instantResult.splitGroupId,
5047
- nostrEventId: instantResult.nostrEventId
5208
+ splitGroupId: builtSplit.splitGroupId
5048
5209
  });
5049
- this.log(`Instant split transfer completed`);
5050
5210
  }
5051
- }
5052
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
5053
- const token = tokenWithAmount.uiToken;
5054
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
5055
- if (transferMode === "conservative") {
5056
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
5057
- const submitResponse = await stClient.submitTransferCommitment(commitment);
5058
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
5059
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
5060
- }
5061
- const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
5062
- const transferTx = commitment.toTransaction(inclusionProof);
5063
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5064
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
5065
- transferTx: JSON.stringify(transferTx.toJSON()),
5066
- memo: request.memo
5067
- });
5068
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
5069
- } else {
5070
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
5071
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5072
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
5073
- commitmentData: JSON.stringify(commitment.toJSON()),
5074
- 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
5075
5220
  });
5076
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
5077
- stClient.submitTransferCommitment(commitment).catch(
5078
- (err) => console.error("[Payments] Background commitment submit failed:", err)
5079
- );
5221
+ await this.removeToken(token.id);
5080
5222
  }
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 ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
5089
- await this.removeToken(token.id);
5223
+ this.log(`V6 combined transfer completed`);
5090
5224
  }
5091
5225
  result.status = "delivered";
5092
5226
  await this.save();
5093
5227
  await this.removeFromOutbox(result.id);
5094
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
+ }));
5095
5236
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
5096
5237
  await this.addToHistory({
5097
5238
  type: "SENT",
@@ -5104,7 +5245,8 @@ var PaymentsModule = class _PaymentsModule {
5104
5245
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
5105
5246
  memo: request.memo,
5106
5247
  transferId: result.id,
5107
- tokenId: sentTokenId || void 0
5248
+ tokenId: sentTokenId || void 0,
5249
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
5108
5250
  });
5109
5251
  this.deps.emitEvent("transfer:confirmed", result);
5110
5252
  return result;
@@ -5274,6 +5416,267 @@ var PaymentsModule = class _PaymentsModule {
5274
5416
  };
5275
5417
  }
5276
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
+ }
5277
5680
  /**
5278
5681
  * Process a received INSTANT_SPLIT bundle.
5279
5682
  *
@@ -5290,39 +5693,17 @@ var PaymentsModule = class _PaymentsModule {
5290
5693
  */
5291
5694
  async processInstantSplitBundle(bundle, senderPubkey, memo) {
5292
5695
  this.ensureInitialized();
5696
+ if (!this.loaded && this.loadedPromise) {
5697
+ await this.loadedPromise;
5698
+ }
5293
5699
  if (!isInstantSplitBundleV5(bundle)) {
5294
5700
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
5295
5701
  }
5296
5702
  try {
5297
- const deterministicId = `v5split_${bundle.splitGroupId}`;
5298
- if (this.tokens.has(deterministicId)) {
5299
- this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
5703
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5704
+ if (!uiToken) {
5300
5705
  return { success: true, durationMs: 0 };
5301
5706
  }
5302
- const registry = TokenRegistry.getInstance();
5303
- const pendingData = {
5304
- type: "v5_bundle",
5305
- stage: "RECEIVED",
5306
- bundleJson: JSON.stringify(bundle),
5307
- senderPubkey,
5308
- savedAt: Date.now(),
5309
- attemptCount: 0
5310
- };
5311
- const uiToken = {
5312
- id: deterministicId,
5313
- coinId: bundle.coinId,
5314
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5315
- name: registry.getName(bundle.coinId) || bundle.coinId,
5316
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
5317
- amount: bundle.amount,
5318
- status: "submitted",
5319
- // UNCONFIRMED
5320
- createdAt: Date.now(),
5321
- updatedAt: Date.now(),
5322
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5323
- };
5324
- await this.addToken(uiToken);
5325
- this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
5326
5707
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
5327
5708
  await this.addToHistory({
5328
5709
  type: "RECEIVED",
@@ -5333,7 +5714,7 @@ var PaymentsModule = class _PaymentsModule {
5333
5714
  senderPubkey,
5334
5715
  ...senderInfo,
5335
5716
  memo,
5336
- tokenId: deterministicId
5717
+ tokenId: uiToken.id
5337
5718
  });
5338
5719
  this.deps.emitEvent("transfer:incoming", {
5339
5720
  id: bundle.splitGroupId,
@@ -5346,6 +5727,7 @@ var PaymentsModule = class _PaymentsModule {
5346
5727
  await this.save();
5347
5728
  this.resolveUnconfirmed().catch(() => {
5348
5729
  });
5730
+ this.scheduleResolveUnconfirmed();
5349
5731
  return { success: true, durationMs: 0 };
5350
5732
  } catch (error) {
5351
5733
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -5982,16 +6364,18 @@ var PaymentsModule = class _PaymentsModule {
5982
6364
  }
5983
6365
  /**
5984
6366
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5985
- * 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").
5986
6369
  */
5987
6370
  aggregateTokens(coinId) {
5988
6371
  const assetsMap = /* @__PURE__ */ new Map();
5989
6372
  for (const token of this.tokens.values()) {
5990
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6373
+ if (token.status === "spent" || token.status === "invalid") continue;
5991
6374
  if (coinId && token.coinId !== coinId) continue;
5992
6375
  const key = token.coinId;
5993
6376
  const amount = BigInt(token.amount);
5994
6377
  const isConfirmed = token.status === "confirmed";
6378
+ const isTransferring = token.status === "transferring";
5995
6379
  const existing = assetsMap.get(key);
5996
6380
  if (existing) {
5997
6381
  if (isConfirmed) {
@@ -6001,6 +6385,7 @@ var PaymentsModule = class _PaymentsModule {
6001
6385
  existing.unconfirmedAmount += amount;
6002
6386
  existing.unconfirmedTokenCount++;
6003
6387
  }
6388
+ if (isTransferring) existing.transferringTokenCount++;
6004
6389
  } else {
6005
6390
  assetsMap.set(key, {
6006
6391
  coinId: token.coinId,
@@ -6011,7 +6396,8 @@ var PaymentsModule = class _PaymentsModule {
6011
6396
  confirmedAmount: isConfirmed ? amount : 0n,
6012
6397
  unconfirmedAmount: isConfirmed ? 0n : amount,
6013
6398
  confirmedTokenCount: isConfirmed ? 1 : 0,
6014
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6399
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6400
+ transferringTokenCount: isTransferring ? 1 : 0
6015
6401
  });
6016
6402
  }
6017
6403
  }
@@ -6029,6 +6415,7 @@ var PaymentsModule = class _PaymentsModule {
6029
6415
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
6030
6416
  confirmedTokenCount: raw.confirmedTokenCount,
6031
6417
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6418
+ transferringTokenCount: raw.transferringTokenCount,
6032
6419
  priceUsd: null,
6033
6420
  priceEur: null,
6034
6421
  change24h: null,
@@ -6092,28 +6479,70 @@ var PaymentsModule = class _PaymentsModule {
6092
6479
  };
6093
6480
  const stClient = this.deps.oracle.getStateTransitionClient?.();
6094
6481
  const trustBase = this.deps.oracle.getTrustBase?.();
6095
- if (!stClient || !trustBase) return result;
6482
+ if (!stClient || !trustBase) {
6483
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: EARLY EXIT \u2014 stClient=${!!stClient} trustBase=${!!trustBase}`);
6484
+ return result;
6485
+ }
6096
6486
  const signingService = await this.createSigningService();
6487
+ const submittedCount = Array.from(this.tokens.values()).filter((t) => t.status === "submitted").length;
6488
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: ${submittedCount} submitted token(s) to process`);
6097
6489
  for (const [tokenId, token] of this.tokens) {
6098
6490
  if (token.status !== "submitted") continue;
6099
6491
  const pending2 = this.parsePendingFinalization(token.sdkData);
6100
6492
  if (!pending2) {
6493
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 16)}: no pending finalization metadata, skipping`);
6101
6494
  result.stillPending++;
6102
6495
  continue;
6103
6496
  }
6104
6497
  if (pending2.type === "v5_bundle") {
6498
+ console.log(`[V5-RESOLVE] Processing ${tokenId.slice(0, 16)}... stage=${pending2.stage} attempt=${pending2.attemptCount}`);
6105
6499
  const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
6500
+ console.log(`[V5-RESOLVE] Result for ${tokenId.slice(0, 16)}...: ${progress} (stage now: ${pending2.stage})`);
6106
6501
  result.details.push({ tokenId, stage: pending2.stage, status: progress });
6107
6502
  if (progress === "resolved") result.resolved++;
6108
6503
  else if (progress === "failed") result.failed++;
6109
6504
  else result.stillPending++;
6110
6505
  }
6111
6506
  }
6112
- if (result.resolved > 0 || result.failed > 0) {
6507
+ if (result.resolved > 0 || result.failed > 0 || result.stillPending > 0) {
6508
+ console.log(`[V5-RESOLVE] Saving: resolved=${result.resolved} failed=${result.failed} stillPending=${result.stillPending}`);
6113
6509
  await this.save();
6114
6510
  }
6115
6511
  return result;
6116
6512
  }
6513
+ /**
6514
+ * Start a periodic interval that retries resolveUnconfirmed() until all
6515
+ * tokens are confirmed or failed. Stops automatically when nothing is
6516
+ * pending and is cleaned up by destroy().
6517
+ */
6518
+ scheduleResolveUnconfirmed() {
6519
+ if (this.resolveUnconfirmedTimer) return;
6520
+ const hasUnconfirmed = Array.from(this.tokens.values()).some(
6521
+ (t) => t.status === "submitted"
6522
+ );
6523
+ if (!hasUnconfirmed) {
6524
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: no submitted tokens, not starting timer`);
6525
+ return;
6526
+ }
6527
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: starting periodic retry (every ${_PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS}ms)`);
6528
+ this.resolveUnconfirmedTimer = setInterval(async () => {
6529
+ try {
6530
+ const result = await this.resolveUnconfirmed();
6531
+ if (result.stillPending === 0) {
6532
+ console.log(`[V5-RESOLVE] All tokens resolved, stopping periodic retry`);
6533
+ this.stopResolveUnconfirmedPolling();
6534
+ }
6535
+ } catch (err) {
6536
+ console.log(`[V5-RESOLVE] Periodic retry error:`, err);
6537
+ }
6538
+ }, _PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS);
6539
+ }
6540
+ stopResolveUnconfirmedPolling() {
6541
+ if (this.resolveUnconfirmedTimer) {
6542
+ clearInterval(this.resolveUnconfirmedTimer);
6543
+ this.resolveUnconfirmedTimer = null;
6544
+ }
6545
+ }
6117
6546
  // ===========================================================================
6118
6547
  // Private - V5 Lazy Resolution Helpers
6119
6548
  // ===========================================================================
@@ -6126,10 +6555,12 @@ var PaymentsModule = class _PaymentsModule {
6126
6555
  pending2.lastAttemptAt = Date.now();
6127
6556
  try {
6128
6557
  if (pending2.stage === "RECEIVED") {
6558
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: RECEIVED \u2192 submitting mint commitment...`);
6129
6559
  const mintDataJson = JSON.parse(bundle.recipientMintData);
6130
6560
  const mintData = await import_MintTransactionData3.MintTransactionData.fromJSON(mintDataJson);
6131
6561
  const mintCommitment = await import_MintCommitment3.MintCommitment.create(mintData);
6132
6562
  const mintResponse = await stClient.submitMintCommitment(mintCommitment);
6563
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint response status=${mintResponse.status}`);
6133
6564
  if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
6134
6565
  throw new Error(`Mint submission failed: ${mintResponse.status}`);
6135
6566
  }
@@ -6137,22 +6568,27 @@ var PaymentsModule = class _PaymentsModule {
6137
6568
  this.updatePendingFinalization(token, pending2);
6138
6569
  }
6139
6570
  if (pending2.stage === "MINT_SUBMITTED") {
6571
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_SUBMITTED \u2192 checking mint proof...`);
6140
6572
  const mintDataJson = JSON.parse(bundle.recipientMintData);
6141
6573
  const mintData = await import_MintTransactionData3.MintTransactionData.fromJSON(mintDataJson);
6142
6574
  const mintCommitment = await import_MintCommitment3.MintCommitment.create(mintData);
6143
6575
  const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
6144
6576
  if (!proof) {
6577
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof not yet available, staying MINT_SUBMITTED`);
6145
6578
  this.updatePendingFinalization(token, pending2);
6146
6579
  return "pending";
6147
6580
  }
6581
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof obtained!`);
6148
6582
  pending2.mintProofJson = JSON.stringify(proof);
6149
6583
  pending2.stage = "MINT_PROVEN";
6150
6584
  this.updatePendingFinalization(token, pending2);
6151
6585
  }
6152
6586
  if (pending2.stage === "MINT_PROVEN") {
6587
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_PROVEN \u2192 submitting transfer commitment...`);
6153
6588
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
6154
6589
  const transferCommitment = await import_TransferCommitment4.TransferCommitment.fromJSON(transferCommitmentJson);
6155
6590
  const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
6591
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer response status=${transferResponse.status}`);
6156
6592
  if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
6157
6593
  throw new Error(`Transfer submission failed: ${transferResponse.status}`);
6158
6594
  }
@@ -6160,13 +6596,16 @@ var PaymentsModule = class _PaymentsModule {
6160
6596
  this.updatePendingFinalization(token, pending2);
6161
6597
  }
6162
6598
  if (pending2.stage === "TRANSFER_SUBMITTED") {
6599
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: TRANSFER_SUBMITTED \u2192 checking transfer proof...`);
6163
6600
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
6164
6601
  const transferCommitment = await import_TransferCommitment4.TransferCommitment.fromJSON(transferCommitmentJson);
6165
6602
  const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
6166
6603
  if (!proof) {
6604
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof not yet available, staying TRANSFER_SUBMITTED`);
6167
6605
  this.updatePendingFinalization(token, pending2);
6168
6606
  return "pending";
6169
6607
  }
6608
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof obtained! Finalizing...`);
6170
6609
  const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
6171
6610
  const confirmedToken = {
6172
6611
  id: token.id,
@@ -6182,6 +6621,12 @@ var PaymentsModule = class _PaymentsModule {
6182
6621
  sdkData: JSON.stringify(finalizedToken.toJSON())
6183
6622
  };
6184
6623
  this.tokens.set(tokenId, confirmedToken);
6624
+ this.deps.emitEvent("transfer:confirmed", {
6625
+ id: crypto.randomUUID(),
6626
+ status: "completed",
6627
+ tokens: [confirmedToken],
6628
+ tokenTransfers: []
6629
+ });
6185
6630
  this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
6186
6631
  return "resolved";
6187
6632
  }
@@ -6323,11 +6768,20 @@ var PaymentsModule = class _PaymentsModule {
6323
6768
  }
6324
6769
  }
6325
6770
  if (pendingTokens.length > 0) {
6771
+ const json = JSON.stringify(pendingTokens);
6772
+ this.log(`[V5-PERSIST] Saving ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")} (${json.length} bytes)`);
6326
6773
  await this.deps.storage.set(
6327
6774
  STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
6328
- JSON.stringify(pendingTokens)
6775
+ json
6329
6776
  );
6777
+ const verify = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6778
+ if (!verify) {
6779
+ console.error("[Payments][V5-PERSIST] CRITICAL: KV write succeeded but read-back is empty!");
6780
+ } else {
6781
+ this.log(`[V5-PERSIST] Verified: read-back ${verify.length} bytes`);
6782
+ }
6330
6783
  } else {
6784
+ this.log(`[V5-PERSIST] No pending V5 tokens to save (total tokens: ${this.tokens.size}), clearing KV`);
6331
6785
  await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
6332
6786
  }
6333
6787
  }
@@ -6337,16 +6791,47 @@ var PaymentsModule = class _PaymentsModule {
6337
6791
  */
6338
6792
  async loadPendingV5Tokens() {
6339
6793
  const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6794
+ this.log(`[V5-PERSIST] loadPendingV5Tokens: KV data = ${data ? `${data.length} bytes` : "null/empty"}`);
6340
6795
  if (!data) return;
6341
6796
  try {
6342
6797
  const pendingTokens = JSON.parse(data);
6798
+ this.log(`[V5-PERSIST] Parsed ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")}`);
6343
6799
  for (const token of pendingTokens) {
6344
6800
  if (!this.tokens.has(token.id)) {
6345
6801
  this.tokens.set(token.id, token);
6802
+ this.log(`[V5-PERSIST] Restored token ${token.id.slice(0, 16)} (status=${token.status})`);
6803
+ } else {
6804
+ this.log(`[V5-PERSIST] Token ${token.id.slice(0, 16)} already in map, skipping`);
6346
6805
  }
6347
6806
  }
6348
- if (pendingTokens.length > 0) {
6349
- this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
6807
+ } catch (err) {
6808
+ console.error("[Payments][V5-PERSIST] Failed to parse pending V5 tokens:", err);
6809
+ }
6810
+ }
6811
+ /**
6812
+ * Persist the set of processed splitGroupIds to KV storage.
6813
+ * This ensures Nostr re-deliveries are ignored across page reloads,
6814
+ * even when the confirmed token's in-memory ID differs from v5split_{id}.
6815
+ */
6816
+ async saveProcessedSplitGroupIds() {
6817
+ const ids = Array.from(this.processedSplitGroupIds);
6818
+ if (ids.length > 0) {
6819
+ await this.deps.storage.set(
6820
+ STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS,
6821
+ JSON.stringify(ids)
6822
+ );
6823
+ }
6824
+ }
6825
+ /**
6826
+ * Load processed splitGroupIds from KV storage.
6827
+ */
6828
+ async loadProcessedSplitGroupIds() {
6829
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS);
6830
+ if (!data) return;
6831
+ try {
6832
+ const ids = JSON.parse(data);
6833
+ for (const id of ids) {
6834
+ this.processedSplitGroupIds.add(id);
6350
6835
  }
6351
6836
  } catch {
6352
6837
  }
@@ -7001,7 +7486,32 @@ var PaymentsModule = class _PaymentsModule {
7001
7486
  try {
7002
7487
  const result = await provider.sync(localData);
7003
7488
  if (result.success && result.merged) {
7489
+ const savedTokens = new Map(this.tokens);
7004
7490
  this.loadFromStorageData(result.merged);
7491
+ let restoredCount = 0;
7492
+ for (const [tokenId, token] of savedTokens) {
7493
+ if (this.tokens.has(tokenId)) continue;
7494
+ const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
7495
+ const stateHash = extractStateHashFromSdkData(token.sdkData);
7496
+ if (sdkTokenId && stateHash && this.isStateTombstoned(sdkTokenId, stateHash)) {
7497
+ continue;
7498
+ }
7499
+ if (sdkTokenId) {
7500
+ let hasEquivalent = false;
7501
+ for (const existing of this.tokens.values()) {
7502
+ if (extractTokenIdFromSdkData(existing.sdkData) === sdkTokenId) {
7503
+ hasEquivalent = true;
7504
+ break;
7505
+ }
7506
+ }
7507
+ if (hasEquivalent) continue;
7508
+ }
7509
+ this.tokens.set(tokenId, token);
7510
+ restoredCount++;
7511
+ }
7512
+ if (restoredCount > 0) {
7513
+ console.log(`[Payments] Sync: restored ${restoredCount} token(s) lost by loadFromStorageData`);
7514
+ }
7005
7515
  if (this.nametags.length === 0 && savedNametags.length > 0) {
7006
7516
  this.nametags = savedNametags;
7007
7517
  }
@@ -7303,7 +7813,7 @@ var PaymentsModule = class _PaymentsModule {
7303
7813
  /**
7304
7814
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7305
7815
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7306
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7816
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7307
7817
  */
7308
7818
  async handleCommitmentOnlyTransfer(transfer, payload) {
7309
7819
  try {
@@ -7313,40 +7823,22 @@ var PaymentsModule = class _PaymentsModule {
7313
7823
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7314
7824
  return;
7315
7825
  }
7316
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7317
- const token = {
7318
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7319
- coinId: tokenInfo.coinId,
7320
- symbol: tokenInfo.symbol,
7321
- name: tokenInfo.name,
7322
- decimals: tokenInfo.decimals,
7323
- iconUrl: tokenInfo.iconUrl,
7324
- amount: tokenInfo.amount,
7325
- status: "submitted",
7326
- // NOSTR-FIRST: unconfirmed until proof
7327
- createdAt: Date.now(),
7328
- updatedAt: Date.now(),
7329
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7330
- };
7331
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7332
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7333
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7334
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7335
- return;
7336
- }
7337
- this.tokens.set(token.id, token);
7338
- await this.save();
7339
- this.log(`NOSTR-FIRST: Token ${token.id.slice(0, 8)}... added as submitted (unconfirmed)`);
7826
+ const token = await this.saveCommitmentOnlyToken(
7827
+ sourceTokenInput,
7828
+ commitmentInput,
7829
+ transfer.senderTransportPubkey
7830
+ );
7831
+ if (!token) return;
7340
7832
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7341
- const incomingTransfer = {
7833
+ this.deps.emitEvent("transfer:incoming", {
7342
7834
  id: transfer.id,
7343
7835
  senderPubkey: transfer.senderTransportPubkey,
7344
7836
  senderNametag: senderInfo.senderNametag,
7345
7837
  tokens: [token],
7346
7838
  memo: payload.memo,
7347
7839
  receivedAt: transfer.timestamp
7348
- };
7349
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7840
+ });
7841
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7350
7842
  await this.addToHistory({
7351
7843
  type: "RECEIVED",
7352
7844
  amount: token.amount,
@@ -7358,29 +7850,6 @@ var PaymentsModule = class _PaymentsModule {
7358
7850
  memo: payload.memo,
7359
7851
  tokenId: nostrTokenId || token.id
7360
7852
  });
7361
- try {
7362
- const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
7363
- const requestIdBytes = commitment.requestId;
7364
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7365
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7366
- if (stClient) {
7367
- const response = await stClient.submitTransferCommitment(commitment);
7368
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7369
- }
7370
- this.addProofPollingJob({
7371
- tokenId: token.id,
7372
- requestIdHex,
7373
- commitmentJson: JSON.stringify(commitmentInput),
7374
- startedAt: Date.now(),
7375
- attemptCount: 0,
7376
- lastAttemptAt: 0,
7377
- onProofReceived: async (tokenId) => {
7378
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7379
- }
7380
- });
7381
- } catch (err) {
7382
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7383
- }
7384
7853
  } catch (error) {
7385
7854
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7386
7855
  }
@@ -7493,8 +7962,34 @@ var PaymentsModule = class _PaymentsModule {
7493
7962
  }
7494
7963
  }
7495
7964
  async handleIncomingTransfer(transfer) {
7965
+ if (!this.loaded && this.loadedPromise) {
7966
+ await this.loadedPromise;
7967
+ }
7496
7968
  try {
7497
7969
  const payload = transfer.payload;
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
+ }
7498
7993
  let instantBundle = null;
7499
7994
  if (isInstantSplitBundle(payload)) {
7500
7995
  instantBundle = payload;
@@ -7526,7 +8021,7 @@ var PaymentsModule = class _PaymentsModule {
7526
8021
  return;
7527
8022
  }
7528
8023
  if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
7529
- this.log("Processing NOSTR-FIRST commitment-only transfer...");
8024
+ console.log("[Payments][DEBUG] >>> NOSTR-FIRST commitment-only transfer detected");
7530
8025
  await this.handleCommitmentOnlyTransfer(transfer, payload);
7531
8026
  return;
7532
8027
  }
@@ -7646,17 +8141,19 @@ var PaymentsModule = class _PaymentsModule {
7646
8141
  memo: payload.memo,
7647
8142
  tokenId: incomingTokenId || token.id
7648
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}`);
7649
8156
  }
7650
- const incomingTransfer = {
7651
- id: transfer.id,
7652
- senderPubkey: transfer.senderTransportPubkey,
7653
- senderNametag: senderInfo.senderNametag,
7654
- tokens: [token],
7655
- memo: payload.memo,
7656
- receivedAt: transfer.timestamp
7657
- };
7658
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7659
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7660
8157
  } catch (error) {
7661
8158
  console.error("[Payments] Failed to process incoming transfer:", error);
7662
8159
  }
@@ -7689,17 +8186,24 @@ var PaymentsModule = class _PaymentsModule {
7689
8186
  // ===========================================================================
7690
8187
  async save() {
7691
8188
  const providers = this.getTokenStorageProviders();
7692
- if (providers.size === 0) {
7693
- this.log("No token storage providers - tokens not persisted");
7694
- return;
7695
- }
7696
- const data = await this.createStorageData();
7697
- for (const [id, provider] of providers) {
7698
- try {
7699
- await provider.save(data);
7700
- } catch (err) {
7701
- console.error(`[Payments] Failed to save to provider ${id}:`, err);
8189
+ const tokenStats = Array.from(this.tokens.values()).map((t) => {
8190
+ const txf = tokenToTxf(t);
8191
+ return `${t.id.slice(0, 12)}(${t.status},txf=${!!txf})`;
8192
+ });
8193
+ console.log(`[Payments][DEBUG] save(): providers=${providers.size}, tokens=[${tokenStats.join(", ")}]`);
8194
+ if (providers.size > 0) {
8195
+ const data = await this.createStorageData();
8196
+ const dataKeys = Object.keys(data).filter((k) => k.startsWith("token-"));
8197
+ console.log(`[Payments][DEBUG] save(): TXF keys=${dataKeys.length} (${dataKeys.join(", ")})`);
8198
+ for (const [id, provider] of providers) {
8199
+ try {
8200
+ await provider.save(data);
8201
+ } catch (err) {
8202
+ console.error(`[Payments] Failed to save to provider ${id}:`, err);
8203
+ }
7702
8204
  }
8205
+ } else {
8206
+ console.log("[Payments][DEBUG] save(): No token storage providers - TXF not persisted");
7703
8207
  }
7704
8208
  await this.savePendingV5Tokens();
7705
8209
  }
@@ -7735,6 +8239,7 @@ var PaymentsModule = class _PaymentsModule {
7735
8239
  }
7736
8240
  loadFromStorageData(data) {
7737
8241
  const parsed = parseTxfStorageData(data);
8242
+ console.log(`[Payments][DEBUG] loadFromStorageData: parsed ${parsed.tokens.length} tokens, ${parsed.tombstones.length} tombstones, errors=[${parsed.validationErrors.join("; ")}]`);
7738
8243
  this.tombstones = parsed.tombstones;
7739
8244
  this.tokens.clear();
7740
8245
  for (const token of parsed.tokens) {
@@ -16761,6 +17266,7 @@ function createPriceProvider(config) {
16761
17266
  identityFromMnemonicSync,
16762
17267
  initSphere,
16763
17268
  isArchivedKey,
17269
+ isCombinedTransferBundleV6,
16764
17270
  isForkedKey,
16765
17271
  isInstantSplitBundle,
16766
17272
  isInstantSplitBundleV4,