@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
@@ -105,7 +105,9 @@ var init_constants = __esm({
105
105
  /** Group chat: processed event IDs for deduplication */
106
106
  GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
107
107
  /** Processed V5 split group IDs for Nostr re-delivery dedup */
108
- PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids"
108
+ PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids",
109
+ /** Processed V6 combined transfer IDs for Nostr re-delivery dedup */
110
+ PROCESSED_COMBINED_TRANSFER_IDS: "processed_combined_transfer_ids"
109
111
  };
110
112
  STORAGE_KEYS = {
111
113
  ...STORAGE_KEYS_GLOBAL,
@@ -3381,14 +3383,149 @@ var InstantSplitExecutor = class {
3381
3383
  this.devMode = config.devMode ?? false;
3382
3384
  }
3383
3385
  /**
3384
- * Execute an instant split transfer with V5 optimized flow.
3386
+ * Build a V5 split bundle WITHOUT sending it via transport.
3385
3387
  *
3386
- * Critical path (~2.3s):
3388
+ * Steps 1-5 of the V5 flow:
3387
3389
  * 1. Create and submit burn commitment
3388
3390
  * 2. Wait for burn proof
3389
3391
  * 3. Create mint commitments with SplitMintReason
3390
3392
  * 4. Create transfer commitment (no mint proof needed)
3391
- * 5. Send bundle via transport
3393
+ * 5. Package V5 bundle
3394
+ *
3395
+ * The caller is responsible for sending the bundle and then calling
3396
+ * `startBackground()` on the result to begin mint proof + change token creation.
3397
+ */
3398
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3399
+ const splitGroupId = crypto.randomUUID();
3400
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3401
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3402
+ const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3403
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3404
+ const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3405
+ const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3406
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3407
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3408
+ const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3409
+ tokenToSplit.type,
3410
+ this.signingService.algorithm,
3411
+ this.signingService.publicKey,
3412
+ import_HashAlgorithm3.HashAlgorithm.SHA256
3413
+ );
3414
+ const senderAddress = await senderAddressRef.toAddress();
3415
+ const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3416
+ const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3417
+ builder.createToken(
3418
+ recipientTokenId,
3419
+ tokenToSplit.type,
3420
+ new Uint8Array(0),
3421
+ coinDataA,
3422
+ senderAddress,
3423
+ // Mint to sender first, then transfer
3424
+ recipientSalt,
3425
+ null
3426
+ );
3427
+ const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3428
+ builder.createToken(
3429
+ senderTokenId,
3430
+ tokenToSplit.type,
3431
+ new Uint8Array(0),
3432
+ coinDataB,
3433
+ senderAddress,
3434
+ senderSalt,
3435
+ null
3436
+ );
3437
+ const split = await builder.build(tokenToSplit);
3438
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3439
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3440
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3441
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3442
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3443
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3444
+ }
3445
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3446
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3447
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3448
+ console.log(`[InstantSplit] Burn proof received`);
3449
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3450
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3451
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3452
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3453
+ const senderIdHex = toHex2(senderTokenId.bytes);
3454
+ const recipientMintCommitment = mintCommitments.find(
3455
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3456
+ );
3457
+ const senderMintCommitment = mintCommitments.find(
3458
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3459
+ );
3460
+ if (!recipientMintCommitment || !senderMintCommitment) {
3461
+ throw new Error("Failed to find expected mint commitments");
3462
+ }
3463
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3464
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3465
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3466
+ recipientMintCommitment.transactionData,
3467
+ recipientAddress,
3468
+ transferSalt,
3469
+ this.signingService
3470
+ );
3471
+ const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3472
+ recipientTokenId,
3473
+ tokenToSplit.type,
3474
+ this.signingService,
3475
+ import_HashAlgorithm3.HashAlgorithm.SHA256,
3476
+ recipientSalt
3477
+ );
3478
+ const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3479
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3480
+ const senderPubkey = toHex2(this.signingService.publicKey);
3481
+ let nametagTokenJson;
3482
+ const recipientAddressStr = recipientAddress.toString();
3483
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3484
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3485
+ }
3486
+ const bundle = {
3487
+ version: "5.0",
3488
+ type: "INSTANT_SPLIT",
3489
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3490
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3491
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3492
+ amount: splitAmount.toString(),
3493
+ coinId: coinIdHex,
3494
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3495
+ splitGroupId,
3496
+ senderPubkey,
3497
+ recipientSaltHex: toHex2(recipientSalt),
3498
+ transferSaltHex: toHex2(transferSalt),
3499
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3500
+ finalRecipientStateJson: "",
3501
+ // Recipient creates their own
3502
+ recipientAddressJson: recipientAddressStr,
3503
+ nametagTokenJson
3504
+ };
3505
+ return {
3506
+ bundle,
3507
+ splitGroupId,
3508
+ startBackground: async () => {
3509
+ if (!options?.skipBackground) {
3510
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3511
+ signingService: this.signingService,
3512
+ tokenType: tokenToSplit.type,
3513
+ coinId,
3514
+ senderTokenId,
3515
+ senderSalt,
3516
+ onProgress: options?.onBackgroundProgress,
3517
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3518
+ onStorageSync: options?.onStorageSync
3519
+ });
3520
+ }
3521
+ }
3522
+ };
3523
+ }
3524
+ /**
3525
+ * Execute an instant split transfer with V5 optimized flow.
3526
+ *
3527
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3528
+ * and starts background processing.
3392
3529
  *
3393
3530
  * @param tokenToSplit - The SDK token to split
3394
3531
  * @param splitAmount - Amount to send to recipient
@@ -3402,117 +3539,19 @@ var InstantSplitExecutor = class {
3402
3539
  */
