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:
-
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.
-
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
- Dapp using
@metamask/connect-solana@1.2.0 (or @metamask/connect-evm with the same underlying multichain client).
- iOS Safari, MetaMask iOS app installed.
- Tap a button whose
onClick calls the Wallet Standard connect() feature.
- 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.
- 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:
-
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.
-
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.
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 tohttps://metamask.ioinstead of handing off to the MetaMask iOS app via themetamask://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/writeContractafter 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 asetTimeoutcallback fires), the gesture token is lost — Safari treats thewindow.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:Warm transport — schedules the open on a 250 ms
setTimeout:The 250 ms delay alone is enough to exit Safari's gesture-token window on every call.
Cold transport — registers a one-shot listener and waits for the relay's
session_requestbefore opening:On a cold WS/TLS the awaited
transport.connect()plus thesession_requestround-trip routinely takes 300-800 ms.openDeeplink(@metamask/connect-multichain/dist/src/multichain/utils/index.js, ~line 67) resolves towindow.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
@metamask/connect-solana@1.2.0(or@metamask/connect-evmwith the same underlying multichain client).onClickcalls the Wallet Standardconnect()feature.https://metamask.ioin the current tab instead of opening the MetaMask app.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:
Pre-allocate the session on
createSolanaClientso the connect URL is known without a relay round-trip. The dapp then calls a syncconnector.getConnectUrl()and doeswindow.location.href = urlinside its ownonClick. The existing async handshake continues in parallel; the MetaMask app, now foregrounded, completes the session via the relay.Have
connect()return the URL synchronously before awaiting, e.g. as a callback option or via the existingmobile.preferredOpenLinkhook — but invoked before the handshake, with the URL ready. TodaypreferredOpenLinkis only called inside thesession_requestlistener, 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.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.