Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into web-vitals
Browse files Browse the repository at this point in the history
  • Loading branch information
rkalis committed Jan 16, 2025
2 parents d0e788e + 4e6aee7 commit 17e9b34
Show file tree
Hide file tree
Showing 36 changed files with 1,347 additions and 460 deletions.
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright 2019-2024 Rosco Kalis
Copyright 2019-2025 Rosco Kalis

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ If you want to learn more about (unlimited) token approvals, I wrote an article
## Running locally

```
git clone [email protected]:rkalis/revoke.cash.git
git clone [email protected]:RevokeCash/revoke.cash.git
cd revoke.cash
yarn
yarn dev
Expand Down Expand Up @@ -51,11 +51,11 @@ Adding a new network is relatively straightforward as you only need to change th

#### Prerequisites

To add a new network, one of the following needs to be available:
To add a new network, **one** of the following needs to be available:

- A (public or private) RPC endpoint that supports `eth_getLogs` requests for the entire history of the network.
- Support in [CovalentHQ](https://www.covalenthq.com/) for the network.
- A block explorer with an exposed API that is compatible with Etherscan's API (such as Blockscout).
- Or: Support in [CovalentHQ](https://www.covalenthq.com/) for the network.
- Or: A block explorer with an exposed API that is compatible with Etherscan's API (such as Blockscout).

Also make sure that your network is listed in [ethereum-lists/chains](https://github.com/ethereum-lists/chains) (and that it has subsequently been included in [@revoke.cash/chains](https://github.com/RevokeCash/chains)). Besides the earlier requirements, we also require a publicly available RPC endpoint with rate limits that are not too restrictive. It is also helpful if your network is listed (with TVL and volume stats) on DeFiLlama, but this is not required.

Expand Down
206 changes: 206 additions & 0 deletions app/[locale]/private/approve/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
'use client';

import ContentPageLayout from 'app/layouts/ContentPageLayout';
import Button from 'components/common/Button';
import Input from 'components/common/Input';
import { displayTransactionSubmittedToast } from 'components/common/TransactionSubmittedToast';
import Select from 'components/common/select/Select';
import { ERC20_ABI, ERC721_ABI } from 'lib/abis';
import { writeContractUnlessExcessiveGas } from 'lib/utils';
import { AllowanceType } from 'lib/utils/allowances';
import { parseErrorMessage } from 'lib/utils/errors';
import { permit2Approve } from 'lib/utils/permit2';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { isAddress } from 'viem';
import { useAccount, usePublicClient } from 'wagmi';
import { useWalletClient } from 'wagmi';

const ApprovePage = () => {
const { data: walletClient } = useWalletClient();
const { address: account } = useAccount();
const publicClient = usePublicClient()!;
const [allowanceType, setAllowanceType] = useState<AllowanceType>(AllowanceType.ERC20);
const [tokenAddress, setTokenAddress] = useState<string>('');
const [spenderAddress, setSpenderAddress] = useState<string>('');
const [permit2Address, setPermit2Address] = useState<string>('');
const [amount, setAmount] = useState<string>('');
const [tokenId, setTokenId] = useState<string>('');
const [expiration, setExpiration] = useState<string>('');
const options = Object.values(AllowanceType).map((type) => ({ value: type, label: type }));

const handleApprove = async () => {
try {
if (!tokenAddress || !spenderAddress) {
throw new Error('Token address and spender address are required');
}

if (allowanceType === AllowanceType.ERC721_SINGLE && !tokenId) {
throw new Error('Token ID is required');
}

if (allowanceType === AllowanceType.PERMIT2 && (!permit2Address || !expiration || !isAddress(permit2Address))) {
throw new Error('Permit2 address and expiration are required');
}

if (!isAddress(tokenAddress) || !isAddress(spenderAddress)) {
throw new Error('Invalid address');
}

switch (allowanceType) {
case AllowanceType.ERC20: {
if (!amount) {
throw new Error('Amount is required');
}

const tx = await writeContractUnlessExcessiveGas(publicClient, walletClient!, {
address: tokenAddress,
account: account!,
chain: walletClient!.chain!,
abi: ERC20_ABI,
functionName: 'approve',
args: [spenderAddress, BigInt(amount)],
});

displayTransactionSubmittedToast(walletClient!.chain!.id, tx);
break;
}
case AllowanceType.PERMIT2: {
if (!amount || !expiration || !permit2Address || !isAddress(permit2Address)) {
throw new Error('Amount, expiration, and permit2 address are required');
}

const tokenContract = {
address: tokenAddress,
publicClient,
abi: ERC20_ABI,
};

const tx = await permit2Approve(
permit2Address,
walletClient!,
tokenContract,
spenderAddress,
BigInt(amount),
Number(expiration),
);

displayTransactionSubmittedToast(walletClient!.chain!.id, tx);
break;
}
case AllowanceType.ERC721_SINGLE: {
if (!tokenId) {
throw new Error('Token ID is required');
}

const tx = await writeContractUnlessExcessiveGas(publicClient, walletClient!, {
address: tokenAddress,
account: account!,
chain: walletClient!.chain!,
abi: ERC721_ABI,
functionName: 'approve',
args: [spenderAddress, BigInt(tokenId)],
});

displayTransactionSubmittedToast(walletClient!.chain!.id, tx);
break;
}
case AllowanceType.ERC721_ALL: {
const tx = await writeContractUnlessExcessiveGas(publicClient, walletClient!, {
address: tokenAddress,
account: account!,
chain: walletClient!.chain!,
abi: ERC721_ABI,
functionName: 'setApprovalForAll',
args: [spenderAddress, true],
});

displayTransactionSubmittedToast(walletClient!.chain!.id, tx);
break;
}
}
} catch (e) {
toast.error(e instanceof Error ? parseErrorMessage(e) : 'An error occurred');
}
};

return (
<ContentPageLayout>
<div className="flex flex-col gap-4 max-w-3xl mx-auto">
<h1>Approve Arbitrary Contracts</h1>
<p>For testing purposes only.</p>
<div className="flex flex-col gap-4 border border-gray-200 rounded-md p-4">
<div className="flex flex-col gap-1">
<span>Approval Type</span>
<Select
instanceId="approval-type-select"
aria-label="Select Approval Type"
options={options}
value={options.find((option) => option.value === allowanceType)}
onChange={(value) => setAllowanceType(value?.value as AllowanceType)}
isMulti={false}
isSearchable={false}
/>
</div>
<div className="flex flex-col gap-1">
<span>Token Address</span>
<Input
size="md"
placeholder="Token Address"
value={tokenAddress}
onChange={(e) => setTokenAddress(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<span>Spender Address</span>
<Input
size="md"
placeholder="Spender Address"
value={spenderAddress}
onChange={(e) => setSpenderAddress(e.target.value)}
/>
</div>
{allowanceType === AllowanceType.PERMIT2 && (
<div className="flex flex-col gap-1">
<span>Permit2 Address</span>
<Input
size="md"
placeholder="Permit2 Address"
value={permit2Address}
onChange={(e) => setPermit2Address(e.target.value)}
/>
</div>
)}
{(allowanceType === AllowanceType.ERC20 || allowanceType === AllowanceType.PERMIT2) && (
<div className="flex flex-col gap-1">
<span>Amount</span>
<Input size="md" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
)}
{allowanceType === AllowanceType.ERC721_SINGLE && (
<div className="flex flex-col gap-1">
<span>Token ID</span>
<Input size="md" placeholder="Token ID" value={tokenId} onChange={(e) => setTokenId(e.target.value)} />
</div>
)}
{allowanceType === AllowanceType.PERMIT2 && (
<div className="flex flex-col gap-1">
<span>Expiration</span>
<Input
size="md"
placeholder="Expiration"
value={expiration}
onChange={(e) => setExpiration(e.target.value)}
/>
</div>
)}
</div>
<Button size="md" style="secondary" onClick={handleApprove}>
Approve
</Button>
</div>
</ContentPageLayout>
);
};

export default ApprovePage;
1 change: 1 addition & 0 deletions app/robots.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
User-agent: *
Allow: /
Disallow: /private/

Sitemap: https://revoke.cash/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import Button from 'components/common/Button';
import TipSection from 'components/common/donate/TipSection';
import { useDonate } from 'lib/hooks/ethereum/useDonate';
import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext';
import { TokenAllowanceData } from 'lib/utils/allowances';
import { analytics } from 'lib/utils/analytics';
import type { TokenAllowanceData } from 'lib/utils/allowances';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import ControlsWrapper from '../ControlsWrapper';
Expand All @@ -13,38 +12,16 @@ interface Props {
isRevoking: boolean;
isAllConfirmed: boolean;
setOpen: (open: boolean) => void;
revoke: () => Promise<void>;
revoke: (tipAmount: string) => Promise<void>;
}

const BatchRevokeControls = ({ selectedAllowances, isRevoking, isAllConfirmed, setOpen, revoke }: Props) => {
const t = useTranslations();
const { address, selectedChainId } = useAddressPageContext();
const { defaultAmount, nativeToken } = useDonate(selectedChainId, 'batch-revoke-tip');

const { donate, nativeToken, defaultAmount } = useDonate(selectedChainId, 'batch-revoke-tip');
const [tipAmount, setTipAmount] = useState<string | null>(null);

const revokeAndTip = async (tipAmount: string | null) => {
if (!tipAmount) throw new Error('Tip amount is required');

const getTipSelection = () => {
if (tipAmount === '0') return 'none';
if (Number(tipAmount) < Number(defaultAmount)) return 'low';
if (Number(tipAmount) > Number(defaultAmount)) return 'high';
return 'mid';
};

analytics.track('Batch Revoked', {
chainId: selectedChainId,
address,
allowances: selectedAllowances.length,
amount: tipAmount,
tipSelection: getTipSelection(),
});

await revoke();
await donate(tipAmount);
};

const getButtonText = () => {
if (isRevoking) return t('common.buttons.revoking');
if (isAllConfirmed) return t('common.buttons.close');
Expand All @@ -53,7 +30,10 @@ const BatchRevokeControls = ({ selectedAllowances, isRevoking, isAllConfirmed, s

const getButtonAction = () => {
if (isAllConfirmed) return () => setOpen(false);
return () => revokeAndTip(tipAmount);
return async () => {
if (!tipAmount) throw new Error('Tip amount is required');
await revoke(tipAmount);
};
};

return (
Expand Down
4 changes: 2 additions & 2 deletions components/header/WalletIndicatorDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ interface Props {
const WalletIndicatorDropdown = ({ size, style, className }: Props) => {
const t = useTranslations();

const { address: account } = useAccount();
const { address: account, chainId } = useAccount();
const { domainName } = useNameLookup(account);
const { disconnect } = useDisconnect();

return (
<div className="flex whitespace-nowrap">
{account ? (
<DropdownMenu menuButton={domainName ?? shortenAddress(account, 4)}>
<DropdownMenuItem href={`/address/${account}`} router retainSearchParams={['chainId']}>
<DropdownMenuItem href={`/address/${account}?chainId=${chainId}`} router retainSearchParams={['chainId']}>
{t('common.buttons.my_allowances')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => disconnect()}>{t('common.buttons.disconnect')}</DropdownMenuItem>
Expand Down
4 changes: 2 additions & 2 deletions components/signatures/cells/CancelPermitCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ const CancelPermitCell = ({ token, onCancel }: Props) => {
const { address, selectedChainId } = useAddressPageContext();
const handleTransaction = useHandleTransaction(selectedChainId);

const sendCancelTransaction = async (): Promise<TransactionSubmitted | undefined> => {
if (isErc721Contract(token.contract)) return;
const sendCancelTransaction = async (): Promise<TransactionSubmitted> => {
if (isErc721Contract(token.contract)) throw new Error('Cannot cancel ERC721 tokens');
const hash = await permit(walletClient!, token.contract, DUMMY_ADDRESS, 0n);

analytics.track('Cancelled Permit Signatures', {
Expand Down
1 change: 1 addition & 0 deletions content/en/exploits/short/moby.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Close to $2.5m was stolen from DeFi platform Moby Trade and its users. The attacker was able to gain access to the protocol's admin keys, which they used to execute malicious upgrades on the protocol's contracts - allowing them to drain the wallets of any users that had active token approvals for the protocol. Close to $1.5m was recovered by the protocol's team and SEAL911, resulting in a loss of $1m for the users.
4 changes: 3 additions & 1 deletion cypress/e2e/chains.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ const TEST_ADDRESSES = {
[ChainId.Shibarium]: '0x8fA1F2969082a8d141DA3f0DD06D308C783fe7bB',
[ChainId.Shiden]: '0xD377cFFCc52C16bF6e9840E77F78F42Ddb946568',
[ChainId.ShimmerEVM]: '0xAc4682eF9fE8c62980cd8bd8d8a3Bb100FD652e7',
[ChainId.Soneium]: '0x351F34efCE7BBF960da2ca61130a89bF41471047',
[ChainId.SonicMainnet]: '0xA93093fc1D0343298966E1F971fAE10a7a629296',
[ChainId['SongbirdCanary-Network']]: '0x4E8De52271D3bE18cC972af892198103C1e6AfE8',
[ChainId.StoryOdysseyTestnet]: '0x2343bcb7f864D6e2880b3510492dc3da33E75f14',
[ChainId.SyscoinMainnet]: '0xc594AE94f7C98d759Ed4c792F5DbFB7285184044',
Expand All @@ -122,7 +124,7 @@ const TEST_ADDRESSES = {
[ChainId.ZkSyncMainnet]: '0x82FdF36736f3f8eE6f04Ab96eA32213c8d826FaA',
[ChainId.Zora]: '0x061EFb2DF7767D6e63529BA99394037d4dCa39D6',
// Testnets
[ChainId.AbstractTestnet]: '0xe126b3E5d052f1F575828f61fEBA4f4f2603652a',
[ChainId.AbstractSepoliaTestnet]: '0xe126b3E5d052f1F575828f61fEBA4f4f2603652a',
[ChainId.Amoy]: '0x57BD9b2E821d2bF1f8136026ba3A29848eff9e47',
[ChainId.ArbitrumSepolia]: '0xDd3287043493E0a08d2B348397554096728B459c',
[ChainId.AvalancheFujiTestnet]: '0x4D915A2f0a2c94b159b69D36bc26338E0ef8E3F6',
Expand Down
6 changes: 3 additions & 3 deletions lib/api/logs/EtherscanEventGetter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ const formatEtherscanEvent = (etherscanLog: any) => ({
topics: etherscanLog.topics.filter((topic: string) => !isNullish(topic)),
data: etherscanLog.data,
transactionHash: etherscanLog.transactionHash,
blockNumber: Number.parseInt(etherscanLog.blockNumber, 16),
transactionIndex: Number.parseInt(etherscanLog.transactionIndex, 16),
logIndex: Number.parseInt(etherscanLog.logIndex, 16),
blockNumber: Number.parseInt(etherscanLog.blockNumber, 16) || 0,
transactionIndex: Number.parseInt(etherscanLog.transactionIndex, 16) || 0,
logIndex: Number.parseInt(etherscanLog.logIndex, 16) || 0,
timestamp: Number.parseInt(etherscanLog.timeStamp, 16),
});

Expand Down
5 changes: 3 additions & 2 deletions lib/chains/Chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ export class Chain {
return this.options.infoUrl ?? getChain(mainnetChainId)?.infoURL ?? getChain(this.chainId)?.infoURL;
}

getNativeToken(): string | undefined {
return this.options.nativeToken ?? getChain(this.chainId)?.nativeCurrency?.symbol;
// Note: we run tests to make sure that this is configured correctly for all chains (which is why we override the type)
getNativeToken(): string {
return (this.options.nativeToken ?? getChain(this.chainId)?.nativeCurrency?.symbol) as string;
}

getEtherscanCompatibleApiUrl(): string | undefined {
Expand Down
2 changes: 2 additions & 0 deletions lib/hooks/ethereum/EthereumProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { abstractWalletConnector } from '@abstract-foundation/agw-react/connectors';
import { useCsrRouter } from 'lib/i18n/csr-navigation';
import { usePathname } from 'lib/i18n/navigation';
import { ORDERED_CHAINS, createViemPublicClientForChain, getViemChainConfig } from 'lib/utils/chains';
Expand All @@ -26,6 +27,7 @@ export const connectors = [
},
}),
coinbaseWallet({ appName: 'Revoke.cash' }),
abstractWalletConnector(),
];

export const wagmiConfig = createConfig({
Expand Down
Loading

0 comments on commit 17e9b34

Please sign in to comment.