3403
3540
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3404
3541
  const startTime = performance.now();
3405
- const splitGroupId = crypto.randomUUID();
3406
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3407
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3408
3542
  try {
3409
- const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3410
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3411
- const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3412
- const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3413
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3414
- const senderSalt = await sha2563(seedString + "_sender_salt");
3415
- const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3416
- tokenToSplit.type,
3417
- this.signingService.algorithm,
3418
- this.signingService.publicKey,
3419
- import_HashAlgorithm3.HashAlgorithm.SHA256
3420
- );
3421
- const senderAddress = await senderAddressRef.toAddress();
3422
- const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3423
- const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3424
- builder.createToken(
3425
- recipientTokenId,
3426
- tokenToSplit.type,
3427
- new Uint8Array(0),
3428
- coinDataA,
3429
- senderAddress,
3430
- // Mint to sender first, then transfer
3431
- recipientSalt,
3432
- null
3433
- );
3434
- const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3435
- builder.createToken(
3436
- senderTokenId,
3437
- tokenToSplit.type,
3438
- new Uint8Array(0),
3439
- coinDataB,
3440
- senderAddress,
3441
- senderSalt,
3442
- null
3443
- );
3444
- const split = await builder.build(tokenToSplit);
3445
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3446
- const burnSalt = await sha2563(seedString + "_burn_salt");
3447
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3448
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3449
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3450
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3451
- }
3452
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3453
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3454
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3455
- const burnDuration = performance.now() - startTime;
3456
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3457
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3458
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3459
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3460
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3461
- const senderIdHex = toHex2(senderTokenId.bytes);
3462
- const recipientMintCommitment = mintCommitments.find(
3463
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3464
- );
3465
- const senderMintCommitment = mintCommitments.find(
3466
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3467
- );
3468
- if (!recipientMintCommitment || !senderMintCommitment) {
3469
- throw new Error("Failed to find expected mint commitments");
3470
- }
3471
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3472
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3473
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3474
- recipientMintCommitment.transactionData,
3543
+ const buildResult = await this.buildSplitBundle(
3544
+ tokenToSplit,
3545
+ splitAmount,
3546
+ remainderAmount,
3547
+ coinIdHex,
3475
3548
  recipientAddress,
3476
- transferSalt,
3477
- this.signingService
3478
- );
3479
- const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3480
- recipientTokenId,
3481
- tokenToSplit.type,
3482
- this.signingService,
3483
- import_HashAlgorithm3.HashAlgorithm.SHA256,
3484
- recipientSalt
3549
+ options
3485
3550
  );
3486
- const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3487
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3551
+ console.log("[InstantSplit] Sending via transport...");
3488
3552
  const senderPubkey = toHex2(this.signingService.publicKey);
3489
- let nametagTokenJson;
3490
- const recipientAddressStr = recipientAddress.toString();
3491
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3492
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3493
- }
3494
- const bundle = {
3495
- version: "5.0",
3496
- type: "INSTANT_SPLIT",
3497
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3498
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3499
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3500
- amount: splitAmount.toString(),
3501
- coinId: coinIdHex,
3502
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3503
- splitGroupId,
3504
- senderPubkey,
3505
- recipientSaltHex: toHex2(recipientSalt),
3506
- transferSaltHex: toHex2(transferSalt),
3507
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3508
- finalRecipientStateJson: "",
3509
- // Recipient creates their own
3510
- recipientAddressJson: recipientAddressStr,
3511
- nametagTokenJson
3512
- };
3513
- console.log("[InstantSplit] Step 6: Sending via transport...");
3514
3553
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3515
- token: JSON.stringify(bundle),
3554
+ token: JSON.stringify(buildResult.bundle),
3516
3555
  proof: null,
3517
3556
  // Proof is included in the bundle
3518
3557
  memo: options?.memo,
