diff --git a/CLAUDE.md b/CLAUDE.md index 8a90850..36d1e95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Rust node implementing Aura (Proof-of-Authority) consensus, custom RLP-encoded t ## Transaction Flow 1. Signed tx arrives via WS RPC (`send_transaction` JSON or `send_raw_transaction` hex RLP) or via gossipsub (`gossipsub_handler.rs`). -2. `Blockchain::add_transaction_to_pool` → `Transaction::validate_transaction`: signature (recover & compare to `from`), nonce (`== last + 1`), then per-type `verify_state` (e.g. RideRequest checks balance ≥ fare and no concurrent open request via `passenger_concurrent.rs`). Valid txs land in the `tx_pool` CF and are re-gossiped. +2. `Blockchain::add_transaction_to_pool` → `Transaction::validate_transaction`: signature (recover & compare to `from`; two schemes, see "Wallet signatures" below), nonce (`== last + 1`), then per-type `verify_state` (e.g. RideRequest checks balance ≥ fare and no concurrent open request via `passenger_concurrent.rs`). Valid txs land in the `tx_pool` CF and are re-gossiped. 3. Authoring loop (`node_services.rs::start_authoring_job`, every 1s) calls `author_new_block`: drains pool, builds+signs block, then `import_block`. Aura rejects it unless this node is the current slot's author, so most ticks are no-ops (`Err` logged at debug). 4. `import_block` = `verify_block_author` (Aura slot check) + `validate_block` (sig, index, prev_hash) + re-validate all txs + `Block::add_block_to_chain`, which batches into one `db.write()`: block, latest-block pointer, per-tx state updates (`state_transaction`), balance effects, one aggregate `tx_fee` credit to the block author, `total_supply` delta from any Mint/Burn, tx_pool deletions. Accepted blocks are gossiped; peers import the same way. 5. Sync: on startup, a node sends an RLP `Handshake` to the first connected peer, then pulls `GetBlockHeaders`/`GetBlockBodies` over libp2p request-response. @@ -66,7 +66,7 @@ docker compose up -d # 3-node local net from ghcr image (this repo ``` - Tests in `tests/` (`ride_sharing.rs`, `author_block.rs`, `balance_effects.rs`, `transfer.rs`, `referrer_account.rs`, `rlp_decode_test.rs`, `p2p_server_tests.rs`, `chain_genesis.rs`, `chain_init.rs`, `mint_burn.rs`, `tx_fee.rs`, `db_error_handling.rs`) hit **real RocksDB instances in the cwd**; DB-touching tests are `#[serial]` (serial_test crate) — keep that attribute on any new test that opens a database, and clean up via `blockchain.shutdown_blockchain()` (developer_mode) — which only deletes when `DB_PATH` is unset, so do not set it in a test environment or the cleanup silently stops happening and stray `clutch-node-*.db` dirs accumulate. -- CI: `.github/workflows/docker-build-push.yml` builds multi-arch images to GHCR (Docker Hub publishing dropped 2026-09-18 — nothing deployed ever read from it) on push to main / `v*` tags, then repository-dispatches `deploy-stage` to clutch-deploy. There is **no CI job running `cargo test`** — run tests locally before pushing. +- CI: `.github/workflows/docker-build-push.yml` builds multi-arch images to GHCR (Docker Hub publishing dropped 2026-09-18 — nothing deployed ever read from it) on push to main / `v*` tags, then repository-dispatches `deploy-stage` to clutch-deploy. `.github/workflows/test.yml` runs `cargo test -- --test-threads=1` on every push and PR; that run is the gate, because the Windows dev host cannot link Rust. ## Gotchas / Conventions @@ -79,5 +79,6 @@ docker compose up -d # 3-node local net from ghcr image (this repo - `Blockchain` is shared as `Arc>` (tokio Mutex) across the WS, p2p, authoring, and sync tasks; other tasks talk to the libp2p swarm only through `P2PServerCommand` over an mpsc channel. - Gossip payloads are `[1-byte GossipMessageType (0x01 tx, 0x02 block)] + RLP bytes` (`p2p_server/commands.rs`). - Transaction hash = **Keccak-256** over RLP `[from (no 0x), nonce, chain_id, data]`, meant to be byte-for-byte identical to clutch-hub-sdk-js `signTransaction` and the clutch-hub-api faucet. The wire format is the 8-item list `[from, nonce, chain_id, signature_r, signature_s, signature_v, hash, data]` — `chain_id` at index 2. No test currently pins this against externally-produced (real SDK) bytes: the old cross-language fixture in `transaction.rs` predates `chain_id` and was removed rather than left to assert something untrue; re-pinning is pending the SDK gaining `chain_id` support (see `TODO(sdk-v3)` in `transaction.rs`). `validate_transaction` recomputes and rejects a mismatched `hash` (the hash doubles as a state key, so a forged one could shadow ride state). Block hash covers `(index, previous_hash, tx hashes)` via SHA-256 — timestamp/author are *not* hashed but the Aura author check uses `block.timestamp`. +- **Wallet signatures (added 2026-10-06).** `Transaction::verify_signature` accepts two schemes, each checked against its own digest. (1) The key signed the hash string itself: `Keccak256(hash.as_bytes())`, what the SDK's local key, the treasury's mint authority and the faucet do. (2) A wallet (MetaMask, Trust Wallet) signed `clutch-tx:{chain_id}:{hash}` with `personal_sign` (EIP-191): `Keccak256("\x19Ethereum Signed Message:\n" + len + text)`, with `hash` as 64 lowercase hex and no `0x` (`Transaction::wallet_signing_text`). Wallets refuse to sign a bare hash, which is why (2) exists. The wire format and the hash do not change, and block, authority and mint-cosignature checks are untouched. **This is a consensus change:** a node without it rejects any block that carries a scheme-2 transaction, so every validator must run the new image before the first wallet transaction. `SignatureKeys::personal_sign_bytes` builds the prefixed bytes; `sign`, `recover_address` and `verify` hash whatever they are given, so they work for both schemes. Pinned by `tests/wallet_signature.rs` and the `verify_signature_*` unit tests, including a signature made by `@noble/secp256k1`. - RLP decode of `from` accepts both string (Rust) and raw-bytes (JS SDK) encodings — keep compatibility when touching `rlp_encoding.rs`. - Stray `clutch-node-*.db` dirs and `output/*.json` at repo root are test/dev leftovers — safe to delete, don't commit new ones. diff --git a/src/node/signature_keys.rs b/src/node/signature_keys.rs index 8959d29..bdb2ddc 100644 --- a/src/node/signature_keys.rs +++ b/src/node/signature_keys.rs @@ -44,6 +44,18 @@ impl SignatureKeys { s.trim_start_matches("0x").trim_start_matches("0X") } + /// The bytes a wallet's `personal_sign` (EIP-191, version `0x45`) puts through Keccak-256: + /// a fixed prefix, the message length in decimal, then the message. + /// + /// `sign`, `recover_address` and `verify` all hash their input with Keccak-256, so giving them + /// these bytes in place of the message gives a wallet's digest exactly. MetaMask and Trust + /// Wallet will not sign a bare hash, and they will sign this. + pub fn personal_sign_bytes(message: &[u8]) -> Vec { + let mut bytes = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes(); + bytes.extend_from_slice(message); + bytes + } + pub fn sign(secret_key: &str, data: &[u8]) -> (String, String, i32) { let secp = Secp256k1::new(); @@ -190,4 +202,73 @@ mod tests { ), } } + + // A wallet signs with `personal_sign` (EIP-191), not over a bare hash. + + /// The committed dev key used across the repo, and its address. + const DEV_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; + const DEV_ADDRESS: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; + + #[test] + fn personal_sign_bytes_has_the_eip191_layout() { + assert_eq!( + SignatureKeys::personal_sign_bytes(b"hello"), + b"\x19Ethereum Signed Message:\n5hello".to_vec() + ); + // The length counts bytes, not characters: "é" is two bytes. + assert_eq!( + SignatureKeys::personal_sign_bytes("é".as_bytes()), + "\x19Ethereum Signed Message:\n2é".as_bytes().to_vec() + ); + // The length is written in decimal, however many digits it needs. + let long = "x".repeat(123); + let mut expected = b"\x19Ethereum Signed Message:\n123".to_vec(); + expected.extend_from_slice(long.as_bytes()); + assert_eq!(SignatureKeys::personal_sign_bytes(long.as_bytes()), expected); + } + + #[test] + fn personal_sign_digest_matches_the_published_hello_world_vector() { + // `hashMessage("Hello World")` from the ethers documentation: it does not come from this code. + let digest = Keccak256::digest(SignatureKeys::personal_sign_bytes(b"Hello World")); + assert_eq!( + hex::encode(digest), + "a1de988600a42c4b4ab089b619297c17d53cffae5d5120d82d8a92d0bb3b78f2" + ); + } + + #[test] + fn a_signature_made_by_a_javascript_library_verifies() { + // Made by @noble/secp256k1 (what the SDK uses) over personal_sign of this text, which is + // what MetaMask and Trust Wallet do: a different implementation of the same standard. + let text = "clutch-tx:1000:6f1e0b5d3a9c4e7f8a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f"; + let bytes = SignatureKeys::personal_sign_bytes(text.as_bytes()); + let r = "03a910ef2c3144e635a9cd5dfb87d0f0a8e9cde11013b5fdbbd3098eb66ede07"; + let s = "7270323fea8d69ffeedac84df538c026d630e027b4380686287350fcb8e03c91"; + assert_eq!(SignatureKeys::verify(DEV_ADDRESS, &bytes, r, s, 28), Ok(true)); + // The bare text is another digest: the same signature must not verify for it. + assert_eq!( + SignatureKeys::verify(DEV_ADDRESS, text.as_bytes(), r, s, 28), + Ok(false) + ); + // And the key behind the vector is the one we think it is. + let (r2, s2, v2) = SignatureKeys::sign(DEV_SK, &bytes); + assert_eq!(SignatureKeys::verify(DEV_ADDRESS, &bytes, &r2, &s2, v2), Ok(true)); + } + + #[test] + fn a_wallet_signature_binds_the_signer_and_the_exact_text() { + let keys = SignatureKeys::generate_new_keypair(); + let other = SignatureKeys::generate_new_keypair(); + let bytes = SignatureKeys::personal_sign_bytes(b"clutch-tx:1000:abcd"); + let (r, s, v) = SignatureKeys::sign(&keys.secret_key, &bytes); + + assert_eq!(SignatureKeys::verify(&keys.address_key, &bytes, &r, &s, v), Ok(true)); + assert_eq!(SignatureKeys::verify(&other.address_key, &bytes, &r, &s, v), Ok(false)); + let other_chain = SignatureKeys::personal_sign_bytes(b"clutch-tx:1001:abcd"); + assert_eq!( + SignatureKeys::verify(&keys.address_key, &other_chain, &r, &s, v), + Ok(false) + ); + } } diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 3520faa..a6c9109 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -109,20 +109,58 @@ impl Transaction { self.signature_v = v; } + /// The text a wallet signs for this transaction with `personal_sign`: readable in the wallet's + /// prompt, and tied to one chain. The hash is written the way `verify_hash` compares it, with + /// no `0x` and in lower case, so a wallet and the node always build the same text. + pub fn wallet_signing_text(&self) -> String { + let hash = self.hash.strip_prefix("0x").unwrap_or(&self.hash).to_lowercase(); + format!("clutch-tx:{}:{}", self.chain_id, hash) + } + + /// Sign the way a wallet does: `personal_sign` over `wallet_signing_text`. For tests and tools; + /// real wallets sign in the browser. + #[allow(dead_code)] + pub fn sign_personal(&mut self, secret_key: &str) { + let bytes = SignatureKeys::personal_sign_bytes(self.wallet_signing_text().as_bytes()); + let (r, s, v) = SignatureKeys::sign(secret_key, &bytes); + + self.signature_r = r; + self.signature_s = s; + self.signature_v = v; + } + + /// Two signatures are accepted, and each one is checked against a different digest, so one + /// cannot pass for the other: + /// + /// 1. the key signed the hash string itself (the SDK's own key, the treasury's mint authority, + /// the faucet); + /// 2. a wallet signed `wallet_signing_text` with `personal_sign` (EIP-191). MetaMask and Trust + /// Wallet will not sign a bare hash, and they will sign this. `verify_hash` runs first and + /// ties `hash` to `chain_id`, and the text names the chain, so the signature cannot move. fn verify_signature(&self) -> Result<(), String> { let from_address = &self.from; - let data = self.hash.as_bytes(); let r = &self.signature_r; let s = &self.signature_s; let v = self.signature_v; - match SignatureKeys::verify(from_address, data, r, s, v) { - Ok(true) => Ok(()), - Ok(false) => Err( + let direct = SignatureKeys::verify(from_address, self.hash.as_bytes(), r, s, v); + if let Ok(true) = direct { + return Ok(()); + } + + let wallet_bytes = SignatureKeys::personal_sign_bytes(self.wallet_signing_text().as_bytes()); + if let Ok(true) = SignatureKeys::verify(from_address, &wallet_bytes, r, s, v) { + return Ok(()); + } + + // Neither matched. A malformed signature keeps its own error; a well-formed one from the + // wrong key is a mismatch. + match direct { + Err(e) => Err(e), + Ok(_) => Err( "Verification failed: transaction signature does not match the from address" .to_string(), ), - Err(e) => Err(e), } } @@ -862,4 +900,141 @@ mod tests { tx.verify_hash() ); } + + // --- Signatures: the hash string (SDK key) or a wallet's `personal_sign` --- + + /// The committed dev key used across the repo, and its address. + const DEV_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; + const DEV_FROM: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; + + /// What a wallet signs, written out here on purpose instead of calling `wallet_signing_text`, + /// so these tests pin the format that the SDK and the wallets must produce. + fn wallet_signed(tx: &mut Transaction, secret: &str, text: &str) { + let bytes = SignatureKeys::personal_sign_bytes(text.as_bytes()); + let (r, s, v) = SignatureKeys::sign(secret, &bytes); + tx.signature_r = r; + tx.signature_s = s; + tx.signature_v = v; + } + + fn bare_hash(tx: &Transaction) -> String { + tx.hash.trim_start_matches("0x").to_string() + } + + #[test] + fn wallet_signing_text_names_the_chain_and_the_bare_hash() { + let tx = tf(DEV_FROM, 1, "0xB"); + let bare = bare_hash(&tx); + assert_eq!(bare.len(), 64); + assert_eq!(tx.wallet_signing_text(), format!("clutch-tx:2077:{}", bare)); + + // A hash that reached the node in upper case, with a prefix, gives the same text. + let mut wire = tx.clone(); + wire.hash = format!("0x{}", bare.to_uppercase()); + assert_eq!(wire.wallet_signing_text(), tx.wallet_signing_text()); + } + + #[test] + fn verify_signature_accepts_a_wallet_signature() { + let mut tx = tf(DEV_FROM, 1, "0xB"); + let text = format!("clutch-tx:2077:{}", bare_hash(&tx)); + wallet_signed(&mut tx, DEV_SK, &text); + assert_eq!(tx.verify_signature(), Ok(())); + } + + #[test] + fn verify_signature_accepts_the_sign_personal_helper() { + let mut tx = tf(DEV_FROM, 1, "0xB"); + tx.sign_personal(DEV_SK); + assert_eq!(tx.verify_signature(), Ok(())); + } + + #[test] + fn verify_signature_still_accepts_a_signature_over_the_hash_string() { + // Node-built hash, with the 0x prefix (the treasury and the faucet sign this way). + let mut tx = tf(DEV_FROM, 1, "0xB"); + tx.sign(DEV_SK); + assert_eq!(tx.verify_signature(), Ok(())); + + // Wire hash, with no prefix (the SDK signs this way). + let mut wire = tf(DEV_FROM, 2, "0xB"); + wire.hash = bare_hash(&wire); + wire.sign(DEV_SK); + assert_eq!(wire.verify_signature(), Ok(())); + } + + #[test] + fn verify_signature_accepts_the_signature_a_javascript_library_made() { + // The text and the signature come from @noble/secp256k1 over `personal_sign`, as the SDK's + // wallet signer will produce them. The Transaction is built by hand: `verify_signature` + // reads only `from`, `chain_id`, `hash` and the signature. + let mut tx = tf(DEV_FROM, 1, "0xB"); + tx.chain_id = 1000; + tx.hash = "6f1e0b5d3a9c4e7f8a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f".to_string(); + tx.signature_r = "03a910ef2c3144e635a9cd5dfb87d0f0a8e9cde11013b5fdbbd3098eb66ede07".to_string(); + tx.signature_s = "7270323fea8d69ffeedac84df538c026d630e027b4380686287350fcb8e03c91".to_string(); + tx.signature_v = 28; + assert_eq!( + tx.wallet_signing_text(), + "clutch-tx:1000:6f1e0b5d3a9c4e7f8a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f" + ); + assert_eq!(tx.verify_signature(), Ok(())); + + // The same signature is for that hash on that chain only. + let mut other_chain = tx.clone(); + other_chain.chain_id = 2077; + assert!(other_chain.verify_signature().is_err()); + } + + #[test] + fn verify_signature_rejects_a_wallet_signature_for_another_chain() { + let mut tx = tf(DEV_FROM, 1, "0xB"); + // Signed for chain 1; the transaction (and its hash) is for chain 2077. + let text = format!("clutch-tx:1:{}", bare_hash(&tx)); + wallet_signed(&mut tx, DEV_SK, &text); + let err = tx.verify_signature().unwrap_err(); + assert!(err.contains("does not match the from address"), "got: {}", err); + } + + #[test] + fn verify_signature_rejects_a_wallet_signature_from_another_key() { + let mut tx = tf(DEV_FROM, 1, "0xB"); + let intruder = SignatureKeys::generate_new_keypair(); + let text = format!("clutch-tx:2077:{}", bare_hash(&tx)); + wallet_signed(&mut tx, &intruder.secret_key, &text); + let err = tx.verify_signature().unwrap_err(); + assert!(err.contains("does not match the from address"), "got: {}", err); + } + + #[test] + fn verify_signature_rejects_a_wallet_signature_for_another_transaction() { + let mut approved = tf(DEV_FROM, 1, "0xB"); + approved.sign_personal(DEV_SK); + + // Same sender, nonce and chain, but a different recipient: a different hash. + let mut other = tf(DEV_FROM, 1, "0xC"); + assert_ne!(other.hash, approved.hash); + other.signature_r = approved.signature_r.clone(); + other.signature_s = approved.signature_s.clone(); + other.signature_v = approved.signature_v; + assert!(other.verify_signature().is_err()); + } + + #[test] + fn verify_signature_rejects_a_flipped_recovery_id() { + let mut tx = tf(DEV_FROM, 1, "0xB"); + tx.sign_personal(DEV_SK); + tx.signature_v = if tx.signature_v == 27 { 28 } else { 27 }; + assert!(tx.verify_signature().is_err()); + } + + #[test] + fn verify_signature_keeps_the_error_for_a_malformed_signature() { + let mut tx = tf(DEV_FROM, 1, "0xB"); + tx.signature_r = "zz".to_string(); + tx.signature_s = "zz".to_string(); + tx.signature_v = 27; + let err = tx.verify_signature().unwrap_err(); + assert!(err.contains("Invalid hex in r"), "got: {}", err); + } } diff --git a/tests/wallet_signature.rs b/tests/wallet_signature.rs new file mode 100644 index 0000000..9b5ba3d --- /dev/null +++ b/tests/wallet_signature.rs @@ -0,0 +1,146 @@ +//! A transaction signed by a wallet (`personal_sign`, EIP-191) goes through the whole node path: +//! the pool, the authored block and its re-validation. Wallets such as MetaMask and Trust Wallet +//! will not sign a bare hash, so this is the way their users reach the chain. +//! +//! The signing text is written out in full in these tests on purpose. They pin the format that the +//! SDK and the wallets have to produce, and do not ask the node what it expects. + +use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::signature_keys::SignatureKeys; +use clutch_node::node::transactions::chain_init::ChainInit; +use clutch_node::node::transactions::function_call::FunctionCall; +use clutch_node::node::transactions::transaction::Transaction; +use clutch_node::node::transactions::transfer::Transfer; +use serial_test::serial; + +const AUTHOR_PK: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; +const AUTHOR_SK: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; +const FAUCET_PK: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; +const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; +const RECIPIENT: &str = "0x1111111111111111111111111111111111111111"; +const CHAIN_ID: u64 = 2077; +const TX_FEE: u64 = 1000; + +fn chain(name: &str) -> Blockchain { + // A failed assertion never reaches shutdown_blockchain(), so start from a clean slate. + let _ = std::fs::remove_dir_all(format!("{}.db", name)); + Blockchain::new( + name.to_string(), + AUTHOR_PK.to_string(), + AUTHOR_SK.to_string(), + true, + vec![AUTHOR_PK.to_string()], + ChainInit { + chain_id: CHAIN_ID, + is_testnet: true, + tx_fee: TX_FEE, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: AUTHOR_PK.to_string(), + faucet_address: FAUCET_PK.to_string(), + faucet_allocation: 1_000_000_000_000_000, + mint_cosigners: Vec::new(), + mint_threshold: 0, + ride_auto_release_secs: 0, + }, + ) +} + +fn transfer(from: &str, nonce: u64, value: u64) -> Transaction { + Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Transfer(Transfer { + to: RECIPIENT.to_string(), + value, + }), + ) +} + +/// Sign `tx` as a wallet would, over `text`. +fn wallet_sign(tx: &mut Transaction, secret: &str, text: &str) { + let bytes = SignatureKeys::personal_sign_bytes(text.as_bytes()); + let (r, s, v) = SignatureKeys::sign(secret, &bytes); + tx.signature_r = r; + tx.signature_s = s; + tx.signature_v = v; +} + +/// Fund `to` from the faucet with an ordinary signed transfer, and author the block. +fn fund(chain: &Blockchain, to: &str, value: u64) { + let mut tx = Transaction::new_transaction( + FAUCET_PK.to_string(), + 1, + CHAIN_ID, + FunctionCall::Transfer(Transfer { + to: to.to_string(), + value, + }), + ); + tx.sign(FAUCET_SK); + chain.add_transaction_to_pool(&tx).expect("funding transfer"); + chain.author_new_block().expect("funding block"); +} + +#[test] +#[serial] +fn a_wallet_signed_transfer_is_accepted_and_applied() { + let mut chain = chain("test-wallet-sig-transfer"); + let wallet = SignatureKeys::generate_new_keypair(); + fund(&chain, &wallet.address_key, 10_000); + assert_eq!(chain.get_account_balance(&wallet.address_key), 10_000); + + let mut tx = transfer(&wallet.address_key, 1, 500); + let text = format!("clutch-tx:{}:{}", CHAIN_ID, tx.hash.trim_start_matches("0x")); + wallet_sign(&mut tx, &wallet.secret_key, &text); + + chain + .add_transaction_to_pool(&tx) + .unwrap_or_else(|e| panic!("pool refused a wallet-signed transfer: {}", e)); + let block = chain.author_new_block().expect("author_new_block"); + assert_eq!(block.transactions.len(), 1, "the block must carry the wallet's transfer"); + + assert_eq!( + chain.get_account_balance(&wallet.address_key), + 10_000 - 500 - TX_FEE, + "the wallet pays value + fee" + ); + assert_eq!(chain.get_account_balance(&RECIPIENT.to_string()), 500); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn a_wallet_signature_made_for_another_chain_is_refused() { + let mut chain = chain("test-wallet-sig-other-chain"); + let wallet = SignatureKeys::generate_new_keypair(); + fund(&chain, &wallet.address_key, 10_000); + + let mut tx = transfer(&wallet.address_key, 1, 500); + // The wallet was shown chain 1; this node runs chain 2077. + let text = format!("clutch-tx:1:{}", tx.hash.trim_start_matches("0x")); + wallet_sign(&mut tx, &wallet.secret_key, &text); + + let err = chain.add_transaction_to_pool(&tx).unwrap_err(); + assert!(err.contains("does not match the from address"), "got: {}", err); + assert_eq!(chain.get_account_balance(&RECIPIENT.to_string()), 0); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn a_wallet_signature_from_another_key_is_refused() { + let mut chain = chain("test-wallet-sig-other-key"); + let wallet = SignatureKeys::generate_new_keypair(); + let intruder = SignatureKeys::generate_new_keypair(); + fund(&chain, &wallet.address_key, 10_000); + + let mut tx = transfer(&wallet.address_key, 1, 500); + let text = format!("clutch-tx:{}:{}", CHAIN_ID, tx.hash.trim_start_matches("0x")); + wallet_sign(&mut tx, &intruder.secret_key, &text); + + let err = chain.add_transaction_to_pool(&tx).unwrap_err(); + assert!(err.contains("does not match the from address"), "got: {}", err); + chain.shutdown_blockchain(); +}