<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Build with Rust]]></title><description><![CDATA[Hands-on Rust for engineers who build real systems. Deep dives into blockchain, desktop applications, web APIs, big data, and full-stack architecture — with complete code, architectural walkthroughs, and production-ready patterns. Companion blog to the Build with Rust book series.]]></description><link>https://buildwithrust.com</link><image><url>https://cdn.hashnode.com/uploads/logos/69c733287cf2706510824caa/fad8fca3-b177-4692-bea1-fe008d2151e6.png</url><title>Build with Rust</title><link>https://buildwithrust.com</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 14 May 2026 23:13:45 GMT</lastBuildDate><atom:link href="https://buildwithrust.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Tauri 2 IPC: How Rust and React Actually Talk]]></title><description><![CDATA[Published May 3, 2026
Just last week, somebody filed a bug against Tauri. Their app pointed a webview at an external URL via tauri::WebviewUrl::External, and invoke() started failing with "Origin head]]></description><link>https://buildwithrust.com/tauri-2-ipc-how-rust-and-react-actually-talk</link><guid isPermaLink="true">https://buildwithrust.com/tauri-2-ipc-how-rust-and-react-actually-talk</guid><category><![CDATA[Rust]]></category><category><![CDATA[Rust programming]]></category><category><![CDATA[Rust Desktop Apps]]></category><category><![CDATA[Tauri]]></category><category><![CDATA[Rust Web]]></category><category><![CDATA[rust-react]]></category><category><![CDATA[React Native]]></category><category><![CDATA[React]]></category><category><![CDATA[rust blockchain]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Sun, 03 May 2026 16:37:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/b937f925-f101-4ee5-b242-e9362d64089c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Published May 3, 2026</em></p>
<p>Just last week, somebody filed <a href="https://github.com/tauri-apps/tauri/issues/15190">a bug</a> against Tauri. Their app pointed a webview at an external URL via <code>tauri::WebviewUrl::External</code>, and <code>invoke()</code> started failing with <code>"Origin header is not a valid URL."</code> They were certain they'd configured everything correctly. The docs didn't help. The framework was just rejecting their commands.</p>
<p>The bug is real and the cause is specific: the embedded webview doesn't add an <code>Origin</code> header on cross-origin requests, and the <code>Referer</code> is empty, so Tauri's IPC scope checker can't verify the request came from an allowed origin and refuses to dispatch it. To debug it, you have to know what the IPC bridge actually does — not what <code>invoke()</code> looks like, but how the request travels from JavaScript through the OS-native webview into Rust and back.</p>
<p>Most Tauri tutorials show you <code>invoke('hello')</code> and stop. That's enough to ship a hello-world; it's not enough to debug <code>#15190</code>-class bugs, design a security boundary, or make architectural choices that survive contact with production. <strong>This post reads the Tauri 2 IPC bridge end to end, traces a real Bitcoin wallet's commands across it, and shows the security pattern that makes Tauri worth picking over Electron in the first place.</strong></p>
<p>By the end you'll know:</p>
<ul>
<li>What <code>invoke()</code> actually does, with line references to the Tauri source</li>
<li>How to design an IPC surface where private keys never leak to the frontend</li>
<li>When Tauri 2's capability system blocks a command and when it doesn't</li>
<li>The difference between the Tauri and Electron threat models, in one paragraph</li>
<li>The five mistakes that bite real teams using Tauri in 2026</li>
</ul>
<p>The example throughout is a Bitcoin wallet from my book <em><a href="https://www.amazon.com/dp/B0GXRYL21V">Rust Blockchain: A Full-Stack Implementation Guide</a></em> — 18 commands, two halves, a real working architecture you can clone and run. But the lessons generalize to any Tauri app: a password manager, an enterprise SSO dashboard, an ML inference UI. Anywhere a Rust backend has secrets and a webview UI shouldn't.</p>
<h2>1. What Tauri IPC actually is</h2>
<p>Tauri is a desktop-app shell. Your application's UI runs in the operating system's native webview (WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux). Your application's logic runs in a Rust process. The two halves live in the same OS process, but the boundary between them is enforced — JavaScript inside the webview can only reach Rust through commands that Rust has explicitly registered.</p>
<p>The mechanism for crossing that boundary is <strong>inter-process communication</strong> (IPC), even though it's technically intra-process. The naming is conventional, not literal.</p>
<p>A request from React to Rust looks like a remote procedure call:</p>
<ol>
<li>React calls <code>invoke('cmd_name', { arg: 'value' })</code></li>
<li>Tauri serializes the arguments to JSON</li>
<li>The message is delivered through the webview's host-bridge channel</li>
<li>Tauri dispatches to a Rust function registered with <code>#[tauri::command]</code></li>
<li>The Rust function returns a <code>Result&lt;T, E&gt;</code></li>
<li>Tauri serializes the result to JSON</li>
<li>React receives the resolved promise</li>
</ol>
<p>That's the whole protocol. It looks like fetch, except there's no network — the bridge is a message channel inside one OS process — and the "server" is a Rust function that has memory-safe access to your filesystem, your secrets, and your databases.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/08a2dd03-7c32-4346-a635-885e5d82993f.png" alt="Tauri 2 architecture overview — React, IPC bridge, Rust backend, blockchain API" /></p>
<p>The diagram above is the architecture for <code>bitcoin-desktop-ui-tauri</code>, the admin UI from Chapter 17 of the book. A React component asks <code>useQuery()</code> for the latest blockchain state. The query function calls <code>commands.getBlockchainInfo()</code>. That function wraps a single <code>invoke()</code> call. On the Rust side, a <code>#[tauri::command]</code> handler receives the call, asks Tauri's dependency injection for the shared <code>AppState</code>, builds an HTTP client (<code>AdminClient</code>) from the shared <code>bitcoin-api</code> crate, hits the blockchain API at <code>:8080</code>, and returns JSON. React gets the response. The component re-renders.</p>
<p>The whole roundtrip is about ~150 lines of Rust and TypeScript split across four files. We'll walk all four.</p>
<h2>2. The IPC bridge under the hood</h2>
<p>This section is the longest, because most existing Tauri content stops where this section begins. Read it once and you'll understand <code>invoke()</code> better than 95% of people shipping Tauri apps today.</p>
<h3>2.1 Where the bridge lives in the source</h3>
<p>The Tauri repository at <a href="https://github.com/tauri-apps/tauri"><code>github.com/tauri-apps/tauri</code></a> contains the IPC implementation in two places:</p>
<ul>
<li><strong><code>crates/tauri/src/ipc/</code></strong> — the high-level command dispatch (the <code>invoke()</code> you call, the <code>#[tauri::command]</code> macro, <code>Channel</code>, <code>InvokeBody</code>, <code>InvokeResolver</code>)</li>
<li><strong><code>crates/tauri-runtime-wry/src/webview.rs</code></strong> — the wry-backed implementation of the message bus that talks to the actual platform webview</li>
</ul>
<p>When you call <code>invoke('cmd_get_balance', { addr: '1A1zP1...' })</code> in TypeScript, here's what happens:</p>
<p><strong>Step 1 — JavaScript-side serialization.</strong> The <code>@tauri-apps/api/core</code> package's <code>invoke</code> function bundles the command name and the arguments object, then posts a message to the webview's host bridge. On Windows that's <code>chrome.webview.postMessage</code>. On macOS it's <code>webkit.messageHandlers.ipc.postMessage</code>. On Linux/GTK it's a custom URI scheme handler. Tauri abstracts these three platform mechanisms behind a single <code>Webview::eval</code> and <code>Webview::on_window_event</code> API so user code never has to think about them.</p>
<p><strong>Step 2 — message arrives in Rust.</strong> The Tauri runtime receives the platform message and routes it to <code>crates/tauri/src/ipc/invoke.rs</code>, where the <code>Invoke</code> struct is constructed. This struct holds the command name (a string), the payload (a JSON value), and a resolver — the channel back to the webview that will eventually carry the response.</p>
<p><strong>Step 3 — capability check.</strong> Before dispatching, the IPC layer consults the access control list (ACL) loaded from the <code>capabilities/*.json</code> files in your project. If the calling window's capability set doesn't include the requested command, the request is rejected with a permission error. We'll come back to this in §6.</p>
<p><strong>Step 4 — argument deserialization.</strong> Tauri uses <code>serde</code> and the <code>#[tauri::command]</code>-generated wrapper code to deserialize the JSON payload into the typed parameters of your Rust function. Each parameter is matched by name to a key in the JSON object. Type mismatches become deserialization errors that propagate back to the JavaScript caller as <code>Error</code> objects, not panics.</p>
<p><strong>Step 5 — your function runs.</strong> The Rust function executes. Async commands execute on <code>tauri::async_runtime</code> (Tokio underneath). The function can take a <code>State&lt;T&gt;</code> parameter, which Tauri's dependency injection populates from values you registered with <code>tauri::Builder::manage()</code>.</p>
<p><strong>Step 6 — result serialization and return.</strong> The function's return value (<code>Result&lt;T, E&gt;</code> where both <code>T</code> and <code>E</code> are <code>serde::Serialize</code>) is serialized back to JSON and posted through the resolver back to the JavaScript caller. The TypeScript <code>invoke()</code> Promise resolves with the decoded result, or rejects with the decoded error.</p>
<h3>2.2 What this means for your application code</h3>
<p>The Rust side of an IPC handler is genuinely small. From the book's admin UI, here is the canonical handler — <code>cmd_get_blockchain_info</code>, in <code>bitcoin-desktop-ui-tauri/src-tauri/src/commands/blockchain.rs</code>:</p>
<pre><code class="language-rust">use crate::api::service::BitcoinApiService;
use tauri::State;
use serde_json::Value;

use crate::AppState;

#[tauri::command]
pub async fn cmd_get_blockchain_info(
    state: State&lt;'_, AppState&gt;,
) -&gt; Result&lt;Value, String&gt; {
    let response = BitcoinApiService::get_blockchain_info(
        state.base_url.clone(),
        state.api_key.clone(),
    )
    .await?;

    serde_json::to_value(&amp;response)
        .map_err(|e| e.to_string())
}
</code></pre>
<p>Three things to notice. First, the <code>#[tauri::command]</code> macro is doing a lot of work — it generates a wrapper that handles JSON deserialization, dispatches to your function, and serializes the result. You don't write any of that.</p>
<p>Second, the <code>state: State&lt;'_, AppState&gt;</code> parameter is dependency injection. You registered <code>AppState</code> once in <code>main()</code> via <code>.manage(app_state)</code>, and Tauri populates this parameter on every call. No globals, no thread-local statics, no manual locking — it's the same idiomatic pattern as <code>axum::extract::State</code> or <code>actix_web::web::Data</code>.</p>
<p>Third, the function returns <code>Result&lt;serde_json::Value, String&gt;</code>. The <code>String</code> error type is conventional in Tauri because everything that crosses the bridge has to be <code>Serialize</code>, and <code>String</code> is the cheapest serializable error. Production code typically uses a custom error enum that derives <code>Serialize</code>, but the pattern is the same.</p>
<p>The TypeScript side is even smaller, in <code>bitcoin-desktop-ui-tauri/src/commands.ts</code>:</p>
<pre><code class="language-typescript">import { invoke } from '@tauri-apps/api/core';
import { BlockchainInfo, BlockSummary, ApiResponse } from './types';

export async function getBlockchainInfo(): Promise&lt;ApiResponse&lt;BlockchainInfo&gt;&gt; {
    return invoke&lt;ApiResponse&lt;BlockchainInfo&gt;&gt;('cmd_get_blockchain_info');
}
</code></pre>
<p>A one-line wrapper around <code>invoke()</code>. The string <code>'cmd_get_blockchain_info'</code> is the same name the Rust function exports through <code>tauri::generate_handler!</code>. The TypeScript generic argument <code>&lt;ApiResponse&lt;BlockchainInfo&gt;&gt;</code> tells the compiler what shape the resolved value will have — but <strong>note this is a TypeScript-only assertion, not a runtime guarantee</strong>. If the Rust side returns a differently-shaped object, TypeScript won't notice until something reads a missing property at runtime.</p>
<p>We'll come back to this gap when we discuss <code>tauri-specta</code>.</p>
<h3>2.3 The <code>tauri::generate_handler!</code> macro</h3>
<p>Every command you want to expose has to be listed in a single <code>tauri::generate_handler!</code> call inside <code>main()</code>. The book's admin UI does this with all 22 of its commands:</p>
<pre><code class="language-rust">fn main() {
    tauri::Builder::default()
        .manage(AppState {
            base_url: "http://127.0.0.1:8080".to_string(),
            api_key: std::env::var("BITCOIN_API_ADMIN_KEY")
                .unwrap_or_else(|_| "admin-secret".to_string()),
        })
        .invoke_handler(tauri::generate_handler![
            cmd_get_blockchain_info,
            cmd_get_latest_blocks,
            cmd_get_blocks,
            cmd_get_block_by_hash,
            cmd_get_mining_info,
            cmd_generate_to_address,
            cmd_get_health,
            cmd_get_liveness,
            cmd_get_readiness,
            cmd_get_mempool,
            cmd_get_mempool_transaction,
            cmd_get_transactions,
            cmd_get_address_transactions,
            cmd_create_wallet_admin,
            cmd_get_addresses_admin,
            cmd_get_wallet_info_admin,
            cmd_get_balance_admin,
            cmd_send_transaction_admin,
            cmd_set_base_url,
            cmd_set_api_key,
            cmd_get_config,
            cmd_check_connection,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
</code></pre>
<p>Two gotchas worth flagging immediately:</p>
<ul>
<li><strong>Calling <code>invoke_handler</code> more than once silently overwrites.</strong> Only the last call wins. If you split command registration across multiple files thinking each one will register its own handlers, only one set will actually be live. Pass every command to a single <code>generate_handler!</code> call.</li>
<li><strong>A command not listed in <code>generate_handler!</code> fails silently from the frontend.</strong> The Rust function still compiles; it's just unreachable through IPC. From JavaScript you get a generic "command not found" error at runtime, with no compile-time warning. <strong>The list above is a runtime contract — keep it audited.</strong></li>
</ul>
<h3>2.4 Async, runtimes, and <code>Send</code></h3>
<p>Async commands run on <code>tauri::async_runtime</code>, which is a Tokio multi-threaded runtime under the hood. Two consequences follow.</p>
<p>First, <strong>everything in your async command's environment must be <code>Send</code></strong>, because the runtime can move the future across worker threads at any <code>.await</code> point. Standard <code>std::sync::MutexGuard</code> is intentionally not <code>Send</code> (this is what protects you from cross-thread deadlocks in non-async code), so you cannot hold one across an <code>.await</code>. Use <code>tokio::sync::Mutex</code>, whose <code>MutexGuard</code> is <code>Send</code>. If you guess wrong, you get a runtime panic — there's no compile-time check that catches it.</p>
<p>Second, <strong><code>State&lt;T&gt;</code> references and <code>AppHandle</code> work differently at <code>.await</code> boundaries.</strong> A <code>State&lt;'_, T&gt;</code> borrow is short-lived and shouldn't be held across an <code>.await</code>. If you need to own data across an await point, extract the data you need out of <code>State</code> <em>before</em> the first <code>.await</code>, or use <code>AppHandle</code> (which is <code>Clone</code> and owned) to access state inside async closures. The book's wallet uses both: synchronous reads use <code>State</code>, while long-running async tasks pass an owned <code>AppHandle</code>.</p>
<h3>2.5 The ergonomic gap: stringly-typed <code>invoke()</code></h3>
<p>If you've made it this far, you've noticed something: the link between <code>invoke('cmd_get_balance')</code> and <code>cmd_get_balance</code> in Rust is a <strong>string</strong>. TypeScript doesn't know the command exists until the Promise resolves at runtime. There's no autocomplete on command names, no compile-time check that you spelled the command correctly, no signal that you passed the wrong argument shape.</p>
<p>This is where the community has done the work that the official docs haven't done, in the form of <a href="https://github.com/specta-rs/tauri-specta"><code>tauri-specta</code></a>. It's a library that generates TypeScript bindings from your Rust commands at build time, using the <a href="https://github.com/oscartbeaumont/specta">Specta</a> crate for type introspection. The same <code>cmd_get_balance</code> function, with <code>#[tauri::command]</code> augmented by <code>#[specta::specta]</code>, becomes a typed export the TypeScript side can import:</p>
<pre><code class="language-typescript">import { commands } from './bindings'; // generated by tauri-specta

const balance: u64 = await commands.cmdGetBalance({ addr: '1A1zP1...' });
</code></pre>
<p>The compiler now knows the command exists, knows it takes an <code>addr: string</code>, and knows it resolves to a <code>u64</code>. Rename the Rust function and the TypeScript build breaks immediately. Ship the wrong argument shape and the TypeScript build breaks immediately. The Tauri 2 docs don't show this. Production teams use it almost universally.</p>
<p>If you take only one practical recommendation from this post, it's <strong>add <code>tauri-specta</code> to any Tauri 2 project as soon as you have more than ~five commands</strong>. The official <code>invoke()</code> is great for prototypes and tutorials. For real applications, the typed bindings are the difference between bugs at compile time and bugs at runtime.</p>
<h2>3. Tauri vs Electron — design and security, not vibes</h2>
<p>The "Tauri vs Electron" comparison appears in every Rust desktop discussion in 2026, and most of those comparisons are vibes-based. Here is the comparison grounded in design and security, with citations to each project's own documentation.</p>
<table>
<thead>
<tr>
<th>Axis</th>
<th>Tauri 2</th>
<th>Electron</th>
</tr>
</thead>
<tbody><tr>
<td>Backend language</td>
<td>Rust</td>
<td>Node.js (V8)</td>
</tr>
<tr>
<td>Webview engine</td>
<td>OS-native (WebView2 / WKWebView / WebKitGTK)</td>
<td>Bundled Chromium</td>
</tr>
<tr>
<td>Mobile support</td>
<td>Yes (Tauri 2 added iOS and Android, Oct 2024)</td>
<td>Desktop only</td>
</tr>
<tr>
<td>IPC mechanism</td>
<td>JSON over the platform webview's host-bridge</td>
<td>JSON / structured-clone over <code>ipcMain</code> / <code>ipcRenderer</code></td>
</tr>
<tr>
<td>Permission model</td>
<td>Capability-based ACL (opt-in per window)</td>
<td>None by default; full filesystem unless you add manual gates</td>
</tr>
<tr>
<td>Plugin ecosystem</td>
<td>~120 official + community plugins (April 2026)</td>
<td>10,000+ npm packages</td>
</tr>
<tr>
<td>Bundle size, hello-world</td>
<td>2–10 MB (community-cited)</td>
<td>80–200 MB (community-cited)</td>
</tr>
<tr>
<td>Memory at idle</td>
<td>~50 MB (community-cited)</td>
<td>~120 MB+ (community-cited)</td>
</tr>
<tr>
<td>License</td>
<td>MIT / Apache 2.0</td>
<td>MIT</td>
</tr>
</tbody></table>
<p>(Bundle and memory figures are widely-cited community measurements from 2026 comparative analyses; I'm not claiming these as my own benchmarks. They are useful as orders-of-magnitude context, not as precision numbers.)</p>
<p>The headline trade-off is this. <strong>Electron buys you the most mature desktop-app ecosystem in existence in exchange for shipping an entire copy of Chromium with every app and giving the JavaScript side default access to the user's filesystem.</strong> Tauri buys you a tenth of the bundle size and a permission system that's secure by default, in exchange for asking your team to write the backend in Rust and giving up most of npm.</p>
<p>Pick Electron when:</p>
<ul>
<li>Your team doesn't know Rust and doesn't have time to learn it</li>
<li>The application doesn't hold secrets, doesn't need a small footprint, and ships only on desktop</li>
<li>You depend on a Node.js library (or an Electron-specific plugin) that has no Rust equivalent</li>
<li>Examples that match: VS Code, Slack, Discord, Atom</li>
</ul>
<p>Pick Tauri when:</p>
<ul>
<li>Your application holds secrets — wallets, password managers, API keys, encryption keys</li>
<li>Your application is positioned on lightweight or low-resource — productivity tools that compete with Electron incumbents (AppFlowy vs Notion is the canonical example)</li>
<li>You need iOS or Android support from the same codebase</li>
<li>Your business logic is already a Rust crate, and the desktop client is just the latest consumer (Spacedrive's <code>spacedrive-core</code> is the textbook case)</li>
<li>Examples that match: Cap.so, AppFlowy, Spacedrive, Padloc, Hoppscotch</li>
</ul>
<p>For the rest of this post we're firmly in Tauri territory: the example application holds private keys, has a strict threat model, and wants the smallest reasonable attack surface.</p>
<h2>4. A working example: a Bitcoin wallet that keeps keys safe</h2>
<p>This is where the post's example earns its keep. The book's <code>bitcoin-wallet-ui-tauri</code> crate is a Bitcoin wallet — 18 IPC commands, an encrypted SQLite database, and a strict invariant: <strong>private keys never cross the IPC boundary</strong>. The React UI never touches them. JavaScript can ask Rust to sign a transaction; it cannot ask Rust to hand over the key that does the signing.</p>
<p>This pattern generalizes beyond blockchain. A password manager has the same threat model. So does an enterprise dashboard that holds API tokens. So does an ML inference UI that holds proprietary model weights. The wallet is a concrete instance of a class of secret-holding desktop apps; once you understand its IPC surface, you understand all of them.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/e345786b-434c-46f9-b0e8-f71e28371a3e.png" alt="Wallet IPC boundary — keys stay in Rust, only signed transactions cross to React" /></p>
<p>The diagram above shows the wallet's IPC surface. The left side is what React can ask for. The right side is what only Rust knows. The orange dashed bar between them is the IPC boundary, and <strong>the design choice is that nothing on the right side has a path to the left side, even in principle</strong>.</p>
<h3>4.1 The wallet's state structure</h3>
<p>The wallet's <code>AppState</code> looks like this:</p>
<pre><code class="language-rust">use tauri::State;
use std::sync::RwLock;

#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct WalletState {
    pub current_wallet: Option&lt;String&gt;,
    pub wallets: Vec&lt;String&gt;,
    pub database_password: String,
}

#[derive(Clone)]
pub struct AppState {
    pub wallet: RwLock&lt;WalletState&gt;,
    pub base_url: String,
}
</code></pre>
<p>Notice what <code>WalletState</code> doesn't contain. There is no <code>private_key</code> field. There is no <code>seed_phrase</code> field. There is no <code>keypair</code>. <strong>The struct that's available to IPC handlers via <code>State&lt;AppState&gt;</code> does not contain anything secret.</strong> Private keys live elsewhere — loaded from an encrypted SQLite database into a <code>SecretKey</code> value that's used inside a single function call and dropped before the function returns.</p>
<p>The <code>database_password</code> field is a deterministic hash of <code>\(USER</code> and <code>\)HOME</code>, so the same user on the same machine always unlocks the same wallet database — but the password itself is derived locally and never shipped to the frontend.</p>
<h3>4.2 The 18 commands the wallet exposes</h3>
<p>These are all 18 commands the wallet exposes across IPC, in the order they're registered with <code>generate_handler!</code>:</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>Returns</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>cmd_list_wallets</code></td>
<td><code>Vec&lt;String&gt;</code></td>
<td>List wallet identifiers stored on disk</td>
</tr>
<tr>
<td><code>cmd_create_wallet</code></td>
<td><code>String</code></td>
<td>Create a new wallet, return its identifier</td>
</tr>
<tr>
<td><code>cmd_select_wallet</code></td>
<td><code>()</code></td>
<td>Set the active wallet for subsequent operations</td>
</tr>
<tr>
<td><code>cmd_get_balance</code></td>
<td><code>u64</code></td>
<td>Balance of an address, in satoshis</td>
</tr>
<tr>
<td><code>cmd_get_addresses</code></td>
<td><code>Vec&lt;Address&gt;</code></td>
<td>All addresses owned by the active wallet</td>
</tr>
<tr>
<td><code>cmd_generate_address</code></td>
<td><code>Address</code></td>
<td>Derive and return a new address</td>
</tr>
<tr>
<td><code>cmd_get_transaction_history</code></td>
<td><code>Vec&lt;Tx&gt;</code></td>
<td>Past transactions for the active wallet</td>
</tr>
<tr>
<td><code>cmd_get_unspent_outputs</code></td>
<td><code>Vec&lt;Utxo&gt;</code></td>
<td>UTXOs available for spending</td>
</tr>
<tr>
<td><code>cmd_send_transaction</code></td>
<td><code>TxId</code></td>
<td>Build, sign, and broadcast a transaction</td>
</tr>
<tr>
<td><code>cmd_sign_transaction</code></td>
<td><code>SignedTx</code></td>
<td>Sign a draft transaction (does not broadcast)</td>
</tr>
<tr>
<td><code>cmd_get_tx_status</code></td>
<td><code>TxStatus</code></td>
<td>Confirmation count for a given txid</td>
</tr>
<tr>
<td><code>cmd_export_wallet</code></td>
<td><code>String</code></td>
<td>Export an encrypted wallet bundle (no plaintext keys)</td>
</tr>
<tr>
<td><code>cmd_import_wallet</code></td>
<td><code>()</code></td>
<td>Import a wallet bundle into local storage</td>
</tr>
<tr>
<td><code>cmd_backup_wallet</code></td>
<td><code>()</code></td>
<td>Trigger a backup to the configured location</td>
</tr>
<tr>
<td><code>cmd_set_passphrase</code></td>
<td><code>()</code></td>
<td>Set the wallet's unlock passphrase</td>
</tr>
<tr>
<td><code>cmd_unlock_wallet</code></td>
<td><code>bool</code></td>
<td>Verify the passphrase and unlock for the session</td>
</tr>
<tr>
<td><code>cmd_get_wallet_info</code></td>
<td><code>WalletInfo</code></td>
<td>Metadata about the active wallet</td>
</tr>
<tr>
<td><code>cmd_cancel_transaction</code></td>
<td><code>()</code></td>
<td>Cancel a pending transaction by txid</td>
</tr>
</tbody></table>
<p><strong>Look at what isn't in this list.</strong> There is no <code>cmd_get_private_key</code>. There is no <code>cmd_export_seed_phrase</code>. There is no <code>cmd_decrypt_database</code>. The IPC surface itself does not offer a path for keys to escape Rust. Even if a malicious npm package compromised the React UI tomorrow and an attacker had complete control of the JavaScript side, the worst they could do is <em>ask Rust to sign things</em> — they could not extract the keys to sign things on their own machine.</p>
<p>This is the security pattern. The IPC layer is your API surface; design it so that the operations you need are present and the operations that would leak secrets are intentionally absent.</p>
<h3>4.3 Three IPC patterns the wallet demonstrates</h3>
<p><strong>Pattern 1 — synchronous query.</strong> A read-only command that takes no parameters or simple parameters and returns a value:</p>
<pre><code class="language-rust">#[tauri::command]
pub async fn cmd_get_balance(
    state: State&lt;'_, AppState&gt;,
    addr: String,
) -&gt; Result&lt;u64, String&gt; {
    let wallet = state.wallet.read().map_err(|e| e.to_string())?;
    // ... query the chain, sum UTXOs, return total
}
</code></pre>
<p>The TypeScript side calls it the same way as a fetch:</p>
<pre><code class="language-typescript">const balance: number = await invoke&lt;number&gt;('cmd_get_balance', { addr });
</code></pre>
<p>React Query wraps this with caching:</p>
<pre><code class="language-typescript">export function useBalance(addr: string | null) {
    return useQuery({
        queryKey: ['balance', addr],
        queryFn: () =&gt; addr ? commands.getBalance(addr) : Promise.resolve(0),
        enabled: !!addr,
        staleTime: 10000,  // balances refresh every 10s
    });
}
</code></pre>
<p><strong>Pattern 2 — async write that mutates state.</strong> A command that changes something — broadcasts a transaction, creates a wallet — and that the rest of the UI needs to know about:</p>
<pre><code class="language-rust">#[tauri::command]
pub async fn cmd_send_transaction(
    state: State&lt;'_, AppState&gt;,
    to: String,
    amount: u64,
) -&gt; Result&lt;String, String&gt; {
    // build the tx, sign it (in place, with the key never leaving Rust),
    // broadcast to the network, return the txid
}
</code></pre>
<p>The React side uses <code>useMutation</code> and invalidates relevant cached reads on success:</p>
<pre><code class="language-typescript">const sendTx = useMutation({
    mutationFn: (vars: { to: string; amount: number }) =&gt;
        commands.sendTransaction(vars.to, vars.amount),
    onSuccess: () =&gt; {
        queryClient.invalidateQueries({ queryKey: ['balance'] });
        queryClient.invalidateQueries({ queryKey: ['transaction-history'] });
    },
});
</code></pre>
<p>After a successful send, the cached balance is marked stale so the next render fetches fresh data. This is React Query's bread and butter — it's also the right design for any IPC bridge that needs to keep client-side caches consistent with server-side mutations.</p>
<p><strong>Pattern 3 — security-bounded operation.</strong> This is the wallet's distinctive pattern. <code>cmd_sign_transaction</code> takes a transaction draft (recipient address, amount, fee), signs it inside Rust, and returns the signed bytes. The private key is loaded from the encrypted database, used inside a single function call, and dropped before the response is serialized back to the frontend.</p>
<pre><code class="language-rust">#[tauri::command]
pub async fn cmd_sign_transaction(
    state: State&lt;'_, AppState&gt;,
    draft: TransactionDraft,
) -&gt; Result&lt;SignedTransaction, String&gt; {
    let wallet = state.wallet.read().map_err(|e| e.to_string())?;
    let sk = load_secret_key(&amp;wallet.database_password)?;
    let signed = sign_in_place(draft, &amp;sk)?;
    // sk is dropped here, before the function returns
    Ok(signed)
}
</code></pre>
<p>The crucial observation: there is no path through this function that surfaces <code>sk</code> to the caller. The function returns <code>SignedTransaction</code>, which is just bytes — no key material in it. <strong>The IPC surface offers signing but does not offer key extraction.</strong> A malicious frontend can spam-call this function but cannot exfiltrate the underlying key.</p>
<p>This is the pattern that separates a Tauri-style desktop wallet from a browser extension wallet. In a browser extension, the "frontend" and the "backend" share the same JavaScript runtime, so isolating keys requires a much weaker form of process boundary. In Tauri, the IPC bridge is a hard boundary enforced by the operating system's webview, and the Rust side controls what crosses it.</p>
<h3>4.4 The shared-crate architecture</h3>
<p>One more architectural insight worth its own subsection. The book has <em>four</em> implementations of the wallet UI: a pure-Rust Iced version (Chapter 18), this Tauri-with-React version (Chapter 19), and a parallel admin UI in both frameworks (Chapters 16 and 17).</p>
<p>All four call <strong>the same underlying Rust crate</strong>: <code>bitcoin-api</code>, which exposes <code>AdminClient</code> and <code>WalletClient</code> types that talk to the blockchain over HTTP. The Tauri commands are thin wrappers — they receive <code>State&lt;AppState&gt;</code>, call into the <code>bitcoin-api</code> crate, and return JSON. The Iced UIs do the same calls from <code>Message</code> handlers. The framework choice is decoupled from the application logic.</p>
<p>Why does this matter? Because <strong>Tauri is a UI shell, not an application framework</strong>. If your team's business logic is already a reusable Rust crate, swapping the desktop shell from Electron to Tauri (or from one Rust UI framework to another) doesn't require refactoring anything except the layer that makes API calls. Spacedrive's open-source codebase demonstrates this at production scale: their <code>spacedrive-core</code> crate is a pure Rust library; the Tauri 2 desktop app, the CLI, and the Spacebot service are all thin consumers of that one crate.</p>
<p>If you're considering Tauri but worried about lock-in, this architecture pattern is your insurance policy. Keep the application logic in plain Rust crates; treat the Tauri layer as the desktop adapter; you can rip out the Tauri layer in a weekend if you ever need to.</p>
<h2>5. The capability system: security at the IPC boundary</h2>
<p>We promised in §2 that we'd come back to the capability system. Here it is.</p>
<p>In Tauri 1, every IPC command was reachable from every window by default. You opted <em>out</em> of dangerous APIs by editing an allowlist. Tauri 2 inverts this: nothing is reachable by default, and you grant access by declaring <strong>capabilities</strong> in JSON files under your project's <code>capabilities/</code> directory.</p>
<p>A capability declaration is a small JSON document that names a window or webview, names the permissions it should have, and optionally scopes those permissions further (which filesystem paths, which HTTP endpoints, which IPC commands). When a window calls <code>invoke()</code>, the Tauri IPC layer consults the capabilities for that window before dispatch — if the requested command isn't covered by a capability grant, the call is rejected before the Rust handler is ever invoked.</p>
<p>For the wallet, a minimal capability file granting access only to the wallet commands looks something like this:</p>
<pre><code class="language-json">{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "wallet-main",
  "description": "Capabilities for the main wallet window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "wallet:allow-balance-and-history",
      "allow": [
        "cmd_get_balance",
        "cmd_get_addresses",
        "cmd_get_transaction_history",
        "cmd_send_transaction",
        "cmd_sign_transaction"
      ]
    }
  ]
}
</code></pre>
<p>The <code>windows: ["main"]</code> line means this capability only applies to the window labeled <code>main</code>. If your app has a tray-icon window or a notification overlay window, those windows have their own capability files with their own permission grants. <strong>Per-window scoping is the design feature that makes the capability system useful</strong> — you can give the main interactive window the broad permissions it needs while keeping the tray-icon window restricted to a single command.</p>
<p>Three things this design buys you:</p>
<p><strong>A clear audit story.</strong> A security reviewer can read your <code>capabilities/</code> directory and immediately see what each window can ask Rust to do. There is no hidden middleware, no ambient permissions. The capability file <em>is</em> the authorization policy.</p>
<p><strong>Defense in depth.</strong> Even before the ACL check, the IPC surface itself is limited (we saw this in §4 with the wallet's commands). Even if a misconfigured capability accidentally grants too much, the only attack surface available is the commands you actually wrote. The capability layer is the second gate, not the only gate.</p>
<p><strong>A red-team analysis you can write down.</strong> Suppose tomorrow a malicious npm package compromised your React build. The attacker now controls the JavaScript running in the webview. What can they do? The answer is: exactly what the capability file allows, no more. They can call <code>cmd_send_transaction</code> if <code>cmd_send_transaction</code> is in the allowlist. They cannot reach files outside the configured filesystem scope. They cannot make HTTP requests to arbitrary servers. They especially cannot extract private keys, because no command exists that would return one.</p>
<p>This story is impossible to write for an Electron app with the default configuration, because Electron's default is "the renderer process can do anything the main process can do." You can lock Electron down — <code>contextIsolation</code>, <code>sandbox</code>, <code>nodeIntegration: false</code>, custom IPC validators — but these are opt-in, ad hoc, and easy to misconfigure. Tauri 2's capability model is opt-in by inversion: secure by default, dangerous by explicit grant. <strong>For an application that holds secrets, this default is the entire reason to pick Tauri.</strong></p>
<h2>6. Five things that bit me</h2>
<p>A practical section for readers who are about to ship Tauri 2 in production. These are the recurring pitfalls — drawn from the Tauri repo's issue tracker, Discord conversations, and the experience of building the book's two Tauri apps.</p>
<p><strong>1. <code>invoke_handler</code> only registers the last call.</strong> If you call <code>.invoke_handler(generate_handler![cmd_a])</code> and then <code>.invoke_handler(generate_handler![cmd_b])</code>, only <code>cmd_b</code> is registered. There is no compile-time warning. Pass every command to a single <code>generate_handler!</code> invocation. If the list gets unwieldy, group commands into modules and re-export them, but the final list lives in one macro call.</p>
<p><strong>2. Standard <code>MutexGuard</code> is not <code>Send</code> — your async command won't compile or will panic.</strong> When you hold a <code>std::sync::MutexGuard</code> across an <code>.await</code> point, the Rust compiler refuses to make the future <code>Send</code>, and Tauri requires async-command futures to be <code>Send</code>. Use <code>tokio::sync::Mutex</code> and <code>tokio::sync::RwLock</code>, whose guards are <code>Send</code>. The book's wallet uses <code>std::sync::RwLock</code> for synchronous code and switches to Tokio's primitives whenever a lock is held across an await.</p>
<p><strong>3. Event listeners need cleanup.</strong> Tauri's frontend event API (<code>listen('event-name', handler)</code>) registers a JavaScript callback for a Rust-emitted event. If the React component that registered the listener unmounts without calling the unlisten function, the callback stays alive — and if a new instance of the component mounts, you now have two callbacks reacting to the same event. The book's React hooks always store the unlisten promise and clean up in <code>useEffect</code> return:</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
    const unlistenPromise = listen('block-mined', (event) =&gt; {
        // ...
    });
    return () =&gt; {
        unlistenPromise.then(fn =&gt; fn());
    };
}, []);
</code></pre>
<p><strong>4. Setup-hook events fire before listeners attach.</strong> If you call <code>app.emit('startup')</code> from the Tauri setup hook in Rust, none of the React components are mounted yet, so the event has no listeners and is dropped. The pattern is to wait for a "ready" handshake from the frontend before emitting startup events. The frontend mounts, calls <code>invoke('cmd_frontend_ready')</code>, and the Rust side then emits the startup events with confidence.</p>
<p><strong>5. The official <code>invoke()</code> is stringly-typed; production teams use <code>tauri-specta</code>.</strong> This was the recommendation in §2.5 and it's worth saying again as a pitfall. Without <code>tauri-specta</code>, every IPC call is a string lookup, every argument is <code>unknown</code> until you assert the type, and every rename of a Rust function is a runtime bug waiting to happen. Add <code>tauri-specta</code> early. The migration cost from <code>invoke()</code> to typed bindings is small if you do it before you have hundreds of <code>invoke()</code> call sites.</p>
<p>Bonus issue worth knowing about: <strong><code>Origin</code> header handling on external URLs</strong> (<a href="https://github.com/tauri-apps/tauri/issues/15190">issue #15190</a>) is the bug we opened the post with. If you set the webview to an external URL via <code>tauri::WebviewUrl::External</code>, the embedded webview may not include an <code>Origin</code> header on IPC requests, and the scope checker rejects them. The current workaround is to use <code>tauri::WebviewUrl::App</code> with an internal HTML shim that loads the external content, or to disable origin checking for that specific window (which has security trade-offs of its own).</p>
<h2>7. Where Tauri is heading</h2>
<p>The Tauri team has been public about what comes after 2.x. Three directions worth knowing about:</p>
<p><strong>Servo as an alternative webview engine.</strong> Tauri currently uses each platform's native webview, which means inheriting that webview's bugs and feature set. Servo — Mozilla's experimental Rust-native browser engine — is being actively developed as an alternative renderer. If it lands, you'll be able to ship Tauri apps that don't depend on the host operating system's webview, eliminating a class of cross-platform inconsistency bugs at the cost of bundling a (still much smaller than Chromium) browser engine. Watch Tauri Discussions and the Servo project for status updates.</p>
<p><strong>WASI plugin architecture.</strong> Native Tauri plugins are written in Rust and compiled into the application binary. A WASI-based plugin model would let plugins ship as <code>.wasm</code> files that the Tauri runtime loads at startup, with capability constraints enforced by the WebAssembly sandbox. This unlocks third-party plugins that the user can install at runtime without recompiling their Tauri app.</p>
<p><strong>Mobile maturity.</strong> Tauri 2.0 added iOS and Android targets, but the mobile experience still has rough edges: Xcode tooling integration is immature, many plugins don't yet support mobile, and the docs lag behind desktop. Expect the 2.x line to keep filling these gaps.</p>
<p>For the Bitcoin wallet specifically, the most interesting near-term direction is mobile. A Tauri-on-iOS Bitcoin wallet that shares its Rust core with the desktop wallet — same <code>bitcoin-api</code> crate, same signing logic, same secret handling — is a single-codebase mobile + desktop app of a kind that's been historically very expensive to build. The shared-crate architecture from §4.4 is what makes that affordable.</p>
<h2>8. Summary</h2>
<p>The five takeaways:</p>
<ol>
<li><strong><code>invoke()</code> is a JSON-RPC over the platform webview's host bridge</strong> — you can read the implementation in <code>crates/tauri/src/ipc/</code> and it's not much code. Understanding it makes debugging IPC bugs tractable.</li>
<li><strong>Design your IPC surface so secrets never have a path out.</strong> The Bitcoin wallet's 18 commands include <code>cmd_sign_transaction</code> but not <code>cmd_get_private_key</code>. The capability system gates command access; the API surface itself bounds what's even askable.</li>
<li><strong>Tauri is a UI shell, not an application framework.</strong> Keep your business logic in plain Rust crates. The framework choice becomes a swap, not a rewrite.</li>
<li><strong>Use <code>tauri-specta</code> for any non-trivial app.</strong> The default <code>invoke()</code> is stringly-typed; production teams add <code>tauri-specta</code> for compile-time safety on every command call.</li>
<li><strong>Tauri vs Electron is a threat-model question.</strong> If your app holds secrets, the capability-based opt-in permission model is the entire reason you'd pick Tauri. If your app doesn't hold secrets and you have an Electron-only npm dependency, Electron is fine.</li>
</ol>
<p>If you want the working code that goes with this post — all 22 admin commands, all 18 wallet commands, the React frontends, the Rust backends, the capability files, and the Iced parallel implementations — clone the <a href="https://github.com/bkunyiha/rust-blockchain">companion repository on GitHub</a>. The wallet alone is ~1,400 lines of Rust and ~1,200 lines of TypeScript, and you can have it building locally in fifteen minutes.</p>
<p>If you want the long form — 33 chapters, 768 pages, taking the same blockchain from the Bitcoin whitepaper to a deployed Kubernetes cluster — that's the book.</p>
<hr />
<h2>Sources and further reading</h2>
<h3>Tauri primary sources</h3>
<ul>
<li><a href="https://v2.tauri.app/concept/inter-process-communication/">Inter-Process Communication</a> — official Tauri 2 IPC docs</li>
<li><a href="https://v2.tauri.app/develop/calling-rust/">Calling Rust from the Frontend</a> — <code>#[tauri::command]</code> reference</li>
<li><a href="https://v2.tauri.app/security/capabilities/">Capabilities</a> — capability declarations</li>
<li><a href="https://v2.tauri.app/security/permissions/">Permissions</a> — permission identifiers and sets</li>
<li><a href="https://v2.tauri.app/security/">Security model overview</a> — Tauri 2's security architecture</li>
<li><a href="https://v2.tauri.app/develop/state-management/">State Management</a> — <code>State&lt;T&gt;</code>, <code>RwLock</code>, <code>tokio::sync</code> choices</li>
<li><a href="https://v2.tauri.app/blog/tauri-20/">Tauri 2.0 Stable Release announcement</a> — what 2.0 changed</li>
</ul>
<h3>Tauri repo issues and discussions cited</h3>
<ul>
<li><a href="https://github.com/tauri-apps/tauri/issues/15190"><code>#15190</code> — Origin header bug with external URL</a></li>
<li><a href="https://github.com/tauri-apps/tauri/issues/6889"><code>#6889</code> — Custom protocol IPC break</a></li>
<li><a href="https://github.com/tauri-apps/tauri/discussions/5690">Discussion <code>#5690</code> — IPC Improvements</a></li>
<li><a href="https://github.com/orgs/tauri-apps/discussions/5413">Discussion <code>#5413</code> — Setup events not captured</a></li>
<li><a href="https://github.com/orgs/tauri-apps/discussions/5194">Discussion <code>#5194</code> — Listen-twice prevention</a></li>
</ul>
<h3>Type safety</h3>
<ul>
<li><a href="https://github.com/specta-rs/tauri-specta"><code>tauri-specta</code> repository</a></li>
<li><a href="https://docs.rs/tauri-specta/latest/tauri_specta/"><code>tauri-specta</code> documentation</a></li>
</ul>
<h3>Production examples</h3>
<ul>
<li><a href="https://github.com/tauri-apps/awesome-tauri">Awesome-Tauri</a> — curated Tauri apps and resources</li>
<li><a href="https://spacedrive.com/blog/spacedrive-v3-launch">Spacedrive launch + architecture</a> — the <code>spacedrive-core</code> shared-crate pattern at production scale</li>
</ul>
<h3>Comparative analyses</h3>
<ul>
<li><a href="https://www.pkgpulse.com/blog/electron-vs-tauri-2026">Tauri vs Electron 2026 — PkgPulse</a></li>
<li><a href="https://tech-insider.org/tauri-vs-electron-2026/">Tauri vs Electron 2026 — Tech Insider</a></li>
<li><a href="https://dev.to/hiyoyok/tauri-v2-vs-electron-after-6-months-of-real-development-my-honest-take-2ic0">Tauri v2 vs Electron, 6-month real-world experience</a></li>
</ul>
<h3>The book and its companion repository</h3>
<ul>
<li><em>Rust Blockchain: A Full-Stack Implementation Guide</em> — <a href="https://www.amazon.com/dp/B0GXRYL21V">Amazon</a> | <a href="https://buildwithrust.gumroad.com/l/rust-blockchain">Gumroad (PDF + EPUB)</a> | <a href="https://leanpub.com/rustblockchainafull-stackimplementationguide">Leanpub (pay what you want)</a></li>
<li><a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a> — the companion source code, including both Tauri apps and the shared <code>bitcoin-api</code> crate</li>
</ul>
<hr />
<p><em>Available now on <a href="https://www.amazon.com/dp/B0GXRYL21V">Amazon</a> (paperback + Kindle + hardcover), <a href="https://buildwithrust.gumroad.com/l/rust-blockchain">Gumroad</a> (PDF + EPUB), and <a href="https://leanpub.com/rustblockchainafull-stackimplementationguide">Leanpub</a> (pay what you want).</em></p>
<p><em>Free resource: <a href="https://github.com/bkunyiha/rust-blockchain">Clone the Starter Template</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Rust Crypto: SHA-256, ECDSA, Keys]]></title><description><![CDATA[When building the cryptography layer for a Bitcoin-style blockchain in Rust, the goal should be a small surface area. Every consensus-critical operation passes through it: transaction IDs, block hashe]]></description><link>https://buildwithrust.com/rust-crypto-sha-256-ecdsa-keys</link><guid isPermaLink="true">https://buildwithrust.com/rust-crypto-sha-256-ecdsa-keys</guid><category><![CDATA[cryptography, bitcoin]]></category><category><![CDATA[Cryptography]]></category><category><![CDATA[blockchain security]]></category><category><![CDATA[Blockchain]]></category><category><![CDATA[crypto security]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Fri, 01 May 2026 01:19:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/f6f0fc07-74d9-46d1-a52f-599517c2fea3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When building the cryptography layer for a Bitcoin-style blockchain in Rust, the goal should be a <em>small</em> surface area. Every consensus-critical operation passes through it: transaction IDs, block hashes, proof-of-work, signature creation, signature verification, address derivation. Get any of those wrong by a single byte, and nodes diverge. Get the dependency hygiene wrong, and you ship a CVE.</p>
<p>The shape that works: four jobs, four submodules, four dozen lines of public API. That's the entire <code>bitcoin/src/crypto/</code> directory in this reference implementation — wrapping libraries that have been audited harder than anything you could write yourself: <code>ring</code> (BoringSSL-backed) for SHA-256 and ECDSA, <code>secp256k1</code> (the same library Bitcoin Core ships) for Schnorr and EC arithmetic, <code>sha2</code> (pure Rust) for Taproot-style hashing, <code>bs58</code> for address encoding.</p>
<p>This post walks through each of those four jobs — what the cryptography actually does, why the API is shaped the way it is, and the specific Rust idioms that make it harder to use the layer wrong than to use it right. By the end, if you've written some Rust but never touched cryptography, you should be able to read every line of this layer and know <em>why</em> each one exists.</p>
<p>The dependencies, for reference — these are the only crypto-related crates the layer pulls in:</p>
<pre><code class="language-toml"># bitcoin/Cargo.toml (relevant excerpt)
[dependencies]
ring        = "0.17"   # SHA-256 + P-256 ECDSA (BoringSSL-backed)
secp256k1   = { version = "0.31", features = ["rand", "global-context"] }
                       # secp256k1 curve + Schnorr (libsecp256k1 wrapper)
sha2        = "0.10"   # Pure-Rust SHA-256 (used for taproot_hash)
bs58        = "0.5"    # Base58 / Base58Check encoding
rand        = "0.9"    # OS-backed RNG, supplied to sign_schnorr_with_rng
</code></pre>
<p>Four crates. Roughly three thousand lines of Rust on top of them. That's the entire perimeter of cryptographic code you need to keep correct.</p>
<h2>The Four Jobs</h2>
<p>A blockchain implementation needs cryptography to answer three practical questions: <em>how to name things</em>, <em>how to prove authorization</em>, and <em>how to represent these ideas as bytes humans can copy/paste</em>. Split that into four submodules:</p>
<table>
<thead>
<tr>
<th>Module</th>
<th>Job</th>
<th>Public API</th>
</tr>
</thead>
<tbody><tr>
<td><code>hash</code></td>
<td>turn arbitrary bytes into stable identifiers</td>
<td><code>sha256_digest</code>, <code>taproot_hash</code></td>
</tr>
<tr>
<td><code>signature</code></td>
<td>prove a private-key holder authorized a message</td>
<td><code>schnorr_sign_digest</code>, <code>schnorr_sign_verify</code></td>
</tr>
<tr>
<td><code>keypair</code></td>
<td>generate secrets and derive their public counterparts</td>
<td><code>new_schnorr_key_pair</code>, <code>get_schnorr_public_key</code></td>
</tr>
<tr>
<td><code>address</code></td>
<td>encode/decode binary payloads into typeable strings</td>
<td><code>base58_encode</code>, <code>base58_decode</code></td>
</tr>
</tbody></table>
<p>The <code>mod.rs</code> is short on purpose — it defines what the rest of the codebase is allowed to call, and nothing else:</p>
<pre><code class="language-rust">pub mod address;
pub mod hash;
pub mod keypair;
pub mod signature;

pub use address::{base58_decode, base58_encode};
pub use hash::{sha256_digest, taproot_hash};
pub use keypair::{get_schnorr_public_key, new_key_pair, new_schnorr_key_pair};
pub use signature::{
    ecdsa_p256_sha256_sign_digest,
    ecdsa_p256_sha256_sign_verify,
    schnorr_sign_digest,
    schnorr_sign_verify,
};
</code></pre>
<p>Because <code>bitcoin/src/lib.rs</code> re-exports the crypto module (<code>pub use crypto::*;</code>), call sites elsewhere in the crate look like <code>crate::sha256_digest(bytes)</code> — short, visible, easy to grep for, and with the cryptographic decisions still centralized in one place.</p>
<h2>Hashing: The Workhorse</h2>
<p>A hash function is a one-way compression function that turns any input bytes into a fixed 32-byte output ("digest"). In a blockchain, hashes do four jobs: they identify transactions, they link blocks together, they drive proof-of-work, and they let nodes agree on what they're looking at without having to compare megabytes of state.</p>
<p>A blockchain relies on five SHA-256 properties:</p>
<ul>
<li><strong>Determinism</strong>: same input, same output — every node, every machine, forever.</li>
<li><strong>Preimage resistance</strong>: given <code>h = SHA256(m)</code>, recovering <code>m</code> is computationally infeasible.</li>
<li><strong>Second-preimage resistance</strong>: given <code>m</code>, finding a different <code>m'</code> with the same hash is infeasible.</li>
<li><strong>Collision resistance</strong>: finding <em>any</em> two distinct inputs with the same hash is infeasible — the cost is ~2¹²⁸ hash evaluations, not 2²⁵⁶, because of the <a href="https://en.wikipedia.org/wiki/Birthday_problem">birthday paradox</a> (the fastest collision attack only needs to search half the bits before two random outputs collide on average).</li>
<li><strong>Avalanche</strong>: flipping 1 input bit flips ~50% of output bits.</li>
</ul>
<p>That last property is the one that does the heavy lifting for tamper-evidence. If a node receives a block claiming to be the Genesis but with one byte modified, the hash won't just be "close" to the expected hash — it will be utterly different, and the chain link from the next block won't point to it anymore.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/c5f52c93-3c2a-46fc-8ce9-84e9e0effa5d.png" alt="SHA-256 avalanche effect: a 1-character input change flips ~128 of 256 output bits" /></p>
<p>The implementation is twelve lines:</p>
<pre><code class="language-rust">use ring::digest::{Context, SHA256};

pub fn sha256_digest(data: &amp;[u8]) -&gt; Vec&lt;u8&gt; {
    let mut context = Context::new(&amp;SHA256);
    context.update(data);
    let digest = context.finish();
    digest.as_ref().to_vec()
}
</code></pre>
<p>Reach for <code>ring</code> because it's a thin wrapper over BoringSSL — the same C cryptographic core that runs production traffic at Google. The <code>Context</code> API is incremental (you can call <code>update</code> repeatedly with chunks), which is useful for hashing large data without materializing it all in memory. The blockchain's hash sites don't need that, but the cost of supporting it is zero.</p>
<h3>Where hashing shows up</h3>
<p>The <code>sha256_digest</code> function gets called in three structurally important places:</p>
<p><strong>Transaction IDs.</strong> A transaction's identity is the hash of its content. But there's a chicken-and-egg problem: the hash depends on the bytes, and the ID is one of the fields in the transaction. The fix is to hash a <em>trimmed copy</em> with the ID field cleared:</p>
<pre><code class="language-rust">fn hash(&amp;mut self) -&gt; Result&lt;Vec&lt;u8&gt;&gt; {
    let tx_copy = Transaction {
        id: vec![],          // exclude from the bytes being hashed
        vin: self.vin.clone(),
        vout: self.vout.clone(),
    };
    Ok(sha256_digest(tx_copy.serialize()?.as_slice()))
}
</code></pre>
<p>Then store the result <em>into</em> the transaction's <code>id</code> field. The hash of any transaction is fully determined by its inputs and outputs — change any byte of either, and the ID changes.</p>
<p><strong>Proof-of-work loops.</strong> PoW is "try nonces until the hash is below a target." It's just <code>sha256_digest</code> running in a loop, hammering through nonces:</p>
<pre><code class="language-rust">loop {
    let candidate_bytes = serialize_header(&amp;header_with_nonce);
    let hash = sha256_digest(&amp;candidate_bytes);
    if hash_int(&amp;hash) &lt; target {            // big-endian compare against
        return (nonce, hash);                // a 256-bit difficulty target
    }
    nonce = nonce.wrapping_add(1);           // wrap on overflow, never panic
}
</code></pre>
<p><code>hash_int</code> interprets the 32-byte digest as a 256-bit big-endian integer so it can be compared against the difficulty target — small numerical value means many leading zero bits, which means many failed hashes had to come before this one. <code>wrapping_add(1)</code> is the Rust-specific touch: integer overflow on <code>nonce += 1</code> would panic in debug builds, which is fatal in a tight CPU loop. Wrapping arithmetic is well-defined and correct here because the absolute value of the nonce doesn't matter — only that it changes between attempts.</p>
<p>The asymmetry here is what makes PoW work: mining requires millions of hash evaluations on average; verification requires <em>one</em>. Every receiving node validates the same nonce-hash pair with a single <code>sha256_digest</code> call.</p>
<p><strong>Block-level transaction commitment.</strong> A block needs a single fingerprint of all its transactions. Bitcoin uses a <em>Merkle tree</em> — a binary tree where each leaf is a transaction hash, each internal node is the hash of its two children concatenated, and the root is the single hash committing to every transaction in the block. The benefit of the tree shape is that you can prove a single transaction is in the block by showing only <code>log₂(n)</code> sibling hashes (the path from leaf to root), which is what light clients and SPV wallets rely on. A teaching codebase can get away with a simpler hash-of-concatenated-IDs:</p>
<pre><code class="language-rust">pub fn hash_transactions(&amp;self) -&gt; Vec&lt;u8&gt; {
    let mut txhashs = vec![];
    for transaction in &amp;self.transactions {
        txhashs.extend(transaction.get_id());
    }
    crate::sha256_digest(txhashs.as_slice())
}
</code></pre>
<p>This is one of the explicit simplifications worth flagging. It costs O(n) bytes to compute (vs. O(n) hashes for a Merkle tree, but with Merkle's per-tx inclusion proofs as the upgrade path). For a teaching codebase that's the right trade-off; for byte-for-byte Bitcoin compatibility, build a real Merkle root.</p>
<p>There's also a second SHA-256 entry point, <code>taproot_hash</code>, that uses the pure-Rust <code>sha2</code> crate instead of <code>ring</code>. The output is identical — same algorithm, different implementation — and the split is historical: this codebase started with <code>ring</code> for general hashing, then added <code>sha2</code> when Taproot-style address derivation got wired in and a no-C-dependency path was needed in the hot path. In production you'd probably consolidate; for a learning codebase, having both paths visible is instructive.</p>
<blockquote>
<p><strong>Note</strong>: Bitcoin Core uses <em>double</em> SHA-256 (<code>SHA256(SHA256(x))</code>) for many identifiers. This codebase uses single SHA-256 for clarity. That's consensus-relevant — for byte-for-byte Bitcoin compatibility, the hashing rules have to be aligned. Call it out explicitly rather than hiding it.</p>
</blockquote>
<h2>Signatures: Schnorr Over secp256k1</h2>
<p>A digital signature is a mathematical proof that someone who knows a private key approved a specific sequence of bytes. Without revealing the key. It provides three things: <strong>authenticity</strong> ("the holder of this key authorized this message"), <strong>integrity</strong> ("if the message changes, verification fails"), and <strong>unforgeability</strong> ("you can't make a valid signature without the key").</p>
<p>Bitcoin historically used ECDSA over <code>secp256k1</code>. Modern Bitcoin (Taproot, BIP-340) uses Schnorr over the same curve. Schnorr signatures are smaller (always 64 bytes vs. ECDSA's variable 70–72), have provably stronger security under standard assumptions, and they support signature aggregation — multiple signers can produce one combined signature, which is what makes Taproot's spending paths so powerful.</p>
<p>For a new implementation, pick Schnorr as the primary path. ECDSA helpers can stick around for contrast (and to show how <code>ring</code>'s API differs).</p>
<h3>Signing</h3>
<pre><code class="language-rust">use secp256k1::{Keypair, Secp256k1, SecretKey};

pub fn schnorr_sign_digest(
    private_key: &amp;[u8],
    message: &amp;[u8],
) -&gt; Result&lt;Vec&lt;u8&gt;&gt; {
    let secp = Secp256k1::new();

    let secret_key_array: [u8; 32] = private_key
        .try_into()
        .map_err(|_| BtcError::TransactionSignatureError(
            "Invalid private key length".to_string()
        ))?;
    let secret_key = SecretKey::from_byte_array(secret_key_array)?;

    let message_hash = sha256_digest(message);
    let keypair = Keypair::from_secret_key(&amp;secp, &amp;secret_key);
    let mut rng = rand::rng();
    let signature = secp.sign_schnorr_with_rng(
        &amp;message_hash,
        &amp;keypair,
        &amp;mut rng,
    );

    Ok(signature.as_ref().to_vec())
}
</code></pre>
<p>A few things worth noticing in those twenty lines:</p>
<ol>
<li><p><strong>Length validation is enforced by the type system, not by an <code>if</code> statement.</strong> <code>private_key.try_into()</code> returns <code>Result&lt;[u8; 32], _&gt;</code> — if the slice isn't exactly 32 bytes, the error maps to a <code>BtcError</code> and the function returns early. There is no path through this function where a 31-byte or 33-byte input gets silently accepted.</p>
</li>
<li><p><strong>The <code>message</code> is hashed <em>inside</em> the signing function.</strong> Schnorr conceptually signs a 32-byte digest, not arbitrary-length data. So always feed in <code>SHA256(message)</code>. Callers can pass whatever bytes they want — for transactions, that's the trimmed-copy hash — and the function does the right thing.</p>
</li>
<li><p><strong>Randomness is supplied at sign time.</strong> Schnorr is technically deterministic in the BIP-340 spec, but the <code>secp256k1</code> crate's API takes an RNG and uses it to build a nonce. Pass <code>rand::rng()</code>, which delegates to the OS. Never seed your own RNG for cryptographic operations — the OS entropy pool is better than anything you can build in user space.</p>
</li>
<li><p><strong>The signature is always 64 bytes</strong>, regardless of the message size or the key. Predictable output sizes simplify storage, serialization, and on-the-wire framing.</p>
</li>
</ol>
<h3>Verification</h3>
<pre><code class="language-rust">pub fn schnorr_sign_verify(
    public_key: &amp;[u8],
    signature: &amp;[u8],
    message: &amp;[u8],
) -&gt; bool {
    let secp = Secp256k1::new();

    let public_key_array: [u8; 33] = match public_key.try_into() {
        Ok(arr) =&gt; arr,
        Err(_) =&gt; return false,
    };
    let pk = match PublicKey::from_byte_array_compressed(public_key_array) {
        Ok(pk) =&gt; pk,
        Err(_) =&gt; return false,
    };

    // Schnorr verifies against an x-only (32-byte) public key,
    // not the 33-byte compressed form.
    let pk_bytes = pk.serialize();
    let xonly_array: [u8; 32] = match pk_bytes[1..33].try_into() {
        Ok(arr) =&gt; arr,
        Err(_) =&gt; return false,
    };
    let xonly_pk = match XOnlyPublicKey::from_byte_array(xonly_array) {
        Ok(pk) =&gt; pk,
        Err(_) =&gt; return false,
    };

    let message_hash = sha256_digest(message);

    let signature_array: [u8; 64] = match signature.try_into() {
        Ok(arr) =&gt; arr,
        Err(_) =&gt; return false,
    };
    let sig = schnorr::Signature::from_byte_array(signature_array);

    secp.verify_schnorr(&amp;sig, &amp;message_hash, &amp;xonly_pk).is_ok()
}
</code></pre>
<p>The verifier's signature-input parsing is <em>paranoid by design</em> — every malformed input path returns <code>false</code>, never a panic. A malicious peer can ship a transaction with a 17-byte signature or a 5-byte public key, and the verification function will reject it cleanly without taking the node down. Returning <code>false</code> for a malformed signature is equivalent to "the signature doesn't verify," which is correct: an unparseable signature is not a valid signature.</p>
<p>The conversion from compressed (33-byte) to x-only (32-byte) public key is Schnorr-specific. Compressed public keys carry a 1-byte parity prefix (<code>0x02</code> or <code>0x03</code>) plus 32 bytes of X coordinate. Schnorr verification only needs the X coordinate — Y is implicit — so the prefix gets stripped.</p>
<h3>How signing and verification thread through the transaction code</h3>
<p>Signing and verifying transactions ties everything together. Both sides reconstruct the <em>same</em> trimmed copy of the transaction, hash it, then sign or verify against that hash. The trick is reconstruction: the verifier doesn't have the signer's draft, just the final transaction with signatures attached, but they can rebuild the exact bytes that were signed by clearing the signature fields and copying in each input's pub-key hash.</p>
<p>A short detour on field naming, because the next code listing reuses one field for two purposes and that's worth flagging up front. A <code>TXInput</code> has both a <code>pub_key</code> field and a <code>signature</code> field. During signing/verification, the input's <code>pub_key</code> field is briefly <em>repurposed</em> to hold the previous output's <code>pub_key_hash</code> so it gets included in the hashed bytes — this is a Bitcoin convention going back to the original whitepaper, and it's how the verifier knows <em>which output's lock</em> the signature was meant to satisfy. After hashing, the field gets cleared again so the bytes don't leak into later inputs' hashes within the same transaction.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/b4d8939d-586c-43f3-8369-1a4ea01f8736.png" alt="Sign and verify: the trimmed-copy roundtrip" /></p>
<p>The signing side, called once per input:</p>
<pre><code class="language-rust">async fn sign(
    &amp;mut self,
    blockchain: &amp;BlockchainService,
    private_key: &amp;[u8],
) -&gt; Result&lt;()&gt; {
    let mut tx_copy = self.trimmed_copy();

    for (idx, vin) in self.vin.iter_mut().enumerate() {
        // 1. Find the previous transaction being spent from.
        let prev_tx = blockchain
            .find_transaction(vin.get_txid())
            .await?
            .ok_or_else(|| BtcError::TransactionNotFoundError(
                "Previous transaction not found".to_string(),
            ))?;

        // 2. Splice the prev-output's pub_key_hash into the trimmed copy.
        tx_copy.vin[idx].signature = vec![];
        tx_copy.vin[idx].pub_key   = prev_tx.vout[vin.vout].pub_key_hash.clone();
        tx_copy.id                 = tx_copy.hash()?;
        tx_copy.vin[idx].pub_key   = vec![];

        // 3. Sign the resulting transaction hash.
        let signature = schnorr_sign_digest(private_key, tx_copy.get_id())?;
        vin.signature = signature;
    }
    Ok(())
}
</code></pre>
<p>And the verifier, structurally identical:</p>
<pre><code class="language-rust">pub async fn verify(&amp;self, blockchain: &amp;BlockchainService) -&gt; Result&lt;bool&gt; {
    if self.is_coinbase() {
        return Ok(true);
    }

    let mut trimmed = self.trimmed_copy();

    for (idx, vin) in self.vin.iter().enumerate() {
        let prev_tx = blockchain
            .find_transaction(vin.get_txid())
            .await?
            .ok_or_else(|| BtcError::TransactionNotFoundError(
                "Previous transaction not found".to_string(),
            ))?;

        trimmed.vin[idx].signature = vec![];
        trimmed.vin[idx].pub_key   = prev_tx.vout[vin.vout].pub_key_hash.clone();
        trimmed.id                 = trimmed.hash()?;
        trimmed.vin[idx].pub_key   = vec![];

        if !schnorr_sign_verify(
            vin.get_pub_key(),
            vin.get_signature(),
            trimmed.get_id(),
        ) {
            return Ok(false);
        }
    }

    Ok(true)
}
</code></pre>
<p>A few worth-noticing details:</p>
<ul>
<li><strong><code>is_coinbase()</code> short-circuits to <code>Ok(true)</code>.</strong> A coinbase transaction is the special transaction that creates new coins as the block's mining reward. It has no real inputs to spend from (the protocol-specified pseudo-input doesn't reference any previous output), so there's nothing to verify a signature against. The miner who built the block is implicitly authorized by the proof-of-work consumed.</li>
<li><strong>The return type is <code>Result&lt;bool&gt;</code>, not <code>bool</code>.</strong> The two possible outcomes are conceptually different: <code>Ok(true)</code> / <code>Ok(false)</code> mean "the signature was successfully checked and did/didn't verify"; <code>Err(...)</code> means "verification couldn't even be attempted because the previous transaction wasn't found in the local chain." The caller treats those very differently — a missing prev-tx is a sync issue, while a failed verification is a protocol violation.</li>
<li><strong>No <code>?</code> inside the verification check.</strong> <code>schnorr_sign_verify</code> returns <code>bool</code>, so the code uses <code>if !...</code> rather than <code>?</code>. This is intentional: a malformed signature is not an <em>error</em> in the program-flow sense, it's just an invalid input to a check that always answers true or false.</li>
</ul>
<p>Notice how mechanical the verifier is. There is no oracle, no external trust, no shared secret. The verifier just replays the same byte-construction that the signer ran, and checks that the attached signature matches the resulting hash. Determinism is what gives the network consensus on which transactions are valid, even though the <em>creation</em> of those transactions is wildly distributed.</p>
<h2>Keys: Generating Secrets and Deriving Their Public Halves</h2>
<p>A private key on <code>secp256k1</code> is, fundamentally, a 32-byte random number — specifically, an integer in the range <code>[1, n)</code> where <code>n</code> is the curve order, ~2²⁵⁶ − 2¹²⁸. Practically, that means almost every random 32-byte value is a valid private key (the exclusion zone near the top of the range and the value 0 together cover about 2¹²⁸ out of 2²⁵⁶ possible values, so the probability of generating an invalid key by chance is negligible). Generating one looks like this:</p>
<pre><code class="language-rust">pub fn new_schnorr_key_pair() -&gt; Result&lt;Vec&lt;u8&gt;&gt; {
    let mut secret_key_bytes = [0u8; 32];
    ring::rand::SystemRandom::new()
        .fill(&amp;mut secret_key_bytes)
        .map_err(|e| BtcError::WalletKeyPairError(e.to_string()))?;

    // Round-trip through SecretKey to validate that the bytes
    // form a valid scalar (rejects 0 and any value above the curve order).
    let secret_key = SecretKey::from_byte_array(secret_key_bytes)
        .map_err(|e| BtcError::WalletKeyPairError(e.to_string()))?;

    Ok(secret_key.secret_bytes().to_vec())
}
</code></pre>
<p>Two operations, both important:</p>
<ul>
<li><strong><code>SystemRandom::new().fill(...)</code></strong> asks the OS for cryptographically secure random bytes — <code>/dev/urandom</code> on Linux, <code>BCryptGenRandom</code> on Windows. The OS entropy pool aggregates timing jitter, hardware events, and (on modern CPUs) <code>RDRAND</code> instructions. Anything user-space can build is a downgrade.</li>
<li><strong><code>SecretKey::from_byte_array</code></strong> validates the candidate bytes. Not every 32-byte value is a valid <code>secp256k1</code> scalar — zero is not (it would produce the <em>point at infinity</em>, which is the curve's "identity element" and doesn't represent a usable public key), and values ≥ the curve order would wrap around modulo <code>n</code> and silently degrade the security argument. The constructor rejects both, returning an error rather than silently corrupting the key.</li>
</ul>
<p>The public key is derived by <em>elliptic-curve scalar multiplication</em> of the private key by the curve's generator point <code>G</code>. Mechanically: take a fixed publicly-known point on the curve (<code>G</code>), and "add it to itself" <code>priv_key</code> times following the curve's addition rule. That gives a new point on the curve — the public key. The addition rule is geometric (draw a line through two points, find the third intersection with the curve, reflect across the X-axis), but the implementation is pure integer arithmetic modulo the curve's prime field. Three things make this useful for cryptography:</p>
<ol>
<li><strong>Forward computation is fast.</strong> With double-and-add, computing <code>priv_key · G</code> takes ~256 doublings and ~128 additions — sub-millisecond.</li>
<li><strong>Backward computation is intractable.</strong> Recovering <code>priv_key</code> from <code>priv_key · G</code> is the <a href="https://en.wikipedia.org/wiki/Elliptic-curve_cryptography">elliptic-curve discrete logarithm problem</a> — there is no known algorithm faster than ~2¹²⁸ operations on <code>secp256k1</code>. That's the security foundation for every Bitcoin signature ever made.</li>
<li><strong>The output is small.</strong> A point on the curve is just two coordinates, each 32 bytes. As shown in a moment, that compresses to 33 bytes total.</li>
</ol>
<p>The Rust call is one line:</p>
<pre><code class="language-rust">pub fn get_schnorr_public_key(private_key: &amp;[u8]) -&gt; Result&lt;Vec&lt;u8&gt;&gt; {
    let secp = Secp256k1::new();

    let secret_key_array: [u8; 32] = private_key
        .try_into()
        .map_err(|_| BtcError::WalletKeyPairError(
            "Invalid private key length".to_string()
        ))?;
    let secret_key = SecretKey::from_byte_array(secret_key_array)?;
    let public_key = PublicKey::from_secret_key(&amp;secp, &amp;secret_key);

    Ok(public_key.serialize().to_vec())
}
</code></pre>
<p>The output is a 33-byte <em>compressed</em> public key — a single prefix byte (<code>0x02</code> if the Y coordinate is even, <code>0x03</code> if odd) followed by the 32-byte X coordinate. The Y coordinate is implied by the prefix and the curve equation, saving 32 bytes per key vs. the uncompressed form. Bitcoin standardized on compressed keys for exactly this reason: storage and bandwidth scale with the size of the key, and over millions of transactions the savings add up.</p>
<p>The full wallet creation flow chains three crypto operations into a typeable address:</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/dc785c0d-5324-4ba2-beca-3b6ffdf8f576.png" alt="Wallet pipeline: 32-byte secret → 33-byte public key → 32-byte hash → ~44-character Base58 address" /></p>
<p>Every arrow on that diagram is computationally cheap going right and infeasible going left — the same one-way property described in the discrete-log point above. Even if an attacker has your address, they can't work back to your public key (it's hashed); even if they had the public key, they couldn't work back to the private key (discrete log).</p>
<h3>A note on ECDSA helpers</h3>
<p>The crypto layer also exposes ECDSA signing and verification, but they're not on the active path:</p>
<pre><code class="language-rust">pub fn ecdsa_p256_sha256_sign_digest(
    pkcs8: &amp;[u8],
    message: &amp;[u8],
) -&gt; Result&lt;Vec&lt;u8&gt;&gt; {
    let rng = ring::rand::SystemRandom::new();
    let key_pair = EcdsaKeyPair::from_pkcs8(
        &amp;ECDSA_P256_SHA256_FIXED_SIGNING,
        pkcs8,
        &amp;rng,
    )?;
    let signature = key_pair.sign(&amp;rng, message)?;
    Ok(signature.as_ref().to_vec())
}
</code></pre>
<p>Two things are different from the Schnorr path. First, the key is in <strong>PKCS#8</strong> format — a standardized, slightly heavier wire format that includes algorithm metadata. Useful for interoperating with non-Bitcoin systems; unnecessary for a Bitcoin-style chain. Second, the curve is <strong>P-256</strong> (<code>secp256r1</code>), not Bitcoin's <code>secp256k1</code>. They look similar — both 256-bit curves over a prime field — but they have different parameters and are not interchangeable. Real Bitcoin ECDSA would be ECDSA over <code>secp256k1</code>, which the <code>secp256k1</code> crate also supports.</p>
<p>Keep the ECDSA helpers as a reference comparison point and as an entry hook for anyone who wants to wire <code>secp256k1</code>-flavored ECDSA in for legacy Bitcoin compatibility. They're a useful contrast: same conceptual operation (sign a message with a key), wildly different ergonomics.</p>
<h2>Addresses: Bytes Humans Can Safely Copy/Paste</h2>
<p>A wallet address isn't a key. It's a human-friendly encoding of a binary payload — usually <code>version_byte || pub_key_hash || 4_byte_checksum</code> — that's been serialized into a string a person can type or paste into a chat without losing characters.</p>
<p>Why not just use hex? Two reasons:</p>
<ul>
<li><strong>Hex is verbose.</strong> A 25-byte payload becomes 50 hex characters. Base58 brings that down to ~34 characters.</li>
<li><strong>Hex has confusable characters.</strong> <code>0</code> vs <code>O</code>, <code>1</code> vs <code>l</code> vs <code>I</code> — easy to mistype, hard to spot. Base58 is the alphabet <code>[A-HJ-NP-Za-km-z1-9]</code> — no zero, no capital O, no capital I, no lowercase L. Anything that survives Base58 decoding looks unambiguous.</li>
</ul>
<p>The encoding/decoding pair lives in <code>crypto/address.rs</code> and wraps the <code>bs58</code> crate:</p>
<pre><code class="language-rust">use bs58;

pub fn base58_encode(data: &amp;[u8]) -&gt; String {
    bs58::encode(data).into_string()
}

pub fn base58_decode(data: &amp;str) -&gt; Result&lt;Vec&lt;u8&gt;&gt; {
    Ok(bs58::decode(data)
        .into_vec()
        .map_err(|e| BtcError::Base58DecodeError(e.to_string()))?)
}
</code></pre>
<p>That's the whole module. The functions are tiny because the actual Base58 alphabet handling is in <code>bs58</code> — the wrapper just provides the right error type at the boundary.</p>
<p>What about the checksum? In Bitcoin's full <strong>Base58Check</strong> format, the address is built as:</p>
<pre><code>payload         = version_byte || pub_key_hash       (1 + 20-32 bytes)
checksum        = SHA256(SHA256(payload))[0..4]      (4 bytes)
address_bytes   = payload || checksum                (25-37 bytes)
address_string  = base58_encode(address_bytes)       (~34-44 chars)
</code></pre>
<p>The checksum gives you typo detection: if you mistype one character of an address, the recomputed checksum almost certainly won't match the four bytes embedded at the end, and the wallet rejects the address before it can send funds to a black hole. This is why Bitcoin addresses fail loudly on a typo instead of just sending money to nowhere.</p>
<p>The raw <code>base58_encode</code> / <code>base58_decode</code> exposed here are the <em>building blocks</em>. The full Base58Check assembly (version byte + checksum + concatenation) belongs in the wallet layer rather than the crypto layer, because the version byte choice depends on the address type (legacy P2PKH, SegWit, Taproot — each has its own version) and the crypto module shouldn't take a position on which one. Separation of concerns: the crypto module knows how to encode bytes; the wallet knows what bytes mean.</p>
<h2>Security and Performance</h2>
<p>The crypto layer doesn't run in a vacuum. Every node that processes a block does the same crypto work — and an active attacker controls the network, can send malformed inputs, and may be measuring timing to extract bits of your secrets. Five practices make the difference between "looks fine in tests" and "doesn't get owned in production":</p>
<p><strong>Use the OS RNG. Always.</strong> <code>ring::rand::SystemRandom</code> for everything random in this layer. There is no scenario in a blockchain context where seeding your own PRNG is correct.</p>
<p><strong>Validate inputs before they touch crypto state.</strong> Length checks in particular. The <code>try_into::&lt;[u8; N]&gt;()</code> pattern enforces this at the type level — there's literally no way past it with a wrong-sized slice. Use it everywhere bytes come in from outside the crate.</p>
<p><strong>Return <code>Result</code>, never panic, on cryptographic operations.</strong> A panic on malformed input becomes a denial-of-service vector — any peer who can ship a 31-byte signature can crash the node. Every public function in <code>crypto/</code> should return <code>Result&lt;T, BtcError&gt;</code> or <code>bool</code>. None of them should unwrap.</p>
<p><strong>Trust the constant-time implementations the libraries give you.</strong> "Constant-time" means the runtime of an operation does <em>not</em> depend on the secret it's manipulating. The reason this matters: if your signature verification finishes faster when the first byte of the key happens to be zero, an attacker who can run thousands of verifications on a server (or measure power draw, or measure cache misses, or any other "side channel") can reconstruct the secret bit by bit. This is not theoretical — Bleichenbacher's RSA attacks and the various Lucky13/timing attacks against TLS were all exactly this. Both <code>ring</code> and <code>secp256k1</code> (the C library <code>libsecp256k1</code> underneath) are explicitly constant-time on the hot paths. Hand-rolling constant-time crypto is famously easy to get wrong — even comparing two byte slices with <code>==</code> is a timing leak. Delegate.</p>
<p><strong>Treat private keys as toxic.</strong> No logging. No serialization in plaintext. No leaving them in memory longer than needed. In a production wallet, private keys live in an SQLCipher-encrypted SQLite database; in transit through the API they exist as <code>Vec&lt;u8&gt;</code> with deliberately minimal lifetime. Rust's ownership model helps — the borrow checker won't let you accidentally hand a <code>&amp;SecretKey</code> to a logging facility — but discipline matters. For very high-value keys, <code>zeroize</code> on drop and hardware-backed key stores are the next steps up.</p>
<h3>Performance, in round numbers</h3>
<p>On a modern x86-64 server core, with the <code>ring</code> and <code>secp256k1</code> libraries:</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Throughput</th>
<th>Per-op latency</th>
</tr>
</thead>
<tbody><tr>
<td><code>sha256_digest</code> (small input)</td>
<td>~1.5 GB/s</td>
<td>&lt;1 µs</td>
</tr>
<tr>
<td>Schnorr signing</td>
<td>1,000–2,000 ops/s</td>
<td>0.5–1 ms</td>
</tr>
<tr>
<td>Schnorr verification</td>
<td>2,000–4,000 ops/s</td>
<td>0.25–0.5 ms</td>
</tr>
<tr>
<td>Public key derivation</td>
<td>5,000–10,000 ops/s</td>
<td>0.1–0.2 ms</td>
</tr>
<tr>
<td>Key generation (incl. RNG)</td>
<td>100–200 ops/s</td>
<td>5–10 ms</td>
</tr>
</tbody></table>
<p>The bottleneck for transaction throughput is signature verification — at 2,000–4,000 verifications per second per core, a busy node spends most of its CPU on <code>verify_schnorr</code>. In practice, for high-throughput nodes you'd want batch verification (<code>secp256k1</code> supports it natively) and you'd parallelize across cores with <code>rayon</code>. Both are off-the-shelf.</p>
<h2>Try it Yourself</h2>
<p>Drop this into a fresh <code>cargo new --bin crypto_demo</code> and add the four crates from the dependencies block at the top of this post. The whole sign-and-verify roundtrip is fewer than 30 lines:</p>
<pre><code class="language-rust">use bitcoin::crypto::{
    sha256_digest,
    new_schnorr_key_pair,
    get_schnorr_public_key,
    schnorr_sign_digest,
    schnorr_sign_verify,
};

fn main() -&gt; anyhow::Result&lt;()&gt; {
    // 1. Generate a fresh key pair
    let private_key = new_schnorr_key_pair()?;
    let public_key  = get_schnorr_public_key(&amp;private_key)?;

    println!("priv: {} bytes  pub: {} bytes",
             private_key.len(), public_key.len());
    // → priv: 32 bytes  pub: 33 bytes

    // 2. Sign a message
    let message   = b"Hello, blockchain!";
    let signature = schnorr_sign_digest(&amp;private_key, message)?;
    println!("sig: {} bytes", signature.len());        // → sig: 64 bytes

    // 3. Verify (happy path)
    let ok = schnorr_sign_verify(&amp;public_key, &amp;signature, message);
    assert!(ok, "valid signature must verify");

    // 4. Verify (tampered message — must fail)
    let tampered = b"Hello, blockchain?";
    let bad      = schnorr_sign_verify(&amp;public_key, &amp;signature, tampered);
    assert!(!bad, "tampered message must NOT verify");

    // 5. Show the avalanche effect on a 1-byte change
    println!("hash(orig)     = {:x?}", &amp;sha256_digest(message)[..8]);
    println!("hash(tampered) = {:x?}", &amp;sha256_digest(tampered)[..8]);
    Ok(())
}
</code></pre>
<p>Two things you'll observe:</p>
<ol>
<li><strong>The valid signature verifies; the tampered-message verification returns <code>false</code> cleanly</strong> — no panic, no error, just <code>false</code>. That's the API working as designed.</li>
<li><strong>The hashes of the two messages share zero bytes in common despite a single character difference.</strong> That's the avalanche effect in action.</li>
</ol>
<h2>Key Takeaways</h2>
<p>If you take five things away from this layer, take these:</p>
<p><strong>Keep the cryptographic surface area small.</strong> Four submodules, ten public functions. Anyone reading the code can audit exactly what's exposed. Anyone adding a new cryptographic operation has to do it in one place. Cryptographic agility comes from a small, deliberate API, not from hiding choices behind wrappers.</p>
<p><strong>Length validation through the type system, not through assertions.</strong> <code>private_key.try_into::&lt;[u8; 32]&gt;()</code> turns "is this the right length?" from a runtime question with a panic-on-wrong-answer into a compile-time-checked <code>Result</code>. Use it everywhere you accept bytes from outside the crate.</p>
<p><strong>Sign and verify must reconstruct the same byte sequence.</strong> The trimmed-copy pattern works because both sides do the same thing in the same order. A single byte of disagreement — different field order, different encoding, an extra newline — and verification fails. Lock down your serialization choices and don't let them drift.</p>
<p><strong>Trust audited libraries. Don't reinvent crypto.</strong> <code>ring</code>, <code>secp256k1</code>, and <code>sha2</code> are the libraries to pick. They've been hammered on by people who specialize in finding cryptographic bugs. A cryptography layer should be mostly <em>plumbing</em> between them and the rest of the codebase, plus type-level guarantees that the plumbing can't leak. The actual hashing and signing bodies are someone else's problem, and that's a feature.</p>
<p><strong>Performance is dominated by verification. Plan for it.</strong> Mining is parallel. Signing is per-user, slow but rare. Verification is per-transaction, must run on every node, and is where &gt;80% of crypto time goes in a busy chain. Batch where you can. Parallelize where you must.</p>
<h2>Explore the Source Code</h2>
<p>The complete cryptography layer is open source:</p>
<p><strong>Full repository:</strong> <a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a></p>
<p><strong>Key files referenced in this post:</strong></p>
<ul>
<li><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/crypto/mod.rs">crypto/mod.rs</a> — module layout and public re-exports</li>
<li><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/crypto/hash.rs">crypto/hash.rs</a> — <code>sha256_digest</code>, <code>taproot_hash</code></li>
<li><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/crypto/signature.rs">crypto/signature.rs</a> — Schnorr and ECDSA signing/verification</li>
<li><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/crypto/keypair.rs">crypto/keypair.rs</a> — key generation and public-key derivation</li>
<li><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/crypto/address.rs">crypto/address.rs</a> — Base58 encoding/decoding</li>
<li><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/primitives/transaction.rs">primitives/transaction.rs</a> — <code>Transaction::sign</code> and <code>Transaction::verify</code> integration</li>
</ul>
<hr />
<p><em>This post is adapted from <a href="https://buildwithrust.com">Rust Blockchain: A Full-Stack Implementation Guide</a> — a 33-chapter book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.</em></p>
<p><em>Available now on <a href="https://www.amazon.com/dp/B0GXRYL21V">Amazon</a> (paperback + Kindle + hardcover), <a href="https://buildwithrust.gumroad.com/l/rust-blockchain">Gumroad</a> (PDF + EPUB), and <a href="https://leanpub.com/rustblockchainafull-stackimplementationguide">Leanpub</a> (pay what you want).</em></p>
<p><em>Free resource: <a href="https://github.com/bkunyiha/rust-blockchain">Clone the Starter Template</a></em></p>
<p><em>Follow <a href="https://buildwithrust.com">buildwithrust.com</a> for weekly posts on building production systems in Rust.</em></p>
]]></content:encoded></item><item><title><![CDATA[From Bitcoin Whitepaper to Working Blockchain in Rust]]></title><description><![CDATA[From Bitcoin Whitepaper to Working Blockchain in Rust
In 2008, Satoshi Nakamoto published a nine-page paper that described a peer-to-peer electronic cash system. The paper defined what the system shou]]></description><link>https://buildwithrust.com/from-bitcoin-whitepaper-to-working-blockchain-in-rust</link><guid isPermaLink="true">https://buildwithrust.com/from-bitcoin-whitepaper-to-working-blockchain-in-rust</guid><category><![CDATA[Rust]]></category><category><![CDATA[Rust programming]]></category><category><![CDATA[Blockchain]]></category><category><![CDATA[Web3]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Mon, 20 Apr 2026 05:57:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/f8fb6fe5-c187-4d77-bf82-cb029de10bb1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>From Bitcoin Whitepaper to Working Blockchain in Rust</h1>
<p>In 2008, Satoshi Nakamoto published a nine-page paper that described a peer-to-peer electronic cash system. The paper defined <em>what</em> the system should do — digital signatures for ownership, proof-of-work for consensus, a chain of hashes for immutability — but left <em>how</em> to turn those ideas into working code as an exercise for the reader.</p>
<p>We took that exercise seriously. We implemented the entire Bitcoin whitepaper in Rust — every core concept, from UTXO-based transactions to proof-of-work mining to Merkle tree commitments — as a working, runnable blockchain. This post walks through that translation: whitepaper concept → Rust data structure → working code.</p>
<p>If you've read the whitepaper and thought "okay, but how do I actually build this?", this is the post for you.</p>
<h2>The Big Picture: How the Pieces Fit Together</h2>
<p>Before diving into code, here's the full architecture. Every component maps to a specific whitepaper section:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/5fd98aa9-cf5a-4c5e-9bed-e58fd6e5fefd.png" alt="Full node architecture: Wallet, Mempool, Miner, Blockchain, UTXO Set, and P2P Network — each mapping to a whitepaper section" style="display:block;margin:0 auto" />

<p>The mapping from whitepaper concepts to Rust types:</p>
<table>
<thead>
<tr>
<th>Whitepaper Concept</th>
<th>Rust Type</th>
<th>Whitepaper Section</th>
</tr>
</thead>
<tbody><tr>
<td>"chain of digital signatures"</td>
<td><code>Transaction</code>, <code>TXInput</code>, <code>TXOutput</code></td>
<td>§2 Transactions</td>
</tr>
<tr>
<td>"timestamp server" / "chain of blocks"</td>
<td><code>Block</code>, <code>BlockHeader</code></td>
<td>§3 Timestamp Server</td>
</tr>
<tr>
<td>"proof-of-work"</td>
<td><code>ProofOfWork</code></td>
<td>§4 Proof-of-Work</td>
</tr>
<tr>
<td>"not already spent"</td>
<td><code>UTXOSet</code> (sled database)</td>
<td>§5 Network</td>
</tr>
<tr>
<td>"coinbase transaction"</td>
<td><code>Transaction::new_coinbase_tx()</code></td>
<td>§6 Incentive</td>
</tr>
<tr>
<td>"Merkle tree"</td>
<td><code>hash_transactions()</code></td>
<td>§7 Reclaiming Disk Space</td>
</tr>
</tbody></table>
<p>The key insight: a blockchain is not a database of accounts and balances. It's a <strong>log of state transitions</strong> — each transaction <em>consumes</em> previously created outputs and <em>creates</em> new ones. Everything else exists to make that rule globally auditable.</p>
<p>Let's build each piece.</p>
<h2>1. Transactions: The Chain of Digital Signatures</h2>
<h3>The Concept</h3>
<p>The whitepaper defines an electronic coin as "a chain of digital signatures." Each owner transfers value by signing a hash of the previous transaction and the public key of the next owner. But what does "transfer value" actually mean in code?</p>
<p>Think of it like cash, not like a bank account. You don't have a "balance" — you have a collection of <strong>unspent outputs</strong> (like bills in your wallet). To pay someone, you hand over specific bills and get change back. A transaction is that exchange: it <em>destroys</em> the bills you're spending and <em>creates</em> new ones.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/b43567eb-6040-47ca-bf37-e192ec9397e4.png" alt="UTXO transaction flow: Alice's two unspent outputs are consumed as inputs, creating a 9-coin output for Bob and 3-coin change back to Alice" style="display:block;margin:0 auto" />

<h3>The Rust Types</h3>
<p>This model translates into three types working together:</p>
<pre><code class="language-rust">/// A spendable output: value locked to a public key hash.
/// Think of it as a "bill" in someone's wallet.
#[derive(Clone, Serialize, Deserialize)]
pub struct TXOutput {
    value: i32,
    pub_key_hash: Vec&lt;u8&gt;,   // the "lock" — who can spend this?
}

/// A claim on a previous output: "I'm spending this specific bill."
/// The signature proves you own the private key that matches the lock.
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct TXInput {
    txid: Vec&lt;u8&gt;,            // which transaction created the output
    vout: usize,              // which output index in that transaction
    signature: Vec&lt;u8&gt;,       // proof of authorization (Schnorr signature)
    pub_key: Vec&lt;u8&gt;,         // the spender's public key
}

/// The atomic ledger transition: spend old outputs, create new ones.
/// A transaction is only valid if ALL inputs are unspent and ALL
/// signatures check out.
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct Transaction {
    id: Vec&lt;u8&gt;,              // SHA-256 hash of the transaction data
    vin: Vec&lt;TXInput&gt;,        // inputs: which existing outputs we consume
    vout: Vec&lt;TXOutput&gt;,      // outputs: new spendable value we create
}
</code></pre>
<p>The relationship between these types:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/4b41f2bb-7e13-4657-a10a-8ffe97446324.png" alt="TXInput refers to a TXOutput from a prior transaction — the validator checks that the output exists in the UTXO set, the public key matches the lock, and the signature is valid" style="display:block;margin:0 auto" />

<h3>Creating a Transaction</h3>
<p>Here's what it looks like in practice — Alice sending coins to Bob:</p>
<pre><code class="language-rust">pub async fn new_utxo_transaction(
    from: &amp;WalletAddress,
    to: &amp;WalletAddress,
    amount: i32,
    utxo_set: &amp;UTXOSet,
) -&gt; Result&lt;Transaction&gt; {
    let wallet = WalletService::new()?.get_wallet(from)?;
    let pub_key_hash = hash_pub_key(wallet.get_public_key());

    // Step 1: Find enough unspent outputs to cover the amount.
    // Like rifling through your wallet for enough bills.
    let (available, valid_outputs) = utxo_set
        .find_spendable_outputs(&amp;pub_key_hash, amount)
        .await?;

    if available &lt; amount {
        return Err(BtcError::NotEnoughFunds);
    }

    // Step 2: Build inputs — one per unspent output we're consuming.
    // Each input says "I'm spending output #vout from transaction txid."
    let mut inputs = vec![];
    for (txid_hex, out_indexes) in valid_outputs {
        let txid = HEXLOWER.decode(txid_hex.as_bytes())?;
        for index in out_indexes {
            inputs.push(TXInput {
                txid: txid.clone(),
                vout: index,
                signature: vec![],
                pub_key: wallet.get_public_key().to_vec(),
            });
        }
    }

    // Step 3: Build outputs — payment to recipient + change back to sender.
    // If we're spending 12 coins but only need to send 9, we create a
    // 3-coin "change" output back to ourselves.
    let mut outputs = vec![TXOutput::new(amount, to)?];
    if available &gt; amount {
        outputs.push(TXOutput::new(available - amount, from)?);
    }

    // Step 4: Hash the transaction to produce its ID, then sign each input
    // with our private key to prove we're authorized to spend these outputs.
    let mut tx = Transaction { id: vec![], vin: inputs, vout: outputs };
    tx.id = tx.hash()?;
    tx.sign(utxo_set.get_blockchain(), wallet.get_pkcs8()).await?;
    Ok(tx)
}
</code></pre>
<p>Every spend references a specific prior output, proves authorization with a cryptographic signature, and creates new outputs that can be spent in the future. This is the whitepaper's "chain of digital signatures" made concrete.</p>
<h2>2. Blocks: The Timestamp Server</h2>
<h3>The Concept</h3>
<p>Transactions on their own aren't enough — we need a way to order them and agree on which transactions happened first (especially when someone tries to spend the same coins twice). The whitepaper solves this with a <strong>timestamp server</strong> that batches transactions into blocks and chains them together.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/d5d9745f-fd75-4936-827f-fc56cf56649c.png" alt="Block chaining: three blocks linked by prev_hash — modifying Block 1 cascades through the entire chain, invalidating every subsequent block" style="display:block;margin:0 auto" />

<h3>The Rust Types</h3>
<pre><code class="language-rust">#[derive(Clone, Serialize, Deserialize)]
pub struct BlockHeader {
    timestamp: i64,
    pre_block_hash: String,   // hash of the previous block (THE chain link)
    hash: String,             // this block's hash (computed via proof-of-work)
    nonce: i64,               // the value that makes the hash valid
    height: usize,            // position in the chain (block number)
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Block {
    header: BlockHeader,
    transactions: Vec&lt;Transaction&gt;,
}
</code></pre>
<p>The <code>pre_block_hash</code> field is what makes this a <em>chain</em>. It's just one <code>String</code> — but that single field is what makes the entire history tamper-evident. Every block commits to the one before it, and through that, transitively commits to every block all the way back to genesis.</p>
<h3>Building a Block</h3>
<p>Block creation ties the header, transactions, and proof-of-work together:</p>
<pre><code class="language-rust">impl Block {
    pub fn new_block(
        pre_block_hash: String,
        transactions: &amp;[Transaction],
        height: usize,
    ) -&gt; Block {
        let header = BlockHeader {
            timestamp: crate::current_timestamp(),
            pre_block_hash,
            hash: String::new(),      // empty — PoW will fill this in
            nonce: 0,                 // zero — PoW will find the right value
            height,
        };
        let mut block = Block {
            header,
            transactions: transactions.to_vec(),
        };

        // Mine the block — find a nonce that produces a valid hash.
        // This is the expensive part. Everything above is instant.
        let pow = ProofOfWork::new_proof_of_work(block.clone());
        let (nonce, hash) = pow.run();
        block.header.nonce = nonce;
        block.header.hash = hash;
        block
    }
}
</code></pre>
<p>Notice that <code>hash</code> starts empty and <code>nonce</code> starts at zero. The proof-of-work algorithm tries millions of nonce values until it finds one that produces a hash meeting the difficulty requirement. Until mining completes, the block isn't valid.</p>
<h2>3. Proof-of-Work: Making History Immutable</h2>
<h3>The Concept</h3>
<p>Here's the problem: if anyone can create blocks cheaply, an attacker could rewrite history by generating an alternative chain. Proof-of-work solves this by making block creation <em>expensive</em> — you have to burn CPU cycles finding a hash below a target threshold.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/36b50b8f-b63d-46a6-a109-57c3c7ce34b3.png" alt="Proof-of-work: the mining target threshold, brute-force nonce search, instant verification, and difficulty comparison from TARGET_BITS=8 to Bitcoin's 2^75" style="display:block;margin:0 auto" />

<h3>The Rust Implementation</h3>
<pre><code class="language-rust">pub struct ProofOfWork {
    block: Block,
    target: BigInt,       // the threshold — hash must be below this
}

const TARGET_BITS: i32 = 8;      // difficulty: 8 leading zero bits
const MAX_NONCE: i64 = i64::MAX;

impl ProofOfWork {
    pub fn new_proof_of_work(block: Block) -&gt; ProofOfWork {
        // BigInt from the `num` crate — Rust's native integers can't hold
        // 256-bit numbers, so we use arbitrary-precision arithmetic.
        let mut target = BigInt::from(1);
        // Shift left (&lt;&lt;= 248) to create the target threshold.
        // 256 - 8 = 248, so target = 2^248.
        // Any hash &lt; 2^248 starts with at least 8 zero bits.
        target.shl_assign(256 - TARGET_BITS);
        ProofOfWork { block, target }
    }

    /// Concatenate all block data + nonce into the bytes we hash.
    fn prepare_data(&amp;self, nonce: i64) -&gt; Vec&lt;u8&gt; {
        let pre_block_hash = self.block.get_pre_block_hash();
        let transactions_hash = self.block.hash_transactions();
        let timestamp = self.block.get_timestamp();

        let mut data_bytes = vec![];
        data_bytes.extend(pre_block_hash.as_bytes());  // chain link
        data_bytes.extend(transactions_hash);           // tx commitment
        data_bytes.extend(timestamp.to_be_bytes());     // when
        data_bytes.extend(TARGET_BITS.to_be_bytes());   // difficulty
        data_bytes.extend(nonce.to_be_bytes());          // the search variable
        data_bytes
    }

    /// The mining loop: try nonces until we find a valid hash.
    pub fn run(&amp;self) -&gt; (i64, String) {
        let mut nonce = 0;
        let mut hash = Vec::new();

        while nonce &lt; MAX_NONCE {
            let data = self.prepare_data(nonce);
            hash = sha256_digest(data.as_slice());
            // Interpret the 32-byte hash as a positive 256-bit integer
            // so we can compare it numerically against the target.
            let hash_int = BigInt::from_bytes_be(Sign::Plus, hash.as_slice());

            if hash_int.lt(&amp;self.target) {
                break;  // Found it! This hash is below the target.
            } else {
                nonce += 1;  // Nope. Try the next one.
            }
        }

        (nonce, HEXLOWER.encode(hash.as_slice()))
    }
}
</code></pre>
<p>The algorithm is a brute-force search. <code>prepare_data()</code> packs the block's identity — previous hash, transaction commitment, timestamp, difficulty, and the current nonce guess — into a byte vector. <code>run()</code> hashes that data, checks whether the result falls below the target, and increments the nonce if it doesn't.</p>
<p>Why this matters: to rewrite a historical block, an attacker would need to redo the proof-of-work for that block <em>and every block after it</em>, then outpace the honest network. Each additional block makes the attack exponentially harder.</p>
<p><strong>Verification is the elegant part.</strong> Mining takes millions of attempts, but any node can verify the result with a single hash:</p>
<pre><code class="language-text">Mining:      try nonce 0, 1, 2, ... 891 → hash &lt; target ✓  (expensive)
Verification: hash(block_data + 891)     → hash &lt; target ✓  (one operation)
</code></pre>
<h2>4. The UTXO Set: Preventing Double-Spending</h2>
<h3>The Concept</h3>
<p>The whitepaper states that "the only way to confirm the absence of a transaction is to be aware of all transactions." In practice, we don't need the full transaction history — we just need to know <strong>which outputs haven't been spent yet</strong>. That's the UTXO set (Unspent Transaction Output set).</p>
<p>Think of it as a ledger of "bills that still exist." After processing a few blocks, the UTXO set might contain four entries: Alice owns a 5-coin output and a 7-coin output, Bob owns a 3-coin output, and a miner owns a 10-coin output. Alice's "balance" is just the sum of her entries (12 coins) — there's no balance field anywhere.</p>
<p>When Alice spends her 5-coin output, that entry is removed from the set and the new outputs her transaction creates are added. If anyone tries to reference that 5-coin output again — whether Alice herself or an attacker replaying the transaction — the lookup fails because it's gone from the set. One set membership check is the entire double-spending prevention mechanism.</p>
<h3>The Rust Implementation</h3>
<pre><code class="language-rust">pub struct UTXOSet {
    blockchain: BlockchainService,
}

impl UTXOSet {
    /// Find outputs owned by this public key hash that we can spend,
    /// accumulating until we have enough to cover the requested amount.
    pub async fn find_spendable_outputs(
        &amp;self,
        pub_key_hash: &amp;[u8],
        amount: i32,
    ) -&gt; Result&lt;(i32, HashMap&lt;String, Vec&lt;usize&gt;&gt;)&gt; {
        let mut unspent_outputs: HashMap&lt;String, Vec&lt;usize&gt;&gt; = HashMap::new();
        let mut accumulated = 0;

        // Open the "chainstate" tree in our sled database.
        // This is where we persist the UTXO set between restarts.
        let db = self.blockchain.get_db().await?;
        let utxo_tree = db.open_tree("chainstate")?;

        for item in utxo_tree.iter() {
            let (k, v) = item?;
            let txid_hex = HEXLOWER.encode(k.to_vec().as_slice());
            let (outputs, _): (Vec&lt;TXOutput&gt;, usize) =
                bincode::serde::decode_from_slice(&amp;v, bincode::config::standard())?;

            for (index, out) in outputs.iter().enumerate() {
                // Three checks: do we own it, is it worth something,
                // and do we still need more?
                if out.is_locked_with_key(pub_key_hash)
                    &amp;&amp; out.get_value() &gt; 0
                    &amp;&amp; accumulated &lt; amount
                {
                    accumulated += out.get_value();
                    unspent_outputs
                        .entry(txid_hex.clone())
                        .or_default()
                        .push(index);
                }
            }
        }

        Ok((accumulated, unspent_outputs))
    }
}
</code></pre>
<p>The UTXO set is stored in a <a href="https://docs.rs/sled">sled</a> embedded database under the <code>"chainstate"</code> tree. When a block is accepted, we remove spent outputs and add newly created ones. When a transaction input references an output that isn't in the set, we reject it — that output was either already spent or never existed.</p>
<h2>5. Merkle Trees: Committing to Transactions</h2>
<h3>The Concept</h3>
<p>A block header needs to commit to its transactions without storing all of them. The whitepaper solves this with a Merkle tree — a binary tree of hashes where only the root is stored in the header. Change any transaction and the root changes, which changes the block hash, which invalidates the proof-of-work.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/8c1836fc-1093-401d-bbd7-4ffafe65a51a.png" alt="Merkle tree: transaction hashes pair up recursively to a single root stored in the block header — tampering with any transaction cascades up and invalidates the proof-of-work" style="display:block;margin:0 auto" />

<h3>The Rust Implementation</h3>
<p>Our implementation takes a simplified approach that preserves the essential commitment property:</p>
<pre><code class="language-rust">impl Block {
    /// Compute a single hash that commits to all transactions in this block.
    /// If any transaction changes, this hash changes, which changes the
    /// block header hash, which invalidates the proof-of-work.
    pub fn hash_transactions(&amp;self) -&gt; Vec&lt;u8&gt; {
        let mut tx_hashes = vec![];
        for transaction in &amp;self.transactions {
            tx_hashes.extend(transaction.get_id());
        }
        sha256_digest(tx_hashes.as_slice())
    }
}
</code></pre>
<p>We concatenate all transaction IDs and hash the result. A full binary Merkle tree (as shown in the diagram above) would hash pairs recursively, enabling compact inclusion proofs for lightweight (SPV) clients. Our simplified version doesn't support SPV proofs, but it preserves the critical property: the block header hash depends on every transaction in the block. Tamper with any transaction and the chain breaks.</p>
<h2>6. The Genesis Block: Where It All Starts</h2>
<p>Every blockchain needs a starting point — a block with no predecessor. This is the genesis block, and it's hardcoded into the software:</p>
<pre><code class="language-rust">pub const GENESIS_BLOCK_PRE_BLOCK_HASH: &amp;str = "None";

pub fn generate_genesis_block(transaction: &amp;Transaction) -&gt; Block {
    Block::new_block(
        GENESIS_BLOCK_PRE_BLOCK_HASH.to_string(),
        &amp;[transaction.clone()],
        1,  // height 1 — the first block
    )
}
</code></pre>
<p>The genesis block's <code>pre_block_hash</code> is <code>"None"</code> — there's nothing before it. It contains a single coinbase transaction that creates the first coins in the system. From this point forward, every block links to the one before it, and every coin in circulation traces back through the transaction graph to a coinbase reward in some block's first transaction.</p>
<h2>7. The Coinbase Transaction: Minting New Coins</h2>
<p>The whitepaper describes the incentive mechanism: "the first transaction in a block is a special transaction that starts a new coin owned by the creator of the block." This is the coinbase transaction — the only way new coins enter circulation.</p>
<pre><code class="language-rust">const SUBSIDY: i32 = 10;  // mining reward: 10 coins per block

impl Transaction {
    pub fn new_coinbase_tx(to: &amp;WalletAddress) -&gt; Result&lt;Transaction&gt; {
        // The output: pay SUBSIDY coins to the miner's address
        let txout = TXOutput::new(SUBSIDY, to)?;

        // Coinbase input is special — it creates coins from nothing.
        // No previous transaction to reference. The signature field
        // carries a unique ID instead (Bitcoin uses arbitrary data here;
        // we use a UUID).
        let tx_input = TXInput {
            signature: Uuid::new_v4().as_bytes().to_vec(),
            ..Default::default()  // txid and vout are zeroed
        };

        let mut tx = Transaction {
            id: vec![],
            vin: vec![tx_input],
            vout: vec![txout],
        };
        tx.id = tx.hash()?;
        Ok(tx)
    }
}
</code></pre>
<p>The coinbase transaction is unique in two ways: it has no real inputs (it creates value rather than transferring it), and it's always the first transaction in a block. Every miner includes one in the block they're trying to mine, paying the reward to their own address. This is how the whitepaper aligns miner incentives with network security — you get paid for doing the work that secures the chain.</p>
<h2>8. Putting It All Together: The Node Pipeline</h2>
<p>With all the pieces in place, here's the end-to-end data flow through a running node:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/bd157ffb-a22a-4ea2-b305-49bef713eb8d.png" alt="Node pipeline: three phases — Receive Transaction (decode, verify, mempool), Mine Block (collect, coinbase, PoW), Accept Block (validate, update UTXO set, extend chain) — mapping to Whitepaper Section 5" style="display:block;margin:0 auto" />

<p>This is exactly the six-step protocol the whitepaper describes in Section 5. Notice how every piece we built maps to a specific step: incoming transactions are verified against the <code>UTXOSet</code> (§4) and held in the mempool. When the miner triggers, it collects those transactions, prepends a coinbase (§7), constructs a <code>Block</code> (§2), and runs <code>ProofOfWork</code> (§3). When a valid block arrives — either from our own miner or from a peer over the network — the node verifies the <code>pre_block_hash</code> chain link, re-checks the proof-of-work, validates every transaction signature, and atomically updates the UTXO set before extending the chain.</p>
<p>The order matters: the UTXO set update is the last step because it's the point of no return. Once spent outputs are removed, they can never be spent again. Everything before that step is validation — everything after it is commitment.</p>
<h2>What We Simplified (and Why)</h2>
<p>This is a teaching implementation, not a production Bitcoin node. We made deliberate simplifications to keep the code focused on whitepaper concepts:</p>
<table>
<thead>
<tr>
<th>Simplification</th>
<th>Real Bitcoin</th>
<th>Our Implementation</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Hash function</td>
<td>SHA-256d (double hash)</td>
<td>SHA-256 (single)</td>
<td>Same concept, less confusion</td>
</tr>
<tr>
<td>Merkle tree</td>
<td>Full binary tree</td>
<td>Concatenate + hash</td>
<td>Preserves commitment; skips SPV proofs</td>
</tr>
<tr>
<td>Difficulty</td>
<td>Dynamic (adjusts every 2,016 blocks)</td>
<td>Fixed (<code>TARGET_BITS = 8</code>)</td>
<td>Keeps mining fast for testing</td>
</tr>
<tr>
<td>Signatures</td>
<td>ECDSA (legacy) / Schnorr (Taproot)</td>
<td>Schnorr</td>
<td>More modern than Bitcoin's original</td>
</tr>
<tr>
<td>Storage</td>
<td>LevelDB</td>
<td>sled (embedded Rust DB)</td>
<td>Pure Rust, no C dependencies</td>
</tr>
<tr>
<td>Script system</td>
<td>Full Bitcoin Script VM</td>
<td>Public key hash lock</td>
<td>Same ownership model, simpler code</td>
</tr>
</tbody></table>
<p>None of these change the architecture. The whitepaper's core design — UTXO model, proof-of-work consensus, hash-linked blocks, digital signatures for authorization — is implemented faithfully. If you understand this code, you understand how Bitcoin works.</p>
<h2>Key Takeaways</h2>
<ol>
<li><p><strong>A blockchain is a log of state transitions, not a database of balances.</strong> Transactions consume outputs and create new ones. The UTXO set is derived state — like an index built from a log.</p>
</li>
<li><p><strong>The whitepaper is surprisingly implementable.</strong> The core types fit in about 100 lines of Rust structs. The mining algorithm is under 50 lines. The hard part isn't the crypto — it's getting the state management right.</p>
</li>
<li><p><strong>Proof-of-work is elegant.</strong> Expensive to produce, trivial to verify, and it makes history rewriting computationally impractical. One <code>if hash_int &lt; target</code> check validates what may have taken millions of attempts to produce.</p>
</li>
<li><p><strong>Rust is a natural fit for blockchain.</strong> Ownership semantics map cleanly to UTXO ownership. The type system catches whole categories of bugs at compile time. <code>serde</code> + <code>bincode</code> make serialization painless, and sled gives us an embedded database without leaving the Rust ecosystem.</p>
</li>
<li><p><strong>The "chain" in blockchain is just one field.</strong> <code>pre_block_hash</code> in the block header links everything together. That single 32-byte hash is what makes the entire history tamper-evident.</p>
</li>
</ol>
<h2>Explore the Source Code</h2>
<p>The complete implementation is open source:</p>
<p><strong>Full repository:</strong> <a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a></p>
<p><strong>Key files referenced in this post:</strong></p>
<ul>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/primitives/block.rs">block.rs</a> — Block and BlockHeader structs, genesis block creation</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/primitives/transaction.rs">transaction.rs</a> — Transaction, TXInput, TXOutput, coinbase creation</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/pow.rs">pow.rs</a> — Proof-of-work implementation</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/chain/utxo_set.rs">utxo_set.rs</a> — UTXO set management and spendable output queries</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/crypto/hash.rs">hash.rs</a> — SHA-256 hashing utilities</p>
</li>
</ul>
<hr />
<p><em>This post is adapted from</em> <a href="https://buildwithrust.com"><em>Rust Blockchain: A Full-Stack Implementation Guide</em></a> <em>— a 33-chapter book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.</em></p>
<p><em>Available now on</em> <a href="https://www.amazon.com/dp/B0GXRYL21V"><em>Amazon</em></a> <em>(paperback + Kindle + hardcover),</em> <a href="https://buildwithrust.gumroad.com/l/rust-blockchain"><em>Gumroad</em></a> <em>(PDF + EPUB), and</em> <a href="https://leanpub.com/rustblockchainafull-stackimplementationguide"><em>Leanpub</em></a> <em>(pay what you want).</em></p>
<p><em>Free resource:</em> <a href="https://github.com/bkunyiha/rust-blockchain"><em>Clone the Starter Template</em></a></p>
<p><em>Follow</em> <a href="https://buildwithrust.com"><em>buildwithrust.com</em></a> <em>for weekly posts on building production systems in Rust.</em></p>
]]></content:encoded></item><item><title><![CDATA[Async Rust with Tokio: Building P2P Blockchain Networking]]></title><description><![CDATA[Async Rust with Tokio: Building P2P Blockchain Networking
We built a peer-to-peer networking layer for a Bitcoin-style blockchain node using Tokio, Rust's async runtime. The node accepts inbound TCP c]]></description><link>https://buildwithrust.com/async-rust-with-tokio-building-p2p-blockchain-networking</link><guid isPermaLink="true">https://buildwithrust.com/async-rust-with-tokio-building-p2p-blockchain-networking</guid><category><![CDATA[tokio rust]]></category><category><![CDATA[rust concurrency]]></category><category><![CDATA[How to build a blockchain with Rust]]></category><category><![CDATA[Async Rust]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Mon, 13 Apr 2026 07:07:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/393dadbe-aa42-441d-89cb-36d6bbaff23e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Async Rust with Tokio: Building P2P Blockchain Networking</h1>
<p>We built a peer-to-peer networking layer for a Bitcoin-style blockchain node using <a href="https://tokio.rs">Tokio</a>, Rust's async runtime. The node accepts inbound TCP connections from peers, dispatches different message types to different subsystems, relays transactions and blocks across the network, runs proof-of-work mining that can be cancelled mid-computation, and shuts down gracefully on Ctrl+C — all concurrently, without any of these activities blocking the others.</p>
<p>This post walks through the concurrency patterns we used, with real code from the project. These patterns — <code>select!</code> for event multiplexing, <code>spawn</code> for per-connection isolation, atomic flags for cross-task coordination, and <code>spawn_blocking</code> for bridging sync and async worlds — transfer directly to any async Rust system that coordinates multiple independent subsystems.</p>
<h2>What a Blockchain Node Must Do Concurrently</h2>
<p>A blockchain node isn't a request-response server. It's a peer in a network where every participant is both client and server simultaneously. At any given moment, our node might be:</p>
<ul>
<li><p><strong>Accepting inbound TCP connections</strong> from peers who want to announce new blocks or transactions</p>
</li>
<li><p><strong>Dispatching messages</strong> — routing each inbound packet to the right handler (chainstate, mempool, peer discovery)</p>
</li>
<li><p><strong>Relaying inventory</strong> — broadcasting transaction and block hashes to every connected peer</p>
</li>
<li><p><strong>Mining a block</strong> — running proof-of-work computation that could take seconds or minutes</p>
</li>
<li><p><strong>Cancelling mining</strong> — if a competing block arrives from a peer while we're still computing, we need to stop immediately</p>
</li>
<li><p><strong>Responding to API queries</strong> — the web UI and wallet apps ask for blockchain height, balances, and transaction history</p>
</li>
<li><p><strong>Shutting down cleanly</strong> — releasing the TCP port, flushing state, without corrupting the chain database</p>
</li>
</ul>
<p>Each of these is a concurrent activity that can't block the others. Tokio gives us the primitives to express this, but the composition is where the real engineering happens.</p>
<h2>The Mental Model: Async, Parallel, or Both?</h2>
<p>Before diving into code, it's worth getting precise about terminology that even experienced developers conflate. These distinctions directly affect which Tokio primitives you reach for at each point in the blockchain node.</p>
<h3>Concurrency vs Parallelism</h3>
<p><strong>Concurrency</strong> means multiple tasks make progress over the same time period. They might interleave on a single core (one runs while another waits for I/O), or they might truly execute simultaneously on separate cores.</p>
<p><strong>Parallelism</strong> is a subset of concurrency where multiple tasks execute at the <em>same instant</em> on different CPU cores.</p>
<p>A blockchain node needs both. Network I/O (waiting for a peer to send a message) is concurrent but doesn't need parallelism — the CPU is idle during the wait, so interleaving is efficient. Mining (computing SHA-256 hashes in a tight loop) is CPU-bound and benefits from parallelism — it actually uses a core continuously.</p>
<p>Async Rust with Tokio gives you concurrency by default. Parallelism comes from Tokio's multi-threaded runtime, which schedules tasks across multiple OS threads. But here's the subtlety: a single async task is never parallel with itself. It runs on one thread at a time and can only be "elsewhere" when it's suspended at an <code>.await</code> point. True parallelism only happens between <em>different</em> tasks.</p>
<h3>OS Threads vs Tokio Tasks</h3>
<p>An <strong>OS thread</strong> has its own stack (typically 2–8 MB), is scheduled by the kernel, and context-switching between threads costs microseconds. Spawning 10,000 threads is impractical — the memory overhead alone would be 20–80 GB.</p>
<p>A <strong>Tokio task</strong> is a lightweight state machine (~few hundred bytes) scheduled cooperatively by the Tokio runtime. You can spawn hundreds of thousands without breaking a sweat. The trade-off: tasks must <em>cooperate</em> by yielding at <code>.await</code> points. An OS thread can be preempted mid-computation by the kernel; a Tokio task cannot.</p>
<p>In our blockchain node, each peer connection gets its own Tokio task (not its own OS thread). With 50 peers, that's 50 tasks multiplexed across 4–8 OS threads — efficient, lightweight, and the reason Tokio can handle thousands of connections where a thread-per-connection model would fall over.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/1449ac40-8ebc-4242-b64b-ecb4352edfd7.png" alt="OS Threads vs Tokio Tasks" style="display:block;margin:0 auto" />

<p>The async model interleaves work from many peers onto a few threads. When Peer A's <code>read</code> suspends at <code>.await</code> (waiting for bytes), the worker thread immediately picks up work from another task. No CPU time is wasted waiting.</p>
<h3>CPU-Bound vs IO-Bound Work</h3>
<p>This distinction determines <em>where</em> you run each piece of work:</p>
<p><strong>IO-bound</strong> work spends most of its time waiting — for network data, disk reads, database queries. This is where <code>async</code>/<code>.await</code> shines. While one task waits, the runtime schedules another. Our peer message handling, transaction relay, and block propagation are all IO-bound.</p>
<p><strong>CPU-bound</strong> work keeps the processor busy continuously — hashing, encryption, proof-of-work computation. Async provides no benefit here because there's no waiting to interleave. Worse, a CPU-bound loop <em>without</em> <code>.await</code> points starves other tasks on the same worker thread.</p>
<pre><code class="language-rust">// BAD: CPU-bound work directly on the async runtime.
// This loop never yields — no .await points — so the worker
// thread running this task can't service ANY other tasks
// (timers, accepts, channel reads) until mining finishes.
async fn mine_block_bad(header: &amp;[u8], difficulty: u32) -&gt; (u64, Vec&lt;u8&gt;) {
    let mut nonce = 0u64;
    loop {
        let hash = sha256(header, nonce);
        if leading_zeros(&amp;hash) &gt;= difficulty {
            return (nonce, hash);
        }
        nonce += 1;
        // No .await — this worker thread is monopolized
    }
}

// GOOD: Yield periodically so other tasks can run,
// OR move to spawn_blocking / a dedicated thread.
async fn mine_block_good(header: &amp;[u8], difficulty: u32) -&gt; (u64, Vec&lt;u8&gt;) {
    let mut nonce = 0u64;
    loop {
        // Check 10,000 nonces, then yield
        for _ in 0..10_000 {
            let hash = sha256(header, nonce);
            if leading_zeros(&amp;hash) &gt;= difficulty {
                return (nonce, hash);
            }
            nonce += 1;
        }
        // Yield to the runtime — other tasks get a chance to run
        tokio::task::yield_now().await;
    }
}
</code></pre>
<p>In our blockchain implementation, mining runs in an async context but internally yields periodically and checks the cancellation flag. The alternative (and arguably cleaner approach for pure CPU work) is <code>tokio::task::spawn_blocking()</code>, which we discuss later.</p>
<h3>Cooperative Scheduling and the <code>.await</code> Contract</h3>
<p>Tokio uses <strong>cooperative scheduling</strong>: tasks voluntarily yield control at <code>.await</code> points. Unlike OS threads, which the kernel can preempt at any instruction, Tokio tasks run uninterrupted between <code>.await</code>s.</p>
<p>This has a crucial implication: <strong>the code between two</strong> <code>.await</code> <strong>points runs atomically from the runtime's perspective.</strong> If you hold a <code>std::sync::MutexGuard</code> across an <code>.await</code>, you're holding a lock while the task might be suspended for an unpredictable duration — and because <code>MutexGuard</code> is <code>!Send</code>, the compiler will actually prevent this in many cases. Tokio provides <code>tokio::sync::Mutex</code>, which is <code>.await</code>-aware and safe to hold across yield points, but it has its own costs (it's an async lock, so acquiring it may itself yield).</p>
<p>In our blockchain node, we're careful about this. The peer set (<code>GLOBAL_NODES</code>) uses <code>std::sync::RwLock</code>, not <code>tokio::sync::RwLock</code>, because we never hold the lock across an <code>.await</code> — every access is a quick read-or-write followed by an immediate drop. This is faster than the async alternative for short critical sections.</p>
<h3><code>Send + 'static</code>: Why <code>tokio::spawn</code> Has Bounds</h3>
<p>Every <code>tokio::spawn</code> call requires the future to be <code>Send + 'static</code>. These bounds exist because of how the runtime works:</p>
<p><code>Send</code> — the task might be started on one worker thread, suspended at an <code>.await</code>, and resumed on a <em>different</em> worker thread. Any data the task holds must be safe to move between threads. This is why you can't spawn a task that captures a <code>Rc&lt;T&gt;</code> (which is <code>!Send</code>) — you need <code>Arc&lt;T&gt;</code> instead.</p>
<p><code>'static</code> — the spawned task has no guaranteed relationship with the spawner's lifetime. The spawner might complete while the task is still running (that's exactly what fire-and-forget means). So the task can't borrow from the spawner's stack — it must own all its data.</p>
<p>In practice, this means you'll clone data before spawning. When our node relays a transaction to peers (Pattern 3 below), we clone the <code>NodeContext</code>, copy the <code>SocketAddr</code>, and clone the transaction — because the spawned task must own everything it touches:</p>
<pre><code class="language-rust">let context = self.clone();     // NodeContext is cheap to clone (Arc inside)
let addr_copy = *addr_from;     // Copy the SocketAddr (it's Copy)
let tx = utxo.clone();          // Clone the transaction data
tokio::spawn(async move {       // 'move' transfers ownership into the task
    let _ = context.submit_transaction_for_mining(&amp;addr_copy, tx).await;
});
</code></pre>
<p>If you've ever fought the compiler over "borrowed value does not live long enough" in a <code>tokio::spawn</code> call, this is why. The fix is almost always: clone the data or wrap it in <code>Arc</code>. The cost of an <code>Arc</code> clone is a single atomic increment — negligible compared to the network I/O the task will perform.</p>
<h2>The Wire Protocol: Gossip and Fetch</h2>
<p>With the mental model in place, let's look at what messages actually flow through the system. Bitcoin's P2P protocol uses a three-step gossip-and-fetch strategy to minimize bandwidth:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/e4ef1160-9855-4095-80df-369382957589.png" alt="Bitcoin Gossip-and-Fetch Protocol" style="display:block;margin:0 auto" />

<p>Peers announce what they have by sharing only the hash (the gossip phase). If the receiver doesn't already have that hash, it requests the full object (the fetch phase). This avoids sending full blocks to peers that already have them.</p>
<p>In our implementation, every message on the wire is a variant of a single Rust enum:</p>
<pre><code class="language-rust">#[derive(Debug, Serialize, Deserialize)]
pub enum Package {
    // Block and transaction relay
    Block { addr_from: SocketAddr, block: Vec&lt;u8&gt; },
    Tx { addr_from: SocketAddr, transaction: Vec&lt;u8&gt; },

    // Gossip-and-fetch protocol
    Inv { addr_from: SocketAddr, op_type: OpType, items: Vec&lt;Vec&lt;u8&gt;&gt; },
    GetData { addr_from: SocketAddr, op_type: OpType, id: Vec&lt;u8&gt; },
    GetBlocks { addr_from: SocketAddr },

    // Peer discovery and versioning
    Version { addr_from: SocketAddr, version: usize, best_height: usize },
    KnownNodes { addr_from: SocketAddr, nodes: Vec&lt;SocketAddr&gt; },

    // Admin operations (local queries, not part of P2P protocol)
    AdminNodeQuery { addr_from: SocketAddr, query_type: AdminNodeQueryType },
    // ...
}
</code></pre>
<p>This enum is the complete vocabulary of peer-to-peer communication. Every TCP message deserializes into one of these variants, and the dispatcher pattern-matches on it to route to the correct handler. The <code>addr_from</code> field on every variant lets the receiver know who sent the message — critical for relay logic that must avoid sending data back to its source.</p>
<h2>Pattern 1: The Server Loop with <code>tokio::select!</code></h2>
<p>The first concurrency challenge is multiplexing two independent concerns: accepting new peer connections and listening for a shutdown signal. <code>tokio::select!</code> lets us race two futures against each other in a loop:</p>
<pre><code class="language-rust">pub async fn run_with_shutdown(
    &amp;self,
    addr: &amp;SocketAddr,
    mut shutdown: tokio::sync::broadcast::Receiver&lt;()&gt;,
) {
    let listener = TcpListener::bind(addr)
        .await
        .expect("Failed to bind TCP listener");

    // Bootstrap: announce ourselves to the network
    if !addr.eq(&amp;CENTRAL_NODE) {
        let height = self.node_context
            .get_blockchain_height().await
            .expect("Blockchain read error");
        send_version(&amp;CENTRAL_NODE, height).await;
    }

    // Main event loop: accept connections OR shut down,
    // whichever happens first on each iteration.
    loop {
        tokio::select! {
            // Branch 1: shutdown signal arrived.
            // The broadcast channel fires when the operator
            // sends SIGINT or the orchestrator calls shutdown.
            _ = shutdown.recv() =&gt; {
                info!("Shutdown signal received");
                break;
            }

            // Branch 2: a new peer connected.
            // We accept and immediately spawn a dedicated
            // task to handle this peer's message stream.
            accept_result = listener.accept() =&gt; {
                match accept_result {
                    Ok((stream, _peer_addr)) =&gt; {
                        let ctx = self.node_context.clone();
                        tokio::spawn(async move {
                            if let Err(e) =
                                process_stream(ctx, stream).await
                            {
                                error!("Peer handler error: {}", e);
                            }
                        });
                    }
                    Err(e) =&gt; error!("Accept error: {}", e),
                }
            }
        }
    }
}
</code></pre>
<p>The key insight: <code>tokio::select!</code> doesn't poll both branches simultaneously — it awaits whichever completes first, executes that branch, and then loops. If a shutdown signal arrives while we're waiting for a connection, we break immediately. If a connection arrives while no shutdown has been requested, we handle it and loop back. This pattern ensures the node can shut down mid-accept without waiting for a timeout.</p>
<h3>Cancellation Safety: Why <code>select!</code> Requires Care</h3>
<p>There's a subtlety that bites many Tokio users: when one branch of <code>select!</code> completes, the other branches' futures are <strong>dropped</strong>. This is fine for <code>listener.accept()</code> and <code>shutdown.recv()</code> — both are cancellation-safe, meaning dropping them mid-await loses no data. But not all futures are safe to cancel this way.</p>
<p>Consider what would happen if instead of <code>listener.accept()</code>, one of our branches was <code>stream.read_exact(&amp;mut buf)</code> — a partially-completed read. If the other branch wins the race, the read future gets dropped, and however many bytes were already read into <code>buf</code> are silently lost. The next time we call <code>read_exact</code>, we'd start from scratch, missing those bytes. This is the class of bugs that async Rust's cancellation model can produce.</p>
<p>The Tokio documentation marks each method's cancellation safety explicitly. When building your own <code>select!</code> loops, check each branch: <code>accept()</code> is safe, <code>recv()</code> is safe, but <code>read_exact()</code>, <code>write_all()</code>, and many buffered I/O operations are not. If you need a cancel-unsafe operation in a <code>select!</code> branch, wrap it in a <code>tokio::spawn</code> — spawned tasks are not cancelled when their <code>JoinHandle</code> is dropped, only when explicitly aborted.</p>
<h3>Shutdown Signals: <code>broadcast</code> vs <code>CancellationToken</code></h3>
<p>Our implementation uses <code>broadcast::Receiver&lt;()&gt;</code> for shutdown coordination. This works, but the Tokio ecosystem has converged on a cleaner abstraction: <code>CancellationToken</code> from the <code>tokio-util</code> crate. Here's how the same server loop would look:</p>
<pre><code class="language-rust">use tokio_util::sync::CancellationToken;

pub async fn run_with_shutdown(
    &amp;self,
    addr: &amp;SocketAddr,
    shutdown: CancellationToken,
) {
    let listener = TcpListener::bind(addr).await.unwrap();

    loop {
        tokio::select! {
            _ = shutdown.cancelled() =&gt; {
                info!("Shutdown signal received");
                break;
            }
            accept_result = listener.accept() =&gt; {
                // ... same as before
            }
        }
    }
}
</code></pre>
<p><code>CancellationToken</code> has two advantages over broadcast channels. First, it's cheaply cloneable — you can hand a clone to every subsystem without worrying about channel capacity or lagged receivers. Second, it supports hierarchical cancellation: you can create child tokens that cancel when the parent cancels, but cancelling a child doesn't cancel the parent. This maps naturally to subsystem lifecycle — shutting down the mining loop shouldn't shut down the API server, but shutting down the entire node should shut down everything.</p>
<p>In production Tokio services, <code>CancellationToken</code> combined with <code>tokio::signal::ctrl_c()</code> is the standard pattern for graceful shutdown. Our broadcast channel approach is functionally equivalent for a single-level shutdown, but <code>CancellationToken</code> scales better as systems grow more complex.</p>
<h2>Pattern 2: One Task Per Connection</h2>
<p>Every accepted connection gets its own <code>tokio::spawn</code>ed task. This is the standard concurrent server pattern, but what happens inside that task is where things get interesting.</p>
<p>The message dispatcher reads a continuous stream of JSON messages from the TCP connection, deserializes each into a <code>Package</code>, and routes it:</p>
<pre><code class="language-rust">pub async fn process_stream(
    node_context: NodeContext,
    stream: TcpStream,
) -&gt; Result&lt;()&gt; {
    let reader = BufReader::new(stream);
    let mut deserializer =
        serde_json::Deserializer::from_reader(reader);

    loop {
        match serde_json::Value::deserialize(&amp;mut deserializer) {
            Ok(value) =&gt; {
                match serde_json::from_value::&lt;Package&gt;(value) {
                    Ok(pkg) =&gt; {
                        // Route by variant — each arm calls into
                        // a different subsystem via NodeContext
                        if let Err(e) = match pkg {
                            Package::Tx { addr_from, transaction } =&gt; {
                                let tx = Transaction::deserialize(&amp;transaction)?;
                                node_context
                                    .process_transaction(&amp;addr_from, tx)
                                    .await
                                    .map(|_| ())
                            }
                            Package::Block { addr_from, block } =&gt; {
                                let blk = Block::deserialize(&amp;block)?;
                                // Cancel any in-progress mining —
                                // a competing block just arrived
                                cancel_current_mining();
                                node_context.add_block(&amp;blk).await
                            }
                            Package::Inv { addr_from, op_type, items } =&gt; {
                                handle_inv(
                                    &amp;node_context, &amp;addr_from,
                                    op_type, items
                                ).await
                            }
                            Package::GetData { addr_from, op_type, id } =&gt; {
                                handle_get_data(
                                    &amp;node_context, &amp;addr_from,
                                    op_type, id
                                ).await
                            }
                            Package::Version { addr_from, .. } =&gt; {
                                GLOBAL_NODES.add_node(addr_from).map(|_| ())
                            }
                            _ =&gt; Ok(()),
                        } {
                            error!("Handler error: {}", e);
                        }
                    }
                    Err(e) =&gt; {
                        error!("Deserialization error: {}", e);
                        break;
                    }
                }
            }
            // Peer disconnected cleanly
            Err(e) if e.is_eof() =&gt; break,
            // Malformed data — close the connection
            Err(e) =&gt; {
                error!("Stream read error: {}", e);
                break;
            }
        }
    }
    Ok(())
}
</code></pre>
<p>Notice the <code>cancel_current_mining()</code> call inside the <code>Package::Block</code> handler. When a peer sends us a new block, our own in-progress mining is now working on a stale tip. We set an atomic flag to abort the proof-of-work computation immediately. We'll look at the mining concurrency guard in detail later.</p>
<p>The <code>match pkg { ... }</code> block is the message router — the equivalent of a network protocol handler. Every incoming message has exactly one code path, and the compiler enforces exhaustiveness. If someone adds a new <code>Package</code> variant and forgets to handle it here, the code won't compile.</p>
<h2>Pattern 3: Fire-and-Forget Broadcasting with <code>tokio::spawn</code></h2>
<p>When a transaction is accepted into the mempool, the node needs to announce it to all peers via INV messages. But this broadcast is not on the critical path — the caller (the API handler or peer connection) shouldn't wait for every peer to acknowledge the relay before returning.</p>
<p>This is where <code>tokio::spawn</code> shines as a fire-and-forget mechanism:</p>
<pre><code class="language-rust">pub async fn process_transaction(
    &amp;self,
    addr_from: &amp;SocketAddr,
    utxo: Transaction,
) -&gt; Result&lt;String&gt; {
    // 1. Duplicate check — fast, synchronous
    if transaction_exists_in_pool(&amp;utxo) {
        return Err(BtcError::TransactionAlreadyExistsInMemoryPool(
            utxo.get_tx_id_hex(),
        ));
    }

    // 2. Accept into mempool (marks UTXO outputs as "in mempool"
    //    to prevent double-spending)
    add_to_memory_pool(utxo.clone(), &amp;self.blockchain).await?;

    // 3. Background: relay to peers + maybe trigger mining.
    //    tokio::spawn detaches this work from the current task.
    //    The caller gets the txid back immediately — they don't
    //    wait for relay or mining to complete.
    let context = self.clone();
    let addr_copy = *addr_from;
    let tx = utxo.clone();
    tokio::spawn(async move {
        let _ = context
            .submit_transaction_for_mining(&amp;addr_copy, tx)
            .await;
    });

    // Return the transaction ID to the caller while
    // relay and mining happen in the background
    Ok(utxo.get_tx_id_hex())
}
</code></pre>
<p>The <code>let _ =</code> inside the spawned task is deliberate. If relay fails (a peer is unreachable), we log it and move on. This is correct for a gossip protocol — if one peer doesn't receive the INV, another peer will relay it later. The alternative — propagating errors back to the caller — would mean a single unreachable peer could block transaction admission.</p>
<p>The same fire-and-forget pattern appears in block broadcasting after mining:</p>
<pre><code class="language-rust">pub async fn broadcast_new_block(block: &amp;Block) -&gt; Result&lt;()&gt; {
    let my_addr = GLOBAL_CONFIG.get_node_addr();
    let nodes = GLOBAL_NODES.get_nodes()?;

    for node in nodes {
        if node.get_addr() != my_addr {
            let addr = node.get_addr();
            let hash = block.get_hash_bytes();
            // Each peer gets its own spawned task.
            // If peer 3 is slow, peers 1 and 2 still
            // get notified immediately.
            tokio::spawn(async move {
                send_inv(&amp;addr, OpType::Block, &amp;[hash]).await;
            });
        }
    }
    Ok(())
}
</code></pre>
<p>Each peer announcement runs in its own task. A slow or disconnected peer doesn't delay announcements to other peers. This is a common pattern in distributed systems: fan-out with independent failure domains.</p>
<h3>When Fire-and-Forget Is Wrong</h3>
<p>It's worth noting when this pattern is <em>not</em> appropriate. Fire-and-forget works for gossip because the protocol is self-healing — if peer B misses an INV from peer A, peer C will send it later. But for operations where you need confirmation (like writing to a database, sending an email, or completing a financial transaction), dropping the <code>JoinHandle</code> without awaiting it means you'll never know if the operation succeeded. For those cases, collect the handles and await them:</p>
<pre><code class="language-rust">// When you need to know the results:
let mut handles = Vec::new();
for peer in &amp;peers {
    let addr = peer.get_addr();
    let data = payload.clone();
    handles.push(tokio::spawn(async move {
        send_data(&amp;addr, data).await
    }));
}

// Wait for all broadcasts and collect failures
let results = futures::future::join_all(handles).await;
let failures: Vec&lt;_&gt; = results.iter()
    .filter(|r| r.as_ref().map_or(true, |inner| inner.is_err()))
    .collect();
if !failures.is_empty() {
    warn!("{} peers failed to acknowledge", failures.len());
}
</code></pre>
<p>The choice between fire-and-forget and awaited fan-out is a design decision about your system's consistency requirements, not a technical limitation.</p>
<h2>Pattern 4: Atomic Guards for Mining Concurrency</h2>
<p>Mining is the most interesting concurrency challenge. Proof-of-work can run for seconds. During that time, a competing block might arrive from a peer, making our computation worthless. We also need to prevent two threads from mining the same block simultaneously (which could happen if multiple transactions trigger the mining threshold at nearly the same time).</p>
<p>We solve both problems with <code>AtomicBool</code> flags:</p>
<pre><code class="language-rust">static MINING_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
static MINING_CANCELLED: AtomicBool = AtomicBool::new(false);

pub async fn process_mine_block(
    txs: Vec&lt;Transaction&gt;,
    blockchain: &amp;BlockchainService,
) -&gt; Result&lt;Block&gt; {
    // Guard: prevent concurrent mining.
    // compare_exchange atomically checks "is it false?"
    // and sets it to true. If another thread already set it
    // to true, we get Err and bail out.
    if MINING_IN_PROGRESS
        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
        .is_err()
    {
        return Err("Mining already in progress".into());
    }

    // Reset the cancellation flag before starting
    reset_mining_cancellation();

    let result = async {
        // Check if a competing block arrived between
        // the trigger and now
        if is_mining_cancelled() {
            return Err("Mining cancelled: new block received".into());
        }

        // Proof-of-work: this is the expensive part.
        // Internally, the PoW loop checks is_mining_cancelled()
        // periodically to abort early if a competing block arrives.
        let new_block = blockchain.mine_block(&amp;txs).await?;

        // CRITICAL: Once mine_block() returns successfully, the block
        // is already in our local chain (tip updated, UTXO set modified).
        // We MUST broadcast it. If we cancel here, we create a permanent
        // fork — our chain has a block that no peer knows about.

        // Remove confirmed transactions from the mempool
        for tx in &amp;txs {
            remove_from_memory_pool(tx.clone(), blockchain).await;
        }

        // Announce the new block hash to all peers
        broadcast_new_block(&amp;new_block).await?;
        Ok(new_block)
    }
    .await;

    // Always release the mining lock, even on error.
    // Without this, a failed mining attempt would
    // permanently block all future mining.
    MINING_IN_PROGRESS.store(false, Ordering::SeqCst);
    result
}
</code></pre>
<p>Several things are worth unpacking here.</p>
<p><code>compare_exchange</code> <strong>is not a mutex.</strong> A mutex would block the second caller until mining finishes. <code>compare_exchange</code> fails immediately — the second caller gets an error and moves on. This is correct behavior: if mining is already in progress with the current mempool contents, queuing a second mining attempt is wasteful.</p>
<p><strong>The cancellation flag is checked at two points.</strong> Once before mining starts (to catch blocks that arrived during preparation), and periodically inside the PoW loop itself. When a peer sends us a <code>Package::Block</code>, the message handler calls <code>cancel_current_mining()</code>, which sets <code>MINING_CANCELLED</code> to <code>true</code>. The PoW loop checks this flag every N iterations and bails out early if set.</p>
<p><strong>The "no cancel after creation" rule is critical.</strong> The comment in the code explains why: once <code>mine_block()</code> returns, the block has been written to our local chain. Our tip has advanced, our UTXO set has been updated. If we skip the broadcast, we create a fork — our chain has a block that no peer will ever request because they don't know its hash. This is a real distributed systems bug that wouldn't show up in unit tests but would cause chain divergence in production.</p>
<p><code>SeqCst</code> <strong>ordering is the safe default.</strong> For two independent atomic flags that interact (in-progress and cancelled), sequential consistency ensures all threads see flag changes in the same order. Weaker orderings (<code>Relaxed</code>, <code>Acquire/Release</code>) could allow a thread to see the cancellation flag before the in-progress flag, leading to subtle race conditions. In practice, <code>SeqCst</code> has negligible performance overhead on x86 architectures (where all loads and stores are already strongly ordered), and the correctness guarantee is worth far more than any nanoseconds saved by weaker orderings.</p>
<h3>A Deeper Look: Why Not <code>tokio::sync::Mutex</code> or <code>CancellationToken</code>?</h3>
<p>You might wonder why we used raw <code>AtomicBool</code> flags instead of higher-level abstractions. There are three alternatives worth considering, each with trade-offs:</p>
<p><code>tokio::sync::Mutex&lt;bool&gt;</code> would work, but it's overkill. A mutex provides mutual exclusion for a critical section — it says "only one task can enter this region at a time." Our mining guard doesn't need a critical section. It needs a non-blocking test: "is mining happening?" If yes, bail out immediately. <code>compare_exchange</code> expresses this intent directly without the overhead of lock acquisition.</p>
<p><code>CancellationToken</code> (from <code>tokio_util</code>) would be a clean fit for the cancellation signal specifically. You'd create a new token before each mining attempt, pass it into the PoW loop, and call <code>token.cancel()</code> when a competing block arrives. The PoW loop would check <code>token.is_cancelled()</code> instead of our custom <code>is_mining_cancelled()</code> function. This is arguably better style — it's the ecosystem-standard way to express cooperative cancellation in Tokio — and it's what we'd use if we were starting fresh today.</p>
<p><code>tokio::sync::Notify</code> could replace the cancellation flag with an async signal. The PoW loop would <code>select!</code> between the computation and <code>notify.notified()</code>. This integrates more naturally with async/await but makes the PoW loop itself async, which adds complexity for CPU-bound work.</p>
<p>We chose <code>AtomicBool</code> because it's the simplest primitive that solves the problem, requires no dependencies beyond <code>std</code>, and is obvious to anyone who's worked with concurrent code in any language. For a more complex cancellation protocol — say, with timeout deadlines or nested cancellation scopes — <code>CancellationToken</code> would be the better choice.</p>
<h2>Pattern 5: The Coordination Façade</h2>
<p>All of these concurrent subsystems — networking, mempool, mining, chainstate — need a single point of coordination. That's <code>NodeContext</code>:</p>
<pre><code class="language-rust">#[derive(Clone, Debug)]
pub struct NodeContext {
    blockchain: BlockchainService,
}
</code></pre>
<p>It's deliberately small. <code>NodeContext</code> owns a single dependency (the blockchain service handle) and reaches into global state for everything else: the mempool (<code>GLOBAL_MEMORY_POOL</code>), the peer set (<code>GLOBAL_NODES</code>), the mining flags (<code>MINING_IN_PROGRESS</code>). This design means <code>NodeContext</code> is cheap to <code>Clone</code> — it's just an <code>Arc</code> under the hood — which matters because every spawned task needs its own copy.</p>
<p>The façade pattern shows up in the transaction lifecycle. What looks like a single operation from the outside is actually a multi-step pipeline across several subsystems:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/3d08d4aa-7e2b-411c-a469-5a0f7975b536.png" alt="NodeContext Coordination Façade" style="display:block;margin:0 auto" />

<p>The caller (the REST API handler or peer connection) sees one <code>async fn</code> call. Behind the façade, the node is coordinating mempool admission, peer relay, and mining — each with its own concurrency model. The mempool uses an <code>RwLock</code> for thread-safe access. The peer set uses its own <code>RwLock</code>. Mining uses atomic flags. The façade hides this complexity behind a clean interface.</p>
<h2>The Trade-off We Made: Blocking I/O in Async Tasks</h2>
<p>There's one deliberate architectural compromise in this codebase that's worth discussing because it reflects a trade-off many real-world Rust projects face.</p>
<p>Our message parsing uses <code>serde_json::Deserializer::from_reader()</code>, which requires a synchronous <code>std::io::Read</code> implementor. Tokio's <code>TcpStream</code> implements <code>AsyncRead</code>, not <code>Read</code>. So in the server loop, we convert the Tokio stream to a standard library stream:</p>
<pre><code class="language-rust">// Inside the spawned task for each connection:
match stream.into_std() {
    Ok(std_stream) =&gt; {
        let _ = std_stream.set_nonblocking(false);
        process_stream(node_context, std_stream).await
    }
    // ...
}
</code></pre>
<p>This means each per-connection task uses blocking I/O on a Tokio worker thread. And this is where understanding Tokio's runtime architecture matters.</p>
<h3>Why Blocking on a Worker Thread Is Dangerous (and When It's Not)</h3>
<p>Tokio's multi-threaded runtime has a fixed pool of worker threads (by default, one per CPU core). Every <code>tokio::spawn</code>ed task runs on one of these workers. When a task blocks — calls a synchronous function that waits for I/O — it holds that worker thread hostage. No other task scheduled to that thread can make progress until the blocking call returns.</p>
<p>Alice Ryhl (a Tokio maintainer) puts it well: if you have 4 worker threads and 4 tasks are all blocking on synchronous I/O, your entire async runtime is frozen. Pending timers, pending accepts, pending channel reads — everything stops until one of those blocking calls finishes.</p>
<p>For our blockchain node, this is acceptable because the peer count is small (8–50 connections) and each connection spends most of its time waiting for the next message. With 4–8 CPU cores, we have enough workers to absorb a few blocked threads without starving the rest of the runtime.</p>
<p>But there's a better approach: <code>tokio::task::spawn_blocking()</code>. This API moves blocking work off the async worker pool onto a dedicated blocking thread pool that Tokio manages separately:</p>
<pre><code class="language-rust">// Better: blocking I/O runs on the blocking thread pool,
// keeping async workers free for non-blocking tasks.
let ctx = self.node_context.clone();
let handle = tokio::runtime::Handle::current();
tokio::task::spawn_blocking(move || {
    // This closure runs on a dedicated blocking thread.
    // The async worker that spawned it is immediately
    // available for other tasks.
    // We use the captured handle to drive async calls
    // (like node_context.add_block().await) from within
    // the blocking context.
    handle.block_on(async {
        process_stream(ctx, std_stream).await
    })
});
</code></pre>
<p><code>spawn_blocking</code> is the correct tool when you must call synchronous APIs from async code. It's used heavily in the Rust ecosystem for file I/O (<code>std::fs</code>), CPU-bound computation (hashing, compression), and synchronous database drivers. The blocking thread pool has a much larger default limit (512 threads) precisely because blocked threads don't consume CPU — they're just waiting.</p>
<h3>The Fully Async Alternative</h3>
<p>For a production node aiming at scale, the right path forward would eliminate the blocking <code>from_reader</code> entirely:</p>
<pre><code class="language-rust">use tokio::io::{AsyncBufReadExt, BufReader};

// Async message framing: read one JSON message per line
async fn process_stream_async(
    node_context: NodeContext,
    stream: tokio::net::TcpStream,
) -&gt; Result&lt;()&gt; {
    let reader = BufReader::new(stream);
    let mut lines = reader.lines();

    while let Some(line) = lines.next_line().await? {
        let pkg: Package = serde_json::from_str(&amp;line)?;
        // ... dispatch pkg to handlers
    }
    Ok(())
}
</code></pre>
<p>This approach requires newline-delimited JSON (each message on one line), which is a minor protocol change. The benefit: zero blocking calls, all I/O is truly asynchronous, and the runtime can efficiently multiplex thousands of connections across a handful of worker threads. Libraries like <code>tokio-serde</code> and <code>tokio-util</code>'s <code>LengthDelimitedCodec</code> provide battle-tested framing implementations.</p>
<p>We chose simplicity over scalability because a blockchain P2P network typically has 8–50 peers, not 10,000. The <code>from_reader</code> API is mature, battle-tested, and requires zero framing logic. For this use case, that trade-off is correct. For yours, it might not be — and that's a judgment call you should make deliberately rather than by default.</p>
<h2>Putting It All Together: A Transaction's Journey</h2>
<p>To see how all these patterns compose, trace a single transaction from arrival to mining:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/94430691-25ab-4d5a-9cff-86b7c2c044b7.png" alt="A Transaction's Journey Through the Node" style="display:block;margin:0 auto" />

<p>Five patterns, one transaction flow. The server loop (<code>select!</code>) accepted the connection. The per-connection task deserialized and routed the message. Fire-and-forget spawning handled relay without blocking the caller. Atomic guards prevented concurrent mining. And the façade (<code>NodeContext</code>) coordinated it all behind a single <code>async fn</code>.</p>
<h2>Key Takeaways</h2>
<p>If you're building a concurrent system in Rust with Tokio — whether it's a blockchain node, a game server, or a distributed cache — here are the patterns that transferred:</p>
<p><code>tokio::select!</code> <strong>is your event multiplexer, but respect cancellation safety.</strong> Use it whenever a loop needs to respond to multiple independent event sources. But check each branch: when one branch wins, the others are dropped. If a dropped future loses partially-completed work (like <code>write_all</code> that's written half the buffer), you have a bug. The Tokio docs mark each method's cancellation safety — read them.</p>
<p><code>tokio::spawn</code> <strong>serves two roles — know which one you need.</strong> As an isolation boundary (one task per connection), it ensures one peer's slow response doesn't block another. As a fire-and-forget mechanism (broadcasting), it detaches background work from the critical path. But fire-and-forget is only safe when the operation is idempotent or self-healing (like gossip). For operations that must succeed, collect and await the <code>JoinHandle</code>s.</p>
<p><strong>Atomic flags beat mutexes for simple boolean coordination.</strong> When you need "is mining happening right now?" — not "wait until mining finishes" — <code>AtomicBool</code> with <code>compare_exchange</code> is lighter weight, non-blocking, and expresses intent more clearly than <code>Mutex&lt;bool&gt;</code>. For more complex cancellation scenarios, <code>CancellationToken</code> from <code>tokio-util</code> is the ecosystem standard.</p>
<p><strong>Never block a Tokio worker thread without knowing the cost.</strong> If you must call synchronous APIs (file I/O, blocking database drivers, CPU-heavy computation), use <code>tokio::task::spawn_blocking()</code> to move the work off the async worker pool. Blocking a worker thread silently degrades the entire runtime's throughput. For a handful of connections it's survivable; at scale it's fatal.</p>
<p><strong>Façades simplify async coordination.</strong> A thin struct that delegates to subsystems lets you change the concurrency model of any individual subsystem without affecting callers. <code>NodeContext</code> could switch its mempool from a global <code>RwLock</code> to an actor-based channel, and no caller would need to change.</p>
<p><strong>Design for the concurrency you have, not the concurrency you imagine.</strong> We used blocking I/O in <code>tokio::spawn</code> tasks because our peer count is small and the simplicity gain was real. Premature optimization toward fully-async I/O would have added framing complexity for no measurable benefit. Know your system's actual concurrency profile, make the simplest choice that works, and document the scaling limit so future you knows when to revisit.</p>
<h2>Explore the Source Code</h2>
<p>The complete networking and node orchestration implementation is open source:</p>
<p><strong>Full repository:</strong> <a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a></p>
<p><strong>Key files:</strong></p>
<ul>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/net/net_processing.rs">net_processing.rs</a> — Message dispatcher and send primitives</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/node/server.rs">server.rs</a> — TCP server loop with graceful shutdown</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/node/miner.rs">miner.rs</a> — Mining pipeline with atomic concurrency guards</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/node/txmempool.rs">txmempool.rs</a> — Transaction mempool admission and removal</p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/blob/main/bitcoin/src/node/context.rs">context.rs</a> — NodeContext façade</p>
</li>
</ul>
<hr />
<p><em>This post is adapted from</em> <a href="https://buildwithrust.com"><em>Rust Blockchain: A Full-Stack Implementation Guide</em></a> <em>— a 33-chapter book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.</em></p>
<p><em>Available now on</em> <a href="https://www.amazon.com/dp/B0GXRYL21V"><em>Amazon</em></a> <em>(paperback + Kindle + hardcover),</em> <a href="https://buildwithrust.gumroad.com/l/rust-blockchain"><em>Gumroad</em></a> <em>(PDF + EPUB), and</em> <a href="https://leanpub.com/rustblockchainafull-stackimplementationguide"><em>Leanpub</em></a> <em>(pay what you want).</em></p>
<p><em>Free resource:</em> <a href="https://github.com/bkunyiha/rust-blockchain"><em>Clone the Starter Template</em></a></p>
<p><em>Follow</em> <a href="https://buildwithrust.com"><em>buildwithrust.com</em></a> <em>for weekly posts on building production systems in Rust.</em></p>
]]></content:encoded></item><item><title><![CDATA[Rust Axum REST API: Auth, CORS, Rate Limiting & OpenAPI]]></title><description><![CDATA[Building a Production REST API with Axum: Authentication, CORS, Rate Limiting, and OpenAPI
We built a complete REST API for a Bitcoin-style blockchain node using Axum, Rust's async web framework built]]></description><link>https://buildwithrust.com/rust-axum-rest-api-auth-cors-rate-limiting-openapi</link><guid isPermaLink="true">https://buildwithrust.com/rust-axum-rest-api-auth-cors-rate-limiting-openapi</guid><category><![CDATA[Rust]]></category><category><![CDATA[axum]]></category><category><![CDATA[Async Rust]]></category><category><![CDATA[tokio rust]]></category><category><![CDATA[rust lang]]></category><category><![CDATA[Rust Web]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Sun, 05 Apr 2026 23:35:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/0d6e9bd5-86b9-4893-aaf8-167683a835b9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Building a Production REST API with Axum: Authentication, CORS, Rate Limiting, and OpenAPI</h1>
<p>We built a complete REST API for a Bitcoin-style blockchain node using <a href="https://github.com/tokio-rs/axum">Axum</a>, Rust's async web framework built on <a href="https://tokio.rs">Tokio</a> and <a href="https://github.com/tower-rs/tower">Tower</a>. The API handles wallet management, transaction submission, block queries, mining operations, and health monitoring — all with role-based authentication, CORS, rate limiting, and auto-generated OpenAPI documentation.</p>
<p>This post walks through the architecture and implementation patterns we used, with real code from the project. If you're building a REST API in Rust with Axum, these patterns transfer directly to any domain — not just blockchain.</p>
<h2>What We're Building</h2>
<p>The API serves as the primary interface to a blockchain node. Desktop clients (we built two — one in <a href="https://iced.rs">Iced</a>, one in <a href="https://tauri.app">Tauri 2</a>), web browsers, and programmatic clients all interact with the node through this single HTTP layer.</p>
<p>Here's the endpoint surface:</p>
<table>
<thead>
<tr>
<th>Group</th>
<th>Endpoints</th>
<th>Auth Required</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Blockchain</strong></td>
<td><code>GET /api/v1/blockchain</code>, <code>/blocks</code>, <code>/blocks/latest</code>, <code>/blocks/{hash}</code></td>
<td>Admin</td>
</tr>
<tr>
<td><strong>Wallet</strong></td>
<td><code>POST /wallet</code>, <code>GET /addresses</code>, <code>/{address}</code>, <code>/{address}/balance</code></td>
<td>Admin or Wallet</td>
</tr>
<tr>
<td><strong>Transactions</strong></td>
<td><code>POST /transactions</code>, <code>GET /transactions</code>, <code>/mempool</code>, <code>/address/{address}</code></td>
<td>Admin or Wallet</td>
</tr>
<tr>
<td><strong>Mining</strong></td>
<td><code>GET /mining/info</code>, <code>POST /mining/generatetoaddress</code></td>
<td>Admin</td>
</tr>
<tr>
<td><strong>Health</strong></td>
<td><code>GET /health</code>, <code>/health/live</code>, <code>/health/ready</code></td>
<td>None</td>
</tr>
<tr>
<td><strong>Docs</strong></td>
<td><code>GET /swagger-ui</code></td>
<td>None</td>
</tr>
</tbody></table>
<p>Every request passes through a middleware pipeline before reaching a handler. Let's start with the architecture.</p>
<h2>Architecture Overview</h2>
<p>The API follows a layered architecture where each component has a single responsibility. Routes map URLs to handlers. Handlers contain business logic. Middleware handles cross-cutting concerns. Models define request and response shapes. The server orchestrates everything.</p>
<pre><code class="language-plaintext">bitcoin/src/web/
├── server.rs           # Server initialization + middleware stack
├── routes/
│   ├── api.rs          # API route definitions
│   └── web.rs          # Swagger UI + static files
├── handlers/
│   ├── blockchain.rs   # Block and chain queries
│   ├── wallet.rs       # Wallet creation + balance
│   ├── transaction.rs  # Transaction submission + history
│   ├── mining.rs       # Block generation
│   └── health.rs       # Liveness + readiness probes
├── middleware/
│   ├── auth.rs         # API key authentication
│   ├── cors.rs         # CORS configuration
│   ├── logging.rs      # Structured logging
│   └── rate_limit.rs   # Token bucket rate limiting
├── models/
│   ├── requests.rs     # Typed request bodies
│   ├── responses.rs    # Typed response envelopes
│   └── errors.rs       # Error types + HTTP mapping
└── openapi.rs          # Utoipa OpenAPI spec
</code></pre>
<p>This structure is intentionally flat. Every handler file maps to one domain. Every middleware file handles one concern. You can find any piece of code in seconds.</p>
<h2>The Request Pipeline</h2>
<p>Every HTTP request flows through the same sequence of middleware layers before reaching a handler. Understanding this pipeline is essential for debugging and extending the API.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/34a4ee36-8bb9-4f4d-9bbf-9baa50931b7e.png" alt="Request Pipeline — each HTTP request flows through CORS, compression, error handling, rate limiting, routing, and authentication before reaching the handler" style="display:block;margin:0 auto" />

<p>The order matters. CORS runs first because browsers send preflight <code>OPTIONS</code> requests that should be handled before any authentication check. Error handling wraps everything inside it so that even middleware failures produce clean JSON responses. Authentication runs per-route (not globally) because health checks are public.</p>
<h2>Server Setup</h2>
<p>The server is configured through a <code>WebServer</code> struct that holds configuration and a shared reference to the blockchain node:</p>
<pre><code class="language-rust">pub struct WebServer {
    config: WebServerConfig,
    node: Arc&lt;NodeContext&gt;,
}

#[derive(Debug, Clone)]
pub struct WebServerConfig {
    pub host: String,
    pub port: u16,
    pub enable_cors: bool,
    pub enable_rate_limiting: bool,
    pub rate_limit_requests_per_second: u32,
    pub rate_limit_burst_size: u32,
}
</code></pre>
<p>The <code>Arc&lt;NodeContext&gt;</code> is the key design decision. Every handler receives a reference-counted pointer to the same blockchain node. There's no global state, no mutex contention on the hot path, and the type system enforces that handlers can only read blockchain data through the <code>NodeContext</code> interface.</p>
<p>Building the application router combines routes and middleware in a single <code>create_app()</code> method:</p>
<pre><code class="language-rust">pub fn create_app(
    &amp;self,
) -&gt; Result&lt;Router, Box&lt;dyn std::error::Error + Send + Sync&gt;&gt; {
    let app = Router::new()
        .merge(create_all_api_routes())
        .merge(create_wallet_only_routes())
        .merge(create_web_routes())
        .with_state(self.node.clone());

    let mut app = app;

    // Rate limiting (conditional)
    if self.config.enable_rate_limiting {
        let rl_config = RateLimitConfig::default();
        if let Some(manager) = build_rate_limiter_manager(&amp;rl_config)? {
            app = app.layer(from_fn_with_state(
                manager,
                axum_rate_limiter::limiter::middleware,
            ));
        }
    }

    // CORS (conditional)
    if self.config.enable_cors {
        app = app.layer(cors::create_cors_layer());
    }

    // Compression + error handling (always on)
    app = app.layer(CompressionLayer::new());
    app = app.layer(axum::middleware::from_fn(handle_errors));

    Ok(app)
}
</code></pre>
<p>Three route sets are merged into one router: the main API endpoints, wallet-specific endpoints with their own auth, and web routes (Swagger UI). State is injected once with <code>.with_state()</code>, then middleware layers are applied conditionally based on configuration.</p>
<p>The server starts with graceful shutdown support — in-flight requests complete before the process exits:</p>
<pre><code class="language-rust">pub async fn start_with_shutdown(
    &amp;self,
) -&gt; Result&lt;(), Box&lt;dyn std::error::Error + Send + Sync&gt;&gt; {
    let app = self.create_app()?;
    let addr = SocketAddr::from(([0, 0, 0, 0], self.config.port));

    let listener = tokio::net::TcpListener::bind(addr).await?;

    let shutdown_signal = async {
        tokio::signal::ctrl_c()
            .await
            .expect("Failed to install CTRL+C signal handler");
        tracing::info!("Shutdown signal received");
    };

    axum::serve(
        listener,
        app.into_make_service_with_connect_info::&lt;SocketAddr&gt;(),
    )
    .with_graceful_shutdown(shutdown_signal)
    .await?;

    Ok(())
}
</code></pre>
<p>The <code>into_make_service_with_connect_info::&lt;SocketAddr&gt;()</code> call preserves client IP addresses through the service layer — essential for per-IP rate limiting.</p>
<h2>Route Organization</h2>
<p>Routes are organized into four groups, each with appropriate authentication:</p>
<pre><code class="language-rust">// Public API routes — full endpoint set, requires admin auth
pub fn create_api_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    Router::new()
        .route("/blockchain", get(blockchain::get_blockchain_info))
        .route("/blockchain/blocks", get(blockchain::get_blocks))
        .route("/blockchain/blocks/latest", get(blockchain::get_latest_blocks))
        .route("/blockchain/blocks/{hash}", get(blockchain::get_block_by_hash))
        .route("/wallet", post(wallet::create_wallet))
        .route("/wallet/{address}/balance", get(wallet::get_balance))
        .route("/transactions", post(transaction::send_transaction))
        .route("/transactions/mempool", get(transaction::get_mempool))
        .route("/mining/info", get(mining::get_mining_info))
        .route("/mining/generatetoaddress", post(mining::generate_to_address))
}

// Admin routes — nests API routes under /api/admin with admin auth
pub fn create_admin_api_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    Router::new()
        .nest("/api/admin", create_api_routes())
        .nest("/api/admin", create_monitor_api_routes())
        .layer(axum::middleware::from_fn(require_admin))
}

// Wallet routes — limited subset with wallet-level auth
pub fn create_wallet_only_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    let wallet_only = Router::new()
        .route("/wallet", post(wallet::create_wallet))
        .route("/transactions", post(transaction::send_transaction));

    Router::new()
        .nest("/api/wallet", wallet_only)
        .layer(axum::middleware::from_fn(require_wallet))
}

// Health checks — public, no auth
pub fn create_monitor_api_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    Router::new()
        .route("/health", get(health::health_check))
        .route("/health/live", get(health::liveness))
        .route("/health/ready", get(health::readiness))
}
</code></pre>
<p>The separation between admin and wallet routes is deliberate. Wallet applications get a limited-privilege API key that can only create wallets and submit transactions. Admin tools get a separate key with full access. This follows the principle of least privilege — a compromised wallet key can't dump the entire blockchain or trigger mining operations.</p>
<h2>Handler Patterns</h2>
<p>Every handler follows the same structure: extract inputs, process the request through <code>NodeContext</code>, and return a typed JSON response. Here's the blockchain info handler:</p>
<pre><code class="language-rust">pub async fn get_blockchain_info(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;BlockchainInfoResponse&gt;&gt;, StatusCode&gt; {
    let height = node.get_blockchain_height().await
        .map_err(|e| {
            error!("Failed to get blockchain height: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let last_block = node.blockchain().get_last_block().await
        .map_err(|e| {
            error!("Failed to get last block: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let mempool_size = node.get_mempool_size()
        .map_err(|e| {
            error!("Failed to get mempool size: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let info = BlockchainInfoResponse {
        height,
        difficulty: 1,
        total_blocks: height,
        mempool_size,
        last_block_hash: last_block.get_hash().to_string(),
        last_block_timestamp: chrono::Utc::now(),
    };

    Ok(Json(ApiResponse::success(info)))
}
</code></pre>
<p>The <code>State(node)</code> extractor gives the handler a reference to the blockchain node. Axum handles the extraction at compile time — if the type doesn't match what <code>.with_state()</code> provided, you get a compile error, not a runtime panic.</p>
<p>For endpoints that take path parameters, Axum can deserialize directly into custom types:</p>
<pre><code class="language-rust">pub async fn get_balance(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
    Path(address): Path&lt;WalletAddress&gt;,  // Custom type, not String
) -&gt; Result&lt;Json&lt;ApiResponse&lt;BalanceResponse&gt;&gt;, StatusCode&gt; {
    let balance = node.get_balance(&amp;address).await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let utxo_set = UTXOSet::new(node.blockchain().clone());
    let utxo_count = utxo_set.utxo_count(&amp;address).await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(ApiResponse::success(BalanceResponse {
        address: address.as_string(),
        balance,
        utxo_count,
        updated_at: chrono::Utc::now(),
    })))
}
</code></pre>
<p><code>Path(address): Path&lt;WalletAddress&gt;</code> extracts the <code>{address}</code> segment and deserializes it into a <code>WalletAddress</code>. If the path segment doesn't parse as a valid wallet address, Axum returns a 400 before the handler even runs.</p>
<p>For POST endpoints, the <code>Json</code> extractor handles request body deserialization:</p>
<pre><code class="language-rust">pub async fn send_transaction(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
    Json(request): Json&lt;SendTransactionRequest&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;SendBitCoinResponse&gt;&gt;, StatusCode&gt; {
    let txid = node.btc_transaction(
        &amp;request.from_address,
        &amp;request.to_address,
        request.amount
    ).await.map_err(|e| {
        error!("Failed to create transaction: {}", e);
        StatusCode::BAD_REQUEST
    })?;

    info!(txid = %txid, "Transaction submitted successfully");

    Ok(Json(ApiResponse::success(SendBitCoinResponse {
        txid,
        timestamp: chrono::Utc::now(),
    })))
}
</code></pre>
<p>A note on the error mapping: in this handler, <code>btc_transaction</code> failures typically indicate client errors (invalid address, insufficient funds), so <code>BAD_REQUEST</code> is reasonable. A production API with more error granularity would use a custom error enum that maps different failure modes to appropriate status codes — <code>400</code> for validation failures, <code>422</code> for business logic rejections, and <code>500</code> for system errors. We cover that pattern in the book's error handling chapter.</p>
<p>The pattern is consistent across every handler: extract, process, respond. This consistency makes the codebase predictable — once you've read one handler, you can read them all.</p>
<h2>Authentication: Role-Based API Keys</h2>
<p>Authentication is implemented as Axum middleware that checks the <code>X-API-Key</code> header and determines the caller's role:</p>
<pre><code class="language-rust">pub async fn require_role(
    mut req: axum::http::Request&lt;axum::body::Body&gt;,
    required: Role,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    // Extract API key from header
    let key = req.headers().get("X-API-Key")
        .and_then(|h| h.to_str().ok());

    // Determine caller's role
    let caller_role = match key {
        Some(k) if is_admin_key(k) =&gt; Role::Admin,
        Some(k) if is_wallet_key(k) =&gt; Role::Wallet,
        _ =&gt; return Err(StatusCode::UNAUTHORIZED),
    };

    // Check authorization (admin can access wallet routes)
    let allowed = caller_role == required
        || (caller_role == Role::Admin &amp;&amp; required == Role::Wallet);

    if !allowed {
        return Err(StatusCode::FORBIDDEN);
    }

    // Attach role to request extensions for downstream handlers
    req.extensions_mut().insert(caller_role);

    Ok(next.run(req).await)
}
</code></pre>
<p>The role hierarchy is simple: Admin has full access, Wallet has limited access, and unauthenticated users can only reach health checks. The <code>req.extensions_mut().insert(caller_role)</code> line attaches the resolved role to the request so handlers can inspect it if needed — for example, to filter response data based on privilege level.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/152ccb7b-f150-4cc3-a8f2-04517a6ffcd7.png" alt="Authentication Flow — API key validation with decision points for key presence, key matching, and role authorization" style="display:block;margin:0 auto" />

<p>Convenience wrappers make it clean to apply per-route:</p>
<pre><code class="language-rust">pub async fn require_admin(
    req: axum::http::Request&lt;axum::body::Body&gt;,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    require_role(req, Role::Admin, next).await
}

pub async fn require_wallet(
    req: axum::http::Request&lt;axum::body::Body&gt;,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    require_role(req, Role::Wallet, next).await
}
</code></pre>
<p>Keys are validated against environment variables (<code>BITCOIN_API_ADMIN_KEY</code> and <code>BITCOIN_API_WALLET_KEY</code>), with development defaults as fallbacks. In production, you'd set strong keys and ideally use a key management service.</p>
<h2>CORS: Development vs Production</h2>
<p>CORS configuration is split into two modes. Development allows everything:</p>
<pre><code class="language-rust">pub fn create_cors_layer() -&gt; CorsLayer {
    CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any)
        .expose_headers(Any)
        .max_age(std::time::Duration::from_secs(86400))
}
</code></pre>
<p>Production restricts to specific origins:</p>
<pre><code class="language-rust">pub fn create_cors_layer_with_origins(origins: Vec&lt;String&gt;) -&gt; CorsLayer {
    let mut cors = CorsLayer::new()
        .allow_methods(Any)
        .allow_headers(Any)
        .expose_headers(Any)
        .max_age(std::time::Duration::from_secs(86400));

    for origin in origins {
        if let Ok(parsed) = origin.parse::&lt;axum::http::HeaderValue&gt;() {
            cors = cors.allow_origin(parsed);
        }
    }

    cors
}
</code></pre>
<p>The 24-hour <code>max_age</code> caches preflight responses so browsers don't re-send <code>OPTIONS</code> requests for every API call. This matters for performance — our desktop clients make dozens of requests per second when polling for blockchain updates.</p>
<h2>Rate Limiting: Token Bucket with Redis</h2>
<p>Rate limiting uses a Redis-backed token bucket algorithm. Each client gets a bucket of tokens; every request consumes one. Tokens refill at a configured rate. When the bucket is empty, the server responds with <code>429 Too Many Requests</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/280ce0e1-b445-43b3-ac32-5177e0729926.png" alt="Token Bucket Rate Limiting — requests consume tokens from a per-client bucket, with 429 responses when empty" style="display:block;margin:0 auto" />

<p>Configuration lives in a TOML file, allowing per-endpoint tuning:</p>
<pre><code class="language-toml">[rate_limiter]
redis_addr = "127.0.0.1:6379"
ip_whitelist = ["127.0.0.1"]

# Global per-IP limit: burst of 20 requests, refill 1 token every 6 seconds
[[rate_limiter.limiter]]
strategy = "ip"
global_bucket = { tokens_count = 20, add_tokens_every = 6 }

# Per-URL limits with tighter control on expensive endpoints
[[rate_limiter.limiter]]
strategy = "url"
global_bucket = { tokens_count = 60, add_tokens_every = 1 }
buckets_per_value = [
  { value = "/api/mining/generate", tokens_count = 2, add_tokens_every = 60 },
]
</code></pre>
<p>The mining endpoint gets only 2 requests per minute — generating blocks is CPU-intensive and should be throttled aggressively. General endpoints get 60 requests per second, which handles normal client polling comfortably.</p>
<h2>Response Envelope: One Shape for Everything</h2>
<p>Every API response uses the same generic envelope:</p>
<pre><code class="language-rust">#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiResponse&lt;T&gt; {
    pub success: bool,
    pub data: Option&lt;T&gt;,
    pub error: Option&lt;String&gt;,
    pub timestamp: DateTime&lt;Utc&gt;,
}

impl&lt;T&gt; ApiResponse&lt;T&gt; {
    pub fn success(data: T) -&gt; Self {
        Self {
            success: true,
            data: Some(data),
            error: None,
            timestamp: Utc::now(),
        }
    }

    pub fn error(error: String) -&gt; Self {
        Self {
            success: false,
            data: None,
            error: Some(error),
            timestamp: Utc::now(),
        }
    }
}
</code></pre>
<p>Clients always know the shape of the response. Parse <code>success</code> first — if <code>true</code>, <code>data</code> is present and typed. If <code>false</code>, <code>error</code> explains what went wrong. The <code>timestamp</code> field is useful for debugging latency and cache staleness.</p>
<p>This consistency pays off in client code. Our Tauri desktop app has a single <code>invoke()</code> wrapper that handles the envelope:</p>
<pre><code class="language-typescript">const response = await invoke&lt;ApiResponse&lt;T&gt;&gt;('api_call', { params });
if (!response.success) throw new Error(response.error);
return response.data;
</code></pre>
<h2>Error Handling: Safe for Clients, Detailed for Operators</h2>
<p>The error handling middleware catches all errors and ensures clients never see stack traces or internal paths:</p>
<pre><code class="language-rust">async fn handle_errors(
    request: axum::http::Request&lt;axum::body::Body&gt;,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    let response = next.run(request).await;

    if response.status().is_server_error() || response.status().is_client_error() {
        let (parts, body) = response.into_parts();
        let body_bytes = axum::body::to_bytes(body, usize::MAX)
            .await.unwrap_or_default();

        // Log the full error for operators
        tracing::error!(
            "[handle_errors]: Error response ({}): {}",
            parts.status,
            String::from_utf8_lossy(&amp;body_bytes)
        );

        // Sanitize internal server errors for clients
        if parts.status == StatusCode::INTERNAL_SERVER_ERROR {
            let safe_error = ErrorResponse {
                error: "Internal Server Error".to_string(),
                message: "An unexpected error occurred".to_string(),
                status_code: 500,
                timestamp: chrono::Utc::now(),
            };
            return Ok(Json(ApiResponse::&lt;()&gt;::error(
                serde_json::to_string(&amp;safe_error)
                    .unwrap_or_else(|_| "Unknown error".to_string()),
            )).into_response());
        }
    }

    Ok(response)
}
</code></pre>
<p>The split is intentional: operators see the full error context in logs (including the original error message, status code, and request context). Clients see a sanitized message that doesn't reveal anything about the internal architecture.</p>
<h2>Health Checks: Three Endpoints, Three Purposes</h2>
<p>We expose three health endpoints, each serving a different consumer:</p>
<pre><code class="language-rust">// Full health check with metrics — for monitoring dashboards
pub async fn health_check(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;HealthResponse&gt;&gt;, StatusCode&gt; {
    let height = node.get_blockchain_height().await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let connected_peers = node.get_peer_count().unwrap_or(0);

    Ok(Json(ApiResponse::success(HealthResponse {
        status: "healthy".to_string(),
        version: env!("CARGO_PKG_VERSION").to_string(),
        blockchain_height: height,
        connected_peers,
        // ... additional metrics
    })))
}

// Liveness probe — for Kubernetes "is the process alive?"
pub async fn liveness() -&gt; Result&lt;Json&lt;ApiResponse&lt;String&gt;&gt;, StatusCode&gt; {
    Ok(Json(ApiResponse::success("alive".to_string())))
}

// Readiness probe — for Kubernetes "can this pod accept traffic?"
pub async fn readiness(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;String&gt;&gt;, StatusCode&gt; {
    match node.get_blockchain_height().await {
        Ok(_) =&gt; Ok(Json(ApiResponse::success("ready".to_string()))),
        Err(_) =&gt; Err(StatusCode::SERVICE_UNAVAILABLE),
    }
}
</code></pre>
<p>Kubernetes uses <code>/health/live</code> to decide whether to restart a pod and <code>/health/ready</code> to decide whether to route traffic to it. The full <code>/health</code> endpoint gives monitoring systems (Prometheus, Datadog, etc.) detailed metrics. This three-endpoint pattern is a production best practice that works well beyond blockchain — any service deployed to containers benefits from it.</p>
<h2>OpenAPI: Documentation That Can't Drift</h2>
<p>The API specification is generated from Rust types using <a href="https://github.com/juhaku/utoipa">Utoipa</a>. Every response model derives <code>ToSchema</code>, and the OpenAPI definition references them directly:</p>
<pre><code class="language-rust">#[derive(OpenApi)]
#[openapi(
    paths(
        health::health_check,
        blockchain::get_blockchain_info,
        wallet::create_wallet,
        transaction::send_transaction,
        mining::get_mining_info,
        // ... all endpoints
    ),
    components(schemas(
        BlockchainInfoResponse,
        WalletResponse,
        SendTransactionRequest,
        // ... all models
    )),
    tags(
        (name = "Health", description = "Health check endpoints"),
        (name = "Blockchain", description = "Blockchain data and queries"),
        (name = "Wallet", description = "Wallet management"),
        (name = "Transactions", description = "Transaction operations"),
        (name = "Mining", description = "Block generation"),
    ),
    info(
        title = "Blockchain API",
        version = "0.1.0",
        description = "A comprehensive blockchain node REST API"
    ),
)]
pub struct ApiDoc;
</code></pre>
<p>Swagger UI is served automatically at <code>/swagger-ui</code>:</p>
<pre><code class="language-rust">pub fn create_swagger_ui() -&gt; SwaggerUi {
    SwaggerUi::new("/swagger-ui")
        .url("/api-docs/openapi.json", ApiDoc::openapi())
}
</code></pre>
<p>Because the spec is generated from the same types the handlers use, it can't drift out of sync. If you add a field to <code>BlockchainInfoResponse</code>, it appears in the OpenAPI spec automatically. If you add a new endpoint, you add it to the <code>paths()</code> list and the compiler reminds you if you forget the schema. This is a significant advantage over maintaining a separate OpenAPI YAML file.</p>
<h2>Structured Logging with Tracing</h2>
<p>The <code>tracing</code> crate is initialized once at server startup — typically in <code>main()</code> with a subscriber that formats output as structured JSON in production or human-readable text in development:</p>
<pre><code class="language-rust">tracing_subscriber::fmt()
    .with_env_filter(EnvFilter::from_default_env())  // RUST_LOG=info,bitcoin=debug
    .json()  // Structured JSON for production log aggregation
    .init();
</code></pre>
<p>With that in place, every handler logs operations with structured key-value pairs:</p>
<pre><code class="language-rust">use tracing::{error, info, instrument};

// Structured logging with context
info!(
    txid = %txid,
    from = %from_address,
    to = %to_address,
    amount = amount,
    "Transaction submitted successfully"
);

// Error logging preserves full context
error!(
    error = %e,
    txid = %txid,
    from = %from_address,
    "Failed to process transaction"
);

// Spans automatically add context to all nested logs
#[instrument]
async fn process_transaction(txid: String) {
    info!("Processing transaction");
    // ↑ This log automatically includes txid from the span
}
</code></pre>
<p>Structured logs are queryable. In production, you can filter for all failed transactions, all requests from a specific address, or all operations that took longer than a threshold — without parsing log strings.</p>
<h2>Request Validation with Derive Macros</h2>
<p>Request models use the <code>validator</code> crate for declarative validation:</p>
<pre><code class="language-rust">#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct SendTransactionRequest {
    pub from_address: WalletAddress,
    pub to_address: WalletAddress,
    #[validate(range(min = 1, message = "Amount must be greater than 0"))]
    pub amount: i32,
}

#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct BlockQuery {
    #[validate(range(min = 0, message = "Page must be 0 or greater"))]
    pub page: Option&lt;u32&gt;,
    #[validate(range(min = 1, max = 100, message = "Limit must be 1-100"))]
    pub limit: Option&lt;u32&gt;,
    pub hash: Option&lt;String&gt;,
}
</code></pre>
<p>Validation rules are declared next to the fields they constrain. The <code>Validate</code> derive macro generates a <code>.validate()</code> method that checks all rules at once. In a handler, you call it before processing:</p>
<pre><code class="language-rust">pub async fn send_transaction(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
    Json(request): Json&lt;SendTransactionRequest&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;SendBitCoinResponse&gt;&gt;, StatusCode&gt; {
    // Validate before processing
    request.validate().map_err(|_| StatusCode::BAD_REQUEST)?;

    // ... proceed with validated request
}
</code></pre>
<p>Combined with <code>ToSchema</code>, the validation constraints also appear in the OpenAPI documentation — clients can see the allowed ranges before making requests.</p>
<h2>Putting It All Together</h2>
<p>Here's a <code>curl</code> session that exercises the main endpoints:</p>
<pre><code class="language-bash"># Health check (no auth required)
curl http://localhost:8080/api/admin/health

# Get blockchain info (admin auth)
curl -H "X-API-Key: admin-secret" \
     http://localhost:8080/api/admin/blockchain

# Create a wallet (wallet auth is sufficient)
curl -X POST \
     -H "X-API-Key: wallet-secret" \
     -H "Content-Type: application/json" \
     -d '{"name": "my-wallet"}' \
     http://localhost:8080/api/wallet/wallet

# Send a transaction
curl -X POST \
     -H "X-API-Key: wallet-secret" \
     -H "Content-Type: application/json" \
     -d '{"from_address": "...", "to_address": "...", "amount": 50}' \
     http://localhost:8080/api/wallet/transactions

# Mine a block (admin only)
curl -X POST \
     -H "X-API-Key: admin-secret" \
     -H "Content-Type: application/json" \
     -d '{"address": "...", "nblocks": 1}' \
     http://localhost:8080/api/admin/mining/generatetoaddress

# Interactive API docs
open http://localhost:8080/swagger-ui
</code></pre>
<h2>Key Takeaways</h2>
<p>After building this API, a few patterns stand out as universally applicable:</p>
<p><strong>Use Axum's type system aggressively.</strong> Custom types for path parameters (<code>WalletAddress</code> instead of <code>String</code>), typed state extraction, and the <code>ApiResponse&lt;T&gt;</code> envelope catch entire categories of bugs at compile time. The initial investment in types pays off in fewer runtime errors and more self-documenting code.</p>
<p><strong>Separate auth by route group, not globally.</strong> Health checks should always be public. Admin and wallet operations need different privilege levels. Axum's <code>.layer()</code> on nested routers makes this natural — you apply middleware where it belongs, not everywhere.</p>
<p><strong>Rate limit by endpoint, not just by IP.</strong> A flat per-IP limit doesn't account for the fact that mining operations are 100x more expensive than health checks. Per-endpoint configuration lets you protect expensive operations without throttling cheap ones.</p>
<p><strong>Generate documentation from code.</strong> Any documentation that exists separately from the implementation will drift. Utoipa's derive macros guarantee that the OpenAPI spec matches the actual types and endpoints. Swagger UI gives you interactive testing for free.</p>
<p><strong>Three health endpoints, not one.</strong> Kubernetes needs liveness and readiness as separate signals. Monitoring dashboards need detailed metrics. One endpoint can't serve all three consumers well.</p>
<h2>Explore the Source Code</h2>
<p>The complete source code for the API layer is open source:</p>
<p><strong>Full repository:</strong> <a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a></p>
<p><strong>API source:</strong> <a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin/src/web">bitcoin/src/web/</a></p>
<p>The same API serves both the Iced and Tauri desktop clients — you can see how they consume it in the <a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-iced">Iced admin UI</a> and <a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-tauri">Tauri admin UI</a>.</p>
<hr />
<p><em>This post is adapted from</em> <a href="https://buildwithrust.com"><em>Rust Blockchain: A Full-Stack Implementation Guide</em></a> <em>— a 33-chapter book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.</em></p>
<p><em>Available now on</em> <a href="https://www.amazon.com/dp/B0GXRYL21V"><em>Amazon</em></a> <em>(paperback + Kindle + hardcover),</em> <a href="https://buildwithrust.gumroad.com/l/rust-blockchain"><em>Gumroad</em></a> <em>(PDF + EPUB), and</em> <a href="https://leanpub.com/rustblockchainafull-stackimplementationguide"><em>Leanpub</em></a> <em>(pay what you want).</em></p>
<p><em>Free resource:</em> <a href="https://github.com/bkunyiha/rust-blockchain"><em>Clone the Starter Template</em></a></p>
<p><em>Follow</em> <a href="https://buildwithrust.com"><em>buildwithrust.com</em></a> <em>for weekly posts on building production systems in Rust.</em></p>
]]></content:encoded></item><item><title><![CDATA[Iced vs Tauri 2: We Built the Same App Twice in Rust ]]></title><description><![CDATA[Iced vs Tauri 2: We Built the Same Desktop App Twice in Rust
We built the same blockchain admin dashboard and wallet application twice — once with Iced (pure Rust, MVU architecture) and once with Taur]]></description><link>https://buildwithrust.com/iced-vs-tauri-2-we-built-the-same-app-twice-in-rust</link><guid isPermaLink="true">https://buildwithrust.com/iced-vs-tauri-2-we-built-the-same-app-twice-in-rust</guid><category><![CDATA[Tauri]]></category><category><![CDATA[Rust]]></category><category><![CDATA[iced]]></category><category><![CDATA[desktop development]]></category><category><![CDATA[rust desktop]]></category><category><![CDATA[rust ui]]></category><category><![CDATA[UI In Rust]]></category><category><![CDATA[Rust Desktop Apps]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Sat, 28 Mar 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/552a26b6-f00e-4484-a471-33c1e9b1cc82.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Iced vs Tauri 2: We Built the Same Desktop App Twice in Rust</h1>
<p>We built the same blockchain admin dashboard and wallet application twice — once with <a href="https://iced.rs">Iced</a> (pure Rust, MVU architecture) and once with <a href="https://tauri.app">Tauri 2</a> (Rust backend + React frontend). Both apps connect to the same blockchain node, use the same API crate, and offer identical functionality. The only difference is the desktop framework.</p>
<p>This post walks through the architectural trade-offs we encountered, with real code from both implementations. If you're choosing between Iced and Tauri for a Rust desktop project, this should help you make an informed decision.</p>
<h2>The Setup: One Blockchain, Two Desktop Clients</h2>
<p>Both applications are admin dashboards for a Bitcoin-style blockchain built entirely in Rust. They let you inspect blocks, manage wallets, send transactions, check balances, and monitor node health. Behind the scenes, both talk to the same REST API via a shared <code>bitcoin-api</code> crate.</p>
<p>The difference is how they render UI and manage state.</p>
<p><strong>Iced</strong> is a cross-platform GUI framework for Rust inspired by Elm. You write your entire application — state, logic, rendering — in Rust. There's no web layer, no JavaScript, no HTML. The framework provides widgets, layout primitives, and an event loop built around the Model-View-Update pattern.</p>
<p><strong>Tauri 2</strong> takes a different approach: your backend is Rust, your frontend is a web app (in our case, React + TypeScript), and the two communicate over an IPC bridge. Tauri uses the OS native webview rather than bundling Chromium, which keeps binaries small (5–24 MB vs Electron's 150+ MB).</p>
<h2>Architecture at a Glance</h2>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Iced</th>
<th>Tauri 2</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Languages</strong></td>
<td>Rust only</td>
<td>Rust + TypeScript</td>
</tr>
<tr>
<td><strong>UI paradigm</strong></td>
<td>Model-View-Update (Elm-style)</td>
<td>React components + IPC</td>
</tr>
<tr>
<td><strong>State management</strong></td>
<td>Single struct (<code>AdminApp</code>)</td>
<td>Zustand (UI) + React Query (server)</td>
</tr>
<tr>
<td><strong>Async model</strong></td>
<td>Manual <code>spawn_on_tokio</code> bridge</td>
<td>Native <code>async fn</code> commands</td>
</tr>
<tr>
<td><strong>Styling</strong></td>
<td>Built-in Iced widgets</td>
<td>Tailwind CSS</td>
</tr>
<tr>
<td><strong>Event vocabulary</strong></td>
<td><code>Message</code> enum (~50 variants)</td>
<td>React Router routes + query keys</td>
</tr>
<tr>
<td><strong>Binary size</strong></td>
<td>Smaller</td>
<td>Slightly larger</td>
</tr>
<tr>
<td><strong>Ecosystem</strong></td>
<td>Growing, Rust-native</td>
<td>Mature (npm + crates.io)</td>
</tr>
</tbody></table>
<h2>How Each Framework Is Structured</h2>
<p>Before comparing behavior, it helps to see how each project is organized on disk and how data flows through the system.</p>
<h3>Iced Project Structure</h3>
<p>Everything is Rust. The entire application compiles into a single binary with no bundled web runtime:</p>
<pre><code class="language-plaintext">bitcoin-desktop-ui-iced/
  src/
    main.rs     # Entry point: boots Tokio runtime, launches Iced app
    runtime.rs  # Tokio bridge: spawn_on_tokio() helper
    types.rs    # Message enum (all events) + Menu enum (navigation)
    app.rs      # AdminApp struct (the single state container)
    update.rs   # update() function: Message -&gt; state mutation + Tasks
    view.rs     # view() function: &amp;Model -&gt; UI elements (pure, no mutation)
    api.rs      # Async HTTP calls to the blockchain node REST API
</code></pre>
<p>Data flows in a strict loop. There are no callbacks, no event listeners, no shared mutable references between components — just one cycle that repeats for every user interaction.</p>
<p><strong>Iced MVU event loop (single Rust binary):</strong></p>
<pre><code class="language-text">  (view)
    |
    | Message
    v
  (update) ---- Task::perform() ---&gt; [Tokio runtime]
    ^                                 |
    | new state                        | HTTP
    |                                  v
    +-------------------------- [Blockchain node]
                                 (REST API)
</code></pre>
<p>The key insight: <code>view()</code> never mutates state. It reads <code>&amp;Model</code> and returns a description of the UI. When the user clicks something, Iced creates a <code>Message</code>, feeds it to <code>update()</code>, which mutates the model and optionally kicks off async work. When that async work completes, it produces another <code>Message</code>, and the cycle repeats.</p>
<h3>Tauri Project Structure</h3>
<p>Tauri splits the project into two halves — a Rust backend and a React frontend — connected by IPC:</p>
<pre><code class="language-plaintext">bitcoin-desktop-ui-tauri/
  src-tauri/                   # --- RUST BACKEND ---
    src/
      main.rs                  # Tauri app builder: registers commands + state
      config/mod.rs            # ApiConfig: base_url + api_key (behind RwLock)
      models/mod.rs            # Shared types: WalletAddress, BlockSummary, etc.
      services/bitcoin_api.rs  # HTTP client wrapping the bitcoin-api crate
      commands/                # #[tauri::command] handlers (the IPC surface)
        blockchain.rs          #   get_blockchain_info, get_block, etc.
        wallet.rs              #   create_wallet, get_balance, etc.
        transactions.rs        #   send_transaction, get_history, etc.
        mining.rs              #   mine_block, get_mining_status
        health.rs              #   health_check
        settings.rs            #   get_config, update_config
  src/                         # --- REACT FRONTEND ---
    main.tsx                   # React app entry point
    App.tsx                    # Router setup + layout
    lib/commands.ts            # invoke() wrappers — typed bridge to Rust
    store/useAppStore.ts       # Zustand: UI state (theme, sidebar, wallet)
    hooks/                     # React Query hooks for each data domain
    components/                # Shared UI: DataCard, DataTable, Sidebar, etc.
    pages/                     # 18 page components across 5 sections
</code></pre>
<p>The data flow crosses a language boundary at the IPC bridge:</p>
<pre><code class="language-text">  [Page component]
    | \
    |  \--&gt; [Zustand store] (UI state)
    |
    v
  [React Query]
    |
    | invoke('command')  (JSON over IPC)
    v
  [#[tauri::command] async fn]
    | \
    |  \--&gt; [Read config (RwLock)]
    |
    v
  [Service layer] -- HTTP --&gt; [Blockchain node]
    ^
    |
  Result&lt;T&gt; (JSON back to React Query)
</code></pre>
<p>The key insight: the IPC bridge is a serialization boundary. Every value that crosses it must be JSON-serializable. The React frontend calls <code>invoke('command_name')</code>, Tauri routes it to the matching <code>#[tauri::command]</code> Rust function, and the result comes back as a JavaScript object. The frontend never touches the Tokio runtime, the service layer, or the HTTP client — it only sees typed Promises.</p>
<h2>State Management: One Struct vs Two Layers</h2>
<p>This is where the philosophies diverge most sharply.</p>
<h3>Iced: Everything in One Place</h3>
<p>In Iced, your entire application state lives in a single Rust struct. Every field is visible, every mutation flows through a single <code>update</code> function, and the compiler enforces it all.</p>
<pre><code class="language-rust">pub struct AdminApp {
    // Navigation: which screen the user is looking at right now
    menu: Menu,

    // Cached API responses — Option&lt;T&gt; because they start empty,
    // then get populated when async fetches complete
    blockchain_info: Option&lt;BlockchainInfo&gt;,
    wallet_addresses: Vec&lt;WalletAddress&gt;,
    selected_block: Option&lt;Block&gt;,

    // UI feedback: shows success/error messages in the status bar
    status_message: String,

    // Form inputs: every text field the user can type into
    // lives here as owned Strings, not in the widget itself
    form_fields: FormState,

    // ... every piece of state in the entire app, right here
    // in one struct. Nothing is hidden.
}
</code></pre>
<p>When the user clicks a button, Iced produces a <code>Message</code> — a variant of a Rust enum that represents every possible event in the application. That message flows to the <code>update</code> function, which pattern-matches on it and returns the next state plus any async tasks to run:</p>
<pre><code class="language-rust">// The Message enum is the complete vocabulary of things that can happen.
// Adding a new feature means adding a new variant — the compiler then
// forces you to handle it everywhere.
enum Message {
    FetchBlockchainInfo,                        // User clicked "Refresh"
    BlockchainInfoLoaded(Result&lt;BlockchainInfo, Error&gt;), // API responded
    MenuChanged(Menu),                          // User navigated
    WalletSelected(String),                     // User picked a wallet
    // ... ~50 total variants across all screens
}

// update() is the brain of the application.
// It receives the current state (&amp;mut self) and a Message,
// performs any synchronous state changes, and returns async
// work to do (if any). The return type Task&lt;Message&gt; means
// "when this async work finishes, feed the result back as
// another Message."
fn update(&amp;mut self, message: Message) -&gt; Task&lt;Message&gt; {
    match message {
        // Step 1: User clicks "Refresh" -&gt; we kick off an API call.
        // spawn_on_tokio bridges Iced's sync update loop into
        // our Tokio runtime (see the Async section below).
        // Message::BlockchainInfoLoaded is the "callback" —
        // Iced will call update() again with that variant
        // when the future completes.
        Message::FetchBlockchainInfo =&gt; {
            Task::perform(
                spawn_on_tokio(api::get_blockchain_info()),
                Message::BlockchainInfoLoaded,
            )
        }

        // Step 2: API call finished -&gt; store the result in our model.
        // Task::none() means "no further async work needed."
        // Iced will call view() next to re-render with the new data.
        Message::BlockchainInfoLoaded(result) =&gt; {
            self.blockchain_info = result.ok();
            Task::none()
        }

        // ... pattern continues for every interaction in the app
    }
}
</code></pre>
<p>Every possible event in the application has a name and a type. Nothing is implicit. If you forget to handle a variant, the compiler tells you. This is Iced's core trade-off: more code upfront, but the type system guarantees you've handled every case.</p>
<h3>Tauri: Split by Concern</h3>
<p>Tauri splits state into two layers. Ephemeral UI state (which menu is open, light/dark theme, toast notifications) lives in a Zustand store on the React side:</p>
<pre><code class="language-typescript">// Zustand store: lightweight, React-external state container.
// This holds UI-only concerns — things that don't come from the server.
// Zustand is chosen over React Context because it doesn't cause
// unnecessary re-renders of unrelated components.
const useAppStore = create&lt;AppState&gt;((set) =&gt; ({
  theme: 'dark',           // UI preference — persists across page navigations
  sidebarOpen: true,       // Layout state — not worth fetching from Rust
  activeWallet: null,      // Currently selected wallet address (string | null)

  // Actions: plain functions that call set() to update state.
  // Any React component can call these without prop drilling.
  setActiveWallet: (wallet) =&gt; set({ activeWallet: wallet }),
}));
</code></pre>
<p>Server state (blockchain info, wallet balances, transaction history) is managed by React Query, which handles caching, background refetching, and loading states automatically:</p>
<pre><code class="language-typescript">// React Query: manages everything that comes from the Rust backend.
// You declare WHAT data you need; React Query handles WHEN to fetch,
// WHEN to refetch, and WHAT to show while loading.
const { data, isLoading, error } = useQuery({
  queryKey: ['blockchain-info'],    // Cache key — React Query deduplicates
                                    // requests with the same key
  queryFn: () =&gt; commands.getBlockchainInfo(),  // The actual IPC call to Rust
  refetchInterval: 30_000,          // Auto-refresh every 30 seconds
                                    // (blockchain data changes as blocks are mined)
});
// At this point:
//   - isLoading = true on first load, false after
//   - data = the BlockchainInfo object from Rust, or undefined
//   - error = any error from the invoke() call
// React Query also gives you: isFetching, isStale, refetch(), and more
</code></pre>
<p>The Rust backend holds only shared configuration behind a <code>RwLock</code>:</p>
<pre><code class="language-rust">pub struct AppState {
    pub api_config: ApiConfig,      // base_url + api_key for the blockchain node
    pub active_wallet: Option&lt;String&gt;, // Currently selected wallet (synced from frontend)
}

// Tauri manages this as shared state across all command handlers.
// RwLock allows multiple commands to READ config concurrently
// (e.g., 3 parallel queries from React Query), while only one
// can WRITE at a time (e.g., when the user updates settings).
// This is Tauri's State extractor — injected automatically into
// any command that declares it as a parameter.
State&lt;RwLock&lt;AppState&gt;&gt;
</code></pre>
<p>This is less unified than Iced's single-struct model, but each layer does what it's best at: React Query handles cache invalidation and refetch logic that would require manual wiring in Iced, while Zustand provides ergonomic UI state without the ceremony of message enums.</p>
<h2>The Async Boundary: Two Very Different Approaches</h2>
<p>Async is where Iced requires the most manual plumbing and where Tauri's two-language architecture actually simplifies things.</p>
<h3>Iced: The Tokio Bridge</h3>
<p>Iced's event loop is synchronous. The <code>update</code> function can't be <code>async</code>. To make API calls, you need to bridge into a Tokio runtime:</p>
<pre><code class="language-rust">// Problem: Iced's update() is synchronous, but our API calls need Tokio.
// Solution: Boot a Tokio runtime on a separate thread at startup,
// then hand futures to it from the synchronous update loop.

// Global handle to the Tokio runtime. OnceCell ensures it's
// initialized exactly once and then available everywhere.
static RUNTIME: OnceCell&lt;Runtime&gt; = OnceCell::new();

fn init_runtime() {
    // Spawn a dedicated OS thread for the Tokio runtime.
    // This thread lives for the entire application lifetime.
    std::thread::spawn(|| {
        let rt = Runtime::new().unwrap();
        RUNTIME.set(rt).unwrap();
        // park() blocks this thread forever without consuming CPU.
        // The runtime stays alive because the Runtime object isn't dropped.
        std::thread::park();
    });
}

// This is the bridge between Iced's sync world and Tokio's async world.
// Call it from update() like:
//   Task::perform(spawn_on_tokio(api::get_balance(addr)), Message::BalanceLoaded)
//
// What happens:
//   1. update() calls spawn_on_tokio(some_future)
//   2. spawn_on_tokio sends that future to the Tokio runtime thread
//   3. Tokio executes it (HTTP call, database query, etc.)
//   4. The result comes back as the return value
//   5. Iced wraps it in Message::SomeLoaded and calls update() again
async fn spawn_on_tokio&lt;F, T&gt;(future: F) -&gt; T
where
    F: Future&lt;Output = T&gt; + Send + 'static,
    T: Send + 'static,
{
    let handle = RUNTIME.get().unwrap().handle().clone();
    handle.spawn(future).await.unwrap()
}
</code></pre>
<p>Every API call follows the same round-trip:</p>
<pre><code class="language-plaintext">User clicks "Refresh"
  -&gt; update() receives Message::FetchBlockchainInfo
    -&gt; Task::perform(spawn_on_tokio(api_call), Message::Loaded)
      -&gt; Tokio runtime executes the HTTP request
        -&gt; Result arrives as Message::BlockchainInfoLoaded(Ok(data))
          -&gt; update() stores data in self.blockchain_info
            -&gt; view() re-renders with new data
</code></pre>
<p>This is explicit and traceable, but it's boilerplate you write for every async operation.</p>
<h3>Tauri: Just Write <code>async fn</code></h3>
<p>Tauri commands are natively async. No runtime bridging, no message round-trips:</p>
<pre><code class="language-rust">// This attribute is all Tauri needs to expose a Rust function to JavaScript.
// Tauri handles: async runtime, JSON serialization, IPC routing, error conversion.
#[tauri::command]
async fn get_blockchain_info(
    // Tauri injects this automatically — it's the shared AppState
    // we registered in main.rs with .manage(RwLock::new(AppState::default()))
    state: State&lt;'_, RwLock&lt;AppState&gt;&gt;,
) -&gt; Result&lt;Value, String&gt; {
    // Step 1: Acquire a read lock on shared config.
    // Multiple commands can read concurrently (RwLock allows many readers).
    let config = state.read().await;

    // Step 2: Create a service instance with the current API configuration.
    // This is the same bitcoin-api crate that Iced uses — identical HTTP calls.
    let service = BitcoinApiService::new(&amp;config.api_config);

    // Step 3: Make the HTTP call and convert any error to a String.
    // Tauri will serialize the Ok value to JSON and send it across IPC.
    // The frontend receives it as a resolved Promise&lt;BlockchainInfo&gt;.
    service.get_blockchain_info()
        .await
        .map_err(|e| e.to_string())
}
</code></pre>
<p>On the frontend, the entire call is one line — <code>invoke()</code> handles IPC serialization, async execution, and error propagation:</p>
<pre><code class="language-typescript">// commands.ts — thin typed wrappers around Tauri's invoke().
// The string 'get_blockchain_info' must exactly match the Rust function name.
// The generic &lt;BlockchainInfo&gt; tells TypeScript what the Promise resolves to.
export async function getBlockchainInfo(): Promise&lt;BlockchainInfo&gt; {
  return invoke&lt;BlockchainInfo&gt;('get_blockchain_info');
}

// For commands that take arguments, pass them as an object:
export async function getBlock(hash: string): Promise&lt;Block&gt; {
  return invoke&lt;Block&gt;('get_block', { hash });
  // Tauri deserializes { hash } into the Rust function's parameters
}
</code></pre>
<p>The entire Iced pattern — <code>Message::Fetch</code> → <code>spawn_on_tokio</code> → <code>Task::perform</code> → <code>Message::Loaded</code> — collapses into one <code>invoke()</code> call that returns a <code>Promise</code>. That's not a minor ergonomic difference; across 22 commands in the admin UI alone, it eliminates roughly 44 message variants and their associated match arms.</p>
<h2>The View Layer: Widgets vs JSX</h2>
<h3>Iced: Rust All the Way Down</h3>
<p>Iced views are pure functions from <code>&amp;Model</code> to <code>Element&lt;Message&gt;</code>. No mutation, no side effects — just a description of what the screen should look like:</p>
<pre><code class="language-rust">// view() is a PURE function: it reads &amp;self (immutable borrow) and
// returns a tree of UI elements. It never mutates state, never makes
// API calls, never has side effects. Iced calls it after every update().
//
// Each widget can produce a Message. For example, a button might produce
// Message::FetchBlockchainInfo when clicked. That message feeds back
// into update(), completing the MVU loop.
fn view(&amp;self) -&gt; Element&lt;Message&gt; {
    // Route to the correct screen based on current navigation state.
    // Each view_* method returns an Element&lt;Message&gt; — a subtree of widgets.
    let content = match self.menu {
        Menu::BlockchainInfo =&gt; self.view_blockchain_info(),
        Menu::WalletList =&gt; self.view_wallet_list(),
        Menu::Send =&gt; self.view_send_form(),
        // ... one arm per screen in the app
    };

    // Compose the layout: sidebar on the left, content in the center,
    // status bar at the bottom. column! stacks children vertically.
    // container wraps everything with padding and alignment.
    container(
        column![
            self.view_sidebar(),    // Navigation menu (produces MenuChanged messages)
            content,                // Active screen (produces screen-specific messages)
            self.view_status_bar(), // Shows self.status_message at the bottom
        ]
    ).into()
}
</code></pre>
<p>Styling is done through Iced's widget API. You compose layouts with <code>row![]</code> (horizontal), <code>column![]</code> (vertical), <code>container</code> (wrapper with padding/alignment), and apply spacing programmatically. It's powerful, but there's no CSS — if you want a specific visual treatment, you build it from widget primitives and theme overrides.</p>
<h3>Tauri: React + Tailwind</h3>
<p>The Tauri frontend is a standard React application. Pages are components, routing is React Router, and styling is Tailwind:</p>
<pre><code class="language-tsx">// A typical Tauri page component. This is standard React — nothing
// Tauri-specific except that commands.getBlockchainInfo() calls
// invoke() under the hood instead of fetch().
export function BlockchainInfoPage() {
  // useQuery handles the entire data lifecycle:
  //   1. First render: isLoading=true, fires queryFn
  //   2. queryFn calls invoke('get_blockchain_info') -&gt; IPC -&gt; Rust -&gt; HTTP
  //   3. Result arrives: isLoading=false, data=BlockchainInfo object
  //   4. On re-mount or stale timeout: refetches silently in background
  const { data, isLoading } = useQuery({
    queryKey: ['blockchain-info'],
    queryFn: commands.getBlockchainInfo,
  });

  // Loading state — React Query gives you this for free.
  // In Iced, you'd need a boolean field in your model and
  // a Message variant to toggle it.
  if (isLoading) return &lt;LoadingSpinner /&gt;;

  // Render the data using Tailwind utility classes.
  // p-6 = padding 1.5rem, space-y-4 = vertical gap between children.
  // DataCard is a shared component used across all pages.
  return (
    &lt;div className="p-6 space-y-4"&gt;
      &lt;h1 className="text-2xl font-bold"&gt;Blockchain Info&lt;/h1&gt;
      &lt;DataCard title="Height" value={data?.height} /&gt;
      &lt;DataCard title="Difficulty" value={data?.difficulty} /&gt;
      {/* data?.height uses optional chaining — safe because
          React Query guarantees data exists when isLoading is false,
          but TypeScript doesn't know that without a type guard */}
    &lt;/div&gt;
  );
}
</code></pre>
<p>This is familiar territory for anyone who has built a web app. The npm ecosystem provides component libraries, form validation (react-hook-form + zod), charting, and every other UI concern you can think of. The trade-off is that you're now maintaining two languages and a serialization boundary between them.</p>
<h2>Where It Gets Interesting: The Wallet</h2>
<p>The wallet application is where architectural differences become most consequential, because it introduces local persistence via SQLCipher — an encrypted SQLite database.</p>
<p>Both implementations use the same deterministic key generation to derive the database password from the local username, home directory, and application name. Same function, same output, same database file. The Iced wallet and Tauri wallet can even read each other's databases.</p>
<p>But the data flow is different.</p>
<p><strong>Iced wallet operations</strong> are multi-step message chains. Creating a wallet means: <code>Message::CreateWallet</code> → API call → <code>Message::WalletCreated(Result)</code> → persist to SQLCipher → <code>Message::WalletSaved</code> → refresh wallet list → <code>Message::WalletsLoaded</code>. Each step is an explicit enum variant.</p>
<p><strong>Tauri wallet operations</strong> collapse that chain into a single command:</p>
<pre><code class="language-rust">#[tauri::command]
async fn create_wallet(
    state: State&lt;'_, RwLock&lt;AppState&gt;&gt;,
) -&gt; Result&lt;WalletAddress, String&gt; {
    let config = state.read().await;
    let service = BitcoinApiService::new(&amp;config.api_config);

    // PHASE 1: Remote — ask the blockchain node to generate a new wallet address.
    // This is an HTTP POST to the node's REST API via the bitcoin-api crate.
    // The node generates a keypair and returns the public address.
    let address = service.create_wallet().await.map_err(|e| e.to_string())?;

    // PHASE 2: Local — persist the new address in the encrypted SQLCipher database.
    // This ensures the wallet survives app restarts. The database is encrypted
    // with a deterministic key derived from the local username + home directory,
    // so no password prompt is needed.
    database::save_wallet_address(&amp;address).map_err(|e| e.to_string())?;

    // The frontend receives a single WalletAddress object.
    // It has no idea that two separate operations happened — one remote,
    // one local. If either fails, the ? operator short-circuits and the
    // frontend gets an Err(String) which React Query surfaces as error state.
    Ok(address)
}
</code></pre>
<p>On the React side, the mutation is equally concise:</p>
<pre><code class="language-typescript">// React Query mutation — handles the invoke() call + cache invalidation.
// When create_wallet succeeds, invalidateQueries forces the wallet list
// to refetch, so the new wallet appears immediately without a manual refresh.
const createWallet = useMutation({
  mutationFn: () =&gt; commands.createWallet(),
  onSuccess: () =&gt; {
    queryClient.invalidateQueries({ queryKey: ['wallets'] });
  },
});
</code></pre>
<p>The frontend sees one <code>invoke()</code> call, gets one result, and React Query handles cache invalidation. The two-phase nature (remote call + local save) is invisible to the UI layer.</p>
<p>This highlights a fundamental trade-off: Iced's message chain makes every step visible and debuggable at the type level, but it's verbose. Tauri's command encapsulation is concise, but the two-phase logic is hidden inside an opaque async function.</p>
<h2>Parallel Data Loading</h2>
<p>When the user selects a wallet, both apps need to fetch wallet info, balance, and transaction history simultaneously. The approaches reflect each framework's async model.</p>
<p><strong>Iced</strong> uses <code>Task::batch</code> to fan out multiple async operations from a single message handler:</p>
<pre><code class="language-rust">Message::WalletSelected(address) =&gt; {
    // Store the selected wallet in our model
    self.active_wallet = Some(address.clone());

    // Clear any stale data from the previously selected wallet
    self.wallet_info = None;
    self.balance = None;
    self.transactions = Vec::new();

    // Fire three API calls in parallel using Task::batch.
    // Each call runs independently on the Tokio runtime.
    // Each produces a different Message variant when it completes.
    // The UI will update incrementally as results arrive —
    // balance might render before transactions, for example.
    Task::batch([
        Task::perform(
            spawn_on_tokio(api::fetch_wallet_info(address.clone())),
            Message::WalletInfoLoaded,   // -&gt; update() stores wallet metadata
        ),
        Task::perform(
            spawn_on_tokio(api::fetch_balance(address.clone())),
            Message::BalanceLoaded,      // -&gt; update() stores balance amount
        ),
        Task::perform(
            spawn_on_tokio(api::fetch_transactions(address)),
            Message::TransactionsLoaded, // -&gt; update() stores tx history
        ),
    ])
}
</code></pre>
<p>Three tasks launch in parallel. Each completes independently and produces its own message. You see exactly what's happening and in what order.</p>
<p><strong>Tauri</strong> achieves the same parallelism through React Query — multiple <code>useQuery</code> hooks on the same page fire their queries independently:</p>
<pre><code class="language-typescript">// React Query hooks declared in the same component.
// React Query automatically deduplicates and parallelizes these —
// all three invoke() calls fire simultaneously when the component mounts.
// Each has its own loading/error/data state, so the UI can show
// partial results as they arrive.