@@ -3523,25 +3562,13 @@ var InstantSplitExecutor = class {
3523
3562
  const criticalPathDuration = performance.now() - startTime;
3524
3563
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3525
3564
  options?.onNostrDelivered?.(nostrEventId);
3526
- let backgroundPromise;
3527
- if (!options?.skipBackground) {
3528
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3529
- signingService: this.signingService,
3530
- tokenType: tokenToSplit.type,
3531
- coinId,
3532
- senderTokenId,
3533
- senderSalt,
3534
- onProgress: options?.onBackgroundProgress,
3535
- onChangeTokenCreated: options?.onChangeTokenCreated,
3536
- onStorageSync: options?.onStorageSync
3537
- });
3538
- }
3565
+ const backgroundPromise = buildResult.startBackground();
3539
3566
  return {
3540
3567
  success: true,
3541
3568
  nostrEventId,
3542
- splitGroupId,
3569
+ splitGroupId: buildResult.splitGroupId,
3543
3570
  criticalPathDurationMs: criticalPathDuration,
3544
- backgroundStarted: !options?.skipBackground,
3571
+ backgroundStarted: true,
3545
3572
  backgroundPromise
3546
3573
  };
3547
3574
  } catch (error) {
@@ -3550,7 +3577,6 @@ var InstantSplitExecutor = class {
3550
3577
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3551
3578
  return {
3552
3579
  success: false,
3553
- splitGroupId,
3554
3580
  criticalPathDurationMs: duration,
3555
3581
  error: errorMessage,
3556
3582
  backgroundStarted: false
@@ -3755,6 +3781,11 @@ function isInstantSplitBundleV4(obj) {
3755
3781
  function isInstantSplitBundleV5(obj) {
3756
3782
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3757
3783
  }
3784
+ function isCombinedTransferBundleV6(obj) {
3785
+ if (typeof obj !== "object" || obj === null) return false;
3786
+ const b = obj;
3787
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
3788
+ }
3758
3789
 
3759
3790
  // modules/payments/InstantSplitProcessor.ts
3760
3791
  function fromHex3(hex) {
@@ -4408,6 +4439,8 @@ var PaymentsModule = class _PaymentsModule {
4408
4439
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4409
4440
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4410
4441
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4442
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4443
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4411
4444
  // Storage event subscriptions (push-based sync)
4412
4445
  storageEventUnsubscribers = [];
4413
4446
  syncDebounceTimer = null;
@@ -4508,10 +4541,23 @@ var PaymentsModule = class _PaymentsModule {
4508
4541
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4509
4542
  }
4510
4543
  }
4544
+ for (const [id, token] of this.tokens) {
4545
+ try {
4546
+ if (token.sdkData) {
4547
+ const data = JSON.parse(token.sdkData);
4548
+ if (data?._placeholder) {
4549
+ this.tokens.delete(id);
4550
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4551
+ }
4552
+ }
4553
+ } catch {
4554
+ }
4555
+ }
4511
4556
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4512
4557
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4513
4558
  await this.loadPendingV5Tokens();
4514
4559
  await this.loadProcessedSplitGroupIds();
4560
+ await this.loadProcessedCombinedTransferIds();
4515
4561
  await this.loadHistory();
4516
4562
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4517
4563
  if (pending2) {
@@ -4603,12 +4649,13 @@ var PaymentsModule = class _PaymentsModule {
4603
4649
  token.status = "transferring";
4604
4650
  this.tokens.set(token.id, token);
4605
4651
  }
4652
+ await this.save();
4606
4653
  await this.saveToOutbox(result, recipientPubkey);
4607
4654
  result.status = "submitted";
4608
4655
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4609
4656
  const transferMode = request.transferMode ?? "instant";
4610
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4611
- if (transferMode === "conservative") {
4657
+ if (transferMode === "conservative") {
4658
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4612
4659
  this.log("Executing conservative split...");
4613
4660
  const splitExecutor = new TokenSplitExecutor({
4614
4661
  stateTransitionClient: stClient,
@@ -4652,27 +4699,59 @@ var PaymentsModule = class _PaymentsModule {
4652
4699
  requestIdHex: splitRequestIdHex
4653
4700
  });
4654
4701
  this.log(`Conservative split transfer completed`);
4655
- } else {
4656
- this.log("Executing instant split...");
4657
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
4702
+ }
4703
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4704
+ const token = tokenWithAmount.uiToken;
4705
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4706
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4707
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4708
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4709
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4710
+ }
4711
+ const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
4712
+ const transferTx = commitment.toTransaction(inclusionProof);
4713
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4714
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4715
+ transferTx: JSON.stringify(transferTx.toJSON()),
4716
+ memo: request.memo
4717
+ });
4718
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4719
+ const requestIdBytes = commitment.requestId;
4720
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4721
+ result.tokenTransfers.push({
4722
+ sourceTokenId: token.id,
4723
+ method: "direct",
4724
+ requestIdHex
4725
+ });
4726
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
4727
+ await this.removeToken(token.id);
4728
+ }
4729
+ } else {
4730
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4731
+ const senderPubkey = this.deps.identity.chainPubkey;
4732
+ let changeTokenPlaceholderId = null;
4733
+ let builtSplit = null;
4734
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4735
+ this.log("Building instant split bundle...");
4658
4736
  const executor = new InstantSplitExecutor({
4659
4737
  stateTransitionClient: stClient,
4660
4738
  trustBase,
4661
4739
  signingService,
4662
4740
  devMode
4663
4741
  });
4664
- const instantResult = await executor.executeSplitInstant(
4742
+ builtSplit = await executor.buildSplitBundle(
4665
4743
  splitPlan.tokenToSplit.sdkToken,
4666
4744
  splitPlan.splitAmount,
4667
4745
  splitPlan.remainderAmount,
4668
4746
  splitPlan.coinId,
4669
4747
  recipientAddress,
4670
- this.deps.transport,
4671
- recipientPubkey,
4672
4748
  {
4673
4749
  memo: request.memo,
4674
4750
  onChangeTokenCreated: async (changeToken) => {
4675
4751
  const changeTokenData = changeToken.toJSON();
4752
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4753
+ this.tokens.delete(changeTokenPlaceholderId);
4754
+ }
4676
4755
  const uiToken = {
4677
4756
  id: crypto.randomUUID(),
4678
4757
  coinId: request.coinId,
@@ -4695,65 +4774,103 @@ var PaymentsModule = class _PaymentsModule {
4695
4774
  }
4696
4775
  }
4697
4776
  );
4698
- if (!instantResult.success) {
4699
- throw new Error(instantResult.error || "Instant split failed");
4700
- }
4701
- if (instantResult.backgroundPromise) {
4702
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4703
- }
4777
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
4778
+ }
4779
+ const directCommitments = await Promise.all(
4780
+ splitPlan.tokensToTransferDirectly.map(
4781
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
4782
+ )
4783
+ );
4784
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
4785
+ (tw, i) => ({
4786
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
4787
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
4788
+ amount: tw.uiToken.amount,
4789
+ coinId: tw.uiToken.coinId,
4790
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
4791
+ })
4792
+ );
4793
+ const combinedBundle = {
4794
+ version: "6.0",
4795
+ type: "COMBINED_TRANSFER",
4796
+ transferId: result.id,
4797
+ splitBundle: builtSplit?.bundle ?? null,
4798
+ directTokens: directTokenEntries,
4799
+ totalAmount: request.amount.toString(),
4800
+ coinId: request.coinId,
4801
+ senderPubkey,
4802
+ memo: request.memo
4803
+ };
4804
+ console.log(
4805
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
4806
+ );
4807
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4808
+ token: JSON.stringify(combinedBundle),
4809
+ proof: null,
4810
+ memo: request.memo,
4811
+ sender: { transportPubkey: senderPubkey }
4812
+ });
4813
+ console.log(`[Payments] V6 combined bundle sent successfully`);
4814
+ if (builtSplit) {
4815
+ const bgPromise = builtSplit.startBackground();
4816
+ this.pendingBackgroundTasks.push(bgPromise);
4817
+ }
4818
+ if (builtSplit && splitPlan.remainderAmount) {
4819
+ changeTokenPlaceholderId = crypto.randomUUID();
4820
+ const placeholder = {
4821
+ id: changeTokenPlaceholderId,
4822
+ coinId: request.coinId,
4823
+ symbol: this.getCoinSymbol(request.coinId),
4824
+ name: this.getCoinName(request.coinId),
4825
+ decimals: this.getCoinDecimals(request.coinId),
4826
+ iconUrl: this.getCoinIconUrl(request.coinId),
4827
+ amount: splitPlan.remainderAmount.toString(),
4828
+ status: "transferring",
4829
+ createdAt: Date.now(),
4830
+ updatedAt: Date.now(),
4831
+ sdkData: JSON.stringify({ _placeholder: true })
4832
+ };
4833
+ this.tokens.set(placeholder.id, placeholder);
4834
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
4835
+ }
4836
+ for (const commitment of directCommitments) {
4837
+ stClient.submitTransferCommitment(commitment).catch(
4838
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4839
+ );
4840
+ }
4841
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4704
4842
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4705
4843
  result.tokenTransfers.push({
4706
4844
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4707
4845
  method: "split",
4708
- splitGroupId: instantResult.splitGroupId,
4709
- nostrEventId: instantResult.nostrEventId
4846
+ splitGroupId: builtSplit.splitGroupId
4710
4847
  });
4711
- this.log(`Instant split transfer completed`);
4712
4848
  }
4713
- }
4714
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4715
- const token = tokenWithAmount.uiToken;
4716
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4717
- if (transferMode === "conservative") {
4718
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4719
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4720
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4721
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4722
- }
4723
- const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
4724
- const transferTx = commitment.toTransaction(inclusionProof);
4725
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4726
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4727
- transferTx: JSON.stringify(transferTx.toJSON()),
4728
- memo: request.memo
4729
- });
4730
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4731
- } else {
4732
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4733
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4734
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4735
- commitmentData: JSON.stringify(commitment.toJSON()),
4736
- memo: request.memo
4849
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
4850
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
4851
+ const commitment = directCommitments[i];
4852
+ const requestIdBytes = commitment.requestId;
4853
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4854
+ result.tokenTransfers.push({
4855
+ sourceTokenId: token.id,
4856
+ method: "direct",
4857
+ requestIdHex
4737
4858
  });
4738
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4739
- stClient.submitTransferCommitment(commitment).catch(
4740
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4741
- );
4859
+ await this.removeToken(token.id);
4742
4860
  }
