Skip to content

Commit 7e1c978

Browse files
feat: route gasless voting through governance vote-relay, drop gelato (#3077)
1 parent 7255f05 commit 7e1c978

6 files changed

Lines changed: 286 additions & 185 deletions

File tree

.env.example

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ TENDERLY_PROJECT=
55
NEXT_PUBLIC_ENV=prod
66
NEXT_PUBLIC_ENABLE_GOVERNANCE=true
77
NEXT_PUBLIC_GOVERNANCE_CACHE_URL=https://governance-cache-api.aave.com/graphql
8-
# Client on/off gate for gasless voting. The relay only works if GELATO_SPONSOR_KEY is also set server-side.
8+
# 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.
99
NEXT_PUBLIC_ENABLE_GASLESS_VOTING=false
1010
NEXT_PUBLIC_ENABLE_STAKING=true
1111
NEXT_PUBLIC_API_BASEURL=https://aave-api-v2.aave.com
@@ -55,5 +55,6 @@ PLAIN_API_KEY=
5555
COMPLIANCE_API_URL=
5656
COMPLIANCE_SECRET=
5757
SENTRY_AUTH_TOKEN=
58-
# Gelato sponsor key for gasless voting (server-side only, never exposed to the client)
59-
GELATO_SPONSOR_KEY=
58+
# Gas-sponsored voting relay (server-side only, never exposed to the client)
59+
VOTE_RELAY_URL=https://governance-cache-api.aave.com/relay
60+
VOTE_RELAY_API_KEY=

pages/api/gelato/relay.ts

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { NextApiRequest, NextApiResponse } from 'next';
2+
3+
// Same-origin proxy for the governance vote-relay (gas-sponsored voting).
4+
// The browser never holds the relay's api key: it calls this route, which attaches
5+
// the server-side `x-api-key` and forwards to the relay, mirroring `rpc-proxy.ts`.
6+
// See governance-v3-cache PR #126 for the relay contract.
7+
const VOTE_RELAY_URL = process.env.VOTE_RELAY_URL; // e.g. https://governance-cache-api.aave.com/relay
8+
const VOTE_RELAY_API_KEY = process.env.VOTE_RELAY_API_KEY;
9+
10+
// Only these relay routes may be proxied — an allowlist so this can't be used as an
11+
// open proxy against the relay. Matched against the path segments after the api route.
12+
const isAllowed = (method: string, segments: string[]): boolean => {
13+
const [v1, votes, ...rest] = segments;
14+
if (v1 !== 'v1' || votes !== 'votes') return false;
15+
16+
if (method === 'POST') {
17+
// POST /v1/votes or POST /v1/votes/representative
18+
return rest.length === 0 || (rest.length === 1 && rest[0] === 'representative');
19+
}
20+
21+
if (method === 'GET') {
22+
// GET /v1/votes/status/{transactionId}
23+
if (rest.length === 2 && rest[0] === 'status') return true;
24+
// GET /v1/votes/{chainId}/{proposalId}/{voter}
25+
if (rest.length === 3 && rest[0] !== 'status' && rest[0] !== 'representative') return true;
26+
return false;
27+
}
28+
29+
return false;
30+
};
31+
32+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
33+
const method = req.method ?? 'GET';
34+
if (method !== 'POST' && method !== 'GET') {
35+
return res.status(405).json({
36+
error: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed', retryable: false },
37+
});
38+
}
39+
40+
if (!VOTE_RELAY_URL || !VOTE_RELAY_API_KEY) {
41+
// Mirror the relay's own transient shape so the client's fallback path triggers.
42+
return res.status(503).json({
43+
error: {
44+
code: 'RELAYER_UNAVAILABLE',
45+
message: 'Vote relay is not configured',
46+
retryable: true,
47+
},
48+
});
49+
}
50+
51+
const rawPath = req.query.path;
52+
const segments = Array.isArray(rawPath) ? rawPath : rawPath ? [rawPath] : [];
53+
54+
if (!isAllowed(method, segments)) {
55+
return res
56+
.status(404)
57+
.json({ error: { code: 'NOT_FOUND', message: 'Unknown relay route', retryable: false } });
58+
}
59+
60+
const target = `${VOTE_RELAY_URL.replace(/\/$/, '')}/${segments.join('/')}`;
61+
62+
// Forward the caller IP so the relay's per-IP rate limiter keys on the real client.
63+
const forwardedFor = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress || '';
64+
65+
try {
66+
const relayResponse = await fetch(target, {
67+
method,
68+
headers: {
69+
'Content-Type': 'application/json',
70+
'x-api-key': VOTE_RELAY_API_KEY,
71+
...(forwardedFor ? { 'x-forwarded-for': forwardedFor } : {}),
72+
},
73+
body: method === 'POST' ? JSON.stringify(req.body ?? {}) : undefined,
74+
});
75+
76+
// Pass the relay's status and JSON body straight through so the client sees the
77+
// real status codes (202/409/503/…) and the { error: { code, … } } shape.
78+
const text = await relayResponse.text();
79+
res.status(relayResponse.status);
80+
res.setHeader('Content-Type', 'application/json');
81+
return res.send(text || '{}');
82+
} catch (error) {
83+
return res.status(503).json({
84+
error: { code: 'RELAYER_UNAVAILABLE', message: 'Vote relay unreachable', retryable: true },
85+
});
86+
}
87+
}

