Skip to content

Commit 64c4223

Browse files
authored
Merge pull request #2415 from balancer/v3-canary
publish to prod
2 parents 2e14c7c + caf7f25 commit 64c4223

File tree

9 files changed

+150
-20
lines changed

9 files changed

+150
-20
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# backend
22

3+
## 1.56.0
4+
5+
### Minor Changes
6+
7+
- 845effd: add coingecko pricing call proxy
8+
9+
### Patch Changes
10+
11+
- 8eed23a: option to skip ssl verification for apr sources
12+
313
## 1.55.3
414

515
### Patch Changes

apps/api/rest-routes.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import {
44
beetsGetCirculatingSupplySonic,
55
beetsGetTotalSupplySonic,
66
} from '../../modules/beets/lib/beets';
7+
import { latestTokenPrice } from '../../modules/token/latest-token-price';
8+
import config from '../../config';
9+
import { Chain } from '@prisma/client';
10+
import * as crypto from 'crypto';
11+
12+
const isHexAddress = (addr: any) =>
13+
typeof addr === 'string' && addr.length === 42 && addr.startsWith('0x') && /^[0-9a-f]{40}$/i.test(addr.slice(2));
714

815
export function loadRestRoutes(app: Express) {
916
app.use('/health', (_, res) => res.sendStatus(200));
@@ -22,4 +29,48 @@ export function loadRestRoutes(app: Express) {
2229
res.send(result);
2330
});
2431
});
32+
33+
app.get('/price', async (req, res) => {
34+
res.type('application/json');
35+
36+
const chain = req.query.chain;
37+
const tokens = req.query.tokens && (req.query.tokens as string).split(',');
38+
39+
// Validate params
40+
if (typeof chain !== 'string' || !(chain in config)) {
41+
res.status(400).end();
42+
return;
43+
}
44+
45+
if (!Array.isArray(tokens) || tokens.length === 0 || !tokens.every(isHexAddress) || tokens.length > 8) {
46+
res.status(400).end();
47+
return;
48+
}
49+
50+
const prices = await latestTokenPrice(chain as Chain, tokens as string[]);
51+
52+
// Build response body
53+
const responseBody = { prices };
54+
const bodyString = JSON.stringify(responseBody);
55+
56+
// Generate strong ETag: MD5 hash of the body (fast for small JSON)
57+
const etag = crypto.createHash('md5').update(bodyString).digest('hex');
58+
59+
// Set ETag header
60+
res.set('ETag', `"${etag}"`);
61+
62+
// Check for conditional request
63+
const clientEtag = req.get('If-None-Match');
64+
if (clientEtag === `"${etag}"` || clientEtag === etag) {
65+
// Handle quoted/unquoted client headers
66+
res.status(304).end(); // Not modified: No body needed
67+
return;
68+
}
69+
70+
// Set caching headers (unchanged)
71+
res.set('Cache-Control', 'public, max-age=600, s-maxage=600, stale-while-revalidate=30, stale-if-error=86400');
72+
73+
// Send full response
74+
res.send(responseBody);
75+
});
2576
}

