@unicitylabs/sphere-sdk 0.7.1-dev.2 → 0.7.1

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.
@@ -1365,6 +1365,17 @@ var NostrTransportProvider = class _NostrTransportProvider {
1365
1365
  getStorageAdapter() {
1366
1366
  return this.storage;
1367
1367
  }
1368
+ /**
1369
+ * Get the underlying NostrClient (or null if not yet connected).
1370
+ *
1371
+ * Exposed so {@link MultiAddressTransportMux} can share the same
1372
+ * client/socket pair instead of opening a duplicate WebSocket per
1373
+ * relay (#123). The transport owns the client's lifecycle — callers
1374
+ * MUST NOT call {@code disconnect()} on the returned instance.
1375
+ */
1376
+ getNostrClient() {
1377
+ return this.nostrClient;
1378
+ }
1368
1379
  /**
1369
1380
  * Suppress event subscriptions — unsubscribe wallet/chat filters
1370
1381
  * but keep the connection alive for resolve/identity-binding operations.
@@ -3003,9 +3014,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3003
3014
  chatSubscriptionId = null;
3004
3015
  chatEoseFired = false;
3005
3016
  resubscribeTimer = null;
3006
- lastWalletEventAt = Date.now();
3007
- lastChatEventAt = Date.now();
3008
- healthCheckTimer = null;
3009
3017
  chatEoseHandlers = [];
3010
3018
  // Dedup — bounded to prevent memory leak in long-running sessions.
3011
3019
  // Set preserves insertion order; evict oldest entries when cap is reached.
@@ -3016,6 +3024,19 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3016
3024
  // Identity key for the Mux's NostrClient — relays may filter gift-wrap
3017
3025
  // delivery to the recipient's subscription key.
3018
3026
  identityPrivateKey;
3027
+ // Resolves the shared NostrClient at use-time (the source provider may
3028
+ // create its client lazily, after the Mux is constructed). null means
3029
+ // "no shared client; create our own."
3030
+ sharedNostrClientGetter;
3031
+ // True when this Mux is using a shared NostrClient and therefore must
3032
+ // not call connect()/disconnect() on it.
3033
+ usingSharedClient = false;
3034
+ // Listener registered on the underlying NostrClient. Tracked so we can
3035
+ // remove it on disconnect / rebind — otherwise a long-lived shared
3036
+ // client accumulates listeners across address switches and (worse)
3037
+ // a "disconnected" Mux still sees onReconnected callbacks fire and
3038
+ // re-establish subscriptions it shouldn't have.
3039
+ connectionListener = null;
3019
3040
  constructor(config) {
3020
3041
  this.identityPrivateKey = config.identityPrivateKey;
3021
3042
  this.config = {
@@ -3028,6 +3049,14 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3028
3049
  generateUUID: config.generateUUID ?? defaultUUIDGenerator
3029
3050
  };
3030
3051
  this.storage = config.storage ?? null;
3052
+ if (typeof config.sharedNostrClient === "function") {
3053
+ this.sharedNostrClientGetter = config.sharedNostrClient;
3054
+ } else if (config.sharedNostrClient) {
3055
+ const c = config.sharedNostrClient;
3056
+ this.sharedNostrClientGetter = () => c;
3057
+ } else {
3058
+ this.sharedNostrClientGetter = null;
3059
+ }
3031
3060
  }
3032
3061
  // ===========================================================================
3033
3062
  // Address Management
@@ -3118,53 +3147,49 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3118
3147
  if (this.status === "connected") return;
3119
3148
  this.status = "connecting";
3120
3149
  try {
3121
- if (!this.primaryKeyManager) {
3122
- if (this.identityPrivateKey) {
3123
- this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(
3124
- Buffer3.from(this.identityPrivateKey)
3150
+ const shared = this.sharedNostrClientGetter ? this.sharedNostrClientGetter() : null;
3151
+ if (shared) {
3152
+ if (!shared.isConnected()) {
3153
+ throw new SphereError(
3154
+ "sharedNostrClient is not connected; the Mux cannot share a closed socket",
3155
+ "TRANSPORT_ERROR"
3125
3156
  );
3126
- } else {
3127
- const tempKey = Buffer3.alloc(32);
3128
- crypto.getRandomValues(tempKey);
3129
- this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(tempKey);
3130
3157
  }
3158
+ this.nostrClient = shared;
3159
+ this.usingSharedClient = true;
3160
+ } else {
3161
+ if (!this.primaryKeyManager) {
3162
+ if (this.identityPrivateKey) {
3163
+ this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(
3164
+ Buffer3.from(this.identityPrivateKey)
3165
+ );
3166
+ } else {
3167
+ const tempKey = Buffer3.alloc(32);
3168
+ crypto.getRandomValues(tempKey);
3169
+ this.primaryKeyManager = NostrKeyManager2.fromPrivateKey(tempKey);
3170
+ }
3171
+ }
3172
+ this.nostrClient = new NostrClient2(this.primaryKeyManager, {
3173
+ autoReconnect: this.config.autoReconnect,
3174
+ reconnectIntervalMs: this.config.reconnectDelay,
3175
+ maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3176
+ pingIntervalMs: 15e3
3177
+ });
3131
3178
  }
3132
- this.nostrClient = new NostrClient2(this.primaryKeyManager, {
3133
- autoReconnect: this.config.autoReconnect,
3134
- reconnectIntervalMs: this.config.reconnectDelay,
3135
- maxReconnectIntervalMs: this.config.reconnectDelay * 16,
3136
- pingIntervalMs: 15e3
3137
- });
3138
- this.nostrClient.addConnectionListener({
3139
- onConnect: (url) => {
3140
- logger.debug("Mux", "Connected to relay:", url);
3141
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3142
- },
3143
- onDisconnect: (url, reason) => {
3144
- logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3145
- },
3146
- onReconnecting: (url, attempt) => {
3147
- logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3148
- this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3149
- },
3150
- onReconnected: (url) => {
3151
- logger.debug("Mux", "Reconnected to relay:", url);
3152
- this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3153
- this.updateSubscriptions().catch((err) => {
3154
- logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3155
- });
3179
+ this.connectionListener = this.buildConnectionListener();
3180
+ this.nostrClient.addConnectionListener(this.connectionListener);
3181
+ if (!this.usingSharedClient) {
3182
+ await Promise.race([
3183
+ this.nostrClient.connect(...this.config.relays),
3184
+ new Promise(
3185
+ (_, reject) => setTimeout(() => reject(new Error(
3186
+ `Transport connection timed out after ${this.config.timeout}ms`
3187
+ )), this.config.timeout)
3188
+ )
3189
+ ]);
3190
+ if (!this.nostrClient.isConnected()) {
3191
+ throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3156
3192
  }
3157
- });
3158
- await Promise.race([
3159
- this.nostrClient.connect(...this.config.relays),
3160
- new Promise(
3161
- (_, reject) => setTimeout(() => reject(new Error(
3162
- `Transport connection timed out after ${this.config.timeout}ms`
3163
- )), this.config.timeout)
3164
- )
3165
- ]);
3166
- if (!this.nostrClient.isConnected()) {
3167
- throw new SphereError("Failed to connect to any relay", "TRANSPORT_ERROR");
3168
3193
  }
3169
3194
  this.status = "connected";
3170
3195
  this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
@@ -3173,6 +3198,21 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3173
3198
  }
3174
3199
  } catch (error) {
3175
3200
  this.status = "error";
3201
+ if (this.connectionListener && this.nostrClient) {
3202
+ try {
3203
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3204
+ } catch {
3205
+ }
3206
+ }
3207
+ this.connectionListener = null;
3208
+ if (this.nostrClient && !this.usingSharedClient) {
3209
+ try {
3210
+ this.nostrClient.disconnect();
3211
+ } catch {
3212
+ }
3213
+ }
3214
+ this.nostrClient = null;
3215
+ this.usingSharedClient = false;
3176
3216
  throw error;
3177
3217
  }
3178
3218
  }
@@ -3181,25 +3221,153 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3181
3221
  clearTimeout(this.resubscribeTimer);
3182
3222
  this.resubscribeTimer = null;
3183
3223
  }
3184
- if (this.healthCheckTimer) {
3185
- clearInterval(this.healthCheckTimer);
3186
- this.healthCheckTimer = null;
3187
- }
3188
3224
  if (this.nostrClient) {
3189
- this.nostrClient.disconnect();
3225
+ if (this.walletSubscriptionId) {
3226
+ try {
3227
+ this.nostrClient.unsubscribe(this.walletSubscriptionId);
3228
+ } catch {
3229
+ }
3230
+ }
3231
+ if (this.chatSubscriptionId) {
3232
+ try {
3233
+ this.nostrClient.unsubscribe(this.chatSubscriptionId);
3234
+ } catch {
3235
+ }
3236
+ }
3237
+ if (this.connectionListener) {
3238
+ try {
3239
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3240
+ } catch {
3241
+ }
3242
+ }
3243
+ if (!this.usingSharedClient) {
3244
+ this.nostrClient.disconnect();
3245
+ }
3190
3246
  this.nostrClient = null;
3191
3247
  }
3248
+ this.connectionListener = null;
3249
+ this.usingSharedClient = false;
3192
3250
  this.walletSubscriptionId = null;
3193
3251
  this.chatSubscriptionId = null;
3194
3252
  this.chatEoseFired = false;
3195
- this.lastWalletEventAt = Date.now();
3196
- this.lastChatEventAt = Date.now();
3197
3253
  this.status = "disconnected";
3198
3254
  this.emitEvent({ type: "transport:disconnected", timestamp: Date.now() });
3199
3255
  }
3200
3256
  isConnected() {
3201
3257
  return this.status === "connected" && this.nostrClient?.isConnected() === true;
3202
3258
  }
3259
+ /**
3260
+ * Build the connection listener used by both {@link connect} and
3261
+ * {@link rebindToSharedClient}.
3262
+ *
3263
+ * Behavioral notes:
3264
+ * - When the Mux is sharing a {@link NostrClient} with the host
3265
+ * transport (#123), we deliberately do NOT emit
3266
+ * {@code transport:connected} / {@code transport:reconnecting} here
3267
+ * — the host transport's own listener already emits those for the
3268
+ * same socket event. Re-subscribing after a reconnect IS still our
3269
+ * responsibility, since the host has
3270
+ * {@code suppressSubscriptions()}'d its own filters.
3271
+ * - {@code onConnect} does not emit {@code transport:connected}.
3272
+ * The SDK only fires {@code onConnect} on the initial socket
3273
+ * connection (subsequent reconnects use {@code onReconnected}),
3274
+ * and {@link connect()}'s bottom already emits
3275
+ * {@code transport:connected} once that returns. Emitting here too
3276
+ * would double-fire on every initial connect.
3277
+ * - Each callback bails out early when the Mux is not in an active
3278
+ * state ({@code disconnected} / {@code error}). Listeners are
3279
+ * removed on {@code disconnect()} before the callback can fire,
3280
+ * so this guard is mainly defense-in-depth against any in-flight
3281
+ * callback that lands during teardown — but having it at the top
3282
+ * means we never emit a misleading {@code transport:connected}
3283
+ * from a Mux that has already torn down.
3284
+ */
3285
+ buildConnectionListener() {
3286
+ const isInactive = () => this.status === "disconnected" || this.status === "error";
3287
+ return {
3288
+ onConnect: (url) => {
3289
+ if (isInactive()) return;
3290
+ logger.debug("Mux", "Connected to relay:", url);
3291
+ },
3292
+ onDisconnect: (url, reason) => {
3293
+ logger.debug("Mux", "Disconnected from relay:", url, "reason:", reason);
3294
+ },
3295
+ onReconnecting: (url, attempt) => {
3296
+ if (isInactive()) return;
3297
+ logger.debug("Mux", "Reconnecting to relay:", url, "attempt:", attempt);
3298
+ if (!this.usingSharedClient) {
3299
+ this.emitEvent({ type: "transport:reconnecting", timestamp: Date.now() });
3300
+ }
3301
+ },
3302
+ onReconnected: (url) => {
3303
+ if (isInactive()) return;
3304
+ logger.debug("Mux", "Reconnected to relay:", url);
3305
+ if (!this.usingSharedClient) {
3306
+ this.emitEvent({ type: "transport:connected", timestamp: Date.now() });
3307
+ }
3308
+ this.updateSubscriptions().catch((err) => {
3309
+ logger.error("Mux", "Failed to re-subscribe after reconnect:", err);
3310
+ });
3311
+ }
3312
+ };
3313
+ }
3314
+ /**
3315
+ * Re-attach to a freshly-created shared NostrClient.
3316
+ *
3317
+ * Call this after the host (e.g. {@link NostrTransportProvider}) has
3318
+ * recreated its NostrClient — typically because the wallet's active
3319
+ * identity changed and the SDK's NostrClient does not support
3320
+ * changing identity at runtime. The previous client has already
3321
+ * been disconnected by the host, so its server-side subscriptions
3322
+ * are gone — we just adopt the new client and re-issue our own.
3323
+ *
3324
+ * The caller is responsible for ordering: by the time rebind runs,
3325
+ * the host transport's new NostrClient must already be created and
3326
+ * connected. In Sphere this is guaranteed because we await
3327
+ * {@code transport.setIdentity()} before calling rebind.
3328
+ *
3329
+ * Returns silently in two cases that are not caller errors:
3330
+ * - the Mux owns its own client (not sharing) — nothing to rebind
3331
+ * - the shared client reference hasn't changed (rebind is a no-op)
3332
+ *
3333
+ * Throws otherwise (rather than silently no-op'ing) so a wiring
3334
+ * mistake — for instance, calling rebind before the host's new
3335
+ * client is ready — surfaces immediately instead of leaving the
3336
+ * Mux pinned to a stale client.
3337
+ */
3338
+ async rebindToSharedClient() {
3339
+ if (!this.usingSharedClient) return;
3340
+ if (!this.sharedNostrClientGetter) return;
3341
+ const newClient = this.sharedNostrClientGetter();
3342
+ if (!newClient) {
3343
+ throw new SphereError(
3344
+ "rebindToSharedClient: shared client getter returned null. The host transport must finish (re)creating its NostrClient before rebind is called.",
3345
+ "TRANSPORT_ERROR"
3346
+ );
3347
+ }
3348
+ if (this.nostrClient === newClient) return;
3349
+ if (!newClient.isConnected()) {
3350
+ throw new SphereError(
3351
+ "rebindToSharedClient: new shared client is not connected. Await transport.setIdentity() / transport.connect() before rebinding.",
3352
+ "TRANSPORT_ERROR"
3353
+ );
3354
+ }
3355
+ if (this.nostrClient && this.connectionListener && this.nostrClient !== newClient) {
3356
+ try {
3357
+ this.nostrClient.removeConnectionListener(this.connectionListener);
3358
+ } catch {
3359
+ }
3360
+ }
3361
+ this.nostrClient = newClient;
3362
+ this.walletSubscriptionId = null;
3363
+ this.chatSubscriptionId = null;
3364
+ this.chatEoseFired = false;
3365
+ this.connectionListener = this.buildConnectionListener();
3366
+ this.nostrClient.addConnectionListener(this.connectionListener);
3367
+ if (this.addresses.size > 0) {
3368
+ await this.updateSubscriptions();
3369
+ }
3370
+ }
3203
3371
  /**
3204
3372
  * One-shot fetch of pending events from the relay.
3205
3373
  * Creates a temporary subscription, waits for EOSE (or timeout),
@@ -3328,8 +3496,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3328
3496
  this.nostrClient.unsubscribe(this.chatSubscriptionId);
3329
3497
  this.chatSubscriptionId = null;
3330
3498
  }
3331
- this.lastWalletEventAt = Date.now();
3332
- this.lastChatEventAt = Date.now();
3333
3499
  if (this.addresses.size === 0) return;
3334
3500
  const allPubkeys = [];
3335
3501
  for (const entry of this.addresses.values()) {
@@ -3416,25 +3582,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3416
3582
  }
3417
3583
  });
3418
3584
  logger.debug("Mux", `updateSubscriptions: walletSub=${this.walletSubscriptionId} chatSub=${this.chatSubscriptionId}`);
3419
- this.startHealthCheck();
3420
- }
3421
- startHealthCheck() {
3422
- if (this.healthCheckTimer) return;
3423
- this.healthCheckTimer = setInterval(() => {
3424
- if (!this.isConnected()) return;
3425
- const chatElapsed = Date.now() - this.lastChatEventAt;
3426
- const walletElapsed = Date.now() - this.lastWalletEventAt;
3427
- const needResubscribe = chatElapsed > 6e4 || walletElapsed > 3e5;
3428
- if (needResubscribe) {
3429
- const reason = chatElapsed > 6e4 ? `No chat events for ${Math.round(chatElapsed / 1e3)}s` : `No wallet events for ${Math.round(walletElapsed / 1e3)}s`;
3430
- logger.warn("Mux", `${reason} \u2014 re-subscribing`);
3431
- this.lastChatEventAt = Date.now();
3432
- this.lastWalletEventAt = Date.now();
3433
- this.updateSubscriptions().catch((err) => {
3434
- logger.warn("Mux", "Health check re-subscription failed:", err);
3435
- });
3436
- }
3437
- }, 3e4);
3438
3585
  }
3439
3586
  /**
3440
3587
  * Schedule a re-subscription after a relay-initiated subscription closure.
@@ -3495,12 +3642,6 @@ var MultiAddressTransportMux = class _MultiAddressTransportMux {
3495
3642
  }
3496
3643
  }
3497
3644
  }
3498
- if (event.kind !== EventKinds2.GIFT_WRAP) {
3499
- this.lastWalletEventAt = Date.now();
3500
- }
3501
- if (event.kind === EventKinds2.GIFT_WRAP) {
3502
- this.lastChatEventAt = Date.now();
3503
- }
3504
3645
  try {
3505
3646
  if (event.kind === EventKinds2.GIFT_WRAP) {
3506
3647
  await this.routeGiftWrap(event);
@@ -11943,6 +12084,132 @@ var PaymentsModule = class _PaymentsModule {
11943
12084
  };
11944
12085
  }
11945
12086
  }
12087
+ /**
12088
+ * Mint a fungible token directly to this wallet (genesis mint).
12089
+ *
12090
+ * Useful for test setups that need to seed a wallet with specific token
12091
+ * balances WITHOUT depending on the testnet faucet HTTP service. The
12092
+ * resulting token has the canonical CoinId bytes (passed in `coinIdHex`)
12093
+ * — when those bytes match a registered symbol in the TokenRegistry,
12094
+ * the token shows up under the symbol's name (e.g. "UCT"). There is no
12095
+ * cryptographic restriction on which key may issue a given CoinId; the
12096
+ * aggregator records the mint regardless of issuer identity.
12097
+ *
12098
+ * The flow:
12099
+ * 1. Generate a random TokenId.
12100
+ * 2. Build TokenCoinData with [(coinId, amount)].
12101
+ * 3. Build MintTransactionData with recipient = self (UnmaskedPredicate
12102
+ * from this wallet's signing service).
12103
+ * 4. Submit MintCommitment to the aggregator.
12104
+ * 5. Wait for the inclusion proof.
12105
+ * 6. Construct an SDK Token via Token.mint().
12106
+ * 7. Convert to wallet Token format and call addToken().
12107
+ *
12108
+ * @param coinIdHex - 64-char lowercase hex CoinId. Must match the bytes
12109
+ * used by the registered symbol if you want the wallet to recognize
12110
+ * the token as that symbol (e.g. UCT's coinId from the public registry).
12111
+ * @param amount - Amount in smallest units (multiply by 10^decimals
12112
+ * when converting from human values).
12113
+ * @returns Result with the resulting wallet Token and its on-chain id.
12114
+ */
12115
+ async mintFungibleToken(coinIdHex, amount) {
12116
+ this.ensureInitialized();
12117
+ const stClient = this.deps.oracle.getStateTransitionClient?.();
12118
+ if (!stClient) {
12119
+ return { success: false, error: "State transition client not available" };
12120
+ }
12121
+ const trustBase = this.deps.oracle.getTrustBase?.();
12122
+ if (!trustBase) {
12123
+ return { success: false, error: "Trust base not available" };
12124
+ }
12125
+ try {
12126
+ const signingService = await this.createSigningService();
12127
+ const { TokenId: TokenId5 } = await import("@unicitylabs/state-transition-sdk/lib/token/TokenId");
12128
+ const { TokenCoinData: TokenCoinData3 } = await import("@unicitylabs/state-transition-sdk/lib/token/fungible/TokenCoinData");
12129
+ const { UnmaskedPredicateReference: UnmaskedPredicateReference4 } = await import("@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference");
12130
+ const tokenTypeBytes = fromHex4("f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509");
12131
+ const tokenType = new TokenType3(tokenTypeBytes);
12132
+ const tokenIdBytes = new Uint8Array(32);
12133
+ crypto.getRandomValues(tokenIdBytes);
12134
+ const tokenId = new TokenId5(tokenIdBytes);
12135
+ const coinIdBytes = fromHex4(coinIdHex);
12136
+ const coinId = new CoinId4(coinIdBytes);
12137
+ const coinData = TokenCoinData3.create([[coinId, amount]]);
12138
+ const addressRef = await UnmaskedPredicateReference4.create(
12139
+ tokenType,
12140
+ signingService.algorithm,
12141
+ signingService.publicKey,
12142
+ HashAlgorithm5.SHA256
12143
+ );
12144
+ const ownerAddress = await addressRef.toAddress();
12145
+ const salt = new Uint8Array(32);
12146
+ crypto.getRandomValues(salt);
12147
+ const mintData = await MintTransactionData3.create(
12148
+ tokenId,
12149
+ tokenType,
12150
+ null,
12151
+ // tokenData: no metadata
12152
+ coinData,
12153
+ // fungible coin data
12154
+ ownerAddress,
12155
+ // recipient = self
12156
+ salt,
12157
+ null,
12158
+ // recipientDataHash
12159
+ null
12160
+ // reason: null (genesis, no burn predecessor)
12161
+ );
12162
+ const commitment = await MintCommitment3.create(mintData);
12163
+ const MAX_RETRIES = 3;
12164
+ let lastStatus;
12165
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
12166
+ const response = await stClient.submitMintCommitment(commitment);
12167
+ lastStatus = response.status;
12168
+ if (response.status === "SUCCESS" || response.status === "REQUEST_ID_EXISTS") break;
12169
+ if (attempt === MAX_RETRIES) {
12170
+ return { success: false, error: `Mint submit failed after ${MAX_RETRIES} attempts: ${response.status}` };
12171
+ }
12172
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
12173
+ }
12174
+ if (lastStatus !== "SUCCESS" && lastStatus !== "REQUEST_ID_EXISTS") {
12175
+ return { success: false, error: `Mint submit failed: ${lastStatus}` };
12176
+ }
12177
+ const inclusionProof = await waitInclusionProof5(trustBase, stClient, commitment);
12178
+ const genesisTransaction = commitment.toTransaction(inclusionProof);
12179
+ const predicate = await UnmaskedPredicate5.create(
12180
+ tokenId,
12181
+ tokenType,
12182
+ signingService,
12183
+ HashAlgorithm5.SHA256,
12184
+ salt
12185
+ );
12186
+ const tokenState = new TokenState5(predicate, null);
12187
+ const sdkToken = await SdkToken2.mint(trustBase, tokenState, genesisTransaction);
12188
+ const tokenIdHex = tokenId.toJSON();
12189
+ const symbol = this.getCoinSymbol(coinIdHex);
12190
+ const name = this.getCoinName(coinIdHex);
12191
+ const decimals = this.getCoinDecimals(coinIdHex);
12192
+ const iconUrl = this.getCoinIconUrl(coinIdHex);
12193
+ const uiToken = {
12194
+ id: tokenIdHex,
12195
+ coinId: coinIdHex,
12196
+ symbol,
12197
+ name,
12198
+ decimals,
12199
+ ...iconUrl !== void 0 ? { iconUrl } : {},
12200
+ amount: amount.toString(),
12201
+ status: "confirmed",
12202
+ createdAt: Date.now(),
12203
+ updatedAt: Date.now(),
12204
+ sdkData: JSON.stringify(sdkToken.toJSON())
12205
+ };
12206
+ await this.addToken(uiToken);
12207
+ return { success: true, token: uiToken, tokenId: tokenIdHex };
12208
+ } catch (err) {
12209
+ const msg = err instanceof Error ? err.message : String(err);
12210
+ return { success: false, error: `Local mint failed: ${msg}` };
12211
+ }
12212
+ }
11946
12213
  /**
11947
12214
  * Check if a nametag is available for minting
11948
12215
  * @param nametag - The nametag to check (e.g., "alice" or "@alice")
@@ -18153,6 +18420,24 @@ var AccountingModule = class _AccountingModule {
18153
18420
  dirtyLedgerEntries = /* @__PURE__ */ new Set();
