UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

318 lines (317 loc) 12.1 kB
import { isExpired, isRecoverable, isSpendable, isSubdust, } from './index.js'; export const DEFAULT_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000; // 3 days /** * Default renewal configuration values */ export const DEFAULT_RENEWAL_CONFIG = { thresholdMs: DEFAULT_THRESHOLD_MS, // 3 days }; function getDustAmount(wallet) { return "dustAmount" in wallet ? wallet.dustAmount : 330n; } /** * Filter VTXOs that are recoverable (swept and still spendable, or preconfirmed subdust) * * Recovery strategy: * - Always recover swept VTXOs (they've been taken by the server) * - Only recover subdust preconfirmed VTXOs (to avoid locking liquidity on settled VTXOs with long expiry) * * @param vtxos - Array of virtual coins to check * @param dustAmount - Dust threshold to identify subdust * @returns Array of recoverable VTXOs */ function getRecoverableVtxos(vtxos, dustAmount) { return vtxos.filter((vtxo) => { // Always recover swept VTXOs if (isRecoverable(vtxo)) { return true; } // also include vtxos that are not swept but expired if (isSpendable(vtxo) && isExpired(vtxo)) { return true; } // Recover preconfirmed subdust to consolidate small amounts if (vtxo.virtualStatus.state === "preconfirmed" && isSubdust(vtxo, dustAmount)) { return true; } return false; }); } /** * Get recoverable VTXOs including subdust coins if the total value exceeds dust threshold. * * Decision is based on the combined total of ALL recoverable VTXOs (regular + subdust), * not just the subdust portion alone. * * @param vtxos - Array of virtual coins to check * @param dustAmount - Dust threshold amount in satoshis * @returns Object containing recoverable VTXOs and whether subdust should be included */ function getRecoverableWithSubdust(vtxos, dustAmount) { const recoverableVtxos = getRecoverableVtxos(vtxos, dustAmount); // Separate subdust from regular recoverable const subdust = []; const regular = []; for (const vtxo of recoverableVtxos) { if (isSubdust(vtxo, dustAmount)) { subdust.push(vtxo); } else { regular.push(vtxo); } } // Calculate totals const regularTotal = regular.reduce((sum, vtxo) => sum + BigInt(vtxo.value), 0n); const subdustTotal = subdust.reduce((sum, vtxo) => sum + BigInt(vtxo.value), 0n); const combinedTotal = regularTotal + subdustTotal; // Include subdust only if the combined total exceeds dust threshold const shouldIncludeSubdust = combinedTotal >= dustAmount; const vtxosToRecover = shouldIncludeSubdust ? recoverableVtxos : regular; const totalAmount = vtxosToRecover.reduce((sum, vtxo) => sum + BigInt(vtxo.value), 0n); return { vtxosToRecover, includesSubdust: shouldIncludeSubdust, totalAmount, }; } /** * Check if a VTXO is expiring soon based on threshold * * @param vtxo - The virtual coin to check * @param thresholdMs - Threshold in milliseconds from now * @returns true if VTXO expires within threshold, false otherwise */ export function isVtxoExpiringSoon(vtxo, thresholdMs // in milliseconds ) { const realThresholdMs = thresholdMs <= 100 ? DEFAULT_THRESHOLD_MS : thresholdMs; const { batchExpiry } = vtxo.virtualStatus; if (!batchExpiry) return false; // it doesn't expire const now = Date.now(); if (batchExpiry <= now) return false; // already expired return batchExpiry - now <= realThresholdMs; } /** * Filter VTXOs that are expiring soon or are recoverable/subdust * * @param vtxos - Array of virtual coins to check * @param thresholdMs - Threshold in milliseconds from now * @param dustAmount - Dust threshold amount in satoshis * @returns Array of VTXOs expiring within threshold */ export function getExpiringAndRecoverableVtxos(vtxos, thresholdMs, dustAmount) { return vtxos.filter((vtxo) => isVtxoExpiringSoon(vtxo, thresholdMs) || isRecoverable(vtxo) || (isSpendable(vtxo) && isExpired(vtxo)) || isSubdust(vtxo, dustAmount)); } /** * VtxoManager is a unified class for managing VTXO lifecycle operations including * recovery of swept/expired VTXOs and renewal to prevent expiration. * * Key Features: * - **Recovery**: Reclaim swept or expired VTXOs back to the wallet * - **Renewal**: Refresh VTXO expiration time before they expire * - **Smart subdust handling**: Automatically includes subdust VTXOs when economically viable * - **Expiry monitoring**: Check for VTXOs that are expiring soon * * VTXOs become recoverable when: * - The Ark server sweeps them (virtualStatus.state === "swept") and they remain spendable * - They are preconfirmed subdust (to consolidate small amounts without locking liquidity on settled VTXOs) * * @example * ```typescript * // Initialize with renewal config * const manager = new VtxoManager(wallet, { * enabled: true, * thresholdMs: 86400000 * }); * * // Check recoverable balance * const balance = await manager.getRecoverableBalance(); * if (balance.recoverable > 0n) { * console.log(`Can recover ${balance.recoverable} sats`); * const txid = await manager.recoverVtxos(); * } * * // Check for expiring VTXOs * const expiring = await manager.getExpiringVtxos(); * if (expiring.length > 0) { * console.log(`${expiring.length} VTXOs expiring soon`); * const txid = await manager.renewVtxos(); * } * ``` */ export class VtxoManager { constructor(wallet, renewalConfig) { this.wallet = wallet; this.renewalConfig = renewalConfig; } // ========== Recovery Methods ========== /** * Recover swept/expired VTXOs by settling them back to the wallet's Ark address. * * This method: * 1. Fetches all VTXOs (including recoverable ones) * 2. Filters for swept but still spendable VTXOs and preconfirmed subdust * 3. Includes subdust VTXOs if the total value >= dust threshold * 4. Settles everything back to the wallet's Ark address * * Note: Settled VTXOs with long expiry are NOT recovered to avoid locking liquidity unnecessarily. * Only preconfirmed subdust is recovered to consolidate small amounts. * * @param eventCallback - Optional callback to receive settlement events * @returns Settlement transaction ID * @throws Error if no recoverable VTXOs found * * @example * ```typescript * const manager = new VtxoManager(wallet); * * // Simple recovery * const txid = await manager.recoverVtxos(); * * // With event callback * const txid = await manager.recoverVtxos((event) => { * console.log('Settlement event:', event.type); * }); * ``` */ async recoverVtxos(eventCallback) { // Get all VTXOs including recoverable ones const allVtxos = await this.wallet.getVtxos({ withRecoverable: true, withUnrolled: false, }); // Get dust amount from wallet const dustAmount = getDustAmount(this.wallet); // Filter recoverable VTXOs and handle subdust logic const { vtxosToRecover, includesSubdust, totalAmount } = getRecoverableWithSubdust(allVtxos, dustAmount); if (vtxosToRecover.length === 0) { throw new Error("No recoverable VTXOs found"); } const arkAddress = await this.wallet.getAddress(); // Settle all recoverable VTXOs back to the wallet return this.wallet.settle({ inputs: vtxosToRecover, outputs: [ { address: arkAddress, amount: totalAmount, }, ], }, eventCallback); } /** * Get information about recoverable balance without executing recovery. * * Useful for displaying to users before they decide to recover funds. * * @returns Object containing recoverable amounts and subdust information * * @example * ```typescript * const manager = new VtxoManager(wallet); * const balance = await manager.getRecoverableBalance(); * * if (balance.recoverable > 0n) { * console.log(`You can recover ${balance.recoverable} sats`); * if (balance.includesSubdust) { * console.log(`This includes ${balance.subdust} sats from subdust VTXOs`); * } * } * ``` */ async getRecoverableBalance() { const allVtxos = await this.wallet.getVtxos({ withRecoverable: true, withUnrolled: false, }); const dustAmount = getDustAmount(this.wallet); const { vtxosToRecover, includesSubdust, totalAmount } = getRecoverableWithSubdust(allVtxos, dustAmount); // Calculate subdust amount separately for reporting const subdustAmount = vtxosToRecover .filter((v) => BigInt(v.value) < dustAmount) .reduce((sum, v) => sum + BigInt(v.value), 0n); return { recoverable: totalAmount, subdust: subdustAmount, includesSubdust, vtxoCount: vtxosToRecover.length, }; } // ========== Renewal Methods ========== /** * Get VTXOs that are expiring soon based on renewal configuration * * @param thresholdMs - Optional override for threshold in milliseconds * @returns Array of expiring VTXOs, empty array if renewal is disabled or no VTXOs expiring * * @example * ```typescript * const manager = new VtxoManager(wallet, { enabled: true, thresholdMs: 86400000 }); * const expiringVtxos = await manager.getExpiringVtxos(); * if (expiringVtxos.length > 0) { * console.log(`${expiringVtxos.length} VTXOs expiring soon`); * } * ``` */ async getExpiringVtxos(thresholdMs) { const vtxos = await this.wallet.getVtxos({ withRecoverable: true }); const threshold = thresholdMs ?? this.renewalConfig?.thresholdMs ?? DEFAULT_RENEWAL_CONFIG.thresholdMs; return getExpiringAndRecoverableVtxos(vtxos, threshold, getDustAmount(this.wallet)); } /** * Renew expiring VTXOs by settling them back to the wallet's address * * This method collects all expiring spendable VTXOs (including recoverable ones) and settles * them back to the wallet, effectively refreshing their expiration time. This is the * primary way to prevent VTXOs from expiring. * * @param eventCallback - Optional callback for settlement events * @returns Settlement transaction ID * @throws Error if no VTXOs available to renew * @throws Error if total amount is below dust threshold * * @example * ```typescript * const manager = new VtxoManager(wallet); * * // Simple renewal * const txid = await manager.renewVtxos(); * * // With event callback * const txid = await manager.renewVtxos((event) => { * console.log('Settlement event:', event.type); * }); * ``` */ async renewVtxos(eventCallback) { // Get all VTXOs (including recoverable ones) const vtxos = await this.getExpiringVtxos(); if (vtxos.length === 0) { throw new Error("No VTXOs available to renew"); } const totalAmount = vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0); // Get dust amount from wallet const dustAmount = getDustAmount(this.wallet); // Check if total amount is above dust threshold if (BigInt(totalAmount) < dustAmount) { throw new Error(`Total amount ${totalAmount} is below dust threshold ${dustAmount}`); } const arkAddress = await this.wallet.getAddress(); return this.wallet.settle({ inputs: vtxos, outputs: [ { address: arkAddress, amount: BigInt(totalAmount), }, ], }, eventCallback); } }