use anda_core::{HttpFeatures, RPCResponse};
use anda_engine::context::Web3ClientFeatures;
use anda_web3_client::{
Agent, Client, Identity, identity_from_pem, identity_from_secret, load_identity,
};
use axum::{Router, body::Bytes, http::StatusCode, response::IntoResponse, routing::post};
use candid::Principal;
use cbor2::to_canonical_vec;
use ic_auth_types::ByteBufB64;
use ic_cose::client::CoseSDK;
use ic_cose_types::CanisterCaller;
use std::sync::Arc;
const SECP256K1_IDENTITY_PEM: &str = "-----BEGIN EC PARAMETERS-----
BgUrgQQACg==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHQCAQEEIAgy7nZEcVHkQ4Z1Kdqby8SwyAiyKDQmtbEHTIM+WNeBoAcGBSuBBAAK
oUQDQgAEgO87rJ1ozzdMvJyZQ+GABDqUxGLvgnAnTlcInV3NuhuPv4O3VGzMGzeB
N3d26cRxD99TPtm8uo2OuzKhSiq6EQ==
-----END EC PRIVATE KEY-----
";
fn boxed_identity(secret: [u8; 32]) -> Arc<dyn Identity> {
Arc::from(identity_from_secret(secret))
}
fn no_proxy_http_client() -> reqwest::Client {
reqwest::Client::builder().no_proxy().build().unwrap()
}
async fn client_with_identity(allow_http: bool) -> Client {
Client::builder()
.with_identity(boxed_identity([7; 32]))
.with_root_secret([9; 48])
.with_http_client(no_proxy_http_client())
.with_allow_http(allow_http)
.build()
.await
.unwrap()
}
fn rpc_response(result: RPCResponse) -> Vec<u8> {
to_canonical_vec(&result).unwrap()
}
async fn rpc_handler(body: Bytes) -> impl IntoResponse {
if body.is_empty() {
return (StatusCode::BAD_REQUEST, "missing body".as_bytes().to_vec());
}
let payload = to_canonical_vec(&"pong".to_string()).unwrap();
(StatusCode::OK, rpc_response(Ok(ByteBufB64::from(payload))))
}
async fn echo_handler(body: Bytes) -> impl IntoResponse {
if body.is_empty() {
(StatusCode::OK, b"ok".to_vec())
} else {
(StatusCode::OK, body.to_vec())
}
}
async fn spawn_server() -> String {
let app = Router::new()
.route("/echo", post(echo_handler))
.route("/rpc", post(rpc_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test(flavor = "current_thread")]
async fn identities_and_builder_options_are_stable() {
let anonymous = load_identity("Anonymous").unwrap();
assert_eq!(anonymous.sender().unwrap(), Principal::anonymous());
let secret_hex = hex::encode([3_u8; 32]);
let from_hex = load_identity(&secret_hex).unwrap();
assert_eq!(
from_hex.sender().unwrap(),
identity_from_secret([3_u8; 32]).sender().unwrap()
);
let invalid = match load_identity("abcd") {
Ok(_) => panic!("expected invalid short hex secret to fail"),
Err(err) => err,
};
assert!(invalid.to_string().contains("invalid id_secret"));
assert!(identity_from_pem("/definitely/missing/identity.pem").is_err());
let identity = boxed_identity([4; 32]);
let agent = Agent::builder()
.with_url("https://icp-api.io")
.with_arc_identity(identity.clone())
.build()
.unwrap();
let cose_canister = Principal::from_text("aaaaa-aa").unwrap();
let client = Client::builder()
.with_ic_host("https://icp-api.io")
.with_root_secret([1; 48])
.with_cose_canister(cose_canister)
.with_identity(identity.clone())
.with_agent(agent)
.with_http_client(no_proxy_http_client())
.with_allow_http(true)
.build()
.await
.unwrap();
assert_eq!(client.get_principal(), identity.sender().unwrap());
assert_eq!(CoseSDK::canister(&client), &cose_canister);
}
#[tokio::test(flavor = "current_thread")]
async fn pem_default_builder_and_canister_error_paths_are_exercised() {
let pem_path = std::env::temp_dir().join(format!(
"anda-web3-identity-{}-{}.pem",
std::process::id(),
"secp256k1"
));
std::fs::write(&pem_path, SECP256K1_IDENTITY_PEM).unwrap();
let from_pem = identity_from_pem(pem_path.to_str().unwrap()).unwrap();
let loaded_from_pem = load_identity(pem_path.to_str().unwrap()).unwrap();
assert_eq!(
from_pem.sender().unwrap(),
loaded_from_pem.sender().unwrap()
);
std::fs::remove_file(&pem_path).unwrap();
let endpoint = spawn_server().await;
let default_client = Client::builder()
.with_ic_host(&endpoint)
.with_allow_http(true)
.build()
.await
.unwrap();
assert_ne!(default_client.get_principal(), Principal::anonymous());
let identity = boxed_identity([8; 32]);
let agent = Agent::builder()
.with_url(endpoint)
.with_arc_identity(identity.clone())
.with_verify_query_signatures(false)
.build()
.unwrap();
let client = Client::builder()
.with_identity(identity)
.with_agent(agent)
.with_http_client(no_proxy_http_client())
.with_allow_http(true)
.build()
.await
.unwrap();
let canister = Principal::anonymous();
let query: Result<String, _> =
CanisterCaller::canister_query(&client, &canister, "greet", ("world",)).await;
assert!(query.is_err());
let update: Result<String, _> =
CanisterCaller::canister_update(&client, &canister, "greet", ("world",)).await;
assert!(update.is_err());
assert!(
Web3ClientFeatures::canister_query_raw(
&client,
canister,
"raw_query".to_string(),
vec![1, 2, 3],
)
.await
.is_err()
);
assert!(
Web3ClientFeatures::canister_update_raw(
&client,
Principal::anonymous(),
"raw_update".to_string(),
vec![4, 5, 6],
)
.await
.is_err()
);
}
#[tokio::test(flavor = "current_thread")]
async fn crypto_feature_methods_sign_verify_and_reject_bad_signatures() {
let client = client_with_identity(false).await;
let path = vec![b"agent".to_vec(), b"tool".to_vec()];
let message = b"message";
assert_eq!(
client
.sign_envelope([1; 32])
.await
.unwrap()
.digest
.unwrap()
.0,
[1; 32]
);
assert_eq!(
Web3ClientFeatures::get_principal(&client),
client.get_principal()
);
assert_eq!(
Web3ClientFeatures::sign_envelope(&client, [2; 32])
.await
.unwrap()
.digest
.unwrap()
.0,
[2; 32]
);
assert_eq!(
Web3ClientFeatures::a256gcm_key(&client, path.clone())
.await
.unwrap()
.len(),
32
);
let ed_sig = Web3ClientFeatures::ed25519_sign_message(&client, path.clone(), message)
.await
.unwrap();
Web3ClientFeatures::ed25519_verify(&client, path.clone(), message, &ed_sig)
.await
.unwrap();
assert!(
Web3ClientFeatures::ed25519_verify(&client, path.clone(), b"wrong", &ed_sig)
.await
.is_err()
);
assert_eq!(
Web3ClientFeatures::ed25519_public_key(&client, path.clone())
.await
.unwrap()
.len(),
32
);
let schnorr = Web3ClientFeatures::secp256k1_sign_message_bip340(&client, path.clone(), message)
.await
.unwrap();
Web3ClientFeatures::secp256k1_verify_bip340(&client, path.clone(), message, &schnorr)
.await
.unwrap();
assert!(
Web3ClientFeatures::secp256k1_verify_bip340(&client, path.clone(), b"wrong", &schnorr)
.await
.is_err()
);
let ecdsa_message =
Web3ClientFeatures::secp256k1_sign_message_ecdsa(&client, path.clone(), message)
.await
.unwrap();
assert_eq!(ecdsa_message.len(), 64);
let digest = [5_u8; 32];
let ecdsa_digest =
Web3ClientFeatures::secp256k1_sign_digest_ecdsa(&client, path.clone(), &digest)
.await
.unwrap();
Web3ClientFeatures::secp256k1_verify_ecdsa(&client, path.clone(), &digest, &ecdsa_digest)
.await
.unwrap();
assert!(
Web3ClientFeatures::secp256k1_verify_ecdsa(
&client,
path.clone(),
&[6_u8; 32],
&ecdsa_digest
)
.await
.is_err()
);
assert_eq!(
Web3ClientFeatures::secp256k1_public_key(&client, path)
.await
.unwrap()
.len(),
33
);
}
#[tokio::test(flavor = "current_thread")]
async fn http_guards_local_calls_and_signed_rpc_paths_are_exercised() {
let guarded = client_with_identity(false).await;
assert!(
HttpFeatures::https_call(
&guarded,
"http://example.test",
http::Method::GET,
None,
None
)
.await
.unwrap_err()
.to_string()
.contains("Invalid url")
);
assert!(
HttpFeatures::https_signed_call(
&guarded,
"http://example.test",
http::Method::GET,
[0; 32],
None,
None,
)
.await
.unwrap_err()
.to_string()
.contains("Invalid url")
);
let guarded_rpc: Result<String, _> =
HttpFeatures::https_signed_rpc(&guarded, "http://example.test/rpc", "ping", &()).await;
assert!(guarded_rpc.unwrap_err().to_string().contains("Invalid url"));
assert!(
Web3ClientFeatures::https_call(
&guarded,
"http://example.test".to_string(),
http::Method::GET,
None,
None,
)
.await
.unwrap_err()
.to_string()
.contains("Invalid url")
);
assert!(
Web3ClientFeatures::https_signed_call(
&guarded,
"http://example.test".to_string(),
http::Method::GET,
[0; 32],
None,
None,
)
.await
.unwrap_err()
.to_string()
.contains("Invalid url")
);
assert!(
Web3ClientFeatures::https_signed_rpc_raw(
&guarded,
"http://example.test/rpc".to_string(),
"ping".to_string(),
Vec::new(),
)
.await
.unwrap_err()
.to_string()
.contains("Invalid url")
);
let endpoint = spawn_server().await;
let client = client_with_identity(true).await;
let mut headers = http::HeaderMap::new();
headers.insert("x-test", "1".parse().unwrap());
let res = HttpFeatures::https_call(
&client,
&format!("{endpoint}/echo"),
http::Method::POST,
Some(headers.clone()),
Some(b"plain".to_vec()),
)
.await
.unwrap();
assert_eq!(res.text().await.unwrap(), "plain");
let res = HttpFeatures::https_signed_call(
&client,
&format!("{endpoint}/echo"),
http::Method::POST,
[8; 32],
Some(headers.clone()),
Some(b"signed".to_vec()),
)
.await
.unwrap();
assert_eq!(res.text().await.unwrap(), "signed");
let res = Web3ClientFeatures::https_call(
&client,
format!("{endpoint}/echo"),
http::Method::POST,
Some(headers.clone()),
None,
)
.await
.unwrap();
assert_eq!(res.text().await.unwrap(), "ok");
let res = Web3ClientFeatures::https_signed_call(
&client,
format!("{endpoint}/echo"),
http::Method::POST,
[9; 32],
Some(headers),
Some(b"raw-signed".to_vec()),
)
.await
.unwrap();
assert_eq!(res.text().await.unwrap(), "raw-signed");
let rpc: String =
HttpFeatures::https_signed_rpc(&client, &format!("{endpoint}/rpc"), "ping", &("arg",))
.await
.unwrap();
assert_eq!(rpc, "pong");
let raw = Web3ClientFeatures::https_signed_rpc_raw(
&client,
format!("{endpoint}/rpc"),
"ping".into(),
vec![1, 2],
)
.await
.unwrap();
assert!(!raw.is_empty());
}