@unicitylabs/sphere-sdk 0.2.3 → 0.2.5

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.
package/dist/index.js CHANGED
@@ -2272,6 +2272,7 @@ import { MintCommitment } from "@unicitylabs/state-transition-sdk/lib/transactio
2272
2272
  import { HashAlgorithm as HashAlgorithm2 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
2273
2273
  import { UnmaskedPredicate as UnmaskedPredicate2 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
2274
2274
  import { waitInclusionProof as waitInclusionProof2 } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils";
2275
+ import { normalizeNametag } from "@unicitylabs/nostr-js-sdk";
2275
2276
  var UNICITY_TOKEN_TYPE_HEX = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
2276
2277
  var NametagMinter = class {
2277
2278
  client;
@@ -2296,7 +2297,8 @@ var NametagMinter = class {
2296
2297
  */
2297
2298
  async isNametagAvailable(nametag) {
2298
2299
  try {
2299
- const cleanNametag = nametag.replace("@", "").trim();
2300
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2301
+ const cleanNametag = normalizeNametag(stripped);
2300
2302
  const nametagTokenId = await TokenId2.fromNameTag(cleanNametag);
2301
2303
  const isMinted = await this.client.isMinted(this.trustBase, nametagTokenId);
2302
2304
  return !isMinted;
@@ -2313,7 +2315,8 @@ var NametagMinter = class {
2313
2315
  * @returns MintNametagResult with token if successful
2314
2316
  */
2315
2317
  async mintNametag(nametag, ownerAddress) {
2316
- const cleanNametag = nametag.replace("@", "").trim();
2318
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2319
+ const cleanNametag = normalizeNametag(stripped);
2317
2320
  this.log(`Starting mint for nametag: ${cleanNametag}`);
2318
2321
  try {
2319
2322
  const nametagTokenId = await TokenId2.fromNameTag(cleanNametag);
@@ -2467,7 +2470,9 @@ var STORAGE_KEYS_ADDRESS = {
2467
2470
  /** Messages for this address */
2468
2471
  MESSAGES: "messages",
2469
2472
  /** Transaction history for this address */
2470
- TRANSACTION_HISTORY: "transaction_history"
2473
+ TRANSACTION_HISTORY: "transaction_history",
2474
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
2475
+ PENDING_V5_TOKENS: "pending_v5_tokens"
2471
2476
  };
2472
2477
  var STORAGE_KEYS = {
2473
2478
  ...STORAGE_KEYS_GLOBAL,
@@ -2882,6 +2887,18 @@ function parseTxfStorageData(data) {
2882
2887
  result.validationErrors.push(`Forked token ${parsed.tokenId}: invalid structure`);
2883
2888
  }
2884
2889
  }
2890
+ } else if (key.startsWith("token-")) {
2891
+ try {
2892
+ const entry = storageData[key];
2893
+ const txfToken = entry?.token;
2894
+ if (txfToken?.genesis?.data?.tokenId) {
2895
+ const tokenId = txfToken.genesis.data.tokenId;
2896
+ const token = txfToToken(tokenId, txfToken);
2897
+ result.tokens.push(token);
2898
+ }
2899
+ } catch (err) {
2900
+ result.validationErrors.push(`Token ${key}: ${err}`);
2901
+ }
2885
2902
  }
2886
2903
  }
2887
2904
  return result;
@@ -3457,8 +3474,9 @@ var InstantSplitExecutor = class {
3457
3474
  const criticalPathDuration = performance.now() - startTime;
3458
3475
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3459
3476
  options?.onNostrDelivered?.(nostrEventId);
3477
+ let backgroundPromise;
3460
3478
  if (!options?.skipBackground) {
3461
- this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3479
+ backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3462
3480
  signingService: this.signingService,
3463
3481
  tokenType: tokenToSplit.type,
3464
3482
  coinId,
@@ -3474,7 +3492,8 @@ var InstantSplitExecutor = class {
3474
3492
  nostrEventId,
3475
3493
  splitGroupId,
3476
3494
  criticalPathDurationMs: criticalPathDuration,
3477
- backgroundStarted: !options?.skipBackground
3495
+ backgroundStarted: !options?.skipBackground,
3496
+ backgroundPromise
3478
3497
  };
3479
3498
  } catch (error) {
3480
3499
  const duration = performance.now() - startTime;
@@ -3536,7 +3555,7 @@ var InstantSplitExecutor = class {
3536
3555
  this.client.submitMintCommitment(recipientMintCommitment).then((res) => ({ type: "recipientMint", status: res.status })).catch((err) => ({ type: "recipientMint", status: "ERROR", error: err })),
3537
3556
  this.client.submitTransferCommitment(transferCommitment).then((res) => ({ type: "transfer", status: res.status })).catch((err) => ({ type: "transfer", status: "ERROR", error: err }))
3538
3557
  ]);
3539
- submissions.then(async (results) => {
3558
+ return submissions.then(async (results) => {
3540
3559
  const submitDuration = performance.now() - startTime;
3541
3560
  console.log(`[InstantSplit] Background: Submissions complete in ${submitDuration.toFixed(0)}ms`);
3542
3561
  context.onProgress?.({
@@ -4001,6 +4020,11 @@ import { AddressScheme } from "@unicitylabs/state-transition-sdk/lib/address/Add
4001
4020
  import { UnmaskedPredicate as UnmaskedPredicate5 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
4002
4021
  import { TokenState as TokenState5 } from "@unicitylabs/state-transition-sdk/lib/token/TokenState";
4003
4022
  import { HashAlgorithm as HashAlgorithm5 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
4023
+ import { TokenType as TokenType3 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
4024
+ import { MintCommitment as MintCommitment3 } from "@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment";
4025
+ import { MintTransactionData as MintTransactionData3 } from "@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData";
4026
+ import { waitInclusionProof as waitInclusionProof5 } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils";
4027
+ import { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/transaction/InclusionProof";
4004
4028
  function enrichWithRegistry(info) {
4005
4029
  const registry = TokenRegistry.getInstance();
4006
4030
  const def = registry.getDefinition(info.coinId);
@@ -4198,6 +4222,13 @@ function extractTokenStateKey(token) {
4198
4222
  if (!tokenId || !stateHash) return null;
4199
4223
  return createTokenStateKey(tokenId, stateHash);
4200
4224
  }
4225
+ function fromHex4(hex) {
4226
+ const bytes = new Uint8Array(hex.length / 2);
4227
+ for (let i = 0; i < hex.length; i += 2) {
4228
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
4229
+ }
4230
+ return bytes;
4231
+ }
4201
4232
  function hasSameGenesisTokenId(t1, t2) {
4202
4233
  const id1 = extractTokenIdFromSdkData(t1.sdkData);
4203
4234
  const id2 = extractTokenIdFromSdkData(t2.sdkData);
@@ -4287,6 +4318,7 @@ var PaymentsModule = class _PaymentsModule {
4287
4318
  // Token State
4288
4319
  tokens = /* @__PURE__ */ new Map();
4289
4320
  pendingTransfers = /* @__PURE__ */ new Map();
4321
+ pendingBackgroundTasks = [];
4290
4322
  // Repository State (tombstones, archives, forked, history)
4291
4323
  tombstones = [];
4292
4324
  archivedTokens = /* @__PURE__ */ new Map();
@@ -4311,6 +4343,12 @@ var PaymentsModule = class _PaymentsModule {
4311
4343
  // Poll every 2s
4312
4344
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
4313
4345
  // Max 30 attempts (~60s)
4346
+ // Storage event subscriptions (push-based sync)
4347
+ storageEventUnsubscribers = [];
4348
+ syncDebounceTimer = null;
4349
+ static SYNC_DEBOUNCE_MS = 500;
4350
+ /** Sync coalescing: concurrent sync() calls share the same operation */
4351
+ _syncInProgress = null;
4314
4352
  constructor(config) {
4315
4353
  this.moduleConfig = {
4316
4354
  autoSync: config?.autoSync ?? true,
@@ -4321,7 +4359,11 @@ var PaymentsModule = class _PaymentsModule {
4321
4359
  };
4322
4360
  this.l1 = config?.l1 === null ? null : new L1PaymentsModule(config?.l1);
4323
4361
  }
4324
- /** Get module configuration */
4362
+ /**
4363
+ * Get the current module configuration (excluding L1 config).
4364
+ *
4365
+ * @returns Resolved configuration with all defaults applied.
4366
+ */
4325
4367
  getConfig() {
4326
4368
  return this.moduleConfig;
4327
4369
  }
@@ -4362,9 +4404,9 @@ var PaymentsModule = class _PaymentsModule {
4362
4404
  transport: deps.transport
4363
4405
  });
4364
4406
  }
4365
- this.unsubscribeTransfers = deps.transport.onTokenTransfer((transfer) => {
4366
- this.handleIncomingTransfer(transfer);
4367
- });
4407
+ this.unsubscribeTransfers = deps.transport.onTokenTransfer(
4408
+ (transfer) => this.handleIncomingTransfer(transfer)
4409
+ );
4368
4410
  if (deps.transport.onPaymentRequest) {
4369
4411
  this.unsubscribePaymentRequests = deps.transport.onPaymentRequest((request) => {
4370
4412
  this.handleIncomingPaymentRequest(request);
@@ -4375,9 +4417,14 @@ var PaymentsModule = class _PaymentsModule {
4375
4417
  this.handlePaymentRequestResponse(response);
4376
4418
  });
4377
4419
  }
4420
+ this.subscribeToStorageEvents();
4378
4421
  }
4379
4422
  /**
4380
- * Load tokens from storage
4423
+ * Load all token data from storage providers and restore wallet state.
4424
+ *
4425
+ * Loads tokens, nametag data, transaction history, and pending transfers
4426
+ * from configured storage providers. Restores pending V5 tokens and
4427
+ * triggers a fire-and-forget {@link resolveUnconfirmed} call.
4381
4428
  */
4382
4429
  async load() {
4383
4430
  this.ensureInitialized();
@@ -4394,6 +4441,7 @@ var PaymentsModule = class _PaymentsModule {
4394
4441
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4395
4442
  }
4396
4443
  }
4444
+ await this.loadPendingV5Tokens();
4397
4445
  await this.loadTokensFromFileStorage();
4398
4446
  await this.loadNametagFromFileStorage();
4399
4447
  const historyData = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.TRANSACTION_HISTORY);
@@ -4411,9 +4459,14 @@ var PaymentsModule = class _PaymentsModule {
4411
4459
  this.pendingTransfers.set(transfer.id, transfer);
4412
4460
  }
4413
4461
  }
4462
+ this.resolveUnconfirmed().catch(() => {
4463
+ });
4414
4464
  }
4415
4465
  /**
4416
- * Cleanup resources
4466
+ * Cleanup all subscriptions, polling jobs, and pending resolvers.
4467
+ *
4468
+ * Should be called when the wallet is being shut down or the module is
4469
+ * no longer needed. Also destroys the L1 sub-module if present.
4417
4470
  */
4418
4471
  destroy() {
4419
4472
  this.unsubscribeTransfers?.();
@@ -4431,6 +4484,7 @@ var PaymentsModule = class _PaymentsModule {
4431
4484
  resolver.reject(new Error("Module destroyed"));
4432
4485
  }
4433
4486
  this.pendingResponseResolvers.clear();
4487
+ this.unsubscribeStorageEvents();
4434
4488
  if (this.l1) {
4435
4489
  this.l1.destroy();
4436
4490
  }
@@ -4447,7 +4501,8 @@ var PaymentsModule = class _PaymentsModule {
4447
4501
  const result = {
4448
4502
  id: crypto.randomUUID(),
4449
4503
  status: "pending",
4450
- tokens: []
4504
+ tokens: [],
4505
+ tokenTransfers: []
4451
4506
  };
4452
4507
  try {
4453
4508
  const peerInfo = await this.deps.transport.resolve?.(request.recipient) ?? null;
@@ -4484,69 +4539,147 @@ var PaymentsModule = class _PaymentsModule {
4484
4539
  await this.saveToOutbox(result, recipientPubkey);
4485
4540
  result.status = "submitted";
4486
4541
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4542
+ const transferMode = request.transferMode ?? "instant";
4487
4543
  if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4488
- this.log("Executing token split...");
4489
- const executor = new TokenSplitExecutor({
4490
- stateTransitionClient: stClient,
4491
- trustBase,
4492
- signingService
4493
- });
4494
- const splitResult = await executor.executeSplit(
4495
- splitPlan.tokenToSplit.sdkToken,
4496
- splitPlan.splitAmount,
4497
- splitPlan.remainderAmount,
4498
- splitPlan.coinId,
4499
- recipientAddress
4500
- );
4501
- const changeTokenData = splitResult.tokenForSender.toJSON();
4502
- const changeToken = {
4503
- id: crypto.randomUUID(),
4504
- coinId: request.coinId,
4505
- symbol: this.getCoinSymbol(request.coinId),
4506
- name: this.getCoinName(request.coinId),
4507
- decimals: this.getCoinDecimals(request.coinId),
4508
- iconUrl: this.getCoinIconUrl(request.coinId),
4509
- amount: splitPlan.remainderAmount.toString(),
4510
- status: "confirmed",
4511
- createdAt: Date.now(),
4512
- updatedAt: Date.now(),
4513
- sdkData: JSON.stringify(changeTokenData)
4514
- };
4515
- await this.addToken(changeToken, true);
4516
- this.log(`Change token saved: ${changeToken.id}, amount: ${changeToken.amount}`);
4517
- console.log(`[Payments] Sending split token to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4518
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4519
- sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4520
- transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4521
- memo: request.memo
4522
- });
4523
- console.log(`[Payments] Split token sent successfully`);
4524
- await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4525
- result.txHash = "split-" + Date.now().toString(16);
4526
- this.log(`Split transfer completed`);
4544
+ if (transferMode === "conservative") {
4545
+ this.log("Executing conservative split...");
4546
+ const splitExecutor = new TokenSplitExecutor({
4547
+ stateTransitionClient: stClient,
4548
+ trustBase,
4549
+ signingService
4550
+ });
4551
+ const splitResult = await splitExecutor.executeSplit(
4552
+ splitPlan.tokenToSplit.sdkToken,
4553
+ splitPlan.splitAmount,
4554
+ splitPlan.remainderAmount,
4555
+ splitPlan.coinId,
4556
+ recipientAddress
4557
+ );
4558
+ const changeTokenData = splitResult.tokenForSender.toJSON();
4559
+ const changeUiToken = {
4560
+ id: crypto.randomUUID(),
4561
+ coinId: request.coinId,
4562
+ symbol: this.getCoinSymbol(request.coinId),
4563
+ name: this.getCoinName(request.coinId),
4564
+ decimals: this.getCoinDecimals(request.coinId),
4565
+ iconUrl: this.getCoinIconUrl(request.coinId),
4566
+ amount: splitPlan.remainderAmount.toString(),
4567
+ status: "confirmed",
4568
+ createdAt: Date.now(),
4569
+ updatedAt: Date.now(),
4570
+ sdkData: JSON.stringify(changeTokenData)
4571
+ };
4572
+ await this.addToken(changeUiToken, true);
4573
+ this.log(`Conservative split: change token saved: ${changeUiToken.id}`);
4574
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4575
+ sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4576
+ transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4577
+ memo: request.memo
4578
+ });
4579
+ const splitCommitmentRequestId = splitResult.recipientTransferTx?.data?.requestId ?? splitResult.recipientTransferTx?.requestId;
4580
+ const splitRequestIdHex = splitCommitmentRequestId instanceof Uint8Array ? Array.from(splitCommitmentRequestId).map((b) => b.toString(16).padStart(2, "0")).join("") : splitCommitmentRequestId ? String(splitCommitmentRequestId) : void 0;
4581
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4582
+ result.tokenTransfers.push({
4583
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4584
+ method: "split",
4585
+ requestIdHex: splitRequestIdHex
4586
+ });
4587
+ this.log(`Conservative split transfer completed`);
4588
+ } else {
4589
+ this.log("Executing instant split...");
4590
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4591
+ const executor = new InstantSplitExecutor({
4592
+ stateTransitionClient: stClient,
4593
+ trustBase,
4594
+ signingService,
4595
+ devMode
4596
+ });
4597
+ const instantResult = await executor.executeSplitInstant(
4598
+ splitPlan.tokenToSplit.sdkToken,
4599
+ splitPlan.splitAmount,
4600
+ splitPlan.remainderAmount,
4601
+ splitPlan.coinId,
4602
+ recipientAddress,
4603
+ this.deps.transport,
4604
+ recipientPubkey,
4605
+ {
4606
+ onChangeTokenCreated: async (changeToken) => {
4607
+ const changeTokenData = changeToken.toJSON();
4608
+ const uiToken = {
4609
+ id: crypto.randomUUID(),
4610
+ coinId: request.coinId,
4611
+ symbol: this.getCoinSymbol(request.coinId),
4612
+ name: this.getCoinName(request.coinId),
4613
+ decimals: this.getCoinDecimals(request.coinId),
4614
+ iconUrl: this.getCoinIconUrl(request.coinId),
4615
+ amount: splitPlan.remainderAmount.toString(),
4616
+ status: "confirmed",
4617
+ createdAt: Date.now(),
4618
+ updatedAt: Date.now(),
4619
+ sdkData: JSON.stringify(changeTokenData)
4620
+ };
4621
+ await this.addToken(uiToken, true);
4622
+ this.log(`Change token saved via background: ${uiToken.id}`);
4623
+ },
4624
+ onStorageSync: async () => {
4625
+ await this.save();
4626
+ return true;
4627
+ }
4628
+ }
4629
+ );
4630
+ if (!instantResult.success) {
4631
+ throw new Error(instantResult.error || "Instant split failed");
4632
+ }
4633
+ if (instantResult.backgroundPromise) {
4634
+ this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4635
+ }
4636
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag);
4637
+ result.tokenTransfers.push({
4638
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4639
+ method: "split",
4640
+ splitGroupId: instantResult.splitGroupId,
4641
+ nostrEventId: instantResult.nostrEventId
4642
+ });
4643
+ this.log(`Instant split transfer completed`);
4644
+ }
4527
4645
  }
4528
4646
  for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4529
4647
  const token = tokenWithAmount.uiToken;
4530
4648
  const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4531
- const response = await stClient.submitTransferCommitment(commitment);
4532
- if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") {
4533
- throw new Error(`Transfer commitment failed: ${response.status}`);
4534
- }
4535
- if (!this.deps.oracle.waitForProofSdk) {
4536
- throw new Error("Oracle provider must implement waitForProofSdk()");
4649
+ if (transferMode === "conservative") {
4650
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4651
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4652
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4653
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4654
+ }
4655
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4656
+ const transferTx = commitment.toTransaction(inclusionProof);
4657
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4658
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4659
+ transferTx: JSON.stringify(transferTx.toJSON()),
4660
+ memo: request.memo
4661
+ });
4662
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4663
+ } else {
4664
+ console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4665
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4666
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4667
+ commitmentData: JSON.stringify(commitment.toJSON()),
4668
+ memo: request.memo
4669
+ });
4670
+ console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4671
+ stClient.submitTransferCommitment(commitment).catch(
4672
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4673
+ );
4537
4674
  }
4538
- const inclusionProof = await this.deps.oracle.waitForProofSdk(commitment);
4539
- const transferTx = commitment.toTransaction(inclusionProof);
4540
4675
  const requestIdBytes = commitment.requestId;
4541
- result.txHash = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4542
- console.log(`[Payments] Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4543
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4544
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4545
- transferTx: JSON.stringify(transferTx.toJSON()),
4546
- memo: request.memo
4676
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4677
+ result.tokenTransfers.push({
4678
+ sourceTokenId: token.id,
4679
+ method: "direct",
4680
+ requestIdHex
4547
4681
  });
4548
- console.log(`[Payments] Direct token sent successfully`);
4549
- this.log(`Token ${token.id} transferred, txHash: ${result.txHash}`);
4682
+ this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4550
4683
  await this.removeToken(token.id, recipientNametag, true);
4551
4684
  }
4552
4685
  result.status = "delivered";
@@ -4559,7 +4692,8 @@ var PaymentsModule = class _PaymentsModule {
4559
4692
  coinId: request.coinId,
4560
4693
  symbol: this.getCoinSymbol(request.coinId),
4561
4694
  timestamp: Date.now(),
4562
- recipientNametag
4695
+ recipientNametag,
4696
+ transferId: result.id
4563
4697
  });
4564
4698
  this.deps.emitEvent("transfer:confirmed", result);
4565
4699
  return result;
@@ -4695,6 +4829,9 @@ var PaymentsModule = class _PaymentsModule {
4695
4829
  }
4696
4830
  );
4697
4831
  if (result.success) {
4832
+ if (result.backgroundPromise) {
4833
+ this.pendingBackgroundTasks.push(result.backgroundPromise);
4834
+ }
4698
4835
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4699
4836
  await this.removeToken(tokenToSplit.id, recipientNametag, true);
4700
4837
  await this.addToHistory({
@@ -4736,6 +4873,63 @@ var PaymentsModule = class _PaymentsModule {
4736
4873
  */
4737
4874
  async processInstantSplitBundle(bundle, senderPubkey) {
4738
4875
  this.ensureInitialized();
4876
+ if (!isInstantSplitBundleV5(bundle)) {
4877
+ return this.processInstantSplitBundleSync(bundle, senderPubkey);
4878
+ }
4879
+ try {
4880
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
4881
+ if (this.tokens.has(deterministicId)) {
4882
+ this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
4883
+ return { success: true, durationMs: 0 };
4884
+ }
4885
+ const registry = TokenRegistry.getInstance();
4886
+ const pendingData = {
4887
+ type: "v5_bundle",
4888
+ stage: "RECEIVED",
4889
+ bundleJson: JSON.stringify(bundle),
4890
+ senderPubkey,
4891
+ savedAt: Date.now(),
4892
+ attemptCount: 0
4893
+ };
4894
+ const uiToken = {
4895
+ id: deterministicId,
4896
+ coinId: bundle.coinId,
4897
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4898
+ name: registry.getName(bundle.coinId) || bundle.coinId,
4899
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
4900
+ amount: bundle.amount,
4901
+ status: "submitted",
4902
+ // UNCONFIRMED
4903
+ createdAt: Date.now(),
4904
+ updatedAt: Date.now(),
4905
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4906
+ };
4907
+ await this.addToken(uiToken, false);
4908
+ this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
4909
+ this.deps.emitEvent("transfer:incoming", {
4910
+ id: bundle.splitGroupId,
4911
+ senderPubkey,
4912
+ tokens: [uiToken],
4913
+ receivedAt: Date.now()
4914
+ });
4915
+ await this.save();
4916
+ this.resolveUnconfirmed().catch(() => {
4917
+ });
4918
+ return { success: true, durationMs: 0 };
4919
+ } catch (error) {
4920
+ const errorMessage = error instanceof Error ? error.message : String(error);
4921
+ return {
4922
+ success: false,
4923
+ error: errorMessage,
4924
+ durationMs: 0
4925
+ };
4926
+ }
4927
+ }
4928
+ /**
4929
+ * Synchronous V4 bundle processing (dev mode only).
4930
+ * Kept for backward compatibility with V4 bundles.
4931
+ */
4932
+ async processInstantSplitBundleSync(bundle, senderPubkey) {
4739
4933
  try {
4740
4934
  const signingService = await this.createSigningService();
4741
4935
  const stClient = this.deps.oracle.getStateTransitionClient?.();
@@ -4821,7 +5015,10 @@ var PaymentsModule = class _PaymentsModule {
4821
5015
  }
4822
5016
  }
4823
5017
  /**
4824
- * Check if a payload is an instant split bundle
5018
+ * Type-guard: check whether a payload is a valid {@link InstantSplitBundle} (V4 or V5).
5019
+ *
5020
+ * @param payload - The object to test.
5021
+ * @returns `true` if the payload matches the InstantSplitBundle shape.
4825
5022
  */
4826
5023
  isInstantSplitBundle(payload) {
4827
5024
  return isInstantSplitBundle(payload);
@@ -4902,39 +5099,57 @@ var PaymentsModule = class _PaymentsModule {
4902
5099
  return [...this.paymentRequests];
4903
5100
  }
4904
5101
  /**
4905
- * Get pending payment requests count
5102
+ * Get the count of payment requests with status `'pending'`.
5103
+ *
5104
+ * @returns Number of pending incoming payment requests.
4906
5105
  */
4907
5106
  getPendingPaymentRequestsCount() {
4908
5107
  return this.paymentRequests.filter((r) => r.status === "pending").length;
4909
5108
  }
4910
5109
  /**
4911
- * Accept a payment request (marks it as accepted, user should then call send())
5110
+ * Accept a payment request and notify the requester.
5111
+ *
5112
+ * Marks the request as `'accepted'` and sends a response via transport.
5113
+ * The caller should subsequently call {@link send} to fulfill the payment.
5114
+ *
5115
+ * @param requestId - ID of the incoming payment request to accept.
4912
5116
  */
4913
5117
  async acceptPaymentRequest(requestId2) {
4914
5118
  this.updatePaymentRequestStatus(requestId2, "accepted");
4915
5119
  await this.sendPaymentRequestResponse(requestId2, "accepted");
4916
5120
  }
4917
5121
  /**
4918
- * Reject a payment request
5122
+ * Reject a payment request and notify the requester.
5123
+ *
5124
+ * @param requestId - ID of the incoming payment request to reject.
4919
5125
  */
4920
5126
  async rejectPaymentRequest(requestId2) {
4921
5127
  this.updatePaymentRequestStatus(requestId2, "rejected");
4922
5128
  await this.sendPaymentRequestResponse(requestId2, "rejected");
4923
5129
  }
4924
5130
  /**
4925
- * Mark a payment request as paid (after successful transfer)
5131
+ * Mark a payment request as paid (local status update only).
5132
+ *
5133
+ * Typically called after a successful {@link send} to record that the
5134
+ * request has been fulfilled.
5135
+ *
5136
+ * @param requestId - ID of the incoming payment request to mark as paid.
4926
5137
  */
4927
5138
  markPaymentRequestPaid(requestId2) {
4928
5139
  this.updatePaymentRequestStatus(requestId2, "paid");
4929
5140
  }
4930
5141
  /**
4931
- * Clear processed (non-pending) payment requests
5142
+ * Remove all non-pending incoming payment requests from memory.
5143
+ *
5144
+ * Keeps only requests with status `'pending'`.
4932
5145
  */
4933
5146
  clearProcessedPaymentRequests() {
4934
5147
  this.paymentRequests = this.paymentRequests.filter((r) => r.status === "pending");
4935
5148
  }
4936
5149
  /**
4937
- * Remove a specific payment request
5150
+ * Remove a specific incoming payment request by ID.
5151
+ *
5152
+ * @param requestId - ID of the payment request to remove.
4938
5153
  */
4939
5154
  removePaymentRequest(requestId2) {
4940
5155
  this.paymentRequests = this.paymentRequests.filter((r) => r.id !== requestId2);
@@ -5059,7 +5274,11 @@ var PaymentsModule = class _PaymentsModule {
5059
5274
  });
5060
5275
  }
5061
5276
  /**
5062
- * Cancel waiting for a payment response
5277
+ * Cancel an active {@link waitForPaymentResponse} call.
5278
+ *
5279
+ * The pending promise is rejected with a `'Cancelled'` error.
5280
+ *
5281
+ * @param requestId - The outgoing request ID whose wait should be cancelled.
5063
5282
  */
5064
5283
  cancelWaitForPaymentResponse(requestId2) {
5065
5284
  const resolver = this.pendingResponseResolvers.get(requestId2);
@@ -5070,14 +5289,16 @@ var PaymentsModule = class _PaymentsModule {
5070
5289
  }
5071
5290
  }
5072
5291
  /**
5073
- * Remove an outgoing payment request
5292
+ * Remove an outgoing payment request and cancel any pending wait.
5293
+ *
5294
+ * @param requestId - ID of the outgoing request to remove.
5074
5295
  */
5075
5296
  removeOutgoingPaymentRequest(requestId2) {
5076
5297
  this.outgoingPaymentRequests.delete(requestId2);
5077
5298
  this.cancelWaitForPaymentResponse(requestId2);
5078
5299
  }
5079
5300
  /**
5080
- * Clear completed/expired outgoing payment requests
5301
+ * Remove all outgoing payment requests that are `'paid'`, `'rejected'`, or `'expired'`.
5081
5302
  */
5082
5303
  clearCompletedOutgoingPaymentRequests() {
5083
5304
  for (const [id, request] of this.outgoingPaymentRequests) {
@@ -5149,6 +5370,71 @@ var PaymentsModule = class _PaymentsModule {
5149
5370
  }
5150
5371
  }
5151
5372
  // ===========================================================================
5373
+ // Public API - Receive
5374
+ // ===========================================================================
5375
+ /**
5376
+ * Fetch and process pending incoming transfers from the transport layer.
5377
+ *
5378
+ * Performs a one-shot query to fetch all pending events, processes them
5379
+ * through the existing pipeline, and resolves after all stored events
5380
+ * are handled. Useful for batch/CLI apps that need explicit receive.
5381
+ *
5382
+ * When `finalize` is true, polls resolveUnconfirmed() + load() until all
5383
+ * tokens are confirmed or the timeout expires. Otherwise calls
5384
+ * resolveUnconfirmed() once to submit pending commitments.
5385
+ *
5386
+ * @param options - Optional receive options including finalization control
5387
+ * @param callback - Optional callback invoked for each newly received transfer
5388
+ * @returns ReceiveResult with transfers and finalization metadata
5389
+ */
5390
+ async receive(options, callback) {
5391
+ this.ensureInitialized();
5392
+ if (!this.deps.transport.fetchPendingEvents) {
5393
+ throw new Error("Transport provider does not support fetchPendingEvents");
5394
+ }
5395
+ const opts = options ?? {};
5396
+ const tokensBefore = new Set(this.tokens.keys());
5397
+ await this.deps.transport.fetchPendingEvents();
5398
+ await this.load();
5399
+ const received = [];
5400
+ for (const [tokenId, token] of this.tokens) {
5401
+ if (!tokensBefore.has(tokenId)) {
5402
+ const transfer = {
5403
+ id: tokenId,
5404
+ senderPubkey: "",
5405
+ tokens: [token],
5406
+ receivedAt: Date.now()
5407
+ };
5408
+ received.push(transfer);
5409
+ if (callback) callback(transfer);
5410
+ }
5411
+ }
5412
+ const result = { transfers: received };
5413
+ if (opts.finalize) {
5414
+ const timeout = opts.timeout ?? 6e4;
5415
+ const pollInterval = opts.pollInterval ?? 2e3;
5416
+ const startTime = Date.now();
5417
+ while (Date.now() - startTime < timeout) {
5418
+ const resolution = await this.resolveUnconfirmed();
5419
+ result.finalization = resolution;
5420
+ if (opts.onProgress) opts.onProgress(resolution);
5421
+ const stillUnconfirmed = Array.from(this.tokens.values()).some(
5422
+ (t) => t.status === "submitted" || t.status === "pending"
5423
+ );
5424
+ if (!stillUnconfirmed) break;
5425
+ await new Promise((r) => setTimeout(r, pollInterval));
5426
+ await this.load();
5427
+ }
5428
+ result.finalizationDurationMs = Date.now() - startTime;
5429
+ result.timedOut = Array.from(this.tokens.values()).some(
5430
+ (t) => t.status === "submitted" || t.status === "pending"
5431
+ );
5432
+ } else {
5433
+ result.finalization = await this.resolveUnconfirmed();
5434
+ }
5435
+ return result;
5436
+ }
5437
+ // ===========================================================================
5152
5438
  // Public API - Balance & Tokens
5153
5439
  // ===========================================================================
5154
5440
  /**
@@ -5158,10 +5444,20 @@ var PaymentsModule = class _PaymentsModule {
5158
5444
  this.priceProvider = provider;
5159
5445
  }
5160
5446
  /**
5161
- * Get total portfolio value in USD
5162
- * Returns null if PriceProvider is not configured
5447
+ * Wait for all pending background operations (e.g., instant split change token creation).
5448
+ * Call this before process exit to ensure all tokens are saved.
5163
5449
  */
5164
- async getBalance() {
5450
+ async waitForPendingOperations() {
5451
+ if (this.pendingBackgroundTasks.length > 0) {
5452
+ await Promise.allSettled(this.pendingBackgroundTasks);
5453
+ this.pendingBackgroundTasks = [];
5454
+ }
5455
+ }
5456
+ /**
5457
+ * Get total portfolio value in USD.
5458
+ * Returns null if PriceProvider is not configured.
5459
+ */
5460
+ async getFiatBalance() {
5165
5461
  const assets = await this.getAssets();
5166
5462
  if (!this.priceProvider) {
5167
5463
  return null;
@@ -5177,19 +5473,95 @@ var PaymentsModule = class _PaymentsModule {
5177
5473
  return hasAnyPrice ? total : null;
5178
5474
  }
5179
5475
  /**
5180
- * Get aggregated assets (tokens grouped by coinId) with price data
5181
- * Only includes confirmed tokens
5476
+ * Get token balances grouped by coin type.
5477
+ *
5478
+ * Returns an array of {@link Asset} objects, one per coin type held.
5479
+ * Each entry includes confirmed and unconfirmed breakdowns. Tokens with
5480
+ * status `'spent'`, `'invalid'`, or `'transferring'` are excluded.
5481
+ *
5482
+ * This is synchronous — no price data is included. Use {@link getAssets}
5483
+ * for the async version with fiat pricing.
5484
+ *
5485
+ * @param coinId - Optional coin ID to filter by (e.g. hex string). When omitted, all coin types are returned.
5486
+ * @returns Array of balance summaries (synchronous — no await needed).
5487
+ */
5488
+ getBalance(coinId) {
5489
+ return this.aggregateTokens(coinId);
5490
+ }
5491
+ /**
5492
+ * Get aggregated assets (tokens grouped by coinId) with price data.
5493
+ * Includes both confirmed and unconfirmed tokens with breakdown.
5182
5494
  */
5183
5495
  async getAssets(coinId) {
5496
+ const rawAssets = this.aggregateTokens(coinId);
5497
+ if (!this.priceProvider || rawAssets.length === 0) {
5498
+ return rawAssets;
5499
+ }
5500
+ try {
5501
+ const registry = TokenRegistry.getInstance();
5502
+ const nameToCoins = /* @__PURE__ */ new Map();
5503
+ for (const asset of rawAssets) {
5504
+ const def = registry.getDefinition(asset.coinId);
5505
+ if (def?.name) {
5506
+ const existing = nameToCoins.get(def.name);
5507
+ if (existing) {
5508
+ existing.push(asset.coinId);
5509
+ } else {
5510
+ nameToCoins.set(def.name, [asset.coinId]);
5511
+ }
5512
+ }
5513
+ }
5514
+ if (nameToCoins.size > 0) {
5515
+ const tokenNames = Array.from(nameToCoins.keys());
5516
+ const prices = await this.priceProvider.getPrices(tokenNames);
5517
+ return rawAssets.map((raw) => {
5518
+ const def = registry.getDefinition(raw.coinId);
5519
+ const price = def?.name ? prices.get(def.name) : void 0;
5520
+ let fiatValueUsd = null;
5521
+ let fiatValueEur = null;
5522
+ if (price) {
5523
+ const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
5524
+ fiatValueUsd = humanAmount * price.priceUsd;
5525
+ if (price.priceEur != null) {
5526
+ fiatValueEur = humanAmount * price.priceEur;
5527
+ }
5528
+ }
5529
+ return {
5530
+ ...raw,
5531
+ priceUsd: price?.priceUsd ?? null,
5532
+ priceEur: price?.priceEur ?? null,
5533
+ change24h: price?.change24h ?? null,
5534
+ fiatValueUsd,
5535
+ fiatValueEur
5536
+ };
5537
+ });
5538
+ }
5539
+ } catch (error) {
5540
+ console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
5541
+ }
5542
+ return rawAssets;
5543
+ }
5544
+ /**
5545
+ * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5546
+ * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
5547
+ */
5548
+ aggregateTokens(coinId) {
5184
5549
  const assetsMap = /* @__PURE__ */ new Map();
5185
5550
  for (const token of this.tokens.values()) {
5186
- if (token.status !== "confirmed") continue;
5551
+ if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
5187
5552
  if (coinId && token.coinId !== coinId) continue;
5188
5553
  const key = token.coinId;
5554
+ const amount = BigInt(token.amount);
5555
+ const isConfirmed = token.status === "confirmed";
5189
5556
  const existing = assetsMap.get(key);
5190
5557
  if (existing) {
5191
- existing.totalAmount = (BigInt(existing.totalAmount) + BigInt(token.amount)).toString();
5192
- existing.tokenCount++;
5558
+ if (isConfirmed) {
5559
+ existing.confirmedAmount += amount;
5560
+ existing.confirmedTokenCount++;
5561
+ } else {
5562
+ existing.unconfirmedAmount += amount;
5563
+ existing.unconfirmedTokenCount++;
5564
+ }
5193
5565
  } else {
5194
5566
  assetsMap.set(key, {
5195
5567
  coinId: token.coinId,
@@ -5197,78 +5569,42 @@ var PaymentsModule = class _PaymentsModule {
5197
5569
  name: token.name,
5198
5570
  decimals: token.decimals,
5199
5571
  iconUrl: token.iconUrl,
5200
- totalAmount: token.amount,
5201
- tokenCount: 1
5572
+ confirmedAmount: isConfirmed ? amount : 0n,
5573
+ unconfirmedAmount: isConfirmed ? 0n : amount,
5574
+ confirmedTokenCount: isConfirmed ? 1 : 0,
5575
+ unconfirmedTokenCount: isConfirmed ? 0 : 1
5202
5576
  });
5203
5577
  }
5204
5578
  }
5205
- const rawAssets = Array.from(assetsMap.values());
5206
- let priceMap = null;
5207
- if (this.priceProvider && rawAssets.length > 0) {
5208
- try {
5209
- const registry = TokenRegistry.getInstance();
5210
- const nameToCoins = /* @__PURE__ */ new Map();
5211
- for (const asset of rawAssets) {
5212
- const def = registry.getDefinition(asset.coinId);
5213
- if (def?.name) {
5214
- const existing = nameToCoins.get(def.name);
5215
- if (existing) {
5216
- existing.push(asset.coinId);
5217
- } else {
5218
- nameToCoins.set(def.name, [asset.coinId]);
5219
- }
5220
- }
5221
- }
5222
- if (nameToCoins.size > 0) {
5223
- const tokenNames = Array.from(nameToCoins.keys());
5224
- const prices = await this.priceProvider.getPrices(tokenNames);
5225
- priceMap = /* @__PURE__ */ new Map();
5226
- for (const [name, coinIds] of nameToCoins) {
5227
- const price = prices.get(name);
5228
- if (price) {
5229
- for (const cid of coinIds) {
5230
- priceMap.set(cid, {
5231
- priceUsd: price.priceUsd,
5232
- priceEur: price.priceEur,
5233
- change24h: price.change24h
5234
- });
5235
- }
5236
- }
5237
- }
5238
- }
5239
- } catch (error) {
5240
- console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
5241
- }
5242
- }
5243
- return rawAssets.map((raw) => {
5244
- const price = priceMap?.get(raw.coinId);
5245
- let fiatValueUsd = null;
5246
- let fiatValueEur = null;
5247
- if (price) {
5248
- const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
5249
- fiatValueUsd = humanAmount * price.priceUsd;
5250
- if (price.priceEur != null) {
5251
- fiatValueEur = humanAmount * price.priceEur;
5252
- }
5253
- }
5579
+ return Array.from(assetsMap.values()).map((raw) => {
5580
+ const totalAmount = (raw.confirmedAmount + raw.unconfirmedAmount).toString();
5254
5581
  return {
5255
5582
  coinId: raw.coinId,
5256
5583
  symbol: raw.symbol,
5257
5584
  name: raw.name,
5258
5585
  decimals: raw.decimals,
5259
5586
  iconUrl: raw.iconUrl,
5260
- totalAmount: raw.totalAmount,
5261
- tokenCount: raw.tokenCount,
5262
- priceUsd: price?.priceUsd ?? null,
5263
- priceEur: price?.priceEur ?? null,
5264
- change24h: price?.change24h ?? null,
5265
- fiatValueUsd,
5266
- fiatValueEur
5587
+ totalAmount,
5588
+ tokenCount: raw.confirmedTokenCount + raw.unconfirmedTokenCount,
5589
+ confirmedAmount: raw.confirmedAmount.toString(),
5590
+ unconfirmedAmount: raw.unconfirmedAmount.toString(),
5591
+ confirmedTokenCount: raw.confirmedTokenCount,
5592
+ unconfirmedTokenCount: raw.unconfirmedTokenCount,
5593
+ priceUsd: null,
5594
+ priceEur: null,
5595
+ change24h: null,
5596
+ fiatValueUsd: null,
5597
+ fiatValueEur: null
5267
5598
  };
5268
5599
  });
5269
5600
  }
5270
5601
  /**
5271
- * Get all tokens
5602
+ * Get all tokens, optionally filtered by coin type and/or status.
5603
+ *
5604
+ * @param filter - Optional filter criteria.
5605
+ * @param filter.coinId - Return only tokens of this coin type.
5606
+ * @param filter.status - Return only tokens with this status (e.g. `'submitted'` for unconfirmed).
5607
+ * @returns Array of matching {@link Token} objects (synchronous).
5272
5608
  */
5273
5609
  getTokens(filter) {
5274
5610
  let tokens = Array.from(this.tokens.values());
@@ -5281,19 +5617,327 @@ var PaymentsModule = class _PaymentsModule {
5281
5617
  return tokens;
5282
5618
  }
5283
5619
  /**
5284
- * Get single token
5620
+ * Get a single token by its local ID.
5621
+ *
5622
+ * @param id - The local UUID assigned when the token was added.
5623
+ * @returns The token, or `undefined` if not found.
5285
5624
  */
5286
5625
  getToken(id) {
5287
5626
  return this.tokens.get(id);
5288
5627
  }
5289
5628
  // ===========================================================================
5629
+ // Public API - Unconfirmed Token Resolution
5630
+ // ===========================================================================
5631
+ /**
5632
+ * Attempt to resolve unconfirmed (status `'submitted'`) tokens by acquiring
5633
+ * their missing aggregator proofs.
5634
+ *
5635
+ * Each unconfirmed V5 token progresses through stages:
5636
+ * `RECEIVED` → `MINT_SUBMITTED` → `MINT_PROVEN` → `TRANSFER_SUBMITTED` → `FINALIZED`
5637
+ *
5638
+ * Uses 500 ms quick-timeouts per proof check so the call returns quickly even
5639
+ * when proofs are not yet available. Tokens that exceed 50 failed attempts are
5640
+ * marked `'invalid'`.
5641
+ *
5642
+ * Automatically called (fire-and-forget) by {@link load}.
5643
+ *
5644
+ * @returns Summary with counts of resolved, still-pending, and failed tokens plus per-token details.
5645
+ */
5646
+ async resolveUnconfirmed() {
5647
+ this.ensureInitialized();
5648
+ const result = {
5649
+ resolved: 0,
5650
+ stillPending: 0,
5651
+ failed: 0,
5652
+ details: []
5653
+ };
5654
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5655
+ const trustBase = this.deps.oracle.getTrustBase?.();
5656
+ if (!stClient || !trustBase) return result;
5657
+ const signingService = await this.createSigningService();
5658
+ for (const [tokenId, token] of this.tokens) {
5659
+ if (token.status !== "submitted") continue;
5660
+ const pending2 = this.parsePendingFinalization(token.sdkData);
5661
+ if (!pending2) {
5662
+ result.stillPending++;
5663
+ continue;
5664
+ }
5665
+ if (pending2.type === "v5_bundle") {
5666
+ const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
5667
+ result.details.push({ tokenId, stage: pending2.stage, status: progress });
5668
+ if (progress === "resolved") result.resolved++;
5669
+ else if (progress === "failed") result.failed++;
5670
+ else result.stillPending++;
5671
+ }
5672
+ }
5673
+ if (result.resolved > 0 || result.failed > 0) {
5674
+ await this.save();
5675
+ }
5676
+ return result;
5677
+ }
5678
+ // ===========================================================================
5679
+ // Private - V5 Lazy Resolution Helpers
5680
+ // ===========================================================================
5681
+ /**
5682
+ * Process a single V5 token through its finalization stages with quick-timeout proof checks.
5683
+ */
5684
+ async resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService) {
5685
+ const bundle = JSON.parse(pending2.bundleJson);
5686
+ pending2.attemptCount++;
5687
+ pending2.lastAttemptAt = Date.now();
5688
+ try {
5689
+ if (pending2.stage === "RECEIVED") {
5690
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5691
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5692
+ const mintCommitment = await MintCommitment3.create(mintData);
5693
+ const mintResponse = await stClient.submitMintCommitment(mintCommitment);
5694
+ if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
5695
+ throw new Error(`Mint submission failed: ${mintResponse.status}`);
5696
+ }
5697
+ pending2.stage = "MINT_SUBMITTED";
5698
+ this.updatePendingFinalization(token, pending2);
5699
+ }
5700
+ if (pending2.stage === "MINT_SUBMITTED") {
5701
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5702
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5703
+ const mintCommitment = await MintCommitment3.create(mintData);
5704
+ const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
5705
+ if (!proof) {
5706
+ this.updatePendingFinalization(token, pending2);
5707
+ return "pending";
5708
+ }
5709
+ pending2.mintProofJson = JSON.stringify(proof);
5710
+ pending2.stage = "MINT_PROVEN";
5711
+ this.updatePendingFinalization(token, pending2);
5712
+ }
5713
+ if (pending2.stage === "MINT_PROVEN") {
5714
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5715
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5716
+ const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
5717
+ if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
5718
+ throw new Error(`Transfer submission failed: ${transferResponse.status}`);
5719
+ }
5720
+ pending2.stage = "TRANSFER_SUBMITTED";
5721
+ this.updatePendingFinalization(token, pending2);
5722
+ }
5723
+ if (pending2.stage === "TRANSFER_SUBMITTED") {
5724
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5725
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5726
+ const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
5727
+ if (!proof) {
5728
+ this.updatePendingFinalization(token, pending2);
5729
+ return "pending";
5730
+ }
5731
+ const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
5732
+ const confirmedToken = {
5733
+ id: token.id,
5734
+ coinId: token.coinId,
5735
+ symbol: token.symbol,
5736
+ name: token.name,
5737
+ decimals: token.decimals,
5738
+ iconUrl: token.iconUrl,
5739
+ amount: token.amount,
5740
+ status: "confirmed",
5741
+ createdAt: token.createdAt,
5742
+ updatedAt: Date.now(),
5743
+ sdkData: JSON.stringify(finalizedToken.toJSON())
5744
+ };
5745
+ this.tokens.set(tokenId, confirmedToken);
5746
+ await this.saveTokenToFileStorage(confirmedToken);
5747
+ await this.addToHistory({
5748
+ type: "RECEIVED",
5749
+ amount: confirmedToken.amount,
5750
+ coinId: confirmedToken.coinId,
5751
+ symbol: confirmedToken.symbol || "UNK",
5752
+ timestamp: Date.now(),
5753
+ senderPubkey: pending2.senderPubkey
5754
+ });
5755
+ this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
5756
+ return "resolved";
5757
+ }
5758
+ return "pending";
5759
+ } catch (error) {
5760
+ console.error(`[Payments] resolveV5Token failed for ${tokenId.slice(0, 8)}:`, error);
5761
+ if (pending2.attemptCount > 50) {
5762
+ token.status = "invalid";
5763
+ token.updatedAt = Date.now();
5764
+ this.tokens.set(tokenId, token);
5765
+ return "failed";
5766
+ }
5767
+ this.updatePendingFinalization(token, pending2);
5768
+ return "pending";
5769
+ }
5770
+ }
5771
+ /**
5772
+ * Non-blocking proof check with 500ms timeout.
5773
+ */
5774
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5775
+ async quickProofCheck(stClient, trustBase, commitment, timeoutMs = 500) {
5776
+ try {
5777
+ const proof = await Promise.race([
5778
+ waitInclusionProof5(trustBase, stClient, commitment),
5779
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
5780
+ ]);
5781
+ return proof;
5782
+ } catch {
5783
+ return null;
5784
+ }
5785
+ }
5786
+ /**
5787
+ * Perform V5 bundle finalization from stored bundle data and proofs.
5788
+ * Extracted from InstantSplitProcessor.processV5Bundle() steps 4-10.
5789
+ */
5790
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5791
+ async finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase) {
5792
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5793
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5794
+ const mintCommitment = await MintCommitment3.create(mintData);
5795
+ const mintProofJson = JSON.parse(pending2.mintProofJson);
5796
+ const mintProof = InclusionProof.fromJSON(mintProofJson);
5797
+ const mintTransaction = mintCommitment.toTransaction(mintProof);
5798
+ const tokenType = new TokenType3(fromHex4(bundle.tokenTypeHex));
5799
+ const senderMintedStateJson = JSON.parse(bundle.mintedTokenStateJson);
5800
+ const tokenJson = {
5801
+ version: "2.0",
5802
+ state: senderMintedStateJson,
5803
+ genesis: mintTransaction.toJSON(),
5804
+ transactions: [],
5805
+ nametags: []
5806
+ };
5807
+ const mintedToken = await SdkToken2.fromJSON(tokenJson);
5808
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5809
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5810
+ const transferProof = await waitInclusionProof5(trustBase, stClient, transferCommitment);
5811
+ const transferTransaction = transferCommitment.toTransaction(transferProof);
5812
+ const transferSalt = fromHex4(bundle.transferSaltHex);
5813
+ const recipientPredicate = await UnmaskedPredicate5.create(
5814
+ mintData.tokenId,
5815
+ tokenType,
5816
+ signingService,
5817
+ HashAlgorithm5.SHA256,
5818
+ transferSalt
5819
+ );
5820
+ const recipientState = new TokenState5(recipientPredicate, null);
5821
+ let nametagTokens = [];
5822
+ const recipientAddressStr = bundle.recipientAddressJson;
5823
+ if (recipientAddressStr.startsWith("PROXY://")) {
5824
+ if (bundle.nametagTokenJson) {
5825
+ try {
5826
+ const nametagToken = await SdkToken2.fromJSON(JSON.parse(bundle.nametagTokenJson));
5827
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5828
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5829
+ if (proxy.address === recipientAddressStr) {
5830
+ nametagTokens = [nametagToken];
5831
+ }
5832
+ } catch {
5833
+ }
5834
+ }
5835
+ if (nametagTokens.length === 0 && this.nametag?.token) {
5836
+ try {
5837
+ const nametagToken = await SdkToken2.fromJSON(this.nametag.token);
5838
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5839
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5840
+ if (proxy.address === recipientAddressStr) {
5841
+ nametagTokens = [nametagToken];
5842
+ }
5843
+ } catch {
5844
+ }
5845
+ }
5846
+ }
5847
+ return stClient.finalizeTransaction(trustBase, mintedToken, recipientState, transferTransaction, nametagTokens);
5848
+ }
5849
+ /**
5850
+ * Parse pending finalization metadata from token's sdkData.
5851
+ */
5852
+ parsePendingFinalization(sdkData) {
5853
+ if (!sdkData) return null;
5854
+ try {
5855
+ const data = JSON.parse(sdkData);
5856
+ if (data._pendingFinalization && data._pendingFinalization.type === "v5_bundle") {
5857
+ return data._pendingFinalization;
5858
+ }
5859
+ return null;
5860
+ } catch {
5861
+ return null;
5862
+ }
5863
+ }
5864
+ /**
5865
+ * Update pending finalization metadata in token's sdkData.
5866
+ * Creates a new token object since sdkData is readonly.
5867
+ */
5868
+ updatePendingFinalization(token, pending2) {
5869
+ const updated = {
5870
+ id: token.id,
5871
+ coinId: token.coinId,
5872
+ symbol: token.symbol,
5873
+ name: token.name,
5874
+ decimals: token.decimals,
5875
+ iconUrl: token.iconUrl,
5876
+ amount: token.amount,
5877
+ status: token.status,
5878
+ createdAt: token.createdAt,
5879
+ updatedAt: Date.now(),
5880
+ sdkData: JSON.stringify({ _pendingFinalization: pending2 })
5881
+ };
5882
+ this.tokens.set(token.id, updated);
5883
+ }
5884
+ /**
5885
+ * Save pending V5 tokens to key-value storage.
5886
+ * These tokens can't be serialized to TXF format (no genesis/state),
5887
+ * so we persist them separately and restore on load().
5888
+ */
5889
+ async savePendingV5Tokens() {
5890
+ const pendingTokens = [];
5891
+ for (const token of this.tokens.values()) {
5892
+ if (this.parsePendingFinalization(token.sdkData)) {
5893
+ pendingTokens.push(token);
5894
+ }
5895
+ }
5896
+ if (pendingTokens.length > 0) {
5897
+ await this.deps.storage.set(
5898
+ STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
5899
+ JSON.stringify(pendingTokens)
5900
+ );
5901
+ } else {
5902
+ await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
5903
+ }
5904
+ }
5905
+ /**
5906
+ * Load pending V5 tokens from key-value storage and merge into tokens map.
5907
+ * Called during load() to restore tokens that TXF format can't represent.
5908
+ */
5909
+ async loadPendingV5Tokens() {
5910
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
5911
+ if (!data) return;
5912
+ try {
5913
+ const pendingTokens = JSON.parse(data);
5914
+ for (const token of pendingTokens) {
5915
+ if (!this.tokens.has(token.id)) {
5916
+ this.tokens.set(token.id, token);
5917
+ }
5918
+ }
5919
+ if (pendingTokens.length > 0) {
5920
+ this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
5921
+ }
5922
+ } catch {
5923
+ }
5924
+ }
5925
+ // ===========================================================================
5290
5926
  // Public API - Token Operations
5291
5927
  // ===========================================================================
5292
5928
  /**
5293
- * Add a token
5294
- * Tokens are uniquely identified by (tokenId, stateHash) composite key.
5295
- * Multiple historic states of the same token can coexist.
5296
- * @returns false if exact duplicate (same tokenId AND same stateHash)
5929
+ * Add a token to the wallet.
5930
+ *
5931
+ * Tokens are uniquely identified by a `(tokenId, stateHash)` composite key.
5932
+ * Duplicate detection:
5933
+ * - **Tombstoned** — rejected if the exact `(tokenId, stateHash)` pair has a tombstone.
5934
+ * - **Exact duplicate** — rejected if a token with the same composite key already exists.
5935
+ * - **State replacement** — if the same `tokenId` exists with a *different* `stateHash`,
5936
+ * the old state is archived and replaced with the incoming one.
5937
+ *
5938
+ * @param token - The token to add.
5939
+ * @param skipHistory - When `true`, do not create a `RECEIVED` transaction history entry (default `false`).
5940
+ * @returns `true` if the token was added, `false` if rejected as duplicate or tombstoned.
5297
5941
  */
5298
5942
  async addToken(token, skipHistory = false) {
5299
5943
  this.ensureInitialized();
@@ -5351,7 +5995,9 @@ var PaymentsModule = class _PaymentsModule {
5351
5995
  });
5352
5996
  }
5353
5997
  await this.save();
5354
- await this.saveTokenToFileStorage(token);
5998
+ if (!this.parsePendingFinalization(token.sdkData)) {
5999
+ await this.saveTokenToFileStorage(token);
6000
+ }
5355
6001
  this.log(`Added token ${token.id}, total: ${this.tokens.size}`);
5356
6002
  return true;
5357
6003
  }
@@ -5408,6 +6054,9 @@ var PaymentsModule = class _PaymentsModule {
5408
6054
  const data = fileData;
5409
6055
  const tokenJson = data.token;
5410
6056
  if (!tokenJson) continue;
6057
+ if (typeof tokenJson === "object" && tokenJson !== null && "_pendingFinalization" in tokenJson) {
6058
+ continue;
6059
+ }
5411
6060
  let sdkTokenId;
5412
6061
  if (typeof tokenJson === "object" && tokenJson !== null) {
5413
6062
  const tokenObj = tokenJson;
@@ -5459,7 +6108,12 @@ var PaymentsModule = class _PaymentsModule {
5459
6108
  this.log(`Loaded ${this.tokens.size} tokens from file storage`);
5460
6109
  }
5461
6110
  /**
5462
- * Update an existing token
6111
+ * Update an existing token or add it if not found.
6112
+ *
6113
+ * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
6114
+ * `token.id`. If no match is found, falls back to {@link addToken}.
6115
+ *
6116
+ * @param token - The token with updated data. Must include a valid `id`.
5463
6117
  */
5464
6118
  async updateToken(token) {
5465
6119
  this.ensureInitialized();
@@ -5483,7 +6137,15 @@ var PaymentsModule = class _PaymentsModule {
5483
6137
  this.log(`Updated token ${token.id}`);
5484
6138
  }
5485
6139
  /**
5486
- * Remove a token by ID
6140
+ * Remove a token from the wallet.
6141
+ *
6142
+ * The token is archived first, then a tombstone `(tokenId, stateHash)` is
6143
+ * created to prevent re-addition via Nostr re-delivery. A `SENT` history
6144
+ * entry is created unless `skipHistory` is `true`.
6145
+ *
6146
+ * @param tokenId - Local UUID of the token to remove.
6147
+ * @param recipientNametag - Optional nametag of the transfer recipient (for history).
6148
+ * @param skipHistory - When `true`, skip creating a transaction history entry (default `false`).
5487
6149
  */
5488
6150
  async removeToken(tokenId, recipientNametag, skipHistory = false) {
5489
6151
  this.ensureInitialized();
@@ -5545,13 +6207,22 @@ var PaymentsModule = class _PaymentsModule {
5545
6207
  // Public API - Tombstones
5546
6208
  // ===========================================================================
5547
6209
  /**
5548
- * Get all tombstones
6210
+ * Get all tombstone entries.
6211
+ *
6212
+ * Each tombstone is keyed by `(tokenId, stateHash)` and prevents a spent
6213
+ * token state from being re-added (e.g. via Nostr re-delivery).
6214
+ *
6215
+ * @returns A shallow copy of the tombstone array.
5549
6216
  */
5550
6217
  getTombstones() {
5551
6218
  return [...this.tombstones];
5552
6219
  }
5553
6220
  /**
5554
- * Check if token state is tombstoned
6221
+ * Check whether a specific `(tokenId, stateHash)` combination is tombstoned.
6222
+ *
6223
+ * @param tokenId - The genesis token ID.
6224
+ * @param stateHash - The state hash of the token version to check.
6225
+ * @returns `true` if the exact combination has been tombstoned.
5555
6226
  */
5556
6227
  isStateTombstoned(tokenId, stateHash) {
5557
6228
  return this.tombstones.some(
@@ -5559,8 +6230,13 @@ var PaymentsModule = class _PaymentsModule {
5559
6230
  );
5560
6231
  }
5561
6232
  /**
5562
- * Merge remote tombstones
5563
- * @returns number of local tokens removed
6233
+ * Merge tombstones received from a remote sync source.
6234
+ *
6235
+ * Any local token whose `(tokenId, stateHash)` matches a remote tombstone is
6236
+ * removed. The remote tombstones are then added to the local set (union merge).
6237
+ *
6238
+ * @param remoteTombstones - Tombstone entries from the remote source.
6239
+ * @returns Number of local tokens that were removed.
5564
6240
  */
5565
6241
  async mergeTombstones(remoteTombstones) {
5566
6242
  this.ensureInitialized();
@@ -5596,7 +6272,9 @@ var PaymentsModule = class _PaymentsModule {
5596
6272
  return removedCount;
5597
6273
  }
5598
6274
  /**
5599
- * Prune old tombstones
6275
+ * Remove tombstones older than `maxAge` and cap the list at 100 entries.
6276
+ *
6277
+ * @param maxAge - Maximum age in milliseconds (default: 30 days).
5600
6278
  */
5601
6279
  async pruneTombstones(maxAge) {
5602
6280
  const originalCount = this.tombstones.length;
@@ -5610,20 +6288,38 @@ var PaymentsModule = class _PaymentsModule {
5610
6288
  // Public API - Archives
5611
6289
  // ===========================================================================
5612
6290
  /**
5613
- * Get archived tokens
6291
+ * Get all archived (spent/superseded) tokens in TXF format.
6292
+ *
6293
+ * Archived tokens are kept for recovery and sync purposes. The map key is
6294
+ * the genesis token ID.
6295
+ *
6296
+ * @returns A shallow copy of the archived token map.
5614
6297
  */
5615
6298
  getArchivedTokens() {
5616
6299
  return new Map(this.archivedTokens);
5617
6300
  }
5618
6301
  /**
5619
- * Get best archived version of a token
6302
+ * Get the best (most committed transactions) archived version of a token.
6303
+ *
6304
+ * Searches both archived and forked token maps and returns the version with
6305
+ * the highest number of committed transactions.
6306
+ *
6307
+ * @param tokenId - The genesis token ID to look up.
6308
+ * @returns The best TXF token version, or `null` if not found.
5620
6309
  */
5621
6310
  getBestArchivedVersion(tokenId) {
5622
6311
  return findBestTokenVersion(tokenId, this.archivedTokens, this.forkedTokens);
5623
6312
  }
5624
6313
  /**
5625
- * Merge remote archived tokens
5626
- * @returns number of tokens updated/added
6314
+ * Merge archived tokens from a remote sync source.
6315
+ *
6316
+ * For each remote token:
6317
+ * - If missing locally, it is added.
6318
+ * - If the remote version is an incremental update of the local, it replaces it.
6319
+ * - If the histories diverge (fork), the remote version is stored via {@link storeForkedToken}.
6320
+ *
6321
+ * @param remoteArchived - Map of genesis token ID → TXF token from remote.
6322
+ * @returns Number of tokens that were updated or added locally.
5627
6323
  */
5628
6324
  async mergeArchivedTokens(remoteArchived) {
5629
6325
  let mergedCount = 0;
@@ -5646,7 +6342,11 @@ var PaymentsModule = class _PaymentsModule {
5646
6342
  return mergedCount;
5647
6343
  }
5648
6344
  /**
5649
- * Prune archived tokens
6345
+ * Prune archived tokens to keep at most `maxCount` entries.
6346
+ *
6347
+ * Oldest entries (by insertion order) are removed first.
6348
+ *
6349
+ * @param maxCount - Maximum number of archived tokens to retain (default: 100).
5650
6350
  */
5651
6351
  async pruneArchivedTokens(maxCount = 100) {
5652
6352
  if (this.archivedTokens.size <= maxCount) return;
@@ -5659,13 +6359,24 @@ var PaymentsModule = class _PaymentsModule {
5659
6359
  // Public API - Forked Tokens
5660
6360
  // ===========================================================================
5661
6361
  /**
5662
- * Get forked tokens
6362
+ * Get all forked token versions.
6363
+ *
6364
+ * Forked tokens represent alternative histories detected during sync.
6365
+ * The map key is `{tokenId}_{stateHash}`.
6366
+ *
6367
+ * @returns A shallow copy of the forked tokens map.
5663
6368
  */
5664
6369
  getForkedTokens() {
5665
6370
  return new Map(this.forkedTokens);
5666
6371
  }
5667
6372
  /**
5668
- * Store a forked token
6373
+ * Store a forked token version (alternative history).
6374
+ *
6375
+ * No-op if the exact `(tokenId, stateHash)` key already exists.
6376
+ *
6377
+ * @param tokenId - Genesis token ID.
6378
+ * @param stateHash - State hash of this forked version.
6379
+ * @param txfToken - The TXF token data to store.
5669
6380
  */
5670
6381
  async storeForkedToken(tokenId, stateHash, txfToken) {
5671
6382
  const key = `${tokenId}_${stateHash}`;
@@ -5675,8 +6386,10 @@ var PaymentsModule = class _PaymentsModule {
5675
6386
  await this.save();
5676
6387
  }
5677
6388
  /**
5678
- * Merge remote forked tokens
5679
- * @returns number of tokens added
6389
+ * Merge forked tokens from a remote sync source. Only new keys are added.
6390
+ *
6391
+ * @param remoteForked - Map of `{tokenId}_{stateHash}` → TXF token from remote.
6392
+ * @returns Number of new forked tokens added.
5680
6393
  */
5681
6394
  async mergeForkedTokens(remoteForked) {
5682
6395
  let addedCount = 0;
@@ -5692,7 +6405,9 @@ var PaymentsModule = class _PaymentsModule {
5692
6405
  return addedCount;
5693
6406
  }
5694
6407
  /**
5695
- * Prune forked tokens
6408
+ * Prune forked tokens to keep at most `maxCount` entries.
6409
+ *
6410
+ * @param maxCount - Maximum number of forked tokens to retain (default: 50).
5696
6411
  */
5697
6412
  async pruneForkedTokens(maxCount = 50) {
5698
6413
  if (this.forkedTokens.size <= maxCount) return;
@@ -5705,13 +6420,19 @@ var PaymentsModule = class _PaymentsModule {
5705
6420
  // Public API - Transaction History
5706
6421
  // ===========================================================================
5707
6422
  /**
5708
- * Get transaction history
6423
+ * Get the transaction history sorted newest-first.
6424
+ *
6425
+ * @returns Array of {@link TransactionHistoryEntry} objects in descending timestamp order.
5709
6426
  */
5710
6427
  getHistory() {
5711
6428
  return [...this.transactionHistory].sort((a, b) => b.timestamp - a.timestamp);
5712
6429
  }
5713
6430
  /**
5714
- * Add to transaction history
6431
+ * Append an entry to the transaction history.
6432
+ *
6433
+ * A unique `id` is auto-generated. The entry is immediately persisted to storage.
6434
+ *
6435
+ * @param entry - History entry fields (without `id`).
5715
6436
  */
5716
6437
  async addToHistory(entry) {
5717
6438
  this.ensureInitialized();
@@ -5729,7 +6450,11 @@ var PaymentsModule = class _PaymentsModule {
5729
6450
  // Public API - Nametag
5730
6451
  // ===========================================================================
5731
6452
  /**
5732
- * Set nametag for current identity
6453
+ * Set the nametag data for the current identity.
6454
+ *
6455
+ * Persists to both key-value storage and file storage (lottery compatibility).
6456
+ *
6457
+ * @param nametag - The nametag data including minted token JSON.
5733
6458
  */
5734
6459
  async setNametag(nametag) {
5735
6460
  this.ensureInitialized();
@@ -5739,19 +6464,23 @@ var PaymentsModule = class _PaymentsModule {
5739
6464
  this.log(`Nametag set: ${nametag.name}`);
5740
6465
  }
5741
6466
  /**
5742
- * Get nametag
6467
+ * Get the current nametag data.
6468
+ *
6469
+ * @returns The nametag data, or `null` if no nametag is set.
5743
6470
  */
5744
6471
  getNametag() {
5745
6472
  return this.nametag;
5746
6473
  }
5747
6474
  /**
5748
- * Check if has nametag
6475
+ * Check whether a nametag is currently set.
6476
+ *
6477
+ * @returns `true` if nametag data is present.
5749
6478
  */
5750
6479
  hasNametag() {
5751
6480
  return this.nametag !== null;
5752
6481
  }
5753
6482
  /**
5754
- * Clear nametag
6483
+ * Remove the current nametag data from memory and storage.
5755
6484
  */
5756
6485
  async clearNametag() {
5757
6486
  this.ensureInitialized();
@@ -5845,9 +6574,9 @@ var PaymentsModule = class _PaymentsModule {
5845
6574
  try {
5846
6575
  const signingService = await this.createSigningService();
5847
6576
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5848
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6577
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
5849
6578
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5850
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6579
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5851
6580
  const addressRef = await UnmaskedPredicateReference4.create(
5852
6581
  tokenType,
5853
6582
  signingService.algorithm,
@@ -5908,11 +6637,27 @@ var PaymentsModule = class _PaymentsModule {
5908
6637
  // Public API - Sync & Validate
5909
6638
  // ===========================================================================
5910
6639
  /**
5911
- * Sync with all token storage providers (IPFS, MongoDB, etc.)
5912
- * Syncs with each provider and merges results
6640
+ * Sync local token state with all configured token storage providers (IPFS, file, etc.).
6641
+ *
6642
+ * For each provider, the local data is packaged into TXF storage format, sent
6643
+ * to the provider's `sync()` method, and the merged result is applied locally.
6644
+ * Emits `sync:started`, `sync:completed`, and `sync:error` events.
6645
+ *
6646
+ * @returns Summary with counts of tokens added and removed during sync.
5913
6647
  */
5914
6648
  async sync() {
5915
6649
  this.ensureInitialized();
6650
+ if (this._syncInProgress) {
6651
+ return this._syncInProgress;
6652
+ }
6653
+ this._syncInProgress = this._doSync();
6654
+ try {
6655
+ return await this._syncInProgress;
6656
+ } finally {
6657
+ this._syncInProgress = null;
6658
+ }
6659
+ }
6660
+ async _doSync() {
5916
6661
  this.deps.emitEvent("sync:started", { source: "payments" });
5917
6662
  try {
5918
6663
  const providers = this.getTokenStorageProviders();
@@ -5950,6 +6695,9 @@ var PaymentsModule = class _PaymentsModule {
5950
6695
  });
5951
6696
  }
5952
6697
  }
6698
+ if (totalAdded > 0 || totalRemoved > 0) {
6699
+ await this.save();
6700
+ }
5953
6701
  this.deps.emitEvent("sync:completed", {
5954
6702
  source: "payments",
5955
6703
  count: this.tokens.size
@@ -5963,6 +6711,66 @@ var PaymentsModule = class _PaymentsModule {
5963
6711
  throw error;
5964
6712
  }
5965
6713
  }
6714
+ // ===========================================================================
6715
+ // Storage Event Subscription (Push-Based Sync)
6716
+ // ===========================================================================
6717
+ /**
6718
+ * Subscribe to 'storage:remote-updated' events from all token storage providers.
6719
+ * When a provider emits this event, a debounced sync is triggered.
6720
+ */
6721
+ subscribeToStorageEvents() {
6722
+ this.unsubscribeStorageEvents();
6723
+ const providers = this.getTokenStorageProviders();
6724
+ for (const [providerId, provider] of providers) {
6725
+ if (provider.onEvent) {
6726
+ const unsub = provider.onEvent((event) => {
6727
+ if (event.type === "storage:remote-updated") {
6728
+ this.log("Remote update detected from provider", providerId, event.data);
6729
+ this.debouncedSyncFromRemoteUpdate(providerId, event.data);
6730
+ }
6731
+ });
6732
+ this.storageEventUnsubscribers.push(unsub);
6733
+ }
6734
+ }
6735
+ }
6736
+ /**
6737
+ * Unsubscribe from all storage provider events and clear debounce timer.
6738
+ */
6739
+ unsubscribeStorageEvents() {
6740
+ for (const unsub of this.storageEventUnsubscribers) {
6741
+ unsub();
6742
+ }
6743
+ this.storageEventUnsubscribers = [];
6744
+ if (this.syncDebounceTimer) {
6745
+ clearTimeout(this.syncDebounceTimer);
6746
+ this.syncDebounceTimer = null;
6747
+ }
6748
+ }
6749
+ /**
6750
+ * Debounced sync triggered by a storage:remote-updated event.
6751
+ * Waits 500ms to batch rapid updates, then performs sync.
6752
+ */
6753
+ debouncedSyncFromRemoteUpdate(providerId, eventData) {
6754
+ if (this.syncDebounceTimer) {
6755
+ clearTimeout(this.syncDebounceTimer);
6756
+ }
6757
+ this.syncDebounceTimer = setTimeout(() => {
6758
+ this.syncDebounceTimer = null;
6759
+ this.sync().then((result) => {
6760
+ const data = eventData;
6761
+ this.deps?.emitEvent("sync:remote-update", {
6762
+ providerId,
6763
+ name: data?.name ?? "",
6764
+ sequence: data?.sequence ?? 0,
6765
+ cid: data?.cid ?? "",
6766
+ added: result.added,
6767
+ removed: result.removed
6768
+ });
6769
+ }).catch((err) => {
6770
+ this.log("Auto-sync from remote update failed:", err);
6771
+ });
6772
+ }, _PaymentsModule.SYNC_DEBOUNCE_MS);
6773
+ }
5966
6774
  /**
5967
6775
  * Get all active token storage providers
5968
6776
  */
@@ -5978,15 +6786,24 @@ var PaymentsModule = class _PaymentsModule {
5978
6786
  return /* @__PURE__ */ new Map();
5979
6787
  }
5980
6788
  /**
5981
- * Update token storage providers (called when providers are added/removed dynamically)
6789
+ * Replace the set of token storage providers at runtime.
6790
+ *
6791
+ * Use when providers are added or removed dynamically (e.g. IPFS node started).
6792
+ *
6793
+ * @param providers - New map of provider ID → TokenStorageProvider.
5982
6794
  */
5983
6795
  updateTokenStorageProviders(providers) {
5984
6796
  if (this.deps) {
5985
6797
  this.deps.tokenStorageProviders = providers;
6798
+ this.subscribeToStorageEvents();
5986
6799
  }
5987
6800
  }
5988
6801
  /**
5989
- * Validate tokens with aggregator
6802
+ * Validate all tokens against the aggregator (oracle provider).
6803
+ *
6804
+ * Tokens that fail validation or are detected as spent are marked `'invalid'`.
6805
+ *
6806
+ * @returns Object with arrays of valid and invalid tokens.
5990
6807
  */
5991
6808
  async validate() {
5992
6809
  this.ensureInitialized();
@@ -6007,7 +6824,9 @@ var PaymentsModule = class _PaymentsModule {
6007
6824
  return { valid, invalid };
6008
6825
  }
6009
6826
  /**
6010
- * Get pending transfers
6827
+ * Get all in-progress (pending) outgoing transfers.
6828
+ *
6829
+ * @returns Array of {@link TransferResult} objects for transfers that have not yet completed.
6011
6830
  */
6012
6831
  getPendingTransfers() {
6013
6832
  return Array.from(this.pendingTransfers.values());
@@ -6071,9 +6890,9 @@ var PaymentsModule = class _PaymentsModule {
6071
6890
  */
6072
6891
  async createDirectAddressFromPubkey(pubkeyHex) {
6073
6892
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
6074
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6893
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6075
6894
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
6076
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6895
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6077
6896
  const pubkeyBytes = new Uint8Array(
6078
6897
  pubkeyHex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
6079
6898
  );
@@ -6285,7 +7104,8 @@ var PaymentsModule = class _PaymentsModule {
6285
7104
  this.deps.emitEvent("transfer:confirmed", {
6286
7105
  id: crypto.randomUUID(),
6287
7106
  status: "completed",
6288
- tokens: [finalizedToken]
7107
+ tokens: [finalizedToken],
7108
+ tokenTransfers: []
6289
7109
  });
6290
7110
  await this.addToHistory({
6291
7111
  type: "RECEIVED",
@@ -6308,14 +7128,26 @@ var PaymentsModule = class _PaymentsModule {
6308
7128
  async handleIncomingTransfer(transfer) {
6309
7129
  try {
6310
7130
  const payload = transfer.payload;
7131
+ let instantBundle = null;
6311
7132
  if (isInstantSplitBundle(payload)) {
7133
+ instantBundle = payload;
7134
+ } else if (payload.token) {
7135
+ try {
7136
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
7137
+ if (isInstantSplitBundle(inner)) {
7138
+ instantBundle = inner;
7139
+ }
7140
+ } catch {
7141
+ }
7142
+ }
7143
+ if (instantBundle) {
6312
7144
  this.log("Processing INSTANT_SPLIT bundle...");
6313
7145
  try {
6314
7146
  if (!this.nametag) {
6315
7147
  await this.loadNametagFromFileStorage();
6316
7148
  }
6317
7149
  const result = await this.processInstantSplitBundle(
6318
- payload,
7150
+ instantBundle,
6319
7151
  transfer.senderTransportPubkey
6320
7152
  );
6321
7153
  if (result.success) {
@@ -6328,6 +7160,11 @@ var PaymentsModule = class _PaymentsModule {
6328
7160
  }
6329
7161
  return;
6330
7162
  }
7163
+ if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
7164
+ this.log("Processing NOSTR-FIRST commitment-only transfer...");
7165
+ await this.handleCommitmentOnlyTransfer(transfer, payload);
7166
+ return;
7167
+ }
6331
7168
  let tokenData;
6332
7169
  let finalizedSdkToken = null;
6333
7170
  if (payload.sourceToken && payload.transferTx) {
@@ -6483,6 +7320,7 @@ var PaymentsModule = class _PaymentsModule {
6483
7320
  console.error(`[Payments] Failed to save to provider ${id}:`, err);
6484
7321
  }
6485
7322
  }
7323
+ await this.savePendingV5Tokens();
6486
7324
  }
6487
7325
  async saveToOutbox(transfer, recipient) {
6488
7326
  const outbox = await this.loadOutbox();
@@ -6500,8 +7338,7 @@ var PaymentsModule = class _PaymentsModule {
6500
7338
  }
6501
7339
  async createStorageData() {
6502
7340
  return await buildTxfStorageData(
6503
- [],
6504
- // Empty - active tokens stored as token-xxx files
7341
+ Array.from(this.tokens.values()),
6505
7342
  {
6506
7343
  version: 1,
6507
7344
  address: this.deps.identity.l1Address,
@@ -6686,7 +7523,7 @@ function createPaymentsModule(config) {
6686
7523
  // modules/payments/TokenRecoveryService.ts
6687
7524
  import { TokenId as TokenId4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenId";
6688
7525
  import { TokenState as TokenState6 } from "@unicitylabs/state-transition-sdk/lib/token/TokenState";
6689
- import { TokenType as TokenType3 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
7526
+ import { TokenType as TokenType4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
6690
7527
  import { CoinId as CoinId5 } from "@unicitylabs/state-transition-sdk/lib/token/fungible/CoinId";
6691
7528
  import { HashAlgorithm as HashAlgorithm6 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
6692
7529
  import { UnmaskedPredicate as UnmaskedPredicate6 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
@@ -7750,15 +8587,20 @@ async function parseAndDecryptWalletDat(data, password, onProgress) {
7750
8587
 
7751
8588
  // core/Sphere.ts
7752
8589
  import { SigningService as SigningService2 } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService";
7753
- import { TokenType as TokenType4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
8590
+ import { TokenType as TokenType5 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
7754
8591
  import { HashAlgorithm as HashAlgorithm7 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
7755
8592
  import { UnmaskedPredicateReference as UnmaskedPredicateReference3 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference";
8593
+ import { normalizeNametag as normalizeNametag2, isPhoneNumber } from "@unicitylabs/nostr-js-sdk";
8594
+ function isValidNametag(nametag) {
8595
+ if (isPhoneNumber(nametag)) return true;
8596
+ return /^[a-z0-9_-]{3,20}$/.test(nametag);
8597
+ }
7756
8598
  var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
7757
8599
  async function deriveL3PredicateAddress(privateKey) {
7758
8600
  const secret = Buffer.from(privateKey, "hex");
7759
8601
  const signingService = await SigningService2.createFromSecret(secret);
7760
8602
  const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX2, "hex");
7761
- const tokenType = new TokenType4(tokenTypeBytes);
8603
+ const tokenType = new TokenType5(tokenTypeBytes);
7762
8604
  const predicateRef = UnmaskedPredicateReference3.create(
7763
8605
  tokenType,
7764
8606
  signingService.algorithm,
@@ -8034,6 +8876,14 @@ var Sphere = class _Sphere {
8034
8876
  console.log("[Sphere.import] Registering nametag...");
8035
8877
  await sphere.registerNametag(options.nametag);
8036
8878
  }
8879
+ if (sphere._tokenStorageProviders.size > 0) {
8880
+ try {
8881
+ const syncResult = await sphere._payments.sync();
8882
+ console.log(`[Sphere.import] Auto-sync: +${syncResult.added} -${syncResult.removed}`);
8883
+ } catch (err) {
8884
+ console.warn("[Sphere.import] Auto-sync failed (non-fatal):", err);
8885
+ }
8886
+ }
8037
8887
  console.log("[Sphere.import] Import complete");
8038
8888
  return sphere;
8039
8889
  }
@@ -8904,9 +9754,9 @@ var Sphere = class _Sphere {
8904
9754
  if (index < 0) {
8905
9755
  throw new Error("Address index must be non-negative");
8906
9756
  }
8907
- const newNametag = options?.nametag?.startsWith("@") ? options.nametag.slice(1) : options?.nametag;
8908
- if (newNametag && !this.validateNametag(newNametag)) {
8909
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
9757
+ const newNametag = options?.nametag ? this.cleanNametag(options.nametag) : void 0;
9758
+ if (newNametag && !isValidNametag(newNametag)) {
9759
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
8910
9760
  }
8911
9761
  const addressInfo = this.deriveAddress(index, false);
8912
9762
  const ipnsHash = sha256(addressInfo.publicKey, "hex").slice(0, 40);
@@ -9290,9 +10140,9 @@ var Sphere = class _Sphere {
9290
10140
  */
9291
10141
  async registerNametag(nametag) {
9292
10142
  this.ensureReady();
9293
- const cleanNametag = nametag.startsWith("@") ? nametag.slice(1) : nametag;
9294
- if (!this.validateNametag(cleanNametag)) {
9295
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
10143
+ const cleanNametag = this.cleanNametag(nametag);
10144
+ if (!isValidNametag(cleanNametag)) {
10145
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
9296
10146
  }
9297
10147
  if (this._identity?.nametag) {
9298
10148
  throw new Error(`Nametag already registered for address ${this._currentAddressIndex}: @${this._identity.nametag}`);
@@ -9601,13 +10451,11 @@ var Sphere = class _Sphere {
9601
10451
  }
9602
10452
  }
9603
10453
  /**
9604
- * Validate nametag format
10454
+ * Strip @ prefix and normalize a nametag (lowercase, phone E.164, strip @unicity suffix).
9605
10455
  */
9606
- validateNametag(nametag) {
9607
- const pattern = new RegExp(
9608
- `^[a-zA-Z0-9_-]{${LIMITS.NAMETAG_MIN_LENGTH},${LIMITS.NAMETAG_MAX_LENGTH}}$`
9609
- );
9610
- return pattern.test(nametag);
10456
+ cleanNametag(raw) {
10457
+ const stripped = raw.startsWith("@") ? raw.slice(1) : raw;
10458
+ return normalizeNametag2(stripped);
9611
10459
  }
9612
10460
  // ===========================================================================
9613
10461
  // Public Methods - Lifecycle
@@ -10307,6 +11155,14 @@ function createTokenValidator(options) {
10307
11155
  return new TokenValidator(options);
10308
11156
  }
10309
11157
 
11158
+ // index.ts
11159
+ import {
11160
+ normalizeNametag as normalizeNametag3,
11161
+ isPhoneNumber as isPhoneNumber2,
11162
+ hashNametag,
11163
+ areSameNametag
11164
+ } from "@unicitylabs/nostr-js-sdk";
11165
+
10310
11166
  // price/CoinGeckoPriceProvider.ts
10311
11167
  var CoinGeckoPriceProvider = class {
10312
11168
  platform = "coingecko";
@@ -10442,6 +11298,7 @@ export {
10442
11298
  TokenRegistry,
10443
11299
  TokenValidator,
10444
11300
  archivedKeyFromTokenId,
11301
+ areSameNametag,
10445
11302
  base58Decode,
10446
11303
  base58Encode2 as base58Encode,
10447
11304
  buildTxfStorageData,
@@ -10489,6 +11346,7 @@ export {
10489
11346
  hasUncommittedTransactions,
10490
11347
  hasValidTxfData,
10491
11348
  hash160,
11349
+ hashNametag,
10492
11350
  hexToBytes,
10493
11351
  identityFromMnemonicSync,
10494
11352
  initSphere,
@@ -10500,10 +11358,12 @@ export {
10500
11358
  isKnownToken,
10501
11359
  isPaymentSessionTerminal,
10502
11360
  isPaymentSessionTimedOut,
11361
+ isPhoneNumber2 as isPhoneNumber,
10503
11362
  isSQLiteDatabase,
10504
11363
  isTextWalletEncrypted,
10505
11364
  isTokenKey,
10506
11365
  isValidBech32,
11366
+ isValidNametag,
10507
11367
  isValidPrivateKey,
10508
11368
  isValidTokenId,
10509
11369
  isWalletDatEncrypted,
@@ -10511,6 +11371,7 @@ export {
10511
11371
  keyFromTokenId,
10512
11372
  loadSphere,
10513
11373
  mnemonicToSeedSync2 as mnemonicToSeedSync,
11374
+ normalizeNametag3 as normalizeNametag,
10514
11375
  normalizeSdkTokenToStorage,
10515
11376
  objectToTxf,
10516
11377
  parseAndDecryptWalletDat,