4743
- const requestIdBytes = commitment.requestId;
4744
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4745
- result.tokenTransfers.push({
4746
- sourceTokenId: token.id,
4747
- method: "direct",
4748
- requestIdHex
4749
- });
4750
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4751
- await this.removeToken(token.id);
4861
+ this.log(`V6 combined transfer completed`);
4752
4862
  }
4753
4863
  result.status = "delivered";
4754
4864
  await this.save();
4755
4865
  await this.removeFromOutbox(result.id);
4756
4866
  result.status = "completed";
4867
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
4868
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
4869
+ id: tt.sourceTokenId,
4870
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
4871
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
4872
+ source: tt.method === "split" ? "split" : "direct"
4873
+ }));
4757
4874
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4758
4875
  await this.addToHistory({
4759
4876
  type: "SENT",
@@ -4766,7 +4883,8 @@ var PaymentsModule = class _PaymentsModule {
4766
4883
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4767
4884
  memo: request.memo,
4768
4885
  transferId: result.id,
4769
- tokenId: sentTokenId || void 0
4886
+ tokenId: sentTokenId || void 0,
4887
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4770
4888
  });
4771
4889
  this.deps.emitEvent("transfer:confirmed", result);
4772
4890
  return result;
@@ -4936,6 +5054,267 @@ var PaymentsModule = class _PaymentsModule {
4936
5054
  };
4937
5055
  }
