@unicitylabs/sphere-sdk 0.5.1 → 0.5.3

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 +669 -277
  6. package/dist/core/index.cjs.map +1 -1
  7. package/dist/core/index.d.cts +57 -2
  8. package/dist/core/index.d.ts +57 -2
  9. package/dist/core/index.js +669 -277
  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 +11 -3
  16. package/dist/impl/browser/index.cjs.map +1 -1
  17. package/dist/impl/browser/index.js +11 -3
  18. package/dist/impl/browser/index.js.map +1 -1
  19. package/dist/impl/browser/ipfs.cjs +9 -2
  20. package/dist/impl/browser/ipfs.cjs.map +1 -1
  21. package/dist/impl/browser/ipfs.js +9 -2
  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 +11 -3
  28. package/dist/impl/nodejs/index.cjs.map +1 -1
  29. package/dist/impl/nodejs/index.d.cts +7 -0
  30. package/dist/impl/nodejs/index.d.ts +7 -0
  31. package/dist/impl/nodejs/index.js +11 -3
  32. package/dist/impl/nodejs/index.js.map +1 -1
  33. package/dist/index.cjs +671 -277
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +128 -3
  36. package/dist/index.d.ts +128 -3
  37. package/dist/index.js +670 -277
  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,
@@ -2504,7 +2506,7 @@ init_constants();
2504
2506
  // types/txf.ts
2505
2507
  var ARCHIVED_PREFIX = "archived-";
2506
2508
  var FORKED_PREFIX = "_forked_";
2507
- var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity"];
2509
+ var RESERVED_KEYS = ["_meta", "_nametag", "_nametags", "_tombstones", "_invalidatedNametags", "_outbox", "_mintOutbox", "_sent", "_invalid", "_integrity", "_history"];
2508
2510
  function isTokenKey(key) {
2509
2511
  return key.startsWith("_") && !key.startsWith(ARCHIVED_PREFIX) && !key.startsWith(FORKED_PREFIX) && !RESERVED_KEYS.includes(key);
2510
2512
  }
