@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.
@@ -520,6 +520,7 @@ __export(core_exports, {
520
520
  initSphere: () => initSphere,
521
521
  isEncryptedData: () => isEncryptedData,
522
522
  isValidBech32: () => isValidBech32,
523
+ isValidNametag: () => isValidNametag,
523
524
  isValidPrivateKey: () => isValidPrivateKey,
524
525
  loadSphere: () => loadSphere,
525
526
  mnemonicToEntropy: () => mnemonicToEntropy2,
@@ -2210,6 +2211,7 @@ var import_MintCommitment = require("@unicitylabs/state-transition-sdk/lib/trans
2210
2211
  var import_HashAlgorithm2 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
2211
2212
  var import_UnmaskedPredicate2 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate");
2212
2213
  var import_InclusionProofUtils2 = require("@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils");
2214
+ var import_nostr_js_sdk = require("@unicitylabs/nostr-js-sdk");
2213
2215
  var UNICITY_TOKEN_TYPE_HEX = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
2214
2216
  var NametagMinter = class {
2215
2217
  client;
@@ -2234,7 +2236,8 @@ var NametagMinter = class {
2234
2236
  */
2235
2237
  async isNametagAvailable(nametag) {
2236
2238
  try {
2237
- const cleanNametag = nametag.replace("@", "").trim();
2239
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2240
+ const cleanNametag = (0, import_nostr_js_sdk.normalizeNametag)(stripped);
2238
2241
  const nametagTokenId = await import_TokenId2.TokenId.fromNameTag(cleanNametag);
2239
2242
  const isMinted = await this.client.isMinted(this.trustBase, nametagTokenId);
2240
2243
  return !isMinted;
@@ -2251,7 +2254,8 @@ var NametagMinter = class {
2251
2254
  * @returns MintNametagResult with token if successful
2252
2255
  */
2253
2256
  async mintNametag(nametag, ownerAddress) {
2254
- const cleanNametag = nametag.replace("@", "").trim();
2257
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2258
+ const cleanNametag = (0, import_nostr_js_sdk.normalizeNametag)(stripped);
2255
2259
  this.log(`Starting mint for nametag: ${cleanNametag}`);
2256
2260
  try {
2257
2261
  const nametagTokenId = await import_TokenId2.TokenId.fromNameTag(cleanNametag);
@@ -2404,7 +2408,9 @@ var STORAGE_KEYS_ADDRESS = {
2404
2408
  /** Messages for this address */
2405
2409
  MESSAGES: "messages",
2406
2410
  /** Transaction history for this address */
2407
- TRANSACTION_HISTORY: "transaction_history"
2411
+ TRANSACTION_HISTORY: "transaction_history",
2412
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
2413
+ PENDING_V5_TOKENS: "pending_v5_tokens"
2408
2414
  };
2409
2415
  var STORAGE_KEYS = {
2410
2416
  ...STORAGE_KEYS_GLOBAL,
@@ -2423,16 +2429,6 @@ function getAddressId(directAddress) {
2423
2429
  }
2424
2430
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
2425
2431
  var DEFAULT_DERIVATION_PATH2 = `${DEFAULT_BASE_PATH}/0/0`;
2426
- var LIMITS = {
2427
- /** Min nametag length */
2428
- NAMETAG_MIN_LENGTH: 3,
2429
- /** Max nametag length */
2430
- NAMETAG_MAX_LENGTH: 20,
2431
- /** Max memo length */
2432
- MEMO_MAX_LENGTH: 500,
2433
- /** Max message length */
2434
- MESSAGE_MAX_LENGTH: 1e4
2435
- };
2436
2432
 
2437
2433
  // types/txf.ts
2438
2434
  var ARCHIVED_PREFIX = "archived-";
@@ -2725,6 +2721,18 @@ function parseTxfStorageData(data) {
2725
2721
  result.validationErrors.push(`Forked token ${parsed.tokenId}: invalid structure`);
2726
2722
  }
2727
2723
  }
2724
+ } else if (key.startsWith("token-")) {
2725
+ try {
2726
+ const entry = storageData[key];
2727
+ const txfToken = entry?.token;
2728
+ if (txfToken?.genesis?.data?.tokenId) {
2729
+ const tokenId = txfToken.genesis.data.tokenId;
2730
+ const token = txfToToken(tokenId, txfToken);
2731
+ result.tokens.push(token);
2732
+ }
2733
+ } catch (err) {
2734
+ result.validationErrors.push(`Token ${key}: ${err}`);
2735
+ }
2728
2736
  }
2729
2737
  }
2730
2738
  return result;
@@ -3225,8 +3233,9 @@ var InstantSplitExecutor = class {
3225
3233
  const criticalPathDuration = performance.now() - startTime;
3226
3234
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3227
3235
  options?.onNostrDelivered?.(nostrEventId);
3236
+ let backgroundPromise;
3228
3237
  if (!options?.skipBackground) {
3229
- this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3238
+ backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3230
3239
  signingService: this.signingService,
3231
3240
  tokenType: tokenToSplit.type,
3232
3241
  coinId,
@@ -3242,7 +3251,8 @@ var InstantSplitExecutor = class {
3242
3251
  nostrEventId,
3243
3252
  splitGroupId,
3244
3253
  criticalPathDurationMs: criticalPathDuration,
3245
- backgroundStarted: !options?.skipBackground
3254
+ backgroundStarted: !options?.skipBackground,
3255
+ backgroundPromise
3246
3256
  };
3247
3257
  } catch (error) {
3248
3258
  const duration = performance.now() - startTime;
@@ -3304,7 +3314,7 @@ var InstantSplitExecutor = class {
3304
3314
  this.client.submitMintCommitment(recipientMintCommitment).then((res) => ({ type: "recipientMint", status: res.status })).catch((err) => ({ type: "recipientMint", status: "ERROR", error: err })),
3305
3315
  this.client.submitTransferCommitment(transferCommitment).then((res) => ({ type: "transfer", status: res.status })).catch((err) => ({ type: "transfer", status: "ERROR", error: err }))
3306
3316
  ]);
3307
- submissions.then(async (results) => {
3317
+ return submissions.then(async (results) => {
3308
3318
  const submitDuration = performance.now() - startTime;
3309
3319
  console.log(`[InstantSplit] Background: Submissions complete in ${submitDuration.toFixed(0)}ms`);
3310
3320
  context.onProgress?.({
@@ -3769,6 +3779,11 @@ var import_AddressScheme = require("@unicitylabs/state-transition-sdk/lib/addres
3769
3779
  var import_UnmaskedPredicate5 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate");
3770
3780
  var import_TokenState5 = require("@unicitylabs/state-transition-sdk/lib/token/TokenState");
3771
3781
  var import_HashAlgorithm5 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
3782
+ var import_TokenType3 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
3783
+ var import_MintCommitment3 = require("@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment");
3784
+ var import_MintTransactionData3 = require("@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData");
3785
+ var import_InclusionProofUtils5 = require("@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils");
3786
+ var import_InclusionProof = require("@unicitylabs/state-transition-sdk/lib/transaction/InclusionProof");
3772
3787
  function enrichWithRegistry(info) {
3773
3788
  const registry = TokenRegistry.getInstance();
3774
3789
  const def = registry.getDefinition(info.coinId);
@@ -3966,6 +3981,13 @@ function extractTokenStateKey(token) {
3966
3981
  if (!tokenId || !stateHash) return null;
3967
3982
  return createTokenStateKey(tokenId, stateHash);
3968
3983
  }
3984
+ function fromHex4(hex) {
3985
+ const bytes = new Uint8Array(hex.length / 2);
3986
+ for (let i = 0; i < hex.length; i += 2) {
3987
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
3988
+ }
3989
+ return bytes;
3990
+ }
3969
3991
  function hasSameGenesisTokenId(t1, t2) {
3970
3992
  const id1 = extractTokenIdFromSdkData(t1.sdkData);
3971
3993
  const id2 = extractTokenIdFromSdkData(t2.sdkData);
@@ -4055,6 +4077,7 @@ var PaymentsModule = class _PaymentsModule {
4055
4077
  // Token State
4056
4078
  tokens = /* @__PURE__ */ new Map();
4057
4079
  pendingTransfers = /* @__PURE__ */ new Map();
4080
+ pendingBackgroundTasks = [];
4058
4081
  // Repository State (tombstones, archives, forked, history)
4059
4082
  tombstones = [];
4060
4083
  archivedTokens = /* @__PURE__ */ new Map();
@@ -4079,6 +4102,12 @@ var PaymentsModule = class _PaymentsModule {
4079
4102
  // Poll every 2s
4080
4103
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
4081
4104
  // Max 30 attempts (~60s)
4105
+ // Storage event subscriptions (push-based sync)
4106
+ storageEventUnsubscribers = [];
4107
+ syncDebounceTimer = null;
4108
+ static SYNC_DEBOUNCE_MS = 500;
4109
+ /** Sync coalescing: concurrent sync() calls share the same operation */
4110
+ _syncInProgress = null;
4082
4111
  constructor(config) {
4083
4112
  this.moduleConfig = {
4084
4113
  autoSync: config?.autoSync ?? true,
@@ -4089,7 +4118,11 @@ var PaymentsModule = class _PaymentsModule {
4089
4118
  };
4090
4119
  this.l1 = config?.l1 === null ? null : new L1PaymentsModule(config?.l1);
4091
4120
  }
4092
- /** Get module configuration */
4121
+ /**
4122
+ * Get the current module configuration (excluding L1 config).
4123
+ *
4124
+ * @returns Resolved configuration with all defaults applied.
4125
+ */
4093
4126
  getConfig() {
4094
4127
  return this.moduleConfig;
4095
4128
  }
@@ -4130,9 +4163,9 @@ var PaymentsModule = class _PaymentsModule {
4130
4163
  transport: deps.transport
4131
4164
  });
4132
4165
  }
4133
- this.unsubscribeTransfers = deps.transport.onTokenTransfer((transfer) => {
4134
- this.handleIncomingTransfer(transfer);
4135
- });
4166
+ this.unsubscribeTransfers = deps.transport.onTokenTransfer(
4167
+ (transfer) => this.handleIncomingTransfer(transfer)
4168
+ );
4136
4169
  if (deps.transport.onPaymentRequest) {
4137
4170
  this.unsubscribePaymentRequests = deps.transport.onPaymentRequest((request) => {
4138
4171
  this.handleIncomingPaymentRequest(request);
@@ -4143,9 +4176,14 @@ var PaymentsModule = class _PaymentsModule {
4143
4176
  this.handlePaymentRequestResponse(response);
4144
4177
  });
4145
4178
  }
4179
+ this.subscribeToStorageEvents();
4146
4180
  }
4147
4181
  /**
4148
- * Load tokens from storage
4182
+ * Load all token data from storage providers and restore wallet state.
4183
+ *
4184
+ * Loads tokens, nametag data, transaction history, and pending transfers
4185
+ * from configured storage providers. Restores pending V5 tokens and
4186
+ * triggers a fire-and-forget {@link resolveUnconfirmed} call.
4149
4187
  */
4150
4188
  async load() {
4151
4189
  this.ensureInitialized();
@@ -4162,6 +4200,7 @@ var PaymentsModule = class _PaymentsModule {
4162
4200
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4163
4201
  }
4164
4202
  }
4203
+ await this.loadPendingV5Tokens();
4165
4204
  await this.loadTokensFromFileStorage();
4166
4205
  await this.loadNametagFromFileStorage();
4167
4206
  const historyData = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.TRANSACTION_HISTORY);
@@ -4179,9 +4218,14 @@ var PaymentsModule = class _PaymentsModule {
4179
4218
  this.pendingTransfers.set(transfer.id, transfer);
4180
4219
  }
4181
4220
  }
4221
+ this.resolveUnconfirmed().catch(() => {
4222
+ });
4182
4223
  }
4183
4224
  /**
4184
- * Cleanup resources
4225
+ * Cleanup all subscriptions, polling jobs, and pending resolvers.
4226
+ *
4227
+ * Should be called when the wallet is being shut down or the module is
4228
+ * no longer needed. Also destroys the L1 sub-module if present.
4185
4229
  */
4186
4230
  destroy() {
4187
4231
  this.unsubscribeTransfers?.();
@@ -4199,6 +4243,7 @@ var PaymentsModule = class _PaymentsModule {
4199
4243
  resolver.reject(new Error("Module destroyed"));
4200
4244
  }
4201
4245
  this.pendingResponseResolvers.clear();
4246
+ this.unsubscribeStorageEvents();
4202
4247
  if (this.l1) {
4203
4248
  this.l1.destroy();
4204
4249
  }
@@ -4215,7 +4260,8 @@ var PaymentsModule = class _PaymentsModule {
4215
4260
  const result = {
4216
4261
  id: crypto.randomUUID(),
4217
4262
  status: "pending",
4218
- tokens: []
4263
+ tokens: [],
4264
+ tokenTransfers: []
4219
4265
  };
4220
4266
  try {
4221
4267
  const peerInfo = await this.deps.transport.resolve?.(request.recipient) ?? null;
@@ -4252,69 +4298,147 @@ var PaymentsModule = class _PaymentsModule {
4252
4298
  await this.saveToOutbox(result, recipientPubkey);
4253
4299
  result.status = "submitted";
4254
4300
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4301
+ const transferMode = request.transferMode ?? "instant";
4255
4302
  if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4256
- this.log("Executing token split...");
4257
- const executor = new TokenSplitExecutor({
4258
- stateTransitionClient: stClient,
4259
- trustBase,
4260
- signingService
4261
- });
4262
- const splitResult = await executor.executeSplit(
4263
- splitPlan.tokenToSplit.sdkToken,
4264
- splitPlan.splitAmount,
4265
- splitPlan.remainderAmount,
4266
- splitPlan.coinId,
4267
- recipientAddress
4268
- );
4269
- const changeTokenData = splitResult.tokenForSender.toJSON();
4270
- const changeToken = {
4271
- id: crypto.randomUUID(),
4272
- coinId: request.coinId,
4273
- symbol: this.getCoinSymbol(request.coinId),
4274
- name: this.getCoinName(request.coinId),
4275
- decimals: this.getCoinDecimals(request.coinId),
4276
- iconUrl: this.getCoinIconUrl(request.coinId),
4277
- amount: splitPlan.remainderAmount.toString(),
4278
- status: "confirmed",
4279
- createdAt: Date.now(),
4280
- updatedAt: Date.now(),
4281
- sdkData: JSON.stringify(changeTokenData)
4282
- };
4283
- await this.addToken(changeToken, true);
4284
- this.log(`Change token saved: ${changeToken.id}, amount: ${changeToken.amount}`);
4285
- console.log(`[Payments] Sending split token to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4286
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4287
- sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4288
- transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4289
- memo: request.memo
4290
- });
4291
- console.log(`[Payments] Split token sent successfully`);
4292
- await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4293
- result.txHash = "split-" + Date.now().toString(16);
4294
- this.log(`Split transfer completed`);
4303
+ if (transferMode === "conservative") {
4304
+ this.log("Executing conservative split...");
4305
+ const splitExecutor = new TokenSplitExecutor({
4306
+ stateTransitionClient: stClient,
4307
+ trustBase,
4308
+ signingService
4309
+ });
4310
+ const splitResult = await splitExecutor.executeSplit(
4311
+ splitPlan.tokenToSplit.sdkToken,
4312
+ splitPlan.splitAmount,
4313
+ splitPlan.remainderAmount,
4314
+ splitPlan.coinId,
4315
+ recipientAddress
4316
+ );
4317
+ const changeTokenData = splitResult.tokenForSender.toJSON();
4318
+ const changeUiToken = {
4319
+ id: crypto.randomUUID(),
4320
+ coinId: request.coinId,
4321
+ symbol: this.getCoinSymbol(request.coinId),
4322
+ name: this.getCoinName(request.coinId),
4323
+ decimals: this.getCoinDecimals(request.coinId),
4324
+ iconUrl: this.getCoinIconUrl(request.coinId),
4325
+ amount: splitPlan.remainderAmount.toString(),
4326
+ status: "confirmed",
4327
+ createdAt: Date.now(),
4328
+ updatedAt: Date.now(),
4329
+ sdkData: JSON.stringify(changeTokenData)
4330
+ };
4331
+ await this.addToken(changeUiToken, true);
4332
+ this.log(`Conservative split: change token saved: ${changeUiToken.id}`);
4333
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4334
+ sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4335
+ transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4336
+ memo: request.memo
4337
+ });
4338
+ const splitCommitmentRequestId = splitResult.recipientTransferTx?.data?.requestId ?? splitResult.recipientTransferTx?.requestId;
4339
+ const splitRequestIdHex = splitCommitmentRequestId instanceof Uint8Array ? Array.from(splitCommitmentRequestId).map((b) => b.toString(16).padStart(2, "0")).join("") : splitCommitmentRequestId ? String(splitCommitmentRequestId) : void 0;
4340
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4341
+ result.tokenTransfers.push({
4342
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4343
+ method: "split",
4344
+ requestIdHex: splitRequestIdHex
4345
+ });
4346
+ this.log(`Conservative split transfer completed`);
4347
+ } else {
4348
+ this.log("Executing instant split...");
4349
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4350
+ const executor = new InstantSplitExecutor({
4351
+ stateTransitionClient: stClient,
4352
+ trustBase,
4353
+ signingService,
4354
+ devMode
4355
+ });
4356
+ const instantResult = await executor.executeSplitInstant(
4357
+ splitPlan.tokenToSplit.sdkToken,
4358
+ splitPlan.splitAmount,
4359
+ splitPlan.remainderAmount,
4360
+ splitPlan.coinId,
4361
+ recipientAddress,
4362
+ this.deps.transport,
4363
+ recipientPubkey,
4364
+ {
4365
+ onChangeTokenCreated: async (changeToken) => {
4366
+ const changeTokenData = changeToken.toJSON();
4367
+ const uiToken = {
4368
+ id: crypto.randomUUID(),
4369
+ coinId: request.coinId,
4370
+ symbol: this.getCoinSymbol(request.coinId),
4371
+ name: this.getCoinName(request.coinId),
4372
+ decimals: this.getCoinDecimals(request.coinId),
4373
+ iconUrl: this.getCoinIconUrl(request.coinId),
4374
+ amount: splitPlan.remainderAmount.toString(),
4375
+ status: "confirmed",
4376
+ createdAt: Date.now(),
4377
+ updatedAt: Date.now(),
4378
+ sdkData: JSON.stringify(changeTokenData)
4379
+ };
4380
+ await this.addToken(uiToken, true);
4381
+ this.log(`Change token saved via background: ${uiToken.id}`);
4382
+ },
4383
+ onStorageSync: async () => {
4384
+ await this.save();
4385
+ return true;
4386
+ }
4387
+ }
4388
+ );
4389
+ if (!instantResult.success) {
4390
+ throw new Error(instantResult.error || "Instant split failed");
4391
+ }
4392
+ if (instantResult.backgroundPromise) {
4393
+ this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4394
+ }
4395
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag);
4396
+ result.tokenTransfers.push({
4397
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4398
+ method: "split",
4399
+ splitGroupId: instantResult.splitGroupId,
4400
+ nostrEventId: instantResult.nostrEventId
4401
+ });
4402
+ this.log(`Instant split transfer completed`);
4403
+ }
4295
4404
  }
4296
4405
  for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4297
4406
  const token = tokenWithAmount.uiToken;
4298
4407
  const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4299
- const response = await stClient.submitTransferCommitment(commitment);
4300
- if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") {
4301
- throw new Error(`Transfer commitment failed: ${response.status}`);
4302
- }
4303
- if (!this.deps.oracle.waitForProofSdk) {
4304
- throw new Error("Oracle provider must implement waitForProofSdk()");
4408
+ if (transferMode === "conservative") {
4409
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4410
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4411
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4412
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4413
+ }
4414
+ const inclusionProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment);
4415
+ const transferTx = commitment.toTransaction(inclusionProof);
4416
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4417
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4418
+ transferTx: JSON.stringify(transferTx.toJSON()),
4419
+ memo: request.memo
4420
+ });
4421
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4422
+ } else {
4423
+ console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4424
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4425
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4426
+ commitmentData: JSON.stringify(commitment.toJSON()),
4427
+ memo: request.memo
4428
+ });
4429
+ console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4430
+ stClient.submitTransferCommitment(commitment).catch(
4431
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4432
+ );
4305
4433
  }
4306
- const inclusionProof = await this.deps.oracle.waitForProofSdk(commitment);
4307
- const transferTx = commitment.toTransaction(inclusionProof);
4308
4434
  const requestIdBytes = commitment.requestId;
4309
- result.txHash = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4310
- console.log(`[Payments] Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4311
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4312
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4313
- transferTx: JSON.stringify(transferTx.toJSON()),
4314
- memo: request.memo
4435
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4436
+ result.tokenTransfers.push({
4437
+ sourceTokenId: token.id,
4438
+ method: "direct",
4439
+ requestIdHex
4315
4440
  });
4316
- console.log(`[Payments] Direct token sent successfully`);
4317
- this.log(`Token ${token.id} transferred, txHash: ${result.txHash}`);
4441
+ this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4318
4442
  await this.removeToken(token.id, recipientNametag, true);
4319
4443
  }
4320
4444
  result.status = "delivered";
@@ -4327,7 +4451,8 @@ var PaymentsModule = class _PaymentsModule {
4327
4451
  coinId: request.coinId,
4328
4452
  symbol: this.getCoinSymbol(request.coinId),
4329
4453
  timestamp: Date.now(),
4330
- recipientNametag
4454
+ recipientNametag,
4455
+ transferId: result.id
4331
4456
  });
4332
4457
  this.deps.emitEvent("transfer:confirmed", result);
4333
4458
  return result;
@@ -4463,6 +4588,9 @@ var PaymentsModule = class _PaymentsModule {
4463
4588
  }
4464
4589
  );
4465
4590
  if (result.success) {
4591
+ if (result.backgroundPromise) {
4592
+ this.pendingBackgroundTasks.push(result.backgroundPromise);
4593
+ }
4466
4594
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4467
4595
  await this.removeToken(tokenToSplit.id, recipientNametag, true);
4468
4596
  await this.addToHistory({
@@ -4504,6 +4632,63 @@ var PaymentsModule = class _PaymentsModule {
4504
4632
  */
4505
4633
  async processInstantSplitBundle(bundle, senderPubkey) {
4506
4634
  this.ensureInitialized();
4635
+ if (!isInstantSplitBundleV5(bundle)) {
4636
+ return this.processInstantSplitBundleSync(bundle, senderPubkey);
4637
+ }
4638
+ try {
4639
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
4640
+ if (this.tokens.has(deterministicId)) {
4641
+ this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
4642
+ return { success: true, durationMs: 0 };
4643
+ }
4644
+ const registry = TokenRegistry.getInstance();
4645
+ const pendingData = {
4646
+ type: "v5_bundle",
4647
+ stage: "RECEIVED",
4648
+ bundleJson: JSON.stringify(bundle),
4649
+ senderPubkey,
4650
+ savedAt: Date.now(),
4651
+ attemptCount: 0
4652
+ };
4653
+ const uiToken = {
4654
+ id: deterministicId,
4655
+ coinId: bundle.coinId,
4656
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4657
+ name: registry.getName(bundle.coinId) || bundle.coinId,
4658
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
4659
+ amount: bundle.amount,
4660
+ status: "submitted",
4661
+ // UNCONFIRMED
4662
+ createdAt: Date.now(),
4663
+ updatedAt: Date.now(),
4664
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4665
+ };
4666
+ await this.addToken(uiToken, false);
4667
+ this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
4668
+ this.deps.emitEvent("transfer:incoming", {
4669
+ id: bundle.splitGroupId,
4670
+ senderPubkey,
4671
+ tokens: [uiToken],
4672
+ receivedAt: Date.now()
4673
+ });
4674
+ await this.save();
4675
+ this.resolveUnconfirmed().catch(() => {
4676
+ });
4677
+ return { success: true, durationMs: 0 };
4678
+ } catch (error) {
4679
+ const errorMessage = error instanceof Error ? error.message : String(error);
4680
+ return {
4681
+ success: false,
4682
+ error: errorMessage,
4683
+ durationMs: 0
4684
+ };
4685
+ }
4686
+ }
4687
+ /**
4688
+ * Synchronous V4 bundle processing (dev mode only).
4689
+ * Kept for backward compatibility with V4 bundles.
4690
+ */
4691
+ async processInstantSplitBundleSync(bundle, senderPubkey) {
4507
4692
  try {
4508
4693
  const signingService = await this.createSigningService();
4509
4694
  const stClient = this.deps.oracle.getStateTransitionClient?.();
@@ -4589,7 +4774,10 @@ var PaymentsModule = class _PaymentsModule {
4589
4774
  }
4590
4775
  }
4591
4776
  /**
4592
- * Check if a payload is an instant split bundle
4777
+ * Type-guard: check whether a payload is a valid {@link InstantSplitBundle} (V4 or V5).
4778
+ *
4779
+ * @param payload - The object to test.
4780
+ * @returns `true` if the payload matches the InstantSplitBundle shape.
4593
4781
  */
4594
4782
  isInstantSplitBundle(payload) {
4595
4783
  return isInstantSplitBundle(payload);
@@ -4670,39 +4858,57 @@ var PaymentsModule = class _PaymentsModule {
4670
4858
  return [...this.paymentRequests];
4671
4859
  }
4672
4860
  /**
4673
- * Get pending payment requests count
4861
+ * Get the count of payment requests with status `'pending'`.
4862
+ *
4863
+ * @returns Number of pending incoming payment requests.
4674
4864
  */
4675
4865
  getPendingPaymentRequestsCount() {
4676
4866
  return this.paymentRequests.filter((r) => r.status === "pending").length;
4677
4867
  }
4678
4868
  /**
4679
- * Accept a payment request (marks it as accepted, user should then call send())
4869
+ * Accept a payment request and notify the requester.
4870
+ *
4871
+ * Marks the request as `'accepted'` and sends a response via transport.
4872
+ * The caller should subsequently call {@link send} to fulfill the payment.
4873
+ *
4874
+ * @param requestId - ID of the incoming payment request to accept.
4680
4875
  */
4681
4876
  async acceptPaymentRequest(requestId2) {
4682
4877
  this.updatePaymentRequestStatus(requestId2, "accepted");
4683
4878
  await this.sendPaymentRequestResponse(requestId2, "accepted");
4684
4879
  }
4685
4880
  /**
4686
- * Reject a payment request
4881
+ * Reject a payment request and notify the requester.
4882
+ *
4883
+ * @param requestId - ID of the incoming payment request to reject.
4687
4884
  */
4688
4885
  async rejectPaymentRequest(requestId2) {
4689
4886
  this.updatePaymentRequestStatus(requestId2, "rejected");
4690
4887
  await this.sendPaymentRequestResponse(requestId2, "rejected");
4691
4888
  }
4692
4889
  /**
4693
- * Mark a payment request as paid (after successful transfer)
4890
+ * Mark a payment request as paid (local status update only).
4891
+ *
4892
+ * Typically called after a successful {@link send} to record that the
4893
+ * request has been fulfilled.
4894
+ *
4895
+ * @param requestId - ID of the incoming payment request to mark as paid.
4694
4896
  */
4695
4897
  markPaymentRequestPaid(requestId2) {
4696
4898
  this.updatePaymentRequestStatus(requestId2, "paid");
4697
4899
  }
4698
4900
  /**
4699
- * Clear processed (non-pending) payment requests
4901
+ * Remove all non-pending incoming payment requests from memory.
4902
+ *
4903
+ * Keeps only requests with status `'pending'`.
4700
4904
  */
4701
4905
  clearProcessedPaymentRequests() {
4702
4906
  this.paymentRequests = this.paymentRequests.filter((r) => r.status === "pending");
4703
4907
  }
4704
4908
  /**
4705
- * Remove a specific payment request
4909
+ * Remove a specific incoming payment request by ID.
4910
+ *
4911
+ * @param requestId - ID of the payment request to remove.
4706
4912
  */
4707
4913
  removePaymentRequest(requestId2) {
4708
4914
  this.paymentRequests = this.paymentRequests.filter((r) => r.id !== requestId2);
@@ -4827,7 +5033,11 @@ var PaymentsModule = class _PaymentsModule {
4827
5033
  });
4828
5034
  }
4829
5035
  /**
4830
- * Cancel waiting for a payment response
5036
+ * Cancel an active {@link waitForPaymentResponse} call.
5037
+ *
5038
+ * The pending promise is rejected with a `'Cancelled'` error.
5039
+ *
5040
+ * @param requestId - The outgoing request ID whose wait should be cancelled.
4831
5041
  */
4832
5042
  cancelWaitForPaymentResponse(requestId2) {
4833
5043
  const resolver = this.pendingResponseResolvers.get(requestId2);
@@ -4838,14 +5048,16 @@ var PaymentsModule = class _PaymentsModule {
4838
5048
  }
4839
5049
  }
4840
5050
  /**
4841
- * Remove an outgoing payment request
5051
+ * Remove an outgoing payment request and cancel any pending wait.
5052
+ *
5053
+ * @param requestId - ID of the outgoing request to remove.
4842
5054
  */
4843
5055
  removeOutgoingPaymentRequest(requestId2) {
4844
5056
  this.outgoingPaymentRequests.delete(requestId2);
4845
5057
  this.cancelWaitForPaymentResponse(requestId2);
4846
5058
  }
4847
5059
  /**
4848
- * Clear completed/expired outgoing payment requests
5060
+ * Remove all outgoing payment requests that are `'paid'`, `'rejected'`, or `'expired'`.
4849
5061
  */
4850
5062
  clearCompletedOutgoingPaymentRequests() {
4851
5063
  for (const [id, request] of this.outgoingPaymentRequests) {
@@ -4917,6 +5129,71 @@ var PaymentsModule = class _PaymentsModule {
4917
5129
  }
4918
5130
  }
4919
5131
  // ===========================================================================
5132
+ // Public API - Receive
5133
+ // ===========================================================================
5134
+ /**
5135
+ * Fetch and process pending incoming transfers from the transport layer.
5136
+ *
5137
+ * Performs a one-shot query to fetch all pending events, processes them
5138
+ * through the existing pipeline, and resolves after all stored events
5139
+ * are handled. Useful for batch/CLI apps that need explicit receive.
5140
+ *
5141
+ * When `finalize` is true, polls resolveUnconfirmed() + load() until all
5142
+ * tokens are confirmed or the timeout expires. Otherwise calls
5143
+ * resolveUnconfirmed() once to submit pending commitments.
5144
+ *
5145
+ * @param options - Optional receive options including finalization control
5146
+ * @param callback - Optional callback invoked for each newly received transfer
5147
+ * @returns ReceiveResult with transfers and finalization metadata
5148
+ */
5149
+ async receive(options, callback) {
5150
+ this.ensureInitialized();
5151
+ if (!this.deps.transport.fetchPendingEvents) {
5152
+ throw new Error("Transport provider does not support fetchPendingEvents");
5153
+ }
5154
+ const opts = options ?? {};
5155
+ const tokensBefore = new Set(this.tokens.keys());
5156
+ await this.deps.transport.fetchPendingEvents();
5157
+ await this.load();
5158
+ const received = [];
5159
+ for (const [tokenId, token] of this.tokens) {
5160
+ if (!tokensBefore.has(tokenId)) {
5161
+ const transfer = {
5162
+ id: tokenId,
5163
+ senderPubkey: "",
5164
+ tokens: [token],
5165
+ receivedAt: Date.now()
5166
+ };
5167
+ received.push(transfer);
5168
+ if (callback) callback(transfer);
5169
+ }
5170
+ }
5171
+ const result = { transfers: received };
5172
+ if (opts.finalize) {
5173
+ const timeout = opts.timeout ?? 6e4;
5174
+ const pollInterval = opts.pollInterval ?? 2e3;
5175
+ const startTime = Date.now();
5176
+ while (Date.now() - startTime < timeout) {
5177
+ const resolution = await this.resolveUnconfirmed();
5178
+ result.finalization = resolution;
5179
+ if (opts.onProgress) opts.onProgress(resolution);
5180
+ const stillUnconfirmed = Array.from(this.tokens.values()).some(
5181
+ (t) => t.status === "submitted" || t.status === "pending"
5182
+ );
5183
+ if (!stillUnconfirmed) break;
5184
+ await new Promise((r) => setTimeout(r, pollInterval));
5185
+ await this.load();
5186
+ }
5187
+ result.finalizationDurationMs = Date.now() - startTime;
5188
+ result.timedOut = Array.from(this.tokens.values()).some(
5189
+ (t) => t.status === "submitted" || t.status === "pending"
5190
+ );
5191
+ } else {
5192
+ result.finalization = await this.resolveUnconfirmed();
5193
+ }
5194
+ return result;
5195
+ }
5196
+ // ===========================================================================
4920
5197
  // Public API - Balance & Tokens
4921
5198
  // ===========================================================================
4922
5199
  /**
@@ -4926,10 +5203,20 @@ var PaymentsModule = class _PaymentsModule {
4926
5203
  this.priceProvider = provider;
4927
5204
  }
4928
5205
  /**
4929
- * Get total portfolio value in USD
4930
- * Returns null if PriceProvider is not configured
5206
+ * Wait for all pending background operations (e.g., instant split change token creation).
5207
+ * Call this before process exit to ensure all tokens are saved.
4931
5208
  */
4932
- async getBalance() {
5209
+ async waitForPendingOperations() {
5210
+ if (this.pendingBackgroundTasks.length > 0) {
5211
+ await Promise.allSettled(this.pendingBackgroundTasks);
5212
+ this.pendingBackgroundTasks = [];
5213
+ }
5214
+ }
5215
+ /**
5216
+ * Get total portfolio value in USD.
5217
+ * Returns null if PriceProvider is not configured.
5218
+ */
5219
+ async getFiatBalance() {
4933
5220
  const assets = await this.getAssets();
4934
5221
  if (!this.priceProvider) {
4935
5222
  return null;
@@ -4945,19 +5232,95 @@ var PaymentsModule = class _PaymentsModule {
4945
5232
  return hasAnyPrice ? total : null;
4946
5233
  }
4947
5234
  /**
4948
- * Get aggregated assets (tokens grouped by coinId) with price data
4949
- * Only includes confirmed tokens
5235
+ * Get token balances grouped by coin type.
5236
+ *
5237
+ * Returns an array of {@link Asset} objects, one per coin type held.
5238
+ * Each entry includes confirmed and unconfirmed breakdowns. Tokens with
5239
+ * status `'spent'`, `'invalid'`, or `'transferring'` are excluded.
5240
+ *
5241
+ * This is synchronous — no price data is included. Use {@link getAssets}
5242
+ * for the async version with fiat pricing.
5243
+ *
5244
+ * @param coinId - Optional coin ID to filter by (e.g. hex string). When omitted, all coin types are returned.
5245
+ * @returns Array of balance summaries (synchronous — no await needed).
5246
+ */
5247
+ getBalance(coinId) {
5248
+ return this.aggregateTokens(coinId);
5249
+ }
5250
+ /**
5251
+ * Get aggregated assets (tokens grouped by coinId) with price data.
5252
+ * Includes both confirmed and unconfirmed tokens with breakdown.
4950
5253
  */
4951
5254
  async getAssets(coinId) {
5255
+ const rawAssets = this.aggregateTokens(coinId);
5256
+ if (!this.priceProvider || rawAssets.length === 0) {
5257
+ return rawAssets;
5258
+ }
5259
+ try {
5260
+ const registry = TokenRegistry.getInstance();
5261
+ const nameToCoins = /* @__PURE__ */ new Map();
5262
+ for (const asset of rawAssets) {
5263
+ const def = registry.getDefinition(asset.coinId);
5264
+ if (def?.name) {
5265
+ const existing = nameToCoins.get(def.name);
5266
+ if (existing) {
5267
+ existing.push(asset.coinId);
5268
+ } else {
5269
+ nameToCoins.set(def.name, [asset.coinId]);
5270
+ }
5271
+ }
5272
+ }
5273
+ if (nameToCoins.size > 0) {
5274
+ const tokenNames = Array.from(nameToCoins.keys());
5275
+ const prices = await this.priceProvider.getPrices(tokenNames);
5276
+ return rawAssets.map((raw) => {
5277
+ const def = registry.getDefinition(raw.coinId);
5278
+ const price = def?.name ? prices.get(def.name) : void 0;
5279
+ let fiatValueUsd = null;
5280
+ let fiatValueEur = null;
5281
+ if (price) {
5282
+ const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
5283
+ fiatValueUsd = humanAmount * price.priceUsd;
5284
+ if (price.priceEur != null) {
5285
+ fiatValueEur = humanAmount * price.priceEur;
5286
+ }
5287
+ }
5288
+ return {
5289
+ ...raw,
5290
+ priceUsd: price?.priceUsd ?? null,
5291
+ priceEur: price?.priceEur ?? null,
5292
+ change24h: price?.change24h ?? null,
5293
+ fiatValueUsd,
5294
+ fiatValueEur
5295
+ };
5296
+ });
5297
+ }
5298
+ } catch (error) {
5299
+ console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
5300
+ }
5301
+ return rawAssets;
5302
+ }
5303
+ /**
5304
+ * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5305
+ * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
5306
+ */
5307
+ aggregateTokens(coinId) {
4952
5308
  const assetsMap = /* @__PURE__ */ new Map();
4953
5309
  for (const token of this.tokens.values()) {
4954
- if (token.status !== "confirmed") continue;
5310
+ if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
4955
5311
  if (coinId && token.coinId !== coinId) continue;
4956
5312
  const key = token.coinId;
5313
+ const amount = BigInt(token.amount);
5314
+ const isConfirmed = token.status === "confirmed";
4957
5315
  const existing = assetsMap.get(key);
4958
5316
  if (existing) {
4959
- existing.totalAmount = (BigInt(existing.totalAmount) + BigInt(token.amount)).toString();
4960
- existing.tokenCount++;
5317
+ if (isConfirmed) {
5318
+ existing.confirmedAmount += amount;
5319
+ existing.confirmedTokenCount++;
5320
+ } else {
5321
+ existing.unconfirmedAmount += amount;
5322
+ existing.unconfirmedTokenCount++;
5323
+ }
4961
5324
  } else {
4962
5325
  assetsMap.set(key, {
4963
5326
  coinId: token.coinId,
@@ -4965,78 +5328,42 @@ var PaymentsModule = class _PaymentsModule {
4965
5328
  name: token.name,
4966
5329
  decimals: token.decimals,
4967
5330
  iconUrl: token.iconUrl,
4968
- totalAmount: token.amount,
4969
- tokenCount: 1
5331
+ confirmedAmount: isConfirmed ? amount : 0n,
5332
+ unconfirmedAmount: isConfirmed ? 0n : amount,
5333
+ confirmedTokenCount: isConfirmed ? 1 : 0,
5334
+ unconfirmedTokenCount: isConfirmed ? 0 : 1
4970
5335
  });
4971
5336
  }
4972
5337
  }
4973
- const rawAssets = Array.from(assetsMap.values());
4974
- let priceMap = null;
4975
- if (this.priceProvider && rawAssets.length > 0) {
4976
- try {
4977
- const registry = TokenRegistry.getInstance();
4978
- const nameToCoins = /* @__PURE__ */ new Map();
4979
- for (const asset of rawAssets) {
4980
- const def = registry.getDefinition(asset.coinId);
4981
- if (def?.name) {
4982
- const existing = nameToCoins.get(def.name);
4983
- if (existing) {
4984
- existing.push(asset.coinId);
4985
- } else {
4986
- nameToCoins.set(def.name, [asset.coinId]);
4987
- }
4988
- }
4989
- }
4990
- if (nameToCoins.size > 0) {
4991
- const tokenNames = Array.from(nameToCoins.keys());
4992
- const prices = await this.priceProvider.getPrices(tokenNames);
4993
- priceMap = /* @__PURE__ */ new Map();
4994
- for (const [name, coinIds] of nameToCoins) {
4995
- const price = prices.get(name);
4996
- if (price) {
4997
- for (const cid of coinIds) {
4998
- priceMap.set(cid, {
4999
- priceUsd: price.priceUsd,
5000
- priceEur: price.priceEur,
5001
- change24h: price.change24h
5002
- });
5003
- }
5004
- }
5005
- }
5006
- }
5007
- } catch (error) {
5008
- console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
5009
- }
5010
- }
5011
- return rawAssets.map((raw) => {
5012
- const price = priceMap?.get(raw.coinId);
5013
- let fiatValueUsd = null;
5014
- let fiatValueEur = null;
5015
- if (price) {
5016
- const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
5017
- fiatValueUsd = humanAmount * price.priceUsd;
5018
- if (price.priceEur != null) {
5019
- fiatValueEur = humanAmount * price.priceEur;
5020
- }
5021
- }
5338
+ return Array.from(assetsMap.values()).map((raw) => {
5339
+ const totalAmount = (raw.confirmedAmount + raw.unconfirmedAmount).toString();
5022
5340
  return {
5023
5341
  coinId: raw.coinId,
5024
5342
  symbol: raw.symbol,
5025
5343
  name: raw.name,
5026
5344
  decimals: raw.decimals,
5027
5345
  iconUrl: raw.iconUrl,
5028
- totalAmount: raw.totalAmount,
5029
- tokenCount: raw.tokenCount,
5030
- priceUsd: price?.priceUsd ?? null,
5031
- priceEur: price?.priceEur ?? null,
5032
- change24h: price?.change24h ?? null,
5033
- fiatValueUsd,
5034
- fiatValueEur
5346
+ totalAmount,
5347
+ tokenCount: raw.confirmedTokenCount + raw.unconfirmedTokenCount,
5348
+ confirmedAmount: raw.confirmedAmount.toString(),
5349
+ unconfirmedAmount: raw.unconfirmedAmount.toString(),
5350
+ confirmedTokenCount: raw.confirmedTokenCount,
5351
+ unconfirmedTokenCount: raw.unconfirmedTokenCount,
5352
+ priceUsd: null,
5353
+ priceEur: null,
5354
+ change24h: null,
5355
+ fiatValueUsd: null,
5356
+ fiatValueEur: null
5035
5357
  };
5036
5358
  });
5037
5359
  }
5038
5360
  /**
5039
- * Get all tokens
5361
+ * Get all tokens, optionally filtered by coin type and/or status.
5362
+ *
5363
+ * @param filter - Optional filter criteria.
5364
+ * @param filter.coinId - Return only tokens of this coin type.
5365
+ * @param filter.status - Return only tokens with this status (e.g. `'submitted'` for unconfirmed).
5366
+ * @returns Array of matching {@link Token} objects (synchronous).
5040
5367
  */
5041
5368
  getTokens(filter) {
5042
5369
  let tokens = Array.from(this.tokens.values());
@@ -5049,19 +5376,327 @@ var PaymentsModule = class _PaymentsModule {
5049
5376
  return tokens;
5050
5377
  }
5051
5378
  /**
5052
- * Get single token
5379
+ * Get a single token by its local ID.
5380
+ *
5381
+ * @param id - The local UUID assigned when the token was added.
5382
+ * @returns The token, or `undefined` if not found.
5053
5383
  */
5054
5384
  getToken(id) {
5055
5385
  return this.tokens.get(id);
5056
5386
  }
5057
5387
  // ===========================================================================
5388
+ // Public API - Unconfirmed Token Resolution
5389
+ // ===========================================================================
5390
+ /**
5391
+ * Attempt to resolve unconfirmed (status `'submitted'`) tokens by acquiring
5392
+ * their missing aggregator proofs.
5393
+ *
5394
+ * Each unconfirmed V5 token progresses through stages:
5395
+ * `RECEIVED` → `MINT_SUBMITTED` → `MINT_PROVEN` → `TRANSFER_SUBMITTED` → `FINALIZED`
5396
+ *
5397
+ * Uses 500 ms quick-timeouts per proof check so the call returns quickly even
5398
+ * when proofs are not yet available. Tokens that exceed 50 failed attempts are
5399
+ * marked `'invalid'`.
5400
+ *
5401
+ * Automatically called (fire-and-forget) by {@link load}.
5402
+ *
5403
+ * @returns Summary with counts of resolved, still-pending, and failed tokens plus per-token details.
5404
+ */
5405
+ async resolveUnconfirmed() {
5406
+ this.ensureInitialized();
5407
+ const result = {
5408
+ resolved: 0,
5409
+ stillPending: 0,
5410
+ failed: 0,
5411
+ details: []
5412
+ };
5413
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5414
+ const trustBase = this.deps.oracle.getTrustBase?.();
5415
+ if (!stClient || !trustBase) return result;
5416
+ const signingService = await this.createSigningService();
5417
+ for (const [tokenId, token] of this.tokens) {
5418
+ if (token.status !== "submitted") continue;
5419
+ const pending2 = this.parsePendingFinalization(token.sdkData);
5420
+ if (!pending2) {
5421
+ result.stillPending++;
5422
+ continue;
5423
+ }
5424
+ if (pending2.type === "v5_bundle") {
5425
+ const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
5426
+ result.details.push({ tokenId, stage: pending2.stage, status: progress });
5427
+ if (progress === "resolved") result.resolved++;
5428
+ else if (progress === "failed") result.failed++;
5429
+ else result.stillPending++;
5430
+ }
5431
+ }
5432
+ if (result.resolved > 0 || result.failed > 0) {
5433
+ await this.save();
5434
+ }
5435
+ return result;
5436
+ }
5437
+ // ===========================================================================
5438
+ // Private - V5 Lazy Resolution Helpers
5439
+ // ===========================================================================
5440
+ /**
5441
+ * Process a single V5 token through its finalization stages with quick-timeout proof checks.
5442
+ */
5443
+ async resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService) {
5444
+ const bundle = JSON.parse(pending2.bundleJson);
5445
+ pending2.attemptCount++;
5446
+ pending2.lastAttemptAt = Date.now();
5447
+ try {
5448
+ if (pending2.stage === "RECEIVED") {
5449
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5450
+ const mintData = await import_MintTransactionData3.MintTransactionData.fromJSON(mintDataJson);
5451
+ const mintCommitment = await import_MintCommitment3.MintCommitment.create(mintData);
5452
+ const mintResponse = await stClient.submitMintCommitment(mintCommitment);
5453
+ if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
5454
+ throw new Error(`Mint submission failed: ${mintResponse.status}`);
5455
+ }
5456
+ pending2.stage = "MINT_SUBMITTED";
5457
+ this.updatePendingFinalization(token, pending2);
5458
+ }
5459
+ if (pending2.stage === "MINT_SUBMITTED") {
5460
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5461
+ const mintData = await import_MintTransactionData3.MintTransactionData.fromJSON(mintDataJson);
5462
+ const mintCommitment = await import_MintCommitment3.MintCommitment.create(mintData);
5463
+ const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
5464
+ if (!proof) {
5465
+ this.updatePendingFinalization(token, pending2);
5466
+ return "pending";
5467
+ }
5468
+ pending2.mintProofJson = JSON.stringify(proof);
5469
+ pending2.stage = "MINT_PROVEN";
5470
+ this.updatePendingFinalization(token, pending2);
5471
+ }
5472
+ if (pending2.stage === "MINT_PROVEN") {
5473
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5474
+ const transferCommitment = await import_TransferCommitment4.TransferCommitment.fromJSON(transferCommitmentJson);
5475
+ const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
5476
+ if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
5477
+ throw new Error(`Transfer submission failed: ${transferResponse.status}`);
5478
+ }
5479
+ pending2.stage = "TRANSFER_SUBMITTED";
5480
+ this.updatePendingFinalization(token, pending2);
5481
+ }
5482
+ if (pending2.stage === "TRANSFER_SUBMITTED") {
5483
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5484
+ const transferCommitment = await import_TransferCommitment4.TransferCommitment.fromJSON(transferCommitmentJson);
5485
+ const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
5486
+ if (!proof) {
5487
+ this.updatePendingFinalization(token, pending2);
5488
+ return "pending";
5489
+ }
5490
+ const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
5491
+ const confirmedToken = {
5492
+ id: token.id,
5493
+ coinId: token.coinId,
5494
+ symbol: token.symbol,
5495
+ name: token.name,
5496
+ decimals: token.decimals,
5497
+ iconUrl: token.iconUrl,
5498
+ amount: token.amount,
5499
+ status: "confirmed",
5500
+ createdAt: token.createdAt,
5501
+ updatedAt: Date.now(),
5502
+ sdkData: JSON.stringify(finalizedToken.toJSON())
5503
+ };
5504
+ this.tokens.set(tokenId, confirmedToken);
5505
+ await this.saveTokenToFileStorage(confirmedToken);
5506
+ await this.addToHistory({
5507
+ type: "RECEIVED",
5508
+ amount: confirmedToken.amount,
5509
+ coinId: confirmedToken.coinId,
5510
+ symbol: confirmedToken.symbol || "UNK",
5511
+ timestamp: Date.now(),
5512
+ senderPubkey: pending2.senderPubkey
5513
+ });
5514
+ this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
5515
+ return "resolved";
5516
+ }
5517
+ return "pending";
5518
+ } catch (error) {
5519
+ console.error(`[Payments] resolveV5Token failed for ${tokenId.slice(0, 8)}:`, error);
5520
+ if (pending2.attemptCount > 50) {
5521
+ token.status = "invalid";
5522
+ token.updatedAt = Date.now();
5523
+ this.tokens.set(tokenId, token);
5524
+ return "failed";
5525
+ }
5526
+ this.updatePendingFinalization(token, pending2);
5527
+ return "pending";
5528
+ }
5529
+ }
5530
+ /**
5531
+ * Non-blocking proof check with 500ms timeout.
5532
+ */
5533
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5534
+ async quickProofCheck(stClient, trustBase, commitment, timeoutMs = 500) {
5535
+ try {
5536
+ const proof = await Promise.race([
5537
+ (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, commitment),
5538
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
5539
+ ]);
5540
+ return proof;
5541
+ } catch {
5542
+ return null;
5543
+ }
5544
+ }
5545
+ /**
5546
+ * Perform V5 bundle finalization from stored bundle data and proofs.
5547
+ * Extracted from InstantSplitProcessor.processV5Bundle() steps 4-10.
5548
+ */
5549
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5550
+ async finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase) {
5551
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5552
+ const mintData = await import_MintTransactionData3.MintTransactionData.fromJSON(mintDataJson);
5553
+ const mintCommitment = await import_MintCommitment3.MintCommitment.create(mintData);
5554
+ const mintProofJson = JSON.parse(pending2.mintProofJson);
5555
+ const mintProof = import_InclusionProof.InclusionProof.fromJSON(mintProofJson);
5556
+ const mintTransaction = mintCommitment.toTransaction(mintProof);
5557
+ const tokenType = new import_TokenType3.TokenType(fromHex4(bundle.tokenTypeHex));
5558
+ const senderMintedStateJson = JSON.parse(bundle.mintedTokenStateJson);
5559
+ const tokenJson = {
5560
+ version: "2.0",
5561
+ state: senderMintedStateJson,
5562
+ genesis: mintTransaction.toJSON(),
5563
+ transactions: [],
5564
+ nametags: []
5565
+ };
5566
+ const mintedToken = await import_Token6.Token.fromJSON(tokenJson);
5567
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5568
+ const transferCommitment = await import_TransferCommitment4.TransferCommitment.fromJSON(transferCommitmentJson);
5569
+ const transferProof = await (0, import_InclusionProofUtils5.waitInclusionProof)(trustBase, stClient, transferCommitment);
5570
+ const transferTransaction = transferCommitment.toTransaction(transferProof);
5571
+ const transferSalt = fromHex4(bundle.transferSaltHex);
5572
+ const recipientPredicate = await import_UnmaskedPredicate5.UnmaskedPredicate.create(
5573
+ mintData.tokenId,
5574
+ tokenType,
5575
+ signingService,
5576
+ import_HashAlgorithm5.HashAlgorithm.SHA256,
5577
+ transferSalt
5578
+ );
5579
+ const recipientState = new import_TokenState5.TokenState(recipientPredicate, null);
5580
+ let nametagTokens = [];
5581
+ const recipientAddressStr = bundle.recipientAddressJson;
5582
+ if (recipientAddressStr.startsWith("PROXY://")) {
5583
+ if (bundle.nametagTokenJson) {
5584
+ try {
5585
+ const nametagToken = await import_Token6.Token.fromJSON(JSON.parse(bundle.nametagTokenJson));
5586
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5587
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5588
+ if (proxy.address === recipientAddressStr) {
5589
+ nametagTokens = [nametagToken];
5590
+ }
5591
+ } catch {
5592
+ }
5593
+ }
5594
+ if (nametagTokens.length === 0 && this.nametag?.token) {
5595
+ try {
5596
+ const nametagToken = await import_Token6.Token.fromJSON(this.nametag.token);
5597
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5598
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5599
+ if (proxy.address === recipientAddressStr) {
5600
+ nametagTokens = [nametagToken];
5601
+ }
5602
+ } catch {
5603
+ }
5604
+ }
5605
+ }
5606
+ return stClient.finalizeTransaction(trustBase, mintedToken, recipientState, transferTransaction, nametagTokens);
5607
+ }
5608
+ /**
5609
+ * Parse pending finalization metadata from token's sdkData.
5610
+ */
5611
+ parsePendingFinalization(sdkData) {
5612
+ if (!sdkData) return null;
5613
+ try {
5614
+ const data = JSON.parse(sdkData);
5615
+ if (data._pendingFinalization && data._pendingFinalization.type === "v5_bundle") {
5616
+ return data._pendingFinalization;
5617
+ }
5618
+ return null;
5619
+ } catch {
5620
+ return null;
5621
+ }
5622
+ }
5623
+ /**
5624
+ * Update pending finalization metadata in token's sdkData.
5625
+ * Creates a new token object since sdkData is readonly.
5626
+ */
5627
+ updatePendingFinalization(token, pending2) {
5628
+ const updated = {
5629
+ id: token.id,
5630
+ coinId: token.coinId,
5631
+ symbol: token.symbol,
5632
+ name: token.name,
5633
+ decimals: token.decimals,
5634
+ iconUrl: token.iconUrl,
5635
+ amount: token.amount,
5636
+ status: token.status,
5637
+ createdAt: token.createdAt,
5638
+ updatedAt: Date.now(),
5639
+ sdkData: JSON.stringify({ _pendingFinalization: pending2 })
5640
+ };
5641
+ this.tokens.set(token.id, updated);
5642
+ }
5643
+ /**
5644
+ * Save pending V5 tokens to key-value storage.
5645
+ * These tokens can't be serialized to TXF format (no genesis/state),
5646
+ * so we persist them separately and restore on load().
5647
+ */
5648
+ async savePendingV5Tokens() {
5649
+ const pendingTokens = [];
5650
+ for (const token of this.tokens.values()) {
5651
+ if (this.parsePendingFinalization(token.sdkData)) {
5652
+ pendingTokens.push(token);
5653
+ }
5654
+ }
5655
+ if (pendingTokens.length > 0) {
5656
+ await this.deps.storage.set(
5657
+ STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
5658
+ JSON.stringify(pendingTokens)
5659
+ );
5660
+ } else {
5661
+ await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
5662
+ }
5663
+ }
5664
+ /**
5665
+ * Load pending V5 tokens from key-value storage and merge into tokens map.
5666
+ * Called during load() to restore tokens that TXF format can't represent.
5667
+ */
5668
+ async loadPendingV5Tokens() {
5669
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
5670
+ if (!data) return;
5671
+ try {
5672
+ const pendingTokens = JSON.parse(data);
5673
+ for (const token of pendingTokens) {
5674
+ if (!this.tokens.has(token.id)) {
5675
+ this.tokens.set(token.id, token);
5676
+ }
5677
+ }
5678
+ if (pendingTokens.length > 0) {
5679
+ this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
5680
+ }
5681
+ } catch {
5682
+ }
5683
+ }
5684
+ // ===========================================================================
5058
5685
  // Public API - Token Operations
5059
5686
  // ===========================================================================
5060
5687
  /**
5061
- * Add a token
5062
- * Tokens are uniquely identified by (tokenId, stateHash) composite key.
5063
- * Multiple historic states of the same token can coexist.
5064
- * @returns false if exact duplicate (same tokenId AND same stateHash)
5688
+ * Add a token to the wallet.
5689
+ *
5690
+ * Tokens are uniquely identified by a `(tokenId, stateHash)` composite key.
5691
+ * Duplicate detection:
5692
+ * - **Tombstoned** — rejected if the exact `(tokenId, stateHash)` pair has a tombstone.
5693
+ * - **Exact duplicate** — rejected if a token with the same composite key already exists.
5694
+ * - **State replacement** — if the same `tokenId` exists with a *different* `stateHash`,
5695
+ * the old state is archived and replaced with the incoming one.
5696
+ *
5697
+ * @param token - The token to add.
5698
+ * @param skipHistory - When `true`, do not create a `RECEIVED` transaction history entry (default `false`).
5699
+ * @returns `true` if the token was added, `false` if rejected as duplicate or tombstoned.
5065
5700
  */
5066
5701
  async addToken(token, skipHistory = false) {
5067
5702
  this.ensureInitialized();
@@ -5119,7 +5754,9 @@ var PaymentsModule = class _PaymentsModule {
5119
5754
  });
5120
5755
  }
5121
5756
  await this.save();
5122
- await this.saveTokenToFileStorage(token);
5757
+ if (!this.parsePendingFinalization(token.sdkData)) {
5758
+ await this.saveTokenToFileStorage(token);
5759
+ }
5123
5760
  this.log(`Added token ${token.id}, total: ${this.tokens.size}`);
5124
5761
  return true;
5125
5762
  }
@@ -5176,6 +5813,9 @@ var PaymentsModule = class _PaymentsModule {
5176
5813
  const data = fileData;
5177
5814
  const tokenJson = data.token;
5178
5815
  if (!tokenJson) continue;
5816
+ if (typeof tokenJson === "object" && tokenJson !== null && "_pendingFinalization" in tokenJson) {
5817
+ continue;
5818
+ }
5179
5819
  let sdkTokenId;
5180
5820
  if (typeof tokenJson === "object" && tokenJson !== null) {
5181
5821
  const tokenObj = tokenJson;
@@ -5227,7 +5867,12 @@ var PaymentsModule = class _PaymentsModule {
5227
5867
  this.log(`Loaded ${this.tokens.size} tokens from file storage`);
5228
5868
  }
5229
5869
  /**
5230
- * Update an existing token
5870
+ * Update an existing token or add it if not found.
5871
+ *
5872
+ * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
5873
+ * `token.id`. If no match is found, falls back to {@link addToken}.
5874
+ *
5875
+ * @param token - The token with updated data. Must include a valid `id`.
5231
5876
  */
5232
5877
  async updateToken(token) {
5233
5878
  this.ensureInitialized();
@@ -5251,7 +5896,15 @@ var PaymentsModule = class _PaymentsModule {
5251
5896
  this.log(`Updated token ${token.id}`);
5252
5897
  }
5253
5898
  /**
5254
- * Remove a token by ID
5899
+ * Remove a token from the wallet.
5900
+ *
5901
+ * The token is archived first, then a tombstone `(tokenId, stateHash)` is
5902
+ * created to prevent re-addition via Nostr re-delivery. A `SENT` history
5903
+ * entry is created unless `skipHistory` is `true`.
5904
+ *
5905
+ * @param tokenId - Local UUID of the token to remove.
5906
+ * @param recipientNametag - Optional nametag of the transfer recipient (for history).
5907
+ * @param skipHistory - When `true`, skip creating a transaction history entry (default `false`).
5255
5908
  */
5256
5909
  async removeToken(tokenId, recipientNametag, skipHistory = false) {
5257
5910
  this.ensureInitialized();
@@ -5313,13 +5966,22 @@ var PaymentsModule = class _PaymentsModule {
5313
5966
  // Public API - Tombstones
5314
5967
  // ===========================================================================
5315
5968
  /**
5316
- * Get all tombstones
5969
+ * Get all tombstone entries.
5970
+ *
5971
+ * Each tombstone is keyed by `(tokenId, stateHash)` and prevents a spent
5972
+ * token state from being re-added (e.g. via Nostr re-delivery).
5973
+ *
5974
+ * @returns A shallow copy of the tombstone array.
5317
5975
  */
5318
5976
  getTombstones() {
5319
5977
  return [...this.tombstones];
5320
5978
  }
5321
5979
  /**
5322
- * Check if token state is tombstoned
5980
+ * Check whether a specific `(tokenId, stateHash)` combination is tombstoned.
5981
+ *
5982
+ * @param tokenId - The genesis token ID.
5983
+ * @param stateHash - The state hash of the token version to check.
5984
+ * @returns `true` if the exact combination has been tombstoned.
5323
5985
  */
5324
5986
  isStateTombstoned(tokenId, stateHash) {
5325
5987
  return this.tombstones.some(
@@ -5327,8 +5989,13 @@ var PaymentsModule = class _PaymentsModule {
5327
5989
  );
5328
5990
  }
5329
5991
  /**
5330
- * Merge remote tombstones
5331
- * @returns number of local tokens removed
5992
+ * Merge tombstones received from a remote sync source.
5993
+ *
5994
+ * Any local token whose `(tokenId, stateHash)` matches a remote tombstone is
5995
+ * removed. The remote tombstones are then added to the local set (union merge).
5996
+ *
5997
+ * @param remoteTombstones - Tombstone entries from the remote source.
5998
+ * @returns Number of local tokens that were removed.
5332
5999
  */
5333
6000
  async mergeTombstones(remoteTombstones) {
5334
6001
  this.ensureInitialized();
@@ -5364,7 +6031,9 @@ var PaymentsModule = class _PaymentsModule {
5364
6031
  return removedCount;
5365
6032
  }
5366
6033
  /**
5367
- * Prune old tombstones
6034
+ * Remove tombstones older than `maxAge` and cap the list at 100 entries.
6035
+ *
6036
+ * @param maxAge - Maximum age in milliseconds (default: 30 days).
5368
6037
  */
5369
6038
  async pruneTombstones(maxAge) {
5370
6039
  const originalCount = this.tombstones.length;
@@ -5378,20 +6047,38 @@ var PaymentsModule = class _PaymentsModule {
5378
6047
  // Public API - Archives
5379
6048
  // ===========================================================================
5380
6049
  /**
5381
- * Get archived tokens
6050
+ * Get all archived (spent/superseded) tokens in TXF format.
6051
+ *
6052
+ * Archived tokens are kept for recovery and sync purposes. The map key is
6053
+ * the genesis token ID.
6054
+ *
6055
+ * @returns A shallow copy of the archived token map.
5382
6056
  */
5383
6057
  getArchivedTokens() {
5384
6058
  return new Map(this.archivedTokens);
5385
6059
  }
5386
6060
  /**
5387
- * Get best archived version of a token
6061
+ * Get the best (most committed transactions) archived version of a token.
6062
+ *
6063
+ * Searches both archived and forked token maps and returns the version with
6064
+ * the highest number of committed transactions.
6065
+ *
6066
+ * @param tokenId - The genesis token ID to look up.
6067
+ * @returns The best TXF token version, or `null` if not found.
5388
6068
  */
5389
6069
  getBestArchivedVersion(tokenId) {
5390
6070
  return findBestTokenVersion(tokenId, this.archivedTokens, this.forkedTokens);
5391
6071
  }
5392
6072
  /**
5393
- * Merge remote archived tokens
5394
- * @returns number of tokens updated/added
6073
+ * Merge archived tokens from a remote sync source.
6074
+ *
6075
+ * For each remote token:
6076
+ * - If missing locally, it is added.
6077
+ * - If the remote version is an incremental update of the local, it replaces it.
6078
+ * - If the histories diverge (fork), the remote version is stored via {@link storeForkedToken}.
6079
+ *
6080
+ * @param remoteArchived - Map of genesis token ID → TXF token from remote.
6081
+ * @returns Number of tokens that were updated or added locally.
5395
6082
  */
5396
6083
  async mergeArchivedTokens(remoteArchived) {
5397
6084
  let mergedCount = 0;
@@ -5414,7 +6101,11 @@ var PaymentsModule = class _PaymentsModule {
5414
6101
  return mergedCount;
5415
6102
  }
5416
6103
  /**
5417
- * Prune archived tokens
6104
+ * Prune archived tokens to keep at most `maxCount` entries.
6105
+ *
6106
+ * Oldest entries (by insertion order) are removed first.
6107
+ *
6108
+ * @param maxCount - Maximum number of archived tokens to retain (default: 100).
5418
6109
  */
5419
6110
  async pruneArchivedTokens(maxCount = 100) {
5420
6111
  if (this.archivedTokens.size <= maxCount) return;
@@ -5427,13 +6118,24 @@ var PaymentsModule = class _PaymentsModule {
5427
6118
  // Public API - Forked Tokens
5428
6119
  // ===========================================================================
5429
6120
  /**
5430
- * Get forked tokens
6121
+ * Get all forked token versions.
6122
+ *
6123
+ * Forked tokens represent alternative histories detected during sync.
6124
+ * The map key is `{tokenId}_{stateHash}`.
6125
+ *
6126
+ * @returns A shallow copy of the forked tokens map.
5431
6127
  */
5432
6128
  getForkedTokens() {
5433
6129
  return new Map(this.forkedTokens);
5434
6130
  }
5435
6131
  /**
5436
- * Store a forked token
6132
+ * Store a forked token version (alternative history).
6133
+ *
6134
+ * No-op if the exact `(tokenId, stateHash)` key already exists.
6135
+ *
6136
+ * @param tokenId - Genesis token ID.
6137
+ * @param stateHash - State hash of this forked version.
6138
+ * @param txfToken - The TXF token data to store.
5437
6139
  */
5438
6140
  async storeForkedToken(tokenId, stateHash, txfToken) {
5439
6141
  const key = `${tokenId}_${stateHash}`;
@@ -5443,8 +6145,10 @@ var PaymentsModule = class _PaymentsModule {
5443
6145
  await this.save();
5444
6146
  }
5445
6147
  /**
5446
- * Merge remote forked tokens
5447
- * @returns number of tokens added
6148
+ * Merge forked tokens from a remote sync source. Only new keys are added.
6149
+ *
6150
+ * @param remoteForked - Map of `{tokenId}_{stateHash}` → TXF token from remote.
6151
+ * @returns Number of new forked tokens added.
5448
6152
  */
5449
6153
  async mergeForkedTokens(remoteForked) {
5450
6154
  let addedCount = 0;
@@ -5460,7 +6164,9 @@ var PaymentsModule = class _PaymentsModule {
5460
6164
  return addedCount;
5461
6165
  }
5462
6166
  /**
5463
- * Prune forked tokens
6167
+ * Prune forked tokens to keep at most `maxCount` entries.
6168
+ *
6169
+ * @param maxCount - Maximum number of forked tokens to retain (default: 50).
5464
6170
  */
5465
6171
  async pruneForkedTokens(maxCount = 50) {
5466
6172
  if (this.forkedTokens.size <= maxCount) return;
@@ -5473,13 +6179,19 @@ var PaymentsModule = class _PaymentsModule {
5473
6179
  // Public API - Transaction History
5474
6180
  // ===========================================================================
5475
6181
  /**
5476
- * Get transaction history
6182
+ * Get the transaction history sorted newest-first.
6183
+ *
6184
+ * @returns Array of {@link TransactionHistoryEntry} objects in descending timestamp order.
5477
6185
  */
5478
6186
  getHistory() {
5479
6187
  return [...this.transactionHistory].sort((a, b) => b.timestamp - a.timestamp);
5480
6188
  }
5481
6189
  /**
5482
- * Add to transaction history
6190
+ * Append an entry to the transaction history.
6191
+ *
6192
+ * A unique `id` is auto-generated. The entry is immediately persisted to storage.
6193
+ *
6194
+ * @param entry - History entry fields (without `id`).
5483
6195
  */
5484
6196
  async addToHistory(entry) {
5485
6197
  this.ensureInitialized();
@@ -5497,7 +6209,11 @@ var PaymentsModule = class _PaymentsModule {
5497
6209
  // Public API - Nametag
5498
6210
  // ===========================================================================
5499
6211
  /**
5500
- * Set nametag for current identity
6212
+ * Set the nametag data for the current identity.
6213
+ *
6214
+ * Persists to both key-value storage and file storage (lottery compatibility).
6215
+ *
6216
+ * @param nametag - The nametag data including minted token JSON.
5501
6217
  */
5502
6218
  async setNametag(nametag) {
5503
6219
  this.ensureInitialized();
@@ -5507,19 +6223,23 @@ var PaymentsModule = class _PaymentsModule {
5507
6223
  this.log(`Nametag set: ${nametag.name}`);
5508
6224
  }
5509
6225
  /**
5510
- * Get nametag
6226
+ * Get the current nametag data.
6227
+ *
6228
+ * @returns The nametag data, or `null` if no nametag is set.
5511
6229
  */
5512
6230
  getNametag() {
5513
6231
  return this.nametag;
5514
6232
  }
5515
6233
  /**
5516
- * Check if has nametag
6234
+ * Check whether a nametag is currently set.
6235
+ *
6236
+ * @returns `true` if nametag data is present.
5517
6237
  */
5518
6238
  hasNametag() {
5519
6239
  return this.nametag !== null;
5520
6240
  }
5521
6241
  /**
5522
- * Clear nametag
6242
+ * Remove the current nametag data from memory and storage.
5523
6243
  */
5524
6244
  async clearNametag() {
5525
6245
  this.ensureInitialized();
@@ -5613,9 +6333,9 @@ var PaymentsModule = class _PaymentsModule {
5613
6333
  try {
5614
6334
  const signingService = await this.createSigningService();
5615
6335
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5616
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6336
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
5617
6337
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5618
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6338
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5619
6339
  const addressRef = await UnmaskedPredicateReference4.create(
5620
6340
  tokenType,
5621
6341
  signingService.algorithm,
@@ -5676,11 +6396,27 @@ var PaymentsModule = class _PaymentsModule {
5676
6396
  // Public API - Sync & Validate
5677
6397
  // ===========================================================================
5678
6398
  /**
5679
- * Sync with all token storage providers (IPFS, MongoDB, etc.)
5680
- * Syncs with each provider and merges results
6399
+ * Sync local token state with all configured token storage providers (IPFS, file, etc.).
6400
+ *
6401
+ * For each provider, the local data is packaged into TXF storage format, sent
6402
+ * to the provider's `sync()` method, and the merged result is applied locally.
6403
+ * Emits `sync:started`, `sync:completed`, and `sync:error` events.
6404
+ *
6405
+ * @returns Summary with counts of tokens added and removed during sync.
5681
6406
  */
5682
6407
  async sync() {
5683
6408
  this.ensureInitialized();
6409
+ if (this._syncInProgress) {
6410
+ return this._syncInProgress;
6411
+ }
6412
+ this._syncInProgress = this._doSync();
6413
+ try {
6414
+ return await this._syncInProgress;
6415
+ } finally {
6416
+ this._syncInProgress = null;
6417
+ }
6418
+ }
6419
+ async _doSync() {
5684
6420
  this.deps.emitEvent("sync:started", { source: "payments" });
5685
6421
  try {
5686
6422
  const providers = this.getTokenStorageProviders();
@@ -5718,6 +6454,9 @@ var PaymentsModule = class _PaymentsModule {
5718
6454
  });
5719
6455
  }
5720
6456
  }
6457
+ if (totalAdded > 0 || totalRemoved > 0) {
6458
+ await this.save();
6459
+ }
5721
6460
  this.deps.emitEvent("sync:completed", {
5722
6461
  source: "payments",
5723
6462
  count: this.tokens.size
@@ -5731,6 +6470,66 @@ var PaymentsModule = class _PaymentsModule {
5731
6470
  throw error;
5732
6471
  }
5733
6472
  }
6473
+ // ===========================================================================
6474
+ // Storage Event Subscription (Push-Based Sync)
6475
+ // ===========================================================================
6476
+ /**
6477
+ * Subscribe to 'storage:remote-updated' events from all token storage providers.
6478
+ * When a provider emits this event, a debounced sync is triggered.
6479
+ */
6480
+ subscribeToStorageEvents() {
6481
+ this.unsubscribeStorageEvents();
6482
+ const providers = this.getTokenStorageProviders();
6483
+ for (const [providerId, provider] of providers) {
6484
+ if (provider.onEvent) {
6485
+ const unsub = provider.onEvent((event) => {
6486
+ if (event.type === "storage:remote-updated") {
6487
+ this.log("Remote update detected from provider", providerId, event.data);
6488
+ this.debouncedSyncFromRemoteUpdate(providerId, event.data);
6489
+ }
6490
+ });
6491
+ this.storageEventUnsubscribers.push(unsub);
6492
+ }
6493
+ }
6494
+ }
6495
+ /**
6496
+ * Unsubscribe from all storage provider events and clear debounce timer.
6497
+ */
6498
+ unsubscribeStorageEvents() {
6499
+ for (const unsub of this.storageEventUnsubscribers) {
6500
+ unsub();
6501
+ }
6502
+ this.storageEventUnsubscribers = [];
6503
+ if (this.syncDebounceTimer) {
6504
+ clearTimeout(this.syncDebounceTimer);
6505
+ this.syncDebounceTimer = null;
6506
+ }
6507
+ }
6508
+ /**
6509
+ * Debounced sync triggered by a storage:remote-updated event.
6510
+ * Waits 500ms to batch rapid updates, then performs sync.
6511
+ */
6512
+ debouncedSyncFromRemoteUpdate(providerId, eventData) {
6513
+ if (this.syncDebounceTimer) {
6514
+ clearTimeout(this.syncDebounceTimer);
6515
+ }
6516
+ this.syncDebounceTimer = setTimeout(() => {
6517
+ this.syncDebounceTimer = null;
6518
+ this.sync().then((result) => {
6519
+ const data = eventData;
6520
+ this.deps?.emitEvent("sync:remote-update", {
6521
+ providerId,
6522
+ name: data?.name ?? "",
6523
+ sequence: data?.sequence ?? 0,
6524
+ cid: data?.cid ?? "",
6525
+ added: result.added,
6526
+ removed: result.removed
6527
+ });
6528
+ }).catch((err) => {
6529
+ this.log("Auto-sync from remote update failed:", err);
6530
+ });
6531
+ }, _PaymentsModule.SYNC_DEBOUNCE_MS);
6532
+ }
5734
6533
  /**
5735
6534
  * Get all active token storage providers
5736
6535
  */
@@ -5746,15 +6545,24 @@ var PaymentsModule = class _PaymentsModule {
5746
6545
  return /* @__PURE__ */ new Map();
5747
6546
  }
5748
6547
  /**
5749
- * Update token storage providers (called when providers are added/removed dynamically)
6548
+ * Replace the set of token storage providers at runtime.
6549
+ *
6550
+ * Use when providers are added or removed dynamically (e.g. IPFS node started).
6551
+ *
6552
+ * @param providers - New map of provider ID → TokenStorageProvider.
5750
6553
  */
5751
6554
  updateTokenStorageProviders(providers) {
5752
6555
  if (this.deps) {
5753
6556
  this.deps.tokenStorageProviders = providers;
6557
+ this.subscribeToStorageEvents();
5754
6558
  }
5755
6559
  }
5756
6560
  /**
5757
- * Validate tokens with aggregator
6561
+ * Validate all tokens against the aggregator (oracle provider).
6562
+ *
6563
+ * Tokens that fail validation or are detected as spent are marked `'invalid'`.
6564
+ *
6565
+ * @returns Object with arrays of valid and invalid tokens.
5758
6566
  */
5759
6567
  async validate() {
5760
6568
  this.ensureInitialized();
@@ -5775,7 +6583,9 @@ var PaymentsModule = class _PaymentsModule {
5775
6583
  return { valid, invalid };
5776
6584
  }
5777
6585
  /**
5778
- * Get pending transfers
6586
+ * Get all in-progress (pending) outgoing transfers.
6587
+ *
6588
+ * @returns Array of {@link TransferResult} objects for transfers that have not yet completed.
5779
6589
  */
5780
6590
  getPendingTransfers() {
5781
6591
  return Array.from(this.pendingTransfers.values());
@@ -5839,9 +6649,9 @@ var PaymentsModule = class _PaymentsModule {
5839
6649
  */
5840
6650
  async createDirectAddressFromPubkey(pubkeyHex) {
5841
6651
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5842
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6652
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
5843
6653
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5844
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6654
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5845
6655
  const pubkeyBytes = new Uint8Array(
5846
6656
  pubkeyHex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
5847
6657
  );
@@ -6053,7 +6863,8 @@ var PaymentsModule = class _PaymentsModule {
6053
6863
  this.deps.emitEvent("transfer:confirmed", {
6054
6864
  id: crypto.randomUUID(),
6055
6865
  status: "completed",
6056
- tokens: [finalizedToken]
6866
+ tokens: [finalizedToken],
6867
+ tokenTransfers: []
6057
6868
  });
6058
6869
  await this.addToHistory({
6059
6870
  type: "RECEIVED",
@@ -6076,14 +6887,26 @@ var PaymentsModule = class _PaymentsModule {
6076
6887
  async handleIncomingTransfer(transfer) {
6077
6888
  try {
6078
6889
  const payload = transfer.payload;
6890
+ let instantBundle = null;
6079
6891
  if (isInstantSplitBundle(payload)) {
6892
+ instantBundle = payload;
6893
+ } else if (payload.token) {
6894
+ try {
6895
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
6896
+ if (isInstantSplitBundle(inner)) {
6897
+ instantBundle = inner;
6898
+ }
6899
+ } catch {
6900
+ }
6901
+ }
6902
+ if (instantBundle) {
6080
6903
  this.log("Processing INSTANT_SPLIT bundle...");
6081
6904
  try {
6082
6905
  if (!this.nametag) {
6083
6906
  await this.loadNametagFromFileStorage();
6084
6907
  }
6085
6908
  const result = await this.processInstantSplitBundle(
6086
- payload,
6909
+ instantBundle,
6087
6910
  transfer.senderTransportPubkey
6088
6911
  );
6089
6912
  if (result.success) {
@@ -6096,6 +6919,11 @@ var PaymentsModule = class _PaymentsModule {
6096
6919
  }
6097
6920
  return;
6098
6921
  }
6922
+ if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
6923
+ this.log("Processing NOSTR-FIRST commitment-only transfer...");
6924
+ await this.handleCommitmentOnlyTransfer(transfer, payload);
6925
+ return;
6926
+ }
6099
6927
  let tokenData;
6100
6928
  let finalizedSdkToken = null;
6101
6929
  if (payload.sourceToken && payload.transferTx) {
@@ -6251,6 +7079,7 @@ var PaymentsModule = class _PaymentsModule {
6251
7079
  console.error(`[Payments] Failed to save to provider ${id}:`, err);
6252
7080
  }
6253
7081
  }
7082
+ await this.savePendingV5Tokens();
6254
7083
  }
6255
7084
  async saveToOutbox(transfer, recipient) {
6256
7085
  const outbox = await this.loadOutbox();
@@ -6268,8 +7097,7 @@ var PaymentsModule = class _PaymentsModule {
6268
7097
  }
6269
7098
  async createStorageData() {
6270
7099
  return await buildTxfStorageData(
6271
- [],
6272
- // Empty - active tokens stored as token-xxx files
7100
+ Array.from(this.tokens.values()),
6273
7101
  {
6274
7102
  version: 1,
6275
7103
  address: this.deps.identity.l1Address,
@@ -6454,7 +7282,7 @@ function createPaymentsModule(config) {
6454
7282
  // modules/payments/TokenRecoveryService.ts
6455
7283
  var import_TokenId4 = require("@unicitylabs/state-transition-sdk/lib/token/TokenId");
6456
7284
  var import_TokenState6 = require("@unicitylabs/state-transition-sdk/lib/token/TokenState");
6457
- var import_TokenType3 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
7285
+ var import_TokenType4 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6458
7286
  var import_CoinId5 = require("@unicitylabs/state-transition-sdk/lib/token/fungible/CoinId");
6459
7287
  var import_HashAlgorithm6 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
6460
7288
  var import_UnmaskedPredicate6 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate");
@@ -7603,15 +8431,20 @@ async function parseAndDecryptWalletDat(data, password, onProgress) {
7603
8431
 
7604
8432
  // core/Sphere.ts
7605
8433
  var import_SigningService2 = require("@unicitylabs/state-transition-sdk/lib/sign/SigningService");
7606
- var import_TokenType4 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
8434
+ var import_TokenType5 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
7607
8435
  var import_HashAlgorithm7 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
7608
8436
  var import_UnmaskedPredicateReference3 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
8437
+ var import_nostr_js_sdk2 = require("@unicitylabs/nostr-js-sdk");
8438
+ function isValidNametag(nametag) {
8439
+ if ((0, import_nostr_js_sdk2.isPhoneNumber)(nametag)) return true;
8440
+ return /^[a-z0-9_-]{3,20}$/.test(nametag);
8441
+ }
7609
8442
  var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
7610
8443
  async function deriveL3PredicateAddress(privateKey) {
7611
8444
  const secret = Buffer.from(privateKey, "hex");
7612
8445
  const signingService = await import_SigningService2.SigningService.createFromSecret(secret);
7613
8446
  const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX2, "hex");
7614
- const tokenType = new import_TokenType4.TokenType(tokenTypeBytes);
8447
+ const tokenType = new import_TokenType5.TokenType(tokenTypeBytes);
7615
8448
  const predicateRef = import_UnmaskedPredicateReference3.UnmaskedPredicateReference.create(
7616
8449
  tokenType,
7617
8450
  signingService.algorithm,
@@ -7887,6 +8720,14 @@ var Sphere = class _Sphere {
7887
8720
  console.log("[Sphere.import] Registering nametag...");
7888
8721
  await sphere.registerNametag(options.nametag);
7889
8722
  }
8723
+ if (sphere._tokenStorageProviders.size > 0) {
8724
+ try {
8725
+ const syncResult = await sphere._payments.sync();
8726
+ console.log(`[Sphere.import] Auto-sync: +${syncResult.added} -${syncResult.removed}`);
8727
+ } catch (err) {
8728
+ console.warn("[Sphere.import] Auto-sync failed (non-fatal):", err);
8729
+ }
8730
+ }
7890
8731
  console.log("[Sphere.import] Import complete");
7891
8732
  return sphere;
7892
8733
  }
@@ -8757,9 +9598,9 @@ var Sphere = class _Sphere {
8757
9598
  if (index < 0) {
8758
9599
  throw new Error("Address index must be non-negative");
8759
9600
  }
8760
- const newNametag = options?.nametag?.startsWith("@") ? options.nametag.slice(1) : options?.nametag;
8761
- if (newNametag && !this.validateNametag(newNametag)) {
8762
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
9601
+ const newNametag = options?.nametag ? this.cleanNametag(options.nametag) : void 0;
9602
+ if (newNametag && !isValidNametag(newNametag)) {
9603
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
8763
9604
  }
8764
9605
  const addressInfo = this.deriveAddress(index, false);
8765
9606
  const ipnsHash = sha256(addressInfo.publicKey, "hex").slice(0, 40);
@@ -9143,9 +9984,9 @@ var Sphere = class _Sphere {
9143
9984
  */
9144
9985
  async registerNametag(nametag) {
9145
9986
  this.ensureReady();
9146
- const cleanNametag = nametag.startsWith("@") ? nametag.slice(1) : nametag;
9147
- if (!this.validateNametag(cleanNametag)) {
9148
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
9987
+ const cleanNametag = this.cleanNametag(nametag);
9988
+ if (!isValidNametag(cleanNametag)) {
9989
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
9149
9990
  }
9150
9991
  if (this._identity?.nametag) {
9151
9992
  throw new Error(`Nametag already registered for address ${this._currentAddressIndex}: @${this._identity.nametag}`);
@@ -9454,13 +10295,11 @@ var Sphere = class _Sphere {
9454
10295
  }
9455
10296
  }
9456
10297
  /**
9457
- * Validate nametag format
10298
+ * Strip @ prefix and normalize a nametag (lowercase, phone E.164, strip @unicity suffix).
9458
10299
  */
9459
- validateNametag(nametag) {
9460
- const pattern = new RegExp(
9461
- `^[a-zA-Z0-9_-]{${LIMITS.NAMETAG_MIN_LENGTH},${LIMITS.NAMETAG_MAX_LENGTH}}$`
9462
- );
9463
- return pattern.test(nametag);
10300
+ cleanNametag(raw) {
10301
+ const stripped = raw.startsWith("@") ? raw.slice(1) : raw;
10302
+ return (0, import_nostr_js_sdk2.normalizeNametag)(stripped);
9464
10303
  }
9465
10304
  // ===========================================================================
9466
10305
  // Public Methods - Lifecycle
@@ -9821,6 +10660,7 @@ init_bech32();
9821
10660
  initSphere,
9822
10661
  isEncryptedData,
9823
10662
  isValidBech32,
10663
+ isValidNametag,
9824
10664
  isValidPrivateKey,
9825
10665
  loadSphere,
9826
10666
  mnemonicToEntropy,