From 1440fb603b0ac0b26dc472f92933f48ed1061107 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Mon, 5 Oct 2026 19:03:42 +0100 Subject: [PATCH 1/3] Read a gate's fixed parameters and a token's metadata once per device - The six values PomboGate sets in initialize are kept per gate address, so a later read asks only for price and duration - Token symbol and decimals are kept once both reads succeed, and concurrent first reads share one request Co-authored-by: Claude Opus 5.5 --- app/src/main/assets/pombo_bridge.html | 80 ++++++++++++++++--- .../android/core/BridgeChainReadCacheTest.kt | 37 +++++++++ 2 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt diff --git a/app/src/main/assets/pombo_bridge.html b/app/src/main/assets/pombo_bridge.html index 06fbbf9..33bed28 100644 --- a/app/src/main/assets/pombo_bridge.html +++ b/app/src/main/assets/pombo_bridge.html @@ -2644,6 +2644,33 @@ _gateInfoCache: new Map(), // gate -> { info|null, at }; null = not v3 + // Contract facts that cannot change (a gate's initialize parameters, a + // token's symbol and decimals), kept in this page's localStorage. Public + // chain data, so shared by every account on the device. + _CHAIN_FACTS_KEY: 'pombo_chain_facts', + _CHAIN_FACTS_MAX: 500, + _chainFacts(kind) { + try { + const all = JSON.parse(localStorage.getItem(api._CHAIN_FACTS_KEY) || '{}'); + return (all && all[kind]) || {}; + } catch (e) { + return {}; + } + }, + _rememberChainFact(kind, address, value) { + try { + const all = JSON.parse(localStorage.getItem(api._CHAIN_FACTS_KEY) || '{}') || {}; + const bucket = all[kind] || (all[kind] = {}); + delete bucket[address]; + bucket[address] = value; + const keys = Object.keys(bucket); + for (let i = 0; i < keys.length - api._CHAIN_FACTS_MAX; i++) delete bucket[keys[i]]; + localStorage.setItem(api._CHAIN_FACTS_KEY, JSON.stringify(all)); + } catch (e) { + // Storage full or unavailable: the facts are read from the chain again. + } + }, + // { gate, fresh? } -> { owner, mode, token, minBalance, price, duration, // wireIdentity, wireIdentityName, readOnly }. TTL'd: price/duration are // owner-mutable in v3. A revert means a pre-v3 clone — unsupported by @@ -2656,23 +2683,38 @@ if (cached.info === null) throw new Error('gate is not v3 (unsupported)'); return cached.info; } + // Only price and duration are read once a gate's initialize parameters + // are known: PomboGate sets the other six in initialize and never again. + const known = api._chainFacts('gates')[key]; + const withFixed = function (f, price, duration) { + return { + owner: f.owner, mode: f.mode, token: f.token, minBalance: f.minBalance, + price: String(price), duration: String(duration), + wireIdentity: f.wireIdentity, + wireIdentityName: f.wireIdentity === 1 ? 'sealed' : 'visible', + readOnly: f.readOnly + }; + }; let info; try { info = await api._gateWithProvider(async function () { const gate = api._gateRead(a.gate); + if (known) { + const pd = await Promise.all([gate.price(), gate.duration()]); + return withFixed(known, pd[0], pd[1]); + } const r = await Promise.all([ gate.owner(), gate.mode(), gate.token(), gate.minBalance(), gate.price(), gate.duration(), gate.wireIdentity(), gate.readOnly() ]); - return { + const fixed = { owner: String(r[0]).toLowerCase(), mode: Number(r[1]), token: String(r[2]).toLowerCase(), minBalance: String(r[3]), - price: String(r[4]), duration: String(r[5]), - wireIdentity: Number(r[6]), - wireIdentityName: Number(r[6]) === 1 ? 'sealed' : 'visible', - readOnly: !!r[7] + wireIdentity: Number(r[6]), readOnly: !!r[7] }; + api._rememberChainFact('gates', key, fixed); + return withFixed(fixed, r[4], r[5]); }); } catch (e) { if (e && e.code === 'CALL_EXCEPTION') { @@ -2802,19 +2844,33 @@ // { token } -> { symbol, decimals|null } — null decimals = ERC-721 // collection (the getter does not exist there), render whole units. + _gateTokenMetaPending: new Map(), // token -> Promise, so concurrent first reads share one async gateTokenMeta(a) { const key = String(a.token).toLowerCase(); if (api._gateTokenMetaCache.has(key)) return api._gateTokenMetaCache.get(key); - const meta = await api._gateWithProvider(async function () { + const stored = api._chainFacts('tokens')[key]; + if (stored) { + api._gateTokenMetaCache.set(key, stored); + return stored; + } + if (api._gateTokenMetaPending.has(key)) return api._gateTokenMetaPending.get(key); + const pending = api._gateWithProvider(async function () { const token = api._gateReadToken(a.token); + let readBoth = true; const r = await Promise.all([ - token.symbol().catch(function () { return key.slice(0, 6) + '…' + key.slice(-4); }), - token.decimals().then(function (d) { return Number(d); }).catch(function () { return null; }) + token.symbol().catch(function () { readBoth = false; return key.slice(0, 6) + '…' + key.slice(-4); }), + token.decimals().then(function (d) { return Number(d); }).catch(function () { readBoth = false; return null; }) ]); - return { symbol: r[0], decimals: r[1] }; - }); - api._gateTokenMetaCache.set(key, meta); - return meta; + return { meta: { symbol: r[0], decimals: r[1] }, readBoth: readBoth }; + }).then(function (res) { + api._gateTokenMetaCache.set(key, res.meta); + // A fallback symbol or a missing decimals may be a failed read, not + // the token's answer (an ERC-721 has no decimals), so neither is kept. + if (res.readBoth) api._rememberChainFact('tokens', key, res.meta); + return res.meta; + }).finally(function () { api._gateTokenMetaPending.delete(key); }); + api._gateTokenMetaPending.set(key, pending); + return pending; }, // { token, user } -> { balance } string bigint (ERC-721 = owned count) diff --git a/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt b/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt new file mode 100644 index 0000000..906e09f --- /dev/null +++ b/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt @@ -0,0 +1,37 @@ +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 BridgeChainReadCacheTest { + + private val asset = File("src/main/assets/pombo_bridge.html").readText() + + private fun body(name: String): String { + val start = asset.indexOf("async $name(") + assertTrue("$name is gone from the bridge", start >= 0) + val next = asset.indexOf("\n async ", start + 1) + return asset.substring(start, if (next > 0) next else asset.length) + } + + @Test + fun `a known gate is read for price and duration only`() { + val info = body("gateInfo") + assertTrue(info.contains("api._chainFacts('gates')")) + assertTrue(info.contains("Promise.all([gate.price(), gate.duration()])")) + assertTrue(info.contains("api._rememberChainFact('gates'")) + } + + @Test + fun `token metadata is kept only when both getters answered, and first reads are shared`() { + val meta = body("gateTokenMeta") + assertTrue(meta.contains("api._gateTokenMetaPending")) + assertTrue(meta.contains("if (res.readBoth) api._rememberChainFact('tokens'")) + } +} From 66f854b6122b429e34b9fa3f7373ba288ace1eda Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Mon, 5 Oct 2026 19:03:57 +0100 Subject: [PATCH 2/3] Keep a confirmed "no ENS name" for a day instead of fifteen minutes - A null some provider answered is kept and stored like a name; a lookup no provider answered now fails, so its null keeps the short TTL Co-authored-by: Claude Opus 5.5 --- app/src/main/assets/pombo_bridge.html | 5 ++ .../java/com/pombo/android/ChannelManager.kt | 2 +- .../java/com/pombo/android/core/EnsStore.kt | 17 ++++--- .../android/core/BridgeChainReadCacheTest.kt | 5 ++ .../com/pombo/android/core/EnsStoreTest.kt | 48 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/pombo/android/core/EnsStoreTest.kt diff --git a/app/src/main/assets/pombo_bridge.html b/app/src/main/assets/pombo_bridge.html index 33bed28..cfa79fc 100644 --- a/app/src/main/assets/pombo_bridge.html +++ b/app/src/main/assets/pombo_bridge.html @@ -1773,13 +1773,18 @@ // multiplies the request count until the free tiers answer HTTP 429 // (identity.js _doResolveENS learned this the hard way). var coverUsed = false; + var answered = false; for (var i = 0; i < ensProviders.length; i++) { try { var name = await api._lookupWithDecoys(ensProviders[i], addr, !coverUsed); coverUsed = true; + answered = true; if (name) return { name: name }; } catch (e) { /* try the next provider */ } } + // EnsStore keeps a null for a day only when a provider said so; a + // lookup that no provider answered must fail instead. + if (!answered) throw new Error('no ENS provider answered'); return { name: null }; }, diff --git a/app/src/main/java/com/pombo/android/ChannelManager.kt b/app/src/main/java/com/pombo/android/ChannelManager.kt index 6b0eca7..8a3c3eb 100644 --- a/app/src/main/java/com/pombo/android/ChannelManager.kt +++ b/app/src/main/java/com/pombo/android/ChannelManager.kt @@ -1197,7 +1197,7 @@ class ChannelManager( .optStringOrNull("name") } catch (e: Exception) { Log.w("PomboEns", "resolveEns bridge call failed for $address: ${e.message}") - null + throw e } } Log.d("PomboEns", "name ${address.take(10)}… -> $name") diff --git a/app/src/main/java/com/pombo/android/core/EnsStore.kt b/app/src/main/java/com/pombo/android/core/EnsStore.kt index 21d6e2b..eedbdd8 100644 --- a/app/src/main/java/com/pombo/android/core/EnsStore.kt +++ b/app/src/main/java/com/pombo/android/core/EnsStore.kt @@ -15,14 +15,16 @@ import java.io.File * * - persisted (the web writes `pombo_ens_` / `pombo_ens_avatar_` * to localStorage, so a reload resolves nothing); - * - positive results cached for 24h, negatives for 15 minutes, so a transient - * RPC failure retries soon instead of sticking for the whole session; + * - names, and a "no name" a provider answered, cached for 24h; a lookup no + * provider answered is kept for 15 minutes, so a transient RPC failure + * retries soon instead of sticking for the whole session; * - in-flight de-duplication that SHARES the result: concurrent callers await * the same lookup rather than skipping it. */ class EnsStore(context: Context) { - private class Entry(val value: String?, val at: Long) + /** [confirmed]: a null that a lookup returned, not one a failure left behind. */ + private class Entry(val value: String?, val at: Long, val confirmed: Boolean = false) private val names = HashMap() private val avatars = HashMap() @@ -63,7 +65,7 @@ class EnsStore(context: Context) { // being read, and such an entry carries a newer timestamp than // anything on disk. Each side is compared on its own clock. if (at > (names[addr]?.at ?: Long.MIN_VALUE)) { - names[addr] = Entry(name, at) + names[addr] = Entry(name, at, o.optBoolean("nameConfirmed", false)) if (name != null) loadedNames[addr] = name else clearedNames += addr } if (avatarAt > (avatars[addr]?.at ?: Long.MIN_VALUE)) { @@ -78,7 +80,7 @@ class EnsStore(context: Context) { private fun fresh(entry: Entry?): Boolean { if (entry == null) return false - val ttl = if (entry.value != null) POSITIVE_TTL_MS else NEGATIVE_TTL_MS + val ttl = if (entry.value != null || entry.confirmed) POSITIVE_TTL_MS else NEGATIVE_TTL_MS return System.currentTimeMillis() - entry.at < ttl } @@ -99,7 +101,7 @@ class EnsStore(context: Context) { synchronized(nameInflight) { nameInflight[key] = deferred } return try { val value = lookup() - names[key] = Entry(value, System.currentTimeMillis()) + names[key] = Entry(value, System.currentTimeMillis(), confirmed = true) if (value != null) _resolved.value = _resolved.value + (key to value) persist() deferred.complete(value) @@ -140,7 +142,7 @@ class EnsStore(context: Context) { /** * The web's `ensCache` sync slice: `{ address: { name, timestamp } }`. - * Positive resolutions only — negatives expire in 15 minutes and would + * Positive resolutions only — a "no name" is cheap to re-learn and would * just bloat every payload. */ fun exportSyncSlice(): JSONObject { @@ -182,6 +184,7 @@ class EnsStore(context: Context) { val a = avatars[addr] root.put(addr, JSONObject() .put("name", n?.value ?: JSONObject.NULL) + .put("nameConfirmed", n?.confirmed ?: false) .put("avatar", a?.value ?: JSONObject.NULL) .put("at", n?.at ?: 0L) .put("avatarAt", a?.at ?: 0L)) diff --git a/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt b/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt index 906e09f..72e335a 100644 --- a/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt +++ b/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt @@ -34,4 +34,9 @@ class BridgeChainReadCacheTest { assertTrue(meta.contains("api._gateTokenMetaPending")) assertTrue(meta.contains("if (res.readBoth) api._rememberChainFact('tokens'")) } + + @Test + fun `an ENS lookup no provider answered fails instead of reading as no name`() { + assertTrue(body("resolveEns").contains("if (!answered) throw new Error('no ENS provider answered')")) + } } diff --git a/app/src/test/java/com/pombo/android/core/EnsStoreTest.kt b/app/src/test/java/com/pombo/android/core/EnsStoreTest.kt new file mode 100644 index 0000000..86142d9 --- /dev/null +++ b/app/src/test/java/com/pombo/android/core/EnsStoreTest.kt @@ -0,0 +1,48 @@ +package com.pombo.android.core + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class EnsStoreTest { + + @get:Rule val dir = TemporaryFolder() + + private val context: Context = mockk { every { filesDir } answers { dir.root } } + private val cacheFile get() = File(dir.root, "ens-cache.json") + private val twentyMinutesAgo get() = System.currentTimeMillis() - 20 * 60 * 1000L + + private fun storeOnDisk(entry: JSONObject) = + cacheFile.writeText(JSONObject().put("0xabc", entry).toString()) + + @Test + fun `a no-name the lookup returned is stored as confirmed`() = runBlocking { + assertNull(EnsStore(context).name("0xABC") { null }) + assertTrue(JSONObject(cacheFile.readText()).getJSONObject("0xabc").getBoolean("nameConfirmed")) + } + + @Test + fun `a confirmed no-name is not looked up again within the day`() = runBlocking { + storeOnDisk(JSONObject().put("name", JSONObject.NULL).put("nameConfirmed", true).put("at", twentyMinutesAgo)) + val store = EnsStore(context).apply { warmUp() } + var lookups = 0 + assertNull(store.name("0xabc") { lookups++; "late.eth" }) + assertEquals(0, lookups) + } + + @Test + fun `a no-name nobody confirmed is looked up again after fifteen minutes`() = runBlocking { + storeOnDisk(JSONObject().put("name", JSONObject.NULL).put("at", twentyMinutesAgo)) + val store = EnsStore(context).apply { warmUp() } + assertEquals("found.eth", store.name("0xabc") { "found.eth" }) + } +} From 1c4e822582226df92f3e0f9eeee20b86de94e6c1 Mon Sep 17 00:00:00 2001 From: Ocnrb Date: Mon, 5 Oct 2026 19:03:58 +0100 Subject: [PATCH 3/3] Drop cloudflare-eth.com from the ENS endpoints - It answers the reverse lookup with -32603 "Internal error" and even eth_blockNumber with -32046 Co-authored-by: Claude Opus 5.5 --- app/src/main/assets/pombo_bridge.html | 3 +-- .../java/com/pombo/android/core/BridgeChainReadCacheTest.kt | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/assets/pombo_bridge.html b/app/src/main/assets/pombo_bridge.html index cfa79fc..1b42c27 100644 --- a/app/src/main/assets/pombo_bridge.html +++ b/app/src/main/assets/pombo_bridge.html @@ -54,8 +54,7 @@ // Ethereum mainnet RPCs for ENS reverse lookup (same list as the web config). const ENS_RPCS = [ 'https://ethereum-rpc.publicnode.com', - 'https://eth.drpc.org', - 'https://cloudflare-eth.com' + 'https://eth.drpc.org' ]; // K-anonymity decoys per reverse lookup (web CONFIG.identity.ensDecoyCount). // Kept at 2 because the free ENS tiers 429 easily: cover fires once per diff --git a/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt b/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt index 72e335a..cc6b1fd 100644 --- a/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt +++ b/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt @@ -1,6 +1,7 @@ package com.pombo.android.core import java.io.File +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -39,4 +40,9 @@ class BridgeChainReadCacheTest { fun `an ENS lookup no provider answered fails instead of reading as no name`() { assertTrue(body("resolveEns").contains("if (!answered) throw new Error('no ENS provider answered')")) } + + @Test + fun `the dead ENS endpoint is gone`() { + assertFalse(asset.contains("cloudflare-eth.com")) + } }