@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.
@@ -2118,6 +2118,7 @@ import { MintCommitment } from "@unicitylabs/state-transition-sdk/lib/transactio
2118
2118
  import { HashAlgorithm as HashAlgorithm2 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
2119
2119
  import { UnmaskedPredicate as UnmaskedPredicate2 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
2120
2120
  import { waitInclusionProof as waitInclusionProof2 } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils";
2121
+ import { normalizeNametag } from "@unicitylabs/nostr-js-sdk";
2121
2122
  var UNICITY_TOKEN_TYPE_HEX = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
2122
2123
  var NametagMinter = class {
2123
2124
  client;
@@ -2142,7 +2143,8 @@ var NametagMinter = class {
2142
2143
  */
2143
2144
  async isNametagAvailable(nametag) {
2144
2145
  try {
2145
- const cleanNametag = nametag.replace("@", "").trim();
2146
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2147
+ const cleanNametag = normalizeNametag(stripped);
2146
2148
  const nametagTokenId = await TokenId2.fromNameTag(cleanNametag);
2147
2149
  const isMinted = await this.client.isMinted(this.trustBase, nametagTokenId);
2148
2150
  return !isMinted;
@@ -2159,7 +2161,8 @@ var NametagMinter = class {
2159
2161
  * @returns MintNametagResult with token if successful
2160
2162
  */
2161
2163
  async mintNametag(nametag, ownerAddress) {
2162
- const cleanNametag = nametag.replace("@", "").trim();
2164
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2165
+ const cleanNametag = normalizeNametag(stripped);
2163
2166
  this.log(`Starting mint for nametag: ${cleanNametag}`);
2164
2167
  try {
2165
2168
  const nametagTokenId = await TokenId2.fromNameTag(cleanNametag);
@@ -2312,7 +2315,9 @@ var STORAGE_KEYS_ADDRESS = {
2312
2315
  /** Messages for this address */
2313
2316
  MESSAGES: "messages",
2314
2317
  /** Transaction history for this address */
2315
- TRANSACTION_HISTORY: "transaction_history"
2318
+ TRANSACTION_HISTORY: "transaction_history",
2319
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
2320
+ PENDING_V5_TOKENS: "pending_v5_tokens"
2316
2321
  };
2317
2322
  var STORAGE_KEYS = {
2318
2323
  ...STORAGE_KEYS_GLOBAL,
@@ -2331,16 +2336,6 @@ function getAddressId(directAddress) {
2331
2336
  }
2332
2337
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
2333
2338
  var DEFAULT_DERIVATION_PATH2 = `${DEFAULT_BASE_PATH}/0/0`;
2334
- var LIMITS = {
2335
- /** Min nametag length */
2336
- NAMETAG_MIN_LENGTH: 3,
2337
- /** Max nametag length */
2338
- NAMETAG_MAX_LENGTH: 20,
2339
- /** Max memo length */
2340
- MEMO_MAX_LENGTH: 500,
2341
- /** Max message length */
2342
- MESSAGE_MAX_LENGTH: 1e4
2343
- };
2344
2339
 
2345
2340
  // types/txf.ts
2346
2341
  var ARCHIVED_PREFIX = "archived-";
@@ -2633,6 +2628,18 @@ function parseTxfStorageData(data) {
2633
2628
  result.validationErrors.push(`Forked token ${parsed.tokenId}: invalid structure`);
2634
2629
  }
2635
2630
  }
2631
+ } else if (key.startsWith("token-")) {
2632
+ try {
2633
+ const entry = storageData[key];
2634
+ const txfToken = entry?.token;
2635
+ if (txfToken?.genesis?.data?.tokenId) {
2636
+ const tokenId = txfToken.genesis.data.tokenId;
2637
+ const token = txfToToken(tokenId, txfToken);
2638
+ result.tokens.push(token);
2639
+ }
2640
+ } catch (err) {
2641
+ result.validationErrors.push(`Token ${key}: ${err}`);
2642
+ }
2636
2643
  }
2637
2644
  }
2638
2645
  return result;
@@ -3133,8 +3140,9 @@ var InstantSplitExecutor = class {
3133
3140
  const criticalPathDuration = performance.now() - startTime;
3134
3141
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3135
3142
  options?.onNostrDelivered?.(nostrEventId);
3143
+ let backgroundPromise;
3136
3144
  if (!options?.skipBackground) {
3137
- this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3145
+ backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3138
3146
  signingService: this.signingService,
3139
3147
  tokenType: tokenToSplit.type,
3140
3148
  coinId,
@@ -3150,7 +3158,8 @@ var InstantSplitExecutor = class {
3150
3158
  nostrEventId,
3151
3159
  splitGroupId,
3152
3160
  criticalPathDurationMs: criticalPathDuration,
3153
- backgroundStarted: !options?.skipBackground
3161
+ backgroundStarted: !options?.skipBackground,
3162
+ backgroundPromise
3154
3163
  };
3155
3164
  } catch (error) {
3156
3165
  const duration = performance.now() - startTime;
@@ -3212,7 +3221,7 @@ var InstantSplitExecutor = class {
3212
3221
  this.client.submitMintCommitment(recipientMintCommitment).then((res) => ({ type: "recipientMint", status: res.status })).catch((err) => ({ type: "recipientMint", status: "ERROR", error: err })),
3213
3222
  this.client.submitTransferCommitment(transferCommitment).then((res) => ({ type: "transfer", status: res.status })).catch((err) => ({ type: "transfer", status: "ERROR", error: err }))
3214
3223
  ]);
3215
- submissions.then(async (results) => {
3224
+ return submissions.then(async (results) => {
3216
3225
  const submitDuration = performance.now() - startTime;
3217
3226
  console.log(`[InstantSplit] Background: Submissions complete in ${submitDuration.toFixed(0)}ms`);
3218
3227
  context.onProgress?.({
@@ -3677,6 +3686,11 @@ import { AddressScheme } from "@unicitylabs/state-transition-sdk/lib/address/Add
3677
3686
  import { UnmaskedPredicate as UnmaskedPredicate5 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
3678
3687
  import { TokenState as TokenState5 } from "@unicitylabs/state-transition-sdk/lib/token/TokenState";
3679
3688
  import { HashAlgorithm as HashAlgorithm5 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
3689
+ import { TokenType as TokenType3 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
3690
+ import { MintCommitment as MintCommitment3 } from "@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment";
3691
+ import { MintTransactionData as MintTransactionData3 } from "@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData";
3692
+ import { waitInclusionProof as waitInclusionProof5 } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils";
3693
+ import { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/transaction/InclusionProof";
3680
3694
  function enrichWithRegistry(info) {
3681
3695
  const registry = TokenRegistry.getInstance();
3682
3696
  const def = registry.getDefinition(info.coinId);
@@ -3874,6 +3888,13 @@ function extractTokenStateKey(token) {
3874
3888
  if (!tokenId || !stateHash) return null;
3875
3889
  return createTokenStateKey(tokenId, stateHash);
3876
3890
  }
3891
+ function fromHex4(hex) {
3892
+ const bytes = new Uint8Array(hex.length / 2);
3893
+ for (let i = 0; i < hex.length; i += 2) {
3894
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
3895
+ }
3896
+ return bytes;
3897
+ }
3877
3898
  function hasSameGenesisTokenId(t1, t2) {
3878
3899
  const id1 = extractTokenIdFromSdkData(t1.sdkData);
3879
3900
  const id2 = extractTokenIdFromSdkData(t2.sdkData);
@@ -3963,6 +3984,7 @@ var PaymentsModule = class _PaymentsModule {
3963
3984
  // Token State
3964
3985
  tokens = /* @__PURE__ */ new Map();
3965
3986
  pendingTransfers = /* @__PURE__ */ new Map();
3987
+ pendingBackgroundTasks = [];
3966
3988
  // Repository State (tombstones, archives, forked, history)
3967
3989
  tombstones = [];
3968
3990
  archivedTokens = /* @__PURE__ */ new Map();
@@ -3987,6 +4009,12 @@ var PaymentsModule = class _PaymentsModule {
3987
4009
  // Poll every 2s
3988
4010
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
3989
4011
  // Max 30 attempts (~60s)
4012
+ // Storage event subscriptions (push-based sync)
4013
+ storageEventUnsubscribers = [];
4014
+ syncDebounceTimer = null;
4015
+ static SYNC_DEBOUNCE_MS = 500;
4016
+ /** Sync coalescing: concurrent sync() calls share the same operation */
4017
+ _syncInProgress = null;
3990
4018
  constructor(config) {
3991
4019
  this.moduleConfig = {
3992
4020
  autoSync: config?.autoSync ?? true,
@@ -3997,7 +4025,11 @@ var PaymentsModule = class _PaymentsModule {
3997
4025
  };
3998
4026
  this.l1 = config?.l1 === null ? null : new L1PaymentsModule(config?.l1);
3999
4027
  }
4000
- /** Get module configuration */
4028
+ /**
4029
+ * Get the current module configuration (excluding L1 config).
4030
+ *
4031
+ * @returns Resolved configuration with all defaults applied.
4032
+ */
4001
4033
  getConfig() {
4002
4034
  return this.moduleConfig;
4003
4035
  }
@@ -4038,9 +4070,9 @@ var PaymentsModule = class _PaymentsModule {
4038
4070
  transport: deps.transport
4039
4071
  });
4040
4072
  }
4041
- this.unsubscribeTransfers = deps.transport.onTokenTransfer((transfer) => {
4042
- this.handleIncomingTransfer(transfer);
4043
- });
4073
+ this.unsubscribeTransfers = deps.transport.onTokenTransfer(
4074
+ (transfer) => this.handleIncomingTransfer(transfer)
4075
+ );
4044
4076
  if (deps.transport.onPaymentRequest) {
4045
4077
  this.unsubscribePaymentRequests = deps.transport.onPaymentRequest((request) => {
4046
4078
  this.handleIncomingPaymentRequest(request);
@@ -4051,9 +4083,14 @@ var PaymentsModule = class _PaymentsModule {
4051
4083
  this.handlePaymentRequestResponse(response);
4052
4084
  });
4053
4085
  }
4086
+ this.subscribeToStorageEvents();
4054
4087
  }
4055
4088
  /**
4056
- * Load tokens from storage
4089
+ * Load all token data from storage providers and restore wallet state.
4090
+ *
4091
+ * Loads tokens, nametag data, transaction history, and pending transfers
4092
+ * from configured storage providers. Restores pending V5 tokens and
4093
+ * triggers a fire-and-forget {@link resolveUnconfirmed} call.
4057
4094
  */
4058
4095
  async load() {
4059
4096
  this.ensureInitialized();
@@ -4070,6 +4107,7 @@ var PaymentsModule = class _PaymentsModule {
4070
4107
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4071
4108
  }
4072
4109
  }
4110
+ await this.loadPendingV5Tokens();
4073
4111
  await this.loadTokensFromFileStorage();
4074
4112
  await this.loadNametagFromFileStorage();
4075
4113
  const historyData = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.TRANSACTION_HISTORY);
@@ -4087,9 +4125,14 @@ var PaymentsModule = class _PaymentsModule {
4087
4125
  this.pendingTransfers.set(transfer.id, transfer);
4088
4126
  }
4089
4127
  }
4128
+ this.resolveUnconfirmed().catch(() => {
4129
+ });
4090
4130
  }
4091
4131
  /**
4092
- * Cleanup resources
4132
+ * Cleanup all subscriptions, polling jobs, and pending resolvers.
4133
+ *
4134
+ * Should be called when the wallet is being shut down or the module is
4135
+ * no longer needed. Also destroys the L1 sub-module if present.
4093
4136
  */
4094
4137
  destroy() {
4095
4138
  this.unsubscribeTransfers?.();
@@ -4107,6 +4150,7 @@ var PaymentsModule = class _PaymentsModule {
4107
4150
  resolver.reject(new Error("Module destroyed"));
4108
4151
  }
4109
4152
  this.pendingResponseResolvers.clear();
4153
+ this.unsubscribeStorageEvents();
4110
4154
  if (this.l1) {
4111
4155
  this.l1.destroy();
4112
4156
  }
@@ -4123,7 +4167,8 @@ var PaymentsModule = class _PaymentsModule {
4123
4167
  const result = {
4124
4168
  id: crypto.randomUUID(),
4125
4169
  status: "pending",
4126
- tokens: []
4170
+ tokens: [],
4171
+ tokenTransfers: []
4127
4172
  };
4128
4173
  try {
4129
4174
  const peerInfo = await this.deps.transport.resolve?.(request.recipient) ?? null;
@@ -4160,69 +4205,147 @@ var PaymentsModule = class _PaymentsModule {
4160
4205
  await this.saveToOutbox(result, recipientPubkey);
4161
4206
  result.status = "submitted";
4162
4207
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4208
+ const transferMode = request.transferMode ?? "instant";
4163
4209
  if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4164
- this.log("Executing token split...");
4165
- const executor = new TokenSplitExecutor({
4166
- stateTransitionClient: stClient,
4167
- trustBase,
4168
- signingService
4169
- });
4170
- const splitResult = await executor.executeSplit(
4171
- splitPlan.tokenToSplit.sdkToken,
4172
- splitPlan.splitAmount,
4173
- splitPlan.remainderAmount,
4174
- splitPlan.coinId,
4175
- recipientAddress
4176
- );
4177
- const changeTokenData = splitResult.tokenForSender.toJSON();
4178
- const changeToken = {
4179
- id: crypto.randomUUID(),
4180
- coinId: request.coinId,
4181
- symbol: this.getCoinSymbol(request.coinId),
4182
- name: this.getCoinName(request.coinId),
4183
- decimals: this.getCoinDecimals(request.coinId),
4184
- iconUrl: this.getCoinIconUrl(request.coinId),
4185
- amount: splitPlan.remainderAmount.toString(),
4186
- status: "confirmed",
4187
- createdAt: Date.now(),
4188
- updatedAt: Date.now(),
4189
- sdkData: JSON.stringify(changeTokenData)
4190
- };
4191
- await this.addToken(changeToken, true);
4192
- this.log(`Change token saved: ${changeToken.id}, amount: ${changeToken.amount}`);
4193
- console.log(`[Payments] Sending split token to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4194
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4195
- sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4196
- transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4197
- memo: request.memo
4198
- });
4199
- console.log(`[Payments] Split token sent successfully`);
4200
- await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4201
- result.txHash = "split-" + Date.now().toString(16);
4202
- this.log(`Split transfer completed`);
4210
+ if (transferMode === "conservative") {
4211
+ this.log("Executing conservative split...");
4212
+ const splitExecutor = new TokenSplitExecutor({
4213
+ stateTransitionClient: stClient,
4214
+ trustBase,
4215
+ signingService
4216
+ });
4217
+ const splitResult = await splitExecutor.executeSplit(
4218
+ splitPlan.tokenToSplit.sdkToken,
4219
+ splitPlan.splitAmount,
4220
+ splitPlan.remainderAmount,
4221
+ splitPlan.coinId,
4222
+ recipientAddress
4223
+ );
4224
+ const changeTokenData = splitResult.tokenForSender.toJSON();
4225
+ const changeUiToken = {
4226
+ id: crypto.randomUUID(),
4227
+ coinId: request.coinId,
4228
+ symbol: this.getCoinSymbol(request.coinId),
4229
+ name: this.getCoinName(request.coinId),
4230
+ decimals: this.getCoinDecimals(request.coinId),
4231
+ iconUrl: this.getCoinIconUrl(request.coinId),
4232
+ amount: splitPlan.remainderAmount.toString(),
4233
+ status: "confirmed",
4234
+ createdAt: Date.now(),
4235
+ updatedAt: Date.now(),
4236
+ sdkData: JSON.stringify(changeTokenData)
4237
+ };
4238
+ await this.addToken(changeUiToken, true);
4239
+ this.log(`Conservative split: change token saved: ${changeUiToken.id}`);
4240
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4241
+ sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4242
+ transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4243
+ memo: request.memo
4244
+ });
4245
+ const splitCommitmentRequestId = splitResult.recipientTransferTx?.data?.requestId ?? splitResult.recipientTransferTx?.requestId;
4246
+ const splitRequestIdHex = splitCommitmentRequestId instanceof Uint8Array ? Array.from(splitCommitmentRequestId).map((b) => b.toString(16).padStart(2, "0")).join("") : splitCommitmentRequestId ? String(splitCommitmentRequestId) : void 0;
4247
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4248
+ result.tokenTransfers.push({
4249
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4250
+ method: "split",
4251
+ requestIdHex: splitRequestIdHex
4252
+ });
4253
+ this.log(`Conservative split transfer completed`);
4254
+ } else {
4255
+ this.log("Executing instant split...");
4256
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4257
+ const executor = new InstantSplitExecutor({
4258
+ stateTransitionClient: stClient,
4259
+ trustBase,
4260
+ signingService,
4261
+ devMode
4262
+ });
4263
+ const instantResult = await executor.executeSplitInstant(
4264
+ splitPlan.tokenToSplit.sdkToken,
4265
+ splitPlan.splitAmount,
4266
+ splitPlan.remainderAmount,
4267
+ splitPlan.coinId,
4268
+ recipientAddress,
4269
+ this.deps.transport,
4270
+ recipientPubkey,
4271
+ {
4272
+ onChangeTokenCreated: async (changeToken) => {
4273
+ const changeTokenData = changeToken.toJSON();
4274
+ const uiToken = {
4275
+ id: crypto.randomUUID(),
4276
+ coinId: request.coinId,
4277
+ symbol: this.getCoinSymbol(request.coinId),
4278
+ name: this.getCoinName(request.coinId),
4279
+ decimals: this.getCoinDecimals(request.coinId),
4280
+ iconUrl: this.getCoinIconUrl(request.coinId),
4281
+ amount: splitPlan.remainderAmount.toString(),
4282
+ status: "confirmed",
4283
+ createdAt: Date.now(),
4284
+ updatedAt: Date.now(),
4285
+ sdkData: JSON.stringify(changeTokenData)
4286
+ };
4287
+ await this.addToken(uiToken, true);
4288
+ this.log(`Change token saved via background: ${uiToken.id}`);
4289
+ },
4290
+ onStorageSync: async () => {
4291
+ await this.save();
4292
+ return true;
4293
+ }
4294
+ }
4295
+ );
4296
+ if (!instantResult.success) {
4297
+ throw new Error(instantResult.error || "Instant split failed");
4298
+ }
4299
+ if (instantResult.backgroundPromise) {
4300
+ this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4301
+ }
4302
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag);
4303
+ result.tokenTransfers.push({
4304
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4305
+ method: "split",
4306
+ splitGroupId: instantResult.splitGroupId,
4307
+ nostrEventId: instantResult.nostrEventId
4308
+ });
4309
+ this.log(`Instant split transfer completed`);
4310
+ }
4203
4311
  }
4204
4312
  for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4205
4313
  const token = tokenWithAmount.uiToken;
4206
4314
  const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4207
- const response = await stClient.submitTransferCommitment(commitment);
4208
- if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") {
4209
- throw new Error(`Transfer commitment failed: ${response.status}`);
4210
- }
4211
- if (!this.deps.oracle.waitForProofSdk) {
4212
- throw new Error("Oracle provider must implement waitForProofSdk()");
4315
+ if (transferMode === "conservative") {
4316
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4317
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4318
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4319
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4320
+ }
4321
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4322
+ const transferTx = commitment.toTransaction(inclusionProof);
4323
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4324
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4325
+ transferTx: JSON.stringify(transferTx.toJSON()),
4326
+ memo: request.memo
4327
+ });
4328
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4329
+ } else {
4330
+ console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4331
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4332
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4333
+ commitmentData: JSON.stringify(commitment.toJSON()),
4334
+ memo: request.memo
4335
+ });
4336
+ console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4337
+ stClient.submitTransferCommitment(commitment).catch(
4338
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4339
+ );
4213
4340
  }
4214
- const inclusionProof = await this.deps.oracle.waitForProofSdk(commitment);
4215
- const transferTx = commitment.toTransaction(inclusionProof);
4216
4341
  const requestIdBytes = commitment.requestId;
4217
- result.txHash = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4218
- console.log(`[Payments] Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4219
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4220
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4221
- transferTx: JSON.stringify(transferTx.toJSON()),
4222
- memo: request.memo
4342
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4343
+ result.tokenTransfers.push({
4344
+ sourceTokenId: token.id,
4345
+ method: "direct",
4346
+ requestIdHex
4223
4347
  });
4224
- console.log(`[Payments] Direct token sent successfully`);
4225
- this.log(`Token ${token.id} transferred, txHash: ${result.txHash}`);
4348
+ this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4226
4349
  await this.removeToken(token.id, recipientNametag, true);
4227
4350
  }
4228
4351
  result.status = "delivered";
@@ -4235,7 +4358,8 @@ var PaymentsModule = class _PaymentsModule {
4235
4358
  coinId: request.coinId,
4236
4359
  symbol: this.getCoinSymbol(request.coinId),
4237
4360
  timestamp: Date.now(),
4238
- recipientNametag
4361
+ recipientNametag,
4362
+ transferId: result.id
4239
4363
  });
4240
4364
  this.deps.emitEvent("transfer:confirmed", result);
4241
4365
  return result;
@@ -4371,6 +4495,9 @@ var PaymentsModule = class _PaymentsModule {
4371
4495
  }
4372
4496
  );
4373
4497
  if (result.success) {
4498
+ if (result.backgroundPromise) {
4499
+ this.pendingBackgroundTasks.push(result.backgroundPromise);
4500
+ }
4374
4501
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4375
4502
  await this.removeToken(tokenToSplit.id, recipientNametag, true);
4376
4503
  await this.addToHistory({
@@ -4412,6 +4539,63 @@ var PaymentsModule = class _PaymentsModule {
4412
4539
  */
4413
4540
  async processInstantSplitBundle(bundle, senderPubkey) {
4414
4541
  this.ensureInitialized();
4542
+ if (!isInstantSplitBundleV5(bundle)) {
4543
+ return this.processInstantSplitBundleSync(bundle, senderPubkey);
4544
+ }
4545
+ try {
4546
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
4547
+ if (this.tokens.has(deterministicId)) {
4548
+ this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
4549
+ return { success: true, durationMs: 0 };
4550
+ }
4551
+ const registry = TokenRegistry.getInstance();
4552
+ const pendingData = {
4553
+ type: "v5_bundle",
4554
+ stage: "RECEIVED",
4555
+ bundleJson: JSON.stringify(bundle),
4556
+ senderPubkey,
4557
+ savedAt: Date.now(),
4558
+ attemptCount: 0
4559
+ };
4560
+ const uiToken = {
4561
+ id: deterministicId,
4562
+ coinId: bundle.coinId,
4563
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4564
+ name: registry.getName(bundle.coinId) || bundle.coinId,
4565
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
4566
+ amount: bundle.amount,
4567
+ status: "submitted",
4568
+ // UNCONFIRMED
4569
+ createdAt: Date.now(),
4570
+ updatedAt: Date.now(),
4571
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4572
+ };
4573
+ await this.addToken(uiToken, false);
4574
+ this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
4575
+ this.deps.emitEvent("transfer:incoming", {
4576
+ id: bundle.splitGroupId,
4577
+ senderPubkey,
4578
+ tokens: [uiToken],
4579
+ receivedAt: Date.now()
4580
+ });
4581
+ await this.save();
4582
+ this.resolveUnconfirmed().catch(() => {
4583
+ });
4584
+ return { success: true, durationMs: 0 };
4585
+ } catch (error) {
4586
+ const errorMessage = error instanceof Error ? error.message : String(error);
4587
+ return {
4588
+ success: false,
4589
+ error: errorMessage,
4590
+ durationMs: 0
4591
+ };
4592
+ }
4593
+ }
4594
+ /**
4595
+ * Synchronous V4 bundle processing (dev mode only).
4596
+ * Kept for backward compatibility with V4 bundles.
4597
+ */
4598
+ async processInstantSplitBundleSync(bundle, senderPubkey) {
4415
4599
  try {
4416
4600
  const signingService = await this.createSigningService();
4417
4601
  const stClient = this.deps.oracle.getStateTransitionClient?.();
@@ -4497,7 +4681,10 @@ var PaymentsModule = class _PaymentsModule {
4497
4681
  }
4498
4682
  }
4499
4683
  /**
4500
- * Check if a payload is an instant split bundle
4684
+ * Type-guard: check whether a payload is a valid {@link InstantSplitBundle} (V4 or V5).
4685
+ *
4686
+ * @param payload - The object to test.
4687
+ * @returns `true` if the payload matches the InstantSplitBundle shape.
4501
4688
  */
4502
4689
  isInstantSplitBundle(payload) {
4503
4690
  return isInstantSplitBundle(payload);
@@ -4578,39 +4765,57 @@ var PaymentsModule = class _PaymentsModule {
4578
4765
  return [...this.paymentRequests];
4579
4766
  }
4580
4767
  /**
4581
- * Get pending payment requests count
4768
+ * Get the count of payment requests with status `'pending'`.
4769
+ *
4770
+ * @returns Number of pending incoming payment requests.
4582
4771
  */
4583
4772
  getPendingPaymentRequestsCount() {
4584
4773
  return this.paymentRequests.filter((r) => r.status === "pending").length;
4585
4774
  }
4586
4775
  /**
4587
- * Accept a payment request (marks it as accepted, user should then call send())
4776
+ * Accept a payment request and notify the requester.
4777
+ *
4778
+ * Marks the request as `'accepted'` and sends a response via transport.
4779
+ * The caller should subsequently call {@link send} to fulfill the payment.
4780
+ *
4781
+ * @param requestId - ID of the incoming payment request to accept.
4588
4782
  */
4589
4783
  async acceptPaymentRequest(requestId2) {
4590
4784
  this.updatePaymentRequestStatus(requestId2, "accepted");
4591
4785
  await this.sendPaymentRequestResponse(requestId2, "accepted");
4592
4786
  }
4593
4787
  /**
4594
- * Reject a payment request
4788
+ * Reject a payment request and notify the requester.
4789
+ *
4790
+ * @param requestId - ID of the incoming payment request to reject.
4595
4791
  */
4596
4792
  async rejectPaymentRequest(requestId2) {
4597
4793
  this.updatePaymentRequestStatus(requestId2, "rejected");
4598
4794
  await this.sendPaymentRequestResponse(requestId2, "rejected");
4599
4795
  }
4600
4796
  /**
4601
- * Mark a payment request as paid (after successful transfer)
4797
+ * Mark a payment request as paid (local status update only).
4798
+ *
4799
+ * Typically called after a successful {@link send} to record that the
4800
+ * request has been fulfilled.
4801
+ *
4802
+ * @param requestId - ID of the incoming payment request to mark as paid.
4602
4803
  */
4603
4804
  markPaymentRequestPaid(requestId2) {
4604
4805
  this.updatePaymentRequestStatus(requestId2, "paid");
4605
4806
  }
4606
4807
  /**
4607
- * Clear processed (non-pending) payment requests
4808
+ * Remove all non-pending incoming payment requests from memory.
4809
+ *
4810
+ * Keeps only requests with status `'pending'`.
4608
4811
  */
4609
4812
  clearProcessedPaymentRequests() {
4610
4813
  this.paymentRequests = this.paymentRequests.filter((r) => r.status === "pending");
4611
4814
  }
4612
4815
  /**
4613
- * Remove a specific payment request
4816
+ * Remove a specific incoming payment request by ID.
4817
+ *
4818
+ * @param requestId - ID of the payment request to remove.
4614
4819
  */
4615
4820
  removePaymentRequest(requestId2) {
4616
4821
  this.paymentRequests = this.paymentRequests.filter((r) => r.id !== requestId2);
@@ -4735,7 +4940,11 @@ var PaymentsModule = class _PaymentsModule {
4735
4940
  });
4736
4941
  }
4737
4942
  /**
4738
- * Cancel waiting for a payment response
4943
+ * Cancel an active {@link waitForPaymentResponse} call.
4944
+ *
4945
+ * The pending promise is rejected with a `'Cancelled'` error.
4946
+ *
4947
+ * @param requestId - The outgoing request ID whose wait should be cancelled.
4739
4948
  */
4740
4949
  cancelWaitForPaymentResponse(requestId2) {
4741
4950
  const resolver = this.pendingResponseResolvers.get(requestId2);
@@ -4746,14 +4955,16 @@ var PaymentsModule = class _PaymentsModule {
4746
4955
  }
4747
4956
  }
4748
4957
  /**
4749
- * Remove an outgoing payment request
4958
+ * Remove an outgoing payment request and cancel any pending wait.
4959
+ *
4960
+ * @param requestId - ID of the outgoing request to remove.
4750
4961
  */
4751
4962
  removeOutgoingPaymentRequest(requestId2) {
4752
4963
  this.outgoingPaymentRequests.delete(requestId2);
4753
4964
  this.cancelWaitForPaymentResponse(requestId2);
4754
4965
  }
4755
4966
  /**
4756
- * Clear completed/expired outgoing payment requests
4967
+ * Remove all outgoing payment requests that are `'paid'`, `'rejected'`, or `'expired'`.
4757
4968
  */
4758
4969
  clearCompletedOutgoingPaymentRequests() {
4759
4970
  for (const [id, request] of this.outgoingPaymentRequests) {
@@ -4825,6 +5036,71 @@ var PaymentsModule = class _PaymentsModule {
4825
5036
  }
4826
5037
  }
4827
5038
  // ===========================================================================
5039
+ // Public API - Receive
5040
+ // ===========================================================================
5041
+ /**
5042
+ * Fetch and process pending incoming transfers from the transport layer.
5043
+ *
5044
+ * Performs a one-shot query to fetch all pending events, processes them
5045
+ * through the existing pipeline, and resolves after all stored events
5046
+ * are handled. Useful for batch/CLI apps that need explicit receive.
5047
+ *
5048
+ * When `finalize` is true, polls resolveUnconfirmed() + load() until all
5049
+ * tokens are confirmed or the timeout expires. Otherwise calls
5050
+ * resolveUnconfirmed() once to submit pending commitments.
5051
+ *
5052
+ * @param options - Optional receive options including finalization control
5053
+ * @param callback - Optional callback invoked for each newly received transfer
5054
+ * @returns ReceiveResult with transfers and finalization metadata
5055
+ */
5056
+ async receive(options, callback) {
5057
+ this.ensureInitialized();
5058
+ if (!this.deps.transport.fetchPendingEvents) {
5059
+ throw new Error("Transport provider does not support fetchPendingEvents");
5060
+ }
5061
+ const opts = options ?? {};
5062
+ const tokensBefore = new Set(this.tokens.keys());
5063
+ await this.deps.transport.fetchPendingEvents();
5064
+ await this.load();
5065
+ const received = [];
5066
+ for (const [tokenId, token] of this.tokens) {
5067
+ if (!tokensBefore.has(tokenId)) {
5068
+ const transfer = {
5069
+ id: tokenId,
5070
+ senderPubkey: "",
5071
+ tokens: [token],
5072
+ receivedAt: Date.now()
5073
+ };
5074
+ received.push(transfer);
5075
+ if (callback) callback(transfer);
5076
+ }
5077
+ }
5078
+ const result = { transfers: received };
5079
+ if (opts.finalize) {
5080
+ const timeout = opts.timeout ?? 6e4;
5081
+ const pollInterval = opts.pollInterval ?? 2e3;
5082
+ const startTime = Date.now();
5083
+ while (Date.now() - startTime < timeout) {
5084
+ const resolution = await this.resolveUnconfirmed();
5085
+ result.finalization = resolution;
5086
+ if (opts.onProgress) opts.onProgress(resolution);
5087
+ const stillUnconfirmed = Array.from(this.tokens.values()).some(
5088
+ (t) => t.status === "submitted" || t.status === "pending"
5089
+ );
5090
+ if (!stillUnconfirmed) break;
5091
+ await new Promise((r) => setTimeout(r, pollInterval));
5092
+ await this.load();
5093
+ }
5094
+ result.finalizationDurationMs = Date.now() - startTime;
5095
+ result.timedOut = Array.from(this.tokens.values()).some(
5096
+ (t) => t.status === "submitted" || t.status === "pending"
5097
+ );
5098
+ } else {
5099
+ result.finalization = await this.resolveUnconfirmed();
5100
+ }
5101
+ return result;
5102
+ }
5103
+ // ===========================================================================
4828
5104
  // Public API - Balance & Tokens
4829
5105
  // ===========================================================================
4830
5106
  /**
@@ -4834,10 +5110,20 @@ var PaymentsModule = class _PaymentsModule {
4834
5110
  this.priceProvider = provider;
4835
5111
  }
4836
5112
  /**
4837
- * Get total portfolio value in USD
4838
- * Returns null if PriceProvider is not configured
5113
+ * Wait for all pending background operations (e.g., instant split change token creation).
5114
+ * Call this before process exit to ensure all tokens are saved.
4839
5115
  */
4840
- async getBalance() {
5116
+ async waitForPendingOperations() {
5117
+ if (this.pendingBackgroundTasks.length > 0) {
5118
+ await Promise.allSettled(this.pendingBackgroundTasks);
5119
+ this.pendingBackgroundTasks = [];
5120
+ }
5121
+ }
5122
+ /**
5123
+ * Get total portfolio value in USD.
5124
+ * Returns null if PriceProvider is not configured.
5125
+ */
5126
+ async getFiatBalance() {
4841
5127
  const assets = await this.getAssets();
4842
5128
  if (!this.priceProvider) {
4843
5129
  return null;
@@ -4853,19 +5139,95 @@ var PaymentsModule = class _PaymentsModule {
4853
5139
  return hasAnyPrice ? total : null;
4854
5140
  }
4855
5141
  /**
4856
- * Get aggregated assets (tokens grouped by coinId) with price data
4857
- * Only includes confirmed tokens
5142
+ * Get token balances grouped by coin type.
5143
+ *
5144
+ * Returns an array of {@link Asset} objects, one per coin type held.
5145
+ * Each entry includes confirmed and unconfirmed breakdowns. Tokens with
5146
+ * status `'spent'`, `'invalid'`, or `'transferring'` are excluded.
5147
+ *
5148
+ * This is synchronous — no price data is included. Use {@link getAssets}
5149
+ * for the async version with fiat pricing.
5150
+ *
5151
+ * @param coinId - Optional coin ID to filter by (e.g. hex string). When omitted, all coin types are returned.
5152
+ * @returns Array of balance summaries (synchronous — no await needed).
5153
+ */
5154
+ getBalance(coinId) {
5155
+ return this.aggregateTokens(coinId);
5156
+ }
5157
+ /**
5158
+ * Get aggregated assets (tokens grouped by coinId) with price data.
5159
+ * Includes both confirmed and unconfirmed tokens with breakdown.
4858
5160
  */
4859
5161
  async getAssets(coinId) {
5162
+ const rawAssets = this.aggregateTokens(coinId);
5163
+ if (!this.priceProvider || rawAssets.length === 0) {
5164
+ return rawAssets;
5165
+ }
5166
+ try {
5167
+ const registry = TokenRegistry.getInstance();
5168
+ const nameToCoins = /* @__PURE__ */ new Map();
5169
+ for (const asset of rawAssets) {
5170
+ const def = registry.getDefinition(asset.coinId);
5171
+ if (def?.name) {
5172
+ const existing = nameToCoins.get(def.name);
5173
+ if (existing) {
5174
+ existing.push(asset.coinId);
5175
+ } else {
5176
+ nameToCoins.set(def.name, [asset.coinId]);
5177
+ }
5178
+ }
5179
+ }
5180
+ if (nameToCoins.size > 0) {
5181
+ const tokenNames = Array.from(nameToCoins.keys());
5182
+ const prices = await this.priceProvider.getPrices(tokenNames);
5183
+ return rawAssets.map((raw) => {
5184
+ const def = registry.getDefinition(raw.coinId);
5185
+ const price = def?.name ? prices.get(def.name) : void 0;
5186
+ let fiatValueUsd = null;
5187
+ let fiatValueEur = null;
5188
+ if (price) {
5189
+ const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
5190
+ fiatValueUsd = humanAmount * price.priceUsd;
5191
+ if (price.priceEur != null) {
5192
+ fiatValueEur = humanAmount * price.priceEur;
5193
+ }
5194
+ }
5195
+ return {
5196
+ ...raw,
5197
+ priceUsd: price?.priceUsd ?? null,
5198
+ priceEur: price?.priceEur ?? null,
5199
+ change24h: price?.change24h ?? null,
5200
+ fiatValueUsd,
5201
+ fiatValueEur
5202
+ };
5203
+ });
5204
+ }
5205
+ } catch (error) {
5206
+ console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
5207
+ }
5208
+ return rawAssets;
5209
+ }
5210
+ /**
5211
+ * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5212
+ * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
5213
+ */
5214
+ aggregateTokens(coinId) {
4860
5215
  const assetsMap = /* @__PURE__ */ new Map();
4861
5216
  for (const token of this.tokens.values()) {
4862
- if (token.status !== "confirmed") continue;
5217
+ if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
4863
5218
  if (coinId && token.coinId !== coinId) continue;
4864
5219
  const key = token.coinId;
5220
+ const amount = BigInt(token.amount);
5221
+ const isConfirmed = token.status === "confirmed";
4865
5222
  const existing = assetsMap.get(key);
4866
5223
  if (existing) {
4867
- existing.totalAmount = (BigInt(existing.totalAmount) + BigInt(token.amount)).toString();
4868
- existing.tokenCount++;
5224
+ if (isConfirmed) {
5225
+ existing.confirmedAmount += amount;
5226
+ existing.confirmedTokenCount++;
5227
+ } else {
5228
+ existing.unconfirmedAmount += amount;
5229
+ existing.unconfirmedTokenCount++;
5230
+ }
4869
5231
  } else {
4870
5232
  assetsMap.set(key, {
4871
5233
  coinId: token.coinId,
@@ -4873,78 +5235,42 @@ var PaymentsModule = class _PaymentsModule {
4873
5235
  name: token.name,
4874
5236
  decimals: token.decimals,
4875
5237
  iconUrl: token.iconUrl,
4876
- totalAmount: token.amount,
4877
- tokenCount: 1
5238
+ confirmedAmount: isConfirmed ? amount : 0n,
5239
+ unconfirmedAmount: isConfirmed ? 0n : amount,
5240
+ confirmedTokenCount: isConfirmed ? 1 : 0,
5241
+ unconfirmedTokenCount: isConfirmed ? 0 : 1
4878
5242
  });
4879
5243
  }
4880
5244
  }
4881
- const rawAssets = Array.from(assetsMap.values());
4882
- let priceMap = null;
4883
- if (this.priceProvider && rawAssets.length > 0) {
4884
- try {
4885
- const registry = TokenRegistry.getInstance();
4886
- const nameToCoins = /* @__PURE__ */ new Map();
4887
- for (const asset of rawAssets) {
4888
- const def = registry.getDefinition(asset.coinId);
4889
- if (def?.name) {
4890
- const existing = nameToCoins.get(def.name);
4891
- if (existing) {
4892
- existing.push(asset.coinId);
4893
- } else {
4894
- nameToCoins.set(def.name, [asset.coinId]);
4895
- }
4896
- }
4897
- }
4898
- if (nameToCoins.size > 0) {
4899
- const tokenNames = Array.from(nameToCoins.keys());
4900
- const prices = await this.priceProvider.getPrices(tokenNames);
4901
- priceMap = /* @__PURE__ */ new Map();
4902
- for (const [name, coinIds] of nameToCoins) {
4903
- const price = prices.get(name);
4904
- if (price) {
4905
- for (const cid of coinIds) {
4906
- priceMap.set(cid, {
4907
- priceUsd: price.priceUsd,
4908
- priceEur: price.priceEur,
4909
- change24h: price.change24h
4910
- });
4911
- }
4912
- }
4913
- }
4914
- }
4915
- } catch (error) {
4916
- console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
4917
- }
4918
- }
4919
- return rawAssets.map((raw) => {
4920
- const price = priceMap?.get(raw.coinId);
4921
- let fiatValueUsd = null;
4922
- let fiatValueEur = null;
4923
- if (price) {
4924
- const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
4925
- fiatValueUsd = humanAmount * price.priceUsd;
4926
- if (price.priceEur != null) {
4927
- fiatValueEur = humanAmount * price.priceEur;
4928
- }
4929
- }
5245
+ return Array.from(assetsMap.values()).map((raw) => {
5246
+ const totalAmount = (raw.confirmedAmount + raw.unconfirmedAmount).toString();
4930
5247
  return {
4931
5248
  coinId: raw.coinId,
4932
5249
  symbol: raw.symbol,
4933
5250
  name: raw.name,
4934
5251
  decimals: raw.decimals,
4935
5252
  iconUrl: raw.iconUrl,
4936
- totalAmount: raw.totalAmount,
4937
- tokenCount: raw.tokenCount,
4938
- priceUsd: price?.priceUsd ?? null,
4939
- priceEur: price?.priceEur ?? null,
4940
- change24h: price?.change24h ?? null,
4941
- fiatValueUsd,
4942
- fiatValueEur
5253
+ totalAmount,
5254
+ tokenCount: raw.confirmedTokenCount + raw.unconfirmedTokenCount,
5255
+ confirmedAmount: raw.confirmedAmount.toString(),
5256
+ unconfirmedAmount: raw.unconfirmedAmount.toString(),
5257
+ confirmedTokenCount: raw.confirmedTokenCount,
5258
+ unconfirmedTokenCount: raw.unconfirmedTokenCount,
5259
+ priceUsd: null,
5260
+ priceEur: null,
5261
+ change24h: null,
5262
+ fiatValueUsd: null,
5263
+ fiatValueEur: null
4943
5264
  };
4944
5265
  });
4945
5266
  }
4946
5267
  /**
4947
- * Get all tokens
5268
+ * Get all tokens, optionally filtered by coin type and/or status.
5269
+ *
5270
+ * @param filter - Optional filter criteria.
5271
+ * @param filter.coinId - Return only tokens of this coin type.
5272
+ * @param filter.status - Return only tokens with this status (e.g. `'submitted'` for unconfirmed).
5273
+ * @returns Array of matching {@link Token} objects (synchronous).
4948
5274
  */
4949
5275
  getTokens(filter) {
4950
5276
  let tokens = Array.from(this.tokens.values());
@@ -4957,19 +5283,327 @@ var PaymentsModule = class _PaymentsModule {
4957
5283
  return tokens;
4958
5284
  }
4959
5285
  /**
4960
- * Get single token
5286
+ * Get a single token by its local ID.
5287
+ *
5288
+ * @param id - The local UUID assigned when the token was added.
5289
+ * @returns The token, or `undefined` if not found.
4961
5290
  */
4962
5291
  getToken(id) {
4963
5292
  return this.tokens.get(id);
4964
5293
  }
4965
5294
  // ===========================================================================
5295
+ // Public API - Unconfirmed Token Resolution
5296
+ // ===========================================================================
5297
+ /**
5298
+ * Attempt to resolve unconfirmed (status `'submitted'`) tokens by acquiring
5299
+ * their missing aggregator proofs.
5300
+ *
5301
+ * Each unconfirmed V5 token progresses through stages:
5302
+ * `RECEIVED` → `MINT_SUBMITTED` → `MINT_PROVEN` → `TRANSFER_SUBMITTED` → `FINALIZED`
5303
+ *
5304
+ * Uses 500 ms quick-timeouts per proof check so the call returns quickly even
5305
+ * when proofs are not yet available. Tokens that exceed 50 failed attempts are
5306
+ * marked `'invalid'`.
5307
+ *
5308
+ * Automatically called (fire-and-forget) by {@link load}.
5309
+ *
5310
+ * @returns Summary with counts of resolved, still-pending, and failed tokens plus per-token details.
5311
+ */
5312
+ async resolveUnconfirmed() {
5313
+ this.ensureInitialized();
5314
+ const result = {
5315
+ resolved: 0,
5316
+ stillPending: 0,
5317
+ failed: 0,
5318
+ details: []
5319
+ };
5320
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5321
+ const trustBase = this.deps.oracle.getTrustBase?.();
5322
+ if (!stClient || !trustBase) return result;
5323
+ const signingService = await this.createSigningService();
5324
+ for (const [tokenId, token] of this.tokens) {
5325
+ if (token.status !== "submitted") continue;
5326
+ const pending2 = this.parsePendingFinalization(token.sdkData);
5327
+ if (!pending2) {
5328
+ result.stillPending++;
5329
+ continue;
5330
+ }
5331
+ if (pending2.type === "v5_bundle") {
5332
+ const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
5333
+ result.details.push({ tokenId, stage: pending2.stage, status: progress });
5334
+ if (progress === "resolved") result.resolved++;
5335
+ else if (progress === "failed") result.failed++;
5336
+ else result.stillPending++;
5337
+ }
5338
+ }
5339
+ if (result.resolved > 0 || result.failed > 0) {
5340
+ await this.save();
5341
+ }
5342
+ return result;
5343
+ }
5344
+ // ===========================================================================
5345
+ // Private - V5 Lazy Resolution Helpers
5346
+ // ===========================================================================
5347
+ /**
5348
+ * Process a single V5 token through its finalization stages with quick-timeout proof checks.
5349
+ */
5350
+ async resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService) {
5351
+ const bundle = JSON.parse(pending2.bundleJson);
5352
+ pending2.attemptCount++;
5353
+ pending2.lastAttemptAt = Date.now();
5354
+ try {
5355
+ if (pending2.stage === "RECEIVED") {
5356
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5357
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5358
+ const mintCommitment = await MintCommitment3.create(mintData);
5359
+ const mintResponse = await stClient.submitMintCommitment(mintCommitment);
5360
+ if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
5361
+ throw new Error(`Mint submission failed: ${mintResponse.status}`);
5362
+ }
5363
+ pending2.stage = "MINT_SUBMITTED";
5364
+ this.updatePendingFinalization(token, pending2);
5365
+ }
5366
+ if (pending2.stage === "MINT_SUBMITTED") {
5367
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5368
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5369
+ const mintCommitment = await MintCommitment3.create(mintData);
5370
+ const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
5371
+ if (!proof) {
5372
+ this.updatePendingFinalization(token, pending2);
5373
+ return "pending";
5374
+ }
5375
+ pending2.mintProofJson = JSON.stringify(proof);
5376
+ pending2.stage = "MINT_PROVEN";
5377
+ this.updatePendingFinalization(token, pending2);
5378
+ }
5379
+ if (pending2.stage === "MINT_PROVEN") {
5380
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5381
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5382
+ const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
5383
+ if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
5384
+ throw new Error(`Transfer submission failed: ${transferResponse.status}`);
5385
+ }
5386
+ pending2.stage = "TRANSFER_SUBMITTED";
5387
+ this.updatePendingFinalization(token, pending2);
5388
+ }
5389
+ if (pending2.stage === "TRANSFER_SUBMITTED") {
5390
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5391
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5392
+ const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
5393
+ if (!proof) {
5394
+ this.updatePendingFinalization(token, pending2);
5395
+ return "pending";
5396
+ }
5397
+ const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
5398
+ const confirmedToken = {
5399
+ id: token.id,
5400
+ coinId: token.coinId,
5401
+ symbol: token.symbol,
5402
+ name: token.name,
5403
+ decimals: token.decimals,
5404
+ iconUrl: token.iconUrl,
5405
+ amount: token.amount,
5406
+ status: "confirmed",
5407
+ createdAt: token.createdAt,
5408
+ updatedAt: Date.now(),
5409
+ sdkData: JSON.stringify(finalizedToken.toJSON())
5410
+ };
5411
+ this.tokens.set(tokenId, confirmedToken);
5412
+ await this.saveTokenToFileStorage(confirmedToken);
5413
+ await this.addToHistory({
5414
+ type: "RECEIVED",
5415
+ amount: confirmedToken.amount,
5416
+ coinId: confirmedToken.coinId,
5417
+ symbol: confirmedToken.symbol || "UNK",
5418
+ timestamp: Date.now(),
5419
+ senderPubkey: pending2.senderPubkey
5420
+ });
5421
+ this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
5422
+ return "resolved";
5423
+ }
5424
+ return "pending";
5425
+ } catch (error) {
5426
+ console.error(`[Payments] resolveV5Token failed for ${tokenId.slice(0, 8)}:`, error);
5427
+ if (pending2.attemptCount > 50) {
5428
+ token.status = "invalid";
5429
+ token.updatedAt = Date.now();
5430
+ this.tokens.set(tokenId, token);
5431
+ return "failed";
5432
+ }
5433
+ this.updatePendingFinalization(token, pending2);
5434
+ return "pending";
5435
+ }
5436
+ }
5437
+ /**
5438
+ * Non-blocking proof check with 500ms timeout.
5439
+ */
5440
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5441
+ async quickProofCheck(stClient, trustBase, commitment, timeoutMs = 500) {
5442
+ try {
5443
+ const proof = await Promise.race([
5444
+ waitInclusionProof5(trustBase, stClient, commitment),
5445
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
5446
+ ]);
5447
+ return proof;
5448
+ } catch {
5449
+ return null;
5450
+ }
5451
+ }
5452
+ /**
5453
+ * Perform V5 bundle finalization from stored bundle data and proofs.
5454
+ * Extracted from InstantSplitProcessor.processV5Bundle() steps 4-10.
5455
+ */
5456
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5457
+ async finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase) {
5458
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5459
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5460
+ const mintCommitment = await MintCommitment3.create(mintData);
5461
+ const mintProofJson = JSON.parse(pending2.mintProofJson);
5462
+ const mintProof = InclusionProof.fromJSON(mintProofJson);
5463
+ const mintTransaction = mintCommitment.toTransaction(mintProof);
5464
+ const tokenType = new TokenType3(fromHex4(bundle.tokenTypeHex));
5465
+ const senderMintedStateJson = JSON.parse(bundle.mintedTokenStateJson);
5466
+ const tokenJson = {
5467
+ version: "2.0",
5468
+ state: senderMintedStateJson,
5469
+ genesis: mintTransaction.toJSON(),
5470
+ transactions: [],
5471
+ nametags: []
5472
+ };
5473
+ const mintedToken = await SdkToken2.fromJSON(tokenJson);
5474
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5475
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5476
+ const transferProof = await waitInclusionProof5(trustBase, stClient, transferCommitment);
5477
+ const transferTransaction = transferCommitment.toTransaction(transferProof);
5478
+ const transferSalt = fromHex4(bundle.transferSaltHex);
5479
+ const recipientPredicate = await UnmaskedPredicate5.create(
5480
+ mintData.tokenId,
5481
+ tokenType,
5482
+ signingService,
5483
+ HashAlgorithm5.SHA256,
5484
+ transferSalt
5485
+ );
5486
+ const recipientState = new TokenState5(recipientPredicate, null);
5487
+ let nametagTokens = [];
5488
+ const recipientAddressStr = bundle.recipientAddressJson;
5489
+ if (recipientAddressStr.startsWith("PROXY://")) {
5490
+ if (bundle.nametagTokenJson) {
5491
+ try {
5492
+ const nametagToken = await SdkToken2.fromJSON(JSON.parse(bundle.nametagTokenJson));
5493
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5494
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5495
+ if (proxy.address === recipientAddressStr) {
5496
+ nametagTokens = [nametagToken];
5497
+ }
5498
+ } catch {
5499
+ }
5500
+ }
5501
+ if (nametagTokens.length === 0 && this.nametag?.token) {
5502
+ try {
5503
+ const nametagToken = await SdkToken2.fromJSON(this.nametag.token);
5504
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5505
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5506
+ if (proxy.address === recipientAddressStr) {
5507
+ nametagTokens = [nametagToken];
5508
+ }
5509
+ } catch {
5510
+ }
5511
+ }
5512
+ }
5513
+ return stClient.finalizeTransaction(trustBase, mintedToken, recipientState, transferTransaction, nametagTokens);
5514
+ }
5515
+ /**
5516
+ * Parse pending finalization metadata from token's sdkData.
5517
+ */
5518
+ parsePendingFinalization(sdkData) {
5519
+ if (!sdkData) return null;
5520
+ try {
5521
+ const data = JSON.parse(sdkData);
5522
+ if (data._pendingFinalization && data._pendingFinalization.type === "v5_bundle") {
5523
+ return data._pendingFinalization;
5524
+ }
5525
+ return null;
5526
+ } catch {
5527
+ return null;
5528
+ }
5529
+ }
5530
+ /**
5531
+ * Update pending finalization metadata in token's sdkData.
5532
+ * Creates a new token object since sdkData is readonly.
5533
+ */
5534
+ updatePendingFinalization(token, pending2) {
5535
+ const updated = {
5536
+ id: token.id,
5537
+ coinId: token.coinId,
5538
+ symbol: token.symbol,
5539
+ name: token.name,
5540
+ decimals: token.decimals,
5541
+ iconUrl: token.iconUrl,
5542
+ amount: token.amount,
5543
+ status: token.status,
5544
+ createdAt: token.createdAt,
5545
+ updatedAt: Date.now(),
5546
+ sdkData: JSON.stringify({ _pendingFinalization: pending2 })
5547
+ };
5548
+ this.tokens.set(token.id, updated);
5549
+ }
5550
+ /**
5551
+ * Save pending V5 tokens to key-value storage.
5552
+ * These tokens can't be serialized to TXF format (no genesis/state),
5553
+ * so we persist them separately and restore on load().
5554
+ */
5555
+ async savePendingV5Tokens() {
5556
+ const pendingTokens = [];
5557
+ for (const token of this.tokens.values()) {
5558
+ if (this.parsePendingFinalization(token.sdkData)) {
5559
+ pendingTokens.push(token);
5560
+ }
5561
+ }
5562
+ if (pendingTokens.length > 0) {
5563
+ await this.deps.storage.set(
5564
+ STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
5565
+ JSON.stringify(pendingTokens)
5566
+ );
5567
+ } else {
5568
+ await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
5569
+ }
5570
+ }
5571
+ /**
5572
+ * Load pending V5 tokens from key-value storage and merge into tokens map.
5573
+ * Called during load() to restore tokens that TXF format can't represent.
5574
+ */
5575
+ async loadPendingV5Tokens() {
5576
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
5577
+ if (!data) return;
5578
+ try {
5579
+ const pendingTokens = JSON.parse(data);
5580
+ for (const token of pendingTokens) {
5581
+ if (!this.tokens.has(token.id)) {
5582
+ this.tokens.set(token.id, token);
5583
+ }
5584
+ }
5585
+ if (pendingTokens.length > 0) {
5586
+ this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
5587
+ }
5588
+ } catch {
5589
+ }
5590
+ }
5591
+ // ===========================================================================
4966
5592
  // Public API - Token Operations
4967
5593
  // ===========================================================================
4968
5594
  /**
4969
- * Add a token
4970
- * Tokens are uniquely identified by (tokenId, stateHash) composite key.
4971
- * Multiple historic states of the same token can coexist.
4972
- * @returns false if exact duplicate (same tokenId AND same stateHash)
5595
+ * Add a token to the wallet.
5596
+ *
5597
+ * Tokens are uniquely identified by a `(tokenId, stateHash)` composite key.
5598
+ * Duplicate detection:
5599
+ * - **Tombstoned** — rejected if the exact `(tokenId, stateHash)` pair has a tombstone.
5600
+ * - **Exact duplicate** — rejected if a token with the same composite key already exists.
5601
+ * - **State replacement** — if the same `tokenId` exists with a *different* `stateHash`,
5602
+ * the old state is archived and replaced with the incoming one.
5603
+ *
5604
+ * @param token - The token to add.
5605
+ * @param skipHistory - When `true`, do not create a `RECEIVED` transaction history entry (default `false`).
5606
+ * @returns `true` if the token was added, `false` if rejected as duplicate or tombstoned.
4973
5607
  */
4974
5608
  async addToken(token, skipHistory = false) {
4975
5609
  this.ensureInitialized();
@@ -5027,7 +5661,9 @@ var PaymentsModule = class _PaymentsModule {
5027
5661
  });
5028
5662
  }
5029
5663
  await this.save();
5030
- await this.saveTokenToFileStorage(token);
5664
+ if (!this.parsePendingFinalization(token.sdkData)) {
5665
+ await this.saveTokenToFileStorage(token);
5666
+ }
5031
5667
  this.log(`Added token ${token.id}, total: ${this.tokens.size}`);
5032
5668
  return true;
5033
5669
  }
@@ -5084,6 +5720,9 @@ var PaymentsModule = class _PaymentsModule {
5084
5720
  const data = fileData;
5085
5721
  const tokenJson = data.token;
5086
5722
  if (!tokenJson) continue;
5723
+ if (typeof tokenJson === "object" && tokenJson !== null && "_pendingFinalization" in tokenJson) {
5724
+ continue;
5725
+ }
5087
5726
  let sdkTokenId;
5088
5727
  if (typeof tokenJson === "object" && tokenJson !== null) {
5089
5728
  const tokenObj = tokenJson;
@@ -5135,7 +5774,12 @@ var PaymentsModule = class _PaymentsModule {
5135
5774
  this.log(`Loaded ${this.tokens.size} tokens from file storage`);
5136
5775
  }
5137
5776
  /**
5138
- * Update an existing token
5777
+ * Update an existing token or add it if not found.
5778
+ *
5779
+ * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
5780
+ * `token.id`. If no match is found, falls back to {@link addToken}.
5781
+ *
5782
+ * @param token - The token with updated data. Must include a valid `id`.
5139
5783
  */
5140
5784
  async updateToken(token) {
5141
5785
  this.ensureInitialized();
@@ -5159,7 +5803,15 @@ var PaymentsModule = class _PaymentsModule {
5159
5803
  this.log(`Updated token ${token.id}`);
5160
5804
  }
5161
5805
  /**
5162
- * Remove a token by ID
5806
+ * Remove a token from the wallet.
5807
+ *
5808
+ * The token is archived first, then a tombstone `(tokenId, stateHash)` is
5809
+ * created to prevent re-addition via Nostr re-delivery. A `SENT` history
5810
+ * entry is created unless `skipHistory` is `true`.
5811
+ *
5812
+ * @param tokenId - Local UUID of the token to remove.
5813
+ * @param recipientNametag - Optional nametag of the transfer recipient (for history).
5814
+ * @param skipHistory - When `true`, skip creating a transaction history entry (default `false`).
5163
5815
  */
5164
5816
  async removeToken(tokenId, recipientNametag, skipHistory = false) {
5165
5817
  this.ensureInitialized();
@@ -5221,13 +5873,22 @@ var PaymentsModule = class _PaymentsModule {
5221
5873
  // Public API - Tombstones
5222
5874
  // ===========================================================================
5223
5875
  /**
5224
- * Get all tombstones
5876
+ * Get all tombstone entries.
5877
+ *
5878
+ * Each tombstone is keyed by `(tokenId, stateHash)` and prevents a spent
5879
+ * token state from being re-added (e.g. via Nostr re-delivery).
5880
+ *
5881
+ * @returns A shallow copy of the tombstone array.
5225
5882
  */
5226
5883
  getTombstones() {
5227
5884
  return [...this.tombstones];
5228
5885
  }
5229
5886
  /**
5230
- * Check if token state is tombstoned
5887
+ * Check whether a specific `(tokenId, stateHash)` combination is tombstoned.
5888
+ *
5889
+ * @param tokenId - The genesis token ID.
5890
+ * @param stateHash - The state hash of the token version to check.
5891
+ * @returns `true` if the exact combination has been tombstoned.
5231
5892
  */
5232
5893
  isStateTombstoned(tokenId, stateHash) {
5233
5894
  return this.tombstones.some(
@@ -5235,8 +5896,13 @@ var PaymentsModule = class _PaymentsModule {
5235
5896
  );
5236
5897
  }
5237
5898
  /**
5238
- * Merge remote tombstones
5239
- * @returns number of local tokens removed
5899
+ * Merge tombstones received from a remote sync source.
5900
+ *
5901
+ * Any local token whose `(tokenId, stateHash)` matches a remote tombstone is
5902
+ * removed. The remote tombstones are then added to the local set (union merge).
5903
+ *
5904
+ * @param remoteTombstones - Tombstone entries from the remote source.
5905
+ * @returns Number of local tokens that were removed.
5240
5906
  */
5241
5907
  async mergeTombstones(remoteTombstones) {
5242
5908
  this.ensureInitialized();
@@ -5272,7 +5938,9 @@ var PaymentsModule = class _PaymentsModule {
5272
5938
  return removedCount;
5273
5939
  }
5274
5940
  /**
5275
- * Prune old tombstones
5941
+ * Remove tombstones older than `maxAge` and cap the list at 100 entries.
5942
+ *
5943
+ * @param maxAge - Maximum age in milliseconds (default: 30 days).
5276
5944
  */
5277
5945
  async pruneTombstones(maxAge) {
5278
5946
  const originalCount = this.tombstones.length;
@@ -5286,20 +5954,38 @@ var PaymentsModule = class _PaymentsModule {
5286
5954
  // Public API - Archives
5287
5955
  // ===========================================================================
5288
5956
  /**
5289
- * Get archived tokens
5957
+ * Get all archived (spent/superseded) tokens in TXF format.
5958
+ *
5959
+ * Archived tokens are kept for recovery and sync purposes. The map key is
5960
+ * the genesis token ID.
5961
+ *
5962
+ * @returns A shallow copy of the archived token map.
5290
5963
  */
5291
5964
  getArchivedTokens() {
5292
5965
  return new Map(this.archivedTokens);
5293
5966
  }
5294
5967
  /**
5295
- * Get best archived version of a token
5968
+ * Get the best (most committed transactions) archived version of a token.
5969
+ *
5970
+ * Searches both archived and forked token maps and returns the version with
5971
+ * the highest number of committed transactions.
5972
+ *
5973
+ * @param tokenId - The genesis token ID to look up.
5974
+ * @returns The best TXF token version, or `null` if not found.
5296
5975
  */
5297
5976
  getBestArchivedVersion(tokenId) {
5298
5977
  return findBestTokenVersion(tokenId, this.archivedTokens, this.forkedTokens);
5299
5978
  }
5300
5979
  /**
5301
- * Merge remote archived tokens
5302
- * @returns number of tokens updated/added
5980
+ * Merge archived tokens from a remote sync source.
5981
+ *
5982
+ * For each remote token:
5983
+ * - If missing locally, it is added.
5984
+ * - If the remote version is an incremental update of the local, it replaces it.
5985
+ * - If the histories diverge (fork), the remote version is stored via {@link storeForkedToken}.
5986
+ *
5987
+ * @param remoteArchived - Map of genesis token ID → TXF token from remote.
5988
+ * @returns Number of tokens that were updated or added locally.
5303
5989
  */
5304
5990
  async mergeArchivedTokens(remoteArchived) {
5305
5991
  let mergedCount = 0;
@@ -5322,7 +6008,11 @@ var PaymentsModule = class _PaymentsModule {
5322
6008
  return mergedCount;
5323
6009
  }
5324
6010
  /**
5325
- * Prune archived tokens
6011
+ * Prune archived tokens to keep at most `maxCount` entries.
6012
+ *
6013
+ * Oldest entries (by insertion order) are removed first.
6014
+ *
6015
+ * @param maxCount - Maximum number of archived tokens to retain (default: 100).
5326
6016
  */
5327
6017
  async pruneArchivedTokens(maxCount = 100) {
5328
6018
  if (this.archivedTokens.size <= maxCount) return;
@@ -5335,13 +6025,24 @@ var PaymentsModule = class _PaymentsModule {
5335
6025
  // Public API - Forked Tokens
5336
6026
  // ===========================================================================
5337
6027
  /**
5338
- * Get forked tokens
6028
+ * Get all forked token versions.
6029
+ *
6030
+ * Forked tokens represent alternative histories detected during sync.
6031
+ * The map key is `{tokenId}_{stateHash}`.
6032
+ *
6033
+ * @returns A shallow copy of the forked tokens map.
5339
6034
  */
5340
6035
  getForkedTokens() {
5341
6036
  return new Map(this.forkedTokens);
5342
6037
  }
5343
6038
  /**
5344
- * Store a forked token
6039
+ * Store a forked token version (alternative history).
6040
+ *
6041
+ * No-op if the exact `(tokenId, stateHash)` key already exists.
6042
+ *
6043
+ * @param tokenId - Genesis token ID.
6044
+ * @param stateHash - State hash of this forked version.
6045
+ * @param txfToken - The TXF token data to store.
5345
6046
  */
5346
6047
  async storeForkedToken(tokenId, stateHash, txfToken) {
5347
6048
  const key = `${tokenId}_${stateHash}`;
@@ -5351,8 +6052,10 @@ var PaymentsModule = class _PaymentsModule {
5351
6052
  await this.save();
5352
6053
  }
5353
6054
  /**
5354
- * Merge remote forked tokens
5355
- * @returns number of tokens added
6055
+ * Merge forked tokens from a remote sync source. Only new keys are added.
6056
+ *
6057
+ * @param remoteForked - Map of `{tokenId}_{stateHash}` → TXF token from remote.
6058
+ * @returns Number of new forked tokens added.
5356
6059
  */
5357
6060
  async mergeForkedTokens(remoteForked) {
5358
6061
  let addedCount = 0;
@@ -5368,7 +6071,9 @@ var PaymentsModule = class _PaymentsModule {
5368
6071
  return addedCount;
5369
6072
  }
5370
6073
  /**
5371
- * Prune forked tokens
6074
+ * Prune forked tokens to keep at most `maxCount` entries.
6075
+ *
6076
+ * @param maxCount - Maximum number of forked tokens to retain (default: 50).
5372
6077
  */
5373
6078
  async pruneForkedTokens(maxCount = 50) {
5374
6079
  if (this.forkedTokens.size <= maxCount) return;
@@ -5381,13 +6086,19 @@ var PaymentsModule = class _PaymentsModule {
5381
6086
  // Public API - Transaction History
5382
6087
  // ===========================================================================
5383
6088
  /**
5384
- * Get transaction history
6089
+ * Get the transaction history sorted newest-first.
6090
+ *
6091
+ * @returns Array of {@link TransactionHistoryEntry} objects in descending timestamp order.
5385
6092
  */
5386
6093
  getHistory() {
5387
6094
  return [...this.transactionHistory].sort((a, b) => b.timestamp - a.timestamp);
5388
6095
  }
5389
6096
  /**
5390
- * Add to transaction history
6097
+ * Append an entry to the transaction history.
6098
+ *
6099
+ * A unique `id` is auto-generated. The entry is immediately persisted to storage.
6100
+ *
6101
+ * @param entry - History entry fields (without `id`).
5391
6102
  */
5392
6103
  async addToHistory(entry) {
5393
6104
  this.ensureInitialized();
@@ -5405,7 +6116,11 @@ var PaymentsModule = class _PaymentsModule {
5405
6116
  // Public API - Nametag
5406
6117
  // ===========================================================================
5407
6118
  /**
5408
- * Set nametag for current identity
6119
+ * Set the nametag data for the current identity.
6120
+ *
6121
+ * Persists to both key-value storage and file storage (lottery compatibility).
6122
+ *
6123
+ * @param nametag - The nametag data including minted token JSON.
5409
6124
  */
5410
6125
  async setNametag(nametag) {
5411
6126
  this.ensureInitialized();
@@ -5415,19 +6130,23 @@ var PaymentsModule = class _PaymentsModule {
5415
6130
  this.log(`Nametag set: ${nametag.name}`);
5416
6131
  }
5417
6132
  /**
5418
- * Get nametag
6133
+ * Get the current nametag data.
6134
+ *
6135
+ * @returns The nametag data, or `null` if no nametag is set.
5419
6136
  */
5420
6137
  getNametag() {
5421
6138
  return this.nametag;
5422
6139
  }
5423
6140
  /**
5424
- * Check if has nametag
6141
+ * Check whether a nametag is currently set.
6142
+ *
6143
+ * @returns `true` if nametag data is present.
5425
6144
  */
5426
6145
  hasNametag() {
5427
6146
  return this.nametag !== null;
5428
6147
  }
5429
6148
  /**
5430
- * Clear nametag
6149
+ * Remove the current nametag data from memory and storage.
5431
6150
  */
5432
6151
  async clearNametag() {
5433
6152
  this.ensureInitialized();
@@ -5521,9 +6240,9 @@ var PaymentsModule = class _PaymentsModule {
5521
6240
  try {
5522
6241
  const signingService = await this.createSigningService();
5523
6242
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5524
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6243
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
5525
6244
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5526
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6245
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5527
6246
  const addressRef = await UnmaskedPredicateReference4.create(
5528
6247
  tokenType,
5529
6248
  signingService.algorithm,
@@ -5584,11 +6303,27 @@ var PaymentsModule = class _PaymentsModule {
5584
6303
  // Public API - Sync & Validate
5585
6304
  // ===========================================================================
5586
6305
  /**
5587
- * Sync with all token storage providers (IPFS, MongoDB, etc.)
5588
- * Syncs with each provider and merges results
6306
+ * Sync local token state with all configured token storage providers (IPFS, file, etc.).
6307
+ *
6308
+ * For each provider, the local data is packaged into TXF storage format, sent
6309
+ * to the provider's `sync()` method, and the merged result is applied locally.
6310
+ * Emits `sync:started`, `sync:completed`, and `sync:error` events.
6311
+ *
6312
+ * @returns Summary with counts of tokens added and removed during sync.
5589
6313
  */
5590
6314
  async sync() {
5591
6315
  this.ensureInitialized();
6316
+ if (this._syncInProgress) {
6317
+ return this._syncInProgress;
6318
+ }
6319
+ this._syncInProgress = this._doSync();
6320
+ try {
6321
+ return await this._syncInProgress;
6322
+ } finally {
6323
+ this._syncInProgress = null;
6324
+ }
6325
+ }
6326
+ async _doSync() {
5592
6327
  this.deps.emitEvent("sync:started", { source: "payments" });
5593
6328
  try {
5594
6329
  const providers = this.getTokenStorageProviders();
@@ -5626,6 +6361,9 @@ var PaymentsModule = class _PaymentsModule {
5626
6361
  });
5627
6362
  }
5628
6363
  }
6364
+ if (totalAdded > 0 || totalRemoved > 0) {
6365
+ await this.save();
6366
+ }
5629
6367
  this.deps.emitEvent("sync:completed", {
5630
6368
  source: "payments",
5631
6369
  count: this.tokens.size
@@ -5639,6 +6377,66 @@ var PaymentsModule = class _PaymentsModule {
5639
6377
  throw error;
5640
6378
  }
5641
6379
  }
6380
+ // ===========================================================================
6381
+ // Storage Event Subscription (Push-Based Sync)
6382
+ // ===========================================================================
6383
+ /**
6384
+ * Subscribe to 'storage:remote-updated' events from all token storage providers.
6385
+ * When a provider emits this event, a debounced sync is triggered.
6386
+ */
6387
+ subscribeToStorageEvents() {
6388
+ this.unsubscribeStorageEvents();
6389
+ const providers = this.getTokenStorageProviders();
6390
+ for (const [providerId, provider] of providers) {
6391
+ if (provider.onEvent) {
6392
+ const unsub = provider.onEvent((event) => {
6393
+ if (event.type === "storage:remote-updated") {
6394
+ this.log("Remote update detected from provider", providerId, event.data);
6395
+ this.debouncedSyncFromRemoteUpdate(providerId, event.data);
6396
+ }
6397
+ });
6398
+ this.storageEventUnsubscribers.push(unsub);
6399
+ }
6400
+ }
6401
+ }
6402
+ /**
6403
+ * Unsubscribe from all storage provider events and clear debounce timer.
6404
+ */
6405
+ unsubscribeStorageEvents() {
6406
+ for (const unsub of this.storageEventUnsubscribers) {
6407
+ unsub();
6408
+ }
6409
+ this.storageEventUnsubscribers = [];
6410
+ if (this.syncDebounceTimer) {
6411
+ clearTimeout(this.syncDebounceTimer);
6412
+ this.syncDebounceTimer = null;
6413
+ }
6414
+ }
6415
+ /**
6416
+ * Debounced sync triggered by a storage:remote-updated event.
6417
+ * Waits 500ms to batch rapid updates, then performs sync.
6418
+ */
6419
+ debouncedSyncFromRemoteUpdate(providerId, eventData) {
6420
+ if (this.syncDebounceTimer) {
6421
+ clearTimeout(this.syncDebounceTimer);
6422
+ }
6423
+ this.syncDebounceTimer = setTimeout(() => {
6424
+ this.syncDebounceTimer = null;
6425
+ this.sync().then((result) => {
6426
+ const data = eventData;
6427
+ this.deps?.emitEvent("sync:remote-update", {
6428
+ providerId,
6429
+ name: data?.name ?? "",
6430
+ sequence: data?.sequence ?? 0,
6431
+ cid: data?.cid ?? "",
6432
+ added: result.added,
6433
+ removed: result.removed
6434
+ });
6435
+ }).catch((err) => {
6436
+ this.log("Auto-sync from remote update failed:", err);
6437
+ });
6438
+ }, _PaymentsModule.SYNC_DEBOUNCE_MS);
6439
+ }
5642
6440
  /**
5643
6441
  * Get all active token storage providers
5644
6442
  */
@@ -5654,15 +6452,24 @@ var PaymentsModule = class _PaymentsModule {
5654
6452
  return /* @__PURE__ */ new Map();
5655
6453
  }
5656
6454
  /**
5657
- * Update token storage providers (called when providers are added/removed dynamically)
6455
+ * Replace the set of token storage providers at runtime.
6456
+ *
6457
+ * Use when providers are added or removed dynamically (e.g. IPFS node started).
6458
+ *
6459
+ * @param providers - New map of provider ID → TokenStorageProvider.
5658
6460
  */
5659
6461
  updateTokenStorageProviders(providers) {
5660
6462
  if (this.deps) {
5661
6463
  this.deps.tokenStorageProviders = providers;
6464
+ this.subscribeToStorageEvents();
5662
6465
  }
5663
6466
  }
5664
6467
  /**
5665
- * Validate tokens with aggregator
6468
+ * Validate all tokens against the aggregator (oracle provider).
6469
+ *
6470
+ * Tokens that fail validation or are detected as spent are marked `'invalid'`.
6471
+ *
6472
+ * @returns Object with arrays of valid and invalid tokens.
5666
6473
  */
5667
6474
  async validate() {
5668
6475
  this.ensureInitialized();
@@ -5683,7 +6490,9 @@ var PaymentsModule = class _PaymentsModule {
5683
6490
  return { valid, invalid };
5684
6491
  }
5685
6492
  /**
5686
- * Get pending transfers
6493
+ * Get all in-progress (pending) outgoing transfers.
6494
+ *
6495
+ * @returns Array of {@link TransferResult} objects for transfers that have not yet completed.
5687
6496
  */
5688
6497
  getPendingTransfers() {
5689
6498
  return Array.from(this.pendingTransfers.values());
@@ -5747,9 +6556,9 @@ var PaymentsModule = class _PaymentsModule {
5747
6556
  */
5748
6557
  async createDirectAddressFromPubkey(pubkeyHex) {
5749
6558
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5750
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6559
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
5751
6560
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5752
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6561
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5753
6562
  const pubkeyBytes = new Uint8Array(
5754
6563
  pubkeyHex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
5755
6564
  );
@@ -5961,7 +6770,8 @@ var PaymentsModule = class _PaymentsModule {
5961
6770
  this.deps.emitEvent("transfer:confirmed", {
5962
6771
  id: crypto.randomUUID(),
5963
6772
  status: "completed",
5964
- tokens: [finalizedToken]
6773
+ tokens: [finalizedToken],
6774
+ tokenTransfers: []
5965
6775
  });
5966
6776
  await this.addToHistory({
5967
6777
  type: "RECEIVED",
@@ -5984,14 +6794,26 @@ var PaymentsModule = class _PaymentsModule {
5984
6794
  async handleIncomingTransfer(transfer) {
5985
6795
  try {
5986
6796
  const payload = transfer.payload;
6797
+ let instantBundle = null;
5987
6798
  if (isInstantSplitBundle(payload)) {
6799
+ instantBundle = payload;
6800
+ } else if (payload.token) {
6801
+ try {
6802
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
6803
+ if (isInstantSplitBundle(inner)) {
6804
+ instantBundle = inner;
6805
+ }
6806
+ } catch {
6807
+ }
6808
+ }
6809
+ if (instantBundle) {
5988
6810
  this.log("Processing INSTANT_SPLIT bundle...");
5989
6811
  try {
5990
6812
  if (!this.nametag) {
5991
6813
  await this.loadNametagFromFileStorage();
5992
6814
  }
5993
6815
  const result = await this.processInstantSplitBundle(
5994
- payload,
6816
+ instantBundle,
5995
6817
  transfer.senderTransportPubkey
5996
6818
  );
5997
6819
  if (result.success) {
@@ -6004,6 +6826,11 @@ var PaymentsModule = class _PaymentsModule {
6004
6826
  }
6005
6827
  return;
6006
6828
  }
6829
+ if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
6830
+ this.log("Processing NOSTR-FIRST commitment-only transfer...");
6831
+ await this.handleCommitmentOnlyTransfer(transfer, payload);
6832
+ return;
6833
+ }
6007
6834
  let tokenData;
6008
6835
  let finalizedSdkToken = null;
6009
6836
  if (payload.sourceToken && payload.transferTx) {
@@ -6159,6 +6986,7 @@ var PaymentsModule = class _PaymentsModule {
6159
6986
  console.error(`[Payments] Failed to save to provider ${id}:`, err);
6160
6987
  }
6161
6988
  }
6989
+ await this.savePendingV5Tokens();
6162
6990
  }
6163
6991
  async saveToOutbox(transfer, recipient) {
6164
6992
  const outbox = await this.loadOutbox();
@@ -6176,8 +7004,7 @@ var PaymentsModule = class _PaymentsModule {
6176
7004
  }
6177
7005
  async createStorageData() {
6178
7006
  return await buildTxfStorageData(
6179
- [],
6180
- // Empty - active tokens stored as token-xxx files
7007
+ Array.from(this.tokens.values()),
6181
7008
  {
6182
7009
  version: 1,
6183
7010
  address: this.deps.identity.l1Address,
@@ -6362,7 +7189,7 @@ function createPaymentsModule(config) {
6362
7189
  // modules/payments/TokenRecoveryService.ts
6363
7190
  import { TokenId as TokenId4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenId";
6364
7191
  import { TokenState as TokenState6 } from "@unicitylabs/state-transition-sdk/lib/token/TokenState";
6365
- import { TokenType as TokenType3 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
7192
+ import { TokenType as TokenType4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
6366
7193
  import { CoinId as CoinId5 } from "@unicitylabs/state-transition-sdk/lib/token/fungible/CoinId";
6367
7194
  import { HashAlgorithm as HashAlgorithm6 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
6368
7195
  import { UnmaskedPredicate as UnmaskedPredicate6 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
@@ -7511,15 +8338,20 @@ async function parseAndDecryptWalletDat(data, password, onProgress) {
7511
8338
 
7512
8339
  // core/Sphere.ts
7513
8340
  import { SigningService as SigningService2 } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService";
7514
- import { TokenType as TokenType4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
8341
+ import { TokenType as TokenType5 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
7515
8342
  import { HashAlgorithm as HashAlgorithm7 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
7516
8343
  import { UnmaskedPredicateReference as UnmaskedPredicateReference3 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference";
8344
+ import { normalizeNametag as normalizeNametag2, isPhoneNumber } from "@unicitylabs/nostr-js-sdk";
8345
+ function isValidNametag(nametag) {
8346
+ if (isPhoneNumber(nametag)) return true;
8347
+ return /^[a-z0-9_-]{3,20}$/.test(nametag);
8348
+ }
7517
8349
  var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
7518
8350
  async function deriveL3PredicateAddress(privateKey) {
7519
8351
  const secret = Buffer.from(privateKey, "hex");
7520
8352
  const signingService = await SigningService2.createFromSecret(secret);
7521
8353
  const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX2, "hex");
7522
- const tokenType = new TokenType4(tokenTypeBytes);
8354
+ const tokenType = new TokenType5(tokenTypeBytes);
7523
8355
  const predicateRef = UnmaskedPredicateReference3.create(
7524
8356
  tokenType,
7525
8357
  signingService.algorithm,
@@ -7795,6 +8627,14 @@ var Sphere = class _Sphere {
7795
8627
  console.log("[Sphere.import] Registering nametag...");
7796
8628
  await sphere.registerNametag(options.nametag);
7797
8629
  }
8630
+ if (sphere._tokenStorageProviders.size > 0) {
8631
+ try {
8632
+ const syncResult = await sphere._payments.sync();
8633
+ console.log(`[Sphere.import] Auto-sync: +${syncResult.added} -${syncResult.removed}`);
8634
+ } catch (err) {
8635
+ console.warn("[Sphere.import] Auto-sync failed (non-fatal):", err);
8636
+ }
8637
+ }
7798
8638
  console.log("[Sphere.import] Import complete");
7799
8639
  return sphere;
7800
8640
  }
@@ -8665,9 +9505,9 @@ var Sphere = class _Sphere {
8665
9505
  if (index < 0) {
8666
9506
  throw new Error("Address index must be non-negative");
8667
9507
  }
8668
- const newNametag = options?.nametag?.startsWith("@") ? options.nametag.slice(1) : options?.nametag;
8669
- if (newNametag && !this.validateNametag(newNametag)) {
8670
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
9508
+ const newNametag = options?.nametag ? this.cleanNametag(options.nametag) : void 0;
9509
+ if (newNametag && !isValidNametag(newNametag)) {
9510
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
8671
9511
  }
8672
9512
  const addressInfo = this.deriveAddress(index, false);
8673
9513
  const ipnsHash = sha256(addressInfo.publicKey, "hex").slice(0, 40);
@@ -9051,9 +9891,9 @@ var Sphere = class _Sphere {
9051
9891
  */
9052
9892
  async registerNametag(nametag) {
9053
9893
  this.ensureReady();
9054
- const cleanNametag = nametag.startsWith("@") ? nametag.slice(1) : nametag;
9055
- if (!this.validateNametag(cleanNametag)) {
9056
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
9894
+ const cleanNametag = this.cleanNametag(nametag);
9895
+ if (!isValidNametag(cleanNametag)) {
9896
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
9057
9897
  }
9058
9898
  if (this._identity?.nametag) {
9059
9899
  throw new Error(`Nametag already registered for address ${this._currentAddressIndex}: @${this._identity.nametag}`);
@@ -9362,13 +10202,11 @@ var Sphere = class _Sphere {
9362
10202
  }
9363
10203
  }
9364
10204
  /**
9365
- * Validate nametag format
10205
+ * Strip @ prefix and normalize a nametag (lowercase, phone E.164, strip @unicity suffix).
9366
10206
  */
9367
- validateNametag(nametag) {
9368
- const pattern = new RegExp(
9369
- `^[a-zA-Z0-9_-]{${LIMITS.NAMETAG_MIN_LENGTH},${LIMITS.NAMETAG_MAX_LENGTH}}$`
9370
- );
9371
- return pattern.test(nametag);
10207
+ cleanNametag(raw) {
10208
+ const stripped = raw.startsWith("@") ? raw.slice(1) : raw;
10209
+ return normalizeNametag2(stripped);
9372
10210
  }
9373
10211
  // ===========================================================================
9374
10212
  // Public Methods - Lifecycle
@@ -9728,6 +10566,7 @@ export {
9728
10566
  initSphere,
9729
10567
  isEncryptedData,
9730
10568
  isValidBech32,
10569
+ isValidNametag,
9731
10570
  isValidPrivateKey,
9732
10571
  loadSphere,
9733
10572
  mnemonicToEntropy2 as mnemonicToEntropy,