@@ -3089,6 +3091,9 @@ async function buildTxfStorageData(tokens, meta, options) {
3089
3091
  if (options?.invalidatedNametags && options.invalidatedNametags.length > 0) {
3090
3092
  storageData._invalidatedNametags = options.invalidatedNametags;
3091
3093
  }
3094
+ if (options?.historyEntries && options.historyEntries.length > 0) {
3095
+ storageData._history = options.historyEntries;
3096
+ }
3092
3097
  for (const token of tokens) {
3093
3098
  const txf = tokenToTxf(token);
3094
3099
  if (txf) {
@@ -3122,6 +3127,7 @@ function parseTxfStorageData(data) {
3122
3127
  outboxEntries: [],
3123
3128
  mintOutboxEntries: [],
3124
3129
  invalidatedNametags: [],
3130
+ historyEntries: [],
3125
3131
  validationErrors: []
3126
3132
  };
3127
3133
  if (!data || typeof data !== "object") {
@@ -3175,6 +3181,13 @@ function parseTxfStorageData(data) {
3175
3181
  }
3176
3182
  }
3177
3183
  }
3184
+ if (Array.isArray(storageData._history)) {
3185
+ for (const entry of storageData._history) {
3186
+ if (typeof entry === "object" && entry !== null && typeof entry.dedupKey === "string" && typeof entry.type === "string") {
3187
+ result.historyEntries.push(entry);
3188
+ }
3189
+ }
3190
+ }
3178
3191
  for (const key of Object.keys(storageData)) {
3179
3192
  if (isTokenKey(key)) {
3180
3193
  const tokenId = tokenIdFromKey(key);
@@ -3286,14 +3299,149 @@ var InstantSplitExecutor = class {
3286
3299
  this.devMode = config.devMode ?? false;
3287
3300
  }
3288
3301
  /**
3289
- * Execute an instant split transfer with V5 optimized flow.
3302
+ * Build a V5 split bundle WITHOUT sending it via transport.
3290
3303
  *
3291
- * Critical path (~2.3s):
3304
+ * Steps 1-5 of the V5 flow:
3292
3305
  * 1. Create and submit burn commitment
3293
3306
  * 2. Wait for burn proof
3294
3307
  * 3. Create mint commitments with SplitMintReason
3295
3308
  * 4. Create transfer commitment (no mint proof needed)
3296
- * 5. Send bundle via transport
3309
+ * 5. Package V5 bundle
3310
+ *
3311
+ * The caller is responsible for sending the bundle and then calling
3312
+ * `startBackground()` on the result to begin mint proof + change token creation.
3313
+ */
3314
+ async buildSplitBundle(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, options) {
3315
+ const splitGroupId = crypto.randomUUID();
3316
+ const tokenIdHex = toHex2(tokenToSplit.id.bytes);
3317
+ console.log(`[InstantSplit] Building V5 bundle for token ${tokenIdHex.slice(0, 8)}...`);
3318
+ const coinId = new CoinId3(fromHex2(coinIdHex));
3319
+ const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}_${Date.now()}`;
3320
+ const recipientTokenId = new TokenId3(await sha2563(seedString));
3321
+ const senderTokenId = new TokenId3(await sha2563(seedString + "_sender"));
3322
+ const recipientSalt = await sha2563(seedString + "_recipient_salt");
3323
+ const senderSalt = await sha2563(seedString + "_sender_salt");
3324
+ const senderAddressRef = await UnmaskedPredicateReference2.create(
3325
+ tokenToSplit.type,
3326
+ this.signingService.algorithm,
3327
+ this.signingService.publicKey,
3328
+ HashAlgorithm3.SHA256
3329
+ );
3330
+ const senderAddress = await senderAddressRef.toAddress();
3331
+ const builder = new TokenSplitBuilder2();
3332
+ const coinDataA = TokenCoinData2.create([[coinId, splitAmount]]);
3333
+ builder.createToken(
3334
+ recipientTokenId,
3335
+ tokenToSplit.type,
3336
+ new Uint8Array(0),
3337
+ coinDataA,
3338
+ senderAddress,
3339
+ // Mint to sender first, then transfer
3340
+ recipientSalt,
3341
+ null
3342
+ );
3343
+ const coinDataB = TokenCoinData2.create([[coinId, remainderAmount]]);
3344
+ builder.createToken(
3345
+ senderTokenId,
3346
+ tokenToSplit.type,
3347
+ new Uint8Array(0),
3348
+ coinDataB,
3349
+ senderAddress,
3350
+ senderSalt,
3351
+ null
3352
+ );
3353
+ const split = await builder.build(tokenToSplit);
3354
+ console.log("[InstantSplit] Step 1: Creating and submitting burn...");
3355
+ const burnSalt = await sha2563(seedString + "_burn_salt");
3356
+ const burnCommitment = await split.createBurnCommitment(burnSalt, this.signingService);
3357
+ const burnResponse = await this.client.submitTransferCommitment(burnCommitment);
3358
+ if (burnResponse.status !== "SUCCESS" && burnResponse.status !== "REQUEST_ID_EXISTS") {
3359
+ throw new Error(`Burn submission failed: ${burnResponse.status}`);
3360
+ }
3361
+ console.log("[InstantSplit] Step 2: Waiting for burn proof...");
3362
+ const burnProof = this.devMode ? await this.waitInclusionProofWithDevBypass(burnCommitment, options?.burnProofTimeoutMs) : await waitInclusionProof3(this.trustBase, this.client, burnCommitment);
3363
+ const burnTransaction = burnCommitment.toTransaction(burnProof);
3364
+ console.log(`[InstantSplit] Burn proof received`);
3365
+ options?.onBurnCompleted?.(JSON.stringify(burnTransaction.toJSON()));
3366
+ console.log("[InstantSplit] Step 3: Creating mint commitments...");
3367
+ const mintCommitments = await split.createSplitMintCommitments(this.trustBase, burnTransaction);
3368
+ const recipientIdHex = toHex2(recipientTokenId.bytes);
3369
+ const senderIdHex = toHex2(senderTokenId.bytes);
3370
+ const recipientMintCommitment = mintCommitments.find(
3371
+ (c) => toHex2(c.transactionData.tokenId.bytes) === recipientIdHex
3372
+ );
3373
+ const senderMintCommitment = mintCommitments.find(
3374
+ (c) => toHex2(c.transactionData.tokenId.bytes) === senderIdHex
3375
+ );
3376
+ if (!recipientMintCommitment || !senderMintCommitment) {
3377
+ throw new Error("Failed to find expected mint commitments");
3378
+ }
3379
+ console.log("[InstantSplit] Step 4: Creating transfer commitment...");
3380
+ const transferSalt = await sha2563(seedString + "_transfer_salt");
3381
+ const transferCommitment = await this.createTransferCommitmentFromMintData(
3382
+ recipientMintCommitment.transactionData,
3383
+ recipientAddress,
3384
+ transferSalt,
3385
+ this.signingService
3386
+ );
3387
+ const mintedPredicate = await UnmaskedPredicate3.create(
3388
+ recipientTokenId,
3389
+ tokenToSplit.type,
3390
+ this.signingService,
3391
+ HashAlgorithm3.SHA256,
3392
+ recipientSalt
3393
+ );
3394
+ const mintedState = new TokenState3(mintedPredicate, null);
3395
+ console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3396
+ const senderPubkey = toHex2(this.signingService.publicKey);
3397
+ let nametagTokenJson;
3398
+ const recipientAddressStr = recipientAddress.toString();
3399
+ if (recipientAddressStr.startsWith("PROXY://") && tokenToSplit.nametagTokens?.length > 0) {
3400
+ nametagTokenJson = JSON.stringify(tokenToSplit.nametagTokens[0].toJSON());
3401
+ }
3402
+ const bundle = {
3403
+ version: "5.0",
3404
+ type: "INSTANT_SPLIT",
3405
+ burnTransaction: JSON.stringify(burnTransaction.toJSON()),
3406
+ recipientMintData: JSON.stringify(recipientMintCommitment.transactionData.toJSON()),
3407
+ transferCommitment: JSON.stringify(transferCommitment.toJSON()),
3408
+ amount: splitAmount.toString(),
3409
+ coinId: coinIdHex,
3410
+ tokenTypeHex: toHex2(tokenToSplit.type.bytes),
3411
+ splitGroupId,
3412
+ senderPubkey,
3413
+ recipientSaltHex: toHex2(recipientSalt),
3414
+ transferSaltHex: toHex2(transferSalt),
3415
+ mintedTokenStateJson: JSON.stringify(mintedState.toJSON()),
3416
+ finalRecipientStateJson: "",
3417
+ // Recipient creates their own
3418
+ recipientAddressJson: recipientAddressStr,
3419
+ nametagTokenJson
3420
+ };
3421
+ return {
3422
+ bundle,
3423
+ splitGroupId,
3424
+ startBackground: async () => {
3425
+ if (!options?.skipBackground) {
3426
+ await this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3427
+ signingService: this.signingService,
3428
+ tokenType: tokenToSplit.type,
3429
+ coinId,
3430
+ senderTokenId,
3431
+ senderSalt,
3432
+ onProgress: options?.onBackgroundProgress,
3433
+ onChangeTokenCreated: options?.onChangeTokenCreated,
3434
+ onStorageSync: options?.onStorageSync
3435
+ });
3436
+ }
3437
+ }
3438
+ };
3439
+ }
3440
+ /**
3441
+ * Execute an instant split transfer with V5 optimized flow.
3442
+ *
3443
+ * Builds the bundle via buildSplitBundle(), sends via transport,
3444
+ * and starts background processing.
3297
3445
  *
3298
3446
  * @param tokenToSplit - The SDK token to split
3299
3447
  * @param splitAmount - Amount to send to recipient
@@ -3307,117 +3455,19 @@ var InstantSplitExecutor = class {
3307
3455
  */
3308
3456
  async executeSplitInstant(tokenToSplit, splitAmount, remainderAmount, coinIdHex, recipientAddress, transport, recipientPubkey, options) {
3309
3457
  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
3458
  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,
3459
+ const buildResult = await this.buildSplitBundle(
3460
+ tokenToSplit,
3461
+ splitAmount,
3462
+ remainderAmount,
3463
+ coinIdHex,
3380
3464
  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
3465
+ options
3390
3466
  );
3391
- const mintedState = new TokenState3(mintedPredicate, null);
3392
- console.log("[InstantSplit] Step 5: Packaging V5 bundle...");
3467
+ console.log("[InstantSplit] Sending via transport...");
3393
3468
  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
3469
  const nostrEventId = await transport.sendTokenTransfer(recipientPubkey, {
3420
- token: JSON.stringify(bundle),
3470
+ token: JSON.stringify(buildResult.bundle),
3421
3471
  proof: null,
3422
3472
  // Proof is included in the bundle
3423
3473
  memo: options?.memo,
@@ -3428,25 +3478,13 @@ var InstantSplitExecutor = class {
3428
3478
  const criticalPathDuration = performance.now() - startTime;
3429
3479
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3430
3480
  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
- }
3481
+ const backgroundPromise = buildResult.startBackground();
3444
3482
  return {
3445
3483
  success: true,
3446
3484
  nostrEventId,
3447
- splitGroupId,
3485
+ splitGroupId: buildResult.splitGroupId,
3448
3486
  criticalPathDurationMs: criticalPathDuration,
3449
- backgroundStarted: !options?.skipBackground,
3487
+ backgroundStarted: true,
3450
3488
  backgroundPromise
3451
3489
  };
3452
3490
  } catch (error) {
@@ -3455,7 +3493,6 @@ var InstantSplitExecutor = class {
3455
3493
  console.error(`[InstantSplit] Failed after ${duration.toFixed(0)}ms:`, error);
3456
3494
  return {
3457
3495
  success: false,
3458
- splitGroupId,
3459
3496
  criticalPathDurationMs: duration,
3460
3497
  error: errorMessage,
3461
3498
  backgroundStarted: false
@@ -3660,6 +3697,11 @@ function isInstantSplitBundleV4(obj) {
3660
3697
  function isInstantSplitBundleV5(obj) {
3661
3698
  return isInstantSplitBundle(obj) && obj.version === "5.0";
3662
3699
  }
3700
+ function isCombinedTransferBundleV6(obj) {
3701
+ if (typeof obj !== "object" || obj === null) return false;
3702
+ const b = obj;
3703
+ return b.version === "6.0" && b.type === "COMBINED_TRANSFER";
3704
+ }
3663
3705
 
3664
3706
  // modules/payments/InstantSplitProcessor.ts
3665
3707
  function fromHex3(hex) {
@@ -3984,6 +4026,7 @@ function computeHistoryDedupKey(type, tokenId, transferId) {
3984
4026
  if (tokenId) return `${type}_${tokenId}`;
3985
4027
  return `${type}_${crypto.randomUUID()}`;
3986
4028
  }
4029
+ var MAX_SYNCED_HISTORY_ENTRIES = 5e3;
3987
4030
  function enrichWithRegistry(info) {
3988
4031
  const registry = TokenRegistry.getInstance();
3989
4032
  const def = registry.getDefinition(info.coinId);
@@ -4313,6 +4356,8 @@ var PaymentsModule = class _PaymentsModule {
4313
4356
  // Survives page reloads via KV storage so Nostr re-deliveries are ignored
4314
4357
  // even when the confirmed token's in-memory ID differs from v5split_{id}.
4315
4358
  processedSplitGroupIds = /* @__PURE__ */ new Set();
4359
+ // Persistent dedup: tracks V6 combined transfer IDs that have been processed.
4360
+ processedCombinedTransferIds = /* @__PURE__ */ new Set();
4316
4361
  // Storage event subscriptions (push-based sync)
4317
4362
  storageEventUnsubscribers = [];
4318
4363
  syncDebounceTimer = null;
@@ -4406,6 +4451,10 @@ var PaymentsModule = class _PaymentsModule {
4406
4451
  const result = await provider.load();
4407
4452
  if (result.success && result.data) {
4408
4453
  this.loadFromStorageData(result.data);
4454
+ const txfData = result.data;
4455
+ if (txfData._history && txfData._history.length > 0) {
4456
+ await this.importRemoteHistoryEntries(txfData._history);
4457
+ }
4409
4458
  this.log(`Loaded metadata from provider ${id}`);
4410
4459
  break;
4411
4460
  }
@@ -4413,10 +4462,23 @@ var PaymentsModule = class _PaymentsModule {
4413
4462
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4414
4463
  }
4415
4464
  }
4465
+ for (const [id, token] of this.tokens) {
4466
+ try {
4467
+ if (token.sdkData) {
4468
+ const data = JSON.parse(token.sdkData);
4469
+ if (data?._placeholder) {
4470
+ this.tokens.delete(id);
4471
+ console.log(`[Payments] Removed stale placeholder token: ${id}`);
4472
+ }
4473
+ }
4474
+ } catch {
4475
+ }
4476
+ }
4416
4477
  const loadedTokens = Array.from(this.tokens.values()).map((t) => `${t.id.slice(0, 12)}(${t.status})`);
4417
4478
  console.log(`[Payments][DEBUG] load(): from TXF providers: ${this.tokens.size} tokens [${loadedTokens.join(", ")}]`);
4418
4479
  await this.loadPendingV5Tokens();
4419
4480
  await this.loadProcessedSplitGroupIds();
4481
+ await this.loadProcessedCombinedTransferIds();
4420
4482
  await this.loadHistory();
4421
4483
  const pending2 = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
4422
4484
  if (pending2) {
@@ -4508,12 +4570,13 @@ var PaymentsModule = class _PaymentsModule {
4508
4570
  token.status = "transferring";
4509
4571
  this.tokens.set(token.id, token);
4510
4572
  }
4573
+ await this.save();
4511
4574
  await this.saveToOutbox(result, recipientPubkey);
4512
4575
  result.status = "submitted";
4513
4576
  const recipientNametag = peerInfo?.nametag || (request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0);
4514
4577
  const transferMode = request.transferMode ?? "instant";
4515
- if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4516
- if (transferMode === "conservative") {
4578
+ if (transferMode === "conservative") {
4579
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4517
4580
  this.log("Executing conservative split...");
4518
4581
  const splitExecutor = new TokenSplitExecutor({
4519
4582
  stateTransitionClient: stClient,
@@ -4557,27 +4620,59 @@ var PaymentsModule = class _PaymentsModule {
4557
4620
  requestIdHex: splitRequestIdHex
4558
4621
  });
4559
4622
  this.log(`Conservative split transfer completed`);
4560
- } else {
4561
- this.log("Executing instant split...");
4562
- const devMode = this.deps.oracle.isDevMode?.() ?? false;
4623
+ }
4624
+ for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4625
+ const token = tokenWithAmount.uiToken;
4626
+ const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4627
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4628
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4629
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4630
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4631
+ }
4632
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4633
+ const transferTx = commitment.toTransaction(inclusionProof);
4634
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4635
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4636
+ transferTx: JSON.stringify(transferTx.toJSON()),
4637
+ memo: request.memo
4638
+ });
4639
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4640
+ const requestIdBytes = commitment.requestId;
4641
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4642
+ result.tokenTransfers.push({
4643
+ sourceTokenId: token.id,
4644
+ method: "direct",
4645
+ requestIdHex
4646
+ });
4647
+ this.log(`Token ${token.id} sent via CONSERVATIVE, requestId: ${requestIdHex}`);
4648
+ await this.removeToken(token.id);
4649
+ }
4650
+ } else {
4651
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4652
+ const senderPubkey = this.deps.identity.chainPubkey;
4653
+ let changeTokenPlaceholderId = null;
4654
+ let builtSplit = null;
4655
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4656
+ this.log("Building instant split bundle...");
4563
4657
  const executor = new InstantSplitExecutor({
4564
4658
  stateTransitionClient: stClient,
4565
4659
  trustBase,
4566
4660
  signingService,
4567
4661
  devMode
4568
4662
  });
4569
- const instantResult = await executor.executeSplitInstant(
4663
+ builtSplit = await executor.buildSplitBundle(
4570
4664
  splitPlan.tokenToSplit.sdkToken,
4571
4665
  splitPlan.splitAmount,
4572
4666
  splitPlan.remainderAmount,
4573
4667
  splitPlan.coinId,
4574
4668
  recipientAddress,
4575
- this.deps.transport,
4576
- recipientPubkey,
4577
4669
  {
4578
4670
  memo: request.memo,
4579
4671
  onChangeTokenCreated: async (changeToken) => {
4580
4672
  const changeTokenData = changeToken.toJSON();
4673
+ if (changeTokenPlaceholderId && this.tokens.has(changeTokenPlaceholderId)) {
4674
+ this.tokens.delete(changeTokenPlaceholderId);
4675
+ }
4581
4676
  const uiToken = {
4582
4677
  id: crypto.randomUUID(),
4583
4678
  coinId: request.coinId,
@@ -4600,65 +4695,103 @@ var PaymentsModule = class _PaymentsModule {
4600
4695
  }
4601
4696
  }
4602
4697
  );
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
- }
4698
+ this.log(`Split bundle built: splitGroupId=${builtSplit.splitGroupId}`);
4699
+ }
4700
+ const directCommitments = await Promise.all(
4701
+ splitPlan.tokensToTransferDirectly.map(
4702
+ (tw) => this.createSdkCommitment(tw.uiToken, recipientAddress, signingService)
4703
+ )
4704
+ );
4705
+ const directTokenEntries = splitPlan.tokensToTransferDirectly.map(
4706
+ (tw, i) => ({
4707
+ sourceToken: JSON.stringify(tw.sdkToken.toJSON()),
4708
+ commitmentData: JSON.stringify(directCommitments[i].toJSON()),
4709
+ amount: tw.uiToken.amount,
4710
+ coinId: tw.uiToken.coinId,
4711
+ tokenId: extractTokenIdFromSdkData(tw.uiToken.sdkData) || void 0
4712
+ })
4713
+ );
4714
+ const combinedBundle = {
4715
+ version: "6.0",
4716
+ type: "COMBINED_TRANSFER",
4717
+ transferId: result.id,
4718
+ splitBundle: builtSplit?.bundle ?? null,
4719
+ directTokens: directTokenEntries,
4720
+ totalAmount: request.amount.toString(),
4721
+ coinId: request.coinId,
4722
+ senderPubkey,
4723
+ memo: request.memo
4724
+ };
4725
+ console.log(
4726
+ `[Payments] Sending V6 combined bundle: transfer=${result.id.slice(0, 8)}... split=${!!builtSplit} direct=${directTokenEntries.length}`
4727
+ );
4728
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4729
+ token: JSON.stringify(combinedBundle),
4730
+ proof: null,
4731
+ memo: request.memo,
4732
+ sender: { transportPubkey: senderPubkey }
4733
+ });
4734
+ console.log(`[Payments] V6 combined bundle sent successfully`);
4735
+ if (builtSplit) {
4736
+ const bgPromise = builtSplit.startBackground();
4737
+ this.pendingBackgroundTasks.push(bgPromise);
4738
+ }
4739
+ if (builtSplit && splitPlan.remainderAmount) {
4740
+ changeTokenPlaceholderId = crypto.randomUUID();
4741
+ const placeholder = {
4742
+ id: changeTokenPlaceholderId,
4743
+ coinId: request.coinId,
4744
+ symbol: this.getCoinSymbol(request.coinId),
4745
+ name: this.getCoinName(request.coinId),
4746
+ decimals: this.getCoinDecimals(request.coinId),
4747
+ iconUrl: this.getCoinIconUrl(request.coinId),
4748
+ amount: splitPlan.remainderAmount.toString(),
4749
+ status: "transferring",
4750
+ createdAt: Date.now(),
4751
+ updatedAt: Date.now(),
4752
+ sdkData: JSON.stringify({ _placeholder: true })
4753
+ };
4754
+ this.tokens.set(placeholder.id, placeholder);
4755
+ this.log(`Placeholder change token created: ${placeholder.id} (${placeholder.amount})`);
4756
+ }
4757
+ for (const commitment of directCommitments) {
4758
+ stClient.submitTransferCommitment(commitment).catch(
4759
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4760
+ );
4761
+ }
4762
+ if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4609
4763
  await this.removeToken(splitPlan.tokenToSplit.uiToken.id);
4610
4764
  result.tokenTransfers.push({
4611
4765
  sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4612
4766
  method: "split",
4613
- splitGroupId: instantResult.splitGroupId,
4614
- nostrEventId: instantResult.nostrEventId
4767
+ splitGroupId: builtSplit.splitGroupId
4615
4768
  });
4616
- this.log(`Instant split transfer completed`);
4617
4769
  }
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
4770
+ for (let i = 0; i < splitPlan.tokensToTransferDirectly.length; i++) {
4771
+ const token = splitPlan.tokensToTransferDirectly[i].uiToken;
4772
+ const commitment = directCommitments[i];
4773
+ const requestIdBytes = commitment.requestId;
4774
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4775
+ result.tokenTransfers.push({
4776
+ sourceTokenId: token.id,
4777
+ method: "direct",
4778
+ requestIdHex
4642
4779
  });
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
- );
4780
+ await this.removeToken(token.id);
4647
4781
  }
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);
4782
+ this.log(`V6 combined transfer completed`);
4657
4783
  }
4658
4784
  result.status = "delivered";
4659
4785
  await this.save();
4660
4786
  await this.removeFromOutbox(result.id);
4661
4787
  result.status = "completed";
4788
+ const tokenMap = new Map(result.tokens.map((t) => [t.id, t]));
4789
+ const sentTokenIds = result.tokenTransfers.map((tt) => ({
4790
+ id: tt.sourceTokenId,
4791
+ // For split tokens, use splitAmount (the portion sent), not the original token amount
4792
+ amount: tt.method === "split" ? splitPlan.splitAmount?.toString() || "0" : tokenMap.get(tt.sourceTokenId)?.amount || "0",
4793
+ source: tt.method === "split" ? "split" : "direct"
4794
+ }));
4662
4795
  const sentTokenId = result.tokens[0] ? extractTokenIdFromSdkData(result.tokens[0].sdkData) : void 0;
4663
4796
  await this.addToHistory({
4664
4797
  type: "SENT",
@@ -4671,7 +4804,8 @@ var PaymentsModule = class _PaymentsModule {
4671
4804
  recipientAddress: peerInfo?.directAddress || recipientAddress?.toString() || recipientPubkey,
4672
4805
  memo: request.memo,
4673
4806
  transferId: result.id,
4674
- tokenId: sentTokenId || void 0
4807
+ tokenId: sentTokenId || void 0,
4808
+ tokenIds: sentTokenIds.length > 0 ? sentTokenIds : void 0
4675
4809
  });
4676
4810
  this.deps.emitEvent("transfer:confirmed", result);
4677
4811
  return result;
@@ -4841,6 +4975,267 @@ var PaymentsModule = class _PaymentsModule {
4841
4975
  };
4842
4976
  }
4843
4977
  }
4978
+ // ===========================================================================
4979
+ // Shared Helpers for V5 and V6 Receiver Processing
4980
+ // ===========================================================================
4981
+ /**
4982
+ * Save a V5 split bundle as an unconfirmed token (shared by V5 standalone and V6 combined).
4983
+ * Returns the created UI token, or null if deduped.
4984
+ *
4985
+ * @param deferPersistence - If true, skip addToken/save calls (caller batches them).
4986
+ * The token is still added to the in-memory map for dedup; caller must call save().
4987
+ */
4988
+ async saveUnconfirmedV5Token(bundle, senderPubkey, deferPersistence = false) {
4989
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
4990
+ if (this.tokens.has(deterministicId) || this.processedSplitGroupIds.has(bundle.splitGroupId)) {
4991
+ console.log(`[Payments] V5 bundle ${bundle.splitGroupId.slice(0, 12)}... already processed, skipping`);
4992
+ return null;
4993
+ }
4994
+ const registry = TokenRegistry.getInstance();
4995
+ const pendingData = {
4996
+ type: "v5_bundle",
4997
+ stage: "RECEIVED",
4998
+ bundleJson: JSON.stringify(bundle),
4999
+ senderPubkey,
5000
+ savedAt: Date.now(),
5001
+ attemptCount: 0
5002
+ };
5003
+ const uiToken = {
5004
+ id: deterministicId,
5005
+ coinId: bundle.coinId,
5006
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
5007
+ name: registry.getName(bundle.coinId) || bundle.coinId,
5008
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
5009
+ amount: bundle.amount,
5010
+ status: "submitted",
5011
+ // UNCONFIRMED
5012
+ createdAt: Date.now(),
5013
+ updatedAt: Date.now(),
5014
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
5015
+ };
5016
+ this.processedSplitGroupIds.add(bundle.splitGroupId);
5017
+ if (deferPersistence) {
5018
+ this.tokens.set(uiToken.id, uiToken);
5019
+ } else {
5020
+ await this.addToken(uiToken);
5021
+ await this.saveProcessedSplitGroupIds();
5022
+ }
5023
+ return uiToken;
5024
+ }
5025
+ /**
5026
+ * Save a commitment-only (NOSTR-FIRST) token and start proof polling.
5027
+ * Shared by standalone NOSTR-FIRST handler and V6 combined handler.
5028
+ * Returns the created UI token, or null if deduped/tombstoned.
5029
+ *
5030
+ * @param deferPersistence - If true, skip save() and commitment submission
5031
+ * (caller batches them). Token is added to in-memory map + proof polling is queued.
5032
+ * @param skipGenesisDedup - If true, skip genesis-ID-only dedup. V6 handler sets this
5033
+ * because bundle-level dedup protects against replays, and split children share genesis IDs.
5034
+ */
5035
+ async saveCommitmentOnlyToken(sourceTokenInput, commitmentInput, senderPubkey, deferPersistence = false, skipGenesisDedup = false) {
5036
+ const tokenInfo = await parseTokenInfo(sourceTokenInput);
5037
+ const sdkData = typeof sourceTokenInput === "string" ? sourceTokenInput : JSON.stringify(sourceTokenInput);
5038
+ const nostrTokenId = extractTokenIdFromSdkData(sdkData);
5039
+ const nostrStateHash = extractStateHashFromSdkData(sdkData);
5040
+ if (nostrTokenId && nostrStateHash && this.isStateTombstoned(nostrTokenId, nostrStateHash)) {
5041
+ this.log(`NOSTR-FIRST: Rejecting tombstoned token ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`);
5042
+ return null;
5043
+ }
5044
+ if (nostrTokenId) {
5045
+ for (const existing of this.tokens.values()) {
5046
+ const existingTokenId = extractTokenIdFromSdkData(existing.sdkData);
5047
+ if (existingTokenId !== nostrTokenId) continue;
5048
+ const existingStateHash = extractStateHashFromSdkData(existing.sdkData);
5049
+ if (nostrStateHash && existingStateHash === nostrStateHash) {
5050
+ console.log(
5051
+ `[Payments] NOSTR-FIRST: Skipping duplicate token state ${nostrTokenId.slice(0, 8)}..._${nostrStateHash.slice(0, 8)}...`
5052
+ );
5053
+ return null;
5054
+ }
5055
+ if (!skipGenesisDedup) {
5056
+ console.log(
5057
+ `[Payments] NOSTR-FIRST: Skipping replay of finalized token ${nostrTokenId.slice(0, 8)}...`
5058
+ );
5059
+ return null;
5060
+ }
5061
+ }
5062
+ }
5063
+ const token = {
5064
+ id: crypto.randomUUID(),
5065
+ coinId: tokenInfo.coinId,
5066
+ symbol: tokenInfo.symbol,
5067
+ name: tokenInfo.name,
5068
+ decimals: tokenInfo.decimals,
5069
+ iconUrl: tokenInfo.iconUrl,
5070
+ amount: tokenInfo.amount,
5071
+ status: "submitted",
5072
+ // NOSTR-FIRST: unconfirmed until proof
5073
+ createdAt: Date.now(),
5074
+ updatedAt: Date.now(),
5075
+ sdkData
5076
+ };
5077
+ this.tokens.set(token.id, token);
5078
+ if (!deferPersistence) {
5079
+ await this.save();
5080
+ }
5081
+ try {
5082
+ const commitment = await TransferCommitment4.fromJSON(commitmentInput);
5083
+ const requestIdBytes = commitment.requestId;
5084
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
5085
+ if (!deferPersistence) {
5086
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5087
+ if (stClient) {
5088
+ const response = await stClient.submitTransferCommitment(commitment);
5089
+ this.log(`NOSTR-FIRST recipient commitment submit: ${response.status}`);
5090
+ }
5091
+ }
5092
+ this.addProofPollingJob({
5093
+ tokenId: token.id,
5094
+ requestIdHex,
5095
+ commitmentJson: JSON.stringify(commitmentInput),
5096
+ startedAt: Date.now(),
5097
+ attemptCount: 0,
5098
+ lastAttemptAt: 0,
5099
+ onProofReceived: async (tokenId) => {
5100
+ await this.finalizeReceivedToken(tokenId, sourceTokenInput, commitmentInput);
5101
+ }
5102
+ });
5103
+ } catch (err) {
5104
+ console.error("[Payments] Failed to parse commitment for proof polling:", err);
5105
+ }
5106
+ return token;
5107
+ }
5108
+ // ===========================================================================
5109
+ // Combined Transfer V6 — Receiver
5110
+ // ===========================================================================
5111
+ /**
5112
+ * Process a received COMBINED_TRANSFER V6 bundle.
5113
+ *
5114
+ * Unpacks a single Nostr message into its component tokens:
5115
+ * - Optional V5 split bundle (saved as unconfirmed, resolved lazily)
5116
+ * - Zero or more direct tokens (saved as unconfirmed, proof-polled)
5117
+ *
5118
+ * Emits ONE transfer:incoming event and records ONE history entry.
5119
+ */
5120
+ async processCombinedTransferBundle(bundle, senderPubkey) {
5121
+ this.ensureInitialized();
5122
+ if (!this.loaded && this.loadedPromise) {
5123
+ await this.loadedPromise;
5124
+ }
5125
+ if (this.processedCombinedTransferIds.has(bundle.transferId)) {
5126
+ console.log(`[Payments] V6 combined transfer ${bundle.transferId.slice(0, 12)}... already processed, skipping`);
5127
+ return;
5128
+ }
5129
+ console.log(
5130
+ `[Payments] Processing V6 combined transfer ${bundle.transferId.slice(0, 12)}... (split=${!!bundle.splitBundle}, direct=${bundle.directTokens.length})`
5131
+ );
5132
+ const allTokens = [];
5133
+ const tokenBreakdown = [];
5134
+ const parsedDirectEntries = bundle.directTokens.map((entry) => ({
5135
+ sourceToken: typeof entry.sourceToken === "string" ? JSON.parse(entry.sourceToken) : entry.sourceToken,
5136
+ commitment: typeof entry.commitmentData === "string" ? JSON.parse(entry.commitmentData) : entry.commitmentData
5137
+ }));
5138
+ if (bundle.splitBundle) {
5139
+ const splitToken = await this.saveUnconfirmedV5Token(bundle.splitBundle, senderPubkey, true);
5140
+ if (splitToken) {
5141
+ allTokens.push(splitToken);
5142
+ tokenBreakdown.push({ id: splitToken.id, amount: splitToken.amount, source: "split" });
5143
+ } else {
5144
+ console.warn(`[Payments] V6: split token was deduped/failed \u2014 amount=${bundle.splitBundle.amount}`);
5145
+ }
5146
+ }
5147
+ const directResults = await Promise.all(
5148
+ parsedDirectEntries.map(
5149
+ ({ sourceToken, commitment }) => this.saveCommitmentOnlyToken(sourceToken, commitment, senderPubkey, true, true)
5150
+ )
5151
+ );
5152
+ for (let i = 0; i < directResults.length; i++) {
5153
+ const token = directResults[i];
5154
+ if (token) {
5155
+ allTokens.push(token);
5156
+ tokenBreakdown.push({ id: token.id, amount: token.amount, source: "direct" });
5157
+ } else {
5158
+ const entry = bundle.directTokens[i];
5159
+ console.warn(
5160
+ `[Payments] V6: direct token #${i} dropped (amount=${entry.amount}, tokenId=${entry.tokenId?.slice(0, 12) ?? "N/A"})`
5161
+ );
5162
+ }
5163
+ }
5164
+ if (allTokens.length === 0) {
5165
+ console.log(`[Payments] V6 combined transfer: all tokens deduped, nothing to save`);
5166
+ return;
5167
+ }
5168
+ this.processedCombinedTransferIds.add(bundle.transferId);
5169
+ const [senderInfo] = await Promise.all([
5170
+ this.resolveSenderInfo(senderPubkey),
5171
+ this.save(),
5172
+ this.saveProcessedCombinedTransferIds(),
5173
+ ...bundle.splitBundle ? [this.saveProcessedSplitGroupIds()] : []
5174
+ ]);
5175
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5176
+ if (stClient) {
5177
+ for (const { commitment } of parsedDirectEntries) {
5178
+ TransferCommitment4.fromJSON(commitment).then(
5179
+ (c) => stClient.submitTransferCommitment(c)
5180
+ ).catch(
5181
+ (err) => console.error("[Payments] V6 background commitment submit failed:", err)
5182
+ );
5183
+ }
5184
+ }
5185
+ this.deps.emitEvent("transfer:incoming", {
5186
+ id: bundle.transferId,
5187
+ senderPubkey,
5188
+ senderNametag: senderInfo.senderNametag,
5189
+ tokens: allTokens,
5190
+ memo: bundle.memo,
5191
+ receivedAt: Date.now()
5192
+ });
5193
+ const actualAmount = allTokens.reduce((sum, t) => sum + BigInt(t.amount || "0"), 0n).toString();
5194
+ await this.addToHistory({
5195
+ type: "RECEIVED",
5196
+ amount: actualAmount,
5197
+ coinId: bundle.coinId,
5198
+ symbol: allTokens[0]?.symbol || bundle.coinId,
5199
+ timestamp: Date.now(),
5200
+ senderPubkey,
5201
+ ...senderInfo,
5202
+ memo: bundle.memo,
5203
+ transferId: bundle.transferId,
5204
+ tokenId: allTokens[0]?.id,
5205
+ tokenIds: tokenBreakdown
5206
+ });
5207
+ if (bundle.splitBundle) {
5208
+ this.resolveUnconfirmed().catch(() => {
5209
+ });
5210
+ this.scheduleResolveUnconfirmed();
5211
+ }
5212
+ }
5213
+ /**
5214
+ * Persist processed combined transfer IDs to KV storage.
5215
+ */
5216
+ async saveProcessedCombinedTransferIds() {
5217
+ const ids = Array.from(this.processedCombinedTransferIds);
5218
+ if (ids.length > 0) {
5219
+ await this.deps.storage.set(
5220
+ STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS,
5221
+ JSON.stringify(ids)
5222
+ );
5223
+ }
5224
+ }
5225
+ /**
5226
+ * Load processed combined transfer IDs from KV storage.
5227
+ */
5228
+ async loadProcessedCombinedTransferIds() {
5229
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PROCESSED_COMBINED_TRANSFER_IDS);
5230
+ if (!data) return;
5231
+ try {
5232
+ const ids = JSON.parse(data);
5233
+ for (const id of ids) {
5234
+ this.processedCombinedTransferIds.add(id);
5235
+ }
5236
+ } catch {
5237
+ }
5238
+ }
4844
5239
  /**
4845
5240
  * Process a received INSTANT_SPLIT bundle.
4846
5241
  *
@@ -4864,36 +5259,10 @@ var PaymentsModule = class _PaymentsModule {
4864
5259
  return this.processInstantSplitBundleSync(bundle, senderPubkey, memo);
4865
5260
  }
4866
5261
  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`);
5262
+ const uiToken = await this.saveUnconfirmedV5Token(bundle, senderPubkey);
5263
+ if (!uiToken) {
4870
5264
  return { success: true, durationMs: 0 };
4871
5265
  }
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
5266
  const senderInfo = await this.resolveSenderInfo(senderPubkey);
4898
5267
  await this.addToHistory({
4899
5268
  type: "RECEIVED",
@@ -4904,7 +5273,7 @@ var PaymentsModule = class _PaymentsModule {
4904
5273
  senderPubkey,
4905
5274
  ...senderInfo,
4906
5275
  memo,
4907
- tokenId: deterministicId
5276
+ tokenId: uiToken.id
4908
5277
  });
4909
5278
  this.deps.emitEvent("transfer:incoming", {
4910
5279
  id: bundle.splitGroupId,
@@ -5554,16 +5923,18 @@ var PaymentsModule = class _PaymentsModule {
5554
5923
  }
5555
5924
  /**
5556
5925
  * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5557
- * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
5926
+ * Excludes tokens with status 'spent' or 'invalid'.
5927
+ * Tokens with status 'transferring' are counted as unconfirmed (visible in UI as "Sending").
5558
5928
  */
