Igneum Wallet 0.1.6: the page bridge (main's order of 8 October 2026: igneum.network/swap connected any injected wallet, the Igneum Wallet signed only inside its own window). The engine listens on a fixed loopback port, 127.0.0.1:26811 (src/bridge.rs, igneum_common::http::serve_on), beside its token-guarded window server; a page's provider (site/wallet-provider.js: window.ethereum when nothing is injected, window.igneum beside MetaMask, announced through EIP-6963) POSTs JSON-RPC there with the X-Igneum-Bridge header (a preflight first; the private-network allow header answered) and polls a request the window has to answer. Rules: a site is approved once (eth_requestAccounts raises a connect request; Approve in the window stores the origin in the settings, Decline or five minutes ends it with 4001); every other method from an unapproved origin answers 4100 and creates nothing; eth_sendTransaction (priced by the node: gas with the call's data, the fees, the balance check), personal_sign (EIP-191) and eth_signTypedData_v4 (EIP-712, src/eip712.rs, the specification's Mail vector) each wait as their own request, shown with the origin and the facts and confirmed in the window (Touch ID or Windows Hello when enrolled, the same gate as a send); after a transaction the card keeps the node's word and the checkpoint this wallet verified; reads go to the wallet's node for a connected site; wallet_switchEthereumChain accepts the wallet's chain and refuses another with 4902. Settings: a Websites card with the switch and the connected sites (Disconnect); the lock screen says which site waits. Known-failed first: a page without approval gets nothing (bridge::tests::a_page_without_approval_gets_nothing, the view and provider tests on build-2 before the files existed). Tests: bridge.rs (4), eip712.rs (3), tx.rs (the digest signer recovers), ui/view.test.mjs (the words), site/wallet-provider.test.mjs (5). Versions 0.1.6 in the three places; rust-toolchain.toml taken from master so cargo on the Mac reads the pinned 1.99.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 11:08:59 +00:00
parent 0720a81d77
commit f3c2f7800a
20 changed files with 1685 additions and 10 deletions

View file

@ -15,6 +15,12 @@ pub struct Req {
pub host: Option<String>,
/// X-Igneum-Host: the window host's token (the engine printed it on stdout; the page never sees it)
pub host_token: Option<String>,
/// X-Igneum-Bridge: present on a page's call to the wallet's page bridge (a custom header, so the browser sends a
/// CORS preflight first and a plain form post from a stranger never reaches the handler)
pub bridge_header: bool,
/// Access-Control-Request-Private-Network: the browser asks (Chrome's private network access) before a page on the
/// public web reaches 127.0.0.1; the bridge answers the preflight with the allow header
pub private_network_ask: bool,
}
pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
@ -27,6 +33,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
let target = parts.next()?.to_string();
let mut content_length = 0usize;
let (mut origin, mut sec_fetch_site, mut host, mut host_token) = (None, None, None, None);
let (mut bridge_header, mut private_network_ask) = (false, false);
loop {
let mut h = String::new();
reader.read_line(&mut h).ok()?;
@ -46,6 +53,10 @@ pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
host = Some(v.to_string());
} else if k.eq_ignore_ascii_case("x-igneum-host") {
host_token = Some(v.to_string());
} else if k.eq_ignore_ascii_case("x-igneum-bridge") {
bridge_header = true;
} else if k.eq_ignore_ascii_case("access-control-request-private-network") {
private_network_ask = v.eq_ignore_ascii_case("true");
}
}
}
@ -60,7 +71,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (target, String::new()),
};
Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token })
Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token, bridge_header, private_network_ask })
}
/// R4.3.7: a mutating request must come from the dashboard itself. A browser sends Sec-Fetch-Site (same-origin for
@ -136,6 +147,48 @@ where
Ok(port)
}
/// Binds 127.0.0.1 on a FIXED port (the wallet's page bridge, 8 October 2026: a page on the web can only find the
/// wallet at a port it knows) and serves every connection on its own thread through `handle`. Err when the port is taken.
pub fn serve_on<F>(port: u16, handle: F) -> std::io::Result<()>
where
F: Fn(TcpStream) + Send + Sync + 'static,
{
let listener = TcpListener::bind(("127.0.0.1", port))?;
let handle = std::sync::Arc::new(handle);
std::thread::spawn(move || {
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let handle = handle.clone();
std::thread::spawn(move || handle(stream));
}
});
Ok(())
}
/// A response with extra headers (the bridge's CORS and private-network answers), Connection: close, no cache.
pub fn respond_with(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], extra: &[(&str, &str)]) {
let reason = match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n", body.len());
for (k, v) in extra {
head.push_str(k);
head.push_str(": ");
head.push_str(v);
head.push_str("\r\n");
}
head.push_str("\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
}
/// The per-launch dashboard token: 32 hex characters from the OS.
pub fn new_token() -> String {
let mut raw = [0u8; 16];

View file

@ -1940,7 +1940,7 @@ dependencies = [
[[package]]
name = "igneum-wallet"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"argon2",
"bip32",

View file

@ -1,6 +1,6 @@
[package]
name = "igneum-wallet"
version = "0.1.5"
version = "0.1.6"
edition = "2021"
description = "Igneum Wallet engine: keeps the key encrypted, signs in Rust, reads a node, verifies finality certificates itself, and serves the window on 127.0.0.1"
license = "MIT"

View file

@ -13,6 +13,7 @@ packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet
| 0.1.1 | 5 Oct 2026 | coin served from `brand/igneum-coin-1024.png`; over-the-air updates v2 (unattended install) |
| 0.1.2 | 5 Oct 2026 | Touch ID (macOS) and Windows Hello (Windows, untested): unlock, confirm sends, show the backup, idle lock; the coin and the "reading the chain" line on the balance card; the version in the header and in Settings |
| 0.1.3 | 5 Oct 2026 | the update card: one centred card over the window when a new version is ready, with what changed and one tap to install (`ui/update-card.js`) |
| 0.1.6 | 8 Oct 2026 | the page bridge (`src/bridge.rs`, `src/eip712.rs`, `site/wallet-provider.js`): websites connect through 127.0.0.1:26811 after your approval in the window; eth_requestAccounts, eth_chainId, eth_sendTransaction, personal_sign and typed data, each shown and confirmed here (Touch ID or Windows Hello when enrolled), the finality line after a transaction; Settings lists the connected sites; a page without approval gets nothing |
| 0.1.4 | 5 Oct 2026 | the lock screen (`ui/lock-screen.js`): the coin on the ember glow, the name, the short address, one control; the Touch ID sheet on a tap, once by itself when the wallet opens (setting), never on an idle lock; locking is a 250 ms transition; the idle lock's minutes in Settings (1, 5, 15, 60, never) |
## The lock screen (ui/lock-screen.js, index.html #screen-unlock, app.js onLockScreen; 0.1.4)

View file

@ -0,0 +1,491 @@
//! The page bridge (0.1.6, 8 October 2026, main's order: igneum.network/swap connects any injected wallet, the Igneum
//! Wallet signed only inside its own window). An EIP-1193 provider for pages: the engine listens on a FIXED loopback port
//! (BRIDGE_PORT, igneum_common::http::serve_on) beside its token-guarded window server; a page's shim
//! (site/wallet-provider.js) POSTs JSON-RPC to /bridge/rpc with an Origin the browser sets and the X-Igneum-Bridge
//! header (so a preflight runs first). Nothing leaves without the person's word in the wallet's own window:
//!
//! - a site is approved once (eth_requestAccounts raises a Connect request; Approve in the window stores the origin
//! in the settings; Decline or 5 minutes of silence ends it with 4001); every other method from an origin that is
//! not approved answers 4100 and creates nothing (the known-failed test: a page without approval gets nothing);
//! - eth_sendTransaction, personal_sign and eth_signTypedData(_v4) each raise a request the window shows with the
//! origin and the facts (to, value, the call's bytes, the fee; the message; the domain and the primary type);
//! Confirm signs (through Touch ID or Windows Hello when enrolled, the same gate as a send) and the page's poll
//! reads the hash or the signature; after a transaction the window keeps the finality certificate's line;
//! - reads (eth_call, eth_estimateGas, receipts, balances, blocks, logs) go to the wallet's node for an approved
//! origin, the chain id and net version with them; wallet_switchEthereumChain accepts the wallet's own chain
//! and refuses any other with 4902.
//!
//! This module holds the pure part (what a request means, what a decision does, what the page may read); the engine
//! wires it to the vault, the node and the biometric gate (engine.rs, the "page bridge" section) and the server routes
//! it (server.rs). Tests here run without a vault.
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// The bridge's port: fixed, so a page can find the wallet (IGNEUM_WALLET_BRIDGE_PORT overrides it for tests).
pub const BRIDGE_PORT: u16 = 26811;
/// A request the window has not answered expires after this.
pub const PENDING_TTL: Duration = Duration::from_secs(300);
/// A result the page has not read is kept this long after the decision.
pub const RESULT_TTL: Duration = Duration::from_secs(120);
pub const MAX_PENDING: usize = 8;
// EIP-1193 provider error codes
pub const E_REJECTED: i64 = 4001;
pub const E_UNAUTHORIZED: i64 = 4100;
pub const E_UNSUPPORTED: i64 = 4200;
pub const E_DISCONNECTED: i64 = 4900;
pub const E_CHAIN: i64 = 4902;
pub const E_PARAMS: i64 = -32602;
pub const E_INTERNAL: i64 = -32603;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Connect,
Send,
Sign,
Typed,
}
impl Kind {
pub fn name(&self) -> &'static str {
match self {
Kind::Connect => "connect",
Kind::Send => "send",
Kind::Sign => "sign",
Kind::Typed => "typed",
}
}
}
/// One request waiting for the window, or decided and waiting for the page to read it.
#[derive(Clone, Debug)]
pub struct Pending {
pub id: String,
pub origin: String,
pub kind: Kind,
pub created: Instant,
pub created_unix: f64,
/// what the window shows (never the key, never the page's whole payload)
pub detail: Value,
/// what the engine acts on at Confirm (the transfer's fields, the digest to sign)
pub request: Value,
/// the biometric challenge for this request ("" when nothing is enrolled)
pub confirm_nonce: String,
pub confirm_reason: String,
pub done: Option<Result<Value, (i64, String)>>,
pub decided: Option<Instant>,
}
/// What the engine knows at the moment of a request.
pub struct Ask<'a> {
pub address: &'a str,
pub unlocked: bool,
pub chain_id: u64,
pub approved: bool,
pub listening: bool,
}
/// What a request means.
#[derive(Debug, PartialEq)]
pub enum Reply {
Result(Value),
Error(i64, String),
/// the page polls igneum_poll with this id
Pending(String),
/// a read the engine forwards to its node as it is
Proxy,
}
const PROXIED: &[&str] = &[
"eth_call", "eth_estimateGas", "eth_blockNumber", "eth_getBalance", "eth_getTransactionReceipt", "eth_getTransactionByHash",
"eth_gasPrice", "eth_maxPriorityFeePerGas", "eth_feeHistory", "eth_getCode", "eth_getLogs", "eth_getBlockByNumber", "eth_getBlockByHash",
"eth_getTransactionCount", "eth_getStorageAt", "igneum_getTransactionStatus",
];
pub struct Bridge {
pub pending: Vec<Pending>,
/// the last call from each origin (the Settings card's "last seen")
pub last_seen: HashMap<String, f64>,
seq: u64,
}
fn hex_quantity(v: &Value) -> Option<u128> {
match v {
Value::String(s) => {
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
if h.is_empty() {
return Some(0);
}
u128::from_str_radix(h, 16).ok()
}
Value::Number(n) => n.as_u64().map(|x| x as u128),
_ => None,
}
}
pub fn hex_bytes(v: &Value) -> Option<Vec<u8>> {
let s = v.as_str()?;
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
(0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).ok()).collect()
}
fn is_address(s: &str) -> bool {
s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
}
/// A personal_sign message: hex bytes when it looks like hex, else the text itself.
pub fn message_bytes(v: &Value) -> Option<Vec<u8>> {
let s = v.as_str()?;
if s.starts_with("0x") && s.len() % 2 == 0 && s[2..].chars().all(|c| c.is_ascii_hexdigit()) {
hex_bytes(v)
} else {
Some(s.as_bytes().to_vec())
}
}
impl Default for Bridge {
fn default() -> Self {
Self::new()
}
}
impl Bridge {
pub fn new() -> Bridge {
Bridge { pending: Vec::new(), last_seen: HashMap::new(), seq: 0 }
}
fn new_id(&mut self) -> String {
self.seq += 1;
let mut raw = [0u8; 8];
getrandom::getrandom(&mut raw).expect("os randomness");
format!("{}-{}", self.seq, igneum_common::keys::hex(&raw))
}
/// Requests nobody answered in PENDING_TTL end with 4001; results nobody read in RESULT_TTL are dropped. Returns
/// the expired ones (the engine logs them).
pub fn expire(&mut self, now: Instant) -> Vec<Pending> {
let mut gone = Vec::new();
for p in self.pending.iter_mut() {
if p.done.is_none() && now.duration_since(p.created) > PENDING_TTL {
p.done = Some(Err((E_REJECTED, "the request expired: nobody answered it in the Igneum Wallet window".into())));
p.decided = Some(now);
gone.push(p.clone());
}
}
self.pending.retain(|p| !(p.done.is_some() && p.decided.map(|d| now.duration_since(d) > RESULT_TTL).unwrap_or(false)));
gone
}
pub fn open(&self) -> Vec<&Pending> {
self.pending.iter().filter(|p| p.done.is_none()).collect()
}
/// What the page's call means. `approved` is the engine's reading of its settings for this origin.
pub fn request(&mut self, origin: &str, method: &str, params: &Value, a: &Ask, now: Instant, now_unix: f64) -> Reply {
if origin.is_empty() {
return Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into());
}
self.last_seen.insert(origin.to_string(), now_unix);
let list = params.as_array().cloned().unwrap_or_default();
let chain_hex = format!("0x{:x}", a.chain_id);
match method {
"igneum_poll" => {
let id = list.first().and_then(|v| v.as_str()).unwrap_or("");
return self.poll(origin, id);
}
"eth_requestAccounts" => {
if a.approved {
return if a.unlocked {
Reply::Result(json!([a.address]))
} else {
Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into())
};
}
if let Some(p) = self.pending.iter().find(|p| p.done.is_none() && p.kind == Kind::Connect && p.origin == origin) {
return Reply::Pending(p.id.clone());
}
if self.open().len() >= MAX_PENDING {
return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into());
}
let id = self.new_id();
self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind: Kind::Connect, created: now, created_unix: now_unix, detail: json!({ "origin": origin }), request: Value::Null, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None });
return Reply::Pending(id);
}
"eth_accounts" => {
return Reply::Result(if a.approved && a.unlocked { json!([a.address]) } else { json!([]) });
}
"igneum_disconnect" => return Reply::Result(Value::Null),
_ => {}
}
if !a.approved {
return Reply::Error(E_UNAUTHORIZED, "this site is not connected to the Igneum Wallet: call eth_requestAccounts first".into());
}
match method {
"eth_chainId" => Reply::Result(json!(chain_hex)),
"net_version" => Reply::Result(json!(a.chain_id.to_string())),
"wallet_switchEthereumChain" | "wallet_addEthereumChain" => {
let want = list.first().and_then(|v| v.get("chainId")).and_then(hex_quantity);
match want {
Some(c) if c == a.chain_id as u128 => Reply::Result(Value::Null),
Some(_) => Reply::Error(E_CHAIN, format!("the Igneum Wallet runs one chain, id {} ({chain_hex})", a.chain_id)),
None => Reply::Error(E_PARAMS, "chainId missing".into()),
}
}
"wallet_requestPermissions" | "wallet_getPermissions" => Reply::Result(json!([{ "parentCapability": "eth_accounts", "caveats": [{ "type": "restrictReturnedAccounts", "value": [a.address] }] }])),
"eth_sendTransaction" | "personal_sign" | "eth_sign" | "eth_signTypedData" | "eth_signTypedData_v3" | "eth_signTypedData_v4" => {
if !a.unlocked {
return Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into());
}
if self.open().len() >= MAX_PENDING {
return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into());
}
let (kind, request, detail) = match method {
"eth_sendTransaction" => {
let tx = list.first().cloned().unwrap_or(Value::Null);
let from = tx.get("from").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
if !from.is_empty() && from != a.address.to_ascii_lowercase() {
return Reply::Error(E_UNAUTHORIZED, "from is not this wallet's address".into());
}
let to = tx.get("to").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
if !is_address(&to) {
return Reply::Error(E_PARAMS, "to must be an address (contract creation is not offered)".into());
}
let value = tx.get("value").map(|v| hex_quantity(v).ok_or(())).unwrap_or(Ok(0));
let Ok(value) = value else { return Reply::Error(E_PARAMS, "value must be a hex quantity".into()) };
let data = match tx.get("data").or_else(|| tx.get("input")) {
None | Some(Value::Null) => Vec::new(),
Some(d) => match hex_bytes(d) {
Some(b) => b,
None => return Reply::Error(E_PARAMS, "data must be 0x hex".into()),
},
};
if data.len() > 128 * 1024 {
return Reply::Error(E_PARAMS, "data over 128 KiB".into());
}
let gas = tx.get("gas").or_else(|| tx.get("gasLimit")).and_then(hex_quantity).map(|g| g as u64);
let selector = if data.len() >= 4 { format!("0x{}", igneum_common::keys::hex(&data[..4])) } else { String::new() };
(Kind::Send, json!({ "to": to, "value": value.to_string(), "data": format!("0x{}", igneum_common::keys::hex(&data)), "gas": gas }),
json!({ "origin": origin, "to": to, "to_display": igneum_common::keys::checksum(&to), "value": value.to_string(), "data_len": data.len(), "selector": selector, "gas": gas }))
}
"personal_sign" | "eth_sign" => {
// personal_sign is [message, address]; eth_sign is [address, message]: take whichever is not the address
let (m, addr) = if method == "personal_sign" { (list.first(), list.get(1)) } else { (list.get(1), list.first()) };
if let Some(ad) = addr.and_then(|v| v.as_str()) {
if !ad.eq_ignore_ascii_case(a.address) {
return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into());
}
}
let Some(bytes) = m.and_then(message_bytes) else { return Reply::Error(E_PARAMS, "message missing".into()) };
if bytes.len() > 64 * 1024 {
return Reply::Error(E_PARAMS, "message over 64 KiB".into());
}
let text = String::from_utf8(bytes.clone()).ok().filter(|t| !t.chars().any(|c| c.is_control() && c != '\n' && c != '\t'));
let digest = crate::tx::personal_digest(&bytes);
(Kind::Sign, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&digest)) }),
json!({ "origin": origin, "text": text, "bytes": bytes.len(), "hex": if text.is_none() { format!("0x{}", igneum_common::keys::hex(&bytes[..bytes.len().min(64)])) } else { String::new() } }))
}
_ => {
// eth_signTypedData(_v3, _v4): [address, typed data as an object or a JSON string]
if let Some(ad) = list.first().and_then(|v| v.as_str()) {
if !ad.eq_ignore_ascii_case(a.address) {
return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into());
}
}
let typed = match list.get(1) {
Some(Value::String(s)) => match serde_json::from_str::<Value>(s) {
Ok(v) => v,
Err(e) => return Reply::Error(E_PARAMS, format!("typed data is not JSON: {e}")),
},
Some(v) if v.is_object() => v.clone(),
_ => return Reply::Error(E_PARAMS, "typed data missing".into()),
};
let t = match crate::eip712::hash(&typed) {
Ok(t) => t,
Err(e) => return Reply::Error(E_PARAMS, format!("typed data: {e}")),
};
if let Some(c) = t.chain_id {
if c != a.chain_id as u128 {
return Reply::Error(E_CHAIN, format!("the typed data names chain {c}; this wallet is on {}", a.chain_id));
}
}
let message = typed.get("message").cloned().unwrap_or(Value::Null);
let shown = serde_json::to_string_pretty(&message).unwrap_or_default();
(Kind::Typed, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&t.digest)) }),
json!({ "origin": origin, "domain": t.domain_name, "primary_type": t.primary_type, "message": if shown.len() > 4000 { format!("{}\n…", &shown[..4000]) } else { shown } }))
}
};
let id = self.new_id();
self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind, created: now, created_unix: now_unix, detail, request, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None });
Reply::Pending(id)
}
m if PROXIED.contains(&m) => Reply::Proxy,
_ => Reply::Error(E_UNSUPPORTED, format!("{method} is not offered by the Igneum Wallet")),
}
}
/// The page reads a request's outcome: pending, the result, or the error. Only the origin that made it may read it.
pub fn poll(&mut self, origin: &str, id: &str) -> Reply {
let Some(p) = self.pending.iter().find(|p| p.id == id) else { return Reply::Error(E_INTERNAL, "unknown request".into()) };
if p.origin != origin {
return Reply::Error(E_UNAUTHORIZED, "not your request".into());
}
match &p.done {
None => Reply::Pending(id.into()),
Some(Ok(v)) => {
let v = v.clone();
self.pending.retain(|q| q.id != id);
Reply::Result(v)
}
Some(Err((c, m))) => {
let (c, m) = (*c, m.clone());
self.pending.retain(|q| q.id != id);
Reply::Error(c, m)
}
}
}
/// The window's word. Decline ends the request with 4001; Approve hands the request back to the engine, which
/// signs or connects and then `resolve`s it.
pub fn decide(&mut self, id: &str, ok: bool, now: Instant) -> Result<Pending, String> {
let p = self.pending.iter_mut().find(|p| p.id == id && p.done.is_none()).ok_or("no such request is waiting")?;
if !ok {
p.done = Some(Err((E_REJECTED, "the person declined in the Igneum Wallet".into())));
p.decided = Some(now);
}
Ok(p.clone())
}
pub fn resolve(&mut self, id: &str, r: Result<Value, (i64, String)>, now: Instant) {
if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) {
p.done = Some(r);
p.decided = Some(now);
}
}
pub fn set_challenge(&mut self, id: &str, nonce: &str, reason: &str) {
if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) {
p.confirm_nonce = nonce.into();
p.confirm_reason = reason.into();
}
}
/// The open requests as the window shows them (the oldest first).
pub fn pending_json(&self, enrolled: bool) -> Value {
Value::Array(self.pending.iter().filter(|p| p.done.is_none()).map(|p| {
let mut d = p.detail.clone();
d["id"] = json!(p.id);
d["kind"] = json!(p.kind.name());
d["created_at"] = json!(p.created_unix);
d["confirm_needed"] = json!(enrolled && p.kind != Kind::Connect);
d["confirm_nonce"] = json!(p.confirm_nonce);
d["confirm_reason"] = json!(p.confirm_reason);
d
}).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
const ME: &str = "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826";
fn ask(approved: bool, unlocked: bool) -> Ask<'static> {
Ask { address: ME, unlocked, chain_id: 4463, approved, listening: true }
}
fn tx() -> Value {
json!([{ "from": ME, "to": "0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7", "value": "0xde0b6b3a7640000", "data": "0x38ed17390000000000000000000000000000000000000000000000000000000000000000" }])
}
/// The known-failed shape of 0.1.5: a page could reach nothing at all; with the bridge, a page without approval
/// must still get nothing but the connect question.
#[test]
fn a_page_without_approval_gets_nothing() {
let mut b = Bridge::new();
let now = Instant::now();
for (m, p) in [("eth_chainId", json!([])), ("eth_sendTransaction", tx()), ("personal_sign", json!(["hello", ME])), ("eth_call", json!([{}, "latest"])), ("eth_signTypedData_v4", json!([ME, "{}"]))] {
match b.request("https://igneum.network", m, &p, &ask(false, true), now, 1.0) {
Reply::Error(c, _) => assert_eq!(c, E_UNAUTHORIZED, "{m}"),
other => panic!("{m} answered {other:?}"),
}
}
assert_eq!(b.request("https://igneum.network", "eth_accounts", &json!([]), &ask(false, true), now, 1.0), Reply::Result(json!([])));
assert!(b.open().is_empty(), "no request was created for an unapproved page");
assert_eq!(b.request("", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into()));
}
#[test]
fn a_connect_request_waits_for_the_windows_word_and_only_its_origin_reads_it() {
let mut b = Bridge::new();
let now = Instant::now();
let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0) else { panic!() };
assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 2.0), Reply::Pending(id.clone()), "the same page asking again gets the same request");
assert_eq!(b.poll("https://igneum.network", &id), Reply::Pending(id.clone()));
assert_eq!(b.poll("https://evil.example", &id), Reply::Error(E_UNAUTHORIZED, "not your request".into()));
let p = b.decide(&id, true, now).unwrap();
assert_eq!(p.kind, Kind::Connect);
b.resolve(&id, Ok(json!([ME])), now);
assert_eq!(b.poll("https://igneum.network", &id), Reply::Result(json!([ME])));
assert_eq!(b.poll("https://igneum.network", &id), Reply::Error(E_INTERNAL, "unknown request".into()), "read once");
// declined
let Reply::Pending(id2) = b.request("https://other.example", "eth_requestAccounts", &json!([]), &ask(false, true), now, 3.0) else { panic!() };
b.decide(&id2, false, now).unwrap();
assert!(matches!(b.poll("https://other.example", &id2), Reply::Error(E_REJECTED, _)));
// once approved: the address, the chain, the reads proxied, another chain refused
assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!([ME])));
assert_eq!(b.request("https://igneum.network", "eth_chainId", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!("0x116f")));
assert_eq!(b.request("https://igneum.network", "eth_call", &json!([{}, "latest"]), &ask(true, true), now, 4.0), Reply::Proxy);
assert_eq!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x116f" }]), &ask(true, true), now, 4.0), Reply::Result(Value::Null));
assert!(matches!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x1" }]), &ask(true, true), now, 4.0), Reply::Error(E_CHAIN, _)));
assert!(matches!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, false), now, 4.0), Reply::Error(E_UNAUTHORIZED, _)), "locked: no address");
}
#[test]
fn a_send_a_message_and_typed_data_each_wait_as_their_own_request_with_the_facts_the_window_shows() {
let mut b = Bridge::new();
let now = Instant::now();
let Reply::Pending(id) = b.request("https://igneum.network", "eth_sendTransaction", &tx(), &ask(true, true), now, 1.0) else { panic!() };
let p = b.pending.iter().find(|p| p.id == id).unwrap().clone();
assert_eq!(p.kind, Kind::Send);
assert_eq!(p.detail["to_display"], json!("0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7"));
assert_eq!(p.detail["value"], json!("1000000000000000000"));
assert_eq!(p.detail["selector"], json!("0x38ed1739"));
assert_eq!(p.detail["data_len"], json!(36));
assert_eq!(p.request["gas"], Value::Null);
assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": "0x1111111111111111111111111111111111111111", "to": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, _)));
assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_PARAMS, _)));
let Reply::Pending(id2) = b.request("https://igneum.network", "personal_sign", &json!(["0x68656c6c6f", ME]), &ask(true, true), now, 1.0) else { panic!() };
let p2 = b.pending.iter().find(|p| p.id == id2).unwrap().clone();
assert_eq!(p2.kind, Kind::Sign);
assert_eq!(p2.detail["text"], json!("hello"));
assert_eq!(p2.request["digest"].as_str().unwrap().len(), 66);
let typed = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "name": "Igneum Swap", "chainId": 4463 }, "message": { "name": "Cow" } });
let Reply::Pending(id3) = b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, typed.to_string()]), &ask(true, true), now, 1.0) else { panic!() };
let p3 = b.pending.iter().find(|p| p.id == id3).unwrap().clone();
assert_eq!(p3.kind, Kind::Typed);
assert_eq!(p3.detail["domain"], json!("Igneum Swap"));
assert_eq!(p3.detail["primary_type"], json!("Person"));
let other_chain = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "chainId": 1 }, "message": { "name": "Cow" } });
assert!(matches!(b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, other_chain]), &ask(true, true), now, 1.0), Reply::Error(E_CHAIN, _)));
assert_eq!(b.pending_json(true).as_array().unwrap().len(), 3);
assert_eq!(b.pending_json(true)[0]["confirm_needed"], json!(true));
// the window's confirm, the engine's resolve, the page's read
b.decide(&id2, true, now).unwrap();
b.resolve(&id2, Ok(json!("0xsig")), now);
assert_eq!(b.poll("https://igneum.network", &id2), Reply::Result(json!("0xsig")));
assert_eq!(b.open().len(), 2);
}
#[test]
fn an_unanswered_request_expires_and_a_read_result_is_dropped() {
let mut b = Bridge::new();
let t0 = Instant::now();
let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 1.0) else { panic!() };
assert!(b.expire(t0 + PENDING_TTL / 2).is_empty());
let gone = b.expire(t0 + PENDING_TTL + Duration::from_secs(1));
assert_eq!(gone.len(), 1);
assert!(matches!(b.poll("https://igneum.network", &id), Reply::Error(E_REJECTED, m) if m.contains("expired")));
let Reply::Pending(id2) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 2.0) else { panic!() };
b.decide(&id2, false, t0).unwrap();
b.expire(t0 + RESULT_TTL + Duration::from_secs(1));
assert!(matches!(b.poll("https://igneum.network", &id2), Reply::Error(E_INTERNAL, _)), "an unread result is gone after RESULT_TTL");
}
}

