Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions examples/docs/erc20-votes-delegate-by-sig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Delegate Governance Voting Power From a BitGo Custodial Wallet

This guide explains how to delegate voting power for OpenZeppelin
`ERC20Votes`-style governance tokens (e.g. WLFI, UNI, COMP, ARB, ENS, OP)
from a BitGo custodial cold wallet.

It uses two scripts in `examples/ts/eth/`:

| Step | Script | What it produces |
| --- | --- | --- |
| 1 | `create-erc20-votes-delegation-txrequest.ts` | Creates a delegation message and prints a `txRequestId` |
| 2 | `get-erc20-votes-delegation-signature.ts` | Once BitGo has signed, prints `v, r, s` and ready-to-broadcast `delegateBySig` calldata |

Between the two steps, BitGo's custodial signing workflow approves and signs
the message with your cold wallet's MPC keys. You re-run step 2 when you
want to retrieve the signature.

## Prerequisites

- BitGo SDK installed
- BitGo account and API access token with permissions on the wallet
- A custodial ETH MPC wallet on BitGo
- `.env` file with the variables listed below
- A JSON-RPC URL for the chain that hosts the token (used to read
`nonces(delegator)`), or the nonce fetched out-of-band

## .env file

Both scripts read the same environment variable names. Create
`<repo-root>/.env`:

```bash
# auth
BITGO_ACCESS_TOKEN=your_access_token_here # or ACCESS_TOKEN
BITGO_ENV=test # or `prod`

# wallet
WALLET_ID=your_wallet_id
COIN=hteth # eth | teth | hteth (must match the wallet)

# delegation message
DELEGATEE=0xYourHotWalletAddress # address that will vote
EXPIRY= # optional unix seconds; default: now + 1h

# nonce lookup (one of these is required)
ETH_RPC_URL=https://... # script will call nonces(delegator) on the token
NONCE= # OR set this manually if you already have it

# domain (one of these is required)
EIP712_DOMAIN_JSON={"name":"...","version":"...","chainId":1,"verifyingContract":"0x..."}
# Omit EIP712_DOMAIN_JSON if you are running BITGO_ENV=prod COIN=eth and want
# the WLFI mainnet defaults baked into the script.

# step 2 only — set after step 1 prints it
TX_REQUEST_ID=
```

## Step 1 — create the delegation request

First, run `yarn install` from the root directory of the repository.

Then run the create script from the repo root:

```
$ npx tsx examples/ts/eth/create-erc20-votes-delegation-txrequest.ts
```

### Expected output

```
Tx request created.

txRequestId : db1ccd60-58d4-418e-9d23-d42d67eebd5b
state : pendingDelivery
delegator : 0xabc...
delegatee : 0xdef...
nonce : 1
expiry : 1777882731

Next: add the txRequestId above to your .env as TX_REQUEST_ID,
then run get-erc20-votes-delegation-signature.ts to retrieve the
signature once BitGo has signed the message.
```

**Copy the `txRequestId`** from the output and add it to `.env` as
`TX_REQUEST_ID`. The unsigned message is now queued in BitGo for signing.

### Note

- `COIN` must match your wallet's chain in BitGo (e.g. `eth`, `teth`,
`hteth`).
- `DELEGATEE` is the address that will vote on your behalf — usually your
self-custody hot wallet.
- `EXPIRY` is the unix-seconds deadline for someone to submit the signature
on-chain. The delegation itself does not expire; only the submission
window does.

## Step 2 — get the signature

Once BitGo has signed the message, run:

```
$ npx tsx examples/ts/eth/get-erc20-votes-delegation-signature.ts
```

### Expected output (signed)

```
Signature retrieved.

txRequestId : db1ccd60-58d4-418e-9d23-d42d67eebd5b
v : 28
r : 0xc7b471134954a6c6f4b0eb4422ce5c400d61c8aa793f6a527cede00fb225d8c3
s : 0x31181c2ad2ffd2562da10f000b6a7e2dafdb1ae6522b46bd3aa9bd1540392424

delegateBySig calldata (broadcast as `data` to the token contract):
0x5c19a95c000000000000000000000000def...
```

### Expected output (still pending)

If BitGo has not signed yet:

```
Message not signed yet, try again later.
```

Re-run the script later to retrieve the signature once BitGo has signed.

## Step 3 — submit `delegateBySig` on-chain

Take `v, r, s` (or the printed calldata) and submit from any address. Your
cold wallet does not pay gas and does not move funds.

Using a contract instance (e.g. ethers):

```ts
await votesToken.delegateBySig(delegatee, nonce, expiry, v, r, s);
```