18154
18421
  /** Count of unknown (not in invoiceTermsCache) invoice IDs in the ledger. */
18155
18422
  unknownLedgerCount = 0;
18423
+ /**
18424
+ * Per-unknown-invoice first-seen timestamp for TTL eviction.
18425
+ *
18426
+ * W1 (steelman round-4): without TTL, an attacker who can deliver 500
18427
+ * inbound transfers with synthesized memo invoiceIds permanently exhausts
18428
+ * the unknown-ledger cap, after which legitimate orphan transfers (out-of-
18429
+ * order delivery for real swaps) are silently dropped at the cap-check.
18430
+ *
18431
+ * Round-5 perf: gated by `unknownLedgerNextSweepMs` to amortize the
18432
+ * sweep cost. The naive every-call sweep is O(N) where N=cap=500;
18433
+ * combined with the per-token cleanup loop inside the sweep it became
18434
+ * O(N×M) on every transfer under flood. Now we sweep at most every
18435
+ * `UNKNOWN_LEDGER_SWEEP_INTERVAL_MS` (60s) UNLESS the cap is currently
18436
+ * full, in which case we sweep on each call (the only path that can
18437
+ * actually drop a legitimate orphan).
18438
+ */
18439
+ unknownLedgerFirstSeen = /* @__PURE__ */ new Map();
18440
+ unknownLedgerNextSweepMs = 0;
18156
18441
  /** W17: Tracks whether tokenScanState has been mutated since last flush. */
