Keaton in your pocket

Why a notary in Termux keeps dropping its doorbell, what a PWA can and cannot do about it, and the smallest Android app that would actually work.

Scoping note · 17 Sep 2026 · companion to “Bots off the box”

The short version. A PWA cannot hold a WebSocket while the screen is off — no amount of cleverness changes that, so it is a viewer, not a notary. The Android app is genuinely small if you keep the decision in JavaScript and let Kotlin do only the three things JavaScript cannot: survive, store a key properly, and wake up. Two evenings to a working demo.

01Start with what your own log says

Before designing anything, there is a fault visible in the console you pasted, and it will follow KEA onto any host:

20:56:51  doorbell connected
20:58:56  doorbell closed — reconnecting in 5s     (2m 05s)
20:59:01  doorbell connected
21:01:06  doorbell closed — reconnecting in 5s     (2m 05s)
21:01:12  doorbell connected
21:03:17  doorbell closed — reconnecting in 5s     (2m 05s)

Two minutes and five seconds, four times, to the second. That is not mobile flakiness — flaky sockets die at irregular intervals. That is an idle timeout somewhere between the phone and the pool, almost certainly at the edge, cutting a connection with no traffic on it.

KEA's doorbell never sends anything once it has subscribed, so on a quiet pool the socket is idle by definition. It has no ping. The ws library supports one and we simply never call it.

Fix first

Send a WebSocket ping every ~45 s in kea/adapters/doorbell.js, and treat a missed pong as a reconnect. That is about five lines and it is worth doing before any of the work below, because every host in this document inherits the problem — and because a reconnect every two minutes is a two-minute window in which a raise can be missed and only the sweep catches it, twenty seconds late.

02What a host has to provide

KEA is small, but it is specific about what it needs. Any candidate host has to do all six:

NeedsWhyBrowser / PWAAndroid app
Outbound WebSocket, held openthe doorbellonly while visibleyes
Outbound HTTPStxRead, addSig, hello, verdict, mirroryesyes
ed25519 sign over arbitrary bytesthe signature itselfWebCryptoyes
Private key at rest, not readable by other appsit is the whole assetno — IndexedDB is plainKeystore
Run with the screen offclaims arrive at 3amnoforeground service
Run the decide() corethe eight checks; JCS; JMESPath; JSONLogicit is JSsee §4

03The PWA

A KEA PWA not a notary useful as a viewer

The honest limits, in order of how much they hurt:

What it is good for: everything that is not signing. A PWA wrapper around the /keaton/ dashboard — installable, on the home screen, notifying you when a deal you care about has been waiting too long — is a genuinely nice thing and costs almost nothing, because the page already exists. Just don't let it hold a key.

The one exception: the KEA web app (signer C) already signs in a browser, and that is fine, because a human is sitting there with it open. Attended signing is a different job from unattended signing.

04The Android notary

The design question that decides the size of this is: where does decide() run?

Port the core to Kotlin don't

You would be reimplementing JCS canonicalisation, dealId hashing, JMESPath and JSONLogic, and then maintaining two implementations that must agree byte for byte forever. The whole trust argument for KEA is "the signer reproduces the transaction itself" — two reproducers that can disagree is worse than one, and the divergence would show up as a refusal nobody can explain.

Run the existing JS core inside the app this one

Kotlin does the three things it is actually needed for; the decision stays exactly the code that runs on the box.

LayerWhatRoughly
Foreground serviceA persistent notification ("KEA — watching 16 deals"), so Android will not kill it. This is what buys you screen-off operation.~80 lines
Socket + HTTPOkHttp WebSocket with pingInterval set — which solves §1 for free — and OkHttp for the reads and posts.~150 lines
Key storageAndroid Keystore. Ed25519 has patchy Keystore support across versions, so the pragmatic version is a Keystore-wrapped AES key encrypting the ed25519 seed in EncryptedSharedPreferences.~60 lines
The corekea/dist/kea-core.browser.js — the bundle that already exists for the web app — evaluated in a headless WebView or QuickJS. Kotlin passes in the record and the chain reads; JS returns the verdict.~120 lines of bridge
SigningDo it in Kotlin, not JS, so the key never enters the JS heap. The core says which keys and over what bytes; Kotlin signs.~30 lines

That split is not a compromise — it is the right boundary. The core is pure and has no business touching a key; it already takes its chain reads through a port, which is exactly the seam a Kotlin host plugs into.

