diff --git a/app/src/main/assets/pombo_bridge.html b/app/src/main/assets/pombo_bridge.html
index 06fbbf9..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
@@ -1773,13 +1772,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 };
},
@@ -2644,6 +2648,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 +2687,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 +2848,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/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
new file mode 100644
index 0000000..cc6b1fd
--- /dev/null
+++ b/app/src/test/java/com/pombo/android/core/BridgeChainReadCacheTest.kt
@@ -0,0 +1,48 @@
+package com.pombo.android.core
+
+import java.io.File
+import org.junit.Assert.assertFalse
+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'"))
+ }
+
+ @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')"))
+ }
+
+ @Test
+ fun `the dead ENS endpoint is gone`() {
+ assertFalse(asset.contains("cloudflare-eth.com"))
+ }
+}
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" })
+ }
+}