import { AccountId, ChainId } from 'caip'; import type { AccountClientInterface } from './account/client'; import type { BundlerConfig } from './bundler'; import { AddressVersion } from './config'; import { type ScryptParams } from './cryptography'; import type { SignedHttpConfig } from './httpSigning/config'; import { ChainSpecification } from './publicClients'; import { type PersistedAccount } from './state'; import { LibQCStorage } from './storage'; import { EvmAddress, AddressIndex, Asset, BalanceResult, ForceNewAddress, Mnemonic, Password, type IsValidMnemonic, type IsValidPassword, type MnemonicGuess, type Symbol, type TokenDetails, type WithdrawalRecord } from './types'; export type BundlerConfigProvider = (chainId: ChainId) => BundlerConfig; export type { SignedHttpConfig } from './httpSigning/config'; type MnemonicScopeCallback = (mnemonic: Mnemonic) => Promise | T; /** * Main LibQC client for managing smart wallet operations * * All sensitive state is encrypted at rest using AES-256-GCM. The user must * set a password (which creates the vault) and call unlock() before performing * any wallet operations. * * @example * ```typescript * const vault = new LibQC(storage, chains, bundlerConfigProvider); * * // First time setup * await vault.setPassword(password); * // vault is auto-unlocked after setPassword * * // Generate mnemonic * const mnemonic = await vault.generateMnemonic(); * * // Returning user * await vault.unlock(password); * const accounts = await vault.listAccounts(); * ``` */ export default class LibQC { private storage; private publicClientRegistry; private bundlerConfigProvider; private chains; private encryptionKey?; private scryptParams?; private readonly signedHttpConfig?; private readonly httpSigningTransportRef; private readonly storageLock; /** * Creates a new LibQC client instance * * @param storage - Storage implementation for persisting wallet state * @param chains - Array of blockchain chain specifications to support * @param bundlerConfigProvider - Function that provides bundler config for a given chain * @param scryptParams - Optional scrypt parameters for key derivation (defaults to production-strength values) * @param signedHttp - Optional ML-DSA-65 signed JSON-RPC (register worker URL) * * @example * ```typescript * const vault = new LibQC( * ...params * ); */ constructor(storage: LibQCStorage, chains: ChainSpecification[], bundlerConfigProvider: BundlerConfigProvider, scryptParams?: ScryptParams, signedHttp?: SignedHttpConfig); /** * Validates mnemonic phrase formatting for LibQC import and derivation * * Accepts only 24-token phrases and normalizes whitespace/casing. * * @param mnemonic - The mnemonic bytes to validate * @returns Canonical normalized mnemonic phrase * @throws Error if mnemonic is invalid (including BIP-39 checksum failure) */ static validateMnemonic(mnemonic: Uint8Array): Mnemonic; /** * Unlocks the vault by deriving the encryption key from the password * * Reads the encrypted vault from storage, derives the key via scrypt, * and verifies the password by attempting decryption. On success the * derived key is cached for subsequent operations. * * @param password - Caller-owned password bytes; caller should wipe with * `fill(0)` once this call is complete and the password is no longer needed * @throws {NoPasswordSetError} If no vault exists yet * @throws {IncorrectPasswordError} If the password is wrong * @throws {KeyDerivationError} If key material import fails * @throws {VaultCorruptedError} If the stored vault data is structurally invalid * @remarks `unlock` runs entirely outside the storage lock (scrypt is slow). * A concurrent `clearState` can delete the vault between the existence check * here and the key being cached, in that case subsequent reads will throw. * These operations are inherently conflicting; callers must not race them. */ unlock(password: Password): Promise; /** * Locks the vault by clearing the cached encryption key from memory */ lock(): void; /** * Returns whether the vault is currently unlocked * * @returns True if the encryption key is cached in memory */ isUnlocked(): boolean; /** * Checks whether a password has been set (i.e. an encrypted vault exists) * * Does NOT require the vault to be unlocked. * * @returns True if an encrypted vault exists in storage */ hasPassword(): Promise; /** * Generates a new mnemonic and stores it securely * * @remarks Entropy generation is delegated to @scure/bip39's internal * crypto.getRandomValues call. * * Ownership model: * - `generateMnemonic()` returns a caller-owned buffer; caller must zero it * with `fill(0)` when done. * - `generateMnemonic(callback)` keeps mnemonic usage scoped and LibQC zeros * callback buffers automatically after use. * * @param callback Scoped callback for safe mnemonic handling * @returns Callback result * @throws {VaultLockedError} If the vault is locked * @throws Error if a mnemonic already exists in storage */ generateMnemonic(callback: MnemonicScopeCallback): Promise; /** * Imports a wallet from a mnemonic and protects it with a password * * Validates the mnemonic and password format first, then atomically checks * for an existing vault and writes the new state under the storage lock. * This prevents two concurrent importWallet calls from overwriting each * other's vault. Call clearState() first if overwriting is intended. * * It also performs automatic discovery of the first 10 addresses for each * supported network (Ethereum, Base and Bitcoin). * * When signed JSON-RPC is configured, ML-DSA credentials are loaded (or * provisioned) before discovery, consistent with `unlock()`. * * @param mnemonic - Caller-owned 24-token mnemonic bytes; caller should wipe * with `fill(0)` when no longer needed * @param password - Caller-owned password bytes; caller should wipe with * `fill(0)` when no longer needed * @throws {InvalidPasswordFormatError} If password format is invalid * @throws {Error} If mnemonic is invalid * @throws {Error} If a vault already exists in storage */ importWallet(mnemonic: Mnemonic, password: Password): Promise; /** * Validates that specific mnemonic words match the securely stored entropy. * * @param guesses An array of mnemonic word guesses with their BIP-39 indices * @returns Whether all provided guesses match the stored wallet state * @throws {VaultLockedError} If the vault is locked * @throws Error if fewer than 4 guesses are supplied * @throws Error if seed or entropy is not found in storage */ validateMnemonic(guesses: MnemonicGuess[]): Promise; /** * Returns the BIP-39 recovery phrase after re-confirming the caller's password. * * Requires the vault to be unlocked. The password is re-derived and verified * against the stored ciphertext before the mnemonic is released — this prevents * silent extraction when a screen is left open by an unattended user. * * This method keeps mnemonic usage scoped. LibQC zeros the callback buffer * automatically after use. If the caller needs an owned copy, they must * create it inside the callback and wipe that copy manually when done. * * @param password - Caller-owned password bytes; caller should wipe with * `fill(0)` once this call is complete * @param callback - Scoped callback for safe mnemonic handling * @returns Callback result * @throws {VaultLockedError} If the vault is locked * @throws {IncorrectPasswordError} If the supplied password is wrong * @throws Error if the mnemonic is not found in storage */ exportMnemonic(password: Password, callback: MnemonicScopeCallback): Promise; /** * Sets the password for the wallet and stores it securely * * If the wallet already has an existing password, then setting a new one requires the existing one to be passed in * * @param password Caller-owned password bytes; caller should wipe with * `fill(0)` when no longer needed * @param existingPassword Caller-owned existing password bytes (if provided); * caller should wipe with `fill(0)` when no longer needed * @throws {InvalidPasswordFormatError} If password format is invalid * @throws {ExistingPasswordRequiredError} If existing password is required but not provided * @throws {ExistingPasswordMismatchError} If existing password is provided but does not match * @throws {KeyDerivationError} If key material import fails * @throws {VaultCorruptedError} If the stored vault data is structurally invalid */ setPassword(password: Password, existingPassword?: Password): Promise; /** * Checks if the provided password matches the vault password * * Derives a key from the password and attempts decryption without * changing the current unlock state. * * @param password - Caller-owned password bytes; caller should wipe with * `fill(0)` when no longer needed * @returns Branded boolean indicating if the password is valid * @throws {NoPasswordSetError} If no vault exists * @throws {KeyDerivationError} If key material import fails * @throws {VaultCorruptedError} If the stored vault data is structurally invalid */ checkPassword(password: Password): Promise; /** * Clears all internal LibQC state and locks the vault * * This method removes the encrypted vault from storage and clears the * cached encryption key. After calling this, the wallet is in the * "NoVault" state. * * @returns Promise that resolves when state has been cleared */ clearState(): Promise; /** * Creates a new account on a chain * * EVM: If an account already exists on a chain this will always create with a new address. * If an account doesn't exist, an existing address will be used unless forceNewAddress is set to true. * * Bitcoin: If an account already exists on a chain this will always create with a new address. * If an account doesn't exist, an existing address will be used unless forceNewAddress is set to true. * * @param chainId The chain to create the account on * @param forceNewAddress Forces the account to be created with a new address (EVM only; ignored for Bitcoin) * @param version The address version to use * @param indexOverride Optional index to use for account creation. If not provided, it will be derived. * @returns An account client * @throws {VaultLockedError} If the vault is locked */ createAccount(chainId: ChainId, forceNewAddress?: ForceNewAddress, version?: AddressVersion, indexOverride?: AddressIndex): Promise; /** * Creates an EVM account on the given chain. * * @remarks Must only be called from within a `storageLock.run()` callback. * Calling it outside the lock is a data race. Never re-acquire the lock * inside this method, doing so will deadlock. */ private createEvmAccount; /** * Creates a Bitcoin account on the given chain. * * @remarks Must only be called from within a `storageLock.run()` callback. * Calling it outside the lock is a data race. Never re-acquire the lock * inside this method, doing so will deadlock. */ private createBitcoinAccount; /** * List all accounts * * @returns A list of accounts * @throws {VaultLockedError} If the vault is locked */ listAccounts(): Promise; /** * Gets an account client for an account ID i.e. a CAIP10 identifier * * @param id A CAIP10 account identifier * @returns An account client * @throws {VaultLockedError} If the vault is locked */ getAccount(id: AccountId): Promise; /** * Constructs an account client for an already-loaded PersistedAccount. * * Separated from getAccount so that callers that already hold the account * list (e.g. getTotalBalances) can build clients without triggering an * extra listAccounts/state-decrypt round-trip per account. */ private buildAccountClient; /** * Checks if an asset is tracked * * @param asset The asset to check * @returns Whether the asset is tracked * @throws {VaultLockedError} If the vault is locked */ isAssetTracked(asset: Asset): Promise; /** * Adds an ERC-20 token to the wallet * * Native assets are automatically added when creating an account on a chain * and should not be manually added using this method. * * @param symbol - Token symbol * @returns The added token asset * @throws {VaultLockedError} If the vault is locked * @throws Error if the token is already tracked */ addToken(symbol: Symbol): Promise; /** * Lists all assets that have been added * * @returns An array of added assets * @throws {VaultLockedError} If the vault is locked */ listAssets(): Promise; /** * Gets all supported assets that can be added to the wallet * * When a chainId is provided, only returns assets that have entities for the specified chain. * The returned assets still contain all their entities across all chains, not just the filtered chain. * * @param chainId Optional chain ID to filter assets by * @returns An array of supported assets */ getSupportedAssets(chainId?: ChainId): Asset[]; /** * Gets all supported chains configured for this LibQC instance * * Returns the chain specifications that were provided during construction. * These chains should align with the assets defined in assets.json (Ethereum and Base). * * @returns An array of configured chain specifications */ getSupportedChains(): ChainSpecification[]; /** * Removes an ERC-20 token from the wallet * * Native assets cannot be removed as they are automatically managed per chain. * * @param asset The token asset to remove * @throws {VaultLockedError} If the vault is locked * @throws Error if the token is not tracked * @throws Error if attempting to remove a native asset */ removeToken(asset: Asset): Promise; /** * Gets the aggregate total balance of an asset across all accounts * * This function goes through all accounts and sums the balances of the asset across all accounts. * If an account is on a chain that does not support the asset then it is ignored. * If an account has no balance of the asset then it is ignored/assumed to be 0. * * @param asset The asset to get the balance of * @returns The aggregate total asset balance across all accounts * @throws {VaultLockedError} If the vault is locked * @throws {InvalidAssetDecimalsError} If the asset has invalid decimal configuration * @throws {InvalidAssetMetadataError} If the asset has missing or incomplete metadata * @throws {OnChainDecimalsMismatchError} If any account's on-chain * decimals disagree with the entity definition — this is treated as * a configuration error and intentionally fails the entire call * rather than returning a partial (silently wrong) result. */ getTotalBalance(asset: Asset): Promise; /** * Gets the aggregate balance of an array of assets across all accounts * * Calls listAccounts once, builds all account clients upfront, then * dispatches every (account × asset) getBalance call in a single * Promise.all so all EVM readContract calls land in the same multicall * batch window — minimizing RPC round-trips and 429 errors. * * Per asset, balances are summed at the highest precision available * across chains, then truncated to asset.decimals once at the end. * Scaling each chain's balance down individually would discard sub-unit * remainders per chain; summing first preserves dust that collectively * rounds up to a whole target unit. * * @param assets The assets to get the balances of * @returns The balances of the assets across all accounts * @throws {VaultLockedError} If the vault is locked * @throws {InvalidAssetDecimalsError} If any asset has invalid decimal configuration * @throws {InvalidAssetMetadataError} If any asset has missing or incomplete metadata * @throws {OnChainDecimalsMismatchError} If any account's on-chain * decimals disagree with the entity definition — this is treated as * a configuration error and intentionally fails the entire call * rather than returning a partial (silently wrong) result. */ getTotalBalances(assets: Asset[]): Promise; /** * Reads an ERC20 contract in order to return the token details for a given token address and chain * * @param chainId The chain to read the token on * @param tokenAddress The address of the token to read * @returns The token details */ readErc20TokenDetails(chainId: ChainId, tokenAddress: EvmAddress): Promise; /** * Lists SDK-owned withdrawal lifecycle records (ENG-1791). * * Records are persisted by `emptyVault` and advanced by * `refreshWithdrawalLifecycle`. They form the canonical source of truth * for withdrawal state and are returned newest-first by `initiatedAt`. * * @param accountId Optional CAIP-10 account filter * @returns Withdrawal records, newest-first * @throws {VaultLockedError} If the vault is locked */ listWithdrawals(accountId?: AccountId): Promise; /** * Reconciles withdrawal lifecycle records for an account against on-chain * state and persists transitions (ENG-1791). * * Iterates non-terminal records (status='pending' or 'sent') for the given * account, asks the appropriate account client to reconcile each record * against the chain, and writes back any updated records. Terminal records * ('withdrawn' / 'failed') are returned unchanged. * * Reconciliation is deterministic: pending → sent on confirmation, sent → * withdrawn once the source balance has been swept, and 'failed' only when * an explicit non-throw failure is observed. * * @param accountId The CAIP-10 account whose withdrawals should be refreshed * @returns The full set of records for the account, newest-first * @throws {VaultLockedError} If the vault is locked */ refreshWithdrawalLifecycle(accountId: AccountId): Promise; /** * Reads the seed from storage and executes a callback with a scoped * seed buffer. * * The seed buffer is always zeroed before this method resolves or rejects. * * @param callback - Scoped seed handler * @returns Callback result * @throws {VaultLockedError} If the vault is locked * @throws Error if the seed is not found in storage */ private getSeed; /** * Internal method to add any asset (native or token) to the wallet * * This bypasses token-only restrictions and is used internally for * adding native assets during account creation. * * @param asset The asset to add * @throws Error if the asset is already tracked * @remarks Must only be called from within a `storageLock.run()` callback. * Never re-acquire the lock inside this method — doing so will deadlock. */ private addAssetInternal; /** * Gets the first free index for account creation * * Filters addresses by chain type (EVM vs Bitcoin) and returns the index * within that filtered list. For Bitcoin, addresses are further filtered by * network (mainnet bc1 vs testnet tb1) since address encodings differ per network. * * @param state - Current LibQC state * @param chainId - The ID of the chain to create the account on * @param forceNewAddress - Whether to force a new address to be created * @returns The index for account creation (within the chain-type- and network-filtered list) */ private getIndexForAccountCreation; /** * Loads ML-DSA HTTP signing credentials from storage, or provisions and * persists them when signed HTTP is enabled and the segment is absent. * * @param encryptionKey - Vault key used to decrypt the `httpSigning` segment */ private ensureHttpSigningLoaded; /** * Copies persisted signing material into the transport ref for viem `fetchFn`. * * @param persisted - Decrypted segment value (zeroed after copying sensitive fields) */ private applyHttpSigningCredentials; /** * Clears ML-DSA signing material from the in-memory transport ref. */ private clearHttpSigningFromRef; /** * Guards operations that require the vault to be unlocked * * @returns The cached encryption key * @throws {VaultLockedError} If the encryption key is not cached */ private requireUnlocked; /** * Reads the key derivation metadata from the encrypted vault without decrypting * * Returns the scrypt salt (decoded from base64) and the scrypt params * that were used when the vault was originally encrypted. These stored * params must be used when re-deriving the key to ensure compatibility * even if the default scrypt params change in a future release. * * @returns The raw scrypt salt and the scrypt params stored in the vault * @throws {NoPasswordSetError} If no vault exists */ private readVaultKeyDerivationParams; }