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
270 changes: 270 additions & 0 deletions examples/ts/eth/push-erc20-votes-delegation-txrequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/* eslint-disable no-console */
/**
* Create a Wallet Platform tx request for EIP-712 ERC20Votes delegation (`signTypedStructuredData`)
* so it flows through policy and appears for operators in the Admin / BGMS approval UI.
*
* This does **not** sign locally: no wallet passphrase or `prv`. It only POSTs the intent BitGo
* expects (same shape as `createTxRequestWithIntentForTypedDataSigning` in sdk-core).
*
* Uses `BitGoAPI` + `@bitgo/sdk-coin-eth` only (not the full `bitgo` metapackage) to avoid loading
* every coin implementation — warnings like duplicate `@polkadot/*` can still appear from
* transitive deps; they are harmless for this script.
*
* From repo root after `yarn install`:
*
* npx tsx examples/ts/eth/push-erc20-votes-delegation-txrequest.ts
*
* Required env:
* BITGO_ACCESS_TOKEN (or ACCESS_TOKEN)
* WALLET_ID
* COIN — must match the wallet’s chain in BitGo / WP (e.g. `eth`, `teth`, `hteth`). If you see
* `Coin unsupported`, this environment does not enable that coin — try `hteth` (Holesky)
* or `eth` (mainnet) per your org.
*
* Delegation fields:
* DELEGATEE — defaults to the wallet’s receive address (self-delegation) if unset
* NONCE — EIP-712 nonce from the token’s `nonces(delegator)`; if unset, set ETH_RPC_URL and this
* script will read it on-chain (delegator = wallet receive address; token = domain.verifyingContract)
* EXPIRY — optional unix seconds; default now + 3600
*
* Optional:
* BITGO_ENV — `test` (default) or `prod`
* ETH_RPC_URL — JSON-RPC URL matching `EIP712_DOMAIN_JSON.chainId` (required when NONCE is omitted)
* EIP712_DOMAIN_JSON — same rules as other delegation examples; required for test / non-WLFI mainnet
* TX_API_VERSION — `full` (default) or `lite`
* TXREQUEST_PREVIEW — set to `true` to preview only (skips policy / pending approval; usually **not** what you want for Admin)
* TX_COMMENT, SEQUENCE_ID, CUSTODIAN_MESSAGE_ID — forwarded on the intent if set
*
* The intent always includes `messageStandardType: EIP712` so OVC `verifyOffchainMessages` can validate
* custodial exports (TAT downloads must carry the same field on each `messages[]` entry — Wallet Platform
* should copy intent fields onto messages when creating the tx request; `createTxRequestWithIntentForTypedDataSigning`
* in sdk-core now sends this field for all typed-data tx request API flows).
*
* Custodial signing (after this script):
* Wallet Platform completes MPC for custodial / trust-held user shares using internal trust
* tooling and admin APIs (not the same as entering a wallet passphrase in the retail UI).
* If you do not see a “sign” action in your portal, use your BitGo runbook or contact BitGo
* support / CSM for the trust-operator flow for `signTypedStructuredData` on your stack.
*
* Copyright 2026, BitGo, Inc. All Rights Reserved.
*/

import path from 'path';

import dotenv from 'dotenv';

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

import {
buildErc20VotesDelegationTypedData,
encodeErc20VotesDelegationTypedDataDigestHex,
wlfiEthereumMainnetDelegationDomain,
type Erc20VotesDelegationDomain,
} from '@bitgo/abstract-eth';
import { BitGoAPI } from '@bitgo/sdk-api';
import { Eth, Hteth, Teth } from '@bitgo/sdk-coin-eth';
import { MessageStandardType, type EnvironmentName } from '@bitgo/sdk-core';
import { ethers } from 'ethers';

function createBitGoClient(env: EnvironmentName): BitGoAPI {
const bitgo = new BitGoAPI({ env });
bitgo.register('eth', Eth.createInstance);
bitgo.register('teth', Teth.createInstance);
bitgo.register('hteth', Hteth.createInstance);
return bitgo;
}

function requireEnv(name: string): string {
const v = process.env[name];
if (!v) {
throw new Error(`Missing required environment variable: ${name}`);
}
return v;
}

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 token 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 (name, version, chainId, verifyingContract from the token `eip712Domain()`), ' +
'or use BITGO_ENV=prod COIN=eth for WLFI mainnet defaults.'
);
}

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

async function fetchDelegationNonceFromChain(params: {
rpcUrl: string;
tokenAddress: string;
delegatorAddress: string;
expectedChainId: number;
}): Promise<string> {
const provider = new ethers.providers.JsonRpcProvider(params.rpcUrl);
const network = await provider.getNetwork();
if (network.chainId !== params.expectedChainId) {
throw new Error(
`ETH_RPC_URL points to chainId ${network.chainId}; EIP712 domain expects chainId ${params.expectedChainId}`
);
}
const token = new ethers.Contract(params.tokenAddress, erc20VotesNoncesAbi, provider);
const n = await token.nonces(params.delegatorAddress);
return ethers.BigNumber.from(n).toString();
}

