Skip to content

Commit a665495

Browse files
authored
[runtime]: Bind source chain to relayer redirect signature (#881)
1 parent 1408933 commit a665495

8 files changed

Lines changed: 296 additions & 49 deletions

File tree

modules/pallets/relayer/src/accumulate.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
//! Substrate), and the per-leaf result validation that ties source-side fee
2323
//! metadata to destination-side delivery receipts.
2424
25-
use crate::{withdrawal::WithdrawalProof, Config, Error, Event, Fees, Pallet};
25+
use crate::{withdrawal::WithdrawalProof, Config, Error, Event, Fees, Nonce, Pallet};
2626
use alloc::{collections::BTreeMap, vec::Vec};
2727
use alloy_primitives::Address;
2828
use codec::Encode;
@@ -107,7 +107,8 @@ where
107107
let beneficiary_address = if let Some((beneficiary_address, signature)) =
108108
withdrawal_proof.beneficiary_details
109109
{
110-
let msg = sp_io::hashing::keccak_256(&beneficiary_address);
110+
let nonce = Nonce::<T>::get(&delivery_address, state_machine);
111+
let msg = beneficiary_message(nonce, state_machine, &beneficiary_address);
111112
match &signature {
112113
Signature::Evm { .. } => {
113114
let eth_address =
@@ -119,11 +120,17 @@ where
119120
Signature::Sr25519 { .. } | Signature::Ed25519 { .. } => {
120121
// verify the signature with the delivery address from the state proof
121122
let _ = signature
122-
.verify(&msg, Some(delivery_address))
123+
.verify(&msg, Some(delivery_address.clone()))
123124
.map_err(|_| Error::<T>::InvalidSignature)?;
124125
},
125126
}
126127

128+
Nonce::<T>::try_mutate(&delivery_address, state_machine, |value| {
129+
*value += 1;
130+
Ok::<(), ()>(())
131+
})
132+
.map_err(|_: ()| Error::<T>::ErrorCompletingCall)?;
133+
127134
let _ = Fees::<T>::try_mutate(state_machine, beneficiary_address.clone(), |inner| {
128135
*inner += total_fee;
129136
Ok::<(), ()>(())
@@ -294,6 +301,18 @@ where
294301
}
295302
}
296303

304+
/// Signed payload authorising a beneficiary redirect on a specific source chain.
305+
/// Including the relayer nonce alongside the state machine keeps the signature usable for
306+
/// exactly one accumulate call on that chain, mirroring how `withdraw_fees` binds its signed
307+
/// payload.
308+
pub fn beneficiary_message(
309+
nonce: u64,
310+
state_machine: StateMachine,
311+
beneficiary: &[u8],
312+
) -> [u8; 32] {
313+
sp_io::hashing::keccak_256(&(nonce, state_machine, beneficiary).encode())
314+
}
315+
297316
impl<T: Config> Pallet<T> {
298317
pub fn accumulate_fee_and_deposit_event(
299318
state_machine: StateMachine,

modules/pallets/relayer/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub mod accumulate;
2424
pub mod outbound_consensus;
2525
pub mod withdrawal;
2626

27+
pub use accumulate::beneficiary_message;
2728
pub use outbound_consensus::*;
2829
pub use withdrawal::message;
2930

modules/pallets/testsuite/src/tests/pallet_ismp_relayer.rs

Lines changed: 199 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ use pallet_ismp::{
3131
};
3232
use pallet_ismp_host_executive::{EvmHostParam, HostParam};
3333
use pallet_ismp_relayer::{
34-
self as pallet_ismp_relayer, message,
34+
self as pallet_ismp_relayer, beneficiary_message, message,
3535
withdrawal::{Signature, WithdrawalInputData, WithdrawalProof},
3636
OutboundConsensusDeliveryClaim,
3737
};
38-
use sp_core::{keccak_256, Pair, H256, U256};
38+
use sp_core::{Pair, H256, U256};
3939
use sp_trie::LayoutV0;
4040
use std::time::Duration;
4141
use substrate_state_machine::{HashAlgorithm, StateMachineProof, SubstrateStateProof};
@@ -220,7 +220,13 @@ fn test_accumulate_fees() {
220220

221221
let beneficiary_address = H256::random();
222222

223-
let signature = pair.sign(&keccak_256(beneficiary_address.as_bytes())).to_vec();
223+
let signature = pair
224+
.sign(&beneficiary_message(
225+
0,
226+
StateMachine::Kusama(2000),
227+
beneficiary_address.as_bytes(),
228+
))
229+
.to_vec();
224230

225231
let withdrawal_proof = WithdrawalProof {
226232
commitments: keys,
@@ -699,7 +705,13 @@ fn test_accumulate_fees_evm_signatures() {
699705

700706
let beneficiary_address = H256::random();
701707

702-
let signature = pair.sign_prehashed(&keccak_256(beneficiary_address.as_bytes())).to_vec();
708+
let signature = pair
709+
.sign_prehashed(&beneficiary_message(
710+
0,
711+
StateMachine::Kusama(2000),
712+
beneficiary_address.as_bytes(),
713+
))
714+
.to_vec();
703715

704716
let withdrawal_proof = WithdrawalProof {
705717
commitments: keys,
@@ -725,7 +737,7 @@ fn test_accumulate_fees_evm_signatures() {
725737
},
726738
beneficiary_details: Some((
727739
beneficiary_address.0.to_vec().clone(),
728-
Signature::Evm { address: eth_address, signature },
740+
Signature::Evm { address: eth_address.clone(), signature },
729741
)),
730742
};
731743

@@ -742,6 +754,188 @@ fn test_accumulate_fees_evm_signatures() {
742754
),
743755
U256::from(5000u128)
744756
);
757+
assert_eq!(
758+
pallet_ismp_relayer::Nonce::<Test>::get(eth_address, StateMachine::Kusama(2000)),
759+
1,
760+
"a successful redirect must consume the signer's nonce"
761+
);
762+
})
763+
}
764+
765+
/// A redirect signature signed for one source chain must not satisfy verification
766+
/// when reused on another. The state machine is folded into the signed payload
767+
/// so the recovered address only matches the delivery address on the chain the
768+
/// signature was issued for.
769+
#[test]
770+
fn test_accumulate_fees_rejects_replay_from_other_chain() {
771+
let mut ext = new_test_ext();
772+
ext.execute_with(|| {
773+
set_timestamp::<Test>(10_000_000_000);
774+
let requests = (0u64..2)
775+
.into_iter()
776+
.map(|nonce| {
777+
let post = PostRequest {
778+
source: StateMachine::Kusama(2000),
779+
dest: StateMachine::Kusama(2001),
780+
nonce,
781+
from: vec![],
782+
to: vec![],
783+
timeout_timestamp: 0,
784+
body: vec![],
785+
};
786+
hash_request::<Ismp>(&Request::Post(post))
787+
})
788+
.collect::<Vec<_>>();
789+
790+
let pair = sp_core::ecdsa::Pair::from_seed_slice(H256::random().as_bytes()).unwrap();
791+
let eth_address = pair.public().to_eth_address().unwrap().to_vec();
792+
793+
let mut source_root = H256::default();
794+
let mut source_db = MemoryDB::<KeccakHasher>::default();
795+
let mut source_trie =
796+
TrieDBMutBuilder::<LayoutV0<KeccakHasher>>::new(&mut source_db, &mut source_root)
797+
.build();
798+
let mut dest_root = H256::default();
799+
let mut dest_db = MemoryDB::<KeccakHasher>::default();
800+
let mut dest_trie =
801+
TrieDBMutBuilder::<LayoutV0<KeccakHasher>>::new(&mut dest_db, &mut dest_root).build();
802+
803+
for request in &requests {
804+
let request_commitment_key = RequestCommitments::<Test>::storage_key(*request);
805+
let request_receipt_key = RequestReceipts::<Test>::storage_key(*request);
806+
let fee_metadata = FeeMetadata::<Test> { payer: [0; 32].into(), fee: 1000u128.into() };
807+
let leaf_meta = RequestMetadata {
808+
offchain: LeafIndexAndPos { leaf_index: 0, pos: 0 },
809+
fee: fee_metadata,
810+
claimed: false,
811+
};
812+
RequestCommitments::<Test>::insert(*request, leaf_meta.clone());
813+
source_trie.insert(&request_commitment_key, &leaf_meta.encode()).unwrap();
814+
dest_trie.insert(&request_receipt_key, &eth_address.encode()).unwrap();
815+
}
816+
drop(source_trie);
817+
drop(dest_trie);
818+
819+
let mut source_recorder = Recorder::<LayoutV0<KeccakHasher>>::default();
820+
let mut dest_recorder = Recorder::<LayoutV0<KeccakHasher>>::default();
821+
let source_trie = TrieDBBuilder::<LayoutV0<KeccakHasher>>::new(&source_db, &source_root)
822+
.with_recorder(&mut source_recorder)
823+
.build();
824+
let dest_trie = TrieDBBuilder::<LayoutV0<KeccakHasher>>::new(&dest_db, &dest_root)
825+
.with_recorder(&mut dest_recorder)
826+
.build();
827+
828+
let mut keys = vec![];
829+
for request in requests.iter() {
830+
let request_commitment_key = RequestCommitments::<Test>::storage_key(*request);
831+
let request_receipt_key = RequestReceipts::<Test>::storage_key(*request);
832+
source_trie.get(&request_commitment_key).unwrap();
833+
dest_trie.get(&request_receipt_key).unwrap();
834+
keys.push(*request);
835+
}
836+
837+
let source_state_proof = SubstrateStateProof::OverlayProof(StateMachineProof {
838+
hasher: HashAlgorithm::Keccak,
839+
storage_proof: source_recorder.drain().into_iter().map(|f| f.data).collect::<Vec<_>>(),
840+
});
841+
let dest_state_proof = SubstrateStateProof::OverlayProof(StateMachineProof {
842+
hasher: HashAlgorithm::Keccak,
843+
storage_proof: dest_recorder.drain().into_iter().map(|f| f.data).collect::<Vec<_>>(),
844+
});
845+
846+
let host = Ismp::default();
847+
for chain in [StateMachine::Kusama(2000), StateMachine::Kusama(2001)] {
848+
host.store_state_machine_commitment(
849+
StateMachineHeight {
850+
id: StateMachineId {
851+
state_id: chain,
852+
consensus_state_id: MOCK_CONSENSUS_STATE_ID,
853+
},
854+
height: 1,
855+
},
856+
StateCommitment {
857+
timestamp: 100,
858+
overlay_root: Some(if chain == StateMachine::Kusama(2000) {
859+
source_root
860+
} else {
861+
dest_root
862+
}),
863+
state_root: Default::default(),
864+
},
865+
)
866+
.unwrap();
867+
host.store_state_machine_update_time(
868+
StateMachineHeight {
869+
id: StateMachineId {
870+
state_id: chain,
871+
consensus_state_id: MOCK_CONSENSUS_STATE_ID,
872+
},
873+
height: 1,
874+
},
875+
Duration::from_secs(100),
876+
)
877+
.unwrap();
878+
host.store_challenge_period(
879+
StateMachineId { state_id: chain, consensus_state_id: MOCK_CONSENSUS_STATE_ID },
880+
0,
881+
)
882+
.unwrap();
883+
}
884+
host.store_consensus_state(MOCK_CONSENSUS_STATE_ID, Default::default()).unwrap();
885+
host.store_consensus_state_id(MOCK_CONSENSUS_STATE_ID, MOCK_CONSENSUS_CLIENT_ID)
886+
.unwrap();
887+
host.store_unbonding_period(MOCK_CONSENSUS_STATE_ID, 10_000_000_000).unwrap();
888+
889+
let beneficiary_address = H256::random();
890+
891+
// A signature the relayer would have produced for a different source chain.
892+
// The byte payload is reused as if captured from that chain and replayed here.
893+
let foreign_chain = StateMachine::Kusama(7777);
894+
let signature = pair
895+
.sign_prehashed(&beneficiary_message(0, foreign_chain, beneficiary_address.as_bytes()))
896+
.to_vec();
897+
898+
let withdrawal_proof = WithdrawalProof {
899+
commitments: keys,
900+
source_proof: Proof {
901+
height: StateMachineHeight {
902+
id: StateMachineId {
903+
state_id: StateMachine::Kusama(2000),
904+
consensus_state_id: MOCK_CONSENSUS_STATE_ID,
905+
},
906+
height: 1,
907+
},
908+
proof: source_state_proof.encode(),
909+
},
910+
dest_proof: Proof {
911+
height: StateMachineHeight {
912+
id: StateMachineId {
913+
state_id: StateMachine::Kusama(2001),
914+
consensus_state_id: MOCK_CONSENSUS_STATE_ID,
915+
},
916+
height: 1,
917+
},
918+
proof: dest_state_proof.encode(),
919+
},
920+
beneficiary_details: Some((
921+
beneficiary_address.0.to_vec(),
922+
Signature::Evm { address: eth_address, signature },
923+
)),
924+
};
925+
926+
let result = pallet_ismp_relayer::Pallet::<Test>::accumulate_fees(
927+
RuntimeOrigin::none(),
928+
withdrawal_proof,
929+
);
930+
931+
assert!(result.is_err(), "replayed signature from a different chain must be rejected");
932+
assert_eq!(
933+
pallet_ismp_relayer::Fees::<Test>::get(
934+
StateMachine::Kusama(2000),
935+
beneficiary_address.0.to_vec()
936+
),
937+
U256::zero()
938+
);
745939
})
746940
}
747941

0 commit comments

Comments
 (0)