Strip native libraries; XCM by genesis; CodeQL for Kotlin; bridge credits in history (#31)

* Strip native libraries in the app and keep light client line tables

The app module named no NDK, so AGP expected its default 27.0 against the 26.1 that CI installs,
could not strip any native library and made no native-debug-symbols.zip for Play. Pin 26.1 in the
app. The light client now keeps line tables for that symbol file; the packaged library is still
stripped to 8.6 MB.

Also adds an XCM encoding test for PezkuwiChain networks. This commit points WESTEND at Zagros
to check that the test fails against that change.

* Fix the encoding test's read of the interior field

* Encode PezkuwiChain networks by genesis in XCM

WESTEND held a stale genesis labelled Zagros. The runtimes declare ThisNetwork as ByGenesis and
the XCM v5 NetworkId has no Westend or Zagros variant, so the named mapping is removed and every
PezkuwiChain network is encoded as ByGenesis.

* Run CodeQL for every language on hosted runners, Kotlin with a build

Default setup analyses Java without a build and leaves out every Kotlin file, and the Java job on the
self-hosted runner stopped at the NDK install because sudo asks for a password, so Kotlin had not been
analysed since February. One matrix job covers actions, JavaScript/TypeScript, Rust and Java/Kotlin.

* Use CodeQL Action v4, v3 is deprecated in December 2026

* Build for CodeQL without Gradle and Kotlin daemons so the analysis has the memory

* Show incoming USDT bridge credits in history without a refresh

The realtime transfer extractor for assets pallets knew only transfer and transfer_keep_alive. The
bridge credits with transfer_approved, spending the multisig's allowance, so a credit appeared only
after the indexer caught up and the user refreshed. transfer_approved is now a transfer from owner to
destination; the signing delegate is not the sender.

* Stop the light client five minutes after RPC is back

A chain registered with the light client kept syncing for as long as the process lived. Measured on
the emulator over 5-minute windows, that was about 14 MB of data an hour and 2% of a core while the
wallet read nothing from it.

Once RPC has stayed connected for five minutes after a fallback, the connection releases the light
client: LightClients unregisters the chain, a relay chain only after its teyrchains. The engine gains
remove_chain. Its database saver now checks, under the registry lock, that the chain is still
registered: smoldot panics on a request to a removed chain. The next fallback starts cold from the
saved database.

* Refresh light client specifications from the signed config while RPC works

The light client started from the checkpoint the APK was built with, so a first fallback months after
a release warp-synced from months back, and on a phone that never fell back there is no saved database
to help. The fallback is wanted when RPC is blocked, and then the config repository may be blocked too.

On the first RPC connection of the process, each chain's specification is fetched from the config
repository through the app's HTTP client, which checks it against the signed targets and falls back to
the mirrors. It is kept when it is the same chain (id and genesis state root) with a later checkpoint;
an older file never rolls the checkpoint back. Registration uses the kept file when it is later than the
APK's own.

* Close the light client's peer connections once its last chain is removed

smoldot keeps its peer-to-peer network service, with the connections in it, for as long as the client
lives, so removing every chain left them open: on the emulator, four libp2p connections were still up
eleven minutes after the release, and data use stayed above the RPC-only level. The engine now
replaces the client once no chain and no loopback connection is left; the connections close about 20 s
later, when the attempts under way finish.

The database saver now checks a per-registration number instead of the smoldot chain id, which smoldot
reuses after a removal: a saver of a removed chain could otherwise keep running for the next chain
that got the same id. A connection whose chain is removed between the handshake and its setup now
ends instead of panicking.

* Obfuscate the app's own classes and build the R8 variant in pull requests

-keep class io.novafoundation.nova.** { *; } with -keepnames left 2.6% of the app's 26,602 classes
renamed in the 1.3.0 Play build (15.6% of all classes). It is replaced by keeping only the field names
of the app's classes, which Gson reads by name where a class has no @SerializedName, so class and method
names are obfuscated. No persisted value or reflective lookup in the app depends on a class or method
name: class names appear only in logs and in an in-memory cache key.

@JavascriptInterface methods had no rule of their own; the blanket keep covered the DApp browser's web3
bridge, its integrity check and the Transak bridge. They are kept explicitly.

Pull requests built only the unminified develop variant, so an R8 break would first show in the Play
build. A second job builds releaseTest (the release rules, debug-signed) and uploads its APK and mapping.

* Give the releaseTest build the debug package, which the CI Firebase config knows

* Build releaseTest under the production package, the one its Firebase config has

* Stop keeping every app class under the JNI rule

-keep class io.novafoundation.nova.** { native <methods>; } keeps every class the pattern matches,
whether or not it has a native method, so the app's class names stayed unobfuscated after the blanket
keep went: 2.6% of its classes were renamed in the R8 build. The rule under it already keeps the names
of classes with native methods and of those methods, which is what JNI binds by name; the Rust
bindings look up no other app class.

* Keep TypeToken and Retrofit generic signatures under R8 full mode

The R8 build failed to map every chain from the local database: R8 full mode keeps generic signatures
only on kept classes, and the app's anonymous TypeToken subclasses had been kept by the blanket rule.
These are the rules Gson 2.11 and Retrofit 2.10 ship; the app is on Gson 2.10.1 and Retrofit 2.9.0.

* Keep constructors of the app's classes so Gson can instantiate its models

The R8 build failed to read the DApp list: R8 full mode had made DappMetadataResponse abstract because
nothing in the code instantiates it, and Gson does so reflectively. It matches none of the name
patterns the rules keep models by. Keeping constructors costs no obfuscation, since they are all named
<init>.

* Keep the type argument of web3j TypeReference subclasses under R8 full mode

* Run R8 in compatibility mode

After the app's own classes became obfuscatable, the full-mode R8 build kept breaking reflective
decoding in new places: TypeToken and TypeReference subclasses lost their type argument, a Gson model
was made abstract, and SubQueryResponse<T> lost its class signature so a proxy query decoded to a
LinkedTreeMap. Full mode keeps generic signatures only on explicitly kept classes, and this code base
has many generic models outside any name pattern. Compatibility mode keeps them everywhere under
-keepattributes Signature and does not make uninstantiated classes abstract; obfuscation is unchanged.

* Allow the java.beans classes Jackson's Java 7 support refers to, missing on Android
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-11-03 18:07:55 +04:00
1 parent 8549501a62
commit 35c71062f0
23 files changed
+697 -74

No files matched your search

@@ -26,6 +26,7 @@ import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionPool
import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets
import io.novafoundation.nova.runtime.multiNetwork.connection.Web3ApiPool
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.NodeAutobalancer
import io.novafoundation.nova.runtime.multiNetwork.connection.light.LightClientSpecApi
import io.novafoundation.nova.runtime.multiNetwork.connection.light.LightClients
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider
import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory
@@ -186,7 +187,8 @@ class ChainRegistryModule {
fun provideLightClients(
context: Context,
encryptedFirstDns: EncryptedFirstDns,
) = LightClients(context, encryptedFirstDns)
networkApiCreator: NetworkApiCreator,
) = LightClients(context, encryptedFirstDns, networkApiCreator.create(LightClientSpecApi::class.java))
@Provides
@ApplicationScope
@@ -461,9 +461,6 @@ object ChainGeneses {
const val KUSAMA = "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe"
const val POLKADOT = "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3"
// Westend constant now points to Zagros Testnet
const val WESTEND = "96eb58af1bb7288115b5e4ff1590422533e749293f231974536dc6672417d06f"
const val KUSAMA_ASSET_HUB = "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a"
const val ACALA = "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c"
@@ -197,7 +197,10 @@ class ChainConnection internal constructor(
is FallbackEvent.Socket -> {
val onLight = light != null && event.state.urlOrNull() == light?.saturatedUrl
if (event.state is State.Connected && !onLight) policy.onRpcConnected()
if (event.state is State.Connected && !onLight) {
policy.onRpcConnected(System.currentTimeMillis())
lightClients.refreshSpecsInBackground()
}
if (event.state.needsAutobalance()) {
Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: ${event.state} failed, source ${policy.source}, on light $onLight")
@@ -217,9 +220,17 @@ class ChainConnection internal constructor(
}
}
FallbackEvent.Tick -> if (policy.onTick(rpcNodeCount(), System.currentTimeMillis())) {
Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: retrying RPC")
emit(latestRpc)
FallbackEvent.Tick -> {
val now = System.currentTimeMillis()
if (policy.onTick(rpcNodeCount(), now)) {
Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: retrying RPC")
emit(latestRpc)
}
if (policy.shouldReleaseLight(now)) {
Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: RPC is stable, releasing the light client")
light = null
lightClients.release(chain.value.id)
}
}
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.runtime.multiNetwork.connection.light
import retrofit2.http.GET
import retrofit2.http.Url
interface LightClientSpecApi {
/** A file of the config repository, checked against its signed targets by the app's HTTP client. */
@GET
suspend fun spec(@Url url: String): String
}
@@ -6,36 +6,45 @@ import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.lightclient.HostResolver
import io.novafoundation.nova.lightclient.PezkuwiLight
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Dns
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/**
* The embedded light client (`bindings/pezkuwi-light`) for the chains it can verify.
*
* Started on first use, so a wallet whose RPC nodes answer never runs it. Chain specifications
* come from the APK (`light-client/` assets, built from pezkuwi-wallet-utils): the genesis state
* root, boot nodes and, for a relay chain, a checkpoint. Peer host names resolve through [dns],
* the app's DNS-over-HTTPS-first resolver.
* Started on first use, so a wallet whose RPC nodes answer never runs it, and a chain stops
* syncing once [release]d. Chain specifications come from the APK (`light-client/` assets, built
* from pezkuwi-wallet-utils): the genesis state root, boot nodes and, for a relay chain, a
* checkpoint. Peer host names resolve through [dns], the app's DNS-over-HTTPS-first resolver.
*/
class LightClients(
private val context: Context,
private val dns: Dns,
private val specApi: LightClientSpecApi,
) {
private class LightChain(val asset: String, val relay: ChainId?)
private class LightChain(val file: String, val relay: ChainId?)
private val chains = mapOf(
ZAGROS to LightChain("light-client/zagros.json", relay = null),
ZAGROS_PEOPLE to LightChain("light-client/people-zagros.json", relay = ZAGROS),
ZAGROS to LightChain("zagros.json", relay = null),
ZAGROS_PEOPLE to LightChain("people-zagros.json", relay = ZAGROS),
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val specRefreshStarted = AtomicBoolean(false)
private val mutex = Mutex()
private var baseUrl: String? = null
private val registered = mutableSetOf<ChainId>()
private val inUse = mutableSetOf<ChainId>()
fun supports(chainId: ChainId): Boolean = chainId in chains
@@ -46,6 +55,7 @@ class LightClients(
if (!supports(chainId)) return@runCatching null
val base = baseUrl ?: PezkuwiLight.start(File(context.filesDir, DATABASE_DIR), resolver()).also { baseUrl = it }
register(chainId)
inUse += chainId
PezkuwiLight.chainUrl(base, chainId)
}.onFailure {
Log.w(LOG_TAG, "Light client unavailable for $chainId", it)
@@ -53,16 +63,72 @@ class LightClients(
}
}
/**
* [chainId] no longer needs the light client, so it stops syncing: a synced chain costs data
* and CPU while nothing reads it. A relay chain goes once none of its teyrchains is in use.
*/
suspend fun release(chainId: ChainId) = mutex.withLock {
withContext(Dispatchers.IO) {
inUse -= chainId
while (true) {
val unused = registered.firstOrNull { key -> key !in inUse && registered.none { chains.getValue(it).relay == key } } ?: break
// Still registered on failure, so a later url() does not register it twice
runCatching { PezkuwiLight.removeChain(unused) }
.onSuccess { registered -= unused }
.onFailure { Log.w(LOG_TAG, "Cannot stop the light client for $unused", it) }
.getOrNull() ?: break
}
}
}
/**
* Fetches each chain's specification from the config repository, once per process, and keeps it
* when it is the same chain with a later checkpoint ([LightSpecs.isUpdate]). Called while RPC
* works: the light client is wanted when it does not, and then the repository may not answer
* either. Without it a first fallback starts from the checkpoint the APK was built with.
*/
fun refreshSpecsInBackground() {
if (!specRefreshStarted.compareAndSet(false, true)) return
scope.launch {
chains.values.forEach { chain ->
runCatching {
val candidate = specApi.spec(SPEC_SOURCE + chain.file)
if (LightSpecs.isUpdate(current = spec(chain), candidate = candidate)) {
writeAtomically(storedSpec(chain), candidate)
Log.d(LOG_TAG, "Light client specification ${chain.file} updated")
}
}.onFailure { Log.w(LOG_TAG, "Cannot refresh light client specification ${chain.file}", it) }
}
}
}
private fun register(chainId: ChainId) {
if (chainId in registered) return
val chain = chains.getValue(chainId)
chain.relay?.let(::register)
val spec = context.assets.open(chain.asset).bufferedReader().use { it.readText() }
PezkuwiLight.addChain(chainId, spec, chain.relay)
PezkuwiLight.addChain(chainId, spec(chain), chain.relay)
registered += chainId
}
/** The stored specification when it is later than the APK's; an app update can bring a later one. */
private fun spec(chain: LightChain): String {
val bundled = context.assets.open(BUNDLED_DIR + chain.file).bufferedReader().use { it.readText() }
val stored = storedSpec(chain).takeIf { it.exists() }?.readText() ?: return bundled
return if (LightSpecs.isUpdate(current = bundled, candidate = stored)) stored else bundled
}
private fun storedSpec(chain: LightChain) = File(context.filesDir, "$SPECS_DIR/${chain.file}")
private fun writeAtomically(file: File, text: String) {
file.parentFile?.mkdirs()
val temporary = File(file.parentFile, "${file.name}.tmp")
temporary.writeText(text)
if (!temporary.renameTo(file)) error("Cannot replace $file")
}
private fun resolver() = HostResolver { host ->
dns.lookup(host).mapNotNull { it.hostAddress }.toTypedArray()
}
@@ -72,5 +138,8 @@ class LightClients(
const val ZAGROS_PEOPLE: ChainId = "c72b38f36affaeb2661018b201c2be02e8c09fc699640525d39200a10fd77878"
const val DATABASE_DIR = "light-client"
const val SPECS_DIR = "light-client-specs"
const val BUNDLED_DIR = "light-client/"
const val SPEC_SOURCE = "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/light-client/"
}
}
@@ -7,12 +7,15 @@ package io.novafoundation.nova.runtime.multiNetwork.connection.light
* those nodes have a fixed number of light client slots. Once every RPC node has failed in a row,
* the light client takes over. RPC is retried after [initialBackoffMillis], doubling up to
* [maxBackoffMillis] while it keeps failing, and takes back the connection once it connects.
* The light client is kept for [releaseLightAfterMillis] after that, in case RPC fails again
* soon, and then released: a synced light client costs data and CPU while nothing reads it.
*
* Not thread-safe: one connection feeds it its events in order.
*/
class LightFallbackPolicy(
private val initialBackoffMillis: Long = 60_000,
private val maxBackoffMillis: Long = 15 * 60_000,
private val releaseLightAfterMillis: Long = 5 * 60_000,
) {
enum class Source { RPC, LIGHT }
@@ -23,21 +26,26 @@ class LightFallbackPolicy(
private var consecutiveRpcFailures = 0
private var backoffMillis = initialBackoffMillis
private var rpcRetryAt: Long? = null
private var lightHeld = false
private var rpcConnectedSince: Long? = null
/** An RPC node failed. True when the light client should take over now. */
fun onRpcFailure(rpcNodeCount: Int, nowMillis: Long): Boolean {
if (source != Source.RPC) return false
rpcConnectedSince = null
consecutiveRpcFailures++
if (consecutiveRpcFailures < rpcNodeCount.coerceAtLeast(1)) return false
source = Source.LIGHT
lightHeld = true
rpcRetryAt = nowMillis + backoffMillis
backoffMillis = (backoffMillis * 2).coerceAtMost(maxBackoffMillis)
return true
}
fun onRpcConnected() {
fun onRpcConnected(nowMillis: Long) {
if (rpcConnectedSince == null) rpcConnectedSince = nowMillis
source = Source.RPC
consecutiveRpcFailures = 0
backoffMillis = initialBackoffMillis
@@ -56,6 +64,18 @@ class LightFallbackPolicy(
return true
}
/**
* True, once, when the light client should be released now: RPC has stayed connected for
* [releaseLightAfterMillis] since the light client was last used.
*/
fun shouldReleaseLight(nowMillis: Long): Boolean {
val since = rpcConnectedSince ?: return false
if (!lightHeld || nowMillis - since < releaseLightAfterMillis) return false
lightHeld = false
return true
}
/** The light client could not start or its connection failed: RPC is all there is. */
fun onLightUnavailable(rpcNodeCount: Int) {
if (source == Source.LIGHT) returnToRpc(rpcNodeCount)
@@ -0,0 +1,57 @@
package io.novafoundation.nova.runtime.multiNetwork.connection.light
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.math.BigInteger
/** Which of two light client chain specifications to keep. */
object LightSpecs {
/**
* True when [candidate] is the same chain as [current] and should replace it: a relay chain only
* for a later checkpoint, so an older file never rolls the checkpoint back; a chain without a
* checkpoint whenever it differs (boot nodes change).
*/
fun isUpdate(current: String, candidate: String): Boolean {
val currentSpec = parse(current) ?: return false
val candidateSpec = parse(candidate) ?: return false
if (currentSpec.chainId() != candidateSpec.chainId() || currentSpec.stateRootHash() != candidateSpec.stateRootHash()) return false
val currentCheckpoint = currentSpec.checkpointNumber()
val candidateCheckpoint = candidateSpec.checkpointNumber()
return when {
currentCheckpoint == null && candidateCheckpoint == null -> currentSpec != candidateSpec
currentCheckpoint == null -> true
candidateCheckpoint == null -> false
else -> candidateCheckpoint > currentCheckpoint
}
}
private fun parse(spec: String): JsonObject? = runCatching { JsonParser.parseString(spec).asJsonObject }.getOrNull()
private fun JsonObject.chainId(): String? = get("id")?.asString
private fun JsonObject.stateRootHash(): String? = getAsJsonObject("genesis")?.get("stateRootHash")?.asString
/** Block number of the checkpoint header: SCALE, a 32-byte parent hash then a compact number. */
private fun JsonObject.checkpointNumber(): BigInteger? = runCatching {
val header = getAsJsonObject("lightSyncState").get("finalizedBlockHeader").asString.removePrefix("0x")
decodeCompact(hexToBytes(header.substring(64, minOf(header.length, 64 + 18))))
}.getOrNull()
private fun decodeCompact(bytes: ByteArray): BigInteger {
val first = bytes[0].toInt() and 0xff
val littleEndian = { from: Int, count: Int -> BigInteger(1, bytes.copyOfRange(from, from + count).reversedArray()) }
return when (first and 0b11) {
0b00 -> BigInteger.valueOf((first shr 2).toLong())
0b01 -> littleEndian(0, 2).shiftRight(2)
0b10 -> littleEndian(0, 4).shiftRight(2)
else -> littleEndian(1, (first shr 2) + 4)
}
}
private fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { hex.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
}
@@ -9,7 +9,7 @@ import org.junit.Test
class LightFallbackPolicyTest {
private val minute = 60_000L
private val policy = LightFallbackPolicy(initialBackoffMillis = minute, maxBackoffMillis = 4 * minute)
private val policy = LightFallbackPolicy(initialBackoffMillis = minute, maxBackoffMillis = 4 * minute, releaseLightAfterMillis = 5 * minute)
@Test
fun `light client takes over only after every RPC node failed in a row`() {
@@ -24,7 +24,7 @@ class LightFallbackPolicyTest {
@Test
fun `a connected RPC node resets the count`() {
policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0)
policy.onRpcConnected()
policy.onRpcConnected(nowMillis = 0)
assertFalse(policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0))
assertTrue(policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0))
@@ -68,7 +68,7 @@ class LightFallbackPolicyTest {
policy.onTick(rpcNodeCount = 1, nowMillis = minute)
policy.onRpcFailure(rpcNodeCount = 1, nowMillis = minute)
policy.onTick(rpcNodeCount = 1, nowMillis = 3 * minute)
policy.onRpcConnected()
policy.onRpcConnected(nowMillis = 3 * minute)
assertTrue(policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 10 * minute))
assertFalse(policy.onTick(rpcNodeCount = 1, nowMillis = 11 * minute - 1))
@@ -91,4 +91,53 @@ class LightFallbackPolicyTest {
assertFalse(policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 0))
assertEquals(Source.LIGHT, policy.source)
}
@Test
fun `the light client is released once RPC has stayed connected for the release delay`() {
useLightThenReturnToRpcAt(minute)
assertFalse(policy.shouldReleaseLight(nowMillis = 6 * minute - 1))
assertTrue(policy.shouldReleaseLight(nowMillis = 6 * minute))
assertFalse(policy.shouldReleaseLight(nowMillis = 7 * minute))
}
@Test
fun `an RPC failure restarts the release delay`() {
useLightThenReturnToRpcAt(minute)
policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 3 * minute)
policy.onRpcConnected(nowMillis = 4 * minute)
assertFalse(policy.shouldReleaseLight(nowMillis = 9 * minute - 1))
assertTrue(policy.shouldReleaseLight(nowMillis = 9 * minute))
}
@Test
fun `a repeated connected state does not restart the release delay`() {
useLightThenReturnToRpcAt(minute)
policy.onRpcConnected(nowMillis = 3 * minute)
assertTrue(policy.shouldReleaseLight(nowMillis = 6 * minute))
}
@Test
fun `nothing is released while the light client serves the connection`() {
policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 0)
assertFalse(policy.shouldReleaseLight(nowMillis = 100 * minute))
}
@Test
fun `a light client that was never used is not released`() {
policy.onRpcConnected(nowMillis = 0)
assertFalse(policy.shouldReleaseLight(nowMillis = 100 * minute))
}
private fun useLightThenReturnToRpcAt(nowMillis: Long) {
policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0)
policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0)
policy.onTick(rpcNodeCount = 2, nowMillis = nowMillis)
policy.onRpcConnected(nowMillis = nowMillis)
}
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.runtime.multiNetwork.connection.light
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LightSpecsTest {
@Test
fun `a relay chain specification is replaced only by a later checkpoint`() {
assertTrue(LightSpecs.isUpdate(relay(checkpoint = 1_000), relay(checkpoint = 1_020_017)))
assertFalse(LightSpecs.isUpdate(relay(checkpoint = 1_020_017), relay(checkpoint = 1_000)))
assertFalse(LightSpecs.isUpdate(relay(checkpoint = 1_000), relay(checkpoint = 1_000)))
}
@Test
fun `a checkpoint does not move to another chain`() {
assertFalse(LightSpecs.isUpdate(relay(checkpoint = 1), relay(checkpoint = 2, stateRoot = "0x02")))
assertFalse(LightSpecs.isUpdate(relay(checkpoint = 1), relay(checkpoint = 2, id = "pezkuwi")))
}
@Test
fun `a chain without a checkpoint takes any change of the same chain`() {
assertTrue(LightSpecs.isUpdate(people(bootNode = "a"), people(bootNode = "b")))
assertFalse(LightSpecs.isUpdate(people(bootNode = "a"), people(bootNode = "a")))
}
@Test
fun `a specification that dropped its checkpoint is not taken`() {
assertFalse(LightSpecs.isUpdate(relay(checkpoint = 5), relay(checkpoint = null)))
}
@Test
fun `an unreadable candidate is not taken`() {
assertFalse(LightSpecs.isUpdate(relay(checkpoint = 5), "<html>"))
assertFalse(LightSpecs.isUpdate(relay(checkpoint = 5), "{}"))
}
@Test
fun `compact numbers of every width are read`() {
for (number in listOf(0, 63, 64, 16_383, 16_384, 1_073_741_823, 1_073_741_824)) {
assertTrue("$number", LightSpecs.isUpdate(relay(checkpoint = number.toLong()), relay(checkpoint = number.toLong() + 1)))
assertFalse("$number", LightSpecs.isUpdate(relay(checkpoint = number.toLong() + 1), relay(checkpoint = number.toLong())))
}
}
private fun relay(checkpoint: Long?, stateRoot: String = "0x01", id: String = "zagros"): String {
val sync = checkpoint?.let { """, "lightSyncState": {"finalizedBlockHeader": "0x${"00".repeat(32)}${compact(it)}${"00".repeat(64)}"}""" } ?: ""
return """{"id": "$id", "genesis": {"stateRootHash": "$stateRoot"}, "bootNodes": []$sync}"""
}
private fun people(bootNode: String) = """{"id": "people-zagros", "genesis": {"stateRootHash": "0x03"}, "bootNodes": ["$bootNode"]}"""
/** SCALE compact encoding, little endian, as hex. */
private fun compact(value: Long): String {
val bytes = when {
value < 1 shl 6 -> byteArrayOf((value shl 2).toByte())
value < 1 shl 14 -> le((value shl 2) or 0b01, 2)
value < 1 shl 30 -> le((value shl 2) or 0b10, 4)
else -> {
val body = le(value, 8).dropLastWhile { it == 0.toByte() }.toByteArray()
byteArrayOf((((body.size - 4) shl 2) or 0b11).toByte()) + body
}
}
return bytes.joinToString("") { "%02x".format(it) }
}
private fun le(value: Long, size: Int) = ByteArray(size) { (value shr (8 * it)).toByte() }
}