@unicitylabs/sphere-sdk 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/connect/index.cjs +3 -1
  2. package/dist/connect/index.cjs.map +1 -1
  3. package/dist/connect/index.js +3 -1
  4. package/dist/connect/index.js.map +1 -1
  5. package/dist/core/index.cjs +615 -275
  6. package/dist/core/index.cjs.map +1 -1
  7. package/dist/core/index.d.cts +49 -2
  8. package/dist/core/index.d.ts +49 -2
  9. package/dist/core/index.js +615 -275
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/impl/browser/connect/index.cjs +3 -1
  12. package/dist/impl/browser/connect/index.cjs.map +1 -1
  13. package/dist/impl/browser/connect/index.js +3 -1
  14. package/dist/impl/browser/connect/index.js.map +1 -1
  15. package/dist/impl/browser/index.cjs +5 -2
  16. package/dist/impl/browser/index.cjs.map +1 -1
  17. package/dist/impl/browser/index.js +5 -2
  18. package/dist/impl/browser/index.js.map +1 -1
  19. package/dist/impl/browser/ipfs.cjs +3 -1
  20. package/dist/impl/browser/ipfs.cjs.map +1 -1
  21. package/dist/impl/browser/ipfs.js +3 -1
  22. package/dist/impl/browser/ipfs.js.map +1 -1
  23. package/dist/impl/nodejs/connect/index.cjs +3 -1
  24. package/dist/impl/nodejs/connect/index.cjs.map +1 -1
  25. package/dist/impl/nodejs/connect/index.js +3 -1
  26. package/dist/impl/nodejs/connect/index.js.map +1 -1
  27. package/dist/impl/nodejs/index.cjs +5 -2
  28. package/dist/impl/nodejs/index.cjs.map +1 -1
  29. package/dist/impl/nodejs/index.d.cts +6 -0
  30. package/dist/impl/nodejs/index.d.ts +6 -0
  31. package/dist/impl/nodejs/index.js +5 -2
  32. package/dist/impl/nodejs/index.js.map +1 -1
  33. package/dist/index.cjs +617 -275
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +118 -3
  36. package/dist/index.d.ts +118 -3
  37. package/dist/index.js +616 -275
  38. package/dist/index.js.map +1 -1
  39. package/dist/l1/index.cjs +3 -1
  40. package/dist/l1/index.cjs.map +1 -1
  41. package/dist/l1/index.js +3 -1
  42. package/dist/l1/index.js.map +1 -1
  43. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -93,7 +93,9 @@ var init_constants = __esm({
93
93
  /** Group chat: processed event IDs for deduplication */
94
94
  GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
95
95
  /** Processed V5 split group IDs for Nostr re-delivery dedup */
96
- PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids"
96
+ PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids",
97
+ /** Processed V6 combined transfer IDs for Nostr re-delivery dedup */
98
+ PROCESSED_COMBINED_TRANSFER_IDS: "processed_combined_transfer_ids"
97
99
  };
98
100
  STORAGE_KEYS = {
99
101
  ...STORAGE_KEYS_GLOBAL,
@@ -3581,14 +3583,149 @@ var InstantSplitExecutor = class {
3581
3583
  this.devMode = config.devMode ?? false;
3582
3584
  }
3583
3585
  /**
3584
- * Execute an instant split transfer with V5 optimized flow.
3586
+ * Build a V5 split bundle WITHOUT sending it via transport.
3585
3587
  *
3586
- * Critical path (~2.3s):
3588
+ * Steps 1-5 of the V5 flow:
3587
3589
  * 1. Create and submit burn commitment
3588
3590
  * 2. Wait for burn proof
3589
3591
  * 3. Create mint commitments with SplitMintReason
3590
3592
  * 4. Create transfer commitment (no mint proof needed)
3591
- * 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.
3592
3729
  *
3593
3730
  * @param tokenToSplit - The SDK token to split
3594
3731
  * @param splitAmount - Amount to send to recipient
@@ -3602,117 +3739,19 @@ var InstantSplitExecutor = class {
3602
3739
  */
3603
3740
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3604
3741
  const startTime = performance.now();
3605
- const splitGroupId = crypto.randomUUID();
3606
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3607
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3608
3742
  try {
3609
- const coinId = new CoinId3(fromHex2(coinIdHex));
3610
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3611
- const recipientTokenId = new TokenId3(await sha2563(seedString));
3612
- const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3613
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3614
- const senderSalt = await sha2563(seedString + "_sender_salt");
3615
- const senderAddressRef = await UnmaskedPredicateReference2.create(
3616
- tokenToSplit.type,
3617
- this.signingService.algorithm,
3618
- this.signingService.publicKey,
3619
- HashAlgorithm3.SHA256
3620
- );
3621
- const senderAddress = await senderAddressRef.toAddress();
3622
- const builder = new TokenSplitBuilder2();
3623
- const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3624
- builder.createToken(
3625
- recipientTokenId,
3626
- tokenToSplit.type,
3627
- new Uint8Array(0),
3628
- coinDataA,
3629
- senderAddress,
3630
- // Mint to sender first, then transfer
3631
- recipientSalt,
3632
- null
3633
- );
3634
- const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3635
- builder.createToken(
3636
- senderTokenId,
3637
- tokenToSplit.type,
3638
- new Uint8Array(0),
3639
- coinDataB,
3640
- senderAddress,
3641
- senderSalt,
3642
- null
3643
- );
3644
- const split = await builder.build(tokenToSplit);
3645
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3646
- const burnSalt = await sha2563(seedString + "_burn_salt");
3647
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3648
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3649
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3650
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3651
- }
3652
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3653
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3654
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3655
- const burnDuration = performance.now() - startTime;
3656
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3657
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3658
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3659
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3660
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3661
- const senderIdHex = toHex2(senderTokenId.bytes);
3662
- const recipientMintCommitment = mintCommitments.find(
3663
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3664
- );
3665
- const senderMintCommitment = mintCommitments.find(
3666
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3667
- );
3668
- if (!recipientMintCommitment || !senderMintCommitment) {
3669
- throw new Error("Failed to find expected mint commitments");
3670
- }
3671
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3672
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3673
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3674
- recipientMintCommitment.transactionData,
3743
+ const buildResult = await this.buildSplitBundle(
3744
+ tokenToSplit,
3745
+ splitAmount,
3746
+ remainderAmount,
3747
+ coinIdHex,
3675
3748
  recipientAddress,
3676
- transferSalt,
3677
- this.signingService
3678
- );
3679
- const mintedPredicate = await UnmaskedPredicate3.create(
3680
- recipientTokenId,
3681
- tokenToSplit.type,
3682
- this.signingService,
3683
- HashAlgorithm3.SHA256,
3684
- recipientSalt
3749
+ options
3685
3750
  );
3686
- const mintedState = new TokenState3(mintedPredicate, null);
3687
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3751
+ console.log("[InstantSplit] Sending via transport...");
3688
3752
  const senderPubkey = toHex2(this.signingService.publicKey);
3689
- let nametagTokenJson;
3690
- const recipientAddressStr = recipientAddress.toString();
3691
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3692
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3693
- }
3694
- const bundle = {
3695
- version: "5.0",
3696
- type: "INSTANT_SPLIT",
3697
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3698
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3699
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3700
- amount: splitAmount.toString(),
3701
- coinId: coinIdHex,
3702
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3703
- splitGroupId,
3704
- senderPubkey,
3705
- recipientSaltHex: toHex2(recipientSalt),
3706
- transferSaltHex: toHex2(transferSalt),
3707
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3708
- finalRecipientStateJson: "",
3709
- // Recipient creates their own
3710
- recipientAddressJson: recipientAddressStr,
3711
- nametagTokenJson
3712
- };
3713
- console.log("[InstantSplit] Step 6: Sending via transport...");
3714
3753
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3715
- token: JSON.stringify(bundle),
3754
+ token: JSON.stringify(buildResult.bundle),
3716
3755
  proof: null,
3717
3756
  // Proof is included in the bundle
3718
3757
  memo: options?.memo,
@@ -3723,25 +3762,13 @@ var InstantSplitExecutor = class {
3723
3762
  const criticalPathDuration = performance.now() - startTime;
3724
3763
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3725
3764
  options?.onNostrDelivered?.(nostrEventId);
3726
- let backgroundPromise;
3727
- if (!options?.skipBackground) {
3728
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3729
- signingService: this.signingService,
3730
- tokenType: tokenToSplit.type,
3731
- coinId,
3732
- senderTokenId,
3733
- senderSalt,
3734
- onProgress: options?.onBackgroundProgress,
3735
- onChangeTokenCreated: options?.onChangeTokenCreated,
3736
- onStorageSync: options?.onStorageSync
3737
- });
3738
- }
3765
+ const backgroundPromise = buildResult.startBackground();
3739
3766
  return {
3740
3767
  success: true,
3741
3768
  nostrEventId,
3742
- splitGroupId,
3769
+ splitGroupId: buildResult.splitGroupId,
3743
3770
  criticalPathDurationMs: criticalPathDuration,
3744
- backgroundStarted: !options?.skipBackground,
3771
+ backgroundStarted: true,
3745
3772
  backgroundPromise
3746
3773
  };
