diff --git a/Cargo.lock b/Cargo.lock index 38235456734..1f950b8dd46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1383,14 +1383,14 @@ dependencies = [ [[package]] name = "ark-bls12-381" -version = "0.6.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2ede2c0c96fa37d5d3484e8a59fec566c4a52b8c84bf993eaa6c67d7225a4c" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" dependencies = [ - "ark-ec 0.6.0", - "ark-ff 0.6.0", - "ark-serialize 0.6.0", - "ark-std 0.6.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", ] [[package]] @@ -1452,19 +1452,19 @@ dependencies = [ [[package]] name = "ark-ec" -version = "0.6.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8352a2b2aedf6ba2cc38f7520fc51191d518dde96175c729af19f2d059f191c4" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" dependencies = [ "ahash", - "ark-ff 0.6.0", - "ark-poly 0.6.0", - "ark-serialize 0.6.0", - "ark-std 0.6.0", + "ark-ff 0.5.0", + "ark-poly 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", "educe", "fnv", - "hashbrown 0.17.1", - "itertools 0.14.0", + "hashbrown 0.15.5", + "itertools 0.13.0", "num-bigint", "num-integer", "num-traits", @@ -1579,23 +1579,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ark-ff" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" -dependencies = [ - "ark-ff-asm 0.6.0", - "ark-ff-macros 0.6.0", - "ark-serialize 0.6.0", - "ark-std 0.6.0", - "digest 0.10.7", - "educe", - "num-bigint", - "num-traits", - "zeroize", -] - [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -1626,16 +1609,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "ark-ff-asm" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" -dependencies = [ - "quote", - "syn 2.0.114", -] - [[package]] name = "ark-ff-macros" version = "0.3.0" @@ -1674,19 +1647,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "ark-ff-macros" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" -dependencies = [ - "num-bigint", - "num-traits", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "ark-models-ext" version = "0.4.1" @@ -1715,17 +1675,17 @@ dependencies = [ [[package]] name = "ark-poly" -version = "0.6.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f55af10b672002b8d953e230282c51206842e20e5791a94432219b4201de5c" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" dependencies = [ "ahash", - "ark-ff 0.6.0", - "ark-serialize 0.6.0", - "ark-std 0.6.0", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", "educe", "fnv", - "hashbrown 0.17.1", + "hashbrown 0.15.5", ] [[package]] @@ -1742,6 +1702,20 @@ dependencies = [ "scale-info", ] +[[package]] +name = "ark-scale" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985c81a9c7b23a72f62b7b20686d5326d2a9956806f37de9ee35cb1238faf0c0" +dependencies = [ + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "parity-scale-codec", + "scale-info", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -1770,24 +1744,13 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ + "ark-serialize-derive 0.5.0", "ark-std 0.5.0", "arrayvec 0.7.6", "digest 0.10.7", "num-bigint", ] -[[package]] -name = "ark-serialize" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" -dependencies = [ - "ark-serialize-derive 0.6.0", - "ark-std 0.6.0", - "digest 0.10.7", - "num-bigint", -] - [[package]] name = "ark-serialize-derive" version = "0.4.2" @@ -1801,9 +1764,9 @@ dependencies = [ [[package]] name = "ark-serialize-derive" -version = "0.6.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", @@ -1841,16 +1804,6 @@ dependencies = [ "rand 0.8.5", ] -[[package]] -name = "ark-std" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "array-bytes" version = "6.2.3" @@ -5638,6 +5591,7 @@ dependencies = [ "ethexe-runtime-common", "ethexe-sdk", "ethexe-service", + "ferveo-gear-tdec", "gear-workspace-hack", "gprimitives", "gsigner", @@ -5661,6 +5615,8 @@ version = "2.0.0" dependencies = [ "alloy-primitives", "anyhow", + "ark-ec 0.5.0", + "ark-serialize 0.5.0", "auto_impl", "derive_more 2.1.1", "ferveo-gear-tdec", @@ -5747,6 +5703,7 @@ dependencies = [ "ethexe-common", "ethexe-ethereum", "ethexe-runtime-common", + "ferveo-gear-tdec", "flate2", "futures", "gear-core", @@ -5809,6 +5766,7 @@ dependencies = [ "ethexe-db", "ethexe-malachite-core", "ethexe-runtime-common", + "ferveo-gear-tdec", "futures", "gear-workspace-hack", "gprimitives", @@ -6035,6 +5993,7 @@ dependencies = [ "ethexe-common", "ethexe-rpc-common", "ethexe-runtime-common", + "ferveo-gear-tdec", "gear-workspace-hack", "gprimitives", "jsonrpsee", @@ -6058,13 +6017,13 @@ name = "ethexe-rpc-server" version = "2.0.0" dependencies = [ "anyhow", - "dashmap 5.5.3", "ethexe-common", "ethexe-db", "ethexe-processor", "ethexe-rpc-client", "ethexe-rpc-common", "ethexe-runtime-common", + "ferveo-gear-tdec", "futures", "gear-core", "gear-workspace-hack", @@ -6174,6 +6133,7 @@ dependencies = [ "ethexe-rpc-server", "ethexe-runtime-common", "ethexe-service-utils", + "ferveo-gear-tdec", "futures", "gear-core", "gear-core-errors", @@ -6184,6 +6144,7 @@ dependencies = [ "log", "ntest", "parity-scale-codec", + "rand 0.8.5", "tempfile", "tokio", "tracing", @@ -6319,44 +6280,44 @@ dependencies = [ [[package]] name = "ferveo-gear-common" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "049504d8d4e9c23ae2ff50e37f81cac1c191eb71baf79f20bf9361ee29546461" +checksum = "30a17e50cd62653ecf51f3123b4b9950e6873aa2fa6c7266e04a6000fa9ff941" dependencies = [ - "ark-ec 0.6.0", - "ark-serialize 0.6.0", - "ark-std 0.6.0", - "bincode", + "ark-ec 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", "const-hex", "generic-array 0.14.7", + "parity-scale-codec", "rand 0.8.5", "serde", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] name = "ferveo-gear-tdec" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "991a44fbe8ea0d5b1e3c49884c1cda791d0bd3612a2deacf652d027d0b759090" +checksum = "8eedc637ff20af20c9aea6b9d82b8740311ed634309739cbd886809932314ad8" dependencies = [ - "ark-bls12-381 0.6.0", - "ark-ec 0.6.0", - "ark-ff 0.6.0", - "ark-poly 0.6.0", - "ark-serialize 0.6.0", - "ark-std 0.6.0", - "bincode", + "ark-bls12-381 0.5.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-poly 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", "chacha20poly1305", "const-hex", "ferveo-gear-common", + "hex", "itertools 0.10.5", "parity-scale-codec", "rand 0.8.5", "serde", "sha2 0.10.9", "subproductdomain-gear", - "thiserror 1.0.69", + "thiserror 2.0.17", "zeroize", ] @@ -7134,11 +7095,11 @@ dependencies = [ name = "gbuiltin-bls381" version = "2.0.0" dependencies = [ - "ark-bls12-381 0.4.0", - "ark-ec 0.4.2", - "ark-ff 0.4.2", - "ark-scale", - "ark-serialize 0.4.2", + "ark-bls12-381 0.5.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-scale 0.0.13", + "ark-serialize 0.5.0", "gear-workspace-hack", "parity-scale-codec", "scale-info", @@ -8000,7 +7961,7 @@ dependencies = [ "ark-ec 0.4.2", "ark-ff 0.4.2", "ark-models-ext", - "ark-scale", + "ark-scale 0.0.12", "ark-serialize 0.4.2", "ark-std 0.4.0", "arrayvec 0.7.6", @@ -8585,11 +8546,11 @@ dependencies = [ name = "gsdk" version = "2.0.0" dependencies = [ - "ark-bls12-381 0.4.0", - "ark-ec 0.4.2", - "ark-scale", - "ark-serialize 0.4.2", - "ark-std 0.4.0", + "ark-bls12-381 0.5.0", + "ark-ec 0.5.0", + "ark-scale 0.0.13", + "ark-serialize 0.5.0", + "ark-std 0.5.0", "colored", "demo-bls381", "demo-constructor", @@ -8650,6 +8611,7 @@ dependencies = [ "colored", "derive_more 2.1.1", "dirs", + "ferveo-gear-tdec", "gear-workspace-hack", "gprimitives", "hex", @@ -8721,7 +8683,7 @@ dependencies = [ name = "gtest" version = "2.0.0" dependencies = [ - "ark-std 0.4.0", + "ark-std 0.5.0", "builtins-common", "cargo_toml", "colored", @@ -8891,7 +8853,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "allocator-api2", "foldhash 0.2.0", ] @@ -12949,7 +12910,7 @@ dependencies = [ name = "pallet-gear-builtin" version = "2.0.0" dependencies = [ - "ark-std 0.4.0", + "ark-std 0.5.0", "builtins-common", "demo-proxy-broker", "demo-staking-broker", @@ -18208,7 +18169,7 @@ dependencies = [ "ark-ed-on-bls12-377-ext", "ark-ed-on-bls12-381-bandersnatch", "ark-ed-on-bls12-381-bandersnatch-ext", - "ark-scale", + "ark-scale 0.0.12", "sp-runtime-interface", ] @@ -18965,15 +18926,15 @@ dependencies = [ [[package]] name = "subproductdomain-gear" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e0585b62ebf2ec655a49e5f60033b0c240695e18ef3f28f4b81710ec15cbbd" +checksum = "cfba387009bc87f6ba69c9461d62da844f7c81904d319e7348a59b15fdd535dc" dependencies = [ "anyhow", - "ark-ec 0.6.0", - "ark-ff 0.6.0", - "ark-poly 0.6.0", - "ark-std 0.6.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-poly 0.5.0", + "ark-std 0.5.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ea2c9ce1aaa..26f40322486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -248,12 +248,12 @@ which = "4.4.2" winapi = "0.3.9" paste = "1.0" tempfile = "3.19" -ark-std = { version = "0.4.0", default-features = false } -ark-bls12-381 = { version = "0.4.0", default-features = false } -ark-serialize = { version = "0.4", default-features = false } -ark-ec = { version = "0.4.2", default-features = false } -ark-ff = { version = "0.4.2", default-features = false } -ark-scale = { version = "0.0.12", default-features = false } +ark-std = { version = "0.5", default-features = false } +ark-bls12-381 = { version = "0.5", default-features = false } +ark-serialize = { version = "0.5", default-features = false } +ark-ec = { version = "0.5", default-features = false } +ark-ff = { version = "0.5", default-features = false } +ark-scale = { version = "0.0.13", default-features = false } sha2 = { version = "0.10.8", default-features = false } sha3 = { version = "0.10.8", default-features = false } arrayvec = { version = "0.7.4", default-features = false } @@ -277,8 +277,7 @@ metrics = "0.24.0" metrics-derive = "0.1" metrics-exporter-prometheus = { version = "0.16.0", default-features = false } -gear-tdec = { package = "ferveo-gear-tdec", version = "0.5.0"} - +gear-tdec = { package = "ferveo-gear-tdec", version = "0.8.0", default-features = false} # Published deps # # https://github.com/gear-tech/gear-dlmalloc/tree/0.2.1 diff --git a/ethexe/cli/Cargo.toml b/ethexe/cli/Cargo.toml index f4d52119e20..84473733c10 100644 --- a/ethexe/cli/Cargo.toml +++ b/ethexe/cli/Cargo.toml @@ -36,6 +36,7 @@ ethexe-processor.workspace = true ethexe-runtime-common.workspace = true ethexe-db.workspace = true gprimitives = { workspace = true, features = ["std"] } +gear-tdec.workspace = true anyhow.workspace = true alloy-chains.workspace = true diff --git a/ethexe/cli/src/params/mod.rs b/ethexe/cli/src/params/mod.rs index 4cfd18715cb..f2371edb4a7 100644 --- a/ethexe/cli/src/params/mod.rs +++ b/ethexe/cli/src/params/mod.rs @@ -19,6 +19,7 @@ mod network; mod node; mod prometheus; mod rpc; +mod tdec; pub use ethereum::EthereumParams; pub use malachite::MalachiteParams; @@ -26,6 +27,7 @@ pub use network::NetworkParams; pub use node::NodeParams; pub use prometheus::PrometheusParams; pub use rpc::RpcParams; +pub use tdec::TdecParams; /// CLI/TOML-config parameters for the ethexe service. #[derive(Clone, Debug, Default, Deserialize, Parser)] @@ -57,6 +59,9 @@ pub struct Params { /// Prometheus (metrics) service parameters. #[clap(flatten)] pub prometheus: Option, + + #[clap(flatten)] + pub tdec: Option, } impl Params { @@ -81,6 +86,7 @@ impl Params { malachite, rpc, prometheus, + tdec, } = self; let node = node.context("missing node params")?; @@ -97,6 +103,7 @@ impl Params { let malachite = malachite.unwrap_or_default().into_config()?; let rpc = rpc.and_then(|p| p.into_config(&node)); let prometheus = prometheus.and_then(|p| p.into_config()); + let tdec = tdec.map(|p| p.into_config()); Ok(Config { node, ethereum, @@ -104,6 +111,7 @@ impl Params { malachite, rpc, prometheus, + tdec, }) } } @@ -117,6 +125,7 @@ impl MergeParams for Params { malachite: MergeParams::optional_merge(self.malachite, with.malachite), rpc: MergeParams::optional_merge(self.rpc, with.rpc), prometheus: MergeParams::optional_merge(self.prometheus, with.prometheus), + tdec: MergeParams::optional_merge(self.tdec, with.tdec), } } } diff --git a/ethexe/cli/src/params/tdec.rs b/ethexe/cli/src/params/tdec.rs new file mode 100644 index 00000000000..f2b5d970cb6 --- /dev/null +++ b/ethexe/cli/src/params/tdec.rs @@ -0,0 +1,179 @@ +// Copyright (C) Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +use crate::params::MergeParams; +use ethexe_service::config::ThresholdDecryptionCliConfig; +use gear_tdec::bls12_381::{ + DkgPublicKey, PublicDecryptionContextSimple as PublicDecryptionContext, +}; +use gsigner::Address; + +/// Threshold-decryption parameters. +#[derive(Clone, Debug, serde::Deserialize, clap::Parser)] +pub struct TdecParams { + /// Minimal number of validator decryption shares required to decrypt a + /// shielded transaction. + #[arg(long)] + pub threshold: std::num::NonZeroUsize, + + /// DKG public key used by clients to encrypt shielded transaction fields. + #[arg(long = "dkg-public-key", alias = "pubic-key")] + #[serde(rename = "dkg-public-key")] + pub dkg_public_key: DkgPublicKey, + + /// Public decryption contexts for validators participating in threshold + /// decryption. + /// + /// Pass one option per validator as `ADDRESS=CONTEXT`, where `ADDRESS` is + /// a secp256k1 validator address and `CONTEXT` is the hex string produced + /// by `PublicDecryptionContextSimple`. + #[arg(long = "validators-contexts", value_name = "ADDRESS=CONTEXT")] + #[serde(rename = "validators-contexts")] + pub validators_contexts: Option>, +} + +impl TdecParams { + pub fn into_config(self) -> ThresholdDecryptionCliConfig { + ThresholdDecryptionCliConfig { + threshold: self.threshold, + dkg_public_key: self.dkg_public_key, + validators_contexts: self + .validators_contexts + .map(|ctxs| ctxs.into_iter().map(ValidatorContext::into_parts).collect()), + } + } +} + +impl MergeParams for TdecParams { + fn merge(self, with: Self) -> Self { + let validators_contexts = match with.validators_contexts { + Some(mut contexts) => { + if let Some(my_contexts) = self.validators_contexts { + contexts.extend(my_contexts); + } + Some(contexts) + } + None => self.validators_contexts, + }; + Self { + threshold: self.threshold, + dkg_public_key: self.dkg_public_key, + validators_contexts, + } + } +} + +#[derive(Clone, Debug)] +pub struct ValidatorContext { + pub address: Address, + pub context: PublicDecryptionContext, +} + +impl ValidatorContext { + fn into_parts(self) -> (Address, PublicDecryptionContext) { + (self.address, self.context) + } +} + +impl From<(Address, PublicDecryptionContext)> for ValidatorContext { + fn from((address, context): (Address, PublicDecryptionContext)) -> Self { + Self { address, context } + } +} + +impl std::str::FromStr for ValidatorContext { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let (address, context) = value.split_once('=').ok_or_else(|| { + anyhow::anyhow!("expected validator context in ADDRESS=CONTEXT format") + })?; + + Ok(Self { + address: address.parse()?, + context: context.parse()?, + }) + } +} + +impl<'de> serde::Deserialize<'de> for ValidatorContext { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum ValidatorContextRepr { + Named { + address: Address, + context: PublicDecryptionContext, + }, + Tuple((Address, PublicDecryptionContext)), + } + + match ValidatorContextRepr::deserialize(deserializer)? { + ValidatorContextRepr::Named { address, context } => Ok(Self { address, context }), + ValidatorContextRepr::Tuple(tuple) => Ok(tuple.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + fn public_decryption_context( + dealer: &gear_tdec::DealerOutput, + ) -> PublicDecryptionContext { + dealer.private_contexts[0].public_decryption_contexts[0].clone() + } + + #[test] + fn validator_context_parses_from_cli_value() { + let dealer = gear_tdec::deal::( + 1, + 1, + &mut gear_tdec::rand_utils::test_rng(), + ); + let address = Address::from([1; 20]); + let context = public_decryption_context(&dealer); + + let parsed = format!("{address}={context}") + .parse::() + .expect("validator context must parse"); + + assert_eq!(parsed.address, address); + assert_eq!(parsed.context.to_string(), context.to_string()); + } + + #[test] + fn tdec_params_accepts_validator_contexts_from_clap() { + let dealer = gear_tdec::deal::( + 1, + 1, + &mut gear_tdec::rand_utils::test_rng(), + ); + let address = Address::from([1; 20]); + let context = public_decryption_context(&dealer); + let context_arg = format!("{address}={context}"); + + let params = TdecParams::try_parse_from([ + "ethexe", + "--threshold", + "1", + "--dkg-public-key", + &dealer.public_key.to_string(), + "--validators-contexts", + &context_arg, + ]) + .expect("tdec params must parse"); + + let contexts = params + .validators_contexts + .expect("validator contexts must be present"); + assert_eq!(contexts.len(), 1); + assert_eq!(contexts[0].address, address); + assert_eq!(contexts[0].context.to_string(), context.to_string()); + } +} diff --git a/ethexe/common/Cargo.toml b/ethexe/common/Cargo.toml index 7669d5eb640..e2a85ffe1fc 100644 --- a/ethexe/common/Cargo.toml +++ b/ethexe/common/Cargo.toml @@ -20,7 +20,6 @@ gprimitives.workspace = true parity-scale-codec.workspace = true scale-info = { workspace = true, features = ["derive"] } hex.workspace = true -serde = { workspace = true, optional = true } derive_more.workspace = true anyhow.workspace = true auto_impl.workspace = true @@ -28,11 +27,18 @@ gsigner = { workspace = true, default-features = false, features = [ "secp256k1", "codec", "serde", + "tdec", ] } sha3.workspace = true k256 = { version = "0.13.4", features = ["ecdsa"], default-features = false } nonempty.workspace = true -gear-tdec = { workspace = true, optional = true } + +ark-ec.workspace = true + +# optional dependencies +serde = { workspace = true, optional = true } +gear-tdec = { workspace = true, features = ["serde-hex", "parity-codec", "bls12_381"]} +ark-serialize = { workspace = true } # mock deps itertools = { workspace = true, optional = true } @@ -59,7 +65,5 @@ std = [ "alloy-primitives/std", "gsigner/std", "gsigner/keyring", - "shielded" ] -shielded = ["dep:gear-tdec"] mock = ["std", "itertools/use_std", "tap", "proptest"] diff --git a/ethexe/common/src/db.rs b/ethexe/common/src/db.rs index d87f9be1f17..6c20cb4d4d4 100644 --- a/ethexe/common/src/db.rs +++ b/ethexe/common/src/db.rs @@ -8,7 +8,10 @@ use crate::{ SimpleBlockData, ValidatorsVec, events::BlockEvent, gear::StateTransition, - injected::{InjectedTransaction, Promise, SignedInjectedTransaction, SignedTxReceipt}, + injected::{ + InjectedTransaction, Promise, ShieldedTransaction, SignedInjectedTransaction, + SignedShieldedTransaction, SignedTxReceipt, + }, malachite::Operations, }; use alloc::{ @@ -19,7 +22,9 @@ use gear_core::{ code::{CodeMetadata, InstrumentedCode}, ids::{ActorId, CodeId}, }; +use gear_tdec::bls12_381::DkgPublicKey; use gprimitives::H256; +use gsigner::VerifiedData; use parity_scale_codec::{Decode, Encode}; use scale_info::TypeInfo; @@ -115,6 +120,12 @@ pub trait InjectedStorageRO { hash: HashOf, ) -> Option; + /// Returns the shielded transaction by its hash. + fn shielded_transaction( + &self, + hash: HashOf, + ) -> Option; + /// Returns the promise by its transaction hash. fn promise(&self, hash: HashOf) -> Option; @@ -126,11 +137,23 @@ pub trait InjectedStorageRO { pub trait InjectedStorageRW: InjectedStorageRO { fn set_injected_transaction(&self, tx: SignedInjectedTransaction); + fn set_shielded_transaction(&self, tx: SignedShieldedTransaction); + fn set_promise(&self, promise: &Promise); fn set_receipt(&self, receipt: &SignedTxReceipt); } +#[auto_impl::auto_impl(&)] +pub trait TdecStorageRO { + fn shielding_key(&self) -> Option; +} + +#[auto_impl::auto_impl(&)] +pub trait TdecStorageRW: TdecStorageRO { + fn set_shielding_key(&self, key: DkgPublicKey); +} + /// MB static identity. Keyed by the Blake2b envelope hash; existence implies /// the matching `Operations` blob is in CAS at `operations_hash`. #[derive( @@ -164,6 +187,7 @@ pub trait MbStorageRO { fn mb_outcome(&self, mb_hash: H256) -> Option>; fn mb_schedule(&self, mb_hash: H256) -> Option; fn mb_meta(&self, mb_hash: H256) -> MbMeta; + fn mb_unshielded_txs(&self, mb_hash: H256) -> Vec>; } #[auto_impl::auto_impl(&)] @@ -175,6 +199,7 @@ pub trait MbStorageRW: MbStorageRO { fn set_mb_program_states(&self, mb_hash: H256, program_states: ProgramStates); fn set_mb_outcome(&self, mb_hash: H256, outcome: Vec); fn set_mb_schedule(&self, mb_hash: H256, schedule: Schedule); + fn set_mb_unshielded_txs(&self, mb_hash: H256, txs: Vec>); fn mutate_mb_meta(&self, mb_hash: H256, f: impl FnOnce(&mut MbMeta)); } @@ -259,7 +284,7 @@ pub use mock_interfaces::{SetConfig, SetGlobals}; #[cfg(test)] mod tests { use super::*; - use crate::malachite::Operations; + // use crate::malachite::Operations; use indoc::formatdoc; use scale_info::{PortableRegistry, Registry, meta_type}; use sha3::{Digest, Sha3_256}; @@ -267,7 +292,7 @@ mod tests { #[test] fn ensure_types_unchanged() { const EXPECTED_TYPE_INFO_HASH: &str = - "cbf21dc97ec57cc6f653a7808672dc2c086fdfae28d1435e93f0dfe812de21c3"; + "c543e8c3d27f17bd77d510ce3f1d2b3a286b6444559444eb78807b3c2fd9ffbf"; let types = [ meta_type::(), @@ -288,7 +313,7 @@ mod tests { // NOTE: `Operation` hand-rolls its `Encode`/`Decode` (fixed-width // u32 tag), so this TypeInfo hash does NOT cover its wire format — // the exact bytes are pinned by `malachite::tests::operation_encoding_is_frozen`. - meta_type::(), + // meta_type::(), meta_type::(), meta_type::(), ]; diff --git a/ethexe/common/src/hash.rs b/ethexe/common/src/hash.rs index 7ef4d7db52c..b903a635a66 100644 --- a/ethexe/common/src/hash.rs +++ b/ethexe/common/src/hash.rs @@ -1,6 +1,7 @@ // Copyright (C) Gear Technologies Inc. // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 +use crate::ToDigest; use alloc::string::{String, ToString}; use anyhow::Result; use core::{ @@ -13,6 +14,7 @@ use core::{ use gprimitives::H256; use parity_scale_codec::{Decode, Encode}; use scale_info::TypeInfo; +use sha3::Digest; fn option_string(value: &Option) -> String { value @@ -200,3 +202,40 @@ impl From> for MaybeHashOf { Self(Some(value)) } } + +#[derive(Debug, PartialEq, Eq, Hash, Encode, Decode, derive_more::Display)] +#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] +pub enum EitherHashOf { + #[display("Left({_0})")] + Left(HashOf), + #[display("Right({_0})")] + Right(HashOf), +} + +impl Clone for EitherHashOf { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for EitherHashOf {} + +impl EitherHashOf { + pub fn inner(&self) -> H256 { + match self { + Self::Left(left_hash) => left_hash.inner(), + Self::Right(right_hash) => right_hash.inner(), + } + } +} + +impl ToDigest for EitherHashOf { + fn update_hasher(&self, hasher: &mut sha3::Keccak256) { + let prefix = match self { + Self::Left(_) => 0u8, + Self::Right(_) => 1u8, + }; + hasher.update([prefix]); + hasher.update(self.inner().as_ref()); + } +} diff --git a/ethexe/common/src/injected.rs b/ethexe/common/src/injected.rs index 1ef91c893c9..6162e66140d 100644 --- a/ethexe/common/src/injected.rs +++ b/ethexe/common/src/injected.rs @@ -1,11 +1,14 @@ // Copyright (C) Gear Technologies Inc. // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 -use crate::{Address, HashOf, ToDigest, ecdsa::SignedMessage}; -use alloc::string::{String, ToString}; +use crate::{Address, EitherHashOf, HashOf, ToDigest, ecdsa::SignedMessage}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +use ark_serialize::CanonicalSerialize; use core::hash::Hash; use gear_core::{limited::LimitedVec, rpc::ReplyInfo}; -#[cfg(feature = "shielded")] use gear_tdec::{ Result as TdecResult, bls12_381::{Ciphertext, DkgPublicKey, SharedSecret}, @@ -36,14 +39,15 @@ pub const MAX_INJECTED_TX_SALT_SIZE: usize = 32; /// always admissible. pub const MAX_INJECTED_TRANSACTIONS_SIZE_PER_MB: usize = 127 * 1024; +// TODO: rename this type to just `TransactionAcceptance` #[cfg_attr(feature = "std", derive(serde::Deserialize, serde::Serialize))] #[derive(Debug, Clone, Encode, Decode, Eq, PartialEq)] -pub enum InjectedTransactionAcceptance { +pub enum TransactionAcceptance { Accept, Reject { reason: String }, } -impl From> for InjectedTransactionAcceptance { +impl From> for TransactionAcceptance { fn from(value: Result<(), E>) -> Self { match value { Ok(()) => Self::Accept, @@ -116,7 +120,7 @@ impl InjectedTransaction { } /// Returns the hash of [`InjectedTransaction`]. - pub fn to_hash(&self) -> HashOf { + pub fn to_hash(&self) -> HashOf { let hashable_bytes = self.to_hashable_bytes(); unsafe { HashOf::new(gear_core::utils::hash(hashable_bytes.as_ref()).into()) } } @@ -126,7 +130,6 @@ impl InjectedTransaction { MessageId::new(self.to_hash().inner().0) } - #[cfg(feature = "shielded")] pub fn shield( self, public_key: &DkgPublicKey, @@ -256,9 +259,9 @@ pub enum Receipt

{ } impl Receipt

{ - pub fn tx_hash(&self) -> HashOf { + pub fn tx_hash(&self) -> TransactionHash { match self { - Self::Promise(promise) => promise.tx_hash(), + Self::Promise(promise) => TransactionHash::Left(promise.tx_hash()), Self::Purged(purged) => purged.tx_hash, } } @@ -284,7 +287,7 @@ impl ToDigest for Receipt

{ #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::From, derive_more::Deref)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "std", serde(transparent))] -pub struct SignedTxReceipt(SignedMessage>); +pub struct SignedTxReceipt(pub SignedMessage>); /// Signed [Receipt] with a [CompactPromise] generic. /// It is used as a lightweight transfer type @@ -347,19 +350,21 @@ impl UnfilledPromiseReceipt { } } -/// Represents the reason why [InjectedTransaction] was not included. +/// Represents the reason why transaction was not included. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] #[cfg_attr(feature = "std", derive(serde::Deserialize, serde::Serialize))] #[display("Injected transaction wasn't executed: tx_hash={tx_hash}, reason={reason}")] pub struct PurgedTransaction { - pub tx_hash: HashOf, + /// Has of [InjectedTransaction] or [ShieldedTransaction]. + pub tx_hash: TransactionHash, + /// Reason why transaction was purged from mempool. pub reason: TransactionPurgedReason, } impl ToDigest for PurgedTransaction { fn update_hasher(&self, hasher: &mut sha3::Keccak256) { let Self { tx_hash, reason } = self; - hasher.update(tx_hash.inner().0); + tx_hash.update_hasher(hasher); hasher.update([reason.variant_index()]); } } @@ -375,6 +380,9 @@ pub enum TransactionPurgedReason { /// The transaction references a block that is not known locally. #[display("transaction reference block is unknown")] UnknownReferenceBlock = 2, + /// The shielded transaction could not be decrypted. + #[display("failed to decrypt shielded transaction")] + DecryptionFailed = 3, /// The transaction has a non-zero value, which is not supported yet. /// @@ -391,16 +399,14 @@ impl TransactionPurgedReason { } } -#[cfg(feature = "shielded")] #[cfg_attr(feature = "serde", derive(Hash))] #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TypeInfo)] pub struct ShieldedFields { - pub(crate) destination: ActorId, - pub(crate) value: u128, - pub(crate) payload: LimitedVec, + pub destination: ActorId, + pub value: u128, + pub payload: LimitedVec, } -#[cfg(feature = "shielded")] impl ToDigest for ShieldedFields { fn update_hasher(&self, hasher: &mut sha3::Keccak256) { let Self { @@ -414,7 +420,6 @@ impl ToDigest for ShieldedFields { } } -#[cfg(feature = "shielded")] #[cfg_attr(feature = "std", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(Hash))] #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -433,7 +438,48 @@ pub struct ShieldedTransaction { pub salt: LimitedVec, } -#[cfg(feature = "shielded")] +impl ShieldedTransaction { + fn append_compressed_point(buffer: &mut Vec, point: &P) { + point + .serialize_compressed(buffer) + .expect("serializing to Vec should not fail"); + } + + pub(crate) fn to_hashable_bytes(&self) -> Vec { + let mut buffer = Vec::with_capacity( + self.ciphertext.commitment.compressed_size() + + self.ciphertext.auth_tag.compressed_size() + + size_of::() + + size_of::() + + size_of::() + + size_of::(), + ); + + Self::append_compressed_point(&mut buffer, &self.ciphertext.commitment); + Self::append_compressed_point(&mut buffer, &self.ciphertext.auth_tag); + buffer.extend_from_slice(gear_core::utils::hash(&self.ciphertext.ciphertext).as_ref()); + buffer.extend_from_slice(self.aad.as_ref()); + buffer.extend_from_slice(self.reference_block.0.as_ref()); + buffer.extend_from_slice(gear_core::utils::hash(&self.salt).as_ref()); + + buffer + } + + /// Constructs blake2b hash over [ShieldedTransaction]. + pub fn to_hash(&self) -> HashOf { + let hashable_bytes = self.to_hashable_bytes(); + unsafe { HashOf::new(gear_core::utils::hash(hashable_bytes.as_ref()).into()) } + } +} + +pub type SignedShieldedTransaction = SignedMessage; + +impl ToDigest for ShieldedTransaction { + fn update_hasher(&self, hasher: &mut sha3::Keccak256) { + hasher.update(self.to_hashable_bytes()); + } +} + impl ShieldedTransaction { /// Decrypts [Ciphertext] with provided [SharedSecret]. /// Returns initial [InjectedTransaction]. @@ -455,6 +501,62 @@ impl ShieldedTransaction { } } +#[cfg_attr(feature = "std", derive(serde::Deserialize, serde::Serialize))] +#[derive(Debug, Clone, Encode, Decode, Eq, PartialEq, derive_more::From)] +#[allow(clippy::large_enum_variant)] +pub enum Transaction { + Injected(SignedInjectedTransaction), + Shielded(SignedShieldedTransaction), +} + +/// Type alias over [EitherHashOf]. +pub type TransactionHash = EitherHashOf; + +impl Transaction { + pub fn as_ref(&self) -> TransactionRef<'_> { + match self { + Self::Injected(tx) => TransactionRef::Injected(tx), + Self::Shielded(tx) => TransactionRef::Shielded(tx), + } + } + + pub fn as_injected(&self) -> Option<&SignedInjectedTransaction> { + match self { + Self::Injected(tx) => Some(tx), + Self::Shielded(_) => None, + } + } +} + +/// Mirroring [Transaction] type, but stores internally references to +/// transactions variants. +/// +/// # Usage +/// This type must be used to transform [Operation] type into [Option]. +/// +/// [Operation]: crate::malachite::Operation +#[derive(Clone, Copy)] +pub enum TransactionRef<'op> { + Injected(&'op SignedInjectedTransaction), + Shielded(&'op SignedShieldedTransaction), +} + +impl<'t> TransactionRef<'t> { + pub fn hash(&self) -> TransactionHash { + match self { + Self::Injected(tx) => TransactionHash::Left(tx.data().to_hash()), + Self::Shielded(tx) => TransactionHash::Right(tx.data().to_hash()), + } + } + + pub fn reference_block(&self) -> H256 { + match self { + Self::Injected(tx) => tx.data().reference_block, + Self::Shielded(tx) => tx.data().reference_block, + } + } +} + /// Encoding and decoding of [LimitedVec] as hex string. #[cfg(feature = "std")] mod limited_vec_hex { @@ -501,11 +603,66 @@ mod digest_hex { #[cfg(all(test, feature = "mock"))] mod tests { + use std::ops::Mul; + + use ark_ec::{AffineRepr, pairing::Pairing}; + use gear_tdec::bls12_381::{E as Bls12_381, Fr}; use gsigner::PrivateKey; use super::*; use crate::mock::Mock; + /// You can use this JavaScript code to reproduce serialize/deserialize paths. + /// ```no_run,ignore + /// import { bls12_381 } from '@noble/curves/bls12-381.js'; + /// const { G1, G2 } = bls12_381; + /// + /// function bytesToHex(bytes) { + /// return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + /// } + /// function dumpPoint(name, point) { + /// const compressed = point.toBytes(true); + /// console.log(`\n${name}`); + /// console.log(`compressed hex: 0x${bytesToHex(compressed)}`); + /// } + /// + /// dumpPoint('G1 * 123', G1.Point.BASE.multiply(123n)); + /// dumpPoint('G2 * 123', G2.Point.BASE.multiply(123n)); + /// ``` + #[test] + fn ark_noble_js_compatible_serialization() { + const NOBLE_JS_G1_123_COMPRESSED_SERIALIZED: &str = r#""0xa0ec3e71a719a25208adc97106b122809210faf45a17db24f10ffb1ac014fac1ab95a4a1967e55b185d4df622685b9e8""#; + const NOBLE_JS_G2_123_COMPRESSED_SERIALIZED: &str = r#""0x95e18bbdb8b7bd39ea677ee923d7e87af449c45209e635907a4a8a2e4c65fff97c46d038cff53a994da273310ac85866096a5e13fd3ebf4e140e26f6ddfac66651e04e530e6045572acab753bb1bcef990fe14b4426caee41016af69d313750d""#; + + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(transparent)] + struct G1Wrapper { + #[serde(with = "gear_tdec::serialization::ark_serde_hex")] + pub point: ::G1, + } + + let g1_123 = ::G1Affine::generator().mul(Fr::from(123)); + let wrapped_g1 = G1Wrapper { point: g1_123 }; + assert_eq!( + serde_json::to_string(&wrapped_g1).unwrap(), + NOBLE_JS_G1_123_COMPRESSED_SERIALIZED + ); + + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(transparent)] + struct G2Wrapper { + #[serde(with = "gear_tdec::serialization::ark_serde_hex")] + pub point: ::G2, + } + + let g2_123 = ::G2Affine::generator().mul(Fr::from(123)); + let wrapped_g2 = G2Wrapper { point: g2_123 }; + assert_eq!( + serde_json::to_string(&wrapped_g2).unwrap(), + NOBLE_JS_G2_123_COMPRESSED_SERIALIZED + ); + } + #[test] fn signed_message_and_injected_transactions() { const RPC_INPUT: &str = r#"{ @@ -646,7 +803,7 @@ mod tests { #[test] fn tx_receipt_has_the_same_hash_for_error() { let purged = PurgedTransaction { - tx_hash: unsafe { HashOf::new(H256::random()) }, + tx_hash: unsafe { TransactionHash::Left(HashOf::new(H256::random())) }, reason: TransactionPurgedReason::Outdated, }; let receipt1 = Receipt::::Purged(purged.clone()); @@ -669,4 +826,33 @@ mod tests { let deserialized: ShieldedTransaction = serde_json::from_str(&serialized).unwrap(); assert_eq!(shielded_tx, deserialized); } + + #[test] + fn signed_message_and_shielded_transactions() { + let injected_tx = InjectedTransaction::mock(()); + let mut rng = gear_tdec::rand_utils::test_rng(); + let dealer_out = gear_tdec::deal::(3, 2, &mut rng); + let shielded_tx = injected_tx + .shield(&dealer_out.public_key, &mut rng) + .unwrap(); + + let signed_tx = + SignedShieldedTransaction::create(PrivateKey::random(), shielded_tx).unwrap(); + + assert_eq!( + signed_tx + .signature() + .recover_message(signed_tx.data()) + .expect("failed to recover message") + .to_address(), + signed_tx.address() + ); + } + + #[test] + fn mock_display() { + let hash = InjectedTransaction::mock(()).to_hash(); + let h = TransactionHash::Left(hash); + println!("{h}"); + } } diff --git a/ethexe/common/src/malachite.rs b/ethexe/common/src/malachite.rs index c44b599f3c9..8dab6c459d9 100644 --- a/ethexe/common/src/malachite.rs +++ b/ethexe/common/src/malachite.rs @@ -27,41 +27,62 @@ //! `ethexe-malachite`) so `ethexe-processor` can accept them without //! depending on the consensus layer. -use crate::injected::SignedInjectedTransaction; -use alloc::vec::Vec; +use crate::{ + Address, HashOf, ToDigest, + injected::{ShieldedTransaction, SignedInjectedTransaction, SignedShieldedTransaction}, +}; +use alloc::{collections::BTreeMap, vec::Vec}; use derive_more::{Deref, DerefMut, IntoIterator}; +use gear_tdec::bls12_381::SharedSecret; use gprimitives::H256; +use gsigner::{DecryptionShare, SignedMessage}; use parity_scale_codec::{Decode, Encode}; -use scale_info::TypeInfo; +use sha3::{Digest as _, Keccak256}; +#[cfg(feature = "std")] +use {gsigner::PublicDecryptionContext, std::collections::HashMap}; #[cfg(feature = "std")] use serde::{Deserialize, Serialize}; /// A single operation in the malachite block. -#[derive(Clone, Debug, PartialEq, Eq, TypeInfo, derive_more::IsVariant)] +#[derive(Clone, Debug, PartialEq, Eq, derive_more::IsVariant)] #[cfg_attr(feature = "std", derive(Serialize, Deserialize))] #[repr(u32)] +#[allow(clippy::large_enum_variant)] pub enum Operation { /// Pin executor's view to a quarantine-passed Ethereum block. - AdvanceTillEthereumBlock { block_hash: H256 } = 0, + AdvanceTillEthereumBlock { + block_hash: H256, + } = 0, /// Progress scheduled tasks (mailbox/waitlist/reservation cleanup). ProgressTasks = 1, /// Execute queued message within `gas_allowance`. - ProcessQueues { gas_allowance: u64 } = 2, + ProcessQueues { + gas_allowance: u64, + } = 2, /// User-submitted transaction from the mempool. Injected(SignedInjectedTransaction) = 3, /// Execute queued messages within `gas_allowance`. /// V2 - changes mailbox validity, from one week to 15 minutes - ProcessQueuesV2 { gas_allowance: u64 } = 4, + ProcessQueuesV2 { + gas_allowance: u64, + } = 4, /// Execute queued messages within `gas_allowance`. /// V3 - auto-replies to Sails event destinations without mailboxing and /// emits Ethereum event destinations via transition messages. - ProcessQueuesV3 { gas_allowance: u64 } = 5, + ProcessQueuesV3 { + gas_allowance: u64, + } = 5, + + /// User-submitted shielded transaction from mempool. + Shielded(SignedShieldedTransaction) = 6, + + DecryptionKeys(BTreeMap, SharedSecret>) = 7, } impl Operation { @@ -75,13 +96,21 @@ impl Operation { // Mirrors the `#[repr(u32)]` discriminants below and the `Decode` // arms. These three must agree; `operation_encoding_is_frozen` pins // the bytes so a divergence can't slip through. + unsafe { (self as *const Operation).cast::().read() } + } + + /// Returns `Some` if `Self` contains shielded transaction. + pub fn as_shielded(&self) -> Option<&SignedShieldedTransaction> { match self { - Self::AdvanceTillEthereumBlock { .. } => 0, - Self::ProgressTasks => 1, - Self::ProcessQueues { .. } => 2, - Self::Injected(_) => 3, - Self::ProcessQueuesV2 { .. } => 4, - Self::ProcessQueuesV3 { .. } => 5, + Self::Shielded(tx) => Some(tx), + _ => None, + } + } + + pub fn into_shielded(self) -> Option { + match self { + Self::Shielded(tx) => Some(tx), + _ => None, } } } @@ -112,6 +141,12 @@ impl Decode for Operation { 5 => Ok(Operation::ProcessQueuesV3 { gas_allowance: u64::decode(input)?, }), + 6 => Ok(Operation::Shielded(SignedShieldedTransaction::decode( + input, + )?)), + 7 => Ok(Operation::DecryptionKeys( + as Decode>::decode(input)?, + )), _ => Err(parity_scale_codec::Error::from("invalid operation tag")), } } @@ -127,14 +162,14 @@ impl Encode for Operation { Operation::Injected(signed_tx) => signed_tx.encode_to(dest), Operation::ProcessQueuesV2 { gas_allowance } => gas_allowance.encode_to(dest), Operation::ProcessQueuesV3 { gas_allowance } => gas_allowance.encode_to(dest), + Operation::Shielded(shielded_tx) => shielded_tx.encode_to(dest), + Operation::DecryptionKeys(keys) => keys.encode_to(dest), } } } /// Ordered list of [`Operation`]s; CAS key = Blake2b-256 of the SCALE-encoded list. -#[derive( - Clone, Debug, Default, PartialEq, Eq, Encode, Decode, TypeInfo, Deref, DerefMut, IntoIterator, -)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Encode, Decode, Deref, DerefMut, IntoIterator)] #[cfg_attr(feature = "std", derive(Serialize, Deserialize))] pub struct Operations(pub Vec); @@ -149,6 +184,52 @@ impl Operations { } } +/// Validator's context for shielded transactions decryption. +#[cfg(feature = "std")] +#[derive(Debug, Clone)] +pub struct MalachiteTdecContext { + /// Minimal number of decryption shares required to decrypt transaction. + pub threshold: core::num::NonZeroUsize, + /// Current validator's public decryption context. + /// Private data stored in [TdecKeyStore]. + /// + /// [TdecKeyStore]: gsigner::tdec::TdecKeyStore + pub my_context: PublicDecryptionContext, + /// Public decryption context of every validator involved in decryption, + /// including the current validator. + pub contexts: HashMap, +} + +/// One validator's decryption-share payload for one shielded transaction. +/// Holds [`DecryptionShare`] over [`ShieldedTransaction`]. +/// +/// [ShieldedTransaction]: crate::injected::ShieldedTransaction +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +pub struct ShieldedTxDecryptionShare { + /// Transaction hash decryption share belongs to. + pub tx_hash: HashOf, + pub share: DecryptionShare, +} + +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +pub struct BlockDecryptionData { + /// Malachite block hash the decryption shares belong to. + pub mb_hash: H256, + /// Decryption shares for [`ShieldedTransaction`]s in the Malachite block. + pub shares: Vec, +} + +impl ToDigest for BlockDecryptionData { + fn update_hasher(&self, hasher: &mut Keccak256) { + hasher.update(self.encode()); + } +} + +/// Validator-signed decryption shares for one Malachite block. +pub type SignedBlockDecryptionShares = SignedMessage; + #[cfg(test)] mod tests { use super::*; diff --git a/ethexe/compute/src/compute.rs b/ethexe/compute/src/compute.rs index e7eb682843a..52ee714a895 100644 --- a/ethexe/compute/src/compute.rs +++ b/ethexe/compute/src/compute.rs @@ -227,7 +227,7 @@ pub fn prepare_executable_for_mb( .. } = compact_mb; - let mb_payload = db + let operations = db .operations(operations_hash) .ok_or(ComputeError::MbPayloadNotFound { mb_hash, @@ -246,7 +246,14 @@ pub fn prepare_executable_for_mb( .ok_or(ComputeError::ParentMbScheduleMissing(parent))?; let advanced_block = db.mb_meta(parent).last_advanced_eb; - build_executable_data(db, mb_payload, program_states, schedule, advanced_block) + build_executable_data( + db, + mb_hash, + operations, + program_states, + schedule, + advanced_block, + ) } /// Walk the MB's `Operations` list and prepare processor input. @@ -256,13 +263,15 @@ pub fn prepare_executable_for_mb( /// genesis block from [`ConfigStorageRO::config`]. fn build_executable_data( db: &Database, + mb_hash: H256, operations: Operations, program_states: ethexe_common::ProgramStates, schedule: ethexe_common::Schedule, advanced_block: H256, ) -> Result { let mut events: Vec = Vec::new(); - let mut injected_transactions = Vec::new(); + // Initialize injected transactions with already unshielded txs. + let mut injected_transactions = db.mb_unshielded_txs(mb_hash); let mut gas_allowance: Option = None; let mut current_anchor = if advanced_block.is_zero() { @@ -293,10 +302,16 @@ fn build_executable_data( } current_anchor = Some(block); } + Operation::DecryptionKeys(_) => { + // ignored + } Operation::Injected(signed) => { let verified = signed.into_verified(); injected_transactions.push(verified); } + Operation::Shielded(shielded) => { + let _verified = shielded.into_verified(); + } Operation::ProgressTasks => {} Operation::ProcessQueues { gas_allowance: op_gas_allowance, diff --git a/ethexe/db/Cargo.toml b/ethexe/db/Cargo.toml index 2d8d7e353a8..74c34851e57 100644 --- a/ethexe/db/Cargo.toml +++ b/ethexe/db/Cargo.toml @@ -18,6 +18,7 @@ ethexe-ethereum.workspace = true ethexe-runtime-common = { workspace = true, features = ["std"] } gear-core = { workspace = true, features = ["std"] } gprimitives = { workspace = true, features = ["std"] } +gear-tdec.workspace = true gsigner.workspace = true alloy.workspace = true diff --git a/ethexe/db/src/database.rs b/ethexe/db/src/database.rs index 19d3103ed2f..38a43b67053 100644 --- a/ethexe/db/src/database.rs +++ b/ethexe/db/src/database.rs @@ -10,16 +10,19 @@ use crate::{ use anyhow::{Context, Result}; use delegate::delegate; use ethexe_common::{ - BlockHeader, CodeBlobInfo, HashOf, ProgramStates, Schedule, ValidatorsVec, + BlockHeader, CodeBlobInfo, HashOf, ProgramStates, Schedule, ValidatorsVec, VerifiedData, db::{ BlockMeta, BlockMetaStorageRO, BlockMetaStorageRW, CodesStorageRO, CodesStorageRW, CompactMb, ConfigStorageRO, DBConfig, DBGlobals, GlobalsStorageRO, GlobalsStorageRW, HashStorageRO, InjectedStorageRO, InjectedStorageRW, MbMeta, MbStorageRO, MbStorageRW, - OnChainStorageRO, OnChainStorageRW, + OnChainStorageRO, OnChainStorageRW, TdecStorageRO, TdecStorageRW, }, events::BlockEvent, gear::StateTransition, - injected::{InjectedTransaction, Promise, SignedInjectedTransaction, SignedTxReceipt}, + injected::{ + InjectedTransaction, Promise, ShieldedTransaction, SignedInjectedTransaction, + SignedShieldedTransaction, SignedTxReceipt, TransactionHash, + }, malachite::Operations, }; use ethexe_runtime_common::state::{ @@ -32,6 +35,7 @@ use gear_core::{ ids::{ActorId, CodeId, prelude::CodeIdExt as _}, memory::PageBuf, }; +use gear_tdec::bls12_381::DkgPublicKey; use gprimitives::H256; use parity_scale_codec::{Decode, Encode}; use scale_info::TypeInfo; @@ -41,6 +45,7 @@ use std::{ sync::{Arc, RwLock, RwLockReadGuard}, }; +#[allow(clippy::enum_variant_names)] #[repr(u64)] enum Key { BlockSmallData(H256) = 0, @@ -67,6 +72,11 @@ enum Key { Promise(HashOf) = 26, TxReceipt(HashOf) = 27, + ShieldedTransaction(HashOf) = 28, + + MbUnshieldedTxs(H256) = 29, + + ShieldingKey = 30, } impl Key { @@ -94,11 +104,13 @@ impl Key { | Self::MbOutcome(hash) | Self::MbSchedule(hash) | Self::MbMeta(hash) - | Self::MbCompactBlock(hash) => bytes.extend(hash.as_ref()), + | Self::MbCompactBlock(hash) + | Self::MbUnshieldedTxs(hash) => bytes.extend(hash.as_ref()), Self::InjectedTransaction(hash) | Self::Promise(hash) | Self::TxReceipt(hash) => { bytes.extend(hash.as_ref()) } + Self::ShieldedTransaction(hash) => bytes.extend(hash.as_ref()), Self::ProgramToCodeId(program_id) => bytes.extend(program_id.as_ref()), @@ -110,7 +122,7 @@ impl Key { bytes.extend(runtime_id.to_le_bytes()); bytes.extend(code_id.as_ref()); } - Self::Globals | Self::Config => { + Self::Globals | Self::Config | Self::ShieldingKey => { // append additional zero bytes to avoid intersection with CAS bytes.extend([0; 8]) } @@ -405,6 +417,16 @@ impl MbStorageRO for RawDatabase { }) } + fn mb_unshielded_txs(&self, mb_hash: H256) -> Vec> { + self.kv + .get(&Key::MbUnshieldedTxs(mb_hash).to_bytes()) + .map(|data| { + Vec::<_>::decode(&mut data.as_slice()) + .expect("Failed to decode data into `Vec>`") + }) + .unwrap_or_default() + } + fn mb_meta(&self, mb_hash: H256) -> MbMeta { self.kv .get(&Key::MbMeta(mb_hash).to_bytes()) @@ -446,6 +468,12 @@ impl MbStorageRW for RawDatabase { .put(&Key::MbSchedule(mb_hash).to_bytes(), schedule.encode()); } + fn set_mb_unshielded_txs(&self, mb_hash: H256, txs: Vec>) { + tracing::trace!(mb_hash = %mb_hash, "Set MB unshielded transactions"); + self.kv + .put(&Key::MbUnshieldedTxs(mb_hash).to_bytes(), txs.encode()); + } + fn mutate_mb_meta(&self, mb_hash: H256, f: impl FnOnce(&mut MbMeta)) { tracing::trace!(mb_hash = %mb_hash, "Mutate MB meta"); let mut meta = self.mb_meta(mb_hash); @@ -671,6 +699,18 @@ impl InjectedStorageRO for RawDatabase { }) } + fn shielded_transaction( + &self, + hash: HashOf, + ) -> Option { + self.kv + .get(&Key::ShieldedTransaction(hash).to_bytes()) + .map(|data| { + SignedShieldedTransaction::decode(&mut data.as_slice()) + .expect("Failed to decode data into `SignedShieldedTransaction`") + }) + } + fn promise(&self, tx_hash: HashOf) -> Option { self.kv.get(&Key::Promise(tx_hash).to_bytes()).map(|data| { Promise::decode(&mut data.as_slice()).expect("Failed to decode data into Promise") @@ -696,6 +736,14 @@ impl InjectedStorageRW for RawDatabase { .put(&Key::InjectedTransaction(tx_hash).to_bytes(), tx.encode()); } + fn set_shielded_transaction(&self, tx: SignedShieldedTransaction) { + let tx_hash = tx.data().to_hash(); + + tracing::trace!(shielded_tx_hash = ?tx_hash, "Set shielded transaction"); + self.kv + .put(&Key::ShieldedTransaction(tx_hash).to_bytes(), tx.encode()); + } + fn set_promise(&self, promise: &Promise) { tracing::trace!(?promise, "Set promise for injected transaction"); @@ -704,7 +752,9 @@ impl InjectedStorageRW for RawDatabase { } fn set_receipt(&self, receipt: &SignedTxReceipt) { - let tx_hash = receipt.data().tx_hash(); + let TransactionHash::Left(tx_hash) = receipt.data().tx_hash() else { + panic!("only injected transaction receipts can be stored"); + }; tracing::trace!(?receipt, "Set receipt for injected transaction"); self.kv @@ -712,6 +762,25 @@ impl InjectedStorageRW for RawDatabase { } } +impl TdecStorageRO for RawDatabase { + fn shielding_key(&self) -> Option { + self.kv.get(&Key::ShieldingKey.to_bytes()).map(|data| { + String::from_utf8(data) + .expect("Failed to decode shielding key as UTF-8") + .parse() + .expect("Failed to parse DkgPublicKey") + }) + } +} + +impl TdecStorageRW for RawDatabase { + fn set_shielding_key(&self, key: DkgPublicKey) { + tracing::trace!("Set shielding key"); + self.kv + .put(&Key::ShieldingKey.to_bytes(), key.to_string().into_bytes()); + } +} + #[derive(derive_more::Debug, Clone)] #[debug("Database(CAS + KV)")] pub struct Database { @@ -961,6 +1030,7 @@ impl OnChainStorageRW for Database { impl InjectedStorageRO for Database { delegate!(to self.raw { fn injected_transaction(&self, hash: HashOf) -> Option; + fn shielded_transaction(&self, hash: HashOf) -> Option; fn promise(&self, hash: HashOf) -> Option; fn receipt(&self, hash: HashOf) -> Option; }); @@ -973,6 +1043,7 @@ impl MbStorageRO for Database { fn mb_program_states(&self, mb_hash: H256) -> Option; fn mb_outcome(&self, mb_hash: H256) -> Option>; fn mb_schedule(&self, mb_hash: H256) -> Option; + fn mb_unshielded_txs(&self, mb_hash: H256) -> Vec>; fn mb_meta(&self, mb_hash: H256) -> MbMeta; }); } @@ -984,6 +1055,7 @@ impl MbStorageRW for Database { fn set_mb_program_states(&self, mb_hash: H256, program_states: ProgramStates); fn set_mb_outcome(&self, mb_hash: H256, outcome: Vec); fn set_mb_schedule(&self, mb_hash: H256, schedule: Schedule); + fn set_mb_unshielded_txs(&self, mb_hash: H256, txs: Vec>); fn mutate_mb_meta(&self, mb_hash: H256, f: impl FnOnce(&mut MbMeta)); }); } @@ -991,11 +1063,24 @@ impl MbStorageRW for Database { impl InjectedStorageRW for Database { delegate!(to self.raw { fn set_injected_transaction(&self, tx: SignedInjectedTransaction); + fn set_shielded_transaction(&self, tx: SignedShieldedTransaction); fn set_promise(&self, promise: &Promise); fn set_receipt(&self, receipt: &SignedTxReceipt); }); } +impl TdecStorageRO for Database { + delegate!(to self.raw { + fn shielding_key(&self) -> Option; + }); +} + +impl TdecStorageRW for Database { + delegate!(to self.raw { + fn set_shielding_key(&self, key: DkgPublicKey); + }); +} + impl CodesStorageRO for Database { delegate!(to self.raw { fn original_code_exists(&self, code_id: CodeId) -> bool; @@ -1079,33 +1164,54 @@ mod tests { use ethexe_common::{ ecdsa::PrivateKey, events::{RouterEvent, router::StorageSlotChangedEvent}, + mock::Mock, }; - use gear_core::{ - code::{InstantiatedSectionSizes, InstrumentationStatus}, - limited::LimitedVec, - }; + use gear_core::code::{InstantiatedSectionSizes, InstrumentationStatus}; + use gsigner::SignedMessage; #[test] fn test_injected_transaction() { let db = Database::memory(); let private_key = PrivateKey::from_seed([1; 32]).expect("valid seed"); - let tx = SignedInjectedTransaction::create( - private_key, - InjectedTransaction { - destination: ActorId::zero(), - payload: LimitedVec::new(), - value: 0, - reference_block: H256::random(), - salt: LimitedVec::new(), - }, - ) - .unwrap(); + let tx = SignedMessage::create(private_key, InjectedTransaction::mock(())).unwrap(); let tx_hash = tx.data().to_hash(); db.set_injected_transaction(tx.clone()); assert_eq!(db.injected_transaction(tx_hash), Some(tx)); } + #[test] + fn test_shielded_transaction() { + let db = Database::memory(); + + let mut rng = gear_tdec::rand_utils::test_rng(); + let dealer_out = gear_tdec::deal::(3, 2, &mut rng); + + let shielded_tx = InjectedTransaction::mock(()) + .shield(&dealer_out.public_key, &mut rng) + .unwrap(); + let tx = SignedMessage::create(PrivateKey::random(), shielded_tx).unwrap(); + let tx_hash = tx.data().to_hash(); + + db.set_shielded_transaction(tx.clone()); + + assert_eq!(db.shielded_transaction(tx_hash), Some(tx)); + } + + #[test] + fn test_shielding_key() { + let db = Database::memory(); + + let mut rng = gear_tdec::rand_utils::test_rng(); + let dealer_out = gear_tdec::deal::(3, 2, &mut rng); + + assert_eq!(db.shielding_key(), None); + + db.set_shielding_key(dealer_out.public_key); + + assert_eq!(db.shielding_key(), Some(dealer_out.public_key)); + } + #[test] fn test_block_events() { let db = Database::memory(); diff --git a/ethexe/malachite/core/Cargo.toml b/ethexe/malachite/core/Cargo.toml index 493ae55c9f1..240dedf6eba 100644 --- a/ethexe/malachite/core/Cargo.toml +++ b/ethexe/malachite/core/Cargo.toml @@ -39,7 +39,7 @@ tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync tracing.workspace = true libp2p-identity.workspace = true rocksdb.workspace = true -derive_more.workspace = true +derive_more = { workspace = true, features = ["is_variant"] } [dev-dependencies] advisory-lock.workspace = true diff --git a/ethexe/malachite/core/src/app.rs b/ethexe/malachite/core/src/app.rs index 4e7d06bc656..f4b7c96a695 100644 --- a/ethexe/malachite/core/src/app.rs +++ b/ethexe/malachite/core/src/app.rs @@ -35,9 +35,9 @@ use crate::{ state::State, store::BlockEntry, streaming::ProposalParts, - types::{Address, Block, CommitCertificate, H256}, + types::{Address, Block, CommitCertificate, EthexeVoteExtension, H256}, }; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, ensure}; use bytes::Bytes; use ethexe_common::Acceptance; use malachitebft_app_channel::{ @@ -194,15 +194,40 @@ where } } - // Vote extensions (unused — return defaults). - AppMsg::ExtendVote { reply, .. } => { + // Vote extensions. + AppMsg::ExtendVote { + height, + round, + value_id, + reply, + } => { + let extension = self + .process_extend_vote(height, round, value_id) + .await + .unwrap_or_else(|e| { + error!(%height, %round, ?e, "ExtendVote: process failed"); + None + }); reply - .send(self.process_extend_vote()) + .send(extension) .map_err(|e| anyhow!("failed to send ExtendVote reply: {e:?}"))?; } - AppMsg::VerifyVoteExtension { reply, .. } => { + AppMsg::VerifyVoteExtension { + height, + round, + value_id, + extension, + reply, + } => { + let result = self + .process_verify_vote_extension(height, round, value_id, &extension) + .await + .unwrap_or_else(|e| { + warn!(%height, %round, ?e, "VerifyVoteExtension: process failed"); + Err(VoteExtensionError::InvalidVoteExtension) + }); reply - .send(self.process_verify_vote_extension()) + .send(result) .map_err(|e| anyhow!("failed to send VerifyVoteExtension reply: {e:?}"))?; } @@ -426,12 +451,53 @@ where Ok(locally) } - fn process_extend_vote(&self) -> Option { - None + async fn process_extend_vote( + &self, + height: Height, + _round: Round, + value_id: ValueId, + ) -> Result> { + let mb_hash = self.mb_hash_for_value(height, &value_id)?; + self.externalities.extend_vote(mb_hash).await } - fn process_verify_vote_extension(&self) -> Result<(), VoteExtensionError> { - Ok(()) + async fn process_verify_vote_extension( + &self, + height: Height, + _round: Round, + value_id: ValueId, + extension: &EthexeVoteExtension, + ) -> Result> { + let mb_hash = self.mb_hash_for_value(height, &value_id)?; + Ok( + match self + .externalities + .verify_vote_extension(mb_hash, extension) + .await? + { + Acceptance::Accepted(()) => Ok(()), + Acceptance::Rejected(reason) => { + debug!(%reason, %mb_hash, "rejecting vote extension"); + Err(VoteExtensionError::InvalidVoteExtension) + } + }, + ) + } + + fn mb_hash_for_value(&self, height: Height, value_id: &ValueId) -> Result { + let proposed = self + .state + .store + .get_undecided_proposal_by_value_id(value_id)? + .context("vote extension refers to an unknown proposal")?; + ensure!( + proposed.height == height, + "vote extension value belongs to height {}, not {height}", + proposed.height + ); + let block = Block::decode(&mut &proposed.value.block_bytes[..]) + .context("decoding vote extension block")?; + Ok(block.hash()) } // TODO: #5475 add per-peer token-bucket rate limit before `ingest_proposal_part` diff --git a/ethexe/malachite/core/src/codec.rs b/ethexe/malachite/core/src/codec.rs index 859f82ddcc2..e053297a9ac 100644 --- a/ethexe/malachite/core/src/codec.rs +++ b/ethexe/malachite/core/src/codec.rs @@ -25,8 +25,8 @@ use malachitebft_codec::{Codec, HasEncodedLen}; use malachitebft_core_consensus::{LivenessMsg, ProposedValue, SignedConsensusMsg}; use malachitebft_core_types::{ CommitCertificate, CommitSignature, NilOrVal, PolkaCertificate, PolkaSignature, Round, - RoundCertificate, RoundCertificateType, RoundSignature, SignedProposal, SignedVote, - ValidatorProof, Validity, VoteType, + RoundCertificate, RoundCertificateType, RoundSignature, SignedMessage, SignedProposal, + SignedVote, ValidatorProof, Validity, VoteType, }; use malachitebft_engine::util::streaming::{StreamContent, StreamMessage}; use malachitebft_sync::{ @@ -36,7 +36,7 @@ use malachitebft_sync::{ use crate::{ context::{Height, MalachiteCtx, Proposal, ProposalPart, Value, ValueId, Vote}, signing::{Signature, signature_from_vec, signature_to_vec}, - types::Address, + types::{Address, EthexeVoteExtension}, }; /// SCALE codec for malachite wire types. Zero-sized handle. @@ -228,18 +228,46 @@ struct RawSignedMessage { signature: RawSignature, } +#[derive(Encode)] +struct RawSignedVote { + message: Vec, + signature: RawSignature, + extension: Option<(EthexeVoteExtension, RawSignature)>, +} + +impl Decode for RawSignedVote { + fn decode(input: &mut I) -> Result { + let message = Vec::::decode(input)?; + let signature = RawSignature::decode(input)?; + let extension = match input.remaining_len()? { + // Votes persisted before vote-extension support end after the base signature. + Some(0) => None, + _ => Option::decode(input)?, + }; + Ok(Self { + message, + signature, + extension, + }) + } +} + #[derive(Encode, Decode)] enum RawSignedConsensusMsg { - Vote(RawSignedMessage), + Vote(RawSignedVote), Proposal(RawSignedMessage), } impl From> for RawSignedConsensusMsg { fn from(value: SignedConsensusMsg) -> Self { match value { - SignedConsensusMsg::Vote(vote) => Self::Vote(RawSignedMessage { + SignedConsensusMsg::Vote(vote) => Self::Vote(RawSignedVote { message: vote.message.to_sign_bytes().to_vec(), signature: RawSignature::from(&vote.signature), + extension: vote + .message + .extension + .map(|extension| (extension.message, RawSignature::from(&extension.signature))), }), SignedConsensusMsg::Proposal(proposal) => Self::Proposal(RawSignedMessage { message: proposal.message.to_sign_bytes().to_vec(), @@ -253,10 +281,20 @@ impl TryFrom for SignedConsensusMsg { type Error = CodecError; fn try_from(value: RawSignedConsensusMsg) -> Result { match value { - RawSignedConsensusMsg::Vote(raw) => Ok(SignedConsensusMsg::Vote(SignedVote { - message: Vote::from_sign_bytes(&raw.message)?, - signature: Signature::try_from(raw.signature)?, - })), + RawSignedConsensusMsg::Vote(raw) => { + let mut message = Vote::from_sign_bytes(&raw.message)?; + message.extension = match raw.extension { + Some((extension, signature)) => Some(SignedMessage::new( + extension, + Signature::try_from(signature)?, + )), + None => None, + }; + Ok(SignedConsensusMsg::Vote(SignedVote { + message, + signature: Signature::try_from(raw.signature)?, + })) + } RawSignedConsensusMsg::Proposal(raw) => { Ok(SignedConsensusMsg::Proposal(SignedProposal { message: Proposal::from_sign_bytes(&raw.message)?, @@ -792,6 +830,11 @@ mod tests { use crate::signing::{MalachiteSigner, private_key_from_bytes}; use proptest::prelude::*; + #[derive(Encode)] + enum LegacyRawSignedConsensusMsg { + Vote(RawSignedMessage), + } + #[test] fn value_round_trip() { let v = Value::new(b"hello".to_vec()); @@ -800,6 +843,72 @@ mod tests { assert_eq!(v, back); } + #[test] + fn signed_vote_round_trip_preserves_extension() { + let mut bytes = [0u8; 32]; + bytes[31] = 9; + let signer = MalachiteSigner::new(private_key_from_bytes(&bytes).unwrap()); + let address = Address::from_public_key(&signer.public_key()); + let extension = EthexeVoteExtension { + sender: address.0, + shares: Vec::new(), + }; + let mut vote = Vote::new_precommit( + Height::new(3), + Round::new(1), + NilOrVal::Val(ValueId([7; 32])), + address, + ); + vote.extension = Some(SignedMessage::new( + extension.clone(), + signer.sign(&extension.encode()), + )); + let message = SignedConsensusMsg::Vote(SignedVote::new( + vote.clone(), + signer.sign(&vote.to_sign_bytes()), + )); + + let codec = ScaleCodec; + let encoded = + >>::encode(&codec, &message) + .unwrap(); + let decoded = + >>::decode(&codec, encoded) + .unwrap(); + + assert_eq!(decoded, message); + } + + #[test] + fn decodes_legacy_vote_without_extension() { + let mut bytes = [0u8; 32]; + bytes[31] = 10; + let signer = MalachiteSigner::new(private_key_from_bytes(&bytes).unwrap()); + let address = Address::from_public_key(&signer.public_key()); + let vote = Vote::new_precommit( + Height::new(3), + Round::new(1), + NilOrVal::Val(ValueId([8; 32])), + address, + ); + let legacy = LegacyRawSignedConsensusMsg::Vote(RawSignedMessage { + message: vote.to_sign_bytes().to_vec(), + signature: RawSignature::from(&signer.sign(&vote.to_sign_bytes())), + }); + + let decoded = >>::decode( + &ScaleCodec, + legacy.encode().into(), + ) + .unwrap(); + + let SignedConsensusMsg::Vote(decoded) = decoded else { + panic!("expected vote") + }; + assert_eq!(decoded.message, vote); + assert!(decoded.message.extension.is_none()); + } + #[test] fn liveness_polka_cert_round_trip_preserves_signatures() { let mut bytes = [0u8; 32]; diff --git a/ethexe/malachite/core/src/config.rs b/ethexe/malachite/core/src/config.rs index ffb863ec941..e52ba0a2eec 100644 --- a/ethexe/malachite/core/src/config.rs +++ b/ethexe/malachite/core/src/config.rs @@ -24,7 +24,7 @@ pub struct ValidatorEntry { } /// Role this node plays in the BFT swarm. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, derive_more::IsVariant)] pub enum NodeRole { /// Signs votes and proposals; the local key must appear in /// [`MalachiteCoreConfig::validators`]. diff --git a/ethexe/malachite/core/src/context.rs b/ethexe/malachite/core/src/context.rs index 85b11a35b0f..9d018dece5a 100644 --- a/ethexe/malachite/core/src/context.rs +++ b/ethexe/malachite/core/src/context.rs @@ -37,7 +37,7 @@ pub use malachitebft_test::Height; use crate::{ signing::{MalachiteSigner, PublicKey, Signature, signature_from_vec, signature_to_vec}, - types::Address, + types::{Address, EthexeVoteExtension}, }; // Address — adopt the foreign trait via our local newtype. @@ -567,7 +567,7 @@ impl Context for MalachiteCtx { type ValidatorSet = ValidatorSet; type Value = Value; type Vote = Vote; - type Extension = Bytes; + type Extension = EthexeVoteExtension; type SigningScheme = K256; type Timeouts = LinearTimeouts; @@ -679,20 +679,22 @@ impl SigningProvider for MalachiteSigner { async fn sign_vote_extension( &self, - extension: Bytes, + extension: EthexeVoteExtension, ) -> Result, SigningError> { - let signature = self.sign(extension.as_ref()); + let signature = self.sign(&extension.encode()); Ok(SignedMessage::new(extension, signature)) } async fn verify_signed_vote_extension( &self, - extension: &Bytes, + extension: &EthexeVoteExtension, signature: &Signature, public_key: &PublicKey, ) -> Result { + let sender = Address::from_public_key(public_key); Ok(VerificationResult::from_bool( - public_key.verify(extension.as_ref(), signature).is_ok(), + sender.0 == extension.sender + && public_key.verify(&extension.encode(), signature).is_ok(), )) } } @@ -867,6 +869,24 @@ mod tests { assert!(signer.verify(&bytes, &sig, &pk)); } + #[tokio::test] + async fn vote_extension_sender_must_match_signer() { + let (pk, signer) = mk_keypair(7); + let (other_pk, _) = mk_keypair(8); + let extension = EthexeVoteExtension { + sender: Address::from_public_key(&other_pk).0, + shares: Vec::new(), + }; + let signature = signer.sign(&extension.encode()); + + let result = signer + .verify_signed_vote_extension(&extension, &signature, &pk) + .await + .unwrap(); + + assert!(result.is_invalid()); + } + #[test] fn proposal_signature_round_trip() { let (pk, signer) = mk_keypair(8); diff --git a/ethexe/malachite/core/src/externalities.rs b/ethexe/malachite/core/src/externalities.rs index ad52163c86c..1f59b308155 100644 --- a/ethexe/malachite/core/src/externalities.rs +++ b/ethexe/malachite/core/src/externalities.rs @@ -3,7 +3,7 @@ //! Application callbacks the service makes to the outside world. -use crate::types::{Block, BlockPayload, CommitCertificate, H256}; +use crate::types::{Block, BlockPayload, CommitCertificate, EthexeVoteExtension, H256}; use anyhow::Result; use async_trait::async_trait; use ethexe_common::Acceptance; @@ -23,6 +23,20 @@ use ethexe_common::Acceptance; /// only after the parent has been finalized. #[async_trait] pub trait Externalities: Send + Sync + 'static { + /// Build this validator's extension for a precommit on `mb_hash`. + async fn extend_vote(&self, _mb_hash: H256) -> Result> { + Ok(None) + } + + /// Validate and ingest an extension attached to a precommit on `mb_hash`. + async fn verify_vote_extension( + &self, + _mb_hash: H256, + _extension: &EthexeVoteExtension, + ) -> Result> { + Ok(Acceptance::Accepted(())) + } + /// Persist `block` indexed by `mb_hash`; called exactly once per hash /// at proposal-assembly time. async fn process_mb_proposal(&self, mb_hash: H256, block: Block) -> Result<()>; diff --git a/ethexe/malachite/core/src/lib.rs b/ethexe/malachite/core/src/lib.rs index 936fed1e5e7..123c56becba 100644 --- a/ethexe/malachite/core/src/lib.rs +++ b/ethexe/malachite/core/src/lib.rs @@ -98,7 +98,10 @@ pub use crate::{ libp2p_keypair_from, libp2p_peer_id, private_key_from_bytes, private_key_from_gsigner, public_key_from_gsigner, }, - types::{Address, Block, BlockPayload, CommitCertificate, H256, MAX_BLOCK_PAYLOAD_BYTES}, + types::{ + Address, Block, BlockPayload, CommitCertificate, EthexeVoteExtension, H256, + MAX_BLOCK_PAYLOAD_BYTES, + }, }; pub use malachitebft_app_channel::NetworkMsg; pub use malachitebft_engine::network::{ diff --git a/ethexe/malachite/core/src/types.rs b/ethexe/malachite/core/src/types.rs index 44c6fd40260..736b916ad13 100644 --- a/ethexe/malachite/core/src/types.rs +++ b/ethexe/malachite/core/src/types.rs @@ -22,6 +22,21 @@ pub type BlockPayload = LimitedVec; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct Address(pub gsigner::schemes::secp256k1::Address); +/// Decryption shares attached to a Malachite precommit. +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +pub struct EthexeVoteExtension { + /// Validator that created and signed this extension. + pub sender: gsigner::Address, + /// Shares for shielded transactions in the voted block. + pub shares: Vec, +} + +impl malachitebft_core_types::Extension for EthexeVoteExtension { + fn size_bytes(&self) -> usize { + self.encoded_size() + } +} + impl Display for Address { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "0x{}", hex::encode(self.0.0)) diff --git a/ethexe/malachite/service/Cargo.toml b/ethexe/malachite/service/Cargo.toml index 6dc4e9bb982..21eb81be74c 100644 --- a/ethexe/malachite/service/Cargo.toml +++ b/ethexe/malachite/service/Cargo.toml @@ -18,6 +18,7 @@ parity-scale-codec.workspace = true tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } bytes.workspace = true tracing.workspace = true +gear-tdec.workspace = true # Generic Malachite-backed consensus service. Carries the engine, # libp2p swarm, store, and codec; ethexe-malachite only ships the @@ -28,7 +29,7 @@ ethexe-malachite-core.workspace = true ethexe-common = { workspace = true, features = ["std"] } ethexe-db = { workspace = true, default-features = false } ethexe-runtime-common = { workspace = true, features = ["std"] } -gsigner = { workspace = true, features = ["std", "secp256k1", "codec", "keyring", "serde"] } +gsigner = { workspace = true, features = ["std", "secp256k1", "codec", "keyring", "serde", "tdec"] } gprimitives = { workspace = true, features = ["std"] } gear-workspace-hack.workspace = true @@ -37,6 +38,7 @@ gear-workspace-hack.workspace = true # Enable the `mock` feature on the in-mem database so tests can call # `Database::memory()` without `unsafe`. ethexe-db = { workspace = true, features = ["mock"] } +gear-tdec.workspace = true malachitebft-app-channel.workspace = true malachitebft-core-types.workspace = true malachitebft-engine.workspace = true diff --git a/ethexe/malachite/service/src/config.rs b/ethexe/malachite/service/src/config.rs index 4c32cad5c97..c2459011e92 100644 --- a/ethexe/malachite/service/src/config.rs +++ b/ethexe/malachite/service/src/config.rs @@ -10,7 +10,7 @@ pub use ethexe_malachite_core::ValidatorEntry; -use crate::Mempool; +use crate::{Mempool, ValidatorTdecSetup}; use gsigner::{PublicKey, secp256k1::Signer}; use std::{path::PathBuf, time::Duration}; @@ -98,6 +98,8 @@ pub struct ValidatorConfig { pub mempool: M, /// Keystore holding the validator's signing key. pub signer: Signer, + /// Threshold-decryption configuration for shielded transactions. + pub validator_tdec_setup: Option, } #[cfg(test)] diff --git a/ethexe/malachite/service/src/decryption_shares.rs b/ethexe/malachite/service/src/decryption_shares.rs new file mode 100644 index 00000000000..e7387479a8a --- /dev/null +++ b/ethexe/malachite/service/src/decryption_shares.rs @@ -0,0 +1,377 @@ +// Copyright (C) Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +//! In-memory collection of threshold-decryption shares. + +use ethexe_common::{Address, HashOf, injected::ShieldedTransaction}; +use gprimitives::H256; +use gsigner::{DecryptionShare, PublicDecryptionContext}; +use std::{collections::HashMap, sync::Mutex}; +use tokio::sync::Notify; + +type ShieldedTxHash = HashOf; + +/// Result of inserting one verified decryption share. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InsertOutcome { + Inserted, + Duplicate, + Equivocation, + InvalidShare, + UnknownBlock, + UnknownTransaction, +} + +/// Decryption shares grouped by MB, shielded transaction, and validator. +pub(crate) struct DecryptionSharesStore { + inner: Mutex>, + changed: Notify, +} + +type BlockShares = HashMap>; + +impl DecryptionSharesStore { + /// Constructs new empty decryption shares store. + pub(crate) fn new() -> Self { + Self { + inner: Mutex::new(HashMap::new()), + changed: Notify::new(), + } + } + + /// Register the shielded transactions belonging to an assembled MB. + pub(crate) fn register_block( + &self, + mb_hash: H256, + tx_hashes: impl IntoIterator, + ) { + let transactions = tx_hashes + .into_iter() + .map(|tx_hash| (tx_hash, HashMap::new())) + .collect(); + self.inner + .lock() + .expect("decryption shares poisoned") + .entry(mb_hash) + .or_insert(transactions); + } + + /// Insert a share after checking transaction membership and cryptographic proof. + pub(crate) fn insert( + &self, + mb_hash: H256, + tx_hash: ShieldedTxHash, + validator: Address, + validator_context: &PublicDecryptionContext, + transaction: &ShieldedTransaction, + share: DecryptionShare, + ) -> InsertOutcome { + if !share.verify( + &validator_context.blinded_key_share.blinded_key_share, + &validator_context.validator_public_key.encryption_key, + &transaction.ciphertext, + ) { + return InsertOutcome::InvalidShare; + } + + let mut blocks = self.inner.lock().expect("decryption shares poisoned"); + let Some(block) = blocks.get_mut(&mb_hash) else { + return InsertOutcome::UnknownBlock; + }; + let Some(shares) = block.get_mut(&tx_hash) else { + return InsertOutcome::UnknownTransaction; + }; + let outcome = match shares.get(&validator) { + Some(existing) if existing == &share => InsertOutcome::Duplicate, + Some(_) => InsertOutcome::Equivocation, + None => { + shares.insert(validator, share); + InsertOutcome::Inserted + } + }; + drop(blocks); + + if outcome == InsertOutcome::Inserted { + self.changed.notify_one(); + } + outcome + } + + /// Return exactly `threshold` verified shares ordered by validator address. + /// + /// Returns `None` until enough distinct validators have provided a share. + pub(crate) fn threshold_shares( + &self, + mb_hash: H256, + tx_hash: ShieldedTxHash, + threshold: usize, + ) -> Option> { + let blocks = self.inner.lock().expect("decryption shares poisoned"); + let shares = blocks.get(&mb_hash)?.get(&tx_hash)?; + if shares.len() < threshold { + return None; + } + + let mut validators = shares.keys().copied().collect::>(); + validators.sort_unstable(); + Some( + validators + .into_iter() + .take(threshold) + .map(|validator| { + let share = shares + .get(&validator) + .expect("validator was collected from this map") + .clone(); + (validator, share) + }) + .collect(), + ) + } + + /// Keep decryption shares only for the finalized MB. + /// Other shares are no longer useful. + pub(crate) fn retain_block(&self, mb_hash: H256) { + let mut this = self.inner.lock().expect("decryption shares poisoned"); + this.retain(|stored_hash, _| *stored_hash == mb_hash); + } + + pub(crate) fn notified(&self) -> impl Future + '_ { + self.changed.notified() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ethexe_common::injected::InjectedTransaction; + use gear_tdec::{bls12_381::E, rand_utils::Rng}; + use gprimitives::ActorId; + + struct ShareFixture { + transaction: ShieldedTransaction, + tx_hash: ShieldedTxHash, + first_context: PublicDecryptionContext, + second_context: PublicDecryptionContext, + first_share: DecryptionShare, + second_share: DecryptionShare, + } + + fn share_fixture() -> ShareFixture { + let mut rng = gear_tdec::rand_utils::test_rng(); + let dealer = gear_tdec::deal::(3, 2, &mut rng); + let transaction = InjectedTransaction { + destination: ActorId::from([1; 32]), + payload: rng.r#gen::<[u8; 32]>().to_vec().try_into().unwrap(), + value: 0, + reference_block: H256::random(), + salt: rng.r#gen::<[u8; 32]>().to_vec().try_into().unwrap(), + } + .shield(&dealer.public_key, &mut rng) + .expect("shielding succeeds"); + let tx_hash = transaction.to_hash(); + let header = transaction.ciphertext.header(); + let aad = transaction.aad.as_ref(); + let first_share = dealer.private_contexts[0] + .create_share(&header, aad) + .expect("share creation succeeds"); + let second_share = dealer.private_contexts[1] + .create_share(&header, aad) + .expect("share creation succeeds"); + + ShareFixture { + transaction, + tx_hash, + first_context: dealer.private_contexts[0].public_decryption_contexts[0].clone(), + second_context: dealer.private_contexts[1].public_decryption_contexts[1].clone(), + first_share, + second_share, + } + } + + fn random_tx_hash() -> ShieldedTxHash { + unsafe { HashOf::new(H256::random()) } + } + + fn validator(byte: u8) -> Address { + [byte; 20].into() + } + + #[tokio::test] + async fn insertion_is_idempotent_and_notifies() { + let store = DecryptionSharesStore::new(); + let mb_hash = H256::random(); + let fixture = share_fixture(); + store.register_block(mb_hash, [fixture.tx_hash]); + + assert_eq!( + store.insert( + mb_hash, + fixture.tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share.clone() + ), + InsertOutcome::Inserted + ); + tokio::time::timeout(std::time::Duration::from_millis(10), store.notified()) + .await + .expect("insert notification is retained"); + assert_eq!( + store.insert( + mb_hash, + fixture.tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share + ), + InsertOutcome::Duplicate + ); + assert_eq!( + store + .threshold_shares(mb_hash, fixture.tx_hash, 1) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn threshold_query_is_deterministic_and_limited() { + let store = DecryptionSharesStore::new(); + let mb_hash = H256::random(); + let fixture = share_fixture(); + store.register_block(mb_hash, [fixture.tx_hash]); + store.insert( + mb_hash, + fixture.tx_hash, + validator(2), + &fixture.second_context, + &fixture.transaction, + fixture.second_share, + ); + + assert!( + store + .threshold_shares(mb_hash, fixture.tx_hash, 2) + .is_none() + ); + + store.insert( + mb_hash, + fixture.tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share, + ); + let shares = store + .threshold_shares(mb_hash, fixture.tx_hash, 1) + .expect("threshold reached"); + assert_eq!(shares.len(), 1); + assert_eq!(shares[0].0, validator(1)); + } + + #[test] + fn rejects_unknown_entries_and_invalid_shares() { + let store = DecryptionSharesStore::new(); + let mb_hash = H256::random(); + let fixture = share_fixture(); + let other_tx_hash = random_tx_hash(); + + assert_eq!( + store.insert( + mb_hash, + fixture.tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share.clone() + ), + InsertOutcome::UnknownBlock + ); + store.register_block(mb_hash, [fixture.tx_hash]); + assert_eq!( + store.insert( + mb_hash, + other_tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share.clone() + ), + InsertOutcome::UnknownTransaction + ); + assert_eq!( + store.insert( + mb_hash, + fixture.tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share + ), + InsertOutcome::Inserted + ); + assert_eq!( + store.insert( + mb_hash, + fixture.tx_hash, + validator(2), + &fixture.first_context, + &fixture.transaction, + fixture.second_share + ), + InsertOutcome::InvalidShare + ); + assert!( + store + .threshold_shares(mb_hash, fixture.tx_hash, 2) + .is_none() + ); + assert_eq!( + store + .threshold_shares(mb_hash, fixture.tx_hash, 1) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn finalization_prunes_sibling_blocks() { + let store = DecryptionSharesStore::new(); + let finalized = H256::random(); + let sibling = H256::random(); + let fixture = share_fixture(); + store.register_block(finalized, [fixture.tx_hash]); + store.register_block(sibling, [fixture.tx_hash]); + assert_eq!( + store.insert( + sibling, + fixture.tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share.clone() + ), + InsertOutcome::Inserted + ); + + store.retain_block(finalized); + + assert_eq!( + store.insert( + sibling, + fixture.tx_hash, + validator(1), + &fixture.first_context, + &fixture.transaction, + fixture.first_share + ), + InsertOutcome::UnknownBlock + ); + } +} diff --git a/ethexe/malachite/service/src/externalities.rs b/ethexe/malachite/service/src/externalities.rs index a1e0894b96c..00f5b6e7f01 100644 --- a/ethexe/malachite/service/src/externalities.rs +++ b/ethexe/malachite/service/src/externalities.rs @@ -39,28 +39,45 @@ //! back via the same key the consensus layer hands in. use crate::{ - Mempool, quarantine, + Mempool, + decryption_shares::{DecryptionSharesStore, InsertOutcome}, + quarantine, tx_validity::{TxValidity, TxValidityChecker, eb_touched_programs}, types::{ChainHead, CommitCertificate, MalachiteEvent}, }; -use anyhow::{Context, Result, anyhow, ensure}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use async_trait::async_trait; use ethexe_common::{ - Acceptance, MAX_TOUCHED_PROGRAMS_PER_MB, + Acceptance, HashOf, MAX_TOUCHED_PROGRAMS_PER_MB, VerifiedData, db::{ CompactMb, GlobalsStorageRO, GlobalsStorageRW, MbStorageRO, MbStorageRW, OnChainStorageRO, }, - injected::{MAX_INJECTED_TRANSACTIONS_SIZE_PER_MB, SignedInjectedTransaction}, - malachite::{Operation, Operations}, + injected::{ + InjectedTransaction, MAX_INJECTED_TRANSACTIONS_SIZE_PER_MB, PurgedTransaction, + ShieldedTransaction, Transaction, TransactionHash, TransactionPurgedReason, + }, + malachite::{MalachiteTdecContext, Operation, Operations, ShieldedTxDecryptionShare}, }; use ethexe_db::Database; -use ethexe_malachite_core::{Block, BlockPayload, Externalities, MAX_BLOCK_PAYLOAD_BYTES}; +use ethexe_malachite_core::{ + Block, BlockPayload, EthexeVoteExtension, Externalities, MAX_BLOCK_PAYLOAD_BYTES, +}; +use gear_tdec::bls12_381::{ + DecryptionShareSimple, SharedSecret, prepare_combine_simple, share_combine_simple, +}; use gprimitives::H256; +use gsigner::{Address, PublicDecryptionContext, tdec::TdecKeyStore}; use parity_scale_codec::{DecodeAll, Encode}; -use std::{collections::VecDeque, sync::Arc}; +use std::{ + collections::{BTreeMap, HashMap, HashSet, VecDeque}, + sync::Arc, +}; use tokio::sync::{RwLock, mpsc::UnboundedSender}; use tracing::{debug, error, trace, warn}; +/// Type alias for decryption keys provided in [Operation]. +pub(crate) type DecryptionKeys = BTreeMap, SharedSecret>; + /// Constant parameters for [`EthexeExternalities`]; /// see [`crate::MalachiteServiceConfig`] for field semantics. pub struct ExternalitiesConfig { @@ -79,6 +96,12 @@ pub(crate) struct EthexeExternalities { pub cfg: ExternalitiesConfig, /// Optional mempool reference for injected-tx processing; `None` when not a validator. pub mempool: Option>, + /// Threshold-decryption context for the local validator. + pub(crate) tdec_ctx: Option, + /// Local threshold-decryption key store. + pub(crate) tdec_store: TdecKeyStore, + /// Verified decryption shares grouped by MB and transaction. + pub(crate) decryption_shares: Arc, /// Reference to the latest chain head data. pub chain_head: Arc, /// Pending service events queue. @@ -98,10 +121,64 @@ pub(crate) struct PendingEvent { pub prerequisite: H256, } +#[derive(Clone, Default)] +struct UnshieldingOutput { + pub unshielded: Vec<( + HashOf, + VerifiedData, + )>, + pub not_unshielded: Vec, +} + #[async_trait] impl Externalities for EthexeExternalities { + async fn extend_vote(&self, mb_hash: H256) -> Result> { + let Some(context) = self.tdec_ctx.as_ref() else { + return Ok(None); + }; + let compact = self + .db + .mb_compact_block(mb_hash) + .with_context(|| format!("vote extension refers to unknown MB {mb_hash}"))?; + let operations = self + .db + .operations(compact.operations_hash) + .with_context(|| format!("operations for MB {mb_hash} are missing"))?; + let transactions = operations + .iter() + .filter_map(|op| op.as_shielded().map(|signed| signed.data())) + .collect::>(); + if transactions.is_empty() { + return Ok(None); + } + + let my_address = context + .contexts + .iter() + .find_map(|(address, participant)| { + (participant.validator_public_key == context.my_context.validator_public_key) + .then_some(*address) + }) + .context("local TDEC context is absent from validator contexts")?; + let shares = + self.provide_decryption_shares(mb_hash, &context.my_context, my_address, &transactions); + + Ok(Some(EthexeVoteExtension { + sender: my_address, + shares, + })) + } + + async fn verify_vote_extension( + &self, + mb_hash: H256, + extension: &EthexeVoteExtension, + ) -> Result> { + Ok(self.receive_decryption_shares(mb_hash, extension.sender, &extension.shares)) + } + async fn process_mb_proposal(&self, mb_hash: H256, mb: Block) -> Result<()> { - let payload = Operations::decode_all(&mut mb.payload.as_ref()) + let operations = Operations::decode_all(&mut mb.payload.as_ref()) .map_err(|e| anyhow!("decoding Operations from block payload bytes: {e}"))?; let parent = mb.parent_hash; @@ -110,7 +187,7 @@ impl Externalities for EthexeExternalities { .is_zero() .then(H256::zero) .unwrap_or_else(|| self.db.mb_meta(parent).last_advanced_eb); - let last_advanced = payload + let last_advanced = operations .iter() .rev() .find_map(|tx| match tx { @@ -119,7 +196,7 @@ impl Externalities for EthexeExternalities { }) .unwrap_or(parent_advanced); - let operations_hash = self.db.set_operations(payload.clone()); + let operations_hash = self.db.set_operations(operations.clone()); self.db.set_mb_compact_block( mb_hash, CompactMb { @@ -132,6 +209,21 @@ impl Externalities for EthexeExternalities { meta.last_advanced_eb = last_advanced; }); + let shielded_transactions = operations + .iter() + .filter_map(|op| op.as_shielded().map(|signed| signed.data())) + .collect::>(); + self.decryption_shares + .register_block(mb_hash, shielded_transactions.iter().map(|tx| tx.to_hash())); + + // If decryption keys provided - decrypt shielded transactions and save them to database. + if let Some(decryption_keys) = operations.iter().find_map(|op| match op { + Operation::DecryptionKeys(keys) => Some(keys.clone()), + _ => None, + }) { + self.process_unshielding(mb_hash, &decryption_keys)?; + } + self.try_emit_or_queue( MalachiteEvent::BlockProposal { height: mb.height, @@ -161,22 +253,21 @@ impl Externalities for EthexeExternalities { .operations(compact.operations_hash) .with_context(|| format!("operations blob missing for block {mb_hash}"))?; - let injected: Vec = operations - .into_iter() - .filter_map(|op| match op { - Operation::Injected(tx) => Some(tx), - _ => None, - }) - .collect(); - - if !injected.is_empty() { - pool.forget(&injected).await; + let transactions = operations + .iter() + .filter_map(utils::operation_to_transaction) + .collect::>(); + if !transactions.is_empty() { + pool.forget(&transactions).await; } } self.db .globals_mutate(|g| g.latest_finalized_mb_hash = mb_hash); + // Retain shares belonging to another block + self.decryption_shares.retain_block(mb_hash); + let app_cert = CommitCertificate { height: cert.height, mb_hash, @@ -202,34 +293,48 @@ impl Externalities for EthexeExternalities { "build_block_above must not be called when node is not validator" ); + let decryption_keys = self + .wait_for_shielded_tx_decryption_keys(parent_mb_hash) + .await?; let parent_advanced = parent_mb_hash .is_zero() .then(H256::zero) .unwrap_or_else(|| self.db.mb_meta(parent_mb_hash).last_advanced_eb); - let (advance, injected) = self.wait_for_proposable_content(parent_advanced).await?; + + let (advance, transactions) = if decryption_keys.is_some() { + // Fast snapshot of proposable content. If no content propose block with decryption keys only. + self.proposable_content_snapshot(parent_advanced).await? + } else { + self.wait_for_proposable_content(parent_advanced).await? + }; debug!( %parent_mb_hash, %parent_advanced, advance = ?advance, - injected_count = injected.len(), + has_decryption_keys = decryption_keys.is_some(), + transactions_count = transactions.len(), "build_block_above: proposable content resolved", ); - // Filter the fetched injected txs down to the valid ones before we start MB assembly - let valid_injected_txs = { + // (a) Per-tx validity. Each candidate tx from the mempool is + // run through TxValidityChecker so we don't waste an MB + // round-trip on a tx the participant would reject. + let valid = if transactions.is_empty() { + Vec::new() + } else { let chain_head = *self.chain_head.latest_synced.read().await; let checker = TxValidityChecker::new_for_mb(self.db.clone(), chain_head, parent_mb_hash)?; - let mut accepted = Vec::with_capacity(injected.len()); - for tx in injected { - match checker.check_tx_validity(&tx)? { + let mut accepted = Vec::with_capacity(transactions.len()); + for tx in transactions { + match checker.check_tx_validity(tx.as_ref())? { TxValidity::Valid => accepted.push(tx), reason => { debug!( - tx_hash = %tx.data().to_hash(), + tx_hash = %tx.as_ref().hash(), ?reason, - "build_block_above: dropping injected tx — fails TxValidity", + "build_block_above: dropping transaction — fails TxValidity", ); } } @@ -241,6 +346,15 @@ impl Externalities for EthexeExternalities { Some(advanced_eb) => eb_touched_programs(&self.db, parent_advanced, advanced_eb)?, None => Default::default(), }; + if let Some(keys) = &decryption_keys { + let UnshieldingOutput { unshielded, .. } = + self.unshield_parent_transactions(parent_mb_hash, keys)?; + touched.extend( + unshielded + .iter() + .map(|(_, injected_tx)| injected_tx.data().destination), + ); + } let initial_touched_count = touched.len(); if initial_touched_count > MAX_TOUCHED_PROGRAMS_PER_MB as usize { // Producer can't shrink this — the EB events themselves @@ -256,9 +370,8 @@ impl Externalities for EthexeExternalities { // Cap the injected txs to stay within the remaining limits let mut size_counter: usize = 0; - let mut capped_injected_txs: Vec = - Vec::with_capacity(valid_injected_txs.len()); - for tx in valid_injected_txs { + let mut capped: Vec = Vec::with_capacity(valid.len()); + for tx in valid { // Skip the whole loop body once initial touched > limit — // any injected tx would only push it further over. if initial_touched_count > MAX_TOUCHED_PROGRAMS_PER_MB as usize { @@ -271,25 +384,34 @@ impl Externalities for EthexeExternalities { continue; } - let destination = tx.data().destination; - if !touched.contains(&destination) - && touched.len() >= MAX_TOUCHED_PROGRAMS_PER_MB as usize - { - // Adding this destination would breach the cap; skip. - continue; + let destination = match &tx { + Transaction::Injected(tx) => Some(tx.data().destination), + Transaction::Shielded(_) => None, + }; + if let Some(destination) = destination { + if !touched.contains(&destination) + && touched.len() >= MAX_TOUCHED_PROGRAMS_PER_MB as usize + { + // Adding this destination would breach the cap; skip. + continue; + } + + touched.insert(destination); } - touched.insert(destination); size_counter += tx_size; - capped_injected_txs.push(tx); + capped.push(tx); } - let mut operations = Vec::with_capacity(capped_injected_txs.len() + 3); + let mut operations = Vec::with_capacity(capped.len() + 3); if let Some(block_hash) = advance { operations.push(Operation::AdvanceTillEthereumBlock { block_hash }); } - for tx in capped_injected_txs { - operations.push(Operation::Injected(tx)); + if let Some(keys) = decryption_keys { + operations.push(Operation::DecryptionKeys(keys)); + } + for tx in capped { + operations.push(utils::transaction_to_operation(tx)); } operations.push(Operation::ProgressTasks); operations.push(Operation::ProcessQueuesV3 { @@ -324,7 +446,11 @@ impl Externalities for EthexeExternalities { Operation::AdvanceTillEthereumBlock { .. } | Operation::ProgressTasks | Operation::ProcessQueuesV3 { .. } - | Operation::Injected(_) => {} + | Operation::Injected(_) + | Operation::Shielded(_) + | Operation::DecryptionKeys(_) => { + // Known and allowed. + } op => { return Ok(Acceptance::Rejected(format!( "deprecated operation in proposed MB: {op:?}" @@ -345,8 +471,16 @@ impl Externalities for EthexeExternalities { None }; - // Skip injected txs for now, check them a little later - while let Some(Operation::Injected(_)) = next { + let decryption_keys = if let Some(Operation::DecryptionKeys(keys)) = next { + let keys = Some(keys); + next = iter.next(); + keys + } else { + None + }; + + // Skip injected and shielded txs for now, check them a little later + while matches!(next, Some(Operation::Injected(_) | Operation::Shielded(_))) { next = iter.next(); } @@ -427,19 +561,35 @@ impl Externalities for EthexeExternalities { } } + // let Some(chain_head) = chain_head_snapshot else { + // let has_injected = operations + // .iter() + // .any(|tx| utils::operation_to_transaction(tx).is_some()); + // if has_injected { + // warn!("validate: MB carries injected txs but no local chain head — abstaining"); + // return Ok(false); + // } + // return Ok(true); + // }; + // Validate injected txs - let checker = TxValidityChecker::new_for_mb(self.db.clone(), chain_head, parent_hash)?; - for tx in payload.iter() { - let Operation::Injected(signed) = tx else { - continue; - }; - match checker.check_tx_validity(signed)? { - TxValidity::Valid => {} - reason => { - return Ok(Acceptance::Rejected(format!( - "injected tx {} fails TxValidity: {reason:?}", - signed.data().to_hash() - ))); + if payload + .iter() + .any(|op| utils::operation_to_transaction(op).is_some()) + { + let checker = TxValidityChecker::new_for_mb(self.db.clone(), chain_head, parent_hash)?; + for op in payload.iter() { + let Some(transaction) = utils::operation_to_transaction(op) else { + continue; + }; + match checker.check_tx_validity(transaction)? { + TxValidity::Valid => {} + reason => { + return Ok(Acceptance::Rejected(format!( + "transaction {} fails TxValidity: {reason:?}", + transaction.hash() + ))); + } } } } @@ -452,10 +602,23 @@ impl Externalities for EthexeExternalities { Some(advanced_eb) => eb_touched_programs(&self.db, parent_advanced, advanced_eb)?, None => Default::default(), }; + if let Some(keys) = decryption_keys { + let UnshieldingOutput { unshielded, .. } = + self.unshield_parent_transactions(parent_hash, keys)?; + touched.extend( + unshielded + .iter() + .map(|(_, injected_tx)| injected_tx.data().destination), + ); + } let limit = touched.len().max(MAX_TOUCHED_PROGRAMS_PER_MB as usize); - for tx in payload.iter() { - if let Operation::Injected(signed) = tx { - touched.insert(signed.data().destination); + for op in payload.iter() { + match op { + Operation::Injected(signed) => { + touched.insert(signed.data().destination); + } + Operation::Shielded(_) => {} + _ => {} } } if touched.len() > limit { @@ -508,24 +671,22 @@ impl EthexeExternalities { async fn wait_for_proposable_content( &self, prev_advanced_eb_hash: H256, - ) -> Result<(Option, Vec)> { + ) -> Result<(Option, Vec)> { + let mempool = self + .mempool + .as_ref() + .context("must never wait for proposable content when not a validator")?; loop { let chain_head_notified = self.chain_head.notify.notified(); tokio::pin!(chain_head_notified); chain_head_notified.as_mut().enable(); - let advance = self - .find_eb_candidate_for_advancing(prev_advanced_eb_hash) + let (advance, transactions) = self + .proposable_content_snapshot(prev_advanced_eb_hash) .await?; - let chain_head = *self.chain_head.latest_synced.read().await; - let Some(mempool) = self.mempool.as_ref() else { - anyhow::bail!("must never call wait_for_proposable_content when not a validator"); - }; - let injected_txs = mempool.fetch(chain_head).await; - - if advance.is_some() || !injected_txs.is_empty() { - return Ok((advance, injected_txs)); + if advance.is_some() || !transactions.is_empty() { + return Ok((advance, transactions)); } tokio::select! { @@ -536,6 +697,283 @@ impl EthexeExternalities { } } + /// Read currently available producer inputs without waiting for + /// any of them to appear. + /// + /// This function called in [`Self::wait_for_proposable_content`] on each poll + /// iteration, and from [`Self::build_block_above`] when decryption keys are already ready. + async fn proposable_content_snapshot( + &self, + prev_advanced_eb_hash: H256, + ) -> Result<(Option, Vec)> { + let advance = self + .find_eb_candidate_for_advancing(prev_advanced_eb_hash) + .await?; + + let chain_head = *self.chain_head.latest_synced.read().await; + let Some(mempool) = self.mempool.as_ref() else { + anyhow::bail!("must never call wait_for_proposable_content when not a validator"); + }; + let transactions = mempool.fetch(chain_head).await; + + Ok((advance, transactions)) + } + + /// Wait until every shielded transaction in the parent has enough verified + /// shares, then reconstruct one shared secret per transaction. + async fn wait_for_shielded_tx_decryption_keys( + &self, + parent_mb_hash: H256, + ) -> Result> { + if parent_mb_hash.is_zero() { + return Ok(None); + } + let Some(compact) = self.db.mb_compact_block(parent_mb_hash) else { + bail!("compact block not found for block with hash={parent_mb_hash}") + }; + + let Some(operations) = self.db.operations(compact.operations_hash) else { + bail!( + "operations not found for block with hash={parent_mb_hash}, op_hash={}", + compact.operations_hash + ) + }; + + let mut pending = operations + .iter() + .filter_map(|op| op.as_shielded().map(|tx| tx.data().to_hash())) + .collect::>(); + + // No shielded transactions in previous block, do not need to wait for decryption shares. + if pending.is_empty() { + return Ok(None); + } + + let Some(ctx) = self.tdec_ctx.as_ref() else { + bail!("block producer has no threshold-decryption context") + }; + + let threshold = ctx.threshold.get(); + if threshold > ctx.contexts.len() { + bail!( + "invalid threshold-decryption context: threshold={threshold}, participants={}", + ctx.contexts.len() + ); + } + + let mut keys = DecryptionKeys::default(); + while !pending.is_empty() { + pending.retain(|tx_hash| { + let Some(selected) = + self.decryption_shares + .threshold_shares(parent_mb_hash, *tx_hash, threshold) + else { + return true; + }; + + let domains = selected + .iter() + .map(|(validator, _)| { + ctx.contexts + .get(validator) + .expect("stored share has a validator context") + .domain + }) + .collect::>(); + let shares = selected + .into_iter() + .map(|(_, share)| share) + .collect::>(); + let coefficients = prepare_combine_simple::(&domains); + keys.insert( + *tx_hash, + share_combine_simple::(&shares, &coefficients), + ); + false + }); + + if !pending.is_empty() { + self.decryption_shares.notified().await; + } + } + + Ok(Some(keys)) + } + + fn receive_decryption_shares( + &self, + mb_hash: H256, + sender: Address, + shares: &[ShieldedTxDecryptionShare], + ) -> Acceptance<(), String> { + let Some(context) = self.tdec_ctx.as_ref() else { + return Acceptance::Rejected("local TDEC context is unavailable".into()); + }; + + let Some(compact) = self.db.mb_compact_block(mb_hash) else { + return Acceptance::Rejected(format!("unknown MB {mb_hash}")); + }; + let Some(operations) = self.db.operations(compact.operations_hash) else { + return Acceptance::Rejected(format!( + "operations {} for MB {mb_hash} are missing", + compact.operations_hash + )); + }; + + let Some(participant_context) = context.contexts.get(&sender) else { + return Acceptance::Rejected(format!("unknown TDEC participant {sender}")); + }; + let transactions = operations + .iter() + .filter_map(|op| op.as_shielded().map(|signed| signed.data())) + .map(|tx| (tx.to_hash(), tx)) + .collect::>(); + + let mut seen = HashSet::with_capacity(shares.len()); + for message_share in shares { + if !seen.insert(message_share.tx_hash) { + return Acceptance::Rejected(format!( + "duplicate decryption share for transaction {}", + message_share.tx_hash.inner() + )); + } + let Some(transaction) = transactions.get(&message_share.tx_hash) else { + return Acceptance::Rejected(format!( + "decryption share for transaction {} outside MB {mb_hash}", + message_share.tx_hash.inner() + )); + }; + match self.decryption_shares.insert( + mb_hash, + message_share.tx_hash, + sender, + participant_context, + transaction, + message_share.share.clone(), + ) { + InsertOutcome::Inserted | InsertOutcome::Duplicate => {} + InsertOutcome::InvalidShare => { + return Acceptance::Rejected("invalid decryption share".into()); + } + InsertOutcome::Equivocation => { + return Acceptance::Rejected("conflicting decryption share".into()); + } + InsertOutcome::UnknownBlock | InsertOutcome::UnknownTransaction => { + return Acceptance::Rejected("unknown MB or transaction".into()); + } + } + } + Acceptance::Accepted(()) + } + + fn provide_decryption_shares( + &self, + mb_hash: H256, + tdec_ctx: &PublicDecryptionContext, + my_address: Address, + transactions: &[&ShieldedTransaction], + ) -> Vec { + let mut shares = Vec::with_capacity(transactions.len()); + for tx in transactions { + let Ok(share) = + self.tdec_store + .create_share(tdec_ctx, &tx.ciphertext.header(), tx.aad.as_ref()) + else { + continue; + }; + let tx_hash = tx.to_hash(); + let outcome = self.decryption_shares.insert( + mb_hash, + tx_hash, + my_address, + tdec_ctx, + tx, + share.clone(), + ); + debug_assert!(matches!( + outcome, + InsertOutcome::Inserted | InsertOutcome::Duplicate + )); + shares.push(ShieldedTxDecryptionShare { tx_hash, share }); + } + + shares + } + + fn process_unshielding(&self, mb_hash: H256, decryption_keys: &DecryptionKeys) -> Result<()> { + let compact = self + .db + .mb_compact_block(mb_hash) + .context("process_unshielding: no compact for {mb_hash}")?; + + let UnshieldingOutput { + unshielded: unshielded_with_hashes, + not_unshielded, + } = self.unshield_parent_transactions(compact.parent, decryption_keys)?; + + let unshielded_hash_mapping = unshielded_with_hashes + .iter() + .map(|(tx_hash, tx)| (*tx_hash, tx.data().to_hash())) + .collect(); + + let unshielded = unshielded_with_hashes + .into_iter() + .map(|(_, tx)| tx) + .collect(); + + self.db.set_mb_unshielded_txs(mb_hash, unshielded); + + let _ = self.event_tx.send(Ok(MalachiteEvent::UnshieldingOutput { + mb_hash, + unshielded_hash_mapping, + not_unshielded, + })); + + Ok(()) + } + + fn unshield_parent_transactions( + &self, + parent_mb_hash: H256, + decryption_keys: &DecryptionKeys, + ) -> Result { + if parent_mb_hash.is_zero() || decryption_keys.is_empty() { + return Ok(UnshieldingOutput::default()); + } + + let compact = self.db.mb_compact_block(parent_mb_hash).ok_or_else(|| { + anyhow!("unshield_parent_transactions: no CompactMb for parent {parent_mb_hash}") + })?; + let operations = self.db.operations(compact.operations_hash).ok_or_else(|| { + anyhow!( + "unshield_parent_transactions: operations blob {} missing for parent {parent_mb_hash}", + compact.operations_hash + ) + })?; + + let mut output = UnshieldingOutput::default(); + for tx in operations.into_iter().filter_map(Operation::into_shielded) { + let tx_hash = tx.data().to_hash(); + match decryption_keys.get(&tx_hash) { + Some(shared_key) => { + match tx.into_verified().try_map(|tx| tx.unshield(shared_key)) { + Ok(injected_tx) => output.unshielded.push((tx_hash, injected_tx)), + Err(_err) => output.not_unshielded.push(PurgedTransaction { + tx_hash: TransactionHash::Right(tx_hash), + reason: TransactionPurgedReason::DecryptionFailed, + }), + } + } + None => output.not_unshielded.push(PurgedTransaction { + tx_hash: TransactionHash::Right(tx_hash), + reason: TransactionPurgedReason::DecryptionFailed, + }), + } + } + + Ok(output) + } + // Find an EB candidate that can be advanced to according to the current chain head: // 1. Should pass quarantine with post quarantine delay against the latest synced EB. // 2. Should be a strict descendant of the previously advanced EB. @@ -575,6 +1013,28 @@ impl EthexeExternalities { } } +mod utils { + use ethexe_common::{ + injected::{Transaction, TransactionRef}, + malachite::Operation, + }; + /// Optimization for reducing `clone` operation for potentially large transactions. + pub(crate) fn operation_to_transaction(operation: &Operation) -> Option> { + match operation { + Operation::Injected(tx) => Some(TransactionRef::Injected(tx)), + Operation::Shielded(tx) => Some(TransactionRef::Shielded(tx)), + _ => None, + } + } + + pub(crate) fn transaction_to_operation(transaction: Transaction) -> Operation { + match transaction { + Transaction::Injected(tx) => Operation::Injected(tx), + Transaction::Shielded(tx) => Operation::Shielded(tx), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -583,7 +1043,11 @@ mod tests { use ethexe_common::{ BlockHeader, SimpleBlockData, db::{BlockMetaStorageRW, OnChainStorageRW}, - injected::PurgedTransaction, + injected::{PurgedTransaction, SignedInjectedTransaction, TransactionRef}, + }; + use gear_tdec::{ + bls12_381::{DkgPublicKey, E as Bls12_381}, + rand_utils::test_rng, }; use tokio::sync::{Notify, mpsc}; @@ -637,8 +1101,11 @@ mod tests { let (event_tx, event_rx) = mpsc::unbounded_channel(); let ext = EthexeExternalities { db, + tdec_ctx: None, + tdec_store: TdecKeyStore::memory(), mempool: Some(Arc::new(EmptyMempool)), chain_head: make_chain_head(), + decryption_shares: Arc::new(DecryptionSharesStore::new()), event_tx, pending_events: RwLock::new(VecDeque::new()), cfg: ExternalitiesConfig { @@ -650,6 +1117,38 @@ mod tests { (ext, event_rx) } + /// Do threshold decryption setup for a single validator. + fn single_validator_tdec_setup() -> (MalachiteTdecContext, TdecKeyStore, DkgPublicKey) { + let validator_key = ethexe_common::PrivateKey::random(); + let validator_public_key = validator_key.public_key(); + let dealer = gear_tdec::deal::(1, 1, &mut test_rng()); + let private_context = dealer + .private_contexts + .into_iter() + .next() + .expect("single-validator dealer output must contain a private context"); + let public_context = private_context + .public_decryption_contexts + .first() + .cloned() + .expect("single-validator dealer output must contain a public context"); + + let key_store = TdecKeyStore::memory(); + key_store + .import_decryption_key(private_context.validator_decryption_key) + .expect("dealer TDEC key must be importable"); + + ( + MalachiteTdecContext { + threshold: std::num::NonZeroUsize::new(1).expect("threshold is non-zero"), + my_context: public_context.clone(), + contexts: HashMap::from([(validator_public_key.to_address(), public_context)]), + }, + key_store, + dealer.public_key, + ) + } + /// Build an [`Operations`] list for unit tests. /// /// The `salt` byte is encoded as the number of leading @@ -721,6 +1220,81 @@ mod tests { assert!(db.globals().latest_finalized_mb_hash.is_zero()); } + #[tokio::test] + async fn process_mb_proposal_unshields_parent_transactions() { + use ethexe_common::{ + SignedMessage, + db::MbStorageRO, + injected::{InjectedTransaction, TransactionHash}, + }; + use gprimitives::ActorId; + + let db = Database::memory(); + let (mut ext, mut rx) = make_externalities(db.clone()); + let (tdec_ctx, tdec_store, dkg_public_key) = single_validator_tdec_setup(); + ext.tdec_ctx = Some(tdec_ctx); + ext.tdec_store = tdec_store; + + let injected = InjectedTransaction { + destination: ActorId::from([1; 32]), + payload: vec![1, 2, 3].try_into().unwrap(), + value: 0, + reference_block: H256::zero(), + salt: vec![7; 32].try_into().unwrap(), + }; + let shielded = injected + .clone() + .shield(&dkg_public_key, &mut test_rng()) + .expect("test shielding must succeed"); + let shielded_hash = shielded.to_hash(); + let signed_shielded = + SignedMessage::create(ethexe_common::PrivateKey::random(), shielded).unwrap(); + + let parent_payload = Operations::new(vec![ + Operation::Shielded(signed_shielded), + Operation::ProgressTasks, + Operation::ProcessQueuesV3 { gas_allowance: 0 }, + ]); + let parent = wrap(parent_payload, 1, H256::zero()); + let parent_hash = parent.hash(); + ext.process_mb_proposal(parent_hash, parent).await.unwrap(); + let _ = rx.recv().await.expect("parent proposal").expect("ok"); + assert!(ext.extend_vote(parent_hash).await.unwrap().is_some()); + + let child_payload = ext + .build_operations(parent_hash) + .await + .expect("single-validator shares should produce decryption keys"); + let child = wrap(child_payload, 2, parent_hash); + let child_hash = child.hash(); + ext.process_mb_proposal(child_hash, child).await.unwrap(); + + let unshielded = db.mb_unshielded_txs(child_hash); + assert_eq!(unshielded.len(), 1); + assert_eq!(unshielded[0].data(), &injected); + + match rx.try_recv().expect("unshielding event").expect("ok") { + MalachiteEvent::UnshieldingOutput { + mb_hash, + unshielded_hash_mapping, + not_unshielded, + } => { + assert_eq!(mb_hash, child_hash); + assert_eq!( + unshielded_hash_mapping, + vec![(shielded_hash, injected.to_hash())] + ); + assert!(not_unshielded.is_empty()); + } + other => panic!("expected UnshieldingOutput, got {other:?}"), + } + + assert_eq!( + TransactionHash::Left(unshielded[0].data().to_hash()), + TransactionHash::Left(injected.to_hash()) + ); + } + /// `process_mb_finalized` reads the [`CompactMb`] + /// operations blob keyed by the consensus envelope hash, /// advances `globals.latest_finalized_mb_hash`, and emits a @@ -976,19 +1550,73 @@ mod tests { ); } + #[tokio::test] + async fn build_emits_decryption_keys_without_other_proposable_content() { + use ethexe_common::{SignedMessage, injected::InjectedTransaction}; + use gprimitives::ActorId; + + let db = Database::memory(); + let (mut ext, mut rx) = make_externalities(db); + let (tdec_ctx, tdec_store, dkg_public_key) = single_validator_tdec_setup(); + ext.tdec_ctx = Some(tdec_ctx); + ext.tdec_store = tdec_store; + + let injected = InjectedTransaction { + destination: ActorId::from([1; 32]), + payload: vec![1, 2, 3].try_into().unwrap(), + value: 0, + reference_block: H256::zero(), + salt: vec![7; 32].try_into().unwrap(), + }; + let mut rng = gear_tdec::rand_utils::test_rng(); + let shielded = injected.shield(&dkg_public_key, &mut rng).unwrap(); + let signed_shielded = + SignedMessage::create(ethexe_common::PrivateKey::random(), shielded).unwrap(); + let shielded_hash = signed_shielded.data().to_hash(); + + let parent_payload = Operations::new(vec![ + Operation::Shielded(signed_shielded), + Operation::ProgressTasks, + Operation::ProcessQueuesV3 { gas_allowance: 0 }, + ]); + let parent = Block::new(H256::zero(), 1, to_payload(parent_payload.encode())); + let parent_hash = parent.hash(); + ext.process_mb_proposal(parent_hash, parent).await.unwrap(); + let event = rx.recv().await.expect("block event").expect("ok"); + assert!(matches!(event, MalachiteEvent::BlockProposal { .. })); + assert!(ext.extend_vote(parent_hash).await.unwrap().is_some()); + + let operations = tokio::time::timeout( + std::time::Duration::from_millis(50), + ext.build_operations(parent_hash), + ) + .await + .expect("decryption keys alone must be enough to build a block") + .unwrap(); + + let mut iter = operations.iter(); + let Some(Operation::DecryptionKeys(keys)) = iter.next() else { + panic!("first operation must carry decryption keys"); + }; + assert!(keys.contains_key(&shielded_hash)); + assert!(matches!(iter.next(), Some(Operation::ProgressTasks))); + assert!(matches!( + iter.next(), + Some(Operation::ProcessQueuesV3 { gas_allowance }) if *gas_allowance == ext.cfg.gas_allowance + )); + assert!(iter.next().is_none()); + } + /// Stub mempool that records every `forget` argument so the test /// can assert which txs reached the mempool eviction path. #[derive(Default)] struct ForgetTracker { - seen: tokio::sync::Mutex>, + seen: tokio::sync::Mutex>, } #[async_trait::async_trait] impl Mempool for ForgetTracker { - async fn insert( - &self, - _tx: SignedInjectedTransaction, - ) -> crate::mempool::TxInsertionStatus { + async fn insert(&self, _tx: Transaction) -> crate::mempool::TxInsertionStatus { crate::mempool::TxInsertionStatus::Inserted } @@ -996,11 +1624,14 @@ mod tests { Vec::new() } - async fn fetch(&self, _head: SimpleBlockData) -> Vec { + async fn fetch(&self, _head: SimpleBlockData) -> Vec { Vec::new() } - async fn forget(&self, committed: &[SignedInjectedTransaction]) { - self.seen.lock().await.extend_from_slice(committed); + async fn forget(&self, committed: &[TransactionRef<'_>]) { + self.seen + .lock() + .await + .extend(committed.iter().map(TransactionRef::hash)); } async fn wait_for_new_tx(&self) { std::future::pending().await @@ -1052,6 +1683,9 @@ mod tests { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let ext = EthexeExternalities { db: db.clone(), + tdec_ctx: None, + decryption_shares: Arc::new(DecryptionSharesStore::new()), + tdec_store: TdecKeyStore::memory(), mempool: Some(Arc::clone(&tracker) as Arc), chain_head: make_chain_head(), event_tx, @@ -1089,15 +1723,14 @@ mod tests { .await .unwrap(); - let seen = tracker.seen.lock().await.clone(); - let seen_hashes: Vec<_> = seen.iter().map(|t| t.data().to_hash()).collect(); + let seen_hashes = tracker.seen.lock().await.clone(); assert_eq!( - seen.len(), + seen_hashes.len(), 2, "exactly two injected txs should be forgotten" ); - assert!(seen_hashes.contains(&tx_a.data().to_hash())); - assert!(seen_hashes.contains(&tx_b.data().to_hash())); + assert!(seen_hashes.contains(&TransactionHash::Left(tx_a.data().to_hash()))); + assert!(seen_hashes.contains(&TransactionHash::Left(tx_b.data().to_hash()))); } // ------------------------------------------------------------------ @@ -1119,6 +1752,9 @@ mod tests { let (event_tx, event_rx) = mpsc::unbounded_channel(); let ext = EthexeExternalities { db, + tdec_ctx: None, + decryption_shares: Arc::new(DecryptionSharesStore::new()), + tdec_store: TdecKeyStore::memory(), mempool: Some(mempool as Arc), chain_head: make_chain_head(), event_tx, @@ -1256,9 +1892,9 @@ mod tests { ) .unwrap(); - mempool.insert(valid.clone()).await; + mempool.insert(valid.clone().into()).await; assert_eq!( - mempool.insert(value_tx.clone()).await, + mempool.insert(value_tx.clone().into()).await, crate::mempool::TxInsertionStatus::NonZeroValue, ); assert_eq!(mempool.len().await, 1); @@ -1328,12 +1964,10 @@ mod tests { let push_end = MAX_TOUCHED_PROGRAMS_PER_MB + 1; for i in push_start..push_end { mempool - .insert(signed_injected_tx( - &pk, - ActorId::from(i as u64), - chain.blocks[9].hash, - i as u8, - )) + .insert( + signed_injected_tx(&pk, ActorId::from(i as u64), chain.blocks[9].hash, i as u8) + .into(), + ) .await; } @@ -1477,7 +2111,7 @@ mod tests { }, ) .unwrap(); - mempool.insert(tx).await; + mempool.insert(tx.into()).await; } assert_eq!(mempool.len().await, 3); @@ -1873,6 +2507,9 @@ mod tests { let (event_tx, _event_rx) = mpsc::unbounded_channel(); let ext = EthexeExternalities { db: db.clone(), + tdec_ctx: None, + decryption_shares: Arc::new(DecryptionSharesStore::new()), + tdec_store: TdecKeyStore::memory(), mempool: Some(Arc::new(EmptyMempool)), chain_head: make_chain_head(), event_tx, diff --git a/ethexe/malachite/service/src/lib.rs b/ethexe/malachite/service/src/lib.rs index 88dcf6e8418..c3a8ed5edfb 100644 --- a/ethexe/malachite/service/src/lib.rs +++ b/ethexe/malachite/service/src/lib.rs @@ -51,6 +51,7 @@ //! RocksDB locks and sockets release. mod config; +mod decryption_shares; mod externalities; mod mempool; mod quarantine; @@ -62,7 +63,7 @@ mod types; pub use crate::{ config::{MalachiteServiceConfig, ValidatorConfig, ValidatorEntry}, mempool::{InjectedTxMempool, Mempool, TxInsertionStatus}, - service::MalachiteService, + service::{MalachiteService, ValidatorTdecSetup}, starter::MalachiteServiceStarter, tx_validity::{TxValidity, TxValidityChecker}, types::{CommitCertificate, MalachiteEvent}, diff --git a/ethexe/malachite/service/src/mempool.rs b/ethexe/malachite/service/src/mempool.rs index f79109232ad..7ef305c0d44 100644 --- a/ethexe/malachite/service/src/mempool.rs +++ b/ethexe/malachite/service/src/mempool.rs @@ -35,8 +35,9 @@ use ethexe_common::{ HashOf, SimpleBlockData, db::{GlobalsStorageRO, InjectedStorageRW, OnChainStorageRO}, injected::{ - InjectedTransaction, InjectedTransactionAcceptance, PurgedTransaction, - SignedInjectedTransaction, TransactionPurgedReason, VALIDITY_WINDOW, + InjectedTransaction, PurgedTransaction, ShieldedTransaction, SignedInjectedTransaction, + SignedShieldedTransaction, Transaction, TransactionAcceptance, TransactionHash, + TransactionPurgedReason, TransactionRef, VALIDITY_WINDOW, }, }; use ethexe_db::Database; @@ -86,7 +87,7 @@ impl TxInsertionStatus { } } -impl From for InjectedTransactionAcceptance { +impl From for TransactionAcceptance { fn from(status: TxInsertionStatus) -> Self { if status.is_accepted() { Self::Accept @@ -103,17 +104,17 @@ impl From for InjectedTransactionAcceptance { #[async_trait] pub trait Mempool: Send + Sync + 'static { /// Attempt to insert a new transaction into the pool. - async fn insert(&self, tx: SignedInjectedTransaction) -> TxInsertionStatus; + async fn insert(&self, tx: Transaction) -> TxInsertionStatus; /// Notify the pool of a new chain head; evicts expired entries /// and returns the purged transactions. async fn set_chain_head(&self, head: SimpleBlockData) -> Vec; /// Txs whose `reference_block` is an ancestor of `head`. - async fn fetch(&self, head: SimpleBlockData) -> Vec; + async fn fetch(&self, head: SimpleBlockData) -> Vec; /// Drop committed txs and remember their hashes for dedup. - async fn forget(&self, committed: &[SignedInjectedTransaction]); + async fn forget(&self, committed: &[TransactionRef<'_>]); /// Best-effort wake-up on new tx; spurious wake-ups allowed. async fn wait_for_new_tx(&self); @@ -129,7 +130,7 @@ pub(crate) struct EmptyMempool; #[cfg(test)] #[async_trait] impl Mempool for EmptyMempool { - async fn insert(&self, _tx: SignedInjectedTransaction) -> TxInsertionStatus { + async fn insert(&self, _tx: Transaction) -> TxInsertionStatus { TxInsertionStatus::Inserted } @@ -137,11 +138,11 @@ impl Mempool for EmptyMempool { Vec::new() } - async fn fetch(&self, _head: SimpleBlockData) -> Vec { + async fn fetch(&self, _head: SimpleBlockData) -> Vec { Vec::new() } - async fn forget(&self, _committed: &[SignedInjectedTransaction]) {} + async fn forget(&self, _committed: &[TransactionRef<'_>]) {} async fn wait_for_new_tx(&self) { std::future::pending().await @@ -159,14 +160,25 @@ pub const DEFAULT_POOL_CAPACITY: usize = 10_000; /// Pool state behind a single lock — operations are short, contention low. #[derive(Debug, Default)] struct Inner { - /// Pending transactions by hash. - pool: HashMap, SignedInjectedTransaction>, - /// Recently committed txs (tx_hash → ref_block) for dedup. Aged out with the validity window. - seen: HashMap, H256>, + /// Injected transactions waiting for its inclusion in chain. + injected_pool: HashMap, SignedInjectedTransaction>, + /// Recently committed injected txs (tx_hash → ref_block) for dedup. Aged out with the validity window. + injected_seen: HashMap, H256>, + /// Shielded transactions waiting for its inclusion in chain. + shielded_pool: HashMap, SignedShieldedTransaction>, + /// Recently committed shielded txs (tx_hash → ref_block) for dedup. Aged out with the validity window. + shielded_seen: HashMap, H256>, /// Latest chain head height — drives age-out of pool/seen entries. latest_head_height: Option, } +impl Inner { + /// Returns number of transactions in `injected_pool` + `shielded_pool`. + pub fn len(&self) -> usize { + self.injected_pool.len() + self.shielded_pool.len() + } +} + /// In-memory injected-tx pool backed by the node DB for ref-block resolution. #[derive(Debug)] pub struct InjectedTxMempool { @@ -194,12 +206,13 @@ impl InjectedTxMempool { } } + /// Delegates call to `Inner::len`. pub async fn len(&self) -> usize { - self.inner.read().await.pool.len() + self.inner.read().await.len() } pub async fn is_empty(&self) -> bool { - self.inner.read().await.pool.is_empty() + self.len().await == 0 } /// Resolve `reference_block` to its canonical height via the DB. @@ -247,60 +260,75 @@ impl InjectedTxMempool { /// Evict pool entries and seen-hashes whose `reference_block` has /// aged out relative to `head_height`. fn purge_expired(inner: &mut Inner, head_height: u32, db: &Database) -> Vec { - inner.seen.retain(|tx_hash, ref_block| { - match db.block_header(*ref_block).map(|h| h.height) { - Some(h) if !Self::is_expired(head_height, h) => true, - _ => { - trace!(%tx_hash, ref_block = %ref_block, "dropping expired seen-hash"); - false - } + let keep_seen = |tx_type: &'static str, tx_hash: H256, reference_block: &H256| match db + .block_header(*reference_block) + .map(|header| header.height) + { + Some(height) if !Self::is_expired(head_height, height) => true, + _ => { + trace!(%tx_type, %tx_hash, %reference_block, "dropping expired seen-hash"); + false } - }); + }; + inner + .injected_seen + .retain(|tx_hash, ref_block| keep_seen("injected", tx_hash.inner(), ref_block)); + inner + .shielded_seen + .retain(|tx_hash, ref_block| keep_seen("shielded", tx_hash.inner(), ref_block)); + let mut purged_txs = Vec::new(); - inner.pool.retain(|tx_hash, tx| { - let ref_block = tx.data().reference_block; - match db.block_header(ref_block).map(|h| h.height) { - Some(h) if !Self::is_expired(head_height, h) => true, - Some(h) => { - trace!( - %tx_hash, %ref_block, ref_height = h, head_height, - "dropping expired tx from pool", - ); - purged_txs.push(PurgedTransaction { - tx_hash: *tx_hash, - reason: TransactionPurgedReason::Outdated, - }); - false - } - None => { - trace!( - %tx_hash, %ref_block, - "dropping tx with unknown ref_block from pool", - ); - purged_txs.push(PurgedTransaction { - tx_hash: *tx_hash, - reason: TransactionPurgedReason::UnknownReferenceBlock, - }); - false - } + let mut purge_fn = |tx_hash: TransactionHash, ref_block: H256| match db + .block_header(ref_block) + .map(|h| h.height) + { + Some(h) if !Self::is_expired(head_height, h) => true, + Some(h) => { + trace!( + %tx_hash, %ref_block, ref_height = h, head_height, + "dropping expired tx from pool", + ); + purged_txs.push(PurgedTransaction { + tx_hash, + reason: TransactionPurgedReason::Outdated, + }); + false } + None => { + trace!( + %tx_hash, %ref_block, + "dropping tx with unknown ref_block from pool", + ); + purged_txs.push(PurgedTransaction { + tx_hash, + reason: TransactionPurgedReason::UnknownReferenceBlock, + }); + false + } + }; + + inner.injected_pool.retain(|tx_hash, tx| { + purge_fn(TransactionHash::Left(*tx_hash), tx.data().reference_block) + }); + inner.shielded_pool.retain(|tx_hash, tx| { + purge_fn(TransactionHash::Right(*tx_hash), tx.data().reference_block) }); + purged_txs } -} -#[async_trait] -impl Mempool for InjectedTxMempool { - async fn insert(&self, tx: SignedInjectedTransaction) -> TxInsertionStatus { - let tx_data = tx.data(); - let tx_hash = tx_data.to_hash(); - let ref_block = tx_data.reference_block; + async fn insert_injected(&self, tx: SignedInjectedTransaction) -> TxInsertionStatus { + let tx_hash = tx.data().to_hash(); + let ref_block = tx.data().reference_block; - // Reject non-zero-value txs first (#5083) so they never burn pool capacity. - if tx_data.value != 0 { + // Reject non-zero-value txs unconditionally (#5083 — value-bearing + // injected txs are not supported yet). Done first so a malicious + // sender can't burn pool capacity with txs that will never be + // selectable. + if tx.data().value != 0 { info!( %tx_hash, - value = tx_data.value, + value = tx.data().value, "mempool: rejecting tx — non-zero value (#5083 not supported)", ); return TxInsertionStatus::NonZeroValue; @@ -308,13 +336,13 @@ impl Mempool for InjectedTxMempool { let inner = self.inner.read().await; - if inner.seen.contains_key(&tx_hash) { + if inner.injected_seen.contains_key(&tx_hash) { info!(%tx_hash, "mempool: idempotent no-op — hash already committed within validity window"); return TxInsertionStatus::AlreadyIncluded; } - if inner.pool.contains_key(&tx_hash) { - info!(%tx_hash, pool_len = inner.pool.len(), "mempool: idempotent no-op — duplicate insert"); + if inner.injected_pool.contains_key(&tx_hash) { + info!(%tx_hash, pool_len = inner.len(), "mempool: idempotent no-op — duplicate insert"); return TxInsertionStatus::AlreadyInPool; } @@ -332,7 +360,7 @@ impl Mempool for InjectedTxMempool { return TxInsertionStatus::ExpiredRefBlock; } - if inner.pool.len() >= self.capacity { + if inner.len() >= self.capacity { info!(%tx_hash, capacity = self.capacity, "mempool: rejecting tx — pool at capacity"); return TxInsertionStatus::PoolFull; } @@ -348,18 +376,18 @@ impl Mempool for InjectedTxMempool { let mut inner = self.inner.write().await; // Recheck dedup / capacity after the lock-free window. - if inner.seen.contains_key(&tx_hash) { + if inner.injected_seen.contains_key(&tx_hash) { return TxInsertionStatus::AlreadyIncluded; } - if inner.pool.contains_key(&tx_hash) { + if inner.injected_pool.contains_key(&tx_hash) { return TxInsertionStatus::AlreadyInPool; } - if inner.pool.len() >= self.capacity { + if inner.len() >= self.capacity { return TxInsertionStatus::PoolFull; } - let pool_len_after = inner.pool.len() + 1; - inner.pool.insert(tx_hash, tx); + let pool_len_after = inner.len() + 1; + inner.injected_pool.insert(tx_hash, tx); info!( %tx_hash, %ref_block, @@ -374,6 +402,75 @@ impl Mempool for InjectedTxMempool { TxInsertionStatus::Inserted } + async fn insert_shielded(&self, tx: SignedShieldedTransaction) -> TxInsertionStatus { + let tx_hash = tx.data().to_hash(); + let ref_block = tx.data().reference_block; + let inner = self.inner.read().await; + + if inner.shielded_seen.contains_key(&tx_hash) { + info!(tx_hash = %tx_hash.inner(), "mempool: idempotent no-op — shielded hash already committed within validity window"); + return TxInsertionStatus::AlreadyIncluded; + } + + if inner.shielded_pool.contains_key(&tx_hash) { + info!(tx_hash = %tx_hash.inner(), pool_len = inner.len(), "mempool: idempotent no-op — duplicate shielded insert"); + return TxInsertionStatus::AlreadyInPool; + } + + let ref_height_opt = self.ref_block_height(ref_block); + if let Some(ref_height) = ref_height_opt + && let Some(head_height) = inner.latest_head_height + && Self::is_expired(head_height, ref_height) + { + info!( + tx_hash = %tx_hash.inner(), %ref_block, ref_height, head_height, + "mempool: rejecting shielded tx — reference_block past VALIDITY_WINDOW" + ); + return TxInsertionStatus::ExpiredRefBlock; + } + + if inner.len() >= self.capacity { + info!(tx_hash = %tx_hash.inner(), capacity = self.capacity, "mempool: rejecting shielded tx — pool at capacity"); + return TxInsertionStatus::PoolFull; + } + drop(inner); + + let mut inner = self.inner.write().await; + if inner.shielded_seen.contains_key(&tx_hash) { + return TxInsertionStatus::AlreadyIncluded; + } + if inner.shielded_pool.contains_key(&tx_hash) { + return TxInsertionStatus::AlreadyInPool; + } + if inner.len() >= self.capacity { + return TxInsertionStatus::PoolFull; + } + + let pool_len_after = inner.len() + 1; + inner.shielded_pool.insert(tx_hash, tx); + info!( + tx_hash = %tx_hash.inner(), + %ref_block, + ref_height = ?ref_height_opt, + pool_len = pool_len_after, + "mempool: shielded insert accepted", + ); + + drop(inner); + self.new_tx_notify.notify_one(); + TxInsertionStatus::Inserted + } +} + +#[async_trait] +impl Mempool for InjectedTxMempool { + async fn insert(&self, tx: Transaction) -> TxInsertionStatus { + match tx { + Transaction::Injected(tx) => self.insert_injected(tx).await, + Transaction::Shielded(tx) => self.insert_shielded(tx).await, + } + } + async fn set_chain_head(&self, head: SimpleBlockData) -> Vec { let mut inner = self.inner.write().await; let h = head.header.height; @@ -386,35 +483,54 @@ impl Mempool for InjectedTxMempool { Self::purge_expired(&mut inner, h, &self.db) } - async fn fetch(&self, head: SimpleBlockData) -> Vec { + async fn fetch(&self, head: SimpleBlockData) -> Vec { let ancestors = self.recent_ancestors(&head); let inner = self.inner.read().await; - let pool_len = inner.pool.len(); - let result: Vec<_> = inner - .pool + let pool_len = inner.len(); + + let mut transactions = Vec::new(); + inner + .injected_pool .values() .filter(|tx| ancestors.contains(&tx.data().reference_block)) - .cloned() - .collect(); + .for_each(|tx| transactions.push(Transaction::Injected(tx.clone()))); + + inner + .shielded_pool + .values() + .filter(|tx| ancestors.contains(&tx.data().reference_block)) + .for_each(|tx| transactions.push(Transaction::Shielded(tx.clone()))); + info!( head_hash = %head.hash, head_height = head.header.height, ancestors = ancestors.len(), pool_len, - returned = result.len(), + returned = transactions.len(), "mempool: fetch", ); - result + transactions } - async fn forget(&self, committed: &[SignedInjectedTransaction]) { + async fn forget(&self, committed: &[TransactionRef<'_>]) { let mut inner = self.inner.write().await; - for tx in committed { - let tx_hash = tx.data().to_hash(); - inner.pool.remove(&tx_hash); - inner.seen.insert(tx_hash, tx.data().reference_block); - } + committed.iter().for_each(|tx_ref| match tx_ref { + TransactionRef::Injected(tx) => { + let tx_hash = tx.data().to_hash(); + inner.injected_pool.remove(&tx_hash); + inner + .injected_seen + .insert(tx_hash, tx.data().reference_block); + } + TransactionRef::Shielded(tx) => { + let tx_hash = tx.data().to_hash(); + inner.shielded_pool.remove(&tx_hash); + inner + .shielded_seen + .insert(tx_hash, tx.data().reference_block); + } + }); } async fn wait_for_new_tx(&self) { @@ -430,12 +546,15 @@ mod tests { use ethexe_common::{ BlockHeader, PrivateKey, SignedMessage, SimpleBlockData, db::{BlockMetaStorageRW, GlobalsStorageRW, OnChainStorageRW}, - injected::{InjectedTransaction, InjectedTransactionAcceptance}, + injected::{ + InjectedTransaction, SignedInjectedTransaction, SignedShieldedTransaction, + TransactionAcceptance, + }, }; use gprimitives::ActorId; use std::time::Duration; - /// Pins the `TxInsertionStatus -> InjectedTransactionAcceptance` split. + /// Pins the `TxInsertionStatus -> TransactionAcceptance` split. /// Adding a variant without updating [`TxInsertionStatus::is_accepted`] /// will be caught here. #[test] @@ -447,8 +566,8 @@ mod tests { ] { assert!(status.is_accepted(), "{status:?} must classify as accepted"); assert_eq!( - InjectedTransactionAcceptance::from(status), - InjectedTransactionAcceptance::Accept, + TransactionAcceptance::from(status), + TransactionAcceptance::Accept, ); } for status in [ @@ -462,8 +581,8 @@ mod tests { ); let reason = status.to_string(); assert_eq!( - InjectedTransactionAcceptance::from(status), - InjectedTransactionAcceptance::Reject { reason }, + TransactionAcceptance::from(status), + TransactionAcceptance::Reject { reason }, ); } } @@ -480,7 +599,7 @@ mod tests { let pk = PrivateKey::random(); // Fill to capacity with a valid tx so PoolFull would normally fire. - pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 0)) + pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 0).into()) .await; let value_tx = SignedMessage::create( @@ -495,7 +614,10 @@ mod tests { ) .unwrap(); - assert_eq!(pool.insert(value_tx).await, TxInsertionStatus::NonZeroValue); + assert_eq!( + pool.insert(value_tx.into()).await, + TxInsertionStatus::NonZeroValue + ); assert_eq!( pool.len().await, 1, @@ -512,7 +634,7 @@ mod tests { let pk = PrivateKey::random(); let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, 0); - assert_eq!(pool.insert(tx).await, TxInsertionStatus::Inserted); + assert_eq!(pool.insert(tx.into()).await, TxInsertionStatus::Inserted); assert_eq!(pool.len().await, 1); } @@ -526,8 +648,14 @@ mod tests { let pk = PrivateKey::random(); let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, 5); - assert_eq!(pool.insert(tx.clone()).await, TxInsertionStatus::Inserted); - assert_eq!(pool.insert(tx).await, TxInsertionStatus::AlreadyInPool); + assert_eq!( + pool.insert(tx.clone().into()).await, + TxInsertionStatus::Inserted + ); + assert_eq!( + pool.insert(tx.into()).await, + TxInsertionStatus::AlreadyInPool + ); assert_eq!(pool.len().await, 1); } @@ -541,11 +669,15 @@ mod tests { let pk = PrivateKey::random(); let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, 11); - pool.insert(tx.clone()).await; - pool.forget(std::slice::from_ref(&tx)).await; + pool.insert(tx.clone().into()).await; + pool.forget(std::slice::from_ref(&TransactionRef::Injected(&tx))) + .await; assert_eq!(pool.len().await, 0); - assert_eq!(pool.insert(tx).await, TxInsertionStatus::AlreadyIncluded); + assert_eq!( + pool.insert(tx.into()).await, + TxInsertionStatus::AlreadyIncluded + ); assert_eq!(pool.len().await, 0); } @@ -563,7 +695,10 @@ mod tests { let _ = pool.set_chain_head(chain[head_idx]).await; let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, 0); - assert_eq!(pool.insert(tx).await, TxInsertionStatus::ExpiredRefBlock); + assert_eq!( + pool.insert(tx.into()).await, + TxInsertionStatus::ExpiredRefBlock + ); assert_eq!(pool.len().await, 0); } @@ -611,13 +746,35 @@ mod tests { .unwrap() } + fn signed_shielded_tx( + pk: &PrivateKey, + destination: ActorId, + ref_block: H256, + salt: u8, + ) -> SignedShieldedTransaction { + let injected_tx = InjectedTransaction { + destination, + payload: vec![1, 2, 3].try_into().unwrap(), + value: 0, + reference_block: ref_block, + salt: vec![salt; 32].try_into().unwrap(), + }; + let mut rng = gear_tdec::rand_utils::test_rng(); + let dealer_out = gear_tdec::deal::(3, 2, &mut rng); + let shielded_tx = injected_tx + .shield(&dealer_out.public_key, &mut rng) + .unwrap(); + + SignedMessage::create(pk.clone(), shielded_tx).unwrap() + } + #[tokio::test] async fn insert_unknown_ref_block_is_accepted() { let db = Database::memory(); let pool = InjectedTxMempool::new(db); let pk = PrivateKey::random(); let tx = signed_tx(&pk, ActorId::zero(), H256::random(), 1); - pool.insert(tx).await; + pool.insert(tx.into()).await; assert_eq!(pool.len().await, 1); } @@ -628,8 +785,8 @@ mod tests { let pool = InjectedTxMempool::new(db); let pk = PrivateKey::random(); - let tx = signed_tx(&pk, ActorId::zero(), chain[2].hash, 1); - let tx_hash = tx.data().to_hash(); + let tx: Transaction = signed_tx(&pk, ActorId::zero(), chain[2].hash, 1).into(); + let tx_hash = tx.as_ref().hash(); pool.insert(tx.clone()).await; assert_eq!(pool.len().await, 1); @@ -639,7 +796,7 @@ mod tests { let head = chain[2]; let fetched = pool.fetch(head).await; assert_eq!(fetched.len(), 1); - assert_eq!(fetched[0].data().to_hash(), tx_hash); + assert_eq!(fetched[0].as_ref().hash(), tx_hash); } #[tokio::test] @@ -649,12 +806,12 @@ mod tests { let pool = InjectedTxMempool::with_capacity(db, 2); let pk = PrivateKey::random(); - pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 0)) + pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 0).into()) .await; - pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 1)) + pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 1).into()) .await; assert_eq!( - pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 2)) + pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 2).into()) .await, TxInsertionStatus::PoolFull, ); @@ -665,6 +822,67 @@ mod tests { ); } + #[tokio::test] + async fn capacity_is_shared_between_injected_and_shielded_pools() { + let db = Database::memory(); + let chain = linear_chain(&db, 2); + let pool = InjectedTxMempool::with_capacity(db, 1); + let pk = PrivateKey::random(); + + pool.insert(signed_shielded_tx(&pk, ActorId::zero(), chain[1].hash, 0).into()) + .await; + + assert_eq!( + pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 1).into()) + .await, + TxInsertionStatus::PoolFull, + ); + assert_eq!(pool.len().await, 1); + } + + #[tokio::test] + async fn shielded_insert_fetch_and_forget_round_trip() { + let db = Database::memory(); + let chain = linear_chain(&db, 3); + let pool = InjectedTxMempool::new(db); + let pk = PrivateKey::random(); + let tx: Transaction = signed_shielded_tx(&pk, ActorId::zero(), chain[2].hash, 1).into(); + let Transaction::Shielded(signed) = &tx else { + unreachable!("helper creates shielded transaction"); + }; + let tx_hash = signed.data().to_hash(); + + assert_eq!(pool.insert(tx.clone()).await, TxInsertionStatus::Inserted); + assert_eq!(pool.len().await, 1); + + let fetched = pool.fetch(chain[2]).await; + assert_eq!(fetched.len(), 1); + let Transaction::Shielded(fetched) = &fetched[0] else { + panic!("expected shielded transaction"); + }; + assert_eq!(fetched.data().to_hash(), tx_hash); + + pool.forget(std::slice::from_ref(&tx.as_ref())).await; + assert_eq!(pool.len().await, 0); + assert_eq!(pool.insert(tx).await, TxInsertionStatus::AlreadyIncluded); + assert_eq!(pool.len().await, 0); + } + + #[tokio::test] + async fn set_chain_head_purges_expired_shielded() { + let db = Database::memory(); + let chain = linear_chain(&db, (VALIDITY_WINDOW as usize) + 5); + let pool = InjectedTxMempool::new(db); + let pk = PrivateKey::random(); + let tx: Transaction = signed_shielded_tx(&pk, ActorId::zero(), chain[1].hash, 0).into(); + pool.insert(tx).await; + assert_eq!(pool.len().await, 1); + + let head_idx = (VALIDITY_WINDOW as usize) + 1; + let _ = pool.set_chain_head(chain[head_idx]).await; + assert_eq!(pool.len().await, 0); + } + #[tokio::test] async fn unresolved_ref_block_txs_purged_on_head_advance() { let db = Database::memory(); @@ -677,7 +895,7 @@ mod tests { // 100 txs each anchored at a random ref_block NOT in our DB. for salt in 0..100u8 { let bogus_ref_block = H256::random(); - pool.insert(signed_tx(&pk, ActorId::zero(), bogus_ref_block, salt)) + pool.insert(signed_tx(&pk, ActorId::zero(), bogus_ref_block, salt).into()) .await; } assert_eq!(pool.len().await, 100); @@ -706,7 +924,7 @@ mod tests { let pk = PrivateKey::random(); // tx anchored at block 1 — height 1 - let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, 0); + let tx: Transaction = signed_tx(&pk, ActorId::zero(), chain[1].hash, 0).into(); pool.insert(tx).await; assert_eq!(pool.len().await, 1); @@ -728,11 +946,11 @@ mod tests { let pool = InjectedTxMempool::new(db); let pk = PrivateKey::random(); - let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, 99); + let tx: Transaction = signed_tx(&pk, ActorId::zero(), chain[1].hash, 99).into(); pool.insert(tx.clone()).await; assert_eq!(pool.len().await, 1); - pool.forget(std::slice::from_ref(&tx)).await; + pool.forget(std::slice::from_ref(&tx.as_ref())).await; assert_eq!(pool.len().await, 0); // Re-inserting the same tx is a seen-hash no-op. @@ -770,7 +988,7 @@ mod tests { let pk = PrivateKey::random(); // tx anchored to the ALT branch - let tx_alt = signed_tx(&pk, ActorId::zero(), alt_hash, 1); + let tx_alt: Transaction = signed_tx(&pk, ActorId::zero(), alt_hash, 1).into(); pool.insert(tx_alt).await; assert_eq!(pool.len().await, 1); @@ -803,7 +1021,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(10)).await; let pk = PrivateKey::random(); - pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 0)) + pool.insert(signed_tx(&pk, ActorId::zero(), chain[1].hash, 0).into()) .await; // Waiter should now wake up promptly. @@ -822,7 +1040,7 @@ mod tests { let chain = linear_chain(&db, 2); let pool = std::sync::Arc::new(InjectedTxMempool::new(db)); let pk = PrivateKey::random(); - let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, 0); + let tx: Transaction = signed_tx(&pk, ActorId::zero(), chain[1].hash, 0).into(); // Seed one accepted insert and consume the resulting permit so // the next `.notified()` re-blocks until the next signal. @@ -936,11 +1154,11 @@ mod tests { let pk = PrivateKey::random(); // Track inserted (and not-yet-forgotten) txs so Forget // can target a real entry. - let mut live: Vec = Vec::new(); + let mut live: Vec = Vec::new(); for action in actions { match action { Action::Insert { ref_idx, salt } => { - let tx = signed_tx(&pk, ActorId::zero(), chain[ref_idx].hash, salt); + let tx: Transaction = signed_tx(&pk, ActorId::zero(), chain[ref_idx].hash, salt).into(); // Only track txs that actually entered the pool — // `AlreadyInPool` / `AlreadyIncluded` / capacity // rejects must not feed `live`, otherwise Forget @@ -953,7 +1171,7 @@ mod tests { if !live.is_empty() { let idx = which % live.len(); let victim = live.swap_remove(idx); - bo(pool.forget(std::slice::from_ref(&victim))); + bo(pool.forget(std::slice::from_ref(&victim.as_ref()))); } } } @@ -999,14 +1217,18 @@ mod tests { // Inserts: alternating canonical-tail and alt anchors. for i in 0..n_txs { let anchor = if i % 2 == 0 { chain[3].hash } else { alt_hash }; - bo(pool.insert(signed_tx(&pk, ActorId::zero(), anchor, i as u8))); + bo(pool.insert(signed_tx(&pk, ActorId::zero(), anchor, i as u8).into())); } let head = chain[3]; let fetched = bo(pool.fetch(head)); for tx in &fetched { prop_assert_ne!( - tx.data().reference_block, alt_hash, + tx.as_injected() + .expect("injected transaction") + .data() + .reference_block, + alt_hash, "alt-branch tx surfaced on canonical fetch" ); } @@ -1024,10 +1246,10 @@ mod tests { let chain = linear_chain_seeded(&db, 2, seed); let pool = InjectedTxMempool::new(db); let pk = PrivateKey::random(); - let tx = signed_tx(&pk, ActorId::zero(), chain[1].hash, salt); + let tx: Transaction = signed_tx(&pk, ActorId::zero(), chain[1].hash, salt).into(); bo(pool.insert(tx.clone())); prop_assert_eq!(bo(pool.len()), 1); - bo(pool.forget(std::slice::from_ref(&tx))); + bo(pool.forget(std::slice::from_ref(&tx.as_ref()))); prop_assert_eq!(bo(pool.len()), 0); // Re-insert: idempotent no-op because the hash sits in // the seen-set and `reference_block` hasn't aged out. diff --git a/ethexe/malachite/service/src/service.rs b/ethexe/malachite/service/src/service.rs index 211c8012276..9a69e746540 100644 --- a/ethexe/malachite/service/src/service.rs +++ b/ethexe/malachite/service/src/service.rs @@ -17,20 +17,35 @@ use bytes::Bytes; use ethexe_common::{ Address, SimpleBlockData, db::{ConfigStorageRO, OnChainStorageRO}, - injected::SignedInjectedTransaction, + injected::Transaction, }; use ethexe_malachite_core::MalachiteCore; use futures::{Stream, stream::FusedStream}; +use gear_tdec::bls12_381::DkgPublicKey; use gprimitives::H256; -use gsigner::schemes::secp256k1::PublicKey; +use gsigner::{PublicDecryptionContext, TdecKeyStore, schemes::secp256k1::PublicKey}; use std::{ collections::HashMap, + num::NonZeroUsize, pin::Pin, sync::Arc, task::{Context, Poll}, }; use tokio::sync::mpsc::UnboundedReceiver; +/// Public threshold-decryption context and local private-key storage for one validator. +#[derive(Clone, Debug)] +pub struct ValidatorTdecSetup { + /// Minimal number of shares for transaction decryption. + pub threshold: NonZeroUsize, + /// Dkg public key for transactions shielding. + pub dkg_public_key: DkgPublicKey, + /// Public contexts used to create and verify validator decryption shares. + pub validators_contexts: Option>, + /// Store containing this validator's private threshold-decryption key. + pub key_store: TdecKeyStore, +} + /// Public consensus service. pub struct MalachiteService { /// Receiver of outbound events produced by the externalities. @@ -66,10 +81,7 @@ impl MalachiteService { /// Route an injected transaction into the mempool. /// Rejects with `PoolFull` when the node is not a validator. - pub async fn receive_injected_transaction( - &self, - tx: SignedInjectedTransaction, - ) -> TxInsertionStatus { + pub async fn receive_transaction(&self, tx: Transaction) -> crate::mempool::TxInsertionStatus { if let Some(pool) = self.mempool.as_ref() { pool.insert(tx).await } else { diff --git a/ethexe/malachite/service/src/starter.rs b/ethexe/malachite/service/src/starter.rs index 75af8cdd1c6..35231768ed1 100644 --- a/ethexe/malachite/service/src/starter.rs +++ b/ethexe/malachite/service/src/starter.rs @@ -4,19 +4,24 @@ use crate::{ MalachiteService, MalachiteServiceConfig, Mempool, config::ValidatorConfig, + decryption_shares::DecryptionSharesStore, externalities::{EthexeExternalities, ExternalitiesConfig}, types::{ChainHead, MalachiteEvent}, }; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, bail}; use ethexe_common::{ Address, SimpleBlockData, - db::{ConfigStorageRO, GlobalsStorageRO}, + db::{ConfigStorageRO, GlobalsStorageRO, TdecStorageRW}, + malachite::MalachiteTdecContext, }; use ethexe_db::Database; use ethexe_malachite_core::{ MalachiteCore, MalachiteCoreConfig, MalachiteNetworkParts, NodeRole, PeerId, }; -use gsigner::schemes::secp256k1::{PrivateKey, PublicKey}; +use gsigner::{ + TdecKeyStore, + schemes::secp256k1::{PrivateKey, PublicKey}, +}; use std::{collections::HashMap, sync::Arc}; use tokio::sync::{ Notify, RwLock, @@ -65,23 +70,56 @@ impl MalachiteServiceStarter { // Validators sign votes/proposals using their on-chain key; // full nodes get an ephemeral secret used only as the libp2p // peer identity. - let (role, validator_secret, mempool) = match validator_config { - Some(ValidatorConfig { - pub_key: public_key, - mempool, - signer, - }) => { - let secret = signer - .private_key(public_key) - .context("extracting validator private key from signer")?; + let (role, validator_secret, validator_pub_key, mempool, validator_tdec_setup) = + match validator_config { + Some(ValidatorConfig { + pub_key: public_key, + mempool, + signer, + validator_tdec_setup, + }) => { + let secret = signer + .private_key(public_key) + .context("extracting validator private key from signer")?; + + ( + NodeRole::Validator, + secret, + Some(public_key), + Some(Arc::new(mempool) as Arc), + validator_tdec_setup, + ) + } + None => (NodeRole::FullNode, PrivateKey::random(), None, None, None), + }; + + if let Some(setup) = &validator_tdec_setup { + db.set_shielding_key(setup.dkg_public_key); + } + let (tdec_ctx, tdec_store) = match validator_tdec_setup { + Some(setup) => { + let contexts = setup + .validators_contexts + .context("validator must have decryption contexts")?; + let my_address = validator_pub_key + .expect("threshold decryption setup belongs to a validator") + .to_address(); + let my_context = contexts + .get(&my_address) + .cloned() + .context("current validator decryption context not found")?; ( - NodeRole::Validator, - secret, - Some(Arc::new(mempool) as Arc), + Some(MalachiteTdecContext { + threshold: setup.threshold, + my_context, + contexts, + }), + setup.key_store, ) } - None => (NodeRole::FullNode, PrivateKey::random(), None), + None if role.is_validator() => bail!("validator must have a tdec context"), + None => (None, TdecKeyStore::memory()), }; let core_config = MalachiteCoreConfig { @@ -108,6 +146,9 @@ impl MalachiteServiceStarter { post_quarantine_delay: config.post_quarantine_delay, }, mempool: mempool.clone(), + tdec_ctx, + tdec_store, + decryption_shares: Arc::new(DecryptionSharesStore::new()), chain_head: chain_head.clone(), pending_events: Default::default(), event_tx, diff --git a/ethexe/malachite/service/src/tx_validity.rs b/ethexe/malachite/service/src/tx_validity.rs index 1b57f83ec4a..8f9f6de86cf 100644 --- a/ethexe/malachite/service/src/tx_validity.rs +++ b/ethexe/malachite/service/src/tx_validity.rs @@ -13,7 +13,10 @@ use ethexe_common::{ db::{GlobalsStorageRO, MbStorageRO, OnChainStorageRO}, events::{BlockRequestEvent, RouterRequestEvent, router::ProgramCreatedEvent}, gear::INJECTED_MESSAGE_PANIC_GAS_CHARGE_THRESHOLD, - injected::{InjectedTransaction, SignedInjectedTransaction, VALIDITY_WINDOW}, + injected::{ + InjectedTransaction, ShieldedTransaction, SignedInjectedTransaction, + SignedShieldedTransaction, TransactionRef, VALIDITY_WINDOW, + }, malachite::Operation, }; use ethexe_db::Database; @@ -61,11 +64,17 @@ pub struct TxValidityChecker { /// Local-history fence; ancestry walks stop here. start_block_hash: H256, /// Hashes of txs included in recent MBs, for dedup. - recent_included_txs: HashSet>, + recent_included_txs: RecentlyIncludedTransactions, /// Program states snapshot of the latest computed MB ancestor. latest_states: ProgramStates, } +#[derive(Clone, Default)] +pub struct RecentlyIncludedTransactions { + pub injected: HashSet>, + pub shielded: HashSet>, +} + impl TxValidityChecker { /// Build a checker for an MB whose consensus-chain parent is /// `parent_mb_hash` (`H256::zero()` for genesis). @@ -102,8 +111,15 @@ impl TxValidityChecker { }) } - /// Determine [`TxValidity`] for one injected transaction. - pub fn check_tx_validity(&self, tx: &SignedInjectedTransaction) -> Result { + /// Determine [`TxValidity`] for one injected or shielded transaction. + pub fn check_tx_validity(&self, tx: TransactionRef<'_>) -> Result { + match tx { + TransactionRef::Injected(tx) => self.check_injected_validity(tx), + TransactionRef::Shielded(tx) => self.check_shielded_validity(tx), + } + } + + fn check_injected_validity(&self, tx: &SignedInjectedTransaction) -> Result { let reference_block = tx.data().reference_block; if tx.data().value != 0 { @@ -118,7 +134,8 @@ impl TxValidityChecker { return Ok(TxValidity::NotOnCurrentBranch); } - if self.recent_included_txs.contains(&tx.data().to_hash()) { + let tx_hash = tx.data().to_hash(); + if self.recent_included_txs.injected.contains(&tx_hash) { return Ok(TxValidity::Duplicate); } @@ -145,6 +162,25 @@ impl TxValidityChecker { Ok(TxValidity::Valid) } + fn check_shielded_validity(&self, tx: &SignedShieldedTransaction) -> Result { + let reference_block = tx.data().reference_block; + + if !self.is_reference_block_within_validity_window(reference_block)? { + return Ok(TxValidity::Outdated); + } + + if !self.is_reference_block_on_current_branch(reference_block)? { + return Ok(TxValidity::NotOnCurrentBranch); + } + + let tx_hash = tx.data().to_hash(); + if self.recent_included_txs.shielded.contains(&tx_hash) { + return Ok(TxValidity::Duplicate); + } + + Ok(TxValidity::Valid) + } + fn is_reference_block_within_validity_window(&self, reference_block: H256) -> Result { let Some(reference_block_height) = self.db.block_header(reference_block).map(|h| h.height) else { @@ -184,8 +220,9 @@ impl TxValidityChecker { pub fn collect_recent_included_txs( db: &Database, parent_mb: H256, - ) -> Result>> { - let mut txs = HashSet::new(); + ) -> Result { + let mut recent_included = RecentlyIncludedTransactions::default(); + let mut mb_hash = parent_mb; for _ in 0..VALIDITY_WINDOW { if mb_hash.is_zero() { @@ -199,13 +236,19 @@ impl TxValidityChecker { break; }; for op in operations.into_iter() { - if let Operation::Injected(signed) = op { - txs.insert(signed.data().to_hash()); + match op { + Operation::Injected(signed) => { + recent_included.injected.insert(signed.data().to_hash()); + } + Operation::Shielded(signed) => { + recent_included.shielded.insert(signed.data().to_hash()); + } + _ => {} } } mb_hash = cb.parent; } - Ok(txs) + Ok(recent_included) } } @@ -293,7 +336,9 @@ mod tests { MaybeHashOf, PrivateKey, SignedMessage, StateHashWithQueueSize, db::{CompactMb, MbStorageRW, OnChainStorageRW}, gear_core::program::MemoryInfix, - injected::InjectedTransaction, + injected::{ + InjectedTransaction, SignedInjectedTransaction, SignedShieldedTransaction, Transaction, + }, malachite::Operations, mock::{BlockChain, Mock, Tap}, }; @@ -323,12 +368,28 @@ mod tests { } } - fn signed_tx(tx: InjectedTransaction) -> SignedInjectedTransaction { + fn sign_injected_tx(tx: InjectedTransaction) -> SignedInjectedTransaction { SignedMessage::create(PrivateKey::random(), tx).unwrap() } - fn mock_tx(reference_block: H256) -> SignedInjectedTransaction { - signed_tx(test_injected_transaction(reference_block, ActorId::zero())) + fn mock_injected_tx() -> SignedInjectedTransaction { + sign_injected_tx(InjectedTransaction::mock(())) + } + + fn mock_tx(reference_block: H256) -> Transaction { + sign_injected_tx(test_injected_transaction(reference_block, ActorId::zero())).into() + } + + fn sign_shielded_tx(tx: InjectedTransaction) -> SignedShieldedTransaction { + let mut rng = gear_tdec::rand_utils::test_rng(); + let dealer_out = gear_tdec::deal::(3, 2, &mut rng); + let shielded_tx = tx.shield(&dealer_out.public_key, &mut rng).unwrap(); + + SignedMessage::create(PrivateKey::random(), shielded_tx).unwrap() + } + + fn mock_shielded_tx(reference_block: H256) -> Transaction { + sign_shielded_tx(test_injected_transaction(reference_block, ActorId::zero())).into() } fn program_state(initialized: bool, executable_balance: u128) -> ProgramState { @@ -385,12 +446,26 @@ mod tests { executable_balance: u128, parent_mb: H256, ) -> H256 { - let ops = Operations::new( + setup_mb_with_ops( + db, injected_transactions .into_iter() .map(Operation::Injected) .collect(), - ); + destination_initialized, + executable_balance, + parent_mb, + ) + } + + fn setup_mb_with_ops( + db: &Database, + operations: Vec, + destination_initialized: bool, + executable_balance: u128, + parent_mb: H256, + ) -> H256 { + let ops = Operations::new(operations); let operations_hash = db.set_operations(ops); let mb_hash = H256::random(); db.set_mb_compact_block( @@ -442,7 +517,7 @@ mod tests { let tx = mock_tx(block.hash); assert_eq!( TxValidity::Valid, - tx_checker.check_tx_validity(&tx).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } } @@ -454,13 +529,62 @@ mod tests { let chain = test_block_chain(100).setup(&db); let chain_head = chain.blocks[9].to_simple(); - let tx = mock_tx(chain.blocks[5].hash); - let parent_mb = setup_mb(&db, vec![tx.clone()], true, chain.mb_hash_at(8)); + let injected_tx = + sign_injected_tx(test_injected_transaction(chain_head.hash, ActorId::zero())); + let tx = Transaction::Injected(injected_tx.clone()); + let parent_mb = setup_mb(&db, vec![injected_tx], true, chain.mb_hash_at(8)); let tx_checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, parent_mb).unwrap(); assert_eq!( TxValidity::Duplicate, - tx_checker.check_tx_validity(&tx).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() + ); + } + + #[test] + fn test_check_shielded_tx_validity() { + let db = Database::memory(); + let chain = test_block_chain(100).setup(&db); + + let chain_head = chain.blocks[VALIDITY_WINDOW as usize].to_simple(); + let parent_mb = setup_mb( + &db, + vec![], + true, + chain.mb_hash_at(VALIDITY_WINDOW as usize - 1), + ); + let tx_checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, parent_mb).unwrap(); + + for block in chain.blocks.iter().skip(1).take(VALIDITY_WINDOW as usize) { + let tx = mock_shielded_tx(block.hash); + assert_eq!( + TxValidity::Valid, + tx_checker.check_tx_validity(tx.as_ref()).unwrap() + ); + } + } + + #[test] + fn test_check_shielded_tx_duplicate() { + let db = Database::memory(); + let chain = test_block_chain(100).setup(&db); + + let chain_head = chain.blocks[9].to_simple(); + let shielded_tx = + sign_shielded_tx(test_injected_transaction(chain_head.hash, ActorId::zero())); + let tx = Transaction::Shielded(shielded_tx.clone()); + let parent_mb = setup_mb_with_ops( + &db, + vec![Operation::Shielded(shielded_tx)], + true, + MIN_EXECUTABLE_BALANCE_FOR_INJECTED_MESSAGES, + chain.mb_hash_at(8), + ); + let tx_checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, parent_mb).unwrap(); + + assert_eq!( + TxValidity::Duplicate, + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } @@ -483,7 +607,7 @@ mod tests { let tx = mock_tx(block.hash); assert_eq!( TxValidity::Outdated, - tx_checker.check_tx_validity(&tx).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } } @@ -514,14 +638,14 @@ mod tests { let tx = mock_tx(block.hash); assert_eq!( TxValidity::NotOnCurrentBranch, - tx_checker.check_tx_validity(&tx).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } for block in chain.blocks.iter().rev().take(VALIDITY_WINDOW as usize) { let tx = mock_tx(block.hash); assert_eq!( TxValidity::Valid, - tx_checker.check_tx_validity(&tx).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } } @@ -539,7 +663,7 @@ mod tests { assert_eq!( TxValidity::UninitializedDestination, - tx_checker.check_tx_validity(&tx).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } @@ -552,13 +676,14 @@ mod tests { let chain_head = chain.blocks[9].to_simple(); let tx = test_injected_transaction(chain.blocks[5].hash, ActorId::zero()) .tap_mut(|tx| tx.value = 100); + let tx: Transaction = sign_injected_tx(tx).into(); let parent_mb = setup_mb(&db, vec![], true, chain.mb_hash_at(8)); let tx_checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, parent_mb).unwrap(); assert_eq!( TxValidity::NonZeroValue, - tx_checker.check_tx_validity(&signed_tx(tx)).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } @@ -569,14 +694,14 @@ mod tests { let chain = test_block_chain(10).setup(&db); let chain_head = chain.blocks[9].to_simple(); - let tx = test_injected_transaction(H256::zero(), ActorId::zero()); + let tx: Transaction = mock_injected_tx().into(); let parent_mb = setup_mb(&db, vec![], true, chain.mb_hash_at(8)); let tx_checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, parent_mb).unwrap(); assert_eq!( TxValidity::Outdated, - tx_checker.check_tx_validity(&signed_tx(tx)).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } @@ -598,14 +723,14 @@ mod tests { .setup(&db); let chain_head = chain.blocks[3].to_simple(); - let tx = test_injected_transaction(chain.blocks[0].hash, ActorId::zero()); + let tx = mock_tx(chain.blocks[0].hash); let parent_mb = setup_mb(&db, vec![], true, chain.mb_hash_at(3)); let tx_checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, parent_mb).unwrap(); assert_eq!( TxValidity::NotOnCurrentBranch, - tx_checker.check_tx_validity(&signed_tx(tx)).unwrap() + tx_checker.check_tx_validity(tx.as_ref()).unwrap() ); } @@ -631,7 +756,7 @@ mod tests { let checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, parent_mb).unwrap(); let tx = mock_tx(chain.blocks[5].hash); assert_eq!( - checker.check_tx_validity(&tx).unwrap(), + checker.check_tx_validity(tx.as_ref()).unwrap(), TxValidity::InsufficientBalanceForInjectedMessages, ); } @@ -647,7 +772,7 @@ mod tests { .unwrap(); let tx = mock_tx(chain.blocks[1].hash); assert_eq!( - checker.check_tx_validity(&tx).unwrap(), + checker.check_tx_validity(tx.as_ref()).unwrap(), TxValidity::UnknownDestination, ); } @@ -675,7 +800,10 @@ mod tests { let chain_head = chain.blocks[9].to_simple(); let checker = TxValidityChecker::new_for_mb(db.clone(), chain_head, mb_parent).unwrap(); let tx = mock_tx(chain.blocks[5].hash); - assert_eq!(checker.check_tx_validity(&tx).unwrap(), TxValidity::Valid); + assert_eq!( + checker.check_tx_validity(tx.as_ref()).unwrap(), + TxValidity::Valid + ); } /// Pin evaluation order: NonZeroValue short-circuits ahead of all @@ -691,10 +819,10 @@ mod tests { .unwrap(); // value != 0 AND ref_block not in DB. NonZeroValue wins. - let tx = - test_injected_transaction(H256::random(), ActorId::zero()).tap_mut(|tx| tx.value = 1); + let tx: Transaction = + sign_injected_tx(InjectedTransaction::mock(()).tap_mut(|tx| tx.value = 1)).into(); assert_eq!( - checker.check_tx_validity(&signed_tx(tx)).unwrap(), + checker.check_tx_validity(tx.as_ref()).unwrap(), TxValidity::NonZeroValue, ); } diff --git a/ethexe/malachite/service/src/types.rs b/ethexe/malachite/service/src/types.rs index bb0c2bc646d..29666239420 100644 --- a/ethexe/malachite/service/src/types.rs +++ b/ethexe/malachite/service/src/types.rs @@ -1,7 +1,10 @@ // Copyright (C) Gear Technologies Inc. // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 -use ethexe_common::{SimpleBlockData, injected::PurgedTransaction}; +use ethexe_common::{ + HashOf, SimpleBlockData, + injected::{InjectedTransaction, PurgedTransaction, ShieldedTransaction}, +}; use gprimitives::H256; use tokio::sync::{Notify, RwLock}; @@ -48,6 +51,13 @@ pub enum MalachiteEvent { eb_hash: H256, transactions: Vec, }, + + /// Output of unshielding transactions in an MB. + UnshieldingOutput { + mb_hash: H256, + unshielded_hash_mapping: Vec<(HashOf, HashOf)>, + not_unshielded: Vec, + }, } impl std::fmt::Display for MalachiteEvent { @@ -77,6 +87,16 @@ impl std::fmt::Display for MalachiteEvent { transactions.len() ) } + Self::UnshieldingOutput { + mb_hash, + unshielded_hash_mapping, + not_unshielded, + } => write!( + f, + "UnshieldingOutput(mb_hash: {mb_hash}, unshielded_len: {}, not_unshielded_len: {})", + unshielded_hash_mapping.len(), + not_unshielded.len(), + ), } } } diff --git a/ethexe/malachite/service/tests/restart_resilience.rs b/ethexe/malachite/service/tests/restart_resilience.rs index 1da31dd2c67..5a2bba348d8 100644 --- a/ethexe/malachite/service/tests/restart_resilience.rs +++ b/ethexe/malachite/service/tests/restart_resilience.rs @@ -16,26 +16,27 @@ //! `globals.latest_finalized_mb_hash` is gap-free across the //! restart boundary, and the latest pointer never rewinds. -use std::{path::Path, time::Duration}; +use std::{collections::HashMap, num::NonZeroUsize, path::Path, time::Duration}; use async_trait::async_trait; use ethexe_common::{ BlockHeader, SimpleBlockData, db::{BlockMetaStorageRW, CompactMb, GlobalsStorageRO, MbStorageRO, OnChainStorageRW}, - injected::{PurgedTransaction, SignedInjectedTransaction}, + injected::{PurgedTransaction, Transaction, TransactionRef}, }; use ethexe_db::Database; use ethexe_malachite::{ MalachiteEvent, MalachiteService, MalachiteServiceConfig, MalachiteServiceStarter, Mempool, - TxInsertionStatus, ValidatorConfig, ValidatorEntry, + TxInsertionStatus, ValidatorConfig, ValidatorEntry, ValidatorTdecSetup, }; use ethexe_malachite_core::{ Address, MalachiteCtx, MalachiteNetworkParts, MalachiteSigner, PeerId, ScaleCodec, libp2p_keypair_from, private_key_from_gsigner, public_key_from_gsigner, }; use futures::StreamExt as _; +use gear_tdec::bls12_381::E as Bls12_381; use gprimitives::H256; -use gsigner::{Signer, schemes::secp256k1::Secp256k1}; +use gsigner::{Signer, TdecKeyStore, schemes::secp256k1::Secp256k1}; use malachitebft_app_channel::app::{metrics::SharedRegistry, types::codec::Codec}; use malachitebft_engine::network::{Network, NetworkIdentity}; use malachitebft_network::{ @@ -52,7 +53,7 @@ struct EmptyMempool; #[async_trait] impl Mempool for EmptyMempool { - async fn insert(&self, _tx: SignedInjectedTransaction) -> TxInsertionStatus { + async fn insert(&self, _tx: Transaction) -> TxInsertionStatus { TxInsertionStatus::Inserted } @@ -60,11 +61,11 @@ impl Mempool for EmptyMempool { Vec::new() } - async fn fetch(&self, _head: SimpleBlockData) -> Vec { + async fn fetch(&self, _head: SimpleBlockData) -> Vec { Vec::new() } - async fn forget(&self, _committed: &[SignedInjectedTransaction]) {} + async fn forget(&self, _committed: &[TransactionRef<'_>]) {} async fn wait_for_new_tx(&self) { std::future::pending().await @@ -126,9 +127,35 @@ fn build_signer(home: &Path) -> (Signer, gsigner::schemes::secp256k1: (signer, pub_key) } +fn build_tdec_setup(pub_key: gsigner::schemes::secp256k1::PublicKey) -> ValidatorTdecSetup { + let dealer = gear_tdec::deal::(1, 1, &mut gear_tdec::rand_utils::test_rng()); + let private_context = dealer + .private_contexts + .into_iter() + .next() + .expect("single-validator dealer output must contain a private context"); + let public_context = private_context + .public_decryption_contexts + .first() + .cloned() + .expect("single-validator dealer output must contain a public context"); + + let key_store = TdecKeyStore::memory(); + key_store + .import_decryption_key(private_context.validator_decryption_key) + .expect("dealer TDEC key must be importable"); + + ValidatorTdecSetup { + threshold: NonZeroUsize::new(1).expect("threshold is non-zero"), + dkg_public_key: dealer.public_key, + validators_contexts: Some(HashMap::from([(pub_key.to_address(), public_context)])), + key_store, + } +} + /// Build the MalachiteServiceConfig used by the resilience tests: /// quarantine-off (so the producer can advance immediately on each -/// new chain head), default listen address, no persistent peers, +/// new chain head), ephemeral listen port, no persistent peers, /// single-validator set so the local node can decide on its own. fn build_config( home: &Path, @@ -267,6 +294,9 @@ async fn collect_until_finalized( Ok(Some(Ok(MalachiteEvent::PurgedTransactions { .. }))) => { // ignore } + Ok(Some(Ok(MalachiteEvent::UnshieldingOutput { .. }))) => { + // ignore + } Ok(Some(Err(e))) => panic!("service error: {e}"), Ok(None) | Err(_) => break, } @@ -293,6 +323,7 @@ async fn single_validator_finalizes_and_recovers_after_restart() { let private_key = signer .private_key(pub_key) .expect("extract validator private key"); + let tdec_setup = build_tdec_setup(pub_key); // ---- first run ------------------------------------------------- let (peer_id, network_parts) = default_network_parts(&private_key, 30_001).await; @@ -302,6 +333,7 @@ async fn single_validator_finalizes_and_recovers_after_restart() { pub_key, mempool: EmptyMempool, signer: signer.clone(), + validator_tdec_setup: Some(tdec_setup.clone()), }), db.clone(), chain[0], @@ -352,6 +384,7 @@ async fn single_validator_finalizes_and_recovers_after_restart() { pub_key, mempool: EmptyMempool, signer, + validator_tdec_setup: Some(tdec_setup), }), db.clone(), chain[31], diff --git a/ethexe/network/src/injected.rs b/ethexe/network/src/injected.rs index 8b3f1c05ee4..7e18fb8203a 100644 --- a/ethexe/network/src/injected.rs +++ b/ethexe/network/src/injected.rs @@ -7,8 +7,8 @@ use crate::{ validator::discovery::ValidatorIdentities, }; use ethexe_common::{ - Address, HashOf, - injected::{InjectedTransaction, InjectedTransactionAcceptance, SignedInjectedTransaction}, + Address, + injected::{Transaction, TransactionAcceptance, TransactionHash}, }; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::FuturesUnordered}; use libp2p::{ @@ -66,24 +66,24 @@ impl Metrics { /// Network-only type to be encoded-decoded and sent over the network #[derive(Debug, Encode, Decode)] -pub(crate) struct InnerRequest(SignedInjectedTransaction); +pub(crate) struct InnerRequest(Transaction); /// Network-only type to be encoded-decoded and sent over the network #[derive(Debug, Encode, Decode)] -pub(crate) struct InnerResponse(InjectedTransactionAcceptance); +pub(crate) struct InnerResponse(TransactionAcceptance); #[derive(Debug)] pub enum Event { /// Peer sent a new transaction to us InboundTransaction { peer: PeerId, - transaction: Box, - channel: oneshot::Sender, + transaction: Box, + channel: oneshot::Sender, }, /// We got a response from a validator we sent transaction to OutboundAcceptance { - transaction_hash: HashOf, - acceptance: InjectedTransactionAcceptance, + transaction_hash: TransactionHash, + acceptance: TransactionAcceptance, }, } @@ -91,11 +91,7 @@ pub enum Event { impl Event { fn unwrap_new_injected_transaction( self, - ) -> ( - PeerId, - SignedInjectedTransaction, - oneshot::Sender, - ) { + ) -> (PeerId, Transaction, oneshot::Sender) { match self { Event::InboundTransaction { peer, @@ -106,9 +102,7 @@ impl Event { } } - fn unwrap_injected_transaction_acceptance( - self, - ) -> (HashOf, InjectedTransactionAcceptance) { + fn unwrap_injected_transaction_acceptance(self) -> (TransactionHash, TransactionAcceptance) { match self { Event::OutboundAcceptance { transaction_hash, @@ -134,9 +128,9 @@ type PendingResponseFuture = BoxFuture<'static, (ResponseChannel, pub(crate) struct Behaviour { inner: InnerBehaviour, - pending_requests: HashMap>, + pending_requests: HashMap, pending_responses: FuturesUnordered, - transaction_cache: LruCache, LruCache>, + transaction_cache: LruCache>, metrics: Metrics, } @@ -155,14 +149,14 @@ impl Behaviour { } } - /// Broadcasts [SignedInjectedTransaction] to all known validators. + /// Broadcasts [Transaction] to all known validators. /// Returns the number of sent requests. pub fn broadcast_transaction( &mut self, identities: &ValidatorIdentities, - transaction: SignedInjectedTransaction, + transaction: Transaction, ) -> Result { - let tx_hash = transaction.data().to_hash(); + let tx_hash = transaction.as_ref().hash(); if identities.is_empty() { return Err(SendTransactionError::NoValidatorsFound); @@ -400,7 +394,7 @@ mod tests { utils::tests::{arb_value, init_logger}, validator::discovery::{SignedValidatorIdentity, ValidatorAddresses, ValidatorIdentity}, }; - use ethexe_common::injected::InjectedTransaction; + use ethexe_common::injected::{InjectedTransaction, Transaction}; use gsigner::secp256k1::{Secp256k1SignerExt, Signer}; use libp2p::{ Swarm, Transport, @@ -410,12 +404,12 @@ mod tests { use libp2p_swarm_test::SwarmExt; use std::time::Duration; - fn signed_injected_tx() -> SignedInjectedTransaction { + fn signed_injected_tx() -> Transaction { let signer = Signer::memory(); let pub_key = signer.generate().unwrap(); let tx = arb_value::(()); - signer.signed_message(pub_key, tx, None).unwrap() + Transaction::Injected(signer.signed_message(pub_key, tx, None).unwrap()) } async fn new_swarm() -> (Swarm, SignedValidatorIdentity) { @@ -472,7 +466,7 @@ mod tests { .next_behaviour_event() .await .unwrap_injected_transaction_acceptance(); - assert_eq!(acceptance, InjectedTransactionAcceptance::Accept); + assert_eq!(acceptance, TransactionAcceptance::Accept); }); let (peer, new_tx, channel) = bob @@ -481,7 +475,7 @@ mod tests { .unwrap_new_injected_transaction(); assert_eq!(peer, alice_peer_id); assert_eq!(new_tx, transaction); - channel.send(InjectedTransactionAcceptance::Accept).unwrap(); + channel.send(TransactionAcceptance::Accept).unwrap(); tokio::spawn(bob.loop_on_next()); alice_handle.await.unwrap(); @@ -513,7 +507,7 @@ mod tests { .next_behaviour_event() .await .unwrap_injected_transaction_acceptance(); - assert_eq!(acceptance, InjectedTransactionAcceptance::Accept); + assert_eq!(acceptance, TransactionAcceptance::Accept); } }); @@ -523,7 +517,7 @@ mod tests { .unwrap_new_injected_transaction(); assert_eq!(peer, alice_peer_id); assert_eq!(new_tx, transaction); - channel.send(InjectedTransactionAcceptance::Accept).unwrap(); + channel.send(TransactionAcceptance::Accept).unwrap(); tokio::spawn(bob.loop_on_next()); let (peer, new_tx, channel) = carol @@ -532,7 +526,7 @@ mod tests { .unwrap_new_injected_transaction(); assert_eq!(peer, alice_peer_id); assert_eq!(new_tx, transaction); - channel.send(InjectedTransactionAcceptance::Accept).unwrap(); + channel.send(TransactionAcceptance::Accept).unwrap(); tokio::spawn(carol.loop_on_next()); alice_handle.await.unwrap(); @@ -560,7 +554,7 @@ mod tests { .unwrap_injected_transaction_acceptance(); assert_eq!( acceptance, - InjectedTransactionAcceptance::Reject { + TransactionAcceptance::Reject { reason: REJECT_REASON.to_string(), } ); @@ -573,7 +567,7 @@ mod tests { assert_eq!(peer, alice_peer_id); assert_eq!(new_tx, transaction); channel - .send(InjectedTransactionAcceptance::Reject { + .send(TransactionAcceptance::Reject { reason: REJECT_REASON.to_string(), }) .unwrap(); @@ -602,7 +596,7 @@ mod tests { .unwrap_injected_transaction_acceptance(); assert_eq!( acceptance, - InjectedTransactionAcceptance::Reject { + TransactionAcceptance::Reject { reason: OutboundFailure::ConnectionClosed.to_string(), } ); diff --git a/ethexe/network/src/lib.rs b/ethexe/network/src/lib.rs index f0884d24a46..286dd529ddb 100644 --- a/ethexe/network/src/lib.rs +++ b/ethexe/network/src/lib.rs @@ -46,7 +46,7 @@ use ethexe_common::{ Address, BlockHeader, ValidatorsVec, db::ConfigStorageRO, ecdsa::PublicKey, - injected::{SignedCompactTxReceipt, SignedInjectedTransaction}, + injected::{SignedCompactTxReceipt, Transaction}, network::{SignedValidatorMessage, VerifiedValidatorMessage}, }; use ethexe_db::Database; @@ -672,7 +672,7 @@ impl NetworkService { /// Send an injected transaction privately to all known validators. pub fn broadcast_injected_transaction( &mut self, - transaction: SignedInjectedTransaction, + transaction: Transaction, ) -> Result { let behaviour = self.swarm.behaviour_mut(); behaviour diff --git a/ethexe/network/src/validator/topic.rs b/ethexe/network/src/validator/topic.rs index 26b20f589ac..586cfeffdf5 100644 --- a/ethexe/network/src/validator/topic.rs +++ b/ethexe/network/src/validator/topic.rs @@ -9,8 +9,8 @@ use crate::{ validator::list::ValidatorListSnapshot, }; use ethexe_common::{ - Address, HashOf, - injected::{InjectedTransaction, SignedCompactTxReceipt}, + Address, + injected::{SignedCompactTxReceipt, TransactionHash}, network::VerifiedValidatorMessage, }; use lru::LruCache; @@ -84,7 +84,7 @@ enum VerifyTxReceiptError { #[display("unknown validator: address={address}, tx_hash={tx_hash}")] UnknownValidator { address: Address, - tx_hash: HashOf, + tx_hash: TransactionHash, }, } diff --git a/ethexe/rpc/client/Cargo.toml b/ethexe/rpc/client/Cargo.toml index 99df93085e9..aa6771b8fa5 100644 --- a/ethexe/rpc/client/Cargo.toml +++ b/ethexe/rpc/client/Cargo.toml @@ -17,6 +17,7 @@ ethexe-common = { workspace = true, features = ["std"] } ethexe-rpc-common.workspace = true ethexe-runtime-common = { workspace = true, features = ["std"] } gprimitives = { workspace = true, features = ["serde", "ethexe"] } +gear-tdec = { workspace = true, features = ["bls12_381"] } jsonrpsee = { workspace = true, features = ["client", "macros"] } sp-core = { workspace = true, features = ["serde"] } gear-workspace-hack.workspace = true diff --git a/ethexe/rpc/client/src/apis/injected/mod.rs b/ethexe/rpc/client/src/apis/injected/mod.rs index 445d115cf8b..c0338c77367 100644 --- a/ethexe/rpc/client/src/apis/injected/mod.rs +++ b/ethexe/rpc/client/src/apis/injected/mod.rs @@ -4,20 +4,24 @@ use ethexe_common::{ HashOf, injected::{ - InjectedTransaction, InjectedTransactionAcceptance, SignedInjectedTransaction, - SignedTxReceipt, + InjectedTransaction, SignedInjectedTransaction, SignedTxReceipt, Transaction, + TransactionAcceptance, }, }; +use gear_tdec::bls12_381::DkgPublicKey; use jsonrpsee::proc_macros::rpc; #[rpc(client, namespace = "injected")] pub trait Injected { + #[method(name = "getShieldingKey")] + async fn shielding_key(&self) -> jsonrpsee::core::RpcResult>; + /// Just sends an injected transaction. #[method(name = "sendTransaction")] async fn send_transaction( &self, - transaction: SignedInjectedTransaction, - ) -> jsonrpsee::core::RpcResult; + transaction: Transaction, + ) -> jsonrpsee::core::RpcResult; /// Sends an injected transaction and subscribes to its promise. #[subscription( @@ -27,7 +31,7 @@ pub trait Injected { )] async fn send_transaction_and_watch( &self, - transaction: SignedInjectedTransaction, + transaction: Transaction, ) -> jsonrpsee::core::SubscriptionResult; #[method(name = "getTransactionReceipt")] diff --git a/ethexe/rpc/server/Cargo.toml b/ethexe/rpc/server/Cargo.toml index 4566499801d..48b10720613 100644 --- a/ethexe/rpc/server/Cargo.toml +++ b/ethexe/rpc/server/Cargo.toml @@ -30,13 +30,13 @@ ethexe-runtime-common = { workspace = true, features = ["std"] } sp-core = { workspace = true, features = ["serde"] } gear-core = { workspace = true, features = ["std"] } tracing.workspace = true -dashmap.workspace = true metrics.workspace = true metrics-derive.workspace = true gear-workspace-hack.workspace = true thiserror.workspace = true scopeguard.workspace = true moka = { workspace = true, features = ["sync"] } +gear-tdec = { workspace = true, features = ["bls12_381"] } [dev-dependencies] ethexe-rpc-client.workspace = true @@ -45,7 +45,7 @@ ethexe-common = { workspace = true, features = ["std", "mock"] } ntest.workspace = true tracing-subscriber.workspace = true -ethexe-db = {workspace = true, features = ["mock"]} +ethexe-db = { workspace = true, features = ["mock"] } [lints] workspace = true diff --git a/ethexe/rpc/server/src/apis/injected/promise_manager.rs b/ethexe/rpc/server/src/apis/injected/promise_manager.rs index a2dcbeedb71..3e639f83959 100644 --- a/ethexe/rpc/server/src/apis/injected/promise_manager.rs +++ b/ethexe/rpc/server/src/apis/injected/promise_manager.rs @@ -2,33 +2,58 @@ // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 use anyhow::Result; -use dashmap::{DashMap, mapref::entry::Entry}; use ethexe_common::{ Address, HashOf, db::{ ConfigStorageRO, GlobalsStorageRO, InjectedStorageRO, InjectedStorageRW, OnChainStorageRO, }, injected::{ - InjectedTransaction, Promise, SignedCompactTxReceipt, SignedTxReceipt, - TryFillPromiseResult, UnfilledPromiseReceipt, UpgradedReceipt, + InjectedTransaction, Promise, ShieldedTransaction, SignedCompactTxReceipt, SignedTxReceipt, + TransactionHash, TryFillPromiseResult, UnfilledPromiseReceipt, UpgradedReceipt, }, }; use ethexe_db::Database; -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; use tokio::sync::oneshot; use tracing::{trace, warn}; -// TODO: #5385. -type PromiseSubscribers = - Arc, oneshot::Sender>>; type PendingReceiptsCache = moka::sync::Cache, UnfilledPromiseReceipt>; +#[derive(Debug)] +struct Subscriber { + registration_hash: TransactionHash, + sender: oneshot::Sender, +} + +/// Stores receipt subscribers under both their current routing hash and original registration hash. +/// +/// A shielded transaction is initially routed by [`TransactionHash::Right`]. After unshielding, +/// its subscriber is moved to the corresponding [`TransactionHash::Left`] entry while retaining +/// the original shielded registration hash for cancellation. +#[derive(Debug, Default)] +struct PromiseSubscribers { + /// Subscribers grouped by the hash under which an incoming receipt will currently arrive. + /// + /// Multiple subscribers can share an injected hash after a shielded subscriber is migrated + /// to a hash that already has a directly registered injected subscriber. + subscribers_by_receipt_hash: HashMap>, + /// Maps each original registration hash to its current receipt hash in + /// [`Self::subscribers_by_receipt_hash`]. + /// + /// For an unmigrated subscriber both hashes are identical. For a migrated shielded + /// subscriber this maps its shielded hash to the resulting injected hash. + receipt_hash_by_registration_hash: HashMap, +} + /// The manager for promise subscribers. #[derive(Debug, Clone)] pub struct PromiseSubscriptionManager { db: Database, - /// Active subscribers for injected transaction receipt ([SignedTxReceipt]). - subscribers: PromiseSubscribers, + /// Active subscribers for transaction receipts ([SignedTxReceipt]). + subscribers: Arc>, /// Cached [UnfilledPromiseReceipt] waiting for local [Promise] computation. pending_receipts: PendingReceiptsCache, } @@ -36,7 +61,7 @@ pub struct PromiseSubscriptionManager { #[derive(Debug, Clone, thiserror::Error)] pub enum RegisterSubscriberError { #[error("Subscriber for this transaction already exists, tx_hash={0}")] - AlreadyRegistered(HashOf), + AlreadyRegistered(TransactionHash), } type TimeoutReceiver = tokio::time::Timeout>; @@ -47,7 +72,7 @@ type TimeoutReceiver = tokio::time::Timeout>; /// Important: to avoid infinite waiting we wrap [oneshot::Receiver] into [tokio::time::timeout]. pub struct PendingSubscriber { /// Tx hash waiting promise for. - tx_hash: HashOf, + tx_hash: TransactionHash, /// Wrapped tx receipt [oneshot::Receiver]. receiver: TimeoutReceiver, } @@ -55,7 +80,7 @@ pub struct PendingSubscriber { impl PendingSubscriber { pub fn new( db: &Database, - tx_hash: HashOf, + tx_hash: TransactionHash, receiver: oneshot::Receiver, ) -> Self { let timeout_duration = utils::receipt_waiting_timeout(db); @@ -63,7 +88,7 @@ impl PendingSubscriber { Self { tx_hash, receiver } } - pub fn into_parts(self) -> (HashOf, TimeoutReceiver) { + pub fn into_parts(self) -> (TransactionHash, TimeoutReceiver) { (self.tx_hash, self.receiver) } } @@ -73,30 +98,106 @@ impl PromiseSubscriptionManager { Self { pending_receipts: utils::build_pending_receipts_cache(&db), db, - subscribers: PromiseSubscribers::default(), + subscribers: Arc::default(), } } // TODO: Issue #5402 pub fn try_register_subscriber( &self, - tx_hash: HashOf, + tx_hash: TransactionHash, ) -> Result { - match self.subscribers.entry(tx_hash) { - Entry::Occupied(_) => Err(RegisterSubscriberError::AlreadyRegistered(tx_hash)), - Entry::Vacant(entry) => { - let (sender, receiver) = oneshot::channel(); - entry.insert(sender); - Ok(PendingSubscriber::new(&self.db, tx_hash, receiver)) - } + let mut subscribers = self.subscribers.lock().expect("subscribers lock poisoned"); + if subscribers + .receipt_hash_by_registration_hash + .contains_key(&tx_hash) + { + return Err(RegisterSubscriberError::AlreadyRegistered(tx_hash)); } + + let (sender, receiver) = oneshot::channel(); + subscribers + .subscribers_by_receipt_hash + .entry(tx_hash) + .or_default() + .push(Subscriber { + registration_hash: tx_hash, + sender, + }); + subscribers + .receipt_hash_by_registration_hash + .insert(tx_hash, tx_hash); + + Ok(PendingSubscriber::new(&self.db, tx_hash, receiver)) } pub fn cancel_registration( &self, - tx_hash: HashOf, + tx_hash: TransactionHash, ) -> Option> { - self.subscribers.remove(&tx_hash).map(|(_, v)| v) + let mut subscribers = self.subscribers.lock().expect("subscribers lock poisoned"); + let receipt_hash = subscribers + .receipt_hash_by_registration_hash + .remove(&tx_hash)?; + let receipt_subscribers = subscribers + .subscribers_by_receipt_hash + .get_mut(&receipt_hash) + .expect("registered subscriber must exist"); + let position = receipt_subscribers + .iter() + .position(|subscriber| subscriber.registration_hash == tx_hash) + .expect("registered subscriber must exist under its receipt hash"); + let subscriber = receipt_subscribers.swap_remove(position); + if receipt_subscribers.is_empty() { + subscribers + .subscribers_by_receipt_hash + .remove(&receipt_hash); + } + Some(subscriber.sender) + } + + pub fn on_unshielded_transactions( + &self, + hash_mapping: Vec<(HashOf, HashOf)>, + ) { + let moved_to = { + let mut subscribers = self.subscribers.lock().expect("subscribers lock poisoned"); + let mut moved_to = Vec::new(); + + for (shielded_hash, injected_hash) in hash_mapping { + let registration_hash = TransactionHash::Right(shielded_hash); + let receipt_hash = TransactionHash::Left(injected_hash); + if subscribers + .receipt_hash_by_registration_hash + .get(®istration_hash) + != Some(®istration_hash) + { + continue; + } + + let moved = subscribers + .subscribers_by_receipt_hash + .remove(®istration_hash) + .expect("registered shielded subscriber must exist"); + subscribers + .subscribers_by_receipt_hash + .entry(receipt_hash) + .or_default() + .extend(moved); + subscribers + .receipt_hash_by_registration_hash + .insert(registration_hash, receipt_hash); + moved_to.push(injected_hash); + } + + moved_to + }; + + for injected_hash in moved_to { + if let Some(receipt) = self.db.receipt(injected_hash) { + self.dispatch_receipt(receipt); + } + } } // TODO: Issue #5403 @@ -160,11 +261,7 @@ impl PromiseSubscriptionManager { self.signer_is_known_validator(receipt.address(), receipt.data().tx_hash()) } - fn signer_is_known_validator( - &self, - address: Address, - tx_hash: HashOf, - ) -> bool { + fn signer_is_known_validator(&self, address: Address, tx_hash: TransactionHash) -> bool { let timestamp = self.db.globals().latest_synced_eb.header.timestamp; let timelines = self.db.config().timelines; @@ -207,21 +304,43 @@ impl PromiseSubscriptionManager { } fn dispatch_receipt(&self, receipt: SignedTxReceipt) { - if let Some((_, sender)) = self.subscribers.remove(&receipt.data().tx_hash()) - && let Err(unsent_receipt) = sender.send(receipt) - { - trace!("failed to send receipt to subscriber, receipt={unsent_receipt:?}"); + let senders = { + let mut subscribers = self.subscribers.lock().expect("subscribers lock poisoned"); + subscribers + .subscribers_by_receipt_hash + .remove(&receipt.data().tx_hash()) + .unwrap_or_default() + .into_iter() + .map(|subscriber| { + subscribers + .receipt_hash_by_registration_hash + .remove(&subscriber.registration_hash); + subscriber.sender + }) + .collect::>() + }; + + for sender in senders { + if let Err(unsent_receipt) = sender.send(receipt.clone()) { + trace!("failed to send receipt to subscriber, receipt={unsent_receipt:?}"); + } } } fn store_and_dispatch_receipt(&self, receipt: SignedTxReceipt) { - self.db.set_receipt(&receipt); + if matches!(receipt.data().tx_hash(), TransactionHash::Left(_)) { + self.db.set_receipt(&receipt); + } self.dispatch_receipt(receipt); } #[cfg(test)] pub fn subscribers_count(&self) -> usize { - self.subscribers.len() + self.subscribers + .lock() + .expect("subscribers lock poisoned") + .receipt_hash_by_registration_hash + .len() } } @@ -267,7 +386,7 @@ mod tests { Address, SignedMessage, ValidatorsVec, db::{GlobalsStorageRO, OnChainStorageRW, SetGlobals}, ecdsa::PrivateKey, - injected::{InjectedTransaction, Receipt}, + injected::{InjectedTransaction, PurgedTransaction, Receipt, TransactionPurgedReason}, mock::Mock, }; use gear_core::{message::ReplyCode, rpc::ReplyInfo}; @@ -303,7 +422,7 @@ mod tests { manager: &PromiseSubscriptionManager, tx_hash: HashOf, ) -> std::pin::Pin>> { - let pending = match manager.try_register_subscriber(tx_hash) { + let pending = match manager.try_register_subscriber(TransactionHash::Left(tx_hash)) { Ok(pending) => pending, Err(err) => panic!("first registration must succeed: {err}"), }; @@ -381,14 +500,120 @@ mod tests { async fn duplicate_subscriber_rejected() { let manager = PromiseSubscriptionManager::new(Database::memory()); let (promise, _) = make_promise(); - let _first = manager.try_register_subscriber(promise.tx_hash).ok(); + let tx_hash = TransactionHash::Left(promise.tx_hash); + let _first = manager.try_register_subscriber(tx_hash).ok(); let err = manager - .try_register_subscriber(promise.tx_hash) + .try_register_subscriber(tx_hash) .err() .expect("second registration must fail"); assert!(matches!(err, RegisterSubscriberError::AlreadyRegistered(_))); } + #[tokio::test] + async fn shielded_subscriber_migrates_to_injected_hash() { + let db = Database::memory(); + let manager = PromiseSubscriptionManager::new(db.clone()); + let (promise, private_key) = make_promise(); + let mut injected_receiver = register(&manager, promise.tx_hash); + let shielded_hash = HashOf::::random(); + let registration_hash = TransactionHash::Right(shielded_hash); + let pending = manager + .try_register_subscriber(registration_hash) + .expect("first registration must succeed"); + let (_, receiver) = pending.into_parts(); + let mut receiver = Box::pin(receiver.into_inner()); + + manager.on_unshielded_transactions(vec![(shielded_hash, promise.tx_hash)]); + assert_eq!(manager.subscribers_count(), 2); + + manager.on_computed_promise(promise.clone()); + let receipt = + SignedMessage::create(private_key, Receipt::Promise(promise.to_compact())).unwrap(); + set_current_validators(&db, vec![receipt.address()]); + manager.on_tx_receipt(receipt.into()); + + let expected = Receipt::Promise(promise); + assert_eq!(receiver.as_mut().await.unwrap().data(), &expected); + assert_eq!(injected_receiver.as_mut().await.unwrap().data(), &expected); + assert_eq!(manager.subscribers_count(), 0); + } + + #[tokio::test] + async fn migration_dispatches_receipt_that_arrived_under_injected_hash_first() { + let db = Database::memory(); + let manager = PromiseSubscriptionManager::new(db.clone()); + let (promise, private_key) = make_promise(); + let shielded_hash = HashOf::::random(); + let pending = manager + .try_register_subscriber(TransactionHash::Right(shielded_hash)) + .expect("first registration must succeed"); + let (_, receiver) = pending.into_parts(); + let mut receiver = Box::pin(receiver.into_inner()); + + manager.on_computed_promise(promise.clone()); + let receipt = + SignedMessage::create(private_key, Receipt::Promise(promise.to_compact())).unwrap(); + set_current_validators(&db, vec![receipt.address()]); + manager.on_tx_receipt(receipt.into()); + assert_eq!(manager.subscribers_count(), 1); + assert!(db.receipt(promise.tx_hash).is_some()); + + manager.on_unshielded_transactions(vec![(shielded_hash, promise.tx_hash)]); + + assert_eq!( + receiver.as_mut().await.unwrap().data(), + &Receipt::Promise(promise) + ); + assert_eq!(manager.subscribers_count(), 0); + } + + #[tokio::test] + async fn shielded_purge_receipt_dispatches_without_database_storage() { + let db = Database::memory(); + let manager = PromiseSubscriptionManager::new(db.clone()); + let shielded_hash = HashOf::::random(); + let registration_hash = TransactionHash::Right(shielded_hash); + let pending = manager + .try_register_subscriber(registration_hash) + .expect("first registration must succeed"); + let (_, receiver) = pending.into_parts(); + let mut receiver = Box::pin(receiver.into_inner()); + let purged = PurgedTransaction { + tx_hash: registration_hash, + reason: TransactionPurgedReason::DecryptionFailed, + }; + let receipt = SignedMessage::create( + PrivateKey::random(), + Receipt::::Purged(purged.clone()), + ) + .unwrap(); + set_current_validators(&db, vec![receipt.address()]); + + manager.on_tx_receipt(receipt.into()); + + assert_eq!( + receiver.as_mut().await.unwrap().data(), + &Receipt::Purged(purged) + ); + assert_eq!(manager.subscribers_count(), 0); + } + + #[tokio::test] + async fn migrated_shielded_subscriber_can_be_cancelled_by_original_hash() { + let manager = PromiseSubscriptionManager::new(Database::memory()); + let shielded_hash = HashOf::::random(); + let injected_hash = HashOf::::random(); + let registration_hash = TransactionHash::Right(shielded_hash); + let _pending = manager + .try_register_subscriber(registration_hash) + .expect("first registration must succeed"); + + manager.on_unshielded_transactions(vec![(shielded_hash, injected_hash)]); + + assert!(manager.cancel_registration(registration_hash).is_some()); + assert_eq!(manager.subscribers_count(), 0); + } + /// A compact promise whose signature does not match the body that /// arrives later is parked rather than delivering a malformed /// [`SignedTxReceipt`]. diff --git a/ethexe/rpc/server/src/apis/injected/relay.rs b/ethexe/rpc/server/src/apis/injected/relay.rs index f5d53760010..2c9cec7568b 100644 --- a/ethexe/rpc/server/src/apis/injected/relay.rs +++ b/ethexe/rpc/server/src/apis/injected/relay.rs @@ -7,7 +7,7 @@ //! validator in the current era and returns the first acceptance. use crate::{RpcEvent, errors}; -use ethexe_common::injected::{InjectedTransactionAcceptance, SignedInjectedTransaction}; +use ethexe_common::injected::{Transaction, TransactionAcceptance}; use jsonrpsee::core::RpcResult; use tokio::sync::{mpsc, oneshot}; @@ -23,33 +23,34 @@ impl TransactionsRelayer { /// Broadcast `transaction` to every validator in the current era, /// returning the first `Accept` observed by the service. - pub async fn relay( - &self, - transaction: SignedInjectedTransaction, - ) -> RpcResult { - let tx_hash = transaction.data().to_hash(); + pub async fn relay(&self, transaction: Transaction) -> RpcResult { + let tx_hash = transaction.as_ref().hash(); tracing::trace!(%tx_hash, ?transaction, "Called injected_sendTransaction with vars"); - if transaction.data().value != 0 { - tracing::warn!( - tx_hash = %tx_hash, - value = transaction.data().value, - "Injected transaction with non-zero value is not supported" - ); - return Err(errors::bad_request( - "Injected transactions with non-zero value are not supported", - )); + match &transaction { + Transaction::Injected(transaction) if transaction.data().value != 0 => { + tracing::warn!( + tx_hash = %tx_hash, + value = transaction.data().value, + "Injected transaction with non-zero value is not supported" + ); + return Err(errors::bad_request( + "Injected transactions with non-zero value are not supported", + )); + } + Transaction::Injected(_) => {} + Transaction::Shielded(_) => {} } let (response_sender, response_receiver) = oneshot::channel(); - let event = RpcEvent::InjectedTransaction { + let event = RpcEvent::Transaction { transaction, response_sender, }; if let Err(err) = self.rpc_sender.send(event) { tracing::error!( - "Failed to send `RpcEvent::InjectedTransaction` event task: {err}. \ + "Failed to send `RpcEvent::Transaction` event task: {err}. \ The receiving end in the main service might have been dropped." ); return Err(errors::internal()); diff --git a/ethexe/rpc/server/src/apis/injected/server.rs b/ethexe/rpc/server/src/apis/injected/server.rs index c34040e1fdc..b72093c2303 100644 --- a/ethexe/rpc/server/src/apis/injected/server.rs +++ b/ethexe/rpc/server/src/apis/injected/server.rs @@ -9,13 +9,14 @@ use super::{ }; use ethexe_common::{ HashOf, - db::InjectedStorageRO, + db::{InjectedStorageRO, TdecStorageRO}, injected::{ - InjectedTransaction, InjectedTransactionAcceptance, SignedInjectedTransaction, - SignedTxReceipt, + InjectedTransaction, ShieldedTransaction, SignedInjectedTransaction, SignedTxReceipt, + Transaction, TransactionAcceptance, }, }; use ethexe_db::Database; +use gear_tdec::bls12_381::DkgPublicKey; use jsonrpsee::{ core::{RpcResult, SubscriptionResult, async_trait}, server::PendingSubscriptionSink, @@ -37,17 +38,18 @@ pub struct InjectedApi { // TODO: Issue #5387 #[async_trait] impl InjectedServer for InjectedApi { - async fn send_transaction( - &self, - transaction: SignedInjectedTransaction, - ) -> RpcResult { + async fn shielding_key(&self) -> RpcResult> { + Ok(self.db.shielding_key()) + } + + async fn send_transaction(&self, transaction: Transaction) -> RpcResult { self.send_transaction(transaction).await } async fn send_transaction_and_watch( &self, pending: PendingSubscriptionSink, - transaction: SignedInjectedTransaction, + transaction: Transaction, ) -> SubscriptionResult { self.send_transaction_and_watch(pending, transaction).await } @@ -88,10 +90,7 @@ impl InjectedApi { // RPC API implementation. impl InjectedApi { - async fn send_transaction( - &self, - transaction: SignedInjectedTransaction, - ) -> RpcResult { + async fn send_transaction(&self, transaction: Transaction) -> RpcResult { self.relayer.relay(transaction).await } @@ -99,9 +98,9 @@ impl InjectedApi { async fn send_transaction_and_watch( &self, pending: PendingSubscriptionSink, - transaction: SignedInjectedTransaction, + transaction: Transaction, ) -> SubscriptionResult { - let tx_hash = transaction.data().to_hash(); + let tx_hash = transaction.as_ref().hash(); let pending_subscriber = match self.manager.try_register_subscriber(tx_hash) { Ok(subscriber) => subscriber, @@ -114,12 +113,10 @@ impl InjectedApi { self.manager.cancel_registration(tx_hash); })?; let sink = match acceptance { - InjectedTransactionAcceptance::Accept => { - pending.accept().await.inspect_err(|_err| { - self.manager.cancel_registration(tx_hash); - })? - } - InjectedTransactionAcceptance::Reject { reason } => { + TransactionAcceptance::Accept => pending.accept().await.inspect_err(|_err| { + self.manager.cancel_registration(tx_hash); + })?, + TransactionAcceptance::Reject { reason } => { self.manager.cancel_registration(tx_hash); return Err(reason.into()); } @@ -134,6 +131,13 @@ impl InjectedApi { Ok(()) } + pub fn on_unshielded_transactions( + &self, + hash_mapping: Vec<(HashOf, HashOf)>, + ) { + self.manager.on_unshielded_transactions(hash_mapping); + } + async fn get_transaction_receipt( &self, tx_hash: HashOf, @@ -173,7 +177,7 @@ mod tests { use super::*; use ethexe_common::{ Address, PrivateKey, SignedMessage, ValidatorsVec, - db::{GlobalsStorageRO, InjectedStorageRW, OnChainStorageRW, SetGlobals}, + db::{GlobalsStorageRO, InjectedStorageRW, OnChainStorageRW, SetGlobals, TdecStorageRW}, injected::{Promise, Receipt}, mock::Mock, }; @@ -270,6 +274,21 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_shielding_key_returns_stored_key() { + let db = Database::memory(); + let api = make_injected_api(db.clone()); + let dealer = gear_tdec::deal::( + 3, + 2, + &mut gear_tdec::rand_utils::test_rng(), + ); + + db.set_shielding_key(dealer.public_key); + + assert_eq!(api.shielding_key().await.unwrap(), Some(dealer.public_key)); + } + #[tokio::test] async fn test_get_transaction_receipt_returns_stored_receipt() { let db = Database::memory(); diff --git a/ethexe/rpc/server/src/apis/injected/spawner.rs b/ethexe/rpc/server/src/apis/injected/spawner.rs index 34ebe957f45..3c578353a7f 100644 --- a/ethexe/rpc/server/src/apis/injected/spawner.rs +++ b/ethexe/rpc/server/src/apis/injected/spawner.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 use super::promise_manager::PendingSubscriber; -use ethexe_common::{HashOf, injected::InjectedTransaction}; +use ethexe_common::injected::TransactionHash; use jsonrpsee::{SubscriptionMessage, SubscriptionSink}; use tracing::{error, trace, warn}; @@ -14,7 +14,7 @@ pub fn spawn_pending_subscriber( subscriber: PendingSubscriber, on_finish: F, ) where - F: FnOnce(HashOf) + std::marker::Send + 'static, + F: FnOnce(TransactionHash) + std::marker::Send + 'static, { let (tx_hash, receiver) = subscriber.into_parts(); diff --git a/ethexe/rpc/server/src/apis/injected/trait.rs b/ethexe/rpc/server/src/apis/injected/trait.rs index 67590da2e2b..a6f8384154a 100644 --- a/ethexe/rpc/server/src/apis/injected/trait.rs +++ b/ethexe/rpc/server/src/apis/injected/trait.rs @@ -4,20 +4,24 @@ use ethexe_common::{ HashOf, injected::{ - InjectedTransaction, InjectedTransactionAcceptance, SignedInjectedTransaction, - SignedTxReceipt, + InjectedTransaction, SignedInjectedTransaction, SignedTxReceipt, Transaction, + TransactionAcceptance, }, }; +use gear_tdec::bls12_381::DkgPublicKey; use jsonrpsee::proc_macros::rpc; #[rpc(server, namespace = "injected")] pub trait Injected { + #[method(name = "getShieldingKey")] + async fn shielding_key(&self) -> jsonrpsee::core::RpcResult>; + /// Just sends an injected transaction. #[method(name = "sendTransaction")] async fn send_transaction( &self, - transaction: SignedInjectedTransaction, - ) -> jsonrpsee::core::RpcResult; + transaction: Transaction, + ) -> jsonrpsee::core::RpcResult; /// Sends an injected transaction and subscribes to its promise. #[subscription( @@ -27,7 +31,7 @@ pub trait Injected { )] async fn send_transaction_and_watch( &self, - transaction: SignedInjectedTransaction, + transaction: Transaction, ) -> jsonrpsee::core::SubscriptionResult; #[method(name = "getTransactionReceipt")] diff --git a/ethexe/rpc/server/src/lib.rs b/ethexe/rpc/server/src/lib.rs index ec334c9bf4e..9d9c45b3de0 100644 --- a/ethexe/rpc/server/src/lib.rs +++ b/ethexe/rpc/server/src/lib.rs @@ -37,8 +37,12 @@ use apis::{ BestStateManager, BlockApi, BlockServer, CodeApi, CodeServer, DevApi, DevServer, InfoApi, InfoServer, InjectedApi, InjectedServer, ProgramApi, ProgramServer, }; -use ethexe_common::injected::{ - InjectedTransactionAcceptance, Promise, SignedCompactTxReceipt, SignedInjectedTransaction, +use ethexe_common::{ + HashOf, + injected::{ + InjectedTransaction, Promise, ShieldedTransaction, SignedCompactTxReceipt, Transaction, + TransactionAcceptance, + }, }; use ethexe_db::Database; use ethexe_processor::{Processor, ProcessorConfig}; @@ -72,9 +76,9 @@ pub const DEFAULT_BLOCK_GAS_LIMIT_MULTIPLIER: u64 = 10; #[derive(Debug)] pub enum RpcEvent { - InjectedTransaction { - transaction: SignedInjectedTransaction, - response_sender: oneshot::Sender, + Transaction { + transaction: Transaction, + response_sender: oneshot::Sender, }, } @@ -200,6 +204,13 @@ impl RpcService { self.injected_api.on_tx_receipt(receipt); } + pub fn receive_unshielded_transactions( + &self, + hash_mapping: Vec<(HashOf, HashOf)>, + ) { + self.injected_api.on_unshielded_transactions(hash_mapping); + } + pub fn receive_mb_computed(&self, mb_hash: H256) { self.best_state.notify(mb_hash); } diff --git a/ethexe/rpc/server/src/tests.rs b/ethexe/rpc/server/src/tests.rs index 14168f6b597..2af3e134561 100644 --- a/ethexe/rpc/server/src/tests.rs +++ b/ethexe/rpc/server/src/tests.rs @@ -2,8 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 use crate::{ - InjectedApi, InjectedTransactionAcceptance, RpcConfig, RpcEvent, RpcServer, RpcService, - test_utils::wasm_with_custom_section, + InjectedApi, RpcConfig, RpcEvent, RpcServer, RpcService, test_utils::wasm_with_custom_section, }; use ethexe_common::{ SignedMessage, ValidatorsVec, @@ -12,6 +11,7 @@ use ethexe_common::{ gear::MAX_BLOCK_GAS_LIMIT, injected::{ InjectedTransaction, Promise, Receipt, SignedCompactTxReceipt, SignedInjectedTransaction, + Transaction, TransactionAcceptance, }, mock::Mock, }; @@ -62,7 +62,7 @@ impl MockService { let mut tx_batch_interval = tokio::time::interval(std::time::Duration::from_millis(350)); - let mut tx_batch = Vec::new(); + let mut tx_batch = Vec::::new(); loop { tokio::select! { @@ -77,10 +77,13 @@ impl MockService { unreachable!("RPC server should not be stopped during the test") }, event = self.rpc.next() => { - let RpcEvent::InjectedTransaction {transaction, response_sender} = event.expect("RPC event will be valid"); + let RpcEvent::Transaction {transaction, response_sender} = event.expect("RPC event will be valid"); - response_sender.send(InjectedTransactionAcceptance::Accept).expect("Response sender will be valid"); - tx_batch.push(transaction); + response_sender.send(TransactionAcceptance::Accept).expect("Response sender will be valid"); + match transaction { + Transaction::Injected(transaction) => tx_batch.push(transaction), + Transaction::Shielded(_) => todo!("Shielded transaction execution"), + } }, } } @@ -203,7 +206,7 @@ async fn test_cleanup_promise_subscribers() { let mut subscribers = JoinSet::new(); for _ in 0..20 { let mut sub = ws_client - .send_transaction_and_watch(mock_signed_transaction()) + .send_transaction_and_watch(mock_signed_transaction().into()) .await .expect("Subscription will be created"); @@ -232,7 +235,7 @@ async fn test_cleanup_promise_subscribers() { let mut subscribers = JoinSet::new(); for _ in 0..20 { let mut subscription = ws_client - .send_transaction_and_watch(mock_signed_transaction()) + .send_transaction_and_watch(mock_signed_transaction().into()) .await .expect("Subscription will be created"); @@ -260,7 +263,7 @@ async fn test_cleanup_promise_subscribers() { let mut subscriptions = vec![]; for _ in 0..20 { let subscription = ws_client - .send_transaction_and_watch(mock_signed_transaction()) + .send_transaction_and_watch(mock_signed_transaction().into()) .await .expect("Subscription will be created"); subscriptions.push(subscription); @@ -297,7 +300,7 @@ async fn test_concurrent_multiple_clients() { let mut subscriptions = vec![]; for _ in 0..50 { let mut subscription = client - .send_transaction_and_watch(mock_signed_transaction()) + .send_transaction_and_watch(mock_signed_transaction().into()) .await .expect("Subscription will be created"); diff --git a/ethexe/sdk/src/mirror.rs b/ethexe/sdk/src/mirror.rs index b4c5c7fd1a0..e8bfab6a839 100644 --- a/ethexe/sdk/src/mirror.rs +++ b/ethexe/sdk/src/mirror.rs @@ -9,8 +9,7 @@ use ethexe_common::{ gear::ValueClaim, gear_core::rpc::ReplyInfo, injected::{ - InjectedTransaction, InjectedTransactionAcceptance, Promise, Receipt, - SignedInjectedTransaction, + InjectedTransaction, Promise, Receipt, SignedInjectedTransaction, TransactionAcceptance, }, }; use ethexe_ethereum::{ @@ -293,22 +292,22 @@ impl<'a> Mirror<'a> { let message_id = injected_transaction.to_message_id(); let tx_hash = injected_transaction.to_hash().into(); - let result: InjectedTransactionAcceptance = self + let result: TransactionAcceptance = self .api .vara_eth_client() - .send_transaction(transaction) + .send_transaction(transaction.into()) .await .with_context(|| "failed to send injected transaction")?; match result { - InjectedTransactionAcceptance::Accept => Ok(InjectedMessageResult { + TransactionAcceptance::Accept => Ok(InjectedMessageResult { message_id, tx_hash, reference_block_number, reference_block_hash, promise: None, }), - InjectedTransactionAcceptance::Reject { reason } => { + TransactionAcceptance::Reject { reason } => { Err(anyhow!("injected transaction was rejected: {reason}")) } } @@ -330,7 +329,7 @@ impl<'a> Mirror<'a> { let mut subscription = self .api .vara_eth_client() - .send_transaction_and_watch(transaction) + .send_transaction_and_watch(transaction.into()) .await .with_context(|| "failed to send injected transaction and subscribe to it's promise")?; diff --git a/ethexe/service/Cargo.toml b/ethexe/service/Cargo.toml index fcdbd136c60..f24b7929df2 100644 --- a/ethexe/service/Cargo.toml +++ b/ethexe/service/Cargo.toml @@ -28,6 +28,7 @@ ethexe-rpc-server.workspace = true gsigner = { workspace = true, features = ["std", "secp256k1", "codec", "keyring", "serde"] } gear-core.workspace = true gprimitives = { workspace = true, features = ["std", "ethexe"] } +gear-tdec.workspace = true log.workspace = true tracing.workspace = true @@ -73,6 +74,8 @@ jsonrpsee = { workspace = true, features = ["client"] } async-broadcast.workspace = true wat.workspace = true tempfile.workspace = true +rand.workspace = true +gsigner = { workspace = true, features = ["tdec"] } demo-ping = { workspace = true, features = ["debug", "ethexe"] } demo-value-sender-ethexe = { workspace = true, features = ["debug", "ethexe"] } diff --git a/ethexe/service/src/config.rs b/ethexe/service/src/config.rs index 03ebc2c127c..bcbd6cdfb95 100644 --- a/ethexe/service/src/config.rs +++ b/ethexe/service/src/config.rs @@ -7,8 +7,17 @@ use anyhow::Result; use ethexe_network::NetworkConfig; use ethexe_prometheus::PrometheusConfig; use ethexe_rpc_server::RpcConfig; -use gsigner::secp256k1::{Address, PublicKey}; -use std::{collections::BTreeMap, path::PathBuf, str::FromStr, time::Duration}; +use gear_tdec::bls12_381::DkgPublicKey; +use gsigner::{ + PublicDecryptionContext, + secp256k1::{Address, PublicKey}, +}; +use std::{ + collections::{BTreeMap, HashMap}, + path::PathBuf, + str::FromStr, + time::Duration, +}; #[derive(Debug)] pub struct Config { @@ -18,6 +27,7 @@ pub struct Config { pub malachite: MalachiteCliConfig, pub rpc: Option, pub prometheus: Option, + pub tdec: Option, } /// User-facing subset of [`ethexe_malachite::MalachiteServiceConfig`], @@ -144,3 +154,13 @@ pub struct EthereumConfig { pub eip1559_max_fee_per_gas_in_gwei: u128, pub blob_gas_multiplier: u128, } + +#[derive(Clone, Debug)] +pub struct ThresholdDecryptionCliConfig { + /// Decryption threshold parameter. + pub threshold: std::num::NonZeroUsize, + /// Validator's dkg public key. + pub dkg_public_key: DkgPublicKey, + /// Other validators public decryption contexts. + pub validators_contexts: Option>, +} diff --git a/ethexe/service/src/lib.rs b/ethexe/service/src/lib.rs index 4742d088b7f..e778dbc6b87 100644 --- a/ethexe/service/src/lib.rs +++ b/ethexe/service/src/lib.rs @@ -47,7 +47,7 @@ use ethexe_common::{ CodeAndIdUnchecked, PromiseEmissionMode, db::{GlobalsStorageRW, MbStorageRO}, gear::CodeState, - injected::{CompactPromise, InjectedTransactionAcceptance, Receipt}, + injected::{CompactPromise, Receipt, TransactionAcceptance}, network::VerifiedValidatorMessage, }; use ethexe_compute::{ComputeEvent, ComputeService}; @@ -58,7 +58,7 @@ use ethexe_db::{ use ethexe_ethereum::{EthereumBuilder, deploy::EthereumDeployer, router::RouterQuery}; use ethexe_malachite::{ InjectedTxMempool, MalachiteEvent, MalachiteServiceConfig, MalachiteServiceStarter, - ValidatorEntry, + ValidatorEntry, ValidatorTdecSetup, }; use ethexe_network::{ NetworkEvent, NetworkRuntimeConfig, NetworkService, TransportType, @@ -74,7 +74,10 @@ use ethexe_rpc_server::{RpcEvent, RpcServer}; use ethexe_service_utils::{OptionFuture as _, OptionStreamNext as _}; use futures::{FutureExt, StreamExt}; use gprimitives::{ActorId, CodeId, H256}; -use gsigner::secp256k1::{Address, PrivateKey, PublicKey, Secp256k1SignerExt, Signer}; +use gsigner::{ + TdecKeyStore, + secp256k1::{Address, PrivateKey, PublicKey, Secp256k1SignerExt, Signer}, +}; use std::{ collections::{BTreeMap, BTreeSet, HashMap}, num::NonZero, @@ -92,6 +95,7 @@ mod pending_tx; mod tests; #[derive(Debug, derive_more::From)] +#[allow(clippy::large_enum_variant)] pub enum Event { Compute(ComputeEvent), Consensus(ConsensusEvent), @@ -410,10 +414,18 @@ impl Service { ); let signer = Signer::fs(config.node.key_path.clone())?; + let tdec_store = TdecKeyStore::fs(config.node.key_path.clone())?; let validator_pub_key = Self::get_config_public_key(config.node.validator, &signer) .with_context(|| "failed to get validator private key")?; + let validator_tdec_setup = config.tdec.clone().map(|config| ValidatorTdecSetup { + threshold: config.threshold, + dkg_public_key: config.dkg_public_key, + validators_contexts: config.validators_contexts, + key_store: tdec_store, + }); + // TODO #4642: use validator session key let _validator_pub_key_session = Self::get_config_public_key(config.node.validator_session, &signer) @@ -515,6 +527,7 @@ impl Service { pub_key, mempool: InjectedTxMempool::new(db.clone()), signer: signer.clone(), + validator_tdec_setup, }); let role = validator_config @@ -823,10 +836,8 @@ impl Service { transaction, channel, } => { - let acceptance = malachite - .receive_injected_transaction(*transaction) - .await - .into(); + let acceptance = + malachite.receive_transaction(*transaction).await.into(); if let Err(err) = channel.send(acceptance) { tracing::error!( ?err, @@ -865,17 +876,15 @@ impl Service { log::trace!("Received RPC event: {event:?}"); match event { - RpcEvent::InjectedTransaction { + RpcEvent::Transaction { transaction, response_sender, } => { - let status = malachite - .receive_injected_transaction(transaction.clone()) - .await; - let local_acceptance = InjectedTransactionAcceptance::from(status); + let status = malachite.receive_transaction(transaction.clone()).await; + let local_acceptance = TransactionAcceptance::from(status); match local_acceptance { - acceptance @ InjectedTransactionAcceptance::Accept => { + acceptance @ TransactionAcceptance::Accept => { // local consensus handle transaction, no need to wait for other acceptances if let Err(err) = network.broadcast_injected_transaction(transaction) @@ -893,7 +902,7 @@ impl Service { } _ => { // local malachite rejected the transaction, wait for other acceptances - let tx_hash = transaction.data().to_hash(); + let tx_hash = transaction.as_ref().hash(); if let Some(pending) = network_injected_txs.get_mut(&tx_hash) { pending.add_response_sender(response_sender); continue; @@ -909,10 +918,9 @@ impl Service { network_injected_txs.insert(tx_hash, pending); } Err(err) => { - let acceptance = - InjectedTransactionAcceptance::Reject { - reason: err.to_string(), - }; + let acceptance = TransactionAcceptance::Reject { + reason: err.to_string(), + }; if let Err(err) = response_sender.send(acceptance) { tracing::error!( @@ -945,7 +953,6 @@ impl Service { mb_hash = %mb_hash, "Malachite: BlockProposal", ); - compute.compute_mb(mb_hash, ethexe_common::PromisePolicy::Enabled); } MalachiteEvent::BlockFinalized { @@ -999,6 +1006,42 @@ impl Service { } }); } + MalachiteEvent::UnshieldingOutput { + mb_hash, + unshielded_hash_mapping, + not_unshielded, + } => { + let Some(rpc) = rpc.as_ref() else { + tracing::trace!( + %mb_hash, + "can not handle unshielding output without RPC service" + ); + continue; + }; + + rpc.receive_unshielded_transactions(unshielded_hash_mapping); + + let Some(pub_key) = validator_pub_key else { + tracing::trace!( + %mb_hash, + "validator public key not found, can not sign failed unshielding receipts" + ); + continue; + }; + + not_unshielded.into_iter().for_each(|purged_tx| { + let receipt = Receipt::::Purged(purged_tx); + match signer.signed_message(pub_key, receipt, None) { + Ok(signed_receipt) => rpc.receive_tx_receipt(signed_receipt.into()), + Err(err) => { + tracing::error!( + %mb_hash, + "failed to sign unshielding receipt: {err}" + ); + } + } + }); + } }, Event::Prometheus(event) => match event { PrometheusEvent::CollectMetrics { libp2p_metrics } => { diff --git a/ethexe/service/src/pending_tx.rs b/ethexe/service/src/pending_tx.rs index fb083834958..bd5a505c026 100644 --- a/ethexe/service/src/pending_tx.rs +++ b/ethexe/service/src/pending_tx.rs @@ -1,7 +1,7 @@ // Copyright (C) Gear Technologies Inc. // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 -use ethexe_common::injected::InjectedTransactionAcceptance; +use ethexe_common::injected::TransactionAcceptance; use std::num::NonZeroUsize; use tokio::sync::oneshot; @@ -10,16 +10,16 @@ use tokio::sync::oneshot; /// Transaction senders waits for acceptance/reject from other validators in /// network. pub(super) struct PendingNetworkInjectedTx { - response_senders: Vec>, + response_senders: Vec>, pending_responses: usize, - last_reject: Option, + last_reject: Option, } impl PendingNetworkInjectedTx { pub(super) fn new( - response_sender: oneshot::Sender, + response_sender: oneshot::Sender, pending_responses: NonZeroUsize, - last_reject: Option, + last_reject: Option, ) -> Self { Self { response_senders: vec![response_sender], @@ -30,24 +30,22 @@ impl PendingNetworkInjectedTx { pub(super) fn add_response_sender( &mut self, - response_sender: oneshot::Sender, + response_sender: oneshot::Sender, ) { self.response_senders.push(response_sender); } - pub(super) fn into_response_senders( - self, - ) -> Vec> { + pub(super) fn into_response_senders(self) -> Vec> { self.response_senders } pub(super) fn record_response( &mut self, - acceptance: InjectedTransactionAcceptance, - ) -> Option { + acceptance: TransactionAcceptance, + ) -> Option { match acceptance { - InjectedTransactionAcceptance::Accept => Some(InjectedTransactionAcceptance::Accept), - rejection @ InjectedTransactionAcceptance::Reject { .. } => { + TransactionAcceptance::Accept => Some(TransactionAcceptance::Accept), + rejection @ TransactionAcceptance::Reject { .. } => { // Infallible because in case of `self.pending_responses == 0` returns `Some`. self.pending_responses = self.pending_responses.checked_sub(1).expect("infallible"); self.last_reject = Some(rejection); @@ -67,7 +65,7 @@ impl PendingNetworkInjectedTx { mod tests { use super::*; - fn response_sender() -> oneshot::Sender { + fn response_sender() -> oneshot::Sender { oneshot::channel().0 } @@ -79,9 +77,9 @@ mod tests { None, ); - let acceptance = pending.record_response(InjectedTransactionAcceptance::Accept); + let acceptance = pending.record_response(TransactionAcceptance::Accept); - assert_eq!(acceptance, Some(InjectedTransactionAcceptance::Accept)); + assert_eq!(acceptance, Some(TransactionAcceptance::Accept)); } #[test] @@ -89,24 +87,24 @@ mod tests { let mut pending = PendingNetworkInjectedTx::new( response_sender(), NonZeroUsize::new(2).expect("non-zero"), - Some(InjectedTransactionAcceptance::Reject { + Some(TransactionAcceptance::Reject { reason: "local".into(), }), ); - let acceptance = pending.record_response(InjectedTransactionAcceptance::Reject { + let acceptance = pending.record_response(TransactionAcceptance::Reject { reason: "remote-1".into(), }); assert_eq!(acceptance, None); - let acceptance = pending.record_response(InjectedTransactionAcceptance::Reject { + let acceptance = pending.record_response(TransactionAcceptance::Reject { reason: "remote-2".into(), }); assert_eq!( acceptance, - Some(InjectedTransactionAcceptance::Reject { + Some(TransactionAcceptance::Reject { reason: "remote-2".into() }) ); @@ -120,13 +118,13 @@ mod tests { None, ); - let acceptance = pending.record_response(InjectedTransactionAcceptance::Reject { + let acceptance = pending.record_response(TransactionAcceptance::Reject { reason: "remote".into(), }); assert_eq!( acceptance, - Some(InjectedTransactionAcceptance::Reject { + Some(TransactionAcceptance::Reject { reason: "remote".into() }) ); diff --git a/ethexe/service/src/tests/mod.rs b/ethexe/service/src/tests/mod.rs index 26465fc4651..2b2754dc446 100644 --- a/ethexe/service/src/tests/mod.rs +++ b/ethexe/service/src/tests/mod.rs @@ -23,7 +23,8 @@ use ethexe_common::{ }, gear::BatchCommitment, injected::{ - InjectedTransaction, InjectedTransactionAcceptance, Receipt, TransactionPurgedReason, + InjectedTransaction, Receipt, TransactionAcceptance, TransactionHash, + TransactionPurgedReason, }, mock::*, }; @@ -1881,17 +1882,17 @@ async fn send_injected_tx() { let acceptance = node1 .rpc_http_client() .unwrap() - .send_transaction(signed_tx.clone()) + .send_transaction(signed_tx.clone().into()) .await .expect("rpc server is set"); - assert_eq!(acceptance, InjectedTransactionAcceptance::Accept); + assert_eq!(acceptance, TransactionAcceptance::Accept); // Tx executable validation takes time, so wait for event. node1 .events() .find(|event| { - if let TestingEvent::Rpc(TestingRpcEvent::InjectedTransaction { transaction }) = event - && *transaction == signed_tx + if let TestingEvent::Rpc(TestingRpcEvent::Transaction { transaction }) = event + && transaction.as_injected() == Some(&signed_tx) { true } else { @@ -1947,7 +1948,7 @@ async fn injected_tx_purged_receipt() { let rpc_tx = env.signer.signed_message(pubkey, tx, None).unwrap(); let mut subscription = rpc_client - .send_transaction_and_watch(rpc_tx) + .send_transaction_and_watch(rpc_tx.into()) .await .expect("successfully subscribe for transaction receipt"); @@ -1964,7 +1965,7 @@ async fn injected_tx_purged_receipt() { subscription_receipt.data() ); }; - assert_eq!(purged.tx_hash, tx_hash); + assert_eq!(purged.tx_hash, TransactionHash::Left(tx_hash)); assert_eq!( purged.reason, TransactionPurgedReason::UnknownReferenceBlock @@ -2631,7 +2632,7 @@ async fn injected_tx_fungible_token() { .unwrap(); let mut subscription = rpc_client - .send_transaction_and_watch(rpc_tx) + .send_transaction_and_watch(rpc_tx.into()) .await .expect("successfully send transaction to RPC"); @@ -2665,7 +2666,10 @@ async fn injected_tx_fungible_token() { .await .expect("subscription produce value") .expect("no errors for correct injected transaction"); - assert_eq!(subscription_receipt.data().tx_hash(), mint_tx.to_hash()); + assert_eq!( + subscription_receipt.data().tx_hash(), + TransactionHash::Left(mint_tx.to_hash()) + ); let subscription_promise = subscription_receipt.data().clone().unwrap_promise(); assert_eq!(subscription_promise.reply.value, 0); assert_eq!( @@ -2737,7 +2741,7 @@ async fn injected_tx_fungible_token() { .expect("RPC WS client provide by node"); let mut subscription = ws_client - .send_transaction_and_watch(rpc_tx) + .send_transaction_and_watch(rpc_tx.into()) .await .expect("successfully subscribe for transaction promise"); @@ -2882,7 +2886,7 @@ async fn program_subscribe_best_state() { .unwrap(); let mut tx_subscription = rpc_client - .send_transaction_and_watch(rpc_tx) + .send_transaction_and_watch(rpc_tx.into()) .await .expect("successfully subscribe for injected transaction promise"); @@ -3046,7 +3050,7 @@ async fn injected_tx_fungible_token_over_network() { .await; let mut subscription = alice_rpc_client - .send_transaction_and_watch(rpc_tx) + .send_transaction_and_watch(rpc_tx.into()) .await .expect("successfully subscribe for transaction promise"); @@ -3090,6 +3094,448 @@ async fn injected_tx_fungible_token_over_network() { stop_nodes([alice_node, bob_node]).await; } +#[tokio::test] +#[ntest::timeout(30_000)] +async fn shielded_tx_fungible_token() { + init_logger(); + + let env_config = TestEnvConfig { + network: EnvNetworkConfig::Enabled, + ..Default::default() + }; + let mut env = TestEnv::new(env_config).await.unwrap(); + + let pubkey = env.validators[0].public_key; + let mut node = env + .new_node( + NodeConfig::default() + .service_rpc(8097) + .validator(env.validators[0]), + ) + .await; + node.start_service().await; + let rpc_client = node + .rpc_ws_client() + .await + .expect("RPC client provide by node"); + + // 1. Create Fungible token config + let token_config = demo_fungible_token::InitConfig { + name: "USD Tether".to_string(), + symbol: "USDT".to_string(), + decimals: 10, + initial_capacity: None, + }; + + // 2. Uploading code and creating program + let res = env + .upload_code(demo_fungible_token::WASM_BINARY) + .await + .unwrap() + .wait_for() + .await + .unwrap(); + + let code_id = res.code_id; + let res = env + .create_program(code_id, 500_000_000_000_000) + .await + .unwrap() + .wait_for() + .await + .unwrap(); + + let usdt_actor_id = res.program_id; + + // 3. Initialize program + let init_reply = env + .send_message(usdt_actor_id, &token_config.encode()) + .await + .unwrap() + .wait_for() + .await + .unwrap(); + + assert_eq!(init_reply.program_id, usdt_actor_id); + assert_eq!(init_reply.value, 0); + assert_eq!( + init_reply.code, + ReplyCode::Success(SuccessReplyReason::Auto) + ); + assert!( + init_reply.payload.is_empty(), + "Expect empty payload, because of initializing Fungible Token returns nothing" + ); + + tracing::info!("✅ Fungible token successfully initialized"); + + let shielding_key = rpc_client.shielding_key().await.unwrap().unwrap(); + + let amount: u128 = 5_000_000_000; + let mint_action = demo_fungible_token::FTAction::Mint(amount); + + let mint_tx = InjectedTransaction { + destination: usdt_actor_id, + payload: mint_action.encode().try_into().unwrap(), + value: 0, + reference_block: node.db.globals().latest_prepared_eb_hash, + salt: vec![1].try_into().unwrap(), + }; + let shielded = mint_tx + .shield(&shielding_key, &mut rand::thread_rng()) + .unwrap(); + let shielded_hash = shielded.to_hash(); + let signed_shielded_tx = env.signer.signed_message(pubkey, shielded, None).unwrap(); + let mut shielded_subscription = rpc_client + .send_transaction_and_watch(signed_shielded_tx.into()) + .await + .unwrap(); + + let mut node_events = node.events(); + node_events + .find_map_with_db(|db, event| { + let TestingEvent::Malachite(ethexe_malachite::MalachiteEvent::BlockFinalized { + mb_hash, + .. + }) = event + else { + return None; + }; + let compact = db.mb_compact_block(mb_hash)?; + let operations = db.operations(compact.operations_hash)?; + operations + .iter() + .filter_map(|op| op.as_shielded()) + .any(|tx| tx.data().to_hash() == shielded_hash) + .then_some(()) + }) + .await; + + let shielded_receipt = shielded_subscription.next().await.unwrap().unwrap(); + let shielded_promise = shielded_receipt.0.into_data().unwrap_promise(); + + let expected_event = demo_fungible_token::FTEvent::Transfer { + from: ActorId::new([0u8; 32]), + to: pubkey.to_address().into(), + amount, + }; + assert_eq!(shielded_promise.reply.payload, expected_event.encode()); + + // Send transfer transaction. + let random_actor = ActorId::new(H256::random().0); + let transfer_amount = 100_000; + let transfer_action = demo_fungible_token::FTAction::Transfer { + from: pubkey.to_address().into(), + to: random_actor, + amount: transfer_amount, + }; + let transfer_tx = InjectedTransaction { + destination: usdt_actor_id, + payload: transfer_action.encode().try_into().unwrap(), + value: 0, + reference_block: node.db.globals().latest_prepared_eb_hash, + salt: vec![1].try_into().unwrap(), + }; + + let signed_transfer_tx = env + .signer + .signed_message(pubkey, transfer_tx.clone(), None) + .unwrap(); + + let mut transfer_subscription = rpc_client + .send_transaction_and_watch(signed_transfer_tx.into()) + .await + .unwrap(); + + let transfer_receipt = transfer_subscription.next().await.unwrap().unwrap(); + let transfer_promise = transfer_receipt.0.into_data().unwrap_promise(); + + let expected_transfer = demo_fungible_token::FTEvent::Transfer { + from: pubkey.to_address().into(), + to: random_actor, + amount: transfer_amount, + }; + + assert_eq!(transfer_promise.reply.payload, expected_transfer.encode()); + stop_nodes([node]).await; +} + +#[tokio::test] +#[ntest::timeout(120_000)] +async fn shielded_tx_threshold_network_batch() { + init_logger(); + + // #1. Start a three-validator network so shielded transactions require threshold decryption. + let env_config = TestEnvConfig { + validators: ValidatorsConfig::PreDefined(3), + network: EnvNetworkConfig::Enabled, + ..Default::default() + }; + let mut env = TestEnv::new(env_config).await.unwrap(); + assert_eq!(env.threshold, 2); + + // #2. Run all validators, exposing RPC on Alice for user-submitted shielded txs. + let user_pubkey = env.signer.generate().unwrap(); + let validator_keys = env.validators.clone(); + let mut alice = env + .new_node( + NodeConfig::named("Alice") + .service_rpc(8098) + .validator(validator_keys[0]), + ) + .await; + alice.start_service().await; + let rpc_client = alice + .rpc_ws_client() + .await + .expect("RPC client provided by node"); + let mut bob = env + .new_node(NodeConfig::named("Bob").validator(validator_keys[1])) + .await; + bob.start_service().await; + let mut charlie = env + .new_node(NodeConfig::named("Charlie").validator(validator_keys[2])) + .await; + charlie.start_service().await; + + // #3. Deploy and initialize the fungible-token program through Ethereum events. + let token_config = demo_fungible_token::InitConfig { + name: "USD Tether".to_string(), + symbol: "USDT".to_string(), + decimals: 10, + initial_capacity: None, + }; + + let res = env + .upload_code(demo_fungible_token::WASM_BINARY) + .await + .unwrap() + .wait_for() + .await + .unwrap(); + let res = env + .create_program(res.code_id, 500_000_000_000_000) + .await + .unwrap() + .wait_for() + .await + .unwrap(); + let token_actor_id = res.program_id; + + let init_reply = env + .send_message(token_actor_id, &token_config.encode()) + .await + .unwrap() + .wait_for() + .await + .unwrap(); + assert_eq!( + init_reply.code, + ReplyCode::Success(SuccessReplyReason::Auto) + ); + + // #4. Fetch the public shielding key published by the validator service. + let shielding_key = rpc_client + .shielding_key() + .await + .unwrap() + .expect("validator RPC exposes threshold-decryption public key"); + assert_eq!(shielding_key, env.tdec_public_key); + + let sender = user_pubkey.to_address().into(); + let mint_amount: u128 = 5_000_000_000; + let bonus_mint_amount: u128 = 125_000; + let reference_block = alice.db.globals().latest_prepared_eb_hash; + + // #5. Build two independent token mints, then shield and sign both transactions. + let mint_tx = InjectedTransaction { + destination: token_actor_id, + payload: demo_fungible_token::FTAction::Mint(mint_amount) + .encode() + .try_into() + .unwrap(), + value: 0, + reference_block, + salt: b"shielded-batch-mint".to_vec().try_into().unwrap(), + }; + let bonus_mint_tx = InjectedTransaction { + destination: token_actor_id, + payload: demo_fungible_token::FTAction::Mint(bonus_mint_amount) + .encode() + .try_into() + .unwrap(), + value: 0, + reference_block, + salt: b"shielded-batch-bonus".to_vec().try_into().unwrap(), + }; + + let mint_hash = mint_tx.to_hash(); + let bonus_mint_hash = bonus_mint_tx.to_hash(); + let shielded_mint = mint_tx + .shield(&shielding_key, &mut rand::thread_rng()) + .unwrap(); + let shielded_bonus_mint = bonus_mint_tx + .shield(&shielding_key, &mut rand::thread_rng()) + .unwrap(); + let shielded_mint_hash = shielded_mint.to_hash(); + let shielded_bonus_mint_hash = shielded_bonus_mint.to_hash(); + + let signed_mint = env + .signer + .signed_message(user_pubkey, shielded_mint, None) + .unwrap(); + let signed_bonus_mint = env + .signer + .signed_message(user_pubkey, shielded_bonus_mint, None) + .unwrap(); + + // #6. Submit both shielded transactions through RPC and keep receipt subscriptions open. + let mut mint_subscription = rpc_client + .send_transaction_and_watch(signed_mint.into()) + .await + .expect("successfully subscribe for shielded mint"); + let mut bonus_mint_subscription = rpc_client + .send_transaction_and_watch(signed_bonus_mint.into()) + .await + .expect("successfully subscribe for second shielded mint"); + + // #7. Wait until one finalized MB includes both shielded transactions. + let mut alice_events = alice.events(); + let shielded_mb_hash = alice_events + .find_map_with_db(|db, event| { + let TestingEvent::Malachite(ethexe_malachite::MalachiteEvent::BlockFinalized { + mb_hash, + .. + }) = event + else { + return None; + }; + + let compact = db.mb_compact_block(mb_hash)?; + let operations = db.operations(compact.operations_hash)?; + let mut has_mint = false; + let mut has_bonus_mint = false; + for op in operations.iter().filter_map(|op| op.as_shielded()) { + has_mint |= op.data().to_hash() == shielded_mint_hash; + has_bonus_mint |= op.data().to_hash() == shielded_bonus_mint_hash; + } + + (has_mint && has_bonus_mint).then_some(mb_hash) + }) + .await; + + // #8. Wait for a later finalized MB to carry decryption keys and unshielded txs. + let decryption_keys_mb_hash = alice_events + .find_map_with_db(|db, event| { + let TestingEvent::Malachite(ethexe_malachite::MalachiteEvent::BlockFinalized { + mb_hash, + .. + }) = event + else { + return None; + }; + + let compact = db.mb_compact_block(mb_hash)?; + let operations = db.operations(compact.operations_hash)?; + let has_keys = operations.iter().any(|op| { + matches!( + op, + ethexe_common::malachite::Operation::DecryptionKeys(keys) + if keys.contains_key(&shielded_mint_hash) + && keys.contains_key(&shielded_bonus_mint_hash) + ) + }); + if !has_keys { + return None; + } + + let unshielded = db.mb_unshielded_txs(mb_hash); + let unshielded_hashes = unshielded + .iter() + .map(|tx| tx.data().to_hash()) + .collect::>(); + assert!(unshielded_hashes.contains(&mint_hash)); + assert!(unshielded_hashes.contains(&bonus_mint_hash)); + + Some(mb_hash) + }) + .await; + assert_ne!(shielded_mb_hash, decryption_keys_mb_hash); + + // #9. Check that both RPC subscriptions resolve under their unshielded hashes. + let mint_receipt = mint_subscription + .next() + .await + .expect("mint subscription produces receipt") + .expect("shielded mint succeeds"); + let mint_promise = mint_receipt.data().clone().unwrap_promise(); + assert_eq!( + mint_receipt.data().tx_hash(), + TransactionHash::Left(mint_hash) + ); + assert_eq!(mint_promise.tx_hash, mint_hash); + assert_eq!( + mint_promise.reply.code, + ReplyCode::Success(SuccessReplyReason::Manual) + ); + assert_eq!( + mint_promise.reply.payload, + demo_fungible_token::FTEvent::Transfer { + from: ActorId::new([0u8; 32]), + to: sender, + amount: mint_amount, + } + .encode() + ); + + let bonus_mint_receipt = bonus_mint_subscription + .next() + .await + .expect("second mint subscription produces receipt") + .expect("second shielded mint succeeds"); + let bonus_mint_promise = bonus_mint_receipt.data().clone().unwrap_promise(); + assert_eq!( + bonus_mint_receipt.data().tx_hash(), + TransactionHash::Left(bonus_mint_hash) + ); + assert_eq!(bonus_mint_promise.tx_hash, bonus_mint_hash); + assert_eq!( + bonus_mint_promise.reply.code, + ReplyCode::Success(SuccessReplyReason::Manual) + ); + assert_eq!( + bonus_mint_promise.reply.payload, + demo_fungible_token::FTEvent::Transfer { + from: ActorId::new([0u8; 32]), + to: sender, + amount: bonus_mint_amount, + } + .encode() + ); + + // #10. Verify computed promises and full receipts are persisted and queryable. + assert!(alice.db.promise(mint_hash).is_some()); + assert!(alice.db.promise(bonus_mint_hash).is_some()); + assert!(alice.db.receipt(mint_hash).is_some()); + assert!(alice.db.receipt(bonus_mint_hash).is_some()); + assert!( + rpc_client + .get_transaction_receipt(mint_hash) + .await + .unwrap() + .is_some() + ); + assert!( + rpc_client + .get_transaction_receipt(bonus_mint_hash) + .await + .unwrap() + .is_some() + ); + + stop_nodes([alice, bob, charlie]).await; +} + #[tokio::test] #[ntest::timeout(120_000)] async fn whole_network_restore() { diff --git a/ethexe/service/src/tests/utils/env.rs b/ethexe/service/src/tests/utils/env.rs index f3bb74b4f8e..3c476ebcec1 100644 --- a/ethexe/service/src/tests/utils/env.rs +++ b/ethexe/service/src/tests/utils/env.rs @@ -39,6 +39,7 @@ use ethexe_ethereum::{ }; use ethexe_malachite::{ InjectedTxMempool, MalachiteServiceConfig, MalachiteServiceStarter, ValidatorEntry, + ValidatorTdecSetup, }; use ethexe_network::{NetworkConfig, NetworkRuntimeConfig, NetworkService, export::Multiaddr}; use ethexe_observer::{ @@ -49,16 +50,21 @@ use ethexe_processor::{DEFAULT_CHUNK_SIZE, Processor}; use ethexe_rpc_server::{DEFAULT_BLOCK_GAS_LIMIT_MULTIPLIER, RpcConfig, RpcServer}; use futures::StreamExt; use gear_core_errors::ReplyCode; +use gear_tdec::bls12_381::{DkgPublicKey, E as Bls12_381}; use gprimitives::{ActorId, CodeId, H160, H256, MessageId}; -use gsigner::secp256k1::{Secp256k1SignerExt, Signer}; +use gsigner::{ + TdecKeyStore, + secp256k1::{Secp256k1SignerExt, Signer}, +}; use jsonrpsee::{ http_client::HttpClient, ws_client::{WsClient, WsClientBuilder}, }; use std::{ + collections::HashMap, fmt, mem, net::SocketAddr, - num::NonZero, + num::{NonZero, NonZeroUsize}, pin::Pin, sync::atomic::{AtomicUsize, Ordering}, time::Duration, @@ -81,6 +87,7 @@ pub struct TestEnv { pub ethereum: Ethereum, pub signer: Signer, pub validators: Vec, + pub tdec_public_key: DkgPublicKey, pub sender_id: ActorId, pub threshold: u64, pub continuous_block_generation: bool, @@ -91,6 +98,7 @@ pub struct TestEnv { pub kicking_per_blocks: Option, #[allow(unused)] pub db: Database, + validator_tdec_setups: HashMap, router_query: RouterQuery, /// In order to reduce amount of observers, we create only one observer and broadcast events to all subscribers. @@ -102,7 +110,85 @@ pub struct TestEnv { _anvil: Option, } +fn build_validator_tdec_setups( + validators: &[ValidatorConfig], + threshold: u64, +) -> (DkgPublicKey, HashMap) { + let threshold = usize::try_from(threshold).expect("TDEC threshold must fit usize"); + assert!( + threshold > 0 && threshold <= validators.len(), + "invalid TDEC threshold {threshold} for {} validators", + validators.len(), + ); + + let dealer = gear_tdec::deal::(validators.len(), threshold, &mut rand::thread_rng()); + let public_key = dealer.public_key; + let private_contexts = dealer.private_contexts; + let public_contexts = private_contexts + .first() + .expect("validator set must be non-empty") + .public_decryption_contexts + .iter() + .take(validators.len()) + .cloned() + .collect::>(); + assert_eq!(validators.len(), public_contexts.len()); + + let contexts: HashMap = validators + .iter() + .zip(&public_contexts) + .map(|(validator, context)| (validator.public_key.to_address(), context.clone())) + .collect(); + let threshold = NonZeroUsize::new(threshold).expect("threshold was checked above"); + let setups = validators + .iter() + .zip(private_contexts) + .map(|(validator, private_context)| { + let key_store = TdecKeyStore::memory(); + key_store + .import_decryption_key(private_context.validator_decryption_key) + .expect("dealer TDEC key must be importable"); + + ( + validator.public_key, + ValidatorTdecSetup { + threshold, + dkg_public_key: public_key, + validators_contexts: Some(contexts.clone()), + key_store, + }, + ) + }) + .collect(); + + (public_key, setups) +} + impl TestEnv { + fn ensure_validator_tdec_setups(&mut self) { + let setup_matches_active_validators = self.validators.first().is_some_and(|validator| { + self.validator_tdec_setups + .get(&validator.public_key) + .is_some_and(|setup| { + setup.validators_contexts.as_ref().unwrap().len() == self.validators.len() + && self.validators.iter().all(|validator| { + setup + .validators_contexts + .as_ref() + .unwrap() + .contains_key(&validator.public_key.to_address()) + }) + }) + }); + if setup_matches_active_validators { + return; + } + + let (public_key, setups) = build_validator_tdec_setups(&self.validators, self.threshold); + self.tdec_public_key = public_key; + self.validator_tdec_setups = setups; + } + pub async fn new(config: TestEnvConfig) -> anyhow::Result { let TestEnvConfig { validators, @@ -294,6 +380,8 @@ impl TestEnv { }; let threshold = router_query.validators_threshold().await?; + let (tdec_public_key, validator_tdec_setups) = + build_validator_tdec_setups(&validator_configs, threshold); let maybe_address = match network { EnvNetworkConfig::Enabled => None, @@ -360,6 +448,7 @@ impl TestEnv { ethereum, signer, validators: validator_configs, + tdec_public_key, sender_id: ActorId::from(H160::from(sender_address.0)), threshold, continuous_block_generation, @@ -369,6 +458,7 @@ impl TestEnv { post_quarantine_delay, kicking_per_blocks, db, + validator_tdec_setups, router_query, observer_events, bootstrap_network, @@ -424,9 +514,15 @@ impl TestEnv { // `BlockFinalized` and can compute MBs locally. let malachite_home = Some(tempfile::tempdir().expect("malachite home tempdir")); + self.ensure_validator_tdec_setups(); + // Snapshot env.validators now so a node spawned post-rotation boots with the new set. let active_validator_pub_keys: Vec = self.validators.iter().map(|v| v.public_key).collect(); + let validator_tdec_setup = validator_config + .as_ref() + .and_then(|config| self.validator_tdec_setups.get(&config.public_key)) + .cloned(); Node { name, @@ -441,6 +537,7 @@ impl TestEnv { signer: self.signer.clone(), threshold: self.threshold, validator_config, + validator_tdec_setup, network_public_key, network_address, network_bootstrap_address, @@ -926,6 +1023,7 @@ pub struct Node { signer: Signer, threshold: u64, validator_config: Option, + validator_tdec_setup: Option, network_public_key: PublicKey, network_address: String, network_bootstrap_address: String, @@ -1077,6 +1175,7 @@ impl Node { pub_key: c.public_key, mempool: InjectedTxMempool::new(self.db.clone()), signer: self.signer.clone(), + validator_tdec_setup: self.validator_tdec_setup.clone(), }); MalachiteServiceStarter::new( @@ -1455,3 +1554,34 @@ pub async fn stop_nodes(nodes: impl IntoIterator) { drop(node); } } + +#[test] +fn validator_tdec_setups_create_decryption_shares() { + let validators = (0..3) + .map(|_| { + let public_key = gsigner::secp256k1::PrivateKey::random().public_key(); + ValidatorConfig { + public_key, + session_public_key: public_key, + } + }) + .collect::>(); + let (public_key, setups) = build_validator_tdec_setups(&validators, 2); + let ciphertext = + gear_tdec::encrypt_raw::(b"test", b"aad", &public_key, &mut rand::thread_rng()) + .expect("test payload must be encrypted"); + + for validator in validators { + let setup = setups + .get(&validator.public_key) + .expect("each validator must have a TDEC setup"); + let contexts = setup.validators_contexts.clone().unwrap(); + assert_eq!(contexts.len(), setups.len()); + assert!(contexts.contains_key(&validator.public_key.to_address())); + let my_context = contexts.get(&validator.public_key.to_address()).unwrap(); + setup + .key_store + .create_share(my_context, &ciphertext.header(), b"aad") + .expect("validator must create a decryption share"); + } +} diff --git a/ethexe/service/src/tests/utils/events.rs b/ethexe/service/src/tests/utils/events.rs index 49b1df3e121..8aa627ef5c0 100644 --- a/ethexe/service/src/tests/utils/events.rs +++ b/ethexe/service/src/tests/utils/events.rs @@ -8,13 +8,10 @@ use alloy::providers::{RootProvider, ext::AnvilApi}; use async_broadcast::{Receiver, RecvError, Sender}; use ethexe_blob_loader::BlobLoaderEvent; use ethexe_common::{ - Address, HashOf, SimpleBlockData, + Address, SimpleBlockData, db::*, events::BlockEvent, - injected::{ - InjectedTransaction, InjectedTransactionAcceptance, SignedCompactTxReceipt, - SignedInjectedTransaction, - }, + injected::{SignedCompactTxReceipt, Transaction, TransactionAcceptance, TransactionHash}, network::VerifiedValidatorMessage, }; use ethexe_compute::ComputeEvent; @@ -42,14 +39,15 @@ pub type TestingEventReceiver = KickingStream>; pub type ObserverEventSender = EventSender; pub type ObserverEventReceiver = KickingStream>; +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Eq, PartialEq)] pub enum TestingNetworkInjectedEvent { InboundTransaction { - transaction: SignedInjectedTransaction, + transaction: Transaction, }, OutboundAcceptance { - transaction_hash: HashOf, - acceptance: InjectedTransactionAcceptance, + transaction_hash: TransactionHash, + acceptance: TransactionAcceptance, }, } @@ -61,7 +59,7 @@ impl TestingNetworkInjectedEvent { transaction, channel: _, } => Self::InboundTransaction { - transaction: SignedInjectedTransaction::clone(transaction), + transaction: transaction.as_ref().clone(), }, NetworkInjectedEvent::OutboundAcceptance { transaction_hash, @@ -74,6 +72,7 @@ impl TestingNetworkInjectedEvent { } } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Eq, PartialEq)] pub enum TestingNetworkEvent { ValidatorMessage(VerifiedValidatorMessage), @@ -103,18 +102,16 @@ impl TestingNetworkEvent { #[derive(Debug, Clone, Eq, PartialEq)] pub enum TestingRpcEvent { - InjectedTransaction { - transaction: SignedInjectedTransaction, - }, + Transaction { transaction: Transaction }, } impl TestingRpcEvent { fn new(event: &RpcEvent) -> Self { match event { - RpcEvent::InjectedTransaction { + RpcEvent::Transaction { transaction, response_sender: _, - } => Self::InjectedTransaction { + } => Self::Transaction { transaction: transaction.clone(), }, } diff --git a/ethexe/service/tests/smoke.rs b/ethexe/service/tests/smoke.rs index e5afb5f6acd..eae546d09f3 100644 --- a/ethexe/service/tests/smoke.rs +++ b/ethexe/service/tests/smoke.rs @@ -97,6 +97,7 @@ async fn constructor() { malachite: config::MalachiteCliConfig { validator_pub_keys }, rpc: None, prometheus: None, + tdec: None, }; let service = Service::new(&config).await.unwrap(); diff --git a/protocol/gsigner/Cargo.toml b/protocol/gsigner/Cargo.toml index 5fb8e15d729..e723881d50e 100644 --- a/protocol/gsigner/Cargo.toml +++ b/protocol/gsigner/Cargo.toml @@ -34,6 +34,7 @@ k256 = { version = "0.13.4", default-features = false, features = [ ], optional = true } nacl = { workspace = true, optional = true } dirs = { workspace = true, optional = true } +gear-tdec = { workspace = true, optional = true, features = ["bls12_381"] } parity-scale-codec = { workspace = true, default-features = false, features = [ "derive", ], optional = true } @@ -73,14 +74,7 @@ serde_json = { workspace = true, features = ["std"] } tempfile = { workspace = true } [features] -default = [ - "std", - "secp256k1", - "sr25519", - "ed25519", - "codec", - "keyring", -] +default = ["std", "secp256k1", "sr25519", "ed25519", "codec", "keyring"] std = [ "dep:anyhow", "dep:rand", @@ -108,6 +102,7 @@ codec = ["dep:parity-scale-codec", "dep:scale-info"] keyring = ["std", "serde", "dep:nacl"] serde = ["dep:serde"] peer-id = ["dep:libp2p-identity"] +tdec = ["serde", "dep:gear-tdec"] [package.metadata.cargo-shear] # we need it for applying full_crypto feature diff --git a/protocol/gsigner/src/lib.rs b/protocol/gsigner/src/lib.rs index b65469de14e..ece8258c6ee 100644 --- a/protocol/gsigner/src/lib.rs +++ b/protocol/gsigner/src/lib.rs @@ -52,6 +52,8 @@ pub mod scheme; pub mod schemes; #[cfg(all(feature = "std", feature = "keyring", feature = "serde"))] pub mod signer; +#[cfg(feature = "tdec")] +pub mod tdec; pub mod utils; #[cfg(feature = "cli")] @@ -84,6 +86,8 @@ pub use scheme::KeystoreOps; pub use signer::Signer; #[cfg(all(feature = "std", feature = "keyring"))] pub use storage::{FilesystemBackend, MemoryBackend, StorageBackend, StorageError, StorageResult}; +#[cfg(feature = "tdec")] +pub use {crate::tdec::*, gear_tdec::bls12_381::DecryptionShareSimple as DecryptionShare}; #[cfg(feature = "secp256k1")] pub use schemes::secp256k1::{ diff --git a/protocol/gsigner/src/schemes/secp256k1/keys.rs b/protocol/gsigner/src/schemes/secp256k1/keys.rs index 5e360d58b4b..6d96224c905 100644 --- a/protocol/gsigner/src/schemes/secp256k1/keys.rs +++ b/protocol/gsigner/src/schemes/secp256k1/keys.rs @@ -11,6 +11,7 @@ use alloc::{format, vec::Vec}; use core::{fmt, str::FromStr}; use derive_more::{From, Into}; use k256::ecdsa::VerifyingKey; +use parity_scale_codec::{Decode, Encode}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; use sp_core::{ @@ -115,7 +116,7 @@ impl<'de> Deserialize<'de> for PrivateKey { } /// secp256k1 public key backed by `sp_core::ecdsa::Public` (compressed form). -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, From, Into)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, From, Into, Encode, Decode)] pub struct PublicKey(SpPublic); impl PublicKey { diff --git a/protocol/gsigner/src/schemes/secp256k1/signature.rs b/protocol/gsigner/src/schemes/secp256k1/signature.rs index 14d717e0752..780bbcb2a4d 100644 --- a/protocol/gsigner/src/schemes/secp256k1/signature.rs +++ b/protocol/gsigner/src/schemes/secp256k1/signature.rs @@ -375,7 +375,7 @@ where } /// A signature verified data structure with the data and recovered public key. -#[derive(Clone, PartialEq, Eq, Debug, Display, Hash)] +#[derive(Clone, PartialEq, Eq, Debug, Display, Hash, Encode, Decode)] #[display("ValidatedData({data}, {public_key})")] pub struct VerifiedData { data: T, @@ -389,6 +389,12 @@ impl VerifiedData { VerifiedData { data, public_key } } + pub fn try_map(self, f: impl FnOnce(T) -> Result) -> Result, E> { + let Self { data, public_key } = self; + let data = f(data)?; + Ok(VerifiedData { data, public_key }) + } + pub fn data(&self) -> &T { &self.data } diff --git a/protocol/gsigner/src/tdec/mod.rs b/protocol/gsigner/src/tdec/mod.rs new file mode 100644 index 00000000000..797658afd28 --- /dev/null +++ b/protocol/gsigner/src/tdec/mod.rs @@ -0,0 +1,24 @@ +// Copyright (C) Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +//! Threshold-decryption key storage. +//! +//! This module stores validator threshold-decryption private material separately +//! from signing schemes. It intentionally does not implement [`crate::CryptoScheme`]: +//! these keys create decryption shares, not signatures. + +pub type Bls12_381 = gear_tdec::bls12_381::E; +pub type TdecPublicKey = gear_tdec::keypair_common::PublicKey; +pub type TdecKeypair = gear_tdec::keypair_common::Keypair; +pub type TdecDecryptionKey = gear_tdec::DomainPoint; +pub type BlindedKeyShare = gear_tdec::BlindedKeyShare; +pub type PublicDecryptionContext = gear_tdec::PublicDecryptionContextSimple; + +#[cfg(all(feature = "std", feature = "keyring", feature = "serde"))] +pub mod store; + +#[cfg(all(feature = "std", feature = "keyring", feature = "serde"))] +pub use store::{TdecKeyEntry, TdecKeyStore}; + +#[cfg(all(test, feature = "std", feature = "keyring", feature = "serde"))] +mod tests; diff --git a/protocol/gsigner/src/tdec/store.rs b/protocol/gsigner/src/tdec/store.rs new file mode 100644 index 00000000000..057b23c807a --- /dev/null +++ b/protocol/gsigner/src/tdec/store.rs @@ -0,0 +1,299 @@ +// Copyright (C) Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +use super::{ + BlindedKeyShare, PublicDecryptionContext, TdecDecryptionKey, TdecKeypair, TdecPublicKey, +}; +use crate::{ + error::{Result, SignerError}, + keyring::{self, KeystoreEntry}, +}; +use gear_tdec::{ + bls12_381::{CiphertextHeader, DecryptionShareSimple as DecryptionShare}, + keypair_common::{from_bytes, to_bytes}, +}; +use hex::ToHex; +use serde::{Deserialize, Serialize}; +use std::{ + fmt, + path::PathBuf, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, +}; +use tempfile::TempDir; + +const NAMESPACE_TDEC: &str = "tdec"; + +/// JSON keyring entry for one validator threshold-decryption key. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TdecKeyEntry { + pub name: String, + pub public_key: String, + pub validator_decryption_key: String, +} + +impl TdecKeyEntry { + fn from_keypair(name: &str, keypair: &TdecKeypair) -> Result { + Ok(Self { + name: name.to_string(), + public_key: encode_public_key(&keypair.public_key())?, + validator_decryption_key: encode_decryption_key(&keypair.decryption_key)?, + }) + } + + fn public_key(&self) -> Result { + decode_public_key(&self.public_key) + } + + fn keypair(&self) -> Result { + Ok(TdecKeypair { + decryption_key: decode_decryption_key(&self.validator_decryption_key)?, + }) + } +} + +impl KeystoreEntry for TdecKeyEntry { + fn name(&self) -> &str { + &self.name + } + + fn set_name(&mut self, name: &str) { + self.name = name.to_string(); + } +} + +/// Store for validator threshold-decryption keys. +/// +/// `TdecKeyStore` keeps only the validator's private decryption scalar and the +/// corresponding public key. It does not store +/// [`gear_tdec::PrivateDecryptionContextSimple`]; callers should keep or obtain +/// [`PublicDecryptionContext`] separately and pass it to [`Self::create_share`]. +/// +/// Typical usage: +/// +/// 1. Import the local validator's `validator_decryption_key` with +/// [`Self::import_decryption_key`]. +/// 2. Receive or load a [`PublicDecryptionContext`] containing +/// `validator_public_key` and `blinded_key_share`. +/// 3. Call [`Self::create_share`] with the public context, ciphertext header, +/// and AAD. The store finds the matching local private scalar by public key +/// and creates a [`TdecDecryptionShare`]. +#[derive(Clone)] +pub struct TdecKeyStore { + keyring: Arc>>, + _tmp_dir: Option>, +} + +impl fmt::Debug for TdecKeyStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TdecKeyStore") + .field("keys", &self.list_public_keys().ok()) + .finish() + } +} + +impl TdecKeyStore { + /// Create a store from an existing keyring backend. + pub fn new(keyring: keyring::Keyring) -> Self { + Self { + keyring: Arc::new(RwLock::new(keyring)), + _tmp_dir: None, + } + } + + fn with_tempdir(keyring: keyring::Keyring, tmp_dir: Option) -> Self { + Self { + keyring: Arc::new(RwLock::new(keyring)), + _tmp_dir: tmp_dir.map(Arc::new), + } + } + + /// Create an in-memory store. + /// + /// This is useful for tests and short-lived processes. Keys are not + /// persisted. + pub fn memory() -> Self { + let keyring = keyring::Keyring::try_memory().expect("memory keyring should not fail"); + Self::new(keyring) + } + + /// Load or create a filesystem-backed store under the `tdec` namespace. + pub fn fs(path: PathBuf) -> Result { + let keyring = keyring::Keyring::load(Self::namespaced_path(path))?; + Ok(Self::new(keyring)) + } + + /// Create a temporary filesystem-backed store. + /// + /// The temporary directory is held for the lifetime of the returned store. + pub fn fs_temporary() -> Result { + let temp_dir = tempfile::tempdir()?; + let keyring = keyring::Keyring::load(Self::namespaced_path(temp_dir.path().to_path_buf()))?; + Ok(Self::with_tempdir(keyring, Some(temp_dir))) + } + + /// Return the path used by the TDEC keyring namespace. + pub fn namespaced_path(path: PathBuf) -> PathBuf { + keyring::Keyring::::namespaced_path(path, NAMESPACE_TDEC) + } + + /// Store a validator decryption scalar and return its derived public key. + /// + /// This is the preferred import path when the caller already has the + /// validator private TDEC scalar but does not want to keep a full private + /// decryption context in memory. + pub fn import_decryption_key( + &self, + validator_decryption_key: TdecDecryptionKey, + ) -> Result { + let keypair = TdecKeypair { + decryption_key: validator_decryption_key, + }; + self.import_keypair(keypair) + } + + /// Store a full TDEC keypair and return its public key. + /// + /// Only the decryption scalar and public key are persisted. + pub fn import_keypair(&self, keypair: TdecKeypair) -> Result { + let public_key = keypair.public_key(); + let name = Self::key_name(&public_key)?; + let keystore = TdecKeyEntry::from_keypair(&name, &keypair)?; + self.keyring_mut()?.store(&name, keystore)?; + Ok(public_key) + } + + /// Get the private validator decryption scalar by public key. + pub fn validator_decryption_key( + &self, + public_key: &TdecPublicKey, + ) -> Result { + Ok(self.keypair(public_key)?.decryption_key) + } + + /// Reconstruct the TDEC keypair for the given public key. + /// + /// The keypair is reconstructed from the stored private scalar. This method + /// returns [`SignerError::KeyNotFound`] when the store has no matching + /// public key. + pub fn keypair(&self, public_key: &TdecPublicKey) -> Result { + let storage = self.keyring()?; + for keystore in storage.list() { + if keystore.public_key()? == *public_key { + return keystore.keypair(); + } + } + Err(SignerError::KeyNotFound(format!("{public_key}"))) + } + + /// Create a decryption share for a public decryption context. + /// + /// The store uses `public_context.validator_public_key` to find the local + /// validator private scalar, combines it with + /// `public_context.blinded_key_share`, and delegates share creation to + /// `gear-tdec`. + pub fn create_share( + &self, + public_context: &PublicDecryptionContext, + ciphertext_header: &CiphertextHeader, + aad: &[u8], + ) -> Result { + self.create_share_with_blinded_key( + &public_context.validator_public_key, + &public_context.blinded_key_share, + ciphertext_header, + aad, + ) + } + + /// Create a decryption share from an explicit public key and blinded share. + /// + /// Use this when the caller already split the fields out of a public + /// decryption context. + pub fn create_share_with_blinded_key( + &self, + public_key: &TdecPublicKey, + blinded_key_share: &BlindedKeyShare, + ciphertext_header: &CiphertextHeader, + aad: &[u8], + ) -> Result { + let keypair = self.keypair(public_key)?; + blinded_key_share + .create_decryption_share_simple(ciphertext_header, aad, &keypair) + .map_err(|err| SignerError::Crypto(err.to_string())) + } + + /// Return whether the store contains a key for the given public key. + pub fn has_key(&self, public_key: &TdecPublicKey) -> Result { + let storage = self.keyring()?; + for keystore in storage.list() { + if keystore.public_key()? == *public_key { + return Ok(true); + } + } + Ok(false) + } + + /// List all public TDEC keys known by the store. + pub fn list_public_keys(&self) -> Result> { + self.keyring()? + .list() + .iter() + .map(TdecKeyEntry::public_key) + .collect() + } + + /// Remove all TDEC keys from the store. + pub fn clear_keys(&self) -> Result<()> { + let mut storage = self.keyring_mut()?; + let names: Vec = storage + .list() + .iter() + .map(|keystore| keystore.name().to_string()) + .collect(); + for name in names { + storage.remove(&name)?; + } + Ok(()) + } + + fn keyring(&self) -> Result>> { + self.keyring + .read() + .map_err(|err| SignerError::Other(format!("Failed to acquire read lock: {err}"))) + } + + fn keyring_mut(&self) -> Result>> { + self.keyring + .write() + .map_err(|err| SignerError::Other(format!("Failed to acquire write lock: {err}"))) + } + + fn key_name(public_key: &TdecPublicKey) -> Result { + let key_bytes = public_key + .to_bytes() + .map_err(|err| SignerError::Serialization(err.to_string()))?; + Ok(format!("key-{}", key_bytes.encode_hex::())) + } +} + +fn encode_public_key(public_key: &TdecPublicKey) -> Result { + Ok(hex::encode(public_key.to_bytes().map_err(|err| { + SignerError::Serialization(err.to_string()) + })?)) +} + +fn decode_public_key(encoded: &str) -> Result { + let bytes = hex::decode(encoded)?; + TdecPublicKey::from_bytes(&bytes).map_err(|err| SignerError::InvalidKey(err.to_string())) +} + +fn encode_decryption_key(key: &TdecDecryptionKey) -> Result { + Ok(hex::encode(to_bytes(key).map_err(|err| { + SignerError::Serialization(err.to_string()) + })?)) +} + +fn decode_decryption_key(encoded: &str) -> Result { + let bytes = hex::decode(encoded)?; + from_bytes(&bytes).map_err(|err| SignerError::InvalidKey(err.to_string())) +} diff --git a/protocol/gsigner/src/tdec/tests.rs b/protocol/gsigner/src/tdec/tests.rs new file mode 100644 index 00000000000..6d0f4c4dce5 --- /dev/null +++ b/protocol/gsigner/src/tdec/tests.rs @@ -0,0 +1,40 @@ +// Copyright (C) Gear Technologies Inc. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +pub use crate::*; + +#[test] +fn imports_and_gets_validator_decryption_key_by_public_key() { + let mut rng = gear_tdec::rand_utils::test_rng(); + let keypair = TdecKeypair::new(&mut rng); + let store = TdecKeyStore::memory(); + + let public_key = store.import_keypair(keypair).unwrap(); + assert!(store.has_key(&public_key).unwrap()); + assert_eq!( + store.validator_decryption_key(&public_key).unwrap(), + keypair.decryption_key + ); +} + +#[test] +fn creates_decryption_share_from_public_context() { + let mut rng = gear_tdec::rand_utils::test_rng(); + let dealer = gear_tdec::deal::(3, 2, &mut rng); + let context = dealer.private_contexts[0].clone(); + let public_context = context.public_decryption_contexts[context.index].clone(); + let ciphertext = + gear_tdec::encrypt_raw::(b"hello", b"aad", &dealer.public_key, &mut rng) + .unwrap(); + let header = ciphertext.header(); + let store = TdecKeyStore::memory(); + store + .import_decryption_key(context.validator_decryption_key) + .unwrap(); + + let expected = context.create_share(&header, b"aad").unwrap(); + let actual = store + .create_share(&public_context, &header, b"aad") + .unwrap(); + assert_eq!(actual, expected); +} diff --git a/sdk/gtest/src/builtins/bls12_381.rs b/sdk/gtest/src/builtins/bls12_381.rs index 180962f5186..a0f6e3e36fc 100644 --- a/sdk/gtest/src/builtins/bls12_381.rs +++ b/sdk/gtest/src/builtins/bls12_381.rs @@ -63,7 +63,7 @@ mod tests { G2Projective as G2, }; use ark_ec::{ - Group, ScalarMul, VariableBaseMSM, + PrimeGroup as Group, ScalarMul, VariableBaseMSM, bls12::Bls12Config as Bls12ConfigTrait, hashing::{HashToCurve, curve_maps::wb, map_to_curve_hasher::MapToCurveBasedHasher}, pairing::Pairing, diff --git a/vara/pallets/gear-builtin/src/benchmarking.rs b/vara/pallets/gear-builtin/src/benchmarking.rs index 5c31c583d44..78d05deb409 100644 --- a/vara/pallets/gear-builtin/src/benchmarking.rs +++ b/vara/pallets/gear-builtin/src/benchmarking.rs @@ -9,7 +9,7 @@ use crate::*; use ark_std::{UniformRand, ops::Mul}; use builtins_common::bls12_381::{ ark_bls12_381::{self, Bls12_381, G1Affine, G1Projective as G1, G2Affine, G2Projective as G2}, - ark_ec::{Group, ScalarMul, pairing::Pairing, short_weierstrass::SWCurveConfig}, + ark_ec::{PrimeGroup as Group, ScalarMul, pairing::Pairing, short_weierstrass::SWCurveConfig}, ark_ff::biginteger::BigInt, ark_scale::{self, hazmat::ArkScaleProjective}, }; diff --git a/vara/pallets/gear-builtin/src/tests/bls381.rs b/vara/pallets/gear-builtin/src/tests/bls381.rs index fd50683d141..ef57cba86fd 100644 --- a/vara/pallets/gear-builtin/src/tests/bls381.rs +++ b/vara/pallets/gear-builtin/src/tests/bls381.rs @@ -7,7 +7,7 @@ use builtins_common::bls12_381::{ Request, Response, ark_bls12_381::{self, Bls12_381, G1Affine, G1Projective as G1, G2Affine, G2Projective as G2}, ark_ec::{ - Group, ScalarMul, VariableBaseMSM, + PrimeGroup as Group, ScalarMul, VariableBaseMSM, bls12::Bls12Config, hashing::{HashToCurve, curve_maps::wb, map_to_curve_hasher::MapToCurveBasedHasher}, pairing::Pairing, diff --git a/vara/sdk/gsdk/tests/builtin_bls381.rs b/vara/sdk/gsdk/tests/builtin_bls381.rs index b09bbfdfc86..29ea6b4b9d7 100644 --- a/vara/sdk/gsdk/tests/builtin_bls381.rs +++ b/vara/sdk/gsdk/tests/builtin_bls381.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 use ark_bls12_381::{G1Affine, G1Projective as G1, G2Affine, G2Projective as G2}; -use ark_ec::Group; +use ark_ec::PrimeGroup as Group; use ark_serialize::CanonicalSerialize; use ark_std::{UniformRand, ops::Mul}; use demo_bls381::*;