diff --git a/examples/docs/erc20-votes-delegate-by-sig.md b/examples/docs/erc20-votes-delegate-by-sig.md new file mode 100644 index 0000000000..c6a1ee7d3d --- /dev/null +++ b/examples/docs/erc20-votes-delegate-by-sig.md @@ -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 +`/.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 diff --git a/examples/ts/eth/create-erc20-votes-delegation-txrequest.ts b/examples/ts/eth/create-erc20-votes-delegation-txrequest.ts new file mode 100644 index 0000000000..e3d379dc30 --- /dev/null +++ b/examples/ts/eth/create-erc20-votes-delegation-txrequest.ts @@ -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 { + 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 { + 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; + 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 { + 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 ?? ''}`); + 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); +}); diff --git a/examples/ts/eth/get-erc20-votes-delegation-signature.ts b/examples/ts/eth/get-erc20-votes-delegation-signature.ts new file mode 100644 index 0000000000..523357fa58 --- /dev/null +++ b/examples/ts/eth/get-erc20-votes-delegation-signature.ts @@ -0,0 +1,196 @@ +/* eslint-disable no-console */ +/** + * Get the ECDSA signature for a delegation request once BitGo has signed it. + * Prints `v`, `r`, `s` and the ABI-encoded `delegateBySig` calldata that you + * can broadcast on-chain from any address. + * + * Step 2 of the flow described in + * `examples/docs/erc20-votes-delegate-by-sig.md`. Step 1 is + * `examples/ts/eth/create-erc20-votes-delegation-txrequest.ts`, which + * creates the request and prints its `txRequestId`. + * + * Run from the repo root: + * + * npx tsx examples/ts/eth/get-erc20-votes-delegation-signature.ts + * + * Required env (same names as step 1): + * BITGO_ACCESS_TOKEN (or ACCESS_TOKEN) + * WALLET_ID + * COIN — must match the wallet's chain (e.g. eth, teth, hteth) + * TX_REQUEST_ID — id printed by step 1 + * + * Optional: + * BITGO_ENV — `test` (default) or `prod` + * + * Copyright 2026, BitGo, Inc. All Rights Reserved. + */ +import fs from 'fs'; +import path from 'path'; + +import dotenv from 'dotenv'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { EnvironmentName } from '@bitgo/sdk-core'; +import { Eth, Hteth, Teth } from '@bitgo/sdk-coin-eth'; +import { ethers } from 'ethers'; + +async function loadEnv(): Promise { + const candidates = [path.join(process.cwd(), '.env'), path.join(__dirname, '../../../.env')]; + for (const envPath of candidates) { + try { + await fs.promises.access(envPath, fs.constants.F_OK); + dotenv.config({ path: envPath }); + return; + } catch { + // try next candidate + } + } + dotenv.config(); +} + +type TxRequestMessage = { + state?: string; + messageRaw?: string; + txHash?: string; +}; + +type TxRequestBody = { + txRequestId?: string; + state?: string; + version?: number; + messages?: TxRequestMessage[]; +}; + +function isCompleteSignatureHex(txHash: string | undefined): boolean { + if (!txHash?.trim()) { + return false; + } + const hex = txHash.trim().startsWith('0x') ? txHash.trim().slice(2) : txHash.trim(); + return /^[0-9a-fA-F]+$/i.test(hex) && hex.length === 130; +} + +function pickSignedSnapshot(txRequests: TxRequestBody[]): TxRequestBody | undefined { + const signed = txRequests.filter( + (t) => t.messages?.[0]?.state === 'signed' && isCompleteSignatureHex(t.messages?.[0]?.txHash) + ); + if (signed.length === 0) { + return undefined; + } + return signed.reduce((best, cur) => ((cur.version ?? -1) >= (best.version ?? -1) ? cur : best)); +} + +function encodeDelegateBySigCalldata(params: { + delegatee: string; + nonce: string; + expiry: string; + v: number; + r: string; + s: string; +}): string { + const iface = new ethers.utils.Interface([ + 'function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s)', + ]); + return iface.encodeFunctionData('delegateBySig', [ + ethers.utils.getAddress(params.delegatee), + ethers.BigNumber.from(params.nonce), + ethers.BigNumber.from(params.expiry), + params.v, + params.r, + params.s, + ]); +} + +function tryParseDelegationFields(messageRaw: string): + | { + delegatee: string; + nonce: string; + expiry: string; + } + | undefined { + try { + const parsed = JSON.parse(messageRaw) as { message?: Record }; + const msg = parsed.message; + if (!msg || typeof msg !== 'object') { + return undefined; + } + const { delegatee, nonce, expiry } = msg; + if (typeof delegatee !== 'string' || typeof nonce === 'undefined' || typeof expiry === 'undefined') { + return undefined; + } + return { delegatee, nonce: String(nonce), expiry: String(expiry) }; + } catch { + return undefined; + } +} + +async function main(): Promise { + await loadEnv(); + + const accessToken = process.env.BITGO_ACCESS_TOKEN ?? process.env.ACCESS_TOKEN; + const walletId = process.env.WALLET_ID; + const txRequestId = process.env.TX_REQUEST_ID; + const coin = process.env.COIN; + const env: EnvironmentName = process.env.BITGO_ENV === 'prod' ? 'prod' : 'test'; + + 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)'); + } + if (!txRequestId) { + throw new Error('Set TX_REQUEST_ID in .env (the id printed by step 1)'); + } + + const bitgo = new BitGoAPI({ accessToken, env }); + bitgo.register('eth', Eth.createInstance); + bitgo.register('teth', Teth.createInstance); + bitgo.register('hteth', Hteth.createInstance); + bitgo.coin(coin); + + const res = (await bitgo + .get(bitgo.url(`/wallet/${walletId}/txrequests`, 2)) + .query({ txRequestIds: txRequestId, latest: 'true' }) + .result()) as { txRequests: TxRequestBody[] }; + + if (!res.txRequests?.length) { + throw new Error(`No tx request found for id ${txRequestId}`); + } + + const signed = pickSignedSnapshot(res.txRequests); + const msg0 = signed?.messages?.[0]; + const txHash = msg0?.txHash; + + if (!signed || !msg0 || !txHash) { + console.log('Message not signed yet, try again later.'); + return; + } + + const sigHex = txHash.startsWith('0x') ? txHash : `0x${txHash}`; + const { v, r, s } = ethers.utils.splitSignature(sigHex); + + console.log(''); + console.log('Signature retrieved.'); + console.log(''); + console.log(` txRequestId : ${signed.txRequestId ?? txRequestId}`); + console.log(` v : ${v}`); + console.log(` r : ${r}`); + console.log(` s : ${s}`); + + if (msg0.messageRaw) { + const fields = tryParseDelegationFields(msg0.messageRaw); + if (fields) { + const calldata = encodeDelegateBySigCalldata({ ...fields, v, r, s }); + console.log(''); + console.log('delegateBySig calldata (broadcast as `data` to the token contract):'); + console.log(` ${calldata}`); + } + } +} + +main().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/modules/abstract-eth/src/lib/eip712/erc20VotesDelegation.ts b/modules/abstract-eth/src/lib/eip712/erc20VotesDelegation.ts new file mode 100644 index 0000000000..a643c9adfa --- /dev/null +++ b/modules/abstract-eth/src/lib/eip712/erc20VotesDelegation.ts @@ -0,0 +1,145 @@ +import { MessageTypes, SignTypedDataVersion, TypedDataUtils, TypedMessage } from '@metamask/eth-sig-util'; +import { ethers } from 'ethers'; + +/** + * EIP-712 typed data for OpenZeppelin ERC20Votes-style `delegateBySig`: + * `Delegation(address delegatee,uint256 nonce,uint256 expiry)` + * + * Domain fields must match the token's on-chain `eip712Domain()` (e.g. WLFI proxy + * `0xda5e1988097297dcdc1f90d4dfe7909e847cbef6` on Ethereum mainnet). + */ + +const delegateBySigAbi = [ + 'function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s)', +]; + +const erc20VotesDelegationTypes: Record> = { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Delegation: [ + { name: 'delegatee', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'expiry', type: 'uint256' }, + ], +}; + +export interface Erc20VotesDelegationDomain { + name: string; + version: string; + chainId: number; + verifyingContract: string; +} + +/** + * Fields of the OpenZeppelin `Delegation(address delegatee,uint256 nonce,uint256 expiry)` struct. + */ +export interface Erc20VotesDelegationMessageFields { + delegatee: string; + nonce: ethers.BigNumberish; + expiry: ethers.BigNumberish; +} + +export interface Erc20VotesDelegationTypedData { + types: typeof erc20VotesDelegationTypes; + primaryType: 'Delegation'; + domain: Erc20VotesDelegationDomain; + message: { + delegatee: string; + nonce: string; + expiry: string; + }; +} + +function uint256FieldToDecimalString(value: ethers.BigNumberish): string { + return ethers.BigNumber.from(value).toString(); +} + +/** + * Build EIP-712 v4 typed data for `delegateBySig` signing (e.g. BitGo `signTypedData` with + * `typedDataRaw: JSON.stringify(result)` and `SignTypedDataVersion.V4`). + */ +export function buildErc20VotesDelegationTypedData(params: { + domain: Erc20VotesDelegationDomain; + message: Erc20VotesDelegationMessageFields; +}): Erc20VotesDelegationTypedData { + return { + types: erc20VotesDelegationTypes, + primaryType: 'Delegation', + domain: { ...params.domain }, + message: { + delegatee: ethers.utils.getAddress(params.message.delegatee), + nonce: uint256FieldToDecimalString(params.message.nonce), + expiry: uint256FieldToDecimalString(params.message.expiry), + }, + }; +} + +const EIP712_DOMAIN_PRIMARY = 'EIP712Domain'; + +/** EIP-712 v4 digest buffer for the structured data (matches ETH-like `encodeTypedData` in this package). */ +export function encodeErc20VotesDelegationTypedDataDigest(typedData: Erc20VotesDelegationTypedData): Buffer { + const raw = JSON.parse(JSON.stringify(typedData)) as TypedMessage; + const sanitized = TypedDataUtils.sanitizeData(raw); + const parts: Buffer[] = [Buffer.from('1901', 'hex')]; + parts.push( + TypedDataUtils.hashStruct(EIP712_DOMAIN_PRIMARY, sanitized.domain, sanitized.types, SignTypedDataVersion.V4) + ); + if (sanitized.primaryType !== EIP712_DOMAIN_PRIMARY) { + parts.push( + TypedDataUtils.hashStruct( + sanitized.primaryType as string, + sanitized.message, + sanitized.types, + SignTypedDataVersion.V4 + ) + ); + } + return Buffer.concat(parts); +} + +/** Hex-encoded digest for BitGo typed-data tx request `messageEncoded` / `typedDataEncoded`. */ +export function encodeErc20VotesDelegationTypedDataDigestHex(typedData: Erc20VotesDelegationTypedData): string { + return encodeErc20VotesDelegationTypedDataDigest(typedData).toString('hex'); +} + +export interface DelegateBySigCallParams { + delegatee: string; + nonce: ethers.BigNumberish; + expiry: ethers.BigNumberish; + v: number; + r: string; + s: string; +} + +/** ABI-encoded calldata for `delegateBySig` (contract call `data` field). */ +export function encodeDelegateBySigCalldata(params: DelegateBySigCallParams): string { + const iface = new ethers.utils.Interface(delegateBySigAbi); + return iface.encodeFunctionData('delegateBySig', [ + ethers.utils.getAddress(params.delegatee), + ethers.BigNumber.from(params.nonce), + ethers.BigNumber.from(params.expiry), + params.v, + params.r, + params.s, + ]); +} + +/** WLFI ERC-20 proxy on Ethereum mainnet (verify `eip712Domain()` before signing). */ +export const WLFI_ETHEREUM_MAINNET_PROXY = '0xda5e1988097297dcdc1f90d4dfe7909e847cbef6'; + +/** + * EIP-712 domain as returned by WLFI `eip712Domain()` on Ethereum mainnet (Apr 2026). + * Re-read on-chain after upgrades; name/version/verifyingContract can change. + */ +export function wlfiEthereumMainnetDelegationDomain(): Erc20VotesDelegationDomain { + return { + name: 'World Liberty Financial', + version: '2', + chainId: 1, + verifyingContract: WLFI_ETHEREUM_MAINNET_PROXY, + }; +} diff --git a/modules/abstract-eth/src/lib/eip712/index.ts b/modules/abstract-eth/src/lib/eip712/index.ts new file mode 100644 index 0000000000..ae057aa314 --- /dev/null +++ b/modules/abstract-eth/src/lib/eip712/index.ts @@ -0,0 +1 @@ +export * from './erc20VotesDelegation'; diff --git a/modules/abstract-eth/src/lib/index.ts b/modules/abstract-eth/src/lib/index.ts index 86eb9024e5..d8cce22d23 100644 --- a/modules/abstract-eth/src/lib/index.ts +++ b/modules/abstract-eth/src/lib/index.ts @@ -11,6 +11,7 @@ export * from './types'; export * from './utils'; export * from './walletUtil'; export * from './messages'; +export * from './eip712'; // for Backwards Compatibility import * as Interface from './iface'; diff --git a/modules/abstract-eth/src/lib/messages/eip712/erc20VotesDelegationMessage.ts b/modules/abstract-eth/src/lib/messages/eip712/erc20VotesDelegationMessage.ts new file mode 100644 index 0000000000..c23161fb2c --- /dev/null +++ b/modules/abstract-eth/src/lib/messages/eip712/erc20VotesDelegationMessage.ts @@ -0,0 +1,57 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { MessageOptions } from '@bitgo/sdk-core'; +import { EIP712Message } from './eip712Message'; +import { + buildErc20VotesDelegationTypedData, + Erc20VotesDelegationDomain, + Erc20VotesDelegationMessageFields, + Erc20VotesDelegationTypedData, +} from '../../eip712/erc20VotesDelegation'; + +/** + * Inputs for building an OpenZeppelin ERC20Votes `delegateBySig` EIP-712 message. + */ +export interface Erc20VotesDelegationParams { + domain: Erc20VotesDelegationDomain; + message: Erc20VotesDelegationMessageFields; +} + +/** + * Thin wrapper over `EIP712Message` that constructs the OpenZeppelin + * `Delegation(address delegatee,uint256 nonce,uint256 expiry)` typed-data payload + * for ERC20Votes / WLFI-style governance tokens. + * + * The on-the-wire `MessageStandardType` is still `EIP712`, so the wallet platform + * (`signTypedStructuredData` intent), TSS signing, OVC, Admin / BGMS approvals, and + * `EIP712Message.getSignablePayload()` digest path all keep working unchanged — this + * class only removes the need for callers to hand-build the typed-data JSON. + */ +export class Erc20VotesDelegationMessage extends EIP712Message { + constructor(options: MessageOptions) { + super(options); + } + + /** + * Build a delegation message from typed-data params (no JSON wrangling required). + */ + static fromDelegation( + coinConfig: Readonly, + params: Erc20VotesDelegationParams, + extra: Omit = {} + ): Erc20VotesDelegationMessage { + const typedData = buildErc20VotesDelegationTypedData(params); + return new Erc20VotesDelegationMessage({ + ...extra, + coinConfig, + payload: JSON.stringify(typedData), + }); + } + + /** + * Returns the typed-data object parsed from the wrapped EIP-712 payload. + * Useful when callers need the structured form (e.g. to display in approval UIs). + */ + getTypedData(): Erc20VotesDelegationTypedData { + return JSON.parse(this.payload) as Erc20VotesDelegationTypedData; + } +} diff --git a/modules/abstract-eth/src/lib/messages/eip712/erc20VotesDelegationMessageBuilder.ts b/modules/abstract-eth/src/lib/messages/eip712/erc20VotesDelegationMessageBuilder.ts new file mode 100644 index 0000000000..8d54d72dfe --- /dev/null +++ b/modules/abstract-eth/src/lib/messages/eip712/erc20VotesDelegationMessageBuilder.ts @@ -0,0 +1,48 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { IMessage, MessageOptions } from '@bitgo/sdk-core'; +import { Eip712MessageBuilder } from './eip712MessageBuilder'; +import { Erc20VotesDelegationMessage, Erc20VotesDelegationParams } from './erc20VotesDelegationMessage'; +import { buildErc20VotesDelegationTypedData } from '../../eip712/erc20VotesDelegation'; + +/** + * Builder for OpenZeppelin ERC20Votes `delegateBySig` EIP-712 messages. + * + * Produces an `EIP712`-typed message (`MessageStandardType.EIP712`) so it flows through + * the standard `signTypedStructuredData` intent on wallet-platform. Callers can either: + * + * - call `setDelegation({ domain, message })` and then `build()`, or + * - call `setPayload(typedDataJson)` directly (same shape as `Eip712MessageBuilder`). + */ +export class Erc20VotesDelegationMessageBuilder extends Eip712MessageBuilder { + protected delegation?: Erc20VotesDelegationParams; + + public constructor(coinConfig: Readonly) { + super(coinConfig); + } + + /** + * Provide the OpenZeppelin Delegation typed-data inputs. Calling this overwrites + * any payload previously set via `setPayload`. + */ + public setDelegation(params: Erc20VotesDelegationParams): this { + this.delegation = params; + const typedData = buildErc20VotesDelegationTypedData(params); + this.setPayload(JSON.stringify(typedData)); + return this; + } + + public getDelegation(): Erc20VotesDelegationParams | undefined { + return this.delegation; + } + + /** + * One-shot helper equivalent to `setDelegation(params).build()`. + */ + public async buildFromDelegation(params: Erc20VotesDelegationParams): Promise { + return this.setDelegation(params).build() as Promise; + } + + async buildMessage(options: MessageOptions): Promise { + return new Erc20VotesDelegationMessage(options); + } +} diff --git a/modules/abstract-eth/src/lib/messages/eip712/index.ts b/modules/abstract-eth/src/lib/messages/eip712/index.ts index a5306e6b35..004b3c8410 100644 --- a/modules/abstract-eth/src/lib/messages/eip712/index.ts +++ b/modules/abstract-eth/src/lib/messages/eip712/index.ts @@ -1,2 +1,4 @@ export * from './eip712Message'; export * from './eip712MessageBuilder'; +export * from './erc20VotesDelegationMessage'; +export * from './erc20VotesDelegationMessageBuilder'; diff --git a/modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts b/modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts index 1b7cffc2ff..1e289758f6 100644 --- a/modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts +++ b/modules/abstract-eth/src/lib/messages/messageBuilderFactory.ts @@ -1,5 +1,5 @@ import { Eip191MessageBuilder } from './eip191'; -import { Eip712MessageBuilder } from './eip712'; +import { Eip712MessageBuilder, Erc20VotesDelegationMessageBuilder } from './eip712'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { BaseMessageBuilderFactory, IMessageBuilder, MessageStandardType } from '@bitgo/sdk-core'; @@ -18,4 +18,15 @@ export class MessageBuilderFactory extends BaseMessageBuilderFactory { throw new Error(`Invalid message standard ${type}`); } } + + /** + * Returns a builder for OpenZeppelin ERC20Votes `delegateBySig` EIP-712 messages. + * + * The produced message is `MessageStandardType.EIP712`, so it routes through the same + * `signTypedStructuredData` wallet-platform intent as any other EIP-712 message; this + * helper just spares callers from hand-crafting the `Delegation(...)` typed-data JSON. + */ + public getErc20VotesDelegationBuilder(): Erc20VotesDelegationMessageBuilder { + return new Erc20VotesDelegationMessageBuilder(this.coinConfig); + } } diff --git a/modules/abstract-eth/test/unit/eip712/erc20VotesDelegation.ts b/modules/abstract-eth/test/unit/eip712/erc20VotesDelegation.ts new file mode 100644 index 0000000000..5c4408b5be --- /dev/null +++ b/modules/abstract-eth/test/unit/eip712/erc20VotesDelegation.ts @@ -0,0 +1,187 @@ +import 'should'; +import { coins } from '@bitgo/statics'; +import { MessageStandardType } from '@bitgo/sdk-core'; +import { SignTypedDataVersion, TypedDataUtils } from '@metamask/eth-sig-util'; +import { ethers } from 'ethers'; + +import { + buildErc20VotesDelegationTypedData, + encodeDelegateBySigCalldata, + encodeErc20VotesDelegationTypedDataDigestHex, + wlfiEthereumMainnetDelegationDomain, +} from '../../../src/lib/eip712/erc20VotesDelegation'; +import { EIP712Message } from '../../../src/lib/messages/eip712/eip712Message'; +import { Erc20VotesDelegationMessage } from '../../../src/lib/messages/eip712/erc20VotesDelegationMessage'; +import { Erc20VotesDelegationMessageBuilder } from '../../../src/lib/messages/eip712/erc20VotesDelegationMessageBuilder'; +import { MessageBuilderFactory } from '../../../src/lib/messages/messageBuilderFactory'; + +describe('ERC20Votes delegation EIP-712', function () { + it('buildErc20VotesDelegationTypedData matches OZ Delegation struct', function () { + const typedData = buildErc20VotesDelegationTypedData({ + domain: wlfiEthereumMainnetDelegationDomain(), + message: { + delegatee: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + nonce: 7, + expiry: 2000000000, + }, + }); + + typedData.primaryType.should.equal('Delegation'); + typedData.message.nonce.should.equal('7'); + typedData.message.expiry.should.equal('2000000000'); + typedData.message.delegatee.should.equal('0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'); + + const raw = JSON.parse(JSON.stringify(typedData)); + const sanitized = TypedDataUtils.sanitizeData(raw); + const domainHash = TypedDataUtils.hashStruct( + 'EIP712Domain', + sanitized.domain, + sanitized.types, + SignTypedDataVersion.V4 + ); + const structHash = TypedDataUtils.hashStruct( + 'Delegation', + sanitized.message, + sanitized.types, + SignTypedDataVersion.V4 + ); + domainHash.should.be.instanceOf(Buffer); + structHash.should.be.instanceOf(Buffer); + domainHash.length.should.equal(32); + structHash.length.should.equal(32); + }); + + it('encodeErc20VotesDelegationTypedDataDigestHex matches EIP-712 prefix + domain + Delegation', function () { + const typedData = buildErc20VotesDelegationTypedData({ + domain: wlfiEthereumMainnetDelegationDomain(), + message: { + delegatee: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + nonce: 7, + expiry: 2000000000, + }, + }); + const hex = encodeErc20VotesDelegationTypedDataDigestHex(typedData); + hex.should.match(/^[0-9a-f]+$/i); + (hex.length / 2).should.equal(66); // 0x1901 + 32-byte domain + 32-byte struct + encodeErc20VotesDelegationTypedDataDigestHex(typedData).should.equal(hex); + }); + + it('encodeDelegateBySigCalldata encodes OZ delegateBySig', function () { + const r = '0x' + '11'.repeat(32); + const s = '0x' + '22'.repeat(32); + const data = encodeDelegateBySigCalldata({ + delegatee: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + nonce: 1, + expiry: 2, + v: 28, + r, + s, + }); + const iface = new ethers.utils.Interface([ + 'function delegateBySig(address delegatee, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s)', + ]); + const decoded = iface.decodeFunctionData('delegateBySig', data); + decoded.delegatee.should.equal('0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB'); + decoded.nonce.toString().should.equal('1'); + decoded.expiry.toString().should.equal('2'); + decoded.v.should.equal(28); + decoded.r.should.equal(r); + decoded.s.should.equal(s); + }); + + describe('parity with EIP712Message.getSignablePayload', function () { + const coinConfig = coins.get('eth'); + + const fixtures = [ + { + name: 'WLFI mainnet self-delegation', + domain: wlfiEthereumMainnetDelegationDomain(), + message: { + delegatee: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + nonce: 0, + expiry: 1893456000, + }, + }, + { + name: 'arbitrary token, large nonce + far expiry', + domain: { + name: 'MyVotesToken', + version: '1', + chainId: 11155111, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + message: { + delegatee: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + nonce: ethers.BigNumber.from('123456789012345678901234567890'), + expiry: ethers.BigNumber.from('9999999999'), + }, + }, + ]; + + fixtures.forEach((f) => { + it(`digest matches EIP712Message.getSignablePayload — ${f.name}`, async function () { + const typedData = buildErc20VotesDelegationTypedData({ domain: f.domain, message: f.message }); + const helperHex = encodeErc20VotesDelegationTypedDataDigestHex(typedData); + + const eip712 = new EIP712Message({ + coinConfig, + payload: JSON.stringify(typedData), + }); + const signable = await eip712.getSignablePayload(); + const signableHex = Buffer.isBuffer(signable) + ? signable.toString('hex') + : Buffer.from(signable).toString('hex'); + + signableHex.should.equal(helperHex); + signableHex.should.have.length(132); // 0x1901 + 32-byte domainHash + 32-byte structHash, hex + }); + }); + }); + + describe('Erc20VotesDelegationMessage / Builder', function () { + const coinConfig = coins.get('eth'); + const params = { + domain: wlfiEthereumMainnetDelegationDomain(), + message: { + delegatee: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + nonce: 7, + expiry: 2000000000, + }, + }; + + it('Erc20VotesDelegationMessage.fromDelegation produces an EIP712-typed message with matching digest', async function () { + const msg = Erc20VotesDelegationMessage.fromDelegation(coinConfig, params); + + msg.getType().should.equal(MessageStandardType.EIP712); + const typed = msg.getTypedData(); + typed.primaryType.should.equal('Delegation'); + typed.message.delegatee.should.equal(params.message.delegatee); + typed.message.nonce.should.equal('7'); + typed.message.expiry.should.equal('2000000000'); + + const signable = await msg.getSignablePayload(); + const helperHex = encodeErc20VotesDelegationTypedDataDigestHex(buildErc20VotesDelegationTypedData(params)); + const signableHex = Buffer.isBuffer(signable) ? signable.toString('hex') : Buffer.from(signable).toString('hex'); + signableHex.should.equal(helperHex); + }); + + it('Erc20VotesDelegationMessageBuilder.buildFromDelegation builds the same message', async function () { + const builder = new Erc20VotesDelegationMessageBuilder(coinConfig); + const built = await builder.buildFromDelegation(params); + + built.should.be.instanceOf(Erc20VotesDelegationMessage); + built.getType().should.equal(MessageStandardType.EIP712); + built.getPayload().should.equal(JSON.stringify(buildErc20VotesDelegationTypedData(params))); + }); + + it('Erc20VotesDelegationMessageBuilder is reachable through MessageBuilderFactory', async function () { + const factory = new MessageBuilderFactory(coinConfig); + const builder = factory.getErc20VotesDelegationBuilder(); + builder.should.be.instanceOf(Erc20VotesDelegationMessageBuilder); + + const msg = await builder.buildFromDelegation(params); + msg.getType().should.equal(MessageStandardType.EIP712); + msg.getPayload().should.equal(JSON.stringify(buildErc20VotesDelegationTypedData(params))); + }); + }); +}); diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index e4dfebe65c..b54f8f6051 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -43,8 +43,8 @@ import { GShare, SignShare } from '../../../account-lib/mpc/tss'; import { RequestTracer } from '../util'; import { envRequiresBitgoPubGpgKeyConfig, getBitgoMpcGpgPubKey } from '../../tss/bitgoPubKeys'; import { getBitgoGpgPubKey } from '../opengpgUtils'; -import assert from 'assert'; import { MessageStandardType } from '../messageTypes'; +import assert from 'assert'; /** * BaseTssUtil class which different signature schemes have to extend @@ -438,6 +438,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil isTss: params.isTss, messageRaw: params.typedDataRaw, messageEncoded: params.typedDataEncoded ?? '', + messageStandardType: params.messageStandardType, }; return this.createTxRequestBase(intentOptions, apiVersion, preview, params.reqId); diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index f773e370a7..22e272c8e5 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -224,6 +224,8 @@ export interface IntentOptionsForMessage extends IntentOptionsBase { export interface IntentOptionsForTypedData extends IntentOptionsBase { typedDataRaw: string; typedDataEncoded?: string; + /** Required for OVC / `verifyOffchainMessages` on custodial exports (e.g. TAT); defaults to EIP712 in `createTxRequestWithIntentForTypedDataSigning`. */ + messageStandardType?: MessageStandardType; } export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase { @@ -318,6 +320,7 @@ export interface PopulatedIntentForTypedDataSigning extends PopulatedIntentBase messageRaw: string; messageEncoded: string; custodianMessageId?: string; + messageStandardType?: MessageStandardType; } export interface PopulatedIntent extends PopulatedIntentBase { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 8bc607b891..0d610c6955 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -48,6 +48,7 @@ import { TokenType, TxRequest, } from '../utils'; +import { MessageStandardType } from '../utils/messageTypes'; import { postWithCodec } from '../utils/postWithCodec'; import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa'; import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa'; @@ -4206,6 +4207,7 @@ export class Wallet implements IWallet { isTss: true, typedDataRaw: params.typedData.typedDataRaw, typedDataEncoded: params.typedData.typedDataEncoded!.toString('hex'), + messageStandardType: MessageStandardType.EIP712, }; txRequest = await this.tssUtils!.createTxRequestWithIntentForTypedDataSigning(intentOptions); params.typedData.txRequestId = txRequest.txRequestId;