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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions