bsv-wallet-cli 0.2.2

Self-hosted BSV wallet CLI and BRC-100 server, wire-compatible with MetaNet Client
Documentation
/**
 * Multi-wallet setup and teardown.
 * Spawns fresh wallets on test ports, funds them from the e2e funder (default :3320).
 */

const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const { WalletClient, ANYONE_KEY, buildP2PKH } = require('./wallet-client');
const { fetchBeef, buildAtomicBEEF, sleep } = require('./woc');

const BSV = path.resolve(__dirname, '../../../target/release/bsv-wallet');
const FUNDER_PORT = parseInt(process.env.FUNDER_PORT || '3320');
const FUNDER_DIR = process.env.FUNDER_DIR || path.resolve(__dirname, '..', '..', '..', 'e2e-funder');

/**
 * Initialize and start a wallet on a given port.
 * Returns { dir, proc, client, identityKey, address }.
 */
async function startWallet(port, name) {
  const dir = fs.mkdtempSync(`/tmp/bsv-wallet-${name}-`);

  // Init wallet (generates ROOT_KEY + wallet.db)
  execSync(`cd "${dir}" && "${BSV}" init`, { stdio: 'pipe' });

  // Start serve in background. The monitor daemon is NOT needed for test wallets
  // because internalized txs are spent via BEEF which includes the parent tx
  // (already broadcast by the sender). The monitor would actually interfere by
  // trying to re-broadcast internalized txs and saturating ARC connections.
  const proc = spawn(BSV, ['--port', String(port), 'serve'], {
    cwd: dir,
    stdio: ['ignore', 'pipe', 'pipe'],
    detached: false,
  });

  // Wait for health
  const client = new WalletClient(port, name);
  for (let i = 0; i < 20; i++) {
    try {
      await client.isAuthenticated();
      break;
    } catch {
      if (i === 19) throw new Error(`${name} on :${port} failed to start`);
      await sleep(500);
    }
  }

  const identityKey = await client.identityKey();
  const addrResult = execSync(`cd "${dir}" && "${BSV}" address`, { encoding: 'utf-8' }).trim();

  return { dir, proc, client, identityKey, address: addrResult, port };
}

/**
 * Fund a wallet from the funder via HTTP API (wallet-to-wallet transfer).
 * The funder daemon on FUNDER_PORT handles UTXO selection and signing.
 * The recipient internalizes the AtomicBEEF directly — no WoC needed.
 */
async function fundWallet(wallet, satoshis) {
  const funderClient = new WalletClient(FUNDER_PORT, 'funder');
  const result = await funderClient.transferTo(wallet.client, satoshis, 'e2e funding');
  return { txid: result.txid, sats: satoshis };
}

/**
 * Sweep all funds from a wallet back to the funder.
 * Returns sats swept, or 0 if balance too low.
 *
 * If the full sweep fails (e.g., some UTXOs are unspendable due to wrong derivation
 * or basket insertion), retries with progressively smaller amounts to recover as much
 * as possible. This prevents losing sats when a scenario creates unspendable outputs.
 */
async function sweepToFunder(wallet) {
  // Use HTTP API for full balance (covers all baskets)
  const balance = await wallet.client.balance();

  if (balance < 600) {
    console.log(`  ${wallet.client.name}: ${balance} sats (dust, skipping sweep)`);
    return 0;
  }

  // Get funder's receiving key via HTTP API
  const funderClient = new WalletClient(FUNDER_PORT, 'funder');
  const funderKey = await funderClient.getPublicKey(
    [2, '3241645161d8'], 'SfKxPIJNgdI= NaGLC6fMH50=', ANYONE_KEY, true,
  );
  const funderScript = buildP2PKH(funderKey.publicKey);

  let sweepAmount = balance - 300; // leave room for fee
  let swept = 0;

  for (let attempt = 0; attempt < 4 && sweepAmount >= 600; attempt++) {
    try {
      const result = await wallet.client.createAction(
        [{ lockingScript: funderScript, satoshis: sweepAmount, outputDescription: 'sweep to funder' }],
        `sweep ${sweepAmount} sats back to funder`,
      );

      // Funder internalizes the BEEF
      await funderClient.internalizeAction(result.tx, [{
        outputIndex: 0,
        protocol: 'wallet payment',
        paymentRemittance: {
          derivationPrefix: 'SfKxPIJNgdI=',
          derivationSuffix: 'NaGLC6fMH50=',
          senderIdentityKey: ANYONE_KEY,
        },
      }], `sweep from ${wallet.client.name}`);

      console.log(`  ${wallet.client.name}: swept ${sweepAmount} sats back to funder`);
      swept += sweepAmount;
      break;
    } catch (e) {
      // Check if the sweep actually went through despite the timeout.
      // The wallet may have broadcast successfully but the HTTP response
      // took too long (e.g., ARC providers slow to respond).
      const balNow = await wallet.client.balance().catch(() => balance);
      if (balNow < balance - 500) {
        console.log(`  ${wallet.client.name}: sweep timed out but balance dropped (${balance}  ${balNow})  tx went through`);
        swept += balance - balNow;
        break;
      }
      if (attempt < 3) {
        const prev = sweepAmount;
        sweepAmount = Math.floor(sweepAmount / 2);
        console.log(`  ${wallet.client.name}: sweep ${prev} failed (${e.message.slice(0, 60)}), retrying with ${sweepAmount}`);
      } else {
        console.log(`  WARNING: ${wallet.client.name}: could not sweep (${sweepAmount} sats stuck: ${e.message.slice(0, 80)})`);
      }
    }
  }

  return swept;
}

/** Kill wallet process and remove temp dir. */
function teardownWallet(wallet) {
  if (wallet.proc && !wallet.proc.killed) {
    wallet.proc.kill('SIGTERM');
  }
  if (wallet.dir && fs.existsSync(wallet.dir)) {
    fs.rmSync(wallet.dir, { recursive: true, force: true });
  }
}

/**
 * Extract JSON from CLI output that may contain tracing log lines.
 * Finds the line starting with '{' and returns it (for single-line JSON).
 */
function extractJson(output) {
  const lines = output.split('\n');
  for (let i = lines.length - 1; i >= 0; i--) {
    const line = lines[i].trim();
    if (line.startsWith('{')) return line;
  }
  throw new Error(`No JSON found in output: ${output.slice(0, 200)}`);
}

/**
 * Parse JSON from CLI output that may be multi-line (pretty-printed).
 * Tries JSON.parse on the full output first, then extracts from first '{' to last '}'.
 */
function parseCliJson(output) {
  const trimmed = output.trim();
  // Try direct parse first (handles both single-line and multi-line)
  try { return JSON.parse(trimmed); } catch { /* fall through */ }
  // Find first '{' and last '}' to extract the JSON block
  const start = trimmed.indexOf('{');
  const end = trimmed.lastIndexOf('}');
  if (start === -1 || end === -1 || end <= start) {
    throw new Error(`No JSON found in output: ${trimmed.slice(0, 200)}`);
  }
  return JSON.parse(trimmed.slice(start, end + 1));
}

module.exports = { startWallet, fundWallet, sweepToFunder, teardownWallet, extractJson, parseCliJson, BSV, FUNDER_PORT };