Or send a raw transaction from your hot wallet:

- `to` = the token contract address (`domain.verifyingContract`)
- `data` = the `delegateBySig calldata` printed by step 2

After confirmation, on-chain `delegates(coldWallet)` returns `delegatee` and
the cold wallet's voting power is delegated.

## Troubleshooting

### `Set BITGO_ACCESS_TOKEN ...` (or any other `Set X ...` error)

The variable is missing from your environment. Confirm `.env` exists at the
repo root, contains the variable, and that you are running the script from
the repo root so the `.env` file is picked up.

### `Coin unsupported` from step 1

`COIN` does not match a chain enabled for this wallet on this BitGo
environment. Try `hteth` for Holesky, `teth` for Sepolia, or `eth` for
mainnet — and make sure it matches the coin shown for your wallet in the
BitGo UI.

### `Wallet has no receiveAddress yet`

The wallet does not have any addresses yet. Create or fund an address
first, or set `DELEGATEE` and `NONCE` manually so the script does not need
to look them up from the wallet.

### `EIP712_DOMAIN_JSON must include "..."`

Your token's domain JSON is missing one of the required fields. Read the
token's `eip712Domain()` function on-chain and pass all four fields:

```bash
EIP712_DOMAIN_JSON='{"name":"...","version":"...","chainId":1,"verifyingContract":"0x..."}'
```

### Step 2 prints `Message not signed yet, try again later.`

BitGo has not finished signing the message. Wait and re-run the script. If
signing has been pending for an unusually long time, contact BitGo support
and reference the `txRequestId` printed in step 1.

## Additional Resources

