diff --git a/.changeset/deep-toys-enter.md b/.changeset/deep-toys-enter.md new file mode 100644 index 000000000..7c5d4f198 --- /dev/null +++ b/.changeset/deep-toys-enter.md @@ -0,0 +1,7 @@ +--- +'@relayprotocol/relay-kit-hooks': patch +'@relayprotocol/relay-sdk': patch +'@relayprotocol/relay-kit-ui': patch +--- + +Add gas sponsorship functionality diff --git a/demo/components/providers/RelayKitProviderWrapper.tsx b/demo/components/providers/RelayKitProviderWrapper.tsx index af655e3ef..044acae79 100644 --- a/demo/components/providers/RelayKitProviderWrapper.tsx +++ b/demo/components/providers/RelayKitProviderWrapper.tsx @@ -46,7 +46,8 @@ export const RelayKitProviderWrapper: FC<{ websocket: { enabled: websocketsEnabled, url: MAINNET_RELAY_WS - } + }, + secureBaseUrl: process.env.NEXT_PUBLIC_RELAY_SECURE_API_URL }} > {children} diff --git a/demo/pages/api/secure/[...path].ts b/demo/pages/api/secure/[...path].ts new file mode 100644 index 000000000..15bdc741f --- /dev/null +++ b/demo/pages/api/secure/[...path].ts @@ -0,0 +1,44 @@ +import { paths } from '@relayprotocol/relay-sdk' +import type { NextApiRequest, NextApiResponse } from 'next' + +type QuoteResponse = + paths['/quote']['post']['responses']['200']['content']['application/json'] + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { query } = req + + const url = new URL('https://api.relay.link/quote') + + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value as string) + } + + // Here you can add any checks you'd like to before fetching the gas subsidized quote + // You can do things like: + // - Check if the tokens are eligible for gas sponsorship + // - Check if the user is likely a bot + + const body: paths['/quote']['post']['requestBody']['content']['application/json'] = + { + ...req.body, + subsidizeFees: true, + maxSubsidizationAmount: '1000000000000000000' + } + body.referrer = 'relay.link' + + const response = await fetch(url.href, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': process.env.NEXT_RELAY_API_KEY as string + }, + body: JSON.stringify(body) + }) + + const responseData = await response.json() + + res.status(response.status).json(responseData as QuoteResponse) +} diff --git a/demo/pages/ui/swap.tsx b/demo/pages/ui/swap.tsx index 944dd787f..7d2aefc12 100644 --- a/demo/pages/ui/swap.tsx +++ b/demo/pages/ui/swap.tsx @@ -51,14 +51,14 @@ const SwapWidgetPage: NextPage = () => { symbol: 'ETH', logoURI: 'https://assets.relay.link/icons/currencies/eth.png' }) - // const [toToken, setToToken] = useState({ - // chainId: 10, - // address: '0xbb586ed34974b15049a876fd5366a4c2d1203115', - // decimals: 18, - // name: 'ETH', - // symbol: 'ETH', - // logoURI: 'https://assets.relay.link/icons/currencies/eth.png', - // }) + const [toToken, setToToken] = useState({ + chainId: 10, + address: '0xbb586ed34974b15049a876fd5366a4c2d1203115', + decimals: 18, + name: 'ETH', + symbol: 'ETH', + logoURI: 'https://assets.relay.link/icons/currencies/eth.png' + }) const { setWalletFilter } = useWalletFilter() const { setShowAuthFlow, primaryWallet } = useDynamicContext() const { theme } = useTheme() @@ -219,10 +219,20 @@ const SwapWidgetPage: NextPage = () => { lockChainId={singleChainMode ? 8453 : undefined} singleChainMode={singleChainMode} supportedWalletVMs={supportedWalletVMs} + sponsoredTokens={ + toToken?.chainId !== 1 + ? [ + '8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + '42161:0xaf88d065e77c8cc2239327c5edb3a432268e5831', + '10:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + '1:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + ] + : undefined + } // popularChainIds={[]} // disableInputAutoFocus={true} - // toToken={toToken} - // setToToken={setToToken} + toToken={toToken} + setToToken={setToToken} // lockToToken={true} // lockFromToken={true} fromToken={fromToken} diff --git a/packages/hooks/src/hooks/useQuote.ts b/packages/hooks/src/hooks/useQuote.ts index 65bd0158d..58ff9a642 100644 --- a/packages/hooks/src/hooks/useQuote.ts +++ b/packages/hooks/src/hooks/useQuote.ts @@ -65,7 +65,8 @@ export default function ( onResponse?: (data: QuoteResponse, options?: QuoteBody) => void, queryOptions?: Partial, onError?: (e: any) => void, - config?: AxiosRequestConfig + config?: AxiosRequestConfig, + baseApiUrl?: string ) { const queryKey = ['useQuote', options] const response = (useQuery as QueryType)({ @@ -75,7 +76,7 @@ export default function ( if (options && client?.source && !options.referrer) { options.referrer = client.source } - const promise = queryQuote(client?.baseApiUrl, options, { + const promise = queryQuote(baseApiUrl ?? client?.baseApiUrl, options, { ...config, headers: { 'relay-sdk-version': client?.version ?? 'unknown', diff --git a/packages/sdk/src/routes/index.ts b/packages/sdk/src/routes/index.ts index d506b19b5..05d884845 100644 --- a/packages/sdk/src/routes/index.ts +++ b/packages/sdk/src/routes/index.ts @@ -14,6 +14,7 @@ export const routes = [ "/execute/permits", "/quote", "/price", + "/execute", "/lives", "/intents/status", "/intents/status/v2", diff --git a/packages/sdk/src/types/api.ts b/packages/sdk/src/types/api.ts index e6d578bb4..6eb52b63f 100644 --- a/packages/sdk/src/types/api.ts +++ b/packages/sdk/src/types/api.ts @@ -376,7 +376,10 @@ export interface paths { source?: string; /** @description Address to send the refund to in the case of failure, if not specified then the receipient address or user address is used */ refundTo?: string; - /** @description Always refund on the origin chain in case of any issues */ + /** + * @deprecated + * @description Always refund on the origin chain in case of any issues + */ refundOnOrigin?: boolean; /** @description Enable this to use the exact input rather than exact output */ useExactInput?: boolean; @@ -553,7 +556,10 @@ export interface paths { source?: string; /** @description Address to send the refund to in the case of failure, if not specified then the receipient address or user address is used */ refundTo?: string; - /** @description Always refund on the origin chain in case of any issues */ + /** + * @deprecated + * @description Always refund on the origin chain in case of any issues + */ refundOnOrigin?: boolean; /** @description Enable this to use the exact input rather than exact output */ useExactInput?: boolean; @@ -825,6 +831,45 @@ export interface paths { amountUsd?: string; minimumAmount?: string; }; + /** + * @description The amount of fees for the request that are subsidized by the request sponsor. Does not include deposit origin gas unless it is a permit based deposit. + * @example { + * "currency": { + * "chainId": 8453, + * "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + * "symbol": "USDC", + * "name": "USD Coin", + * "decimals": 6, + * "metadata": { + * "logoURI": "https://ethereum-optimism.github.io/data/USDC/logo.png", + * "verified": false, + * "isNative": false + * } + * }, + * "amount": "30754920", + * "amountFormatted": "30.75492", + * "amountUsd": "30.901612", + * "minimumAmount": "30454920" + * } + */ + subsidized?: { + currency?: { + chainId?: number; + address?: string; + symbol?: string; + name?: string; + decimals?: number; + metadata?: { + logoURI?: string; + verified?: boolean; + isNative?: boolean; + }; + }; + amount?: string; + amountFormatted?: string; + amountUsd?: string; + minimumAmount?: string; + }; }; breakdown?: { /** @description Amount that will be bridged in the estimated time */ @@ -895,7 +940,10 @@ export interface paths { appFees?: string[]; /** @description Address to send the refund to in the case of failure, if not specified then the receipient address or user address is used */ refundTo?: string; - /** @description Always refund on the origin chain in case of any issues */ + /** + * @deprecated + * @description Always refund on the origin chain in case of any issues + */ refundOnOrigin?: boolean; source?: string; /** @@ -1079,7 +1127,10 @@ export interface paths { appFees?: string[]; /** @description Address to send the refund to in the case of failure, if not specified then the receipient address or user address is used */ refundTo?: string; - /** @description Always refund on the origin chain in case of any issues */ + /** + * @deprecated + * @description Always refund on the origin chain in case of any issues + */ refundOnOrigin?: boolean; source?: string; /** @@ -1344,6 +1395,45 @@ export interface paths { amountUsd?: string; minimumAmount?: string; }; + /** + * @description The amount of fees for the request that are subsidized by the request sponsor. Does not include deposit origin gas unless it is a permit based deposit. + * @example { + * "currency": { + * "chainId": 8453, + * "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + * "symbol": "USDC", + * "name": "USD Coin", + * "decimals": 6, + * "metadata": { + * "logoURI": "https://ethereum-optimism.github.io/data/USDC/logo.png", + * "verified": false, + * "isNative": false + * } + * }, + * "amount": "30754920", + * "amountFormatted": "30.75492", + * "amountUsd": "30.901612", + * "minimumAmount": "30454920" + * } + */ + subsidized?: { + currency?: { + chainId?: number; + address?: string; + symbol?: string; + name?: string; + decimals?: number; + metadata?: { + logoURI?: string; + verified?: boolean; + isNative?: boolean; + }; + }; + amount?: string; + amountFormatted?: string; + amountUsd?: string; + minimumAmount?: string; + }; }; /** * @example { @@ -1440,7 +1530,10 @@ export interface paths { source?: string; /** @description Address to send the refund to in the case of failure, if not specified then the recipient address or user address is used */ refundTo?: string; - /** @description Always refund on the origin chain in case of any issues */ + /** + * @deprecated + * @description Always refund on the origin chain in case of any issues + */ refundOnOrigin?: boolean; /** * @description Enable this to route payments via a forwarder contract. This contract will emit an event when receiving payments before forwarding to the solver. This is needed when depositing from a smart contract as the payment will be an internal transaction and detecting such a transaction requires obtaining the transaction traces. @@ -1720,6 +1813,45 @@ export interface paths { amountUsd?: string; minimumAmount?: string; }; + /** + * @description The amount of fees for the request that are subsidized by the request sponsor. Does not include deposit origin gas unless it is a permit based deposit. + * @example { + * "currency": { + * "chainId": 8453, + * "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + * "symbol": "USDC", + * "name": "USD Coin", + * "decimals": 6, + * "metadata": { + * "logoURI": "https://ethereum-optimism.github.io/data/USDC/logo.png", + * "verified": false, + * "isNative": false + * } + * }, + * "amount": "30754920", + * "amountFormatted": "30.75492", + * "amountUsd": "30.901612", + * "minimumAmount": "30454920" + * } + */ + subsidized?: { + currency?: { + chainId?: number; + address?: string; + symbol?: string; + name?: string; + decimals?: number; + metadata?: { + logoURI?: string; + verified?: boolean; + isNative?: boolean; + }; + }; + amount?: string; + amountFormatted?: string; + amountUsd?: string; + minimumAmount?: string; + }; }; breakdown?: { /** @description Amount that will be swapped in the estimated time */ @@ -1908,6 +2040,7 @@ export interface paths { tradeType: "EXACT_INPUT" | "EXACT_OUTPUT"; referrer?: string; gasLimitForDepositSpecifiedTxs?: number; + originGasOverhead?: number; }; }; }; @@ -2167,6 +2300,45 @@ export interface paths { amountUsd?: string; minimumAmount?: string; }; + /** + * @description The amount of fees for the request that are subsidized by the request sponsor. Does not include deposit origin gas unless it is a permit based deposit. + * @example { + * "currency": { + * "chainId": 8453, + * "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + * "symbol": "USDC", + * "name": "USD Coin", + * "decimals": 6, + * "metadata": { + * "logoURI": "https://ethereum-optimism.github.io/data/USDC/logo.png", + * "verified": false, + * "isNative": false + * } + * }, + * "amount": "30754920", + * "amountFormatted": "30.75492", + * "amountUsd": "30.901612", + * "minimumAmount": "30454920" + * } + */ + subsidized?: { + currency?: { + chainId?: number; + address?: string; + symbol?: string; + name?: string; + decimals?: number; + metadata?: { + logoURI?: string; + verified?: boolean; + isNative?: boolean; + }; + }; + amount?: string; + amountFormatted?: string; + amountUsd?: string; + minimumAmount?: string; + }; }; breakdown?: { /** @description Amount that will be swapped in the estimated time */ @@ -2417,11 +2589,19 @@ export interface paths { r: string; s: string; }[]; + /** @description Additional data needed for specific routes */ + additionalData?: { + /** @description If the request originates from Bitcoin and the user is a P2SH address, the public key is needed to be able to generate the transaction data */ + userPublicKey?: string; + }; referrer?: string; referrerAddress?: string; /** @description Address to send the refund to in the case of failure, if not specified then the recipient address or user address is used */ refundTo?: string; - /** @description Always refund on the origin chain in case of any issues */ + /** + * @deprecated + * @description Always refund on the origin chain in case of any issues + */ refundOnOrigin?: boolean; /** @description If set, the destination fill will include a gas topup to the recipient (only supported for EVM chains if the requested currency is not the gas currency on the destination chain) */ topupGas?: boolean; @@ -2457,6 +2637,8 @@ export interface paths { useDepositAddress?: boolean; /** @description Slippage tolerance for the swap, if not specified then the slippage tolerance is automatically calculated to avoid front-running. This value is in basis points (1/100th of a percent), e.g. 50 for 0.5% slippage */ slippageTolerance?: string; + /** @description Slippage tolerance for destination gas in the event that the deposit occurs after the order deadline, and more gas is required for the solver to execute the destination transaction. */ + latePaymentSlippageTolerance?: string; appFees?: { /** @description Address that will receive the app fee, if not specified then the user address is used */ recipient?: string; @@ -2475,6 +2657,14 @@ export interface paths { includedSwapSources?: string[]; /** @description Swap sources to exclude for swap routing. */ excludedSwapSources?: string[]; + /** @description Swap sources to include for swap routing on origin. */ + includedOriginSwapSources?: string[]; + /** @description Swap sources to include for swap routing on destination. */ + includedDestinationSwapSources?: string[]; + /** @description The gas overhead for the origin chain, this is used to calculate the gas fee for the origin chain when the solver is executing a gasless transaction on the origin chain */ + originGasOverhead?: number; + /** @description The payer to be set for deposit transactions on solana. This account must have enough for fees and rent. */ + depositFeePayer?: string; }; }; }; @@ -2739,6 +2929,45 @@ export interface paths { amountUsd?: string; minimumAmount?: string; }; + /** + * @description The amount of fees for the request that are subsidized by the request sponsor. Does not include deposit origin gas unless it is a permit based deposit. + * @example { + * "currency": { + * "chainId": 8453, + * "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + * "symbol": "USDC", + * "name": "USD Coin", + * "decimals": 6, + * "metadata": { + * "logoURI": "https://ethereum-optimism.github.io/data/USDC/logo.png", + * "verified": false, + * "isNative": false + * } + * }, + * "amount": "30754920", + * "amountFormatted": "30.75492", + * "amountUsd": "30.901612", + * "minimumAmount": "30454920" + * } + */ + subsidized?: { + currency?: { + chainId?: number; + address?: string; + symbol?: string; + name?: string; + decimals?: number; + metadata?: { + logoURI?: string; + verified?: boolean; + isNative?: boolean; + }; + }; + amount?: string; + amountFormatted?: string; + amountUsd?: string; + minimumAmount?: string; + }; }; /** @description A summary of the swap and what the user should expect to happen given an input */ details?: { @@ -2872,6 +3101,24 @@ export interface paths { usd?: string; percent?: string; }; + expandedPriceImpact?: { + /** @description Cost to execute swap or bridge depending on available liquidity. This value can be negative (representing network rewards for improving liquidity distribution) */ + swap?: { + usd?: string; + }; + /** @description Fees paid to cover transaction execution costs */ + execution?: { + usd?: string; + }; + /** @description Fees paid to the protocol */ + relay?: { + usd?: string; + }; + /** @description Fees paid to the app. Currency will be the same as the relayer fee currency. This needs to be claimed later by the app owner and is not immediately distributed to the app */ + app?: { + usd?: string; + }; + }; /** @description The swap rate which is equal to 1 input unit in the output unit, e.g. 1 USDC -> x ETH. This value can fluctuate based on gas and fees. */ rate?: string; slippageTolerance?: { @@ -2975,7 +3222,10 @@ export interface paths { referrer?: string; /** @description Address to send the refund to in the case of failure, if not specified then the recipient address or user address is used */ refundTo?: string; - /** @description Always refund on the origin chain in case of any issues */ + /** + * @deprecated + * @description Always refund on the origin chain in case of any issues + */ refundOnOrigin?: boolean; /** * @description Enable this to route payments via a receiver contract. This contract will emit an event when receiving payments before forwarding to the solver. This is needed when depositing from a smart contract as the payment will be an internal transaction and detecting such a transaction requires obtaining the transaction traces. @@ -3202,6 +3452,45 @@ export interface paths { amountUsd?: string; minimumAmount?: string; }; + /** + * @description The amount of fees for the request that are subsidized by the request sponsor. Does not include deposit origin gas unless it is a permit based deposit. + * @example { + * "currency": { + * "chainId": 8453, + * "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + * "symbol": "USDC", + * "name": "USD Coin", + * "decimals": 6, + * "metadata": { + * "logoURI": "https://ethereum-optimism.github.io/data/USDC/logo.png", + * "verified": false, + * "isNative": false + * } + * }, + * "amount": "30754920", + * "amountFormatted": "30.75492", + * "amountUsd": "30.901612", + * "minimumAmount": "30454920" + * } + */ + subsidized?: { + currency?: { + chainId?: number; + address?: string; + symbol?: string; + name?: string; + decimals?: number; + metadata?: { + logoURI?: string; + verified?: boolean; + isNative?: boolean; + }; + }; + amount?: string; + amountFormatted?: string; + amountUsd?: string; + minimumAmount?: string; + }; }; /** @description A summary of the swap and what the user should expect to happen given an input */ details?: { @@ -3352,6 +3641,105 @@ export interface paths { }; }; }; + "/execute": { + post: { + requestBody: { + content: { + "application/json": { + /** + * @description The kind of gasless transaction to execute. Currently supported: rawCalls + * @enum {string} + */ + executionKind: "rawCalls"; + /** @description Raw call parameters for the gasless transaction */ + data: { + /** @description Chain ID of the EVM network */ + chainId: number; + /** @description Address of the contract to call */ + to: string; + /** @description Encoded function call data */ + data: string; + /** @description ETH value to send with the call (in wei) */ + value: string; + /** @description Authorization list for EIP-7702 transactions to be executed on destination chain */ + authorizationList?: { + chainId: number; + address: string; + nonce: number; + yParity: number; + r: string; + s: string; + }[]; + }; + /** @description Options related to gas fee sponsorship and app referrer */ + executionOptions: { + /** @description The referrer of the app which is executing the gasless transaction */ + referrer: string; + /** @description If the app should pay for the fees associated with the request */ + subsidizeFees: boolean; + }; + }; + }; + }; + responses: { + /** @description Transaction successfully queued for execution */ + 200: { + content: { + "application/json": { + /** @example Transaction submitted */ + message?: string; + /** @example 0xabc123... */ + requestId?: string; + }; + }; + }; + /** @description Bad Request - Invalid input or simulation failure */ + 400: { + content: { + "application/json": { + /** @example to is required */ + error?: string; + } | { + /** @example data is required */ + error?: string; + } | { + /** @example value is required */ + error?: string; + } | { + /** @example chainId is required */ + error?: string; + } | { + /** @example authorizationList cannot be empty */ + error?: string; + } | { + /** @example SimulationError */ + error?: string; + /** @example execution reverted: invalid opcode */ + message?: string; + }; + }; + }; + /** @description Unauthorized - Missing or invalid API key or referrer */ + 401: { + content: { + "application/json": { + /** @example Unauthorized */ + error?: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + "application/json": { + /** @example Internal server error */ + error?: string; + }; + }; + }; + }; + }; + }; "/lives": { get: { responses: { @@ -4009,6 +4397,8 @@ export interface paths { slippageTolerance?: string; /** @enum {string} */ failReason?: "UNKNOWN" | "AMOUNT_TOO_LOW_TO_REFUND" | "DEPOSIT_ADDRESS_MISMATCH" | "DEPOSIT_CHAIN_MISMATCH" | "SLIPPAGE" | "INCORRECT_DEPOSIT_CURRENCY" | "DOUBLE_SPEND" | "SOLVER_CAPACITY_EXCEEDED" | "DEPOSITED_AMOUNT_TOO_LOW_TO_FILL" | "NEGATIVE_NEW_AMOUNT_AFTER_FEES" | "NO_QUOTES" | "MISSING_REVERT_DATA" | "REVERSE_SWAP_FAILED" | "GENERATE_SWAP_FAILED" | "TOO_LITTLE_RECEIVED" | "EXECUTION_REVERTED" | "NEW_CALLDATA_INCLUDES_HIGHER_RENT_FEE" | "TRANSACTION_REVERTED" | "N/A"; + /** @enum {string} */ + refundFailReason?: "AMOUNT_TOO_LOW_TO_REFUND"; fees?: { /** @description Estimated gas cost required for execution, in wei */ gas?: string; diff --git a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx index 90518a3c3..304f3c714 100644 --- a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx +++ b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx @@ -36,10 +36,7 @@ import { eclipse, solana } from '../../../utils/solana.js' import { bitcoin } from '../../../utils/bitcoin.js' import { ChainFilterSidebar } from './ChainFilterSidebar.js' import { SuggestedTokens } from './SuggestedTokens.js' -import { - convertApiCurrencyToToken, - mergeTokenLists -} from '../../../utils/tokens.js' +import { mergeTokenLists } from '../../../utils/tokens.js' import { bitcoinDeadAddress, evmDeadAddress, diff --git a/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx b/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx index 3dcf972bb..1bb135dbb 100644 --- a/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx +++ b/packages/ui/src/components/common/TransactionModal/TransactionModal.tsx @@ -194,7 +194,8 @@ const InnerTransactionModal: FC = ({ toChain, isLoadingTransaction, setQuote, - requestId + requestId, + isGasSponsored }) => { useEffect(() => { if (!open) { @@ -287,6 +288,7 @@ const InnerTransactionModal: FC = ({ details={details} isLoadingTransaction={isLoadingTransaction} requestId={requestId} + isGasSponsored={isGasSponsored} /> ) : null} {progressStep === TransactionProgressStep.Error ? ( diff --git a/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx b/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx index d31a73ab0..b46e9058f 100644 --- a/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx +++ b/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx @@ -22,7 +22,10 @@ import { import type { Token } from '../../../types/index.js' import { useRequests } from '@relayprotocol/relay-kit-hooks' import { useRelayClient } from '../../../hooks/index.js' -import { calculatePriceTimeEstimate } from '../../../utils/quote.js' +import { + calculatePriceTimeEstimate, + isGasSponsored +} from '../../../utils/quote.js' export enum TransactionProgressStep { Confirmation, Success, @@ -63,6 +66,7 @@ export type ChildrenProps = { isLoadingTransaction: boolean isAutoSlippage: boolean timeEstimate?: { time: number; formattedTime: string } + isGasSponsored: boolean } type Props = { @@ -83,9 +87,6 @@ type Props = { export const TransactionModalRenderer: FC = ({ open, - address, - fromToken, - toToken, slippageTolerance, wallet, steps, @@ -224,6 +225,7 @@ export const TransactionModalRenderer: FC = ({ const isAutoSlippage = slippageTolerance === undefined const timeEstimate = calculatePriceTimeEstimate(quote?.details) + const _isGasSponsored = isGasSponsored(quote as Execute) return ( <> @@ -252,7 +254,8 @@ export const TransactionModalRenderer: FC = ({ requestId, isLoadingTransaction, isAutoSlippage, - timeEstimate + timeEstimate, + isGasSponsored: _isGasSponsored })} ) diff --git a/packages/ui/src/components/common/TransactionModal/steps/SwapSuccessStep.tsx b/packages/ui/src/components/common/TransactionModal/steps/SwapSuccessStep.tsx index 137d42729..a81a75768 100644 --- a/packages/ui/src/components/common/TransactionModal/steps/SwapSuccessStep.tsx +++ b/packages/ui/src/components/common/TransactionModal/steps/SwapSuccessStep.tsx @@ -7,7 +7,8 @@ import { Text, ChainTokenIcon, ChainIcon, - Skeleton + Skeleton, + Anchor } from '../../../primitives/index.js' import { motion } from 'framer-motion' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -23,6 +24,7 @@ import { bitcoin } from '../../../../utils/bitcoin.js' import { formatBN } from '../../../../utils/numbers.js' import { TransactionsByChain } from './TransactionsByChain.js' import { faArrowRight } from '@fortawesome/free-solid-svg-icons' +import { XIcon } from '../../../../icons/index.js' type SwapSuccessStepProps = { fromToken?: Token @@ -39,6 +41,7 @@ type SwapSuccessStepProps = { isLoadingTransaction?: boolean onOpenChange: (open: boolean) => void requestId: string | null + isGasSponsored?: boolean } export const SwapSuccessStep: FC = ({ @@ -55,7 +58,8 @@ export const SwapSuccessStep: FC = ({ details, isLoadingTransaction, onOpenChange, - requestId + requestId, + isGasSponsored }) => { const relayClient = useRelayClient() const isWrap = details?.operation === 'wrap' @@ -418,6 +422,32 @@ export const SwapSuccessStep: FC = ({ )} + {isGasSponsored && _toToken?.symbol === 'USDC' ? ( + + You've completed a free USDC bridge! + + Share on + + + ) : null} + {requestId ? ( = ({ return ( - + View {chain?.displayName} Tx {isRefundChain ? ( diff --git a/packages/ui/src/components/widgets/PriceImpactTooltip.tsx b/packages/ui/src/components/widgets/PriceImpactTooltip.tsx index 5cf933d8c..96c9cb518 100644 --- a/packages/ui/src/components/widgets/PriceImpactTooltip.tsx +++ b/packages/ui/src/components/widgets/PriceImpactTooltip.tsx @@ -70,9 +70,15 @@ export const PriceImpactTooltip: FC = ({ {fee.name} - - {fee.usd.formatted} - + {feeBreakdown.isGasSponsored && fee.usd.value === 0 ? ( + + Free + + ) : ( + + {fee.usd.formatted} + + )} ) })} diff --git a/packages/ui/src/components/widgets/SwapWidget/PriceImpact.tsx b/packages/ui/src/components/widgets/SwapWidget/PriceImpact.tsx new file mode 100644 index 000000000..8c642ba80 --- /dev/null +++ b/packages/ui/src/components/widgets/SwapWidget/PriceImpact.tsx @@ -0,0 +1,78 @@ +import type { FC } from 'react' +import type { QuoteResponse } from '@relayprotocol/relay-kit-hooks' +import { Flex, Text } from '../../primitives/index.js' +import type { Token } from '../../../types/index.js' +import type { FeeBreakdown } from '../../../types/FeeBreakdown.js' + +export const PriceImpact: FC<{ + toToken?: Token + isFetchingQuote?: boolean + feeBreakdown?: FeeBreakdown + quote?: QuoteResponse +}> = ({ toToken, isFetchingQuote, feeBreakdown, quote }) => { + if ( + toToken && + quote?.details?.currencyOut?.amountUsd && + quote?.details?.currencyOut?.amountUsd !== '0' && + !isFetchingQuote + ) { + if ( + feeBreakdown?.isGasSponsored && + quote?.details?.totalImpact?.percent === '0' + ) { + return ( + + + ( + + + 0% + + + 0.01% + + + ) + + + ) + } else if (feeBreakdown?.isGasSponsored) { + return ( + + + ( + + + {feeBreakdown?.totalFees.priceImpactPercentage} + + + Fee Subsidized + + + ) + + + ) + } else { + return ( + + ({feeBreakdown?.totalFees.priceImpactPercentage}) + + ) + } + } + + return null +} diff --git a/packages/ui/src/components/widgets/SwapWidget/index.tsx b/packages/ui/src/components/widgets/SwapWidget/index.tsx index f22caffe6..ff39b4c05 100644 --- a/packages/ui/src/components/widgets/SwapWidget/index.tsx +++ b/packages/ui/src/components/widgets/SwapWidget/index.tsx @@ -45,6 +45,7 @@ import { UnverifiedTokenModal } from '../../common/UnverifiedTokenModal.js' import { alreadyAcceptedToken } from '../../../utils/localStorage.js' import GasTopUpSection from './GasTopUpSection.js' import { calculateUsdValue, getSwapEventData } from '../../../utils/quote.js' +import { PriceImpact } from './PriceImpact.js' type BaseSwapWidgetProps = { fromToken?: Token @@ -64,6 +65,7 @@ type BaseSwapWidgetProps = { disableInputAutoFocus?: boolean popularChainIds?: number[] disablePasteWalletAddressOption?: boolean + sponsoredTokens?: string[] onFromTokenChange?: (token?: Token) => void onToTokenChange?: (token?: Token) => void onConnectWallet?: () => void @@ -112,6 +114,7 @@ const SwapWidget: FC = ({ disableInputAutoFocus = false, popularChainIds, disablePasteWalletAddressOption, + sponsoredTokens, onSetPrimaryWallet, onLinkNewWallet, onFromTokenChange, @@ -187,6 +190,7 @@ const SwapWidget: FC = ({ onSwapError={onSwapError} onAnalyticEvent={onAnalyticEvent} supportedWalletVMs={supportedWalletVMs} + sponsoredTokens={sponsoredTokens} > {({ quote, @@ -1506,18 +1510,12 @@ const SwapWidget: FC = ({ '$0.00' )} - {toToken && - quote?.details?.currencyOut?.amountUsd && - quote?.details?.currencyOut?.amountUsd !== '0' && - !isFetchingQuote && - quote?.details?.totalImpact?.percent ? ( - - ({feeBreakdown?.totalFees.priceImpactPercentage}) - - ) : null} + {toToken ? ( diff --git a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx index ef4f31878..0fa564ec3 100644 --- a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx @@ -22,7 +22,7 @@ import type { Address, WalletClient } from 'viem' import { formatUnits, parseUnits } from 'viem' import { useAccount, useWalletClient } from 'wagmi' import { useCapabilities } from 'wagmi/experimental' -import type { BridgeFee, Token } from '../../types/index.js' +import type { Token } from '../../types/index.js' import { useQueryClient } from '@tanstack/react-query' import type { ChainVM, Execute } from '@relayprotocol/relay-sdk' import { @@ -39,7 +39,6 @@ import { useQuote, useTokenPrice } from '@relayprotocol/relay-kit-hooks' import { EventNames } from '../../constants/events.js' import { ProviderOptionsContext } from '../../providers/RelayKitProvider.js' import type { DebouncedState } from 'usehooks-ts' -import type Text from '../../components/primitives/Text.js' import type { AdaptedWallet } from '@relayprotocol/relay-sdk' import type { LinkedWallet } from '../../types/index.js' import { @@ -52,6 +51,7 @@ import { errorToJSON } from '../../utils/errors.js' import { useSwapButtonCta } from '../../hooks/widget/useSwapButtonCta.js' import { sha256 } from '../../utils/hashing.js' import { get15MinuteInterval } from '../../utils/time.js' +import type { FeeBreakdown } from '../../types/FeeBreakdown.js' export type TradeType = 'EXACT_INPUT' | 'EXPECTED_OUTPUT' @@ -76,6 +76,7 @@ type SwapWidgetRendererProps = { onConnectWallet?: () => void onAnalyticEvent?: (eventName: string, data?: any) => void onSwapError?: (error: string, data?: Execute) => void + sponsoredTokens?: string[] } export type ChildrenProps = { @@ -85,19 +86,7 @@ export type ChildrenProps = { swap: () => void transactionModalOpen: boolean details: null | Execute['details'] - feeBreakdown: { - breakdown: BridgeFee[] - totalFees: { - usd?: string - priceImpactPercentage?: string - priceImpact?: string - priceImpactColor?: ComponentPropsWithoutRef['color'] - swapImpact?: { - value: number - formatted: string - } - } - } | null + feeBreakdown: FeeBreakdown | null fromToken?: Token setFromToken: Dispatch> toToken?: Token @@ -171,6 +160,7 @@ export type ChildrenProps = { isLoadingFromTokenPrice: boolean toTokenPriceData: ReturnType['data'] isLoadingToTokenPrice: boolean + sponsoredTokens?: string[] } // shared query options for useTokenPrice @@ -197,6 +187,7 @@ const SwapWidgetRenderer: FC = ({ multiWalletSupportEnabled = false, linkedWallets, supportedWalletVMs, + sponsoredTokens, children, onAnalyticEvent, onSwapError @@ -579,6 +570,18 @@ const SwapWidgetRenderer: FC = ({ const loadingProtocolVersion = fromChain?.id && originChainSupportsProtocolv2 && isLoadingFromTokenPrice + const isGasSponsorshipEnabled = + sponsoredTokens && + sponsoredTokens.length > 0 && + toToken && + fromToken && + sponsoredTokens.includes( + `${toToken.chainId}:${toToken.address.toLowerCase()}` + ) && + sponsoredTokens.includes( + `${fromToken.chainId}:${fromToken.address.toLowerCase()}` + ) + const quoteParameters: Parameters['2'] = fromToken && toToken ? { @@ -707,7 +710,9 @@ const SwapWidgetRenderer: FC = ({ quote_request_id: quoteRequestId, status_code: e.response.status ?? e.status ?? '' }) - } + }, + undefined, + isGasSponsorshipEnabled ? providerOptionsContext?.secureBaseUrl : undefined ) const invalidateQuoteQuery = useCallback(() => { diff --git a/packages/ui/src/icons/XIcon.tsx b/packages/ui/src/icons/XIcon.tsx new file mode 100644 index 000000000..482b3f0f2 --- /dev/null +++ b/packages/ui/src/icons/XIcon.tsx @@ -0,0 +1,42 @@ +import React from 'react' + +type XIconProps = React.HTMLAttributes & { + width?: number | string + height?: number | string + fill?: string +} + +export const XIcon = ({ + width = 10, + height = 10, + fill = 'currentColor', + ...props +}: XIconProps) => { + return ( + + + + + + + + + + + ) +} diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index d649c5859..8b563fead 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -1 +1,2 @@ export { SwitchIcon } from './SwitchIcon.js' +export { XIcon } from './XIcon.js' diff --git a/packages/ui/src/providers/RelayKitProvider.tsx b/packages/ui/src/providers/RelayKitProvider.tsx index 3268435cd..335088054 100644 --- a/packages/ui/src/providers/RelayKitProvider.tsx +++ b/packages/ui/src/providers/RelayKitProvider.tsx @@ -48,6 +48,11 @@ type RelayKitProviderOptions = { * The icon theme to use for the chain icons. Defaults to light. */ themeScheme?: 'dark' | 'light' + /** + * The secure base url for the relay api, if omitted the default will be used. Override this config to protect your api key via a proxy. + * Currently only relevant for the quote api in the SwapWidget + */ + secureBaseUrl?: string } export interface RelayKitProviderProps { @@ -175,7 +180,8 @@ export const RelayKitProvider: FC = function ({ disablePoweredByReservoir: options.disablePoweredByReservoir, vmConnectorKeyOverrides: options.vmConnectorKeyOverrides, privateChainIds: options.privateChainIds, - themeScheme: options.themeScheme + themeScheme: options.themeScheme, + secureBaseUrl: options.secureBaseUrl }), [options] ) diff --git a/packages/ui/src/types/FeeBreakdown.ts b/packages/ui/src/types/FeeBreakdown.ts new file mode 100644 index 000000000..1dc971a87 --- /dev/null +++ b/packages/ui/src/types/FeeBreakdown.ts @@ -0,0 +1,18 @@ +import type { ComponentPropsWithoutRef } from 'react' +import type { BridgeFee } from './BridgeFee.js' +import type { Text } from '../components/primitives/index.js' + +export type FeeBreakdown = { + breakdown: BridgeFee[] + totalFees: { + usd?: string + priceImpactPercentage?: string + priceImpact?: string + priceImpactColor?: ComponentPropsWithoutRef['color'] + swapImpact?: { + value: number + formatted: string + } + } + isGasSponsored: boolean +} | null diff --git a/packages/ui/src/utils/quote.ts b/packages/ui/src/utils/quote.ts index c26d0a5f0..06f6c85bb 100644 --- a/packages/ui/src/utils/quote.ts +++ b/packages/ui/src/utils/quote.ts @@ -8,6 +8,7 @@ import type Text from '../components/primitives/Text.js' import { bitcoin } from '../utils/bitcoin.js' import axios from 'axios' import { sha256 } from './hashing.js' +import type { FeeBreakdown } from '../types/FeeBreakdown.js' const formatUsdFee = ( amountUsd: string | undefined, @@ -25,19 +26,7 @@ export const parseFees = ( selectedTo: RelayChain, selectedFrom: RelayChain, quote?: ReturnType['data'] -): { - breakdown: BridgeFee[] - totalFees: { - usd?: string - priceImpactPercentage?: string - priceImpact?: string - priceImpactColor?: ComponentPropsWithoutRef['color'] - swapImpact?: { - value: number - formatted: string - } - } -} => { +): FeeBreakdown => { const fees = quote?.fees const gasFee = BigInt(fees?.gas?.amount ?? 0) const formattedGasFee = formatBN( @@ -67,6 +56,7 @@ export const parseFees = ( const appFee = BigInt(fees?.app?.amount ?? 0) const totalFeesUsd = Number(fees?.relayer?.amountUsd ?? 0) + Number(fees?.app?.amountUsd ?? 0) + const _isGasSponsored = isGasSponsored(quote) const breakdown: BridgeFee[] = [ { @@ -80,9 +70,11 @@ export const parseFees = ( currency: fees?.gas?.currency }, { - raw: relayerGasFee, - formatted: `${formattedRelayerGas}`, - usd: formatUsdFee(fees?.relayerGas?.amountUsd, true), + raw: _isGasSponsored ? 0n : relayerGasFee, + formatted: _isGasSponsored ? '0' : `${formattedRelayerGas}`, + usd: _isGasSponsored + ? { value: 0, formatted: '0' } + : formatUsdFee(fees?.relayerGas?.amountUsd, true), name: `Fill Gas (${selectedTo.displayName})`, tooltip: null, type: 'gas', @@ -90,9 +82,13 @@ export const parseFees = ( currency: fees?.relayerGas?.currency }, { - raw: relayerFee, - formatted: `${relayerFeeIsReward ? '+' : '-'}${formattedRelayer}`, - usd: formatUsdFee(fees?.relayerService?.amountUsd, true), + raw: _isGasSponsored ? 0n : relayerFee, + formatted: _isGasSponsored + ? '0' + : `${relayerFeeIsReward ? '+' : '-'}${formattedRelayer}`, + usd: _isGasSponsored + ? { value: 0, formatted: '0' } + : formatUsdFee(fees?.relayerService?.amountUsd, true), name: relayerFeeIsReward ? 'Reward' : 'Relay Fee', tooltip: null, type: 'relayer', @@ -107,9 +103,11 @@ export const parseFees = ( Number(fees?.app?.currency?.decimals ?? 18) ) breakdown.push({ - raw: appFee, - formatted: `${formattedAppFee}`, - usd: formatUsdFee(fees?.app?.amountUsd, true), + raw: _isGasSponsored ? 0n : appFee, + formatted: _isGasSponsored ? '0' : `${formattedAppFee}`, + usd: _isGasSponsored + ? { value: 0, formatted: '0' } + : formatUsdFee(fees?.app?.amountUsd, true), name: 'App Fee', tooltip: null, type: 'relayer', @@ -131,6 +129,8 @@ export const parseFees = ( priceImpactColor = 'red' } else if (percent > 0) { priceImpactColor = 'success' + } else if (_isGasSponsored) { + priceImpactColor = 'success' } } return { @@ -140,12 +140,15 @@ export const parseFees = ( priceImpactPercentage: quote?.details?.totalImpact?.percent ? `${quote?.details?.totalImpact?.percent}%` : undefined, - priceImpact: quote?.details?.totalImpact?.usd - ? formatDollar(parseFloat(quote?.details?.totalImpact?.usd ?? 0)) - : undefined, + priceImpact: + quote?.details?.totalImpact?.usd && + quote?.details?.totalImpact?.usd != '0' + ? formatDollar(parseFloat(quote?.details?.totalImpact?.usd ?? 0)) + : undefined, priceImpactColor, swapImpact: formatUsdFee(quote?.details?.swapImpact?.usd, false) - } + }, + isGasSponsored: _isGasSponsored } } @@ -351,3 +354,10 @@ export const calculateUsdValue = ( } return undefined } + +export const isGasSponsored = (quote?: QuoteResponse) => { + return ( + quote?.fees?.subsidized?.amount != undefined && + quote?.fees?.subsidized?.amount != '0' + ) +}