UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

679 lines (678 loc) 27.3 kB
/// <reference lib="webworker" /> import { ReadonlySingleKey, SingleKey } from '../../identity/singleKey.js'; import { isExpired, isRecoverable, isSpendable, isSubdust, } from '../index.js'; import { ReadonlyWallet, Wallet } from '../wallet.js'; import { Request } from './request.js'; import { Response } from './response.js'; import { RestArkProvider } from '../../providers/ark.js'; import { vtxosToTxs } from '../../utils/transactionHistory.js'; import { RestIndexerProvider } from '../../providers/indexer.js'; import { hex } from "@scure/base"; import { IndexedDBStorageAdapter } from '../../storage/indexedDB.js'; import { WalletRepositoryImpl, } from '../../repositories/walletRepository.js'; import { extendCoin, extendVirtualCoin } from '../utils.js'; import { DEFAULT_DB_NAME } from './utils.js'; class ReadonlyHandler { constructor(wallet) { this.wallet = wallet; } get offchainTapscript() { return this.wallet.offchainTapscript; } get boardingTapscript() { return this.wallet.boardingTapscript; } get onchainProvider() { return this.wallet.onchainProvider; } get dustAmount() { return this.wallet.dustAmount; } get identity() { return this.wallet.identity; } notifyIncomingFunds(...args) { return this.wallet.notifyIncomingFunds(...args); } getAddress() { return this.wallet.getAddress(); } getBoardingAddress() { return this.wallet.getBoardingAddress(); } getBoardingTxs() { return this.wallet.getBoardingTxs(); } async handleReload(_) { const pending = await this.wallet.fetchPendingTxs(); return { pending, finalized: [] }; } async handleSettle(..._) { return undefined; } async handleSendBitcoin(..._) { return undefined; } } class Handler extends ReadonlyHandler { constructor(wallet) { super(wallet); this.wallet = wallet; } async handleReload(vtxos) { return this.wallet.finalizePendingTxs(vtxos.filter((vtxo) => vtxo.virtualStatus.state !== "swept" && vtxo.virtualStatus.state !== "settled")); } async handleSettle(...args) { return this.wallet.settle(...args); } async handleSendBitcoin(...args) { return this.wallet.sendBitcoin(...args); } } /** * Worker is a class letting to interact with ServiceWorkerWallet and ServiceWorkerReadonlyWallet from * the client; it aims to be run in a service worker context. * * The messages requiring a Wallet rather than a ReadonlyWallet result in no-op * without errors. */ export class Worker { constructor(dbName = DEFAULT_DB_NAME, dbVersion = 1, messageCallback = () => { }) { this.dbName = dbName; this.dbVersion = dbVersion; this.messageCallback = messageCallback; this.storage = new IndexedDBStorageAdapter(dbName, dbVersion); this.walletRepository = new WalletRepositoryImpl(this.storage); } /** * Get spendable vtxos for the current wallet address */ async getSpendableVtxos() { if (!this.handler) return []; const address = await this.handler.getAddress(); const allVtxos = await this.walletRepository.getVtxos(address); return allVtxos.filter(isSpendable); } /** * Get swept vtxos for the current wallet address */ async getSweptVtxos() { if (!this.handler) return []; const address = await this.handler.getAddress(); const allVtxos = await this.walletRepository.getVtxos(address); return allVtxos.filter((vtxo) => vtxo.virtualStatus.state === "swept"); } /** * Get all vtxos categorized by type */ async getAllVtxos() { if (!this.handler) return { spendable: [], spent: [] }; const address = await this.handler.getAddress(); const allVtxos = await this.walletRepository.getVtxos(address); return { spendable: allVtxos.filter(isSpendable), spent: allVtxos.filter((vtxo) => !isSpendable(vtxo)), }; } /** * Get all boarding utxos from wallet repository */ async getAllBoardingUtxos() { if (!this.handler) return []; const address = await this.handler.getBoardingAddress(); return await this.walletRepository.getUtxos(address); } async getTransactionHistory() { if (!this.handler) return []; let txs = []; try { const { boardingTxs, commitmentsToIgnore: roundsToIgnore } = await this.handler.getBoardingTxs(); const { spendable, spent } = await this.getAllVtxos(); // convert VTXOs to offchain transactions const offchainTxs = vtxosToTxs(spendable, spent, roundsToIgnore); txs = [...boardingTxs, ...offchainTxs]; // sort transactions by creation time in descending order (newest first) txs.sort( // place createdAt = 0 (unconfirmed txs) first, then descending (a, b) => { if (a.createdAt === 0) return -1; if (b.createdAt === 0) return 1; return b.createdAt - a.createdAt; }); } catch (error) { console.error("Error getting transaction history:", error); } return txs; } async start(withServiceWorkerUpdate = true) { self.addEventListener("message", async (event) => { await this.handleMessage(event); }); if (withServiceWorkerUpdate) { // activate service worker immediately self.addEventListener("install", () => { self.skipWaiting(); }); // take control of clients immediately self.addEventListener("activate", () => { self.clients.claim(); }); } } async clear() { if (this.incomingFundsSubscription) this.incomingFundsSubscription(); // Clear storage - this replaces vtxoRepository.close() await this.storage.clear(); // Reset in-memory caches by recreating the repository this.walletRepository = new WalletRepositoryImpl(this.storage); this.handler = undefined; this.arkProvider = undefined; this.indexerProvider = undefined; } async reload() { await this.onWalletInitialized(); } async onWalletInitialized() { if (!this.handler || !this.arkProvider || !this.indexerProvider || !this.handler.offchainTapscript || !this.handler.boardingTapscript) { return; } // Get public key script and set the initial vtxos state const script = hex.encode(this.handler.offchainTapscript.pkScript); const response = await this.indexerProvider.getVtxos({ scripts: [script], }); const vtxos = response.vtxos.map((vtxo) => extendVirtualCoin(this.handler, vtxo)); try { // recover pending transactions if possible const { pending, finalized } = await this.handler.handleReload(vtxos); console.info(`Recovered ${finalized.length}/${pending.length} pending transactions: ${finalized.join(", ")}`); } catch (error) { console.error("Error recovering pending transactions:", error); } // Get wallet address and save vtxos using unified repository const address = await this.handler.getAddress(); await this.walletRepository.saveVtxos(address, vtxos); // Fetch boarding utxos and save using unified repository const boardingAddress = await this.handler.getBoardingAddress(); const coins = await this.handler.onchainProvider.getCoins(boardingAddress); await this.walletRepository.saveUtxos(boardingAddress, coins.map((utxo) => extendCoin(this.handler, utxo))); // Get transaction history to cache boarding txs const txs = await this.getTransactionHistory(); if (txs) await this.walletRepository.saveTransactions(address, txs); // unsubscribe previous subscription if any if (this.incomingFundsSubscription) this.incomingFundsSubscription(); // subscribe for incoming funds and notify all clients when new funds arrive this.incomingFundsSubscription = await this.handler.notifyIncomingFunds(async (funds) => { if (funds.type === "vtxo") { const newVtxos = funds.newVtxos.length > 0 ? funds.newVtxos.map((vtxo) => extendVirtualCoin(this.handler, vtxo)) : []; const spentVtxos = funds.spentVtxos.length > 0 ? funds.spentVtxos.map((vtxo) => extendVirtualCoin(this.handler, vtxo)) : []; if ([...newVtxos, ...spentVtxos].length === 0) return; // save vtxos using unified repository await this.walletRepository.saveVtxos(address, [ ...newVtxos, ...spentVtxos, ]); // notify all clients about the vtxo update await this.sendMessageToAllClients(Response.vtxoUpdate(newVtxos, spentVtxos)); } if (funds.type === "utxo") { const utxos = funds.coins.map((utxo) => extendCoin(this.handler, utxo)); const boardingAddress = await this.handler?.getBoardingAddress(); // save utxos using unified repository await this.walletRepository.clearUtxos(boardingAddress); await this.walletRepository.saveUtxos(boardingAddress, utxos); // notify all clients about the utxo update await this.sendMessageToAllClients(Response.utxoUpdate(utxos)); } }); } async handleClear(event) { await this.clear(); if (Request.isBase(event.data)) { event.source?.postMessage(Response.clearResponse(event.data.id, true)); } } async handleInitWallet(event) { if (!Request.isInitWallet(event.data)) { console.error("Invalid INIT_WALLET message format", event.data); event.source?.postMessage(Response.error(event.data.id, "Invalid INIT_WALLET message format")); return; } const message = event.data; const { arkServerPublicKey, arkServerUrl } = message; this.arkProvider = new RestArkProvider(arkServerUrl); this.indexerProvider = new RestIndexerProvider(arkServerUrl); try { if ("privateKey" in message.key && typeof message.key.privateKey === "string") { const { key: { privateKey }, } = message; const identity = SingleKey.fromHex(privateKey); const wallet = await Wallet.create({ identity, arkServerUrl, arkServerPublicKey, storage: this.storage, // Use unified storage for wallet too }); this.handler = new Handler(wallet); } else if ("publicKey" in message.key && typeof message.key.publicKey === "string") { const { key: { publicKey }, } = message; const identity = ReadonlySingleKey.fromPublicKey(hex.decode(publicKey)); const wallet = await ReadonlyWallet.create({ identity, arkServerUrl, arkServerPublicKey, storage: this.storage, // Use unified storage for wallet too }); this.handler = new ReadonlyHandler(wallet); } else { const err = "Missing privateKey or publicKey in key object"; event.source?.postMessage(Response.error(message.id, err)); console.error(err); return; } } catch (error) { console.error("Error initializing wallet:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); return; } event.source?.postMessage(Response.walletInitialized(message.id)); await this.onWalletInitialized(); } async handleSettle(event) { const message = event.data; if (!Request.isSettle(message)) { console.error("Invalid SETTLE message format", message); event.source?.postMessage(Response.error(message.id, "Invalid SETTLE message format")); return; } try { if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } const txid = await this.handler.handleSettle(message.params, (e) => { event.source?.postMessage(Response.settleEvent(message.id, e)); }); if (txid) { event.source?.postMessage(Response.settleSuccess(message.id, txid)); } else { event.source?.postMessage(Response.error(message.id, "Operation not supported in readonly mode")); } } catch (error) { console.error("Error settling:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleSendBitcoin(event) { const message = event.data; if (!Request.isSendBitcoin(message)) { console.error("Invalid SEND_BITCOIN message format", message); event.source?.postMessage(Response.error(message.id, "Invalid SEND_BITCOIN message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const txid = await this.handler.handleSendBitcoin(message.params); if (txid) { event.source?.postMessage(Response.sendBitcoinSuccess(message.id, txid)); } else { event.source?.postMessage(Response.error(message.id, "Operation not supported in readonly mode")); } } catch (error) { console.error("Error sending bitcoin:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleGetAddress(event) { const message = event.data; if (!Request.isGetAddress(message)) { console.error("Invalid GET_ADDRESS message format", message); event.source?.postMessage(Response.error(message.id, "Invalid GET_ADDRESS message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const address = await this.handler.getAddress(); event.source?.postMessage(Response.address(message.id, address)); } catch (error) { console.error("Error getting address:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleGetBoardingAddress(event) { const message = event.data; if (!Request.isGetBoardingAddress(message)) { console.error("Invalid GET_BOARDING_ADDRESS message format", message); event.source?.postMessage(Response.error(message.id, "Invalid GET_BOARDING_ADDRESS message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const address = await this.handler.getBoardingAddress(); event.source?.postMessage(Response.boardingAddress(message.id, address)); } catch (error) { console.error("Error getting boarding address:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleGetBalance(event) { const message = event.data; if (!Request.isGetBalance(message)) { console.error("Invalid GET_BALANCE message format", message); event.source?.postMessage(Response.error(message.id, "Invalid GET_BALANCE message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const [boardingUtxos, spendableVtxos, sweptVtxos] = await Promise.all([ this.getAllBoardingUtxos(), this.getSpendableVtxos(), this.getSweptVtxos(), ]); // boarding let confirmed = 0; let unconfirmed = 0; for (const utxo of boardingUtxos) { if (utxo.status.confirmed) { confirmed += utxo.value; } else { unconfirmed += utxo.value; } } // offchain let settled = 0; let preconfirmed = 0; let recoverable = 0; for (const vtxo of spendableVtxos) { if (vtxo.virtualStatus.state === "settled") { settled += vtxo.value; } else if (vtxo.virtualStatus.state === "preconfirmed") { preconfirmed += vtxo.value; } } for (const vtxo of sweptVtxos) { if (isSpendable(vtxo)) { recoverable += vtxo.value; } } const totalBoarding = confirmed + unconfirmed; const totalOffchain = settled + preconfirmed + recoverable; event.source?.postMessage(Response.balance(message.id, { boarding: { confirmed, unconfirmed, total: totalBoarding, }, settled, preconfirmed, available: settled + preconfirmed, recoverable, total: totalBoarding + totalOffchain, })); } catch (error) { console.error("Error getting balance:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleGetVtxos(event) { const message = event.data; if (!Request.isGetVtxos(message)) { console.error("Invalid GET_VTXOS message format", message); event.source?.postMessage(Response.error(message.id, "Invalid GET_VTXOS message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const vtxos = await this.getSpendableVtxos(); const dustAmount = this.handler.dustAmount; const includeRecoverable = message.filter?.withRecoverable ?? false; const filteredVtxos = includeRecoverable ? vtxos : vtxos.filter((v) => { if (dustAmount != null && isSubdust(v, dustAmount)) { return false; } if (isRecoverable(v)) { return false; } if (isExpired(v)) { return false; } return true; }); event.source?.postMessage(Response.vtxos(message.id, filteredVtxos)); } catch (error) { console.error("Error getting vtxos:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleGetBoardingUtxos(event) { const message = event.data; if (!Request.isGetBoardingUtxos(message)) { console.error("Invalid GET_BOARDING_UTXOS message format", message); event.source?.postMessage(Response.error(message.id, "Invalid GET_BOARDING_UTXOS message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const boardingUtxos = await this.getAllBoardingUtxos(); event.source?.postMessage(Response.boardingUtxos(message.id, boardingUtxos)); } catch (error) { console.error("Error getting boarding utxos:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleGetTransactionHistory(event) { const message = event.data; if (!Request.isGetTransactionHistory(message)) { console.error("Invalid GET_TRANSACTION_HISTORY message format", message); event.source?.postMessage(Response.error(message.id, "Invalid GET_TRANSACTION_HISTORY message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const txs = await this.getTransactionHistory(); event.source?.postMessage(Response.transactionHistory(message.id, txs)); } catch (error) { console.error("Error getting transaction history:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } async handleGetStatus(event) { const message = event.data; if (!Request.isGetStatus(message)) { console.error("Invalid GET_STATUS message format", message); event.source?.postMessage(Response.error(message.id, "Invalid GET_STATUS message format")); return; } const pubKey = this.handler ? await this.handler.identity.xOnlyPublicKey() : undefined; event.source?.postMessage(Response.walletStatus(message.id, this.handler !== undefined, pubKey)); } async handleMessage(event) { this.messageCallback(event); const message = event.data; if (!Request.isBase(message)) { console.warn("Invalid message format", JSON.stringify(message)); // ignore invalid messages return; } switch (message.type) { case "INIT_WALLET": { await this.handleInitWallet(event); break; } case "SETTLE": { await this.handleSettle(event); break; } case "SEND_BITCOIN": { await this.handleSendBitcoin(event); break; } case "GET_ADDRESS": { await this.handleGetAddress(event); break; } case "GET_BOARDING_ADDRESS": { await this.handleGetBoardingAddress(event); break; } case "GET_BALANCE": { await this.handleGetBalance(event); break; } case "GET_VTXOS": { await this.handleGetVtxos(event); break; } case "GET_BOARDING_UTXOS": { await this.handleGetBoardingUtxos(event); break; } case "GET_TRANSACTION_HISTORY": { await this.handleGetTransactionHistory(event); break; } case "GET_STATUS": { await this.handleGetStatus(event); break; } case "CLEAR": { await this.handleClear(event); break; } case "RELOAD_WALLET": { await this.handleReloadWallet(event); break; } default: event.source?.postMessage(Response.error(message.id, "Unknown message type")); } } async sendMessageToAllClients(message) { self.clients .matchAll({ includeUncontrolled: true, type: "window" }) .then((clients) => { clients.forEach((client) => { client.postMessage(message); }); }); } async handleReloadWallet(event) { const message = event.data; if (!Request.isReloadWallet(message)) { console.error("Invalid RELOAD_WALLET message format", message); event.source?.postMessage(Response.error(message.id, "Invalid RELOAD_WALLET message format")); return; } if (!this.handler) { console.error("Wallet not initialized"); event.source?.postMessage(Response.walletReloaded(message.id, false)); return; } try { await this.onWalletInitialized(); event.source?.postMessage(Response.walletReloaded(message.id, true)); } catch (error) { console.error("Error reloading wallet:", error); event.source?.postMessage(Response.walletReloaded(message.id, false)); } } }