katomia · lab1 · bundle dd98ee1 · WC-RELAYHEALTH1 R2c · 2026-08-06 · daily testnet cron: WCPATHS class on the QA grid
Every branch a user can take through connect / sign / disconnect, what the code does with it today, and what is actually proven. Two separate columns on purpose: Code is "is there a path for this", Proven is "has anyone ever seen it work". Almost everything is green on the first and thin on the second — that gap is the honest state of things.
Correction first. I told you to hard-reload both phones. That was wrong advice and you
are right to push back — CACHE-FRESH1 (bf014c6, 2026-08-05) exists precisely so users
never have to. What I should have said: the pin bump reaches you on any fresh navigation, and a
reload should never be something a user has to know about. There is still a real hole
(D2) where a tab that is resumed rather than navigated keeps its old bundle, and the fix for
that is code, not instructions. It is listed as TODO below, not as a thing you should do by hand.
Clear connections in HashPack (accounts left alone) → back into katomia → tap Sign → connection options appear → connects → wallet asks to sign → signs fine → back in katomia → everything wipes.
That is row A5, and the mechanism was exact:
DAppConnector.init() rebuilds a signer for it — a ghost.if(!active) active = await wc.getAccount(). The ghost
answered, truthy, so the connect branch never ran — you were never actually re-paired,
whatever the wallet showed you.boardShowResult(txid,false) → wiper. The wipe is downstream and correct; the
sign failing is the bug.R2b breaks that chain when the wallet's delete actually reaches us: the signer is
removed, getAccount() returns null, the connect branch runs, you get a genuine
pairing. That path is now automated and green (A5a, 8/8).
But there is a second half, and it is still broken. If you clear HashPack while katomia
is closed — or the relay drops the notification — the delete never lands, the session stays in our
IndexedDB, and signerIsLive() reads that same store, so it happily reports the
corpse as alive. Reproduced mechanically on 2026-08-06 (A5b): the sign goes to the dead topic, the
wallet has no such session and never replies, and katomia waits the full 120 seconds before
giving up. Your transaction expires at 40. So the board wipes, exactly as you described.
What R2b did fix is real, and recovery works — re-pairing gets you out and the next sign lands on the new session (A5b.6/A5b.7 pass). What is not fixed is the 120-second wait against a session that was never going to answer. The fix is to bound the wait by the transaction's own validity window, which the bundle can read straight out of the tx bytes — there is no point waiting 120s for something that dies at 40s. That is a real deadline, not the unreliable ping signal that caused the earlier regressions.
| # | Scenario | Expected | What the code does | Code | Proven |
|---|---|---|---|---|---|
| A1 | No session, tap Connect | QR/deep-link, wallet approves, one signer | connectWalletCore → openModal() → poll getSigner() 8skwcCore.js:633 |
HANDLED | smoke ✓ |
| A2 | No session, tap Sign | Sign auto-connects first | if(!active) active = await wc.connect()katomia-board.js:3938 |
HANDLED | smoke ✓ |
| A3 | Live session, tap Connect again | No duplicate signer for the same account | SDK onSessionConnected disconnects + drops same-accountId dupes before pushing |
HANDLED | no |
| A4 | Ghost session, tap Connect | Fresh pairing; ghost gone | Ghost reaped by reapDeadSigners(); A3 dedupe also fireskwcCore.js:561 |
HANDLED | no |
| A5a | Wallet cleared while katomia is OPEN | Detect dead session, force real pairing, sign | SDK's session_delete arrives, signer removed; getAccount() returns null so the connect branch runskwcCore.js:578 |
HANDLED | auto 8/8 |
| A5b | Wallet cleared while katomia is CLOSED — the true ghost | Same | NOT handled. The delete never reaches us, so the session is still in our IndexedDB and signerIsLive() — which reads that same store — says it is alive. The sign goes to the dead topic, the wallet has no such session and never answers, and we wait the full 120s before KWC-TIMEOUT. The tx expires at 40s, so the board wipes long before that. |
TODO | reproduced 08-06 |
| A6 | Game is testnet, connector built mainnet | Rebuild for the tx network before pairing | ensureNetwork() called before any WC callkwcCore.js:432 · board:3930 |
HANDLED | once, 07-07 |
| A7 | User closes the WC modal without approving | Clean error, no half state | openModal() rejects only if throwErrorOnReject; we pass false, so it hangs until the 8s poll throws "Wallet connection timeout" |
PARTIAL | no |
| A8 | User rejects in the wallet | "Rejected", offer retry | Rejection surfaces as a generic connect throw; no typed error, no retry affordance | PARTIAL | no |
| A9 | Pairing proposal expires (user wanders off) | Say so, re-offer pairing | Throws from approval(); board shows Signing failed: … — accurate but opaque |
PARTIAL | no |
| A10 | Stacked <wcm-modal> after a network switch | One live modal only | kwcCore removes all on rebuild; kauthPruneWcModals() keeps the lastkwcCore.js:452 · katomia-auth.js:48 |
HANDLED | fixed 07-07 |
| A11 | The QR / connection-string popup that lingers | Modal closes itself once paired | Undiagnosed. SDK closes it in openModal's finally, so a lingering one means the promise never settled — or it is an orphan from a rebuild that pruning missed. No kdiag on modal open/close, so there is nothing to read. |
TODO | no |
A11 — what that popup actually is. It is WalletConnect's own <wcm-modal>,
not ours and not HashPack's. The "connection string" is the pairing URI
(wc:…@2?relay-protocol=…&symKey=…) — the thing a QR encodes. It is supposed to
be on screen only while a pairing is in flight, and the SDK closes it in a finally when
openModal() settles. That you can approve and sign underneath it and everything works
means the pairing succeeded and the modal simply never got closed — cosmetic, but it is also the
visible symptom of a promise that never settled, which is worth knowing. I have not diagnosed
it and I am not going to guess: the first step is a kdiag line on every modal open/close plus a count
of live <wcm-modal> elements, which would make it self-explaining next time.
| # | Scenario | Expected | What the code does | Code | Proven |
|---|---|---|---|---|---|
| B1 | Live session, desktop | Signs | Straight through | HANDLED | yes |
| B2 | Mobile, wallet backgrounded | Request wakes the wallet | Request sent first; ping is a kdiag line only and cannot fail a signkwcCore.js:1420 | HANDLED | smoke ✓ both phones |
| B3 | Slow approval, 30–120s | Still succeeds | R2a removed the 30s cap that killed exactly this. Only the 120s backstop remains. | HANDLED | no — needs a deliberate slow approval |
| B4 | Approval > 120s | Clear timeout error | KWC-TIMEOUT. Note the tx is dead at 40s anyway, so this only ends the await. |
HANDLED | no |
| B5 | Sign hits a dead session, live one exists | Re-sign on the live session, no user action | _isSessionGoneError → purge → findSigner() → sign again oncekwcCore.js:518 |
HANDLED | no |
| B6 | Sign hits a dead session, nothing live | "Reconnect" — not a retry loop | KWC-GONE: tap Connect to pair again |
HANDLED | no |
| B7 | Relay drops mid-sign, recovers <15s | Late answer still lands | Loss timer armed on relayer_disconnect, cleared on reconnect; relay mailboxes the replykwcCore.js:359 |
HANDLED | observed in the wild, never forced |
| B8 | Relay drops mid-sign, never recovers | Real error at 15s | KWC-RELAY-LOST. Never once fired in production logs. |
HANDLED | no |
| B9 | Wallet active on the wrong account | Hard stop, name both accounts | WS-ACCTMISMATCH1 pre-check before bytes are fetchedkatomia-board.js:3947 | HANDLED | yes, 08-05 |
| B10 | Only a wrong-network signer connected | Name the mismatch | WC-ACCTFILTER1 stray checkkwcCore.js:1367+ | HANDLED | once |
| B11 | tx expires during approval | Honest "expired", wipe | Backend verdict drives boardShowResult(false) → wiper. Correct behaviour. |
HANDLED | seen often |
| B12 | Ghost + fresh signer share one accountId | Freshest live one wins | findSigner picks max session expiry, not [0]kwcCore.js:550 |
HANDLED | no — the core case to automate |
| B13 | Double sign request race | One request only | ORDER-AUTOSIGN1 R3 claims the txid before any await78086ae | HANDLED | fixed after live 18:46 repro |
| B14 | Wallet approves but reply is lost | Don't claim failure if the sig lands late | Backend is the source of truth via txsocket, so a late sig still settles. UI may already have shown an error. | PARTIAL | no |
| # | Scenario | Expected | What the code does | Code | Proven |
|---|---|---|---|---|---|
| C1 | dApp-side disconnect, peer alive | Session dropped both ends | disconnectAccountCore → SDK disconnect → prune by topickwcCore.js:781 |
HANDLED | yes |
| C2 | dApp-side disconnect, peer already gone | Local state still cleaned | Round-trip throws, swallowed; _purgeLocalSession removes our copy regardlesskwcCore.js:704 |
HANDLED | no |
| C3 | Wallet-side clear, then page reload | Not shown as connected | Rehydrated signer fails signerIsLive, reaped before any readkwcCore.js:529 |
HANDLED | no |
| C4 | Wallet-side clear, no reload (live tab) | Same | SDK session_delete handler fires on the peer's delete; if the relay was down when it happened, the reap catches it on the next getSigner() |
HANDLED | no |
| C5 | Escape hatch | Guaranteed fresh pairing | WC.resetWalletLink() wipes wc@2:* + drops the connector. Console only — no UI control (deliberately: no phantom buttons until it earns one)kwcCore.js:750 |
HANDLED | headless ✓ |
| C6 | disconnectAll() half-fails with 2 accounts | Live account survives | R2a: only signers whose session is verifiably gone are droppedkwcCore.js:718 | HANDLED | no |
| C7 | Disconnect one of two accounts | The other keeps working | Per-topic disconnect + WC-PICKDEL1 local prune | HANDLED | single-account only so far |
| # | Scenario | Expected | What the code does | Code | Proven |
|---|---|---|---|---|---|
| D1 | New pin, user navigates | New bundle, no user action | CACHE-FRESH1: HTML is no-cache, must-revalidate, so the script tag carries the new hashkasset.php:14 |
HANDLED | verified by header |
| D2 | New pin, tab resumed rather than navigated | App pulls the update through itself | Nothing does this. On 08-06 at 07:21:02 a phone was still running a99a66e, 7h after the pin moved, then loaded e31d3b7 4s later. Back/forward and restored tabs can skip revalidation. modal:cachecheck compares running vs fresh markers but only logs, never acts, and only when a modal opens.katomia-modals.js:279 |
TODO | no |
| D3 | bfcache restore mid-session | State intact or refreshed cleanly | No pageshow(persisted) handler anywhere |
TODO | no |
| D4 | Reload with a genuinely live session | Stays connected | Rehydrated, passes signerIsLive, kept |
HANDLED | yes |
| D5 | Session expires by TTL while the tab sits open | Shown as disconnected | Reap checks expiry * 1000 <= nowkwcCore.js:529 |
HANDLED | no — needs a clock-shifted test |
D2 is the one that matters to you and it is genuinely not done. It is fixable in code and
does not need anything from the user: on visibilitychange and
pageshow(persisted), re-fetch the build marker, and if it moved, reload — suppressed
while a sign is in flight so it can never interrupt a trade. That is roughly twenty lines. It is
listed as TODO rather than done because I have not written it yet, not because it is impossible.
| # | Scenario | Expected | What the code does | Code | Proven |
|---|---|---|---|---|---|
| E1 | mainnet → testnet switch | Re-pair on the new network, old modal gone | WC-NETSWITCH1 + WC-SESSIONDISPOSE1: rebuild, close old modal, close old relay onlykwcCore.js:432 | HANDLED | yes, 07-07/07-08 |
| E2 | Relay drops while idle | Silent auto-recover | restartTransport() with backoff to 10skwcCore.js:381 |
HANDLED | seen constantly on mobile |
| E3 | Backgrounding flaps the relay repeatedly | No user-visible effect | Reconnects in ~100ms; no in-flight request so no loss timer | HANDLED | yes |
| E4 | Airplane mode mid-sign | Real error, not a hang | B8 path | HANDLED | no |
Mostly yes — and that is the answer to your question. kWallet already exists as a real
WalletConnect wallet peer (KWALLET1, done 2026-07-15): /opt/kwallet, pm2
kwallet + kwallet-mainnet, both online now, hosted UI at
kpay.uk/devtools/kwallet/. It pairs over the real relay, approves sessions and signs
Hedera transactions — proven on-chain without HashPack.
What it can drive today, unattended: A1–A4, A6, B1, B3, B4, B9, B10, B12, C1, C7, D4, D5, E1
— via POST /api/pair, /api/approve, /api/reject, and
/api/mode for auto vs manual.
One primitive is missing, and it is the one your scenario needs. kWallet has no
disconnect endpoint — kwallet-service.cjs exposes pair / approve / reject / state /
mode / log and nothing else. "Clear the connection in the wallet" is therefore the single thing it
cannot yet simulate, which is exactly A5, B5, B6, C3 and C4.
Fix: add POST /api/disconnect calling the wallet peer's
disconnectSession({topic, reason}). Small, and it unlocks the whole ghost-session
family as automated regression tests — the difference between "I reasoned it through" and "it is
proven and stays proven".
Deliberate approval delay (B3) also needs a knob — approve after N seconds — to regression-test the 30s cap bug that I introduced and removed. Without it, nothing stops that class of mistake coming back.
/api/disconnect — unlocks A5/B5/B6/C3/C4, the family that has burned you repeatedly.Source of truth for every citation:
public_html/vendor/walletconnect/builder/src/kwcCore.js at 3c7e7c3,
public_html/m/katomia/katomia-board.js, katomia-auth.js,
katomia-modals.js, kasset.php. Line numbers move — grep the marker names.