18157
18442
  tokenScanDirty = false;
18158
18443
  /** W2 fix: Serialization guard for _flushDirtyLedgerEntries. */
@@ -19143,6 +19428,7 @@ var AccountingModule = class _AccountingModule {
19143
19428
  }
19144
19429
  if (this.invoiceLedger.has(tokenId) && !this.invoiceTermsCache.has(tokenId)) {
19145
19430
  this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
19431
+ this.unknownLedgerFirstSeen.delete(tokenId);
19146
19432
  }
19147
19433
  this.invoiceTermsCache.set(tokenId, terms);
19148
19434
  this._addToHashIndex(tokenId);
@@ -19411,6 +19697,29 @@ var AccountingModule = class _AccountingModule {
19411
19697
  closed: this.closedInvoices.has(invoiceId)
19412
19698
  };
19413
19699
  }
19700
+ /**
19701
+ * Return the set of token IDs that are currently linked to the given
19702
+ * invoice. Populated by both the on-chain `_processTokenTransactions`
19703
+ * path (tokens with `inv:` references) and the transport-memo orphan
19704
+ * buffering path in `_handleIncomingTransfer`.
19705
+ *
19706
+ * Used by callers that want to scope per-invoice operations (e.g.
19707
+ * SwapModule.verifyPayout's L3 validation) to only the tokens that
19708
+ * cover this invoice — avoiding false negatives when the wallet
19709
+ * contains unrelated tokens of the same currency in unconfirmed or
19710
+ * spent state.
19711
+ *
19712
+ * Returns an empty set if no tokens are currently linked.
19713
+ */
19714
+ getTokenIdsForInvoice(invoiceId) {
19715
+ const result = /* @__PURE__ */ new Set();
19716
+ for (const [tokenId, invoiceIds] of this.tokenInvoiceMap) {
19717
+ if (invoiceIds.has(invoiceId)) {
19718
+ result.add(tokenId);
19719
+ }
19720
+ }
19721
+ return result;
19722
+ }
19414
19723
  /**
19415
19724
  * Explicitly close an invoice. Only target parties may close (§8.3).
19416
19725
  *
@@ -19730,6 +20039,7 @@ var AccountingModule = class _AccountingModule {
19730
20039
  ledger.set(entryKey, forwardRef);
19731
20040
  this.dirtyLedgerEntries.add(invoiceId);
19732
20041
  this.balanceCache.delete(invoiceId);
20042
+ await this._persistProvisionalAndVerify(invoiceId, "payInvoice");
19733
20043
  }
19734
20044
  return result;
19735
20045
  } finally {
@@ -19897,6 +20207,7 @@ var AccountingModule = class _AccountingModule {
19897
20207
  this.dirtyLedgerEntries.add(invoiceId);
19898
20208
  }
19899
20209
  this.balanceCache.delete(invoiceId);
20210
+ await this._persistProvisionalAndVerify(invoiceId, "returnInvoicePayment");
19900
20211
  }
19901
20212
  return result;
19902
20213
  } finally {
@@ -20976,9 +21287,30 @@ var AccountingModule = class _AccountingModule {
20976
21287
  continue;
20977
21288
  }
20978
21289
  innerMap.set(entryKey, ref);
20979
- if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
21290
+ const HEX_64 = /^[a-f0-9]{64}$/i;
21291
+ if (entryKey.startsWith("mt:")) {
21292
+ const firstColon = entryKey.indexOf(":");
21293
+ const secondColon = entryKey.indexOf(":", firstColon + 1);
21294
+ if (secondColon > firstColon + 1) {
21295
+ const tokenIdFromKey2 = entryKey.slice(firstColon + 1, secondColon);
21296
+ if (HEX_64.test(tokenIdFromKey2)) {
21297
+ this._addToTokenInvoiceMap(tokenIdFromKey2, invoiceId);
21298
+ }
21299
+ }
21300
+ } else if (entryKey.startsWith("synthetic:")) {
21301
+ const afterPrefix = entryKey.slice("synthetic:".length);
21302
+ const tokenIdEnd = afterPrefix.indexOf(":");
21303
+ if (tokenIdEnd > 0) {
21304
+ const tokenId = afterPrefix.slice(0, tokenIdEnd);
21305
+ if (HEX_64.test(tokenId) && ref.transferId !== tokenId) {
21306
+ this._addToTokenInvoiceMap(tokenId, invoiceId);
21307
+ }
21308
+ }
21309
+ } else if (!ref.transferId.startsWith("provisional:") && ref.transferId.includes(":")) {
20980
21310
  const tokenIdFromRef = ref.transferId.slice(0, ref.transferId.indexOf(":"));
20981
- this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21311
+ if (HEX_64.test(tokenIdFromRef)) {
21312
+ this._addToTokenInvoiceMap(tokenIdFromRef, invoiceId);
21313
+ }
20982
21314
  }
20983
21315
  }
20984
21316
  } catch (err) {
@@ -21179,7 +21511,14 @@ var AccountingModule = class _AccountingModule {
21179
21511
  }
21180
21512
  }
21181
21513
  for (const [existingKey, existingRef] of ledger) {
21182
- if (existingKey.startsWith("synthetic:") && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21514
+ if ((existingKey.startsWith("synthetic:") || existingKey.startsWith("synthetic-tx:")) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21515
+ keysToDelete.push(existingKey);
21516
+ break;
21517
+ }
21518
+ }
21519
+ const mtPrefix = `mt:${tokenId}:`;
21520
+ for (const [existingKey, existingRef] of ledger) {
21521
+ if (existingKey.startsWith(mtPrefix) && existingRef.coinId === coinId && existingRef.paymentDirection === paymentDirection) {
21183
21522
  keysToDelete.push(existingKey);
21184
21523
  break;
21185
21524
  }
@@ -21296,6 +21635,49 @@ var AccountingModule = class _AccountingModule {
21296
21635
  });
21297
21636
  }
21298
21637
  }
21638
+ /**
21639
+ * Synchronously persist any pending provisional ledger entry for `invoiceId`
21640
+ * before returning to the caller. Used by `payInvoice` and
21641
+ * `returnInvoicePayment` to make the in-memory provisional entry durable
21642
+ * inside the same per-invoice gate that wrote it, closing the
21643
+ * crash-mid-conclude race that produces over-coverage on receivers.
21644
+ *
21645
+ * Implementation:
21646
+ * 1. Schedule a flush via the existing `_flushPromise` chain (so
21647
+ * concurrent `_handleTokenChange` callers waiting on the chain
21648
+ * observe ours as part of the sequence).
21649
+ * 2. Await OUR flush directly — NOT `_drainFlushPromise()`, which would
21650
+ * spin while concurrent token changes keep extending the chain and
21651
+ * hold the per-invoice gate for an unbounded number of additional
21652
+ * flushes. We only need OUR provisional entry durable.
21653
+ * 3. `_flushDirtyLedgerEntries` swallows per-invoice `storage.set`
21654
+ * rejections internally (sets a local `step1Failed` flag), leaving
21655
+ * the dirty entry on the set without re-throwing. So we post-check
21656
+ * `dirtyLedgerEntries.has(invoiceId)` and throw a `STORAGE_ERROR`
21657
+ * `SphereError` if our entry is still dirty — propagating to the
21658
+ * caller so they learn about the durability failure rather than
21659
+ * receiving a silent "success" return that lies on disk.
21660
+ *
21661
+ * @param invoiceId The invoice whose provisional entry must be durable.
21662
+ * @param callContext Used in the error message so the caller is named
21663
+ * ('payInvoice' / 'returnInvoicePayment') without
21664
+ * forcing a stack-trace inspection.
21665
+ */
21666
+ async _persistProvisionalAndVerify(invoiceId, callContext) {
21667
+ const flushTrigger = (this._flushPromise ?? Promise.resolve()).then(() => this._flushDirtyLedgerEntries());
21668
+ const tracked = flushTrigger.catch(() => {
21669
+ }).finally(() => {
21670
+ if (this._flushPromise === tracked) this._flushPromise = null;
21671
+ });
21672
+ this._flushPromise = tracked;
21673
+ await flushTrigger;
21674
+ if (this.dirtyLedgerEntries.has(invoiceId)) {
21675
+ throw new SphereError(
21676
+ `${callContext}: provisional ledger entry for invoice ${invoiceId} failed to persist \u2014 caller should retry`,
21677
+ "STORAGE_ERROR"
21678
+ );
21679
+ }
21680
+ }
21299
21681
  // ===========================================================================