const walletInfo = useQuery({
  queryKey: ['wallet', address],   // Cache key includes the address, so
  queryFn: () =&gt; commands.getWalletInfo(address),  // switching wallets refetches
});

const balance = useQuery({
  queryKey: ['balance', address],
  queryFn: () =&gt; commands.getBalance(address),
  refetchInterval: 15_000,         // Balance changes when transactions confirm —
                                   // poll every 15s to stay current
});

const history = useQuery({
  queryKey: ['history', address],
  queryFn: () =&gt; commands.getTransactions(address),
});
</code></pre>
<p>Same result, less ceremony. React Query manages concurrency, caching, and refetching — but that logic is implicit in the framework rather than explicit in your code.</p>
<h2>Side-by-Side: The Same Operation in Both Frameworks</h2>
<p>To make the architectural difference concrete, here's the complete code path for one operation — "user clicks Refresh to load blockchain info" — in both frameworks.</p>
<h3>Iced: 6 touch points across 4 files</h3>
<pre><code class="language-rust">// --- types.rs: Define the event ---
enum Message {
    FetchBlockchainInfo,                                // 1. Event name
    BlockchainInfoLoaded(Result&lt;BlockchainInfo, Error&gt;), // 2. Completion event
    // ...
}

// --- api.rs: Define the async call ---
pub async fn get_blockchain_info() -&gt; Result&lt;BlockchainInfo, Error&gt; {
    let client = AdminClient::new(&amp;base_url, &amp;api_key);  // 3. HTTP call
    client.get_blockchain_info().await
}