View file

@ -0,0 +1,334 @@
//! EIP-712 typed data hashing for the page bridge (8 October 2026): the digest a page asks the wallet to sign with
//! eth_signTypedData_v4, computed here so the window can show the domain and the primary type and the key never
//! leaves the engine. Supports the atomic types (uintN, intN, bytesN, bool, address), the dynamic ones (string,
//! bytes), arrays (fixed and dynamic) and nested structs; the domain's fields are the ones present in `domain`
//! (name, version, chainId, verifyingContract, salt) when `types` carries no EIP712Domain of its own.
use serde_json::Value;
use sha3::{Digest, Keccak256};
pub struct Typed {
pub digest: [u8; 32],
pub domain_name: String,
pub primary_type: String,
pub chain_id: Option<u128>,
}
fn keccak(b: &[u8]) -> [u8; 32] {
Keccak256::digest(b).into()
}
/// A 256-bit unsigned number from a JSON number, a decimal string or a 0x hex string, big-endian in 32 bytes.
pub fn u256_bytes(v: &Value) -> Result<[u8; 32], String> {
let mut out = [0u8; 32];
match v {
Value::Number(n) => {
let x = n.as_u64().ok_or("a number must be a whole non-negative number")?;
out[24..].copy_from_slice(&x.to_be_bytes());
Ok(out)
}
Value::String(s) => {
let s = s.trim();
if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
if h.is_empty() || h.len() > 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("not a 256-bit hex number: {s}"));
}
let padded = format!("{:0>64}", h);
for i in 0..32 {
out[i] = u8::from_str_radix(&padded[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())?;
}
Ok(out)
} else {
if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("not a decimal number: {s}"));
}
// schoolbook base-10 into 32 big-endian bytes
for c in s.bytes() {
let mut carry = (c - b'0') as u32;
for i in (0..32).rev() {
let x = out[i] as u32 * 10 + carry;
out[i] = (x & 0xff) as u8;
carry = x >> 8;
}
if carry != 0 {
return Err("number over 256 bits".into());
}
}
Ok(out)
}
}
_ => Err("not a number".into()),
}
}
fn i256_bytes(v: &Value) -> Result<[u8; 32], String> {
let neg = match v {
Value::Number(n) => n.as_i64().map(|x| x < 0).unwrap_or(false),
Value::String(s) => s.trim().starts_with('-'),
_ => false,
};
if !neg {
return u256_bytes(v);
}
let mag = match v {
Value::Number(n) => Value::String((n.as_i64().unwrap().unsigned_abs()).to_string()),
Value::String(s) => Value::String(s.trim()[1..].to_string()),
_ => unreachable!(),
};
let m = u256_bytes(&mag)?;
// two's complement: invert and add one
let mut out = [0u8; 32];
let mut carry = 1u16;
for i in (0..32).rev() {
let x = (!m[i]) as u16 + carry;
out[i] = (x & 0xff) as u8;
carry = x >> 8;
}
Ok(out)
}
fn hex_bytes(s: &str) -> Result<Vec<u8>, String> {
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).ok_or("bytes must be 0x hex")?;
if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("bytes must be even-length hex".into());
}
(0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())).collect()
}
fn is_struct(types: &Value, t: &str) -> bool {
types.get(t).map(|v| v.is_array()).unwrap_or(false)
}
fn base_type(t: &str) -> &str {
match t.find('[') {
Some(i) => &t[..i],
None => t,
}
}
/// The struct types `t` depends on, `t` first, the rest sorted by name (EIP-712 encodeType).
fn dependencies(types: &Value, t: &str) -> Result<Vec<String>, String> {
let mut found: Vec<String> = Vec::new();
let mut todo = vec![t.to_string()];
while let Some(cur) = todo.pop() {
if found.contains(&cur) {
continue;
}
let fields = types.get(&cur).and_then(|v| v.as_array()).ok_or(format!("unknown type {cur}"))?;
found.push(cur.clone());
for f in fields {
let ft = f.get("type").and_then(|v| v.as_str()).ok_or("a field without a type")?;
let b = base_type(ft);
if is_struct(types, b) && !found.contains(&b.to_string()) {
todo.push(b.to_string());
}
}
}
let mut rest: Vec<String> = found.iter().skip(1).cloned().collect();
rest.sort();
let mut out = vec![found[0].clone()];
out.extend(rest);
Ok(out)
}
pub fn encode_type(types: &Value, t: &str) -> Result<String, String> {
let mut s = String::new();
for name in dependencies(types, t)? {
let fields = types.get(&name).and_then(|v| v.as_array()).ok_or("type")?;
s.push_str(&name);
s.push('(');
let mut first = true;
for f in fields {
if !first {
s.push(',');
}
first = false;
s.push_str(f.get("type").and_then(|v| v.as_str()).ok_or("field type")?);
s.push(' ');
s.push_str(f.get("name").and_then(|v| v.as_str()).ok_or("field name")?);
}
s.push(')');
}
Ok(s)
}
pub fn type_hash(types: &Value, t: &str) -> Result<[u8; 32], String> {
Ok(keccak(encode_type(types, t)?.as_bytes()))
}
fn encode_value(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> {
if let Some(i) = t.find('[') {
// an array: keccak of the concatenated encodings of its elements
let inner = &t[..i];
let rest = &t[i + 1..];
let items = v.as_array().ok_or(format!("{t}: not an array"))?;
if let Some(n) = rest.strip_suffix(']').and_then(|n| if n.is_empty() { None } else { n.parse::<usize>().ok() }) {
if items.len() != n {
return Err(format!("{t}: {} items, {n} expected", items.len()));
}
}
let tail = &rest[rest.find(']').map(|j| j + 1).unwrap_or(rest.len())..];
let elem_type = format!("{inner}{tail}");
let mut cat = Vec::new();
for it in items {
cat.extend_from_slice(&encode_value(types, &elem_type, it)?);
}
return Ok(keccak(&cat));
}
if is_struct(types, t) {
return hash_struct(types, t, v);
}
match t {
"string" => Ok(keccak(v.as_str().ok_or("string expected")?.as_bytes())),
"bytes" => Ok(keccak(&hex_bytes(v.as_str().ok_or("bytes expected")?)?)),
"bool" => {
let mut out = [0u8; 32];
out[31] = if v.as_bool().ok_or("bool expected")? { 1 } else { 0 };
Ok(out)
}
"address" => {
let b = hex_bytes(v.as_str().ok_or("address expected")?)?;
if b.len() != 20 {
return Err("an address is 20 bytes".into());
}
let mut out = [0u8; 32];
out[12..].copy_from_slice(&b);
Ok(out)
}
_ if t.starts_with("uint") => {
let bits: usize = t[4..].parse().map_err(|_| format!("bad type {t}"))?;
if bits == 0 || bits > 256 || bits % 8 != 0 {
return Err(format!("bad type {t}"));
}
u256_bytes(v)
}
_ if t.starts_with("int") => {
let bits: usize = t[3..].parse().map_err(|_| format!("bad type {t}"))?;
if bits == 0 || bits > 256 || bits % 8 != 0 {
return Err(format!("bad type {t}"));
}
i256_bytes(v)
}
_ if t.starts_with("bytes") => {
let n: usize = t[5..].parse().map_err(|_| format!("bad type {t}"))?;
if n == 0 || n > 32 {
return Err(format!("bad type {t}"));
}
let b = hex_bytes(v.as_str().ok_or("bytes expected")?)?;
if b.len() != n {
return Err(format!("{t}: {} bytes given", b.len()));
}
let mut out = [0u8; 32];
out[..n].copy_from_slice(&b);
Ok(out)
}
_ => Err(format!("unsupported type {t}")),
}
}
pub fn hash_struct(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> {
let fields = types.get(t).and_then(|x| x.as_array()).ok_or(format!("unknown type {t}"))?;
let obj = v.as_object().ok_or(format!("{t}: an object expected"))?;
let mut enc = Vec::new();
enc.extend_from_slice(&type_hash(types, t)?);
for f in fields {
let name = f.get("name").and_then(|x| x.as_str()).ok_or("field name")?;
let ft = f.get("type").and_then(|x| x.as_str()).ok_or("field type")?;
let fv = obj.get(name).ok_or(format!("{t}.{name} is missing"))?;
enc.extend_from_slice(&encode_value(types, ft, fv)?);
}
Ok(keccak(&enc))
}
/// The domain type when `types` has none: the fields present in `domain`, in the canonical order.
fn domain_types(domain: &Value) -> Value {
let order = [("name", "string"), ("version", "string"), ("chainId", "uint256"), ("verifyingContract", "address"), ("salt", "bytes32")];
let mut v = Vec::new();
for (n, t) in order {
if domain.get(n).is_some() {
v.push(serde_json::json!({ "name": n, "type": t }));
}
}
Value::Array(v)
}
/// The EIP-712 digest of a typed-data object ({types, primaryType, domain, message}).
pub fn hash(typed: &Value) -> Result<Typed, String> {
let mut types = typed.get("types").cloned().ok_or("typed data without types")?;
let domain = typed.get("domain").cloned().unwrap_or(Value::Object(Default::default()));
if !types.get("EIP712Domain").map(|v| v.is_array()).unwrap_or(false) {
types["EIP712Domain"] = domain_types(&domain);
}
let primary = typed.get("primaryType").and_then(|v| v.as_str()).ok_or("typed data without primaryType")?.to_string();
let message = typed.get("message").cloned().ok_or("typed data without message")?;
let ds = hash_struct(&types, "EIP712Domain", &domain)?;
let ms = if primary == "EIP712Domain" { None } else { Some(hash_struct(&types, &primary, &message)?) };
let mut pre = vec![0x19, 0x01];
pre.extend_from_slice(&ds);
if let Some(m) = ms {
pre.extend_from_slice(&m);
}
let chain_id = domain.get("chainId").and_then(|v| u256_bytes(v).ok()).map(|b| {
let mut x = 0u128;
for byte in &b[16..] {
x = (x << 8) | *byte as u128;
}
x
});
Ok(Typed { digest: keccak(&pre), domain_name: domain.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), primary_type: primary, chain_id })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// The specification's own example (EIP-712, "Mail" from Cow to Bob): the three known hashes.
fn mail() -> Value {
json!({
"types": {
"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
"Person": [{"name": "name", "type": "string"}, {"name": "wallet", "type": "address"}],
"Mail": [{"name": "from", "type": "Person"}, {"name": "to", "type": "Person"}, {"name": "contents", "type": "string"}]
},
"primaryType": "Mail",
"domain": {"name": "Ether Mail", "version": "1", "chainId": 1, "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},
"message": {"from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, "to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"}, "contents": "Hello, Bob!"}
})
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn the_specifications_mail_example_hashes_as_published() {
let m = mail();
assert_eq!(encode_type(&m["types"], "Mail").unwrap(), "Mail(Person from,Person to,string contents)Person(string name,address wallet)");
assert_eq!(hex(&hash_struct(&m["types"], "EIP712Domain", &m["domain"]).unwrap()), "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f"); // no-secrets-ok: the EIP-712 specification's published example hash
assert_eq!(hex(&hash_struct(&m["types"], "Mail", &m["message"]).unwrap()), "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e"); // no-secrets-ok: the EIP-712 specification's published example hash
let t = hash(&m).unwrap();
assert_eq!(hex(&t.digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest
assert_eq!(t.domain_name, "Ether Mail");
assert_eq!(t.primary_type, "Mail");
assert_eq!(t.chain_id, Some(1));
}
#[test]
fn a_domain_without_declared_types_takes_the_fields_present_in_order() {
let mut m = mail();
m["types"].as_object_mut().unwrap().remove("EIP712Domain");
assert_eq!(hex(&hash(&m).unwrap().digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest
}
#[test]
fn numbers_arrays_and_bytes_encode() {
assert_eq!(hex(&u256_bytes(&json!("115792089237316195423570985008687907853269984665640564039457584007913129639935")).unwrap()), "f".repeat(64));
assert_eq!(hex(&u256_bytes(&json!("0x1f")).unwrap()), format!("{:0>64}", "1f"));
assert_eq!(hex(&u256_bytes(&json!(31)).unwrap()), format!("{:0>64}", "1f"));
assert_eq!(hex(&i256_bytes(&json!(-1)).unwrap()), "f".repeat(64));
assert!(u256_bytes(&json!("1157920892373161954235709850086879078532699846656405640394575840079131296399350")).is_err());
let types = json!({ "T": [{"name": "xs", "type": "uint8[]"}, {"name": "b", "type": "bytes4"}, {"name": "ok", "type": "bool"}] });
let one = hash_struct(&types, "T", &json!({ "xs": [1, 2], "b": "0x01020304", "ok": true })).unwrap();
let two = hash_struct(&types, "T", &json!({ "xs": [2, 1], "b": "0x01020304", "ok": true })).unwrap();
assert_ne!(one, two);
assert!(hash_struct(&types, "T", &json!({ "xs": [1], "b": "0x0102", "ok": true })).is_err(), "bytes4 with two bytes is refused");
assert!(hash_struct(&types, "T", &json!({ "b": "0x01020304", "ok": true })).is_err(), "a missing field is refused");
}
}

View file

@ -40,6 +40,17 @@ pub struct Settings {
/// the first arrival after the person opened the app may raise the Touch ID sheet once, by itself
#[serde(default = "yes")]
pub ask_on_open: bool,
/// the page bridge (0.1.6): websites may connect through the loopback port, each after its own approval
#[serde(default = "yes")]
pub bridge_on: bool,
/// the sites the person approved in the window (origin, unix s)
#[serde(default)]
pub sites: Vec<Site>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Site {
pub origin: String,
pub approved_at: f64,
}
fn yes() -> bool {
true
@ -48,7 +59,7 @@ fn yes() -> bool {
pub const IDLE_CHOICES: [u64; 5] = [1, 5, 15, 60, 0];
impl Default for Settings {
fn default() -> Settings {
Settings { auto_update: true, display_name: String::new(), idle_lock: true, idle_lock_min: Some(biometric::IDLE_LOCK_MIN), ask_on_open: true }
Settings { auto_update: true, display_name: String::new(), idle_lock: true, idle_lock_min: Some(biometric::IDLE_LOCK_MIN), ask_on_open: true, bridge_on: true, sites: Vec::new() }
}
}
impl Settings {
@ -147,6 +158,10 @@ pub struct Shared {
gate: Mutex<Gate>,
/// the last time the window reported a person at it (the idle lock)
last_activity: Mutex<Instant>,
/// the page bridge (src/bridge.rs): the requests pages made and the window has to answer
pub bridge: Mutex<crate::bridge::Bridge>,
/// whether the bridge listener is up, and why not
pub bridge_listening: Mutex<(bool, u16, String)>,
}
/// Counts one /api/send from entry to exit, whatever the outcome.
@ -211,6 +226,8 @@ impl Shared {
host_token,
gate: Mutex::new(Gate::new()),
last_activity: Mutex::new(Instant::now()),
bridge: Mutex::new(crate::bridge::Bridge::new()),
bridge_listening: Mutex::new((false, crate::bridge::BRIDGE_PORT, String::from("not started"))),
}
}
@ -265,7 +282,261 @@ impl Shared {
st.history = h.entries.clone();
st.scanned_to = h.scanned_to;
}
serde_json::to_value(st).unwrap_or(json!({}))
let mut v = serde_json::to_value(st).unwrap_or(json!({}));
v["bridge"] = self.bridge_json();
v
}
/// The page bridge as the window reads it: the listener, the open requests, the approved sites.
pub fn bridge_json(&self) -> Value {
let (listening, port, why) = self.bridge_listening.lock().unwrap().clone();
let settings = self.settings.lock().unwrap();
let b = self.bridge.lock().unwrap();
let sites: Vec<Value> = settings.sites.iter().map(|x| json!({ "origin": x.origin, "approved_at": x.approved_at, "last_seen": b.last_seen.get(&x.origin).copied() })).collect();
json!({ "on": settings.bridge_on, "listening": listening, "port": port, "reason": why, "pending": b.pending_json(self.enrolled()), "sites": sites })
}
pub fn site_approved(&self, origin: &str) -> bool {
let settings = self.settings.lock().unwrap();
settings.bridge_on && settings.sites.iter().any(|x| x.origin == origin)
}
fn chain_id_now(&self) -> u64 {
let st = self.state.lock().unwrap();
if st.node.chain_id > 0 { st.node.chain_id } else { self.evm.lock().unwrap().as_ref().and_then(|e| e.chain_id().ok()).unwrap_or(0) }
}
// ---- the page bridge (src/bridge.rs): a page's call, the window's decision --------------------------------
/// A page's JSON-RPC call: {result} | {error: {code, message}} | {pending: id}.
pub fn bridge_request(&self, origin: &str, method: &str, params: &Value) -> Value {
use crate::bridge::{Kind, Reply};
let now = Instant::now();
let now_unix = igneum_common::platform::unix_now_f();
let enrolled = self.enrolled();
let on = self.settings.lock().unwrap().bridge_on;
if !on {
return json!({ "error": { "code": crate::bridge::E_DISCONNECTED, "message": "websites are switched off in the Igneum Wallet's settings" } });
}
let address = self.address();
let ask = crate::bridge::Ask { address: &address, unlocked: self.unlocked(), chain_id: self.chain_id_now(), approved: self.site_approved(origin), listening: true };
let reply = self.bridge.lock().unwrap().request(origin, method, params, &ask, now, now_unix);
match reply {
Reply::Result(v) => json!({ "result": v }),
Reply::Error(c, m) => json!({ "error": { "code": c, "message": m } }),
Reply::Proxy => {
match self.evm().and_then(|e| e.call(method, params.clone())) {
Ok(v) => json!({ "result": v }),
Err(e) => json!({ "error": { "code": crate::bridge::E_INTERNAL, "message": e } }),
}
}
Reply::Pending(id) => {
// a send needs its price now, so the window shows the fee and the page learns of a refusal at once
let p = self.bridge.lock().unwrap().pending.iter().find(|p| p.id == id).cloned();
if let Some(p) = p {
if p.kind == Kind::Send && p.done.is_none() {
match self.bridge_price(&p) {
Ok((request, detail, reason, bound)) => {
let mut b = self.bridge.lock().unwrap();
if let Some(q) = b.pending.iter_mut().find(|q| q.id == id) {
q.request = request;
q.detail = detail;
}
drop(b);
if enrolled {
let nonce = self.gate.lock().unwrap().issue("bridge", &bound, &reason, Instant::now());
self.bridge.lock().unwrap().set_challenge(&id, &nonce, &reason);
}
}
Err(e) => {
self.bridge.lock().unwrap().resolve(&id, Err((crate::bridge::E_INTERNAL, e)), Instant::now());
}
}
} else if (p.kind == Kind::Sign || p.kind == Kind::Typed) && enrolled {
let reason = biometric::clip_reason(&format!("Sign for {}", origin.trim_start_matches("https://").trim_start_matches("http://")));
let nonce = self.gate.lock().unwrap().issue("bridge", &id, &reason, Instant::now());
self.bridge.lock().unwrap().set_challenge(&id, &nonce, &reason);
}
if p.kind == Kind::Connect {
self.event("info", &format!("{} asks to connect: answer it in the wallet window", origin));
} else {
self.event("info", &format!("{} asks you to {}: answer it in the wallet window", origin, match p.kind { Kind::Send => "send a transaction", Kind::Sign => "sign a message", Kind::Typed => "sign typed data", Kind::Connect => "connect" }));
}
}
json!({ "pending": id })
}
}
}
/// Prices a page's transaction: the nonce, the fees, the gas (the page's or the node's estimate) and the balance
/// check, as a quote does. Returns the request the engine signs at Confirm, the detail the window shows, the
/// biometric prompt's line and its binding.
fn bridge_price(&self, p: &crate::bridge::Pending) -> Result<(Value, Value, String, String), String> {
let evm = self.evm()?;
let me = self.address();
let to = p.request["to"].as_str().unwrap_or("").to_string();
let value: u128 = p.request["value"].as_str().unwrap_or("0").parse().map_err(|_| "value")?;
let data = p.request["data"].as_str().unwrap_or("0x").to_string();
let chain_id = evm.chain_id()?;
let nonce = evm.nonce(&me)?;
let (base, tip) = evm.fees()?;
let gas = match p.request["gas"].as_u64() {
Some(g) if g >= 21_000 => g,
_ => evm.estimate_gas_call(&me, &to, value, &data).map_err(|e| format!("the node refused to estimate the gas: {e}"))?.saturating_mul(12) / 10,
};
let max_fee = base.saturating_mul(2).saturating_add(tip).max(1);
let fee_max = (gas as u128).saturating_mul(max_fee);
let total = value.checked_add(fee_max).ok_or("amount too large")?;
let balance = evm.balance(&me)?;
if total > balance {
return Err(format!("not enough IGN: {} needed with the fee, {} in the wallet", crate::evm::ign(total, 6), crate::evm::ign(balance, 6)));
}
let request = json!({ "to": to, "value": value.to_string(), "data": data, "gas": gas, "max_fee": max_fee.to_string(), "tip": tip.to_string(), "chain_id": chain_id, "nonce": nonce });
let mut detail = p.detail.clone();
detail["gas"] = json!(gas);
detail["fee_max"] = json!(fee_max.to_string());
detail["fee_max_ign"] = json!(crate::evm::ign(fee_max, 9));
detail["value_ign"] = json!(crate::evm::ign(value, 18));
detail["total_max_ign"] = json!(crate::evm::ign(total, 9));
let reason = biometric::send_reason(&crate::evm::ign(value, 4), &keys::checksum(&to));
let bound = biometric::send_binding(&to, &value.to_string(), nonce, chain_id);
Ok((request, detail, reason, bound))
}
/// The window's decision on a page's request. Approve: a connect stores the site and answers the address; a
/// send signs and sends the priced transaction (the biometric nonce taken first when enrolled); a message or
/// typed data is signed. The page reads the outcome through its poll; the window gets it here too.
pub fn bridge_decide(&self, id: &str, ok: bool, nonce: Option<&str>) -> Result<Value, String> {
use crate::bridge::Kind;
let now = Instant::now();
let p = self.bridge.lock().unwrap().decide(id, ok, now)?;
if !ok {
self.event("info", &format!("{} declined for {}", match p.kind { Kind::Connect => "connection", Kind::Send => "transaction", Kind::Sign => "message", Kind::Typed => "typed data" }, p.origin));
return Ok(json!({ "ok": true, "declined": true }));
}
self.touch_activity();
let outcome: Result<Value, (i64, String)> = match p.kind {
Kind::Connect => {
let mut settings = self.settings.lock().unwrap();
if !settings.sites.iter().any(|x| x.origin == p.origin) {
settings.sites.push(Site { origin: p.origin.clone(), approved_at: igneum_common::platform::unix_now_f() });
settings.save(&self.paths.settings);
}
drop(settings);
self.event("ok", &format!("{} connected", p.origin));
if self.unlocked() { Ok(json!([self.address()])) } else { Err((crate::bridge::E_UNAUTHORIZED, "the wallet is locked".into())) }
}
Kind::Send => {
if self.enrolled() {
let bound = biometric::send_binding(p.request["to"].as_str().unwrap_or(""), p.request["value"].as_str().unwrap_or(""), p.request["nonce"].as_u64().unwrap_or(0), p.request["chain_id"].as_u64().unwrap_or(0));
if let Err(e) = self.take_nonce(nonce.unwrap_or(""), "bridge", &bound) {
self.bridge.lock().unwrap().resolve(id, Err((crate::bridge::E_REJECTED, format!("refused: {e}"))), now);
return Err(format!("refused: {e}"));
}
}
self.bridge_send(&p).map_err(|e| (crate::bridge::E_INTERNAL, e))
}
Kind::Sign | Kind::Typed => {
if self.enrolled() {
if let Err(e) = self.take_nonce(nonce.unwrap_or(""), "bridge", &p.id) {
self.bridge.lock().unwrap().resolve(id, Err((crate::bridge::E_REJECTED, format!("refused: {e}"))), now);
return Err(format!("refused: {e}"));
}
}
let digest_hex = p.request["digest"].as_str().unwrap_or("");
let d = keys::unhex(digest_hex.trim_start_matches("0x")).filter(|d| d.len() == 32).ok_or("digest")?;
let mut h = [0u8; 32];
h.copy_from_slice(&d);
let sig = {
let guard = self.secret.lock().unwrap();
let s = guard.as_ref().ok_or("locked")?;
let raw = keys::unhex(&s.private_key).ok_or("key")?;
let mut k = [0u8; 32];
k.copy_from_slice(&raw);
let r = crate::tx::sign_digest(&h, &k);
k.zeroize();
r
};
match sig {
Ok(sig) => {
self.event("ok", &format!("signed {} for {}", if p.kind == Kind::Sign { "a message" } else { "typed data" }, p.origin));
Ok(json!(format!("0x{}", keys::hex(&sig))))
}
Err(e) => Err((crate::bridge::E_INTERNAL, e)),
}
}
};
let answer = match &outcome {
Ok(v) => json!({ "ok": true, "result": v }),
Err((c, m)) => json!({ "ok": false, "error": m, "code": c }),
};
self.bridge.lock().unwrap().resolve(id, outcome, Instant::now());
if answer["ok"] == json!(false) {
return Err(answer["error"].as_str().unwrap_or("failed").to_string());
}
Ok(answer)
}
/// Signs and sends a page's priced transaction (the same path as the window's send, with the call's data).
fn bridge_send(&self, p: &crate::bridge::Pending) -> Result<Value, String> {
self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let _guard = SendGuard(self);
let r = &p.request;
let to = r["to"].as_str().unwrap_or("").to_ascii_lowercase();
if !keys::valid_address(&to) || to == "0x0000000000000000000000000000000000000000" {
return Err("refused: bad or zero address".into());
}
let value: u128 = r["value"].as_str().unwrap_or("0").parse().map_err(|_| "bad value")?;
let max_fee: u128 = r["max_fee"].as_str().unwrap_or("0").parse().map_err(|_| "bad fee")?;
let tip: u128 = r["tip"].as_str().unwrap_or("0").parse().map_err(|_| "bad tip")?;
let gas = r["gas"].as_u64().ok_or("gas")?;
let data = keys::unhex(r["data"].as_str().unwrap_or("0x").trim_start_matches("0x")).unwrap_or_default();
let evm = self.evm()?;
let me = self.address();
let chain_id = evm.chain_id()?;
if chain_id != r["chain_id"].as_u64().unwrap_or(0) {
return Err("the chain id changed under the request; ask again".into());
}
let nonce = evm.nonce(&me)?;
let mut to20 = [0u8; 20];
to20.copy_from_slice(&keys::unhex(&to).ok_or("address")?);
let t = crate::tx::Transfer { chain_id, nonce, max_priority_fee: tip, max_fee, gas_limit: gas, to: to20, value, data };
let signed = {
let guard = self.secret.lock().unwrap();
let s = guard.as_ref().ok_or("locked")?;
let raw = keys::unhex(&s.private_key).ok_or("key")?;
let mut k = [0u8; 32];
k.copy_from_slice(&raw);
let res = crate::tx::sign(&t, &k);
k.zeroize();
res?
};
if crate::tx::recover_from(&signed.raw).as_deref() != Some(me.as_str()) {
return Err("refused: the signed transaction does not recover to this wallet".into());
}
let hash = evm.send_raw(&signed.raw)?;
let e = Entry { hash: hash.clone(), kind: if to == me { "self".into() } else { "sent".into() }, block: 0, block_hash: String::new(), from: me.clone(), to: to.clone(), value: value.to_string(), fee: String::new(), ok: true, time: igneum_common::platform::unix_now(), finality: "pending".into(), checkpoint: None, note: format!("for {}", p.origin) };
self.event("ok", &format!("sent {} IGN to {} for {} ({})", crate::evm::ign(value, 6), keys::checksum(&to), p.origin, &hash[..10]));
self.send(Cmd::Sent(e));
Ok(json!({ "hash": hash }))
}
pub fn bridge_sites_remove(&self, origin: &str) -> Result<Value, String> {
let mut settings = self.settings.lock().unwrap();
settings.sites.retain(|x| x.origin != origin);
settings.save(&self.paths.settings);
drop(settings);
self.event("info", &format!("{} disconnected", origin));
Ok(json!({ "ok": true }))
}
pub fn set_bridge_on(&self, on: bool) -> Result<Value, String> {
let mut settings = self.settings.lock().unwrap();
settings.bridge_on = on;
settings.save(&self.paths.settings);
Ok(json!({ "ok": true }))
}
/// The run loop's sweep: expired requests end with 4001 and are logged.
pub fn bridge_sweep(&self) {
let gone = self.bridge.lock().unwrap().expire(Instant::now());
for p in gone {
self.event("info", &format!("{}'s request expired unanswered", p.origin));
}
}
pub fn wrapper_state(&self) -> Value {
let st = self.state.lock().unwrap();
@ -900,6 +1171,7 @@ impl Engine {
self.last_scan = Instant::now();
self.scan();
}
self.shared.bridge_sweep();
if let Some(min) = self.shared.idle_lock_due() {
self.shared.lock();
self.shared.event("info", &format!("locked after {} idle", if min == 1 { "1 minute".to_string() } else { format!("{min} minutes") }));

View file

@ -63,6 +63,15 @@ impl Evm {
pub fn estimate_gas(&self, from: &str, to: &str, value: u128) -> Result<u64, String> {
q(&self.call("eth_estimateGas", json!([{ "from": from, "to": to, "value": hexq(value) }]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into())
}
/// The gas a call needs, with its data (the page bridge's contract calls; the node prices proving gas inside
/// execution gas on Devnet 3, so the node's own estimate is the one to use).
pub fn estimate_gas_call(&self, from: &str, to: &str, value: u128, data: &str) -> Result<u64, String> {
let mut call = json!({ "from": from, "to": to, "value": hexq(value) });
if data.len() > 2 {
call["data"] = json!(data);
}
q(&self.call("eth_estimateGas", json!([call]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into())
}
pub fn send_raw(&self, raw: &[u8]) -> Result<String, String> {
let v = self.call("eth_sendRawTransaction", json!([crate::tx::hex0x(raw)]))?;
v.as_str().map(|s| s.to_string()).ok_or("eth_sendRawTransaction: no hash".into())

View file

@ -12,6 +12,8 @@
//! IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS, IGNEUM_WALLET_UPDATE_MANIFEST, IGNEUM_WALLET_IDLE_LOCK_S.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod bridge;
mod eip712;
mod engine;
mod evm;
mod finality;
@ -85,6 +87,7 @@ fn main() {
std::process::exit(1);
}
};
server::start_bridge(shared.clone());
let url = format!("http://127.0.0.1:{port}/t/{token}/");
let url_file = shared.paths.app_dir.join("wallet.url");
let _ = std::fs::write(&url_file, &url);

View file

@ -2,7 +2,7 @@
//! path under `/t/<token>/` (igneum_common::http). Mutating calls must come from the window itself (same origin).
use crate::engine::{Cmd, Shared};
use igneum_common::http::{from_dashboard, json_resp, query_param, read_request, respond};
use igneum_common::http::{from_dashboard, json_resp, query_param, read_request, respond, respond_with};
use serde_json::{json, Value};
use std::net::TcpStream;
use std::sync::Arc;
@ -30,6 +30,61 @@ pub fn start(shared: Arc<Shared>) -> std::io::Result<u16> {
Ok(port)
}
/// The page bridge's listener on its fixed port (src/bridge.rs): GET /bridge/hello (the page's probe), POST /bridge/rpc
/// (JSON-RPC with the page's Origin and the X-Igneum-Bridge header), and the CORS preflight that must come first.
/// Nothing under /t/<token>/ is served here, and nothing here reads the window's token.
pub fn start_bridge(shared: Arc<Shared>) {
let port = std::env::var("IGNEUM_WALLET_BRIDGE_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(crate::bridge::BRIDGE_PORT);
let s = shared.clone();
match igneum_common::http::serve_on(port, move |stream| handle_bridge(stream, s.clone())) {
Ok(()) => {
*shared.bridge_listening.lock().unwrap() = (true, port, String::new());
shared.log(&format!("page bridge listening on 127.0.0.1:{port} (websites connect here after your approval in the window)"));
}
Err(e) => {
*shared.bridge_listening.lock().unwrap() = (false, port, format!("port {port} is not free: {e}"));
shared.log(&format!("page bridge not listening: port {port} is not free ({e}); websites cannot connect until the wallet restarts with the port free"));
}
}
}
fn bridge_origin_ok(o: &str) -> bool {
o.starts_with("https://") || o.starts_with("http://localhost") || o.starts_with("http://127.0.0.1")
}
fn handle_bridge(mut stream: TcpStream, shared: Arc<Shared>) {
let Some(req) = read_request(&mut stream) else { return };
let origin = req.origin.clone().unwrap_or_default();
let cors: Vec<(&str, &str)> = if bridge_origin_ok(&origin) {
vec![("Access-Control-Allow-Origin", origin.as_str()), ("Vary", "Origin"), ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), ("Access-Control-Allow-Headers", "content-type, x-igneum-bridge"), ("Access-Control-Allow-Private-Network", "true"), ("Access-Control-Max-Age", "600")]
} else {
vec![]
};
match (req.method.as_str(), req.path.as_str()) {
("OPTIONS", _) => respond_with(&mut stream, 204, "text/plain", b"", &cors),
("GET", "/bridge/hello") => {
// the probe: the wallet is here, its version and chain; never an address
let v = json!({ "ok": true, "wallet": "igneum", "version": crate::engine::VERSION, "chain_id": shared.network_json()["chain_id"], "chain_id_hex": shared.network_json()["chain_id_hex"], "bridge": 1 });
respond_with(&mut stream, 200, "application/json; charset=utf-8", v.to_string().as_bytes(), &cors)
}
("POST", "/bridge/rpc") => {
if origin.is_empty() || !bridge_origin_ok(&origin) || !req.bridge_header {
respond_with(&mut stream, 403, "application/json; charset=utf-8", json!({ "error": { "code": crate::bridge::E_UNAUTHORIZED, "message": "a page bridge call carries its origin and the X-Igneum-Bridge header" } }).to_string().as_bytes(), &cors);
return;
}
let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({}));
let id = body.get("id").cloned().unwrap_or(Value::Null);
let method = body.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
let params = body.get("params").cloned().unwrap_or(json!([]));
let mut out = shared.bridge_request(&origin, &method, &params);
out["id"] = id;
out["jsonrpc"] = json!("2.0");
respond_with(&mut stream, 200, "application/json; charset=utf-8", out.to_string().as_bytes(), &cors)
}
_ => respond_with(&mut stream, 404, "text/plain", b"Igneum Wallet page bridge", &cors),
}
}
fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
let Some(req) = read_request(&mut stream) else { return };
if req.path == "/" {
@ -190,6 +245,10 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value, from_host: bool) -> R
"/api/biometric/confirm" => shared.confirm(&s("nonce").ok_or("nonce missing")?),
"/api/biometric/enrol/take" => shared.enrol_take(&s("token").ok_or("token missing")?),
"/api/biometric/enrolled" => shared.enrolled_set(&s("kind").unwrap_or_default()),
// ---- the page bridge: the window's decisions (src/bridge.rs) ----
"/api/bridge/decide" => shared.bridge_decide(&s("id").ok_or("id missing")?, b("ok").unwrap_or(false), s("nonce").as_deref()),
"/api/bridge/sites/remove" => shared.bridge_sites_remove(&s("origin").ok_or("origin missing")?),
"/api/bridge/on" => shared.set_bridge_on(b("on").ok_or("on missing")?),
"/api/refresh" => {
shared.send(Cmd::Refresh);
Ok(json!({ "ok": true }))

View file

@ -109,6 +109,45 @@ pub fn sign(t: &Transfer, private_key: &[u8; 32]) -> Result<Signed, String> {
Ok(Signed { raw, hash })
}
/// Signs a 32-byte digest (EIP-191 personal messages, EIP-712 typed data) with the raw private key: the 65-byte
/// r || s || v signature pages expect (v = 27 + recovery bit), low s, the recovered key checked before anything is
/// returned. The page bridge (8 October 2026).
pub fn sign_digest(h: &[u8; 32], private_key: &[u8; 32]) -> Result<[u8; 65], String> {
let sk = SigningKey::from_bytes(private_key.into()).map_err(|_| "invalid private key")?;
let (sig, rec): (Signature, RecoveryId) = sk.sign_prehash_recoverable(h).map_err(|e| e.to_string())?;
let (sig, rec) = match sig.normalize_s() {
Some(low) => (low, RecoveryId::from_byte(rec.to_byte() ^ 1).ok_or("recovery id")?),
None => (sig, rec),
};
let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).map_err(|e| format!("recovery check: {e}"))?;
if vk != *sk.verifying_key() {
return Err("the signature does not recover to the signing key".into());
}
let mut out = [0u8; 65];
out[..32].copy_from_slice(&sig.r().to_bytes());
out[32..64].copy_from_slice(&sig.s().to_bytes());
out[64] = 27 + rec.to_byte();
Ok(out)
}
/// The EIP-191 digest of a personal message: keccak("\x19Ethereum Signed Message:\n" + len + message).
pub fn personal_digest(message: &[u8]) -> [u8; 32] {
let mut pre = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes();
pre.extend_from_slice(message);
Keccak256::digest(&pre).into()
}
/// The address a 65-byte signature over `h` recovers to, lower-case 0x hex.
pub fn recover_digest(h: &[u8; 32], sig65: &[u8; 65]) -> Option<String> {
let v = sig65[64];
let rec = RecoveryId::from_byte(if v >= 27 { v - 27 } else { v })?;
let sig = Signature::from_slice(&sig65[..64]).ok()?;
let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).ok()?;
let pk = vk.to_encoded_point(false);
let h2: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into();
Some(format!("0x{}", h2[12..].iter().map(|b| format!("{b:02x}")).collect::<String>()))
}
/// A big-endian scalar (r, s: 32 bytes) as an RLP integer: leading zeros stripped, zero is the empty string.
pub fn rlp_scalar(b: &[u8], out: &mut Vec<u8>) {
let first = b.iter().position(|x| *x != 0);
@ -198,6 +237,25 @@ pub fn hex0x(b: &[u8]) -> String {
#[cfg(test)]
mod tests {
#[test]
fn a_personal_message_signature_recovers_to_the_signer_and_carries_v_27_or_28() {
let key = [7u8; 32];
let sk = SigningKey::from_bytes((&key).into()).unwrap();
let pk = sk.verifying_key().to_encoded_point(false);
let addr_h: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into();
let me = format!("0x{}", addr_h[12..].iter().map(|b| format!("{b:02x}")).collect::<String>());
let h = personal_digest(b"hello world");
// the prefix is the EIP-191 one: the same digest as keccak of the literal prefixed bytes
let mut lit = b"\x19Ethereum Signed Message:\n11hello world".to_vec();
let direct: [u8; 32] = Keccak256::digest(&lit).into();
lit.clear();
assert_eq!(h, direct);
let sig = sign_digest(&h, &key).unwrap();
assert!(sig[64] == 27 || sig[64] == 28);
assert_eq!(recover_digest(&h, &sig).as_deref(), Some(me.as_str()));
assert!(sig[32] < 0x80, "low s");
}
use super::*;
fn to20() -> [u8; 20] {

View file

@ -475,3 +475,15 @@ body.has-rail .toast{left:calc(50% + var(--rail) / 2)}
.lead{font-size:var(--t-lg)}
.step{padding:32px 0 32px}
}
/* the page bridge (0.1.6): a website's request as a card over the window; the Settings card's site rows */
.bridge-card{max-width:540px}
.bridge-amount{font-family:var(--head);font-weight:700;font-size:28px;color:var(--ember);margin:4px 0 8px;letter-spacing:-.01em}
.bridge-message{white-space:pre-wrap;word-break:break-word;max-height:220px;overflow:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.5;color:var(--ink-2);margin:6px 0 10px;text-align:left}
.bridge-done{margin-top:12px;text-align:left}
.bridge-done .kv{margin-top:8px}
#bridge-origin{margin-top:6px}
.sites{display:flex;flex-direction:column}
.sites .srow .sw-text b{font-weight:600}
.sites .srow .sw-text .dim{display:block;margin-top:2px}
.lock-line.bridge-waiting{color:var(--molten);margin-top:4px}

View file

@ -191,6 +191,46 @@ var View = (function () {
// ---------- updates: the strip line and the card's note ----------
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
// ---- the page bridge (0.1.6, 8 October 2026): what the window says for a page's request; the facts come from the
// engine (src/bridge.rs pending_json), never from the page ----
function hostOf(origin) { return String(origin || '').replace(/^https?:\/\//, '').replace(/\/.*$/, ''); }
function bridgeWords(p, bioName) {
p = p || {}; var host = hostOf(p.origin), out = { id: p.id, kind: p.kind, host: host, title: '', amount: '', message: '', lines: [], yes: '', no: 'Decline', confirm: !!p.confirm_needed, nonce: p.confirm_nonce || '' };
var withBio = p.confirm_needed && bioName ? ' with ' + bioName : '';
if (p.kind === 'connect') {
out.title = host + ' wants to connect';
out.lines = ['It will see your address and may ask you to sign or send. Nothing leaves without your word here.', 'You can disconnect it any time in Settings.'];
out.yes = 'Connect';
} else if (p.kind === 'send') {
out.title = host + ' asks you to send';
out.amount = (p.value_ign !== undefined && p.value_ign !== null ? p.value_ign : ign(p.value || '0', 18)) + ' IGN';
var call = p.data_len > 0 ? 'a contract call (' + withCommas(p.data_len) + ' bytes' + (p.selector ? ', ' + p.selector : '') + ')' : 'a plain transfer';
out.lines = ['To ' + shortHex(p.to_display || p.to || '', 6, 4) + ', ' + call + '.'];
out.lines.push(p.fee_max_ign ? 'Fee up to ' + p.fee_max_ign + ' IGN (' + withCommas(p.gas) + ' gas). Total up to ' + p.total_max_ign + ' IGN.' : 'Pricing the fee with the node.');
out.yes = 'Send' + withBio;
} else if (p.kind === 'sign') {
out.title = host + ' asks you to sign a message';
out.message = p.text !== null && p.text !== undefined ? p.text : (withCommas(p.bytes || 0) + ' bytes: ' + (p.hex || ''));
out.lines = ['Signing proves this wallet is yours. It moves no coins.'];
out.yes = 'Sign' + withBio;
} else if (p.kind === 'typed') {
out.title = host + ' asks you to sign typed data';
out.message = p.message || '';
out.lines = [(p.primary_type || 'Data') + (p.domain ? ' for ' + p.domain : '') + '. Read it before you sign: a permit can let a contract spend tokens.'];
out.yes = 'Sign' + withBio;
}
return out;
}
// the lock screen's line while a page waits
function bridgeWaiting(pending) { var l = pending || []; if (!l.length) return ''; var hosts = []; l.forEach(function (p) { var h = hostOf(p.origin); if (hosts.indexOf(h) < 0) hosts.push(h); }); return (hosts.length === 1 ? hosts[0] + ' is' : hosts.length + ' sites are') + ' waiting: unlock to answer ' + (hosts.length === 1 ? 'it' : 'them'); }
// the Settings card's rows
function siteRows(sites, now) { return (sites || []).map(function (x) { return { origin: x.origin, host: hostOf(x.origin), line: 'connected ' + ago(now - (x.approved_at || now)) + (x.last_seen ? ' · last call ' + ago(now - x.last_seen) : '') }; }); }
function bridgeStatus(b) {
b = b || {};
if (b.on === false) return 'Off. No website can connect; the switch turns it on.';
if (b.listening === false) return 'Not listening: ' + (b.reason || 'the port is not free') + '. Quit whatever holds the port and open the wallet again.';
return 'On. Websites you approve can connect through port ' + (b.port || 26811) + ' on this machine only.';
}
function updateLine(u) {
var v = 'Igneum Wallet ' + u.version;
if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' };
@ -220,7 +260,7 @@ var View = (function () {
return parts.join(' ');
}
return { PAGES: PAGES, page: page, withCommas: withCommas, shortHex: shortHex, ign: ign, ago: ago, timeWord: timeWord, balanceLine: balanceLine, moneyLine: moneyLine, nodeWords: nodeWords, sourceWords: sourceWords, verifyWords: verifyWords, nodeLine: nodeLine, nodeDetails: nodeDetails, pillWords: pillWords, kindWord: kindWord, whoLine: whoLine, stateWord: stateWord, rowModel: rowModel, needsNodeWord: needsNodeWord, gwei: gwei, feeWords: feeWords, sendAsk: sendAsk, bioSentence: bioSentence, idleSentence: idleSentence, updateLine: updateLine, updateNote: updateNote, cap: cap };
return { PAGES: PAGES, page: page, withCommas: withCommas, shortHex: shortHex, ign: ign, ago: ago, timeWord: timeWord, balanceLine: balanceLine, moneyLine: moneyLine, nodeWords: nodeWords, sourceWords: sourceWords, verifyWords: verifyWords, nodeLine: nodeLine, nodeDetails: nodeDetails, pillWords: pillWords, kindWord: kindWord, whoLine: whoLine, stateWord: stateWord, rowModel: rowModel, needsNodeWord: needsNodeWord, gwei: gwei, feeWords: feeWords, sendAsk: sendAsk, bioSentence: bioSentence, idleSentence: idleSentence, updateLine: updateLine, updateNote: updateNote, cap: cap, hostOf: hostOf, bridgeWords: bridgeWords, bridgeWaiting: bridgeWaiting, siteRows: siteRows, bridgeStatus: bridgeStatus };
})();
if (typeof module === 'object' && module && module.exports) { module.exports = { View: View }; }
@ -411,6 +451,7 @@ if (typeof document !== 'undefined') (function () {
$('ask-quit-yes').onclick = function () { $('ask-quit').hidden = true; post('/api/quit').catch(function () {}); toast('Quitting'); };
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
if (!$('bridge').hidden) { e.preventDefault(); if (bridgeDoneId) $('bridge-close').click(); else bridgeDecide(false); return; }
if (!$('upd').hidden) { e.preventDefault(); cardLater(); return; }
if (!$('ask-quit').hidden) { $('ask-quit').hidden = true; return; }
if (!$('ask-send').hidden) { cancelAsk(); return; }
@ -445,6 +486,7 @@ if (typeof document !== 'undefined') (function () {
if (forcedUpdate) s.update = sampleUpdate(forcedUpdate);
renderNotices(s);
renderUpdateCard(s);
renderBridge(s);
if (phase === 'unlock' || locking) renderLock();
$('seg-miner').disabled = !s.miner_wallet_present;
if (phase === 'home') {
@ -681,7 +723,84 @@ if (typeof document !== 'undefined') (function () {
};
// ---------- Settings ----------
// ---------- the page bridge (0.1.6): a website's request as a card, the Settings card ----------
var bridgeOpen = null, bridgeBusy = false, bridgeDoneId = null, bridgeSig = '';
function renderBridge(s) {
var br = s.bridge || {}, list = (br.pending || []), wrap = $('bridge');
var ul = $('unlock-bridge'); if (ul) { var wl = View.bridgeWaiting(list); ul.textContent = wl; ul.hidden = !wl; }
if (s.phase !== 'home') { if (!wrap.hidden) { wrap.hidden = true; bridgeOpen = null; bridgeSig = ''; } return; }
if (bridgeDoneId) return; // the sent view stays until Done
var p = list[0] || null;
if (!p) { if (!wrap.hidden) { wrap.hidden = true; bridgeOpen = null; bridgeSig = ''; } return; }
var words = View.bridgeWords(p, p.confirm_needed ? what() : ''), sig = JSON.stringify([p.id, words, p.confirm_nonce]);
if (sig === bridgeSig) return;
bridgeSig = sig; bridgeOpen = p;
setText('bridge-eyebrow', p.kind === 'connect' ? 'a website asks to connect' : p.kind === 'send' ? 'a website asks you to send' : 'a website asks you to sign');
setText('bridge-title', words.title);
$('bridge-amount').hidden = !words.amount; setText('bridge-amount', words.amount || '');
$('bridge-message').hidden = !words.message; $('bridge-message').textContent = words.message || '';
$('bridge-lines').innerHTML = words.lines.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join('');
setText('bridge-origin', p.origin);
setText('bridge-yes-text', words.yes); $('bridge-yes').querySelector('.fp-ico').hidden = !(p.confirm_needed && bio.host);
$('bridge-yes').disabled = p.kind === 'send' && !p.fee_max_ign; $('bridge-no').textContent = words.no;
$('bridge-err').textContent = p.confirm_needed && !bio.host ? what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.' : '';
setLine($('bridge-bio-line'), '');
$('bridge-done').hidden = true; $('bridge-actions').hidden = false;
if (wrap.hidden) { wrap.hidden = false; $('bridge-card').focus({ preventScroll: true }); }
}
async function bridgeDecide(ok) {
var p = bridgeOpen; if (!p || bridgeBusy) return;
bridgeBusy = true; $('bridge-yes').disabled = true; $('bridge-no').disabled = true; $('bridge-err').textContent = '';
try {
var nonce = p.confirm_nonce || '';
if (ok && p.confirm_needed) {
setLine($('bridge-bio-line'), '<span class="bio-state wait">' + FP + esc('Waiting for ' + what()) + '</span>');
var c = await bio.call('confirm', { nonce: nonce });
setLine($('bridge-bio-line'), bioLine(c, 'confirmed'));
if (!c.ok) { bridgeBusy = false; $('bridge-yes').disabled = false; $('bridge-no').disabled = false; return; }
}
var r = await post('/api/bridge/decide', { id: p.id, ok: ok, nonce: nonce });
if (ok && p.kind === 'send' && r.result && r.result.hash) {
bridgeDoneId = r.result.hash; $('bridge-actions').hidden = true; $('bridge-done').hidden = false;
setText('bridge-done-line', 'Sent for ' + View.hostOf(p.origin) + '. The page has the hash; the chain\u2019s word follows here.');
$('bridge-hash-box').hidden = false; setText('bridge-hash', r.result.hash); $('bridge-final').innerHTML = '';
bridgeFinality(r.result.hash);
} else {
$('bridge').hidden = true; bridgeOpen = null; bridgeSig = '';
toast(ok ? (p.kind === 'connect' ? View.hostOf(p.origin) + ' connected' : 'Signed for ' + View.hostOf(p.origin)) : 'Declined');
}
} catch (e) { $('bridge-err').textContent = e.message; }
bridgeBusy = false; $('bridge-yes').disabled = false; $('bridge-no').disabled = false;
poll();
}
// the finality certificate's line after a page's transaction: the node's word, then the checkpoint this wallet verified
async function bridgeFinality(hash) {
for (var i = 0; i < 90 && bridgeDoneId === hash; i++) {
try {
var r = await api('/api/tx/' + hash), st = (r.node_status && r.node_status.state) || 'pending', e = (state && state.history || []).filter(function (x) { return x.hash.toLowerCase() === hash.toLowerCase(); })[0];
var rows = [['the node says', st], ['block', e && e.block ? View.withCommas(e.block) : 'none yet']];
if (e && e.checkpoint) rows.push(['checkpoint', View.withCommas(e.checkpoint) + ', verified by this wallet']);
else if (r.verified && r.verified.verified_index) rows.push(['finality', 'the wallet verified checkpoint ' + View.withCommas(r.verified.verified_index) + '; this transaction is final once a locked checkpoint covers its block']);
$('bridge-final').innerHTML = rows.map(function (x) { return '<div><span class="k">' + esc(x[0]) + '</span><span class="v mono">' + esc(x[1]) + '</span></div>'; }).join('');
if (e && e.finality === 'final') break;
} catch (x) { }
await new Promise(function (res) { setTimeout(res, 2000); });
}
}
$('bridge-yes').onclick = function () { bridgeDecide(true); };
$('bridge-no').onclick = function () { bridgeDecide(false); };
$('bridge-close').onclick = function () { bridgeDoneId = null; $('bridge').hidden = true; bridgeOpen = null; bridgeSig = ''; if (state) renderBridge(state); };
$('bridge-on').addEventListener('change', function () { var on = this.checked; post('/api/bridge/on', { on: on }).then(function () { toast(on ? 'Websites can connect after your approval' : 'Websites cannot connect'); poll(); }).catch(function (e) { toast(e.message); }); });
$('sites').addEventListener('click', function (e) { var b = e.target.closest('[data-site]'); if (!b) return; var o = b.dataset.site; post('/api/bridge/sites/remove', { origin: o }).then(function () { toast(View.hostOf(o) + ' disconnected'); poll(); }).catch(function (x) { toast(x.message); }); });
function renderSites(s) {
var br = s.bridge || {};
if (document.activeElement !== $('bridge-on')) $('bridge-on').checked = br.on !== false;
setText('bridge-status', View.bridgeStatus(br));
var rows = View.siteRows(br.sites, s.now);
$('sites').innerHTML = rows.length ? rows.map(function (r) { return '<div class="srow"><div class="sw-text"><b>' + esc(r.host) + '</b> <span class="dim">' + esc(r.line) + '</span></div><div class="row"><button class="btn small ghost" data-site="' + esc(r.origin) + '">Disconnect</button></div></div>'; }).join('') : '<p class="note">No website is connected.</p>';
}
function renderSettings(s) {
renderSites(s);
var b = s.biometric || {}, w = what(), host = hostHere();
setText('bio-title', w);
setText('bio-sentence', View.bioSentence(b, w, host));

View file

@ -145,6 +145,7 @@
<div class="lock-coin"><span class="lock-glow" aria-hidden="true"></span><div class="mark-wrap lock-mark"><img src="mark.svg" width="88" height="88" alt=""></div></div>
<h1 class="lock-title">Igneum Wallet</h1>
<p class="lock-address mono" id="unlock-address"></p>
<p class="lock-line bridge-waiting" id="unlock-bridge" hidden></p>
<div class="lock-controls">
<div class="lock-touch" id="unlock-bio" hidden>
<button class="btn primary big fp lock-btn" id="unlock-touch" type="button"><svg width="22" height="22" aria-hidden="true"><use href="#i-fp"></use></svg><span id="unlock-touch-text">Unlock with Touch ID</span></button>
@ -279,6 +280,12 @@
</div>
</div>
<div class="card">
<div class="card-head"><h3>Websites</h3><span class="eyebrow" id="sites-eyebrow">page bridge</span></div>
<label class="switch" id="bridge-on-row"><input type="checkbox" id="bridge-on"><span class="track"></span><span class="sw-text"><b>Let websites connect</b> <span class="dim" id="bridge-status"></span></span></label>
<div class="sites" id="sites"></div>
<p class="note" id="sites-note">A site asks once; you answer in this window. Each site sees your address and can ask you to sign or send; every request is shown here first. Disconnect a site any time.</p>
</div>
<div class="card">
<div class="card-head"><h3>Backup</h3><span class="eyebrow" id="backup-eyebrow"></span></div>
<button class="disclose" id="backup-toggle" aria-expanded="false" aria-controls="backup-details"><span>Show my words</span><span class="chev" aria-hidden="true"></span></button>
@ -375,6 +382,30 @@
<!-- the update card (update-card.js, app.js renderUpdateCard): one update, centred; Later, Escape or the backdrop
leaves the strip above -->
<!-- the page bridge (0.1.6): a website's request, answered here and nowhere else -->
<div class="upd-wrap bridge-wrap" id="bridge" hidden>
<div class="upd-card bridge-card" id="bridge-card" role="dialog" aria-modal="true" aria-labelledby="bridge-title" tabindex="-1">
<div class="eyebrow ember" id="bridge-eyebrow">a website asks</div>
<h2 class="upd-name" id="bridge-title"></h2>
<p class="bridge-amount mono" id="bridge-amount" hidden></p>
<pre class="bridge-message mono" id="bridge-message" hidden></pre>
<ul class="upd-notes" id="bridge-lines"></ul>
<p class="upd-meta mono" id="bridge-origin"></p>
<div class="upd-actions" id="bridge-actions">
<button class="btn primary big" id="bridge-yes"><svg class="fp-ico" width="16" height="16" hidden><use href="#i-fp"></use></svg><span id="bridge-yes-text">Connect</span></button>
<button class="btn ghost" id="bridge-no">Decline</button>
</div>
<p class="ask-foot" id="bridge-bio-line"></p>
<p class="err" id="bridge-err"></p>
<div class="bridge-done" id="bridge-done" hidden>
<p class="upd-line" id="bridge-done-line"></p>
<div class="box mono" id="bridge-hash-box" hidden><span id="bridge-hash"></span><button class="btn tiny" data-copy="bridge-hash">Copy</button></div>
<div class="kv" id="bridge-final"></div>
<div class="upd-actions"><button class="btn ghost" id="bridge-close">Done</button></div>
</div>
</div>
</div>
<div class="upd-wrap" id="upd" hidden>
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
<div class="upd-mark" id="upd-mark">

View file

@ -159,3 +159,45 @@ test('settings: the Touch ID sentence, the idle sentence, the update card note',
assert.equal(V.updateLine({ status: 'downloading', version: '0.1.6', size: 20080717, progress: 0.43 }).text, 'Downloading Igneum Wallet 0.1.6 (20 MB): 43%');
assert.equal(V.updateLine({ status: 'current', version: '0.1.5' }), null);
});
// the page bridge (0.1.6, 8 October 2026): what the window says for a page's request (connect, a transaction, a message,
// typed data), the Settings card's site rows, the lock-screen line while a page waits. Known-failed first on 0.1.5:
// View.bridgeWords is not a function.
test('the page bridge: the words for each request kind, the site rows, the waiting line', () => {
const connect = V.bridgeWords({ id: '1-ab', kind: 'connect', origin: 'https://igneum.network', created_at: 1 });
assert.equal(connect.title, 'igneum.network wants to connect');
assert.equal(connect.lines[0], 'It will see your address and may ask you to sign or send. Nothing leaves without your word here.');
assert.equal(connect.yes, 'Connect'); assert.equal(connect.no, 'Decline');
const send = V.bridgeWords({ id: '2-cd', kind: 'send', origin: 'https://igneum.network', to: '0x9a6fa842c4e58a87aef1f3ad15233d99283002b7', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value: '1000000000000000000', value_ign: '1', data_len: 36, selector: '0x38ed1739', gas: 184000, fee_max_ign: '0.000368', total_max_ign: '1.000368', confirm_needed: false });
assert.equal(send.title, 'igneum.network asks you to send');
assert.equal(send.amount, '1 IGN');
assert.equal(send.lines[0], 'To 0x9a6fA8…02B7, a contract call (36 bytes, 0x38ed1739).');
assert.equal(send.lines[1], 'Fee up to 0.000368 IGN (184,000 gas). Total up to 1.000368 IGN.');
assert.equal(send.yes, 'Send'); assert.equal(send.no, 'Decline');
const plain = V.bridgeWords({ id: '3', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value_ign: '0.5', data_len: 0, selector: '', gas: 21000, fee_max_ign: '0.000042', total_max_ign: '0.500042' });
assert.equal(plain.lines[0], 'To 0x9a6fA8…02B7, a plain transfer.');
const unpriced = V.bridgeWords({ id: '4', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value: '0', data_len: 4, selector: '0xd0e30db0' });
assert.equal(unpriced.amount, '0 IGN'); assert.equal(unpriced.lines[1], 'Pricing the fee with the node.');
const touch = V.bridgeWords({ id: '5', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value_ign: '1', data_len: 0, selector: '', gas: 21000, fee_max_ign: '0.00004', total_max_ign: '1.00004', confirm_needed: true }, 'Touch ID');
assert.equal(touch.yes, 'Send with Touch ID');
const sign = V.bridgeWords({ id: '6', kind: 'sign', origin: 'https://igneum.network', text: 'Sign in to Igneum Swap\nnonce: 42', bytes: 31 });
assert.equal(sign.title, 'igneum.network asks you to sign a message');
assert.equal(sign.message, 'Sign in to Igneum Swap\nnonce: 42');
assert.equal(sign.lines[0], 'Signing proves this wallet is yours. It moves no coins.');
assert.equal(sign.yes, 'Sign');
const bin = V.bridgeWords({ id: '7', kind: 'sign', origin: 'https://igneum.network', text: null, bytes: 32, hex: '0x0102' });
assert.equal(bin.message, '32 bytes: 0x0102');
const typed = V.bridgeWords({ id: '8', kind: 'typed', origin: 'https://igneum.network', domain: 'Igneum Swap', primary_type: 'Permit', message: '{\n "owner": "0x1"\n}' });
assert.equal(typed.title, 'igneum.network asks you to sign typed data');
assert.equal(typed.lines[0], 'Permit for Igneum Swap. Read it before you sign: a permit can let a contract spend tokens.');
assert.equal(typed.message, '{\n "owner": "0x1"\n}');
// the lock-screen line and the site rows
assert.equal(V.bridgeWaiting([connect, send].map((x) => ({ origin: 'https://igneum.network' }))), 'igneum.network is waiting: unlock to answer it');
assert.equal(V.bridgeWaiting([]), '');
const rows = V.siteRows([{ origin: 'https://igneum.network', approved_at: 1_800_000_000 - 3600, last_seen: 1_800_000_000 - 30 }], 1_800_000_000);
assert.equal(rows[0].host, 'igneum.network'); assert.equal(rows[0].line, 'connected 1 h ago · last call 30 s ago');
assert.equal(V.siteRows([], 1).length, 0);
assert.equal(V.bridgeStatus({ on: true, listening: true, port: 26811, sites: [] }), 'On. Websites you approve can connect through port 26811 on this machine only.');
assert.equal(V.bridgeStatus({ on: true, listening: false, port: 26811, reason: 'port 26811 is not free: in use', sites: [] }), 'Not listening: port 26811 is not free: in use. Quit whatever holds the port and open the wallet again.');
assert.equal(V.bridgeStatus({ on: false, listening: true, port: 26811, sites: [] }), 'Off. No website can connect; the switch turns it on.');
});

View file

@ -2,6 +2,6 @@
// Keep it equal to app/igneum-wallet/Cargo.toml and the AppVersion default in packaging/windows/Igneum-Wallet.iss.
#ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.1.5"
#define IGNEUM_HOST_VERSION_RC 0,1,5,0
#define IGNEUM_HOST_VERSION_STR "0.1.6"
#define IGNEUM_HOST_VERSION_RC 0,1,6,0
#endif

View file

@ -9,7 +9,7 @@
#define ArtDir "..\..\brand\icons"
#endif
#ifndef AppVersion
#define AppVersion "0.1.5"
#define AppVersion "0.1.6"
#endif
#define AppName "Igneum Wallet"
#define Publisher "Igneum"

7
rust-toolchain.toml Normal file
View file

@ -0,0 +1,7 @@
# One pin for every side (main, 7 October 2026): the Mac, igneum-build-1 (provision.sh RUST_TOOLCHAIN reads this), the PCs and CI.
# rustup resolves the nearest rust-toolchain.toml walking up from the crate, so the fork worktrees under vendor/ are covered by
# this file and carry their own copy for the fork's standalone checkout. lib.sh bs_toolchain_check refuses a build when the pin,
# the Mac's rustc or the box's rustc differ. Bump here and in vendor/igneum-node/rust-toolchain.toml in one commit each.
[toolchain]
channel = "1.99.0"
targets = ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-gnu"]

94
site/wallet-provider.js Normal file
View file

@ -0,0 +1,94 @@
/* The Igneum Wallet's page provider (8 October 2026): an EIP-1193 provider for a page when the Igneum Wallet runs on
* the same machine. The wallet's engine listens on 127.0.0.1:26811 (its page bridge, app/igneum-wallet/src/bridge.rs);
* this file POSTs JSON-RPC there with the X-Igneum-Bridge header (so the browser sends a CORS preflight the bridge
* answers for the page's origin) and polls a request the wallet window has to answer (connect, send, sign) until the
* person decides there. Nothing is signed on the page; the wallet shows the facts and asks in its own window.
*
* <script src="/wallet-provider.js"></script> before the page's own script
*
* Rules: a page that already has window.ethereum (MetaMask) keeps it; the Igneum provider is then window.igneum and is
* announced through EIP-6963 (rdns network.igneum.wallet) so a wallet picker can list it. Without an injected wallet,
* the Igneum provider is window.ethereum. A request while the wallet is not open rejects with code 4900. Errors come
* back as {code, message} (EIP-1193 codes: 4001 declined, 4100 not connected, 4200 not offered, 4902 another chain).
* Tests: site/wallet-provider.test.mjs. */
(function (win) {
var BASE = 'http://127.0.0.1:26811', POLL_MS = 800, TICK_MS = 4000;
var seq = 0, hello = null, helloAt = 0, listeners = {}, lastAccounts = '', lastChain = '';
function err(code, message) { var e = new Error(message); e.code = code; return e; }
function emit(ev, arg) { (listeners[ev] || []).slice().forEach(function (f) { try { f(arg); } catch (e) { } }); }
function probe() {
var now = Date.now();
if (hello && now - helloAt < 10000) return Promise.resolve(hello);
return fetch(BASE + '/bridge/hello', { method: 'GET', cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (j) {
if (!j || !j.ok || j.wallet !== 'igneum') throw err(4900, 'The Igneum Wallet is not open on this computer');
hello = j; helloAt = Date.now(); return j;
}).catch(function (e) { hello = null; throw e.code ? e : err(4900, 'The Igneum Wallet is not open on this computer. Open it, then try again.'); });
}
function rpc(method, params) {
var id = ++seq;
return fetch(BASE + '/bridge/rpc', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Igneum-Bridge': '1' }, body: JSON.stringify({ jsonrpc: '2.0', id: id, method: method, params: params || [] }), cache: 'no-store' })
.then(function (r) { return r.json(); }, function () { throw err(4900, 'The Igneum Wallet stopped answering. Open it, then try again.'); });
}
function settle(j) {
if (j && j.error) throw err(typeof j.error.code === 'number' ? j.error.code : -32603, j.error.message || 'the wallet refused');
return j ? j.result : null;
}
function wait(id) {
return new Promise(function (resolve, reject) {
function again() {
rpc('igneum_poll', [id]).then(function (j) {
if (j && j.pending) { win.setTimeout(again, POLL_MS); return; }
try { resolve(settle(j)); } catch (e) { reject(e); }
}, reject);
}
win.setTimeout(again, POLL_MS);
});
}
var provider = {
isIgneum: true,
isConnected: function () { return !!hello; },
selectedAddress: null,
chainId: null,
request: function (args) {
if (!args || typeof args.method !== 'string') return Promise.reject(err(-32602, 'request({method, params})'));
var method = args.method, params = args.params || [];
return probe().then(function () { return rpc(method, params); }).then(function (j) {
if (j && j.pending) return wait(j.pending);
return settle(j);
}).then(function (result) {
if (method === 'eth_requestAccounts' || method === 'eth_accounts') { provider.selectedAddress = Array.isArray(result) && result.length ? result[0] : null; }
if (method === 'eth_chainId' && typeof result === 'string') provider.chainId = result;
return result;
});
},
on: function (ev, f) { (listeners[ev] = listeners[ev] || []).push(f); return provider; },
removeListener: function (ev, f) { listeners[ev] = (listeners[ev] || []).filter(function (g) { return g !== f; }); return provider; },
// the poll behind accountsChanged and chainChanged (tests call it directly)
_tick: function () {
return (hello ? Promise.resolve() : probe()).then(function () { return rpc('eth_accounts', []); }).then(function (j) {
var acc = (j && !j.error && Array.isArray(j.result)) ? j.result : [];
var key = acc.join(',');
if (lastAccounts !== '' || key !== '') { if (key !== lastAccounts) { lastAccounts = key; provider.selectedAddress = acc[0] || null; emit('accountsChanged', acc); } } else lastAccounts = key;
return rpc('eth_chainId', []);
}).then(function (j) {
var c = j && !j.error && typeof j.result === 'string' ? j.result : '';
if (c && lastChain && c !== lastChain) emit('chainChanged', c);
if (c) { lastChain = c; provider.chainId = c; }
}).catch(function () { });
},
// legacy shapes some libraries still call
enable: function () { return provider.request({ method: 'eth_requestAccounts' }); },
send: function (method, params) { return provider.request(typeof method === 'string' ? { method: method, params: params } : method); },
sendAsync: function (payload, cb) { provider.request(payload).then(function (r) { cb(null, { id: payload.id, jsonrpc: '2.0', result: r }); }, function (e) { cb(e); }); }
};
var ICON = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="22" fill="#0C0C0E"/><path d="M50 10 L78 50 L50 90 L22 50 Z M50 36 L61 50 L50 64 L39 50 Z" fill="#F2541B" fill-rule="evenodd"/></svg>');
var info = { uuid: 'c0b3a5a6-6b2e-4a0a-9d0e-igneumwallet1', name: 'Igneum Wallet', icon: ICON, rdns: 'network.igneum.wallet' };
function announce() { try { win.dispatchEvent(new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info: info, provider: provider }) })); } catch (e) { } }
win.igneum = provider;
if (!win.ethereum) win.ethereum = provider;
announce();
win.addEventListener('eip6963:requestProvider', announce);
// the first probe tells the page whether the wallet is here; the tick keeps accountsChanged honest
probe().then(function () { provider._tick(); }, function () { });
win.setInterval(function () { if (hello) provider._tick(); else probe().catch(function () { }); }, TICK_MS);
})(typeof window !== 'undefined' ? window : this);

View file

@ -0,0 +1,90 @@
// node --test site/wallet-provider.test.mjs
// The Igneum Wallet's page provider (site/wallet-provider.js): an EIP-1193 provider a page gets when the wallet runs on
// the same machine (its page bridge on 127.0.0.1:26811). Known-failed first on 8 October 2026: the file did not exist,
// so igneum.network/swap could connect only an injected wallet. The rules: a page that already has window.ethereum
// keeps it (the Igneum provider is announced through EIP-6963 and sits at window.igneum); without one, the Igneum
// provider is window.ethereum; a request while the wallet is not open rejects with 4900; a pending answer is polled
// until the wallet decides; errors come back as {code, message}; accountsChanged and chainChanged fire from polling.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'wallet-provider.js'), 'utf8');
function boot(opts = {}) {
const calls = [];
const win = { location: { origin: 'https://igneum.network' }, dispatched: [], listeners: {}, setTimeout: (f) => { f(); return 1; }, clearTimeout() {}, setInterval: () => 1, clearInterval() {} };
win.addEventListener = (n, f) => { (win.listeners[n] = win.listeners[n] || []).push(f); };
win.dispatchEvent = (e) => { win.dispatched.push(e); (win.listeners[e.type] || []).forEach((f) => f(e)); return true; };
if (opts.existing) win.ethereum = opts.existing;
const fetch = async (url, init) => {
calls.push({ url, init });
if (opts.down) throw new Error('connection refused');
const body = init && init.body ? JSON.parse(init.body) : null;
if (url.endsWith('/bridge/hello')) return { ok: true, json: async () => ({ ok: true, wallet: 'igneum', version: '0.1.6', chain_id: 4463, chain_id_hex: '0x116f', bridge: 1 }) };
const r = opts.reply(body, calls.length);
return { ok: true, json: async () => r };
};
const Event = class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } };
new Function('window', 'fetch', 'CustomEvent', 'Event', 'document', src)(win, fetch, Event, Event, { readyState: 'complete', addEventListener() {} });
return { win, calls };
}
test('without an injected wallet the Igneum provider is window.ethereum and is announced through EIP-6963', () => {
const { win } = boot({ reply: () => ({ result: [] }) });
assert.ok(win.ethereum && win.ethereum.isIgneum === true);
assert.equal(win.igneum, win.ethereum);
const ann = win.dispatched.filter((e) => e.type === 'eip6963:announceProvider');
assert.equal(ann.length, 1);
assert.equal(ann[0].detail.info.rdns, 'network.igneum.wallet');
assert.equal(ann[0].detail.provider, win.ethereum);
});
test('an injected wallet keeps window.ethereum; the Igneum provider sits at window.igneum and is still announced', () => {
const mm = { isMetaMask: true };
const { win } = boot({ existing: mm, reply: () => ({ result: [] }) });
assert.equal(win.ethereum, mm);
assert.ok(win.igneum && win.igneum.isIgneum);
assert.equal(win.dispatched.filter((e) => e.type === 'eip6963:announceProvider').length, 1);
});
test('a request while the wallet is not open rejects with 4900 and the words a person can act on', async () => {
const { win } = boot({ down: true });
await assert.rejects(win.igneum.request({ method: 'eth_requestAccounts' }), (e) => e.code === 4900 && /Igneum Wallet is not open/.test(e.message));
});
test('a direct result comes back; an error comes back as {code, message}; a pending answer is polled until decided', async () => {
let polls = 0;
const { win, calls } = boot({ reply: (body) => {
if (body.method === 'eth_chainId') return { result: '0x116f' };
if (body.method === 'eth_requestAccounts') return { pending: '1-ab' };
if (body.method === 'igneum_poll') { polls++; return polls < 3 ? { pending: '1-ab' } : { result: ['0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'] }; }
if (body.method === 'wallet_switchEthereumChain') return { error: { code: 4902, message: 'one chain' } };
return { result: null };
} });
assert.equal(await win.igneum.request({ method: 'eth_chainId' }), '0x116f');
const acc = await win.igneum.request({ method: 'eth_requestAccounts' });
assert.deepEqual(acc, ['0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826']);
assert.equal(polls, 3);
assert.equal(win.igneum.selectedAddress, '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826');
assert.equal(win.igneum.chainId, '0x116f');
await assert.rejects(win.igneum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: '0x1' }] }), (e) => e.code === 4902 && e.message === 'one chain');
const rpc = calls.filter((c) => c.url.endsWith('/bridge/rpc'));
assert.ok(rpc.every((c) => c.init.headers['X-Igneum-Bridge'] === '1' && c.init.method === 'POST'), 'every call carries the bridge header');
assert.ok(rpc.every((c) => c.url.startsWith('http://127.0.0.1:26811/')), 'the fixed loopback port');
});
test('accountsChanged and chainChanged fire from the poll when they differ; on() and removeListener() work', async () => {
let accounts = [];
const { win } = boot({ reply: (body) => body.method === 'eth_accounts' ? { result: accounts } : body.method === 'eth_chainId' ? { result: '0x116f' } : { result: null } });
const seen = [];
const h = (a) => seen.push(a);
win.igneum.on('accountsChanged', h);
await win.igneum._tick();
assert.deepEqual(seen, []);
accounts = ['0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'];
await win.igneum._tick();
assert.deepEqual(seen, [accounts]);
win.igneum.removeListener('accountsChanged', h);
accounts = [];
await win.igneum._tick();
assert.equal(seen.length, 1);
});