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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ TENDERLY_ACCOUNT=
TENDERLY_PROJECT=
NEXT_PUBLIC_ENV=prod
NEXT_PUBLIC_ENABLE_GOVERNANCE=true
NEXT_PUBLIC_USE_GOVERNANCE_CACHE=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.
NEXT_PUBLIC_ENABLE_GASLESS_VOTING=false
NEXT_PUBLIC_ENABLE_STAKING=true
NEXT_PUBLIC_API_BASEURL=https://aave-api-v2.aave.com
NEXT_PUBLIC_TRANSAK_APP_URL=https://global.transak.com
Expand Down Expand Up @@ -53,3 +54,5 @@ 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=
64 changes: 64 additions & 0 deletions pages/api/gelato/relay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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<number, string> = Object.entries(
governanceV3Config.votingChainConfig
).reduce((acc, [chainId, config]) => {
acc[Number(chainId)] = config.votingMachineAddress.toLowerCase();
return acc;
}, {} as Record<number, string>);

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) });
}
}
20 changes: 10 additions & 10 deletions pages/governance/v3/proposal/index.governance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { useRouter } from 'next/router';
import { Meta } from 'src/components/Meta';
import {
useGovernanceProposalDetail,
useGovernanceProposalPayloads,
useGovernanceVotersSplit,
} from 'src/hooks/governance/useGovernanceProposals';
import { useProposalPayloadsCache } from 'src/hooks/governance/useProposalDetailCache';
} from 'src/hooks/governance/useGovernanceCache';
import { MainLayout } from 'src/layouts/MainLayout';
import { ProposalLifecycle } from 'src/modules/governance/proposal/ProposalLifecycle';
import { ProposalLifecycleCache } from 'src/modules/governance/proposal/ProposalLifecycleCache';
import { ProposalOverview } from 'src/modules/governance/proposal/ProposalOverview';
import { ProposalPayloads } from 'src/modules/governance/proposal/ProposalPayloads';
import { ProposalTopPanel } from 'src/modules/governance/proposal/ProposalTopPanel';
import { VoteInfo } from 'src/modules/governance/proposal/VoteInfo';
import { VotingResults } from 'src/modules/governance/proposal/VotingResults';
Expand All @@ -33,10 +33,11 @@ export default function ProposalPage() {
error: proposalError,
} = useGovernanceProposalDetail(proposalId);

const votingChainId = proposal?.voteProposalData?.votingMachineChainId;

const voters = useGovernanceVotersSplit(proposalId, votingChainId);
const { data: payloads, isLoading: payloadsLoading } = useProposalPayloadsCache(proposalId);
const voters = useGovernanceVotersSplit(proposalId);
// Payloads are only rendered by ProposalLifecycleCache (cache data path).
const { data: payloads, isLoading: payloadsLoading } = useGovernanceProposalPayloads(proposalId, {
enabled: !!proposal?.rawCacheDetail,
});

