Skip to content

feat(ismp): prove outbound delivery on-chain by reading the destination's receipt - #148

Merged
nol4lej merged 1 commit into
mainfrom
feat/ismp-delivery-confirmation
Sep 13, 2026
Merged

nol4lej merged 1 commit into
mainfrom
feat/ismp-delivery-confirmation

Conversation

@nol4lej

@nol4lej nol4lej commented Sep 13, 2026

Copy link
Copy Markdown
Member

What

A POST dispatched from Orbinum left no trace here once delivered: the explorer had to ask a
public BSC RPC whether the PostRequestHandled log existed — an answer nothing verifies.
This turns that question into an on-chain proof.

Adds confirm_delivery (call index 4) and the DeliveryConfirmed event. spec 13 → 14,
transaction_version stays at 3.

Why not the obvious way

Having the destination reply is not possible. Upstream #840 removed PostResponse from
the protocol (ismp/src/messaging.rs: "the protocol no longer carries PostResponse");
IsmpModule has three callbacks and on_response takes only a GetResponse. Any design
built on "BSC answers us" is dead on arrival.

What the destination does leave is a receipt. handlers/request.rs:112 writes
RequestReceipts[commitment] = relayer before invoking the receiving module, and
:122-125 deletes it again if that module errs. So its presence proves delivered and
executed
— a stronger guarantee than the event itself — and it is storage, which is what a
GET proves.

What it proves, precisely

The receipt we read is Hyperbridge's, not BSC's. ismp-grandpa only gives this chain the
coprocessor's ISMP child trie root (consensus.rs:148), so an EVM chain's state is not
provable here at all. Gargantua's proxy re-dispatches through the same on_accept that writes
receipts, so a receipt there means the coprocessor accepted and forwarded it — one hop
short of execution on the far side, but proven rather than taken on an RPC's word.

confirm_delivery takes its destination from Coprocessor::get(), never from the caller, so
it cannot be pointed at a chain whose child trie we cannot verify.

Correlation without storage

The confirming GET has its own commitment, unrelated to the POST it proves. The link travels
in DispatchGet.context, which comes back inside GetResponse.get — so there is no
StorageMap to clean up, and no orphaned entry when a GET expires.

dispatch_get keeps its signature (it passes Default::default()): changing it would have
moved transaction_version and invalidated offline-signed extrinsics, for no gain.

There is no negative event

A response with an absent value emits nothing. It proves the receipt was not there at that
height
, which is indistinguishable from asking too early. Nothing in this pallet can claim a
message failed to arrive.

Audit against the official documentation

Two real defects found and fixed:

  1. values arrives sorted by key bytes, not in request order. verify_state_proof returns
    a BTreeMap (state-machines/substrate/src/lib.rs:246). The code used .first(), which
    happens to be right today only because we ask for one key. It now matches by key, with a
    test that plants a decoy sorting ahead of it.
    (The official docs claim the opposite — "one entry per key in the original request" — and
    are wrong.)

  2. context was unbounded. MaxBodyLen bounds bodies and MaxGetKeys bounds keys, but
    the context travelled uncapped while dispatch_get's weight is measured per key, not
    per context byte. Now bounded by MaxBodyLen.

Also validated: context used as the docs prescribe, None treated as proven absence,
on_response never returning Err (the replay warning), the 8MB/85% block length the
solochain guide requires, and the AURA digest being present.

Verification

Against the release binary and the real Gargantua — the test does not invent a receipt: it
reads one Hyperbridge genuinely wrote, feeds the commitment from its key to confirm_delivery,
and asserts the pallet rebuilds the same key byte for byte.

Suite
unit (pallet + runtime) 98
e2e 61/61
security 43/43
confirm-delivery (real Gargantua) 19/19
GET round trip 28/28

cargo fmt, clippy and the no_std build are clean; compiles with
--features hyperbridge-testnet.

After merge

  1. Deploy spec 14 and confirm commitment 0x92c95a9b… on real testnet, with the root arriving
    through GRANDPA instead of seeded by sudo.
  2. accept_source(Kusama(4009)) via sudo — AcceptedSources is empty today, so no inbound
    POST can land (InboundCount: 0). That is an extrinsic, not a release.

@nol4lej
nol4lej merged commit 20acbd5 into main Sep 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant