Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions frame/ismp-messaging/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ pub mod pallet {
#[pallet::constant]
type MaxBodyLen: Get<u32>;

/// Largest number of storage keys a single GET may request.
///
/// Bounds work we impose on someone else: every key in a GET is a separate
/// membership proof the relayer must fetch from the destination and the
/// destination must include. An unbounded GET is a cheap way to make a remote
/// chain and a relayer do expensive work, so it is capped for the same reason
/// [`Config::MaxBodyLen`] caps a body.
#[pallet::constant]
type MaxGetKeys: Get<u32>;

type WeightInfo: WeightInfo;
}

Expand Down Expand Up @@ -259,6 +269,16 @@ pub mod pallet {
DestinationIsSelf,
/// `pallet-ismp` refused the request.
DispatchFailed,
/// A GET with no keys asks for nothing and would still cost a round trip.
NoKeysRequested,
/// Exceeded [`Config::MaxGetKeys`].
TooManyKeys,
/// A GET must name the height to read at, and `0` is never a real one.
///
/// The response handler requires the proof height to equal the requested height
/// exactly (`ismp-2606.1.0/src/handlers/response.rs:71`), so a height nobody can
/// prove leaves the request hanging until it expires rather than failing fast.
InvalidGetHeight,
}

#[pallet::call]
Expand All @@ -281,6 +301,43 @@ pub mod pallet {
outbound::post::<T>(dest, to, body, timeout)
}

/// Read state from `dest` over ISMP.
///
/// A GET is answered differently from a POST, and the difference is the point: no
/// module runs on the destination. A relayer reads the requested keys, proves them
/// against a state commitment we already hold, and the answer arrives back here as
/// [`Event::GetResponseReceived`] via our own `on_response`. So a destination
/// cannot refuse us the way a receiving module can refuse a POST.
///
/// "A relayer" is not the public one: Tesseract delivers GET responses only to EVM
/// sources (`tesseract/messaging/messaging/src/events.rs:314-336`), so on this chain
/// the answer is carried by `scripts/hyperbridge/relay-get-response.mjs`. The proof
/// is verified here regardless of who submits it.
///
/// `height` must be one this chain can already prove — a height for which
/// `pallet-ismp` holds a state commitment of `dest`. The response handler compares
/// it for equality, not as a lower bound, so an unprovable height means the request
/// simply expires.
///
/// `keys` are proven against what this chain holds of `dest`. For the coprocessor
/// that is its ISMP **child trie** root, not its state root
/// (`ismp-grandpa/src/consensus.rs:142-150`), so a GET to Hyperbridge can only read
/// keys inside `:child_storage:default:ISMPv2`.
///
/// `timeout` is **relative seconds**; `0` means *never expires*.
#[pallet::call_index(3)]
#[pallet::weight(T::WeightInfo::dispatch_get(keys.len() as u32))]
pub fn dispatch_get(
origin: OriginFor<T>,
dest: StateMachine,
keys: Vec<Vec<u8>>,
height: u64,
timeout: u64,
) -> DispatchResult {
T::DispatchOrigin::ensure_origin(origin)?;
outbound::get::<T>(dest, keys, height, timeout)
}

