Skip to content

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

Description

@alnoki

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:

    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:

    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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions