Skip to content

feat: basename renewals #2227

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions apps/web/src/components/Basenames/ManageNames/NameDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useCallback } from 'react';
import UsernameProfileProvider from 'apps/web/src/components/Basenames/UsernameProfileContext';
import UsernameProfileRenewalModal from 'apps/web/src/components/Basenames/UsernameProfileRenewalModal';
import ProfileTransferOwnershipProvider from 'apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal/context';
import UsernameProfileTransferOwnershipModal from 'apps/web/src/components/Basenames/UsernameProfileTransferOwnershipModal';
import BasenameAvatar from 'apps/web/src/components/Basenames/BasenameAvatar';
Expand Down Expand Up @@ -43,15 +44,21 @@ type NameDisplayProps = {

export default function NameDisplay({ domain, isPrimary, tokenId, expiresAt }: NameDisplayProps) {
const expirationText = formatDistanceToNow(parseISO(expiresAt), { addSuffix: true });
const name = domain.split('.')[0];

const { setPrimaryUsername } = useUpdatePrimaryName(domain as Basename);

const [isOpen, setIsOpen] = useState<boolean>(false);
const openModal = useCallback(() => setIsOpen(true), []);
const closeModal = useCallback(() => setIsOpen(false), []);

// Transfer state and callbacks
const [isTransferModalOpen, setIsTransferModalOpen] = useState<boolean>(false);
const openTransferModal = useCallback(() => setIsTransferModalOpen(true), []);
const closeTransferModal = useCallback(() => setIsTransferModalOpen(false), []);
const { removeNameFromUI } = useRemoveNameFromUI(domain as Basename);

// Renewal state and callbacks
const [isRenewalModalOpen, setIsRenewalModalOpen] = useState<boolean>(false);
const openRenewalModal = useCallback(() => setIsRenewalModalOpen(true), []);
const closeRenewalModal = useCallback(() => setIsRenewalModalOpen(false), []);

return (
<li key={tokenId} className={pillNameClasses}>
<div className="flex items-center justify-between">
Expand All @@ -78,7 +85,7 @@ export default function NameDisplay({ domain, isPrimary, tokenId, expiresAt }: N
<Icon name="verticalDots" color="currentColor" width="2rem" height="2rem" />
</DropdownToggle>
<DropdownMenu>
<DropdownItem onClick={openModal}>
<DropdownItem onClick={openTransferModal}>
<span className="flex flex-row items-center gap-2">
<Icon name="transfer" color="currentColor" width="1rem" height="1rem" /> Transfer
name
Expand All @@ -93,18 +100,29 @@ export default function NameDisplay({ domain, isPrimary, tokenId, expiresAt }: N
</span>
</DropdownItem>
) : null}
<DropdownItem onClick={openRenewalModal}>
<span className="flex flex-row items-center gap-2">
<Icon name="clock" color="currentColor" width="1rem" height="1rem" /> Renew Name
</span>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
<UsernameProfileProvider username={domain as Basename}>
<ProfileTransferOwnershipProvider>
<UsernameProfileTransferOwnershipModal
isOpen={isOpen}
onClose={closeModal}
isOpen={isTransferModalOpen}
onClose={closeTransferModal}
onSuccess={removeNameFromUI}
/>
</ProfileTransferOwnershipProvider>
<UsernameProfileRenewalModal
name={name}
isOpen={isRenewalModalOpen}
onClose={closeRenewalModal}
// onSuccess={removeNameFromUI}
/>
</UsernameProfileProvider>
</li>
);
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/Basenames/RegistrationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,16 @@ export const RegistrationContext = createContext<RegistrationContextProps>({
discount: undefined,
allActiveDiscounts: new Set(),
registerName: function () {
return Promise.resolve();
},
reverseRecord: false,
setReverseRecord: function () {
return undefined;
},
hasExistingBasename: false,
registerNameIsPending: false,
registerNameError: null,
code: undefined,
Comment on lines +102 to +111
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add all empty values to default context

});

type RegistrationProviderProps = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use client';

import { useErrors } from 'apps/web/contexts/Errors';
import { Button, ButtonVariants } from 'apps/web/src/components/Button/Button';
import Fieldset from 'apps/web/src/components/Fieldset';
import Input from 'apps/web/src/components/Input';
import Modal from 'apps/web/src/components/Modal';
import { useRenewBasename } from 'apps/web/src/hooks/useRenewBasename';
import { useCallback, useState } from 'react';
import { formatEther } from 'viem';
import { useAccount } from 'wagmi';

enum RenewalSteps {
SetYears = 'set-years',
Confirm = 'confirm',
WalletRequests = 'wallet-requests',
Success = 'success',
}

const rewnewalStepsTitleForDisplay = {
[RenewalSteps.SetYears]: 'Set years',
[RenewalSteps.Confirm]: 'Confirm renewal details',
[RenewalSteps.WalletRequests]: 'Confirm transactions',
[RenewalSteps.Success]: '',
};

type UsernameProfileRenewalModalProps = {
name: string;
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
};

export default function UsernameProfileRenewalModal({
name,
isOpen,
onClose,
onSuccess,
}: UsernameProfileRenewalModalProps) {
const [years, setYears] = useState<number | null>(null);
const [currentRenewalStep, setCurrentRenewalStep] = useState<RenewalSteps>(RenewalSteps.SetYears);

const { address } = useAccount();
const { logError } = useErrors();

const {
callback: renewBasename,
price,
isPending,
error,
renewNameStatus,
batchCallsStatus,
} = useRenewBasename({
name,
years: years ?? 0,
});

const onConfirmRenewal = useCallback(() => {
setCurrentRenewalStep(RenewalSteps.Confirm);
}, []);

if (!address) {
return null;
}

return (
<Modal
isOpen={isOpen}
title={rewnewalStepsTitleForDisplay[currentRenewalStep]}
titleAlign="left"
onClose={onClose}
>
{currentRenewalStep === RenewalSteps.SetYears && (
<div className="mt-2 flex w-full flex-col gap-4">
<p>Renew Basename for</p>
<Fieldset className="w-full">
<Input
value={years ?? ''}
onChange={(e) => setYears(Number(e.target.value))}
type="number"
className="w-full flex-1 rounded-xl border border-gray-40/20 p-4 text-black"
placeholder="input years"
/>
</Fieldset>
<Button
disabled={!years}
variant={ButtonVariants.Black}
fullWidth
rounded
onClick={onConfirmRenewal}
>
Continue
</Button>
</div>
)}

{currentRenewalStep === RenewalSteps.Confirm && (
<div className="mt-2 flex w-full flex-col gap-4">
<p>
Renew {name} for {years} {years === 1 ? 'year' : 'years'}
</p>
<p>Renewal Price: {price ? formatEther(price) : '...'} ETH</p>
<Button variant={ButtonVariants.Black} fullWidth rounded onClick={renewBasename}>
Continue
</Button>
</div>
)}
</Modal>
);
}
21 changes: 16 additions & 5 deletions apps/web/src/hooks/useRegisterNameCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,30 @@ import { USERNAME_L2_RESOLVER_ADDRESSES } from 'apps/web/src/addresses/usernames
import useBaseEnsName from 'apps/web/src/hooks/useBaseEnsName';
import useBasenameChain from 'apps/web/src/hooks/useBasenameChain';
import useCapabilitiesSafe from 'apps/web/src/hooks/useCapabilitiesSafe';
import useWriteContractsWithLogs from 'apps/web/src/hooks/useWriteContractsWithLogs';
import useWriteContractWithReceipt from 'apps/web/src/hooks/useWriteContractWithReceipt';
import useWriteContractsWithLogs, { BatchCallsStatus } from 'apps/web/src/hooks/useWriteContractsWithLogs';
import useWriteContractWithReceipt, { WriteTransactionWithReceiptStatus } from 'apps/web/src/hooks/useWriteContractWithReceipt';
import {
formatBaseEthDomain,
IS_EARLY_ACCESS,
normalizeEnsDomainName,
REGISTER_CONTRACT_ABI,
REGISTER_CONTRACT_ADDRESSES,
} from 'apps/web/src/utils/usernames';
import { useCallback, useMemo, useState } from 'react';
import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react';
import { encodeFunctionData, namehash } from 'viem';
import { useAccount } from 'wagmi';

type UseRegisterNameCallbackReturnType = {
callback: () => Promise<void>;
isPending: boolean;
error: Error | null;
reverseRecord: boolean;
setReverseRecord: Dispatch<SetStateAction<boolean>>
hasExistingBasename: boolean;
batchCallsStatus: BatchCallsStatus;
registerNameStatus: WriteTransactionWithReceiptStatus;
}

function secondsInYears(years: number): bigint {
const secondsPerYear = 365.25 * 24 * 60 * 60; // .25 accounting for leap years
return BigInt(Math.round(years * secondsPerYear));
Comment on lines 31 to 33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to swap this call out to the shared util?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, there are a bunch to dedupe, i'll handle that once we get the renewals working

Expand All @@ -28,7 +39,7 @@ export function useRegisterNameCallback(
years: number,
discountKey?: `0x${string}`,
validationData?: `0x${string}`,
) {
): UseRegisterNameCallbackReturnType {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix type issue

const { address } = useAccount();
const { basenameChain } = useBasenameChain();
const { logError } = useErrors();
Expand Down Expand Up @@ -61,7 +72,7 @@ export function useRegisterNameCallback(
transactionStatus: registerNameStatus,
transactionIsLoading: registerNameIsLoading,
transactionError: registerNameError,
} = useWriteContractWithReceipt({
} = useWriteContractWithReceipt<typeof REGISTER_CONTRACT_ABI, 'register'>({
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix type issue

chain: basenameChain,
eventName: 'register_name',
});
Expand Down
113 changes: 113 additions & 0 deletions apps/web/src/hooks/useRenewBasename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useErrors } from 'apps/web/contexts/Errors';
import useBasenameChain from 'apps/web/src/hooks/useBasenameChain';
import useCapabilitiesSafe from 'apps/web/src/hooks/useCapabilitiesSafe';
import { useRentPrice } from 'apps/web/src/hooks/useRentPrice';
import useWriteContractsWithLogs from 'apps/web/src/hooks/useWriteContractsWithLogs';
import useWriteContractWithReceipt from 'apps/web/src/hooks/useWriteContractWithReceipt';
import { secondsInYears } from 'apps/web/src/utils/secondsInYears';
import {
normalizeEnsDomainName,
REGISTER_CONTRACT_ABI,
REGISTER_CONTRACT_ADDRESSES,
} from 'apps/web/src/utils/usernames';
import { useCallback } from 'react';
import { useAccount } from 'wagmi';

type UseRenewBasenameProps = {
name: string;
years: number;
};

export function useRenewBasename({ name, years }: UseRenewBasenameProps) {
const { logError } = useErrors();
const { address } = useAccount();
const { basenameChain } = useBasenameChain();
const { paymasterService: paymasterServiceEnabled } = useCapabilitiesSafe({
chainId: basenameChain.id,
});

// Transaction with paymaster enabled
const { initiateBatchCalls, batchCallsStatus, batchCallsIsLoading, batchCallsError } =
useWriteContractsWithLogs({
chain: basenameChain,
eventName: 'renew_name',
});

// Transaction without paymaster
const {
initiateTransaction: initiateRenewName,
transactionStatus: renewNameStatus,
transactionIsLoading: renewNameIsLoading,
transactionError: renewNameError,
} = useWriteContractWithReceipt<typeof REGISTER_CONTRACT_ABI, 'renew'>({
chain: basenameChain,
eventName: 'renew_name',
});

// Params
const normalizedName = normalizeEnsDomainName(name);
const { basePrice: price } = useRentPrice(normalizedName, years);

// Callback
const renewName = useCallback(async () => {
if (!address) {
return;
}

const renewRequest = [normalizedName, secondsInYears(years)];

try {
if (!paymasterServiceEnabled) {
console.log('Renewing name without paymaster', {
renewRequest,
price,
contractAddress: REGISTER_CONTRACT_ADDRESSES[basenameChain.id],
});
await initiateRenewName({
abi: REGISTER_CONTRACT_ABI,
address: REGISTER_CONTRACT_ADDRESSES[basenameChain.id],
functionName: 'renew',
args: renewRequest,
value: price,
});
} else {
console.log('Renewing name with paymaster');
await initiateBatchCalls({
contracts: [
{
abi: REGISTER_CONTRACT_ABI,
address: REGISTER_CONTRACT_ADDRESSES[basenameChain.id],
functionName: 'renew',
args: renewRequest,
value: price,
},
],
account: address,
chain: basenameChain,
});
}
} catch (e) {
logError(e, 'Renew name transaction canceled');
}
}, [
address,
basenameChain,
initiateBatchCalls,
initiateRenewName,
logError,
name,
normalizedName,
paymasterServiceEnabled,
price,
years,
]);

return {
callback: renewName,
price,
isPending: renewNameIsLoading || batchCallsIsLoading,
error: renewNameError ?? batchCallsError,
renewNameStatus,
batchCallsStatus,
};
}
Loading
Loading