src/components/transactions/GovVote/GovVoteActions.tsx

Lines changed: 81 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { queryKeysFactory } from 'src/ui-config/queries';
1313
import { getProvider } from 'src/utils/marketsAndNetworksConfig';
1414

1515
import { TxActionsWrapper } from '../TxActionsWrapper';
16+
import { pollVoteStatus, RelayError, submitRelayVote } from './temporary/voteRelayClient';
1617
import { VotingMachineService } from './temporary/VotingMachineService';
1718

1819
export const baseSlots = {
@@ -180,27 +181,6 @@ const getVotingBalanceProofs = (
180181
);
181182
};
182183

183-
const GELATO_TASK_STATUS_URL = 'https://api.gelato.digital/tasks/status';
184-
185-
// Poll Gelato's public task status until the sponsored vote is mined. This endpoint
186-
// needs no key — only the relay call itself is authenticated (server-side).
187-
const waitForRelayedTx = async (taskId: string): Promise<string> => {
188-
const maxAttempts = 40; // ~2 min at 3s intervals
189-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
190-
await new Promise((resolve) => setTimeout(resolve, 3000));
191-
const res = await fetch(`${GELATO_TASK_STATUS_URL}/${taskId}`);
192-
if (!res.ok) continue;
193-
const { task } = await res.json();
194-
if (task?.taskState === 'ExecSuccess' && task.transactionHash) {
195-
return task.transactionHash as string;
196-
}
197-
if (task?.taskState === 'ExecReverted' || task?.taskState === 'Cancelled') {
198-
throw new Error(`Relayed vote ${task.taskState}`);
199-
}
200-
}
201-
throw new Error('Timed out waiting for the relayed vote');
202-
};
203-
204184
export const GovVoteActions = ({
205185
isWrongNetwork,
206186
blocked,
@@ -221,7 +201,7 @@ export const GovVoteActions = ({
221201
const votingMachineAddress =
222202
governanceV3Config.votingChainConfig[votingChainId].votingMachineAddress;
223203

224-
const withGelatoRelayer = process.env.NEXT_PUBLIC_ENABLE_GASLESS_VOTING === 'true';
204+
const withGaslessVoting = process.env.NEXT_PUBLIC_ENABLE_GASLESS_VOTING === 'true';
225205

226206
const assets: Array<{ underlyingAsset: string; isWithDelegatedPower: boolean }> = [];
227207

@@ -246,84 +226,93 @@ export const GovVoteActions = ({
246226
});
247227
}
248228

229+
// Self-paid vote: the connected wallet sends `submitVote` and pays gas. Also the
230+
// fallback when the sponsored relay is unavailable.
231+
const submitSelfPaidVote = async (proofs: Awaited<ReturnType<typeof getVotingBalanceProofs>>) => {
232+
const votingMachineService = new VotingMachineService(votingMachineAddress);
233+
const tx = await votingMachineService.generateSubmitVoteTxData(
234+
user,
235+
proposalId,
236+
support,
237+
proofs
238+
);
239+
240+
const txWithEstimatedGas = await estimateGasLimit(tx, votingChainId);
241+
242+
const response = await sendTx(txWithEstimatedGas);
243+
await response.wait(1);
244+
setMainTxState({
245+
txHash: response.hash,
246+
loading: false,
247+
success: true,
248+
});
249+
250+
queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache });
251+
};
252+
249253
const action = async () => {
250254
setMainTxState({ ...mainTxState, loading: true });
251255
try {
252256
const proofs = await getVotingBalanceProofs(user, assets, ChainId.mainnet, blockHash);
253257

254-
const votingMachineService = new VotingMachineService(votingMachineAddress);
255-
256-
if (withGelatoRelayer) {
257-
const toSign = generateSubmitVoteSignature(
258-
votingChainId,
259-
votingMachineAddress,
260-
proposalId,
261-
user,
262-
support,
263-
assets.map((elem) => ({
264-
underlyingAsset: elem.underlyingAsset,
265-
slot: getVoteBalanceSlot(
266-
elem.underlyingAsset,
267-
elem.isWithDelegatedPower,
268-
governanceV3Config.votingAssets.aAaveTokenAddress,
269-
assetsBalanceSlots
270-
),
271-
}))
272-
);
273-
const signature = await signTxData(toSign);
274-
275-
const tx = await votingMachineService.generateSubmitVoteBySignatureTxData(
276-
user,
277-
proposalId,
278-
support,
279-
proofs,
280-
signature.toString()
281-
);
282-
283-
const relayResponse = await fetch('/api/gelato/relay', {
284-
method: 'POST',
285-
headers: { 'Content-Type': 'application/json' },
286-
body: JSON.stringify({
258+
if (withGaslessVoting) {
259+
try {
260+
// Sign over the assets + slots only; the proof bytes are sent but not signed.
261+
const toSign = generateSubmitVoteSignature(
262+
votingChainId,
263+
votingMachineAddress,
264+
proposalId,
265+
user,
266+
support,
267+
assets.map((elem) => ({
268+
underlyingAsset: elem.underlyingAsset,
269+
slot: getVoteBalanceSlot(
270+
elem.underlyingAsset,
271+
elem.isWithDelegatedPower,
272+
governanceV3Config.votingAssets.aAaveTokenAddress,
273+
assetsBalanceSlots
274+
),
275+
}))
276+
);
277+
const signature = await signTxData(toSign);
278+
279+
// The relay encodes `submitVoteBySignature` itself — send raw proofs + signature.
280+
const accepted = await submitRelayVote({
287281
chainId: votingChainId,
288-
target: votingMachineAddress,
289-
data: tx.data,
290-
}),
291-
});
292-
293-
if (!relayResponse.ok) {
294-
throw new Error('Relay request failed');
282+
proposalId,
283+
voter: user,
284+
support,
285+
votingBalanceProofs: proofs,
286+
signature: signature.toString(),
287+
});
288+
289+
const txHash = await pollVoteStatus(accepted.transactionId, accepted.transactionHash);
290+
291+
setMainTxState({
292+
txHash,
293+
loading: false,
294+
success: true,
295+
});
296+
297+
// The relay indexes the VoteEmitted event asynchronously, so this immediate
298+
// refetch usually races ahead of the indexer. Refetch again after a short
299+
// delay to pick up the freshly-cast vote.
300+
queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache });
301+
setTimeout(() => {
302+
queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache });
303+
}, 8000);
304+
return;
305+
} catch (err) {
306+
// Relayer temporarily down — fall back to a self-paid vote. Any other relay
307+
// error (bad signature, already voted, simulation reverted, vote in flight)
308+
// is surfaced to the user rather than silently retried.
309+
if (!(err instanceof RelayError && err.code === 'RELAYER_UNAVAILABLE')) {
310+
throw err;
311+
}
295312
}
296-
297-
const { taskId } = await relayResponse.json();
298-
const txHash = await waitForRelayedTx(taskId);
299-
300-
setMainTxState({
301-
txHash,
302-
loading: false,
303-
success: true,
304-
});
305-
306-
queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache });
307-
} else {
308-
const tx = await votingMachineService.generateSubmitVoteTxData(
309-
user,
310-
proposalId,
311-
support,
312-
proofs
313-
);
314-
315-
const txWithEstimatedGas = await estimateGasLimit(tx, votingChainId);
316-
317-
const response = await sendTx(txWithEstimatedGas);
318-
await response.wait(1);
319-
setMainTxState({
320-
txHash: response.hash,
321-
loading: false,
322-
success: true,
323-
});
324-
325-
queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache });
326313
}
314+
315+
await submitSelfPaidVote(proofs);
327316
} catch (err) {
328317
setTxError(getErrorTextFromError(err as Error, TxAction.MAIN_ACTION, false));
329318
setMainTxState({

0 commit comments

Comments
 (0)