Skip to content

Commit 1f952ae

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 1f952ae

14 files changed

Lines changed: 1027 additions & 2 deletions
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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.
6+
7+
It uses two scripts in `examples/ts/eth/`:
8+
9+
| Step | Script | What it produces |
10+
| --- | --- | --- |
11+
| 1 | `create-erc20-votes-delegation-txrequest.ts` | Creates a delegation message and prints a `txRequestId` |
12+
| 2 | `get-erc20-votes-delegation-signature.ts` | Once BitGo has signed, prints `v, r, s` and ready-to-broadcast `delegateBySig` calldata |
13+
14+
Between the two steps, BitGo's custodial signing workflow approves and signs
15+
the message with your cold wallet's MPC keys. You re-run step 2 when you
16+
want to retrieve the signature.
17+
18+
## Prerequisites
19+
20+
- BitGo SDK installed
21+
- BitGo account and API access token with permissions on the wallet
22+
- A custodial ETH MPC wallet on BitGo
23+
- `.env` file with the variables listed below
24+
- A JSON-RPC URL for the chain that hosts the token (used to read
25+
`nonces(delegator)`), or the nonce fetched out-of-band
26+
27+
## .env file
28+
29+
Both scripts read the same environment variable names. Create
30+
`<repo-root>/.env`:
31+
32+
```bash
33+
# auth
34+
BITGO_ACCESS_TOKEN=your_access_token_here # or ACCESS_TOKEN
35+
BITGO_ENV=test # or `prod`
36+
37+
# wallet
38+
WALLET_ID=your_wallet_id
39+
COIN=hteth # eth | teth | hteth (must match the wallet)
40+
41+
# delegation message
42+
DELEGATEE=0xYourHotWalletAddress # address that will vote
43+
EXPIRY= # optional unix seconds; default: now + 1h
44+
45+
# nonce lookup (one of these is required)
46+
ETH_RPC_URL=https://... # script will call nonces(delegator) on the token
47+
NONCE= # OR set this manually if you already have it
48+
49+
# domain (one of these is required)
50+
EIP712_DOMAIN_JSON={"name":"...","version":"...","chainId":1,"verifyingContract":"0x..."}
51+
# Omit EIP712_DOMAIN_JSON if you are running BITGO_ENV=prod COIN=eth and want
52+
# the WLFI mainnet defaults baked into the script.
53+
54+
# step 2 only — set after step 1 prints it
55+
TX_REQUEST_ID=
56+
```
57+
58+
## Step 1 — create the delegation request
59+
60+
First, run `yarn install` from the root directory of the repository.
61+
62+
Then run the create script from the repo root:
63+
64+
```
65+
$ npx tsx examples/ts/eth/create-erc20-votes-delegation-txrequest.ts
66+
```
67+
68+
### Expected output
69+
70+
```
71+
Tx request created.
72+
73+
txRequestId : db1ccd60-58d4-418e-9d23-d42d67eebd5b
74+
state : pendingDelivery
75+
delegator : 0xabc...
76+
delegatee : 0xdef...
77+
nonce : 1
78+
expiry : 1777882731
79+
80+
Next: add the txRequestId above to your .env as TX_REQUEST_ID,
81+
then run get-erc20-votes-delegation-signature.ts to retrieve the
82+
signature once BitGo has signed the message.
83+
```
84+
85+
**Copy the `txRequestId`** from the output and add it to `.env` as
86+
`TX_REQUEST_ID`. The unsigned message is now queued in BitGo for signing.
87+
88+
### Note
89+
90+
- `COIN` must match your wallet's chain in BitGo (e.g. `eth`, `teth`,
91+
`hteth`).
92+
- `DELEGATEE` is the address that will vote on your behalf — usually your
93+
self-custody hot wallet.
94+
- `EXPIRY` is the unix-seconds deadline for someone to submit the signature
95+
on-chain. The delegation itself does not expire; only the submission
96+
window does.
97+
98+
## Step 2 — get the signature
99+
100+
Once BitGo has signed the message, run:
101+
102+
```
103+
$ npx tsx examples/ts/eth/get-erc20-votes-delegation-signature.ts
104+
```
105+
106+
### Expected output (signed)
107+
108+
```
109+
Signature retrieved.
110+
111+
txRequestId : db1ccd60-58d4-418e-9d23-d42d67eebd5b
112+
v : 28
113+
r : 0xc7b471134954a6c6f4b0eb4422ce5c400d61c8aa793f6a527cede00fb225d8c3
114+
s : 0x31181c2ad2ffd2562da10f000b6a7e2dafdb1ae6522b46bd3aa9bd1540392424
115+
116+
delegateBySig calldata (broadcast as `data` to the token contract):
117+
0x5c19a95c000000000000000000000000def...
118+
```
119+
120+
### Expected output (still pending)
121+
122+
If BitGo has not signed yet:
123+
124+
```
125+
Message not signed yet, try again later.
126+
```
127+
128+
Re-run the script later to retrieve the signature once BitGo has signed.
129+
130+
## Step 3 — submit `delegateBySig` on-chain
131+
132+
Take `v, r, s` (or the printed calldata) and submit from any address. Your
133+
cold wallet does not pay gas and does not move funds.
134+
135+
Using a contract instance (e.g. ethers):
136+
137+
```ts
138+
await votesToken.delegateBySig(delegatee, nonce, expiry, v, r, s);
139+
```
140+
141+
Or send a raw transaction from your hot wallet:
142+
143+
- `to` = the token contract address (`domain.verifyingContract`)
144+
- `data` = the `delegateBySig calldata` printed by step 2
145+
146+
After confirmation, on-chain `delegates(coldWallet)` returns `delegatee` and
147+
the cold wallet's voting power is delegated.
148+
149+
## Troubleshooting
150+
151+
### `Set BITGO_ACCESS_TOKEN ...` (or any other `Set X ...` error)
152+
153+
The variable is missing from your environment. Confirm `.env` exists at the
154+
repo root, contains the variable, and that you are running the script from
155+
the repo root so the `.env` file is picked up.
156+
157+
### `Coin unsupported` from step 1
158+
159+
`COIN` does not match a chain enabled for this wallet on this BitGo
160+
environment. Try `hteth` for Holesky, `teth` for Sepolia, or `eth` for
161+
mainnet — and make sure it matches the coin shown for your wallet in the
162+
BitGo UI.
163+
164+
### `Wallet has no receiveAddress yet`
165+
166+
The wallet does not have any addresses yet. Create or fund an address
167+
first, or set `DELEGATEE` and `NONCE` manually so the script does not need
168+
to look them up from the wallet.
169+
170+
### `EIP712_DOMAIN_JSON must include "..."`
171+
172+
Your token's domain JSON is missing one of the required fields. Read the
173+
token's `eip712Domain()` function on-chain and pass all four fields:
174+
175+
```bash
176+
EIP712_DOMAIN_JSON='{"name":"...","version":"...","chainId":1,"verifyingContract":"0x..."}'
177+
```
178+
179+
### Step 2 prints `Message not signed yet, try again later.`
180+
181+
BitGo has not finished signing the message. Wait and re-run the script. If
182+
signing has been pending for an unusually long time, contact BitGo support
183+
and reference the `txRequestId` printed in step 1.
184+
185+
## Additional Resources
186+
187+
- [BitGo Developer Documentation](https://developers.bitgo.com/)
188+
- [EIP-712: Typed Structured Data Signing](https://eips.ethereum.org/EIPS/eip-712)
189+
- [OpenZeppelin `ERC20VotesUpgradeable`](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/master/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol)
190+
191+
## Support
192+
193+
For questions or issues:
194+
195+
1. Check the [BitGo Developer Documentation](https://developers.bitgo.com/)
196+
2. Open an issue on [GitHub](https://github.com/BitGo/BitGoJS/issues)
197+
3. Contact BitGo support
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Create a delegation request for an OpenZeppelin ERC20Votes-style
4+
* `delegateBySig` from a BitGo custodial wallet.
5+
*
6+
* Step 1 of the flow described in
7+
* `examples/docs/erc20-votes-delegate-by-sig.md`. Prints a `txRequestId`
8+
* that you copy into `.env` as `TX_REQUEST_ID` for step 2
9+
* (`examples/ts/eth/get-erc20-votes-delegation-signature.ts`).
10+
*
11+
* Run from the repo root:
12+
*
13+
* npx tsx examples/ts/eth/create-erc20-votes-delegation-txrequest.ts
14+
*
15+
* Required env:
16+
* BITGO_ACCESS_TOKEN (or ACCESS_TOKEN)
17+
* WALLET_ID
18+
* COIN — must match the wallet's chain (e.g. eth, teth, hteth)
19+
* DELEGATEE — address that will vote (defaults to the wallet's
20+
* receive address for self-delegation)
21+
*
22+
* Nonce — set one of:
23+
* ETH_RPC_URL — JSON-RPC URL for the token chain; the script calls
24+
* `nonces(delegator)` on the token contract.
25+
* NONCE — manual override (wins over ETH_RPC_URL).
26+
*
27+
* Domain — set one of:
28+
* EIP712_DOMAIN_JSON — { name, version, chainId, verifyingContract } from
29+
* the token's on-chain `eip712Domain()`.
30+
* Or run with `BITGO_ENV=prod COIN=eth` to use the WLFI mainnet defaults.
31+
*
32+
* Optional:
33+
* BITGO_ENV — `test` (default) or `prod`
34+
* EXPIRY — unix seconds; default: now + 1h
35+
*
36+
* Copyright 2026, BitGo, Inc. All Rights Reserved.
37+
*/
38+
39+
import path from 'path';
40+
41+
import dotenv from 'dotenv';
42+
43+
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
44+
45+
import {
46+
Erc20VotesDelegationMessageBuilder,
47+
wlfiEthereumMainnetDelegationDomain,
48+
type Erc20VotesDelegationDomain,
49+
} from '../../../modules/abstract-eth/src/index';
50+
import { BitGoAPI } from '@bitgo/sdk-api';
51+
import { Eth, Hteth, Teth } from '@bitgo/sdk-coin-eth';
52+
import { MessageStandardType, type EnvironmentName } from '@bitgo/sdk-core';
53+
import { coins } from '@bitgo/statics';
54+
import { ethers } from 'ethers';
55+
56+
const NONCES_ABI = ['function nonces(address owner) view returns (uint256)'];
57+
58+
async function fetchTokenNonce(rpcUrl: string, tokenAddress: string, delegator: string): Promise<string> {
59+
const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
60+
const token = new ethers.Contract(tokenAddress, NONCES_ABI, provider);
61+
const nonce = (await token.nonces(delegator)) as ethers.BigNumber;
62+
return nonce.toString();
63+
}
64+
65+
async function resolveNonce(domainContract: string, delegator: string): Promise<string> {
66+
const manual = process.env.NONCE?.trim();
67+
if (manual) {
68+
return manual;
69+
}
70+
const rpcUrl = process.env.ETH_RPC_URL?.trim();
71+
if (!rpcUrl) {
72+
throw new Error('Set ETH_RPC_URL (to fetch nonces(delegator)) or NONCE in .env');
73+
}
74+
return fetchTokenNonce(rpcUrl, domainContract, delegator);
75+
}
76+
77+
function parseDelegationDomainFromEnv(coin: string, bitgoEnv: EnvironmentName): Erc20VotesDelegationDomain {
78+
const raw = process.env.EIP712_DOMAIN_JSON?.trim();
79+
if (raw) {
80+
const d = JSON.parse(raw) as Record<string, unknown>;
81+
for (const k of ['name', 'version', 'chainId', 'verifyingContract']) {
82+
if (d[k] === undefined) {
83+
throw new Error(`EIP712_DOMAIN_JSON must include "${k}" (from the token's eip712Domain())`);
84+
}
85+
}
86+
return {
87+
name: String(d.name),
88+
version: String(d.version),
89+
chainId: Number(d.chainId),
90+
verifyingContract: ethers.utils.getAddress(String(d.verifyingContract)),
91+
};
92+
}
93+
if (coin === 'eth' && bitgoEnv === 'prod') {
94+
return wlfiEthereumMainnetDelegationDomain();
95+
}
96+
throw new Error('Set EIP712_DOMAIN_JSON in .env (or use BITGO_ENV=prod COIN=eth for WLFI mainnet defaults)');
97+
}
98+
99+
async function main(): Promise<void> {
100+
const accessToken = process.env.BITGO_ACCESS_TOKEN ?? process.env.ACCESS_TOKEN;
101+
const walletId = process.env.WALLET_ID;
102+
const coin = process.env.COIN;
103+
const env: EnvironmentName = process.env.BITGO_ENV === 'prod' ? 'prod' : 'test';
104+
const expiry = process.env.EXPIRY ?? String(Math.floor(Date.now() / 1000) + 3600);
105+
106+
if (!accessToken) {
107+
throw new Error('Set BITGO_ACCESS_TOKEN (or ACCESS_TOKEN) in .env');
108+
}
109+
if (!walletId) {
110+
throw new Error('Set WALLET_ID in .env');
111+
}
112+
if (!coin) {
113+
throw new Error('Set COIN in .env (e.g. eth, teth, hteth)');
114+
}
115+
116+
const domain = parseDelegationDomainFromEnv(coin, env);
117+
118+
const bitgo = new BitGoAPI({ env });
119+
bitgo.register('eth', Eth.createInstance);
120+
bitgo.register('teth', Teth.createInstance);
121+
bitgo.register('hteth', Hteth.createInstance);
122+
await bitgo.authenticateWithAccessToken({ accessToken });
123+
124+
const wallet = await bitgo.coin(coin).wallets().get({ id: walletId });
125+
const delegator = wallet.receiveAddress();
126+
if (!delegator) {
127+
throw new Error('Wallet has no receiveAddress yet. Create or fund an address first.');
128+
}
129+
const delegatee = process.env.DELEGATEE?.trim() || delegator;
130+
131+
const nonce = await resolveNonce(domain.verifyingContract, delegator);
132+
133+
const builder = new Erc20VotesDelegationMessageBuilder(coins.get(coin));
134+
const message = await builder.buildFromDelegation({
135+
domain,
136+
message: { delegatee, nonce, expiry },
137+
});
138+
const messageEncoded = (await message.getSignablePayload()).toString('hex');
139+
140+
const body = {
141+
intent: {
142+
intentType: 'signTypedStructuredData',
143+
isTss: true,
144+
messageRaw: message.getPayload(),
145+
messageEncoded,
146+
messageStandardType: MessageStandardType.EIP712,
147+
},
148+
apiVersion: 'full',
149+
};
150+
151+
const txRequest = (await bitgo
152+
.post(bitgo.url(`/wallet/${walletId}/txrequests`, 2))
153+
.send(body)
154+
.result()) as { txRequestId?: string; state?: string };
155+
156+
console.log('');
157+
console.log('Tx request created.');
158+
console.log('');
159+
console.log(` txRequestId : ${txRequest.txRequestId ?? '<not returned>'}`);
160+
console.log(` state : ${txRequest.state ?? 'n/a'}`);
161+
console.log(` delegator : ${delegator}`);
162+
console.log(` delegatee : ${delegatee}`);
163+
console.log(` nonce : ${nonce}`);
164+
console.log(` expiry : ${expiry}`);
165+
console.log('');
166+
console.log('Next: add the txRequestId above to your .env as TX_REQUEST_ID,');
167+
console.log('then run get-erc20-votes-delegation-signature.ts to retrieve the');
168+
console.log('signature once BitGo has signed the message.');
169+
}
170+
171+
main().catch((e) => {
172+
console.error(e);
173+
process.exit(1);
174+
});

0 commit comments

Comments
 (0)