diff --git a/.env.sample b/.env.sample
index c55c15f3..80b6e1c8 100644
--- a/.env.sample
+++ b/.env.sample
@@ -21,3 +21,8 @@ CSP_REPORT_ONLY=
# api endpoint for reporting csp violations
CSP_REPORT_URI=
+
+# tenderly credentials
+TENDERLY_USER=
+TENDERLY_PROJECT=
+TENDERLY_ACCESS_KEY=
diff --git a/assets/tenderly.com.svg.react b/assets/tenderly.com.svg.react
new file mode 100644
index 00000000..c6f9ad10
--- /dev/null
+++ b/assets/tenderly.com.svg.react
@@ -0,0 +1 @@
+
diff --git a/examples/sample.env-develop.env b/examples/sample.env-develop.env
index 027d7d16..a02093d6 100644
--- a/examples/sample.env-develop.env
+++ b/examples/sample.env-develop.env
@@ -21,3 +21,8 @@ CSP_REPORT_ONLY=
# api endpoint for reporting csp violations
CSP_REPORT_URI=
+
+# tenderly credentials
+TENDERLY_USER=
+TENDERLY_PROJECT=
+TENDERLY_ACCESS_KEY=
diff --git a/examples/sample.env-production.env b/examples/sample.env-production.env
index 638ab3b3..dbe14ecd 100644
--- a/examples/sample.env-production.env
+++ b/examples/sample.env-production.env
@@ -21,3 +21,8 @@ CSP_REPORT_ONLY=
# api endpoint for reporting csp violations
CSP_REPORT_URI=
+
+# tenderly credentials
+TENDERLY_USER=
+TENDERLY_PROJECT=
+TENDERLY_ACCESS_KEY=
diff --git a/examples/sample.env-staging.env b/examples/sample.env-staging.env
index 638ab3b3..dbe14ecd 100644
--- a/examples/sample.env-staging.env
+++ b/examples/sample.env-staging.env
@@ -21,3 +21,8 @@ CSP_REPORT_ONLY=
# api endpoint for reporting csp violations
CSP_REPORT_URI=
+
+# tenderly credentials
+TENDERLY_USER=
+TENDERLY_PROJECT=
+TENDERLY_ACCESS_KEY=
diff --git a/modules/dashboard/ui/DashboardVote/DashboardVote.tsx b/modules/dashboard/ui/DashboardVote/DashboardVote.tsx
index 1fe562c0..9d77a5a0 100644
--- a/modules/dashboard/ui/DashboardVote/DashboardVote.tsx
+++ b/modules/dashboard/ui/DashboardVote/DashboardVote.tsx
@@ -1,5 +1,6 @@
import { useCallback } from 'react'
import { useVotePassedCallback } from 'modules/votes/hooks/useVotePassedCallback'
+import { useVoteDetailsFormatted } from 'modules/votes/hooks/useVoteDetailsFormatted'
import Link from 'next/link'
import { VoteStatusBanner } from 'modules/votes/ui/VoteStatusBanner'
@@ -17,9 +18,6 @@ import {
} from './DashboardVoteStyle'
import type { StartVoteEventObject } from 'generated/AragonVotingAbi'
import { Vote, VoteStatus } from 'modules/votes/types'
-import { weiToNum } from 'modules/blockChain/utils/parseWei'
-import { getVoteDetailsFormatted } from 'modules/votes/utils/getVoteDetailsFormatted'
-import { formatFloatPct } from 'modules/shared/utils/formatFloatPct'
import * as urls from 'modules/network/utils/urls'
type Props = {
@@ -44,12 +42,13 @@ export function DashboardVote({
const {
nayPct,
yeaPct,
- yeaPctOfTotalSupply,
nayPctOfTotalSupplyFormatted,
yeaPctOfTotalSupplyFormatted,
+ neededToQuorum,
+ neededToQuorumFormatted,
startDate,
endDate,
- } = getVoteDetailsFormatted({ vote, voteTime })
+ } = useVoteDetailsFormatted({ vote, voteTime })! // we are sure that we have non-undefined `vote` and `voteTime` here
const handlePass = useCallback(() => {
// TODO:
@@ -72,11 +71,6 @@ export function DashboardVote({
onPass: handlePass,
})
- const neededToQuorum = weiToNum(vote.minAcceptQuorum) - yeaPctOfTotalSupply
- const neededToQuorumFormatted = formatFloatPct(neededToQuorum, {
- floor: true,
- }).toFixed(2)
-
const isEnded =
status === VoteStatus.Rejected || status === VoteStatus.Executed
diff --git a/modules/shared/metrics/index.ts b/modules/shared/metrics/index.ts
index a9fb69f6..ddf0c595 100644
--- a/modules/shared/metrics/index.ts
+++ b/modules/shared/metrics/index.ts
@@ -3,7 +3,11 @@ import { buildInfo } from './buildInfo'
import { chainInfo } from './chainInfo'
import { METRICS_PREFIX } from './constants'
import { contractInfo } from './contractInfo'
-import { rpcResponseTime, etherscanResponseTime } from './responseTime'
+import {
+ rpcResponseTime,
+ etherscanResponseTime,
+ tenderlyResponseTime,
+} from './responseTime'
const registry = new Registry()
@@ -13,6 +17,7 @@ if (process.env.NODE_ENV === 'production') {
registry.registerMetric(contractInfo)
registry.registerMetric(rpcResponseTime)
registry.registerMetric(etherscanResponseTime)
+ registry.registerMetric(tenderlyResponseTime)
collectDefaultMetrics({ prefix: METRICS_PREFIX, register: registry })
}
diff --git a/modules/shared/metrics/responseTime.ts b/modules/shared/metrics/responseTime.ts
index 2da28e7b..9aafdaac 100644
--- a/modules/shared/metrics/responseTime.ts
+++ b/modules/shared/metrics/responseTime.ts
@@ -16,3 +16,11 @@ export const etherscanResponseTime = new Histogram({
labelNames: ['chainId'],
registers: [],
})
+
+export const tenderlyResponseTime = new Histogram({
+ name: METRICS_PREFIX + 'tenderly_response',
+ help: 'Tenderly response times',
+ labelNames: [],
+ buckets: [0.1, 0.2, 0.3, 0.6, 1, 1.5, 2, 5],
+ registers: [],
+})
diff --git a/modules/votes/hooks/useVoteDetailsFormatted.ts b/modules/votes/hooks/useVoteDetailsFormatted.ts
new file mode 100644
index 00000000..8dbb60fe
--- /dev/null
+++ b/modules/votes/hooks/useVoteDetailsFormatted.ts
@@ -0,0 +1,14 @@
+import { useMemo } from 'react'
+import { getVoteDetailsFormatted } from '../utils/getVoteDetailsFormatted'
+
+type Args = Partial[0]>
+
+export const useVoteDetailsFormatted = ({ vote, voteTime }: Args) => {
+ return useMemo(
+ () =>
+ vote && voteTime
+ ? getVoteDetailsFormatted({ vote, voteTime })
+ : undefined,
+ [vote, voteTime],
+ )
+}
diff --git a/modules/votes/ui/VoteDetails/VoteDetails.tsx b/modules/votes/ui/VoteDetails/VoteDetails.tsx
index afeb0a7e..09add531 100644
--- a/modules/votes/ui/VoteDetails/VoteDetails.tsx
+++ b/modules/votes/ui/VoteDetails/VoteDetails.tsx
@@ -20,7 +20,7 @@ import { VoteDescription } from '../VoteDescription'
import { Vote, VoteStatus } from 'modules/votes/types'
import { weiToNum } from 'modules/blockChain/utils/parseWei'
import { formatNumber } from 'modules/shared/utils/formatNumber'
-import { getVoteDetailsFormatted } from 'modules/votes/utils/getVoteDetailsFormatted'
+import type { getVoteDetailsFormatted } from 'modules/votes/utils/getVoteDetailsFormatted'
type Props = {
vote: Vote
@@ -32,6 +32,7 @@ type Props = {
metadata?: string
isEnded: boolean
executedTxHash?: string
+ voteDetailsFormatted: ReturnType
}
export function VoteDetails({
@@ -44,6 +45,7 @@ export function VoteDetails({
metadata = '',
isEnded,
executedTxHash,
+ voteDetailsFormatted,
}: Props) {
const {
totalSupplyFormatted,
@@ -55,7 +57,7 @@ export function VoteDetails({
yeaPctOfTotalSupplyFormatted,
startDate,
endDate,
- } = getVoteDetailsFormatted({ vote, voteTime })
+ } = voteDetailsFormatted
return (
<>
diff --git a/modules/votes/ui/VoteForm/VoteForm.tsx b/modules/votes/ui/VoteForm/VoteForm.tsx
index df3f6ec0..5e495dc3 100644
--- a/modules/votes/ui/VoteForm/VoteForm.tsx
+++ b/modules/votes/ui/VoteForm/VoteForm.tsx
@@ -14,8 +14,11 @@ import { VoteFormVoterState } from '../VoteFormVoterState'
import { VoteVotersList } from '../VoteVotersList'
import { Desc, ClearButton } from './VoteFormStyle'
import { FetchErrorBanner } from 'modules/shared/ui/Common/FetchErrorBanner'
+import { VoteSimulation } from '../VoteSimulation'
+import { DetailsBoxWrap } from '../VoteDetails/VoteDetailsStyle'
import { VoteStatus } from 'modules/votes/types'
+import { isVoteEnactable } from 'modules/votes/utils/isVoteEnactable'
type Props = {
voteId?: string
@@ -39,14 +42,21 @@ export function VoteForm({ voteId }: Props) {
eventsVoted,
eventExecuteVote,
status,
+ voteDetailsFormatted,
} = useFormVoteInfo({ voteId })
const { clearVoteId } = useVotePrompt()
- const { txVote, txEnact, handleVote, handleEnact, isSubmitting } =
- useFormVoteSubmit({
- voteId,
- onFinish: doRevalidate,
- })
+ const {
+ txVote,
+ txEnact,
+ handleVote,
+ populateEnact,
+ handleEnact,
+ isSubmitting,
+ } = useFormVoteSubmit({
+ voteId,
+ onFinish: doRevalidate,
+ })
useVotePassedCallback({
startDate,
@@ -67,6 +77,15 @@ export function VoteForm({ voteId }: Props) {
const isEmpty = !voteId
const isNotFound = swrVote.error?.reason === 'VOTING_NO_VOTE'
const isFound = !isEmpty && !isNotFound && !isLoading && vote && status
+ const isEnactmentStillPossible =
+ vote &&
+ voteDetailsFormatted &&
+ isVoteEnactable(vote) &&
+ !isEnded &&
+ !(
+ status !== VoteStatus.ActiveMain &&
+ voteDetailsFormatted.neededToQuorum > 0
+ )
return (
@@ -118,8 +137,21 @@ export function VoteForm({ voteId }: Props) {
creator={eventStart?.creator}
metadata={eventStart?.metadata}
executedTxHash={eventExecuteVote?.event.transactionHash}
+ voteDetailsFormatted={voteDetailsFormatted!}
/>
+ {isEnactmentStillPossible && (
+
+
+
+ )}
+
{!isWalletConnected && }
{isWalletConnected && (
diff --git a/modules/votes/ui/VoteForm/useFormVoteInfo.ts b/modules/votes/ui/VoteForm/useFormVoteInfo.ts
index eeda94a2..546697f5 100644
--- a/modules/votes/ui/VoteForm/useFormVoteInfo.ts
+++ b/modules/votes/ui/VoteForm/useFormVoteInfo.ts
@@ -4,6 +4,7 @@ import { useCallback, useEffect } from 'react'
import { useSWR } from 'modules/network/hooks/useSwr'
import { useWeb3 } from 'modules/blockChain/hooks/useWeb3'
import { useConfig } from 'modules/config/hooks/useConfig'
+import { useVoteDetailsFormatted } from 'modules/votes/hooks/useVoteDetailsFormatted'
import {
ContractVoting,
@@ -125,6 +126,11 @@ export function useFormVoteInfo({ voteId }: Args) {
}
}, [doRevalidate, contractVoting, voteId])
+ const voteDetailsFormatted = useVoteDetailsFormatted({
+ vote,
+ voteTime,
+ })
+
return {
swrVote,
vote,
@@ -142,5 +148,6 @@ export function useFormVoteInfo({ voteId }: Args) {
eventsVoted: swrVote.data?.eventsVoted,
eventExecuteVote: swrVote.data?.eventExecuteVote,
status: swrVote.data?.status,
+ voteDetailsFormatted,
}
}
diff --git a/modules/votes/ui/VoteForm/useFormVoteSubmit.ts b/modules/votes/ui/VoteForm/useFormVoteSubmit.ts
index d3abf804..b84ea58a 100644
--- a/modules/votes/ui/VoteForm/useFormVoteSubmit.ts
+++ b/modules/votes/ui/VoteForm/useFormVoteSubmit.ts
@@ -68,20 +68,17 @@ export function useFormVoteSubmit({ voteId, onFinish }: Args) {
[voteId, txVote],
)
- const populateEnact = useCallback(
- async (args: { voteId: string }) => {
- const gasLimit = await estimateGasFallback(
- contractVoting.estimateGas.executeVote(args.voteId),
- 2000000,
- )
- const tx = await contractVoting.populateTransaction.executeVote(
- args.voteId,
- { gasLimit },
- )
- return tx
- },
- [contractVoting],
- )
+ const populateEnact = useCallback(async () => {
+ if (!voteId) throw new Error('voteId is required')
+ const gasLimit = await estimateGasFallback(
+ contractVoting.estimateGas.executeVote(voteId),
+ 2000000,
+ )
+ const tx = await contractVoting.populateTransaction.executeVote(voteId, {
+ gasLimit,
+ })
+ return tx
+ }, [voteId, contractVoting])
const txEnact = useTransactionSender(populateEnact, {
onError: handleError,
onFinish: handleFinish,
@@ -91,7 +88,7 @@ export function useFormVoteSubmit({ voteId, onFinish }: Args) {
if (!voteId) return
try {
setSubmitting('enact')
- await txEnact.send({ voteId })
+ await txEnact.send()
} catch (err) {
console.error(err)
setSubmitting(false)
@@ -104,5 +101,6 @@ export function useFormVoteSubmit({ voteId, onFinish }: Args) {
handleVote,
handleEnact,
isSubmitting,
+ populateEnact,
}
}
diff --git a/modules/votes/ui/VoteSimulation/VoteSimulation.tsx b/modules/votes/ui/VoteSimulation/VoteSimulation.tsx
new file mode 100644
index 00000000..ad0236e0
--- /dev/null
+++ b/modules/votes/ui/VoteSimulation/VoteSimulation.tsx
@@ -0,0 +1,114 @@
+import { useCallback, useMemo, useState } from 'react'
+import { useWeb3 } from 'modules/blockChain/hooks/useWeb3'
+import { useSWR } from 'modules/network/hooks/useSwr'
+
+import { ButtonIcon, ToastError } from '@lidofinance/lido-ui'
+import { TenderlyIcon } from './VoteSimulationStyle'
+
+import {
+ getForkSimulationUrl,
+ sumulateVoteEnactmentOnFork,
+} from './tenderlyUtils'
+import { fetchForkTransactionsList, fetchForksList } from './tenderlyFetchers'
+import { getErrorMessage } from 'modules/shared/utils/getErrorMessage'
+import { openWindow } from 'modules/shared/utils/openWindow'
+import type { Vote } from 'modules/votes/types'
+import type { PopulatedTransaction } from 'ethers'
+
+type Props = {
+ voteId: string
+ vote: Vote
+ voteTime?: number
+ objectionPhaseTime?: number
+ populateEnact: () => Promise
+}
+
+export const VoteSimulation = ({
+ voteId,
+ vote,
+ voteTime,
+ objectionPhaseTime,
+ populateEnact,
+}: Props) => {
+ const { chainId } = useWeb3()
+ const [simulationIdState, setSimulationId] = useState(
+ undefined,
+ )
+ const [isPendingSimulation, setPendingSimulation] = useState(false)
+
+ const { data: enactTxPopulated, initialLoading: isLoadingTx } = useSWR(
+ ['populated-enact-tx', voteId],
+ populateEnact,
+ )
+
+ const { data: simulationsList, initialLoading: isLoadingList } = useSWR(
+ 'tenderly-simulations-list',
+ async () => {
+ const { simulation_forks: forks } = await fetchForksList()
+ const transactionsRaw = await Promise.all(
+ forks.map(fork => fetchForkTransactionsList(fork.id)),
+ )
+ const transactions = transactionsRaw.reduce(
+ (prev, curr) => [...prev, ...curr.fork_transactions],
+ [],
+ )
+ return transactions
+ },
+ )
+
+ const simulationPreexisted = useMemo(() => {
+ if (!simulationsList || !enactTxPopulated) return null
+ return simulationsList.find(
+ s =>
+ s.status === true &&
+ s.to.toLowerCase() === enactTxPopulated.to?.toLowerCase() &&
+ s.input === enactTxPopulated.data,
+ )
+ }, [simulationsList, enactTxPopulated])
+
+ const isLoading = isLoadingList || isLoadingTx || isPendingSimulation
+ const simulationId = simulationPreexisted?.id ?? simulationIdState
+
+ const runSimulation = useCallback(async () => {
+ if (!enactTxPopulated || !voteTime || !objectionPhaseTime) return
+ setPendingSimulation(true)
+ try {
+ const id = await sumulateVoteEnactmentOnFork({
+ chainId,
+ voteId,
+ vote,
+ voteTime,
+ objectionPhaseTime,
+ })
+ setSimulationId(id)
+ return id
+ } catch (error) {
+ console.error(error)
+ ToastError(getErrorMessage(error), {})
+ } finally {
+ setPendingSimulation(false)
+ }
+ }, [chainId, enactTxPopulated, vote, voteId, voteTime, objectionPhaseTime])
+
+ const handleClick = useCallback(async () => {
+ if (isLoading || !enactTxPopulated) return
+ let id = simulationId
+ if (!id) {
+ id = await runSimulation()
+ }
+ if (id) openWindow(getForkSimulationUrl(id))
+ }, [isLoading, enactTxPopulated, simulationId, runSimulation])
+
+ return (
+ }
+ color="secondary"
+ variant="translucent"
+ fullwidth
+ loading={isLoading}
+ onClick={handleClick}
+ >
+ {simulationId ? 'Show' : 'Run'} Tenderly enactment simulation
+
+ )
+}
diff --git a/modules/votes/ui/VoteSimulation/VoteSimulationStyle.ts b/modules/votes/ui/VoteSimulation/VoteSimulationStyle.ts
new file mode 100644
index 00000000..42efb8be
--- /dev/null
+++ b/modules/votes/ui/VoteSimulation/VoteSimulationStyle.ts
@@ -0,0 +1,9 @@
+import styled from 'styled-components'
+import TenderlySvg from 'assets/tenderly.com.svg.react'
+
+export const TenderlyIcon = styled(TenderlySvg).attrs({
+ width: 24,
+ height: 24,
+})`
+ margin-right: ${({ theme }) => theme.spaceMap.sm}px;
+`
diff --git a/modules/votes/ui/VoteSimulation/index.ts b/modules/votes/ui/VoteSimulation/index.ts
new file mode 100644
index 00000000..5b1e019a
--- /dev/null
+++ b/modules/votes/ui/VoteSimulation/index.ts
@@ -0,0 +1 @@
+export * from './VoteSimulation'
diff --git a/modules/votes/ui/VoteSimulation/tenderlyFetchers.ts b/modules/votes/ui/VoteSimulation/tenderlyFetchers.ts
new file mode 100644
index 00000000..dfc96457
--- /dev/null
+++ b/modules/votes/ui/VoteSimulation/tenderlyFetchers.ts
@@ -0,0 +1,58 @@
+import { CHAINS } from '@lido-sdk/constants'
+import {
+ TenderlyForkResponse,
+ TenderlyResponseForksList,
+ TenderlyResponseForkTransactionsList,
+} from './type'
+
+export const TENDERLY_API_URL = '/api/tenderly'
+
+export const getForkSimulationUrl = (id: string) =>
+ `https://dashboard.tenderly.co/shared/fork/simulation/${id}`
+
+export const fetchForksList = async (): Promise => {
+ const res = await fetch(`${TENDERLY_API_URL}/forks`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+ return res.json()
+}
+
+export const fetchForkTransactionsList = async (
+ forkId: string,
+): Promise => {
+ const res = await fetch(`${TENDERLY_API_URL}/fork/${forkId}/transactions`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+ return res.json()
+}
+
+export const requestSimulationShare = async (
+ forkId: string,
+ simulationId: string,
+) => {
+ await fetch(
+ `${TENDERLY_API_URL}/fork/${forkId}/transaction/${simulationId}/share`,
+ {
+ method: 'POST',
+ },
+ )
+}
+
+export const requestNetworkFork = async (args: {
+ chainId: CHAINS
+}): Promise => {
+ const res = await fetch(`${TENDERLY_API_URL}/fork`, {
+ method: 'POST',
+ body: JSON.stringify({
+ network_id: String(args.chainId),
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+ return res.json()
+}
diff --git a/modules/votes/ui/VoteSimulation/tenderlyUtils.ts b/modules/votes/ui/VoteSimulation/tenderlyUtils.ts
new file mode 100644
index 00000000..eda4ef6b
--- /dev/null
+++ b/modules/votes/ui/VoteSimulation/tenderlyUtils.ts
@@ -0,0 +1,102 @@
+import * as ethers from 'ethers'
+import { CHAINS } from '@lido-sdk/constants'
+import { ContractVoting } from 'modules/blockChain/contracts'
+import {
+ fetchForkTransactionsList,
+ requestNetworkFork,
+ requestSimulationShare,
+} from './tenderlyFetchers'
+import { estimateGasFallback } from 'modules/shared/utils/estimateGasFallback'
+import { Vote } from 'modules/votes/types'
+
+const TREASURY_ADDRESS: Partial> = {
+ [CHAINS.Mainnet]: '0x3e40D73EB977Dc6a537aF587D48316feE66E9C8c',
+ [CHAINS.Goerli]: '0x4333218072D5d7008546737786663c38B4D561A4',
+} as const
+
+export const getForkSimulationUrl = (id: string) =>
+ `https://dashboard.tenderly.co/shared/fork/simulation/${id}`
+
+export const createNetworkFork = async (args: { chainId: CHAINS }) => {
+ const fork = await requestNetworkFork(args)
+ const { id: forkId } = fork.simulation_fork
+ const rpcUrl = `https://rpc.tenderly.co/fork/${forkId}`
+ const forkProvider = new ethers.providers.JsonRpcProvider(rpcUrl)
+ return {
+ forkId,
+ forkProvider,
+ }
+}
+
+export const sumulateVoteEnactmentOnFork = async ({
+ chainId,
+ voteId,
+ vote,
+ voteTime,
+ objectionPhaseTime,
+}: {
+ chainId: CHAINS
+ voteId: string
+ vote: Vote
+ voteTime: number
+ objectionPhaseTime: number
+}) => {
+ const { forkId, forkProvider } = await createNetworkFork({ chainId })
+
+ const treasuryAddress = TREASURY_ADDRESS[chainId]!
+ const treasurySigner = forkProvider.getSigner(treasuryAddress)
+ const forkedVotingContract = ContractVoting.connect({
+ chainId,
+ library: treasurySigner,
+ })
+
+ const { timestamp } = await forkProvider.getBlock('latest')
+ const startDate = vote.startDate.toNumber()
+ const objectionEndDate = startDate + (voteTime - objectionPhaseTime)
+ const endDate = startDate + voteTime
+
+ if (timestamp < objectionEndDate) {
+ const gasLimit = await estimateGasFallback(
+ forkedVotingContract.estimateGas.vote(voteId, true, false),
+ 2000000,
+ )
+
+ const voteTx = await forkedVotingContract.populateTransaction.vote(
+ voteId,
+ true,
+ false,
+ {
+ gasLimit,
+ },
+ )
+
+ await treasurySigner.sendTransaction({
+ ...voteTx,
+ from: treasuryAddress,
+ })
+ }
+
+ if (timestamp < endDate) {
+ await forkProvider.send('evm_increaseTime', [
+ ethers.utils.hexValue(endDate - timestamp),
+ ])
+ await forkProvider.send('evm_increaseBlocks', [ethers.utils.hexValue(1)])
+ }
+
+ const gasLimit = await estimateGasFallback(
+ forkedVotingContract.estimateGas.executeVote(voteId),
+ 2000000,
+ )
+
+ const tx = await forkedVotingContract.executeVote(voteId, { gasLimit })
+
+ const { fork_transactions } = await fetchForkTransactionsList(forkId)
+
+ const { id: txTenderlyId } = fork_transactions.find(
+ forkTx => forkTx.hash === tx.hash,
+ )!
+
+ await requestSimulationShare(forkId, txTenderlyId)
+
+ return txTenderlyId
+}
diff --git a/modules/votes/ui/VoteSimulation/type.ts b/modules/votes/ui/VoteSimulation/type.ts
new file mode 100644
index 00000000..92859c32
--- /dev/null
+++ b/modules/votes/ui/VoteSimulation/type.ts
@@ -0,0 +1,137 @@
+export type TenderlyBlockHeader = {
+ baseFeePerGas: string
+ difficulty: string
+ extraData: string
+ gasLimit: string
+ gasUsed: string
+ hash: string
+ logsBloom: string
+ miner: string
+ mixHash: string
+ nonce: string
+ number: string
+ parentHash: string
+ receiptsRoot: string
+ sha3Uncles: string
+ size: string
+ stateRoot: string
+ timestamp: string
+ totalDifficulty: string
+ transactions: unknown
+ transactionsRoot: string
+ uncles: null | any
+}
+
+export type TenderlyReceipt = {
+ blockHash: string
+ blockNumber: string
+ contractAddress: null | any
+ cumulativeGasUsed: string
+ effectiveGasPrice: string
+ from: string
+ gasUsed: string
+ logs: any[]
+ logsBloom: string
+ status: string
+ to: string
+ transactionHash: string
+ transactionIndex: string
+ type: string
+}
+
+export type TenderlyStateObject = {
+ address: string
+ data: { balance: string }
+}
+
+export type TenderlyFork = {
+ accounts: Record
+ block_number: number
+ chain_config: {
+ berlin_block: number
+ byzantium_block: number
+ chain_id: number
+ clique: { period: number; epoch: number }
+ constantinople_block: number
+ dao_fork_support: boolean
+ eip_150_block: number
+ eip_150_hash: string
+ eip_155_block: number
+ eip_158_block: number
+ homestead_block: number
+ istanbul_block: number
+ london_block: number
+ petersburg_block: number
+ polygon: {
+ heimdall_url: string
+ sprint_length_changelog: null | any
+ state_sync: {
+ do_not_skip: boolean
+ event_records_amount_override: null | any
+ }
+ }
+ shanghai_time: number
+ type: string
+ }
+ created_at: string
+ current_block_number: number
+ fork_config: null | any
+ id: string
+ network_id: string
+ project_id: string
+ shared: false
+ transaction_index: number
+}
+
+export type TenderlyTransaction = {
+ access_list: null | any
+ alias: string
+ block_hash: string
+ block_header: TenderlyBlockHeader
+ block_number: number
+ branch_root: boolean
+ created_at: string
+ deposit_tx: boolean
+ description: string
+ fork_height: number
+ fork_id: string
+ from: string
+ gas: number
+ gas_price: string
+ hash: string
+ id: string
+ immutable: boolean
+ input: string
+ internal: boolean
+ kind: string
+ l1_block_number: number
+ l1_message_sender: string
+ l1_timestamp: number
+ network_id: string
+ nonce: number
+ origin: string
+ parent_id: string
+ project_id: string
+ receipt: TenderlyReceipt
+ shared: boolean
+ state_objects: TenderlyStateObject[]
+ status: boolean
+ system_tx: boolean
+ timestamp: string
+ to: string
+ transaction_index: number
+ value: string
+}
+
+export type TenderlyResponseForksList = {
+ simulation_forks: TenderlyFork[]
+}
+
+export type TenderlyResponseForkTransactionsList = {
+ fork_transactions: TenderlyTransaction[]
+}
+
+export type TenderlyForkResponse = {
+ root_transaction: TenderlyTransaction
+ simulation_fork: TenderlyFork
+}
diff --git a/modules/votes/utils/getVoteDetailsFormatted.ts b/modules/votes/utils/getVoteDetailsFormatted.ts
index 39d17218..ab3edcae 100644
--- a/modules/votes/utils/getVoteDetailsFormatted.ts
+++ b/modules/votes/utils/getVoteDetailsFormatted.ts
@@ -31,6 +31,11 @@ export function getVoteDetailsFormatted({ vote, voteTime }: Args) {
const startDate = vote.startDate.toNumber()
const endDate = startDate + voteTime
+ const neededToQuorum = weiToNum(vote.minAcceptQuorum) - yeaPctOfTotalSupply
+ const neededToQuorumFormatted = formatFloatPct(neededToQuorum, {
+ floor: true,
+ }).toFixed(2)
+
return {
totalSupply,
totalSupplyFormatted,
@@ -42,6 +47,8 @@ export function getVoteDetailsFormatted({ vote, voteTime }: Args) {
yeaPctOfTotalSupply,
nayPctOfTotalSupplyFormatted,
yeaPctOfTotalSupplyFormatted,
+ neededToQuorum,
+ neededToQuorumFormatted,
startDate,
endDate,
}
diff --git a/next-logger.config.cjs b/next-logger.config.cjs
index fac40cf4..b81e083c 100644
--- a/next-logger.config.cjs
+++ b/next-logger.config.cjs
@@ -14,6 +14,9 @@ const patterns = [
process.env.INFURA_API_KEY,
process.env.ALCHEMY_API_KEY,
process.env.ETHERSCAN_API_KEY,
+ process.env.TENDERLY_USER,
+ process.env.TENDERLY_PROJECT,
+ process.env.TENDERLY_API_KEY,
]
const mask = satanizer(patterns)
diff --git a/next.config.mjs b/next.config.mjs
index aa558408..6b3536ed 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -13,6 +13,10 @@ const cspReportUri = process.env.CSP_REPORT_URI
const ipfsMode = process.env.IPFS_MODE
const walletconnectProjectId = process.env.WALLETCONNECT_PROJECT_ID
+const tenderlyUser = process.env.TENDERLY_USER
+const tenderlyProject = process.env.TENDERLY_PROJECT
+const tendrerlyAccessKey = process.env.TENDERLY_ACCESS_KEY
+
export default {
basePath,
webpack5: true,
@@ -117,6 +121,9 @@ export default {
cspTrustedHosts,
cspReportOnly,
cspReportUri,
+ tenderlyUser,
+ tenderlyProject,
+ tendrerlyAccessKey,
},
publicRuntimeConfig: {
defaultChain,
diff --git a/pages/api/tenderly/[[...route]].ts b/pages/api/tenderly/[[...route]].ts
new file mode 100644
index 00000000..a591d57c
--- /dev/null
+++ b/pages/api/tenderly/[[...route]].ts
@@ -0,0 +1,75 @@
+import isEmpty from 'lodash/isEmpty'
+import clone from 'just-clone'
+import getConfig from 'next/config'
+import { tenderlyResponseTime } from 'modules/shared/metrics/responseTime'
+import type { NextApiRequest, NextApiResponse } from 'next'
+
+const { serverRuntimeConfig } = getConfig()
+const { tenderlyUser, tenderlyProject, tendrerlyAccessKey } =
+ serverRuntimeConfig
+
+const getTenderlyApiUrl = (user: string, project: string) =>
+ `https://api.tenderly.co/api/v1/account/${user}/project/${project}`
+
+export default async function tenderly(
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ const requestInfo = {
+ type: 'API request',
+ path: 'tenderly',
+ body: clone(req.body),
+ query: clone(req.query),
+ method: req.method,
+ stage: 'INCOMING',
+ }
+
+ console.info('Incoming request to tenderly', requestInfo)
+
+ try {
+ const contentType = req.headers['content-type']
+ const jsonMode = contentType === 'application/json'
+
+ const route =
+ typeof req.query.route === 'object'
+ ? (req.query.route as string[]).join('/')
+ : req.query.route
+
+ const url =
+ getTenderlyApiUrl(tenderlyUser, tenderlyProject) +
+ (route ? `/${route}` : '')
+
+ const end = tenderlyResponseTime.startTimer()
+ const requested = await fetch(url, {
+ method: req.method,
+ ...(!isEmpty(req.body) ? { body: JSON.stringify(req.body) } : {}),
+ headers: {
+ 'X-Access-Key': tendrerlyAccessKey,
+ ...(jsonMode ? { 'Content-Type': 'application/json' } : {}),
+ },
+ })
+ end()
+
+ res.status(requested.status)
+
+ if (jsonMode) {
+ const responded = await requested.json()
+ res.json(responded)
+ } else {
+ res.end()
+ }
+
+ console.info('Request to tenderly successfully fullfilled', {
+ ...requestInfo,
+ stage: 'FULFILLED',
+ })
+ } catch (error) {
+ console.error(
+ error instanceof Error ? error.message : 'Something went wrong',
+ error,
+ )
+ res.status(500).send({ error: 'Something went wrong!' })
+ }
+
+ res.end()
+}