21300
21682
  // Internal: Event handlers
21301
21683
  // ===========================================================================
@@ -21356,13 +21738,96 @@ var AccountingModule = class _AccountingModule {
21356
21738
  }
21357
21739
  }
21358
21740
  if (!this.invoiceTermsCache.has(invoiceId)) {
21359
- const syntheticRef = this._buildSyntheticTransferRef(
21360
- transfer,
21361
- invoiceId,
21362
- paymentDirection,
21363
- confirmed
21364
- );
21365
- deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
21741
+ let gracefullyGraduated = false;
21742
+ await this.withInvoiceGate(invoiceId, async () => {
21743
+ if (this.invoiceTermsCache.has(invoiceId)) {
21744
+ gracefullyGraduated = true;
21745
+ return;
21746
+ }
21747
+ const syntheticRef = this._buildSyntheticTransferRef(
21748
+ transfer,
21749
+ invoiceId,
21750
+ paymentDirection,
21751
+ confirmed
21752
+ );
21753
+ deps.emitEvent("invoice:unknown_reference", { invoiceId, transfer: syntheticRef });
21754
+ const MAX_UNKNOWN_INVOICE_IDS = 500;
21755
+ const UNKNOWN_LEDGER_TTL_MS = 30 * 60 * 1e3;
21756
+ const MAX_ORPHAN_ENTRIES_PER_INVOICE = 50;
21757
+ const UNKNOWN_LEDGER_SWEEP_INTERVAL_MS = 6e4;
21758
+ const nowMs = Date.now();
21759
+ const capFull = this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS;
21760
+ const sweepDue = nowMs >= this.unknownLedgerNextSweepMs;
21761
+ if (this.unknownLedgerFirstSeen.size > 0 && (capFull || sweepDue)) {
21762
+ this.unknownLedgerNextSweepMs = nowMs + UNKNOWN_LEDGER_SWEEP_INTERVAL_MS;
21763
+ const expiredIds = [];
21764
+ for (const [unkId, firstSeen] of this.unknownLedgerFirstSeen) {
21765
+ if (nowMs - firstSeen > UNKNOWN_LEDGER_TTL_MS) {
21766
+ expiredIds.push(unkId);
21767
+ }
21768
+ }
21769
+ for (const expiredId of expiredIds) {
21770
+ if (!this.invoiceTermsCache.has(expiredId) && this.invoiceLedger.has(expiredId)) {
21771
+ this.invoiceLedger.delete(expiredId);
21772
+ this.unknownLedgerCount = Math.max(0, this.unknownLedgerCount - 1);
21773
+ for (const [tokenId, invoiceSet] of this.tokenInvoiceMap) {
21774
+ if (invoiceSet.has(expiredId)) {
21775
+ invoiceSet.delete(expiredId);
21776
+ if (invoiceSet.size === 0) this.tokenInvoiceMap.delete(tokenId);
21777
+ }
21778
+ }
21779
+ }
21780
+ this.unknownLedgerFirstSeen.delete(expiredId);
21781
+ }
21782
+ }
21783
+ if (!this.invoiceLedger.has(invoiceId)) {
21784
+ if (this.unknownLedgerCount >= MAX_UNKNOWN_INVOICE_IDS) {
21785
+ return;
21786
+ }
21787
+ this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
21788
+ this.unknownLedgerCount++;
21789
+ this.unknownLedgerFirstSeen.set(invoiceId, nowMs);
21790
+ }
21791
+ const orphanLedger = this.invoiceLedger.get(invoiceId);
21792
+ let mtEntryCount = 0;
21793
+ for (const k of orphanLedger.keys()) {
21794
+ if (k.startsWith("mt:")) mtEntryCount++;
21795
+ }
21796
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
21797
+ return;
21798
+ }
21799
+ for (const token of transfer.tokens) {
21800
+ if (!token.id) continue;
21801
+ let onChainAttributed = false;
21802
+ const tokenKeyPrefix = `${token.id}:`;
21803
+ for (const existingKey of orphanLedger.keys()) {
21804
+ if (existingKey.startsWith(tokenKeyPrefix) && !existingKey.startsWith("mt:")) {
21805
+ onChainAttributed = true;
21806
+ break;
21807
+ }
21808
+ }
21809
+ if (!onChainAttributed) {
21810
+ if (mtEntryCount >= MAX_ORPHAN_ENTRIES_PER_INVOICE) {
21811
+ break;
21812
+ }
21813
+ const orphanKey = `mt:${token.id}:${transfer.id}`;
21814
+ if (!orphanLedger.has(orphanKey)) {
21815
+ orphanLedger.set(orphanKey, syntheticRef);
21816
+ mtEntryCount++;
21817
+ }
21818
+ }
21819
+ if (!this.tokenInvoiceMap.has(token.id)) {
21820
+ this.tokenInvoiceMap.set(token.id, /* @__PURE__ */ new Set());
21821
+ }
21822
+ this.tokenInvoiceMap.get(token.id).add(invoiceId);
21823
+ }
21824
+ this.dirtyLedgerEntries.add(invoiceId);
21825
+ this.balanceCache.delete(invoiceId);
21826
+ await this._flushDirtyLedgerEntries();
21827
+ });
21828
+ if (gracefullyGraduated) {
21829
+ await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
21830
+ }
21366
21831
  return;
