MetaMask / MetaMask/connect-monorepo
60s invokeMethod timeout silently loses transactions the user approves late
- Dominant language
- TypeScript
- Stars
- 12
- Forks
- 11
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
The problem – `invokeMethod` enforces a hard-coded 60s timeout on user-approval RPCs, aborting and silently discarding pending signature/transaction requests
Every RPC that requires user approval — `eth_sendTransaction`, `personal_sign`, `eth_signTypedData_v4`, and anything else routed through `handleWithWallet` — is aborted by the SDK after 60 seconds, while MetaMask is still showing the confirmation prompt to the user. The dApp gets:
```
RPCErr53: RPC Client invoke method reason (Transport request timed out)
```
60s is far below how long a real user takes to approve: reading the confirmation, switching to the mobile app, or confirming on a hardware wallet through MetaMask all routinely exceed it.
Worse, the abort is not merely cosmetic. Rejecting the promise on the dApp side cancels **only the waiting** — it does not cancel the request inside the wallet. No cancellation message is sent to MetaMask, and the confirmation prompt stays open and fully functional. Meanwhile the SDK has already deleted the request from its pending map, so when the user finally approves, the wallet signs and broadcasts the transaction as normal and the response is discarded on arrival — with no error, no log and no event.
The result is a split-brain state: the transaction is on-chain, and the dApp is reporting it as failed with no way to recover the hash.
| | Wallet | dApp |
| ------------ | -------------------- | ------------------- |
| Transaction | signed and broadcast | reported as failed |
| Hash | known | lost, unrecoverable |
| User's funds | moved | UI shows an error |
There is no public option to change or disable this timeout (details below).
## Impact
- **Wrong failure reported to users**, per the split-brain above. For a staking / DeFi dApp this is the worst failure class there is: the UI contradicts the chain, and we cannot even link the user to the transaction we have just told them failed. Full mechanics in [Why the late approval is unrecoverable](#why-the-late-approval-is-unrecoverable).
- **Affects both transports.** `DefaultTransport` (extension) and `MWPTransport` (mobile deeplink / QR) each apply the same 60s. Mobile is the more likely to breach it, since it includes the app switch.
- **Affects all approval-gated methods**, not just transactions — any method not in `RPC_HANDLED_METHODS`, `SDK_HANDLED_METHODS`, or `EIP1193_PASSTHROUGH_METHODS` falls through to `handleWithWallet`.
- **`wallet_switchEthereumChain` too**, via `sendEip1193Message`, which uses the same 60s budget and rejects with a plain `Error('Request timeout')`.
- Hardware-wallet users behind MetaMask hit this on nearly every signature.
- Analytics are skewed: these are recorded as `mmconnect_wallet_action_failed` with `failure_reason: transport_timeout`, even though nothing failed — the user was simply still deciding.
## Versions
| Package | Version |
| --------------------------------- | -------------------------------------- |
| `@metamask/connect-evm` | 2.1.1 |
| `@metamask/connect-multichain` | 1.2.0 |
| `@metamask/multichain-api-client` | 0.10.1 |
| `wagmi` / `@wagmi/connectors` | 3.7.6 / 8.1.0 (`metaMask()` connector) |
Also reproduced against current `main` by reading the source — the constants are unchanged there (see permalinks below, pinned to `eb0bb5b`).
## Steps to reproduce
1. Connect a dApp to the MetaMask extension via the `metaMask()` wagmi connector (or `createEVMClient` directly).
2. Trigger any signing request, e.g.
```ts
await provider.request({
method: "eth_sendTransaction",
params: [{ from, to, value: "0x0" }],
});
```
3. Leave the MetaMask confirmation open and do **not** approve.
**Expected:** the request stays pending until the user approves or rejects. Only the user (or an explicit dApp-supplied timeout) ends it.
**Actual:** at exactly 60s the promise rejects with `RPCErr53: RPC Client invoke method reason (Transport request timed out)`.
4. Now approve the transaction at ~90s.
**Expected:** the dApp receives the transaction hash.
**Actual:** MetaMask broadcasts the transaction; the dApp receives nothing. The response is discarded because the pending entry was already deleted on timeout.
## Root cause
`RequestRouter.handleWithWallet` calls the transport with **no** request options:
https://github.com/MetaMask/connect-monorepo/blob/eb0bb5ba5ef28671bf885efeff55c49c076614df/packages/connect-multichain/src/multichain/rpc/requestRouter.ts#L200-L205
```ts
const request = this.transport.request({
method: "wallet_invokeMethod",
params: options,
}); // ← no options argument
```
so `DefaultTransport.request` falls back to its default parameter:
https://github.com/MetaMask/connect-monorepo/blob/eb0bb5ba5ef28671bf885efeff55c49c076614df/packages/connect-multichain/src/multichain/transports/default/index.ts#L23
https://github.com/MetaMask/connect-monorepo/blob/eb0bb5ba5ef28671bf885efeff55c49c076614df/packages/connect-multichain/src/multichain/transports/default/index.ts#L319
```ts
const DEFAULT_REQUEST_TIMEOUT = 60 * 1000;
readonly #defaultRequestOptions = { timeout: DEFAULT_REQUEST_TIMEOUT };
async request(request, options = this.#defaultRequestOptions) { ... }
```
`MWPTransport` applies the same 60s to its own requests:
https://github.com/MetaMask/connect-monorepo/blob/eb0bb5ba5ef28671bf885efeff55c49c076614df/packages/connect-multichain/src/multichain/transports/mwp/index.ts#L46-L49
The 60s is then handed to `@metamask/multichain-api-client`, whose transport wraps the request in `withTimeout(..., timeout, () => new TransportTimeoutError())` and, on rejection, deletes the pending entry:
```js
.catch((err) => {
if (pendingRequests.has(id)) pendingRequests.delete(id);
throw err;
})
```
That deletion is what makes the late approval unrecoverable. `TransportTimeoutError` is then wrapped by `#withAnalyticsTracking` → `toRPCInvokeMethodErr` into the `RPCErr53` the dApp sees.
Worth noting: `@metamask/multichain-api-client` sets `DEFAULT_REQUEST_TIMEOUT = -1 // No timeout by default`. `connect-multichain` is deliberately overriding that sane default with 60s, and applying it to requests whose duration is bounded by human decision time rather than by network latency.
## Why the late approval is unrecoverable
This is the part we consider a correctness bug independent of the timeout value, so it is worth spelling out.
The transport keeps in-flight requests in a `Map` keyed by JSON-RPC id, and resolves them by matching the id of an incoming message:
```js
// @metamask/multichain-api-client — windowPostMessageTransport.mjs
function handleMessage(message) {
if (message?.id === null || message?.id === undefined) {
notifyCallbacks(message); // no id => notification
} else if (pendingRequests.has(message.id)) {
const resolve = pendingRequests.get(message.id);
pendingRequests.delete(message.id);
resolve?.(message); // resolve the dApp's promise
}
// no third branch: an id with no pending entry falls through silently
}
```
Timeline of an approval that takes 90 seconds:
| t | What happens |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0s` | dApp calls `eth_sendTransaction`. `pendingRequests.set(N, resolve)`, request posted to the extension. |
| `0s` | MetaMask opens the confirmation. From here the request is owned by the wallet; the dApp has no further control over it. |
| `60s` | `withTimeout` fires. The `.catch` runs `pendingRequests.delete(N)` and rethrows. The dApp surfaces `RPCErr53`. **No cancellation is sent to the wallet.** |
| `60s+` | MetaMask's confirmation is still open and still valid. Nothing about it indicates that the dApp has given up. |
| `90s` | User clicks Confirm. MetaMask signs and broadcasts the transaction. It reaches the mempool and is mined. |
| `90s` | MetaMask posts back `{ id: N, result: '0x' }`. `pendingRequests.has(N)` is now `false`, so `handleMessage` matches neither branch and the message is dropped silently. |
Consequences for a dApp:
- The user is shown an error for a transaction that succeeds.
- The hash cannot be recovered from the SDK by any means — no event is emitted, so the only fallback is scanning the account's on-chain history.
- Real risk of a **double send**: the user sees "failed" and clicks Stake again. The retry gets the next nonce, so both transactions are valid and both are mined — the user stakes twice what they intended.
## Why dApps can't work around it
- `createEVMClient` only accepts `transport.extensionId` — there is no timeout option in `MultichainOptions['transport']`.
- Both transports are constructed with no options anywhere in the SDK, so their defaults always win:
https://github.com/MetaMask/connect-monorepo/blob/eb0bb5ba5ef28671bf885efeff55c49c076614df/packages/connect-multichain/src/multichain/index.ts#L343
https://github.com/MetaMask/connect-monorepo/blob/eb0bb5ba5ef28671bf885efeff55c49c076614df/packages/connect-multichain/src/multichain/index.ts#L356
- `handleWithWallet` never forwards a per-request timeout, so even a hypothetical option on `invokeMethod` would not reach the transport today.
- The public `transport` accessor was removed in 1.0.0, so there is no escape hatch left.
The only remaining option for consumers is patching the package, which is what we are currently considering.
## Suggested fix
1. **Do not apply a client-side timeout to approval-gated requests.** A request whose duration is bounded by a human decision should not have a network-style deadline. `handleWithWallet` could pass `timeout: -1` (already supported by `withTimeout`) for methods that require user interaction, keeping the 60s for genuinely bounded internal calls like `wallet_getSession`.
2. **At minimum, make it configurable** — e.g. a `transport.requestTimeout` option on `createMultichainClient` / `createEVMClient`, and/or a per-request `timeout` on `invokeMethod`.
3. **Independently, do not discard a request that is still live.** Even if a timeout stays, the pending entry should either be kept so a late wallet response can still resolve it, or the SDK should surface the late response through an event so the dApp can recover the transaction hash instead of silently losing it. This part is a correctness bug regardless of what the timeout value is.
Contributor guide
Research direction
Start with RequestRouter.handleWithWallet in packages/connect-multichain/src/multichain/rpc/requestRouter.ts, then compare the timeout defaults in transports/default/index.ts and transports/mwp/index.ts. Trace the pending-request cleanup in the multichain API client and define the expected behavior for approval-gated requests, including whether late wallet responses remain recoverable; done means both transports follow that policy without silently losing approved results.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, blockchain
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100