Skip to content

Commit 3caee11

Browse files
authored
Merge pull request #13 from kleros/feat/tenderly-simulations
Feat/tenderly simulations
2 parents ebe7aa6 + 89466c6 commit 3caee11

File tree

20 files changed

+693
-22
lines changed

20 files changed

+693
-22
lines changed

web/.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
export NEXT_PUBLIC_APP_DEPLOYMENT=devnet
22
export NEXT_PUBLIC_REOWN_PROJECT_ID="abcabcabcabcabcabc"
33
export NEXT_PUBLIC_ALCHEMY_API_KEY="abcabcabcabcabcabc"
4-
export NEXT_PUBLIC_COURT_SITE="https://dev--kleros-v2-testnet.netlify.app/#"
4+
export NEXT_PUBLIC_COURT_SITE="https://dev--kleros-v2-testnet.netlify.app/#"
5+
6+
# tenderly simulations
7+
export TENDERLY_ACCOUNT_NAME="test"
8+
export TENDERLY_PROJECT_NAME="governor-test"
9+
export TENDERLY_ACCESS_KEY="abcabcabcabcabcabc"
10+
export ALLOWED_ORIGINS="http://localhost:3000"
11+
export ETHERSCAN_API_KEY="abcabcabcabcabcabc"

web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"prettier": "@kleros/governor-v2-prettier-config",
1616
"dependencies": {
1717
"@kleros/kleros-v2-contracts": "^0.7.0",
18-
"@kleros/ui-components-library": "^3.4.5",
18+
"@kleros/ui-components-library": "^3.6.0",
1919
"@reown/appkit": "^1.7.7",
2020
"@reown/appkit-adapter-wagmi": "^1.7.7",
2121
"@tailwindcss/postcss": "^4.1.7",

web/src/app/(main)/governor/[governorAddress]/MyLists/AddTxnModal.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"use client";
2-
import { useState } from "react";
2+
import { useMemo, useState } from "react";
33

44
import { BigNumberField, Button, Form, Modal, Radio, TextArea, TextField } from "@kleros/ui-components-library";
55
import clsx from "clsx";
66
import { type Abi, type AbiFunction, Address, encodeFunctionData, parseEther } from "viem";
77

8-
import { ListTransaction, useLists } from "@/context/LIstsContext";
8+
import { ListTransaction, useLists } from "@/context/NewListsContext";
9+
import { useContractInfo } from "@/hooks/useContractInfo";
910

1011
import { isUndefined } from "@/utils";
1112
import { flattenToNested, formatFunctionCall } from "@/utils/txnBuilder/format";
@@ -28,7 +29,20 @@ interface IAddTxnModal {
2829
const AddTxnModal: React.FC<IAddTxnModal> = ({ listId, isOpen, toggleIsOpen }) => {
2930
const [inputType, setInputType] = useState(InputType.DataInput);
3031
const [txnValue, setTxnValue] = useState("0");
32+
const [contractAddress, setContractAddress] = useState<Address>();
3133
const { addTxnToList } = useLists();
34+
const { data: contractInfo, isLoading: isLoadingContractInfo } = useContractInfo(contractAddress);
35+
36+
const { addressInputVariant, addressInputMessage } = useMemo(() => {
37+
if (isLoadingContractInfo)
38+
return { addressInputVariant: "info" as const, addressInputMessage: "Fetching contract details" };
39+
if (contractInfo?.abi)
40+
return {
41+
addressInputVariant: "success" as const,
42+
addressInputMessage: contractInfo?.name ?? "Verified contract",
43+
};
44+
return { addressInputVariant: undefined };
45+
}, [contractInfo, isLoadingContractInfo]);
3246

3347
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
3448
e.preventDefault();
@@ -116,6 +130,9 @@ const AddTxnModal: React.FC<IAddTxnModal> = ({ listId, isOpen, toggleIsOpen }) =
116130
validate={(val) => validateInputValue(val, { type: "address" })}
117131
label="Contract Address"
118132
isRequired
133+
variant={addressInputVariant}
134+
message={addressInputMessage}
135+
onChange={(val) => setContractAddress(val as Address)}
119136
/>
120137
<BigNumberField
121138
name="value"
@@ -153,7 +170,7 @@ const AddTxnModal: React.FC<IAddTxnModal> = ({ listId, isOpen, toggleIsOpen }) =
153170
validate={(val) => validateInputValue(val, { type: "bytes" })}
154171
/>
155172
) : (
156-
<JSONInput />
173+
<JSONInput abi={contractInfo?.abi} />
157174
)}
158175
<Button text="Submit" type="submit" className="self-end mt-2" />
159176
</Form>

web/src/app/(main)/governor/[governorAddress]/MyLists/Header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22
import { Button } from "@kleros/ui-components-library";
33

4-
import { useLists } from "@/context/LIstsContext";
4+
import { useLists } from "@/context/NewListsContext";
55

66
import Paper from "@/assets/svgs/icons/paper.svg";
77

web/src/app/(main)/governor/[governorAddress]/MyLists/JsonInput.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22

33
import { BigNumberField, DropdownSelect, TextArea, TextField } from "@kleros/ui-components-library";
44
import clsx from "clsx";
55
import type { Abi, AbiFunction } from "viem";
66

7+
import { isUndefined } from "@/utils";
78
import { getDefaultPlaceholder, mapSolidityToInputType } from "@/utils/txnBuilder/format";
89
import { TupleInput } from "@/utils/txnBuilder/parsing";
910
import { validateInputValue } from "@/utils/txnBuilder/validation";
@@ -84,13 +85,17 @@ function renderInputField(input: TupleInput, path: string): JSX.Element {
8485
);
8586
}
8687

