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.
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.
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.
* 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:<port>/<token>/<chain key>.
- 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/<host>/tcp/<port>/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:<port>/<token>/<genesis>. 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.
* DKS hub: open each app on its own domain
Seven tiles opened app.pezkuwichain.io routes or a front page while the app
has a domain of its own. Each now opens that domain, even where the site is
still a "coming soon" page, so the tile keeps its address when the site
arrives:
Wallet explorer front page -> cuzdan.pex.network
P2P / Buy-Sell app/p2p -> buy-sell.pezkiwi.app
B2B app/bereketli -> bereketli.pezkiwi.app
Vote app/elections -> governance.pezkuwichain.io
Proposals app/citizens/government-> governance.pezkuwichain.io
Identity app/identity -> be-citizen.pezkiwi.app
Forum app/forum -> forum.pezkuwichain.io
B2B went through a sign-in bridge that only helps a user signed in to the
web app, which the wallet's DApp browser never is: it loaded the whole web
app, then forwarded to bereketli.pezkiwi.app anyway.
* Release 1.3.0
Version 1.2.0 -> 1.3.0, with release notes in English, Turkish and
Kurmanci for the Ecosystem Hub, the config that keeps working when
GitHub is blocked, and the splash and deep link fixes.
* feat(network): fetch config from jsDelivr when raw.githubusercontent.com fails
Every config file the app reads -- chains, assets, DApps, XCM, banners, token
icons -- comes from raw.githubusercontent.com with no other source, so when that
host cannot be reached a fresh install has no chains at all.
ConfigMirrorInterceptor retries the same file on cdn.jsdelivr.net and then
fastly.jsdelivr.net when the primary fails to connect or answers 403, 429, 451
or 5xx. A 404 is an answer and is returned as is. A host that failed is skipped
for ten minutes so later requests do not each wait for its timeout. Mirror
requests revalidate, so a mirror's long max-age cannot pin a copy in the HTTP
cache after the primary is back.
The shared OkHttp client and Coil's image client both carry it.
* feat(runtime): start a first install from the chain list the APK was built with
A first install has no chains until chains.json is fetched, and the fetch
retries forever: with GitHub and its mirrors unreachable the wallet stays
empty.
The runtime module now bundles the live chains.json at build time
(bundleChainsConfig, run before preBuild, falling back to jsDelivr and
refusing anything that is not a non-empty JSON array). When the local
database is empty, ChainSyncService tries the network once and, if that
fails, applies the bundled list so the wallet opens; the fetched list then
replaces it through the usual diff as soon as it arrives.
* feat(dapps): show the bundled DApps catalog until one can be fetched
The DApps catalog lived only in memory and every read waited on first(), so
with the catalog URL unreachable the DApps screen waited forever.
feature-dapp-impl now bundles dapps_full.json at build time. On the first
sync the repository tries the network once and, if that fails, publishes the
bundled catalog so every read returns; the fetched catalog replaces it as
soon as it arrives.
The download task moves to scripts/bundled-config.gradle and serves both
the chain list and the DApps catalog. runtime and feature-dapp-impl unit
tests return defaults for android.util.Log, which the fallback calls.
* feat(network): resolve names over DNS-over-HTTPS first, system DNS as fallback
A network that answers plain DNS with a wrong address decides where the app
connects. The shared OkHttp client and Coil's client now resolve through
EncryptedFirstDns: Google, Cloudflare and Quad9 over HTTPS, each reached by
IP, then the system resolver if all of them fail, so no network is worse off.
- A resolver whose request failed is skipped for ten minutes, so a network
that blocks DoH pays the four-second timeout once. NXDOMAIN and SERVFAIL do
not count as failures.
- IPv4 addresses are returned first: DoH answers IPv6 whether or not the
network has it, and OkHttp tries addresses in order.
- IP literals and localhost never leave the device.
WebSocket RPC (nv-websocket) still resolves through the system.
* feat(config): verify signed config metadata, TUF style
The rules the app will apply to config it fetches, not wired yet:
- A root, signed by a threshold of root keys, names the keys that may sign
the targets list and how many must. The APK carries the first root.
- A new root is accepted one version at a time, signed by a threshold of the
old root's keys and of its own, so keys can be replaced without a release
and a lost targets key can be revoked.
- The targets list gives the sha256 of each config file; it cannot go back a
version, cannot jump more than 10,000 at once, and is refused once it or
its root has expired.
- Ed25519 over the payload bytes exactly as published (BouncyCastle, already
a dependency); a key signing twice counts once; a key must match its id.
* fix(config): parse metadata with the Gson API the app resolves
JsonParser.parseString is not in the Gson version on the app's classpath;
the instance parse method is, and behaves the same here.
* test(runtime): add the Log.e overload with a throwable to the test shim
The first-install fallback logs the failed fetch with its exception, and the
shim only had the two-argument form, so the test hit NoSuchMethodError.
* feat(config): refuse config files the signed list does not give
SignedConfigInterceptor checks every JSON file from pezkuwi-wallet-utils
against signed/targets.json, whichever host the mirrors got it from. A
file the list does not give, or one without a verified list behind it,
comes back as a 502, so callers keep the stored chains and bundled
lists as they do when the source is unreachable. Newer roots are
followed from signed/root.N.json; the targets version and the latest
time seen are stored, so an older list cannot be replayed.
Root keys may now be sr25519 wallet accounts: they sign through the
wallet's message signing, which wraps the bytes in <Bytes>, and the
SDK's schnorrkel verifies them. The APK carries root 1 as an asset,
trusted as the APK is.
* test(deeplink): pin terminal-failure replay and referendum path matching
These fail on main: a link that fails for good is kept as pending and
replayed on every launch, and /open/governance matches the referendum
handler.
* fix(deeplink): drop a pending link once it fails for good
RootDeepLinkHandler cleared the persisted pending link only on success or
when no handler matched. A link that reached a handler and failed with a
DeepLinkHandlingException stayed in preferences and was replayed on
every launch. Clear it on terminal handling failures in both the fresh
and pending paths; this also recovers installs already stuck on update.
Other failures keep the link pending for a retry.
* fix(deeplink): match the referendum path exactly
ReferendumDeepLinkHandler matched any path starting with /open/gov, so
the governance banner link /open/governance went to the referendum
screen and failed with ReferendumIsNotSpecified.
* ci(release): smoke-test the release APK on an emulator, drop the BrowserStack trigger
The release workflow ended by triggering BrowserStack tests in
appium-mobile-tests with a personal token. The token expired on 2026-10-17, so
every release build since is red, and the BrowserStack run behind it had never
produced a result (it fails uploading the app, KeyError 'app_url', since
2026-08-03). Its tests mostly asserted nothing.
In its place, every release build now installs the APK on an x86_64 API 34
emulator on a GitHub-hosted runner, launches it, and fails unless the
first-run welcome screen appears with both its create and restore buttons and
the app has neither crashed nor exited. The screen is matched by view id, so
it holds in any locale. A screenshot, the view tree and logcat are kept as an
artifact. It needs no account and no stored secret, and it can be run by hand
against the APK of an earlier run.
* ci: run the emulator smoke test on pull requests that change it, against main's latest release APK
* TEMP: nail check, expect red
* Revert "TEMP: nail check, expect red"
This reverts commit 7f804317b4.
* test(splash): the initial destination is navigated to once
* fix(splash): navigate to the first screen once
The splash screen asks for the initial destination on every onResume, which
runs again when the activity is recreated during startup while the view model
survives. The second navigation found the first already on the welcome screen,
with no route from there, and crashed the app with 'Unknown case for
welcomeFragment'. The view model now starts it once.
* DApps: the Dijital Kurdistan Ecosystem Hub, a native screen
Tapping "Dijital Kurdistan" in the DApps tab used to open pezkuwichain.io in the
browser. It now opens the hub first, inside the DApps tab so the wallet's bottom
bar stays in view; the user picks an app there and it opens in the DApp browser.
- Four sections: Finance (8), Governance (9), Education (4), Social (8). Each is
a card with a glowing border in its colour (green, red, amber, blue), a header
band with its name and app count, and a four-column grid of apps.
- Apps open the same pages the web app's mobile home links to, at
app.pezkuwichain.io, or their own sites: Wallet and Validators the explorer,
Exchange pex.network, Delegation wekil.pex.mom, KurdAI kurdishtts.dks.news,
Games games.pex.mom, Rewshenbir rewshenbir.pezkiwi.app. Music has no page yet:
it is shown dimmed and does nothing when tapped.
- The entry is recognised by its URL (pezkuwichain.io root, from
wallet-utils dapps_full.json), so favourites open the hub too. Back, from the
arrow or the system, closes the hub before it leaves the tab.
- Strings in English, Turkish and Kurmanji, worded as the web app words them;
other languages fall back to English.
* DApps hub: open Music at music.dks.news
Every app in the hub now has an address, so the dimmed, inert state for an app
without one is gone along with its string.
* DApps hub: open KurdMedia at dks.news
---------
Co-authored-by: Claude <noreply@anthropic.com>
* ci(security): scan new commits on push/PR and the current tree weekly
`base: main` made every push to main rescan the whole history, where Nova's
own Infura keys are still live, so Secret Scan was red on every push and a
new leak would have read as the same old red.
- push and pull_request: no `base`, so the action scans only the commits the
event brings
- schedule: scan a `git archive HEAD` copy of today's tree
- pin the action to v3.99.2 by commit and the image to 3.99.2 (was @main and
latest)
* ci(security): exempt the TruffleHog action from the AGPL licence denial
Pinning the action made dependency review evaluate it for the first time.
It runs unmodified in CI and is not linked into or shipped with anything.
Nothing in the build, the code or the workflows reads this file; the app
fetches chains from pezkuwi-wallet-utils (CHAINS_URL in runtime/build.gradle).
The copy had not changed since February and still carried Nova's Infura
project keys in its Ethereum node URLs.
* build(rust): bump vulnerable crates in native binding lockfiles
- rand 0.8.5 -> 0.8.6 (GHSA-cq8v-f236-94qc) in hydra-dx-math and sr25519-bizinikiwi
- keccak 0.1.5 -> 0.1.6 (GHSA-3288-p39f-rqpv) in hydra-dx-math and sr25519-bizinikiwi
- bytes -> 1.11.1 (GHSA-434x-w66g-qw3r) in hydra-dx-math and metadata_shortener
- time 0.3.41 -> 0.3.47 (GHSA-r6v5-fh4h-64xc) in hydra-dx-math
- tracing-subscriber 0.3.19 -> 0.3.20 (GHSA-xwfj-jgwm-7wp5) in hydra-dx-math
Lockfile-only updates via cargo update --precise; Cargo.toml requirements
are unchanged. time 0.3.47 pulls serde 1.0.229/serde_core and requires
rustc 1.88, which the stable toolchain used by CI satisfies.
* ci: restrict default GITHUB_TOKEN permissions to contents: read
None of these workflows write through GITHUB_TOKEN: artifact upload,
Firebase, S3, Play Store, gh-pages and the Appium dispatch all use their
own credentials. Reusable workflows they call inherit the same cap.
* fix(dapp): send only fixed, JSON-quoted error messages to dApp pages
The polkadot.js responder forwarded Throwable.message of any rejection
error to the page and both responders interpolated the message into
JavaScript without escaping. Map polkadot.js rejections to a fixed public
message (unknown errors become "Request failed"), give MetamaskError an
explicit publicMessage, and quote both with JSONObject.quote so a message
can no longer break out of the string literal.
* ci: stop installing the removed legacy SDK 'tools' package
android-actions/setup-android installs 'tools platform-tools' by default.
sdkmanager now fails with "Failed to find package 'tools'", which aborts
dependency installation for every build and lint job. Only platform-tools
is needed; nothing in the workflows uses the legacy tools directory.
* ci: fail the build when a 64-bit native library is not 16 KB aligned
Play rejects updates whose 64-bit native libraries are not aligned for 16 KB memory
pages, but nothing in the build says so. A misaligned .so surfaces only as a policy
issue in Play Console, days after a release has gone out — which is how one reached
production here unnoticed.
The check reads what the build actually produced rather than trusting the dependency
list, because a single transitive dependency shipping an unaligned library is enough
to fail the whole bundle. It walks every .aab and .apk under app/build/outputs, pulls
each arm64-v8a and x86_64 .so, and compares its largest LOAD segment alignment against
16384. 32-bit ABIs are skipped: those devices use 4 KB pages.
Verified against the v1.2.0 release bundle before it was cleaned up: 14 libraries
scanned, and it caught libpl_droidsonroids_gif.so at 0x1000 — the one library Play
flagged. A check that cannot fail on known-bad input is not a check.
* test: make the tamper-detection test actually tamper
checkTamperedDataFails wrote to encoded[lastIndex - 1] but computed the new value from
encoded.last(). Whenever those two bytes already differed by exactly 0x01 the write was
a no-op, the ciphertext stayed valid, decryption succeeded, and the expected
AEADBadTagException never arrived — so the test failed for reasons unrelated to what it
checks. One run in 256.
Flip a bit in the byte being written instead, which changes the ciphertext every time.
Play rejects updates whose native libraries are not aligned for 16 KB memory pages,
enforced since Nov 1 2025. Measuring the released bundle showed exactly one library
short, and only on one ABI:
arm64-v8a 7/7 aligned at 0x4000
x86_64 6/7 aligned; libpl_droidsonroids_gif.so at 0x1000
That library comes from pl.droidsonroids.gif:android-gif-drawable 1.2.19, inherited
from upstream and never used here: no GifDrawable, GifImageView, GifTextView or
pl.droidsonroids reference exists in any Kotlin, Java or XML file in the project.
Removing it beats upgrading to 1.2.32 — an unused dependency should not be carried at
any version, and this also drops roughly 175 KB of dead native code across four ABIs.
Everything else in the bundle was already aligned, and jniLibs.useLegacyPackaging is
already false, so nothing more is needed for the requirement.
Two gaps, both invisible until you look for them.
There was no update mechanism at all. Someone on an old build stayed on it until they
happened to open the Play Store on their own. For a wallet that is worse than an
inconvenience: today's multisig signing fix would have reached nobody who was not
already looking.
InAppUpdates asks Play on every foreground. A user a few days behind gets the flexible
flow — the download runs in the background and the wallet stays usable, because
interrupting someone mid-transfer to force an update is its own kind of harm. Past
fourteen days of staleness, or on a release marked priority 4+ in Play Console, it
switches to immediate. onResume finishes an interrupted immediate update and installs a
flexible one that completed while the app was backgrounded; without that the first
leaves a user stuck and the second never installs.
Ratings had the same shape of gap: people who would happily rate the wallet never do,
because nothing ever asks. Play's in-app card asks without sending them to the store.
Play answers neither "has this user rated" nor "what did they choose" — by design. It
also throttles to a handful of showings a year and silently drops the rest. So the gates
in AppReviewTracker are not there to avoid nagging, which Play already handles; they
exist to spend those few real chances well: three successful operations, three days
since first use, ninety since the last ask, and not within two days of an error.
Recording sits in RealExtrinsicService, the single point every on-chain action passes
through, so transfers and staking are covered without a hook per screen. The tracker
swallows everything it touches — a rating counter must never be able to fail a transfer.
Both features no-op outside a Play install, so neither can be verified from a Firebase
build; that needs an internal testing track.
Version 1.2.0.
* diag: probe both era encodings when building the multisig weight-estimation extrinsic
Approving a multisig operation on Pezkuwi Asset Hub fails with "Failed to encode
extension CheckMortality". The on-device stack puts it in wrapInFakeExtrinsic,
reached only from estimateCallWeight — asMulti needs a max_weight, so a throwaway
signed extrinsic is built to measure the inner call. Plain transfers never reach
that path, which is why sending HEZ works while approving does not.
The same failure on Polkadot Asset Hub was fixed by gating the era encoding on
chain identity, and Pezkuwi Asset Hub satisfies that gate: its genesis hash matches
the constant, so PezkuwiCheckImmortal should already be in use. Reading the code
cannot say which side actually fails.
So build the extrinsic both ways and report each outcome with its full cause chain.
One reproduction then names the encoding the chain accepts, and the permanent fix
follows from that rather than from another guess.
Diagnostic only — the probe and the DIAG constant come out with the real fix.
* style: drop the blank line ktlint flags before the closing brace
* fix(multisig): approve operations on Pezkuwi chains again by dropping the custom era extension
Approving a multisig operation on Pezkuwi Asset Hub failed with "Failed to encode
extension CheckMortality", so no signatory could approve from the wallet — the
bridge treasury included. Initiating an operation worked, which made the break look
narrower than it was.
Only multisig reaches the failing code. asMulti needs a max_weight, so
estimateCallWeight builds a throwaway signed extrinsic to measure the inner call.
Plain transfers never build one, which is why sending HEZ kept working.
PezkuwiCheckImmortal was the cause, not the cure. It passes a raw
DictEnum.Entry("Immortal", null) as the era, and the SDK rejects it:
EncodeDecodeException: Entry(name=Immortal, value=null) (Entry)
is not a valid instance of Era (EraType)
The premise it was written on — that Pezkuwi's pezsp_runtime Era breaks the standard
codec — does not hold. Measured on device against Pezkuwi Asset Hub, the standard
CheckMortality(Era.Immortal) encodes without complaint, and ExtrinsicBuilderFactory
has been signing every ordinary transfer through it with a mortal era all along. The
era type resolves from metadata by index, so the renamed module path never mattered.
So the chain gate goes and both custom extensions go with it: PezkuwiCheckImmortal
had one caller, PezkuwiCheckMortality had none. One path for every chain.
Verified on device (Pezkuwi Asset Hub, versionCode 340): a build that tried both
encodings logged the custom one failing and the standard one succeeding, then
carried a real 3-of-5 approval through to MultisigExecuted with the inner call Ok
and 10 HEZ leaving the multisig.
Enabling notifications started failing with
java.util.concurrent.ExecutionException: java.io.IOException: SERVICE_NOT_AVAILABLE
SERVICE_NOT_AVAILABLE is Firebase saying its endpoint is momentarily
unreachable. It is transient, and Firebase's own guidance is to retry with
backoff. requestToken() made a single attempt with no catch, so a few seconds of
that ended the flow and put a raw stack trace in front of the user.
Nothing about the app or its Firebase project changed — the project is healthy
and the API key still authenticates. The app simply had no tolerance for a
service that is documented as intermittently unavailable, so it worked until the
first time Google was slow to answer.
Now four attempts with 1s/2s/4s backoff.
The wrapping matters: the IOException arrives inside an ExecutionException, so
catching IOException directly would never have matched and the retry would have
been dead code. The check walks the cause chain instead. Failures that are not
network-related — a wrong google-services.json, a missing Play Services install —
are rethrown on the first attempt, since retrying those only delays the same
error.
wallet-utils master now has the coordinated Bitcoin/Tron/Solana chain
config content (its own compat-revert was reverted back and merged with
pending/post-fix-release now that this app version can parse it) - drop
the temporary pending/post-fix-release override used during development.
Confirmed via on-device logcat trace: approving a multisig operation on
Polkadot Asset Hub failed with "Failed to encode extension CheckMortality"
because isPezkuwi checked for the "AuthorizeCall" signed extension, which
Polkadot Asset Hub also declares - so PezkuwiCheckImmortal's raw DictEnum
value was used there too, and it isn't a valid instance of Polkadot's own
Era type codec. Switched the gate to the existing chain.isPezkuwiChain
(genesis-hash based) check already used for the bizinikiwi signing context
split. Also removes the temporary diagnostic logging added while tracing
this bug and the earlier deep-link routing fix.
USDT has no buy/sell provider on either bridge leg (Pezkuwi wUSDT / Polkadot
USDT); Bridge is the actual way to acquire it there, so route straight to it
instead of a dead-end "not supported" message.
Real on-device failure while approving a multisig operation on Polkadot Asset
Hub: 'Failed to encode extension CheckMortality'. isPezkuwi is detected via
signedExtensions.any { it.id == "AuthorizeCall" } - confirmed via direct
metadata inspection that AuthorizeCall exists on BOTH Polkadot Asset Hub and
Pezkuwi Asset Hub (both on Metadata V16), so this heuristic can't actually
distinguish the two chains. Need to confirm what isPezkuwi evaluates to at
the moment of the real failure, and exactly where the throw happens
(setTransactionExtension vs buildExtrinsic), before proposing a fix.
Deep link debug: DeepLinkModule wiring at app/root/di/deeplink/DeepLinksModule.kt
confirmed MultisigDeepLinks IS included in the assembled handler list (ruling that
out), yet the earlier MultisigOperationDetailsDeepLinkHandler-level Log.e calls never
fired at all - not even the very first one in matches(). Logging one level up, at
the true entry point, to see the raw Uri/scheme/authority/path as parsed and the
final Result, before guessing further.
Mining Simulation: adds a 'PEZ Airdrop' label above the square (user request), and
refuses to activate + shows an explicit error if trust score is 0, instead of
silently starting a permanently-zero-rate session.
The manifest fix (84bbff6) confirmed the intent-filter now resolves and the
app receives the intent (RootActivity gets focus), but the details screen
never opens and nothing crashes or logs anything - suspecting the deep link
coroutine may be permanently suspended on automaticInteractionGate.awaitInteractionAllowed()
(the app's PIN/background-recheck gate), which an ADB-driven test may never
satisfy. Tracing each step to confirm before concluding. To be removed once
the real cause is confirmed.
android:scheme="@string/deep_linking_scheme"/android:host="@string/deep_linking_host"
compiled fine but never resolved at runtime - confirmed by decoding the actual built
APK's binary manifest, which showed the raw unresolved resource ids
(@7F13034E/@7F13034A) instead of literal values, unlike every sibling intent-filter
in this same manifest (buy-success, request, wc, https), which all use literal
strings and work correctly. PackageManager can't match a <data> scheme/host that
never resolves, so this intent-filter - covering every /open/... deep link,
including the just-added first-signer multisig operation case (PR #14) - has never
been reachable on any real device since it was written, regardless of the
correctness of the Kotlin handling code behind it. Confirmed by attempting the
actual deep link on-device (ADB VIEW intent, unresolved) before finding this.
Cosmetic, client-side-only preview of a trust-score-weighted PEZ emission
share, meant to incentivize referral usage (higher trust score -> visibly
faster mining). Not tied to any real distribution mechanism or backend.
- MiningSimulationFormula: pure calc. Rate per minute = (92.5M PEZ / 43200
min era) * (trustScore / 500,000 reference total) - trust score is a
direct multiplier so zero trust score always yields zero rate. Diamonds
only accrue while a 24h session is active.
- MiningSimulationRepository: local-only persistence via the existing
Preferences wrapper (same pattern as BannerVisibilityRepository), keyed
per accountId. Tapping the square freezes the live total as the new base
and starts a fresh 24h session - never resets progress on tap. A full
43200-minute era rolling over resets the total to 0 (that era's pool is
considered distributed) independently of session taps.
- UI: square button in the dashboard card's previously-empty right column,
red when inactive (needs a tap), gold (#FDB813, matching the existing
Trust Score color) while actively mining. Small Telegram icon opens
https://t.me/DKSKurdistanBot via the existing Browserable mixin.
Root cause found via device-side Log.e tracing (now removed): the on-chain
query, off-chain call-data fetch, and call decode all worked correctly - a
real pending approval was found and its call decoded - but chain.assetsById
is keyed by this app's own local Chain.Asset.id, which is NOT the same as
the raw pallet_assets id these constants hold (e.g. USDT is local id 1 but
on-chain id 1984 on Polkadot Asset Hub - they only happened to match by
coincidence on Pezkuwi Asset Hub, masking the bug there). Every result was
silently dropped at this single lookup. Fixed by reverse-resolving through
findAssetByStatemineAssetId instead of indexing assetsById directly.
Nothing renders on-device despite the multisigsApiUrl fix and a confirmed
real pending approval on-chain, with zero exceptions surfacing anywhere -
every runCatching in this path swallows silently with no logging. Adding
Log.e at each step (account resolution, signatory gate, on-chain
keys/entries, off-chain fetch, call decode, transfer parse) to find where
it actually drops the result. To be removed once the real cause is found.
The previous commit reused MultisigPendingOperationsService, but that
service only syncs accounts formally registered as a MultisigMetaAccount
in this wallet - it silently returns an empty NoOpSyncer for every other
account type. The bridge's 5 signatories (Serok etc.) use their own
regular wallets to approve calls against the bridge's separate,
hardcoded multisig (BridgeMultisigConstants) - they never "are" that
multisig account, so the feature was structurally guaranteed to show
nothing, not just empty by coincidence.
Fix queries the bridge's own multisig accounts directly, mirroring the
exact pattern this screen's existing renewal-signing code already uses
(BridgeMultisigRuntimeApi's runtime-metadata accessors, a local mirror
of feature-account-impl's off-chain call-data indexer client - same
module-boundary reasoning already documented in that file) rather than
the generic, differently-scoped service:
- BridgeMultisigInteractor.getPendingApprovals(): enumerates
Multisig.Multisigs entries for both bridge multisig accounts via
QueryableStorageEntry2.keys()/entries() (a general runtime-storage
capability, not multisig-feature-specific), fetches real call content
from the same off-chain indexer the generic feature uses (also
general-purpose, keyed by address+hashes - never required the account
to be a MultisigMetaAccount either), and filters to calls this
signatory hasn't approved yet.
- BridgeMultisigInteractor.submitApproval(): reuses
composeBridgeMultisigAsMulti generalized to an arbitrary call+
timepoint (previously only used for the renewal call) - refuses to
proceed if the call content is unknown, never blind-signs.
- BridgeViewModel formats the real parsed transfer (amount+destination)
via AssetSourceRegistry.tryParseTransfer, already used elsewhere for
the exact same "decode a transfer call" job - no fabricated preview
data.
Reverts the feature-multisig:operations cross-feature dependency and
FeatureApi wiring added in the previous commit - no longer needed since
everything now lives in this screen's own existing self-contained
domain layer, matching how the rest of BridgeMultisigInteractor already
works (and already explains, in its own doc comments, why a synthetic
MultisigMetaAccount was deliberately avoided for exactly this reason).
Wrong screen in the previous commit - moved it to sit right below the
existing per-leg renewal sign buttons on the Bridge input screen, which
is where it was actually asked for: a signatory renewing their own
wUSDT/Polkadot USDT allowance there should also see (and be able to
jump to) other pending multisig operations - e.g. someone else's large
bridge swap - waiting on their signature, without navigating elsewhere.
BalanceListFragment/ViewModel/Module are reverted to their prior state.
The reusable pieces (PendingSignatureModel, item_pending_signature_row
layout) are kept and repurposed for the Bridge screen; the RecyclerView
SingleItemAdapter/card layout built for the home screen are removed
since Bridge is a plain (non-RecyclerView) fragment - rows are inflated
directly into a LinearLayout container instead, same underlying data
and MultisigCallFormatter reuse as before.
The feature-assets -> feature-multisig:operations dependency and the
FeatureApi wiring (AssetsFeatureComponent/Dependencies/Holder) from the
previous commit stay as-is - Bridge is the same Gradle module, so no
change needed there.
Surfaces multisig operations awaiting the current signatory's own
approval directly on the main balance screen, instead of requiring
navigation to the separate pending-operations list. Each row shows the
formatted amount, destination, chain, and approval progress, with a
red Sign button that opens the existing operation-details/approve flow
(fee estimation, signatory balance check, and validation are all
already handled there - not reimplemented here).
Reuses the already-tested MultisigCallFormatter from
feature-multisig:operations (added as a new feature-assets dependency,
exposed via the standard FeatureApi/FeatureContainer pattern - see
AssetsFeatureHolder/AssetsFeatureComponent/AssetsFeatureDependencies)
rather than re-deriving call-preview formatting independently. The
card follows the same SingleItemAdapter + ConcatAdapter pattern as the
existing Pezkuwi Dashboard card on the same screen.
Once a pending operation is actually signed, it naturally drops off
this list on the next sync (no separate client-side "signed" state is
tracked in parallel with the real on-chain approval status).
String only added to the base (English) strings.xml for now - not yet
translated into the other ~14 locales this app otherwise maintains.
Signer.kt/SignatureVerifier.kt in substrate-sdk-android look up the
"EdDSA" JCA provider by name at runtime (Signature.getInstance(...,
"EdDSA")), backed by net.i2p.crypto:eddsa's EdDSASecurityProvider. With
no keep rule, R8 stripped/renamed its classes in the minified release
build, so the provider's own registered service implementation could no
longer be found - "class configured for Signature (provider: EdDSA)
cannot be found", reproduced on-device sending SOL (every Solana
transaction signs with Ed25519, so this path is always hit).
Never referenced anywhere (self-declaration only, confirmed via grep),
sitting since the initial commit. Its RistrettoPoint.multiply()/add() are
both no-ops returning `this` unchanged, BASE is all-zero bytes, and
keccakF1600() never writes output back into the Strobe state - none of
this could ever produce a valid signature. The real, live bizinikiwi
signing path is the native JNI binding in bindings/sr25519-bizinikiwi/
(PezkuwiKeyPairSigner -> BizinikiwSr25519), unaffected by this removal.
Leaving broken placeholder crypto sitting in a wallet's signer package
is its own audit red flag even when provably unused.
Was a context-recovery scratch doc from the initial rebrand port (Feb 2026):
self-contradicted (top says debug cleanup done, bottom checklist still
unchecked), and its only non-obvious content (SDK/runtime type-lookup
gotchas) is already explained in the actual source docstrings
(PezkuwiCheckMortality.kt, PezkuwiAddressConstructor.kt) or preserved
in memory for future reference.
Prevents a repeat of docs/PACKAGE_STRUCTURE_REBRAND.md (a never-executed
planning draft that sat in the repo for months) - working notes belong
in a scratchpad, not the tracked tree. Real docs (BRAND.md, README.md,
docs/*) are unaffected.
docs/PACKAGE_STRUCTURE_REBRAND.md was a 2026-01-23 planning note (status:
BEKLEMEDE, never actioned, references a contributor's personal machine
path) that had been sitting in the repo as dead weight since. Real,
maintained docs (BRAND.md, README.md, docs/*) are unaffected.
The previous commit's SafeOptional/custom() field wrapper was wrong and
broke real tests (RealLocalAccountsCloudBackupFacadeTest, Solana/Tron
backfill migration tests - Mockito WantedButNotInvoked, IllegalArgumentException).
Root cause of that failure: EncodableStruct.get() (in the external
substrate-sdk-android library) only returns null for an unset field
when field.dataType is specifically an instance of the library's own
`optional<*>` class - not any DataType that merely behaves like one.
optional<T> is not open, so no wrapper class can satisfy that check;
swapping in a custom DataType just moves the same crash from read-time
to access-time.
Correct fix: leave the MetaAccountSecrets schema exactly as it always
was (plain `.optional()`, real library instances, so get() keeps
working), and instead add readMetaAccountSecrets() - a resilient
top-level reader that tries the normal Schema.read() first and, only
if that throws, falls back to reading each field by hand in schema
order, stopping at the first one whose bytes don't exist and leaving
every field after it genuinely unset. Because those fields were never
set, EncodableStruct.get()'s existing (correct) optional-field handling
returns null for them exactly as it already does for any account that
was never migrated - no library behavior is being routed around,
just extended to tolerate a shorter/older stored blob.
Found on a real device: opening the backup/export screen for an
existing wallet crashed with
java.lang.IndexOutOfBoundsException: Cannot read 412 of 412
at .../scale/dataType/optional.read
at .../scale/Schema.read (x3)
at SecretStoreV2$getMetaAccountSecrets$2.invokeSuspend
Root cause: MetaAccountSecrets is a positional (SCALE) binary schema.
optional<T>.read() unconditionally reads a presence-flag byte with no
EOF handling - reading a blob that predates a trailing field (written
before that field existed) throws instead of yielding null. This is
also why Solana's own address was never getting backfilled: the
migration's own read of the account's stored secrets was throwing on
every attempted run, silently caught by its own per-account try/catch.
Confirmed via substrate-sdk-android's actual source (Dsl.kt/Delegate.kt)
that Tron/Bitcoin's identical schema-growth pattern carried the exact
same latent risk - it likely only avoided crashing so far by chance
byte-alignment in previously-stored blobs, not because the format
tolerates it.
Fix: read Tron/Bitcoin/Solana's keypair/derivationPath fields (every
field added after the original Substrate+Ethereum schema) through a
SafeOptional DataType that catches a failed read and returns null,
via the library's own custom() field-declaration escape hatch. Write
behavior is byte-for-byte unchanged - only reading a shorter/older
blob now correctly yields null for fields it doesn't have, instead of
crashing every caller of getMetaAccountSecrets (balance sync, signing,
backup/export, this Solana backfill migration itself).
Both SolanaTransactionTest and RealSolanaTransactionServiceTest carried
a transcription error from the earlier solders cross-validation: the
sender/recipient pubkey hex strings (and one of expectedMessageHex's
concatenated chunks) were each missing their trailing hex digit,
producing odd-length hex that BouncyCastle's decoder throws on
(StringIndexOutOfBoundsException) - all 5 SolanaTransactionTest cases
failed at field-init time as a result. Re-derived every fixture
programmatically from the same solders-verified reference message
(rather than hand-splitting the hex string again) and confirmed the
full round trip (build -> compare) matches byte-for-byte.
ChainExt.kt called toSolanaAddress/solanaAddressToAccountId/
emptySolanaAccountId/isValidSolanaAddress without importing them - an
earlier-session oversight that only surfaced once CI actually compiled
the runtime module (no local Kotlin compiler available to catch this
sooner). ktlint separately flagged an unused AccountId import.
- SolanaTransaction: hand-rolled compact-u16/legacy-Message/signed-tx
wire-format primitives, cross-validated byte-for-byte against the
solders Python library's own serialization of an identical System
Program transfer message.
- SolanaApi: JSON-RPC client (getBalance/getLatestBlockhash/
getFeeForMessage/sendTransaction) over a single POST endpoint, unlike
Tron/Bitcoin's path-per-resource REST style.
- SolanaNativeAssetBalance: getBalance polling, same design as Bitcoin's
mempool.space polling (no push/subscription mechanism available).
- RealSolanaTransactionService: builds a single-instruction transfer
message, signs it raw via the existing Ed25519 signer dispatch
(skipMessageHashing - Solana signs the message directly, no external
hashing), broadcasts via sendTransaction. Fee is Solana's actual
getFeeForMessage answer, not a client-side estimate.
- TransactionExecution.Solana(hash) variant; wired into AssetsModule via
a new SolanaAssetsModule, following the Bitcoin/Tron precedent exactly.
Same read/send-capable chain-family pattern as Tron/Bitcoin, but for
Ed25519/SLIP-0010 instead of secp256k1/BIP32 (Solana's account id IS its
raw public key, so no separate address-hash derivation step is needed).
- SLIP-0010 Ed25519 keypair derivation (Bip32Ed25519KeypairFactory),
cross-validated against an independent slip10 reference implementation
and pinned to a known-good test vector.
- Chain.isSolanaBased / Chain.Asset.Type.SolanaNative wired through every
chain-mapper, account-model, and signer dispatch site (mirrors the
existing Tron/Bitcoin wiring exactly).
- SolanaAddressBackfillMigration derives Solana keys for wallets created
before this support existed, same self-healing no-stuck-flag design as
the Tron/Bitcoin backfills - also fixed a latent bug in those two
migrations where MetaAccountSecrets/MetaAccountLocal.addXAccount()
dropped sibling chain-family fields on a re-run.
- Room migration 75->76 adds isSolanaBased/solanaPublicKey/solanaAddress
columns.
- CloudBackup's already-reserved solanaAddress/SolanaSecrets fields are
now actually read/written by RealLocalAccountsCloudBackupFacade; also
fixed isCompletelyEmpty() to check solana, so a Solana-only wallet
doesn't get silently dropped from backup.