Skip to content

Commit ca5b1c5

Browse files
authored
[feat]: Implement pricing controller (#42)
* feat: Implement a pricing controller to query arbitrary token prices * fix: Make use of 'tokenId' * fix: Wait for the pricing service to be ready
1 parent 8b69899 commit ca5b1c5

9 files changed

+152
-35
lines changed

config.example.yaml

+4-4
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ chains:
104104
interval: 5000
105105

106106
pricing:
107-
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
107+
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration
108108

109109
# AMB configuration
110110
wormhole:
@@ -117,7 +117,7 @@ chains:
117117
rpc: 'https://sepolia.optimism.io'
118118
resolver: 'op-sepolia'
119119
pricing:
120-
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
120+
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration
121121
wormhole:
122122
wormholeChainId: 10005
123123
incentivesAddress: '0x198cDD55d90277726f3222D5A8111AdB8b0af9ee'
@@ -133,7 +133,7 @@ chains:
133133
rpc: 'https://sepolia.base.org'
134134
resolver: 'base-sepolia'
135135
pricing:
136-
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
136+
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration
137137
wormhole:
138138
wormholeChainId: 10004
139139
incentivesAddress: '0x63B4E24DC9814fAcDe241fB4dEFcA04d5fc6d763'
@@ -143,7 +143,7 @@ chains:
143143
name: 'Blast Testnet'
144144
rpc: 'https://sepolia.blast.io'
145145
pricing:
146-
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
146+
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration
147147
wormhole:
148148
wormholeChainId: 36
149149
incentivesAddress: '0x9524ACA1fF46fAd177160F0a803189Cb552A3780'

src/pricing/pricing.controller.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Controller, Get, OnModuleInit, Query } from "@nestjs/common";
2+
import { PricingService } from './pricing.service';
3+
import { PricingInterface } from './pricing.interface';
4+
import { GetPriceQuery, GetPriceQueryResponse } from './pricing.types';
5+
6+
7+
@Controller()
8+
export class PricingController implements OnModuleInit {
9+
private pricing!: PricingInterface;
10+
11+
constructor(
12+
private readonly pricingService: PricingService,
13+
) {}
14+
15+
async onModuleInit() {
16+
await this.initializePricingInterface();
17+
}
18+
19+
private async initializePricingInterface(): Promise<void> {
20+
const port = await this.pricingService.attachToPricing();
21+
this.pricing = new PricingInterface(port);
22+
}
23+
24+
@Get('getPrice')
25+
async getPrice(@Query() query: GetPriceQuery): Promise<any> {
26+
//TODO schema validate request
27+
28+
if (query.chainId == undefined || query.amount == undefined) {
29+
return undefined; //TODO return error
30+
}
31+
32+
const amount = BigInt(query.amount);
33+
const price = await this.pricing.getPrice(
34+
query.chainId,
35+
amount,
36+
query.tokenId,
37+
);
38+
39+
const response: GetPriceQueryResponse = {
40+
chainId: query.chainId,
41+
tokenId: query.tokenId,
42+
amount: amount.toString(),
43+
price: price != null ? price : null,
44+
};
45+
46+
return response;
47+
}
48+
}

src/pricing/pricing.interface.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class PricingInterface {
1313
async getPrice(
1414
chainId: string,
1515
amount: bigint,
16+
tokenId?: string,
1617
): Promise<number | null> {
1718

1819
const messageId = this.getNextPortMessageId();
@@ -29,7 +30,8 @@ export class PricingInterface {
2930
const request: GetPriceMessage = {
3031
messageId,
3132
chainId,
32-
amount
33+
amount,
34+
tokenId
3335
};
3436
this.port.postMessage(request);
3537
});

src/pricing/pricing.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Global, Module } from '@nestjs/common';
22
import { PricingService } from './pricing.service';
3+
import { PricingController } from './pricing.controller';
34

45
@Global()
56
@Module({
7+
controllers: [PricingController],
68
providers: [PricingService],
79
exports: [PricingService],
810
})

src/pricing/pricing.provider.ts

+51-21
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,17 @@ export async function loadPricingProviderAsync<Config extends PricingProviderCon
4040
) as unknown as PricingProvider<Config>;
4141
}
4242

43+
interface CachedPriceData {
44+
price: number;
45+
timestamp: number;
46+
}
47+
4348

