Skip to content

feat: GnoConnect wallet interop, a selectable network, and failures the user can read - #19

Open
D4ryl00 wants to merge 17 commits into
gnoverse:mainfrom
D4ryl00:feat/gnoconnect-connect
Open

feat: GnoConnect wallet interop, a selectable network, and failures the user can read#19
D4ryl00 wants to merge 17 commits into
gnoverse:mainfrom
D4ryl00:feat/gnoconnect-connect

Conversation

@D4ryl00

@D4ryl00 D4ryl00 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Moves boards2's wallet interop from the old gnokey-mobile://tosign links to
gno's GnoConnect standard for external wallets
(docs/resources/gnoconnect.md, launch links in gnolang/gno#5970), and makes
every failure along the way something the user can read.

15 commits, in three groups:

GnoConnect (ea585bb42a6d78) — connect for sign-in, signtx for
signing, with boards2 broadcasting. It names the network it expects on every
link, requires and validates the callback state token (a callback scheme is
public — anything installed can open ours), and reads the wallet's enumerated
code rather than parsing prose.

The network is the app's (d231ed2) — boards2 is the GnoConnect producer,
so it owns the rpc/chain-id it works against and tells the wallet. That needs a
screen to pick one, reachable before sign-in, and a store reset on switch: cached
boards and threads belong to the chain they came from.

Failures reach the user (623f191, 9e75580, 3580481) — every rejected
thunk and non-success wallet callback now surfaces in one snackbar, worded from
the ErrCode gnonative 5.1 sends with the error rather than from its message
text. A declined request stops the spinner; a failed broadcast keeps the form so
it can be retried.

Where to look

redux/features/linkingSlice.ts the launch links and their callbacks
src/utils/callback-state.ts why state is required, and persisted
redux/utils/async-thunk.ts why the code is read at serialisation time
src/utils/describe-error.ts the only wording this app still owns
redux/middleware/error-reporter.ts one place failures become visible

Verification

tsc, eslint and prettier clean. Exercised on an Android emulator and an iOS
simulator against a local gnodev, with gnokey-mobile as the wallet: sign-in,
create board/thread/reply, network switch, and the refusal paths — declining in
the wallet, a realm rejecting the call, and an unreachable node.

Needs @gnolang/gnonative ≥ 5.1.0 for the ErrDetails the error wording comes
from.

D4ryl00 added 15 commits July 28, 2026 12:10
Replace the legacy tosignin/tosign deep links with the GnoConnect contract:

- Login: tosignin -> connect (land.gno.gnokey://connect). Display-level
  sign-in; the callback returns the address. Drop client_name.
- Signing: tosign -> tx launch link in sign-only mode (broadcast=false).
  boards2 no longer builds the txJson; it passes path/func/args (positional
  args as a back-compat alias) + signer (pins the connected account) and
  gnokey builds, reviews, signs, and returns the signed tx as base64
  signedtx, which we broadcast.
- Callback security: an opaque single-use state token is issued with each
  launch and validated on return (LinkingProvider), rejecting forged/
  unsolicited callbacks on the public callback scheme; non-success statuses
  are ignored. setLinkingData base64-decodes signedtx to amino-JSON, so the
  consumer screens drop their now-redundant decodeURIComponent.
- Login gates on the address only (connect returns no remote URL; boards2
  knows its own network).

Not build-verified (no RN/Expo toolchain here); tsc adds no new errors over
the pre-existing missing-@berty/gnonative-ui module errors.
Four issues found verifying the GnoConnect migration:

- **A callback without `state` was accepted.** The check only ran when the
  parameter was present, so a forged `land.gno.boards2:/…?status=success&
  address=…` bypassed it entirely by omitting it — no token to guess. Every
  link we open (connect and tx alike) sends a state, so a callback without one
  cannot be a reply to us; it is now rejected.

- **Tokens lived in a module-level Set,** which is empty after a cold start.
  The round trip leaves this app for the wallet and the OS may evict us while
  backgrounded, so a legitimate callback would be dropped on exactly the
  restart it triggers — intermittent, and it would look like the wallet's
  fault. Tokens are now persisted (expo-file-system, already a dependency)
  with a 15-minute TTL and single-use consumption.

- **Positional `args=`.** The standard asks producers to emit named
  `arg.<name>`; positional is a back-compat alias that binds by position, so a
  divergence from the realm's declaration order silently binds values to the
  wrong parameters. Named args are now emitted for the functions whose
  signature we have, taken from the realm sources rather than guessed.
  `AddReaction` and `RepostThread` keep positional: neither is an exported
  function of gno.land/r/gnoland/boards2/v1 (there is only a renderRepostThread
  *render* route), so there is no signature to name them from — those calls
  look broken independently of this migration.

- **`cancelled`/`error` were logged and dropped,** leaving the screen that
  opened the wallet waiting on a request the user had declined. The status is
  now recorded in the slice and exposed via `selectLinkingFailure`.

Typecheck is clean (0 errors) once @berty/gnonative-ui is installed — it was
simply missing from node_modules, which is what the "not build-verified" note
on the migration commit was really reporting.
Login threw on every GnoConnect sign-in. The migration relaxed the gate in
app/index.tsx to require only the address, but the `loggedIn` thunk it
dispatches still demanded `remoteURL` from linking state — and `connect`
deliberately returns no RPC endpoint, because a dapp must never take one from
a callback it cannot authenticate. Nothing else set it, so the thunk threw
"No bech32 address, chainId or remoteURL found for login" every time.

Complete the change the migration started ("boards2 knows its own network"):
REMOTE/CHAIN_ID constants, applied when gnonative is initialised, and `loggedIn`
now requires only the address.

`connect` does report which chain the wallet is on, and that is worth checking:
if it differs from ours the wallet would sign for one network while we broadcast
to another, producing a signature that cannot land. Login now fails loudly on
that mismatch instead.

NOTE: REMOTE/CHAIN_ID default to the local gnodev (127.0.0.1:26657, chain
`dev`) because the app had no network of its own to inherit — it had always
taken one from the wallet. Point them elsewhere for a public chain.
`broadcastTxCommit` returns a stream — `Promise<AsyncIterable<…>>` — so
awaiting it only hands back the iterator. Nothing iterated it, so the call was
never driven and the signed transaction never left the device. It logged
`broadcasted tx: {}` and looked like a success while the chain saw nothing.

Iterate the stream and report the height/hash, and fail loudly if it yields no
response rather than reporting a silent success.

Only reachable now: under the old `tosign` flow the wallet broadcast, so this
path did nothing until sign-only made boards2 the broadcaster.
Broadcasting the wallet's signed transaction failed with ErrInvalidPubKey(#207)
and nothing reached the chain.

gnokey-mobile signs with a *session* key: the signature carries `session_addr`
and the session's pubkey while `caller` is the master. gnonative 4.8.0 has no
notion of session accounts anywhere in the package, so it re-encoded the
transaction without that field, leaving one that claims `caller = master` but
is signed by the session key — which the chain correctly rejects.

5.0.1 vendors a gno with session accounts (querySessionAccount, createSession,
revokeSession and friends). All seven methods this app uses are unchanged, and
tsc is clean across the major bump.

Worth noting for the standard: sign-only (`broadcast=false`) quietly assumes
the producer can serialise whatever signature scheme the wallet used. A client
can accept a `signedtx` it is unable to broadcast, and the failure only shows
up at the very last step.
The avatar comes from a separate realm (r/demo/profile) that may be
absent, reloading, or erroring on a given network. In getUser the fetch
was uncaught, so a profile failure threw all the way up and collapsed the
whole board list to "No Boards yet." Wrap it and fall back to the default
avatar, so a cosmetic miss never takes down the list.

Also downgrade loadBech32AvatarFromChain's already-caught failure from
console.error to console.warn: falling back to the default is expected
degradation, not an error worth a red LogBox alarm.
Follows the GnoConnect standard settling on two things.

Sign-only is a host, not a param: send `signtx://` and drop `broadcast=false`.
An unknown param is silently ignored, so a wallet predating sign-only would
have broadcast a transaction we asked it only to sign; an unknown host is
declined with `unsupported_host` instead.

The failure callback carries `code`, an enumerated machine value
(`no_signer`, `signer_unavailable`, `tx_failed`, `network_declined`), where it
used to carry `message`. Renamed the stored field to match, since the point of
the rename is that it is branched on, not displayed.
The GnoConnect spec has `connect` take rpc/chainid so the wallet can offer to
switch networks before answering. boards2 sent only callback and state, so
gnokey had nothing to switch to and answered from whatever network it happened
to be on. The mismatch then surfaced at `loggedIn`, as a refusal the user had no
way to act on from the sign-in screen.

boards2 is the producer and owns the network, so name it. `signtx` already did;
only `connect` was missing.

Also log the launch link as a string: Hermes prints a URL object as `{}`, which
made the outgoing link impossible to inspect.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
The RPC endpoint and chain ID were hardcoded constants, so the only way to point
the app at another chain was to edit and rebuild. That is wrong for anyone
running a custom node, and it made the Android emulator unusable out of the box:
loopback is relative to the device, so 127.0.0.1 reached the emulator itself
rather than the gnodev on the host, which is at 10.0.2.2.

A network is now one unit — endpoint plus the chain ID that endpoint serves —
picked whole from a screen, never as two free-text fields, so a chain ID cannot
be paired with an endpoint serving a different chain. Topaz is the default;
Local gnodev carries the platform-aware loopback; Custom covers the rest. The
selection is persisted with the same expo-file-system approach already used for
callback state tokens, and presets are restored by id so a preset whose endpoint
later changes reaches someone who chose it long ago.

Switching resets the whole store. The connected address came from a `connect`
against the old chain and every cached board is that chain's state, so carrying
any of it over renders one chain's data under another chain's identity — wrong
content rather than an error. The reset is applied where each slice is
registered, so a slice added later is cleared too.

The picker is reachable from the sign-in screen, not only from Settings: since
`connect` names the network the wallet should switch to, it has to be selectable
before signing in, and the profile screen is behind the auth guard. `network` is
therefore allowed while signed out.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
Failures were handled three different half-finished ways and none of them
reached the user. `boardsSlice`, `threadsSlice` and `boardsCreateSlice` each
stored an `error` no screen read. `linkingSlice` stored a `failure` whose own
comment says a screen should explain it — none did, and `selectLinkingFailure`
was exported and unused. Five screens caught broadcast rejections into
`console.error`. In a release build there is no LogBox either, so approving a
transaction in the wallet and returning to nothing happening was the whole
user-visible behaviour.

Route all of it through one channel. `errorReporter` middleware catches both
shapes — a rejected thunk, and a non-success wallet callback, which is a plain
action because the wallet did answer, just not `success` — and feeds a queue
that one snackbar renders. No screen was changed: that is what makes it uniform
rather than five copies of the same code.

The snackbar is mounted at the app root, not per screen, because these failures
arrive after a round trip through the wallet, by which point the screen that
started the action may be gone or the app relaunched. It does not block: these
are reports, not decisions.

`describeError` turns anything into one sentence. Raw text is not usable — a
dead RPC produced `[unknown] invoke bridge method error: unknown: … dial tcp …:
connect: connection refused`, which is a developer's reason, not a user's. It
prefers gnonative's `ErrCode`, which travels inside the message text as
`ErrOutOfGas(#211)` and so survives the serialisation Redux Toolkit applies to a
rejection before any middleware sees it.

The deny list is deliberately a deny list: the problem was failures reaching
nobody, so a thunk added later is reported by default and silence is what has to
be justified.

patches/ holds the version of describe-error.ts for once gnolang/gnonative#231
is released, verified end to end against a local build of it: the map shrinks to
the entries naming a boards2 screen and the connectivity regex goes away.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
Three things wrong in the same band at the top of the sign-in screen, measured
from screenshots rather than judged by eye.

`HomeLayout` renders its header outside the view that paints the page and gives
it no background, so the bare window colour showed through: #F2F2F2 in the
header against #FDFDFD below, a visible seam. The header now paints
`theme.colors.background` itself, so it stays tied to the theme rather than
being a hardcoded copy of it.

The network icon rendered 55px, about 21dp at 420dpi. The touch target was
already fine at 48dp through hitSlop, but as the only thing in the header,
against a 120dp logo, it read small. Now 28dp. The icon set keeps its 24dp
default; only this call site asks for more.

Painting the header lighter then exposed a pre-existing problem: `expo-status-bar`
is a dependency that was never rendered anywhere, and with userInterfaceStyle
automatic the system chose light status bar icons, invisible against every
screen in this light-only app. The darkest pixel in that band was exactly the
background colour, before and after. `StatusBar style="dark"` at the root fixes
it for every screen, not just this one.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
It held the version of describe-error.ts to apply once gnolang/gnonative#231
released. That PR is closed, superseded by #232, which sends the message with the
code instead of shipping a TypeScript map — so the file describes an approach
that no longer exists.

describe-error.ts already goes further than the patch did: it reads the code and
the wording from ErrDetails and keeps only the two overrides that name a screen
of this app.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
gnonative 5.1 sends an ErrCode and default wording with every failure, as
ErrDetails on the error itself. This app was matching `ErrOutOfGas(#211)` out of
the message and carrying a table of thirty sentences to go with it — wording it
had to invent, and which said different things from every other gno app.

The detail only exists while the error object does: Redux Toolkit flattens a
rejection to `{ name, message, stack, code }` before any reducer or middleware
runs, so by the time errorReporter sees it the ConnectError is gone. Serialising
is the last point it can be read, hence createAppAsyncThunk — a createAsyncThunk
with a serializeError that keeps the code (as a string, the only shape RTK
preserves) and gnonative's wording. Every thunk uses it, including ones that
throw while iterating a stream, which a wrapper around the client would miss.

describeError shrinks to what only this app can say: two codes that name its
network settings screen. Everything else shows the library's sentence, so
boards2 says what every gno app says for the same failure. A realm's own
rejection is attributed — "The chain replied: …" — rather than spoken in the
app's voice, since a realm chooses those words.

Failures that reach here uncoded still have the bridge's framing on them
("[unknown] stream receive error: unknown: …"), so the stripping stays, now
covering all three bridge contexts and peeling the layers as they nest.
… it fails

Two ways a creation screen ended up lying about what had happened.

**It never stopped waiting.** Declining in the wallet left the submit button on
"Loading" for good. The answer arrives as a deep link into a screen that never
lost focus, so the `navigation` 'focus' listener that clears the flag does not
fire, and no thunk rejects because nothing was signed. useWalletFailure watches
the failure the wallet callback already puts in the store, which is the only
signal that the round trip ended.

**It went back on failure.** `await dispatch(broadcastTxCommit(…))` resolves even
when the thunk rejects, so `router.back()` ran regardless and the catch beside it
was dead code: a thread that was never created closed its screen as if it had
been, taking what the user typed with it. `.unwrap()` makes the rejection reach
the catch, which now keeps the screen and its filled-in form so they can submit
again — the snackbar already says why.
The comments across this branch argue their case at a length that makes them
harder to read than the code they sit above: several restate history that
belongs in a commit message, some spend a paragraph on a decision a sentence
settles, and a few explain the same thing twice.

Keep the facts a reader cannot recover from the code — that a callback scheme is
public, so `state` is required; that presets restore by id so a moved endpoint
reaches whoever chose it; that a snackbar sits at the root because the screen
that started the action may be gone — and drop the narration around them.

No behaviour changes.
@D4ryl00
D4ryl00 requested a review from jefft0 July 29, 2026 12:24
@D4ryl00 D4ryl00 self-assigned this Jul 29, 2026
D4ryl00 added 2 commits August 3, 2026 15:21
signedtx is base64 amino-binary and the standard requires a producer treat it as
opaque and broadcast it unmodified. We were base64-decoding it to a UTF-8 JSON
string and handing that to gnonative.broadcastTxCommit, whose parameter is
signedTxJson — precisely the decode-and-re-encode the rule exists to prevent. A
session key or multisig signature carries fields a generic client drops on that
round trip, producing a well-formed-looking but invalid transaction that fails at
the last step and looks like the wallet's fault.

So the blob is stored exactly as received and POSTed to the chain's RPC as the
single parameter of broadcast_tx_commit, which is what it already is. We never
need to understand the signature at all.

That path also checks the result: a node can accept a transaction that then fails
on-chain, and check_tx and deliver_tx say which. The stream-iteration version
only distinguished "delivered" from "not sent", so an on-chain failure read as
success.

txJsonSigned is renamed signedTx across the screens that read it — the old name
described the amino-JSON it no longer holds.
Two defects, one visible and one that hid it.

**Every failure read "Internal error".** tm2 answers a JSON-RPC error with the
generic -32603 message and puts the actual cause in `error.data` — "Could not
find tx result for hash …", and so on. The error path read `message`, so every
distinct failure came out as the same three words and the diagnosis was thrown
away at the point it was needed. It now reports `data`, falling back to `message`.

**`broadcast_tx_commit` fails for transactions that land.** It holds the request
open until the transaction is in a block, and when that wait fails — a timeout,
a problem with the event subscription it depends on — the RPC returns an error
for a transaction the node already accepted and goes on to commit. Reporting
failure for a transaction that succeeded is the worst answer available, and it is
what the user saw.

Broadcast is now `broadcast_tx_sync`, which returns once the node has accepted
the transaction into its mempool — the part the node can be definitive about, and
what Adena uses against the same endpoints. Acceptance is not landing, so the
hash is then polled through `tx` until it appears in a block. That is the
standard's own guidance — a broadcast result is a hint, and a producer should
confirm on its own RPC — and it restores the "it is on chain" signal that
`_commit` was really being used for, without depending on the machinery that was
failing. An accepted transaction that never appears is reported as exactly that,
rather than as a failure: it may still land.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant