@arkade-os/sdk
Version:
Bitcoin wallet SDK with Taproot and Ark integration
679 lines (678 loc) • 27.3 kB
JavaScript
/// <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));
}
}
}