5559
5929
  aggregateTokens(coinId) {
5560
5930
  const assetsMap = /* @__PURE__ */ new Map();
5561
5931
  for (const token of this.tokens.values()) {
5562
- if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
5932
+ if (token.status === "spent" || token.status === "invalid") continue;
5563
5933
  if (coinId && token.coinId !== coinId) continue;
5564
5934
  const key = token.coinId;
5565
5935
  const amount = BigInt(token.amount);
5566
5936
  const isConfirmed = token.status === "confirmed";
5937
+ const isTransferring = token.status === "transferring";
5567
5938
  const existing = assetsMap.get(key);
5568
5939
  if (existing) {
5569
5940
  if (isConfirmed) {
@@ -5573,6 +5944,7 @@ var PaymentsModule = class _PaymentsModule {
5573
5944
  existing.unconfirmedAmount += amount;
5574
5945
  existing.unconfirmedTokenCount++;
5575
5946
  }
5947
+ if (isTransferring) existing.transferringTokenCount++;
5576
5948
  } else {
5577
5949
  assetsMap.set(key, {
5578
5950
  coinId: token.coinId,
@@ -5583,7 +5955,8 @@ var PaymentsModule = class _PaymentsModule {
5583
5955
  confirmedAmount: isConfirmed ? amount : 0n,
5584
5956
  unconfirmedAmount: isConfirmed ? 0n : amount,
5585
5957
  confirmedTokenCount: isConfirmed ? 1 : 0,
5586
- unconfirmedTokenCount: isConfirmed ? 0 : 1
5958
+ unconfirmedTokenCount: isConfirmed ? 0 : 1,
5959
+ transferringTokenCount: isTransferring ? 1 : 0
5587
5960
  });
5588
5961
  }
5589
5962
  }
@@ -5601,6 +5974,7 @@ var PaymentsModule = class _PaymentsModule {
5601
5974
  unconfirmedAmount: raw.unconfirmedAmount.toString(),
5602
5975
  confirmedTokenCount: raw.confirmedTokenCount,
5603
5976
  unconfirmedTokenCount: raw.unconfirmedTokenCount,
5977
+ transferringTokenCount: raw.transferringTokenCount,
5604
5978
  priceUsd: null,
5605
5979
  priceEur: null,
5606
5980
  change24h: null,
@@ -6453,6 +6827,33 @@ var PaymentsModule = class _PaymentsModule {
6453
6827
  }
6454
6828
  }
6455
6829
  }
6830
+ /**
6831
+ * Import history entries from remote TXF data into local store.
6832
+ * Delegates to the local TokenStorageProvider's importHistoryEntries() for
6833
+ * persistent storage, with in-memory fallback.
6834
+ * Reused by both load() (initial IPFS fetch) and _doSync() (merge result).
6835
+ */
6836
+ async importRemoteHistoryEntries(entries) {
6837
+ if (entries.length === 0) return 0;
6838
+ const provider = this.getLocalTokenStorageProvider();
6839
+ if (provider?.importHistoryEntries) {
6840
+ const imported2 = await provider.importHistoryEntries(entries);
6841
+ if (imported2 > 0) {
6842
+ this._historyCache = await provider.getHistoryEntries();
6843
+ }
6844
+ return imported2;
6845
+ }
6846
+ const existingKeys = new Set(this._historyCache.map((e) => e.dedupKey));
6847
+ let imported = 0;
6848
+ for (const entry of entries) {
6849
+ if (!existingKeys.has(entry.dedupKey)) {
6850
+ this._historyCache.push(entry);
6851
+ existingKeys.add(entry.dedupKey);
6852
+ imported++;
6853
+ }
6854
+ }
6855
+ return imported;
6856
+ }
6456
6857
  /**
6457
6858
  * Get the first local token storage provider (for history operations).
6458
6859
  */
@@ -6700,6 +7101,13 @@ var PaymentsModule = class _PaymentsModule {
6700
7101
  if (this.nametags.length === 0 && savedNametags.length > 0) {
6701
7102
  this.nametags = savedNametags;
6702
7103
  }
7104
+ const txfData = result.merged;
7105
+ if (txfData._history && txfData._history.length > 0) {
7106
+ const imported = await this.importRemoteHistoryEntries(txfData._history);
7107
+ if (imported > 0) {
7108
+ this.log(`Imported ${imported} history entries from IPFS sync`);
7109
+ }
7110
+ }
6703
7111
  totalAdded += result.added;
6704
7112
  totalRemoved += result.removed;
6705
7113
  }
@@ -6998,7 +7406,7 @@ var PaymentsModule = class _PaymentsModule {
6998
7406
  /**
6999
7407
  * Handle NOSTR-FIRST commitment-only transfer (recipient side)
7000
7408
  * 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.
7409
+ * Delegates to saveCommitmentOnlyToken() helper, then emits event + records history.
7002
7410
  */
7003
7411
  async handleCommitmentOnlyTransfer(transfer, payload) {
7004
7412
  try {
@@ -7008,41 +7416,22 @@ var PaymentsModule = class _PaymentsModule {
7008
7416
  console.warn("[Payments] Invalid NOSTR-FIRST transfer format");
7009
7417
  return;
7010
7418
  }
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}`);
7419
+ const token = await this.saveCommitmentOnlyToken(
7420
+ sourceTokenInput,
7421
+ commitmentInput,
7422
+ transfer.senderTransportPubkey
7423
+ );
7424
+ if (!token) return;
7036
7425
  const senderInfo = await this.resolveSenderInfo(transfer.senderTransportPubkey);
7037
- const incomingTransfer = {
7426
+ this.deps.emitEvent("transfer:incoming", {
7038
7427
  id: transfer.id,
7039
7428
  senderPubkey: transfer.senderTransportPubkey,
7040
7429
  senderNametag: senderInfo.senderNametag,
7041
7430
  tokens: [token],
7042
7431
  memo: payload.memo,
7043
7432
  receivedAt: transfer.timestamp
7044
- };
7045
- this.deps.emitEvent("transfer:incoming", incomingTransfer);
7433
+ });
7434
+ const nostrTokenId = extractTokenIdFromSdkData(token.sdkData);
7046
7435
  await this.addToHistory({
7047
7436
  type: "RECEIVED",
7048
7437
  amount: token.amount,
@@ -7054,29 +7443,6 @@ var PaymentsModule = class _PaymentsModule {
7054
7443
  memo: payload.memo,
7055
7444
  tokenId: nostrTokenId || token.id
7056
7445
  });
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
7446
  } catch (error) {
7081
7447
  console.error("[Payments] Failed to process NOSTR-FIRST transfer:", error);
7082
7448
  }
@@ -7195,6 +7561,28 @@ var PaymentsModule = class _PaymentsModule {
7195
7561
  try {
7196
7562
  const payload = transfer.payload;
7197
7563
  console.log("[Payments][DEBUG] handleIncomingTransfer: keys=", Object.keys(payload).join(","));
7564
+ let combinedBundle = null;
7565
+ if (isCombinedTransferBundleV6(payload)) {
7566
+ combinedBundle = payload;
7567
+ } else if (payload.token) {
7568
+ try {
7569
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7570
+ if (isCombinedTransferBundleV6(inner)) {
7571
+ combinedBundle = inner;
7572
+ }
7573
+ } catch {
7574
+ }
7575
+ }
7576
+ if (combinedBundle) {
7577
+ this.log("Processing COMBINED_TRANSFER V6 bundle...");
7578
+ try {
7579
+ await this.processCombinedTransferBundle(combinedBundle, transfer.senderTransportPubkey);
7580
+ this.log("COMBINED_TRANSFER V6 processed successfully");
7581
+ } catch (err) {
7582
+ console.error("[Payments] COMBINED_TRANSFER V6 processing error:", err);
7583
+ }
7584
+ return;
7585
+ }
7198
7586
  let instantBundle = null;
7199
7587
  if (isInstantSplitBundle(payload)) {
7200
7588
  instantBundle = payload;
@@ -7346,17 +7734,19 @@ var PaymentsModule = class _PaymentsModule {
7346
7734
  memo: payload.memo,
7347
7735
  tokenId: incomingTokenId || token.id
7348
7736
  });
7737
+ const incomingTransfer = {
7738
+ id: transfer.id,
7739
+ senderPubkey: transfer.senderTransportPubkey,
7740
+ senderNametag: senderInfo.senderNametag,
7741
+ tokens: [token],
7742
+ memo: payload.memo,
7743
+ receivedAt: transfer.timestamp
7744
+ };
7745
+ this.deps.emitEvent("transfer:incoming", incomingTransfer);
7746
+ this.log(`Incoming transfer processed: ${token.id}, ${token.amount} ${token.symbol}`);
7747
+ } else {
7748
+ this.log(`Duplicate transfer ignored: ${token.id}, ${token.amount} ${token.symbol}`);
7349
7749
  }
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
7750
  } catch (error) {
7361
7751
  console.error("[Payments] Failed to process incoming transfer:", error);
7362
7752
  }
@@ -7425,6 +7815,7 @@ var PaymentsModule = class _PaymentsModule {
7425
7815
  return data ? JSON.parse(data) : [];
7426
7816
  }
7427
7817
  async createStorageData() {
7818
+ const sorted = [...this._historyCache].sort((a, b) => b.timestamp - a.timestamp);
7428
7819
  return await buildTxfStorageData(
7429
7820
  Array.from(this.tokens.values()),
7430
7821
  {
@@ -7436,7 +7827,8 @@ var PaymentsModule = class _PaymentsModule {
7436
7827
  nametags: this.nametags,
7437
7828
  tombstones: this.tombstones,
7438
7829
  archivedTokens: this.archivedTokens,
7439
- forkedTokens: this.forkedTokens
7830
+ forkedTokens: this.forkedTokens,
7831
+ historyEntries: sorted.slice(0, MAX_SYNCED_HISTORY_ENTRIES)
7440
7832
  }
7441
7833
  );
7442
7834
  }