Repository navigation
feat(dispatch): Safe-signed dispatch in the transmitter and receiver - #135
Open
pegahcarter wants to merge 83 commits into
Open
pegahcarter wants to merge 83 commits into
pegahcarter wants to merge 83 commits into
Conversation
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
docs(deploy): re-measure layerzero-oft fork delivery gas
refactor(transmitter): one bytes-based bootstrap entry point
…igned-dispatch-108
…g' into feat/signed-dispatch-108
…g' into feat/signed-dispatch-108
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 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
keccak256(abi.encode(owner, userSalt, homeChainKey, graphHash)); plain derivation is preserved.SafeTxEvidencecontainsSignedSafeTx signedSafeTxandNestedSafeApproval[] nestedApprovals.SignedBootstrap.evidencecarries it alongside canonical creation records.SafeOwnerandSafeAuthorityRecord; managing storage stays beside its code asSafeAuthorityState/SafeOwnerState. Traversal state isSafeAuthorityGraph.TraversalState; atomic source entries areSafeBatchExecutor.SafeExecution.messaging/inboundandmessaging/outbound; verification helpers live undermessaging/signed, and shared types undersigned/types.bootstrapandregisterRootare declared directly onISignedTransmitter, which supplies the guard's selectors.Bytes.argumentsretains the Paris-compatible implementation.extractContractSignatureextracts bounds-checked bytes; recursive stored-authority verification authenticates nested Safe owners.registerRootstores 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.quoteRelayandrelayLeafwhere 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.#134 completed
Measurements and reproduction and deployment checks §8.
Validation
At
5d7be13(measurements pinned to code commitc598ef9):script/lint.pyto avoid Forge 1.8.1 Solar duplicate declarations; no lint rule is disabled.Deferred
Closes #134.