Skip to content

Commit 8c19fcd

Browse files
0xPrabhcursoragent
andcommitted
feat: add ERC20Votes delegateBySig EIP-712 helpers + two-step example flow
Adds reusable EIP-712 builders for OpenZeppelin `ERC20Votes`-style `delegateBySig` so cold-custody clients can delegate voting power to a self-custody hot wallet without moving funds: - `buildErc20VotesDelegationTypedData` returns the canonical `Delegation(address delegatee,uint256 nonce,uint256 expiry)` typed data with a well-formed `EIP712Domain` block. - `encodeErc20VotesDelegationTypedDataDigest(Hex)` produces the v4 `\x19\x01 || hashStruct(domain) || hashStruct(message)` digest used by BitGo typed-data tx requests (`messageEncoded` / `typedDataEncoded`). - `encodeDelegateBySigCalldata` returns the ABI-encoded on-chain submission payload so any relayer can post the signature. - Ships a WLFI Ethereum mainnet domain helper as a reference template. Also: - Wires `MessageStandardType.EIP712` through `createTxRequestWithIntentForTypedDataSigning` / `IntentOptionsForTypedData` / `PopulatedIntentForTypedDataSigning` so WP can distinguish typed-data delegation messages from plain `signMessage` intents and route them to TAT instead of sendQ. - Adds the runnable two-step client flow under examples: - `examples/ts/eth/push-erc20-votes-delegation-txrequest.ts` creates a custodial tx request via `wallet.signTypedData(...)` and prints the `txRequestId` for the operator to sign in TAT. - `examples/ts/eth/fetch-erc20-votes-delegation-txrequest-signature.ts` reads the latest signed snapshot of that tx request and prints `v, r, s` plus optional `delegateBySig` calldata. - Adds client-facing docs at `examples/docs/erc20-votes-delegate-by-sig.md` covering the full push -> TAT signs -> fetch -> submit on-chain flow. - Unit tests cover the domain/message hash, digest hex, calldata, and factory wiring. Ticket: CGD-842 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 77c7517 commit 8c19fcd

14 files changed