3747
3774
  } catch (error) {
@@ -3750,7 +3777,6 @@ var InstantSplitExecutor = class {
3750
3777
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3751
3778
  return {
3752
3779
  success: false,
3753
- splitGroupId,
3754
3780
  criticalPathDurationMs: duration,
3755
3781
  error: errorMessage,
3756
3782
  backgroundStarted: false
@@ -3955,6 +3981,11 @@ function isInstantSplitBundleV4(obj) {
3955
3981
  function isInstantSplitBundleV5(obj) {
3956
3982
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3957
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
+ }
3958
3989
 
3959
3990
  // modules/payments/InstantSplitProcessor.ts
3960
3991
  function fromHex3(hex) {
@@ -4608,6 +4639,8 @@ var PaymentsModule = class _PaymentsModule {
4608
4639
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4609
4640
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4610
4641
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4642
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4643
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4611
4644
  // Storage event subscriptions (push-based sync)
4612
4645
  storageEventUnsubscribers = [];
4613
4646
  syncDebounceTimer = null;
@@ -4708,10 +4741,23 @@ var PaymentsModule = class _PaymentsModule {
4708
4741
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4709
4742
  }
4710
4743
  }
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
+ }
4755
+ }
4711
4756
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4712
4757
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4713
4758
  await this.loadPendingV5Tokens();
4714
4759
  await this.loadProcessedSplitGroupIds();
4760
+ await this.loadProcessedCombinedTransferIds();
4715
4761
  await this.loadHistory();
4716
4762
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4717
4763
  if (pending2) {
@@ -4803,12 +4849,13 @@ var PaymentsModule = class _PaymentsModule {
4803
4849
  token.status = "transferring";
4804
4850
  this.tokens.set(token.id, token);
4805
4851
  }
4852
+ await this.save();
4806
4853
  await this.saveToOutbox(result, recipientPubkey);
4807
4854
  result.status = "submitted";
4808
4855
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4809
4856
  const transferMode = request.transferMode ?? "instant";
4810
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4811
- if (transferMode === "conservative") {
4857
+ if (transferMode === "conservative") {
4858
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4812
4859
  this.log("Executing conservative split...");
4813
4860
  const splitExecutor = new TokenSplitExecutor({
4814
4861
  stateTransitionClient: stClient,
@@ -4852,27 +4899,59 @@ var PaymentsModule = class _PaymentsModule {
4852
4899
  requestIdHex: splitRequestIdHex
4853
4900
  });
4854
4901
  this.log(`Conservative split transfer completed`);
4855
- } else {
4856
- this.log("Executing instant split...");
4857
- 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...");
4858
4936
  const executor = new InstantSplitExecutor({
4859
4937
  stateTransitionClient: stClient,
4860
4938
  trustBase,
4861
4939
  signingService,
4862
4940
  devMode
4863
4941
  });
4864
- const instantResult = await executor.executeSplitInstant(
4942
+ builtSplit = await executor.buildSplitBundle(
4865
4943
  splitPlan.tokenToSplit.sdkToken,
4866
4944
  splitPlan.splitAmount,
4867
4945
  splitPlan.remainderAmount,
4868
4946
  splitPlan.coinId,
4869
4947
  recipientAddress,
4870
- this.deps.transport,
4871
- recipientPubkey,
4872
4948
  {
4873
4949
  memo: request.memo,
4874
4950
  onChangeTokenCreated: async (changeToken) => {
4875
4951
  const changeTokenData = changeToken.toJSON();
4952
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4953
+ this.tokens.delete(changeTokenPlaceholderId);
4954
+ }
4876
4955
  const uiToken = {
4877
4956
  id: crypto.randomUUID(),
4878
4957
  coinId: request.coinId,
@@ -4895,65 +4974,103 @@ var PaymentsModule = class _PaymentsModule {
4895
4974
  }
4896
4975
  }
4897
4976
  );
4898
- if (!instantResult.success) {
4899
- throw new Error(instantResult.error || "Instant split failed");
4900
- }
4901
- if (instantResult.backgroundPromise) {
4902
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4903
- }
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) {
4904
5042
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4905
5043
  result.tokenTransfers.push({
4906
5044
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4907
5045
  method: "split",
4908
- splitGroupId: instantResult.splitGroupId,
4909
- nostrEventId: instantResult.nostrEventId
5046
+ splitGroupId: builtSplit.splitGroupId
4910
5047
  });
4911
- this.log(`Instant split transfer completed`);
4912
5048
  }
4913
- }
4914
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4915
- const token = tokenWithAmount.uiToken;
4916
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4917
- if (transferMode === "conservative") {
4918
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4919
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4920
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4921
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4922
- }
4923
- const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4924
- const transferTx = commitment.toTransaction(inclusionProof);
4925
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4926
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4927
- transferTx: JSON.stringify(transferTx.toJSON()),
4928
- memo: request.memo
4929
- });
4930
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4931
- } else {
4932
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4933
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4934
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4935
- commitmentData: JSON.stringify(commitment.toJSON()),
4936
- memo: request.memo
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
4937
5058
  });
4938
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4939
- stClient.submitTransferCommitment(commitment).catch(
4940
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4941
- );
5059
+ await this.removeToken(token.id);
4942
5060
  }
4943
- const requestIdBytes = commitment.requestId;
4944
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4945
- result.tokenTransfers.push({
4946
- sourceTokenId: token.id,
4947
- method: "direct",
4948
- requestIdHex
4949
- });
4950
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4951
- await this.removeToken(token.id);
5061
+ this.log(`V6 combined transfer completed`);
4952
5062
  }
4953
5063
  result.status = "delivered";
4954
5064
  await this.save();
4955
5065
  await this.removeFromOutbox(result.id);
4956
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
+ }));
4957
5074
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4958
5075
  await this.addToHistory({
4959
5076
  type: "SENT",
@@ -4966,7 +5083,8 @@ var PaymentsModule = class _PaymentsModule {
4966
5083
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4967
5084
  memo: request.memo,
4968
5085
  transferId: result.id,
4969
- tokenId: sentTokenId || void 0
5086
+ tokenId: sentTokenId || void 0,
5087
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4970
5088
  });
4971
5089
  this.deps.emitEvent("transfer:confirmed", result);
4972
5090
  return result;
@@ -5136,6 +5254,267 @@ var PaymentsModule = class _PaymentsModule {
5136
5254
  };
5137
5255
  }
