use zeroize::Zeroizing; use rand_core::{RngCore, OsRng}; use scale::Encode; use serai_client::{ primitives::{Amount, NetworkId, Coin, Balance, ExternalAddress}, validator_sets::primitives::ExternalKey, in_instructions::primitives::{InInstruction, RefundableInInstruction, Shorthand}, }; use dockertest::{PullPolicy, Image, StartPolicy, TestBodySpecification, DockerOperations}; use crate::*; pub const RPC_USER: &str = "serai"; pub const RPC_PASS: &str = "seraidex"; pub const BTC_PORT: u32 = 8332; pub const ETH_PORT: u32 = 8545; pub const XMR_PORT: u32 = 18081; pub fn bitcoin_instance() -> (TestBodySpecification, u32) { serai_docker_tests::build("bitcoin".to_string()); let composition = TestBodySpecification::with_image( Image::with_repository("serai-dev-bitcoin").pull_policy(PullPolicy::Never), ) .set_publish_all_ports(true); (composition, BTC_PORT) } pub fn ethereum_instance() -> (TestBodySpecification, u32) { serai_docker_tests::build("ethereum".to_string()); let composition = TestBodySpecification::with_image( Image::with_repository("serai-dev-ethereum").pull_policy(PullPolicy::Never), ) .set_start_policy(StartPolicy::Strict) .set_publish_all_ports(true); (composition, ETH_PORT) } pub fn monero_instance() -> (TestBodySpecification, u32) { serai_docker_tests::build("monero".to_string()); let composition = TestBodySpecification::with_image( Image::with_repository("serai-dev-monero").pull_policy(PullPolicy::Never), ) .set_start_policy(StartPolicy::Strict) .set_publish_all_ports(true); (composition, XMR_PORT) } pub fn network_instance(network: NetworkId) -> (TestBodySpecification, u32) { match network { NetworkId::Bitcoin => bitcoin_instance(), NetworkId::Ethereum => ethereum_instance(), NetworkId::Monero => monero_instance(), NetworkId::Serai => { panic!("Serai is not a valid network to spawn an instance of for a processor") } } } pub fn network_rpc(network: NetworkId, ops: &DockerOperations, handle: &str) -> String { let (ip, port) = ops .handle(handle) .host_port(match network { NetworkId::Bitcoin => BTC_PORT, NetworkId::Ethereum => ETH_PORT, NetworkId::Monero => XMR_PORT, NetworkId::Serai => panic!("getting port for external network yet it was Serai"), }) .unwrap(); format!("http://{RPC_USER}:{RPC_PASS}@{ip}:{port}") } pub fn confirmations(network: NetworkId) -> usize { use processor::networks::*; match network { NetworkId::Bitcoin => Bitcoin::CONFIRMATIONS, NetworkId::Ethereum => Ethereum::::CONFIRMATIONS, NetworkId::Monero => Monero::CONFIRMATIONS, NetworkId::Serai => panic!("getting confirmations required for Serai"), } } #[derive(Clone)] pub enum Wallet { Bitcoin { private_key: bitcoin_serai::bitcoin::PrivateKey, public_key: bitcoin_serai::bitcoin::PublicKey, input_tx: bitcoin_serai::bitcoin::Transaction, }, Ethereum { rpc_url: String, key: ::F, nonce: u64, }, Monero { handle: String, spend_key: Zeroizing, view_pair: monero_wallet::ViewPair, last_tx: (usize, [u8; 32]), }, } // TODO: Merge these functions with the processor's tests, which offers very similar functionality impl Wallet { pub async fn new(network: NetworkId, ops: &DockerOperations, handle: String) -> Wallet { let rpc_url = network_rpc(network, ops, &handle); match network { NetworkId::Bitcoin => { use bitcoin_serai::{ bitcoin::{ secp256k1::{SECP256K1, SecretKey}, PrivateKey, PublicKey, ScriptBuf, Network, Address, }, rpc::Rpc, }; let secret_key = SecretKey::new(&mut rand_core::OsRng); let private_key = PrivateKey::new(secret_key, Network::Regtest); let public_key = PublicKey::from_private_key(SECP256K1, &private_key); let main_addr = Address::p2pkh(public_key, Network::Regtest); let rpc = Rpc::new(rpc_url).await.expect("couldn't connect to the Bitcoin RPC"); let new_block = rpc.get_latest_block_number().await.unwrap() + 1; rpc .rpc_call::>("generatetoaddress", serde_json::json!([1, main_addr])) .await .unwrap(); // Mine it to maturity rpc .rpc_call::>( "generatetoaddress", serde_json::json!([100, Address::p2sh(&ScriptBuf::new(), Network::Regtest).unwrap()]), ) .await .unwrap(); let funds = rpc .get_block(&rpc.get_block_hash(new_block).await.unwrap()) .await .unwrap() .txdata .swap_remove(0); Wallet::Bitcoin { private_key, public_key, input_tx: funds } } NetworkId::Ethereum => { use ciphersuite::{group::ff::Field, Secp256k1}; use ethereum_serai::alloy::{ primitives::{U256, Address}, simple_request_transport::SimpleRequest, rpc_client::ClientBuilder, provider::{Provider, RootProvider}, network::Ethereum, }; let key = ::F::random(&mut OsRng); let address = ethereum_serai::crypto::address(&(::generator() * key)); let provider = RootProvider::<_, Ethereum>::new( ClientBuilder::default().transport(SimpleRequest::new(rpc_url.clone()), true), ); provider .raw_request::<_, ()>( "anvil_setBalance".into(), [Address(address.into()).to_string(), { let nine_decimals = U256::from(1_000_000_000u64); (U256::from(100u64) * nine_decimals * nine_decimals).to_string() }], ) .await .unwrap(); Wallet::Ethereum { rpc_url: rpc_url.clone(), key, nonce: 0 } } NetworkId::Monero => { use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar}; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{rpc::Rpc, address::Network, ViewPair}; let spend_key = Scalar::random(&mut OsRng); let view_key = Scalar::random(&mut OsRng); let view_pair = ViewPair::new(ED25519_BASEPOINT_POINT * spend_key, Zeroizing::new(view_key)).unwrap(); let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); let height = rpc.get_height().await.unwrap(); // Mines 200 blocks so sufficient decoys exist, as only 60 is needed for maturity rpc.generate_blocks(&view_pair.legacy_address(Network::Mainnet), 200).await.unwrap(); let block = rpc.get_block(rpc.get_block_hash(height).await.unwrap()).await.unwrap(); Wallet::Monero { handle, spend_key: Zeroizing::new(spend_key), view_pair, last_tx: (height, block.miner_transaction.hash()), } } NetworkId::Serai => panic!("creating a wallet for for Serai"), } } pub async fn send_to_address( &mut self, ops: &DockerOperations, to: &ExternalKey, instruction: Option, ) -> (Vec, Balance) { match self { Wallet::Bitcoin { private_key, public_key, ref mut input_tx } => { use bitcoin_serai::bitcoin::{ secp256k1::{SECP256K1, Message}, key::{XOnlyPublicKey, TweakedPublicKey}, consensus::Encodable, sighash::{EcdsaSighashType, SighashCache}, script::{PushBytesBuf, Script, ScriptBuf, Builder}, OutPoint, Sequence, Witness, TxIn, Amount, TxOut, absolute::LockTime, transaction::{Version, Transaction}, }; const AMOUNT: u64 = 100000000; let mut tx = Transaction { version: Version(2), lock_time: LockTime::ZERO, input: vec![TxIn { previous_output: OutPoint { txid: input_tx.compute_txid(), vout: 0 }, script_sig: Script::new().into(), sequence: Sequence(u32::MAX), witness: Witness::default(), }], output: vec![ TxOut { value: Amount::from_sat(input_tx.output[0].value.to_sat() - AMOUNT - 10000), script_pubkey: input_tx.output[0].script_pubkey.clone(), }, TxOut { value: Amount::from_sat(AMOUNT), script_pubkey: ScriptBuf::new_p2tr_tweaked( TweakedPublicKey::dangerous_assume_tweaked( XOnlyPublicKey::from_slice(&to[1 ..]).unwrap(), ), ), }, ], }; if let Some(instruction) = instruction { tx.output.push(TxOut { value: Amount::ZERO, script_pubkey: ScriptBuf::new_op_return( PushBytesBuf::try_from( Shorthand::Raw(RefundableInInstruction { origin: None, instruction }).encode(), ) .unwrap(), ), }); } let mut der = SECP256K1 .sign_ecdsa_low_r( &Message::from_digest_slice( SighashCache::new(&tx) .legacy_signature_hash( 0, &input_tx.output[0].script_pubkey, EcdsaSighashType::All.to_u32(), ) .unwrap() .to_raw_hash() .as_ref(), ) .unwrap(), &private_key.inner, ) .serialize_der() .to_vec(); der.push(1); tx.input[0].script_sig = Builder::new() .push_slice(PushBytesBuf::try_from(der).unwrap()) .push_key(public_key) .into_script(); let mut buf = vec![]; tx.consensus_encode(&mut buf).unwrap(); *input_tx = tx; (buf, Balance { coin: Coin::Bitcoin, amount: Amount(AMOUNT) }) } Wallet::Ethereum { rpc_url, key, ref mut nonce } => { use std::sync::Arc; use ethereum_serai::{ alloy::{ primitives::{U256, Signature, TxKind}, sol_types::SolCall, simple_request_transport::SimpleRequest, consensus::{TxLegacy, SignableTransaction}, rpc_client::ClientBuilder, provider::{Provider, RootProvider}, network::Ethereum, }, crypto::PublicKey, deployer::Deployer, }; let eight_decimals = U256::from(100_000_000u64); let nine_decimals = eight_decimals * U256::from(10u64); let eighteen_decimals = nine_decimals * nine_decimals; let one_eth = eighteen_decimals; let provider = Arc::new(RootProvider::<_, Ethereum>::new( ClientBuilder::default().transport(SimpleRequest::new(rpc_url.clone()), true), )); let to_as_key = PublicKey::new( ::read_G(&mut to.as_slice()).unwrap(), ) .unwrap(); let router_addr = { // Find the deployer let deployer = Deployer::new(provider.clone()).await.unwrap().unwrap(); // Find the router, deploying if non-existent let router = if let Some(router) = deployer.find_router(provider.clone(), &to_as_key).await.unwrap() { router } else { let mut tx = deployer.deploy_router(&to_as_key); tx.gas_price = 1_000_000_000u64.into(); let tx = ethereum_serai::crypto::deterministically_sign(&tx); let signer = tx.recover_signer().unwrap(); let (tx, sig, _) = tx.into_parts(); provider .raw_request::<_, ()>( "anvil_setBalance".into(), [signer.to_string(), (tx.gas_limit * tx.gas_price).to_string()], ) .await .unwrap(); let mut bytes = vec![]; tx.encode_with_signature_fields(&Signature::from(sig), &mut bytes); let _ = provider.send_raw_transaction(&bytes).await.unwrap(); provider.raw_request::<_, ()>("anvil_mine".into(), [96]).await.unwrap(); deployer.find_router(provider.clone(), &to_as_key).await.unwrap().unwrap() }; router.address() }; let tx = TxLegacy { chain_id: None, nonce: *nonce, gas_price: 1_000_000_000u128, gas_limit: 200_000u128, to: TxKind::Call(router_addr.into()), // 1 ETH value: one_eth, input: ethereum_serai::router::abi::inInstructionCall::new(( [0; 20].into(), one_eth, if let Some(instruction) = instruction { Shorthand::Raw(RefundableInInstruction { origin: None, instruction }).encode().into() } else { vec![].into() }, )) .abi_encode() .into(), }; *nonce += 1; let sig = k256::ecdsa::SigningKey::from(k256::elliptic_curve::NonZeroScalar::new(*key).unwrap()) .sign_prehash_recoverable(tx.signature_hash().as_ref()) .unwrap(); let mut bytes = vec![]; tx.encode_with_signature_fields(&Signature::from(sig), &mut bytes); // We drop the bottom 10 decimals ( bytes, Balance { coin: Coin::Ether, amount: Amount(u64::try_from(eight_decimals).unwrap()) }, ) } Wallet::Monero { handle, ref spend_key, ref view_pair, ref mut last_tx } => { use curve25519_dalek::constants::ED25519_BASEPOINT_POINT; use monero_simple_request_rpc::SimpleRequestRpc; use monero_wallet::{ io::decompress_point, ringct::RctType, rpc::{FeePriority, Rpc}, address::{Network, AddressType, Address}, Scanner, OutputWithDecoys, send::{Change, SignableTransaction}, }; use processor::{additional_key, networks::Monero}; let rpc_url = network_rpc(NetworkId::Monero, ops, handle); let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC"); // Prepare inputs let current_height = rpc.get_height().await.unwrap(); let mut outputs = vec![]; for block in last_tx.0 .. current_height { let block = rpc.get_block_by_number(block).await.unwrap(); if (block.miner_transaction.hash() == last_tx.1) || block.transactions.contains(&last_tx.1) { outputs = Scanner::new(view_pair.clone()) .scan(rpc.get_scannable_block(block).await.unwrap()) .unwrap() .ignore_additional_timelock(); } } assert!(!outputs.is_empty()); let mut inputs = Vec::with_capacity(outputs.len()); for output in outputs { inputs.push( OutputWithDecoys::fingerprintable_deterministic_new( &mut OsRng, &rpc, 16, rpc.get_height().await.unwrap(), output, ) .await .unwrap(), ); } let to_spend_key = decompress_point(<[u8; 32]>::try_from(to.as_slice()).unwrap()).unwrap(); let to_view_key = additional_key::(0); let to_addr = Address::new( Network::Mainnet, AddressType::Featured { subaddress: false, payment_id: None, guaranteed: true }, to_spend_key, ED25519_BASEPOINT_POINT * to_view_key.0, ); // Create and sign the TX const AMOUNT: u64 = 1_000_000_000_000; let mut data = vec![]; if let Some(instruction) = instruction { data.push(Shorthand::Raw(RefundableInInstruction { origin: None, instruction }).encode()); } let mut outgoing_view_key = Zeroizing::new([0; 32]); OsRng.fill_bytes(outgoing_view_key.as_mut()); let tx = SignableTransaction::new( RctType::ClsagBulletproofPlus, outgoing_view_key, inputs, vec![(to_addr, AMOUNT)], Change::new(view_pair.clone(), None), data, rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(), ) .unwrap() .sign(&mut OsRng, spend_key) .unwrap(); // Update the last TX to track the change output last_tx.0 = current_height; last_tx.1 = tx.hash(); (tx.serialize(), Balance { coin: Coin::Monero, amount: Amount(AMOUNT) }) } } } pub fn address(&self) -> ExternalAddress { use serai_client::networks; match self { Wallet::Bitcoin { public_key, .. } => { use bitcoin_serai::bitcoin::ScriptBuf; ExternalAddress::new( networks::bitcoin::Address::new(ScriptBuf::new_p2pkh(&public_key.pubkey_hash())) .unwrap() .into(), ) .unwrap() } Wallet::Ethereum { key, .. } => ExternalAddress::new( ethereum_serai::crypto::address(&(ciphersuite::Secp256k1::generator() * key)).into(), ) .unwrap(), Wallet::Monero { view_pair, .. } => { use monero_wallet::address::Network; ExternalAddress::new( networks::monero::Address::new(view_pair.legacy_address(Network::Mainnet)) .unwrap() .into(), ) .unwrap() } } } }