A one-screen Android app that downloads files from deCDN
on a phone. It runs the deCDN client SDK (decdn-client)
natively: it finds the nodes that hold a blob, streams it from all of them at once, verifies
every byte against its BLAKE3 hash as it arrives, and pays per megabyte in USDC from a payment
pool. A sponsor funds that pool, so the phone holds no USDC and no wallet.
Enter a content hash and tap Download. The first time, the app opens a one-step check in the
browser; come back to the app and the download starts. The verified file lands in
Download/decdn/. Tick Whole bundle and the hash names a bundle manifest instead: every file
it lists lands under a folder of its own.
On a Pixel 8a over home Wi-Fi, a 4.2 GiB video bundle downloads at about 8 MiB/s, and an interrupted pull resumes from the bytes already on disk without paying for them twice.
┌──────────── Android app ────────────┐
│ MainActivity (Java) │ ┌────────────┐
│ hash, namespace ──────────────────┼─────▶│ onramp │ up.decdn.org: a capability for
│ │ JNI ▲ progress │ │ │ the app's key, after a browser check
│ ▼ │ │ └────────────┘
│ libdecdn_android.so (Rust) │ ┌────────────┐
│ decdn-client BundlePuller ────────┼─────▶│ deCDN nodes│ iroh QUIC, paid per MB
│ throwaway key + capability │ └────────────┘
│ │ │ ┌────────────┐
│ └────────────────────────────┼─────▶│ Arbitrum │ pool + registry reads
└─────────────────────────────────────┘ │ Sepolia │
└────────────┘
rust/src/lib.rsis the whole native side. It drivesdecdn_client::bundle::BundlePullerwithFunding::Delegated, so discovery, payment, resume and verification all come from the SDK. A single file is a one-entry manifest built in code. ABundleObserverfeeds verified-byte counts to the UI.MainActivity.javais the screen. It gets an allowance throughOnramp.java, callsNative.pullon a worker thread, pollsNative.progressevery 500 ms, and copies the finished files intoDownload/decdn/throughMediaStore.Native.inithands the app'sContextto two crates that read Android system state over JNI: iroh's DNS resolver (ndk-context) andrustls-platform-verifier, which checks TLS certificates against the Android trust store. The verifier's Android half comes from upstream's Maven archive asorg.rustls:rustls-platform-verifier:0.2.0.
- macOS or Linux
- Android Studio, with NDK (Side by side) and Android SDK Platform-Tools from SDK Manager > SDK Tools
- Rust.
rust/rust-toolchain.tomlpins 1.99 and theaarch64-linux-androidtarget, so rustup installs both on first build. cargo-ndk:cargo install cargo-ndk- A 64-bit ARM phone on Android 11 or newer, with USB debugging on
Plug in the phone, then:
./build.shThat builds the native library with cargo ndk, builds the debug APK with Gradle, installs it,
and launches it. ./build.sh apk builds without installing. The script finds the SDK under
~/Library/Android/sdk or ~/Android/Sdk and the newest NDK inside it; set ANDROID_HOME or
ANDROID_NDK_HOME to override.
The app pays as a delegate of a sponsord gateway, the same
way its decdn.sh installer does:
- On first launch the app makes a random throwaway key and keeps it in its private storage. The key holds no funds.
- On Download with no allowance, the app opens the onramp's gate page,
https://up.decdn.org/fund?client=<address>, in the browser. Passing the check there has the sponsor sign a capability for the app's key: the right to spend up to a cap from the sponsor's pool until an expiry. - Meanwhile the app polls
GET /v1/capability?client=<address>every 2 s. The gate page says to return to your terminal; switch back to the app instead. The capability is waiting, and the download starts.
The allowance line on the screen shows the key and how long its capability lasts. A capability's terms are fixed once issued, so when one runs out or comes within about an hour of expiry, the app starts over with a fresh key and sends you through the check again. An interrupted download resumes under the new key without paying twice for the bytes it has.
| Field | What to enter |
|---|---|
| Content hash | The blob's BLAKE3 hash, hex, with or without b3: |
| Namespace | The publisher's namespace id. Empty sends none. |
| Save as | The file name, or the folder name for a bundle |
| Whole bundle | Tick when the hash is a bundle manifest |
The status line shows the phase (finding nodes, downloading, verifying), verified bytes against the total, and the current rate. Cancel stops the pull where it is; Download again resumes it.
To try it, pull the Sintel 4K bundle (namespace 1, tick
Whole bundle, about 4.2 GiB):
b3:87539fba0ee64b9f7814d50af89988b0e10f624340dd6e8477a0984c27eaab42
This is a demo of the SDK, not a product. It targets the Arbitrum Sepolia testnet, whose
contract addresses are built into rust/src/lib.rs, and builds for arm64-v8a only. Android
stops a sleeping app's network, so the screen stays on while a pull runs; a production app runs
the pull in a foreground service instead. Finished files are copied into Download/, so a pull
needs twice its size free.
Dual-licensed under either MIT or Apache-2.0, at your option.