Skip to content

Commit c27b0ab

Browse files
authored
feat: add batch revoke fees (#289)
- Remove TipSection from batch revoking - Add automatic fee payment to batch revokes - Add FeeNotice that explains the fee - Update cached native token prices more often - Add blog post about this change - Add banner about this change
1 parent 89741d1 commit c27b0ab

40 files changed

+373
-279
lines changed

app/[locale]/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { Metadata } from 'next';
1414
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server';
1515
import 'react-lite-youtube-embed/dist/LiteYouTubeEmbed.css';
1616
import '../../styles/index.css';
17+
import AnnouncementsContainer from 'components/common/AnnouncementsContainer';
1718

1819
interface Props {
1920
children: React.ReactNode;
@@ -67,6 +68,7 @@ const MainLayout = async ({ children, params }: Props) => {
6768
<EthereumProvider>
6869
<ColorThemeProvider>
6970
<div className="flex flex-col mx-auto min-h-screen">
71+
<AnnouncementsContainer />
7072
<Header />
7173
<main className="w-full grow">{children}</main>
7274
<div className="flex flex-col justify-end">

app/[locale]/learn/faq/page.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import LearnLayout from 'app/layouts/LearnLayout';
2+
import { BASE_FEE, PER_ALLOWANCE_FEE } from 'components/allowances/controls/batch-revoke/fee';
23
import ChainLogo from 'components/common/ChainLogo';
34
import Href from 'components/common/Href';
45
import RichText from 'components/common/RichText';
@@ -65,12 +66,20 @@ const FaqPage: NextPage<Props> = async ({ params }) => {
6566
<FaqItem question={t('faq.questions.enough_to_disconnect.question')} slug="enough_to_disconnect">
6667
<RichText>{(tags) => t.rich('faq.questions.enough_to_disconnect.answer', tags)}</RichText>
6768
</FaqItem>
69+
<FaqItem question={t('faq.questions.costs.question')} slug="costs">
70+
<RichText>
71+
{(tags) =>
72+
t.rich('faq.questions.costs.answer', {
73+
...tags,
74+
BASE_FEE: BASE_FEE.toFixed(2),
75+
PER_ALLOWANCE_FEE: PER_ALLOWANCE_FEE.toFixed(2),
76+
})
77+
}
78+
</RichText>
79+
</FaqItem>
6880
<FaqItem question={t('faq.questions.hardware_wallets.question')} slug="hardware_wallets">
6981
<RichText>{(tags) => t.rich('faq.questions.hardware_wallets.answer', tags)}</RichText>
7082
</FaqItem>
71-
<FaqItem question={t('faq.questions.costs.question')} slug="costs">
72-
<RichText>{(tags) => t.rich('faq.questions.costs.answer', tags)}</RichText>
73-
</FaqItem>
7483
<FaqItem question={t('faq.questions.wallet_mentions_approve.question')} slug="wallet_mentions_approve">
7584
<RichText>{(tags) => t.rich('faq.questions.wallet_mentions_approve.answer', tags)}</RichText>
7685
</FaqItem>

app/api/[chainId]/native-price/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const PRICE_QUEUE = new RequestQueue('token-price-native', {
3232
interval: 1 * MINUTE,
3333
});
3434

35-
const CACHE_TTL = 1 * 60 * 60; // 1 hour
35+
const CACHE_TTL = 1 * 60 * 20; // 20 minutes
3636

3737
export async function GET(req: NextRequest, { params }: Props) {
3838
const { chainId: chainIdString } = await params;

components/allowances/controls/batch-revoke/BatchRevokeControls.tsx

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,20 @@
11
import Button from 'components/common/Button';
2-
import TipSection from 'components/common/donate/TipSection';
3-
import { useDonate } from 'lib/hooks/ethereum/useDonate';
4-
import { useNativeTokenPrice } from 'lib/hooks/ethereum/useNativeTokenPrice';
52
import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext';
6-
import { isNullish } from 'lib/utils';
73
import { useTranslations } from 'next-intl';
8-
import { useState } from 'react';
94
import ControlsWrapper from '../ControlsWrapper';
5+
import FeeNotice from './FeeNotice';
106

117
interface Props {
8+
feeDollarAmount: string;
129
isRevoking: boolean;
1310
isAllConfirmed: boolean;
1411
setOpen: (open: boolean) => void;
15-
revoke: (tipDollarAmount: string) => Promise<void>;
12+
revoke: () => Promise<void>;
1613
}
1714

18-
const BatchRevokeControls = ({ isRevoking, isAllConfirmed, setOpen, revoke }: Props) => {
15+
const BatchRevokeControls = ({ feeDollarAmount, isRevoking, isAllConfirmed, setOpen, revoke }: Props) => {
1916
const t = useTranslations();
2017
const { address, selectedChainId } = useAddressPageContext();
21-
const { nativeToken } = useDonate(selectedChainId, 'batch-revoke-tip');
22-
const { nativeTokenPrice } = useNativeTokenPrice(selectedChainId);
23-
24-
const [tipAmount, setTipAmount] = useState<string | null>(null);
2518

2619
const getButtonText = () => {
2720
if (isRevoking) return t('common.buttons.revoking');
@@ -31,21 +24,13 @@ const BatchRevokeControls = ({ isRevoking, isAllConfirmed, setOpen, revoke }: Pr
3124

3225
const getButtonAction = () => {
3326
if (isAllConfirmed) return () => setOpen(false);
34-
return async () => {
35-
if (!tipAmount && !isNullish(nativeTokenPrice)) throw new Error('Tip amount is required');
36-
await revoke(tipAmount ?? '0');
37-
};
27+
return revoke;
3828
};
3929

4030
return (
41-
<div className="flex flex-col items-center justify-center gap-4">
42-
<TipSection chainId={selectedChainId} nativeToken={nativeToken} onSelect={setTipAmount} />
43-
<ControlsWrapper
44-
chainId={selectedChainId}
45-
address={address}
46-
overrideDisabled={!tipAmount && !isNullish(nativeTokenPrice)}
47-
disabledReason={t('address.tooltips.select_tip')}
48-
>
31+
<div className="flex flex-col items-center justify-center gap-8">
32+
<FeeNotice chainId={selectedChainId} feeDollarAmount={feeDollarAmount} />
33+
<ControlsWrapper chainId={selectedChainId} address={address}>
4934
{(disabled) => (
5035
<div>
5136
<Button

components/allowances/controls/batch-revoke/BatchRevokeModalWithButton.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const BatchRevokeModalWithButton = ({ table }: Props) => {
2525
return table.getGroupedSelectedRowModel().flatRows.map((row) => row.original);
2626
}, [open]);
2727

28-
const { results, revoke, pause, isRevoking, isAllConfirmed } = useRevokeBatch(
28+
const { results, revoke, pause, isRevoking, isAllConfirmed, feeDollarAmount } = useRevokeBatch(
2929
selectedAllowances,
3030
table.options.meta!.onUpdate,
3131
);
@@ -66,6 +66,7 @@ const BatchRevokeModalWithButton = ({ table }: Props) => {
6666
</div>
6767
</div>
6868
<BatchRevokeControls
69+
feeDollarAmount={feeDollarAmount}
6970
isRevoking={isRevoking}
7071
isAllConfirmed={isAllConfirmed}
7172
setOpen={setOpen}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { InformationCircleIcon } from '@heroicons/react/24/outline';
2+
import Href from 'components/common/Href';
3+
import RichText from 'components/common/RichText';
4+
import WithHoverTooltip from 'components/common/WithHoverTooltip';
5+
import { useNativeTokenPrice } from 'lib/hooks/ethereum/useNativeTokenPrice';
6+
import { getChainName } from 'lib/utils/chains';
7+
import { useTranslations } from 'next-intl';
8+
import type { ReactNode } from 'react';
9+
import { BASE_FEE, FEE_SPONSORS, PER_ALLOWANCE_FEE } from './fee';
10+
11+
interface Props {
12+
chainId: number;
13+
feeDollarAmount: string;
14+
}
15+
16+
const FeeNotice = ({ chainId, feeDollarAmount }: Props) => {
17+
const t = useTranslations();
18+
const { nativeTokenPrice } = useNativeTokenPrice(chainId);
19+
20+
if (!nativeTokenPrice) return null;
21+
22+
const sponsor = FEE_SPONSORS[chainId];
23+
24+
if (sponsor) {
25+
return <SponsoredFeeNotice chainId={chainId} />;
26+
}
27+
28+
const tooltipContent = (
29+
<RichText>
30+
{(tags) =>
31+
t.rich('address.batch_revoke.fee.tooltip', {
32+
...tags,
33+
BASE_FEE: BASE_FEE.toFixed(2),
34+
PER_ALLOWANCE_FEE: PER_ALLOWANCE_FEE.toFixed(2),
35+
})
36+
}
37+
</RichText>
38+
);
39+
40+
return (
41+
<div className="flex items-center justify-center gap-2 text-center text-sm text-zinc-600 dark:text-zinc-300 bg-brand/30 dark:bg-brand/20 py-4 px-6">
42+
<span>
43+
<RichText>
44+
{(tags) =>
45+
t.rich('address.batch_revoke.fee.notice', {
46+
...tags,
47+
feeDollarAmount,
48+
})
49+
}
50+
</RichText>
51+
</span>
52+
<WithHoverTooltip tooltip={tooltipContent}>
53+
<InformationCircleIcon className="w-6 h-6 shrink-0" />
54+
</WithHoverTooltip>
55+
</div>
56+
);
57+
};
58+
59+
export default FeeNotice;
60+
61+
interface SponsoredFeeNoticeProps {
62+
chainId: number;
63+
}
64+
65+
const SponsoredFeeNotice = ({ chainId }: SponsoredFeeNoticeProps) => {
66+
const t = useTranslations();
67+
68+
const chainName = getChainName(chainId);
69+
const sponsor = FEE_SPONSORS[chainId];
70+
71+
if (!sponsor) return null;
72+
73+
const sponsorTags = {
74+
sponsor: sponsor.name,
75+
chainName,
76+
'sponsor-link': (children: ReactNode) =>
77+
sponsor.url ? (
78+
<Href href={sponsor.url} external className="font-medium">
79+
{children}
80+
</Href>
81+
) : (
82+
<span className="font-medium">{children}</span>
83+
),
84+
};
85+
86+
return (
87+
<div className="flex items-center justify-center gap-2 text-center text-sm text-zinc-600 dark:text-zinc-300 bg-brand/30 dark:bg-brand/20 py-4 px-6">
88+
<span>
89+
<RichText>
90+
{(tags) => t.rich('address.batch_revoke.fee.sponsored_notice', { ...tags, ...sponsorTags })}
91+
</RichText>
92+
</span>
93+
</div>
94+
);
95+
};

components/common/donate/TipSection.tsx renamed to components/allowances/controls/batch-revoke/TipSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { formatDonationTokenAmount } from 'lib/utils/formatting';
77
import { useTranslations } from 'next-intl';
88
import { useMemo, useState } from 'react';
99
import { twMerge } from 'tailwind-merge';
10-
import Select from '../select/Select';
11-
import WithHoverTooltip from '../WithHoverTooltip';
10+
import Select from '../../../common/select/Select';
11+
import WithHoverTooltip from '../../../common/WithHoverTooltip';
1212

1313
interface Props {
1414
chainId: number;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ChainId } from '@revoke.cash/chains';
2+
3+
export const BASE_FEE = 1.5;
4+
export const PER_ALLOWANCE_FEE = 0.0;
5+
6+
interface FeeSponsor {
7+
name: string;
8+
url?: string;
9+
}
10+
11+
export const FEE_SPONSORS: Record<number, FeeSponsor> = {
12+
[ChainId.OPMainnet]: {
13+
name: 'Optimism Foundation',
14+
url: 'https://www.optimism.io/',
15+
},
16+
[ChainId.Palm]: {
17+
name: 'Revoke.cash',
18+
},
19+
};
20+
21+
export const getFeeDollarAmount = (chainId: number, allowancesCount: number) => {
22+
if (FEE_SPONSORS[chainId]) return 0;
23+
return BASE_FEE + allowancesCount * PER_ALLOWANCE_FEE;
24+
};
25+
26+
export const isNonZeroFeeDollarAmount = (feeDollarAmount: string) => {
27+
return Number(feeDollarAmount) > 0;
28+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import { XMarkIcon } from '@heroicons/react/24/outline';
4+
import { useMounted } from 'lib/hooks/useMounted';
5+
import { useTranslations } from 'next-intl';
6+
import useLocalStorage from 'use-local-storage';
7+
8+
interface Props {
9+
storageKey: string;
10+
children: React.ReactNode;
11+
}
12+
13+
const AnnouncementBanner = ({ storageKey, children }: Props) => {
14+
const t = useTranslations();
15+
const isMounted = useMounted();
16+
const [dismissed, setDismissed] = useLocalStorage<boolean>(storageKey, false);
17+
18+
if (!isMounted) return null;
19+
if (dismissed) return null;
20+
21+
return (
22+
<div className="w-full bg-brand text-zinc-900 dark:bg-brand dark:text-zinc-900">
23+
<div className="px-4 lg:px-8 py-2 flex items-center justify-between gap-3 text-sm">
24+
<div className="w-5" />
25+
<div className="flex items-center gap-2">{children}</div>
26+
<button
27+
type="button"
28+
aria-label={t('common.buttons.close')}
29+
onClick={() => setDismissed(true)}
30+
className="text-zinc-800 hover:text-zinc-600"
31+
>
32+
<XMarkIcon className="w-5 h-5" />
33+
</button>
34+
</div>
35+
</div>
36+
);
37+
};
38+
39+
export default AnnouncementBanner;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use client';
2+
3+
import AnnouncementBanner from './AnnouncementBanner';
4+
import Button from './Button';
5+
6+
const AnnouncementsContainer = () => {
7+
return (
8+
<AnnouncementBanner storageKey="banner-dismissed:2025-10-10">
9+
<span>Why we're adding batch revoke fees: </span>
10+
<Button
11+
style="secondary"
12+
size="sm"
13+
href="/blog/2025/why-were-adding-batch-revoke-fees"
14+
router
15+
className="bg-transparent hover:bg-black/20 dark:border-black dark:text-zinc-900"
16+
>
17+
Learn more
18+
</Button>
19+
</AnnouncementBanner>
20+
);
21+
};
22+
23+
export default AnnouncementsContainer;

0 commit comments

Comments
 (0)