A software HSM (Hardware Security Module) simulator written in Rust. It holds a private key in memory, exposes signing over HTTP, and demonstrates dual-path recovery for Bitcoin and Ethereum: everyday signing plus a separate admin recovery path (Taproot-style on Bitcoin, mirrored in a Solidity contract on Ethereum).
Testnets only. Do not use generated keys or demo admin keys with real funds.
- Rust (
cargo) — backend - Node.js (
npm) — web UI - EVM demo: a browser wallet (MetaMask, etc.) with testnet ETH for deployment and broadcasting
- Optional (EVM contract dev): Foundry (
forge) for running Solidity tests / regenerating artifacts
-
Config:
cp Settings.example.toml Settings.toml(example is fine for local testing). -
Start the server:
cargo run— exposes the signing API onhttp://localhost:3001and the recovery API onhttp://localhost:5051. The web app defaults to these URLs (override withNEXT_PUBLIC_HSM_URL/NEXT_PUBLIC_RECOVERY_URLif needed). -
Start the UI (another terminal):
cd frontend && npm install && npm run dev
-
Open http://localhost:3000 and enter the API key from
Settings.toml(defaulttest-api-key).
Use this when the app shows your keys and Send, not the recovery screen.
- Copy the Bitcoin receive address from the app.
- Fund it from the Testnet4 faucet and wait for confirmation.
- In Send, enter a testnet destination address and amount, then submit.
The recovery screen appears after a restart once you’ve already used a session: each start creates a new session and marks the previous one as needing recovery.
- Fund the address and optionally send a normal transaction (see above).
- Restart the simulator (
cargo runagain), refresh the app, and sign in. You should see Recovery required. - While that screen is open, new sends are blocked until you finish recovery or tap Nothing to recover if the balance is zero.
Steps
-
Paste the demo admin keys from
Settings.example.toml(must matchrecovery_public_keysinSettings.toml):[ "0000000000000000000000000000000000000000000000000000000000000001", "0000000000000000000000000000000000000000000000000000000000000002", "0000000000000000000000000000000000000000000000000000000000000003" ]
-
Set a destination address → Begin recovery → Sign for each admin in order → Submit recovery transaction.
The EVM side mirrors the Bitcoin “dual path” idea using a small smart-contract wallet:
- Key path (everyday use): the HSM signs an EIP-712 digest and the contract executes via
executeWithHSM(...). - Recovery path (admins): admins sign the same digest, propose via
proposeAdminExecution(...), wait for a timelock, then execute viaexecuteAdminProposal(...).
The EVMAccount contract can be deployed directly from the browser using your connected wallet — no Foundry CLI needed.
Supported testnets in the UI: Ethereum Sepolia, Base Sepolia, Arbitrum Sepolia.
The demo admin keys are the well-known Ethereum test private keys 0x1, 0x2, 0x3 (never use with real funds). Their corresponding addresses are documented in src/core/chains/evm/contracts/script/Deploy.s.sol and embedded in the frontend deploy artifact.
- Start the simulator and open the UI.
- Connect your browser wallet (MetaMask, etc.) using the Connect button in the top-right.
- Switch your wallet to a supported testnet (Sepolia / Base Sepolia / Arbitrum Sepolia).
- The EVMAccount Contract section appears once you sign in with your API key. Click Deploy.
- Approve the deployment transaction. The contract is deployed with:
- Your current HSM key as
hsmKey - The three demo admin keys from
Settings.example.tomlas recovery admins - 120-second timelock
- Your current HSM key as
- The contract address is saved to
localStorageautomatically and used for all subsequent sends.
Alternative (Foundry CLI): You can still deploy manually via
forge script script/Deploy.s.sol. After deploying, paste the contract address into the EVMAccount contract address field in the Send panel.
When a contract is deployed for the current HSM key, the Send panel uses it automatically. If your wallet is connected it also broadcasts the transaction — no broadcaster private key required.
Funding note: you fund the contract address (not the HSM EOA). Send testnet ETH (and any ERC-20s you want to test) to the deployed EVMAccount address. For Sepolia ETH, you can use the Google Cloud Sepolia faucet.
Balance display note: in the Your Wallets panel, EVM balances are queried against the active EVMAccount contract address when one is available for the current HSM key/chain. If no contract is active yet, balances fall back to the current HSM EOA from /evm/key-info.
When the HSM restarts, its key changes — but funds held in the deployed EVMAccount are still safe. Admin keys can recover them via the timelock-protected admin path regardless of the current HSM key.
The EVMAccount Contract section shows stale contracts (warning badge) after a restart. The recovery panel pre-fills the contract address automatically.
On the recovery screen, the EVM contract address field is auto-filled from localStorage using the most recently deployed EVMAccount contract entry.
The recovery setup form also shows live balances for that contract on the selected chain so you can verify what can be refunded before proposing/executing recovery.
Open the recovery screen (restart after a session if it doesn't show), expand EVM Contract Recovery, and run propose → wait ~2 min → execute.
- Broadcasting propose/execute can be done either by a connected wallet on the selected chain, or by providing a broadcaster private key (browser-only).
- The three demo admin keys (same JSON as Bitcoin) work for any contract deployed from the UI.
Note —
cancelAdminProposal: TheEVMAccountcontract also includes acancelAdminProposalfunction that allows cancelling a pending admin proposal before the timelock expires; the caller must supply an HSM signature. It is not exposed through the simulator UI — invoke it directly viacast send, ethers.js, viem, etc.
- Solidity contract (dual path wallet):
src/core/chains/evm/contracts/src/EVMAccount.sol - Foundry tests for the contract:
src/core/chains/evm/contracts/test/EVMAccount.t.sol - Rust EIP-712 hashing + ABI encoding helpers:
src/core/chains/evm/recovery.rs - Rust recovery “prepare” endpoint (hash + calldata helpers):
src/service/evm_recovery_service.rs - Rust HSM signing endpoint (sign 32-byte digest):
src/core/chains/evm/account.rs - Frontend digest computation + contract calls:
- EIP-712 digest (used for normal
executeWithHSM):frontend/src/lib/evm-contract-client.ts - Admin signing + broadcast helper:
frontend/src/lib/evm-recovery-signer.ts - Recovery UI flow:
frontend/src/components/EvmRecoveryPanel.tsx
- EIP-712 digest (used for normal
[server]
port = 3001
recovery_port = 5051
api_key = "your-api-key"
[[chains]]
chain_type = "btc"
recovery_public_keys = ["<64-char hex x-only pubkey>", ...]
required_keys = 2 # must be more than half of the listed keys
[[chains]]
chain_type = "evm"Each start generates new HSM keys. If sessions.json has a pending recovery, signing returns 503 until recovery is done or dismissed.
If you configure only an evm chain (no btc block), sessions still rotate on each start: the recovery screen lists the previous HSM Ethereum address and EVM Contract Recovery; there is no Bitcoin Taproot data for that session.
Signing API http://localhost:3001 — header: Authorization: Bearer <api_key>
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Health |
| GET | /btc/key-info |
Bitcoin key material |
| POST | /btc/sign |
Sign PSBT (hex). Response signature is the hex raw signed transaction. |
| GET | /evm/key-info |
Ethereum address of the current HSM key |
| POST | /evm/sign |
Sign 32-byte hash (hex). Response signature is the hex signature for the caller to attach. |
Recovery API http://localhost:5051 — same header
| Method | Path | Purpose |
|---|---|---|
| GET | /sessions |
List sessions |
| GET | /sessions/{id} |
One session |
| PATCH | /sessions/{id}/recovered |
Mark recovery complete |
| POST | /recovery/btc/prepare |
Bitcoin recovery tx data |
| POST | /recovery/evm/prepare |
EVM hashes / calldata |
POST /recovery/evm/prepare example body:
{
"contract_address": "0x...",
"to": "0x...",
"calldata": "",
"value_wei": "1000000000000000000",
"admin_signatures": ["<hex>", "<hex>"],
"chain_id": 11155111
}Response includes eip712_hash, propose_calldata, execute_calldata.
propose_calldata / execute_calldata are primarily useful if you want to broadcast using a raw-tx broadcaster. The UI can also broadcast directly using a connected wallet (ABI calls).
cargo testFrontend checks:
cd frontend
npm run lint
npm run buildOptional Solidity checks (requires Foundry):
cd src/core/chains/evm/contracts
forge test| Issue | Try |
|---|---|
| No recovery screen | Restart the simulator after you’ve already used a session. |
| Send fails / 503 | Complete or skip recovery first. |
| Bitcoin recovery rejects keys | Settings.toml must match the keys from when that session was created. |
| EVM send via contract fails | Check that the contract address in the UI matches your deployed EVMAccount. |
| Header shows Offline | Ensure the signing API is on port 3001 (or set NEXT_PUBLIC_HSM_URL). |
| Signing returns 503 unexpectedly | Check sessions.json exists and is readable/writable; signing now fails closed if recovery state can't be read safely. |
- Bitcoin concepts reference: learnmeabitcoin.com