mirror of
https://github.com/serai-dex/serai.git
synced 2025-01-03 09:29:46 +00:00
Ethereum relayer server
Causes send test to pass for the processor.
This commit is contained in:
parent
ac709b2945
commit
1d2beb3ee4
20 changed files with 416 additions and 44 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
@ -7884,6 +7884,17 @@ dependencies = [
|
||||||
name = "serai-env"
|
name = "serai-env"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serai-ethereum-relayer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"serai-db",
|
||||||
|
"serai-env",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serai-full-stack-tests"
|
name = "serai-full-stack-tests"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
@ -38,8 +38,11 @@ members = [
|
||||||
"crypto/schnorrkel",
|
"crypto/schnorrkel",
|
||||||
|
|
||||||
"coins/bitcoin",
|
"coins/bitcoin",
|
||||||
|
|
||||||
"coins/ethereum/alloy-simple-request-transport",
|
"coins/ethereum/alloy-simple-request-transport",
|
||||||
"coins/ethereum",
|
"coins/ethereum",
|
||||||
|
"coins/ethereum/relayer",
|
||||||
|
|
||||||
"coins/monero/generators",
|
"coins/monero/generators",
|
||||||
"coins/monero",
|
"coins/monero",
|
||||||
|
|
||||||
|
|
30
coins/ethereum/relayer/Cargo.toml
Normal file
30
coins/ethereum/relayer/Cargo.toml
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "serai-ethereum-relayer"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A relayer for Serai's Ethereum transactions"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
repository = "https://github.com/serai-dex/serai/tree/develop/coins/ethereum/relayer"
|
||||||
|
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
|
||||||
|
keywords = []
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
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", "time", "io-util", "net", "macros"] }
|
||||||
|
|
||||||
|
serai-env = { path = "../../../common/env" }
|
||||||
|
serai-db = { path = "../../../common/db" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
parity-db = ["serai-db/parity-db"]
|
||||||
|
rocksdb = ["serai-db/rocksdb"]
|
15
coins/ethereum/relayer/LICENSE
Normal file
15
coins/ethereum/relayer/LICENSE
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
AGPL-3.0-only license
|
||||||
|
|
||||||
|
Copyright (c) 2023-2024 Luke Parker
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License Version 3 as
|
||||||
|
published by the Free Software Foundation.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
4
coins/ethereum/relayer/README.md
Normal file
4
coins/ethereum/relayer/README.md
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# Ethereum Transaction Relayer
|
||||||
|
|
||||||
|
This server collects Ethereum router commands to be published, offering an RPC
|
||||||
|
to fetch them.
|
100
coins/ethereum/relayer/src/main.rs
Normal file
100
coins/ethereum/relayer/src/main.rs
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
pub(crate) use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::TcpListener,
|
||||||
|
};
|
||||||
|
|
||||||
|
use serai_db::{Get, DbTxn, Db as DbTrait};
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() {
|
||||||
|
// Override the panic handler with one which will panic if any tokio task panics
|
||||||
|
{
|
||||||
|
let existing = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |panic| {
|
||||||
|
existing(panic);
|
||||||
|
const MSG: &str = "exiting the process due to a task panicking";
|
||||||
|
println!("{MSG}");
|
||||||
|
log::error!("{MSG}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if std::env::var("RUST_LOG").is_err() {
|
||||||
|
std::env::set_var("RUST_LOG", serai_env::var("RUST_LOG").unwrap_or_else(|| "info".to_string()));
|
||||||
|
}
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
log::info!("Starting Ethereum relayer server...");
|
||||||
|
|
||||||
|
// Open the DB
|
||||||
|
#[allow(unused_variables, unreachable_code)]
|
||||||
|
let db = {
|
||||||
|
#[cfg(all(feature = "parity-db", feature = "rocksdb"))]
|
||||||
|
panic!("built with parity-db and rocksdb");
|
||||||
|
#[cfg(all(feature = "parity-db", not(feature = "rocksdb")))]
|
||||||
|
let db =
|
||||||
|
serai_db::new_parity_db(&serai_env::var("DB_PATH").expect("path to DB wasn't specified"));
|
||||||
|
#[cfg(feature = "rocksdb")]
|
||||||
|
let db =
|
||||||
|
serai_db::new_rocksdb(&serai_env::var("DB_PATH").expect("path to DB wasn't specified"));
|
||||||
|
db
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start command recipience server
|
||||||
|
// This should not be publicly exposed
|
||||||
|
// TODO: Add auth
|
||||||
|
tokio::spawn({
|
||||||
|
let db = db.clone();
|
||||||
|
async move {
|
||||||
|
// 5132 ^ ((b'E' << 8) | b'R')
|
||||||
|
let server = TcpListener::bind("0.0.0.0:20830").await.unwrap();
|
||||||
|
loop {
|
||||||
|
let (mut socket, _) = server.accept().await.unwrap();
|
||||||
|
let db = db.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut db = db.clone();
|
||||||
|
loop {
|
||||||
|
let Ok(msg_len) = socket.read_u32_le().await else { break };
|
||||||
|
let mut buf = vec![0; usize::try_from(msg_len).unwrap()];
|
||||||
|
let Ok(_) = socket.read_exact(&mut buf).await else { break };
|
||||||
|
|
||||||
|
if buf.len() < 5 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nonce = u32::from_le_bytes(buf[.. 4].try_into().unwrap());
|
||||||
|
let mut txn = db.txn();
|
||||||
|
txn.put(nonce.to_le_bytes(), &buf[4 ..]);
|
||||||
|
txn.commit();
|
||||||
|
|
||||||
|
let Ok(()) = socket.write_all(&[1]).await else { break };
|
||||||
|
|
||||||
|
log::info!("received signed command #{nonce}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start command fetch server
|
||||||
|
// 5132 ^ ((b'E' << 8) | b'R') + 1
|
||||||
|
let server = TcpListener::bind("0.0.0.0:20831").await.unwrap();
|
||||||
|
loop {
|
||||||
|
let (mut socket, _) = server.accept().await.unwrap();
|
||||||
|
let db = db.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let db = db.clone();
|
||||||
|
loop {
|
||||||
|
// Nonce to get the router comamnd for
|
||||||
|
let mut buf = vec![0; 4];
|
||||||
|
let Ok(_) = socket.read_exact(&mut buf).await else { break };
|
||||||
|
|
||||||
|
let command = db.get(&buf[.. 4]).unwrap_or(vec![]);
|
||||||
|
let Ok(()) = socket.write_all(&u32::try_from(command.len()).unwrap().to_le_bytes()).await
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Ok(()) = socket.write_all(&command).await else { break };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,6 +44,7 @@ exceptions = [
|
||||||
{ allow = ["AGPL-3.0"], name = "serai-env" },
|
{ allow = ["AGPL-3.0"], name = "serai-env" },
|
||||||
|
|
||||||
{ allow = ["AGPL-3.0"], name = "ethereum-serai" },
|
{ allow = ["AGPL-3.0"], name = "ethereum-serai" },
|
||||||
|
{ allow = ["AGPL-3.0"], name = "serai-ethereum-relayer" },
|
||||||
|
|
||||||
{ allow = ["AGPL-3.0"], name = "serai-message-queue" },
|
{ allow = ["AGPL-3.0"], name = "serai-message-queue" },
|
||||||
|
|
||||||
|
|
11
orchestration/dev/coins/ethereum-relayer/.folder
Normal file
11
orchestration/dev/coins/ethereum-relayer/.folder
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
RPC_USER="${RPC_USER:=serai}"
|
||||||
|
RPC_PASS="${RPC_PASS:=seraidex}"
|
||||||
|
|
||||||
|
# Run Monero
|
||||||
|
monerod --non-interactive --regtest --offline --fixed-difficulty=1 \
|
||||||
|
--no-zmq --rpc-bind-ip=0.0.0.0 --rpc-bind-port=18081 --confirm-external-bind \
|
||||||
|
--rpc-access-control-origins "*" --disable-rpc-ban \
|
||||||
|
--rpc-login=$RPC_USER:$RPC_PASS \
|
||||||
|
$1
|
39
orchestration/src/ethereum_relayer.rs
Normal file
39
orchestration/src/ethereum_relayer.rs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{Network, Os, mimalloc, os, build_serai_service, write_dockerfile};
|
||||||
|
|
||||||
|
pub fn ethereum_relayer(orchestration_path: &Path, network: Network) {
|
||||||
|
let setup = mimalloc(Os::Debian).to_string() +
|
||||||
|
&build_serai_service("", network.release(), network.db(), "serai-ethereum-relayer");
|
||||||
|
|
||||||
|
let env_vars = [
|
||||||
|
("DB_PATH", "/volume/ethereum-relayer-db".to_string()),
|
||||||
|
("RUST_LOG", "info,serai_ethereum_relayer=trace".to_string()),
|
||||||
|
];
|
||||||
|
let mut env_vars_str = String::new();
|
||||||
|
for (env_var, value) in env_vars {
|
||||||
|
env_vars_str += &format!(r#"{env_var}=${{{env_var}:="{value}"}} "#);
|
||||||
|
}
|
||||||
|
|
||||||
|
let run_ethereum_relayer = format!(
|
||||||
|
r#"
|
||||||
|
# Copy the relayer server binary and relevant license
|
||||||
|
COPY --from=builder --chown=ethereumrelayer /serai/bin/serai-ethereum-relayer /bin
|
||||||
|
|
||||||
|
# Run ethereum-relayer
|
||||||
|
EXPOSE 20830
|
||||||
|
EXPOSE 20831
|
||||||
|
CMD {env_vars_str} serai-ethereum-relayer
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let run = os(Os::Debian, "", "ethereumrelayer") + &run_ethereum_relayer;
|
||||||
|
let res = setup + &run;
|
||||||
|
|
||||||
|
let mut ethereum_relayer_path = orchestration_path.to_path_buf();
|
||||||
|
ethereum_relayer_path.push("coins");
|
||||||
|
ethereum_relayer_path.push("ethereum-relayer");
|
||||||
|
ethereum_relayer_path.push("Dockerfile");
|
||||||
|
|
||||||
|
write_dockerfile(ethereum_relayer_path, &res);
|
||||||
|
}
|
|
@ -32,6 +32,9 @@ use mimalloc::mimalloc;
|
||||||
mod coins;
|
mod coins;
|
||||||
use coins::*;
|
use coins::*;
|
||||||
|
|
||||||
|
mod ethereum_relayer;
|
||||||
|
use ethereum_relayer::ethereum_relayer;
|
||||||
|
|
||||||
mod message_queue;
|
mod message_queue;
|
||||||
use message_queue::message_queue;
|
use message_queue::message_queue;
|
||||||
|
|
||||||
|
@ -280,6 +283,8 @@ fn dockerfiles(network: Network) {
|
||||||
let ethereum_key = infrastructure_keys.remove("ethereum").unwrap();
|
let ethereum_key = infrastructure_keys.remove("ethereum").unwrap();
|
||||||
let monero_key = infrastructure_keys.remove("monero").unwrap();
|
let monero_key = infrastructure_keys.remove("monero").unwrap();
|
||||||
|
|
||||||
|
ethereum_relayer(&orchestration_path, network);
|
||||||
|
|
||||||
message_queue(
|
message_queue(
|
||||||
&orchestration_path,
|
&orchestration_path,
|
||||||
network,
|
network,
|
||||||
|
@ -363,6 +368,7 @@ fn start(network: Network, services: HashSet<String>) {
|
||||||
let name = match service.as_ref() {
|
let name = match service.as_ref() {
|
||||||
"serai" => "serai",
|
"serai" => "serai",
|
||||||
"coordinator" => "coordinator",
|
"coordinator" => "coordinator",
|
||||||
|
"ethereum-relayer" => "ethereum-relayer",
|
||||||
"message-queue" => "message-queue",
|
"message-queue" => "message-queue",
|
||||||
"bitcoin-daemon" => "bitcoin",
|
"bitcoin-daemon" => "bitcoin",
|
||||||
"bitcoin-processor" => "bitcoin-processor",
|
"bitcoin-processor" => "bitcoin-processor",
|
||||||
|
@ -495,6 +501,10 @@ fn start(network: Network, services: HashSet<String>) {
|
||||||
command
|
command
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"ethereum-relayer" => {
|
||||||
|
// Expose the router command fetch server
|
||||||
|
command.arg("-p").arg("20831:20831")
|
||||||
|
}
|
||||||
"monero" => {
|
"monero" => {
|
||||||
// Expose the RPC for tests
|
// Expose the RPC for tests
|
||||||
if network == Network::Dev {
|
if network == Network::Dev {
|
||||||
|
@ -561,6 +571,9 @@ Commands:
|
||||||
- `message-queue`
|
- `message-queue`
|
||||||
- `bitcoin-daemon`
|
- `bitcoin-daemon`
|
||||||
- `bitcoin-processor`
|
- `bitcoin-processor`
|
||||||
|
- `ethereum-daemon`
|
||||||
|
- `ethereum-processor`
|
||||||
|
- `ethereum-relayer`
|
||||||
- `monero-daemon`
|
- `monero-daemon`
|
||||||
- `monero-processor`
|
- `monero-processor`
|
||||||
- `monero-wallet-rpc` (if "dev")
|
- `monero-wallet-rpc` (if "dev")
|
||||||
|
@ -593,6 +606,9 @@ Commands:
|
||||||
Some("start") => {
|
Some("start") => {
|
||||||
let mut services = HashSet::new();
|
let mut services = HashSet::new();
|
||||||
for arg in args {
|
for arg in args {
|
||||||
|
if arg == "ethereum-processor" {
|
||||||
|
services.insert("ethereum-relayer".to_string());
|
||||||
|
}
|
||||||
if let Some(ext_network) = arg.strip_suffix("-processor") {
|
if let Some(ext_network) = arg.strip_suffix("-processor") {
|
||||||
services.insert(ext_network.to_string() + "-daemon");
|
services.insert(ext_network.to_string() + "-daemon");
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,24 +41,32 @@ RUN apt install -y ca-certificates
|
||||||
const RPC_PASS: &str = "seraidex";
|
const RPC_PASS: &str = "seraidex";
|
||||||
// TODO: Isolate networks
|
// TODO: Isolate networks
|
||||||
let hostname = format!("serai-{}-{coin}", network.label());
|
let hostname = format!("serai-{}-{coin}", network.label());
|
||||||
let port = match coin {
|
let port = format!(
|
||||||
"bitcoin" => 8332,
|
"{}",
|
||||||
"ethereum" => 8545,
|
match coin {
|
||||||
"monero" => 18081,
|
"bitcoin" => 8332,
|
||||||
_ => panic!("unrecognized external network"),
|
"ethereum" => 8545,
|
||||||
};
|
"monero" => 18081,
|
||||||
|
_ => panic!("unrecognized external network"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let env_vars = [
|
let mut env_vars = vec![
|
||||||
("MESSAGE_QUEUE_RPC", format!("serai-{}-message-queue", network.label())),
|
("MESSAGE_QUEUE_RPC", format!("serai-{}-message-queue", network.label())),
|
||||||
("MESSAGE_QUEUE_KEY", hex::encode(coin_key.to_repr())),
|
("MESSAGE_QUEUE_KEY", hex::encode(coin_key.to_repr())),
|
||||||
("ENTROPY", hex::encode(entropy.as_ref())),
|
("ENTROPY", hex::encode(entropy.as_ref())),
|
||||||
("NETWORK", coin.to_string()),
|
("NETWORK", coin.to_string()),
|
||||||
("NETWORK_RPC_LOGIN", format!("{RPC_USER}:{RPC_PASS}")),
|
("NETWORK_RPC_LOGIN", format!("{RPC_USER}:{RPC_PASS}")),
|
||||||
("NETWORK_RPC_HOSTNAME", hostname),
|
("NETWORK_RPC_HOSTNAME", hostname),
|
||||||
("NETWORK_RPC_PORT", format!("{port}")),
|
("NETWORK_RPC_PORT", port),
|
||||||
("DB_PATH", "/volume/processor-db".to_string()),
|
("DB_PATH", "/volume/processor-db".to_string()),
|
||||||
("RUST_LOG", "info,serai_processor=debug".to_string()),
|
("RUST_LOG", "info,serai_processor=debug".to_string()),
|
||||||
];
|
];
|
||||||
|
if coin == "ethereum" {
|
||||||
|
env_vars
|
||||||
|
.push(("ETHEREUM_RELAYER_HOSTNAME", format!("serai-{}-ethereum-relayer", network.label())));
|
||||||
|
env_vars.push(("ETHEREUM_RELAYER_PORT", "20830".to_string()));
|
||||||
|
}
|
||||||
let mut env_vars_str = String::new();
|
let mut env_vars_str = String::new();
|
||||||
for (env_var, value) in env_vars {
|
for (env_var, value) in env_vars {
|
||||||
env_vars_str += &format!(r#"{env_var}=${{{env_var}:="{value}"}} "#);
|
env_vars_str += &format!(r#"{env_var}=${{{env_var}:="{value}"}} "#);
|
||||||
|
|
11
orchestration/testnet/coins/ethereum-relayer/.folder
Normal file
11
orchestration/testnet/coins/ethereum-relayer/.folder
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
RPC_USER="${RPC_USER:=serai}"
|
||||||
|
RPC_PASS="${RPC_PASS:=seraidex}"
|
||||||
|
|
||||||
|
# Run Monero
|
||||||
|
monerod --non-interactive --regtest --offline --fixed-difficulty=1 \
|
||||||
|
--no-zmq --rpc-bind-ip=0.0.0.0 --rpc-bind-port=18081 --confirm-external-bind \
|
||||||
|
--rpc-access-control-origins "*" --disable-rpc-ban \
|
||||||
|
--rpc-login=$RPC_USER:$RPC_PASS \
|
||||||
|
$1
|
|
@ -748,7 +748,15 @@ async fn main() {
|
||||||
#[cfg(feature = "bitcoin")]
|
#[cfg(feature = "bitcoin")]
|
||||||
NetworkId::Bitcoin => run(db, Bitcoin::new(url).await, coordinator).await,
|
NetworkId::Bitcoin => run(db, Bitcoin::new(url).await, coordinator).await,
|
||||||
#[cfg(feature = "ethereum")]
|
#[cfg(feature = "ethereum")]
|
||||||
NetworkId::Ethereum => run(db.clone(), Ethereum::new(db, url).await, coordinator).await,
|
NetworkId::Ethereum => {
|
||||||
|
let relayer_hostname = env::var("ETHEREUM_RELAYER_HOSTNAME")
|
||||||
|
.expect("ethereum relayer hostname wasn't specified")
|
||||||
|
.to_string();
|
||||||
|
let relayer_port =
|
||||||
|
env::var("ETHEREUM_RELAYER_PORT").expect("ethereum relayer port wasn't specified");
|
||||||
|
let relayer_url = relayer_hostname + ":" + &relayer_port;
|
||||||
|
run(db.clone(), Ethereum::new(db, url, relayer_url).await, coordinator).await
|
||||||
|
}
|
||||||
#[cfg(feature = "monero")]
|
#[cfg(feature = "monero")]
|
||||||
NetworkId::Monero => run(db, Monero::new(url).await, coordinator).await,
|
NetworkId::Monero => run(db, Monero::new(url).await, coordinator).await,
|
||||||
_ => panic!("spawning a processor for an unsupported network"),
|
_ => panic!("spawning a processor for an unsupported network"),
|
||||||
|
|
|
@ -31,6 +31,11 @@ use tokio::{
|
||||||
time::sleep,
|
time::sleep,
|
||||||
sync::{RwLock, RwLockReadGuard},
|
sync::{RwLock, RwLockReadGuard},
|
||||||
};
|
};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::TcpStream,
|
||||||
|
};
|
||||||
|
|
||||||
use serai_client::{
|
use serai_client::{
|
||||||
primitives::{Coin, Amount, Balance, NetworkId},
|
primitives::{Coin, Amount, Balance, NetworkId},
|
||||||
|
@ -290,6 +295,8 @@ pub struct Ethereum<D: Db> {
|
||||||
// address. Accordingly, all methods present are consistent to a Serai chain with a finalized
|
// address. Accordingly, all methods present are consistent to a Serai chain with a finalized
|
||||||
// first key (regardless of local state), and this is safe.
|
// first key (regardless of local state), and this is safe.
|
||||||
db: D,
|
db: D,
|
||||||
|
#[cfg_attr(test, allow(unused))]
|
||||||
|
relayer_url: String,
|
||||||
provider: Arc<RootProvider<SimpleRequest>>,
|
provider: Arc<RootProvider<SimpleRequest>>,
|
||||||
deployer: Deployer,
|
deployer: Deployer,
|
||||||
router: Arc<RwLock<Option<Router>>>,
|
router: Arc<RwLock<Option<Router>>>,
|
||||||
|
@ -309,9 +316,9 @@ impl<D: Db> fmt::Debug for Ethereum<D> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl<D: Db> Ethereum<D> {
|
impl<D: Db> Ethereum<D> {
|
||||||
pub async fn new(db: D, url: String) -> Self {
|
pub async fn new(db: D, daemon_url: String, relayer_url: String) -> Self {
|
||||||
let provider = Arc::new(RootProvider::new(
|
let provider = Arc::new(RootProvider::new(
|
||||||
ClientBuilder::default().transport(SimpleRequest::new(url), true),
|
ClientBuilder::default().transport(SimpleRequest::new(daemon_url), true),
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut deployer = Deployer::new(provider.clone()).await;
|
let mut deployer = Deployer::new(provider.clone()).await;
|
||||||
|
@ -322,7 +329,9 @@ impl<D: Db> Ethereum<D> {
|
||||||
}
|
}
|
||||||
let deployer = deployer.unwrap().unwrap();
|
let deployer = deployer.unwrap().unwrap();
|
||||||
|
|
||||||
Ethereum { db, provider, deployer, router: Arc::new(RwLock::new(None)) }
|
dbg!(&relayer_url);
|
||||||
|
dbg!(relayer_url.len());
|
||||||
|
Ethereum { db, relayer_url, provider, deployer, router: Arc::new(RwLock::new(None)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtain a reference to the Router, sleeping until it's deployed if it hasn't already been.
|
// Obtain a reference to the Router, sleeping until it's deployed if it hasn't already been.
|
||||||
|
@ -714,8 +723,32 @@ impl<D: Db> Network for Ethereum<D> {
|
||||||
// Publish this to the dedicated TX server for a solver to actually publish
|
// Publish this to the dedicated TX server for a solver to actually publish
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
{
|
{
|
||||||
let _ = completion;
|
let mut msg = vec![];
|
||||||
todo!("TODO");
|
match completion.command() {
|
||||||
|
RouterCommand::UpdateSeraiKey { nonce, .. } | RouterCommand::Execute { nonce, .. } => {
|
||||||
|
msg.extend(&u32::try_from(nonce).unwrap().to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completion.write(&mut msg).unwrap();
|
||||||
|
|
||||||
|
let Ok(mut socket) = TcpStream::connect(&self.relayer_url).await else {
|
||||||
|
log::warn!("couldn't connect to the relayer server");
|
||||||
|
Err(NetworkError::ConnectionError)?
|
||||||
|
};
|
||||||
|
let Ok(()) = socket.write_all(&u32::try_from(msg.len()).unwrap().to_le_bytes()).await else {
|
||||||
|
log::warn!("couldn't send the message's len to the relayer server");
|
||||||
|
Err(NetworkError::ConnectionError)?
|
||||||
|
};
|
||||||
|
let Ok(()) = socket.write_all(&msg).await else {
|
||||||
|
log::warn!("couldn't write the message to the relayer server");
|
||||||
|
Err(NetworkError::ConnectionError)?
|
||||||
|
};
|
||||||
|
if socket.read_u8().await.ok() != Some(1) {
|
||||||
|
log::warn!("didn't get the ack from the relayer server");
|
||||||
|
Err(NetworkError::ConnectionError)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish this using a dummy account we fund with magic RPC commands
|
// Publish this using a dummy account we fund with magic RPC commands
|
||||||
|
|
|
@ -423,7 +423,7 @@ mod ethereum {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ethereum::new(db, url.clone()).await
|
Ethereum::new(db, url.clone(), String::new()).await
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,7 +85,7 @@ pub fn build(name: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dockerfile_path = orchestration_path.clone();
|
let mut dockerfile_path = orchestration_path.clone();
|
||||||
if HashSet::from(["bitcoin", "ethereum", "monero"]).contains(name.as_str()) {
|
if HashSet::from(["bitcoin", "ethereum", "ethereum-relayer", "monero"]).contains(name.as_str()) {
|
||||||
dockerfile_path = dockerfile_path.join("coins");
|
dockerfile_path = dockerfile_path.join("coins");
|
||||||
}
|
}
|
||||||
if name.contains("-processor") {
|
if name.contains("-processor") {
|
||||||
|
@ -125,6 +125,7 @@ pub fn build(name: String) {
|
||||||
let meta = |path: PathBuf| (path.clone(), fs::metadata(path));
|
let meta = |path: PathBuf| (path.clone(), fs::metadata(path));
|
||||||
let mut metadatas = match name.as_str() {
|
let mut metadatas = match name.as_str() {
|
||||||
"bitcoin" | "ethereum" | "monero" => vec![],
|
"bitcoin" | "ethereum" | "monero" => vec![],
|
||||||
|
"ethereum-relayer" => vec![meta(repo_path.join("common")), meta(repo_path.join("coins"))],
|
||||||
"message-queue" => vec![
|
"message-queue" => vec![
|
||||||
meta(repo_path.join("common")),
|
meta(repo_path.join("common")),
|
||||||
meta(repo_path.join("crypto")),
|
meta(repo_path.join("crypto")),
|
||||||
|
|
|
@ -57,12 +57,16 @@ pub(crate) async fn new_test(test_body: impl TestBody) {
|
||||||
let (coord_key, message_queue_keys, message_queue_composition) = message_queue_instance();
|
let (coord_key, message_queue_keys, message_queue_composition) = message_queue_instance();
|
||||||
|
|
||||||
let (bitcoin_composition, bitcoin_port) = network_instance(NetworkId::Bitcoin);
|
let (bitcoin_composition, bitcoin_port) = network_instance(NetworkId::Bitcoin);
|
||||||
let bitcoin_processor_composition =
|
let mut bitcoin_processor_composition =
|
||||||
processor_instance(NetworkId::Bitcoin, bitcoin_port, message_queue_keys[&NetworkId::Bitcoin]);
|
processor_instance(NetworkId::Bitcoin, bitcoin_port, message_queue_keys[&NetworkId::Bitcoin]);
|
||||||
|
assert_eq!(bitcoin_processor_composition.len(), 1);
|
||||||
|
let bitcoin_processor_composition = bitcoin_processor_composition.swap_remove(0);
|
||||||
|
|
||||||
let (monero_composition, monero_port) = network_instance(NetworkId::Monero);
|
let (monero_composition, monero_port) = network_instance(NetworkId::Monero);
|
||||||
let monero_processor_composition =
|
let mut monero_processor_composition =
|
||||||
processor_instance(NetworkId::Monero, monero_port, message_queue_keys[&NetworkId::Monero]);
|
processor_instance(NetworkId::Monero, monero_port, message_queue_keys[&NetworkId::Monero]);
|
||||||
|
assert_eq!(monero_processor_composition.len(), 1);
|
||||||
|
let monero_processor_composition = monero_processor_composition.swap_remove(0);
|
||||||
|
|
||||||
let coordinator_composition = coordinator_instance(name, coord_key);
|
let coordinator_composition = coordinator_instance(name, coord_key);
|
||||||
let serai_composition = serai_composition(name);
|
let serai_composition = serai_composition(name);
|
||||||
|
|
|
@ -28,7 +28,7 @@ pub fn processor_instance(
|
||||||
network: NetworkId,
|
network: NetworkId,
|
||||||
port: u32,
|
port: u32,
|
||||||
message_queue_key: <Ristretto as Ciphersuite>::F,
|
message_queue_key: <Ristretto as Ciphersuite>::F,
|
||||||
) -> TestBodySpecification {
|
) -> Vec<TestBodySpecification> {
|
||||||
let mut entropy = [0; 32];
|
let mut entropy = [0; 32];
|
||||||
OsRng.fill_bytes(&mut entropy);
|
OsRng.fill_bytes(&mut entropy);
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ pub fn processor_instance(
|
||||||
let image = format!("{network_str}-processor");
|
let image = format!("{network_str}-processor");
|
||||||
serai_docker_tests::build(image.clone());
|
serai_docker_tests::build(image.clone());
|
||||||
|
|
||||||
TestBodySpecification::with_image(
|
let mut res = vec![TestBodySpecification::with_image(
|
||||||
Image::with_repository(format!("serai-dev-{image}")).pull_policy(PullPolicy::Never),
|
Image::with_repository(format!("serai-dev-{image}")).pull_policy(PullPolicy::Never),
|
||||||
)
|
)
|
||||||
.replace_env(
|
.replace_env(
|
||||||
|
@ -55,10 +55,30 @@ pub fn processor_instance(
|
||||||
("RUST_LOG".to_string(), "serai_processor=trace,".to_string()),
|
("RUST_LOG".to_string(), "serai_processor=trace,".to_string()),
|
||||||
]
|
]
|
||||||
.into(),
|
.into(),
|
||||||
)
|
)];
|
||||||
|
|
||||||
|
if network == NetworkId::Ethereum {
|
||||||
|
serai_docker_tests::build("ethereum-relayer".to_string());
|
||||||
|
res.push(
|
||||||
|
TestBodySpecification::with_image(
|
||||||
|
Image::with_repository("serai-dev-ethereum-relayer".to_string())
|
||||||
|
.pull_policy(PullPolicy::Never),
|
||||||
|
)
|
||||||
|
.replace_env(
|
||||||
|
[
|
||||||
|
("DB_PATH".to_string(), "./ethereum-relayer-db".to_string()),
|
||||||
|
("RUST_LOG".to_string(), "serai_ethereum_relayer=trace,".to_string()),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
.set_publish_all_ports(true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Handles = (String, String, String);
|
pub type Handles = (String, String, String, String);
|
||||||
pub fn processor_stack(
|
pub fn processor_stack(
|
||||||
network: NetworkId,
|
network: NetworkId,
|
||||||
network_hostname_override: Option<String>,
|
network_hostname_override: Option<String>,
|
||||||
|
@ -68,7 +88,7 @@ pub fn processor_stack(
|
||||||
let (coord_key, message_queue_keys, message_queue_composition) =
|
let (coord_key, message_queue_keys, message_queue_composition) =
|
||||||
serai_message_queue_tests::instance();
|
serai_message_queue_tests::instance();
|
||||||
|
|
||||||
let processor_composition =
|
let mut processor_compositions =
|
||||||
processor_instance(network, network_rpc_port, message_queue_keys[&network]);
|
processor_instance(network, network_rpc_port, message_queue_keys[&network]);
|
||||||
|
|
||||||
// Give every item in this stack a unique ID
|
// Give every item in this stack a unique ID
|
||||||
|
@ -84,7 +104,7 @@ pub fn processor_stack(
|
||||||
let mut compositions = vec![];
|
let mut compositions = vec![];
|
||||||
let mut handles = vec![];
|
let mut handles = vec![];
|
||||||
for (name, composition) in [
|
for (name, composition) in [
|
||||||
(
|
Some((
|
||||||
match network {
|
match network {
|
||||||
NetworkId::Serai => unreachable!(),
|
NetworkId::Serai => unreachable!(),
|
||||||
NetworkId::Bitcoin => "bitcoin",
|
NetworkId::Bitcoin => "bitcoin",
|
||||||
|
@ -92,10 +112,14 @@ pub fn processor_stack(
|
||||||
NetworkId::Monero => "monero",
|
NetworkId::Monero => "monero",
|
||||||
},
|
},
|
||||||
network_composition,
|
network_composition,
|
||||||
),
|
)),
|
||||||
("message_queue", message_queue_composition),
|
Some(("message_queue", message_queue_composition)),
|
||||||
("processor", processor_composition),
|
Some(("processor", processor_compositions.remove(0))),
|
||||||
] {
|
processor_compositions.pop().map(|composition| ("relayer", composition)),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
let handle = format!("processor-{name}-{unique_id}");
|
let handle = format!("processor-{name}-{unique_id}");
|
||||||
compositions.push(
|
compositions.push(
|
||||||
composition.set_start_policy(StartPolicy::Strict).set_handle(handle.clone()).set_log_options(
|
composition.set_start_policy(StartPolicy::Strict).set_handle(handle.clone()).set_log_options(
|
||||||
|
@ -113,14 +137,27 @@ pub fn processor_stack(
|
||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
let processor_composition = compositions.last_mut().unwrap();
|
let processor_composition = compositions.get_mut(2).unwrap();
|
||||||
processor_composition.inject_container_name(
|
processor_composition.inject_container_name(
|
||||||
network_hostname_override.unwrap_or_else(|| handles[0].clone()),
|
network_hostname_override.unwrap_or_else(|| handles[0].clone()),
|
||||||
"NETWORK_RPC_HOSTNAME",
|
"NETWORK_RPC_HOSTNAME",
|
||||||
);
|
);
|
||||||
|
if let Some(hostname) = handles.get(3) {
|
||||||
|
processor_composition.inject_container_name(hostname, "ETHEREUM_RELAYER_HOSTNAME");
|
||||||
|
processor_composition.modify_env("ETHEREUM_RELAYER_PORT", "20830");
|
||||||
|
}
|
||||||
processor_composition.inject_container_name(handles[1].clone(), "MESSAGE_QUEUE_RPC");
|
processor_composition.inject_container_name(handles[1].clone(), "MESSAGE_QUEUE_RPC");
|
||||||
|
|
||||||
((handles[0].clone(), handles[1].clone(), handles[2].clone()), coord_key, compositions)
|
(
|
||||||
|
(
|
||||||
|
handles[0].clone(),
|
||||||
|
handles[1].clone(),
|
||||||
|
handles[2].clone(),
|
||||||
|
handles.get(3).cloned().unwrap_or(String::new()),
|
||||||
|
),
|
||||||
|
coord_key,
|
||||||
|
compositions,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Debug)]
|
#[derive(serde::Deserialize, Debug)]
|
||||||
|
@ -134,6 +171,7 @@ pub struct Coordinator {
|
||||||
message_queue_handle: String,
|
message_queue_handle: String,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
processor_handle: String,
|
processor_handle: String,
|
||||||
|
relayer_handle: String,
|
||||||
|
|
||||||
next_send_id: u64,
|
next_send_id: u64,
|
||||||
next_recv_id: u64,
|
next_recv_id: u64,
|
||||||
|
@ -144,7 +182,7 @@ impl Coordinator {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
network: NetworkId,
|
network: NetworkId,
|
||||||
ops: &DockerOperations,
|
ops: &DockerOperations,
|
||||||
handles: (String, String, String),
|
handles: Handles,
|
||||||
coord_key: <Ristretto as Ciphersuite>::F,
|
coord_key: <Ristretto as Ciphersuite>::F,
|
||||||
) -> Coordinator {
|
) -> Coordinator {
|
||||||
let rpc = ops.handle(&handles.1).host_port(2287).unwrap();
|
let rpc = ops.handle(&handles.1).host_port(2287).unwrap();
|
||||||
|
@ -156,6 +194,7 @@ impl Coordinator {
|
||||||
network_handle: handles.0,
|
network_handle: handles.0,
|
||||||
message_queue_handle: handles.1,
|
message_queue_handle: handles.1,
|
||||||
processor_handle: handles.2,
|
processor_handle: handles.2,
|
||||||
|
relayer_handle: handles.3,
|
||||||
|
|
||||||
next_send_id: 0,
|
next_send_id: 0,
|
||||||
next_recv_id: 0,
|
next_recv_id: 0,
|
||||||
|
@ -508,7 +547,7 @@ impl Coordinator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn publish_transacton(&self, ops: &DockerOperations, tx: &[u8]) {
|
pub async fn publish_transaction(&self, ops: &DockerOperations, tx: &[u8]) {
|
||||||
let rpc_url = network_rpc(self.network, ops, &self.network_handle);
|
let rpc_url = network_rpc(self.network, ops, &self.network_handle);
|
||||||
match self.network {
|
match self.network {
|
||||||
NetworkId::Bitcoin => {
|
NetworkId::Bitcoin => {
|
||||||
|
@ -545,6 +584,14 @@ impl Coordinator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn publish_eventuality_completion(&self, ops: &DockerOperations, tx: &[u8]) {
|
||||||
|
match self.network {
|
||||||
|
NetworkId::Bitcoin | NetworkId::Monero => self.publish_transaction(ops, tx).await,
|
||||||
|
NetworkId::Ethereum => (),
|
||||||
|
NetworkId::Serai => panic!("processor tests broadcasting block to Serai"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_published_transaction(
|
pub async fn get_published_transaction(
|
||||||
&self,
|
&self,
|
||||||
ops: &DockerOperations,
|
ops: &DockerOperations,
|
||||||
|
@ -575,14 +622,7 @@ impl Coordinator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NetworkId::Ethereum => {
|
NetworkId::Ethereum => {
|
||||||
use ethereum_serai::alloy::{
|
/*
|
||||||
consensus::{TxLegacy, Signed},
|
|
||||||
simple_request_transport::SimpleRequest,
|
|
||||||
rpc_client::ClientBuilder,
|
|
||||||
provider::{Provider, RootProvider},
|
|
||||||
network::Ethereum,
|
|
||||||
};
|
|
||||||
|
|
||||||
let provider = RootProvider::<_, Ethereum>::new(
|
let provider = RootProvider::<_, Ethereum>::new(
|
||||||
ClientBuilder::default().transport(SimpleRequest::new(rpc_url.clone()), true),
|
ClientBuilder::default().transport(SimpleRequest::new(rpc_url.clone()), true),
|
||||||
);
|
);
|
||||||
|
@ -593,6 +633,43 @@ impl Coordinator {
|
||||||
let mut bytes = vec![];
|
let mut bytes = vec![];
|
||||||
tx.encode_with_signature_fields(&sig, &mut bytes);
|
tx.encode_with_signature_fields(&sig, &mut bytes);
|
||||||
Some(bytes)
|
Some(bytes)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// This is being passed a signature. We need to check the relayer has a TX with this
|
||||||
|
// signature
|
||||||
|
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::TcpStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (ip, port) = ops.handle(&self.relayer_handle).host_port(20831).unwrap();
|
||||||
|
let relayer_url = format!("{ip}:{port}");
|
||||||
|
|
||||||
|
let mut socket = TcpStream::connect(&relayer_url).await.unwrap();
|
||||||
|
// Iterate over every published command
|
||||||
|
for i in 1 .. u32::MAX {
|
||||||
|
socket.write_all(&i.to_le_bytes()).await.unwrap();
|
||||||
|
|
||||||
|
let mut recvd_len = [0; 4];
|
||||||
|
socket.read_exact(&mut recvd_len).await.unwrap();
|
||||||
|
if recvd_len == [0; 4] {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut msg = vec![0; usize::try_from(u32::from_le_bytes(recvd_len)).unwrap()];
|
||||||
|
socket.read_exact(&mut msg).await.unwrap();
|
||||||
|
for start_pos in 0 .. msg.len() {
|
||||||
|
if (start_pos + tx.len()) > msg.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if &msg[start_pos .. (start_pos + tx.len())] == tx {
|
||||||
|
return Some(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
}
|
}
|
||||||
NetworkId::Monero => {
|
NetworkId::Monero => {
|
||||||
use monero_serai::rpc::HttpRpc;
|
use monero_serai::rpc::HttpRpc;
|
||||||
|
|
|
@ -229,7 +229,7 @@ fn batch_test() {
|
||||||
let (tx, balance_sent) =
|
let (tx, balance_sent) =
|
||||||
wallet.send_to_address(&ops, &key_pair.1, instruction.clone()).await;
|
wallet.send_to_address(&ops, &key_pair.1, instruction.clone()).await;
|
||||||
for coordinator in &mut coordinators {
|
for coordinator in &mut coordinators {
|
||||||
coordinator.publish_transacton(&ops, &tx).await;
|
coordinator.publish_transaction(&ops, &tx).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put the TX past the confirmation depth
|
// Put the TX past the confirmation depth
|
||||||
|
|
|
@ -147,7 +147,7 @@ pub(crate) async fn sign_tx(
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn send_test() {
|
fn send_test() {
|
||||||
for network in [NetworkId::Bitcoin, /* TODO NetworkId::Ethereum, */ NetworkId::Monero] {
|
for network in [NetworkId::Bitcoin, NetworkId::Ethereum, NetworkId::Monero] {
|
||||||
let (coordinators, test) = new_test(network);
|
let (coordinators, test) = new_test(network);
|
||||||
|
|
||||||
test.run(|ops| async move {
|
test.run(|ops| async move {
|
||||||
|
@ -182,7 +182,7 @@ fn send_test() {
|
||||||
let (tx, balance_sent) =
|
let (tx, balance_sent) =
|
||||||
wallet.send_to_address(&ops, &key_pair.1, Some(instruction.clone())).await;
|
wallet.send_to_address(&ops, &key_pair.1, Some(instruction.clone())).await;
|
||||||
for coordinator in &mut coordinators {
|
for coordinator in &mut coordinators {
|
||||||
coordinator.publish_transacton(&ops, &tx).await;
|
coordinator.publish_transaction(&ops, &tx).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put the TX past the confirmation depth
|
// Put the TX past the confirmation depth
|
||||||
|
@ -295,7 +295,7 @@ fn send_test() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for (i, coordinator) in coordinators.iter_mut().enumerate() {
|
for (i, coordinator) in coordinators.iter_mut().enumerate() {
|
||||||
if !participating.contains(&i) {
|
if !participating.contains(&i) {
|
||||||
coordinator.publish_transacton(&ops, &tx).await;
|
coordinator.publish_eventuality_completion(&ops, &tx).await;
|
||||||
// Tell them of it as a completion of the relevant signing nodes
|
// Tell them of it as a completion of the relevant signing nodes
|
||||||
coordinator
|
coordinator
|
||||||
.send_message(messages::sign::CoordinatorMessage::Completed {
|
.send_message(messages::sign::CoordinatorMessage::Completed {
|
||||||
|
|
Loading…
Reference in a new issue