Skip to content

feat(dispatch): Safe-signed dispatch in the transmitter and receiver - #135

Open
pegahcarter wants to merge 83 commits into
feat/safe-guard-107from
feat/signed-dispatch-108
Open

pegahcarter wants to merge 83 commits into
feat/safe-guard-107from
feat/signed-dispatch-108

Conversation

@pegahcarter

@pegahcarter pegahcarter commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Delivers #108 and includes the gas, payload sizing and real-Safe integration work from #134.

Stacks on #117 (feat/safe-guard-107); this PR stays open for review and is not merged. Supersedes #119, whose history is retained through the existing merge. Authority rotation remains #109.

Result

A Safe can authorize a single-destination send, register a published Merkle root and relay its leaves independently, or bootstrap a destination receiver. Provider delivery and permissionless direct submission share signature verification and replay state. A complete destination authorization can execute before the source Safe transaction; canceling that source transaction does not invalidate the signed bundle.

The source uses the live Safe/guard, while receivers verify the retained creation authority in the source-chain Safe domain. No arbitrary ERC-1271 implementation or live source-owner query substitutes for that stored authority.

Final architecture

  • One signed-capable transmitter/receiver implementation per supported provider. Mode is fixed at creation after compatible-guard discovery.
  • Signed salt: keccak256(abi.encode(owner, userSalt, homeChainKey, graphHash)); plain derivation is preserved.
  • SafeTxEvidence contains SignedSafeTx signedSafeTx and NestedSafeApproval[] nestedApprovals. SignedBootstrap.evidence carries it alongside canonical creation records.
  • Shared authority types: SafeOwner and SafeAuthorityRecord; managing storage stays beside its code as SafeAuthorityState / SafeOwnerState. Traversal state is SafeAuthorityGraph.TraversalState; atomic source entries are SafeBatchExecutor.SafeExecution.
  • Conduit implementations/interfaces live under messaging/inbound and messaging/outbound; verification helpers live under messaging/signed, and shared types under signed/types. bootstrap and registerRoot are declared directly on ISignedTransmitter, which supplies the guard's selectors.
  • Bytes.arguments retains the Paris-compatible implementation. extractContractSignature extracts bounds-checked bytes; recursive stored-authority verification authenticates nested Safe owners.
  • registerRoot stores only the root. Relays supply signed registration evidence, prove a leaf, validate live source quorum, pay the exact quoted fee and preserve the transmitter's prefunded balance.
  • Provider refund recipients are the funding relayer for both quoteRelay and relayLeaf where supported (LayerZero, Hyperlane, Wormhole). Synchronous refunds received by the transmitter are forwarded under its relay lock; delayed refunds can go directly to the payer. Safe-funded sends retain conduit refunds. This does not make unused provider execution gas refundable.
  • Function/handler NatSpec, corresponding tests, events/errors/imports and protocol documentation reflect the final organization.

#134 completed

Measurements and reproduction and deployment checks §8.

  • Production Paris/0.8.28 receiver gas and complete wire sizes for a 2-of-3 EOA Safe, inline child, cached child approval, two nested levels, shared child, and a realistic 10-of-13 plus 5-of-7 child graph. Additional samples vary owner count, proof depth, calls, attributes and two cached approvals.
  • Source bootstrap/send/register/relay observations with real Safe 1.5.0, real guard, real transceivers/conduits and mock transport, built separately under Cancun/0.8.30/via-IR. Mock payload journaling and benchmark boundaries are explicit.
  • Real integration covers staged sends and replay; cached nested approvals; shared child domains; two-destination published-tree parity and relay; atomic nonces 42/43, destination binding and rollback; bootstrap before source execution; direct delivery without source execution; destination out-of-gas and exactly-once direct retry.
  • Provider framing, configured size limits, and LayerZero/CCIP byte-sensitive pricing are documented. The largest tested Merkle wire is 7,264 bytes. No live RPC, numeric lane fee or live cap certification is implied.
  • Inherited 200k payload / 1m bootstrap defaults are unsuitable for general signed delivery. Shared-child payload verification exceeds 200k; large signed initialization alone exceeds 1m. Operators must simulate the complete provider entry point and sign explicit gas attributes. No universal safe constant, protocol cap or gas floor is introduced.

Validation

At 5d7be13 (measurements pinned to code commit c598ef9):

  • EVM: 1,128 passed, 0 failed, 3 RPC-dependent fork tests skipped.
  • Safe guard: 156 passed, 0 failed.
  • Local coverage run: 1,087 passed, 0 failed, 27 skipped under the unoptimized coverage build.
  • Formatting, both lint profiles, production contract-size checks and selector checks pass (12 transceivers, 3 extensions, no clashes).
  • Guard CI canonicalizes external import paths with script/lint.py to avoid Forge 1.8.1 Solar duplicate declarations; no lint rule is disabled.
  • All production contracts fit EIP-170. Transmitter headroom: CCIP 535 bytes, LayerZero 642, Hyperlane 944.
  • Refund regressions cover recipient-sensitive quote/send parity, delayed refunds, direct refund callbacks/reentrancy, excess payment and failed refund rollback.

