Skip to content

Commit 95218df

Browse files
Merge pull request #439 from yieldprotocol/integration/tx-replay-custom-inputs-simpler
Integration: transaction replay
2 parents fe2cb0c + ecb3205 commit 95218df

File tree

7 files changed

+430
-17
lines changed

7 files changed

+430
-17
lines changed

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@
5757
"react-use-websocket": "^4.3.1",
5858
"siwe": "^2.1.4",
5959
"swr": "^2.0.4",
60-
"typescript": "4.9.4",
60+
"typescript": "^5.2.2",
61+
"viem": "^1.9.3",
6162
"wagmi": "^0.12.0",
6263
"zksync-web3": "^0.14.3"
6364
},

src/components/cactiComponents/ActionResponse.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useMemo, useState } from 'react';
22
import Skeleton from 'react-loading-skeleton';
3+
import { TransactionReceipt } from '@ethersproject/abstract-provider';
34
import { AddressZero } from '@ethersproject/constants';
45
import { CheckCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
56
import { ConnectButton } from '@rainbow-me/rainbowkit';
@@ -13,7 +14,6 @@ import { ActionStepper } from './ActionStepper';
1314
import useApproval, { ApprovalBasicParams } from './hooks/useApproval';
1415
import useBalance from './hooks/useBalance';
1516
import useSubmitTx, { TxBasicParams } from './hooks/useSubmitTx';
16-
import { TransactionReceipt } from '@ethersproject/abstract-provider';
1717

1818
export enum ActionResponseState {
1919
LOADING = 'LOADING', // background async checks
@@ -58,7 +58,7 @@ export type ActionResponseProps = {
5858
disabled?: boolean;
5959
skipBalanceCheck?: boolean;
6060
stepper?: boolean;
61-
onSuccess?: (receipt?: TransactionReceipt ) => void;
61+
onSuccess?: (receipt?: TransactionReceipt) => void;
6262
onError?: (receipt?: TransactionReceipt) => void;
6363
};
6464

@@ -78,7 +78,6 @@ export const ActionResponse = ({
7878
onSuccess,
7979
onError,
8080
}: ActionResponseProps) => {
81-
8281
const { address } = useAccount();
8382
const _approvalParams = useMemo<ApprovalBasicParams>(
8483
() =>
@@ -153,7 +152,6 @@ export const ActionResponse = ({
153152
* Update all the local states on tx/approval status changes.
154153
**/
155154
useEffect(() => {
156-
157155
if (disabled) {
158156
setButtonLabel(label ?? 'Disabled');
159157
return setState(ActionResponseState.DISABLED);
@@ -252,6 +250,7 @@ export const ActionResponse = ({
252250
isWaitingOnUser,
253251
submitTx,
254252
token?.symbol,
253+
disabled,
255254
]);
256255

257256
/* Set the styling based on the state (Note: always diasbled if 'disabled' from props) */

src/components/current/MessageTranslator.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { Fragment, useEffect, useMemo, useState } from 'react';
22
import { Message } from '@/contexts/ChatContext';
33
import { SharedStateContextProvider } from '@/contexts/SharedStateContext';
44
import { parseMessage } from '@/utils/parse-message';
5-
import Avatar from '../shared/Avatar';
6-
import { Widgetize } from '../legacy/legacyComponents/MessageTranslator';
75
import { ErrorResponse, TextResponse } from '../cactiComponents';
86
import { ImageVariant } from '../cactiComponents/ImageResponse';
97
import { TableResponse } from '../cactiComponents/TableResponse';
8+
import { Widgetize } from '../legacy/legacyComponents/MessageTranslator';
9+
import Avatar from '../shared/Avatar';
1010
import { FeedbackButton } from './FeedbackButton';
1111
import { MessageWrap } from './MessageWrap';
1212
import ListContainer from './containers/ListContainer';
@@ -29,6 +29,7 @@ import { NftCollection } from './widgets/nft/NftCollection';
2929
import RethDeposit from './widgets/rocketPool/rocketPoolDeposit';
3030
import RethWithdraw from './widgets/rocketPool/rocketPoolWithdraw';
3131
import Transfer from './widgets/transfer/Transfer';
32+
import TransactionReplay from './widgets/tx-replay/TransactionReplay';
3233
import Uniswap from './widgets/uniswap/Uniswap';
3334
import WrapEth from './widgets/weth/WrapEth';
3435
import YieldProtocolBorrowClose from './widgets/yield-protocol/actions/borrow-close/YieldProtocolBorrowClose';
@@ -310,6 +311,7 @@ export const Widget = (props: WidgetProps) => {
310311
<WithdrawVault withdrawToken={parsedArgs[0]} amount={parsedArgs[1]} vault={parsedArgs[2]} />
311312
);
312313
widgets.set('wrap-eth', <WrapEth amtString={'1'} />);
314+
widgets.set('tx-replay', <TransactionReplay txHash={parsedArgs[0]} />);
313315

314316
/* If available, return the widget in the widgets map */
315317
if (widgets.has(fnName)) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import React, { useCallback, useEffect, useState } from 'react';
2+
import { BigNumber, UnsignedTransaction, ethers } from 'ethers';
3+
import { decodeFunctionData } from 'viem';
4+
import { Address, useTransaction } from 'wagmi';
5+
import { ActionResponse, HeaderResponse, SingleLineResponse } from '@/components/cactiComponents';
6+
import SkeletonWrap from '@/components/shared/SkeletonWrap';
7+
import useAbi from '@/hooks/useAbi';
8+
import TransactionReplayInput from './TransactionReplayInput';
9+
10+
interface TransactionReplayProps {
11+
txHash: Address;
12+
}
13+
14+
const TransactionReplay = ({ txHash }: TransactionReplayProps) => {
15+
const { data, isLoading } = useTransaction({ hash: txHash });
16+
const { data: abi } = useAbi(data?.to as Address | undefined);
17+
const [sendParams, setSendParams] = useState<UnsignedTransaction>();
18+
const [isError, setIsError] = useState(false);
19+
20+
const explorerUrl = `https://etherscan.io/tx/${txHash}`;
21+
22+
// State to hold editable fields, stored as strings for simplicity
23+
const [decoded, setDecoded] = useState<{
24+
to?: string;
25+
value: string;
26+
functionName?: string;
27+
args?: {
28+
name: string; // name of the argument
29+
value: string; // value of the argument
30+
type: string; // type of the argument
31+
}[];
32+
}>();
33+
34+
// handle decoding the transaction data
35+
const handleDecode = useCallback(() => {
36+
if (!data) return console.log('no data');
37+
if (!abi) {
38+
console.log('no abi, is possibly an eth/native currency transfer');
39+
return setDecoded({
40+
to: data.to,
41+
value: data.value.toString(),
42+
functionName: 'transfer ETH',
43+
});
44+
}
45+
46+
// Decode the function data
47+
let args: string[] = [];
48+
let functionName: string;
49+
50+
try {
51+
const decoded = decodeFunctionData({ abi, data: data.data as Address });
52+
args = decoded.args as string[];
53+
functionName = decoded.functionName;
54+
} catch (e) {
55+
setIsError(true);
56+
return console.error('error decoding function data', e);
57+
}
58+
59+
// Get the types of the arguments for the function
60+
const getArgsTypes = ({
61+
abi,
62+
functionName,
63+
argsLength,
64+
}: {
65+
abi: any[];
66+
functionName: string;
67+
argsLength: number;
68+
}) => {
69+
return abi.find((item) => item.name === functionName && item.inputs.length === argsLength)
70+
?.inputs as {
71+
name: string;
72+
type: string;
73+
}[];
74+
};
75+
76+
const _args = args as string[];
77+
const types = getArgsTypes({ abi, functionName, argsLength: _args.length });
78+
79+
setDecoded({
80+
to: data.to,
81+
value: data.value.toString(),
82+
functionName,
83+
args: _args.map((_, i) => ({
84+
name: types[i].name,
85+
value: _args[i],
86+
type: types[i].type,
87+
})),
88+
});
89+
}, [abi, data]);
90+
91+
useEffect(() => {
92+
handleDecode();
93+
}, [handleDecode]);
94+
95+
const handleReset = useCallback(
96+
(e: React.MouseEvent<HTMLButtonElement>) => {
97+
e.preventDefault();
98+
handleDecode();
99+
},
100+
[handleDecode]
101+
);
102+
103+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
104+
const { name, value } = e.target;
105+
106+
// handle changing the value param
107+
if (name === 'value') {
108+
setDecoded((d) => d && { ...d, value });
109+
return;
110+
}
111+
// handle changing the to param
112+
if (name === 'to') {
113+
setDecoded((d) => d && { ...d, to: value });
114+
return;
115+
}
116+
117+
// handle changing the args
118+
setDecoded((d) => {
119+
if (!d?.args) return;
120+
121+
const newArgs = [...d.args];
122+
const argIndex = newArgs.findIndex((arg) => arg.name === name);
123+
if (argIndex !== -1) newArgs[argIndex] = { ...newArgs[argIndex], value: value };
124+
125+
return { ...d, args: newArgs };
126+
});
127+
};
128+
129+
const getSendParams = useCallback((): UnsignedTransaction | undefined => {
130+
if (!decoded) {
131+
console.error('Decoded data is missing');
132+
return;
133+
}
134+
135+
// Initialize a transaction object
136+
let transaction: Partial<UnsignedTransaction> = {
137+
to: decoded.to,
138+
value: BigNumber.from(decoded.value),
139+
};
140+
141+
// If it's a simple transfer
142+
if (decoded.functionName === 'transfer ETH') {
143+
transaction.data = '0x';
144+
} else {
145+
if (!decoded.functionName || !decoded.args) {
146+
console.error('Missing function name or args');
147+
return;
148+
}
149+
// For contract interactions
150+
// First, convert decoded arguments to their proper types
151+
const convertedArgs =
152+
decoded.args?.map((arg) => {
153+
// Conversion logic here, based on the ABI or arg.type
154+
// For simplicity, let's assume everything's a string
155+
return arg.value;
156+
}) || [];
157+
158+
// Create the function signature
159+
const functionTypes = decoded.args.map((arg) => arg.type).join(',');
160+
const functionSignature = `${decoded.functionName}(${functionTypes})`;
161+
162+
// Create the encoded data field for contract interaction
163+
const iface = new ethers.utils.Interface(abi);
164+
try {
165+
// Encode the function data
166+
const data = iface.encodeFunctionData(functionSignature, convertedArgs);
167+
transaction.data = data;
168+
// Now, you can populate the transaction object and pass it to ActionResponse
169+
} catch (e) {
170+
console.error('Error encoding function data', e);
171+
}
172+
}
173+
174+
setSendParams(transaction);
175+
}, [abi, decoded]);
176+
177+
useEffect(() => {
178+
getSendParams();
179+
}, [getSendParams]);
180+
181+
return (
182+
<>
183+
{isError && <SingleLineResponse>error handling this transaction</SingleLineResponse>}
184+
{!data && !isLoading && (
185+
<SingleLineResponse>no data found for this transaction</SingleLineResponse>
186+
)}
187+
{!decoded ? (
188+
isError ? null : (
189+
<SkeletonWrap />
190+
)
191+
) : (
192+
<>
193+
<HeaderResponse text={`${decoded.functionName}`} altUrl={explorerUrl} />
194+
<SingleLineResponse className="flex items-center justify-center p-2">
195+
<form className="w-full p-2">
196+
{decoded?.args?.map((arg, i) => (
197+
<div className="mb-4" key={i}>
198+
<label htmlFor={arg.name} className="block font-medium text-gray-400">
199+
{arg.name}
200+
</label>
201+
<TransactionReplayInput
202+
name={arg.name}
203+
value={arg.value}
204+
onChange={handleInputChange}
205+
/>
206+
</div>
207+
))}
208+
{BigNumber.from(decoded.value)?.gt(ethers.constants.Zero) && decoded.to && (
209+
<>
210+
<div className="mb-4">
211+
<label htmlFor={'to'} className="block font-medium text-gray-400">
212+
{'to'}
213+
</label>
214+
<TransactionReplayInput
215+
name={'to'}
216+
value={decoded.to}
217+
onChange={handleInputChange}
218+
/>
219+
</div>
220+
<div className="mb-4">
221+
<label htmlFor={'value'} className="block font-medium text-gray-400">
222+
{'value'}
223+
</label>
224+
<TransactionReplayInput
225+
name={'value'}
226+
value={decoded.value}
227+
onChange={handleInputChange}
228+
/>
229+
</div>
230+
</>
231+
)}
232+
<button className="rounded-md bg-green-primary p-1.5" onClick={handleReset}>
233+
Reset
234+
</button>
235+
</form>
236+
</SingleLineResponse>
237+
<ActionResponse txParams={undefined} sendParams={sendParams} approvalParams={undefined} />
238+
</>
239+
)}
240+
</>
241+
);
242+
};
243+
244+
export default TransactionReplay;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
interface TransactionReplayInputProps {
2+
name: string;
3+
value: string;
4+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
5+
}
6+
7+
const TransactionReplayInput = ({ name, value, onChange }: TransactionReplayInputProps) => (
8+
<input
9+
className="mt-2 w-full rounded-md border border-gray-700 bg-gray-primary p-2 hover:border-gray-500 focus:outline-none focus:ring-1 focus:ring-gray-500"
10+
type="text"
11+
name={name}
12+
value={value}
13+
placeholder={value}
14+
onChange={onChange}
15+
/>
16+
);
17+
18+
export default TransactionReplayInput;

src/hooks/useAbi.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useQuery } from 'react-query';
2+
import axios from 'axios';
3+
import { Address } from 'wagmi';
4+
5+
const useAbi = (contractAddress: Address | undefined) => {
6+
const fetchAbi = async () => {
7+
const { data } = await axios.get(
8+
`https://api.etherscan.io/api?module=contract&action=getabi&address=${contractAddress}&apikey=${process.env.NEXT_PUBLIC_ETHERSCAN_API_KEY}`
9+
);
10+
return JSON.parse(data.result);
11+
};
12+
13+
const { data, ...rest } = useQuery(['abi', contractAddress], fetchAbi, {
14+
refetchOnWindowFocus: false,
15+
});
16+
17+
return { data, ...rest };
18+
};
19+
20+
export default useAbi;

0 commit comments

Comments
 (0)