diff options
| author | Adam Soltys <[email protected]> | 2026-11-03 18:19:14 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-03 18:19:14 -0700 |
| commit | 08821882bc323c3411940f6150b4a731c1833c49 (patch) | |
| tree | 61244060215f248f24f7ef020d277cf9c4c2f3bc | |
| parent | 7e8f2b375aebd3809b6fe9c474c6a36b392163f2 (diff) | |
| download | coinosv3-0882188.tar.gz coinosv3-0882188.zip | |
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 / <text>" 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" / "<name>" 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" / "<name>" is 11–29 where every "<name> 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 countHEADmaster
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
| -rw-r--r-- | src/features/ark.js | 4 | ||||
| -rw-r--r-- | src/i18n.js | 5 | ||||
| -rw-r--r-- | src/notify-text.js | 79 | ||||
| -rw-r--r-- | src/nwc-respond.js | 4 | ||||
| -rw-r--r-- | src/sw-nwc.js | 32 | ||||
| -rw-r--r-- | tools/call-wake-test.js | 4 | ||||
| -rw-r--r-- | tools/notif-spam-score.js | 82 | ||||
| -rw-r--r-- | tools/sw-push-quota.test.js | 2 |
8 files changed, 186 insertions, 26 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 8b3121c..d1fa356 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -3616,8 +3616,8 @@ export function arkFeature(ctx) { date: new Date(arkRenewWarn.deadlineMs).toLocaleDateString(), }); navigator.serviceWorker?.ready - .then((reg) => reg.showNotification('coinos', { body })) - .catch(() => { try { new Notification('coinos', { body }); } catch {} }); + .then((reg) => reg.showNotification(t('arkRenewWarnTitle'), { body })) + .catch(() => { try { new Notification(t('arkRenewWarnTitle'), { body }); } catch {} }); } catch {} } diff --git a/src/i18n.js b/src/i18n.js index ac6066a..db54708 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1235,7 +1235,10 @@ const STR = { arkRenewForecast: 'Coins renew in ~{when} for {fee}.', arkRenewForecastNow: 'Coins renew shortly for {fee}.', arkBoardStuck: 'Moving money hit a snag and will keep retrying: {error}', - arkRenewWarnPush: 'Your Spending balance is too small to renew itself and expires around {date}. Receive any amount or spend it to keep it alive.', + // the push's words are measured against Chrome's spam model (src/notify-text.js): + // 'too small to renew itself and expires around' scored 98, this 11 + arkRenewWarnTitle: 'Spending balance', + arkRenewWarnPush: 'Receive or spend sats before {date} to keep it alive.', giftArkOutstanding: '{n} unclaimed instant gift(s)', giftArkRevoked: 'Gift swept back into Spending.', claimArkFree: 'instant, no fee', diff --git a/src/notify-text.js b/src/notify-text.js new file mode 100644 index 0000000..a88ab14 --- /dev/null +++ b/src/notify-text.js @@ -0,0 +1,79 @@ +// The words of every notification the worker shows while the app is closed. +// +// Chrome on Android (and, quietly, on desktop) scores each web notification's +// title and body with an on-device "unwanted notification" model before it +// shows it, and above 90 of 100 the notification is replaced by a "Possible +// spam" warning — hidden until the user taps "Show notification", and nine +// such in a week revoke the site's permission outright on a phone that +// doesn't visit much (Chromium kAutoRevokeSuspiciousNotification). Our old +// wording scored 92–99 across the board: "Payment received", "Incoming +// payment", "Payment request", "alice is calling", "New message", "alice +// sent you a message" — the exact shape of the scam notifications the model +// was trained (on synthetic data) to catch. What it lets through is less a +// rule than a measurement; the scores here come from Chrome 156's model via +// tools/notif-spam-score.js, which re-scores SAMPLES in a throwaway Chrome. +// Measured 2026-11-03: "sats" reads as money without reading as a payment +// scam (9–14), "payment" does not (95–97); a person's NAME in the body of a +// message or reaction line pushes it over (80–99), "New chat" / "<name>" +// does not (8–32); "reply" in any title is 96–99, "Someone replied" / +// "Open coinos to see it." is 13; a reply's TEXT is uncontrollable (an +// "@name" or a link in it scores 97+), so none rides along; "Calling" / +// "<name>" is 11–29 where every "<name> is calling" shape is 80–99. +// Re-run the scorer before changing any of these. + +export const OPEN = 'Open coinos to see it.'; + +function sats(n) { + const v = Number(n); + return v > 0 ? `+${v.toLocaleString()} sats. ${OPEN}` : OPEN; +} + +// A user-facing notification by reason. `d` is the push payload (amountSat, +// emoji) plus what the device adds (a sender's name). +export function notifyText(reason, d = {}) { + switch (reason) { + case 'payment': return ['Sats received', sats(d.amountSat)]; + case 'zap': return ['Zap received', sats(d.amountSat)]; + case 'dm': return ['New chat', d.name || 'Someone wrote to you']; + case 'chat': return ['New chat activity', 'There are new messages in your communities.']; + case 'mention': return [d.reply ? 'Someone replied' : 'You were mentioned', OPEN]; + case 'reaction': { + // a unicode emoji in the title is fine (17); a :shortcode: is not (85) + const e = d.emoji && !/^:[\w-]+:$/.test(d.emoji) ? ' ' + d.emoji : ''; + return ['New reaction' + e, `to your post. ${OPEN}`]; + } + case 'call': return ['Calling', d.name || 'Someone']; + // the wallet was asked for an invoice while closed (someone paying its name) + case 'incoming': return ['Someone is sending sats', 'Open coinos to receive them.']; + // a connected app asked the closed wallet to pay + case 'spend': return ['Spend request', 'A connected app asked to spend sats. Open coinos to approve.']; + default: return notifyText('chat'); + } +} + +// What tools/notif-spam-score.js scores: one line per shape a user can see. +export const SAMPLES = [ + notifyText('payment', { amountSat: 1000 }), + notifyText('payment', { amountSat: 21 }), + notifyText('payment'), + notifyText('zap', { amountSat: 1000 }), + notifyText('zap'), + notifyText('dm', { name: 'alice' }), + notifyText('dm', { name: 'Bitcoin Bob' }), + notifyText('dm', { name: 'Jarryd' }), + notifyText('dm'), + notifyText('chat'), + notifyText('mention'), + notifyText('mention', { reply: true }), + notifyText('reaction', { emoji: '❤️' }), + notifyText('reaction', { emoji: '🤙' }), + notifyText('reaction', { emoji: ':pepe:' }), + notifyText('call', { name: 'alice' }), + notifyText('call', { name: 'Bitcoin Bob' }), + notifyText('call', { name: 'Alice Caller' }), + notifyText('call'), + notifyText('incoming'), + notifyText('spend'), + ['Payment sent', '-1,000 sats via Damus'], + ['Spending balance', 'Receive or spend sats before 11/20/2026 to keep it alive.'], +]; diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 12101f5..98fd19c 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -14,6 +14,7 @@ // to a notification that opens the wallet. import { hex } from '@scure/base'; +import { notifyText } from './notify-text.js'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent } from 'nostr-tools/pure'; @@ -198,7 +199,8 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log await saveFn(walletKey, rec); await publish({ bolt11: a.invoice }); log(`minted ${sat} sat offer invoice while closed`); - return { notify: { title: 'Incoming payment', body: 'Someone is paying you. Open Coinos to receive it.' } }; + const [title, body] = notifyText('incoming'); + return { notify: { title, body } }; } catch (e) { log('offer mint failed: ' + e.message); await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {}); diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 3a48cff..a4e4e56 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -9,6 +9,7 @@ import { withRequestLock, withWorkLock } from './nwc-lock.js'; import { appendBgLog, takeLease } from './nwc-bg.js'; import { bgAutoWithdraw } from './bg-autowithdraw.js'; import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js'; +import { notifyText } from './notify-text.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; // The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is @@ -108,8 +109,11 @@ self.addEventListener('push', (e) => { await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } })); } catch (_) {} for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} } - await show((ring.name || 'Someone') + ' is calling', { - body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.', + // the words are measured against Chrome's spam model (notify-text.js): + // "<name> is calling" is hidden behind a warning, "Calling" / "<name>" shows + const [callTitle, callBody] = notifyText('call', { name: ring.name }); + await show(callTitle, { + body: callBody, icon: 'icon-192.png', badge: 'badge-96.png', tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400], data: { url: './?open=call', view: 'call' }, @@ -141,24 +145,15 @@ self.addEventListener('push', (e) => { if (verdict && verdict.name) dmName = verdict.name; } catch (err) { console.warn('[sw-dm] could not classify:', err && err.message); } } - // A reaction names who, when this device knows them, and is dropped - // when they are muted here — the notifier can't know either. - let who = null; + // A reaction from someone muted on this device is dropped — the + // notifier can't know. (It no longer names who: a name in the line + // is what tips Chrome's spam model over, see notify-text.js.) if (data.reason === 'reaction' && data.from) { const boxes = await allInboxes().catch(() => []); if (boxes.some((b) => (b.muted || []).includes(data.from))) return; - who = boxes.map((b) => b.names && b.names[data.from]).find(Boolean) || null; } const emoji = !data.emoji || data.emoji === '+' ? '\u2764\ufe0f' : /^:[\w-]+:$/.test(data.emoji) ? data.emoji : data.emoji.slice(0, 8); - const T = { - payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Open coinos to see it.'], - dm: dmName ? [dmName, 'sent you a message.'] : ['New message', 'You have a new private message.'], - chat: ['New chat activity', 'There are new messages in your communities.'], - mention: [data.reply ? 'New reply to your post' : 'You were mentioned', data.text || 'Open coinos to see it.'], - zap: ['Zap received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats. Open coinos to see it.` : 'Someone zapped you. Open coinos to see it.'], - reaction: [who ? `${who} reacted ${emoji}` : `New reaction ${emoji}`, 'to your post. Open coinos to see it.'], - }; - const [title, body] = T[data.reason] || T.chat; + const [title, body] = notifyText(data.reason, { amountSat: data.amountSat, name: dmName, reply: data.reply, emoji }); // a reply lands you in the Notifications list, where it is const target = data.reason === 'mention' || data.reason === 'reaction' ? { url: './?open=notifs', view: 'notifs' } : { url: './' }; await show(title, { @@ -254,10 +249,9 @@ self.addEventListener('push', (e) => { // mint an invoice) — "an app wants to pay" would read as the opposite. const incoming = data.event && data.event.kind === 21001; diary(incoming ? 'waking the user: incoming payment' : 'waking the user: payment request'); - await show(incoming ? 'Incoming payment' : 'Payment request', { - body: incoming - ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.' - : 'An app is asking your wallet to pay. Open Coinos to approve.', + const [reqTitle, reqBody] = notifyText(incoming ? 'incoming' : 'spend'); + await show(reqTitle, { + body: reqBody, icon: 'icon-192.png', badge: 'badge-96.png', tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, data: { url: './' }, 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(`<!doctype html><title>score</title><script>navigator.serviceWorker.register('./sw.js'); + window.show = async (t, b) => { const r = await navigator.serviceWorker.ready; await r.showNotification(t, { body: b, tag: 's' + Date.now() + Math.random() }); };</script>score`, + { 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']); }); |
