@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.js CHANGED
@@ -91,7 +91,11 @@ var init_constants = __esm({
91
91
  /** Group chat: members for this address */
92
92
  GROUP_CHAT_MEMBERS: "group_chat_members",
93
93
  /** Group chat: processed event IDs for deduplication */
94
- GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events"
94
+ GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
95
+ /** Processed V5 split group IDs for Nostr re-delivery dedup */
96
+ PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids",
97
+ /** Processed V6 combined transfer IDs for Nostr re-delivery dedup */
98
+ PROCESSED_COMBINED_TRANSFER_IDS: "processed_combined_transfer_ids"
95
99
  };
96
100
  STORAGE_KEYS = {
97
101
  ...STORAGE_KEYS_GLOBAL,
@@ -3579,14 +3583,149 @@ var InstantSplitExecutor = class {
3579
3583
  this.devMode = config.devMode ?? false;
3580
3584
  }
3581
3585
  /**
3582
- * Execute an instant split transfer with V5 optimized flow.
3586
+ * Build a V5 split bundle WITHOUT sending it via transport.
3583
3587
  *
3584
- * Critical path (~2.3s):
3588
+ * Steps 1-5 of the V5 flow:
3585
3589
  * 1. Create and submit burn commitment
3586
3590
  * 2. Wait for burn proof
3587
3591
  * 3. Create mint commitments with SplitMintReason
3588
3592
  * 4. Create transfer commitment (no mint proof needed)
3589
- * 5. Send bundle via transport
3593
+ * 5. Package V5 bundle
3594
+ *
3595
+ * The caller is responsible for sending the bundle and then calling
3596
+ * `startBackground()` on the result to begin mint proof + change token creation.
3597
+ */
3598
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3599
+ const splitGroupId = crypto.randomUUID();
3600
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3601
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3602
+ const coinId = new CoinId3(fromHex2(coinIdHex));
3603
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3604
+ const recipientTokenId = new TokenId3(await sha2563(seedString));
3605
+ const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3606
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3607
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3608
+ const senderAddressRef = await UnmaskedPredicateReference2.create(
3609
+ tokenToSplit.type,
3610
+ this.signingService.algorithm,
3611
+ this.signingService.publicKey,
3612
+ HashAlgorithm3.SHA256
3613
+ );
3614
+ const senderAddress = await senderAddressRef.toAddress();
3615
+ const builder = new TokenSplitBuilder2();
3616
+ const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3617
+ builder.createToken(
3618
+ recipientTokenId,
3619
+ tokenToSplit.type,
3620
+ new Uint8Array(0),
3621
+ coinDataA,
3622
+ senderAddress,
3623
+ // Mint to sender first, then transfer
3624
+ recipientSalt,
3625
+ null
3626
+ );
3627
+ const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3628
+ builder.createToken(
3629
+ senderTokenId,
3630
+ tokenToSplit.type,
3631
+ new Uint8Array(0),
3632
+ coinDataB,
3633
+ senderAddress,
3634
+ senderSalt,
3635
+ null
3636
+ );
3637
+ const split = await builder.build(tokenToSplit);
3638
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3639
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3640
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3641
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3642
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3643
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3644
+ }
3645
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3646
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3647
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3648
+ console.log(`[InstantSplit] Burn proof received`);
3649
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3650
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3651
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3652
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3653
+ const senderIdHex = toHex2(senderTokenId.bytes);
3654
+ const recipientMintCommitment = mintCommitments.find(
3655
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3656
+ );
3657
+ const senderMintCommitment = mintCommitments.find(
3658
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3659
+ );
3660
+ if (!recipientMintCommitment || !senderMintCommitment) {
3661
+ throw new Error("Failed to find expected mint commitments");
3662
+ }
3663
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3664
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3665
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3666
+ recipientMintCommitment.transactionData,
3667
+ recipientAddress,
3668
+ transferSalt,
3669
+ this.signingService
3670
+ );
3671
+ const mintedPredicate = await UnmaskedPredicate3.create(
3672
+ recipientTokenId,
3673
+ tokenToSplit.type,
3674
+ this.signingService,
3675
+ HashAlgorithm3.SHA256,
3676
+ recipientSalt
3677
+ );
3678
+ const mintedState = new TokenState3(mintedPredicate, null);
3679
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3680
+ const senderPubkey = toHex2(this.signingService.publicKey);
3681
+ let nametagTokenJson;
3682
+ const recipientAddressStr = recipientAddress.toString();
3683
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3684
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3685
+ }
3686
+ const bundle = {
3687
+ version: "5.0",
3688
+ type: "INSTANT_SPLIT",
3689
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3690
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3691
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3692
+ amount: splitAmount.toString(),
3693
+ coinId: coinIdHex,
3694
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3695
+ splitGroupId,
3696
+ senderPubkey,
3697
+ recipientSaltHex: toHex2(recipientSalt),
3698
+ transferSaltHex: toHex2(transferSalt),
3699
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3700
+ finalRecipientStateJson: "",
3701
+ // Recipient creates their own
3702
+ recipientAddressJson: recipientAddressStr,
3703
+ nametagTokenJson
3704
+ };
3705
+ return {
3706
+ bundle,
3707
+ splitGroupId,
3708
+ startBackground: async () => {
3709
+ if (!options?.skipBackground) {
3710
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3711
+ signingService: this.signingService,
3712
+ tokenType: tokenToSplit.type,
3713
+ coinId,
3714
+ senderTokenId,
3715
+ senderSalt,
3716
+ onProgress: options?.onBackgroundProgress,
3717
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3718
+ onStorageSync: options?.onStorageSync
3719
+ });
3720
+ }
3721
+ }
3722
+ };
3723
+ }
3724
+ /**
3725
+ * Execute an instant split transfer with V5 optimized flow.
3726
+ *
3727
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3728
+ * and starts background processing.
3590
3729
  *
3591
3730
  * @param tokenToSplit - The SDK token to split
3592
3731
  * @param splitAmount - Amount to send to recipient
@@ -3600,117 +3739,19 @@ var InstantSplitExecutor = class {
3600
3739
  */
3601
3740
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3602
3741
  const startTime = performance.now();
3603
- const splitGroupId = crypto.randomUUID();
3604
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3605
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3606
3742
  try {
3607
- const coinId = new CoinId3(fromHex2(coinIdHex));
3608
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3609
- const recipientTokenId = new TokenId3(await sha2563(seedString));
3610
- const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3611
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3612
- const senderSalt = await sha2563(seedString + "_sender_salt");
3613
- const senderAddressRef = await UnmaskedPredicateReference2.create(
3614
- tokenToSplit.type,
3615
- this.signingService.algorithm,
3616
- this.signingService.publicKey,
3617
- HashAlgorithm3.SHA256
3618
- );
3619
- const senderAddress = await senderAddressRef.toAddress();
3620
- const builder = new TokenSplitBuilder2();
3621
- const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3622
- builder.createToken(
3623
- recipientTokenId,
3624
- tokenToSplit.type,
3625
- new Uint8Array(0),
3626
- coinDataA,
3627
- senderAddress,
3628
- // Mint to sender first, then transfer
3629
- recipientSalt,
3630
- null
3631
- );
3632
- const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3633
- builder.createToken(
3634
- senderTokenId,
3635
- tokenToSplit.type,
3636
- new Uint8Array(0),
3637
- coinDataB,
3638
- senderAddress,
3639
- senderSalt,
3640
- null
3641
- );
3642
- const split = await builder.build(tokenToSplit);
3643
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3644
- const burnSalt = await sha2563(seedString + "_burn_salt");
3645
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3646
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3647
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3648
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3649
- }
3650
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3651
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3652
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3653
- const burnDuration = performance.now() - startTime;
3654
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3655
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3656
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3657
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3658
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3659
- const senderIdHex = toHex2(senderTokenId.bytes);
3660
- const recipientMintCommitment = mintCommitments.find(
3661
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3662
- );
3663
- const senderMintCommitment = mintCommitments.find(
3664
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3665
- );
3666
- if (!recipientMintCommitment || !senderMintCommitment) {
3667
- throw new Error("Failed to find expected mint commitments");
3668
- }
3669
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3670
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3671
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3672
- recipientMintCommitment.transactionData,
3743
+ const buildResult = await this.buildSplitBundle(
3744
+ tokenToSplit,
3745
+ splitAmount,
3746
+ remainderAmount,
3747
+ coinIdHex,
3673
3748
  recipientAddress,
3674
- transferSalt,
3675
- this.signingService
3749
+ options
3676
3750
  );