Lines changed: 1143 additions & 2 deletions
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# Delegate Governance Voting Power From a BitGo Custodial Wallet
2+
3+
This guide explains how to delegate voting power for OpenZeppelin
4+
`ERC20Votes`-style governance tokens (e.g. WLFI, UNI, COMP, ARB, ENS, OP)
5+
from a BitGo custodial cold wallet to a self-custody hot wallet, **without
6+
moving funds and without paying gas from the cold wallet**.
7+
8+
It uses two scripts in `examples/ts/eth/`:
9+
10+
| Step | Script | What it produces |
11+
| --- | --- | --- |
12+
| 1 | `push-erc20-votes-delegation-txrequest.ts` | Creates a delegation message and prints a `txRequestId` |
13+
| 2 | `fetch-erc20-votes-delegation-txrequest-signature.ts` | Once BitGo has signed, prints `v, r, s` and ready-to-broadcast `delegateBySig` calldata |
14+
15+
Between the two steps, BitGo's custodial signing workflow approves and signs
16+
the message with your cold wallet's MPC keys. You re-run the fetch script
17+
when you want to retrieve the signature.
18+
19+
## Prerequisites
20+
21+
- BitGo SDK installed
22+
- BitGo account and API access token with permissions on the wallet
23+
- A custodial ETH MPC wallet on BitGo
24+
- `.env` file with the variables listed below
25+
- A JSON-RPC URL for the chain that hosts the token (used to read
26+
`nonces(delegator)`), or the nonce fetched out-of-band
27+
28+
## .env file
29+
30+
Both scripts read the same environment variable names. Create
31+
`<repo-root>/.env`:
32+
33+
```bash
34+
# auth
35+
BITGO_ACCESS_TOKEN=your_access_token_here # or ACCESS_TOKEN
36+
BITGO_ENV=test # or `prod`
37+
38+
# wallet
39+
WALLET_ID=your_wallet_id
40+
COIN=hteth # eth | teth | hteth (must match the wallet)
41+
42+
# delegation message
43+
DELEGATEE=0xYourHotWalletAddress # address that will vote
44+
EXPIRY= # optional unix seconds; default: now + 1h
45+
46+
# nonce lookup (one of these is required)
47+
ETH_RPC_URL=https://... # script will call nonces(delegator) on the token
48+
NONCE= # OR set this manually if you already have it
49+
50+
# domain (one of these is required)
51+
EIP712_DOMAIN_JSON={"name":"...","version":"...","chainId":1,"verifyingContract":"0x..."}
52+
# Omit EIP712_DOMAIN_JSON if you are running BITGO_ENV=prod COIN=eth and want
53+
# the WLFI mainnet defaults baked into the script.
54+
55+
# step 2 only — set after step 1 prints it
56+
TX_REQUEST_ID=
57+
```
58+
59+
## Step 1 — create the delegation request
60+
61+
First, run `yarn install` from the root directory of the repository.
62+
63+
Then run the push script from the repo root:
64+
65+
```
66+
$ npx tsx examples/ts/eth/push-erc20-votes-delegation-txrequest.ts
67+
```
68+
69+
### Expected output
70+
71+
```
72+
Resolved nonce { nonce: '1', source: 'rpc' }
73+
POST /api/v2/wallet/<walletId>/txrequests { apiVersion: 'full', preview: false, coin: 'hteth' }
74+
Delegator (wallet receive address): 0xabc...
75+
Delegatee: 0xdef...
76+
intent.intentType: signTypedStructuredData
77+
intent.messageEncoded length (hex chars): 132
78+
79+
Tx request created: {
80+
"txRequestId": "db1ccd60-58d4-418e-9d23-d42d67eebd5b",
81+
"state": "pendingDelivery",
82+
...
83+
}
84+
85+
Next: open Admin for this wallet or enterprise and look up this tx request / linked pending approval.
86+
```
87+
88+
**Copy the `txRequestId`** from the output and add it to `.env` as
89+
`TX_REQUEST_ID`. The unsigned message is now queued in BitGo for approval and
90+
signing.
91+
92+
### Note
93+
94+
- `COIN` must match your wallet's chain in BitGo (e.g. `eth`, `teth`,
95+
`hteth`).
96+
- `DELEGATEE` is the address that will vote on your behalf — usually your
97+
self-custody hot wallet.
98+
- `EXPIRY` is the unix-seconds deadline for someone to submit the signature
99+
on-chain. The delegation itself does not expire; only the submission
100+
window does.
101+
102+
## Step 2 — fetch the signature
103+
104+
Once BitGo has signed the message, run:
105+
106+
```
107+
$ npx tsx examples/ts/eth/fetch-erc20-votes-delegation-txrequest-signature.ts
108+
```
109+
110+
### Expected output (signed)
111+
112+
```
113+
txRequestId: db1ccd60-58d4-418e-9d23-d42d67eebd5b
114+
snapshot version: 7
115+
messages[0].state: signed
116+
117+
ECDSA signature (from messages[0].txHash):
118+
v: 28
119+
r: 0xc7b471134954a6c6f4b0eb4422ce5c400d61c8aa793f6a527cede00fb225d8c3
120+
s: 0x31181c2ad2ffd2562da10f000b6a7e2dafdb1ae6522b46bd3aa9bd1540392424
121+
122+
delegateBySig calldata:
123+
0x5c19a95c000000000000000000000000def...
124+
```
125+
126+
### Expected output (still pending)
127+
128+
If BitGo has not signed yet:
129+
130+
```
131+
Message not signed yet, try again later.
132+
```
133+
134+
Re-run the script later to retrieve the signature.
135+
136+
## Step 3 — submit `delegateBySig` on-chain
137+
138+
Take `v, r, s` (or the printed calldata) and submit from any address. Your
139+
cold wallet does not pay gas and does not move funds.
140+
141+
Using a contract instance (e.g. ethers):
142+
143+
```ts
144+
await votesToken.delegateBySig(delegatee, nonce, expiry, v, r, s);
145+
```
146+
147+
Or send a raw transaction from your hot wallet:
148+
149+
- `to` = the token contract address (`domain.verifyingContract`)
150+
- `data` = the `delegateBySig calldata` printed by step 2
151+
152+
After confirmation, on-chain `delegates(coldWallet)` returns `delegatee` and
153+
the cold wallet's voting power is delegated.
154+
155+
## Troubleshooting
156+
157+
### `Set BITGO_ACCESS_TOKEN ...` (or any other `Set X ...` error)
158+
159+
The variable is missing from your environment. Confirm `.env` exists at the
160+
repo root, contains the variable, and that you are running the script from
161+
the repo root so the `.env` file is picked up.
162+
163+
### `Coin unsupported` from step 1
164+
165+
`COIN` does not match a chain enabled for this wallet on this BitGo
166+
environment. Try `hteth` for Holesky, `teth` for Sepolia, or `eth` for
167+
mainnet — and make sure it matches the coin shown for your wallet in the
168+
BitGo UI.
169+
170+
### `Wallet has no receiveAddress yet`
171+
172+
The wallet does not have any addresses yet. Create or fund an address
173+
first, or set `DELEGATEE` and `NONCE` manually so the script does not need
174+
to look them up from the wallet.
175+
176+
### `EIP712_DOMAIN_JSON must include "..."`
177+
178+
Your token's domain JSON is missing one of the required fields. Read the
179+
token's `eip712Domain()` function on-chain and pass all four fields:
180+
181+
```bash
182+
EIP712_DOMAIN_JSON='{"name":"...","version":"...","chainId":1,"verifyingContract":"0x..."}'
183+
```
184+
185+
### Step 2 prints `Message not signed yet, try again later.`
186+
187+
BitGo has not finished signing the message. Wait and re-run the fetch
188+
script. If signing has been pending for an unusually long time, contact
189+
BitGo support and reference the `txRequestId` printed in step 1.
190+
191+
### Step 2 prints a tx request but no signature
192+
193+
The fetch script only prints `v, r, s` once `messages[0].state` is `signed`
194+
and the signature on `messages[0].txHash` is a complete 65-byte hex value.
195+
If you see other states (e.g. an in-progress signing state), simply re-run
196+
the fetch script later.
197+
198+
## Additional Resources
199+
200+
- [BitGo Developer Documentation](https://developers.bitgo.com/)
201+
- [EIP-712: Typed Structured Data Signing](https://eips.ethereum.org/EIPS/eip-712)
202+
- [OpenZeppelin `ERC20VotesUpgradeable`](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/master/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol)
203+
204+
## Support
205+
206+
For questions or issues:
207+
208+
1. Check the [BitGo Developer Documentation](https://developers.bitgo.com/)
209+
2. Open an issue on [GitHub](https://github.com/BitGo/BitGoJS/issues)
210+
3. Contact BitGo support

0 commit comments

Comments
 (0)