Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,34 @@

// 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
// alive: an endpoint can serve everyone and still be down, or be up and refuse
// 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
Expand Down
4 changes: 2 additions & 2 deletions src/js/gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/GasEstimator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 })),
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/gate.checkAccessQuorum.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading