@unicitylabs/sphere-sdk 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/connect/index.cjs +5 -1
  2. package/dist/connect/index.cjs.map +1 -1
  3. package/dist/connect/index.js +5 -1
  4. package/dist/connect/index.js.map +1 -1
  5. package/dist/core/index.cjs +813 -309
  6. package/dist/core/index.cjs.map +1 -1
  7. package/dist/core/index.d.cts +71 -2
  8. package/dist/core/index.d.ts +71 -2
  9. package/dist/core/index.js +813 -309
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/impl/browser/connect/index.cjs +5 -1
  12. package/dist/impl/browser/connect/index.cjs.map +1 -1
  13. package/dist/impl/browser/connect/index.js +5 -1
  14. package/dist/impl/browser/connect/index.js.map +1 -1
  15. package/dist/impl/browser/index.cjs +7 -2
  16. package/dist/impl/browser/index.cjs.map +1 -1
  17. package/dist/impl/browser/index.js +7 -2
  18. package/dist/impl/browser/index.js.map +1 -1
  19. package/dist/impl/browser/ipfs.cjs +5 -1
  20. package/dist/impl/browser/ipfs.cjs.map +1 -1
  21. package/dist/impl/browser/ipfs.js +5 -1
  22. package/dist/impl/browser/ipfs.js.map +1 -1
  23. package/dist/impl/nodejs/connect/index.cjs +5 -1
  24. package/dist/impl/nodejs/connect/index.cjs.map +1 -1
  25. package/dist/impl/nodejs/connect/index.js +5 -1
  26. package/dist/impl/nodejs/connect/index.js.map +1 -1
  27. package/dist/impl/nodejs/index.cjs +7 -2
  28. package/dist/impl/nodejs/index.cjs.map +1 -1
  29. package/dist/impl/nodejs/index.d.cts +6 -0
  30. package/dist/impl/nodejs/index.d.ts +6 -0
  31. package/dist/impl/nodejs/index.js +7 -2
  32. package/dist/impl/nodejs/index.js.map +1 -1
  33. package/dist/index.cjs +815 -309
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +144 -3
  36. package/dist/index.d.ts +144 -3
  37. package/dist/index.js +814 -309
  38. package/dist/index.js.map +1 -1
  39. package/dist/l1/index.cjs +5 -1
  40. package/dist/l1/index.cjs.map +1 -1
  41. package/dist/l1/index.js +5 -1
  42. package/dist/l1/index.js.map +1 -1
  43. package/package.json +1 -1
