@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.
@@ -1461,7 +1461,7 @@ var L1PaymentsModule = class {
1461
1461
  _transport;
1462
1462
  constructor(config) {
1463
1463
  this._config = {
1464
- electrumUrl: config?.electrumUrl ?? "wss://fulcrum.alpha.unicity.network:50004",
1464
+ electrumUrl: config?.electrumUrl ?? "wss://fulcrum.unicity.network:50004",
1465
1465
  network: config?.network ?? "mainnet",
1466
1466
  defaultFeeRate: config?.defaultFeeRate ?? 10,
1467
1467
  enableVesting: config?.enableVesting ?? true
@@ -1493,10 +1493,17 @@ var L1PaymentsModule = class {
1493
1493
  });
1494
1494
  }
1495
1495
  }
1496
- if (this._config.electrumUrl) {
1496
+ this._initialized = true;
1497
+ }
1498
+ /**
1499
+ * Ensure the Fulcrum WebSocket is connected. Called lazily before any
1500
+ * operation that needs the network. If the singleton is already connected
1501
+ * (e.g. by the address scanner), this is a no-op.
1502
+ */
1503
+ async ensureConnected() {
1504
+ if (!isWebSocketConnected() && this._config.electrumUrl) {
1497
1505
  await connect(this._config.electrumUrl);
1498
1506
  }
1499
- this._initialized = true;
1500
1507
  }