// --- update.rs: Wire the event to the async call ---
Message::FetchBlockchainInfo =&gt; {                         // 4. Dispatch
    Task::perform(
        spawn_on_tokio(api::get_blockchain_info()),
        Message::BlockchainInfoLoaded,
    )
}
Message::BlockchainInfoLoaded(result) =&gt; {                // 5. Handle result
    self.blockchain_info = result.ok();
    Task::none()
}

// --- view.rs: Render a button that produces the event ---
button("Refresh")                                         // 6. UI trigger
    .on_press(Message::FetchBlockchainInfo)
</code></pre>
<h3>Tauri: 3 touch points across 3 files</h3>
<pre><code class="language-rust">// --- commands/blockchain.rs: Define the command ---
#[tauri::command]
async fn get_blockchain_info(                             // 1. Command handler
    state: State&lt;'_, RwLock&lt;AppState&gt;&gt;,
) -&gt; Result&lt;Value, String&gt; {
    let config = state.read().await;
    let service = BitcoinApiService::new(&amp;config.api_config);
    service.get_blockchain_info().await.map_err(|e| e.to_string())
}
</code></pre>
<pre><code class="language-typescript">// --- lib/commands.ts: Typed invoke wrapper ---
export async function getBlockchainInfo() {               // 2. IPC bridge
  return invoke&lt;BlockchainInfo&gt;('get_blockchain_info');
}