5138
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
+ }
5139
5518
  /**
5140
5519
  * Process a received INSTANT_SPLIT bundle.
5141
5520
  *
@@ -5159,36 +5538,10 @@ var PaymentsModule = class _PaymentsModule {
5159
5538
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
5160
5539
  }
5161
5540
  try {
5162
- const deterministicId = `v5split_${bundle.splitGroupId}`;
5163
- if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5164
- console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5541
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5542
+ if (!uiToken) {
5165
5543
  return { success: true, durationMs: 0 };
5166
5544
  }
5167
- const registry = TokenRegistry.getInstance();
5168
- const pendingData = {
5169
- type: "v5_bundle",
5170
- stage: "RECEIVED",
5171
- bundleJson: JSON.stringify(bundle),
5172
- senderPubkey,
5173
- savedAt: Date.now(),
5174
- attemptCount: 0
5175
- };
5176
- const uiToken = {
5177
- id: deterministicId,
5178
- coinId: bundle.coinId,
5179
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5180
- name: registry.getName(bundle.coinId) || bundle.coinId,
5181
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
5182
- amount: bundle.amount,
5183
- status: "submitted",
5184
- // UNCONFIRMED
5185
- createdAt: Date.now(),
5186
- updatedAt: Date.now(),
5187
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5188
- };
5189
- await this.addToken(uiToken);
5190
- this.processedSplitGroupIds.add(bundle.splitGroupId);
5191
- await this.saveProcessedSplitGroupIds();
5192
5545
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
5193
5546
  await this.addToHistory({
5194
5547
  type: "RECEIVED",
@@ -5199,7 +5552,7 @@ var PaymentsModule = class _PaymentsModule {
5199
5552
  senderPubkey,
5200
5553
  ...senderInfo,
5201
5554
  memo,
5202
- tokenId: deterministicId
5555
+ tokenId: uiToken.id
5203
5556
  });
5204
5557
  this.deps.emitEvent("transfer:incoming", {
5205
5558
  id: bundle.splitGroupId,
@@ -5849,16 +6202,18 @@ var PaymentsModule = class _PaymentsModule {
5849
6202
  }
5850
6203
  /**
5851
6204
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5852
- * 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").
5853
6207
  */
