diff --git a/cross-app-connect/src/app/cross-app/_lib/decoder.ts b/cross-app-connect/src/app/cross-app/_lib/decoder.ts index 10341532a..904e604d1 100644 --- a/cross-app-connect/src/app/cross-app/_lib/decoder.ts +++ b/cross-app-connect/src/app/cross-app/_lib/decoder.ts @@ -113,6 +113,12 @@ export async function decodeClause( thor: ThorClient | null, network: NETWORK_TYPE, self?: string, + /** Lowercased address of the generic delegator's deposit account. When + * set, clauses transferring VET / VTHO / B3TR / VOT3 to this address + * are re-labelled as "Pay transaction fee" instead of an opaque + * "Send X VET to 0x86…fa" — the user understands the clause exists + * to fund the gas payer, not as a separate transfer. */ + feeDepositAccount?: string, ): Promise { const data = (clause.data ?? '0x').toLowerCase(); const value = (() => { @@ -123,9 +129,25 @@ export async function decodeClause( } })(); + const isFeeDeposit = (recipient: string): boolean => + !!feeDepositAccount && + recipient.toLowerCase() === feeDepositAccount; + // 1. Native VET transfer if ((data === '0x' || data === '') && value > ZERO) { const amount = formatUnits(value, 18); + if (isFeeDeposit(clause.to)) { + return { + kind: 'known_action', + category: 'fee', + recipient: clause.to, + summary: t('action.fee.payTransactionFee'), + detail: t('action.fee.amount', { + amount: trimAmount(amount), + symbol: 'VET', + }), + }; + } return { kind: 'native_transfer', recipient: clause.to, @@ -157,6 +179,18 @@ export async function decodeClause( bigint, ]; const amount = formatUnits(raw, token.decimals); + if (isFeeDeposit(recipient)) { + return { + kind: 'known_action', + category: 'fee', + recipient, + summary: t('action.fee.payTransactionFee'), + detail: t('action.fee.amount', { + amount: trimAmount(amount), + symbol: token.symbol, + }), + }; + } return { kind: 'token_transfer', recipient, diff --git a/cross-app-connect/src/app/cross-app/_lib/knownActions.ts b/cross-app-connect/src/app/cross-app/_lib/knownActions.ts index f05036e1a..a2aecce9c 100644 --- a/cross-app-connect/src/app/cross-app/_lib/knownActions.ts +++ b/cross-app-connect/src/app/cross-app/_lib/knownActions.ts @@ -29,7 +29,8 @@ export type KnownActionCategory = | 'nft' | 'token' | 'staking' - | 'swap'; + | 'swap' + | 'fee'; /** * Structured side-channel that travels alongside the localized summary so diff --git a/cross-app-connect/src/app/cross-app/_lib/thor.ts b/cross-app-connect/src/app/cross-app/_lib/thor.ts index cd79b094e..9da11b3d0 100644 --- a/cross-app-connect/src/app/cross-app/_lib/thor.ts +++ b/cross-app-connect/src/app/cross-app/_lib/thor.ts @@ -22,10 +22,12 @@ const NETWORK = { main: { nodeUrl: 'https://mainnet.vechain.org', accountFactoryAddress: '0xC06Ad8573022e2BE416CA89DA47E8c592971679A', + genericDelegatorUrl: 'https://mainnet.delegator.vechain.org/api/v1/', }, test: { nodeUrl: 'https://testnet.vechain.org', accountFactoryAddress: '0x713b908Bcf77f3E00EFEf328E50b657a1A23AeaF', + genericDelegatorUrl: 'https://testnet.delegator.vechain.org/api/v1/', }, } as const; @@ -33,6 +35,35 @@ export const networkType = NETWORK_TYPE; export const networkConfig = NETWORK[NETWORK_TYPE]; export const thor = ThorClient.at(networkConfig.nodeUrl); +// Fetch the generic delegator's current deposit account once per page load. +// The recogniser in decoder.ts uses the result to re-label clauses sending +// gas tokens to that address as "Pay transaction fee" instead of an opaque +// "Send X VET to 0x86…fa", so the user understands what they're paying. +let depositAccountPromise: Promise | null = null; +export async function fetchGenericDelegatorDepositAccount(): Promise< + string | null +> { + if (!depositAccountPromise) { + depositAccountPromise = (async () => { + try { + const res = await fetch( + new URL( + 'deposit/account', + networkConfig.genericDelegatorUrl, + ), + { method: 'GET', headers: { 'Content-Type': 'application/json' } }, + ); + if (!res.ok) return null; + const data = (await res.json()) as { depositAccount?: string }; + return data.depositAccount?.toLowerCase() ?? null; + } catch { + return null; + } + })(); + } + return depositAccountPromise; +} + // Minimal ABI for SocialLoginSmartAccountFactory.getAccountAddress. Inlined // to avoid pulling the full `@vechain/vechain-contract-types` package; this // single read is all the host needs. diff --git a/cross-app-connect/src/app/cross-app/transact/TransactClient.tsx b/cross-app-connect/src/app/cross-app/transact/TransactClient.tsx index 79a6bd066..c712d8806 100644 --- a/cross-app-connect/src/app/cross-app/transact/TransactClient.tsx +++ b/cross-app-connect/src/app/cross-app/transact/TransactClient.tsx @@ -28,6 +28,7 @@ import { type Risk, } from '../_lib/labels'; import { + fetchGenericDelegatorDepositAccount, getChainId, getSmartAccountAddress, networkType, @@ -387,9 +388,21 @@ export function TransactClient() { const selfAddress = smartAccount?.address; let cancelled = false; (async () => { + // Resolve the generic delegator's deposit account so transfers + // funding the gas payer get re-labelled as "Pay transaction fee". + // Cached after the first call; null if the delegator is + // unreachable (decoder gracefully falls back to the raw label). + const feeDepositAccount = + (await fetchGenericDelegatorDepositAccount()) ?? undefined; const results = await Promise.all( parsed.clauses.map((c) => - decodeClause(c, thor, networkType, selfAddress), + decodeClause( + c, + thor, + networkType, + selfAddress, + feeDepositAccount, + ), ), ); if (!cancelled) setDecoded(results); diff --git a/cross-app-connect/src/app/i18n/locales/en.json b/cross-app-connect/src/app/i18n/locales/en.json index ab785b821..5b9dd8c65 100644 --- a/cross-app-connect/src/app/i18n/locales/en.json +++ b/cross-app-connect/src/app/i18n/locales/en.json @@ -53,6 +53,10 @@ "native": "Send {{amount}} VET", "token": "Send {{amount}} {{symbol}}" }, + "fee": { + "payTransactionFee": "Pay transaction fee", + "amount": "{{amount}} {{symbol}} to the gas payer" + }, "approve": { "unlimited": "Allow unlimited {{symbol}} spending", "upTo": "Allow spending up to {{amount}} {{symbol}}" diff --git a/cross-app-connect/src/app/i18n/locales/it.json b/cross-app-connect/src/app/i18n/locales/it.json index a43fda31e..35b153416 100644 --- a/cross-app-connect/src/app/i18n/locales/it.json +++ b/cross-app-connect/src/app/i18n/locales/it.json @@ -53,6 +53,10 @@ "native": "Invia {{amount}} VET", "token": "Invia {{amount}} {{symbol}}" }, + "fee": { + "payTransactionFee": "Paga fee per transazione", + "amount": "{{amount}} {{symbol}} al pagatore del gas" + }, "approve": { "unlimited": "Consenti spesa illimitata di {{symbol}}", "upTo": "Consenti spesa fino a {{amount}} {{symbol}}" diff --git a/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx b/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx index 9279f05b8..7bc2584e3 100644 --- a/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx +++ b/packages/vechain-kit/src/components/AccountModal/Contents/ChooseName/ChooseNameSummaryContent.tsx @@ -163,7 +163,16 @@ export const ChooseNameSummaryContent = ({ const [userSelectedGasToken, setUserSelectedGasToken] = React.useState(null); + // VeChain pays gas for domain CLAIMS via the kit-sponsored delegator + // (see useClaimVetDomain / useClaimVeWorldSubdomain). The unset path + // (useUnsetDomain) is NOT sponsored — the user pays — so we still + // need the gas-token UI and balance check for that case. Drives: + // skip estimation, hide GasFeeSummary, force hasEnoughGasBalance, + // and suppress gas-estimation errors. + const KIT_PAYS_GAS = !isUnsetting; + const shouldEstimateGas = + !KIT_PAYS_GAS && preferences.availableGasTokens.length > 0 && (connection.isConnectedWithPrivy || connection.isConnectedWithVeChain) && @@ -182,6 +191,7 @@ export const ChooseNameSummaryContent = ({ }); const usedGasToken = gasEstimation?.usedToken; const disableConfirmButtonDuringEstimation = + !KIT_PAYS_GAS && (gasEstimationLoading || !gasEstimation) && connection.isConnectedWithPrivy && !feeDelegation?.delegatorUrl; @@ -196,7 +206,8 @@ export const ChooseNameSummaryContent = ({ ); // hasEnoughBalance is now determined by the hook itself - const hasEnoughBalance = !!usedGasToken && !gasEstimationError; + const hasEnoughBalance = + KIT_PAYS_GAS || (!!usedGasToken && !gasEstimationError); // Auto-fallback: if the selected token cannot cover fees (estimation error), // clear selection to re-estimate across all available tokens @@ -256,7 +267,7 @@ export const ChooseNameSummaryContent = ({ )} - {connection.isConnectedWithPrivy && ( + {!KIT_PAYS_GAS && connection.isConnectedWithPrivy && ( (null); + // VeChain pays gas for profile updates via the kit-sponsored delegator + // (see useUpdateTextRecord). Skip the gas-token UI and "do you have + // enough VTHO" check so users with no gas tokens can still proceed. + const KIT_PAYS_GAS = true; + const shouldEstimateGas = + !KIT_PAYS_GAS && preferences.availableGasTokens.length > 0 && (connection.isConnectedWithPrivy || connection.isConnectedWithVeChain) && @@ -226,6 +232,7 @@ export const CustomizationSummaryContent = ({ }); const usedGasToken = gasEstimation?.usedToken; const disableConfirmButtonDuringEstimation = + !KIT_PAYS_GAS && (gasEstimationLoading || !gasEstimation) && connection.isConnectedWithPrivy && !feeDelegation?.delegatorUrl; @@ -240,7 +247,8 @@ export const CustomizationSummaryContent = ({ ); // hasEnoughBalance is now determined by the hook itself - const hasEnoughBalance = !!usedGasToken && !gasEstimationError; + const hasEnoughBalance = + KIT_PAYS_GAS || (!!usedGasToken && !gasEstimationError); // Auto-fallback: if the selected token cannot cover fees (estimation error), // clear selection to re-estimate across all available tokens @@ -377,7 +385,7 @@ export const CustomizationSummaryContent = ({ renderField(t('Website'), changes.website)} {changes.email && renderField(t('Email'), changes.email)} - {connection.isConnectedWithPrivy && ( + {!KIT_PAYS_GAS && connection.isConnectedWithPrivy && ( ( + null, + ); + const effectiveAmount = adjustedAmount ?? amount; + // Get the final image URL const toImageSrc = useMemo(() => { if (avatar) { @@ -111,9 +125,12 @@ export const SendTokenSummaryContent = ({ description: t( '{{amount}} {{symbol}} is on its way to {{recipient}}.', { - amount: Number(amount).toLocaleString(undefined, { - maximumFractionDigits: 6, - }), + amount: Number(effectiveAmount).toLocaleString( + undefined, + { + maximumFractionDigits: 6, + }, + ), symbol: selectedToken.symbol, recipient: recipientLabel, }, @@ -134,7 +151,7 @@ export const SendTokenSummaryContent = ({ t, isolatedView, closeAccountModal, - amount, + effectiveAmount, selectedToken.symbol, resolvedDomain, resolvedAddress, @@ -154,7 +171,7 @@ export const SendTokenSummaryContent = ({ } = useTransferERC20({ fromAddress: account?.address ?? '', receiverAddress: resolvedAddress || toAddressOrDomain, - amount, + amount: effectiveAmount, tokenAddress: selectedToken.address, tokenName: selectedToken.symbol, onError: (error) => { @@ -172,7 +189,7 @@ export const SendTokenSummaryContent = ({ } = useTransferVET({ fromAddress: account?.address ?? '', receiverAddress: resolvedAddress || toAddressOrDomain, - amount, + amount: effectiveAmount, onError: (error) => { handleError(error ?? ''); }, @@ -256,10 +273,18 @@ export const SendTokenSummaryContent = ({ tokens: selectedGasToken ? [selectedGasToken] : preferences.availableGasTokens, // Use selected token or all available - sendingAmount: amount, + sendingAmount: effectiveAmount, sendingTokenSymbol: selectedToken.symbol, enabled: shouldEstimateGas && !!feeDelegation?.genericDelegatorUrl, }); + + // Per-token gas costs (independent of which token the iteration picks), + // used by the auto-adjust salvage below. + const { data: allTokenCosts } = useEstimateAllTokens({ + clauses: selectedToken.symbol === 'VET' ? vetClauses : erc20Clauses, + tokens: preferences.availableGasTokens, + enabled: shouldEstimateGas && !!feeDelegation?.genericDelegatorUrl, + }); const usedGasToken = gasEstimation?.usedToken; const disableConfirmButtonDuringEstimation = (gasEstimationLoading || !gasEstimation) && @@ -288,6 +313,46 @@ export const SendTokenSummaryContent = ({ } }, [gasEstimationError, selectedGasToken]); + // VeWorld-style auto-adjust: when the iteration cannot find any gas + // token whose balance covers gas + the sending amount, AND the + // sending token is itself one of the available gas tokens with + // enough balance for the gas alone, drop the amount down to + // (balance - gas * SAFETY_FACTOR) so the tx goes through. Mirrors + // veworld-mobile's SummaryScreen.tsx co-spend handling. + const ADJUST_SAFETY_FACTOR = 1.05; + React.useEffect(() => { + // Don't loop: once adjusted, we stop re-evaluating. + if (adjustedAmount !== null) return; + if (!gasEstimationError) return; + if (!allTokenCosts) return; + const sendingSymbol = selectedToken.symbol as GasTokenType; + if ( + !preferences.availableGasTokens.includes(sendingSymbol as never) + ) { + return; + } + const costEntry = allTokenCosts[sendingSymbol]; + if (!costEntry || costEntry.loading || costEntry.cost <= 0) return; + const balance = Number(selectedToken.balance); + const gasReserve = costEntry.cost * ADJUST_SAFETY_FACTOR; + const maxSpendable = balance - gasReserve; + if (maxSpendable <= 0) return; + if (Number(amount) <= maxSpendable) return; + // Floor to 4 decimals so the displayed number doesn't show + // floating-point noise; the underlying parseUnits handles the + // rest at full precision. + const rounded = Math.floor(maxSpendable * 10000) / 10000; + if (rounded <= 0) return; + setAdjustedAmount(rounded.toString()); + }, [ + adjustedAmount, + gasEstimationError, + allTokenCosts, + selectedToken, + preferences.availableGasTokens, + amount, + ]); + return ( <> @@ -346,6 +411,36 @@ export const SendTokenSummaryContent = ({ /> )} + {adjustedAmount !== null && ( + + + {t( + 'Amount adjusted from {{original}} to {{adjusted}} {{symbol}} to cover the transaction fee.', + { + original: Number( + amount, + ).toLocaleString(undefined, { + maximumFractionDigits: 4, + }), + adjusted: Number( + adjustedAmount, + ).toLocaleString(undefined, { + maximumFractionDigits: 4, + }), + symbol: selectedToken.symbol, + }, + )} + + + )} + - {Number(amount).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}{' '} + {Number(effectiveAmount).toLocaleString( + undefined, + { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }, + )}{' '} {selectedToken.symbol} diff --git a/packages/vechain-kit/src/components/common/GasFeeSummary.tsx b/packages/vechain-kit/src/components/common/GasFeeSummary.tsx index 07b3368da..716261d13 100644 --- a/packages/vechain-kit/src/components/common/GasFeeSummary.tsx +++ b/packages/vechain-kit/src/components/common/GasFeeSummary.tsx @@ -44,7 +44,7 @@ export const GasFeeSummary: React.FC = ({ userSelectedToken, }: GasFeeSummaryProps) => { const { t } = useTranslation(); - const { feeDelegation } = useVeChainKitConfig(); + const { feeDelegation, darkMode: isDark } = useVeChainKitConfig(); const { connection, account } = useWallet(); const { preferences, reorderTokenPriority } = useGasTokenSelection(); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -52,6 +52,13 @@ export const GasFeeSummary: React.FC = ({ const textPrimary = useToken('colors', 'vechain-kit-text-primary'); const textSecondary = useToken('colors', 'vechain-kit-text-secondary'); + // Subtle hover surface — lighten in dark mode, darken in light mode. + // Previously this used `textSecondary` as the hover bg, which matches + // the button's text color and made the label disappear on hover. + const hoverBg = isDark + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.04)'; + const [tokenEstimations, setTokenEstimations] = useState< Record >(() => { @@ -281,7 +288,9 @@ export const GasFeeSummary: React.FC = ({ color={textSecondary} borderColor={textSecondary} _hover={{ - bg: textSecondary, + bg: hoverBg, + color: textPrimary, + borderColor: textPrimary, }} leftIcon={React.cloneElement( TOKEN_LOGO_COMPONENTS[ diff --git a/packages/vechain-kit/src/components/common/GasFeeTokenSelector.tsx b/packages/vechain-kit/src/components/common/GasFeeTokenSelector.tsx index 500a945de..8845a0344 100644 --- a/packages/vechain-kit/src/components/common/GasFeeTokenSelector.tsx +++ b/packages/vechain-kit/src/components/common/GasFeeTokenSelector.tsx @@ -19,6 +19,7 @@ import { GasTokenType } from '@/types/gasToken'; import { SUPPORTED_GAS_TOKENS, TOKEN_LOGO_COMPONENTS } from '@/utils/constants'; import { formatGasCost } from '@/types/gasEstimation'; import { useTokenBalances } from '@/hooks'; +import { useVeChainKitConfig } from '@/providers'; import { BaseModal } from './BaseModal'; interface GasFeeTokenSelectorProps { @@ -41,6 +42,7 @@ export const GasFeeTokenSelector = ({ walletAddress, }: GasFeeTokenSelectorProps) => { const { t } = useTranslation(); + const { darkMode: isDark } = useVeChainKitConfig(); const { balances } = useTokenBalances(walletAddress); const [tempSelectedToken, setTempSelectedToken] = React.useState(selectedToken); @@ -48,11 +50,28 @@ export const GasFeeTokenSelector = ({ const textPrimary = useToken('colors', 'vechain-kit-text-primary'); const textSecondary = useToken('colors', 'vechain-kit-text-secondary'); - const textTertiary = useToken('colors', 'vechain-kit-text-tertiary'); const errorColor = useToken('colors', 'vechain-kit-error'); + // Hover / selected surfaces — alpha overlays on the modal bg. + // Chakra's whiteAlpha/blackAlpha tokens aren't wired to the kit's + // custom darkMode flag (we don't use Chakra color mode), so we pick + // the alpha overlay manually. Selected sits a notch above hover so + // the active row reads as picked without yelling — previously this + // row used `textTertiary` (a text colour: a near-white at 50% in + // dark mode, mid-grey #718096 in light mode), which was way too + // strong as a background. + const hoverBg = isDark + ? 'rgba(255, 255, 255, 0.08)' + : 'rgba(0, 0, 0, 0.04)'; + const hoverBorder = isDark + ? 'rgba(255, 255, 255, 0.16)' + : 'rgba(0, 0, 0, 0.12)'; + const selectedBg = isDark + ? 'rgba(255, 255, 255, 0.12)' + : 'rgba(0, 0, 0, 0.06)'; + const itemBg = (selected: boolean) => - selected ? textTertiary : 'transparent'; + selected ? selectedBg : 'transparent'; const itemBorderColor = (selected: boolean) => selected ? textPrimary : 'transparent'; @@ -127,12 +146,14 @@ export const GasFeeTokenSelector = ({ _hover={{ backgroundColor: insufficient ? itemBg(isSelected) - : textSecondary - ? '#ffffff12' - : textSecondary, + : isSelected + ? itemBg(isSelected) + : hoverBg, borderColor: insufficient ? itemBorderColor(isSelected) - : textSecondary, + : isSelected + ? itemBorderColor(isSelected) + : hoverBorder, }} opacity={insufficient ? 0.5 : 1} onClick={() => diff --git a/packages/vechain-kit/src/constants/urls.ts b/packages/vechain-kit/src/constants/urls.ts index b9ada4e5d..5a9e4c85e 100644 --- a/packages/vechain-kit/src/constants/urls.ts +++ b/packages/vechain-kit/src/constants/urls.ts @@ -34,6 +34,15 @@ export const GENERIC_DELEGATOR_MAINNET_URL = export const GENERIC_DELEGATOR_TESTNET_URL = 'https://testnet.delegator.vechain.org'; +// VeChain-sponsored fee delegator used to subsidize kit-managed onboarding +// actions (claim a domain, update profile records) so users without any +// gas tokens can still complete first-time setup. The user signs as usual; +// VeChain pays the gas. Each URL is a vechain.energy sponsor endpoint. +export const KIT_SPONSORED_DELEGATOR_MAINNET_URL = + 'https://sponsor.vechain.energy/by/1060'; +export const KIT_SPONSORED_DELEGATOR_TESTNET_URL = + 'https://sponsor-testnet.vechain.energy/by/221'; + // Explorers export const VECHAIN_EXPLORER_BASE_URL = 'https://explore.vechain.org'; export const VECHAIN_EXPLORER_TESTNET_BASE_URL = diff --git a/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVeWorldSubdomain.ts b/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVeWorldSubdomain.ts index 31a3d387d..26237c152 100644 --- a/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVeWorldSubdomain.ts +++ b/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVeWorldSubdomain.ts @@ -11,7 +11,7 @@ import { import { useQueryClient } from '@tanstack/react-query'; import { getConfig } from '@/config'; import { useVeChainKitConfig, VeChainKitConfig } from '@/providers'; -import { humanAddress } from '@/utils'; +import { getKitSponsoredDelegatorUrl, humanAddress } from '@/utils'; import { ethers } from 'ethers'; import { useRefreshMetadata } from '../wallet/useRefreshMetadata'; import { invalidateAndRefetchDomainQueries } from './utils/domainQueryUtils'; @@ -199,7 +199,12 @@ export const useClaimVeWorldSubdomain = ({ ...result, clauses, sendTransaction: async () => { - return result.sendTransaction(clauses()); + // Route through VeChain's sponsored delegator so new users + // without VTHO / B3TR can still claim a veworld.vet subdomain. + return result.sendTransaction( + clauses(), + getKitSponsoredDelegatorUrl(), + ); }, }; }; diff --git a/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVetDomain.ts b/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVetDomain.ts index 61f081354..89b927d4d 100644 --- a/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVetDomain.ts +++ b/packages/vechain-kit/src/hooks/api/vetDomains/useClaimVetDomain.ts @@ -11,7 +11,7 @@ import { getConfig } from '@/config'; import { useVeChainKitConfig, VeChainKitConfig } from '@/providers'; import { ethers } from 'ethers'; import { invalidateAndRefetchDomainQueries } from './utils/domainQueryUtils'; -import { humanAddress } from '@/utils'; +import { getKitSponsoredDelegatorUrl, humanAddress } from '@/utils'; import { Wallet } from '@/types'; import { TransactionClause } from '@vechain/sdk-core'; @@ -147,7 +147,12 @@ export const useClaimVetDomain = ({ ...result, clauses, sendTransaction: async () => { - return result.sendTransaction(clauses()); + // Route through VeChain's sponsored delegator so new users without + // VTHO / B3TR can still claim a domain. + return result.sendTransaction( + clauses(), + getKitSponsoredDelegatorUrl(), + ); }, }; }; diff --git a/packages/vechain-kit/src/hooks/api/vetDomains/useUpdateTextRecord.ts b/packages/vechain-kit/src/hooks/api/vetDomains/useUpdateTextRecord.ts index 26fcd00c2..fb294e931 100644 --- a/packages/vechain-kit/src/hooks/api/vetDomains/useUpdateTextRecord.ts +++ b/packages/vechain-kit/src/hooks/api/vetDomains/useUpdateTextRecord.ts @@ -2,6 +2,7 @@ import { Interface, namehash } from 'ethers'; import { useCallback } from 'react'; import { UseSendTransactionReturnValue, useSendTransaction } from '@/hooks'; import { TransactionClause } from '@vechain/sdk-core'; +import { getKitSponsoredDelegatorUrl } from '@/utils'; const nameInterface = new Interface([ 'function resolver(bytes32 node) returns (address resolverAddress)', @@ -87,7 +88,12 @@ export const useUpdateTextRecord = ({ ...result, clauses: buildClausesCallback, // Return the callback directly sendTransaction: async (params: UpdateTextRecordVariables[]) => { - return result.sendTransaction(buildClausesCallback(params)); + // Route through VeChain's sponsored delegator so users without + // VTHO / B3TR can still update their profile records. + return result.sendTransaction( + buildClausesCallback(params), + getKitSponsoredDelegatorUrl(), + ); }, }; }; diff --git a/packages/vechain-kit/src/hooks/generic-delegator/useEstimateAllTokens.ts b/packages/vechain-kit/src/hooks/generic-delegator/useEstimateAllTokens.ts index 430ef9cc6..1d7718cb4 100644 --- a/packages/vechain-kit/src/hooks/generic-delegator/useEstimateAllTokens.ts +++ b/packages/vechain-kit/src/hooks/generic-delegator/useEstimateAllTokens.ts @@ -5,7 +5,8 @@ import { useWallet, estimateGas, useGetAccountVersion, - computeCorrectedGasTokenCost, + computeCorrectedTotalGasNoFeePayer, + convertGasToGasTokenAmount, } from '@/hooks'; import { useVeChainKitConfig } from '@/providers'; import { TransactionClause } from '@vechain/sdk-core'; @@ -46,6 +47,15 @@ export const useEstimateAllTokens = ({ { cost: number; loading: boolean; error?: string } > = {} as any; + // Local gas estimate is token-agnostic — compute once, + // bounded by a timeout so a slow node can't hang the UI. + const totalGasNoFeePayer = await computeCorrectedTotalGasNoFeePayer({ + thor, + clauses, + smartAccountAddress: smartAccount?.address ?? '', + version: smartAccountVersion?.version ?? 0, + }); + await Promise.all( tokens.map(async (token) => { try { @@ -56,14 +66,14 @@ export const useEstimateAllTokens = ({ token, 'medium', ); - const correctedCost = await computeCorrectedGasTokenCost({ - thor, - clauses, - smartAccountAddress: smartAccount?.address ?? '', - version: smartAccountVersion?.version ?? 0, - estimationResponse: estimation, - gasToken: token, - }); + const correctedCost = + totalGasNoFeePayer !== null + ? convertGasToGasTokenAmount({ + totalGasNoFeePayer, + gasToken: token, + estimationResponse: estimation, + }) + : (estimation.transactionCost ?? 0) * 2; estimates[token] = { cost: correctedCost || 0, loading: false, diff --git a/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegator.ts b/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegator.ts index 60b49a8c3..d1edc8aef 100644 --- a/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegator.ts +++ b/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegator.ts @@ -134,69 +134,87 @@ export const estimateAndBuildTxBody = async ( }; /** - * Compute the gas-token amount the smart account must transfer to the - * generic delegator's deposit account to cover the transaction. - * - * The delegator's `/estimate/clauses` endpoint simulates the user's raw - * clauses as if executed directly by the smart account, with no - * `executeWithAuthorization` wrapper and no embedded-wallet signature, so - * it under-estimates (and for NFT-heavy clauses can revert outright). We - * trust its **rate** information (the gas-token-per-gas ratio is just a - * market price and doesn't depend on the gas amount) but recompute the - * gas number locally — including the wrapper overhead, fee-payer overhead, - * and a 10% safety multiplier — and reapply the rate. + * Hard timeout (ms) applied to the local Thor gas estimation. Stops the + * fee-estimation UI from hanging if the node is slow or unreachable. + */ +export const GENERIC_DELEGATOR_LOCAL_ESTIMATE_TIMEOUT_MS = 6_000; + +const withTimeout = ( + promise: Promise, + ms: number, +): Promise => + new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), ms); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch(() => { + clearTimeout(timer); + resolve(null); + }); + }); + +/** + * Run the local Thor gas estimation for the user's raw clauses (caller = + * smart account) and return the gas-token-agnostic total: raw gas + wrapper + * overhead, padded by the safety multiplier. Returns `null` if the + * simulation reverts, times out, or returns a non-positive number — the + * caller should then fall back to a delegator-derived estimate. * - * @param thor - Thor client used for the local gas estimation - * @param clauses - The user's raw clauses (NOT the wrapped ones) - * @param smartAccountAddress - Caller used during simulation - * @param version - Smart account version (selects the wrapper overhead) - * @param estimationResponse - The delegator's /estimate/clauses response - * @param gasToken - The selected gas token (selects the fee-payer overhead) - * @returns The corrected gas-token amount as a decimal (human-readable) + * The output is independent of the gas token, so callers iterating over + * a token-priority list should call this once and reuse the result. */ -export const computeCorrectedGasTokenCost = async ({ +export const computeCorrectedTotalGasNoFeePayer = async ({ thor, clauses, smartAccountAddress, version, - estimationResponse, - gasToken, + timeoutMs = GENERIC_DELEGATOR_LOCAL_ESTIMATE_TIMEOUT_MS, }: { thor: ThorClient; clauses: TransactionClause[]; smartAccountAddress: string; version: number; - estimationResponse: EstimationResponse; - gasToken: GasTokenType; -}): Promise => { - const fallbackCost = (estimationResponse.transactionCost ?? 0) * 2; - - let rawGas: number; - try { - const rawGasResult = await thor.gas.estimateGas( - clauses, - smartAccountAddress, - ); - rawGas = rawGasResult.totalGas; - } catch { - return fallbackCost; - } + timeoutMs?: number; +}): Promise => { + const rawGasResult = await withTimeout( + thor.gas.estimateGas(clauses, smartAccountAddress), + timeoutMs, + ); + const rawGas = rawGasResult?.totalGas; if (!rawGas || rawGas <= 0) { - return fallbackCost; + return null; } const wrapperOverhead = getWrapperOverheadGas(version); - const feePayerOverhead = GENERIC_DELEGATOR_FEE_PAYER_OVERHEAD_GAS[gasToken]; - - const totalGas = Math.ceil( - (rawGas + wrapperOverhead) * GENERIC_DELEGATOR_GAS_SAFETY_MULTIPLIER + - feePayerOverhead, + return Math.ceil( + (rawGas + wrapperOverhead) * GENERIC_DELEGATOR_GAS_SAFETY_MULTIPLIER, ); +}; + +/** + * Convert a gas number (without the fee-payer transfer overhead) into the + * gas-token amount required to cover the transaction, using the per-gas + * rate returned by the delegator's `/estimate/clauses` response (which is + * accurate even when the absolute gas number from the same response is + * not). Adds the gas-token-specific fee-payer transfer overhead. + */ +export const convertGasToGasTokenAmount = ({ + totalGasNoFeePayer, + gasToken, + estimationResponse, +}: { + totalGasNoFeePayer: number; + gasToken: GasTokenType; + estimationResponse: EstimationResponse; +}): number => { + const totalGas = + totalGasNoFeePayer + + GENERIC_DELEGATOR_FEE_PAYER_OVERHEAD_GAS[gasToken]; - // Derive the gas-token-per-gas rate from the delegator response. The - // rate is price-driven and independent of the gas amount, so it - // remains accurate even when the delegator's gas number is wrong. let gasTokenPerGas = 0; if ( estimationResponse.transactionCost && @@ -210,18 +228,67 @@ export const computeCorrectedGasTokenCost = async ({ const rate = estimationResponse.rate ?? 1; const serviceFee = estimationResponse.serviceFee ?? 0; gasTokenPerGas = - estimationResponse.vthoPerGasAtSpeed * - rate * - (1 + serviceFee); + estimationResponse.vthoPerGasAtSpeed * rate * (1 + serviceFee); } if (!gasTokenPerGas || gasTokenPerGas <= 0) { - return fallbackCost; + return 0; } return totalGas * gasTokenPerGas; }; +/** + * Compute the gas-token amount the smart account must transfer to the + * generic delegator's deposit account to cover the transaction. + * + * The delegator's `/estimate/clauses` endpoint simulates the user's raw + * clauses as if executed directly by the smart account, with no + * `executeWithAuthorization` wrapper and no embedded-wallet signature, so + * it under-estimates (and for NFT-heavy clauses can revert outright). We + * trust its **rate** information (the gas-token-per-gas ratio is just a + * market price and doesn't depend on the gas amount) but recompute the + * gas number locally — including the wrapper overhead, fee-payer overhead, + * and a 10% safety multiplier — and reapply the rate. + */ +export const computeCorrectedGasTokenCost = async ({ + thor, + clauses, + smartAccountAddress, + version, + estimationResponse, + gasToken, + timeoutMs, +}: { + thor: ThorClient; + clauses: TransactionClause[]; + smartAccountAddress: string; + version: number; + estimationResponse: EstimationResponse; + gasToken: GasTokenType; + timeoutMs?: number; +}): Promise => { + const fallbackCost = (estimationResponse.transactionCost ?? 0) * 2; + + const totalGasNoFeePayer = await computeCorrectedTotalGasNoFeePayer({ + thor, + clauses, + smartAccountAddress, + version, + timeoutMs, + }); + if (totalGasNoFeePayer === null) { + return fallbackCost; + } + + const cost = convertGasToGasTokenAmount({ + totalGasNoFeePayer, + gasToken, + estimationResponse, + }); + return cost > 0 ? cost : fallbackCost; +}; + /** * Sign the final transaction with the given private key and signature * returned by the generic delegator. diff --git a/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegatorFeeEstimation.ts b/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegatorFeeEstimation.ts index 22063904d..e8f153c37 100644 --- a/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegatorFeeEstimation.ts +++ b/packages/vechain-kit/src/hooks/generic-delegator/useGenericDelegatorFeeEstimation.ts @@ -8,7 +8,8 @@ import { useTokenBalances, useGasTokenSelection, useGetAccountVersion, - computeCorrectedGasTokenCost, + computeCorrectedTotalGasNoFeePayer, + convertGasToGasTokenAmount, } from '@/hooks'; import { useVeChainKitConfig } from '@/providers'; import { TransactionClause } from '@vechain/sdk-core'; @@ -48,6 +49,18 @@ export const useGenericDelegatorFeeEstimation = ({ return useQuery({ queryKey, queryFn: async () => { + // Run the local Thor gas estimate ONCE — the gas number is + // token-agnostic so we shouldn't pay for it inside the loop. + // Bounded by a timeout so a slow / unreachable node can't + // hang the fee-estimation UI forever; on timeout/failure each + // token falls back to a delegator-derived value. + const totalGasNoFeePayer = await computeCorrectedTotalGasNoFeePayer({ + thor, + clauses: clauses as TransactionClause[], + smartAccountAddress: smartAccount?.address ?? '', + version: smartAccountVersion?.version ?? 0, + }); + let lastError: Error | null = null; // Try each token in sequence until one succeeds AND has sufficient balance for (const token of tokens) { @@ -61,16 +74,17 @@ export const useGenericDelegatorFeeEstimation = ({ ); // The delegator's `transactionCost` is computed from a // simulation that omits the smart-account auth wrapper - // and can underestimate (or revert outright). Recompute - // locally so the UI agrees with the actual send path. - const gasCost = await computeCorrectedGasTokenCost({ - thor, - clauses: clauses as TransactionClause[], - smartAccountAddress: smartAccount?.address ?? '', - version: smartAccountVersion?.version ?? 0, - estimationResponse: estimation, - gasToken: token as GasTokenType, - }); + // and can underestimate (or revert outright). Reapply the + // delegator's per-gas rate to our locally-estimated gas + // number so the UI agrees with the actual send path. + const gasCost = + totalGasNoFeePayer !== null + ? convertGasToGasTokenAmount({ + totalGasNoFeePayer, + gasToken: token as GasTokenType, + estimationResponse: estimation, + }) + : (estimation.transactionCost ?? 0) * 2; const tokenBalance = Number(balances.find(t => t.symbol === token)?.balance || 0); // If sending the same token as gas token, need balance for both // If no sendingAmount is provided, we're only checking for gas fees diff --git a/packages/vechain-kit/src/languages/en.json b/packages/vechain-kit/src/languages/en.json index 6858f0582..cc3a0357f 100644 --- a/packages/vechain-kit/src/languages/en.json +++ b/packages/vechain-kit/src/languages/en.json @@ -500,5 +500,6 @@ "Tokens sent": "Tokens sent", "{{amount}} {{symbol}} is on its way to {{recipient}}.": "{{amount}} {{symbol}} is on its way to {{recipient}}.", "NFT sent": "NFT sent", - "{{nft}} is now in {{recipient}}’s wallet.": "{{nft}} is now in {{recipient}}’s wallet." + "{{nft}} is now in {{recipient}}’s wallet.": "{{nft}} is now in {{recipient}}’s wallet.", + "Amount adjusted from {{original}} to {{adjusted}} {{symbol}} to cover the transaction fee.": "Amount adjusted from {{original}} to {{adjusted}} {{symbol}} to cover the transaction fee." } diff --git a/packages/vechain-kit/src/providers/VeChainKitProvider.tsx b/packages/vechain-kit/src/providers/VeChainKitProvider.tsx index 3feac5bd4..e7cce26d9 100644 --- a/packages/vechain-kit/src/providers/VeChainKitProvider.tsx +++ b/packages/vechain-kit/src/providers/VeChainKitProvider.tsx @@ -290,29 +290,30 @@ const validateConfig = ( }; } - // Check if fee delegation is required based on conditions - const requiresFeeDelegation = - validatedProps.privy !== undefined || - validatedProps.loginMethods?.some( - (method) => - method.method === 'vechain' || method.method === 'ecosystem', - ); - - // Validate fee delegation - if (requiresFeeDelegation) { - if (!validatedProps.feeDelegation) { - validatedProps.feeDelegation = { - genericDelegatorUrl: getGenericDelegatorUrl(), - }; - } else { - if ( - !validatedProps.feeDelegation.delegatorUrl && - !validatedProps.feeDelegation.genericDelegatorUrl - ) { - validatedProps.feeDelegation.genericDelegatorUrl = - getGenericDelegatorUrl(); - } - } + // Auto-inject the default generic-delegator endpoint whenever the + // consumer hasn't picked their own delegation strategy. + // + // We used to gate this on `privy !== undefined || loginMethods includes + // 'vechain'/'ecosystem'`, but after #620 every Google/Apple/Twitter/etc. + // button silently falls back to the VeChain whitelabel cross-app host + // when no host-supplied `privy` prop exists -- meaning users can land + // on a smart-account wallet without ever listing 'vechain' in + // loginMethods. The old gate then left `feeDelegation` undefined, and + // `useGenericDelegatorFeeEstimation` / `useEstimateAllTokens` stayed + // permanently disabled (their `enabled` depends on + // `feeDelegation?.genericDelegatorUrl`). Always seed the default so + // smart-account users have a working fee-delegation path; dapp-kit + // wallets simply ignore it. + if (!validatedProps.feeDelegation) { + validatedProps.feeDelegation = { + genericDelegatorUrl: getGenericDelegatorUrl(), + }; + } else if ( + !validatedProps.feeDelegation.delegatorUrl && + !validatedProps.feeDelegation.genericDelegatorUrl + ) { + validatedProps.feeDelegation.genericDelegatorUrl = + getGenericDelegatorUrl(); } // Validate network - always ensure we have a valid network configuration diff --git a/packages/vechain-kit/src/theme/input.ts b/packages/vechain-kit/src/theme/input.ts new file mode 100644 index 000000000..1c0226a0b --- /dev/null +++ b/packages/vechain-kit/src/theme/input.ts @@ -0,0 +1,34 @@ +import { + defineStyle, + defineStyleConfig, + createMultiStyleConfigHelpers, +} from '@chakra-ui/react'; +import { inputAnatomy } from '@chakra-ui/anatomy'; + +/** + * Force a 16px font size on form inputs so mobile Safari doesn't + * auto-zoom on focus. iOS zooms the page whenever a focused input's + * computed font-size is below 16px CSS pixels — and the kit's `md` + * font token resolves to 14px (see tokens.ts), so the Chakra default + * Input/Textarea would land at 14px and trigger the zoom. We pin the + * size in absolute pixels rather than `lg` so future token tweaks + * can't accidentally regress this. + */ +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(inputAnatomy.keys); + +export const getInputTheme = () => + defineMultiStyleConfig({ + baseStyle: definePartsStyle({ + field: { + fontSize: '16px', + }, + }), + }); + +export const getTextareaTheme = () => + defineStyleConfig({ + baseStyle: defineStyle({ + fontSize: '16px', + }), + }); diff --git a/packages/vechain-kit/src/theme/theme.tsx b/packages/vechain-kit/src/theme/theme.tsx index 59961b31e..795506f78 100644 --- a/packages/vechain-kit/src/theme/theme.tsx +++ b/packages/vechain-kit/src/theme/theme.tsx @@ -7,6 +7,7 @@ import { getCloseButtonTheme, } from './button'; import { getPopoverTheme } from './popover'; +import { getInputTheme, getTextareaTheme } from './input'; import { VechainKitThemeConfig, ThemeTokens, @@ -55,6 +56,8 @@ const getThemeConfig = ( IconButton: getIconButtonTheme(tokens), CloseButton: getCloseButtonTheme(tokens), Popover: getPopoverTheme(tokens), + Input: getInputTheme(), + Textarea: getTextareaTheme(), }, // No global styles - fonts will be applied via component-level styles // to ensure they only affect VeChain Kit components, not the host app diff --git a/packages/vechain-kit/src/utils/constants.tsx b/packages/vechain-kit/src/utils/constants.tsx index 676e92ff2..98808da44 100644 --- a/packages/vechain-kit/src/utils/constants.tsx +++ b/packages/vechain-kit/src/utils/constants.tsx @@ -11,6 +11,8 @@ import { getConfig } from '@/config'; import { GENERIC_DELEGATOR_MAINNET_URL, GENERIC_DELEGATOR_TESTNET_URL, + KIT_SPONSORED_DELEGATOR_MAINNET_URL, + KIT_SPONSORED_DELEGATOR_TESTNET_URL, VECHAIN_KIT_WEBSITE_BASE_URL, COINMARKETCAP_STATIC_BASE_URL, VECHAIN_TOKEN_REGISTRY_ASSETS_BASE_URL, @@ -110,6 +112,21 @@ export const getGenericDelegatorUrl = () => { : `${GENERIC_DELEGATOR_TESTNET_URL}/api/v1/`; // or url to your delegator }; +/** + * VeChain-sponsored fee-delegator endpoint used by kit-managed onboarding + * transactions (claim a VET domain, set a primary name, update profile + * text records, etc.) so first-time users with no gas tokens can still + * complete these flows. VeChain pays the gas via the configured + * sponsor.vechain.energy endpoint; consumer dApps don't need to set up + * their own fee delegation to support these kit features. + */ +export const getKitSponsoredDelegatorUrl = () => { + const env = getENV(); + return env.isProduction + ? KIT_SPONSORED_DELEGATOR_MAINNET_URL + : KIT_SPONSORED_DELEGATOR_TESTNET_URL; +}; + export type PrivyEcosystemApp = { id: string; name: string;