Skip to content

Commit c6a7c04

Browse files
authored
feat: new gateway proposal builder script (#121)
* feat: new gateway proposal builder script * chore: better sanitization * feat: add aragonProposal.json output * chore: improved comments * feat: decodeOptionsGatewayProposal script * feat: add gas estimation via fork testing, using backtested buffers * chore: enforcing targets are actually contracts * chore: simplify comments * chore: add selector check and calldata format
1 parent 346e373 commit c6a7c04

9 files changed

Lines changed: 1538 additions & 0 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# mainnet variables
2+
RPC_GATEWAY_MAINNET=https://rpc.mainnet.zama.org
3+
4+
# testnet variables
5+
RPC_GATEWAY_TESTNET=https://rpc-zama-testnet-0.t.conduit.xyz
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.env
2+
node_modules/
3+
gateway-proposal-filled.json
4+
gateway-proposal-temp.json
5+
aragonProposal.json
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Governance Proposal Builder
2+
3+
Helpers to build, validate, and fill DAO Governance proposals.
4+
5+
## Prerequisites
6+
7+
- Node.js v18+
8+
- npm
9+
10+
## Installation
11+
12+
```
13+
npm install
14+
```
15+
16+
## Configuration
17+
18+
Create a `.env` file based on `.env.example`:
19+
```
20+
cp .env.example .env
21+
```
22+
23+
## Available scripts
24+
25+
Currently availabe scripts are:
26+
```
27+
[*] fill-options-gateway-proposal
28+
[*] decode-options-gateway-proposal
29+
```
30+
31+
### fillOptionsGatewayProposal
32+
33+
#### Workflow
34+
35+
1. Edit `gateway-proposal-temp.json` (see one of the
36+
`gateway-proposal-temp.<network>-example.json` files as a starting point) to
37+
describe the proposal you want to send. Leave `arguments.options` set to
38+
`"0x"` — that field is what this script fills in.
39+
2. Run the fill script for the target network:
40+
41+
```bash
42+
npm run fill-options-gateway-proposal:mainnet
43+
# or
44+
npm run fill-options-gateway-proposal:testnet
45+
```
46+
47+
3. The script writes two files in current directory:
48+
- `gateway-proposal-filled.json` — the validated proposal mirroring the
49+
input shape, with `arguments.options` filled in. Useful as a
50+
human-readable record of what was generated.
51+
- `aragonProposal.json` — the same call rendered as a single Aragon
52+
transaction (`[{ to, value, data }]`) where `data` is the ABI-encoded
53+
`sendRemoteProposal(...)` calldata. **This is the file to upload via the
54+
Aragon DAO front-end** when creating the proposal that calls
55+
`sendRemoteProposal` on the `GovernanceOAppSender` contract.
56+
57+
#### What the script checks
58+
59+
Before computing options, the script enforces that the input matches the
60+
canonical structure of `gateway-proposal-temp.json`:
61+
62+
- Top-level keys are exactly `to`, `method`, `arguments`.
63+
- `arguments` keys are exactly `targets`, `values`, `functionSignatures`,
64+
`datas`, `operations`, `options`.
65+
- `targets`, `values`, `functionSignatures`, `datas`, `operations` are arrays
66+
of strings of identical length.
67+
- `options` is a string equal to `"0x"` : empty placeholder — **the whole point
68+
of the script is to fill it**.
69+
- `method` is exactly `"sendRemoteProposal"`.
70+
- `to` matches the canonical `GovernanceOAppSender` for the chosen network:
71+
- mainnet: `0x1c5D750D18917064915901048cdFb2dB815e0910`
72+
- testnet: `0x909692c2f4979ca3fa11B5859d499308A1ec4932`
73+
- currently it only allows `values` and `operations` to be arrays of `"0"`s.
74+
- each `targets[i]` has non-empty bytecode on the Gateway chain.
75+
76+
#### How `options` is computed
77+
78+
The script uses `@layerzerolabs/lz-v2-utilities` to build a LayerZero
79+
option containing a single executor `lzReceive` action. To do this it forks the Gateway chain and impersonates the `SafeProxy` account to estimate the gas needed, and adds some constant and proportional buffers.
80+
81+
#### Output
82+
83+
The script writes two files next to the input:
84+
85+
```
86+
./gateway-proposal-filled.json
87+
./aragonProposal.json
88+
```
89+
90+
It **refuses to overwrite** either file if it already exists, and exits
91+
with a non-zero status before writing anything. Delete the file(s) first if
92+
you want to regenerate.
93+
94+
#### RECOMMENDED MANUAL STEP: Decoding individual `datas` entries
95+
96+
Independently from this script, when doing a cross-chain proposal, it is highly recommended to always sanity-check what each `arguments.datas[i]` actually calls (using the matching
97+
`arguments.functionSignatures[i]`) — note that usually datas are encoded **without**
98+
the 4-byte selector, so use `cast abi-decode` and treat the bytes as the
99+
"return-value" tuple, for example:
100+
101+
```bash
102+
DATA=0x00000000000000000000000012345678901234567890123456789012345678900000000000000000000000000000000000000000000000000000000000000002
103+
104+
cast abi-decode 'f()(address,uint256)' "$DATA"
105+
# 0x1234567890123456789012345678901234567890
106+
# 2
107+
```
108+
109+
The `f()` is just a placeholder; only the parameter-types tuple matters. Pass
110+
the same types as the matching `functionSignatures[i]`.
111+
112+
### decodeOptionsGatewayProposal
113+
114+
Reverse of the `computeLZOptions` step inside `fillOptionsGatewayProposal`:
115+
takes a LayerZero options hex string
116+
and prints the decoded `gasLimit` and `nativeValue`. Useful to sanity-check
117+
what's in `arguments.options` of a Gateway proposal and to use before voting on a pending DAO Gateway proposal.
118+
119+
#### Usage
120+
121+
```bash
122+
npm run decode-options-gateway-proposal -- --options 0x000301001101000000000000000000000000000493e0
123+
```
124+
125+
The leading `--` is required so npm forwards the flag to the script instead
126+
of consuming it itself. `--options` is the only flag and is required.
127+
128+
#### Output
129+
130+
Prints (to stdout) the raw hex and the decoded fields:
131+
132+
```
133+
Options hex: 0x000301001101000000000000000000000000000493e0
134+
gasLimit: 300000
135+
nativeValue: 0
136+
```
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
const { Options } = require('@layerzerolabs/lz-v2-utilities')
2+
3+
const SCRIPT_NAME = 'decodeOptionsGatewayProposal.js'
4+
const EMPTY_OPTIONS = '0x'
5+
6+
function printUsage() {
7+
console.log(
8+
`Usage:
9+
npm run decode-options-gateway-proposal -- --options <hex>
10+
node ${SCRIPT_NAME} --options <hex>
11+
12+
Flags:
13+
--options REQUIRED The LayerZero options hex string to decode
14+
(e.g. 0x000301001101000000000000000000000000000493e0).
15+
-h, --help Show this help.
16+
17+
Reverse of computeLZOptions in fillOptionsGatewayProposal.js: takes a
18+
LayerZero options hex string (a single executor lzReceive option) and prints
19+
the decoded gas limit and native value.
20+
`
21+
)
22+
}
23+
24+
function parseArgs(argv) {
25+
const args = { options: undefined }
26+
27+
for (let i = 2; i < argv.length; i++) {
28+
const flag = argv[i]
29+
switch (flag) {
30+
case '-h':
31+
case '--help':
32+
printUsage()
33+
process.exit(0)
34+
case '--options': {
35+
const value = argv[++i]
36+
if (!value) throw new Error(`Missing value for ${flag}`)
37+
args.options = value
38+
break
39+
}
40+
default:
41+
throw new Error(`Unknown flag: ${flag}`)
42+
}
43+
}
44+
45+
if (args.options === undefined) {
46+
throw new Error('Missing required flag: --options <hex>')
47+
}
48+
49+
return args
50+
}
51+
52+
/**
53+
* Reverse of computeLZOptions in fillOptionsGatewayProposal.js.
54+
*
55+
* Decodes a LayerZero options hex string produced by
56+
* Options.newOptions().addExecutorLzReceiveOption(gasLimit, nativeValue).toHex()
57+
* and returns { gasLimit, nativeValue } as BigInts.
58+
*
59+
* Throws if the hex is empty ("0x"), malformed, or does not contain an
60+
* executor lzReceive option.
61+
*/
62+
function decodeLZOptions(hex) {
63+
if (typeof hex !== 'string') {
64+
throw new Error(`options must be a string (got ${typeof hex}).`)
65+
}
66+
if (hex === EMPTY_OPTIONS || hex === '') {
67+
throw new Error('options is empty ("0x"); nothing to decode.')
68+
}
69+
70+
let parsed
71+
try {
72+
parsed = Options.fromOptions(hex)
73+
} catch (err) {
74+
throw new Error(`Failed to parse options hex "${hex}": ${err.message}`)
75+
}
76+
77+
const decoded = parsed.decodeExecutorLzReceiveOption()
78+
if (!decoded) {
79+
throw new Error(`No executor lzReceive option found in: ${hex}`)
80+
}
81+
// The library returns { gas, value }; rename to match computeLZOptions's
82+
// (gasLimit, nativeValue) parameter names so the reverse mapping is obvious.
83+
return {
84+
gasLimit: BigInt(decoded.gas),
85+
nativeValue: BigInt(decoded.value),
86+
}
87+
}
88+
89+
function main() {
90+
let args
91+
try {
92+
args = parseArgs(process.argv)
93+
} catch (err) {
94+
console.error(`Error: ${err.message}\n`)
95+
printUsage()
96+
process.exit(1)
97+
}
98+
99+
let decoded
100+
try {
101+
decoded = decodeLZOptions(args.options)
102+
} catch (err) {
103+
console.error(`Error: ${err.message}`)
104+
process.exit(1)
105+
}
106+
107+
console.log(`Options hex: ${args.options}`)
108+
console.log(`gasLimit: ${decoded.gasLimit.toString()}`)
109+
console.log(`nativeValue: ${decoded.nativeValue.toString()}`)
110+
}
111+
112+
if (require.main === module) {
113+
main()
114+
}
115+
116+
module.exports = { decodeLZOptions }

0 commit comments

Comments
 (0)