From f2cf03cedfb9ef09839e6517b627aaa1c00472d6 Mon Sep 17 00:00:00 2001 From: Luke Parker Date: Thu, 12 Sep 2024 18:40:10 -0400 Subject: [PATCH] Monero processor primitives --- Cargo.lock | 36 ++- processor/bin/Cargo.toml | 17 +- processor/bin/src/coordinator.rs | 1 + processor/bitcoin/Cargo.toml | 18 +- processor/monero/Cargo.toml | 36 +-- processor/monero/src/key_gen.rs | 11 + processor/monero/src/lib.rs | 2 + processor/monero/src/main.rs | 43 ++++ processor/monero/src/primitives/block.rs | 54 +++++ processor/monero/src/primitives/mod.rs | 3 + processor/monero/src/primitives/output.rs | 86 +++++++ .../monero/src/primitives/transaction.rs | 137 ++++++++++++ processor/monero/src/rpc.rs | 156 +++++++++++++ processor/monero/src/scheduler.rs | 205 +++++++++++++++++ substrate/client/Cargo.toml | 4 +- substrate/client/src/networks/monero.rs | 211 +++++++++++------- 16 files changed, 873 insertions(+), 147 deletions(-) create mode 100644 processor/monero/src/key_gen.rs create mode 100644 processor/monero/src/main.rs create mode 100644 processor/monero/src/primitives/block.rs create mode 100644 processor/monero/src/primitives/mod.rs create mode 100644 processor/monero/src/primitives/output.rs create mode 100644 processor/monero/src/primitives/transaction.rs create mode 100644 processor/monero/src/rpc.rs create mode 100644 processor/monero/src/scheduler.rs diff --git a/Cargo.lock b/Cargo.lock index 7e7d78a3..ec3ccf8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8129,7 +8129,6 @@ dependencies = [ "borsh", "ciphersuite", "dkg", - "env_logger", "flexible-transcript", "hex", "log", @@ -8139,11 +8138,8 @@ dependencies = [ "secp256k1", "serai-client", "serai-db", - "serai-env", - "serai-message-queue", "serai-processor-bin", "serai-processor-key-gen", - "serai-processor-messages", "serai-processor-primitives", "serai-processor-scanner", "serai-processor-scheduler-primitives", @@ -8152,7 +8148,6 @@ dependencies = [ "serai-processor-utxo-scheduler-primitives", "tokio", "zalloc", - "zeroize", ] [[package]] @@ -8170,7 +8165,7 @@ dependencies = [ "frost-schnorrkel", "hex", "modular-frost", - "monero-wallet", + "monero-address", "multiaddr", "parity-scale-codec", "rand_core", @@ -8522,19 +8517,26 @@ version = "0.1.0" dependencies = [ "async-trait", "borsh", - "const-hex", + "ciphersuite", "dalek-ff-group", - "env_logger", + "dkg", + "flexible-transcript", "hex", "log", - "monero-simple-request-rpc", + "modular-frost", "monero-wallet", "parity-scale-codec", + "rand_core", + "serai-client", "serai-db", - "serai-env", - "serai-message-queue", - "serai-processor-messages", - "serde_json", + "serai-processor-bin", + "serai-processor-key-gen", + "serai-processor-primitives", + "serai-processor-scanner", + "serai-processor-scheduler-primitives", + "serai-processor-signers", + "serai-processor-utxo-scheduler", + "serai-processor-utxo-scheduler-primitives", "tokio", "zalloc", ] @@ -8643,18 +8645,13 @@ name = "serai-processor-bin" version = "0.1.0" dependencies = [ "async-trait", - "bitcoin-serai", "borsh", "ciphersuite", "dkg", "env_logger", - "flexible-transcript", "hex", "log", - "modular-frost", "parity-scale-codec", - "rand_core", - "secp256k1", "serai-client", "serai-db", "serai-env", @@ -8665,10 +8662,7 @@ dependencies = [ "serai-processor-scanner", "serai-processor-scheduler-primitives", "serai-processor-signers", - "serai-processor-transaction-chaining-scheduler", - "serai-processor-utxo-scheduler-primitives", "tokio", - "zalloc", "zeroize", ] diff --git a/processor/bin/Cargo.toml b/processor/bin/Cargo.toml index f3f3b753..01a774ac 100644 --- a/processor/bin/Cargo.toml +++ b/processor/bin/Cargo.toml @@ -19,29 +19,22 @@ workspace = true [dependencies] async-trait = { version = "0.1", default-features = false } zeroize = { version = "1", default-features = false, features = ["std"] } -rand_core = { version = "0.6", default-features = false } hex = { version = "0.4", default-features = false, features = ["std"] } scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["std"] } borsh = { version = "1", default-features = false, features = ["std", "derive", "de_strict_order"] } -transcript = { package = "flexible-transcript", path = "../../crypto/transcript", default-features = false, features = ["std", "recommended"] } -ciphersuite = { path = "../../crypto/ciphersuite", default-features = false, features = ["std", "secp256k1"] } -dkg = { path = "../../crypto/dkg", default-features = false, features = ["std", "evrf-secp256k1"] } -frost = { package = "modular-frost", path = "../../crypto/frost", default-features = false } +ciphersuite = { path = "../../crypto/ciphersuite", default-features = false, features = ["std"] } +dkg = { path = "../../crypto/dkg", default-features = false, features = ["std", "evrf-ristretto"] } -secp256k1 = { version = "0.29", default-features = false, features = ["std", "global-context", "rand-std"] } -bitcoin-serai = { path = "../../networks/bitcoin", default-features = false, features = ["std"] } +serai-client = { path = "../../substrate/client", default-features = false, features = ["bitcoin"] } log = { version = "0.4", default-features = false, features = ["std"] } env_logger = { version = "0.10", default-features = false, features = ["humantime"] } tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "sync", "time", "macros"] } -zalloc = { path = "../../common/zalloc" } -serai-db = { path = "../../common/db" } serai-env = { path = "../../common/env" } - -serai-client = { path = "../../substrate/client", default-features = false, features = ["bitcoin"] } +serai-db = { path = "../../common/db" } messages = { package = "serai-processor-messages", path = "../messages" } key-gen = { package = "serai-processor-key-gen", path = "../key-gen" } @@ -49,8 +42,6 @@ key-gen = { package = "serai-processor-key-gen", path = "../key-gen" } primitives = { package = "serai-processor-primitives", path = "../primitives" } scheduler = { package = "serai-processor-scheduler-primitives", path = "../scheduler/primitives" } scanner = { package = "serai-processor-scanner", path = "../scanner" } -utxo-scheduler = { package = "serai-processor-utxo-scheduler-primitives", path = "../scheduler/utxo/primitives" } -transaction-chaining-scheduler = { package = "serai-processor-transaction-chaining-scheduler", path = "../scheduler/utxo/transaction-chaining" } signers = { package = "serai-processor-signers", path = "../signers" } message-queue = { package = "serai-message-queue", path = "../../message-queue" } diff --git a/processor/bin/src/coordinator.rs b/processor/bin/src/coordinator.rs index 12442c3d..ead4a131 100644 --- a/processor/bin/src/coordinator.rs +++ b/processor/bin/src/coordinator.rs @@ -69,6 +69,7 @@ impl Coordinator { "monero" => NetworkId::Monero, _ => panic!("unrecognized network"), }; + // TODO: Read this from ScannerFeed let service = Service::Processor(network_id); let message_queue = Arc::new(MessageQueue::from_env(service)); diff --git a/processor/bitcoin/Cargo.toml b/processor/bitcoin/Cargo.toml index c968e36b..2d4958c7 100644 --- a/processor/bitcoin/Cargo.toml +++ b/processor/bitcoin/Cargo.toml @@ -18,7 +18,6 @@ workspace = true [dependencies] async-trait = { version = "0.1", default-features = false } -zeroize = { version = "1", default-features = false, features = ["std"] } rand_core = { version = "0.6", default-features = false } hex = { version = "0.4", default-features = false, features = ["std"] } @@ -33,17 +32,14 @@ frost = { package = "modular-frost", path = "../../crypto/frost", default-featur secp256k1 = { version = "0.29", default-features = false, features = ["std", "global-context", "rand-std"] } bitcoin-serai = { path = "../../networks/bitcoin", default-features = false, features = ["std"] } -log = { version = "0.4", default-features = false, features = ["std"] } -env_logger = { version = "0.10", default-features = false, features = ["humantime"] } -tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "sync", "time", "macros"] } - -zalloc = { path = "../../common/zalloc" } -serai-db = { path = "../../common/db" } -serai-env = { path = "../../common/env" } - serai-client = { path = "../../substrate/client", default-features = false, features = ["bitcoin"] } -messages = { package = "serai-processor-messages", path = "../messages" } +zalloc = { path = "../../common/zalloc" } +log = { version = "0.4", default-features = false, features = ["std"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "sync", "time", "macros"] } + +serai-db = { path = "../../common/db" } + key-gen = { package = "serai-processor-key-gen", path = "../key-gen" } primitives = { package = "serai-processor-primitives", path = "../primitives" } @@ -55,8 +51,6 @@ signers = { package = "serai-processor-signers", path = "../signers" } bin = { package = "serai-processor-bin", path = "../bin" } -message-queue = { package = "serai-message-queue", path = "../../message-queue" } - [features] parity-db = ["bin/parity-db"] rocksdb = ["bin/rocksdb"] diff --git a/processor/monero/Cargo.toml b/processor/monero/Cargo.toml index e71472e4..5538d025 100644 --- a/processor/monero/Cargo.toml +++ b/processor/monero/Cargo.toml @@ -18,29 +18,39 @@ workspace = true [dependencies] async-trait = { version = "0.1", default-features = false } +rand_core = { version = "0.6", default-features = false } -const-hex = { version = "1", default-features = false } hex = { version = "0.4", default-features = false, features = ["std"] } scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["std"] } borsh = { version = "1", default-features = false, features = ["std", "derive", "de_strict_order"] } -serde_json = { version = "1", default-features = false, features = ["std"] } -dalek-ff-group = { path = "../../crypto/dalek-ff-group", default-features = false, features = ["std"], optional = true } -monero-simple-request-rpc = { path = "../../networks/monero/rpc/simple-request", default-features = false, optional = true } -monero-wallet = { path = "../../networks/monero/wallet", default-features = false, features = ["std", "multisig", "compile-time-generators"], optional = true } +transcript = { package = "flexible-transcript", path = "../../crypto/transcript", default-features = false, features = ["std", "recommended"] } +dalek-ff-group = { path = "../../crypto/dalek-ff-group", default-features = false, features = ["std"] } +ciphersuite = { path = "../../crypto/ciphersuite", default-features = false, features = ["std", "ed25519"] } +dkg = { path = "../../crypto/dkg", default-features = false, features = ["std", "evrf-ed25519"] } +frost = { package = "modular-frost", path = "../../crypto/frost", default-features = false } -log = { version = "0.4", default-features = false, features = ["std"] } -env_logger = { version = "0.10", default-features = false, features = ["humantime"] } -tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "sync", "time", "macros"] } +monero-wallet = { path = "../../networks/monero/wallet", default-features = false, features = ["std", "multisig"] } + +serai-client = { path = "../../substrate/client", default-features = false, features = ["monero"] } zalloc = { path = "../../common/zalloc" } +log = { version = "0.4", default-features = false, features = ["std"] } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "sync", "time", "macros"] } + serai-db = { path = "../../common/db" } -serai-env = { path = "../../common/env" } -messages = { package = "serai-processor-messages", path = "../messages" } +key-gen = { package = "serai-processor-key-gen", path = "../key-gen" } -message-queue = { package = "serai-message-queue", path = "../../message-queue" } +primitives = { package = "serai-processor-primitives", path = "../primitives" } +scheduler = { package = "serai-processor-scheduler-primitives", path = "../scheduler/primitives" } +scanner = { package = "serai-processor-scanner", path = "../scanner" } +utxo-scheduler = { package = "serai-processor-utxo-scheduler-primitives", path = "../scheduler/utxo/primitives" } +utxo-standard-scheduler = { package = "serai-processor-utxo-scheduler", path = "../scheduler/utxo/standard" } +signers = { package = "serai-processor-signers", path = "../signers" } + +bin = { package = "serai-processor-bin", path = "../bin" } [features] -parity-db = ["serai-db/parity-db"] -rocksdb = ["serai-db/rocksdb"] +parity-db = ["bin/parity-db"] +rocksdb = ["bin/rocksdb"] diff --git a/processor/monero/src/key_gen.rs b/processor/monero/src/key_gen.rs new file mode 100644 index 00000000..dee33029 --- /dev/null +++ b/processor/monero/src/key_gen.rs @@ -0,0 +1,11 @@ +use ciphersuite::{group::GroupEncoding, Ciphersuite, Ed25519}; +use frost::ThresholdKeys; + +pub(crate) struct KeyGenParams; +impl key_gen::KeyGenParams for KeyGenParams { + const ID: &'static str = "Monero"; + + type ExternalNetworkCiphersuite = Ed25519; + + fn tweak_keys(keys: &mut ThresholdKeys) {} +} diff --git a/processor/monero/src/lib.rs b/processor/monero/src/lib.rs index 8786bef3..f9b334ef 100644 --- a/processor/monero/src/lib.rs +++ b/processor/monero/src/lib.rs @@ -1,3 +1,4 @@ +/* #![cfg_attr(docsrs, feature(doc_auto_cfg))] #![doc = include_str!("../README.md")] #![deny(missing_docs)] @@ -809,3 +810,4 @@ impl UtxoNetwork for Monero { // TODO: Test creating a TX this big const MAX_INPUTS: usize = 120; } +*/ diff --git a/processor/monero/src/main.rs b/processor/monero/src/main.rs new file mode 100644 index 00000000..41896de1 --- /dev/null +++ b/processor/monero/src/main.rs @@ -0,0 +1,43 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] + +#[global_allocator] +static ALLOCATOR: zalloc::ZeroizingAlloc = + zalloc::ZeroizingAlloc(std::alloc::System); + +use monero_wallet::rpc::Rpc as MRpc; + +mod primitives; +pub(crate) use crate::primitives::*; + +/* +mod key_gen; +use crate::key_gen::KeyGenParams; +mod rpc; +use rpc::Rpc; +mod scheduler; +use scheduler::Scheduler; + +#[tokio::main] +async fn main() { + let db = bin::init(); + let feed = Rpc { + db: db.clone(), + rpc: loop { + match MRpc::new(bin::url()).await { + Ok(rpc) => break rpc, + Err(e) => { + log::error!("couldn't connect to the Monero node: {e:?}"); + tokio::time::sleep(core::time::Duration::from_secs(5)).await; + } + } + }, + }; + + bin::main_loop::<_, KeyGenParams, Scheduler<_>, Rpc>(db, feed.clone(), feed).await; +} +*/ + +#[tokio::main] +async fn main() {} diff --git a/processor/monero/src/primitives/block.rs b/processor/monero/src/primitives/block.rs new file mode 100644 index 00000000..40d0f296 --- /dev/null +++ b/processor/monero/src/primitives/block.rs @@ -0,0 +1,54 @@ +use std::collections::HashMap; + +use ciphersuite::{Ciphersuite, Ed25519}; + +use monero_wallet::{transaction::Transaction, block::Block as MBlock}; + +use serai_client::networks::monero::Address; + +use primitives::{ReceivedOutput, EventualityTracker}; + +use crate::{output::Output, transaction::Eventuality}; + +#[derive(Clone, Debug)] +pub(crate) struct BlockHeader(pub(crate) MBlock); +impl primitives::BlockHeader for BlockHeader { + fn id(&self) -> [u8; 32] { + self.0.hash() + } + fn parent(&self) -> [u8; 32] { + self.0.header.previous + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Block(pub(crate) MBlock, Vec); + +#[async_trait::async_trait] +impl primitives::Block for Block { + type Header = BlockHeader; + + type Key = ::G; + type Address = Address; + type Output = Output; + type Eventuality = Eventuality; + + fn id(&self) -> [u8; 32] { + self.0.hash() + } + + fn scan_for_outputs_unordered(&self, key: Self::Key) -> Vec { + todo!("TODO") + } + + #[allow(clippy::type_complexity)] + fn check_for_eventuality_resolutions( + &self, + eventualities: &mut EventualityTracker, + ) -> HashMap< + >::TransactionId, + Self::Eventuality, + > { + todo!("TODO") + } +} diff --git a/processor/monero/src/primitives/mod.rs b/processor/monero/src/primitives/mod.rs new file mode 100644 index 00000000..fba52dd9 --- /dev/null +++ b/processor/monero/src/primitives/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod output; +pub(crate) mod transaction; +pub(crate) mod block; diff --git a/processor/monero/src/primitives/output.rs b/processor/monero/src/primitives/output.rs new file mode 100644 index 00000000..d3eb3be3 --- /dev/null +++ b/processor/monero/src/primitives/output.rs @@ -0,0 +1,86 @@ +use std::io; + +use ciphersuite::{group::Group, Ciphersuite, Ed25519}; + +use monero_wallet::WalletOutput; + +use scale::{Encode, Decode}; +use borsh::{BorshSerialize, BorshDeserialize}; + +use serai_client::{ + primitives::{Coin, Amount, Balance}, + networks::monero::Address, +}; + +use primitives::{OutputType, ReceivedOutput}; + +#[rustfmt::skip] +#[derive( + Clone, Copy, PartialEq, Eq, Default, Hash, Debug, Encode, Decode, BorshSerialize, BorshDeserialize, +)] +pub(crate) struct OutputId(pub(crate) [u8; 32]); +impl AsRef<[u8]> for OutputId { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} +impl AsMut<[u8]> for OutputId { + fn as_mut(&mut self) -> &mut [u8] { + self.0.as_mut() + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) struct Output(WalletOutput); + +impl Output { + pub(crate) fn new(output: WalletOutput) -> Self { + Self(output) + } +} + +impl ReceivedOutput<::G, Address> for Output { + type Id = OutputId; + type TransactionId = [u8; 32]; + + fn kind(&self) -> OutputType { + todo!("TODO") + } + + fn id(&self) -> Self::Id { + OutputId(self.0.key().compress().to_bytes()) + } + + fn transaction_id(&self) -> Self::TransactionId { + self.0.transaction() + } + + fn key(&self) -> ::G { + // The spend key will be a key we generated, so it'll be in the prime-order subgroup + // The output's key is the spend key + (key_offset * G), so it's in the prime-order subgroup if + // the spend key is + dalek_ff_group::EdwardsPoint( + self.0.key() - (*::G::generator() * self.0.key_offset()), + ) + } + + fn presumed_origin(&self) -> Option
{ + None + } + + fn balance(&self) -> Balance { + Balance { coin: Coin::Monero, amount: Amount(self.0.commitment().amount) } + } + + fn data(&self) -> &[u8] { + self.0.arbitrary_data().first().map_or(&[], Vec::as_slice) + } + + fn write(&self, writer: &mut W) -> io::Result<()> { + self.0.write(writer) + } + + fn read(reader: &mut R) -> io::Result { + WalletOutput::read(reader).map(Self) + } +} diff --git a/processor/monero/src/primitives/transaction.rs b/processor/monero/src/primitives/transaction.rs new file mode 100644 index 00000000..1ba49471 --- /dev/null +++ b/processor/monero/src/primitives/transaction.rs @@ -0,0 +1,137 @@ +use std::io; + +use rand_core::{RngCore, CryptoRng}; + +use ciphersuite::Ed25519; +use frost::{dkg::ThresholdKeys, sign::PreprocessMachine}; + +use monero_wallet::{ + transaction::Transaction as MTransaction, + send::{ + SignableTransaction as MSignableTransaction, TransactionMachine, Eventuality as MEventuality, + }, +}; + +use crate::output::OutputId; + +#[derive(Clone, Debug)] +pub(crate) struct Transaction(pub(crate) MTransaction); + +impl From for Transaction { + fn from(tx: MTransaction) -> Self { + Self(tx) + } +} + +impl scheduler::Transaction for Transaction { + fn read(reader: &mut impl io::Read) -> io::Result { + MTransaction::read(reader).map(Self) + } + fn write(&self, writer: &mut impl io::Write) -> io::Result<()> { + self.0.write(writer) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct SignableTransaction { + id: [u8; 32], + signable: MSignableTransaction, +} + +#[derive(Clone)] +pub(crate) struct ClonableTransctionMachine(MSignableTransaction, ThresholdKeys); +impl PreprocessMachine for ClonableTransctionMachine { + type Preprocess = ::Preprocess; + type Signature = ::Signature; + type SignMachine = ::SignMachine; + + fn preprocess( + self, + rng: &mut R, + ) -> (Self::SignMachine, Self::Preprocess) { + self.0.multisig(self.1).expect("incorrect keys used for SignableTransaction").preprocess(rng) + } +} + +impl scheduler::SignableTransaction for SignableTransaction { + type Transaction = Transaction; + type Ciphersuite = Ed25519; + type PreprocessMachine = ClonableTransctionMachine; + + fn read(reader: &mut impl io::Read) -> io::Result { + let mut id = [0; 32]; + reader.read_exact(&mut id)?; + + let signable = MSignableTransaction::read(reader)?; + Ok(SignableTransaction { id, signable }) + } + fn write(&self, writer: &mut impl io::Write) -> io::Result<()> { + writer.write_all(&self.id)?; + self.signable.write(writer) + } + + fn id(&self) -> [u8; 32] { + self.id + } + + fn sign(self, keys: ThresholdKeys) -> Self::PreprocessMachine { + ClonableTransctionMachine(self.signable, keys) + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) struct Eventuality { + id: [u8; 32], + singular_spent_output: Option, + eventuality: MEventuality, +} + +impl primitives::Eventuality for Eventuality { + type OutputId = OutputId; + + fn id(&self) -> [u8; 32] { + self.id + } + + // We define the lookup as our ID since the resolving transaction only has a singular possible ID + fn lookup(&self) -> Vec { + self.eventuality.extra() + } + + fn singular_spent_output(&self) -> Option { + self.singular_spent_output + } + + fn read(reader: &mut impl io::Read) -> io::Result { + let mut id = [0; 32]; + reader.read_exact(&mut id)?; + + let singular_spent_output = { + let mut singular_spent_output_opt = [0xff]; + reader.read_exact(&mut singular_spent_output_opt)?; + assert!(singular_spent_output_opt[0] <= 1); + (singular_spent_output_opt[0] == 1) + .then(|| -> io::Result<_> { + let mut singular_spent_output = [0; 32]; + reader.read_exact(&mut singular_spent_output)?; + Ok(OutputId(singular_spent_output)) + }) + .transpose()? + }; + + let eventuality = MEventuality::read(reader)?; + Ok(Self { id, singular_spent_output, eventuality }) + } + fn write(&self, writer: &mut impl io::Write) -> io::Result<()> { + writer.write_all(&self.id)?; + + if let Some(singular_spent_output) = self.singular_spent_output { + writer.write_all(&[1])?; + writer.write_all(singular_spent_output.as_ref())?; + } else { + writer.write_all(&[0])?; + } + + self.eventuality.write(writer) + } +} diff --git a/processor/monero/src/rpc.rs b/processor/monero/src/rpc.rs new file mode 100644 index 00000000..a6f6e5fd --- /dev/null +++ b/processor/monero/src/rpc.rs @@ -0,0 +1,156 @@ +use bitcoin_serai::rpc::{RpcError, Rpc as BRpc}; + +use serai_client::primitives::{NetworkId, Coin, Amount}; + +use serai_db::Db; +use scanner::ScannerFeed; +use signers::TransactionPublisher; + +use crate::{ + db, + transaction::Transaction, + block::{BlockHeader, Block}, +}; + +#[derive(Clone)] +pub(crate) struct Rpc { + pub(crate) db: D, + pub(crate) rpc: BRpc, +} + +#[async_trait::async_trait] +impl ScannerFeed for Rpc { + const NETWORK: NetworkId = NetworkId::Bitcoin; + const CONFIRMATIONS: u64 = 6; + const WINDOW_LENGTH: u64 = 6; + + const TEN_MINUTES: u64 = 1; + + type Block = Block; + + type EphemeralError = RpcError; + + async fn latest_finalized_block_number(&self) -> Result { + db::LatestBlockToYieldAsFinalized::get(&self.db).ok_or(RpcError::ConnectionError) + } + + async fn time_of_block(&self, number: u64) -> Result { + let number = usize::try_from(number).unwrap(); + + /* + The block time isn't guaranteed to be monotonic. It is guaranteed to be greater than the + median time of prior blocks, as detailed in BIP-0113 (a BIP which used that fact to improve + CLTV). This creates a monotonic median time which we use as the block time. + */ + // This implements `GetMedianTimePast` + let median = { + const MEDIAN_TIMESPAN: usize = 11; + let mut timestamps = Vec::with_capacity(MEDIAN_TIMESPAN); + for i in number.saturating_sub(MEDIAN_TIMESPAN) .. number { + timestamps.push(self.rpc.get_block(&self.rpc.get_block_hash(i).await?).await?.header.time); + } + timestamps.sort(); + timestamps[timestamps.len() / 2] + }; + + /* + This block's timestamp is guaranteed to be greater than this median: + https://github.com/bitcoin/bitcoin/blob/0725a374941355349bb4bc8a79dad1affb27d3b9 + /src/validation.cpp#L4182-L4184 + + This does not guarantee the median always increases however. Take the following trivial + example, as the window is initially built: + + 0 block has time 0 // Prior blocks: [] + 1 block has time 1 // Prior blocks: [0] + 2 block has time 2 // Prior blocks: [0, 1] + 3 block has time 2 // Prior blocks: [0, 1, 2] + + These two blocks have the same time (both greater than the median of their prior blocks) and + the same median. + + The median will never decrease however. The values pushed onto the window will always be + greater than the median. If a value greater than the median is popped, the median will remain + the same (due to the counterbalance of the pushed value). If a value less than the median is + popped, the median will increase (either to another instance of the same value, yet one + closer to the end of the repeating sequence, or to a higher value). + */ + Ok(median.into()) + } + + async fn unchecked_block_header_by_number( + &self, + number: u64, + ) -> Result<::Header, Self::EphemeralError> { + Ok(BlockHeader( + self.rpc.get_block(&self.rpc.get_block_hash(number.try_into().unwrap()).await?).await?.header, + )) + } + + async fn unchecked_block_by_number( + &self, + number: u64, + ) -> Result { + Ok(Block( + self.db.clone(), + self.rpc.get_block(&self.rpc.get_block_hash(number.try_into().unwrap()).await?).await?, + )) + } + + fn dust(coin: Coin) -> Amount { + assert_eq!(coin, Coin::Bitcoin); + + /* + A Taproot input is: + - 36 bytes for the OutPoint + - 0 bytes for the script (+1 byte for the length) + - 4 bytes for the sequence + Per https://developer.bitcoin.org/reference/transactions.html#raw-transaction-format + + There's also: + - 1 byte for the witness length + - 1 byte for the signature length + - 64 bytes for the signature + which have the SegWit discount. + + (4 * (36 + 1 + 4)) + (1 + 1 + 64) = 164 + 66 = 230 weight units + 230 ceil div 4 = 57 vbytes + + Bitcoin defines multiple minimum feerate constants *per kilo-vbyte*. Currently, these are: + - 1000 sat/kilo-vbyte for a transaction to be relayed + - Each output's value must exceed the fee of the TX spending it at 3000 sat/kilo-vbyte + The DUST constant needs to be determined by the latter. + Since these are solely relay rules, and may be raised, we require all outputs be spendable + under a 5000 sat/kilo-vbyte fee rate. + + 5000 sat/kilo-vbyte = 5 sat/vbyte + 5 * 57 = 285 sats/spent-output + + Even if an output took 100 bytes (it should be just ~29-43), taking 400 weight units, adding + 100 vbytes, tripling the transaction size, then the sats/tx would be < 1000. + + Increase by an order of magnitude, in order to ensure this is actually worth our time, and we + get 10,000 satoshis. This is $5 if 1 BTC = 50,000 USD. + */ + Amount(10_000) + } + + async fn cost_to_aggregate( + &self, + coin: Coin, + _reference_block: &Self::Block, + ) -> Result { + assert_eq!(coin, Coin::Bitcoin); + // TODO + Ok(Amount(0)) + } +} + +#[async_trait::async_trait] +impl TransactionPublisher for Rpc { + type EphemeralError = RpcError; + + async fn publish(&self, tx: Transaction) -> Result<(), Self::EphemeralError> { + self.rpc.send_raw_transaction(&tx.0).await.map(|_| ()) + } +} diff --git a/processor/monero/src/scheduler.rs b/processor/monero/src/scheduler.rs new file mode 100644 index 00000000..6e49d23d --- /dev/null +++ b/processor/monero/src/scheduler.rs @@ -0,0 +1,205 @@ +use ciphersuite::{Ciphersuite, Secp256k1}; + +use bitcoin_serai::{ + bitcoin::ScriptBuf, + wallet::{TransactionError, SignableTransaction as BSignableTransaction, p2tr_script_buf}, +}; + +use serai_client::{ + primitives::{Coin, Amount}, + networks::bitcoin::Address, +}; + +use serai_db::Db; +use primitives::{OutputType, ReceivedOutput, Payment}; +use scanner::{KeyFor, AddressFor, OutputFor, BlockFor}; +use utxo_scheduler::{PlannedTransaction, TransactionPlanner}; +use transaction_chaining_scheduler::{EffectedReceivedOutputs, Scheduler as GenericScheduler}; + +use crate::{ + scan::{offsets_for_key, scanner}, + output::Output, + transaction::{SignableTransaction, Eventuality}, + rpc::Rpc, +}; + +fn address_from_serai_key(key: ::G, kind: OutputType) -> Address { + let offset = ::G::GENERATOR * offsets_for_key(key)[&kind]; + Address::new( + p2tr_script_buf(key + offset) + .expect("creating address from Serai key which wasn't properly tweaked"), + ) + .expect("couldn't create Serai-representable address for P2TR script") +} + +fn signable_transaction( + fee_per_vbyte: u64, + inputs: Vec>>, + payments: Vec>>>, + change: Option>>, +) -> Result<(SignableTransaction, BSignableTransaction), TransactionError> { + assert!( + inputs.len() < + , EffectedReceivedOutputs>>>::MAX_INPUTS + ); + assert!( + (payments.len() + usize::from(u8::from(change.is_some()))) < + , EffectedReceivedOutputs>>>::MAX_OUTPUTS + ); + + let inputs = inputs.into_iter().map(|input| input.output).collect::>(); + + let mut payments = payments + .into_iter() + .map(|payment| { + (payment.address().clone(), { + let balance = payment.balance(); + assert_eq!(balance.coin, Coin::Bitcoin); + balance.amount.0 + }) + }) + .collect::>(); + /* + Push a payment to a key with a known private key which anyone can spend. If this transaction + gets stuck, this lets anyone create a child transaction spending this output, raising the fee, + getting the transaction unstuck (via CPFP). + */ + payments.push(( + // The generator is even so this is valid + Address::new(p2tr_script_buf(::G::GENERATOR).unwrap()).unwrap(), + // This uses the minimum output value allowed, as defined as a constant in bitcoin-serai + // TODO: Add a test for this comparing to bitcoin's `minimal_non_dust` + bitcoin_serai::wallet::DUST, + )); + + let change = change + .map(, EffectedReceivedOutputs>>>::change_address); + + BSignableTransaction::new( + inputs.clone(), + &payments + .iter() + .cloned() + .map(|(address, amount)| (ScriptBuf::from(address), amount)) + .collect::>(), + change.clone().map(ScriptBuf::from), + None, + fee_per_vbyte, + ) + .map(|bst| (SignableTransaction { inputs, payments, change, fee_per_vbyte }, bst)) +} + +pub(crate) struct Planner; +impl TransactionPlanner, EffectedReceivedOutputs>> for Planner { + type FeeRate = u64; + + type SignableTransaction = SignableTransaction; + + /* + Bitcoin has a max weight of 400,000 (MAX_STANDARD_TX_WEIGHT). + + A non-SegWit TX will have 4 weight units per byte, leaving a max size of 100,000 bytes. While + our inputs are entirely SegWit, such fine tuning is not necessary and could create issues in + the future (if the size decreases or we misevaluate it). It also offers a minimal amount of + benefit when we are able to logarithmically accumulate inputs/fulfill payments. + + For 128-byte inputs (36-byte output specification, 64-byte signature, whatever overhead) and + 64-byte outputs (40-byte script, 8-byte amount, whatever overhead), they together take up 192 + bytes. + + 100,000 / 192 = 520 + 520 * 192 leaves 160 bytes of overhead for the transaction structure itself. + */ + const MAX_INPUTS: usize = 520; + // We always reserve one output to create an anyone-can-spend output enabling anyone to use CPFP + // to unstick any transactions which had too low of a fee. + const MAX_OUTPUTS: usize = 519; + + fn fee_rate(block: &BlockFor>, coin: Coin) -> Self::FeeRate { + assert_eq!(coin, Coin::Bitcoin); + // TODO + 1 + } + + fn branch_address(key: KeyFor>) -> AddressFor> { + address_from_serai_key(key, OutputType::Branch) + } + fn change_address(key: KeyFor>) -> AddressFor> { + address_from_serai_key(key, OutputType::Change) + } + fn forwarding_address(key: KeyFor>) -> AddressFor> { + address_from_serai_key(key, OutputType::Forwarded) + } + + fn calculate_fee( + fee_rate: Self::FeeRate, + inputs: Vec>>, + payments: Vec>>>, + change: Option>>, + ) -> Amount { + match signable_transaction::(fee_rate, inputs, payments, change) { + Ok(tx) => Amount(tx.1.needed_fee()), + Err( + TransactionError::NoInputs | TransactionError::NoOutputs | TransactionError::DustPayment, + ) => panic!("malformed arguments to calculate_fee"), + // No data, we have a minimum fee rate, we checked the amount of inputs/outputs + Err( + TransactionError::TooMuchData | + TransactionError::TooLowFee | + TransactionError::TooLargeTransaction, + ) => unreachable!(), + Err(TransactionError::NotEnoughFunds { fee, .. }) => Amount(fee), + } + } + + fn plan( + fee_rate: Self::FeeRate, + inputs: Vec>>, + payments: Vec>>>, + change: Option>>, + ) -> PlannedTransaction, Self::SignableTransaction, EffectedReceivedOutputs>> { + let key = inputs.first().unwrap().key(); + for input in &inputs { + assert_eq!(key, input.key()); + } + + let singular_spent_output = (inputs.len() == 1).then(|| inputs[0].id()); + match signable_transaction::(fee_rate, inputs.clone(), payments, change) { + Ok(tx) => PlannedTransaction { + signable: tx.0, + eventuality: Eventuality { txid: tx.1.txid(), singular_spent_output }, + auxilliary: EffectedReceivedOutputs({ + let tx = tx.1.transaction(); + let scanner = scanner(key); + + let mut res = vec![]; + for output in scanner.scan_transaction(tx) { + res.push(Output::new_with_presumed_origin( + key, + tx, + // It shouldn't matter if this is wrong as we should never try to return these + // We still provide an accurate value to ensure a lack of discrepancies + Some(Address::new(inputs[0].output.output().script_pubkey.clone()).unwrap()), + output, + )); + } + res + }), + }, + Err( + TransactionError::NoInputs | TransactionError::NoOutputs | TransactionError::DustPayment, + ) => panic!("malformed arguments to plan"), + // No data, we have a minimum fee rate, we checked the amount of inputs/outputs + Err( + TransactionError::TooMuchData | + TransactionError::TooLowFee | + TransactionError::TooLargeTransaction, + ) => unreachable!(), + Err(TransactionError::NotEnoughFunds { .. }) => { + panic!("plan called for a transaction without enough funds") + } + } + } +} + +pub(crate) type Scheduler = GenericScheduler, Planner>; diff --git a/substrate/client/Cargo.toml b/substrate/client/Cargo.toml index 5cba05f0..5f7a24d4 100644 --- a/substrate/client/Cargo.toml +++ b/substrate/client/Cargo.toml @@ -42,7 +42,7 @@ simple-request = { path = "../../common/request", version = "0.1", optional = tr bitcoin = { version = "0.32", optional = true } ciphersuite = { path = "../../crypto/ciphersuite", version = "0.4", optional = true } -monero-wallet = { path = "../../networks/monero/wallet", version = "0.1.0", default-features = false, features = ["std"], optional = true } +monero-address = { path = "../../networks/monero/wallet/address", version = "0.1.0", default-features = false, features = ["std"], optional = true } [dev-dependencies] rand_core = "0.6" @@ -65,7 +65,7 @@ borsh = ["serai-abi/borsh"] networks = [] bitcoin = ["networks", "dep:bitcoin"] -monero = ["networks", "ciphersuite/ed25519", "monero-wallet"] +monero = ["networks", "ciphersuite/ed25519", "monero-address"] # Assumes the default usage is to use Serai as a DEX, which doesn't actually # require connecting to a Serai node diff --git a/substrate/client/src/networks/monero.rs b/substrate/client/src/networks/monero.rs index bd5e0a15..c99a0abd 100644 --- a/substrate/client/src/networks/monero.rs +++ b/substrate/client/src/networks/monero.rs @@ -1,102 +1,141 @@ use core::{str::FromStr, fmt}; -use scale::{Encode, Decode}; - use ciphersuite::{Ciphersuite, Ed25519}; -use monero_wallet::address::{AddressError, Network, AddressType, MoneroAddress}; +use monero_address::{Network, AddressType as MoneroAddressType, MoneroAddress}; -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct Address(MoneroAddress); -impl Address { - pub fn new(address: MoneroAddress) -> Option
{ - if address.payment_id().is_some() { - return None; - } - Some(Address(address)) - } -} +use crate::primitives::ExternalAddress; -impl FromStr for Address { - type Err = AddressError; - fn from_str(str: &str) -> Result { - MoneroAddress::from_str(Network::Mainnet, str).map(Address) - } -} - -impl fmt::Display for Address { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -// SCALE-encoded variant of Monero addresses. -#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode)] -enum EncodedAddressType { +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum AddressType { Legacy, Subaddress, Featured(u8), } -#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode)] -struct EncodedAddress { - kind: EncodedAddressType, - spend: [u8; 32], - view: [u8; 32], +/// A representation of a Monero address. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Address { + kind: AddressType, + spend: ::G, + view: ::G, } -impl TryFrom> for Address { - type Error = (); - fn try_from(data: Vec) -> Result { - // Decode as SCALE - let addr = EncodedAddress::decode(&mut data.as_ref()).map_err(|_| ())?; - // Convert over - Ok(Address(MoneroAddress::new( - Network::Mainnet, - match addr.kind { - EncodedAddressType::Legacy => AddressType::Legacy, - EncodedAddressType::Subaddress => AddressType::Subaddress, - EncodedAddressType::Featured(flags) => { - let subaddress = (flags & 1) != 0; - let integrated = (flags & (1 << 1)) != 0; - let guaranteed = (flags & (1 << 2)) != 0; - if integrated { - Err(())?; - } - AddressType::Featured { subaddress, payment_id: None, guaranteed } - } - }, - Ed25519::read_G::<&[u8]>(&mut addr.spend.as_ref()).map_err(|_| ())?.0, - Ed25519::read_G::<&[u8]>(&mut addr.view.as_ref()).map_err(|_| ())?.0, - ))) - } -} - -#[allow(clippy::from_over_into)] -impl Into for Address { - fn into(self) -> MoneroAddress { - self.0 - } -} - -#[allow(clippy::from_over_into)] -impl Into> for Address { - fn into(self) -> Vec { - EncodedAddress { - kind: match self.0.kind() { - AddressType::Legacy => EncodedAddressType::Legacy, - AddressType::LegacyIntegrated(_) => { - panic!("integrated address became Serai Monero address") - } - AddressType::Subaddress => EncodedAddressType::Subaddress, - AddressType::Featured { subaddress, payment_id, guaranteed } => { - debug_assert!(payment_id.is_none()); - EncodedAddressType::Featured(u8::from(*subaddress) + (u8::from(*guaranteed) << 2)) - } - }, - spend: self.0.spend().compress().0, - view: self.0.view().compress().0, +fn byte_for_kind(kind: AddressType) -> u8 { + // We use the second and third highest bits for the type + // This leaves the top bit open for interpretation as a VarInt later + match kind { + AddressType::Legacy => 0, + AddressType::Subaddress => 1 << 5, + AddressType::Featured(flags) => { + // The flags only take up the low three bits + debug_assert!(flags <= 0b111); + (2 << 5) | flags } - .encode() + } +} + +impl borsh::BorshSerialize for Address { + fn serialize(&self, writer: &mut W) -> borsh::io::Result<()> { + writer.write_all(&[byte_for_kind(self.kind)])?; + writer.write_all(&self.spend.compress().to_bytes())?; + writer.write_all(&self.view.compress().to_bytes()) + } +} +impl borsh::BorshDeserialize for Address { + fn deserialize_reader(reader: &mut R) -> borsh::io::Result { + let mut kind_byte = [0xff]; + reader.read_exact(&mut kind_byte)?; + let kind_byte = kind_byte[0]; + let kind = match kind_byte >> 5 { + 0 => AddressType::Legacy, + 1 => AddressType::Subaddress, + 2 => AddressType::Featured(kind_byte & 0b111), + _ => Err(borsh::io::Error::other("unrecognized type"))?, + }; + // Check this wasn't malleated + if byte_for_kind(kind) != kind_byte { + Err(borsh::io::Error::other("malleated type byte"))?; + } + let spend = Ed25519::read_G(reader)?; + let view = Ed25519::read_G(reader)?; + Ok(Self { kind, spend, view }) + } +} + +impl TryFrom for Address { + type Error = (); + fn try_from(address: MoneroAddress) -> Result { + let spend = address.spend().compress().to_bytes(); + let view = address.view().compress().to_bytes(); + let kind = match address.kind() { + MoneroAddressType::Legacy => AddressType::Legacy, + MoneroAddressType::LegacyIntegrated(_) => Err(())?, + MoneroAddressType::Subaddress => AddressType::Subaddress, + MoneroAddressType::Featured { subaddress, payment_id, guaranteed } => { + if payment_id.is_some() { + Err(())? + } + // This maintains the same bit layout as featured addresses use + AddressType::Featured(u8::from(*subaddress) + (u8::from(*guaranteed) << 2)) + } + }; + Ok(Address { + kind, + spend: Ed25519::read_G(&mut spend.as_slice()).map_err(|_| ())?, + view: Ed25519::read_G(&mut view.as_slice()).map_err(|_| ())?, + }) + } +} + +impl From
for MoneroAddress { + fn from(address: Address) -> MoneroAddress { + let kind = match address.kind { + AddressType::Legacy => MoneroAddressType::Legacy, + AddressType::Subaddress => MoneroAddressType::Subaddress, + AddressType::Featured(features) => { + debug_assert!(features <= 0b111); + let subaddress = (features & 1) != 0; + let integrated = (features & (1 << 1)) != 0; + debug_assert!(!integrated); + let guaranteed = (features & (1 << 2)) != 0; + MoneroAddressType::Featured { subaddress, payment_id: None, guaranteed } + } + }; + MoneroAddress::new(Network::Mainnet, kind, address.spend.0, address.view.0) + } +} + +impl TryFrom for Address { + type Error = (); + fn try_from(data: ExternalAddress) -> Result { + // Decode as an Address + let mut data = data.as_ref(); + let address = +
::deserialize_reader(&mut data).map_err(|_| ())?; + if !data.is_empty() { + Err(())? + } + Ok(address) + } +} +impl From
for ExternalAddress { + fn from(address: Address) -> ExternalAddress { + // This is 65 bytes which is less than MAX_ADDRESS_LEN + ExternalAddress::new(borsh::to_vec(&address).unwrap()).unwrap() + } +} + +impl FromStr for Address { + type Err = (); + fn from_str(str: &str) -> Result { + let Ok(address) = MoneroAddress::from_str(Network::Mainnet, str) else { Err(())? }; + Address::try_from(address) + } +} + +impl fmt::Display for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + MoneroAddress::from(*self).fmt(f) } }