The Monero/Wownero signing path passed `Zeroizing::new([0u8; 32])` as the `outgoing_view_key` argument to `SignableTransaction::new`. Per monero-oxide's API contract that value is treated as a private key: it seeds the RNG used to derive the per-tx scalar `r` and the ECDH shared secrets with each receiver. A known constant lets any observer recompute `r`, derive the same shared secrets, and decode amounts / link outputs back to the recipient. Replace the constant with a `fresh_outgoing_view_key()` helper backed by `OsRng`, called once per `sign_transaction`. Per-tx randomness is preferred over a deterministic derivation because reusing an `outgoing_view_key` across two signs of the same UTXO causes CLSAG nonce reuse, which leaks the spend key. Add `test_outgoing_view_key_is_fresh_per_call` asserting each call returns a distinct, non-zero 32-byte key — fails immediately if the constant is reintroduced.
smirk-wasm
Monero/Wownero transaction construction for browser extensions, compiled to WebAssembly.
Overview
This crate provides client-side cryptographic operations for Monero and Wownero wallets running in browser extensions. The spend key never leaves the client - the backend only provides blockchain data.
Features
- Address validation - Parse and validate Monero/Wownero addresses
- Key image computation - Compute key images to verify spent outputs (client-side balance verification)
- Transaction parsing - Decode and inspect transactions
- Fee estimation - Estimate transaction fees
- Transaction signing - Construct and sign transactions locally (XMR and WOW)
Wownero Support
Wownero transactions are fully supported with the following differences from Monero:
| Property | Monero (XMR) | Wownero (WOW) |
|---|---|---|
| RCT Type | 6 (ClsagBulletproofPlus) | 8 (BulletproofPlus) |
| Ring Size | 16 (15 decoys + 1 real) | 22 (21 decoys + 1 real) |
| Commitment Format | Full commitment | C/8 (scaled by INV_EIGHT) |
| Network Prefix | 4 (mainnet) |
Wo (mainnet) |
The signing implementation handles these differences automatically based on the network parameter.
Architecture
┌─────────────────────┐ ┌─────────────────────┐
│ Browser Extension │ │ Backend │
│ │ │ │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ smirk-wasm │ │ │ │ LWS │ │
│ │ (~165KB) │ │ │ │ (Monero) │ │
│ │ │ │ │ │ │ │
│ │ - Keys │◄─┼─────┼──┤ - Outputs │ │
│ │ - Signing │ │ │ │ - Decoys │ │
│ │ - Addresses │──┼─────┼──► - Broadcast │ │
│ └───────────────┘ │ │ └───────────────┘ │
│ │ │ │
│ Spend key stays │ │ No access to │
│ here │ │ spend key │
└─────────────────────┘ └─────────────────────┘
Building
Prerequisites
- Rust (stable) with
wasm32-unknown-unknowntarget - wasm-bindgen-cli
# Add WASM target
rustup target add wasm32-unknown-unknown
# Install wasm-bindgen CLI
cargo install wasm-bindgen-cli
Build
# Quick build (uses build.sh)
./build.sh
# Or manually:
cargo build --target wasm32-unknown-unknown --release
wasm-bindgen --target web --out-dir pkg \
target/wasm32-unknown-unknown/release/smirk_wasm.wasm
Output
After building:
pkg/smirk_wasm.js- JavaScript modulepkg/smirk_wasm.d.ts- TypeScript definitionspkg/smirk_wasm_bg.wasm- WebAssembly binary (~380KB)
Testing
Rust unit tests
cargo test
Browser testing
# Build first
./build.sh
# Serve with any static server
python3 -m http.server 8080
# Open http://localhost:8080/test.html
Usage
import init, {
test,
version,
validate_address,
estimate_fee,
sign_transaction,
compute_key_image
} from './pkg/smirk_wasm.js';
async function main() {
// Initialize WASM
await init();
// Verify loaded
console.log(test()); // "smirk-wasm ready"
// Validate address
const result = JSON.parse(validate_address(
'888tNkZrPN6JsEgekjMnABU4TBzc...'
));
if (result.success) {
console.log(result.data.network); // "mainnet"
}
// Estimate fee (2 inputs, 2 outputs)
const fee = JSON.parse(estimate_fee(2, 2, 20n, 10000n));
console.log(fee.data); // fee in atomic units
// Sign transaction
const txResult = JSON.parse(sign_transaction(JSON.stringify({
inputs: [/* from get_unspent_outs + get_random_outs */],
destinations: [{ address: '...', amount: 1000000 }],
change_address: '...',
fee_per_byte: 20,
fee_mask: 10000,
view_key: '...', // hex
spend_key: '...', // hex
network: 'mainnet'
})));
if (txResult.success) {
console.log(txResult.data.tx_hex); // signed tx ready for broadcast
console.log(txResult.data.tx_hash); // transaction hash
console.log(txResult.data.fee); // actual fee
}
}
API
All functions return JSON strings:
interface Result<T> {
success: boolean;
data?: T;
error?: string;
}
Core Functions
| Function | Description |
|---|---|
test() |
Returns "smirk-wasm ready" if loaded |
version() |
Returns crate version |
Address Functions
validate_address(address: string) -> string
Validates a Monero address and returns its components.
// Returns:
{
valid: boolean;
network: "mainnet" | "testnet" | "stagenet";
is_subaddress: boolean;
has_payment_id: boolean;
spend_key: string; // hex
view_key: string; // hex
}
Key Functions
derive_key_image(output_key, spend_key, key_offset) -> string
Computes the key image for an output. All arguments are 32-byte hex strings.
Returns the key image as a hex string.
Transaction Functions
parse_tx(hex_data: string) -> string
Parses a transaction from hex.
// Returns:
{
inputs: number;
outputs: number;
version: number;
}
estimate_fee(inputs, outputs, fee_per_byte, fee_mask) -> string
Estimates transaction fee.
inputs- Number of inputs (u32)outputs- Number of outputs including change (u32)fee_per_byte- Fee per byte from LWS (u64/bigint)fee_mask- Fee rounding mask from LWS (u64/bigint)
Returns estimated fee in atomic units.
sign_transaction(params_json: string) -> string
Builds and signs a transaction.
Input format:
{
inputs: [{
output: {
amount: number, // atomic units
public_key: string, // hex
tx_pub_key: string, // hex
index: number, // output index in tx
global_index: number // global output index
},
decoys: [{ // XMR: 15 decoys (ring 16), WOW: 21 decoys (ring 22)
global_index: number,
public_key: string, // hex
rct: string // hex commitment
}]
}],
destinations: [{ address: string, amount: number }],
change_address: string,
fee_per_byte: number,
fee_mask: number,
view_key: string, // hex, 64 chars
spend_key: string, // hex, 64 chars
network: "mainnet" | "testnet" | "stagenet" | "wownero"
}
Returns:
{
tx_hex: string, // signed transaction ready for broadcast
tx_hash: string, // transaction hash
fee: number // actual fee in atomic units
}
derive_output_key_image(view_key, spend_key, tx_pub_key, output_index, output_key) -> string
Derives the key image for a specific output when you have the output's public key.
compute_key_image(view_key, spend_key, tx_pub_key, output_index) -> string
Computes the key image for an output without requiring the output public key. This is useful for verifying LWS spent_outputs where only tx_pub_key and out_index are provided.
The function:
- Derives the one-time private key:
x = Hs(a*R || outputIndex) + b - Computes the output public key:
P = x * G - Returns the key image:
KI = x * Hp(P)
This uses monero-oxide's Point::biased_hash for the Hp() operation, which is Monero's hash_to_ec (ge_fromfe_frombytes_vartime).
Project Structure
smirk-wasm/
├── Cargo.toml
├── build.sh
├── README.md
├── test.html
└── src/
├── lib.rs # Main module, re-exports
├── result.rs # WasmResult type
├── address.rs # Address validation
├── keys.rs # Key image derivation
├── output.rs # Output derivation (key_offset, commitment_mask)
├── transaction.rs # Transaction parsing
├── signing.rs # Transaction signing
└── tests.rs # Unit tests
Dependencies
- monero-oxide - Pure Rust Monero implementation (MIT)
- curve25519-dalek - Elliptic curve ops
- wasm-bindgen - Rust/JS interop
License
MIT