// Native-JS arkoor send: checkpoint + arkoor transaction construction, // BIP-341 sighashes, MuSig2 cosign ceremony with the ASP, and signed-VTXO // assembly. Mirrors bark's lib/src/arkoor/mod.rs ArkoorBuilder (checkpoint // mode, no dust isolation). import { hex } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ripemd160 } from '@noble/hashes/ripemd160'; import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; import * as musig2 from '@scure/btc-signer/musig2'; import { concatBytes, reader, grpcCall, pbWriter, pbFields } from './proto.js'; // hex string or byte array -> bytes (decoded vtxos carry hex-string fields) const asBytes = (v) => (typeof v === 'string' ? hex.decode(v) : Uint8Array.from(v)); // The key that cosigns arkoor spends of a vtxo with this policy (bark's // VtxoPolicy::arkoor_pubkey). export const policyOwnerPubkey = (policy) => policy.userPubkey; const te = new TextEncoder(); // --------------------------------------------------------------------------- // small bitcoin helpers // --------------------------------------------------------------------------- const u16le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff); const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff); const u64le = (n) => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(n), true); return b; }; const varint = (n) => { if (n < 0xfd) return Uint8Array.of(n); if (n <= 0xffff) return concatBytes(Uint8Array.of(0xfd), u16le(n)); return concatBytes(Uint8Array.of(0xfe), u32le(n)); }; const sha256d = (b) => sha256(sha256(b)); const taggedHash = (tag, ...data) => { const th = sha256(te.encode(tag)); return sha256(concatBytes(th, th, ...data)); }; // minimal script-number push (as bitcoin's push_int) function pushInt(n) { if (n === 0) return Uint8Array.of(0x00); // OP_0 if (n >= 1 && n <= 16) return Uint8Array.of(0x50 + n); // OP_1..OP_16 const bytes = []; let v = n; while (v > 0) { bytes.push(v & 0xff); v >>= 8; } if (bytes[bytes.length - 1] & 0x80) bytes.push(0x00); return Uint8Array.of(bytes.length, ...bytes); } const OP = { CSV: 0xb2, CLTV: 0xb1, DROP: 0x75, CHECKSIG: 0xac, HASH160: 0xa9, EQUALVERIFY: 0x88, SIZE: 0x82 }; export const P2A_SCRIPT = hex.decode('51024e73'); // delayed_sign: OP_CSV OP_DROP OP_CHECKSIG const delayedSignScript = (delta, xonly) => concatBytes(pushInt(delta), Uint8Array.of(OP.CSV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); // timelock_sign: OP_CLTV OP_DROP OP_CHECKSIG const timelockSignScript = (height, xonly) => concatBytes(pushInt(height), Uint8Array.of(OP.CLTV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); // hash_delay_sign_v0: OP_CSV OP_DROP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG // (the witness reveals the 32B preimage; HASH160(preimage) = ripemd160(sha256(preimage))) const hashDelaySignScript = (paymentHash, delta, xonly) => concatBytes( pushInt(delta), Uint8Array.of(OP.CSV, OP.DROP, OP.HASH160, 0x14), ripemd160(paymentHash), Uint8Array.of(OP.EQUALVERIFY, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); // hash_delay_sign (v1, pver 5): same, but the preimage's size is checked to be // 32 bytes before hashing — OP_SIZE <32> OP_EQUALVERIFY inserted after the // timelock, mirroring Lightning's own on-chain check. const hashDelaySignScriptV1 = (paymentHash, delta, xonly) => concatBytes( pushInt(delta), Uint8Array.of(OP.CSV, OP.DROP, OP.SIZE, 0x01, 0x20, OP.EQUALVERIFY, OP.HASH160, 0x14), ripemd160(paymentHash), Uint8Array.of(OP.EQUALVERIFY, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); // delay_timelock_sign: OP_CLTV OP_DROP OP_CSV OP_DROP OP_CHECKSIG const delayTimelockSignScript = (height, delta, xonly) => concatBytes( pushInt(height), Uint8Array.of(OP.CLTV, OP.DROP), pushInt(delta), Uint8Array.of(OP.CSV, OP.DROP, 0x20), xonly, Uint8Array.of(OP.CHECKSIG)); // --------------------------------------------------------------------------- // taproot + musig key aggregation // --------------------------------------------------------------------------- const xonly = (pub33) => pub33.slice(1); // bark: musig::combine_keys — KeySort then KeyAgg, no tweak export function musigInternalKey(keys) { const sorted = musig2.sortKeys(keys.map((k) => Uint8Array.from(k))); const ctx = musig2.keyAggregate(sorted); return { sortedKeys: sorted, internalXOnly: musig2.keyAggExport(ctx) }; // 32B x-only } const tapLeafHash = (script) => taggedHash('TapLeaf', Uint8Array.of(0xc0), varint(script.length), script); // BIP-341 branch hash: children sorted lexicographically const tapBranch = (a, b) => { let swap = false; for (let i = 0; i < 32; i++) { if (a[i] !== b[i]) { swap = a[i] > b[i]; break; } } return swap ? taggedHash('TapBranch', b, a) : taggedHash('TapBranch', a, b); }; // Taproot around a musig2(user,server) internal key with 1 or 2 script leaves // (two leaves sit at depth 1, mirroring bark's TaprootBuilder usage). // Returns everything both tx-building and signing need. export function taprootFromLeaves(userPub, serverPub, leafScripts) { const { sortedKeys, internalXOnly } = musigInternalKey([userPub, serverPub]); const leafHashes = leafScripts.map(tapLeafHash); const merkleRoot = leafHashes.length === 1 ? leafHashes[0] : tapBranch(leafHashes[0], leafHashes[1]); const tapTweak = taggedHash('TapTweak', internalXOnly, merkleRoot); const P = secp256k1.ProjectivePoint; // lift internal x-only, add tweak*G const internalPoint = P.fromHex(concatBytes(Uint8Array.of(0x02), internalXOnly)); const outputPoint = internalPoint.add(P.BASE.multiply(BigInt('0x' + hex.encode(tapTweak)))); const outputCompressed = outputPoint.toRawBytes(true); const outputXOnly = outputCompressed.slice(1); const scriptPubKey = concatBytes(hex.decode('5120'), outputXOnly); const outputParity = outputCompressed[0] === 0x03 ? 1 : 0; // for script-path control blocks return { sortedKeys, internalXOnly, merkleRoot, tapTweak, outputXOnly, outputParity, scriptPubKey }; } export const taprootOneLeaf = (userPub, serverPub, leafScript) => taprootFromLeaves(userPub, serverPub, [leafScript]); // PubkeyVtxoPolicy: keyspend musig(user,server); leaf = delayed_sign(exit_delta, user). // leafScript is returned so a unilateral exit can claim through it after the CSV. export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => { const leafScript = delayedSignScript(exitDelta, xonly(userPub)); return { ...taprootOneLeaf(userPub, serverPub, leafScript), leafScript }; }; // CheckpointVtxoPolicy: keyspend musig(user,server); leaf = timelock_sign(expiry, server) export const checkpointPolicyTaproot = (userPub, serverPub, expiryHeight) => taprootOneLeaf(userPub, serverPub, timelockSignScript(e
// Two things about the front door.
//
// One: making your own keys has to look like the way in. Four equally
// weighted buttons, where only the sign-in ones said what they did, sent
// people straight past the seed phrase — which IS the wallet — into a login
// that is only a convenience wrapped around one.
//
// Two: a wallet that arrived by SIGN-IN has a recovery phrase its owner has
// never seen, and nobody can reissue it. So it gets shown once, during
// setup, and if they put it off the wallet keeps one quiet line about it.
//
// Run: bun tools/onboard-backup-test.js
import puppeteer from 'puppeteer-core';
import { bech32 } from '@scure/base';
import { generateSecretKey } from 'nostr-tools/pure';
import { seedFromNostrKey } from '../src/nostr-login.js';
import { buildHtml } from '../build.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };

const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5291, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });

const click = (page, t, sel = 'button') => page.evaluate((s, x) => {
  const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x));
  if (e) { e.click(); return true; } return false;
}, sel, t.toLowerCase());
const text = (page) => page.evaluate(() => document.body.innerText);
const waitText = async (page, x, ms = 30000) => {
  for (let i = 0; i < ms / 250; i++) { if ((await text(page)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); }
  return false;
};

// Sign in with a pasted nsec and stop at whatever the wizard shows next.
// Its own browser context, so each run meets a genuinely fresh front door
// rather than the wallet the previous one left in localStorage.
async function signIn() {
  const ctx = await browser.createBrowserContext();
  const page = await ctx.newPage();
  await page.setViewport({ width: 390, height: 844 });
  await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  // wait for the front door to actually be painted before poking it
  await waitText(page, 'sign in with nostr');
  const sk = generateSecretKey();
  const nsec = bech32.encode('nsec', bech32.toWords(sk), 1000);
  await click(page, 'sign in with nostr');
  await waitText(page, 'log in with nostr');
  await page.waitForSelector('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', { timeout: 15000 });
  await page.type('input[placeholder*="nsec" i], textarea[placeholder*="nsec" i]', nsec);
  await click(page, 'log in');
  return { page, ctx, seed: seedFromNostrKey(sk) };
}

try {
  console.log('[the start page]');
  {
    const page = await browser.newPage();
    await page.setViewport({ width: 390, height: 844 });
    await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' });
    await sleep(1500);
    const front = await page.evaluate(() => {
      const btns = [...document.querySelectorAll('button')].filter((b) => b.offsetParent);
      const primary = document.getElementById('onb-create');
      const idx = btns.indexOf(primary);
      const signins = btns.filter((b) => /sign in with/i.test(b.textContent));
      return {
        label: primary ? primary.textContent.trim() : null,
        first: idx === 0 || (idx >= 0 && idx < btns.findIndex((b) => /sign in with/i.test(b.textContent))),
        primaryTop: primary ? primary.getBoundingClientRect().top : -1,
        signinTop: signins.length ? signins[0].getBoundingClientRect().top : -1,
        divider: !!document.querySelector('.onb-or'),
        body: (document.querySelector('.onb p') || {}).textContent || '',
      };
    });
    check('the way in says what it makes', /wallet/i.test(front.label || ''), front.label || 'missing');
    check('...and comes before the sign-in buttons', front.first && front.primaryTop < front.signinTop,
      `create at ${Math.round(front.primaryTop)}, first sign-in at ${Math.round(front.signinTop)}`);
    check('...with a line between them, so they read as the alternative', front.divider);
    check('the page says what the wallet actually is', /twelve words|seed|phrase/i.test(front.body),
      front.body.slice(0, 60) + '…');
    await page.close();
  }

  console.log('\n[signing in, then writing it down]');
  {
    const { page, ctx, seed } = await signIn();
    check('a sign-in is asked to write the phrase down', await waitText(page, 'write down your recovery phrase'));
    const shown = await page.evaluate(() => [...document.querySelectorAll('.words .w .t')].map((e) => e.textContent.trim()));
    check('...and the real twelve words are on screen', shown.length === 12 && shown.join(' ') === seed,
      shown.length + ' words' + (shown.join(' ') === seed ? ', matching the derived seed' : ', WRONG'));
    await click(page, "i've written it down");
    check('confirming lands in the wallet', await waitText(page, 'receive'));
    const nag = await text(page);
    check('...and there is no reminder afterwards', !/still unwritten/i.test(nag));
    await page.reload({ waitUntil: 'domcontentloaded' });
    check('...not after a reload either', await waitText(page, 'receive') && !/still unwritten/i.test(await text(page)));
    await ctx.close();
  }

  console.log('\n[signing in, then putting it off]');
  {
    const { page, ctx } = await signIn();
    check('the ask appears', await waitText(page, 'write down your recovery phrase'));
    await click(page, 'later');
    check('later lands in the wallet too', await waitText(page, 'receive'));
    check('...but the wallet keeps a quiet reminder', /still unwritten/i.test(await text(page)));
    await page.reload({ waitUntil: 'domcontentloaded' });
    await waitText(page, 'receive');
    check('...which survives a reload', /still unwritten/i.test(await text(page)));
    // and it is a way IN, not just a scold
    await click(page, 'write it down');
    check('tapping it opens the recovery phrase', await waitText(page, 'recovery phrase'));
    await ctx.close();
  }
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ the front door points at your own keys' : '\n❌ failed');
process.exit(ok ? 0 : 1);