@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
@@ -87,7 +87,11 @@ var init_constants = __esm({
87
87
  /** Group chat: members for this address */
88
88
  GROUP_CHAT_MEMBERS: "group_chat_members",
89
89
  /** Group chat: processed event IDs for deduplication */
90
- GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events"
90
+ GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
91
+ /** Processed V5 split group IDs for Nostr re-delivery dedup */
92
+ PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids",
93
+ /** Processed V6 combined transfer IDs for Nostr re-delivery dedup */
94
+ PROCESSED_COMBINED_TRANSFER_IDS: "processed_combined_transfer_ids"
91
95
  };
92
96
  STORAGE_KEYS = {
93
97
  ...STORAGE_KEYS_GLOBAL,
@@ -3284,14 +3288,149 @@ var InstantSplitExecutor = class {
3284
3288
  this.devMode = config.devMode ?? false;
3285
3289
  }
3286
3290
  /**
3287
- * Execute an instant split transfer with V5 optimized flow.
3291
+ * Build a V5 split bundle WITHOUT sending it via transport.
3288
3292
  *
3289
- * Critical path (~2.3s):
3293
+ * Steps 1-5 of the V5 flow:
3290
3294
  * 1. Create and submit burn commitment
3291
3295
  * 2. Wait for burn proof
3292
3296
  * 3. Create mint commitments with SplitMintReason
3293
3297
  * 4. Create transfer commitment (no mint proof needed)
3294
- * 5. Send bundle via transport
3298
+ * 5. Package V5 bundle
3299
+ *
3300
+ * The caller is responsible for sending the bundle and then calling
3301
+ * `startBackground()` on the result to begin mint proof + change token creation.
3302
+ */
3303
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3304
+ const splitGroupId = crypto.randomUUID();
3305
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3306
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3307
+ const coinId = new CoinId3(fromHex2(coinIdHex));
3308
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3309
+ const recipientTokenId = new TokenId3(await sha2563(seedString));
3310
+ const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3311
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3312
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3313
+ const senderAddressRef = await UnmaskedPredicateReference2.create(
3314
+ tokenToSplit.type,
3315
+ this.signingService.algorithm,
3316
+ this.signingService.publicKey,
3317
+ HashAlgorithm3.SHA256
3318
+ );
3319
+ const senderAddress = await senderAddressRef.toAddress();
3320
+ const builder = new TokenSplitBuilder2();
3321
+ const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3322
+ builder.createToken(
3323
+ recipientTokenId,
3324
+ tokenToSplit.type,
3325
+ new Uint8Array(0),
3326
+ coinDataA,
3327
+ senderAddress,
3328
+ // Mint to sender first, then transfer
3329
+ recipientSalt,
3330
+ null
3331
+ );
3332
+ const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3333
+ builder.createToken(
3334
+ senderTokenId,
3335
+ tokenToSplit.type,
3336
+ new Uint8Array(0),
3337
+ coinDataB,
3338
+ senderAddress,
3339
+ senderSalt,
3340
+ null
3341
+ );
3342
+ const split = await builder.build(tokenToSplit);
3343
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3344
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3345
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3346
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3347
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3348
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3349
+ }
3350
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3351
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3352
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3353
+ console.log(`[InstantSplit] Burn proof received`);
3354
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3355
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3356
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3357
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3358
+ const senderIdHex = toHex2(senderTokenId.bytes);
3359
+ const recipientMintCommitment = mintCommitments.find(
3360
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3361
+ );
3362
+ const senderMintCommitment = mintCommitments.find(
3363
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3364
+ );
3365
+ if (!recipientMintCommitment || !senderMintCommitment) {
3366
+ throw new Error("Failed to find expected mint commitments");
3367
+ }
3368
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3369
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3370
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3371
+ recipientMintCommitment.transactionData,
3372
+ recipientAddress,
3373
+ transferSalt,
3374
+ this.signingService
3375
+ );
3376
+ const mintedPredicate = await UnmaskedPredicate3.create(
3377
+ recipientTokenId,
3378
+ tokenToSplit.type,
3379
+ this.signingService,
3380
+ HashAlgorithm3.SHA256,
3381
+ recipientSalt
3382
+ );
3383
+ const mintedState = new TokenState3(mintedPredicate, null);
3384
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3385
+ const senderPubkey = toHex2(this.signingService.publicKey);
3386
+ let nametagTokenJson;
3387
+ const recipientAddressStr = recipientAddress.toString();
3388
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3389
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3390
+ }
3391
+ const bundle = {
3392
+ version: "5.0",
3393
+ type: "INSTANT_SPLIT",
3394
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3395
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3396
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3397
+ amount: splitAmount.toString(),
3398
+ coinId: coinIdHex,
3399
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3400
+ splitGroupId,
3401
+ senderPubkey,
3402
+ recipientSaltHex: toHex2(recipientSalt),
3403
+ transferSaltHex: toHex2(transferSalt),
3404
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3405
+ finalRecipientStateJson: "",
3406
+ // Recipient creates their own
3407
+ recipientAddressJson: recipientAddressStr,
3408
+ nametagTokenJson
3409
+ };
3410
+ return {
3411
+ bundle,
3412
+ splitGroupId,
3413
+ startBackground: async () => {
3414
+ if (!options?.skipBackground) {
3415
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3416
+ signingService: this.signingService,
3417
+ tokenType: tokenToSplit.type,
3418
+ coinId,
3419
+ senderTokenId,
3420
+ senderSalt,
3421
+ onProgress: options?.onBackgroundProgress,
3422
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3423
+ onStorageSync: options?.onStorageSync
3424
+ });
3425
+ }
3426
+ }
3427
+ };
3428
+ }
3429
+ /**
3430
+ * Execute an instant split transfer with V5 optimized flow.
3431
+ *
3432
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3433
+ * and starts background processing.
3295
3434
  *
3296
3435
  * @param tokenToSplit - The SDK token to split
3297
3436
  * @param splitAmount - Amount to send to recipient
@@ -3305,117 +3444,19 @@ var InstantSplitExecutor = class {
3305
3444
  */
3306
3445
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3307
3446
  const startTime = performance.now();
3308
- const splitGroupId = crypto.randomUUID();
3309
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3310
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3311
3447
  try {
3312
- const coinId = new CoinId3(fromHex2(coinIdHex));
3313
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3314
- const recipientTokenId = new TokenId3(await sha2563(seedString));
3315
- const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3316
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3317
- const senderSalt = await sha2563(seedString + "_sender_salt");
3318
- const senderAddressRef = await UnmaskedPredicateReference2.create(
3319
- tokenToSplit.type,
3320
- this.signingService.algorithm,
3321
- this.signingService.publicKey,
3322
- HashAlgorithm3.SHA256
3323
- );
3324
- const senderAddress = await senderAddressRef.toAddress();
3325
- const builder = new TokenSplitBuilder2();
3326
- const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3327
- builder.createToken(
3328
- recipientTokenId,
3329
- tokenToSplit.type,
3330
- new Uint8Array(0),
3331
- coinDataA,
3332
- senderAddress,
3333
- // Mint to sender first, then transfer
3334
- recipientSalt,
3335
- null
3336
- );
3337
- const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3338
- builder.createToken(
3339
- senderTokenId,
3340
- tokenToSplit.type,
3341
- new Uint8Array(0),
3342
- coinDataB,
3343
- senderAddress,
3344
- senderSalt,
3345
- null
3346
- );
3347
- const split = await builder.build(tokenToSplit);
3348
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3349
- const burnSalt = await sha2563(seedString + "_burn_salt");
3350
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3351
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3352
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3353
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3354
- }
3355
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3356
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3357
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3358
- const burnDuration = performance.now() - startTime;
3359
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3360
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3361
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3362
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3363
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3364
- const senderIdHex = toHex2(senderTokenId.bytes);
3365
- const recipientMintCommitment = mintCommitments.find(
3366
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3367
- );
3368
- const senderMintCommitment = mintCommitments.find(
3369
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3370
- );
3371
- if (!recipientMintCommitment || !senderMintCommitment) {
3372
- throw new Error("Failed to find expected mint commitments");
3373
- }
3374
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3375
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3376
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3377
- recipientMintCommitment.transactionData,
3448
+ const buildResult = await this.buildSplitBundle(
3449
+ tokenToSplit,
3450
+ splitAmount,
3451
+ remainderAmount,
3452
+ coinIdHex,
3378
3453
  recipientAddress,
3379
- transferSalt,
3380
- this.signingService
3454
+ options
3381
3455
  );
