@unicitylabs/sphere-sdk 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/connect/index.cjs +3 -1
  2. package/dist/connect/index.cjs.map +1 -1
  3. package/dist/connect/index.js +3 -1
  4. package/dist/connect/index.js.map +1 -1
  5. package/dist/core/index.cjs +615 -275
  6. package/dist/core/index.cjs.map +1 -1
  7. package/dist/core/index.d.cts +49 -2
  8. package/dist/core/index.d.ts +49 -2
  9. package/dist/core/index.js +615 -275
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/impl/browser/connect/index.cjs +3 -1
  12. package/dist/impl/browser/connect/index.cjs.map +1 -1
  13. package/dist/impl/browser/connect/index.js +3 -1
  14. package/dist/impl/browser/connect/index.js.map +1 -1
  15. package/dist/impl/browser/index.cjs +5 -2
  16. package/dist/impl/browser/index.cjs.map +1 -1
  17. package/dist/impl/browser/index.js +5 -2
  18. package/dist/impl/browser/index.js.map +1 -1
  19. package/dist/impl/browser/ipfs.cjs +3 -1
  20. package/dist/impl/browser/ipfs.cjs.map +1 -1
  21. package/dist/impl/browser/ipfs.js +3 -1
  22. package/dist/impl/browser/ipfs.js.map +1 -1
  23. package/dist/impl/nodejs/connect/index.cjs +3 -1
  24. package/dist/impl/nodejs/connect/index.cjs.map +1 -1
  25. package/dist/impl/nodejs/connect/index.js +3 -1
  26. package/dist/impl/nodejs/connect/index.js.map +1 -1
  27. package/dist/impl/nodejs/index.cjs +5 -2
  28. package/dist/impl/nodejs/index.cjs.map +1 -1
  29. package/dist/impl/nodejs/index.d.cts +6 -0
  30. package/dist/impl/nodejs/index.d.ts +6 -0
  31. package/dist/impl/nodejs/index.js +5 -2
  32. package/dist/impl/nodejs/index.js.map +1 -1
  33. package/dist/index.cjs +617 -275
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +118 -3
  36. package/dist/index.d.ts +118 -3
  37. package/dist/index.js +616 -275
  38. package/dist/index.js.map +1 -1
  39. package/dist/l1/index.cjs +3 -1
  40. package/dist/l1/index.cjs.map +1 -1
  41. package/dist/l1/index.js +3 -1
  42. package/dist/l1/index.js.map +1 -1
  43. package/package.json +1 -1