87-
const JSONInput: React.FC = () => {
88+
const JSONInput: React.FC<{ abi?: Abi }> = ({ abi }) => {
8889
const [abiInput, setAbiInput] = useState<string>("");
8990
const [functions, setFunctions] = useState<AbiFunction[]>([]);
9091
const [selectedFunction, setSelectedFunction] = useState<AbiFunction | null>(null);
9192

9293
const [error, setError] = useState<string>("");
9394

95+
useEffect(() => {
96+
if (!isUndefined(abi)) handleAbiChange(JSON.stringify(abi));
97+
}, [abi]);
98+
9499
const handleAbiChange = (val: string) => {
95100
setAbiInput(val);
96101
setError("");

web/src/app/(main)/governor/[governorAddress]/MyLists/Lists.tsx

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import { Button, Card, CustomAccordion, DraggableList } from "@kleros/ui-compone
55
import clsx from "clsx";
66
import { useToggle } from "react-use";
77

8-
import { List, useLists } from "@/context/LIstsContext";
8+
import { List, useLists } from "@/context/NewListsContext";
99

10+
import ExternalLink from "@/components/ExternalLink";
1011
import DisplayCard from "@/components/ListDisplayCard";
1112
import Status from "@/components/Status";
1213

1314
import Calendar from "@/assets/svgs/icons/calendar.svg";
1415
import Trash from "@/assets/svgs/icons/trash.svg";
16+
import TenderlyIcon from "@/assets/svgs/logos/tenderly.svg";
1517

16-
import { formatDate } from "@/utils";
18+
import { formatDate, isUndefined } from "@/utils";
1719
import { formatETH } from "@/utils/format";
1820

1921
import AddTxnModal from "./AddTxnModal";
@@ -25,7 +27,9 @@ const AccordionBody: React.FC<IAccordionBody> = ({ list }) => {
2527
const { id: listId, transactions } = list;
2628
const [isOpen, toggleIsOpen] = useToggle(false);
2729
const [selectedTxn, setSelectedTxn] = useState<List["transactions"][number] | undefined>(transactions?.[0]);
28-
const { governorAddress, updateTransactions, deleteList } = useLists();
30+
const { governorAddress, updateTransactions, deleteList, simulateList, isSimulating, simulations } = useLists();
31+
32+
const simulationShareLink = simulations.get(listId);
2933

3034
// select the latest txn, when new txn added
3135
useEffect(() => {
@@ -36,6 +40,9 @@ const AccordionBody: React.FC<IAccordionBody> = ({ list }) => {
3640
}
3741
}, [transactions]);
3842

43+
const simulate = () => {
44+
simulateList(listId);
45+
};
3946
return (
4047
<div className="w-full pt-2 lg:px-6 flex flex-col justify-end items-end">
4148
<Button
@@ -77,6 +84,33 @@ const AccordionBody: React.FC<IAccordionBody> = ({ list }) => {
7784
<Button text="Add tx" variant="secondary" small onPress={toggleIsOpen} />
7885
<SubmissionButton {...{ governorAddress, list }} />
7986
</div>
87+
{transactions.length > 0 ? (
88+
<div
89+
className={clsx(
90+
"w-full px-6 pt-4 gap-4",
91+
"flex flex-wrap items-center ",
92+
"border-t border-t-klerosUIComponentsStroke",
93+
isUndefined(simulationShareLink) ? "justify-end" : "justify-between"
94+
)}
95+
>
96+
{simulationShareLink ? (
97+
<ExternalLink
98+
url={simulationShareLink}
99+
text="Inspect on Tenderly"
100+
className="text-klerosUIComponentsPrimaryBlue"
101+
/>
102+
) : null}
103+
<Button
104+
small
105+
text="Simulate"
106+
variant="secondary"
107+
onPress={simulate}
108+
isDisabled={isSimulating}
109+
isLoading={isSimulating}
110+
icon={<TenderlyIcon className="size-4 mr-2 ml" />}
111+
/>
112+
</div>
113+
) : null}
80114
</Card>
81115
<div className="flex flex-col gap-2.5 md:gap-4">
82116
<DisplayCard label="Contract Address" value={selectedTxn?.to ?? ""} />
@@ -85,7 +119,7 @@ const AccordionBody: React.FC<IAccordionBody> = ({ list }) => {
85119
<DisplayCard label="Decoded Input" value={selectedTxn?.decodedInput ?? ""} />
86120
</div>
87121

88-
<AddTxnModal {...{ isOpen, toggleIsOpen, listId }} />
122+
{isOpen ? <AddTxnModal {...{ isOpen, toggleIsOpen, listId }} /> : null}
89123
</div>
90124
</div>
91125
);

web/src/app/(main)/governor/[governorAddress]/MyLists/SubmissionButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Address } from "viem";
66

77
import { usePublicClient } from "wagmi";
88

9-
import { List, useLists } from "@/context/LIstsContext";
9+
import { List, useLists } from "@/context/NewListsContext";
1010
import { useSimulateSubmitList, useWriteSubmitList } from "@/hooks/useGovernor";
1111
import { useSubmissionFee } from "@/hooks/useSubmissionFee";
1212

web/src/app/(main)/governor/[governorAddress]/MyLists/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Address } from "viem";
44

5-
import { ListsProvider } from "@/context/LIstsContext";
5+
import { ListsProvider } from "@/context/NewListsContext";
66

77
import Header from "./Header";
88
import Lists from "./Lists";

web/src/app/api/contract/route.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { isAddress } from "viem";
3+
4+
import { isUndefined } from "@/utils";
5+
import { checkRateLimit } from "@/utils/simulateRouteUtils";
6+
7+
export async function GET(request: NextRequest) {
8+
const ip = request.ip || request.headers.get("x-forwarded-for") || "unknown";
9+
10+
const rateLimitCheck = checkRateLimit(ip);
11+
if (!rateLimitCheck.allowed) {
12+
return NextResponse.json(
13+
{ error: "Rate limit exceeded. Try again later." },
14+
{
15+
status: 429,
16+
headers: {
17+
"Retry-After": Math.ceil((rateLimitCheck.resetTime || 0 - Date.now()) / 1000).toString(),
18+
},
19+
}
20+
);
21+
}
22+
23+
const searchParams = request.nextUrl.searchParams;
24+
const networkId = searchParams.get("networkId");
25+
const contractAddress = searchParams.get("contractAddress");
26+
27+
if (!networkId || !contractAddress) {
28+
return NextResponse.json({ error: "Missing required parameters: networkId and contractAddress" }, { status: 400 });
29+
}
30+
31+
if (!isAddress(contractAddress)) {
32+
return NextResponse.json({ error: "Invalid contract address format" }, { status: 400 });
33+
}
34+
35+
try {
36+
if (isUndefined(process.env.TENDERLY_ACCESS_KEY)) {
37+
throw new Error("Failed to fetch contract details: Environment variables not configured.");
38+
}
39+
40+
// Fetch contract details from Tenderly API
41+
const tenderlyApiUrl = `https://api.tenderly.co/api/v1/public-contracts/${networkId}/${contractAddress}`;
42+
43+
const response = await fetch(tenderlyApiUrl, {
44+
method: "GET",
45+
headers: {
46+
"Content-Type": "application/json",
47+
"X-Access-Key": process.env.TENDERLY_ACCESS_KEY,
48+
},
49+
});
50+
51+
if (!response.ok) {
52+
// If Tenderly fails, try Etherscan as fallback
53+
return await tryEtherscanFallback(networkId, contractAddress);
54+
}
55+
56+
const tenderlyData = await response.json();
57+
58+
// If contract is unverified in Tenderly, try Etherscan
59+
if (tenderlyData?.type === "unverified_contract" || isUndefined(tenderlyData?.data?.abi)) {
60+
return await tryEtherscanFallback(networkId, contractAddress);
61+
}
62+
63+
// Return formatted data for verified contract from Tenderly
64+
return NextResponse.json({
65+
address: contractAddress,
66+
name: tenderlyData.contract_name,
67+
abi: tenderlyData.data.abi,
68+
});
69+
} catch (error) {
70+
console.error("Contract fetch error:", error instanceof Error ? error.message : "Unknown error");
71+
72+
return NextResponse.json(
73+
{
74+
error: error instanceof Error ? error.message : "Failed to fetch contract details",
75+
},
76+
{ status: 500 }
77+
);
78+
}
79+
}
80+
81+
async function tryEtherscanFallback(networkId: string, contractAddress: string) {
82+
try {
83+
const etherscanApiKey = process.env.ETHERSCAN_API_KEY;
84+
85+
if (isUndefined(etherscanApiKey)) {
86+
return NextResponse.json({ error: "Etherscan API key not configured" }, { status: 500 });
87+
}
88+
89+
const baseUrl = "https://api.etherscan.io/v2/api";
90+
// eslint-disable-next-line max-len
91+
const url = `${baseUrl}?chainid=${networkId}&module=contract&action=getabi&address=${contractAddress}&apikey=${etherscanApiKey}`;
92+
const response = await fetch(url);
93+
94+
if (!response.ok) {
95+
return NextResponse.json(
96+
{ error: `Failed to fetch from Etherscan: ${response.status}` },
97+
{ status: response.status }
98+
);
99+
}
100+
101+
const arbiscanData = await response.json();
102+
103+
if (arbiscanData.status !== "1" || !arbiscanData.result) {
104+
return NextResponse.json({ error: "Contract not verified on Etherscan" }, { status: 404 });
105+
}
106+
107+
const abi = JSON.parse(arbiscanData.result);
108+
109+
return NextResponse.json({
110+
address: contractAddress,
111+
name: null, // Etherscan doesn't provide contract name in this API
112+
abi,
113+
});
114+
} catch (error) {
115+
console.error("Etherscan fallback error:", error instanceof Error ? error.message : "Unknown error");
116+
return NextResponse.json(
117+
{
118+
error: error instanceof Error ? error.message : "Failed to fetch from Etherscan",
119+
},
120+
{ status: 500 }
121+
);
122+
}
123+
}

0 commit comments

Comments
 (0)