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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=
64 changes: 0 additions & 64 deletions pages/api/gelato/relay.ts

This file was deleted.

87 changes: 87 additions & 0 deletions pages/api/governance/vote-relay/[...path].ts
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JoaquinBattilana Do we need to add this to vercel?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we should add typed env vars as we have in v4 (in a separated PR)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah agreed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to be env var this one i think

const VOTE_RELAY_API_KEY = process.env.VOTE_RELAY_API_KEY;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JoaquinBattilana Add to vercel?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added to staging, prod not available yet


// 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');
Comment on lines +78 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how we handle 4xxs

return res.send(text || '{}');
} catch (error) {
return res.status(503).json({
error: { code: 'RELAYER_UNAVAILABLE', message: 'Vote relay unreachable', retryable: true },
});
}
}
167 changes: 75 additions & 92 deletions src/components/transactions/GovVote/GovVoteActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string> => {
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,
Expand All @@ -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 }> = [];

Expand All @@ -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<ReturnType<typeof getVotingBalanceProofs>>) => {
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({
Expand Down
Loading
Loading