mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-04 18:01:14 +00:00
* Strip native libraries in the app and keep light client line tables
The app module named no NDK, so AGP expected its default 27.0 against the 26.1 that CI installs,
could not strip any native library and made no native-debug-symbols.zip for Play. Pin 26.1 in the
app. The light client now keeps line tables for that symbol file; the packaged library is still
stripped to 8.6 MB.
Also adds an XCM encoding test for PezkuwiChain networks. This commit points WESTEND at Zagros
to check that the test fails against that change.
* Fix the encoding test's read of the interior field
* Encode PezkuwiChain networks by genesis in XCM
WESTEND held a stale genesis labelled Zagros. The runtimes declare ThisNetwork as ByGenesis and
the XCM v5 NetworkId has no Westend or Zagros variant, so the named mapping is removed and every
PezkuwiChain network is encoded as ByGenesis.
* Run CodeQL for every language on hosted runners, Kotlin with a build
Default setup analyses Java without a build and leaves out every Kotlin file, and the Java job on the
self-hosted runner stopped at the NDK install because sudo asks for a password, so Kotlin had not been
analysed since February. One matrix job covers actions, JavaScript/TypeScript, Rust and Java/Kotlin.
* Use CodeQL Action v4, v3 is deprecated in December 2026
* Build for CodeQL without Gradle and Kotlin daemons so the analysis has the memory
* Show incoming USDT bridge credits in history without a refresh
The realtime transfer extractor for assets pallets knew only transfer and transfer_keep_alive. The
bridge credits with transfer_approved, spending the multisig's allowance, so a credit appeared only
after the indexer caught up and the user refreshed. transfer_approved is now a transfer from owner to
destination; the signing delegate is not the sender.
* Stop the light client five minutes after RPC is back
A chain registered with the light client kept syncing for as long as the process lived. Measured on
the emulator over 5-minute windows, that was about 14 MB of data an hour and 2% of a core while the
wallet read nothing from it.
Once RPC has stayed connected for five minutes after a fallback, the connection releases the light
client: LightClients unregisters the chain, a relay chain only after its teyrchains. The engine gains
remove_chain. Its database saver now checks, under the registry lock, that the chain is still
registered: smoldot panics on a request to a removed chain. The next fallback starts cold from the
saved database.
* Refresh light client specifications from the signed config while RPC works
The light client started from the checkpoint the APK was built with, so a first fallback months after
a release warp-synced from months back, and on a phone that never fell back there is no saved database
to help. The fallback is wanted when RPC is blocked, and then the config repository may be blocked too.
On the first RPC connection of the process, each chain's specification is fetched from the config
repository through the app's HTTP client, which checks it against the signed targets and falls back to
the mirrors. It is kept when it is the same chain (id and genesis state root) with a later checkpoint;
an older file never rolls the checkpoint back. Registration uses the kept file when it is later than the
APK's own.
* Close the light client's peer connections once its last chain is removed
smoldot keeps its peer-to-peer network service, with the connections in it, for as long as the client
lives, so removing every chain left them open: on the emulator, four libp2p connections were still up
eleven minutes after the release, and data use stayed above the RPC-only level. The engine now
replaces the client once no chain and no loopback connection is left; the connections close about 20 s
later, when the attempts under way finish.
The database saver now checks a per-registration number instead of the smoldot chain id, which smoldot
reuses after a removal: a saver of a removed chain could otherwise keep running for the next chain
that got the same id. A connection whose chain is removed between the handshake and its setup now
ends instead of panicking.
* Obfuscate the app's own classes and build the R8 variant in pull requests
-keep class io.novafoundation.nova.** { *; } with -keepnames left 2.6% of the app's 26,602 classes
renamed in the 1.3.0 Play build (15.6% of all classes). It is replaced by keeping only the field names
of the app's classes, which Gson reads by name where a class has no @SerializedName, so class and method
names are obfuscated. No persisted value or reflective lookup in the app depends on a class or method
name: class names appear only in logs and in an in-memory cache key.
@JavascriptInterface methods had no rule of their own; the blanket keep covered the DApp browser's web3
bridge, its integrity check and the Transak bridge. They are kept explicitly.
Pull requests built only the unminified develop variant, so an R8 break would first show in the Play
build. A second job builds releaseTest (the release rules, debug-signed) and uploads its APK and mapping.
* Give the releaseTest build the debug package, which the CI Firebase config knows
* Build releaseTest under the production package, the one its Firebase config has
* Stop keeping every app class under the JNI rule
-keep class io.novafoundation.nova.** { native <methods>; } keeps every class the pattern matches,
whether or not it has a native method, so the app's class names stayed unobfuscated after the blanket
keep went: 2.6% of its classes were renamed in the R8 build. The rule under it already keeps the names
of classes with native methods and of those methods, which is what JNI binds by name; the Rust
bindings look up no other app class.
* Keep TypeToken and Retrofit generic signatures under R8 full mode
The R8 build failed to map every chain from the local database: R8 full mode keeps generic signatures
only on kept classes, and the app's anonymous TypeToken subclasses had been kept by the blanket rule.
These are the rules Gson 2.11 and Retrofit 2.10 ship; the app is on Gson 2.10.1 and Retrofit 2.9.0.
* Keep constructors of the app's classes so Gson can instantiate its models
The R8 build failed to read the DApp list: R8 full mode had made DappMetadataResponse abstract because
nothing in the code instantiates it, and Gson does so reflectively. It matches none of the name
patterns the rules keep models by. Keeping constructors costs no obfuscation, since they are all named
<init>.
* Keep the type argument of web3j TypeReference subclasses under R8 full mode
* Run R8 in compatibility mode
After the app's own classes became obfuscatable, the full-mode R8 build kept breaking reflective
decoding in new places: TypeToken and TypeReference subclasses lost their type argument, a Gson model
was made abstract, and SubQueryResponse<T> lost its class signature so a proxy query decoded to a
LinkedTreeMap. Full mode keeps generic signatures only on explicitly kept classes, and this code base
has many generic models outside any name pattern. Compatibility mode keeps them everywhere under
-keepattributes Signature and does not make uninstantiated classes abstract; obfuscation is unchanged.
* Allow the java.beans classes Jackson's Java 7 support refers to, missing on Android
309 lines
14 KiB
Rust
309 lines
14 KiB
Rust
//! 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<MaybeTlsStream<TcpStream>>;
|
|
|
|
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<u8>` 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))
|
|
}
|
|
|
|
/// Established TCP connections of this process to anything but the loopback interface (Linux).
|
|
fn peer_connections() -> usize {
|
|
let inodes: std::collections::HashSet<String> = std::fs::read_dir("/proc/self/fd")
|
|
.unwrap()
|
|
.filter_map(|fd| std::fs::read_link(fd.ok()?.path()).ok())
|
|
.filter_map(|target| {
|
|
let target = target.to_string_lossy().into_owned();
|
|
Some(target.strip_prefix("socket:[")?.strip_suffix(']')?.to_owned())
|
|
})
|
|
.collect();
|
|
["/proc/net/tcp", "/proc/net/tcp6"]
|
|
.iter()
|
|
.flat_map(|table| std::fs::read_to_string(table).unwrap_or_default().lines().skip(1).map(str::to_owned).collect::<Vec<_>>())
|
|
.filter(|line| {
|
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
|
let remote = fields[2];
|
|
let loopback = remote.starts_with("0100007F") || remote.starts_with("00000000000000000000000001000000");
|
|
fields[3] == "01" && !loopback && inodes.contains(fields[9])
|
|
})
|
|
.count()
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "connects to the live Zagros testnet; needs PEZKUWI_LIGHT_RELAY_SPEC and PEZKUWI_LIGHT_PEOPLE_SPEC"]
|
|
fn a_removed_chain_stops_and_can_be_registered_again() {
|
|
use pezkuwi_light::engine::Error;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
// A panic on an executor thread would not fail the test by itself.
|
|
static PANICKED: AtomicBool = AtomicBool::new(false);
|
|
let default_hook = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(move |info| {
|
|
PANICKED.store(true, Ordering::SeqCst);
|
|
default_hook(info);
|
|
}));
|
|
|
|
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 engine = start(temporary_dir("remove"));
|
|
engine.add_chain("relay", &relay, None).unwrap();
|
|
engine.add_chain("people", &people, Some("relay")).unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(90);
|
|
while peer_connections() == 0 {
|
|
assert!(Instant::now() < deadline, "no peer connection was opened");
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
}
|
|
|
|
assert!(matches!(engine.remove_chain("relay"), Err(Error::RelayChainInUse(_))));
|
|
engine.remove_chain("people").unwrap();
|
|
engine.remove_chain("relay").unwrap();
|
|
|
|
// Removing every chain alone left smoldot's network service, and its peer connections, open.
|
|
// Connection attempts already under way finish first: about 20 s on Zagros.
|
|
let deadline = Instant::now() + Duration::from_secs(60);
|
|
while peer_connections() > 0 {
|
|
assert!(Instant::now() < deadline, "peer connections outlived every chain");
|
|
std::thread::sleep(Duration::from_secs(1));
|
|
}
|
|
assert!(matches!(engine.remove_chain("relay"), Err(Error::NotRegistered(_))));
|
|
|
|
match tungstenite::connect(format!("{}/relay", engine.base_url())) {
|
|
Err(tungstenite::Error::Http(response)) => assert_eq!(response.status(), 404),
|
|
other => panic!("a removed chain was served: {:?}", other.map(|_| ())),
|
|
}
|
|
|
|
// Past the first database save of the removed chains, which must not reach smoldot.
|
|
std::thread::sleep(Duration::from_secs(25));
|
|
assert!(!PANICKED.load(Ordering::SeqCst), "a removed chain's database save panicked");
|
|
|
|
engine.add_chain("relay", &relay, None).unwrap();
|
|
let mut socket = connect(format!("{}/relay", engine.base_url()));
|
|
rpc(&mut socket, 1, "chainSpec_v1_chainName", serde_json::json!([]));
|
|
assert!(answer(&mut socket, 1)["result"].is_string());
|
|
}
|