@@ -89,7 +89,9 @@ var init_constants = __esm({
89
89
  /** Group chat: processed event IDs for deduplication */
90
90
  GROUP_CHAT_PROCESSED_EVENTS: "group_chat_processed_events",
91
91
  /** Processed V5 split group IDs for Nostr re-delivery dedup */
92
- PROCESSED_SPLIT_GROUP_IDS: "processed_split_group_ids"
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"
93
95
  };
94
96
  STORAGE_KEYS = {
95
97
  ...STORAGE_KEYS_GLOBAL,
@@ -3286,14 +3288,149 @@ var InstantSplitExecutor = class {
3286
3288
  this.devMode = config.devMode ?? false;
3287
3289
  }
3288
3290
  /**
3289
- * Execute an instant split transfer with V5 optimized flow.
3291
+ * Build a V5 split bundle WITHOUT sending it via transport.
3290
3292
  *
3291
- * Critical path (~2.3s):
3293
+ * Steps 1-5 of the V5 flow:
3292
3294
  * 1. Create and submit burn commitment
3293
3295
  * 2. Wait for burn proof
3294
3296
  * 3. Create mint commitments with SplitMintReason
3295
3297
  * 4. Create transfer commitment (no mint proof needed)
3296
- * 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.
3297
3434
  *
3298
3435
  * @param tokenToSplit - The SDK token to split
3299
3436
  * @param splitAmount - Amount to send to recipient
@@ -3307,117 +3444,19 @@ var InstantSplitExecutor = class {
3307
3444
  */
3308
3445
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3309
3446
  const startTime = performance.now();
3310
- const splitGroupId = crypto.randomUUID();
3311
- const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3312
- console.log(`[InstantSplit] Starting V5 split for token ${tokenIdHex.slice(0, 8)}...`);
3313
3447
  try {
3314
- const coinId = new CoinId3(fromHex2(coinIdHex));
3315
- const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3316
- const recipientTokenId = new TokenId3(await sha2563(seedString));
3317
- const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3318
- const recipientSalt = await sha2563(seedString + "_recipient_salt");
3319
- const senderSalt = await sha2563(seedString + "_sender_salt");
3320
- const senderAddressRef = await UnmaskedPredicateReference2.create(
3321
- tokenToSplit.type,
3322
- this.signingService.algorithm,
3323
- this.signingService.publicKey,
3324
- HashAlgorithm3.SHA256
3325
- );
3326
- const senderAddress = await senderAddressRef.toAddress();
3327
- const builder = new TokenSplitBuilder2();
3328
- const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3329
- builder.createToken(
3330
- recipientTokenId,
3331
- tokenToSplit.type,
3332
- new Uint8Array(0),
3333
- coinDataA,
3334
- senderAddress,
3335
- // Mint to sender first, then transfer
3336
- recipientSalt,
3337
- null
3338
- );
3339
- const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3340
- builder.createToken(
3341
- senderTokenId,
3342
- tokenToSplit.type,
3343
- new Uint8Array(0),
3344
- coinDataB,
3345
- senderAddress,
3346
- senderSalt,
3347
- null
3348
- );
3349
- const split = await builder.build(tokenToSplit);
3350
- console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3351
- const burnSalt = await sha2563(seedString + "_burn_salt");
3352
- const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3353
- const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3354
- if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3355
- throw new Error(`Burn submission failed: ${burnResponse.status}`);
3356
- }
3357
- console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3358
- const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3359
- const burnTransaction = burnCommitment.toTransaction(burnProof);
3360
- const burnDuration = performance.now() - startTime;
3361
- console.log(`[InstantSplit] Burn proof received in ${burnDuration.toFixed(0)}ms`);
3362
- options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3363
- console.log("[InstantSplit] Step 3: Creating mint commitments...");
3364
- const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3365
- const recipientIdHex = toHex2(recipientTokenId.bytes);
3366
- const senderIdHex = toHex2(senderTokenId.bytes);
3367
- const recipientMintCommitment = mintCommitments.find(
3368
- (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3369
- );
3370
- const senderMintCommitment = mintCommitments.find(
3371
- (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3372
- );
3373
- if (!recipientMintCommitment || !senderMintCommitment) {
3374
- throw new Error("Failed to find expected mint commitments");
3375
- }
3376
- console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3377
- const transferSalt = await sha2563(seedString + "_transfer_salt");
3378
- const transferCommitment = await this.createTransferCommitmentFromMintData(
3379
- recipientMintCommitment.transactionData,
3448
+ const buildResult = await this.buildSplitBundle(
3449
+ tokenToSplit,
3450
+ splitAmount,
3451
+ remainderAmount,
3452
+ coinIdHex,
3380
3453
  recipientAddress,
3381
- transferSalt,
3382
- this.signingService
3383
- );
3384
- const mintedPredicate = await UnmaskedPredicate3.create(
3385
- recipientTokenId,
3386
- tokenToSplit.type,
3387
- this.signingService,
3388
- HashAlgorithm3.SHA256,
3389
- recipientSalt
3454
+ options
3390
3455
  );
3391
- const mintedState = new TokenState3(mintedPredicate, null);
3392
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3456
+ console.log("[InstantSplit] Sending via transport...");
3393
3457
  const senderPubkey = toHex2(this.signingService.publicKey);
3394
- let nametagTokenJson;
3395
- const recipientAddressStr = recipientAddress.toString();
3396
- if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3397
- nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3398
- }
3399
- const bundle = {
3400
- version: "5.0",
3401
- type: "INSTANT_SPLIT",
3402
- burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3403
- recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3404
- transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3405
- amount: splitAmount.toString(),
3406
- coinId: coinIdHex,
3407
- tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3408
- splitGroupId,
3409
- senderPubkey,
3410
- recipientSaltHex: toHex2(recipientSalt),
3411
- transferSaltHex: toHex2(transferSalt),
3412
- mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3413
- finalRecipientStateJson: "",
3414
- // Recipient creates their own
3415
- recipientAddressJson: recipientAddressStr,
3416
- nametagTokenJson
3417
- };
3418
- console.log("[InstantSplit] Step 6: Sending via transport...");
3419
3458
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3420
- token: JSON.stringify(bundle),
3459
+ token: JSON.stringify(buildResult.bundle),
3421
3460
  proof: null,
3422
3461
  // Proof is included in the bundle
3423
3462
  memo: options?.memo,
@@ -3428,25 +3467,13 @@ var InstantSplitExecutor = class {
3428
3467
  const criticalPathDuration = performance.now() - startTime;
3429
3468
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3430
3469
  options?.onNostrDelivered?.(nostrEventId);
3431
- let backgroundPromise;
3432
- if (!options?.skipBackground) {
3433
- backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3434
- signingService: this.signingService,
3435
- tokenType: tokenToSplit.type,
3436
- coinId,
3437
- senderTokenId,
3438
- senderSalt,
3439
- onProgress: options?.onBackgroundProgress,
3440
- onChangeTokenCreated: options?.onChangeTokenCreated,
3441
- onStorageSync: options?.onStorageSync
3442
- });
3443
- }
3470
+ const backgroundPromise = buildResult.startBackground();
3444
3471
  return {
3445
3472
  success: true,
3446
3473
  nostrEventId,
3447
- splitGroupId,
3474
+ splitGroupId: buildResult.splitGroupId,
3448
3475
  criticalPathDurationMs: criticalPathDuration,
3449
- backgroundStarted: !options?.skipBackground,
3476
+ backgroundStarted: true,
3450
3477
  backgroundPromise
3451
3478
  };
3452
3479
  } catch (error) {
@@ -3455,7 +3482,6 @@ var InstantSplitExecutor = class {
3455
3482
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3456
3483
  return {
3457
3484
  success: false,
3458
- splitGroupId,
3459
3485
  criticalPathDurationMs: duration,
3460
3486
  error: errorMessage,
3461
3487
  backgroundStarted: false
@@ -3660,6 +3686,11 @@ function isInstantSplitBundleV4(obj) {
3660
3686
  function isInstantSplitBundleV5(obj) {
3661
3687
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3662
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
+ }
3663
3694
 
3664
3695
  // modules/payments/InstantSplitProcessor.ts
3665
3696
  function fromHex3(hex) {
@@ -4313,6 +4344,8 @@ var PaymentsModule = class _PaymentsModule {
4313
4344
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4314
4345
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4315
4346
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4347
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4348
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4316
4349
  // Storage event subscriptions (push-based sync)
4317
4350
  storageEventUnsubscribers = [];
4318
4351
  syncDebounceTimer = null;
@@ -4413,10 +4446,23 @@ var PaymentsModule = class _PaymentsModule {
4413
4446
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4414
4447
  }
4415
4448
  }
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
+ }
4460
+ }
4416
4461
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4417
4462
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4418
4463
  await this.loadPendingV5Tokens();
4419
4464
  await this.loadProcessedSplitGroupIds();
4465
+ await this.loadProcessedCombinedTransferIds();
4420
4466
  await this.loadHistory();
4421
4467
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4422
4468
  if (pending2) {
@@ -4508,12 +4554,13 @@ var PaymentsModule = class _PaymentsModule {
4508
4554
  token.status = "transferring";
4509
4555
  this.tokens.set(token.id, token);
4510
4556
  }
4557
+ await this.save();
4511
4558
  await this.saveToOutbox(result, recipientPubkey);
4512
4559
  result.status = "submitted";
4513
4560
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4514
4561
  const transferMode = request.transferMode ?? "instant";
4515
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4516
- if (transferMode === "conservative") {
4562
+ if (transferMode === "conservative") {
4563
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4517
4564
  this.log("Executing conservative split...");
4518
4565
  const splitExecutor = new TokenSplitExecutor({
4519
4566
  stateTransitionClient: stClient,
@@ -4557,27 +4604,59 @@ var PaymentsModule = class _PaymentsModule {
4557
4604
  requestIdHex: splitRequestIdHex
4558
4605
  });
4559
4606
  this.log(`Conservative split transfer completed`);
4560
- } else {
4561
- this.log("Executing instant split...");
4562
- 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...");
4563
4641
  const executor = new InstantSplitExecutor({
4564
4642
  stateTransitionClient: stClient,
4565
4643
  trustBase,
4566
4644
  signingService,
4567
4645
  devMode
4568
4646
  });
4569
- const instantResult = await executor.executeSplitInstant(
4647
+ builtSplit = await executor.buildSplitBundle(
4570
4648
  splitPlan.tokenToSplit.sdkToken,
4571
4649
  splitPlan.splitAmount,
4572
4650
  splitPlan.remainderAmount,
4573
4651
  splitPlan.coinId,
4574
4652
  recipientAddress,
4575
- this.deps.transport,
4576
- recipientPubkey,
4577
4653
  {
4578
4654
  memo: request.memo,
4579
4655
  onChangeTokenCreated: async (changeToken) => {
4580
4656
  const changeTokenData = changeToken.toJSON();
4657
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4658
+ this.tokens.delete(changeTokenPlaceholderId);
4659
+ }
4581
4660
  const uiToken = {
4582
4661
  id: crypto.randomUUID(),
4583
4662
  coinId: request.coinId,
@@ -4600,65 +4679,103 @@ var PaymentsModule = class _PaymentsModule {
4600
4679
  }
4601
4680
  }
4602
4681
  );
4603
- if (!instantResult.success) {
4604
- throw new Error(instantResult.error || "Instant split failed");
4605
- }
4606
- if (instantResult.backgroundPromise) {
4607
- this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4608
- }
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) {
4609
4747
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4610
4748
  result.tokenTransfers.push({
4611
4749
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4612
4750
  method: "split",
4613
- splitGroupId: instantResult.splitGroupId,
4614
- nostrEventId: instantResult.nostrEventId
4751
+ splitGroupId: builtSplit.splitGroupId
4615
4752
  });
4616
- this.log(`Instant split transfer completed`);
4617
4753
  }
4618
- }
4619
- for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4620
- const token = tokenWithAmount.uiToken;
4621
- const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4622
- if (transferMode === "conservative") {
4623
- console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4624
- const submitResponse = await stClient.submitTransferCommitment(commitment);
4625
- if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4626
- throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4627
- }
4628
- const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4629
- const transferTx = commitment.toTransaction(inclusionProof);
4630
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4631
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4632
- transferTx: JSON.stringify(transferTx.toJSON()),
4633
- memo: request.memo
4634
- });
4635
- console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4636
- } else {
4637
- console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4638
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4639
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4640
- commitmentData: JSON.stringify(commitment.toJSON()),
4641
- 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
4642
4763
  });
4643
- console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4644
- stClient.submitTransferCommitment(commitment).catch(
4645
- (err) => console.error("[Payments] Background commitment submit failed:", err)
4646
- );
4764
+ await this.removeToken(token.id);
4647
4765
  }
4648
- const requestIdBytes = commitment.requestId;
4649
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4650
- result.tokenTransfers.push({
4651
- sourceTokenId: token.id,
4652
- method: "direct",
4653
- requestIdHex
4654
- });
4655
- this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4656
- await this.removeToken(token.id);
4766
+ this.log(`V6 combined transfer completed`);
4657
4767
  }
4658
4768
  result.status = "delivered";
4659
4769
  await this.save();
4660
4770
  await this.removeFromOutbox(result.id);
4661
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
+ }));
4662
4779
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4663
4780
  await this.addToHistory({
4664
4781
  type: "SENT",
@@ -4671,7 +4788,8 @@ var PaymentsModule = class _PaymentsModule {
4671
4788
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4672
4789
  memo: request.memo,
4673
4790
  transferId: result.id,
4674
- tokenId: sentTokenId || void 0
4791
+ tokenId: sentTokenId || void 0,
4792
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4675
4793
  });
4676
4794
  this.deps.emitEvent("transfer:confirmed", result);
4677
4795
  return result;
@@ -4841,6 +4959,267 @@ var PaymentsModule = class _PaymentsModule {
4841
4959
  };
4842
4960
  }