3382
- const mintedPredicate = await UnmaskedPredicate3.create(
3383
- recipientTokenId,
3384
- tokenToSplit.type,
3385
- this.signingService,
3386
- HashAlgorithm3.SHA256,
3387
- recipientSalt
3388
- );
3389
- const mintedState = new TokenState3(mintedPredicate, null);
3390
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3456
+ console.log("[InstantSplit] Sending via transport...");
3391
3457
  const senderPubkey = toHex2(this.signingService.publicKey);
3392
- let nametagTokenJson;
3393
- const recipientAddressStr = recipientAddress.toString();
3394
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3395
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3396
- }
3397
- const bundle = {
3398
- version: "5.0",
3399
- type: "INSTANT_SPLIT",
3400
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3401
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3402
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3403
- amount: splitAmount.toString(),
3404
- coinId: coinIdHex,
3405
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3406
- splitGroupId,
3407
- senderPubkey,
3408
- recipientSaltHex: toHex2(recipientSalt),
3409
- transferSaltHex: toHex2(transferSalt),
3410
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3411
- finalRecipientStateJson: "",
3412
- // Recipient creates their own
3413
- recipientAddressJson: recipientAddressStr,
3414
- nametagTokenJson
3415
- };
3416
- console.log("[InstantSplit] Step 6: Sending via transport...");
3417
3458
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3418
- token: JSON.stringify(bundle),
3459
+ token: JSON.stringify(buildResult.bundle),
3419
3460
  proof: null,
3420
3461
  // Proof is included in the bundle
3421
3462
  memo: options?.memo,
@@ -3426,25 +3467,13 @@ var InstantSplitExecutor = class {
3426
3467
  const criticalPathDuration = performance.now() - startTime;
3427
3468
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3428
3469
  options?.onNostrDelivered?.(nostrEventId);
3429
- let backgroundPromise;
3430
- if (!options?.skipBackground) {
3431
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3432
- signingService: this.signingService,
3433
- tokenType: tokenToSplit.type,
3434
- coinId,
3435
- senderTokenId,
3436
- senderSalt,
3437
- onProgress: options?.onBackgroundProgress,
3438
- onChangeTokenCreated: options?.onChangeTokenCreated,
3439
- onStorageSync: options?.onStorageSync
3440
- });
3441
- }
3470
+ const backgroundPromise = buildResult.startBackground();
3442
3471
  return {
3443
3472
  success: true,
3444
3473
  nostrEventId,
3445
- splitGroupId,
3474
+ splitGroupId: buildResult.splitGroupId,
3446
3475
  criticalPathDurationMs: criticalPathDuration,
3447
- backgroundStarted: !options?.skipBackground,
3476
+ backgroundStarted: true,
3448
3477
  backgroundPromise
3449
3478
  };
