Monero processor primitives

This commit is contained in:
Luke Parker 2024-09-12 18:40:10 -04:00
parent 0d4c8cf032
commit f2cf03cedf
16 changed files with 873 additions and 147 deletions

36
Cargo.lock generated
View file

@ -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",
]

View file

@ -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" }

View file

@ -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));

View file

@ -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"]

View file

@ -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"]

View file

@ -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<Self::ExternalNetworkCiphersuite>) {}
}

View file

@ -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;
}
*/

View file

@ -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<std::alloc::System> =
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<bin::Db>>(db, feed.clone(), feed).await;
}
*/
#[tokio::main]
async fn main() {}

View file

@ -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<Transaction>);
#[async_trait::async_trait]
impl primitives::Block for Block {
type Header = BlockHeader;
type Key = <Ed25519 as Ciphersuite>::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<Self::Output> {
todo!("TODO")
}
#[allow(clippy::type_complexity)]
fn check_for_eventuality_resolutions(
&self,
eventualities: &mut EventualityTracker<Self::Eventuality>,
) -> HashMap<
<Self::Output as ReceivedOutput<Self::Key, Self::Address>>::TransactionId,
Self::Eventuality,
> {
todo!("TODO")
}
}

View file

@ -0,0 +1,3 @@
pub(crate) mod output;
pub(crate) mod transaction;
pub(crate) mod block;

View file

@ -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<<Ed25519 as Ciphersuite>::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) -> <Ed25519 as Ciphersuite>::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() - (*<Ed25519 as Ciphersuite>::G::generator() * self.0.key_offset()),
)
}
fn presumed_origin(&self) -> Option<Address> {
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<W: io::Write>(&self, writer: &mut W) -> io::Result<()> {
self.0.write(writer)
}
fn read<R: io::Read>(reader: &mut R) -> io::Result<Self> {
WalletOutput::read(reader).map(Self)
}
}

View file