WebView or QuickJS?

Start with WebView: zero dependencies, `evaluateJavascript` is a one-liner, and it can load the same bundle the web app uses. Move to QuickJS (via quickjs-android) only if the WebView's lifecycle inside a service proves awkward — it is the smaller and more predictable engine, but it is another dependency to justify.

05Staged, so each stage is worth having alone

  1. Ping the doorbell~30 minutesFive lines in the existing adapter. Fixes the two-minute drop on every host, including the phones you have now. Nothing else depends on it, and everything benefits.
  2. A shell that stays aliveone eveningEmpty Kotlin app, foreground service, OkHttp socket subscribing to one deal, logging what arrives. No keys, no signing. Proves the hard part — that Android lets you hold a socket overnight — before you build anything on it.
  3. The core in a WebViewone eveningLoad the existing bundle, feed it a real pooled record, compare the verdict against what the box decides for the same transaction. That comparison is the whole test.
  4. Keys and a real signaturehalf a dayKeystore-wrapped seed, sign the body bytes in Kotlin, POST to addSig. At this point it is a notary and will appear on the dashboard beside the phones.
  5. Presence and verdictsa couple of hoursBoth are just signed JSON over HTTP; reuse the signing you already built. Now it is indistinguishable from the node body on the board.

06What will bite

Doze

A foreground service survives Doze, but network access can still be deferred in deep idle. Expect to need the socket plus the existing sweep as a backstop — which you already have, and which is another argument for never narrowing the sweep.

OEM killers

Xiaomi, Huawei, OnePlus and Samsung all ship aggressive background-app killers that ignore the documented rules. Your S20 and OnePlus are exactly the affected brands. Budget an afternoon for per-device battery-optimisation exemptions, and treat "it died overnight on one handset" as expected rather than a bug in your code.

Do not ship it

Play Store review dislikes persistent foreground services without an obvious user-facing reason, and a "signs blockchain transactions in the background" app invites a fight. Sideload it. This is a test rig for you and a handful of devices, not a product — and keeping it off the store also keeps it out of the conversation about who is responsible when a key on a phone signs something.


07The identity problem this exposes

Everything above assumes more devices, run by more people, on a board anyone can read. That brings forward a question worth answering before it is urgent: on the device list, what is actually true?

Exactly one thing is proven, and it is worth being blunt about which:

ProvenWhoever posted this holds the private key for by. That is what the signature establishes, and it is all it establishes.
CheckableWhether a claimed public key is really on a claimed account. Nobody checks it today, but the pool could: it already reads account keys from the mirror and normalises threshold trees for the readiness check. One lookup per pair turns a claim into a fact.
Just claimedThe label, the deals, the subscription, and every key other than by. A device can announce any label it likes and list any public keys it likes — including yours. Nothing stops it.

So yes, to answer the question directly: someone can sign with their own key and claim to hold anyone's. Today the pool would store it and the dashboard would draw it. Two of my own recent changes widened that, and I should name them: dropping the rule that by must be one of the announced keys (so that an identity key need not be a signing key), and returning the announcement whole. Both were right for their own reasons; together they mean the keys map is now purely a claim.

What I would do about it, cheapest first

  1. Say so on the page. Render by as the device's identity and everything else as claimed, with the label visibly subordinate to the key. Costs nothing and removes the illusion. This is the one to do now.
  2. Verify the pairs. The pool checks each claimed key against the account's on-chain key list and marks each one verified or not. Now "this device holds a key on 0.0.7718" is a fact, and a liar's row is visibly hollow.
  3. Prove possession, not just assertion. Require the announcement to carry a signature from each key it claims, not only the identity key. More bytes, but then the keys map is as trustworthy as by.
  4. Earned reputation. The strongest signal is one you already store: has this key ever produced a signature the pool accepted on a transaction? A device that has signed real work is in a different class from one that has only ever talked, and no amount of graffiti gets you into it.
  5. Let the viewer name devices. Nicknames kept locally, keyed by public key, overriding the self-declared label — which the dashboard is already half-way to, because it remembers devices by key in "Seen before".

Steps 1 and 5 are page work. Step 2 is perhaps thirty lines in the pool. Steps 3 and 4 are design decisions worth taking deliberately, and none of this is urgent while the board is behind Cloudflare Access and every announcing device is one of yours — which is precisely why it is the right time to decide it.