// --- pages/BlockchainInfoPage.tsx: UI + data in one place ---
const { data, isLoading, refetch } = useQuery({           // 3. Fetch + render
  queryKey: ['blockchain-info'],
  queryFn: commands.getBlockchainInfo,
});
// ...
&lt;button onClick={() =&gt; refetch()}&gt;Refresh&lt;/button&gt;
</code></pre>
<p>The Iced version is more code, but every step is named and typed. The Tauri version is more concise, but the async lifecycle (loading states, error handling, caching) is managed by React Query rather than your own code.</p>
<h2>When to Choose Which</h2>
<p>After building both implementations side-by-side, here's our take:</p>
<p><strong>Choose Iced when:</strong></p>
<ul>
<li><p>You want a single-language codebase with no JavaScript dependency</p>
</li>
<li><p>Compile-time type safety across the entire application matters to you</p>
</li>
<li><p>You prefer explicit, auditable control flow over implicit framework behavior</p>
</li>
<li><p>Your team is strong in Rust but less experienced with web frontends</p>
</li>
<li><p>Binary size is a priority (Iced produces smaller executables)</p>
</li>
</ul>
<p><strong>Choose Tauri 2 when:</strong></p>
<ul>
<li><p>You want to leverage the React/npm ecosystem for UI</p>
</li>
<li><p>Your team has frontend web experience alongside Rust skills</p>
</li>
<li><p>You need rapid UI iteration (hot reload, CSS utilities, component libraries)</p>
</li>
<li><p>Async operations are central to your app and you want ergonomic handling</p>
</li>
<li><p>You're building something visually complex where CSS and web layout shine</p>
</li>
</ul>
<p><strong>Choose both when:</strong></p>
<ul>
<li>You're writing a book about desktop development in Rust and want to give readers the full picture</li>
</ul>
<p>Neither framework is objectively better. Iced trades ecosystem breadth for language unity and type-level guarantees. Tauri trades language unity for ergonomics and access to the web platform. The right choice depends on your team, your project, and what you value most.</p>
<h2>What We Learned</h2>
<p>Building the same application twice was instructive in ways we didn't expect. The <code>Message</code> enum in Iced, which initially felt like boilerplate, became a comprehensive protocol of everything the application can do — a property that's genuinely valuable for debugging and reasoning about state. Meanwhile, Tauri's <code>invoke()</code> pattern, which felt like it was hiding complexity, turned out to produce code that was easier to onboard new contributors to.</p>
<p>The most surprising finding: the backend code was nearly identical. Both apps use the same <code>bitcoin-api</code> crate, the same SQLCipher database schema, and the same deterministic key derivation. The framework choice only affects the presentation layer and the plumbing between it and the business logic. If you design your Rust backend as a clean service layer, you can put any frontend on top of it.</p>
<h2>Explore the Source Code</h2>
<p>The complete source code for both implementations is open source. You can clone the repo and run either application locally:</p>
<p><strong>Full repository:</strong> <a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a></p>
<p><strong>Iced implementations:</strong></p>
<ul>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-iced">Desktop Admin UI (Iced)</a></p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-wallet-ui-iced">Wallet UI (Iced)</a></p>
</li>
</ul>
<p><strong>Tauri implementations:</strong></p>
<ul>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-tauri">Desktop Admin UI (Tauri)</a></p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-wallet-ui-tauri">Wallet UI (Tauri)</a></p>
</li>
</ul>
<p>Compare the two approaches side-by-side in the same workspace — both consume the shared <code>bitcoin-api</code> crate, so the backend boundary is identical.</p>
<hr />
<p><em>This post is adapted from</em> <a href="https://buildwithrust.com"><em>Rust Blockchain: A Full-Stack Implementation Guide</em></a> <em>— a 33-chapter book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.</em></p>
<p><em>Available now on</em> <a href="https://www.amazon.com/dp/B0GXRYL21V"><em>Amazon</em></a> <em>(paperback + Kindle + hardcover),</em> <a href="https://buildwithrust.gumroad.com/l/rust-blockchain"><em>Gumroad</em></a> <em>(PDF + EPUB), and</em> <a href="https://leanpub.com/rustblockchainafull-stackimplementationguide"><em>Leanpub</em></a> <em>(pay what you want).</em></p>
<p><em>Free resource:</em> <a href="https://github.com/bkunyiha/rust-blockchain"><em>Clone the Starter Template</em></a></p>
<p><em>Follow</em> <a href="https://buildwithrust.com"><em>buildwithrust.com</em></a> <em>for weekly posts on building production systems in Rust.</em></p>
<hr />
<p><em>Tags: rust, blockchain, iced, tauri, desktop-development, framework-comparison</em></p>
]]></content:encoded></item></channel></rss>