3677
- const mintedPredicate = await UnmaskedPredicate3.create(
3678
- recipientTokenId,
3679
- tokenToSplit.type,
3680
- this.signingService,
3681
- HashAlgorithm3.SHA256,
3682
- recipientSalt
3683
- );
3684
- const mintedState = new TokenState3(mintedPredicate, null);
3685
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3751
+ console.log("[InstantSplit] Sending via transport...");
3686
3752
  const senderPubkey = toHex2(this.signingService.publicKey);
3687
- let nametagTokenJson;
3688
- const recipientAddressStr = recipientAddress.toString();
3689
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3690
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3691
- }
3692
- const bundle = {
3693
- version: "5.0",
3694
- type: "INSTANT_SPLIT",
3695
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3696
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3697
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3698
- amount: splitAmount.toString(),
3699
- coinId: coinIdHex,
3700
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3701
- splitGroupId,
3702
- senderPubkey,
3703
- recipientSaltHex: toHex2(recipientSalt),
3704
- transferSaltHex: toHex2(transferSalt),
3705
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3706
- finalRecipientStateJson: "",
3707
- // Recipient creates their own
3708
- recipientAddressJson: recipientAddressStr,
3709
- nametagTokenJson
3710
- };
3711
- console.log("[InstantSplit] Step 6: Sending via transport...");
3712
3753
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3713
- token: JSON.stringify(bundle),
3754
+ token: JSON.stringify(buildResult.bundle),
3714
3755
  proof: null,
3715
3756
  // Proof is included in the bundle
3716
3757
  memo: options?.memo,
@@ -3721,25 +3762,13 @@ var InstantSplitExecutor = class {
3721
3762
  const criticalPathDuration = performance.now() - startTime;
3722
3763
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3723
3764
  options?.onNostrDelivered?.(nostrEventId);
3724
- let backgroundPromise;
3725
- if (!options?.skipBackground) {
3726
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3727
- signingService: this.signingService,
3728
- tokenType: tokenToSplit.type,
3729
- coinId,
3730
- senderTokenId,
3731
- senderSalt,
3732
- onProgress: options?.onBackgroundProgress,
3733
- onChangeTokenCreated: options?.onChangeTokenCreated,
3734
- onStorageSync: options?.onStorageSync
3735
- });
3736
- }
3765
+ const backgroundPromise = buildResult.startBackground();
3737
3766
  return {
3738
3767
  success: true,
3739
3768
  nostrEventId,
3740
- splitGroupId,
3769
+ splitGroupId: buildResult.splitGroupId,
3741
3770
  criticalPathDurationMs: criticalPathDuration,
3742
- backgroundStarted: !options?.skipBackground,
3771
+ backgroundStarted: true,
3743
3772
  backgroundPromise
3744
3773
  };