4938
5056
  }
5057
+ // ===========================================================================
5058
+ // Shared Helpers for V5 and V6 Receiver Processing
5059
+ // ===========================================================================
5060
+ /**
5061
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
5062
+ * Returns the created UI token, or null if deduped.
5063
+ *
5064
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
5065
+ * The token is still added to the in-memory map for dedup; caller must call save().
5066
+ */
5067
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
5068
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
5069
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
5070
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5071
+ return null;
5072
+ }
5073
+ const registry = TokenRegistry.getInstance();
5074
+ const pendingData = {
5075
+ type: "v5_bundle",
5076
+ stage: "RECEIVED",
5077
+ bundleJson: JSON.stringify(bundle),
5078
+ senderPubkey,
5079
+ savedAt: Date.now(),
5080
+ attemptCount: 0
5081
+ };
5082
+ const uiToken = {
5083
+ id: deterministicId,
5084
+ coinId: bundle.coinId,
5085
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5086
+ name: registry.getName(bundle.coinId) || bundle.coinId,
5087
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
5088
+ amount: bundle.amount,
5089
+ status: "submitted",
5090
+ // UNCONFIRMED
5091
+ createdAt: Date.now(),
5092
+ updatedAt: Date.now(),
5093
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5094
+ };
5095
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5096
+ if (deferPersistence) {
5097
+ this.tokens.set(uiToken.id, uiToken);
5098
+ } else {
5099
+ await this.addToken(uiToken);
5100
+ await this.saveProcessedSplitGroupIds();
5101
+ }
5102
+ return uiToken;
5103
+ }
5104
+ /**
5105
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5106
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5107
+ * Returns the created UI token, or null if deduped/tombstoned.
5108
+ *
5109
+ * @param deferPersistence - If true, skip save() and commitment submission
5110
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5111
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5112
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5113
+ */
5114
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5115
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5116
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5117
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5118
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5119
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5120
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5121
+ return null;
5122
+ }
5123
+ if (nostrTokenId) {
5124
+ for (const existing of this.tokens.values()) {
5125
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5126
+ if (existingTokenId !== nostrTokenId) continue;
5127
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5128
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5129
+ console.log(
5130
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5131
+ );
5132
+ return null;
5133
+ }
5134
+ if (!skipGenesisDedup) {
5135
+ console.log(
5136
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5137
+ );
5138
+ return null;
5139
+ }
5140
+ }
5141
+ }
5142
+ const token = {
5143
+ id: crypto.randomUUID(),
5144
+ coinId: tokenInfo.coinId,
5145
+ symbol: tokenInfo.symbol,
5146
+ name: tokenInfo.name,
5147
+ decimals: tokenInfo.decimals,
5148
+ iconUrl: tokenInfo.iconUrl,
5149
+ amount: tokenInfo.amount,
5150
+ status: "submitted",
5151
+ // NOSTR-FIRST: unconfirmed until proof
5152
+ createdAt: Date.now(),
5153
+ updatedAt: Date.now(),
5154
+ sdkData
5155
+ };
5156
+ this.tokens.set(token.id, token);
5157
+ if (!deferPersistence) {
5158
+ await this.save();
5159
+ }
5160
+ try {
5161
+ const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
5162
+ const requestIdBytes = commitment.requestId;
5163
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5164
+ if (!deferPersistence) {
5165
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5166
+ if (stClient) {
5167
+ const response = await stClient.submitTransferCommitment(commitment);
5168
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5169
+ }
5170
+ }
5171
+ this.addProofPollingJob({
5172
+ tokenId: token.id,
5173
+ requestIdHex,
5174
+ commitmentJson: JSON.stringify(commitmentInput),
5175
+ startedAt: Date.now(),
5176
+ attemptCount: 0,
5177
+ lastAttemptAt: 0,
5178
+ onProofReceived: async (tokenId) => {
5179
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5180
+ }
5181
+ });
5182
+ } catch (err) {
5183
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5184
+ }
5185
+ return token;
5186
+ }
5187
+ // ===========================================================================
5188
+ // Combined Transfer V6 — Receiver
5189
+ // ===========================================================================
5190
+ /**
5191
+ * Process a received COMBINED_TRANSFER V6 bundle.
5192
+ *
5193
+ * Unpacks a single Nostr message into its component tokens:
5194
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5195
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5196
+ *
5197
+ * Emits ONE transfer:incoming event and records ONE history entry.
5198
+ */
5199
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5200
+ this.ensureInitialized();
5201
+ if (!this.loaded && this.loadedPromise) {
5202
+ await this.loadedPromise;
5203
+ }
5204
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5205
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5206
+ return;
5207
+ }
5208
+ console.log(
5209
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5210
+ );
5211
+ const allTokens = [];
5212
+ const tokenBreakdown = [];
5213
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5214
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5215
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5216
+ }));
5217
+ if (bundle.splitBundle) {
5218
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5219
+ if (splitToken) {
5220
+ allTokens.push(splitToken);
5221
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5222
+ } else {
5223
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5224
+ }
5225
+ }
5226
+ const directResults = await Promise.all(
5227
+ parsedDirectEntries.map(
5228
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5229
+ )
5230
+ );
5231
+ for (let i = 0; i < directResults.length; i++) {
5232
+ const token = directResults[i];
5233
+ if (token) {
5234
+ allTokens.push(token);
5235
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5236
+ } else {
5237
+ const entry = bundle.directTokens[i];
5238
+ console.warn(
5239
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5240
+ );
5241
+ }
5242
+ }
5243
+ if (allTokens.length === 0) {
5244
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5245
+ return;
5246
+ }
5247
+ this.processedCombinedTransferIds.add(bundle.transferId);
5248
+ const [senderInfo] = await Promise.all([
5249
+ this.resolveSenderInfo(senderPubkey),
5250
+ this.save(),
5251
+ this.saveProcessedCombinedTransferIds(),
5252
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5253
+ ]);
5254
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5255
+ if (stClient) {
5256
+ for (const { commitment } of parsedDirectEntries) {
5257
+ import_TransferCommitment4.TransferCommitment.fromJSON(commitment).then(
5258
+ (c) => stClient.submitTransferCommitment(c)
5259
+ ).catch(
5260
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5261
+ );
5262
+ }
5263
+ }
5264
+ this.deps.emitEvent("transfer:incoming", {
5265
+ id: bundle.transferId,
5266
+ senderPubkey,
5267
+ senderNametag: senderInfo.senderNametag,
5268
+ tokens: allTokens,
5269
+ memo: bundle.memo,
5270
+ receivedAt: Date.now()
5271
+ });
5272
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5273
+ await this.addToHistory({
5274
+ type: "RECEIVED",
5275
+ amount: actualAmount,
5276
+ coinId: bundle.coinId,
5277
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5278
+ timestamp: Date.now(),
5279
+ senderPubkey,
5280
+ ...senderInfo,
5281
+ memo: bundle.memo,
5282
+ transferId: bundle.transferId,
5283
+ tokenId: allTokens[0]?.id,
5284
+ tokenIds: tokenBreakdown
5285
+ });
5286
+ if (bundle.splitBundle) {
5287
+ this.resolveUnconfirmed().catch(() => {
5288
+ });
5289
+ this.scheduleResolveUnconfirmed();
5290
+ }
5291
+ }
5292
+ /**
5293
+ * Persist processed combined transfer IDs to KV storage.
5294
+ */
5295
+ async saveProcessedCombinedTransferIds() {
5296
+ const ids = Array.from(this.processedCombinedTransferIds);
5297
+ if (ids.length > 0) {
5298
+ await this.deps.storage.set(
5299
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5300
+ JSON.stringify(ids)
5301
+ );
5302
+ }
5303
+ }
5304
+ /**
5305
+ * Load processed combined transfer IDs from KV storage.
5306
+ */
5307
+ async loadProcessedCombinedTransferIds() {
5308
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5309
+ if (!data) return;
5310
+ try {
5311
+ const ids = JSON.parse(data);
5312
+ for (const id of ids) {
5313
+ this.processedCombinedTransferIds.add(id);
5314
+ }
5315
+ } catch {
5316
+ }
5317
+ }
4939
5318
  /**
4940
5319
  * Process a received INSTANT_SPLIT bundle.
4941
5320
  *
@@ -4959,36 +5338,10 @@ var PaymentsModule = class _PaymentsModule {
4959
5338
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
4960
5339
  }
4961
5340
  try {
4962
- const deterministicId = `v5split_${bundle.splitGroupId}`;
4963
- if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
4964
- console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5341
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5342
+ if (!uiToken) {
4965
5343
  return { success: true, durationMs: 0 };
4966
5344
  }
4967
- const registry = TokenRegistry.getInstance();
4968
- const pendingData = {
4969
- type: "v5_bundle",
4970
- stage: "RECEIVED",
4971
- bundleJson: JSON.stringify(bundle),
4972
- senderPubkey,
4973
- savedAt: Date.now(),
4974
- attemptCount: 0
4975
- };
4976
- const uiToken = {
4977
- id: deterministicId,
4978
- coinId: bundle.coinId,
4979
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4980
- name: registry.getName(bundle.coinId) || bundle.coinId,
4981
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
4982
- amount: bundle.amount,
4983
- status: "submitted",
4984
- // UNCONFIRMED
4985
- createdAt: Date.now(),
4986
- updatedAt: Date.now(),
4987
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4988
- };
4989
- await this.addToken(uiToken);
4990
- this.processedSplitGroupIds.add(bundle.splitGroupId);
4991
- await this.saveProcessedSplitGroupIds();
4992
5345
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
4993
5346
  await this.addToHistory({
4994
5347
  type: "RECEIVED",
@@ -4999,7 +5352,7 @@ var PaymentsModule = class _PaymentsModule {
4999
5352
  senderPubkey,
5000
5353
  ...senderInfo,
5001
5354
  memo,
5002
- tokenId: deterministicId
5355
+ tokenId: uiToken.id
5003
5356
  });
5004
5357
  this.deps.emitEvent("transfer:incoming", {
5005
5358
  id: bundle.splitGroupId,
@@ -5649,16 +6002,18 @@ var PaymentsModule = class _PaymentsModule {
5649
6002
  }
5650
6003
  /**
5651
6004
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5652
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
6005
+ * Excludes tokens with status 'spent' or 'invalid'.
6006
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
5653
6007
  */