4843
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
+ }
4844
5223
  /**
4845
5224
  * Process a received INSTANT_SPLIT bundle.
4846
5225
  *
@@ -4864,36 +5243,10 @@ var PaymentsModule = class _PaymentsModule {
4864
5243
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
4865
5244
  }
4866
5245
  try {
4867
- const deterministicId = `v5split_${bundle.splitGroupId}`;
4868
- if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
4869
- console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
5246
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5247
+ if (!uiToken) {
4870
5248
  return { success: true, durationMs: 0 };
4871
5249
  }
4872
- const registry = TokenRegistry.getInstance();
4873
- const pendingData = {
4874
- type: "v5_bundle",
4875
- stage: "RECEIVED",
4876
- bundleJson: JSON.stringify(bundle),
4877
- senderPubkey,
4878
- savedAt: Date.now(),
4879
- attemptCount: 0
4880
- };
4881
- const uiToken = {
4882
- id: deterministicId,
4883
- coinId: bundle.coinId,
4884
- symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4885
- name: registry.getName(bundle.coinId) || bundle.coinId,
4886
- decimals: registry.getDecimals(bundle.coinId) ?? 8,
4887
- amount: bundle.amount,
4888
- status: "submitted",
4889
- // UNCONFIRMED
4890
- createdAt: Date.now(),
4891
- updatedAt: Date.now(),
4892
- sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4893
- };
4894
- await this.addToken(uiToken);
4895
- this.processedSplitGroupIds.add(bundle.splitGroupId);
4896
- await this.saveProcessedSplitGroupIds();
4897
5250
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
4898
5251
  await this.addToHistory({
4899
5252
  type: "RECEIVED",
@@ -4904,7 +5257,7 @@ var PaymentsModule = class _PaymentsModule {
4904
5257
  senderPubkey,
4905
5258
  ...senderInfo,
4906
5259
  memo,
4907
- tokenId: deterministicId
5260
+ tokenId: uiToken.id
4908
5261
  });
4909
5262
  this.deps.emitEvent("transfer:incoming", {
4910
5263
  id: bundle.splitGroupId,
@@ -5554,16 +5907,18 @@ var PaymentsModule = class _PaymentsModule {
5554
5907
  }
5555
5908
  /**
5556
5909
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5557
- * 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").
5558
5912
  */
