Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -79,5 +79,6 @@ docker compose up -d # 3-node local net from ghcr image (this repo
- `Blockchain` is shared as `Arc<Mutex<...>>` (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.
81 changes: 81 additions & 0 deletions src/node/signature_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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();

Expand Down Expand Up @@ -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)
);
}
}
185 changes: 180 additions & 5 deletions src/node/transactions/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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);
}
}
Loading
Loading