WalletConnect — every path, and whether the code handles it

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.

HANDLED code path exists, cited PARTIAL handled but with a known hole TODO not handled

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.

The scenario you described

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:

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.

A · Connect

#ScenarioExpectedWhat the code doesCodeProven
A1No session, tap ConnectQR/deep-link, wallet approves, one signer connectWalletCore → openModal() → poll getSigner() 8skwcCore.js:633 HANDLEDsmoke ✓
A2No session, tap SignSign auto-connects first if(!active) active = await wc.connect()katomia-board.js:3938 HANDLEDsmoke ✓
A3Live session, tap Connect againNo duplicate signer for the same account SDK onSessionConnected disconnects + drops same-accountId dupes before pushing HANDLEDno
A4Ghost session, tap ConnectFresh pairing; ghost gone Ghost reaped by reapDeadSigners(); A3 dedupe also fireskwcCore.js:561 HANDLEDno
A5aWallet cleared while katomia is OPENDetect dead session, force real pairing, sign SDK's session_delete arrives, signer removed; getAccount() returns null so the connect branch runskwcCore.js:578 HANDLEDauto 8/8
A5bWallet cleared while katomia is CLOSED — the true ghostSame 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. TODOreproduced 08-06
A6Game is testnet, connector built mainnetRebuild for the tx network before pairing ensureNetwork() called before any WC callkwcCore.js:432 · board:3930 HANDLEDonce, 07-07
A7User closes the WC modal without approvingClean error, no half state openModal() rejects only if throwErrorOnReject; we pass false, so it hangs until the 8s poll throws "Wallet connection timeout" PARTIALno
A8User rejects in the wallet"Rejected", offer retry Rejection surfaces as a generic connect throw; no typed error, no retry affordance PARTIALno
A9Pairing proposal expires (user wanders off)Say so, re-offer pairing Throws from approval(); board shows Signing failed: … — accurate but opaque PARTIALno
A10Stacked <wcm-modal> after a network switchOne live modal only kwcCore removes all on rebuild; kauthPruneWcModals() keeps the lastkwcCore.js:452 · katomia-auth.js:48 HANDLEDfixed 07-07
A11The QR / connection-string popup that lingersModal 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. TODOno

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.

B · Sign

#ScenarioExpectedWhat the code doesCodeProven
B1Live session, desktopSignsStraight through HANDLEDyes
B2Mobile, wallet backgroundedRequest wakes the wallet Request sent first; ping is a kdiag line only and cannot fail a signkwcCore.js:1420 HANDLEDsmoke ✓ both phones
B3Slow approval, 30–120sStill succeeds R2a removed the 30s cap that killed exactly this. Only the 120s backstop remains. HANDLEDno — needs a deliberate slow approval
B4Approval > 120sClear timeout error KWC-TIMEOUT. Note the tx is dead at 40s anyway, so this only ends the await. HANDLEDno
B5Sign hits a dead session, live one existsRe-sign on the live session, no user action _isSessionGoneError → purge → findSigner() → sign again oncekwcCore.js:518 HANDLEDno
B6Sign hits a dead session, nothing live"Reconnect" — not a retry loop KWC-GONE: tap Connect to pair again HANDLEDno
B7Relay drops mid-sign, recovers <15sLate answer still lands Loss timer armed on relayer_disconnect, cleared on reconnect; relay mailboxes the replykwcCore.js:359 HANDLEDobserved in the wild, never forced
B8Relay drops mid-sign, never recoversReal error at 15s KWC-RELAY-LOST. Never once fired in production logs. HANDLEDno
B9Wallet active on the wrong accountHard stop, name both accounts WS-ACCTMISMATCH1 pre-check before bytes are fetchedkatomia-board.js:3947 HANDLEDyes, 08-05
B10Only a wrong-network signer connectedName the mismatch WC-ACCTFILTER1 stray checkkwcCore.js:1367+ HANDLEDonce
B11tx expires during approvalHonest "expired", wipe Backend verdict drives boardShowResult(false) → wiper. Correct behaviour. HANDLEDseen often
B12Ghost + fresh signer share one accountIdFreshest live one wins findSigner picks max session expiry, not [0]kwcCore.js:550 HANDLEDno — the core case to automate
B13Double sign request raceOne request only ORDER-AUTOSIGN1 R3 claims the txid before any await78086ae HANDLEDfixed after live 18:46 repro
B14Wallet approves but reply is lostDon'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. PARTIALno

C · Disconnect

#ScenarioExpectedWhat the code doesCodeProven
C1dApp-side disconnect, peer aliveSession dropped both ends disconnectAccountCore → SDK disconnect → prune by topickwcCore.js:781 HANDLEDyes
C2dApp-side disconnect, peer already goneLocal state still cleaned Round-trip throws, swallowed; _purgeLocalSession removes our copy regardlesskwcCore.js:704 HANDLEDno
C3Wallet-side clear, then page reloadNot shown as connected Rehydrated signer fails signerIsLive, reaped before any readkwcCore.js:529 HANDLEDno
C4Wallet-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() HANDLEDno
C5Escape hatchGuaranteed 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 HANDLEDheadless ✓
C6disconnectAll() half-fails with 2 accountsLive account survives R2a: only signers whose session is verifiably gone are droppedkwcCore.js:718 HANDLEDno
C7Disconnect one of two accountsThe other keeps working Per-topic disconnect + WC-PICKDEL1 local prune HANDLEDsingle-account only so far

D · Freshness & lifecycle

#ScenarioExpectedWhat the code doesCodeProven
D1New pin, user navigatesNew bundle, no user action CACHE-FRESH1: HTML is no-cache, must-revalidate, so the script tag carries the new hashkasset.php:14 HANDLEDverified by header
D2New pin, tab resumed rather than navigatedApp 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 TODOno
D3bfcache restore mid-sessionState intact or refreshed cleanly No pageshow(persisted) handler anywhere TODOno
D4Reload with a genuinely live sessionStays connected Rehydrated, passes signerIsLive, kept HANDLEDyes
D5Session expires by TTL while the tab sits openShown as disconnected Reap checks expiry * 1000 <= nowkwcCore.js:529 HANDLEDno — 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.

E · Network & relay

#ScenarioExpectedWhat the code doesCodeProven
E1mainnet → testnet switchRe-pair on the new network, old modal gone WC-NETSWITCH1 + WC-SESSIONDISPOSE1: rebuild, close old modal, close old relay onlykwcCore.js:432 HANDLEDyes, 07-07/07-08
E2Relay drops while idleSilent auto-recover restartTransport() with backoff to 10skwcCore.js:381 HANDLEDseen constantly on mobile
E3Backgrounding flaps the relay repeatedlyNo user-visible effect Reconnects in ~100ms; no in-flight request so no loss timer HANDLEDyes
E4Airplane mode mid-signReal error, not a hang B8 path HANDLEDno

Can kWallet drive these instead of HashPack?

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.

What I would do next, in order

  1. kWallet /api/disconnect — unlocks A5/B5/B6/C3/C4, the family that has burned you repeatedly.
  2. D2 freshness check — so a pin bump reaches a resumed tab without anyone being told to reload.
  3. A11 modal kdiag — open/close/live-count, so the lingering QR popup stops being folklore.
  4. kWallet approval delay knob — locks in B3 against regression.
  5. A7/A8/A9 typed errors — "you closed the pairing window", "you rejected it", "the pairing expired" instead of one generic throw.

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.