3745
3774
  } catch (error) {
@@ -3748,7 +3777,6 @@ var InstantSplitExecutor = class {
3748
3777
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3749
3778
  return {
3750
3779
  success: false,
3751
- splitGroupId,
3752
3780
  criticalPathDurationMs: duration,
3753
3781
  error: errorMessage,
3754
3782
  backgroundStarted: false
@@ -3953,6 +3981,11 @@ function isInstantSplitBundleV4(obj) {
3953
3981
  function isInstantSplitBundleV5(obj) {
3954
3982
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3955
3983
  }
3984
+ function isCombinedTransferBundleV6(obj) {
3985
+ if (typeof obj !== "object" || obj === null) return false;
3986
+ const b = obj;
3987
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
3988
+ }
3956
3989
 
3957
3990
  // modules/payments/InstantSplitProcessor.ts
3958
3991
  function fromHex3(hex) {
@@ -4595,6 +4628,19 @@ var PaymentsModule = class _PaymentsModule {
4595
4628
  // Poll every 2s
4596
4629
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
4597
4630
  // Max 30 attempts (~60s)
4631
+ // Periodic retry for resolveUnconfirmed (V5 lazy finalization)
4632
+ resolveUnconfirmedTimer = null;
4633
+ static RESOLVE_UNCONFIRMED_INTERVAL_MS = 1e4;
4634
+ // Retry every 10s
4635
+ // Guard: ensure load() completes before processing incoming bundles
4636
+ loadedPromise = null;
4637
+ loaded = false;
4638
+ // Persistent dedup: tracks splitGroupIds that have been fully processed.
4639
+ // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4640
+ // even when the confirmed token's in-memory ID differs from v5split_{id}.
4641
+ processedSplitGroupIds = /* @__PURE__ */ new Set();
4642
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4643
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4598
4644
  // Storage event subscriptions (push-based sync)
4599
4645
  storageEventUnsubscribers = [];
4600
4646
  syncDebounceTimer = null;
@@ -4680,31 +4726,53 @@ var PaymentsModule = class _PaymentsModule {
4680
4726
  */
4681
4727
  async load() {
4682
4728
  this.ensureInitialized();
4683
- await TokenRegistry.waitForReady();
4684
- const providers = this.getTokenStorageProviders();
4685
- for (const [id, provider] of providers) {
4686
- try {
4687
- const result = await provider.load();
4688
- if (result.success && result.data) {
4689
- this.loadFromStorageData(result.data);
4690
- this.log(`Loaded metadata from provider ${id}`);
4691
- break;
4729
+ const doLoad = async () => {
4730
+ await TokenRegistry.waitForReady();
4731
+ const providers = this.getTokenStorageProviders();
4732
+ for (const [id, provider] of providers) {
4733
+ try {
4734
+ const result = await provider.load();
4735
+ if (result.success && result.data) {
4736
+ this.loadFromStorageData(result.data);
4737
+ this.log(`Loaded metadata from provider ${id}`);
4738
+ break;
4739
+ }
4740
+ } catch (err) {
4741
+ console.error(`[Payments] Failed to load from provider ${id}:`, err);
4692
4742
  }
4693
- } catch (err) {
4694
- console.error(`[Payments] Failed to load from provider ${id}:`, err);
4695
4743
  }
4696
- }
4697
- await this.loadPendingV5Tokens();
4698
- await this.loadHistory();
4699
- const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4700
- if (pending2) {
4701
- const transfers = JSON.parse(pending2);
4702
- for (const transfer of transfers) {
4703
- this.pendingTransfers.set(transfer.id, transfer);
4744
+ for (const [id, token] of this.tokens) {
4745
+ try {
4746
+ if (token.sdkData) {
4747
+ const data = JSON.parse(token.sdkData);
4748
+ if (data?._placeholder) {
4749
+ this.tokens.delete(id);
4750
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4751
+ }
4752
+ }
4753
+ } catch {
4754
+ }
4704
4755
  }
4705
- }
4756
+ const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4757
+ console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4758
+ await this.loadPendingV5Tokens();
4759
+ await this.loadProcessedSplitGroupIds();
4760
+ await this.loadProcessedCombinedTransferIds();
4761
+ await this.loadHistory();
4762
+ const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4763
+ if (pending2) {
4764
+ const transfers = JSON.parse(pending2);
4765
+ for (const transfer of transfers) {
4766
+ this.pendingTransfers.set(transfer.id, transfer);
4767
+ }
4768
+ }
4769
+ this.loaded = true;
4770
+ };
4771
+ this.loadedPromise = doLoad();
4772
+ await this.loadedPromise;
4706
4773
  this.resolveUnconfirmed().catch(() => {
4707
4774
  });
4775
+ this.scheduleResolveUnconfirmed();
4708
4776
  }
4709
4777
  /**
4710
4778
  * Cleanup all subscriptions, polling jobs, and pending resolvers.
@@ -4723,6 +4791,7 @@ var PaymentsModule = class _PaymentsModule {
4723
4791
  this.paymentRequestResponseHandlers.clear();
4724
4792
  this.stopProofPolling();
4725
4793
  this.proofPollingJobs.clear();
4794
+ this.stopResolveUnconfirmedPolling();
4726
4795
  for (const [, resolver] of this.pendingResponseResolvers) {
4727
4796
  clearTimeout(resolver.timeout);
4728
4797
  resolver.reject(new Error("Module destroyed"));
@@ -4780,12 +4849,13 @@ var PaymentsModule = class _PaymentsModule {
4780
4849
  token.status = "transferring";
4781
4850
  this.tokens.set(token.id, token);
4782
4851
  }
4852
+ await this.save();
4783
4853
  await this.saveToOutbox(result, recipientPubkey);
4784
4854
  result.status = "submitted";
4785
4855
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4786
4856
  const transferMode = request.transferMode ?? "instant";
4787
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4788
- if (transferMode === "conservative") {
4857
+ if (transferMode === "conservative") {
4858
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4789
4859
  this.log("Executing conservative split...");
4790
4860
  const splitExecutor = new TokenSplitExecutor({
4791
4861
  stateTransitionClient: stClient,
@@ -4829,27 +4899,59 @@ var PaymentsModule = class _PaymentsModule {
4829
4899
  requestIdHex: splitRequestIdHex
4830
4900
  });
4831
4901
  this.log(`Conservative split transfer completed`);
4832
- } else {
4833
- this.log("Executing instant split...");
4834
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
4902
+ }
4903
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4904
+ const token = tokenWithAmount.uiToken;
4905
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4906
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4907
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4908
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4909
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4910
+ }
4911
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4912
+ const transferTx = commitment.toTransaction(inclusionProof);
4913
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4914
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4915
+ transferTx: JSON.stringify(transferTx.toJSON()),
4916
+ memo: request.memo
4917
+ });
4918
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4919
+ const requestIdBytes = commitment.requestId;
4920
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4921
+ result.tokenTransfers.push({
4922
+ sourceTokenId: token.id,
4923
+ method: "direct",
4924
+ requestIdHex
4925
+ });
4926
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
4927
+ await this.removeToken(token.id);
4928
+ }
4929
+ } else {
4930
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4931
+ const senderPubkey = this.deps.identity.chainPubkey;
4932
+ let changeTokenPlaceholderId = null;
4933
+ let builtSplit = null;
4934
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4935
+ this.log("Building instant split bundle...");
4835
4936
  const executor = new InstantSplitExecutor({
4836
4937
  stateTransitionClient: stClient,
4837
4938
  trustBase,
4838
4939
  signingService,
4839
4940
  devMode
4840
4941
  });
4841
- const instantResult = await executor.executeSplitInstant(
4942
+ builtSplit = await executor.buildSplitBundle(
4842
4943
  splitPlan.tokenToSplit.sdkToken,
4843
4944
  splitPlan.splitAmount,
4844
4945
  splitPlan.remainderAmount,
4845
4946
  splitPlan.coinId,
4846
4947
  recipientAddress,
4847
- this.deps.transport,
4848
- recipientPubkey,
4849
4948
  {
4850
4949
  memo: request.memo,
4851
4950
  onChangeTokenCreated: async (changeToken) => {
4852
4951
  const changeTokenData = changeToken.toJSON();
4952
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4953
+ this.tokens.delete(changeTokenPlaceholderId);
4954
+ }
4853
4955
  const uiToken = {
4854
4956
  id: crypto.randomUUID(),
4855
4957
  coinId: request.coinId,
@@ -4872,65 +4974,103 @@ var PaymentsModule = class _PaymentsModule {
4872
4974
  }
4873
4975
  }
4874
4976
  );
4875
- if (!instantResult.success) {
4876
- throw new Error(instantResult.error || "Instant split failed");
4877
- }
4878
- if (instantResult.backgroundPromise) {
4879
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4880
- }
4977
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
4978
+ }
4979
+ const directCommitments = await Promise.all(
4980
+ splitPlan.tokensToTransferDirectly.map(
4981
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
4982
+ )
4983
+ );
4984
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
4985
+ (tw, i) => ({
4986
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
4987
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
4988
+ amount: tw.uiToken.amount,
4989
+ coinId: tw.uiToken.coinId,
4990
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
4991
+ })
4992
+ );
4993
+ const combinedBundle = {
4994
+ version: "6.0",
4995
+ type: "COMBINED_TRANSFER",
4996
+ transferId: result.id,
4997
+ splitBundle: builtSplit?.bundle ?? null,
4998
+ directTokens: directTokenEntries,
4999
+ totalAmount: request.amount.toString(),
5000
+ coinId: request.coinId,
5001
+ senderPubkey,
5002
+ memo: request.memo
5003
+ };
5004
+ console.log(
5005
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
5006
+ );
5007
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
5008
+ token: JSON.stringify(combinedBundle),
5009
+ proof: null,
5010
+ memo: request.memo,
5011
+ sender: { transportPubkey: senderPubkey }
5012
+ });
5013
+ console.log(`[Payments] V6 combined bundle sent successfully`);
5014
+ if (builtSplit) {
5015
+ const bgPromise = builtSplit.startBackground();
5016
+ this.pendingBackgroundTasks.push(bgPromise);
5017
+ }
5018
+ if (builtSplit && splitPlan.remainderAmount) {
5019
+ changeTokenPlaceholderId = crypto.randomUUID();
5020
+ const placeholder = {
5021
+ id: changeTokenPlaceholderId,
5022
+ coinId: request.coinId,
5023
+ symbol: this.getCoinSymbol(request.coinId),
5024
+ name: this.getCoinName(request.coinId),
5025
+ decimals: this.getCoinDecimals(request.coinId),
5026
+ iconUrl: this.getCoinIconUrl(request.coinId),
5027
+ amount: splitPlan.remainderAmount.toString(),
5028
+ status: "transferring",
5029
+ createdAt: Date.now(),
5030
+ updatedAt: Date.now(),
5031
+ sdkData: JSON.stringify({ _placeholder: true })
5032
+ };
5033
+ this.tokens.set(placeholder.id, placeholder);
5034
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
5035
+ }
5036
+ for (const commitment of directCommitments) {
5037
+ stClient.submitTransferCommitment(commitment).catch(
5038
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
5039
+ );
5040
+ }
5041
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4881
5042
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4882
5043
  result.tokenTransfers.push({
4883
5044
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4884
5045
  method: "split",
4885
- splitGroupId: instantResult.splitGroupId,
4886
- nostrEventId: instantResult.nostrEventId
5046
+ splitGroupId: builtSplit.splitGroupId
4887
5047
  });
4888
- this.log(`Instant split transfer completed`);
4889
5048
  }
4890
- }
4891
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4892
- const token = tokenWithAmount.uiToken;
4893
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4894
- if (transferMode === "conservative") {
4895
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4896
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4897
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4898
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4899
- }
4900
- const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4901
- const transferTx = commitment.toTransaction(inclusionProof);
4902
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4903
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4904
- transferTx: JSON.stringify(transferTx.toJSON()),
4905
- memo: request.memo
4906
- });
4907
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4908
- } else {
4909
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4910
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4911
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4912
- commitmentData: JSON.stringify(commitment.toJSON()),
4913
- memo: request.memo
5049
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
5050
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
5051
+ const commitment = directCommitments[i];
5052
+ const requestIdBytes = commitment.requestId;
5053
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5054
+ result.tokenTransfers.push({
5055
+ sourceTokenId: token.id,
5056
+ method: "direct",
5057
+ requestIdHex
4914
5058
  });
4915
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4916
- stClient.submitTransferCommitment(commitment).catch(
4917
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4918
- );
5059
+ await this.removeToken(token.id);
4919
5060
  }
4920
- const requestIdBytes = commitment.requestId;
4921
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4922
- result.tokenTransfers.push({
4923
- sourceTokenId: token.id,
4924
- method: "direct",
4925
- requestIdHex
4926
- });
4927
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4928
- await this.removeToken(token.id);
5061
+ this.log(`V6 combined transfer completed`);
4929
5062
  }
4930
5063
  result.status = "delivered";
4931
5064
  await this.save();
4932
5065
  await this.removeFromOutbox(result.id);
4933
5066
  result.status = "completed";
5067
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
5068
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
5069
+ id: tt.sourceTokenId,
5070
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
5071
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
5072
+ source: tt.method === "split" ? "split" : "direct"
5073
+ }));
4934
5074
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4935
5075
  await this.addToHistory({
4936
5076
  type: "SENT",
@@ -4943,7 +5083,8 @@ var PaymentsModule = class _PaymentsModule {
4943
5083
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4944
5084
  memo: request.memo,
4945
5085
  transferId: result.id,
4946
- tokenId: sentTokenId || void 0
5086
+ tokenId: sentTokenId || void 0,
5087
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4947
5088
  });
4948
5089
  this.deps.emitEvent("transfer:confirmed", result);
4949
5090
  return result;
@@ -5113,6 +5254,267 @@ var PaymentsModule = class _PaymentsModule {
5113
5254
  };
5114
5255
  }
5115
5256
  }
5257
+ // ===========================================================================
5258
+ // Shared Helpers for V5 and V6 Receiver Processing
5259
+ // ===========================================================================
5260
+ /**
5261
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
5262
+ * Returns the created UI token, or null if deduped.
5263
+ *
5264
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
5265
+ * The token is still added to the in-memory map for dedup; caller must call save().
5266
+ */
5267
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
5268
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
5269
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5270
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5271
+ return null;
5272
+ }
5273
+ const registry = TokenRegistry.getInstance();
5274
+ const pendingData = {
5275
+ type: "v5_bundle",
5276
+ stage: "RECEIVED",
5277
+ bundleJson: JSON.stringify(bundle),
5278
+ senderPubkey,
5279
+ savedAt: Date.now(),
5280
+ attemptCount: 0
5281
+ };
5282
+ const uiToken = {
5283
+ id: deterministicId,
5284
+ coinId: bundle.coinId,
5285
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5286
+ name: registry.getName(bundle.coinId) || bundle.coinId,
5287
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
5288
+ amount: bundle.amount,
5289
+ status: "submitted",
5290
+ // UNCONFIRMED
5291
+ createdAt: Date.now(),
5292
+ updatedAt: Date.now(),
5293
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5294
+ };
5295
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5296
+ if (deferPersistence) {
5297
+ this.tokens.set(uiToken.id, uiToken);
5298
+ } else {
5299
+ await this.addToken(uiToken);
5300
+ await this.saveProcessedSplitGroupIds();
5301
+ }
5302
+ return uiToken;
5303
+ }
5304
+ /**
5305
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5306
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5307
+ * Returns the created UI token, or null if deduped/tombstoned.
5308
+ *
5309
+ * @param deferPersistence - If true, skip save() and commitment submission
5310
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5311
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5312
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5313
+ */
5314
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5315
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5316
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5317
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5318
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5319
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5320
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5321
+ return null;
5322
+ }
5323
+ if (nostrTokenId) {
5324
+ for (const existing of this.tokens.values()) {
5325
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5326
+ if (existingTokenId !== nostrTokenId) continue;
5327
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5328
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5329
+ console.log(
5330
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5331
+ );
5332
+ return null;
5333
+ }
5334
+ if (!skipGenesisDedup) {
5335
+ console.log(
5336
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5337
+ );
5338
+ return null;
5339
+ }
5340
+ }
5341
+ }
5342
+ const token = {
5343
+ id: crypto.randomUUID(),
5344
+ coinId: tokenInfo.coinId,
5345
+ symbol: tokenInfo.symbol,
5346
+ name: tokenInfo.name,
5347
+ decimals: tokenInfo.decimals,
5348
+ iconUrl: tokenInfo.iconUrl,
5349
+ amount: tokenInfo.amount,
5350
+ status: "submitted",
5351
+ // NOSTR-FIRST: unconfirmed until proof
5352
+ createdAt: Date.now(),
5353
+ updatedAt: Date.now(),
5354
+ sdkData
5355
+ };
5356
+ this.tokens.set(token.id, token);
5357
+ if (!deferPersistence) {
5358
+ await this.save();
5359
+ }
5360
+ try {
5361
+ const commitment = await TransferCommitment4.fromJSON(commitmentInput);
5362
+ const requestIdBytes = commitment.requestId;
5363
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5364
+ if (!deferPersistence) {
5365
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5366
+ if (stClient) {
5367
+ const response = await stClient.submitTransferCommitment(commitment);
5368
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5369
+ }
5370
+ }
5371
+ this.addProofPollingJob({
5372
+ tokenId: token.id,
5373
+ requestIdHex,
5374
+ commitmentJson: JSON.stringify(commitmentInput),
5375
+ startedAt: Date.now(),
5376
+ attemptCount: 0,
5377
+ lastAttemptAt: 0,
5378
+ onProofReceived: async (tokenId) => {
5379
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5380
+ }
5381
+ });
5382
+ } catch (err) {
5383
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5384
+ }
5385
+ return token;
5386
+ }
5387
+ // ===========================================================================
5388
+ // Combined Transfer V6 — Receiver
5389
+ // ===========================================================================
5390
+ /**
5391
+ * Process a received COMBINED_TRANSFER V6 bundle.
5392
+ *
5393
+ * Unpacks a single Nostr message into its component tokens:
5394
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5395
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5396
+ *
5397
+ * Emits ONE transfer:incoming event and records ONE history entry.
5398
+ */
5399
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5400
+ this.ensureInitialized();
5401
+ if (!this.loaded && this.loadedPromise) {
5402
+ await this.loadedPromise;
5403
+ }
5404
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5405
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5406
+ return;
5407
+ }
5408
+ console.log(
5409
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5410
+ );
5411
+ const allTokens = [];
5412
+ const tokenBreakdown = [];
5413
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5414
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5415
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5416
+ }));
5417
+ if (bundle.splitBundle) {
5418
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5419
+ if (splitToken) {
5420
+ allTokens.push(splitToken);
5421
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5422
+ } else {
5423
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5424
+ }
5425
+ }
5426
+ const directResults = await Promise.all(
5427
+ parsedDirectEntries.map(
5428
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5429
+ )
5430
+ );
5431
+ for (let i = 0; i < directResults.length; i++) {
5432
+ const token = directResults[i];
5433
+ if (token) {
5434
+ allTokens.push(token);
5435
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5436
+ } else {
5437
+ const entry = bundle.directTokens[i];
5438
+ console.warn(
5439
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5440
+ );
5441
+ }
5442
+ }
5443
+ if (allTokens.length === 0) {
5444
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5445
+ return;
5446
+ }
5447
+ this.processedCombinedTransferIds.add(bundle.transferId);
5448
+ const [senderInfo] = await Promise.all([
5449
+ this.resolveSenderInfo(senderPubkey),
5450
+ this.save(),
5451
+ this.saveProcessedCombinedTransferIds(),
5452
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5453
+ ]);
5454
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5455
+ if (stClient) {
5456
+ for (const { commitment } of parsedDirectEntries) {
5457
+ TransferCommitment4.fromJSON(commitment).then(
5458
+ (c) => stClient.submitTransferCommitment(c)
5459
+ ).catch(
5460
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5461
+ );
5462
+ }
5463
+ }
5464
+ this.deps.emitEvent("transfer:incoming", {
5465
+ id: bundle.transferId,
5466
+ senderPubkey,
5467
+ senderNametag: senderInfo.senderNametag,
5468
+ tokens: allTokens,
5469
+ memo: bundle.memo,
5470
+ receivedAt: Date.now()
5471
+ });
5472
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5473
+ await this.addToHistory({
5474
+ type: "RECEIVED",
5475
+ amount: actualAmount,
5476
+ coinId: bundle.coinId,
5477
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5478
+ timestamp: Date.now(),
5479
+ senderPubkey,
5480
+ ...senderInfo,
5481
+ memo: bundle.memo,
5482
+ transferId: bundle.transferId,
5483
+ tokenId: allTokens[0]?.id,
5484
+ tokenIds: tokenBreakdown
5485
+ });
5486
+ if (bundle.splitBundle) {
5487
+ this.resolveUnconfirmed().catch(() => {
5488
+ });
5489
+ this.scheduleResolveUnconfirmed();
5490
+ }
5491
+ }
5492
+ /**
5493
+ * Persist processed combined transfer IDs to KV storage.
5494
+ */
5495
+ async saveProcessedCombinedTransferIds() {
5496
+ const ids = Array.from(this.processedCombinedTransferIds);
5497
+ if (ids.length > 0) {
5498
+ await this.deps.storage.set(
5499
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5500
+ JSON.stringify(ids)
5501
+ );
5502
+ }
5503
+ }
5504
+ /**
5505
+ * Load processed combined transfer IDs from KV storage.
5506
+ */
5507
+ async loadProcessedCombinedTransferIds() {
5508
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5509
+ if (!data) return;
5510
+ try {
5511
+ const ids = JSON.parse(data);
5512
+ for (const id of ids) {
5513
+ this.processedCombinedTransferIds.add(id);
5514
+ }
5515
+ } catch {
5516
+ }
5517
+ }
5116
5518
  /**
5117
5519
  * Process a received INSTANT_SPLIT bundle.
5118
5520
  *
@@ -5129,39 +5531,17 @@ var PaymentsModule = class _PaymentsModule {
5129
5531
  */