3450
3479
  } catch (error) {
@@ -3453,7 +3482,6 @@ var InstantSplitExecutor = class {
3453
3482
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3454
3483
  return {
3455
3484
  success: false,
3456
- splitGroupId,
3457
3485
  criticalPathDurationMs: duration,
3458
3486
  error: errorMessage,
3459
3487
  backgroundStarted: false
@@ -3658,6 +3686,11 @@ function isInstantSplitBundleV4(obj) {
3658
3686
  function isInstantSplitBundleV5(obj) {
3659
3687
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3660
3688
  }
3689
+ function isCombinedTransferBundleV6(obj) {
3690
+ if (typeof obj !== "object" || obj === null) return false;
3691
+ const b = obj;
3692
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
3693
+ }
3661
3694
 
3662
3695
  // modules/payments/InstantSplitProcessor.ts
3663
3696
  function fromHex3(hex) {
@@ -4300,6 +4333,19 @@ var PaymentsModule = class _PaymentsModule {
4300
4333
  // Poll every 2s
4301
4334
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
4302
4335
  // Max 30 attempts (~60s)
4336
+ // Periodic retry for resolveUnconfirmed (V5 lazy finalization)
4337
+ resolveUnconfirmedTimer = null;
4338
+ static RESOLVE_UNCONFIRMED_INTERVAL_MS = 1e4;
4339
+ // Retry every 10s
4340
+ // Guard: ensure load() completes before processing incoming bundles
4341
+ loadedPromise = null;
4342
+ loaded = false;
4343
+ // Persistent dedup: tracks splitGroupIds that have been fully processed.
4344
+ // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4345
+ // even when the confirmed token's in-memory ID differs from v5split_{id}.
4346
+ processedSplitGroupIds = /* @__PURE__ */ new Set();
4347
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4348
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4303
4349
  // Storage event subscriptions (push-based sync)
4304
4350
  storageEventUnsubscribers = [];
4305
4351
  syncDebounceTimer = null;
@@ -4385,31 +4431,53 @@ var PaymentsModule = class _PaymentsModule {
4385
4431
  */
4386
4432
  async load() {
4387
4433
  this.ensureInitialized();
4388
- await TokenRegistry.waitForReady();
4389
- const providers = this.getTokenStorageProviders();
4390
- for (const [id, provider] of providers) {
4391
- try {
4392
- const result = await provider.load();
4393
- if (result.success && result.data) {
4394
- this.loadFromStorageData(result.data);
4395
- this.log(`Loaded metadata from provider ${id}`);
4396
- break;
4434
+ const doLoad = async () => {
4435
+ await TokenRegistry.waitForReady();
4436
+ const providers = this.getTokenStorageProviders();
4437
+ for (const [id, provider] of providers) {
4438
+ try {
4439
+ const result = await provider.load();
4440
+ if (result.success && result.data) {
4441
+ this.loadFromStorageData(result.data);
4442
+ this.log(`Loaded metadata from provider ${id}`);
4443
+ break;
4444
+ }
4445
+ } catch (err) {
4446
+ console.error(`[Payments] Failed to load from provider ${id}:`, err);
4397
4447
  }
4398
- } catch (err) {
4399
- console.error(`[Payments] Failed to load from provider ${id}:`, err);
4400
4448
  }
4401
- }
4402
- await this.loadPendingV5Tokens();
4403
- await this.loadHistory();
4404
- const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4405
- if (pending2) {
4406
- const transfers = JSON.parse(pending2);
4407
- for (const transfer of transfers) {
4408
- this.pendingTransfers.set(transfer.id, transfer);
4449
+ for (const [id, token] of this.tokens) {
4450
+ try {
4451
+ if (token.sdkData) {
4452
+ const data = JSON.parse(token.sdkData);
4453
+ if (data?._placeholder) {
4454
+ this.tokens.delete(id);
4455
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4456
+ }
4457
+ }
4458
+ } catch {
4459
+ }
4409
4460
  }
4410
- }
4461
+ const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4462
+ console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4463
+ await this.loadPendingV5Tokens();
4464
+ await this.loadProcessedSplitGroupIds();
4465
+ await this.loadProcessedCombinedTransferIds();
4466
+ await this.loadHistory();
4467
+ const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4468
+ if (pending2) {
4469
+ const transfers = JSON.parse(pending2);
4470
+ for (const transfer of transfers) {
4471
+ this.pendingTransfers.set(transfer.id, transfer);
4472
+ }
4473
+ }
4474
+ this.loaded = true;
4475
+ };
4476
+ this.loadedPromise = doLoad();
4477
+ await this.loadedPromise;
4411
4478
  this.resolveUnconfirmed().catch(() => {
4412
4479
  });
4480
+ this.scheduleResolveUnconfirmed();
4413
4481
  }
4414
4482
  /**
4415
4483
  * Cleanup all subscriptions, polling jobs, and pending resolvers.
@@ -4428,6 +4496,7 @@ var PaymentsModule = class _PaymentsModule {
4428
4496
  this.paymentRequestResponseHandlers.clear();
4429
4497
  this.stopProofPolling();
4430
4498
  this.proofPollingJobs.clear();
4499
+ this.stopResolveUnconfirmedPolling();
4431
4500
  for (const [, resolver] of this.pendingResponseResolvers) {
4432
4501
  clearTimeout(resolver.timeout);
4433
4502
  resolver.reject(new Error("Module destroyed"));
@@ -4485,12 +4554,13 @@ var PaymentsModule = class _PaymentsModule {
4485
4554
  token.status = "transferring";
4486
4555
  this.tokens.set(token.id, token);
4487
4556
  }
4557
+ await this.save();
4488
4558
  await this.saveToOutbox(result, recipientPubkey);
4489
4559
  result.status = "submitted";
4490
4560
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4491
4561
  const transferMode = request.transferMode ?? "instant";
4492
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4493
- if (transferMode === "conservative") {
4562
+ if (transferMode === "conservative") {
4563
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4494
4564
  this.log("Executing conservative split...");
4495
4565
  const splitExecutor = new TokenSplitExecutor({
4496
4566
  stateTransitionClient: stClient,
@@ -4534,27 +4604,59 @@ var PaymentsModule = class _PaymentsModule {
4534
4604
  requestIdHex: splitRequestIdHex
4535
4605
  });
4536
4606
  this.log(`Conservative split transfer completed`);
4537
- } else {
4538
- this.log("Executing instant split...");
4539
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
4607
+ }
4608
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4609
+ const token = tokenWithAmount.uiToken;
4610
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4611
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4612
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4613
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4614
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4615
+ }
4616
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4617
+ const transferTx = commitment.toTransaction(inclusionProof);
4618
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4619
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4620
+ transferTx: JSON.stringify(transferTx.toJSON()),
4621
+ memo: request.memo
4622
+ });
4623
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4624
+ const requestIdBytes = commitment.requestId;
4625
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4626
+ result.tokenTransfers.push({
4627
+ sourceTokenId: token.id,
4628
+ method: "direct",
4629
+ requestIdHex
4630
+ });
4631
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
4632
+ await this.removeToken(token.id);
4633
+ }
4634
+ } else {
4635
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4636
+ const senderPubkey = this.deps.identity.chainPubkey;
4637
+ let changeTokenPlaceholderId = null;
4638
+ let builtSplit = null;
4639
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4640
+ this.log("Building instant split bundle...");
4540
4641
  const executor = new InstantSplitExecutor({
4541
4642
  stateTransitionClient: stClient,
4542
4643
  trustBase,
4543
4644
  signingService,
4544
4645
  devMode
4545
4646
  });
4546
- const instantResult = await executor.executeSplitInstant(
4647
+ builtSplit = await executor.buildSplitBundle(
4547
4648
  splitPlan.tokenToSplit.sdkToken,
4548
4649
  splitPlan.splitAmount,
4549
4650
  splitPlan.remainderAmount,
4550
4651
  splitPlan.coinId,
4551
4652
  recipientAddress,
4552
- this.deps.transport,
4553
- recipientPubkey,
4554
4653
  {
4555
4654
  memo: request.memo,
4556
4655
  onChangeTokenCreated: async (changeToken) => {
4557
4656
  const changeTokenData = changeToken.toJSON();
4657
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4658
+ this.tokens.delete(changeTokenPlaceholderId);
4659
+ }
4558
4660
  const uiToken = {
4559
4661
  id: crypto.randomUUID(),
4560
4662
  coinId: request.coinId,
@@ -4577,65 +4679,103 @@ var PaymentsModule = class _PaymentsModule {
4577
4679
  }
4578
4680
  }
4579
4681
  );
4580
- if (!instantResult.success) {
4581
- throw new Error(instantResult.error || "Instant split failed");
4582
- }
4583
- if (instantResult.backgroundPromise) {
4584
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4585
- }
4682
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
4683
+ }
4684
+ const directCommitments = await Promise.all(
4685
+ splitPlan.tokensToTransferDirectly.map(
4686
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
4687
+ )
4688
+ );
4689
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
4690
+ (tw, i) => ({
4691
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
4692
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
4693
+ amount: tw.uiToken.amount,
4694
+ coinId: tw.uiToken.coinId,
4695
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
4696
+ })
4697
+ );
4698
+ const combinedBundle = {
4699
+ version: "6.0",
4700
+ type: "COMBINED_TRANSFER",
4701
+ transferId: result.id,
4702
+ splitBundle: builtSplit?.bundle ?? null,
4703
+ directTokens: directTokenEntries,
4704
+ totalAmount: request.amount.toString(),
4705
+ coinId: request.coinId,
4706
+ senderPubkey,
4707
+ memo: request.memo
4708
+ };
4709
+ console.log(
4710
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
4711
+ );
4712
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4713
+ token: JSON.stringify(combinedBundle),
4714
+ proof: null,
4715
+ memo: request.memo,
4716
+ sender: { transportPubkey: senderPubkey }
4717
+ });
4718
+ console.log(`[Payments] V6 combined bundle sent successfully`);
4719
+ if (builtSplit) {
4720
+ const bgPromise = builtSplit.startBackground();
4721
+ this.pendingBackgroundTasks.push(bgPromise);
4722
+ }
4723
+ if (builtSplit && splitPlan.remainderAmount) {
4724
+ changeTokenPlaceholderId = crypto.randomUUID();
4725
+ const placeholder = {
4726
+ id: changeTokenPlaceholderId,
4727
+ coinId: request.coinId,
4728
+ symbol: this.getCoinSymbol(request.coinId),
4729
+ name: this.getCoinName(request.coinId),
4730
+ decimals: this.getCoinDecimals(request.coinId),
4731
+ iconUrl: this.getCoinIconUrl(request.coinId),
4732
+ amount: splitPlan.remainderAmount.toString(),
4733
+ status: "transferring",
4734
+ createdAt: Date.now(),
4735
+ updatedAt: Date.now(),
4736
+ sdkData: JSON.stringify({ _placeholder: true })
4737
+ };
4738
+ this.tokens.set(placeholder.id, placeholder);
4739
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
4740
+ }
4741
+ for (const commitment of directCommitments) {
4742
+ stClient.submitTransferCommitment(commitment).catch(
4743
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4744
+ );
4745
+ }
4746
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4586
4747
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4587
4748
  result.tokenTransfers.push({
4588
4749
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4589
4750
  method: "split",
4590
- splitGroupId: instantResult.splitGroupId,
4591
- nostrEventId: instantResult.nostrEventId
4751
+ splitGroupId: builtSplit.splitGroupId
4592
4752
  });
4593
- this.log(`Instant split transfer completed`);
4594
4753
  }
4595
- }
4596
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4597
- const token = tokenWithAmount.uiToken;
4598
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4599
- if (transferMode === "conservative") {
4600
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4601
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4602
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4603
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4604
- }
4605
- const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4606
- const transferTx = commitment.toTransaction(inclusionProof);
4607
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4608
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4609
- transferTx: JSON.stringify(transferTx.toJSON()),
4610
- memo: request.memo
4611
- });
4612
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4613
- } else {
4614
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4615
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4616
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4617
- commitmentData: JSON.stringify(commitment.toJSON()),
4618
- memo: request.memo
4754
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
4755
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
4756
+ const commitment = directCommitments[i];
4757
+ const requestIdBytes = commitment.requestId;
4758
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4759
+ result.tokenTransfers.push({
4760
+ sourceTokenId: token.id,
4761
+ method: "direct",
4762
+ requestIdHex
4619
4763
  });
4620
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4621
- stClient.submitTransferCommitment(commitment).catch(
4622
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4623
- );
4764
+ await this.removeToken(token.id);
4624
4765
  }
4625
- const requestIdBytes = commitment.requestId;
4626
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4627
- result.tokenTransfers.push({
4628
- sourceTokenId: token.id,
4629
- method: "direct",
4630
- requestIdHex
4631
- });
4632
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4633
- await this.removeToken(token.id);
4766
+ this.log(`V6 combined transfer completed`);
4634
4767
  }
4635
4768
  result.status = "delivered";
4636
4769
  await this.save();
4637
4770
  await this.removeFromOutbox(result.id);
4638
4771
  result.status = "completed";
4772
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
4773
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
4774
+ id: tt.sourceTokenId,
4775
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
4776
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
4777
+ source: tt.method === "split" ? "split" : "direct"
4778
+ }));
4639
4779
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4640
4780
  await this.addToHistory({
4641
4781
  type: "SENT",
@@ -4648,7 +4788,8 @@ var PaymentsModule = class _PaymentsModule {
4648
4788
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4649
4789
  memo: request.memo,
4650
4790
  transferId: result.id,
4651
- tokenId: sentTokenId || void 0
4791
+ tokenId: sentTokenId || void 0,
4792
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4652
4793
  });
4653
4794
  this.deps.emitEvent("transfer:confirmed", result);
4654
4795
  return result;
@@ -4818,6 +4959,267 @@ var PaymentsModule = class _PaymentsModule {
4818
4959
  };
4819
4960
  }
4820
4961
  }
4962
+ // ===========================================================================
4963
+ // Shared Helpers for V5 and V6 Receiver Processing
4964
+ // ===========================================================================
4965
+ /**
4966
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
4967
+ * Returns the created UI token, or null if deduped.
4968
+ *
4969
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
4970
+ * The token is still added to the in-memory map for dedup; caller must call save().
4971
+ */
4972
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
4973
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
4974
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
4975
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
4976
+ return null;
4977
+ }
4978
+ const registry = TokenRegistry.getInstance();
4979
+ const pendingData = {
4980
+ type: "v5_bundle",
4981
+ stage: "RECEIVED",
4982
+ bundleJson: JSON.stringify(bundle),
4983
+ senderPubkey,
4984
+ savedAt: Date.now(),
4985
+ attemptCount: 0
4986
+ };
4987
+ const uiToken = {
4988
+ id: deterministicId,
4989
+ coinId: bundle.coinId,
4990
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4991
+ name: registry.getName(bundle.coinId) || bundle.coinId,
4992
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
4993
+ amount: bundle.amount,
4994
+ status: "submitted",
4995
+ // UNCONFIRMED
4996
+ createdAt: Date.now(),
4997
+ updatedAt: Date.now(),
4998
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4999
+ };
5000
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5001
+ if (deferPersistence) {
5002
+ this.tokens.set(uiToken.id, uiToken);
5003
+ } else {
5004
+ await this.addToken(uiToken);
5005
+ await this.saveProcessedSplitGroupIds();
5006
+ }
5007
+ return uiToken;
5008
+ }
5009
+ /**
5010
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5011
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5012
+ * Returns the created UI token, or null if deduped/tombstoned.
5013
+ *
5014
+ * @param deferPersistence - If true, skip save() and commitment submission
5015
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5016
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5017
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5018
+ */
5019
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5020
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5021
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5022
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5023
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5024
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5025
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5026
+ return null;
5027
+ }
5028
+ if (nostrTokenId) {
5029
+ for (const existing of this.tokens.values()) {
5030
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5031
+ if (existingTokenId !== nostrTokenId) continue;
5032
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5033
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5034
+ console.log(
5035
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5036
+ );
5037
+ return null;
5038
+ }
5039
+ if (!skipGenesisDedup) {
5040
+ console.log(
5041
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5042
+ );
5043
+ return null;
5044
+ }
5045
+ }
5046
+ }
5047
+ const token = {
5048
+ id: crypto.randomUUID(),
5049
+ coinId: tokenInfo.coinId,
5050
+ symbol: tokenInfo.symbol,
5051
+ name: tokenInfo.name,
5052
+ decimals: tokenInfo.decimals,
5053
+ iconUrl: tokenInfo.iconUrl,
5054
+ amount: tokenInfo.amount,
5055
+ status: "submitted",
5056
+ // NOSTR-FIRST: unconfirmed until proof
5057
+ createdAt: Date.now(),
5058
+ updatedAt: Date.now(),
5059
+ sdkData
5060
+ };
5061
+ this.tokens.set(token.id, token);
5062
+ if (!deferPersistence) {
5063
+ await this.save();
5064
+ }
5065
+ try {
5066
+ const commitment = await TransferCommitment4.fromJSON(commitmentInput);
5067
+ const requestIdBytes = commitment.requestId;
5068
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5069
+ if (!deferPersistence) {
5070
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5071
+ if (stClient) {
5072
+ const response = await stClient.submitTransferCommitment(commitment);
5073
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5074
+ }
5075
+ }
5076
+ this.addProofPollingJob({
5077
+ tokenId: token.id,
5078
+ requestIdHex,
5079
+ commitmentJson: JSON.stringify(commitmentInput),
5080
+ startedAt: Date.now(),
5081
+ attemptCount: 0,
5082
+ lastAttemptAt: 0,
5083
+ onProofReceived: async (tokenId) => {
5084
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5085
+ }
5086
+ });
5087
+ } catch (err) {
5088
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5089
+ }
5090
+ return token;
5091
+ }
5092
+ // ===========================================================================
5093
+ // Combined Transfer V6 — Receiver
5094
+ // ===========================================================================
5095
+ /**
5096
+ * Process a received COMBINED_TRANSFER V6 bundle.
5097
+ *
5098
+ * Unpacks a single Nostr message into its component tokens:
5099
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5100
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5101
+ *
5102
+ * Emits ONE transfer:incoming event and records ONE history entry.
5103
+ */
5104
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5105
+ this.ensureInitialized();
5106
+ if (!this.loaded && this.loadedPromise) {
5107
+ await this.loadedPromise;
5108
+ }
5109
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5110
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5111
+ return;
5112
+ }
5113
+ console.log(
5114
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5115
+ );
5116
+ const allTokens = [];
5117
+ const tokenBreakdown = [];
5118
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5119
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5120
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5121
+ }));
5122
+ if (bundle.splitBundle) {
5123
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5124
+ if (splitToken) {
5125
+ allTokens.push(splitToken);
5126
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5127
+ } else {
5128
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5129
+ }
5130
+ }
5131
+ const directResults = await Promise.all(
5132
+ parsedDirectEntries.map(
5133
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5134
+ )
5135
+ );
5136
+ for (let i = 0; i < directResults.length; i++) {
5137
+ const token = directResults[i];
5138
+ if (token) {
5139
+ allTokens.push(token);
5140
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5141
+ } else {
5142
+ const entry = bundle.directTokens[i];
5143
+ console.warn(
5144
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5145
+ );
5146
+ }
5147
+ }
5148
+ if (allTokens.length === 0) {
5149
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5150
+ return;
5151
+ }
5152
+ this.processedCombinedTransferIds.add(bundle.transferId);
5153
+ const [senderInfo] = await Promise.all([
5154
+ this.resolveSenderInfo(senderPubkey),
5155
+ this.save(),
5156
+ this.saveProcessedCombinedTransferIds(),
5157
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5158
+ ]);
5159
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5160
+ if (stClient) {
5161
+ for (const { commitment } of parsedDirectEntries) {
5162
+ TransferCommitment4.fromJSON(commitment).then(
5163
+ (c) => stClient.submitTransferCommitment(c)
5164
+ ).catch(
5165
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5166
+ );
5167
+ }
5168
+ }
5169
+ this.deps.emitEvent("transfer:incoming", {
5170
+ id: bundle.transferId,
5171
+ senderPubkey,
5172
+ senderNametag: senderInfo.senderNametag,
5173
+ tokens: allTokens,
5174
+ memo: bundle.memo,
5175
+ receivedAt: Date.now()
5176
+ });
5177
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5178
+ await this.addToHistory({
5179
+ type: "RECEIVED",
5180
+ amount: actualAmount,
5181
+ coinId: bundle.coinId,
5182
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5183
+ timestamp: Date.now(),
5184
+ senderPubkey,
5185
+ ...senderInfo,
5186
+ memo: bundle.memo,
5187
+ transferId: bundle.transferId,
5188
+ tokenId: allTokens[0]?.id,
5189
+ tokenIds: tokenBreakdown
5190
+ });
5191
+ if (bundle.splitBundle) {
5192
+ this.resolveUnconfirmed().catch(() => {
5193
+ });
5194
+ this.scheduleResolveUnconfirmed();
5195
+ }
5196
+ }
5197
+ /**
5198
+ * Persist processed combined transfer IDs to KV storage.
5199
+ */
5200
+ async saveProcessedCombinedTransferIds() {
5201
+ const ids = Array.from(this.processedCombinedTransferIds);
5202
+ if (ids.length > 0) {
5203
+ await this.deps.storage.set(
5204
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5205
+ JSON.stringify(ids)
5206
+ );
5207
+ }
5208
+ }
5209
+ /**
5210
+ * Load processed combined transfer IDs from KV storage.
5211
+ */
5212
+ async loadProcessedCombinedTransferIds() {
5213
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5214
+ if (!data) return;
5215
+ try {
5216
+ const ids = JSON.parse(data);
5217
+ for (const id of ids) {
5218
+ this.processedCombinedTransferIds.add(id);
5219
+ }
5220
+ } catch {
5221
+ }
5222
+ }
4821
5223
  /**
4822
5224
  * Process a received INSTANT_SPLIT bundle.
4823
5225
  *
@@ -4834,39 +5236,17 @@ var PaymentsModule = class _PaymentsModule {
4834
5236
  */