@@ -103,7 +103,11 @@ var init_constants = __esm({
103
103
  /** Group chat: members for this address */
104
104
  GROUP_CHAT_MEMBERS: "group_chat_members",
105
105
  /** Group chat: processed event IDs for deduplication */
106
- GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events"
106
+ GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
107
+ /** Processed V5 split group IDs for Nostr re-delivery dedup */
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"
107
111
  };
108
112
  STORAGE_KEYS = {
109
113
  ...STORAGE_KEYS_GLOBAL,
@@ -3379,14 +3383,149 @@ var InstantSplitExecutor = class {
3379
3383
  this.devMode = config.devMode ?? false;
3380
3384
  }
3381
3385
  /**
3382
- * Execute an instant split transfer with V5 optimized flow.
3386
+ * Build a V5 split bundle WITHOUT sending it via transport.
3383
3387
  *
3384
- * Critical path (~2.3s):
3388
+ * Steps 1-5 of the V5 flow:
3385
3389
  * 1. Create and submit burn commitment
3386
3390
  * 2. Wait for burn proof
3387
3391
  * 3. Create mint commitments with SplitMintReason
3388
3392
  * 4. Create transfer commitment (no mint proof needed)
3389
- * 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.
3390
3529
  *
3391
3530
  * @param tokenToSplit - The SDK token to split
3392
3531
  * @param splitAmount - Amount to send to recipient
@@ -3400,117 +3539,19 @@ var InstantSplitExecutor = class {
3400
3539
  */
3401
3540
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3402
3541
  const startTime = performance.now();
3403
- const splitGroupId = crypto.randomUUID();
3404
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3405
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3406
3542
  try {
3407
- const coinId = new import_CoinId3.CoinId(fromHex2(coinIdHex));
3408
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3409
- const recipientTokenId = new import_TokenId3.TokenId(await sha2563(seedString));
3410
- const senderTokenId = new import_TokenId3.TokenId(await sha2563(seedString + "_sender"));
3411
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3412
- const senderSalt = await sha2563(seedString + "_sender_salt");
3413
- const senderAddressRef = await import_UnmaskedPredicateReference2.UnmaskedPredicateReference.create(
3414
- tokenToSplit.type,
3415
- this.signingService.algorithm,
3416
- this.signingService.publicKey,
3417
- import_HashAlgorithm3.HashAlgorithm.SHA256
3418
- );
3419
- const senderAddress = await senderAddressRef.toAddress();
3420
- const builder = new import_TokenSplitBuilder2.TokenSplitBuilder();
3421
- const coinDataA = import_TokenCoinData2.TokenCoinData.create([[coinId, splitAmount]]);
3422
- builder.createToken(
3423
- recipientTokenId,
3424
- tokenToSplit.type,
3425
- new Uint8Array(0),
3426
- coinDataA,
3427
- senderAddress,
3428
- // Mint to sender first, then transfer
3429
- recipientSalt,
3430
- null
3431
- );
3432
- const coinDataB = import_TokenCoinData2.TokenCoinData.create([[coinId, remainderAmount]]);
3433
- builder.createToken(
3434
- senderTokenId,
3435
- tokenToSplit.type,
3436
- new Uint8Array(0),
3437
- coinDataB,
3438
- senderAddress,
3439
- senderSalt,
3440
- null
3441
- );
3442
- const split = await builder.build(tokenToSplit);
3443
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3444
- const burnSalt = await sha2563(seedString + "_burn_salt");
3445
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3446
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3447
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3448
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3449
- }
3450
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3451
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await (0, import_InclusionProofUtils3.waitInclusionProof)(this.trustBase, this.client, burnCommitment);
3452
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3453
- const burnDuration = performance.now() - startTime;
3454
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3455
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3456
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3457
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3458
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3459
- const senderIdHex = toHex2(senderTokenId.bytes);
3460
- const recipientMintCommitment = mintCommitments.find(
3461
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3462
- );
3463
- const senderMintCommitment = mintCommitments.find(
3464
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3465
- );
3466
- if (!recipientMintCommitment || !senderMintCommitment) {
3467
- throw new Error("Failed to find expected mint commitments");
3468
- }
3469
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3470
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3471
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3472
- recipientMintCommitment.transactionData,
3543
+ const buildResult = await this.buildSplitBundle(
3544
+ tokenToSplit,
3545
+ splitAmount,
3546
+ remainderAmount,
3547
+ coinIdHex,
3473
3548
  recipientAddress,
3474
- transferSalt,
3475
- this.signingService
3549
+ options
3476
3550
  );
3477
- const mintedPredicate = await import_UnmaskedPredicate3.UnmaskedPredicate.create(
3478
- recipientTokenId,
3479
- tokenToSplit.type,
3480
- this.signingService,
3481
- import_HashAlgorithm3.HashAlgorithm.SHA256,
3482
- recipientSalt
3483
- );
3484
- const mintedState = new import_TokenState3.TokenState(mintedPredicate, null);
3485
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3551
+ console.log("[InstantSplit] Sending via transport...");
3486
3552
  const senderPubkey = toHex2(this.signingService.publicKey);
3487
- let nametagTokenJson;
3488
- const recipientAddressStr = recipientAddress.toString();
3489
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3490
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3491
- }
3492
- const bundle = {
3493
- version: "5.0",
3494
- type: "INSTANT_SPLIT",
3495
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3496
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3497
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3498
- amount: splitAmount.toString(),
3499
- coinId: coinIdHex,
3500
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3501
- splitGroupId,
3502
- senderPubkey,
3503
- recipientSaltHex: toHex2(recipientSalt),
3504
- transferSaltHex: toHex2(transferSalt),
3505
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3506
- finalRecipientStateJson: "",
3507
- // Recipient creates their own
3508
- recipientAddressJson: recipientAddressStr,
3509
- nametagTokenJson
3510
- };
3511
- console.log("[InstantSplit] Step 6: Sending via transport...");
3512
3553
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3513
- token: JSON.stringify(bundle),
3554
+ token: JSON.stringify(buildResult.bundle),
3514
3555
  proof: null,
3515
3556
  // Proof is included in the bundle
3516
3557
  memo: options?.memo,
@@ -3521,25 +3562,13 @@ var InstantSplitExecutor = class {
3521
3562
  const criticalPathDuration = performance.now() - startTime;
3522
3563
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3523
3564
  options?.onNostrDelivered?.(nostrEventId);
3524
- let backgroundPromise;
3525
- if (!options?.skipBackground) {
3526
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3527
- signingService: this.signingService,
3528
- tokenType: tokenToSplit.type,
3529
- coinId,
3530
- senderTokenId,
3531
- senderSalt,
3532
- onProgress: options?.onBackgroundProgress,
3533
- onChangeTokenCreated: options?.onChangeTokenCreated,
3534
- onStorageSync: options?.onStorageSync
3535
- });
3536
- }
3565
+ const backgroundPromise = buildResult.startBackground();
3537
3566
  return {
3538
3567
  success: true,
3539
3568
  nostrEventId,
3540
- splitGroupId,
3569
+ splitGroupId: buildResult.splitGroupId,
3541
3570
  criticalPathDurationMs: criticalPathDuration,
3542
- backgroundStarted: !options?.skipBackground,
3571
+ backgroundStarted: true,
3543
3572
  backgroundPromise
3544
3573
  };