function rethrowIfCoinUnsupported(e: unknown, coin: string): never {
const err = e as { result?: { error?: string; name?: string } };
if (err?.result?.name === 'CoinUnsupported' || String(err?.result?.error).includes('Coin unsupported')) {
throw new Error(
`Wallet Platform does not support coin "${coin}" for this host (Coin unsupported). ` +
`Set COIN to a chain your environment enables (examples: hteth for Holesky, eth for Ethereum mainnet, ` +
`teth for Sepolia on public BitGo test). It must match the wallet’s coin in the BitGo UI.`
);
}
throw e;
}

async function main(): Promise<void> {
const accessToken = process.env.BITGO_ACCESS_TOKEN ?? process.env.ACCESS_TOKEN;
if (!accessToken) {
throw new Error('Set BITGO_ACCESS_TOKEN (or ACCESS_TOKEN)');
}

const env: EnvironmentName = process.env.BITGO_ENV === 'prod' ? 'prod' : 'test';
const coin = requireEnv('COIN');
const walletId = requireEnv('WALLET_ID');
const expiry = process.env.EXPIRY ?? String(Math.floor(Date.now() / 1000) + 3600);

const apiVersion = (process.env.TX_API_VERSION as 'full' | 'lite') || 'full';
if (apiVersion !== 'full' && apiVersion !== 'lite') {
throw new Error('TX_API_VERSION must be "full" or "lite"');
}
const preview = process.env.TXREQUEST_PREVIEW === 'true';

const domain = parseDelegationDomainFromEnv(coin, env);

const bitgo = createBitGoClient(env);
await bitgo.authenticateWithAccessToken({ accessToken });

let wallet;
try {
wallet = await bitgo.coin(coin).wallets().get({ id: walletId });
} catch (e) {
rethrowIfCoinUnsupported(e, coin);
}

const delegator = wallet.receiveAddress();
if (!delegator) {
throw new Error(
'Wallet has no receiveAddress yet; create or fund an address first, or set DELEGATEE / NONCE manually.'
);
}

const delegatee = process.env.DELEGATEE?.trim() || delegator;

let nonce = process.env.NONCE?.trim();
if (!nonce) {
const rpcUrl = process.env.ETH_RPC_URL?.trim();
if (!rpcUrl) {
throw new Error(
'Set NONCE (decimal from token `nonces(delegator)` on-chain), or omit NONCE and set ETH_RPC_URL ' +
'so this script can call `nonces` on domain.verifyingContract for the wallet receive address.'
);
}
nonce = await fetchDelegationNonceFromChain({
rpcUrl,
tokenAddress: domain.verifyingContract,
delegatorAddress: ethers.utils.getAddress(delegator),
expectedChainId: domain.chainId,
});
console.log('Fetched EIP-712 nonce from token nonces(delegator):', nonce, {
delegator,
token: domain.verifyingContract,
});
}

const typedDataObject = buildErc20VotesDelegationTypedData({
domain,
message: { delegatee, nonce, expiry },
});
const typedDataRaw = JSON.stringify(typedDataObject);
const messageEncoded = encodeErc20VotesDelegationTypedDataDigestHex(typedDataObject);

const intent: Record<string, string | boolean | undefined> = {
intentType: 'signTypedStructuredData',
isTss: true,
messageRaw: typedDataRaw,
messageEncoded,
messageStandardType: MessageStandardType.EIP712,
};
if (process.env.TX_COMMENT) {
intent.comment = process.env.TX_COMMENT;
}
if (process.env.SEQUENCE_ID) {
intent.sequenceId = process.env.SEQUENCE_ID;
}
if (process.env.CUSTODIAN_MESSAGE_ID) {
intent.custodianMessageId = process.env.CUSTODIAN_MESSAGE_ID;
}

const body = {
intent,
apiVersion,
preview,
};

console.log('POST', `/api/v2/wallet/${walletId}/txrequests`, { apiVersion, preview, coin });
console.log('Delegator (wallet receive address):', delegator);
console.log('Delegatee:', delegatee);
console.log('intent.intentType:', intent.intentType);
console.log('intent.messageEncoded length (hex chars):', messageEncoded.length);

const txRequest = (await bitgo
.post(bitgo.url(`/wallet/${walletId}/txrequests`, 2))
.send(body)
.result()) as Record<string, unknown>;

console.log('');
console.log('Tx request created:', JSON.stringify(txRequest, null, 2));
console.log('');
if (preview) {
console.log('Note: TXREQUEST_PREVIEW=true — this was a preview; it may not create a pending approval in Admin.');
} else {
console.log(
'Next: open Admin for this wallet or enterprise and look up this tx request / linked pending approval.'
);
console.log('If nothing appears, check enterprise policy, wallet permissions, and that the wallet is TSS on', coin);
}

console.log('');
console.log(
'Custodial TSS: completing this sign usually requires trust / BitGo-operator tools (MPC rounds), ' +
'not a wallet passphrase in the customer UI. Ask your BitGo contact for the procedure on your environment.'
);
console.log(
'Also align EIP-712 domain.chainId with the chain your wallet uses (your payload used mainnet domain ' +
'fields while the tx request message may show a test coin like hteth — fix domain vs COIN if signing fails).'
);
}

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