Skip to content

Commit bbbacd9

Browse files
authored
Merge branch 'master' into sync/tokenlist-20251021-150742
Signed-off-by: Dariusz Glowinski <[email protected]>
2 parents bd73656 + 50e4d63 commit bbbacd9

File tree

5 files changed

+239
-1
lines changed

5 files changed

+239
-1
lines changed

src/common/utils/tokenList.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type TokenListItem = {
1616
pendleCrossChainPTPaired?: string
1717
isPendleLP?: boolean
1818
isPendleWrappedLP?: boolean
19+
isSpectraPT?: boolean
20+
spectraPool?: string
1921
}
2022
}
2123

src/swapService/config/mainnet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ const mainnetRoutingConfig: ChainRoutingConfig = [
119119
"pendle",
120120
"okx-dex",
121121
"0x",
122+
"spectra",
122123
],
123124
},
124125
},
@@ -157,6 +158,7 @@ const mainnetRoutingConfig: ChainRoutingConfig = [
157158
"enso",
158159
"pendle",
159160
"0x",
161+
"spectra",
160162
],
161163
},
162164
},

src/swapService/strategies/balmySDK/customSourceList.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { CustomOogaboogaQuoteSource } from "./sources/oogaboogaQuoteSource"
2222
import { CustomOpenOceanQuoteSource } from "./sources/openOceanQuoteSource"
2323
import { CustomParaswapQuoteSource } from "./sources/paraswapQuoteSource"
2424
import { CustomPendleQuoteSource } from "./sources/pendleQuoteSource"
25+
import { CustomSpectraQuoteSource } from "./sources/spectraQuoteSource"
2526
import { CustomUniswapQuoteSource } from "./sources/uniswapQuoteSource"
2627

2728
import pendleAggregators from "./sources/pendle/pendleAggregators.json"
@@ -45,6 +46,7 @@ const customSources = {
4546
"okx-dex": new CustomOKXDexQuoteSource(),
4647
paraswap: new CustomParaswapQuoteSource(),
4748
"0x": new CustomZRXQuoteSource(),
49+
spectra: new CustomSpectraQuoteSource(),
4850
oku_bob_icecreamswap: new CustomOkuQuoteSource(
4951
"icecreamswap",
5052
"IceCreamSwap",
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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+
}

tokenLists/tokenList_1.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2333,7 +2333,7 @@
23332333
"decimals": 18,
23342334
"logoURI": "/tokens/1/0x50Bd66D59911F5e086Ec87aE43C811e0D059DD11.svg",
23352335
"meta": {
2336-
"isSpectraMarket": true,
2336+
"isSpectraPT": true,
23372337
"spectraPool": "0x593a8a7c12d8111e12945ff167389662a02b5ece"
23382338
}
23392339
},

0 commit comments

Comments
 (0)