Skip to content

Commit

Permalink
[feat]: Implement pricing controller (#42)
Browse files Browse the repository at this point in the history
* feat: Implement a pricing controller to query arbitrary token prices

* fix: Make use of 'tokenId'

* fix: Wait for the pricing service to be ready
  • Loading branch information
jsanmigimeno authored Jul 2, 2024
1 parent 8b69899 commit ca5b1c5
Show file tree
Hide file tree
Showing 9 changed files with 152 additions and 35 deletions.
8 changes: 4 additions & 4 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ chains:
interval: 5000

pricing:
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration

# AMB configuration
wormhole:
Expand All @@ -117,7 +117,7 @@ chains:
rpc: 'https://sepolia.optimism.io'
resolver: 'op-sepolia'
pricing:
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration
wormhole:
wormholeChainId: 10005
incentivesAddress: '0x198cDD55d90277726f3222D5A8111AdB8b0af9ee'
Expand All @@ -133,7 +133,7 @@ chains:
rpc: 'https://sepolia.base.org'
resolver: 'base-sepolia'
pricing:
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration
wormhole:
wormholeChainId: 10004
incentivesAddress: '0x63B4E24DC9814fAcDe241fB4dEFcA04d5fc6d763'
Expand All @@ -143,7 +143,7 @@ chains:
name: 'Blast Testnet'
rpc: 'https://sepolia.blast.io'
pricing:
coinId: 'ethereum' # coin-gecko pricing provider specific configuration
gasCoinId: 'ethereum' # coin-gecko pricing provider specific configuration
wormhole:
wormholeChainId: 36
incentivesAddress: '0x9524ACA1fF46fAd177160F0a803189Cb552A3780'
Expand Down
48 changes: 48 additions & 0 deletions src/pricing/pricing.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Controller, Get, OnModuleInit, Query } from "@nestjs/common";
import { PricingService } from './pricing.service';
import { PricingInterface } from './pricing.interface';
import { GetPriceQuery, GetPriceQueryResponse } from './pricing.types';


@Controller()
export class PricingController implements OnModuleInit {
private pricing!: PricingInterface;

constructor(
private readonly pricingService: PricingService,
) {}

async onModuleInit() {
await this.initializePricingInterface();
}

private async initializePricingInterface(): Promise<void> {
const port = await this.pricingService.attachToPricing();
this.pricing = new PricingInterface(port);
}

@Get('getPrice')
async getPrice(@Query() query: GetPriceQuery): Promise<any> {
//TODO schema validate request

if (query.chainId == undefined || query.amount == undefined) {
return undefined; //TODO return error
}

const amount = BigInt(query.amount);
const price = await this.pricing.getPrice(
query.chainId,
amount,
query.tokenId,
);

const response: GetPriceQueryResponse = {
chainId: query.chainId,
tokenId: query.tokenId,
amount: amount.toString(),
price: price != null ? price : null,
};

return response;
}
}
4 changes: 3 additions & 1 deletion src/pricing/pricing.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class PricingInterface {
async getPrice(
chainId: string,
amount: bigint,
tokenId?: string,
): Promise<number | null> {

const messageId = this.getNextPortMessageId();
Expand All @@ -29,7 +30,8 @@ export class PricingInterface {
const request: GetPriceMessage = {
messageId,
chainId,
amount
amount,
tokenId
};
this.port.postMessage(request);
});
Expand Down
2 changes: 2 additions & 0 deletions src/pricing/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PricingService } from './pricing.service';
import { PricingController } from './pricing.controller';

@Global()
@Module({
controllers: [PricingController],
providers: [PricingService],
exports: [PricingService],
})
Expand Down
72 changes: 51 additions & 21 deletions src/pricing/pricing.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,17 @@ export async function loadPricingProviderAsync<Config extends PricingProviderCon
) as unknown as PricingProvider<Config>;
}

interface CachedPriceData {
price: number;
timestamp: number;
}


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

protected lastPriceUpdateTimestamp: number = 0;
protected cachedCoinPrice: number = 0;
protected cachedGasPrice: CachedPriceData | undefined;
protected cachedTokenPrices: Record<string, CachedPriceData> = {};

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

abstract queryCoinPrice(): Promise<number>;
abstract queryCoinPrice(tokenId?: string): Promise<number>;

async getPrice(amount: bigint): Promise<number> {
const cacheValidUntilTimestamp = this.lastPriceUpdateTimestamp + this.config.cacheDuration;
const isCacheValid = Date.now() < cacheValidUntilTimestamp;
if (!isCacheValid) {
await this.updateCoinPrice();
}
async getPrice(amount: bigint, tokenId?: string): Promise<number> {
const cachedPriceData = tokenId == undefined
? this.cachedGasPrice
: this.cachedTokenPrices[tokenId];

const cacheValidUntilTimestamp = cachedPriceData != undefined
? cachedPriceData.timestamp + this.config.cacheDuration
: null;

const isCacheValid = cacheValidUntilTimestamp != null && Date.now() < cacheValidUntilTimestamp;

return this.cachedCoinPrice * Number(amount) / 10**this.config.coinDecimals;
const latestPriceData = isCacheValid
? cachedPriceData!
: await this.updateCoinPrice(tokenId);

return latestPriceData.price * Number(amount) / 10**this.config.coinDecimals;
}

private async updateCoinPrice(): Promise<number> {
private async updateCoinPrice(tokenId?: string): Promise<CachedPriceData> {

const cachedPriceData = tokenId == undefined
? this.cachedGasPrice
: this.cachedTokenPrices[tokenId];

let latestPrice: number | undefined;

let tryCount = 0;
while (latestPrice == undefined) {
try {
latestPrice = await this.queryCoinPrice();
latestPrice = await this.queryCoinPrice(tokenId);
}
catch (error) {
this.logger.warn(
Expand All @@ -104,33 +121,46 @@ export abstract class PricingProvider<Config extends PricingProviderConfig> {

// Skip update and continue with 'stale' pricing info if 'maxTries' is reached, unless
// the price has never been successfully queried from the provider.
if (tryCount >= this.config.maxTries && this.lastPriceUpdateTimestamp != 0) {
if (tryCount >= this.config.maxTries && cachedPriceData != undefined) {
this.logger.warn(
{
try: tryCount,
maxTries: this.config.maxTries,
price: this.cachedCoinPrice,
price: cachedPriceData.price,
pricingDenomination: this.config.pricingDenomination,
lastUpdate: cachedPriceData.timestamp,
tokenId,
},
`Failed to query coin price. Max tries reached. Continuing with stale data.`
`Failed to query token price. Max tries reached. Continuing with stale data.`
);
return this.cachedCoinPrice;
return cachedPriceData;
}

await wait(this.config.retryInterval);
}
}

this.lastPriceUpdateTimestamp = Date.now();
this.cachedCoinPrice = latestPrice;
const latestPriceData: CachedPriceData = {
price: latestPrice,
timestamp: Date.now(),
};

if (tokenId == undefined) {
this.cachedGasPrice = latestPriceData;
}
else {
this.cachedTokenPrices[tokenId] = latestPriceData;
}

this.logger.info(
{
price: latestPrice,
pricingDenomination: this.config.pricingDenomination
pricingDenomination: this.config.pricingDenomination,
tokenId,
},
'Coin price updated.'
'Token price updated.'
)
return latestPrice;
return latestPriceData;
}

}
Expand Down
17 changes: 16 additions & 1 deletion src/pricing/pricing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,30 @@ export class PricingService implements OnModuleInit {
private worker: Worker | null = null;
private requestPortMessageId = 0;

private setReady!: () => void;
readonly isReady: Promise<void>;

constructor(
private readonly configService: ConfigService,
private readonly loggerService: LoggerService,
) {}
) {
this.isReady = this.initializeIsReady();
}

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

this.initializeWorker();

this.initiateIntervalStatusLog();

this.setReady();
}

private initializeIsReady(): Promise<void> {
return new Promise<void>((resolve) => {
this.setReady = resolve;
});
}

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

async attachToPricing(): Promise<MessagePort> {

await this.isReady;

const worker = this.worker;
if (worker == undefined) {
throw new Error(`Pricing worker is null.`);
Expand Down
20 changes: 20 additions & 0 deletions src/pricing/pricing.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,31 @@ export interface GetPriceMessage {
messageId: number;
chainId: string;
amount: bigint;
tokenId?: string;
}

export interface GetPriceResponse {
messageId: number;
chainId: string;
amount: bigint;
price: number | null;
tokenId?: string;
}



// Controller Types
// ************************************************************************************************

export interface GetPriceQuery {
chainId: string;
tokenId?: string;
amount: string;
}

export interface GetPriceQueryResponse {
chainId: string;
tokenId?: string;
amount: string;
price: number | null;
}
6 changes: 3 additions & 3 deletions src/pricing/pricing.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class PricingWorker {
const { port1, port2 } = new MessageChannel();

port1.on('message', (request: GetPriceMessage) => {
const pricePromise = this.getPrice(request.chainId, request.amount);
const pricePromise = this.getPrice(request.chainId, request.amount, request.tokenId);
void pricePromise.then((price) => {
const response: GetPriceResponse = {
messageId: request.messageId,
Expand All @@ -83,13 +83,13 @@ class PricingWorker {
return port2;
}

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

return provider.getPrice(amount);
return provider.getPrice(amount, tokenId);
}

}
Expand Down
10 changes: 5 additions & 5 deletions src/pricing/providers/coin-gecko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const BASE_COIN_GECKO_URL = 'https://api.coingecko.com/api/v3';
//TODO add support for an api key

export interface CoinGeckoPricingConfig extends PricingProviderConfig {
coinId: string;
gasCoinId: string;
}

export class CoinGeckoPricingProvider extends PricingProvider<CoinGeckoPricingConfig> {
Expand All @@ -27,14 +27,14 @@ export class CoinGeckoPricingProvider extends PricingProvider<CoinGeckoPricingCo
}

private validateCoinGeckoConfig(config: CoinGeckoPricingConfig): void {
if (config.coinId == undefined) {
throw new Error('Invalid CoinGecko config: no coinId specified.')
if (config.gasCoinId == undefined) {
throw new Error('Invalid CoinGecko config: no gasCoinId specified.')
}
}

async queryCoinPrice(): Promise<number> {
async queryCoinPrice(tokenId?: string): Promise<number> {

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

Expand Down

0 comments on commit ca5b1c5

Please sign in to comment.