MetaMask / MetaMask/connect-monorepo

iOS Safari opens metamask.io instead of metamask:// on connect() — deeplink fires outside the user-gesture window

Open
#322 4 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
12
Forks
11
PR merge metrics
No merged PRs in 30d

Description

### Affected versions

- `@metamask/connect-multichain@0.15.0`
- `@metamask/connect-solana@1.2.0` (which depends on the above)

Reproduced on current iOS Safari on iPhone.

### Symptom

A dapp calls Wallet Standard `connect()` on the MetaMask Connect connector from inside a user tap handler. **Sometimes** (more often on a cold first-of-day load) Safari navigates the current tab to `https://metamask.io` instead of handing off to the MetaMask iOS app via the `metamask://` custom scheme. Refresh or wait and retry usually gets a successful hand-off; the failure rate is latency-correlated.

Phantom and other wallets on iOS Safari are unaffected (when they have a working universal-link / sync-deeplink path).

A sister bug exists for `signMessage` / `sendCalls` / `writeContract` after a successful connect: #252.

### Root cause

iOS Safari only honors a navigation to a custom scheme (`metamask://…`) when the navigation runs inside the synchronous task that originated from the user's tap. Once any awaited promise resolves first (or a `setTimeout` callback fires), the gesture token is lost — Safari treats the `window.location.href = '…'` assignment as a programmatic navigation and resolves the host (`metamask.io`) in the current tab instead of handing off to the app.

In `_deeplinkConnect` (`@metamask/connect-multichain/dist/src/multichain/index.js`, around lines 700-770) the deeplink is opened on either of two paths, **both of which exit the gesture window**:

1. **Warm transport** — schedules the open on a 250 ms `setTimeout`:

```js
if (this.transport.isConnected()) {
timeout = setTimeout(() => {
this.openSimpleDeeplinkIfNeeded();
}, 250);
}
```

The 250 ms delay alone is enough to exit Safari's gesture-token window on every call.

2. **Cold transport** — registers a one-shot listener and waits for the relay's `session_request` before opening:

```js
this.dappClient.once('session_request', (sessionRequest) => {
const deeplink = this.options.ui.factory.createConnectionDeeplink(connectionRequest);
const universalLink = this.options.ui.factory.createConnectionUniversalLink(connectionRequest);
openDeeplink(this.options, deeplink, universalLink);
});
return this.transport.connect({ scopes, … }).then(resolve)…
```

On a cold WS/TLS the awaited `transport.connect()` plus the `session_request` round-trip routinely takes 300-800 ms.

`openDeeplink` (`@metamask/connect-multichain/dist/src/multichain/utils/index.js`, ~line 67) resolves to `window.location.href = deeplink`. By the time Safari sees that assignment, the gesture is already gone — so the navigation falls back to host resolution.

### Why intermittent

Handshake latency varies. On a warm relay the 250 ms timer can still squeak past Safari on some iOS builds; on a cold relay (first-of-day, slow network, ad-block intercepting endpoints) it spills past the gesture window every time. That matches the "works sometimes, refresh and retry" symptom reported by our end users.

### Reproduction

1. Dapp using `@metamask/connect-solana@1.2.0` (or `@metamask/connect-evm` with the same underlying multichain client).
2. iOS Safari, MetaMask iOS app installed.
3. Tap a button whose `onClick` calls the Wallet Standard `connect()` feature.
4. On cold relay (Settings → Safari → Clear History; first connect of session), observe Safari navigates to `https://metamask.io` in the current tab instead of opening the MetaMask app.
5. Refresh, wait a few seconds, retry — usually succeeds.

### Suggested fix shape

The fundamental constraint is: **on iOS Safari, the `metamask://` navigation must run synchronously inside the user-gesture task.** That means either the SDK opens it sync, or it gives the dapp the URL to open sync.

Either of:

1. **Pre-allocate the session on `createSolanaClient`** so the connect URL is known without a relay round-trip. The dapp then calls a sync `connector.getConnectUrl()` and does `window.location.href = url` inside its own `onClick`. The existing async handshake continues in parallel; the MetaMask app, now foregrounded, completes the session via the relay.

2. **Have `connect()` return the URL synchronously before awaiting**, e.g. as a callback option or via the existing `mobile.preferredOpenLink` hook — but invoked *before* the handshake, with the URL ready. Today `preferredOpenLink` is only called inside the `session_request` listener, so this still misses the gesture window; moving the call earlier would fix it.

### Notes

- `mobile.preferredOpenLink` (around line 743) lets the dapp choose how to open the URL but doesn't help with the gesture-loss timing — by the time it's called, the gesture is already gone.
- A workaround dapps can apply today: gate the MetaMask row in their wallet picker on `registerMetaMaskConnect()` completion, so users can't tap before the SDK has finished initializing. That prevents one cold-start failure mode but **does not** fix the gesture-window race; the relay-handshake path still loses it.

Contributor guide

Open the contributing guide

Research direction

Start with _deeplinkConnect in @metamask/connect-multichain/dist/src/multichain/index.js and openDeeplink in dist/src/multichain/utils/index.js, then trace the warm timer and cold session_request paths. Reproduce the cold and warm flows on iOS Safari; done means the metamask:// navigation occurs within the tap gesture rather than falling back to metamask.io.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, mobile-dev
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.