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