// Moderation: the mute list (people, words, hashtags, threads) applies to // the feed and to thread replies; a post reported by someone you follow is // folded with a Show; Report… from the ⋯ menu publishes a kind 1984 and // hides the post; repeated posts and hashtag-stuffed posts from strangers // are hidden; Settings → Nostr lays the mute list out and edits it. // No relay is written for real — the WebSocket shim swallows EVENT. // Run: bun tools/moderation-test.js import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; 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 key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; }; const AUTHOR = key(), MUTED = key(), REPORTED = key(), OTHER = key(), SPAMMER = key(), TAGGER = key(), FLAGGED = key(); const now = Math.floor(Date.now() / 1000); const post = (who, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, who.sk); const ROOT = post(AUTHOR, 'a post to open, about #bitcoin and other things', 300); const THREADMUTED = post(AUTHOR, 'another post, in a thread I muted', 400); const seeded = [ ROOT, THREADMUTED, post(MUTED, 'muted person post', 500), post(AUTHOR, 'my friend loves lasagna apparently', 600), post(AUTHOR, 'tagged post', 700, [['t', 'nsfw']]), post(REPORTED, 'reported post here, flagged by my friend', 800), post(FLAGGED, 'a fine post by someone one friend mis-flagged', 900), ]; const reply = (who, text, ago) => post(who, text, ago, [['e', ROOT.id, '', 'root'], ['p', AUTHOR.pk]]); const REPLIES = [reply(OTHER, 'reply from ok person', 200), reply(MUTED, 'reply from muted person', 150)]; const LONG = 'buy the dip now, best exchange ever, sign up with my link and get free sats today!!!'; const html = await buildHtml({ minify: true, pwa: false }); // nos.lol (one of the relays the mute list is read from) is a stand-in // served here — an empty relay — so a real one that won't connect can't hold // the list back past the checks const server = Bun.serve({ port: 5299, fetch: (req, srv) => (new URL(req.url).pathname === '/relay' && srv.upgrade(req) ? undefined : new Response(html, { headers: { 'content-type': 'text/html' } })), websocket: { message: (ws, data) => { try { const m = JSON.parse(data); if (m[0] === 'REQ') ws.send(JSON.stringify(['EOSE', m[1]])); } catch {} } } }); const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); await page.evaluateOnNewDocument((fx) => { window.__reqs = []; window.__published = []; const subs = []; const Real = window.WebSocket; const answer = (ws, id, evs) => setTimeout(() => { for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); }, 0); window.WebSocket = function (...a) { if (/^wss:\/\/nos\.lol\/?$/.test(String(a[0]))) a[0] = 'ws://localhost:5299/relay'; const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (data) => { try { const m = JSON.parse(data); if (m[0] === 'REQ' && m[2]) { const f = m[2]; window.__reqs.push(f); const kinds = f.kinds || []; if (kinds.includes(10000) && window.__mutes && (f.authors || []).includes(window.__mutes.pubkey)) answer(ws, m[1], [window.__mutes]); else if (kinds.includes(1984) && (f.authors || []).includes(fx.author)) answer(ws, m[1], fx.reports); else if (kinds.includes(1) && f['#e'] && f['#e'].includes(fx.root)) answer(ws, m[1], fx.replies); else if (kinds.includes(1)) { subs.push([ws, m[1], f]); answer(ws, m[1], []); return; } // no real posts: the feeds hold only what the test injects } if (m[0] === 'EVENT' && m[1]) { window.__published.push(m[1]); setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); return; } } catch {} return send(data); }; return ws; }; window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); window.__inject = (ev) => { let n = 0; for (const [ws, id, f] of subs) { if (ws.readyState !== 1) continue; if (f.authors && !f.authors.includes(ev.pubkey)) continue; if (f['#t'] && !ev.tags.some((t) => t[0] === 't' && f['#t'].includes(t[1]))) continue; ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); n++; } return n; }; }, { author: AUTHOR.pk, root: ROOT.id, replies: REPLIES, reports: [ finalizeEvent({ kind: 1984, created_at: now - 100, tags: [['e', seeded[5].id, 'spam'], ['p', REPORTED.pk]], content: '' }, AUTHOR.sk), // one friend flagging a PERSON is not enough to fold everything they write finalizeEvent({ kind: 1984, created_at: now - 90, tags: [['p', FLAGGED.pk, 'impersonation']], content: '' }, AUTHOR.sk), ] }); const errs = []; page.on('pageerror', (e) => errs.push(String(e))); const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); const text = () => page.evaluate(() => document.body.innerText); const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; }; const has = async (x) => (await text()).includes(x); const openFeed = async () => { // the Feed is a tab in the bottom nav (it was a row on the chat home) await page.evaluate(() => [...document.querySelectorAll('.app-nav-button')].find((b) => b.getAttribute('aria-label') === 'Feed')?.click()); await sleep(1200); }; try { await page.setViewport({ width: 390, height: 844 }); await page.goto('http://localhost:5299/', { waitUntil: 'domcontentloaded' }); await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); await click('create a new wallet'); await sleep(600); await page.waitForSelector('.words .w .t'); const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); await click('skip verification'); await waitText('receive', 20000); const base = cacheKeyFor(mn + '\n'); const ME_SK = privateKeyFromSeedWords(mn); const ME = getPublicKey(ME_SK); // the mute list as another client left it: a person, a word, a hashtag, a thread const MUTES = finalizeEvent({ kind: 10000, created_
const axios = require("axios");
const bitcoin = require("bitcoinjs-lib");
const coinselect = require("coinselect");
const split = require("coinselect/split");
const api = prod ? "https://blockstream.info/api" : "http://localhost:3095";
const SATS = 100000000;
const network = prod
  ? bitcoin.networks["bitcoin"]
  : bitcoin.networks["regtest"];

module.exports = async ({ address, amount, feeRate, replaceable, user }) => {
  if (!feeRate) feeRate = (await bc.estimateSmartFee(6)).feerate * SATS;
  feeRate = Math.round(feeRate / 1000);

  const payments = await db.Payment.findAll({
    where: {
      received: true,
      account_id: user.account_id,
      network: 'bitcoin',
    } 
  });


  const addresses = [...new Set(payments.map(p => p.address))];
  const utxos = [];
  for (let i = 0; i < addresses.length; i++) {
    try {
      utxos.push(...(await axios.get(`${api}/address/${addresses[i]}/utxo`)).data);
    } catch (e) {
      l.error("problem fetching utxos", e.message);
    }
  } 

  let targets = [
    {
      address,
      value: amount
    }
  ];

  let { inputs, outputs, fee } = coinselect(utxos, targets, feeRate);

  if (!inputs || !outputs) {
    l.info("utxos", amount, feeRate, utxos.map(u => u.value));
    throw new Error("Couldn't find inputs for transaction, check your balance/fee");
  } 

  let psbt = new bitcoin.Psbt({ network });
  let total = 0;

  outputs.map(({ address, value }) => {
    total += value;
    if (!address) {
      address =  payments[payments.length - 1].address;
      change.push(address);
    } 

    psbt = psbt.addOutput({
      address,
      value
    });
  });

  for (let i = 0; i < inputs.length; i++) {
    let { txid, vout } = inputs[i];
    let nonWitnessUtxo = Buffer.from(await bc.getRawTransaction(txid), "hex");
    psbt = psbt.addInput({
      hash: txid,
      index: vout,
      nonWitnessUtxo
    });
  }

  if (replaceable) psbt.setInputSequence(0, 0xffffffff - 2);

  feeRate = Math.round(feeRate * 1000);
  psbt = psbt.toBase64();

  return { tx: psbt, feeRate };
};