From 8549501a62faf4a2bb9584c8050f068ef23b0866 Mon Sep 17 00:00:00 2001 From: SatoshiQaziMuhammed Date: Tue, 3 Nov 2026 03:30:15 +0400 Subject: [PATCH] Embedded PezkuwiChain light client with RPC fallback (Zagros) (#30) * Add pezkuwi-light: embedded PezkuwiChain light client binding A JNI binding around the PezkuwiChain fork of smoldot (pezkuwichain/smoldot), which verifies PezkuwiChain blocks under the chain's sovereign constants. Upstream smoldot cannot. The wallet reaches its chains through JSON-RPC WebSockets, so the light client is served the same way: a WebSocket server bound to 127.0.0.1 on a random port. Every path carries a random per-process token: ws://127.0.0.1://. - A wrong token, a missing token or an unregistered chain is refused during the handshake with 404. - Each connection adds its own smoldot chain instance. smoldot shares the sync between instances of the same chain. - Each registered chain's finalized state is written to disk every minute, through a temporary file and a rename. The next start resumes from it. Kotlin surface: PezkuwiLight.start(dir), addChain(key, spec, relayKey), chainUrl(base, key). The module is not yet a dependency of the app. Tested against the live Zagros testnet, with the relay chain and People specs: - dks.kurd resolves through NavnisanApi_resolve to the same bytes the full node returns. - An unregistered name returns 0x00. - The saved state lets a new client resolve. - Reverting the token check turns the routing test red. The library builds for arm, arm64, x86 and x86_64 with 16 KB aligned LOAD segments (0x4000). * pezkuwi-light: secure WebSockets and the app's resolver for peers smoldot's DefaultPlatform cannot open wss connections and resolves names through the system resolver. Where our domains are blocked, both fail: a poisoned DNS answer decides where the client connects, and plain libp2p TCP is recognisable on the wire. PezkuwiPlatform replaces it: - Opens /dns//tcp//wss over TLS, using rustls with the ring provider and the Mozilla roots, so a connection looks like any HTTPS WebSocket. - Resolves every name through a Resolve trait. On Android this is the app's HostResolver, called over JNI, so the light client follows the same DNS policy as the rest of the app (EncryptedFirstDns: DoH first, system resolver last). - Tries IPv4 before IPv6, and gives up on an address after 10 s. - With setSecureOnly(true) it opens nothing but secure WebSockets. Peers found through the DHT advertise plain TCP too, and none of them is used. The peer's identity is still checked end to end by libp2p Noise. Tested against live Zagros with only the /wss boot nodes in the specs and secure-only on: relay and People sync and dks.kurd resolves to the full node's bytes. Nail: with secure-only on and the specs' plain TCP boot nodes, nothing syncs and the test times out red. * pezkuwi-light: measure a start from the saved database in its own process An app launch resumes from the database in a fresh process. The in-process warm measurement ran beside a client that was still syncing, so it did not show that case. The new ignored test resolves dks.kurd after a start from the database in PEZKUWI_LIGHT_DB_DIR. On the first run, when no database exists yet, it syncs and waits for a save. The tests also log through env_logger, so RUST_LOG shows smoldot's own timeline when a measurement needs explaining. * Fall back to the embedded light client when every Zagros RPC node fails ChainConnection stays on RPC first. A light client loads the peer-to-peer nodes, and those nodes have a fixed number of light client slots, so RPC is the default. When every RPC node of a supported chain has failed in a row, the connection moves to the light client, served at ws://127.0.0.1://. RPC is retried after one minute. The wait doubles up to 15 minutes while RPC keeps failing. The connection returns to RPC as soon as a retry connects. If the light client cannot start, or its own connection fails, the connection stays on RPC. LightFallbackPolicy holds these rules. Its tests cover each one, and five mutations of the policy each turn a test red. ChainConnection handles RPC node changes, socket states and a 15 s tick as one event stream, one event at a time, so a node change and a fallback cannot race. LightClients starts the client on first use. A wallet whose RPC answers never runs it. LightClients registers Zagros and Zagros People, the chains the fork can verify, from specifications bundled at build time from pezkuwi-wallet-utils light-client/: the genesis state root, the /wss boot nodes on 443 first, and the relay checkpoint. Peer names resolve through EncryptedFirstDns, which CommonApi now exposes to the runtime module. * pezkuwi-light: strip in the cargo profile The Android Gradle plugin packages a native library it cannot strip as it is. With debug info, libpezkuwi_light.so is 147 MB per ABI. Stripped it is 8.6 MB. The first CI build of this library ran out of disk: four targets with debug info filled the runner. Stripping in the release profile, with debug info off, fixes both: - The library is 8.6 MB for arm64 and keeps its three JNI symbols. - The build directory is 389 MB per target. * Log the light client fallback's decisions Each RPC failure the fallback sees, the light client URL it got, and each RPC retry are logged under LightFallback. The first device run on a blocked Zagros RPC showed no fallback and no trace of where it stopped. --- bindings/pezkuwi-light/.gitignore | 2 + bindings/pezkuwi-light/build.gradle | 46 + bindings/pezkuwi-light/consumer-rules.pro | 11 + bindings/pezkuwi-light/proguard-rules.pro | 11 + .../pezkuwi-light/rust/.cargo/config.toml | 11 + bindings/pezkuwi-light/rust/Cargo.lock | 2311 +++++++++++++++++ bindings/pezkuwi-light/rust/Cargo.toml | 44 + bindings/pezkuwi-light/rust/src/engine.rs | 423 +++ bindings/pezkuwi-light/rust/src/lib.rs | 170 ++ bindings/pezkuwi-light/rust/src/platform.rs | 423 +++ bindings/pezkuwi-light/rust/tests/loopback.rs | 233 ++ .../src/main/AndroidManifest.xml | 1 + .../nova/lightclient/HostResolver.kt | 13 + .../nova/lightclient/PezkuwiLight.kt | 43 + .../nova/common/di/CommonApi.kt | 3 + runtime/build.gradle | 1 + .../nova/runtime/di/ChainRegistryModule.kt | 11 + .../nova/runtime/di/RuntimeDependencies.kt | 3 + .../connection/ChainConnection.kt | 112 +- .../connection/light/LightClients.kt | 76 + .../connection/light/LightFallbackPolicy.kt | 69 + .../light/LightFallbackPolicyTest.kt | 94 + settings.gradle | 1 + 23 files changed, 4100 insertions(+), 12 deletions(-) create mode 100644 bindings/pezkuwi-light/.gitignore create mode 100644 bindings/pezkuwi-light/build.gradle create mode 100644 bindings/pezkuwi-light/consumer-rules.pro create mode 100644 bindings/pezkuwi-light/proguard-rules.pro create mode 100644 bindings/pezkuwi-light/rust/.cargo/config.toml create mode 100644 bindings/pezkuwi-light/rust/Cargo.lock create mode 100644 bindings/pezkuwi-light/rust/Cargo.toml create mode 100644 bindings/pezkuwi-light/rust/src/engine.rs create mode 100644 bindings/pezkuwi-light/rust/src/lib.rs create mode 100644 bindings/pezkuwi-light/rust/src/platform.rs create mode 100644 bindings/pezkuwi-light/rust/tests/loopback.rs create mode 100644 bindings/pezkuwi-light/src/main/AndroidManifest.xml create mode 100644 bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/HostResolver.kt create mode 100644 bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/PezkuwiLight.kt create mode 100644 runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt create mode 100644 runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicy.kt create mode 100644 runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicyTest.kt diff --git a/bindings/pezkuwi-light/.gitignore b/bindings/pezkuwi-light/.gitignore new file mode 100644 index 00000000..c42713eb --- /dev/null +++ b/bindings/pezkuwi-light/.gitignore @@ -0,0 +1,2 @@ +/build +/rust/target/ diff --git a/bindings/pezkuwi-light/build.gradle b/bindings/pezkuwi-light/build.gradle new file mode 100644 index 00000000..79164f95 --- /dev/null +++ b/bindings/pezkuwi-light/build.gradle @@ -0,0 +1,46 @@ +apply plugin: 'org.mozilla.rust-android-gradle.rust-android' +apply from: '../../scripts/bundled-config.gradle' + +android { + namespace 'io.novafoundation.nova.pezkuwi_light' + + ndkVersion "26.1.10909125" + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation kotlinDep + + testImplementation jUnitDep +} + +cargo { + module = "rust/" + libname = "pezkuwi_light" + targets = ["arm", "arm64", "x86", "x86_64"] + profile = "release" + pythonCommand = "python3" +} + +tasks.matching { it.name.matches(/merge.*JniLibFolders/) }.configureEach { + it.inputs.dir(new File(buildDir, "rustJniLibs/android")) + it.dependsOn("cargoBuild") +} + +// Chain specifications the light client starts from: genesis state root, boot nodes and, for the +// relay chain, a checkpoint. A checkpoint is a trust anchor, so it reaches the client only with the +// APK; pezkuwi-wallet-utils' light_client_specs.py writes them and signed/targets.json lists them. +def lightClientSpecs = "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/light-client" +def isLightClientSpec = { it instanceof Map && it.genesis?.stateRootHash && it.bootNodes } +bundleConfigAsset("bundleZagrosLightSpec", "$lightClientSpecs/zagros.json", "light-client/zagros.json") { + isLightClientSpec(it) && it.lightSyncState +} +bundleConfigAsset("bundleZagrosPeopleLightSpec", "$lightClientSpecs/people-zagros.json", "light-client/people-zagros.json") { + isLightClientSpec(it) && it.relay_chain == "zagros" +} diff --git a/bindings/pezkuwi-light/consumer-rules.pro b/bindings/pezkuwi-light/consumer-rules.pro new file mode 100644 index 00000000..1191b44a --- /dev/null +++ b/bindings/pezkuwi-light/consumer-rules.pro @@ -0,0 +1,11 @@ +# JNI resolves these methods by name. +-keep class io.novafoundation.nova.lightclient.PezkuwiLight { + native ; +} +# Native code calls HostResolver.resolve by name. +-keep interface io.novafoundation.nova.lightclient.HostResolver { + java.lang.String[] resolve(java.lang.String); +} +-keepclassmembers class * implements io.novafoundation.nova.lightclient.HostResolver { + java.lang.String[] resolve(java.lang.String); +} diff --git a/bindings/pezkuwi-light/proguard-rules.pro b/bindings/pezkuwi-light/proguard-rules.pro new file mode 100644 index 00000000..1191b44a --- /dev/null +++ b/bindings/pezkuwi-light/proguard-rules.pro @@ -0,0 +1,11 @@ +# JNI resolves these methods by name. +-keep class io.novafoundation.nova.lightclient.PezkuwiLight { + native ; +} +# Native code calls HostResolver.resolve by name. +-keep interface io.novafoundation.nova.lightclient.HostResolver { + java.lang.String[] resolve(java.lang.String); +} +-keepclassmembers class * implements io.novafoundation.nova.lightclient.HostResolver { + java.lang.String[] resolve(java.lang.String); +} diff --git a/bindings/pezkuwi-light/rust/.cargo/config.toml b/bindings/pezkuwi-light/rust/.cargo/config.toml new file mode 100644 index 00000000..5070d2c9 --- /dev/null +++ b/bindings/pezkuwi-light/rust/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.aarch64-linux-android] +rustflags = ["-C", "link-arg=-z", "-C", "link-arg=max-page-size=16384"] + +[target.armv7-linux-androideabi] +rustflags = ["-C", "link-arg=-z", "-C", "link-arg=max-page-size=16384"] + +[target.i686-linux-android] +rustflags = ["-C", "link-arg=-z", "-C", "link-arg=max-page-size=16384"] + +[target.x86_64-linux-android] +rustflags = ["-C", "link-arg=-z", "-C", "link-arg=max-page-size=16384"] diff --git a/bindings/pezkuwi-light/rust/Cargo.lock b/bindings/pezkuwi-light/rust/Cargo.lock new file mode 100644 index 00000000..6cab369d --- /dev/null +++ b/bindings/pezkuwi-light/rust/Cargo.lock @@ -0,0 +1,2311 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-tungstenite" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c348fb0b6d132c596eca3dcd941df48fb597aafcb07a738ec41c004b087dc99" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite", +] + +[[package]] +name = "atomic-take" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8ab6b55fe97976e46f91ddbed8d147d966475dc29b2032757ba47e02376fbc3" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2-rfc" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d530bdd2d52966a6d03b7a964add7ae1a288d25214066fd4b600f0f796400" +dependencies = [ + "arrayvec 0.4.12", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" +dependencies = [ + "curve25519-dalek", + "ed25519", + "hashbrown", + "pkcs8", + "rand_core", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "env_filter 2.0.0", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec 0.7.8", +] + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core", + "zeroize", +] + +[[package]] +name = "multi-stash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pezkuwi-light" +version = "0.1.0" +dependencies = [ + "android_logger", + "async-tungstenite", + "env_logger", + "futures-rustls", + "futures-util", + "hex", + "jni", + "log", + "pin-project", + "rand", + "rustls", + "serde_json", + "smol", + "smoldot", + "smoldot-light", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec 0.7.8", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smoldot" +version = "0.21.0" +source = "git+https://github.com/pezkuwichain/smoldot?rev=182d996a3ae2a9b507a776fa307f2e6fc5d14f7a#182d996a3ae2a9b507a776fa307f2e6fc5d14f7a" +dependencies = [ + "arrayvec 0.7.8", + "async-lock", + "atomic-take", + "base64", + "bip39", + "blake2-rfc", + "bs58", + "chacha20", + "crossbeam-queue", + "derive_more", + "ed25519-zebra", + "either", + "event-listener", + "fnv", + "futures-lite", + "futures-util", + "hashbrown", + "hex", + "hmac 0.12.1", + "itertools", + "libm", + "libsecp256k1", + "merlin", + "nom", + "num-bigint", + "num-rational", + "num-traits", + "pbkdf2", + "pin-project", + "poly1305", + "rand", + "rand_chacha", + "ruzstd", + "schnorrkel", + "serde", + "serde_json", + "sha2 0.10.9", + "sha3", + "siphasher", + "slab", + "smallvec", + "soketto", + "twox-hash", + "wasmi", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "smoldot-light" +version = "0.19.0" +source = "git+https://github.com/pezkuwichain/smoldot?rev=182d996a3ae2a9b507a776fa307f2e6fc5d14f7a#182d996a3ae2a9b507a776fa307f2e6fc5d14f7a" +dependencies = [ + "async-channel", + "async-lock", + "base64", + "blake2-rfc", + "bs58", + "derive_more", + "either", + "event-listener", + "fnv", + "futures-channel", + "futures-lite", + "futures-util", + "hashbrown", + "hex", + "itertools", + "log", + "lru", + "parking_lot", + "pin-project", + "rand", + "rand_chacha", + "serde", + "serde_json", + "siphasher", + "slab", + "smol", + "smoldot", + "zeroize", +] + +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64", + "bytes", + "futures", + "httparse", + "log", + "rand", + "sha1", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasmi" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19af97fcb96045dd1d6b4d23e2b4abdbbe81723dbc5c9f016eb52145b320063" +dependencies = [ + "arrayvec 0.7.8", + "multi-stash", + "smallvec", + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser", +] + +[[package]] +name = "wasmi_collections" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80d6b275b1c922021939d561574bf376613493ae2b61c6963b15db0e8813562" + +[[package]] +name = "wasmi_core" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8c51482cc32d31c2c7ff211cd2bedd73c5bd057ba16a2ed0110e7a96097c33" +dependencies = [ + "downcast-rs", + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e431a14c186db59212a88516788bd68ed51f87aa1e08d1df742522867b5289a" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bindings/pezkuwi-light/rust/Cargo.toml b/bindings/pezkuwi-light/rust/Cargo.toml new file mode 100644 index 00000000..720553aa --- /dev/null +++ b/bindings/pezkuwi-light/rust/Cargo.toml @@ -0,0 +1,44 @@ +[package] +authors = ['PezkuwiChain'] +edition = '2021' +license = 'Apache-2.0' +name = "pezkuwi-light" +version = "0.1.0" +publish = false + +[dependencies] +# PezkuwiChain fork of smoldot: upstream smoldot cannot verify PezkuwiChain blocks (sovereign +# sr25519 and BABE VRF signing contexts, `TeyrchainHost` runtime API). +smoldot-light = { git = "https://github.com/pezkuwichain/smoldot", rev = "182d996a3ae2a9b507a776fa307f2e6fc5d14f7a", default-features = false, features = ["std"] } +smoldot = { git = "https://github.com/pezkuwichain/smoldot", rev = "182d996a3ae2a9b507a776fa307f2e6fc5d14f7a", default-features = false, features = ["std"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +futures-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } +webpki-roots = "0.26" +pin-project = "1" +async-tungstenite = { version = "0.28", default-features = false, features = ["handshake", "futures-03-sink"] } +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } +smol = "2.0" +jni = { version = "0.21", default-features = false } +log = "0.4" +rand = "0.8" +hex = "0.4" +serde_json = "1" + +[target.'cfg(target_os = "android")'.dependencies] +android_logger = { version = "0.14", default-features = false } + +[dev-dependencies] +env_logger = { version = "0.11", default-features = false, features = ["humantime"] } +tungstenite = "0.24" + +# Stripped here, not left to the Android Gradle plugin: it packages a library it cannot strip as +# it is, and with debug info this one is 147 MB per ABI instead of 8.6 MB. +[profile.release] +strip = true +debug = false +lto = true +opt-level = "s" + +[lib] +name = "pezkuwi_light" +crate-type = ["cdylib", "rlib"] diff --git a/bindings/pezkuwi-light/rust/src/engine.rs b/bindings/pezkuwi-light/rust/src/engine.rs new file mode 100644 index 00000000..1a657430 --- /dev/null +++ b/bindings/pezkuwi-light/rust/src/engine.rs @@ -0,0 +1,423 @@ +//! Embedded PezkuwiChain light client behind a loopback JSON-RPC WebSocket server. +//! +//! The wallet talks to its chains through `SocketService` (a JSON-RPC WebSocket client), so the +//! light client is exposed the same way: `ws://127.0.0.1://`. Every +//! accepted connection adds its own smoldot chain instance; smoldot shares the underlying sync +//! between instances of the same chain, so a connection costs a JSON-RPC frontend, not a sync. +//! +//! The port is bound to 127.0.0.1 only and every path carries a random token, so other apps on +//! the device cannot drive the client without first learning the token from this process. + +use async_tungstenite::tungstenite::{ + handshake::server::{ErrorResponse, Request, Response}, + http::StatusCode, + Message, +}; +use futures_util::{SinkExt as _, StreamExt as _}; +use crate::platform::{PezkuwiPlatform, Resolve}; +use smoldot_light::{ + AddChainConfig, AddChainConfigJsonRpc, AddChainSuccess, ChainId, + Client, HandleRpcError, JsonRpcResponses, +}; +use std::{ + collections::HashMap, + fs, io, + net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}, + num::NonZero, + path::PathBuf, + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +type Platform = PezkuwiPlatform; + +/// How often the finalized state of each chain is written to disk, so that the next start +/// resumes from it instead of syncing from the chain specification. +const DATABASE_SAVE_INTERVAL: Duration = Duration::from_secs(60); +const FIRST_DATABASE_SAVE: Duration = Duration::from_secs(20); + +/// Request id of the database export on a chain's own JSON-RPC frontend. +const DATABASE_REQUEST_ID: &str = "pezkuwi-light-database"; + +pub struct Engine { + inner: Arc, + platform: PezkuwiPlatform, + port: u16, +} + +struct Inner { + client: Mutex>, + chains: Mutex>, + executor: Arc>, + token: String, + db_dir: PathBuf, +} + +struct Registered { + spec: Arc, + /// Instance added at registration. It keeps the chain synced while no connection is open and + /// serves the database export. + base: ChainId, + /// Base instance of the relay chain, for a teyrchain. + relay: Option, +} + +#[derive(Debug)] +pub enum Error { + UnknownRelayChain(String), + AlreadyRegistered(String), + AddChain(String), + Io(io::Error), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::UnknownRelayChain(key) => write!(f, "relay chain {key} is not registered"), + Error::AlreadyRegistered(key) => write!(f, "chain {key} is already registered"), + Error::AddChain(err) => write!(f, "smoldot rejected the chain: {err}"), + Error::Io(err) => write!(f, "{err}"), + } + } +} + +impl Engine { + /// Starts the light client and its loopback server. `db_dir` holds one finalized-state file + /// per chain key; `resolver` resolves the host names of peers. + pub fn start(db_dir: PathBuf, resolver: Arc) -> Result { + fs::create_dir_all(&db_dir).map_err(Error::Io)?; + let listener = + TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).map_err(Error::Io)?; + let port = listener.local_addr().map_err(Error::Io)?.port(); + + let mut token = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token); + + let platform = PezkuwiPlatform::new( + env!("CARGO_PKG_NAME").into(), + env!("CARGO_PKG_VERSION").into(), + resolver, + ); + let executor = Arc::new(smol::Executor::new()); + let inner = Arc::new(Inner { + client: Mutex::new(Client::new(platform.clone())), + chains: Mutex::new(HashMap::new()), + executor: executor.clone(), + token: hex::encode(token), + db_dir, + }); + + let listener = smol::Async::new(listener).map_err(Error::Io)?; + executor.spawn(accept_loop(inner.clone(), listener)).detach(); + thread::Builder::new() + .name("pezkuwi-light-rpc".into()) + .spawn(move || smol::block_on(executor.run(futures_util::future::pending::<()>()))) + .map_err(Error::Io)?; + + Ok(Engine { + inner, + platform, + port, + }) + } + + /// Restricts new peer connections to secure WebSockets (`wss`), which look like any HTTPS + /// WebSocket on the wire. Off by default. + pub fn set_secure_only(&self, secure_only: bool) { + self.platform.set_secure_only(secure_only); + } + + /// `ws://127.0.0.1:/`; a chain is reached at `/`. + pub fn base_url(&self) -> String { + format!("ws://127.0.0.1:{}/{}", self.port, self.inner.token) + } + + /// Registers a chain under `key` and starts syncing it. A teyrchain names the key of its + /// already registered relay chain. + pub fn add_chain(&self, key: &str, spec: &str, relay_key: Option<&str>) -> Result<(), Error> { + let mut chains = self.inner.chains.lock().unwrap(); + if chains.contains_key(key) { + return Err(Error::AlreadyRegistered(key.into())); + } + let relay = match relay_key { + Some(relay_key) => Some( + chains + .get(relay_key) + .ok_or_else(|| Error::UnknownRelayChain(relay_key.into()))? + .base, + ), + None => None, + }; + + let database = fs::read_to_string(self.inner.database_path(key)).unwrap_or_default(); + let AddChainSuccess { + chain_id, + json_rpc_responses, + } = self + .inner + .client + .lock() + .unwrap() + .add_chain(AddChainConfig { + user_data: (), + specification: spec, + database_content: &database, + potential_relay_chains: relay.into_iter(), + json_rpc: AddChainConfigJsonRpc::Enabled { + max_pending_requests: NonZero::new(4).unwrap(), + max_subscriptions: 0, + }, + }) + .map_err(|err| Error::AddChain(err.to_string()))?; + + chains.insert( + key.into(), + Registered { + spec: spec.into(), + base: chain_id, + relay, + }, + ); + self.inner + .executor + .spawn(save_database( + self.inner.clone(), + key.to_owned(), + chain_id, + json_rpc_responses.expect("JSON-RPC is enabled"), + )) + .detach(); + Ok(()) + } +} + +impl Inner { + fn database_path(&self, key: &str) -> PathBuf { + database_path(&self.db_dir, key) + } +} + +fn database_path(db_dir: &std::path::Path, key: &str) -> PathBuf { + // Keys come from the app (genesis hashes); keep them from escaping the directory anyway. + let file: String = key + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + db_dir.join(format!("{file}.db")) +} + +/// Constant-time comparison, so the token cannot be guessed byte by byte from timing. +fn token_matches(token: &str, candidate: &str) -> bool { + let (a, b) = (token.as_bytes(), candidate.as_bytes()); + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +async fn accept_loop(inner: Arc, listener: smol::Async) { + loop { + match listener.accept().await { + Ok((stream, _)) => inner.executor.spawn(serve(inner.clone(), stream)).detach(), + Err(err) => { + log::warn!("loopback accept failed: {err}"); + smol::Timer::after(Duration::from_millis(100)).await; + } + } + } +} + +/// Maps `//` to a registered chain key, rejecting everything else during the +/// WebSocket handshake. +fn route(token: &str, is_registered: impl Fn(&str) -> bool, path: &str) -> Option { + let (candidate, key) = path.strip_prefix('/')?.split_once('/')?; + (token_matches(token, candidate) && is_registered(key)).then(|| key.to_owned()) +} + +async fn serve(inner: Arc, stream: smol::Async) { + let mut routed = None; + let handshake = async_tungstenite::accept_hdr_async(stream, |request: &Request, response: Response| { + routed = route( + &inner.token, + |key| inner.chains.lock().unwrap().contains_key(key), + request.uri().path(), + ); + if routed.is_some() { + Ok(response) + } else { + let mut rejection = ErrorResponse::new(None); + *rejection.status_mut() = StatusCode::NOT_FOUND; + Err(rejection) + } + }) + .await; + let (Ok(socket), Some(key)) = (handshake, routed) else { + return; + }; + + let (chain_id, mut responses) = { + let chains = inner.chains.lock().unwrap(); + let registered = &chains[&key]; + let added = inner.client.lock().unwrap().add_chain(AddChainConfig { + user_data: (), + specification: ®istered.spec, + // The base instance already holds the chain; smoldot shares it with this one. + database_content: "", + potential_relay_chains: registered.relay.into_iter(), + json_rpc: AddChainConfigJsonRpc::Enabled { + max_pending_requests: NonZero::new(128).unwrap(), + max_subscriptions: 1024, + }, + }); + match added { + Ok(success) => (success.chain_id, success.json_rpc_responses.expect("JSON-RPC is enabled")), + Err(err) => { + log::warn!("{key}: cannot open a connection instance: {err}"); + return; + } + } + }; + + let (mut sink, mut source) = socket.split(); + let (overflow_tx, overflow_rx) = smol::channel::unbounded::(); + + let inbound = async { + while let Some(Ok(message)) = source.next().await { + let request = match message { + Message::Text(text) => text, + Message::Binary(bytes) => match String::from_utf8(bytes) { + Ok(text) => text, + Err(_) => continue, + }, + Message::Close(_) => break, + _ => continue, + }; + let refused = inner.client.lock().unwrap().json_rpc_request(request, chain_id); + if let Err(HandleRpcError::TooManyPendingRequests { json_rpc_request }) = refused { + if let Some(error) = busy_error(&json_rpc_request) { + let _ = overflow_tx.send(error).await; + } + } + } + }; + let outbound = async { + loop { + let response = futures_util::future::select( + Box::pin(responses.next()), + Box::pin(overflow_rx.recv()), + ) + .await; + let text = match response { + futures_util::future::Either::Left((Some(text), _)) => text, + futures_util::future::Either::Right((Ok(text), _)) => text, + _ => break, + }; + if sink.send(Message::Text(text)).await.is_err() { + break; + } + } + }; + futures_util::future::select(Box::pin(inbound), Box::pin(outbound)).await; + + let () = inner.client.lock().unwrap().remove_chain(chain_id); +} + +/// JSON-RPC error for a request smoldot refused because its queue is full, so the caller gets an +/// answer instead of waiting forever. Notifications (no id) get none. +fn busy_error(request: &str) -> Option { + let id = serde_json::from_str::(request).ok()?.get("id")?.clone(); + Some( + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32000, "message": "light client is busy, retry" }, + }) + .to_string(), + ) +} + +/// Periodically writes the chain's finalized state to disk through its base instance. +async fn save_database( + inner: Arc, + key: String, + chain_id: ChainId, + mut responses: JsonRpcResponses, +) { + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": DATABASE_REQUEST_ID, + "method": "chainHead_unstable_finalizedDatabase", + "params": [], + }) + .to_string(); + let path = inner.database_path(&key); + let mut wait = FIRST_DATABASE_SAVE; + loop { + smol::Timer::after(wait).await; + wait = DATABASE_SAVE_INTERVAL; + if inner + .client + .lock() + .unwrap() + .json_rpc_request(request.clone(), chain_id) + .is_err() + { + continue; + } + while let Some(response) = responses.next().await { + let response: serde_json::Value = match serde_json::from_str(&response) { + Ok(value) => value, + Err(_) => continue, + }; + if response.get("id").and_then(|id| id.as_str()) != Some(DATABASE_REQUEST_ID) { + continue; + } + if let Some(database) = response.get("result").and_then(|db| db.as_str()) { + if let Err(err) = write_atomically(&path, database) { + log::warn!("{key}: cannot save the light client database: {err}"); + } + } + break; + } + } +} + +/// Writes through a temporary file and a rename, so a crash never leaves a torn database. +fn write_atomically(path: &std::path::Path, contents: &str) -> io::Result<()> { + let temporary = path.with_extension("db.tmp"); + fs::write(&temporary, contents)?; + fs::rename(&temporary, path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_requires_the_token_and_a_registered_chain() { + let token = "ab".repeat(32); + let route = |path: &str| route(&token, |key| key == "people", path); + assert_eq!(route(&format!("/{token}/people")), Some("people".into())); + assert_eq!(route(&format!("/{token}/relay")), None); + assert_eq!(route(&format!("/{}/people", "ac".repeat(32))), None); + assert_eq!(route(&format!("/{}/people", &token[1..])), None); + assert_eq!(route(&format!("/{token}people")), None); + assert_eq!(route("/people"), None); + assert_eq!(route(&format!("{token}/people")), None); + } + + #[test] + fn database_path_stays_inside_the_directory() { + assert_eq!( + database_path(std::path::Path::new("/data"), "../../etc/passwd"), + PathBuf::from("/data/______etc_passwd.db") + ); + } + + #[test] + fn busy_error_answers_the_request_id() { + let error = busy_error(r#"{"jsonrpc":"2.0","id":7,"method":"state_call","params":[]}"#).unwrap(); + let error: serde_json::Value = serde_json::from_str(&error).unwrap(); + assert_eq!(error["id"], 7); + assert_eq!(error["error"]["code"], -32000); + assert_eq!(busy_error(r#"{"jsonrpc":"2.0","method":"x"}"#), None); + } +} diff --git a/bindings/pezkuwi-light/rust/src/lib.rs b/bindings/pezkuwi-light/rust/src/lib.rs new file mode 100644 index 00000000..3e6d5cd6 --- /dev/null +++ b/bindings/pezkuwi-light/rust/src/lib.rs @@ -0,0 +1,170 @@ +//! JNI surface of the embedded PezkuwiChain light client. See [`engine`] for how it works. + +pub mod engine; +pub mod platform; + +use engine::Engine; +use jni::objects::{GlobalRef, JObject, JObjectArray, JString}; +use jni::sys::{jboolean, jstring, JNI_TRUE}; +use jni::{JNIEnv, JavaVM}; +use platform::Resolve; +use std::{io, net::IpAddr, sync::Arc, sync::OnceLock}; + +/// One light client per process: smoldot shares chains between its instances, and the app +/// reaches it through a single loopback server. +static ENGINE: OnceLock> = OnceLock::new(); + +fn engine() -> Option<&'static Engine> { + ENGINE.get().and_then(|engine| engine.as_ref().ok()) +} + +fn throw(env: &mut JNIEnv, message: &str) { + let _ = env.throw_new("java/lang/IllegalStateException", message); +} + +fn read_string(env: &mut JNIEnv, value: &JString) -> Option { + env.get_string(value).ok().map(Into::into) +} + +/// Resolves peer host names through the app's `HostResolver`, so the light client follows the +/// app's DNS policy (DNS over HTTPS first). +struct AppResolver { + vm: JavaVM, + resolver: GlobalRef, +} + +impl Resolve for AppResolver { + fn resolve(&self, host: &str) -> io::Result> { + // Blocking-pool threads are reused, so they stay attached instead of paying an attach + // per lookup. + let mut env = self + .vm + .attach_current_thread_permanently() + .map_err(|_| lookup_error(host, "cannot attach to the JVM"))?; + // The frame's own result must convert from `jni::errors::Error`; the lookup's outcome + // travels inside it. + env.with_local_frame(8, |env| -> jni::errors::Result<_> { + Ok(lookup(env, &self.resolver, host)) + }) + .unwrap_or_else(|_| Err(lookup_error(host, "JNI frame failed"))) + } +} + +fn lookup_error(host: &str, what: &str) -> io::Error { + io::Error::new(io::ErrorKind::Other, format!("{host}: {what}")) +} + +/// Calls `String[] resolve(String)` on the app's resolver. A thrown exception (an unknown host) +/// is cleared and reported as an error. +fn lookup(env: &mut JNIEnv, resolver: &GlobalRef, host: &str) -> io::Result> { + let failed = |what| lookup_error(host, what); + let name = env.new_string(host).map_err(|_| failed("invalid name"))?; + let answer = env.call_method( + resolver, + "resolve", + "(Ljava/lang/String;)[Ljava/lang/String;", + &[(&name).into()], + ); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + return Err(failed("no address")); + } + let answer: JObjectArray = answer + .and_then(|value| value.l()) + .map_err(|_| failed("resolver failed"))? + .into(); + let count = env.get_array_length(&answer).map_err(|_| failed("bad answer"))?; + let mut addresses = Vec::new(); + for index in 0..count { + let element: JString = env + .get_object_array_element(&answer, index) + .map_err(|_| failed("bad answer"))? + .into(); + if let Some(address) = read_string(env, &element).and_then(|text| text.parse().ok()) { + addresses.push(address); + } + } + Ok(addresses) +} + +/// Starts the light client (once per process) and returns its loopback base URL. +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_lightclient_PezkuwiLight_start<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + database_dir: JString<'local>, + resolver: JObject<'local>, +) -> jstring { + let Some(database_dir) = read_string(&mut env, &database_dir) else { + throw(&mut env, "invalid database directory"); + return std::ptr::null_mut(); + }; + let resolver = match (env.get_java_vm(), env.new_global_ref(&resolver)) { + (Ok(vm), Ok(resolver)) => AppResolver { vm, resolver }, + _ => { + throw(&mut env, "invalid resolver"); + return std::ptr::null_mut(); + } + }; + let started = ENGINE.get_or_init(|| { + #[cfg(target_os = "android")] + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Info) + .with_tag("PezkuwiLight"), + ); + Engine::start(database_dir.into(), Arc::new(resolver)).map_err(|err| err.to_string()) + }); + match started { + Ok(engine) => match env.new_string(engine.base_url()) { + Ok(url) => url.into_raw(), + Err(_) => std::ptr::null_mut(), + }, + Err(err) => { + throw(&mut env, err); + std::ptr::null_mut() + } + } +} + +/// Registers a chain under `key`; a teyrchain passes its relay chain's key. +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_lightclient_PezkuwiLight_addChain<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + key: JString<'local>, + chain_spec: JString<'local>, + relay_key: JString<'local>, +) { + let Some(engine) = engine() else { + throw(&mut env, "light client is not started"); + return; + }; + let (Some(key), Some(chain_spec)) = + (read_string(&mut env, &key), read_string(&mut env, &chain_spec)) + else { + throw(&mut env, "invalid chain key or specification"); + return; + }; + let relay_key = if relay_key.is_null() { + None + } else { + read_string(&mut env, &relay_key) + }; + if let Err(err) = engine.add_chain(&key, &chain_spec, relay_key.as_deref()) { + throw(&mut env, &err.to_string()); + } +} + +/// Restricts new peer connections to secure WebSockets. +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_lightclient_PezkuwiLight_setSecureOnly<'local>( + mut env: JNIEnv<'local>, + _this: JObject<'local>, + secure_only: jboolean, +) { + match engine() { + Some(engine) => engine.set_secure_only(secure_only == JNI_TRUE), + None => throw(&mut env, "light client is not started"), + } +} diff --git a/bindings/pezkuwi-light/rust/src/platform.rs b/bindings/pezkuwi-light/rust/src/platform.rs new file mode 100644 index 00000000..5280ba9f --- /dev/null +++ b/bindings/pezkuwi-light/rust/src/platform.rs @@ -0,0 +1,423 @@ +//! smoldot platform for the wallet: secure WebSockets and an app-supplied name resolver. +//! +//! smoldot's own `DefaultPlatform` cannot open `wss` connections and resolves names through the +//! system resolver. Where our domains are blocked, both fail: a poisoned DNS answer decides where +//! the client connects, and plain libp2p TCP is recognisable on the wire. This platform: +//! - opens `/dns//tcp//wss` over TLS (rustls, Mozilla roots), so a connection looks +//! like any HTTPS WebSocket; +//! - resolves every name through [`Resolve`], which the app backs with its DNS-over-HTTPS +//! resolver, so the light client and the rest of the app follow one DNS policy; +//! - can be restricted to secure WebSockets only ([`PezkuwiPlatform::set_secure_only`]). +//! +//! The peer's identity is still checked end to end by libp2p Noise, so neither the TLS terminator +//! in front of a node nor a CDN can alter what the client verifies. + +use futures_util::{future, AsyncRead, AsyncWrite, FutureExt as _}; +use smoldot_light::platform::{ + with_buffers, Address, ConnectionType, LogLevel, MultiStreamAddress, + MultiStreamWebRtcConnection, PlatformRef, SubstreamDirection, +}; +use std::{ + borrow::Cow, + future::Future, + fmt::{self, Write as _}, + io, + net::{self, SocketAddr}, + panic, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::{Duration, Instant, UNIX_EPOCH}, +}; + +/// Resolves a host name to addresses. Called on a blocking thread. +pub trait Resolve: Send + Sync + 'static { + fn resolve(&self, host: &str) -> io::Result>; +} + +/// The operating system's resolver, for tests and hosts without an app resolver. +pub struct SystemResolver; + +impl Resolve for SystemResolver { + fn resolve(&self, host: &str) -> io::Result> { + use std::net::ToSocketAddrs as _; + Ok((host, 0).to_socket_addrs()?.map(|addr| addr.ip()).collect()) + } +} + +/// Bound on each TCP connection attempt, so an address that silently drops packets does not hold +/// a connection slot. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone)] +pub struct PezkuwiPlatform(Arc); + +struct Shared { + client_name: String, + client_version: String, + executor: Arc>, + resolver: Arc, + tls: futures_rustls::TlsConnector, + secure_only: AtomicBool, +} + +// smoldot requires `UnwindSafe`. A panic inside a task is caught at the task boundary +// (`spawn_task`), and nothing here is left half-updated across it: the resolver is called +// through `&self`, and the TLS configuration is immutable. +impl panic::UnwindSafe for PezkuwiPlatform {} +impl panic::RefUnwindSafe for PezkuwiPlatform {} + +impl PezkuwiPlatform { + pub fn new(client_name: String, client_version: String, resolver: Arc) -> Self { + let executor = Arc::new(smol::Executor::new()); + let threads = thread::available_parallelism().map_or(2, |n| n.get().min(4)); + for n in 0..threads { + let executor = executor.clone(); + thread::Builder::new() + .name(format!("pezkuwi-light-{n}")) + .spawn(move || smol::block_on(executor.run(future::pending::<()>()))) + .expect("cannot spawn a light client thread"); + } + + let roots = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(), + }; + let tls = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("ring supports the default protocol versions") + .with_root_certificates(roots) + .with_no_client_auth(); + + PezkuwiPlatform(Arc::new(Shared { + client_name, + client_version, + executor, + resolver, + tls: futures_rustls::TlsConnector::from(Arc::new(tls)), + secure_only: AtomicBool::new(false), + })) + } + + /// Restricts new connections to secure WebSockets. Open connections are kept. + pub fn set_secure_only(&self, secure_only: bool) { + self.0.secure_only.store(secure_only, Ordering::Relaxed); + } +} + +/// A connected byte stream: plain TCP, WebSocket, or WebSocket over TLS. +pub trait Socket: AsyncRead + AsyncWrite + Send {} +impl Socket for T {} + +type BoxedSocket = Pin>; +type Connecting = future::BoxFuture<'static, io::Result>; + +#[pin_project::pin_project] +pub struct Stream(#[pin] with_buffers::WithBuffers); + +/// Where to connect and what to speak once connected. +struct Target { + host: Host, + port: u16, + websocket: Option, +} + +enum Host { + Ip(net::IpAddr), + Name(String), +} + +struct WebSocket { + /// `Host` header value. + authority: String, + /// TLS server name, for `wss`. + tls_name: Option, +} + +fn target(address: Address) -> Target { + match address { + Address::TcpIp { ip, port } => Target { + host: Host::Ip(ip), + port, + websocket: None, + }, + Address::TcpDns { hostname, port } => Target { + host: Host::Name(hostname.into()), + port, + websocket: None, + }, + Address::WebSocketIp { ip, port } => { + Target { + host: Host::Ip(ip), + port, + websocket: Some(WebSocket { + authority: SocketAddr::from((ip, port)).to_string(), + tls_name: None, + }), + } + } + Address::WebSocketDns { + hostname, + port, + secure, + } => Target { + host: Host::Name(hostname.into()), + port, + websocket: Some(WebSocket { + authority: if (secure && port == 443) || (!secure && port == 80) { + hostname.into() + } else { + format!("{hostname}:{port}") + }, + tls_name: secure.then(|| hostname.into()), + }), + }, + } +} + +/// IPv4 first: a resolver may return IPv6 addresses on a network without an IPv6 route, and a +/// dead route would otherwise cost a full connect timeout before every connection. +fn ordered(mut addresses: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + addresses.retain(|address| seen.insert(*address)); + addresses.sort_by_key(|address| address.is_ipv6()); + addresses +} + +async fn connect_tcp(shared: Arc, host: Host, port: u16) -> io::Result { + let addresses = match host { + Host::Ip(address) => vec![address], + Host::Name(name) => { + let resolver = shared.resolver.clone(); + ordered(smol::unblock(move || resolver.resolve(&name)).await?) + } + }; + let mut last_error = io::Error::new(io::ErrorKind::NotFound, "the name has no address"); + for address in addresses { + let attempt = smol::net::TcpStream::connect(SocketAddr::from((address, port))); + let timeout = async { + smol::Timer::after(CONNECT_TIMEOUT).await; + Err(io::Error::new(io::ErrorKind::TimedOut, "connect timed out")) + }; + match future::select(Box::pin(attempt), Box::pin(timeout)).await.factor_first().0 { + Ok(socket) => { + let _ = socket.set_nodelay(true); + return Ok(socket); + } + Err(err) => last_error = err, + } + } + Err(last_error) +} + +async fn connect(shared: Arc, target: Target) -> io::Result { + let tcp = connect_tcp(shared.clone(), target.host, target.port).await?; + let Some(websocket) = target.websocket else { + return Ok(Box::pin(tcp)); + }; + match &websocket.tls_name { + None => websocket_handshake(tcp, &websocket.authority).await, + Some(name) => { + let name = rustls::pki_types::ServerName::try_from(name.clone()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let tls = shared.tls.connect(name, tcp).await?; + websocket_handshake(tls, &websocket.authority).await + } + } +} + +async fn websocket_handshake(socket: T, authority: &str) -> io::Result +where + T: AsyncRead + AsyncWrite + Send + Unpin + 'static, +{ + let connection = smoldot::libp2p::websocket::websocket_client_handshake( + smoldot::libp2p::websocket::Config { + tcp_socket: socket, + host: authority, + url: "/", + }, + ) + .await?; + Ok(Box::pin(connection)) +} + +impl PlatformRef for PezkuwiPlatform { + type Delay = future::Map ()>; + type Instant = Instant; + type MultiStream = std::convert::Infallible; + type Stream = Stream; + type StreamConnectFuture = future::Ready; + type MultiStreamConnectFuture = future::Pending>; + type ReadWriteAccess<'a> = with_buffers::ReadWriteAccess<'a, Instant>; + type StreamUpdateFuture<'a> = future::BoxFuture<'a, ()>; + type StreamErrorRef<'a> = &'a io::Error; + type NextSubstreamFuture<'a> = future::Pending>; + + fn now_from_unix_epoch(&self) -> Duration { + UNIX_EPOCH.elapsed().expect("the clock is set after 1970") + } + + fn now(&self) -> Instant { + Instant::now() + } + + fn fill_random_bytes(&self, buffer: &mut [u8]) { + rand::RngCore::fill_bytes(&mut rand::thread_rng(), buffer); + } + + fn sleep(&self, duration: Duration) -> Self::Delay { + smol::Timer::after(duration).map(drop as fn(Instant)) + } + + fn sleep_until(&self, when: Instant) -> Self::Delay { + smol::Timer::at(when).map(drop as fn(Instant)) + } + + fn spawn_task(&self, _task_name: Cow, task: impl Future + Send + 'static) { + self.0 + .executor + .spawn(panic::AssertUnwindSafe(task).catch_unwind().map(drop)) + .detach(); + } + + fn log<'a>( + &self, + log_level: LogLevel, + log_target: &'a str, + message: &'a str, + key_values: impl Iterator, + ) { + let level = match log_level { + LogLevel::Error => log::Level::Error, + LogLevel::Warn => log::Level::Warn, + LogLevel::Info => log::Level::Info, + LogLevel::Debug => log::Level::Debug, + LogLevel::Trace => log::Level::Trace, + }; + if !log::log_enabled!(target: log_target, level) { + return; + } + let mut line = String::from(message); + for (n, (key, value)) in key_values.enumerate() { + let _ = write!(line, "{}{key}={value}", if n == 0 { "; " } else { ", " }); + } + log::log!(target: log_target, level, "{line}"); + } + + fn client_name(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.0.client_name) + } + + fn client_version(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.0.client_version) + } + + fn supports_connection_type(&self, connection_type: ConnectionType) -> bool { + match connection_type { + ConnectionType::WebSocketDns { secure: true, .. } => true, + ConnectionType::TcpIpv4 + | ConnectionType::TcpIpv6 + | ConnectionType::TcpDns + | ConnectionType::WebSocketIpv4 { .. } + | ConnectionType::WebSocketIpv6 { .. } + | ConnectionType::WebSocketDns { secure: false, .. } => { + !self.0.secure_only.load(Ordering::Relaxed) + } + // WebRTC needs a multi-stream implementation, which a native client has no use for. + _ => false, + } + } + + fn connect_stream(&self, address: Address) -> Self::StreamConnectFuture { + let connecting: Connecting = Box::pin(connect(self.0.clone(), target(address))); + future::ready(Stream(with_buffers::WithBuffers::new(connecting))) + } + + fn connect_multistream(&self, _address: MultiStreamAddress) -> Self::MultiStreamConnectFuture { + unreachable!("no multi-stream connection type is supported") + } + + fn open_out_substream(&self, connection: &mut Self::MultiStream) { + match *connection {} + } + + fn next_substream<'a>( + &self, + connection: &'a mut Self::MultiStream, + ) -> Self::NextSubstreamFuture<'a> { + match *connection {} + } + + fn read_write_access<'a>( + &self, + stream: Pin<&'a mut Self::Stream>, + ) -> Result, &'a io::Error> { + stream.project().0.read_write_access(Instant::now()) + } + + fn wait_read_write_again<'a>(&self, stream: Pin<&'a mut Self::Stream>) -> Self::StreamUpdateFuture<'a> { + Box::pin( + stream + .project() + .0 + .wait_read_write_again(|when| smol::Timer::at(when).map(drop)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn platform() -> PezkuwiPlatform { + PezkuwiPlatform::new("test".into(), "0".into(), Arc::new(SystemResolver)) + } + + #[test] + fn secure_only_admits_nothing_but_secure_websockets() { + let platform = platform(); + let plain = [ + ConnectionType::TcpIpv4, + ConnectionType::TcpIpv6, + ConnectionType::TcpDns, + ConnectionType::WebSocketIpv4 { remote_is_localhost: false }, + ConnectionType::WebSocketIpv6 { remote_is_localhost: false }, + ConnectionType::WebSocketDns { secure: false, remote_is_localhost: false }, + ]; + let secure = ConnectionType::WebSocketDns { secure: true, remote_is_localhost: false }; + assert!(platform.supports_connection_type(secure)); + assert!(plain.iter().all(|kind| platform.supports_connection_type(*kind))); + + platform.set_secure_only(true); + assert!(platform.supports_connection_type(secure)); + assert!(plain.iter().all(|kind| !platform.supports_connection_type(*kind))); + } + + #[test] + fn websocket_targets_carry_the_right_host_header_and_tls_name() { + let wss = target(Address::WebSocketDns { hostname: "node.example", port: 443, secure: true }); + let ws = wss.websocket.unwrap(); + assert_eq!((ws.authority.as_str(), ws.tls_name.as_deref()), ("node.example", Some("node.example"))); + + let wss = target(Address::WebSocketDns { hostname: "node.example", port: 8443, secure: true }); + assert_eq!(wss.websocket.unwrap().authority, "node.example:8443"); + + let ws = target(Address::WebSocketDns { hostname: "node.example", port: 30335, secure: false }); + let ws = ws.websocket.unwrap(); + assert_eq!((ws.authority.as_str(), ws.tls_name), ("node.example:30335", None)); + + assert!(target(Address::TcpDns { hostname: "node.example", port: 30333 }).websocket.is_none()); + } + + #[test] + fn ipv4_is_tried_before_ipv6() { + let v6: net::IpAddr = "2001:db8::1".parse().unwrap(); + let v4: net::IpAddr = "192.0.2.1".parse().unwrap(); + let other: net::IpAddr = "192.0.2.2".parse().unwrap(); + assert_eq!(ordered(vec![v6, v4, other, v4]), vec![v4, other, v6]); + } +} diff --git a/bindings/pezkuwi-light/rust/tests/loopback.rs b/bindings/pezkuwi-light/rust/tests/loopback.rs new file mode 100644 index 00000000..ba488b50 --- /dev/null +++ b/bindings/pezkuwi-light/rust/tests/loopback.rs @@ -0,0 +1,233 @@ +//! End-to-end tests of the loopback server. +//! +//! The Zagros test syncs the live testnet, so it is ignored by default. Run it with the chain +//! specifications of the relay chain and People: +//! +//! ```sh +//! PEZKUWI_LIGHT_RELAY_SPEC=zagros.json PEZKUWI_LIGHT_PEOPLE_SPEC=people-zagros.json \ +//! cargo test --release -- --ignored --nocapture +//! ``` + +use pezkuwi_light::{engine::Engine, platform::SystemResolver}; +use std::{ + net::TcpStream, + sync::Arc, + time::{Duration, Instant}, +}; +use tungstenite::{stream::MaybeTlsStream, Message, WebSocket}; + +type Socket = WebSocket>; + +fn temporary_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("pezkuwi-light-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +fn start(db_dir: std::path::PathBuf) -> Engine { + // RUST_LOG=... shows smoldot's own timeline when a measurement needs explaining. + let _ = env_logger::builder().format_timestamp_millis().is_test(true).try_init(); + Engine::start(db_dir, Arc::new(SystemResolver)).unwrap() +} + +/// Connects to the loopback server; a read that waits longer than a sync can take fails the test +/// instead of hanging it. +fn connect(url: String) -> Socket { + let (socket, _) = tungstenite::connect(url).unwrap(); + if let MaybeTlsStream::Plain(stream) = socket.get_ref() { + stream.set_read_timeout(Some(Duration::from_secs(180))).unwrap(); + } + socket +} + +fn rpc(socket: &mut Socket, id: u64, method: &str, params: serde_json::Value) { + let request = serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + socket.send(Message::Text(request.to_string())).unwrap(); +} + +fn next_json(socket: &mut Socket) -> serde_json::Value { + loop { + if let Message::Text(text) = socket.read().unwrap() { + return serde_json::from_str(&text).unwrap(); + } + } +} + +fn answer(socket: &mut Socket, id: u64) -> serde_json::Value { + loop { + let message = next_json(socket); + if message["id"] == id { + return message; + } + } +} + +/// `Vec` SCALE encoding: compact length, then the bytes. +fn scale_bytes(bytes: &[u8]) -> String { + assert!(bytes.len() < 64, "single-byte compact length only"); + format!("0x{:02x}{}", (bytes.len() as u8) << 2, hex::encode(bytes)) +} + +#[test] +fn a_path_without_the_token_is_refused() { + let engine = start(temporary_dir("refused")); + let base = engine.base_url(); + let (_, token) = base.rsplit_once('/').unwrap(); + let wrong = format!("{}{}", &token[..token.len() - 1], if token.ends_with('0') { '1' } else { '0' }); + let port = base.split(':').nth(2).unwrap().split('/').next().unwrap(); + + for url in [ + format!("ws://127.0.0.1:{port}/{wrong}/relay"), + format!("ws://127.0.0.1:{port}/relay"), + // The token is right but no chain is registered under the key. + format!("{base}/relay"), + ] { + match tungstenite::connect(&url) { + Err(tungstenite::Error::Http(response)) => assert_eq!(response.status(), 404, "{url}"), + other => panic!("{url} was not refused: {:?}", other.map(|_| ())), + } + } +} + +#[test] +#[ignore = "syncs the live Zagros testnet; needs PEZKUWI_LIGHT_RELAY_SPEC and PEZKUWI_LIGHT_PEOPLE_SPEC"] +fn resolves_kurd_names_on_zagros_people() { + let relay = std::fs::read_to_string(std::env::var("PEZKUWI_LIGHT_RELAY_SPEC").unwrap()).unwrap(); + let people = std::fs::read_to_string(std::env::var("PEZKUWI_LIGHT_PEOPLE_SPEC").unwrap()).unwrap(); + let db_dir = temporary_dir("zagros"); + let started = Instant::now(); + + let engine = start(db_dir.clone()); + engine.add_chain("relay", &relay, None).unwrap(); + engine.add_chain("people", &people, Some("relay")).unwrap(); + assert!(engine.add_chain("people", &people, Some("relay")).is_err()); + + let mut socket = connect(format!("{}/people", engine.base_url())); + rpc(&mut socket, 1, "chain_subscribeFinalizedHeads", serde_json::json!([])); + loop { + if next_json(&mut socket).pointer("/params/result/number").is_some() { + break; + } + } + println!("first verified People head after {:.1}s", started.elapsed().as_secs_f64()); + let synced_at = std::time::SystemTime::now(); + + // Registered on Zagros People by the Meclis grant of 2026-10-31: Governance-owned, active, + // official. The full node answers the same bytes. + rpc(&mut socket, 2, "state_call", serde_json::json!(["NavnisanApi_resolve", scale_bytes(b"dks.kurd")])); + let resolved = answer(&mut socket, 2)["result"].as_str().unwrap().to_owned(); + assert!( + resolved.starts_with("0x0195c0de27b9a054a3a68cf2bb26abf99071ad46c2485295bb4120185a811a042f"), + "{resolved}" + ); + + rpc(&mut socket, 3, "state_call", serde_json::json!(["NavnisanApi_resolve", scale_bytes(b"yok-boyle-bir-ad.kurd")])); + assert_eq!(answer(&mut socket, 3)["result"], "0x00"); + + // A second connection to the same chain shares the sync and answers at once. + let mut second = connect(format!("{}/people", engine.base_url())); + let asked = Instant::now(); + rpc(&mut second, 4, "state_call", serde_json::json!(["NavnisanApi_resolve", scale_bytes(b"dks.kurd")])); + assert_eq!(answer(&mut second, 4)["result"].as_str().unwrap(), resolved); + println!("second connection answered in {:.2}s", asked.elapsed().as_secs_f64()); + + // The finalized state is saved for the next start: a save made after the first verified + // head, from which a new client resumes. + let deadline = Instant::now() + Duration::from_secs(150); + for file in ["relay.db", "people.db"] { + let path = db_dir.join(file); + while std::fs::metadata(&path).and_then(|m| m.modified()).map_or(true, |at| at < synced_at) { + assert!(Instant::now() < deadline, "{file} was not saved after the sync"); + std::thread::sleep(Duration::from_millis(500)); + } + println!("{file}: {} bytes", std::fs::metadata(&path).unwrap().len()); + } + + let restarted = Instant::now(); + let warm = start(db_dir); + warm.add_chain("relay", &relay, None).unwrap(); + warm.add_chain("people", &people, Some("relay")).unwrap(); + let mut socket = connect(format!("{}/people", warm.base_url())); + rpc(&mut socket, 5, "state_call", serde_json::json!(["NavnisanApi_resolve", scale_bytes(b"dks.kurd")])); + assert_eq!(answer(&mut socket, 5)["result"].as_str().unwrap(), resolved); + println!("warm start resolved after {:.1}s", restarted.elapsed().as_secs_f64()); +} + +/// Replaces a specification's boot nodes. +fn with_boot_nodes(spec: &str, boot_nodes: &[&str]) -> String { + let mut spec: serde_json::Value = serde_json::from_str(spec).unwrap(); + spec["bootNodes"] = serde_json::json!(boot_nodes); + spec.to_string() +} + +#[test] +#[ignore = "syncs the live Zagros testnet; needs PEZKUWI_LIGHT_RELAY_SPEC and PEZKUWI_LIGHT_PEOPLE_SPEC"] +fn syncs_zagros_over_secure_websockets_only() { + // The `/wss` boot nodes of 2026-10-31: nginx on 443 in front of each node's WebSocket port. + let relay = with_boot_nodes( + &std::fs::read_to_string(std::env::var("PEZKUWI_LIGHT_RELAY_SPEC").unwrap()).unwrap(), + &[ + "/dns/zagros-bootnode-0.pezkuwichain.io/tcp/443/wss/p2p/12D3KooWHKMr39ubvsigZXdVJzr7nnMp5YTzMywKuTzPDo3Xf1tZ", + "/dns/zagros-bootnode-1.pezkuwichain.io/tcp/443/wss/p2p/12D3KooWH6tzPuoFeyf8n6gNmus9Q3CdEGEVUvbduUnPTqJcLRLC", + ], + ); + let people = with_boot_nodes( + &std::fs::read_to_string(std::env::var("PEZKUWI_LIGHT_PEOPLE_SPEC").unwrap()).unwrap(), + &["/dns/zagros-people-bootnode.pezkuwichain.io/tcp/443/wss/p2p/12D3KooWLnDS5p5bvMzfco2BR4arKRbtpqoZwRvLKgCdzgVpuEQG"], + ); + let started = Instant::now(); + let engine = start(temporary_dir("wss")); + // Peers found through the DHT advertise plain TCP too; none of them may be used. + engine.set_secure_only(true); + engine.add_chain("relay", &relay, None).unwrap(); + engine.add_chain("people", &people, Some("relay")).unwrap(); + + let mut socket = connect(format!("{}/people", engine.base_url())); + rpc(&mut socket, 1, "state_call", serde_json::json!(["NavnisanApi_resolve", scale_bytes(b"dks.kurd")])); + let resolved = answer(&mut socket, 1)["result"].as_str().unwrap().to_owned(); + assert!( + resolved.starts_with("0x0195c0de27b9a054a3a68cf2bb26abf99071ad46c2485295bb4120185a811a042f"), + "{resolved}" + ); + println!("resolved over wss only after {:.1}s", started.elapsed().as_secs_f64()); +} + +/// Measures a start from a saved database in a process of its own, as an app launch is. The first +/// run with an empty `PEZKUWI_LIGHT_DB_DIR` syncs and waits for a save; later runs resume from it. +#[test] +#[ignore = "syncs the live Zagros testnet; needs PEZKUWI_LIGHT_RELAY_SPEC, PEZKUWI_LIGHT_PEOPLE_SPEC and PEZKUWI_LIGHT_DB_DIR"] +fn resolves_after_a_start_from_the_saved_database() { + let relay = std::fs::read_to_string(std::env::var("PEZKUWI_LIGHT_RELAY_SPEC").unwrap()).unwrap(); + let people = std::fs::read_to_string(std::env::var("PEZKUWI_LIGHT_PEOPLE_SPEC").unwrap()).unwrap(); + let db_dir = std::path::PathBuf::from(std::env::var("PEZKUWI_LIGHT_DB_DIR").unwrap()); + let warm = db_dir.join("people.db").exists(); + let started = Instant::now(); + + let engine = start(db_dir.clone()); + engine.add_chain("relay", &relay, None).unwrap(); + engine.add_chain("people", &people, Some("relay")).unwrap(); + let mut socket = connect(format!("{}/people", engine.base_url())); + rpc(&mut socket, 1, "state_call", serde_json::json!(["NavnisanApi_resolve", scale_bytes(b"dks.kurd")])); + let resolved = answer(&mut socket, 1)["result"].as_str().unwrap().to_owned(); + assert!( + resolved.starts_with("0x0195c0de27b9a054a3a68cf2bb26abf99071ad46c2485295bb4120185a811a042f"), + "{resolved}" + ); + println!( + "{} start resolved after {:.1}s", + if warm { "warm" } else { "cold" }, + started.elapsed().as_secs_f64() + ); + + if !warm { + let synced_at = std::time::SystemTime::now(); + let deadline = Instant::now() + Duration::from_secs(150); + for file in ["relay.db", "people.db"] { + let path = db_dir.join(file); + while std::fs::metadata(&path).and_then(|m| m.modified()).map_or(true, |at| at < synced_at) { + assert!(Instant::now() < deadline, "{file} was not saved after the sync"); + std::thread::sleep(Duration::from_millis(500)); + } + } + } +} diff --git a/bindings/pezkuwi-light/src/main/AndroidManifest.xml b/bindings/pezkuwi-light/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cc947c56 --- /dev/null +++ b/bindings/pezkuwi-light/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/HostResolver.kt b/bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/HostResolver.kt new file mode 100644 index 00000000..8c701e18 --- /dev/null +++ b/bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/HostResolver.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.lightclient + +/** + * Resolves the host names of light client peers, so they follow the app's DNS policy. + * + * Called from native threads, never the main thread. Throw (for example + * [java.net.UnknownHostException]) when the name has no address. + */ +fun interface HostResolver { + + /** Addresses of [host] as IP literals, IPv4 or IPv6. */ + fun resolve(host: String): Array +} diff --git a/bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/PezkuwiLight.kt b/bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/PezkuwiLight.kt new file mode 100644 index 00000000..434799f5 --- /dev/null +++ b/bindings/pezkuwi-light/src/main/java/io/novafoundation/nova/lightclient/PezkuwiLight.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.lightclient + +import java.io.File + +/** + * Embedded PezkuwiChain light client (smoldot fork `pezkuwichain/smoldot`). + * + * Every answer is verified against finalized block headers, so it does not depend on trusting + * an RPC node. The client is reached like any JSON-RPC node, over a WebSocket bound to + * 127.0.0.1 whose path carries a random per-process token: `chainUrl(start(dir), key)`. + */ +object PezkuwiLight { + + init { + System.loadLibrary("pezkuwi_light") + } + + /** + * Starts the light client once per process and returns its loopback base URL. Later calls + * return the same URL. [databaseDir] keeps each chain's finalized state between starts; + * [resolver] resolves the host names of peers. + */ + fun start(databaseDir: File, resolver: HostResolver): String = start(databaseDir.absolutePath, resolver) + + /** + * Registers [chainSpec] under [key] and starts syncing it. A teyrchain names the [key] its + * relay chain was registered under, which must come first. + * + * @throws IllegalStateException if the client is not started, the key is taken, or smoldot + * rejects the specification + */ + external fun addChain(key: String, chainSpec: String, relayChainKey: String?) + + /** + * Restricts new peer connections to secure WebSockets (`wss`, normally on port 443), which + * look like any HTTPS WebSocket on the wire. Open connections are kept. + */ + external fun setSecureOnly(secureOnly: Boolean) + + fun chainUrl(baseUrl: String, key: String): String = "$baseUrl/$key" + + private external fun start(databaseDir: String, resolver: HostResolver): String +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt b/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt index d7b54e4a..bb58b1aa 100644 --- a/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt +++ b/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt @@ -15,6 +15,7 @@ import io.novafoundation.nova.common.data.network.AppLinksProvider import io.novafoundation.nova.common.data.network.HttpExceptionHandler import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.network.dns.EncryptedFirstDns import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository @@ -167,6 +168,8 @@ interface CommonApi { fun provideEncryptedPreferences(): EncryptedPreferences + fun encryptedFirstDns(): EncryptedFirstDns + fun provideIconGenerator(): IconGenerator fun provideClipboardManager(): ClipboardManager diff --git a/runtime/build.gradle b/runtime/build.gradle index ca53ccd1..5893b734 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -57,6 +57,7 @@ dependencies { implementation project(":bindings:metadata_shortener") implementation project(":bindings:sr25519-bizinikiwi") + implementation project(":bindings:pezkuwi-light") implementation gsonDep implementation substrateSdkDep diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt index 29ff4187..ceec116a 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt @@ -6,6 +6,7 @@ import dagger.Module import dagger.Provides import io.novafoundation.nova.common.BuildConfig import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.dns.EncryptedFirstDns import io.novafoundation.nova.common.data.network.rpc.BulkRetriever import io.novafoundation.nova.common.data.storage.Preferences import io.novafoundation.nova.common.di.scope.ApplicationScope @@ -25,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.LightClients import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory import io.novafoundation.nova.runtime.multiNetwork.runtime.AsyncChainSyncDispatcher @@ -179,16 +181,25 @@ class ChainRegistryModule { .build() ) + @Provides + @ApplicationScope + fun provideLightClients( + context: Context, + encryptedFirstDns: EncryptedFirstDns, + ) = LightClients(context, encryptedFirstDns) + @Provides @ApplicationScope fun provideChainConnectionFactory( socketProvider: Provider, externalRequirementsFlow: MutableStateFlow, nodeAutobalancer: NodeAutobalancer, + lightClients: LightClients, ) = ChainConnectionFactory( externalRequirementsFlow, nodeAutobalancer, socketProvider, + lightClients, ) @Provides diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt index f56a081e..732ea32e 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt @@ -3,6 +3,7 @@ package io.novafoundation.nova.runtime.di import android.content.Context import com.google.gson.Gson import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.dns.EncryptedFirstDns import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository import io.novafoundation.nova.common.data.storage.Preferences import io.novafoundation.nova.common.interfaces.FileProvider @@ -32,4 +33,6 @@ interface RuntimeDependencies { fun chainAssetDao(): ChainAssetDao fun assetsIconModeService(): AssetsIconModeRepository + + fun encryptedFirstDns(): EncryptedFirstDns } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ChainConnection.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ChainConnection.kt index 0e73a1c6..2696724a 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ChainConnection.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ChainConnection.kt @@ -5,6 +5,8 @@ import io.novafoundation.nova.common.utils.LOG_TAG import io.novafoundation.nova.common.utils.share import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.NodeAutobalancer +import io.novafoundation.nova.runtime.multiNetwork.connection.light.LightClients +import io.novafoundation.nova.runtime.multiNetwork.connection.light.LightFallbackPolicy import io.novasama.substrate_sdk_android.wsrpc.SocketService import io.novasama.substrate_sdk_android.wsrpc.interceptor.WebSocketResponseInterceptor import io.novasama.substrate_sdk_android.wsrpc.interceptor.WebSocketResponseInterceptor.ResponseDelivery @@ -14,6 +16,7 @@ import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine.State import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +24,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -28,12 +32,14 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform import javax.inject.Provider class ChainConnectionFactory( private val externalRequirementFlow: Flow, private val nodeAutobalancer: NodeAutobalancer, private val socketServiceProvider: Provider, + private val lightClients: LightClients, ) { suspend fun create(chain: Chain): ChainConnection { @@ -41,7 +47,8 @@ class ChainConnectionFactory( socketService = socketServiceProvider.get(), externalRequirementFlow = externalRequirementFlow, nodeAutobalancer = nodeAutobalancer, - initialChain = chain + initialChain = chain, + lightClients = lightClients.takeIf { it.supports(chain.id) }, ) connection.setup() @@ -58,6 +65,11 @@ private const val BLUST_RATE_LIMIT_ERROR_CODE = -32097 private const val ON_FINALITY_RATE_LIMIT_ERROR_CODE = -32029 +/** How often a connection on the light client checks whether to retry RPC. */ +private const val LIGHT_FALLBACK_TICK_MILLIS = 15_000L + +private const val LIGHT_FALLBACK_TAG = "LightFallback" + private val RATE_LIMIT_ERROR_CODES = listOf( INFURA_ERROR_CODE, ALCHEMY_ERROR_CODE, @@ -71,6 +83,7 @@ class ChainConnection internal constructor( private val externalRequirementFlow: Flow, private val nodeAutobalancer: NodeAutobalancer, initialChain: Chain, + private val lightClients: LightClients? = null, ) : CoroutineScope by CoroutineScope(Dispatchers.Default), WebSocketResponseInterceptor { @@ -94,11 +107,14 @@ class ChainConnection internal constructor( .distinctUntilChanged() .share(SharingStarted.Eagerly) - val currentUrl = nodeAutobalancer.connectionUrlFlow( + private val rpcUrl = nodeAutobalancer.connectionUrlFlow( chainId = initialChain.id, changeConnectionEventFlow = nodeChangeSignal, availableNodesFlow = availableNodes, - ).share(SharingStarted.Eagerly) + ) + + val currentUrl = (lightClients?.let { rpcUrl.withLightFallback(it) } ?: rpcUrl) + .share(SharingStarted.Eagerly) suspend fun setup() { socketService.setInterceptor(this) @@ -138,15 +154,7 @@ class ChainConnection internal constructor( socketService.stop() } - private suspend fun actualUrl(): String? { - return when (val stateSnapshot = state.first()) { - is State.WaitingForReconnect -> stateSnapshot.url - is State.Connecting -> stateSnapshot.url - is State.Connected -> stateSnapshot.url - State.Disconnected -> null - is State.Paused -> stateSnapshot.url - } - } + private suspend fun actualUrl(): String? = state.first().urlOrNull() private fun Flow.nodeChangeEvents(): Flow { return mapNotNull { stateValue -> @@ -156,6 +164,86 @@ class ChainConnection internal constructor( private fun State.needsAutobalance() = this is State.WaitingForReconnect && attempt > 1 + private sealed interface FallbackEvent { + class Rpc(val node: NodeWithSaturatedUrl?) : FallbackEvent + class Socket(val state: State) : FallbackEvent + object Tick : FallbackEvent + } + + /** + * Moves the connection to the light client once every RPC node has failed in a row, and back + * to RPC when a retry connects (see [LightFallbackPolicy]). Events are handled one at a time, + * in the order they arrive. + */ + private fun Flow.withLightFallback(lightClients: LightClients): Flow { + Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: light client fallback armed") + val policy = LightFallbackPolicy() + var latestRpc: NodeWithSaturatedUrl? = null + var light: NodeWithSaturatedUrl? = null + val ticks = flow { + while (true) { + delay(LIGHT_FALLBACK_TICK_MILLIS) + emit(FallbackEvent.Tick) + } + } + val rpcNodeCount = { chain.value.nodes.nodes.count { it.connectionType == Chain.Node.ConnectionType.WSS } } + + return merge(map(FallbackEvent::Rpc), state.map(FallbackEvent::Socket), ticks).transform { event -> + when (event) { + is FallbackEvent.Rpc -> { + latestRpc = event.node + if (policy.source == LightFallbackPolicy.Source.RPC) emit(event.node) + } + + is FallbackEvent.Socket -> { + val onLight = light != null && event.state.urlOrNull() == light?.saturatedUrl + if (event.state is State.Connected && !onLight) policy.onRpcConnected() + + if (event.state.needsAutobalance()) { + Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: ${event.state} failed, source ${policy.source}, on light $onLight") + if (onLight) { + policy.onLightUnavailable(rpcNodeCount()) + emit(latestRpc) + } else if (policy.onRpcFailure(rpcNodeCount(), System.currentTimeMillis())) { + val url = lightClients.url(chain.value.id) + Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: light client url $url") + if (url == null) { + policy.onLightUnavailable(rpcNodeCount()) + } else { + Log.d(LIGHT_FALLBACK_TAG, "Every RPC node of ${chain.value.name} failed, using the light client") + light = lightClientNode(url).also { emit(it) } + } + } + } + } + + FallbackEvent.Tick -> if (policy.onTick(rpcNodeCount(), System.currentTimeMillis())) { + Log.d(LIGHT_FALLBACK_TAG, "${chain.value.name}: retrying RPC") + emit(latestRpc) + } + } + } + } + + private fun lightClientNode(url: String) = NodeWithSaturatedUrl( + node = Chain.Node( + chainId = chain.value.id, + unformattedUrl = url, + name = "Light client", + orderId = Int.MAX_VALUE, + isCustom = false, + ), + saturatedUrl = url, + ) + + private fun State.urlOrNull(): String? = when (this) { + is State.WaitingForReconnect -> url + is State.Connecting -> url + is State.Connected -> url + State.Disconnected -> null + is State.Paused -> url + } + override fun onRpcResponseReceived(rpcResponse: RpcResponse): ResponseDelivery { val error = rpcResponse.error diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt new file mode 100644 index 00000000..262250bf --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.light + +import android.content.Context +import android.util.Log +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.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okhttp3.Dns +import java.io.File + +/** + * 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. + */ +class LightClients( + private val context: Context, + private val dns: Dns, +) { + + private class LightChain(val asset: 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), + ) + + private val mutex = Mutex() + private var baseUrl: String? = null + private val registered = mutableSetOf() + + fun supports(chainId: ChainId): Boolean = chainId in chains + + /** Loopback JSON-RPC URL of [chainId], or null when the light client cannot serve it. */ + suspend fun url(chainId: ChainId): String? = mutex.withLock { + withContext(Dispatchers.IO) { + runCatching { + if (!supports(chainId)) return@runCatching null + val base = baseUrl ?: PezkuwiLight.start(File(context.filesDir, DATABASE_DIR), resolver()).also { baseUrl = it } + register(chainId) + PezkuwiLight.chainUrl(base, chainId) + }.onFailure { + Log.w(LOG_TAG, "Light client unavailable for $chainId", it) + }.getOrNull() + } + } + + 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) + registered += chainId + } + + private fun resolver() = HostResolver { host -> + dns.lookup(host).mapNotNull { it.hostAddress }.toTypedArray() + } + + private companion object { + const val ZAGROS: ChainId = "1b0b4727bbb5e44ed587d1056b4ad537ffb330034509a58866a088d77e792cd5" + const val ZAGROS_PEOPLE: ChainId = "c72b38f36affaeb2661018b201c2be02e8c09fc699640525d39200a10fd77878" + + const val DATABASE_DIR = "light-client" + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicy.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicy.kt new file mode 100644 index 00000000..f146a2ae --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicy.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.light + +/** + * When a chain's connection moves from its RPC nodes to the embedded light client, and back. + * + * RPC comes first: a light client puts load on the peer-to-peer nodes that RPC does not, and + * 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. + * + * 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, +) { + + enum class Source { RPC, LIGHT } + + var source = Source.RPC + private set + + private var consecutiveRpcFailures = 0 + private var backoffMillis = initialBackoffMillis + private var rpcRetryAt: 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 + + consecutiveRpcFailures++ + if (consecutiveRpcFailures < rpcNodeCount.coerceAtLeast(1)) return false + + source = Source.LIGHT + rpcRetryAt = nowMillis + backoffMillis + backoffMillis = (backoffMillis * 2).coerceAtMost(maxBackoffMillis) + return true + } + + fun onRpcConnected() { + source = Source.RPC + consecutiveRpcFailures = 0 + backoffMillis = initialBackoffMillis + rpcRetryAt = null + } + + /** + * True when RPC should be tried again now. One more RPC failure then hands the connection + * straight back to the light client, without walking every node again. + */ + fun onTick(rpcNodeCount: Int, nowMillis: Long): Boolean { + val retryAt = rpcRetryAt ?: return false + if (source != Source.LIGHT || nowMillis < retryAt) return false + + returnToRpc(rpcNodeCount) + 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) + } + + private fun returnToRpc(rpcNodeCount: Int) { + source = Source.RPC + consecutiveRpcFailures = (rpcNodeCount - 1).coerceAtLeast(0) + rpcRetryAt = null + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicyTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicyTest.kt new file mode 100644 index 00000000..1444397b --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightFallbackPolicyTest.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.light + +import io.novafoundation.nova.runtime.multiNetwork.connection.light.LightFallbackPolicy.Source +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LightFallbackPolicyTest { + + private val minute = 60_000L + private val policy = LightFallbackPolicy(initialBackoffMillis = minute, maxBackoffMillis = 4 * minute) + + @Test + fun `light client takes over only after every RPC node failed in a row`() { + assertFalse(policy.onRpcFailure(rpcNodeCount = 3, nowMillis = 0)) + assertFalse(policy.onRpcFailure(rpcNodeCount = 3, nowMillis = 0)) + assertEquals(Source.RPC, policy.source) + + assertTrue(policy.onRpcFailure(rpcNodeCount = 3, nowMillis = 0)) + assertEquals(Source.LIGHT, policy.source) + } + + @Test + fun `a connected RPC node resets the count`() { + policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0) + policy.onRpcConnected() + + assertFalse(policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0)) + assertTrue(policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0)) + } + + @Test + fun `a single RPC node hands over on its first failure`() { + assertTrue(policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 0)) + } + + @Test + fun `RPC is retried after the backoff and one more failure returns to the light client`() { + policy.onRpcFailure(rpcNodeCount = 3, nowMillis = 0) + policy.onRpcFailure(rpcNodeCount = 3, nowMillis = 0) + policy.onRpcFailure(rpcNodeCount = 3, nowMillis = 0) + + assertFalse(policy.onTick(rpcNodeCount = 3, nowMillis = minute - 1)) + assertTrue(policy.onTick(rpcNodeCount = 3, nowMillis = minute)) + assertEquals(Source.RPC, policy.source) + + assertTrue(policy.onRpcFailure(rpcNodeCount = 3, nowMillis = minute)) + assertEquals(Source.LIGHT, policy.source) + } + + @Test + fun `the backoff doubles while RPC keeps failing, up to the maximum`() { + var now = 0L + policy.onRpcFailure(rpcNodeCount = 1, nowMillis = now) + + for (expectedWait in listOf(minute, 2 * minute, 4 * minute, 4 * minute)) { + assertFalse(policy.onTick(rpcNodeCount = 1, nowMillis = now + expectedWait - 1)) + now += expectedWait + assertTrue(policy.onTick(rpcNodeCount = 1, nowMillis = now)) + assertTrue(policy.onRpcFailure(rpcNodeCount = 1, nowMillis = now)) + } + } + + @Test + fun `a connected RPC retry resets the backoff`() { + policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 0) + policy.onTick(rpcNodeCount = 1, nowMillis = minute) + policy.onRpcFailure(rpcNodeCount = 1, nowMillis = minute) + policy.onTick(rpcNodeCount = 1, nowMillis = 3 * minute) + policy.onRpcConnected() + + assertTrue(policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 10 * minute)) + assertFalse(policy.onTick(rpcNodeCount = 1, nowMillis = 11 * minute - 1)) + assertTrue(policy.onTick(rpcNodeCount = 1, nowMillis = 11 * minute)) + } + + @Test + fun `an unavailable light client leaves the connection on RPC`() { + policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0) + policy.onRpcFailure(rpcNodeCount = 2, nowMillis = 0) + policy.onLightUnavailable(rpcNodeCount = 2) + + assertEquals(Source.RPC, policy.source) + assertFalse(policy.onTick(rpcNodeCount = 2, nowMillis = 10 * minute)) + } + + @Test + fun `no failure is counted while on the light client`() { + policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 0) + assertFalse(policy.onRpcFailure(rpcNodeCount = 1, nowMillis = 0)) + assertEquals(Source.LIGHT, policy.source) + } +} diff --git a/settings.gradle b/settings.gradle index 50dba306..4c81b7ed 100644 --- a/settings.gradle +++ b/settings.gradle @@ -38,6 +38,7 @@ include ':feature-buy-api' include ':feature-buy-impl' include ':bindings:hydra-dx-math' include ':bindings:sr25519-bizinikiwi' +include ':bindings:pezkuwi-light' include ':feature-proxy-impl' include ':feature-proxy-api' include ':feature-push-notifications'