diff --git a/apps/subgraph/schema.graphql b/apps/subgraph/schema.graphql index 5f9af4f26..7c36147a6 100644 --- a/apps/subgraph/schema.graphql +++ b/apps/subgraph/schema.graphql @@ -380,7 +380,6 @@ type ClankerToken @entity { tokenAddress: Bytes! msgSender: Bytes! tokenAdmin: Bytes! # The admin address (may or may not be a DAO) - # Token metadata tokenImage: String! tokenName: String! @@ -565,6 +564,7 @@ type ZoraDropMintComment @entity { # Swap routing entities enum CoinType { + UNKNOWN WETH CLANKER_TOKEN ZORA_COIN @@ -622,6 +622,8 @@ type PaymentOption @entity { # Payment token details tokenAddress: Bytes! tokenType: CoinType! + tokenName: String! + tokenSymbol: String! # Hops from this payment token to the target coin (when buying) # Or from target coin to this payment token (when selling) diff --git a/apps/subgraph/src/utils/coinInfo.ts b/apps/subgraph/src/utils/coinInfo.ts index c58d92c72..8a8f398ba 100644 --- a/apps/subgraph/src/utils/coinInfo.ts +++ b/apps/subgraph/src/utils/coinInfo.ts @@ -7,6 +7,7 @@ import { CLANKER_TICK_SPACING, DYNAMIC_FEE_FLAG, WETH_ADDRESS } from './constant * Coin type constants for routing */ export namespace CoinType { + export const UNKNOWN: string = 'UNKNOWN' export const WETH: string = 'WETH' export const CLANKER_TOKEN: string = 'CLANKER_TOKEN' export const ZORA_COIN: string = 'ZORA_COIN' @@ -18,6 +19,8 @@ export namespace CoinType { export class CoinInfo { address: Bytes type: string + name: string + symbol: string pairedToken: Bytes | null poolId: Bytes | null // Uniswap V4 pool identifier (from either poolId or poolKeyHash) fee: BigInt | null @@ -27,6 +30,8 @@ export class CoinInfo { constructor( address: Bytes, type: string, + name: string, + symbol: string, pairedToken: Bytes | null, poolId: Bytes | null, fee: BigInt | null, @@ -35,6 +40,8 @@ export class CoinInfo { ) { this.address = address this.type = type + this.name = name + this.symbol = symbol this.pairedToken = pairedToken this.poolId = poolId this.fee = fee @@ -55,6 +62,8 @@ export function loadCoinInfo(tokenAddress: Bytes): CoinInfo | null { return new CoinInfo( tokenAddress, CoinType.WETH, + 'Wrapped Ether', + 'WETH', null, // WETH has no pairedToken null, // WETH has no pool null, @@ -69,6 +78,8 @@ export function loadCoinInfo(tokenAddress: Bytes): CoinInfo | null { return new CoinInfo( zoraCoin.coinAddress, CoinType.ZORA_COIN, + zoraCoin.name, + zoraCoin.symbol, zoraCoin.currency, zoraCoin.poolKeyHash, // poolKeyHash is the Uniswap V4 pool identifier zoraCoin.poolFee, @@ -83,6 +94,8 @@ export function loadCoinInfo(tokenAddress: Bytes): CoinInfo | null { return new CoinInfo( clankerToken.tokenAddress, CoinType.CLANKER_TOKEN, + clankerToken.tokenName, + clankerToken.tokenSymbol, clankerToken.pairedToken, clankerToken.poolId, // poolId is the Uniswap V4 pool identifier DYNAMIC_FEE_FLAG, diff --git a/apps/subgraph/src/utils/swapPath.ts b/apps/subgraph/src/utils/swapPath.ts index 71f59b943..e8907ea51 100644 --- a/apps/subgraph/src/utils/swapPath.ts +++ b/apps/subgraph/src/utils/swapPath.ts @@ -240,11 +240,18 @@ export function buildSwapRoute(coinAddress: Bytes, timestamp: BigInt): SwapRoute continue } - // Determine token type - let tokenType = CoinType.WETH + let tokenType = CoinType.UNKNOWN + let tokenName = 'Unknown Token' + let tokenSymbol = 'UNKNOWN' const info = loadCoinInfo(tokenBytes) - if (info) { + if (!info) { + log.warning('Payment option has missing coin info, using UNKNOWN metadata: {}', [ + tokenAddr, + ]) + } else { tokenType = info.type + tokenName = info.name + tokenSymbol = info.symbol } // Create payment option @@ -253,6 +260,8 @@ export function buildSwapRoute(coinAddress: Bytes, timestamp: BigInt): SwapRoute option.route = routeId option.tokenAddress = tokenBytes option.tokenType = tokenType + option.tokenName = tokenName + option.tokenSymbol = tokenSymbol option.startHopIndex = startHopIndex option.endHopIndex = endHopIndex option.isDirectSwap = endHopIndex - startHopIndex == 0 // Single hop = direct swap diff --git a/apps/web/src/pages/api/coins/swap-options.ts b/apps/web/src/pages/api/coins/swap-options.ts deleted file mode 100644 index a42a5e221..000000000 --- a/apps/web/src/pages/api/coins/swap-options.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { buildSwapOptions } from '@buildeross/swap' -import { CHAIN_ID } from '@buildeross/types' -import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' -import { NextApiRequest, NextApiResponse } from 'next' -import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' -import { isAddress } from 'viem' - -/** - * Serialize a SwapOption for JSON transport. - * Converts bigint fields (fee) to strings so JSON.stringify doesn't throw. - */ -function serializeSwapOptions( - result: NonNullable>> -) { - return { - options: result.options.map((opt) => ({ - ...opt, - token: { - ...opt.token, - fee: - opt.token.type === 'zora-coin' || opt.token.type === 'clanker-token' - ? opt.token.fee.toString() - : undefined, - }, - path: { - ...opt.path, - hops: opt.path.hops.map((hop) => ({ - ...hop, - fee: hop.fee !== undefined ? hop.fee.toString() : undefined, - })), - estimatedGas: - opt.path.estimatedGas !== undefined - ? opt.path.estimatedGas.toString() - : undefined, - }, - })), - } -} - -async function handler(req: NextApiRequest, res: NextApiResponse) { - const { chainId, coinAddress, isBuying } = req.query - - if (!chainId || !coinAddress) { - return res.status(400).json({ error: 'Missing chainId or coinAddress parameter' }) - } - - const chainIdNum = parseInt(chainId as string, 10) - - if (!Object.values(CHAIN_ID).includes(chainIdNum)) { - return res.status(400).json({ error: 'Invalid chainId' }) - } - - if (!isChainIdSupportedByCoining(chainIdNum as CHAIN_ID)) { - return res.status(400).json({ error: 'Chain not supported for swap options' }) - } - - if (!isAddress(coinAddress as string)) { - return res.status(400).json({ error: 'Invalid coinAddress' }) - } - - const buying = isBuying !== 'false' - - try { - const result = await buildSwapOptions( - chainIdNum as CHAIN_ID, - coinAddress as `0x${string}`, - buying - ) - - if (!result) { - res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=10') - return res.status(200).json({ options: [] }) - } - - res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=10') - return res.status(200).json(serializeSwapOptions(result)) - } catch (error) { - console.error('Swap options API error:', error) - return res.status(500).json({ error: 'Internal server error' }) - } -} - -export default withCors()( - withRateLimit({ - maxRequests: 60, - keyPrefix: 'coins:swapOptions', - })(handler) -) diff --git a/packages/dao-ui/src/components/Cards/Cards.css.ts b/packages/dao-ui/src/components/Cards/Cards.css.ts index 865666a3c..aa53baa48 100644 --- a/packages/dao-ui/src/components/Cards/Cards.css.ts +++ b/packages/dao-ui/src/components/Cards/Cards.css.ts @@ -3,10 +3,12 @@ import { style } from '@vanilla-extract/css' export const card = style([ { - transition: 'transform 0.2s ease, box-shadow 0.2s ease', + position: 'relative', + top: 0, + transition: 'top 0.2s ease, box-shadow 0.2s ease', border: `2px solid ${color.border}`, ':hover': { - transform: 'translateY(-2px)', + top: -2, boxShadow: `0 4px 12px ${theme.colors.ghostHover}`, }, }, diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 9ff6159dd..b9f6683dc 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -51,7 +51,6 @@ export * from './useQueryParams' export * from './useScrollDirection' export * from './useStreamData' export * from './useSwapOptions' -export * from './useSwapPath' export * from './useSwapQuote' export * from './useTimeout' export * from './useTokenBalances' diff --git a/packages/hooks/src/useSwapOptions.ts b/packages/hooks/src/useSwapOptions.ts index 47c66037b..c51daaeea 100644 --- a/packages/hooks/src/useSwapOptions.ts +++ b/packages/hooks/src/useSwapOptions.ts @@ -1,11 +1,11 @@ -import { BASE_URL } from '@buildeross/constants/baseUrl' +import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' import { SWR_KEYS } from '@buildeross/constants/swrKeys' -import { SwapOption as SwapOptionType } from '@buildeross/swap' +import { type SwapRouteFragment, swapRouteRequest } from '@buildeross/sdk/subgraph' +import type { SwapOption as SwapOptionType, SwapPath, TokenInfo } from '@buildeross/swap' import { AddressType, CHAIN_ID } from '@buildeross/types' import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' import useSWR from 'swr' -// Re-export SwapOption type from swap package export type SwapOption = SwapOptionType export interface UseSwapOptionsResult { @@ -14,46 +14,291 @@ export interface UseSwapOptionsResult { error: Error | null } +type Hop = { + tokenIn: AddressType + tokenOut: AddressType + poolId: AddressType + fee?: bigint + hooks?: AddressType + tickSpacing?: number +} + /** - * Deserialize swap options from the API response. - * The API serializes bigint fields (fee, estimatedGas) as strings. + * Subgraph mainPath is stored as: COIN -> PAYMENT TOKEN (SELL direction) + * If isBuying=true, we must invert to get PAYMENT -> COIN. */ -function deserializeSwapOptions(data: any): SwapOption[] { - if (!Array.isArray(data?.options)) return [] - return data.options.map((opt: any) => ({ - ...opt, - token: { - ...opt.token, - fee: opt.token.fee !== undefined ? BigInt(opt.token.fee) : undefined, - }, - path: { - ...opt.path, - hops: (opt.path.hops ?? []).map((hop: any) => ({ - ...hop, - fee: hop.fee !== undefined ? BigInt(hop.fee) : undefined, - })), - estimatedGas: - opt.path.estimatedGas !== undefined ? BigInt(opt.path.estimatedGas) : undefined, - }, +function normalizeHopsForTradeDirection( + relevantHops: SwapRouteFragment['mainPath'], + isBuying: boolean +): Hop[] { + if (!isBuying) { + // Selling: Coin -> Payment (as stored) + return relevantHops.map((hop) => ({ + tokenIn: hop.tokenIn as AddressType, + tokenOut: hop.tokenOut as AddressType, + poolId: hop.poolId as AddressType, + fee: hop.fee != null ? BigInt(hop.fee) : undefined, + hooks: hop.hooks ? (hop.hooks as AddressType) : undefined, + tickSpacing: hop.tickSpacing ?? undefined, + })) + } + + // Buying: Payment -> Coin (invert) + return [...relevantHops].reverse().map((hop) => ({ + tokenIn: hop.tokenOut as AddressType, + tokenOut: hop.tokenIn as AddressType, + poolId: hop.poolId as AddressType, + fee: hop.fee ? BigInt(hop.fee) : undefined, + hooks: hop.hooks ? (hop.hooks as AddressType) : undefined, + tickSpacing: hop.tickSpacing ?? undefined, })) } +function convertCoinType( + coinType: string +): 'eth' | 'weth' | 'zora-coin' | 'clanker-token' | undefined { + switch (coinType) { + case 'ETH': + return 'eth' + case 'WETH': + return 'weth' + case 'CLANKER_TOKEN': + return 'clanker-token' + case 'ZORA_COIN': + return 'zora-coin' + case 'UNKNOWN': + return undefined + default: + return undefined + } +} + +function sanitizeHopRange( + startHopIndex: number, + endHopIndex: number, + pathLength: number, + label: string +): { start: number; end: number } | null { + if (pathLength <= 0) { + console.warn(`[useSwapOptions] Skipping ${label}: mainPath is empty`) + return null + } + + const originalStart = startHopIndex + const originalEnd = endHopIndex + + if (!Number.isInteger(startHopIndex) || !Number.isInteger(endHopIndex)) { + console.warn( + `[useSwapOptions] Non-integer hop indices for ${label}: start=${startHopIndex}, end=${endHopIndex}` + ) + } + + if (!Number.isFinite(startHopIndex) || !Number.isFinite(endHopIndex)) { + console.warn( + `[useSwapOptions] Skipping ${label}: non-finite hop indices start=${startHopIndex}, end=${endHopIndex}` + ) + return null + } + + const maxIndex = pathLength - 1 + const start = Math.min(Math.max(Math.trunc(startHopIndex), 0), maxIndex) + const end = Math.min(Math.max(Math.trunc(endHopIndex), 0), maxIndex) + + if (start !== originalStart || end !== originalEnd) { + console.warn( + `[useSwapOptions] Clamped hop indices for ${label}: start=${originalStart}->${start}, end=${originalEnd}->${end}, pathLength=${pathLength}` + ) + } + + if (start > end) { + console.warn( + `[useSwapOptions] Skipping ${label}: invalid hop range start=${start}, end=${end}` + ) + return null + } + + return { start, end } +} + +function addrEq(a?: string, b?: string): boolean { + if (!a || !b) return false + return a.toLowerCase() === b.toLowerCase() +} + +export function convertSwapRouteToOptions( + swapRoute: SwapRouteFragment, + isBuying: boolean, + chainId: CHAIN_ID +): SwapOption[] { + const paymentOptions = swapRoute.paymentOptions ?? [] + if (paymentOptions.length === 0) return [] + + const tokenDetailsMap = new Map() + + // Target token metadata (if present) + if (swapRoute.clankerToken) { + tokenDetailsMap.set(swapRoute.clankerToken.tokenAddress.toLowerCase(), { + symbol: swapRoute.clankerToken.tokenSymbol, + name: swapRoute.clankerToken.tokenName, + }) + } + if (swapRoute.zoraCoin) { + tokenDetailsMap.set(swapRoute.zoraCoin.coinAddress.toLowerCase(), { + symbol: swapRoute.zoraCoin.symbol, + name: swapRoute.zoraCoin.name, + }) + } + + // WETH metadata + const wethAddress = WETH_ADDRESS[chainId] + if (wethAddress) { + tokenDetailsMap.set(wethAddress.toLowerCase(), { + symbol: 'WETH', + name: 'Wrapped Ether', + }) + } + + // Payment options metadata from subgraph + paymentOptions.forEach((paymentOption) => { + tokenDetailsMap.set(paymentOption.tokenAddress.toLowerCase(), { + symbol: paymentOption.tokenSymbol, + name: paymentOption.tokenName, + }) + }) + + const options: SwapOption[] = [] + + // Build options from paymentOptions + for (const paymentOption of paymentOptions) { + const hopRange = sanitizeHopRange( + paymentOption.startHopIndex, + paymentOption.endHopIndex, + swapRoute.mainPath.length, + `payment option ${paymentOption.tokenAddress}` + ) + if (!hopRange) continue + + const relevantHops = swapRoute.mainPath.slice(hopRange.start, hopRange.end + 1) + if (relevantHops.length === 0) { + console.warn( + `[useSwapOptions] Skipping payment option ${paymentOption.tokenAddress}: no hops in sanitized range` + ) + continue + } + + const hops = normalizeHopsForTradeDirection(relevantHops, isBuying) + + const path: SwapPath = { + hops, + isOptimal: true, + estimatedGas: undefined, + } + + const tokenAddress = paymentOption.tokenAddress as AddressType + const tokenDetails = tokenDetailsMap.get(tokenAddress.toLowerCase()) ?? { + symbol: 'TOKEN', + name: 'Token', + } + + const tokenType = convertCoinType(paymentOption.tokenType) + if (!tokenType) { + console.warn( + `[useSwapOptions] Skipping payment option ${paymentOption.tokenAddress}: unsupported tokenType ${paymentOption.tokenType}` + ) + continue + } + + const tokenInfo: TokenInfo = { + address: tokenAddress, + symbol: tokenDetails.symbol, + name: tokenDetails.name, + type: tokenType, + } + + options.push({ + token: tokenInfo, + path, + isDirectSwap: paymentOption.isDirectSwap, + }) + } + + options.reverse() + + // Add ETH option based on the WETH option (same hops), but token is native ETH + const wethOpt = paymentOptions.find((opt) => { + const addr = (opt.tokenAddress as string)?.toLowerCase() + return wethAddress && addr === wethAddress.toLowerCase() + }) + + if (wethOpt) { + const hopRange = sanitizeHopRange( + wethOpt.startHopIndex, + wethOpt.endHopIndex, + swapRoute.mainPath.length, + `WETH payment option ${wethOpt.tokenAddress}` + ) + if (!hopRange) { + return options + } + + const relevantHops = swapRoute.mainPath.slice(hopRange.start, hopRange.end + 1) + if (relevantHops.length === 0) { + console.warn('[useSwapOptions] Skipping ETH option: WETH path has no hops in range') + return options + } + + const hops = normalizeHopsForTradeDirection(relevantHops, isBuying) + + // If user selected ETH (not WETH), preserve native currency in displayed path. + if (hops.length > 0 && wethAddress) { + if (isBuying) { + const firstHop = hops[0] + if (addrEq(firstHop.tokenIn, wethAddress)) { + hops[0] = { ...firstHop, tokenIn: NATIVE_TOKEN_ADDRESS } + } + } else { + const lastHop = hops[hops.length - 1] + if (addrEq(lastHop.tokenOut, wethAddress)) { + hops[hops.length - 1] = { ...lastHop, tokenOut: NATIVE_TOKEN_ADDRESS } + } + } + } + + const ethPath: SwapPath = { + hops, + isOptimal: true, + estimatedGas: undefined, + } + + const ethInfo: TokenInfo = { + address: NATIVE_TOKEN_ADDRESS, + symbol: 'ETH', + name: 'Ethereum', + type: 'eth', + } + + options.unshift({ + token: ethInfo, + path: ethPath, + isDirectSwap: wethOpt.isDirectSwap, + }) + } + + return options +} + async function fetchSwapOptions( chainId: CHAIN_ID, coinAddress: string, isBuying: boolean ): Promise { - const params = new URLSearchParams() - params.set('chainId', chainId.toString()) - params.set('coinAddress', coinAddress) - params.set('isBuying', isBuying.toString()) - - const response = await fetch(`${BASE_URL}/api/coins/swap-options?${params.toString()}`) - if (!response.ok) { - throw new Error('Failed to fetch swap options') + const swapRoute = await swapRouteRequest(coinAddress, chainId) + + if (!swapRoute) { + return [] } - const result = await response.json() - return deserializeSwapOptions(result) + + return convertSwapRouteToOptions(swapRoute, isBuying, chainId) } /** @@ -73,7 +318,7 @@ export function useSwapOptions( async ([, _chainId, _coinAddress, _isBuying]) => fetchSwapOptions(_chainId, _coinAddress, _isBuying), { - refreshInterval: 30_000, + refreshInterval: 60_000, revalidateOnFocus: false, revalidateOnReconnect: false, } diff --git a/packages/hooks/src/useSwapPath.ts b/packages/hooks/src/useSwapPath.ts deleted file mode 100644 index 74dfec3c3..000000000 --- a/packages/hooks/src/useSwapPath.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { buildSwapPath, SwapPath } from '@buildeross/swap' -import { CHAIN_ID } from '@buildeross/types' -import { useEffect, useState } from 'react' -import { Address } from 'viem' - -interface UseSwapPathParams { - chainId: CHAIN_ID - tokenIn?: Address - tokenOut?: Address - enabled?: boolean -} - -interface UseSwapPathReturn { - path: SwapPath | null - isLoading: boolean - error: Error | null -} - -/** - * Hook to build a swap path between two tokens - */ -export function useSwapPath({ - chainId, - tokenIn, - tokenOut, - enabled = true, -}: UseSwapPathParams): UseSwapPathReturn { - const [path, setPath] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (!enabled || !tokenIn || !tokenOut) { - setPath(null) - setIsLoading(false) - setError(null) - return - } - - let cancelled = false - - const fetchPath = async () => { - setIsLoading(true) - setError(null) - - try { - const result = await buildSwapPath(chainId, tokenIn, tokenOut) - - if (!cancelled) { - setPath(result) - setIsLoading(false) - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err : new Error('Failed to build swap path')) - setIsLoading(false) - } - } - } - - fetchPath() - - return () => { - cancelled = true - } - }, [chainId, tokenIn, tokenOut, enabled]) - - return { path, isLoading, error } -} diff --git a/packages/sdk/codegen.yml b/packages/sdk/codegen.yml index 7e75d4b78..15f1573fe 100644 --- a/packages/sdk/codegen.yml +++ b/packages/sdk/codegen.yml @@ -1,6 +1,6 @@ generates: src/subgraph/sdk.generated.ts: - schema: 'https://api.goldsky.com/api/public/project_cm33ek8kjx6pz010i2c3w8z25/subgraphs/nouns-builder-base-sepolia/0.1.13/gn' + schema: 'https://api.goldsky.com/api/public/project_cm33ek8kjx6pz010i2c3w8z25/subgraphs/nouns-builder-base-mainnet/0.1.14/gn' documents: 'src/subgraph/**/*.graphql' plugins: - typescript diff --git a/packages/sdk/src/subgraph/fragments/PaymentOption.graphql b/packages/sdk/src/subgraph/fragments/PaymentOption.graphql new file mode 100644 index 000000000..069dbcf72 --- /dev/null +++ b/packages/sdk/src/subgraph/fragments/PaymentOption.graphql @@ -0,0 +1,10 @@ +fragment PaymentOption on PaymentOption { + id + tokenAddress + tokenType + tokenName + tokenSymbol + startHopIndex + endHopIndex + isDirectSwap +} diff --git a/packages/sdk/src/subgraph/fragments/SwapHop.graphql b/packages/sdk/src/subgraph/fragments/SwapHop.graphql new file mode 100644 index 000000000..3af902c36 --- /dev/null +++ b/packages/sdk/src/subgraph/fragments/SwapHop.graphql @@ -0,0 +1,10 @@ +fragment SwapHop on SwapHop { + id + tokenIn + tokenOut + poolId + fee + hooks + tickSpacing + hopIndex +} diff --git a/packages/sdk/src/subgraph/fragments/SwapRoute.graphql b/packages/sdk/src/subgraph/fragments/SwapRoute.graphql new file mode 100644 index 000000000..c443566c6 --- /dev/null +++ b/packages/sdk/src/subgraph/fragments/SwapRoute.graphql @@ -0,0 +1,25 @@ +#import "./SwapHop.graphql" +#import "./PaymentOption.graphql" + +fragment SwapRoute on SwapRoute { + id + coinAddress + clankerToken { + tokenAddress + tokenName + tokenSymbol + } + zoraCoin { + coinAddress + name + symbol + } + mainPath(orderBy: hopIndex, orderDirection: asc) { + ...SwapHop + } + paymentOptions { + ...PaymentOption + } + createdAt + updatedAt +} diff --git a/packages/sdk/src/subgraph/index.ts b/packages/sdk/src/subgraph/index.ts index 0ee10d79e..0acf00350 100644 --- a/packages/sdk/src/subgraph/index.ts +++ b/packages/sdk/src/subgraph/index.ts @@ -21,6 +21,7 @@ export * from './requests/memberSnapshot' export * from './requests/proposalByExecutionTxHashQuery' export * from './requests/proposalQuery' export * from './requests/proposalsQuery' +export * from './requests/swapRouteQuery' export * from './requests/sync' export * from './requests/tokensQuery' export * from './requests/userProposalVote' @@ -42,11 +43,14 @@ export { type DaosForDashboardQuery, FeedEventType, OrderDirection, + type PaymentOptionFragment, type Proposal_Filter, type ProposalFragment, type ProposalVoteFragment, ProposalVoteSupport, Snapshot_OrderBy, + type SwapHopFragment, + type SwapRouteFragment, Token_OrderBy, type TokenWithDaoQuery, ZoraCoin_OrderBy, diff --git a/packages/sdk/src/subgraph/queries/swapRoute.graphql b/packages/sdk/src/subgraph/queries/swapRoute.graphql new file mode 100644 index 000000000..95e006f71 --- /dev/null +++ b/packages/sdk/src/subgraph/queries/swapRoute.graphql @@ -0,0 +1,7 @@ +#import "../fragments/SwapRoute.graphql" + +query swapRoute($coinAddress: ID!) { + swapRoute(id: $coinAddress) { + ...SwapRoute + } +} diff --git a/packages/sdk/src/subgraph/requests/swapRouteQuery.ts b/packages/sdk/src/subgraph/requests/swapRouteQuery.ts new file mode 100644 index 000000000..0fc0a120d --- /dev/null +++ b/packages/sdk/src/subgraph/requests/swapRouteQuery.ts @@ -0,0 +1,29 @@ +import { CHAIN_ID } from '@buildeross/types' +import { isAddress } from 'viem' + +import { SDK } from '../client' +import type { SwapRouteFragment } from '../sdk.generated' + +export const swapRouteRequest = async ( + coinAddress: string, + chainId: CHAIN_ID +): Promise => { + if (!coinAddress) throw new Error('No coin address provided') + if (!isAddress(coinAddress)) throw new Error('Invalid coin address') + + try { + const data = await SDK.connect(chainId).swapRoute({ + coinAddress: coinAddress.toLowerCase(), + }) + + return data.swapRoute || null + } catch (e: any) { + console.error('Error fetching swap route:', e) + try { + const sentry = (await import('@sentry/nextjs')) as typeof import('@sentry/nextjs') + sentry.captureException(e) + sentry.flush(2000).catch(() => {}) + } catch (_) {} + return null + } +} diff --git a/packages/sdk/src/subgraph/sdk.generated.ts b/packages/sdk/src/subgraph/sdk.generated.ts index 6a1cb8df0..bcd8badd9 100644 --- a/packages/sdk/src/subgraph/sdk.generated.ts +++ b/packages/sdk/src/subgraph/sdk.generated.ts @@ -2676,6 +2676,8 @@ export type PaymentOption = { route: SwapRoute startHopIndex: Scalars['Int']['output'] tokenAddress: Scalars['Bytes']['output'] + tokenName: Scalars['String']['output'] + tokenSymbol: Scalars['String']['output'] tokenType: CoinType } @@ -2743,6 +2745,46 @@ export type PaymentOption_Filter = { tokenAddress_not?: InputMaybe tokenAddress_not_contains?: InputMaybe tokenAddress_not_in?: InputMaybe> + tokenName?: InputMaybe + tokenName_contains?: InputMaybe + tokenName_contains_nocase?: InputMaybe + tokenName_ends_with?: InputMaybe + tokenName_ends_with_nocase?: InputMaybe + tokenName_gt?: InputMaybe + tokenName_gte?: InputMaybe + tokenName_in?: InputMaybe> + tokenName_lt?: InputMaybe + tokenName_lte?: InputMaybe + tokenName_not?: InputMaybe + tokenName_not_contains?: InputMaybe + tokenName_not_contains_nocase?: InputMaybe + tokenName_not_ends_with?: InputMaybe + tokenName_not_ends_with_nocase?: InputMaybe + tokenName_not_in?: InputMaybe> + tokenName_not_starts_with?: InputMaybe + tokenName_not_starts_with_nocase?: InputMaybe + tokenName_starts_with?: InputMaybe + tokenName_starts_with_nocase?: InputMaybe + tokenSymbol?: InputMaybe + tokenSymbol_contains?: InputMaybe + tokenSymbol_contains_nocase?: InputMaybe + tokenSymbol_ends_with?: InputMaybe + tokenSymbol_ends_with_nocase?: InputMaybe + tokenSymbol_gt?: InputMaybe + tokenSymbol_gte?: InputMaybe + tokenSymbol_in?: InputMaybe> + tokenSymbol_lt?: InputMaybe + tokenSymbol_lte?: InputMaybe + tokenSymbol_not?: InputMaybe + tokenSymbol_not_contains?: InputMaybe + tokenSymbol_not_contains_nocase?: InputMaybe + tokenSymbol_not_ends_with?: InputMaybe + tokenSymbol_not_ends_with_nocase?: InputMaybe + tokenSymbol_not_in?: InputMaybe> + tokenSymbol_not_starts_with?: InputMaybe + tokenSymbol_not_starts_with_nocase?: InputMaybe + tokenSymbol_starts_with?: InputMaybe + tokenSymbol_starts_with_nocase?: InputMaybe tokenType?: InputMaybe tokenType_in?: InputMaybe> tokenType_not?: InputMaybe @@ -2760,6 +2802,8 @@ export enum PaymentOption_OrderBy { RouteUpdatedAt = 'route__updatedAt', StartHopIndex = 'startHopIndex', TokenAddress = 'tokenAddress', + TokenName = 'tokenName', + TokenSymbol = 'tokenSymbol', TokenType = 'tokenType', } @@ -7371,6 +7415,18 @@ export type ExploreDaoFragment = { token: { __typename?: 'Token'; name: string; image?: string | null; tokenId: any } } +export type PaymentOptionFragment = { + __typename?: 'PaymentOption' + id: string + tokenAddress: any + tokenType: CoinType + tokenName: string + tokenSymbol: string + startHopIndex: number + endHopIndex: number + isDirectSwap: boolean +} + export type ProposalFragment = { __typename?: 'Proposal' abstainVotes: number @@ -7421,6 +7477,60 @@ export type SnapshotFragment = { dao: { __typename?: 'DAO'; name: string; id: string } } +export type SwapHopFragment = { + __typename?: 'SwapHop' + id: string + tokenIn: any + tokenOut: any + poolId: any + fee?: any | null + hooks?: any | null + tickSpacing?: number | null + hopIndex: number +} + +export type SwapRouteFragment = { + __typename?: 'SwapRoute' + id: string + coinAddress: any + createdAt: any + updatedAt: any + clankerToken?: { + __typename?: 'ClankerToken' + tokenAddress: any + tokenName: string + tokenSymbol: string + } | null + zoraCoin?: { + __typename?: 'ZoraCoin' + coinAddress: any + name: string + symbol: string + } | null + mainPath: Array<{ + __typename?: 'SwapHop' + id: string + tokenIn: any + tokenOut: any + poolId: any + fee?: any | null + hooks?: any | null + tickSpacing?: number | null + hopIndex: number + }> + paymentOptions: Array<{ + __typename?: 'PaymentOption' + id: string + tokenAddress: any + tokenType: CoinType + tokenName: string + tokenSymbol: string + startHopIndex: number + endHopIndex: number + isDirectSwap: boolean + }> +} + export type TokenFragment = { __typename?: 'Token' tokenId: any @@ -8765,6 +8875,55 @@ export type SnapshotsQuery = { }> } +export type SwapRouteQueryVariables = Exact<{ + coinAddress: Scalars['ID']['input'] +}> + +export type SwapRouteQuery = { + __typename?: 'Query' + swapRoute?: { + __typename?: 'SwapRoute' + id: string + coinAddress: any + createdAt: any + updatedAt: any + clankerToken?: { + __typename?: 'ClankerToken' + tokenAddress: any + tokenName: string + tokenSymbol: string + } | null + zoraCoin?: { + __typename?: 'ZoraCoin' + coinAddress: any + name: string + symbol: string + } | null + mainPath: Array<{ + __typename?: 'SwapHop' + id: string + tokenIn: any + tokenOut: any + poolId: any + fee?: any | null + hooks?: any | null + tickSpacing?: number | null + hopIndex: number + }> + paymentOptions: Array<{ + __typename?: 'PaymentOption' + id: string + tokenAddress: any + tokenType: CoinType + tokenName: string + tokenSymbol: string + startHopIndex: number + endHopIndex: number + isDirectSwap: boolean + }> + } | null +} + export type TokenWithDaoQueryVariables = Exact<{ id: Scalars['ID']['input'] }> @@ -9264,6 +9423,56 @@ export const SnapshotFragmentDoc = gql` blockNumber } ` +export const SwapHopFragmentDoc = gql` + fragment SwapHop on SwapHop { + id + tokenIn + tokenOut + poolId + fee + hooks + tickSpacing + hopIndex + } +` +export const PaymentOptionFragmentDoc = gql` + fragment PaymentOption on PaymentOption { + id + tokenAddress + tokenType + tokenName + tokenSymbol + startHopIndex + endHopIndex + isDirectSwap + } +` +export const SwapRouteFragmentDoc = gql` + fragment SwapRoute on SwapRoute { + id + coinAddress + clankerToken { + tokenAddress + tokenName + tokenSymbol + } + zoraCoin { + coinAddress + name + symbol + } + mainPath(orderBy: hopIndex, orderDirection: asc) { + ...SwapHop + } + paymentOptions { + ...PaymentOption + } + createdAt + updatedAt + } + ${SwapHopFragmentDoc} + ${PaymentOptionFragmentDoc} +` export const TokenFragmentDoc = gql` fragment Token on Token { tokenId @@ -10178,6 +10387,14 @@ export const SnapshotsDocument = gql` } ${SnapshotFragmentDoc} ` +export const SwapRouteDocument = gql` + query swapRoute($coinAddress: ID!) { + swapRoute(id: $coinAddress) { + ...SwapRoute + } + } + ${SwapRouteFragmentDoc} +` export const TokenWithDaoDocument = gql` query tokenWithDao($id: ID!) { token(id: $id) { @@ -10984,6 +11201,24 @@ export function getSdk( variables ) }, + swapRoute( + variables: SwapRouteQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + signal?: RequestInit['signal'] + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request({ + document: SwapRouteDocument, + variables, + requestHeaders: { ...requestHeaders, ...wrappedRequestHeaders }, + signal, + }), + 'swapRoute', + 'query', + variables + ) + }, tokenWithDao( variables: TokenWithDaoQueryVariables, requestHeaders?: GraphQLClientRequestHeaders, diff --git a/packages/swap/src/buildSwapOptions.ts b/packages/swap/src/buildSwapOptions.ts deleted file mode 100644 index 5760c7955..000000000 --- a/packages/swap/src/buildSwapOptions.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' -import { CHAIN_ID } from '@buildeross/types' -import { Address } from 'viem' - -import { buildSwapPath } from './buildSwapPath' -import { getCoinInfo } from './getCoinInfo' -import { CoinInfo, SwapPath } from './types' - -export interface SwapOption { - /** Token info including address, symbol, and type */ - token: CoinInfo - /** Swap path for this token <-> coin */ - path: SwapPath - /** True if this is a direct swap (single hop or no hop) */ - isDirectSwap: boolean -} - -export interface BuildSwapOptionsResult { - /** All available swap options */ - options: SwapOption[] - /** The main path used to discover options (coin <-> WETH) */ - mainPath: SwapPath -} - -/** - * Build all available swap options for a coin - * Returns ETH, WETH, and all intermediate tokens in the swap path - * - * @param chainId - Chain ID - * @param coinAddress - The coin to swap with - * @param isBuying - True if buying the coin, false if selling - * @returns All swap options with paths and token metadata - */ -export async function buildSwapOptions( - chainId: CHAIN_ID, - coinAddress: Address, - isBuying: boolean -): Promise { - const weth = WETH_ADDRESS[chainId] - if (!weth) return null - - // Build main path: coin <-> WETH to discover all tokens - const tokenIn = isBuying ? weth : coinAddress - const tokenOut = isBuying ? coinAddress : weth - - const mainPath = await buildSwapPath(chainId, tokenIn, tokenOut) - if (!mainPath) return null - - // Base tokens: ETH and WETH - const ethInfo: CoinInfo = { - address: NATIVE_TOKEN_ADDRESS, - type: 'eth', - symbol: 'ETH', - name: 'Ethereum', - } - const wethInfo: CoinInfo = { - address: weth, - type: 'weth', - symbol: 'WETH', - name: 'Wrapped Ether', - } - - const options: SwapOption[] = [] - - // If no hops, just return ETH and WETH (direct swap or no path) - if (!mainPath.hops || mainPath.hops.length === 0) { - // Build ETH path (same as WETH but with wrapping/unwrapping) - const ethPath = await buildSwapPath( - chainId, - isBuying ? NATIVE_TOKEN_ADDRESS : coinAddress, - isBuying ? coinAddress : NATIVE_TOKEN_ADDRESS - ) - - options.push( - { - token: ethInfo, - path: ethPath || { hops: [], isOptimal: true }, - isDirectSwap: true, - }, - { - token: wethInfo, - path: mainPath, - isDirectSwap: true, - } - ) - - return { options, mainPath } - } - - // Extract all unique tokens from the main path - const allTokensInPath = new Set() - mainPath.hops.forEach((hop) => { - allTokensInPath.add(hop.tokenIn.toLowerCase()) - allTokensInPath.add(hop.tokenOut.toLowerCase()) - }) - - // Separate intermediate tokens (exclude WETH, ETH, and the coin itself) - const intermediateAddresses: string[] = [] - allTokensInPath.forEach((addr) => { - if ( - addr !== weth.toLowerCase() && - addr !== coinAddress.toLowerCase() && - addr !== NATIVE_TOKEN_ADDRESS.toLowerCase() - ) { - intermediateAddresses.push(addr) - } - }) - - // Fetch coin info for all intermediate tokens - // getCoinInfo now has built-in caching, so this reuses any fetches from buildSwapPath - const intermediateTokens: CoinInfo[] = [] - for (const addr of intermediateAddresses) { - const info = await getCoinInfo(chainId, addr as Address) - if (info && info.symbol && info.type) { - intermediateTokens.push(info) - } - } - - // Add ETH option (same as WETH but with wrapping/unwrapping) - const ethPath = await buildSwapPath( - chainId, - isBuying ? NATIVE_TOKEN_ADDRESS : coinAddress, - isBuying ? coinAddress : NATIVE_TOKEN_ADDRESS - ) - options.push({ - token: ethInfo, - path: ethPath || mainPath, - isDirectSwap: !ethPath || ethPath.hops.length <= 1, - }) - - // Add WETH option (full main path) - options.push({ - token: wethInfo, - path: mainPath, - isDirectSwap: mainPath.hops.length <= 1, - }) - - // Add intermediate token options using subpaths - for (const token of intermediateTokens) { - const tokenAddr = token.address.toLowerCase() - let hopIndex = -1 - - // Find which hop this token appears in - for (let i = 0; i < mainPath.hops.length; i++) { - const hop = mainPath.hops[i] - if ( - hop.tokenIn.toLowerCase() === tokenAddr || - hop.tokenOut.toLowerCase() === tokenAddr - ) { - hopIndex = i - break - } - } - - if (hopIndex === -1) { - console.warn(`Token ${token.symbol} not found in main path`) - continue - } - - // Build subpath based on buy/sell direction - let subPath: SwapPath - - if (isBuying) { - // Buying: intermediate token -> coin - // Take hops from where intermediate token appears to the end - const hopsFromIntermediate = mainPath.hops.slice(hopIndex) - - // Check if the first hop starts with our token - if (hopsFromIntermediate[0].tokenIn.toLowerCase() === tokenAddr) { - subPath = { hops: hopsFromIntermediate, isOptimal: mainPath.isOptimal } - } else { - // Token is tokenOut of this hop, take from next hop - subPath = { - hops: mainPath.hops.slice(hopIndex + 1), - isOptimal: mainPath.isOptimal, - } - } - } else { - // Selling: coin -> intermediate token - // Take hops from start to where intermediate token appears - const hopsToIntermediate = mainPath.hops.slice(0, hopIndex + 1) - - // Check if the last hop ends with our token - const lastHop = hopsToIntermediate[hopsToIntermediate.length - 1] - if (lastHop.tokenOut.toLowerCase() === tokenAddr) { - subPath = { hops: hopsToIntermediate, isOptimal: mainPath.isOptimal } - } else { - // Token is tokenIn of this hop, don't include it - subPath = { - hops: mainPath.hops.slice(0, hopIndex), - isOptimal: mainPath.isOptimal, - } - } - } - - options.push({ - token, - path: subPath, - isDirectSwap: subPath.hops.length <= 1, - }) - } - - return { options, mainPath } -} diff --git a/packages/swap/src/buildSwapPath.ts b/packages/swap/src/buildSwapPath.ts deleted file mode 100644 index 8da1b649f..000000000 --- a/packages/swap/src/buildSwapPath.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' -import { CHAIN_ID } from '@buildeross/types' -import { Address } from 'viem' - -import { getCoinInfo } from './getCoinInfo' -import { CoinInfo, SwapPath, SwapPathHop } from './types' - -const addrEq = (a?: Address, b?: Address) => - !!a && !!b && a.toLowerCase() === b.toLowerCase() - -/** - * Check if address is a valid payment currency (ETH or WETH) - */ -const isValidPaymentCurrency = (addr: Address, weth: Address): boolean => { - return addrEq(addr, weth) || addrEq(addr, NATIVE_TOKEN_ADDRESS) -} - -function hopFromPairingSide( - pairingSide: CoinInfo, - tokenIn: Address, - tokenOut: Address -): SwapPathHop { - // Only Zora coins and Clanker tokens have pool info - if (pairingSide.type !== 'zora-coin' && pairingSide.type !== 'clanker-token') { - throw new Error('Cannot create hop from non-pool coin') - } - - return { - tokenIn, - tokenOut, - poolId: - pairingSide.type === 'clanker-token' ? pairingSide.poolId : pairingSide.poolKeyHash, - fee: pairingSide.fee, - hooks: pairingSide.hooks, - tickSpacing: pairingSide.tickSpacing, - } -} - -/** - * Create a hop between two tokens if they are paired in either direction. - * We pick hop metadata from the token whose `pairedToken` points at the other. - */ -function makeDirectHop(a: CoinInfo, b: CoinInfo): SwapPathHop | null { - // Check if a has pairedToken and it matches b - if ( - (a.type === 'zora-coin' || a.type === 'clanker-token') && - addrEq(a.pairedToken, b.address) - ) { - return hopFromPairingSide(a, a.address, b.address) - } - // Check if b has pairedToken and it matches a - if ( - (b.type === 'zora-coin' || b.type === 'clanker-token') && - addrEq(b.pairedToken, a.address) - ) { - return hopFromPairingSide(b, a.address, b.address) - } - return null -} - -/** - * Build a coin-info chain from `start` to WETH by following pairedToken pointers. - * Example: Z2 -> C2 -> C1 -> WETH - */ -async function buildChainToWeth( - chainId: CHAIN_ID, - start: Address, - weth: Address, - maxSteps = 4 -): Promise { - const chain: CoinInfo[] = [] - const visited = new Set() - - let cur: Address = start - - for (let i = 0; i < maxSteps; i++) { - const info = await getCoinInfo(chainId, cur) - if (!info) return null - - const key = info.address.toLowerCase() - if (visited.has(key)) return null // cycle / bad data - visited.add(key) - - chain.push(info) - - if (addrEq(info.address, weth)) return chain - - // Only Zora coins and Clanker tokens have pairedToken - if (info.type !== 'zora-coin' && info.type !== 'clanker-token') return null - if (!info.pairedToken) return null - cur = info.pairedToken - } - - return null -} - -/** - * buildSwapPath constraint: - * - tokenIn === WETH/ETH OR tokenOut === WETH/ETH - * - ETH (NATIVE_TOKEN_ADDRESS) and WETH are both valid payment currencies - * - Routing still goes through WETH pools (executeSwap handles ETH wrapping) - */ -export async function buildSwapPath( - chainId: CHAIN_ID, - tokenIn: Address, - tokenOut: Address -): Promise { - const weth = WETH_ADDRESS[chainId] - if (!weth) return null - - const tokenInIsValid = isValidPaymentCurrency(tokenIn, weth) - const tokenOutIsValid = isValidPaymentCurrency(tokenOut, weth) - - // Enforce constraint: one side must be a valid payment currency (ETH or WETH) - if (!tokenInIsValid && !tokenOutIsValid) return null - - // No-op swap between payment currencies (ETH<->WETH or ETH<->ETH or WETH<->WETH) - if (tokenInIsValid && tokenOutIsValid) return { hops: [], isOptimal: true } - - // Determine the non-payment-currency side (the coin we're swapping) - // Note: If tokenIn is ETH/WETH, we're buying the coin (tokenOut) - // If tokenOut is ETH/WETH, we're selling the coin (tokenIn) - const nonPaymentCurrency = (tokenInIsValid ? tokenOut : tokenIn) as Address - const chainToWeth = await buildChainToWeth(chainId, nonPaymentCurrency, weth, 4) - if (!chainToWeth) return null - - // Convert chain to hops: - // - If swapping coin -> ETH/WETH: use chain order - // - If swapping ETH/WETH -> coin: reverse chain - const ordered = tokenOutIsValid ? chainToWeth : [...chainToWeth].reverse() - - const hops: SwapPathHop[] = [] - for (let i = 0; i < ordered.length - 1; i++) { - const a = ordered[i] - const b = ordered[i + 1] - const hop = makeDirectHop(a, b) - if (!hop) return null // inconsistent pairing info vs expected adjacency - hops.push(hop) - } - - // Important: If user selected ETH (not WETH), replace WETH with NATIVE_TOKEN_ADDRESS - // in the first or last hop to preserve their currency choice - if (hops.length > 0) { - const isTokenInEth = addrEq(tokenIn, NATIVE_TOKEN_ADDRESS) - const isTokenOutEth = addrEq(tokenOut, NATIVE_TOKEN_ADDRESS) - - // Replace WETH with ETH in the appropriate hop - if (isTokenInEth) { - // User is buying with ETH - replace WETH in first hop's tokenIn - const firstHop = hops[0] - if (addrEq(firstHop.tokenIn, weth)) { - hops[0] = { ...firstHop, tokenIn: NATIVE_TOKEN_ADDRESS } - } - } - - if (isTokenOutEth) { - // User is selling for ETH - replace WETH in last hop's tokenOut - const lastHop = hops[hops.length - 1] - if (addrEq(lastHop.tokenOut, weth)) { - hops[hops.length - 1] = { ...lastHop, tokenOut: NATIVE_TOKEN_ADDRESS } - } - } - } - - return { hops, isOptimal: true } -} diff --git a/packages/swap/src/getCoinInfo.ts b/packages/swap/src/getCoinInfo.ts deleted file mode 100644 index 2da9a404d..000000000 --- a/packages/swap/src/getCoinInfo.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NATIVE_TOKEN_ADDRESS, WETH_ADDRESS } from '@buildeross/constants/addresses' -import { clankerTokenRequest, zoraCoinRequest } from '@buildeross/sdk/subgraph' -import { CHAIN_ID } from '@buildeross/types' -import { DEFAULT_CLANKER_TICK_SPACING, DYNAMIC_FEE_FLAG } from '@buildeross/utils/coining' -import { Address } from 'viem' - -import { CoinInfo } from './types' - -/** - * In-memory cache for coin info - * Key format: `${chainId}-${tokenAddress.toLowerCase()}` - */ -const coinInfoCache = new Map>() - -/** - * Clear the coin info cache (useful for testing or forcing refresh) - */ -export function clearCoinInfoCache(): void { - coinInfoCache.clear() -} - -/** - * Internal implementation that fetches coin info without caching - */ -async function getCoinInfoUncached( - chainId: CHAIN_ID, - tokenAddress: Address -): Promise { - const wethAddress = WETH_ADDRESS[chainId] - - // Check if it's native ETH - if (tokenAddress.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { - return { - address: NATIVE_TOKEN_ADDRESS, - type: 'eth', - symbol: 'ETH', - name: 'Ethereum', - } - } - - // Check if it's WETH - if (tokenAddress.toLowerCase() === wethAddress?.toLowerCase()) { - return { - address: wethAddress, - type: 'weth', - symbol: 'WETH', - name: 'Wrapped Ether', - } - } - - // Try to fetch as ZoraCoin - try { - const coin = await zoraCoinRequest(tokenAddress, chainId) - if (coin) { - return { - address: coin.coinAddress as Address, - type: 'zora-coin', - symbol: coin.symbol, - name: coin.name, - pairedToken: coin.currency as Address, - poolKeyHash: coin.poolKeyHash as string, - hooks: coin.poolHooks as Address, - fee: BigInt(coin.poolFee), - tickSpacing: coin.poolTickSpacing, - } - } - } catch (e) { - // Not a ZoraCoin, try ClankerToken - } - - // Try to fetch as ClankerToken - try { - const token = await clankerTokenRequest(tokenAddress, chainId) - if (token) { - return { - address: token.tokenAddress as Address, - type: 'clanker-token', - symbol: token.tokenSymbol, - name: token.tokenName, - pairedToken: token.pairedToken as Address, - poolId: token.poolId as string, - hooks: token.poolHook as Address, - // ClankerTokens always use dynamic fees - fee: BigInt(DYNAMIC_FEE_FLAG), - tickSpacing: DEFAULT_CLANKER_TICK_SPACING, - } - } - } catch (e) { - // Not a ClankerToken either - } - - return null -} - -/** - * Fetches coin information from the subgraph with caching - * Caches results in memory to avoid duplicate API calls - */ -export async function getCoinInfo( - chainId: CHAIN_ID, - tokenAddress: Address -): Promise { - const cacheKey = `${chainId}-${tokenAddress.toLowerCase()}` - - // Check cache first - if (coinInfoCache.has(cacheKey)) { - return coinInfoCache.get(cacheKey)! - } - - // Fetch and cache the promise (not just the result) - // This prevents multiple concurrent requests for the same token - const promise = getCoinInfoUncached(chainId, tokenAddress) - coinInfoCache.set(cacheKey, promise) - - return promise -} diff --git a/packages/swap/src/index.ts b/packages/swap/src/index.ts index 7ffe7e903..74ae097ee 100644 --- a/packages/swap/src/index.ts +++ b/packages/swap/src/index.ts @@ -7,11 +7,8 @@ * - Execute swaps directly via Uniswap V4 Universal Router */ -export * from './buildSwapOptions' -export * from './buildSwapPath' export * from './errors' export * from './executeSwap' -export { clearCoinInfoCache, getCoinInfo } from './getCoinInfo' export * from './getPoolMaxSwapAmount' export * from './getQuoteFromUniswap' export * from './types' diff --git a/packages/swap/src/types.ts b/packages/swap/src/types.ts index 20a472cdb..1f70acee3 100644 --- a/packages/swap/src/types.ts +++ b/packages/swap/src/types.ts @@ -225,6 +225,16 @@ type ClankerTokenInfo = BaseCoinInfo & { */ export type CoinInfo = EthCoinInfo | WethCoinInfo | ZoraCoinInfo | ClankerTokenInfo +/** + * Minimal token info used for swap option selection. + */ +export type TokenInfo = { + address: Address + symbol: string + name: string + type: CoinType +} + /** * Pool key for Uniswap V4 */ @@ -249,3 +259,15 @@ export type PoolMaxSwapAmountResult = { /** Available liquidity */ liquidity: bigint } + +/** + * Swap option for a coin + */ +export interface SwapOption { + /** Token info including address, symbol, and type */ + token: TokenInfo + /** Swap path for this token <-> coin */ + path: SwapPath + /** True if this is a direct swap (single hop or no hop) */ + isDirectSwap: boolean +} diff --git a/packages/ui/src/LikeButton/LikeButton.tsx b/packages/ui/src/LikeButton/LikeButton.tsx index 95287bac2..433058b1e 100644 --- a/packages/ui/src/LikeButton/LikeButton.tsx +++ b/packages/ui/src/LikeButton/LikeButton.tsx @@ -82,7 +82,7 @@ const LikeButton: React.FC = ({ justLikedTimeoutRef.current = setTimeout(() => { setJustLiked(false) - }, 3000) + }, 10000) }, [onLikeSuccess] ) diff --git a/packages/ui/src/SwapWidget/SwapWidget.css.ts b/packages/ui/src/SwapWidget/SwapWidget.css.ts index 79dfe0599..d1a26db67 100644 --- a/packages/ui/src/SwapWidget/SwapWidget.css.ts +++ b/packages/ui/src/SwapWidget/SwapWidget.css.ts @@ -5,6 +5,9 @@ export const swapInputContainer = style([ atoms({ p: 'x4', borderRadius: 'phat', + borderColor: 'border', + borderWidth: 'normal', + borderStyle: 'solid', backgroundColor: 'background1', }), ]) diff --git a/packages/ui/src/SwapWidget/SwapWidget.tsx b/packages/ui/src/SwapWidget/SwapWidget.tsx index 30efefe47..773f54edb 100644 --- a/packages/ui/src/SwapWidget/SwapWidget.tsx +++ b/packages/ui/src/SwapWidget/SwapWidget.tsx @@ -11,8 +11,7 @@ import { import { SwapError, SwapErrorCode, SwapErrorMessages } from '@buildeross/swap' import { CHAIN_ID } from '@buildeross/types' import { isChainIdSupportedForSaleOfZoraCoins } from '@buildeross/utils/coining' -import { truncateHex } from '@buildeross/utils/helpers' -import { formatPrice } from '@buildeross/utils/numbers' +import { formatPrice, formatTokenAmount } from '@buildeross/utils/numbers' import { Box, Button, Flex, Input, Text } from '@buildeross/zord' import { useCallback, useEffect, useMemo, useState } from 'react' import { Address, erc20Abi, formatEther, parseEther } from 'viem' @@ -191,6 +190,11 @@ export const SwapWidget = ({ const inputBalance = isBuying ? balanceMap.get(selectedPaymentToken.toLowerCase()) : balanceMap.get(coinAddress.toLowerCase()) + const coinBalance = balanceMap.get(coinAddress.toLowerCase()) + const receiveTokenBalance = isBuying + ? coinBalance + : balanceMap.get(selectedPaymentToken.toLowerCase()) + const receiveTokenSymbol = isBuying ? symbol : (selectedOption?.token.symbol ?? 'ETH') // Use the path from the selected option const path = selectedOption?.path ?? null @@ -334,7 +338,7 @@ export const SwapWidget = ({ opt.token.address.toLowerCase() === selectedPaymentToken.toLowerCase() )?.token.symbol || 'ETH' : symbol - return `Amount exceeds pool limit (max: ${parseFloat(formatEther(poolMaxAmount!)).toFixed(4)} ${tokenSymbol})` + return `Amount exceeds pool limit (max: ${formatTokenAmount(formatEther(poolMaxAmount!))} ${tokenSymbol})` } if (!userAddress) { return 'Please connect your wallet' @@ -470,6 +474,9 @@ export const SwapWidget = ({ return amount * price }, [amountOut, isBuying, coinAddress, selectedPaymentToken, getTokenUsdPrice]) + const hasValidQuote = + !!amountOut && amountOut > 0n && !exceedsBalance && !exceedsPoolLimit + // Create dropdown options for payment tokens with balances const tokenOptions: SelectOption
[] = useMemo(() => { return swapOptions.map((option) => { @@ -484,7 +491,7 @@ export const SwapWidget = ({ // Format label with balance if available let label: string if (balance !== undefined) { - const formattedBalance = parseFloat(formatEther(balance)).toFixed(4) + const formattedBalance = formatTokenAmount(formatEther(balance)) label = `${displayName} (${formattedBalance} ${token.symbol})` } else { label = displayName @@ -571,7 +578,7 @@ export const SwapWidget = ({ {inputBalance !== undefined && ( - Balance: {parseFloat(formatEther(inputBalance)).toFixed(4)} + Balance: {formatTokenAmount(formatEther(inputBalance))} )} @@ -614,7 +621,7 @@ export const SwapWidget = ({ !isLoadingPoolMax && ( - Pool limit: {parseFloat(formatEther(poolMaxAmount)).toFixed(4)}{' '} + Pool limit: {formatTokenAmount(formatEther(poolMaxAmount))}{' '} {isBuying ? swapOptions.find( (opt) => @@ -629,45 +636,44 @@ export const SwapWidget = ({ {/* Output Display */} - {amountOut && amountOut > 0n && !exceedsBalance && !exceedsPoolLimit && ( - - + + + You receive (estimated) - - {parseFloat(formatEther(amountOut)).toFixed(6)} + {userAddress && receiveTokenBalance !== undefined && ( + + Balance: {formatTokenAmount(formatEther(receiveTokenBalance))} + + )} + + + {hasValidQuote && amountOut ? formatTokenAmount(formatEther(amountOut)) : '--'} + + + + {receiveTokenSymbol} - + {hasValidQuote && outputUsdValue !== null && ( - {isBuying - ? symbol - : swapOptions.find( - (opt) => - opt.token.address.toLowerCase() === - selectedPaymentToken.toLowerCase() - )?.token.symbol || 'ETH'} + ≈ {formatPrice(outputUsdValue)} - {outputUsdValue !== null && ( - - ≈ {formatPrice(outputUsdValue)} - - )} - - - )} + )} + + {/* Pending Transaction Message */} {pendingTxHash && ( - Transaction pending... View transaction:{' '} + Transaction pending... View on{' '} - {truncateHex(pendingTxHash)} + Explorer diff --git a/packages/utils/src/numbers.test.ts b/packages/utils/src/numbers.test.ts new file mode 100644 index 000000000..b8329fe80 --- /dev/null +++ b/packages/utils/src/numbers.test.ts @@ -0,0 +1,21 @@ +import { assert, describe, it } from 'vitest' + +import { formatTokenAmount } from './numbers' + +describe('formatTokenAmount', () => { + it('shows at least two decimals for whole numbers', () => { + assert.equal(formatTokenAmount('42'), '42.00') + }) + + it('truncates (does not round) beyond max decimals', () => { + assert.equal(formatTokenAmount('1.23456789129'), '1.2345678912') + }) + + it('keeps values representable within max decimals', () => { + assert.equal(formatTokenAmount('0.0000000019'), '0.0000000019') + }) + + it('shows a small non-zero indicator for tiny values that collapse to zero', () => { + assert.equal(formatTokenAmount('0.00000000009'), '<0.000000001') + }) +}) diff --git a/packages/utils/src/numbers.ts b/packages/utils/src/numbers.ts index 06d1edb43..69f8f47f5 100644 --- a/packages/utils/src/numbers.ts +++ b/packages/utils/src/numbers.ts @@ -2,6 +2,15 @@ import BigNumber from 'bignumber.js' export type BigNumberish = BigNumber | bigint | string | number +type DecimalFormatMode = 'round' | 'truncate' + +type DecimalFormatOptions = { + minDecimals: number + maxDecimals: number + mode?: DecimalFormatMode + useGrouping?: boolean +} + const ONE_QUADRILLION = new BigNumber(1000000000000000) const ONE_TRILLION = new BigNumber(1000000000000) const ONE_BILLION = new BigNumber(1000000000) @@ -68,6 +77,71 @@ export function formatCryptoVal(cryptoVal: BigNumber | BigNumberish | string) { : formatCryptoValUnder100K(parsedamount) } +function formatDecimalValue( + value: BigNumber | BigNumberish | string, + { minDecimals, maxDecimals, mode = 'round', useGrouping = false }: DecimalFormatOptions +): string { + const raw = typeof value === 'string' ? value : value?.toString() + const parsed = new BigNumber(raw) + + if (!parsed.isFinite()) return '0' + + const roundingMode = + mode === 'truncate' ? BigNumber.ROUND_DOWN : BigNumber.ROUND_HALF_UP + const normalized = parsed.decimalPlaces(maxDecimals, roundingMode) + + let [integerPart, fractionalPart = ''] = normalized.toFixed(maxDecimals).split('.') + + fractionalPart = fractionalPart.replace(/0+$/, '') + if (fractionalPart.length < minDecimals) { + fractionalPart = fractionalPart.padEnd(minDecimals, '0') + } + + if (useGrouping) { + const sign = integerPart.startsWith('-') ? '-' : '' + const absInteger = sign ? integerPart.slice(1) : integerPart + integerPart = `${sign}${absInteger.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}` + } + + return fractionalPart.length > 0 ? `${integerPart}.${fractionalPart}` : integerPart +} + +export function formatTokenAmount(value: BigNumber | BigNumberish | string): string { + const minDecimals = 2 + const maxDecimals = 10 + const mode: DecimalFormatMode = 'truncate' + const useGrouping = false + + const formatted = formatDecimalValue(value, { + minDecimals, + maxDecimals, + mode, + useGrouping, + }) + + const raw = typeof value === 'string' ? value : value?.toString() + const parsed = new BigNumber(raw) + if (!parsed.isFinite()) return formatted + + const zeroFormatted = formatDecimalValue(0, { + minDecimals, + maxDecimals, + mode, + useGrouping, + }) + + const threshold = new BigNumber(10).pow(1 - maxDecimals) + if ( + parsed.isGreaterThan(0) && + parsed.isLessThan(threshold) && + formatted === zeroFormatted + ) { + return `<${threshold.toFixed(Math.max(0, maxDecimals - 1))}` + } + + return formatted +} + /** * Format price to human-readable format with appropriate precision * Uses adaptive precision: @@ -90,31 +164,32 @@ export function formatPrice(price: number | null | undefined): string { // For prices >= $1, use 2 decimals with thousand separators if (absValue >= 1) { - return `${sign}$${absValue.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + return `${sign}$${formatDecimalValue(absValue, { + minDecimals: 2, + maxDecimals: 2, + mode: 'round', + useGrouping: true, })}` } // For prices >= $0.01, use 4 decimals if (absValue >= 0.01) { - return `${sign}$${absValue.toFixed(4)}` + return `${sign}$${formatDecimalValue(absValue, { + minDecimals: 4, + maxDecimals: 4, + mode: 'round', + useGrouping: false, + })}` } // For very small prices (< $0.01), show up to 10 decimals - // This handles values like $0.0000000433 properly - let formatted = absValue.toFixed(10) - - // Trim trailing zeros but keep at least 2 decimals - formatted = formatted.replace(/(\.\d*?)0+$/, '$1') - if (formatted.endsWith('.')) { - formatted += '00' - } else { - const decimalPart = formatted.split('.')[1] - if (decimalPart && decimalPart.length < 2) { - formatted += '0' - } - } + // This handles values like $0.0000000433 properly. + const formatted = formatDecimalValue(absValue, { + minDecimals: 2, + maxDecimals: 10, + mode: 'round', + useGrouping: false, + }) return `${sign}$${formatted}` }