UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

499 lines (498 loc) 20.4 kB
/// <reference lib="webworker" /> import { SingleKey } from '../../identity/singleKey.js'; import { isSpendable, isSubdust } from '../index.js'; import { Wallet } from '../wallet.js'; import { Request } from './request.js'; import { Response } from './response.js'; import { RestArkProvider } from '../../providers/ark.js'; import { IndexedDBVtxoRepository } from './db/vtxo/idb.js'; import { vtxosToTxs } from '../../utils/transactionHistory.js'; import { RestIndexerProvider } from '../../providers/indexer.js'; import { base64, hex } from "@scure/base"; import { Transaction } from "@scure/btc-signer"; /** * Worker is a class letting to interact with ServiceWorkerWallet from the client * it aims to be run in a service worker context */ export class Worker { constructor(vtxoRepository = new IndexedDBVtxoRepository(), messageCallback = () => { }) { this.vtxoRepository = vtxoRepository; this.messageCallback = messageCallback; } 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.vtxoSubscription) { this.vtxoSubscription.abort(); } await this.vtxoRepository.close(); this.wallet = undefined; this.arkProvider = undefined; this.indexerProvider = undefined; this.vtxoSubscription = undefined; } async onWalletInitialized() { if (!this.wallet || !this.arkProvider || !this.indexerProvider || !this.wallet.offchainTapscript || !this.wallet.boardingTapscript) { return; } // subscribe to address updates await this.vtxoRepository.open(); const encodedOffchainTapscript = this.wallet.offchainTapscript.encode(); const forfeit = this.wallet.offchainTapscript.forfeit(); const exit = this.wallet.offchainTapscript.exit(); const script = hex.encode(this.wallet.offchainTapscript.pkScript); // set the initial vtxos state const response = await this.indexerProvider.getVtxos({ scripts: [script], }); const vtxos = response.vtxos.map((vtxo) => ({ ...vtxo, forfeitTapLeafScript: forfeit, intentTapLeafScript: exit, tapTree: encodedOffchainTapscript, })); await this.vtxoRepository.addOrUpdate(vtxos); this.processVtxoSubscription({ script, vtxoScript: this.wallet.offchainTapscript, }); } async processVtxoSubscription({ script, vtxoScript, }) { try { const forfeitTapLeafScript = vtxoScript.forfeit(); const intentTapLeafScript = vtxoScript.exit(); const abortController = new AbortController(); const subscriptionId = await this.indexerProvider.subscribeForScripts([script]); const subscription = this.indexerProvider.getSubscription(subscriptionId, abortController.signal); this.vtxoSubscription = abortController; const tapTree = vtxoScript.encode(); for await (const update of subscription) { const vtxos = [...update.newVtxos, ...update.spentVtxos]; if (vtxos.length === 0) { continue; } const extendedVtxos = vtxos.map((vtxo) => ({ ...vtxo, forfeitTapLeafScript, intentTapLeafScript, tapTree, })); await this.vtxoRepository.addOrUpdate(extendedVtxos); } } catch (error) { console.error("Error processing address updates:", error); } } async handleClear(event) { this.clear(); if (Request.isBase(event.data)) { event.source?.postMessage(Response.clearResponse(event.data.id, true)); } } async handleInitWallet(event) { const message = event.data; if (!Request.isInitWallet(message)) { console.error("Invalid INIT_WALLET message format", message); event.source?.postMessage(Response.error(message.id, "Invalid INIT_WALLET message format")); return; } try { this.arkProvider = new RestArkProvider(message.arkServerUrl); this.indexerProvider = new RestIndexerProvider(message.arkServerUrl); this.wallet = await Wallet.create({ identity: SingleKey.fromHex(message.privateKey), arkServerUrl: message.arkServerUrl, arkServerPublicKey: message.arkServerPublicKey, }); event.source?.postMessage(Response.walletInitialized(message.id)); await this.onWalletInitialized(); } 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)); } } 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.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } const txid = await this.wallet.settle(message.params, (e) => { event.source?.postMessage(Response.settleEvent(message.id, e)); }); event.source?.postMessage(Response.settleSuccess(message.id, txid)); } 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.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const txid = await this.wallet.sendBitcoin(message.params); event.source?.postMessage(Response.sendBitcoinSuccess(message.id, txid)); } 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.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const address = await this.wallet.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.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const address = await this.wallet.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.wallet) { 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.wallet.getBoardingUtxos(), this.vtxoRepository.getSpendableVtxos(), this.vtxoRepository.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.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { let vtxos = await this.vtxoRepository.getSpendableVtxos(); if (!message.filter?.withRecoverable) { if (!this.wallet) throw new Error("Wallet not initialized"); // exclude subdust is we don't want recoverable vtxos = vtxos.filter((v) => !isSubdust(v, this.wallet.dustAmount)); } if (message.filter?.withRecoverable) { // get also swept and spendable vtxos const sweptVtxos = await this.vtxoRepository.getSweptVtxos(); vtxos.push(...sweptVtxos.filter(isSpendable)); } event.source?.postMessage(Response.vtxos(message.id, vtxos)); } 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.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const boardingUtxos = await this.wallet.getBoardingUtxos(); 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.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const { boardingTxs, commitmentsToIgnore: roundsToIgnore } = await this.wallet.getBoardingTxs(); const { spendable, spent } = await this.vtxoRepository.getAllVtxos(); // convert VTXOs to offchain transactions const offchainTxs = vtxosToTxs(spendable, spent, roundsToIgnore); const 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; }); 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; } event.source?.postMessage(Response.walletStatus(message.id, this.wallet !== undefined)); } async handleSign(event) { const message = event.data; if (!Request.isSign(message)) { console.error("Invalid SIGN message format", message); event.source?.postMessage(Response.error(message.id, "Invalid SIGN message format")); return; } if (!this.wallet) { console.error("Wallet not initialized"); event.source?.postMessage(Response.error(message.id, "Wallet not initialized")); return; } try { const tx = Transaction.fromPSBT(base64.decode(message.tx), { allowUnknown: true, allowUnknownInputs: true, }); const signedTx = await this.wallet.identity.sign(tx, message.inputIndexes); event.source?.postMessage(Response.signSuccess(message.id, base64.encode(signedTx.toPSBT()))); } catch (error) { console.error("Error signing:", error); const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; event.source?.postMessage(Response.error(message.id, errorMessage)); } } 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 "SIGN": { await this.handleSign(event); break; } default: event.source?.postMessage(Response.error(message.id, "Unknown message type")); } } }