1501
1508
  destroy() {
1502
1509
  if (isWebSocketConnected()) {
@@ -1554,6 +1561,7 @@ var L1PaymentsModule = class {
1554
1561
  }
1555
1562
  async send(request) {
1556
1563
  this.ensureInitialized();
1564
+ await this.ensureConnected();
1557
1565
  if (!this._wallet || !this._identity) {
1558
1566
  return { success: false, error: "No wallet available" };
1559
1567
  }
@@ -1588,6 +1596,7 @@ var L1PaymentsModule = class {
1588
1596
  }
1589
1597
  async getBalance() {
1590
1598
  this.ensureInitialized();
1599
+ await this.ensureConnected();
1591
1600
  const addresses = this._getWatchedAddresses();
1592
1601
  let totalAlpha = 0;
1593
1602
  let vestedSats = BigInt(0);
@@ -1619,6 +1628,7 @@ var L1PaymentsModule = class {
1619
1628
  }
1620
1629
  async getUtxos() {
1621
1630
  this.ensureInitialized();
1631
+ await this.ensureConnected();
1622
1632
  const result = [];
1623
1633
  const currentHeight = await getCurrentBlockHeight();
1624
1634
  const allUtxos = await this._getAllUtxos();
@@ -1654,42 +1664,73 @@ var L1PaymentsModule = class {
1654
1664
  return result;
1655
1665
  }
1656
1666
  async getHistory(limit) {
1667
+ await this.ensureConnected();
1657
1668
  this.ensureInitialized();
1658
1669
  const addresses = this._getWatchedAddresses();
1659
1670
  const transactions = [];
1660
1671
  const seenTxids = /* @__PURE__ */ new Set();
1661
1672
  const currentHeight = await getCurrentBlockHeight();
1673
+ const txCache = /* @__PURE__ */ new Map();
1674
+ const fetchTx = async (txid) => {
1675
+ if (txCache.has(txid)) return txCache.get(txid);
1676
+ const detail = await getTransaction(txid);
1677
+ txCache.set(txid, detail);
1678
+ return detail;
1679
+ };
1680
+ const addressSet = new Set(addresses.map((a) => a.toLowerCase()));
1662
1681
  for (const address of addresses) {
1663
1682
  const history = await getTransactionHistory(address);
1664
1683
  for (const item of history) {
1665
1684
  if (seenTxids.has(item.tx_hash)) continue;
1666
1685
  seenTxids.add(item.tx_hash);
1667
- const tx = await getTransaction(item.tx_hash);
1686
+ const tx = await fetchTx(item.tx_hash);
1668
1687
  if (!tx) continue;
1669
- const isSend = tx.vin?.some(
1670
- (vin) => addresses.includes(vin.txid ?? "")
1671
- );
1672
- let amount = "0";
1688
+ let isSend = false;
1689
+ for (const vin of tx.vin ?? []) {
1690
+ if (!vin.txid) continue;
1691
+ const prevTx = await fetchTx(vin.txid);
1692
+ if (prevTx?.vout?.[vin.vout]) {
1693
+ const prevOut = prevTx.vout[vin.vout];
1694
+ const prevAddrs = [
1695
+ ...prevOut.scriptPubKey?.addresses ?? [],
1696
+ ...prevOut.scriptPubKey?.address ? [prevOut.scriptPubKey.address] : []
1697
+ ];
1698
+ if (prevAddrs.some((a) => addressSet.has(a.toLowerCase()))) {
1699
+ isSend = true;
1700
+ break;
1701
+ }
1702
+ }
1703
+ }
1704
+ let amountToUs = 0;
1705
+ let amountToOthers = 0;
1673
1706
  let txAddress = address;
1707
+ let externalAddress = "";
1674
1708
  if (tx.vout) {
1675
1709
  for (const vout of tx.vout) {
1676
- const voutAddresses = vout.scriptPubKey?.addresses ?? [];
1677
- if (vout.scriptPubKey?.address) {
1678
- voutAddresses.push(vout.scriptPubKey.address);
1679
- }
1680
- const matchedAddr = voutAddresses.find((a) => addresses.includes(a));
1681
- if (matchedAddr) {
1682
- amount = Math.floor((vout.value ?? 0) * 1e8).toString();
1683
- txAddress = matchedAddr;
1684
- break;
1710
+ const voutAddresses = [
1711
+ ...vout.scriptPubKey?.addresses ?? [],
1712
+ ...vout.scriptPubKey?.address ? [vout.scriptPubKey.address] : []
1713
+ ];
1714
+ const isOurs = voutAddresses.some((a) => addressSet.has(a.toLowerCase()));
1715
+ const valueSats = Math.floor((vout.value ?? 0) * 1e8);
1716
+ if (isOurs) {
1717
+ amountToUs += valueSats;
1718
+ if (!txAddress) txAddress = voutAddresses[0];
1719
+ } else {
1720
+ amountToOthers += valueSats;
1721
+ if (!externalAddress && voutAddresses.length > 0) {
1722
+ externalAddress = voutAddresses[0];
1723
+ }
1685
1724
  }
1686
1725
  }
1687
1726
  }
1727
+ const amount = isSend ? amountToOthers.toString() : amountToUs.toString();
1728
+ const displayAddress = isSend ? externalAddress || txAddress : txAddress;
1688
1729
  transactions.push({
1689
1730
  txid: item.tx_hash,
1690
1731
  type: isSend ? "send" : "receive",
1691
1732
  amount,
1692
- address: txAddress,
1733
+ address: displayAddress,
1693
1734
  confirmations: item.height > 0 ? currentHeight - item.height : 0,
1694
1735
  timestamp: tx.time ? tx.time * 1e3 : Date.now(),
1695
1736
  blockHeight: item.height > 0 ? item.height : void 0
@@ -1701,6 +1742,7 @@ var L1PaymentsModule = class {
1701
1742
  }
1702
1743
  async getTransaction(txid) {
1703
1744
  this.ensureInitialized();
1745
+ await this.ensureConnected();
1704
1746
  const tx = await getTransaction(txid);
1705
1747
  if (!tx) return null;
1706
1748
  const addresses = this._getWatchedAddresses();
@@ -1736,6 +1778,7 @@ var L1PaymentsModule = class {
1736
1778
  }
1737
1779
  async estimateFee(to, amount) {
1738
1780
  this.ensureInitialized();
1781
+ await this.ensureConnected();
1739
1782
  if (!this._wallet) {
1740
1783
  return { fee: "0", feeRate: this._config.defaultFeeRate ?? 10 };
1741
1784
  }
@@ -2075,6 +2118,7 @@ import { MintCommitment } from "@unicitylabs/state-transition-sdk/lib/transactio
2075
2118
  import { HashAlgorithm as HashAlgorithm2 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
2076
2119
  import { UnmaskedPredicate as UnmaskedPredicate2 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
2077
2120
  import { waitInclusionProof as waitInclusionProof2 } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils";
2121
+ import { normalizeNametag } from "@unicitylabs/nostr-js-sdk";
2078
2122
  var UNICITY_TOKEN_TYPE_HEX = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
2079
2123
  var NametagMinter = class {
2080
2124
  client;
@@ -2099,7 +2143,8 @@ var NametagMinter = class {
2099
2143
  */
2100
2144
  async isNametagAvailable(nametag) {
2101
2145
  try {
2102
- const cleanNametag = nametag.replace("@", "").trim();
2146
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2147
+ const cleanNametag = normalizeNametag(stripped);
2103
2148
  const nametagTokenId = await TokenId2.fromNameTag(cleanNametag);
2104
2149
  const isMinted = await this.client.isMinted(this.trustBase, nametagTokenId);
2105
2150
  return !isMinted;
@@ -2116,7 +2161,8 @@ var NametagMinter = class {
2116
2161
  * @returns MintNametagResult with token if successful
2117
2162
  */
2118
2163
  async mintNametag(nametag, ownerAddress) {
2119
- const cleanNametag = nametag.replace("@", "").trim();
2164
+ const stripped = nametag.startsWith("@") ? nametag.slice(1) : nametag;
2165
+ const cleanNametag = normalizeNametag(stripped);
2120
2166
  this.log(`Starting mint for nametag: ${cleanNametag}`);
2121
2167
  try {
2122
2168
  const nametagTokenId = await TokenId2.fromNameTag(cleanNametag);
@@ -2255,7 +2301,9 @@ var STORAGE_KEYS_GLOBAL = {
2255
2301
  /** Nametag cache per address (separate from tracked addresses registry) */
2256
2302
  ADDRESS_NAMETAGS: "address_nametags",
2257
2303
  /** Active addresses registry (JSON: TrackedAddressesStorage) */
2258
- TRACKED_ADDRESSES: "tracked_addresses"
2304
+ TRACKED_ADDRESSES: "tracked_addresses",
2305
+ /** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
2306
+ LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
2259
2307
  };
2260
2308
  var STORAGE_KEYS_ADDRESS = {
2261
2309
  /** Pending transfers for this address */
@@ -2267,7 +2315,9 @@ var STORAGE_KEYS_ADDRESS = {
2267
2315
  /** Messages for this address */
2268
2316
  MESSAGES: "messages",
2269
2317
  /** Transaction history for this address */
2270
- TRANSACTION_HISTORY: "transaction_history"
2318
+ TRANSACTION_HISTORY: "transaction_history",
2319
+ /** Pending V5 finalization tokens (unconfirmed instant split tokens) */
2320
+ PENDING_V5_TOKENS: "pending_v5_tokens"
2271
2321
  };
2272
2322
  var STORAGE_KEYS = {
2273
2323
  ...STORAGE_KEYS_GLOBAL,
@@ -2286,16 +2336,6 @@ function getAddressId(directAddress) {
2286
2336
  }
2287
2337
  var DEFAULT_BASE_PATH = "m/44'/0'/0'";
2288
2338
  var DEFAULT_DERIVATION_PATH2 = `${DEFAULT_BASE_PATH}/0/0`;
2289
- var LIMITS = {
2290
- /** Min nametag length */
2291
- NAMETAG_MIN_LENGTH: 3,
2292
- /** Max nametag length */
2293
- NAMETAG_MAX_LENGTH: 20,
2294
- /** Max memo length */
2295
- MEMO_MAX_LENGTH: 500,
2296
- /** Max message length */
2297
- MESSAGE_MAX_LENGTH: 1e4
2298
- };
2299
2339
 
2300
2340
  // types/txf.ts
2301
2341
  var ARCHIVED_PREFIX = "archived-";
@@ -2588,6 +2628,18 @@ function parseTxfStorageData(data) {
2588
2628
  result.validationErrors.push(`Forked token ${parsed.tokenId}: invalid structure`);
2589
2629
  }
2590
2630
  }
2631
+ } else if (key.startsWith("token-")) {
2632
+ try {
2633
+ const entry = storageData[key];
2634
+ const txfToken = entry?.token;
2635
+ if (txfToken?.genesis?.data?.tokenId) {
2636
+ const tokenId = txfToken.genesis.data.tokenId;
2637
+ const token = txfToToken(tokenId, txfToken);
2638
+ result.tokens.push(token);
2639
+ }
2640
+ } catch (err) {
2641
+ result.validationErrors.push(`Token ${key}: ${err}`);
2642
+ }
2591
2643
  }
2592
2644
  }
2593
2645
  return result;
@@ -3088,8 +3140,9 @@ var InstantSplitExecutor = class {
3088
3140
  const criticalPathDuration = performance.now() - startTime;
3089
3141
  console.log(`[InstantSplit] V5 complete in ${criticalPathDuration.toFixed(0)}ms`);
3090
3142
  options?.onNostrDelivered?.(nostrEventId);
3143
+ let backgroundPromise;
3091
3144
  if (!options?.skipBackground) {
3092
- this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3145
+ backgroundPromise = this.submitBackgroundV5(senderMintCommitment, recipientMintCommitment, transferCommitment, {
3093
3146
  signingService: this.signingService,
3094
3147
  tokenType: tokenToSplit.type,
3095
3148
  coinId,
@@ -3105,7 +3158,8 @@ var InstantSplitExecutor = class {
3105
3158
  nostrEventId,
3106
3159
  splitGroupId,
3107
3160
  criticalPathDurationMs: criticalPathDuration,
3108
- backgroundStarted: !options?.skipBackground
3161
+ backgroundStarted: !options?.skipBackground,
3162
+ backgroundPromise
3109
3163
  };
3110
3164
  } catch (error) {
3111
3165
  const duration = performance.now() - startTime;
@@ -3167,7 +3221,7 @@ var InstantSplitExecutor = class {
3167
3221
  this.client.submitMintCommitment(recipientMintCommitment).then((res) => ({ type: "recipientMint", status: res.status })).catch((err) => ({ type: "recipientMint", status: "ERROR", error: err })),
3168
3222
  this.client.submitTransferCommitment(transferCommitment).then((res) => ({ type: "transfer", status: res.status })).catch((err) => ({ type: "transfer", status: "ERROR", error: err }))
3169
3223
  ]);
3170
- submissions.then(async (results) => {
3224
+ return submissions.then(async (results) => {
3171
3225
  const submitDuration = performance.now() - startTime;
3172
3226
  console.log(`[InstantSplit] Background: Submissions complete in ${submitDuration.toFixed(0)}ms`);
3173
3227
  context.onProgress?.({
@@ -3632,6 +3686,11 @@ import { AddressScheme } from "@unicitylabs/state-transition-sdk/lib/address/Add
3632
3686
  import { UnmaskedPredicate as UnmaskedPredicate5 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
3633
3687
  import { TokenState as TokenState5 } from "@unicitylabs/state-transition-sdk/lib/token/TokenState";
3634
3688
  import { HashAlgorithm as HashAlgorithm5 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
3689
+ import { TokenType as TokenType3 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
3690
+ import { MintCommitment as MintCommitment3 } from "@unicitylabs/state-transition-sdk/lib/transaction/MintCommitment";
3691
+ import { MintTransactionData as MintTransactionData3 } from "@unicitylabs/state-transition-sdk/lib/transaction/MintTransactionData";
3692
+ import { waitInclusionProof as waitInclusionProof5 } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils";
3693
+ import { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/transaction/InclusionProof";
3635
3694
  function enrichWithRegistry(info) {
3636
3695
  const registry = TokenRegistry.getInstance();
3637
3696
  const def = registry.getDefinition(info.coinId);
@@ -3659,7 +3718,7 @@ async function parseTokenInfo(tokenData) {
3659
3718
  try {
3660
3719
  const sdkToken = await SdkToken2.fromJSON(data);
3661
3720
  if (sdkToken.id) {
3662
- defaultInfo.tokenId = sdkToken.id.toString();
3721
+ defaultInfo.tokenId = sdkToken.id.toJSON();
3663
3722
  }
3664
3723
  if (sdkToken.coins && sdkToken.coins.coins) {
3665
3724
  const rawCoins = sdkToken.coins.coins;
@@ -3829,6 +3888,13 @@ function extractTokenStateKey(token) {
3829
3888
  if (!tokenId || !stateHash) return null;
3830
3889
  return createTokenStateKey(tokenId, stateHash);
3831
3890
  }
3891
+ function fromHex4(hex) {
3892
+ const bytes = new Uint8Array(hex.length / 2);
3893
+ for (let i = 0; i < hex.length; i += 2) {
3894
+ bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
3895
+ }
3896
+ return bytes;
3897
+ }
3832
3898
  function hasSameGenesisTokenId(t1, t2) {
3833
3899
  const id1 = extractTokenIdFromSdkData(t1.sdkData);
3834
3900
  const id2 = extractTokenIdFromSdkData(t2.sdkData);
@@ -3918,6 +3984,7 @@ var PaymentsModule = class _PaymentsModule {
3918
3984
  // Token State
3919
3985
  tokens = /* @__PURE__ */ new Map();
3920
3986
  pendingTransfers = /* @__PURE__ */ new Map();
3987
+ pendingBackgroundTasks = [];
3921
3988
  // Repository State (tombstones, archives, forked, history)
3922
3989
  tombstones = [];
3923
3990
  archivedTokens = /* @__PURE__ */ new Map();
@@ -3942,6 +4009,12 @@ var PaymentsModule = class _PaymentsModule {
3942
4009
  // Poll every 2s
3943
4010
  static PROOF_POLLING_MAX_ATTEMPTS = 30;
3944
4011
  // Max 30 attempts (~60s)
4012
+ // Storage event subscriptions (push-based sync)
4013
+ storageEventUnsubscribers = [];
4014
+ syncDebounceTimer = null;
4015
+ static SYNC_DEBOUNCE_MS = 500;
4016
+ /** Sync coalescing: concurrent sync() calls share the same operation */
4017
+ _syncInProgress = null;
3945
4018
  constructor(config) {
3946
4019
  this.moduleConfig = {
3947
4020
  autoSync: config?.autoSync ?? true,
@@ -3950,10 +4023,13 @@ var PaymentsModule = class _PaymentsModule {
3950
4023
  maxRetries: config?.maxRetries ?? 3,
3951
4024
  debug: config?.debug ?? false
3952
4025
  };
3953
- const l1Enabled = config?.l1?.electrumUrl && config.l1.electrumUrl.length > 0;
3954
- this.l1 = l1Enabled ? new L1PaymentsModule(config?.l1) : null;
4026
+ this.l1 = config?.l1 === null ? null : new L1PaymentsModule(config?.l1);
3955
4027
  }
3956
- /** Get module configuration */
4028
+ /**
4029
+ * Get the current module configuration (excluding L1 config).
4030
+ *
4031
+ * @returns Resolved configuration with all defaults applied.
4032
+ */
3957
4033
  getConfig() {
3958
4034
  return this.moduleConfig;
3959
4035
  }
@@ -3994,9 +4070,9 @@ var PaymentsModule = class _PaymentsModule {
3994
4070
  transport: deps.transport
3995
4071
  });
3996
4072
  }
3997
- this.unsubscribeTransfers = deps.transport.onTokenTransfer((transfer) => {
3998
- this.handleIncomingTransfer(transfer);
3999
- });
4073
+ this.unsubscribeTransfers = deps.transport.onTokenTransfer(
4074
+ (transfer) => this.handleIncomingTransfer(transfer)
4075
+ );
4000
4076
  if (deps.transport.onPaymentRequest) {
4001
4077
  this.unsubscribePaymentRequests = deps.transport.onPaymentRequest((request) => {
4002
4078
  this.handleIncomingPaymentRequest(request);
@@ -4007,9 +4083,14 @@ var PaymentsModule = class _PaymentsModule {
4007
4083
  this.handlePaymentRequestResponse(response);
4008
4084
  });
4009
4085
  }
4086
+ this.subscribeToStorageEvents();
4010
4087
  }
4011
4088
  /**
4012
- * Load tokens from storage
4089
+ * Load all token data from storage providers and restore wallet state.
4090
+ *
4091
+ * Loads tokens, nametag data, transaction history, and pending transfers
4092
+ * from configured storage providers. Restores pending V5 tokens and
4093
+ * triggers a fire-and-forget {@link resolveUnconfirmed} call.
4013
4094
  */
4014
4095
  async load() {
4015
4096
  this.ensureInitialized();
@@ -4026,6 +4107,7 @@ var PaymentsModule = class _PaymentsModule {
4026
4107
  console.error(`[Payments] Failed to load from provider ${id}:`, err);
4027
4108
  }
4028
4109
  }
4110
+ await this.loadPendingV5Tokens();
4029
4111
  await this.loadTokensFromFileStorage();
4030
4112
  await this.loadNametagFromFileStorage();
4031
4113
  const historyData = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.TRANSACTION_HISTORY);
@@ -4043,9 +4125,14 @@ var PaymentsModule = class _PaymentsModule {
4043
4125
  this.pendingTransfers.set(transfer.id, transfer);
4044
4126
  }
4045
4127
  }
4128
+ this.resolveUnconfirmed().catch(() => {
4129
+ });
4046
4130
  }
4047
4131
  /**
4048
- * Cleanup resources
4132
+ * Cleanup all subscriptions, polling jobs, and pending resolvers.
4133
+ *
4134
+ * Should be called when the wallet is being shut down or the module is
4135
+ * no longer needed. Also destroys the L1 sub-module if present.
4049
4136
  */
4050
4137
  destroy() {
4051
4138
  this.unsubscribeTransfers?.();
@@ -4063,6 +4150,7 @@ var PaymentsModule = class _PaymentsModule {
4063
4150
  resolver.reject(new Error("Module destroyed"));
4064
4151
  }
4065
4152
  this.pendingResponseResolvers.clear();
4153
+ this.unsubscribeStorageEvents();
4066
4154
  if (this.l1) {
4067
4155
  this.l1.destroy();
4068
4156
  }
@@ -4079,7 +4167,8 @@ var PaymentsModule = class _PaymentsModule {
4079
4167
  const result = {
4080
4168
  id: crypto.randomUUID(),
4081
4169
  status: "pending",
4082
- tokens: []
4170
+ tokens: [],
4171
+ tokenTransfers: []
4083
4172
  };
4084
4173
  try {
4085
4174
  const peerInfo = await this.deps.transport.resolve?.(request.recipient) ?? null;
@@ -4116,69 +4205,147 @@ var PaymentsModule = class _PaymentsModule {
4116
4205
  await this.saveToOutbox(result, recipientPubkey);
4117
4206
  result.status = "submitted";
4118
4207
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4208
+ const transferMode = request.transferMode ?? "instant";
4119
4209
  if (splitPlan.requiresSplit && splitPlan.tokenToSplit) {
4120
- this.log("Executing token split...");
4121
- const executor = new TokenSplitExecutor({
4122
- stateTransitionClient: stClient,
4123
- trustBase,
4124
- signingService
4125
- });
4126
- const splitResult = await executor.executeSplit(
4127
- splitPlan.tokenToSplit.sdkToken,
4128
- splitPlan.splitAmount,
4129
- splitPlan.remainderAmount,
4130
- splitPlan.coinId,
4131
- recipientAddress
4132
- );
4133
- const changeTokenData = splitResult.tokenForSender.toJSON();
4134
- const changeToken = {
4135
- id: crypto.randomUUID(),
4136
- coinId: request.coinId,
4137
- symbol: this.getCoinSymbol(request.coinId),
4138
- name: this.getCoinName(request.coinId),
4139
- decimals: this.getCoinDecimals(request.coinId),
4140
- iconUrl: this.getCoinIconUrl(request.coinId),
4141
- amount: splitPlan.remainderAmount.toString(),
4142
- status: "confirmed",
4143
- createdAt: Date.now(),
4144
- updatedAt: Date.now(),
4145
- sdkData: JSON.stringify(changeTokenData)
4146
- };
4147
- await this.addToken(changeToken, true);
4148
- this.log(`Change token saved: ${changeToken.id}, amount: ${changeToken.amount}`);
4149
- console.log(`[Payments] Sending split token to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4150
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4151
- sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4152
- transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4153
- memo: request.memo
4154
- });
4155
- console.log(`[Payments] Split token sent successfully`);
4156
- await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4157
- result.txHash = "split-" + Date.now().toString(16);
4158
- this.log(`Split transfer completed`);
4210
+ if (transferMode === "conservative") {
4211
+ this.log("Executing conservative split...");
4212
+ const splitExecutor = new TokenSplitExecutor({
4213
+ stateTransitionClient: stClient,
4214
+ trustBase,
4215
+ signingService
4216
+ });
4217
+ const splitResult = await splitExecutor.executeSplit(
4218
+ splitPlan.tokenToSplit.sdkToken,
4219
+ splitPlan.splitAmount,
4220
+ splitPlan.remainderAmount,
4221
+ splitPlan.coinId,
4222
+ recipientAddress
4223
+ );
4224
+ const changeTokenData = splitResult.tokenForSender.toJSON();
4225
+ const changeUiToken = {
4226
+ id: crypto.randomUUID(),
4227
+ coinId: request.coinId,
4228
+ symbol: this.getCoinSymbol(request.coinId),
4229
+ name: this.getCoinName(request.coinId),
4230
+ decimals: this.getCoinDecimals(request.coinId),
4231
+ iconUrl: this.getCoinIconUrl(request.coinId),
4232
+ amount: splitPlan.remainderAmount.toString(),
4233
+ status: "confirmed",
4234
+ createdAt: Date.now(),
4235
+ updatedAt: Date.now(),
4236
+ sdkData: JSON.stringify(changeTokenData)
4237
+ };
4238
+ await this.addToken(changeUiToken, true);
4239
+ this.log(`Conservative split: change token saved: ${changeUiToken.id}`);
4240
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4241
+ sourceToken: JSON.stringify(splitResult.tokenForRecipient.toJSON()),
4242
+ transferTx: JSON.stringify(splitResult.recipientTransferTx.toJSON()),
4243
+ memo: request.memo
4244
+ });
4245
+ const splitCommitmentRequestId = splitResult.recipientTransferTx?.data?.requestId ?? splitResult.recipientTransferTx?.requestId;
4246
+ const splitRequestIdHex = splitCommitmentRequestId instanceof Uint8Array ? Array.from(splitCommitmentRequestId).map((b) => b.toString(16).padStart(2, "0")).join("") : splitCommitmentRequestId ? String(splitCommitmentRequestId) : void 0;
4247
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag, true);
4248
+ result.tokenTransfers.push({
4249
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4250
+ method: "split",
4251
+ requestIdHex: splitRequestIdHex
4252
+ });
4253
+ this.log(`Conservative split transfer completed`);
4254
+ } else {
4255
+ this.log("Executing instant split...");
4256
+ const devMode = this.deps.oracle.isDevMode?.() ?? false;
4257
+ const executor = new InstantSplitExecutor({
4258
+ stateTransitionClient: stClient,
4259
+ trustBase,
4260
+ signingService,
4261
+ devMode
4262
+ });
4263
+ const instantResult = await executor.executeSplitInstant(
4264
+ splitPlan.tokenToSplit.sdkToken,
4265
+ splitPlan.splitAmount,
4266
+ splitPlan.remainderAmount,
4267
+ splitPlan.coinId,
4268
+ recipientAddress,
4269
+ this.deps.transport,
4270
+ recipientPubkey,
4271
+ {
4272
+ onChangeTokenCreated: async (changeToken) => {
4273
+ const changeTokenData = changeToken.toJSON();
4274
+ const uiToken = {
4275
+ id: crypto.randomUUID(),
4276
+ coinId: request.coinId,
4277
+ symbol: this.getCoinSymbol(request.coinId),
4278
+ name: this.getCoinName(request.coinId),
4279
+ decimals: this.getCoinDecimals(request.coinId),
4280
+ iconUrl: this.getCoinIconUrl(request.coinId),
4281
+ amount: splitPlan.remainderAmount.toString(),
4282
+ status: "confirmed",
4283
+ createdAt: Date.now(),
4284
+ updatedAt: Date.now(),
4285
+ sdkData: JSON.stringify(changeTokenData)
4286
+ };
4287
+ await this.addToken(uiToken, true);
4288
+ this.log(`Change token saved via background: ${uiToken.id}`);
4289
+ },
4290
+ onStorageSync: async () => {
4291
+ await this.save();
4292
+ return true;
4293
+ }
4294
+ }
4295
+ );
4296
+ if (!instantResult.success) {
4297
+ throw new Error(instantResult.error || "Instant split failed");
4298
+ }
4299
+ if (instantResult.backgroundPromise) {
4300
+ this.pendingBackgroundTasks.push(instantResult.backgroundPromise);
4301
+ }
4302
+ await this.removeToken(splitPlan.tokenToSplit.uiToken.id, recipientNametag);
4303
+ result.tokenTransfers.push({
4304
+ sourceTokenId: splitPlan.tokenToSplit.uiToken.id,
4305
+ method: "split",
4306
+ splitGroupId: instantResult.splitGroupId,
4307
+ nostrEventId: instantResult.nostrEventId
4308
+ });
4309
+ this.log(`Instant split transfer completed`);
4310
+ }
4159
4311
  }
4160
4312
  for (const tokenWithAmount of splitPlan.tokensToTransferDirectly) {
4161
4313
  const token = tokenWithAmount.uiToken;
4162
4314
  const commitment = await this.createSdkCommitment(token, recipientAddress, signingService);
4163
- const response = await stClient.submitTransferCommitment(commitment);
4164
- if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") {
4165
- throw new Error(`Transfer commitment failed: ${response.status}`);
4166
- }
4167
- if (!this.deps.oracle.waitForProofSdk) {
4168
- throw new Error("Oracle provider must implement waitForProofSdk()");
4315
+ if (transferMode === "conservative") {
4316
+ console.log(`[Payments] CONSERVATIVE: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4317
+ const submitResponse = await stClient.submitTransferCommitment(commitment);
4318
+ if (submitResponse.status !== "SUCCESS" && submitResponse.status !== "REQUEST_ID_EXISTS") {
4319
+ throw new Error(`Transfer commitment failed: ${submitResponse.status}`);
4320
+ }
4321
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
4322
+ const transferTx = commitment.toTransaction(inclusionProof);
4323
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4324
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4325
+ transferTx: JSON.stringify(transferTx.toJSON()),
4326
+ memo: request.memo
4327
+ });
4328
+ console.log(`[Payments] CONSERVATIVE: Direct token sent successfully`);
4329
+ } else {
4330
+ console.log(`[Payments] NOSTR-FIRST: Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}...`);
4331
+ await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4332
+ sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4333
+ commitmentData: JSON.stringify(commitment.toJSON()),
4334
+ memo: request.memo
4335
+ });
4336
+ console.log(`[Payments] NOSTR-FIRST: Direct token sent successfully`);
4337
+ stClient.submitTransferCommitment(commitment).catch(
4338
+ (err) => console.error("[Payments] Background commitment submit failed:", err)
4339
+ );
4169
4340
  }
4170
- const inclusionProof = await this.deps.oracle.waitForProofSdk(commitment);
4171
- const transferTx = commitment.toTransaction(inclusionProof);
4172
4341
  const requestIdBytes = commitment.requestId;
4173
- result.txHash = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4174
- console.log(`[Payments] Sending direct token ${token.id.slice(0, 8)}... to ${recipientPubkey.slice(0, 8)}... via Nostr`);
4175
- await this.deps.transport.sendTokenTransfer(recipientPubkey, {
4176
- sourceToken: JSON.stringify(tokenWithAmount.sdkToken.toJSON()),
4177
- transferTx: JSON.stringify(transferTx.toJSON()),
4178
- memo: request.memo
4342
+ const requestIdHex = requestIdBytes instanceof Uint8Array ? Array.from(requestIdBytes).map((b) => b.toString(16).padStart(2, "0")).join("") : String(requestIdBytes);
4343
+ result.tokenTransfers.push({
4344
+ sourceTokenId: token.id,
4345
+ method: "direct",
4346
+ requestIdHex
4179
4347
  });
4180
- console.log(`[Payments] Direct token sent successfully`);
4181
- this.log(`Token ${token.id} transferred, txHash: ${result.txHash}`);
4348
+ this.log(`Token ${token.id} sent via ${transferMode.toUpperCase()}, requestId: ${requestIdHex}`);
4182
4349
  await this.removeToken(token.id, recipientNametag, true);
4183
4350
  }
4184
4351
  result.status = "delivered";
@@ -4191,7 +4358,8 @@ var PaymentsModule = class _PaymentsModule {
4191
4358
  coinId: request.coinId,
4192
4359
  symbol: this.getCoinSymbol(request.coinId),
4193
4360
  timestamp: Date.now(),
4194
- recipientNametag
4361
+ recipientNametag,
4362
+ transferId: result.id
4195
4363
  });
4196
4364
  this.deps.emitEvent("transfer:confirmed", result);
4197
4365
  return result;
@@ -4327,6 +4495,9 @@ var PaymentsModule = class _PaymentsModule {
4327
4495
  }
4328
4496
  );
4329
4497
  if (result.success) {
4498
+ if (result.backgroundPromise) {
4499
+ this.pendingBackgroundTasks.push(result.backgroundPromise);
4500
+ }
4330
4501
  const recipientNametag = request.recipient.startsWith("@") ? request.recipient.slice(1) : void 0;
4331
4502
  await this.removeToken(tokenToSplit.id, recipientNametag, true);
4332
4503
  await this.addToHistory({
@@ -4368,6 +4539,63 @@ var PaymentsModule = class _PaymentsModule {
4368
4539
  */
4369
4540
  async processInstantSplitBundle(bundle, senderPubkey) {
4370
4541
  this.ensureInitialized();
4542
+ if (!isInstantSplitBundleV5(bundle)) {
4543
+ return this.processInstantSplitBundleSync(bundle, senderPubkey);
4544
+ }
4545
+ try {
4546
+ const deterministicId = `v5split_${bundle.splitGroupId}`;
4547
+ if (this.tokens.has(deterministicId)) {
4548
+ this.log(`V5 bundle ${deterministicId.slice(0, 16)}... already exists, skipping duplicate`);
4549
+ return { success: true, durationMs: 0 };
4550
+ }
4551
+ const registry = TokenRegistry.getInstance();
4552
+ const pendingData = {
4553
+ type: "v5_bundle",
4554
+ stage: "RECEIVED",
4555
+ bundleJson: JSON.stringify(bundle),
4556
+ senderPubkey,
4557
+ savedAt: Date.now(),
4558
+ attemptCount: 0
4559
+ };
4560
+ const uiToken = {
4561
+ id: deterministicId,
4562
+ coinId: bundle.coinId,
4563
+ symbol: registry.getSymbol(bundle.coinId) || bundle.coinId,
4564
+ name: registry.getName(bundle.coinId) || bundle.coinId,
4565
+ decimals: registry.getDecimals(bundle.coinId) ?? 8,
4566
+ amount: bundle.amount,
4567
+ status: "submitted",
4568
+ // UNCONFIRMED
4569
+ createdAt: Date.now(),
4570
+ updatedAt: Date.now(),
4571
+ sdkData: JSON.stringify({ _pendingFinalization: pendingData })
4572
+ };
4573
+ await this.addToken(uiToken, false);
4574
+ this.log(`V5 bundle saved as unconfirmed: ${uiToken.id.slice(0, 8)}...`);
4575
+ this.deps.emitEvent("transfer:incoming", {
4576
+ id: bundle.splitGroupId,
4577
+ senderPubkey,
4578
+ tokens: [uiToken],
4579
+ receivedAt: Date.now()
4580
+ });
4581
+ await this.save();
4582
+ this.resolveUnconfirmed().catch(() => {
4583
+ });
4584
+ return { success: true, durationMs: 0 };
4585
+ } catch (error) {
4586
+ const errorMessage = error instanceof Error ? error.message : String(error);
4587
+ return {
4588
+ success: false,
4589
+ error: errorMessage,
4590
+ durationMs: 0
4591
+ };
4592
+ }
4593
+ }
4594
+ /**
4595
+ * Synchronous V4 bundle processing (dev mode only).
4596
+ * Kept for backward compatibility with V4 bundles.
4597
+ */
4598
+ async processInstantSplitBundleSync(bundle, senderPubkey) {
4371
4599
  try {
4372
4600
  const signingService = await this.createSigningService();
4373
4601
  const stClient = this.deps.oracle.getStateTransitionClient?.();
@@ -4453,7 +4681,10 @@ var PaymentsModule = class _PaymentsModule {
4453
4681
  }
4454
4682
  }
4455
4683
  /**
4456
- * Check if a payload is an instant split bundle
4684
+ * Type-guard: check whether a payload is a valid {@link InstantSplitBundle} (V4 or V5).
4685
+ *
4686
+ * @param payload - The object to test.
4687
+ * @returns `true` if the payload matches the InstantSplitBundle shape.
4457
4688
  */
4458
4689
  isInstantSplitBundle(payload) {
4459
4690
  return isInstantSplitBundle(payload);
@@ -4534,39 +4765,57 @@ var PaymentsModule = class _PaymentsModule {
4534
4765
  return [...this.paymentRequests];
4535
4766
  }
4536
4767
  /**
4537
- * Get pending payment requests count
4768
+ * Get the count of payment requests with status `'pending'`.
4769
+ *
4770
+ * @returns Number of pending incoming payment requests.
4538
4771
  */
4539
4772
  getPendingPaymentRequestsCount() {
4540
4773
  return this.paymentRequests.filter((r) => r.status === "pending").length;
4541
4774
  }
4542
4775
  /**
4543
- * Accept a payment request (marks it as accepted, user should then call send())
4776
+ * Accept a payment request and notify the requester.
4777
+ *
4778
+ * Marks the request as `'accepted'` and sends a response via transport.
4779
+ * The caller should subsequently call {@link send} to fulfill the payment.
4780
+ *
4781
+ * @param requestId - ID of the incoming payment request to accept.
4544
4782
  */
4545
4783
  async acceptPaymentRequest(requestId2) {
4546
4784
  this.updatePaymentRequestStatus(requestId2, "accepted");
4547
4785
  await this.sendPaymentRequestResponse(requestId2, "accepted");
4548
4786
  }
4549
4787
  /**
4550
- * Reject a payment request
4788
+ * Reject a payment request and notify the requester.
4789
+ *
4790
+ * @param requestId - ID of the incoming payment request to reject.
4551
4791
  */
4552
4792
  async rejectPaymentRequest(requestId2) {
4553
4793
  this.updatePaymentRequestStatus(requestId2, "rejected");
4554
4794
  await this.sendPaymentRequestResponse(requestId2, "rejected");
4555
4795
  }
4556
4796
  /**
4557
- * Mark a payment request as paid (after successful transfer)
4797
+ * Mark a payment request as paid (local status update only).
4798
+ *
4799
+ * Typically called after a successful {@link send} to record that the
4800
+ * request has been fulfilled.
4801
+ *
4802
+ * @param requestId - ID of the incoming payment request to mark as paid.
4558
4803
  */
4559
4804
  markPaymentRequestPaid(requestId2) {
4560
4805
  this.updatePaymentRequestStatus(requestId2, "paid");
4561
4806
  }
4562
4807
  /**
4563
- * Clear processed (non-pending) payment requests
4808
+ * Remove all non-pending incoming payment requests from memory.
4809
+ *
4810
+ * Keeps only requests with status `'pending'`.
4564
4811
  */
4565
4812
  clearProcessedPaymentRequests() {
4566
4813
  this.paymentRequests = this.paymentRequests.filter((r) => r.status === "pending");
4567
4814
  }
4568
4815
  /**
4569
- * Remove a specific payment request
4816
+ * Remove a specific incoming payment request by ID.
4817
+ *
4818
+ * @param requestId - ID of the payment request to remove.
4570
4819
  */
4571
4820
  removePaymentRequest(requestId2) {
4572
4821
  this.paymentRequests = this.paymentRequests.filter((r) => r.id !== requestId2);
@@ -4613,13 +4862,16 @@ var PaymentsModule = class _PaymentsModule {
4613
4862
  if (this.paymentRequests.find((r) => r.id === transportRequest.id)) {
4614
4863
  return;
4615
4864
  }
4865
+ const coinId = transportRequest.request.coinId;
4866
+ const registry = TokenRegistry.getInstance();
4867
+ const coinDef = registry.getDefinition(coinId);
4616
4868
  const request = {
4617
4869
  id: transportRequest.id,
4618
4870
  senderPubkey: transportRequest.senderTransportPubkey,
4871
+ senderNametag: transportRequest.senderNametag,
4619
4872
  amount: transportRequest.request.amount,
4620
- coinId: transportRequest.request.coinId,
4621
- symbol: transportRequest.request.coinId,
4622
- // Use coinId as symbol for now
4873
+ coinId,
4874
+ symbol: coinDef?.symbol || coinId.slice(0, 8),
4623
4875
  message: transportRequest.request.message,
4624
4876
  recipientNametag: transportRequest.request.recipientNametag,
4625
4877
  requestId: transportRequest.request.requestId,
@@ -4688,7 +4940,11 @@ var PaymentsModule = class _PaymentsModule {
4688
4940
  });
4689
4941
  }
4690
4942
  /**
4691
- * Cancel waiting for a payment response
4943
+ * Cancel an active {@link waitForPaymentResponse} call.
4944
+ *
4945
+ * The pending promise is rejected with a `'Cancelled'` error.
4946
+ *
4947
+ * @param requestId - The outgoing request ID whose wait should be cancelled.
4692
4948
  */
4693
4949
  cancelWaitForPaymentResponse(requestId2) {
4694
4950
  const resolver = this.pendingResponseResolvers.get(requestId2);
@@ -4699,14 +4955,16 @@ var PaymentsModule = class _PaymentsModule {
4699
4955
  }
4700
4956
  }
4701
4957
  /**
4702
- * Remove an outgoing payment request
4958
+ * Remove an outgoing payment request and cancel any pending wait.
4959
+ *
4960
+ * @param requestId - ID of the outgoing request to remove.
4703
4961
  */
4704
4962
  removeOutgoingPaymentRequest(requestId2) {
4705
4963
  this.outgoingPaymentRequests.delete(requestId2);
4706
4964
  this.cancelWaitForPaymentResponse(requestId2);
4707
4965
  }
4708
4966
  /**
4709
- * Clear completed/expired outgoing payment requests
4967
+ * Remove all outgoing payment requests that are `'paid'`, `'rejected'`, or `'expired'`.
4710
4968
  */
4711
4969
  clearCompletedOutgoingPaymentRequests() {
4712
4970
  for (const [id, request] of this.outgoingPaymentRequests) {
@@ -4778,6 +5036,71 @@ var PaymentsModule = class _PaymentsModule {
4778
5036
  }
4779
5037
  }
4780
5038
  // ===========================================================================
5039
+ // Public API - Receive
5040
+ // ===========================================================================
5041
+ /**
5042
+ * Fetch and process pending incoming transfers from the transport layer.
5043
+ *
5044
+ * Performs a one-shot query to fetch all pending events, processes them
5045
+ * through the existing pipeline, and resolves after all stored events
5046
+ * are handled. Useful for batch/CLI apps that need explicit receive.
5047
+ *
5048
+ * When `finalize` is true, polls resolveUnconfirmed() + load() until all
5049
+ * tokens are confirmed or the timeout expires. Otherwise calls
5050
+ * resolveUnconfirmed() once to submit pending commitments.
5051
+ *
5052
+ * @param options - Optional receive options including finalization control
5053
+ * @param callback - Optional callback invoked for each newly received transfer
5054
+ * @returns ReceiveResult with transfers and finalization metadata
5055
+ */
5056
+ async receive(options, callback) {
5057
+ this.ensureInitialized();
5058
+ if (!this.deps.transport.fetchPendingEvents) {
5059
+ throw new Error("Transport provider does not support fetchPendingEvents");
5060
+ }
5061
+ const opts = options ?? {};
5062
+ const tokensBefore = new Set(this.tokens.keys());
5063
+ await this.deps.transport.fetchPendingEvents();
5064
+ await this.load();
5065
+ const received = [];
5066
+ for (const [tokenId, token] of this.tokens) {
5067
+ if (!tokensBefore.has(tokenId)) {
5068
+ const transfer = {
5069
+ id: tokenId,
5070
+ senderPubkey: "",
5071
+ tokens: [token],
5072
+ receivedAt: Date.now()
5073
+ };
5074
+ received.push(transfer);
5075
+ if (callback) callback(transfer);
5076
+ }
5077
+ }
5078
+ const result = { transfers: received };
5079
+ if (opts.finalize) {
5080
+ const timeout = opts.timeout ?? 6e4;
5081
+ const pollInterval = opts.pollInterval ?? 2e3;
5082
+ const startTime = Date.now();
5083
+ while (Date.now() - startTime < timeout) {
5084
+ const resolution = await this.resolveUnconfirmed();
5085
+ result.finalization = resolution;
5086
+ if (opts.onProgress) opts.onProgress(resolution);
5087
+ const stillUnconfirmed = Array.from(this.tokens.values()).some(
5088
+ (t) => t.status === "submitted" || t.status === "pending"
5089
+ );
5090
+ if (!stillUnconfirmed) break;
5091
+ await new Promise((r) => setTimeout(r, pollInterval));
5092
+ await this.load();
5093
+ }
5094
+ result.finalizationDurationMs = Date.now() - startTime;
5095
+ result.timedOut = Array.from(this.tokens.values()).some(
5096
+ (t) => t.status === "submitted" || t.status === "pending"
5097
+ );
5098
+ } else {
5099
+ result.finalization = await this.resolveUnconfirmed();
5100
+ }
5101
+ return result;
5102
+ }
5103
+ // ===========================================================================
4781
5104
  // Public API - Balance & Tokens
4782
5105
  // ===========================================================================
4783
5106
  /**
@@ -4787,10 +5110,20 @@ var PaymentsModule = class _PaymentsModule {
4787
5110
  this.priceProvider = provider;
4788
5111
  }
4789
5112
  /**
4790
- * Get total portfolio value in USD
4791
- * Returns null if PriceProvider is not configured
5113
+ * Wait for all pending background operations (e.g., instant split change token creation).
5114
+ * Call this before process exit to ensure all tokens are saved.
4792
5115
  */
4793
- async getBalance() {
5116
+ async waitForPendingOperations() {
5117
+ if (this.pendingBackgroundTasks.length > 0) {
5118
+ await Promise.allSettled(this.pendingBackgroundTasks);
5119
+ this.pendingBackgroundTasks = [];
5120
+ }
5121
+ }
5122
+ /**
5123
+ * Get total portfolio value in USD.
5124
+ * Returns null if PriceProvider is not configured.
5125
+ */
5126
+ async getFiatBalance() {
4794
5127
  const assets = await this.getAssets();
4795
5128
  if (!this.priceProvider) {
4796
5129
  return null;
@@ -4806,19 +5139,95 @@ var PaymentsModule = class _PaymentsModule {
4806
5139
  return hasAnyPrice ? total : null;
4807
5140
  }
4808
5141
  /**
4809
- * Get aggregated assets (tokens grouped by coinId) with price data
4810
- * Only includes confirmed tokens
5142
+ * Get token balances grouped by coin type.
5143
+ *
5144
+ * Returns an array of {@link Asset} objects, one per coin type held.
5145
+ * Each entry includes confirmed and unconfirmed breakdowns. Tokens with
5146
+ * status `'spent'`, `'invalid'`, or `'transferring'` are excluded.
5147
+ *
5148
+ * This is synchronous — no price data is included. Use {@link getAssets}
5149
+ * for the async version with fiat pricing.
5150
+ *
5151
+ * @param coinId - Optional coin ID to filter by (e.g. hex string). When omitted, all coin types are returned.
5152
+ * @returns Array of balance summaries (synchronous — no await needed).
5153
+ */
5154
+ getBalance(coinId) {
5155
+ return this.aggregateTokens(coinId);
5156
+ }
5157
+ /**
5158
+ * Get aggregated assets (tokens grouped by coinId) with price data.
5159
+ * Includes both confirmed and unconfirmed tokens with breakdown.
4811
5160
  */
4812
5161
  async getAssets(coinId) {
5162
+ const rawAssets = this.aggregateTokens(coinId);
5163
+ if (!this.priceProvider || rawAssets.length === 0) {
5164
+ return rawAssets;
5165
+ }
5166
+ try {
5167
+ const registry = TokenRegistry.getInstance();
5168
+ const nameToCoins = /* @__PURE__ */ new Map();
5169
+ for (const asset of rawAssets) {
5170
+ const def = registry.getDefinition(asset.coinId);
5171
+ if (def?.name) {
5172
+ const existing = nameToCoins.get(def.name);
5173
+ if (existing) {
5174
+ existing.push(asset.coinId);
5175
+ } else {
5176
+ nameToCoins.set(def.name, [asset.coinId]);
5177
+ }
5178
+ }
5179
+ }
5180
+ if (nameToCoins.size > 0) {
5181
+ const tokenNames = Array.from(nameToCoins.keys());
5182
+ const prices = await this.priceProvider.getPrices(tokenNames);
5183
+ return rawAssets.map((raw) => {
5184
+ const def = registry.getDefinition(raw.coinId);
5185
+ const price = def?.name ? prices.get(def.name) : void 0;
5186
+ let fiatValueUsd = null;
5187
+ let fiatValueEur = null;
5188
+ if (price) {
5189
+ const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
5190
+ fiatValueUsd = humanAmount * price.priceUsd;
5191
+ if (price.priceEur != null) {
5192
+ fiatValueEur = humanAmount * price.priceEur;
5193
+ }
5194
+ }
5195
+ return {
5196
+ ...raw,
5197
+ priceUsd: price?.priceUsd ?? null,
5198
+ priceEur: price?.priceEur ?? null,
5199
+ change24h: price?.change24h ?? null,
5200
+ fiatValueUsd,
5201
+ fiatValueEur
5202
+ };
5203
+ });
5204
+ }
5205
+ } catch (error) {
5206
+ console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
5207
+ }
5208
+ return rawAssets;
5209
+ }
5210
+ /**
5211
+ * Aggregate tokens by coinId with confirmed/unconfirmed breakdown.
5212
+ * Excludes tokens with status 'spent', 'invalid', or 'transferring'.
5213
+ */
5214
+ aggregateTokens(coinId) {
4813
5215
  const assetsMap = /* @__PURE__ */ new Map();
4814
5216
  for (const token of this.tokens.values()) {
4815
- if (token.status !== "confirmed") continue;
5217
+ if (token.status === "spent" || token.status === "invalid" || token.status === "transferring") continue;
4816
5218
  if (coinId && token.coinId !== coinId) continue;
4817
5219
  const key = token.coinId;
5220
+ const amount = BigInt(token.amount);
5221
+ const isConfirmed = token.status === "confirmed";
4818
5222
  const existing = assetsMap.get(key);
4819
5223
  if (existing) {
4820
- existing.totalAmount = (BigInt(existing.totalAmount) + BigInt(token.amount)).toString();
4821
- existing.tokenCount++;
5224
+ if (isConfirmed) {
5225
+ existing.confirmedAmount += amount;
5226
+ existing.confirmedTokenCount++;
5227
+ } else {
5228
+ existing.unconfirmedAmount += amount;
5229
+ existing.unconfirmedTokenCount++;
5230
+ }
4822
5231
  } else {
4823
5232
  assetsMap.set(key, {
4824
5233
  coinId: token.coinId,
@@ -4826,78 +5235,42 @@ var PaymentsModule = class _PaymentsModule {
4826
5235
  name: token.name,
4827
5236
  decimals: token.decimals,
4828
5237
  iconUrl: token.iconUrl,
4829
- totalAmount: token.amount,
4830
- tokenCount: 1
5238
+ confirmedAmount: isConfirmed ? amount : 0n,
5239
+ unconfirmedAmount: isConfirmed ? 0n : amount,
5240
+ confirmedTokenCount: isConfirmed ? 1 : 0,
5241
+ unconfirmedTokenCount: isConfirmed ? 0 : 1
4831
5242
  });
4832
5243
  }
4833
5244
  }
4834
- const rawAssets = Array.from(assetsMap.values());
4835
- let priceMap = null;
4836
- if (this.priceProvider && rawAssets.length > 0) {
4837
- try {
4838
- const registry = TokenRegistry.getInstance();
4839
- const nameToCoins = /* @__PURE__ */ new Map();
4840
- for (const asset of rawAssets) {
4841
- const def = registry.getDefinition(asset.coinId);
4842
- if (def?.name) {
4843
- const existing = nameToCoins.get(def.name);
4844
- if (existing) {
4845
- existing.push(asset.coinId);
4846
- } else {
4847
- nameToCoins.set(def.name, [asset.coinId]);
4848
- }
4849
- }
4850
- }
4851
- if (nameToCoins.size > 0) {
4852
- const tokenNames = Array.from(nameToCoins.keys());
4853
- const prices = await this.priceProvider.getPrices(tokenNames);
4854
- priceMap = /* @__PURE__ */ new Map();
4855
- for (const [name, coinIds] of nameToCoins) {
4856
- const price = prices.get(name);
4857
- if (price) {
4858
- for (const cid of coinIds) {
4859
- priceMap.set(cid, {
4860
- priceUsd: price.priceUsd,
4861
- priceEur: price.priceEur,
4862
- change24h: price.change24h
4863
- });
4864
- }
4865
- }
4866
- }
4867
- }
4868
- } catch (error) {
4869
- console.warn("[Payments] Failed to fetch prices, returning assets without price data:", error);
4870
- }
4871
- }
4872
- return rawAssets.map((raw) => {
4873
- const price = priceMap?.get(raw.coinId);
4874
- let fiatValueUsd = null;
4875
- let fiatValueEur = null;
4876
- if (price) {
4877
- const humanAmount = Number(raw.totalAmount) / Math.pow(10, raw.decimals);
4878
- fiatValueUsd = humanAmount * price.priceUsd;
4879
- if (price.priceEur != null) {
4880
- fiatValueEur = humanAmount * price.priceEur;
4881
- }
4882
- }
5245
+ return Array.from(assetsMap.values()).map((raw) => {
5246
+ const totalAmount = (raw.confirmedAmount + raw.unconfirmedAmount).toString();
4883
5247
  return {
4884
5248
  coinId: raw.coinId,
4885
5249
  symbol: raw.symbol,
4886
5250
  name: raw.name,
4887
5251
  decimals: raw.decimals,
4888
5252
  iconUrl: raw.iconUrl,
4889
- totalAmount: raw.totalAmount,
4890
- tokenCount: raw.tokenCount,
4891
- priceUsd: price?.priceUsd ?? null,
4892
- priceEur: price?.priceEur ?? null,
4893
- change24h: price?.change24h ?? null,
4894
- fiatValueUsd,
4895
- fiatValueEur
5253
+ totalAmount,
5254
+ tokenCount: raw.confirmedTokenCount + raw.unconfirmedTokenCount,
5255
+ confirmedAmount: raw.confirmedAmount.toString(),
5256
+ unconfirmedAmount: raw.unconfirmedAmount.toString(),
5257
+ confirmedTokenCount: raw.confirmedTokenCount,
5258
+ unconfirmedTokenCount: raw.unconfirmedTokenCount,
5259
+ priceUsd: null,
5260
+ priceEur: null,
5261
+ change24h: null,
5262
+ fiatValueUsd: null,
5263
+ fiatValueEur: null
4896
5264
  };
4897
5265
  });
4898
5266
  }
4899
5267
  /**
4900
- * Get all tokens
5268
+ * Get all tokens, optionally filtered by coin type and/or status.
5269
+ *
5270
+ * @param filter - Optional filter criteria.
5271
+ * @param filter.coinId - Return only tokens of this coin type.
5272
+ * @param filter.status - Return only tokens with this status (e.g. `'submitted'` for unconfirmed).
5273
+ * @returns Array of matching {@link Token} objects (synchronous).
4901
5274
  */
4902
5275
  getTokens(filter) {
4903
5276
  let tokens = Array.from(this.tokens.values());
@@ -4910,19 +5283,327 @@ var PaymentsModule = class _PaymentsModule {
4910
5283
  return tokens;
4911
5284
  }
4912
5285
  /**
4913
- * Get single token
5286
+ * Get a single token by its local ID.
5287
+ *
5288
+ * @param id - The local UUID assigned when the token was added.
5289
+ * @returns The token, or `undefined` if not found.
4914
5290
  */
4915
5291
  getToken(id) {
4916
5292
  return this.tokens.get(id);
4917
5293
  }
4918
5294
  // ===========================================================================
5295
+ // Public API - Unconfirmed Token Resolution
5296
+ // ===========================================================================
5297
+ /**
5298
+ * Attempt to resolve unconfirmed (status `'submitted'`) tokens by acquiring
5299
+ * their missing aggregator proofs.
5300
+ *
5301
+ * Each unconfirmed V5 token progresses through stages:
5302
+ * `RECEIVED` → `MINT_SUBMITTED` → `MINT_PROVEN` → `TRANSFER_SUBMITTED` → `FINALIZED`
5303
+ *
5304
+ * Uses 500 ms quick-timeouts per proof check so the call returns quickly even
5305
+ * when proofs are not yet available. Tokens that exceed 50 failed attempts are
5306
+ * marked `'invalid'`.
5307
+ *
5308
+ * Automatically called (fire-and-forget) by {@link load}.
5309
+ *
5310
+ * @returns Summary with counts of resolved, still-pending, and failed tokens plus per-token details.
5311
+ */
5312
+ async resolveUnconfirmed() {
5313
+ this.ensureInitialized();
5314
+ const result = {
5315
+ resolved: 0,
5316
+ stillPending: 0,
5317
+ failed: 0,
5318
+ details: []
5319
+ };
5320
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
5321
+ const trustBase = this.deps.oracle.getTrustBase?.();
5322
+ if (!stClient || !trustBase) return result;
5323
+ const signingService = await this.createSigningService();
5324
+ for (const [tokenId, token] of this.tokens) {
5325
+ if (token.status !== "submitted") continue;
5326
+ const pending2 = this.parsePendingFinalization(token.sdkData);
5327
+ if (!pending2) {
5328
+ result.stillPending++;
5329
+ continue;
5330
+ }
5331
+ if (pending2.type === "v5_bundle") {
5332
+ const progress = await this.resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService);
5333
+ result.details.push({ tokenId, stage: pending2.stage, status: progress });
5334
+ if (progress === "resolved") result.resolved++;
5335
+ else if (progress === "failed") result.failed++;
5336
+ else result.stillPending++;
5337
+ }
5338
+ }
5339
+ if (result.resolved > 0 || result.failed > 0) {
5340
+ await this.save();
5341
+ }
5342
+ return result;
5343
+ }
5344
+ // ===========================================================================
5345
+ // Private - V5 Lazy Resolution Helpers
5346
+ // ===========================================================================
5347
+ /**
5348
+ * Process a single V5 token through its finalization stages with quick-timeout proof checks.
5349
+ */
5350
+ async resolveV5Token(tokenId, token, pending2, stClient, trustBase, signingService) {
5351
+ const bundle = JSON.parse(pending2.bundleJson);
5352
+ pending2.attemptCount++;
5353
+ pending2.lastAttemptAt = Date.now();
5354
+ try {
5355
+ if (pending2.stage === "RECEIVED") {
5356
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5357
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5358
+ const mintCommitment = await MintCommitment3.create(mintData);
5359
+ const mintResponse = await stClient.submitMintCommitment(mintCommitment);
5360
+ if (mintResponse.status !== "SUCCESS" && mintResponse.status !== "REQUEST_ID_EXISTS") {
5361
+ throw new Error(`Mint submission failed: ${mintResponse.status}`);
5362
+ }
5363
+ pending2.stage = "MINT_SUBMITTED";
5364
+ this.updatePendingFinalization(token, pending2);
5365
+ }
5366
+ if (pending2.stage === "MINT_SUBMITTED") {
5367
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5368
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5369
+ const mintCommitment = await MintCommitment3.create(mintData);
5370
+ const proof = await this.quickProofCheck(stClient, trustBase, mintCommitment);
5371
+ if (!proof) {
5372
+ this.updatePendingFinalization(token, pending2);
5373
+ return "pending";
5374
+ }
5375
+ pending2.mintProofJson = JSON.stringify(proof);
5376
+ pending2.stage = "MINT_PROVEN";
5377
+ this.updatePendingFinalization(token, pending2);
5378
+ }
5379
+ if (pending2.stage === "MINT_PROVEN") {
5380
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5381
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5382
+ const transferResponse = await stClient.submitTransferCommitment(transferCommitment);
5383
+ if (transferResponse.status !== "SUCCESS" && transferResponse.status !== "REQUEST_ID_EXISTS") {
5384
+ throw new Error(`Transfer submission failed: ${transferResponse.status}`);
5385
+ }
5386
+ pending2.stage = "TRANSFER_SUBMITTED";
5387
+ this.updatePendingFinalization(token, pending2);
5388
+ }
5389
+ if (pending2.stage === "TRANSFER_SUBMITTED") {
5390
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5391
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5392
+ const proof = await this.quickProofCheck(stClient, trustBase, transferCommitment);
5393
+ if (!proof) {
5394
+ this.updatePendingFinalization(token, pending2);
5395
+ return "pending";
5396
+ }
5397
+ const finalizedToken = await this.finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase);
5398
+ const confirmedToken = {
5399
+ id: token.id,
5400
+ coinId: token.coinId,
5401
+ symbol: token.symbol,
5402
+ name: token.name,
5403
+ decimals: token.decimals,
5404
+ iconUrl: token.iconUrl,
5405
+ amount: token.amount,
5406
+ status: "confirmed",
5407
+ createdAt: token.createdAt,
5408
+ updatedAt: Date.now(),
5409
+ sdkData: JSON.stringify(finalizedToken.toJSON())
5410
+ };
5411
+ this.tokens.set(tokenId, confirmedToken);
5412
+ await this.saveTokenToFileStorage(confirmedToken);
5413
+ await this.addToHistory({
5414
+ type: "RECEIVED",
5415
+ amount: confirmedToken.amount,
5416
+ coinId: confirmedToken.coinId,
5417
+ symbol: confirmedToken.symbol || "UNK",
5418
+ timestamp: Date.now(),
5419
+ senderPubkey: pending2.senderPubkey
5420
+ });
5421
+ this.log(`V5 token resolved: ${tokenId.slice(0, 8)}...`);
5422
+ return "resolved";
5423
+ }
5424
+ return "pending";
5425
+ } catch (error) {
5426
+ console.error(`[Payments] resolveV5Token failed for ${tokenId.slice(0, 8)}:`, error);
5427
+ if (pending2.attemptCount > 50) {
5428
+ token.status = "invalid";
5429
+ token.updatedAt = Date.now();
5430
+ this.tokens.set(tokenId, token);
5431
+ return "failed";
5432
+ }
5433
+ this.updatePendingFinalization(token, pending2);
5434
+ return "pending";
5435
+ }
5436
+ }
5437
+ /**
5438
+ * Non-blocking proof check with 500ms timeout.
5439
+ */
5440
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5441
+ async quickProofCheck(stClient, trustBase, commitment, timeoutMs = 500) {
5442
+ try {
5443
+ const proof = await Promise.race([
5444
+ waitInclusionProof5(trustBase, stClient, commitment),
5445
+ new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
5446
+ ]);
5447
+ return proof;
5448
+ } catch {
5449
+ return null;
5450
+ }
5451
+ }
5452
+ /**
5453
+ * Perform V5 bundle finalization from stored bundle data and proofs.
5454
+ * Extracted from InstantSplitProcessor.processV5Bundle() steps 4-10.
5455
+ */
5456
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5457
+ async finalizeFromV5Bundle(bundle, pending2, signingService, stClient, trustBase) {
5458
+ const mintDataJson = JSON.parse(bundle.recipientMintData);
5459
+ const mintData = await MintTransactionData3.fromJSON(mintDataJson);
5460
+ const mintCommitment = await MintCommitment3.create(mintData);
5461
+ const mintProofJson = JSON.parse(pending2.mintProofJson);
5462
+ const mintProof = InclusionProof.fromJSON(mintProofJson);
5463
+ const mintTransaction = mintCommitment.toTransaction(mintProof);
5464
+ const tokenType = new TokenType3(fromHex4(bundle.tokenTypeHex));
5465
+ const senderMintedStateJson = JSON.parse(bundle.mintedTokenStateJson);
5466
+ const tokenJson = {
5467
+ version: "2.0",
5468
+ state: senderMintedStateJson,
5469
+ genesis: mintTransaction.toJSON(),
5470
+ transactions: [],
5471
+ nametags: []
5472
+ };
5473
+ const mintedToken = await SdkToken2.fromJSON(tokenJson);
5474
+ const transferCommitmentJson = JSON.parse(bundle.transferCommitment);
5475
+ const transferCommitment = await TransferCommitment4.fromJSON(transferCommitmentJson);
5476
+ const transferProof = await waitInclusionProof5(trustBase, stClient, transferCommitment);
5477
+ const transferTransaction = transferCommitment.toTransaction(transferProof);
5478
+ const transferSalt = fromHex4(bundle.transferSaltHex);
5479
+ const recipientPredicate = await UnmaskedPredicate5.create(
5480
+ mintData.tokenId,
5481
+ tokenType,
5482
+ signingService,
5483
+ HashAlgorithm5.SHA256,
5484
+ transferSalt
5485
+ );
5486
+ const recipientState = new TokenState5(recipientPredicate, null);
5487
+ let nametagTokens = [];
5488
+ const recipientAddressStr = bundle.recipientAddressJson;
5489
+ if (recipientAddressStr.startsWith("PROXY://")) {
5490
+ if (bundle.nametagTokenJson) {
5491
+ try {
5492
+ const nametagToken = await SdkToken2.fromJSON(JSON.parse(bundle.nametagTokenJson));
5493
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5494
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5495
+ if (proxy.address === recipientAddressStr) {
5496
+ nametagTokens = [nametagToken];
5497
+ }
5498
+ } catch {
5499
+ }
5500
+ }
5501
+ if (nametagTokens.length === 0 && this.nametag?.token) {
5502
+ try {
5503
+ const nametagToken = await SdkToken2.fromJSON(this.nametag.token);
5504
+ const { ProxyAddress } = await import("@unicitylabs/state-transition-sdk/lib/address/ProxyAddress");
5505
+ const proxy = await ProxyAddress.fromTokenId(nametagToken.id);
5506
+ if (proxy.address === recipientAddressStr) {
5507
+ nametagTokens = [nametagToken];
5508
+ }
5509
+ } catch {
5510
+ }
5511
+ }
5512
+ }
5513
+ return stClient.finalizeTransaction(trustBase, mintedToken, recipientState, transferTransaction, nametagTokens);
5514
+ }
5515
+ /**
5516
+ * Parse pending finalization metadata from token's sdkData.
5517
+ */
5518
+ parsePendingFinalization(sdkData) {
5519
+ if (!sdkData) return null;
5520
+ try {
5521
+ const data = JSON.parse(sdkData);
5522
+ if (data._pendingFinalization && data._pendingFinalization.type === "v5_bundle") {
5523
+ return data._pendingFinalization;
5524
+ }
5525
+ return null;
5526
+ } catch {
5527
+ return null;
5528
+ }
5529
+ }
5530
+ /**
5531
+ * Update pending finalization metadata in token's sdkData.
5532
+ * Creates a new token object since sdkData is readonly.
5533
+ */
5534
+ updatePendingFinalization(token, pending2) {
5535
+ const updated = {
5536
+ id: token.id,
5537
+ coinId: token.coinId,
5538
+ symbol: token.symbol,
5539
+ name: token.name,
5540
+ decimals: token.decimals,
5541
+ iconUrl: token.iconUrl,
5542
+ amount: token.amount,
5543
+ status: token.status,
5544
+ createdAt: token.createdAt,
5545
+ updatedAt: Date.now(),
5546
+ sdkData: JSON.stringify({ _pendingFinalization: pending2 })
5547
+ };
5548
+ this.tokens.set(token.id, updated);
5549
+ }
5550
+ /**
5551
+ * Save pending V5 tokens to key-value storage.
5552
+ * These tokens can't be serialized to TXF format (no genesis/state),
5553
+ * so we persist them separately and restore on load().
5554
+ */
5555
+ async savePendingV5Tokens() {
5556
+ const pendingTokens = [];
5557
+ for (const token of this.tokens.values()) {
5558
+ if (this.parsePendingFinalization(token.sdkData)) {
5559
+ pendingTokens.push(token);
5560
+ }
5561
+ }
5562
+ if (pendingTokens.length > 0) {
5563
+ await this.deps.storage.set(
5564
+ STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS,
5565
+ JSON.stringify(pendingTokens)
5566
+ );
5567
+ } else {
5568
+ await this.deps.storage.set(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS, "");
5569
+ }
5570
+ }
5571
+ /**
5572
+ * Load pending V5 tokens from key-value storage and merge into tokens map.
5573
+ * Called during load() to restore tokens that TXF format can't represent.
5574
+ */
5575
+ async loadPendingV5Tokens() {
5576
+ const data = await this.deps.storage.get(STORAGE_KEYS_ADDRESS.PENDING_V5_TOKENS);
5577
+ if (!data) return;
5578
+ try {
5579
+ const pendingTokens = JSON.parse(data);
5580
+ for (const token of pendingTokens) {
5581
+ if (!this.tokens.has(token.id)) {
5582
+ this.tokens.set(token.id, token);
5583
+ }
5584
+ }
5585
+ if (pendingTokens.length > 0) {
5586
+ this.log(`Restored ${pendingTokens.length} pending V5 token(s)`);
5587
+ }
5588
+ } catch {
5589
+ }
5590
+ }
5591
+ // ===========================================================================
4919
5592
  // Public API - Token Operations
4920
5593
  // ===========================================================================
4921
5594
  /**
4922
- * Add a token
4923
- * Tokens are uniquely identified by (tokenId, stateHash) composite key.
4924
- * Multiple historic states of the same token can coexist.
4925
- * @returns false if exact duplicate (same tokenId AND same stateHash)
5595
+ * Add a token to the wallet.
5596
+ *
5597
+ * Tokens are uniquely identified by a `(tokenId, stateHash)` composite key.
5598
+ * Duplicate detection:
5599
+ * - **Tombstoned** — rejected if the exact `(tokenId, stateHash)` pair has a tombstone.
5600
+ * - **Exact duplicate** — rejected if a token with the same composite key already exists.
5601
+ * - **State replacement** — if the same `tokenId` exists with a *different* `stateHash`,
5602
+ * the old state is archived and replaced with the incoming one.
5603
+ *
5604
+ * @param token - The token to add.
5605
+ * @param skipHistory - When `true`, do not create a `RECEIVED` transaction history entry (default `false`).
5606
+ * @returns `true` if the token was added, `false` if rejected as duplicate or tombstoned.
4926
5607
  */
4927
5608
  async addToken(token, skipHistory = false) {
4928
5609
  this.ensureInitialized();
@@ -4980,7 +5661,9 @@ var PaymentsModule = class _PaymentsModule {
4980
5661
  });
4981
5662
  }
4982
5663
  await this.save();
4983
- await this.saveTokenToFileStorage(token);
5664
+ if (!this.parsePendingFinalization(token.sdkData)) {
5665
+ await this.saveTokenToFileStorage(token);
5666
+ }
4984
5667
  this.log(`Added token ${token.id}, total: ${this.tokens.size}`);
4985
5668
  return true;
4986
5669
  }
@@ -5037,6 +5720,9 @@ var PaymentsModule = class _PaymentsModule {
5037
5720
  const data = fileData;
5038
5721
  const tokenJson = data.token;
5039
5722
  if (!tokenJson) continue;
5723
+ if (typeof tokenJson === "object" && tokenJson !== null && "_pendingFinalization" in tokenJson) {
5724
+ continue;
5725
+ }
5040
5726
  let sdkTokenId;
5041
5727
  if (typeof tokenJson === "object" && tokenJson !== null) {
5042
5728
  const tokenObj = tokenJson;
@@ -5088,7 +5774,12 @@ var PaymentsModule = class _PaymentsModule {
5088
5774
  this.log(`Loaded ${this.tokens.size} tokens from file storage`);
5089
5775
  }
5090
5776
  /**
5091
- * Update an existing token
5777
+ * Update an existing token or add it if not found.
5778
+ *
5779
+ * Looks up the token by genesis `tokenId` (from `sdkData`) first, then by
5780
+ * `token.id`. If no match is found, falls back to {@link addToken}.
5781
+ *
5782
+ * @param token - The token with updated data. Must include a valid `id`.
5092
5783
  */
5093
5784
  async updateToken(token) {
5094
5785
  this.ensureInitialized();
@@ -5112,7 +5803,15 @@ var PaymentsModule = class _PaymentsModule {
5112
5803
  this.log(`Updated token ${token.id}`);
5113
5804
  }
5114
5805
  /**
5115
- * Remove a token by ID
5806
+ * Remove a token from the wallet.
5807
+ *
5808
+ * The token is archived first, then a tombstone `(tokenId, stateHash)` is
5809
+ * created to prevent re-addition via Nostr re-delivery. A `SENT` history
5810
+ * entry is created unless `skipHistory` is `true`.
5811
+ *
5812
+ * @param tokenId - Local UUID of the token to remove.
5813
+ * @param recipientNametag - Optional nametag of the transfer recipient (for history).
5814
+ * @param skipHistory - When `true`, skip creating a transaction history entry (default `false`).
5116
5815
  */
5117
5816
  async removeToken(tokenId, recipientNametag, skipHistory = false) {
5118
5817
  this.ensureInitialized();
@@ -5174,13 +5873,22 @@ var PaymentsModule = class _PaymentsModule {
5174
5873
  // Public API - Tombstones
5175
5874
  // ===========================================================================
5176
5875
  /**
5177
- * Get all tombstones
5876
+ * Get all tombstone entries.
5877
+ *
5878
+ * Each tombstone is keyed by `(tokenId, stateHash)` and prevents a spent
5879
+ * token state from being re-added (e.g. via Nostr re-delivery).
5880
+ *
5881
+ * @returns A shallow copy of the tombstone array.
5178
5882
  */
5179
5883
  getTombstones() {
5180
5884
  return [...this.tombstones];
5181
5885
  }
5182
5886
  /**
5183
- * Check if token state is tombstoned
5887
+ * Check whether a specific `(tokenId, stateHash)` combination is tombstoned.
5888
+ *
5889
+ * @param tokenId - The genesis token ID.
5890
+ * @param stateHash - The state hash of the token version to check.
5891
+ * @returns `true` if the exact combination has been tombstoned.
5184
5892
  */
5185
5893
  isStateTombstoned(tokenId, stateHash) {
5186
5894
  return this.tombstones.some(
@@ -5188,8 +5896,13 @@ var PaymentsModule = class _PaymentsModule {
5188
5896
  );
5189
5897
  }
5190
5898
  /**
5191
- * Merge remote tombstones
5192
- * @returns number of local tokens removed
5899
+ * Merge tombstones received from a remote sync source.
5900
+ *
5901
+ * Any local token whose `(tokenId, stateHash)` matches a remote tombstone is
5902
+ * removed. The remote tombstones are then added to the local set (union merge).
5903
+ *
5904
+ * @param remoteTombstones - Tombstone entries from the remote source.
5905
+ * @returns Number of local tokens that were removed.
5193
5906
  */
5194
5907
  async mergeTombstones(remoteTombstones) {
5195
5908
  this.ensureInitialized();
@@ -5225,7 +5938,9 @@ var PaymentsModule = class _PaymentsModule {
5225
5938
  return removedCount;
5226
5939
  }
5227
5940
  /**
5228
- * Prune old tombstones
5941
+ * Remove tombstones older than `maxAge` and cap the list at 100 entries.
5942
+ *
5943
+ * @param maxAge - Maximum age in milliseconds (default: 30 days).
5229
5944
  */
5230
5945
  async pruneTombstones(maxAge) {
5231
5946
  const originalCount = this.tombstones.length;
@@ -5239,20 +5954,38 @@ var PaymentsModule = class _PaymentsModule {
5239
5954
  // Public API - Archives
5240
5955
  // ===========================================================================
5241
5956
  /**
5242
- * Get archived tokens
5957
+ * Get all archived (spent/superseded) tokens in TXF format.
5958
+ *
5959
+ * Archived tokens are kept for recovery and sync purposes. The map key is
5960
+ * the genesis token ID.
5961
+ *
5962
+ * @returns A shallow copy of the archived token map.
5243
5963
  */
5244
5964
  getArchivedTokens() {
5245
5965
  return new Map(this.archivedTokens);
5246
5966
  }
5247
5967
  /**
5248
- * Get best archived version of a token
5968
+ * Get the best (most committed transactions) archived version of a token.
5969
+ *
5970
+ * Searches both archived and forked token maps and returns the version with
5971
+ * the highest number of committed transactions.
5972
+ *
5973
+ * @param tokenId - The genesis token ID to look up.
5974
+ * @returns The best TXF token version, or `null` if not found.
5249
5975
  */
5250
5976
  getBestArchivedVersion(tokenId) {
5251
5977
  return findBestTokenVersion(tokenId, this.archivedTokens, this.forkedTokens);
5252
5978
  }
5253
5979
  /**
5254
- * Merge remote archived tokens
5255
- * @returns number of tokens updated/added
5980
+ * Merge archived tokens from a remote sync source.
5981
+ *
5982
+ * For each remote token:
5983
+ * - If missing locally, it is added.
5984
+ * - If the remote version is an incremental update of the local, it replaces it.
5985
+ * - If the histories diverge (fork), the remote version is stored via {@link storeForkedToken}.
5986
+ *
5987
+ * @param remoteArchived - Map of genesis token ID → TXF token from remote.
5988
+ * @returns Number of tokens that were updated or added locally.
5256
5989
  */
5257
5990
  async mergeArchivedTokens(remoteArchived) {
5258
5991
  let mergedCount = 0;
@@ -5275,7 +6008,11 @@ var PaymentsModule = class _PaymentsModule {
5275
6008
  return mergedCount;
5276
6009
  }
5277
6010
  /**
5278
- * Prune archived tokens
6011
+ * Prune archived tokens to keep at most `maxCount` entries.
6012
+ *
6013
+ * Oldest entries (by insertion order) are removed first.
6014
+ *
6015
+ * @param maxCount - Maximum number of archived tokens to retain (default: 100).
5279
6016
  */
5280
6017
  async pruneArchivedTokens(maxCount = 100) {
5281
6018
  if (this.archivedTokens.size <= maxCount) return;
@@ -5288,13 +6025,24 @@ var PaymentsModule = class _PaymentsModule {
5288
6025
  // Public API - Forked Tokens
5289
6026
  // ===========================================================================
5290
6027
  /**
5291
- * Get forked tokens
6028
+ * Get all forked token versions.
6029
+ *
6030
+ * Forked tokens represent alternative histories detected during sync.
6031
+ * The map key is `{tokenId}_{stateHash}`.
6032
+ *
6033
+ * @returns A shallow copy of the forked tokens map.
5292
6034
  */
5293
6035
  getForkedTokens() {
5294
6036
  return new Map(this.forkedTokens);
5295
6037
  }
5296
6038
  /**
5297
- * Store a forked token
6039
+ * Store a forked token version (alternative history).
6040
+ *
6041
+ * No-op if the exact `(tokenId, stateHash)` key already exists.
6042
+ *
6043
+ * @param tokenId - Genesis token ID.
6044
+ * @param stateHash - State hash of this forked version.
6045
+ * @param txfToken - The TXF token data to store.
5298
6046
  */
5299
6047
  async storeForkedToken(tokenId, stateHash, txfToken) {
5300
6048
  const key = `${tokenId}_${stateHash}`;
@@ -5304,8 +6052,10 @@ var PaymentsModule = class _PaymentsModule {
5304
6052
  await this.save();
5305
6053
  }
5306
6054
  /**
5307
- * Merge remote forked tokens
5308
- * @returns number of tokens added
6055
+ * Merge forked tokens from a remote sync source. Only new keys are added.
6056
+ *
6057
+ * @param remoteForked - Map of `{tokenId}_{stateHash}` → TXF token from remote.
6058
+ * @returns Number of new forked tokens added.
5309
6059
  */
5310
6060
  async mergeForkedTokens(remoteForked) {
5311
6061
  let addedCount = 0;
@@ -5321,7 +6071,9 @@ var PaymentsModule = class _PaymentsModule {
5321
6071
  return addedCount;
5322
6072
  }
5323
6073
  /**
5324
- * Prune forked tokens
6074
+ * Prune forked tokens to keep at most `maxCount` entries.
6075
+ *
6076
+ * @param maxCount - Maximum number of forked tokens to retain (default: 50).
5325
6077
  */
5326
6078
  async pruneForkedTokens(maxCount = 50) {
5327
6079
  if (this.forkedTokens.size <= maxCount) return;
@@ -5334,13 +6086,19 @@ var PaymentsModule = class _PaymentsModule {
5334
6086
  // Public API - Transaction History
5335
6087
  // ===========================================================================
5336
6088
  /**
5337
- * Get transaction history
6089
+ * Get the transaction history sorted newest-first.
6090
+ *
6091
+ * @returns Array of {@link TransactionHistoryEntry} objects in descending timestamp order.
5338
6092
  */
5339
6093
  getHistory() {
5340
6094
  return [...this.transactionHistory].sort((a, b) => b.timestamp - a.timestamp);
5341
6095
  }
5342
6096
  /**
5343
- * Add to transaction history
6097
+ * Append an entry to the transaction history.
6098
+ *
6099
+ * A unique `id` is auto-generated. The entry is immediately persisted to storage.
6100
+ *
6101
+ * @param entry - History entry fields (without `id`).
5344
6102
  */
5345
6103
  async addToHistory(entry) {
5346
6104
  this.ensureInitialized();
@@ -5358,7 +6116,11 @@ var PaymentsModule = class _PaymentsModule {
5358
6116
  // Public API - Nametag
5359
6117
  // ===========================================================================
5360
6118
  /**
5361
- * Set nametag for current identity
6119
+ * Set the nametag data for the current identity.
6120
+ *
6121
+ * Persists to both key-value storage and file storage (lottery compatibility).
6122
+ *
6123
+ * @param nametag - The nametag data including minted token JSON.
5362
6124
  */
5363
6125
  async setNametag(nametag) {
5364
6126
  this.ensureInitialized();
@@ -5368,19 +6130,23 @@ var PaymentsModule = class _PaymentsModule {
5368
6130
  this.log(`Nametag set: ${nametag.name}`);
5369
6131
  }
5370
6132
  /**
5371
- * Get nametag
6133
+ * Get the current nametag data.
6134
+ *
6135
+ * @returns The nametag data, or `null` if no nametag is set.
5372
6136
  */
5373
6137
  getNametag() {
5374
6138
  return this.nametag;
5375
6139
  }
5376
6140
  /**
5377
- * Check if has nametag
6141
+ * Check whether a nametag is currently set.
6142
+ *
6143
+ * @returns `true` if nametag data is present.
5378
6144
  */
5379
6145
  hasNametag() {
5380
6146
  return this.nametag !== null;
5381
6147
  }
5382
6148
  /**
5383
- * Clear nametag
6149
+ * Remove the current nametag data from memory and storage.
5384
6150
  */
5385
6151
  async clearNametag() {
5386
6152
  this.ensureInitialized();
@@ -5474,9 +6240,9 @@ var PaymentsModule = class _PaymentsModule {
5474
6240
  try {
5475
6241
  const signingService = await this.createSigningService();
5476
6242
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5477
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6243
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
5478
6244
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5479
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6245
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5480
6246
  const addressRef = await UnmaskedPredicateReference4.create(
5481
6247
  tokenType,
5482
6248
  signingService.algorithm,
@@ -5537,11 +6303,27 @@ var PaymentsModule = class _PaymentsModule {
5537
6303
  // Public API - Sync & Validate
5538
6304
  // ===========================================================================
5539
6305
  /**
5540
- * Sync with all token storage providers (IPFS, MongoDB, etc.)
5541
- * Syncs with each provider and merges results
6306
+ * Sync local token state with all configured token storage providers (IPFS, file, etc.).
6307
+ *
6308
+ * For each provider, the local data is packaged into TXF storage format, sent
6309
+ * to the provider's `sync()` method, and the merged result is applied locally.
6310
+ * Emits `sync:started`, `sync:completed`, and `sync:error` events.
6311
+ *
6312
+ * @returns Summary with counts of tokens added and removed during sync.
5542
6313
  */
5543
6314
  async sync() {
5544
6315
  this.ensureInitialized();
6316
+ if (this._syncInProgress) {
6317
+ return this._syncInProgress;
6318
+ }
6319
+ this._syncInProgress = this._doSync();
6320
+ try {
6321
+ return await this._syncInProgress;
6322
+ } finally {
6323
+ this._syncInProgress = null;
6324
+ }
6325
+ }
6326
+ async _doSync() {
5545
6327
  this.deps.emitEvent("sync:started", { source: "payments" });
5546
6328
  try {
5547
6329
  const providers = this.getTokenStorageProviders();
@@ -5579,6 +6361,9 @@ var PaymentsModule = class _PaymentsModule {
5579
6361
  });
5580
6362
  }
5581
6363
  }
6364
+ if (totalAdded > 0 || totalRemoved > 0) {
6365
+ await this.save();
6366
+ }
5582
6367
  this.deps.emitEvent("sync:completed", {
5583
6368
  source: "payments",
5584
6369
  count: this.tokens.size
@@ -5592,6 +6377,66 @@ var PaymentsModule = class _PaymentsModule {
5592
6377
  throw error;
5593
6378
  }
5594
6379
  }
6380
+ // ===========================================================================
6381
+ // Storage Event Subscription (Push-Based Sync)
6382
+ // ===========================================================================
6383
+ /**
6384
+ * Subscribe to 'storage:remote-updated' events from all token storage providers.
6385
+ * When a provider emits this event, a debounced sync is triggered.
6386
+ */
6387
+ subscribeToStorageEvents() {
6388
+ this.unsubscribeStorageEvents();
6389
+ const providers = this.getTokenStorageProviders();
6390
+ for (const [providerId, provider] of providers) {
6391
+ if (provider.onEvent) {
6392
+ const unsub = provider.onEvent((event) => {
6393
+ if (event.type === "storage:remote-updated") {
6394
+ this.log("Remote update detected from provider", providerId, event.data);
6395
+ this.debouncedSyncFromRemoteUpdate(providerId, event.data);
6396
+ }
6397
+ });
6398
+ this.storageEventUnsubscribers.push(unsub);
6399
+ }
6400
+ }
6401
+ }
6402
+ /**
6403
+ * Unsubscribe from all storage provider events and clear debounce timer.
6404
+ */
6405
+ unsubscribeStorageEvents() {
6406
+ for (const unsub of this.storageEventUnsubscribers) {
6407
+ unsub();
6408
+ }
6409
+ this.storageEventUnsubscribers = [];
6410
+ if (this.syncDebounceTimer) {
6411
+ clearTimeout(this.syncDebounceTimer);
6412
+ this.syncDebounceTimer = null;
6413
+ }
6414
+ }
6415
+ /**
6416
+ * Debounced sync triggered by a storage:remote-updated event.
6417
+ * Waits 500ms to batch rapid updates, then performs sync.
6418
+ */
6419
+ debouncedSyncFromRemoteUpdate(providerId, eventData) {
6420
+ if (this.syncDebounceTimer) {
6421
+ clearTimeout(this.syncDebounceTimer);
6422
+ }
6423
+ this.syncDebounceTimer = setTimeout(() => {
6424
+ this.syncDebounceTimer = null;
6425
+ this.sync().then((result) => {
6426
+ const data = eventData;
6427
+ this.deps?.emitEvent("sync:remote-update", {
6428
+ providerId,
6429
+ name: data?.name ?? "",
6430
+ sequence: data?.sequence ?? 0,
6431
+ cid: data?.cid ?? "",
6432
+ added: result.added,
6433
+ removed: result.removed
6434
+ });
6435
+ }).catch((err) => {
6436
+ this.log("Auto-sync from remote update failed:", err);
6437
+ });
6438
+ }, _PaymentsModule.SYNC_DEBOUNCE_MS);
6439
+ }
5595
6440
  /**
5596
6441
  * Get all active token storage providers
5597
6442
  */
@@ -5607,15 +6452,24 @@ var PaymentsModule = class _PaymentsModule {
5607
6452
  return /* @__PURE__ */ new Map();
5608
6453
  }
5609
6454
  /**
5610
- * Update token storage providers (called when providers are added/removed dynamically)
6455
+ * Replace the set of token storage providers at runtime.
6456
+ *
6457
+ * Use when providers are added or removed dynamically (e.g. IPFS node started).
6458
+ *
6459
+ * @param providers - New map of provider ID → TokenStorageProvider.
5611
6460
  */
5612
6461
  updateTokenStorageProviders(providers) {
5613
6462
  if (this.deps) {
5614
6463
  this.deps.tokenStorageProviders = providers;
6464
+ this.subscribeToStorageEvents();
5615
6465
  }
5616
6466
  }
5617
6467
  /**
5618
- * Validate tokens with aggregator
6468
+ * Validate all tokens against the aggregator (oracle provider).
6469
+ *
6470
+ * Tokens that fail validation or are detected as spent are marked `'invalid'`.
6471
+ *
6472
+ * @returns Object with arrays of valid and invalid tokens.
5619
6473
  */
5620
6474
  async validate() {
5621
6475
  this.ensureInitialized();
@@ -5636,7 +6490,9 @@ var PaymentsModule = class _PaymentsModule {
5636
6490
  return { valid, invalid };
5637
6491
  }
5638
6492
  /**
5639
- * Get pending transfers
6493
+ * Get all in-progress (pending) outgoing transfers.
6494
+ *
6495
+ * @returns Array of {@link TransferResult} objects for transfers that have not yet completed.
5640
6496
  */
5641
6497
  getPendingTransfers() {
5642
6498
  return Array.from(this.pendingTransfers.values());
@@ -5700,9 +6556,9 @@ var PaymentsModule = class _PaymentsModule {
5700
6556
  */
5701
6557
  async createDirectAddressFromPubkey(pubkeyHex) {
5702
6558
  const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
5703
- const { TokenType: TokenType5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
6559
+ const { TokenType: TokenType6 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenType");
5704
6560
  const UNICITY_TOKEN_TYPE_HEX3 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
5705
- const tokenType = new TokenType5(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
6561
+ const tokenType = new TokenType6(Buffer.from(UNICITY_TOKEN_TYPE_HEX3, "hex"));
5706
6562
  const pubkeyBytes = new Uint8Array(
5707
6563
  pubkeyHex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
5708
6564
  );
@@ -5914,7 +6770,8 @@ var PaymentsModule = class _PaymentsModule {
5914
6770
  this.deps.emitEvent("transfer:confirmed", {
5915
6771
  id: crypto.randomUUID(),
5916
6772
  status: "completed",
5917
- tokens: [finalizedToken]
6773
+ tokens: [finalizedToken],
6774
+ tokenTransfers: []
5918
6775
  });
5919
6776
  await this.addToHistory({
5920
6777
  type: "RECEIVED",
@@ -5937,14 +6794,26 @@ var PaymentsModule = class _PaymentsModule {
5937
6794
  async handleIncomingTransfer(transfer) {
5938
6795
  try {
5939
6796
  const payload = transfer.payload;
6797
+ let instantBundle = null;
5940
6798
  if (isInstantSplitBundle(payload)) {
6799
+ instantBundle = payload;
6800
+ } else if (payload.token) {
6801
+ try {
6802
+ const inner = typeof payload.token === "string" ? JSON.parse(payload.token) : payload.token;
6803
+ if (isInstantSplitBundle(inner)) {
6804
+ instantBundle = inner;
6805
+ }
6806
+ } catch {
6807
+ }
6808
+ }
6809
+ if (instantBundle) {
5941
6810
  this.log("Processing INSTANT_SPLIT bundle...");
5942
6811
  try {
5943
6812
  if (!this.nametag) {
5944
6813
  await this.loadNametagFromFileStorage();
5945
6814
  }
5946
6815
  const result = await this.processInstantSplitBundle(
5947
- payload,
6816
+ instantBundle,
5948
6817
  transfer.senderTransportPubkey
5949
6818
  );
5950
6819
  if (result.success) {
@@ -5957,6 +6826,11 @@ var PaymentsModule = class _PaymentsModule {
5957
6826
  }
5958
6827
  return;
5959
6828
  }
6829
+ if (payload.sourceToken && payload.commitmentData && !payload.transferTx) {
6830
+ this.log("Processing NOSTR-FIRST commitment-only transfer...");
6831
+ await this.handleCommitmentOnlyTransfer(transfer, payload);
6832
+ return;
6833
+ }
5960
6834
  let tokenData;
5961
6835
  let finalizedSdkToken = null;
5962
6836
  if (payload.sourceToken && payload.transferTx) {
@@ -6112,6 +6986,7 @@ var PaymentsModule = class _PaymentsModule {
6112
6986
  console.error(`[Payments] Failed to save to provider ${id}:`, err);
6113
6987
  }
6114
6988
  }
6989
+ await this.savePendingV5Tokens();
6115
6990
  }
6116
6991
  async saveToOutbox(transfer, recipient) {
6117
6992
  const outbox = await this.loadOutbox();
@@ -6129,8 +7004,7 @@ var PaymentsModule = class _PaymentsModule {
6129
7004
  }
6130
7005
  async createStorageData() {
6131
7006
  return await buildTxfStorageData(
6132
- [],
6133
- // Empty - active tokens stored as token-xxx files
7007
+ Array.from(this.tokens.values()),
6134
7008
  {
6135
7009
  version: 1,
6136
7010
  address: this.deps.identity.l1Address,
@@ -6315,7 +7189,7 @@ function createPaymentsModule(config) {
6315
7189
  // modules/payments/TokenRecoveryService.ts
6316
7190
  import { TokenId as TokenId4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenId";
6317
7191
  import { TokenState as TokenState6 } from "@unicitylabs/state-transition-sdk/lib/token/TokenState";
6318
- import { TokenType as TokenType3 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
7192
+ import { TokenType as TokenType4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
6319
7193
  import { CoinId as CoinId5 } from "@unicitylabs/state-transition-sdk/lib/token/fungible/CoinId";
6320
7194
  import { HashAlgorithm as HashAlgorithm6 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
6321
7195
  import { UnmaskedPredicate as UnmaskedPredicate6 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
@@ -7464,15 +8338,20 @@ async function parseAndDecryptWalletDat(data, password, onProgress) {
7464
8338
 
7465
8339
  // core/Sphere.ts
7466
8340
  import { SigningService as SigningService2 } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService";
7467
- import { TokenType as TokenType4 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
8341
+ import { TokenType as TokenType5 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
7468
8342
  import { HashAlgorithm as HashAlgorithm7 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
7469
8343
  import { UnmaskedPredicateReference as UnmaskedPredicateReference3 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference";
8344
+ import { normalizeNametag as normalizeNametag2, isPhoneNumber } from "@unicitylabs/nostr-js-sdk";
8345
+ function isValidNametag(nametag) {
8346
+ if (isPhoneNumber(nametag)) return true;
8347
+ return /^[a-z0-9_-]{3,20}$/.test(nametag);
8348
+ }
7470
8349
  var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
7471
8350
  async function deriveL3PredicateAddress(privateKey) {
7472
8351
  const secret = Buffer.from(privateKey, "hex");
7473
8352
  const signingService = await SigningService2.createFromSecret(secret);
7474
8353
  const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX2, "hex");
7475
- const tokenType = new TokenType4(tokenTypeBytes);
8354
+ const tokenType = new TokenType5(tokenTypeBytes);
7476
8355
  const predicateRef = UnmaskedPredicateReference3.create(
7477
8356
  tokenType,
7478
8357
  signingService.algorithm,
@@ -7638,8 +8517,8 @@ var Sphere = class _Sphere {
7638
8517
  if (options.nametag) {
7639
8518
  await sphere.registerNametag(options.nametag);
7640
8519
  } else {
7641
- await sphere.syncIdentityWithTransport();
7642
8520
  await sphere.recoverNametagFromTransport();
8521
+ await sphere.syncIdentityWithTransport();
7643
8522
  }
7644
8523
  return sphere;
7645
8524
  }
@@ -7686,9 +8565,14 @@ var Sphere = class _Sphere {
7686
8565
  if (!options.mnemonic && !options.masterKey) {
7687
8566
  throw new Error("Either mnemonic or masterKey is required");
7688
8567
  }
8568
+ console.log("[Sphere.import] Starting import...");
8569
+ console.log("[Sphere.import] Clearing existing wallet data...");
7689
8570
  await _Sphere.clear({ storage: options.storage, tokenStorage: options.tokenStorage });
8571
+ console.log("[Sphere.import] Clear done");
7690
8572
  if (!options.storage.isConnected()) {
8573
+ console.log("[Sphere.import] Reconnecting storage...");
7691
8574
  await options.storage.connect();
8575
+ console.log("[Sphere.import] Storage reconnected");
7692
8576
  }
7693
8577
  const sphere = new _Sphere(
7694
8578
  options.storage,
@@ -7702,9 +8586,12 @@ var Sphere = class _Sphere {
7702
8586
  if (!_Sphere.validateMnemonic(options.mnemonic)) {
7703
8587
  throw new Error("Invalid mnemonic");
7704
8588
  }
8589
+ console.log("[Sphere.import] Storing mnemonic...");
7705
8590
  await sphere.storeMnemonic(options.mnemonic, options.derivationPath, options.basePath);
8591
+ console.log("[Sphere.import] Initializing identity from mnemonic...");
7706
8592
  await sphere.initializeIdentityFromMnemonic(options.mnemonic, options.derivationPath);
7707
8593
  } else if (options.masterKey) {
8594
+ console.log("[Sphere.import] Storing master key...");
7708
8595
  await sphere.storeMasterKey(
7709
8596
  options.masterKey,
7710
8597
  options.chainCode,
@@ -7712,24 +8599,43 @@ var Sphere = class _Sphere {
7712
8599
  options.basePath,
7713
8600
  options.derivationMode
7714
8601
  );
8602
+ console.log("[Sphere.import] Initializing identity from master key...");
7715
8603
  await sphere.initializeIdentityFromMasterKey(
7716
8604
  options.masterKey,
7717
8605
  options.chainCode,
7718
8606
  options.derivationPath
7719
8607
  );
7720
8608
  }
8609
+ console.log("[Sphere.import] Initializing providers...");
7721
8610
  await sphere.initializeProviders();
8611
+ console.log("[Sphere.import] Providers initialized. Initializing modules...");
7722
8612
  await sphere.initializeModules();
8613
+ console.log("[Sphere.import] Modules initialized");
7723
8614
  if (!options.nametag) {
8615
+ console.log("[Sphere.import] Recovering nametag from transport...");
7724
8616
  await sphere.recoverNametagFromTransport();
8617
+ console.log("[Sphere.import] Nametag recovery done");
8618
+ await sphere.syncIdentityWithTransport();
7725
8619
  }
8620
+ console.log("[Sphere.import] Finalizing wallet creation...");
7726
8621
  await sphere.finalizeWalletCreation();
7727
8622
  sphere._initialized = true;
7728
8623
  _Sphere.instance = sphere;
8624
+ console.log("[Sphere.import] Tracking address 0...");
7729
8625
  await sphere.ensureAddressTracked(0);
7730
8626
  if (options.nametag) {
8627
+ console.log("[Sphere.import] Registering nametag...");
7731
8628
  await sphere.registerNametag(options.nametag);
7732
8629
  }
8630
+ if (sphere._tokenStorageProviders.size > 0) {
8631
+ try {
8632
+ const syncResult = await sphere._payments.sync();
8633
+ console.log(`[Sphere.import] Auto-sync: +${syncResult.added} -${syncResult.removed}`);
8634
+ } catch (err) {
8635
+ console.warn("[Sphere.import] Auto-sync failed (non-fatal):", err);
8636
+ }
8637
+ }
8638
+ console.log("[Sphere.import] Import complete");
7733
8639
  return sphere;
7734
8640
  }
7735
8641
  /**
@@ -7754,6 +8660,10 @@ var Sphere = class _Sphere {
7754
8660
  static async clear(storageOrOptions) {
7755
8661
  const storage = "get" in storageOrOptions ? storageOrOptions : storageOrOptions.storage;
7756
8662
  const tokenStorage = "get" in storageOrOptions ? void 0 : storageOrOptions.tokenStorage;
8663
+ if (!storage.isConnected()) {
8664
+ await storage.connect();
8665
+ }
8666
+ console.log("[Sphere.clear] Removing storage keys...");
7757
8667
  await storage.remove(STORAGE_KEYS_GLOBAL.MNEMONIC);
7758
8668
  await storage.remove(STORAGE_KEYS_GLOBAL.MASTER_KEY);
7759
8669
  await storage.remove(STORAGE_KEYS_GLOBAL.CHAIN_CODE);
@@ -7766,12 +8676,30 @@ var Sphere = class _Sphere {
7766
8676
  await storage.remove(STORAGE_KEYS_GLOBAL.ADDRESS_NAMETAGS);
7767
8677
  await storage.remove(STORAGE_KEYS_ADDRESS.PENDING_TRANSFERS);
7768
8678
  await storage.remove(STORAGE_KEYS_ADDRESS.OUTBOX);
8679
+ console.log("[Sphere.clear] Storage keys removed");
7769
8680
  if (tokenStorage?.clear) {
7770
- await tokenStorage.clear();
8681
+ console.log("[Sphere.clear] Clearing token storage...");
8682
+ try {
8683
+ await Promise.race([
8684
+ tokenStorage.clear(),
8685
+ new Promise(
8686
+ (_, reject) => setTimeout(() => reject(new Error("tokenStorage.clear() timed out after 2s")), 2e3)
8687
+ )
8688
+ ]);
8689
+ console.log("[Sphere.clear] Token storage cleared");
8690
+ } catch (err) {
8691
+ console.warn("[Sphere.clear] Token storage clear failed/timed out:", err);
8692
+ }
7771
8693
  }
8694
+ console.log("[Sphere.clear] Destroying vesting classifier...");
7772
8695
  await vestingClassifier.destroy();
8696
+ console.log("[Sphere.clear] Vesting classifier destroyed");
7773
8697
  if (_Sphere.instance) {
8698
+ console.log("[Sphere.clear] Destroying Sphere instance...");
7774
8699
  await _Sphere.instance.destroy();
8700
+ console.log("[Sphere.clear] Sphere instance destroyed");
8701
+ } else {
8702
+ console.log("[Sphere.clear] No Sphere instance to destroy");
7775
8703
  }
7776
8704
  }
7777
8705
  /**
@@ -8152,7 +9080,8 @@ var Sphere = class _Sphere {
8152
9080
  storage: options.storage,
8153
9081
  transport: options.transport,
8154
9082
  oracle: options.oracle,
8155
- tokenStorage: options.tokenStorage
9083
+ tokenStorage: options.tokenStorage,
9084
+ l1: options.l1
8156
9085
  });
8157
9086
  return { success: true, mnemonic };
8158
9087
  }
@@ -8165,7 +9094,8 @@ var Sphere = class _Sphere {
8165
9094
  storage: options.storage,
8166
9095
  transport: options.transport,
8167
9096
  oracle: options.oracle,
8168
- tokenStorage: options.tokenStorage
9097
+ tokenStorage: options.tokenStorage,
9098
+ l1: options.l1
8169
9099
  });
8170
9100
  return { success: true };
8171
9101
  }
@@ -8224,7 +9154,8 @@ var Sphere = class _Sphere {
8224
9154
  transport: options.transport,
8225
9155
  oracle: options.oracle,
8226
9156
  tokenStorage: options.tokenStorage,
8227
- nametag: options.nametag
9157
+ nametag: options.nametag,
9158
+ l1: options.l1
8228
9159
  });
8229
9160
  return { success: true, sphere, mnemonic };
8230
9161
  }
@@ -8253,7 +9184,8 @@ var Sphere = class _Sphere {
8253
9184
  transport: options.transport,
8254
9185
  oracle: options.oracle,
8255
9186
  tokenStorage: options.tokenStorage,
8256
- nametag: options.nametag
9187
+ nametag: options.nametag,
9188
+ l1: options.l1
8257
9189
  });
8258
9190
  return { success: true, sphere };
8259
9191
  }
@@ -8284,7 +9216,8 @@ var Sphere = class _Sphere {
8284
9216
  transport: options.transport,
8285
9217
  oracle: options.oracle,
8286
9218
  tokenStorage: options.tokenStorage,
8287
- nametag: options.nametag
9219
+ nametag: options.nametag,
9220
+ l1: options.l1
8288
9221
  });
8289
9222
  return { success: true, sphere };
8290
9223
  }
@@ -8303,7 +9236,8 @@ var Sphere = class _Sphere {
8303
9236
  storage: options.storage,
8304
9237
  transport: options.transport,
8305
9238
  oracle: options.oracle,
8306
- tokenStorage: options.tokenStorage
9239
+ tokenStorage: options.tokenStorage,
9240
+ l1: options.l1
8307
9241
  });
8308
9242
  if (result.success) {
8309
9243
  const sphere2 = _Sphere.getInstance();
@@ -8352,7 +9286,8 @@ var Sphere = class _Sphere {
8352
9286
  transport: options.transport,
8353
9287
  oracle: options.oracle,
8354
9288
  tokenStorage: options.tokenStorage,
8355
- nametag: options.nametag
9289
+ nametag: options.nametag,
9290
+ l1: options.l1
8356
9291
  });
8357
9292
  return { success: true, sphere: sphere2, mnemonic };
8358
9293
  }
@@ -8365,7 +9300,8 @@ var Sphere = class _Sphere {
8365
9300
  transport: options.transport,
8366
9301
  oracle: options.oracle,
8367
9302
  tokenStorage: options.tokenStorage,
8368
- nametag: options.nametag
9303
+ nametag: options.nametag,
9304
+ l1: options.l1
8369
9305
  });
8370
9306
  return { success: true, sphere };
8371
9307
  }
@@ -8569,9 +9505,9 @@ var Sphere = class _Sphere {
8569
9505
  if (index < 0) {
8570
9506
  throw new Error("Address index must be non-negative");
8571
9507
  }
8572
- const newNametag = options?.nametag?.startsWith("@") ? options.nametag.slice(1) : options?.nametag;
8573
- if (newNametag && !this.validateNametag(newNametag)) {
8574
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
9508
+ const newNametag = options?.nametag ? this.cleanNametag(options.nametag) : void 0;
9509
+ if (newNametag && !isValidNametag(newNametag)) {
9510
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
8575
9511
  }
8576
9512
  const addressInfo = this.deriveAddress(index, false);
8577
9513
  const ipnsHash = sha256(addressInfo.publicKey, "hex").slice(0, 40);
@@ -8955,9 +9891,9 @@ var Sphere = class _Sphere {
8955
9891
  */
8956
9892
  async registerNametag(nametag) {
8957
9893
  this.ensureReady();
8958
- const cleanNametag = nametag.startsWith("@") ? nametag.slice(1) : nametag;
8959
- if (!this.validateNametag(cleanNametag)) {
8960
- throw new Error("Invalid nametag format. Use alphanumeric characters, 3-20 chars.");
9894
+ const cleanNametag = this.cleanNametag(nametag);
9895
+ if (!isValidNametag(cleanNametag)) {
9896
+ throw new Error("Invalid nametag format. Use lowercase alphanumeric, underscore, or hyphen (3-20 chars), or a valid phone number.");
8961
9897
  }
8962
9898
  if (this._identity?.nametag) {
8963
9899
  throw new Error(`Nametag already registered for address ${this._currentAddressIndex}: @${this._identity.nametag}`);
@@ -9228,46 +10164,49 @@ var Sphere = class _Sphere {
9228
10164
  if (this._identity?.nametag) {
9229
10165
  return;
9230
10166
  }
9231
- if (!this._transport.recoverNametag) {
10167
+ let recoveredNametag = null;
10168
+ if (this._transport.recoverNametag) {
10169
+ try {
10170
+ recoveredNametag = await this._transport.recoverNametag();
10171
+ } catch {
10172
+ }
10173
+ }
10174
+ if (!recoveredNametag && this._transport.resolveAddressInfo && this._identity?.l1Address) {
10175
+ try {
10176
+ const info = await this._transport.resolveAddressInfo(this._identity.l1Address);
10177
+ if (info?.nametag) {
10178
+ recoveredNametag = info.nametag;
10179
+ }
10180
+ } catch {
10181
+ }
10182
+ }
10183
+ if (!recoveredNametag) {
9232
10184
  return;
9233
10185
  }
9234
10186
  try {
9235
- const recoveredNametag = await this._transport.recoverNametag();
9236
- if (recoveredNametag) {
9237
- if (this._identity) {
9238
- this._identity.nametag = recoveredNametag;
9239
- await this._updateCachedProxyAddress();
9240
- }
9241
- const entry = await this.ensureAddressTracked(this._currentAddressIndex);
9242
- let nametags = this._addressNametags.get(entry.addressId);
9243
- if (!nametags) {
9244
- nametags = /* @__PURE__ */ new Map();
9245
- this._addressNametags.set(entry.addressId, nametags);
9246
- }
9247
- const nextIndex = nametags.size;
9248
- nametags.set(nextIndex, recoveredNametag);
9249
- await this.persistAddressNametags();
9250
- if (this._transport.publishIdentityBinding) {
9251
- await this._transport.publishIdentityBinding(
9252
- this._identity.chainPubkey,
9253
- this._identity.l1Address,
9254
- this._identity.directAddress || "",
9255
- recoveredNametag
9256
- );
9257
- }
9258
- this.emitEvent("nametag:recovered", { nametag: recoveredNametag });
10187
+ if (this._identity) {
10188
+ this._identity.nametag = recoveredNametag;
10189
+ await this._updateCachedProxyAddress();
10190
+ }
10191
+ const entry = await this.ensureAddressTracked(this._currentAddressIndex);
10192
+ let nametags = this._addressNametags.get(entry.addressId);
10193
+ if (!nametags) {
10194
+ nametags = /* @__PURE__ */ new Map();
10195
+ this._addressNametags.set(entry.addressId, nametags);
9259
10196
  }
10197
+ const nextIndex = nametags.size;
10198
+ nametags.set(nextIndex, recoveredNametag);
10199
+ await this.persistAddressNametags();
10200
+ this.emitEvent("nametag:recovered", { nametag: recoveredNametag });
9260
10201
  } catch {
9261
10202
  }
9262
10203
  }
9263
10204
  /**
9264
- * Validate nametag format
10205
+ * Strip @ prefix and normalize a nametag (lowercase, phone E.164, strip @unicity suffix).
9265
10206
  */
9266
- validateNametag(nametag) {
9267
- const pattern = new RegExp(
9268
- `^[a-zA-Z0-9_-]{${LIMITS.NAMETAG_MIN_LENGTH},${LIMITS.NAMETAG_MAX_LENGTH}}$`
9269
- );
9270
- return pattern.test(nametag);
10207
+ cleanNametag(raw) {
10208
+ const stripped = raw.startsWith("@") ? raw.slice(1) : raw;
10209
+ return normalizeNametag2(stripped);
9271
10210
  }
9272
10211
  // ===========================================================================
9273
10212
  // Public Methods - Lifecycle
@@ -9465,8 +10404,12 @@ var Sphere = class _Sphere {
9465
10404
  for (const provider of this._tokenStorageProviders.values()) {
9466
10405
  provider.setIdentity(this._identity);
9467
10406
  }
9468
- await this._storage.connect();
9469
- await this._transport.connect();
10407
+ if (!this._storage.isConnected()) {
10408
+ await this._storage.connect();
10409
+ }
10410
+ if (!this._transport.isConnected()) {
10411
+ await this._transport.connect();
10412
+ }
9470
10413
  await this._oracle.initialize();
9471
10414
  for (const provider of this._tokenStorageProviders.values()) {
9472
10415
  await provider.initialize();
@@ -9623,6 +10566,7 @@ export {
9623
10566
  initSphere,
9624
10567
  isEncryptedData,
9625
10568
  isValidBech32,
10569
+ isValidNametag,
9626
10570
  isValidPrivateKey,
9627
10571
  loadSphere,
9628
10572
  mnemonicToEntropy2 as mnemonicToEntropy,