5854
6208
  aggregateTokens(coinId) {
5855
6209
  const assetsMap = /* @__PURE__ */ new Map();
5856
6210
  for (const token of this.tokens.values()) {
5857
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6211
+ if (token.status === "spent" || token.status === "invalid") continue;
5858
6212
  if (coinId && token.coinId !== coinId) continue;
5859
6213
  const key = token.coinId;
5860
6214
  const amount = BigInt(token.amount);
5861
6215
  const isConfirmed = token.status === "confirmed";
6216
+ const isTransferring = token.status === "transferring";
5862
6217
  const existing = assetsMap.get(key);
5863
6218
  if (existing) {
5864
6219
  if (isConfirmed) {
@@ -5868,6 +6223,7 @@ var PaymentsModule = class _PaymentsModule {
5868
6223
  existing.unconfirmedAmount += amount;
5869
6224
  existing.unconfirmedTokenCount++;
5870
6225
  }
6226
+ if (isTransferring) existing.transferringTokenCount++;
5871
6227
  } else {
5872
6228
  assetsMap.set(key, {
5873
6229
  coinId: token.coinId,
@@ -5878,7 +6234,8 @@ var PaymentsModule = class _PaymentsModule {
5878
6234
  confirmedAmount: isConfirmed ? amount : 0n,
5879
6235
  unconfirmedAmount: isConfirmed ? 0n : amount,
5880
6236
  confirmedTokenCount: isConfirmed ? 1 : 0,
5881
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6237
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6238
+ transferringTokenCount: isTransferring ? 1 : 0
5882
6239
  });
5883
6240
  }
5884
6241
  }
@@ -5896,6 +6253,7 @@ var PaymentsModule = class _PaymentsModule {
5896
6253
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5897
6254
  confirmedTokenCount: raw.confirmedTokenCount,
5898
6255
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6256
+ transferringTokenCount: raw.transferringTokenCount,
5899
6257
  priceUsd: null,
5900
6258
  priceEur: null,
5901
6259
  change24h: null,
@@ -7293,7 +7651,7 @@ var PaymentsModule = class _PaymentsModule {
7293
7651
  /**
7294
7652
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7295
7653
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7296
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7654
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7297
7655
  */
7298
7656
  async handleCommitmentOnlyTransfer(transfer, payload) {
7299
7657
  try {
@@ -7303,41 +7661,22 @@ var PaymentsModule = class _PaymentsModule {
7303
7661
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7304
7662
  return;
7305
7663
  }
7306
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7307
- const token = {
7308
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7309
- coinId: tokenInfo.coinId,
7310
- symbol: tokenInfo.symbol,
7311
- name: tokenInfo.name,
7312
- decimals: tokenInfo.decimals,
7313
- iconUrl: tokenInfo.iconUrl,
7314
- amount: tokenInfo.amount,
7315
- status: "submitted",
7316
- // NOSTR-FIRST: unconfirmed until proof
7317
- createdAt: Date.now(),
7318
- updatedAt: Date.now(),
7319
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7320
- };
7321
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7322
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7323
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7324
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7325
- return;
7326
- }
7327
- this.tokens.set(token.id, token);
7328
- console.log(`[Payments][DEBUG] NOSTR-FIRST: saving token id=${token.id.slice(0, 16)} status=${token.status} sdkData.length=${token.sdkData?.length}`);
7329
- await this.save();
7330
- console.log(`[Payments][DEBUG] NOSTR-FIRST: save() completed, tokens.size=${this.tokens.size}`);
7664
+ const token = await this.saveCommitmentOnlyToken(
7665
+ sourceTokenInput,
7666
+ commitmentInput,
7667
+ transfer.senderTransportPubkey
7668
+ );
7669
+ if (!token) return;
7331
7670
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7332
- const incomingTransfer = {
7671
+ this.deps.emitEvent("transfer:incoming", {
7333
7672
  id: transfer.id,
7334
7673
  senderPubkey: transfer.senderTransportPubkey,
7335
7674
  senderNametag: senderInfo.senderNametag,
7336
7675
  tokens: [token],
7337
7676
  memo: payload.memo,
7338
7677
  receivedAt: transfer.timestamp
7339
- };
7340
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7678
+ });
7679
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7341
7680
  await this.addToHistory({
7342
7681
  type: "RECEIVED",
7343
7682
  amount: token.amount,
@@ -7349,29 +7688,6 @@ var PaymentsModule = class _PaymentsModule {
7349
7688
  memo: payload.memo,
7350
7689
  tokenId: nostrTokenId || token.id
7351
7690
  });
7352
- try {
7353
- const commitment = await TransferCommitment4.fromJSON(commitmentInput);
7354
- const requestIdBytes = commitment.requestId;
7355
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7356
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7357
- if (stClient) {
7358
- const response = await stClient.submitTransferCommitment(commitment);
7359
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7360
- }
7361
- this.addProofPollingJob({
7362
- tokenId: token.id,
7363
- requestIdHex,
7364
- commitmentJson: JSON.stringify(commitmentInput),
7365
- startedAt: Date.now(),
7366
- attemptCount: 0,
7367
- lastAttemptAt: 0,
7368
- onProofReceived: async (tokenId) => {
7369
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7370
- }
7371
- });
7372
- } catch (err) {
7373
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7374
- }
7375
7691
  } catch (error) {
7376
7692
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7377
7693
  }
@@ -7490,6 +7806,28 @@ var PaymentsModule = class _PaymentsModule {
7490
7806
  try {
7491
7807
  const payload = transfer.payload;
7492
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
+ }
7493
7831
  let instantBundle = null;
7494
7832
  if (isInstantSplitBundle(payload)) {
7495
7833
  instantBundle = payload;
@@ -7641,17 +7979,19 @@ var PaymentsModule = class _PaymentsModule {
7641
7979
  memo: payload.memo,
7642
7980
  tokenId: incomingTokenId || token.id
7643
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}`);
7644
7994
  }
7645
- const incomingTransfer = {
7646
- id: transfer.id,
7647
- senderPubkey: transfer.senderTransportPubkey,
7648
- senderNametag: senderInfo.senderNametag,
7649
- tokens: [token],
7650
- memo: payload.memo,
7651
- receivedAt: transfer.timestamp
7652
- };
7653
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7654
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7655
7995
  } catch (error) {
7656
7996
  console.error("[Payments] Failed to process incoming transfer:", error);
7657
7997
  }
@@ -16772,6 +17112,7 @@ export {
16772
17112
  identityFromMnemonicSync,
16773
17113
  initSphere,
16774
17114
  isArchivedKey,
17115
+ isCombinedTransferBundleV6,
16775
17116
  isForkedKey,
16776
17117
  isInstantSplitBundle,
16777
17118
  isInstantSplitBundleV4,