| 
 | 1 | +import type { TokenListItem } from "@/common/utils/tokenList"  | 
 | 2 | +import { findToken } from "@/swapService/utils"  | 
 | 3 | +import { Chains, type IFetchService } from "@balmy/sdk"  | 
 | 4 | +import type {  | 
 | 5 | +  BuildTxParams,  | 
 | 6 | +  IQuoteSource,  | 
 | 7 | +  QuoteParams,  | 
 | 8 | +  QuoteSourceMetadata,  | 
 | 9 | +  SourceQuoteResponse,  | 
 | 10 | +  SourceQuoteTransaction,  | 
 | 11 | +} from "@balmy/sdk/dist/services/quotes/quote-sources/types"  | 
 | 12 | +import {  | 
 | 13 | +  addQuoteSlippage,  | 
 | 14 | +  calculateAllowanceTarget,  | 
 | 15 | +  failed,  | 
 | 16 | +} from "@balmy/sdk/dist/services/quotes/quote-sources/utils"  | 
 | 17 | +import { log } from "@uniswap/smart-order-router"  | 
 | 18 | +import qs from "qs"  | 
 | 19 | +import { type Address, getAddress, isAddressEqual } from "viem"  | 
 | 20 | +import { arbitrum, avalanche, base, bsc, mainnet, optimism } from "viem/chains"  | 
 | 21 | + | 
 | 22 | +const SUPPORTED_CHAINS: Record<string, string> = {  | 
 | 23 | +  [mainnet.id]: "mainnet",  | 
 | 24 | +  [base.id]: "base",  | 
 | 25 | +  [optimism.id]: "optimism",  | 
 | 26 | +  [arbitrum.id]: "arbitrum",  | 
 | 27 | +  [avalanche.id]: "avalanche",  | 
 | 28 | +  [bsc.id]: "bsc",  | 
 | 29 | +  [999]: "hyperevm",  | 
 | 30 | +  [747474]: "katana",  | 
 | 31 | +  [146]: "sonic",  | 
 | 32 | +  [43111]: "hemi",  | 
 | 33 | +}  | 
 | 34 | + | 
 | 35 | +export const SPECTRA_METADATA: QuoteSourceMetadata<SpectraSupport> = {  | 
 | 36 | +  name: "Spectra",  | 
 | 37 | +  supports: {  | 
 | 38 | +    chains: Object.keys(SUPPORTED_CHAINS).map(Number),  | 
 | 39 | +    swapAndTransfer: true,  | 
 | 40 | +    buyOrders: false,  | 
 | 41 | +  },  | 
 | 42 | +  logoURI: "",  | 
 | 43 | +}  | 
 | 44 | +type SpectraSupport = { buyOrders: false; swapAndTransfer: true }  | 
 | 45 | +type SpectraConfig = object  | 
 | 46 | +type SpectraData = { tx: SourceQuoteTransaction }  | 
 | 47 | + | 
 | 48 | +type ExpiredPoolsCache = {  | 
 | 49 | +  [chainId: number]: {  | 
 | 50 | +    lastUpdated: number  | 
 | 51 | +    pools: string[]  | 
 | 52 | +  }  | 
 | 53 | +}  | 
 | 54 | + | 
 | 55 | +const todayUTC = () => new Date().setUTCHours(0, 0, 0, 0)  | 
 | 56 | + | 
 | 57 | +export class CustomSpectraQuoteSource  | 
 | 58 | +  implements IQuoteSource<SpectraSupport, SpectraConfig, SpectraData>  | 
 | 59 | +{  | 
 | 60 | +  private expiredPoolsCache: ExpiredPoolsCache = {}  | 
 | 61 | + | 
 | 62 | +  getMetadata() {  | 
 | 63 | +    return SPECTRA_METADATA  | 
 | 64 | +  }  | 
 | 65 | + | 
 | 66 | +  async quote(  | 
 | 67 | +    params: QuoteParams<SpectraSupport, SpectraConfig>,  | 
 | 68 | +  ): Promise<SourceQuoteResponse<SpectraData>> {  | 
 | 69 | +    const { dstAmount, to, data } = await this.getQuote(params)  | 
 | 70 | +    const quote = {  | 
 | 71 | +      sellAmount: params.request.order.sellAmount,  | 
 | 72 | +      buyAmount: BigInt(dstAmount),  | 
 | 73 | +      allowanceTarget: calculateAllowanceTarget(params.request.sellToken, to),  | 
 | 74 | +      customData: {  | 
 | 75 | +        tx: {  | 
 | 76 | +          to,  | 
 | 77 | +          calldata: data,  | 
 | 78 | +        },  | 
 | 79 | +      },  | 
 | 80 | +    }  | 
 | 81 | + | 
 | 82 | +    return addQuoteSlippage(  | 
 | 83 | +      quote,  | 
 | 84 | +      params.request.order.type,  | 
 | 85 | +      params.request.config.slippagePercentage,  | 
 | 86 | +    )  | 
 | 87 | +  }  | 
 | 88 | + | 
 | 89 | +  async buildTx({  | 
 | 90 | +    request,  | 
 | 91 | +  }: BuildTxParams<  | 
 | 92 | +    SpectraConfig,  | 
 | 93 | +    SpectraData  | 
 | 94 | +  >): Promise<SourceQuoteTransaction> {  | 
 | 95 | +    return request.customData.tx  | 
 | 96 | +  }  | 
 | 97 | + | 
 | 98 | +  private async getQuote({  | 
 | 99 | +    components: { fetchService },  | 
 | 100 | +    request: {  | 
 | 101 | +      chainId,  | 
 | 102 | +      sellToken,  | 
 | 103 | +      buyToken,  | 
 | 104 | +      order,  | 
 | 105 | +      config: { slippagePercentage, timeout },  | 
 | 106 | +      accounts: { takeFrom, recipient },  | 
 | 107 | +    },  | 
 | 108 | +  }: QuoteParams<SpectraSupport, SpectraConfig>) {  | 
 | 109 | +    const tokenIn = findToken(chainId, getAddress(sellToken))  | 
 | 110 | +    const tokenOut = findToken(chainId, getAddress(buyToken))  | 
 | 111 | +    if (!tokenIn || !tokenOut) throw new Error("Missing token in or out")  | 
 | 112 | +    if (!tokenIn.meta?.isSpectraPT && !tokenOut.meta?.isSpectraPT) {  | 
 | 113 | +      failed(  | 
 | 114 | +        SPECTRA_METADATA,  | 
 | 115 | +        chainId,  | 
 | 116 | +        sellToken,  | 
 | 117 | +        buyToken,  | 
 | 118 | +        "Not Spectra PT tokens",  | 
 | 119 | +      )  | 
 | 120 | +    }  | 
 | 121 | +    let url  | 
 | 122 | +    if (tokenIn.meta?.isSpectraPT && tokenOut.meta?.isSpectraPT) {  | 
 | 123 | +      // rollover  | 
 | 124 | +      failed(SPECTRA_METADATA, chainId, sellToken, buyToken, "Not supported")  | 
 | 125 | +    } else if (  | 
 | 126 | +      tokenIn.meta?.isSpectraPT &&  | 
 | 127 | +      (await this.isExpiredMarket(fetchService, chainId, tokenIn, timeout))  | 
 | 128 | +    ) {  | 
 | 129 | +      // redeem expired PT  | 
 | 130 | + | 
 | 131 | +      const queryParams = {  | 
 | 132 | +        receiver: recipient || takeFrom,  | 
 | 133 | +        slippage: slippagePercentage, // (0 to 100)  | 
 | 134 | +        tokenOut: buyToken,  | 
 | 135 | +        amountIn: order.sellAmount.toString(),  | 
 | 136 | +      }  | 
 | 137 | + | 
 | 138 | +      const queryString = qs.stringify(queryParams)  | 
 | 139 | + | 
 | 140 | +      url = `${getUrl(chainId)}/pts/${sellToken}/redeem?${queryString}`  | 
 | 141 | +    } else {  | 
 | 142 | +      // swap  | 
 | 143 | +      const queryParams = {  | 
 | 144 | +        tokenIn: sellToken,  | 
 | 145 | +        tokenOut: buyToken,  | 
 | 146 | +        receiver: recipient || takeFrom,  | 
 | 147 | +        amountIn: order.sellAmount.toString(),  | 
 | 148 | +        slippage: slippagePercentage, // 0 to 100  | 
 | 149 | +      }  | 
 | 150 | + | 
 | 151 | +      const queryString = qs.stringify(queryParams)  | 
 | 152 | + | 
 | 153 | +      const spectraMarket =  | 
 | 154 | +        tokenIn?.meta?.spectraPool || tokenOut?.meta?.spectraPool  | 
 | 155 | + | 
 | 156 | +      url = `${getUrl(chainId)}/pools/${spectraMarket}/swap?${queryString}`  | 
 | 157 | +    }  | 
 | 158 | + | 
 | 159 | +    const response = await fetchService.fetch(url, {  | 
 | 160 | +      timeout,  | 
 | 161 | +    })  | 
 | 162 | + | 
 | 163 | +    if (!response.ok) {  | 
 | 164 | +      const msg =  | 
 | 165 | +        (await response.text()) || `Failed with status ${response.status}`  | 
 | 166 | + | 
 | 167 | +      log({ name: "[SPECTRA ERROR]", msg, recipient, url })  | 
 | 168 | +      failed(SPECTRA_METADATA, chainId, sellToken, buyToken, msg)  | 
 | 169 | +    }  | 
 | 170 | + | 
 | 171 | +    const {  | 
 | 172 | +      amountOut: dstAmount,  | 
 | 173 | +      router: to,  | 
 | 174 | +      calldata: data,  | 
 | 175 | +    } = await response.json()  | 
 | 176 | + | 
 | 177 | +    return { dstAmount, to, data }  | 
 | 178 | +  }  | 
 | 179 | + | 
 | 180 | +  private async isExpiredMarket(  | 
 | 181 | +    fetchService: IFetchService,  | 
 | 182 | +    chainId: number,  | 
 | 183 | +    token: TokenListItem,  | 
 | 184 | +    timeout?: string,  | 
 | 185 | +  ) {  | 
 | 186 | +    if (  | 
 | 187 | +      !this.expiredPoolsCache[chainId] ||  | 
 | 188 | +      this.expiredPoolsCache[chainId].lastUpdated !== todayUTC()  | 
 | 189 | +    ) {  | 
 | 190 | +      this.expiredPoolsCache[chainId] = {  | 
 | 191 | +        pools: [],  | 
 | 192 | +        lastUpdated: -1,  | 
 | 193 | +      }  | 
 | 194 | + | 
 | 195 | +      const url = `${getUrl(chainId)}/pools`  | 
 | 196 | +      const response = await fetchService.fetch(url, {  | 
 | 197 | +        timeout: timeout as any,  | 
 | 198 | +      })  | 
 | 199 | + | 
 | 200 | +      if (response.ok) {  | 
 | 201 | +        const allPools = await response.json()  | 
 | 202 | + | 
 | 203 | +        this.expiredPoolsCache[chainId] = {  | 
 | 204 | +          pools: allPools  | 
 | 205 | +            .filter((p: any) => p.maturity < (Date.now() / 1000).toFixed(0))  | 
 | 206 | +            .map((p: any) => p.address),  | 
 | 207 | +          lastUpdated: todayUTC(),  | 
 | 208 | +        }  | 
 | 209 | +      }  | 
 | 210 | +    }  | 
 | 211 | + | 
 | 212 | +    return !!this.expiredPoolsCache[chainId].pools.find((p) =>  | 
 | 213 | +      isAddressEqual(p as Address, token.meta?.spectraPool as Address),  | 
 | 214 | +    )  | 
 | 215 | +  }  | 
 | 216 | + | 
 | 217 | +  isConfigAndContextValidForQuoting(  | 
 | 218 | +    config: Partial<SpectraConfig> | undefined,  | 
 | 219 | +  ): config is SpectraConfig {  | 
 | 220 | +    return true  | 
 | 221 | +  }  | 
 | 222 | + | 
 | 223 | +  isConfigAndContextValidForTxBuilding(  | 
 | 224 | +    config: Partial<SpectraConfig> | undefined,  | 
 | 225 | +  ): config is SpectraConfig {  | 
 | 226 | +    return true  | 
 | 227 | +  }  | 
 | 228 | +}  | 
 | 229 | + | 
 | 230 | +function getUrl(chainId: number) {  | 
 | 231 | +  return `https://app.spectra.finance/api/v1/${SUPPORTED_CHAINS[chainId]}`  | 
 | 232 | +}  | 
0 commit comments