return (
<>
Expand Down Expand Up @@ -66,9 +67,8 @@ export default function ProposalPage() {
loading={proposalLoading}
votesLoading={voters.isFetching}
/>
{proposal?.rawProposal ? (
<ProposalLifecycle proposal={proposal.rawProposal} />
) : proposal?.rawCacheDetail ? (
<ProposalPayloads payloads={payloads} loading={payloadsLoading} />
{proposal?.rawCacheDetail ? (
<ProposalLifecycleCache
proposal={proposal.rawCacheDetail}
payloads={payloads}
Expand Down
171 changes: 78 additions & 93 deletions src/components/transactions/GovVote/GovVoteActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { ChainId } from '@aave/contract-helpers';
import { Trans } from '@lingui/macro';
import { useQueryClient } from '@tanstack/react-query';
import { AbiCoder, keccak256, RLP } from 'ethers/lib/utils';
import { useState } from 'react';
import { MOCK_SIGNED_HASH } from 'src/helpers/useTransactionHandler';
import { useGovernanceTokensAndPowers } from 'src/hooks/governance/useGovernanceTokensAndPowers';
import { useModalContext } from 'src/hooks/useModal';
import { useWeb3Context } from 'src/libs/hooks/useWeb3Context';
import { VoteProposalData } from 'src/modules/governance/types';
import { useRootStore } from 'src/store/root';
import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping';
import { governanceV3Config } from 'src/ui-config/governanceConfig';
import { queryKeysFactory } from 'src/ui-config/queries';
import { getProvider } from 'src/utils/marketsAndNetworksConfig';

import { TxActionsWrapper } from '../TxActionsWrapper';
Expand Down Expand Up @@ -180,35 +180,48 @@ 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,
proposal,
support,
}: GovVoteActionsProps) => {
const {
mainTxState,
loadingTxns,
setMainTxState,
setApprovalTxState,
approvalTxState,
setTxError,
} = useModalContext();
const { mainTxState, loadingTxns, setMainTxState, setTxError } = useModalContext();
const user = useRootStore((store) => store.account);

const estimateGasLimit = useRootStore((store) => store.estimateGasLimit);
const { sendTx, signTxData } = useWeb3Context();
const queryClient = useQueryClient();

const tokenPowers = useGovernanceTokensAndPowers(proposal.snapshotBlockHash);
const [signature, setSignature] = useState<string | undefined>(undefined);
const proposalId = +proposal.proposalId;
const blockHash = proposal.snapshotBlockHash;
const votingChainId = proposal.votingMachineChainId;
const votingMachineAddress =
governanceV3Config.votingChainConfig[votingChainId].votingMachineAddress;

const withGelatoRelayer = false;
const withGelatoRelayer = process.env.NEXT_PUBLIC_ENABLE_GASLESS_VOTING === 'true';

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

Expand Down Expand Up @@ -240,46 +253,57 @@ export const GovVoteActions = ({

const votingMachineService = new VotingMachineService(votingMachineAddress);

if (withGelatoRelayer && signature) {
// const tx = await votingMachineService.generateSubmitVoteBySignatureTxData(
// user,
// proposalId,
// support,
// proofs,
// signature.toString()
// );
// const gelatoRelay = new GelatoRelay();
// const gelatoRequest = {
// chainId: BigInt(votingChainId),
// target: votingMachineAddress,
// data: tx.data || '',
// };
// const response = await gelatoRelay.sponsoredCall(gelatoRequest, '');
// setTimeout(async function checkForStatus() {
// const status = await gelatoRelay.getTaskStatus(response.taskId);
// if (status?.blockNumber && status.transactionHash) {
// setMainTxState({
// txHash: status.transactionHash,
// loading: false,
// success: true,
// });
// queryClient.invalidateQueries({ queryKey: ['governance_proposal', proposalId, user] });
// queryClient.invalidateQueries({
// queryKey: ['governance-detail-cache', proposalId, user],
// });
// queryClient.invalidateQueries({ queryKey: ['proposalVotes', proposalId] });
// queryClient.invalidateQueries({
// queryKey: ['governance-voters-cache-for', proposalId],
// });
// queryClient.invalidateQueries({
// queryKey: ['governance-voters-cache-against', proposalId],
// });
// return;
// } else {
// setTimeout(checkForStatus, 5000);
// return;
// }
// }, 5000);
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({
chainId: votingChainId,
target: votingMachineAddress,
data: tx.data,
}),
});

if (!relayResponse.ok) {
throw new Error('Relay request failed');
}

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,
Expand All @@ -298,55 +322,17 @@ export const GovVoteActions = ({
success: true,
});

queryClient.invalidateQueries({ queryKey: ['governance_proposal', proposalId, user] });
queryClient.invalidateQueries({ queryKey: ['governance-detail-cache', proposalId, user] });
queryClient.invalidateQueries({ queryKey: ['proposalVotes', proposalId] });
queryClient.invalidateQueries({
queryKey: ['governance-voters-cache-for', proposalId],
});
queryClient.invalidateQueries({
queryKey: ['governance-voters-cache-against', proposalId],
});
queryClient.invalidateQueries({ queryKey: queryKeysFactory.governanceCache });
}
} catch (err) {
setTxError(getErrorTextFromError(err as Error, TxAction.MAIN_ACTION, false));
setMainTxState({
txHash: undefined,
loading: false,
});
}
};

const approve = async () => {
try {
setApprovalTxState({ ...approvalTxState, loading: true });
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);
setSignature(signature.toString());
setTxError(undefined);
setApprovalTxState({
txHash: MOCK_SIGNED_HASH,
loading: false,
success: true,
});
} catch {}
};

return (
<TxActionsWrapper
requiresApproval={false}
Expand All @@ -357,7 +343,6 @@ export const GovVoteActions = ({
actionText={support ? <Trans>VOTE YAE</Trans> : <Trans>VOTE NAY</Trans>}
actionInProgressText={support ? <Trans>VOTE YAE</Trans> : <Trans>VOTE NAY</Trans>}
isWrongNetwork={isWrongNetwork}
handleApproval={approve}
/>
);
};
Loading