From a2537c2d5d607ff25a9b2840a42192e507eb9037 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 1 Apr 2026 11:24:45 +0400 Subject: [PATCH 1/3] feat: be able to claim sponsored name --- src/api/backend.ts | 57 ++ src/components/modals/ClaimChainNameModal.tsx | 432 ++++++++++++++ .../__tests__/ClaimChainNameModal.test.tsx | 87 +++ .../TransactionNotificationBanner.tsx | 43 ++ .../transaction-notification.context.tsx | 11 + src/hooks/__tests__/useClaimChainName.test.ts | 367 ++++++++++++ src/hooks/useClaimChainName.ts | 534 ++++++++++++++++++ src/hooks/useProfile.ts | 6 +- src/locales/en.json | 31 +- src/views/UserProfile.tsx | 78 +-- 10 files changed, 1605 insertions(+), 41 deletions(-) create mode 100644 src/components/modals/ClaimChainNameModal.tsx create mode 100644 src/components/modals/__tests__/ClaimChainNameModal.test.tsx create mode 100644 src/hooks/__tests__/useClaimChainName.test.ts create mode 100644 src/hooks/useClaimChainName.ts diff --git a/src/api/backend.ts b/src/api/backend.ts index 5dedb3378..62ad86d20 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -68,6 +68,40 @@ export type XAttestationResponse = { signature_base64: string; }; +export type ChainNameChallengeResponse = { + nonce: string; + expires_at: string | number; + message: string; +}; + +export type ChainNameClaimRequest = { + address: string; + name: string; + challenge_nonce: string; + challenge_expires_at: string; + signature_hex: string; +}; + +export type ChainNameClaimResponse = { + status: string; + message?: string | null; +}; + +export type ChainNameClaimStatusResponse = { + status: string; + name?: string | null; + error?: string | null; + preclaim_tx_hash?: string | null; + claim_tx_hash?: string | null; + update_tx_hash?: string | null; + transfer_tx_hash?: string | null; + expires_at?: string | number | null; + approximate_expire_time?: string | number | null; + approximateExpireTime?: string | number | null; + expire_time?: string | number | null; + expireTime?: string | number | null; +}; + // Superhero API client export const SuperheroApi = { async fetchJson(path: string, init?: RequestInit) { @@ -376,6 +410,29 @@ export const SuperheroApi = { }), }) as Promise; }, + createChainNameChallenge(address: string) { + return this.fetchJson('/api/profile/chain-name/challenge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + address, + }), + }) as Promise; + }, + claimChainName(payload: ChainNameClaimRequest) { + return this.fetchJson('/api/profile/chain-name/claim', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) as Promise; + }, + getChainNameClaimStatus(address: string) { + return this.fetchJson(`/api/profile/${encodeURIComponent(address)}/chain-name-claim`) as Promise; + }, /** Exchange OAuth code (from X redirect) for attestation; backend exchanges code for token and creates attestation. */ createXAttestationFromCode( address: string, diff --git a/src/components/modals/ClaimChainNameModal.tsx b/src/components/modals/ClaimChainNameModal.tsx new file mode 100644 index 000000000..2abb541c2 --- /dev/null +++ b/src/components/modals/ClaimChainNameModal.tsx @@ -0,0 +1,432 @@ +import { + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useSetAtom } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { useQueryClient } from '@tanstack/react-query'; +import { type ChainNameClaimStatusResponse } from '@/api/backend'; +import { chainNamesAtom } from '@/atoms/walletAtoms'; +import { useAeSdk } from '@/hooks/useAeSdk'; +import { useClaimChainName } from '@/hooks/useClaimChainName'; +import { + TxPayloadType, + useTransactionNotification, +} from '@/features/transaction-notification'; +import { useToast } from '../ToastProvider'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; + +const normalizeClaimChainName = (value: string) => value.trim().toLowerCase().replace(/\.chain$/u, ''); +const stripApiErrorPrefix = (value: string) => value.replace(/^superhero api error \(\d+\):\s*/iu, '').trim(); +const CLAIMABLE_CHAIN_NAME_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const AVAILABILITY_CHECK_DELAY_MS = 500; +const getSafeSdkAddress = (sdk: unknown) => { + try { + const value = (sdk as any)?.address; + return typeof value === 'string' ? value : ''; + } catch { + return ''; + } +}; + +type NameAvailabilityStatus = 'idle' | 'checking' | 'available' | 'unavailable'; + +export const resolveClaimErrorMessage = ( + claimError: unknown, + t: (key: string) => string, +) => { + const rawMessage = claimError instanceof Error ? claimError.message : String(claimError || ''); + const msg = stripApiErrorPrefix(rawMessage); + const lower = msg.toLowerCase(); + + if ( + lower.includes('429') + || lower.includes('rate limit') + || lower.includes('too many') + ) return t('messages.tooManyRequests'); + + if (lower.includes('timed out')) return t('messages.chainNameClaimTimedOut'); + + if ( + lower.includes('connect your wallet') + || lower.includes('connect the wallet for this profile') + || lower.includes('you are not connected to wallet') + || lower.includes('you are not subscribed for an account') + || lower.includes('do not have access to account') + ) return t('messages.connectWalletToClaimChainName'); + + if (lower.includes('wallet message signing is not available')) { + return t('messages.chainNameClaimWalletUnavailable'); + } + + if ( + lower.includes('already taken on-chain') + || lower.includes('name is already taken') + ) return t('messages.chainNameClaimNameTaken'); + + if (lower.includes('already being claimed by another address')) { + return t('messages.chainNameClaimNameInProgress'); + } + + if (lower.includes('already has an in-progress chain name claim')) { + return t('messages.chainNameClaimAddressInProgress'); + } + + if (lower.includes('already has a claimed chain name')) { + return t('messages.chainNameClaimAddressClaimed'); + } + + if ( + lower.includes('challenge has expired') + || lower.includes('challenge expiry mismatch') + ) return t('messages.chainNameClaimChallengeExpired'); + + if ( + lower.includes('shorter than 13') + || lower.includes('too short') + || lower.includes('more than 12 characters') + ) return t('messages.chainNameClaimTooShort'); + + if ( + lower.includes('invalid challenge signature') + || lower.includes('challenge proof is required') + ) return t('messages.chainNameClaimVerificationFailed'); + + if ( + lower.includes('claiming is not available at this time') + || lower.includes('temporarily unavailable due to insufficient sponsor funds') + || lower.includes('temporarily unavailable') + || lower.includes('unable to verify chain name availability right now') + ) return t('messages.chainNameClaimUnavailable'); + + if ( + lower.includes('invalid address') + || lower.includes('bad request') + ) return t('messages.chainNameClaimRetry'); + + return t('messages.chainNameClaimFailed'); +}; + +const ClaimChainNameModal = ({ + open, + onClose, + address, +}: { + open: boolean; + onClose: () => void; + address?: string; +}) => { + const { t } = useTranslation('common'); + const queryClient = useQueryClient(); + const { push } = useToast(); + const { activeAccount, aeSdk } = useAeSdk(); + const { + claimSponsoredChainName, + canClaim, + checkNameAvailability, + } = useClaimChainName(address); + const { + notifySubmitted, + notifyPending, + notifyConfirmed, + notifyError, + } = useTransactionNotification(); + const setChainNames = useSetAtom(chainNamesAtom); + const inputRef = useRef(null); + const submittedRef = useRef(false); + const availabilityRequestIdRef = useRef(0); + + const [claiming, setClaiming] = useState(false); + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + const [availabilityStatus, setAvailabilityStatus] = useState('idle'); + const [lastCheckedValue, setLastCheckedValue] = useState(''); + + useEffect(() => { + if (!open) { + setClaiming(false); + setValue(''); + setError(null); + setAvailabilityStatus('idle'); + setLastCheckedValue(''); + return; + } + submittedRef.current = false; + window.setTimeout(() => { + inputRef.current?.focus(); + }, 120); + }, [open]); + + const normalizedValue = useMemo(() => normalizeClaimChainName(value), [value]); + const normalizedValueLength = normalizedValue.length; + const validateClaimChainName = (name: string): string | null => { + if (!name) return t('messages.chainNameClaimRequired'); + if (!CLAIMABLE_CHAIN_NAME_LABEL_REGEX.test(name)) return t('messages.chainNameClaimInvalidChars'); + if (name.length <= 12) return t('messages.chainNameClaimTooShort'); + return null; + }; + const validationError = validateClaimChainName(normalizedValue); + const isTooShort = Boolean(normalizedValue && normalizedValueLength <= 12); + + const getClaimNotificationPayload = ( + name: string, + claimStatus?: ChainNameClaimStatusResponse | null, + ) => { + const statusValue = String(claimStatus?.status || '').toLowerCase(); + let step: 'wallet' | 'queued' | 'preclaim' | 'claim' | 'update' | 'transfer' = 'queued'; + if (statusValue.includes('transfer')) step = 'transfer'; + else if (statusValue.includes('update')) step = 'update'; + else if (statusValue.includes('claim')) step = 'claim'; + else if (statusValue.includes('preclaim')) step = 'preclaim'; + else if (claimStatus?.transfer_tx_hash) step = 'transfer'; + else if (claimStatus?.update_tx_hash) step = 'update'; + else if (claimStatus?.claim_tx_hash) step = 'claim'; + else if (claimStatus?.preclaim_tx_hash) step = 'preclaim'; + return { + type: TxPayloadType.ClaimChainName, + name, + step, + }; + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && !claiming) onClose(); + }; + + useEffect(() => { + if (!open) return undefined; + + availabilityRequestIdRef.current += 1; + const requestId = availabilityRequestIdRef.current; + + if (!normalizedValue || validationError) { + setAvailabilityStatus('idle'); + setLastCheckedValue(''); + return undefined; + } + + setAvailabilityStatus('checking'); + + const timeoutId = window.setTimeout(() => { + checkNameAvailability(normalizedValue) + .then((isAvailable) => { + if (availabilityRequestIdRef.current !== requestId) return; + setLastCheckedValue(normalizedValue); + setAvailabilityStatus(isAvailable ? 'available' : 'unavailable'); + if (isAvailable) { + setError((currentError) => ( + currentError === t('messages.chainNameClaimNameTaken') ? null : currentError + )); + return; + } + setError(t('messages.chainNameClaimNameTaken')); + }) + .catch(() => { + if (availabilityRequestIdRef.current !== requestId) return; + setLastCheckedValue(normalizedValue); + setAvailabilityStatus('idle'); + setError((currentError) => ( + currentError === t('messages.chainNameClaimNameTaken') ? null : currentError + )); + }); + }, AVAILABILITY_CHECK_DELAY_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [ + checkNameAvailability, + normalizedValue, + open, + t, + validationError, + ]); + + const onClaim = async () => { + try { + const connectedAddress = activeAccount + || getSafeSdkAddress(aeSdk); + const targetAddress = (address as string) || connectedAddress; + if (!targetAddress || !connectedAddress) { + const msg = t('messages.connectWalletToClaimChainName'); + setError(msg); + push(
{msg}
); + return; + } + + if (validationError) { + setError(validationError); + push(
{validationError}
); + return; + } + + submittedRef.current = false; + setClaiming(true); + setError(null); + let isNameAvailable = availabilityStatus === 'available' + && lastCheckedValue === normalizedValue; + if (!isNameAvailable) { + try { + isNameAvailable = await checkNameAvailability(normalizedValue); + } catch (availabilityError) { + const msg = resolveClaimErrorMessage(availabilityError, t); + setError(msg); + notifyError(msg); + push(
{msg}
); + setClaiming(false); + return; + } + } + if (!isNameAvailable) { + const msg = t('messages.chainNameClaimNameTaken'); + setError(msg); + push(
{msg}
); + setClaiming(false); + return; + } + notifySubmitted({ + type: TxPayloadType.ClaimChainName, + name: normalizedValue, + step: 'wallet', + }); + + const claimPromise = claimSponsoredChainName({ + name: normalizedValue, + onSubmitted: (claimStatus) => { + submittedRef.current = true; + setClaiming(false); + notifyPending(getClaimNotificationPayload(normalizedValue, claimStatus)); + onClose(); + }, + onStatusChange: (claimStatus) => { + notifyPending(getClaimNotificationPayload(normalizedValue, claimStatus)); + }, + }); + + claimPromise.then((finalStatus) => { + const claimedName = String(finalStatus.name || `${normalizedValue}.chain`).trim().toLowerCase(); + setChainNames((prev) => ({ + ...prev, + [targetAddress]: claimedName, + })); + queryClient.invalidateQueries({ queryKey: ['SuperheroApi.getProfile', targetAddress] }); + queryClient.invalidateQueries({ queryKey: ['AccountsService.getAccount', targetAddress] }); + notifyConfirmed({ + type: TxPayloadType.ClaimChainName, + name: normalizedValue, + }); + push(
{t('messages.chainNameClaimCompleted')}
); + }).catch((claimError) => { + const msg = resolveClaimErrorMessage(claimError, t); + if (submittedRef.current) { + notifyError(msg); + push(
{msg}
); + return; + } + setError(msg); + notifyError(msg); + push(
{msg}
); + }).finally(() => { + if (!submittedRef.current) setClaiming(false); + }); + } catch (claimError) { + const msg = resolveClaimErrorMessage(claimError, t); + setError(msg); + notifyError(msg); + push(
{msg}
); + setClaiming(false); + } + }; + + const isCheckingAvailability = availabilityStatus === 'checking'; + const isCurrentNameUnavailable = Boolean( + availabilityStatus === 'unavailable' + && lastCheckedValue === normalizedValue, + ); + const isClaimDisabled = Boolean( + claiming + || isCheckingAvailability + || !canClaim + || validationError + || isCurrentNameUnavailable, + ); + let claimButtonLabel = t('buttons.claimChainName'); + if (claiming) claimButtonLabel = t('messages.chainNameClaimLoading'); + else if (isCheckingAvailability) claimButtonLabel = t('messages.chainNameClaimChecking'); + + return ( + + + + + {t('labels.claimChainName')} + + +
+
+

{t('messages.chainNameClaimHint')}

+
+
+ { + const nextValue = e.target.value; + const nextNormalizedValue = normalizeClaimChainName(nextValue); + const nextValidationError = validateClaimChainName(nextNormalizedValue); + + setValue(nextValue); + setAvailabilityStatus( + nextNormalizedValue && !nextValidationError ? 'checking' : 'idle', + ); + setLastCheckedValue(''); + if (error) setError(null); + }} + placeholder={t('placeholders.claimChainName')} + className={[ + 'pr-16 bg-white/7 text-white rounded-xl focus-visible:ring-0', + isTooShort + ? 'border border-amber-400/70 focus:border-amber-300' + : 'border border-white/14 focus:border-[var(--neon-teal)]', + ].join(' ')} + maxLength={64} + disabled={claiming} + /> + + .chain + +
+ +
+ {isTooShort && ( +

+ {normalizedValueLength} + /13 characters before `.chain` +

+ )} +
+ {error && ( +
+

{error}

+
+ )} +
+
+
+ ); +}; + +export default ClaimChainNameModal; diff --git a/src/components/modals/__tests__/ClaimChainNameModal.test.tsx b/src/components/modals/__tests__/ClaimChainNameModal.test.tsx new file mode 100644 index 000000000..969b51b85 --- /dev/null +++ b/src/components/modals/__tests__/ClaimChainNameModal.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { resolveClaimErrorMessage } from '../ClaimChainNameModal'; + +const messages: Record = { + 'messages.tooManyRequests': 'Too many requests. Please wait and try again.', + 'messages.connectWalletToClaimChainName': 'Connect your wallet to claim a .chain name', + 'messages.chainNameClaimTimedOut': 'The claim is still processing. Please check back in a moment.', + 'messages.chainNameClaimWalletUnavailable': 'Wallet message signing is not available right now. Please try again.', + 'messages.chainNameClaimNameTaken': 'That .chain name is already taken. Try another one.', + 'messages.chainNameClaimNameInProgress': 'That .chain name is currently being claimed. Try another one.', + 'messages.chainNameClaimAddressInProgress': 'You already have a sponsored .chain claim in progress.', + 'messages.chainNameClaimAddressClaimed': 'This wallet already has a sponsored .chain name.', + 'messages.chainNameClaimChallengeExpired': 'Your claim session expired. Please try again.', + 'messages.chainNameClaimVerificationFailed': 'We could not verify your wallet signature. Please try again.', + 'messages.chainNameClaimUnavailable': 'Sponsored .chain claiming is temporarily unavailable. Please try again later.', + 'messages.chainNameClaimRetry': 'We could not start the .chain claim. Please try again.', + 'messages.chainNameClaimFailed': 'Failed to claim .chain name.', +}; + +const t = (key: string) => messages[key] || key; + +describe('resolveClaimErrorMessage', () => { + it('maps name availability conflicts to friendly copy', () => { + expect(resolveClaimErrorMessage( + new Error('Superhero API error (400): This name is already taken on-chain'), + t, + )).toBe(messages['messages.chainNameClaimNameTaken']); + + expect(resolveClaimErrorMessage( + new Error('Superhero API error (409): This name is already being claimed by another address'), + t, + )).toBe(messages['messages.chainNameClaimNameInProgress']); + }); + + it('maps address-specific conflicts to friendly copy', () => { + expect(resolveClaimErrorMessage( + new Error('Superhero API error (409): Address already has an in-progress chain name claim: abc.chain'), + t, + )).toBe(messages['messages.chainNameClaimAddressInProgress']); + + expect(resolveClaimErrorMessage( + new Error('Superhero API error (409): Address already has a claimed chain name: abc.chain'), + t, + )).toBe(messages['messages.chainNameClaimAddressClaimed']); + }); + + it('maps challenge and signature issues to retryable copy', () => { + expect(resolveClaimErrorMessage( + new Error('Superhero API error (400): Challenge has expired'), + t, + )).toBe(messages['messages.chainNameClaimChallengeExpired']); + + expect(resolveClaimErrorMessage( + new Error('Superhero API error (400): Invalid challenge signature'), + t, + )).toBe(messages['messages.chainNameClaimVerificationFailed']); + }); + + it('maps temporary backend availability issues to generic unavailable copy', () => { + expect(resolveClaimErrorMessage( + new Error('Superhero API error (503): Chain name claiming is temporarily unavailable due to insufficient sponsor funds'), + t, + )).toBe(messages['messages.chainNameClaimUnavailable']); + + expect(resolveClaimErrorMessage( + new Error('Superhero API error (503): Unable to verify chain name availability right now'), + t, + )).toBe(messages['messages.chainNameClaimUnavailable']); + }); + + it('maps rate limits and unknown backend errors safely', () => { + expect(resolveClaimErrorMessage( + new Error('Superhero API error (429): Too many requests'), + t, + )).toBe(messages['messages.tooManyRequests']); + + expect(resolveClaimErrorMessage( + new Error('Superhero API error (400): Invalid address'), + t, + )).toBe(messages['messages.chainNameClaimRetry']); + + expect(resolveClaimErrorMessage( + new Error('Superhero API error (500): unexpected internal stack trace'), + t, + )).toBe(messages['messages.chainNameClaimFailed']); + }); +}); diff --git a/src/features/transaction-notification/TransactionNotificationBanner.tsx b/src/features/transaction-notification/TransactionNotificationBanner.tsx index b359894b0..5fd70143a 100644 --- a/src/features/transaction-notification/TransactionNotificationBanner.tsx +++ b/src/features/transaction-notification/TransactionNotificationBanner.tsx @@ -30,6 +30,11 @@ function getSubmittedMeta(payload: TxPayload): { title: string; subtitle: string return { title: 'Publishing post', subtitle: 'Sign in your wallet to continue' }; case TxPayloadType.CreateComment: return { title: 'Publishing reply', subtitle: 'Sign in your wallet to continue' }; + case TxPayloadType.ClaimChainName: + return { + title: 'Confirm in your wallet', + subtitle: `Sign the claim request for ${payload.name}.chain`, + }; case TxPayloadType.SwapToken: return { title: 'Confirm in your wallet', @@ -68,6 +73,39 @@ function getPendingMeta(payload: TxPayload): { title: string; subtitle: string } return { title: 'Publishing post', subtitle: 'Confirming on blockchain…' }; case TxPayloadType.CreateComment: return { title: 'Publishing reply', subtitle: 'Confirming on blockchain…' }; + case TxPayloadType.ClaimChainName: + switch (payload.step) { + case 'preclaim': + return { + title: `Claiming ${payload.name}.chain`, + subtitle: 'Step 1 of 4 - reserving the name on-chain', + }; + case 'claim': + return { + title: `Claiming ${payload.name}.chain`, + subtitle: 'Step 2 of 4 - submitting the sponsored claim', + }; + case 'update': + return { + title: `Claiming ${payload.name}.chain`, + subtitle: 'Step 3 of 4 - updating the name pointers', + }; + case 'transfer': + return { + title: `Claiming ${payload.name}.chain`, + subtitle: 'Step 4 of 4 - transferring the name to your wallet', + }; + case 'queued': + return { + title: `Claiming ${payload.name}.chain`, + subtitle: 'Preparing the sponsored transactions. This can take a couple of minutes.', + }; + default: + return { + title: `Claiming ${payload.name}.chain`, + subtitle: 'Processing in the background. You can continue using the app.', + }; + } case TxPayloadType.SwapToken: return { title: 'Swap in progress', subtitle: 'Usually confirms in a few seconds' }; case TxPayloadType.WrapToken: @@ -155,6 +193,11 @@ function getConfirmedMeta(payload: TxPayload): { return { title: 'Post published', line: null }; case TxPayloadType.CreateComment: return { title: 'Reply published', line: null }; + case TxPayloadType.ClaimChainName: + return { + title: 'Name claimed', + line: { leftLabel: `${payload.name}.chain`, leftColor: '#4ade80' }, + }; case TxPayloadType.AddLiquidity: return { title: 'Liquidity added', diff --git a/src/features/transaction-notification/transaction-notification.context.tsx b/src/features/transaction-notification/transaction-notification.context.tsx index 488abf5c6..dfca19082 100644 --- a/src/features/transaction-notification/transaction-notification.context.tsx +++ b/src/features/transaction-notification/transaction-notification.context.tsx @@ -12,6 +12,7 @@ export const TxPayloadType = { CreateToken: 'create_token', CreatePost: 'create_post', CreateComment: 'create_comment', + ClaimChainName: 'claim_chain_name', SwapToken: 'swap_token', WrapToken: 'wrap_ae', UnwrapToken: 'unwrap_wae', @@ -26,6 +27,7 @@ export type TxPayload = | { type: typeof TxPayloadType.CreateToken; tokenName: string } | { type: typeof TxPayloadType.CreatePost; content: string } | { type: typeof TxPayloadType.CreateComment; postId: string } + | { type: typeof TxPayloadType.ClaimChainName; name: string; step?: 'wallet' | 'queued' | 'preclaim' | 'claim' | 'update' | 'transfer' } | { type: typeof TxPayloadType.SwapToken; tokenInSymbol: string; tokenOutSymbol: string; amountIn: string; amountOut: string } | { type: typeof TxPayloadType.WrapToken; amount: string } | { type: typeof TxPayloadType.UnwrapToken; amount: string } @@ -44,6 +46,7 @@ export type NotificationState = type TransactionNotificationContextValue = { notificationState: NotificationState; notifySubmitted: (payload: TxPayload) => void; + notifyPending: (payload: TxPayload) => void; notifyPendingTx: (payload: TxPayload, txHash: string) => void; notifyConfirmed: (payload: TxPayload) => void; notifyError: (message: string) => void; @@ -121,6 +124,12 @@ export const TransactionNotificationProvider: React.FC<{ setNotificationState({ status: 'submitted', payload }); }, []); + const notifyPending = useCallback((payload: TxPayload) => { + clearDismissTimer(); + clearPollInterval(); + setNotificationState({ status: 'pending', payload, txHash: '' }); + }, []); + const notifyConfirmed = useCallback((payload: TxPayload) => { clearDismissTimer(); clearPollInterval(); @@ -167,6 +176,7 @@ export const TransactionNotificationProvider: React.FC<{ const contextValue = useMemo(() => ({ notificationState, notifySubmitted, + notifyPending, notifyPendingTx, notifyConfirmed, notifyError, @@ -174,6 +184,7 @@ export const TransactionNotificationProvider: React.FC<{ }), [ notificationState, notifySubmitted, + notifyPending, notifyPendingTx, notifyConfirmed, notifyError, diff --git a/src/hooks/__tests__/useClaimChainName.test.ts b/src/hooks/__tests__/useClaimChainName.test.ts new file mode 100644 index 000000000..02d044152 --- /dev/null +++ b/src/hooks/__tests__/useClaimChainName.test.ts @@ -0,0 +1,367 @@ +import { act, renderHook } from '@testing-library/react'; +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { useClaimChainName } from '@/hooks/useClaimChainName'; + +const mockCreateChainNameChallenge = vi.fn(); +const mockClaimChainName = vi.fn(); +const mockGetChainNameClaimStatus = vi.fn(); +const mockSignMessage = vi.fn(); +const mockAeSdkSignMessage = vi.fn(); +const mockSelectAccount = vi.fn(); +const mockGetName = vi.fn(); +const mockGetNameEntryByName = vi.fn(); +const mockResolveAccount = vi.fn(); +const mockConnectWallet = vi.fn(); +let mockWalletConnected = true; +let mockWalletInfo: Record | undefined = { id: 'wallet' }; +let mockConnectingWallet = false; + +let mockActiveAccount = 'ak_test_active'; +const mockFetch = vi.fn(); +let mockAeSdkState: Record | undefined; + +vi.mock('@/api/backend', () => ({ + SuperheroApi: { + createChainNameChallenge: (...args: any[]) => mockCreateChainNameChallenge(...args), + claimChainName: (...args: any[]) => mockClaimChainName(...args), + getChainNameClaimStatus: (...args: any[]) => mockGetChainNameClaimStatus(...args), + }, +})); + +vi.mock('@/hooks/useAeSdk', () => ({ + useAeSdk: () => ({ + activeAccount: mockActiveAccount, + sdk: { + getName: (...args: any[]) => mockGetName(...args), + api: { + getNameEntryByName: (...args: any[]) => mockGetNameEntryByName(...args), + }, + }, + staticAeSdk: null, + aeSdk: mockAeSdkState, + }), +})); + +vi.mock('@/hooks/useWalletConnect', () => ({ + useWalletConnect: () => ({ + connectWallet: (...args: any[]) => mockConnectWallet(...args), + connectingWallet: mockConnectingWallet, + walletConnected: mockWalletConnected, + walletInfo: mockWalletInfo, + }), +})); + +vi.mock('@/config', () => ({ + CONFIG: { + NODE_URL: 'https://node.example', + MIDDLEWARE_URL: 'https://mdw.example', + }, +})); + +describe('useClaimChainName', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', mockFetch); + mockActiveAccount = 'ak_test_active'; + mockAeSdkState = { + signMessage: (...args: any[]) => mockAeSdkSignMessage(...args), + selectAccount: (...args: any[]) => mockSelectAccount(...args), + addresses: () => [mockActiveAccount], + _resolveAccount: (...args: any[]) => mockResolveAccount(...args), + }; + mockSignMessage.mockResolvedValue(new Uint8Array([0xab, 0xcd])); + mockAeSdkSignMessage.mockResolvedValue(new Uint8Array([0xab, 0xcd])); + mockResolveAccount.mockReturnValue({ + signMessage: (...args: any[]) => mockSignMessage(...args), + }); + mockConnectWallet.mockResolvedValue(undefined); + mockWalletConnected = true; + mockWalletInfo = { id: 'wallet' }; + mockConnectingWallet = false; + mockCreateChainNameChallenge.mockResolvedValue({ + nonce: 'nonce-1', + expires_at: '123456', + message: 'profile_chain_name_claim:ak_test_active:nonce-1:123456', + }); + mockGetName.mockRejectedValue(new Error('Name not found')); + mockGetNameEntryByName.mockRejectedValue(new Error('Name not found')); + mockClaimChainName.mockResolvedValue({ status: 'ok' }); + mockFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/v3/transactions/th_transfer')) { + return { + ok: true, + json: async () => ({ block_height: 123 }), + }; + } + if (url.includes('/v3/names/averylongchain.chain')) { + return { + ok: true, + json: async () => ({ + ownership: { current: 'ak_test_active' }, + pointers: { account_pubkey: 'ak_test_active' }, + }), + }; + } + return { + ok: false, + json: async () => ({}), + }; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('waits for transfer confirmation and final middleware ownership before completing', async () => { + const onStatusChange = vi.fn(); + const onSubmitted = vi.fn(); + mockGetChainNameClaimStatus + .mockResolvedValueOnce({ + status: 'completed', + name: 'averylongchain.chain', + transfer_tx_hash: 'th_transfer', + expires_at: 999999, + }) + .mockResolvedValueOnce({ + status: 'completed', + name: 'averylongchain.chain', + transfer_tx_hash: 'th_transfer', + expires_at: 999999, + }); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ block_height: -1 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ block_height: 123 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ownership: { current: 'ak_test_active' }, + pointers: [{ + key: 'account_pubkey', + id: 'ak_test_active', + }], + }), + }); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + let response: any; + await act(async () => { + response = await result.current.claimSponsoredChainName({ + name: 'averylongchain', + onSubmitted, + onStatusChange, + pollIntervalMs: 0, + }); + }); + + expect(mockCreateChainNameChallenge).toHaveBeenCalledWith('ak_test_active'); + expect(mockSelectAccount).toHaveBeenCalledWith('ak_test_active'); + expect(mockAeSdkSignMessage).toHaveBeenCalledWith( + 'profile_chain_name_claim:ak_test_active:nonce-1:123456', + { onAccount: 'ak_test_active' }, + ); + expect(mockClaimChainName).toHaveBeenCalledWith({ + address: 'ak_test_active', + name: 'averylongchain', + challenge_nonce: 'nonce-1', + challenge_expires_at: '123456', + signature_hex: 'abcd', + }); + expect(onSubmitted).toHaveBeenCalledWith(expect.objectContaining({ status: 'ok' })); + expect(onStatusChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'ok' })); + expect(onStatusChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'queued' })); + expect(onStatusChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'transfer_pending' })); + expect(onStatusChange).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed' })); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/transactions/th_transfer'), + expect.any(Object), + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/v3/names/averylongchain.chain'), + expect.any(Object), + ); + expect(response).toMatchObject({ + status: 'completed', + name: 'averylongchain.chain', + expiresAt: 999999, + }); + }); + + it('surfaces backend chain name claim failures', async () => { + mockFetch.mockReset(); + mockGetChainNameClaimStatus.mockResolvedValueOnce({ + status: 'failed', + error: 'Name is already taken', + }); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await act(async () => { + await expect(result.current.claimSponsoredChainName({ + name: 'averylongchain', + pollIntervalMs: 0, + })).rejects.toThrow('Name is already taken'); + }); + }); + + it('checks whether a name is already present on chain', async () => { + mockGetName.mockResolvedValueOnce({ status: 'claimed' }); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await expect(result.current.checkNameAvailability('taken-name')).resolves.toBe(false); + await expect(result.current.checkNameAvailability('available-name')).resolves.toBe(true); + }); + + it('falls back to the node name lookup when sdk.getName is unavailable', async () => { + mockGetName.mockImplementation(() => { + throw new Error('getName unavailable'); + }); + mockGetNameEntryByName + .mockResolvedValueOnce({ id: 'nm_taken' }) + .mockRejectedValueOnce(new Error('404 not found')); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await expect(result.current.checkNameAvailability('taken-name')).resolves.toBe(false); + await expect(result.current.checkNameAvailability('available-name')).resolves.toBe(true); + }); + + it('rejects claims when the wallet signer account is unavailable', async () => { + mockAeSdkSignMessage.mockRejectedValueOnce(new Error('Wallet message signing is not available')); + mockResolveAccount.mockReturnValueOnce(null); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await act(async () => { + await expect(result.current.claimSponsoredChainName({ + name: 'averylongchain', + pollIntervalMs: 0, + })).rejects.toThrow('Wallet message signing is not available'); + }); + }); + + it('does not retry signing through the fallback signer after user rejection', async () => { + mockAeSdkSignMessage.mockRejectedValueOnce(new Error('Rejected by user')); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await act(async () => { + await expect(result.current.claimSponsoredChainName({ + name: 'averylongchain', + pollIntervalMs: 0, + })).rejects.toThrow('Rejected by user'); + }); + + expect(mockResolveAccount).not.toHaveBeenCalled(); + expect(mockSignMessage).not.toHaveBeenCalled(); + }); + + it('falls back to an authorized wallet signer when direct sdk signing is unavailable', async () => { + mockAeSdkSignMessage.mockRejectedValueOnce(new Error('sdk sign failed')); + mockGetChainNameClaimStatus.mockResolvedValueOnce({ + status: 'completed', + name: 'averylongchain.chain', + transfer_tx_hash: 'th_transfer', + expires_at: 999999, + }); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await act(async () => { + await expect(result.current.claimSponsoredChainName({ + name: 'averylongchain', + pollIntervalMs: 0, + })).resolves.toMatchObject({ + status: 'completed', + name: 'averylongchain.chain', + }); + }); + + expect(mockResolveAccount).toHaveBeenCalledWith('ak_test_active'); + expect(mockSignMessage).toHaveBeenCalledWith('profile_chain_name_claim:ak_test_active:nonce-1:123456'); + }); + + it('keeps claiming enabled for the connected profile address', () => { + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + expect(result.current.canClaim).toBe(true); + }); + + it('does not crash when aeSdk.address throws before wallet reconnect', () => { + mockActiveAccount = undefined as any; + mockAeSdkState = { + signMessage: (...args: any[]) => mockAeSdkSignMessage(...args), + selectAccount: (...args: any[]) => mockSelectAccount(...args), + addresses: () => [], + _resolveAccount: (...args: any[]) => mockResolveAccount(...args), + }; + Object.defineProperty(mockAeSdkState, 'address', { + get() { + throw new Error('You are not connected to Wallet'); + }, + }); + + expect(() => renderHook(() => useClaimChainName('ak_test_active'))).not.toThrow(); + }); + + it('reconnects the extension before signing when wallet session is stale', async () => { + mockWalletConnected = false; + mockConnectWallet.mockImplementation(async () => { + mockWalletConnected = true; + }); + mockGetChainNameClaimStatus.mockResolvedValueOnce({ + status: 'completed', + name: 'averylongchain.chain', + transfer_tx_hash: 'th_transfer', + expires_at: 999999, + }); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await act(async () => { + await expect(result.current.claimSponsoredChainName({ + name: 'averylongchain', + pollIntervalMs: 0, + })).resolves.toMatchObject({ + status: 'completed', + name: 'averylongchain.chain', + }); + }); + + expect(mockConnectWallet).toHaveBeenCalled(); + }); + + it('accepts final middleware ownership even when pointers are omitted', async () => { + mockGetChainNameClaimStatus.mockResolvedValueOnce({ + status: 'completed', + name: 'averylongchain.chain', + transfer_tx_hash: 'th_transfer', + expires_at: 999999, + }); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ block_height: 123 }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ownership: { current: 'ak_test_active' }, + }), + }); + const { result } = renderHook(() => useClaimChainName('ak_test_active')); + + await act(async () => { + await expect(result.current.claimSponsoredChainName({ + name: 'averylongchain', + pollIntervalMs: 0, + })).resolves.toMatchObject({ + status: 'completed', + name: 'averylongchain.chain', + }); + }); + }); +}); diff --git a/src/hooks/useClaimChainName.ts b/src/hooks/useClaimChainName.ts new file mode 100644 index 000000000..535c85f92 --- /dev/null +++ b/src/hooks/useClaimChainName.ts @@ -0,0 +1,534 @@ +import { + useCallback, + useEffect, + useRef, +} from 'react'; +import { + type ChainNameClaimStatusResponse, + SuperheroApi, +} from '@/api/backend'; +import { decode } from '@aeternity/aepp-sdk'; +import { CONFIG } from '@/config'; +import { useAeSdk } from './useAeSdk'; +import { useWalletConnect } from './useWalletConnect'; + +const normalizeName = (value: string) => value.trim().toLowerCase(); +const normalizeAddress = (value?: string | null) => (value || '').trim().toLowerCase(); +const readSdkString = (sdkRecord: Record, key: string) => { + try { + const value = sdkRecord[key]; + return typeof value === 'string' ? value : ''; + } catch { + return ''; + } +}; +const getSdkAddresses = (candidate: any): string[] => { + // eslint-disable-next-line no-underscore-dangle, dot-notation + const current = candidate?.['_accounts']?.current; + if (current && typeof current === 'object') return Object.keys(current); + if (typeof candidate?.addresses === 'function') return candidate.addresses(); + return []; +}; +const sdkHasAccount = (candidate: any, expectedAddress?: string): boolean => { + const addresses = getSdkAddresses(candidate); + if (!addresses.length) return false; + if (!expectedAddress) return true; + const target = normalizeAddress(expectedAddress); + return addresses.some((address) => normalizeAddress(address) === target); +}; +const isNameNotFoundError = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error || ''); + return /404|not found|name not found|Name revoked/i.test(message); +}; +const isUserRejectedSigningError = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error || ''); + const lower = message.toLowerCase(); + const code = (error as any)?.code; + return Boolean( + code === 'ACTION_REJECTED' + || code === 4001 + || lower.includes('rejected by user') + || lower.includes('user rejected') + || lower.includes('user denied') + || lower.includes('denied by user') + || lower.includes('cancelled by user') + || lower.includes('canceled by user') + || lower.includes('operation cancelled') + || lower.includes('operation canceled'), + ); +}; +const getSdkAddress = (sdk: unknown) => { + if (sdk && typeof sdk === 'object') { + const sdkRecord = sdk as Record; + const directAddress = readSdkString(sdkRecord, 'address'); + const selectedAddress = readSdkString(sdkRecord, 'selectedAddress'); + if (typeof directAddress === 'string' && directAddress) return directAddress; + + if (typeof selectedAddress === 'string' && selectedAddress) return selectedAddress; + + // eslint-disable-next-line no-underscore-dangle, dot-notation + const accounts = sdkRecord['_accounts']; + const currentAccounts = accounts && typeof accounts === 'object' + ? (accounts as Record).current + : null; + if (currentAccounts && typeof currentAccounts === 'object') { + const firstAddress = Object.keys(currentAccounts as Record)[0]; + if (firstAddress) return firstAddress; + } + } + return ''; +}; +const wait = (ms: number) => new Promise((resolve) => { + window.setTimeout(resolve, ms); +}); +const stripTrailingSlash = (value: string) => value.replace(/\/$/, ''); + +const bytesToHex = (value: Uint8Array): string => Array.from(value) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + +const normalizeSignatureHex = (signature: unknown): string => { + if (typeof signature === 'string') { + if (signature.startsWith('sg_')) return bytesToHex(decode(signature)); + const clean = signature.startsWith('0x') ? signature.slice(2) : signature; + if (/^[0-9a-f]+$/iu.test(clean) && clean.length % 2 === 0) return clean.toLowerCase(); + } + if (signature instanceof Uint8Array) return bytesToHex(signature); + if (signature instanceof ArrayBuffer) return bytesToHex(new Uint8Array(signature)); + if (ArrayBuffer.isView(signature)) { + return bytesToHex( + new Uint8Array(signature.buffer, signature.byteOffset, signature.byteLength), + ); + } + if (Array.isArray(signature)) return bytesToHex(Uint8Array.from(signature)); + if (signature && typeof signature === 'object') { + const nested = (signature as Record).signature + ?? (signature as Record).raw + ?? (signature as Record).value; + if (nested != null) return normalizeSignatureHex(nested); + } + throw new Error('Wallet did not return a valid signature'); +}; + +const extractChainNameExpiry = (status?: ChainNameClaimStatusResponse | null): number | null => { + if (!status) return null; + return [ + status.expires_at, + status.approximate_expire_time, + status.approximateExpireTime, + status.expire_time, + status.expireTime, + ].reduce((resolved, value) => { + if (resolved) return resolved; + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric > 0) return Math.floor(numeric); + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed); + } + return null; + }, null); +}; + +const readJson = async (url: string) => { + try { + const response = await fetch(url, { cache: 'no-cache' }); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +}; + +const getApiBases = () => Array.from( + new Set( + [ + CONFIG.NODE_URL, + CONFIG.MIDDLEWARE_URL, + ].filter(Boolean).map((value) => stripTrailingSlash(String(value))), + ), +); + +const fetchTransactionRecord = async (txHash: string) => { + const bases = getApiBases(); + const encodedHash = encodeURIComponent(txHash); + const tryRead = async (index: number): Promise => { + if (index >= bases.length) return null; + const data = await readJson(`${bases[index]}/v3/transactions/${encodedHash}?int-as-string=false`); + if (data) return data; + return tryRead(index + 1); + }; + return tryRead(0); +}; + +const isTransactionMined = async (txHash?: string | null) => { + if (!txHash) return false; + const record = await fetchTransactionRecord(txHash); + const blockHeight = Number(record?.block_height ?? record?.blockHeight ?? -1); + return Number.isFinite(blockHeight) && blockHeight > 0; +}; + +const fetchNameRecord = async (name: string) => { + const bases = getApiBases(); + const encodedName = encodeURIComponent(name); + const tryRead = async (index: number): Promise => { + if (index >= bases.length) return null; + const data = await readJson(`${bases[index]}/v3/names/${encodedName}`); + if (data) return data; + return tryRead(index + 1); + }; + return tryRead(0); +}; + +const getAuthorizedWalletSigner = ( + walletSdk: unknown, + targetAddress: string, +) => { + if (!walletSdk || typeof walletSdk !== 'object') return null; + const walletRecord = walletSdk as Record; + // eslint-disable-next-line no-underscore-dangle, dot-notation + const resolver = walletRecord['_resolveAccount']; + if (typeof resolver === 'function') { + try { + return resolver.call(walletSdk, targetAddress) as { + signMessage?: (message: string) => Promise; + }; + } catch { + return null; + } + } + return null; +}; + +const signMessageWithSdk = async ( + signerSdk: unknown, + address: string, + message: string, +) => { + if (!signerSdk || typeof signerSdk !== 'object') { + throw new Error('Wallet message signing is not available'); + } + if (typeof (signerSdk as any).selectAccount === 'function') { + try { + (signerSdk as any).selectAccount(address); + } catch { + // Continue; some sdk variants may not support explicit selection. + } + } + if (typeof (signerSdk as any).signMessage === 'function') { + try { + return await (signerSdk as any).signMessage(message, { onAccount: address }); + } catch (error) { + if (isUserRejectedSigningError(error)) throw error; + // Fall through to account-level signer resolution below. + } + } + const walletSigner = getAuthorizedWalletSigner(signerSdk, address); + if (walletSigner && typeof walletSigner.signMessage === 'function') { + return walletSigner.signMessage(message); + } + throw new Error('Wallet message signing is not available'); +}; + +const extractAccountPointer = (pointers: unknown) => { + if (Array.isArray(pointers)) { + const accountPointer = pointers.find((pointer) => ( + normalizeName(String((pointer as Record)?.key || '')) === 'account_pubkey' + )); + return normalizeAddress( + (accountPointer as Record | undefined)?.id as string | undefined, + ); + } + if (pointers && typeof pointers === 'object') { + return normalizeAddress( + (pointers as Record).account_pubkey as string | undefined + ?? (pointers as Record).accountPubkey as string | undefined, + ); + } + return ''; +}; + +const isNameClaimFinalized = async (name: string, address: string) => { + const record = await fetchNameRecord(name); + const ownerAddress = normalizeAddress( + record?.ownership?.current ?? record?.owner_id ?? record?.owner ?? record?.ownerId, + ); + const targetAddress = normalizeAddress(address); + if (ownerAddress !== targetAddress) return false; + + const accountPointer = extractAccountPointer(record?.pointers); + return !accountPointer || accountPointer === targetAddress; +}; + +const buildVerifiedStatus = ( + name: string, + baseStatus: ChainNameClaimStatusResponse, + overrides: Partial = {}, +): ChainNameClaimStatusResponse => ({ + ...baseStatus, + name, + ...overrides, +}); +export function useClaimChainName(targetAddress?: string) { + const { + activeAccount, + aeSdk, + sdk, + staticAeSdk, + } = useAeSdk(); + const { + connectWallet, + connectingWallet, + walletConnected, + walletInfo, + } = useWalletConnect(); + const activeAccountRef = useRef(activeAccount); + const walletConnectedRef = useRef(walletConnected); + const walletInfoRef = useRef(walletInfo); + const connectingWalletRef = useRef(connectingWallet); + + useEffect(() => { + activeAccountRef.current = activeAccount; + }, [activeAccount]); + + useEffect(() => { + walletConnectedRef.current = walletConnected; + }, [walletConnected]); + + useEffect(() => { + walletInfoRef.current = walletInfo; + }, [walletInfo]); + + useEffect(() => { + connectingWalletRef.current = connectingWallet; + }, [connectingWallet]); + + const connectedAddress = activeAccount || getSdkAddress(aeSdk) || getSdkAddress(sdk); + + const waitForWalletReconnect = useCallback(async ( + expectedAddress?: string, + timeoutMs = 20_000, + ): Promise => { + const knownAddress = expectedAddress || targetAddress; + const normalizedKnownAddress = knownAddress ? normalizeAddress(knownAddress) : ''; + const matchesExpectedAddress = (account?: string) => { + if (!account) return false; + if (!knownAddress) return true; + return normalizeAddress(account) === normalizedKnownAddress; + }; + const hasKnownSignerReady = () => ( + Boolean(knownAddress) + && walletConnectedRef.current + && sdkHasAccount(aeSdk, knownAddress) + ); + const getReconnectAddress = (): string | null => { + const { current } = activeAccountRef; + if (knownAddress) { + if (hasKnownSignerReady()) return knownAddress as string; + if (walletConnectedRef.current && matchesExpectedAddress(current)) return current as string; + } else if (walletConnectedRef.current && matchesExpectedAddress(current)) { + return current as string; + } + return null; + }; + + const immediate = getReconnectAddress(); + if (immediate) return immediate; + + if (!walletConnectedRef.current && !walletInfoRef.current) { + throw new Error('You are not connected to Wallet'); + } + + if (!walletConnectedRef.current && walletInfoRef.current && !connectingWalletRef.current) { + try { + await connectWallet(); + } catch { + // Continue waiting below in case wallet state is still propagating. + } + } + + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + let reconnectAttempted = walletConnectedRef.current + || !walletInfoRef.current + || connectingWalletRef.current; + const interval = window.setInterval(() => { + const resolvedAddress = getReconnectAddress(); + if (resolvedAddress) { + window.clearInterval(interval); + resolve(resolvedAddress); + return; + } + if (!reconnectAttempted && Date.now() - startedAt > 3_000) { + reconnectAttempted = true; + Promise.resolve(connectWallet() as any).catch(() => { + // Keep waiting until timeout. + }); + } + if (Date.now() - startedAt >= timeoutMs) { + window.clearInterval(interval); + reject(new Error('You are not connected to Wallet')); + } + }, 300); + }); + }, [aeSdk, connectWallet, targetAddress]); + + const checkNameAvailability = useCallback(async (name: string) => { + const normalizedName = normalizeName(name).replace(/\.chain$/u, ''); + const fullName = `${normalizedName}.chain` as `${string}.chain`; + const readSdk = staticAeSdk || sdk; + const lookups = [ + async () => { + if (typeof (readSdk as any)?.getName !== 'function') return null; + return (readSdk as any).getName(fullName); + }, + async () => { + if (typeof (readSdk as any)?.api?.getNameEntryByName !== 'function') return null; + return (readSdk as any).api.getNameEntryByName(fullName); + }, + async () => fetchNameRecord(fullName), + ]; + const runLookup = async (index: number): Promise => { + if (index >= lookups.length) return true; + try { + const result = await lookups[index](); + if (result == null) return runLookup(index + 1); + return false; + } catch (error) { + if (isNameNotFoundError(error)) return true; + if (index === lookups.length - 1) { + throw new Error('Unable to verify chain name availability right now'); + } + return runLookup(index + 1); + } + }; + + return runLookup(0); + }, [sdk, staticAeSdk]); + + const claimSponsoredChainName = useCallback(async (params: { + name: string; + onSubmitted?: (status: ChainNameClaimStatusResponse) => void; + onStatusChange?: (status: ChainNameClaimStatusResponse) => void; + pollIntervalMs?: number; + maxAttempts?: number; + }) => { + let reconnectedAddress: string | undefined; + try { + reconnectedAddress = await waitForWalletReconnect(targetAddress || connectedAddress); + } catch { + // Keep original error path below if extension reconnect fails. + } + const target = targetAddress || reconnectedAddress || connectedAddress; + if (!target) throw new Error('Connect your wallet to claim a .chain name'); + const signerAddress = reconnectedAddress || connectedAddress; + if (!signerAddress || normalizeAddress(signerAddress) !== normalizeAddress(target)) { + throw new Error('Connect the wallet for this profile to claim a .chain name'); + } + + const normalizedName = normalizeName(params.name).replace(/\.chain$/u, ''); + const challenge = await SuperheroApi.createChainNameChallenge(target); + const signature = await signMessageWithSdk(aeSdk, target, challenge.message).catch((error) => { + throw error instanceof Error + ? error + : new Error('Wallet message signing is not available'); + }); + const signatureHex = normalizeSignatureHex(signature); + + const claimResponse = await SuperheroApi.claimChainName({ + address: target, + name: normalizedName, + challenge_nonce: challenge.nonce, + challenge_expires_at: String(challenge.expires_at), + signature_hex: signatureHex, + }); + + const initialStatus: ChainNameClaimStatusResponse = { + status: claimResponse.status || 'pending', + name: `${normalizedName}.chain`, + }; + params.onSubmitted?.(initialStatus); + params.onStatusChange?.(initialStatus); + + const fullName = `${normalizedName}.chain`; + const pollIntervalMs = params.pollIntervalMs ?? 5_000; + const maxAttempts = params.maxAttempts ?? 120; + const verifyClaimProgress = async ( + status: ChainNameClaimStatusResponse, + ): Promise => { + if (String(status.status || '').toLowerCase() === 'failed') return buildVerifiedStatus(fullName, status); + + const hasPreclaim = Boolean(status.preclaim_tx_hash); + const hasClaim = Boolean(status.claim_tx_hash); + const hasUpdate = Boolean(status.update_tx_hash); + const hasTransfer = Boolean(status.transfer_tx_hash); + + if (hasPreclaim && !(await isTransactionMined(status.preclaim_tx_hash))) { + return buildVerifiedStatus(fullName, status, { status: 'preclaim_pending' }); + } + if (hasClaim && !(await isTransactionMined(status.claim_tx_hash))) { + return buildVerifiedStatus(fullName, status, { status: 'claim_pending' }); + } + if (hasUpdate && !(await isTransactionMined(status.update_tx_hash))) { + return buildVerifiedStatus(fullName, status, { status: 'update_pending' }); + } + if (hasTransfer && !(await isTransactionMined(status.transfer_tx_hash))) { + return buildVerifiedStatus(fullName, status, { status: 'transfer_pending' }); + } + + const statusValue = String(status.status || '').toLowerCase(); + if (hasTransfer || statusValue === 'completed') { + const finalized = await isNameClaimFinalized(fullName, target); + if (!finalized) { + return buildVerifiedStatus(fullName, status, { status: 'transfer_pending' }); + } + return buildVerifiedStatus(fullName, status, { status: 'completed' }); + } + + return buildVerifiedStatus(fullName, status, { status: 'queued' }); + }; + + const pollStatus = async ( + attempt: number, + latestStatus: ChainNameClaimStatusResponse, + ): Promise => { + const verifiedStatus = await verifyClaimProgress(latestStatus); + params.onStatusChange?.(verifiedStatus); + const latestStatusName = String(verifiedStatus.status || '').toLowerCase(); + if (['completed', 'failed'].includes(latestStatusName)) return verifiedStatus; + if (attempt >= maxAttempts) return verifiedStatus; + + await wait(pollIntervalMs); + const nextStatus = await SuperheroApi.getChainNameClaimStatus(target); + if (!nextStatus.name) nextStatus.name = fullName; + return pollStatus(attempt + 1, nextStatus); + }; + + const latestStatus = await pollStatus(0, initialStatus); + const finalStatusName = String(latestStatus.status || '').toLowerCase(); + if (!['completed', 'failed'].includes(finalStatusName)) { + throw new Error('Timed out while waiting for .chain name claim to finish'); + } + if (finalStatusName === 'failed') { + throw new Error(latestStatus.error || 'Failed to claim .chain name'); + } + + return { + ...latestStatus, + name: latestStatus.name || `${normalizedName}.chain`, + expiresAt: extractChainNameExpiry(latestStatus), + }; + }, [aeSdk, connectedAddress, targetAddress, waitForWalletReconnect]); + + return { + canClaim: Boolean( + connectedAddress + && ( + !targetAddress + || normalizeAddress(targetAddress) === normalizeAddress(connectedAddress) + ), + ), + checkNameAvailability, + claimSponsoredChainName, + }; +} diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts index 71b7d71a7..24eeab5c1 100644 --- a/src/hooks/useProfile.ts +++ b/src/hooks/useProfile.ts @@ -70,7 +70,11 @@ type SetProfileInput = { type ProfileRegistryContractApi = ContractMethodsBase & { _calldata: { - encode: (contractName: string, functionName: string, args: unknown[]) => Encoded.ContractBytearray; + encode: ( + contractName: string, + functionName: string, + args: unknown[], + ) => Encoded.ContractBytearray; }; }; diff --git a/src/locales/en.json b/src/locales/en.json index 5aa091df2..11cba151e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -24,7 +24,9 @@ "saveProfile": "Save profile", "sendFeedback": "Send Feedback", "retry": "Retry", - "connectX": "Connect X" + "connectX": "Connect X", + "getChainName": "+ Get .chain name", + "claimChainName": "Claim" }, "labels": { "numberOfInvites": "Number of invites", @@ -50,7 +52,8 @@ "none": "None", "xAccessToken": "X access token (optional)", "connectX": "Link X account", - "xAccount": "X account" + "xAccount": "X account", + "claimChainName": "Claim a new .chain name" }, "actions": { "copy": "Copy", @@ -115,7 +118,8 @@ "username": "your_username", "selectChainName": "Select one of your chain names", "solName": "Your Solana name", - "xAccessToken": "Paste your X OAuth access token" + "xAccessToken": "Paste your X OAuth access token", + "claimChainName": "myuniquename123" }, "messages": { "notEnoughBalance": "Not enough balance. You need {{amount}} AE.", @@ -142,6 +146,27 @@ "failedToRefreshProfile": "Profile transaction succeeded but refresh failed. Please reload the page.", "tooManyRequests": "Too many requests. Please wait and try again.", "noChainNamesFound": "No owned chain names found for this account.", + "connectWalletToClaimChainName": "Connect your wallet to claim a .chain name", + "chainNameClaimRequired": "Enter the name you want to claim.", + "chainNameClaimHint": "Use letters, numbers, or hyphens. More than 12 characters name is for free.", + "chainNameClaimInvalidChars": "Chain names can use letters, numbers, and hyphens, and can't start or end with a hyphen.", + "chainNameClaimChecking": "Checking…", + "chainNameClaimTooShort": "Sponsored claims require more than 12 characters before .chain.", + "chainNameClaimLoading": "Claiming…", + "chainNameClaimStarted": "Claim submitted. Waiting for backend sponsorship and transfer…", + "chainNameClaimPending": "Claim status: {{status}}", + "chainNameClaimCompleted": ".chain name claimed successfully.", + "chainNameClaimWalletUnavailable": "Wallet message signing is not available right now. Please try again.", + "chainNameClaimNameTaken": "That .chain name is already taken. Try another one.", + "chainNameClaimNameInProgress": "That .chain name is currently being claimed. Try another one.", + "chainNameClaimAddressInProgress": "You already have a sponsored .chain claim in progress.", + "chainNameClaimAddressClaimed": "This wallet already has a sponsored .chain name.", + "chainNameClaimChallengeExpired": "Your claim session expired. Please try again.", + "chainNameClaimVerificationFailed": "We could not verify your wallet signature. Please try again.", + "chainNameClaimUnavailable": "Sponsored .chain claiming is temporarily unavailable. Please try again later.", + "chainNameClaimRetry": "We could not start the .chain claim. Please try again.", + "chainNameClaimFailed": "Failed to claim .chain name.", + "chainNameClaimTimedOut": "The claim is still processing. Please check back in a moment.", "selectChainNameForDisplaySource": "Choose a chain name before using Chain as display source.", "oops": "Oops", "loading": "Loading…", diff --git a/src/views/UserProfile.tsx b/src/views/UserProfile.tsx index 476292291..39c36d4c0 100644 --- a/src/views/UserProfile.tsx +++ b/src/views/UserProfile.tsx @@ -38,6 +38,7 @@ import { useAddressByChainName, useChainName } from '../hooks/useChainName'; import { SuperheroApi } from '../api/backend'; import AccountPortfolio from '@/components/Account/AccountPortfolio'; +import ClaimChainNameModal from '@/components/modals/ClaimChainNameModal'; import ProfileEditModal from '../components/modals/ProfileEditModal'; import { CONFIG } from '../config'; import { useModal } from '../hooks'; @@ -97,6 +98,7 @@ export default function UserProfile({ }); const profileDisplayName = (profileInfo?.public_name || chainName || '').trim(); const hasProfileDisplayName = Boolean(profileDisplayName); + const showClaimChainNameCta = Boolean(canEdit && !chainName); const profileHeading = profileDisplayName || formatAddress(effectiveAddress, 6, true); const { data: onChainProfile, refetch: refetchOnChainProfile } = useQuery({ @@ -108,6 +110,7 @@ export default function UserProfile({ const [profile, setProfile] = useState(null); const [editOpen, setEditOpen] = useState(false); + const [claimChainNameOpen, setClaimChainNameOpen] = useState(false); const [editInitialSection, setEditInitialSection] = useState<'profile' | 'x'>('profile'); // Get tab from URL search params, default to "feed" @@ -379,17 +382,34 @@ export default function UserProfile({ />
-

- {profileHeading} -

+
+

+ {profileHeading} +

+ {showClaimChainNameCta && ( + + )} +
{effectiveAddress}
@@ -403,19 +423,6 @@ export default function UserProfile({ {/* Action buttons */}
- {(canEdit && false) ? ( - { - setEditInitialSection('profile'); - setEditOpen(true); - }} - > - {t('buttons.editProfile')} - - ) : null} {!canEdit ? ( openModal({ name: 'tip', props: { toAddress: effectiveAddress } })} @@ -445,19 +452,6 @@ export default function UserProfile({
- {(canEdit && !isXVerified && false) && ( - - )} - {/* Portfolio Chart and Stats - Side by side on md+ */}
{/* Portfolio Chart - Smaller on md+ */} @@ -600,6 +594,11 @@ export default function UserProfile({ initialBio={bioText} initialSection={editInitialSection} /> + setClaimChainNameOpen(false)} + address={effectiveAddress} + /> ) : ( <> @@ -630,6 +629,11 @@ export default function UserProfile({ initialBio={bioText} initialSection={editInitialSection} /> + setClaimChainNameOpen(false)} + address={effectiveAddress} + /> ); } From a15212d426cf0d0030e097d7d30beeb5f51b11d3 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 2 Apr 2026 14:42:16 +0400 Subject: [PATCH 2/3] temp: set develop env --- src/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index a4c8b8bbe..629c5c048 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,12 +90,12 @@ export const NETWORKS: Record = { ae_mainnet: { name: 'Mainnet', NETWORK: 'ae_mainnet', - BACKEND_URL: 'https://api.superhero.com', - SUPERHERO_API_URL: 'https://api.superhero.com', + BACKEND_URL: 'https://api.dev.tokensale.org', + SUPERHERO_API_URL: 'https://api.dev.tokensale.org', NODE_URL: 'https://mdw.wordcraft.fun', MIDDLEWARE_URL: 'https://mdw.wordcraft.fun/mdw', EXPLORER_URL: 'https://aescan.io', - websocketUrl: 'https://api.superhero.com', + websocketUrl: 'https://api.dev.tokensale.org', compilerUrl: 'https://v7.compiler.aepps.com', superheroBackendUrl: 'https://superhero-backend-mainnet.prd.service.aepps.com', DEX_BACKEND_URL: 'https://dex-backend-mainnet.prd.service.aepps.com', From f06cb5bcda61b3860fb58644f5b6edf8a0318f53 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 2 Apr 2026 16:52:11 +0400 Subject: [PATCH 3/3] style: remove gradient green color --- .../Address/AddressAvatarWithChainName.tsx | 16 ++- src/components/Trendminer/TokenChat.tsx | 8 +- src/components/Truncate.tsx | 2 +- .../dex/core/LiquiditySuccessNotification.tsx | 2 +- src/components/dex/core/SwapConfirmation.tsx | 2 +- src/components/dex/core/SwapForm.tsx | 2 +- src/components/dex/core/TokenSelector.tsx | 2 +- src/components/hero-banner/banner.styles.css | 8 -- .../layout/app-header/MobileAppHeader.tsx | 11 +- .../layout/app-header/WebAppHeader.tsx | 8 +- src/components/modals/ConnectWalletModal.tsx | 8 +- .../__tests__/ClaimChainNameModal.test.tsx | 6 ++ src/components/social/PostHashtagLink.tsx | 8 +- .../ae-eth-bridge/components/AeEthBridge.tsx | 2 +- .../components/BridgeTokenSelector.tsx | 2 +- .../ae-eth-buy/components/BuyAeWidget.tsx | 2 +- src/features/dex/WrapUnwrapWidget.tsx | 2 +- .../dex/components/AddLiquidityForm.tsx | 2 +- .../dex/components/LiquidityConfirmation.tsx | 2 +- .../dex/components/RemoveLiquidityForm.tsx | 4 +- src/features/dex/views/DexExplorePools.tsx | 2 +- src/features/dex/views/DexExploreTokens.tsx | 2 +- .../dex/views/DexExploreTransactions.tsx | 2 +- src/features/dex/views/Pool.tsx | 2 +- .../components/BlockchainInfoPopover.tsx | 12 +-- .../components/TokenCreatedActivityItem.tsx | 2 +- .../components/TrendingAssetsFeedItem.tsx | 10 +- src/features/trending/views/TokenList.tsx | 20 +--- src/styles/base.scss | 100 ++++-------------- src/styles/tailwind.css | 8 -- src/utils/linkify.tsx | 22 ---- 31 files changed, 77 insertions(+), 204 deletions(-) diff --git a/src/@components/Address/AddressAvatarWithChainName.tsx b/src/@components/Address/AddressAvatarWithChainName.tsx index 69ed63a66..27d63cd43 100644 --- a/src/@components/Address/AddressAvatarWithChainName.tsx +++ b/src/@components/Address/AddressAvatarWithChainName.tsx @@ -161,13 +161,11 @@ export const AddressAvatarWithChainName = memo(({ const chainNameClass = variant === 'feed' ? [ 'chain-name text-[14px] md:text-sm font-bold', - 'bg-gradient-to-r from-[var(--neon-teal)] via-[var(--neon-teal)] to-teal-300', - 'bg-clip-text text-transparent', + 'text-[var(--standard-font-color)]', ].join(' ') : [ 'chain-name text-[14px] md:text-[15px] font-bold', - 'bg-gradient-to-r from-[var(--neon-teal)] via-[var(--neon-teal)] to-teal-300', - 'bg-clip-text text-transparent', + 'text-[var(--standard-font-color)]', ].join(' '); return (
@@ -179,8 +177,7 @@ export const AddressAvatarWithChainName = memo(({ @@ -192,7 +189,7 @@ export const AddressAvatarWithChainName = memo(({ return ( {preferredName} - + diff --git a/src/components/Trendminer/TokenChat.tsx b/src/components/Trendminer/TokenChat.tsx index 5fe574c36..fbc086789 100644 --- a/src/components/Trendminer/TokenChat.tsx +++ b/src/components/Trendminer/TokenChat.tsx @@ -131,12 +131,12 @@ const AddCommentCTA = ({ token }: { token: { name: string; address: string } }) href={qualiPublicUrl} target="_blank" rel="noopener noreferrer" - className="group no-underline rounded-xl border border-white/15 bg-white/[0.05] p-3 text-left transition-all duration-200 hover:-translate-y-0.5 hover:bg-white/[0.09] focus:outline-none focus:ring-2 focus:ring-white/30 no-gradient-text" + className="group no-underline rounded-xl border border-white/15 bg-white/[0.05] p-3 text-left transition-all duration-200 hover:-translate-y-0.5 hover:bg-white/[0.09] focus:outline-none focus:ring-2 focus:ring-white/30" title="Open the public chat on Quali.chat" >
- 🌐 + 🌐
@@ -152,12 +152,12 @@ const AddCommentCTA = ({ token }: { token: { name: string; address: string } }) href={qualiPrivateUrl} target="_blank" rel="noopener noreferrer" - className="group no-underline rounded-xl border border-white/15 bg-white/[0.05] p-3 text-left transition-all duration-200 hover:-translate-y-0.5 hover:bg-white/[0.09] focus:outline-none focus:ring-2 focus:ring-white/30 no-gradient-text" + className="group no-underline rounded-xl border border-white/15 bg-white/[0.05] p-3 text-left transition-all duration-200 hover:-translate-y-0.5 hover:bg-white/[0.09] focus:outline-none focus:ring-2 focus:ring-white/30" title="Open the private chat on Quali.chat (holders only)" >
- 🔒 + 🔒
diff --git a/src/components/Truncate.tsx b/src/components/Truncate.tsx index 9700d0782..d4ea0efbd 100644 --- a/src/components/Truncate.tsx +++ b/src/components/Truncate.tsx @@ -87,7 +87,7 @@ export const Truncate = ({ '--animation-delay': '1s !important', } as React.CSSProperties : undefined} > -
{nameComponent}
+
{nameComponent}
{nameComponent !== str && ( diff --git a/src/components/dex/core/LiquiditySuccessNotification.tsx b/src/components/dex/core/LiquiditySuccessNotification.tsx index a5dbd3bbf..fe303c159 100644 --- a/src/components/dex/core/LiquiditySuccessNotification.tsx +++ b/src/components/dex/core/LiquiditySuccessNotification.tsx @@ -72,7 +72,7 @@ export default function LiquiditySuccessNotification({
{/* Title */} - + Liquidity Added Successfully! diff --git a/src/components/dex/core/SwapConfirmation.tsx b/src/components/dex/core/SwapConfirmation.tsx index af4a9b393..521489250 100644 --- a/src/components/dex/core/SwapConfirmation.tsx +++ b/src/components/dex/core/SwapConfirmation.tsx @@ -69,7 +69,7 @@ export default function SwapConfirmation({ {/* Header */}
- + Confirm Swap diff --git a/src/components/dex/core/SwapForm.tsx b/src/components/dex/core/SwapForm.tsx index 8fe1d1712..74c55ef9b 100644 --- a/src/components/dex/core/SwapForm.tsx +++ b/src/components/dex/core/SwapForm.tsx @@ -356,7 +356,7 @@ export default function SwapForm({ onPairSelected, onFromTokenSelected }: SwapFo
{/* Header */}
-

+

{t('swap.title')}

diff --git a/src/components/dex/core/TokenSelector.tsx b/src/components/dex/core/TokenSelector.tsx index 12b75eb57..d4a455d2f 100644 --- a/src/components/dex/core/TokenSelector.tsx +++ b/src/components/dex/core/TokenSelector.tsx @@ -138,7 +138,7 @@ export default function TokenSelector({ {/* Header */}
- + Select a token diff --git a/src/components/hero-banner/banner.styles.css b/src/components/hero-banner/banner.styles.css index d08d6b6b4..723b4b85e 100644 --- a/src/components/hero-banner/banner.styles.css +++ b/src/components/hero-banner/banner.styles.css @@ -151,10 +151,6 @@ html.ios-webkit .hero-banner--ios-safe .banner-dismiss { color: #ffffff !important; text-shadow: 0 2px 16px rgba(0, 0, 0, 0.35); animation: fadeUp 0.9s ease both 0.05s; - background: none !important; - -webkit-background-clip: unset !important; - background-clip: unset !important; - -webkit-text-fill-color: #ffffff !important; } .mobile-break { @@ -260,10 +256,6 @@ html.ios-webkit .hero-banner--ios-safe .banner-dismiss { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; border: none; - /* Override global link styles */ - -webkit-background-clip: unset !important; - background-clip: unset !important; - -webkit-text-fill-color: unset !important; filter: none !important; } diff --git a/src/components/layout/app-header/MobileAppHeader.tsx b/src/components/layout/app-header/MobileAppHeader.tsx index 97b68ee13..1c6f476a7 100644 --- a/src/components/layout/app-header/MobileAppHeader.tsx +++ b/src/components/layout/app-header/MobileAppHeader.tsx @@ -203,7 +203,7 @@ const MobileAppHeader = () => { ) : ( <> - +
@@ -268,9 +268,6 @@ const MobileAppHeader = () => { style={{ color: 'rgba(255,255,255,0.8)', background: 'transparent', - backgroundImage: 'none', - WebkitTextFillColor: 'rgba(255,255,255,0.8)', - WebkitBackgroundClip: 'initial' as any, }} > {t('labels.menu')} @@ -343,7 +340,7 @@ const MobileAppHeader = () => { target="_blank" rel="noreferrer" className={`${commonClasses} bg-transparent`} - style={{ WebkitTextFillColor: 'white', WebkitBackgroundClip: 'initial' as any, background: 'none' }} + style={{ background: 'none' }} onClick={handleNavigationClick} > {item.label} @@ -355,7 +352,7 @@ const MobileAppHeader = () => { to={item.path} onClick={handleNavigationClick} className={`${commonClasses} bg-transparent`} - style={{ WebkitTextFillColor: 'white', WebkitBackgroundClip: 'initial' as any, background: 'none' }} + style={{ background: 'none' }} > {item.label} @@ -377,7 +374,7 @@ const MobileAppHeader = () => { to="/defi/buy-ae-with-eth" onClick={handleNavigationClick} className="w-full no-underline font-semibold transition-all duration-200 h-[56px] sm:h-[52px] rounded-xl text-white text-base flex items-center justify-center px-5 bg-transparent" - style={{ WebkitTextFillColor: 'white', WebkitBackgroundClip: 'initial' as any, background: 'none' }} + style={{ background: 'none' }} > {t('labels.buyAe')} diff --git a/src/components/layout/app-header/WebAppHeader.tsx b/src/components/layout/app-header/WebAppHeader.tsx index a5094380f..3a979a1c5 100644 --- a/src/components/layout/app-header/WebAppHeader.tsx +++ b/src/components/layout/app-header/WebAppHeader.tsx @@ -59,7 +59,7 @@ const WebAppHeader = () => {
@@ -88,7 +88,7 @@ const WebAppHeader = () => {