Skip to content

Commit 2dd5905

Browse files
committed
feat(abstract-eth): add ERC20Votes delegateBySig EIP-712 helpers
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. - Adds a runnable example under `examples/ts/eth/push-erc20-votes-delegation-txrequest.ts` that pushes a delegation tx request to WP via `wallet.signTypedData(...)`. - Unit tests cover the domain/message hash, digest hex, and calldata. Ticket: CGD-842 Made-with: Cursor
1 parent 74744c2 commit 2dd5905

8 files changed

Lines changed: 506 additions & 1 deletion

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Create a Wallet Platform tx request for EIP-712 ERC20Votes delegation (`signTypedStructuredData`)
4+
* so it flows through policy and appears for operators in the Admin / BGMS approval UI.
5+
*
6+
* This does **not** sign locally: no wallet passphrase or `prv`. It only POSTs the intent BitGo
7+
* expects (same shape as `createTxRequestWithIntentForTypedDataSigning` in sdk-core).
8+
*
9+
* Uses `BitGoAPI` + `@bitgo/sdk-coin-eth` only (not the full `bitgo` metapackage) to avoid loading
10+
* every coin implementation — warnings like duplicate `@polkadot/*` can still appear from
11+
* transitive deps; they are harmless for this script.
12+
*
13+
* From repo root after `yarn install`:
14+
*
15+
* npx tsx examples/ts/eth/push-erc20-votes-delegation-txrequest.ts
16+
*
17+
* Required env:
18+
* BITGO_ACCESS_TOKEN (or ACCESS_TOKEN)
19+
* WALLET_ID
20+
* COIN — must match the wallet’s chain in BitGo / WP (e.g. `eth`, `teth`, `hteth`). If you see
21+
* `Coin unsupported`, this environment does not enable that coin — try `hteth` (Holesky)
22+
* or `eth` (mainnet) per your org.
23+
*
24+
* Delegation fields:
25+
* DELEGATEE — defaults to the wallet’s receive address (self-delegation) if unset
26+
* NONCE — EIP-712 nonce from the token’s `nonces(delegator)`; if unset, set ETH_RPC_URL and this
27+
* script will read it on-chain (delegator = wallet receive address; token = domain.verifyingContract)
28+
* EXPIRY — optional unix seconds; default now + 3600
29+
*
30+
* Optional:
31+
* BITGO_ENV — `test` (default) or `prod`
32+
* ETH_RPC_URL — JSON-RPC URL matching `EIP712_DOMAIN_JSON.chainId` (required when NONCE is omitted)
33+
* EIP712_DOMAIN_JSON — same rules as other delegation examples; required for test / non-WLFI mainnet
34+
* TX_API_VERSION — `full` (default) or `lite`
35+
* TXREQUEST_PREVIEW — set to `true` to preview only (skips policy / pending approval; usually **not** what you want for Admin)
36+
* TX_COMMENT, SEQUENCE_ID, CUSTODIAN_MESSAGE_ID — forwarded on the intent if set
37+
*
38+
* The intent always includes `messageStandardType: EIP712` so OVC `verifyOffchainMessages` can validate
39+
* custodial exports (TAT downloads must carry the same field on each `messages[]` entry — Wallet Platform
40+
* should copy intent fields onto messages when creating the tx request; `createTxRequestWithIntentForTypedDataSigning`
41+
* in sdk-core now sends this field for all typed-data tx request API flows).
42+
*
43+
* Custodial signing (after this script):
44+
* Wallet Platform completes MPC for custodial / trust-held user shares using internal trust
45+
* tooling and admin APIs (not the same as entering a wallet passphrase in the retail UI).
46+
* If you do not see a “sign” action in your portal, use your BitGo runbook or contact BitGo
47+
* support / CSM for the trust-operator flow for `signTypedStructuredData` on your stack.
48+
*
49+
* Copyright 2026, BitGo, Inc. All Rights Reserved.
50+
*/
51+
52+
import path from 'path';
53+
54+
import dotenv from 'dotenv';
55+
56+
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
57+
58+
import {
59+
buildErc20VotesDelegationTypedData,
60+
encodeErc20VotesDelegationTypedDataDigestHex,
61+
wlfiEthereumMainnetDelegationDomain,
62+
type Erc20VotesDelegationDomain,
63+
} from '@bitgo/abstract-eth';
64+
import { BitGoAPI } from '@bitgo/sdk-api';
65+
import { Eth, Hteth, Teth } from '@bitgo/sdk-coin-eth';
66+
import { MessageStandardType, type EnvironmentName } from '@bitgo/sdk-core';
67+
import { ethers } from 'ethers';
68+
69+
function createBitGoClient(env: EnvironmentName): BitGoAPI {
70+
const bitgo = new BitGoAPI({ env });
71+
bitgo.register('eth', Eth.createInstance);
72+
bitgo.register('teth', Teth.createInstance);
73+
bitgo.register('hteth', Hteth.createInstance);
74+
return bitgo;
75+
}
76+
77+
function requireEnv(name: string): string {
78+
const v = process.env[name];
79+
if (!v) {
80+
throw new Error(`Missing required environment variable: ${name}`);
81+
}
82+
return v;
83+
}
84+
85+
function parseDelegationDomainFromEnv(coin: string, bitgoEnv: EnvironmentName): Erc20VotesDelegationDomain {
86+
const raw = process.env.EIP712_DOMAIN_JSON?.trim();
87+
if (raw) {
88+
const d = JSON.parse(raw) as Record<string, unknown>;
89+
for (const k of ['name', 'version', 'chainId', 'verifyingContract']) {
90+
if (d[k] === undefined) {
91+
throw new Error(`EIP712_DOMAIN_JSON must include "${k}" (from token eip712Domain())`);
92+
}
93+
}
94+
return {
95+
name: String(d.name),
96+
version: String(d.version),
97+
chainId: Number(d.chainId),
98+
verifyingContract: ethers.utils.getAddress(String(d.verifyingContract)),
99+
};
100+
}
101+
102+
if (coin === 'eth' && bitgoEnv === 'prod') {
103+
return wlfiEthereumMainnetDelegationDomain();
104+
}
105+
106+
throw new Error(
107+
'Set EIP712_DOMAIN_JSON (name, version, chainId, verifyingContract from the token `eip712Domain()`), ' +
108+
'or use BITGO_ENV=prod COIN=eth for WLFI mainnet defaults.'
109+
);
110+
}
111+
112+
const erc20VotesNoncesAbi = ['function nonces(address owner) view returns (uint256)'];
113+
114+
async function fetchDelegationNonceFromChain(params: {
115+
rpcUrl: string;
116+
tokenAddress: string;
117+
delegatorAddress: string;
118+
expectedChainId: number;
119+
}): Promise<string> {
120+
const provider = new ethers.providers.JsonRpcProvider(params.rpcUrl);
121+
const network = await provider.getNetwork();
122+
if (network.chainId !== params.expectedChainId) {
123+
throw new Error(
124+
`ETH_RPC_URL points to chainId ${network.chainId}; EIP712 domain expects chainId ${params.expectedChainId}`
125+
);
126+
}
127+
const token = new ethers.Contract(params.tokenAddress, erc20VotesNoncesAbi, provider);
128+
const n = await token.nonces(params.delegatorAddress);
129+
return ethers.BigNumber.from(n).toString();
130+
}
131+
132+
function rethrowIfCoinUnsupported(e: unknown, coin: string): never {
133+
const err = e as { result?: { error?: string; name?: string } };
134+
if (err?.result?.name === 'CoinUnsupported' || String(err?.result?.error).includes('Coin unsupported')) {
135+
throw new Error(
136+
`Wallet Platform does not support coin "${coin}" for this host (Coin unsupported). ` +
137+
`Set COIN to a chain your environment enables (examples: hteth for Holesky, eth for Ethereum mainnet, ` +
138+
`teth for Sepolia on public BitGo test). It must match the wallet’s coin in the BitGo UI.`
139+
);
140+
}
141+
throw e;
142+
}
143+
144+
async function main(): Promise<void> {
145+
const accessToken = process.env.BITGO_ACCESS_TOKEN ?? process.env.ACCESS_TOKEN;
146+
if (!accessToken) {
147+
throw new Error('Set BITGO_ACCESS_TOKEN (or ACCESS_TOKEN)');
148+
}
149+
150+
const env: EnvironmentName = process.env.BITGO_ENV === 'prod' ? 'prod' : 'test';
151+
const coin = requireEnv('COIN');
152+
const walletId = requireEnv('WALLET_ID');
153+
const expiry = process.env.EXPIRY ?? String(Math.floor(Date.now() / 1000) + 3600);
154+
155+
const apiVersion = (process.env.TX_API_VERSION as 'full' | 'lite') || 'full';
156+
if (apiVersion !== 'full' && apiVersion !== 'lite') {
157+
throw new Error('TX_API_VERSION must be "full" or "lite"');
158+
}
159+
const preview = process.env.TXREQUEST_PREVIEW === 'true';
160+
161+
const domain = parseDelegationDomainFromEnv(coin, env);
162+
163+
const bitgo = createBitGoClient(env);
164+
await bitgo.authenticateWithAccessToken({ accessToken });
165+
166+
let wallet;
167+
try {
168+
wallet = await bitgo.coin(coin).wallets().get({ id: walletId });
169+
} catch (e) {
170+
rethrowIfCoinUnsupported(e, coin);
171+
}
172+
173+
const delegator = wallet.receiveAddress();
174+
if (!delegator) {
175+
throw new Error(
176+
'Wallet has no receiveAddress yet; create or fund an address first, or set DELEGATEE / NONCE manually.'
177+
);
178+
}
179+
180+
const delegatee = process.env.DELEGATEE?.trim() || delegator;
181+
182+
let nonce = process.env.NONCE?.trim();
183+
if (!nonce) {
184+
const rpcUrl = process.env.ETH_RPC_URL?.trim();
185+
if (!rpcUrl) {
186+
throw new Error(
187+
'Set NONCE (decimal from token `nonces(delegator)` on-chain), or omit NONCE and set ETH_RPC_URL ' +
188+
'so this script can call `nonces` on domain.verifyingContract for the wallet receive address.'
189+
);
190+
}
191+
nonce = await fetchDelegationNonceFromChain({
192+
rpcUrl,
193+
tokenAddress: domain.verifyingContract,
194+
delegatorAddress: ethers.utils.getAddress(delegator),
195+
expectedChainId: domain.chainId,
196+
});
197+
console.log('Fetched EIP-712 nonce from token nonces(delegator):', nonce, {
198+
delegator,
199+
token: domain.verifyingContract,
200+
});
201+
}
202+
203+
const typedDataObject = buildErc20VotesDelegationTypedData({
204+
domain,
205+
message: { delegatee, nonce, expiry },
206+
});
207+
const typedDataRaw = JSON.stringify(typedDataObject);
208+
const messageEncoded = encodeErc20VotesDelegationTypedDataDigestHex(typedDataObject);
209+
210+
const intent: Record<string, string | boolean | undefined> = {
211+
intentType: 'signTypedStructuredData',
212+
isTss: true,
213+
messageRaw: typedDataRaw,
214+
messageEncoded,
215+
messageStandardType: MessageStandardType.EIP712,
216+
};
217+
if (process.env.TX_COMMENT) {
218+
intent.comment = process.env.TX_COMMENT;
219+
}
220+
if (process.env.SEQUENCE_ID) {
221+
intent.sequenceId = process.env.SEQUENCE_ID;
222+
}
223+
if (process.env.CUSTODIAN_MESSAGE_ID) {
224+
intent.custodianMessageId = process.env.CUSTODIAN_MESSAGE_ID;
225+
}
226+
227+
const body = {
228+
intent,
229+
apiVersion,
230+
preview,
231+
};
232+
233+
console.log('POST', `/api/v2/wallet/${walletId}/txrequests`, { apiVersion, preview, coin });
234+
console.log('Delegator (wallet receive address):', delegator);
235+
console.log('Delegatee:', delegatee);
236+
console.log('intent.intentType:', intent.intentType);
237+
console.log('intent.messageEncoded length (hex chars):', messageEncoded.length);
238+
239+
const txRequest = (await bitgo
240+
.post(bitgo.url(`/wallet/${walletId}/txrequests`, 2))
241+
.send(body)
242+
.result()) as Record<string, unknown>;
243+
244+
console.log('');
245+
console.log('Tx request created:', JSON.stringify(txRequest, null, 2));
246+
console.log('');
247+
if (preview) {
248+
console.log('Note: TXREQUEST_PREVIEW=true — this was a preview; it may not create a pending approval in Admin.');
249+
} else {
250+
console.log(
251+
'Next: open Admin for this wallet or enterprise and look up this tx request / linked pending approval.'
252+
);
253+
console.log('If nothing appears, check enterprise policy, wallet permissions, and that the wallet is TSS on', coin);
254+
}
255+
256+
console.log('');
257+
console.log(
258+
'Custodial TSS: completing this sign usually requires trust / BitGo-operator tools (MPC rounds), ' +
259+
'not a wallet passphrase in the customer UI. Ask your BitGo contact for the procedure on your environment.'
260+
);
261+
console.log(
262+
'Also align EIP-712 domain.chainId with the chain your wallet uses (your payload used mainnet domain ' +
263+
'fields while the tx request message may show a test coin like hteth — fix domain vs COIN if signing fails).'
264+
);
265+
}
266+
267+
main().catch((e) => {
268+
console.error(e);
269+
process.exit(1);
270+
});

0 commit comments

Comments
 (0)