@crossecute/sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 crossecute
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md CHANGED
@@ -1,3 +1,171 @@
1
- # Temporary Holding Version
1
+ # @crossecute/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Foundry and TypeScript helpers for running calls on a Crossecute conduit's receivers. A
4
+ Foundry script runs on the conduit's home chain, simulates each batch as the receiver on a
5
+ fork of its destination, and turns the batches into one Safe transaction for the owner. It
6
+ writes that transaction as the EIP-712 typed data owners sign, which
7
+ [Forge Attest](https://github.com/crossecute/forge-attest) reproduces from the script, then
8
+ signs and proposes. The TypeScript side checks the same files and gives a frontend the
9
+ protocol's encoders.
10
+
11
+ The SDK holds no keys and sends nothing to a Safe Transaction Service. Signing and proposing
12
+ belong to Forge Attest, `cast wallet sign`, or the Safe UI.
13
+
14
+ This release covers plain mode: `sendMessage` and `bootstrap` from an owner Safe or EOA.
15
+ Safe-signed dispatch (standalone signed sends, Merkle roots, leaves and relay bundles) follows
16
+ once [crossecute/protocol#117](https://github.com/crossecute/protocol/pull/117) and
17
+ [crossecute/protocol#135](https://github.com/crossecute/protocol/pull/135) merge.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @crossecute/sdk @crossecute/protocol @openzeppelin/contracts@5.4.0 @openzeppelin/contracts-upgradeable@5.4.0
23
+ ```
24
+
25
+ Foundry remappings:
26
+
27
+ ```
28
+ @crossecute/sdk/=node_modules/@crossecute/sdk/contracts/
29
+ @crossecute/protocol/=node_modules/@crossecute/protocol/contracts/
30
+ @openzeppelin/contracts/=node_modules/@openzeppelin/contracts/
31
+ @openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/
32
+ ```
33
+
34
+ The project supplies `forge-std` itself. The sources need solc 0.8.28 or later. The SDK's
35
+ scripts never need `ffi`.
36
+
37
+ ## Foundry
38
+
39
+ Extend `CrossecuteScript` and run the script with `--fork-url` (or `--rpc-url`) set to the home
40
+ chain.
41
+ [`script/Example.s.sol`](script/Example.s.sol) is a complete script.
42
+
43
+ ```solidity
44
+ _useHome(registry, transmitter); // the --fork-url chain; verifies the conduit
45
+ uint256 optimism = _useDestination(vm.envString("OPTIMISM_RPC_URL"));
46
+
47
+ _add(optimism, calls, new bytes[](0)); // simulated as the receiver there
48
+ Plan memory plan = _plan(); // the owner's calls, run once as a check
49
+
50
+ _writeSafeTx(plan, nonce, "out/safe-tx.json"); // EIP-712 typed data for the owner Safe
51
+ _writePlan(plan, "out/plan.json"); // batches and calls, for `crossecute check`
52
+ _writeSafeBatch(plan, "out/batch.json"); // Safe Transaction Builder file
53
+ _broadcast(plan); // owner is an EOA and the script's signer
54
+ ```
55
+
56
+ What it checks:
57
+
58
+ - **The conduit.** `_useHome` reverts unless the registry's `predictTransceiver` for the
59
+ transmitter's provider on this chain is the transmitter's transceiver, and that
60
+ transceiver's `predictTransmitter(deployer, salt)` is the transmitter. Every later read
61
+ (receiver, route, quote) trusts those two contracts, so a lookalike transmitter is refused
62
+ before it can steer anything. The registry address is the trust anchor and comes from the
63
+ script. A home chain whose CREATE2 is not `Predetermined` (zkSync, Tron) has no registry
64
+ prediction and is refused.
65
+ - **The receiver.** Once bootstrapped, the receiver address comes from the transmitter's
66
+ record. Before that, it comes from the home transceiver's `predictReceiver`. The SDK never
67
+ derives it locally, because `Proxy`'s initcode hash depends on the compiler settings. A
68
+ destination that reports its receiver back has no address until the report arrives, and
69
+ `_add` reverts with `ReceiverNotPredictable`.
70
+ - **Simulation.** Each call runs from the receiver's address on the destination fork, in order,
71
+ and later batches build on earlier ones. A call that reverts stops the script with
72
+ `CallReverted(chainId, index, reason)`. If the receiver holds less than the batch's total
73
+ value, `_add` reverts: a send cannot fund the receiver. Where the receiver is not deployed
74
+ yet, a call back into it meets no code, so a callback the real receiver would refuse
75
+ succeeds in simulation.
76
+ - **Gas.** With empty attributes a batch gets the provider's default delivery gas
77
+ (`DeliveryGas`). The script logs a warning when the calls alone use more than that default.
78
+ The default also pays the receiver's own overhead, so this is a floor, not a guarantee.
79
+ - **Bootstrap or send.** The first batch to a destination with no receiver becomes its
80
+ `bootstrap`; later batches become `sendMessage`.
81
+ - **Fees.** The transmitter pays fees from its own balance. `_plan` quotes every call in order
82
+ and sets `calls[0].value` to top the balance up to the total plus `feeBufferBps` (10% by
83
+ default). Anything left over stays in the transmitter for later sends.
84
+ - **The home calls.** `_plan` runs them as the owner on the home fork, then restores the fork.
85
+ - **The Safe transaction.** One call goes as a direct `CALL`; several go through the
86
+ MultiSendCallOnly for the Safe's `VERSION()`, taken from a table of known deployments
87
+ (1.3.0, 1.4.1, 1.5.0) and used only if its code hash matches on the home chain. The nonce
88
+ must not be below the Safe's. The SDK computes the `safeTxHash` itself and reverts unless the
89
+ Safe's own `getTransactionHash` agrees. It logs the domain hash, message hash and
90
+ `safeTxHash`.
91
+
92
+ ## Forge Attest
93
+
94
+ `_writeSafeTx` writes Forge Attest's `safe-eip712` input. Forge Attest runs the script with
95
+ no arguments, so a script it reproduces reads its inputs from the environment, as the example
96
+ does. The file depends only on the plan,
97
+ the nonce and the home chain's state, so Forge Attest reproduces it byte for byte when the
98
+ claim pins the home block. Destination forks affect only the simulation verdict, not the
99
+ file; pass a block to `_useDestination` to make that repeatable too.
100
+
101
+ ```toml
102
+ producer_repo = "https://github.com/you/your-ops-repo"
103
+ producer_commit = "<40-hex commit sha>"
104
+ producer_setup = "npm ci"
105
+
106
+ [[run]]
107
+ script = "script/Example.s.sol:Example"
108
+ rpc_env = "MAINNET_RPC_URL"
109
+ fork_block = "<home block>"
110
+ unclaimed = ["out/example-plan.json"]
111
+
112
+ [run.env]
113
+ CROSSECUTE_REGISTRY = "0x<registry>"
114
+ CROSSECUTE_TRANSMITTER = "0x<transmitter>"
115
+ OPTIMISM_TARGET = "0x<target on Optimism>"
116
+ BASE_TARGET = "0x<target on Base>"
117
+ NEW_OWNER = "0x<new owner>"
118
+ SAFE_NONCE = "<nonce>"
119
+
120
+ [run.defaults]
121
+ input_format = "safe-eip712"
122
+ safe_version = "1.4.1"
123
+
124
+ [[run.claim]]
125
+ output_path = "out/example-safe-tx.json"
126
+ safe_network = "ethereum"
127
+ safe_address = "0x<owner Safe>"
128
+ safe_nonce = "<nonce>"
129
+ expected_output_sha256 = "<sha256 of the file>"
130
+ expected_safe_tx_hash = "0x<safeTxHash>"
131
+ ```
132
+
133
+ Destination RPC URLs (`OPTIMISM_RPC_URL`, `BASE_RPC_URL` in the example) come from the
134
+ environment `attest.sh` runs in. Each owner then signs and proposes or confirms with
135
+ `./attest.sh --config attest.toml --attest -- --ledger`, which never sees the key. Check the
136
+ claim fields against the Forge Attest version you pin; this example follows v1.4.0.
137
+
138
+ `npm run test:attest` runs Forge Attest v1.4.0's own reader and hash derivation over the
139
+ typed data the tests write, and checks the hash matches the SDK's.
140
+
141
+ ## TypeScript
142
+
143
+ ```ts
144
+ import { parsePlan, checkPlan, parseSafeTxTypedData, checkSafeTx, safeTxHashes, encodeEvm, encodeCalls, chainKey } from "@crossecute/sdk";
145
+ ```
146
+
147
+ `checkPlan` re-derives each home call from its batch and throws if the calldata differs.
148
+ `checkSafeTx` throws unless the typed data is the one Safe transaction that carries the plan:
149
+ the owner Safe's domain on the home chain, no gas refund, and the plan's call directly or all
150
+ of its calls through a known MultiSendCallOnly.
151
+
152
+ ```bash
153
+ npx crossecute check out/plan.json out/safe-tx.json
154
+ ```
155
+
156
+ prints the batches, then the domain hash, message hash and `safeTxHash` a signer's device
157
+ shows. The only runtime dependency besides `@crossecute/protocol` is viem.
158
+
159
+ ## Development
160
+
161
+ ```bash
162
+ npm install
163
+ npm test # forge test, then vitest
164
+ npm run typecheck
165
+ npm run test:attest # needs network access, jq and cast
166
+ ```
167
+
168
+ `test/Vectors.t.sol` writes `vectors/` from the protocol's own Solidity encoders and the
169
+ SDK's MultiSendCallOnly table, and the TypeScript tests check their copies against it. The
170
+ TypeScript tests also read files the Foundry tests write under `out/`, so run `forge test`
171
+ first.
@@ -0,0 +1,378 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {Script} from "forge-std/Script.sol";
5
+ import {console2} from "forge-std/console2.sol";
6
+ import {Call} from "@crossecute/protocol/messaging/Call.sol";
7
+ import {Payload} from "@crossecute/protocol/messaging/Payload.sol";
8
+ import {DeliveryGas} from "@crossecute/protocol/messaging/DeliveryGas.sol";
9
+ import {ChainKey} from "@crossecute/protocol/addressing/ChainKey.sol";
10
+ import {Erc7930} from "@crossecute/protocol/addressing/Erc7930.sol";
11
+ import {IConduitTransceiver} from "@crossecute/protocol/messaging/outbound/TransmitterBase.sol";
12
+ import {OwnableTransmitter} from "@crossecute/protocol/messaging/outbound/OwnableTransmitter.sol";
13
+ import {TransceiverExtension} from "@crossecute/protocol/messaging/transceiver/TransceiverExtension.sol";
14
+ import {ChainRegistry} from "@crossecute/protocol/registry/ChainRegistry.sol";
15
+ import {HomeCall, Batch, Plan, SafeTx} from "./Types.sol";
16
+ import {PlanJson} from "./PlanJson.sol";
17
+ import {ISafe} from "./ISafe.sol";
18
+ import {SafeTxHash} from "./SafeTxHash.sol";
19
+
20
+ /// @title CrossecuteScript
21
+ /// @notice Base for scripts that run calls on a conduit's receivers. A script runs on the home
22
+ /// chain (`--fork-url`), forks each destination, adds batches (each simulated as the receiver on its
23
+ /// destination fork), then turns them into the owner's home-chain calls and hands those
24
+ /// to a sink: a Safe Transaction Builder file, a plan file, the Safe transaction's
25
+ /// EIP-712 typed data, or a wallet broadcast.
26
+ ///
27
+ /// @dev Receiver addresses come from the deployed transmitter and transceiver, never from a
28
+ /// local `ProxyInitCode.hash()`, which depends on this project's compiler settings.
29
+ ///
30
+ /// @dev Simulation runs each call from the receiver's address. Where the receiver is not yet
31
+ /// deployed, a call back into it meets no code, so callbacks it would refuse succeed here.
32
+ abstract contract CrossecuteScript is Script {
33
+ OwnableTransmitter internal transmitter;
34
+
35
+ /// Headroom on the quoted fee for price movement before execution. The excess stays in the
36
+ /// transmitter and pays later sends.
37
+ uint256 internal feeBufferBps = 1000;
38
+
39
+ uint256 private _homeFork;
40
+ mapping(uint256 chainId => uint256 forkPlusOne) private _destinationForks;
41
+ mapping(uint256 chainId => bool) private _bootstrapQueued;
42
+ Batch[] private _batches;
43
+
44
+ error UnknownDestination(uint256 chainId);
45
+ error ReceiverNotPredictable(uint256 chainId);
46
+ error MalformedReceiver(uint256 chainId, bytes receiver);
47
+ error ReceiverUnderfunded(uint256 chainId, address receiver, uint256 needed, uint256 balance);
48
+ error CallReverted(uint256 chainId, uint256 index, bytes reason);
49
+ error HomeCallReverted(uint256 index, bytes reason);
50
+ error OwnerIsContract(address owner);
51
+ error NothingPlanned();
52
+ error NotAConduit(address transmitter);
53
+ error TransceiverNotRegistered(address transceiver, address expected);
54
+ error TransmitterNotFromTransceiver(address transmitter, address expected);
55
+ error SafeNonceUsed(uint256 nonce, uint256 current);
56
+ error SafeTxHashMismatch(bytes32 safe, bytes32 local);
57
+ error UnsupportedSafeVersion(string version);
58
+ error NoMultiSendCallOnly(string version);
59
+
60
+ /// `multiSend(bytes)`.
61
+ bytes4 internal constant MULTI_SEND = 0x8d80ff0a;
62
+
63
+ /* ================================== forks ================================== */
64
+
65
+ /// @notice Takes the chain the script runs on (`--fork-url`, which Forge Attest pins with
66
+ /// `fork_block`) as the conduit's home, and checks `transmitter_` descends from
67
+ /// `registry`. Call before `_useDestination`.
68
+ function _useHome(address registry, address transmitter_) internal {
69
+ _homeFork = vm.activeFork();
70
+ transmitter = OwnableTransmitter(payable(transmitter_));
71
+ _verifyConduit(registry);
72
+ }
73
+
74
+ function _useDestination(string memory rpcUrl) internal returns (uint256 chainId) {
75
+ return _registerDestination(vm.createSelectFork(rpcUrl));
76
+ }
77
+
78
+ /// @notice A destination fork at a fixed block, so a re-run simulates against the same state.
79
+ function _useDestination(string memory rpcUrl, uint256 blockNumber) internal returns (uint256 chainId) {
80
+ return _registerDestination(vm.createSelectFork(rpcUrl, blockNumber));
81
+ }
82
+
83
+ function _registerDestination(uint256 fork) private returns (uint256 chainId) {
84
+ chainId = block.chainid;
85
+ _destinationForks[chainId] = fork + 1;
86
+ _selectHome();
87
+ }
88
+
89
+ /// @dev Virtual so a test can run every chain in one EVM.
90
+ function _selectHome() internal virtual {
91
+ vm.selectFork(_homeFork);
92
+ }
93
+
94
+ function _selectDestination(uint256 chainId) internal virtual {
95
+ uint256 fork = _destinationForks[chainId];
96
+ if (fork == 0) revert UnknownDestination(chainId);
97
+ vm.selectFork(fork - 1);
98
+ }
99
+
100
+ /* ================================= conduit ================================= */
101
+
102
+ /// @notice Reverts unless the registry places the transmitter's transceiver for its provider
103
+ /// on this chain, and that transceiver's CREATE2 places the transmitter.
104
+ /// @dev Every later read (receiver, route, quote) trusts the transmitter and transceiver, so
105
+ /// a lookalike transmitter could otherwise steer simulation and planning. The provider
106
+ /// and the CREATE2 inputs are read from the contracts being checked, which is sound
107
+ /// because each is bound by an address derived from the trusted registry. A home whose
108
+ /// CREATE2 is not `Predetermined` (zkSync, Tron) has no registry prediction and fails here.
109
+ function _verifyConduit(address registry) internal virtual {
110
+ _selectHome();
111
+ if (address(transmitter).code.length == 0) revert NotAConduit(address(transmitter));
112
+ TransceiverExtension transceiver = TransceiverExtension(payable(transmitter.transceiver()));
113
+ address expected =
114
+ ChainRegistry(registry).predictTransceiver(ChainKey.forEvm(block.chainid), transceiver.messageProvider());
115
+ if (address(transceiver) != expected) revert TransceiverNotRegistered(address(transceiver), expected);
116
+ expected = transceiver.predictTransmitter(transmitter.deployer(), transmitter.conduitSalt());
117
+ if (address(transmitter) != expected) revert TransmitterNotFromTransceiver(address(transmitter), expected);
118
+ }
119
+
120
+ /* ================================ receivers ================================ */
121
+
122
+ /// @notice The conduit's receiver on an EVM destination: the recorded one once bootstrapped,
123
+ /// otherwise the home transceiver's prediction.
124
+ function _receiverOn(uint256 chainId) internal returns (address) {
125
+ _selectHome();
126
+ bytes32 key = ChainKey.forEvm(chainId);
127
+ if (transmitter.isBootstrapped(key)) {
128
+ bytes memory recorded = transmitter.destinationReceiverOn(key);
129
+ // A reporting destination has no receiver until its report arrives home.
130
+ if (recorded.length == 0) revert ReceiverNotPredictable(chainId);
131
+ return _toAddress(chainId, recorded);
132
+ }
133
+ IConduitTransceiver transceiver = IConduitTransceiver(transmitter.transceiver());
134
+ if (transceiver.reportsReceiver(key)) revert ReceiverNotPredictable(chainId);
135
+ return _toAddress(chainId, transceiver.predictReceiver(key, transmitter.deployer(), transmitter.conduitSalt()));
136
+ }
137
+
138
+ function _toAddress(uint256 chainId, bytes memory raw) private pure returns (address) {
139
+ if (raw.length != 20) revert MalformedReceiver(chainId, raw);
140
+ return address(bytes20(raw));
141
+ }
142
+
143
+ /* ================================= batches ================================= */
144
+
145
+ /// @notice Simulate `calls` as the receiver on `chainId` and queue them. Batches to one
146
+ /// destination run in the order added; the first to an unbootstrapped destination
147
+ /// becomes its bootstrap. Empty `attributes` take the provider's default gas.
148
+ /// @dev The destination fork keeps the batch's effects, so a later batch simulates on top.
149
+ function _add(uint256 chainId, Call[] memory calls, bytes[] memory attributes) internal returns (uint256 gasUsed) {
150
+ address receiver = _receiverOn(chainId);
151
+ bool bootstrap = !transmitter.isBootstrapped(ChainKey.forEvm(chainId)) && !_bootstrapQueued[chainId];
152
+ if (bootstrap) _bootstrapQueued[chainId] = true;
153
+
154
+ gasUsed = _simulate(chainId, receiver, calls);
155
+
156
+ Batch storage b = _batches.push();
157
+ b.chainId = chainId;
158
+ b.receiver = receiver;
159
+ b.attributes = attributes;
160
+ b.gasUsed = gasUsed;
161
+ b.bootstrap = bootstrap;
162
+ for (uint256 i; i < calls.length; ++i) {
163
+ b.calls.push(calls[i]);
164
+ }
165
+
166
+ if (attributes.length == 0) {
167
+ uint256 limit = bootstrap ? DeliveryGas.BOOTSTRAP : DeliveryGas.PAYLOAD;
168
+ // The default also covers receiver creation or dispatch, so calls under it can still
169
+ // run short.
170
+ if (gasUsed > limit) {
171
+ console2.log(
172
+ "calls on chain %s use %s gas, over the default %s: pass a gas attribute", chainId, gasUsed, limit
173
+ );
174
+ }
175
+ }
176
+ }
177
+
178
+ function _simulate(uint256 chainId, address receiver, Call[] memory calls) internal returns (uint256 gasUsed) {
179
+ _selectDestination(chainId);
180
+ uint256 needed;
181
+ for (uint256 i; i < calls.length; ++i) {
182
+ needed += calls[i].value;
183
+ }
184
+ if (receiver.balance < needed) revert ReceiverUnderfunded(chainId, receiver, needed, receiver.balance);
185
+ if (receiver.code.length == 0) {
186
+ console2.log(
187
+ "receiver %s on chain %s is not deployed: callbacks into it are not simulated", receiver, chainId
188
+ );
189
+ }
190
+
191
+ for (uint256 i; i < calls.length; ++i) {
192
+ vm.prank(receiver);
193
+ uint256 before = gasleft();
194
+ (bool ok, bytes memory reason) = calls[i].target.call{value: calls[i].value}(calls[i].data);
195
+ gasUsed += before - gasleft();
196
+ if (!ok) revert CallReverted(chainId, i, reason);
197
+ }
198
+ }
199
+
200
+ /* =================================== plan ================================== */
201
+
202
+ /// @notice The owner's home-chain calls for every queued batch, checked by running them as
203
+ /// the owner on the home fork. Leaves the home fork as it found it.
204
+ function _plan() internal returns (Plan memory plan) {
205
+ if (_batches.length == 0) revert NothingPlanned();
206
+ _selectHome();
207
+ plan.homeChainId = block.chainid;
208
+ plan.transmitter = address(transmitter);
209
+ plan.owner = transmitter.owner();
210
+ plan.batches = _batches;
211
+ plan.calls = new HomeCall[](_batches.length);
212
+
213
+ // Quotes in order, each after the calls before it, since a bootstrap changes what follows.
214
+ uint256 snapshot = vm.snapshotState();
215
+ vm.deal(address(transmitter), type(uint128).max);
216
+ for (uint256 i; i < plan.batches.length; ++i) {
217
+ uint256 fee;
218
+ (plan.calls[i], fee) = _homeCallFor(plan.batches[i]);
219
+ plan.fee += fee;
220
+ _runAsOwner(plan.owner, plan.calls[i], i);
221
+ }
222
+ vm.revertToStateAndDelete(snapshot);
223
+
224
+ uint256 needed = plan.fee + plan.fee * feeBufferBps / 10_000;
225
+ uint256 balance = address(transmitter).balance;
226
+ if (needed > balance) plan.calls[0].value = needed - balance;
227
+
228
+ snapshot = vm.snapshotState();
229
+ if (plan.owner.balance < plan.calls[0].value) {
230
+ console2.log("owner %s needs %s wei to top up the transmitter", plan.owner, plan.calls[0].value);
231
+ vm.deal(plan.owner, plan.calls[0].value);
232
+ }
233
+ for (uint256 i; i < plan.calls.length; ++i) {
234
+ _runAsOwner(plan.owner, plan.calls[i], i);
235
+ }
236
+ vm.revertToStateAndDelete(snapshot);
237
+ }
238
+
239
+ function _homeCallFor(Batch memory b) private view returns (HomeCall memory call, uint256 fee) {
240
+ bytes memory body = Payload.encodeCalls(b.calls);
241
+ call.to = address(transmitter);
242
+ if (transmitter.isBootstrapped(ChainKey.forEvm(b.chainId))) {
243
+ bytes memory recipient = transmitter.recipientOn(b.chainId);
244
+ call.data = abi.encodeCall(transmitter.sendMessage, (recipient, body, b.attributes));
245
+ fee = transmitter.quoteMessage(recipient, body, b.attributes);
246
+ } else {
247
+ bytes memory payload = Payload.encodeBootstrap(Erc7930.encodeEvmChain(b.chainId), body);
248
+ call.data = abi.encodeCall(transmitter.bootstrap, (payload, b.attributes));
249
+ fee = transmitter.quoteBootstrap(payload, b.attributes);
250
+ }
251
+ }
252
+
253
+ function _runAsOwner(address owner, HomeCall memory call, uint256 index) private {
254
+ vm.prank(owner);
255
+ (bool ok, bytes memory reason) = call.to.call{value: call.value}(call.data);
256
+ if (!ok) revert HomeCallReverted(index, reason);
257
+ }
258
+
259
+ /* =================================== sinks ================================= */
260
+
261
+ /// @notice A Safe Transaction Builder file for the owner Safe.
262
+ function _writeSafeBatch(Plan memory plan, string memory path) internal {
263
+ vm.writeFile(path, PlanJson.safeBatch(plan));
264
+ }
265
+
266
+ /// @notice The plan file the TypeScript SDK reads and `crossecute check` verifies.
267
+ function _writePlan(Plan memory plan, string memory path) internal {
268
+ vm.writeFile(path, PlanJson.planFile(plan));
269
+ }
270
+
271
+ /// @notice The plan as one Safe transaction at `nonce`, written as the EIP-712 typed data
272
+ /// owners sign: Forge Attest's `safe-eip712` input, and what
273
+ /// `cast wallet sign --data --from-file` signs.
274
+ /// @dev The bytes depend only on the plan, the nonce and the home fork's state, so a re-run
275
+ /// at the same home block and nonce writes the same file.
276
+ function _writeSafeTx(Plan memory plan, uint256 nonce, string memory path) internal returns (bytes32 safeTxHash) {
277
+ SafeTx memory t;
278
+ (t, safeTxHash) = _safeTx(plan, nonce);
279
+ vm.writeFile(path, PlanJson.safeTxTypedData(plan.homeChainId, plan.owner, t));
280
+ }
281
+
282
+ /// @notice The one Safe transaction carrying `plan`: its call directly, or several through
283
+ /// MultiSendCallOnly. Checked against the owner Safe's own `getTransactionHash`.
284
+ function _safeTx(Plan memory plan, uint256 nonce) internal returns (SafeTx memory t, bytes32 safeTxHash) {
285
+ _selectHome();
286
+ ISafe safe = ISafe(plan.owner);
287
+ uint256 current = safe.nonce();
288
+ if (nonce < current) revert SafeNonceUsed(nonce, current);
289
+
290
+ t.nonce = nonce;
291
+ if (plan.calls.length == 1) {
292
+ t.to = plan.calls[0].to;
293
+ t.value = plan.calls[0].value;
294
+ t.data = plan.calls[0].data;
295
+ } else {
296
+ t.to = _multiSendCallOnly(safe.VERSION());
297
+ t.operation = 1;
298
+ t.data = abi.encodeWithSelector(MULTI_SEND, _multiSendPacked(plan.calls));
299
+ }
300
+
301
+ safeTxHash = SafeTxHash.hash(block.chainid, plan.owner, t);
302
+ bytes32 live = safe.getTransactionHash(
303
+ t.to,
304
+ t.value,
305
+ t.data,
306
+ t.operation,
307
+ t.safeTxGas,
308
+ t.baseGas,
309
+ t.gasPrice,
310
+ t.gasToken,
311
+ t.refundReceiver,
312
+ t.nonce
313
+ );
314
+ if (live != safeTxHash) revert SafeTxHashMismatch(live, safeTxHash);
315
+
316
+ console2.log("Safe %s on chain %s, nonce %s", plan.owner, block.chainid, nonce);
317
+ console2.log("domain hash: %s", vm.toString(SafeTxHash.domainSeparator(block.chainid, plan.owner)));
318
+ console2.log("message hash: %s", vm.toString(SafeTxHash.structHash(t)));
319
+ console2.log("safeTxHash: %s", vm.toString(safeTxHash));
320
+ }
321
+
322
+ /// @notice The MultiSendCallOnly a Safe of `version` uses on this chain: the first known
323
+ /// deployment whose code matches.
324
+ function _multiSendCallOnly(string memory version) internal view returns (address) {
325
+ (address[] memory candidates, bytes32 codeHash) = _knownMultiSendCallOnly(version);
326
+ for (uint256 i; i < candidates.length; ++i) {
327
+ if (candidates[i].codehash == codeHash) return candidates[i];
328
+ }
329
+ revert NoMultiSendCallOnly(version);
330
+ }
331
+
332
+ /// @dev From @safe-global/safe-deployments 1.37.67 (`multi_send_call_only.json`), canonical
333
+ /// and eip155 deployments. Virtual so a test can name a deployment it etched.
334
+ function _knownMultiSendCallOnly(string memory version)
335
+ internal
336
+ view
337
+ virtual
338
+ returns (address[] memory candidates, bytes32 codeHash)
339
+ {
340
+ bytes32 v = keccak256(bytes(version));
341
+ if (v == keccak256("1.3.0")) {
342
+ candidates = new address[](2);
343
+ candidates[0] = 0x40A2aCCbd92BCA938b02010E17A5b8929b49130D;
344
+ candidates[1] = 0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B;
345
+ codeHash = 0xa9865ac2d9c7a1591619b188c4d88167b50df6cc0c5327fcbd1c8c75f7c066ad;
346
+ } else if (v == keccak256("1.4.1")) {
347
+ candidates = new address[](1);
348
+ candidates[0] = 0x9641d764fc13c8B624c04430C7356C1C7C8102e2;
349
+ codeHash = 0xecd5bd14a08c5d2122379900b2f272bdf107a7e92423c10dd5fe3254386c9939;
350
+ } else if (v == keccak256("1.5.0")) {
351
+ candidates = new address[](1);
352
+ candidates[0] = 0xA83c336B20401Af773B6219BA5027174338D1836;
353
+ codeHash = 0xcdbdcec38d2f1c7d961b0029ff8416b7e86e9974d6f0e9c9580c7d17fcfb6663;
354
+ } else {
355
+ revert UnsupportedSafeVersion(version);
356
+ }
357
+ }
358
+
359
+ /// @dev MultiSend's packed transactions: operation, to, value, data length, data.
360
+ function _multiSendPacked(HomeCall[] memory calls) private pure returns (bytes memory packed) {
361
+ for (uint256 i; i < calls.length; ++i) {
362
+ packed =
363
+ abi.encodePacked(packed, uint8(0), calls[i].to, calls[i].value, calls[i].data.length, calls[i].data);
364
+ }
365
+ }
366
+
367
+ /// @notice Sends the plan from an EOA owner with the script's signer.
368
+ function _broadcast(Plan memory plan) internal {
369
+ _selectHome();
370
+ if (plan.owner.code.length != 0) revert OwnerIsContract(plan.owner);
371
+ vm.startBroadcast(plan.owner);
372
+ for (uint256 i; i < plan.calls.length; ++i) {
373
+ (bool ok, bytes memory reason) = plan.calls[i].to.call{value: plan.calls[i].value}(plan.calls[i].data);
374
+ if (!ok) revert HomeCallReverted(i, reason);
375
+ }
376
+ vm.stopBroadcast();
377
+ }
378
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ /// @notice The Safe views a plan reads.
5
+ interface ISafe {
6
+ function VERSION() external view returns (string memory);
7
+
8
+ function nonce() external view returns (uint256);
9
+
10
+ function getTransactionHash(
11
+ address to,
12
+ uint256 value,
13
+ bytes calldata data,
14
+ uint8 operation,
15
+ uint256 safeTxGas,
16
+ uint256 baseGas,
17
+ uint256 gasPrice,
18
+ address gasToken,
19
+ address refundReceiver,
20
+ uint256 nonce
21
+ ) external view returns (bytes32);
22
+ }