5654
6008
  aggregateTokens(coinId) {
5655
6009
  const assetsMap = /* @__PURE__ */ new Map();
5656
6010
  for (const token of this.tokens.values()) {
5657
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6011
+ if (token.status === "spent" || token.status === "invalid") continue;
5658
6012
  if (coinId && token.coinId !== coinId) continue;
5659
6013
  const key = token.coinId;
5660
6014
  const amount = BigInt(token.amount);
5661
6015
  const isConfirmed = token.status === "confirmed";
6016
+ const isTransferring = token.status === "transferring";
5662
6017
  const existing = assetsMap.get(key);
5663
6018
  if (existing) {
5664
6019
  if (isConfirmed) {
@@ -5668,6 +6023,7 @@ var PaymentsModule = class _PaymentsModule {
5668
6023
  existing.unconfirmedAmount += amount;
5669
6024
  existing.unconfirmedTokenCount++;
5670
6025
  }
6026
+ if (isTransferring) existing.transferringTokenCount++;
5671
6027
  } else {
5672
6028
  assetsMap.set(key, {
5673
6029
  coinId: token.coinId,
@@ -5678,7 +6034,8 @@ var PaymentsModule = class _PaymentsModule {
5678
6034
  confirmedAmount: isConfirmed ? amount : 0n,
5679
6035
  unconfirmedAmount: isConfirmed ? 0n : amount,
5680
6036
  confirmedTokenCount: isConfirmed ? 1 : 0,
5681
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6037
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6038
+ transferringTokenCount: isTransferring ? 1 : 0
5682
6039
  });
5683
6040
  }
5684
6041
  }
@@ -5696,6 +6053,7 @@ var PaymentsModule = class _PaymentsModule {
5696
6053
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5697
6054
  confirmedTokenCount: raw.confirmedTokenCount,
5698
6055
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6056
+ transferringTokenCount: raw.transferringTokenCount,
5699
6057
  priceUsd: null,
5700
6058
  priceEur: null,
5701
6059
  change24h: null,
@@ -7093,7 +7451,7 @@ var PaymentsModule = class _PaymentsModule {
7093
7451
  /**
7094
7452
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7095
7453
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7096
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7454
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7097
7455
  */
7098
7456
  async handleCommitmentOnlyTransfer(transfer, payload) {
7099
7457
  try {
@@ -7103,41 +7461,22 @@ var PaymentsModule = class _PaymentsModule {
7103
7461
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7104
7462
  return;
7105
7463
  }
7106
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7107
- const token = {
7108
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7109
- coinId: tokenInfo.coinId,
7110
- symbol: tokenInfo.symbol,
7111
- name: tokenInfo.name,
7112
- decimals: tokenInfo.decimals,
7113
- iconUrl: tokenInfo.iconUrl,
7114
- amount: tokenInfo.amount,
7115
- status: "submitted",
7116
- // NOSTR-FIRST: unconfirmed until proof
7117
- createdAt: Date.now(),
7118
- updatedAt: Date.now(),
7119
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7120
- };
7121
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7122
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7123
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7124
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7125
- return;
7126
- }
7127
- this.tokens.set(token.id, token);
7128
- console.log(`[Payments][DEBUG] NOSTR-FIRST: saving token id=${token.id.slice(0, 16)} status=${token.status} sdkData.length=${token.sdkData?.length}`);
7129
- await this.save();
7130
- console.log(`[Payments][DEBUG] NOSTR-FIRST: save() completed, tokens.size=${this.tokens.size}`);
7464
+ const token = await this.saveCommitmentOnlyToken(
7465
+ sourceTokenInput,
7466
+ commitmentInput,
7467
+ transfer.senderTransportPubkey
7468
+ );
7469
+ if (!token) return;
7131
7470
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7132
- const incomingTransfer = {
7471
+ this.deps.emitEvent("transfer:incoming", {
7133
7472
  id: transfer.id,
7134
7473
  senderPubkey: transfer.senderTransportPubkey,
7135
7474
  senderNametag: senderInfo.senderNametag,
7136
7475
  tokens: [token],
7137
7476
  memo: payload.memo,
7138
7477
  receivedAt: transfer.timestamp
7139
- };
7140
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7478
+ });
7479
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7141
7480
  await this.addToHistory({
7142
7481
  type: "RECEIVED",
7143
7482
  amount: token.amount,
@@ -7149,29 +7488,6 @@ var PaymentsModule = class _PaymentsModule {
7149
7488
  memo: payload.memo,
7150
7489
  tokenId: nostrTokenId || token.id
7151
7490
  });
7152
- try {
7153
- const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
7154
- const requestIdBytes = commitment.requestId;
7155
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7156
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7157
- if (stClient) {
7158
- const response = await stClient.submitTransferCommitment(commitment);
7159
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7160
- }
7161
- this.addProofPollingJob({
7162
- tokenId: token.id,
7163
- requestIdHex,
7164
- commitmentJson: JSON.stringify(commitmentInput),
7165
- startedAt: Date.now(),
7166
- attemptCount: 0,
7167
- lastAttemptAt: 0,
7168
- onProofReceived: async (tokenId) => {
7169
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7170
- }
7171
- });
7172
- } catch (err) {
7173
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7174
- }
7175
7491
  } catch (error) {
7176
7492
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7177
7493
  }
@@ -7290,6 +7606,28 @@ var PaymentsModule = class _PaymentsModule {
7290
7606
  try {
7291
7607
  const payload = transfer.payload;
7292
7608
  console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
7609
+ let combinedBundle = null;
7610
+ if (isCombinedTransferBundleV6(payload)) {
7611
+ combinedBundle = payload;
7612
+ } else if (payload.token) {
7613
+ try {
7614
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7615
+ if (isCombinedTransferBundleV6(inner)) {
7616
+ combinedBundle = inner;
7617
+ }
7618
+ } catch {
7619
+ }
7620
+ }
7621
+ if (combinedBundle) {
7622
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
7623
+ try {
7624
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
7625
+ this.log("COMBINED_TRANSFER V6 processed successfully");
7626
+ } catch (err) {
7627
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
7628
+ }
7629
+ return;
7630
+ }
7293
7631
  let instantBundle = null;
7294
7632
  if (isInstantSplitBundle(payload)) {
7295
7633
  instantBundle = payload;
@@ -7441,17 +7779,19 @@ var PaymentsModule = class _PaymentsModule {
7441
7779
  memo: payload.memo,
7442
7780
  tokenId: incomingTokenId || token.id
7443
7781
  });
7782
+ const incomingTransfer = {
7783
+ id: transfer.id,
7784
+ senderPubkey: transfer.senderTransportPubkey,
7785
+ senderNametag: senderInfo.senderNametag,
7786
+ tokens: [token],
7787
+ memo: payload.memo,
7788
+ receivedAt: transfer.timestamp
7789
+ };
7790
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
7791
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7792
+ } else {
7793
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7444
7794
  }
7445
- const incomingTransfer = {
7446
- id: transfer.id,
7447
- senderPubkey: transfer.senderTransportPubkey,
7448
- senderNametag: senderInfo.senderNametag,
7449
- tokens: [token],
7450
- memo: payload.memo,
7451
- receivedAt: transfer.timestamp
7452
- };
7453
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7454
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7455
7795
  } catch (error) {
7456
7796
  console.error("[Payments] Failed to process incoming transfer:", error);
7457
7797
  }