Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions docs/resources/gnoconnect.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,159 @@ can call `vm/qdoc` to retrieve the remaining fields

TODO ([discussion](https://github.com/gnolang/gno/issues/3283)).

## Launch Links (external wallets)

Launch links hand an intent off to an external wallet — a mobile app or
standalone desktop signer registered under a custom URL scheme — when an
in-page provider is not available. Gnoweb emits them from `$help` Execute; any
producer may author them.

Two hosts are defined: `tx` signs a transaction, `connect` asks for the user's
on-chain identity. Further hosts (`run`, `sign`) may be added under the same
scheme.

**Encoding.** Names and values are percent-encoded:

- Producers MUST percent-encode (`encodeURIComponent`). A literal plus is
`%2B`.
- Wallets MUST accept form-encoded argument values as well: in `arg.<name>`
and `args` values, `+` decodes to a space. Substitute **before**
percent-decoding, so `%2B` still yields a literal `+`.
- Everywhere else — `path`, `func`, `send`, `rpc`, `chainid`, `callback`,
`state`, `signer`, `broadcast` — `+` is a literal plus and is not
substituted.

The leniency is there because `URLSearchParams`, the obvious way to build a
link in a browser, emits `application/x-www-form-urlencoded`, where a space is
`+`. A wallet parsing strictly per RFC 3986 shows the user `testing+board` for
a board they named `testing board`, and signs that. The leniency stops at
argument values because elsewhere a `+` may be data: `state` is often base64,
and rewriting it would break the correlation check it exists for.

### `tx` — review, sign, broadcast

```
<scheme>://tx?path=<pkgPath>&func=<Foo>&arg.<name>=<value>&send=<coins>&rpc=<rpc>&chainid=<chainid>&callback=<url>&state=<token>&signer=<address>&broadcast=<bool>
```

- `<scheme>` is the wallet's registered custom scheme (e.g.
`land.gno.gnokey`). Wallets should accept `call` as a silent back-compat
alias for the `tx` host but emit and document only `tx`.
- Function arguments are named like TxLink arguments, but namespaced under
`arg.` so realm parameter names cannot collide with the link's own reserved
keys (`path`, `func`, `send`, `rpc`, `chainid`, `callback`, `state`,
`signer`, `broadcast`). As with TxLinks, a link may prefill only some
arguments; the wallet resolves parameter order and remaining fields via
`vm/qdoc`.
- `send` (optional) is the coin amount to attach, in `gnokey` coin syntax
(e.g. `1000000ugnot`).
- `rpc` and `chainid` mirror the `gnoconnect:rpc`/`gnoconnect:chainid`
metadata of the emitting page, verbatim. `rpc` may be scheme-less
(`127.0.0.1:26657`); the wallet assumes `http://` when the scheme is
missing.
- `callback` (optional) is the URL the wallet reopens with the result.
- `state` (optional, RECOMMENDED) is an opaque producer-generated token,
echoed verbatim in every callback. A callback scheme is public — anything
installed can open it — so without `state` a producer cannot tell its own
result from one an attacker synthesised. Producers that consume callbacks
should always send one and drop responses that match no outstanding request.
- `signer` (optional) is the address the producer expects to sign, typically
carried over from a prior `connect`. The wallet MUST sign with that account
and MUST NOT silently sign as another identity; if it is unavailable, refuse
and say so rather than substituting a different one.
- `broadcast` (optional, default `true`) selects the mode:
- `true` — the wallet signs **and broadcasts**, returning `hash`.
- `false` — **sign-only**: after the same review, the wallet returns the
signed transaction and does not broadcast. The producer broadcasts on its
own RPC. This suits a dapp that owns its connection to the chain and only
needs a signature.

User review before signing is mandatory in both modes.

Sign-only moves real obligations to the producer, and they are easy to miss:

- **It must be able to broadcast what the wallet signed.** A wallet may sign
with a scheme the producer's client does not know — a session key, a
multisig — and a client that cannot represent that signature will re-encode
the transaction into an invalid one rather than refuse it. The failure
surfaces at the very last step and looks like the wallet's fault.
- **It owns the errors.** Out-of-gas, a rejected signature, a realm that
refuses the call: all arrive at the producer, about a transaction the wallet
composed, once the wallet's review screen is gone.
- **`status=success` here means _signed_, not _landed_.** Nothing has been
broadcast when the callback fires. A producer that treats it as completion —
the reasonable reading in the default mode — will report success for a
transaction that never reached the chain.

Prefer the default when the producer has no specific reason to broadcast
itself: the wallet built the transaction, resolved the account sequence, and
understands its own signatures, so it is better placed to report what happened.

#### `tx` callback results

The wallet appends to `callback`, preserving any parameters already there:

```
<callback>?status=success&hash=<txhash>&state=<echoed> # broadcast=true
<callback>?status=success&signedtx=<base64>&state=<echoed> # broadcast=false
<callback>?status=cancelled&state=<echoed> # user declined
<callback>?status=error&message=<text>&state=<echoed> # signing/broadcast failed
```

`signedtx` is the signed transaction as amino-JSON, base64-encoded.

`state` is echoed on **every** response, including failures, and is absent when
the request omitted it.

A wallet SHOULD answer every request it accepted — a producer waiting on a
callback cannot see an error surfaced on the user's device, and without a
`cancelled` or `error` response it waits indefinitely.

`hash` is a hint, not proof: the callback scheme is public, so a producer
should confirm the transaction on its own RPC before treating it as landed.

### `connect` — request the user's identity

```
<scheme>://connect?callback=<url>&state=<token>&rpc=<rpc>&chainid=<chainid>
```

Asks the wallet which address the user wants to act as — the sign-in step
before any `tx`. `callback` is **required**: the verb exists only to deliver an
answer, so a request without a usable one is dropped. `state` behaves as for
`tx`. `rpc`/`chainid` (optional) name the network the producer expects; the
wallet may prompt the user to switch before answering.

The wallet MUST ask the user before disclosing anything, and MUST show the
callback's host: a producer's claimed name is self-asserted and unverifiable,
so the destination is the only anti-phishing anchor the user has.

```
<callback>?status=success&address=<bech32>&session=<bech32>&pubkey=<gpub>&chainid=<id>&state=<echoed>
<callback>?status=cancelled&state=<echoed>
<callback>?status=error&message=<code>&state=<echoed>
```

Error codes: `no_active_session`, `network_declined`, `invalid_request`.

The returned identity is **display-level**. It carries no challenge and no
signature, so it proves nothing about control of the address: treat it as the
user stating who they are, not as authentication. Authority comes from the
on-chain `tx` the user reviews and signs. A proof-of-control extension
(challenge + signature) is left for producers with a backend able to verify one.

### Callback URL rules

A wallet opens `callback`, so it MUST constrain it:

- Accept `https:` and custom app schemes, but **reject** schemes dangerous to
open: `javascript:`, `data:`, `file:`, `content:`, `blob:`, `about:`, and
(Android) `intent:`.
- Require an absolute URI with a scheme, no control characters, bounded length.
- On violation for `connect`, drop the request — there is nowhere to answer.
For `tx` the callback is optional, so the wallet MAY still let the user sign,
but MUST make clear that the requesting producer will not be notified.

## Supported Clients

- **Gnoweb** (provider)
Expand Down
147 changes: 147 additions & 0 deletions gno.land/adr/pr5970_gnoconnect_external_wallets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# ADR: GnoConnect External-Wallet Transport (Registry + Chooser + Launch Link)

## Context

`gnoconnect:*` meta tags and TxLinks already carry the network and transaction
intent on gnoweb's `$help` pages. The in-page JS draft
(`registerWallet`/`getWallets`, issue #2799) covers browser extensions — wallets
that inject code into the page. Neither reaches a wallet that cannot run code
in the page: a mobile app or a standalone desktop signer. In-page discovery is
structurally extension-only (a native app cannot announce itself on `window`),
and a same-domain Execute submit cannot hand off to an external app.

This change adds the missing transport and discovery path for external wallets,
composing with the in-page layer rather than replacing it.

## Decision

### Launch link, host `tx`

On Execute, gnoweb opens the wallet with:

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

- Named args, like TxLinks (`&param=value`), but namespaced under `arg.` so a
realm parameter named `func`, `send`, `rpc`, `chainid`, or `callback` cannot
collide with the link's own keys. Every name and value is percent-encoded.
`rpc`/`chainid` come from the existing `gnoconnect:*` meta so the wallet
targets the right node (including localnet).
- Host is `tx`, not `call`: it names the intent and leaves 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`.
- Named args are self-describing: the wallet resolves declaration order via
`vm/qdoc` before building the `MsgCall`. This adds no connectivity
requirement — the wallet already needs the RPC node for the account
sequence and the broadcast — and it protects third-party-authored links
from silent positional mis-binding.
- Args are read live from the form inputs at submit time — never from the
address bar, which goes stale between edits and submit.
- `callback` is the current page URL (minus any `status`/`hash` params left by
a previous wallet round trip, so they don't accumulate). The wallet reopens
exactly where the user was, appending `status` and tx `hash`. Works on
localhost.
- RPC normalization is wallet-side: gnoweb forwards `gnoconnect:rpc` verbatim
(it may be scheme-less like `127.0.0.1:26657`); the wallet assumes `http://`
when the scheme is missing.

### Registry: in-repo, server-embedded

`components/wallets.json` is embedded via `//go:embed`, parsed and re-marshaled
once at package init (marshal, not the raw file, because `json.Marshal`
HTML-escapes), and exposed to the frontend through a
`<script type="application/json">` tag. Entry shape:

```json
{ "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" }
```

- The registry stores the bare scheme (`land.gno.gnokey`, not
`land.gno.gnokey://tx`); gnoweb composes the launch prefix, so the registry
stays reusable when the standard grows other hosts.
- Embedding (vs a served endpoint or on-chain catalog) is decisive for gnoweb:
it renders offline, needs no fetch, and is cache-safe — the localnet
requirement. A served/on-chain catalog can come later without changing the
entry shape. Wallets register by PR.
- `platforms` and `install_url` are informational for now: no platform
filtering and no "not installed" fallback yet.
- Entries are validated at package init — RFC 3986 scheme grammar (which
cannot contain `:`, so a registry entry can never smuggle a `javascript:`
payload into `window.location`), unique ids and schemes, `data:image/`
icons. A bad registry is an authoring error and panics.

### Selection: gnoweb, never the OS

A duplicated custom scheme on iOS routes to one arbitrary app with no system
chooser, so the OS cannot pick. gnoweb merges candidates and shows its own
chooser `<dialog>`, which also makes desktop and mobile behave identically.
The chooser appears whenever at least one wallet is registered — even for a
single wallet — 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.

### Additive and fail-safe by construction

`preventDefault()` fires only when actually routing to an external wallet.
The controller falls through to the native submit when: an in-page provider
(extension) is present, the device is not mobile, or the registry is
empty/malformed. Mobile detection is `(pointer: coarse)` only —
`maxTouchPoints` would also match touchscreen laptops, where a failed
custom-scheme launch would leave Execute dead.

### 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.

## Alternatives considered

- **OS-level chooser** — impossible: iOS resolves duplicate schemes to one app.
- **Served or on-chain registry** — breaks offline/localnet rendering; can be
added later behind the same entry shape.
- **Universal/App Links as primary transport** — origin-dependent, needs CDN
infrastructure, fails on localhost.
- **Reading args back from the TxLink URL** — stale between input edits and
submit; live form values are authoritative.
- **Subscribing to `params:changed` events for arg state** — rejected during
review: reading the DOM at submit time is always fresh and reads the same
inputs, so the event subscription was pure duplication.
- **Positional repeated `args=` values** — works without `vm/qdoc`, but any
producer whose ordering diverges from declaration order silently binds
values to the wrong parameters, and it cannot express partial prefill.
Rejected during review; the wallet is online at signing time anyway.
- **Flat TxLink-style names (no `arg.` prefix)** — a realm parameter named
`func`, `send`, `rpc`, `chainid`, or `callback` would collide with the
link's own keys.

## Consequences

- Mobile users with a registered wallet get a native sign-and-broadcast flow;
everyone else sees no behavior change.
- Wallet authors must implement the `<scheme>://tx?...` link format above,
documented in the GnoConnect standard (`docs/resources/gnoconnect.md`,
"Launch Links").
- The registry gates wallet listing on gnoweb PRs until a served/on-chain
catalog exists.
- Follow-ups (not gaps): QR for desktop→phone (the launch link is already the
payload), merging in-page announced extensions into the same chooser
(blocked on the `registerWallet`/`getWallets` draft), and normalizing
gnodev's scheme-less `gnoconnect:rpc` meta (separate PR).

## Validation

Full round trip validated against a local `gnodev` with gnokey-mobile
(`feat/deeplink-tx` branch) on the iOS simulator and Android emulator:
Execute → chooser → wallet opens prefilled → sign & broadcast → `callback`
reopens gnoweb with `?status=success&hash=…`. Multi-arg ordering exercised via
`r/demo/profile.SetStringField(field, value)` under a scoped session
allow-path, confirmed on-chain.

That round trip used the original positional `args=` encoding; the switch to
named `arg.<name>=` params (post-review) still needs an end-to-end pass with
the updated wallet branch.
5 changes: 5 additions & 0 deletions gno.land/pkg/gnoweb/components/view_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type HelpData struct {
Doc Component
Domain string
Origin string // request scheme+host; makes help URLs shareable

// External-wallet registry for the GnoConnect chooser; set by HelpView.
WalletsJSON template.JS
}

type HelpTocData struct {
Expand Down Expand Up @@ -105,6 +108,8 @@ func registerHelpFuncs(funcs template.FuncMap) {
}

func HelpView(data HelpData) *View {
data.WalletsJSON = WalletsJSON()

tocData := HelpTocData{
Icon: "code",
Items: make([]HelpTocItem, len(data.Functions)),
Expand Down
19 changes: 17 additions & 2 deletions gno.land/pkg/gnoweb/components/views/action.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ <h2 class="b-content-h2" id="overview">
<h2 class="b-content-h2">
Function{{- if gt (len .Functions) 1 }}s{{- end }}
</h2>
{{/* External-wallet registry, consumed by controller-wallet-launch.ts. */}}
<script type="application/json" data-wallet-launch-target="wallet-registry">{{ $data.WalletsJSON }}</script>
<div class="c-stack">
{{ range .Functions }}
<article class="b-action-function" data-controller="action-function" data-action-function-name-value="{{ .Name }}"
Expand Down Expand Up @@ -102,7 +104,7 @@ <h2 id="func-{{ .Name }}">
<div class="c-doc-view description">{{ render . }}</div>
{{ end }}
<form class="params" id="form-{{ .Name }}" method="GET" action="{{ buildHelpURL $data . }}"
data-action-function-target="function-execute">
data-controller="wallet-launch" data-action-function-target="function-execute">
{{- with .Params }}
<h3 class="title">
Param{{- if gt (len .) 1 }}s{{ end }}
Expand Down Expand Up @@ -176,4 +178,17 @@ <h3 class="title">Result</h3>
</article>
{{ end }}
</div>
{{ end }}
{{/* GnoConnect wallet chooser; populated by controller-wallet-launch.ts. */}}
<dialog class="b-wallet-chooser" data-wallet-launch-target="chooser">
<div class="b-wallet-chooser__inner">
<h3 class="b-wallet-chooser__title">Open with a wallet</h3>
<ul class="b-wallet-chooser__list" data-wallet-launch-target="chooser-list"></ul>
<button type="button" class="b-btn b-btn--secondary" data-wallet-launch-target="chooser-browser">
Continue in browser
</button>
<button type="button" class="b-btn b-btn--secondary" data-wallet-launch-target="chooser-cancel">
Cancel
</button>
</div>
</dialog>
{{ end }}
Loading
Loading