#[pallet::call_index(1)]
#[pallet::weight(T::WeightInfo::accept_source())]
pub fn accept_source(origin: OriginFor<T>, source: StateMachine) -> DispatchResult {
Expand Down
2 changes: 2 additions & 0 deletions frame/ismp-messaging/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ impl pallet_ismp::Config for Test {
impl pallet_ismp_messaging::Config for Test {
type DispatchOrigin = frame_system::EnsureRoot<AccountId>;
type MaxBodyLen = ConstU32<8192>;
// Small on purpose, so the TooManyKeys path is cheap to exercise.
type MaxGetKeys = ConstU32<4>;
type WeightInfo = ();
}

Expand Down
96 changes: 95 additions & 1 deletion frame/ismp-messaging/src/outbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{Config, Error, Event, PALLET_ID_BYTES, Pallet, RequestKind};
use alloc::vec::Vec;
use frame_support::{ensure, traits::Get, traits::UnixTime};
use ismp::{
dispatcher::{DispatchPost, DispatchRequest, FeeMetadata, IsmpDispatcher},
dispatcher::{DispatchGet, DispatchPost, DispatchRequest, FeeMetadata, IsmpDispatcher},
host::StateMachine,
};
use pallet_ismp::pallet::ModuleId;
Expand Down Expand Up @@ -110,6 +110,100 @@ pub fn post<T: Config>(
Ok(())
}

/// Read state from `dest` at `height`.
///
/// The mirror of [`post`], and the asymmetry is what makes it useful: a POST is handed to a
/// module on the destination, which may refuse it — `pallet-ismp-demo` on Hyperbridge, for
/// one, rejects any `Substrate(_)` source outright. A GET is answered by a relayer reading
/// the destination's storage and proving it, so nothing on the far side can turn us away.
/// The answer lands back here in our own `on_response` — carried by our own relaying
/// script for now, since Tesseract only delivers GET responses to EVM sources.
///
/// `timeout` is **relative seconds**; `0` means the request never expires.
pub fn get<T: Config>(
dest: StateMachine,
keys: Vec<Vec<u8>>,
height: u64,
timeout: u64,
) -> DispatchResult {
// Reading our own state over ISMP is never meaningful: the round trip proves something
// we can already read directly.
ensure!(
dest != <T as pallet_ismp::Config>::HostStateMachine::get(),
Error::<T>::DestinationIsSelf
);

// A keyless GET still costs a dispatch, a relayer round trip and a response, and
// answers nothing.
ensure!(!keys.is_empty(), Error::<T>::NoKeysRequested);

// Each key is a separate membership proof the destination must produce, so this bounds
// work we impose on someone else — the same reason a body is bounded.
ensure!(
keys.len() as u32 <= T::MaxGetKeys::get(),
Error::<T>::TooManyKeys
);

// Rejected here rather than left to expire. `handlers/response.rs:71` requires the
// proof height to EQUAL the requested height, so a height no relayer can prove is not
// a slow request — it is one that can never be answered, and failing now says so.
ensure!(height > 0, Error::<T>::InvalidGetHeight);

// Same reasoning as `post`: not the destination, just proof a route exists.
ensure!(
<T as pallet_ismp::Config>::Coprocessor::get().is_some(),
Error::<T>::CoprocessorNotSet
);

// Captured before the dispatch consumes the nonce, and before `keys` is moved.
let nonce = pallet_ismp::Nonce::<T>::get();
let timeout_timestamp = if timeout == 0 {
0
} else {
<<T as pallet_ismp::Config>::TimestampProvider as UnixTime>::now()
.as_secs()
.saturating_add(timeout)
};

let get = DispatchGet {
dest,
from: PALLET_ID_BYTES.to_vec(),
keys,
height,
timeout,
// Application metadata travels with the request and comes back on the response.
// Nothing here needs it, and it is remote-visible, so it stays empty.
context: Default::default(),
};

let commitment = pallet_ismp::Pallet::<T>::default()
.dispatch_request(
DispatchRequest::Get(get),
// Zero fee, for the same reason as `post` — see the note there.
FeeMetadata {
payer: payer::<T>(),
fee: Default::default(),
},
)
.map_err(|_| Error::<T>::DispatchFailed)?;

Pallet::<T>::deposit_event(Event::RequestDispatched {
dest,
// A GET addresses storage, not a module, so there is no `to`. Our own module id
// goes here because that is what the protocol records as `from` and what the
// response is routed back to.
to: PALLET_ID_BYTES.to_vec(),
commitment,
nonce,
timeout_timestamp,
// A GET has no body. Reported as 0 rather than omitted so the column means the
// same thing on every row.
body_len: 0,
kind: RequestKind::Get,
});
Ok(())
}

/// Account recorded as the fee payer.
///
/// Derived from the pallet id because Root has no account; the fee is zero, so nothing
Expand Down
Loading
Loading