config/mainnet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ export default <NetworkData>{
405405
extractors: [
406406
{ type: 'path', token: '0x198d7387fa97a73f05b8578cdeff8f2a1f34cd1f', path: '$.wjauraApy' },
407407
],
408+
skipSSL: true,
408409
},
409410
{
410411
url: 'https://universe.staderlabs.com/eth/apy',

modules/token-yields/examples/fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ const main = async (_chain: string) => {
1010
};
1111

1212
main(process.argv[2])
13-
.then(console.log)
13+
.then((data) => console.log(JSON.stringify(data, null, 2)))
1414
.catch(console.log)
1515
.finally(() => process.exit(0));

modules/token-yields/handlers/sources/http-yield-handler.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
import { JSONPath } from 'jsonpath-plus';
22
import { TokenYieldHandler, TokenYieldHttpFetchConfig } from '../../types';
33

4-
const fetchWithTimeout = async (url: string, options: RequestInit = {}, timeoutMs: number = 20000): Promise<any> => {
4+
const fetchWithTimeout = async (
5+
url: string,
6+
options: RequestInit = {},
7+
skipSSL = false,
8+
timeoutMs: number = 20000,
9+
): Promise<any> => {
510
const controller = new AbortController();
611
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
712

13+
let previous_NODE_TLS_REJECT_UNAUTHORIZED = process.env['NODE_TLS_REJECT_UNAUTHORIZED'];
14+
if (skipSSL) {
15+
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
16+
}
817
const fetchPromise = fetch(url, {
918
...options,
1019
signal: controller.signal,
1120
});
21+
if (skipSSL) {
22+
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = previous_NODE_TLS_REJECT_UNAUTHORIZED;
23+
}
1224

1325
// Timeout promise - needed to handle bun edge case that doesn't throw when fetch is aborted
1426
const timeoutPromise = new Promise((_, reject) => {
@@ -65,11 +77,15 @@ const normalizeValue = (value: any, { url, average, scale }: TokenYieldHttpFetch
6577
};
6678

6779
export const httpTokenYieldHandler: TokenYieldHandler = async (config: TokenYieldHttpFetchConfig) => {
68-
const res = await fetchWithTimeout(config.url, {
69-
method: config.method ?? (config.body ? 'POST' : 'GET'),
70-
headers: config.headers,
71-
...(config.body && { body: config.body }),
72-
});
80+
const res = await fetchWithTimeout(
81+
config.url,
82+
{
83+
method: config.method ?? (config.body ? 'POST' : 'GET'),
84+
headers: config.headers,
85+
...(config.body && { body: config.body }),
86+
},
87+
config.skipSSL,
88+
);
7389
const json = await res.json();
7490

7591
return transform(extract(json, config), config);

modules/token-yields/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface TokenYieldHttpFetchConfig {
3737
body?: string;
3838
scale?: number;
3939
average?: boolean;
40+
skipSSL?: boolean;
4041
extractors: readonly EntryExtractor[];
4142
}
4243

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { prisma } from '../../prisma/prisma-client';
2+
import { Chain } from '@prisma/client';
3+
import { CoingeckoDataService } from './lib/coingecko-data.service';
4+
5+
export const latestTokenPrice = async (chain: Chain, tokenAddresses: string[]) => {
6+
const lowerCaseAddresses = tokenAddresses.map((a) => a.toLowerCase());
7+
const prices = await prisma.prismaTokenCurrentPrice
8+
.findMany({
9+
where: {
10+
chain,
11+
tokenAddress: {
12+
in: lowerCaseAddresses,
13+
},
14+
},
15+
})
16+
.then((arr) =>
17+
arr.reduce(
18+
(obj, item) => {
19+
obj[item.tokenAddress] = item.price;
20+
21+
return obj;
22+
},
23+
{} as Record<string, number>,
24+
),
25+
);
26+
27+
const missingPrices = lowerCaseAddresses.filter((tokenAddress) => !prices[tokenAddress]);
28+
29+
if (missingPrices.length > 0) {
30+
const cg = new CoingeckoDataService();
31+
const coingeckoPrices = await cg.tokenPrice(chain, missingPrices);
32+
33+
for (const token of Object.keys(coingeckoPrices)) {
34+
const price = coingeckoPrices[token];
35+
36+
prices[token] = price.usd;
37+
}
38+
}
39+
40+
return prices;
41+
};

modules/token/lib/coingecko-data.service.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,18 @@ export class CoingeckoDataService {
100100
.filter(([chain, _]) => chain !== 'SEPOLIA') // Sepolia is not in CG
101101
.map(([chain, chainConfig]) => [chainConfig.coingecko.platformId, chain]),
102102
);
103-
const coinMap = coinIds.reduce((acc, coin) => {
104-
for (const [platform, address] of Object.entries(coin.platforms)) {
105-
if (platformToChain[platform]) {
106-
// tokenAddress-chain
107-
acc[`${address.toLowerCase()}-${platformToChain[platform]}`] = coin.id;
103+
const coinMap = coinIds.reduce(
104+
(acc, coin) => {
105+
for (const [platform, address] of Object.entries(coin.platforms)) {
106+
if (platformToChain[platform]) {
107+
// tokenAddress-chain
108+
acc[`${address.toLowerCase()}-${platformToChain[platform]}`] = coin.id;
109+
}
108110
}
109-
}
110-
return acc;
111-
}, {} as Record<string, string>);
111+
return acc;
112+
},
113+
{} as Record<string, string>,
114+
);
112115

113116
const updates = allTokens
114117
.map((token) => {
@@ -154,6 +157,13 @@ export class CoingeckoDataService {
154157
// }));
155158
// }
156159

160+
async tokenPrice(chain: Chain, tokens: string[]) {
161+
const platformId = config[chain].coingecko.platformId;
162+
const endpoint = `/simple/token_price/${platformId}?vs_currencies=usd&contract_addresses=${tokens.join(',')}`;
163+
164+
return this.get<{ [token: string]: { usd: number } }>(endpoint);
165+
}
166+
157167
public async getMarketDataForTokenIds(tokenIds: string[]): Promise<CoingeckoTokenMarketData[]> {
158168
const endpoint = `/coins/markets?vs_currency=${this.fiatParam}&ids=${tokenIds}&per_page=250&page=1&sparkline=false&price_change_percentage=1h%2C24h%2C7d%2C14d%2C30d`;
159169

@@ -168,17 +178,17 @@ export class CoingeckoDataService {
168178
private async get<T>(endpoint: string): Promise<T> {
169179
const remainingRequests = await requestRateLimiter.removeTokens(1);
170180
console.log('Remaining coingecko requests', remainingRequests);
171-
181+
172182
const response = await fetch(this.baseUrl + endpoint + this.apiKeyParam);
173-
183+
174184
if (!response.ok) {
175185
if (response.status === 429) {
176186
throw Error(`Coingecko ratelimit: ${response.status} ${response.statusText}`);
177187
}
178188
throw Error(`Coingecko API error: ${response.status} ${response.statusText}`);
179189
}
180-
181-
return await response.json() as T;
190+
191+
return (await response.json()) as T;
182192
}
183193
}
184194

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "backend",
3-
"version": "1.55.3",
3+
"version": "1.56.0",
44
"description": "Backend service for Beethoven X and Balancer",
55
"repository": "https://github.com/balancer/backend",
66
"author": "Beethoven X",

0 commit comments

Comments
 (0)