5130
5532
  async processInstantSplitBundle(bundle, senderPubkey, memo) {
5131
5533
  this.ensureInitialized();
5534
+ if (!this.loaded && this.loadedPromise) {
5535
+ await this.loadedPromise;
5536
+ }
5132
5537
  if (!isInstantSplitBundleV5(bundle)) {
5133
5538
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
5134
5539
  }
5135
5540
  try {
5136
- const deterministicId = `v5split_${bundle.splitGroupId}`;
5137
- if (this.tokens.has(deterministicId)) {
5138
- this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
5541
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5542
+ if (!uiToken) {
5139
5543
  return { success: true, durationMs: 0 };
5140
5544
  }
5141
- const registry = TokenRegistry.getInstance();
5142
- const pendingData = {
5143
- type: "v5_bundle",
5144
- stage: "RECEIVED",
5145
- bundleJson: JSON.stringify(bundle),
5146
- senderPubkey,
5147
- savedAt: Date.now(),
5148
- attemptCount: 0
5149
- };
5150
- const uiToken = {
5151
- id: deterministicId,
5152
- coinId: bundle.coinId,
5153
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5154
- name: registry.getName(bundle.coinId) || bundle.coinId,
5155
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
5156
- amount: bundle.amount,
5157
- status: "submitted",
5158
- // UNCONFIRMED
5159
- createdAt: Date.now(),
5160
- updatedAt: Date.now(),
5161
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5162
- };
5163
- await this.addToken(uiToken);
5164
- this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
5165
5545
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
5166
5546
  await this.addToHistory({
5167
5547
  type: "RECEIVED",
@@ -5172,7 +5552,7 @@ var PaymentsModule = class _PaymentsModule {
5172
5552
  senderPubkey,
5173
5553
  ...senderInfo,
5174
5554
  memo,
5175
- tokenId: deterministicId
5555
+ tokenId: uiToken.id
5176
5556
  });
5177
5557
  this.deps.emitEvent("transfer:incoming", {
5178
5558
  id: bundle.splitGroupId,
@@ -5185,6 +5565,7 @@ var PaymentsModule = class _PaymentsModule {
5185
5565
  await this.save();
5186
5566
  this.resolveUnconfirmed().catch(() => {
5187
5567
  });
5568
+ this.scheduleResolveUnconfirmed();
5188
5569
  return { success: true, durationMs: 0 };
5189
5570
  } catch (error) {
5190
5571
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -5821,16 +6202,18 @@ var PaymentsModule = class _PaymentsModule {
5821
6202
  }
5822
6203
  /**
5823
6204
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5824
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
6205
+ * Excludes tokens with status 'spent' or 'invalid'.
6206
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
5825
6207
  */
5826
6208
  aggregateTokens(coinId) {
5827
6209
  const assetsMap = /* @__PURE__ */ new Map();
5828
6210
  for (const token of this.tokens.values()) {
5829
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6211
+ if (token.status === "spent" || token.status === "invalid") continue;
5830
6212
  if (coinId && token.coinId !== coinId) continue;
5831
6213
  const key = token.coinId;
5832
6214
  const amount = BigInt(token.amount);
5833
6215
  const isConfirmed = token.status === "confirmed";
6216
+ const isTransferring = token.status === "transferring";
5834
6217
  const existing = assetsMap.get(key);
5835
6218
  if (existing) {
5836
6219
  if (isConfirmed) {
@@ -5840,6 +6223,7 @@ var PaymentsModule = class _PaymentsModule {
5840
6223
  existing.unconfirmedAmount += amount;
5841
6224
  existing.unconfirmedTokenCount++;
5842
6225
  }
6226
+ if (isTransferring) existing.transferringTokenCount++;
5843
6227
  } else {
5844
6228
  assetsMap.set(key, {
5845
6229
  coinId: token.coinId,
@@ -5850,7 +6234,8 @@ var PaymentsModule = class _PaymentsModule {
5850
6234
  confirmedAmount: isConfirmed ? amount : 0n,
5851
6235
  unconfirmedAmount: isConfirmed ? 0n : amount,
5852
6236
  confirmedTokenCount: isConfirmed ? 1 : 0,
5853
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6237
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6238
+ transferringTokenCount: isTransferring ? 1 : 0
5854
6239
  });
5855
6240
  }
5856
6241
  }
@@ -5868,6 +6253,7 @@ var PaymentsModule = class _PaymentsModule {
5868
6253
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5869
6254
  confirmedTokenCount: raw.confirmedTokenCount,
5870
6255
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6256
+ transferringTokenCount: raw.transferringTokenCount,
5871
6257
  priceUsd: null,
5872
6258
  priceEur: null,
5873
6259
  change24h: null,
@@ -5931,28 +6317,70 @@ var PaymentsModule = class _PaymentsModule {
5931
6317
  };
5932
6318
  const stClient = this.deps.oracle.getStateTransitionClient?.();
5933
6319
  const trustBase = this.deps.oracle.getTrustBase?.();
5934
- if (!stClient || !trustBase) return result;
6320
+ if (!stClient || !trustBase) {
6321
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: EARLY EXIT \u2014 stClient=${!!stClient} trustBase=${!!trustBase}`);
6322
+ return result;
6323
+ }
5935
6324
  const signingService = await this.createSigningService();
6325
+ const submittedCount = Array.from(this.tokens.values()).filter((t) => t.status === "submitted").length;
6326
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: ${submittedCount} submitted token(s) to process`);
5936
6327
  for (const [tokenId, token] of this.tokens) {
5937
6328
  if (token.status !== "submitted") continue;
5938
6329
  const pending2 = this.parsePendingFinalization(token.sdkData);
5939
6330
  if (!pending2) {
6331
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 16)}: no pending finalization metadata, skipping`);
5940
6332
  result.stillPending++;
5941
6333
  continue;
5942
6334
  }
5943
6335
  if (pending2.type === "v5_bundle") {
6336
+ console.log(`[V5-RESOLVE] Processing ${tokenId.slice(0, 16)}... stage=${pending2.stage} attempt=${pending2.attemptCount}`);
5944
6337
  const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
6338
+ console.log(`[V5-RESOLVE] Result for ${tokenId.slice(0, 16)}...: ${progress} (stage now: ${pending2.stage})`);
5945
6339
  result.details.push({ tokenId, stage: pending2.stage, status: progress });
5946
6340
  if (progress === "resolved") result.resolved++;
5947
6341
  else if (progress === "failed") result.failed++;
5948
6342
  else result.stillPending++;
5949
6343
  }
5950
6344
  }
5951
- if (result.resolved > 0 || result.failed > 0) {
6345
+ if (result.resolved > 0 || result.failed > 0 || result.stillPending > 0) {
6346
+ console.log(`[V5-RESOLVE] Saving: resolved=${result.resolved} failed=${result.failed} stillPending=${result.stillPending}`);
5952
6347
  await this.save();
5953
6348
  }
5954
6349
  return result;
5955
6350
  }
6351
+ /**
6352
+ * Start a periodic interval that retries resolveUnconfirmed() until all
6353
+ * tokens are confirmed or failed. Stops automatically when nothing is
6354
+ * pending and is cleaned up by destroy().
6355
+ */
6356
+ scheduleResolveUnconfirmed() {
6357
+ if (this.resolveUnconfirmedTimer) return;
6358
+ const hasUnconfirmed = Array.from(this.tokens.values()).some(
6359
+ (t) => t.status === "submitted"
6360
+ );
6361
+ if (!hasUnconfirmed) {
6362
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: no submitted tokens, not starting timer`);
6363
+ return;
6364
+ }
6365
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: starting periodic retry (every ${_PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS}ms)`);
6366
+ this.resolveUnconfirmedTimer = setInterval(async () => {
6367
+ try {
6368
+ const result = await this.resolveUnconfirmed();
6369
+ if (result.stillPending === 0) {
6370
+ console.log(`[V5-RESOLVE] All tokens resolved, stopping periodic retry`);
6371
+ this.stopResolveUnconfirmedPolling();
6372
+ }
6373
+ } catch (err) {
6374
+ console.log(`[V5-RESOLVE] Periodic retry error:`, err);
6375
+ }
6376
+ }, _PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS);
6377
+ }
6378
+ stopResolveUnconfirmedPolling() {
6379
+ if (this.resolveUnconfirmedTimer) {
6380
+ clearInterval(this.resolveUnconfirmedTimer);
6381
+ this.resolveUnconfirmedTimer = null;
6382
+ }
6383
+ }
5956
6384
  // ===========================================================================
5957
6385
  // Private - V5 Lazy Resolution Helpers
5958
6386
  // ===========================================================================
@@ -5965,10 +6393,12 @@ var PaymentsModule = class _PaymentsModule {
5965
6393
  pending2.lastAttemptAt = Date.now();
5966
6394
  try {
5967
6395
  if (pending2.stage === "RECEIVED") {
6396
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: RECEIVED \u2192 submitting mint commitment...`);
5968
6397
  const mintDataJson = JSON.parse(bundle.recipientMintData);
5969
6398
  const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5970
6399
  const mintCommitment = await MintCommitment3.create(mintData);
5971
6400
  const mintResponse = await stClient.submitMintCommitment(mintCommitment);
6401
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint response status=${mintResponse.status}`);
5972
6402
  if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
5973
6403
  throw new Error(`Mint submission failed: ${mintResponse.status}`);
5974
6404
  }
@@ -5976,22 +6406,27 @@ var PaymentsModule = class _PaymentsModule {
5976
6406
  this.updatePendingFinalization(token, pending2);
5977
6407
  }
5978
6408
  if (pending2.stage === "MINT_SUBMITTED") {
6409
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_SUBMITTED \u2192 checking mint proof...`);
5979
6410
  const mintDataJson = JSON.parse(bundle.recipientMintData);
5980
6411
  const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5981
6412
  const mintCommitment = await MintCommitment3.create(mintData);
5982
6413
  const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
5983
6414
  if (!proof) {
6415
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof not yet available, staying MINT_SUBMITTED`);
5984
6416
  this.updatePendingFinalization(token, pending2);
5985
6417
  return "pending";
5986
6418
  }
6419
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof obtained!`);
5987
6420
  pending2.mintProofJson = JSON.stringify(proof);
5988
6421
  pending2.stage = "MINT_PROVEN";
5989
6422
  this.updatePendingFinalization(token, pending2);
5990
6423
  }
5991
6424
  if (pending2.stage === "MINT_PROVEN") {
6425
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_PROVEN \u2192 submitting transfer commitment...`);
5992
6426
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5993
6427
  const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5994
6428
  const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
6429
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer response status=${transferResponse.status}`);
5995
6430
  if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
5996
6431
  throw new Error(`Transfer submission failed: ${transferResponse.status}`);
5997
6432
  }
@@ -5999,13 +6434,16 @@ var PaymentsModule = class _PaymentsModule {
5999
6434
  this.updatePendingFinalization(token, pending2);
6000
6435
  }
6001
6436
  if (pending2.stage === "TRANSFER_SUBMITTED") {
6437
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: TRANSFER_SUBMITTED \u2192 checking transfer proof...`);
6002
6438
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
6003
6439
  const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
6004
6440
  const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
6005
6441
  if (!proof) {
6442
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof not yet available, staying TRANSFER_SUBMITTED`);
6006
6443
  this.updatePendingFinalization(token, pending2);
6007
6444
  return "pending";
6008
6445
  }
6446
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof obtained! Finalizing...`);
6009
6447
  const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
6010
6448
  const confirmedToken = {
6011
6449
  id: token.id,
@@ -6021,6 +6459,12 @@ var PaymentsModule = class _PaymentsModule {
6021
6459
  sdkData: JSON.stringify(finalizedToken.toJSON())
6022
6460
  };
6023
6461
  this.tokens.set(tokenId, confirmedToken);
6462
+ this.deps.emitEvent("transfer:confirmed", {
6463
+ id: crypto.randomUUID(),
6464
+ status: "completed",
6465
+ tokens: [confirmedToken],
6466
+ tokenTransfers: []
6467
+ });
6024
6468
  this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
6025
6469
  return "resolved";
6026
6470
  }
@@ -6162,11 +6606,20 @@ var PaymentsModule = class _PaymentsModule {
6162
6606
  }
6163
6607
  }
6164
6608
  if (pendingTokens.length > 0) {
6609
+ const json = JSON.stringify(pendingTokens);
6610
+ this.log(`[V5-PERSIST] Saving ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")} (${json.length} bytes)`);
6165
6611
  await this.deps.storage.set(
6166
6612
  STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
6167
- JSON.stringify(pendingTokens)
6613
+ json
6168
6614
  );
6615
+ const verify = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6616
+ if (!verify) {
6617
+ console.error("[Payments][V5-PERSIST] CRITICAL: KV write succeeded but read-back is empty!");
6618
+ } else {
6619
+ this.log(`[V5-PERSIST] Verified: read-back ${verify.length} bytes`);
6620
+ }
6169
6621
  } else {
6622
+ this.log(`[V5-PERSIST] No pending V5 tokens to save (total tokens: ${this.tokens.size}), clearing KV`);
6170
6623
  await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
6171
6624
  }
6172
6625
  }
@@ -6176,16 +6629,47 @@ var PaymentsModule = class _PaymentsModule {
6176
6629
  */
6177
6630
  async loadPendingV5Tokens() {
6178
6631
  const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6632
+ this.log(`[V5-PERSIST] loadPendingV5Tokens: KV data = ${data ? `${data.length} bytes` : "null/empty"}`);
6179
6633
  if (!data) return;
6180
6634
  try {
6181
6635
  const pendingTokens = JSON.parse(data);
6636
+ this.log(`[V5-PERSIST] Parsed ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")}`);
6182
6637
  for (const token of pendingTokens) {
6183
6638
  if (!this.tokens.has(token.id)) {
6184
6639
  this.tokens.set(token.id, token);
6640
+ this.log(`[V5-PERSIST] Restored token ${token.id.slice(0, 16)} (status=${token.status})`);
6641
+ } else {
6642
+ this.log(`[V5-PERSIST] Token ${token.id.slice(0, 16)} already in map, skipping`);
6185
6643
  }
6186
6644
  }
6187
- if (pendingTokens.length > 0) {
6188
- this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
6645
+ } catch (err) {
6646
+ console.error("[Payments][V5-PERSIST] Failed to parse pending V5 tokens:", err);
6647
+ }
6648
+ }
6649
+ /**
6650
+ * Persist the set of processed splitGroupIds to KV storage.
6651
+ * This ensures Nostr re-deliveries are ignored across page reloads,
6652
+ * even when the confirmed token's in-memory ID differs from v5split_{id}.
6653
+ */
6654
+ async saveProcessedSplitGroupIds() {
6655
+ const ids = Array.from(this.processedSplitGroupIds);
6656
+ if (ids.length > 0) {
6657
+ await this.deps.storage.set(
6658
+ STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS,
6659
+ JSON.stringify(ids)
6660
+ );
6661
+ }
6662
+ }
6663
+ /**
6664
+ * Load processed splitGroupIds from KV storage.
6665
+ */
6666
+ async loadProcessedSplitGroupIds() {
6667
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS);
6668
+ if (!data) return;
6669
+ try {
6670
+ const ids = JSON.parse(data);
6671
+ for (const id of ids) {
6672
+ this.processedSplitGroupIds.add(id);
6189
6673
  }
6190
6674
  } catch {
6191
6675
  }
@@ -6840,7 +7324,32 @@ var PaymentsModule = class _PaymentsModule {
6840
7324
  try {
6841
7325
  const result = await provider.sync(localData);
6842
7326
  if (result.success && result.merged) {
7327
+ const savedTokens = new Map(this.tokens);
6843
7328
  this.loadFromStorageData(result.merged);
7329
+ let restoredCount = 0;
7330
+ for (const [tokenId, token] of savedTokens) {
7331
+ if (this.tokens.has(tokenId)) continue;
7332
+ const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
7333
+ const stateHash = extractStateHashFromSdkData(token.sdkData);
7334
+ if (sdkTokenId && stateHash && this.isStateTombstoned(sdkTokenId, stateHash)) {
7335
+ continue;
7336
+ }
7337
+ if (sdkTokenId) {
7338
+ let hasEquivalent = false;
7339
+ for (const existing of this.tokens.values()) {
7340
+ if (extractTokenIdFromSdkData(existing.sdkData) === sdkTokenId) {
7341
+ hasEquivalent = true;
7342
+ break;
7343
+ }
7344
+ }
7345
+ if (hasEquivalent) continue;
7346
+ }
7347
+ this.tokens.set(tokenId, token);
7348
+ restoredCount++;
7349
+ }
7350
+ if (restoredCount > 0) {
7351
+ console.log(`[Payments] Sync: restored ${restoredCount} token(s) lost by loadFromStorageData`);
7352
+ }
6844
7353
  if (this.nametags.length === 0 && savedNametags.length > 0) {
6845
7354
  this.nametags = savedNametags;
6846
7355
  }
@@ -7142,7 +7651,7 @@ var PaymentsModule = class _PaymentsModule {
7142
7651
  /**
7143
7652
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7144
7653
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7145
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7654
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7146
7655
  */
7147
7656
  async handleCommitmentOnlyTransfer(transfer, payload) {
7148
7657
  try {
@@ -7152,40 +7661,22 @@ var PaymentsModule = class _PaymentsModule {
7152
7661
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7153
7662
  return;
7154
7663
  }
7155
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7156
- const token = {
7157
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7158
- coinId: tokenInfo.coinId,
7159
- symbol: tokenInfo.symbol,
7160
- name: tokenInfo.name,
7161
- decimals: tokenInfo.decimals,
7162
- iconUrl: tokenInfo.iconUrl,
7163
- amount: tokenInfo.amount,
7164
- status: "submitted",
7165
- // NOSTR-FIRST: unconfirmed until proof
7166
- createdAt: Date.now(),
7167
- updatedAt: Date.now(),
7168
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7169
- };
7170
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7171
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7172
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7173
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7174
- return;
7175
- }
7176
- this.tokens.set(token.id, token);
7177
- await this.save();
7178
- this.log(`NOSTR-FIRST: Token ${token.id.slice(0, 8)}... added as submitted (unconfirmed)`);
7664
+ const token = await this.saveCommitmentOnlyToken(
7665
+ sourceTokenInput,
7666
+ commitmentInput,
7667
+ transfer.senderTransportPubkey
7668
+ );
7669
+ if (!token) return;
7179
7670
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7180
- const incomingTransfer = {
7671
+ this.deps.emitEvent("transfer:incoming", {
7181
7672
  id: transfer.id,
7182
7673
  senderPubkey: transfer.senderTransportPubkey,
7183
7674
  senderNametag: senderInfo.senderNametag,
7184
7675
  tokens: [token],
7185
7676
  memo: payload.memo,
7186
7677
  receivedAt: transfer.timestamp
7187
- };
7188
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7678
+ });
7679
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7189
7680
  await this.addToHistory({
7190
7681
  type: "RECEIVED",
7191
7682
  amount: token.amount,
@@ -7197,29 +7688,6 @@ var PaymentsModule = class _PaymentsModule {
7197
7688
  memo: payload.memo,
7198
7689
  tokenId: nostrTokenId || token.id
7199
7690
  });
7200
- try {
7201
- const commitment = await TransferCommitment4.fromJSON(commitmentInput);
7202
- const requestIdBytes = commitment.requestId;
7203
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7204
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7205
- if (stClient) {
7206
- const response = await stClient.submitTransferCommitment(commitment);
7207
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7208
- }
7209
- this.addProofPollingJob({
7210
- tokenId: token.id,
7211
- requestIdHex,
7212
- commitmentJson: JSON.stringify(commitmentInput),
7213
- startedAt: Date.now(),
7214
- attemptCount: 0,
7215
- lastAttemptAt: 0,
7216
- onProofReceived: async (tokenId) => {
7217
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7218
- }
7219
- });
7220
- } catch (err) {
7221
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7222
- }
7223
7691
  } catch (error) {
7224
7692
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7225
7693
  }
@@ -7332,8 +7800,34 @@ var PaymentsModule = class _PaymentsModule {
7332
7800
  }
7333
7801
  }
7334
7802
  async handleIncomingTransfer(transfer) {
7803
+ if (!this.loaded && this.loadedPromise) {
7804
+ await this.loadedPromise;
7805
+ }
7335
7806
  try {
7336
7807
  const payload = transfer.payload;
7808
+ console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
7809
+ let combinedBundle = null;
7810
+ if (isCombinedTransferBundleV6(payload)) {
7811
+ combinedBundle = payload;
7812
+ } else if (payload.token) {
7813
+ try {
7814
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7815
+ if (isCombinedTransferBundleV6(inner)) {
7816
+ combinedBundle = inner;
7817
+ }
7818
+ } catch {
7819
+ }
7820
+ }
7821
+ if (combinedBundle) {
7822
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
7823
+ try {
7824
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
7825
+ this.log("COMBINED_TRANSFER V6 processed successfully");
7826
+ } catch (err) {
7827
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
7828
+ }
7829
+ return;
7830
+ }
7337
7831
  let instantBundle = null;
7338
7832
  if (isInstantSplitBundle(payload)) {
7339
7833
  instantBundle = payload;
@@ -7365,7 +7859,7 @@ var PaymentsModule = class _PaymentsModule {
7365
7859
  return;
7366
7860
  }
7367
7861
  if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
7368
- this.log("Processing NOSTR-FIRST commitment-only transfer...");
7862
+ console.log("[Payments][DEBUG] >>> NOSTR-FIRST commitment-only transfer detected");
7369
7863
  await this.handleCommitmentOnlyTransfer(transfer, payload);
7370
7864
  return;
7371
7865
  }
@@ -7485,17 +7979,19 @@ var PaymentsModule = class _PaymentsModule {
7485
7979
  memo: payload.memo,
7486
7980
  tokenId: incomingTokenId || token.id
7487
7981
  });
7982
+ const incomingTransfer = {
7983
+ id: transfer.id,
7984
+ senderPubkey: transfer.senderTransportPubkey,
7985
+ senderNametag: senderInfo.senderNametag,
7986
+ tokens: [token],
7987
+ memo: payload.memo,
7988
+ receivedAt: transfer.timestamp
7989
+ };
7990
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
7991
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7992
+ } else {
7993
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7488
7994
  }
7489
- const incomingTransfer = {
7490
- id: transfer.id,
7491
- senderPubkey: transfer.senderTransportPubkey,
7492
- senderNametag: senderInfo.senderNametag,
7493
- tokens: [token],
7494
- memo: payload.memo,
7495
- receivedAt: transfer.timestamp
7496
- };
7497
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7498
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7499
7995
  } catch (error) {
7500
7996
  console.error("[Payments] Failed to process incoming transfer:", error);
7501
7997
  }
@@ -7528,17 +8024,24 @@ var PaymentsModule = class _PaymentsModule {
7528
8024
  // ===========================================================================
7529
8025
  async save() {
7530
8026
  const providers = this.getTokenStorageProviders();
7531
- if (providers.size === 0) {
7532
- this.log("No token storage providers - tokens not persisted");
7533
- return;
7534
- }
7535
- const data = await this.createStorageData();
7536
- for (const [id, provider] of providers) {
7537
- try {
7538
- await provider.save(data);
7539
- } catch (err) {
7540
- console.error(`[Payments] Failed to save to provider ${id}:`, err);
8027
+ const tokenStats = Array.from(this.tokens.values()).map((t) => {
8028
+ const txf = tokenToTxf(t);
8029
+ return `${t.id.slice(0, 12)}(${t.status},txf=${!!txf})`;
8030
+ });
8031
+ console.log(`[Payments][DEBUG] save(): providers=${providers.size}, tokens=[${tokenStats.join(", ")}]`);
8032
+ if (providers.size > 0) {
8033
+ const data = await this.createStorageData();
8034
+ const dataKeys = Object.keys(data).filter((k) => k.startsWith("token-"));
8035
+ console.log(`[Payments][DEBUG] save(): TXF keys=${dataKeys.length} (${dataKeys.join(", ")})`);
8036
+ for (const [id, provider] of providers) {
8037
+ try {
8038
+ await provider.save(data);
8039
+ } catch (err) {
8040
+ console.error(`[Payments] Failed to save to provider ${id}:`, err);
8041
+ }
7541
8042
  }
8043
+ } else {
8044
+ console.log("[Payments][DEBUG] save(): No token storage providers - TXF not persisted");
7542
8045
  }
7543
8046
  await this.savePendingV5Tokens();
7544
8047
  }
@@ -7574,6 +8077,7 @@ var PaymentsModule = class _PaymentsModule {
7574
8077
  }
7575
8078
  loadFromStorageData(data) {
7576
8079
  const parsed = parseTxfStorageData(data);
8080
+ console.log(`[Payments][DEBUG] loadFromStorageData: parsed ${parsed.tokens.length} tokens, ${parsed.tombstones.length} tombstones, errors=[${parsed.validationErrors.join("; ")}]`);
7577
8081
  this.tombstones = parsed.tombstones;
7578
8082
  this.tokens.clear();
7579
8083
  for (const token of parsed.tokens) {
@@ -16608,6 +17112,7 @@ export {
16608
17112
  identityFromMnemonicSync,
16609
17113
  initSphere,
16610
17114
  isArchivedKey,
17115
+ isCombinedTransferBundleV6,
16611
17116
  isForkedKey,
16612
17117
  isInstantSplitBundle,
16613
17118
  isInstantSplitBundleV4,