4835
5237
  async processInstantSplitBundle(bundle, senderPubkey, memo) {
4836
5238
  this.ensureInitialized();
5239
+ if (!this.loaded && this.loadedPromise) {
5240
+ await this.loadedPromise;
5241
+ }
4837
5242
  if (!isInstantSplitBundleV5(bundle)) {
4838
5243
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
4839
5244
  }
4840
5245
  try {
4841
- const deterministicId = `v5split_${bundle.splitGroupId}`;
4842
- if (this.tokens.has(deterministicId)) {
4843
- this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
5246
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5247
+ if (!uiToken) {
4844
5248
  return { success: true, durationMs: 0 };
4845
5249
  }
4846
- const registry = TokenRegistry.getInstance();
4847
- const pendingData = {
4848
- type: "v5_bundle",
4849
- stage: "RECEIVED",
4850
- bundleJson: JSON.stringify(bundle),
4851
- senderPubkey,
4852
- savedAt: Date.now(),
4853
- attemptCount: 0
4854
- };
4855
- const uiToken = {
4856
- id: deterministicId,
4857
- coinId: bundle.coinId,
4858
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4859
- name: registry.getName(bundle.coinId) || bundle.coinId,
4860
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
4861
- amount: bundle.amount,
4862
- status: "submitted",
4863
- // UNCONFIRMED
4864
- createdAt: Date.now(),
4865
- updatedAt: Date.now(),
4866
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4867
- };
4868
- await this.addToken(uiToken);
4869
- this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
4870
5250
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
4871
5251
  await this.addToHistory({
4872
5252
  type: "RECEIVED",
@@ -4877,7 +5257,7 @@ var PaymentsModule = class _PaymentsModule {
4877
5257
  senderPubkey,
4878
5258
  ...senderInfo,
4879
5259
  memo,
4880
- tokenId: deterministicId
5260
+ tokenId: uiToken.id
4881
5261
  });
4882
5262
  this.deps.emitEvent("transfer:incoming", {
4883
5263
  id: bundle.splitGroupId,
@@ -4890,6 +5270,7 @@ var PaymentsModule = class _PaymentsModule {
4890
5270
  await this.save();
4891
5271
  this.resolveUnconfirmed().catch(() => {
4892
5272
  });
5273
+ this.scheduleResolveUnconfirmed();
4893
5274
  return { success: true, durationMs: 0 };
4894
5275
  } catch (error) {
4895
5276
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -5526,16 +5907,18 @@ var PaymentsModule = class _PaymentsModule {
5526
5907
  }
5527
5908
  /**
5528
5909
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5529
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
5910
+ * Excludes tokens with status 'spent' or 'invalid'.
5911
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
5530
5912
  */
5531
5913
  aggregateTokens(coinId) {
5532
5914
  const assetsMap = /* @__PURE__ */ new Map();
5533
5915
  for (const token of this.tokens.values()) {
5534
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
5916
+ if (token.status === "spent" || token.status === "invalid") continue;
5535
5917
  if (coinId && token.coinId !== coinId) continue;
5536
5918
  const key = token.coinId;
5537
5919
  const amount = BigInt(token.amount);
5538
5920
  const isConfirmed = token.status === "confirmed";
5921
+ const isTransferring = token.status === "transferring";
5539
5922
  const existing = assetsMap.get(key);
5540
5923
  if (existing) {
5541
5924
  if (isConfirmed) {
@@ -5545,6 +5928,7 @@ var PaymentsModule = class _PaymentsModule {
5545
5928
  existing.unconfirmedAmount += amount;
5546
5929
  existing.unconfirmedTokenCount++;
5547
5930
  }
5931
+ if (isTransferring) existing.transferringTokenCount++;
5548
5932
  } else {
5549
5933
  assetsMap.set(key, {
5550
5934
  coinId: token.coinId,
@@ -5555,7 +5939,8 @@ var PaymentsModule = class _PaymentsModule {
5555
5939
  confirmedAmount: isConfirmed ? amount : 0n,
5556
5940
  unconfirmedAmount: isConfirmed ? 0n : amount,
5557
5941
  confirmedTokenCount: isConfirmed ? 1 : 0,
5558
- unconfirmedTokenCount: isConfirmed ? 0 : 1
5942
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
5943
+ transferringTokenCount: isTransferring ? 1 : 0
5559
5944
  });
5560
5945
  }
5561
5946
  }
@@ -5573,6 +5958,7 @@ var PaymentsModule = class _PaymentsModule {
5573
5958
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5574
5959
  confirmedTokenCount: raw.confirmedTokenCount,
5575
5960
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
5961
+ transferringTokenCount: raw.transferringTokenCount,
5576
5962
  priceUsd: null,
5577
5963
  priceEur: null,
5578
5964
  change24h: null,
@@ -5636,28 +6022,70 @@ var PaymentsModule = class _PaymentsModule {
5636
6022
  };
5637
6023
  const stClient = this.deps.oracle.getStateTransitionClient?.();
5638
6024
  const trustBase = this.deps.oracle.getTrustBase?.();
5639
- if (!stClient || !trustBase) return result;
6025
+ if (!stClient || !trustBase) {
6026
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: EARLY EXIT \u2014 stClient=${!!stClient} trustBase=${!!trustBase}`);
6027
+ return result;
6028
+ }
5640
6029
  const signingService = await this.createSigningService();
6030
+ const submittedCount = Array.from(this.tokens.values()).filter((t) => t.status === "submitted").length;
6031
+ console.log(`[V5-RESOLVE] resolveUnconfirmed: ${submittedCount} submitted token(s) to process`);
5641
6032
  for (const [tokenId, token] of this.tokens) {
5642
6033
  if (token.status !== "submitted") continue;
5643
6034
  const pending2 = this.parsePendingFinalization(token.sdkData);
5644
6035
  if (!pending2) {
6036
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 16)}: no pending finalization metadata, skipping`);
5645
6037
  result.stillPending++;
5646
6038
  continue;
5647
6039
  }
5648
6040
  if (pending2.type === "v5_bundle") {
6041
+ console.log(`[V5-RESOLVE] Processing ${tokenId.slice(0, 16)}... stage=${pending2.stage} attempt=${pending2.attemptCount}`);
5649
6042
  const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
6043
+ console.log(`[V5-RESOLVE] Result for ${tokenId.slice(0, 16)}...: ${progress} (stage now: ${pending2.stage})`);
5650
6044
  result.details.push({ tokenId, stage: pending2.stage, status: progress });
5651
6045
  if (progress === "resolved") result.resolved++;
5652
6046
  else if (progress === "failed") result.failed++;
5653
6047
  else result.stillPending++;
5654
6048
  }
5655
6049
  }
5656
- if (result.resolved > 0 || result.failed > 0) {
6050
+ if (result.resolved > 0 || result.failed > 0 || result.stillPending > 0) {
6051
+ console.log(`[V5-RESOLVE] Saving: resolved=${result.resolved} failed=${result.failed} stillPending=${result.stillPending}`);
5657
6052
  await this.save();
5658
6053
  }
5659
6054
  return result;
5660
6055
  }
6056
+ /**
6057
+ * Start a periodic interval that retries resolveUnconfirmed() until all
6058
+ * tokens are confirmed or failed. Stops automatically when nothing is
6059
+ * pending and is cleaned up by destroy().
6060
+ */
6061
+ scheduleResolveUnconfirmed() {
6062
+ if (this.resolveUnconfirmedTimer) return;
6063
+ const hasUnconfirmed = Array.from(this.tokens.values()).some(
6064
+ (t) => t.status === "submitted"
6065
+ );
6066
+ if (!hasUnconfirmed) {
6067
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: no submitted tokens, not starting timer`);
6068
+ return;
6069
+ }
6070
+ console.log(`[V5-RESOLVE] scheduleResolveUnconfirmed: starting periodic retry (every ${_PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS}ms)`);
6071
+ this.resolveUnconfirmedTimer = setInterval(async () => {
6072
+ try {
6073
+ const result = await this.resolveUnconfirmed();
6074
+ if (result.stillPending === 0) {
6075
+ console.log(`[V5-RESOLVE] All tokens resolved, stopping periodic retry`);
6076
+ this.stopResolveUnconfirmedPolling();
6077
+ }
6078
+ } catch (err) {
6079
+ console.log(`[V5-RESOLVE] Periodic retry error:`, err);
6080
+ }
6081
+ }, _PaymentsModule.RESOLVE_UNCONFIRMED_INTERVAL_MS);
6082
+ }
6083
+ stopResolveUnconfirmedPolling() {
6084
+ if (this.resolveUnconfirmedTimer) {
6085
+ clearInterval(this.resolveUnconfirmedTimer);
6086
+ this.resolveUnconfirmedTimer = null;
6087
+ }
6088
+ }
5661
6089
  // ===========================================================================
5662
6090
  // Private - V5 Lazy Resolution Helpers
5663
6091
  // ===========================================================================
@@ -5670,10 +6098,12 @@ var PaymentsModule = class _PaymentsModule {
5670
6098
  pending2.lastAttemptAt = Date.now();
5671
6099
  try {
5672
6100
  if (pending2.stage === "RECEIVED") {
6101
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: RECEIVED \u2192 submitting mint commitment...`);
5673
6102
  const mintDataJson = JSON.parse(bundle.recipientMintData);
5674
6103
  const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5675
6104
  const mintCommitment = await MintCommitment3.create(mintData);
5676
6105
  const mintResponse = await stClient.submitMintCommitment(mintCommitment);
6106
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint response status=${mintResponse.status}`);
5677
6107
  if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
5678
6108
  throw new Error(`Mint submission failed: ${mintResponse.status}`);
5679
6109
  }
@@ -5681,22 +6111,27 @@ var PaymentsModule = class _PaymentsModule {
5681
6111
  this.updatePendingFinalization(token, pending2);
5682
6112
  }
5683
6113
  if (pending2.stage === "MINT_SUBMITTED") {
6114
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_SUBMITTED \u2192 checking mint proof...`);
5684
6115
  const mintDataJson = JSON.parse(bundle.recipientMintData);
5685
6116
  const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5686
6117
  const mintCommitment = await MintCommitment3.create(mintData);
5687
6118
  const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
5688
6119
  if (!proof) {
6120
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof not yet available, staying MINT_SUBMITTED`);
5689
6121
  this.updatePendingFinalization(token, pending2);
5690
6122
  return "pending";
5691
6123
  }
6124
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: mint proof obtained!`);
5692
6125
  pending2.mintProofJson = JSON.stringify(proof);
5693
6126
  pending2.stage = "MINT_PROVEN";
5694
6127
  this.updatePendingFinalization(token, pending2);
5695
6128
  }
5696
6129
  if (pending2.stage === "MINT_PROVEN") {
6130
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: MINT_PROVEN \u2192 submitting transfer commitment...`);
5697
6131
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5698
6132
  const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5699
6133
  const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
6134
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer response status=${transferResponse.status}`);
5700
6135
  if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
5701
6136
  throw new Error(`Transfer submission failed: ${transferResponse.status}`);
5702
6137
  }
@@ -5704,13 +6139,16 @@ var PaymentsModule = class _PaymentsModule {
5704
6139
  this.updatePendingFinalization(token, pending2);
5705
6140
  }
5706
6141
  if (pending2.stage === "TRANSFER_SUBMITTED") {
6142
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: TRANSFER_SUBMITTED \u2192 checking transfer proof...`);
5707
6143
  const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5708
6144
  const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5709
6145
  const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
5710
6146
  if (!proof) {
6147
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof not yet available, staying TRANSFER_SUBMITTED`);
5711
6148
  this.updatePendingFinalization(token, pending2);
5712
6149
  return "pending";
5713
6150
  }
6151
+ console.log(`[V5-RESOLVE] ${tokenId.slice(0, 12)}: transfer proof obtained! Finalizing...`);
5714
6152
  const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
5715
6153
  const confirmedToken = {
5716
6154
  id: token.id,
@@ -5726,6 +6164,12 @@ var PaymentsModule = class _PaymentsModule {
5726
6164
  sdkData: JSON.stringify(finalizedToken.toJSON())
5727
6165
  };
5728
6166
  this.tokens.set(tokenId, confirmedToken);
6167
+ this.deps.emitEvent("transfer:confirmed", {
6168
+ id: crypto.randomUUID(),
6169
+ status: "completed",
6170
+ tokens: [confirmedToken],
6171
+ tokenTransfers: []
6172
+ });
5729
6173
  this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
5730
6174
  return "resolved";
5731
6175
  }
@@ -5867,11 +6311,20 @@ var PaymentsModule = class _PaymentsModule {
5867
6311
  }
5868
6312
  }
5869
6313
  if (pendingTokens.length > 0) {
6314
+ const json = JSON.stringify(pendingTokens);
6315
+ this.log(`[V5-PERSIST] Saving ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")} (${json.length} bytes)`);
5870
6316
  await this.deps.storage.set(
5871
6317
  STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
5872
- JSON.stringify(pendingTokens)
6318
+ json
5873
6319
  );
6320
+ const verify = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6321
+ if (!verify) {
6322
+ console.error("[Payments][V5-PERSIST] CRITICAL: KV write succeeded but read-back is empty!");
6323
+ } else {
6324
+ this.log(`[V5-PERSIST] Verified: read-back ${verify.length} bytes`);
6325
+ }
5874
6326
  } else {
6327
+ this.log(`[V5-PERSIST] No pending V5 tokens to save (total tokens: ${this.tokens.size}), clearing KV`);
5875
6328
  await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
5876
6329
  }
5877
6330
  }
@@ -5881,16 +6334,47 @@ var PaymentsModule = class _PaymentsModule {
5881
6334
  */
5882
6335
  async loadPendingV5Tokens() {
5883
6336
  const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
6337
+ this.log(`[V5-PERSIST] loadPendingV5Tokens: KV data = ${data ? `${data.length} bytes` : "null/empty"}`);
5884
6338
  if (!data) return;
5885
6339
  try {
5886
6340
  const pendingTokens = JSON.parse(data);
6341
+ this.log(`[V5-PERSIST] Parsed ${pendingTokens.length} pending V5 token(s): ${pendingTokens.map((t) => t.id.slice(0, 16)).join(", ")}`);
5887
6342
  for (const token of pendingTokens) {
5888
6343
  if (!this.tokens.has(token.id)) {
5889
6344
  this.tokens.set(token.id, token);
6345
+ this.log(`[V5-PERSIST] Restored token ${token.id.slice(0, 16)} (status=${token.status})`);
6346
+ } else {
6347
+ this.log(`[V5-PERSIST] Token ${token.id.slice(0, 16)} already in map, skipping`);
5890
6348
  }
5891
6349
  }
5892
- if (pendingTokens.length > 0) {
5893
- this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
6350
+ } catch (err) {
6351
+ console.error("[Payments][V5-PERSIST] Failed to parse pending V5 tokens:", err);
6352
+ }
6353
+ }
6354
+ /**
6355
+ * Persist the set of processed splitGroupIds to KV storage.
6356
+ * This ensures Nostr re-deliveries are ignored across page reloads,
6357
+ * even when the confirmed token's in-memory ID differs from v5split_{id}.
6358
+ */
6359
+ async saveProcessedSplitGroupIds() {
6360
+ const ids = Array.from(this.processedSplitGroupIds);
6361
+ if (ids.length > 0) {
6362
+ await this.deps.storage.set(
6363
+ STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS,
6364
+ JSON.stringify(ids)
6365
+ );
6366
+ }
6367
+ }
6368
+ /**
6369
+ * Load processed splitGroupIds from KV storage.
6370
+ */
6371
+ async loadProcessedSplitGroupIds() {
6372
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_SPLIT_GROUP_IDS);
6373
+ if (!data) return;
6374
+ try {
6375
+ const ids = JSON.parse(data);
6376
+ for (const id of ids) {
6377
+ this.processedSplitGroupIds.add(id);
5894
6378
  }
5895
6379
  } catch {
5896
6380
  }
@@ -6545,7 +7029,32 @@ var PaymentsModule = class _PaymentsModule {
6545
7029
  try {
6546
7030
  const result = await provider.sync(localData);
6547
7031
  if (result.success && result.merged) {
7032
+ const savedTokens = new Map(this.tokens);
6548
7033
  this.loadFromStorageData(result.merged);
7034
+ let restoredCount = 0;
7035
+ for (const [tokenId, token] of savedTokens) {
7036
+ if (this.tokens.has(tokenId)) continue;
7037
+ const sdkTokenId = extractTokenIdFromSdkData(token.sdkData);
7038
+ const stateHash = extractStateHashFromSdkData(token.sdkData);
7039
+ if (sdkTokenId && stateHash && this.isStateTombstoned(sdkTokenId, stateHash)) {
7040
+ continue;
7041
+ }
7042
+ if (sdkTokenId) {
7043
+ let hasEquivalent = false;
7044
+ for (const existing of this.tokens.values()) {
7045
+ if (extractTokenIdFromSdkData(existing.sdkData) === sdkTokenId) {
7046
+ hasEquivalent = true;
7047
+ break;
7048
+ }
7049
+ }
7050
+ if (hasEquivalent) continue;
7051
+ }
7052
+ this.tokens.set(tokenId, token);
7053
+ restoredCount++;
7054
+ }
7055
+ if (restoredCount > 0) {
7056
+ console.log(`[Payments] Sync: restored ${restoredCount} token(s) lost by loadFromStorageData`);
7057
+ }
6549
7058
  if (this.nametags.length === 0 && savedNametags.length > 0) {
6550
7059
  this.nametags = savedNametags;
6551
7060
  }
@@ -6847,7 +7356,7 @@ var PaymentsModule = class _PaymentsModule {
6847
7356
  /**
6848
7357
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
6849
7358
  * This is called when receiving a transfer with only commitmentData and no proof yet.
6850
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7359
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
6851
7360
  */
6852
7361
  async handleCommitmentOnlyTransfer(transfer, payload) {
6853
7362
  try {
@@ -6857,40 +7366,22 @@ var PaymentsModule = class _PaymentsModule {
6857
7366
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
6858
7367
  return;
6859
7368
  }
6860
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
6861
- const token = {
6862
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
6863
- coinId: tokenInfo.coinId,
6864
- symbol: tokenInfo.symbol,
6865
- name: tokenInfo.name,
6866
- decimals: tokenInfo.decimals,
6867
- iconUrl: tokenInfo.iconUrl,
6868
- amount: tokenInfo.amount,
6869
- status: "submitted",
6870
- // NOSTR-FIRST: unconfirmed until proof
6871
- createdAt: Date.now(),
6872
- updatedAt: Date.now(),
6873
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
6874
- };
6875
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
6876
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
6877
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
6878
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
6879
- return;
6880
- }
6881
- this.tokens.set(token.id, token);
6882
- await this.save();
6883
- this.log(`NOSTR-FIRST: Token ${token.id.slice(0, 8)}... added as submitted (unconfirmed)`);
7369
+ const token = await this.saveCommitmentOnlyToken(
7370
+ sourceTokenInput,
7371
+ commitmentInput,
7372
+ transfer.senderTransportPubkey
7373
+ );
7374
+ if (!token) return;
6884
7375
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
6885
- const incomingTransfer = {
7376
+ this.deps.emitEvent("transfer:incoming", {
6886
7377
  id: transfer.id,
6887
7378
  senderPubkey: transfer.senderTransportPubkey,
6888
7379
  senderNametag: senderInfo.senderNametag,
6889
7380
  tokens: [token],
6890
7381
  memo: payload.memo,
6891
7382
  receivedAt: transfer.timestamp
6892
- };
6893
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7383
+ });
7384
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
6894
7385
  await this.addToHistory({
6895
7386
  type: "RECEIVED",
6896
7387
  amount: token.amount,
@@ -6902,29 +7393,6 @@ var PaymentsModule = class _PaymentsModule {
6902
7393
  memo: payload.memo,
6903
7394
  tokenId: nostrTokenId || token.id
6904
7395
  });
6905
- try {
6906
- const commitment = await TransferCommitment4.fromJSON(commitmentInput);
6907
- const requestIdBytes = commitment.requestId;
6908
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
6909
- const stClient = this.deps.oracle.getStateTransitionClient?.();
6910
- if (stClient) {
6911
- const response = await stClient.submitTransferCommitment(commitment);
6912
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
6913
- }
6914
- this.addProofPollingJob({
6915
- tokenId: token.id,
6916
- requestIdHex,
6917
- commitmentJson: JSON.stringify(commitmentInput),
6918
- startedAt: Date.now(),
6919
- attemptCount: 0,
6920
- lastAttemptAt: 0,
6921
- onProofReceived: async (tokenId) => {
6922
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
6923
- }
6924
- });
6925
- } catch (err) {
6926
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
6927
- }
6928
7396
  } catch (error) {
6929
7397
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
6930
7398
  }
@@ -7037,8 +7505,34 @@ var PaymentsModule = class _PaymentsModule {
7037
7505
  }
7038
7506
  }
7039
7507
  async handleIncomingTransfer(transfer) {
7508
+ if (!this.loaded && this.loadedPromise) {
7509
+ await this.loadedPromise;
7510
+ }
7040
7511
  try {
7041
7512
  const payload = transfer.payload;
7513
+ console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
7514
+ let combinedBundle = null;
7515
+ if (isCombinedTransferBundleV6(payload)) {
7516
+ combinedBundle = payload;
7517
+ } else if (payload.token) {
7518
+ try {
7519
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7520
+ if (isCombinedTransferBundleV6(inner)) {
7521
+ combinedBundle = inner;
7522
+ }
7523
+ } catch {
7524
+ }
7525
+ }
7526
+ if (combinedBundle) {
7527
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
7528
+ try {
7529
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
7530
+ this.log("COMBINED_TRANSFER V6 processed successfully");
7531
+ } catch (err) {
7532
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
7533
+ }
7534
+ return;
7535
+ }
7042
7536
  let instantBundle = null;
7043
7537
  if (isInstantSplitBundle(payload)) {
7044
7538
  instantBundle = payload;
@@ -7070,7 +7564,7 @@ var PaymentsModule = class _PaymentsModule {
7070
7564
  return;
7071
7565
  }
7072
7566
  if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
7073
- this.log("Processing NOSTR-FIRST commitment-only transfer...");
7567
+ console.log("[Payments][DEBUG] >>> NOSTR-FIRST commitment-only transfer detected");
7074
7568
  await this.handleCommitmentOnlyTransfer(transfer, payload);
7075
7569
  return;
7076
7570
  }
@@ -7190,17 +7684,19 @@ var PaymentsModule = class _PaymentsModule {
7190
7684
  memo: payload.memo,
7191
7685
  tokenId: incomingTokenId || token.id
7192
7686
  });
7687
+ const incomingTransfer = {
7688
+ id: transfer.id,
7689
+ senderPubkey: transfer.senderTransportPubkey,
7690
+ senderNametag: senderInfo.senderNametag,
7691
+ tokens: [token],
7692
+ memo: payload.memo,
7693
+ receivedAt: transfer.timestamp
7694
+ };
7695
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
7696
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7697
+ } else {
7698
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7193
7699
  }
7194
- const incomingTransfer = {
7195
- id: transfer.id,
7196
- senderPubkey: transfer.senderTransportPubkey,
7197
- senderNametag: senderInfo.senderNametag,
7198
- tokens: [token],
7199
- memo: payload.memo,
7200
- receivedAt: transfer.timestamp
7201
- };
7202
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7203
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7204
7700
  } catch (error) {
7205
7701
  console.error("[Payments] Failed to process incoming transfer:", error);
7206
7702
  }
@@ -7233,17 +7729,24 @@ var PaymentsModule = class _PaymentsModule {
7233
7729
  // ===========================================================================
7234
7730
  async save() {
7235
7731
  const providers = this.getTokenStorageProviders();
7236
- if (providers.size === 0) {
7237
- this.log("No token storage providers - tokens not persisted");
7238
- return;
7239
- }
7240
- const data = await this.createStorageData();
7241
- for (const [id, provider] of providers) {
7242
- try {
7243
- await provider.save(data);
7244
- } catch (err) {
7245
- console.error(`[Payments] Failed to save to provider ${id}:`, err);
7732
+ const tokenStats = Array.from(this.tokens.values()).map((t) => {
7733
+ const txf = tokenToTxf(t);
7734
+ return `${t.id.slice(0, 12)}(${t.status},txf=${!!txf})`;
7735
+ });
7736
+ console.log(`[Payments][DEBUG] save(): providers=${providers.size}, tokens=[${tokenStats.join(", ")}]`);
7737
+ if (providers.size > 0) {
7738
+ const data = await this.createStorageData();
7739
+ const dataKeys = Object.keys(data).filter((k) => k.startsWith("token-"));
7740
+ console.log(`[Payments][DEBUG] save(): TXF keys=${dataKeys.length} (${dataKeys.join(", ")})`);
7741
+ for (const [id, provider] of providers) {
7742
+ try {
7743
+ await provider.save(data);
7744
+ } catch (err) {
7745
+ console.error(`[Payments] Failed to save to provider ${id}:`, err);
7746
+ }
7246
7747
  }
7748
+ } else {
7749
+ console.log("[Payments][DEBUG] save(): No token storage providers - TXF not persisted");
7247
7750
  }
7248
7751
  await this.savePendingV5Tokens();
7249
7752
  }
@@ -7279,6 +7782,7 @@ var PaymentsModule = class _PaymentsModule {
7279
7782
  }
7280
7783
  loadFromStorageData(data) {
7281
7784
  const parsed = parseTxfStorageData(data);
7785
+ console.log(`[Payments][DEBUG] loadFromStorageData: parsed ${parsed.tokens.length} tokens, ${parsed.tombstones.length} tombstones, errors=[${parsed.validationErrors.join("; ")}]`);
7282
7786
  this.tombstones = parsed.tombstones;
7283
7787
  this.tokens.clear();
7284
7788
  for (const token of parsed.tokens) {