@ -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<MTransaction> for Transaction {
fn from(tx: MTransaction) -> Self {
Self(tx)
}
}
impl scheduler::Transaction for Transaction {
fn read(reader: &mut impl io::Read) -> io::Result<Self> {
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<Ed25519>);
impl PreprocessMachine for ClonableTransctionMachine {
type Preprocess = <TransactionMachine as PreprocessMachine>::Preprocess;
type Signature = <TransactionMachine as PreprocessMachine>::Signature;
type SignMachine = <TransactionMachine as PreprocessMachine>::SignMachine;
fn preprocess<R: RngCore + CryptoRng>(
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<Self> {
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::Ciphersuite>) -> Self::PreprocessMachine {
ClonableTransctionMachine(self.signable, keys)
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) struct Eventuality {
id: [u8; 32],
singular_spent_output: Option<OutputId>,
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<u8> {
self.eventuality.extra()
}
fn singular_spent_output(&self) -> Option<Self::OutputId> {
self.singular_spent_output
}
fn read(reader: &mut impl io::Read) -> io::Result<Self> {
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)
}
}

156
processor/monero/src/rpc.rs Normal file
View file

@ -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<D: Db> {
pub(crate) db: D,
pub(crate) rpc: BRpc,
}
#[async_trait::async_trait]
impl<D: Db> ScannerFeed for Rpc<D> {
const NETWORK: NetworkId = NetworkId::Bitcoin;
const CONFIRMATIONS: u64 = 6;
const WINDOW_LENGTH: u64 = 6;
const TEN_MINUTES: u64 = 1;
type Block = Block<D>;
type EphemeralError = RpcError;
async fn latest_finalized_block_number(&self) -> Result<u64, Self::EphemeralError> {
db::LatestBlockToYieldAsFinalized::get(&self.db).ok_or(RpcError::ConnectionError)
}
async fn time_of_block(&self, number: u64) -> Result<u64, Self::EphemeralError> {
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<<Self::Block as primitives::Block>::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<Self::Block, Self::EphemeralError> {
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<Amount, Self::EphemeralError> {
assert_eq!(coin, Coin::Bitcoin);
// TODO
Ok(Amount(0))
}
}
#[async_trait::async_trait]
impl<D: Db> TransactionPublisher<Transaction> for Rpc<D> {
type EphemeralError = RpcError;
async fn publish(&self, tx: Transaction) -> Result<(), Self::EphemeralError> {
self.rpc.send_raw_transaction(&tx.0).await.map(|_| ())
}
}

View file

@ -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: <Secp256k1 as Ciphersuite>::G, kind: OutputType) -> Address {
let offset = <Secp256k1 as Ciphersuite>::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<D: Db>(
fee_per_vbyte: u64,
inputs: Vec<OutputFor<Rpc<D>>>,
payments: Vec<Payment<AddressFor<Rpc<D>>>>,
change: Option<KeyFor<Rpc<D>>>,
) -> Result<(SignableTransaction, BSignableTransaction), TransactionError> {
assert!(
inputs.len() <
<Planner as TransactionPlanner<Rpc<D>, EffectedReceivedOutputs<Rpc<D>>>>::MAX_INPUTS
);
assert!(
(payments.len() + usize::from(u8::from(change.is_some()))) <
<Planner as TransactionPlanner<Rpc<D>, EffectedReceivedOutputs<Rpc<D>>>>::MAX_OUTPUTS
);
let inputs = inputs.into_iter().map(|input| input.output).collect::<Vec<_>>();
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::<Vec<_>>();
/*
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(<Secp256k1 as Ciphersuite>::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(<Planner as TransactionPlanner<Rpc<D>, EffectedReceivedOutputs<Rpc<D>>>>::change_address);
BSignableTransaction::new(
inputs.clone(),
&payments
.iter()
.cloned()
.map(|(address, amount)| (ScriptBuf::from(address), amount))
.collect::<Vec<_>>(),
change.clone().map(ScriptBuf::from),
None,
fee_per_vbyte,
)
.map(|bst| (SignableTransaction { inputs, payments, change, fee_per_vbyte }, bst))
}
pub(crate) struct Planner;
impl<D: Db> TransactionPlanner<Rpc<D>, EffectedReceivedOutputs<Rpc<D>>> 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<Rpc<D>>, coin: Coin) -> Self::FeeRate {
assert_eq!(coin, Coin::Bitcoin);
// TODO
1
}
fn branch_address(key: KeyFor<Rpc<D>>) -> AddressFor<Rpc<D>> {
address_from_serai_key(key, OutputType::Branch)
}
fn change_address(key: KeyFor<Rpc<D>>) -> AddressFor<Rpc<D>> {
address_from_serai_key(key, OutputType::Change)
}
fn forwarding_address(key: KeyFor<Rpc<D>>) -> AddressFor<Rpc<D>> {
address_from_serai_key(key, OutputType::Forwarded)
}
fn calculate_fee(
fee_rate: Self::FeeRate,
inputs: Vec<OutputFor<Rpc<D>>>,
payments: Vec<Payment<AddressFor<Rpc<D>>>>,
change: Option<KeyFor<Rpc<D>>>,
) -> Amount {
match signable_transaction::<D>(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<OutputFor<Rpc<D>>>,
payments: Vec<Payment<AddressFor<Rpc<D>>>>,
change: Option<KeyFor<Rpc<D>>>,
) -> PlannedTransaction<Rpc<D>, Self::SignableTransaction, EffectedReceivedOutputs<Rpc<D>>> {
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::<D>(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<D> = GenericScheduler<Rpc<D>, Planner>;

View file

@ -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

View file

@ -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<Address> {
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<Address, AddressError> {
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: <Ed25519 as Ciphersuite>::G,
view: <Ed25519 as Ciphersuite>::G,
}
impl TryFrom<Vec<u8>> for Address {
type Error = ();
fn try_from(data: Vec<u8>) -> Result<Address, ()> {
// 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<MoneroAddress> for Address {
fn into(self) -> MoneroAddress {
self.0
}
}
#[allow(clippy::from_over_into)]
impl Into<Vec<u8>> for Address {
fn into(self) -> Vec<u8> {
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<W: borsh::io::Write>(&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<R: borsh::io::Read>(reader: &mut R) -> borsh::io::Result<Self> {
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<MoneroAddress> for Address {
type Error = ();
fn try_from(address: MoneroAddress) -> Result<Self, ()> {
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<Address> 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<ExternalAddress> for Address {
type Error = ();
fn try_from(data: ExternalAddress) -> Result<Address, ()> {
// Decode as an Address
let mut data = data.as_ref();
let address =
<Address as borsh::BorshDeserialize>::deserialize_reader(&mut data).map_err(|_| ())?;
if !data.is_empty() {
Err(())?
}
Ok(address)
}
}
impl From<Address> 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<Address, ()> {
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)
}
}