From 8561a11c290c5df252a45bb8e1c5d0cb9fcc7a09 Mon Sep 17 00:00:00 2001 From: Steve Degosserie <723552+stiiifff@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:34:32 +0100 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E2=9C=A8=20Bump=20client=20version?= =?UTF-8?q?=20to=20v0.26.0=20and=20runtime=20to=20RT1400=20(#465)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- operator/Cargo.lock | 122 +++++++++++++-------------- operator/Cargo.toml | 2 +- operator/runtime/mainnet/src/lib.rs | 2 +- operator/runtime/stagenet/src/lib.rs | 2 +- operator/runtime/testnet/src/lib.rs | 2 +- test/.papi/metadata/datahaven.scale | Bin 631232 -> 631232 bytes 6 files changed, 65 insertions(+), 65 deletions(-) diff --git a/operator/Cargo.lock b/operator/Cargo.lock index 702cc2d4..66120801 100644 --- a/operator/Cargo.lock +++ b/operator/Cargo.lock @@ -1521,7 +1521,7 @@ dependencies = [ "pallet-message-queue", "parity-scale-codec", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "sp-core", "sp-runtime", "sp-std", @@ -2607,7 +2607,7 @@ dependencies = [ [[package]] name = "datahaven-mainnet-runtime" -version = "0.25.0" +version = "0.26.0" dependencies = [ "alloy-core", "bridge-hub-common 0.13.1", @@ -2720,8 +2720,8 @@ dependencies = [ "shp-treasury-funding", "shp-tx-implicits-runtime-api", "smallvec", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "snowbridge-merkle-tree", "snowbridge-outbound-queue-primitives", @@ -2764,7 +2764,7 @@ dependencies = [ [[package]] name = "datahaven-node" -version = "0.25.0" +version = "0.26.0" dependencies = [ "async-channel 1.9.0", "clap", @@ -2877,7 +2877,7 @@ dependencies = [ [[package]] name = "datahaven-runtime-common" -version = "0.25.0" +version = "0.26.0" dependencies = [ "alloy-core", "fp-account", @@ -2911,7 +2911,7 @@ dependencies = [ [[package]] name = "datahaven-stagenet-runtime" -version = "0.25.0" +version = "0.26.0" dependencies = [ "alloy-core", "bridge-hub-common 0.13.1", @@ -3024,8 +3024,8 @@ dependencies = [ "shp-treasury-funding", "shp-tx-implicits-runtime-api", "smallvec", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "snowbridge-merkle-tree", "snowbridge-outbound-queue-primitives", @@ -3068,7 +3068,7 @@ dependencies = [ [[package]] name = "datahaven-testnet-runtime" -version = "0.25.0" +version = "0.26.0" dependencies = [ "alloy-core", "bridge-hub-common 0.13.1", @@ -3181,8 +3181,8 @@ dependencies = [ "shp-treasury-funding", "shp-tx-implicits-runtime-api", "smallvec", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "snowbridge-merkle-tree", "snowbridge-outbound-queue-primitives", @@ -3374,7 +3374,7 @@ dependencies = [ [[package]] name = "dhp-bridge" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-support", "frame-system", @@ -3382,7 +3382,7 @@ dependencies = [ "pallet-datahaven-native-transfer", "pallet-external-validators", "parity-scale-codec", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "sp-core", "sp-std", @@ -8719,7 +8719,7 @@ dependencies = [ [[package]] name = "pallet-datahaven-native-transfer" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -8727,7 +8727,7 @@ dependencies = [ "pallet-balances", "parity-scale-codec", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-outbound-queue-primitives", "sp-core", "sp-io", @@ -8831,7 +8831,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-balances-erc20" -version = "0.25.0" +version = "0.26.0" dependencies = [ "fp-evm", "frame-support", @@ -8854,7 +8854,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-batch" -version = "0.25.0" +version = "0.26.0" dependencies = [ "evm", "fp-evm", @@ -8893,7 +8893,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-call-permit" -version = "0.25.0" +version = "0.26.0" dependencies = [ "evm", "fp-evm", @@ -8959,7 +8959,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-datahaven-native-transfer" -version = "0.25.0" +version = "0.26.0" dependencies = [ "evm", "fp-evm", @@ -8973,7 +8973,7 @@ dependencies = [ "parity-scale-codec", "precompile-utils", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-outbound-queue-primitives", "sp-core", "sp-io", @@ -9052,7 +9052,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-proxy" -version = "0.25.0" +version = "0.26.0" dependencies = [ "evm", "fp-evm", @@ -9096,7 +9096,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-registry" -version = "0.25.0" +version = "0.26.0" dependencies = [ "fp-evm", "frame-support", @@ -9147,7 +9147,7 @@ dependencies = [ "parity-scale-codec", "scale-info", "serde", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-outbound-queue-primitives", "sp-core", "sp-io", @@ -9157,7 +9157,7 @@ dependencies = [ [[package]] name = "pallet-external-validators" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -9181,7 +9181,7 @@ dependencies = [ [[package]] name = "pallet-external-validators-rewards" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -9194,7 +9194,7 @@ dependencies = [ "pallet-timestamp", "parity-scale-codec", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-outbound-queue-primitives", "sp-core", "sp-io", @@ -9287,7 +9287,7 @@ dependencies = [ [[package]] name = "pallet-grandpa-benchmarking" -version = "0.25.0" +version = "0.26.0" dependencies = [ "finality-grandpa", "frame-benchmarking", @@ -9439,7 +9439,7 @@ dependencies = [ [[package]] name = "pallet-outbound-commitment-store" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-support", "frame-system", @@ -9563,7 +9563,7 @@ dependencies = [ [[package]] name = "pallet-proxy-genesis-companion" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-support", "frame-system", @@ -9674,7 +9674,7 @@ dependencies = [ [[package]] name = "pallet-session-benchmarking" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -14827,7 +14827,7 @@ dependencies = [ [[package]] name = "snowbridge-beacon-primitives" -version = "0.25.0" +version = "0.26.0" dependencies = [ "byte-slice-cast", "frame-support", @@ -14872,7 +14872,7 @@ dependencies = [ [[package]] name = "snowbridge-core" -version = "0.25.0" +version = "0.26.0" dependencies = [ "bp-relayers", "ethabi-decode", @@ -14949,8 +14949,8 @@ dependencies = [ "log", "parity-scale-codec", "scale-info", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-verification-primitives", "sp-core", "sp-io", @@ -14963,7 +14963,7 @@ dependencies = [ [[package]] name = "snowbridge-merkle-tree" -version = "0.25.0" +version = "0.26.0" dependencies = [ "array-bytes", "hex", @@ -15004,7 +15004,7 @@ dependencies = [ [[package]] name = "snowbridge-outbound-queue-primitives" -version = "0.25.0" +version = "0.26.0" dependencies = [ "alloy-core", "ethabi-decode", @@ -15016,7 +15016,7 @@ dependencies = [ "parity-scale-codec", "polkadot-parachain-primitives", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-verification-primitives", "sp-arithmetic", "sp-core", @@ -15030,12 +15030,12 @@ dependencies = [ [[package]] name = "snowbridge-outbound-queue-v2-runtime-api" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-support", "parity-scale-codec", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-merkle-tree", "snowbridge-outbound-queue-primitives", "sp-api", @@ -15045,7 +15045,7 @@ dependencies = [ [[package]] name = "snowbridge-pallet-ethereum-client" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -15058,8 +15058,8 @@ dependencies = [ "scale-info", "serde", "serde_json", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-ethereum 0.3.0", "snowbridge-inbound-queue-primitives", "snowbridge-pallet-ethereum-client-fixtures", @@ -15075,8 +15075,8 @@ name = "snowbridge-pallet-ethereum-client-fixtures" version = "0.9.0" dependencies = [ "hex-literal 0.3.4", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "sp-core", "sp-std", @@ -15084,7 +15084,7 @@ dependencies = [ [[package]] name = "snowbridge-pallet-inbound-queue-v2" -version = "0.25.0" +version = "0.26.0" dependencies = [ "alloy-core", "bp-relayers", @@ -15098,8 +15098,8 @@ dependencies = [ "parity-scale-codec", "scale-info", "serde", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "snowbridge-pallet-ethereum-client", "snowbridge-pallet-inbound-queue-v2-fixtures", @@ -15120,8 +15120,8 @@ name = "snowbridge-pallet-inbound-queue-v2-fixtures" version = "0.10.0" dependencies = [ "hex-literal 0.3.4", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "sp-core", "sp-std", @@ -15151,7 +15151,7 @@ dependencies = [ [[package]] name = "snowbridge-pallet-outbound-queue-v2" -version = "0.25.0" +version = "0.26.0" dependencies = [ "alloy-core", "bp-relayers", @@ -15165,8 +15165,8 @@ dependencies = [ "parity-scale-codec", "scale-info", "serde", - "snowbridge-beacon-primitives 0.25.0", - "snowbridge-core 0.25.0", + "snowbridge-beacon-primitives 0.26.0", + "snowbridge-core 0.26.0", "snowbridge-inbound-queue-primitives", "snowbridge-merkle-tree", "snowbridge-outbound-queue-primitives", @@ -15197,7 +15197,7 @@ dependencies = [ "parity-scale-codec", "polkadot-primitives", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-outbound-queue-primitives", "snowbridge-pallet-outbound-queue", "sp-core", @@ -15210,7 +15210,7 @@ dependencies = [ [[package]] name = "snowbridge-pallet-system-v2" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -15222,7 +15222,7 @@ dependencies = [ "parity-scale-codec", "polkadot-primitives", "scale-info", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "snowbridge-outbound-queue-primitives", "snowbridge-pallet-outbound-queue-v2", "snowbridge-pallet-system", @@ -15238,10 +15238,10 @@ dependencies = [ [[package]] name = "snowbridge-system-v2-runtime-api" -version = "0.25.0" +version = "0.26.0" dependencies = [ "parity-scale-codec", - "snowbridge-core 0.25.0", + "snowbridge-core 0.26.0", "sp-api", "sp-std", "staging-xcm", @@ -15249,7 +15249,7 @@ dependencies = [ [[package]] name = "snowbridge-test-utils" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -15269,12 +15269,12 @@ dependencies = [ [[package]] name = "snowbridge-verification-primitives" -version = "0.25.0" +version = "0.26.0" dependencies = [ "frame-support", "parity-scale-codec", "scale-info", - "snowbridge-beacon-primitives 0.25.0", + "snowbridge-beacon-primitives 0.26.0", "sp-core", "sp-std", ] diff --git a/operator/Cargo.toml b/operator/Cargo.toml index 194e5f4e..b27e5176 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" homepage = "https://datahaven.xyz/" license = "GPL-3" repository = "https://github.com/datahavenxyz/datahaven" -version = "0.25.0" +version = "0.26.0" [workspace] members = [ diff --git a/operator/runtime/mainnet/src/lib.rs b/operator/runtime/mainnet/src/lib.rs index 94b4ec5b..f295318a 100644 --- a/operator/runtime/mainnet/src/lib.rs +++ b/operator/runtime/mainnet/src/lib.rs @@ -142,7 +142,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 200 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 1300, + spec_version: 1400, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/operator/runtime/stagenet/src/lib.rs b/operator/runtime/stagenet/src/lib.rs index 0655d3be..df43090f 100644 --- a/operator/runtime/stagenet/src/lib.rs +++ b/operator/runtime/stagenet/src/lib.rs @@ -145,7 +145,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 200 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 1300, + spec_version: 1400, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/operator/runtime/testnet/src/lib.rs b/operator/runtime/testnet/src/lib.rs index c177d49b..a8248dbd 100644 --- a/operator/runtime/testnet/src/lib.rs +++ b/operator/runtime/testnet/src/lib.rs @@ -142,7 +142,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 200 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 1300, + spec_version: 1400, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/test/.papi/metadata/datahaven.scale b/test/.papi/metadata/datahaven.scale index 5b2fcad3cab1d654fcef964afa8a0cdcfeac95f9..359a4996ce83cd35c179a02ef7773a7cd14400ef 100644 GIT binary patch delta 43 ucmX?bSna@JwT2eP7N!>F7M3lne&&o7?f&MhK+Fcj>_E)1-QS$EFbe=xu?`ae delta 43 ucmX?bSna@JwT2eP7N!>F7M3lne&&oK?f&MhK+Fcj>_E)1-QS$EFbe=owGFQT From 58c0101cca11d6e6be517349890979ee6b6843cf Mon Sep 17 00:00:00 2001 From: undercover-cactus Date: Wed, 4 Mar 2026 08:49:14 +0100 Subject: [PATCH 2/8] refactor: remove the unused pallet-staking from dependencies (#466) ## Summary Remove unused dependency `pallet-staking`. ## What changes * Remove `pallet-staking` from the Cargo.toml file * Update Cargo.lock Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com> --- operator/Cargo.lock | 1 - operator/Cargo.toml | 1 - operator/pallets/external-validator-slashes/Cargo.toml | 4 ---- 3 files changed, 6 deletions(-) diff --git a/operator/Cargo.lock b/operator/Cargo.lock index 66120801..2843c578 100644 --- a/operator/Cargo.lock +++ b/operator/Cargo.lock @@ -9142,7 +9142,6 @@ dependencies = [ "log", "pallet-external-validators", "pallet-session", - "pallet-staking", "pallet-timestamp", "parity-scale-codec", "scale-info", diff --git a/operator/Cargo.toml b/operator/Cargo.toml index b27e5176..ab8b6185 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -146,7 +146,6 @@ pallet-referenda = { git = "https://github.com/paritytech/polkadot-sdk", tag = " pallet-safe-mode = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-scheduler = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-session = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } -pallet-staking = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-sudo = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-timestamp = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } pallet-transaction-payment = { git = "https://github.com/paritytech/polkadot-sdk", tag = "polkadot-stable2412-6", default-features = false } diff --git a/operator/pallets/external-validator-slashes/Cargo.toml b/operator/pallets/external-validator-slashes/Cargo.toml index cf9a9804..7dd77a28 100644 --- a/operator/pallets/external-validator-slashes/Cargo.toml +++ b/operator/pallets/external-validator-slashes/Cargo.toml @@ -18,7 +18,6 @@ frame-support = { workspace = true } frame-system = { workspace = true } log = { workspace = true } pallet-session = { workspace = true } -pallet-staking = { workspace = true } parity-scale-codec = { workspace = true, features = ["derive", "max-encoded-len"] } scale-info = { workspace = true } snowbridge-core = { workspace = true } @@ -42,7 +41,6 @@ std = [ "frame-system/std", "log/std", "pallet-session/std", - "pallet-staking/std", "pallet-timestamp/std", "parity-scale-codec/std", "pallet-external-validators/std", @@ -58,7 +56,6 @@ runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", - "pallet-staking/runtime-benchmarks", "pallet-timestamp/runtime-benchmarks", "pallet-external-validators/runtime-benchmarks", "snowbridge-core/runtime-benchmarks", @@ -70,7 +67,6 @@ try-runtime = [ "frame-support/try-runtime", "frame-system/try-runtime", "pallet-session/try-runtime", - "pallet-staking/try-runtime", "pallet-timestamp/try-runtime", "sp-runtime/try-runtime", ] From 70d748fb2b6ca9b233abf7d91eaacce14bdb925d Mon Sep 17 00:00:00 2001 From: Tobi Demeco <50408393+TDemeco@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:56:06 -0300 Subject: [PATCH 3/8] =?UTF-8?q?build:=20=E2=AC=86=EF=B8=8F=20upgrade=20to?= =?UTF-8?q?=20StorageHub=20v0.4.3=20(#468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New update to StorageHub containing only a simple bugfix. No changes required in DataHaven Co-authored-by: undercover-cactus --- operator/Cargo.lock | 148 ++++++++++++++++++++++---------------------- operator/Cargo.toml | 68 ++++++++++---------- 2 files changed, 108 insertions(+), 108 deletions(-) diff --git a/operator/Cargo.lock b/operator/Cargo.lock index 2843c578..586435c3 100644 --- a/operator/Cargo.lock +++ b/operator/Cargo.lock @@ -8642,8 +8642,8 @@ dependencies = [ [[package]] name = "pallet-bucket-nfts" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-benchmarking", "frame-support", @@ -8699,8 +8699,8 @@ dependencies = [ [[package]] name = "pallet-cr-randomness" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-support", "frame-system", @@ -8983,8 +8983,8 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-file-system" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "fp-account", "fp-evm", @@ -9222,8 +9222,8 @@ dependencies = [ [[package]] name = "pallet-file-system" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-benchmarking", "frame-support", @@ -9251,8 +9251,8 @@ dependencies = [ [[package]] name = "pallet-file-system-runtime-api" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "parity-scale-codec", "scale-info", @@ -9466,8 +9466,8 @@ dependencies = [ [[package]] name = "pallet-payment-streams" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-benchmarking", "frame-support", @@ -9486,8 +9486,8 @@ dependencies = [ [[package]] name = "pallet-payment-streams-runtime-api" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "parity-scale-codec", "scale-info", @@ -9514,8 +9514,8 @@ dependencies = [ [[package]] name = "pallet-proofs-dealer" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-benchmarking", "frame-support", @@ -9540,8 +9540,8 @@ dependencies = [ [[package]] name = "pallet-proofs-dealer-runtime-api" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "parity-scale-codec", "scale-info", @@ -9579,8 +9579,8 @@ dependencies = [ [[package]] name = "pallet-randomness" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-benchmarking", "frame-support", @@ -9717,8 +9717,8 @@ dependencies = [ [[package]] name = "pallet-storage-providers" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-benchmarking", "frame-support", @@ -9739,8 +9739,8 @@ dependencies = [ [[package]] name = "pallet-storage-providers-runtime-api" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "parity-scale-codec", "scale-info", @@ -13902,8 +13902,8 @@ dependencies = [ [[package]] name = "shc-actors-derive" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "once_cell", "proc-macro2", @@ -13915,8 +13915,8 @@ dependencies = [ [[package]] name = "shc-actors-framework" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "anyhow", "bincode", @@ -13934,8 +13934,8 @@ dependencies = [ [[package]] name = "shc-blockchain-service" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "anyhow", "array-bytes", @@ -13990,8 +13990,8 @@ dependencies = [ [[package]] name = "shc-blockchain-service-db" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "chrono", "diesel", @@ -14014,8 +14014,8 @@ dependencies = [ [[package]] name = "shc-client" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "anyhow", "array-bytes", @@ -14089,8 +14089,8 @@ dependencies = [ [[package]] name = "shc-common" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "anyhow", "bigdecimal", @@ -14154,8 +14154,8 @@ dependencies = [ [[package]] name = "shc-file-manager" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "bincode", "hash-db", @@ -14179,8 +14179,8 @@ dependencies = [ [[package]] name = "shc-file-transfer-service" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "anyhow", "array-bytes", @@ -14210,8 +14210,8 @@ dependencies = [ [[package]] name = "shc-fisherman-service" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "async-trait", "diesel", @@ -14241,8 +14241,8 @@ dependencies = [ [[package]] name = "shc-forest-manager" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "anyhow", "async-trait", @@ -14267,8 +14267,8 @@ dependencies = [ [[package]] name = "shc-indexer-db" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "bigdecimal", "chrono", @@ -14295,8 +14295,8 @@ dependencies = [ [[package]] name = "shc-indexer-service" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "anyhow", "array-bytes", @@ -14346,8 +14346,8 @@ dependencies = [ [[package]] name = "shc-rpc" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "array-bytes", "async-trait", @@ -14392,8 +14392,8 @@ dependencies = [ [[package]] name = "shc-telemetry" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "log", "substrate-prometheus-endpoint", @@ -14409,8 +14409,8 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "shp-constants" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "sp-core", "sp-runtime", @@ -14418,8 +14418,8 @@ dependencies = [ [[package]] name = "shp-data-price-updater" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-support", "parity-scale-codec", @@ -14433,8 +14433,8 @@ dependencies = [ [[package]] name = "shp-file-key-verifier" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-support", "parity-scale-codec", @@ -14451,8 +14451,8 @@ dependencies = [ [[package]] name = "shp-file-metadata" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "hex", "num-bigint", @@ -14467,8 +14467,8 @@ dependencies = [ [[package]] name = "shp-forest-verifier" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-support", "parity-scale-codec", @@ -14484,16 +14484,16 @@ dependencies = [ [[package]] name = "shp-opaque" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "sp-runtime", ] [[package]] name = "shp-session-keys" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "async-trait", "parity-scale-codec", @@ -14507,8 +14507,8 @@ dependencies = [ [[package]] name = "shp-traits" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "frame-support", "parity-scale-codec", @@ -14521,8 +14521,8 @@ dependencies = [ [[package]] name = "shp-treasury-funding" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "log", "shp-traits", @@ -14532,8 +14532,8 @@ dependencies = [ [[package]] name = "shp-tx-implicits-runtime-api" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "parity-scale-codec", "scale-info", @@ -14545,8 +14545,8 @@ dependencies = [ [[package]] name = "shp-types" -version = "0.4.2" -source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.2#5b52af21ca6c60db96bb7c3fe7c069075e941614" +version = "0.4.3" +source = "git+https://github.com/Moonsong-Labs/storage-hub.git?tag=v0.4.3#0176413b97dbbacb9148f1134e2797e174afa713" dependencies = [ "sp-core", "sp-runtime", diff --git a/operator/Cargo.toml b/operator/Cargo.toml index ab8b6185..40a689e9 100644 --- a/operator/Cargo.toml +++ b/operator/Cargo.toml @@ -272,42 +272,42 @@ fc-storage = { git = "https://github.com/polkadot-evm/frontier", branch = "stabl # StorageHub ## Runtime -pallet-bucket-nfts = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-cr-randomness = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-file-system = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-file-system-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-payment-streams = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-payment-streams-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-proofs-dealer = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-proofs-dealer-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-randomness = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-storage-providers = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -pallet-storage-providers-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-constants = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-data-price-updater = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-file-key-verifier = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-file-metadata = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-forest-verifier = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-traits = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-treasury-funding = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } +pallet-bucket-nfts = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-cr-randomness = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-file-system = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-file-system-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-payment-streams = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-payment-streams-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-proofs-dealer = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-proofs-dealer-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-randomness = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-storage-providers = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +pallet-storage-providers-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-constants = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-data-price-updater = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-file-key-verifier = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-file-metadata = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-forest-verifier = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-traits = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-treasury-funding = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } ## Client -shc-actors-derive = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-actors-framework = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-blockchain-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-client = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-common = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-file-manager = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-file-transfer-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-fisherman-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-forest-manager = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-indexer-db = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-indexer-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shc-rpc = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-opaque = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-tx-implicits-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } -shp-types = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } +shc-actors-derive = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-actors-framework = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-blockchain-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-client = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-common = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-file-manager = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-file-transfer-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-fisherman-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-forest-manager = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-indexer-db = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-indexer-service = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shc-rpc = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-opaque = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-tx-implicits-runtime-api = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } +shp-types = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } ## Precompiles -pallet-evm-precompile-file-system = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.2", default-features = false } +pallet-evm-precompile-file-system = { git = "https://github.com/Moonsong-Labs/storage-hub.git", tag = "v0.4.3", default-features = false } # Static linking From aa3409b239312ec2a5c984539dc83b552846c5b0 Mon Sep 17 00:00:00 2001 From: Steve Degosserie <723552+stiiifff@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:25:17 +0100 Subject: [PATCH 4/8] feat(slashes): typed offence kinds, Perbill-to-WAD conversion, historical filtering, and liveness E2E test (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces typed offence classification, a linear Perbill-to-WAD conversion for EigenLayer slashing, historical offence filtering, and a new E2E test proving end-to-end liveness detection through `pallet_im_online`. --- ### OffenceKind enum New `OffenceKind` enum classifies consensus offences: - `LivenessOffence` — missed heartbeats (ImOnline) - `BabeEquivocation` — double block production - `GrandpaEquivocation` — double finality votes - `BeefyEquivocation` — double BEEFY votes / fork voting / future block voting - `Custom(BoundedVec)` — manual / governance slashes Each variant carries a human-readable description string through the Snowbridge message to EigenLayer's `DatahavenServiceManager.slashValidatorsOperator()`. ### EquivocationReportWrapper Generic wrapper around `ReportOffence` wired for BABE, GRANDPA, BEEFY, and ImOnline in all three runtimes: 1. **Filters historical offences** — discards reports whose session predates the bonding period, using `BondedEras` storage (analogous to `FilterHistoricalOffences` in `pallet_staking`, but adapted to this pallet's own era tracking). 2. **Tags offence kind** — stores the `OffenceKind` in `PendingOffenceKind` double-map `(SessionIndex, ValidatorId)` before delegating to `pallet_offences`. The `on_offence` handler reads it via `take()` in the same block. 3. **Cleans up on failure** — removes stale `PendingOffenceKind` entries if the inner reporter returns an error (e.g. duplicate report), preventing them from leaking into unrelated future offences. ### Perbill to WAD conversion and MaxSlashWad #### How Substrate computes slash fractions Each offence type in Substrate defines its own `slash_fraction(offenders_count)` returning a `Perbill`: | Offence | Formula | Typical range | |---|---|---| | **BABE equivocation** | `min((3k/n)^2, 1)` | 1 offender / 100 validators: ~0.09%; 1/2: capped to 100% | | **GRANDPA equivocation** | `min((3k/n)^2, 1)` | Same as BABE | | **BEEFY double-vote** | `min((3k/n)^2, 1)` | Same as BABE/GRANDPA | | **BEEFY fork/future voting** | Fixed `50%` | Always 50% | | **ImOnline liveness** | `min(3*(k - floor(n/10) - 1)/n, 1) * 7%` | 10% or fewer offline: **0%**; ~33% offline: ~5%; ~43% offline: 7% (max) | Where `k` = number of concurrent offenders, `n` = validator set size. **Key behavior for small validator sets (E2E):** With n=2, the ImOnline threshold is `floor(2/10) + 1 = 1`. A single offender (`k=1`) fails `checked_sub(1)` giving `Perbill(0)`. This means no `Slashes` storage entry is created (since `compute_slash` returns `None` when the new fraction doesn't exceed the prior slash), but the `SlashReported` event is still emitted, proving the full detection pipeline works. #### Linear conversion to EigenLayer WAD The Substrate `Perbill` is linearly mapped to a WAD value capped by `MaxSlashWad`: ``` WAD = perbill.deconstruct() * MaxSlashWad / 1_000_000_000 ``` - `MaxSlashWad` default: **5e16** (= 5% in WAD format, where 1e18 = 100%) - Governance-changeable dynamic runtime parameter (codec index 46) - `Perbill(100%)` maps to exactly `MaxSlashWad` (the cap) - `Perbill(0%)` maps to 0 (no slash sent to EigenLayer) #### Concrete examples (with default MaxSlashWad = 5%) | Scenario | Substrate Perbill | WAD sent to EigenLayer | EigenLayer % | |---|---|---|---| | BABE equivocation (1 of 100 validators) | ~0.09% | ~4.5e13 | ~0.0045% | | BABE equivocation (1 of 2 validators) | 100% (capped) | 5e16 | 5% (max) | | BEEFY fork voting | 50% | 2.5e16 | 2.5% | | ImOnline liveness (1 of 2 offline) | 0% | 0 (no slash) | 0% | | ImOnline liveness (~33% of large set offline) | ~5% | ~2.5e15 | ~0.25% | | Manual `force_inject_slash` at 20% | 20% | 1e16 | 1% | | Manual `force_inject_slash` at 100% | 100% | 5e16 | 5% (max) | The same WAD value is applied uniformly to all configured strategies via the `SlashingRequest` struct sent through Snowbridge to `DatahavenServiceManager.slashValidatorsOperator()`. ### E2E liveness slashing test New test scenario (`should detect and slash an unresponsive validator`) validates the full liveness detection pipeline: 1. Pauses bob's Docker container (preserving GRANDPA state via `docker pause`) 2. Waits 200s (>= 2 full sessions) for `pallet_im_online` to detect missed heartbeats 3. Unpauses bob to restore GRANDPA finality (2/2 validators needed) 4. Polls for `SlashReported` event (not `Slashes` storage — see slash fraction note above) 5. Verifies the event confirms the full pipeline: `pallet_im_online -> EquivocationReportWrapper -> pallet_offences -> on_offence` The test uses `try/finally` to always unpause bob, `{ at: "best" }` queries for non-finalized chain state during the pause, and drains prior `SlashReported` events before starting. ### Tests - **10 new unit tests**: `PendingOffenceKind` double-map semantics, session isolation, wrapper historical filtering, error cleanup, WAD conversion (100%, 50%, 0%), offence kind description propagation - **New mock infrastructure**: `MockInnerReporter`, `MockOffence`, `MockOkOutboundQueue` with slash data capture - **E2E**: Updated `force_inject_slash` test to use `offence_kind` enum, new liveness detection test --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Gonza Montiel Co-authored-by: undercover-cactus --- .../src/benchmarking.rs | 26 +- .../external-validator-slashes/src/lib.rs | 229 +++++++++- .../external-validator-slashes/src/mock.rs | 84 +++- .../external-validator-slashes/src/tests.rs | 402 +++++++++++++++++- .../runtime/common/src/slashes_adapter.rs | 4 +- operator/runtime/mainnet/src/configs/mod.rs | 37 +- .../mainnet/src/configs/runtime_params.rs | 10 + operator/runtime/stagenet/src/configs/mod.rs | 37 +- .../stagenet/src/configs/runtime_params.rs | 10 + operator/runtime/testnet/src/configs/mod.rs | 37 +- .../testnet/src/configs/runtime_params.rs | 10 + test/.papi/descriptors/package.json | 2 +- test/.papi/metadata/datahaven.scale | Bin 631232 -> 632706 bytes test/e2e/suites/slash.test.ts | 116 ++++- 14 files changed, 936 insertions(+), 68 deletions(-) diff --git a/operator/pallets/external-validator-slashes/src/benchmarking.rs b/operator/pallets/external-validator-slashes/src/benchmarking.rs index 51693fb7..7e8d7a00 100644 --- a/operator/pallets/external-validator-slashes/src/benchmarking.rs +++ b/operator/pallets/external-validator-slashes/src/benchmarking.rs @@ -41,7 +41,14 @@ mod benchmarks { let era = T::EraIndexProvider::active_era().index; let dummy = || T::AccountId::decode(&mut TrailingZeroInput::zeroes()).unwrap(); for _ in 0..MAX_SLASHES { - existing_slashes.push(Slash::::default_from(dummy())); + existing_slashes.push(Slash { + validator: dummy(), + reporters: vec![], + slash_id: One::one(), + percentage: Perbill::from_percent(1), + confirmed: false, + offence_kind: OffenceKind::LivenessOffence, + }); } Slashes::::insert( era.saturating_add(T::SlashDeferDuration::get()) @@ -74,7 +81,13 @@ mod benchmarks { let era = T::EraIndexProvider::active_era().index; let dummy = || T::AccountId::decode(&mut TrailingZeroInput::zeroes()).unwrap(); #[extrinsic_call] - _(RawOrigin::Root, era, dummy(), Perbill::from_percent(50)); + _( + RawOrigin::Root, + era, + dummy(), + Perbill::from_percent(50), + OffenceKind::LivenessOffence, + ); assert_eq!( Slashes::::get( @@ -93,7 +106,14 @@ mod benchmarks { let dummy = || T::AccountId::decode(&mut TrailingZeroInput::zeroes()).unwrap(); for _ in 0..(s + 1) { - queue.push_back(Slash::::default_from(dummy())); + queue.push_back(Slash { + validator: dummy(), + reporters: vec![], + slash_id: One::one(), + percentage: Perbill::from_percent(1), + confirmed: false, + offence_kind: OffenceKind::LivenessOffence, + }); } UnreportedSlashesQueue::::set(queue); diff --git a/operator/pallets/external-validator-slashes/src/lib.rs b/operator/pallets/external-validator-slashes/src/lib.rs index 8ef718d3..b24b85db 100644 --- a/operator/pallets/external-validator-slashes/src/lib.rs +++ b/operator/pallets/external-validator-slashes/src/lib.rs @@ -31,7 +31,7 @@ extern crate alloc; use pallet_external_validators::apply; use snowbridge_outbound_queue_primitives::SendError; use { - alloc::{collections::vec_deque::VecDeque, vec, vec::Vec}, + alloc::{collections::vec_deque::VecDeque, string::String, vec, vec::Vec}, frame_support::{pallet_prelude::*, traits::DefensiveSaturating}, frame_system::pallet_prelude::*, log::log, @@ -46,7 +46,7 @@ use { DispatchResult, Perbill, }, sp_staking::{ - offence::{OffenceDetails, OnOffenceHandler}, + offence::{Offence, OffenceDetails, OffenceError, OnOffenceHandler, ReportOffence}, EraIndex, SessionIndex, }, }; @@ -63,10 +63,45 @@ mod tests; mod benchmarking; pub mod weights; +/// Identifies the type of consensus offence for EigenLayer slash reporting. +#[derive( + Encode, + Decode, + DecodeWithMemTracking, + RuntimeDebug, + TypeInfo, + Clone, + PartialEq, + Eq, + MaxEncodedLen, +)] +pub enum OffenceKind { + /// Liveness offence (i.e. Unresponsiveness) + LivenessOffence, + BabeEquivocation, + GrandpaEquivocation, + BeefyEquivocation, + Custom(BoundedVec>), +} + +impl OffenceKind { + pub fn to_description(&self) -> String { + match self { + Self::LivenessOffence => "Liveness offence".into(), + Self::BabeEquivocation => "BABE equivocation".into(), + Self::GrandpaEquivocation => "GRANDPA equivocation".into(), + Self::BeefyEquivocation => "BEEFY equivocation".into(), + Self::Custom(desc) => String::from_utf8(desc.to_vec()) + .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()), + } + } +} + #[derive(Debug, PartialEq, Eq, Clone)] pub struct SlashData { pub validator: AccountId, pub wad_to_slash: u128, + pub description: String, } // FIXME (nice to have): Merge with SendMessage trait from pallet external-validator-reward (similar trait) @@ -153,6 +188,11 @@ pub mod pallet { /// Provider to retrieve the current external index of validators type ExternalIndexProvider: ExternalIndexProvider; + /// Maximum WAD value for EigenLayer slashing. Maps Perbill(100%) to this value. + /// Default: 5e16 = 5% in WAD format (1e18 = 100%). + #[pallet::constant] + type MaxSlashWad: Get; + /// How many queued slashes are being processed per block. #[pallet::constant] type QueuedSlashesProcessedPerBlock: Get; @@ -183,6 +223,9 @@ pub mod pallet { EthereumDeliverFail, /// Invalid params for root_test_send_msg_to_eth RootTestInvalidParams, + /// No PendingOffenceKind found for (session, validator) — offence was not + /// reported through EquivocationReportWrapper, so the offence kind is unknown. + MissingOffenceKind, } #[apply(derive_storage_traits)] @@ -237,6 +280,27 @@ pub mod pallet { #[pallet::storage] pub type SlashingMode = StorageValue<_, SlashingModeOption, ValueQuery>; + /// Temporarily stores the offence kind per (session, offender), set by + /// `EquivocationReportWrapper` before `on_offence` is called synchronously within + /// the same block. Keyed by session index and validator ID so that offences from + /// different sessions or for different validators cannot interfere with each other. + /// + /// SAFETY: relies on `pallet_offences::report_offence` calling `on_offence` + /// synchronously in the same block. Entries are cleaned up via `take()` in + /// `on_offence` on success, or explicit `remove()` in the wrapper on error. + /// If the offence pipeline ever becomes asynchronous, this storage should be + /// replaced with an offence-payload-based approach. + #[pallet::storage] + pub type PendingOffenceKind = StorageDoubleMap< + _, + Twox64Concat, + SessionIndex, + Twox64Concat, + T::ValidatorId, + OffenceKind, + OptionQuery, + >; + #[pallet::genesis_config] #[derive(frame_support::DefaultNoBound)] pub struct GenesisConfig { @@ -305,6 +369,7 @@ pub mod pallet { era: EraIndex, validator: T::AccountId, percentage: Perbill, + offence_kind: OffenceKind, ) -> DispatchResult { ensure_root(origin)?; let active_era = T::EraIndexProvider::active_era().index; @@ -324,6 +389,7 @@ pub mod pallet { era, validator, slash_defer_duration, + offence_kind, ) .ok_or(Error::::ErrorComputingSlash)?; @@ -374,7 +440,8 @@ pub mod pallet { } } -/// This is intended to be used with `FilterHistoricalOffences`. +/// This is intended to be used with `EquivocationReportWrapper`, which filters +/// out historical offences (before the bonding period) and tags the offence kind. impl OnOffenceHandler, Weight> for Pallet @@ -453,6 +520,25 @@ where for (details, slash_fraction) in offenders.iter().zip(slash_fraction) { let (stash, _) = &details.offender; + // Read the per-(session, offender) offence kind set by EquivocationReportWrapper. + // This is set synchronously before on_offence is called, so take() clears it. + // Type safety: `stash` is T::ValidatorId (from IdentificationTuple), matching + // the key used by the wrapper. The trait bounds above enforce ValidatorId == AccountId. + let offence_kind = match pallet::PendingOffenceKind::::take(slash_session, stash) { + Some(kind) => kind, + None => { + log!( + log::Level::Error, + "MissingOffenceKind for session {:?}, validator {:?} — skipping slash", + slash_session, + stash, + ); + add_db_reads_writes(1, 1); + continue; + } + }; + add_db_reads_writes(1, 1); + // Skip if the validator is invulnerable. if invulnerables.contains(stash) { continue; @@ -477,6 +563,7 @@ where slash_era, stash.clone(), slash_defer_duration, + offence_kind.clone(), ); if let Some(mut slash) = slash { @@ -594,9 +681,22 @@ impl Pallet { break; }; + // Convert Perbill to EigenLayer WAD format with linear mapping. + // Perbill(100%) → MaxSlashWad (e.g. 5% WAD = 5e16). + // Formula: perbill_inner * MaxSlashWad / 1e9 + // Clamp to MaxSlashWad to guard against overflow if governance + // sets MaxSlashWad high enough for saturating_mul to hit u128::MAX. + let max_wad = T::MaxSlashWad::get(); + let wad_to_slash = (slash.percentage.deconstruct() as u128) + .saturating_mul(max_wad) + .checked_div(1_000_000_000u128) + .unwrap_or(0) + .min(max_wad); + slashes_to_send.push(SlashData { validator: slash.validator, - wad_to_slash: u128::from_str_radix("10000000000000000", 10).unwrap(), // TODO: need to compute how much we slash (for now it is 1e16) + wad_to_slash, + description: slash.offence_kind.to_description(), }); } }); @@ -655,19 +755,8 @@ pub struct Slash { pub percentage: Perbill, // Whether the slash is confirmed or still needs to go through deferred period pub confirmed: bool, -} - -impl Slash { - /// Initializes the default object using the given `validator`. - pub fn default_from(validator: AccountId) -> Self { - Self { - validator, - reporters: vec![], - slash_id: One::one(), - percentage: Perbill::from_percent(50), - confirmed: false, - } - } + /// The type of consensus offence (relayed to EigenLayer as a description string). + pub offence_kind: OffenceKind, } /// Computes a slash of a validator and nominators. It returns an unapplied @@ -682,6 +771,7 @@ pub(crate) fn compute_slash( slash_era: EraIndex, stash: T::AccountId, slash_defer_duration: EraIndex, + offence_kind: OffenceKind, ) -> Option> { let prior_slash_p = ValidatorSlashInEra::::get(slash_era, &stash).unwrap_or(Zero::zero()); @@ -707,6 +797,7 @@ pub(crate) fn compute_slash( slash_id, reporters: Vec::new(), confirmed, + offence_kind, }) } @@ -714,3 +805,107 @@ pub(crate) fn compute_slash( fn is_sorted_and_unique(list: &[u32]) -> bool { list.windows(2).all(|w| w[0] < w[1]) } + +/// Trait for associating an `OffenceKind` with a reporter type. +pub trait OffenceKindProvider { + fn kind() -> OffenceKind; +} + +/// Extracts the validator (account) ID from an offender identification tuple. +pub trait HasValidatorId { + fn validator_id(&self) -> &ValidatorId; +} + +impl HasValidatorId for (V, F) { + fn validator_id(&self) -> &V { + &self.0 + } +} + +/// Wraps a `ReportOffence` implementation to: +/// 1. **Filter historical offences**: discard reports whose session predates the bonding +/// period (similar to `FilterHistoricalOffences` in `pallet_staking`, but using this +/// pallet's own `BondedEras` storage instead of staking eras). +/// 2. **Tag offence kind**: store the `OffenceKind` per offender in `PendingOffenceKind` +/// before delegating to the inner reporter, so that `on_offence` can read it via +/// `PendingOffenceKind::take()`. +/// +/// If the inner `report_offence` fails (e.g. duplicate report), stale `PendingOffenceKind` +/// entries are cleaned up to prevent leaking into unrelated future offences. +pub struct EquivocationReportWrapper(PhantomData<(T, Inner, Kind)>); + +impl ReportOffence for EquivocationReportWrapper +where + T: Config, + Inner: ReportOffence, + O: Offence, + Kind: OffenceKindProvider, + Id: HasValidatorId, +{ + fn report_offence(reporters: Vec, offence: O) -> Result<(), OffenceError> { + // Discard offences from before the bonding period. + let offence_session = offence.session_index(); + let bonded_eras = pallet::BondedEras::::get(); + if bonded_eras + .first() + .filter(|(_, start, _)| offence_session >= *start) + .is_none() + { + log!( + log::Level::Debug, + "discarding offence from session {} — predates bonded eras {:?}", + offence_session, + bonded_eras.first(), + ); + return Ok(()); + } + + let offenders = offence.offenders(); + for offender in &offenders { + pallet::PendingOffenceKind::::insert( + offence_session, + offender.validator_id(), + Kind::kind(), + ); + } + let result = Inner::report_offence(reporters, offence); + if result.is_err() { + for offender in &offenders { + pallet::PendingOffenceKind::::remove(offence_session, offender.validator_id()); + } + } + result + } + + fn is_known_offence(offenders: &[Id], time_slot: &O::TimeSlot) -> bool { + Inner::is_known_offence(offenders, time_slot) + } +} + +pub struct BabeEquivocation; +impl OffenceKindProvider for BabeEquivocation { + fn kind() -> OffenceKind { + OffenceKind::BabeEquivocation + } +} + +pub struct GrandpaEquivocation; +impl OffenceKindProvider for GrandpaEquivocation { + fn kind() -> OffenceKind { + OffenceKind::GrandpaEquivocation + } +} + +pub struct BeefyEquivocation; +impl OffenceKindProvider for BeefyEquivocation { + fn kind() -> OffenceKind { + OffenceKind::BeefyEquivocation + } +} + +pub struct ImOnlineUnresponsive; +impl OffenceKindProvider for ImOnlineUnresponsive { + fn kind() -> OffenceKind { + OffenceKind::LivenessOffence + } +} diff --git a/operator/pallets/external-validator-slashes/src/mock.rs b/operator/pallets/external-validator-slashes/src/mock.rs index c21f13c5..efe2c509 100644 --- a/operator/pallets/external-validator-slashes/src/mock.rs +++ b/operator/pallets/external-validator-slashes/src/mock.rs @@ -25,7 +25,7 @@ use { core::cell::RefCell, frame_support::{ parameter_types, - traits::{ConstU16, ConstU32, ConstU64, Get}, + traits::{ConstU128, ConstU16, ConstU32, ConstU64, Get}, weights::constants::RocksDbWeight, }, frame_system as system, @@ -132,7 +132,9 @@ thread_local! { pub static ERA_INDEX: RefCell = const { RefCell::new(0) }; pub static DEFER_PERIOD: RefCell = const { RefCell::new(2) }; pub static SENT_ETHEREUM_MESSAGE_NONCE: RefCell = const { RefCell::new(0) }; - + pub static MOCK_REPORT_OFFENCE_SHOULD_FAIL: RefCell = const { RefCell::new(false) }; + pub static MOCK_REPORT_OFFENCE_CALLED: RefCell = const { RefCell::new(false) }; + pub static LAST_SENT_SLASHES: RefCell>> = RefCell::new(Vec::new()); } impl MockEraIndexProvider { @@ -215,10 +217,16 @@ impl DeferPeriodGetter { } pub struct MockOkOutboundQueue; +impl MockOkOutboundQueue { + pub fn last_sent_slashes() -> Vec> { + LAST_SENT_SLASHES.with(|r| r.borrow().clone()) + } +} impl crate::SendMessage for MockOkOutboundQueue { type Ticket = (); type Message = (); - fn build(_: &Vec>, _: u32) -> Option { + fn build(slashes: &Vec>, _: u32) -> Option { + LAST_SENT_SLASHES.with(|r| *r.borrow_mut() = slashes.clone()); Some(()) } fn validate(_: Self::Ticket) -> Result { @@ -258,6 +266,7 @@ impl external_validator_slashes::Config for Test { type EraIndexProvider = MockEraIndexProvider; type InvulnerablesProvider = MockInvulnerableProvider; type ExternalIndexProvider = TimestampProvider; + type MaxSlashWad = ConstU128<50_000_000_000_000_000>; type QueuedSlashesProcessedPerBlock = ConstU32<20>; type WeightInfo = (); type SendMessage = MockOkOutboundQueue; @@ -289,6 +298,75 @@ impl sp_runtime::traits::Convert> for IdentityValidator { } } +// --- Mock infrastructure for testing EquivocationReportWrapper --- + +use sp_staking::offence::{Offence, OffenceError, ReportOffence}; + +/// A mock inner ReportOffence that can be configured to succeed or fail. +pub struct MockInnerReporter; + +impl MockInnerReporter { + pub fn set_should_fail(fail: bool) { + MOCK_REPORT_OFFENCE_SHOULD_FAIL.with(|r| *r.borrow_mut() = fail); + } + pub fn was_called() -> bool { + MOCK_REPORT_OFFENCE_CALLED.with(|r| *r.borrow()) + } + pub fn reset() { + MOCK_REPORT_OFFENCE_SHOULD_FAIL.with(|r| *r.borrow_mut() = false); + MOCK_REPORT_OFFENCE_CALLED.with(|r| *r.borrow_mut() = false); + } +} + +impl> ReportOffence for MockInnerReporter { + fn report_offence(_reporters: Vec, _offence: O) -> Result<(), OffenceError> { + MOCK_REPORT_OFFENCE_CALLED.with(|r| *r.borrow_mut() = true); + if MOCK_REPORT_OFFENCE_SHOULD_FAIL.with(|r| *r.borrow()) { + Err(OffenceError::DuplicateReport) + } else { + Ok(()) + } + } + fn is_known_offence(_offenders: &[Id], _time_slot: &O::TimeSlot) -> bool { + false + } +} + +/// A minimal mock Offence for testing the wrapper. +pub struct MockOffence { + pub session_index: SessionIndex, + pub offenders: Vec<(u64, ())>, +} + +impl Offence<(u64, ())> for MockOffence { + const ID: sp_staking::offence::Kind = *b"mock:offence0000"; + type TimeSlot = u128; + + fn offenders(&self) -> Vec<(u64, ())> { + self.offenders.clone() + } + fn session_index(&self) -> SessionIndex { + self.session_index + } + fn validator_set_count(&self) -> u32 { + 3 + } + fn time_slot(&self) -> Self::TimeSlot { + self.session_index as u128 + } + fn slash_fraction(&self, _offenders_count: u32) -> sp_runtime::Perbill { + sp_runtime::Perbill::from_percent(50) + } +} + +/// Type alias for the wrapper using the mock reporter with BabeEquivocation kind. +pub type MockBabeWrapper = + crate::EquivocationReportWrapper; + +/// Type alias for the wrapper using the mock reporter with GrandpaEquivocation kind. +pub type MockGrandpaWrapper = + crate::EquivocationReportWrapper; + pub fn run_block() { run_to_block(System::block_number() + 1); } diff --git a/operator/pallets/external-validator-slashes/src/tests.rs b/operator/pallets/external-validator-slashes/src/tests.rs index 0c21466c..126485b8 100644 --- a/operator/pallets/external-validator-slashes/src/tests.rs +++ b/operator/pallets/external-validator-slashes/src/tests.rs @@ -18,12 +18,14 @@ use { super::*, crate::{ mock::{ - new_test_ext, run_block, DeferPeriodGetter, ExternalValidatorSlashes, - MockEraIndexProvider, RuntimeEvent, RuntimeOrigin, System, Test, + new_test_ext, run_block, DeferPeriodGetter, ExternalValidatorSlashes, MockBabeWrapper, + MockEraIndexProvider, MockGrandpaWrapper, MockInnerReporter, MockOffence, + MockOkOutboundQueue, RuntimeEvent, RuntimeOrigin, System, Test, }, - Slash, + OffenceKind, Slash, }, - frame_support::{assert_noop, assert_ok}, + frame_support::{assert_noop, assert_ok, BoundedVec}, + sp_staking::offence::ReportOffence, }; #[test] @@ -35,6 +37,7 @@ fn root_can_inject_manual_offence() { 0, 1u64, Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_eq!( Slashes::::get(get_slashing_era(0)), @@ -43,7 +46,10 @@ fn root_can_inject_manual_offence() { percentage: Perbill::from_percent(75), confirmed: false, reporters: vec![], - slash_id: 0 + slash_id: 0, + offence_kind: OffenceKind::Custom(BoundedVec::truncate_from( + b"Test slash".to_vec() + )), }] ); assert_eq!(NextSlashId::::get(), 1); @@ -59,7 +65,8 @@ fn cannot_inject_future_era_offence() { RuntimeOrigin::root(), 1, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), ), Error::::ProvidedFutureEra ); @@ -76,7 +83,8 @@ fn cannot_inject_era_offence_too_far_in_the_past() { RuntimeOrigin::root(), 1, 4u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), ), Error::::ProvidedNonSlashableEra ); @@ -91,7 +99,8 @@ fn root_can_cancel_deferred_slash() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_ok!(ExternalValidatorSlashes::cancel_deferred_slash( RuntimeOrigin::root(), @@ -111,7 +120,8 @@ fn root_cannot_cancel_deferred_slash_if_outside_deferring_period() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); start_era(4, 0, 4); @@ -131,7 +141,8 @@ fn root_cannot_cancel_out_of_bounds() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_noop!( ExternalValidatorSlashes::cancel_deferred_slash( @@ -152,7 +163,8 @@ fn root_cannot_cancel_duplicates() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_noop!( ExternalValidatorSlashes::cancel_deferred_slash(RuntimeOrigin::root(), 3, vec![0, 0]), @@ -169,13 +181,15 @@ fn root_cannot_cancel_if_not_sorted() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_ok!(ExternalValidatorSlashes::force_inject_slash( RuntimeOrigin::root(), 0, 2u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_noop!( ExternalValidatorSlashes::cancel_deferred_slash(RuntimeOrigin::root(), 3, vec![1, 0]), @@ -196,7 +210,8 @@ fn test_after_bonding_period_we_can_remove_slashes() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_eq!( @@ -206,7 +221,10 @@ fn test_after_bonding_period_we_can_remove_slashes() { percentage: Perbill::from_percent(75), confirmed: false, reporters: vec![], - slash_id: 0 + slash_id: 0, + offence_kind: OffenceKind::Custom(BoundedVec::truncate_from( + b"Test slash".to_vec() + )), }] ); @@ -226,6 +244,7 @@ fn test_on_offence_injects_offences() { new_test_ext().execute_with(|| { start_era(0, 0, 0); start_era(1, 1, 1); + PendingOffenceKind::::insert(0, 3u64, OffenceKind::LivenessOffence); Pallet::::on_offence( &[OffenceDetails { // 1 and 2 are invulnerables @@ -242,7 +261,8 @@ fn test_on_offence_injects_offences() { percentage: Perbill::from_percent(75), confirmed: false, reporters: vec![], - slash_id: 0 + slash_id: 0, + offence_kind: OffenceKind::LivenessOffence, }] ); }); @@ -253,7 +273,8 @@ fn test_on_offence_does_not_work_for_invulnerables() { new_test_ext().execute_with(|| { start_era(0, 0, 0); start_era(1, 1, 1); - // account 1 invulnerable + // account 1 invulnerable — populate kind so we test the invulnerable check, not missing kind + PendingOffenceKind::::insert(0, 1u64, OffenceKind::LivenessOffence); Pallet::::on_offence( &[OffenceDetails { offender: (1, ()), @@ -276,6 +297,7 @@ fn test_on_offence_does_not_work_if_slashing_disabled() { RuntimeOrigin::root(), SlashingModeOption::Disabled, )); + PendingOffenceKind::::insert(0, 3u64, OffenceKind::LivenessOffence); let weight = Pallet::::on_offence( &[OffenceDetails { // 1 and 2 are invulnerables @@ -303,7 +325,8 @@ fn defer_period_of_zero_confirms_immediately_slashes() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_eq!( Slashes::::get(get_slashing_era(0)), @@ -312,7 +335,10 @@ fn defer_period_of_zero_confirms_immediately_slashes() { percentage: Perbill::from_percent(75), confirmed: true, reporters: vec![], - slash_id: 0 + slash_id: 0, + offence_kind: OffenceKind::Custom(BoundedVec::truncate_from( + b"Test slash".to_vec() + )), }] ); }); @@ -327,7 +353,8 @@ fn we_cannot_cancel_anything_with_defer_period_zero() { RuntimeOrigin::root(), 0, 1u64, - Perbill::from_percent(75) + Perbill::from_percent(75), + OffenceKind::Custom(BoundedVec::truncate_from(b"Test slash".to_vec())), )); assert_noop!( ExternalValidatorSlashes::cancel_deferred_slash(RuntimeOrigin::root(), 0, vec![0]), @@ -342,6 +369,7 @@ fn test_on_offence_defer_period_0() { crate::mock::DeferPeriodGetter::with_defer_period(0); start_era(0, 0, 0); start_era(1, 1, 1); + PendingOffenceKind::::insert(0, 3u64, OffenceKind::LivenessOffence); Pallet::::on_offence( &[OffenceDetails { // 1 and 2 are invulnerables @@ -359,7 +387,8 @@ fn test_on_offence_defer_period_0() { percentage: Perbill::from_percent(75), confirmed: true, reporters: vec![], - slash_id: 0 + slash_id: 0, + offence_kind: OffenceKind::LivenessOffence, }] ); start_era(2, 2, 2); @@ -373,6 +402,7 @@ fn test_slashes_command_matches_event() { crate::mock::DeferPeriodGetter::with_defer_period(0); start_era(0, 0, 0); start_era(1, 1, 1); + PendingOffenceKind::::insert(0, 3u64, OffenceKind::LivenessOffence); Pallet::::on_offence( &[OffenceDetails { // 1 and 2 are invulnerables @@ -391,7 +421,8 @@ fn test_slashes_command_matches_event() { percentage: Perbill::from_percent(75), confirmed: true, reporters: vec![], - slash_id: 0 + slash_id: 0, + offence_kind: OffenceKind::LivenessOffence, }] ); start_era(2, 2, 2); @@ -405,6 +436,122 @@ fn test_slashes_command_matches_event() { }); } +// ── WAD conversion tests ── +// MaxSlashWad in mock = 50_000_000_000_000_000 (5e16 = 5% in WAD format). +// Perbill(100%) = 1_000_000_000 inner. +// Formula: wad = perbill_inner * MaxSlashWad / 1e9 + +#[test] +fn wad_conversion_100_percent_slash_maps_to_max_slash_wad() { + new_test_ext().execute_with(|| { + crate::mock::DeferPeriodGetter::with_defer_period(0); + start_era(0, 0, 0); + start_era(1, 1, 1); + + PendingOffenceKind::::insert(0, 3u64, OffenceKind::LivenessOffence); + Pallet::::on_offence( + &[OffenceDetails { + offender: (3, ()), + reporters: vec![], + }], + &[Perbill::from_percent(100)], + 0, + ); + + start_era(2, 2, 2); + run_block(); + + let sent = MockOkOutboundQueue::last_sent_slashes(); + assert_eq!(sent.len(), 1); + // 100% → full MaxSlashWad = 5e16 + assert_eq!(sent[0].wad_to_slash, 50_000_000_000_000_000u128); + assert_eq!(sent[0].validator, 3); + }); +} + +#[test] +fn wad_conversion_50_percent_slash_maps_to_half_max_slash_wad() { + new_test_ext().execute_with(|| { + crate::mock::DeferPeriodGetter::with_defer_period(0); + start_era(0, 0, 0); + start_era(1, 1, 1); + + PendingOffenceKind::::insert(0, 3u64, OffenceKind::LivenessOffence); + Pallet::::on_offence( + &[OffenceDetails { + offender: (3, ()), + reporters: vec![], + }], + &[Perbill::from_percent(50)], + 0, + ); + + start_era(2, 2, 2); + run_block(); + + let sent = MockOkOutboundQueue::last_sent_slashes(); + assert_eq!(sent.len(), 1); + // 50% → MaxSlashWad / 2 = 2.5e16 + assert_eq!(sent[0].wad_to_slash, 25_000_000_000_000_000u128); + }); +} + +#[test] +fn wad_conversion_zero_percent_slash_maps_to_zero() { + new_test_ext().execute_with(|| { + crate::mock::DeferPeriodGetter::with_defer_period(0); + start_era(0, 0, 0); + start_era(1, 1, 1); + + PendingOffenceKind::::insert(0, 3u64, OffenceKind::LivenessOffence); + Pallet::::on_offence( + &[OffenceDetails { + offender: (3, ()), + reporters: vec![], + }], + &[Perbill::from_percent(0)], + 0, + ); + + start_era(2, 2, 2); + run_block(); + + // 0% slash → no slash recorded (compute_slash returns None for 0%) + let sent = MockOkOutboundQueue::last_sent_slashes(); + assert_eq!(sent.len(), 0); + }); +} + +#[test] +fn wad_conversion_carries_offence_kind_description() { + new_test_ext().execute_with(|| { + crate::mock::DeferPeriodGetter::with_defer_period(0); + start_era(0, 0, 0); + start_era(1, 1, 1); + + // Pre-populate a BabeEquivocation kind for session 0, validator 3. + PendingOffenceKind::::insert(0, 3u64, OffenceKind::BabeEquivocation); + + Pallet::::on_offence( + &[OffenceDetails { + offender: (3, ()), + reporters: vec![], + }], + &[Perbill::from_percent(75)], + 0, + ); + + start_era(2, 2, 2); + run_block(); + + let sent = MockOkOutboundQueue::last_sent_slashes(); + assert_eq!(sent.len(), 1); + // 75% → 75% of MaxSlashWad = 3.75e16 + assert_eq!(sent[0].wad_to_slash, 37_500_000_000_000_000u128); + assert_eq!(sent[0].description, "BABE equivocation"); + }); +} + #[test] fn test_on_offence_defer_period_0_messages_get_queued() { new_test_ext().execute_with(|| { @@ -413,6 +560,7 @@ fn test_on_offence_defer_period_0_messages_get_queued() { start_era(1, 1, 1); // The limit is 20, for i in 0..25 { + PendingOffenceKind::::insert(0, 3 + i, OffenceKind::LivenessOffence); Pallet::::on_offence( &[OffenceDetails { // 1 and 2 are invulnerables @@ -450,6 +598,7 @@ fn test_account_id_encoding() { slash_id: 1, percentage: Perbill::default(), confirmed: true, + offence_kind: OffenceKind::LivenessOffence, }; let encoded_account = slash.validator.encode(); @@ -466,6 +615,7 @@ fn test_on_offence_defer_period_0_messages_get_queued_across_eras() { start_era(1, 1, 1); // The limit is 20, for i in 0..25 { + PendingOffenceKind::::insert(0, 3 + i, OffenceKind::LivenessOffence); Pallet::::on_offence( &[OffenceDetails { // 1 and 2 are invulnerables @@ -487,6 +637,7 @@ fn test_on_offence_defer_period_0_messages_get_queued_across_eras() { // We have 5 non-dispatched, which should accumulate // We shoulld have 30 after we initialie era 3 for i in 0..25 { + PendingOffenceKind::::insert(2, 3 + i, OffenceKind::LivenessOffence); Pallet::::on_offence( &[OffenceDetails { // 1 and 2 are invulnerables @@ -512,6 +663,213 @@ fn test_on_offence_defer_period_0_messages_get_queued_across_eras() { }); } +// ── PendingOffenceKind & EquivocationReportWrapper tests ── + +#[test] +fn on_offence_reads_pending_offence_kind_from_double_map() { + new_test_ext().execute_with(|| { + start_era(0, 0, 0); + start_era(1, 1, 1); + + // Pre-populate PendingOffenceKind for validator 3 at session 0. + PendingOffenceKind::::insert(0, 3u64, OffenceKind::BabeEquivocation); + + Pallet::::on_offence( + &[OffenceDetails { + offender: (3, ()), + reporters: vec![], + }], + &[Perbill::from_percent(75)], + 0, + ); + + assert_eq!( + Slashes::::get(get_slashing_era(0)), + vec![Slash { + validator: 3, + percentage: Perbill::from_percent(75), + confirmed: false, + reporters: vec![], + slash_id: 0, + offence_kind: OffenceKind::BabeEquivocation, + }] + ); + + // Entry should have been consumed. + assert_eq!(PendingOffenceKind::::get(0, 3u64), None); + }); +} + +#[test] +fn pending_offence_kind_is_session_isolated() { + new_test_ext().execute_with(|| { + start_era(0, 0, 0); + start_era(1, 1, 1); + + // Same validator, different kinds in different sessions. + PendingOffenceKind::::insert(0, 3u64, OffenceKind::BabeEquivocation); + PendingOffenceKind::::insert(1, 3u64, OffenceKind::GrandpaEquivocation); + + // Report at session 0 — should use BabeEquivocation. + Pallet::::on_offence( + &[OffenceDetails { + offender: (3, ()), + reporters: vec![], + }], + &[Perbill::from_percent(50)], + 0, + ); + + // Session 0 consumed, session 1 untouched. + assert_eq!(PendingOffenceKind::::get(0, 3u64), None); + assert_eq!( + PendingOffenceKind::::get(1, 3u64), + Some(OffenceKind::GrandpaEquivocation), + ); + }); +} + +#[test] +fn wrapper_filters_historical_offence_before_bonding_period() { + new_test_ext().execute_with(|| { + start_era(0, 0, 0); + start_era(1, 1, 1); + MockInnerReporter::reset(); + + // BondedEras now contains [(0,0,0), (1,1,1)]. + // An offence at session 0 is within the bonding period — should pass. + let result = MockBabeWrapper::report_offence( + Vec::::new(), + MockOffence { + session_index: 0, + offenders: vec![(3, ())], + }, + ); + assert!(result.is_ok()); + assert!(MockInnerReporter::was_called()); + + // The mock reporter doesn't trigger on_offence, so manually consume the entry. + assert_eq!( + PendingOffenceKind::::take(0, 3u64), + Some(OffenceKind::BabeEquivocation), + ); + + // Advance eras until era 0 drops out of BondedEras. + // BondingDuration = 5, so after era 6 starts, era 0 is pruned. + for i in 2..=7 { + start_era(i, i, i as u64); + } + + MockInnerReporter::reset(); + + // Session 0 now predates the bonding period — should be silently discarded. + let result = MockBabeWrapper::report_offence( + Vec::::new(), + MockOffence { + session_index: 0, + offenders: vec![(3, ())], + }, + ); + assert!(result.is_ok()); + assert!(!MockInnerReporter::was_called()); + + // No PendingOffenceKind should have been written. + assert_eq!(PendingOffenceKind::::get(0, 3u64), None); + }); +} + +#[test] +fn wrapper_sets_pending_offence_kind_per_session_and_offender() { + new_test_ext().execute_with(|| { + start_era(0, 0, 0); + start_era(1, 1, 1); + MockInnerReporter::reset(); + + let _ = MockBabeWrapper::report_offence( + Vec::::new(), + MockOffence { + session_index: 0, + offenders: vec![(3, ()), (4, ())], + }, + ); + + // Both offenders should have entries at session 0. + assert_eq!( + PendingOffenceKind::::get(0, 3u64), + Some(OffenceKind::BabeEquivocation), + ); + assert_eq!( + PendingOffenceKind::::get(0, 4u64), + Some(OffenceKind::BabeEquivocation), + ); + // No entry at a different session. + assert_eq!(PendingOffenceKind::::get(1, 3u64), None); + }); +} + +#[test] +fn wrapper_cleans_up_pending_offence_kind_on_error() { + new_test_ext().execute_with(|| { + start_era(0, 0, 0); + start_era(1, 1, 1); + MockInnerReporter::reset(); + MockInnerReporter::set_should_fail(true); + + let result = MockBabeWrapper::report_offence( + Vec::::new(), + MockOffence { + session_index: 0, + offenders: vec![(3, ()), (4, ())], + }, + ); + + assert!(result.is_err()); + // Entries should have been cleaned up. + assert_eq!(PendingOffenceKind::::get(0, 3u64), None); + assert_eq!(PendingOffenceKind::::get(0, 4u64), None); + }); +} + +#[test] +fn wrapper_error_cleanup_does_not_affect_other_sessions() { + new_test_ext().execute_with(|| { + start_era(0, 0, 0); + start_era(1, 1, 1); + MockInnerReporter::reset(); + + // Successfully report at session 0. + let _ = MockGrandpaWrapper::report_offence( + Vec::::new(), + MockOffence { + session_index: 0, + offenders: vec![(3, ())], + }, + ); + assert_eq!( + PendingOffenceKind::::get(0, 3u64), + Some(OffenceKind::GrandpaEquivocation), + ); + + // Now fail a report at session 1 for the same validator. + MockInnerReporter::set_should_fail(true); + let result = MockBabeWrapper::report_offence( + Vec::::new(), + MockOffence { + session_index: 1, + offenders: vec![(3, ())], + }, + ); + assert!(result.is_err()); + + // Session 1 cleaned up, session 0 untouched. + assert_eq!(PendingOffenceKind::::get(1, 3u64), None); + assert_eq!( + PendingOffenceKind::::get(0, 3u64), + Some(OffenceKind::GrandpaEquivocation), + ); + }); +} + fn start_era(era_index: EraIndex, session_index: SessionIndex, external_idx: u64) { Pallet::::on_era_start(era_index, session_index, external_idx); crate::mock::MockEraIndexProvider::with_era(era_index); diff --git a/operator/runtime/common/src/slashes_adapter.rs b/operator/runtime/common/src/slashes_adapter.rs index e28aabc4..74fa5c58 100644 --- a/operator/runtime/common/src/slashes_adapter.rs +++ b/operator/runtime/common/src/slashes_adapter.rs @@ -111,8 +111,8 @@ fn encode_slashing_request( let slashing_request = SlashingRequest { operator: Address::from(slash_operator.validator.0), strategies: strategies.clone(), - wadsToSlash: wads_to_slash, // We only have one strategy deployed - description: "Slashing validator".into(), + wadsToSlash: wads_to_slash, + description: slash_operator.description.clone().into(), }; slashings.push(slashing_request); diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index 2d97fc46..1499a9c5 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -321,8 +321,16 @@ impl pallet_babe::Config for Runtime { type KeyOwnerProof = >::Proof; - type EquivocationReportSystem = - pallet_babe::EquivocationReportSystem; + type EquivocationReportSystem = pallet_babe::EquivocationReportSystem< + Self, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::BabeEquivocation, + >, + Historical, + ReportLongevity, + >; } impl pallet_timestamp::Config for Runtime { @@ -401,7 +409,11 @@ impl pallet_im_online::Config for Runtime { type RuntimeEvent = RuntimeEvent; type ValidatorSet = Historical; type NextSessionRotation = Babe; - type ReportUnresponsiveness = Offences; + type ReportUnresponsiveness = pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::ImOnlineUnresponsive, + >; type UnsignedPriority = ImOnlineUnsignedPriority; type WeightInfo = crate::weights::pallet_im_online::WeightInfo; } @@ -424,7 +436,11 @@ impl pallet_grandpa::Config for Runtime { type KeyOwnerProof = >::Proof; type EquivocationReportSystem = pallet_grandpa::EquivocationReportSystem< Self, - Offences, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::GrandpaEquivocation, + >, Historical, EquivocationReportPeriodInBlocks, >; @@ -501,8 +517,16 @@ impl pallet_beefy::Config for Runtime { type AncestryHelper = BeefyMmrLeaf; type WeightInfo = (); type KeyOwnerProof = >::Proof; - type EquivocationReportSystem = - pallet_beefy::EquivocationReportSystem; + type EquivocationReportSystem = pallet_beefy::EquivocationReportSystem< + Self, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::BeefyEquivocation, + >, + Historical, + ReportLongevity, + >; } parameter_types! { @@ -1701,6 +1725,7 @@ impl pallet_external_validator_slashes::Config for Runtime { type EraIndexProvider = ExternalValidators; type InvulnerablesProvider = ExternalValidators; type ExternalIndexProvider = ExternalValidators; + type MaxSlashWad = runtime_params::dynamic_params::runtime_config::MaxSlashWad; type QueuedSlashesProcessedPerBlock = ConstU32<10>; type WeightInfo = mainnet_weights::pallet_external_validator_slashes::WeightInfo; type SendMessage = SlashesSendAdapter; diff --git a/operator/runtime/mainnet/src/configs/runtime_params.rs b/operator/runtime/mainnet/src/configs/runtime_params.rs index 605c9f73..aa35f269 100644 --- a/operator/runtime/mainnet/src/configs/runtime_params.rs +++ b/operator/runtime/mainnet/src/configs/runtime_params.rs @@ -417,6 +417,16 @@ pub mod dynamic_params { BoundedVec::truncate_from(vec![]); // ╚══════════════════════ EigenLayer Rewards V2 ═══════════════════════╝ + + // ╔══════════════════════ EigenLayer Slashing ═══════════════════════╗ + + #[codec(index = 46)] + #[allow(non_upper_case_globals)] + /// Maximum WAD value for EigenLayer slashing. Maps Perbill(100%) to this value. + /// 5e16 = 5% in WAD format (1e18 = 100%). + pub static MaxSlashWad: u128 = 50_000_000_000_000_000u128; + + // ╚══════════════════════ EigenLayer Slashing ═══════════════════════╝ } } diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index fb4cc4bb..299554c6 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -321,8 +321,16 @@ impl pallet_babe::Config for Runtime { type KeyOwnerProof = >::Proof; - type EquivocationReportSystem = - pallet_babe::EquivocationReportSystem; + type EquivocationReportSystem = pallet_babe::EquivocationReportSystem< + Self, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::BabeEquivocation, + >, + Historical, + ReportLongevity, + >; } impl pallet_timestamp::Config for Runtime { @@ -400,7 +408,11 @@ impl pallet_im_online::Config for Runtime { type RuntimeEvent = RuntimeEvent; type ValidatorSet = Historical; type NextSessionRotation = Babe; - type ReportUnresponsiveness = Offences; + type ReportUnresponsiveness = pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::ImOnlineUnresponsive, + >; type UnsignedPriority = ImOnlineUnsignedPriority; type WeightInfo = crate::weights::pallet_im_online::WeightInfo; } @@ -423,7 +435,11 @@ impl pallet_grandpa::Config for Runtime { type KeyOwnerProof = >::Proof; type EquivocationReportSystem = pallet_grandpa::EquivocationReportSystem< Self, - Offences, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::GrandpaEquivocation, + >, Historical, EquivocationReportPeriodInBlocks, >; @@ -498,8 +514,16 @@ impl pallet_beefy::Config for Runtime { type AncestryHelper = BeefyMmrLeaf; type WeightInfo = (); type KeyOwnerProof = >::Proof; - type EquivocationReportSystem = - pallet_beefy::EquivocationReportSystem; + type EquivocationReportSystem = pallet_beefy::EquivocationReportSystem< + Self, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::BeefyEquivocation, + >, + Historical, + ReportLongevity, + >; } parameter_types! { @@ -1697,6 +1721,7 @@ impl pallet_external_validator_slashes::Config for Runtime { type EraIndexProvider = ExternalValidators; type InvulnerablesProvider = ExternalValidators; type ExternalIndexProvider = ExternalValidators; + type MaxSlashWad = runtime_params::dynamic_params::runtime_config::MaxSlashWad; type QueuedSlashesProcessedPerBlock = ConstU32<10>; type WeightInfo = stagenet_weights::pallet_external_validator_slashes::WeightInfo; type SendMessage = SlashesSendAdapter; diff --git a/operator/runtime/stagenet/src/configs/runtime_params.rs b/operator/runtime/stagenet/src/configs/runtime_params.rs index f05caff5..d7775020 100644 --- a/operator/runtime/stagenet/src/configs/runtime_params.rs +++ b/operator/runtime/stagenet/src/configs/runtime_params.rs @@ -424,6 +424,16 @@ pub mod dynamic_params { BoundedVec::truncate_from(vec![]); // ╚══════════════════════ EigenLayer Rewards V2 ═══════════════════════╝ + + // ╔══════════════════════ EigenLayer Slashing ═══════════════════════╗ + + #[codec(index = 46)] + #[allow(non_upper_case_globals)] + /// Maximum WAD value for EigenLayer slashing. Maps Perbill(100%) to this value. + /// 5e16 = 5% in WAD format (1e18 = 100%). + pub static MaxSlashWad: u128 = 50_000_000_000_000_000u128; + + // ╚══════════════════════ EigenLayer Slashing ═══════════════════════╝ } } diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index 5b9305a9..ccf9d48a 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -321,8 +321,16 @@ impl pallet_babe::Config for Runtime { type KeyOwnerProof = >::Proof; - type EquivocationReportSystem = - pallet_babe::EquivocationReportSystem; + type EquivocationReportSystem = pallet_babe::EquivocationReportSystem< + Self, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::BabeEquivocation, + >, + Historical, + ReportLongevity, + >; } impl pallet_timestamp::Config for Runtime { @@ -400,7 +408,11 @@ impl pallet_im_online::Config for Runtime { type RuntimeEvent = RuntimeEvent; type ValidatorSet = Historical; type NextSessionRotation = Babe; - type ReportUnresponsiveness = Offences; + type ReportUnresponsiveness = pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::ImOnlineUnresponsive, + >; type UnsignedPriority = ImOnlineUnsignedPriority; type WeightInfo = crate::weights::pallet_im_online::WeightInfo; } @@ -423,7 +435,11 @@ impl pallet_grandpa::Config for Runtime { type KeyOwnerProof = >::Proof; type EquivocationReportSystem = pallet_grandpa::EquivocationReportSystem< Self, - Offences, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::GrandpaEquivocation, + >, Historical, EquivocationReportPeriodInBlocks, >; @@ -501,8 +517,16 @@ impl pallet_beefy::Config for Runtime { type AncestryHelper = BeefyMmrLeaf; type WeightInfo = (); type KeyOwnerProof = >::Proof; - type EquivocationReportSystem = - pallet_beefy::EquivocationReportSystem; + type EquivocationReportSystem = pallet_beefy::EquivocationReportSystem< + Self, + pallet_external_validator_slashes::EquivocationReportWrapper< + Runtime, + Offences, + pallet_external_validator_slashes::BeefyEquivocation, + >, + Historical, + ReportLongevity, + >; } parameter_types! { @@ -1700,6 +1724,7 @@ impl pallet_external_validator_slashes::Config for Runtime { type EraIndexProvider = ExternalValidators; type InvulnerablesProvider = ExternalValidators; type ExternalIndexProvider = ExternalValidators; + type MaxSlashWad = runtime_params::dynamic_params::runtime_config::MaxSlashWad; type QueuedSlashesProcessedPerBlock = ConstU32<10>; type WeightInfo = testnet_weights::pallet_external_validator_slashes::WeightInfo; type SendMessage = SlashesSendAdapter; diff --git a/operator/runtime/testnet/src/configs/runtime_params.rs b/operator/runtime/testnet/src/configs/runtime_params.rs index bc218f6c..5753d215 100644 --- a/operator/runtime/testnet/src/configs/runtime_params.rs +++ b/operator/runtime/testnet/src/configs/runtime_params.rs @@ -419,6 +419,16 @@ pub mod dynamic_params { BoundedVec::truncate_from(vec![]); // ╚══════════════════════ EigenLayer Rewards V2 ═══════════════════════╝ + + // ╔══════════════════════ EigenLayer Slashing ═══════════════════════╗ + + #[codec(index = 46)] + #[allow(non_upper_case_globals)] + /// Maximum WAD value for EigenLayer slashing. Maps Perbill(100%) to this value. + /// 5e16 = 5% in WAD format (1e18 = 100%). + pub static MaxSlashWad: u128 = 50_000_000_000_000_000u128; + + // ╚══════════════════════ EigenLayer Slashing ═══════════════════════╝ } } diff --git a/test/.papi/descriptors/package.json b/test/.papi/descriptors/package.json index d7be6139..d5f24906 100644 --- a/test/.papi/descriptors/package.json +++ b/test/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.13357056092938763018", + "version": "0.1.0-autogenerated.18296316742446681711", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/test/.papi/metadata/datahaven.scale b/test/.papi/metadata/datahaven.scale index 359a4996ce83cd35c179a02ef7773a7cd14400ef..d0ecbe092134c6f09ec29777cb7151c9c7c39f20 100644 GIT binary patch delta 29027 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 Q;62(2{6)1TjRTbb2VX8UKL7v# delta 28159 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μOldv564T77*#yN6J>HF=UezRBb5H+ei5 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 diff --git a/test/e2e/suites/slash.test.ts b/test/e2e/suites/slash.test.ts index 87e370f6..c212cd90 100644 --- a/test/e2e/suites/slash.test.ts +++ b/test/e2e/suites/slash.test.ts @@ -1,9 +1,11 @@ import { beforeAll, describe, expect, it } from "bun:test"; -import { FixedSizeBinary } from "polkadot-api"; +import { $ } from "bun"; +import { Binary, FixedSizeBinary } from "polkadot-api"; import { CROSS_CHAIN_TIMEOUTS, getPapiSigner, logger } from "utils"; import type { Address } from "viem"; import { getContractInstance, parseDeploymentsFile } from "../../utils/contracts"; import { waitForDataHavenEvent } from "../../utils/events"; +import { waitFor } from "../../utils/waits"; import { BaseTestSuite } from "../framework"; class SlashTestSuite extends BaseTestSuite { @@ -15,6 +17,10 @@ class SlashTestSuite extends BaseTestSuite { // Set up hooks in constructor this.setupHooks(); } + + getNetworkId(): string { + return this.networkId; + } } // Create the test suite instance @@ -129,7 +135,11 @@ describe("Should slash an operator", () => { const sudoSlashCall = dhApi.tx.ExternalValidatorsSlashes.force_inject_slash({ validator, era: activeEra?.index || 0, // Will fail if active era is 0. !! Important !! Sometimes for the inject to work (because of some latency) we need to inject in the `activeEra.index + 1` - percentage: 20 + percentage: 20, + offence_kind: { + type: "Custom", + value: Binary.fromText("Manual slash: E2E test") + } }); const sudoTx = dhApi.tx.Sudo.sudo({ call: sudoSlashCall.decodedCall @@ -159,4 +169,106 @@ describe("Should slash an operator", () => { } logger.info("Slashes message sent"); }, 560000); + + it("should detect and slash an unresponsive validator (liveness)", async () => { + const networkId = suite.getNetworkId(); + const bobContainer = `datahaven-bob-${networkId}`; + + // Drain any prior SlashReported events so we only see events from this test. + await dhApi.event.ExternalValidatorsSlashes.SlashReported.pull(); + + const activeEra = await dhApi.query.ExternalValidators.ActiveEra.getValue({ at: "best" }); + const eraAtStart = activeEra?.index ?? 0; + const sessionAtStart = await dhApi.query.Session.CurrentIndex.getValue({ at: "best" }); + logger.info(`Liveness test start — era: ${eraAtStart}, session: ${sessionAtStart}`); + + // Pause bob to simulate a liveness failure (missed heartbeats). + // Using pause/unpause instead of stop/start preserves bob's process + // state (GRANDPA voter, peer connections, keystore) so finality can + // resume quickly once unpaused. + logger.info(`Pausing bob container: ${bobContainer}`); + await $`docker pause ${bobContainer}`.quiet(); + logger.info("Bob container paused. Waiting for sessions to elapse..."); + + let slashReportedEvent: any; + try { + // Wait for at least TWO full sessions so pallet_im_online detects bob's + // missing heartbeats at a session boundary. + // + // Why two sessions: + // 1. Bob may have already sent his heartbeat for the current session + // before being paused, so the first session boundary won't report him. + // 2. The NEXT full session (where bob is offline from start to finish) + // will trigger the unresponsiveness report at its boundary. + // + // Timing with BABE c=(1,4) and PrimaryAndSecondaryVRFSlots: + // - Alice alone produces ~62.5% of blocks → ~9.6s per block on average. + // - 10 blocks/session → ~96s per session with alice only. + // - 2 sessions = ~192s. We use 200s for margin. + await Bun.sleep(200_000); + + // Unpause bob to restore GRANDPA finality (needs 2/2 validators). + // Bob's process resumes immediately with full state, so he can vote + // on the pending blocks that alice produced while he was paused. + logger.info("Unpausing bob container..."); + await $`docker unpause ${bobContainer}`.quiet(); + logger.info("Bob unpaused. Waiting for finality and SlashReported event..."); + + // Poll for a SlashReported event from the ExternalValidatorsSlashes pallet. + // + // Why SlashReported instead of Slashes storage: + // pallet_im_online's UnresponsivenessOffence::slash_fraction() formula + // gives 0% for small validator sets (1 out of 2 offline is below the + // 10%+1 threshold). With 0% fraction, compute_slash returns None and + // no Slashes entry is created. However, on_offence still emits the + // SlashReported event, which proves the full detection pipeline works: + // pallet_im_online → EquivocationReportWrapper → pallet_offences → on_offence. + // + // After unpausing bob, GRANDPA finality catches up and the events from + // alice's blocks (produced during the pause) become finalized and visible + // to the PAPI event subscription. + let pollCount = 0; + const collectedEvents: any[] = []; + await waitFor({ + lambda: async () => { + pollCount++; + const events = await dhApi.event.ExternalValidatorsSlashes.SlashReported.pull(); + for (const event of events) { + const payload = (event as any)?.payload ?? event; + collectedEvents.push(payload); + logger.info( + `[poll ${pollCount}] SlashReported event: validator=${payload?.validator}, ` + + `fraction=${JSON.stringify(payload?.fraction)}, slash_era=${payload?.slash_era}` + ); + } + if (collectedEvents.length > 0) { + slashReportedEvent = collectedEvents[0]; + return true; + } + if (pollCount % 10 === 0) { + const curEra = await dhApi.query.ExternalValidators.ActiveEra.getValue({ at: "best" }); + const curSession = await dhApi.query.Session.CurrentIndex.getValue({ at: "best" }); + logger.info( + `[poll ${pollCount}] era: ${curEra?.index}, session: ${curSession} (started at era=${eraAtStart}, session=${sessionAtStart})` + ); + } + return false; + }, + iterations: 60, + delay: 5000, + errorMessage: "SlashReported event not found after pausing bob for liveness detection" + }); + } finally { + // Ensure bob is always unpaused so the network stays healthy for teardown + await $`docker unpause ${bobContainer}`.nothrow().quiet(); + } + + expect(slashReportedEvent).toBeDefined(); + logger.info( + "Liveness offence confirmed via SlashReported: " + + `validator=${slashReportedEvent.validator}, ` + + `fraction=${JSON.stringify(slashReportedEvent.fraction)}, ` + + `slash_era=${slashReportedEvent.slash_era}` + ); + }, 600_000); }); From f5067ea8425e9bf5143c4f33797746160d330776 Mon Sep 17 00:00:00 2001 From: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:08:13 +0100 Subject: [PATCH 5/8] fix(e2e): stabilize submitter CI and local relayer startup (#470) ## Summary - fixes the untrusted CI failure in `e2e-tests / E2E Tests with Kurtosis Ethereum Network` - keeps validator-set-submitter startup actionable by avoiding test-only contract config imports during container startup - improves submitter readiness diagnostics by capturing both stdout and stderr from container logs and making streamed log matching robust to chunked UTF-8 output - reduces validator-set-submitter Docker build time in CI by building from `test/` and adding a tight `test/.dockerignore` - makes local arm64 E2E runs use a native local Snowbridge relayer image instead of forcing `linux/amd64` emulation - auto-builds the local relayer image when needed for `:local` tags ## Why The original failing untrusted test started as a submitter container startup problem, but the branch now also addresses a second timeout path that showed up while debugging: - the submitter image was being built from the repository root with a large Docker context, which made the `validator-set-update` suite spend most of its hook timeout budget inside `docker build` - on Apple Silicon, forcing `datahavenxyz/snowbridge-relay:latest` through `linux/amd64` caused `generate-beacon-checkpoint` to segfault during local runs These changes make the submitter failure actionable, cut the CI Docker build context down substantially, and keep local E2E runs reliable on arm64. ## Validation - `cd test && bun fmt` - `cd test && bun x tsc --noEmit` - `bun test e2e/suites/validator-set-update.test.ts --timeout 900000` - `cd test && docker build -f tools/validator-set-submitter/Dockerfile -t datahavenxyz/validator-set-submitter:local .` --- test/.dockerignore | 15 ++++++ test/e2e/framework/submitter.ts | 14 ++--- test/e2e/framework/suite.ts | 3 +- test/launcher/network/index.ts | 18 ++++--- test/launcher/relayers.ts | 53 ++++++++++++++++--- test/tools/validator-set-submitter/Dockerfile | 17 +++--- test/tools/validator-set-submitter/config.ts | 2 +- test/utils/docker.ts | 21 +++++--- 8 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 test/.dockerignore diff --git a/test/.dockerignore b/test/.dockerignore new file mode 100644 index 00000000..4731dc5a --- /dev/null +++ b/test/.dockerignore @@ -0,0 +1,15 @@ +# Keep submitter image build context minimal. +* + +!package.json +!bun.lock +!tsconfig.json +!bunfig.toml +!.papi/ +!.papi/** +!tools/validator-set-submitter/ +!tools/validator-set-submitter/** +!contract-bindings/ +!contract-bindings/** +!utils/ +!utils/** diff --git a/test/e2e/framework/submitter.ts b/test/e2e/framework/submitter.ts index cad168eb..66ce466f 100644 --- a/test/e2e/framework/submitter.ts +++ b/test/e2e/framework/submitter.ts @@ -17,13 +17,13 @@ const SUBMITTER_READY_TIMEOUT_SECONDS = 30; const SUBMITTER_LOG_TAIL_LINES = 200; /** - * Builds the validator-set-submitter Docker image from the repo root. + * Builds the validator-set-submitter Docker image from the test directory. */ export async function buildSubmitterImage(): Promise { logger.debug("Building validator-set-submitter Docker image..."); - const repoRoot = path.resolve(import.meta.dir, "../../.."); - await $`docker build -f test/tools/validator-set-submitter/Dockerfile -t ${SUBMITTER_IMAGE} .` - .cwd(repoRoot) + const testRoot = path.resolve(import.meta.dir, "../.."); + await $`docker build -f tools/validator-set-submitter/Dockerfile -t ${SUBMITTER_IMAGE} .` + .cwd(testRoot) .quiet(); logger.debug("Validator-set-submitter image built successfully"); } @@ -106,9 +106,11 @@ export async function launchSubmitter(options: LaunchSubmitterOptions): Promise< timeoutSeconds: SUBMITTER_READY_TIMEOUT_SECONDS }); } catch (error) { + const logResult = await $`docker logs --tail ${SUBMITTER_LOG_TAIL_LINES} ${containerName}` + .nothrow() + .quiet(); const logs = - (await $`docker logs --tail ${SUBMITTER_LOG_TAIL_LINES} ${containerName}`.nothrow().text()) || - ""; + `${logResult.stdout.toString()}${logResult.stderr.toString()}`.trim() || ""; await stopSubmitter(containerName); throw new Error( `Submitter did not become ready. Expected log "${SUBMITTER_READY_LOG}". Last ${SUBMITTER_LOG_TAIL_LINES} log lines:\n${logs}`, diff --git a/test/e2e/framework/suite.ts b/test/e2e/framework/suite.ts index 5afb7144..4a74252a 100644 --- a/test/e2e/framework/suite.ts +++ b/test/e2e/framework/suite.ts @@ -3,6 +3,7 @@ import readline from "node:readline"; import { isCI } from "launcher/network"; import { logger } from "utils"; import { launchNetwork } from "../../launcher"; +import { getDefaultRelayerImageTag } from "../../launcher/network"; import type { LaunchNetworkResult } from "../../launcher/types"; import { ConnectorFactory, type TestConnectors } from "./connectors"; import { TestSuiteManager } from "./manager"; @@ -57,7 +58,7 @@ export abstract class BaseTestSuite { datahavenImageTag: this.options.networkOptions?.datahavenImageTag || "datahavenxyz/datahaven:local", relayerImageTag: - this.options.networkOptions?.relayerImageTag || "datahavenxyz/snowbridge-relay:latest", + this.options.networkOptions?.relayerImageTag || getDefaultRelayerImageTag(), buildDatahaven: false, // default to false in the test suite so we can speed up the CI ...this.options.networkOptions }); diff --git a/test/launcher/network/index.ts b/test/launcher/network/index.ts index 55c1139f..783eb162 100644 --- a/test/launcher/network/index.ts +++ b/test/launcher/network/index.ts @@ -144,6 +144,7 @@ export const launchNetwork = async ( options: NetworkLaunchOptions ): Promise => { const networkId = options.networkId; + const relayerImageTag = options.relayerImageTag || getDefaultRelayerImageTag(); const launchedNetwork = new LaunchedNetwork(); launchedNetwork.networkName = networkId; let injectContracts = false; @@ -177,7 +178,7 @@ export const launchNetwork = async ( { networkId, datahavenImageTag: options.datahavenImageTag || "datahavenxyz/datahaven:local", - relayerImageTag: options.relayerImageTag || "datahavenxyz/snowbridge-relay:latest", + relayerImageTag, authorityIds: TEST_AUTHORITY_IDS, buildDatahaven: options.buildDatahaven ?? !isCI, // if not specified, default to false for CI, true for local testing datahavenBuildExtraArgs: options.datahavenBuildExtraArgs || "--features=fast-runtime" @@ -248,14 +249,10 @@ export const launchNetwork = async ( // 7. Launch relayers logger.info("❄️ Launching Snowbridge relayers..."); - if (!options.relayerImageTag) { - throw new Error("Relayer image tag not specified"); - } - await launchRelayers( { networkId, - relayerImageTag: options.relayerImageTag, + relayerImageTag, kurtosisEnclaveName }, launchedNetwork @@ -297,4 +294,13 @@ export const launchNetwork = async ( } }; +export const getDefaultRelayerImageTag = (): string => { + if (process.env.RELAYER_IMAGE_TAG) { + return process.env.RELAYER_IMAGE_TAG; + } + return process.arch === "arm64" + ? "datahavenxyz/snowbridge-relay:local" + : "datahavenxyz/snowbridge-relay:latest"; +}; + export const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; diff --git a/test/launcher/relayers.ts b/test/launcher/relayers.ts index a64cbad4..d1f33569 100644 --- a/test/launcher/relayers.ts +++ b/test/launcher/relayers.ts @@ -93,6 +93,47 @@ export const RELAYER_CONFIG_PATHS = { SOLOCHAIN: path.join(RELAYER_CONFIG_DIR, "solochain-relay.json") }; +const LOCAL_RELAYER_SOURCE_DIR = path.resolve( + import.meta.dir, + "..", + "..", + "contracts", + "lib", + "snowbridge", + "relayer" +); + +const isLocalRelayerImage = (relayerImageTag: string): boolean => + relayerImageTag.endsWith(":local"); + +const ensureLocalRelayerImage = async (relayerImageTag: string): Promise => { + if (!isLocalRelayerImage(relayerImageTag)) { + return; + } + + const localImageExists = await $`docker image inspect ${relayerImageTag}`.nothrow().quiet(); + if (localImageExists.exitCode === 0) { + logger.debug(`Local relayer image already available: ${relayerImageTag}`); + return; + } + + const dockerfilePath = path.join(LOCAL_RELAYER_SOURCE_DIR, "Dockerfile"); + const dockerfileExists = await Bun.file(dockerfilePath).exists(); + invariant( + dockerfileExists, + `❌ Local relayer Dockerfile not found at ${dockerfilePath}. Cannot build ${relayerImageTag}` + ); + + logger.info( + `🐳 Local relayer image ${relayerImageTag} not found. Building from ${LOCAL_RELAYER_SOURCE_DIR} for ${process.arch}...` + ); + await runShellCommandWithLogger(`docker build -f Dockerfile -t ${relayerImageTag} .`, { + cwd: LOCAL_RELAYER_SOURCE_DIR, + logLevel: "debug" + }); + logger.success(`✅ Built local relayer image: ${relayerImageTag}`); +}; + /** * Generates configuration files for relayers. * @@ -278,16 +319,16 @@ export const initEthClientPallet = async ( process.platform === "linux" ? "--add-host host.docker.internal:host-gateway" : ""; // Opportunistic pull - pull the image from Docker Hub only if it's not a local image - const isLocal = relayerImageTag.endsWith(":local"); + const isLocal = isLocalRelayerImage(relayerImageTag); + const platformParam = isLocal ? "" : "--platform linux/amd64"; logger.debug("Generating beacon checkpoint"); const datastoreHostPath = path.resolve(datastorePath); - const command = `docker run \ + const command = `docker run ${platformParam} \ -v ${beaconConfigHostPath}:${beaconConfigContainerPath}:ro \ -v ${checkpointHostPath}:${checkpointContainerPath} \ -v ${datastoreHostPath}:/data \ --name generate-beacon-checkpoint-${networkId} \ - --platform linux/amd64 \ --workdir /app \ ${addHostParam} \ ${launchedNetwork.networkName ? `--network ${launchedNetwork.networkName}` : ""} \ @@ -400,6 +441,7 @@ export const launchRelayers = async ( const { relayerImageTag, kurtosisEnclaveName } = options; invariant(relayerImageTag, "❌ relayerImageTag is required"); + await ensureLocalRelayerImage(relayerImageTag); await killExistingContainers("snowbridge-"); @@ -623,7 +665,7 @@ const launchRelayerContainers = async ( launchedNetwork: LaunchedNetwork, networkId: string ): Promise => { - const isLocal = relayerImageTag.endsWith(":local"); + const isLocal = isLocalRelayerImage(relayerImageTag); const networkName = launchedNetwork.networkName; invariant(networkName, "❌ Docker network name not found in LaunchedNetwork instance"); const restartArgs = ["--restart", "on-failure:5"]; @@ -641,8 +683,7 @@ const launchRelayerContainers = async ( "docker", "run", "-d", - "--platform", - "linux/amd64", + ...(isLocal ? [] : ["--platform", "linux/amd64"]), "--add-host", "host.docker.internal:host-gateway", "--name", diff --git a/test/tools/validator-set-submitter/Dockerfile b/test/tools/validator-set-submitter/Dockerfile index 84edba6d..584a0f20 100644 --- a/test/tools/validator-set-submitter/Dockerfile +++ b/test/tools/validator-set-submitter/Dockerfile @@ -1,7 +1,8 @@ # Validator Set Submitter image # -# Build from the repository root: -# docker build -f test/tools/validator-set-submitter/Dockerfile \ +# Build from the test directory: +# cd test +# docker build -f tools/validator-set-submitter/Dockerfile \ # -t datahavenxyz/validator-set-submitter:local . # # Runtime expectations: @@ -13,8 +14,8 @@ FROM oven/bun:1.3.3-slim AS deps WORKDIR /app -COPY test/package.json test/bun.lock test/tsconfig.json ./ -COPY test/.papi ./.papi +COPY package.json bun.lock tsconfig.json ./ +COPY .papi ./.papi RUN bun install --frozen-lockfile --production FROM oven/bun:1.3.3-slim @@ -24,10 +25,10 @@ WORKDIR /app RUN useradd -m -u 1001 -U -s /bin/sh -d /submitter submitter COPY --from=deps /app/node_modules ./node_modules -COPY test/tsconfig.json test/bunfig.toml ./ -COPY test/tools/validator-set-submitter/ ./tools/validator-set-submitter/ -COPY test/contract-bindings/ ./contract-bindings/ -COPY test/utils/ ./utils/ +COPY tsconfig.json bunfig.toml ./ +COPY tools/validator-set-submitter/ ./tools/validator-set-submitter/ +COPY contract-bindings/ ./contract-bindings/ +COPY utils/ ./utils/ ENV NODE_ENV=production diff --git a/test/tools/validator-set-submitter/config.ts b/test/tools/validator-set-submitter/config.ts index dc9a73d8..4c6107c9 100644 --- a/test/tools/validator-set-submitter/config.ts +++ b/test/tools/validator-set-submitter/config.ts @@ -1,4 +1,3 @@ -import { parseDeploymentsFile } from "utils"; import { parseEther } from "viem"; import { parse as parseYaml } from "yaml"; @@ -37,6 +36,7 @@ export async function loadConfig( let serviceManagerAddress = optionalHexString(raw, "service_manager_address"); if (!serviceManagerAddress) { + const { parseDeploymentsFile } = await import("../../utils/contracts.ts"); const deployments = await parseDeploymentsFile(networkId); serviceManagerAddress = deployments.ServiceManager; } diff --git a/test/utils/docker.ts b/test/utils/docker.ts index 7029e765..b1c00b62 100644 --- a/test/utils/docker.ts +++ b/test/utils/docker.ts @@ -178,6 +178,13 @@ export async function waitForLog(opts: { const { readable } = Transform.toWeb(pass); const decoder = new TextDecoder(); + let bufferedLogs = ""; + const hasHit = (text: string): boolean => { + if (typeof opts.search === "string") return text.includes(opts.search); + // Avoid stateful regex surprises with /g or /y across multiple checks. + opts.search.lastIndex = 0; + return opts.search.test(text); + }; const timer = setTimeout( () => pass.destroy( @@ -190,14 +197,16 @@ export async function waitForLog(opts: { try { for await (const chunk of readable) { - const text = decoder.decode(chunk as Uint8Array, { stream: false }); - - const hit = - typeof opts.search === "string" ? text.includes(opts.search) : opts.search.test(text); - - if (hit) return text.trim(); + bufferedLogs += decoder.decode(chunk as Uint8Array, { stream: true }); + if (hasHit(bufferedLogs)) return bufferedLogs.trim(); + if (bufferedLogs.length > 64_000) { + bufferedLogs = bufferedLogs.slice(-64_000); + } } + bufferedLogs += decoder.decode(); + if (hasHit(bufferedLogs)) return bufferedLogs.trim(); + throw new Error( `Log stream ended before "${opts.search}" appeared for container ${opts.containerName}` ); From b4e22035a31dd1f1c676f49759924d8c404ebd65 Mon Sep 17 00:00:00 2001 From: undercover-cactus Date: Mon, 9 Mar 2026 14:33:43 +0100 Subject: [PATCH 6/8] fix: Register the snowbridge agent in the Dathaven Service instead of the operator node (#428) ## Summary This PR rename the `rewardsAgentOrigin`, `rewardsMessageOrigin`, etc... into a less specific less now that the Snowbrige Agent is also being used to relay slashing messages. This PR also have a fix to register the Agent address instead of the operator node address to check the sender of the message. Without this fix we could never relay rewards or execute slashing because we would get an error regarding the message. ## What changed * Removing the prefix `rewards` everytime we were refering the snowbridge agent (to clarify that the agent is not only being used by the reward feature) * Fix the deployment script to register the `agentAddress` as the required sender for relaying substrate message ## What is missing [ ] ~~Rename `onlyRewardsInitiator` and `rewardsInitiator` in the `DatahavenServiceManager.sol ` for something that would include slashing~~ This should be done in another PR. [x] Check the Testnet Deploy script to make sure it is using the agent address --------- Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> --- contracts/config/anvil.json | 2 +- contracts/config/example.jsonc | 6 +- contracts/config/mainnet-ethereum.json | 2 +- contracts/config/stagenet-hoodi.json | 2 +- contracts/config/testnet-hoodi.json | 2 +- contracts/deployments/anvil-agent-info.json | 1 + contracts/deployments/anvil-rewards-info.json | 2 +- .../stagenet-hoodi-rewards-info.json | 2 +- contracts/script/deploy/Config.sol | 2 +- contracts/script/deploy/DeployBase.s.sol | 51 +++++++-------- contracts/script/deploy/DeployParams.s.sol | 3 +- operator/runtime/mainnet/src/configs/mod.rs | 6 +- .../mainnet/src/configs/runtime_params.rs | 4 +- operator/runtime/stagenet/src/configs/mod.rs | 16 ++--- .../stagenet/src/configs/runtime_params.rs | 4 +- operator/runtime/testnet/src/configs/mod.rs | 17 +++-- .../testnet/src/configs/runtime_params.rs | 4 +- test/.papi/descriptors/package.json | 2 +- test/.papi/metadata/datahaven.scale | Bin 632706 -> 632664 bytes test/cli/handlers/contracts/rewards-origin.ts | 58 +++++++++--------- test/cli/index.ts | 9 +-- .../parameters/datahaven-parameters.json | 2 +- test/e2e/suites/slash.test.ts | 18 +++++- test/utils/types.ts | 2 +- 24 files changed, 115 insertions(+), 102 deletions(-) create mode 100644 contracts/deployments/anvil-agent-info.json diff --git a/contracts/config/anvil.json b/contracts/config/anvil.json index 8e3796c3..4a134ced 100644 --- a/contracts/config/anvil.json +++ b/contracts/config/anvil.json @@ -35,7 +35,7 @@ "randaoCommitExpiration": 24, "minNumRequiredSignatures": 2, "startBlock": 1, - "rewardsMessageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000", + "messageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000", "initialValidatorSetId": 0, "initialValidatorHashes": [ "0xaeb47a269393297f4b0a3c9c9cfd00c7a4195255274cf39d83dabc2fcc9ff3d7", diff --git a/contracts/config/example.jsonc b/contracts/config/example.jsonc index e57c1ec7..aeef7cc4 100644 --- a/contracts/config/example.jsonc +++ b/contracts/config/example.jsonc @@ -99,9 +99,9 @@ /// Initial BEEFY block number. Set to latest finalized block by update-beefy-checkpoint. /// The BeefyClient will only accept BEEFY commitments with block numbers > startBlock. "startBlock": 1, - /// The origin linked to the Rewards Agent, the Agent contract who's allowed to submit - /// new reward merkle roots to the RewardsRegistry contract. - "rewardsMessageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000", + /// The origin linked to the Agent, the Agent contract who's allowed to submit + /// new reward merkle roots or slashes. + "messageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000", /// The BEEFY validator set ID for the initial validators. /// This is fetched from Beefy.ValidatorSetId on the DataHaven chain. "initialValidatorSetId": 0, diff --git a/contracts/config/mainnet-ethereum.json b/contracts/config/mainnet-ethereum.json index 5a0e13fb..28775dad 100644 --- a/contracts/config/mainnet-ethereum.json +++ b/contracts/config/mainnet-ethereum.json @@ -43,7 +43,7 @@ "randaoCommitExpiration": 24, "minNumRequiredSignatures": 16, "startBlock": 1, - "rewardsMessageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000", + "messageOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000", "initialValidatorSetId": 0, "initialValidatorHashes": [], "nextValidatorSetId": 1, diff --git a/contracts/config/stagenet-hoodi.json b/contracts/config/stagenet-hoodi.json index a79ac73a..40d80dbc 100644 --- a/contracts/config/stagenet-hoodi.json +++ b/contracts/config/stagenet-hoodi.json @@ -44,7 +44,7 @@ "randaoCommitExpiration": 24, "minNumRequiredSignatures": 3, "startBlock": 1303065, - "rewardsMessageOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd", + "messageOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd", "initialValidatorSetId": 2186, "initialValidatorHashes": [ "0x07ce4f2cd558f4d4b529a3362b6ff7d616ca0893b53252dc62829b8218ea5c10", diff --git a/contracts/config/testnet-hoodi.json b/contracts/config/testnet-hoodi.json index 1aa56fee..3396b7b4 100644 --- a/contracts/config/testnet-hoodi.json +++ b/contracts/config/testnet-hoodi.json @@ -44,7 +44,7 @@ "randaoCommitExpiration": 24, "minNumRequiredSignatures": 5, "startBlock": 1381173, - "rewardsMessageOrigin": "0xd0d6dbd1ffb401ef613f00e93cd5061ecec03ae35d2f820cd6754a5b5f020215", + "messageOrigin": "0xd0d6dbd1ffb401ef613f00e93cd5061ecec03ae35d2f820cd6754a5b5f020215", "initialValidatorSetId": 2303, "initialValidatorHashes": [ "0x0ec3102f334aba804c18b843e45ec874005587122a1b49273b1b04d6fd98b1a2", diff --git a/contracts/deployments/anvil-agent-info.json b/contracts/deployments/anvil-agent-info.json new file mode 100644 index 00000000..b0bc6882 --- /dev/null +++ b/contracts/deployments/anvil-agent-info.json @@ -0,0 +1 @@ +{"Agent": "0xac06641381166cf085281c45292147f833C622d7","AgentOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file diff --git a/contracts/deployments/anvil-rewards-info.json b/contracts/deployments/anvil-rewards-info.json index ea1ee44d..c91dba57 100644 --- a/contracts/deployments/anvil-rewards-info.json +++ b/contracts/deployments/anvil-rewards-info.json @@ -1 +1 @@ -{"RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7","RewardsAgentOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file +{"RewardsAgent": "0xac06641381166cf085281c45292147f833C622d7","AgentOrigin": "0x0000000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file diff --git a/contracts/deployments/stagenet-hoodi-rewards-info.json b/contracts/deployments/stagenet-hoodi-rewards-info.json index 32bba16a..fc588b28 100644 --- a/contracts/deployments/stagenet-hoodi-rewards-info.json +++ b/contracts/deployments/stagenet-hoodi-rewards-info.json @@ -1 +1 @@ -{"RewardsAgent": "0x2E039a88838241d1Ac738cf2e3C5763ba12571e7","RewardsAgentOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd"} \ No newline at end of file +{"RewardsAgent": "0x2E039a88838241d1Ac738cf2e3C5763ba12571e7","AgentOrigin": "0x56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd"} \ No newline at end of file diff --git a/contracts/script/deploy/Config.sol b/contracts/script/deploy/Config.sol index 63558988..4d076407 100644 --- a/contracts/script/deploy/Config.sol +++ b/contracts/script/deploy/Config.sol @@ -12,7 +12,7 @@ contract Config { bytes32[] initialValidatorHashes; uint128 nextValidatorSetId; bytes32[] nextValidatorHashes; - bytes32 rewardsMessageOrigin; + bytes32 messageOrigin; } // AVS parameters diff --git a/contracts/script/deploy/DeployBase.s.sol b/contracts/script/deploy/DeployBase.s.sol index 64de00e4..145c54aa 100644 --- a/contracts/script/deploy/DeployBase.s.sol +++ b/contracts/script/deploy/DeployBase.s.sol @@ -123,7 +123,7 @@ abstract contract DeployBase is Script, DeployParams, Accounts { BeefyClient beefyClient, AgentExecutor agentExecutor, IGatewayV2 gateway, - address payable rewardsAgentAddress + address payable agentAddress ) = _deploySnowbridge(snowbridgeConfig); Logging.logFooter(); _logProgress(); @@ -132,14 +132,14 @@ abstract contract DeployBase is Script, DeployParams, Accounts { ( DataHavenServiceManager serviceManager, DataHavenServiceManager serviceManagerImplementation - ) = _deployDataHavenContracts(avsConfig, proxyAdmin, gateway); + ) = _deployDataHavenContracts(avsConfig, proxyAdmin, gateway, agentAddress); Logging.logFooter(); _logProgress(); // Final configuration (same for both modes) Logging.logHeader("FINAL CONFIGURATION"); - Logging.logContractDeployed("Rewards Agent Address", rewardsAgentAddress); + Logging.logContractDeployed("Agent Address", agentAddress); Logging.logFooter(); _logProgress(); @@ -150,11 +150,11 @@ abstract contract DeployBase is Script, DeployParams, Accounts { gateway, serviceManager, serviceManagerImplementation, - rewardsAgentAddress, + agentAddress, proxyAdmin ); - _outputRewardsAgentInfo(rewardsAgentAddress, snowbridgeConfig.rewardsMessageOrigin); + _outputAgentInfo(agentAddress, snowbridgeConfig.messageOrigin); } /** @@ -201,11 +201,11 @@ abstract contract DeployBase is Script, DeployParams, Accounts { // Create Agent Logging.logSection("Creating Snowbridge Agent"); vm.broadcast(_deployerPrivateKey); - gateway.v2_createAgent(config.rewardsMessageOrigin); - address payable rewardsAgentAddress = payable(gateway.agentOf(config.rewardsMessageOrigin)); - Logging.logContractDeployed("Rewards Agent", rewardsAgentAddress); + gateway.v2_createAgent(config.messageOrigin); + address payable agentAddress = payable(gateway.agentOf(config.messageOrigin)); + Logging.logContractDeployed("Rewards Agent", agentAddress); - return (beefyClient, agentExecutor, gateway, rewardsAgentAddress); + return (beefyClient, agentExecutor, gateway, agentAddress); } /** @@ -240,7 +240,8 @@ abstract contract DeployBase is Script, DeployParams, Accounts { function _deployDataHavenContracts( AVSConfig memory avsConfig, ProxyAdmin proxyAdmin, - IGatewayV2 gateway + IGatewayV2 gateway, + address agentAddress ) internal returns (DataHavenServiceManager, DataHavenServiceManager) { Logging.logHeader("DATAHAVEN CUSTOM CONTRACTS DEPLOYMENT"); @@ -269,7 +270,7 @@ abstract contract DeployBase is Script, DeployParams, Accounts { // Create service manager initialisation parameters struct ServiceManagerInitParams memory initParams = ServiceManagerInitParams({ avsOwner: avsConfig.avsOwner, - rewardsInitiator: avsConfig.rewardsInitiator, + rewardsInitiator: agentAddress, validatorsStrategiesAndMultipliers: strategiesAndMultipliers, gateway: address(gateway), validatorSetSubmitter: avsConfig.validatorSetSubmitter, @@ -313,38 +314,38 @@ abstract contract DeployBase is Script, DeployParams, Accounts { IGatewayV2 gateway, DataHavenServiceManager serviceManager, DataHavenServiceManager serviceManagerImplementation, - address rewardsAgent, + address agent, ProxyAdmin proxyAdmin ) internal virtual; /** - * @notice Output rewards agent info (shared across all deployment types) + * @notice Output agent info (shared across all deployment types) */ - function _outputRewardsAgentInfo( - address rewardsAgent, - bytes32 rewardsAgentOrigin + function _outputAgentInfo( + address agent, + bytes32 agentOrigin ) internal { - Logging.logHeader("REWARDS AGENT INFO"); - Logging.logContractDeployed("RewardsAgent", rewardsAgent); - Logging.logAgentOrigin("RewardsAgentOrigin", vm.toString(rewardsAgentOrigin)); + Logging.logHeader("AGENT INFO"); + Logging.logContractDeployed("Agent", agent); + Logging.logAgentOrigin("AgentOrigin", vm.toString(agentOrigin)); Logging.logFooter(); // Write to deployment file for future reference string memory network = _getNetworkName(); - string memory rewardsInfoPath = - string.concat(vm.projectRoot(), "/deployments/", network, "-rewards-info.json"); + string memory agentInfoPath = + string.concat(vm.projectRoot(), "/deployments/", network, "-agent-info.json"); // Create directory if it doesn't exist vm.createDir(string.concat(vm.projectRoot(), "/deployments"), true); // Create JSON with rewards info string memory json = "{"; - json = string.concat(json, '"RewardsAgent": "', vm.toString(rewardsAgent), '",'); - json = string.concat(json, '"RewardsAgentOrigin": "', vm.toString(rewardsAgentOrigin), '"'); + json = string.concat(json, '"Agent": "', vm.toString(agent), '",'); + json = string.concat(json, '"AgentOrigin": "', vm.toString(agentOrigin), '"'); json = string.concat(json, "}"); // Write to file - vm.writeFile(rewardsInfoPath, json); - Logging.logInfo(string.concat("Rewards info saved to: ", rewardsInfoPath)); + vm.writeFile(agentInfoPath, json); + Logging.logInfo(string.concat("Agent info saved to: ", agentInfoPath)); } } diff --git a/contracts/script/deploy/DeployParams.s.sol b/contracts/script/deploy/DeployParams.s.sol index eda4630f..c77f99b9 100644 --- a/contracts/script/deploy/DeployParams.s.sol +++ b/contracts/script/deploy/DeployParams.s.sol @@ -24,8 +24,7 @@ contract DeployParams is Script, Config { config.minNumRequiredSignatures = vm.parseJsonUint(configJson, ".snowbridge.minNumRequiredSignatures"); config.startBlock = vm.parseJsonUint(configJson, ".snowbridge.startBlock").toUint64(); - config.rewardsMessageOrigin = - vm.parseJsonBytes32(configJson, ".snowbridge.rewardsMessageOrigin"); + config.messageOrigin = vm.parseJsonBytes32(configJson, ".snowbridge.messageOrigin"); // Load validators from file or generate placeholder ones in dev mode bool isDevMode = keccak256(abi.encodePacked(vm.envOr("DEV_MODE", string("false")))) diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index 1499a9c5..0deeff2a 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -1518,7 +1518,7 @@ impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for Main } fn rewards_agent_origin() -> H256 { - runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get() + runtime_params::dynamic_params::runtime_config::AgentOrigin::get() } fn strategies_and_multipliers() -> Vec<(H160, u128)> { @@ -1692,9 +1692,9 @@ impl datahaven_runtime_common::slashes_adapter::SlashesSubmissionConfig for Main runtime_params::dynamic_params::runtime_config::DatahavenServiceManagerAddress::get() } + // TODO: remove `slashes_` prefix and just call it `agent_origin` fn slashes_agent_origin() -> H256 { - runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get() - // TODO: Can we use the same as reward and just rename the config to `AgentOrigin` ? + runtime_params::dynamic_params::runtime_config::AgentOrigin::get() } fn strategies() -> Vec
{ diff --git a/operator/runtime/mainnet/src/configs/runtime_params.rs b/operator/runtime/mainnet/src/configs/runtime_params.rs index aa35f269..9d3cdeb3 100644 --- a/operator/runtime/mainnet/src/configs/runtime_params.rs +++ b/operator/runtime/mainnet/src/configs/runtime_params.rs @@ -48,9 +48,9 @@ pub mod dynamic_params { #[codec(index = 3)] #[allow(non_upper_case_globals)] - /// The RewardsAgentOrigin is the hash of the string "external_validators_rewards" + /// The AgentOrigin is the hash of the string "external_validators_rewards" /// TODO: Decide which agent origin we want to use. Currently for testing it's the zero hash - pub static RewardsAgentOrigin: H256 = H256::from_slice(&hex!( + pub static AgentOrigin: H256 = H256::from_slice(&hex!( "c505dfb2df107d106d08bd0f1a0acd10052ca9aa078629a4ccfd0c90c6e69b65" )); diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index 299554c6..60fee86c 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -1514,7 +1514,7 @@ impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for Stag } fn rewards_agent_origin() -> H256 { - runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get() + runtime_params::dynamic_params::runtime_config::AgentOrigin::get() } fn strategies_and_multipliers() -> Vec<(H160, u128)> { @@ -1689,7 +1689,7 @@ impl datahaven_runtime_common::slashes_adapter::SlashesSubmissionConfig for Stag } fn slashes_agent_origin() -> H256 { - runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get() + runtime_params::dynamic_params::runtime_config::AgentOrigin::get() // TODO: Can we use the same as reward and just rename the config to `AgentOrigin` ? } @@ -1951,8 +1951,8 @@ mod tests { /// Test that the Rewards Agent ID (used for Snowbridge outbound messages from the rewards pallet) /// is correctly computed from the chain's genesis hash and the ExternalValidatorRewardsAccount. /// - /// This test verifies the value that should be set as `RewardsAgentOrigin` in runtime parameters - /// and as `rewardsMessageOrigin` in the AVS contract configuration. + /// This test verifies the value that should be set as `AgentOrigin` in runtime parameters + /// and as `messageOrigin` in the AVS contract configuration. /// /// The Agent ID is computed following Snowbridge's pattern for GlobalConsensus locations: /// blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), compact_len, "AccountKey20", account_key)) @@ -1992,8 +1992,8 @@ mod tests { // Hash with blake2_256 let computed_agent_id = H256(blake2_256(&encoded)); - // Expected Agent ID - this value must match RewardsAgentOrigin in runtime_params.rs - // If this test fails, update RewardsAgentOrigin to match the computed value. + // Expected Agent ID - this value must match AgentOrigin in runtime_params.rs + // If this test fails, update AgentOrigin to match the computed value. let expected_agent_id = H256(hex_literal::hex!( "56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd" )); @@ -2003,8 +2003,8 @@ mod tests { expected_agent_id, "Computed Rewards Agent ID must match expected value.\n\ This value should be set as:\n\ - - RewardsAgentOrigin in runtime_params.rs\n\ - - rewardsMessageOrigin in AVS contract config\n\ + - AgentOrigin in runtime_params.rs\n\ + - messageOrigin in AVS contract config\n\ \n\ Rewards account: 0x{}\n\ Genesis hash: 0x{}\n\ diff --git a/operator/runtime/stagenet/src/configs/runtime_params.rs b/operator/runtime/stagenet/src/configs/runtime_params.rs index d7775020..dcf136c9 100644 --- a/operator/runtime/stagenet/src/configs/runtime_params.rs +++ b/operator/runtime/stagenet/src/configs/runtime_params.rs @@ -51,13 +51,13 @@ pub mod dynamic_params { #[codec(index = 3)] #[allow(non_upper_case_globals)] - /// The RewardsAgentOrigin is the Agent ID for the rewards pallet's outbound Snowbridge messages. + /// The AgentOrigin is the Agent ID for the rewards/slashes pallet's outbound Snowbridge messages. /// Computed as: blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), interior)) /// where interior = SCALE_ENCODE("AccountKey20", ExternalValidatorRewardsAccount) /// /// For stagenet with genesis hash 0x72d0856fd339e09cb21df7bac8ac3120bd871e327ec0e1658395df68acef9bee /// and rewards account 0x6d6f646c64682f65767265770000000000000000 (from PalletId "dh/evrew"): - pub static RewardsAgentOrigin: H256 = H256::from_slice(&hex!( + pub static AgentOrigin: H256 = H256::from_slice(&hex!( "56490bd3f367447bfaf57bb18e7a45e1b2db7d538fe42098e87d2aa106c6afdd" )); diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index ccf9d48a..caca5de5 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -1518,7 +1518,7 @@ impl datahaven_runtime_common::rewards_adapter::RewardsSubmissionConfig for Test } fn rewards_agent_origin() -> H256 { - runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get() + runtime_params::dynamic_params::runtime_config::AgentOrigin::get() } fn strategies_and_multipliers() -> Vec<(H160, u128)> { @@ -1693,8 +1693,7 @@ impl datahaven_runtime_common::slashes_adapter::SlashesSubmissionConfig for Test } fn slashes_agent_origin() -> H256 { - runtime_params::dynamic_params::runtime_config::RewardsAgentOrigin::get() - // TODO: Can we use the same as reward and just rename the config to `AgentOrigin` ? + runtime_params::dynamic_params::runtime_config::AgentOrigin::get() } fn strategies() -> Vec
{ @@ -1973,8 +1972,8 @@ mod tests { /// Test that the Rewards Agent ID (used for Snowbridge outbound messages from the rewards pallet) /// is correctly computed from the chain's genesis hash and the ExternalValidatorRewardsAccount. /// - /// This test verifies the value that should be set as `RewardsAgentOrigin` in runtime parameters - /// and as `rewardsMessageOrigin` in the AVS contract configuration. + /// This test verifies the value that should be set as `AgentOrigin` in runtime parameters + /// and as `messageOrigin` in the AVS contract configuration. /// /// The Agent ID is computed following Snowbridge's pattern for GlobalConsensus locations: /// blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), compact_len, "AccountKey20", account_key)) @@ -2014,8 +2013,8 @@ mod tests { // Hash with blake2_256 let computed_agent_id = H256(blake2_256(&encoded)); - // Expected Agent ID - this value must match RewardsAgentOrigin in runtime_params.rs - // If this test fails, update RewardsAgentOrigin to match the computed value. + // Expected Agent ID - this value must match AgentOrigin in runtime_params.rs + // If this test fails, update AgentOrigin to match the computed value. let expected_agent_id = H256(hex_literal::hex!( "d0d6dbd1ffb401ef613f00e93cd5061ecec03ae35d2f820cd6754a5b5f020215" )); @@ -2025,8 +2024,8 @@ mod tests { expected_agent_id, "Computed Rewards Agent ID must match expected value.\n\ This value should be set as:\n\ - - RewardsAgentOrigin in runtime_params.rs\n\ - - rewardsMessageOrigin in AVS contract config\n\ + - AgentOrigin in runtime_params.rs\n\ + - messageOrigin in AVS contract config\n\ \n\ Rewards account: 0x{}\n\ Genesis hash: 0x{}\n\ diff --git a/operator/runtime/testnet/src/configs/runtime_params.rs b/operator/runtime/testnet/src/configs/runtime_params.rs index 5753d215..40c89108 100644 --- a/operator/runtime/testnet/src/configs/runtime_params.rs +++ b/operator/runtime/testnet/src/configs/runtime_params.rs @@ -49,13 +49,13 @@ pub mod dynamic_params { #[codec(index = 3)] #[allow(non_upper_case_globals)] - /// The RewardsAgentOrigin is the Agent ID for the rewards pallet's outbound Snowbridge messages. + /// The AgentOrigin is the Agent ID for the rewards/slashes pallet's outbound Snowbridge messages. /// Computed as: blake2_256(SCALE_ENCODE("GlobalConsensus", ByGenesis(genesis_hash), interior)) /// where interior = SCALE_ENCODE("AccountKey20", ExternalValidatorRewardsAccount) /// /// For testnet with genesis hash 0xdbf403d348916fb0694485bc7f9c0d8c53fdf86664ebac019af209c090c3df99 /// and rewards account 0x6d6f646c64682f65767265770000000000000000 (from PalletId "dh/evrew"): - pub static RewardsAgentOrigin: H256 = H256::from_slice(&hex!( + pub static AgentOrigin: H256 = H256::from_slice(&hex!( "d0d6dbd1ffb401ef613f00e93cd5061ecec03ae35d2f820cd6754a5b5f020215" )); diff --git a/test/.papi/descriptors/package.json b/test/.papi/descriptors/package.json index d5f24906..5ad366a9 100644 --- a/test/.papi/descriptors/package.json +++ b/test/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.18296316742446681711", + "version": "0.1.0-autogenerated.15484599658830368838", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/test/.papi/metadata/datahaven.scale b/test/.papi/metadata/datahaven.scale index d0ecbe092134c6f09ec29777cb7151c9c7c39f20..1ad174a797dc11e0174c4289ff51aa16bb594ea0 100644 GIT binary patch delta 104 zcmZoVuXf{{+J+t{Asxr`)Vvb^qRjNnJQfB{Mn;{@Yn%ePfE25*zX*_uNqqekMHL5F hReO3kBM>tIF*6Xe05K~NvjH(X5OZu#59gE)00232A*lcW delta 146 zcmca{POa&@+J+t{1&^TA^2DN)V#oB { * blake2_256(SCALE_ENCODE(("GlobalConsensus", ByGenesis(genesis_hash), ("AccountKey20", account_key)))) * * NOTE: This computation follows Snowbridge's pattern but may need verification against - * the actual on-chain Agent ID. The preferred approach is to set RewardsAgentOrigin on + * the actual on-chain Agent ID. The preferred approach is to set AgentOrigin on * the chain and fetch it via this command. * * @param genesisHash - The chain's genesis hash (32 bytes, hex string with 0x prefix) @@ -116,36 +116,36 @@ const computeAgentId = async (genesisHash: Hex, accountKey20: Hex): Promise }; /** - * Fetches the RewardsAgentOrigin from the runtime parameters. + * Fetches the AgentOrigin from the runtime parameters. * * @param rpcUrl - WebSocket RPC endpoint of the DataHaven chain - * @returns The RewardsAgentOrigin as a hex string, or null if not set or zero + * @returns The AgentOrigin as a hex string, or null if not set or zero */ -const fetchRewardsAgentOrigin = async (rpcUrl: string): Promise => { +const fetchAgentOrigin = async (rpcUrl: string): Promise => { logger.info(`📡 Connecting to DataHaven chain at ${rpcUrl}...`); const { client: papiClient, typedApi: dhApi } = createPapiConnectors(rpcUrl); try { - logger.info("🔍 Fetching RewardsAgentOrigin from runtime parameters..."); + logger.info("🔍 Fetching AgentOrigin from runtime parameters..."); - // Query the Parameters pallet for RewardsAgentOrigin + // Query the Parameters pallet for AgentOrigin const parameter = await dhApi.query.Parameters.Parameters.getValue( { type: "RuntimeConfig", - value: { type: "RewardsAgentOrigin", value: undefined } + value: { type: "AgentOrigin", value: undefined } }, { at: "best" } ); if (!parameter) { - logger.info("ℹ️ RewardsAgentOrigin parameter not found (using default)"); + logger.info("ℹ️ AgentOrigin parameter not found (using default)"); return null; } // Extract the value from the parameter result // The parameter is wrapped in the RuntimeConfig enum variant - if (parameter.type === "RuntimeConfig" && parameter.value.type === "RewardsAgentOrigin") { + if (parameter.type === "RuntimeConfig" && parameter.value.type === "AgentOrigin") { const origin = parameter.value.value; if (origin) { const originHex = origin.asHex(); @@ -153,15 +153,15 @@ const fetchRewardsAgentOrigin = async (rpcUrl: string): Promise => { const zeroHash = "0x0000000000000000000000000000000000000000000000000000000000000000" as Hex; if (originHex === zeroHash) { - logger.info("ℹ️ RewardsAgentOrigin is set to zero (placeholder)"); + logger.info("ℹ️ AgentOrigin is set to zero (placeholder)"); return null; } - logger.success(`Found RewardsAgentOrigin: ${originHex}`); + logger.success(`Found AgentOrigin: ${originHex}`); return originHex as Hex; } } - logger.info("ℹ️ RewardsAgentOrigin value not available"); + logger.info("ℹ️ AgentOrigin value not available"); return null; } finally { papiClient.destroy(); @@ -193,9 +193,9 @@ const fetchGenesisHash = async (rpcUrl: string): Promise => { * Updates the config file with the rewards message origin. * * @param networkId - The network identifier (e.g., "hoodi", "stagenet-hoodi") - * @param rewardsMessageOrigin - The rewards message origin (Agent ID) + * @param messageOrigin - The rewards message origin (Agent ID) */ -const updateConfigFile = async (networkId: string, rewardsMessageOrigin: Hex): Promise => { +const updateConfigFile = async (networkId: string, messageOrigin: Hex): Promise => { const configFilePath = `../contracts/config/${networkId}.json`; const configFile = Bun.file(configFilePath); @@ -211,21 +211,21 @@ const updateConfigFile = async (networkId: string, rewardsMessageOrigin: Hex): P configJson.snowbridge = {}; } - const oldOrigin = configJson.snowbridge.rewardsMessageOrigin; - configJson.snowbridge.rewardsMessageOrigin = rewardsMessageOrigin; + const oldOrigin = configJson.snowbridge.messageOrigin; + configJson.snowbridge.messageOrigin = messageOrigin; await Bun.write(configFilePath, `${JSON.stringify(configJson, null, 2)}\n`); logger.success(`Config file updated: ${configFilePath}`); - if (oldOrigin !== rewardsMessageOrigin) { - logger.info(` rewardsMessageOrigin: ${oldOrigin ?? "unset"} -> ${rewardsMessageOrigin}`); + if (oldOrigin !== messageOrigin) { + logger.info(` messageOrigin: ${oldOrigin ?? "unset"} -> ${messageOrigin}`); } }; /** * Main handler for the update-rewards-origin command. - * Fetches or computes the RewardsAgentOrigin and updates the config file. + * Fetches or computes the AgentOrigin and updates the config file. */ export const updateRewardsOrigin = async (options: UpdateRewardsOriginOptions): Promise => { const networkId = buildNetworkId(options.chain, options.environment); @@ -246,17 +246,17 @@ export const updateRewardsOrigin = async (options: UpdateRewardsOriginOptions): printDivider(); try { - // Step 1: Try to fetch RewardsAgentOrigin from the chain - let rewardsMessageOrigin = await fetchRewardsAgentOrigin(options.rpcUrl); + // Step 1: Try to fetch AgentOrigin from the chain + let messageOrigin = await fetchAgentOrigin(options.rpcUrl); printDivider(); - if (rewardsMessageOrigin) { + if (messageOrigin) { // Use the value from the chain - logger.info("✅ Using RewardsAgentOrigin from chain runtime parameters"); + logger.info("✅ Using AgentOrigin from chain runtime parameters"); } else { // Compute the Agent ID from genesis hash and pallet account - logger.info("🔧 Computing RewardsAgentOrigin from genesis hash and pallet account..."); + logger.info("🔧 Computing AgentOrigin from genesis hash and pallet account..."); // Get genesis hash (from option or fetch from chain) const genesisHash = options.genesisHash @@ -272,22 +272,22 @@ export const updateRewardsOrigin = async (options: UpdateRewardsOriginOptions): // Compute the Agent ID logger.info("🔐 Computing Agent ID..."); logger.warn( - "⚠️ Note: Computed Agent ID may need verification. Prefer setting RewardsAgentOrigin on-chain." + "⚠️ Note: Computed Agent ID may need verification. Prefer setting AgentOrigin on-chain." ); - rewardsMessageOrigin = await computeAgentId(genesisHash, rewardsAccount); - logger.info(` Agent ID: ${rewardsMessageOrigin}`); + messageOrigin = await computeAgentId(genesisHash, rewardsAccount); + logger.info(` Agent ID: ${messageOrigin}`); } printDivider(); // Display the final value logger.info("📝 Rewards Message Origin:"); - logger.info(` ${rewardsMessageOrigin}`); + logger.info(` ${messageOrigin}`); printDivider(); // Update the config file - await updateConfigFile(networkId, rewardsMessageOrigin); + await updateConfigFile(networkId, messageOrigin); printDivider(); logger.success(`Rewards message origin updated successfully for ${networkId}`); diff --git a/test/cli/index.ts b/test/cli/index.ts index 1ed926ef..27763287 100644 --- a/test/cli/index.ts +++ b/test/cli/index.ts @@ -212,7 +212,7 @@ const contractsCommand = program - upgrade: Upgrade contracts by deploying new implementations - verify: Verify deployed contracts on block explorer - update-beefy-checkpoint: Fetch BEEFY authorities from a live chain and update config - - update-rewards-origin: Fetch or compute the RewardsAgentOrigin and update config + - update-rewards-origin: Fetch or compute the AgentOrigin and update config - update-metadata: Update the metadata URI of an existing AVS contract Common options: @@ -385,17 +385,14 @@ contractsCommand contractsCommand .command("update-rewards-origin") .description( - "Fetch or compute the RewardsAgentOrigin and update the config file with the rewards message origin" + "Fetch or compute the AgentOrigin and update the config file with the rewards message origin" ) .option("--chain ", "Target chain (hoodi, ethereum, anvil)") .option( "--environment ", "Deployment environment (stagenet, testnet, mainnet). Config and deployment files will be prefixed with this value." ) - .option( - "--rpc-url ", - "WebSocket RPC URL of the DataHaven chain to fetch RewardsAgentOrigin from" - ) + .option("--rpc-url ", "WebSocket RPC URL of the DataHaven chain to fetch AgentOrigin from") .option( "--genesis-hash ", "Chain genesis hash (32 bytes hex). If not provided, will be fetched from the chain." diff --git a/test/configs/parameters/datahaven-parameters.json b/test/configs/parameters/datahaven-parameters.json index ce875f03..0072a499 100644 --- a/test/configs/parameters/datahaven-parameters.json +++ b/test/configs/parameters/datahaven-parameters.json @@ -8,7 +8,7 @@ "value": null }, { - "name": "RewardsAgentOrigin", + "name": "AgentOrigin", "value": null }, { diff --git a/test/e2e/suites/slash.test.ts b/test/e2e/suites/slash.test.ts index c212cd90..7547a84c 100644 --- a/test/e2e/suites/slash.test.ts +++ b/test/e2e/suites/slash.test.ts @@ -3,8 +3,9 @@ import { $ } from "bun"; import { Binary, FixedSizeBinary } from "polkadot-api"; import { CROSS_CHAIN_TIMEOUTS, getPapiSigner, logger } from "utils"; import type { Address } from "viem"; +import { gatewayAbi } from "../../contract-bindings"; import { getContractInstance, parseDeploymentsFile } from "../../utils/contracts"; -import { waitForDataHavenEvent } from "../../utils/events"; +import { waitForDataHavenEvent, waitForEthereumEvent } from "../../utils/events"; import { waitFor } from "../../utils/waits"; import { BaseTestSuite } from "../framework"; @@ -122,6 +123,8 @@ describe("Should slash an operator", () => { }, 40000); it("use sudo to slash operator", async () => { + const { publicClient } = suite.getTestConnectors(); + // get era number const activeEra = await dhApi.query.ExternalValidators.ActiveEra.getValue(); @@ -168,6 +171,19 @@ describe("Should slash an operator", () => { throw new Error("SlashesMessageSent event not found"); } logger.info("Slashes message sent"); + + const fromBlock = await publicClient.getBlockNumber(); + const deployments = await parseDeploymentsFile(); + const _ethEvent = await waitForEthereumEvent({ + client: publicClient, + address: deployments.Gateway, + abi: gatewayAbi, + eventName: "SlashingComplete", + fromBlock: fromBlock > 0n ? fromBlock - 1n : fromBlock, + timeout: CROSS_CHAIN_TIMEOUTS.DH_TO_ETH_MS + }); + + logger.info("Got Ethereum event!"); }, 560000); it("should detect and slash an unresponsive validator (liveness)", async () => { diff --git a/test/utils/types.ts b/test/utils/types.ts index 695788ff..df4f8143 100644 --- a/test/utils/types.ts +++ b/test/utils/types.ts @@ -184,7 +184,7 @@ export const parseJsonToBeaconCheckpoint = (jsonInput: any): BeaconCheckpoint => const DATAHAVEN_PARAM_NAMES = [ "EthereumGatewayAddress", "RewardsUpdateSelector", - "RewardsAgentOrigin", + "AgentOrigin", "DatahavenServiceManagerAddress" ] as const; From 406a0dc59e2cdc8139cb48579b5dd6d5c6d39f29 Mon Sep 17 00:00:00 2001 From: undercover-cactus Date: Wed, 11 Mar 2026 12:39:47 +0100 Subject: [PATCH 7/8] test: storagehub e2e (#394) ## Summary Add Storage Hub basic end to end test. This PR also include some fixes to allow Storage Hub node and datahaven node to run on the same network (local chain). Before that one was running on dev and the other one on the local chain. ## What changed * Added `storagehub.test.ts` e2e test. In this file we explicitly start the storagehub node using the launch function already used in the CI * Added Storage Hub backend the flow so it can be used in the e2e test * Fix the `--chain local` vs `--chain dev` issue. The storagehub nodes were started on the dev network and therefore they were never syncing with the datahaven node * Fix the folder permission issue in the CI by fixing the folder name * Added StorageHub javascript lib --------- Co-authored-by: Gonza Montiel Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> Co-authored-by: Gonza Montiel --- biome.json | 3 +- operator/.dockerignore | 1 - operator/Dockerfile | 2 +- test/bun.lock | 15 ++ test/e2e/framework/validators.ts | 2 + test/e2e/suites/storagehub.test.ts | 228 +++++++++++++++++++++++++++++ test/launcher/datahaven.ts | 4 +- test/launcher/storagehub-docker.ts | 22 ++- test/package.json | 3 + test/scripts/register-providers.ts | 2 +- test/utils/docker.ts | 11 ++ test/utils/papi.ts | 1 + test/utils/validators.ts | 2 + 13 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 test/e2e/suites/storagehub.test.ts diff --git a/biome.json b/biome.json index aed14936..2b197a48 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,8 @@ "!**/html/**/*", "!**/moonwall/contracts/out/**/*", "!**/contracts/out/**/*", - "!**/contracts/deployments/state-diff.checksum" + "!**/contracts/deployments/state-diff.checksum", + "!**/bun.lock" ], "maxSize": 3000000 }, diff --git a/operator/.dockerignore b/operator/.dockerignore index 8f7393e4..13c345c5 100644 --- a/operator/.dockerignore +++ b/operator/.dockerignore @@ -50,4 +50,3 @@ examples/ Cargo.lock.old *.toml.old *.lock.old -**/target/ \ No newline at end of file diff --git a/operator/Dockerfile b/operator/Dockerfile index 7e52508d..c21849c3 100644 --- a/operator/Dockerfile +++ b/operator/Dockerfile @@ -55,7 +55,7 @@ COPY --from=builder \ RUN useradd -m -u 1001 -U -s /bin/sh -d /datahaven datahaven && \ mkdir -p /datahaven/.local/share /data && \ chown -R datahaven:datahaven /data && \ - ln -s /data /datahaven/.local/share/datahaven + ln -s /data /datahaven/.local/share/datahaven-node USER datahaven diff --git a/test/bun.lock b/test/bun.lock index d95f380f..a80af401 100644 --- a/test/bun.lock +++ b/test/bun.lock @@ -14,6 +14,9 @@ "@noble/curves": "^1.9.2", "@noble/hashes": "^1.8.0", "@polkadot-api/descriptors": "file:.papi/descriptors", + "@storagehub-sdk/core": "^0.4.4", + "@storagehub-sdk/msp-client": "^0.4.4", + "@storagehub/api-augment": "^0.4.0", "@types/dockerode": "^3.3.41", "@types/node": "^22.15.32", "@wagmi/cli": "^2.3.1", @@ -568,6 +571,14 @@ "@sqltools/formatter": ["@sqltools/formatter@1.2.5", "", {}, "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw=="], + "@storagehub-sdk/core": ["@storagehub-sdk/core@0.4.4", "", { "dependencies": { "@polkadot/types": "^16.4.7", "abitype": "^1.0.0", "ethers": "^6.15.0" }, "peerDependencies": { "viem": ">=2.38.3" } }, "sha512-3tvsp5ILx4r1JWzqef02EKKL+u9nZIrl+/PMpj4Ode17v+mDmYI2ME3On9fZ8/+dEIAXWgqGh8/EjkYdP9PAEQ=="], + + "@storagehub-sdk/msp-client": ["@storagehub-sdk/msp-client@0.4.4", "", { "peerDependencies": { "@storagehub-sdk/core": ">=0.0.5", "viem": ">=2.38.3" } }, "sha512-7TLSQAhwJ+RFxU5SbknRw37Qkhts3u2DycdZyA7aUe6e+QyD917QNnlYcM/JJLZFFiqGwy+Nrk07xhKv1zKAZg=="], + + "@storagehub/api-augment": ["@storagehub/api-augment@0.4.2", "", { "dependencies": { "@polkadot/api": "^16.4.7", "@polkadot/api-base": "^16.4.7", "@polkadot/rpc-core": "^16.4.7", "@polkadot/typegen": "^16.4.7", "@polkadot/types": "^16.4.7", "@polkadot/types-codec": "^16.4.7", "@storagehub/types-bundle": "0.4.2", "tsx": "4.20.5", "typescript": "^5.9.2" } }, "sha512-L3q5ZsZD+iLPEdBs2ZTKeH5fDaihiUJQpyxSC3pj0geOdE97m+FqxgOALEvAZT7Eqi0m38B0xneREzwPpIGtnA=="], + + "@storagehub/types-bundle": ["@storagehub/types-bundle@0.4.2", "", { "dependencies": { "@polkadot/api": "^16.4.7", "@polkadot/api-base": "^16.4.7", "@polkadot/rpc-core": "^16.4.6", "@polkadot/typegen": "^16.4.6", "@polkadot/types": "^16.4.7", "@polkadot/types-codec": "^16.4.7", "typescript": "^5.9.2" } }, "sha512-kkWYP1WwiVP0NGQqIWLfcOsIkb1BJXk7Qw+pkNIzf7QW6HpJaPySJybRksK6ClwKdqzNXXyZ4Sw0vBO1//8h0w=="], + "@substrate/connect": ["@substrate/connect@0.8.11", "", { "dependencies": { "@substrate/connect-extension-protocol": "^2.0.0", "@substrate/connect-known-chains": "^1.1.5", "@substrate/light-client-extension-helpers": "^1.0.0", "smoldot": "2.0.26" } }, "sha512-ofLs1PAO9AtDdPbdyTYj217Pe+lBfTLltdHDs3ds8no0BseoLeAGxpz1mHfi7zB4IxI3YyAiLjH6U8cw4pj4Nw=="], "@substrate/connect-extension-protocol": ["@substrate/connect-extension-protocol@2.2.2", "", {}, "sha512-t66jwrXA0s5Goq82ZtjagLNd7DPGCNjHeehRlE/gcJmJ+G56C0W+2plqOMRicJ8XGR1/YFnUSEqUFiSNbjGrAA=="], @@ -2180,6 +2191,10 @@ "@safe-global/safe-apps-sdk/viem": ["viem@2.29.2", "", { "dependencies": { "@noble/curves": "1.8.2", "@noble/hashes": "1.7.2", "@scure/bip32": "1.6.2", "@scure/bip39": "1.5.4", "abitype": "1.0.8", "isows": "1.0.6", "ox": "0.6.9", "ws": "8.18.1" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-cukRxab90jvQ+TDD84sU3qB3UmejYqgCw4cX8SfWzvh7JPfZXI3kAMUaT5OSR2As1Mgvx1EJawccwPjGqkSSwA=="], + "@storagehub/api-augment/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@storagehub/types-bundle/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@substrate/connect/smoldot": ["smoldot@2.0.26", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-F+qYmH4z2s2FK+CxGj8moYcd1ekSIKH8ywkdqlOz88Dat35iB1DIYL11aILN46YSGMzQW/lbJNS307zBSDN5Ig=="], "@substrate/light-client-extension-helpers/@polkadot-api/json-rpc-provider": ["@polkadot-api/json-rpc-provider@0.0.1", "", {}, "sha512-/SMC/l7foRjpykLTUTacIH05H3mr9ip8b5xxfwXlVezXrNVLp3Cv0GX6uItkKd+ZjzVPf3PFrDF2B2/HLSNESA=="], diff --git a/test/e2e/framework/validators.ts b/test/e2e/framework/validators.ts index 2ecee8a9..a6c0ff38 100644 --- a/test/e2e/framework/validators.ts +++ b/test/e2e/framework/validators.ts @@ -54,6 +54,8 @@ export const launchDatahavenValidator = async ( const COMMON_LAUNCH_ARGS = [ "--unsafe-force-node-key-generation", "--tmp", + "--chain", + "local", "--validator", "--discover-local", "--no-prometheus", diff --git a/test/e2e/suites/storagehub.test.ts b/test/e2e/suites/storagehub.test.ts new file mode 100644 index 00000000..229b47ca --- /dev/null +++ b/test/e2e/suites/storagehub.test.ts @@ -0,0 +1,228 @@ +/** + * StorageHub E2E Tests + * + * Tests the uploading a file to storage through Datahaven + * + * Prerequisites: + * - DataHaven network with StorageHub service running + * - Storage hub MSP and BSP + */ +import "@storagehub/api-augment"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { TypeRegistry } from "@polkadot/types"; +import { + FileManager, + initWasm, + ReplicationLevel, + SH_FILE_SYSTEM_PRECOMPILE_ADDRESS, + StorageHubClient +} from "@storagehub-sdk/core"; +import { MspClient } from "@storagehub-sdk/msp-client"; +import { $ } from "bun"; +import { Binary } from "polkadot-api"; +import { createPapiConnectors, logger } from "utils"; +import { CHAIN_ID, SUBSTRATE_FUNDED_ACCOUNTS } from "utils/constants"; +import { getEvmEcdsaSigner } from "utils/papi"; +import { createPublicClient, createWalletClient, defineChain, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { launchLocalDataHavenSolochain } from "../../launcher/datahaven"; +import { + launchBackend, + launchBspNode, + launchIndexerNode, + launchMspNode, + launchStorageHubPostgres +} from "../../launcher/storagehub-docker"; +import { LaunchedNetwork } from "../../launcher/types/launchedNetwork"; +import { registerProviders } from "../../scripts/register-providers"; + +const TEST_AUTHORITY_IDS = ["alice", "bob"] as const; +const networkId = `storagehub-${Date.now()}`.toLowerCase().replace(/[^a-z0-9-]/g, "-"); + +describe("test uploading file to storage hub", () => { + let aliceUrl: string; + let _mspUrl: string; + let backendUrl: string; + + beforeAll(async () => { + await initWasm(); + + const datahavenImageTag = "datahavenxyz/datahaven:local"; + const relayerImageTag = "datahavenxyz/snowbridge-relay:latest"; + const authorityIds = TEST_AUTHORITY_IDS; + const buildDatahaven = false; + const datahavenBuildExtraArgs = ""; + + const options = { + networkId, + datahavenImageTag, + relayerImageTag, + authorityIds, + buildDatahaven, + datahavenBuildExtraArgs + }; + + const run = new LaunchedNetwork(); + + // 1. Launch DataHaven validator nodes + logger.info("📦 Launching DataHaven validator nodes..."); + aliceUrl = await launchLocalDataHavenSolochain(options, run); + + // 2. Launch PostgreSQL database + logger.info("🗄️ Launching StorageHub PostgreSQL..."); + await launchStorageHubPostgres(options, run); + + // 3. Launch MSP node + logger.info("📦 Launching MSP node..."); + _mspUrl = await launchMspNode(options, run); + + // 4. Launch BSP node + logger.info("📦 Launching BSP node..."); + await launchBspNode(options, run); + + // 6. Launch Indexer node + logger.info("📦 Launching Indexer node..."); + await launchIndexerNode(options, run); + + // // 7. Launch Fisherman node + // logger.info("📦 Launching Fisherman node..."); + // await launchFishermanNode(options, run); + + // Register providers + logger.info("📝 Registering providers..."); + await registerProviders({ launchedNetwork: run }); + + // Launch Storage Hub Backend + logger.info("📦 Launching Storage hub backend..."); + backendUrl = await launchBackend(options, run); + }); + + it("Create a bucket", async () => { + const { typedApi: dhApi } = createPapiConnectors(aliceUrl); + + const mspCount = await dhApi.query.Providers.MspCount.getValue(); + const bspCount = await dhApi.query.Providers.BspCount.getValue(); + + expect(mspCount).toBe(1); + expect(bspCount).toBe(1); + + const msp_id = await dhApi.query.Providers.AccountIdToMainStorageProviderId.getValue( + SUBSTRATE_FUNDED_ACCOUNTS.CHARLETH.publicKey + ); + expect(msp_id).toBeDefined(); + if (!msp_id) { + throw new Error("mspId for Charleth not found"); + } + + const value_prop_id = + await dhApi.apis.StorageProvidersApi.query_value_propositions_for_msp(msp_id); + + const call = await dhApi.tx.FileSystem.create_bucket({ + msp_id, + name: Binary.fromText("bucket"), + private: false, + value_prop_id: value_prop_id[0].id + }); + const aliceSigner = getEvmEcdsaSigner(SUBSTRATE_FUNDED_ACCOUNTS.ALITH.privateKey); + const mspResult = await call.signAndSubmit(aliceSigner); + expect(mspResult.ok).toBeTrue(); + }, 30000); + + it("Send a request", async () => { + const { typedApi: dhApi } = createPapiConnectors(aliceUrl); + + const msp_id = await dhApi.query.Providers.AccountIdToMainStorageProviderId.getValue( + SUBSTRATE_FUNDED_ACCOUNTS.CHARLETH.publicKey + ); + expect(msp_id).toBeDefined(); + if (!msp_id) { + throw new Error("mspId for Charleth not found"); + } + + const buckets = await dhApi.apis.StorageProvidersApi.query_buckets_for_msp(msp_id); + if (!buckets.success) { + throw new Error("Bucket not found for the registered msp"); + } + expect(buckets.value.length).toBe(1); + + const bucketId = buckets.value[0].asHex(); + const fileContent = "foo bar"; + const location = "foo/bar.txt"; + + // Build FileManager from in-memory file content + const fileBytes = new TextEncoder().encode(fileContent); + const fileManager = new FileManager({ + size: fileBytes.length, + stream: () => + new ReadableStream({ + start(controller) { + controller.enqueue(fileBytes); + controller.close(); + } + }) as ReadableStream + }); + + // Compute fingerprint and file key from the file metadata + const registry = new TypeRegistry(); + const account = privateKeyToAccount(SUBSTRATE_FUNDED_ACCOUNTS.ALITH.privateKey); + const owner = registry.createType("AccountId20", account.address); + const bucketIdH256 = registry.createType("H256", bucketId); + const fingerprint = await fileManager.getFingerprint(); + const _fileKey = await fileManager.computeFileKey(owner, bucketIdH256, location); + + // Set up EVM clients + const httpUrl = aliceUrl.replace("ws://", "http://"); + const chain = defineChain({ + id: CHAIN_ID, + name: "DataHaven", + nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" }, + rpcUrls: { default: { http: [httpUrl] } } + }); + const walletClient = createWalletClient({ account, chain, transport: http(httpUrl) }); + const publicClient = createPublicClient({ chain, transport: http(httpUrl) }); + const storageHubClient = new StorageHubClient({ + rpcUrl: httpUrl, + chain, + walletClient, + filesystemContractAddress: SH_FILE_SYSTEM_PRECOMPILE_ADDRESS + }); + + // Issue storage request + const txHash = await storageHubClient.issueStorageRequest( + bucketId as `0x${string}`, + location, + fingerprint.toHex() as `0x${string}`, + BigInt(fileBytes.length), + msp_id.asHex() as `0x${string}`, + [], + ReplicationLevel.Basic, + 1 + ); + + // Wait for storage request transaction + // Don't proceed until receipt is confirmed on chain + if (!txHash) { + throw new Error("Storage request transaction was not submitted"); + } + const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + if (receipt.status !== "success") { + throw new Error(`Storage request failed: ${txHash}`); + } + console.log("issueStorageRequest() txReceipt:", receipt); + + // Authenticate with the backend via SIWE and upload the file + let sessionRef: { token: string; user: { address: string } } | undefined; + const sessionProvider = async () => sessionRef; + const mspClient = await MspClient.connect({ baseUrl: backendUrl }, sessionProvider); + + const domain = new URL(backendUrl).host; + const siweSession = await mspClient.auth.SIWE(walletClient, domain, backendUrl); + const sessionToken = (siweSession as { token: string }).token; + expect(sessionToken).toBeDefined(); + }, 60000); + + afterAll(async () => { + // Delete all the containers started by this test suite + await $`docker container rm -f $(docker container ls -q --filter name=${networkId})`; + }); +}); diff --git a/test/launcher/datahaven.ts b/test/launcher/datahaven.ts index fcc004cc..429a326a 100644 --- a/test/launcher/datahaven.ts +++ b/test/launcher/datahaven.ts @@ -84,7 +84,7 @@ export const getPortMappingForNode = (nodeId: string, networkId: string): string export const launchLocalDataHavenSolochain = async ( options: DataHavenOptions, launchedNetwork: LaunchedNetwork -): Promise => { +): Promise => { logger.info("🚀 Launching DataHaven network..."); invariant(options.datahavenImageTag, "❌ DataHaven image tag not defined"); @@ -165,6 +165,8 @@ export const launchLocalDataHavenSolochain = async ( await setupDataHavenValidatorConfig(launchedNetwork, "datahaven-"); logger.success(`DataHaven network started, primary node accessible on port ${alicePort}`); + + return `ws://127.0.0.1:${alicePort}`; }; /** diff --git a/test/launcher/storagehub-docker.ts b/test/launcher/storagehub-docker.ts index a9f64c0e..9cb9c7df 100644 --- a/test/launcher/storagehub-docker.ts +++ b/test/launcher/storagehub-docker.ts @@ -124,7 +124,7 @@ export const injectStorageHubKey = async ( // Use Bun's $ directly with docker exec (no sh -c wrapper needed) // This properly handles the spaces in the seed phrase try { - await $`docker exec ${containerName} datahaven-node key insert --base-path /data --key-type bcsv --scheme ecdsa --suri ${secretKey}`; + await $`docker exec ${containerName} datahaven-node key insert --chain local --key-type bcsv --scheme ecdsa --suri ${secretKey}`; logger.success("Key injected successfully"); } catch (error) { logger.error(`Failed to inject key : ${error}`); @@ -141,7 +141,7 @@ export const injectStorageHubKey = async ( export const launchMspNode = async ( options: DataHavenOptions, launchedNetwork: LaunchedNetwork -): Promise => { +): Promise => { logger.info("🚀 Launching StorageHub MSP node..."); const containerName = `storagehub-msp-${options.networkId}`; @@ -182,7 +182,10 @@ export const launchMspNode = async ( "--max-storage-capacity", "10737418240", // 10 GiB "--jump-capacity", - "1073741824" // 1 GiB + "1073741824", // 1 GiB + "--trusted-file-transfer-server", + "--trusted-file-transfer-server-host", + "0.0.0.0" // Listen on all interfaces so the backend container can reach it ]; logger.debug(`Executing: ${command.join(" ")}`); @@ -217,6 +220,8 @@ export const launchMspNode = async ( launchedNetwork.addContainer(containerName, { ws: wsPort }, { ws: DEFAULT_SUBSTRATE_WS_PORT }); logger.success(`MSP node started on port ${wsPort}`); + + return `ws://127.0.0.1:${wsPort}`; }; /** @@ -457,11 +462,12 @@ export const launchFishermanNode = async ( * * @param options - Configuration options for launching the network * @param launchedNetwork - The launched network instance to track the node + * @returns The HTTP URL of the backend API (e.g. "http://127.0.0.1:8080") */ export const launchBackend = async ( options: DataHavenOptions, launchedNetwork: LaunchedNetwork -): Promise => { +): Promise => { logger.info("🚀 Launching StorageHub Backend..."); const backendImage = "moonsonglabs/storage-hub-msp-backend:latest"; @@ -484,8 +490,10 @@ export const launchBackend = async ( "-e", "RUST_LOG=info", backendImage, - "--chain", - "local", + "--host", + "0.0.0.0", + "--port", + "8080", "--log-format", "text", "--database-url", @@ -507,6 +515,8 @@ export const launchBackend = async ( launchedNetwork.addContainer(containerName, { http: apiPort }, { http: apiPort }); logger.success(`StorageHub Backend container started on port ${apiPort}`); + + return `http://127.0.0.1:${apiPort}`; }; /** diff --git a/test/package.json b/test/package.json index ad1f1156..4d521359 100644 --- a/test/package.json +++ b/test/package.json @@ -54,6 +54,9 @@ "@noble/curves": "^1.9.2", "@noble/hashes": "^1.8.0", "@polkadot-api/descriptors": "file:.papi/descriptors", + "@storagehub-sdk/core": "^0.4.4", + "@storagehub-sdk/msp-client": "^0.4.4", + "@storagehub/api-augment": "^0.4.0", "@types/dockerode": "^3.3.41", "@types/node": "^22.15.32", "@wagmi/cli": "^2.3.1", diff --git a/test/scripts/register-providers.ts b/test/scripts/register-providers.ts index 216d509b..5bc850cc 100644 --- a/test/scripts/register-providers.ts +++ b/test/scripts/register-providers.ts @@ -212,7 +212,7 @@ export async function verifyProvidersRegistered( ): Promise { logger.info("🔍 Verifying provider registration..."); - const aliceContainerName = `datahaven - alice - ${options.launchedNetwork.networkId} `; + const aliceContainerName = `datahaven-alice-${options.launchedNetwork.networkId} `; const alicePort = options.launchedNetwork.getContainerPort(aliceContainerName); const { client, typedApi } = createPapiConnectors(`ws://127.0.0.1:${alicePort}`); diff --git a/test/utils/docker.ts b/test/utils/docker.ts index b1c00b62..9d5b438e 100644 --- a/test/utils/docker.ts +++ b/test/utils/docker.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { type Duplex, PassThrough, Transform } from "node:stream"; +import { $ } from "bun"; import Docker from "dockerode"; import invariant from "tiny-invariant"; import { logger } from "./logger"; @@ -238,6 +239,9 @@ export const waitForContainerToStart = async ( logger.debug(`Waiting for container ${containerName} to start...`); const seconds = options?.timeoutSeconds ?? 30; + // sleep 2 seconds to see if the started container didn't exit right away + await Bun.sleep(2000); + for (let i = 0; i < seconds; i++) { const containers = await docker.listContainers(); const container = containers.find((container) => @@ -245,10 +249,17 @@ export const waitForContainerToStart = async ( ); if (container) { logger.debug(`Container ${containerName} started after ${i} seconds`); + const result = await $`docker logs ${containerName}`.nothrow().quiet().text(); + console.log(result); + return; } await Bun.sleep(1000); } + + const result = await $`docker logs ${containerName}`; + console.log(result); + invariant( false, `❌ container ${containerName} cannot be found in running container list after ${seconds} seconds` diff --git a/test/utils/papi.ts b/test/utils/papi.ts index 42a4dc63..1ec97545 100644 --- a/test/utils/papi.ts +++ b/test/utils/papi.ts @@ -43,6 +43,7 @@ export const createPapiConnectors = ( ): { client: PolkadotClient; typedApi: DataHavenApi } => { const url = wsUrl ?? "ws://127.0.0.1:9944"; const client = createClient(withPolkadotSdkCompat(getWsProvider(url))); + return { client, typedApi: client.getTypedApi(datahaven) }; }; diff --git a/test/utils/validators.ts b/test/utils/validators.ts index 637c9ae6..5e635200 100644 --- a/test/utils/validators.ts +++ b/test/utils/validators.ts @@ -8,6 +8,8 @@ export const COMMON_LAUNCH_ARGS = [ "--unsafe-force-node-key-generation", "--tmp", + "--chain", + "local", "--validator", "--discover-local", "--no-prometheus", From 8d82f63efa048ae965572f38819dee847c2de273 Mon Sep 17 00:00:00 2001 From: Steve Degosserie <723552+stiiifff@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:09:02 +0100 Subject: [PATCH 8/8] feat: add retry mechanism for failed Snowbridge rewards messages (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When `send_rewards_message` fails at era end (bridge paused, queue full, etc.), tokens have already been minted to the Ethereum sovereign account but the `submitRewards` message to EigenLayer is silently lost. This creates an inconsistency: wHAVE tokens exist on DataHaven but validators never receive their rewards for that era. This PR adds: - **Automatic retry via `on_initialize`** — processes one failed era per block - **Head-of-line blocking avoidance** — failed entries are moved to the back of the queue so a single stuck era doesn't block retries for subsequent ones - **Governance escape hatch** — `retry_unsent_reward_era` extrinsic gated by configurable `GovernanceOrigin` - **Automatic cleanup** — expired entries (reward points pruned past `HistoryDepth`) are discarded ### Context: HistoryDepth window Reward points are kept in storage for `HistoryDepth` eras (64 on mainnet, ~16 days with 6-hour eras). Retries are only possible while the data exists. After that window, the entry is automatically expired and dropped from the queue. This means governance has up to ~16 days to intervene if automatic retries keep failing. ### Storage design A ring buffer (`StorageMap` + head/tail pointers) with capacity 64, matching `HistoryDepth`. Each entry stores: - `era_index` — which era's rewards message failed - `era_start_timestamp` — preserved from the original era (seconds since epoch) - `scaled_inflation` — the exact minted amount (stored because recomputing later could yield a different value if `EraInflationProvider` has changed) ### Retry behavior | Scenario | Action | |----------|--------| | Queue empty | Return minimal weight (2 reads) | | Entry reward points pruned | Remove entry, emit `UnsentEraExpired` | | Retry succeeds | Remove entry, emit `RewardsMessageRetried` | | Retry fails | Move entry to back of queue, try next entry next block | ### Changes - **`lib.rs`** — Ring buffer storage, events (`RewardsMessageSendFailed`, `RewardsMessageRetried`, `UnsentEraExpired`, `UnsentQueueFull`), errors, `on_initialize` hook, `retry_unsent_reward_era` extrinsic with configurable `GovernanceOrigin`, modified `on_era_end` (queue on failure) and `on_era_start` (prune expired entries where `idx <= era_index_to_delete`) - **`mock.rs`** — Configurable `send_message_fails` flag on `MockOkOutboundQueue` - **`tests.rs`** — 14 new test cases covering all retry/expiry/governance paths including head-of-line blocking avoidance - **`benchmarking.rs`** — 5 new benchmarks (empty queue, expired entry, success, failure, governance extrinsic) - **`weights.rs`** — New weight functions in trait and both impls - **Runtime configs** — `GovernanceOrigin = EnsureRoot` + placeholder weight functions for mainnet/stagenet/testnet ## Test plan - [x] `cargo test -p pallet-external-validators-rewards` — 90 tests pass - [x] `cargo clippy -p pallet-external-validators-rewards` — no new warnings - [x] `cargo check -p datahaven-mainnet-runtime` — compiles - [ ] Run benchmarks to generate production weight values --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Gonza Montiel Co-authored-by: undercover-cactus Co-authored-by: Tobi Demeco <50408393+TDemeco@users.noreply.github.com> Co-authored-by: Ahmad Kaouk <56095276+ahmadkaouk@users.noreply.github.com> --- .../src/benchmarking.rs | 109 +++- .../external-validators-rewards/src/lib.rs | 316 +++++++++++- .../external-validators-rewards/src/mock.rs | 6 + .../external-validators-rewards/src/tests.rs | 471 +++++++++++++++++- .../src/weights.rs | 60 +++ operator/runtime/mainnet/src/configs/mod.rs | 2 + .../pallet_external_validators_rewards.rs | 25 + operator/runtime/stagenet/src/configs/mod.rs | 2 + .../pallet_external_validators_rewards.rs | 25 + operator/runtime/testnet/src/configs/mod.rs | 2 + .../pallet_external_validators_rewards.rs | 25 + test/.papi/descriptors/package.json | 2 +- test/.papi/metadata/datahaven.scale | Bin 632664 -> 634460 bytes 13 files changed, 1018 insertions(+), 27 deletions(-) diff --git a/operator/pallets/external-validators-rewards/src/benchmarking.rs b/operator/pallets/external-validators-rewards/src/benchmarking.rs index 335557f4..4b84bc3f 100644 --- a/operator/pallets/external-validators-rewards/src/benchmarking.rs +++ b/operator/pallets/external-validators-rewards/src/benchmarking.rs @@ -21,9 +21,9 @@ use super::*; #[allow(unused)] use crate::Pallet as ExternalValidatorsRewards; use { - crate::{types::BenchmarkHelper, OnEraEnd}, + crate::types::BenchmarkHelper, frame_benchmarking::{account, v2::*, BenchmarkError}, - frame_support::traits::Currency, + frame_support::traits::{Currency, EnsureOrigin}, sp_std::prelude::*, }; @@ -43,6 +43,11 @@ fn create_funded_user( user } +/// Helper: insert a single entry into the ring buffer at slot 0. +fn push_unsent_entry(era_index: u32, timestamp: u32, inflation: u128) { + ExternalValidatorsRewards::::unsent_queue_push((era_index, timestamp, inflation)); +} + #[allow(clippy::multiple_bound_locations)] #[benchmarks(where T: pallet_balances::Config)] mod benchmarks { @@ -72,6 +77,106 @@ mod benchmarks { Ok(()) } + /// Helper to populate reward points for an era with 1000 validators. + fn setup_era_reward_points(era_index: u32) { + let mut era_reward_points = EraRewardPoints::default(); + era_reward_points.total = 20 * 1000; + + for i in 0..1000 { + let account_id = create_funded_user::("candidate", i, 100); + era_reward_points.individual.insert(account_id, 20); + } + + >::insert(era_index, era_reward_points); + } + + // on_initialize: unsent queue is empty (2 reads for head+tail) + #[benchmark] + fn process_unsent_reward_eras_empty() -> Result<(), BenchmarkError> { + // Ensure queue is empty (default state: head == tail == 0) + assert!(ExternalValidatorsRewards::::unsent_queue_is_empty()); + + #[block] + { + ExternalValidatorsRewards::::process_unsent_reward_eras(); + } + + Ok(()) + } + + // on_initialize: oldest entry has pruned reward points + #[benchmark] + fn process_unsent_reward_eras_expired() -> Result<(), BenchmarkError> { + // Push an entry whose reward points do NOT exist in storage + push_unsent_entry::(999, 0, 42); + + #[block] + { + ExternalValidatorsRewards::::process_unsent_reward_eras(); + } + + // Entry should have been removed + assert!(ExternalValidatorsRewards::::unsent_queue_is_empty()); + + Ok(()) + } + + // on_initialize: oldest entry retried successfully + #[benchmark] + fn process_unsent_reward_eras_success() -> Result<(), BenchmarkError> { + frame_system::Pallet::::set_block_number(0u32.into()); + T::BenchmarkHelper::setup(); + setup_era_reward_points::(1); + + push_unsent_entry::(1, 0, 42); + + #[block] + { + ExternalValidatorsRewards::::process_unsent_reward_eras(); + } + + assert!(ExternalValidatorsRewards::::unsent_queue_is_empty()); + + Ok(()) + } + + // Use success weight as upper bound for the failed path + #[benchmark] + fn process_unsent_reward_eras_failed() -> Result<(), BenchmarkError> { + frame_system::Pallet::::set_block_number(0u32.into()); + T::BenchmarkHelper::setup(); + setup_era_reward_points::(1); + + push_unsent_entry::(1, 0, 42); + + #[block] + { + ExternalValidatorsRewards::::process_unsent_reward_eras(); + } + + Ok(()) + } + + // Governance extrinsic: retry a specific unsent era + #[benchmark] + fn retry_unsent_reward_era() -> Result<(), BenchmarkError> { + frame_system::Pallet::::set_block_number(0u32.into()); + T::BenchmarkHelper::setup(); + setup_era_reward_points::(1); + + push_unsent_entry::(1, 0, 42); + + let origin = + T::GovernanceOrigin::try_successful_origin().map_err(|_| BenchmarkError::Weightless)?; + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, 1u32); + + assert!(ExternalValidatorsRewards::::unsent_queue_is_empty()); + + Ok(()) + } + impl_benchmark_test_suite!( ExternalValidatorsRewards, crate::mock::new_test_ext(), diff --git a/operator/pallets/external-validators-rewards/src/lib.rs b/operator/pallets/external-validators-rewards/src/lib.rs index 376d3a55..8aeaa123 100644 --- a/operator/pallets/external-validators-rewards/src/lib.rs +++ b/operator/pallets/external-validators-rewards/src/lib.rs @@ -66,13 +66,13 @@ pub mod pallet { pub use crate::weights::WeightInfo; use { - super::*, frame_support::pallet_prelude::*, + super::*, frame_support::pallet_prelude::*, frame_system::pallet_prelude::OriginFor, pallet_external_validators::traits::EraIndexProvider, sp_runtime::Saturating, sp_std::collections::btree_map::BTreeMap, }; /// The current storage version. - const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); pub type RewardPoints = u32; pub type EraIndex = u32; @@ -168,6 +168,9 @@ pub mod pallet { /// Hook for minting inflation tokens. type HandleInflation: HandleInflation; + /// Origin for governance calls (e.g., retrying unsent reward messages). + type GovernanceOrigin: EnsureOrigin; + #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper: types::BenchmarkHelper; } @@ -175,6 +178,62 @@ pub mod pallet { #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_initialize(_n: frame_system::pallet_prelude::BlockNumberFor) -> Weight { + Self::process_unsent_reward_eras() + } + } + + #[pallet::call] + impl Pallet { + /// Governance escape hatch: manually retry sending a rewards message for + /// an era that is stuck in the unsent queue. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::retry_unsent_reward_era())] + pub fn retry_unsent_reward_era( + origin: OriginFor, + era_index: EraIndex, + ) -> DispatchResult { + T::GovernanceOrigin::ensure_origin(origin)?; + + // Scan the ring buffer for the requested era + let head = UnsentRewardHead::::get(); + let tail = UnsentRewardTail::::get(); + let mut found = None; + let mut slot = head; + while slot != tail { + if let Some(entry @ (idx, _, _)) = UnsentRewardEra::::get(slot) { + if idx == era_index { + found = Some((slot, entry)); + break; + } + } + slot = (slot + 1) % UNSENT_QUEUE_CAPACITY; + } + let (slot, (_, timestamp, inflation)) = found.ok_or(Error::::EraNotInUnsentQueue)?; + + let reward_points = RewardPointsForEra::::get(era_index); + let info = reward_points + .generate_era_rewards_info(era_index, inflation, timestamp) + .ok_or(Error::::RewardPointsPruned)?; + + let message_id = + Self::send_rewards_message(&info).ok_or(Error::::MessageSendFailed)?; + + Self::unsent_queue_remove_slot(slot); + + Self::deposit_event(Event::RewardsMessageRetried { + message_id, + era_index, + total_points: info.total_points, + inflation_amount: inflation, + }); + + Ok(()) + } + } + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -185,6 +244,29 @@ pub mod pallet { total_points: u128, inflation_amount: u128, }, + /// The rewards message failed to send; era queued for retry. + RewardsMessageSendFailed { era_index: EraIndex }, + /// A previously failed rewards message was retried and sent successfully. + RewardsMessageRetried { + message_id: H256, + era_index: EraIndex, + total_points: u128, + inflation_amount: u128, + }, + /// An unsent era was dropped because its reward points have been pruned. + UnsentEraExpired { era_index: EraIndex }, + /// The unsent queue is full; this era could not be enqueued for retry. + UnsentQueueFull { era_index: EraIndex }, + } + + #[pallet::error] + pub enum Error { + /// The specified era is not in the unsent queue. + EraNotInUnsentQueue, + /// Reward points for the era have been pruned from storage. + RewardPointsPruned, + /// The message delivery still failed on retry. + MessageSendFailed, } /// Keep tracks of distributed points per validator and total. @@ -200,7 +282,7 @@ pub mod pallet { /// - individual_points: (address, points) tuples for each validator. /// - inflation_amount: total inflation tokens to distribute. /// - era_start_timestamp: timestamp when the era started (seconds since Unix epoch). - pub fn generate_era_rewards_utils( + pub fn generate_era_rewards_info( &self, era_index: EraIndex, inflation_amount: u128, @@ -260,6 +342,33 @@ pub mod pallet { pub type BlocksProducedInEra = StorageMap<_, Twox64Concat, EraIndex, u32, ValueQuery>; + /// Maximum number of unsent reward entries in the ring buffer. + pub const UNSENT_QUEUE_CAPACITY: u32 = 64; + + /// Ring buffer of eras whose rewards messages failed to send. + /// Each slot stores (era_index, era_start_timestamp, scaled_inflation). + /// Keyed by slot index [0, UNSENT_QUEUE_CAPACITY). + #[pallet::storage] + pub type UnsentRewardEra = StorageMap< + _, + Twox64Concat, + u32, + ( + EraIndex, + /* era_start_timestamp */ u32, + /* scaled_inflation */ u128, + ), + >; + + /// Ring buffer head: next slot to be processed by `on_initialize`. + #[pallet::storage] + pub type UnsentRewardHead = StorageValue<_, u32, ValueQuery>; + + /// Ring buffer tail: next slot to write a new entry into. + /// When head == tail the buffer is empty. + #[pallet::storage] + pub type UnsentRewardTail = StorageValue<_, u32, ValueQuery>; + impl Pallet { /// Reward validators. Does not check if the validators are valid, caller needs to make sure of that. pub fn reward_by_ids(points: impl IntoIterator) { @@ -276,8 +385,8 @@ pub mod pallet { /// Helper to build, validate and deliver an outbound message. /// Logs any error and returns None on failure. - fn send_rewards_message(utils: &EraRewardsUtils) -> Option { - let outbound = T::SendMessage::build(utils).or_else(|| { + fn send_rewards_message(info: &EraRewardsUtils) -> Option { + let outbound = T::SendMessage::build(info).or_else(|| { log::error!(target: "ext_validators_rewards", "Failed to build outbound message"); None })?; @@ -303,6 +412,147 @@ pub mod pallet { .ok() } + // ── Ring-buffer helpers ────────────────────────────────────────── + + /// Returns true when the ring buffer is empty (head == tail). + #[allow(dead_code)] + pub(crate) fn unsent_queue_is_empty() -> bool { + UnsentRewardHead::::get() == UnsentRewardTail::::get() + } + + /// Number of entries currently in the ring buffer. + #[allow(dead_code)] + pub(crate) fn unsent_queue_len() -> u32 { + let head = UnsentRewardHead::::get(); + let tail = UnsentRewardTail::::get(); + tail.wrapping_sub(head) % UNSENT_QUEUE_CAPACITY + } + + /// Push a new entry into the ring buffer. + /// Returns `true` on success, `false` if the buffer is full. + pub(crate) fn unsent_queue_push(entry: (EraIndex, u32, u128)) -> bool { + let head = UnsentRewardHead::::get(); + let tail = UnsentRewardTail::::get(); + let next_tail = (tail + 1) % UNSENT_QUEUE_CAPACITY; + if next_tail == head { + // Buffer full + return false; + } + UnsentRewardEra::::insert(tail, entry); + UnsentRewardTail::::put(next_tail); + true + } + + /// Remove the entry at a given slot and compact the buffer by shifting + /// subsequent entries back. Used by the extrinsic and `on_era_start`. + fn unsent_queue_remove_slot(slot: u32) { + let tail = UnsentRewardTail::::get(); + // Shift entries after `slot` backward to fill the gap + let mut cur = slot; + loop { + let next = (cur + 1) % UNSENT_QUEUE_CAPACITY; + if next == tail { + break; + } + // Move next → cur + if let Some(entry) = UnsentRewardEra::::get(next) { + UnsentRewardEra::::insert(cur, entry); + } + cur = next; + } + // Remove the now-duplicate last entry and shrink tail + UnsentRewardEra::::remove(cur); + let new_tail = if tail == 0 { + UNSENT_QUEUE_CAPACITY - 1 + } else { + tail - 1 + }; + UnsentRewardTail::::put(new_tail); + + // If head was after the removed slot, adjust it too + let head = UnsentRewardHead::::get(); + // We also need to handle head potentially pointing past the buffer + // after a removal. Since we shifted everything between slot..tail back, + // the head only needs adjustment if it was == tail (now new_tail) — but + // that means the buffer just became empty, which is fine (head == new_tail). + // However, if head was pointing *at* a slot beyond the removed one, the + // entry it pointed to slid back by one, so head should also slide back. + // In practice, removal only happens when we know the slot, so we can + // simply recalculate emptiness. + if head == tail { + // Was already at tail, buffer must be empty now + UnsentRewardHead::::put(new_tail); + } + } + + // ── Core retry logic ────────────────────────────────────────────── + + /// Process at most one unsent reward era per block. + /// On failure the head pointer advances to the next entry so a single + /// stuck era does not block retries for subsequent eras. + pub(crate) fn process_unsent_reward_eras() -> Weight { + let head = UnsentRewardHead::::get(); + let tail = UnsentRewardTail::::get(); + + if head == tail { + return T::WeightInfo::process_unsent_reward_eras_empty(); + } + + let Some((era_index, timestamp, inflation)) = UnsentRewardEra::::get(head) else { + // Slot unexpectedly empty — advance head past it + UnsentRewardHead::::put((head + 1) % UNSENT_QUEUE_CAPACITY); + return T::WeightInfo::process_unsent_reward_eras_empty(); + }; + + // Check if reward points are still available + let reward_points = RewardPointsForEra::::get(era_index); + let info = + match reward_points.generate_era_rewards_info(era_index, inflation, timestamp) { + Some(info) => info, + None => { + // Reward points have been pruned — discard this entry + log::warn!( + target: "ext_validators_rewards", + "Unsent era {era_index} expired: reward points pruned", + ); + UnsentRewardEra::::remove(head); + UnsentRewardHead::::put((head + 1) % UNSENT_QUEUE_CAPACITY); + Self::deposit_event(Event::UnsentEraExpired { era_index }); + return T::WeightInfo::process_unsent_reward_eras_expired(); + } + }; + + // Attempt to resend + match Self::send_rewards_message(&info) { + Some(message_id) => { + UnsentRewardEra::::remove(head); + UnsentRewardHead::::put((head + 1) % UNSENT_QUEUE_CAPACITY); + Self::deposit_event(Event::RewardsMessageRetried { + message_id, + era_index, + total_points: info.total_points, + inflation_amount: inflation, + }); + T::WeightInfo::process_unsent_reward_eras_success() + } + None => { + // Move the failed entry to the back of the queue so the + // next block tries a different era (avoids head-of-line + // blocking). The entry is not lost — it will be retried + // after all other pending entries. + UnsentRewardEra::::remove(head); + UnsentRewardHead::::put((head + 1) % UNSENT_QUEUE_CAPACITY); + UnsentRewardEra::::insert(tail, (era_index, timestamp, inflation)); + UnsentRewardTail::::put((tail + 1) % UNSENT_QUEUE_CAPACITY); + log::warn!( + target: "ext_validators_rewards", + "Retry for unsent era {era_index} still failing, moved to back of queue", + ); + T::WeightInfo::process_unsent_reward_eras_failed() + } + } + } + /// Track a block authored by a validator pub fn note_block_author(author: T::AccountId) { // Track per-session authorship for performance points @@ -619,6 +869,24 @@ pub mod pallet { RewardPointsForEra::::remove(era_index_to_delete); BlocksProducedInEra::::remove(era_index_to_delete); + + // Proactively clean up any unsent entries whose reward points + // have been pruned (this era and any older ones still lingering). + let head = UnsentRewardHead::::get(); + let mut tail = UnsentRewardTail::::get(); + let mut slot = head; + while slot != tail { + if let Some((idx, _, _)) = UnsentRewardEra::::get(slot) { + if idx <= era_index_to_delete { + Self::unsent_queue_remove_slot(slot); + tail = UnsentRewardTail::::get(); + Self::deposit_event(Event::UnsentEraExpired { era_index: idx }); + // Don't advance slot — next entry slid into this position + continue; + } + } + slot = (slot + 1) % UNSENT_QUEUE_CAPACITY; + } } } @@ -671,17 +939,17 @@ pub mod pallet { // Generate era rewards utils with the actual rewards amount (post-treasury split). // This ensures the message to EigenLayer matches the actual minted rewards. - let utils = match era_reward_points.generate_era_rewards_utils( + let info = match RewardPointsForEra::::get(&era_index).generate_era_rewards_info( era_index, mint_result.rewards_amount, era_start_timestamp, ) { - Some(utils) => utils, + Some(info) => info, None => { // Returns None when total_points is zero or no validators have rewards log::error!( target: "ext_validators_rewards", - "Failed to generate era rewards utils (no rewards to distribute)" + "Failed to generate era rewards info (no rewards to distribute)" ); return; } @@ -692,13 +960,31 @@ pub mod pallet { DispatchClass::Mandatory, ); - if let Some(message_id) = Self::send_rewards_message(&utils) { - Self::deposit_event(Event::RewardsMessageSent { - message_id, - era_index, - total_points: utils.total_points, - inflation_amount: mint_result.rewards_amount, - }); + match Self::send_rewards_message(&info) { + Some(message_id) => { + Self::deposit_event(Event::RewardsMessageSent { + message_id, + era_index, + total_points: info.total_points, + inflation_amount: mint_result.rewards_amount, + }); + } + None => { + // Message failed — queue for automatic retry via on_initialize + if Self::unsent_queue_push(( + era_index, + era_start_timestamp, + mint_result.rewards_amount, + )) { + Self::deposit_event(Event::RewardsMessageSendFailed { era_index }); + } else { + log::error!( + target: "ext_validators_rewards", + "Unsent reward queue full, cannot enqueue era {era_index}", + ); + Self::deposit_event(Event::UnsentQueueFull { era_index }); + } + } } } } diff --git a/operator/pallets/external-validators-rewards/src/mock.rs b/operator/pallets/external-validators-rewards/src/mock.rs index 3c892f35..6b99b7c3 100644 --- a/operator/pallets/external-validators-rewards/src/mock.rs +++ b/operator/pallets/external-validators-rewards/src/mock.rs @@ -131,6 +131,9 @@ impl crate::types::SendMessage for MockOkOutboundQueue { } fn validate(ticket: Self::Ticket) -> Result { + if Mock::mock().send_message_fails { + return Err(SendError::MessageTooLarge); + } Ok(ticket) } @@ -223,6 +226,7 @@ impl pallet_external_validators_rewards::Config for Test { type HandleInflation = InflationMinter; type Currency = Balances; type RewardsEthereumSovereignAccount = RewardsEthereumSovereignAccount; + type GovernanceOrigin = frame_system::EnsureRoot; type WeightInfo = (); #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); @@ -292,6 +296,8 @@ pub mod mock_data { pub offline_validators: sp_std::vec::Vec, /// Set of (era_index, validator_id) pairs that are slashed pub slashed_validators: sp_std::vec::Vec<(u32, sp_core::H160)>, + /// When true, MockOkOutboundQueue::validate will return Err(SendError::MessageTooLarge) + pub send_message_fails: bool, } #[pallet::config] diff --git a/operator/pallets/external-validators-rewards/src/tests.rs b/operator/pallets/external-validators-rewards/src/tests.rs index 1a66daa0..752a55c0 100644 --- a/operator/pallets/external-validators-rewards/src/tests.rs +++ b/operator/pallets/external-validators-rewards/src/tests.rs @@ -16,7 +16,7 @@ use { crate::{self as pallet_external_validators_rewards, mock::*}, - frame_support::traits::fungible::Mutate, + frame_support::{assert_noop, assert_ok, traits::fungible::Mutate}, pallet_external_validators::traits::{ActiveEraInfo, OnEraEnd, OnEraStart}, sp_core::H160, sp_std::collections::btree_map::BTreeMap, @@ -165,8 +165,8 @@ fn test_on_era_end() { let treasury_amount = InflationTreasuryProportion::get().mul_floor(inflation); let rewards_amount = inflation - treasury_amount; // Use 0 for era_start_timestamp in tests - let rewards_utils = era_rewards.generate_era_rewards_utils(1, rewards_amount, 0); - assert!(rewards_utils.is_some()); + let rewards_info = era_rewards.generate_era_rewards_info(1, inflation, 0); + assert!(rewards_info.is_some()); System::assert_last_event(RuntimeEvent::ExternalValidatorsRewards( crate::Event::RewardsMessageSent { message_id: Default::default(), @@ -207,8 +207,8 @@ fn test_on_era_end_with_zero_inflation() { let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::::get(1); let inflation = ::EraInflationProvider::get(); - let rewards_utils = era_rewards.generate_era_rewards_utils(1, inflation, 0); - assert!(rewards_utils.is_some()); + let rewards_info = era_rewards.generate_era_rewards_info(1, inflation, 0); + assert!(rewards_info.is_some()); // With zero inflation, no RewardsMessageSent event should be emitted let events = System::events(); assert!( @@ -246,15 +246,15 @@ fn test_on_era_end_with_zero_points() { ExternalValidatorsRewards::reward_by_ids(accounts_points); ExternalValidatorsRewards::on_era_end(1); - // When all validators have zero points, generate_era_rewards_utils should return None + // When all validators have zero points, generate_era_rewards_info should return None // to prevent inflation from being minted with no way to distribute it let era_rewards = pallet_external_validators_rewards::RewardPointsForEra::::get(1); let inflation = ::EraInflationProvider::get(); - let rewards_utils = era_rewards.generate_era_rewards_utils(1, inflation, 0); + let rewards_info = era_rewards.generate_era_rewards_info(1, inflation, 0); assert!( - rewards_utils.is_none(), - "generate_era_rewards_utils should return None when total_points is zero" + rewards_info.is_none(), + "generate_era_rewards_info should return None when total_points is zero" ); // Verify no RewardsMessageSent event was emitted @@ -3722,3 +3722,456 @@ fn test_era_end_uses_correct_era_blocks_not_session() { ); }) } + +// ═══════════════════════════════════════════════════════════════════════════ +// Retry mechanism tests (ring-buffer storage) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Helper: push an entry into the unsent ring buffer via the pallet API. +fn push_unsent(era_index: u32, timestamp: u32, inflation: u128) { + assert!( + ExternalValidatorsRewards::unsent_queue_push((era_index, timestamp, inflation)), + "unsent_queue_push should succeed" + ); +} + +/// Helper: return the number of entries in the unsent ring buffer. +fn unsent_len() -> u32 { + ExternalValidatorsRewards::unsent_queue_len() +} + +/// Helper: check if unsent queue is empty. +fn unsent_is_empty() -> bool { + ExternalValidatorsRewards::unsent_queue_is_empty() +} + +#[test] +fn send_failure_queues_era() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: Some(30_000), + }); + mock.send_message_fails = true; + }); + + // Give validators some points + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + // Author expected blocks for 100% inflation + for _ in 0..600 { + ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1)); + } + + ExternalValidatorsRewards::on_era_end(1); + + // Verify era is queued + assert_eq!(unsent_len(), 1); + + // Verify event + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::RewardsMessageSendFailed { era_index: 1 }, + )); + }) +} + +#[test] +fn on_initialize_retries_and_succeeds() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: Some(30_000), + }); + }); + + // Set up reward points for era 1 + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + + // Manually populate the unsent queue + push_unsent(1, 30, 42); + + // Sending should succeed (send_message_fails is false by default) + System::reset_events(); + ExternalValidatorsRewards::process_unsent_reward_eras(); + + // Queue should be empty + assert!(unsent_is_empty()); + + // Verify retry event + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::RewardsMessageRetried { + message_id: Default::default(), + era_index: 1, + total_points: 100, + inflation_amount: 42, + }, + )); + }) +} + +#[test] +fn on_initialize_moves_failed_entry_to_back() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 2, + start: Some(30_000), + }); + mock.send_message_fails = true; + }); + + // Set up reward points for eras 1 and 2 + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: Some(30_000), + }); + }); + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 200)]); + + // Push two entries: era 1 then era 2 + push_unsent(1, 30, 42); + push_unsent(2, 30, 84); + + // First call: tries era 1, fails, moves era 1 to back of queue + ExternalValidatorsRewards::process_unsent_reward_eras(); + // Queue length stays the same (entry moved, not removed) + assert_eq!(unsent_len(), 2); + + // Second call: tries era 2 (NOT era 1 again), fails, moves era 2 to back + ExternalValidatorsRewards::process_unsent_reward_eras(); + assert_eq!(unsent_len(), 2); + + // Re-enable sending + Mock::mutate(|mock| mock.send_message_fails = false); + + // Third call: era 1 (now at front again), succeeds + System::reset_events(); + ExternalValidatorsRewards::process_unsent_reward_eras(); + assert_eq!(unsent_len(), 1); + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::RewardsMessageRetried { + message_id: Default::default(), + era_index: 1, + total_points: 200, + inflation_amount: 42, + }, + )); + + // Fourth call: era 2, succeeds + ExternalValidatorsRewards::process_unsent_reward_eras(); + assert!(unsent_is_empty()); + }) +} + +#[test] +fn on_initialize_removes_expired_era() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Populate unsent queue with era 999 but do NOT add RewardPointsForEra for it + push_unsent(999, 0, 42); + + System::reset_events(); + ExternalValidatorsRewards::process_unsent_reward_eras(); + + // Entry should be removed + assert!(unsent_is_empty()); + + // Verify expired event + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::UnsentEraExpired { era_index: 999 }, + )); + }) +} + +#[test] +fn on_initialize_noop_when_queue_empty() { + new_test_ext().execute_with(|| { + run_to_block(1); + System::reset_events(); + + ExternalValidatorsRewards::process_unsent_reward_eras(); + + // No events should be emitted + let events = System::events(); + assert!( + events.is_empty(), + "No events should be emitted when unsent queue is empty" + ); + }) +} + +#[test] +fn on_initialize_processes_only_head() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 3, + start: Some(30_000), + }); + }); + + // Set up reward points for both eras + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 2, + start: Some(30_000), + }); + }); + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(2), 200)]); + + // Push two entries + push_unsent(3, 30, 42); + push_unsent(2, 20, 84); + + System::reset_events(); + ExternalValidatorsRewards::process_unsent_reward_eras(); + + // Only the head entry (era 3) should be processed (and removed on success) + assert_eq!(unsent_len(), 1); + }) +} + +#[test] +fn retry_extrinsic_success() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: Some(30_000), + }); + }); + + // Set up reward points + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + + // Populate unsent queue + push_unsent(1, 30, 42); + + System::reset_events(); + assert_ok!(ExternalValidatorsRewards::retry_unsent_reward_era( + RuntimeOrigin::root(), + 1 + )); + + // Queue should be empty + assert!(unsent_is_empty()); + + // Verify retry event + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::RewardsMessageRetried { + message_id: Default::default(), + era_index: 1, + total_points: 100, + inflation_amount: 42, + }, + )); + }) +} + +#[test] +fn retry_extrinsic_era_not_in_queue() { + new_test_ext().execute_with(|| { + run_to_block(1); + + assert_noop!( + ExternalValidatorsRewards::retry_unsent_reward_era(RuntimeOrigin::root(), 1), + crate::Error::::EraNotInUnsentQueue + ); + }) +} + +#[test] +fn retry_extrinsic_pruned_data() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Queue an era but don't create reward points for it + push_unsent(999, 0, 42); + + assert_noop!( + ExternalValidatorsRewards::retry_unsent_reward_era(RuntimeOrigin::root(), 999), + crate::Error::::RewardPointsPruned + ); + }) +} + +#[test] +fn retry_extrinsic_requires_root() { + new_test_ext().execute_with(|| { + run_to_block(1); + + assert_noop!( + ExternalValidatorsRewards::retry_unsent_reward_era( + RuntimeOrigin::signed(H160::from_low_u64_be(1)), + 1 + ), + sp_runtime::DispatchError::BadOrigin + ); + }) +} + +#[test] +fn unsent_queue_full() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 65, + start: Some(30_000), + }); + mock.send_message_fails = true; + }); + + // Fill the ring buffer to capacity (63 entries, since capacity=64 + // means 63 usable slots in a ring buffer with head==tail==empty). + for i in 0..63u32 { + push_unsent(i, 0, 42); + } + assert_eq!(unsent_len(), 63); + + // Give validators some points so on_era_end doesn't bail early + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + for _ in 0..600 { + ExternalValidatorsRewards::note_block_author(H160::from_low_u64_be(1)); + } + + System::reset_events(); + ExternalValidatorsRewards::on_era_end(65); + + // Verify UnsentQueueFull event + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::UnsentQueueFull { era_index: 65 }, + )); + + // Queue should still be at 63 + assert_eq!(unsent_len(), 63); + }) +} + +#[test] +fn on_era_start_prunes_unsent_entry() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Set up: era 1 has an unsent entry + push_unsent(1, 0, 42); + + // HistoryDepth is 10, so era 11 should prune era 1 + System::reset_events(); + ExternalValidatorsRewards::on_era_start(11, 0, 11); + + // Unsent entry should be removed + assert!(unsent_is_empty()); + + // Verify expired event + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::UnsentEraExpired { era_index: 1 }, + )); + }) +} + +#[test] +fn retry_extrinsic_send_still_fails() { + new_test_ext().execute_with(|| { + run_to_block(1); + + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: 1, + start: Some(30_000), + }); + mock.send_message_fails = true; + }); + + // Set up reward points + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + + // Populate unsent queue + push_unsent(1, 30, 42); + + assert_noop!( + ExternalValidatorsRewards::retry_unsent_reward_era(RuntimeOrigin::root(), 1), + crate::Error::::MessageSendFailed + ); + + // Queue should still have the entry + assert_eq!(unsent_len(), 1); + }) +} + +#[test] +fn head_of_line_blocking_avoided() { + new_test_ext().execute_with(|| { + run_to_block(1); + + // Set up reward points for eras 1, 2, 3 + for era in 1..=3u32 { + Mock::mutate(|mock| { + mock.active_era = Some(ActiveEraInfo { + index: era, + start: Some(30_000), + }); + }); + ExternalValidatorsRewards::reward_by_ids([(H160::from_low_u64_be(1), 100)]); + } + + // Push eras 1, 2, 3 into the queue + push_unsent(1, 30, 10); + push_unsent(2, 30, 20); + push_unsent(3, 30, 30); + + // Make sending fail + Mock::mutate(|mock| mock.send_message_fails = true); + + // Block 1: tries era 1, fails, advances head → era 2 + ExternalValidatorsRewards::process_unsent_reward_eras(); + // Block 2: tries era 2, fails, advances head → era 3 + ExternalValidatorsRewards::process_unsent_reward_eras(); + + // Now re-enable sending + Mock::mutate(|mock| mock.send_message_fails = false); + + // Block 3: tries era 3, succeeds + System::reset_events(); + ExternalValidatorsRewards::process_unsent_reward_eras(); + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::RewardsMessageRetried { + message_id: Default::default(), + era_index: 3, + total_points: 100, + inflation_amount: 30, + }, + )); + + // Block 4: wraps around to era 1, succeeds + ExternalValidatorsRewards::process_unsent_reward_eras(); + System::assert_has_event(RuntimeEvent::ExternalValidatorsRewards( + crate::Event::RewardsMessageRetried { + message_id: Default::default(), + era_index: 1, + total_points: 100, + inflation_amount: 10, + }, + )); + + // Block 5: era 2, succeeds + ExternalValidatorsRewards::process_unsent_reward_eras(); + assert!(unsent_is_empty()); + }) +} diff --git a/operator/pallets/external-validators-rewards/src/weights.rs b/operator/pallets/external-validators-rewards/src/weights.rs index 766adfcf..a7585778 100644 --- a/operator/pallets/external-validators-rewards/src/weights.rs +++ b/operator/pallets/external-validators-rewards/src/weights.rs @@ -54,6 +54,11 @@ use sp_std::marker::PhantomData; /// Weight functions needed for pallet_external_validators_rewards. pub trait WeightInfo { fn on_era_end() -> Weight; + fn process_unsent_reward_eras_empty() -> Weight; + fn process_unsent_reward_eras_expired() -> Weight; + fn process_unsent_reward_eras_success() -> Weight; + fn process_unsent_reward_eras_failed() -> Weight; + fn retry_unsent_reward_era() -> Weight; } /// Weights for pallet_external_validators_rewards using the Substrate node and recommended hardware. @@ -84,6 +89,36 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(5_u64)) } + + fn process_unsent_reward_eras_empty() -> Weight { + // 1 read for UnsentRewardEras + Weight::from_parts(5_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + + fn process_unsent_reward_eras_expired() -> Weight { + // 1 read UnsentRewardEras + 1 read RewardPointsForEra + 1 write UnsentRewardEras + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + + fn process_unsent_reward_eras_success() -> Weight { + // Same as on_era_end + queue read/write + Weight::from_parts(1_136_401_000, 39987) + .saturating_add(T::DbWeight::get().reads(7_u64)) + .saturating_add(T::DbWeight::get().writes(6_u64)) + } + + fn process_unsent_reward_eras_failed() -> Weight { + // Use success weight as upper bound + Self::process_unsent_reward_eras_success() + } + + fn retry_unsent_reward_era() -> Weight { + // Same as success path + Self::process_unsent_reward_eras_success() + } } // For backwards compatibility and tests @@ -113,4 +148,29 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(5_u64)) } + + fn process_unsent_reward_eras_empty() -> Weight { + Weight::from_parts(5_000_000, 0) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + } + + fn process_unsent_reward_eras_expired() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(RocksDbWeight::get().reads(2_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + + fn process_unsent_reward_eras_success() -> Weight { + Weight::from_parts(1_136_401_000, 39987) + .saturating_add(RocksDbWeight::get().reads(7_u64)) + .saturating_add(RocksDbWeight::get().writes(6_u64)) + } + + fn process_unsent_reward_eras_failed() -> Weight { + Self::process_unsent_reward_eras_success() + } + + fn retry_unsent_reward_era() -> Weight { + Self::process_unsent_reward_eras_success() + } } diff --git a/operator/runtime/mainnet/src/configs/mod.rs b/operator/runtime/mainnet/src/configs/mod.rs index 0deeff2a..48f1aad9 100644 --- a/operator/runtime/mainnet/src/configs/mod.rs +++ b/operator/runtime/mainnet/src/configs/mod.rs @@ -1598,6 +1598,8 @@ impl pallet_external_validators_rewards::Config for Runtime { type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount; type SendMessage = RewardsSendAdapter; type HandleInflation = ExternalRewardsInflationHandler; + type GovernanceOrigin = + EitherOfDiverse, governance::custom_origins::GeneralAdmin>; type WeightInfo = mainnet_weights::pallet_external_validators_rewards::WeightInfo; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); diff --git a/operator/runtime/mainnet/src/weights/pallet_external_validators_rewards.rs b/operator/runtime/mainnet/src/weights/pallet_external_validators_rewards.rs index b8be1393..10854100 100644 --- a/operator/runtime/mainnet/src/weights/pallet_external_validators_rewards.rs +++ b/operator/runtime/mainnet/src/weights/pallet_external_validators_rewards.rs @@ -74,4 +74,29 @@ impl pallet_external_validators_rewards::WeightInfo for .saturating_add(T::DbWeight::get().reads(9_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } + + fn process_unsent_reward_eras_empty() -> Weight { + Weight::from_parts(5_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + + fn process_unsent_reward_eras_expired() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + + fn process_unsent_reward_eras_success() -> Weight { + Weight::from_parts(1_905_623_000, 29162) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + + fn process_unsent_reward_eras_failed() -> Weight { + Self::process_unsent_reward_eras_success() + } + + fn retry_unsent_reward_era() -> Weight { + Self::process_unsent_reward_eras_success() + } } diff --git a/operator/runtime/stagenet/src/configs/mod.rs b/operator/runtime/stagenet/src/configs/mod.rs index 60fee86c..12b4a960 100644 --- a/operator/runtime/stagenet/src/configs/mod.rs +++ b/operator/runtime/stagenet/src/configs/mod.rs @@ -1594,6 +1594,8 @@ impl pallet_external_validators_rewards::Config for Runtime { type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount; type SendMessage = RewardsSendAdapter; type HandleInflation = ExternalRewardsInflationHandler; + type GovernanceOrigin = + EitherOfDiverse, governance::custom_origins::GeneralAdmin>; type WeightInfo = stagenet_weights::pallet_external_validators_rewards::WeightInfo; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); diff --git a/operator/runtime/stagenet/src/weights/pallet_external_validators_rewards.rs b/operator/runtime/stagenet/src/weights/pallet_external_validators_rewards.rs index 4d223163..34d31953 100644 --- a/operator/runtime/stagenet/src/weights/pallet_external_validators_rewards.rs +++ b/operator/runtime/stagenet/src/weights/pallet_external_validators_rewards.rs @@ -74,4 +74,29 @@ impl pallet_external_validators_rewards::WeightInfo for .saturating_add(T::DbWeight::get().reads(9_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } + + fn process_unsent_reward_eras_empty() -> Weight { + Weight::from_parts(5_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + + fn process_unsent_reward_eras_expired() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + + fn process_unsent_reward_eras_success() -> Weight { + Weight::from_parts(1_894_953_000, 29162) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + + fn process_unsent_reward_eras_failed() -> Weight { + Self::process_unsent_reward_eras_success() + } + + fn retry_unsent_reward_era() -> Weight { + Self::process_unsent_reward_eras_success() + } } diff --git a/operator/runtime/testnet/src/configs/mod.rs b/operator/runtime/testnet/src/configs/mod.rs index caca5de5..27dbc538 100644 --- a/operator/runtime/testnet/src/configs/mod.rs +++ b/operator/runtime/testnet/src/configs/mod.rs @@ -1598,6 +1598,8 @@ impl pallet_external_validators_rewards::Config for Runtime { type RewardsEthereumSovereignAccount = ExternalValidatorRewardsAccount; type SendMessage = RewardsSendAdapter; type HandleInflation = ExternalRewardsInflationHandler; + type GovernanceOrigin = + EitherOfDiverse, governance::custom_origins::GeneralAdmin>; type WeightInfo = testnet_weights::pallet_external_validators_rewards::WeightInfo; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); diff --git a/operator/runtime/testnet/src/weights/pallet_external_validators_rewards.rs b/operator/runtime/testnet/src/weights/pallet_external_validators_rewards.rs index b2403bcf..9b7e752d 100644 --- a/operator/runtime/testnet/src/weights/pallet_external_validators_rewards.rs +++ b/operator/runtime/testnet/src/weights/pallet_external_validators_rewards.rs @@ -74,4 +74,29 @@ impl pallet_external_validators_rewards::WeightInfo for .saturating_add(T::DbWeight::get().reads(9_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } + + fn process_unsent_reward_eras_empty() -> Weight { + Weight::from_parts(5_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1_u64)) + } + + fn process_unsent_reward_eras_expired() -> Weight { + Weight::from_parts(10_000_000, 0) + .saturating_add(T::DbWeight::get().reads(2_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + + fn process_unsent_reward_eras_success() -> Weight { + Weight::from_parts(1_893_280_000, 29162) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + + fn process_unsent_reward_eras_failed() -> Weight { + Self::process_unsent_reward_eras_success() + } + + fn retry_unsent_reward_era() -> Weight { + Self::process_unsent_reward_eras_success() + } } diff --git a/test/.papi/descriptors/package.json b/test/.papi/descriptors/package.json index 5ad366a9..185bc678 100644 --- a/test/.papi/descriptors/package.json +++ b/test/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.15484599658830368838", + "version": "0.1.0-autogenerated.18139584469151706411", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/test/.papi/metadata/datahaven.scale b/test/.papi/metadata/datahaven.scale index 1ad174a797dc11e0174c4289ff51aa16bb594ea0..3f4882b211f71d8afcb306069413c129280f4b53 100644 GIT binary patch delta 14621 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 delta 13172 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 u#F?#S(Vm90IJpcKwTsz-b!wWU+`gsMQKQC_