Skip to content

Commit f2d2539

Browse files
enesozturkclaude
andauthored
fix(controllers): recover Coinbase 4100 unauthorized signing at the provider seam (#5712)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent da65c78 commit f2d2539

5 files changed

Lines changed: 506 additions & 2 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@reown/appkit-utils': patch
3+
'@reown/appkit': patch
4+
'@reown/appkit-cdn': patch
5+
'@reown/appkit-cli': patch
6+
'@reown/appkit-codemod': patch
7+
'@reown/appkit-common': patch
8+
'@reown/appkit-core': patch
9+
'@reown/appkit-experimental': patch
10+
'@reown/appkit-pay': patch
11+
'@reown/appkit-polyfills': patch
12+
'@reown/appkit-scaffold-ui': patch
13+
'@reown/appkit-siwe': patch
14+
'@reown/appkit-siwx': patch
15+
'@reown/appkit-testing': patch
16+
'@reown/appkit-ui': patch
17+
'@reown/appkit-universal-connector': patch
18+
'@reown/appkit-wallet-button': patch
19+
'@reown/appkit-wallet': patch
20+
'@reown/appkit-controllers': patch
21+
'@reown/appkit-adapter-bitcoin': patch
22+
'@reown/appkit-adapter-ethers': patch
23+
'@reown/appkit-adapter-ethers5': patch
24+
'@reown/appkit-adapter-solana': patch
25+
'@reown/appkit-adapter-ton': patch
26+
'@reown/appkit-adapter-tron': patch
27+
'@reown/appkit-adapter-wagmi': patch
28+
---
29+
30+
Recover Coinbase Wallet from the EIP-1193 `4100` ("Must call 'eth_requestAccounts' before other methods") error that could dead-end signing after a session restore.
31+
32+
On an AppKit auto-restore, the Coinbase Wallet SDK provider keeps its accounts but drops its internal authorization — unlike wagmi's own `reconnect`, AppKit's restore reads `eth_accounts` without re-issuing `eth_requestAccounts`. Consumers that call `.request()` directly on the provider (rather than through wagmi's hooks) then failed the first signing RPC with `4100`.
33+
34+
The provider registration seam (`syncProvider`) now wraps Coinbase eip155 providers — keyed on the connector `id`, which is stable across the wagmi, ethers, and ethers5 adapters (the provider "type" is remapped to `'EXTERNAL'` on most paths, so it can't be used to detect Coinbase). A `4100` then triggers a one-shot recovery: a single `eth_requestAccounts` re-authorization, an active-chain re-assert before an `eth_sendTransaction` retry (so the transaction can't broadcast on the wrong network after the handshake resets the SDK's chain), then exactly one retry. Non-`4100` errors, rejected re-auth prompts, and non-Coinbase providers are unaffected. The wrapper is cached per provider instance so consumers keep a stable reference.

packages/appkit/src/client/appkit-base-client.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ import {
7979
ThemeController,
8080
WalletUtil,
8181
WcHelpersUtil,
82-
getPreferredAccountType
82+
getPreferredAccountType,
83+
maybeWrapCoinbaseProvider
8384
} from '@reown/appkit-controllers'
8485
import { setColorTheme, setThemeVariables } from '@reown/appkit-ui'
8586
import {
@@ -1579,7 +1580,15 @@ export abstract class AppKitBaseClient {
15791580
chainNamespace: ChainNamespace
15801581
}) {
15811582
ProviderController.setProviderId(chainNamespace, type)
1582-
ProviderController.setProvider(chainNamespace, provider)
1583+
/*
1584+
* Coinbase eip155 providers can be restored unauthorized and throw EIP-1193
1585+
* 4100 on the first signing RPC — wrap so `.request()` self-heals. Keyed on
1586+
* the connector `id` (stable across adapters/paths), not the remapped type.
1587+
*/
1588+
ProviderController.setProvider(
1589+
chainNamespace,
1590+
maybeWrapCoinbaseProvider({ connectorId: id, chainNamespace, provider })
1591+
)
15831592
ConnectorController.setConnectorId(id, chainNamespace)
15841593
}
15851594

packages/controllers/exports/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export type { ChainControllerState, AccountState } from '../src/controllers/Chai
1313

1414
export { ProviderController } from '../src/controllers/ProviderController.js'
1515
export type { ProviderControllerState } from '../src/controllers/ProviderController.js'
16+
export {
17+
isCoinbaseConnectorId,
18+
isUnauthorizedProviderError,
19+
maybeWrapCoinbaseProvider,
20+
withCoinbaseReauth
21+
} from '../src/utils/ProviderReauthUtil.js'
1622

1723
export { OnRampController } from '../src/controllers/OnRampController.js'
1824
export type { OnRampControllerState, OnRampProvider } from '../src/controllers/OnRampController.js'
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import type { ChainNamespace } from '@reown/appkit-common'
2+
3+
import { ChainController } from '../controllers/ChainController.js'
4+
5+
/**
6+
* Recovers a provider whose wallet session was restored WITHOUT re-authorizing
7+
* the underlying SDK — the Coinbase Wallet SDK case.
8+
*
9+
* On an AppKit auto-restore, the Coinbase provider is read back via
10+
* `eth_accounts` only; `eth_requestAccounts` is never re-issued (unlike wagmi's
11+
* own `reconnect`, which calls the connector's `connect()` →
12+
* `eth_requestAccounts`). The Coinbase SDK keeps the accounts but drops its
13+
* internal authorization, so the first signing RPC on the raw provider throws
14+
* EIP-1193 `4100` ("Must call 'eth_requestAccounts' before other methods").
15+
* Consumers that call `.request()` directly on the provider (e.g. WalletConnect
16+
* Pay) hit this; consumers going through wagmi's hooks do not, because wagmi
17+
* re-authorized first.
18+
*
19+
* The recovery is adapter-agnostic: it wraps the one shared Coinbase provider
20+
* instance that every consumer reads, so both the wagmi and ethers adapters are
21+
* covered, and downstream code keeps no workaround of its own.
22+
*/
23+
24+
/** Marks a provider already wrapped by {@link withCoinbaseReauth}. */
25+
const REAUTH_WRAPPED = Symbol('appkit.coinbaseReauthWrapped')
26+
27+
/** Maximum `cause`-chain depth walked by {@link isUnauthorizedProviderError}. */
28+
const MAX_CAUSE_DEPTH = 5
29+
30+
interface RequestArgs {
31+
method: string
32+
params?: unknown
33+
}
34+
35+
interface Eip1193LikeProvider {
36+
request(args: RequestArgs): Promise<unknown>
37+
}
38+
39+
/** Stable-identity cache: one wrapper per raw provider instance. */
40+
const wrapperCache = new WeakMap<object, object>()
41+
42+
/**
43+
* True when `connectorId` is a Coinbase connector.
44+
*
45+
* The connector id (not the provider "type") is the reliable signal: it is a
46+
* stable `'coinbaseWallet'` / `'coinbaseWalletSDK'` across every adapter and
47+
* registration path, whereas the provider type is remapped to `'EXTERNAL'` by
48+
* `PresetsUtil.ConnectorTypesMap` on most paths. Matched case-insensitively by
49+
* substring to also tolerate the wagmi restore path's uppercased
50+
* `connector.type` (`'COINBASEWALLET'`).
51+
*/
52+
export function isCoinbaseConnectorId(connectorId: string | undefined): boolean {
53+
return typeof connectorId === 'string' && connectorId.toLowerCase().includes('coinbase')
54+
}
55+
56+
/**
57+
* EIP-1193 `4100` unauthorized error, matched by code or by the SDK's
58+
* `eth_requestAccounts` wording, walking the `cause` chain (viem/provider
59+
* wrappers nest the original error) with bounded depth.
60+
*/
61+
export function isUnauthorizedProviderError(err: unknown, depth = 0): boolean {
62+
if (!err || typeof err !== 'object' || depth > MAX_CAUSE_DEPTH) {
63+
return false
64+
}
65+
66+
const candidate = err as { code?: unknown; message?: unknown; cause?: unknown }
67+
68+
if (candidate.code === 4100) {
69+
return true
70+
}
71+
72+
if (typeof candidate.message === 'string' && candidate.message.includes('eth_requestAccounts')) {
73+
return true
74+
}
75+
76+
if (candidate.cause && candidate.cause !== err) {
77+
return isUnauthorizedProviderError(candidate.cause, depth + 1)
78+
}
79+
80+
return false
81+
}
82+
83+
/**
84+
* Re-assert the app's active eip155 chain on the provider. `eth_requestAccounts`
85+
* can reset the Coinbase SDK's active chain (it tends to land back on mainnet),
86+
* so an `eth_sendTransaction` retry must re-pin the chain first — otherwise the
87+
* transaction could broadcast on the wrong network. Failure to switch is
88+
* propagated: better to fail the signing than to send funds on the wrong chain.
89+
*/
90+
async function reassertActiveChain(provider: Eip1193LikeProvider): Promise<void> {
91+
const chainId = ChainController.getActiveCaipNetwork('eip155')?.id
92+
93+
if (typeof chainId !== 'number') {
94+
return
95+
}
96+
97+
await provider.request({
98+
method: 'wallet_switchEthereumChain',
99+
params: [{ chainId: `0x${chainId.toString(16)}` }]
100+
})
101+
}
102+
103+
/**
104+
* Wrap a Coinbase provider so any signing RPC that fails with `4100` triggers a
105+
* one-shot recovery: a single `eth_requestAccounts` re-authorization, an active-
106+
* chain re-assert for state-changing calls, then exactly one retry.
107+
*
108+
* - Non-`4100` failures pass through untouched (no re-auth attempt).
109+
* - A rejected re-auth prompt propagates and classifies as a user rejection.
110+
* - A still-unauthorized retry fails once (no loop) — the recovery calls the raw
111+
* provider, never the wrapper, so it cannot recurse.
112+
*
113+
* Non-`request` members are delegated to the raw provider bound to the raw
114+
* instance, so private class fields and EIP-1193 event emitters keep working.
115+
* The wrapper is cached per raw instance so repeated `setProvider` calls with
116+
* the same provider return a stable reference (no consumer identity churn).
117+
*/
118+
export function withCoinbaseReauth<T extends object>(provider: T): T {
119+
const raw = provider as unknown as Eip1193LikeProvider
120+
121+
if (!provider || typeof raw.request !== 'function') {
122+
return provider
123+
}
124+
125+
if ((provider as Record<PropertyKey, unknown>)[REAUTH_WRAPPED]) {
126+
return provider
127+
}
128+
129+
const cached = wrapperCache.get(provider)
130+
if (cached) {
131+
return cached as T
132+
}
133+
134+
async function request(args: RequestArgs): Promise<unknown> {
135+
try {
136+
return await raw.request(args)
137+
} catch (err) {
138+
if (!isUnauthorizedProviderError(err)) {
139+
throw err
140+
}
141+
142+
// One handshake re-authorizes the same SDK provider instance.
143+
await raw.request({ method: 'eth_requestAccounts' })
144+
145+
if (args.method === 'eth_sendTransaction') {
146+
await reassertActiveChain(raw)
147+
}
148+
149+
return raw.request(args)
150+
}
151+
}
152+
153+
const proxy = new Proxy(provider, {
154+
get(target, prop) {
155+
if (prop === REAUTH_WRAPPED) {
156+
return true
157+
}
158+
159+
if (prop === 'request') {
160+
return request
161+
}
162+
163+
const value = Reflect.get(target, prop, target)
164+
165+
return typeof value === 'function' ? value.bind(target) : value
166+
}
167+
})
168+
169+
wrapperCache.set(provider, proxy)
170+
171+
return proxy
172+
}
173+
174+
/**
175+
* Apply {@link withCoinbaseReauth} to an eip155 Coinbase provider, returning
176+
* every other provider untouched. This is the single decision the provider
177+
* registration seam (`syncProvider`) needs — keeping the guard here (keyed on
178+
* the reliable connector id) rather than in the state setter.
179+
*/
180+
export function maybeWrapCoinbaseProvider<T>(params: {
181+
connectorId: string | undefined
182+
chainNamespace: ChainNamespace | undefined
183+
provider: T
184+
}): T {
185+
const { connectorId, chainNamespace, provider } = params
186+
187+
const shouldWrap =
188+
chainNamespace === 'eip155' &&
189+
isCoinbaseConnectorId(connectorId) &&
190+
typeof provider === 'object' &&
191+
provider !== null
192+
193+
return shouldWrap ? (withCoinbaseReauth(provider as object) as T) : provider
194+
}

0 commit comments

Comments
 (0)