Deferred

  • No zkSync/Tron signed support; no work on signed receivers for diverging chains.
  • Signed LayerZero OFT, authority updates/rotation (Design ownership changes and signer rotation #109), and versioned off-chain signing/indexing tooling remain separate.
  • Live provider gas/fee/size verification and remaining mainnet operational checks remain deployment prerequisites.

Closes #134.

Records the decisions, the conflicts that block the rest, and the open
questions for #108, with proposed answers and the receiver and
transmitter check order. Replaces the draft in PR #115.
Replace bootstrap(uint256,Call[],bytes[]) and the two bootstrapTo overloads
with bootstrap(bytes payload, bytes[] attributes), and the three quotes with
quoteBootstrap(bytes, bytes[]). The payload is abi.encode(destination chain
identifier, body), built by bootstrapPayload; the body is sendMessage's wire
form for that destination. Quote and send decode, route and check the payload
through one helper, and the body is decoded in the form the destination's
chain type implies, so the typed/opaque pairing errors on path B go away.

The bootstrap hooks take the body bytes, so layerzero-oft's two overrides
cover both forms and refuse a non-EVM destination before asking the factory.
A receiver created from the payload where the conduit holds no route, quote and send parity in both body forms, the empty destination and body refusals, the payload builder, and layerzero-oft's refusal of a non-EVM destination.
The factory now builds a conduit's bootstrap message (conduitBootstrap) from
the fields it stores, and checks that the conduit's (owner, salt) derive the
caller. TransmitterBase asks its transceiver for that message on every
bootstrap: an empty answer keeps path B through the transceiver, and a message
is sent and priced through the conduit's own _sendMessage and _quoteMessage.
The conduit still calls oft.send itself, so composeFrom stays the conduit.

This removes the _quoteBootstrap and _sendBootstrap hooks and the
layerzero-oft transmitter's overrides of them. A non-EVM destination is now
refused by the factory (NotAnEvmChain).
… paths

_decodeBootstrap, the OFT factory's conduitBootstrap and LzOftTransmitter's
_messageParam each parsed the same envelope two or three times. Parse it once
and derive the key from the parsed form (ChainKey.fromParsed). The OFT
conduit now detects a bootstrap by the missing destination record, which it
reads anyway, instead of an extra read of transceiver.

Against main, a layerzero-oft path A send costs about 14,900 less gas and its
bootstrap about 18,700 more (was 38,700 more before this change).
The mock table still held the 2026-10-25 numbers. #113 made Proxy's runtime
code smaller, which cut about 182,000 gas from a bootstrap and 2,400 from a
payload; measuring each merge since then shows nothing else moved them.

The frxUSD fork rows need RPC URLs and were not re-measured, so they now say
they predate the mock rows, and the fork-against-mock comparison is dropped.
docs(deploy): re-measure layerzero-oft delivery gas after #113
Turning Payload.decodeCalls and decodeElements into memory functions made every
receiver copy its payload before decoding, about 240 gas per delivery on every
binding. Restore the calldata versions and give the transmitter, which holds
the bootstrap body in memory, decodeCallsFromMemory and
decodeElementsFromMemory. Receiver code is back to main's, and layerzero-oft
payload delivery measures 31,844 again.
docs(gas): refresh remaining delivery measurements after #113
The frxUSD fork rows were still from 2026-10-25, before #113 made Proxy's
runtime code smaller, so #122 marked them as not comparable with the mock
rows. Measured again through public RPCs on the same code as the mock rows,
which brings back the fork-against-mock comparison.
docs(deploy): re-measure layerzero-oft fork delivery gas
refactor(transmitter): one bytes-based bootstrap entry point
Both structs move above ICrossecuteGuard so importers name them directly
instead of through the interface. The ABI and the ERC-165 interface id
are unchanged.
ISignedOwnerCalls holds bootstrap(bytes,bytes[]) and registerRoot(bytes32)
without imports, so the guard reads both selectors from contracts/evm as
it already reads sendMessage's, in place of its own stand-in interfaces.
…decoding

The shared types move under messaging/signed. Leaves double-hash their
abi.encode framing and prove with sorted pairs, as the guard's
publishLeaves builds them; the execution ID omits attributes. SignedCalls
decodes signed sendMessage, registerRoot and bootstrap calldata once for
the transmitter and the receiver.
AuthorityGraph walks a Safe's owners depth first from live getOwners and
getThreshold, one record per Safe, and checks a supplied record list is
what that walk produces. NestedOwnerManager stores each Safe's owners as
Safe's own linked list. SignedMode reads whether an owner's guard
advertises ICrossecuteGuard, without reverting.
SafeAuthority reproduces Safe 1.5.0's checkNSignatures and the fallback
handler's nested check against stored authority, refusing v = 1 and
counting an empty nested signature only with a verified signMessage
approval. SafeEvidence walks the same blob under live thresholds on the
source and reads each needed approval from the child's guard.
The zkSync and Tron formulas now override _conduitAt(bytes32), the step
from a CREATE2 salt to an address, instead of _predictConduit. The salt
gains an optional graph field for a signed conduit: zero keeps the plain
three-field preimage, so every existing address is unchanged.
…otstraps

createTransmitter selects signed mode when the caller's guard advertises
ICrossecuteGuard, derives the caller's owner graph, deploys under the
four-field salt and hands the records to the transmitter. bootstrapSigned
sends a SIGNED_BOOTSTRAP envelope, and its arrival derives the transmitter
and receiver from the body's graph, then creates and initializes the
receiver in one step or checks a repeat. A diverging chain and a home this
chain cannot derive refuse it.
SignedReceiver sits between ReceiverBase and every binding's receiver but
LayerZero OFT's. initializeSigned stores the creation authority, verifies
the signed bootstrap and runs its setup. In signed mode a provider message
or a direct submitMessage or submitLeaf runs only what the stored authority
signed, with replay keyed by the Safe transaction hash for a standalone
message and by local nonce and execution ID for a leaf, and the source
transmitter's address authorizes no call.
SignedTransmitter sits between OwnableTransmitter and every binding's
transmitter but LayerZero OFT's. In signed mode sendMessage and bootstrap
consume the owner Safe's staged record and send it with nested evidence
collected from the child guards; a failed bootstrap send stays pending for
anyone to retry. registerRoot records a root published on the guard, and
relayLeaf takes the signed registration, checks it with Safe's
checkSignatures, and settles the caller-paid fee leaving the balance
unchanged. The builders are in the linked SignedTransmitterLogic to stay
under EIP-170.
SafeBatchExecutor runs separately signed Safe transactions in one outer
transaction and reverts the batch if any reverts or returns false, so the
sends and nonce advances roll back together. Tested at Safe nonces 42 and
43 with the real guard.
An independent Merkle builder checks the leaf encoding and tree shape.
The graph suite covers derivation from stub Safes, EIP-7702 owners,
cycles, missing, duplicate and unreachable records. The verifier suite
covers Safe's entry rules, v = 1, mixed ECDSA modes, inline and
approval-backed nested Safes and malformed contract signatures.
Hand-built deliveries drive the receiver through its provider and direct
submission. A real Hyperlane transceiver with a stub Safe and guard covers
mode selection, the signed salt, signed bootstrap arrival, sends, pending
and retried bootstraps, root registration and relay settlement.
The status, decisions and transmitter order follow #108's root
registration, which stores no signatures and has each relay supply the
signed registration. An Implementation section maps each part to its file
and states what the build leaves out; measurements and the end-to-end test
are #134.
The README, message flow, encoding and provider spec name signed mode and
its four-field conduit salt, the deploy README and provider spec list the
two new linked libraries, and todo.md tracks what #108 leaves out. The
provider spec also records that the governor's Safe must create its
transmitter without a compatible guard, or it lands at the signed address
and is not the owner each transceiver names.
The guard's selectors now come from ISignedOwnerCalls, the structs sit at
file level, the batch executor is listed, and the design doc states that
the protocol's signed transmitters consume the guard and its receivers
reject v = 1 entries.
fix(dispatch): audit signed ownership and registration edge cases
Wrap signed delivery evidence in SignedSafeTx and clarify struct, variable,
library, error, and event names. Separate inbound and outbound interfaces
and implementations, move shared types into signed/types, and fold owner
calls into ISignedTransmitter. Update guard selector imports, tests, and docs.

Validation: 1,114 EVM tests and 142 guard tests passed; 3 fork tests skipped.
Formatting, lint, contract size, and selector checks passed.
Explain what each function does and why it exists across the Solidity files
added in PR 135, including verification boundaries and test helpers.

Rename contractSignature to extractContractSignature and Slices to Bytes.
Use data as the Bytes.arguments parameter and extension calls through
using Bytes for bytes in SignedDispatchCalls.

Validation: 1,114 EVM tests and 142 guard tests passed; 3 fork tests skipped.
Formatting, both lint profiles, contract sizes and selector checks passed.

This branch has not been deployed

No deployments
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