Skip to content

Commit dc01d96

Browse files
ci(release): publish latest release
1 parent 1284450 commit dc01d96

15 files changed

Lines changed: 744 additions & 74 deletions

RELEASE

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
IPFS hash of the deployment:
2-
- CIDv0: `QmcSvSrURT9nXdKL4981wSjoVEtAmvpA9vmwXKY4rgfvqS`
3-
- CIDv1: `bafybeigrt3su45yk54xor7ogqlu3d7ck4rjpknuq6kfnfiyy65at5coevu`
2+
- CIDv0: `QmfUHqPyDETu7D7raa1XynT5U7EXeSFEoSrJLJAMFbW3WX`
3+
- CIDv1: `bafybeih6ro4lrfcs7ret2n7k5xmd2fld7b5xxvl732ckqim2ny2r6hby4a`
44

55
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
66

@@ -10,5 +10,5 @@ You can also access the Uniswap Interface from an IPFS gateway.
1010
Your Uniswap settings are never remembered across different URLs.
1111

1212
IPFS gateways:
13-
- https://bafybeigrt3su45yk54xor7ogqlu3d7ck4rjpknuq6kfnfiyy65at5coevu.ipfs.dweb.link/
14-
- [ipfs://QmcSvSrURT9nXdKL4981wSjoVEtAmvpA9vmwXKY4rgfvqS/](ipfs://QmcSvSrURT9nXdKL4981wSjoVEtAmvpA9vmwXKY4rgfvqS/)
13+
- https://bafybeih6ro4lrfcs7ret2n7k5xmd2fld7b5xxvl732ckqim2ny2r6hby4a.ipfs.dweb.link/
14+
- [ipfs://QmfUHqPyDETu7D7raa1XynT5U7EXeSFEoSrJLJAMFbW3WX/](ipfs://QmfUHqPyDETu7D7raa1XynT5U7EXeSFEoSrJLJAMFbW3WX/)

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
web/5.148.2
1+
web/5.148.3

apps/web/src/connection/wagmiConfig.ts

Lines changed: 33 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
import { getWagmiConnectorV2 } from '@binance/w3w-wagmi-connector-v2'
2-
import { getEntryGatewayUrl } from '@universe/api'
32
import {
43
createObservableTransport,
5-
createRpcConfigResolver,
6-
createUniRpcConfigResolver,
4+
createUniRpcRoutedTransport,
75
createUniRpcTransportFactory,
86
getRpcObserver,
97
} from '@universe/chains'
108
import { isE2eTestEnv, isTestEnv } from '@universe/environment'
11-
import { FeatureFlags, getFeatureFlag } from '@universe/gating'
129
import { UNISWAP_LOGO } from 'ui/src/assets'
1310
import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls'
1411
import { CONNECTION_PROVIDER_IDS } from 'uniswap/src/constants/web3'
1512
import type { getChainInfo } from 'uniswap/src/features/chains/chainInfo'
1613
import { ORDERED_EVM_CHAINS } from 'uniswap/src/features/chains/chainInfo'
1714
import { RPCType } from 'uniswap/src/features/chains/types'
1815
import { isTestnetChain } from 'uniswap/src/features/chains/utils'
19-
import { selectRpcUrl } from 'uniswap/src/features/providers/rpcUrlSelector'
16+
import { defaultResolveRpcConfig } from 'uniswap/src/features/providers/resolveRpcConfig'
2017
import { logger } from 'utilities/src/logger/logger'
2118
import { getNonEmptyArrayOrThrow } from 'utilities/src/primitives/array'
2219
import type { Chain } from 'viem'
@@ -111,16 +108,9 @@ function createWagmiConnectors(params: {
111108
: baseConnectors
112109
}
113110

114-
const webResolveRpcConfig = createRpcConfigResolver({
115-
resolveUniRpcConfig: createUniRpcConfigResolver({
116-
getFeatureFlag: () => getFeatureFlag(FeatureFlags.UniRpcEnabled),
117-
getEntryGatewayUrl,
118-
requestSource: 'uniswap-web',
119-
credentials: 'include',
120-
}),
121-
selectLegacyRpcUrl: selectRpcUrl,
122-
})
123-
111+
// Cookie-session UniRPC transport factory (web's injected session strategy).
112+
// The gating decision lives in the shared `defaultResolveRpcConfig` resolver;
113+
// this only constructs the UniRPC transport once that resolver says to use it.
124114
const buildWebUniRpcTransport = createUniRpcTransportFactory({
125115
session: { type: 'cookies' },
126116
})
@@ -138,40 +128,42 @@ function createWagmiConfig(params: {
138128
chains: getNonEmptyArrayOrThrow(ORDERED_EVM_CHAINS),
139129
connectors,
140130
client({ chain }) {
141-
const rpcConfig = webResolveRpcConfig({ chainId: chain.id, rpcType: RPCType.Public })
142-
// Branch on the explicit `isUniRpc` flag — header presence used to be
143-
// the implicit signal, which would have routed any legacy provider with
144-
// static headers through the UniRPC transport by accident.
145-
if (rpcConfig?.isUniRpc) {
146-
return createClient({
147-
chain,
148-
batch: { multicall: true },
149-
pollingInterval: 12_000,
150-
transport: createObservableTransport({
151-
baseTransportFactory: buildWebUniRpcTransport({
152-
config: { rpcUrl: rpcConfig.rpcUrl, headers: rpcConfig.headers ?? {} },
153-
}),
154-
observer: getRpcObserver(),
155-
meta: { chainId: chain.id, url: rpcConfig.rpcUrl },
156-
}),
157-
})
158-
}
159-
131+
// wagmi builds this client once per chain and caches it for the session,
132+
// so the UniRPC-vs-legacy choice must NOT be snapshotted here: on app
133+
// start the gate behind `isUniRpc` is usually still unresolved (Statsig
134+
// inits async; a cold load has no cached value), and a snapshot would pin
135+
// the chain to the legacy Infura/QuickNode providers for the whole
136+
// session even after the gate turns on. createUniRpcRoutedTransport
137+
// re-reads the shared resolver per request, so the cached client
138+
// self-heals onto UniRPC the moment the gate resolves — same guarantee
139+
// ViemClientManager gets by re-resolving per `getViemClient` call.
160140
return createClient({
161141
chain,
162142
batch: { multicall: true },
163143
pollingInterval: 12_000,
164-
transport: fallback(
165-
orderedTransportUrls(chain).map((url) =>
144+
transport: createUniRpcRoutedTransport({
145+
resolveRpcConfig: () => defaultResolveRpcConfig({ chainId: chain.id, rpcType: RPCType.Public }),
146+
buildUniRpcTransport: (rpcConfig) =>
166147
createObservableTransport({
167-
baseTransportFactory: http(url, {
168-
onFetchResponse: (response) => onFetchResponse(response, chain, url),
148+
baseTransportFactory: buildWebUniRpcTransport({
149+
config: { rpcUrl: rpcConfig.rpcUrl, headers: rpcConfig.headers ?? {} },
169150
}),
170151
observer: getRpcObserver(),
171-
meta: { chainId: chain.id, url },
152+
meta: { chainId: chain.id, url: rpcConfig.rpcUrl },
172153
}),
173-
),
174-
),
154+
buildLegacyTransport: () =>
155+
fallback(
156+
orderedTransportUrls(chain).map((url) =>
157+
createObservableTransport({
158+
baseTransportFactory: http(url, {
159+
onFetchResponse: (response) => onFetchResponse(response, chain, url),
160+
}),
161+
observer: getRpcObserver(),
162+
meta: { chainId: chain.id, url },
163+
}),
164+
),
165+
),
166+
}),
175167
})
176168
},
177169
})

apps/web/src/constants/providers.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,20 @@ const createProvider = createEthersProviderFactory({ resolveRpcConfig: defaultRe
2121
* `<StatsigProviderWrapper>` mounts and triggers `StatsigClient.instance(key)`
2222
* to create a no-options client; the React provider's `useClientAsyncInit`
2323
* then reuses that broken instance and silently drops `networkConfig.api`,
24-
* `overrideAdapter`, and `environment.tier`. The cache below preserves the
25-
* "captured at first call, not rebuilt mid-session" semantics.
24+
* `overrideAdapter`, and `environment.tier`. If that first call still beats
25+
* Statsig init the chain resolves to legacy — so the cache rebuilds once on the
26+
* legacy→UniRPC transition (see `getRpcProvider`) rather than pinning the chain
27+
* to legacy for the whole session. ViemClientManager solves the same staleness
28+
* by re-resolving per call; ethers providers are stateful (polling/listeners),
29+
* so we rebuild-on-transition instead.
2630
*/
27-
function buildAppProvider(chainId: EVMUniverseChainId): ethersProviders.JsonRpcProvider {
31+
interface CachedProvider {
32+
provider: ethersProviders.JsonRpcProvider
33+
/** Whether `provider` is the UniRPC provider (vs the legacy multi-URL fallback). */
34+
isUniRpc: boolean
35+
}
36+
37+
function buildAppProvider(chainId: EVMUniverseChainId): CachedProvider {
2838
// Prefer UniRPC routing; fall through to the legacy multi-URL provider on
2939
// either branch — UniRPC inactive OR factory construction returned null
3040
// (createEthersProviderFactory swallows internal exceptions and returns
@@ -34,32 +44,45 @@ function buildAppProvider(chainId: EVMUniverseChainId): ethersProviders.JsonRpcP
3444
if (rpcConfig?.isUniRpc) {
3545
const provider = createProvider({ chainId, rpcType: RPCType.Public })
3646
if (provider) {
37-
return provider
47+
return { provider, isUniRpc: true }
3848
}
3949
}
4050

4151
const info = getChainInfo(chainId)
42-
return new AppJsonRpcProvider(
43-
info.rpcUrls.interface.http.map(
44-
(url) => new ConfiguredJsonRpcProvider({ url, networkish: { chainId, name: info.interfaceName } }),
52+
return {
53+
provider: new AppJsonRpcProvider(
54+
info.rpcUrls.interface.http.map(
55+
(url) => new ConfiguredJsonRpcProvider({ url, networkish: { chainId, name: info.interfaceName } }),
56+
),
4557
),
46-
)
58+
isUniRpc: false,
59+
}
4760
}
4861

49-
const providerCache = new Map<EVMUniverseChainId, ethersProviders.JsonRpcProvider>()
62+
const providerCache = new Map<EVMUniverseChainId, CachedProvider>()
5063

5164
/**
52-
* Returns the singleton interface RPC provider for `chainId`. Constructs on
53-
* first call, then caches. Use this everywhere instead of building providers
54-
* ad-hoc — it's the only entry point that respects the UniRPC gate.
65+
* Returns the singleton interface RPC provider for `chainId`. Use this
66+
* everywhere instead of building providers ad-hoc — it's the only entry point
67+
* that respects the UniRPC gate.
68+
*
69+
* Constructs on first call, then caches. The one exception: if the cached
70+
* provider is the legacy fallback (built before the UniRPC gate resolved) and
71+
* the gate has since turned on, it rebuilds once onto UniRPC and sticks — so a
72+
* first call that beat Statsig init doesn't pin the chain to legacy for the
73+
* session.
5574
*/
5675
export function getRpcProvider(chainId: EVMUniverseChainId): ethersProviders.JsonRpcProvider {
57-
let provider = providerCache.get(chainId)
58-
if (!provider) {
59-
provider = buildAppProvider(chainId)
60-
providerCache.set(chainId, provider)
76+
const cached = providerCache.get(chainId)
77+
if (cached) {
78+
// Already on UniRPC, or still legacy and the gate is still off → reuse.
79+
if (cached.isUniRpc || !defaultResolveRpcConfig({ chainId, rpcType: RPCType.Public })?.isUniRpc) {
80+
return cached.provider
81+
}
6182
}
62-
return provider
83+
const built = buildAppProvider(chainId)
84+
providerCache.set(chainId, built)
85+
return built.provider
6386
}
6487

6588
export function getInterfaceProvider(

packages/chains/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export { zeroAddress } from './createZeroAddress'
5353
// Observability
5454
export { InstrumentedJsonRpcProvider } from './rpc/observability/InstrumentedJsonRpcProvider'
5555
export { createObservableTransport } from './rpc/observability/createObservableTransport'
56+
export { createUniRpcRoutedTransport } from './rpc/createUniRpcRoutedTransport'
5657
export { extractProviderName } from './rpc/observability/extractProviderName'
5758
export {
5859
noopObserver,

packages/chains/src/rpc/__tests__/observabilityInvariant.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,77 @@ describe('observability invariant — error message cardinality is bounded', ()
270270
expect(errorCtx.error.message).not.toContain('id":99')
271271
})
272272
})
273+
274+
// ─────────────────────────────────────────────────────────────────────────
275+
// Status + JSON-RPC code must reach the observer as structured fields on every
276+
// path — that's what makes "401 bot-block vs 5xx outage vs JSON-RPC error"
277+
// answerable in Datadog instead of buried in a free-text message.
278+
// ─────────────────────────────────────────────────────────────────────────
279+
describe('observability invariant — structured error fields (httpStatus + rpcErrorCode)', () => {
280+
test('viem: non-OK HTTP surfaces ctx.httpStatus (401 — not retried by viem)', async () => {
281+
;(globalThis.fetch as Mock).mockReset()
282+
;(globalThis.fetch as Mock).mockResolvedValue(
283+
new Response(JSON.stringify({ message: 'unauthorized' }), {
284+
status: 401,
285+
headers: { 'content-type': 'application/json' },
286+
}),
287+
)
288+
289+
const factory = buildViemFactory({
290+
rpcUrl: 'https://gateway/rpc/1',
291+
isUniRpc: true,
292+
headers: {},
293+
credentials: 'include',
294+
})
295+
const client = factory({ chainId: CHAIN_ID, rpcType: RPCType.Public })!
296+
297+
await expect(client.getBlockNumber()).rejects.toThrow()
298+
299+
const ctxs = observer.onError.mock.calls.map((call) => call[0] as { httpStatus?: number })
300+
expect(ctxs.some((c) => c.httpStatus === 401)).toBe(true)
301+
})
302+
303+
test('ethers Web3Provider: non-OK HTTP surfaces ctx.httpStatus', async () => {
304+
;(globalThis.fetch as Mock).mockResolvedValueOnce(
305+
new Response(JSON.stringify({ message: 'Forbidden' }), {
306+
status: 403,
307+
headers: { 'content-type': 'application/json' },
308+
}),
309+
)
310+
311+
const factory = buildEthersFactory({
312+
rpcUrl: 'https://gateway/rpc/1',
313+
isUniRpc: true,
314+
headers: {},
315+
getRequestHeaders: async () => ({}),
316+
})
317+
const provider = factory({ chainId: CHAIN_ID, rpcType: RPCType.Public })!
318+
319+
await expect(provider.send('eth_blockNumber', [])).rejects.toThrow()
320+
321+
const ctx = observer.onError.mock.calls[0]?.[0] as { httpStatus?: number }
322+
expect(ctx.httpStatus).toBe(403)
323+
})
324+
325+
test('ethers Web3Provider: JSON-RPC error surfaces ctx.rpcErrorCode', async () => {
326+
;(globalThis.fetch as Mock).mockResolvedValueOnce(
327+
new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'execution reverted' } }), {
328+
status: 200,
329+
headers: { 'content-type': 'application/json' },
330+
}),
331+
)
332+
333+
const factory = buildEthersFactory({
334+
rpcUrl: 'https://gateway/rpc/1',
335+
isUniRpc: true,
336+
headers: {},
337+
getRequestHeaders: async () => ({}),
338+
})
339+
const provider = factory({ chainId: CHAIN_ID, rpcType: RPCType.Public })!
340+
341+
await expect(provider.send('eth_blockNumber', [])).rejects.toThrow('execution reverted')
342+
343+
const ctx = observer.onError.mock.calls[0]?.[0] as { rpcErrorCode?: number }
344+
expect(ctx.rpcErrorCode).toBe(-32000)
345+
})
346+
})

packages/chains/src/rpc/createEthersProvider.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { providers as ethersProviders } from 'ethers/lib/ethers'
22
import { logger } from 'utilities/src/logger/logger'
33
import { SignerInfo } from './FlashbotsCommon'
44
import { FlashbotsRpcProvider } from './FlashbotsRpcProvider'
5+
import { extractRpcErrorMeta } from './observability/extractRpcErrorMeta'
56
import { InstrumentedJsonRpcProvider } from './observability/InstrumentedJsonRpcProvider'
67
import { normalizeRpcError } from './observability/normalizeRpcError'
78
import { generateRequestId, getRpcObserver, type RpcObserver } from './observability/rpcObserver'
@@ -70,7 +71,15 @@ function createJsonRpcFetchFunc(config: {
7071
})
7172

7273
if (!response.ok) {
73-
throw new Error(`RPC request failed: ${response.status} ${response.statusText}`)
74+
// Carry the status as a structured field, not just in the message:
75+
// on React Native `response.statusText` is empty, so the message alone
76+
// ("RPC request failed: 403 ") isn't reliably parseable. extractRpcErrorMeta
77+
// reads `.status` directly (with the message as a fallback).
78+
const httpError = new Error(`RPC request failed: ${response.status} ${response.statusText}`.trim()) as Error & {
79+
status?: number
80+
}
81+
httpError.status = response.status
82+
throw httpError
7483
}
7584

7685
const json = (await response.json()) as {
@@ -87,8 +96,15 @@ function createJsonRpcFetchFunc(config: {
8796
return json.result
8897
} catch (error) {
8998
// Normalize before handing to the observer so the rate limiter's
90-
// bucket-by-message strategy stays effective. Throw the original.
91-
config.observer.onError({ ...ctx, durationMs: performance.now() - start, error: normalizeRpcError(error) })
99+
// bucket-by-message strategy stays effective; extract status/code from the
100+
// raw error (the non-ok branch attaches `.status`, the JSON-RPC branch
101+
// attaches `.code`). Throw the original.
102+
config.observer.onError({
103+
...ctx,
104+
durationMs: performance.now() - start,
105+
error: normalizeRpcError(error),
106+
...extractRpcErrorMeta(error),
107+
})
92108
throw error
93109
}
94110
}

0 commit comments

Comments
 (0)