From 13ca13f971606543b20f2adf8fdc844d17e1b6e7 Mon Sep 17 00:00:00 2001 From: Arkadiy Paronyan Date: Thu, 4 May 2023 12:24:32 +0200 Subject: [PATCH] Statement store (#13701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP Statement store * Sync with networking changes in master * WIP statement pallet * Statement validation * pallet tests * Validation queue * Store maintenance * Basic statement refactoring + tests + docs * Store metrics * Store tests * Store maintenance test * cargo fmt * Build fix * OCW Api * Offchain worker * Enable host functions * fmt * Minor tweaks * Fixed a warning * Removed tracing * Manual expiration * Reworked constraint management * Updated pallet constraint calculation * Added small test * Added remove function to the APIs * Copy-paste spec into readme * Comments * Made the store optional * Removed network protocol controller * fmt * Clippy fixes * fmt * fmt * More clippy fixes * More clippy fixes * More clippy fixes * Update client/statement-store/README.md Co-authored-by: cheme * Apply suggestions from code review Co-authored-by: Bastian Köcher * Removed sstore from node-template * Sort out data path * Added offline check * Removed dispatch_statement * Renamed into_generic * Fixed commit placement * Use HashSet for tracking peers/statements * fmt * Use ExtendedHostFunctions * Fixed benches * Tweaks * Apply suggestions from code review Co-authored-by: cheme * Fixed priority mixup * Rename * newtypes for priorities * Added MAX_TOPICS * Fixed key filtering logic * Remove empty entrie * Removed prefix from signing * More documentation * fmt * Moved store setup from sc-service to node * Handle maintenance task in sc-statement-store * Use statement iterator * Renamed runtime API mod * fmt * Remove dump_encoded * fmt * Apply suggestions from code review Co-authored-by: Bastian Köcher * Apply suggestions from code review Co-authored-by: Bastian Köcher * Fixed build after applying review suggestions * License exceptions * fmt * Store options * Moved pallet consts to config trait * Removed global priority * Validate fields when decoding * Limit validation channel size * Made a comment into module doc * Removed submit_encoded --------- Co-authored-by: cheme Co-authored-by: Bastian Köcher --- Cargo.lock | 88 ++ Cargo.toml | 4 + bin/node-template/node/Cargo.toml | 1 + bin/node/cli/Cargo.toml | 2 + bin/node/cli/benches/block_production.rs | 3 +- bin/node/cli/benches/transaction_pool.rs | 3 +- bin/node/cli/src/service.rs | 49 +- bin/node/executor/Cargo.toml | 1 + bin/node/executor/src/lib.rs | 5 +- bin/node/rpc/Cargo.toml | 1 + bin/node/rpc/src/lib.rs | 21 +- bin/node/runtime/Cargo.toml | 5 + bin/node/runtime/src/lib.rs | 30 + client/api/Cargo.toml | 1 + client/api/src/execution_extensions.rs | 15 +- client/cli/src/config.rs | 3 +- client/cli/src/runner.rs | 6 +- client/network/statement/Cargo.toml | 29 + client/network/statement/src/config.rs | 33 + client/network/statement/src/lib.rs | 486 +++++++ client/rpc-api/src/lib.rs | 1 + client/rpc-api/src/statement/error.rs | 55 + client/rpc-api/src/statement/mod.rs | 60 + client/rpc/Cargo.toml | 1 + client/rpc/src/lib.rs | 1 + client/rpc/src/statement/mod.rs | 108 ++ client/service/src/builder.rs | 2 +- client/service/src/config.rs | 6 +- client/service/test/src/lib.rs | 3 +- client/statement-store/Cargo.toml | 36 + client/statement-store/README.md | 4 + client/statement-store/src/lib.rs | 1226 +++++++++++++++++ client/statement-store/src/metrics.rs | 79 ++ frame/statement/Cargo.toml | 46 + frame/statement/src/lib.rs | 222 +++ frame/statement/src/mock.rs | 126 ++ frame/statement/src/tests.rs | 159 +++ frame/system/src/lib.rs | 8 + primitives/application-crypto/src/lib.rs | 14 + primitives/blockchain/src/error.rs | 3 + primitives/core/src/crypto.rs | 2 + primitives/core/src/offchain/mod.rs | 12 +- primitives/statement-store/Cargo.toml | 41 + primitives/statement-store/README.md | 39 + primitives/statement-store/src/lib.rs | 618 +++++++++ primitives/statement-store/src/runtime_api.rs | 187 +++ primitives/statement-store/src/store_api.rs | 90 ++ scripts/ci/deny.toml | 2 + 48 files changed, 3911 insertions(+), 26 deletions(-) create mode 100644 client/network/statement/Cargo.toml create mode 100644 client/network/statement/src/config.rs create mode 100644 client/network/statement/src/lib.rs create mode 100644 client/rpc-api/src/statement/error.rs create mode 100644 client/rpc-api/src/statement/mod.rs create mode 100644 client/rpc/src/statement/mod.rs create mode 100644 client/statement-store/Cargo.toml create mode 100644 client/statement-store/README.md create mode 100644 client/statement-store/src/lib.rs create mode 100644 client/statement-store/src/metrics.rs create mode 100644 frame/statement/Cargo.toml create mode 100644 frame/statement/src/lib.rs create mode 100644 frame/statement/src/mock.rs create mode 100644 frame/statement/src/tests.rs create mode 100644 primitives/statement-store/Cargo.toml create mode 100644 primitives/statement-store/README.md create mode 100644 primitives/statement-store/src/lib.rs create mode 100644 primitives/statement-store/src/runtime_api.rs create mode 100644 primitives/statement-store/src/store_api.rs diff --git a/Cargo.lock b/Cargo.lock index 42ad1855248a0..e65562a56b5fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3783,6 +3783,7 @@ dependencies = [ "pallet-staking-reward-curve", "pallet-staking-runtime-api", "pallet-state-trie-migration", + "pallet-statement", "pallet-sudo", "pallet-timestamp", "pallet-tips", @@ -3808,6 +3809,7 @@ dependencies = [ "sp-runtime", "sp-session", "sp-staking", + "sp-statement-store", "sp-std", "sp-transaction-pool", "sp-version", @@ -5120,10 +5122,12 @@ dependencies = [ "sc-keystore", "sc-network", "sc-network-common", + "sc-network-statement", "sc-network-sync", "sc-rpc", "sc-service", "sc-service-test", + "sc-statement-store", "sc-storage-monitor", "sc-sync-state-rpc", "sc-sysinfo", @@ -5192,6 +5196,7 @@ dependencies = [ "sp-keystore", "sp-runtime", "sp-state-machine", + "sp-statement-store", "sp-tracing", "sp-trie", "wat", @@ -5251,6 +5256,7 @@ dependencies = [ "sp-consensus-babe", "sp-keystore", "sp-runtime", + "sp-statement-store", "substrate-frame-rpc-system", "substrate-state-trie-migration-rpc", ] @@ -5288,6 +5294,7 @@ dependencies = [ "sc-rpc", "sc-rpc-api", "sc-service", + "sc-statement-store", "sc-telemetry", "sc-transaction-pool", "sc-transaction-pool-api", @@ -6990,6 +6997,24 @@ dependencies = [ "zstd 0.12.3+zstd.1.5.2", ] +[[package]] +name = "pallet-statement" +version = "4.0.0-dev" +dependencies = [ + "frame-support", + "frame-system", + "log", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-api", + "sp-core", + "sp-io", + "sp-runtime", + "sp-statement-store", + "sp-std", +] + [[package]] name = "pallet-sudo" version = "4.0.0-dev" @@ -8618,6 +8643,7 @@ dependencies = [ "sp-keystore", "sp-runtime", "sp-state-machine", + "sp-statement-store", "sp-storage", "sp-test-primitives", "substrate-prometheus-endpoint", @@ -9293,6 +9319,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "sc-network-statement" +version = "0.10.0-dev" +dependencies = [ + "array-bytes", + "async-channel", + "futures", + "libp2p", + "log", + "parity-scale-codec", + "pin-project", + "sc-network", + "sc-network-common", + "sc-peerset", + "sp-consensus", + "sp-runtime", + "sp-statement-store", + "substrate-prometheus-endpoint", +] + [[package]] name = "sc-network-sync" version = "0.10.0-dev" @@ -9474,6 +9520,7 @@ dependencies = [ "sp-rpc", "sp-runtime", "sp-session", + "sp-statement-store", "sp-version", "substrate-test-runtime-client", "tokio", @@ -9672,6 +9719,30 @@ dependencies = [ "sp-core", ] +[[package]] +name = "sc-statement-store" +version = "4.0.0-dev" +dependencies = [ + "async-trait", + "env_logger 0.9.3", + "futures", + "futures-timer", + "log", + "parity-db", + "parity-scale-codec", + "parking_lot 0.12.1", + "sc-client-api", + "sp-api", + "sp-blockchain", + "sp-core", + "sp-runtime", + "sp-statement-store", + "sp-tracing", + "substrate-prometheus-endpoint", + "tempfile", + "tokio", +] + [[package]] name = "sc-storage-monitor" version = "0.1.0" @@ -10980,6 +11051,23 @@ dependencies = [ "trie-db", ] +[[package]] +name = "sp-statement-store" +version = "4.0.0-dev" +dependencies = [ + "log", + "parity-scale-codec", + "scale-info", + "sp-api", + "sp-application-crypto", + "sp-core", + "sp-externalities", + "sp-runtime", + "sp-runtime-interface", + "sp-std", + "thiserror", +] + [[package]] name = "sp-std" version = "5.0.0" diff --git a/Cargo.toml b/Cargo.toml index 2f3c0e946fcea..da5e1d532a970 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ members = [ "client/merkle-mountain-range/rpc", "client/network", "client/network/transactions", + "client/network/statement", "client/network-gossip", "client/network/bitswap", "client/network/common", @@ -63,6 +64,7 @@ members = [ "client/service", "client/service/test", "client/state-db", + "client/statement-store", "client/storage-monitor", "client/sysinfo", "client/sync-state-rpc", @@ -151,6 +153,7 @@ members = [ "frame/sudo", "frame/root-offences", "frame/root-testing", + "frame/statement", "frame/support", "frame/support/procedural", "frame/support/procedural/tools", @@ -220,6 +223,7 @@ members = [ "primitives/session", "primitives/staking", "primitives/state-machine", + "primitives/statement-store", "primitives/std", "primitives/storage", "primitives/test-primitives", diff --git a/bin/node-template/node/Cargo.toml b/bin/node-template/node/Cargo.toml index 42011075bd482..b729667c57788 100644 --- a/bin/node-template/node/Cargo.toml +++ b/bin/node-template/node/Cargo.toml @@ -28,6 +28,7 @@ sc-telemetry = { version = "4.0.0-dev", path = "../../../client/telemetry" } sc-keystore = { version = "4.0.0-dev", path = "../../../client/keystore" } sc-transaction-pool = { version = "4.0.0-dev", path = "../../../client/transaction-pool" } sc-transaction-pool-api = { version = "4.0.0-dev", path = "../../../client/transaction-pool/api" } +sc-statement-store = { version = "4.0.0-dev", path = "../../../client/statement-store" } sc-consensus-aura = { version = "0.10.0-dev", path = "../../../client/consensus/aura" } sp-consensus-aura = { version = "0.10.0-dev", path = "../../../primitives/consensus/aura" } sp-consensus = { version = "0.10.0-dev", path = "../../../primitives/consensus/common" } diff --git a/bin/node/cli/Cargo.toml b/bin/node/cli/Cargo.toml index e37e2eb94221a..ca9b25b6e75f3 100644 --- a/bin/node/cli/Cargo.toml +++ b/bin/node/cli/Cargo.toml @@ -66,9 +66,11 @@ sc-chain-spec = { version = "4.0.0-dev", path = "../../../client/chain-spec" } sc-consensus = { version = "0.10.0-dev", path = "../../../client/consensus/common" } sc-transaction-pool = { version = "4.0.0-dev", path = "../../../client/transaction-pool" } sc-transaction-pool-api = { version = "4.0.0-dev", path = "../../../client/transaction-pool/api" } +sc-statement-store = { version = "4.0.0-dev", path = "../../../client/statement-store" } sc-network = { version = "0.10.0-dev", path = "../../../client/network" } sc-network-common = { version = "0.10.0-dev", path = "../../../client/network/common" } sc-network-sync = { version = "0.10.0-dev", path = "../../../client/network/sync" } +sc-network-statement = { version = "0.10.0-dev", path = "../../../client/network/statement" } sc-consensus-slots = { version = "0.10.0-dev", path = "../../../client/consensus/slots" } sc-consensus-babe = { version = "0.10.0-dev", path = "../../../client/consensus/babe" } grandpa = { version = "0.10.0-dev", package = "sc-consensus-grandpa", path = "../../../client/consensus/grandpa" } diff --git a/bin/node/cli/benches/block_production.rs b/bin/node/cli/benches/block_production.rs index 36e8076925ebc..fd0bbb71dd408 100644 --- a/bin/node/cli/benches/block_production.rs +++ b/bin/node/cli/benches/block_production.rs @@ -104,7 +104,8 @@ fn new_node(tokio_handle: Handle) -> node_cli::service::NewFullBase { max_runtime_instances: 8, runtime_cache_size: 2, announce_block: true, - base_path: Some(base_path), + data_path: base_path.path().into(), + base_path, informant_output_format: Default::default(), wasm_runtime_overrides: None, }; diff --git a/bin/node/cli/benches/transaction_pool.rs b/bin/node/cli/benches/transaction_pool.rs index 14d99c37a4a33..dc00255308741 100644 --- a/bin/node/cli/benches/transaction_pool.rs +++ b/bin/node/cli/benches/transaction_pool.rs @@ -98,7 +98,8 @@ fn new_node(tokio_handle: Handle) -> node_cli::service::NewFullBase { max_runtime_instances: 8, runtime_cache_size: 2, announce_block: true, - base_path: Some(base_path), + data_path: base_path.path().into(), + base_path, informant_output_format: Default::default(), wasm_runtime_overrides: None, }; diff --git a/bin/node/cli/src/service.rs b/bin/node/cli/src/service.rs index de640180574ce..b704bf0290ddc 100644 --- a/bin/node/cli/src/service.rs +++ b/bin/node/cli/src/service.rs @@ -35,6 +35,7 @@ use sc_network::{event::Event, NetworkEventStream, NetworkService}; use sc_network_common::sync::warp::WarpSyncParams; use sc_network_sync::SyncingService; use sc_service::{config::Configuration, error::Error as ServiceError, RpcHandlers, TaskManager}; +use sc_statement_store::Store as StatementStore; use sc_telemetry::{Telemetry, TelemetryWorker}; use sp_api::ProvideRuntimeApi; use sp_core::crypto::Pair; @@ -148,6 +149,7 @@ pub fn new_partial( ), grandpa::SharedVoterState, Option, + Arc, ), >, ServiceError, @@ -227,6 +229,15 @@ pub fn new_partial( let import_setup = (block_import, grandpa_link, babe_link); + let statement_store = sc_statement_store::Store::new_shared( + &config.data_path, + Default::default(), + client.clone(), + config.prometheus_registry(), + &task_manager.spawn_handle(), + ) + .map_err(|e| ServiceError::Other(format!("Statement store error: {:?}", e)))?; + let (rpc_extensions_builder, rpc_setup) = { let (_, grandpa_link, _) = &import_setup; @@ -247,6 +258,7 @@ pub fn new_partial( let chain_spec = config.chain_spec.cloned_box(); let rpc_backend = backend.clone(); + let rpc_statement_store = statement_store.clone(); let rpc_extensions_builder = move |deny_unsafe, subscription_executor| { let deps = node_rpc::FullDeps { client: client.clone(), @@ -265,6 +277,7 @@ pub fn new_partial( subscription_executor, finality_provider: finality_proof_provider.clone(), }, + statement_store: rpc_statement_store.clone(), }; node_rpc::create_full(deps, rpc_backend.clone()).map_err(Into::into) @@ -281,7 +294,7 @@ pub fn new_partial( select_chain, import_queue, transaction_pool, - other: (rpc_extensions_builder, import_setup, rpc_setup, telemetry), + other: (rpc_extensions_builder, import_setup, rpc_setup, telemetry, statement_store), }) } @@ -325,7 +338,7 @@ pub fn new_full_base( keystore_container, select_chain, transaction_pool, - other: (rpc_builder, import_setup, rpc_setup, mut telemetry), + other: (rpc_builder, import_setup, rpc_setup, mut telemetry, statement_store), } = new_partial(&config)?; let shared_voter_state = rpc_setup; @@ -335,6 +348,16 @@ pub fn new_full_base( &config.chain_spec, ); + let statement_handler_proto = sc_network_statement::StatementHandlerPrototype::new( + client + .block_hash(0u32.into()) + .ok() + .flatten() + .expect("Genesis block exists; qed"), + config.chain_spec.fork_id(), + ); + config.network.extra_sets.push(statement_handler_proto.set_config()); + config .network .extra_sets @@ -526,7 +549,7 @@ pub fn new_full_base( sync: Arc::new(sync_service.clone()), telemetry: telemetry.as_ref().map(|x| x.handle()), voting_rule: grandpa::VotingRulesBuilder::default().build(), - prometheus_registry, + prometheus_registry: prometheus_registry.clone(), shared_voter_state, }; @@ -539,6 +562,26 @@ pub fn new_full_base( ); } + // Spawn statement protocol worker + let statement_protocol_executor = { + let spawn_handle = task_manager.spawn_handle(); + Box::new(move |fut| { + spawn_handle.spawn("network-statement-validator", Some("networking"), fut); + }) + }; + let statement_handler = statement_handler_proto.build( + network.clone(), + sync_service.clone(), + statement_store.clone(), + prometheus_registry.as_ref(), + statement_protocol_executor, + )?; + task_manager.spawn_handle().spawn( + "network-statement-handler", + Some("networking"), + statement_handler.run(), + ); + network_starter.start_network(); Ok(NewFullBase { task_manager, diff --git a/bin/node/executor/Cargo.toml b/bin/node/executor/Cargo.toml index c4711adcb0e82..5f11d513c434b 100644 --- a/bin/node/executor/Cargo.toml +++ b/bin/node/executor/Cargo.toml @@ -24,6 +24,7 @@ sp-keystore = { version = "0.13.0", path = "../../../primitives/keystore" } sp-state-machine = { version = "0.13.0", path = "../../../primitives/state-machine" } sp-tracing = { version = "6.0.0", path = "../../../primitives/tracing" } sp-trie = { version = "7.0.0", path = "../../../primitives/trie" } +sp-statement-store = { version = "4.0.0-dev", path = "../../../primitives/statement-store" } [dev-dependencies] criterion = "0.4.0" diff --git a/bin/node/executor/src/lib.rs b/bin/node/executor/src/lib.rs index 4e3ec9a0b34d5..3557a16740b8a 100644 --- a/bin/node/executor/src/lib.rs +++ b/bin/node/executor/src/lib.rs @@ -25,7 +25,10 @@ pub use sc_executor::NativeElseWasmExecutor; pub struct ExecutorDispatch; impl sc_executor::NativeExecutionDispatch for ExecutorDispatch { - type ExtendHostFunctions = frame_benchmarking::benchmarking::HostFunctions; + type ExtendHostFunctions = ( + frame_benchmarking::benchmarking::HostFunctions, + sp_statement_store::runtime_api::HostFunctions, + ); fn dispatch(method: &str, data: &[u8]) -> Option> { kitchensink_runtime::api::dispatch(method, data) diff --git a/bin/node/rpc/Cargo.toml b/bin/node/rpc/Cargo.toml index 724efbe9a5721..8a336242cd267 100644 --- a/bin/node/rpc/Cargo.toml +++ b/bin/node/rpc/Cargo.toml @@ -35,5 +35,6 @@ sp-consensus = { version = "0.10.0-dev", path = "../../../primitives/consensus/c sp-consensus-babe = { version = "0.10.0-dev", path = "../../../primitives/consensus/babe" } sp-keystore = { version = "0.13.0", path = "../../../primitives/keystore" } sp-runtime = { version = "7.0.0", path = "../../../primitives/runtime" } +sp-statement-store = { version = "4.0.0-dev", path = "../../../primitives/statement-store" } substrate-frame-rpc-system = { version = "4.0.0-dev", path = "../../../utils/frame/rpc/system" } substrate-state-trie-migration-rpc = { version = "4.0.0-dev", path = "../../../utils/frame/rpc/state-trie-migration-rpc/" } diff --git a/bin/node/rpc/src/lib.rs b/bin/node/rpc/src/lib.rs index 5f61fdcd55d97..5ab96bf1c7064 100644 --- a/bin/node/rpc/src/lib.rs +++ b/bin/node/rpc/src/lib.rs @@ -88,6 +88,8 @@ pub struct FullDeps { pub babe: BabeDeps, /// GRANDPA specific dependencies. pub grandpa: GrandpaDeps, + /// Shared statement store reference. + pub statement_store: Arc, } /// Instantiate all Full RPC extensions. @@ -118,14 +120,26 @@ where use pallet_transaction_payment_rpc::{TransactionPayment, TransactionPaymentApiServer}; use sc_consensus_babe_rpc::{Babe, BabeApiServer}; use sc_consensus_grandpa_rpc::{Grandpa, GrandpaApiServer}; - use sc_rpc::dev::{Dev, DevApiServer}; + use sc_rpc::{ + dev::{Dev, DevApiServer}, + statement::StatementApiServer, + }; use sc_rpc_spec_v2::chain_spec::{ChainSpec, ChainSpecApiServer}; use sc_sync_state_rpc::{SyncState, SyncStateApiServer}; use substrate_frame_rpc_system::{System, SystemApiServer}; use substrate_state_trie_migration_rpc::{StateMigration, StateMigrationApiServer}; let mut io = RpcModule::new(()); - let FullDeps { client, pool, select_chain, chain_spec, deny_unsafe, babe, grandpa } = deps; + let FullDeps { + client, + pool, + select_chain, + chain_spec, + deny_unsafe, + babe, + grandpa, + statement_store, + } = deps; let BabeDeps { keystore, babe_worker_handle } = babe; let GrandpaDeps { @@ -169,6 +183,9 @@ where io.merge(StateMigration::new(client.clone(), backend, deny_unsafe).into_rpc())?; io.merge(Dev::new(client, deny_unsafe).into_rpc())?; + let statement_store = + sc_rpc::statement::StatementStore::new(statement_store, deny_unsafe).into_rpc(); + io.merge(statement_store)?; Ok(io) } diff --git a/bin/node/runtime/Cargo.toml b/bin/node/runtime/Cargo.toml index 0fa037c657cee..dadd868e136ce 100644 --- a/bin/node/runtime/Cargo.toml +++ b/bin/node/runtime/Cargo.toml @@ -39,6 +39,7 @@ sp-runtime = { version = "7.0.0", default-features = false, path = "../../../pri sp-staking = { version = "4.0.0-dev", default-features = false, path = "../../../primitives/staking" } sp-session = { version = "4.0.0-dev", default-features = false, path = "../../../primitives/session" } sp-transaction-pool = { version = "4.0.0-dev", default-features = false, path = "../../../primitives/transaction-pool" } +sp-statement-store = { version = "4.0.0-dev", default-features = false, path = "../../../primitives/statement-store" } sp-version = { version = "5.0.0", default-features = false, path = "../../../primitives/version" } sp-io = { version = "7.0.0", default-features = false, path = "../../../primitives/io" } @@ -105,6 +106,7 @@ pallet-staking = { version = "4.0.0-dev", default-features = false, path = "../. pallet-staking-reward-curve = { version = "4.0.0-dev", default-features = false, path = "../../../frame/staking/reward-curve" } pallet-staking-runtime-api = { version = "4.0.0-dev", default-features = false, path = "../../../frame/staking/runtime-api" } pallet-state-trie-migration = { version = "4.0.0-dev", default-features = false, path = "../../../frame/state-trie-migration" } +pallet-statement = { version = "4.0.0-dev", default-features = false, path = "../../../frame/statement" } pallet-scheduler = { version = "4.0.0-dev", default-features = false, path = "../../../frame/scheduler" } pallet-society = { version = "4.0.0-dev", default-features = false, path = "../../../frame/society" } pallet-sudo = { version = "4.0.0-dev", default-features = false, path = "../../../frame/sudo" } @@ -187,6 +189,7 @@ std = [ "pallet-staking/std", "pallet-staking-runtime-api/std", "pallet-state-trie-migration/std", + "pallet-statement/std", "pallet-salary/std", "sp-session/std", "pallet-sudo/std", @@ -204,6 +207,7 @@ std = [ "pallet-treasury/std", "pallet-asset-rate/std", "sp-transaction-pool/std", + "sp-statement-store/std", "pallet-utility/std", "sp-version/std", "pallet-society/std", @@ -330,6 +334,7 @@ try-runtime = [ "pallet-session/try-runtime", "pallet-staking/try-runtime", "pallet-state-trie-migration/try-runtime", + "pallet-statement/try-runtime", "pallet-scheduler/try-runtime", "pallet-society/try-runtime", "pallet-sudo/try-runtime", diff --git a/bin/node/runtime/src/lib.rs b/bin/node/runtime/src/lib.rs index 60d9549135b4f..5ce55ba8b89a7 100644 --- a/bin/node/runtime/src/lib.rs +++ b/bin/node/runtime/src/lib.rs @@ -1755,6 +1755,26 @@ impl frame_benchmarking_pallet_pov::Config for Runtime { type RuntimeEvent = RuntimeEvent; } +parameter_types! { + pub StatementCost: Balance = 1 * DOLLARS; + pub StatementByteCost: Balance = 100 * MILLICENTS; + pub const MinAllowedStatements: u32 = 4; + pub const MaxAllowedStatements: u32 = 10; + pub const MinAllowedBytes: u32 = 1024; + pub const MaxAllowedBytes: u32 = 4096; +} + +impl pallet_statement::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type StatementCost = StatementCost; + type ByteCost = StatementByteCost; + type MinAllowedStatements = MinAllowedStatements; + type MaxAllowedStatements = MaxAllowedStatements; + type MinAllowedBytes = MinAllowedBytes; + type MaxAllowedBytes = MaxAllowedBytes; +} + construct_runtime!( pub struct Runtime where Block = Block, @@ -1826,6 +1846,7 @@ construct_runtime!( FastUnstake: pallet_fast_unstake, MessageQueue: pallet_message_queue, Pov: frame_benchmarking_pallet_pov, + Statement: pallet_statement, } ); @@ -2011,6 +2032,15 @@ impl_runtime_apis! { } } + impl sp_statement_store::runtime_api::ValidateStatement for Runtime { + fn validate_statement( + source: sp_statement_store::runtime_api::StatementSource, + statement: sp_statement_store::Statement, + ) -> Result { + Statement::validate_statement(source, statement) + } + } + impl sp_offchain::OffchainWorkerApi for Runtime { fn offchain_worker(header: &::Header) { Executive::offchain_worker(header) diff --git a/client/api/Cargo.toml b/client/api/Cargo.toml index f494200852729..02f4292aac594 100644 --- a/client/api/Cargo.toml +++ b/client/api/Cargo.toml @@ -34,6 +34,7 @@ sp-externalities = { version = "0.13.0", path = "../../primitives/externalities" sp-keystore = { version = "0.13.0", default-features = false, path = "../../primitives/keystore" } sp-runtime = { version = "7.0.0", default-features = false, path = "../../primitives/runtime" } sp-state-machine = { version = "0.13.0", path = "../../primitives/state-machine" } +sp-statement-store = { version = "4.0.0-dev", path = "../../primitives/statement-store" } sp-storage = { version = "7.0.0", path = "../../primitives/storage" } [dev-dependencies] diff --git a/client/api/src/execution_extensions.rs b/client/api/src/execution_extensions.rs index 9344afbd3e6dd..9ff4b6db418ad 100644 --- a/client/api/src/execution_extensions.rs +++ b/client/api/src/execution_extensions.rs @@ -166,7 +166,7 @@ pub struct ExecutionExtensions { strategies: ExecutionStrategies, keystore: Option, offchain_db: Option>, - // FIXME: these two are only RwLock because of https://github.com/paritytech/substrate/issues/4587 + // FIXME: these three are only RwLock because of https://github.com/paritytech/substrate/issues/4587 // remove when fixed. // To break retain cycle between `Client` and `TransactionPool` we require this // extension to be a `Weak` reference. @@ -174,6 +174,7 @@ pub struct ExecutionExtensions { // during initialization. transaction_pool: RwLock>>>, extensions_factory: RwLock>>, + statement_store: RwLock>>, read_runtime_version: Arc, } @@ -186,6 +187,7 @@ impl ExecutionExtensions { read_runtime_version: Arc, ) -> Self { let transaction_pool = RwLock::new(None); + let statement_store = RwLock::new(None); let extensions_factory = Box::new(()); Self { strategies, @@ -193,6 +195,7 @@ impl ExecutionExtensions { offchain_db, extensions_factory: RwLock::new(extensions_factory), transaction_pool, + statement_store, read_runtime_version, } } @@ -215,6 +218,11 @@ impl ExecutionExtensions { *self.transaction_pool.write() = Some(Arc::downgrade(pool) as _); } + /// Register statement store extension. + pub fn register_statement_store(&self, store: Arc) { + *self.statement_store.write() = Some(Arc::downgrade(&store) as _); + } + /// Based on the execution context and capabilities it produces /// the extensions object to support desired set of APIs. pub fn extensions( @@ -245,6 +253,11 @@ impl ExecutionExtensions { } } + if capabilities.contains(offchain::Capabilities::STATEMENT_STORE) { + if let Some(store) = self.statement_store.read().as_ref().and_then(|x| x.upgrade()) { + extensions.register(sp_statement_store::runtime_api::StatementStoreExt(store)); + } + } if capabilities.contains(offchain::Capabilities::OFFCHAIN_DB_READ) || capabilities.contains(offchain::Capabilities::OFFCHAIN_DB_WRITE) { diff --git a/client/cli/src/config.rs b/client/cli/src/config.rs index 92b1eea7ae266..01f6adbba22fe 100644 --- a/client/cli/src/config.rs +++ b/client/cli/src/config.rs @@ -491,6 +491,7 @@ pub trait CliConfiguration: Sized { )?, keystore, database: self.database_config(&config_dir, database_cache_size, database)?, + data_path: config_dir, trie_cache_maximum_size: self.trie_cache_maximum_size()?, state_pruning: self.state_pruning()?, blocks_pruning: self.blocks_pruning()?, @@ -519,7 +520,7 @@ pub trait CliConfiguration: Sized { max_runtime_instances, announce_block: self.announce_block()?, role, - base_path: Some(base_path), + base_path, informant_output_format: Default::default(), runtime_cache_size, }) diff --git a/client/cli/src/runner.rs b/client/cli/src/runner.rs index 9db078046e3c5..1fa12e2a0eafe 100644 --- a/client/cli/src/runner.rs +++ b/client/cli/src/runner.rs @@ -259,6 +259,7 @@ mod tests { fn create_runner() -> Runner { let runtime = build_runtime().unwrap(); + let root = PathBuf::from("db"); let runner = Runner::new( Configuration { impl_name: "spec".into(), @@ -268,7 +269,7 @@ mod tests { transaction_pool: Default::default(), network: NetworkConfiguration::new_memory(), keystore: sc_service::config::KeystoreConfig::InMemory, - database: sc_client_db::DatabaseSource::ParityDb { path: PathBuf::from("db") }, + database: sc_client_db::DatabaseSource::ParityDb { path: root.clone() }, trie_cache_maximum_size: None, state_pruning: None, blocks_pruning: sc_client_db::BlocksPruning::KeepAll, @@ -306,7 +307,8 @@ mod tests { tracing_receiver: Default::default(), max_runtime_instances: 8, announce_block: true, - base_path: None, + base_path: sc_service::BasePath::new(root.clone()), + data_path: root, informant_output_format: Default::default(), runtime_cache_size: 2, }, diff --git a/client/network/statement/Cargo.toml b/client/network/statement/Cargo.toml new file mode 100644 index 0000000000000..36d8cb077210d --- /dev/null +++ b/client/network/statement/Cargo.toml @@ -0,0 +1,29 @@ +[package] +description = "Substrate statement protocol" +name = "sc-network-statement" +version = "0.10.0-dev" +license = "GPL-3.0-or-later WITH Classpath-exception-2.0" +authors = ["Parity Technologies "] +edition = "2021" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +documentation = "https://docs.rs/sc-network-statement" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +array-bytes = "4.1" +async-channel = "1.8.0" +codec = { package = "parity-scale-codec", version = "3.2.2", features = ["derive"] } +futures = "0.3.21" +libp2p = "0.50.0" +log = "0.4.17" +pin-project = "1.0.12" +prometheus-endpoint = { package = "substrate-prometheus-endpoint", version = "0.10.0-dev", path = "../../../utils/prometheus" } +sc-network-common = { version = "0.10.0-dev", path = "../common" } +sc-network = { version = "0.10.0-dev", path = "../" } +sc-peerset = { version = "4.0.0-dev", path = "../../peerset" } +sp-runtime = { version = "7.0.0", path = "../../../primitives/runtime" } +sp-consensus = { version = "0.10.0-dev", path = "../../../primitives/consensus/common" } +sp-statement-store = { version = "4.0.0-dev", path = "../../../primitives/statement-store" } diff --git a/client/network/statement/src/config.rs b/client/network/statement/src/config.rs new file mode 100644 index 0000000000000..159998a0fe300 --- /dev/null +++ b/client/network/statement/src/config.rs @@ -0,0 +1,33 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Configuration of the statement protocol + +use std::time; + +/// Interval at which we propagate statements; +pub(crate) const PROPAGATE_TIMEOUT: time::Duration = time::Duration::from_millis(1000); + +/// Maximum number of known statement hashes to keep for a peer. +pub(crate) const MAX_KNOWN_STATEMENTS: usize = 10240; + +/// Maximum allowed size for a statement notification. +pub(crate) const MAX_STATEMENT_SIZE: u64 = 256 * 1024; + +/// Maximum number of statement validation request we keep at any moment. +pub(crate) const MAX_PENDING_STATEMENTS: usize = 8192; diff --git a/client/network/statement/src/lib.rs b/client/network/statement/src/lib.rs new file mode 100644 index 0000000000000..02cbab27a6a15 --- /dev/null +++ b/client/network/statement/src/lib.rs @@ -0,0 +1,486 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Statement handling to plug on top of the network service. +//! +//! Usage: +//! +//! - Use [`StatementHandlerPrototype::new`] to create a prototype. +//! - Pass the return value of [`StatementHandlerPrototype::set_config`] to the network +//! configuration as an extra peers set. +//! - Use [`StatementHandlerPrototype::build`] then [`StatementHandler::run`] to obtain a +//! `Future` that processes statements. + +use crate::config::*; +use codec::{Decode, Encode}; +use futures::{channel::oneshot, prelude::*, stream::FuturesUnordered, FutureExt}; +use libp2p::{multiaddr, PeerId}; +use prometheus_endpoint::{register, Counter, PrometheusError, Registry, U64}; +use sc_network::{ + config::{NonDefaultSetConfig, NonReservedPeerMode, SetConfig}, + error, + event::Event, + types::ProtocolName, + utils::{interval, LruHashSet}, + NetworkEventStream, NetworkNotification, NetworkPeers, +}; +use sc_network_common::{ + role::ObservedRole, + sync::{SyncEvent, SyncEventStream}, +}; +use sp_statement_store::{ + Hash, NetworkPriority, Statement, StatementSource, StatementStore, SubmitResult, +}; +use std::{ + collections::{hash_map::Entry, HashMap, HashSet}, + iter, + num::NonZeroUsize, + pin::Pin, + sync::Arc, +}; + +pub mod config; + +/// A set of statements. +pub type Statements = Vec; +/// Future resolving to statement import result. +pub type StatementImportFuture = oneshot::Receiver; + +mod rep { + use sc_peerset::ReputationChange as Rep; + /// Reputation change when a peer sends us any statement. + /// + /// This forces node to verify it, thus the negative value here. Once statement is verified, + /// reputation change should be refunded with `ANY_STATEMENT_REFUND` + pub const ANY_STATEMENT: Rep = Rep::new(-(1 << 4), "Any statement"); + /// Reputation change when a peer sends us any statement that is not invalid. + pub const ANY_STATEMENT_REFUND: Rep = Rep::new(1 << 4, "Any statement (refund)"); + /// Reputation change when a peer sends us an statement that we didn't know about. + pub const GOOD_STATEMENT: Rep = Rep::new(1 << 7, "Good statement"); + /// Reputation change when a peer sends us a bad statement. + pub const BAD_STATEMENT: Rep = Rep::new(-(1 << 12), "Bad statement"); + /// Reputation change when a peer sends us a duplicate statement. + pub const DUPLICATE_STATEMENT: Rep = Rep::new(-(1 << 7), "Duplicate statement"); + /// Reputation change when a peer sends us particularly useful statement + pub const EXCELLENT_STATEMENT: Rep = Rep::new(1 << 8, "High priority statement"); +} + +const LOG_TARGET: &str = "statement-gossip"; + +struct Metrics { + propagated_statements: Counter, +} + +impl Metrics { + fn register(r: &Registry) -> Result { + Ok(Self { + propagated_statements: register( + Counter::new( + "substrate_sync_propagated_statements", + "Number of statements propagated to at least one peer", + )?, + r, + )?, + }) + } +} + +/// Prototype for a [`StatementHandler`]. +pub struct StatementHandlerPrototype { + protocol_name: ProtocolName, +} + +impl StatementHandlerPrototype { + /// Create a new instance. + pub fn new>(genesis_hash: Hash, fork_id: Option<&str>) -> Self { + let genesis_hash = genesis_hash.as_ref(); + let protocol_name = if let Some(fork_id) = fork_id { + format!("/{}/{}/statement/1", array_bytes::bytes2hex("", genesis_hash), fork_id) + } else { + format!("/{}/statement/1", array_bytes::bytes2hex("", genesis_hash)) + }; + + Self { protocol_name: protocol_name.into() } + } + + /// Returns the configuration of the set to put in the network configuration. + pub fn set_config(&self) -> NonDefaultSetConfig { + NonDefaultSetConfig { + notifications_protocol: self.protocol_name.clone(), + fallback_names: Vec::new(), + max_notification_size: MAX_STATEMENT_SIZE, + handshake: None, + set_config: SetConfig { + in_peers: 0, + out_peers: 0, + reserved_nodes: Vec::new(), + non_reserved_mode: NonReservedPeerMode::Deny, + }, + } + } + + /// Turns the prototype into the actual handler. + /// + /// Important: the statements handler is initially disabled and doesn't gossip statements. + /// Gossiping is enabled when major syncing is done. + pub fn build< + N: NetworkPeers + NetworkEventStream + NetworkNotification, + S: SyncEventStream + sp_consensus::SyncOracle, + >( + self, + network: N, + sync: S, + statement_store: Arc, + metrics_registry: Option<&Registry>, + executor: impl Fn(Pin + Send>>) + Send, + ) -> error::Result> { + let net_event_stream = network.event_stream("statement-handler-net"); + let sync_event_stream = sync.event_stream("statement-handler-sync"); + let (queue_sender, mut queue_receiver) = async_channel::bounded(100_000); + + let store = statement_store.clone(); + executor( + async move { + loop { + let task: Option<(Statement, oneshot::Sender)> = + queue_receiver.next().await; + match task { + None => return, + Some((statement, completion)) => { + let result = store.submit(statement, StatementSource::Network); + if completion.send(result).is_err() { + log::debug!( + target: LOG_TARGET, + "Error sending validation completion" + ); + } + }, + } + } + } + .boxed(), + ); + + let handler = StatementHandler { + protocol_name: self.protocol_name, + propagate_timeout: (Box::pin(interval(PROPAGATE_TIMEOUT)) + as Pin + Send>>) + .fuse(), + pending_statements: FuturesUnordered::new(), + pending_statements_peers: HashMap::new(), + network, + sync, + net_event_stream: net_event_stream.fuse(), + sync_event_stream: sync_event_stream.fuse(), + peers: HashMap::new(), + statement_store, + queue_sender, + metrics: if let Some(r) = metrics_registry { + Some(Metrics::register(r)?) + } else { + None + }, + }; + + Ok(handler) + } +} + +/// Handler for statements. Call [`StatementHandler::run`] to start the processing. +pub struct StatementHandler< + N: NetworkPeers + NetworkEventStream + NetworkNotification, + S: SyncEventStream + sp_consensus::SyncOracle, +> { + protocol_name: ProtocolName, + /// Interval at which we call `propagate_statements`. + propagate_timeout: stream::Fuse + Send>>>, + /// Pending statements verification tasks. + pending_statements: + FuturesUnordered)> + Send>>>, + /// As multiple peers can send us the same statement, we group + /// these peers using the statement hash while the statement is + /// imported. This prevents that we import the same statement + /// multiple times concurrently. + pending_statements_peers: HashMap>, + /// Network service to use to send messages and manage peers. + network: N, + /// Syncing service. + sync: S, + /// Stream of networking events. + net_event_stream: stream::Fuse + Send>>>, + /// Receiver for syncing-related events. + sync_event_stream: stream::Fuse + Send>>>, + // All connected peers + peers: HashMap, + statement_store: Arc, + queue_sender: async_channel::Sender<(Statement, oneshot::Sender)>, + /// Prometheus metrics. + metrics: Option, +} + +/// Peer information +#[derive(Debug)] +struct Peer { + /// Holds a set of statements known to this peer. + known_statements: LruHashSet, + role: ObservedRole, +} + +impl StatementHandler +where + N: NetworkPeers + NetworkEventStream + NetworkNotification, + S: SyncEventStream + sp_consensus::SyncOracle, +{ + /// Turns the [`StatementHandler`] into a future that should run forever and not be + /// interrupted. + pub async fn run(mut self) { + loop { + futures::select! { + _ = self.propagate_timeout.next() => { + self.propagate_statements(); + }, + (hash, result) = self.pending_statements.select_next_some() => { + if let Some(peers) = self.pending_statements_peers.remove(&hash) { + if let Some(result) = result { + peers.into_iter().for_each(|p| self.on_handle_statement_import(p, &result)); + } + } else { + log::warn!(target: LOG_TARGET, "Inconsistent state, no peers for pending statement!"); + } + }, + network_event = self.net_event_stream.next() => { + if let Some(network_event) = network_event { + self.handle_network_event(network_event).await; + } else { + // Networking has seemingly closed. Closing as well. + return; + } + }, + sync_event = self.sync_event_stream.next() => { + if let Some(sync_event) = sync_event { + self.handle_sync_event(sync_event); + } else { + // Syncing has seemingly closed. Closing as well. + return; + } + } + } + } + } + + fn handle_sync_event(&mut self, event: SyncEvent) { + match event { + SyncEvent::PeerConnected(remote) => { + let addr = iter::once(multiaddr::Protocol::P2p(remote.into())) + .collect::(); + let result = self.network.add_peers_to_reserved_set( + self.protocol_name.clone(), + iter::once(addr).collect(), + ); + if let Err(err) = result { + log::error!(target: LOG_TARGET, "Add reserved peer failed: {}", err); + } + }, + SyncEvent::PeerDisconnected(remote) => { + self.network.remove_peers_from_reserved_set( + self.protocol_name.clone(), + iter::once(remote).collect(), + ); + }, + } + } + + async fn handle_network_event(&mut self, event: Event) { + match event { + Event::Dht(_) => {}, + Event::NotificationStreamOpened { remote, protocol, role, .. } + if protocol == self.protocol_name => + { + let _was_in = self.peers.insert( + remote, + Peer { + known_statements: LruHashSet::new( + NonZeroUsize::new(MAX_KNOWN_STATEMENTS).expect("Constant is nonzero"), + ), + role, + }, + ); + debug_assert!(_was_in.is_none()); + }, + Event::NotificationStreamClosed { remote, protocol } + if protocol == self.protocol_name => + { + let _peer = self.peers.remove(&remote); + debug_assert!(_peer.is_some()); + }, + + Event::NotificationsReceived { remote, messages } => { + for (protocol, message) in messages { + if protocol != self.protocol_name { + continue + } + // Accept statements only when node is not major syncing + if self.sync.is_major_syncing() { + log::trace!( + target: LOG_TARGET, + "{remote}: Ignoring statements while major syncing or offline" + ); + continue + } + if let Ok(statements) = ::decode(&mut message.as_ref()) { + self.on_statements(remote, statements); + } else { + log::debug!( + target: LOG_TARGET, + "Failed to decode statement list from {remote}" + ); + } + } + }, + + // Not our concern. + Event::NotificationStreamOpened { .. } | Event::NotificationStreamClosed { .. } => {}, + } + } + + /// Called when peer sends us new statements + fn on_statements(&mut self, who: PeerId, statements: Statements) { + log::trace!(target: LOG_TARGET, "Received {} statements from {}", statements.len(), who); + if let Some(ref mut peer) = self.peers.get_mut(&who) { + for s in statements { + if self.pending_statements.len() > MAX_PENDING_STATEMENTS { + log::debug!( + target: LOG_TARGET, + "Ignoring any further statements that exceed `MAX_PENDING_STATEMENTS`({}) limit", + MAX_PENDING_STATEMENTS, + ); + break + } + + let hash = s.hash(); + peer.known_statements.insert(hash); + + self.network.report_peer(who, rep::ANY_STATEMENT); + + match self.pending_statements_peers.entry(hash) { + Entry::Vacant(entry) => { + let (completion_sender, completion_receiver) = oneshot::channel(); + match self.queue_sender.try_send((s, completion_sender)) { + Ok(()) => { + self.pending_statements.push( + async move { + let res = completion_receiver.await; + (hash, res.ok()) + } + .boxed(), + ); + entry.insert(HashSet::from_iter([who])); + }, + Err(async_channel::TrySendError::Full(_)) => { + log::debug!( + target: LOG_TARGET, + "Dropped statement because validation channel is full", + ); + }, + Err(async_channel::TrySendError::Closed(_)) => { + log::trace!( + target: LOG_TARGET, + "Dropped statement because validation channel is closed", + ); + }, + } + }, + Entry::Occupied(mut entry) => { + if !entry.get_mut().insert(who) { + // Already received this from the same peer. + self.network.report_peer(who, rep::DUPLICATE_STATEMENT); + } + }, + } + } + } + } + + fn on_handle_statement_import(&mut self, who: PeerId, import: &SubmitResult) { + match import { + SubmitResult::New(NetworkPriority::High) => + self.network.report_peer(who, rep::EXCELLENT_STATEMENT), + SubmitResult::New(NetworkPriority::Low) => + self.network.report_peer(who, rep::GOOD_STATEMENT), + SubmitResult::Known => self.network.report_peer(who, rep::ANY_STATEMENT_REFUND), + SubmitResult::KnownExpired => {}, + SubmitResult::Ignored => {}, + SubmitResult::Bad(_) => self.network.report_peer(who, rep::BAD_STATEMENT), + SubmitResult::InternalError(_) => {}, + } + } + + /// Propagate one statement. + pub fn propagate_statement(&mut self, hash: &Hash) { + // Accept statements only when node is not major syncing + if self.sync.is_major_syncing() { + return + } + + log::debug!(target: LOG_TARGET, "Propagating statement [{:?}]", hash); + if let Ok(Some(statement)) = self.statement_store.statement(hash) { + self.do_propagate_statements(&[(*hash, statement)]); + } + } + + fn do_propagate_statements(&mut self, statements: &[(Hash, Statement)]) { + let mut propagated_statements = 0; + + for (who, peer) in self.peers.iter_mut() { + // never send statements to light nodes + if matches!(peer.role, ObservedRole::Light) { + continue + } + + let to_send = statements + .iter() + .filter_map(|(hash, stmt)| peer.known_statements.insert(*hash).then(|| stmt)) + .collect::>(); + + propagated_statements += to_send.len(); + + if !to_send.is_empty() { + log::trace!(target: LOG_TARGET, "Sending {} statements to {}", to_send.len(), who); + self.network + .write_notification(*who, self.protocol_name.clone(), to_send.encode()); + } + } + + if let Some(ref metrics) = self.metrics { + metrics.propagated_statements.inc_by(propagated_statements as _) + } + } + + /// Call when we must propagate ready statements to peers. + fn propagate_statements(&mut self) { + // Send out statements only when node is not major syncing + if self.sync.is_major_syncing() { + return + } + + log::debug!(target: LOG_TARGET, "Propagating statements"); + if let Ok(statements) = self.statement_store.statements() { + self.do_propagate_statements(&statements); + } + } +} diff --git a/client/rpc-api/src/lib.rs b/client/rpc-api/src/lib.rs index bc76d029ab6bb..83054584370a1 100644 --- a/client/rpc-api/src/lib.rs +++ b/client/rpc-api/src/lib.rs @@ -32,4 +32,5 @@ pub mod child_state; pub mod dev; pub mod offchain; pub mod state; +pub mod statement; pub mod system; diff --git a/client/rpc-api/src/statement/error.rs b/client/rpc-api/src/statement/error.rs new file mode 100644 index 0000000000000..549b147115fb2 --- /dev/null +++ b/client/rpc-api/src/statement/error.rs @@ -0,0 +1,55 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Statement RPC errors. + +use jsonrpsee::{ + core::Error as JsonRpseeError, + types::error::{CallError, ErrorObject}, +}; + +/// Statement RPC Result type. +pub type Result = std::result::Result; + +/// Statement RPC errors. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Statement store internal error. + #[error("Statement store error")] + StatementStore(String), + /// Call to an unsafe RPC was denied. + #[error(transparent)] + UnsafeRpcCalled(#[from] crate::policy::UnsafeRpcError), +} + +/// Base error code for all statement errors. +const BASE_ERROR: i32 = 6000; + +impl From for JsonRpseeError { + fn from(e: Error) -> Self { + match e { + Error::StatementStore(message) => CallError::Custom(ErrorObject::owned( + BASE_ERROR + 1, + format!("Statement store error: {message}"), + None::<()>, + )) + .into(), + Error::UnsafeRpcCalled(e) => e.into(), + } + } +} diff --git a/client/rpc-api/src/statement/mod.rs b/client/rpc-api/src/statement/mod.rs new file mode 100644 index 0000000000000..39ec52cbea013 --- /dev/null +++ b/client/rpc-api/src/statement/mod.rs @@ -0,0 +1,60 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Substrate Statement Store RPC API. + +use jsonrpsee::{core::RpcResult, proc_macros::rpc}; +use sp_core::Bytes; + +pub mod error; + +/// Substrate statement RPC API +#[rpc(client, server)] +pub trait StatementApi { + /// Return all statements, SCALE-encoded. + #[method(name = "statement_dump")] + fn dump(&self) -> RpcResult>; + + /// Return the data of all known statements which include all topics and have no `DecryptionKey` + /// field. + #[method(name = "statement_broadcasts")] + fn broadcasts(&self, match_all_topics: Vec<[u8; 32]>) -> RpcResult>; + + /// Return the data of all known statements whose decryption key is identified as `dest` (this + /// will generally be the public key or a hash thereof for symmetric ciphers, or a hash of the + /// private key for symmetric ciphers). + #[method(name = "statement_posted")] + fn posted(&self, match_all_topics: Vec<[u8; 32]>, dest: [u8; 32]) -> RpcResult>; + + /// Return the decrypted data of all known statements whose decryption key is identified as + /// `dest`. The key must be available to the client. + #[method(name = "statement_postedClear")] + fn posted_clear( + &self, + match_all_topics: Vec<[u8; 32]>, + dest: [u8; 32], + ) -> RpcResult>; + + /// Submit a pre-encoded statement. + #[method(name = "statement_submit")] + fn submit(&self, encoded: Bytes) -> RpcResult<()>; + + /// Remove a statement from the store. + #[method(name = "statement_remove")] + fn remove(&self, statement_hash: [u8; 32]) -> RpcResult<()>; +} diff --git a/client/rpc/Cargo.toml b/client/rpc/Cargo.toml index a22f657878812..5a6e3e1083923 100644 --- a/client/rpc/Cargo.toml +++ b/client/rpc/Cargo.toml @@ -35,6 +35,7 @@ sp-rpc = { version = "6.0.0", path = "../../primitives/rpc" } sp-runtime = { version = "7.0.0", path = "../../primitives/runtime" } sp-session = { version = "4.0.0-dev", path = "../../primitives/session" } sp-version = { version = "5.0.0", path = "../../primitives/version" } +sp-statement-store = { version = "4.0.0-dev", path = "../../primitives/statement-store" } tokio = "1.22.0" diff --git a/client/rpc/src/lib.rs b/client/rpc/src/lib.rs index 6230ef6648e20..475fc77a9b5bd 100644 --- a/client/rpc/src/lib.rs +++ b/client/rpc/src/lib.rs @@ -36,6 +36,7 @@ pub mod chain; pub mod dev; pub mod offchain; pub mod state; +pub mod statement; pub mod system; #[cfg(any(test, feature = "test-helpers"))] diff --git a/client/rpc/src/statement/mod.rs b/client/rpc/src/statement/mod.rs new file mode 100644 index 0000000000000..b4f432bbbb0e3 --- /dev/null +++ b/client/rpc/src/statement/mod.rs @@ -0,0 +1,108 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Substrate statement store API. + +use codec::{Decode, Encode}; +use jsonrpsee::core::{async_trait, RpcResult}; +/// Re-export the API for backward compatibility. +pub use sc_rpc_api::statement::{error::Error, StatementApiServer}; +use sc_rpc_api::DenyUnsafe; +use sp_core::Bytes; +use sp_statement_store::{StatementSource, SubmitResult}; +use std::sync::Arc; + +/// Statement store API +pub struct StatementStore { + store: Arc, + deny_unsafe: DenyUnsafe, +} + +impl StatementStore { + /// Create new instance of Offchain API. + pub fn new( + store: Arc, + deny_unsafe: DenyUnsafe, + ) -> Self { + StatementStore { store, deny_unsafe } + } +} + +#[async_trait] +impl StatementApiServer for StatementStore { + fn dump(&self) -> RpcResult> { + self.deny_unsafe.check_if_safe()?; + + let statements = + self.store.statements().map_err(|e| Error::StatementStore(e.to_string()))?; + Ok(statements.into_iter().map(|(_, s)| s.encode().into()).collect()) + } + + fn broadcasts(&self, match_all_topics: Vec<[u8; 32]>) -> RpcResult> { + Ok(self + .store + .broadcasts(&match_all_topics) + .map_err(|e| Error::StatementStore(e.to_string()))? + .into_iter() + .map(Into::into) + .collect()) + } + + fn posted(&self, match_all_topics: Vec<[u8; 32]>, dest: [u8; 32]) -> RpcResult> { + Ok(self + .store + .posted(&match_all_topics, dest) + .map_err(|e| Error::StatementStore(e.to_string()))? + .into_iter() + .map(Into::into) + .collect()) + } + + fn posted_clear( + &self, + match_all_topics: Vec<[u8; 32]>, + dest: [u8; 32], + ) -> RpcResult> { + Ok(self + .store + .posted_clear(&match_all_topics, dest) + .map_err(|e| Error::StatementStore(e.to_string()))? + .into_iter() + .map(Into::into) + .collect()) + } + + fn submit(&self, encoded: Bytes) -> RpcResult<()> { + let statement = Decode::decode(&mut &*encoded) + .map_err(|e| Error::StatementStore(format!("Eror decoding statement: {:?}", e)))?; + match self.store.submit(statement, StatementSource::Local) { + SubmitResult::New(_) | SubmitResult::Known => Ok(()), + // `KnownExpired` should not happen. Expired statements submitted with + // `StatementSource::Rpc` should be renewed. + SubmitResult::KnownExpired => + Err(Error::StatementStore("Submitted an expired statement.".into()).into()), + SubmitResult::Bad(e) => Err(Error::StatementStore(e.into()).into()), + SubmitResult::Ignored => Err(Error::StatementStore("Store is full.".into()).into()), + SubmitResult::InternalError(e) => Err(Error::StatementStore(e.to_string()).into()), + } + } + + fn remove(&self, hash: [u8; 32]) -> RpcResult<()> { + Ok(self.store.remove(&hash).map_err(|e| Error::StatementStore(e.to_string()))?) + } +} diff --git a/client/service/src/builder.rs b/client/service/src/builder.rs index d338ceb78958d..cb8986a5dc6ae 100644 --- a/client/service/src/builder.rs +++ b/client/service/src/builder.rs @@ -938,8 +938,8 @@ where Arc::new(TransactionPoolAdapter { pool: transaction_pool, client: client.clone() }), config.prometheus_config.as_ref().map(|config| &config.registry), )?; - spawn_handle.spawn("network-transactions-handler", Some("networking"), tx_handler.run()); + spawn_handle.spawn_blocking( "chain-sync-network-service-provider", Some("networking"), diff --git a/client/service/src/config.rs b/client/service/src/config.rs index 40f8c6a4f0445..0ff2c96d848bf 100644 --- a/client/service/src/config.rs +++ b/client/service/src/config.rs @@ -130,8 +130,10 @@ pub struct Configuration { pub max_runtime_instances: usize, /// Announce block automatically after they have been imported pub announce_block: bool, - /// Base path of the configuration - pub base_path: Option, + /// Data path root for the configured chain. + pub data_path: PathBuf, + /// Base path of the configuration. This is shared between chains. + pub base_path: BasePath, /// Configuration of the output format that the informant uses. pub informant_output_format: sc_informant::OutputFormat, /// Maximum number of different runtime versions that can be cached. diff --git a/client/service/test/src/lib.rs b/client/service/test/src/lib.rs index 0eb4489ad089a..db8432e44c4be 100644 --- a/client/service/test/src/lib.rs +++ b/client/service/test/src/lib.rs @@ -265,7 +265,8 @@ fn node_config< tracing_receiver: Default::default(), max_runtime_instances: 8, announce_block: true, - base_path: Some(BasePath::new(root)), + base_path: BasePath::new(root.clone()), + data_path: root, informant_output_format: Default::default(), runtime_cache_size: 2, } diff --git a/client/statement-store/Cargo.toml b/client/statement-store/Cargo.toml new file mode 100644 index 0000000000000..d9c9f238ddc0d --- /dev/null +++ b/client/statement-store/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "sc-statement-store" +version = "4.0.0-dev" +authors = ["Parity Technologies "] +edition = "2021" +license = "GPL-3.0-or-later WITH Classpath-exception-2.0" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +description = "Substrate statement store." +readme = "README.md" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +async-trait = "0.1.57" +codec = { package = "parity-scale-codec", version = "3.2.2" } +futures = "0.3.21" +futures-timer = "3.0.2" +log = "0.4.17" +parking_lot = "0.12.1" +parity-db = "0.4.6" +tokio = { version = "1.22.0", features = ["time"] } +sp-statement-store = { version = "4.0.0-dev", path = "../../primitives/statement-store" } +prometheus-endpoint = { package = "substrate-prometheus-endpoint", version = "0.10.0-dev", path = "../../utils/prometheus" } +sp-api = { version = "4.0.0-dev", path = "../../primitives/api" } +sp-blockchain = { version = "4.0.0-dev", path = "../../primitives/blockchain" } +sp-core = { version = "7.0.0", path = "../../primitives/core" } +sp-runtime = { version = "7.0.0", path = "../../primitives/runtime" } +sp-tracing = { version = "6.0.0", path = "../../primitives/tracing" } +sc-client-api = { version = "4.0.0-dev", path = "../api" } + +[dev-dependencies] +tempfile = "3.1.0" +env_logger = "0.9" + diff --git a/client/statement-store/README.md b/client/statement-store/README.md new file mode 100644 index 0000000000000..41e268f4ece0d --- /dev/null +++ b/client/statement-store/README.md @@ -0,0 +1,4 @@ +Substrate statement store implementation. + +License: GPL-3.0-or-later WITH Classpath-exception-2.0 + diff --git a/client/statement-store/src/lib.rs b/client/statement-store/src/lib.rs new file mode 100644 index 0000000000000..2e2bb3bd3b430 --- /dev/null +++ b/client/statement-store/src/lib.rs @@ -0,0 +1,1226 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Disk-backed statement store. +//! +//! This module contains an implementation of `sp_statement_store::StatementStore` which is backed +//! by a database. +//! +//! Constraint management. +//! +//! Each time a new statement is inserted into the store, it is first validated with the runtime +//! Validation function computes `global_priority`, 'max_count' and `max_size` for a statement. +//! The following constraints are then checked: +//! * For a given account id, there may be at most `max_count` statements with `max_size` total data +//! size. To satisfy this, statements for this account ID are removed from the store starting with +//! the lowest priority until a constraint is satisfied. +//! * There may not be more than `MAX_TOTAL_STATEMENTS` total statements with `MAX_TOTAL_SIZE` size. +//! To satisfy this, statements are removed from the store starting with the lowest +//! `global_priority` until a constraint is satisfied. +//! +//! When a new statement is inserted that would not satisfy constraints in the first place, no +//! statements are deleted and `Ignored` result is returned. +//! The order in which statements with the same priority are deleted is unspecified. +//! +//! Statement expiration. +//! +//! Each time a statement is removed from the store (Either evicted by higher priority statement or +//! explicitly with the `remove` function) the statement is marked as expired. Expired statements +//! can't be added to the store for `Options::purge_after_sec` seconds. This is to prevent old +//! statements from being propagated on the network. + +#![warn(missing_docs)] +#![warn(unused_extern_crates)] + +mod metrics; + +pub use sp_statement_store::{Error, StatementStore, MAX_TOPICS}; + +use metrics::MetricsLink as PrometheusMetrics; +use parking_lot::RwLock; +use prometheus_endpoint::Registry as PrometheusRegistry; +use sp_api::ProvideRuntimeApi; +use sp_blockchain::HeaderBackend; +use sp_core::{hexdisplay::HexDisplay, traits::SpawnNamed, Decode, Encode}; +use sp_runtime::traits::Block as BlockT; +use sp_statement_store::{ + runtime_api::{InvalidStatement, StatementSource, ValidStatement, ValidateStatement}, + AccountId, BlockHash, Channel, DecryptionKey, Hash, NetworkPriority, Proof, Result, Statement, + SubmitResult, Topic, +}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +const KEY_VERSION: &[u8] = b"version".as_slice(); +const CURRENT_VERSION: u32 = 1; + +const LOG_TARGET: &str = "statement-store"; + +const DEFAULT_PURGE_AFTER_SEC: u64 = 2 * 24 * 60 * 60; //48h +const DEFAULT_MAX_TOTAL_STATEMENTS: usize = 8192; +const DEFAULT_MAX_TOTAL_SIZE: usize = 64 * 1024 * 1024; + +const MAINTENANCE_PERIOD: std::time::Duration = std::time::Duration::from_secs(30); + +mod col { + pub const META: u8 = 0; + pub const STATEMENTS: u8 = 1; + pub const EXPIRED: u8 = 2; + + pub const COUNT: u8 = 3; +} + +#[derive(Eq, PartialEq, Debug, Ord, PartialOrd, Clone, Copy)] +struct Priority(u32); + +#[derive(PartialEq, Eq)] +struct PriorityKey { + hash: Hash, + priority: Priority, +} + +impl PartialOrd for PriorityKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PriorityKey { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.priority.cmp(&other.priority).then_with(|| self.hash.cmp(&other.hash)) + } +} + +#[derive(PartialEq, Eq)] +struct ChannelEntry { + hash: Hash, + priority: Priority, +} + +#[derive(Default)] +struct StatementsForAccount { + // Statements ordered by priority. + by_priority: BTreeMap, usize)>, + // Channel to statement map. Only one statement per channel is allowed. + channels: HashMap, + // Sum of all `Data` field sizes. + data_size: usize, +} + +/// Store configuration +pub struct Options { + /// Maximum statement allowed in the store. Once this limit is reached lower-priority + /// statements may be evicted. + max_total_statements: usize, + /// Maximum total data size allowed in the store. Once this limit is reached lower-priority + /// statements may be evicted. + max_total_size: usize, + /// Number of seconds for which removed statements won't be allowed to be added back in. + purge_after_sec: u64, +} + +impl Default for Options { + fn default() -> Self { + Options { + max_total_statements: DEFAULT_MAX_TOTAL_STATEMENTS, + max_total_size: DEFAULT_MAX_TOTAL_SIZE, + purge_after_sec: DEFAULT_PURGE_AFTER_SEC, + } + } +} + +#[derive(Default)] +struct Index { + by_topic: HashMap>, + by_dec_key: HashMap, HashSet>, + topics_and_keys: HashMap; MAX_TOPICS], Option)>, + entries: HashMap, + expired: HashMap, // Value is expiration timestamp. + accounts: HashMap, + options: Options, + total_size: usize, +} + +struct ClientWrapper { + client: Arc, + _block: std::marker::PhantomData, +} + +impl ClientWrapper +where + Block: BlockT, + Block::Hash: From, + Client: ProvideRuntimeApi + HeaderBackend + Send + Sync + 'static, + Client::Api: ValidateStatement, +{ + fn validate_statement( + &self, + block: Option, + source: StatementSource, + statement: Statement, + ) -> std::result::Result { + let api = self.client.runtime_api(); + let block = block.map(Into::into).unwrap_or_else(|| { + // Validate against the finalized state. + self.client.info().finalized_hash + }); + api.validate_statement(block, source, statement) + .map_err(|_| InvalidStatement::InternalError)? + } +} + +/// Statement store. +pub struct Store { + db: parity_db::Db, + index: RwLock, + validate_fn: Box< + dyn Fn( + Option, + StatementSource, + Statement, + ) -> std::result::Result + + Send + + Sync, + >, + // Used for testing + time_override: Option, + metrics: PrometheusMetrics, +} + +enum IndexQuery { + Unknown, + Exists, + Expired, +} + +enum MaybeInserted { + Inserted(HashSet), + Ignored, +} + +impl Index { + fn new(options: Options) -> Index { + Index { options, ..Default::default() } + } + + fn insert_new(&mut self, hash: Hash, account: AccountId, statement: &Statement) { + let mut all_topics = [None; MAX_TOPICS]; + let mut nt = 0; + while let Some(t) = statement.topic(nt) { + self.by_topic.entry(t).or_default().insert(hash); + all_topics[nt] = Some(t); + nt += 1; + } + let key = statement.decryption_key(); + self.by_dec_key.entry(key).or_default().insert(hash); + if nt > 0 || key.is_some() { + self.topics_and_keys.insert(hash, (all_topics, key)); + } + let priority = Priority(statement.priority().unwrap_or(0)); + self.entries.insert(hash, (account, priority, statement.data_len())); + self.total_size += statement.data_len(); + let mut account_info = self.accounts.entry(account).or_default(); + account_info.data_size += statement.data_len(); + if let Some(channel) = statement.channel() { + account_info.channels.insert(channel, ChannelEntry { hash, priority }); + } + account_info + .by_priority + .insert(PriorityKey { hash, priority }, (statement.channel(), statement.data_len())); + } + + fn query(&self, hash: &Hash) -> IndexQuery { + if self.entries.contains_key(hash) { + return IndexQuery::Exists + } + if self.expired.contains_key(hash) { + return IndexQuery::Expired + } + IndexQuery::Unknown + } + + fn insert_expired(&mut self, hash: Hash, timestamp: u64) { + self.expired.insert(hash, timestamp); + } + + fn iterate_with( + &self, + key: Option, + match_all_topics: &[Topic], + mut f: impl FnMut(&Hash) -> Result<()>, + ) -> Result<()> { + let empty = HashSet::new(); + let mut sets: [&HashSet; MAX_TOPICS + 1] = [∅ MAX_TOPICS + 1]; + if match_all_topics.len() > MAX_TOPICS { + return Ok(()) + } + let key_set = self.by_dec_key.get(&key); + if key_set.map_or(0, |s| s.len()) == 0 { + // Key does not exist in the index. + return Ok(()) + } + sets[0] = key_set.expect("Function returns if key_set is None"); + for (i, t) in match_all_topics.iter().enumerate() { + let set = self.by_topic.get(t); + if set.map_or(0, |s| s.len()) == 0 { + // At least one of the match_all_topics does not exist in the index. + return Ok(()) + } + sets[i + 1] = set.expect("Function returns if set is None"); + } + let sets = &mut sets[0..match_all_topics.len() + 1]; + // Start with the smallest topic set or the key set. + sets.sort_by_key(|s| s.len()); + for item in sets[0] { + if sets[1..].iter().all(|set| set.contains(item)) { + log::trace!( + target: LOG_TARGET, + "Iterating by topic/key: statement {:?}", + HexDisplay::from(item) + ); + f(item)? + } + } + Ok(()) + } + + fn maintain(&mut self, current_time: u64) -> Vec { + // Purge previously expired messages. + let mut purged = Vec::new(); + self.expired.retain(|hash, timestamp| { + if *timestamp + self.options.purge_after_sec <= current_time { + purged.push(*hash); + log::trace!(target: LOG_TARGET, "Purged statement {:?}", HexDisplay::from(hash)); + false + } else { + true + } + }); + purged + } + + fn make_expired(&mut self, hash: &Hash, current_time: u64) -> bool { + if let Some((account, priority, len)) = self.entries.remove(hash) { + self.total_size -= len; + if let Some((topics, key)) = self.topics_and_keys.remove(hash) { + for t in topics.into_iter().flatten() { + if let std::collections::hash_map::Entry::Occupied(mut set) = + self.by_topic.entry(t) + { + set.get_mut().remove(hash); + if set.get().is_empty() { + set.remove_entry(); + } + } + } + if let std::collections::hash_map::Entry::Occupied(mut set) = + self.by_dec_key.entry(key) + { + set.get_mut().remove(hash); + if set.get().is_empty() { + set.remove_entry(); + } + } + } + self.expired.insert(*hash, current_time); + if let std::collections::hash_map::Entry::Occupied(mut account_rec) = + self.accounts.entry(account) + { + let key = PriorityKey { hash: *hash, priority }; + if let Some((channel, len)) = account_rec.get_mut().by_priority.remove(&key) { + account_rec.get_mut().data_size -= len; + if let Some(channel) = channel { + account_rec.get_mut().channels.remove(&channel); + } + } + if account_rec.get().by_priority.is_empty() { + account_rec.remove_entry(); + } + } + log::trace!(target: LOG_TARGET, "Expired statement {:?}", HexDisplay::from(hash)); + true + } else { + false + } + } + + fn insert( + &mut self, + hash: Hash, + statement: &Statement, + account: &AccountId, + validation: &ValidStatement, + current_time: u64, + ) -> MaybeInserted { + let statement_len = statement.data_len(); + if statement_len > validation.max_size as usize { + log::debug!( + target: LOG_TARGET, + "Ignored oversize message: {:?} ({} bytes)", + HexDisplay::from(&hash), + statement_len, + ); + return MaybeInserted::Ignored + } + + let mut evicted = HashSet::new(); + let mut would_free_size = 0; + let priority = Priority(statement.priority().unwrap_or(0)); + let (max_size, max_count) = (validation.max_size as usize, validation.max_count as usize); + // It may happen that we can't delete enough lower priority messages + // to satisfy size constraints. We check for that before deleting anything, + // taking into account channel message replacement. + if let Some(account_rec) = self.accounts.get(account) { + if let Some(channel) = statement.channel() { + if let Some(channel_record) = account_rec.channels.get(&channel) { + if priority <= channel_record.priority { + // Trying to replace channel message with lower priority + log::debug!( + target: LOG_TARGET, + "Ignored lower priority channel message: {:?} {:?} <= {:?}", + HexDisplay::from(&hash), + priority, + channel_record.priority, + ); + return MaybeInserted::Ignored + } else { + // Would replace channel message. Still need to check for size constraints + // below. + log::debug!( + target: LOG_TARGET, + "Replacing higher priority channel message: {:?} ({:?}) > {:?} ({:?})", + HexDisplay::from(&hash), + priority, + HexDisplay::from(&channel_record.hash), + channel_record.priority, + ); + let key = PriorityKey { + hash: channel_record.hash, + priority: channel_record.priority, + }; + if let Some((_channel, len)) = account_rec.by_priority.get(&key) { + would_free_size += *len; + evicted.insert(channel_record.hash); + } + } + } + } + // Check if we can evict enough lower priority statements to satisfy constraints + for (entry, (_, len)) in account_rec.by_priority.iter() { + if (account_rec.data_size - would_free_size + statement_len <= max_size) && + account_rec.by_priority.len() + 1 - evicted.len() <= max_count + { + // Satisfied + break + } + if evicted.contains(&entry.hash) { + // Already accounted for above + continue + } + if entry.priority >= priority { + log::debug!( + target: LOG_TARGET, + "Ignored message due to constraints {:?} {:?} < {:?}", + HexDisplay::from(&hash), + priority, + entry.priority, + ); + return MaybeInserted::Ignored + } + evicted.insert(entry.hash); + would_free_size += len; + } + } + // Now check global constraints as well. + if !((self.total_size - would_free_size + statement_len <= self.options.max_total_size) && + self.entries.len() + 1 - evicted.len() <= self.options.max_total_statements) + { + log::debug!( + target: LOG_TARGET, + "Ignored statement {} because the store is full (size={}, count={})", + HexDisplay::from(&hash), + self.total_size, + self.entries.len(), + ); + return MaybeInserted::Ignored + } + + for h in &evicted { + self.make_expired(h, current_time); + } + self.insert_new(hash, *account, statement); + MaybeInserted::Inserted(evicted) + } +} + +impl Store { + /// Create a new shared store instance. There should only be one per process. + /// `path` will be used to open a statement database or create a new one if it does not exist. + pub fn new_shared( + path: &std::path::Path, + options: Options, + client: Arc, + prometheus: Option<&PrometheusRegistry>, + task_spawner: &dyn SpawnNamed, + ) -> Result> + where + Block: BlockT, + Block::Hash: From, + Client: ProvideRuntimeApi + + HeaderBackend + + sc_client_api::ExecutorProvider + + Send + + Sync + + 'static, + Client::Api: ValidateStatement, + { + let store = Arc::new(Self::new(path, options, client.clone(), prometheus)?); + client.execution_extensions().register_statement_store(store.clone()); + + // Perform periodic statement store maintenance + let worker_store = store.clone(); + task_spawner.spawn( + "statement-store-maintenance", + Some("statement-store"), + Box::pin(async move { + let mut interval = tokio::time::interval(MAINTENANCE_PERIOD); + loop { + interval.tick().await; + worker_store.maintain(); + } + }), + ); + + Ok(store) + } + + /// Create a new instance. + /// `path` will be used to open a statement database or create a new one if it does not exist. + fn new( + path: &std::path::Path, + options: Options, + client: Arc, + prometheus: Option<&PrometheusRegistry>, + ) -> Result + where + Block: BlockT, + Block::Hash: From, + Client: ProvideRuntimeApi + HeaderBackend + Send + Sync + 'static, + Client::Api: ValidateStatement, + { + let mut path: std::path::PathBuf = path.into(); + path.push("statements"); + + let mut config = parity_db::Options::with_columns(&path, col::COUNT); + + let mut statement_col = &mut config.columns[col::STATEMENTS as usize]; + statement_col.ref_counted = false; + statement_col.preimage = true; + statement_col.uniform = true; + let db = parity_db::Db::open_or_create(&config).map_err(|e| Error::Db(e.to_string()))?; + match db.get(col::META, &KEY_VERSION).map_err(|e| Error::Db(e.to_string()))? { + Some(version) => { + let version = u32::from_le_bytes( + version + .try_into() + .map_err(|_| Error::Db("Error reading database version".into()))?, + ); + if version != CURRENT_VERSION { + return Err(Error::Db(format!("Unsupported database version: {version}"))) + } + }, + None => { + db.commit([( + col::META, + KEY_VERSION.to_vec(), + Some(CURRENT_VERSION.to_le_bytes().to_vec()), + )]) + .map_err(|e| Error::Db(e.to_string()))?; + }, + } + + let validator = ClientWrapper { client, _block: Default::default() }; + let validate_fn = Box::new(move |block, source, statement| { + validator.validate_statement(block, source, statement) + }); + + let store = Store { + db, + index: RwLock::new(Index::new(options)), + validate_fn, + time_override: None, + metrics: PrometheusMetrics::new(prometheus), + }; + store.populate()?; + Ok(store) + } + + /// Create memory index from the data. + // This may be moved to a background thread if it slows startup too much. + // This function should only be used on startup. There should be no other DB operations when + // iterating the index. + fn populate(&self) -> Result<()> { + { + let mut index = self.index.write(); + self.db + .iter_column_while(col::STATEMENTS, |item| { + let statement = item.value; + if let Ok(statement) = Statement::decode(&mut statement.as_slice()) { + let hash = statement.hash(); + log::trace!( + target: LOG_TARGET, + "Statement loaded {:?}", + HexDisplay::from(&hash) + ); + if let Some(account_id) = statement.account_id() { + index.insert_new(hash, account_id, &statement); + } else { + log::debug!( + target: LOG_TARGET, + "Error decoding statement loaded from the DB: {:?}", + HexDisplay::from(&hash) + ); + } + } + true + }) + .map_err(|e| Error::Db(e.to_string()))?; + self.db + .iter_column_while(col::EXPIRED, |item| { + let expired_info = item.value; + if let Ok((hash, timestamp)) = + <(Hash, u64)>::decode(&mut expired_info.as_slice()) + { + log::trace!( + target: LOG_TARGET, + "Statement loaded (expired): {:?}", + HexDisplay::from(&hash) + ); + index.insert_expired(hash, timestamp); + } + true + }) + .map_err(|e| Error::Db(e.to_string()))?; + } + + self.maintain(); + Ok(()) + } + + fn collect_statements( + &self, + key: Option, + match_all_topics: &[Topic], + mut f: impl FnMut(Statement) -> Option, + ) -> Result> { + let mut result = Vec::new(); + let index = self.index.read(); + index.iterate_with(key, match_all_topics, |hash| { + match self.db.get(col::STATEMENTS, hash).map_err(|e| Error::Db(e.to_string()))? { + Some(entry) => { + if let Ok(statement) = Statement::decode(&mut entry.as_slice()) { + if let Some(data) = f(statement) { + result.push(data); + } + } else { + // DB inconsistency + log::warn!( + target: LOG_TARGET, + "Corrupt statement {:?}", + HexDisplay::from(hash) + ); + } + }, + None => { + // DB inconsistency + log::warn!( + target: LOG_TARGET, + "Missing statement {:?}", + HexDisplay::from(hash) + ); + }, + } + Ok(()) + })?; + Ok(result) + } + + /// Perform periodic store maintenance + pub fn maintain(&self) { + log::trace!(target: LOG_TARGET, "Started store maintenance"); + let deleted = self.index.write().maintain(self.timestamp()); + let deleted: Vec<_> = + deleted.into_iter().map(|hash| (col::EXPIRED, hash.to_vec(), None)).collect(); + let count = deleted.len() as u64; + if let Err(e) = self.db.commit(deleted) { + log::warn!(target: LOG_TARGET, "Error writing to the statement database: {:?}", e); + } else { + self.metrics.report(|metrics| metrics.statements_pruned.inc_by(count)); + } + log::trace!( + target: LOG_TARGET, + "Completed store maintenance. Purged: {}, Active: {}, Expired: {}", + count, + self.index.read().entries.len(), + self.index.read().expired.len() + ); + } + + fn timestamp(&self) -> u64 { + self.time_override.unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + } + + #[cfg(test)] + fn set_time(&mut self, time: u64) { + self.time_override = Some(time); + } +} + +impl StatementStore for Store { + /// Return all statements. + fn statements(&self) -> Result> { + let index = self.index.read(); + let mut result = Vec::with_capacity(index.entries.len()); + for h in self.index.read().entries.keys() { + let encoded = self.db.get(col::STATEMENTS, h).map_err(|e| Error::Db(e.to_string()))?; + if let Some(encoded) = encoded { + if let Ok(statement) = Statement::decode(&mut encoded.as_slice()) { + let hash = statement.hash(); + result.push((hash, statement)); + } + } + } + Ok(result) + } + + /// Returns a statement by hash. + fn statement(&self, hash: &Hash) -> Result> { + Ok( + match self + .db + .get(col::STATEMENTS, hash.as_slice()) + .map_err(|e| Error::Db(e.to_string()))? + { + Some(entry) => { + log::trace!( + target: LOG_TARGET, + "Queried statement {:?}", + HexDisplay::from(hash) + ); + Some( + Statement::decode(&mut entry.as_slice()) + .map_err(|e| Error::Decode(e.to_string()))?, + ) + }, + None => { + log::trace!( + target: LOG_TARGET, + "Queried missing statement {:?}", + HexDisplay::from(hash) + ); + None + }, + }, + ) + } + + /// Return the data of all known statements which include all topics and have no `DecryptionKey` + /// field. + fn broadcasts(&self, match_all_topics: &[Topic]) -> Result>> { + self.collect_statements(None, match_all_topics, |statement| statement.into_data()) + } + + /// Return the data of all known statements whose decryption key is identified as `dest` (this + /// will generally be the public key or a hash thereof for symmetric ciphers, or a hash of the + /// private key for symmetric ciphers). + fn posted(&self, match_all_topics: &[Topic], dest: [u8; 32]) -> Result>> { + self.collect_statements(Some(dest), match_all_topics, |statement| statement.into_data()) + } + + /// Return the decrypted data of all known statements whose decryption key is identified as + /// `dest`. The key must be available to the client. + fn posted_clear(&self, match_all_topics: &[Topic], dest: [u8; 32]) -> Result>> { + self.collect_statements(Some(dest), match_all_topics, |statement| statement.into_data()) + } + + /// Submit a statement to the store. Validates the statement and returns validation result. + fn submit(&self, statement: Statement, source: StatementSource) -> SubmitResult { + let hash = statement.hash(); + match self.index.read().query(&hash) { + IndexQuery::Expired => + if !source.can_be_resubmitted() { + return SubmitResult::KnownExpired + }, + IndexQuery::Exists => + if !source.can_be_resubmitted() { + return SubmitResult::Known + }, + IndexQuery::Unknown => {}, + } + + let Some(account_id) = statement.account_id() else { + log::debug!( + target: LOG_TARGET, + "Statement validation failed: Missing proof ({:?})", + HexDisplay::from(&hash), + ); + self.metrics.report(|metrics| metrics.validations_invalid.inc()); + return SubmitResult::Bad("No statement proof") + }; + + // Validate. + let at_block = if let Some(Proof::OnChain { block_hash, .. }) = statement.proof() { + Some(*block_hash) + } else { + None + }; + let validation_result = (self.validate_fn)(at_block, source, statement.clone()); + let validation = match validation_result { + Ok(validation) => validation, + Err(InvalidStatement::BadProof) => { + log::debug!( + target: LOG_TARGET, + "Statement validation failed: BadProof, {:?}", + HexDisplay::from(&hash), + ); + self.metrics.report(|metrics| metrics.validations_invalid.inc()); + return SubmitResult::Bad("Bad statement proof") + }, + Err(InvalidStatement::NoProof) => { + log::debug!( + target: LOG_TARGET, + "Statement validation failed: NoProof, {:?}", + HexDisplay::from(&hash), + ); + self.metrics.report(|metrics| metrics.validations_invalid.inc()); + return SubmitResult::Bad("Missing statement proof") + }, + Err(InvalidStatement::InternalError) => + return SubmitResult::InternalError(Error::Runtime), + }; + + let current_time = self.timestamp(); + let mut commit = Vec::new(); + { + let mut index = self.index.write(); + + let evicted = + match index.insert(hash, &statement, &account_id, &validation, current_time) { + MaybeInserted::Ignored => return SubmitResult::Ignored, + MaybeInserted::Inserted(evicted) => evicted, + }; + + commit.push((col::STATEMENTS, hash.to_vec(), Some(statement.encode()))); + for hash in evicted { + commit.push((col::STATEMENTS, hash.to_vec(), None)); + commit.push((col::EXPIRED, hash.to_vec(), Some((hash, current_time).encode()))); + } + if let Err(e) = self.db.commit(commit) { + log::debug!( + target: LOG_TARGET, + "Statement validation failed: database error {}, {:?}", + e, + statement + ); + return SubmitResult::InternalError(Error::Db(e.to_string())) + } + } // Release index lock + self.metrics.report(|metrics| metrics.submitted_statements.inc()); + let network_priority = NetworkPriority::High; + log::trace!(target: LOG_TARGET, "Statement submitted: {:?}", HexDisplay::from(&hash)); + SubmitResult::New(network_priority) + } + + /// Remove a statement by hash. + fn remove(&self, hash: &Hash) -> Result<()> { + let current_time = self.timestamp(); + { + let mut index = self.index.write(); + if index.make_expired(hash, current_time) { + let commit = [ + (col::STATEMENTS, hash.to_vec(), None), + (col::EXPIRED, hash.to_vec(), Some((hash, current_time).encode())), + ]; + if let Err(e) = self.db.commit(commit) { + log::debug!( + target: LOG_TARGET, + "Error removing statement: database error {}, {:?}", + e, + HexDisplay::from(hash), + ); + return Err(Error::Db(e.to_string())) + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::Store; + use sp_core::Pair; + use sp_statement_store::{ + runtime_api::{InvalidStatement, ValidStatement, ValidateStatement}, + AccountId, Channel, DecryptionKey, NetworkPriority, Proof, SignatureVerificationResult, + Statement, StatementSource, StatementStore, SubmitResult, Topic, + }; + + type Extrinsic = sp_runtime::OpaqueExtrinsic; + type Hash = sp_core::H256; + type Hashing = sp_runtime::traits::BlakeTwo256; + type BlockNumber = u64; + type Header = sp_runtime::generic::Header; + type Block = sp_runtime::generic::Block; + + const CORRECT_BLOCK_HASH: [u8; 32] = [1u8; 32]; + + #[derive(Clone)] + pub(crate) struct TestClient; + + pub(crate) struct RuntimeApi { + _inner: TestClient, + } + + impl sp_api::ProvideRuntimeApi for TestClient { + type Api = RuntimeApi; + fn runtime_api(&self) -> sp_api::ApiRef { + RuntimeApi { _inner: self.clone() }.into() + } + } + sp_api::mock_impl_runtime_apis! { + impl ValidateStatement for RuntimeApi { + fn validate_statement( + _source: StatementSource, + statement: Statement, + ) -> std::result::Result { + use crate::tests::account; + match statement.verify_signature() { + SignatureVerificationResult::Valid(_) => Ok(ValidStatement{max_count: 100, max_size: 1000}), + SignatureVerificationResult::Invalid => Err(InvalidStatement::BadProof), + SignatureVerificationResult::NoSignature => { + if let Some(Proof::OnChain { block_hash, .. }) = statement.proof() { + if block_hash == &CORRECT_BLOCK_HASH { + let (max_count, max_size) = match statement.account_id() { + Some(a) if a == account(1) => (1, 1000), + Some(a) if a == account(2) => (2, 1000), + Some(a) if a == account(3) => (3, 1000), + Some(a) if a == account(4) => (4, 1000), + _ => (2, 2000), + }; + Ok(ValidStatement{ max_count, max_size }) + } else { + Err(InvalidStatement::BadProof) + } + } else { + Err(InvalidStatement::BadProof) + } + } + } + } + } + } + + impl sp_blockchain::HeaderBackend for TestClient { + fn header(&self, _hash: Hash) -> sp_blockchain::Result> { + unimplemented!() + } + fn info(&self) -> sp_blockchain::Info { + sp_blockchain::Info { + best_hash: CORRECT_BLOCK_HASH.into(), + best_number: 0, + genesis_hash: Default::default(), + finalized_hash: CORRECT_BLOCK_HASH.into(), + finalized_number: 1, + finalized_state: None, + number_leaves: 0, + block_gap: None, + } + } + fn status(&self, _hash: Hash) -> sp_blockchain::Result { + unimplemented!() + } + fn number(&self, _hash: Hash) -> sp_blockchain::Result> { + unimplemented!() + } + fn hash(&self, _number: BlockNumber) -> sp_blockchain::Result> { + unimplemented!() + } + } + + fn test_store() -> (Store, tempfile::TempDir) { + let _ = env_logger::try_init(); + let temp_dir = tempfile::Builder::new().tempdir().expect("Error creating test dir"); + + let client = std::sync::Arc::new(TestClient); + let mut path: std::path::PathBuf = temp_dir.path().into(); + path.push("db"); + let store = Store::new(&path, Default::default(), client, None).unwrap(); + (store, temp_dir) // return order is important. Store must be dropped before TempDir + } + + fn signed_statement(data: u8) -> Statement { + signed_statement_with_topics(data, &[], None) + } + + fn signed_statement_with_topics( + data: u8, + topics: &[Topic], + dec_key: Option, + ) -> Statement { + let mut statement = Statement::new(); + statement.set_plain_data(vec![data]); + for i in 0..topics.len() { + statement.set_topic(i, topics[i]); + } + if let Some(key) = dec_key { + statement.set_decryption_key(key); + } + let kp = sp_core::ed25519::Pair::from_string("//Alice", None).unwrap(); + statement.sign_ed25519_private(&kp); + statement + } + + fn topic(data: u64) -> Topic { + let mut topic: Topic = Default::default(); + topic[0..8].copy_from_slice(&data.to_le_bytes()); + topic + } + + fn dec_key(data: u64) -> DecryptionKey { + let mut dec_key: DecryptionKey = Default::default(); + dec_key[0..8].copy_from_slice(&data.to_le_bytes()); + dec_key + } + + fn account(id: u64) -> AccountId { + let mut account: AccountId = Default::default(); + account[0..8].copy_from_slice(&id.to_le_bytes()); + account + } + + fn channel(id: u64) -> Channel { + let mut channel: Channel = Default::default(); + channel[0..8].copy_from_slice(&id.to_le_bytes()); + channel + } + + fn statement(account_id: u64, priority: u32, c: Option, data_len: usize) -> Statement { + let mut statement = Statement::new(); + let mut data = Vec::new(); + data.resize(data_len, 0); + statement.set_plain_data(data); + statement.set_priority(priority); + if let Some(c) = c { + statement.set_channel(channel(c)); + } + statement.set_proof(Proof::OnChain { + block_hash: CORRECT_BLOCK_HASH, + who: account(account_id), + event_index: 0, + }); + statement + } + + #[test] + fn submit_one() { + let (store, _temp) = test_store(); + let statement0 = signed_statement(0); + assert_eq!( + store.submit(statement0, StatementSource::Network), + SubmitResult::New(NetworkPriority::High) + ); + let unsigned = statement(0, 1, None, 0); + assert_eq!( + store.submit(unsigned, StatementSource::Network), + SubmitResult::New(NetworkPriority::High) + ); + } + + #[test] + fn save_and_load_statements() { + let (store, temp) = test_store(); + let statement0 = signed_statement(0); + let statement1 = signed_statement(1); + let statement2 = signed_statement(2); + assert_eq!( + store.submit(statement0.clone(), StatementSource::Network), + SubmitResult::New(NetworkPriority::High) + ); + assert_eq!( + store.submit(statement1.clone(), StatementSource::Network), + SubmitResult::New(NetworkPriority::High) + ); + assert_eq!( + store.submit(statement2.clone(), StatementSource::Network), + SubmitResult::New(NetworkPriority::High) + ); + assert_eq!(store.statements().unwrap().len(), 3); + assert_eq!(store.broadcasts(&[]).unwrap().len(), 3); + assert_eq!(store.statement(&statement1.hash()).unwrap(), Some(statement1.clone())); + drop(store); + + let client = std::sync::Arc::new(TestClient); + let mut path: std::path::PathBuf = temp.path().into(); + path.push("db"); + let store = Store::new(&path, Default::default(), client, None).unwrap(); + assert_eq!(store.statements().unwrap().len(), 3); + assert_eq!(store.broadcasts(&[]).unwrap().len(), 3); + assert_eq!(store.statement(&statement1.hash()).unwrap(), Some(statement1)); + } + + #[test] + fn search_by_topic_and_key() { + let (store, _temp) = test_store(); + let statement0 = signed_statement(0); + let statement1 = signed_statement_with_topics(1, &[topic(0)], None); + let statement2 = signed_statement_with_topics(2, &[topic(0), topic(1)], Some(dec_key(2))); + let statement3 = signed_statement_with_topics(3, &[topic(0), topic(1), topic(2)], None); + let statement4 = + signed_statement_with_topics(4, &[topic(0), topic(42), topic(2), topic(3)], None); + let statements = vec![statement0, statement1, statement2, statement3, statement4]; + for s in &statements { + store.submit(s.clone(), StatementSource::Network); + } + + let assert_topics = |topics: &[u64], key: Option, expected: &[u8]| { + let key = key.map(dec_key); + let topics: Vec<_> = topics.iter().map(|t| topic(*t)).collect(); + let mut got_vals: Vec<_> = if let Some(key) = key { + store.posted(&topics, key).unwrap().into_iter().map(|d| d[0]).collect() + } else { + store.broadcasts(&topics).unwrap().into_iter().map(|d| d[0]).collect() + }; + got_vals.sort(); + assert_eq!(expected.to_vec(), got_vals); + }; + + assert_topics(&[], None, &[0, 1, 3, 4]); + assert_topics(&[], Some(2), &[2]); + assert_topics(&[0], None, &[1, 3, 4]); + assert_topics(&[1], None, &[3]); + assert_topics(&[2], None, &[3, 4]); + assert_topics(&[3], None, &[4]); + assert_topics(&[42], None, &[4]); + + assert_topics(&[0, 1], None, &[3]); + assert_topics(&[0, 1], Some(2), &[2]); + assert_topics(&[0, 1, 99], Some(2), &[]); + assert_topics(&[1, 2], None, &[3]); + assert_topics(&[99], None, &[]); + assert_topics(&[0, 99], None, &[]); + assert_topics(&[0, 1, 2, 3, 42], None, &[]); + } + + #[test] + fn constraints() { + let (store, _temp) = test_store(); + + store.index.write().options.max_total_size = 3000; + let source = StatementSource::Network; + let ok = SubmitResult::New(NetworkPriority::High); + let ignored = SubmitResult::Ignored; + + // Account 1 (limit = 1 msg, 1000 bytes) + + // Oversized statement is not allowed. Limit for account 1 is 1 msg, 1000 bytes + assert_eq!(store.submit(statement(1, 1, Some(1), 2000), source), ignored); + assert_eq!(store.submit(statement(1, 1, Some(1), 500), source), ok); + // Would not replace channel message with same priority + assert_eq!(store.submit(statement(1, 1, Some(1), 200), source), ignored); + assert_eq!(store.submit(statement(1, 2, Some(1), 600), source), ok); + // Submit another message to another channel with lower priority. Should not be allowed + // because msg count limit is 1 + assert_eq!(store.submit(statement(1, 1, Some(2), 100), source), ignored); + assert_eq!(store.index.read().expired.len(), 1); + + // Account 2 (limit = 2 msg, 1000 bytes) + + assert_eq!(store.submit(statement(2, 1, None, 500), source), ok); + assert_eq!(store.submit(statement(2, 2, None, 100), source), ok); + // Should evict priority 1 + assert_eq!(store.submit(statement(2, 3, None, 500), source), ok); + assert_eq!(store.index.read().expired.len(), 2); + // Should evict all + assert_eq!(store.submit(statement(2, 4, None, 1000), source), ok); + assert_eq!(store.index.read().expired.len(), 4); + + // Account 3 (limit = 3 msg, 1000 bytes) + + assert_eq!(store.submit(statement(3, 2, Some(1), 300), source), ok); + assert_eq!(store.submit(statement(3, 3, Some(2), 300), source), ok); + assert_eq!(store.submit(statement(3, 4, Some(3), 300), source), ok); + // Should evict 2 and 3 + assert_eq!(store.submit(statement(3, 5, None, 500), source), ok); + assert_eq!(store.index.read().expired.len(), 6); + + assert_eq!(store.index.read().total_size, 2400); + assert_eq!(store.index.read().entries.len(), 4); + + // Should be over the global size limit + assert_eq!(store.submit(statement(1, 1, None, 700), source), ignored); + // Should be over the global count limit + store.index.write().options.max_total_statements = 4; + assert_eq!(store.submit(statement(1, 1, None, 100), source), ignored); + + let mut expected_statements = vec![ + statement(1, 2, Some(1), 600).hash(), + statement(2, 4, None, 1000).hash(), + statement(3, 4, Some(3), 300).hash(), + statement(3, 5, None, 500).hash(), + //statement(4, 6, None, 100).hash(), + ]; + expected_statements.sort(); + let mut statements: Vec<_> = + store.statements().unwrap().into_iter().map(|(hash, _)| hash).collect(); + statements.sort(); + assert_eq!(expected_statements, statements); + } + + #[test] + fn expired_statements_are_purged() { + use super::DEFAULT_PURGE_AFTER_SEC; + let (mut store, temp) = test_store(); + let mut statement = statement(1, 1, Some(3), 100); + store.set_time(0); + statement.set_topic(0, topic(4)); + store.submit(statement.clone(), StatementSource::Network); + assert_eq!(store.index.read().entries.len(), 1); + store.remove(&statement.hash()).unwrap(); + assert_eq!(store.index.read().entries.len(), 0); + assert_eq!(store.index.read().accounts.len(), 0); + store.set_time(DEFAULT_PURGE_AFTER_SEC + 1); + store.maintain(); + assert_eq!(store.index.read().expired.len(), 0); + drop(store); + + let client = std::sync::Arc::new(TestClient); + let mut path: std::path::PathBuf = temp.path().into(); + path.push("db"); + let store = Store::new(&path, Default::default(), client, None).unwrap(); + assert_eq!(store.statements().unwrap().len(), 0); + assert_eq!(store.index.read().expired.len(), 0); + } +} diff --git a/client/statement-store/src/metrics.rs b/client/statement-store/src/metrics.rs new file mode 100644 index 0000000000000..cf191b79757ed --- /dev/null +++ b/client/statement-store/src/metrics.rs @@ -0,0 +1,79 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Statement store Prometheus metrics. + +use std::sync::Arc; + +use prometheus_endpoint::{register, Counter, PrometheusError, Registry, U64}; + +#[derive(Clone, Default)] +pub struct MetricsLink(Arc>); + +impl MetricsLink { + pub fn new(registry: Option<&Registry>) -> Self { + Self(Arc::new(registry.and_then(|registry| { + Metrics::register(registry) + .map_err(|err| { + log::warn!("Failed to register prometheus metrics: {}", err); + }) + .ok() + }))) + } + + pub fn report(&self, do_this: impl FnOnce(&Metrics)) { + if let Some(metrics) = self.0.as_ref() { + do_this(metrics); + } + } +} + +/// Statement store Prometheus metrics. +pub struct Metrics { + pub submitted_statements: Counter, + pub validations_invalid: Counter, + pub statements_pruned: Counter, +} + +impl Metrics { + pub fn register(registry: &Registry) -> Result { + Ok(Self { + submitted_statements: register( + Counter::new( + "substrate_sub_statement_store_submitted_statements", + "Total number of statements submitted", + )?, + registry, + )?, + validations_invalid: register( + Counter::new( + "substrate_sub_statement_store_validations_invalid", + "Total number of statements that were removed from the pool as invalid", + )?, + registry, + )?, + statements_pruned: register( + Counter::new( + "substrate_sub_statement_store_block_statements", + "Total number of statements that was requested to be pruned by block events", + )?, + registry, + )?, + }) + } +} diff --git a/frame/statement/Cargo.toml b/frame/statement/Cargo.toml new file mode 100644 index 0000000000000..8f9a6269573ec --- /dev/null +++ b/frame/statement/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "pallet-statement" +version = "4.0.0-dev" +authors = ["Parity Technologies "] +edition = "2021" +license = "Apache-2.0" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +description = "FRAME pallet for statement store" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.2.2", default-features = false, features = ["derive"]} +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +frame-support = { version = "4.0.0-dev", default-features = false, path = "../support" } +frame-system = { version = "4.0.0-dev", default-features = false, path = "../system" } +sp-statement-store = { version = "4.0.0-dev", default-features = false, path = "../../primitives/statement-store" } +sp-api = { version = "4.0.0-dev", default-features = false, path = "../../primitives/api" } +sp-runtime = { version = "7.0.0", default-features = false, path = "../../primitives/runtime" } +sp-std = { version = "5.0.0", default-features = false, path = "../../primitives/std" } +sp-io = { version = "7.0.0", default-features = false, path = "../../primitives/io" } +sp-core = { version = "7.0.0", default-features = false, path = "../../primitives/core" } +log = { version = "0.4.17", default-features = false } + +[dev-dependencies] +pallet-balances = { version = "4.0.0-dev", path = "../balances" } + +[features] +default = [ "std" ] +std = [ + "codec/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", + "sp-api/std", + "sp-runtime/std", + "sp-std/std", + "sp-io/std", + "sp-core/std", + "sp-statement-store/std", +] +try-runtime = [ + "frame-support/try-runtime", +] diff --git a/frame/statement/src/lib.rs b/frame/statement/src/lib.rs new file mode 100644 index 0000000000000..c68dac2d29722 --- /dev/null +++ b/frame/statement/src/lib.rs @@ -0,0 +1,222 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Supporting pallet for the statement store. +//! +//! - [`Pallet`] +//! +//! ## Overview +//! +//! The Statement pallet provides means to create and validate statements for the statement store. +//! +//! For each statement validation function calculates the following three values based on the +//! statement author balance: +//! `max_count`: Maximum number of statements allowed for the author (signer) of this statement. +//! `max_size`: Maximum total size of statements allowed for the author (signer) of this statement. +//! +//! This pallet also contains an offchain worker that turns on-chain statement events into +//! statements. These statements are placed in the store and propagated over the network. + +#![cfg_attr(not(feature = "std"), no_std)] + +use frame_support::{ + pallet_prelude::*, + sp_runtime::{traits::CheckedDiv, SaturatedConversion}, + traits::fungible::Inspect, +}; +use frame_system::pallet_prelude::*; +use sp_statement_store::{ + runtime_api::{InvalidStatement, StatementSource, ValidStatement}, + Proof, SignatureVerificationResult, Statement, +}; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +pub use pallet::*; + +const LOG_TARGET: &str = "runtime::statement"; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + + pub type BalanceOf = + <::Currency as Inspect<::AccountId>>::Balance; + + pub type AccountIdOf = ::AccountId; + + #[pallet::config] + pub trait Config: frame_system::Config + where + ::AccountId: From, + { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + /// The currency which is used to calculate account limits. + type Currency: Inspect; + /// Min balance for priority statements. + #[pallet::constant] + type StatementCost: Get>; + /// Cost of data byte used for priority calculation. + #[pallet::constant] + type ByteCost: Get>; + /// Minimum number of statements allowed per account. + #[pallet::constant] + type MinAllowedStatements: Get; + /// Maximum number of statements allowed per account. + #[pallet::constant] + type MaxAllowedStatements: Get; + /// Minimum data bytes allowed per account. + #[pallet::constant] + type MinAllowedBytes: Get; + /// Maximum data bytes allowed per account. + #[pallet::constant] + type MaxAllowedBytes: Get; + } + + #[pallet::pallet] + pub struct Pallet(sp_std::marker::PhantomData); + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event + where + ::AccountId: From, + { + /// A new statement is submitted + NewStatement { account: T::AccountId, statement: Statement }, + } + + #[pallet::hooks] + impl Hooks> for Pallet + where + ::AccountId: From, + sp_statement_store::AccountId: From<::AccountId>, + ::RuntimeEvent: From>, + ::RuntimeEvent: TryInto>, + sp_statement_store::BlockHash: From<::Hash>, + { + fn offchain_worker(now: BlockNumberFor) { + log::trace!(target: LOG_TARGET, "Collecting statements at #{:?}", now); + Pallet::::collect_statements(); + } + } +} + +impl Pallet +where + ::AccountId: From, + sp_statement_store::AccountId: From<::AccountId>, + ::RuntimeEvent: From>, + ::RuntimeEvent: TryInto>, + sp_statement_store::BlockHash: From<::Hash>, +{ + /// Validate a statement against current state. This is supposed to be called by the statement + /// store on the host side. + pub fn validate_statement( + _source: StatementSource, + mut statement: Statement, + ) -> Result { + sp_io::init_tracing(); + log::debug!(target: LOG_TARGET, "Validating statement {:?}", statement); + let account: T::AccountId = match statement.proof() { + Some(Proof::OnChain { who, block_hash, event_index }) => { + if frame_system::Pallet::::parent_hash().as_ref() != block_hash.as_slice() { + log::debug!(target: LOG_TARGET, "Bad block hash."); + return Err(InvalidStatement::BadProof) + } + let account: T::AccountId = (*who).into(); + match frame_system::Pallet::::event_no_consensus(*event_index as usize) { + Some(e) => { + statement.remove_proof(); + if let Ok(Event::NewStatement { account: a, statement: s }) = e.try_into() { + if a != account || s != statement { + log::debug!(target: LOG_TARGET, "Event data mismatch"); + return Err(InvalidStatement::BadProof) + } + } else { + log::debug!(target: LOG_TARGET, "Event type mismatch"); + return Err(InvalidStatement::BadProof) + } + }, + _ => { + log::debug!(target: LOG_TARGET, "Bad event index"); + return Err(InvalidStatement::BadProof) + }, + } + account + }, + _ => match statement.verify_signature() { + SignatureVerificationResult::Valid(account) => account.into(), + SignatureVerificationResult::Invalid => { + log::debug!(target: LOG_TARGET, "Bad statement signature."); + return Err(InvalidStatement::BadProof) + }, + SignatureVerificationResult::NoSignature => { + log::debug!(target: LOG_TARGET, "Missing statement signature."); + return Err(InvalidStatement::NoProof) + }, + }, + }; + let statement_cost = T::StatementCost::get(); + let byte_cost = T::ByteCost::get(); + let balance = >>::balance(&account); + let min_allowed_statements = T::MinAllowedStatements::get(); + let max_allowed_statements = T::MaxAllowedStatements::get(); + let min_allowed_bytes = T::MinAllowedBytes::get(); + let max_allowed_bytes = T::MaxAllowedBytes::get(); + let max_count = balance + .checked_div(&statement_cost) + .unwrap_or_default() + .saturated_into::() + .clamp(min_allowed_statements, max_allowed_statements); + let max_size = balance + .checked_div(&byte_cost) + .unwrap_or_default() + .saturated_into::() + .clamp(min_allowed_bytes, max_allowed_bytes); + + Ok(ValidStatement { max_count, max_size }) + } + + /// Submit a statement event. The statement will be picked up by the offchain worker and + /// broadcast to the network. + pub fn submit_statement(account: T::AccountId, statement: Statement) { + Self::deposit_event(Event::NewStatement { account, statement }); + } + + fn collect_statements() { + // Find `NewStatement` events and submit them to the store + for (index, event) in frame_system::Pallet::::read_events_no_consensus().enumerate() { + if let Ok(Event::::NewStatement { account, mut statement }) = event.event.try_into() + { + if statement.proof().is_none() { + let proof = Proof::OnChain { + who: account.into(), + block_hash: frame_system::Pallet::::parent_hash().into(), + event_index: index as u64, + }; + statement.set_proof(proof); + } + sp_statement_store::runtime_api::statement_store::submit_statement(statement); + } + } + } +} diff --git a/frame/statement/src/mock.rs b/frame/statement/src/mock.rs new file mode 100644 index 0000000000000..f4d9360c9a6c0 --- /dev/null +++ b/frame/statement/src/mock.rs @@ -0,0 +1,126 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! # Statement pallet test environment. + +use super::*; + +use crate as pallet_statement; +use frame_support::{ + ord_parameter_types, + traits::{ConstU32, ConstU64, Everything}, + weights::constants::RocksDbWeight, +}; +use sp_core::{Pair, H256}; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + AccountId32, +}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +pub const MIN_ALLOWED_STATEMENTS: u32 = 4; +pub const MAX_ALLOWED_STATEMENTS: u32 = 10; +pub const MIN_ALLOWED_BYTES: u32 = 1024; +pub const MAX_ALLOWED_BYTES: u32 = 4096; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + Balances: pallet_balances, + Statement: pallet_statement, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = RocksDbWeight; + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId32; + type Lookup = IdentityLookup; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; +} + +impl pallet_balances::Config for Test { + type Balance = u64; + type RuntimeEvent = RuntimeEvent; + type DustRemoval = (); + type ExistentialDeposit = ConstU64<5>; + type AccountStore = System; + type WeightInfo = (); + type MaxLocks = (); + type MaxReserves = ConstU32<50>; + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = (); + type MaxFreezes = (); + type HoldIdentifier = (); + type MaxHolds = (); +} + +ord_parameter_types! { + pub const One: u64 = 1; +} + +impl Config for Test { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type StatementCost = ConstU64<1000>; + type ByteCost = ConstU64<2>; + type MinAllowedStatements = ConstU32; + type MaxAllowedStatements = ConstU32; + type MinAllowedBytes = ConstU32; + type MaxAllowedBytes = ConstU32; +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + let balances = pallet_balances::GenesisConfig:: { + balances: vec![ + (sp_core::sr25519::Pair::from_string("//Alice", None).unwrap().public().into(), 6000), + ( + sp_core::sr25519::Pair::from_string("//Charlie", None).unwrap().public().into(), + 500000, + ), + ], + }; + balances.assimilate_storage(&mut t).unwrap(); + t.into() +} diff --git a/frame/statement/src/tests.rs b/frame/statement/src/tests.rs new file mode 100644 index 0000000000000..51103caca60fa --- /dev/null +++ b/frame/statement/src/tests.rs @@ -0,0 +1,159 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! # Statement runtime support tests. + +#![cfg(test)] + +use super::*; +use crate::mock::*; +use sp_core::Pair; +use sp_runtime::AccountId32; +use sp_statement_store::{ + runtime_api::{InvalidStatement, StatementSource, ValidStatement}, + Proof, Statement, +}; + +#[test] +fn sign_and_validate_no_balance() { + new_test_ext().execute_with(|| { + let pair = sp_core::sr25519::Pair::from_string("//Bob", None).unwrap(); + let mut statement = Statement::new(); + statement.sign_sr25519_private(&pair); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!( + Ok(ValidStatement { max_count: MIN_ALLOWED_STATEMENTS, max_size: MIN_ALLOWED_BYTES }), + result + ); + + let pair = sp_core::ed25519::Pair::from_string("//Bob", None).unwrap(); + let mut statement = Statement::new(); + statement.sign_ed25519_private(&pair); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!( + Ok(ValidStatement { max_count: MIN_ALLOWED_STATEMENTS, max_size: MIN_ALLOWED_BYTES }), + result + ); + + let pair = sp_core::ecdsa::Pair::from_string("//Bob", None).unwrap(); + let mut statement = Statement::new(); + statement.sign_ecdsa_private(&pair); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!( + Ok(ValidStatement { max_count: MIN_ALLOWED_STATEMENTS, max_size: MIN_ALLOWED_BYTES }), + result + ); + }); +} + +#[test] +fn validate_with_balance() { + new_test_ext().execute_with(|| { + let pair = sp_core::sr25519::Pair::from_string("//Alice", None).unwrap(); + let mut statement = Statement::new(); + statement.sign_sr25519_private(&pair); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!(Ok(ValidStatement { max_count: 6, max_size: 3000 }), result); + + let pair = sp_core::sr25519::Pair::from_string("//Charlie", None).unwrap(); + let mut statement = Statement::new(); + statement.sign_sr25519_private(&pair); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!( + Ok(ValidStatement { max_count: MAX_ALLOWED_STATEMENTS, max_size: MAX_ALLOWED_BYTES }), + result + ); + }); +} + +#[test] +fn validate_no_proof_fails() { + new_test_ext().execute_with(|| { + let statement = Statement::new(); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!(Err(InvalidStatement::NoProof), result); + }); +} + +#[test] +fn validate_bad_signature_fails() { + new_test_ext().execute_with(|| { + let statement = Statement::new_with_proof(Proof::Sr25519 { + signature: [0u8; 64], + signer: Default::default(), + }); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!(Err(InvalidStatement::BadProof), result); + }); +} + +#[test] +fn validate_event() { + new_test_ext().execute_with(|| { + let parent_hash = sp_core::H256::random(); + System::reset_events(); + System::initialize(&1, &parent_hash, &Default::default()); + let mut statement = Statement::new(); + let pair = sp_core::sr25519::Pair::from_string("//Alice", None).unwrap(); + let account: AccountId32 = pair.public().into(); + Pallet::::submit_statement(account.clone(), statement.clone()); + statement.set_proof(Proof::OnChain { + who: account.clone().into(), + event_index: 0, + block_hash: parent_hash.into(), + }); + let result = Pallet::::validate_statement(StatementSource::Chain, statement.clone()); + assert_eq!(Ok(ValidStatement { max_count: 6, max_size: 3000 }), result); + + // Use wrong event index + statement.set_proof(Proof::OnChain { + who: account.clone().into(), + event_index: 1, + block_hash: parent_hash.into(), + }); + let result = Pallet::::validate_statement(StatementSource::Chain, statement.clone()); + assert_eq!(Err(InvalidStatement::BadProof), result); + + // Use wrong block hash + statement.set_proof(Proof::OnChain { + who: account.clone().into(), + event_index: 0, + block_hash: sp_core::H256::random().into(), + }); + let result = Pallet::::validate_statement(StatementSource::Chain, statement.clone()); + assert_eq!(Err(InvalidStatement::BadProof), result); + }); +} + +#[test] +fn validate_no_event_fails() { + new_test_ext().execute_with(|| { + let parent_hash = sp_core::H256::random(); + System::reset_events(); + System::initialize(&1, &parent_hash, &Default::default()); + let mut statement = Statement::new(); + let pair = sp_core::sr25519::Pair::from_string("//Alice", None).unwrap(); + let account: AccountId32 = pair.public().into(); + statement.set_proof(Proof::OnChain { + who: account.into(), + event_index: 0, + block_hash: parent_hash.into(), + }); + let result = Pallet::::validate_statement(StatementSource::Chain, statement); + assert_eq!(Err(InvalidStatement::BadProof), result); + }); +} diff --git a/frame/system/src/lib.rs b/frame/system/src/lib.rs index 6bbebb870594c..81bead4f849b4 100644 --- a/frame/system/src/lib.rs +++ b/frame/system/src/lib.rs @@ -1452,6 +1452,14 @@ impl Pallet { Self::read_events_no_consensus().map(|e| *e).collect() } + /// Get a single event at specified index. + /// + /// Should only be called if you know what you are doing and outside of the runtime block + /// execution else it can have a large impact on the PoV size of a block. + pub fn event_no_consensus(index: usize) -> Option { + Self::read_events_no_consensus().nth(index).map(|e| e.event.clone()) + } + /// Get the current events deposited by the runtime. /// /// Should only be called if you know what you are doing and outside of the runtime block diff --git a/primitives/application-crypto/src/lib.rs b/primitives/application-crypto/src/lib.rs index 5e77795199dd9..fa92e427aa711 100644 --- a/primitives/application-crypto/src/lib.rs +++ b/primitives/application-crypto/src/lib.rs @@ -309,6 +309,13 @@ macro_rules! app_crypto_public_common { <$public>::try_from(data).map(Into::into) } } + + impl Public { + /// Convert into wrapped generic public key type. + pub fn into_inner(self) -> $public { + self.0 + } + } }; } @@ -470,6 +477,13 @@ macro_rules! app_crypto_signature_common { Self::try_from(&data[..]) } } + + impl Signature { + /// Convert into wrapped generic signature type. + pub fn into_inner(self) -> $sig { + self.0 + } + } }; } diff --git a/primitives/blockchain/src/error.rs b/primitives/blockchain/src/error.rs index 41e5cda9c11c5..d7f7086388e7f 100644 --- a/primitives/blockchain/src/error.rs +++ b/primitives/blockchain/src/error.rs @@ -159,6 +159,9 @@ pub enum Error { #[error("State Database error: {0}")] StateDatabase(String), + #[error("Statement store error: {0}")] + StatementStore(String), + #[error("Failed to set the chain head to a block that's too old.")] SetHeadTooOld, diff --git a/primitives/core/src/crypto.rs b/primitives/core/src/crypto.rs index dd0d9e60529f2..9d63cbc518936 100644 --- a/primitives/core/src/crypto.rs +++ b/primitives/core/src/crypto.rs @@ -1140,6 +1140,8 @@ pub mod key_types { pub const AUTHORITY_DISCOVERY: KeyTypeId = KeyTypeId(*b"audi"); /// Key type for staking, built-in. Identified as `stak`. pub const STAKING: KeyTypeId = KeyTypeId(*b"stak"); + /// A key type for signing statements + pub const STATEMENT: KeyTypeId = KeyTypeId(*b"stmt"); /// A key type ID useful for tests. pub const DUMMY: KeyTypeId = KeyTypeId(*b"dumy"); } diff --git a/primitives/core/src/offchain/mod.rs b/primitives/core/src/offchain/mod.rs index 5a77e19a3e522..a9e6639807023 100644 --- a/primitives/core/src/offchain/mod.rs +++ b/primitives/core/src/offchain/mod.rs @@ -278,16 +278,8 @@ bitflags::bitflags! { const NODE_AUTHORIZATION = 0b0000_1000_0000; /// Access time related functionality const TIME = 0b0001_0000_0000; - } -} - -impl Capabilities { - /// Return capabilities for rich offchain calls. - /// - /// Those calls should be allowed to sign and submit transactions - /// and access offchain workers database (but read only!). - pub fn rich_offchain_call() -> Self { - Capabilities::TRANSACTION_POOL | Capabilities::KEYSTORE | Capabilities::OFFCHAIN_DB_READ + /// Access the statement store. + const STATEMENT_STORE = 0b0010_0000_0000; } } diff --git a/primitives/statement-store/Cargo.toml b/primitives/statement-store/Cargo.toml new file mode 100644 index 0000000000000..5aa4d833637cf --- /dev/null +++ b/primitives/statement-store/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "sp-statement-store" +version = "4.0.0-dev" +authors = ["Parity Technologies "] +edition = "2021" +license = "Apache-2.0" +homepage = "https://substrate.io" +repository = "https://github.com/paritytech/substrate/" +description = "A crate which contains primitives related to the statement store" +readme = "README.md" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.2.2", default-features = false, features = ["derive"] } +scale-info = { version = "2.1.1", default-features = false, features = ["derive"] } +sp-core = { version = "7.0.0", default-features = false, path = "../core" } +sp-runtime = { version = "7.0.0", default-features = false, path = "../runtime" } +sp-std = { version = "5.0.0", default-features = false, path = "../std" } +sp-api = { version = "4.0.0-dev", default-features = false, path = "../api" } +sp-application-crypto = { version = "7.0.0", default-features = false, path = "../application-crypto" } +sp-runtime-interface = { version = "7.0.0", default-features = false, path = "../runtime-interface" } +sp-externalities = { version = "0.13.0", default-features = false, path = "../externalities" } +thiserror = { version = "1.0", optional = true } +log = { version = "0.4.17", optional = true } + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "sp-core/std", + "sp-runtime/std", + "sp-runtime-interface/std", + "sp-std/std", + "sp-api/std", + "sp-application-crypto/std", + "thiserror", + "log", +] diff --git a/primitives/statement-store/README.md b/primitives/statement-store/README.md new file mode 100644 index 0000000000000..fb88aaa4ecd9c --- /dev/null +++ b/primitives/statement-store/README.md @@ -0,0 +1,39 @@ +Statement store is an off-chain data-store for signed statements accessible via RPC and OCW. + +Nodes hold a number of statements with a proof of authenticity owing to an account ID. OCWs can place items in the data-store (with valid signatures) for any accounts whose keys they control. Users can also submit pre-signed statements via RPC. Statements can also be submitted from on-chain logic through an on-chain event. + +A new system event `NewStatement` is added to the runtime. This event allows any account on-chain to declare that they want to make a statement for the store. Within the node store and for broadcasting, the statement would be accompanied with the hash of the block and index of the event within it, essentially taking the place of a real signature. + +Statements comprise an optional proof of authenticity (e.g. a signature) and a number of fields. For statements without a proof, nodes would gossip statements randomly with a rate-limiter to minimise the chance of being overrun by a misbehaving node. These will generally be disregarded by nodes unless they are gossiped by several different peers or if a peer pays for it somehow (e.g. gossiping something valuable). + +Each field is effectively a key/value pair. Fields must be sorted and the same field type may not be repeated. Depending on which keys are present, clients may index the message for ease of retrieval. + +Formally, `Statement` is equivalent to the type `Vec` and `Field` is the SCALE-encoded enumeration: +- 0: `AuthenticityProof(Proof)`: The signature of the message. For cryptography where the public key cannot be derived from the signature together with the message data, then this will also include the signer's public key. The message data is all fields of the messages fields except the signature concatenated together *without the length prefix that a `Vec` would usually imply*. This is so that the signature can be derived without needing to re-encode the statement. +- 1: `DecryptionKey([u8; 32])`: The decryption key identifier which should be used to decrypt the statement's data. In the absense of this field `Data` should be treated as not encrypted. +- 2: `Priority(u32)`: Priority specifier. Higher priority statements should be kept around at the cost of lower priority statements if multiple statements from the same sender are competing for persistence or transport. Nodes should disregard when considering unsigned statements. +- 3: `Channel([u8; 32])`: The channel identifier. Only one message of a given channel should be retained at once (the one of highest priority). Nodes should disregard when considering unsigned statements. +- 4: `Topic1([u8; 32]))`: First topic identifier. +- 5: `Topic2([u8; 32]))`: Second topic identifier. +- 6: `Topic3([u8; 32]))`: Third topic identifier. +- 7: `Topic4([u8; 32]))`: Fourth topic identifier. +- 8: `Data(Vec)`: General data segment. No special meaning. + +`Proof` is defined as the SCALE-encoded enumeration: +- 0: `Sr25519 { signature: [u8; 64], signer: [u8; 32] }` +- 1: `Ed25519 { signature: [u8; 64], signer: [u8; 32] )` +- 2: `Secp256k1Ecdsa { signature: [u8; 65], signer: [u8; 33] )` +- 3: `OnChain { who: [u8; 32], block_hash: [u8; 32], event_index: u64 }` + +### Potential uses + +Potential use-cases are various and include: +- ring-signature aggregation; +- messaging; +- state-channels; +- deferral of the initial "advertising" phase of multi-party transactions; +- publication of preimage data whose hash is referenced on-chain; +- effective transferal of fee payment to a second-party. + + +License: Apache-2.0 diff --git a/primitives/statement-store/src/lib.rs b/primitives/statement-store/src/lib.rs new file mode 100644 index 0000000000000..e5c642d24e2b3 --- /dev/null +++ b/primitives/statement-store/src/lib.rs @@ -0,0 +1,618 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(missing_docs)] + +//! A crate which contains statement-store primitives. + +use codec::{Decode, Encode}; +use scale_info::TypeInfo; +use sp_application_crypto::RuntimeAppPublic; +#[cfg(feature = "std")] +use sp_core::Pair; +use sp_runtime_interface::pass_by::PassByCodec; +use sp_std::vec::Vec; + +/// Statement topic. +pub type Topic = [u8; 32]; +/// Decryption key identifier. +pub type DecryptionKey = [u8; 32]; +/// Statement hash. +pub type Hash = [u8; 32]; +/// Block hash. +pub type BlockHash = [u8; 32]; +/// Account id +pub type AccountId = [u8; 32]; +/// Statement channel. +pub type Channel = [u8; 32]; + +/// Total number of topic fields allowed. +pub const MAX_TOPICS: usize = 4; + +#[cfg(feature = "std")] +pub use store_api::{ + Error, NetworkPriority, Result, StatementSource, StatementStore, SubmitResult, +}; + +pub mod runtime_api; +#[cfg(feature = "std")] +mod store_api; + +mod sr25519 { + mod app_sr25519 { + use sp_application_crypto::{app_crypto, key_types::STATEMENT, sr25519}; + app_crypto!(sr25519, STATEMENT); + } + pub type Public = app_sr25519::Public; +} + +mod ed25519 { + mod app_ed25519 { + use sp_application_crypto::{app_crypto, ed25519, key_types::STATEMENT}; + app_crypto!(ed25519, STATEMENT); + } + pub type Public = app_ed25519::Public; +} + +mod ecdsa { + mod app_ecdsa { + use sp_application_crypto::{app_crypto, ecdsa, key_types::STATEMENT}; + app_crypto!(ecdsa, STATEMENT); + } + pub type Public = app_ecdsa::Public; +} + +/// Returns blake2-256 hash for the encoded statement. +#[cfg(feature = "std")] +pub fn hash_encoded(data: &[u8]) -> [u8; 32] { + sp_core::hashing::blake2_256(data) +} + +/// Statement proof. +#[derive(Encode, Decode, TypeInfo, sp_core::RuntimeDebug, Clone, PartialEq, Eq)] +pub enum Proof { + /// Sr25519 Signature. + Sr25519 { + /// Signature. + signature: [u8; 64], + /// Public key. + signer: [u8; 32], + }, + /// Ed25519 Signature. + Ed25519 { + /// Signature. + signature: [u8; 64], + /// Public key. + signer: [u8; 32], + }, + /// Secp256k1 Signature. + Secp256k1Ecdsa { + /// Signature. + signature: [u8; 65], + /// Public key. + signer: [u8; 33], + }, + /// On-chain event proof. + OnChain { + /// Account identifier associated with the event. + who: AccountId, + /// Hash of block that contains the event. + block_hash: BlockHash, + /// Index of the event in the event list. + event_index: u64, + }, +} + +impl Proof { + /// Return account id for the proof creator. + pub fn account_id(&self) -> AccountId { + match self { + Proof::Sr25519 { signer, .. } => *signer, + Proof::Ed25519 { signer, .. } => *signer, + Proof::Secp256k1Ecdsa { signer, .. } => + ::hash(signer).into(), + Proof::OnChain { who, .. } => *who, + } + } +} + +/// Statement attributes. Each statement is a list of 0 or more fields. Fields may only appear once +/// and in the order declared here. +#[derive(Encode, Decode, TypeInfo, sp_core::RuntimeDebug, Clone, PartialEq, Eq)] +#[repr(u8)] +pub enum Field { + /// Statement proof. + AuthenticityProof(Proof) = 0, + /// An identifier for the key that `Data` field may be decrypted with. + DecryptionKey(DecryptionKey) = 1, + /// Priority when competing with other messages from the same sender. + Priority(u32) = 2, + /// Account channel to use. Only one message per `(account, channel)` pair is allowed. + Channel(Channel) = 3, + /// First statement topic. + Topic1(Topic) = 4, + /// Second statement topic. + Topic2(Topic) = 5, + /// Third statement topic. + Topic3(Topic) = 6, + /// Fourth statement topic. + Topic4(Topic) = 7, + /// Additional data. + Data(Vec) = 8, +} + +impl Field { + fn discriminant(&self) -> u8 { + // This is safe for repr(u8) + // see https://doc.rust-lang.org/reference/items/enumerations.html#pointer-casting + unsafe { *(self as *const Self as *const u8) } + } +} + +/// Statement structure. +#[derive(TypeInfo, sp_core::RuntimeDebug, PassByCodec, Clone, PartialEq, Eq, Default)] +pub struct Statement { + proof: Option, + decryption_key: Option, + channel: Option, + priority: Option, + num_topics: u8, + topics: [Topic; MAX_TOPICS], + data: Option>, +} + +impl Decode for Statement { + fn decode(input: &mut I) -> core::result::Result { + // Encoding matches that of Vec. Basically this just means accepting that there + // will be a prefix of vector length. + let num_fields: codec::Compact = Decode::decode(input)?; + let mut tag = 0; + let mut statement = Statement::new(); + for i in 0..num_fields.into() { + let field: Field = Decode::decode(input)?; + if i > 0 && field.discriminant() <= tag { + return Err("Invalid field order or duplicate fields".into()) + } + tag = field.discriminant(); + match field { + Field::AuthenticityProof(p) => statement.set_proof(p), + Field::DecryptionKey(key) => statement.set_decryption_key(key), + Field::Priority(p) => statement.set_priority(p), + Field::Channel(c) => statement.set_channel(c), + Field::Topic1(t) => statement.set_topic(0, t), + Field::Topic2(t) => statement.set_topic(1, t), + Field::Topic3(t) => statement.set_topic(2, t), + Field::Topic4(t) => statement.set_topic(3, t), + Field::Data(data) => statement.set_plain_data(data), + } + } + Ok(statement) + } +} + +impl Encode for Statement { + fn encode(&self) -> Vec { + self.encoded(false) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +/// Result returned by `Statement::verify_signature` +pub enum SignatureVerificationResult { + /// Signature is valid and matches this account id. + Valid(AccountId), + /// Signature has failed verification. + Invalid, + /// No signature in the proof or no proof. + NoSignature, +} + +impl Statement { + /// Create a new empty statement with no proof. + pub fn new() -> Statement { + Default::default() + } + + /// Create a new statement with a proof. + pub fn new_with_proof(proof: Proof) -> Statement { + let mut statement = Self::new(); + statement.set_proof(proof); + statement + } + + /// Sign with a key that matches given public key in the keystore. + /// + /// Returns `true` if signing worked (private key present etc). + /// + /// NOTE: This can only be called from the runtime. + pub fn sign_sr25519_public(&mut self, key: &sr25519::Public) -> bool { + let to_sign = self.signature_material(); + if let Some(signature) = key.sign(&to_sign) { + let proof = Proof::Sr25519 { + signature: signature.into_inner().into(), + signer: key.clone().into_inner().into(), + }; + self.set_proof(proof); + true + } else { + false + } + } + + /// Sign with a given private key and add the signature proof field. + #[cfg(feature = "std")] + pub fn sign_sr25519_private(&mut self, key: &sp_core::sr25519::Pair) { + let to_sign = self.signature_material(); + let proof = + Proof::Sr25519 { signature: key.sign(&to_sign).into(), signer: key.public().into() }; + self.set_proof(proof); + } + + /// Sign with a key that matches given public key in the keystore. + /// + /// Returns `true` if signing worked (private key present etc). + /// + /// NOTE: This can only be called from the runtime. + pub fn sign_ed25519_public(&mut self, key: &ed25519::Public) -> bool { + let to_sign = self.signature_material(); + if let Some(signature) = key.sign(&to_sign) { + let proof = Proof::Ed25519 { + signature: signature.into_inner().into(), + signer: key.clone().into_inner().into(), + }; + self.set_proof(proof); + true + } else { + false + } + } + + /// Sign with a given private key and add the signature proof field. + #[cfg(feature = "std")] + pub fn sign_ed25519_private(&mut self, key: &sp_core::ed25519::Pair) { + let to_sign = self.signature_material(); + let proof = + Proof::Ed25519 { signature: key.sign(&to_sign).into(), signer: key.public().into() }; + self.set_proof(proof); + } + + /// Sign with a key that matches given public key in the keystore. + /// + /// Returns `true` if signing worked (private key present etc). + /// + /// NOTE: This can only be called from the runtime. + /// + /// Returns `true` if signing worked (private key present etc). + /// + /// NOTE: This can only be called from the runtime. + pub fn sign_ecdsa_public(&mut self, key: &ecdsa::Public) -> bool { + let to_sign = self.signature_material(); + if let Some(signature) = key.sign(&to_sign) { + let proof = Proof::Secp256k1Ecdsa { + signature: signature.into_inner().into(), + signer: key.clone().into_inner().0, + }; + self.set_proof(proof); + true + } else { + false + } + } + + /// Sign with a given private key and add the signature proof field. + #[cfg(feature = "std")] + pub fn sign_ecdsa_private(&mut self, key: &sp_core::ecdsa::Pair) { + let to_sign = self.signature_material(); + let proof = + Proof::Secp256k1Ecdsa { signature: key.sign(&to_sign).into(), signer: key.public().0 }; + self.set_proof(proof); + } + + /// Check proof signature, if any. + pub fn verify_signature(&self) -> SignatureVerificationResult { + use sp_runtime::traits::Verify; + + match self.proof() { + Some(Proof::OnChain { .. }) | None => SignatureVerificationResult::NoSignature, + Some(Proof::Sr25519 { signature, signer }) => { + let to_sign = self.signature_material(); + let signature = sp_core::sr25519::Signature(*signature); + let public = sp_core::sr25519::Public(*signer); + if signature.verify(to_sign.as_slice(), &public) { + SignatureVerificationResult::Valid(*signer) + } else { + SignatureVerificationResult::Invalid + } + }, + Some(Proof::Ed25519 { signature, signer }) => { + let to_sign = self.signature_material(); + let signature = sp_core::ed25519::Signature(*signature); + let public = sp_core::ed25519::Public(*signer); + if signature.verify(to_sign.as_slice(), &public) { + SignatureVerificationResult::Valid(*signer) + } else { + SignatureVerificationResult::Invalid + } + }, + Some(Proof::Secp256k1Ecdsa { signature, signer }) => { + let to_sign = self.signature_material(); + let signature = sp_core::ecdsa::Signature(*signature); + let public = sp_core::ecdsa::Public(*signer); + if signature.verify(to_sign.as_slice(), &public) { + let sender_hash = + ::hash(signer); + SignatureVerificationResult::Valid(sender_hash.into()) + } else { + SignatureVerificationResult::Invalid + } + }, + } + } + + /// Calculate statement hash. + #[cfg(feature = "std")] + pub fn hash(&self) -> [u8; 32] { + self.using_encoded(hash_encoded) + } + + /// Returns a topic by topic index. + pub fn topic(&self, index: usize) -> Option { + if index < self.num_topics as usize { + Some(self.topics[index]) + } else { + None + } + } + + /// Returns decryption key if any. + pub fn decryption_key(&self) -> Option { + self.decryption_key + } + + /// Convert to internal data. + pub fn into_data(self) -> Option> { + self.data + } + + /// Get a reference to the statement proof, if any. + pub fn proof(&self) -> Option<&Proof> { + self.proof.as_ref() + } + + /// Get proof account id, if any + pub fn account_id(&self) -> Option { + self.proof.as_ref().map(Proof::account_id) + } + + /// Get plain data. + pub fn data(&self) -> Option<&Vec> { + self.data.as_ref() + } + + /// Get plain data len. + pub fn data_len(&self) -> usize { + self.data().map_or(0, Vec::len) + } + + /// Get channel, if any. + pub fn channel(&self) -> Option { + self.channel + } + + /// Get priority, if any. + pub fn priority(&self) -> Option { + self.priority + } + + /// Return encoded fields that can be signed to construct or verify a proof + fn signature_material(&self) -> Vec { + self.encoded(true) + } + + /// Remove the proof of this statement. + pub fn remove_proof(&mut self) { + self.proof = None; + } + + /// Set statement proof. Any existing proof is overwritten. + pub fn set_proof(&mut self, proof: Proof) { + self.proof = Some(proof) + } + + /// Set statement priority. + pub fn set_priority(&mut self, priority: u32) { + self.priority = Some(priority) + } + + /// Set statement channel. + pub fn set_channel(&mut self, channel: Channel) { + self.channel = Some(channel) + } + + /// Set topic by index. Does noting if index is over `MAX_TOPICS`. + pub fn set_topic(&mut self, index: usize, topic: Topic) { + if index < MAX_TOPICS { + self.topics[index] = topic; + self.num_topics = self.num_topics.max(index as u8 + 1); + } + } + + /// Set decryption key. + pub fn set_decryption_key(&mut self, key: DecryptionKey) { + self.decryption_key = Some(key); + } + + /// Set unencrypted statement data. + pub fn set_plain_data(&mut self, data: Vec) { + self.data = Some(data) + } + + fn encoded(&self, for_signing: bool) -> Vec { + // Encoding matches that of Vec. Basically this just means accepting that there + // will be a prefix of vector length. + let num_fields = if !for_signing && self.proof.is_some() { 1 } else { 0 } + + if self.decryption_key.is_some() { 1 } else { 0 } + + if self.priority.is_some() { 1 } else { 0 } + + if self.channel.is_some() { 1 } else { 0 } + + if self.data.is_some() { 1 } else { 0 } + + self.num_topics as u32; + + let mut output = Vec::new(); + // When encoding signature payload, the length prefix is omitted. + // This is so that the signature for encoded statement can potentially be derived without + // needing to re-encode the statement. + if !for_signing { + let compact_len = codec::Compact::(num_fields); + compact_len.encode_to(&mut output); + + if let Some(proof) = &self.proof { + 0u8.encode_to(&mut output); + proof.encode_to(&mut output); + } + } + if let Some(decryption_key) = &self.decryption_key { + 1u8.encode_to(&mut output); + decryption_key.encode_to(&mut output); + } + if let Some(priority) = &self.priority { + 2u8.encode_to(&mut output); + priority.encode_to(&mut output); + } + if let Some(channel) = &self.channel { + 3u8.encode_to(&mut output); + channel.encode_to(&mut output); + } + for t in 0..self.num_topics { + (4u8 + t).encode_to(&mut output); + self.topics[t as usize].encode_to(&mut output); + } + if let Some(data) = &self.data { + 8u8.encode_to(&mut output); + data.encode_to(&mut output); + } + output + } +} + +#[cfg(test)] +mod test { + use crate::{hash_encoded, Field, Proof, SignatureVerificationResult, Statement}; + use codec::{Decode, Encode}; + use sp_application_crypto::Pair; + + #[test] + fn statement_encoding_matches_vec() { + let mut statement = Statement::new(); + assert!(statement.proof().is_none()); + let proof = Proof::OnChain { who: [42u8; 32], block_hash: [24u8; 32], event_index: 66 }; + + let decryption_key = [0xde; 32]; + let topic1 = [0x01; 32]; + let topic2 = [0x02; 32]; + let data = vec![55, 99]; + let priority = 999; + let channel = [0xcc; 32]; + + statement.set_proof(proof.clone()); + statement.set_decryption_key(decryption_key); + statement.set_priority(priority); + statement.set_channel(channel); + statement.set_topic(0, topic1); + statement.set_topic(1, topic2); + statement.set_plain_data(data.clone()); + + statement.set_topic(5, [0x55; 32]); + assert_eq!(statement.topic(5), None); + + let fields = vec![ + Field::AuthenticityProof(proof.clone()), + Field::DecryptionKey(decryption_key), + Field::Priority(priority), + Field::Channel(channel), + Field::Topic1(topic1), + Field::Topic2(topic2), + Field::Data(data.clone()), + ]; + + let encoded = statement.encode(); + assert_eq!(statement.hash(), hash_encoded(&encoded)); + assert_eq!(encoded, fields.encode()); + + let decoded = Statement::decode(&mut encoded.as_slice()).unwrap(); + assert_eq!(decoded, statement); + } + + #[test] + fn decode_checks_fields() { + let topic1 = [0x01; 32]; + let topic2 = [0x02; 32]; + let priority = 999; + + let fields = vec![ + Field::Priority(priority), + Field::Topic1(topic1), + Field::Topic1(topic1), + Field::Topic2(topic2), + ] + .encode(); + + assert!(Statement::decode(&mut fields.as_slice()).is_err()); + + let fields = + vec![Field::Topic1(topic1), Field::Priority(priority), Field::Topic2(topic2)].encode(); + + assert!(Statement::decode(&mut fields.as_slice()).is_err()); + } + + #[test] + fn sign_and_verify() { + let mut statement = Statement::new(); + statement.set_plain_data(vec![42]); + + let sr25519_kp = sp_core::sr25519::Pair::from_string("//Alice", None).unwrap(); + let ed25519_kp = sp_core::ed25519::Pair::from_string("//Alice", None).unwrap(); + let secp256k1_kp = sp_core::ecdsa::Pair::from_string("//Alice", None).unwrap(); + + statement.sign_sr25519_private(&sr25519_kp); + assert_eq!( + statement.verify_signature(), + SignatureVerificationResult::Valid(sr25519_kp.public().0) + ); + + statement.sign_ed25519_private(&ed25519_kp); + assert_eq!( + statement.verify_signature(), + SignatureVerificationResult::Valid(ed25519_kp.public().0) + ); + + statement.sign_ecdsa_private(&secp256k1_kp); + assert_eq!( + statement.verify_signature(), + SignatureVerificationResult::Valid(sp_core::hashing::blake2_256( + &secp256k1_kp.public().0 + )) + ); + + // set an invalid signature + statement.set_proof(Proof::Sr25519 { signature: [0u8; 64], signer: [0u8; 32] }); + assert_eq!(statement.verify_signature(), SignatureVerificationResult::Invalid); + + statement.remove_proof(); + assert_eq!(statement.verify_signature(), SignatureVerificationResult::NoSignature); + } +} diff --git a/primitives/statement-store/src/runtime_api.rs b/primitives/statement-store/src/runtime_api.rs new file mode 100644 index 0000000000000..13f88bc977e9e --- /dev/null +++ b/primitives/statement-store/src/runtime_api.rs @@ -0,0 +1,187 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Runtime support for the statement store. + +use crate::{Hash, Statement, Topic}; +use codec::{Decode, Encode}; +use scale_info::TypeInfo; +use sp_runtime::RuntimeDebug; +use sp_runtime_interface::{pass_by::PassByEnum, runtime_interface}; +use sp_std::vec::Vec; + +#[cfg(feature = "std")] +use sp_externalities::ExternalitiesExt; + +/// Information concerning a valid statement. +#[derive(Clone, PartialEq, Eq, Encode, Decode, RuntimeDebug, TypeInfo)] +pub struct ValidStatement { + /// Max statement count for this account, as calculated by the runtime. + pub max_count: u32, + /// Max total data size for this account, as calculated by the runtime. + pub max_size: u32, +} + +/// An reason for an invalid statement. +#[derive(Clone, PartialEq, Eq, Encode, Decode, Copy, RuntimeDebug, TypeInfo)] +pub enum InvalidStatement { + /// Failed proof validation. + BadProof, + /// Missing proof. + NoProof, + /// Validity could not be checked because of internal error. + InternalError, +} + +/// The source of the statement. +/// +/// Depending on the source we might apply different validation schemes. +#[derive(Copy, Clone, PartialEq, Eq, Encode, Decode, RuntimeDebug, TypeInfo)] +pub enum StatementSource { + /// Statement is coming from the on-chain worker. + Chain, + /// Statement has been received from the gossip network. + Network, + /// Statement has been submitted over the local api. + Local, +} + +impl StatementSource { + /// Check if the source allows the statement to be resubmitted to the store, extending its + /// expiration date. + pub fn can_be_resubmitted(&self) -> bool { + match self { + StatementSource::Chain | StatementSource::Local => true, + StatementSource::Network => false, + } + } +} + +sp_api::decl_runtime_apis! { + /// Runtime API trait for statement validation. + pub trait ValidateStatement { + /// Validate the statement. + fn validate_statement( + source: StatementSource, + statement: Statement, + ) -> Result; + } +} + +#[cfg(feature = "std")] +sp_externalities::decl_extension! { + /// The offchain database extension that will be registered at the Substrate externalities. + pub struct StatementStoreExt(std::sync::Arc); +} + +// Host extensions for the runtime. +#[cfg(feature = "std")] +impl StatementStoreExt { + /// Create new instance of externalities extensions. + pub fn new(store: std::sync::Arc) -> Self { + Self(store) + } +} + +/// Submission result. +#[derive(Debug, Eq, PartialEq, Clone, Copy, Encode, Decode, PassByEnum)] +pub enum SubmitResult { + /// Accepted as new. + OkNew, + /// Known statement + OkKnown, + /// Statement failed validation. + Bad, + /// The store is not available. + NotAvailable, + /// Statement could not be inserted because of priority or size checks. + Full, +} + +/// Export functions for the WASM host. +#[cfg(feature = "std")] +pub type HostFunctions = (statement_store::HostFunctions,); + +/// Host interface +#[runtime_interface] +pub trait StatementStore { + /// Submit a new new statement. The statement will be broadcast to the network. + /// This is meant to be used by the offchain worker. + fn submit_statement(&mut self, statement: Statement) -> SubmitResult { + if let Some(StatementStoreExt(store)) = self.extension::() { + match store.submit(statement, StatementSource::Chain) { + crate::SubmitResult::New(_) => SubmitResult::OkNew, + crate::SubmitResult::Known => SubmitResult::OkKnown, + crate::SubmitResult::Ignored => SubmitResult::Full, + // This should not happen for `StatementSource::Chain`. An existing statement will + // be overwritten. + crate::SubmitResult::KnownExpired => SubmitResult::Bad, + crate::SubmitResult::Bad(_) => SubmitResult::Bad, + crate::SubmitResult::InternalError(_) => SubmitResult::Bad, + } + } else { + SubmitResult::NotAvailable + } + } + + /// Return all statements. + fn statements(&mut self) -> Vec<(Hash, Statement)> { + if let Some(StatementStoreExt(store)) = self.extension::() { + store.statements().unwrap_or_default() + } else { + Vec::default() + } + } + + /// Return the data of all known statements which include all topics and have no `DecryptionKey` + /// field. + fn broadcasts(&mut self, match_all_topics: &[Topic]) -> Vec> { + if let Some(StatementStoreExt(store)) = self.extension::() { + store.broadcasts(match_all_topics).unwrap_or_default() + } else { + Vec::default() + } + } + + /// Return the data of all known statements whose decryption key is identified as `dest` (this + /// will generally be the public key or a hash thereof for symmetric ciphers, or a hash of the + /// private key for symmetric ciphers). + fn posted(&mut self, match_all_topics: &[Topic], dest: [u8; 32]) -> Vec> { + if let Some(StatementStoreExt(store)) = self.extension::() { + store.posted(match_all_topics, dest).unwrap_or_default() + } else { + Vec::default() + } + } + + /// Return the decrypted data of all known statements whose decryption key is identified as + /// `dest`. The key must be available to the client. + fn posted_clear(&mut self, match_all_topics: &[Topic], dest: [u8; 32]) -> Vec> { + if let Some(StatementStoreExt(store)) = self.extension::() { + store.posted_clear(match_all_topics, dest).unwrap_or_default() + } else { + Vec::default() + } + } + + /// Remove a statement from the store by hash. + fn remove(&mut self, hash: &Hash) { + if let Some(StatementStoreExt(store)) = self.extension::() { + store.remove(hash).unwrap_or_default() + } + } +} diff --git a/primitives/statement-store/src/store_api.rs b/primitives/statement-store/src/store_api.rs new file mode 100644 index 0000000000000..89daa3e963c56 --- /dev/null +++ b/primitives/statement-store/src/store_api.rs @@ -0,0 +1,90 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub use crate::runtime_api::StatementSource; +use crate::{Hash, Statement, Topic}; + +/// Statement store error. +#[derive(Debug, Eq, PartialEq, thiserror::Error)] +pub enum Error { + /// Database error. + #[error("Database error: {0:?}")] + Db(String), + /// Error decoding statement structure. + #[error("Error decoding statement: {0:?}")] + Decode(String), + /// Error making runtime call. + #[error("Error calling into the runtime")] + Runtime, +} + +#[derive(Debug, PartialEq, Eq)] +/// Network propagation priority. +pub enum NetworkPriority { + /// High priority. Statement should be broadcast to all peers. + High, + /// Low priority. + Low, +} + +/// Statement submission outcome +#[derive(Debug, Eq, PartialEq)] +pub enum SubmitResult { + /// Accepted as new with given score + New(NetworkPriority), + /// Known statement + Known, + /// Known statement that's already expired. + KnownExpired, + /// Priority is too low or the size is too big. + Ignored, + /// Statement failed validation. + Bad(&'static str), + /// Internal store error. + InternalError(Error), +} + +/// Result type for `Error` +pub type Result = std::result::Result; + +/// Statement store API. +pub trait StatementStore: Send + Sync { + /// Return all statements. + fn statements(&self) -> Result>; + + /// Get statement by hash. + fn statement(&self, hash: &Hash) -> Result>; + + /// Return the data of all known statements which include all topics and have no `DecryptionKey` + /// field. + fn broadcasts(&self, match_all_topics: &[Topic]) -> Result>>; + + /// Return the data of all known statements whose decryption key is identified as `dest` (this + /// will generally be the public key or a hash thereof for symmetric ciphers, or a hash of the + /// private key for symmetric ciphers). + fn posted(&self, match_all_topics: &[Topic], dest: [u8; 32]) -> Result>>; + + /// Return the decrypted data of all known statements whose decryption key is identified as + /// `dest`. The key must be available to the client. + fn posted_clear(&self, match_all_topics: &[Topic], dest: [u8; 32]) -> Result>>; + + /// Submit a statement. + fn submit(&self, statement: Statement, source: StatementSource) -> SubmitResult; + + /// Remove a statement from the store. + fn remove(&self, hash: &Hash) -> Result<()>; +} diff --git a/scripts/ci/deny.toml b/scripts/ci/deny.toml index f932875937606..91822c831cc19 100644 --- a/scripts/ci/deny.toml +++ b/scripts/ci/deny.toml @@ -75,6 +75,7 @@ exceptions = [ { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-network-sync" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-network-test" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-network-transactions" }, + { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-network-statement" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-offchain" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-peerset" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-proposer-metrics" }, @@ -86,6 +87,7 @@ exceptions = [ { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-service" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-service-test" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-state-db" }, + { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-statement-store" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-storage-monitor" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-sysinfo" }, { allow = ["GPL-3.0 WITH Classpath-exception-2.0"], name = "sc-telemetry" },