21367
21832
  }
21368
21833
  await this._processInvoiceTransferEvent(transfer, invoiceId, paymentDirection, confirmed);
@@ -21798,7 +22263,8 @@ var AccountingModule = class _AccountingModule {
21798
22263
  }
21799
22264
  const existingLedger = this.invoiceLedger.get(invoiceId);
21800
22265
  const firstTokenId = transfer.tokens.find((t) => t.id)?.id;
21801
- const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22266
+ const syntheticKey = firstTokenId ? `synthetic:${firstTokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22267
+ let mutated = false;
21802
22268
  if (!existingLedger.has(syntheticKey)) {
21803
22269
  let hasRealEntry = false;
21804
22270
  for (const tok of transfer.tokens) {
@@ -21813,8 +22279,25 @@ var AccountingModule = class _AccountingModule {
21813
22279
  }
21814
22280
  if (!hasRealEntry) {
21815
22281
  existingLedger.set(syntheticKey, { ...syntheticRef });
22282
+ mutated = true;
21816
22283
  }
21817
22284
  }
22285
+ for (const tok of transfer.tokens) {
22286
+ if (!tok.id) continue;
22287
+ if (!this.tokenInvoiceMap.has(tok.id)) {
22288
+ this.tokenInvoiceMap.set(tok.id, /* @__PURE__ */ new Set());
22289
+ mutated = true;
22290
+ }
22291
+ const beforeSize = this.tokenInvoiceMap.get(tok.id).size;
22292
+ this.tokenInvoiceMap.get(tok.id).add(invoiceId);
22293
+ if (this.tokenInvoiceMap.get(tok.id).size !== beforeSize) {
22294
+ mutated = true;
22295
+ }
22296
+ }
22297
+ if (mutated) {
22298
+ this.dirtyLedgerEntries.add(invoiceId);
22299
+ this.balanceCache.delete(invoiceId);
22300
+ }
21818
22301
  deps.emitEvent("invoice:payment", {
21819
22302
  invoiceId,
21820
22303
  transfer: syntheticRef,
@@ -21964,7 +22447,8 @@ var AccountingModule = class _AccountingModule {
21964
22447
  this.invoiceLedger.set(invoiceId, /* @__PURE__ */ new Map());
21965
22448
  }
21966
22449
  const hLedger = this.invoiceLedger.get(invoiceId);
21967
- const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22450
+ const hKey = entry.tokenId ? `synthetic:${entry.tokenId}::${syntheticRef.coinId}` : `synthetic-tx:${syntheticRef.transferId}::${syntheticRef.coinId}`;
22451
+ let hMutated = false;
21968
22452
  if (!hLedger.has(hKey)) {
21969
22453
  let hasRealEntry = false;
21970
22454
  if (entry.tokenId) {
@@ -21977,7 +22461,23 @@ var AccountingModule = class _AccountingModule {
21977
22461
  }
21978
22462
  if (!hasRealEntry) {
21979
22463
  hLedger.set(hKey, { ...syntheticRef });
22464
+ hMutated = true;
22465
+ }
22466
+ }
22467
+ if (entry.tokenId) {
22468
+ if (!this.tokenInvoiceMap.has(entry.tokenId)) {
22469
+ this.tokenInvoiceMap.set(entry.tokenId, /* @__PURE__ */ new Set());
22470
+ hMutated = true;
21980
22471
  }
22472
+ const beforeSize = this.tokenInvoiceMap.get(entry.tokenId).size;
22473
+ this.tokenInvoiceMap.get(entry.tokenId).add(invoiceId);
22474
+ if (this.tokenInvoiceMap.get(entry.tokenId).size !== beforeSize) {
22475
+ hMutated = true;
22476
+ }
22477
+ }
22478
+ if (hMutated) {
22479
+ this.dirtyLedgerEntries.add(invoiceId);
22480
+ this.balanceCache.delete(invoiceId);
21981
22481
  }
21982
22482
  deps.emitEvent("invoice:payment", {
21983
22483
  invoiceId,
@@ -24509,17 +25009,63 @@ var SwapModule = class {
24509
25009
  for (const addr of allAddresses) {
24510
25010
  myDirectAddresses.add(addr.directAddress);
24511
25011
  }
24512
- let assetIndex;
24513
- if (myDirectAddresses.has(swap.manifest.party_a_address)) {
24514
- assetIndex = 0;
24515
- } else if (myDirectAddresses.has(swap.manifest.party_b_address)) {
24516
- assetIndex = 1;
25012
+ const matchesPartyA = myDirectAddresses.has(swap.manifest.party_a_address);
25013
+ const matchesPartyB = myDirectAddresses.has(swap.manifest.party_b_address);
25014
+ if (matchesPartyA && matchesPartyB) {
25015
+ throw new SphereError(
25016
+ "Ambiguous party identity: local wallet matches both party_a_address and party_b_address",
25017
+ "SWAP_DEPOSIT_FAILED"
25018
+ );
25019
+ }
25020
+ let myExpectedCurrency;
25021
+ if (matchesPartyA) {
25022
+ myExpectedCurrency = swap.manifest.party_a_currency_to_change;
25023
+ } else if (matchesPartyB) {
25024
+ myExpectedCurrency = swap.manifest.party_b_currency_to_change;
24517
25025
  } else {
24518
25026
  throw new SphereError(
24519
25027
  "Local wallet address does not match either party in the swap manifest",
24520
25028
  "SWAP_DEPOSIT_FAILED"
24521
25029
  );
24522
25030
  }
25031
+ if (!myExpectedCurrency || myExpectedCurrency === "") {
25032
+ throw new SphereError(
25033
+ "Manifest currency_to_change is empty for this party",
25034
+ "SWAP_DEPOSIT_FAILED"
25035
+ );
25036
+ }
25037
+ const invoiceRefForAssetLookup = deps.accounting.getInvoice(swap.depositInvoiceId);
25038
+ if (!invoiceRefForAssetLookup) {
25039
+ throw new SphereError(
25040
+ "Deposit invoice not yet imported into accounting module",
25041
+ "SWAP_WRONG_STATE"
25042
+ );
25043
+ }
25044
+ const depositTarget = invoiceRefForAssetLookup.terms.targets[0];
25045
+ if (!depositTarget) {
25046
+ throw new SphereError(
25047
+ "Deposit invoice has no targets",
25048
+ "SWAP_DEPOSIT_FAILED"
25049
+ );
25050
+ }
25051
+ const assetIndex = depositTarget.assets.findIndex(
25052
+ (a) => a.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)
25053
+ );
25054
+ if (assetIndex < 0) {
25055
+ throw new SphereError(
25056
+ `No asset matching expected currency ${myExpectedCurrency} found in deposit invoice`,
25057
+ "SWAP_DEPOSIT_FAILED"
25058
+ );
25059
+ }
25060
+ for (let i = assetIndex + 1; i < depositTarget.assets.length; i += 1) {
25061
+ const a = depositTarget.assets[i];
25062
+ if (a?.coin !== void 0 && coinIdsMatch(a.coin[0], myExpectedCurrency)) {
25063
+ throw new SphereError(
25064
+ `Ambiguous asset match in deposit invoice: slots ${assetIndex} and ${i} both match currency ${myExpectedCurrency}`,
25065
+ "SWAP_DEPOSIT_FAILED"
25066
+ );
25067
+ }
25068
+ }
24523
25069
  return this.withSwapGate(swapId, async () => {
24524
25070
  if (swap.progress !== "announced") {
24525
25071
  throw new SphereError(
@@ -24625,7 +25171,6 @@ var SwapModule = class {
24625
25171
  swap.updatedAt = Date.now();
24626
25172
  this.clearLocalTimer(swap.swapId);
24627
25173
  this.terminalSwapIds.add(swap.swapId);
24628
- const entryIdx = this._storedTerminalEntries.length;
24629
25174
  this._storedTerminalEntries.push({
24630
25175
  swapId: swap.swapId,
24631
25176
  progress: "failed",
@@ -24640,7 +25185,13 @@ var SwapModule = class {
24640
25185
  swap.error = prevError;
24641
25186
  swap.updatedAt = prevUpdatedAt;
24642
25187
  this.terminalSwapIds.delete(swap.swapId);
24643
- this._storedTerminalEntries.splice(entryIdx, 1);
25188
+ for (let i = this._storedTerminalEntries.length - 1; i >= 0; i--) {
25189
+ const entry = this._storedTerminalEntries[i];
25190
+ if (entry.swapId === swap.swapId && entry.progress === "failed") {
25191
+ this._storedTerminalEntries.splice(i, 1);
25192
+ break;
25193
+ }
25194
+ }
24644
25195
  logger.warn(LOG_TAG3, `failPayout: persistSwap failed for ${swapId}; fraud detection will retry on next load:`, persistErr);
24645
25196
  throw persistErr;
24646
25197
  }
@@ -24684,9 +25235,25 @@ var SwapModule = class {
24684
25235
  if (!targetStatus.coinAssets[0].isCovered) {
24685
25236
  return returnFalse();
24686
25237
  }
24687
- if (BigInt(targetStatus.coinAssets[0].netCoveredAmount) < BigInt(expectedAmount)) {
25238
+ let netCoveredAmount;
25239
+ let expectedAmountBigInt;
25240
+ try {
25241
+ netCoveredAmount = BigInt(targetStatus.coinAssets[0].netCoveredAmount);
25242
+ expectedAmountBigInt = BigInt(expectedAmount);
25243
+ } catch (parseErr) {
25244
+ return failPayout(
25245
+ `MALFORMED_AMOUNT: failed to parse coverage amounts (netCoveredAmount=${targetStatus.coinAssets[0].netCoveredAmount}, expectedAmount=${expectedAmount}): ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`
25246
+ );
25247
+ }
25248
+ if (netCoveredAmount < expectedAmountBigInt) {
24688
25249
  return returnFalse();
24689
25250
  }
25251
+ if (netCoveredAmount > expectedAmountBigInt) {
25252
+ const surplus = netCoveredAmount - expectedAmountBigInt;
25253
+ return failPayout(
25254
+ `OVER_COVERAGE: net=${netCoveredAmount.toString()}, expected=${expectedAmount}, surplus=${surplus.toString()} \u2014 surplus refund expected via auto-return; settlement halted`
25255
+ );
25256
+ }
24690
25257
  const escrowAddr = swap.deal.escrowAddress ?? this.config.defaultEscrowAddress;
24691
25258
  if (escrowAddr) {
24692
25259
  const escrowPeer = await deps.resolve(escrowAddr);
@@ -24696,8 +25263,28 @@ var SwapModule = class {
24696
25263
  }
24697
25264
  const validationResult = await deps.payments.validate();
24698
25265
  if (validationResult.invalid.length > 0) {
24699
- logger.warn(LOG_TAG3, `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${validationResult.invalid.length} invalid token(s) \u2014 retry after wallet sync`);
24700
- return returnFalse();
25266
+ const payoutTokenIds = deps.accounting.getTokenIdsForInvoice?.(swap.payoutInvoiceId) ?? /* @__PURE__ */ new Set();
25267
+ if (payoutTokenIds.size === 0) {
25268
+ logger.warn(
25269
+ LOG_TAG3,
25270
+ `verifyPayout for ${swapId.slice(0, 12)}: ${validationResult.invalid.length} invalid token(s) but tokenInvoiceMap is empty for this payout invoice \u2014 failing closed until reverse index rebuilds`
25271
+ );
25272
+ return returnFalse();
25273
+ }
25274
+ const relevantInvalid = validationResult.invalid.filter(
25275
+ (t) => payoutTokenIds.has(t.id)
25276
+ );
25277
+ if (relevantInvalid.length > 0) {
25278
+ logger.warn(
25279
+ LOG_TAG3,
25280
+ `verifyPayout for ${swapId.slice(0, 12)}: L3 validation found ${relevantInvalid.length} invalid token(s) covering this payout invoice \u2014 retry after wallet sync`
25281
+ );
25282
+ return returnFalse();
25283
+ }
25284
+ logger.debug(
25285
+ LOG_TAG3,
25286
+ `verifyPayout for ${swapId.slice(0, 12)}: ${validationResult.invalid.length} unrelated invalid token(s) ignored (not linked to this payout invoice)`
25287
+ );
24701
25288
  }
24702
25289
  if (swap.progress === "completed") {
24703
25290
  swap.payoutVerified = true;
@@ -24876,8 +25463,22 @@ var SwapModule = class {
24876
25463
  * @param dm - The incoming direct message.
24877
25464
  */
24878
25465
  handleIncomingDM(dm) {
25466
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25467
+ logger.warn(
25468
+ LOG_TAG3,
25469
+ `diag_swap_dm_arrived sender=${dm.senderPubkey.slice(0, 16)} length=${dm.content.length}`
25470
+ );
25471
+ }
24879
25472
  const parsed = parseSwapDM(dm.content);
24880
- if (!parsed) return;
25473
+ if (!parsed) {
25474
+ if (dm.content.startsWith("{") && dm.content.includes('"invoice_delivery"')) {
25475
+ logger.warn(
25476
+ LOG_TAG3,
25477
+ `diag_swap_dm_parse_rejected sender=${dm.senderPubkey.slice(0, 16)} prefix=${dm.content.slice(0, 80)}`
25478
+ );
25479
+ }
25480
+ return;
25481
+ }
24881
25482
  void (async () => {
24882
25483
  try {
24883
25484
  switch (parsed.kind) {
@@ -25243,16 +25844,43 @@ var SwapModule = class {
25243
25844
  // invoice_delivery (§12.4.2 + §12.4.3)
25244
25845
  // ---------------------------------------------------------------
25245
25846
  case "invoice_delivery": {
25246
- if (!swapId) return;
25847
+ logger.warn(
25848
+ LOG_TAG3,
25849
+ `diag_invoice_delivery_received swap_id=${swapId?.slice(0, 16)} sender=${dm.senderPubkey.slice(0, 16)} invoice_type=${msg.invoice_type} invoice_id=${msg.invoice_id?.slice(0, 16)}`
25850
+ );
25851
+ if (!swapId) {
25852
+ logger.warn(LOG_TAG3, "diag_invoice_delivery_dropped reason=no_swap_id");
25853
+ return;
25854
+ }
25247
25855
  const swap = this.swaps.get(swapId);
25248
- if (!swap) return;
25249
- if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) return;
25856
+ if (!swap) {
25857
+ logger.warn(
25858
+ LOG_TAG3,
25859
+ `diag_invoice_delivery_dropped reason=swap_not_in_map swap_id=${swapId.slice(0, 16)} known_swap_ids_count=${this.swaps.size}`
25860
+ );
25861
+ return;
25862
+ }
25863
+ if (!this.isFromExpectedEscrow(dm.senderPubkey, swap)) {
25864
+ logger.warn(
25865
+ LOG_TAG3,
25866
+ `diag_invoice_delivery_dropped reason=not_expected_escrow swap_id=${swapId.slice(0, 16)} sender=${dm.senderPubkey.slice(0, 16)} expected_escrow_pubkey=${swap.escrowPubkey?.slice(0, 16)} expected_escrow_addr=${swap.escrowDirectAddress?.slice(0, 24)}`
25867
+ );
25868
+ return;
25869
+ }
25250
25870
  const deps = this.deps;
25251
25871
  if (msg.invoice_type === "deposit") {
25872
+ logger.warn(
25873
+ LOG_TAG3,
25874
+ `diag_invoice_delivery_proceeding_to_import swap_id=${swapId.slice(0, 16)} progress=${swap.progress} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)}`
25875
+ );
25252
25876
  await this.withSwapGate(swapId, async () => {
25253
25877
  if (isTerminalProgress(swap.progress)) return;
25254
25878
  try {
25255
25879
  await deps.accounting.importInvoice(msg.invoice_token);
25880
+ logger.warn(
25881
+ LOG_TAG3,
25882
+ `diag_invoice_imported swap_id=${swapId.slice(0, 16)} invoice_id=${(msg.invoice_id ?? "").slice(0, 16)} type=deposit`
25883
+ );
25256
25884
  } catch (err) {
25257
25885
  if (err instanceof SphereError && err.code === "INVOICE_ALREADY_EXISTS") {
25258
25886
  logger.debug(LOG_TAG3, `Deposit invoice for swap ${swapId} already imported \u2014 relay re-delivery, continuing`);
@@ -26745,27 +27373,42 @@ async function parseAndDecryptWalletDat(data, password, onProgress) {
26745
27373
 
26746
27374
  // core/Sphere.ts
26747
27375
  import { SigningService as SigningService2 } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService";
27376
+ import { normalizeNametag as normalizeNametag2, isPhoneNumber } from "@unicitylabs/nostr-js-sdk";
27377
+
27378
+ // core/address-derivation.ts
26748
27379
  import { TokenType as TokenType5 } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
26749
27380
  import { HashAlgorithm as HashAlgorithm7 } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
26750
27381
  import { UnmaskedPredicateReference as UnmaskedPredicateReference3 } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicateReference";
26751
- import { normalizeNametag as normalizeNametag2, isPhoneNumber } from "@unicitylabs/nostr-js-sdk";
27382
+ var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
27383
+ var COMPRESSED_PUBKEY_RE = /^(02|03)[0-9a-fA-F]{64}$/;
27384
+ async function computeDirectAddressFromChainPubkey(chainPubkey) {
27385
+ if (typeof chainPubkey !== "string" || !COMPRESSED_PUBKEY_RE.test(chainPubkey)) {
27386
+ throw new Error(
27387
+ `computeDirectAddressFromChainPubkey: chainPubkey must be 66-char hex with 02/03 prefix, got "${String(chainPubkey).slice(0, 12)}..."`
27388
+ );
27389
+ }
27390
+ const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX2, "hex");
27391
+ const tokenType = new TokenType5(tokenTypeBytes);
27392
+ const publicKeyBytes = Buffer.from(chainPubkey, "hex");
27393
+ const predicateRef = await UnmaskedPredicateReference3.create(
27394
+ tokenType,
27395
+ "secp256k1",
27396
+ publicKeyBytes,
27397
+ HashAlgorithm7.SHA256
27398
+ );
27399
+ return (await predicateRef.toAddress()).toString();
27400
+ }
27401
+
27402
+ // core/Sphere.ts
26752
27403
  function isValidNametag2(nametag) {
26753
27404
  if (isPhoneNumber(nametag)) return true;
26754
27405
  return /^[a-z0-9_-]{3,20}$/.test(nametag);
26755
27406
  }
26756
- var UNICITY_TOKEN_TYPE_HEX2 = "f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
26757
27407
  async function deriveL3PredicateAddress(privateKey) {
26758
27408
  const secret = Buffer.from(privateKey, "hex");
26759
27409
  const signingService = await SigningService2.createFromSecret(secret);
26760
- const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX2, "hex");
26761
- const tokenType = new TokenType5(tokenTypeBytes);
26762
- const predicateRef = UnmaskedPredicateReference3.create(
26763
- tokenType,
26764
- signingService.algorithm,
26765
- signingService.publicKey,
26766
- HashAlgorithm7.SHA256
26767
- );
26768
- return (await (await predicateRef).toAddress()).toString();
27410
+ const pubkeyHex = Buffer.from(signingService.publicKey).toString("hex");
27411
+ return computeDirectAddressFromChainPubkey(pubkeyHex);
26769
27412
  }
26770
27413
  var Sphere = class _Sphere {
26771
27414
  // Singleton
@@ -28219,6 +28862,9 @@ var Sphere = class _Sphere {
28219
28862
  this._transport.setFallbackSince(fallbackTs);
28220
28863
  }
28221
28864
  await this._transport.setIdentity(this._identity);
28865
+ if (this._transportMux && typeof this._transportMux.rebindToSharedClient === "function") {
28866
+ await this._transportMux.rebindToSharedClient();
28867
+ }
28222
28868
  this.emitEvent("identity:changed", {
28223
28869
  l1Address: this._identity.l1Address,
28224
28870
  directAddress: this._identity.directAddress,
@@ -28429,7 +29075,12 @@ var Sphere = class _Sphere {
28429
29075
  this._transportMux = new MultiAddressTransportMux({
28430
29076
  relays: nostrTransport.getConfiguredRelays(),
28431
29077
  createWebSocket: nostrTransport.getWebSocketFactory(),
28432
- storage: nostrTransport.getStorageAdapter() ?? void 0
29078
+ storage: nostrTransport.getStorageAdapter() ?? void 0,
29079
+ // #123: share the original transport's NostrClient instead of
29080
+ // opening a second WebSocket per relay. Pass a getter so the
29081
+ // Mux resolves it at connect-time (after the transport finishes
29082
+ // its own connect()).
29083
+ sharedNostrClient: typeof nostrTransport.getNostrClient === "function" ? () => nostrTransport.getNostrClient() : void 0
28433
29084
  });
28434
29085
  await this._transportMux.connect();
28435
29086
  if (typeof nostrTransport.suppressSubscriptions === "function") {
@@ -30141,6 +30792,7 @@ export {
30141
30792
  base58Encode,
30142
30793
  bytesToHex3 as bytesToHex,
30143
30794
  checkNetworkHealth,
30795
+ computeDirectAddressFromChainPubkey,
30144
30796
  computeHash160,
30145
30797
  convertBits,
30146
30798
  createAddress,