From 3c2314b8969e945efae6618780e9f41e2f4f9e92 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Mon, 5 Oct 2026 13:57:42 +0100 Subject: [PATCH 1/2] Default to three RPCs that answer contract reads without a key - dRPC, Pocket and Sentio are on; Blockmachine (60 requests/min per IP without a key), Tenderly (community and gateway), QuickNode, PublicNode and 1RPC stay as rows to turn on - A saved selection keeps its choice; the new rows arrive off Co-authored-by: Claude Opus 5.5 --- src/js/config.js | 21 ++++++++++++++------- tests/unit/GasEstimator.test.js | 4 ++-- tests/unit/config.test.js | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/js/config.js b/src/js/config.js index ed7f977..dae28a5 100644 --- a/src/js/config.js +++ b/src/js/config.js @@ -5,8 +5,9 @@ // Polygon RPC endpoints, in the default order of preference. The user's // selection in Settings is an ordered subset of these plus an optional custom -// URL: what is enabled is exactly what gets used, and the first one is the -// preferred one. +// URL: what is enabled is exactly what gets used. The order only matters where +// the app walks the list (gate reads, the first two of the access quorum, gas +// estimation); the Streamr SDK sends each request to one of them at random. // // `webviewSafe` records whether the endpoint answers CORS from the Android // bridge's https://pombo.local origin. That property is independent from being @@ -14,18 +15,24 @@ // the origin. Only the Android client is constrained by it. export const RPC_ENDPOINTS = [ { key: 'drpc', name: 'dRPC', url: 'https://polygon.drpc.org', webviewSafe: true }, - { key: 'publicnode', name: 'PublicNode', url: 'https://polygon-bor-rpc.publicnode.com', webviewSafe: true }, + { key: 'pocket', name: 'Pocket Network', url: 'https://poly.api.pocket.network', webviewSafe: true }, + { key: 'sentio', name: 'Sentio', url: 'https://matic.rpc.sentio.xyz', webviewSafe: true }, + { key: 'blockmachine', name: 'Blockmachine', url: 'https://rpc-polygon.blockmachine.io/', webviewSafe: true }, + { key: 'tenderly-community', name: 'Tenderly (Polygon community)', url: 'https://tenderly.rpc.polygon.community', webviewSafe: true }, { key: 'tenderly', name: 'Tenderly', url: 'https://polygon.gateway.tenderly.co', webviewSafe: true }, + { key: 'quiknode', name: 'QuickNode (public)', url: 'https://rpc-mainnet.matic.quiknode.pro', webviewSafe: true }, + { key: 'publicnode', name: 'PublicNode', url: 'https://polygon-bor-rpc.publicnode.com', webviewSafe: true }, { key: '1rpc', name: '1RPC (Privacy)', url: 'https://1rpc.io/matic', webviewSafe: true } ]; /** The custom URL is a row of its own, orderable and toggleable like the rest. */ export const RPC_CUSTOM_KEY = 'custom'; -/** On by default: the reliable providers, several so a lone one going down is - * not an outage and access reads cross-check. 1RPC (privacy relay) stays a row - * users can turn on, but its latency and outages hurt as a default. */ -export const RPC_DEFAULT_ENABLED = ['drpc', 'publicnode', 'tenderly']; +/** On by default: providers that answer contract reads reliably without a key + * and from both client origins. At most four: the SDK gives each request to + * one of them at random, so a degraded one fails its share of calls, and every + * client start waits for all of them. */ +export const RPC_DEFAULT_ENABLED = ['drpc', 'pocket', 'sentio']; export const CONFIG = { // Polygon Network Configuration diff --git a/tests/unit/GasEstimator.test.js b/tests/unit/GasEstimator.test.js index 8fbbcca..3af3c8d 100644 --- a/tests/unit/GasEstimator.test.js +++ b/tests/unit/GasEstimator.test.js @@ -112,8 +112,8 @@ describe('GasEstimator', () => { it('should have polygon/matic RPC URLs', () => { GasEstimator.RPC_URLS.forEach(url => { - // URLs can contain 'polygon' or 'matic' (both refer to Polygon network) - const isPolygonUrl = url.includes('polygon') || url.includes('matic'); + // URLs can contain 'polygon', 'matic' or Pocket's 'poly.' (all refer to Polygon network) + const isPolygonUrl = url.includes('polygon') || url.includes('matic') || url.includes('//poly.'); expect(isPolygonUrl).toBe(true); }); }); diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index 88fd103..8533dc3 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -154,6 +154,12 @@ describe('config', () => { const keys = RPC_ENDPOINTS.map(e => e.key); RPC_DEFAULT_ENABLED.forEach(k => expect(keys).toContain(k)); }); + + it('should enable at most four endpoints by default, all usable from the Android bridge', () => { + expect(RPC_DEFAULT_ENABLED.length).toBeLessThanOrEqual(4); + RPC_DEFAULT_ENABLED.forEach(k => + expect(RPC_ENDPOINTS.find(e => e.key === k).webviewSafe).toBe(true)); + }); }); describe('getRpcEndpoints with localStorage', () => { @@ -263,6 +269,20 @@ describe('config', () => { sel.rows.slice(1).forEach(r => expect(r.on).toBe(false)); }); + it('should leave a saved selection as it was when the default changes', () => { + saveRpcSelection({ + rows: [ + { key: 'drpc', on: true }, { key: 'publicnode', on: true }, + { key: 'tenderly', on: true }, { key: '1rpc', on: false } + ], + customUrl: '' + }); + const sel = loadRpcSelection(); + expect(rpcSelectionUrls(sel)).toEqual(['drpc', 'publicnode', 'tenderly'] + .map(k => RPC_ENDPOINTS.find(e => e.key === k).url)); + expect(sel.rows.find(r => r.key === 'pocket')).toEqual({ key: 'pocket', on: false }); + }); + it('should fall back to the default when nothing is enabled', () => { saveRpcSelection({ rows: RPC_ENDPOINTS.map(e => ({ key: e.key, on: false })), From 97472c830ed47d9aee97f8f461789b5311bba235 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Mon, 5 Oct 2026 13:57:42 +0100 Subject: [PATCH 2/2] Bound each access-quorum read by the gate read deadline Co-authored-by: Claude Opus 5.5 --- src/js/gate.js | 4 ++-- tests/unit/gate.checkAccessQuorum.test.js | 24 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/js/gate.js b/src/js/gate.js index 3772fc7..d1afb1a 100644 --- a/src/js/gate.js +++ b/src/js/gate.js @@ -323,11 +323,11 @@ class GateManager { } } - /** One checkAccess read against a specific RPC url. null on any RPC error. */ + /** One checkAccess read against a specific RPC url. null on any RPC error or timeout. */ async _readAccessAt(url, gateAddress, userAddress) { try { const contract = new ethers.Contract(gateAddress, GATE_ABI, this._makeProvider(url)); - return await contract.checkAccess(userAddress); + return await withTimeout(contract.checkAccess(userAddress), 'The network'); } catch (error) { Logger.debug('gate: quorum read failed at', url, '-', error.message); return null; diff --git a/tests/unit/gate.checkAccessQuorum.test.js b/tests/unit/gate.checkAccessQuorum.test.js index 7aa061e..9c85e82 100644 --- a/tests/unit/gate.checkAccessQuorum.test.js +++ b/tests/unit/gate.checkAccessQuorum.test.js @@ -94,6 +94,30 @@ describe('checkAccessQuorum over enabled RPCs', () => { expect(new Set(urlsQueried)).toEqual(new Set(['a', 'b'])); }); + it('an endpoint that never answers costs the read deadline, not the whole decision', async () => { + readSpy.mockRestore(); + const TRUE = '0x' + '0'.repeat(63) + '1'; + vi.spyOn(gateManager, '_makeProvider').mockImplementation((url) => ({ + call: url === 'hangs' ? () => new Promise(() => {}) : async () => TRUE + })); + const savedEthers = globalThis.ethers; + globalThis.ethers = (await import('ethers')).ethers; + vi.useFakeTimers(); + try { + ENABLED = ['hangs', 'answers']; + let result = null; + gateManager.checkAccessQuorum(GATE, USER).then((r) => { result = r; }); + await vi.advanceTimersByTimeAsync(19_000); + expect(result).toBeNull(); + await vi.advanceTimersByTimeAsync(1_000); + expect(result).toEqual({ access: true }); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + globalThis.ethers = savedEthers; + } + }); + it('warn is rate-limited per gate (second unresolved call is silent)', async () => { ENABLED = ['a', 'b']; answers = { a: true, b: false }; const first = await gateManager.checkAccessQuorum(GATE, USER);