Skip to content

feat(gnoweb): GnoConnect external-wallet transport — registry + chooser + launch-link#5970

Open
D4ryl00 wants to merge 12 commits into
gnolang:masterfrom
D4ryl00:feat/gnoconnect-external-wallets
Open

feat(gnoweb): GnoConnect external-wallet transport — registry + chooser + launch-link#5970
D4ryl00 wants to merge 12 commits into
gnolang:masterfrom
D4ryl00:feat/gnoconnect-external-wallets

Conversation

@D4ryl00

@D4ryl00 D4ryl00 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

feat(gnoweb): GnoConnect external-wallet transport — registry + chooser + launch-link

Closes the mobile/standalone half of #2799 (see my proposal there:
#2799 (comment)).

Design rationale, alternatives, and the launch-link protocol are documented in
the ADR: gno.land/adr/pr5970_gnoconnect_external_wallets.md. The wire format
itself is specified in docs/resources/gnoconnect.md § Launch Links.

The problem

gnoconnect:* meta + TxLinks (#3609/#3615) already carry the network + tx
intent. The in-page JS draft (registerWallet/getWallets) handles browser
extensions
— wallets that inject JS into the page. Neither reaches a wallet
that can't run code in the page: a mobile app or a standalone desktop signer.
In-page discovery is structurally extension-only (a native app can't announce
itself on window), and a same-domain Execute submit can't hand off to an
external app.

This PR adds the missing transport + discovery path, and it composes with the
in-page layer rather than replacing it.

The solution (and why these are the right calls)

This is an opinionated design. Each decision below is a position I'll defend;
push back with a concrete counter-case, not a preference.

1. Launch link, host tx. gnoweb opens the wallet with:

<scheme>://tx?path=<pkgPath>&func=<fn>&arg.<name>=<value>&send=<coins>&rpc=<gnoconnect:rpc>&chainid=<gnoconnect:chainid>&callback=<current-page-url>

Named args, like TxLinks (&param=value), but namespaced under arg. so a
realm parameter named func, send, rpc, chainid, or callback can't
collide with the link's own keys. The wallet resolves declaration order via
vm/qdoc before building the MsgCall — no new connectivity requirement,
since it already needs the RPC node for the account sequence and the broadcast.
rpc/chainid come from the existing meta so the wallet targets the right
node (incl. localnet). Host is tx, not call: it names the intent,
leaving room for future hosts (run, sign) under the same scheme. Wallets
should accept call as a silent back-compat alias but emit/document only tx.

2. Args are built client-side from live form values. Not read back from the
address bar (which goes stale between edits and submit). One arg.<name> pair
per parameter, every name and value encodeURIComponentd. Values are read
straight from the inputs at submit time — always fresh, no event-driven state
to go stale.

3. callback = the current page URL. The wallet reopens exactly where the
user was, with status + tx hash appended. Any status/hash left by a
previous round trip is stripped before composing the link, so repeated round
trips don't accumulate stale result params. No new return-page concept, and it
works on localhost.

4. Registry stores the bare scheme; gnoweb composes the prefix. Entry
shape:

{ "name": "Gnokey", "id": "gnokey", "icon": "data:image/svg+xml;base64,…",
  "scheme": "land.gno.gnokey", "platforms": ["ios","android"],
  "install_url": "https://github.com/gnolang/gnokey-mobile" }

Storing "land.gno.gnokey" (not "land.gno.gnokey://tx") keeps the registry
reusable when the standard grows other hosts, and keeps gnoweb the single owner
of link composition. Entries are validated at package init — RFC 3986 scheme
grammar (which can't contain :, so an entry can never smuggle a
javascript: payload into window.location), unique ids/schemes,
data:image/ icons; a bad registry panics. platforms and install_url are
informational for now (no platform filtering or "not installed" auto-fallback
yet).

5. The registry lives in-repo, server-embedded — not a served endpoint.
//go:embed wallets.json, parsed, validated, and pre-marshaled once at package
init, then served into a <script type="application/json"> the controller
reads. This is the decisive call for gnoweb specifically: it renders
offline, needs no fetch, and is cache-safe — exactly what the
localnet requirement demands. A served/on-chain catalog can come later without
changing the entry shape. Wallets register by PR.

6. Selection lives in gnoweb, never the OS. A duplicated custom scheme on
iOS routes to one arbitrary app with no system chooser, so the OS cannot do the
picking. gnoweb merges candidates and shows the chooser (one page-level
<dialog>, populated on demand) — which also makes desktop and mobile behave
identically. The chooser appears whenever at least one wallet is registered —
even for a single one — because it carries a "Continue in browser" action
that performs the native submit: a failed custom-scheme launch is silent, so
without that escape hatch a mobile user without the wallet installed would find
Execute dead-ended. No registered wallet → today's copy-paste TxLink,
untouched.

7. RPC normalization is wallet-side. gnoweb forwards the gnoconnect:rpc
meta verbatim (it may be scheme-less 127.0.0.1:26657 or a full URL). The wallet
assumes http:// when the scheme is missing. Keeping gnoweb dumb here avoids
baking transport assumptions into the provider.

8. Custom schemes over Universal/App Links. Origin-independent, work
offline/localhost, need no AASA/assetlinks/CDN. UL/AL are at most an optional
"not installed" fallback — never the primary path.

9. Additive and fail-safe by construction. preventDefault() fires only
when routing to an external wallet. No registry, in-page provider present, or
desktop-without-QR → fall straight through to the native submit; from the
chooser, "Continue in browser" gets back to it explicitly. The Adena/extension
path is never intercepted. Mobile detection is (pointer: coarse) only
maxTouchPoints would also match touchscreen laptops, where a failed
custom-scheme launch would leave Execute dead.

What's in this PR

  • components/wallets.json + wallet_registry.go (//go:embed, parsed,
    validated, and pre-marshaled at init) + wallet_registry_test.go.
  • HelpData.WalletsJSON, populated in HelpView.
  • views/action.html: serialized registry + one page-level chooser <dialog>
    with "Continue in browser" and Cancel actions.
  • frontend/js/controller-wallet-launch.ts: submit-time param collection,
    launch-link builder (named arg.<name> params), (pointer: coarse) mobile
    gate, fail-open behavior, lazily parsed shared registry.
  • frontend/css/06-blocks.css: .b-wallet-chooser.
  • docs/resources/gnoconnect.md: new "Launch Links" section — the wire format
    wallet authors must implement, in the public standard, not just the ADR.
  • gno.land/adr/pr5970_gnoconnect_external_wallets.md: design rationale and
    alternatives considered.
  • Rebuilt public/ artifacts (controller-wallet-launch.js, main.css).

Validated end-to-end (localhost, no internet)

Full round-trip against a local gnodev, wallet = gnokey-mobile
(companion branch: D4ryl00/gnokey-mobile@feat/deeplink-tx
https://github.com/D4ryl00/gnokey-mobile/tree/feat/deeplink-tx):

  • iOS simulator and Android emulator: Execute → chooser → Gnokey opens,
    prefilled → Sign & Broadcast against local gnodev → callback reopens gnoweb
    with ?status=success&hash=….
  • Multi-arg + arg-ordering exercised via r/demo/profile.SetStringField(field, value) under a scoped session allow-path vm/exec:gno.land/r/demo/profile
    — confirmed on-chain (GetStringField(master,"DisplayName") == "AndroidE2E").

⚠️ That round trip used the original positional args= encoding. The switch
to named arg.<name>= params (post-review, see below) still needs an
end-to-end pass with the updated wallet branch.

Deliberately out of scope (follow-ups, not gaps in the design)

  • QR for desktop→phone (proposal §4). Without it this path is same-device
    only (mobile browser). Clean follow-up; the launch link is already the payload.
  • Merging in-page announced extensions into the same chooser (proposal §3).
    Blocked on Onbloc's registerWallet/getWallets draft; the chooser is built
    to absorb them.
  • gnodev fix (separate PR): contribs/gnodev/setup_web.go assigns
    RemoteHelp = remoteAddr without normalizeRemoteURL, so gnodev-embedded web
    emits a scheme-less gnoconnect:rpc. Harmless with §7, but worth aligning.

The thing I'd still take input on

Registry location — in-repo embed now; served/on-chain catalog later. (The
launch-link shape was the other open question; it's now settled as named
arg.<name> params and specified in docs/resources/gnoconnect.md.)

Post-review updates (2026-07-16)

Applied from an AI-assisted review pass (fixes reviewed and pushed by the
author; ADR added per AGENTS.md):

  • Fix: mobile gate is (pointer: coarse) only — the previous
    maxTouchPoints > 0 clause broke Execute on touchscreen laptops.
  • Fix: callback strips status/hash from the page URL so repeated
    wallet round trips don't accumulate stale result params.
  • Simplify: params/send are read from the DOM at submit time; the
    params:changed subscription (debounced, so potentially stale at submit) and
    its events.d.ts declaration are gone.
  • Simplify: registry marshaled once at package init; unused
    HelpData.Wallets field and per-request marshal dropped.
  • Simplify: one page-level chooser <dialog> instead of one per function;
    registry parsed lazily into a cache shared across controller instances.
  • Docs: comments trimmed; design rationale and the launch-link protocol
    moved into the ADR.

Post-review updates, round 2 (2026-07-16)

Applied from a second review pass (two external findings + follow-ups from the
first pass):

  • Protocol: launch-link args switched from positional args= to named
    arg.<name>= — aligns with TxLink's named encoding (the old PR text claimed
    parity it didn't have), is safe against positional mis-binding by third-party
    producers, and supports partial prefill. The arg. namespace prevents
    collisions with the link's reserved keys. Requires the wallet to resolve
    declaration order via vm/qdoc — free, since it's online for the
    sequence/broadcast anyway. Documented in docs/resources/gnoconnect.md
    § Launch Links.
  • Security: registry entries validated at package init. The old test-only
    check (no "://") missed schemes like javascript:alert(1)//, which would
    execute once concatenated and assigned to window.location. The RFC 3986
    scheme grammar structurally excludes :; also enforces unique ids/schemes
    and data:image/ icons.
  • UX: the chooser is always shown (even for a single wallet) with a
    "Continue in browser" action that performs the native submit — previously a
    mobile user without the wallet installed hit a silent dead end with no way
    back.
  • Pending: gnokey-mobile feat/deeplink-tx still parses positional args;
    it needs the named-params update, then the e2e validation will be re-run.

D4ryl00 added 3 commits July 15, 2026 17:19
Phase B of the GnoConnect external-wallet transport: add a server-side
embedded wallet registry so gnoweb can render a wallet chooser offline,
with no fetch.

- components/wallets.json: registry array, single Gnokey entry (bare
  scheme land.gno.gnokey, self-contained data: URI icon).
- components/wallet_registry.go: Wallet type + //go:embed loader, parsed
  once at init; Wallets() accessor.
- HelpData gains Wallets []Wallet, populated in HelpView.
- Tests cover the embedded registry and HelpView population.
Phase C of the GnoConnect external-wallet transport. On Execute, route to
a native wallet via a deep link on touch devices; otherwise fall through
to today's TxLink navigation unchanged.

- controller-wallet-launch.ts: intercepts the Execute submit per
  b-action-function. Builds <scheme>://tx?path=&func=&args=&send=&rpc=
  &chainid=&callback= from the live form (args in declaration order),
  reading rpc/chainid from the gnoconnect meta tags. Strictly additive:
  no-op (native submit) on desktop, with no registered wallet, or when an
  in-page provider (extension) is present. 1 wallet opens directly; >1
  shows a chooser dialog.
- view_action.go: HelpData.WalletsJSON (template.JS), pre-marshaled once
  in HelpView; attach the wallet-launch controller and embed the registry
  as <script type=application/json> + a hidden chooser <dialog>.
- events.d.ts: declare the params:changed CustomEvent.
- 06-blocks.css: .b-wallet-chooser block.
- Rebuilt public/js + public/main.css artifacts.
- Tests cover WalletsJSON round-trip and verbatim script-tag rendering.
The wallet chooser never intercepted Execute: the controller threw during
construction and the loader swallowed it. Root cause — the article carried
two controllers (data-controller="action-function wallet-launch"), but
BaseController derives its target-attribute prefix from the *entire*
data-controller value. With two names it built the invalid CSS selector
[data-action-function wallet-launch-target=...], so getGlobalTarget threw
in connect() (and degraded action-function on that article too). The
framework assumes one controller per element.

Move data-controller="wallet-launch" onto the <form> (a single-controller
element): the controller now reads the func/pkgpath from the enclosing
article via closest(), scopes params to the form, attaches the submit
listener to the form directly, and scopes the chooser dialog lookups to the
article. Also drop the unused default export (all other controllers use the
named export only) and wire the chooser Cancel button in JS (it lives
outside the form, so setupActions can't reach it via data-action).

Verified end-to-end on the iOS simulator: Execute -> Gnokey opens prefilled
-> sign & broadcast -> counter increments -> callback returns to Safari.
@github-actions github-actions Bot added 📦 ⛰️ gno.land Issues or PRs gno.land package related 🌍 gnoweb Issues & PRs related to gnoweb and render labels Jul 16, 2026
@Gno2D2
Gno2D2 requested review from alexiscolin and gfanton July 16, 2026 10:51
@Gno2D2

Gno2D2 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

🛠 PR Checks Summary

🔴 Changes related to gnoweb must be reviewed by its codeowners

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
  • The pull request description provides enough details
Read More

🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers.

✅ Automated Checks (for Contributors):

🟢 Maintainers must be able to edit this pull request (more info)
🔴 Changes related to gnoweb must be reviewed by its codeowners
🟢 Pending initial approval by a review team member, or review from tech-staff

☑️ Contributor Actions:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Automated Checks
Maintainers must be able to edit this pull request (more info)

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 The pull request was created from a fork (head branch repo: D4ryl00/gno)

Then

🟢 Requirement satisfied
└── 🟢 Maintainer can modify this pull request

Changes related to gnoweb must be reviewed by its codeowners

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 A changed file matches this pattern: ^gno.land/pkg/gnoweb/ (filename: gno.land/pkg/gnoweb/components/view_action.go)

Then

🔴 Requirement not satisfied
└── 🔴 Or
    ├── 🔴 Or
    │   ├── 🔴 And
    │   │   ├── 🔴 Pull request author is user: alexiscolin
    │   │   └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")
    │   └── 🔴 And
    │       ├── 🔴 Pull request author is user: gfanton
    │       └── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
    └── 🔴 And
        ├── 🟢 Not (🔴 Pull request author is user: alexiscolin)
        ├── 🟢 Not (🔴 Pull request author is user: gfanton)
        └── 🔴 Or
            ├── 🔴 This user reviewed pull request: alexiscolin (with state "APPROVED")
            └── 🔴 This user reviewed pull request: gfanton (with state "APPROVED")

Pending initial approval by a review team member, or review from tech-staff

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 Not (🔴 Pull request author is a member of the team: tech-staff)

Then

🟢 Requirement satisfied
└── 🟢 If
    ├── 🟢 Condition
    │   └── 🟢 Or
    │       ├── 🟢 User jefft0 already reviewed PR 5970 with state APPROVED
    │       ├── 🔴 At least 1 user(s) of the team tech-staff reviewed pull request
    │       └── 🔴 This pull request is a draft
    └── 🟢 Then
        └── 🟢 Not (🔴 This label is applied to pull request: review/triage-pending)

Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission
The pull request description provides enough details

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 Not (🔴 Pull request author is a member of the team: core-contributors)
    └── 🟢 Not (🔴 Pull request author is user: dependabot[bot])

Can be checked by

  • team core-contributors

D4ryl00 added 6 commits July 16, 2026 13:50
… ADR

- Gate external-wallet routing on (pointer: coarse) only; maxTouchPoints
  also matches touchscreen laptops, where a failed custom-scheme launch
  left Execute dead.
- Strip status/hash from the callback URL so wallet round trips don't
  accumulate stale result params.
- Read params/send from the DOM at submit time and drop the
  params:changed subscription (debounced dispatch could be stale).
- Marshal the registry once at package init; drop the unused
  HelpData.Wallets field and the per-request marshal fallback.
- Render one page-level chooser dialog instead of one per function;
  parse the registry lazily with a shared cache.
- Trim comments; move design rationale into an ADR that also documents
  the launch-link protocol.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
Same pattern as feature/state/page.go: the value is the repo-embedded
registry marshaled by json.Marshal (HTML-escaped), destined for a
<script type="application/json"> tag.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
Aligns the external-wallet launch link with TxLink's named-argument
encoding, namespaced under arg. so realm parameter names cannot collide
with the link's reserved keys. Documents the launch-link protocol in
docs/resources/gnoconnect.md instead of only the PR ADR.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
The previous test-only check (no "://" substring) missed schemes like
javascript:alert(1)//, which would execute once concatenated into the
launch link and assigned to window.location. Enforce the RFC 3986 scheme
grammar (which cannot contain ":"), unique ids/schemes, and data:image/
icons at init; a bad registry panics.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
…back

A failed custom-scheme launch is silent, so a mobile user without the
wallet installed had no way back to the native submit once a single
registered wallet triggered a direct launch. The chooser now always
appears and carries a "Continue in browser" action that submits the
form natively (form.submit() bypasses submit listeners, so there is no
re-interception).

Also restores action.html's trailing newline and reverts an unrelated
attribute reflow.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
@github-actions github-actions Bot added the 📖 documentation Improvements or additions to documentation label Jul 16, 2026
D4ryl00 added 2 commits July 16, 2026 15:31
Replace the placeholder SVG with the icon from gnolang/gnokey-mobile
(mobile/assets/images/icon.png), downscaled to 96x96 PNG.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
The preflight reset (* { margin: 0 }) zeroed the UA's dialog margin:
auto, pinning the wallet chooser to the top-left corner. Re-assert it
at the element level so any dialog centers correctly.

On top of that, iOS auto-zooms when focusing sub-16px inputs and
showModal() centers in the layout viewport, so the dialog could land
outside the zoomed visual viewport. The controller now re-centers the
open dialog into the visual viewport and caps its size to the visible
area, tracking zoom/scroll while open.

Signed-off-by: D4ryl00 <d4ryl00@gmail.com>
@OddAna

OddAna commented Jul 16, 2026

Copy link
Copy Markdown

Independent local validation report for head 14b1a844aa55c6dd56b82686f3341e3ca6c4f4fe:

  • go test ./gno.land/pkg/gnoweb/..., make generate with a clean generated-artifact diff, and make lint.front all passed locally.
  • Against a local gnodev counter realm, the mobile Execute flow opened the chooser; Cancel and Continue in browser worked; desktop fell through to the native submit; and an injected in-page provider bypassed the chooser.
  • Live form input hello world&x produced the expected encoded launch parameter arg._=hello%20world%26x.
  • At a synthetic 2x mobile page scale, the chooser stayed fully inside the 180x366 visual viewport, with the visual-viewport transform/max-size styles applied.
  • The emitted tcp:// RPC address is accepted by the Gno RPC client transport, which builds an HTTP request URL while dialing the original TCP endpoint.

I also independently confirmed the integration gap already called out in the PR description: the referenced D4ryl00/gnokey-mobile@feat/deeplink-tx branch currently parses only repeated positional args= values on both iOS and Android. It ignores the new arg.<name>= fields, so parameterized calls currently reach the wallet with an empty argument list. The companion branch's existing go test ./... suite passes, but it does not cover named launch parameters.

From this pass, the gnoweb transport/chooser behavior looks good. The remaining end-to-end blocker is updating the wallet parser to collect arg.*, resolve declaration order via vm/qdoc, and then rerun the iOS/Android round trip.

@D4ryl00

D4ryl00 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! You're right about the named args — I forgot to push the fix, now on the companion branch: D4ryl00/gnokey-mobile@7b28cf0c326878b181afb57ce58e5f3fd72440d6 (note: the repo is now private).

@D4ryl00
D4ryl00 marked this pull request as ready for review July 16, 2026 21:01
@Gno2D2 Gno2D2 added the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Jul 16, 2026

@jefft0 jefft0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR resolves part of an issue. See the description. Has tests for this solution. CI checks pass. Ready for core dev review.

@Gno2D2 Gno2D2 removed the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📖 documentation Improvements or additions to documentation 🌍 gnoweb Issues & PRs related to gnoweb and render 📦 ⛰️ gno.land Issues or PRs gno.land package related

Projects

Development

Successfully merging this pull request may close these issues.

4 participants