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
88 changes: 74 additions & 14 deletions app/src/main/assets/pombo_bridge.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 };
},

Expand Down Expand Up @@ -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
Expand All @@ -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') {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/pombo/android/ChannelManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
17 changes: 10 additions & 7 deletions app/src/main/java/com/pombo/android/core/EnsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ import java.io.File
*
* - persisted (the web writes `pombo_ens_<addr>` / `pombo_ens_avatar_<addr>`
* 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<String, Entry>()
private val avatars = HashMap<String, Entry>()
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
48 changes: 48 additions & 0 deletions app/src/test/java/com/pombo/android/core/EnsStoreTest.kt
Original file line number Diff line number Diff line change
@@ -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" })
}
}
Loading