@unicitylabs/sphere-sdk 0.2.2 → 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,
@@ -1553,7 +1554,7 @@ var L1PaymentsModule = class {
1553
1554
  _transport;
1554
1555
  constructor(config) {
1555
1556
  this._config = {
1556
- electrumUrl: config?.electrumUrl ?? "wss://fulcrum.alpha.unicity.network:50004",
1557
+ electrumUrl: config?.electrumUrl ?? "wss://fulcrum.unicity.network:50004",
1557
1558
  network: config?.network ?? "mainnet",
1558
1559
  defaultFeeRate: config?.defaultFeeRate ?? 10,
1559
1560
  enableVesting: config?.enableVesting ?? true
@@ -1585,10 +1586,17 @@ var L1PaymentsModule = class {
1585
1586
  });
1586
1587
  }
1587
1588
  }
1588
- if (this._config.electrumUrl) {
1589
+ this._initialized = true;
1590
+ }
1591
+ /**
1592
+ * Ensure the Fulcrum WebSocket is connected. Called lazily before any
1593
+ * operation that needs the network. If the singleton is already connected
1594
+ * (e.g. by the address scanner), this is a no-op.
1595
+ */
1596
+ async ensureConnected() {
1597
+ if (!isWebSocketConnected() && this._config.electrumUrl) {
1589
1598
  await connect(this._config.electrumUrl);
1590
1599
  }
1591
- this._initialized = true;
1592
1600
  }