3545
3574
  } catch (error) {
@@ -3548,7 +3577,6 @@ var InstantSplitExecutor = class {
3548
3577
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3549
3578
  return {
3550
3579
  success: false,
3551
- splitGroupId,
3552
3580
  criticalPathDurationMs: duration,
3553
3581
  error: errorMessage,
3554
3582
  backgroundStarted: false
@@ -3753,6 +3781,11 @@ function isInstantSplitBundleV4(obj) {
3753
3781
  function isInstantSplitBundleV5(obj) {
3754
3782
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3755
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
+ }
3756
3789
 
3757
3790
  // modules/payments/InstantSplitProcessor.ts
3758
3791
  function fromHex3(hex) {
@@ -4395,6 +4428,19 @@ var PaymentsModule = class _PaymentsModule {
4395
4428
  // Poll every 2s
4396
4429
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
4397
4430
  // Max 30 attempts (~60s)
4431
+ // Periodic retry for resolveUnconfirmed (V5 lazy finalization)
4432
+ resolveUnconfirmedTimer = null;
4433
+ static RESOLVE_UNCONFIRMED_INTERVAL_MS = 1e4;
4434
+ // Retry every 10s
4435
+ // Guard: ensure load() completes before processing incoming bundles
4436
+ loadedPromise = null;
4437
+ loaded = false;
4438
+ // Persistent dedup: tracks splitGroupIds that have been fully processed.
4439
+ // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4440
+ // even when the confirmed token's in-memory ID differs from v5split_{id}.
4441
+ processedSplitGroupIds = /* @__PURE__ */ new Set();
4442
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4443
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4398
4444
  // Storage event subscriptions (push-based sync)
4399
4445
  storageEventUnsubscribers = [];
4400
4446
  syncDebounceTimer = null;
@@ -4480,31 +4526,53 @@ var PaymentsModule = class _PaymentsModule {
4480
4526
  */
4481
4527
  async load() {
4482
4528
  this.ensureInitialized();
4483
- await TokenRegistry.waitForReady();
4484
- const providers = this.getTokenStorageProviders();
4485
- for (const [id, provider] of providers) {
4486
- try {
4487
- const result = await provider.load();
4488
- if (result.success && result.data) {
4489
- this.loadFromStorageData(result.data);
4490
- this.log(`Loaded metadata from provider ${id}`);
4491
- break;
4529
+ const doLoad = async () => {
4530
+ await TokenRegistry.waitForReady();
4531
+ const providers = this.getTokenStorageProviders();
4532
+ for (const [id, provider] of providers) {
4533
+ try {
4534
+ const result = await provider.load();
4535
+ if (result.success && result.data) {
4536
+ this.loadFromStorageData(result.data);
4537
+ this.log(`Loaded metadata from provider ${id}`);
4538
+ break;
4539
+ }
4540
+ } catch (err) {
4541
+ console.error(`[Payments] Failed to load from provider ${id}:`, err);
4492
4542
  }
4493
- } catch (err) {
4494
- console.error(`[Payments] Failed to load from provider ${id}:`, err);
4495
4543
  }
4496
- }
4497
- await this.loadPendingV5Tokens();
4498
- await this.loadHistory();
4499
- const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4500
- if (pending2) {
4501
- const transfers = JSON.parse(pending2);
4502
- for (const transfer of transfers) {
4503
- this.pendingTransfers.set(transfer.id, transfer);
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
+ }
4504
4555
  }
4505
- }
4556
+ const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4557
+ console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4558
+ await this.loadPendingV5Tokens();
4559
+ await this.loadProcessedSplitGroupIds();
4560
+ await this.loadProcessedCombinedTransferIds();
4561
+ await this.loadHistory();
4562
+ const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4563
+ if (pending2) {
4564
+ const transfers = JSON.parse(pending2);
4565
+ for (const transfer of transfers) {
4566
+ this.pendingTransfers.set(transfer.id, transfer);
4567
+ }
4568
+ }
4569
+ this.loaded = true;
4570
+ };
4571
+ this.loadedPromise = doLoad();
4572
+ await this.loadedPromise;
4506
4573
  this.resolveUnconfirmed().catch(() => {
4507
4574
  });
4575
+ this.scheduleResolveUnconfirmed();
4508
4576
  }
4509
4577
  /**
4510
4578
  * Cleanup all subscriptions, polling jobs, and pending resolvers.
@@ -4523,6 +4591,7 @@ var PaymentsModule = class _PaymentsModule {
4523
4591
  this.paymentRequestResponseHandlers.clear();
4524
4592
  this.stopProofPolling();
4525
4593
  this.proofPollingJobs.clear();
4594
+ this.stopResolveUnconfirmedPolling();
4526
4595
  for (const [, resolver] of this.pendingResponseResolvers) {
4527
4596
  clearTimeout(resolver.timeout);
4528
4597
  resolver.reject(new Error("Module destroyed"));
@@ -4580,12 +4649,13 @@ var PaymentsModule = class _PaymentsModule {
4580
4649
  token.status = "transferring";
4581
4650
  this.tokens.set(token.id, token);
4582
4651
  }
4652
+ await this.save();
4583
4653
  await this.saveToOutbox(result, recipientPubkey);
4584
4654
  result.status = "submitted";
4585
4655
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4586
4656
  const transferMode = request.transferMode ?? "instant";
4587
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4588
- if (transferMode === "conservative") {
4657
+ if (transferMode === "conservative") {
4658
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4589
4659
  this.log("Executing conservative split...");
4590
4660
  const splitExecutor = new TokenSplitExecutor({
4591
4661
  stateTransitionClient: stClient,
@@ -4629,27 +4699,59 @@ var PaymentsModule = class _PaymentsModule {
4629
4699
  requestIdHex: splitRequestIdHex
4630
4700
  });
4631
4701
  this.log(`Conservative split transfer completed`);
4632
- } else {
4633
- this.log("Executing instant split...");
4634
- 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...");
4635
4736
  const executor = new InstantSplitExecutor({
4636
4737
  stateTransitionClient: stClient,
4637
4738
  trustBase,
4638
4739
  signingService,
4639
4740
  devMode
4640
4741
  });
4641
- const instantResult = await executor.executeSplitInstant(
4742
+ builtSplit = await executor.buildSplitBundle(
4642
4743
  splitPlan.tokenToSplit.sdkToken,
4643
4744
  splitPlan.splitAmount,
4644
4745
  splitPlan.remainderAmount,
4645
4746
  splitPlan.coinId,
4646
4747
  recipientAddress,
4647
- this.deps.transport,
4648
- recipientPubkey,
4649
4748
  {
4650
4749
  memo: request.memo,
4651
4750
  onChangeTokenCreated: async (changeToken) => {
4652
4751
  const changeTokenData = changeToken.toJSON();
4752
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4753
+ this.tokens.delete(changeTokenPlaceholderId);
4754
+ }
4653
4755
  const uiToken = {
4654
4756
  id: crypto.randomUUID(),
4655
4757
  coinId: request.coinId,
@@ -4672,65 +4774,103 @@ var PaymentsModule = class _PaymentsModule {
4672
4774
  }
4673
4775
  }
4674
4776
  );
4675
- if (!instantResult.success) {
4676
- throw new Error(instantResult.error || "Instant split failed");
4677
- }
4678
- if (instantResult.backgroundPromise) {
4679
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4680
- }
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) {
4681
4842
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4682
4843
  result.tokenTransfers.push({
4683
4844
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4684
4845
  method: "split",
4685
- splitGroupId: instantResult.splitGroupId,
4686
- nostrEventId: instantResult.nostrEventId
4846
+ splitGroupId: builtSplit.splitGroupId
4687
4847
  });
4688
- this.log(`Instant split transfer completed`);
4689
4848
  }
4690
- }
4691
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4692
- const token = tokenWithAmount.uiToken;
4693
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4694
- if (transferMode === "conservative") {
4695
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4696
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4697
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4698
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4699
- }
4700
- const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
4701
- const transferTx = commitment.toTransaction(inclusionProof);
4702
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4703
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4704
- transferTx: JSON.stringify(transferTx.toJSON()),
4705
- memo: request.memo
4706
- });
4707
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4708
- } else {
4709
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4710
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4711
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4712
- commitmentData: JSON.stringify(commitment.toJSON()),
4713
- 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
4714
4858
  });
4715
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4716
- stClient.submitTransferCommitment(commitment).catch(
4717
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4718
- );
4859
+ await this.removeToken(token.id);
4719
4860
  }
4720
- const requestIdBytes = commitment.requestId;
4721
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4722
- result.tokenTransfers.push({
4723
- sourceTokenId: token.id,
4724
- method: "direct",
4725
- requestIdHex
4726
- });
4727
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4728
- await this.removeToken(token.id);
4861
+ this.log(`V6 combined transfer completed`);
4729
4862
  }
4730
4863
  result.status = "delivered";
4731
4864
  await this.save();
4732
4865
  await this.removeFromOutbox(result.id);
4733
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
+ }));
4734
4874
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4735
4875
  await this.addToHistory({
4736
4876
  type: "SENT",
@@ -4743,7 +4883,8 @@ var PaymentsModule = class _PaymentsModule {
4743
4883
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4744
4884
  memo: request.memo,
4745
4885
  transferId: result.id,
4746
- tokenId: sentTokenId || void 0
4886
+ tokenId: sentTokenId || void 0,
4887
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4747
4888
  });
4748
4889
  this.deps.emitEvent("transfer:confirmed", result);
4749
4890
  return result;
@@ -4913,6 +5054,267 @@ var PaymentsModule = class _PaymentsModule {
4913
5054
  };
4914
5055
  }
4915
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
+ }
4916
5318
  /**
4917
5319
  * Process a received INSTANT_SPLIT bundle.
4918
5320
  *
@@ -4929,39 +5331,17 @@ var PaymentsModule = class _PaymentsModule {
4929
5331
  */
4930
5332
  async processInstantSplitBundle(bundle, senderPubkey, memo) {
4931
5333
  this.ensureInitialized();
5334
+ if (!this.loaded && this.loadedPromise) {
5335
+ await this.loadedPromise;
5336
+ }
4932
5337
  if (!isInstantSplitBundleV5(bundle)) {
4933
5338
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
4934
5339
  }
4935
5340
  try {
4936
- const deterministicId = `v5split_${bundle.splitGroupId}`;
4937
- if (this.tokens.has(deterministicId)) {
4938
- this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
5341
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5342
+ if (!uiToken) {
4939
5343
  return { success: true, durationMs: 0 };
4940
5344
  }
4941
- const registry = TokenRegistry.getInstance();
4942
- const pendingData = {
4943
- type: "v5_bundle",
4944
- stage: "RECEIVED",
4945
- bundleJson: JSON.stringify(bundle),
4946
- senderPubkey,
4947
- savedAt: Date.now(),
4948
- attemptCount: 0
4949
- };
4950
- const uiToken = {
4951
- id: deterministicId,
4952
- coinId: bundle.coinId,
4953
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4954
- name: registry.getName(bundle.coinId) || bundle.coinId,
4955
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
4956
- amount: bundle.amount,
4957
- status: "submitted",
4958
- // UNCONFIRMED
4959
- createdAt: Date.now(),
4960
- updatedAt: Date.now(),
4961
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4962
- };
4963
- await this.addToken(uiToken);
4964
- this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
4965
5345
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
4966
5346
  await this.addToHistory({
4967
5347
  type: "RECEIVED",
@@ -4972,7 +5352,7 @@ var PaymentsModule = class _PaymentsModule {
4972
5352
  senderPubkey,
4973
5353
  ...senderInfo,
4974
5354
  memo,
4975
- tokenId: deterministicId
5355
+ tokenId: uiToken.id
4976
5356
  });
4977
5357
  this.deps.emitEvent("transfer:incoming", {
4978
5358
  id: bundle.splitGroupId,
@@ -4985,6 +5365,7 @@ var PaymentsModule = class _PaymentsModule {
4985
5365
  await this.save();
4986
5366
  this.resolveUnconfirmed().catch(() => {
4987
5367
  });
5368
+ this.scheduleResolveUnconfirmed();
4988
5369
  return { success: true, durationMs: 0 };
4989
5370
  } catch (error) {
4990
5371
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -5621,16 +6002,18 @@ var PaymentsModule = class _PaymentsModule {
5621
6002
  }
5622
6003
  /**
5623
6004
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5624
- * 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").
5625
6007
  */
5626
6008
  aggregateTokens(coinId) {
5627
6009
  const assetsMap = /* @__PURE__ */ new Map();
5628
6010
  for (const token of this.tokens.values()) {
5629
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
6011
+ if (token.status === "spent" || token.status === "invalid") continue;
5630
6012
  if (coinId && token.coinId !== coinId) continue;
5631
6013
  const key = token.coinId;
5632
6014
  const amount = BigInt(token.amount);
5633
6015
  const isConfirmed = token.status === "confirmed";
6016
+ const isTransferring = token.status === "transferring";
5634
6017
  const existing = assetsMap.get(key);
5635
6018
  if (existing) {
5636
6019
  if (isConfirmed) {
@@ -5640,6 +6023,7 @@ var PaymentsModule = class _PaymentsModule {
5640
6023
  existing.unconfirmedAmount += amount;
5641
6024
  existing.unconfirmedTokenCount++;
5642
6025
  }
6026
+ if (isTransferring) existing.transferringTokenCount++;
5643
6027
  } else {
5644
6028
  assetsMap.set(key, {
5645
6029
  coinId: token.coinId,
@@ -5650,7 +6034,8 @@ var PaymentsModule = class _PaymentsModule {
5650
6034
  confirmedAmount: isConfirmed ? amount : 0n,
5651
6035
  unconfirmedAmount: isConfirmed ? 0n : amount,
5652
6036
  confirmedTokenCount: isConfirmed ? 1 : 0,
5653
- unconfirmedTokenCount: isConfirmed ? 0 : 1
6037
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
6038
+ transferringTokenCount: isTransferring ? 1 : 0
5654
6039
  });
5655
6040
  }
5656
6041
  }
@@ -5668,6 +6053,7 @@ var PaymentsModule = class _PaymentsModule {
5668
6053
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5669
6054
  confirmedTokenCount: raw.confirmedTokenCount,
5670
6055
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
6056
+ transferringTokenCount: raw.transferringTokenCount,
5671
6057
  priceUsd: null,
5672
6058
  priceEur: null,
5673
6059
  change24h: null,
@@ -5731,28 +6117,70 @@ var PaymentsModule = class _PaymentsModule {
5731
6117
  };
5732
6118
  const stClient = this.deps.oracle.getStateTransitionClient?.();
5733
6119
  const trustBase = this.deps.oracle.getTrustBase?.();
5734
- if (!stClient || !trustBase) return result;
6120
+ if (!stClient || !trustBase) {
6121
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: EARLY EXIT \u2014 stClient=${!!stClient} trustBase=${!!trustBase}`);
6122
+ return result;
6123
+ }
5735
6124
  const signingService = await this.createSigningService();
6125
+ const submittedCount = Array.from(this.tokens.values()).filter((t) => t.status === "submitted").length;
6126
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: ${submittedCount} submitted token(s) to process`);
5736
6127
  for (const [tokenId, token] of this.tokens) {
5737
6128
  if (token.status !== "submitted") continue;
5738
6129
  const pending2 = this.parsePendingFinalization(token.sdkData);
5739
6130
  if (!pending2) {
6131
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 16)}: no pending finalization metadata, skipping`);
5740
6132
  result.stillPending++;
5741
6133
  continue;
5742
6134
  }
5743
6135
  if (pending2.type === "v5_bundle") {
6136
+ console.log(`[V5-RESOLVE] Processing ${tokenId.slice(0, 16)}... stage=${pending2.stage} attempt=${pending2.attemptCount}`);
5744
6137
  const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
6138
+ console.log(`[V5-RESOLVE] Result for ${tokenId.slice(0, 16)}...: ${progress} (stage now: ${pending2.stage})`);
5745
6139
  result.details.push({ tokenId, stage: pending2.stage, status: progress });
5746
6140
  if (progress === "resolved") result.resolved++;
5747
6141
  else if (progress === "failed") result.failed++;
5748
6142
  else result.stillPending++;
5749
6143
  }
5750
6144
  }
5751
- if (result.resolved > 0 || result.failed > 0) {
6145
+ if (result.resolved > 0 || result.failed > 0 || result.stillPending > 0) {
6146
+ console.log(`[V5-RESOLVE] Saving: resolved=${result.resolved} failed=${result.failed} stillPending=${result.stillPending}`);
5752
6147
  await this.save();
5753
6148
  }
5754
6149
  return result;
5755
6150
  }
6151
+ /**
6152
+ * Start a periodic interval that retries resolveUnconfirmed() until all
6153
+ * tokens are confirmed or failed. Stops automatically when nothing is
6154
+ * pending and is cleaned up by destroy().
6155
+ */
6156
+ scheduleResolveUnconfirmed() {
6157
+ if (this.resolveUnconfirmedTimer) return;
6158
+ const hasUnconfirmed = Array.from(this.tokens.values()).some(
6159
+ (t) => t.status === "submitted"
6160
+ );
6161
+ if (!hasUnconfirmed) {
6162
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: no submitted tokens, not starting timer`);
6163
+ return;
6164
+ }
6165
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: starting periodic retry (every ${_PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS}ms)`);
6166
+ this.resolveUnconfirmedTimer = setInterval(async () => {
6167
+ try {
6168
+ const result = await this.resolveUnconfirmed();
6169
+ if (result.stillPending === 0) {
6170
+ console.log(`[V5-RESOLVE] All tokens resolved, stopping periodic retry`);
6171
+ this.stopResolveUnconfirmedPolling();
6172
+ }
6173
+ } catch (err) {
6174
+ console.log(`[V5-RESOLVE] Periodic retry error:`, err);
6175
+ }
6176
+ }, _PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS);
6177
+ }
6178
+ stopResolveUnconfirmedPolling() {
6179
+ if (this.resolveUnconfirmedTimer) {
6180
+ clearInterval(this.resolveUnconfirmedTimer);
6181
+ this.resolveUnconfirmedTimer = null;
6182
+ }
6183
+ }
5756
6184
  // ===========================================================================
5757
6185
  // Private - V5 Lazy Resolution Helpers
5758
6186
  // ===========================================================================
@@ -5765,10 +6193,12 @@ var PaymentsModule = class _PaymentsModule {
5765
6193
  pending2.lastAttemptAt = Date.now();
5766
6194
  try {
5767
6195
  if (pending2.stage === "RECEIVED") {
6196
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: RECEIVED \u2192 submitting mint commitment...`);
5768
6197
  const mintDataJson = JSON.parse(bundle.recipientMintData);
5769
6198
  const mintData = await import_MintTransactionData3.MintTransactionData.fromJSON(mintDataJson);
5770
6199
  const mintCommitment = await import_MintCommitment3.MintCommitment.create(mintData);
5771
6200
  const mintResponse = await stClient.submitMintCommitment(mintCommitment);
6201
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint response status=${mintResponse.status}`);
5772
6202
  if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
5773
6203
  throw new Error(`Mint submission failed: ${mintResponse.status}`);
5774
6204
  }
@@ -5776,22 +6206,27 @@ var PaymentsModule = class _PaymentsModule {
5776
6206
  this.updatePendingFinalization(token, pending2);
5777
6207
  }
5778
6208
  if (pending2.stage === "MINT_SUBMITTED") {
6209
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_SUBMITTED \u2192 checking mint proof...`);
5779
6210
  const mintDataJson = JSON.parse(bundle.recipientMintData);
5780
6211
  const mintData = await import_MintTransactionData3.MintTransactionData.fromJSON(mintDataJson);
5781
6212
  const mintCommitment = await import_MintCommitment3.MintCommitment.create(mintData);
5782
6213
  const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
5783
6214
  if (!proof) {
6215
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof not yet available, staying MINT_SUBMITTED`);
5784
6216
  this.updatePendingFinalization(token, pending2);
5785
6217
  return "pending";
5786
6218
  }
6219
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof obtained!`);
5787
6220
  pending2.mintProofJson = JSON.stringify(proof);
5788
6221
  pending2.stage = "MINT_PROVEN";
5789
6222
  this.updatePendingFinalization(token, pending2);
5790
6223
  }
5791
6224
  if (pending2.stage === "MINT_PROVEN") {
6225
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_PROVEN \u2192 submitting transfer commitment...`);
5792
6226
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5793
6227
  const transferCommitment = await import_TransferCommitment4.TransferCommitment.fromJSON(transferCommitmentJson);
5794
6228
  const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
6229
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer response status=${transferResponse.status}`);
5795
6230
  if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
5796
6231
  throw new Error(`Transfer submission failed: ${transferResponse.status}`);
5797
6232
  }
@@ -5799,13 +6234,16 @@ var PaymentsModule = class _PaymentsModule {
5799
6234
  this.updatePendingFinalization(token, pending2);
5800
6235
  }
5801
6236
  if (pending2.stage === "TRANSFER_SUBMITTED") {
6237
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: TRANSFER_SUBMITTED \u2192 checking transfer proof...`);
5802
6238
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5803
6239
  const transferCommitment = await import_TransferCommitment4.TransferCommitment.fromJSON(transferCommitmentJson);
5804
6240
  const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
5805
6241
  if (!proof) {
6242
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof not yet available, staying TRANSFER_SUBMITTED`);
5806
6243
  this.updatePendingFinalization(token, pending2);
5807
6244
  return "pending";
5808
6245
  }
6246
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof obtained! Finalizing...`);
5809
6247
  const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
5810
6248
  const confirmedToken = {
5811
6249
  id: token.id,
@@ -5821,6 +6259,12 @@ var PaymentsModule = class _PaymentsModule {
5821
6259
  sdkData: JSON.stringify(finalizedToken.toJSON())
5822
6260
  };
5823
6261
  this.tokens.set(tokenId, confirmedToken);
6262
+ this.deps.emitEvent("transfer:confirmed", {
6263
+ id: crypto.randomUUID(),
6264
+ status: "completed",
6265
+ tokens: [confirmedToken],
6266
+ tokenTransfers: []
6267
+ });
5824
6268
  this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
5825
6269
  return "resolved";
5826
6270
  }
@@ -5962,11 +6406,20 @@ var PaymentsModule = class _PaymentsModule {
5962
6406
  }
5963
6407
  }
5964
6408
  if (pendingTokens.length > 0) {
6409
+ const json = JSON.stringify(pendingTokens);
6410
+ this.log(`[V5-PERSIST] Saving ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")} (${json.length} bytes)`);
5965
6411
  await this.deps.storage.set(
5966
6412
  STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
5967
- JSON.stringify(pendingTokens)
6413
+ json
5968
6414
  );
6415
+ const verify = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6416
+ if (!verify) {
6417
+ console.error("[Payments][V5-PERSIST] CRITICAL: KV write succeeded but read-back is empty!");
6418
+ } else {
6419
+ this.log(`[V5-PERSIST] Verified: read-back ${verify.length} bytes`);
6420
+ }
5969
6421
  } else {
6422
+ this.log(`[V5-PERSIST] No pending V5 tokens to save (total tokens: ${this.tokens.size}), clearing KV`);
5970
6423
  await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
5971
6424
  }
5972
6425
  }
@@ -5976,16 +6429,47 @@ var PaymentsModule = class _PaymentsModule {
5976
6429
  */
5977
6430
  async loadPendingV5Tokens() {
5978
6431
  const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6432
+ this.log(`[V5-PERSIST] loadPendingV5Tokens: KV data = ${data ? `${data.length} bytes` : "null/empty"}`);
5979
6433
  if (!data) return;
5980
6434
  try {
5981
6435
  const pendingTokens = JSON.parse(data);
6436
+ this.log(`[V5-PERSIST] Parsed ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")}`);
5982
6437
  for (const token of pendingTokens) {
5983
6438
  if (!this.tokens.has(token.id)) {
5984
6439
  this.tokens.set(token.id, token);
6440
+ this.log(`[V5-PERSIST] Restored token ${token.id.slice(0, 16)} (status=${token.status})`);
6441
+ } else {
6442
+ this.log(`[V5-PERSIST] Token ${token.id.slice(0, 16)} already in map, skipping`);
5985
6443
  }
5986
6444
  }
5987
- if (pendingTokens.length > 0) {
5988
- this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
6445
+ } catch (err) {
6446
+ console.error("[Payments][V5-PERSIST] Failed to parse pending V5 tokens:", err);
6447
+ }
6448
+ }
6449
+ /**
6450
+ * Persist the set of processed splitGroupIds to KV storage.
6451
+ * This ensures Nostr re-deliveries are ignored across page reloads,
6452
+ * even when the confirmed token's in-memory ID differs from v5split_{id}.
6453
+ */
6454
+ async saveProcessedSplitGroupIds() {
6455
+ const ids = Array.from(this.processedSplitGroupIds);
6456
+ if (ids.length > 0) {
6457
+ await this.deps.storage.set(
6458
+ STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS,
6459
+ JSON.stringify(ids)
6460
+ );
6461
+ }
6462
+ }
6463
+ /**
6464
+ * Load processed splitGroupIds from KV storage.
6465
+ */
6466
+ async loadProcessedSplitGroupIds() {
6467
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS);
6468
+ if (!data) return;
6469
+ try {
6470
+ const ids = JSON.parse(data);
6471
+ for (const id of ids) {
6472
+ this.processedSplitGroupIds.add(id);
5989
6473
  }
5990
6474
  } catch {
5991
6475
  }
@@ -6640,7 +7124,32 @@ var PaymentsModule = class _PaymentsModule {
6640
7124
  try {
6641
7125
  const result = await provider.sync(localData);
6642
7126
  if (result.success && result.merged) {
7127
+ const savedTokens = new Map(this.tokens);
6643
7128
  this.loadFromStorageData(result.merged);
7129
+ let restoredCount = 0;
7130
+ for (const [tokenId, token] of savedTokens) {
7131
+ if (this.tokens.has(tokenId)) continue;
7132
+ const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
7133
+ const stateHash = extractStateHashFromSdkData(token.sdkData);
7134
+ if (sdkTokenId && stateHash && this.isStateTombstoned(sdkTokenId, stateHash)) {
7135
+ continue;
7136
+ }
7137
+ if (sdkTokenId) {
7138
+ let hasEquivalent = false;
7139
+ for (const existing of this.tokens.values()) {
7140
+ if (extractTokenIdFromSdkData(existing.sdkData) === sdkTokenId) {
7141
+ hasEquivalent = true;
7142
+ break;
7143
+ }
7144
+ }
7145
+ if (hasEquivalent) continue;
7146
+ }
7147
+ this.tokens.set(tokenId, token);
7148
+ restoredCount++;
7149
+ }
7150
+ if (restoredCount > 0) {
7151
+ console.log(`[Payments] Sync: restored ${restoredCount} token(s) lost by loadFromStorageData`);
7152
+ }
6644
7153
  if (this.nametags.length === 0 && savedNametags.length > 0) {
6645
7154
  this.nametags = savedNametags;
6646
7155
  }
@@ -6942,7 +7451,7 @@ var PaymentsModule = class _PaymentsModule {
6942
7451
  /**
6943
7452
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
6944
7453
  * This is called when receiving a transfer with only commitmentData and no proof yet.
6945
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7454
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
6946
7455
  */
6947
7456
  async handleCommitmentOnlyTransfer(transfer, payload) {
6948
7457
  try {
@@ -6952,40 +7461,22 @@ var PaymentsModule = class _PaymentsModule {
6952
7461
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
6953
7462
  return;
6954
7463
  }
6955
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
6956
- const token = {
6957
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
6958
- coinId: tokenInfo.coinId,
6959
- symbol: tokenInfo.symbol,
6960
- name: tokenInfo.name,
6961
- decimals: tokenInfo.decimals,
6962
- iconUrl: tokenInfo.iconUrl,
6963
- amount: tokenInfo.amount,
6964
- status: "submitted",
6965
- // NOSTR-FIRST: unconfirmed until proof
6966
- createdAt: Date.now(),
6967
- updatedAt: Date.now(),
6968
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
6969
- };
6970
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
6971
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
6972
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
6973
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
6974
- return;
6975
- }
6976
- this.tokens.set(token.id, token);
6977
- await this.save();
6978
- this.log(`NOSTR-FIRST: Token ${token.id.slice(0, 8)}... added as submitted (unconfirmed)`);
7464
+ const token = await this.saveCommitmentOnlyToken(
7465
+ sourceTokenInput,
7466
+ commitmentInput,
7467
+ transfer.senderTransportPubkey
7468
+ );
7469
+ if (!token) return;
6979
7470
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
6980
- const incomingTransfer = {
7471
+ this.deps.emitEvent("transfer:incoming", {
6981
7472
  id: transfer.id,
6982
7473
  senderPubkey: transfer.senderTransportPubkey,
6983
7474
  senderNametag: senderInfo.senderNametag,
6984
7475
  tokens: [token],
6985
7476
  memo: payload.memo,
6986
7477
  receivedAt: transfer.timestamp
6987
- };
6988
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7478
+ });
7479
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
6989
7480
  await this.addToHistory({
6990
7481
  type: "RECEIVED",
6991
7482
  amount: token.amount,
@@ -6997,29 +7488,6 @@ var PaymentsModule = class _PaymentsModule {
6997
7488
  memo: payload.memo,
6998
7489
  tokenId: nostrTokenId || token.id
6999
7490
  });
7000
- try {
7001
- const commitment = await import_TransferCommitment4.TransferCommitment.fromJSON(commitmentInput);
7002
- const requestIdBytes = commitment.requestId;
7003
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7004
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7005
- if (stClient) {
7006
- const response = await stClient.submitTransferCommitment(commitment);
7007
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7008
- }
7009
- this.addProofPollingJob({
7010
- tokenId: token.id,
7011
- requestIdHex,
7012
- commitmentJson: JSON.stringify(commitmentInput),
7013
- startedAt: Date.now(),
7014
- attemptCount: 0,
7015
- lastAttemptAt: 0,
7016
- onProofReceived: async (tokenId) => {
7017
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7018
- }
7019
- });
7020
- } catch (err) {
7021
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7022
- }
7023
7491
  } catch (error) {
7024
7492
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7025
7493
  }
@@ -7132,8 +7600,34 @@ var PaymentsModule = class _PaymentsModule {
7132
7600
  }
7133
7601
  }
7134
7602
  async handleIncomingTransfer(transfer) {
7603
+ if (!this.loaded && this.loadedPromise) {
7604
+ await this.loadedPromise;
7605
+ }
7135
7606
  try {
7136
7607
  const payload = transfer.payload;
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
+ }
7137
7631
  let instantBundle = null;
7138
7632
  if (isInstantSplitBundle(payload)) {
7139
7633
  instantBundle = payload;
@@ -7165,7 +7659,7 @@ var PaymentsModule = class _PaymentsModule {
7165
7659
  return;
7166
7660
  }
7167
7661
  if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
7168
- this.log("Processing NOSTR-FIRST commitment-only transfer...");
7662
+ console.log("[Payments][DEBUG] >>> NOSTR-FIRST commitment-only transfer detected");
7169
7663
  await this.handleCommitmentOnlyTransfer(transfer, payload);
7170
7664
  return;
7171
7665
  }
@@ -7285,17 +7779,19 @@ var PaymentsModule = class _PaymentsModule {
7285
7779
  memo: payload.memo,
7286
7780
  tokenId: incomingTokenId || token.id
7287
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}`);
7288
7794
  }
7289
- const incomingTransfer = {
7290
- id: transfer.id,
7291
- senderPubkey: transfer.senderTransportPubkey,
7292
- senderNametag: senderInfo.senderNametag,
7293
- tokens: [token],
7294
- memo: payload.memo,
7295
- receivedAt: transfer.timestamp
7296
- };
7297
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7298
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7299
7795
  } catch (error) {
7300
7796
  console.error("[Payments] Failed to process incoming transfer:", error);
7301
7797
  }
@@ -7328,17 +7824,24 @@ var PaymentsModule = class _PaymentsModule {
7328
7824
  // ===========================================================================
7329
7825
  async save() {
7330
7826
  const providers = this.getTokenStorageProviders();
7331
- if (providers.size === 0) {
7332
- this.log("No token storage providers - tokens not persisted");
7333
- return;
7334
- }
7335
- const data = await this.createStorageData();
7336
- for (const [id, provider] of providers) {
7337
- try {
7338
- await provider.save(data);
7339
- } catch (err) {
7340
- console.error(`[Payments] Failed to save to provider ${id}:`, err);
7827
+ const tokenStats = Array.from(this.tokens.values()).map((t) => {
7828
+ const txf = tokenToTxf(t);
7829
+ return `${t.id.slice(0, 12)}(${t.status},txf=${!!txf})`;
7830
+ });
7831
+ console.log(`[Payments][DEBUG] save(): providers=${providers.size}, tokens=[${tokenStats.join(", ")}]`);
7832
+ if (providers.size > 0) {
7833
+ const data = await this.createStorageData();
7834
+ const dataKeys = Object.keys(data).filter((k) => k.startsWith("token-"));
7835
+ console.log(`[Payments][DEBUG] save(): TXF keys=${dataKeys.length} (${dataKeys.join(", ")})`);
7836
+ for (const [id, provider] of providers) {
7837
+ try {
7838
+ await provider.save(data);
7839
+ } catch (err) {
7840
+ console.error(`[Payments] Failed to save to provider ${id}:`, err);
7841
+ }
7341
7842
  }
7843
+ } else {
7844
+ console.log("[Payments][DEBUG] save(): No token storage providers - TXF not persisted");
7342
7845
  }
7343
7846
  await this.savePendingV5Tokens();
7344
7847
  }
@@ -7374,6 +7877,7 @@ var PaymentsModule = class _PaymentsModule {
7374
7877
  }
7375
7878
  loadFromStorageData(data) {
7376
7879
  const parsed = parseTxfStorageData(data);
7880
+ console.log(`[Payments][DEBUG] loadFromStorageData: parsed ${parsed.tokens.length} tokens, ${parsed.tombstones.length} tombstones, errors=[${parsed.validationErrors.join("; ")}]`);
7377
7881
  this.tombstones = parsed.tombstones;
7378
7882
  this.tokens.clear();
7379
7883
  for (const token of parsed.tokens) {