@crossecute/sdk 0.0.0 → 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 +22 -0
- package/README.md +169 -1
- package/contracts/CrossecuteScript.sol +378 -0
- package/contracts/ISafe.sol +22 -0
- package/contracts/PlanJson.sol +154 -0
- package/contracts/SafeTxHash.sol +39 -0
- package/contracts/Types.sol +50 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +39 -0
- package/dist/encoding.d.ts +17 -0
- package/dist/encoding.js +48 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/plan.d.ts +37 -0
- package/dist/plan.js +92 -0
- package/dist/safeTx.d.ts +86 -0
- package/dist/safeTx.js +134 -0
- package/package.json +48 -3
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
1
|
# @crossecute/sdk
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|