5559
5913
  aggregateTokens(coinId) {
5560
5914
  const assetsMap = /* @__PURE__ */ new Map();
5561
5915
  for (const token of this.tokens.values()) {
5562
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
5916
+ if (token.status === "spent" || token.status === "invalid") continue;
5563
5917
  if (coinId && token.coinId !== coinId) continue;
5564
5918
  const key = token.coinId;
5565
5919
  const amount = BigInt(token.amount);
5566
5920
  const isConfirmed = token.status === "confirmed";
5921
+ const isTransferring = token.status === "transferring";
5567
5922
  const existing = assetsMap.get(key);
5568
5923
  if (existing) {
5569
5924
  if (isConfirmed) {
@@ -5573,6 +5928,7 @@ var PaymentsModule = class _PaymentsModule {
5573
5928
  existing.unconfirmedAmount += amount;
5574
5929
  existing.unconfirmedTokenCount++;
5575
5930
  }
5931
+ if (isTransferring) existing.transferringTokenCount++;
5576
5932
  } else {
5577
5933
  assetsMap.set(key, {
5578
5934
  coinId: token.coinId,
@@ -5583,7 +5939,8 @@ var PaymentsModule = class _PaymentsModule {
5583
5939
  confirmedAmount: isConfirmed ? amount : 0n,
5584
5940
  unconfirmedAmount: isConfirmed ? 0n : amount,
5585
5941
  confirmedTokenCount: isConfirmed ? 1 : 0,
5586
- unconfirmedTokenCount: isConfirmed ? 0 : 1
5942
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
5943
+ transferringTokenCount: isTransferring ? 1 : 0
5587
5944
  });
5588
5945
  }
5589
5946
  }
@@ -5601,6 +5958,7 @@ var PaymentsModule = class _PaymentsModule {
5601
5958
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5602
5959
  confirmedTokenCount: raw.confirmedTokenCount,
5603
5960
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
5961
+ transferringTokenCount: raw.transferringTokenCount,
5604
5962
  priceUsd: null,
5605
5963
  priceEur: null,
5606
5964
  change24h: null,
@@ -6998,7 +7356,7 @@ var PaymentsModule = class _PaymentsModule {
6998
7356
  /**
6999
7357
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7000
7358
  * This is called when receiving a transfer with only commitmentData and no proof yet.
7001
- * We create the token as 'submitted', submit commitment (idempotent), and poll for proof.
7359
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7002
7360
  */
7003
7361
  async handleCommitmentOnlyTransfer(transfer, payload) {
7004
7362
  try {
@@ -7008,41 +7366,22 @@ var PaymentsModule = class _PaymentsModule {
7008
7366
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7009
7367
  return;
7010
7368
  }
7011
- const tokenInfo = await parseTokenInfo(sourceTokenInput);
7012
- const token = {
7013
- id: tokenInfo.tokenId ?? crypto.randomUUID(),
7014
- coinId: tokenInfo.coinId,
7015
- symbol: tokenInfo.symbol,
7016
- name: tokenInfo.name,
7017
- decimals: tokenInfo.decimals,
7018
- iconUrl: tokenInfo.iconUrl,
7019
- amount: tokenInfo.amount,
7020
- status: "submitted",
7021
- // NOSTR-FIRST: unconfirmed until proof
7022
- createdAt: Date.now(),
7023
- updatedAt: Date.now(),
7024
- sdkData: typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput)
7025
- };
7026
- const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7027
- const nostrStateHash = extractStateHashFromSdkData(token.sdkData);
7028
- if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
7029
- this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
7030
- return;
7031
- }
7032
- this.tokens.set(token.id, token);
7033
- console.log(`[Payments][DEBUG] NOSTR-FIRST: saving token id=${token.id.slice(0, 16)} status=${token.status} sdkData.length=${token.sdkData?.length}`);
7034
- await this.save();
7035
- console.log(`[Payments][DEBUG] NOSTR-FIRST: save() completed, tokens.size=${this.tokens.size}`);
7369
+ const token = await this.saveCommitmentOnlyToken(
7370
+ sourceTokenInput,
7371
+ commitmentInput,
7372
+ transfer.senderTransportPubkey
7373
+ );
7374
+ if (!token) return;
7036
7375
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7037
- const incomingTransfer = {
7376
+ this.deps.emitEvent("transfer:incoming", {
7038
7377
  id: transfer.id,
7039
7378
  senderPubkey: transfer.senderTransportPubkey,
7040
7379
  senderNametag: senderInfo.senderNametag,
7041
7380
  tokens: [token],
7042
7381
  memo: payload.memo,
7043
7382
  receivedAt: transfer.timestamp
7044
- };
7045
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7383
+ });
7384
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7046
7385
  await this.addToHistory({
7047
7386
  type: "RECEIVED",
7048
7387
  amount: token.amount,
@@ -7054,29 +7393,6 @@ var PaymentsModule = class _PaymentsModule {
7054
7393
  memo: payload.memo,
7055
7394
  tokenId: nostrTokenId || token.id
7056
7395
  });
7057
- try {
7058
- const commitment = await TransferCommitment4.fromJSON(commitmentInput);
7059
- const requestIdBytes = commitment.requestId;
7060
- const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
7061
- const stClient = this.deps.oracle.getStateTransitionClient?.();
7062
- if (stClient) {
7063
- const response = await stClient.submitTransferCommitment(commitment);
7064
- this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
7065
- }
7066
- this.addProofPollingJob({
7067
- tokenId: token.id,
7068
- requestIdHex,
7069
- commitmentJson: JSON.stringify(commitmentInput),
7070
- startedAt: Date.now(),
7071
- attemptCount: 0,
7072
- lastAttemptAt: 0,
7073
- onProofReceived: async (tokenId) => {
7074
- await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
7075
- }
7076
- });
7077
- } catch (err) {
7078
- console.error("[Payments] Failed to parse commitment for proof polling:", err);
7079
- }
7080
7396
  } catch (error) {
7081
7397
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7082
7398
  }
@@ -7195,6 +7511,28 @@ var PaymentsModule = class _PaymentsModule {
7195
7511
  try {
7196
7512
  const payload = transfer.payload;
7197
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
+ }
7198
7536
  let instantBundle = null;
7199
7537
  if (isInstantSplitBundle(payload)) {
7200
7538
  instantBundle = payload;
@@ -7346,17 +7684,19 @@ var PaymentsModule = class _PaymentsModule {
7346
7684
  memo: payload.memo,
7347
7685
  tokenId: incomingTokenId || token.id
7348
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}`);
7349
7699
  }
7350
- const incomingTransfer = {
7351
- id: transfer.id,
7352
- senderPubkey: transfer.senderTransportPubkey,
7353
- senderNametag: senderInfo.senderNametag,
7354
- tokens: [token],
7355
- memo: payload.memo,
7356
- receivedAt: transfer.timestamp
7357
- };
7358
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7359
- this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7360
7700
  } catch (error) {
7361
7701
  console.error("[Payments] Failed to process incoming transfer:", error);
7362
7702
  }