From 08821882bc323c3411940f6150b4a731c1833c49 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 3 Nov 2026 18:19:14 -0700 Subject: The wallet's push notifications are worded so Chrome's spam model lets them through. "Any way to stop classifying v3 notifications as spam? I've seen this a few times now" (Adam, 2026-11-03, phone: "Possible spam — Chrome detected possible spam from v3.coinos.io", Unsubscribe / Show notification): Chrome on Android scores every web notification's title and body with an on-device model before showing it, hides it behind that warning above 90 of 100, and — nine warnings in a week on a phone whose engagement with the site is under 50 — revokes the site's notification permission outright (Chromium kAutoRevokeSuspiciousNotification, enabled by default). The model is a text classifier trained on Gemini-made examples of scam notifications, and our lines were their very shape: "Payment received" 95, "Incoming payment / Someone is trying to send you money…" 99, "Payment request / An app is asking your wallet to pay…" 99, "alice is calling / Voice call. Tap to answer." 99, "New message / You have a new private message." 92, "alice / sent you a message." 95, "New reply to your post / " 98, "alice reacted ❤️ / to your post…" 97. Desktop Chrome runs the same model (without the warning) and reports each score to chrome://histograms, so the lines were measured rather than guessed — ~200 candidates on Chrome 156's model: "sats" reads as money without reading as a payment scam, "payment" doesn't; a sender's name in the body of a message or reaction line tips it over, "New chat" / "" doesn't; any "reply" title is 96–99; a reply's own text is out of our hands (an @name or a link in it scores 97+), so it no longer rides along; "Calling" / "" is 11–29 where every " is calling" is 80–99. src/notify-text.js holds every line, with the measurements; the worker, the background responder and the renewal warning draw from it. The new lines score 8–46 (the shortcode reaction 67). tools/notif-spam-score.js re-measures them in a throwaway Chrome (SystemNotifications off, so nothing reaches the desktop) and fails at 90 — run it before changing any of them. On the phone that already warned, "Show notification" then "Always allow" marks the site trusted and stops both the warning and the revocation count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs --- tools/call-wake-test.js | 4 +-- tools/notif-spam-score.js | 82 +++++++++++++++++++++++++++++++++++++++++++++ tools/sw-push-quota.test.js | 2 +- 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 tools/notif-spam-score.js (limited to 'tools') diff --git a/tools/call-wake-test.js b/tools/call-wake-test.js index 54c42ee..b0f64cc 100644 --- a/tools/call-wake-test.js +++ b/tools/call-wake-test.js @@ -1,7 +1,7 @@ // A call wakes a closed app: with Bob's coinos closed, Alice calls; the push // the notifier would send (delivered here to Bob's service worker through // Chrome's DevTools protocol) makes the worker fetch the offer's wrap, open -// it with Bob's key and ring — "Alice Caller is calling" — and the tap that +// it with Bob's key and ring — "Calling | Alice Caller" — and the tap that // opens the app finds the offer kept for it: it rings there, Bob answers, // they talk. A push for a stranger's call, or for something that isn't an // offer, rings nothing. Plus the worker's check on its own (classifyCall). @@ -152,7 +152,7 @@ try { // the tap: the worker opens the app at ?open=call await B.page.goto(`http://localhost:${PORT}/?open=call`, { waitUntil: 'domcontentloaded' }); const ns = await notifications(B.page); - check('the worker rang: "Alice Caller is calling"', ns.some((n) => /^Alice Caller is calling \| Voice call/.test(n) && / \| call$/.test(n)), JSON.stringify(ns)); + check('the worker rang: "Calling | Alice Caller"', ns.some((n) => /^Calling \| Alice Caller/.test(n) && / \| call$/.test(n)), JSON.stringify(ns)); check('the app the tap opened rings with the offer the worker kept', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText), 25000)); // Alice's candidates to Bob (his own 'answered here' copy, ~770 chars, isn't one) const toBob = () => [...callWraps.values()].filter((w) => !wrapsBefore.has(w.id) && w.tags.some((x) => x[0] === 'p' && x[1] === B.pk) && w.content.length > 1000 && w.content.length < 1500).length; diff --git a/tools/notif-spam-score.js b/tools/notif-spam-score.js new file mode 100644 index 0000000..c9e48ba --- /dev/null +++ b/tools/notif-spam-score.js @@ -0,0 +1,82 @@ +// Scores every notification the wallet can show against Chrome's on-device +// "unwanted notification" model — the one that replaces a web notification +// with "Possible spam" on Android when its title + body score above 90. +// +// Not part of the test suite: it launches a throwaway headful-less Chrome +// (/usr/bin/google-chrome) that fetches the model from Google's optimization +// guide on first use (~10 s, network), shows each line from src/notify-text.js +// SAMPLES as a persistent notification on a local page, and reads the score +// off chrome://histograms (SafeBrowsing.NotificationContentDetection +// .SuspiciousScore, one new sample per notification). Desktop Chrome runs the +// model too, flag or no flag; SystemNotifications is off so nothing reaches +// the desktop's notification tray. Exit 1 if any line scores 90 or more. +// +// bun tools/notif-spam-score.js # SAMPLES from src/notify-text.js +// bun tools/notif-spam-score.js "Title" "Body" ["Title" "Body" ...] +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import puppeteer from 'puppeteer-core'; +import { SAMPLES } from '../src/notify-text.js'; + +const CHROME = process.env.CHROME || '/usr/bin/google-chrome'; +const PORT = 8766, DEBUG = 9445, THRESHOLD = 90; +const argv = process.argv.slice(2); +const items = argv.length ? argv.reduce((a, v, i) => (i % 2 ? a[a.length - 1].push(v) : a.push([v]), a), []) : SAMPLES; + +const server = Bun.serve({ port: PORT, hostname: '127.0.0.1', fetch(req) { + const p = new URL(req.url).pathname; + if (p === '/sw.js') return new Response("self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));", { headers: { 'content-type': 'text/javascript' } }); + return new Response(`scorescore`, + { headers: { 'content-type': 'text/html' } }); +} }); +const profile = mkdtempSync(join(tmpdir(), 'notif-score-')); +const chrome = spawn(CHROME, ['--headless=new', `--user-data-dir=${profile}`, `--remote-debugging-port=${DEBUG}`, '--no-first-run', '--password-store=basic', + '--enable-features=ShowWarningsForSuspiciousNotifications', '--disable-features=SystemNotifications', 'about:blank'], { stdio: 'ignore' }); +const cleanup = () => { try { chrome.kill(); } catch {} try { server.stop(true); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} }; +process.on('exit', cleanup); +let browser; +for (let i = 0; i < 40 && !browser; i++) { + await Bun.sleep(500); + browser = await puppeteer.connect({ browserURL: `http://localhost:${DEBUG}`, defaultViewport: null }).catch(() => null); +} +if (!browser) { console.error('could not reach the scoring Chrome'); process.exit(2); } +await browser.defaultBrowserContext().overridePermissions(`http://127.0.0.1:${PORT}`, ['notifications']); +const page = await browser.newPage(); +await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'load' }); +await page.evaluate(() => navigator.serviceWorker.ready); +const hist = await browser.newPage(); +async function counts() { + await hist.goto('chrome://histograms/SafeBrowsing.NotificationContentDetection.SuspiciousScore', { waitUntil: 'load' }); + await Bun.sleep(250); + const txt = await hist.evaluate(() => document.body.innerText); + const m = {}; + for (const line of txt.split('\n')) { const r = /^(\d+)\s+.*\((\d+) = /.exec(line); if (r) m[r[1]] = +r[2]; } + const t = /recorded (\d+) samples/.exec(txt); + return { m, total: t ? +t[1] : 0 }; +} +// the model arrives on first use: warm up until one line scores +let before = await counts(); +for (let i = 0; i < 12 && before.total === 0; i++) { + await page.evaluate(() => window.show('warm up', 'the model is still downloading')); + await Bun.sleep(5000); + before = await counts(); +} +if (before.total === 0) { console.error('the model never scored anything (offline? no optimization-guide access?)'); process.exit(2); } +let worst = 0; +for (const [title, body] of items) { + await page.evaluate((t, b) => window.show(t, b), title, body); + let after, tries = 0; + do { await Bun.sleep(600); after = await counts(); } while (after.total === before.total && ++tries < 25); + const d = after.total - before.total; + const bucket = Object.keys(after.m).filter((k) => (after.m[k] || 0) > (before.m[k] || 0)).join('+'); + const score = d === 1 ? Number(bucket) : NaN; + worst = Math.max(worst, score || 0); + console.log(`${(d === 1 ? String(score) : d === 0 ? '??' : 'ambiguous').padStart(9)} | ${title} | ${body}`); + before = after; +} +await page.close(); await hist.close(); browser.disconnect(); +console.log(worst >= THRESHOLD ? `\nFAIL: a line scored ${worst} (warning at >${THRESHOLD - 1})` : `\nok: every line under ${THRESHOLD}, worst ${worst}`); +process.exit(worst >= THRESHOLD ? 1 : 0); diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js index cf4a7bf..544c72e 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -85,5 +85,5 @@ test('elsewhere: quiet stays quiet', async () => { expect(asked).toBe(0); // the open (if hidden) window answers on Android windows = []; respond = async () => false; - expect(await push(nwc)).toEqual(['Payment request']); + expect(await push(nwc)).toEqual(['Spend request']); }); -- cgit v1.2.3