@crossecute/sdk 0.0.0 → 0.1.1
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 +170 -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 +52 -3
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Vm} from "forge-std/Vm.sol";
|
|
5
|
+
import {Call} from "@crossecute/protocol/messaging/Call.sol";
|
|
6
|
+
import {HomeCall, Batch, Plan, SafeTx} from "./Types.sol";
|
|
7
|
+
|
|
8
|
+
/// @title PlanJson
|
|
9
|
+
/// @notice The two files a plan is written as. Integers are decimal strings so no reader
|
|
10
|
+
/// rounds a wei amount through a float.
|
|
11
|
+
library PlanJson {
|
|
12
|
+
Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
|
|
13
|
+
|
|
14
|
+
string internal constant PLAN_VERSION = "crossecute-plan/1";
|
|
15
|
+
|
|
16
|
+
/// @notice The Safe Transaction Builder format, bound to the owner Safe by
|
|
17
|
+
/// `meta.createdFromSafeAddress`.
|
|
18
|
+
function safeBatch(Plan memory plan) internal pure returns (string memory json) {
|
|
19
|
+
json = string.concat(
|
|
20
|
+
'{"version":"1.0","chainId":"',
|
|
21
|
+
vm.toString(plan.homeChainId),
|
|
22
|
+
'","createdAt":0,"meta":{"name":"crossecute","description":"","createdFromSafeAddress":"',
|
|
23
|
+
vm.toString(plan.owner),
|
|
24
|
+
'"},"transactions":',
|
|
25
|
+
_homeCalls(plan.calls, true),
|
|
26
|
+
"}"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function planFile(Plan memory p) internal pure returns (string memory json) {
|
|
31
|
+
json = string.concat(
|
|
32
|
+
'{"version":"',
|
|
33
|
+
PLAN_VERSION,
|
|
34
|
+
'","homeChainId":"',
|
|
35
|
+
vm.toString(p.homeChainId),
|
|
36
|
+
'","owner":"',
|
|
37
|
+
vm.toString(p.owner),
|
|
38
|
+
'","transmitter":"',
|
|
39
|
+
vm.toString(p.transmitter),
|
|
40
|
+
'","fee":"',
|
|
41
|
+
vm.toString(p.fee),
|
|
42
|
+
'","calls":',
|
|
43
|
+
_homeCalls(p.calls, false),
|
|
44
|
+
',"batches":['
|
|
45
|
+
);
|
|
46
|
+
for (uint256 i; i < p.batches.length; ++i) {
|
|
47
|
+
json = string.concat(json, i == 0 ? "" : ",", _batch(p.batches[i]));
|
|
48
|
+
}
|
|
49
|
+
json = string.concat(json, "]}");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// @notice Safe EIP-712 typed data in the field order Forge Attest's `safe-eip712` reader
|
|
53
|
+
/// requires for its default profile.
|
|
54
|
+
function safeTxTypedData(uint256 chainId, address safe, SafeTx memory t) internal pure returns (string memory) {
|
|
55
|
+
return string.concat(
|
|
56
|
+
'{"types":{"EIP712Domain":[{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],',
|
|
57
|
+
'"SafeTx":[{"name":"to","type":"address"},{"name":"value","type":"uint256"},{"name":"data","type":"bytes"},',
|
|
58
|
+
'{"name":"operation","type":"uint8"},{"name":"safeTxGas","type":"uint256"},{"name":"baseGas","type":"uint256"},',
|
|
59
|
+
'{"name":"gasPrice","type":"uint256"},{"name":"gasToken","type":"address"},{"name":"refundReceiver","type":"address"},',
|
|
60
|
+
'{"name":"nonce","type":"uint256"}]},"primaryType":"SafeTx","domain":{"chainId":"',
|
|
61
|
+
vm.toString(chainId),
|
|
62
|
+
'","verifyingContract":"',
|
|
63
|
+
vm.toString(safe),
|
|
64
|
+
'"},"message":',
|
|
65
|
+
_safeTxMessage(t),
|
|
66
|
+
"}"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _safeTxMessage(SafeTx memory t) private pure returns (string memory) {
|
|
71
|
+
return string.concat(
|
|
72
|
+
string.concat(
|
|
73
|
+
'{"to":"',
|
|
74
|
+
vm.toString(t.to),
|
|
75
|
+
'","value":"',
|
|
76
|
+
vm.toString(t.value),
|
|
77
|
+
'","data":"',
|
|
78
|
+
vm.toString(t.data),
|
|
79
|
+
'","operation":"',
|
|
80
|
+
vm.toString(t.operation),
|
|
81
|
+
'","safeTxGas":"',
|
|
82
|
+
vm.toString(t.safeTxGas)
|
|
83
|
+
),
|
|
84
|
+
string.concat(
|
|
85
|
+
'","baseGas":"',
|
|
86
|
+
vm.toString(t.baseGas),
|
|
87
|
+
'","gasPrice":"',
|
|
88
|
+
vm.toString(t.gasPrice),
|
|
89
|
+
'","gasToken":"',
|
|
90
|
+
vm.toString(t.gasToken),
|
|
91
|
+
'","refundReceiver":"',
|
|
92
|
+
vm.toString(t.refundReceiver),
|
|
93
|
+
'","nonce":"',
|
|
94
|
+
vm.toString(t.nonce),
|
|
95
|
+
'"}'
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// @param builder Adds the Transaction Builder's `operation` field.
|
|
101
|
+
function _homeCalls(HomeCall[] memory calls, bool builder) private pure returns (string memory json) {
|
|
102
|
+
json = "[";
|
|
103
|
+
for (uint256 i; i < calls.length; ++i) {
|
|
104
|
+
json = string.concat(
|
|
105
|
+
json,
|
|
106
|
+
i == 0 ? "" : ",",
|
|
107
|
+
'{"to":"',
|
|
108
|
+
vm.toString(calls[i].to),
|
|
109
|
+
'","value":"',
|
|
110
|
+
vm.toString(calls[i].value),
|
|
111
|
+
'","data":"',
|
|
112
|
+
vm.toString(calls[i].data),
|
|
113
|
+
builder ? '","operation":"0"}' : '"}'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
json = string.concat(json, "]");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _batch(Batch memory b) private pure returns (string memory json) {
|
|
120
|
+
json = string.concat(
|
|
121
|
+
'{"chainId":"',
|
|
122
|
+
vm.toString(b.chainId),
|
|
123
|
+
'","receiver":"',
|
|
124
|
+
vm.toString(b.receiver),
|
|
125
|
+
'","bootstrap":',
|
|
126
|
+
b.bootstrap ? "true" : "false",
|
|
127
|
+
',"gasUsed":"',
|
|
128
|
+
vm.toString(b.gasUsed),
|
|
129
|
+
'","attributes":['
|
|
130
|
+
);
|
|
131
|
+
for (uint256 i; i < b.attributes.length; ++i) {
|
|
132
|
+
json = string.concat(json, i == 0 ? '"' : ',"', vm.toString(b.attributes[i]), '"');
|
|
133
|
+
}
|
|
134
|
+
json = string.concat(json, '],"calls":', _calls(b.calls), "}");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _calls(Call[] memory calls) private pure returns (string memory json) {
|
|
138
|
+
json = "[";
|
|
139
|
+
for (uint256 i; i < calls.length; ++i) {
|
|
140
|
+
json = string.concat(
|
|
141
|
+
json,
|
|
142
|
+
i == 0 ? "" : ",",
|
|
143
|
+
'{"target":"',
|
|
144
|
+
vm.toString(calls[i].target),
|
|
145
|
+
'","value":"',
|
|
146
|
+
vm.toString(calls[i].value),
|
|
147
|
+
'","data":"',
|
|
148
|
+
vm.toString(calls[i].data),
|
|
149
|
+
'"}'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
json = string.concat(json, "]");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.28;
|
|
3
|
+
|
|
4
|
+
import {SafeTx} from "./Types.sol";
|
|
5
|
+
|
|
6
|
+
/// @notice Safe's EIP-712 hashes for Safe 1.3.0 and later, whose domain is the chain id and the
|
|
7
|
+
/// Safe's address.
|
|
8
|
+
library SafeTxHash {
|
|
9
|
+
bytes32 internal constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(uint256 chainId,address verifyingContract)");
|
|
10
|
+
bytes32 internal constant SAFE_TX_TYPEHASH = keccak256(
|
|
11
|
+
"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
function domainSeparator(uint256 chainId, address safe) internal pure returns (bytes32) {
|
|
15
|
+
return keccak256(abi.encode(DOMAIN_TYPEHASH, chainId, safe));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function structHash(SafeTx memory t) internal pure returns (bytes32) {
|
|
19
|
+
return keccak256(
|
|
20
|
+
abi.encode(
|
|
21
|
+
SAFE_TX_TYPEHASH,
|
|
22
|
+
t.to,
|
|
23
|
+
t.value,
|
|
24
|
+
keccak256(t.data),
|
|
25
|
+
t.operation,
|
|
26
|
+
t.safeTxGas,
|
|
27
|
+
t.baseGas,
|
|
28
|
+
t.gasPrice,
|
|
29
|
+
t.gasToken,
|
|
30
|
+
t.refundReceiver,
|
|
31
|
+
t.nonce
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hash(uint256 chainId, address safe, SafeTx memory t) internal pure returns (bytes32) {
|
|
37
|
+
return keccak256(abi.encodePacked(hex"1901", domainSeparator(chainId, safe), structHash(t)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Call} from "@crossecute/protocol/messaging/Call.sol";
|
|
5
|
+
|
|
6
|
+
/// @notice A call the conduit's owner makes on the home chain.
|
|
7
|
+
struct HomeCall {
|
|
8
|
+
address to;
|
|
9
|
+
uint256 value;
|
|
10
|
+
bytes data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// @notice One ordered batch for the conduit's receiver on one EVM destination, as simulated.
|
|
14
|
+
struct Batch {
|
|
15
|
+
uint256 chainId;
|
|
16
|
+
address receiver;
|
|
17
|
+
Call[] calls;
|
|
18
|
+
bytes[] attributes;
|
|
19
|
+
/// Gas the calls used in simulation, excluding the receiver's own delivery overhead.
|
|
20
|
+
uint256 gasUsed;
|
|
21
|
+
/// Whether the batch is sent as the destination's bootstrap rather than a message.
|
|
22
|
+
bool bootstrap;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// @notice What the owner signs or sends on the home chain, and the batches it carries.
|
|
26
|
+
struct Plan {
|
|
27
|
+
uint256 homeChainId;
|
|
28
|
+
address owner;
|
|
29
|
+
address transmitter;
|
|
30
|
+
/// The quoted total of every call's fee. `calls[0].value` tops the transmitter up to cover
|
|
31
|
+
/// it with the script's buffer.
|
|
32
|
+
uint256 fee;
|
|
33
|
+
HomeCall[] calls;
|
|
34
|
+
Batch[] batches;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// @notice Safe's EIP-712 `SafeTx`, in its field order. `operation` is 0 for CALL, 1 for
|
|
38
|
+
/// DELEGATECALL.
|
|
39
|
+
struct SafeTx {
|
|
40
|
+
address to;
|
|
41
|
+
uint256 value;
|
|
42
|
+
bytes data;
|
|
43
|
+
uint8 operation;
|
|
44
|
+
uint256 safeTxGas;
|
|
45
|
+
uint256 baseGas;
|
|
46
|
+
uint256 gasPrice;
|
|
47
|
+
address gasToken;
|
|
48
|
+
address refundReceiver;
|
|
49
|
+
uint256 nonce;
|
|
50
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { formatEther } from "viem";
|
|
4
|
+
import { checkPlan, parsePlan } from "./plan.js";
|
|
5
|
+
import { checkSafeTx, parseSafeTxTypedData, safeTxHashes } from "./safeTx.js";
|
|
6
|
+
const usage = `usage: crossecute check <plan.json> [safe-tx.json]
|
|
7
|
+
|
|
8
|
+
Checks the plan's calls against its batches and, given the Safe transaction's typed data,
|
|
9
|
+
that it carries exactly those calls. Prints the hashes a signer's device shows.
|
|
10
|
+
|
|
11
|
+
The SDK holds no keys and sends nothing. Sign and propose with Forge Attest
|
|
12
|
+
(./attest.sh --attest -- --ledger) or cast wallet sign --data --from-file <safe-tx.json>.`;
|
|
13
|
+
function main([command, planPath, safeTxPath]) {
|
|
14
|
+
if (command !== "check" || !planPath)
|
|
15
|
+
throw new Error(usage);
|
|
16
|
+
const plan = parsePlan(readFileSync(planPath, "utf8"));
|
|
17
|
+
checkPlan(plan);
|
|
18
|
+
console.log(`plan for Safe ${plan.owner} on chain ${plan.homeChainId}: ${plan.calls.length} call(s), ` +
|
|
19
|
+
`fee ${formatEther(plan.fee)}, top-up ${formatEther(plan.calls[0].value)}`);
|
|
20
|
+
for (const b of plan.batches) {
|
|
21
|
+
console.log(` chain ${b.chainId}: ${b.bootstrap ? "bootstrap" : "message"} with ${b.calls.length} call(s) to ${b.receiver}`);
|
|
22
|
+
}
|
|
23
|
+
if (!safeTxPath)
|
|
24
|
+
return;
|
|
25
|
+
const envelope = parseSafeTxTypedData(readFileSync(safeTxPath, "utf8"));
|
|
26
|
+
checkSafeTx(plan, envelope);
|
|
27
|
+
const { domainHash, messageHash, safeTxHash } = safeTxHashes(envelope);
|
|
28
|
+
console.log(`Safe transaction at nonce ${envelope.tx.nonce}${envelope.tx.operation === 1 ? " through MultiSendCallOnly" : ""}`);
|
|
29
|
+
console.log(` domain hash: ${domainHash}`);
|
|
30
|
+
console.log(` message hash: ${messageHash}`);
|
|
31
|
+
console.log(` safeTxHash: ${safeTxHash}`);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
main(process.argv.slice(2));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.error(error.message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Address, type Hex } from "viem";
|
|
2
|
+
/** One call in a payload: the ERC-7579 `Execution` tuple the protocol's `Call` uses. */
|
|
3
|
+
export type Call = {
|
|
4
|
+
target: Address;
|
|
5
|
+
value: bigint;
|
|
6
|
+
data: Hex;
|
|
7
|
+
};
|
|
8
|
+
/** `Erc7930.encodeEvmChain`: an eip155 chain identifier with no address. */
|
|
9
|
+
export declare function encodeEvmChain(chainId: bigint): Hex;
|
|
10
|
+
/** `Erc7930.encodeEvm`: an account on an eip155 chain. */
|
|
11
|
+
export declare function encodeEvm(chainId: bigint, account: Address): Hex;
|
|
12
|
+
/** `ChainKey.forEvm`. */
|
|
13
|
+
export declare function chainKey(chainId: bigint): Hex;
|
|
14
|
+
/** `Payload.encodeCalls`: the wire bytes an EVM receiver decodes. */
|
|
15
|
+
export declare function encodeCalls(calls: readonly Call[]): Hex;
|
|
16
|
+
/** `Payload.encodeBootstrap`. */
|
|
17
|
+
export declare function encodeBootstrap(destinationChainIdentifier: Hex, body: Hex): Hex;
|
package/dist/encoding.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { concat, encodeAbiParameters, keccak256, numberToHex } from "viem";
|
|
2
|
+
const VERSION_1 = "0x0001";
|
|
3
|
+
const CT_EIP155 = "0x0000";
|
|
4
|
+
/** The chain id as a minimal big-endian integer, as ERC-7930's eip155 profile requires. */
|
|
5
|
+
function chainRef(chainId) {
|
|
6
|
+
if (chainId <= 0n)
|
|
7
|
+
throw new Error(`chain id must be positive: ${chainId}`);
|
|
8
|
+
const hex = chainId.toString(16);
|
|
9
|
+
return `0x${hex.length % 2 ? "0" : ""}${hex}`;
|
|
10
|
+
}
|
|
11
|
+
function lengthByte(bytes) {
|
|
12
|
+
const length = (bytes.length - 2) / 2;
|
|
13
|
+
if (length > 255)
|
|
14
|
+
throw new Error("ERC-7930 field longer than 255 bytes");
|
|
15
|
+
return numberToHex(length, { size: 1 });
|
|
16
|
+
}
|
|
17
|
+
/** `Erc7930.encodeEvmChain`: an eip155 chain identifier with no address. */
|
|
18
|
+
export function encodeEvmChain(chainId) {
|
|
19
|
+
const ref = chainRef(chainId);
|
|
20
|
+
return concat([VERSION_1, CT_EIP155, lengthByte(ref), ref, "0x00"]);
|
|
21
|
+
}
|
|
22
|
+
/** `Erc7930.encodeEvm`: an account on an eip155 chain. */
|
|
23
|
+
export function encodeEvm(chainId, account) {
|
|
24
|
+
const ref = chainRef(chainId);
|
|
25
|
+
return concat([VERSION_1, CT_EIP155, lengthByte(ref), ref, "0x14", account.toLowerCase()]);
|
|
26
|
+
}
|
|
27
|
+
/** `ChainKey.forEvm`. */
|
|
28
|
+
export function chainKey(chainId) {
|
|
29
|
+
return keccak256(encodeEvmChain(chainId));
|
|
30
|
+
}
|
|
31
|
+
const callsType = [
|
|
32
|
+
{
|
|
33
|
+
type: "tuple[]",
|
|
34
|
+
components: [
|
|
35
|
+
{ name: "target", type: "address" },
|
|
36
|
+
{ name: "value", type: "uint256" },
|
|
37
|
+
{ name: "data", type: "bytes" },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
/** `Payload.encodeCalls`: the wire bytes an EVM receiver decodes. */
|
|
42
|
+
export function encodeCalls(calls) {
|
|
43
|
+
return encodeAbiParameters(callsType, [calls]);
|
|
44
|
+
}
|
|
45
|
+
/** `Payload.encodeBootstrap`. */
|
|
46
|
+
export function encodeBootstrap(destinationChainIdentifier, body) {
|
|
47
|
+
return encodeAbiParameters([{ type: "bytes" }, { type: "bytes" }], [destinationChainIdentifier, body]);
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Address, type Hex } from "viem";
|
|
2
|
+
import { type Call } from "./encoding.js";
|
|
3
|
+
export declare const PLAN_VERSION = "crossecute-plan/1";
|
|
4
|
+
/** A call the conduit's owner makes on the home chain. */
|
|
5
|
+
export type HomeCall = {
|
|
6
|
+
to: Address;
|
|
7
|
+
value: bigint;
|
|
8
|
+
data: Hex;
|
|
9
|
+
};
|
|
10
|
+
/** One batch for the conduit's receiver on an EVM destination, as the Foundry script simulated it. */
|
|
11
|
+
export type Batch = {
|
|
12
|
+
chainId: bigint;
|
|
13
|
+
receiver: Address;
|
|
14
|
+
bootstrap: boolean;
|
|
15
|
+
gasUsed: bigint;
|
|
16
|
+
attributes: Hex[];
|
|
17
|
+
calls: Call[];
|
|
18
|
+
};
|
|
19
|
+
/** What `CrossecuteScript._writePlan` writes. */
|
|
20
|
+
export type Plan = {
|
|
21
|
+
homeChainId: bigint;
|
|
22
|
+
owner: Address;
|
|
23
|
+
transmitter: Address;
|
|
24
|
+
fee: bigint;
|
|
25
|
+
calls: HomeCall[];
|
|
26
|
+
batches: Batch[];
|
|
27
|
+
};
|
|
28
|
+
/** Parses and type-checks a plan file. Does not check that its calls match its batches; see `checkPlan`. */
|
|
29
|
+
export declare function parsePlan(json: string): Plan;
|
|
30
|
+
/** The transmitter calldata that carries `batch`, as `CrossecuteScript._homeCallFor` builds it. */
|
|
31
|
+
export declare function homeCallData(batch: Batch): Hex;
|
|
32
|
+
/**
|
|
33
|
+
* Throws unless every home call is the transmitter call its batch implies, so a plan edited
|
|
34
|
+
* after simulation cannot carry calldata other than what was simulated. Only the first call
|
|
35
|
+
* may carry value: the transmitter top-up.
|
|
36
|
+
*/
|
|
37
|
+
export declare function checkPlan(plan: Plan): void;
|
package/dist/plan.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { TransmitterBaseAbi } from "@crossecute/protocol/abi";
|
|
2
|
+
import { encodeFunctionData, getAddress, isAddress, isHex } from "viem";
|
|
3
|
+
import { encodeBootstrap, encodeCalls, encodeEvm, encodeEvmChain } from "./encoding.js";
|
|
4
|
+
export const PLAN_VERSION = "crossecute-plan/1";
|
|
5
|
+
function address(value, at) {
|
|
6
|
+
if (typeof value !== "string" || !isAddress(value, { strict: false }))
|
|
7
|
+
throw new Error(`${at}: not an address`);
|
|
8
|
+
return getAddress(value);
|
|
9
|
+
}
|
|
10
|
+
function hex(value, at) {
|
|
11
|
+
if (typeof value !== "string" || !isHex(value))
|
|
12
|
+
throw new Error(`${at}: not hex`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function uint(value, at) {
|
|
16
|
+
if (typeof value !== "string" || !/^[0-9]+$/.test(value))
|
|
17
|
+
throw new Error(`${at}: not a decimal string`);
|
|
18
|
+
return BigInt(value);
|
|
19
|
+
}
|
|
20
|
+
function array(value, at) {
|
|
21
|
+
if (!Array.isArray(value))
|
|
22
|
+
throw new Error(`${at}: not an array`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
/** Parses and type-checks a plan file. Does not check that its calls match its batches; see `checkPlan`. */
|
|
26
|
+
export function parsePlan(json) {
|
|
27
|
+
const raw = JSON.parse(json);
|
|
28
|
+
if (raw.version !== PLAN_VERSION)
|
|
29
|
+
throw new Error(`unsupported plan version ${raw.version}`);
|
|
30
|
+
return {
|
|
31
|
+
homeChainId: uint(raw.homeChainId, "homeChainId"),
|
|
32
|
+
owner: address(raw.owner, "owner"),
|
|
33
|
+
transmitter: address(raw.transmitter, "transmitter"),
|
|
34
|
+
fee: uint(raw.fee, "fee"),
|
|
35
|
+
calls: array(raw.calls, "calls").map((c, i) => ({
|
|
36
|
+
to: address(c.to, `calls[${i}].to`),
|
|
37
|
+
value: uint(c.value, `calls[${i}].value`),
|
|
38
|
+
data: hex(c.data, `calls[${i}].data`),
|
|
39
|
+
})),
|
|
40
|
+
batches: array(raw.batches, "batches").map((b, i) => {
|
|
41
|
+
if (typeof b.bootstrap !== "boolean")
|
|
42
|
+
throw new Error(`batches[${i}].bootstrap: not a boolean`);
|
|
43
|
+
return {
|
|
44
|
+
chainId: uint(b.chainId, `batches[${i}].chainId`),
|
|
45
|
+
receiver: address(b.receiver, `batches[${i}].receiver`),
|
|
46
|
+
bootstrap: b.bootstrap,
|
|
47
|
+
gasUsed: uint(b.gasUsed, `batches[${i}].gasUsed`),
|
|
48
|
+
attributes: array(b.attributes, `batches[${i}].attributes`).map((a, j) => hex(a, `batches[${i}].attributes[${j}]`)),
|
|
49
|
+
calls: array(b.calls, `batches[${i}].calls`).map((c, j) => ({
|
|
50
|
+
target: address(c.target, `batches[${i}].calls[${j}].target`),
|
|
51
|
+
value: uint(c.value, `batches[${i}].calls[${j}].value`),
|
|
52
|
+
data: hex(c.data, `batches[${i}].calls[${j}].data`),
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** The transmitter calldata that carries `batch`, as `CrossecuteScript._homeCallFor` builds it. */
|
|
59
|
+
export function homeCallData(batch) {
|
|
60
|
+
const body = encodeCalls(batch.calls);
|
|
61
|
+
if (batch.bootstrap) {
|
|
62
|
+
return encodeFunctionData({
|
|
63
|
+
abi: TransmitterBaseAbi,
|
|
64
|
+
functionName: "bootstrap",
|
|
65
|
+
args: [encodeBootstrap(encodeEvmChain(batch.chainId), body), batch.attributes],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return encodeFunctionData({
|
|
69
|
+
abi: TransmitterBaseAbi,
|
|
70
|
+
functionName: "sendMessage",
|
|
71
|
+
args: [encodeEvm(batch.chainId, batch.receiver), body, batch.attributes],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Throws unless every home call is the transmitter call its batch implies, so a plan edited
|
|
76
|
+
* after simulation cannot carry calldata other than what was simulated. Only the first call
|
|
77
|
+
* may carry value: the transmitter top-up.
|
|
78
|
+
*/
|
|
79
|
+
export function checkPlan(plan) {
|
|
80
|
+
if (plan.calls.length === 0)
|
|
81
|
+
throw new Error("plan has no calls");
|
|
82
|
+
if (plan.calls.length !== plan.batches.length)
|
|
83
|
+
throw new Error("plan has a different number of calls and batches");
|
|
84
|
+
plan.calls.forEach((call, i) => {
|
|
85
|
+
if (call.to !== plan.transmitter)
|
|
86
|
+
throw new Error(`calls[${i}] is not to the transmitter`);
|
|
87
|
+
if (i > 0 && call.value !== 0n)
|
|
88
|
+
throw new Error(`calls[${i}] carries value`);
|
|
89
|
+
if (call.data.toLowerCase() !== homeCallData(plan.batches[i]))
|
|
90
|
+
throw new Error(`calls[${i}] does not carry batches[${i}]`);
|
|
91
|
+
});
|
|
92
|
+
}
|
package/dist/safeTx.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type Address, type Hex } from "viem";
|
|
2
|
+
import { type Plan } from "./plan.js";
|
|
3
|
+
/** Safe's EIP-712 `SafeTx`. */
|
|
4
|
+
export type SafeTx = {
|
|
5
|
+
to: Address;
|
|
6
|
+
value: bigint;
|
|
7
|
+
data: Hex;
|
|
8
|
+
operation: 0 | 1;
|
|
9
|
+
safeTxGas: bigint;
|
|
10
|
+
baseGas: bigint;
|
|
11
|
+
gasPrice: bigint;
|
|
12
|
+
gasToken: Address;
|
|
13
|
+
refundReceiver: Address;
|
|
14
|
+
nonce: bigint;
|
|
15
|
+
};
|
|
16
|
+
/** A Safe transaction with the domain it is signed under. */
|
|
17
|
+
export type SafeTxEnvelope = {
|
|
18
|
+
chainId: bigint;
|
|
19
|
+
safe: Address;
|
|
20
|
+
tx: SafeTx;
|
|
21
|
+
};
|
|
22
|
+
export declare const safeTxTypes: {
|
|
23
|
+
readonly EIP712Domain: readonly [{
|
|
24
|
+
readonly name: "chainId";
|
|
25
|
+
readonly type: "uint256";
|
|
26
|
+
}, {
|
|
27
|
+
readonly name: "verifyingContract";
|
|
28
|
+
readonly type: "address";
|
|
29
|
+
}];
|
|
30
|
+
readonly SafeTx: readonly [{
|
|
31
|
+
readonly name: "to";
|
|
32
|
+
readonly type: "address";
|
|
33
|
+
}, {
|
|
34
|
+
readonly name: "value";
|
|
35
|
+
readonly type: "uint256";
|
|
36
|
+
}, {
|
|
37
|
+
readonly name: "data";
|
|
38
|
+
readonly type: "bytes";
|
|
39
|
+
}, {
|
|
40
|
+
readonly name: "operation";
|
|
41
|
+
readonly type: "uint8";
|
|
42
|
+
}, {
|
|
43
|
+
readonly name: "safeTxGas";
|
|
44
|
+
readonly type: "uint256";
|
|
45
|
+
}, {
|
|
46
|
+
readonly name: "baseGas";
|
|
47
|
+
readonly type: "uint256";
|
|
48
|
+
}, {
|
|
49
|
+
readonly name: "gasPrice";
|
|
50
|
+
readonly type: "uint256";
|
|
51
|
+
}, {
|
|
52
|
+
readonly name: "gasToken";
|
|
53
|
+
readonly type: "address";
|
|
54
|
+
}, {
|
|
55
|
+
readonly name: "refundReceiver";
|
|
56
|
+
readonly type: "address";
|
|
57
|
+
}, {
|
|
58
|
+
readonly name: "nonce";
|
|
59
|
+
readonly type: "uint256";
|
|
60
|
+
}];
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* MultiSendCallOnly deployments by Safe version, from @safe-global/safe-deployments 1.37.67.
|
|
64
|
+
* `CrossecuteScript._knownMultiSendCallOnly` holds the same table; test/Vectors.t.sol writes
|
|
65
|
+
* it to vectors/ so the two are compared.
|
|
66
|
+
*/
|
|
67
|
+
export declare const multiSendCallOnly: Record<string, {
|
|
68
|
+
addresses: Address[];
|
|
69
|
+
codeHash: Hex;
|
|
70
|
+
}>;
|
|
71
|
+
/** Parses typed data written by `CrossecuteScript._writeSafeTx`, refusing any other shape. */
|
|
72
|
+
export declare function parseSafeTxTypedData(json: string): SafeTxEnvelope;
|
|
73
|
+
/** The domain hash, message hash and `safeTxHash` a signer's device shows. */
|
|
74
|
+
export declare function safeTxHashes({ chainId, safe, tx }: SafeTxEnvelope): {
|
|
75
|
+
domainHash: Hex;
|
|
76
|
+
messageHash: Hex;
|
|
77
|
+
safeTxHash: Hex;
|
|
78
|
+
};
|
|
79
|
+
/** `CrossecuteScript._multiSendPacked` followed by the `multiSend` call. */
|
|
80
|
+
export declare function multiSendData(calls: Plan["calls"]): Hex;
|
|
81
|
+
/**
|
|
82
|
+
* Throws unless `envelope` is the one Safe transaction that carries `plan`: under the owner
|
|
83
|
+
* Safe's domain on the home chain, with no gas refund, and either the plan's one call directly
|
|
84
|
+
* or all of them through a known MultiSendCallOnly.
|
|
85
|
+
*/
|
|
86
|
+
export declare function checkSafeTx(plan: Plan, envelope: SafeTxEnvelope): void;
|