1593
1601
  destroy() {
1594
1602
  if (isWebSocketConnected()) {
@@ -1646,6 +1654,7 @@ var L1PaymentsModule = class {
1646
1654
  }
1647
1655
  async send(request) {
1648
1656
  this.ensureInitialized();
1657
+ await this.ensureConnected();
1649
1658
  if (!this._wallet || !this._identity) {
1650
1659
  return { success: false, error: "No wallet available" };
1651
1660
  }
@@ -1680,6 +1689,7 @@ var L1PaymentsModule = class {
1680
1689
  }
1681
1690
  async getBalance() {
1682
1691
  this.ensureInitialized();
1692
+ await this.ensureConnected();
1683
1693
  const addresses = this._getWatchedAddresses();
1684
1694
  let totalAlpha = 0;
1685
1695
  let vestedSats = BigInt(0);
@@ -1711,6 +1721,7 @@ var L1PaymentsModule = class {
1711
1721
  }
1712
1722
  async getUtxos() {
1713
1723
  this.ensureInitialized();
1724
+ await this.ensureConnected();
1714
1725
  const result = [];
1715
1726
  const currentHeight = await getCurrentBlockHeight();
1716
1727
  const allUtxos = await this._getAllUtxos();
@@ -1746,42 +1757,73 @@ var L1PaymentsModule = class {
1746
1757
  return result;
1747
1758
  }
1748
1759
  async getHistory(limit) {
1760
+ await this.ensureConnected();
1749
1761
  this.ensureInitialized();
1750
1762
  const addresses = this._getWatchedAddresses();
1751
1763
  const transactions = [];
1752
1764
  const seenTxids = /* @__PURE__ */ new Set();
1753
1765
  const currentHeight = await getCurrentBlockHeight();
1766
+ const txCache = /* @__PURE__ */ new Map();
1767
+ const fetchTx = async (txid) => {
1768
+ if (txCache.has(txid)) return txCache.get(txid);
1769
+ const detail = await getTransaction(txid);
1770
+ txCache.set(txid, detail);
1771
+ return detail;
1772
+ };
1773
+ const addressSet = new Set(addresses.map((a) => a.toLowerCase()));
1754
1774
  for (const address of addresses) {
1755
1775
  const history = await getTransactionHistory(address);
1756
1776
  for (const item of history) {
1757
1777
  if (seenTxids.has(item.tx_hash)) continue;
1758
1778
  seenTxids.add(item.tx_hash);
1759
- const tx = await getTransaction(item.tx_hash);
1779
+ const tx = await fetchTx(item.tx_hash);
1760
1780
  if (!tx) continue;
1761
- const isSend = tx.vin?.some(
1762
- (vin) => addresses.includes(vin.txid ?? "")
1763
- );
1764
- let amount = "0";
1781
+ let isSend = false;
1782
+ for (const vin of tx.vin ?? []) {
1783
+ if (!vin.txid) continue;
1784
+ const prevTx = await fetchTx(vin.txid);
1785
+ if (prevTx?.vout?.[vin.vout]) {
1786
+ const prevOut = prevTx.vout[vin.vout];
1787
+ const prevAddrs = [
1788
+ ...prevOut.scriptPubKey?.addresses ?? [],
1789
+ ...prevOut.scriptPubKey?.address ? [prevOut.scriptPubKey.address] : []
1790
+ ];
1791
+ if (prevAddrs.some((a) => addressSet.has(a.toLowerCase()))) {
1792
+ isSend = true;
1793
+ break;
1794
+ }
1795
+ }
1796
+ }
1797
+ let amountToUs = 0;
1798
+ let amountToOthers = 0;
1765
1799
  let txAddress = address;
1800
+ let externalAddress = "";
1766
1801
  if (tx.vout) {
1767
1802
  for (const vout of tx.vout) {
1768
- const voutAddresses = vout.scriptPubKey?.addresses ?? [];
1769
- if (vout.scriptPubKey?.address) {
1770
- voutAddresses.push(vout.scriptPubKey.address);
1771
- }
1772
- const matchedAddr = voutAddresses.find((a) => addresses.includes(a));
1773
- if (matchedAddr) {
1774
- amount = Math.floor((vout.value ?? 0) * 1e8).toString();
1775
- txAddress = matchedAddr;
1776
- break;
1803
+ const voutAddresses = [
1804
+ ...vout.scriptPubKey?.addresses ?? [],
1805
+ ...vout.scriptPubKey?.address ? [vout.scriptPubKey.address] : []
1806
+ ];
1807
+ const isOurs = voutAddresses.some((a) => addressSet.has(a.toLowerCase()));
1808
+ const valueSats = Math.floor((vout.value ?? 0) * 1e8);
1809
+ if (isOurs) {
1810
+ amountToUs += valueSats;
1811
+ if (!txAddress) txAddress = voutAddresses[0];
1812
+ } else {
1813
+ amountToOthers += valueSats;
1814
+ if (!externalAddress && voutAddresses.length > 0) {
1815
+ externalAddress = voutAddresses[0];
1816
+ }
1777
1817
  }
1778
1818
  }
1779
1819
  }
1820
+ const amount = isSend ? amountToOthers.toString() : amountToUs.toString();
1821
+ const displayAddress = isSend ? externalAddress || txAddress : txAddress;
1780
1822
  transactions.push({
1781
1823
  txid: item.tx_hash,
1782
1824
  type: isSend ? "send" : "receive",
1783
1825
  amount,
1784
- address: txAddress,
1826
+ address: displayAddress,
1785
1827
  confirmations: item.height > 0 ? currentHeight - item.height : 0,
1786
1828
  timestamp: tx.time ? tx.time * 1e3 : Date.now(),
1787
1829
  blockHeight: item.height > 0 ? item.height : void 0
@@ -1793,6 +1835,7 @@ var L1PaymentsModule = class {
1793
1835
  }
1794
1836
  async getTransaction(txid) {
1795
1837
  this.ensureInitialized();
1838
+ await this.ensureConnected();
1796
1839
  const tx = await getTransaction(txid);
1797
1840
  if (!tx) return null;
1798
1841
  const addresses = this._getWatchedAddresses();
@@ -1828,6 +1871,7 @@ var L1PaymentsModule = class {
1828
1871
  }
1829
1872
  async estimateFee(to, amount) {
1830
1873
  this.ensureInitialized();
1874
+ await this.ensureConnected();
1831
1875
  if (!this._wallet) {
1832
1876
  return { fee: "0", feeRate: this._config.defaultFeeRate ?? 10 };
1833
1877
  }
@@ -2167,6 +2211,7 @@ var import_MintCommitment = require("@unicitylabs/state-transition-sdk/lib/trans
2167
2211
  var import_HashAlgorithm2 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
2168
2212
  var import_UnmaskedPredicate2 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate");
2169
2213
  var import_InclusionProofUtils2 = require("@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils");
2214
+ var import_nostr_js_sdk = require("@unicitylabs/nostr-js-sdk");
2170
2215
  var UNICITY_TOKEN_TYPE_HEX = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
2171
2216
  var NametagMinter = class {
2172
2217
  client;
@@ -2191,7 +2236,8 @@ var NametagMinter = class {
2191
2236
  */
2192
2237
  async isNametagAvailable(nametag) {
2193
2238
  try {
2194
- const cleanNametag = nametag.replace("@", "").trim();
2239
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2240
+ const cleanNametag = (0, import_nostr_js_sdk.normalizeNametag)(stripped);
2195
2241
  const nametagTokenId = await import_TokenId2.TokenId.fromNameTag(cleanNametag);
2196
2242
  const isMinted = await this.client.isMinted(this.trustBase, nametagTokenId);
2197
2243
  return !isMinted;
@@ -2208,7 +2254,8 @@ var NametagMinter = class {
2208
2254
  * @returns MintNametagResult with token if successful
2209
2255
  */
2210
2256
  async mintNametag(nametag, ownerAddress) {
2211
- const cleanNametag = nametag.replace("@", "").trim();
2257
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2258
+ const cleanNametag = (0, import_nostr_js_sdk.normalizeNametag)(stripped);
2212
2259
  this.log(`Starting mint for nametag: ${cleanNametag}`);
2213
2260
  try {
2214
2261
  const nametagTokenId = await import_TokenId2.TokenId.fromNameTag(cleanNametag);
@@ -2347,7 +2394,9 @@ var STORAGE_KEYS_GLOBAL = {
2347
2394
  /** Nametag cache per address (separate from tracked addresses registry) */
2348
2395
  ADDRESS_NAMETAGS: "address_nametags",
2349
2396
  /** Active addresses registry (JSON: TrackedAddressesStorage) */
2350
- TRACKED_ADDRESSES: "tracked_addresses"
2397
+ TRACKED_ADDRESSES: "tracked_addresses",
2398
+ /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
2399
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
2351
2400
  };
2352
2401
  var STORAGE_KEYS_ADDRESS = {
2353
2402
  /** Pending transfers for this address */
@@ -2359,7 +2408,9 @@ var STORAGE_KEYS_ADDRESS = {
2359
2408
  /** Messages for this address */
2360
2409
  MESSAGES: "messages",
2361
2410
  /** Transaction history for this address */
2362
- 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"
2363
2414
  };
2364
2415
  var STORAGE_KEYS = {
2365
2416
  ...STORAGE_KEYS_GLOBAL,
@@ -2378,16 +2429,6 @@ function getAddressId(directAddress) {
2378
2429
  }
2379
2430
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
2380
2431
  var DEFAULT_DERIVATION_PATH2 = `${DEFAULT_BASE_PATH}/0/0`;
2381
- var LIMITS = {
2382
- /** Min nametag length */
2383
- NAMETAG_MIN_LENGTH: 3,
2384
- /** Max nametag length */
2385
- NAMETAG_MAX_LENGTH: 20,
2386
- /** Max memo length */
2387
- MEMO_MAX_LENGTH: 500,
2388
- /** Max message length */
2389
- MESSAGE_MAX_LENGTH: 1e4
2390
- };
2391
2432
 
2392
2433
  // types/txf.ts
2393
2434
  var ARCHIVED_PREFIX = "archived-";
@@ -2680,6 +2721,18 @@ function parseTxfStorageData(data) {
2680
2721
  result.validationErrors.push(`Forked token ${parsed.tokenId}: invalid structure`);
2681
2722
  }
2682
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
+ }
2683
2736
  }
2684
2737
  }
2685
2738
  return result;
@@ -3180,8 +3233,9 @@ var InstantSplitExecutor = class {
3180
3233
  const criticalPathDuration = performance.now() - startTime;
3181
3234
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3182
3235
  options?.onNostrDelivered?.(nostrEventId);
3236
+ let backgroundPromise;
3183
3237
  if (!options?.skipBackground) {
3184
- this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3238
+ backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3185
3239
  signingService: this.signingService,
3186
3240
  tokenType: tokenToSplit.type,
3187
3241
  coinId,
@@ -3197,7 +3251,8 @@ var InstantSplitExecutor = class {
3197
3251
  nostrEventId,
3198
3252
  splitGroupId,
3199
3253
  criticalPathDurationMs: criticalPathDuration,
3200
- backgroundStarted: !options?.skipBackground
3254
+ backgroundStarted: !options?.skipBackground,
3255
+ backgroundPromise
3201
3256
  };
3202
3257
  } catch (error) {
3203
3258
  const duration = performance.now() - startTime;
@@ -3259,7 +3314,7 @@ var InstantSplitExecutor = class {
3259
3314
  this.client.submitMintCommitment(recipientMintCommitment).then((res) => ({ type: "recipientMint", status: res.status })).catch((err) => ({ type: "recipientMint", status: "ERROR", error: err })),
3260
3315
  this.client.submitTransferCommitment(transferCommitment).then((res) => ({ type: "transfer", status: res.status })).catch((err) => ({ type: "transfer", status: "ERROR", error: err }))
3261
3316
  ]);
3262
- submissions.then(async (results) => {
3317
+ return submissions.then(async (results) => {
3263
3318
  const submitDuration = performance.now() - startTime;
3264
3319
  console.log(`[InstantSplit] Background: Submissions complete in ${submitDuration.toFixed(0)}ms`);
3265
3320
  context.onProgress?.({
@@ -3724,6 +3779,11 @@ var import_AddressScheme = require("@unicitylabs/state-transition-sdk/lib/addres
3724
3779
  var import_UnmaskedPredicate5 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate");
3725
3780
  var import_TokenState5 = require("@unicitylabs/state-transition-sdk/lib/token/TokenState");
3726
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");
3727
3787
  function enrichWithRegistry(info) {
3728
3788
  const registry = TokenRegistry.getInstance();
3729
3789
  const def = registry.getDefinition(info.coinId);
@@ -3751,7 +3811,7 @@ async function parseTokenInfo(tokenData) {
3751
3811
  try {
3752
3812
  const sdkToken = await import_Token6.Token.fromJSON(data);
3753
3813
  if (sdkToken.id) {
3754
- defaultInfo.tokenId = sdkToken.id.toString();
3814
+ defaultInfo.tokenId = sdkToken.id.toJSON();
3755
3815
  }
3756
3816
  if (sdkToken.coins && sdkToken.coins.coins) {
3757
3817
  const rawCoins = sdkToken.coins.coins;
@@ -3921,6 +3981,13 @@ function extractTokenStateKey(token) {
3921
3981
  if (!tokenId || !stateHash) return null;
3922
3982
  return createTokenStateKey(tokenId, stateHash);
3923
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
+ }
3924
3991
  function hasSameGenesisTokenId(t1, t2) {
3925
3992
  const id1 = extractTokenIdFromSdkData(t1.sdkData);
3926
3993
  const id2 = extractTokenIdFromSdkData(t2.sdkData);
@@ -4010,6 +4077,7 @@ var PaymentsModule = class _PaymentsModule {
4010
4077
  // Token State
4011
4078
  tokens = /* @__PURE__ */ new Map();
4012
4079
  pendingTransfers = /* @__PURE__ */ new Map();
4080
+ pendingBackgroundTasks = [];
4013
4081
  // Repository State (tombstones, archives, forked, history)
4014
4082
  tombstones = [];
4015
4083
  archivedTokens = /* @__PURE__ */ new Map();
@@ -4034,6 +4102,12 @@ var PaymentsModule = class _PaymentsModule {
4034
4102
  // Poll every 2s
4035
4103
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
4036
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;
4037
4111
  constructor(config) {
4038
4112
  this.moduleConfig = {
4039
4113
  autoSync: config?.autoSync ?? true,
@@ -4042,10 +4116,13 @@ var PaymentsModule = class _PaymentsModule {
4042
4116
  maxRetries: config?.maxRetries ?? 3,
4043
4117
  debug: config?.debug ?? false
4044
4118
  };
4045
- const l1Enabled = config?.l1?.electrumUrl && config.l1.electrumUrl.length > 0;
4046
- this.l1 = l1Enabled ? new L1PaymentsModule(config?.l1) : null;
4119
+ this.l1 = config?.l1 === null ? null : new L1PaymentsModule(config?.l1);
4047
4120
  }
4048
- /** Get module configuration */
4121
+ /**
4122
+ * Get the current module configuration (excluding L1 config).
4123
+ *
4124
+ * @returns Resolved configuration with all defaults applied.
4125
+ */
4049
4126
  getConfig() {
4050
4127
  return this.moduleConfig;
4051
4128
  }
@@ -4086,9 +4163,9 @@ var PaymentsModule = class _PaymentsModule {
4086
4163
  transport: deps.transport
4087
4164
  });
4088
4165
  }
4089
- this.unsubscribeTransfers = deps.transport.onTokenTransfer((transfer) => {
4090
- this.handleIncomingTransfer(transfer);
4091
- });
4166
+ this.unsubscribeTransfers = deps.transport.onTokenTransfer(
4167
+ (transfer) => this.handleIncomingTransfer(transfer)
4168
+ );
4092
4169
  if (deps.transport.onPaymentRequest) {
4093
4170
  this.unsubscribePaymentRequests = deps.transport.onPaymentRequest((request) => {
4094
4171
  this.handleIncomingPaymentRequest(request);
@@ -4099,9 +4176,14 @@ var PaymentsModule = class _PaymentsModule {
4099
4176
  this.handlePaymentRequestResponse(response);
4100
4177
  });
4101
4178
  }
4179
+ this.subscribeToStorageEvents();
4102
4180
  }
4103
4181
  /**
4104
- * 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.
4105
4187
  */
4106
4188
  async load() {
4107
4189
  this.ensureInitialized();
@@ -4118,6 +4200,7 @@ var PaymentsModule = class _PaymentsModule {
4118
4200
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4119
4201
  }
4120
4202
  }
4203
+ await this.loadPendingV5Tokens();
4121
4204
  await this.loadTokensFromFileStorage();
4122
4205
  await this.loadNametagFromFileStorage();
4123
4206
  const historyData = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.TRANSACTION_HISTORY);
@@ -4135,9 +4218,14 @@ var PaymentsModule = class _PaymentsModule {
4135
4218
  this.pendingTransfers.set(transfer.id, transfer);
4136
4219
  }
4137
4220
  }
4221
+ this.resolveUnconfirmed().catch(() => {
4222
+ });
4138
4223
  }
4139
4224
  /**
4140
- * 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.
4141
4229
  */
4142
4230
  destroy() {
4143
4231
  this.unsubscribeTransfers?.();
@@ -4155,6 +4243,7 @@ var PaymentsModule = class _PaymentsModule {
4155
4243
  resolver.reject(new Error("Module destroyed"));
4156
4244
  }
4157
4245
  this.pendingResponseResolvers.clear();
4246
+ this.unsubscribeStorageEvents();
4158
4247
  if (this.l1) {
4159
4248
  this.l1.destroy();
4160
4249
  }
@@ -4171,7 +4260,8 @@ var PaymentsModule = class _PaymentsModule {
4171
4260
  const result = {
4172
4261
  id: crypto.randomUUID(),
4173
4262
  status: "pending",
4174
- tokens: []
4263
+ tokens: [],
4264
+ tokenTransfers: []
4175
4265
  };
4176
4266
  try {
4177
4267
  const peerInfo = await this.deps.transport.resolve?.(request.recipient) ?? null;
@@ -4208,69 +4298,147 @@ var PaymentsModule = class _PaymentsModule {
4208
4298
  await this.saveToOutbox(result, recipientPubkey);
4209
4299
  result.status = "submitted";
4210
4300
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4301
+ const transferMode = request.transferMode ?? "instant";
4211
4302
  if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4212
- this.log("Executing token split...");
4213
- const executor = new TokenSplitExecutor({
4214
- stateTransitionClient: stClient,
4215
- trustBase,
4216
- signingService
4217
- });
4218
- const splitResult = await executor.executeSplit(
4219
- splitPlan.tokenToSplit.sdkToken,
4220
- splitPlan.splitAmount,
4221
- splitPlan.remainderAmount,
4222
- splitPlan.coinId,
4223
- recipientAddress
4224
- );
4225
- const changeTokenData = splitResult.tokenForSender.toJSON();
4226
- const changeToken = {
4227
- id: crypto.randomUUID(),
4228
- coinId: request.coinId,
4229
- symbol: this.getCoinSymbol(request.coinId),
4230
- name: this.getCoinName(request.coinId),
4231
- decimals: this.getCoinDecimals(request.coinId),
4232
- iconUrl: this.getCoinIconUrl(request.coinId),
4233
- amount: splitPlan.remainderAmount.toString(),
4234
- status: "confirmed",
4235
- createdAt: Date.now(),
4236
- updatedAt: Date.now(),
4237
- sdkData: JSON.stringify(changeTokenData)
4238
- };
4239
- await this.addToken(changeToken, true);
4240
- this.log(`Change token saved: ${changeToken.id}, amount: ${changeToken.amount}`);
4241
- console.log(`[Payments] Sending split token to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4242
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4243
- sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4244
- transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4245
- memo: request.memo
4246
- });
4247
- console.log(`[Payments] Split token sent successfully`);
4248
- await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4249
- result.txHash = "split-" + Date.now().toString(16);
4250
- 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
+ }
4251
4404
  }
4252
4405
  for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4253
4406
  const token = tokenWithAmount.uiToken;
4254
4407
  const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4255
- const response = await stClient.submitTransferCommitment(commitment);
4256
- if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") {
4257
- throw new Error(`Transfer commitment failed: ${response.status}`);
4258
- }
4259
- if (!this.deps.oracle.waitForProofSdk) {
4260
- 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
+ );
4261
4433
  }
4262
- const inclusionProof = await this.deps.oracle.waitForProofSdk(commitment);
4263
- const transferTx = commitment.toTransaction(inclusionProof);
4264
4434
  const requestIdBytes = commitment.requestId;
4265
- result.txHash = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4266
- console.log(`[Payments] Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4267
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4268
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4269
- transferTx: JSON.stringify(transferTx.toJSON()),
4270
- 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
4271
4440
  });
4272
- console.log(`[Payments] Direct token sent successfully`);
4273
- this.log(`Token ${token.id} transferred, txHash: ${result.txHash}`);
4441
+ this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4274
4442
  await this.removeToken(token.id, recipientNametag, true);
4275
4443
  }
4276
4444
  result.status = "delivered";
@@ -4283,7 +4451,8 @@ var PaymentsModule = class _PaymentsModule {
4283
4451
  coinId: request.coinId,
4284
4452
  symbol: this.getCoinSymbol(request.coinId),
4285
4453
  timestamp: Date.now(),
4286
- recipientNametag
4454
+ recipientNametag,
4455
+ transferId: result.id
4287
4456
  });
4288
4457
  this.deps.emitEvent("transfer:confirmed", result);
4289
4458
  return result;
@@ -4419,6 +4588,9 @@ var PaymentsModule = class _PaymentsModule {
4419
4588
  }
4420
4589
  );
4421
4590
  if (result.success) {
4591
+ if (result.backgroundPromise) {
4592
+ this.pendingBackgroundTasks.push(result.backgroundPromise);
4593
+ }
4422
4594
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4423
4595
  await this.removeToken(tokenToSplit.id, recipientNametag, true);
4424
4596
  await this.addToHistory({
@@ -4460,6 +4632,63 @@ var PaymentsModule = class _PaymentsModule {
4460
4632
  */
4461
4633
  async processInstantSplitBundle(bundle, senderPubkey) {
4462
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) {
4463
4692
  try {
4464
4693
  const signingService = await this.createSigningService();
4465
4694
  const stClient = this.deps.oracle.getStateTransitionClient?.();
@@ -4545,7 +4774,10 @@ var PaymentsModule = class _PaymentsModule {
4545
4774
  }
4546
4775
  }
4547
4776
  /**
4548
- * 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.
4549
4781
  */
4550
4782
  isInstantSplitBundle(payload) {
4551
4783
  return isInstantSplitBundle(payload);
@@ -4626,39 +4858,57 @@ var PaymentsModule = class _PaymentsModule {
4626
4858
  return [...this.paymentRequests];
4627
4859
  }
4628
4860
  /**
4629
- * Get pending payment requests count
4861
+ * Get the count of payment requests with status `'pending'`.
4862
+ *
4863
+ * @returns Number of pending incoming payment requests.
4630
4864
  */
4631
4865
  getPendingPaymentRequestsCount() {
4632
4866
  return this.paymentRequests.filter((r) => r.status === "pending").length;
4633
4867
  }
4634
4868
  /**
4635
- * 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.
4636
4875
  */
4637
4876
  async acceptPaymentRequest(requestId2) {
4638
4877
  this.updatePaymentRequestStatus(requestId2, "accepted");
4639
4878
  await this.sendPaymentRequestResponse(requestId2, "accepted");
4640
4879
  }
4641
4880
  /**
4642
- * 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.
4643
4884
  */
4644
4885
  async rejectPaymentRequest(requestId2) {
4645
4886
  this.updatePaymentRequestStatus(requestId2, "rejected");
4646
4887
  await this.sendPaymentRequestResponse(requestId2, "rejected");
4647
4888
  }
4648
4889
  /**
4649
- * 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.
4650
4896
  */
4651
4897
  markPaymentRequestPaid(requestId2) {
4652
4898
  this.updatePaymentRequestStatus(requestId2, "paid");
4653
4899
  }
4654
4900
  /**
4655
- * Clear processed (non-pending) payment requests
4901
+ * Remove all non-pending incoming payment requests from memory.
4902
+ *
4903
+ * Keeps only requests with status `'pending'`.
4656
4904
  */
4657
4905
  clearProcessedPaymentRequests() {
4658
4906
  this.paymentRequests = this.paymentRequests.filter((r) => r.status === "pending");
4659
4907
  }
4660
4908
  /**
4661
- * 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.
4662
4912
  */
4663
4913
  removePaymentRequest(requestId2) {
4664
4914
  this.paymentRequests = this.paymentRequests.filter((r) => r.id !== requestId2);
@@ -4705,13 +4955,16 @@ var PaymentsModule = class _PaymentsModule {
4705
4955
  if (this.paymentRequests.find((r) => r.id === transportRequest.id)) {
4706
4956
  return;
4707
4957
  }
4958
+ const coinId = transportRequest.request.coinId;
4959
+ const registry = TokenRegistry.getInstance();
4960
+ const coinDef = registry.getDefinition(coinId);
4708
4961
  const request = {
4709
4962
  id: transportRequest.id,
4710
4963
  senderPubkey: transportRequest.senderTransportPubkey,
4964
+ senderNametag: transportRequest.senderNametag,
4711
4965
  amount: transportRequest.request.amount,
4712
- coinId: transportRequest.request.coinId,
4713
- symbol: transportRequest.request.coinId,
4714
- // Use coinId as symbol for now
4966
+ coinId,
4967
+ symbol: coinDef?.symbol || coinId.slice(0, 8),
4715
4968
  message: transportRequest.request.message,
4716
4969
  recipientNametag: transportRequest.request.recipientNametag,
4717
4970
  requestId: transportRequest.request.requestId,
@@ -4780,7 +5033,11 @@ var PaymentsModule = class _PaymentsModule {
4780
5033
  });
4781
5034
  }
4782
5035
  /**
4783
- * 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.
4784
5041
  */
4785
5042
  cancelWaitForPaymentResponse(requestId2) {
4786
5043
  const resolver = this.pendingResponseResolvers.get(requestId2);
@@ -4791,14 +5048,16 @@ var PaymentsModule = class _PaymentsModule {
4791
5048
  }
4792
5049
  }
4793
5050
  /**
4794
- * 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.
4795
5054
  */
4796
5055
  removeOutgoingPaymentRequest(requestId2) {
4797
5056
  this.outgoingPaymentRequests.delete(requestId2);
4798
5057
  this.cancelWaitForPaymentResponse(requestId2);
4799
5058
  }
4800
5059
  /**
4801
- * Clear completed/expired outgoing payment requests
5060
+ * Remove all outgoing payment requests that are `'paid'`, `'rejected'`, or `'expired'`.
4802
5061
  */
4803
5062
  clearCompletedOutgoingPaymentRequests() {
4804
5063
  for (const [id, request] of this.outgoingPaymentRequests) {
@@ -4870,6 +5129,71 @@ var PaymentsModule = class _PaymentsModule {
4870
5129
  }
4871
5130
  }
4872
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
+ // ===========================================================================
4873
5197
  // Public API - Balance & Tokens
4874
5198
  // ===========================================================================
4875
5199
  /**
@@ -4879,10 +5203,20 @@ var PaymentsModule = class _PaymentsModule {
4879
5203
  this.priceProvider = provider;
4880
5204
  }
4881
5205
  /**
4882
- * Get total portfolio value in USD
4883
- * 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.
4884
5208
  */
4885
- 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() {
4886
5220
  const assets = await this.getAssets();
4887
5221
  if (!this.priceProvider) {
4888
5222
  return null;
@@ -4898,19 +5232,95 @@ var PaymentsModule = class _PaymentsModule {
4898
5232
  return hasAnyPrice ? total : null;
4899
5233
  }
4900
5234
  /**
4901
- * Get aggregated assets (tokens grouped by coinId) with price data
4902
- * 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.
4903
5253
  */
4904
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) {
4905
5308
  const assetsMap = /* @__PURE__ */ new Map();
4906
5309
  for (const token of this.tokens.values()) {
4907
- if (token.status !== "confirmed") continue;
5310
+ if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
4908
5311
  if (coinId && token.coinId !== coinId) continue;
4909
5312
  const key = token.coinId;
5313
+ const amount = BigInt(token.amount);
5314
+ const isConfirmed = token.status === "confirmed";
4910
5315
  const existing = assetsMap.get(key);
4911
5316
  if (existing) {
4912
- existing.totalAmount = (BigInt(existing.totalAmount) + BigInt(token.amount)).toString();
4913
- existing.tokenCount++;
5317
+ if (isConfirmed) {
5318
+ existing.confirmedAmount += amount;
5319
+ existing.confirmedTokenCount++;
5320
+ } else {
5321
+ existing.unconfirmedAmount += amount;
5322
+ existing.unconfirmedTokenCount++;
5323
+ }
4914
5324
  } else {
4915
5325
  assetsMap.set(key, {
4916
5326
  coinId: token.coinId,
@@ -4918,78 +5328,42 @@ var PaymentsModule = class _PaymentsModule {
4918
5328
  name: token.name,
4919
5329
  decimals: token.decimals,
4920
5330
  iconUrl: token.iconUrl,
4921
- totalAmount: token.amount,
4922
- tokenCount: 1
5331
+ confirmedAmount: isConfirmed ? amount : 0n,
5332
+ unconfirmedAmount: isConfirmed ? 0n : amount,
5333
+ confirmedTokenCount: isConfirmed ? 1 : 0,
5334
+ unconfirmedTokenCount: isConfirmed ? 0 : 1
4923
5335
  });
4924
5336
  }
4925
5337
  }
4926
- const rawAssets = Array.from(assetsMap.values());
4927
- let priceMap = null;
4928
- if (this.priceProvider && rawAssets.length > 0) {
4929
- try {
4930
- const registry = TokenRegistry.getInstance();
4931
- const nameToCoins = /* @__PURE__ */ new Map();
4932
- for (const asset of rawAssets) {
4933
- const def = registry.getDefinition(asset.coinId);
4934
- if (def?.name) {
4935
- const existing = nameToCoins.get(def.name);
4936
- if (existing) {
4937
- existing.push(asset.coinId);
4938
- } else {
4939
- nameToCoins.set(def.name, [asset.coinId]);
4940
- }
4941
- }
4942
- }
4943
- if (nameToCoins.size > 0) {
4944
- const tokenNames = Array.from(nameToCoins.keys());
4945
- const prices = await this.priceProvider.getPrices(tokenNames);
4946
- priceMap = /* @__PURE__ */ new Map();
4947
- for (const [name, coinIds] of nameToCoins) {
4948
- const price = prices.get(name);
4949
- if (price) {
4950
- for (const cid of coinIds) {
4951
- priceMap.set(cid, {
4952
- priceUsd: price.priceUsd,
4953
- priceEur: price.priceEur,
4954
- change24h: price.change24h
4955
- });
4956
- }
4957
- }
4958
- }
4959
- }
4960
- } catch (error) {
4961
- console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
4962
- }
4963
- }
4964
- return rawAssets.map((raw) => {
4965
- const price = priceMap?.get(raw.coinId);
4966
- let fiatValueUsd = null;
4967
- let fiatValueEur = null;
4968
- if (price) {
4969
- const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
4970
- fiatValueUsd = humanAmount * price.priceUsd;
4971
- if (price.priceEur != null) {
4972
- fiatValueEur = humanAmount * price.priceEur;
4973
- }
4974
- }
5338
+ return Array.from(assetsMap.values()).map((raw) => {
5339
+ const totalAmount = (raw.confirmedAmount + raw.unconfirmedAmount).toString();
4975
5340
  return {
4976
5341
  coinId: raw.coinId,
4977
5342
  symbol: raw.symbol,
4978
5343
  name: raw.name,
4979
5344
  decimals: raw.decimals,
4980
5345
  iconUrl: raw.iconUrl,
4981
- totalAmount: raw.totalAmount,
4982
- tokenCount: raw.tokenCount,
4983
- priceUsd: price?.priceUsd ?? null,
4984
- priceEur: price?.priceEur ?? null,
4985
- change24h: price?.change24h ?? null,
4986
- fiatValueUsd,
4987
- 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
4988
5357
  };
4989
5358
  });
4990
5359
  }
4991
5360
  /**
4992
- * 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).
4993
5367
  */
4994
5368
  getTokens(filter) {
4995
5369
  let tokens = Array.from(this.tokens.values());
@@ -5002,19 +5376,327 @@ var PaymentsModule = class _PaymentsModule {
5002
5376
  return tokens;
5003
5377
  }
5004
5378
  /**
5005
- * 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.
5006
5383
  */
5007
5384
  getToken(id) {
5008
5385
  return this.tokens.get(id);
5009
5386
  }
5010
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
+ // ===========================================================================
5011
5685
  // Public API - Token Operations
5012
5686
  // ===========================================================================
5013
5687
  /**
5014
- * Add a token
5015
- * Tokens are uniquely identified by (tokenId, stateHash) composite key.
5016
- * Multiple historic states of the same token can coexist.
5017
- * @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.
5018
5700
  */
5019
5701
  async addToken(token, skipHistory = false) {
5020
5702
  this.ensureInitialized();
@@ -5072,7 +5754,9 @@ var PaymentsModule = class _PaymentsModule {
5072
5754
  });
5073
5755
  }
5074
5756
  await this.save();
5075
- await this.saveTokenToFileStorage(token);
5757
+ if (!this.parsePendingFinalization(token.sdkData)) {
5758
+ await this.saveTokenToFileStorage(token);
5759
+ }
5076
5760
  this.log(`Added token ${token.id}, total: ${this.tokens.size}`);
5077
5761
  return true;
5078
5762
  }
@@ -5129,6 +5813,9 @@ var PaymentsModule = class _PaymentsModule {
5129
5813
  const data = fileData;
5130
5814
  const tokenJson = data.token;
5131
5815
  if (!tokenJson) continue;
5816
+ if (typeof tokenJson === "object" && tokenJson !== null && "_pendingFinalization" in tokenJson) {
5817
+ continue;
5818
+ }
5132
5819
  let sdkTokenId;
5133
5820
  if (typeof tokenJson === "object" && tokenJson !== null) {
5134
5821
  const tokenObj = tokenJson;
@@ -5180,7 +5867,12 @@ var PaymentsModule = class _PaymentsModule {
5180
5867
  this.log(`Loaded ${this.tokens.size} tokens from file storage`);
5181
5868
  }
5182
5869
  /**
5183
- * 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`.
5184
5876
  */
5185
5877
  async updateToken(token) {
5186
5878
  this.ensureInitialized();
@@ -5204,7 +5896,15 @@ var PaymentsModule = class _PaymentsModule {
5204
5896
  this.log(`Updated token ${token.id}`);
5205
5897
  }
5206
5898
  /**
5207
- * 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`).
5208
5908
  */
5209
5909
  async removeToken(tokenId, recipientNametag, skipHistory = false) {
5210
5910
  this.ensureInitialized();
@@ -5266,13 +5966,22 @@ var PaymentsModule = class _PaymentsModule {
5266
5966
  // Public API - Tombstones
5267
5967
  // ===========================================================================
5268
5968
  /**
5269
- * 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.
5270
5975
  */
5271
5976
  getTombstones() {
5272
5977
  return [...this.tombstones];
5273
5978
  }
5274
5979
  /**
5275
- * 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.
5276
5985
  */
5277
5986
  isStateTombstoned(tokenId, stateHash) {
5278
5987
  return this.tombstones.some(
@@ -5280,8 +5989,13 @@ var PaymentsModule = class _PaymentsModule {
5280
5989
  );
5281
5990
  }
5282
5991
  /**
5283
- * Merge remote tombstones
5284
- * @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.
5285
5999
  */
5286
6000
  async mergeTombstones(remoteTombstones) {
5287
6001
  this.ensureInitialized();
@@ -5317,7 +6031,9 @@ var PaymentsModule = class _PaymentsModule {
5317
6031
  return removedCount;
5318
6032
  }
5319
6033
  /**
5320
- * 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).
5321
6037
  */
5322
6038
  async pruneTombstones(maxAge) {
5323
6039
  const originalCount = this.tombstones.length;
@@ -5331,20 +6047,38 @@ var PaymentsModule = class _PaymentsModule {
5331
6047
  // Public API - Archives
5332
6048
  // ===========================================================================
5333
6049
  /**
5334
- * 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.
5335
6056
  */
5336
6057
  getArchivedTokens() {
5337
6058
  return new Map(this.archivedTokens);
5338
6059
  }
5339
6060
  /**
5340
- * 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.
5341
6068
  */
5342
6069
  getBestArchivedVersion(tokenId) {
5343
6070
  return findBestTokenVersion(tokenId, this.archivedTokens, this.forkedTokens);
5344
6071
  }
5345
6072
  /**
5346
- * Merge remote archived tokens
5347
- * @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.
5348
6082
  */
5349
6083
  async mergeArchivedTokens(remoteArchived) {
5350
6084
  let mergedCount = 0;
@@ -5367,7 +6101,11 @@ var PaymentsModule = class _PaymentsModule {
5367
6101
  return mergedCount;
5368
6102
  }
5369
6103
  /**
5370
- * 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).
5371
6109
  */
5372
6110
  async pruneArchivedTokens(maxCount = 100) {
5373
6111
  if (this.archivedTokens.size <= maxCount) return;
@@ -5380,13 +6118,24 @@ var PaymentsModule = class _PaymentsModule {
5380
6118
  // Public API - Forked Tokens
5381
6119
  // ===========================================================================
5382
6120
  /**
5383
- * 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.
5384
6127
  */
5385
6128
  getForkedTokens() {
5386
6129
  return new Map(this.forkedTokens);
5387
6130
  }
5388
6131
  /**
5389
- * 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.
5390
6139
  */
5391
6140
  async storeForkedToken(tokenId, stateHash, txfToken) {
5392
6141
  const key = `${tokenId}_${stateHash}`;
@@ -5396,8 +6145,10 @@ var PaymentsModule = class _PaymentsModule {
5396
6145
  await this.save();
5397
6146
  }
5398
6147
  /**
5399
- * Merge remote forked tokens
5400
- * @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.
5401
6152
  */
5402
6153
  async mergeForkedTokens(remoteForked) {
5403
6154
  let addedCount = 0;
@@ -5413,7 +6164,9 @@ var PaymentsModule = class _PaymentsModule {
5413
6164
  return addedCount;
5414
6165
  }
5415
6166
  /**
5416
- * 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).
5417
6170
  */
5418
6171
  async pruneForkedTokens(maxCount = 50) {
5419
6172
  if (this.forkedTokens.size <= maxCount) return;
@@ -5426,13 +6179,19 @@ var PaymentsModule = class _PaymentsModule {
5426
6179
  // Public API - Transaction History
5427
6180
  // ===========================================================================
5428
6181
  /**
5429
- * Get transaction history
6182
+ * Get the transaction history sorted newest-first.
6183
+ *
6184
+ * @returns Array of {@link TransactionHistoryEntry} objects in descending timestamp order.
5430
6185
  */
5431
6186
  getHistory() {
5432
6187
  return [...this.transactionHistory].sort((a, b) => b.timestamp - a.timestamp);
5433
6188
  }
5434
6189
  /**
5435
- * 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`).
5436
6195
  */
5437
6196
  async addToHistory(entry) {
5438
6197
  this.ensureInitialized();
@@ -5450,7 +6209,11 @@ var PaymentsModule = class _PaymentsModule {
5450
6209
  // Public API - Nametag
5451
6210
  // ===========================================================================
5452
6211
  /**
5453
- * 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.
5454
6217
  */
5455
6218
  async setNametag(nametag) {
5456
6219
  this.ensureInitialized();
@@ -5460,19 +6223,23 @@ var PaymentsModule = class _PaymentsModule {
5460
6223
  this.log(`Nametag set: ${nametag.name}`);
5461
6224
  }
5462
6225
  /**
5463
- * Get nametag
6226
+ * Get the current nametag data.
6227
+ *
6228
+ * @returns The nametag data, or `null` if no nametag is set.
5464
6229
  */
5465
6230
  getNametag() {
5466
6231
  return this.nametag;
5467
6232
  }
5468
6233
  /**
5469
- * Check if has nametag
6234
+ * Check whether a nametag is currently set.
6235
+ *
6236
+ * @returns `true` if nametag data is present.
5470
6237
  */
5471
6238
  hasNametag() {
5472
6239
  return this.nametag !== null;
5473
6240
  }
5474
6241
  /**
5475
- * Clear nametag
6242
+ * Remove the current nametag data from memory and storage.
5476
6243
  */
5477
6244
  async clearNametag() {
5478
6245
  this.ensureInitialized();
@@ -5566,9 +6333,9 @@ var PaymentsModule = class _PaymentsModule {
5566
6333
  try {
5567
6334
  const signingService = await this.createSigningService();
5568
6335
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5569
- 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");
5570
6337
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5571
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6338
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5572
6339
  const addressRef = await UnmaskedPredicateReference4.create(
5573
6340
  tokenType,
5574
6341
  signingService.algorithm,
@@ -5629,11 +6396,27 @@ var PaymentsModule = class _PaymentsModule {
5629
6396
  // Public API - Sync & Validate
5630
6397
  // ===========================================================================
5631
6398
  /**
5632
- * Sync with all token storage providers (IPFS, MongoDB, etc.)
5633
- * 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.
5634
6406
  */
5635
6407
  async sync() {
5636
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() {
5637
6420
  this.deps.emitEvent("sync:started", { source: "payments" });
5638
6421
  try {
5639
6422
  const providers = this.getTokenStorageProviders();
@@ -5671,6 +6454,9 @@ var PaymentsModule = class _PaymentsModule {
5671
6454
  });
5672
6455
  }
5673
6456
  }
6457
+ if (totalAdded > 0 || totalRemoved > 0) {
6458
+ await this.save();
6459
+ }
5674
6460
  this.deps.emitEvent("sync:completed", {
5675
6461
  source: "payments",
5676
6462
  count: this.tokens.size
@@ -5684,6 +6470,66 @@ var PaymentsModule = class _PaymentsModule {
5684
6470
  throw error;
5685
6471
  }
5686
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
+ }
5687
6533
  /**
5688
6534
  * Get all active token storage providers
5689
6535
  */
@@ -5699,15 +6545,24 @@ var PaymentsModule = class _PaymentsModule {
5699
6545
  return /* @__PURE__ */ new Map();
5700
6546
  }
5701
6547
  /**
5702
- * 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.
5703
6553
  */
5704
6554
  updateTokenStorageProviders(providers) {
5705
6555
  if (this.deps) {
5706
6556
  this.deps.tokenStorageProviders = providers;
6557
+ this.subscribeToStorageEvents();
5707
6558
  }
5708
6559
  }
5709
6560
  /**
5710
- * 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.
5711
6566
  */
5712
6567
  async validate() {
5713
6568
  this.ensureInitialized();
@@ -5728,7 +6583,9 @@ var PaymentsModule = class _PaymentsModule {
5728
6583
  return { valid, invalid };
5729
6584
  }
5730
6585
  /**
5731
- * 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.
5732
6589
  */
5733
6590
  getPendingTransfers() {
5734
6591
  return Array.from(this.pendingTransfers.values());
@@ -5792,9 +6649,9 @@ var PaymentsModule = class _PaymentsModule {
5792
6649
  */
5793
6650
  async createDirectAddressFromPubkey(pubkeyHex) {
5794
6651
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5795
- 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");
5796
6653
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5797
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6654
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5798
6655
  const pubkeyBytes = new Uint8Array(
5799
6656
  pubkeyHex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
5800
6657
  );
@@ -6006,7 +6863,8 @@ var PaymentsModule = class _PaymentsModule {
6006
6863
  this.deps.emitEvent("transfer:confirmed", {
6007
6864
  id: crypto.randomUUID(),
6008
6865
  status: "completed",
6009
- tokens: [finalizedToken]
6866
+ tokens: [finalizedToken],
6867
+ tokenTransfers: []
6010
6868
  });
6011
6869
  await this.addToHistory({
6012
6870
  type: "RECEIVED",
@@ -6029,14 +6887,26 @@ var PaymentsModule = class _PaymentsModule {
6029
6887
  async handleIncomingTransfer(transfer) {
6030
6888
  try {
6031
6889
  const payload = transfer.payload;
6890
+ let instantBundle = null;
6032
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) {
6033
6903
  this.log("Processing INSTANT_SPLIT bundle...");
6034
6904
  try {
6035
6905
  if (!this.nametag) {
6036
6906
  await this.loadNametagFromFileStorage();
6037
6907
  }
6038
6908
  const result = await this.processInstantSplitBundle(
6039
- payload,
6909
+ instantBundle,
6040
6910
  transfer.senderTransportPubkey
6041
6911
  );
6042
6912
  if (result.success) {
@@ -6049,6 +6919,11 @@ var PaymentsModule = class _PaymentsModule {
6049
6919
  }
6050
6920
  return;
6051
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
+ }
6052
6927
  let tokenData;
6053
6928
  let finalizedSdkToken = null;
6054
6929
  if (payload.sourceToken && payload.transferTx) {
@@ -6204,6 +7079,7 @@ var PaymentsModule = class _PaymentsModule {
6204
7079
  console.error(`[Payments] Failed to save to provider ${id}:`, err);
6205
7080
  }
6206
7081
  }
7082
+ await this.savePendingV5Tokens();
6207
7083
  }
6208
7084
  async saveToOutbox(transfer, recipient) {
6209
7085
  const outbox = await this.loadOutbox();
@@ -6221,8 +7097,7 @@ var PaymentsModule = class _PaymentsModule {
6221
7097
  }
6222
7098
  async createStorageData() {
6223
7099
  return await buildTxfStorageData(
6224
- [],
6225
- // Empty - active tokens stored as token-xxx files
7100
+ Array.from(this.tokens.values()),
6226
7101
  {
6227
7102
  version: 1,
6228
7103
  address: this.deps.identity.l1Address,
@@ -6407,7 +7282,7 @@ function createPaymentsModule(config) {
6407
7282
  // modules/payments/TokenRecoveryService.ts
6408
7283
  var import_TokenId4 = require("@unicitylabs/state-transition-sdk/lib/token/TokenId");
6409
7284
  var import_TokenState6 = require("@unicitylabs/state-transition-sdk/lib/token/TokenState");
6410
- var import_TokenType3 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
7285
+ var import_TokenType4 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6411
7286
  var import_CoinId5 = require("@unicitylabs/state-transition-sdk/lib/token/fungible/CoinId");
6412
7287
  var import_HashAlgorithm6 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
6413
7288
  var import_UnmaskedPredicate6 = require("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate");
@@ -7556,15 +8431,20 @@ async function parseAndDecryptWalletDat(data, password, onProgress) {
7556
8431
 
7557
8432
  // core/Sphere.ts
7558
8433
  var import_SigningService2 = require("@unicitylabs/state-transition-sdk/lib/sign/SigningService");
7559
- var import_TokenType4 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
8434
+ var import_TokenType5 = require("@unicitylabs/state-transition-sdk/lib/token/TokenType");
7560
8435
  var import_HashAlgorithm7 = require("@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm");
7561
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
+ }
7562
8442
  var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
7563
8443
  async function deriveL3PredicateAddress(privateKey) {
7564
8444
  const secret = Buffer.from(privateKey, "hex");
7565
8445
  const signingService = await import_SigningService2.SigningService.createFromSecret(secret);
7566
8446
  const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX2, "hex");
7567
- const tokenType = new import_TokenType4.TokenType(tokenTypeBytes);
8447
+ const tokenType = new import_TokenType5.TokenType(tokenTypeBytes);
7568
8448
  const predicateRef = import_UnmaskedPredicateReference3.UnmaskedPredicateReference.create(
7569
8449
  tokenType,
7570
8450
  signingService.algorithm,
@@ -7730,8 +8610,8 @@ var Sphere = class _Sphere {
7730
8610
  if (options.nametag) {
7731
8611
  await sphere.registerNametag(options.nametag);
7732
8612
  } else {
7733
- await sphere.syncIdentityWithTransport();
7734
8613
  await sphere.recoverNametagFromTransport();
8614
+ await sphere.syncIdentityWithTransport();
7735
8615
  }
7736
8616
  return sphere;
7737
8617
  }
@@ -7778,9 +8658,14 @@ var Sphere = class _Sphere {
7778
8658
  if (!options.mnemonic && !options.masterKey) {
7779
8659
  throw new Error("Either mnemonic or masterKey is required");
7780
8660
  }
8661
+ console.log("[Sphere.import] Starting import...");
8662
+ console.log("[Sphere.import] Clearing existing wallet data...");
7781
8663
  await _Sphere.clear({ storage: options.storage, tokenStorage: options.tokenStorage });
8664
+ console.log("[Sphere.import] Clear done");
7782
8665
  if (!options.storage.isConnected()) {
8666
+ console.log("[Sphere.import] Reconnecting storage...");
7783
8667
  await options.storage.connect();
8668
+ console.log("[Sphere.import] Storage reconnected");
7784
8669
  }
7785
8670
  const sphere = new _Sphere(
7786
8671
  options.storage,
@@ -7794,9 +8679,12 @@ var Sphere = class _Sphere {
7794
8679
  if (!_Sphere.validateMnemonic(options.mnemonic)) {
7795
8680
  throw new Error("Invalid mnemonic");
7796
8681
  }
8682
+ console.log("[Sphere.import] Storing mnemonic...");
7797
8683
  await sphere.storeMnemonic(options.mnemonic, options.derivationPath, options.basePath);
8684
+ console.log("[Sphere.import] Initializing identity from mnemonic...");
7798
8685
  await sphere.initializeIdentityFromMnemonic(options.mnemonic, options.derivationPath);
7799
8686
  } else if (options.masterKey) {
8687
+ console.log("[Sphere.import] Storing master key...");
7800
8688
  await sphere.storeMasterKey(
7801
8689
  options.masterKey,
7802
8690
  options.chainCode,
@@ -7804,24 +8692,43 @@ var Sphere = class _Sphere {
7804
8692
  options.basePath,
7805
8693
  options.derivationMode
7806
8694
  );
8695
+ console.log("[Sphere.import] Initializing identity from master key...");
7807
8696
  await sphere.initializeIdentityFromMasterKey(
7808
8697
  options.masterKey,
7809
8698
  options.chainCode,
7810
8699
  options.derivationPath
7811
8700
  );
7812
8701
  }
8702
+ console.log("[Sphere.import] Initializing providers...");
7813
8703
  await sphere.initializeProviders();
8704
+ console.log("[Sphere.import] Providers initialized. Initializing modules...");
7814
8705
  await sphere.initializeModules();
8706
+ console.log("[Sphere.import] Modules initialized");
7815
8707
  if (!options.nametag) {
8708
+ console.log("[Sphere.import] Recovering nametag from transport...");
7816
8709
  await sphere.recoverNametagFromTransport();
8710
+ console.log("[Sphere.import] Nametag recovery done");
8711
+ await sphere.syncIdentityWithTransport();
7817
8712
  }
8713
+ console.log("[Sphere.import] Finalizing wallet creation...");
7818
8714
  await sphere.finalizeWalletCreation();
7819
8715
  sphere._initialized = true;
7820
8716
  _Sphere.instance = sphere;
8717
+ console.log("[Sphere.import] Tracking address 0...");
7821
8718
  await sphere.ensureAddressTracked(0);
7822
8719
  if (options.nametag) {
8720
+ console.log("[Sphere.import] Registering nametag...");
7823
8721
  await sphere.registerNametag(options.nametag);
7824
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
+ }
8731
+ console.log("[Sphere.import] Import complete");
7825
8732
  return sphere;
7826
8733
  }
7827
8734
  /**
@@ -7846,6 +8753,10 @@ var Sphere = class _Sphere {
7846
8753
  static async clear(storageOrOptions) {
7847
8754
  const storage = "get" in storageOrOptions ? storageOrOptions : storageOrOptions.storage;
7848
8755
  const tokenStorage = "get" in storageOrOptions ? void 0 : storageOrOptions.tokenStorage;
8756
+ if (!storage.isConnected()) {
8757
+ await storage.connect();
8758
+ }
8759
+ console.log("[Sphere.clear] Removing storage keys...");
7849
8760
  await storage.remove(STORAGE_KEYS_GLOBAL.MNEMONIC);
7850
8761
  await storage.remove(STORAGE_KEYS_GLOBAL.MASTER_KEY);
7851
8762
  await storage.remove(STORAGE_KEYS_GLOBAL.CHAIN_CODE);
@@ -7858,12 +8769,30 @@ var Sphere = class _Sphere {
7858
8769
  await storage.remove(STORAGE_KEYS_GLOBAL.ADDRESS_NAMETAGS);
7859
8770
  await storage.remove(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
7860
8771
  await storage.remove(STORAGE_KEYS_ADDRESS.OUTBOX);
8772
+ console.log("[Sphere.clear] Storage keys removed");
7861
8773
  if (tokenStorage?.clear) {
7862
- await tokenStorage.clear();
8774
+ console.log("[Sphere.clear] Clearing token storage...");
8775
+ try {
8776
+ await Promise.race([
8777
+ tokenStorage.clear(),
8778
+ new Promise(
8779
+ (_, reject) => setTimeout(() => reject(new Error("tokenStorage.clear() timed out after 2s")), 2e3)
8780
+ )
8781
+ ]);
8782
+ console.log("[Sphere.clear] Token storage cleared");
8783
+ } catch (err) {
8784
+ console.warn("[Sphere.clear] Token storage clear failed/timed out:", err);
8785
+ }
7863
8786
  }
8787
+ console.log("[Sphere.clear] Destroying vesting classifier...");
7864
8788
  await vestingClassifier.destroy();
8789
+ console.log("[Sphere.clear] Vesting classifier destroyed");
7865
8790
  if (_Sphere.instance) {
8791
+ console.log("[Sphere.clear] Destroying Sphere instance...");
7866
8792
  await _Sphere.instance.destroy();
8793
+ console.log("[Sphere.clear] Sphere instance destroyed");
8794
+ } else {
8795
+ console.log("[Sphere.clear] No Sphere instance to destroy");
7867
8796
  }
7868
8797
  }
7869
8798
  /**
@@ -8244,7 +9173,8 @@ var Sphere = class _Sphere {
8244
9173
  storage: options.storage,
8245
9174
  transport: options.transport,
8246
9175
  oracle: options.oracle,
8247
- tokenStorage: options.tokenStorage
9176
+ tokenStorage: options.tokenStorage,
9177
+ l1: options.l1
8248
9178
  });
8249
9179
  return { success: true, mnemonic };
8250
9180
  }
@@ -8257,7 +9187,8 @@ var Sphere = class _Sphere {
8257
9187
  storage: options.storage,
8258
9188
  transport: options.transport,
8259
9189
  oracle: options.oracle,
8260
- tokenStorage: options.tokenStorage
9190
+ tokenStorage: options.tokenStorage,
9191
+ l1: options.l1
8261
9192
  });
8262
9193
  return { success: true };
8263
9194
  }
@@ -8316,7 +9247,8 @@ var Sphere = class _Sphere {
8316
9247
  transport: options.transport,
8317
9248
  oracle: options.oracle,
8318
9249
  tokenStorage: options.tokenStorage,
8319
- nametag: options.nametag
9250
+ nametag: options.nametag,
9251
+ l1: options.l1
8320
9252
  });
8321
9253
  return { success: true, sphere, mnemonic };
8322
9254
  }
@@ -8345,7 +9277,8 @@ var Sphere = class _Sphere {
8345
9277
  transport: options.transport,
8346
9278
  oracle: options.oracle,
8347
9279
  tokenStorage: options.tokenStorage,
8348
- nametag: options.nametag
9280
+ nametag: options.nametag,
9281
+ l1: options.l1
8349
9282
  });
8350
9283
  return { success: true, sphere };
8351
9284
  }
@@ -8376,7 +9309,8 @@ var Sphere = class _Sphere {
8376
9309
  transport: options.transport,
8377
9310
  oracle: options.oracle,
8378
9311
  tokenStorage: options.tokenStorage,
8379
- nametag: options.nametag
9312
+ nametag: options.nametag,
9313
+ l1: options.l1
8380
9314
  });
8381
9315
  return { success: true, sphere };
8382
9316
  }
@@ -8395,7 +9329,8 @@ var Sphere = class _Sphere {
8395
9329
  storage: options.storage,
8396
9330
  transport: options.transport,
8397
9331
  oracle: options.oracle,
8398
- tokenStorage: options.tokenStorage
9332
+ tokenStorage: options.tokenStorage,
9333
+ l1: options.l1
8399
9334
  });
8400
9335
  if (result.success) {
8401
9336
  const sphere2 = _Sphere.getInstance();
@@ -8444,7 +9379,8 @@ var Sphere = class _Sphere {
8444
9379
  transport: options.transport,
8445
9380
  oracle: options.oracle,
8446
9381
  tokenStorage: options.tokenStorage,
8447
- nametag: options.nametag
9382
+ nametag: options.nametag,
9383
+ l1: options.l1
8448
9384
  });
8449
9385
  return { success: true, sphere: sphere2, mnemonic };
8450
9386
  }
@@ -8457,7 +9393,8 @@ var Sphere = class _Sphere {
8457
9393
  transport: options.transport,
8458
9394
  oracle: options.oracle,
8459
9395
  tokenStorage: options.tokenStorage,
8460
- nametag: options.nametag
9396
+ nametag: options.nametag,
9397
+ l1: options.l1
8461
9398
  });
8462
9399
  return { success: true, sphere };
8463
9400
  }
@@ -8661,9 +9598,9 @@ var Sphere = class _Sphere {
8661
9598
  if (index < 0) {
8662
9599
  throw new Error("Address index must be non-negative");
8663
9600
  }
8664
- const newNametag = options?.nametag?.startsWith("@") ? options.nametag.slice(1) : options?.nametag;
8665
- if (newNametag && !this.validateNametag(newNametag)) {
8666
- 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.");
8667
9604
  }
8668
9605
  const addressInfo = this.deriveAddress(index, false);
8669
9606
  const ipnsHash = sha256(addressInfo.publicKey, "hex").slice(0, 40);
@@ -9047,9 +9984,9 @@ var Sphere = class _Sphere {
9047
9984
  */
9048
9985
  async registerNametag(nametag) {
9049
9986
  this.ensureReady();
9050
- const cleanNametag = nametag.startsWith("@") ? nametag.slice(1) : nametag;
9051
- if (!this.validateNametag(cleanNametag)) {
9052
- 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.");
9053
9990
  }
9054
9991
  if (this._identity?.nametag) {
9055
9992
  throw new Error(`Nametag already registered for address ${this._currentAddressIndex}: @${this._identity.nametag}`);
@@ -9320,46 +10257,49 @@ var Sphere = class _Sphere {
9320
10257
  if (this._identity?.nametag) {
9321
10258
  return;
9322
10259
  }
9323
- if (!this._transport.recoverNametag) {
10260
+ let recoveredNametag = null;
10261
+ if (this._transport.recoverNametag) {
10262
+ try {
10263
+ recoveredNametag = await this._transport.recoverNametag();
10264
+ } catch {
10265
+ }
10266
+ }
10267
+ if (!recoveredNametag && this._transport.resolveAddressInfo && this._identity?.l1Address) {
10268
+ try {
10269
+ const info = await this._transport.resolveAddressInfo(this._identity.l1Address);
10270
+ if (info?.nametag) {
10271
+ recoveredNametag = info.nametag;
10272
+ }
10273
+ } catch {
10274
+ }
10275
+ }
10276
+ if (!recoveredNametag) {
9324
10277
  return;
9325
10278
  }
9326
10279
  try {
9327
- const recoveredNametag = await this._transport.recoverNametag();
9328
- if (recoveredNametag) {
9329
- if (this._identity) {
9330
- this._identity.nametag = recoveredNametag;
9331
- await this._updateCachedProxyAddress();
9332
- }
9333
- const entry = await this.ensureAddressTracked(this._currentAddressIndex);
9334
- let nametags = this._addressNametags.get(entry.addressId);
9335
- if (!nametags) {
9336
- nametags = /* @__PURE__ */ new Map();
9337
- this._addressNametags.set(entry.addressId, nametags);
9338
- }
9339
- const nextIndex = nametags.size;
9340
- nametags.set(nextIndex, recoveredNametag);
9341
- await this.persistAddressNametags();
9342
- if (this._transport.publishIdentityBinding) {
9343
- await this._transport.publishIdentityBinding(
9344
- this._identity.chainPubkey,
9345
- this._identity.l1Address,
9346
- this._identity.directAddress || "",
9347
- recoveredNametag
9348
- );
9349
- }
9350
- this.emitEvent("nametag:recovered", { nametag: recoveredNametag });
10280
+ if (this._identity) {
10281
+ this._identity.nametag = recoveredNametag;
10282
+ await this._updateCachedProxyAddress();
10283
+ }
10284
+ const entry = await this.ensureAddressTracked(this._currentAddressIndex);
10285
+ let nametags = this._addressNametags.get(entry.addressId);
10286
+ if (!nametags) {
10287
+ nametags = /* @__PURE__ */ new Map();
10288
+ this._addressNametags.set(entry.addressId, nametags);
9351
10289
  }
10290
+ const nextIndex = nametags.size;
10291
+ nametags.set(nextIndex, recoveredNametag);
10292
+ await this.persistAddressNametags();
10293
+ this.emitEvent("nametag:recovered", { nametag: recoveredNametag });
9352
10294
  } catch {
9353
10295
  }
9354
10296
  }
9355
10297
  /**
9356
- * Validate nametag format
10298
+ * Strip @ prefix and normalize a nametag (lowercase, phone E.164, strip @unicity suffix).
9357
10299
  */
9358
- validateNametag(nametag) {
9359
- const pattern = new RegExp(
9360
- `^[a-zA-Z0-9_-]{${LIMITS.NAMETAG_MIN_LENGTH},${LIMITS.NAMETAG_MAX_LENGTH}}$`
9361
- );
9362
- 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);
9363
10303
  }
9364
10304
  // ===========================================================================
9365
10305
  // Public Methods - Lifecycle
@@ -9557,8 +10497,12 @@ var Sphere = class _Sphere {
9557
10497
  for (const provider of this._tokenStorageProviders.values()) {
9558
10498
  provider.setIdentity(this._identity);
9559
10499
  }
9560
- await this._storage.connect();
9561
- await this._transport.connect();
10500
+ if (!this._storage.isConnected()) {
10501
+ await this._storage.connect();
10502
+ }
10503
+ if (!this._transport.isConnected()) {
10504
+ await this._transport.connect();
10505
+ }
9562
10506
  await this._oracle.initialize();
9563
10507
  for (const provider of this._tokenStorageProviders.values()) {
9564
10508
  await provider.initialize();
@@ -9716,6 +10660,7 @@ init_bech32();
9716
10660
  initSphere,
9717
10661
  isEncryptedData,
9718
10662
  isValidBech32,
10663
+ isValidNametag,
9719
10664
  isValidPrivateKey,
9720
10665
  loadSphere,
9721
10666
  mnemonicToEntropy,