Skip to content
Closed
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: 5 additions & 0 deletions .changeset/fancy-moments-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/app': patch
---

Implement unique enum ID for LockToVote dialog
5 changes: 5 additions & 0 deletions .changeset/hot-numbers-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/app': minor
---

Implement Tenderly simulations for proposal actions
5 changes: 5 additions & 0 deletions .changeset/nine-badgers-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/app': patch
---

Strip all headers from RPC requests to prevent 413 errors
5 changes: 5 additions & 0 deletions .changeset/six-mugs-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/app': patch
---

Fix calculation for stage 'Expiration period' on process details
5 changes: 5 additions & 0 deletions .changeset/two-sloths-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/app': patch
---

Fix isSupportReached utility for token-based plugins to handle support-threshold setting with decimals
5 changes: 5 additions & 0 deletions .changeset/weird-chains-break.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aragon/app': patch
---

Add Lock To Vote plugin support for ZKsync and ZKsync Sepolia
17 changes: 16 additions & 1 deletion src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,10 @@
}
},
"createProposalPage": {
"createProposalPageClientSteps": {
"simulate": "Simulate actions",
"skipSimulation": "Skip simulation"
},
"finalStep": "Publish proposal",
"steps": {
"ACTIONS": {
Expand Down Expand Up @@ -852,7 +856,9 @@
},
"main": {
"actions": {
"header": "Actions"
"header": "Actions",
"lastSimulationError": "There was an error getting the last simulation data. Please try again later.",
"simulationError": "There was an error simulating the actions. Please try again later."
},
"description": {
"header": "Proposal",
Expand Down Expand Up @@ -972,6 +978,15 @@
"title": "Create proposal"
}
},
"simulateActionsDialog": {
"action": {
"cancel": "Cancel",
"error": "Continue anyway",
"success": "Continue"
},
"error": "There was an error simulating the actions. Please try again later.",
"title": "Simulate actions"
},
"verifySmartContractDialog": {
"action": {
"add": "Add contract",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ describe('proxyBackend utils', () => {
describe('request', () => {
it('calls the rpc endpoint with the specified parameters, parses and returns the result', async () => {
const parsedResponse = { result: 'test' };
const fetchReturn = generateResponse({ json: jest.fn(() => Promise.resolve(parsedResponse)) });
const headers = new Headers();
const fetchReturn = generateResponse({ json: jest.fn(() => Promise.resolve(parsedResponse)), headers });
const mockNextResponse = {} as NextResponse;
fetchSpy.mockResolvedValue(fetchReturn);
await proxyBackendUtils.request(generateNextRequest());
nextResponseJsonSpy.mockReturnValue(mockNextResponse);

const result = await proxyBackendUtils.request(generateNextRequest());

expect(fetchSpy).toHaveBeenCalled();
expect(fetchReturn.json).toHaveBeenCalled();
expect(nextResponseJsonSpy).toHaveBeenCalledWith(parsedResponse);
expect(nextResponseJsonSpy).toHaveBeenCalledWith(parsedResponse, fetchReturn);
expect(result).toEqual(mockNextResponse);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ export class ProxyBackendUtils {

request = async (request: NextRequest) => {
const url = this.buildBackendUrl(request);
const requestOptions = await this.buildRequestOptions(request);

const result = await fetch(url, request);
const result = await fetch(url, requestOptions);
const parsedResult = (await result.json()) as unknown;

return NextResponse.json(parsedResult);
return NextResponse.json(parsedResult, result);
};

private buildBackendUrl = (request: NextRequest): string => {
Expand All @@ -18,6 +19,13 @@ export class ProxyBackendUtils {

return url;
};

private buildRequestOptions = async (request: NextRequest): Promise<RequestInit> => {
const { method, headers } = request;
const body = method.toUpperCase() === 'POST' ? await request.text() : undefined;

return { method, body, headers };
};
}

export const proxyBackendUtils = new ProxyBackendUtils();
29 changes: 23 additions & 6 deletions src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,30 @@ describe('proxyRpc utils', () => {
describe('buildRequestOptions', () => {
it('returns the parameters for the fetch call', () => {
const testClass = createTestClass();
const request = generateRequest({ method: 'POST', body: {} as ReadableStream });
expect(testClass['buildRequestOptions'](request)).toEqual({
method: request.method,
body: request.body,
headers: request.headers,
duplex: 'half',
const request = generateRequest({ method: 'POST', body: {} as ReadableStream, credentials: 'include' });

const requestOptions = testClass['buildRequestOptions'](request);

expect(requestOptions.method).toEqual(request.method);
expect(requestOptions.body).toEqual(request.body);
expect(requestOptions.duplex).toEqual('half');
expect(requestOptions.credentials).toEqual('omit'); // always omit credentials on proxy requests
});

it('strips all headers from the request', () => {
const testClass = createTestClass();
const headers = new Headers({
'test-header': 'test-value',
cookie: 'test-cookie=test-value',
Cookie: 'test-cookie=test-value',
});
const request = generateRequest({
headers,
});

const requestOptions = testClass['buildRequestOptions'](request);

expect(requestOptions.headers).toBeUndefined();
});
});
});
15 changes: 12 additions & 3 deletions src/modules/application/utils/proxyRpcUtils/proxyRpcUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,18 @@ export class ProxyRpcUtils {
private chainIdToNetwork = (chainId: string): Network | undefined =>
Object.values(Network).find((network) => networkDefinitions[network as Network].id === Number(chainId));

private buildRequestOptions = (request: Request): RequestInit => {
const { method, body, headers } = request;
// Return type extended to include Node-specific 'duplex' property used for streamed requests.
private buildRequestOptions = (request: Request): RequestInit & { duplex?: 'half' } => {
const { method, body } = request;

return { method, body, headers, duplex: 'half' } as RequestInit;
// Don't forward headers: avoid RPC 413 "Request Entity Too Large" errors caused by sending headers' data, specifically cookies.
// (Also, beneficial to prevent potential sensitive data leaks to 3rd party services.)
return {
method,
body,
// Ensure no implicit credential forwarding
credentials: 'omit',
duplex: 'half',
};
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Network } from '@/shared/api/daoService';
import type { IRequestUrlBodyParams, IRequestUrlParams } from '@/shared/api/httpService';

export interface ISimulateActionsUrlParams {
/**
* Network to simulate the actions on.
*/
network: Network;
/**
* Address of the plugin to simulate the actions for. Used as `from` address.
*/
pluginAddress: string;
}

export interface ISimulateActionsItem {
/**
* Address to simulate the transaction to.
*/
to: string;
/**
* Transaction data to simulate.
*/
data: string;
/**
* Value to send with the transaction.
*/
value: string;
}

export interface ISimulateActionsBody {
/**
* List of actions to simulate.
*/
actions: ISimulateActionsItem[];
}

export interface ISimulateActionsParams
extends IRequestUrlBodyParams<ISimulateActionsUrlParams, ISimulateActionsBody> {}

export interface ISimulateProposalUrlParams {
/**
* ID of the proposal to simulate.
*/
proposalId: string;
}

export interface ISimulateProposalParams extends IRequestUrlParams<ISimulateProposalUrlParams> {}

export interface IGetLastSimulationUrlParams {
/**
* ID of the proposal to get last simulation for.
*/
proposalId: string;
}

export interface IGetLastSimulationParams extends IRequestUrlParams<IGetLastSimulationUrlParams> {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { AragonBackendService } from '@/shared/api/aragonBackendService';
import type {
IGetLastSimulationParams,
ISimulateActionsParams,
ISimulateProposalParams,
} from './actionSimulationService.api';
import type { ISimulationResult } from './domain';

class ActionSimulationService extends AragonBackendService {
private urls = {
simulateActions: '/v2/simulations/:network/plugin/:pluginAddress/simulate',
simulateProposal: '/v2/simulations/proposal/:proposalId',
getLastSimulation: '/v2/simulations/proposal/:proposalId',
};

simulateActions = async (params: ISimulateActionsParams): Promise<ISimulationResult> => {
const result = await this.request<ISimulationResult>(this.urls.simulateActions, params, { method: 'POST' });

return result;
};

simulateProposal = async (params: ISimulateProposalParams): Promise<ISimulationResult> => {
const result = await this.request<ISimulationResult>(this.urls.simulateProposal, params, { method: 'POST' });

return result;
};

getLastSimulation = async (params: IGetLastSimulationParams): Promise<ISimulationResult> => {
const result = await this.request<ISimulationResult>(this.urls.getLastSimulation, params);

return result;
};
}

export const actionSimulationService = new ActionSimulationService();
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { IGetLastSimulationParams } from './actionSimulationService.api';

export enum ActionSimulationServiceKey {
LAST_SIMULATION = 'LAST_SIMULATION',
}

export const actionSimulationServiceKeys = {
lastSimulation: (params: IGetLastSimulationParams) => [ActionSimulationServiceKey.LAST_SIMULATION, params],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { ISimulationResult } from './simulationResult';
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export interface ISimulationResult {
/**
* Timestamp when the simulation was run.
*/
runAt: number;
/**
* Status of the simulation.
*/
status: 'success' | 'failed';
/**
* URL to view the simulation in Tenderly.
*/
url: string;
}
6 changes: 6 additions & 0 deletions src/modules/governance/api/actionSimulationService/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { actionSimulationService } from './actionSimulationService';
export type * from './actionSimulationService.api';
export { ActionSimulationServiceKey, actionSimulationServiceKeys } from './actionSimulationServiceKeys';
export type * from './domain';
export * from './mutations';
export * from './queries';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useSimulateActions';
export * from './useSimulateProposal';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useSimulateActions } from './useSimulateActions';
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { generateSimulationResult } from '@/modules/governance/testUtils';
import { Network } from '@/shared/api/daoService';
import { ReactQueryWrapper } from '@/shared/testUtils';
import { act, renderHook, waitFor } from '@testing-library/react';
import { actionSimulationService } from '../../actionSimulationService';
import { useSimulateActions } from './useSimulateActions';

describe('useSimulateActions mutation', () => {
const simulateActionsSpy = jest.spyOn(actionSimulationService, 'simulateActions');

afterEach(() => {
simulateActionsSpy.mockReset();
});

it('simulates actions and returns the result', async () => {
const simulationResult = generateSimulationResult();
const params = {
urlParams: { network: Network.ETHEREUM_MAINNET, pluginAddress: '0x123' },
body: { actions: [{ to: '0x456', data: '0x000', value: '0' }] },
};
simulateActionsSpy.mockResolvedValue(simulationResult);
const { result } = renderHook(() => useSimulateActions(), { wrapper: ReactQueryWrapper });
act(() => result.current.mutate(params));
await waitFor(() => expect(result.current.data).toEqual(simulationResult));
expect(simulateActionsSpy).toHaveBeenCalledWith(params);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useMutation, type MutationOptions } from '@tanstack/react-query';
import { actionSimulationService } from '../../actionSimulationService';
import type { ISimulateActionsParams } from '../../actionSimulationService.api';
import type { ISimulationResult } from '../../domain';

export const useSimulateActions = (options?: MutationOptions<ISimulationResult, unknown, ISimulateActionsParams>) => {
return useMutation({
mutationFn: (params) => actionSimulationService.simulateActions(params),
...options,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useSimulateProposal } from './useSimulateProposal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useMutation, type MutationOptions } from '@tanstack/react-query';
import { actionSimulationService } from '../../actionSimulationService';
import type { ISimulateProposalParams } from '../../actionSimulationService.api';
import type { ISimulationResult } from '../../domain';

export const useSimulateProposal = (options?: MutationOptions<ISimulationResult, unknown, ISimulateProposalParams>) => {
return useMutation({
mutationFn: (params) => actionSimulationService.simulateProposal(params),
...options,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useLastSimulation';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { lastSimulationOptions, useLastSimulation } from './useLastSimulation';
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { generateSimulationResult } from '@/modules/governance/testUtils';
import { ReactQueryWrapper } from '@/shared/testUtils';
import { renderHook, waitFor } from '@testing-library/react';
import { actionSimulationService } from '../../actionSimulationService';
import { useLastSimulation } from './useLastSimulation';

describe('useLastSimulation query', () => {
const getLastSimulationSpy = jest.spyOn(actionSimulationService, 'getLastSimulation');

afterEach(() => {
getLastSimulationSpy.mockReset();
});

it('fetches last simulation result for a proposal', async () => {
const simulationResult = generateSimulationResult();
const params = { urlParams: { proposalId: 'proposal-123' } };
getLastSimulationSpy.mockResolvedValue(simulationResult);
const { result } = renderHook(() => useLastSimulation(params), { wrapper: ReactQueryWrapper });
await waitFor(() => expect(result.current.data).toEqual(simulationResult));
expect(getLastSimulationSpy).toHaveBeenCalledWith(params);
});
});
Loading
Loading