+ {selectedFile.name.endsWith(".json") ? (
+
{selectedFile.name}
diff --git a/src/components/wallet/L3/services/ConflictResolutionService.ts b/src/components/wallet/L3/services/ConflictResolutionService.ts
index bccaec44a..6f49ebe5a 100644
--- a/src/components/wallet/L3/services/ConflictResolutionService.ts
+++ b/src/components/wallet/L3/services/ConflictResolutionService.ts
@@ -10,12 +10,20 @@ import type {
TokenConflict,
MergeResult,
TxfTransaction,
+ TombstoneEntry,
} from "./types/TxfTypes";
import {
isTokenKey,
+ isArchivedKey,
+ isForkedKey,
tokenIdFromKey,
+ tokenIdFromArchivedKey,
+ parseForkedKey,
keyFromTokenId,
+ archivedKeyFromTokenId,
+ forkedKeyFromTokenIdAndState,
} from "./types/TxfTypes";
+import { getCurrentStateHash } from "./TxfSerializer";
import type { NametagData } from "../../../../repositories/WalletRepository";
// ==========================================
@@ -53,7 +61,6 @@ export class ConflictResolutionService {
baseMeta = {
...remote._meta,
version: remoteVersion + 1, // Increment for merged version
- timestamp: Date.now(),
};
baseTokens = this.extractTokens(remote);
otherTokens = this.extractTokens(local);
@@ -64,39 +71,21 @@ export class ConflictResolutionService {
baseMeta = {
...local._meta,
version: localVersion + 1,
- timestamp: Date.now(),
};
baseTokens = this.extractTokens(local);
otherTokens = this.extractTokens(remote);
baseIsLocal = true;
console.log(`📦 Local is newer, using local as base`);
} else {
- // Same version - merge based on timestamp
- const localTs = local._meta.timestamp;
- const remoteTs = remote._meta.timestamp;
-
- if (remoteTs > localTs) {
- baseMeta = {
- ...remote._meta,
- version: remoteVersion + 1,
- timestamp: Date.now(),
- };
- baseTokens = this.extractTokens(remote);
- otherTokens = this.extractTokens(local);
- baseIsLocal = false;
- } else {
- baseMeta = {
- ...local._meta,
- version: localVersion + 1,
- timestamp: Date.now(),
- };
- baseTokens = this.extractTokens(local);
- otherTokens = this.extractTokens(remote);
- baseIsLocal = true;
- }
- console.log(
- `📦 Same version, using ${baseIsLocal ? "local" : "remote"} as base (newer timestamp)`
- );
+ // Same version - use local as base (local wins on tie)
+ baseMeta = {
+ ...local._meta,
+ version: localVersion + 1,
+ };
+ baseTokens = this.extractTokens(local);
+ otherTokens = this.extractTokens(remote);
+ baseIsLocal = true;
+ console.log(`📦 Same version, using local as base (local wins on tie)`);
}
// Merge tokens
@@ -149,13 +138,74 @@ export class ConflictResolutionService {
merged._nametag = this.mergeNametags(localNametag, remoteNametag);
}
- // Add all tokens
+ // Merge tombstones (union of local and remote by tokenId+stateHash)
+ // This ensures deleted token states stay deleted across devices
+ const localTombstones: TombstoneEntry[] = local._tombstones || [];
+ const remoteTombstones: TombstoneEntry[] = remote._tombstones || [];
+
+ // Use Map for deduplication by tokenId+stateHash key
+ const tombstoneMap = new Map();
+ for (const t of [...localTombstones, ...remoteTombstones]) {
+ const key = `${t.tokenId}:${t.stateHash}`;
+ if (!tombstoneMap.has(key)) {
+ tombstoneMap.set(key, t);
+ }
+ }
+ const mergedTombstones = [...tombstoneMap.values()];
+
+ if (mergedTombstones.length > 0) {
+ merged._tombstones = mergedTombstones;
+ console.log(`📦 Merged ${mergedTombstones.length} tombstone(s) (${localTombstones.length} local + ${remoteTombstones.length} remote)`);
+ }
+
+ // Build tombstone lookup set (tokenId:stateHash)
+ const tombstoneKeySet = new Set(tombstoneMap.keys());
+
+ // Add all tokens (excluding tombstoned states)
for (const [tokenId, token] of mergedTokens) {
+ // Get the token's current state hash
+ const stateHash = getCurrentStateHash(token);
+ const tombstoneKey = `${tokenId}:${stateHash}`;
+
+ // Don't include tokens whose current state is tombstoned
+ if (tombstoneKeySet.has(tombstoneKey)) {
+ console.log(`📦 Excluding tombstoned token ${tokenId.slice(0, 8)}... (state ${stateHash.slice(0, 12)}...) from merge`);
+ removedTokens.push(tokenId);
+ continue;
+ }
merged[keyFromTokenId(tokenId)] = token;
}
+ // Merge archived tokens (union of local and remote, prefer more transactions)
+ const localArchived = this.extractArchivedTokens(local);
+ const remoteArchived = this.extractArchivedTokens(remote);
+ const mergedArchived = this.mergeArchivedTokenMaps(localArchived, remoteArchived);
+ for (const [tokenId, token] of mergedArchived) {
+ merged[archivedKeyFromTokenId(tokenId)] = token;
+ }
+ if (mergedArchived.size > 0) {
+ console.log(`📦 Merged ${mergedArchived.size} archived token(s)`);
+ }
+
+ // Merge forked tokens (union of local and remote)
+ const localForked = this.extractForkedTokens(local);
+ const remoteForked = this.extractForkedTokens(remote);
+ const mergedForked = this.mergeForkedTokenMaps(localForked, remoteForked);
+ for (const [key, token] of mergedForked) {
+ // Parse key to get tokenId and stateHash
+ const parts = key.split("_");
+ if (parts.length >= 2) {
+ const tokenId = parts[0];
+ const stateHash = parts.slice(1).join("_"); // stateHash may contain underscores
+ merged[forkedKeyFromTokenIdAndState(tokenId, stateHash)] = token;
+ }
+ }
+ if (mergedForked.size > 0) {
+ console.log(`📦 Merged ${mergedForked.size} forked token(s)`);
+ }
+
console.log(
- `📦 Merge complete: ${mergedTokens.size} tokens, ${conflicts.length} conflicts resolved, ${newTokens.length} new tokens`
+ `📦 Merge complete: ${mergedTokens.size - removedTokens.length} active, ${mergedArchived.size} archived, ${mergedForked.size} forked, ${conflicts.length} conflicts resolved, ${newTokens.length} new, ${removedTokens.length} tombstoned`
);
return {
@@ -365,6 +415,103 @@ export class ConflictResolutionService {
return true;
}
+
+ // ==========================================
+ // Archived Token Methods
+ // ==========================================
+
+ /**
+ * Extract archived tokens from storage data into a Map
+ */
+ private extractArchivedTokens(data: TxfStorageData): Map {
+ const archived = new Map();
+
+ for (const key of Object.keys(data)) {
+ if (isArchivedKey(key)) {
+ const tokenId = tokenIdFromArchivedKey(key);
+ const token = data[key] as TxfToken;
+ if (token && token.genesis) {
+ archived.set(tokenId, token);
+ }
+ }
+ }
+
+ return archived;
+ }
+
+ /**
+ * Extract forked tokens from storage data into a Map
+ * Map key format: tokenId_stateHash
+ */
+ private extractForkedTokens(data: TxfStorageData): Map {
+ const forked = new Map();
+
+ for (const key of Object.keys(data)) {
+ if (isForkedKey(key)) {
+ const parsed = parseForkedKey(key);
+ if (parsed) {
+ const token = data[key] as TxfToken;
+ if (token && token.genesis) {
+ // Use tokenId_stateHash as map key
+ const mapKey = `${parsed.tokenId}_${parsed.stateHash}`;
+ forked.set(mapKey, token);
+ }
+ }
+ }
+ }
+
+ return forked;
+ }
+
+ /**
+ * Merge archived token maps
+ * Prefers token with more transactions (more complete history)
+ */
+ private mergeArchivedTokenMaps(
+ local: Map,
+ remote: Map
+ ): Map {
+ const merged = new Map(local);
+
+ for (const [tokenId, remoteToken] of remote) {
+ const localToken = merged.get(tokenId);
+
+ if (!localToken) {
+ // Only in remote - add it
+ merged.set(tokenId, remoteToken);
+ } else {
+ // Both have it - prefer one with more transactions (more complete history)
+ const localTxnCount = localToken.transactions?.length || 0;
+ const remoteTxnCount = remoteToken.transactions?.length || 0;
+
+ if (remoteTxnCount > localTxnCount) {
+ merged.set(tokenId, remoteToken);
+ }
+ // else keep local (ties go to local)
+ }
+ }
+
+ return merged;
+ }
+
+ /**
+ * Merge forked token maps (union merge)
+ * Each fork is unique by tokenId_stateHash
+ */
+ private mergeForkedTokenMaps(
+ local: Map,
+ remote: Map
+ ): Map {
+ const merged = new Map(local);
+
+ for (const [key, token] of remote) {
+ if (!merged.has(key)) {
+ merged.set(key, token);
+ }
+ }
+
+ return merged;
+ }
}
// ==========================================
diff --git a/src/components/wallet/L3/services/IdentityManager.ts b/src/components/wallet/L3/services/IdentityManager.ts
index 69166aba0..870739b74 100644
--- a/src/components/wallet/L3/services/IdentityManager.ts
+++ b/src/components/wallet/L3/services/IdentityManager.ts
@@ -1,7 +1,5 @@
import { SigningService } from "@unicitylabs/state-transition-sdk/lib/sign/SigningService";
-import { TokenId } from "@unicitylabs/state-transition-sdk/lib/token/TokenId";
import { TokenType } from "@unicitylabs/state-transition-sdk/lib/token/TokenType";
-import { UnmaskedPredicate } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
import * as bip39 from "bip39";
import CryptoJS from "crypto-js";
import { HashAlgorithm } from "@unicitylabs/state-transition-sdk/lib/hash/HashAlgorithm";
@@ -10,14 +8,23 @@ import { UnmaskedPredicateReference } from "@unicitylabs/state-transition-sdk/li
import { UnifiedKeyManager } from "../../shared/services/UnifiedKeyManager";
const STORAGE_KEY_ENC_SEED = "encrypted_seed";
-const STORAGE_KEY_SELECTED_INDEX = "l3_selected_address_index";
+const STORAGE_KEY_SELECTED_PATH = "l3_selected_address_path";
+// Legacy key - will be migrated to path-based
+const STORAGE_KEY_SELECTED_INDEX_LEGACY = "l3_selected_address_index";
const UNICITY_TOKEN_TYPE_HEX =
"f8aa13834268d29355ff12183066f0cb902003629bbc5eb9ef0efbe397867509";
const DEFAULT_SESSION_KEY = "user-pin-1234";
+/**
+ * User identity for L3 Unicity wallet.
+ *
+ * NOTE: The wallet address is derived using UnmaskedPredicateReference (no nonce/salt).
+ * This creates a stable, reusable DirectAddress from publicKey + tokenType.
+ * The SDK's UnmaskedPredicate (which uses salt) is only used for token ownership
+ * predicates during transfers, where the salt comes from the transaction itself.
+ */
export interface UserIdentity {
privateKey: string;
- nonce: string;
publicKey: string;
address: string;
mnemonic?: string;
@@ -28,7 +35,6 @@ export interface UserIdentity {
export class IdentityManager {
private static instance: IdentityManager;
private sessionKey: string;
- private unifiedKeyManager: UnifiedKeyManager | null = null;
private constructor(sessionKey: string) {
this.sessionKey = sessionKey;
@@ -43,35 +49,38 @@ export class IdentityManager {
/**
* Get the UnifiedKeyManager instance
+ * NOTE: Always fetch fresh from singleton to avoid stale references after resetInstance()
*/
getUnifiedKeyManager(): UnifiedKeyManager {
- if (!this.unifiedKeyManager) {
- this.unifiedKeyManager = UnifiedKeyManager.getInstance(this.sessionKey);
- }
- return this.unifiedKeyManager;
+ // Always get fresh instance from singleton - don't cache!
+ // UnifiedKeyManager.resetInstance() can make cached references stale
+ return UnifiedKeyManager.getInstance(this.sessionKey);
}
/**
- * Get the selected address index for identity derivation
- * Defaults to 0 if not set
+ * Get the selected address PATH for identity derivation
+ * Returns null if not set (caller should use default first address)
*/
- getSelectedAddressIndex(): number {
- const saved = localStorage.getItem(STORAGE_KEY_SELECTED_INDEX);
- return saved ? parseInt(saved, 10) : 0;
+ getSelectedAddressPath(): string | null {
+ return localStorage.getItem(STORAGE_KEY_SELECTED_PATH);
}
/**
- * Set the selected address index for identity derivation
+ * Set the selected address PATH for identity derivation
+ * @param path - Full BIP32 path like "m/84'/1'/0'/0/0"
*/
- setSelectedAddressIndex(index: number): void {
- localStorage.setItem(STORAGE_KEY_SELECTED_INDEX, index.toString());
+ setSelectedAddressPath(path: string): void {
+ localStorage.setItem(STORAGE_KEY_SELECTED_PATH, path);
+ // Clean up legacy index key
+ localStorage.removeItem(STORAGE_KEY_SELECTED_INDEX_LEGACY);
}
/**
- * Clear the selected address index (for wallet reset)
+ * Clear the selected address path (for wallet reset)
*/
- clearSelectedAddressIndex(): void {
- localStorage.removeItem(STORAGE_KEY_SELECTED_INDEX);
+ clearSelectedAddressPath(): void {
+ localStorage.removeItem(STORAGE_KEY_SELECTED_PATH);
+ localStorage.removeItem(STORAGE_KEY_SELECTED_INDEX_LEGACY);
}
/**
@@ -81,79 +90,67 @@ export class IdentityManager {
async generateNewIdentity(): Promise {
const keyManager = this.getUnifiedKeyManager();
const mnemonic = await keyManager.generateNew(12);
- return this.deriveIdentityFromUnifiedWallet(0, mnemonic);
+ // Use path-based derivation - PATH is the single identifier
+ const basePath = keyManager.getBasePath();
+ const defaultPath = `${basePath}/0/0`;
+ const identity = await this.deriveIdentityFromPath(defaultPath);
+ // Save mnemonic for legacy compatibility
+ if (mnemonic) {
+ this.saveSeed(mnemonic);
+ }
+ return { ...identity, mnemonic };
}
/**
- * Derive identity from the UnifiedKeyManager at a specific index
- * Uses standard BIP32 derivation: m/44'/0'/0'/0/{index}
+ * Derive L3 identity from a BIP32 path
+ * This is the PREFERRED method - use path as the single identifier
+ * @param path - Full BIP32 path like "m/84'/1'/0'/0/0"
*/
- async deriveIdentityFromUnifiedWallet(
- index: number = 0,
- mnemonic?: string
- ): Promise {
+ async deriveIdentityFromPath(path: string): Promise {
const keyManager = this.getUnifiedKeyManager();
if (!keyManager.isInitialized()) {
throw new Error("Unified wallet not initialized");
}
- const derived = keyManager.deriveAddress(index);
- const nonce = keyManager.deriveL3Nonce(derived.privateKey, index);
-
+ const derived = keyManager.deriveAddressFromPath(path);
const secret = Buffer.from(derived.privateKey, "hex");
- const nonceBuffer = Buffer.from(nonce, "hex");
- const l3Address = await this.deriveL3Address(secret, nonceBuffer);
+ const l3Address = await this.deriveL3Address(secret);
const signingService = await SigningService.createFromSecret(secret);
const publicKey = Buffer.from(signingService.publicKey).toString("hex");
- const identity: UserIdentity = {
+ // Parse path to get index for addressIndex field
+ const match = path.match(/\/(\d+)$/);
+ const index = match ? parseInt(match[1], 10) : 0;
+
+ return {
privateKey: derived.privateKey,
- nonce: nonce,
publicKey: publicKey,
address: l3Address,
- mnemonic: mnemonic || keyManager.getMnemonic() || undefined,
+ mnemonic: keyManager.getMnemonic() || undefined,
l1Address: derived.l1Address,
addressIndex: index,
};
-
- // Save mnemonic for legacy compatibility
- if (identity.mnemonic) {
- this.saveSeed(identity.mnemonic);
- }
-
- return identity;
}
/**
- * Derive identity from a raw private key and index
+ * Derive identity from a raw private key
* Useful for external integrations
*/
- async deriveIdentityFromPrivateKey(
- privateKey: string,
- index: number = 0
- ): Promise {
- const nonce = CryptoJS.HmacSHA256(
- CryptoJS.enc.Utf8.parse(`unicity-nonce-${index}`),
- CryptoJS.enc.Hex.parse(privateKey)
- ).toString();
-
+ async deriveIdentityFromPrivateKey(privateKey: string): Promise {
const secret = Buffer.from(privateKey, "hex");
- const nonceBuffer = Buffer.from(nonce, "hex");
- const l3Address = await this.deriveL3Address(secret, nonceBuffer);
+ const l3Address = await this.deriveL3Address(secret);
const signingService = await SigningService.createFromSecret(secret);
const publicKey = Buffer.from(signingService.publicKey).toString("hex");
return {
privateKey,
- nonce,
publicKey,
address: l3Address,
- addressIndex: index,
};
}
@@ -168,33 +165,42 @@ export class IdentityManager {
throw new Error("Invalid recovery phrase. Please check your words and try again.");
}
- // Use UnifiedKeyManager for BIP32 derivation - no legacy fallback
+ // Use UnifiedKeyManager for BIP32 derivation - PATH is the single identifier
const keyManager = this.getUnifiedKeyManager();
await keyManager.createFromMnemonic(mnemonic);
- return this.deriveIdentityFromUnifiedWallet(0, mnemonic);
+
+ // Use path-based derivation for the default first external address
+ const basePath = keyManager.getBasePath();
+ const defaultPath = `${basePath}/0/0`;
+ const identity = await this.deriveIdentityFromPath(defaultPath);
+
+ // Save mnemonic for legacy compatibility
+ this.saveSeed(mnemonic);
+
+ return { ...identity, mnemonic };
}
/**
- * Derive L3 Unicity address from secret and nonce
+ * Derive L3 Unicity address from secret
+ * Uses UnmaskedPredicateReference (no nonce) for a stable, reusable address
*/
- private async deriveL3Address(secret: Buffer, nonce: Buffer): Promise {
+ private async deriveL3Address(secret: Buffer): Promise {
try {
const signingService = await SigningService.createFromSecret(secret);
const tokenTypeBytes = Buffer.from(UNICITY_TOKEN_TYPE_HEX, "hex");
const tokenType = new TokenType(tokenTypeBytes);
- const tokenId = new TokenId(Buffer.alloc(32));
-
- const predicate = await UnmaskedPredicate.create(
- tokenId,
+ // Use UnmaskedPredicateReference for stable wallet address (no nonce)
+ // This matches getWalletAddress() and is the correct approach per SDK
+ const predicateRef = UnmaskedPredicateReference.create(
tokenType,
- signingService,
- HashAlgorithm.SHA256,
- nonce
+ signingService.algorithm,
+ signingService.publicKey,
+ HashAlgorithm.SHA256
);
- return (await (await predicate.getReference()).toAddress()).toString();
+ return (await (await predicateRef).toAddress()).toString();
} catch (error) {
console.error("Error deriving address", error);
throw error;
@@ -216,8 +222,16 @@ export class IdentityManager {
const initialized = await keyManager.initialize();
if (initialized) {
- const index = this.getSelectedAddressIndex(); // Use stored index instead of hardcoded 0
- return this.deriveIdentityFromUnifiedWallet(index);
+ // Use path-based derivation (not index-based) - PATH is the ONLY reliable identifier
+ const selectedPath = this.getSelectedAddressPath();
+ if (selectedPath) {
+ return this.deriveIdentityFromPath(selectedPath);
+ }
+
+ // Fallback to first external address if no path stored
+ const basePath = keyManager.getBasePath();
+ const defaultPath = `${basePath}/0/0`;
+ return this.deriveIdentityFromPath(defaultPath);
}
// No wallet initialized - user must create or import a wallet
@@ -244,6 +258,8 @@ export class IdentityManager {
Buffer.from(UNICITY_TOKEN_TYPE_HEX, "hex")
);
+ // UnmaskedPredicateReference creates a stable, reusable DirectAddress
+ // This does NOT use nonce - the address is derived only from publicKey + tokenType
const predicateRef = UnmaskedPredicateReference.create(
tokenType,
signingService.algorithm,
diff --git a/src/components/wallet/L3/services/IpfsStorageService.ts b/src/components/wallet/L3/services/IpfsStorageService.ts
index 879ad0cf1..2cfe78963 100644
--- a/src/components/wallet/L3/services/IpfsStorageService.ts
+++ b/src/components/wallet/L3/services/IpfsStorageService.ts
@@ -1,18 +1,28 @@
import { createHelia, type Helia } from "helia";
import { json } from "@helia/json";
import { bootstrap } from "@libp2p/bootstrap";
+import { generateKeyPairFromSeed } from "@libp2p/crypto/keys";
+import { peerIdFromPrivateKey } from "@libp2p/peer-id";
+import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey } from "ipns";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha256";
import { sha512 } from "@noble/hashes/sha512";
import * as ed from "@noble/ed25519";
+import type { CID } from "multiformats/cid";
+import type { PrivateKey, ConnectionGater, PeerId } from "@libp2p/interface";
import { WalletRepository, type NametagData } from "../../../../repositories/WalletRepository";
-import type { IdentityManager } from "./IdentityManager";
+import { OutboxRepository } from "../../../../repositories/OutboxRepository";
+import { IdentityManager } from "./IdentityManager";
import type { Token } from "../data/model";
-import type { TxfStorageData, TxfMeta } from "./types/TxfTypes";
-import { buildTxfStorageData, parseTxfStorageData } from "./TxfSerializer";
+import type { TxfStorageData, TxfMeta, TxfToken, TombstoneEntry } from "./types/TxfTypes";
+import { isTokenKey, tokenIdFromKey } from "./types/TxfTypes";
+import { buildTxfStorageData, parseTxfStorageData, txfToToken, tokenToTxf, getCurrentStateHash } from "./TxfSerializer";
import { getTokenValidationService } from "./TokenValidationService";
import { getConflictResolutionService } from "./ConflictResolutionService";
-import { getBootstrapPeers, getConfiguredCustomPeers } from "../../../../config/ipfs.config";
+import { getSyncCoordinator } from "./SyncCoordinator";
+import { getTokenBackupService } from "./TokenBackupService";
+// Note: retryWithBackoff was used for DHT publish, now handled by HTTP primary path
+import { getBootstrapPeers, getConfiguredCustomPeers, getBackendPeerId, getAllBackendGatewayUrls, IPNS_RESOLUTION_CONFIG, IPFS_CONFIG } from "../../../../config/ipfs.config";
// Configure @noble/ed25519 to use sync sha512 (required for getPublicKey without WebCrypto)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -26,7 +36,8 @@ export type StorageEventType =
| "storage:started"
| "storage:completed"
| "storage:failed"
- | "ipns:published";
+ | "ipns:published"
+ | "sync:state-changed";
export interface StorageEvent {
type: StorageEventType;
@@ -36,6 +47,7 @@ export interface StorageEvent {
ipnsName?: string;
tokenCount?: number;
error?: string;
+ isSyncing?: boolean;
};
}
@@ -50,6 +62,8 @@ export interface StorageResult {
tokenCount?: number;
validationIssues?: string[];
conflictsResolved?: number;
+ ipnsPublished?: boolean;
+ ipnsPublishPending?: boolean; // True if IPNS publish failed and will be retried
error?: string;
}
@@ -93,6 +107,28 @@ interface SerializedToken {
iconUrl?: string;
}
+/**
+ * Result of IPNS resolution from a single gateway
+ */
+interface IpnsGatewayResult {
+ cid: string;
+ sequence: bigint;
+ gateway: string;
+ recordData: Uint8Array;
+ /** Cached content from gateway path (avoids re-fetch) */
+ _cachedContent?: TxfStorageData;
+}
+
+/**
+ * Result of progressive IPNS resolution across multiple gateways
+ */
+interface IpnsProgressiveResult {
+ best: IpnsGatewayResult | null;
+ allResults: IpnsGatewayResult[];
+ respondedCount: number;
+ totalGateways: number;
+}
+
// ==========================================
// Constants
// ==========================================
@@ -101,6 +137,7 @@ const HKDF_INFO = "ipfs-storage-ed25519-v1";
const SYNC_DEBOUNCE_MS = 5000;
const VERSION_STORAGE_PREFIX = "ipfs_version_";
const CID_STORAGE_PREFIX = "ipfs_last_cid_";
+const PENDING_IPNS_PREFIX = "ipfs_pending_ipns_";
// ==========================================
// IpfsStorageService
@@ -113,16 +150,28 @@ export class IpfsStorageService {
private ed25519PrivateKey: Uint8Array | null = null;
private ed25519PublicKey: Uint8Array | null = null;
private cachedIpnsName: string | null = null;
+ private ipnsKeyPair: PrivateKey | null = null;
+ private ipnsSequenceNumber: bigint = 0n;
private identityManager: IdentityManager;
private eventCallbacks: StorageEventCallback[] = [];
private isInitializing = false;
private isSyncing = false;
+ private isInitialSyncing = false; // Tracks initial IPNS-based sync on startup
+ private pendingSync = false; // Track if sync was requested while another sync was running
private syncTimer: ReturnType | null = null;
private lastSync: StorageResult | null = null;
private autoSyncEnabled = false;
private boundSyncHandler: (() => void) | null = null;
+ private connectionMaintenanceInterval: ReturnType | null = null;
+
+ // IPNS polling state
+ private ipnsPollingInterval: ReturnType | null = null;
+ private boundVisibilityHandler: (() => void) | null = null;
+ private lastKnownRemoteSequence: bigint = 0n;
+ private isTabVisible: boolean = true; // Track tab visibility for adaptive polling
+ private currentIdentityAddress: string | null = null; // Track current identity for key re-derivation on switch
private constructor(identityManager: IdentityManager) {
this.identityManager = identityManager;
@@ -135,6 +184,19 @@ export class IpfsStorageService {
return IpfsStorageService.instance;
}
+ /**
+ * Reset the singleton instance.
+ * Must be called when the user switches to a different identity/address
+ * so that the new identity's IPFS storage is used.
+ */
+ static async resetInstance(): Promise {
+ if (IpfsStorageService.instance) {
+ console.log("📦 Resetting IpfsStorageService instance for identity switch...");
+ await IpfsStorageService.instance.shutdown();
+ IpfsStorageService.instance = null;
+ }
+ }
+
// ==========================================
// Lifecycle
// ==========================================
@@ -153,6 +215,13 @@ export class IpfsStorageService {
window.addEventListener("wallet-updated", this.boundSyncHandler);
this.autoSyncEnabled = true;
console.log("📦 IPFS auto-sync enabled");
+
+ // Set up IPNS polling with visibility-based control
+ this.setupVisibilityListener();
+
+ // On startup, run IPNS-based sync to discover remote state
+ // This resolves IPNS, verifies remote content, and merges if needed
+ this.syncFromIpns().catch(console.error);
}
/**
@@ -166,10 +235,17 @@ export class IpfsStorageService {
}
this.autoSyncEnabled = false;
+ // Clean up IPNS polling and visibility listener
+ this.cleanupVisibilityListener();
+
if (this.syncTimer) {
clearTimeout(this.syncTimer);
this.syncTimer = null;
}
+ if (this.connectionMaintenanceInterval) {
+ clearInterval(this.connectionMaintenanceInterval);
+ this.connectionMaintenanceInterval = null;
+ }
if (this.helia) {
await this.helia.stop();
this.helia = null;
@@ -198,6 +274,16 @@ export class IpfsStorageService {
new CustomEvent("ipfs-storage-event", { detail: event })
);
+ // Update sync timestamp on successful storage completion
+ // This is used by TokenBackupService to determine if backup is needed
+ if (event.type === "storage:completed") {
+ try {
+ getTokenBackupService().updateSyncTimestamp();
+ } catch {
+ // Ignore errors from backup service
+ }
+ }
+
// Call registered callbacks (for future Nostr integration)
for (const callback of this.eventCallbacks) {
try {
@@ -208,6 +294,23 @@ export class IpfsStorageService {
}
}
+ /**
+ * Emit sync state change event for React components to update UI in real-time
+ */
+ private emitSyncStateChange(): void {
+ const isSyncing = this.isSyncing || this.isInitialSyncing;
+ console.log(`📦 Sync state changed: isSyncing=${isSyncing}`);
+ window.dispatchEvent(
+ new CustomEvent("ipfs-storage-event", {
+ detail: {
+ type: "sync:state-changed",
+ timestamp: Date.now(),
+ data: { isSyncing },
+ } as StorageEvent,
+ })
+ );
+ }
+
// ==========================================
// Initialization
// ==========================================
@@ -226,183 +329,2054 @@ export class IpfsStorageService {
}
/**
- * Lazy initialization of Helia and key derivation
+ * Lazy initialization of Helia and key derivation
+ * Detects identity changes and re-derives keys automatically
+ */
+ private async ensureInitialized(): Promise {
+ // First, check if identity changed - we need to do this BEFORE the early return
+ const identity = await this.identityManager.getCurrentIdentity();
+ if (!identity) {
+ console.warn("📦 No wallet identity - skipping IPFS init");
+ return false;
+ }
+
+ // If identity changed since last init, clear cached keys to force re-derivation
+ // This ensures we sync to the correct IPNS name when switching addresses
+ if (this.currentIdentityAddress && this.currentIdentityAddress !== identity.address) {
+ console.log(`📦 Identity changed: ${this.currentIdentityAddress.slice(0, 20)}... → ${identity.address.slice(0, 20)}...`);
+ console.log(`📦 Clearing cached IPNS keys for re-derivation`);
+ this.ed25519PrivateKey = null;
+ this.ed25519PublicKey = null;
+ this.ipnsKeyPair = null;
+ this.cachedIpnsName = null;
+ this.ipnsSequenceNumber = 0n;
+ // Keep helia alive - only re-derive cryptographic keys
+ }
+
+ if (this.helia && this.ed25519PrivateKey) {
+ return true;
+ }
+
+ if (this.isInitializing) {
+ // Wait for ongoing initialization
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ return this.ensureInitialized();
+ }
+
+ this.isInitializing = true;
+
+ try {
+ // 0. Check WebCrypto availability (required by Helia/libp2p)
+ if (!this.isWebCryptoAvailable()) {
+ console.warn("📦 WebCrypto (crypto.subtle) not available - IPFS sync disabled");
+ console.warn("📦 This typically happens in non-secure contexts (HTTP instead of HTTPS)");
+ console.warn("📦 Wallet will continue to work, but IPFS backup/sync is unavailable");
+ return false;
+ }
+
+ // Identity already fetched above, no need to fetch again
+
+ // 2. Derive Ed25519 key from secp256k1 private key using HKDF
+ const walletSecret = this.hexToBytes(identity.privateKey);
+ const derivedKey = hkdf(
+ sha256,
+ walletSecret,
+ undefined, // no salt for deterministic derivation
+ HKDF_INFO,
+ 32
+ );
+ this.ed25519PrivateKey = derivedKey;
+ this.ed25519PublicKey = ed.getPublicKey(derivedKey);
+
+ // 3. Generate libp2p key pair for IPNS from the derived key
+ this.ipnsKeyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
+ const ipnsPeerId = peerIdFromPrivateKey(this.ipnsKeyPair);
+
+ // 4. Compute proper IPNS name from peer ID and migrate old storage keys
+ const oldIpnsName = `ipns-${this.bytesToHex(this.ed25519PublicKey).slice(0, 32)}`;
+ const newIpnsName = ipnsPeerId.toString();
+ this.cachedIpnsName = newIpnsName;
+ this.currentIdentityAddress = identity.address; // Track which identity we initialized for
+ this.migrateStorageKeys(oldIpnsName, newIpnsName);
+
+ // Load last IPNS sequence number from storage
+ this.ipnsSequenceNumber = this.getIpnsSequenceNumber();
+
+ // 4. Initialize Helia (browser IPFS) with custom bootstrap peers
+ const bootstrapPeers = getBootstrapPeers();
+ const customPeerCount = getConfiguredCustomPeers().length;
+
+ console.log("📦 Initializing Helia with restricted peer connections...");
+ console.log(`📦 Bootstrap peers: ${bootstrapPeers.length} total (${customPeerCount} custom, ${bootstrapPeers.length - customPeerCount} fallback)`);
+
+ // Create connection gater to restrict connections to bootstrap peers only
+ const connectionGater = this.createConnectionGater(bootstrapPeers);
+
+ this.helia = await createHelia({
+ libp2p: {
+ connectionGater,
+ peerDiscovery: [
+ bootstrap({ list: bootstrapPeers }),
+ // No mDNS - don't discover local network peers
+ ],
+ connectionManager: {
+ maxConnections: IPFS_CONFIG.maxConnections,
+ },
+ },
+ });
+
+ // Log browser's peer ID for debugging
+ const browserPeerId = this.helia.libp2p.peerId.toString();
+ console.log("📦 IPFS storage service initialized");
+ console.log("📦 Browser Peer ID:", browserPeerId);
+ console.log("📦 IPNS name:", this.cachedIpnsName);
+ console.log("📦 Identity address:", identity.address.slice(0, 30) + "...");
+
+ // Extract bootstrap peer IDs for filtering connection logs
+ const bootstrapPeerIds = new Set(
+ bootstrapPeers.map((addr) => {
+ const match = addr.match(/\/p2p\/([^/]+)$/);
+ return match ? match[1] : null;
+ }).filter(Boolean) as string[]
+ );
+
+ // Set up peer connection event handlers - only log bootstrap peers
+ this.helia.libp2p.addEventListener("peer:connect", (event) => {
+ const remotePeerId = event.detail.toString();
+ if (bootstrapPeerIds.has(remotePeerId)) {
+ console.log(`📦 Connected to bootstrap peer: ${remotePeerId.slice(0, 16)}...`);
+ }
+ });
+
+ this.helia.libp2p.addEventListener("peer:disconnect", (event) => {
+ const remotePeerId = event.detail.toString();
+ if (bootstrapPeerIds.has(remotePeerId)) {
+ console.log(`📦 Disconnected from bootstrap peer: ${remotePeerId.slice(0, 16)}...`);
+ }
+ });
+
+ // Log initial connections after a short delay
+ setTimeout(() => {
+ const connections = this.helia?.libp2p.getConnections() || [];
+ console.log(`📦 Active connections: ${connections.length}`);
+ connections.slice(0, 5).forEach((conn) => {
+ console.log(`📦 - ${conn.remotePeer.toString().slice(0, 16)}... via ${conn.remoteAddr.toString()}`);
+ });
+ }, 5000);
+
+ // Start connection maintenance for backend peer
+ this.startBackendConnectionMaintenance();
+
+ return true;
+ } catch (error) {
+ console.error("📦 Failed to initialize IPFS storage:", error);
+ // Provide helpful context for WebCrypto-related errors
+ if (error instanceof Error && error.message.includes("crypto")) {
+ console.warn("📦 This error is likely due to missing WebCrypto support");
+ console.warn("📦 Consider using HTTPS or a secure development environment");
+ }
+ return false;
+ } finally {
+ this.isInitializing = false;
+ }
+ }
+
+ // ==========================================
+ // Key Derivation Utilities
+ // ==========================================
+
+ private hexToBytes(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
+ }
+ return bytes;
+ }
+
+ private bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ }
+
+ /**
+ * Migrate local storage keys from old IPNS name format to new PeerId format
+ */
+ private migrateStorageKeys(oldIpnsName: string, newIpnsName: string): void {
+ if (oldIpnsName === newIpnsName) return;
+
+ // Migrate version counter
+ const oldVersionKey = `${VERSION_STORAGE_PREFIX}${oldIpnsName}`;
+ const newVersionKey = `${VERSION_STORAGE_PREFIX}${newIpnsName}`;
+ const version = localStorage.getItem(oldVersionKey);
+ if (version && !localStorage.getItem(newVersionKey)) {
+ localStorage.setItem(newVersionKey, version);
+ localStorage.removeItem(oldVersionKey);
+ console.log(`📦 Migrated version key: ${oldIpnsName} -> ${newIpnsName}`);
+ }
+
+ // Migrate last CID
+ const oldCidKey = `${CID_STORAGE_PREFIX}${oldIpnsName}`;
+ const newCidKey = `${CID_STORAGE_PREFIX}${newIpnsName}`;
+ const lastCid = localStorage.getItem(oldCidKey);
+ if (lastCid && !localStorage.getItem(newCidKey)) {
+ localStorage.setItem(newCidKey, lastCid);
+ localStorage.removeItem(oldCidKey);
+ console.log(`📦 Migrated CID key: ${oldIpnsName} -> ${newIpnsName}`);
+ }
+ }
+
+ // ==========================================
+ // Connection Gater (Peer Filtering)
+ // ==========================================
+
+ /**
+ * Create a connection gater that only allows connections to bootstrap peers.
+ * This restricts libp2p from connecting to random DHT-discovered peers,
+ * reducing browser traffic significantly.
+ *
+ * @param bootstrapPeers - List of bootstrap multiaddrs containing allowed peer IDs
+ */
+ private createConnectionGater(bootstrapPeers: string[]): ConnectionGater {
+ // Extract peer IDs from bootstrap multiaddrs
+ const allowedPeerIds = new Set(
+ bootstrapPeers.map((addr) => {
+ const match = addr.match(/\/p2p\/([^/]+)$/);
+ return match ? match[1] : null;
+ }).filter((id): id is string => id !== null)
+ );
+
+ console.log(`📦 Connection gater: allowing ${allowedPeerIds.size} peer(s)`);
+
+ return {
+ // Allow dialing any multiaddr (peer filtering happens at connection level)
+ denyDialMultiaddr: async () => false,
+
+ // Block outbound connections to non-allowed peers
+ denyDialPeer: async (peerId: PeerId) => {
+ const peerIdStr = peerId.toString();
+ const denied = !allowedPeerIds.has(peerIdStr);
+ if (denied) {
+ console.debug(`📦 Blocked dial to non-bootstrap peer: ${peerIdStr.slice(0, 16)}...`);
+ }
+ return denied;
+ },
+
+ // Allow inbound connections (rare in browser, but don't block)
+ denyInboundConnection: async () => false,
+
+ // Block outbound connections to non-allowed peers
+ denyOutboundConnection: async (peerId: PeerId) => {
+ const peerIdStr = peerId.toString();
+ return !allowedPeerIds.has(peerIdStr);
+ },
+
+ // Allow encrypted connections (peer already passed connection check)
+ denyInboundEncryptedConnection: async () => false,
+ denyOutboundEncryptedConnection: async () => false,
+
+ // Allow upgraded connections
+ denyInboundUpgradedConnection: async () => false,
+ denyOutboundUpgradedConnection: async () => false,
+
+ // Allow all multiaddrs for allowed peers
+ filterMultiaddrForPeer: async () => true,
+ };
+ }
+
+ // ==========================================
+ // IPNS Publishing
+ // ==========================================
+
+ private readonly IPNS_SEQ_STORAGE_PREFIX = "ipns_seq_";
+
+ /**
+ * Get the last IPNS sequence number from storage
+ */
+ private getIpnsSequenceNumber(): bigint {
+ if (!this.cachedIpnsName) return 0n;
+ const key = `${this.IPNS_SEQ_STORAGE_PREFIX}${this.cachedIpnsName}`;
+ const stored = localStorage.getItem(key);
+ return stored ? BigInt(stored) : 0n;
+ }
+
+ /**
+ * Save the IPNS sequence number to storage
+ */
+ private setIpnsSequenceNumber(seq: bigint): void {
+ if (!this.cachedIpnsName) return;
+ const key = `${this.IPNS_SEQ_STORAGE_PREFIX}${this.cachedIpnsName}`;
+ localStorage.setItem(key, seq.toString());
+ }
+
+ /**
+ * Publish pre-signed IPNS record via Kubo HTTP API
+ * Much faster than browser DHT - server has better connectivity
+ * @param marshalledRecord The signed, marshalled IPNS record bytes
+ * @returns true if at least one backend accepted the record
+ */
+ private async publishIpnsViaHttp(
+ marshalledRecord: Uint8Array
+ ): Promise {
+ const gatewayUrls = getAllBackendGatewayUrls();
+ if (gatewayUrls.length === 0) {
+ console.warn("📦 No backend gateways configured for HTTP IPNS publish");
+ return false;
+ }
+
+ // For Kubo API, we pass the IPNS name (peer ID) as the first arg
+ const ipnsName = this.cachedIpnsName;
+ if (!ipnsName) {
+ console.warn("📦 No IPNS name cached - cannot publish via HTTP");
+ return false;
+ }
+
+ console.log(`📦 Publishing IPNS via HTTP to ${gatewayUrls.length} backend(s)...`);
+
+ // Try all configured gateways in parallel
+ const results = await Promise.allSettled(
+ gatewayUrls.map(async (gatewayUrl) => {
+ try {
+ // Kubo /api/v0/routing/put expects:
+ // - arg: the routing key path (e.g., "/ipns/12D3KooW...")
+ // - body: the marshalled record bytes as multipart form
+ const formData = new FormData();
+ // Create Blob from Uint8Array (spread to array for type compatibility)
+ formData.append(
+ "file",
+ new Blob([new Uint8Array(marshalledRecord)]),
+ "record"
+ );
+
+ // allow-offline=true: Store record locally first, then propagate async
+ // This makes the HTTP call return quickly instead of waiting for DHT
+ const response = await fetch(
+ `${gatewayUrl}/api/v0/routing/put?arg=/ipns/${ipnsName}&allow-offline=true`,
+ {
+ method: "POST",
+ body: formData,
+ signal: AbortSignal.timeout(30000), // 30s timeout
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => "");
+ throw new Error(`HTTP ${response.status}: ${errorText.slice(0, 100)}`);
+ }
+
+ const hostname = new URL(gatewayUrl).hostname;
+ console.log(`📦 IPNS record accepted by ${hostname}`);
+ return gatewayUrl;
+ } catch (error) {
+ const hostname = new URL(gatewayUrl).hostname;
+ console.warn(`📦 HTTP IPNS publish to ${hostname} failed:`, error);
+ throw error;
+ }
+ })
+ );
+
+ const successful = results.filter((r) => r.status === "fulfilled");
+ if (successful.length > 0) {
+ console.log(
+ `📦 IPNS record published via HTTP to ${successful.length}/${gatewayUrls.length} backends`
+ );
+ return true;
+ }
+
+ console.warn("📦 HTTP IPNS publish failed on all backends");
+ return false;
+ }
+
+ /**
+ * Fire-and-forget IPNS publish via browser DHT
+ * Runs in background - doesn't block sync completion
+ * Provides redundancy alongside HTTP publish
+ * @param routingKey The DHT routing key
+ * @param marshalledRecord The signed, marshalled IPNS record bytes
+ */
+ private publishIpnsViaDhtAsync(
+ routingKey: Uint8Array,
+ marshalledRecord: Uint8Array
+ ): void {
+ if (!this.helia) return;
+
+ const helia = this.helia;
+ const DHT_BACKGROUND_TIMEOUT = 60000; // 60s - longer timeout since it's background
+
+ // Don't await - let it run in background
+ (async () => {
+ try {
+ await Promise.race([
+ helia.routing.put(routingKey, marshalledRecord),
+ new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error("DHT background timeout")),
+ DHT_BACKGROUND_TIMEOUT
+ )
+ ),
+ ]);
+ console.log("📦 IPNS record also propagated via browser DHT");
+ } catch (error) {
+ // Non-fatal - HTTP publish is primary
+ console.debug("📦 Browser DHT IPNS publish completed with:", error);
+ }
+ })();
+ }
+
+ /**
+ * Publish CID to IPNS using dual strategy:
+ * 1. Primary: HTTP POST to Kubo backend (fast, reliable)
+ * 2. Fallback: Fire-and-forget browser DHT (slow but provides redundancy)
+ * @param cid The CID to publish
+ * @returns The IPNS name on success, null on failure (non-fatal)
+ */
+ private async publishToIpns(cid: CID): Promise {
+ if (!this.helia || !this.ipnsKeyPair) {
+ console.warn("📦 IPNS key not initialized - skipping IPNS publish");
+ return null;
+ }
+
+ const IPNS_LIFETIME = 99 * 365 * 24 * 60 * 60 * 1000; // 99 years in ms
+ const ipnsKeyPair = this.ipnsKeyPair;
+
+ try {
+ console.log(
+ `📦 Publishing to IPNS: ${this.cachedIpnsName?.slice(0, 16)}... -> ${cid.toString().slice(0, 16)}...`
+ );
+
+ // Use max of local and known remote sequence + 1 to ensure we're always ahead
+ // This handles the case where another device published with a higher sequence
+ const baseSeq = this.ipnsSequenceNumber > this.lastKnownRemoteSequence
+ ? this.ipnsSequenceNumber
+ : this.lastKnownRemoteSequence;
+ this.ipnsSequenceNumber = baseSeq + 1n;
+ console.log(`📦 IPNS sequence: local=${this.ipnsSequenceNumber - 1n}, remote=${this.lastKnownRemoteSequence}, using=${this.ipnsSequenceNumber}`);
+
+ // 1. Create and sign IPNS record (once - used for both paths)
+ const record = await createIPNSRecord(
+ ipnsKeyPair,
+ `/ipfs/${cid.toString()}`,
+ this.ipnsSequenceNumber,
+ IPNS_LIFETIME
+ );
+
+ // Marshal the record for storage/transmission
+ const marshalledRecord = marshalIPNSRecord(record);
+
+ // Create the routing key from the public key (needed for DHT path)
+ const routingKey = multihashToIPNSRoutingKey(
+ ipnsKeyPair.publicKey.toMultihash()
+ );
+
+ // 2. Publish via HTTP (primary, fast) - AWAIT this
+ // HTTP path uses cachedIpnsName internally, doesn't need routingKey
+ const httpSuccess = await this.publishIpnsViaHttp(marshalledRecord);
+
+ // 3. Publish via browser DHT (fallback, fire-and-forget) - DON'T await
+ // This runs in background regardless of HTTP result for redundancy
+ this.publishIpnsViaDhtAsync(routingKey, marshalledRecord);
+
+ if (httpSuccess) {
+ // Save sequence number on HTTP success
+ this.setIpnsSequenceNumber(this.ipnsSequenceNumber);
+ console.log(
+ `📦 IPNS record published successfully (seq: ${this.ipnsSequenceNumber})`
+ );
+ return this.cachedIpnsName;
+ }
+
+ // HTTP failed - DHT is still trying in background
+ // We still consider this a partial success since DHT may succeed
+ console.warn(
+ "📦 HTTP IPNS publish failed, DHT attempting in background"
+ );
+ // Don't rollback sequence - DHT may succeed with this sequence
+ // But don't persist it either - if DHT fails, we'll retry with same seq
+ return null;
+ } catch (error) {
+ // Rollback sequence number on failure
+ this.ipnsSequenceNumber--;
+ // Non-fatal - content is still stored and announced
+ console.warn(`📦 IPNS publish failed:`, error);
+ return null;
+ }
+ }
+
+ // ==========================================
+ // Progressive IPNS Resolution (Multi-Gateway)
+ // ==========================================
+
+ /**
+ * Fetch IPNS record from a single HTTP gateway
+ * Returns the CID and sequence number, or null if failed
+ */
+ private async resolveIpnsFromGateway(gatewayUrl: string): Promise {
+ if (!this.cachedIpnsName) {
+ return null;
+ }
+
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(
+ () => controller.abort(),
+ IPNS_RESOLUTION_CONFIG.perGatewayTimeoutMs
+ );
+
+ // Use Kubo's routing/get API to fetch the raw IPNS record
+ const response = await fetch(
+ `${gatewayUrl}/api/v0/routing/get?arg=/ipns/${this.cachedIpnsName}`,
+ {
+ method: "POST",
+ signal: controller.signal,
+ }
+ );
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned ${response.status}`);
+ return null;
+ }
+
+ // Kubo returns JSON with base64-encoded record in "Extra" field:
+ // {"ID":"","Type":5,"Responses":null,"Extra":""}
+ const json = await response.json() as { Extra?: string; Type?: number };
+
+ if (!json.Extra) {
+ console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned no Extra field`);
+ return null;
+ }
+
+ // Decode base64 Extra field to get raw IPNS record
+ const recordData = Uint8Array.from(atob(json.Extra), c => c.charCodeAt(0));
+ const record = unmarshalIPNSRecord(recordData);
+
+ // Extract CID from value path
+ const cidMatch = record.value.match(/^\/ipfs\/(.+)$/);
+ if (!cidMatch) {
+ console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned invalid IPNS value: ${record.value}`);
+ return null;
+ }
+
+ return {
+ cid: cidMatch[1],
+ sequence: record.sequence,
+ gateway: gatewayUrl,
+ recordData,
+ };
+ } catch (error) {
+ const hostname = new URL(gatewayUrl).hostname;
+ if (error instanceof Error && error.name === "AbortError") {
+ console.debug(`📦 Gateway ${hostname} timeout`);
+ } else {
+ console.debug(`📦 Gateway ${hostname} error:`, error);
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Resolve IPNS via gateway path (fast, ~30ms with cache)
+ * Uses /ipns/{name}?format=dag-json for cached resolution
+ * Returns CID and content directly, but no sequence number
+ */
+ private async resolveIpnsViaGatewayPath(
+ gatewayUrl: string
+ ): Promise<{ cid: string; content: TxfStorageData; latency: number } | null> {
+ if (!this.cachedIpnsName) {
+ return null;
+ }
+
+ const startTime = Date.now();
+ const controller = new AbortController();
+ const timeoutId = setTimeout(
+ () => controller.abort(),
+ IPNS_RESOLUTION_CONFIG.gatewayPathTimeoutMs
+ );
+
+ try {
+ const url = `${gatewayUrl}/ipns/${this.cachedIpnsName}?format=dag-json`;
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ Accept: "application/vnd.ipld.dag-json, application/json",
+ },
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ return null;
+ }
+
+ // Extract CID from X-Ipfs-Path header: "/ipfs/bafk..."
+ const ipfsPath = response.headers.get("X-Ipfs-Path");
+ const cidMatch = ipfsPath?.match(/^\/ipfs\/(.+)$/);
+ const cid = cidMatch?.[1] || "";
+
+ const content = await response.json() as TxfStorageData;
+ const latency = Date.now() - startTime;
+
+ if (!cid) {
+ console.debug(`📦 Gateway ${new URL(gatewayUrl).hostname} returned no X-Ipfs-Path header`);
+ }
+
+ return { cid, content, latency };
+ } catch (error) {
+ clearTimeout(timeoutId);
+ const hostname = new URL(gatewayUrl).hostname;
+ if (error instanceof Error && error.name === "AbortError") {
+ console.debug(`📦 Gateway path ${hostname} timeout`);
+ } else {
+ console.debug(`📦 Gateway path ${hostname} error:`, error);
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Resolve IPNS progressively from all gateways using dual-path racing
+ *
+ * Races both methods in parallel for each gateway:
+ * - Gateway path: /ipns/{name}?format=dag-json (fast ~30ms, returns content)
+ * - Routing API: /api/v0/routing/get (slow ~5s, returns sequence number)
+ *
+ * Returns best result after initial timeout, continues collecting late responses.
+ * Gateway path results include cached content to avoid re-fetch.
+ * Calls onLateHigherSequence if a late response has higher sequence.
+ */
+ private async resolveIpnsProgressively(
+ onLateHigherSequence?: (result: IpnsGatewayResult) => void
+ ): Promise {
+ const gatewayUrls = getAllBackendGatewayUrls();
+ if (gatewayUrls.length === 0 || !this.cachedIpnsName) {
+ return { best: null, allResults: [], respondedCount: 0, totalGateways: 0 };
+ }
+
+ console.log(`📦 Racing IPNS resolution from ${gatewayUrls.length} gateways (gateway path + routing API)...`);
+
+ const results: IpnsGatewayResult[] = [];
+ // Track which gateways have responded via gateway path (for fast results)
+ const gatewayPathResults = new Map();
+
+ // Create promises for each gateway - race both methods
+ const gatewayPromises = gatewayUrls.map(async (url) => {
+ const hostname = new URL(url).hostname;
+
+ // Start both methods in parallel
+ const gatewayPathPromise = this.resolveIpnsViaGatewayPath(url);
+ const routingApiPromise = this.resolveIpnsFromGateway(url);
+
+ // Wait for both to settle (we want results from both if available)
+ const [gatewayPathResult, routingApiResult] = await Promise.allSettled([
+ gatewayPathPromise,
+ routingApiPromise,
+ ]);
+
+ // Process gateway path result (fast, has content, no sequence)
+ let fastCid: string | null = null;
+ let fastContent: TxfStorageData | null = null;
+ if (gatewayPathResult.status === "fulfilled" && gatewayPathResult.value) {
+ const { cid, content, latency } = gatewayPathResult.value;
+ if (cid) {
+ fastCid = cid;
+ fastContent = content;
+ gatewayPathResults.set(url, { cid, content, latency });
+ console.log(`📦 Gateway path ${hostname}: CID=${cid.slice(0, 16)}... (${latency}ms)`);
+ }
+ }
+
+ // Process routing API result (slow, has sequence)
+ if (routingApiResult.status === "fulfilled" && routingApiResult.value) {
+ const result = routingApiResult.value;
+ // Merge cached content from gateway path if same CID
+ if (fastContent && fastCid === result.cid) {
+ result._cachedContent = fastContent;
+ }
+ results.push(result);
+ console.log(`📦 Routing API ${hostname}: seq=${result.sequence}, CID=${result.cid.slice(0, 16)}...`);
+ return result;
+ }
+
+ // If only gateway path succeeded (no routing result), create result with sequence 0
+ // This allows fast content fetch, sequence will be updated by late routing responses
+ if (fastCid && fastContent) {
+ const partialResult: IpnsGatewayResult = {
+ cid: fastCid,
+ sequence: 0n, // Unknown sequence - will be updated by late routing response
+ gateway: url,
+ recordData: new Uint8Array(),
+ _cachedContent: fastContent,
+ };
+ results.push(partialResult);
+ console.log(`📦 Gateway path only ${hostname}: CID=${fastCid.slice(0, 16)}... (seq unknown)`);
+ return partialResult;
+ }
+
+ return null;
+ });
+
+ // Wait for initial timeout to collect responses
+ await Promise.race([
+ Promise.allSettled(gatewayPromises),
+ new Promise((resolve) => setTimeout(resolve, IPNS_RESOLUTION_CONFIG.initialTimeoutMs)),
+ ]);
+
+ // Find best result (highest sequence, or first with content if no sequences)
+ const findBest = (arr: IpnsGatewayResult[]): IpnsGatewayResult | null => {
+ if (arr.length === 0) return null;
+ // Prefer results with known sequence (> 0)
+ const withSequence = arr.filter(r => r.sequence > 0n);
+ if (withSequence.length > 0) {
+ return withSequence.reduce((best, current) =>
+ current.sequence > best.sequence ? current : best
+ );
+ }
+ // Fall back to first result with cached content
+ const withContent = arr.find(r => r._cachedContent);
+ return withContent || arr[0];
+ };
+
+ const initialBest = findBest(results);
+ const initialCount = results.length;
+ const initialSeq = initialBest?.sequence ?? 0n;
+ const hasContent = !!initialBest?._cachedContent;
+
+ console.log(
+ `📦 Initial timeout: ${initialCount}/${gatewayUrls.length} responded, ` +
+ `best seq=${initialSeq.toString()}, hasContent=${hasContent}`
+ );
+
+ // Continue waiting for late responses in background
+ if (onLateHigherSequence && initialCount < gatewayUrls.length) {
+ // Don't await - let this run in background
+ Promise.allSettled(gatewayPromises).then(() => {
+ // Find the new best after all responses
+ const finalBest = findBest(results);
+ // Check if any late response has higher sequence than initial best
+ if (finalBest && finalBest.sequence > initialSeq) {
+ console.log(
+ `📦 Late response with higher sequence: seq=${finalBest.sequence} ` +
+ `from ${new URL(finalBest.gateway).hostname} (was seq=${initialSeq})`
+ );
+ onLateHigherSequence(finalBest);
+ }
+ });
+ }
+
+ return {
+ best: initialBest,
+ allResults: [...results], // Snapshot at initial timeout
+ respondedCount: initialCount,
+ totalGateways: gatewayUrls.length,
+ };
+ }
+
+ /**
+ * Handle discovery of a higher IPNS sequence number
+ * Fetches the new content and merges with local state
+ */
+ private async handleHigherSequenceDiscovered(result: IpnsGatewayResult): Promise {
+ console.log(`📦 Handling higher sequence discovery: seq=${result.sequence}, cid=${result.cid.slice(0, 16)}...`);
+
+ // Don't process if already syncing
+ if (this.isSyncing) {
+ console.log(`📦 Sync in progress, deferring higher sequence handling`);
+ return;
+ }
+
+ // Update last known remote sequence
+ this.lastKnownRemoteSequence = result.sequence;
+
+ // Fetch the content from IPFS
+ const remoteData = await this.fetchRemoteContent(result.cid);
+ if (!remoteData) {
+ console.warn(`📦 Failed to fetch content for higher sequence CID: ${result.cid.slice(0, 16)}...`);
+ return;
+ }
+
+ // Compare versions
+ const localVersion = this.getVersionCounter();
+ const remoteVersion = remoteData._meta.version;
+
+ if (remoteVersion > localVersion) {
+ console.log(`📦 Remote version ${remoteVersion} > local ${localVersion}, importing...`);
+
+ // Import the remote data
+ const importedCount = await this.importRemoteData(remoteData);
+
+ // Update local tracking
+ this.setVersionCounter(remoteVersion);
+ this.setLastCid(result.cid);
+
+ console.log(`📦 Imported ${importedCount} token(s) from late-arriving higher sequence`);
+
+ // Emit event to notify UI
+ await this.emitEvent({
+ type: "storage:completed",
+ timestamp: Date.now(),
+ data: {
+ cid: result.cid,
+ tokenCount: importedCount,
+ },
+ });
+
+ // Trigger wallet refresh
+ window.dispatchEvent(new Event("wallet-updated"));
+ } else {
+ // Local version is same or higher, BUT remote might have new tokens we don't have
+ // (e.g., Browser 2 received token via Nostr while Browser 1 was offline)
+ console.log(`📦 Remote version ${remoteVersion} not newer than local ${localVersion}, checking for new tokens...`);
+
+ // Still import remote data - importRemoteData handles deduplication
+ const importedCount = await this.importRemoteData(remoteData);
+
+ if (importedCount > 0) {
+ console.log(`📦 Imported ${importedCount} new token(s) from remote despite lower version`);
+
+ // Trigger wallet refresh
+ window.dispatchEvent(new Event("wallet-updated"));
+ }
+
+ // Only sync if local differs from remote (has unique tokens or better versions)
+ // This prevents unnecessary re-publishing when local now matches remote
+ if (this.localDiffersFromRemote(remoteData)) {
+ console.log(`📦 Local differs from remote, scheduling sync to publish merged state`);
+ this.scheduleSync();
+
+ // Emit event to notify UI
+ await this.emitEvent({
+ type: "storage:completed",
+ timestamp: Date.now(),
+ data: {
+ cid: result.cid,
+ tokenCount: importedCount,
+ },
+ });
+ } else {
+ console.log(`📦 Local now matches remote after import, no sync needed`);
+
+ // Update local tracking to match remote (we're in sync)
+ this.setLastCid(result.cid);
+ this.setVersionCounter(remoteVersion);
+ }
+ }
+ }
+
+ // ==========================================
+ // IPNS Polling (Background Re-fetch)
+ // ==========================================
+
+ /**
+ * Start periodic IPNS polling to detect cross-device updates
+ * Only runs when tab is visible
+ */
+ private startIpnsPolling(): void {
+ if (this.ipnsPollingInterval) {
+ return; // Already running
+ }
+
+ const poll = async () => {
+ if (!this.cachedIpnsName || this.isSyncing) {
+ return;
+ }
+
+ console.log(`📦 IPNS poll: checking for remote updates...`);
+
+ const result = await this.resolveIpnsProgressively();
+
+ if (result.best) {
+ const localSeq = this.ipnsSequenceNumber;
+
+ // Check for higher sequence number
+ const hasHigherSequence = result.best.sequence > localSeq &&
+ result.best.sequence > this.lastKnownRemoteSequence;
+
+ // Also check for CID mismatch at same sequence (race condition between devices)
+ // This can happen when two devices publish with the same sequence number
+ const localCid = this.getLastCid();
+ const hasDifferentCid = localCid && result.best.cid !== localCid &&
+ result.best.sequence >= localSeq;
+
+ if (hasHigherSequence) {
+ console.log(
+ `📦 IPNS poll detected higher sequence: remote=${result.best.sequence}, local=${localSeq}`
+ );
+ await this.handleHigherSequenceDiscovered(result.best);
+ } else if (hasDifferentCid) {
+ console.log(
+ `📦 IPNS poll detected different CID at same sequence: ` +
+ `remote=${result.best.cid.slice(0, 16)}... != local=${localCid?.slice(0, 16)}...`
+ );
+ await this.handleHigherSequenceDiscovered(result.best);
+ } else {
+ console.log(
+ `📦 IPNS poll: no updates (remote seq=${result.best.sequence}, local seq=${localSeq}, ` +
+ `cid match=${result.best.cid === localCid})`
+ );
+ }
+ }
+
+ // Run spent token sanity check after checking for remote updates
+ await this.runSpentTokenSanityCheck();
+ };
+
+ // Calculate random interval with jitter (uses longer interval when tab is inactive)
+ const getRandomInterval = () => {
+ const config = IPNS_RESOLUTION_CONFIG;
+ const minMs = this.isTabVisible ? config.pollingIntervalMinMs : config.inactivePollingIntervalMinMs;
+ const maxMs = this.isTabVisible ? config.pollingIntervalMaxMs : config.inactivePollingIntervalMaxMs;
+ return minMs + Math.random() * (maxMs - minMs);
+ };
+
+ // Schedule next poll with jitter
+ const scheduleNextPoll = () => {
+ const interval = getRandomInterval();
+ this.ipnsPollingInterval = setTimeout(async () => {
+ await poll();
+ scheduleNextPoll();
+ }, interval);
+ };
+
+ // Start polling
+ scheduleNextPoll();
+ const intervalDesc = this.isTabVisible
+ ? `${IPNS_RESOLUTION_CONFIG.pollingIntervalMinMs/1000}-${IPNS_RESOLUTION_CONFIG.pollingIntervalMaxMs/1000}s`
+ : `${IPNS_RESOLUTION_CONFIG.inactivePollingIntervalMinMs/1000}-${IPNS_RESOLUTION_CONFIG.inactivePollingIntervalMaxMs/1000}s (inactive)`;
+ console.log(`📦 IPNS polling started (interval: ${intervalDesc})`);
+
+ // Run first poll after a short delay
+ setTimeout(poll, 5000);
+ }
+
+ /**
+ * Stop IPNS polling (when tab becomes hidden)
+ */
+ private stopIpnsPolling(): void {
+ if (this.ipnsPollingInterval) {
+ clearTimeout(this.ipnsPollingInterval);
+ this.ipnsPollingInterval = null;
+ console.log(`📦 IPNS polling stopped`);
+ }
+ }
+
+ /**
+ * Handle tab visibility changes
+ * Adjusts polling interval based on tab visibility (slower when inactive)
+ */
+ private handleVisibilityChange = (): void => {
+ const wasVisible = this.isTabVisible;
+ this.isTabVisible = document.visibilityState === "visible";
+
+ if (this.isTabVisible !== wasVisible) {
+ // Restart polling with new interval
+ this.stopIpnsPolling();
+ if (this.isTabVisible) {
+ console.log(`📦 Tab visible, switching to active polling interval (45-75s)`);
+ } else {
+ console.log(`📦 Tab hidden, switching to slower polling interval (4-4.5 min)`);
+ }
+ this.startIpnsPolling();
+ }
+ };
+
+ /**
+ * Set up visibility change listener for polling control
+ */
+ private setupVisibilityListener(): void {
+ if (this.boundVisibilityHandler) {
+ return; // Already set up
+ }
+
+ // Initialize visibility state
+ this.isTabVisible = document.visibilityState === "visible";
+
+ this.boundVisibilityHandler = this.handleVisibilityChange;
+ document.addEventListener("visibilitychange", this.boundVisibilityHandler);
+ console.log(`📦 Visibility listener registered (tab ${this.isTabVisible ? "visible" : "hidden"})`);
+
+ // Always start polling (with appropriate interval based on visibility)
+ this.startIpnsPolling();
+ }
+
+ /**
+ * Remove visibility listener and stop polling
+ */
+ private cleanupVisibilityListener(): void {
+ if (this.boundVisibilityHandler) {
+ document.removeEventListener("visibilitychange", this.boundVisibilityHandler);
+ this.boundVisibilityHandler = null;
+ }
+ this.stopIpnsPolling();
+ }
+
+ // ==========================================
+ // Backend Connection Maintenance
+ // ==========================================
+
+ /**
+ * Maintain a persistent connection to the backend IPFS node
+ * This ensures bitswap can function properly for content transfer
+ */
+ private startBackendConnectionMaintenance(): void {
+ const backendPeerId = getBackendPeerId();
+ if (!backendPeerId || !this.helia) {
+ return;
+ }
+
+ // Import peerIdFromString dynamically
+ const maintainConnection = async () => {
+ if (!this.helia) return;
+
+ try {
+ // Check if we're connected to the backend
+ const connections = this.helia.libp2p.getConnections();
+ const isConnected = connections.some(
+ (conn) => conn.remotePeer.toString() === backendPeerId
+ );
+
+ if (!isConnected) {
+ console.log(`📦 Backend peer disconnected, reconnecting...`);
+ // The bootstrap will reconnect automatically, but we can also dial directly
+ const bootstrapPeers = getBootstrapPeers();
+ const backendAddr = bootstrapPeers.find((addr) =>
+ addr.includes(backendPeerId)
+ );
+ if (backendAddr) {
+ try {
+ const { multiaddr } = await import("@multiformats/multiaddr");
+ await this.helia.libp2p.dial(multiaddr(backendAddr));
+ console.log(`📦 Reconnected to backend peer`);
+ } catch (dialError) {
+ console.warn(`📦 Failed to reconnect to backend:`, dialError);
+ }
+ }
+ } else {
+ // Connection exists, log status
+ const backendConn = connections.find(
+ (conn) => conn.remotePeer.toString() === backendPeerId
+ );
+ if (backendConn) {
+ console.log(`📦 Backend connection alive: ${backendConn.remoteAddr.toString()}`);
+ }
+ }
+ } catch (error) {
+ console.warn(`📦 Connection maintenance error:`, error);
+ }
+ };
+
+ // Run immediately
+ setTimeout(maintainConnection, 2000);
+
+ // Then periodically (every 30 seconds)
+ this.connectionMaintenanceInterval = setInterval(maintainConnection, 30000);
+ console.log(`📦 Backend connection maintenance started`);
+ }
+
+ /**
+ * Ensure backend is connected before storing content
+ * Returns true if connected or successfully reconnected
+ */
+ private async ensureBackendConnected(): Promise {
+ const backendPeerId = getBackendPeerId();
+ if (!backendPeerId || !this.helia) {
+ return false;
+ }
+
+ const connections = this.helia.libp2p.getConnections();
+ const isConnected = connections.some(
+ (conn) => conn.remotePeer.toString() === backendPeerId
+ );
+
+ if (isConnected) {
+ return true;
+ }
+
+ // Try to reconnect
+ console.log(`📦 Backend not connected, dialing...`);
+ const bootstrapPeers = getBootstrapPeers();
+ const backendAddr = bootstrapPeers.find((addr) =>
+ addr.includes(backendPeerId)
+ );
+
+ if (backendAddr) {
+ try {
+ const { multiaddr } = await import("@multiformats/multiaddr");
+ await this.helia.libp2p.dial(multiaddr(backendAddr));
+ console.log(`📦 Connected to backend for content transfer`);
+ return true;
+ } catch (error) {
+ console.warn(`📦 Failed to connect to backend:`, error);
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ // ==========================================
+ // Version Counter Management
+ // ==========================================
+
+ /**
+ * Get current version counter for this wallet
+ */
+ private getVersionCounter(): number {
+ if (!this.cachedIpnsName) return 0;
+ const key = `${VERSION_STORAGE_PREFIX}${this.cachedIpnsName}`;
+ return parseInt(localStorage.getItem(key) || "0", 10);
+ }
+
+ /**
+ * Increment and return new version counter
+ */
+ private incrementVersionCounter(): number {
+ if (!this.cachedIpnsName) return 1;
+ const key = `${VERSION_STORAGE_PREFIX}${this.cachedIpnsName}`;
+ const current = this.getVersionCounter();
+ const next = current + 1;
+ localStorage.setItem(key, String(next));
+ return next;
+ }
+
+ /**
+ * Set version counter to specific value (used after merge)
+ */
+ private setVersionCounter(version: number): void {
+ if (!this.cachedIpnsName) return;
+ const key = `${VERSION_STORAGE_PREFIX}${this.cachedIpnsName}`;
+ localStorage.setItem(key, String(version));
+ }
+
+ /**
+ * Get last stored CID for this wallet
+ */
+ private getLastCid(): string | null {
+ if (!this.cachedIpnsName) return null;
+ const key = `${CID_STORAGE_PREFIX}${this.cachedIpnsName}`;
+ return localStorage.getItem(key);
+ }
+
+ /**
+ * Store last CID for recovery
+ */
+ private setLastCid(cid: string): void {
+ if (!this.cachedIpnsName) return;
+ const key = `${CID_STORAGE_PREFIX}${this.cachedIpnsName}`;
+ localStorage.setItem(key, cid);
+ }
+
+ // ==========================================
+ // Pending IPNS Publish Tracking
+ // ==========================================
+
+ /**
+ * Get pending IPNS publish CID (if previous publish failed)
+ */
+ private getPendingIpnsPublish(): string | null {
+ if (!this.cachedIpnsName) return null;
+ const key = `${PENDING_IPNS_PREFIX}${this.cachedIpnsName}`;
+ return localStorage.getItem(key);
+ }
+
+ /**
+ * Set pending IPNS publish CID for retry
+ */
+ private setPendingIpnsPublish(cid: string): void {
+ if (!this.cachedIpnsName) return;
+ const key = `${PENDING_IPNS_PREFIX}${this.cachedIpnsName}`;
+ localStorage.setItem(key, cid);
+ console.log(`📦 IPNS publish marked as pending for CID: ${cid.slice(0, 16)}...`);
+ }
+
+ /**
+ * Clear pending IPNS publish after successful publish
+ */
+ private clearPendingIpnsPublish(): void {
+ if (!this.cachedIpnsName) return;
+ const key = `${PENDING_IPNS_PREFIX}${this.cachedIpnsName}`;
+ localStorage.removeItem(key);
+ }
+
+ /**
+ * Retry any pending IPNS publish from previous failed sync
+ */
+ private async retryPendingIpnsPublish(): Promise {
+ const pendingCid = this.getPendingIpnsPublish();
+ if (!pendingCid) return true; // No pending publish
+
+ console.log(`📦 Retrying pending IPNS publish for CID: ${pendingCid.slice(0, 16)}...`);
+
+ try {
+ const { CID } = await import("multiformats/cid");
+ const cid = CID.parse(pendingCid);
+ const result = await this.publishToIpns(cid);
+
+ if (result) {
+ this.clearPendingIpnsPublish();
+ this.setLastCid(pendingCid);
+ console.log(`📦 Pending IPNS publish succeeded`);
+ return true;
+ }
+ return false;
+ } catch (error) {
+ console.warn(`📦 Pending IPNS publish retry failed:`, error);
+ return false;
+ }
+ }
+
+ // ==========================================
+ // IPNS Sync Helpers
+ // ==========================================
+
+ /**
+ * Fetch remote content from IPFS by CID
+ * Returns the TXF storage data or null if fetch fails
+ */
+ private async fetchRemoteContent(cidString: string): Promise {
+ if (!this.helia) return null;
+
+ const FETCH_TIMEOUT = 15000; // 15 seconds
+
+ try {
+ console.log(`📦 Fetching remote content: ${cidString.slice(0, 16)}...`);
+ const j = json(this.helia);
+ const { CID } = await import("multiformats/cid");
+ const cid = CID.parse(cidString);
+
+ const data = await Promise.race([
+ j.get(cid),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("Fetch timeout")), FETCH_TIMEOUT)
+ ),
+ ]);
+
+ // Validate it's TXF format
+ if (data && typeof data === "object" && "_meta" in (data as object)) {
+ console.log(`📦 Remote content fetched successfully`);
+ return data as TxfStorageData;
+ }
+
+ console.warn(`📦 Remote content is not valid TXF format`);
+ return null;
+ } catch (error) {
+ console.warn(`📦 Failed to fetch CID ${cidString.slice(0, 16)}...:`, error);
+ return null;
+ }
+ }
+
+ // ==========================================
+ // Sanity Check Methods (Token Loss Prevention)
+ // ==========================================
+
+ /**
+ * Sanity check tombstones before applying deletions
+ * Verifies each tombstoned token is actually spent on Unicity
+ * Returns tokens that should NOT be deleted (false tombstones)
+ */
+ private async sanityCheckTombstones(
+ tombstonesToApply: TombstoneEntry[],
+ walletRepo: WalletRepository
+ ): Promise<{
+ validTombstones: TombstoneEntry[];
+ invalidTombstones: TombstoneEntry[];
+ tokensToRestore: Array<{ tokenId: string; txf: TxfToken }>;
+ }> {
+ const validTombstones: TombstoneEntry[] = [];
+ const invalidTombstones: TombstoneEntry[] = [];
+ const tokensToRestore: Array<{ tokenId: string; txf: TxfToken }> = [];
+
+ if (tombstonesToApply.length === 0) {
+ return { validTombstones, invalidTombstones, tokensToRestore };
+ }
+
+ // Get identity for verification
+ const identity = await this.identityManager.getCurrentIdentity();
+ if (!identity) {
+ console.warn("⚠️ No identity available, skipping tombstone verification (accepting all tombstones)");
+ return { validTombstones: tombstonesToApply, invalidTombstones: [], tokensToRestore: [] };
+ }
+
+ // Build Map of tokenId -> TxfToken from archived versions
+ const tokensToCheck = new Map();
+ for (const tombstone of tombstonesToApply) {
+ const archivedVersion = walletRepo.getBestArchivedVersion(tombstone.tokenId);
+ if (archivedVersion) {
+ tokensToCheck.set(tombstone.tokenId, archivedVersion);
+ }
+ }
+
+ if (tokensToCheck.size === 0) {
+ console.warn("⚠️ No archived tokens available for verification, accepting all tombstones");
+ return { validTombstones: tombstonesToApply, invalidTombstones: [], tokensToRestore: [] };
+ }
+
+ // Check which tokens are NOT spent (should not be deleted)
+ const validationService = getTokenValidationService();
+ const publicKey = identity.publicKey;
+ const unspentTokenIds = await validationService.checkUnspentTokens(tokensToCheck, publicKey);
+ const unspentSet = new Set(unspentTokenIds);
+
+ // Categorize tombstones
+ for (const tombstone of tombstonesToApply) {
+ if (unspentSet.has(tombstone.tokenId)) {
+ // Token is NOT spent - tombstone is invalid
+ invalidTombstones.push(tombstone);
+
+ // Find best version to restore
+ const bestVersion = walletRepo.getBestArchivedVersion(tombstone.tokenId);
+ if (bestVersion) {
+ tokensToRestore.push({ tokenId: tombstone.tokenId, txf: bestVersion });
+ }
+
+ console.log(`⚠️ Invalid tombstone for ${tombstone.tokenId.slice(0, 8)}... - token is NOT spent on Unicity`);
+ } else {
+ // Token is spent - tombstone is valid
+ validTombstones.push(tombstone);
+ }
+ }
+
+ if (tombstonesToApply.length > 0) {
+ console.log(`📦 Tombstone sanity check: ${validTombstones.length} valid, ${invalidTombstones.length} invalid`);
+ }
+
+ return { validTombstones, invalidTombstones, tokensToRestore };
+ }
+
+ /**
+ * Check for tokens missing from remote collection (not tombstoned, just absent)
+ * This handles case where remote "jumped over" a version
+ * Returns tokens that should be preserved (unspent on Unicity)
+ */
+ private async sanityCheckMissingTokens(
+ localTokens: Token[],
+ remoteTokenIds: Set,
+ remoteTombstoneIds: Set
+ ): Promise> {
+ const tokensToPreserve: Array<{ tokenId: string; txf: TxfToken }> = [];
+
+ // Find tokens that are in local but missing from remote (and not tombstoned)
+ const missingTokens: Token[] = [];
+ for (const token of localTokens) {
+ const txf = tokenToTxf(token);
+ if (!txf) continue;
+
+ const tokenId = txf.genesis.data.tokenId;
+ if (!remoteTokenIds.has(tokenId) && !remoteTombstoneIds.has(tokenId)) {
+ missingTokens.push(token);
+ }
+ }
+
+ if (missingTokens.length === 0) return [];
+
+ console.log(`📦 Found ${missingTokens.length} token(s) missing from remote (no tombstone)`);
+
+ // Get identity for verification
+ const identity = await this.identityManager.getCurrentIdentity();
+ if (!identity) {
+ console.warn("⚠️ No identity available, preserving all missing tokens (safe fallback)");
+ // Safe fallback: preserve all missing tokens
+ for (const token of missingTokens) {
+ const txf = tokenToTxf(token);
+ if (txf) {
+ tokensToPreserve.push({ tokenId: txf.genesis.data.tokenId, txf });
+ }
+ }
+ return tokensToPreserve;
+ }
+
+ // Build Map of tokenId -> TxfToken for verification
+ const tokensToCheck = new Map();
+ for (const token of missingTokens) {
+ const txf = tokenToTxf(token);
+ if (!txf) continue;
+
+ const tokenId = txf.genesis.data.tokenId;
+ tokensToCheck.set(tokenId, txf);
+ }
+
+ if (tokensToCheck.size === 0) return tokensToPreserve;
+
+ // Check which are unspent (should be preserved)
+ const validationService = getTokenValidationService();
+ const publicKey = identity.publicKey;
+ const unspentTokenIds = await validationService.checkUnspentTokens(tokensToCheck, publicKey);
+ const unspentSet = new Set(unspentTokenIds);
+
+ for (const [tokenId, txf] of tokensToCheck) {
+ if (unspentSet.has(tokenId)) {
+ // Token is NOT spent - should be preserved
+ tokensToPreserve.push({ tokenId, txf });
+ console.log(`📦 Preserving missing token ${tokenId.slice(0, 8)}... - NOT spent on Unicity`);
+ } else {
+ console.log(`📦 Token ${tokenId.slice(0, 8)}... legitimately removed (spent on Unicity)`);
+ }
+ }
+
+ return tokensToPreserve;
+ }
+
+ /**
+ * Verify integrity invariants after sync operations
+ * All spent tokens should have both tombstone and archive entry
+ */
+ private verifyIntegrityInvariants(walletRepo: WalletRepository): void {
+ const tombstones = walletRepo.getTombstones();
+ const archivedTokens = walletRepo.getArchivedTokens();
+ const activeTokens = walletRepo.getTokens();
+
+ let issues = 0;
+
+ // Check 1: Every tombstoned token should have archive entry
+ for (const tombstone of tombstones) {
+ if (!archivedTokens.has(tombstone.tokenId)) {
+ console.warn(`⚠️ Integrity: Tombstone ${tombstone.tokenId.slice(0, 8)}... has no archive entry`);
+ issues++;
+ }
+ }
+
+ // Check 2: Active tokens should not be tombstoned
+ const tombstoneKeySet = new Set(
+ tombstones.map(t => `${t.tokenId}:${t.stateHash}`)
+ );
+
+ for (const token of activeTokens) {
+ const txf = tokenToTxf(token);
+ if (!txf) continue;
+
+ const tokenId = txf.genesis.data.tokenId;
+ const stateHash = getCurrentStateHash(txf);
+ const key = `${tokenId}:${stateHash}`;
+
+ if (tombstoneKeySet.has(key)) {
+ console.warn(`⚠️ Integrity: Active token ${tokenId.slice(0, 8)}... matches a tombstone`);
+ issues++;
+ }
+ }
+
+ if (issues > 0) {
+ console.warn(`⚠️ Integrity check found ${issues} issue(s)`);
+ } else {
+ console.log(`✅ Integrity check passed`);
+ }
+ }
+
+ // ==========================================
+ // Sync Decision Helpers
+ // ==========================================
+
+ /**
+ * Compare two TXF tokens and determine which is "better"
+ * Returns: "local" if local wins, "remote" if remote wins, "equal" if identical
+ *
+ * CRITICAL: Committed transactions ALWAYS beat pending transactions!
+ * This prevents a device with 3 pending (unsubmittable) transactions from
+ * overwriting a device with 1 committed transaction.
+ *
+ * Rules:
+ * 1) Committed beats pending (committed transactions always win over pending-only)
+ * 2) Longer COMMITTED chain wins (not total chain length!)
+ * 3) More proofs wins (including genesis proof)
+ * 4) Identical state hashes = equal
+ * 5) Deterministic tiebreaker for forks
+ */
+ private compareTokenVersions(localTxf: TxfToken, remoteTxf: TxfToken): "local" | "remote" | "equal" {
+ // Helper to count COMMITTED transactions (those with inclusion proof)
+ const countCommitted = (txf: TxfToken): number => {
+ return txf.transactions.filter(tx => tx.inclusionProof !== null).length;
+ };
+
+ const localCommitted = countCommitted(localTxf);
+ const remoteCommitted = countCommitted(remoteTxf);
+
+ // 1. COMMITTED transactions ALWAYS beat pending
+ // Token with committed transactions beats token with only pending transactions
+ const localHasPending = localTxf.transactions.some(tx => tx.inclusionProof === null);
+ const remoteHasPending = remoteTxf.transactions.some(tx => tx.inclusionProof === null);
+
+ if (localCommitted > 0 && remoteCommitted === 0 && remoteHasPending) {
+ // Local has committed, remote has only pending - local wins
+ console.log(`📦 compareTokenVersions: Local wins (committed=${localCommitted} beats pending-only remote)`);
+ return "local";
+ }
+ if (remoteCommitted > 0 && localCommitted === 0 && localHasPending) {
+ // Remote has committed, local has only pending - remote wins
+ console.log(`📦 compareTokenVersions: Remote wins (committed=${remoteCommitted} beats pending-only local)`);
+ return "remote";
+ }
+
+ // 2. Compare COMMITTED chain lengths (not total length!)
+ if (localCommitted > remoteCommitted) {
+ console.log(`📦 compareTokenVersions: Local wins (${localCommitted} committed > ${remoteCommitted} committed)`);
+ return "local";
+ }
+ if (remoteCommitted > localCommitted) {
+ console.log(`📦 compareTokenVersions: Remote wins (${remoteCommitted} committed > ${localCommitted} committed)`);
+ return "remote";
+ }
+
+ // 3. Same committed count - check total proofs (including genesis)
+ const countProofs = (txf: TxfToken): number => {
+ let count = txf.genesis?.inclusionProof ? 1 : 0;
+ count += txf.transactions.filter(tx => tx.inclusionProof !== null).length;
+ return count;
+ };
+
+ const localProofs = countProofs(localTxf);
+ const remoteProofs = countProofs(remoteTxf);
+
+ if (localProofs > remoteProofs) return "local";
+ if (remoteProofs > localProofs) return "remote";
+
+ // 4. Check if last transaction states differ (fork detection)
+ const localStateHash = getCurrentStateHash(localTxf);
+ const remoteStateHash = getCurrentStateHash(remoteTxf);
+
+ if (localStateHash === remoteStateHash) {
+ return "equal"; // Identical tokens
+ }
+
+ // 5. Deterministic tiebreaker for forks (use genesis hash)
+ const localGenesisHash = localTxf._integrity?.genesisDataJSONHash || "";
+ const remoteGenesisHash = remoteTxf._integrity?.genesisDataJSONHash || "";
+
+ if (localGenesisHash > remoteGenesisHash) return "local";
+ if (remoteGenesisHash > localGenesisHash) return "remote";
+
+ return "local"; // Ultimate fallback: prefer local
+ }
+
+ /**
+ * Check if local differs from remote in any way that requires sync
+ * Returns true if we need to sync local changes to remote
+ */
+ private localDiffersFromRemote(remoteData: TxfStorageData): boolean {
+ const walletRepo = WalletRepository.getInstance();
+ const localTokens = walletRepo.getTokens();
+
+ // Check if local nametag differs from remote
+ const localNametag = walletRepo.getNametag();
+ const remoteNametag = remoteData._nametag;
+
+ if (localNametag && !remoteNametag) {
+ console.log(`📦 Local has nametag "${localNametag.name}" not in remote`);
+ return true;
+ }
+ if (localNametag && remoteNametag && localNametag.name !== remoteNametag.name) {
+ console.log(`📦 Local nametag "${localNametag.name}" differs from remote "${remoteNametag.name}"`);
+ return true;
+ }
+
+ // Extract remote tokens as TxfToken map
+ const remoteTokenMap = new Map();
+ for (const key of Object.keys(remoteData)) {
+ if (isTokenKey(key)) {
+ const tokenId = tokenIdFromKey(key);
+ const remoteTxf = remoteData[key] as TxfToken;
+ if (remoteTxf?.genesis?.data?.tokenId) {
+ remoteTokenMap.set(tokenId, remoteTxf);
+ }
+ }
+ }
+
+ // Check each local token
+ for (const token of localTokens) {
+ const localTxf = tokenToTxf(token);
+ if (!localTxf) continue;
+
+ const tokenId = localTxf.genesis.data.tokenId;
+ const remoteTxf = remoteTokenMap.get(tokenId);
+
+ if (!remoteTxf) {
+ // Local has token that remote doesn't
+ console.log(`📦 Local has token ${tokenId.slice(0, 8)}... not in remote`);
+ return true;
+ }
+
+ // Compare versions - if local is better, we need to sync
+ const comparison = this.compareTokenVersions(localTxf, remoteTxf);
+ if (comparison === "local") {
+ const localCommitted = localTxf.transactions.filter(tx => tx.inclusionProof !== null).length;
+ const remoteCommitted = remoteTxf.transactions.filter(tx => tx.inclusionProof !== null).length;
+ console.log(`📦 Local token ${tokenId.slice(0, 8)}... is better than remote (local: ${localCommitted} committed, remote: ${remoteCommitted} committed)`);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // ==========================================
+ // Data Import Methods
+ // ==========================================
+
+ /**
+ * Import remote data into local storage
+ * - Imports tokens that don't exist locally (unless tombstoned)
+ * - Removes local tokens that are tombstoned in remote (with Unicity verification)
+ * - Handles missing tokens (tokens in local but not in remote)
+ * - Merges tombstones from remote
+ * - Imports nametag if local doesn't have one
+ */
+ private async importRemoteData(remoteTxf: TxfStorageData): Promise {
+ const walletRepo = WalletRepository.getInstance();
+
+ // Debug: Log raw tombstones from remote data
+ const rawTombstones = (remoteTxf as Record)._tombstones;
+ console.log(`📦 Raw remote _tombstones field:`, rawTombstones);
+
+ const { tokens: remoteTokens, nametag, tombstones: remoteTombstones, archivedTokens: remoteArchived, forkedTokens: remoteForked, outboxEntries: remoteOutbox } = parseTxfStorageData(remoteTxf);
+
+ // Import outbox entries from remote (CRITICAL for transfer recovery)
+ if (remoteOutbox && remoteOutbox.length > 0) {
+ const outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.importFromRemote(remoteOutbox);
+ console.log(`📦 Imported ${remoteOutbox.length} outbox entries from remote`);
+ }
+
+ // Debug: Log parsed tombstones (now TombstoneEntry[])
+ console.log(`📦 Parsed remote tombstones (${remoteTombstones.length}):`,
+ remoteTombstones.map(t => `${t.tokenId.slice(0, 8)}:${t.stateHash.slice(0, 8)}`));
+
+ // Get local tokens and tombstones
+ const localTokens = walletRepo.getWallet()?.tokens || [];
+ const localTokenIds = new Set(localTokens.map(t => t.id));
+ const localTombstones = walletRepo.getTombstones();
+
+ // Debug: Log local token IDs for comparison
+ console.log(`📦 Local token IDs (${localTokenIds.size}):`, [...localTokenIds].map(id => id.slice(0, 8) + '...'));
+
+ let importedCount = 0;
+
+ // ==========================================
+ // SANITY CHECKS - Prevent token loss from race conditions
+ // ==========================================
+
+ // 1. Build remote token ID set for missing token detection
+ const remoteTokenIds = new Set();
+ for (const token of remoteTokens) {
+ const txf = tokenToTxf(token);
+ if (txf) remoteTokenIds.add(txf.genesis.data.tokenId);
+ }
+
+ // 2. Build remote tombstone ID set
+ const remoteTombstoneIds = new Set(remoteTombstones.map(t => t.tokenId));
+
+ // 3. Check for missing tokens (local tokens absent from remote without tombstone)
+ const tokensToPreserveFromMissing = await this.sanityCheckMissingTokens(
+ localTokens,
+ remoteTokenIds,
+ remoteTombstoneIds
+ );
+
+ // 4. Get new tombstones that would be applied
+ const localTombstoneKeys = new Set(
+ localTombstones.map(t => `${t.tokenId}:${t.stateHash}`)
+ );
+ const newTombstones = remoteTombstones.filter(
+ t => !localTombstoneKeys.has(`${t.tokenId}:${t.stateHash}`)
+ );
+
+ // 5. Sanity check new tombstones with Unicity
+ let tokensToRestore: Array<{ tokenId: string; txf: TxfToken }> = [];
+ let validTombstones = newTombstones;
+
+ if (newTombstones.length > 0) {
+ console.log(`📦 Sanity checking ${newTombstones.length} new tombstone(s) with Unicity...`);
+ const result = await this.sanityCheckTombstones(newTombstones, walletRepo);
+ validTombstones = result.validTombstones;
+ tokensToRestore = result.tokensToRestore;
+
+ if (result.invalidTombstones.length > 0) {
+ console.log(`⚠️ Rejected ${result.invalidTombstones.length} invalid tombstone(s)`);
+ }
+ }
+
+ // 6. Combine tokens to preserve/restore
+ const allTokensToRestore = [...tokensToRestore, ...tokensToPreserveFromMissing];
+
+ // 7. Restore any tokens that should not be deleted
+ for (const { tokenId, txf } of allTokensToRestore) {
+ walletRepo.restoreTokenFromArchive(tokenId, txf);
+ }
+
+ // 8. Apply only valid tombstones (not the rejected invalid ones)
+ const tombstonesToApply = [...localTombstones];
+ for (const t of validTombstones) {
+ if (!localTombstoneKeys.has(`${t.tokenId}:${t.stateHash}`)) {
+ tombstonesToApply.push(t);
+ }
+ }
+
+ // Merge valid tombstones - this removes local tokens whose state matches tombstones
+ if (tombstonesToApply.length > 0) {
+ console.log(`📦 Processing ${tombstonesToApply.length} valid tombstone(s)`);
+ const removedCount = walletRepo.mergeTombstones(tombstonesToApply);
+ if (removedCount > 0) {
+ console.log(`📦 Removed ${removedCount} tombstoned token(s) from local`);
+ }
+ }
+
+ // ==========================================
+ // IMPORT/UPDATE TOKENS FROM REMOTE
+ // ==========================================
+
+ // Build combined tombstone lookup (tokenId:stateHash -> true)
+ const allTombstoneKeys = new Set();
+ for (const t of walletRepo.getTombstones()) {
+ allTombstoneKeys.add(`${t.tokenId}:${t.stateHash}`);
+ }
+
+ // Build local token map for comparison (re-get as they may have changed after restore)
+ const currentLocalTokens = walletRepo.getWallet()?.tokens || [];
+ const localTokenMap = new Map();
+ for (const token of currentLocalTokens) {
+ const txf = tokenToTxf(token);
+ if (txf) {
+ localTokenMap.set(txf.genesis.data.tokenId, token);
+ }
+ }
+
+ for (const remoteToken of remoteTokens) {
+ // Extract tokenId and stateHash from remote token
+ const remoteTxf = tokenToTxf(remoteToken);
+ if (!remoteTxf) continue;
+
+ const tokenId = remoteTxf.genesis.data.tokenId;
+ const stateHash = getCurrentStateHash(remoteTxf);
+
+ // Skip if this specific state is tombstoned
+ const tombstoneKey = `${tokenId}:${stateHash}`;
+ if (allTombstoneKeys.has(tombstoneKey)) {
+ console.log(`📦 Skipping tombstoned token ${tokenId.slice(0, 8)}... state ${stateHash.slice(0, 8)}... from remote`);
+ continue;
+ }
+
+ const localToken = localTokenMap.get(tokenId);
+
+ if (!localToken) {
+ // NEW token - import it
+ walletRepo.addToken(remoteToken);
+ console.log(`📦 Imported new token ${tokenId.slice(0, 8)}... from remote`);
+ importedCount++;
+ } else {
+ // Token EXISTS in both - compare versions
+ const localTxf = tokenToTxf(localToken);
+ if (!localTxf) continue;
+
+ const comparison = this.compareTokenVersions(localTxf, remoteTxf);
+
+ if (comparison === "remote") {
+ // Remote is BETTER - update local with remote version
+ const localLen = localTxf.transactions.length;
+ const remoteLen = remoteTxf.transactions.length;
+ console.log(`📦 Updating token ${tokenId.slice(0, 8)}... from remote (remote: ${remoteLen} txns > local: ${localLen} txns)`);
+
+ // Archive local version before replacing (in case of fork)
+ const localStateHash = getCurrentStateHash(localTxf);
+ if (localStateHash !== stateHash) {
+ // Different state = fork, archive the losing local version
+ walletRepo.storeForkedToken(tokenId, localStateHash, localTxf);
+ console.log(`📦 Archived forked local version of ${tokenId.slice(0, 8)}... (state ${localStateHash.slice(0, 8)}...)`);
+ }
+
+ // Update with remote version
+ walletRepo.updateToken(remoteToken);
+ importedCount++;
+ } else if (comparison === "local") {
+ // Local is better - keep local, but archive remote if it's a fork
+ const remoteStateHash = getCurrentStateHash(remoteTxf);
+ const localStateHash = getCurrentStateHash(localTxf);
+ if (remoteStateHash !== localStateHash) {
+ // Different state = fork, archive the remote version
+ walletRepo.storeForkedToken(tokenId, remoteStateHash, remoteTxf);
+ console.log(`📦 Archived forked remote version of ${tokenId.slice(0, 8)}... (state ${remoteStateHash.slice(0, 8)}...)`);
+ }
+ }
+ // If "equal", tokens are identical - nothing to do
+ }
+ }
+
+ // ==========================================
+ // IMPORT METADATA & ARCHIVES
+ // ==========================================
+
+ // Import nametag if local doesn't have one
+ if (nametag && !walletRepo.getNametag()) {
+ walletRepo.setNametag(nametag);
+ console.log(`📦 Imported nametag "${nametag.name}" from remote`);
+ }
+
+ // Merge archived and forked tokens from remote
+ if (remoteArchived.size > 0) {
+ const archivedMergedCount = walletRepo.mergeArchivedTokens(remoteArchived);
+ if (archivedMergedCount > 0) {
+ console.log(`📦 Merged ${archivedMergedCount} archived token(s) from remote`);
+ }
+ }
+ if (remoteForked.size > 0) {
+ const forkedMergedCount = walletRepo.mergeForkedTokens(remoteForked);
+ if (forkedMergedCount > 0) {
+ console.log(`📦 Merged ${forkedMergedCount} forked token(s) from remote`);
+ }
+ }
+
+ // Prune old tombstones and archives to prevent unlimited growth
+ walletRepo.pruneTombstones();
+ walletRepo.pruneArchivedTokens();
+ walletRepo.pruneForkedTokens();
+
+ // ==========================================
+ // INTEGRITY VERIFICATION
+ // ==========================================
+ this.verifyIntegrityInvariants(walletRepo);
+
+ return importedCount;
+ }
+
+ // ==========================================
+ // Storage Operations
+ // ==========================================
+
+ /**
+ * Schedule a debounced sync
+ * If sync is currently running, marks pendingSync flag for execution after current sync completes
+ */
+ private scheduleSync(): void {
+ // If a sync is already running, mark pending so it will run after completion
+ if (this.isSyncing) {
+ console.log(`📦 Sync in progress - marking pending sync for after completion`);
+ this.pendingSync = true;
+ return;
+ }
+
+ if (this.syncTimer) {
+ clearTimeout(this.syncTimer);
+ }
+ this.syncTimer = setTimeout(() => {
+ this.syncNow().catch(console.error);
+ }, SYNC_DEBOUNCE_MS);
+ }
+
+ /**
+ * Sync from IPNS on startup - resolves IPNS and merges with local state
+ * Uses progressive multi-gateway resolution for conflict detection
+ *
+ * Flow:
+ * 0. Retry any pending IPNS publishes from previous failed syncs
+ * 1. Resolve IPNS progressively from all gateways (highest sequence wins)
+ * 2. Compare with local CID - if different, fetch remote content
+ * 3. Version comparison: remote > local → import; local > remote → sync to update IPNS
+ * 4. Always verify remote is fetchable (handles interrupted syncs)
+ * 5. If fetch fails, fall back to normal sync (republish local)
+ * 6. Late-arriving higher sequences trigger automatic merge
*/
- private async ensureInitialized(): Promise {
- if (this.helia && this.ed25519PrivateKey) {
- return true;
- }
+ async syncFromIpns(): Promise {
+ console.log(`📦 Starting IPNS-based sync...`);
- if (this.isInitializing) {
- // Wait for ongoing initialization
- await new Promise((resolve) => setTimeout(resolve, 100));
- return this.ensureInitialized();
- }
-
- this.isInitializing = true;
+ // Set initial syncing flag for UI feedback
+ this.isInitialSyncing = true;
+ this.emitSyncStateChange();
try {
- // 0. Check WebCrypto availability (required by Helia/libp2p)
- if (!this.isWebCryptoAvailable()) {
- console.warn("📦 WebCrypto (crypto.subtle) not available - IPFS sync disabled");
- console.warn("📦 This typically happens in non-secure contexts (HTTP instead of HTTPS)");
- console.warn("📦 Wallet will continue to work, but IPFS backup/sync is unavailable");
- return false;
+ const initialized = await this.ensureInitialized();
+ if (!initialized) {
+ console.warn(`📦 Not initialized, skipping IPNS sync`);
+ return { success: false, timestamp: Date.now(), error: "Not initialized" };
}
- // 1. Get wallet identity
- const identity = await this.identityManager.getCurrentIdentity();
- if (!identity) {
- console.warn("📦 No wallet identity - skipping IPFS init");
- return false;
- }
+ // 0. Retry any pending IPNS publishes from previous failed syncs
+ await this.retryPendingIpnsPublish();
- // 2. Derive Ed25519 key from secp256k1 private key using HKDF
- const walletSecret = this.hexToBytes(identity.privateKey);
- const derivedKey = hkdf(
- sha256,
- walletSecret,
- undefined, // no salt for deterministic derivation
- HKDF_INFO,
- 32
- );
- this.ed25519PrivateKey = derivedKey;
- this.ed25519PublicKey = ed.getPublicKey(derivedKey);
+ // 1. Resolve IPNS progressively from all gateways
+ // Late arrivals with higher sequence will trigger handleHigherSequenceDiscovered
+ const resolution = await this.resolveIpnsProgressively(
+ (lateResult) => this.handleHigherSequenceDiscovered(lateResult)
+ );
- // 3. Compute IPNS name from public key
- this.cachedIpnsName = this.computeIpnsName(this.ed25519PublicKey);
+ const remoteCid = resolution.best?.cid || null;
+ const localCid = this.getLastCid();
- // 4. Initialize Helia (browser IPFS) with custom bootstrap peers
- const bootstrapPeers = getBootstrapPeers();
- const customPeerCount = getConfiguredCustomPeers().length;
+ // Update last known remote sequence
+ if (resolution.best) {
+ this.lastKnownRemoteSequence = resolution.best.sequence;
+ console.log(
+ `📦 IPNS resolved: seq=${resolution.best.sequence}, ` +
+ `${resolution.respondedCount}/${resolution.totalGateways} gateways responded`
+ );
+ }
- console.log("📦 Initializing Helia with custom peers...");
- console.log(`📦 Bootstrap peers: ${bootstrapPeers.length} total (${customPeerCount} custom, ${bootstrapPeers.length - customPeerCount} default)`);
+ console.log(`📦 IPNS sync: remote=${remoteCid?.slice(0, 16) || 'none'}..., local=${localCid?.slice(0, 16) || 'none'}...`);
- this.helia = await createHelia({
- libp2p: {
- peerDiscovery: [
- bootstrap({ list: bootstrapPeers }),
- ],
- },
- });
+ // Track if IPNS needs recovery (IPNS resolution returned nothing but we have local data)
+ // In this case, we need to force IPNS republish even if CID is unchanged
+ const ipnsNeedsRecovery = !remoteCid && !!localCid;
+ if (ipnsNeedsRecovery) {
+ console.log(`📦 IPNS recovery needed - IPNS empty but local CID exists`);
+ }
- console.log("📦 IPFS storage service initialized");
- console.log("📦 IPNS name:", this.cachedIpnsName);
- return true;
- } catch (error) {
- console.error("📦 Failed to initialize IPFS storage:", error);
- // Provide helpful context for WebCrypto-related errors
- if (error instanceof Error && error.message.includes("crypto")) {
- console.warn("📦 This error is likely due to missing WebCrypto support");
- console.warn("📦 Consider using HTTPS or a secure development environment");
+ // 2. Determine which CID to fetch
+ const cidToFetch = remoteCid || localCid;
+
+ if (!cidToFetch) {
+ // No IPNS record and no local CID - could be fresh wallet OR failed resolution
+ // CRITICAL: Don't upload if IPNS resolution failed and we have no local data
+ // This prevents overwriting existing remote tokens on wallet restore
+
+ const ipnsResolutionFailed = resolution.respondedCount === 0;
+ const localWallet = WalletRepository.getInstance();
+ const localTokenCount = localWallet.getTokens().length;
+ const localNametag = localWallet.getNametag();
+
+ if (ipnsResolutionFailed && localTokenCount === 0 && !localNametag) {
+ // IPNS resolution failed AND we have no local tokens AND no nametag
+ // This is likely a wallet restore - DO NOT overwrite remote!
+ console.warn(`📦 IPNS resolution failed (0/${resolution.totalGateways} responded) and no local tokens`);
+ console.warn(`📦 Skipping upload to prevent overwriting existing remote tokens`);
+ console.warn(`📦 Will retry IPNS resolution on next poll`);
+ return {
+ success: false,
+ timestamp: Date.now(),
+ error: "IPNS resolution failed - waiting for successful resolution before sync"
+ };
}
- return false;
- } finally {
- this.isInitializing = false;
+
+ console.log(`📦 No IPNS record or local CID - fresh wallet, triggering initial sync`);
+ return this.syncNow();
}
- }
- // ==========================================
- // Key Derivation Utilities
- // ==========================================
+ // 3. Check if remote CID differs from local (another device may have updated IPNS)
+ if (remoteCid && remoteCid !== localCid) {
+ console.log(`📦 IPNS CID differs from local! Remote may have been updated from another device`);
+ }
- private hexToBytes(hex: string): Uint8Array {
- const bytes = new Uint8Array(hex.length / 2);
- for (let i = 0; i < bytes.length; i++) {
- bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
+ // 4. Always try to fetch and verify remote content
+ // This handles cases where previous sync was interrupted
+ // Use cached content from gateway path if available (avoids re-fetch)
+ let remoteData: TxfStorageData | null = null;
+
+ if (resolution.best?._cachedContent && resolution.best.cid === cidToFetch) {
+ // Use cached content from gateway path resolution (fast path)
+ remoteData = resolution.best._cachedContent;
+ console.log(`📦 Using cached content from gateway path (avoided re-fetch)`);
+ } else {
+ // Fetch content via IPFS
+ remoteData = await this.fetchRemoteContent(cidToFetch);
}
- return bytes;
- }
- private bytesToHex(bytes: Uint8Array): string {
- return Array.from(bytes)
- .map((b) => b.toString(16).padStart(2, "0"))
- .join("");
- }
+ if (!remoteData) {
+ // Could not fetch remote content - republish local
+ // Force IPNS publish if IPNS was empty (recovery scenario)
+ console.warn(`📦 Failed to fetch remote content (CID: ${cidToFetch.slice(0, 16)}...), will republish local`);
+ return this.syncNow({ forceIpnsPublish: ipnsNeedsRecovery });
+ }
- /**
- * Compute IPNS name from Ed25519 public key
- * Format: Base36-encoded CIDv1 of the public key
- */
- private computeIpnsName(publicKey: Uint8Array): string {
- // For now, use hex-encoded public key as identifier
- // In production, this would be a proper CIDv1/PeerId
- return `ipns-${this.bytesToHex(publicKey).slice(0, 32)}`;
- }
+ // 5. Compare versions and decide action
+ const localVersion = this.getVersionCounter();
+ const remoteVersion = remoteData._meta.version;
- // ==========================================
- // Version Counter Management
- // ==========================================
+ console.log(`📦 Version comparison: local=v${localVersion}, remote=v${remoteVersion}`);
- /**
- * Get current version counter for this wallet
- */
- private getVersionCounter(): number {
- if (!this.cachedIpnsName) return 0;
- const key = `${VERSION_STORAGE_PREFIX}${this.cachedIpnsName}`;
- return parseInt(localStorage.getItem(key) || "0", 10);
- }
+ if (remoteVersion > localVersion) {
+ // Remote is newer - import to local
+ console.log(`📦 Remote is newer (v${remoteVersion} > v${localVersion}), importing...`);
+ const importedCount = await this.importRemoteData(remoteData);
- /**
- * Increment and return new version counter
- */
- private incrementVersionCounter(): number {
- if (!this.cachedIpnsName) return 1;
- const key = `${VERSION_STORAGE_PREFIX}${this.cachedIpnsName}`;
- const current = this.getVersionCounter();
- const next = current + 1;
- localStorage.setItem(key, String(next));
- return next;
- }
+ // Update local version and CID to match remote
+ this.setVersionCounter(remoteVersion);
+ this.setLastCid(cidToFetch);
- /**
- * Set version counter to specific value (used after merge)
- */
- private setVersionCounter(version: number): void {
- if (!this.cachedIpnsName) return;
- const key = `${VERSION_STORAGE_PREFIX}${this.cachedIpnsName}`;
- localStorage.setItem(key, String(version));
- }
+ console.log(`📦 Imported ${importedCount} token(s) from remote, now at v${remoteVersion}`);
- /**
- * Get last stored CID for this wallet
- */
- private getLastCid(): string | null {
- if (!this.cachedIpnsName) return null;
- const key = `${CID_STORAGE_PREFIX}${this.cachedIpnsName}`;
- return localStorage.getItem(key);
- }
+ // If IPNS needs recovery, force publish even though we just imported
+ if (ipnsNeedsRecovery) {
+ console.log(`📦 Content imported but IPNS needs recovery - publishing to IPNS`);
+ return this.syncNow({ forceIpnsPublish: true });
+ }
- /**
- * Store last CID for recovery
- */
- private setLastCid(cid: string): void {
- if (!this.cachedIpnsName) return;
- const key = `${CID_STORAGE_PREFIX}${this.cachedIpnsName}`;
- localStorage.setItem(key, cid);
- }
+ return {
+ success: true,
+ cid: cidToFetch,
+ ipnsName: this.cachedIpnsName || undefined,
+ timestamp: Date.now(),
+ version: remoteVersion,
+ };
+ } else if (remoteVersion < localVersion) {
+ // Local is newer - BUT remote might have new tokens we don't have
+ // (e.g., Browser 2 received token via Nostr while Browser 1 was offline)
+ console.log(`📦 Local is newer (v${localVersion} > v${remoteVersion}), checking for new remote tokens first...`);
+
+ // Import any new tokens from remote before pushing local state
+ const importedCount = await this.importRemoteData(remoteData);
+ if (importedCount > 0) {
+ console.log(`📦 Imported ${importedCount} new token(s) from remote before updating IPNS`);
+ window.dispatchEvent(new Event("wallet-updated"));
+ }
- // ==========================================
- // Storage Operations
- // ==========================================
+ // Only sync if local differs from remote (has unique tokens or better versions)
+ if (this.localDiffersFromRemote(remoteData)) {
+ console.log(`📦 Local differs from remote, syncing merged state...`);
+ return this.syncNow({ forceIpnsPublish: ipnsNeedsRecovery });
+ } else {
+ console.log(`📦 Local now matches remote after import, no sync needed`);
+ // Update local tracking to match remote
+ this.setLastCid(cidToFetch);
+ this.setVersionCounter(remoteVersion);
+
+ // If IPNS needs recovery, force publish even though content is synced
+ if (ipnsNeedsRecovery) {
+ console.log(`📦 Content synced but IPNS needs recovery - publishing to IPNS`);
+ return this.syncNow({ forceIpnsPublish: true });
+ }
- /**
- * Schedule a debounced sync
- */
- private scheduleSync(): void {
- if (this.syncTimer) {
- clearTimeout(this.syncTimer);
+ return {
+ success: true,
+ cid: cidToFetch,
+ ipnsName: this.cachedIpnsName || undefined,
+ timestamp: Date.now(),
+ version: remoteVersion,
+ };
+ }
+ } else {
+ // Same version - remote is in sync
+ // Still update lastCid to match IPNS if resolved
+ if (remoteCid && remoteCid !== localCid) {
+ this.setLastCid(remoteCid);
+ console.log(`📦 Updated local CID to match IPNS`);
+ }
+
+ console.log(`📦 Versions match (v${remoteVersion}), remote verified accessible`);
+
+ // If IPNS needs recovery, force publish even though content is synced
+ if (ipnsNeedsRecovery) {
+ console.log(`📦 Content synced but IPNS needs recovery - publishing to IPNS`);
+ return this.syncNow({ forceIpnsPublish: true });
+ }
+
+ return {
+ success: true,
+ cid: cidToFetch,
+ ipnsName: this.cachedIpnsName || undefined,
+ timestamp: Date.now(),
+ version: remoteVersion,
+ };
+ }
+ } finally {
+ this.isInitialSyncing = false;
+ this.emitSyncStateChange();
}
- this.syncTimer = setTimeout(() => {
- this.syncNow().catch(console.error);
- }, SYNC_DEBOUNCE_MS);
}
/**
* Perform immediate sync to IPFS with TXF format and validation
+ * Uses SyncCoordinator for cross-tab coordination to prevent race conditions
+ * @param options.forceIpnsPublish Force IPNS publish even if CID unchanged (for recovery when IPNS expired)
*/
- async syncNow(): Promise {
+ async syncNow(options?: { forceIpnsPublish?: boolean }): Promise {
+ const { forceIpnsPublish = false } = options || {};
+ // Use SyncCoordinator to acquire distributed lock across browser tabs
+ const coordinator = getSyncCoordinator();
+
if (this.isSyncing) {
return {
success: false,
@@ -411,7 +2385,19 @@ export class IpfsStorageService {
};
}
+ // Try to acquire cross-tab lock
+ const lockAcquired = await coordinator.acquireLock();
+ if (!lockAcquired) {
+ console.log(`📦 Another tab is syncing, skipping this sync`);
+ return {
+ success: false,
+ timestamp: Date.now(),
+ error: "Another tab is syncing",
+ };
+ }
+
this.isSyncing = true;
+ this.emitSyncStateChange();
await this.emitEvent({
type: "storage:started",
@@ -468,7 +2454,15 @@ export class IpfsStorageService {
const j = json(this.helia);
const { CID } = await import("multiformats/cid");
const remoteCid = CID.parse(lastCid);
- const remoteData = await j.get(remoteCid) as unknown;
+
+ // Add timeout to prevent hanging indefinitely when IPFS network is slow
+ const REMOTE_FETCH_TIMEOUT = 15000; // 15 seconds
+ const remoteData = await Promise.race([
+ j.get(remoteCid),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("Remote fetch timeout")), REMOTE_FETCH_TIMEOUT)
+ ),
+ ]) as unknown;
if (remoteData && typeof remoteData === "object" && "_meta" in (remoteData as object)) {
const remoteTxf = remoteData as TxfStorageData;
@@ -478,14 +2472,14 @@ export class IpfsStorageService {
if (remoteVersion !== localVersion) {
console.log(`📦 Version mismatch detected: local v${localVersion} vs remote v${remoteVersion}`);
- // Build local storage data for comparison
+ // Build local storage data for comparison (include tombstones)
const localMeta: Omit = {
version: localVersion,
- timestamp: Date.now(),
address: wallet.address,
ipnsName: this.cachedIpnsName || "",
};
- const localTxf = buildTxfStorageData(validTokens, localMeta, nametag || undefined);
+ const localTombstones = walletRepo.getTombstones();
+ const localTxf = buildTxfStorageData(validTokens, localMeta, nametag || undefined, localTombstones);
// Resolve conflicts
const conflictService = getConflictResolutionService();
@@ -501,6 +2495,55 @@ export class IpfsStorageService {
if (mergeResult.newTokens.length > 0) {
console.log(`📦 Added ${mergeResult.newTokens.length} token(s) from remote`);
+
+ // Save new tokens from remote to local storage (IPFS → localStorage sync)
+ for (const tokenId of mergeResult.newTokens) {
+ const tokenKey = `_${tokenId}`;
+ const txfToken = mergeResult.merged[tokenKey] as TxfToken;
+ if (txfToken) {
+ const token = txfToToken(tokenId, txfToken);
+ walletRepo.addToken(token);
+ console.log(`📦 Synced token ${tokenId.slice(0, 8)}... from IPFS to local`);
+ }
+ }
+ }
+
+ // Process tombstones: merge remote tombstones into local
+ // This removes local tokens that were deleted on other devices
+ const remoteTombstones = remoteTxf._tombstones || [];
+ if (remoteTombstones.length > 0) {
+ console.log(`📦 Processing ${remoteTombstones.length} remote tombstone(s)`);
+ const removedCount = walletRepo.mergeTombstones(remoteTombstones);
+ if (removedCount > 0) {
+ console.log(`📦 Removed ${removedCount} tombstoned token(s) from local during conflict resolution`);
+ }
+ }
+
+ // Merge archived, forked tokens, and outbox entries from remote
+ const { archivedTokens: remoteArchived, forkedTokens: remoteForked, outboxEntries: remoteOutbox } = parseTxfStorageData(remoteTxf);
+ if (remoteArchived.size > 0) {
+ const archivedMergedCount = walletRepo.mergeArchivedTokens(remoteArchived);
+ if (archivedMergedCount > 0) {
+ console.log(`📦 Merged ${archivedMergedCount} archived token(s) from remote`);
+ }
+ }
+ if (remoteForked.size > 0) {
+ const forkedMergedCount = walletRepo.mergeForkedTokens(remoteForked);
+ if (forkedMergedCount > 0) {
+ console.log(`📦 Merged ${forkedMergedCount} forked token(s) from remote`);
+ }
+ }
+ // Import outbox entries from remote (CRITICAL for transfer recovery)
+ if (remoteOutbox && remoteOutbox.length > 0) {
+ const outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.importFromRemote(remoteOutbox);
+ console.log(`📦 Imported ${remoteOutbox.length} outbox entries from remote during conflict resolution`);
+ }
+
+ // Also sync nametag from remote if local doesn't have one
+ if (!nametag && mergeResult.merged._nametag) {
+ walletRepo.setNametag(mergeResult.merged._nametag);
+ console.log(`📦 Synced nametag "${mergeResult.merged._nametag.name}" from IPFS to local`);
}
// Extract tokens from merged data for re-sync
@@ -510,7 +2553,44 @@ export class IpfsStorageService {
// Update local version to merged version
this.setVersionCounter(mergeResult.merged._meta.version);
} else {
- console.log(`📦 Remote is in sync (v${remoteVersion})`);
+ // Remote is in sync - check if local has any changes worth uploading
+ // Extract genesis token IDs from local tokens (same as buildTxfStorageData uses)
+ const localTokenIds = validTokens.map(t => {
+ try {
+ const txf = JSON.parse(t.jsonData || "{}");
+ return txf.genesis?.data?.tokenId || t.id;
+ } catch {
+ return t.id;
+ }
+ }).sort().join(",");
+ // TXF format stores tokens as _tokenId keys (genesis token IDs)
+ const remoteTokenIds = Object.keys(remoteTxf)
+ .filter(k => k.startsWith("_") && k !== "_meta" && k !== "_nametag" && k !== "_tombstones")
+ .map(k => k.slice(1))
+ .sort()
+ .join(",");
+
+ if (localTokenIds === remoteTokenIds && !forceIpnsPublish) {
+ // No changes - remote was verified accessible by startup syncFromIpns()
+ // Skip re-upload for this wallet-updated event
+ // BUT: don't skip if forceIpnsPublish is set (IPNS recovery needed)
+ console.log(`📦 Remote is in sync (v${remoteVersion}) - no changes to upload`);
+ this.isSyncing = false;
+ this.emitSyncStateChange();
+ coordinator.releaseLock(); // Release cross-tab lock on early return
+ return {
+ success: true,
+ cid: lastCid || undefined,
+ ipnsName: this.cachedIpnsName || undefined,
+ timestamp: Date.now(),
+ version: remoteVersion,
+ tokenCount: validTokens.length,
+ };
+ }
+ if (localTokenIds === remoteTokenIds && forceIpnsPublish) {
+ console.log(`📦 Remote is in sync but IPNS recovery needed - continuing to publish IPNS`);
+ }
+ console.log(`📦 Remote version matches but local has token changes - uploading...`);
}
}
} catch (err) {
@@ -519,24 +2599,130 @@ export class IpfsStorageService {
}
}
- // 4. Build TXF storage data with incremented version
+ // 4. Build TXF storage data with incremented version (include tombstones, archives, forks, outbox)
const newVersion = this.incrementVersionCounter();
+ const tombstones = walletRepo.getTombstones();
+ const archivedTokens = walletRepo.getArchivedTokens();
+ const forkedTokens = walletRepo.getForkedTokens();
+
+ // Get outbox entries for IPFS sync (CRITICAL for transfer recovery)
+ const outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.setCurrentAddress(wallet.address);
+ const outboxEntries = outboxRepo.getAllForSync();
+
const meta: Omit = {
version: newVersion,
- timestamp: Date.now(),
address: wallet.address,
ipnsName: this.cachedIpnsName || "",
lastCid: this.getLastCid() || undefined,
};
- const txfStorageData = buildTxfStorageData(tokensToSync, meta, nametag || undefined);
+ const txfStorageData = buildTxfStorageData(tokensToSync, meta, nametag || undefined, tombstones, archivedTokens, forkedTokens, outboxEntries);
+ if (tombstones.length > 0 || archivedTokens.size > 0 || forkedTokens.size > 0 || outboxEntries.length > 0) {
+ console.log(`📦 Including ${tombstones.length} tombstone(s), ${archivedTokens.size} archived, ${forkedTokens.size} forked, ${outboxEntries.length} outbox in sync`);
+ }
+
+ // 4. Ensure backend is connected before storing
+ const backendConnected = await this.ensureBackendConnected();
+ if (backendConnected) {
+ console.log(`📦 Backend connected - content will be available via bitswap`);
+ }
- // 4. Store to IPFS
+ // 4.1. Store to IPFS
const j = json(this.helia);
const cid = await j.add(txfStorageData);
const cidString = cid.toString();
- // 5. Store CID for recovery
+ // 4.2. Wait briefly for bitswap to have a chance to exchange blocks
+ // This gives the backend time to request blocks while we're connected
+ if (backendConnected) {
+ console.log(`📦 Waiting for bitswap block exchange...`);
+ await new Promise((resolve) => setTimeout(resolve, 3000));
+ }
+
+ // 4.3. Multi-node upload: directly upload content to all configured IPFS nodes
+ // This bypasses bitswap limitations since browser can't be directly dialed
+ const gatewayUrls = getAllBackendGatewayUrls();
+ if (gatewayUrls.length > 0) {
+ console.log(`📦 Uploading to ${gatewayUrls.length} IPFS node(s)...`);
+
+ const jsonBlob = new Blob([JSON.stringify(txfStorageData)], {
+ type: "application/json",
+ });
+
+ // Upload to all nodes in parallel
+ const uploadPromises = gatewayUrls.map(async (gatewayUrl) => {
+ try {
+ const formData = new FormData();
+ formData.append("file", jsonBlob, "wallet.json");
+
+ const response = await fetch(
+ `${gatewayUrl}/api/v0/add?pin=true&cid-version=1`,
+ { method: "POST", body: formData }
+ );
+ if (response.ok) {
+ const result = await response.json();
+ const hostname = new URL(gatewayUrl).hostname;
+ console.log(`📦 Uploaded to ${hostname}: ${result.Hash}`);
+ return { success: true, host: gatewayUrl, cid: result.Hash };
+ }
+ return { success: false, host: gatewayUrl, error: response.status };
+ } catch (error) {
+ const hostname = new URL(gatewayUrl).hostname;
+ console.warn(`📦 Upload to ${hostname} failed:`, error);
+ return { success: false, host: gatewayUrl, error };
+ }
+ });
+
+ const results = await Promise.allSettled(uploadPromises);
+ const successful = results.filter(
+ (r) => r.status === "fulfilled" && r.value.success
+ ).length;
+ console.log(`📦 Content uploaded to ${successful}/${gatewayUrls.length} nodes`);
+ }
+
+ // 4.4. Announce content to connected peers (DHT provide)
+ // This helps ensure our backend IPFS node can discover and fetch the content
+ // Use timeout since DHT operations can be slow in browser
+ const PROVIDE_TIMEOUT = 10000; // 10 seconds
+ try {
+ console.log(`📦 Announcing CID to network: ${cidString.slice(0, 16)}...`);
+ await Promise.race([
+ this.helia.routing.provide(cid),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("DHT provide timeout")), PROVIDE_TIMEOUT)
+ ),
+ ]);
+ console.log(`📦 CID announced to network`);
+ } catch (provideError) {
+ // Non-fatal - content is still stored locally
+ console.warn(`📦 Could not announce to DHT (non-fatal):`, provideError);
+ }
+
+ // 4.5. Publish to IPNS only if CID changed (or forced for IPNS recovery)
+ const previousCid = this.getLastCid();
+ let ipnsPublished = false;
+ let ipnsPublishPending = false;
+ const shouldPublishIpns = cidString !== previousCid || forceIpnsPublish;
+ if (shouldPublishIpns) {
+ if (forceIpnsPublish && cidString === previousCid) {
+ console.log(`📦 Forcing IPNS republish (CID unchanged but IPNS may be expired)`);
+ }
+ const ipnsResult = await this.publishToIpns(cid);
+ if (ipnsResult) {
+ ipnsPublished = true;
+ this.clearPendingIpnsPublish(); // Clear any previous pending
+ } else {
+ // IPNS publish failed - mark as pending for retry
+ this.setPendingIpnsPublish(cidString);
+ ipnsPublishPending = true;
+ }
+ } else {
+ console.log(`📦 CID unchanged (${cidString.slice(0, 16)}...) - skipping IPNS publish`);
+ this.clearPendingIpnsPublish(); // Clear any stale pending
+ }
+
+ // 5. Store CID for recovery (even if IPNS failed, content is stored)
this.setLastCid(cidString);
console.log(`📦 Tokens stored to IPFS (v${newVersion}): ${cidString}`);
@@ -551,6 +2737,8 @@ export class IpfsStorageService {
tokenCount: tokensToSync.length,
validationIssues: issues.length > 0 ? issues.map(i => i.reason) : undefined,
conflictsResolved: conflictsResolved > 0 ? conflictsResolved : undefined,
+ ipnsPublished,
+ ipnsPublishPending: ipnsPublishPending || undefined,
};
this.lastSync = result;
@@ -598,6 +2786,98 @@ export class IpfsStorageService {
return result;
} finally {
this.isSyncing = false;
+ this.emitSyncStateChange();
+ // Release cross-tab lock
+ coordinator.releaseLock();
+
+ // Check if a sync was requested while we were busy
+ if (this.pendingSync) {
+ console.log(`📦 Processing pending sync request that arrived during sync`);
+ this.pendingSync = false;
+ // Use setTimeout to avoid deep recursion and allow event loop to process
+ setTimeout(() => {
+ this.scheduleSync();
+ }, 100);
+ }
+ }
+ }
+
+ // ==========================================
+ // Spent Token Sanity Check
+ // ==========================================
+
+ /**
+ * Run sanity check to detect and remove spent tokens
+ * Called during each IPNS poll cycle
+ */
+ private async runSpentTokenSanityCheck(): Promise {
+ console.log("📦 Running spent token sanity check...");
+
+ try {
+ // Get current identity for public key
+ const identity = await this.identityManager.getCurrentIdentity();
+ if (!identity) {
+ console.warn("📦 Sanity check: No identity, skipping");
+ return;
+ }
+
+ // Get all tokens from wallet
+ const walletRepo = WalletRepository.getInstance();
+ const tokens = walletRepo.getTokens();
+
+ if (tokens.length === 0) {
+ console.log("📦 Sanity check: No tokens to check");
+ return;
+ }
+
+ // Run spent token check
+ const validationService = getTokenValidationService();
+ const result = await validationService.checkSpentTokens(tokens, identity.publicKey, {
+ batchSize: 3,
+ onProgress: (completed, total) => {
+ if (completed % 5 === 0 || completed === total) {
+ console.log(`📦 Sanity check progress: ${completed}/${total}`);
+ }
+ },
+ });
+
+ // Remove spent tokens
+ if (result.spentTokens.length > 0) {
+ console.log(`📦 Sanity check found ${result.spentTokens.length} spent token(s):`);
+
+ for (const spent of result.spentTokens) {
+ const tokenIdStr = spent.tokenId || spent.localId || "unknown";
+ const stateHashStr = spent.stateHash || "unknown";
+ console.log(
+ `📦 - Removing spent token ${tokenIdStr.slice(0, 8)}... (state: ${stateHashStr.slice(0, 12)}...)`
+ );
+ // Use skipHistory=true since this is cleanup, not a user-initiated transfer
+ if (spent.localId) {
+ walletRepo.removeToken(spent.localId, undefined, true);
+ }
+ }
+
+ // Emit wallet-updated to refresh UI
+ window.dispatchEvent(new Event("wallet-updated"));
+
+ console.log(`📦 Sanity check complete: removed ${result.spentTokens.length} spent token(s)`);
+ } else {
+ console.log("📦 Sanity check complete: no spent tokens found");
+ }
+
+ // Log any errors (non-fatal)
+ if (result.errors.length > 0) {
+ console.warn(
+ `📦 Sanity check had ${result.errors.length} error(s):`,
+ result.errors.slice(0, 3)
+ );
+ }
+ } catch (error) {
+ // Non-fatal - sanity check failure shouldn't break sync
+ console.warn(
+ "📦 Sanity check failed (non-fatal):",
+ error instanceof Error ? error.message : error
+ );
}
}
@@ -743,6 +3023,7 @@ export class IpfsStorageService {
/**
* Get the deterministic IPNS name for this wallet
+ * Returns a proper PeerId-based IPNS name
*/
async getIpnsName(): Promise {
if (this.cachedIpnsName) {
@@ -758,9 +3039,12 @@ export class IpfsStorageService {
try {
const walletSecret = this.hexToBytes(identity.privateKey);
const derivedKey = hkdf(sha256, walletSecret, undefined, HKDF_INFO, 32);
- const publicKey = ed.getPublicKey(derivedKey);
- this.cachedIpnsName = this.computeIpnsName(publicKey);
+ // Generate libp2p key pair and derive peer ID for proper IPNS name
+ const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
+ const peerId = peerIdFromPrivateKey(keyPair);
+ this.cachedIpnsName = peerId.toString();
+
return this.cachedIpnsName;
} catch (error) {
console.warn("📦 Failed to compute IPNS name:", error);
@@ -774,7 +3058,7 @@ export class IpfsStorageService {
getStatus(): StorageStatus {
return {
initialized: this.helia !== null,
- isSyncing: this.isSyncing,
+ isSyncing: this.isSyncing || this.isInitialSyncing,
lastSync: this.lastSync,
ipnsName: this.cachedIpnsName,
webCryptoAvailable: this.isWebCryptoAvailable(),
@@ -794,7 +3078,7 @@ export class IpfsStorageService {
* Check if currently syncing
*/
isCurrentlySyncing(): boolean {
- return this.isSyncing;
+ return this.isSyncing || this.isInitialSyncing;
}
// ==========================================
diff --git a/src/components/wallet/L3/services/IpnsNametagFetcher.ts b/src/components/wallet/L3/services/IpnsNametagFetcher.ts
new file mode 100644
index 000000000..36c9310c3
--- /dev/null
+++ b/src/components/wallet/L3/services/IpnsNametagFetcher.ts
@@ -0,0 +1,305 @@
+/**
+ * IPNS Nametag Fetcher
+ *
+ * Fetches nametag data from IPFS via IPNS resolution without requiring
+ * full IpfsStorageService initialization. Uses dual-path racing for optimal speed:
+ *
+ * Two resolution methods raced in parallel:
+ * 1. Gateway path (/ipns/{name}?format=dag-json) - Fast (~30ms with cache)
+ * 2. Routing API (/api/v0/routing/get) - Slower (~5s) but more reliable
+ *
+ * Flow:
+ * 1. Derive IPNS name from private key
+ * 2. Race both methods - gateway path and routing API
+ * 3. Return first successful result
+ * 4. Parse TXF content and extract _nametag.name
+ */
+
+import { deriveIpnsNameFromPrivateKey } from "./IpnsUtils";
+import { unmarshalIPNSRecord } from "ipns";
+import { getBackendGatewayUrl, getAllBackendGatewayUrls, IPNS_RESOLUTION_CONFIG } from "../../../../config/ipfs.config";
+
+export interface IpnsNametagResult {
+ ipnsName: string;
+ nametag: string | null;
+ nametagData?: {
+ name: string;
+ token: object;
+ timestamp?: number;
+ format?: string;
+ };
+ source: "http" | "none";
+ error?: string;
+}
+
+/**
+ * Fetch nametag from IPFS using IPNS resolution
+ *
+ * @param privateKeyHex - The secp256k1 private key in hex format
+ * @returns Result containing IPNS name and resolved nametag (if found)
+ */
+export async function fetchNametagFromIpns(
+ privateKeyHex: string
+): Promise {
+ let ipnsName = "";
+
+ try {
+ // 1. Derive IPNS name from private key
+ ipnsName = await deriveIpnsNameFromPrivateKey(privateKeyHex);
+
+ // 2. Try HTTP gateway (fast path)
+ const result = await fetchViaHttpGateway(ipnsName);
+ if (result) {
+ return {
+ ipnsName,
+ nametag: result.name,
+ nametagData: {
+ name: result.name,
+ token: result.data.token || {},
+ timestamp: result.data.timestamp,
+ format: result.data.format,
+ },
+ source: "http",
+ };
+ }
+
+ // No nametag found
+ return { ipnsName, nametag: null, source: "none" };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.warn(`Failed to fetch nametag for IPNS ${ipnsName}:`, errorMessage);
+ return {
+ ipnsName: ipnsName || "unknown",
+ nametag: null,
+ source: "none",
+ error: errorMessage,
+ };
+ }
+}
+
+/**
+ * Fetch with timeout support and JSON headers
+ * Returns the response regardless of status code (let caller handle it)
+ */
+async function fetchWithTimeout(
+ url: string,
+ timeoutMs: number
+): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: {
+ // Request JSON format for DAG-JSON content
+ Accept: "application/json, application/vnd.ipld.dag-json",
+ },
+ });
+ // Don't throw for non-200 - let caller handle IPNS resolution failures
+ return response;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Fetch nametag via HTTP gateway using dual-path racing
+ *
+ * Races both methods in parallel for each gateway:
+ * - Gateway path: /ipns/{name}?format=dag-json (fast ~30ms)
+ * - Routing API: /api/v0/routing/get (slow ~5s, more reliable)
+ *
+ * Returns first successful result from any gateway.
+ */
+async function fetchViaHttpGateway(ipnsName: string): Promise {
+ // Get all configured gateway URLs
+ const gatewayUrls = getAllBackendGatewayUrls();
+ if (gatewayUrls.length === 0) {
+ const fallbackUrl = getBackendGatewayUrl();
+ if (!fallbackUrl) {
+ throw new Error("No IPFS gateway configured");
+ }
+ gatewayUrls.push(fallbackUrl);
+ }
+
+ // Race both methods across all gateways
+ // Create promise for each gateway that races gateway path vs routing API
+ const racePromises = gatewayUrls.flatMap((gatewayUrl) => [
+ // Gateway path (fast)
+ tryGatewayPath(gatewayUrl, ipnsName).catch(() => null),
+ // Routing API (slow but reliable)
+ tryRoutingApi(gatewayUrl, ipnsName).catch(() => null),
+ ]);
+
+ // Use Promise.any to return first successful result
+ try {
+ const result = await Promise.any(
+ racePromises.map(async (p) => {
+ const result = await p;
+ if (result === null) {
+ throw new Error("No result");
+ }
+ return result;
+ })
+ );
+ return result;
+ } catch {
+ // All promises rejected - no result found
+ return null;
+ }
+}
+
+interface NametagFetchResult {
+ name: string;
+ data: {
+ token?: object;
+ timestamp?: number;
+ format?: string;
+ };
+}
+
+/**
+ * Try fetching from a single gateway using IPNS gateway path (fast path)
+ * Uses /ipns/{name}?format=dag-json which lets the gateway resolve IPNS
+ * and return DAG-JSON content (since @helia/json stores in this format)
+ */
+async function tryGatewayPath(
+ gatewayUrl: string,
+ ipnsName: string
+): Promise {
+ // Use IPNS gateway path with dag-json format
+ // The format parameter is needed because @helia/json stores content as DAG-JSON
+ const ipnsUrl = `${gatewayUrl}/ipns/${ipnsName}?format=dag-json`;
+
+ let contentResponse: Response;
+ try {
+ contentResponse = await fetchWithTimeout(ipnsUrl, IPNS_RESOLUTION_CONFIG.gatewayPathTimeoutMs);
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new Error("IPNS gateway path timeout");
+ }
+ throw error;
+ }
+
+ // Check response status - 404/500 means IPNS name not found or resolution failed
+ if (!contentResponse.ok) {
+ return null;
+ }
+
+ // Parse TXF content and extract nametag
+ let txfData;
+ try {
+ txfData = await contentResponse.json();
+ } catch {
+ return null;
+ }
+
+ // TXF format has _nametag at top level
+ if (txfData._nametag && typeof txfData._nametag.name === "string") {
+ // Return full nametag data for localStorage persistence
+ return {
+ name: txfData._nametag.name,
+ data: txfData._nametag,
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Try fetching from a single gateway using routing API (slow but reliable)
+ * Uses /api/v0/routing/get to get raw IPNS record, then fetches content via CID
+ */
+async function tryRoutingApi(
+ gatewayUrl: string,
+ ipnsName: string
+): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(
+ () => controller.abort(),
+ IPNS_RESOLUTION_CONFIG.perGatewayTimeoutMs
+ );
+
+ try {
+ // 1. Resolve IPNS to CID via routing API
+ const routingUrl = `${gatewayUrl}/api/v0/routing/get?arg=/ipns/${ipnsName}`;
+ const routingResponse = await fetch(routingUrl, {
+ method: "POST",
+ signal: controller.signal,
+ });
+
+ if (!routingResponse.ok) {
+ return null;
+ }
+
+ // Parse routing response to get IPNS record
+ const json = await routingResponse.json() as { Extra?: string };
+ if (!json.Extra) {
+ return null;
+ }
+
+ // Decode base64 Extra field to get raw IPNS record
+ const recordData = Uint8Array.from(atob(json.Extra), c => c.charCodeAt(0));
+ const record = unmarshalIPNSRecord(recordData);
+
+ // Extract CID from value path
+ const cidMatch = record.value.match(/^\/ipfs\/(.+)$/);
+ if (!cidMatch) {
+ return null;
+ }
+
+ const cid = cidMatch[1];
+
+ // 2. Fetch content via CID
+ const contentUrl = `${gatewayUrl}/ipfs/${cid}?format=dag-json`;
+ const contentResponse = await fetch(contentUrl, {
+ signal: controller.signal,
+ headers: {
+ Accept: "application/vnd.ipld.dag-json, application/json",
+ },
+ });
+
+ if (!contentResponse.ok) {
+ return null;
+ }
+
+ // Parse TXF content and extract nametag
+ let txfData;
+ try {
+ txfData = await contentResponse.json();
+ } catch {
+ return null;
+ }
+
+ // TXF format has _nametag at top level
+ if (txfData._nametag && typeof txfData._nametag.name === "string") {
+ return {
+ name: txfData._nametag.name,
+ data: txfData._nametag,
+ };
+ }
+
+ return null;
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new Error("IPNS routing API timeout");
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Batch fetch nametags for multiple private keys in parallel
+ *
+ * @param privateKeys - Array of private keys in hex format
+ * @returns Array of results (same order as input)
+ */
+export async function fetchNametagsForKeys(
+ privateKeys: string[]
+): Promise {
+ const promises = privateKeys.map((key) => fetchNametagFromIpns(key));
+ return Promise.all(promises);
+}
diff --git a/src/components/wallet/L3/services/IpnsUtils.ts b/src/components/wallet/L3/services/IpnsUtils.ts
new file mode 100644
index 000000000..7cbb8ead2
--- /dev/null
+++ b/src/components/wallet/L3/services/IpnsUtils.ts
@@ -0,0 +1,63 @@
+/**
+ * IPNS Name Derivation Utility
+ *
+ * Derives IPNS names from secp256k1 private keys without requiring
+ * full Helia/IPFS initialization. Uses the same derivation logic
+ * as IpfsStorageService for compatibility.
+ *
+ * Derivation path:
+ * secp256k1 privateKey (hex)
+ * → HKDF(sha256, key, info="ipfs-storage-ed25519-v1", 32 bytes)
+ * → Ed25519 key pair
+ * → libp2p PeerId
+ * → IPNS name (e.g., "12D3KooW...")
+ */
+
+import { hkdf } from "@noble/hashes/hkdf";
+import { sha256 } from "@noble/hashes/sha256";
+import { generateKeyPairFromSeed } from "@libp2p/crypto/keys";
+import { peerIdFromPrivateKey } from "@libp2p/peer-id";
+
+// Must match IpfsStorageService.HKDF_INFO for compatible IPNS names
+const HKDF_INFO = "ipfs-storage-ed25519-v1";
+
+/**
+ * Convert hex string to Uint8Array
+ */
+function hexToBytes(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
+ }
+ return bytes;
+}
+
+/**
+ * Derive IPNS name from a secp256k1 private key
+ *
+ * @param privateKeyHex - The secp256k1 private key in hex format
+ * @returns The IPNS name (libp2p PeerId string, e.g., "12D3KooW...")
+ */
+export async function deriveIpnsNameFromPrivateKey(
+ privateKeyHex: string
+): Promise {
+ // 1. Convert private key from hex to bytes
+ const walletSecret = hexToBytes(privateKeyHex);
+
+ // 2. Derive Ed25519 key material using HKDF
+ const derivedKey = hkdf(
+ sha256,
+ walletSecret,
+ undefined, // no salt for deterministic derivation
+ HKDF_INFO,
+ 32
+ );
+
+ // 3. Generate Ed25519 key pair from the derived key
+ const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
+
+ // 4. Convert to libp2p PeerId which gives us the IPNS name
+ const peerId = peerIdFromPrivateKey(keyPair);
+
+ return peerId.toString();
+}
diff --git a/src/components/wallet/L3/services/NametagService.ts b/src/components/wallet/L3/services/NametagService.ts
index 247a3ef4e..a32259d49 100644
--- a/src/components/wallet/L3/services/NametagService.ts
+++ b/src/components/wallet/L3/services/NametagService.ts
@@ -43,8 +43,8 @@ export class NametagService {
async isNametagAvailable(nametag: string): Promise {
const nametagTokenId = await TokenId.fromNameTag(nametag);
const isAlreadyMinted = await ServiceProvider.stateTransitionClient.isMinted(
- ServiceProvider.getRootTrustBase(),
- nametagTokenId
+ ServiceProvider.getRootTrustBase(),
+ nametagTokenId
);
return !isAlreadyMinted;
}
diff --git a/src/components/wallet/L3/services/NostrPinPublisher.ts b/src/components/wallet/L3/services/NostrPinPublisher.ts
new file mode 100644
index 000000000..52faf1814
--- /dev/null
+++ b/src/components/wallet/L3/services/NostrPinPublisher.ts
@@ -0,0 +1,160 @@
+/**
+ * NostrPinPublisher
+ *
+ * Listens for IPFS storage events and publishes CID announcements
+ * to Nostr relays. Pin services subscribed to these relays will
+ * automatically pin the announced content.
+ *
+ * Event flow:
+ * 1. IpfsStorageService stores data to IPFS
+ * 2. Emits "ipfs-storage-event" with type "storage:completed"
+ * 3. NostrPinPublisher catches event and publishes to Nostr
+ * 4. Remote pin services receive and pin the CID
+ */
+
+import { NOSTR_PIN_CONFIG } from "../../../../config/nostrPin.config";
+import { NostrService } from "./NostrService";
+import type { StorageEvent } from "./IpfsStorageService";
+
+export class NostrPinPublisher {
+ private static instance: NostrPinPublisher | null = null;
+ private isStarted = false;
+ private boundHandler: ((e: Event) => void) | null = null;
+
+ private constructor() {}
+
+ /**
+ * Get singleton instance
+ */
+ static getInstance(): NostrPinPublisher {
+ if (!NostrPinPublisher.instance) {
+ NostrPinPublisher.instance = new NostrPinPublisher();
+ }
+ return NostrPinPublisher.instance;
+ }
+
+ /**
+ * Start listening for IPFS storage events
+ */
+ async start(): Promise {
+ if (this.isStarted) {
+ return;
+ }
+
+ if (!NOSTR_PIN_CONFIG.enabled) {
+ if (NOSTR_PIN_CONFIG.debug) {
+ console.log("📌 NostrPinPublisher disabled by config");
+ }
+ return;
+ }
+
+ this.boundHandler = (e: Event) => {
+ this.handleStorageEvent(e as CustomEvent);
+ };
+
+ window.addEventListener("ipfs-storage-event", this.boundHandler);
+ this.isStarted = true;
+
+ if (NOSTR_PIN_CONFIG.debug) {
+ console.log("📌 NostrPinPublisher started - listening for IPFS storage events");
+ }
+ }
+
+ /**
+ * Stop listening for events
+ */
+ stop(): void {
+ if (!this.isStarted || !this.boundHandler) {
+ return;
+ }
+
+ window.removeEventListener("ipfs-storage-event", this.boundHandler);
+ this.boundHandler = null;
+ this.isStarted = false;
+
+ if (NOSTR_PIN_CONFIG.debug) {
+ console.log("📌 NostrPinPublisher stopped");
+ }
+ }
+
+ /**
+ * Check if publisher is running
+ */
+ isRunning(): boolean {
+ return this.isStarted;
+ }
+
+ /**
+ * Handle IPFS storage event
+ */
+ private async handleStorageEvent(e: CustomEvent): Promise {
+ const event = e.detail;
+
+ // Only process successful storage completions
+ if (event.type !== "storage:completed") {
+ return;
+ }
+
+ const cid = event.data?.cid;
+ if (!cid) {
+ if (NOSTR_PIN_CONFIG.debug) {
+ console.log("📌 Storage event without CID, skipping");
+ }
+ return;
+ }
+
+ const ipnsName = event.data?.ipnsName;
+ const tokenCount = event.data?.tokenCount;
+
+ if (NOSTR_PIN_CONFIG.debug) {
+ console.log(`📌 Publishing pin request for CID: ${cid.slice(0, 16)}...`);
+ }
+
+ try {
+ await this.publishPinRequest(cid, ipnsName, tokenCount);
+ } catch (error) {
+ console.error("📌 Failed to publish pin request:", error);
+ }
+ }
+
+ /**
+ * Publish CID pin request to Nostr
+ */
+ private async publishPinRequest(
+ cid: string,
+ ipnsName?: string,
+ tokenCount?: number
+ ): Promise {
+ const nostrService = NostrService.getInstance();
+
+ // Build tags for NIP-78 app-specific event
+ const tags: string[][] = [
+ ["d", NOSTR_PIN_CONFIG.dTag],
+ ["cid", cid],
+ ];
+
+ // Add optional IPNS name tag
+ if (ipnsName) {
+ tags.push(["ipns", ipnsName]);
+ }
+
+ // Content can include metadata (optional)
+ const content = tokenCount !== undefined
+ ? JSON.stringify({ tokenCount, timestamp: Date.now() })
+ : "";
+
+ const eventId = await nostrService.publishAppDataEvent(
+ NOSTR_PIN_CONFIG.eventKind,
+ tags,
+ content
+ );
+
+ if (eventId) {
+ if (NOSTR_PIN_CONFIG.debug) {
+ console.log(`📌 Pin request published: ${eventId.slice(0, 8)}... for CID ${cid.slice(0, 16)}...`);
+ }
+ } else {
+ console.warn("📌 Failed to publish pin request to Nostr");
+ }
+ }
+}
diff --git a/src/components/wallet/L3/services/NostrService.ts b/src/components/wallet/L3/services/NostrService.ts
index 630cbbc40..92387f303 100644
--- a/src/components/wallet/L3/services/NostrService.ts
+++ b/src/components/wallet/L3/services/NostrService.ts
@@ -159,7 +159,7 @@ export class NostrService {
});
}
- private handleSubscriptionEvent(event: Event, isWalletEvent: boolean) {
+ private async handleSubscriptionEvent(event: Event, isWalletEvent: boolean) {
// Deduplicate by event ID (persistent storage - works across page reloads)
if (this.isEventProcessed(event.id)) {
console.log(`⏭️ Event ${event.id.slice(0, 8)} already processed (persistent check), skipping`);
@@ -179,13 +179,48 @@ export class NostrService {
console.log(`📥 Processing ${isWalletEvent ? 'wallet' : 'chat'} event kind=${event.kind}`);
- // Mark as processed BEFORE handling to prevent race conditions
- this.markEventAsProcessed(event.id);
+ // Process the event and only mark as processed AFTER successful handling
+ // This prevents token loss if browser closes during processing
+ const success = await this.handleIncomingEvent(event);
+
+ if (success) {
+ // IPFS is primary source of truth - sync before marking event as processed
+ // This ensures token can be recovered from Nostr if IPFS sync fails
+ try {
+ const { IpfsStorageService } = await import("./IpfsStorageService");
+ const ipfsService = IpfsStorageService.getInstance(this.identityManager);
+ let syncResult = await ipfsService.syncNow();
+
+ // If sync is already in progress, wait for it to complete then sync again
+ // This ensures the newly added token gets synced before marking as processed
+ if (!syncResult.success && syncResult.error === "Sync already in progress") {
+ console.log(`⏳ Sync in progress for event ${event.id.slice(0, 8)}, waiting for completion...`);
+ await this.waitForSyncCompletion();
+ // Now sync again to include our newly added token
+ syncResult = await ipfsService.syncNow();
+ }
- this.handleIncomingEvent(event);
+ if (!syncResult.success) {
+ console.warn(`⚠️ IPFS sync failed for event ${event.id.slice(0, 8)}: ${syncResult.error}`);
+ console.warn(`Token saved locally but NOT marked as processed - will retry on next connect`);
+ return; // Don't mark as processed - retry on next connect
+ }
- // Update lastSync only for wallet events
- if (isWalletEvent) {
+ console.log(`☁️ Token synced to IPFS: CID=${syncResult.cid?.slice(0, 12)}...`);
+ } catch (err) {
+ console.error(`IPFS sync error for event ${event.id.slice(0, 8)}:`, err);
+ console.warn(`Token saved locally but NOT marked as processed - will retry on next connect`);
+ return; // Don't mark as processed - retry on next connect
+ }
+
+ this.markEventAsProcessed(event.id);
+ console.log(`✅ Event ${event.id.slice(0, 8)} fully processed (localStorage + IPFS)`);
+ } else {
+ console.warn(`⚠️ Event ${event.id.slice(0, 8)} processing failed, will retry on next connect`);
+ }
+
+ // Update lastSync only for wallet events that were successfully processed
+ if (isWalletEvent && success) {
this.updateLastSync(event.created_at);
}
}
@@ -208,6 +243,35 @@ export class NostrService {
}
}
+ /**
+ * Wait for IPFS sync to complete by listening for storage:completed event
+ * Timeout after 60 seconds to prevent infinite waiting
+ */
+ private waitForSyncCompletion(): Promise {
+ return new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ console.warn(`⏰ Sync wait timed out after 60s`);
+ window.removeEventListener("ipfs-storage-event", handler);
+ resolve();
+ }, 60000);
+
+ // Use EventListener type to avoid conflict with Nostr's Event type
+ const handler: EventListener = (e) => {
+ const detail = (e as unknown as CustomEvent).detail;
+ if (detail?.type === "storage:completed" || detail?.type === "sync:state-changed") {
+ // Check if sync is no longer in progress
+ if (detail.type === "storage:completed" || detail.data?.isSyncing === false) {
+ clearTimeout(timeout);
+ window.removeEventListener("ipfs-storage-event", handler);
+ resolve();
+ }
+ }
+ };
+
+ window.addEventListener("ipfs-storage-event", handler);
+ });
+ }
+
private loadProcessedEvents() {
try {
const saved = localStorage.getItem(STORAGE_KEY_PROCESSED_EVENTS);
@@ -256,19 +320,22 @@ export class NostrService {
return this.processedEventIds.has(eventId);
}
- private async handleIncomingEvent(event: Event) {
+ private async handleIncomingEvent(event: Event): Promise {
console.log(
`Received event kind=${event.kind} from=${event.pubkey.slice(0, 16)}`
);
if (event.kind === EventKinds.TOKEN_TRANSFER) {
- this.handleTokenTransfer(event);
+ return await this.handleTokenTransfer(event);
} else if (event.kind === EventKinds.GIFT_WRAP) {
console.log("Received NIP-17 gift-wrapped message");
this.handleGiftWrappedMessage(event);
+ return true; // Chat messages always succeed (stored in local chat repo)
} else if (event.kind === EventKinds.PAYMENT_REQUEST) {
this.handlePaymentRequest(event);
+ return true; // Payment requests are in-memory only
} else {
console.log(`Unhandled event kind - ${event.kind}`);
+ return true; // Unknown events - don't retry
}
}
@@ -350,12 +417,12 @@ export class NostrService {
return this.paymentRequests;
}
- private async handleTokenTransfer(event: Event) {
+ private async handleTokenTransfer(event: Event): Promise {
try {
const keyManager = await this.getKeyManager();
if (!keyManager) {
console.error("KeyManager is undefined");
- return;
+ return false;
}
const tokenJson = await TokenTransferProtocol.parseTokenTransfer(
@@ -377,16 +444,18 @@ export class NostrService {
payloadObj = JSON.parse(tokenJson);
} catch (error) {
console.warn("Failed to parse JSON:", error);
- return;
+ return false;
}
- this.handleProperTokenTransfer(payloadObj, event.pubkey);
+ return await this.handleProperTokenTransfer(payloadObj, event.pubkey);
}
+ return false; // Unknown transfer format
} catch (error) {
console.error("Failed to handle token transfer", error);
+ return false;
}
}
- private async handleProperTokenTransfer(payloadObj: Record, senderPubkey: string) {
+ private async handleProperTokenTransfer(payloadObj: Record, senderPubkey: string): Promise {
try {
let sourceTokenInput = payloadObj["sourceToken"];
let transferTxInput = payloadObj["transferTx"];
@@ -409,15 +478,16 @@ export class NostrService {
if (!sourceTokenInput || !transferTxInput) {
console.error("Missing sourceToken or transferTx in payload");
- return;
+ return false;
}
const sourceToken = await Token.fromJSON(sourceTokenInput);
const transferTx = await TransferTransaction.fromJSON(transferTxInput);
- this.finalizeTransfer(sourceToken, transferTx, senderPubkey);
+ return await this.finalizeTransfer(sourceToken, transferTx, senderPubkey);
} catch (error) {
console.error("Error handling proper token transfer", error);
+ return false;
}
}
@@ -425,7 +495,7 @@ export class NostrService {
sourceToken: Token,
transferTx: TransferTransaction,
senderPubkey: string
- ) {
+ ): Promise {
try {
const recipientAddress = transferTx.data.recipient;
console.log(`Recipient address: ${recipientAddress}`);
@@ -441,7 +511,7 @@ export class NostrService {
if (allNametags.length === 0) {
console.error("No nametags configured for this wallet");
- return;
+ return false;
}
let myNametagToken: Token | null = null;
@@ -457,7 +527,7 @@ export class NostrService {
console.error("Transfer is not for any of my nametags!");
console.error(`Got: ${recipientAddress.address}`);
console.error(`My nametags: ${allNametags.toString()}`);
- return;
+ return false;
}
console.log("Transfer is for my nametag!");
@@ -468,7 +538,7 @@ export class NostrService {
console.error(
"No wallet identity found, can't finalize the transfer!"
);
- return;
+ return false;
}
const secret = Buffer.from(identity.privateKey, "hex");
@@ -498,19 +568,20 @@ export class NostrService {
);
console.log("Token finalized successfully!");
- this.saveReceivedToken(finalizedToken, senderPubkey);
+ return this.saveReceivedToken(finalizedToken, senderPubkey);
} else {
console.log(
"Transfer is to DIRECT address - saving without finalization"
);
- this.saveReceivedToken(sourceToken, senderPubkey);
+ return this.saveReceivedToken(sourceToken, senderPubkey);
}
} catch (error) {
console.error("Error occured while finalizing transfer:", error);
+ return false;
}
}
- private saveReceivedToken(token: Token, senderPubkey: string) {
+ private saveReceivedToken(token: Token, senderPubkey: string): boolean {
let amount = undefined;
let coinId = undefined;
let symbol = undefined;
@@ -558,7 +629,7 @@ export class NostrService {
if (!coinId || amount === "0" || coinId === "0" || coinId === "undefined") {
console.error("❌ Invalid token data. Skipping.");
- return;
+ return false;
}
if (coinId) {
@@ -587,6 +658,8 @@ export class NostrService {
});
walletRepo.addToken(uiToken);
+ console.log(`💾 Token saved to wallet: ${uiToken.id}`);
+ return true;
}
async queryPubkeyByNametag(nametag: string): Promise {
diff --git a/src/components/wallet/L3/services/OutboxRecoveryService.ts b/src/components/wallet/L3/services/OutboxRecoveryService.ts
new file mode 100644
index 000000000..edac06939
--- /dev/null
+++ b/src/components/wallet/L3/services/OutboxRecoveryService.ts
@@ -0,0 +1,485 @@
+/**
+ * OutboxRecoveryService
+ *
+ * Handles recovery of incomplete token transfers on startup and periodically.
+ * Reads outbox entries from localStorage and resumes operations
+ * based on where they left off.
+ *
+ * Recovery by status:
+ * - PENDING_IPFS_SYNC: Re-sync to IPFS, then continue
+ * - READY_TO_SUBMIT: Submit to aggregator (idempotent)
+ * - SUBMITTED: Poll for inclusion proof
+ * - PROOF_RECEIVED: Retry Nostr delivery
+ * - NOSTR_SENT: Just mark as completed
+ * - COMPLETED: Remove from outbox
+ * - FAILED: Skip (requires manual intervention)
+ *
+ * Periodic retry:
+ * - Runs every 60 seconds while app is open
+ * - Uses exponential backoff (30s base, 1h max)
+ * - No age limit - entries remain recoverable indefinitely
+ * - Only 10 consecutive failures mark entry as FAILED
+ */
+
+import { TransferCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/TransferCommitment";
+import { waitInclusionProof } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils";
+import { OutboxRepository } from "../../../../repositories/OutboxRepository";
+import { ServiceProvider } from "./ServiceProvider";
+import type { NostrService } from "./NostrService";
+import type { IdentityManager } from "./IdentityManager";
+import type {
+ OutboxEntry,
+ RecoveryResult,
+ RecoveryDetail,
+} from "./types/OutboxTypes";
+import { IpfsStorageService } from "./IpfsStorageService";
+
+// ==========================================
+// Configuration Constants
+// ==========================================
+
+/** Check outbox every 60 seconds */
+const PERIODIC_RETRY_INTERVAL_MS = 60000;
+
+/** Base delay between retries (30 seconds) */
+const ENTRY_BACKOFF_BASE_MS = 30000;
+
+/** Maximum delay between retries (1 hour) */
+const ENTRY_MAX_BACKOFF_MS = 3600000;
+
+/** Maximum consecutive failures before marking as FAILED */
+const MAX_RETRIES_PER_ENTRY = 10;
+
+/** Cleanup COMPLETED entries after 24 hours */
+const COMPLETED_CLEANUP_AGE_MS = 24 * 60 * 60 * 1000;
+
+export class OutboxRecoveryService {
+ private static instance: OutboxRecoveryService;
+
+ private identityManager: IdentityManager | null = null;
+ private isRecovering = false;
+
+ // Periodic retry state
+ private periodicRetryInterval: ReturnType | null = null;
+ private walletAddress: string | null = null;
+ private nostrServiceRef: NostrService | null = null;
+
+ private constructor() {}
+
+ static getInstance(): OutboxRecoveryService {
+ if (!OutboxRecoveryService.instance) {
+ OutboxRecoveryService.instance = new OutboxRecoveryService();
+ }
+ return OutboxRecoveryService.instance;
+ }
+
+ /**
+ * Set the identity manager (needed for IPFS sync)
+ */
+ setIdentityManager(manager: IdentityManager): void {
+ this.identityManager = manager;
+ }
+
+ /**
+ * Check if there are any pending entries that need recovery
+ */
+ hasPendingRecovery(walletAddress: string): boolean {
+ const outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.setCurrentAddress(walletAddress);
+ return outboxRepo.getPendingCount() > 0;
+ }
+
+ /**
+ * Get count of pending entries
+ */
+ getPendingCount(walletAddress: string): number {
+ const outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.setCurrentAddress(walletAddress);
+ return outboxRepo.getPendingCount();
+ }
+
+ // ==========================================
+ // Periodic Retry Methods
+ // ==========================================
+
+ /**
+ * Start periodic retry checking
+ * Call after initial startup recovery completes
+ */
+ startPeriodicRetry(walletAddress: string, nostrService: NostrService): void {
+ this.stopPeriodicRetry(); // Clear any existing interval
+
+ this.walletAddress = walletAddress;
+ this.nostrServiceRef = nostrService;
+
+ console.log(`📤 OutboxRecovery: Starting periodic retry (every ${PERIODIC_RETRY_INTERVAL_MS / 1000}s)`);
+
+ this.periodicRetryInterval = setInterval(() => {
+ this.runPeriodicRecovery();
+ }, PERIODIC_RETRY_INTERVAL_MS);
+ }
+
+ /**
+ * Stop periodic retry checking
+ * Call on logout or app shutdown
+ */
+ stopPeriodicRetry(): void {
+ if (this.periodicRetryInterval) {
+ clearInterval(this.periodicRetryInterval);
+ this.periodicRetryInterval = null;
+ console.log("📤 OutboxRecovery: Stopped periodic retry");
+ }
+ this.walletAddress = null;
+ this.nostrServiceRef = null;
+ }
+
+ /**
+ * Run a periodic recovery cycle
+ * - Skips if already recovering
+ * - Only processes entries ready for retry (respects backoff)
+ * - Cleans up old completed entries
+ */
+ private async runPeriodicRecovery(): Promise {
+ if (!this.walletAddress || !this.nostrServiceRef) return;
+ if (this.isRecovering) return; // Already running
+
+ const outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.setCurrentAddress(this.walletAddress);
+
+ const pendingCount = outboxRepo.getPendingCount();
+ if (pendingCount === 0) return; // Nothing to do
+
+ // Get entries that are ready for retry (respect backoff)
+ const pendingEntries = outboxRepo.getPendingEntries();
+ const readyForRetry = pendingEntries.filter(entry => this.isReadyForRetry(entry));
+
+ if (readyForRetry.length === 0) {
+ // All entries in backoff, don't log every 60s
+ return;
+ }
+
+ console.log(`📤 OutboxRecovery: Periodic check - ${readyForRetry.length}/${pendingCount} entries ready for retry`);
+
+ await this.recoverPendingTransfers(this.walletAddress, this.nostrServiceRef);
+
+ // Cleanup old COMPLETED entries only (not pending ones - those may complete later)
+ outboxRepo.cleanupCompleted(COMPLETED_CLEANUP_AGE_MS);
+ }
+
+ /**
+ * Check if an entry is ready for retry based on exponential backoff
+ * NOTE: No age limit - users may close app for days/weeks and return
+ */
+ private isReadyForRetry(entry: OutboxEntry): boolean {
+ if (entry.status === "FAILED") return false;
+ if (entry.status === "COMPLETED") return false;
+
+ // Check retry count - entries at or beyond max will be marked FAILED during recovery
+ if (entry.retryCount >= MAX_RETRIES_PER_ENTRY) {
+ return true; // Let recoverEntry handle marking it as FAILED
+ }
+
+ // Calculate backoff delay based on retry count
+ const backoffDelay = Math.min(
+ ENTRY_BACKOFF_BASE_MS * Math.pow(2, entry.retryCount),
+ ENTRY_MAX_BACKOFF_MS
+ );
+
+ const timeSinceLastUpdate = Date.now() - entry.updatedAt;
+ return timeSinceLastUpdate >= backoffDelay;
+ }
+
+ // ==========================================
+ // Recovery Methods
+ // ==========================================
+
+ /**
+ * Main recovery entry point - called on app startup
+ * Recovers all pending transfers for the given wallet address
+ */
+ async recoverPendingTransfers(
+ walletAddress: string,
+ nostrService: NostrService
+ ): Promise {
+ if (this.isRecovering) {
+ console.log("📤 OutboxRecovery: Recovery already in progress, skipping");
+ return { recovered: 0, failed: 0, skipped: 0, details: [] };
+ }
+
+ this.isRecovering = true;
+ const result: RecoveryResult = {
+ recovered: 0,
+ failed: 0,
+ skipped: 0,
+ details: [],
+ };
+
+ try {
+ const outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.setCurrentAddress(walletAddress);
+
+ const pendingEntries = outboxRepo.getPendingEntries();
+
+ if (pendingEntries.length === 0) {
+ console.log("📤 OutboxRecovery: No pending entries to recover");
+ return result;
+ }
+
+ console.log(`📤 OutboxRecovery: Found ${pendingEntries.length} pending entries`);
+
+ for (const entry of pendingEntries) {
+ const detail = await this.recoverEntry(entry, outboxRepo, nostrService);
+ result.details.push(detail);
+
+ switch (detail.status) {
+ case "recovered":
+ result.recovered++;
+ break;
+ case "failed":
+ result.failed++;
+ break;
+ case "skipped":
+ result.skipped++;
+ break;
+ }
+ }
+
+ // Final IPFS sync after recovery
+ if (this.identityManager && (result.recovered > 0 || result.failed > 0)) {
+ try {
+ const ipfsService = IpfsStorageService.getInstance(this.identityManager);
+ await ipfsService.syncNow();
+ console.log("📤 OutboxRecovery: Final IPFS sync completed");
+ } catch (syncError) {
+ console.warn("📤 OutboxRecovery: Final IPFS sync failed:", syncError);
+ }
+ }
+
+ console.log(`📤 OutboxRecovery: Complete - ${result.recovered} recovered, ${result.failed} failed, ${result.skipped} skipped`);
+ return result;
+ } finally {
+ this.isRecovering = false;
+ }
+ }
+
+ /**
+ * Recover a single outbox entry
+ */
+ private async recoverEntry(
+ entry: OutboxEntry,
+ outboxRepo: OutboxRepository,
+ nostrService: NostrService
+ ): Promise {
+ const detail: RecoveryDetail = {
+ entryId: entry.id,
+ status: "skipped",
+ previousStatus: entry.status,
+ };
+
+ console.log(`📤 OutboxRecovery: Processing entry ${entry.id.slice(0, 8)}... (status=${entry.status}, type=${entry.type})`);
+
+ try {
+ switch (entry.status) {
+ case "PENDING_IPFS_SYNC":
+ await this.resumeFromPendingIpfs(entry, outboxRepo, nostrService);
+ detail.status = "recovered";
+ detail.newStatus = "COMPLETED";
+ break;
+
+ case "READY_TO_SUBMIT":
+ await this.resumeFromReadyToSubmit(entry, outboxRepo, nostrService);
+ detail.status = "recovered";
+ detail.newStatus = "COMPLETED";
+ break;
+
+ case "SUBMITTED":
+ await this.resumeFromSubmitted(entry, outboxRepo, nostrService);
+ detail.status = "recovered";
+ detail.newStatus = "COMPLETED";
+ break;
+
+ case "PROOF_RECEIVED":
+ await this.resumeFromProofReceived(entry, outboxRepo, nostrService);
+ detail.status = "recovered";
+ detail.newStatus = "COMPLETED";
+ break;
+
+ case "NOSTR_SENT":
+ // Just mark as completed - Nostr already sent
+ outboxRepo.updateStatus(entry.id, "COMPLETED");
+ detail.status = "recovered";
+ detail.newStatus = "COMPLETED";
+ break;
+
+ case "COMPLETED":
+ // Already done, just clean up
+ detail.status = "skipped";
+ break;
+
+ case "FAILED":
+ // Requires manual intervention
+ console.warn(`📤 OutboxRecovery: Entry ${entry.id.slice(0, 8)}... is FAILED, skipping`);
+ detail.status = "skipped";
+ break;
+ }
+ } catch (error) {
+ console.error(`📤 OutboxRecovery: Failed to recover entry ${entry.id.slice(0, 8)}...`, error);
+ const newRetryCount = entry.retryCount + 1;
+ outboxRepo.updateEntry(entry.id, {
+ lastError: error instanceof Error ? error.message : String(error),
+ retryCount: newRetryCount,
+ });
+
+ // Mark as FAILED after MAX_RETRIES_PER_ENTRY consecutive failures
+ if (newRetryCount >= MAX_RETRIES_PER_ENTRY) {
+ outboxRepo.updateStatus(entry.id, "FAILED", `Max retries exceeded (${MAX_RETRIES_PER_ENTRY})`);
+ }
+
+ detail.status = "failed";
+ detail.error = error instanceof Error ? error.message : String(error);
+ }
+
+ return detail;
+ }
+
+ /**
+ * Resume from PENDING_IPFS_SYNC: Sync to IPFS, then continue full flow
+ */
+ private async resumeFromPendingIpfs(
+ entry: OutboxEntry,
+ outboxRepo: OutboxRepository,
+ nostrService: NostrService
+ ): Promise {
+ console.log(`📤 OutboxRecovery: Resuming from PENDING_IPFS_SYNC...`);
+
+ // First sync to IPFS
+ if (this.identityManager) {
+ const ipfsService = IpfsStorageService.getInstance(this.identityManager);
+ const syncResult = await ipfsService.syncNow();
+ if (!syncResult.success) {
+ throw new Error("IPFS sync failed during recovery");
+ }
+ }
+
+ // Update status and continue
+ outboxRepo.updateStatus(entry.id, "READY_TO_SUBMIT");
+ entry.status = "READY_TO_SUBMIT";
+
+ await this.resumeFromReadyToSubmit(entry, outboxRepo, nostrService);
+ }
+
+ /**
+ * Resume from READY_TO_SUBMIT: Submit to aggregator, wait for proof, send via Nostr
+ */
+ private async resumeFromReadyToSubmit(
+ entry: OutboxEntry,
+ outboxRepo: OutboxRepository,
+ nostrService: NostrService
+ ): Promise {
+ console.log(`📤 OutboxRecovery: Resuming from READY_TO_SUBMIT...`);
+
+ // Reconstruct commitment from stored JSON
+ const commitment = await this.reconstructCommitment(entry);
+
+ // Submit to aggregator (idempotent - REQUEST_ID_EXISTS is ok)
+ const client = ServiceProvider.stateTransitionClient;
+ const response = await client.submitTransferCommitment(commitment);
+
+ if (response.status !== "SUCCESS" && response.status !== "REQUEST_ID_EXISTS") {
+ throw new Error(`Aggregator submission failed: ${response.status}`);
+ }
+
+ outboxRepo.updateStatus(entry.id, "SUBMITTED");
+ entry.status = "SUBMITTED";
+
+ await this.resumeFromSubmitted(entry, outboxRepo, nostrService);
+ }
+
+ /**
+ * Resume from SUBMITTED: Wait for inclusion proof, then send via Nostr
+ */
+ private async resumeFromSubmitted(
+ entry: OutboxEntry,
+ outboxRepo: OutboxRepository,
+ nostrService: NostrService
+ ): Promise {
+ console.log(`📤 OutboxRecovery: Resuming from SUBMITTED...`);
+
+ // Reconstruct commitment from stored JSON
+ const commitment = await this.reconstructCommitment(entry);
+
+ // Wait for inclusion proof
+ const trustBase = ServiceProvider.getRootTrustBase();
+ const client = ServiceProvider.stateTransitionClient;
+
+ const inclusionProof = await waitInclusionProof(
+ trustBase,
+ client,
+ commitment
+ );
+
+ // Create transfer transaction
+ const transferTx = commitment.toTransaction(inclusionProof);
+
+ // Update entry with proof data
+ outboxRepo.updateEntry(entry.id, {
+ status: "PROOF_RECEIVED",
+ inclusionProofJson: JSON.stringify(inclusionProof.toJSON()),
+ transferTxJson: JSON.stringify(transferTx.toJSON()),
+ });
+ entry.status = "PROOF_RECEIVED";
+ entry.inclusionProofJson = JSON.stringify(inclusionProof.toJSON());
+ entry.transferTxJson = JSON.stringify(transferTx.toJSON());
+
+ await this.resumeFromProofReceived(entry, outboxRepo, nostrService);
+ }
+
+ /**
+ * Resume from PROOF_RECEIVED: Send via Nostr
+ */
+ private async resumeFromProofReceived(
+ entry: OutboxEntry,
+ outboxRepo: OutboxRepository,
+ nostrService: NostrService
+ ): Promise {
+ console.log(`📤 OutboxRecovery: Resuming from PROOF_RECEIVED...`);
+
+ if (!entry.transferTxJson) {
+ throw new Error("Missing transferTxJson for Nostr delivery");
+ }
+
+ // Build Nostr payload
+ const payload = JSON.stringify({
+ sourceToken: entry.sourceTokenJson,
+ transferTx: entry.transferTxJson,
+ });
+
+ // Send via Nostr
+ await nostrService.sendTokenTransfer(entry.recipientPubkey, payload);
+
+ // Update status
+ outboxRepo.updateStatus(entry.id, "NOSTR_SENT");
+ outboxRepo.updateStatus(entry.id, "COMPLETED");
+
+ console.log(`📤 OutboxRecovery: Entry ${entry.id.slice(0, 8)}... recovered and completed`);
+ }
+
+ /**
+ * Reconstruct a TransferCommitment from stored JSON
+ * Note: For direct transfers, this recreates from stored data.
+ * For splits, the commitment is deterministic so can be recreated.
+ */
+ private async reconstructCommitment(entry: OutboxEntry): Promise {
+ try {
+ const commitmentData = JSON.parse(entry.commitmentJson);
+ return await TransferCommitment.fromJSON(commitmentData);
+ } catch (error) {
+ throw new Error(`Failed to reconstruct commitment: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+}
+
+// Export singleton getter for convenience
+export function getOutboxRecoveryService(): OutboxRecoveryService {
+ return OutboxRecoveryService.getInstance();
+}
diff --git a/src/components/wallet/L3/services/SyncCoordinator.ts b/src/components/wallet/L3/services/SyncCoordinator.ts
new file mode 100644
index 000000000..498cc145b
--- /dev/null
+++ b/src/components/wallet/L3/services/SyncCoordinator.ts
@@ -0,0 +1,343 @@
+/**
+ * SyncCoordinator - Tab coordination for IPFS sync operations
+ *
+ * Uses BroadcastChannel API to coordinate sync operations across browser tabs.
+ * Implements leader election to ensure only one tab syncs at a time, preventing
+ * race conditions and duplicate IPNS publishes.
+ *
+ * Key features:
+ * - Leader election among tabs
+ * - Sync lock acquisition/release
+ * - Heartbeat for leader liveness detection
+ * - Graceful handoff on tab close
+ */
+
+interface SyncMessage {
+ type:
+ | "leader-request"
+ | "leader-announce"
+ | "leader-ack"
+ | "sync-start"
+ | "sync-complete"
+ | "heartbeat"
+ | "ping"
+ | "pong";
+ from: string;
+ timestamp: number;
+ payload?: unknown;
+}
+
+// Singleton instance
+let coordinatorInstance: SyncCoordinator | null = null;
+
+export class SyncCoordinator {
+ private channel: BroadcastChannel;
+ private readonly instanceId: string;
+
+ // Leadership state
+ private isLeader = false;
+ private leaderId: string | null = null;
+ private leaderLastSeen: number = 0;
+
+ // Sync state
+ private isSyncing = false;
+ private syncQueue: Array<{
+ resolve: (acquired: boolean) => void;
+ timeout: ReturnType;
+ }> = [];
+
+ // Timers
+ private heartbeatInterval: ReturnType | null = null;
+ private leaderCheckInterval: ReturnType | null = null;
+
+ // Constants
+ private readonly LEADER_TIMEOUT = 10000; // 10s - leader considered dead if no heartbeat
+ private readonly HEARTBEAT_INTERVAL = 3000; // 3s heartbeat
+ private readonly LOCK_TIMEOUT = 30000; // 30s max wait for lock
+
+ constructor() {
+ this.instanceId = crypto.randomUUID();
+
+ // Initialize BroadcastChannel
+ this.channel = new BroadcastChannel("ipfs-sync-coordinator");
+ this.channel.onmessage = this.handleMessage.bind(this);
+
+ // Start leader check interval
+ this.leaderCheckInterval = setInterval(
+ () => this.checkLeaderLiveness(),
+ this.LEADER_TIMEOUT / 2
+ );
+
+ // Request leadership on startup
+ this.requestLeadership();
+
+ // Handle tab close
+ window.addEventListener("beforeunload", () => this.cleanup());
+
+ console.log(`📋 SyncCoordinator initialized: ${this.instanceId.slice(0, 8)}...`);
+ }
+
+ /**
+ * Get the singleton instance
+ */
+ static getInstance(): SyncCoordinator {
+ if (!coordinatorInstance) {
+ coordinatorInstance = new SyncCoordinator();
+ }
+ return coordinatorInstance;
+ }
+
+ /**
+ * Acquire sync lock - waits for leadership or current sync to complete
+ * Returns true if lock acquired, false if timeout
+ */
+ async acquireLock(timeout: number = this.LOCK_TIMEOUT): Promise {
+ // If we're already the leader and not syncing, we have the lock
+ if (this.isLeader && !this.isSyncing) {
+ this.isSyncing = true;
+ this.broadcast({ type: "sync-start" });
+ return true;
+ }
+
+ // If another tab is leader and syncing, wait
+ return new Promise((resolve) => {
+ const timeoutHandle = setTimeout(() => {
+ // Timeout - remove from queue and return false
+ this.syncQueue = this.syncQueue.filter((q) => q.resolve !== resolve);
+ resolve(false);
+ }, timeout);
+
+ this.syncQueue.push({ resolve, timeout: timeoutHandle });
+
+ // Ping leader to check if still alive
+ this.broadcast({ type: "ping" });
+ });
+ }
+
+ /**
+ * Release sync lock
+ */
+ releaseLock(): void {
+ if (!this.isSyncing) return;
+
+ this.isSyncing = false;
+ this.broadcast({ type: "sync-complete" });
+
+ // Process waiting queue
+ this.processQueue();
+ }
+
+ /**
+ * Check if we currently hold the lock
+ */
+ hasLock(): boolean {
+ return this.isLeader && this.isSyncing;
+ }
+
+ /**
+ * Check if this tab is the leader
+ */
+ isCurrentLeader(): boolean {
+ return this.isLeader;
+ }
+
+ /**
+ * Request to become leader
+ */
+ private requestLeadership(): void {
+ // If no leader or leader is dead, claim leadership
+ if (!this.leaderId || this.isLeaderDead()) {
+ this.becomeLeader();
+ } else {
+ // Request leadership from current leader
+ this.broadcast({ type: "leader-request" });
+ }
+ }
+
+ /**
+ * Become the leader
+ */
+ private becomeLeader(): void {
+ this.isLeader = true;
+ this.leaderId = this.instanceId;
+ this.leaderLastSeen = Date.now();
+
+ // Start heartbeat
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ }
+ this.heartbeatInterval = setInterval(() => {
+ this.broadcast({ type: "heartbeat" });
+ }, this.HEARTBEAT_INTERVAL);
+
+ // Announce leadership
+ this.broadcast({ type: "leader-announce" });
+
+ console.log(`📋 Became sync leader: ${this.instanceId.slice(0, 8)}...`);
+
+ // Process any waiting sync requests
+ this.processQueue();
+ }
+
+ /**
+ * Check if current leader is dead (no heartbeat)
+ */
+ private isLeaderDead(): boolean {
+ if (!this.leaderId) return true;
+ if (this.leaderId === this.instanceId) return false;
+ return Date.now() - this.leaderLastSeen > this.LEADER_TIMEOUT;
+ }
+
+ /**
+ * Check leader liveness and take over if dead
+ */
+ private checkLeaderLiveness(): void {
+ if (this.isLeader) return;
+
+ if (this.isLeaderDead()) {
+ console.log(`📋 Leader ${this.leaderId?.slice(0, 8)}... appears dead, taking over`);
+ this.becomeLeader();
+ }
+ }
+
+ /**
+ * Process queued sync requests
+ */
+ private processQueue(): void {
+ if (!this.isLeader || this.isSyncing || this.syncQueue.length === 0) {
+ return;
+ }
+
+ // Grant lock to first in queue
+ const next = this.syncQueue.shift();
+ if (next) {
+ clearTimeout(next.timeout);
+ this.isSyncing = true;
+ this.broadcast({ type: "sync-start" });
+ next.resolve(true);
+ }
+ }
+
+ /**
+ * Handle incoming messages
+ */
+ private handleMessage(event: MessageEvent): void {
+ const msg = event.data;
+
+ // Ignore our own messages
+ if (msg.from === this.instanceId) return;
+
+ switch (msg.type) {
+ case "leader-announce":
+ // Another tab claimed leadership
+ if (this.isLeader && msg.from !== this.instanceId) {
+ // Resolve conflict - higher ID wins
+ if (msg.from > this.instanceId) {
+ console.log(`📋 Yielding leadership to ${msg.from.slice(0, 8)}...`);
+ this.isLeader = false;
+ this.leaderId = msg.from;
+ this.leaderLastSeen = Date.now();
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ this.heartbeatInterval = null;
+ }
+ } else {
+ // We have higher ID, re-announce
+ this.broadcast({ type: "leader-announce" });
+ }
+ } else {
+ this.leaderId = msg.from;
+ this.leaderLastSeen = Date.now();
+ console.log(`📋 Acknowledged leader: ${msg.from.slice(0, 8)}...`);
+ }
+ break;
+
+ case "heartbeat":
+ if (msg.from === this.leaderId) {
+ this.leaderLastSeen = Date.now();
+ }
+ break;
+
+ case "leader-request":
+ // Someone wants leadership - if we're leader, send heartbeat
+ if (this.isLeader) {
+ this.broadcast({ type: "heartbeat" });
+ }
+ break;
+
+ case "sync-start":
+ // Leader started syncing
+ this.leaderLastSeen = Date.now();
+ break;
+
+ case "sync-complete":
+ // Leader finished syncing - might be our turn
+ this.leaderLastSeen = Date.now();
+ // If we have queued requests and we're the leader, process them
+ if (this.isLeader) {
+ this.processQueue();
+ }
+ break;
+
+ case "ping":
+ // Liveness check - respond if we're leader
+ if (this.isLeader) {
+ this.broadcast({ type: "pong" });
+ }
+ break;
+
+ case "pong":
+ // Leader is alive
+ if (msg.from === this.leaderId) {
+ this.leaderLastSeen = Date.now();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Broadcast a message to all tabs
+ */
+ private broadcast(msg: Omit): void {
+ this.channel.postMessage({
+ ...msg,
+ from: this.instanceId,
+ timestamp: Date.now(),
+ } as SyncMessage);
+ }
+
+ /**
+ * Cleanup on tab close
+ */
+ private cleanup(): void {
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ }
+ if (this.leaderCheckInterval) {
+ clearInterval(this.leaderCheckInterval);
+ }
+
+ // If we're leader and syncing, let others know
+ if (this.isLeader) {
+ this.broadcast({ type: "sync-complete" });
+ }
+
+ this.channel.close();
+ }
+
+ /**
+ * Shutdown the coordinator
+ */
+ shutdown(): void {
+ this.cleanup();
+ coordinatorInstance = null;
+ console.log(`📋 SyncCoordinator shutdown: ${this.instanceId.slice(0, 8)}...`);
+ }
+}
+
+/**
+ * Get the singleton SyncCoordinator instance
+ */
+export function getSyncCoordinator(): SyncCoordinator {
+ return SyncCoordinator.getInstance();
+}
diff --git a/src/components/wallet/L3/services/TokenBackupService.ts b/src/components/wallet/L3/services/TokenBackupService.ts
new file mode 100644
index 000000000..09ac1b517
--- /dev/null
+++ b/src/components/wallet/L3/services/TokenBackupService.ts
@@ -0,0 +1,491 @@
+/**
+ * Token Backup Service
+ *
+ * Provides encrypted local backup for tokens since Unicity cannot recover lost tokens.
+ *
+ * CRITICAL CONTEXT:
+ * - Unicity blockchain stores ONLY cryptographic hashes, NOT token data
+ * - If a token is lost (IPFS failure, device loss, sync error), it is UNRECOVERABLE
+ * - This service provides a safety net via encrypted local/downloadable backups
+ *
+ * Features:
+ * - AES-256-GCM encryption with PBKDF2 key derivation
+ * - Compatible with browser Web Crypto API
+ * - Backup status monitoring (warns when backup is stale)
+ * - Support for both file download and localStorage backup
+ */
+
+import { Token as LocalToken, TokenStatus } from "../data/model";
+import type { TxfToken } from "./types/TxfTypes";
+
+// ==========================================
+// Types
+// ==========================================
+
+export interface BackupMetadata {
+ version: "1.0";
+ timestamp: number;
+ tokenCount: number;
+ walletAddress: string;
+ checksum: string; // SHA-256 of token data for integrity
+}
+
+export interface TokenBackupData {
+ metadata: BackupMetadata;
+ tokens: {
+ id: string;
+ jsonData: string;
+ coinId: string;
+ amount: string;
+ symbol: string;
+ type: string;
+ }[];
+}
+
+export interface BackupStatus {
+ needsBackup: boolean;
+ reason: string;
+ lastBackupTime: number | null;
+ daysSinceBackup: number | null;
+ lastSyncTime: number | null;
+ daysSinceSync: number | null;
+}
+
+// ==========================================
+// TokenBackupService
+// ==========================================
+
+export class TokenBackupService {
+ private static instance: TokenBackupService | null = null;
+
+ private readonly BACKUP_TIMESTAMP_KEY = "token_backup_timestamp";
+ private readonly SYNC_TIMESTAMP_KEY = "last_ipfs_sync_success";
+ private readonly LOCAL_BACKUP_KEY = "encrypted_token_backup";
+ private readonly BACKUP_STALE_DAYS = 7;
+ private readonly SYNC_WARNING_DAYS = 3;
+
+ // ==========================================
+ // Singleton
+ // ==========================================
+
+ static getInstance(): TokenBackupService {
+ if (!TokenBackupService.instance) {
+ TokenBackupService.instance = new TokenBackupService();
+ }
+ return TokenBackupService.instance;
+ }
+
+ // ==========================================
+ // Public API
+ // ==========================================
+
+ /**
+ * Create encrypted backup of all tokens
+ * Returns a Blob that can be downloaded by the user
+ */
+ async createEncryptedBackup(
+ tokens: LocalToken[],
+ password: string,
+ walletAddress: string
+ ): Promise<{ blob: Blob; tokenCount: number; checksum: string }> {
+ // Build backup data structure
+ const tokensData = tokens
+ .filter(t => t.jsonData) // Only include tokens with valid data
+ .map(t => ({
+ id: t.id,
+ jsonData: t.jsonData!,
+ coinId: t.coinId || "",
+ amount: t.amount || "0",
+ symbol: t.symbol || "",
+ type: t.type,
+ }));
+
+ // Calculate checksum for integrity verification
+ const checksum = await this.calculateChecksum(JSON.stringify(tokensData));
+
+ const backup: TokenBackupData = {
+ metadata: {
+ version: "1.0",
+ timestamp: Date.now(),
+ tokenCount: tokensData.length,
+ walletAddress,
+ checksum,
+ },
+ tokens: tokensData,
+ };
+
+ // Encrypt with password
+ const encrypted = await this.encryptWithPassword(
+ JSON.stringify(backup),
+ password
+ );
+
+ // Update backup timestamp
+ this.updateBackupTimestamp();
+
+ console.log(`📦 Backup created: ${tokensData.length} tokens, checksum: ${checksum.slice(0, 16)}...`);
+
+ return {
+ blob: new Blob([encrypted], { type: "application/octet-stream" }),
+ tokenCount: tokensData.length,
+ checksum,
+ };
+ }
+
+ /**
+ * Restore tokens from encrypted backup
+ * Validates checksum to ensure data integrity
+ */
+ async restoreFromBackup(
+ encryptedData: ArrayBuffer,
+ password: string
+ ): Promise<{
+ tokens: LocalToken[];
+ metadata: BackupMetadata;
+ warnings: string[];
+ }> {
+ const warnings: string[] = [];
+
+ // Decrypt
+ let decrypted: string;
+ try {
+ decrypted = await this.decryptWithPassword(encryptedData, password);
+ } catch {
+ throw new Error("Failed to decrypt backup. Wrong password or corrupted file.");
+ }
+
+ // Parse backup data
+ let backup: TokenBackupData;
+ try {
+ backup = JSON.parse(decrypted);
+ } catch {
+ throw new Error("Invalid backup format. File may be corrupted.");
+ }
+
+ // Validate structure
+ if (!backup.metadata || !backup.tokens || !Array.isArray(backup.tokens)) {
+ throw new Error("Invalid backup structure");
+ }
+
+ // Verify checksum
+ const calculatedChecksum = await this.calculateChecksum(JSON.stringify(backup.tokens));
+ if (calculatedChecksum !== backup.metadata.checksum) {
+ warnings.push("Checksum mismatch - backup may have been tampered with");
+ }
+
+ // Check backup age
+ const backupAge = Date.now() - backup.metadata.timestamp;
+ const daysSinceBackup = backupAge / (1000 * 60 * 60 * 24);
+ if (daysSinceBackup > this.BACKUP_STALE_DAYS) {
+ warnings.push(`Backup is ${Math.floor(daysSinceBackup)} days old. Some tokens may have changed.`);
+ }
+
+ // Convert to Token objects
+ const tokens = backup.tokens.map(t => new LocalToken({
+ id: t.id,
+ name: t.type === "NFT" ? "NFT" : "Token",
+ type: t.type,
+ timestamp: backup.metadata.timestamp,
+ jsonData: t.jsonData,
+ status: TokenStatus.CONFIRMED,
+ amount: t.amount,
+ coinId: t.coinId,
+ symbol: t.symbol,
+ sizeBytes: t.jsonData.length,
+ }));
+
+ console.log(`📦 Backup restored: ${tokens.length} tokens from ${new Date(backup.metadata.timestamp).toISOString()}`);
+
+ return { tokens, metadata: backup.metadata, warnings };
+ }
+
+ /**
+ * Create a quick local backup in localStorage (encrypted)
+ * Useful for automatic periodic backups
+ */
+ async createLocalBackup(
+ tokens: LocalToken[],
+ password: string,
+ walletAddress: string
+ ): Promise {
+ const { blob } = await this.createEncryptedBackup(tokens, password, walletAddress);
+ const arrayBuffer = await blob.arrayBuffer();
+ const base64 = this.arrayBufferToBase64(arrayBuffer);
+
+ localStorage.setItem(this.LOCAL_BACKUP_KEY, base64);
+ console.log(`📦 Local backup saved to localStorage`);
+ }
+
+ /**
+ * Restore from local backup in localStorage
+ */
+ async restoreFromLocalBackup(
+ password: string
+ ): Promise<{
+ tokens: LocalToken[];
+ metadata: BackupMetadata;
+ warnings: string[];
+ } | null> {
+ const base64 = localStorage.getItem(this.LOCAL_BACKUP_KEY);
+ if (!base64) {
+ return null;
+ }
+
+ const arrayBuffer = this.base64ToArrayBuffer(base64);
+ return this.restoreFromBackup(arrayBuffer, password);
+ }
+
+ /**
+ * Check if backup is recommended (IPFS sync old or failed)
+ * Call this on app startup to prompt user
+ */
+ checkBackupStatus(): BackupStatus {
+ const lastBackup = localStorage.getItem(this.BACKUP_TIMESTAMP_KEY);
+ const lastSync = localStorage.getItem(this.SYNC_TIMESTAMP_KEY);
+
+ const now = Date.now();
+ const lastBackupTime = lastBackup ? parseInt(lastBackup) : null;
+ const lastSyncTime = lastSync ? parseInt(lastSync) : null;
+
+ const daysSinceBackup = lastBackupTime
+ ? (now - lastBackupTime) / (1000 * 60 * 60 * 24)
+ : null;
+
+ const daysSinceSync = lastSyncTime
+ ? (now - lastSyncTime) / (1000 * 60 * 60 * 24)
+ : null;
+
+ // Determine if backup is needed
+ let needsBackup = false;
+ let reason = "";
+
+ if (!lastBackupTime) {
+ needsBackup = true;
+ reason = "No backup recorded. Create your first backup to protect your tokens!";
+ } else if (daysSinceBackup !== null && daysSinceBackup > this.BACKUP_STALE_DAYS) {
+ needsBackup = true;
+ reason = `Last backup was ${Math.floor(daysSinceBackup)} days ago. Create a fresh backup!`;
+ } else if (!lastSyncTime) {
+ needsBackup = true;
+ reason = "No IPFS sync recorded. Backup recommended to protect tokens.";
+ } else if (daysSinceSync !== null && daysSinceSync > this.SYNC_WARNING_DAYS) {
+ needsBackup = true;
+ reason = `No IPFS sync in ${Math.floor(daysSinceSync)} days. Create a backup!`;
+ }
+
+ return {
+ needsBackup,
+ reason,
+ lastBackupTime,
+ daysSinceBackup,
+ lastSyncTime,
+ daysSinceSync,
+ };
+ }
+
+ /**
+ * Update the backup timestamp (called after successful backup)
+ */
+ updateBackupTimestamp(): void {
+ localStorage.setItem(this.BACKUP_TIMESTAMP_KEY, Date.now().toString());
+ }
+
+ /**
+ * Update the sync timestamp (call after successful IPFS sync)
+ */
+ updateSyncTimestamp(): void {
+ localStorage.setItem(this.SYNC_TIMESTAMP_KEY, Date.now().toString());
+ }
+
+ /**
+ * Get a summary of what's in a backup without full decryption
+ * Useful for showing backup info before restore
+ */
+ async getBackupInfo(
+ encryptedData: ArrayBuffer,
+ password: string
+ ): Promise {
+ try {
+ const decrypted = await this.decryptWithPassword(encryptedData, password);
+ const backup = JSON.parse(decrypted) as TokenBackupData;
+ return backup.metadata;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Verify backup integrity without full restore
+ */
+ async verifyBackup(
+ encryptedData: ArrayBuffer,
+ password: string
+ ): Promise<{ valid: boolean; error?: string }> {
+ try {
+ const decrypted = await this.decryptWithPassword(encryptedData, password);
+ const backup = JSON.parse(decrypted) as TokenBackupData;
+
+ // Verify checksum
+ const calculatedChecksum = await this.calculateChecksum(JSON.stringify(backup.tokens));
+ if (calculatedChecksum !== backup.metadata.checksum) {
+ return { valid: false, error: "Checksum mismatch" };
+ }
+
+ // Verify each token has valid TXF structure
+ for (const tokenData of backup.tokens) {
+ try {
+ const txf = JSON.parse(tokenData.jsonData) as TxfToken;
+ if (!txf.genesis || !txf.state) {
+ return { valid: false, error: `Token ${tokenData.id} has invalid structure` };
+ }
+ } catch {
+ return { valid: false, error: `Token ${tokenData.id} has invalid JSON` };
+ }
+ }
+
+ return { valid: true };
+ } catch (err) {
+ return { valid: false, error: err instanceof Error ? err.message : String(err) };
+ }
+ }
+
+ /**
+ * Export backup with human-readable filename
+ */
+ getBackupFilename(walletAddress: string): string {
+ const date = new Date().toISOString().split("T")[0];
+ const shortAddr = walletAddress.slice(0, 8);
+ return `unicity-tokens-backup-${shortAddr}-${date}.enc`;
+ }
+
+ // ==========================================
+ // Encryption/Decryption (AES-256-GCM with PBKDF2)
+ // ==========================================
+
+ private async encryptWithPassword(data: string, password: string): Promise {
+ const encoder = new TextEncoder();
+
+ // Generate random salt and IV
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+
+ // Derive key from password using PBKDF2
+ const keyMaterial = await crypto.subtle.importKey(
+ "raw",
+ encoder.encode(password),
+ "PBKDF2",
+ false,
+ ["deriveKey"]
+ );
+
+ const key = await crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt,
+ iterations: 100000,
+ hash: "SHA-256",
+ },
+ keyMaterial,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["encrypt"]
+ );
+
+ // Encrypt the data
+ const encrypted = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ key,
+ encoder.encode(data)
+ );
+
+ // Combine salt + iv + encrypted data
+ const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
+ result.set(salt, 0);
+ result.set(iv, salt.length);
+ result.set(new Uint8Array(encrypted), salt.length + iv.length);
+
+ return result.buffer;
+ }
+
+ private async decryptWithPassword(data: ArrayBuffer, password: string): Promise {
+ const encoder = new TextEncoder();
+ const decoder = new TextDecoder();
+ const dataArray = new Uint8Array(data);
+
+ // Extract salt, IV, and encrypted data
+ const salt = dataArray.slice(0, 16);
+ const iv = dataArray.slice(16, 28);
+ const encrypted = dataArray.slice(28);
+
+ // Derive key from password
+ const keyMaterial = await crypto.subtle.importKey(
+ "raw",
+ encoder.encode(password),
+ "PBKDF2",
+ false,
+ ["deriveKey"]
+ );
+
+ const key = await crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt,
+ iterations: 100000,
+ hash: "SHA-256",
+ },
+ keyMaterial,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["decrypt"]
+ );
+
+ // Decrypt
+ const decrypted = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv },
+ key,
+ encrypted
+ );
+
+ return decoder.decode(decrypted);
+ }
+
+ // ==========================================
+ // Utility Methods
+ // ==========================================
+
+ private async calculateChecksum(data: string): Promise {
+ const encoder = new TextEncoder();
+ const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(data));
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
+ }
+
+ private arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+ }
+
+ private base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+}
+
+// ==========================================
+// Singleton Export
+// ==========================================
+
+/**
+ * Get singleton instance of TokenBackupService
+ */
+export function getTokenBackupService(): TokenBackupService {
+ return TokenBackupService.getInstance();
+}
diff --git a/src/components/wallet/L3/services/TokenValidationService.ts b/src/components/wallet/L3/services/TokenValidationService.ts
index 25c1ef612..a54de021b 100644
--- a/src/components/wallet/L3/services/TokenValidationService.ts
+++ b/src/components/wallet/L3/services/TokenValidationService.ts
@@ -10,14 +10,49 @@ import type {
TokenValidationResult,
TxfTransaction,
TxfInclusionProof,
+ TxfToken,
} from "./types/TxfTypes";
+import { getCurrentStateHash, tokenToTxf } from "./TxfSerializer";
+
+// ==========================================
+// Validation Action Types
+// ==========================================
+
+/**
+ * Describes what action should be taken based on validation result
+ * - ACCEPT: Token is valid, can be used
+ * - RETRY_LATER: Proof not available yet, retry submission later
+ * - DISCARD_FORK: Transaction can NEVER succeed (source state spent), should be discarded
+ */
+export type ValidationAction = "ACCEPT" | "RETRY_LATER" | "DISCARD_FORK";
+
+/**
+ * Extended validation result with action guidance
+ */
+export interface ExtendedValidationResult extends TokenValidationResult {
+ action?: ValidationAction;
+}
+
+// ==========================================
+// Spent Token Detection Types
+// ==========================================
+
+export interface SpentTokenInfo {
+ tokenId: string; // SDK token ID from genesis
+ localId: string; // Local Token.id for repository removal
+ stateHash: string; // Current state hash being checked
+}
+
+export interface SpentTokenResult {
+ spentTokens: SpentTokenInfo[];
+ errors: string[];
+}
// ==========================================
// Constants
// ==========================================
const DEFAULT_AGGREGATOR_URL = "https://alpha-aggregator.unicity.network";
-const TRUST_BASE_URL = "https://alpha-explorer.unicity.network/api/trustbase";
// ==========================================
// TokenValidationService
@@ -227,6 +262,555 @@ export class TokenValidationService {
});
}
+ // ==========================================
+ // Pending Transaction Validation
+ // ==========================================
+
+ /**
+ * Check if a pending transaction can still be submitted
+ * Returns false if the source state is already spent (transaction is dead)
+ *
+ * CRITICAL: This prevents tokens from being stuck in PENDING state forever
+ * when another device has already committed a different transaction from the same state
+ */
+ async isPendingTransactionSubmittable(
+ token: LocalToken,
+ pendingTxIndex: number
+ ): Promise<{ submittable: boolean; reason?: string; action?: ValidationAction }> {
+ const txf = tokenToTxf(token);
+ if (!txf) {
+ return { submittable: false, reason: "Invalid token", action: "DISCARD_FORK" };
+ }
+
+ const pendingTx = txf.transactions[pendingTxIndex];
+ if (!pendingTx) {
+ return { submittable: false, reason: "Transaction index out of bounds", action: "DISCARD_FORK" };
+ }
+
+ // If already committed, it's not pending
+ if (pendingTx.inclusionProof !== null) {
+ return { submittable: true, action: "ACCEPT" };
+ }
+
+ // Get the state hash BEFORE this pending transaction
+ let prevStateHash: string;
+ if (pendingTxIndex === 0) {
+ // First transaction - source state is genesis state
+ prevStateHash = txf.genesis.inclusionProof.authenticator.stateHash;
+ } else {
+ // Previous transaction's new state
+ const prevTx = txf.transactions[pendingTxIndex - 1];
+ if (!prevTx) {
+ return { submittable: false, reason: "Previous transaction not found", action: "DISCARD_FORK" };
+ }
+ prevStateHash = prevTx.newStateHash;
+ }
+
+ // Check if that state is already spent
+ const trustBase = await this.getTrustBase();
+ if (!trustBase) {
+ // Can't verify - assume submittable (retry later)
+ return { submittable: true, reason: "Cannot verify - trust base unavailable", action: "RETRY_LATER" };
+ }
+
+ let client: unknown;
+ try {
+ const { ServiceProvider } = await import("./ServiceProvider");
+ client = ServiceProvider.stateTransitionClient;
+ } catch {
+ return { submittable: true, reason: "Cannot verify - client unavailable", action: "RETRY_LATER" };
+ }
+
+ if (!client) {
+ return { submittable: true, reason: "Cannot verify - client is null", action: "RETRY_LATER" };
+ }
+
+ try {
+ // Use SDK to check if source state is spent
+ const { Token } = await import("@unicitylabs/state-transition-sdk/lib/token/Token");
+ const sdkToken = await Token.fromJSON(txf);
+
+ // Get token owner public key from IdentityManager
+ const { IdentityManager } = await import("./IdentityManager");
+ const identity = await IdentityManager.getInstance().getCurrentIdentity();
+ if (!identity?.publicKey) {
+ return { submittable: true, reason: "Cannot verify - no identity", action: "RETRY_LATER" };
+ }
+
+ const pubKeyBytes = Buffer.from(identity.publicKey, "hex");
+
+ // Check if token state is spent
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const isSpent = await (client as any).isTokenStateSpent(
+ trustBase,
+ sdkToken,
+ pubKeyBytes
+ );
+
+ if (isSpent) {
+ return {
+ submittable: false,
+ reason: `Source state ${prevStateHash.slice(0, 12)}... already spent - transaction can never be committed`,
+ action: "DISCARD_FORK"
+ };
+ }
+
+ return { submittable: true, action: "ACCEPT" };
+ } catch (err) {
+ // On error, assume submittable but retry later
+ console.warn(`📦 isPendingTransactionSubmittable: Error checking state:`, err);
+ return {
+ submittable: true,
+ reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
+ action: "RETRY_LATER"
+ };
+ }
+ }
+
+ /**
+ * Check all pending transactions in a token and return their status
+ * Useful for UI to show which transactions are dead vs just pending
+ */
+ async checkAllPendingTransactions(
+ token: LocalToken
+ ): Promise<{
+ pendingCount: number;
+ submittable: number;
+ dead: number;
+ deadTransactions: { index: number; reason: string }[];
+ }> {
+ const txf = tokenToTxf(token);
+ if (!txf) {
+ return { pendingCount: 0, submittable: 0, dead: 0, deadTransactions: [] };
+ }
+
+ let pendingCount = 0;
+ let submittable = 0;
+ let dead = 0;
+ const deadTransactions: { index: number; reason: string }[] = [];
+
+ for (let i = 0; i < txf.transactions.length; i++) {
+ const tx = txf.transactions[i];
+ if (tx.inclusionProof === null) {
+ pendingCount++;
+ const result = await this.isPendingTransactionSubmittable(token, i);
+ if (result.submittable) {
+ submittable++;
+ } else {
+ dead++;
+ deadTransactions.push({ index: i, reason: result.reason || "Unknown" });
+ }
+ }
+ }
+
+ return { pendingCount, submittable, dead, deadTransactions };
+ }
+
+ // ==========================================
+ // Split Token Validation
+ // ==========================================
+
+ /**
+ * Validate that split tokens are still valid
+ *
+ * CRITICAL: Split tokens are only valid if their parent burn transaction was committed.
+ * If token was split on Device A (burn + mints), but Device B spent the original
+ * token first, Device A's burn is REJECTED and split tokens can NEVER exist.
+ *
+ * This method:
+ * 1. Identifies split tokens (by checking genesis.data.reason for SPLIT_MINT pattern)
+ * 2. Verifies the referenced burn transaction was committed on Unicity
+ * 3. Returns valid/invalid token lists for the caller to handle
+ */
+ async validateSplitTokens(
+ tokens: LocalToken[]
+ ): Promise<{
+ valid: LocalToken[];
+ invalid: LocalToken[];
+ errors: { tokenId: string; reason: string }[];
+ }> {
+ const valid: LocalToken[] = [];
+ const invalid: LocalToken[] = [];
+ const errors: { tokenId: string; reason: string }[] = [];
+
+ for (const token of tokens) {
+ const txf = tokenToTxf(token);
+ if (!txf) {
+ invalid.push(token);
+ errors.push({ tokenId: token.id, reason: "Invalid TXF structure" });
+ continue;
+ }
+
+ // Check if this is a split token by examining genesis.data.reason
+ // Split tokens typically have a reason field referencing the parent burn
+ const genesisData = txf.genesis?.data;
+ const reason = genesisData?.reason;
+
+ // If no reason field, not a split token - assume valid
+ if (!reason) {
+ valid.push(token);
+ continue;
+ }
+
+ // Parse the reason to check if it's a split mint
+ // Common patterns: "SPLIT_MINT:" or JSON with splitMintReason
+ let burnTxHash: string | null = null;
+
+ if (typeof reason === "string") {
+ // Check for SPLIT_MINT prefix
+ if (reason.startsWith("SPLIT_MINT:")) {
+ burnTxHash = reason.substring("SPLIT_MINT:".length);
+ }
+ // Check for JSON format
+ else if (reason.startsWith("{")) {
+ try {
+ const reasonObj = JSON.parse(reason);
+ if (reasonObj.splitMintReason?.burnTransactionHash) {
+ burnTxHash = reasonObj.splitMintReason.burnTransactionHash;
+ } else if (reasonObj.burnTransactionHash) {
+ burnTxHash = reasonObj.burnTransactionHash;
+ }
+ } catch {
+ // Not JSON, continue checking other formats
+ }
+ }
+ }
+
+ // If no burn transaction reference found, not a split token - assume valid
+ if (!burnTxHash) {
+ valid.push(token);
+ continue;
+ }
+
+ // This is a split token - verify the burn was committed
+ console.log(`📦 Validating split token ${token.id.slice(0, 8)}... (burn hash: ${burnTxHash.slice(0, 12)}...)`);
+
+ const burnCommitted = await this.checkBurnTransactionCommitted(burnTxHash);
+
+ if (burnCommitted.committed) {
+ valid.push(token);
+ console.log(`📦 Split token ${token.id.slice(0, 8)}... is VALID (burn committed)`);
+ } else {
+ invalid.push(token);
+ errors.push({
+ tokenId: token.id,
+ reason: burnCommitted.error || "Burn transaction not committed - split token invalid"
+ });
+ console.warn(`⚠️ Split token ${token.id.slice(0, 8)}... is INVALID: ${burnCommitted.error}`);
+ }
+ }
+
+ console.log(`📦 Split token validation: ${valid.length} valid, ${invalid.length} invalid`);
+ return { valid, invalid, errors };
+ }
+
+ /**
+ * Check if a burn transaction was committed on Unicity
+ * Used to validate split tokens whose existence depends on the burn being committed
+ */
+ private async checkBurnTransactionCommitted(
+ burnTxHash: string
+ ): Promise<{ committed: boolean; error?: string }> {
+ // Get trust base for verification
+ const trustBase = await this.getTrustBase();
+ if (!trustBase) {
+ // Can't verify - assume committed (safe fallback to avoid false negatives)
+ return { committed: true, error: "Cannot verify - trust base unavailable" };
+ }
+
+ try {
+ // Try to fetch the inclusion proof for the burn transaction
+ // If it exists, the burn was committed
+ const proof = await this.fetchProofFromAggregator(burnTxHash);
+
+ if (proof) {
+ return { committed: true };
+ }
+
+ // No proof found - check if it's just pending or actually rejected
+ // For safety, we can't definitively say it's rejected without more context
+ // Return not committed but allow retry
+ return {
+ committed: false,
+ error: "Burn transaction proof not found - may be pending or rejected"
+ };
+ } catch (err) {
+ return {
+ committed: false,
+ error: `Failed to verify burn: ${err instanceof Error ? err.message : String(err)}`
+ };
+ }
+ }
+
+ /**
+ * Identify which tokens in a list are split tokens
+ * Useful for filtering before validation
+ */
+ identifySplitTokens(tokens: LocalToken[]): {
+ splitTokens: LocalToken[];
+ regularTokens: LocalToken[];
+ } {
+ const splitTokens: LocalToken[] = [];
+ const regularTokens: LocalToken[] = [];
+
+ for (const token of tokens) {
+ const txf = tokenToTxf(token);
+ if (!txf) {
+ regularTokens.push(token);
+ continue;
+ }
+
+ const reason = txf.genesis?.data?.reason;
+
+ // Check if reason indicates a split token
+ const isSplit = reason && (
+ (typeof reason === "string" && reason.startsWith("SPLIT_MINT:")) ||
+ (typeof reason === "string" && reason.includes("burnTransactionHash"))
+ );
+
+ if (isSplit) {
+ splitTokens.push(token);
+ } else {
+ regularTokens.push(token);
+ }
+ }
+
+ return { splitTokens, regularTokens };
+ }
+
+ // ==========================================
+ // Spent Token Detection
+ // ==========================================
+
+ /**
+ * Check which tokens are NOT spent (unspent) on Unicity
+ * Used for sanity check when importing remote tombstones/missing tokens
+ * Requires full TxfToken data for SDK-based verification
+ * Returns array of tokenIds that are still valid/unspent
+ */
+ async checkUnspentTokens(
+ tokens: Map,
+ publicKey: string
+ ): Promise {
+ if (tokens.size === 0) return [];
+
+ const unspentTokenIds: string[] = [];
+
+ console.log(`📦 Sanity check: Verifying ${tokens.size} token(s) with aggregator...`);
+
+ // Get trust base and client
+ const trustBase = await this.getTrustBase();
+ if (!trustBase) {
+ console.warn("📦 Sanity check: Trust base not available, assuming all tokens unspent (safe fallback)");
+ return [...tokens.keys()];
+ }
+
+ let client: unknown;
+ try {
+ const { ServiceProvider } = await import("./ServiceProvider");
+ client = ServiceProvider.stateTransitionClient;
+ } catch {
+ console.warn("📦 Sanity check: StateTransitionClient not available, assuming all tokens unspent");
+ return [...tokens.keys()];
+ }
+
+ if (!client) {
+ console.warn("📦 Sanity check: StateTransitionClient is null, assuming all tokens unspent");
+ return [...tokens.keys()];
+ }
+
+ for (const [tokenId, txfToken] of tokens) {
+ try {
+ // Parse SDK token from TXF data
+ const { Token } = await import(
+ "@unicitylabs/state-transition-sdk/lib/token/Token"
+ );
+ const sdkToken = await Token.fromJSON(txfToken);
+
+ // Convert public key to bytes for SDK
+ const pubKeyBytes = Buffer.from(publicKey, "hex");
+
+ // Check if token state is spent using SDK
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const isSpent = await (client as any).isTokenStateSpent(
+ trustBase,
+ sdkToken,
+ pubKeyBytes
+ );
+
+ if (!isSpent) {
+ unspentTokenIds.push(tokenId);
+ console.log(`📦 Token ${tokenId.slice(0, 8)}... is NOT spent`);
+ } else {
+ console.log(`📦 Token ${tokenId.slice(0, 8)}... is SPENT`);
+ }
+ } catch (err) {
+ console.warn(`📦 Sanity check: Error checking token ${tokenId.slice(0, 8)}...:`, err);
+ // On error, assume unspent (safe fallback to avoid data loss)
+ unspentTokenIds.push(tokenId);
+ }
+ }
+
+ console.log(`📦 Sanity check result: ${unspentTokenIds.length} unspent, ${tokens.size - unspentTokenIds.length} spent`);
+ return unspentTokenIds;
+ }
+
+ /**
+ * Check all tokens for spent state against aggregator
+ * Returns list of spent tokens that should be removed
+ */
+ async checkSpentTokens(
+ tokens: LocalToken[],
+ publicKey: string,
+ options?: { batchSize?: number; onProgress?: (completed: number, total: number) => void }
+ ): Promise {
+ const spentTokens: SpentTokenInfo[] = [];
+ const errors: string[] = [];
+
+ const batchSize = options?.batchSize ?? 3; // Smaller batch for network calls
+ const total = tokens.length;
+ let completed = 0;
+
+ // Get trust base
+ const trustBase = await this.getTrustBase();
+ if (!trustBase) {
+ console.warn("📦 Sanity check: Trust base not available, skipping");
+ return { spentTokens: [], errors: ["Trust base not available"] };
+ }
+
+ // Get state transition client
+ let client: unknown;
+ try {
+ const { ServiceProvider } = await import("./ServiceProvider");
+ client = ServiceProvider.stateTransitionClient;
+ } catch {
+ console.warn("📦 Sanity check: StateTransitionClient not available");
+ return { spentTokens: [], errors: ["StateTransitionClient not available"] };
+ }
+
+ // Process in batches
+ for (let i = 0; i < tokens.length; i += batchSize) {
+ const batch = tokens.slice(i, i + batchSize);
+
+ const batchResults = await Promise.allSettled(
+ batch.map(async (token) => {
+ try {
+ return await this.checkSingleTokenSpent(token, publicKey, trustBase, client);
+ } catch (err) {
+ return {
+ tokenId: token.id,
+ localId: token.id,
+ stateHash: "",
+ spent: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+ })
+ );
+
+ for (const result of batchResults) {
+ completed++;
+ if (result.status === "fulfilled") {
+ if (result.value.spent) {
+ spentTokens.push({
+ tokenId: result.value.tokenId,
+ localId: result.value.localId,
+ stateHash: result.value.stateHash,
+ });
+ }
+ if (result.value.error) {
+ errors.push(`Token ${result.value.tokenId}: ${result.value.error}`);
+ }
+ } else {
+ errors.push(String(result.reason));
+ }
+ }
+
+ if (options?.onProgress) {
+ options.onProgress(completed, total);
+ }
+ }
+
+ return { spentTokens, errors };
+ }
+
+ /**
+ * Check if a single token's current state is spent
+ */
+ private async checkSingleTokenSpent(
+ token: LocalToken,
+ publicKey: string,
+ trustBase: unknown,
+ client: unknown
+ ): Promise<{
+ tokenId: string;
+ localId: string;
+ stateHash: string;
+ spent: boolean;
+ error?: string;
+ }> {
+ if (!token.jsonData) {
+ return {
+ tokenId: token.id,
+ localId: token.id,
+ stateHash: "",
+ spent: false,
+ error: "No jsonData",
+ };
+ }
+
+ let txfToken: TxfToken;
+ try {
+ txfToken = JSON.parse(token.jsonData);
+ } catch {
+ return {
+ tokenId: token.id,
+ localId: token.id,
+ stateHash: "",
+ spent: false,
+ error: "Invalid JSON",
+ };
+ }
+
+ // Get SDK token ID and state hash
+ const tokenId = txfToken.genesis?.data?.tokenId || token.id;
+ const stateHash = getCurrentStateHash(txfToken);
+
+ try {
+ // Parse SDK token
+ const { Token } = await import(
+ "@unicitylabs/state-transition-sdk/lib/token/Token"
+ );
+ const sdkToken = await Token.fromJSON(txfToken);
+
+ // Convert public key to bytes for SDK
+ const pubKeyBytes = Buffer.from(publicKey, "hex");
+
+ // Check if token state is spent using SDK client
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const isSpent = await (client as any).isTokenStateSpent(
+ trustBase,
+ sdkToken,
+ pubKeyBytes
+ );
+
+ return {
+ tokenId,
+ localId: token.id,
+ stateHash,
+ spent: isSpent === true,
+ };
+ } catch (err) {
+ return {
+ tokenId,
+ localId: token.id,
+ stateHash,
+ spent: false,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+ }
+
// ==========================================
// Private Helpers
// ==========================================
@@ -336,7 +920,7 @@ export class TokenValidationService {
}
/**
- * Fetch trust base from network
+ * Get trust base from ServiceProvider (local file)
*/
private async getTrustBase(): Promise {
// Check cache
@@ -348,25 +932,17 @@ export class TokenValidationService {
}
try {
- const response = await fetch(TRUST_BASE_URL);
- if (!response.ok) {
- return null;
- }
-
- const trustBaseJson = await response.json();
-
- // Parse using SDK
- const { RootTrustBase } = await import(
- "@unicitylabs/state-transition-sdk/lib/bft/RootTrustBase"
- );
- const trustBase = RootTrustBase.fromJSON(trustBaseJson);
+ // Use ServiceProvider which loads from local trustbase-testnet.json
+ const { ServiceProvider } = await import("./ServiceProvider");
+ const trustBase = ServiceProvider.getRootTrustBase();
// Cache
this.trustBaseCache = trustBase;
this.trustBaseCacheTime = Date.now();
return trustBase;
- } catch {
+ } catch (err) {
+ console.warn("📦 Failed to get trust base from ServiceProvider:", err);
return null;
}
}
diff --git a/src/components/wallet/L3/services/TxfSerializer.ts b/src/components/wallet/L3/services/TxfSerializer.ts
index 1cf5f7156..92b10bc41 100644
--- a/src/components/wallet/L3/services/TxfSerializer.ts
+++ b/src/components/wallet/L3/services/TxfSerializer.ts
@@ -11,10 +11,18 @@ import {
type TxfToken,
type TxfGenesis,
type TxfTransaction,
+ type TombstoneEntry,
isTokenKey,
+ isArchivedKey,
+ isForkedKey,
tokenIdFromKey,
+ tokenIdFromArchivedKey,
+ parseForkedKey,
keyFromTokenId,
+ archivedKeyFromTokenId,
+ forkedKeyFromTokenIdAndState,
} from "./types/TxfTypes";
+import type { OutboxEntry } from "./types/OutboxTypes";
import {
safeParseTxfToken,
safeParseTxfMeta,
@@ -41,7 +49,12 @@ export function tokenToTxf(token: Token): TxfToken | null {
// Validate it has the expected TXF structure
if (!txfData.genesis || !txfData.state) {
- console.warn(`Token ${token.id} jsonData is not in TXF format`);
+ console.warn(`Token ${token.id} jsonData is not in TXF format`, {
+ hasGenesis: !!txfData.genesis,
+ hasState: !!txfData.state,
+ topLevelKeys: Object.keys(txfData),
+ genesisKeys: txfData.genesis ? Object.keys(txfData.genesis) : [],
+ });
return null;
}
@@ -153,7 +166,11 @@ export function txfToToken(tokenId: string, txf: TxfToken): Token {
export function buildTxfStorageData(
tokens: Token[],
meta: Omit,
- nametag?: NametagData
+ nametag?: NametagData,
+ tombstones?: TombstoneEntry[],
+ archivedTokens?: Map,
+ forkedTokens?: Map,
+ outboxEntries?: OutboxEntry[]
): TxfStorageData {
const storageData: TxfStorageData = {
_meta: {
@@ -166,7 +183,17 @@ export function buildTxfStorageData(
storageData._nametag = nametag;
}
- // Add each token with _ key
+ // Add tombstones for spent token states (prevents zombie token resurrection)
+ if (tombstones && tombstones.length > 0) {
+ storageData._tombstones = tombstones;
+ }
+
+ // Add outbox entries (CRITICAL for transfer recovery)
+ if (outboxEntries && outboxEntries.length > 0) {
+ storageData._outbox = outboxEntries;
+ }
+
+ // Add each active token with _ key
for (const token of tokens) {
const txf = tokenToTxf(token);
if (txf) {
@@ -176,6 +203,24 @@ export function buildTxfStorageData(
}
}
+ // Add archived tokens with _archived_ key
+ if (archivedTokens && archivedTokens.size > 0) {
+ for (const [tokenId, txf] of archivedTokens) {
+ storageData[archivedKeyFromTokenId(tokenId)] = txf;
+ }
+ }
+
+ // Add forked tokens with _forked__ key
+ if (forkedTokens && forkedTokens.size > 0) {
+ for (const [key, txf] of forkedTokens) {
+ // Key is already in format tokenId_stateHash
+ const [tokenId, stateHash] = key.split("_");
+ if (tokenId && stateHash) {
+ storageData[forkedKeyFromTokenIdAndState(tokenId, stateHash)] = txf;
+ }
+ }
+ }
+
return storageData;
}
@@ -186,17 +231,29 @@ export function parseTxfStorageData(data: unknown): {
tokens: Token[];
meta: TxfMeta | null;
nametag: NametagData | null;
+ tombstones: TombstoneEntry[];
+ archivedTokens: Map;
+ forkedTokens: Map;
+ outboxEntries: OutboxEntry[];
validationErrors: string[];
} {
const result: {
tokens: Token[];
meta: TxfMeta | null;
nametag: NametagData | null;
+ tombstones: TombstoneEntry[];
+ archivedTokens: Map;
+ forkedTokens: Map;
+ outboxEntries: OutboxEntry[];
validationErrors: string[];
} = {
tokens: [],
meta: null,
nametag: null,
+ tombstones: [],
+ archivedTokens: new Map(),
+ forkedTokens: new Map(),
+ outboxEntries: [],
validationErrors: [],
};
@@ -226,8 +283,47 @@ export function parseTxfStorageData(data: unknown): {
result.nametag = storageData._nametag as NametagData;
}
- // Extract and validate tokens using Zod
+ // Extract tombstones (state-hash-aware entries)
+ if (storageData._tombstones && Array.isArray(storageData._tombstones)) {
+ for (const entry of storageData._tombstones) {
+ // Parse TombstoneEntry objects (new format)
+ if (
+ typeof entry === "object" &&
+ entry !== null &&
+ typeof (entry as TombstoneEntry).tokenId === "string" &&
+ typeof (entry as TombstoneEntry).stateHash === "string" &&
+ typeof (entry as TombstoneEntry).timestamp === "number"
+ ) {
+ result.tombstones.push(entry as TombstoneEntry);
+ }
+ // Legacy string format: discard (no state hash info)
+ // Per migration strategy: start fresh with state-hash-aware tombstones
+ }
+ }
+
+ // Extract outbox entries (CRITICAL for transfer recovery)
+ if (storageData._outbox && Array.isArray(storageData._outbox)) {
+ for (const entry of storageData._outbox) {
+ // Basic validation for OutboxEntry structure
+ if (
+ typeof entry === "object" &&
+ entry !== null &&
+ typeof (entry as OutboxEntry).id === "string" &&
+ typeof (entry as OutboxEntry).status === "string" &&
+ typeof (entry as OutboxEntry).sourceTokenId === "string" &&
+ typeof (entry as OutboxEntry).salt === "string" &&
+ typeof (entry as OutboxEntry).commitmentJson === "string"
+ ) {
+ result.outboxEntries.push(entry as OutboxEntry);
+ } else {
+ result.validationErrors.push("Invalid outbox entry structure");
+ }
+ }
+ }
+
+ // Extract and validate all keys
for (const key of Object.keys(storageData)) {
+ // Active tokens: _
if (isTokenKey(key)) {
const tokenId = tokenIdFromKey(key);
const validation = validateTokenEntry(key, storageData[key]);
@@ -254,6 +350,34 @@ export function parseTxfStorageData(data: unknown): {
}
}
}
+ // Archived tokens: _archived_
+ else if (isArchivedKey(key)) {
+ const tokenId = tokenIdFromArchivedKey(key);
+ try {
+ const txfToken = storageData[key] as TxfToken;
+ if (txfToken?.genesis?.data?.tokenId) {
+ result.archivedTokens.set(tokenId, txfToken);
+ }
+ } catch {
+ result.validationErrors.push(`Archived token ${tokenId}: invalid structure`);
+ }
+ }
+ // Forked tokens: _forked__
+ else if (isForkedKey(key)) {
+ const parsed = parseForkedKey(key);
+ if (parsed) {
+ try {
+ const txfToken = storageData[key] as TxfToken;
+ if (txfToken?.genesis?.data?.tokenId) {
+ // Store with key format tokenId_stateHash (matching WalletRepository format)
+ const mapKey = `${parsed.tokenId}_${parsed.stateHash}`;
+ result.forkedTokens.set(mapKey, txfToken);
+ }
+ } catch {
+ result.validationErrors.push(`Forked token ${parsed.tokenId}: invalid structure`);
+ }
+ }
+ }
}
if (result.validationErrors.length > 0) {
@@ -356,6 +480,34 @@ export function getTokenId(token: Token): string {
return token.id;
}
+/**
+ * Get the current state hash from a TXF token
+ * - If no transactions: use genesis state hash
+ * - If has transactions: use newStateHash from last transaction
+ */
+export function getCurrentStateHash(txf: TxfToken): string {
+ if (txf.transactions.length === 0) {
+ // No transfers yet - use genesis state hash
+ return txf.genesis.inclusionProof.authenticator.stateHash;
+ }
+ // Use newStateHash from the most recent transaction
+ return txf.transactions[txf.transactions.length - 1].newStateHash;
+}
+
+/**
+ * Get current state hash from a Token object (parses jsonData)
+ */
+export function getCurrentStateHashFromToken(token: Token): string | null {
+ if (!token.jsonData) return null;
+
+ try {
+ const txf = JSON.parse(token.jsonData) as TxfToken;
+ return getCurrentStateHash(txf);
+ } catch {
+ return null;
+ }
+}
+
/**
* Check if token has valid TXF data
*/
diff --git a/src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts b/src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts
index a0b1c14e9..2cd929bb1 100644
--- a/src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts
+++ b/src/components/wallet/L3/services/transfer/TokenSplitExecutor.ts
@@ -16,6 +16,9 @@ import { TokenCoinData } from "@unicitylabs/state-transition-sdk/lib/token/fungi
import { TransferCommitment } from "@unicitylabs/state-transition-sdk/lib/transaction/TransferCommitment";
import { UnmaskedPredicate } from "@unicitylabs/state-transition-sdk/lib/predicate/embedded/UnmaskedPredicate";
import { TokenState } from "@unicitylabs/state-transition-sdk/lib/token/TokenState";
+import { OutboxRepository } from "../../../../../repositories/OutboxRepository";
+import type { OutboxSplitGroup } from "../types/OutboxTypes";
+import { createOutboxEntry } from "../types/OutboxTypes";
// === Helper Types ===
@@ -31,6 +34,10 @@ interface SplitTokenResult {
tokenForRecipient: SdkToken;
tokenForSender: SdkToken;
recipientTransferTx: TransferTransaction;
+ /** Outbox entry ID for tracking Nostr delivery (if outbox enabled) */
+ outboxEntryId?: string;
+ /** Split group ID for recovery (if outbox enabled) */
+ splitGroupId?: string;
}
// === Helper: SHA-256 ===
@@ -53,12 +60,22 @@ export class TokenSplitExecutor {
plan: SplitPlan,
recipientAddress: IAddress,
signingService: SigningService,
- onTokenBurned: (uiId: string) => void
+ onTokenBurned: (uiId: string) => void,
+ /** Optional outbox context for tracking. If provided, creates outbox entries for recovery. */
+ outboxContext?: {
+ walletAddress: string;
+ recipientNametag: string;
+ recipientPubkey: string;
+ }
): Promise<{
tokensForRecipient: SdkToken[];
tokensKeptBySender: SdkToken[];
burnedTokens: any[];
recipientTransferTxs: TransferTransaction[];
+ /** Outbox entry IDs for tracking Nostr delivery (one per recipient token) */
+ outboxEntryIds: string[];
+ /** Split group ID for recovery */
+ splitGroupId?: string;
}> {
console.log(`⚙️ Executing split plan using TokenSplitBuilder...`);
@@ -67,6 +84,8 @@ export class TokenSplitExecutor {
tokensKeptBySender: [] as SdkToken[],
burnedTokens: [] as any[],
recipientTransferTxs: [] as TransferTransaction[],
+ outboxEntryIds: [] as string[],
+ splitGroupId: undefined as string | undefined,
};
if (
@@ -86,13 +105,22 @@ export class TokenSplitExecutor {
recipientAddress,
signingService,
onTokenBurned,
- plan.tokenToSplit.uiToken.id
+ plan.tokenToSplit.uiToken.id,
+ outboxContext
);
result.tokensForRecipient.push(splitResult.tokenForRecipient);
result.tokensKeptBySender.push(splitResult.tokenForSender);
result.burnedTokens.push(plan.tokenToSplit.uiToken);
result.recipientTransferTxs.push(splitResult.recipientTransferTx);
+
+ // Track outbox entries for Nostr delivery
+ if (splitResult.outboxEntryId) {
+ result.outboxEntryIds.push(splitResult.outboxEntryId);
+ }
+ if (splitResult.splitGroupId) {
+ result.splitGroupId = splitResult.splitGroupId;
+ }
}
return result;
@@ -106,7 +134,12 @@ export class TokenSplitExecutor {
recipientAddress: IAddress,
signingService: SigningService,
onTokenBurned: (uiId: string) => void,
- uiTokenId: string
+ uiTokenId: string,
+ outboxContext?: {
+ walletAddress: string;
+ recipientNametag: string;
+ recipientPubkey: string;
+ }
): Promise {
const tokenIdHex = Buffer.from(tokenToSplit.id.bytes).toString("hex");
console.log(`🔪 Splitting token ${tokenIdHex.slice(0, 8)}...`);
@@ -115,6 +148,28 @@ export class TokenSplitExecutor {
const seedString = `${tokenIdHex}_${splitAmount.toString()}_${remainderAmount.toString()}`;
+ // Initialize outbox tracking if context provided
+ let outboxRepo: OutboxRepository | null = null;
+ let splitGroupId: string | undefined;
+ let transferEntryId: string | undefined;
+
+ if (outboxContext) {
+ outboxRepo = OutboxRepository.getInstance();
+ outboxRepo.setCurrentAddress(outboxContext.walletAddress);
+
+ // Create a split group to track this operation
+ splitGroupId = crypto.randomUUID();
+ const splitGroup: OutboxSplitGroup = {
+ groupId: splitGroupId,
+ createdAt: Date.now(),
+ originalTokenId: uiTokenId,
+ seedString: seedString,
+ entryIds: [],
+ };
+ outboxRepo.createSplitGroup(splitGroup);
+ console.log(`📤 Outbox: Created split group ${splitGroupId.slice(0, 8)}...`);
+ }
+
const recipientTokenId = new TokenId(await sha256(seedString));
const senderTokenId = new TokenId(await sha256(seedString + "_sender"));
@@ -257,15 +312,51 @@ export class TokenSplitExecutor {
signingService
);
+ // Create outbox entry for transfer tracking BEFORE submitting
+ // This is critical for Nostr delivery recovery
+ if (outboxRepo && outboxContext && splitGroupId) {
+ const coinIdHex = Buffer.from(coinId.bytes).toString("hex");
+ const transferEntry = createOutboxEntry(
+ "SPLIT_TRANSFER",
+ uiTokenId,
+ outboxContext.recipientNametag,
+ outboxContext.recipientPubkey,
+ JSON.stringify((recipientAddress as any).toJSON ? (recipientAddress as any).toJSON() : recipientAddress),
+ splitAmount.toString(),
+ coinIdHex,
+ Buffer.from(transferSalt).toString("hex"),
+ JSON.stringify(recipientTokenBeforeTransfer.toJSON()),
+ JSON.stringify(transferCommitment.toJSON()),
+ splitGroupId,
+ 3 // Index 3 = transfer phase (after burn=0, mint-sender=1, mint-recipient=2)
+ );
+
+ // Set status to READY_TO_SUBMIT since IPFS sync should happen at caller level
+ transferEntry.status = "READY_TO_SUBMIT";
+ outboxRepo.addEntry(transferEntry);
+ outboxRepo.addEntryToSplitGroup(splitGroupId, transferEntry.id);
+ transferEntryId = transferEntry.id;
+ console.log(`📤 Outbox: Added split transfer entry ${transferEntry.id.slice(0, 8)}...`);
+ }
+
const transferRes = await this.client.submitTransferCommitment(transferCommitment);
if (
transferRes.status !== "SUCCESS" &&
transferRes.status !== "REQUEST_ID_EXISTS"
) {
+ // Mark outbox entry as failed
+ if (outboxRepo && transferEntryId) {
+ outboxRepo.updateStatus(transferEntryId, "FAILED", `Transfer failed: ${transferRes.status}`);
+ }
throw new Error(`Transfer failed: ${transferRes.status}`);
}
+ // Update outbox: submitted
+ if (outboxRepo && transferEntryId) {
+ outboxRepo.updateStatus(transferEntryId, "SUBMITTED");
+ }
+
const transferProof = await waitInclusionProof(
this.trustBase,
this.client,
@@ -273,12 +364,24 @@ export class TokenSplitExecutor {
);
const transferTx = transferCommitment.toTransaction(transferProof);
+
+ // Update outbox: proof received (ready for Nostr delivery)
+ if (outboxRepo && transferEntryId) {
+ outboxRepo.updateEntry(transferEntryId, {
+ status: "PROOF_RECEIVED",
+ inclusionProofJson: JSON.stringify(transferProof.toJSON()),
+ transferTxJson: JSON.stringify(transferTx.toJSON()),
+ });
+ }
+
console.log("✅ Split transfer complete!");
return {
tokenForRecipient: recipientTokenBeforeTransfer,
tokenForSender: senderToken,
recipientTransferTx: transferTx,
+ outboxEntryId: transferEntryId,
+ splitGroupId: splitGroupId,
};
}
diff --git a/src/components/wallet/L3/services/types/OutboxTypes.ts b/src/components/wallet/L3/services/types/OutboxTypes.ts
new file mode 100644
index 000000000..59a612011
--- /dev/null
+++ b/src/components/wallet/L3/services/types/OutboxTypes.ts
@@ -0,0 +1,333 @@
+/**
+ * Outbox Types
+ * Data structures for persisting pending token transfers
+ *
+ * The Outbox pattern ensures tokens are never lost during the transfer process
+ * by saving the transfer state (including non-reproducible commitment data)
+ * to localStorage AND IPFS BEFORE submitting to the Unicity aggregator.
+ */
+
+// ==========================================
+// Status Types
+// ==========================================
+
+/**
+ * Status of an outbox entry through the transfer lifecycle
+ */
+export type OutboxEntryStatus =
+ | "PENDING_IPFS_SYNC" // Saved to localStorage, awaiting IPFS confirmation
+ | "READY_TO_SUBMIT" // IPFS confirmed, safe to submit to aggregator
+ | "SUBMITTED" // Submitted to aggregator, awaiting inclusion proof
+ | "PROOF_RECEIVED" // Have inclusion proof, ready for Nostr delivery
+ | "NOSTR_SENT" // Sent via Nostr, awaiting confirmation
+ | "COMPLETED" // Fully completed, pending cleanup
+ | "FAILED"; // Terminal failure (manual intervention needed)
+
+/**
+ * Type of transfer operation
+ */
+export type OutboxEntryType =
+ | "DIRECT_TRANSFER" // Whole token transfer to recipient
+ | "SPLIT_BURN" // Burn phase of token split
+ | "SPLIT_MINT" // Mint phase of token split (sender or recipient portion)
+ | "SPLIT_TRANSFER"; // Transfer phase of split (recipient token to recipient)
+
+// ==========================================
+// Main Outbox Entry
+// ==========================================
+
+/**
+ * A single outbox entry representing a pending transfer operation
+ *
+ * CRITICAL: This structure contains the commitment JSON which includes
+ * the random salt. Without this data, recovery is IMPOSSIBLE after
+ * aggregator submission.
+ */
+export interface OutboxEntry {
+ /** Unique identifier for this outbox entry */
+ id: string;
+
+ /** Timestamp when entry was created */
+ createdAt: number;
+
+ /** Timestamp of last status update */
+ updatedAt: number;
+
+ /** Current status in the transfer lifecycle */
+ status: OutboxEntryStatus;
+
+ /** Type of transfer operation */
+ type: OutboxEntryType;
+
+ // ==========================================
+ // Transfer Metadata
+ // ==========================================
+
+ /** UI Token ID being spent (from wallet repository) */
+ sourceTokenId: string;
+
+ /** Recipient's human-readable nametag (e.g., "@alice") */
+ recipientNametag: string;
+
+ /** Recipient's Nostr public key (hex) */
+ recipientPubkey: string;
+
+ /** Recipient's Unicity address (serialized ProxyAddress JSON) */
+ recipientAddressJson: string;
+
+ /** Amount being transferred (BigInt as string) */
+ amount: string;
+
+ /** Coin ID for the token type */
+ coinId: string;
+
+ // ==========================================
+ // CRITICAL: Non-Reproducible Data
+ // ==========================================
+
+ /**
+ * Hex-encoded 32-byte random salt used in commitment creation.
+ * THIS IS THE CRITICAL DATA - without it, the commitment cannot
+ * be recreated and the requestId cannot be derived.
+ */
+ salt: string;
+
+ /**
+ * Serialized source token (SdkToken.toJSON() as string)
+ * Needed for Nostr delivery payload
+ */
+ sourceTokenJson: string;
+
+ /**
+ * Serialized transfer commitment (TransferCommitment.toJSON() as string)
+ * Contains: requestId, transactionData (including salt), authenticator
+ */
+ commitmentJson: string;
+
+ // ==========================================
+ // Post-Submission Data (filled during flow)
+ // ==========================================
+
+ /**
+ * Serialized inclusion proof (after aggregator response)
+ * Set during SUBMITTED → PROOF_RECEIVED transition
+ */
+ inclusionProofJson?: string;
+
+ /**
+ * Serialized transfer transaction (commitment.toTransaction(proof))
+ * Set during SUBMITTED → PROOF_RECEIVED transition
+ */
+ transferTxJson?: string;
+
+ // ==========================================
+ // Nostr Delivery Tracking
+ // ==========================================
+
+ /** Nostr event ID after successful send */
+ nostrEventId?: string;
+
+ /** Timestamp when Nostr delivery was confirmed */
+ nostrConfirmedAt?: number;
+
+ // ==========================================
+ // Error Tracking
+ // ==========================================
+
+ /** Last error message (for debugging/retry logic) */
+ lastError?: string;
+
+ /** Number of retry attempts */
+ retryCount: number;
+
+ // ==========================================
+ // Split Group Tracking (for split operations)
+ // ==========================================
+
+ /**
+ * Group ID linking related split entries (burn + mints + transfers)
+ * Only set for SPLIT_* types
+ */
+ splitGroupId?: string;
+
+ /**
+ * Index within split group (e.g., 0=burn, 1=mint-sender, 2=mint-recipient, 3=transfer)
+ * Only set for SPLIT_* types
+ */
+ splitGroupIndex?: number;
+}
+
+// ==========================================
+// Split Group (for tracking multi-step splits)
+// ==========================================
+
+/**
+ * Groups related split operation entries
+ * A single token split creates multiple outbox entries (burn + mints + transfer)
+ * that need to be tracked together for proper recovery.
+ */
+export interface OutboxSplitGroup {
+ /** Unique identifier for this split group */
+ groupId: string;
+
+ /** Timestamp when split was initiated */
+ createdAt: number;
+
+ /** Original token ID being split */
+ originalTokenId: string;
+
+ /** Serialized split plan (for recovery) */
+ splitPlanJson?: string;
+
+ /** Seed string used for deterministic salt derivation */
+ seedString: string;
+
+ /** Entry IDs in this group (in order: burn, mints..., transfer) */
+ entryIds: string[];
+}
+
+// ==========================================
+// Recovery Types
+// ==========================================
+
+/**
+ * Result of recovering pending transfers on startup
+ */
+export interface RecoveryResult {
+ /** Number of successfully recovered transfers */
+ recovered: number;
+
+ /** Number of failed recovery attempts */
+ failed: number;
+
+ /** Number of skipped entries (already completed) */
+ skipped: number;
+
+ /** Details of each recovery attempt */
+ details: RecoveryDetail[];
+}
+
+/**
+ * Detail of a single recovery attempt
+ */
+export interface RecoveryDetail {
+ entryId: string;
+ status: "recovered" | "failed" | "skipped";
+ previousStatus: OutboxEntryStatus;
+ newStatus?: OutboxEntryStatus;
+ error?: string;
+}
+
+// ==========================================
+// Utility Functions
+// ==========================================
+
+/**
+ * Check if an outbox entry is in a terminal state (completed or failed)
+ */
+export function isTerminalStatus(status: OutboxEntryStatus): boolean {
+ return status === "COMPLETED" || status === "FAILED";
+}
+
+/**
+ * Check if an outbox entry is pending (needs processing)
+ */
+export function isPendingStatus(status: OutboxEntryStatus): boolean {
+ return !isTerminalStatus(status);
+}
+
+/**
+ * Check if an outbox entry can be safely retried
+ */
+export function isRetryableStatus(status: OutboxEntryStatus): boolean {
+ return (
+ status === "PENDING_IPFS_SYNC" ||
+ status === "READY_TO_SUBMIT" ||
+ status === "SUBMITTED" ||
+ status === "PROOF_RECEIVED" ||
+ status === "NOSTR_SENT"
+ );
+}
+
+/**
+ * Get the next expected status after current status
+ */
+export function getNextStatus(current: OutboxEntryStatus): OutboxEntryStatus | null {
+ const statusOrder: OutboxEntryStatus[] = [
+ "PENDING_IPFS_SYNC",
+ "READY_TO_SUBMIT",
+ "SUBMITTED",
+ "PROOF_RECEIVED",
+ "NOSTR_SENT",
+ "COMPLETED",
+ ];
+
+ const currentIndex = statusOrder.indexOf(current);
+ if (currentIndex === -1 || currentIndex >= statusOrder.length - 1) {
+ return null;
+ }
+
+ return statusOrder[currentIndex + 1];
+}
+
+/**
+ * Create a minimal outbox entry with required fields
+ */
+export function createOutboxEntry(
+ type: OutboxEntryType,
+ sourceTokenId: string,
+ recipientNametag: string,
+ recipientPubkey: string,
+ recipientAddressJson: string,
+ amount: string,
+ coinId: string,
+ salt: string,
+ sourceTokenJson: string,
+ commitmentJson: string,
+ splitGroupId?: string,
+ splitGroupIndex?: number
+): OutboxEntry {
+ const now = Date.now();
+ return {
+ id: crypto.randomUUID(),
+ createdAt: now,
+ updatedAt: now,
+ status: "PENDING_IPFS_SYNC",
+ type,
+ sourceTokenId,
+ recipientNametag,
+ recipientPubkey,
+ recipientAddressJson,
+ amount,
+ coinId,
+ salt,
+ sourceTokenJson,
+ commitmentJson,
+ retryCount: 0,
+ splitGroupId,
+ splitGroupIndex,
+ };
+}
+
+/**
+ * Validate that an outbox entry has all required fields for its current status
+ */
+export function validateOutboxEntry(entry: OutboxEntry): { valid: boolean; error?: string } {
+ // Basic required fields
+ if (!entry.id || !entry.sourceTokenId || !entry.salt || !entry.commitmentJson) {
+ return { valid: false, error: "Missing required fields (id, sourceTokenId, salt, or commitmentJson)" };
+ }
+
+ // Status-specific validation
+ switch (entry.status) {
+ case "PROOF_RECEIVED":
+ case "NOSTR_SENT":
+ case "COMPLETED":
+ if (!entry.inclusionProofJson) {
+ return { valid: false, error: "Missing inclusionProofJson for status " + entry.status };
+ }
+ break;
+ }
+
+ return { valid: true };
+}
diff --git a/src/components/wallet/L3/services/types/TxfSchemas.ts b/src/components/wallet/L3/services/types/TxfSchemas.ts
index 7f0e236d0..b5c5c3383 100644
--- a/src/components/wallet/L3/services/types/TxfSchemas.ts
+++ b/src/components/wallet/L3/services/types/TxfSchemas.ts
@@ -105,7 +105,6 @@ export const TxfTokenSchema = z.object({
export const TxfMetaSchema = z.object({
version: z.number().int().nonnegative(),
- timestamp: z.number(),
address: z.string(),
ipnsName: z.string(),
formatVersion: z.literal("2.0"),
diff --git a/src/components/wallet/L3/services/types/TxfTypes.ts b/src/components/wallet/L3/services/types/TxfTypes.ts
index dc882d918..c5d775311 100644
--- a/src/components/wallet/L3/services/types/TxfTypes.ts
+++ b/src/components/wallet/L3/services/types/TxfTypes.ts
@@ -4,32 +4,46 @@
*/
import type { NametagData } from "../../../../../repositories/WalletRepository";
+import type { OutboxEntry } from "./OutboxTypes";
// ==========================================
// Storage Format (for IPFS)
// ==========================================
+/**
+ * Tombstone entry for tracking spent token states
+ * Tracks both tokenId AND stateHash to allow same token to return with new state
+ */
+export interface TombstoneEntry {
+ tokenId: string; // 64-char hex token ID
+ stateHash: string; // State hash that was spent (with "0000" prefix)
+ timestamp: number; // When tombstoned (epoch ms)
+}
+
/**
* Complete storage data structure for IPFS
- * Contains metadata, nametag, and all tokens keyed by their IDs
+ * Contains metadata, nametag, tombstones, outbox, and all tokens keyed by their IDs
*/
export interface TxfStorageData {
_meta: TxfMeta;
_nametag?: NametagData;
+ _tombstones?: TombstoneEntry[]; // State-hash-aware tombstones (spent token states)
+ _outbox?: OutboxEntry[]; // Pending transfers (CRITICAL for recovery)
// Dynamic keys for tokens: _
- [key: string]: TxfToken | TxfMeta | NametagData | undefined;
+ [key: string]: TxfToken | TxfMeta | NametagData | TombstoneEntry[] | OutboxEntry[] | undefined;
}
/**
* Storage metadata
+ * Note: timestamp is excluded to ensure CID stability (same content = same CID)
*/
export interface TxfMeta {
version: number; // Monotonic counter (increments each sync)
- timestamp: number; // Unix timestamp of last sync
address: string; // Wallet L3 address
ipnsName: string; // IPNS name for this wallet
formatVersion: "2.0"; // TXF format version
lastCid?: string; // Last successfully stored CID
+ deviceId?: string; // Unique device identifier for conflict resolution
}
// ==========================================
@@ -176,16 +190,46 @@ export interface MergeResult {
// Utility Types
// ==========================================
+// Key prefixes for special storage types
+const ARCHIVED_PREFIX = "_archived_";
+const FORKED_PREFIX = "_forked_";
+
/**
- * Check if a key is a token key (starts with _ but not reserved)
+ * Check if a key is an archived token key
*/
-export function isTokenKey(key: string): boolean {
+export function isArchivedKey(key: string): boolean {
+ return key.startsWith(ARCHIVED_PREFIX);
+}
+
+/**
+ * Check if a key is a forked token key
+ */
+export function isForkedKey(key: string): boolean {
+ return key.startsWith(FORKED_PREFIX);
+}
+
+/**
+ * Check if a key is an active token key (not archived, forked, or reserved)
+ */
+export function isActiveTokenKey(key: string): boolean {
return key.startsWith("_") &&
+ !key.startsWith(ARCHIVED_PREFIX) &&
+ !key.startsWith(FORKED_PREFIX) &&
key !== "_meta" &&
key !== "_nametag" &&
+ key !== "_tombstones" &&
+ key !== "_outbox" &&
key !== "_integrity";
}
+/**
+ * Check if a key is a token key (starts with _ but not reserved)
+ * NOTE: This now only returns true for ACTIVE tokens (excludes archived/forked)
+ */
+export function isTokenKey(key: string): boolean {
+ return isActiveTokenKey(key);
+}
+
/**
* Extract token ID from key (remove leading underscore)
*/
@@ -200,6 +244,45 @@ export function keyFromTokenId(tokenId: string): string {
return `_${tokenId}`;
}
+/**
+ * Create archived token key from token ID
+ */
+export function archivedKeyFromTokenId(tokenId: string): string {
+ return `${ARCHIVED_PREFIX}${tokenId}`;
+}
+
+/**
+ * Extract token ID from archived key
+ */
+export function tokenIdFromArchivedKey(key: string): string {
+ return key.startsWith(ARCHIVED_PREFIX) ? key.substring(ARCHIVED_PREFIX.length) : key;
+}
+
+/**
+ * Create forked token key from token ID and state hash
+ */
+export function forkedKeyFromTokenIdAndState(tokenId: string, stateHash: string): string {
+ return `${FORKED_PREFIX}${tokenId}_${stateHash}`;
+}
+
+/**
+ * Parse forked key into tokenId and stateHash
+ * Returns null if key is not a valid forked key
+ */
+export function parseForkedKey(key: string): { tokenId: string; stateHash: string } | null {
+ if (!key.startsWith(FORKED_PREFIX)) return null;
+ const remainder = key.substring(FORKED_PREFIX.length);
+ // Format: tokenId_stateHash
+ // tokenId is 64 chars, stateHash starts with "0000" (68+ chars)
+ // Find underscore after 64-char tokenId
+ const underscoreIndex = remainder.indexOf("_");
+ if (underscoreIndex === -1 || underscoreIndex < 64) return null;
+ return {
+ tokenId: remainder.substring(0, underscoreIndex),
+ stateHash: remainder.substring(underscoreIndex + 1),
+ };
+}
+
/**
* Validate 64-character hex token ID
*/
diff --git a/src/components/wallet/L3/views/L3WalletView.tsx b/src/components/wallet/L3/views/L3WalletView.tsx
index 83c632171..7c03ec1de 100644
--- a/src/components/wallet/L3/views/L3WalletView.tsx
+++ b/src/components/wallet/L3/views/L3WalletView.tsx
@@ -17,7 +17,7 @@ type Tab = 'assets' | 'tokens';
export function L3WalletView({ showBalances }: { showBalances: boolean }) {
const { identity, assets, tokens, isLoadingAssets, isLoadingIdentity, nametag, getSeedPhrase } = useWallet();
- const { exportTxf, importTxf, isExportingTxf, isImportingTxf } = useIpfsStorage();
+ const { exportTxf, importTxf, isExportingTxf, isImportingTxf, isSyncing, isEnabled: isIpfsEnabled } = useIpfsStorage();
const [activeTab, setActiveTab] = useState('assets');
const [isSendModalOpen, setIsSendModalOpen] = useState(false);
const [isRequestsOpen, setIsRequestsOpen] = useState(false);
@@ -29,6 +29,8 @@ export function L3WalletView({ showBalances }: { showBalances: boolean }) {
const [faucetError, setFaucetError] = useState(null);
const [importSuccess, setImportSuccess] = useState(false);
const [importError, setImportError] = useState(null);
+ const [initialSyncComplete, setInitialSyncComplete] = useState(false);
+ const hasSyncStarted = useRef(false);
const fileInputRef = useRef(null);
const { pendingCount } = useIncomingPaymentRequests();
@@ -43,10 +45,29 @@ export function L3WalletView({ showBalances }: { showBalances: boolean }) {
prevPendingCount.current = pendingCount;
}, [pendingCount]);
+ // Track when initial IPFS sync completes (latches true after first sync has ended)
+ useEffect(() => {
+ // Track when sync starts
+ if (isSyncing && isIpfsEnabled) {
+ hasSyncStarted.current = true;
+ console.log(`🔄 L3WalletView: sync started, hasSyncStarted=true`);
+ }
+ // Only mark complete after sync has started AND then stopped
+ if (!isSyncing && isIpfsEnabled && !initialSyncComplete && hasSyncStarted.current) {
+ console.log(`🔄 L3WalletView: sync completed, marking initialSyncComplete=true`);
+ setInitialSyncComplete(true);
+ }
+ }, [isSyncing, isIpfsEnabled, initialSyncComplete]);
+
+
const totalValue = useMemo(() => {
return assets.reduce((sum, asset) => sum + asset.getTotalFiatValue('USD'), 0);
}, [assets]);
+ // Debug: Log spinner visibility conditions
+ const shouldShowSpinner = isSyncing && isIpfsEnabled && !initialSyncComplete;
+ console.log(`🔄 L3WalletView render: isSyncing=${isSyncing}, isIpfsEnabled=${isIpfsEnabled}, initialSyncComplete=${initialSyncComplete}, shouldShowSpinner=${shouldShowSpinner}`);
+
const handleTopUp = async () => {
if (!nametag) {
setFaucetError('Nametag is required to request tokens');
@@ -80,6 +101,8 @@ export function L3WalletView({ showBalances }: { showBalances: boolean }) {
if (phrase) {
setSeedPhrase(phrase);
setIsSeedPhraseOpen(true);
+ } else {
+ alert("Recovery phrase not available.\n\nThis wallet was imported from a file that doesn't contain a mnemonic phrase. Only the master key was imported.");
}
};
@@ -174,11 +197,16 @@ export function L3WalletView({ showBalances }: { showBalances: boolean }) {