Skip to content
Draft
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ lazy_static = "1.5"
pocket-ic = "13.0.0"
pretty_assertions = "1.4"
bitcoin = "0.32"
# Keccak-256 (Ethereum's hash, distinct from NIST SHA-3) for deriving Ethereum
# addresses from a public key. Already a transitive dep in the lockfile; this
# declaration makes it directly available to the backend crate.
tiny-keccak = { version = "2.0", features = ["keccak"] }
paste = "1.0"
# Used for `futures::future::join_all` to run independent HTTP outcalls in
# parallel (e.g. exchange price providers). Already a transitive dep via the
Expand Down
2 changes: 2 additions & 0 deletions src/backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ crate-type = ["cdylib"]

[dependencies]
bitcoin = { workspace = true }
bs58 = { workspace = true }
canbench-rs = { workspace = true, optional = true }
candid = { workspace = true }
futures = { workspace = true }
Expand All @@ -28,6 +29,7 @@ serde_bytes = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
shared = { path = "../shared" }
tiny-keccak = { workspace = true }

[dev-dependencies]
candid_parser = { workspace = true }
Expand Down
144 changes: 144 additions & 0 deletions src/backend/src/signer/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use shared::types::signer::{
},
AllowSigningError, GetAllowedCyclesError,
};
use tiny_keccak::{Hasher, Keccak};

use super::canister_ids::{CYCLES_LEDGER, SIGNER};
use crate::state::read_config;
Expand Down Expand Up @@ -238,6 +239,83 @@ pub async fn btc_principal_to_p2wpkh_address(
}
}

/// Keccak-256 of `data`. This is Ethereum's hash function — the original Keccak, **not** the
/// NIST-standardized SHA-3 (they differ in padding). Used for both the address derivation and the
/// EIP-55 checksum below.
fn keccak256(data: &[u8]) -> [u8; 32] {
let mut hasher = Keccak::v256();
let mut output = [0u8; 32];
hasher.update(data);
hasher.finalize(&mut output);
output
}

/// Encodes 20 address bytes as an EIP-55 mixed-case checksum string (with `0x` prefix): a hex
/// letter is uppercased when the corresponding nibble of the Keccak-256 of the lowercase hex is `>=
/// 8`. See <https://eips.ethereum.org/EIPS/eip-55>.
fn to_eip55_checksum(address: &[u8]) -> String {
let hex_addr = hex::encode(address);
let hash = keccak256(hex_addr.as_bytes());
let mut out = String::with_capacity(2 + hex_addr.len());
out.push_str("0x");
for (i, c) in hex_addr.char_indices() {
let nibble = if i % 2 == 0 {
hash[i / 2] >> 4
} else {
hash[i / 2] & 0x0f
};
if c.is_ascii_alphabetic() && nibble >= 8 {
out.push(c.to_ascii_uppercase());
} else {
out.push(c);
}
}
out
}

/// Derives the EIP-55-checksummed Ethereum address from a SEC1 secp256k1 public key (compressed or
/// uncompressed). The address is the last 20 bytes of the Keccak-256 of the 64-byte uncompressed
/// public key, excluding the leading `0x04` SEC1 tag.
///
/// # Errors
/// - The bytes are not a valid secp256k1 public key.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "wired into sign_onramper_widget_url in the stacked follow-up PR"
)
)]
pub(crate) fn eth_address_from_ecdsa_pubkey(pubkey: &[u8]) -> Result<String, String> {
let public_key = bitcoin::secp256k1::PublicKey::from_slice(pubkey)
.map_err(|e| format!("Invalid secp256k1 public key: {e}"))?;
let uncompressed = public_key.serialize_uncompressed();
let hash = keccak256(&uncompressed[1..]);
Ok(to_eip55_checksum(&hash[12..]))
}

/// Derives the Solana address — base58 of the 32-byte Ed25519 public key — from a raw Ed25519
/// public key.
///
/// # Errors
/// - The public key is not exactly 32 bytes.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "wired into sign_onramper_widget_url in the stacked follow-up PR"
)
)]
pub(crate) fn sol_address_from_ed25519_pubkey(pubkey: &[u8]) -> Result<String, String> {
if pubkey.len() != 32 {
return Err(format!(
"Invalid Ed25519 public key length: expected 32, got {}",
pubkey.len()
));
}
Ok(bs58::encode(pubkey).into_string())
}

/// Tops up the backend canister account on the cycles ledger.
///
/// # Context
Expand Down Expand Up @@ -337,3 +415,69 @@ pub async fn top_up_cycles_ledger(request: TopUpCyclesLedgerRequest) -> TopUpCyc
.into()
}
}

#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;

use super::{eth_address_from_ecdsa_pubkey, sol_address_from_ed25519_pubkey};

// secp256k1 private key `1` — a canonical known-answer vector. Its public key derives the
// Ethereum address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf (widely published).
const PRIV_KEY_ONE_COMPRESSED_PUBKEY: &str =
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
const PRIV_KEY_ONE_UNCOMPRESSED_PUBKEY: &str = "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8";
const PRIV_KEY_ONE_ETH_ADDRESS: &str = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf";

fn hex_to_bytes(s: &str) -> Vec<u8> {
hex::decode(s).expect("valid hex test vector")
}

#[test]
fn eth_address_from_compressed_pubkey_matches_known_vector() {
let address =
eth_address_from_ecdsa_pubkey(&hex_to_bytes(PRIV_KEY_ONE_COMPRESSED_PUBKEY)).unwrap();

// The address is correctly derived AND EIP-55 checksummed (mixed case).
assert_eq!(address, PRIV_KEY_ONE_ETH_ADDRESS);
}

#[test]
fn eth_address_is_independent_of_pubkey_sec1_encoding() {
let from_compressed =
eth_address_from_ecdsa_pubkey(&hex_to_bytes(PRIV_KEY_ONE_COMPRESSED_PUBKEY)).unwrap();
let from_uncompressed =
eth_address_from_ecdsa_pubkey(&hex_to_bytes(PRIV_KEY_ONE_UNCOMPRESSED_PUBKEY)).unwrap();

assert_eq!(from_compressed, from_uncompressed);
}

#[test]
fn eth_address_rejects_invalid_pubkey() {
assert!(eth_address_from_ecdsa_pubkey(&[0x00, 0x01, 0x02]).is_err());
}

#[test]
fn sol_address_of_zero_pubkey_is_the_known_base58_string() {
// base58 of 32 zero bytes is 32 '1's — the Solana System Program id.
let address = sol_address_from_ed25519_pubkey(&[0u8; 32]).unwrap();

assert_eq!(address, "1".repeat(32));
}

#[test]
fn sol_address_base58_round_trips_to_the_pubkey() {
let pubkey: [u8; 32] = core::array::from_fn(|i| u8::try_from(i).unwrap());

let address = sol_address_from_ed25519_pubkey(&pubkey).unwrap();
let decoded = bs58::decode(&address).into_vec().unwrap();

assert_eq!(decoded, pubkey);
}

#[test]
fn sol_address_rejects_wrong_length_pubkey() {
assert!(sol_address_from_ed25519_pubkey(&[0u8; 31]).is_err());
assert!(sol_address_from_ed25519_pubkey(&[0u8; 33]).is_err());
}
}
Loading