diff --git a/.env.example b/.env.example index 822180468c..d243f8d1e1 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ TENDERLY_PROJECT= NEXT_PUBLIC_ENV=prod NEXT_PUBLIC_ENABLE_GOVERNANCE=true NEXT_PUBLIC_GOVERNANCE_CACHE_URL=https://governance-cache-api.aave.com/graphql -# Client on/off gate for gasless voting. The relay only works if GELATO_SPONSOR_KEY is also set server-side. +# Client on/off gate for gasless voting. The relay only works if VOTE_RELAY_URL and VOTE_RELAY_API_KEY are also set server-side. NEXT_PUBLIC_ENABLE_GASLESS_VOTING=false NEXT_PUBLIC_ENABLE_STAKING=true NEXT_PUBLIC_API_BASEURL=https://aave-api-v2.aave.com @@ -55,5 +55,6 @@ PLAIN_API_KEY= COMPLIANCE_API_URL= COMPLIANCE_SECRET= SENTRY_AUTH_TOKEN= -# Gelato sponsor key for gasless voting (server-side only, never exposed to the client) -GELATO_SPONSOR_KEY= +# Gas-sponsored voting relay (server-side only, never exposed to the client) +VOTE_RELAY_URL=https://governance-cache-api.aave.com/relay +VOTE_RELAY_API_KEY= diff --git a/pages/api/gelato/relay.ts b/pages/api/gelato/relay.ts deleted file mode 100644 index af38461668..0000000000 --- a/pages/api/gelato/relay.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; -import { VotingMachine__factory } from 'src/components/transactions/GovVote/temporary/typechain/factory/VotingMachine__factory'; -import { governanceV3Config } from 'src/ui-config/governanceConfig'; - -// Gelato's sponsored-call REST endpoint. The sponsor key authorizes gas payment -// from our 1Balance account and must never reach the browser, so the call lives here. -const GELATO_SPONSORED_CALL_URL = 'https://api.gelato.digital/relays/v2/sponsored-call'; - -// Only submitVoteBySignature may be relayed — anything else would let callers spend -// sponsored gas on arbitrary transactions. -const SUBMIT_VOTE_BY_SIGNATURE_SELECTOR = VotingMachine__factory.createInterface() - .getSighash('submitVoteBySignature') - .toLowerCase(); - -// chainId -> voting machine address, the only targets we relay to. -const VOTING_MACHINES: Record = Object.entries( - governanceV3Config.votingChainConfig -).reduce((acc, [chainId, config]) => { - acc[Number(chainId)] = config.votingMachineAddress.toLowerCase(); - return acc; -}, {} as Record); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - const sponsorApiKey = process.env.GELATO_SPONSOR_KEY; - if (!sponsorApiKey) { - return res.status(503).json({ error: 'Gasless voting is not configured' }); - } - - const { chainId, target, data } = req.body ?? {}; - const chainIdNumber = typeof chainId === 'string' ? parseInt(chainId, 10) : chainId; - - const expectedTarget = VOTING_MACHINES[chainIdNumber]; - if (!expectedTarget || typeof target !== 'string' || target.toLowerCase() !== expectedTarget) { - return res.status(400).json({ error: 'Target is not a known voting machine' }); - } - - if ( - typeof data !== 'string' || - data.slice(0, 10).toLowerCase() !== SUBMIT_VOTE_BY_SIGNATURE_SELECTOR - ) { - return res.status(400).json({ error: 'Calldata is not a submitVoteBySignature call' }); - } - - try { - const response = await fetch(GELATO_SPONSORED_CALL_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chainId: chainIdNumber, target, data, sponsorApiKey }), - }); - - const result = await response.json(); - if (!response.ok || !result?.taskId) { - return res.status(502).json({ error: 'Relay request failed', details: result }); - } - - return res.status(200).json({ taskId: result.taskId }); - } catch (error) { - return res.status(500).json({ error: 'Internal server error', details: String(error) }); - } -} diff --git a/pages/api/governance/vote-relay/[...path].ts b/pages/api/governance/vote-relay/[...path].ts new file mode 100644 index 0000000000..53c343b196 --- /dev/null +++ b/pages/api/governance/vote-relay/[...path].ts @@ -0,0 +1,87 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +// Same-origin proxy for the governance vote-relay (gas-sponsored voting). +// The browser never holds the relay's api key: it calls this route, which attaches +// the server-side `x-api-key` and forwards to the relay, mirroring `rpc-proxy.ts`. +// See governance-v3-cache PR #126 for the relay contract. +const VOTE_RELAY_URL = process.env.VOTE_RELAY_URL; // e.g. https://governance-cache-api.aave.com/relay +const VOTE_RELAY_API_KEY = process.env.VOTE_RELAY_API_KEY; + +// Only these relay routes may be proxied — an allowlist so this can't be used as an +// open proxy against the relay. Matched against the path segments after the api route. +const isAllowed = (method: string, segments: string[]): boolean => { + const [v1, votes, ...rest] = segments; + if (v1 !== 'v1' || votes !== 'votes') return false; + + if (method === 'POST') { + // POST /v1/votes or POST /v1/votes/representative + return rest.length === 0 || (rest.length === 1 && rest[0] === 'representative'); + } + + if (method === 'GET') { + // GET /v1/votes/status/{transactionId} + if (rest.length === 2 && rest[0] === 'status') return true; + // GET /v1/votes/{chainId}/{proposalId}/{voter} + if (rest.length === 3 && rest[0] !== 'status' && rest[0] !== 'representative') return true; + return false; + } + + return false; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const method = req.method ?? 'GET'; + if (method !== 'POST' && method !== 'GET') { + return res.status(405).json({ + error: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed', retryable: false }, + }); + } + + if (!VOTE_RELAY_URL || !VOTE_RELAY_API_KEY) { + // Mirror the relay's own transient shape so the client's fallback path triggers. + return res.status(503).json({ + error: { + code: 'RELAYER_UNAVAILABLE', + message: 'Vote relay is not configured', + retryable: true, + }, + }); + } + + const rawPath = req.query.path; + const segments = Array.isArray(rawPath) ? rawPath : rawPath ? [rawPath] : []; + + if (!isAllowed(method, segments)) { + return res + .status(404) + .json({ error: { code: 'NOT_FOUND', message: 'Unknown relay route', retryable: false } }); + } + + const target = `${VOTE_RELAY_URL.replace(/\/$/, '')}/${segments.join('/')}`; + + // Forward the caller IP so the relay's per-IP rate limiter keys on the real client. + const forwardedFor = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress || ''; + + try { + const relayResponse = await fetch(target, { + method, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': VOTE_RELAY_API_KEY, + ...(forwardedFor ? { 'x-forwarded-for': forwardedFor } : {}), + }, + body: method === 'POST' ? JSON.stringify(req.body ?? {}) : undefined, + }); + + // Pass the relay's status and JSON body straight through so the client sees the + // real status codes (202/409/503/…) and the { error: { code, … } } shape. + const text = await relayResponse.text(); + res.status(relayResponse.status); + res.setHeader('Content-Type', 'application/json'); + return res.send(text || '{}'); + } catch (error) { + return res.status(503).json({ + error: { code: 'RELAYER_UNAVAILABLE', message: 'Vote relay unreachable', retryable: true }, + }); + } +} diff --git a/src/components/transactions/GovVote/GovVoteActions.tsx b/src/components/transactions/GovVote/GovVoteActions.tsx index 34f5619254..30fdc5e5d4 100644 --- a/src/components/transactions/GovVote/GovVoteActions.tsx +++ b/src/components/transactions/GovVote/GovVoteActions.tsx @@ -13,6 +13,7 @@ import { queryKeysFactory } from 'src/ui-config/queries'; import { getProvider } from 'src/utils/marketsAndNetworksConfig'; import { TxActionsWrapper } from '../TxActionsWrapper'; +import { pollVoteStatus, RelayError, submitRelayVote } from './temporary/voteRelayClient'; import { VotingMachineService } from './temporary/VotingMachineService'; export const baseSlots = { @@ -180,27 +181,6 @@ const getVotingBalanceProofs = ( ); }; -const GELATO_TASK_STATUS_URL = 'https://api.gelato.digital/tasks/status'; - -// Poll Gelato's public task status until the sponsored vote is mined. This endpoint -// needs no key — only the relay call itself is authenticated (server-side). -const waitForRelayedTx = async (taskId: string): Promise => { - const maxAttempts = 40; // ~2 min at 3s intervals - for (let attempt = 0; attempt < maxAttempts; attempt++) { - await new Promise((resolve) => setTimeout(resolve, 3000)); - const res = await fetch(`${GELATO_TASK_STATUS_URL}/${taskId}`); - if (!res.ok) continue; - const { task } = await res.json(); - if (task?.taskState === 'ExecSuccess' && task.transactionHash) { - return task.transactionHash as string; - } - if (task?.taskState === 'ExecReverted' || task?.taskState === 'Cancelled') { - throw new Error(`Relayed vote ${task.taskState}`); - } - } - throw new Error('Timed out waiting for the relayed vote'); -}; - export const GovVoteActions = ({ isWrongNetwork, blocked, @@ -221,7 +201,7 @@ export const GovVoteActions = ({ const votingMachineAddress = governanceV3Config.votingChainConfig[votingChainId].votingMachineAddress; - const withGelatoRelayer = process.env.NEXT_PUBLIC_ENABLE_GASLESS_VOTING === 'true'; + const withGaslessVoting = process.env.NEXT_PUBLIC_ENABLE_GASLESS_VOTING === 'true'; const assets: Array<{ underlyingAsset: string; isWithDelegatedPower: boolean }> = []; @@ -246,84 +226,87 @@ export const GovVoteActions = ({ }); } + // Self-paid vote: the connected wallet sends `submitVote` and pays gas. Also the + // fallback when the sponsored relay is unavailable. + const submitSelfPaidVote = async (proofs: Awaited>) => { + const votingMachineService = new VotingMachineService(votingMachineAddress); + const tx = await votingMachineService.generateSubmitVoteTxData( + user, + proposalId, + support, + proofs + ); + + const txWithEstimatedGas = await estimateGasLimit(tx, votingChainId); + + const response = await sendTx(txWithEstimatedGas); + await response.wait(1); + setMainTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + + queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache }); + }; + const action = async () => { setMainTxState({ ...mainTxState, loading: true }); try { const proofs = await getVotingBalanceProofs(user, assets, ChainId.mainnet, blockHash); - const votingMachineService = new VotingMachineService(votingMachineAddress); - - if (withGelatoRelayer) { - const toSign = generateSubmitVoteSignature( - votingChainId, - votingMachineAddress, - proposalId, - user, - support, - assets.map((elem) => ({ - underlyingAsset: elem.underlyingAsset, - slot: getVoteBalanceSlot( - elem.underlyingAsset, - elem.isWithDelegatedPower, - governanceV3Config.votingAssets.aAaveTokenAddress, - assetsBalanceSlots - ), - })) - ); - const signature = await signTxData(toSign); - - const tx = await votingMachineService.generateSubmitVoteBySignatureTxData( - user, - proposalId, - support, - proofs, - signature.toString() - ); - - const relayResponse = await fetch('/api/gelato/relay', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + if (withGaslessVoting) { + try { + // Sign over the assets + slots only; the proof bytes are sent but not signed. + const toSign = generateSubmitVoteSignature( + votingChainId, + votingMachineAddress, + proposalId, + user, + support, + assets.map((elem) => ({ + underlyingAsset: elem.underlyingAsset, + slot: getVoteBalanceSlot( + elem.underlyingAsset, + elem.isWithDelegatedPower, + governanceV3Config.votingAssets.aAaveTokenAddress, + assetsBalanceSlots + ), + })) + ); + const signature = await signTxData(toSign); + + // The relay encodes `submitVoteBySignature` itself — send raw proofs + signature. + const accepted = await submitRelayVote({ chainId: votingChainId, - target: votingMachineAddress, - data: tx.data, - }), - }); - - if (!relayResponse.ok) { - throw new Error('Relay request failed'); + proposalId, + voter: user, + support, + votingBalanceProofs: proofs, + signature: signature.toString(), + }); + + const txHash = await pollVoteStatus(accepted.transactionId, accepted.transactionHash); + + setMainTxState({ + txHash, + loading: false, + success: true, + }); + + queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache }); + return; + } catch (err) { + // Relayer temporarily down — fall back to a self-paid vote. Any other relay + // error (bad signature, already voted, simulation reverted, vote in flight) + // is surfaced to the user rather than silently retried. + if (!(err instanceof RelayError && err.code === 'RELAYER_UNAVAILABLE')) { + throw err; + } } - - const { taskId } = await relayResponse.json(); - const txHash = await waitForRelayedTx(taskId); - - setMainTxState({ - txHash, - loading: false, - success: true, - }); - - queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache }); - } else { - const tx = await votingMachineService.generateSubmitVoteTxData( - user, - proposalId, - support, - proofs - ); - - const txWithEstimatedGas = await estimateGasLimit(tx, votingChainId); - - const response = await sendTx(txWithEstimatedGas); - await response.wait(1); - setMainTxState({ - txHash: response.hash, - loading: false, - success: true, - }); - - queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache }); } + + await submitSelfPaidVote(proofs); } catch (err) { setTxError(getErrorTextFromError(err as Error, TxAction.MAIN_ACTION, false)); setMainTxState({ diff --git a/src/components/transactions/GovVote/temporary/VotingMachineService.ts b/src/components/transactions/GovVote/temporary/VotingMachineService.ts index cd169858b3..7c21ffe825 100644 --- a/src/components/transactions/GovVote/temporary/VotingMachineService.ts +++ b/src/components/transactions/GovVote/temporary/VotingMachineService.ts @@ -1,5 +1,4 @@ import { BigNumber, PopulatedTransaction, providers } from 'ethers'; -import { splitSignature } from 'ethers/lib/utils'; import { VotingMachine__factory } from './typechain/factory/VotingMachine__factory'; @@ -39,29 +38,4 @@ export class VotingMachineService { tx.gasLimit = BigNumber.from(1000000); return tx; }; - - generateSubmitVoteBySignatureTxData = async ( - user: string, - proposalId: number, - support: boolean, - votingProofs: VotingBalanceProof[], - signature: string - ) => { - const { v, r, s } = splitSignature(signature); - const tx: PopulatedTransaction = {}; - const txData = this._interface.encodeFunctionData('submitVoteBySignature', [ - proposalId, - user, - support, - votingProofs, - v, - r, - s, - ]); - tx.to = this.votingMachineContractAddress; - tx.from = user; - tx.data = txData; - tx.gasLimit = BigNumber.from(1000000); - return tx; - }; } diff --git a/src/components/transactions/GovVote/temporary/voteRelayClient.ts b/src/components/transactions/GovVote/temporary/voteRelayClient.ts new file mode 100644 index 0000000000..a3fd68c36c --- /dev/null +++ b/src/components/transactions/GovVote/temporary/voteRelayClient.ts @@ -0,0 +1,114 @@ +// Client for the governance vote-relay (gas-sponsored voting), reached through the +// same-origin proxy at /api/governance/vote-relay/*. The relay encodes the +// `submitVoteBySignature` calldata itself, so the browser only sends the raw proofs +// and the EIP-712 signature. See governance-v3-cache PR #126 for the contract. + +const RELAY_BASE = '/api/governance/vote-relay/v1/votes'; + +export interface RelayVotingBalanceProof { + underlyingAsset: string; + slot: string; + proof: string; +} + +export interface SubmitRelayVoteParams { + chainId: number; + proposalId: number; + voter: string; + support: boolean; + votingBalanceProofs: RelayVotingBalanceProof[]; + signature: string; // 65-byte r||s||v hex string from signTypedData +} + +interface VoteAccepted { + externalId: string; + transactionId: string; + transactionHash: string | null; + chainId: number; + votingMachine: string; + status: string; +} + +interface VoteStatusResponse { + transactionId: string; + status: string; + transactionHash: string | null; +} + +// Relay status values (rrelayer). Anything not terminal keeps us polling. +const SUCCESS_STATUSES = ['MINED', 'CONFIRMED']; +const FAILURE_STATUSES = ['FAILED', 'EXPIRED', 'CANCELLED', 'DROPPED']; + +export class RelayError extends Error { + code: string; + retryable: boolean; + constructor(code: string, message: string, retryable: boolean) { + super(message); + this.name = 'RelayError'; + this.code = code; + this.retryable = retryable; + } +} + +const parseError = async (res: Response): Promise => { + try { + const body = await res.json(); + const err = body?.error; + if (err?.code) { + return new RelayError(err.code, err.message ?? 'Vote relay error', !!err.retryable); + } + } catch { + // fall through to a generic error below + } + const retryable = res.status === 503 || res.status === 429; + return new RelayError('RELAY_ERROR', `Vote relay responded with ${res.status}`, retryable); +}; + +export const submitRelayVote = async (params: SubmitRelayVoteParams): Promise => { + const res = await fetch(RELAY_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + + if (!res.ok) { + throw await parseError(res); + } + + return (await res.json()) as VoteAccepted; +}; + +// Poll the relay until the sponsored vote reaches a terminal state. Returns the +// mined transaction hash on success. +export const pollVoteStatus = async ( + transactionId: string, + initialHash: string | null +): Promise => { + const maxAttempts = 40; // ~2 min at 3s intervals + let lastHash = initialHash; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const res = await fetch(`${RELAY_BASE}/status/${transactionId}`); + if (!res.ok) { + // A transient status lookup failure shouldn't abort the vote — keep polling. + continue; + } + + const { status, transactionHash } = (await res.json()) as VoteStatusResponse; + if (transactionHash) lastHash = transactionHash; + + if (SUCCESS_STATUSES.includes(status)) { + if (!lastHash) + throw new RelayError('RELAY_ERROR', 'Vote mined without a transaction hash', false); + return lastHash; + } + if (FAILURE_STATUSES.includes(status)) { + throw new RelayError('RELAY_ERROR', `Relayed vote ${status.toLowerCase()}`, false); + } + // PENDING | INMEMPOOL | REPLACED — keep waiting. + } + + throw new RelayError('RELAY_ERROR', 'Timed out waiting for the relayed vote', true); +};