@arkade-os/sdk
Version:
Bitcoin wallet SDK with Taproot and Ark integration
365 lines (364 loc) • 13.1 kB
JavaScript
import { Response } from './response.js';
import { hex } from "@scure/base";
import { IndexedDBStorageAdapter } from '../../storage/indexedDB.js';
import { WalletRepositoryImpl } from '../../repositories/walletRepository.js';
import { ContractRepositoryImpl } from '../../repositories/contractRepository.js';
import { DEFAULT_DB_NAME, setupServiceWorker } from './utils.js';
const isPrivateKeyIdentity = (identity) => {
return typeof identity.toHex === "function";
};
class UnexpectedResponseError extends Error {
constructor(response) {
super(`Unexpected response type. Got: ${JSON.stringify(response, null, 2)}`);
this.name = "UnexpectedResponseError";
}
}
const createCommon = (options) => {
// Default to IndexedDB for service worker context
const storage = new IndexedDBStorageAdapter(options.dbName || DEFAULT_DB_NAME, options.dbVersion);
// Create repositories
return {
walletRepo: new WalletRepositoryImpl(storage),
contractRepo: new ContractRepositoryImpl(storage),
};
};
export class ServiceWorkerReadonlyWallet {
constructor(serviceWorker, identity, walletRepository, contractRepository) {
this.serviceWorker = serviceWorker;
this.identity = identity;
this.walletRepository = walletRepository;
this.contractRepository = contractRepository;
}
static async create(options) {
const { walletRepo, contractRepo } = createCommon(options);
// Create the wallet instance
const wallet = new ServiceWorkerReadonlyWallet(options.serviceWorker, options.identity, walletRepo, contractRepo);
const publicKey = await options.identity
.compressedPublicKey()
.then(hex.encode);
// Initialize the service worker with the config
const initMessage = {
type: "INIT_WALLET",
id: getRandomId(),
key: { publicKey },
arkServerUrl: options.arkServerUrl,
arkServerPublicKey: options.arkServerPublicKey,
};
// Initialize the service worker
await wallet.sendMessage(initMessage);
return wallet;
}
/**
* Simplified setup method that handles service worker registration,
* identity creation, and wallet initialization automatically.
*
* @example
* ```typescript
* // One-liner setup - handles everything automatically!
* const wallet = await ServiceWorkerReadonlyWallet.setup({
* serviceWorkerPath: '/service-worker.js',
* arkServerUrl: 'https://mutinynet.arkade.sh'
* });
*
* // With custom readonly identity
* const identity = ReadonlySingleKey.fromPublicKey('your_public_key_hex');
* const wallet = await ServiceWorkerReadonlyWallet.setup({
* serviceWorkerPath: '/service-worker.js',
* arkServerUrl: 'https://mutinynet.arkade.sh',
* identity
* });
* ```
*/
static async setup(options) {
// Register and setup the service worker
const serviceWorker = await setupServiceWorker(options.serviceWorkerPath);
// Use the existing create method
return ServiceWorkerReadonlyWallet.create({
...options,
serviceWorker,
});
}
// send a message and wait for a response
async sendMessage(message) {
return new Promise((resolve, reject) => {
const messageHandler = (event) => {
const response = event.data;
if (response.id === "") {
reject(new Error("Invalid response id"));
return;
}
if (response.id !== message.id) {
return;
}
navigator.serviceWorker.removeEventListener("message", messageHandler);
if (!response.success) {
reject(new Error(response.message));
}
else {
resolve(response);
}
};
navigator.serviceWorker.addEventListener("message", messageHandler);
this.serviceWorker.postMessage(message);
});
}
async clear() {
const message = {
type: "CLEAR",
id: getRandomId(),
};
// Clear page-side storage to maintain parity with SW
try {
const address = await this.getAddress();
await this.walletRepository.clearVtxos(address);
}
catch (_) {
console.warn("Failed to clear vtxos from wallet repository");
}
await this.sendMessage(message);
}
async getAddress() {
const message = {
type: "GET_ADDRESS",
id: getRandomId(),
};
try {
const response = await this.sendMessage(message);
if (Response.isAddress(response)) {
return response.address;
}
throw new UnexpectedResponseError(response);
}
catch (error) {
throw new Error(`Failed to get address: ${error}`);
}
}
async getBoardingAddress() {
const message = {
type: "GET_BOARDING_ADDRESS",
id: getRandomId(),
};
try {
const response = await this.sendMessage(message);
if (Response.isBoardingAddress(response)) {
return response.address;
}
throw new UnexpectedResponseError(response);
}
catch (error) {
throw new Error(`Failed to get boarding address: ${error}`);
}
}
async getBalance() {
const message = {
type: "GET_BALANCE",
id: getRandomId(),
};
try {
const response = await this.sendMessage(message);
if (Response.isBalance(response)) {
return response.balance;
}
throw new UnexpectedResponseError(response);
}
catch (error) {
throw new Error(`Failed to get balance: ${error}`);
}
}
async getBoardingUtxos() {
const message = {
type: "GET_BOARDING_UTXOS",
id: getRandomId(),
};
try {
const response = await this.sendMessage(message);
if (Response.isBoardingUtxos(response)) {
return response.boardingUtxos;
}
throw new UnexpectedResponseError(response);
}
catch (error) {
throw new Error(`Failed to get boarding UTXOs: ${error}`);
}
}
async getStatus() {
const message = {
type: "GET_STATUS",
id: getRandomId(),
};
const response = await this.sendMessage(message);
if (Response.isWalletStatus(response)) {
return response.status;
}
throw new UnexpectedResponseError(response);
}
async getTransactionHistory() {
const message = {
type: "GET_TRANSACTION_HISTORY",
id: getRandomId(),
};
try {
const response = await this.sendMessage(message);
if (Response.isTransactionHistory(response)) {
return response.transactions;
}
throw new UnexpectedResponseError(response);
}
catch (error) {
throw new Error(`Failed to get transaction history: ${error}`);
}
}
async getVtxos(filter) {
const message = {
type: "GET_VTXOS",
id: getRandomId(),
filter,
};
try {
const response = await this.sendMessage(message);
if (Response.isVtxos(response)) {
return response.vtxos;
}
throw new UnexpectedResponseError(response);
}
catch (error) {
throw new Error(`Failed to get vtxos: ${error}`);
}
}
async reload() {
const message = {
type: "RELOAD_WALLET",
id: getRandomId(),
};
const response = await this.sendMessage(message);
if (Response.isWalletReloaded(response)) {
return response.success;
}
throw new UnexpectedResponseError(response);
}
}
export class ServiceWorkerWallet extends ServiceWorkerReadonlyWallet {
constructor(serviceWorker, identity, walletRepository, contractRepository) {
super(serviceWorker, identity, walletRepository, contractRepository);
this.serviceWorker = serviceWorker;
this.identity = identity;
this.walletRepository = walletRepository;
this.contractRepository = contractRepository;
}
static async create(options) {
const { walletRepo, contractRepo } = createCommon(options);
// Extract identity and check if it can expose private key
const identity = isPrivateKeyIdentity(options.identity)
? options.identity
: null;
if (!identity) {
throw new Error("ServiceWorkerWallet.create() requires a Identity that can expose a single private key");
}
// Extract private key for service worker initialization
const privateKey = identity.toHex();
// Create the wallet instance
const wallet = new ServiceWorkerWallet(options.serviceWorker, identity, walletRepo, contractRepo);
// Initialize the service worker with the config
const initMessage = {
type: "INIT_WALLET",
id: getRandomId(),
key: { privateKey },
arkServerUrl: options.arkServerUrl,
arkServerPublicKey: options.arkServerPublicKey,
};
// Initialize the service worker
await wallet.sendMessage(initMessage);
return wallet;
}
/**
* Simplified setup method that handles service worker registration,
* identity creation, and wallet initialization automatically.
*
* @example
* ```typescript
* // One-liner setup - handles everything automatically!
* const wallet = await ServiceWorkerWallet.setup({
* serviceWorkerPath: '/service-worker.js',
* arkServerUrl: 'https://mutinynet.arkade.sh'
* });
*
* // With custom identity
* const identity = SingleKey.fromHex('your_private_key_hex');
* const wallet = await ServiceWorkerWallet.setup({
* serviceWorkerPath: '/service-worker.js',
* arkServerUrl: 'https://mutinynet.arkade.sh',
* identity
* });
* ```
*/
static async setup(options) {
// Register and setup the service worker
const serviceWorker = await setupServiceWorker(options.serviceWorkerPath);
// Use the existing create method
return ServiceWorkerWallet.create({
...options,
serviceWorker,
});
}
async sendBitcoin(params) {
const message = {
type: "SEND_BITCOIN",
params,
id: getRandomId(),
};
try {
const response = await this.sendMessage(message);
if (Response.isSendBitcoinSuccess(response)) {
return response.txid;
}
throw new UnexpectedResponseError(response);
}
catch (error) {
throw new Error(`Failed to send bitcoin: ${error}`);
}
}
async settle(params, callback) {
const message = {
type: "SETTLE",
params,
id: getRandomId(),
};
try {
return new Promise((resolve, reject) => {
const messageHandler = (event) => {
const response = event.data;
if (response.id !== message.id) {
return;
}
if (!response.success) {
navigator.serviceWorker.removeEventListener("message", messageHandler);
reject(new Error(response.message));
return;
}
switch (response.type) {
case "SETTLE_EVENT":
if (callback) {
callback(response.event);
}
break;
case "SETTLE_SUCCESS":
navigator.serviceWorker.removeEventListener("message", messageHandler);
resolve(response.txid);
break;
default:
break;
}
};
navigator.serviceWorker.addEventListener("message", messageHandler);
this.serviceWorker.postMessage(message);
});
}
catch (error) {
throw new Error(`Settlement failed: ${error}`);
}
}
}
function getRandomId() {
const randomValue = crypto.getRandomValues(new Uint8Array(16));
return hex.encode(randomValue);
}