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
26 changes: 16 additions & 10 deletions app/src/main/assets/pombo_bridge.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,15 @@
const BRIDGE_PUBKEY = '__BRIDGE_PUB__';

// Polygon RPCs. The user's preference (Settings → API) is injected by Kotlin;
// when nothing is injected we fall back to the endpoints that answer CORS in
// a WebView (origin https://pombo.local) — some (e.g. meowrpc) don't send the
// headers and spam the console. Kotlin already appends these safe ones after
// the chosen preset, so a CORS-broken pick still falls through to a working
// endpoint instead of killing the connection.
// when nothing is injected we fall back to the default providers
// (RpcEndpoints.DEFAULT_ENABLED), which must all answer CORS in a WebView
// (origin https://pombo.local).
let BRIDGE_RPCS = [];
try { BRIDGE_RPCS = JSON.parse('__BRIDGE_RPCS__'); } catch (e) { /* not injected */ }
const DEFAULT_RPCS = [
'https://polygon.drpc.org',
'https://polygon-bor-rpc.publicnode.com'
'https://poly.api.pocket.network',
'https://matic.rpc.sentio.xyz'
];
const RPCS = (Array.isArray(BRIDGE_RPCS) && BRIDGE_RPCS.length ? BRIDGE_RPCS : DEFAULT_RPCS)
.map(function (url) { return { url: url }; });
Expand Down Expand Up @@ -2436,7 +2435,7 @@
// RPCS is the settings-derived list injected at page build — entries
// are Streamr-shaped ({url}); JsonRpcProvider needs the string or it
// treats the object as a FetchRequest.
const list = (typeof RPCS !== 'undefined' && RPCS && RPCS.length) ? RPCS : [{ url: 'https://polygon-bor-rpc.publicnode.com' }];
const list = (typeof RPCS !== 'undefined' && RPCS && RPCS.length) ? RPCS : DEFAULT_RPCS;
const entry = list[api._gateRpcIndex % list.length];
return typeof entry === 'string' ? entry : entry.url;
},
Expand Down Expand Up @@ -2577,7 +2576,7 @@
async _gateReadAt(url, gate, user) {
try {
const p = new window.ethers.JsonRpcProvider(url, 137, { staticNetwork: true, batchMaxCount: 1 });
return await new window.ethers.Contract(gate, api._GATE_ABI, p).checkAccess(user);
return await api._gateDeadline(new window.ethers.Contract(gate, api._GATE_ABI, p).checkAccess(user));
} catch (e) {
console.warn('gate: quorum read failed: ' + (e && e.message || e));
return null;
Expand All @@ -2593,7 +2592,7 @@
const cacheKey = String(a.gate).toLowerCase() + '|' + String(a.user).toLowerCase();
const cached = api._gateAccessCache.get(cacheKey);
if (cached && Date.now() - cached.at < api._gateAccessTtl(cached.value)) return { access: cached.value };
const list = (typeof RPCS !== 'undefined' && RPCS && RPCS.length) ? RPCS : [{ url: 'https://polygon-bor-rpc.publicnode.com' }];
const list = (typeof RPCS !== 'undefined' && RPCS && RPCS.length) ? RPCS : DEFAULT_RPCS;
const urls = list.map(function (e) { return typeof e === 'string' ? e : e.url; }).filter(Boolean);
if (!urls.length) return { access: false, failed: true };
const gen = api._gateGen(a.gate);
Expand Down Expand Up @@ -2947,7 +2946,14 @@
const value = await fn(args);
Native.result(id, true, utf8ToB64(JSON.stringify(value === undefined ? {} : value)));
}).catch(function (e) {
const msg = (e && e.message) ? e.message : String(e);
let msg = (e && e.message) ? e.message : String(e);
// The SDK's contract-call wrapper keeps only code/reason in its message;
// the FallbackProvider's failed block-number read shows only on the
// error it wraps, and ChainErrors.kt matches this suffix.
const inner = e && e.reason;
if (inner && inner.code === 'INVALID_ARGUMENT' && inner.argument === '%internal') {
msg += ' (argument="%internal")';
}
Native.result(id, false, utf8ToB64(JSON.stringify({ error: msg })));
});
};
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/java/com/pombo/android/core/ChainErrors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ object ChainErrors {
// What the WebView itself says when a request cannot leave.
"failed\\s*to\\s*fetch", "load\\s*failed",
// Bridge-specific transient states the ethers list cannot know.
"client\\s*not\\s*connected", "bridge\\s*reloaded", "Could not reach"
"client\\s*not\\s*connected", "bridge\\s*reloaded", "Could not reach",
// Every RPC failed the block-number read (marked by bridgeCall).
"argument=\"%internal\""
),
Kind(
false,
Expand Down
28 changes: 18 additions & 10 deletions app/src/main/java/com/pombo/android/core/RpcEndpoints.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import org.json.JSONObject
* Polygon RPC endpoints and the user's selection over them — same keys, labels,
* order and storage shape as the web's RPC_ENDPOINTS (config.js).
*
* What is enabled is exactly what gets used, in the order of the list, first
* one preferred. Nothing is appended underneath.
* What is enabled is exactly what gets used. Nothing is appended underneath.
* The order only matters where the app walks the list (gate reads, the first
* two of the access quorum, [GasEstimator]); the Streamr SDK sends each
* request to one of them at random.
*/
object RpcEndpoints {

Expand All @@ -30,15 +32,21 @@ object RpcEndpoints {

val ALL = listOf(
Endpoint("drpc", "dRPC", "https://polygon.drpc.org", true),
Endpoint("publicnode", "PublicNode", "https://polygon-bor-rpc.publicnode.com", true),
Endpoint("pocket", "Pocket Network", "https://poly.api.pocket.network", true),
Endpoint("sentio", "Sentio", "https://matic.rpc.sentio.xyz", true),
Endpoint("blockmachine", "Blockmachine", "https://rpc-polygon.blockmachine.io/", true),
Endpoint("tenderly-community", "Tenderly (Polygon community)", "https://tenderly.rpc.polygon.community", true),
Endpoint("tenderly", "Tenderly", "https://polygon.gateway.tenderly.co", true),
Endpoint("quiknode", "QuickNode (public)", "https://rpc-mainnet.matic.quiknode.pro", true),
Endpoint("publicnode", "PublicNode", "https://polygon-bor-rpc.publicnode.com", true),
Endpoint("1rpc", "1RPC (Privacy)", "https://1rpc.io/matic", true)
)

/** 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. */
val DEFAULT_ENABLED = listOf("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. */
val DEFAULT_ENABLED = listOf("drpc", "pocket", "sentio")

fun byKey(key: String): Endpoint? = ALL.firstOrNull { it.key == key }

Expand Down Expand Up @@ -141,9 +149,9 @@ object RpcEndpoints {
* custom URL. 'auto' stood for every endpoint and a provider key for that
* one alone.
*
* 'custom' keeps the two CORS-safe providers beside it: the bridge used to
* append them underneath every choice, and a custom URL that turns out to
* refuse the WebView origin would otherwise leave a migrated install with
* 'custom' keeps the default providers beside it: the bridge used to
* append its own underneath every choice, and a custom URL that turns out
* to refuse the WebView origin would otherwise leave a migrated install with
* nothing to connect to.
*/
fun fromLegacy(preset: String?, customUrl: String?): Selection {
Expand Down
26 changes: 26 additions & 0 deletions app/src/test/java/com/pombo/android/core/BridgeGateQuorumTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.pombo.android.core

import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* The bridge page runs in the WebView and has no test runner of its own, so
* what is checked here is the shipped asset (same approach as
* BridgeWriteConfirmTest).
*/
class BridgeGateQuorumTest {

private val asset = File("src/main/assets/pombo_bridge.html").readText()

@Test
fun `each access-quorum read is bounded by the gate read deadline`() {
val start = asset.indexOf("async _gateReadAt(")
assertTrue("_gateReadAt is gone from the bridge", start >= 0)
val body = asset.substring(start, asset.indexOf("\n },", start))
assertTrue(
"an endpoint that never answers holds the key responder's decision",
body.contains("api._gateDeadline(")
)
}
}
34 changes: 34 additions & 0 deletions app/src/test/java/com/pombo/android/core/ChainErrorsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.pombo.android.core

import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class ChainErrorsTest {

@Test
fun `a write whose block-number read failed on every RPC is retried`() {
val fromBridge = IllegalStateException(
"Error while executing contract call \"streamRegistry.setMultipleStreamPermissionsForUserIds\", " +
"code=INVALID_ARGUMENT (argument=\"%internal\")"
)
assertTrue(ChainErrors.isTransient(fromBridge))
}

@Test
fun `any other invalid argument still fails at once`() {
val badInput = IllegalStateException(
"Error while executing contract call \"streamRegistry.setMultipleStreamPermissionsForUserIds\", " +
"code=INVALID_ARGUMENT"
)
assertFalse(ChainErrors.isTransient(badInput))
}

@Test
fun `the bridge marks only the wrapped internal block-number failure`() {
val asset = File("src/main/assets/pombo_bridge.html").readText()
assertTrue(asset.contains("inner.code === 'INVALID_ARGUMENT' && inner.argument === '%internal'"))
assertTrue(asset.contains("msg += ' (argument=\"%internal\")'"))
}
}
25 changes: 18 additions & 7 deletions app/src/test/java/com/pombo/android/core/RpcEndpointsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,26 @@ class RpcEndpointsTest {
selection.rows.filter { it.on }.map { it.key }

@Test
fun defaultEnabled_isTheReliableProviders_notOneRpc() {
// Several providers ship enabled so the responder's quorum has members to
// cross-check with; 1RPC (privacy relay) is available but opt-in.
assertTrue("1rpc" in RpcEndpoints.ALL.map { it.key })
assertFalse("1rpc" in RpcEndpoints.DEFAULT_ENABLED)
fun defaultEnabled_isAtMostFour_allUsableFromTheBridge() {
assertTrue(RpcEndpoints.DEFAULT_ENABLED.size in 2..4)
RpcEndpoints.DEFAULT_ENABLED.forEach { assertTrue(RpcEndpoints.byKey(it)!!.webviewSafe) }
assertEquals(
RpcEndpoints.ALL.map { it.key }.filter { it != "1rpc" },
RpcEndpoints.DEFAULT_ENABLED
RpcEndpoints.DEFAULT_ENABLED,
keysOn(RpcEndpoints.normalize(emptyList(), ""))
)
}

@Test
fun savedSelection_staysAsItWasWhenTheDefaultChanges() {
val saved = RpcEndpoints.normalize(
listOf(
RpcEndpoints.Row("drpc", true), RpcEndpoints.Row("publicnode", true),
RpcEndpoints.Row("tenderly", true), RpcEndpoints.Row("1rpc", false)
),
""
)
assertEquals(listOf("drpc", "publicnode", "tenderly"), keysOn(saved))
assertEquals(RpcEndpoints.Row("pocket", false), saved.rows.first { it.key == "pocket" })
}

@Test
Expand Down
Loading