4449
export abstract class PricingProvider<Config extends PricingProviderConfig> {
4550
readonly abstract pricingProviderType: string;
4651

47-
protected lastPriceUpdateTimestamp: number = 0;
48-
protected cachedCoinPrice: number = 0;
52+
protected cachedGasPrice: CachedPriceData | undefined;
53+
protected cachedTokenPrices: Record<string, CachedPriceData> = {};
4954

5055
constructor(
5156
protected readonly config: Config,
@@ -72,26 +77,38 @@ export abstract class PricingProvider<Config extends PricingProviderConfig> {
7277
// Pricing functions
7378
// ********************************************************************************************
7479

75-
abstract queryCoinPrice(): Promise<number>;
80+
abstract queryCoinPrice(tokenId?: string): Promise<number>;
7681

77-
async getPrice(amount: bigint): Promise<number> {
78-
const cacheValidUntilTimestamp = this.lastPriceUpdateTimestamp + this.config.cacheDuration;
79-
const isCacheValid = Date.now() < cacheValidUntilTimestamp;
80-
if (!isCacheValid) {
81-
await this.updateCoinPrice();
82-
}
82+
async getPrice(amount: bigint, tokenId?: string): Promise<number> {
83+
const cachedPriceData = tokenId == undefined
84+
? this.cachedGasPrice
85+
: this.cachedTokenPrices[tokenId];
86+
87+
const cacheValidUntilTimestamp = cachedPriceData != undefined
88+
? cachedPriceData.timestamp + this.config.cacheDuration
89+
: null;
90+
91+
const isCacheValid = cacheValidUntilTimestamp != null && Date.now() < cacheValidUntilTimestamp;
8392

84-
return this.cachedCoinPrice * Number(amount) / 10**this.config.coinDecimals;
93+
const latestPriceData = isCacheValid
94+
? cachedPriceData!
95+
: await this.updateCoinPrice(tokenId);
96+
97+
return latestPriceData.price * Number(amount) / 10**this.config.coinDecimals;
8598
}
8699

87-
private async updateCoinPrice(): Promise<number> {
100+
private async updateCoinPrice(tokenId?: string): Promise<CachedPriceData> {
101+
102+
const cachedPriceData = tokenId == undefined
103+
? this.cachedGasPrice
104+
: this.cachedTokenPrices[tokenId];
88105

89106
let latestPrice: number | undefined;
90107

91108
let tryCount = 0;
92109
while (latestPrice == undefined) {
93110
try {
94-
latestPrice = await this.queryCoinPrice();
111+
latestPrice = await this.queryCoinPrice(tokenId);
95112
}
96113
catch (error) {
97114
this.logger.warn(
@@ -104,33 +121,46 @@ export abstract class PricingProvider<Config extends PricingProviderConfig> {
104121

105122
// Skip update and continue with 'stale' pricing info if 'maxTries' is reached, unless
106123
// the price has never been successfully queried from the provider.
107-
if (tryCount >= this.config.maxTries && this.lastPriceUpdateTimestamp != 0) {
124+
if (tryCount >= this.config.maxTries && cachedPriceData != undefined) {
108125
this.logger.warn(
109126
{
110127
try: tryCount,
111128
maxTries: this.config.maxTries,
112-
price: this.cachedCoinPrice,
129+
price: cachedPriceData.price,
130+
pricingDenomination: this.config.pricingDenomination,
131+
lastUpdate: cachedPriceData.timestamp,
132+
tokenId,
113133
},
114-
`Failed to query coin price. Max tries reached. Continuing with stale data.`
134+
`Failed to query token price. Max tries reached. Continuing with stale data.`
115135
);
116-
return this.cachedCoinPrice;
136+
return cachedPriceData;
117137
}
118138

119139
await wait(this.config.retryInterval);
120140
}
121141
}
122142

123-
this.lastPriceUpdateTimestamp = Date.now();
124-
this.cachedCoinPrice = latestPrice;
143+
const latestPriceData: CachedPriceData = {
144+
price: latestPrice,
145+
timestamp: Date.now(),
146+
};
147+
148+
if (tokenId == undefined) {
149+
this.cachedGasPrice = latestPriceData;
150+
}
151+
else {
152+
this.cachedTokenPrices[tokenId] = latestPriceData;
153+
}
125154

126155
this.logger.info(
127156
{
128157
price: latestPrice,
129-
pricingDenomination: this.config.pricingDenomination
158+
pricingDenomination: this.config.pricingDenomination,
159+
tokenId,
130160
},
131-
'Coin price updated.'
161+
'Token price updated.'
132162
)
133-
return latestPrice;
163+
return latestPriceData;
134164
}
135165

136166
}

src/pricing/pricing.service.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,30 @@ export class PricingService implements OnModuleInit {
2525
private worker: Worker | null = null;
2626
private requestPortMessageId = 0;
2727

28+
private setReady!: () => void;
29+
readonly isReady: Promise<void>;
30+
2831
constructor(
2932
private readonly configService: ConfigService,
3033
private readonly loggerService: LoggerService,
31-
) {}
34+
) {
35+
this.isReady = this.initializeIsReady();
36+
}
3237

3338
onModuleInit() {
3439
this.loggerService.info(`Starting Pricing worker...`);
3540

3641
this.initializeWorker();
3742

3843
this.initiateIntervalStatusLog();
44+
45+
this.setReady();
46+
}
47+
48+
private initializeIsReady(): Promise<void> {
49+
return new Promise<void>((resolve) => {
50+
this.setReady = resolve;
51+
});
3952
}
4053

4154
private initializeWorker(): void {
@@ -139,6 +152,8 @@ export class PricingService implements OnModuleInit {
139152

140153
async attachToPricing(): Promise<MessagePort> {
141154

155+
await this.isReady;
156+
142157
const worker = this.worker;
143158
if (worker == undefined) {
144159
throw new Error(`Pricing worker is null.`);

src/pricing/pricing.types.ts

+20
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,31 @@ export interface GetPriceMessage {
1818
messageId: number;
1919
chainId: string;
2020
amount: bigint;
21+
tokenId?: string;
2122
}
2223

2324
export interface GetPriceResponse {
2425
messageId: number;
2526
chainId: string;
2627
amount: bigint;
2728
price: number | null;
29+
tokenId?: string;
30+
}
31+
32+
33+
34+
// Controller Types
35+
// ************************************************************************************************
36+
37+
export interface GetPriceQuery {
38+
chainId: string;
39+
tokenId?: string;
40+
amount: string;
41+
}
42+
43+
export interface GetPriceQueryResponse {
44+
chainId: string;
45+
tokenId?: string;
46+
amount: string;
47+
price: number | null;
2848
}

src/pricing/pricing.worker.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class PricingWorker {
6666
const { port1, port2 } = new MessageChannel();
6767

6868
port1.on('message', (request: GetPriceMessage) => {
69-
const pricePromise = this.getPrice(request.chainId, request.amount);
69+
const pricePromise = this.getPrice(request.chainId, request.amount, request.tokenId);
7070
void pricePromise.then((price) => {
7171
const response: GetPriceResponse = {
7272
messageId: request.messageId,
@@ -83,13 +83,13 @@ class PricingWorker {
8383
return port2;
8484
}
8585

86-
private async getPrice(chainId: string, amount: bigint): Promise<number | null> {
86+
private async getPrice(chainId: string, amount: bigint, tokenId?: string): Promise<number | null> {
8787
const provider = this.providers.get(chainId);
8888
if (provider == undefined) {
8989
return null;
9090
}
9191

92-
return provider.getPrice(amount);
92+
return provider.getPrice(amount, tokenId);
9393
}
9494

9595
}

src/pricing/providers/coin-gecko.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const BASE_COIN_GECKO_URL = 'https://api.coingecko.com/api/v3';
88
//TODO add support for an api key
99

1010
export interface CoinGeckoPricingConfig extends PricingProviderConfig {
11-
coinId: string;
11+
gasCoinId: string;
1212
}
1313

1414
export class CoinGeckoPricingProvider extends PricingProvider<CoinGeckoPricingConfig> {
@@ -27,14 +27,14 @@ export class CoinGeckoPricingProvider extends PricingProvider<CoinGeckoPricingCo
2727
}
2828

2929
private validateCoinGeckoConfig(config: CoinGeckoPricingConfig): void {
30-
if (config.coinId == undefined) {
31-
throw new Error('Invalid CoinGecko config: no coinId specified.')
30+
if (config.gasCoinId == undefined) {
31+
throw new Error('Invalid CoinGecko config: no gasCoinId specified.')
3232
}
3333
}
3434

35-
async queryCoinPrice(): Promise<number> {
35+
async queryCoinPrice(tokenId?: string): Promise<number> {
3636

37-
const coinId = this.config.coinId;
37+
const coinId = tokenId ?? this.config.gasCoinId;
3838
const denom = this.config.pricingDenomination.toLowerCase();
3939
const path = `/simple/price?ids=${coinId}&vs_currencies=${denom}`;
4040

0 commit comments

Comments
 (0)