Skip to content

Commit dec6205

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 dec6205

14 files changed

Lines changed: 1091 additions & 2 deletions
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# ERC20Votes `delegateBySig` from a BitGo cold (custodial) wallet
2+
3+
> Linear: [CGD-842](https://linear.app/bitgo/issue/CGD-842) ·
4+
> PR: [BitGo/BitGoJS#8660](https://github.com/BitGo/BitGoJS/pull/8660)
5+
6+
This is the client workflow for delegating governance voting power from an
7+
**ETH MPC custodial cold wallet** (e.g. WLFI, UNI, COMP, ARB, ENS, OP, …) to a
8+
self-custody hot wallet **without moving funds and without paying gas from the
9+
cold wallet**. Funds stay in custody; only the *votes* are delegated.
10+
11+
The pattern uses OpenZeppelin `ERC20Votes.delegateBySig`: BitGo signs the
12+
EIP-712 typed-data message with your cold wallet's MPC keys, and any relayer
13+
(or your hot wallet) submits the resulting `delegateBySig(...)` transaction.
14+
15+
The flow is **two scripts**:
16+
17+
| Step | Script | What it does |
18+
| --- | --- | --- |
19+
| 1 | [`examples/ts/eth/push-erc20-votes-delegation-txrequest.ts`](../ts/eth/push-erc20-votes-delegation-txrequest.ts) | Creates a custodial tx request (`signTypedStructuredData` intent) and prints a `txRequestId`. The unsigned message appears in TAT for approval / signing. |
20+
| 2 | [`examples/ts/eth/fetch-erc20-votes-delegation-txrequest-signature.ts`](../ts/eth/fetch-erc20-votes-delegation-txrequest-signature.ts) | Once TAT signs, fetches the tx request and prints `v, r, s` (and optional `delegateBySig` calldata). |
21+
22+
Between the two steps, signing happens in **TAT** (operator-driven). You only
23+
re-run the fetch script after the message moves to `signed`.
24+
25+
## Prerequisites
26+
27+
- Node.js `>=20`
28+
- A repo-root `.env` file (the scripts load this via `dotenv`)
29+
- A custodial ETH MPC wallet on BitGo
30+
- A BitGo access token with permissions on that wallet
31+
- For test/non-WLFI tokens: the token's on-chain `eip712Domain()` values
32+
- A JSON-RPC URL for the chain that hosts the token (used to read
33+
`nonces(delegator)`), or the nonce fetched out-of-band
34+
35+
## `.env` template
36+
37+
Both scripts read the same environment variable names.
38+
39+
```bash
40+
# auth
41+
BITGO_ACCESS_TOKEN=... # or ACCESS_TOKEN
42+
BITGO_ENV=test # or `prod`
43+
44+
# wallet
45+
WALLET_ID=...
46+
COIN=hteth # eth | teth | hteth (must match the wallet)
47+
48+
# delegation message
49+
DELEGATEE=0x... # address that will vote (often a hot wallet)
50+
EXPIRY= # optional unix seconds; default: now + 1h
51+
52+
# nonce lookup (one of these)
53+
ETH_RPC_URL=https://... # script will call nonces(delegator) on the token
54+
NONCE= # OR set this manually if you already have it
55+
56+
# domain (one of these)
57+
EIP712_DOMAIN_JSON={"name":"...","version":"...","chainId":1,"verifyingContract":"0x..."}
58+
# (omit EIP712_DOMAIN_JSON if you are running BITGO_ENV=prod COIN=eth and want
59+
# the WLFI mainnet defaults baked into the script)
60+
61+
# step 2 only — set after step 1 prints it
62+
TX_REQUEST_ID=
63+
```
64+
65+
## Step 1 — push the EIP-712 delegation tx request
66+
67+
From the repo root:
68+
69+
```bash
70+
npx tsx examples/ts/eth/push-erc20-votes-delegation-txrequest.ts
71+
```
72+
73+
The script will:
74+
75+
1. Fetch the token's `nonces(delegator)` over `ETH_RPC_URL` (or use `NONCE`).
76+
2. Build the OZ `Delegation(address delegatee,uint256 nonce,uint256 expiry)`
77+
typed-data via `Erc20VotesDelegationMessageBuilder` from
78+
`@bitgo/abstract-eth`.
79+
3. `POST /api/v2/wallet/{walletId}/txrequests` with intent
80+
`signTypedStructuredData`, `isTss: true`, and
81+
`messageStandardType: EIP712`.
82+
4. Print the created tx request, including `txRequestId`.
83+
84+
**Save the `txRequestId`.** Put it in `.env` as `TX_REQUEST_ID` for step 2.
85+
86+
The unsigned message now appears in **TAT** for the operator to approve and
87+
sign with the cold wallet's MPC keys. WP routes EIP-712 typed-data messages
88+
through TAT (Kafka topic) instead of `sendQ` because the intent is tagged
89+
`messageStandardType: EIP712`.
90+
91+
## Step 2 — fetch the signature once TAT signs
92+
93+
```bash
94+
npx tsx examples/ts/eth/fetch-erc20-votes-delegation-txrequest-signature.ts
95+
```
96+
97+
The script will:
98+
99+
1. `GET /api/v2/wallet/{walletId}/txrequests?txRequestIds={id}&latest=true`.
100+
2. The list endpoint returns multiple **versioned snapshots** of the same
101+
`txRequestId` as the message moves through MPC rounds. The script picks
102+
the snapshot whose `messages[0].state === "signed"` (and whose `txHash`
103+
is a complete 65-byte ECDSA hex).
104+
3. If no signed snapshot exists yet, it prints
105+
`Message not signed yet, try again later.` and exits. Re-run once TAT has
106+
signed.
107+
4. Otherwise it splits `messages[0].txHash` into **`v`, `r`, `s`** with
108+
`ethers.utils.splitSignature` and prints them, plus ABI-encoded
109+
`delegateBySig(delegatee, nonce, expiry, v, r, s)` calldata when
110+
`messageRaw` is the delegation typed-data JSON.
111+
112+
## Step 3 — submit `delegateBySig` on-chain
113+
114+
Take `v, r, s` (or the printed calldata) and submit from any address. The
115+
cold wallet does not pay gas and does not move funds.
116+
117+
```ts
118+
await wlfiToken.delegateBySig(delegatee, nonce, expiry, v, r, s);
119+
```
120+
121+
If you printed calldata in step 2, broadcast a normal transaction from your
122+
hot wallet with `to = domain.verifyingContract` and `data = <calldata>`.
123+
124+
After confirmation, on-chain `delegates(coldWallet)` returns `delegatee` and
125+
the cold wallet's voting power is delegated.
126+
127+
## Troubleshooting
128+
129+
- **`Set BITGO_ACCESS_TOKEN ...`**`.env` was not found or the variable is
130+
unset. The scripts try `process.cwd()/.env` first, then
131+
`<script-dir>/../../../.env`. Run from the repo root, or export the
132+
variables in your shell.
133+
- **`Coin unsupported` from step 1**`COIN` must match the wallet's chain
134+
in WP. Try `hteth` for Holesky, `eth` for mainnet.
135+
- **Step 2 prints `Message not signed yet.`** — TAT has not signed yet, or
136+
the operator rejected the request. Check TAT for the `txRequestId` from
137+
step 1; re-run step 2 after it moves to `signed`.
138+
- **Multiple snapshots returned** — The list endpoint legitimately returns
139+
one row per MPC round. Step 2 always picks the signed snapshot when
140+
available, so this is informational.
141+
- **`splitSignature` throws** — Means `messages[0].txHash` is not a complete
142+
65-byte hex. Wait for `messages[0].state === "signed"` and re-run.
143+
144+
## Where this lives in the SDK
145+
146+
- `@bitgo/abstract-eth` (`modules/abstract-eth/src/lib/eip712/erc20VotesDelegation.ts`)
147+
- `buildErc20VotesDelegationTypedData({ domain, message })`
148+
- `encodeErc20VotesDelegationTypedDataDigest(Hex)`
149+
- `encodeDelegateBySigCalldata({ delegatee, nonce, expiry, v, r, s })`
150+
- `wlfiEthereumMainnetDelegationDomain()`
151+
- `@bitgo/abstract-eth` messages
152+
(`modules/abstract-eth/src/lib/messages/eip712/`)
153+
- `Erc20VotesDelegationMessage` / `Erc20VotesDelegationMessageBuilder`
154+
- `MessageBuilderFactory.getErc20VotesDelegationBuilder()`
155+
- `@bitgo/sdk-core`
156+
- `IntentOptionsForTypedData.messageStandardType`
157+
- `Wallet.signTypedData` sets `MessageStandardType.EIP712` so WP routes
158+
the message to TAT.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Fetch a custodial TSS typed-data (EIP-712) tx request and print the ECDSA signature
4+
* as v, r, s for use with ERC20Votes-style `delegateBySig`.
5+
*
6+
* Step 2 of the two-step ERC20Votes / WLFI cold-custody delegation flow described in
7+
* `examples/docs/erc20-votes-delegate-by-sig.md` (CGD-842). Step 1 is
8+
* `examples/ts/eth/push-erc20-votes-delegation-txrequest.ts`, which creates the tx
9+
* request and prints its `txRequestId`.
10+
*
11+
* API: GET /api/v2/wallet/{walletId}/txrequests?txRequestIds={id}&latest=true
12+
* (same shape as {@link https://developers.bitgo.com/reference/v2wallettxrequestget v2.wallet.txrequest.get})
13+
*
14+
* After signing completes, BitGo stores the 65-byte secp256k1 signature on
15+
* `messages[0].txHash` (see wallet `signTypedDataTss`).
16+
*
17+
* The list endpoint can return **multiple rows** for the same `txRequestId`
18+
* (versioned snapshots: MPC rounds, then `signed`). This script picks the
19+
* snapshot whose `messages[0].state === "signed"` (and whose `txHash` is a
20+
* complete 65-byte ECDSA hex). If no signed snapshot exists yet, it prints
21+
* "Message not signed yet, try again later." — re-run after TAT signs.
22+
*
23+
* Configuration is read from `.env` via dotenv. The file is resolved as:
24+
* `{current working directory}/.env` if it exists, otherwise the monorepo
25+
* root `.env` next to this script (`examples/ts/eth` → three levels up).
26+
*
27+
* Environment (same names as the companion push script):
28+
* - `BITGO_ACCESS_TOKEN` (or `ACCESS_TOKEN`)
29+
* - `WALLET_ID`
30+
* - `TX_REQUEST_ID` — id printed by the push script
31+
* - `COIN` — must match the wallet's chain (e.g. `eth`, `teth`, `hteth`)
32+
* - `BITGO_ENV` — `test` (default) or `prod`
33+
*
34+
* Copyright 2026, BitGo, Inc. All Rights Reserved.
35+
*/
36+
import fs from 'fs';
37+
import path from 'path';
38+
39+
import dotenv from 'dotenv';
40+
import { BitGoAPI } from '@bitgo/sdk-api';
41+
import { EnvironmentName } from '@bitgo/sdk-core';
42+
import { Eth, Hteth, Teth } from '@bitgo/sdk-coin-eth';
43+
import { ethers } from 'ethers';
44+
45+
async function loadEnv(): Promise<void> {
46+
const candidates = [path.join(process.cwd(), '.env'), path.join(__dirname, '../../../.env')];
47+
for (const envPath of candidates) {
48+
try {
49+
await fs.promises.access(envPath, fs.constants.F_OK);
50+
dotenv.config({ path: envPath });
51+
return;
52+
} catch {
53+
// try next candidate
54+
}
55+
}
56+
dotenv.config();
57+
}
58+
59+
type TxRequestMessage = {
60+
state?: string;
61+
messageRaw?: string;
62+
txHash?: string;
63+
};
64+
65+
type TxRequestBody = {
66+
txRequestId?: string;
67+
state?: string;
68+
/** Monotonic snapshot id when BitGo returns several rows for one logical tx request */
69+
version?: number;
70+
/** True on the row you should treat as current (e.g. delivered + signed txHash) */
71+
latest?: boolean;
72+
messages?: TxRequestMessage[];
73+
};
74+
75+
/** BitGo stores a 65-byte secp256k1 ECDSA signature as hex (130 digits, optional 0x). */
76+
function isCompleteSignatureHex(txHash: string | undefined): boolean {
77+
if (!txHash?.trim()) {
78+
return false;
79+
}
80+
const hex = txHash.trim().startsWith('0x') ? txHash.trim().slice(2) : txHash.trim();
81+
if (!/^[0-9a-fA-F]+$/i.test(hex)) {
82+
return false;
83+
}
84+
return hex.length === 130;
85+
}
86+
87+
function isSignedMessageSnapshot(t: TxRequestBody): boolean {
88+
const m = t.messages?.[0];
89+
return m?.state === 'signed' && isCompleteSignatureHex(m.txHash);
90+
}
91+
92+
function maxByVersion(txRequests: TxRequestBody[]): TxRequestBody {
93+
return txRequests.reduce((best, cur) => ((cur.version ?? -1) >= (best.version ?? -1) ? cur : best));
94+
}
95+
96+
function pickSignedSnapshot(txRequests: TxRequestBody[]): TxRequestBody | undefined {
97+
const signedSnapshots = txRequests.filter(isSignedMessageSnapshot);
98+
if (signedSnapshots.length === 0) {
99+
return undefined;
100+
}
101+
return maxByVersion(signedSnapshots);
102+
}
103+
104+
function registerEthCoins(bitgo: BitGoAPI): void {
105+
bitgo.register('eth', Eth.createInstance);
106+
bitgo.register('teth', Teth.createInstance);
107+
bitgo.register('hteth', Hteth.createInstance);
108+
}
109+
110+
function encodeDelegateBySigCalldata(params: {
111+
delegatee: string;
112+
nonce: string;
113+
expiry: string;
114+
v: number;
115+
r: string;
116+
s: string;
117+
}): string {
118+
const delegateBySigAbi = [
119+
'function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s)',
120+
];
121+
const iface = new ethers.utils.Interface(delegateBySigAbi);
122+
return iface.encodeFunctionData('delegateBySig', [
123+
ethers.utils.getAddress(params.delegatee),
124+
ethers.BigNumber.from(params.nonce),
125+
ethers.BigNumber.from(params.expiry),
126+
params.v,
127+
params.r,
128+
params.s,
129+
]);
130+
}
131+
132+
function tryParseDelegationFields(messageRaw: string):
133+
| {
134+
delegatee: string;
135+
nonce: string;
136+
expiry: string;
137+
}
138+
| undefined {
139+
try {
140+
const parsed = JSON.parse(messageRaw) as { message?: Record<string, unknown> };
141+
const msg = parsed.message;
142+
if (!msg || typeof msg !== 'object') {
143+
return undefined;
144+
}
145+
const { delegatee, nonce, expiry } = msg;
146+
if (typeof delegatee !== 'string' || typeof nonce === 'undefined' || typeof expiry === 'undefined') {
147+
return undefined;
148+
}
149+
return { delegatee, nonce: String(nonce), expiry: String(expiry) };
150+
} catch {
151+
return undefined;
152+
}
153+
}
154+
155+
async function fetchTxRequests(bitgo: BitGoAPI, walletId: string, txRequestId: string): Promise<TxRequestBody[]> {
156+
const res = (await bitgo
157+
.get(bitgo.url('/wallet/' + walletId + '/txrequests', 2))
158+
.query({ txRequestIds: txRequestId, latest: 'true' })
159+
.result()) as { txRequests: TxRequestBody[] };
160+
161+
if (!res.txRequests?.length) {
162+
throw new Error(`No tx request found for id ${txRequestId}`);
163+
}
164+
return res.txRequests;
165+
}
166+
167+
async function main(): Promise<void> {
168+
await loadEnv();
169+
170+
const accessToken = process.env.BITGO_ACCESS_TOKEN ?? process.env.ACCESS_TOKEN;
171+
const walletId = process.env.WALLET_ID;
172+
const txRequestId = process.env.TX_REQUEST_ID;
173+
const coin = process.env.COIN;
174+
const env: EnvironmentName = process.env.BITGO_ENV === 'prod' ? 'prod' : 'test';
175+
176+
if (!accessToken) {
177+
throw new Error('Set BITGO_ACCESS_TOKEN (or ACCESS_TOKEN)');
178+
}
179+
if (!walletId) {
180+
throw new Error('Set WALLET_ID');
181+
}
182+
if (!coin) {
183+
throw new Error('Set COIN to the wallet chain (e.g. eth, teth, hteth)');
184+
}
185+
if (!txRequestId) {
186+
throw new Error('Set TX_REQUEST_ID — use the tx request id printed by the push script');
187+
}
188+
189+
const bitgo = new BitGoAPI({ accessToken, env });
190+
registerEthCoins(bitgo);
191+
bitgo.coin(coin);
192+
193+
const txRequests = await fetchTxRequests(bitgo, walletId, txRequestId);
194+
const signed = pickSignedSnapshot(txRequests);
195+
196+
if (!signed) {
197+
console.log('Message not signed yet, try again later.');
198+
return;
199+
}
200+
201+
const msg0 = signed.messages?.[0];
202+
const txHash = msg0?.txHash;
203+
if (!msg0 || !txHash) {
204+
console.log('Message not signed yet, try again later.');
205+
return;
206+
}
207+
const sigHex = txHash.startsWith('0x') ? txHash : `0x${txHash}`;
208+
const { v, r, s } = ethers.utils.splitSignature(sigHex);
209+
210+
console.log('txRequestId:', signed.txRequestId ?? txRequestId);
211+
console.log('snapshot version:', signed.version ?? 'n/a');
212+
console.log('messages[0].state:', msg0.state);
213+
console.log('\nECDSA signature (from messages[0].txHash):');
214+
console.log('v:', v);
215+
console.log('r:', r);
216+
console.log('s:', s);
217+
218+
if (msg0.messageRaw) {
219+
const fields = tryParseDelegationFields(msg0.messageRaw);
220+
if (fields) {
221+
try {
222+
const calldata = encodeDelegateBySigCalldata({ ...fields, v, r, s });
223+
console.log('\ndelegateBySig calldata:');
224+
console.log(calldata);
225+
} catch (e) {
226+
console.warn('Could not build delegateBySig calldata:', (e as Error).message);
227+
}
228+
}
229+
}
230+
}
231+
232+
main().catch((e) => {
233+
console.error(e);
234+
process.exitCode = 1;
235+
});

0 commit comments

Comments
 (0)