- [BitGo Developer Documentation](https://developers.bitgo.com/)
- [EIP-712: Typed Structured Data Signing](https://eips.ethereum.org/EIPS/eip-712)
- [OpenZeppelin `ERC20VotesUpgradeable`](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/master/contracts/token/ERC20/extensions/ERC20VotesUpgradeable.sol)

## Support

For questions or issues:

1. Check the [BitGo Developer Documentation](https://developers.bitgo.com/)
2. Open an issue on [GitHub](https://github.com/BitGo/BitGoJS/issues)
3. Contact BitGo support
174 changes: 174 additions & 0 deletions examples/ts/eth/create-erc20-votes-delegation-txrequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* eslint-disable no-console */
/**
* Create a delegation request for an OpenZeppelin ERC20Votes-style
* `delegateBySig` from a BitGo custodial wallet.
*
* Step 1 of the flow described in
* `examples/docs/erc20-votes-delegate-by-sig.md`. Prints a `txRequestId`
* that you copy into `.env` as `TX_REQUEST_ID` for step 2
* (`examples/ts/eth/get-erc20-votes-delegation-signature.ts`).
*
* Run from the repo root:
*
* npx tsx examples/ts/eth/create-erc20-votes-delegation-txrequest.ts
*
* Required env:
* BITGO_ACCESS_TOKEN (or ACCESS_TOKEN)
* WALLET_ID
* COIN — must match the wallet's chain (e.g. eth, teth, hteth)
* DELEGATEE — address that will vote (defaults to the wallet's
* receive address for self-delegation)
*
* Nonce — set one of:
* ETH_RPC_URL — JSON-RPC URL for the token chain; the script calls
* `nonces(delegator)` on the token contract.
* NONCE — manual override (wins over ETH_RPC_URL).
*
* Domain — set one of:
* EIP712_DOMAIN_JSON — { name, version, chainId, verifyingContract } from
* the token's on-chain `eip712Domain()`.
* Or run with `BITGO_ENV=prod COIN=eth` to use the WLFI mainnet defaults.
*
* Optional:
* BITGO_ENV — `test` (default) or `prod`
* EXPIRY — unix seconds; default: now + 1h
*
* Copyright 2026, BitGo, Inc. All Rights Reserved.
*/

import path from 'path';

import dotenv from 'dotenv';

dotenv.config({ path: path.resolve(__dirname, '../../../.env') });

import {
Erc20VotesDelegationMessageBuilder,
wlfiEthereumMainnetDelegationDomain,
type Erc20VotesDelegationDomain,
} from '../../../modules/abstract-eth/src/index';
import { BitGoAPI } from '@bitgo/sdk-api';
import { Eth, Hteth, Teth } from '@bitgo/sdk-coin-eth';
import { MessageStandardType, type EnvironmentName } from '@bitgo/sdk-core';
import { coins } from '@bitgo/statics';
import { ethers } from 'ethers';

const NONCES_ABI = ['function nonces(address owner) view returns (uint256)'];

async function fetchTokenNonce(rpcUrl: string, tokenAddress: string, delegator: string): Promise<string> {
const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
const token = new ethers.Contract(tokenAddress, NONCES_ABI, provider);
const nonce = (await token.nonces(delegator)) as ethers.BigNumber;
return nonce.toString();
}

async function resolveNonce(domainContract: string, delegator: string): Promise<string> {
const manual = process.env.NONCE?.trim();
if (manual) {
return manual;
}
const rpcUrl = process.env.ETH_RPC_URL?.trim();
if (!rpcUrl) {
throw new Error('Set ETH_RPC_URL (to fetch nonces(delegator)) or NONCE in .env');
}
return fetchTokenNonce(rpcUrl, domainContract, delegator);
}

function parseDelegationDomainFromEnv(coin: string, bitgoEnv: EnvironmentName): Erc20VotesDelegationDomain {
const raw = process.env.EIP712_DOMAIN_JSON?.trim();
if (raw) {
const d = JSON.parse(raw) as Record<string, unknown>;
for (const k of ['name', 'version', 'chainId', 'verifyingContract']) {
if (d[k] === undefined) {
throw new Error(`EIP712_DOMAIN_JSON must include "${k}" (from the token's eip712Domain())`);
}
}
return {
name: String(d.name),
version: String(d.version),
chainId: Number(d.chainId),
verifyingContract: ethers.utils.getAddress(String(d.verifyingContract)),
};
}
if (coin === 'eth' && bitgoEnv === 'prod') {
return wlfiEthereumMainnetDelegationDomain();
}
throw new Error('Set EIP712_DOMAIN_JSON in .env (or use BITGO_ENV=prod COIN=eth for WLFI mainnet defaults)');
}

async function main(): Promise<void> {
const accessToken = process.env.BITGO_ACCESS_TOKEN ?? process.env.ACCESS_TOKEN;
const walletId = process.env.WALLET_ID;
const coin = process.env.COIN;
const env: EnvironmentName = process.env.BITGO_ENV === 'prod' ? 'prod' : 'test';
const expiry = process.env.EXPIRY ?? String(Math.floor(Date.now() / 1000) + 3600);

if (!accessToken) {
throw new Error('Set BITGO_ACCESS_TOKEN (or ACCESS_TOKEN) in .env');
}
if (!walletId) {
throw new Error('Set WALLET_ID in .env');
}
if (!coin) {
throw new Error('Set COIN in .env (e.g. eth, teth, hteth)');
}

const domain = parseDelegationDomainFromEnv(coin, env);

const bitgo = new BitGoAPI({ env });
bitgo.register('eth', Eth.createInstance);
bitgo.register('teth', Teth.createInstance);
bitgo.register('hteth', Hteth.createInstance);
await bitgo.authenticateWithAccessToken({ accessToken });

const wallet = await bitgo.coin(coin).wallets().get({ id: walletId });
const delegator = wallet.receiveAddress();
if (!delegator) {
throw new Error('Wallet has no receiveAddress yet. Create or fund an address first.');
}
const delegatee = process.env.DELEGATEE?.trim() || delegator;

const nonce = await resolveNonce(domain.verifyingContract, delegator);

const builder = new Erc20VotesDelegationMessageBuilder(coins.get(coin));
const message = await builder.buildFromDelegation({
domain,
message: { delegatee, nonce, expiry },
});
const messageEncoded = (await message.getSignablePayload()).toString('hex');

const body = {
intent: {
intentType: 'signTypedStructuredData',
isTss: true,
messageRaw: message.getPayload(),
messageEncoded,
messageStandardType: MessageStandardType.EIP712,
},
apiVersion: 'full',
};

const txRequest = (await bitgo
.post(bitgo.url(`/wallet/${walletId}/txrequests`, 2))
.send(body)
.result()) as { txRequestId?: string; state?: string };

console.log('');
console.log('Tx request created.');
console.log('');
console.log(` txRequestId : ${txRequest.txRequestId ?? '<not returned>'}`);
console.log(` state : ${txRequest.state ?? 'n/a'}`);
console.log(` delegator : ${delegator}`);
console.log(` delegatee : ${delegatee}`);
console.log(` nonce : ${nonce}`);
console.log(` expiry : ${expiry}`);
console.log('');
console.log('Next: add the txRequestId above to your .env as TX_REQUEST_ID,');
console.log('then run get-erc20-votes-delegation-signature.ts to retrieve the');
console.log('signature once BitGo has signed the message.');
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
Loading
Loading