Skip to content

Commit

Permalink
web3 integrations + get token info + 24h caching time + unit testings (
Browse files Browse the repository at this point in the history
…#73)

* Add repo and cached repo for erc20

* Add dependency injection of erc20 repository

* Add tests for erc20 cache

* Add unit tests for viem

* Add JS doc to the new repo

* Fix name of test

* Add all chains automatically
  • Loading branch information
anxolin authored Jul 30, 2024
1 parent cce10ef commit ffab2bb
Show file tree
Hide file tree
Showing 16 changed files with 642 additions and 19 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ VESION=1.0.0
# Port
PORT=8080

# RPC urls (http or websocket)
# RPC_URL_1=https://mainnet.infura.io/v3/your-infura-key
# RPC_URL_100=https://rpc.gnosischain.com
# RPC_URL_42161=wss://arbitrum-mainnet.io/ws/v3/your-infura-key
# RPC_URL_11155111=https://sepolia.infura.io/v3/your-infura-key

# Caching
REDIS_HOST=redis
# REDIS_PORT=6379
Expand Down
39 changes: 31 additions & 8 deletions apps/api/src/app/inversify.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@ import {
CacheRepository,
CacheRepositoryMemory,
CacheRepositoryRedis,
Erc20Repository,
Erc20RepositoryCache,
Erc20RepositoryViem,
UsdRepository,
UsdRepositoryCache,
UsdRepositoryCoingecko,
UsdRepositoryCow,
UsdRepositoryFallback,
cacheRepositorySymbol,
erc20RepositorySymbol,
redisClient,
usdRepositorySymbol,
viemClients,
} from '@cowprotocol/repositories';

const DEFAULT_CACHE_VALUE_SECONDS = ms('2min') / 1000; // 2min cache time by default for values
const DEFAULT_CACHE_NULL_SECONDS = ms('30min') / 1000; // 30min cache time by default for NULL values (when the repository don't know)

const CACHE_TOKEN_INFO_SECONDS = ms('24h') / 1000; // 24h

import { Container } from 'inversify';
import {
SlippageService,
Expand All @@ -26,8 +33,13 @@ import {
} from '@cowprotocol/services';
import ms from 'ms';

function getTokenDecimals(tokenAddress: string): number | null {
return 18; // TODO: Implement!!!
function getErc20Repository(cacheRepository: CacheRepository): Erc20Repository {
return new Erc20RepositoryCache(
new Erc20RepositoryViem(viemClients),
cacheRepository,
'erc20',
CACHE_TOKEN_INFO_SECONDS
);
}

function getCacheRepository(_apiContainer: Container): CacheRepository {
Expand All @@ -38,9 +50,12 @@ function getCacheRepository(_apiContainer: Container): CacheRepository {
return new CacheRepositoryMemory();
}

function getUsdRepositoryCow(cacheRepository: CacheRepository): UsdRepository {
function getUsdRepositoryCow(
cacheRepository: CacheRepository,
erc20Repository: Erc20Repository
): UsdRepository {
return new UsdRepositoryCache(
new UsdRepositoryCow(getTokenDecimals),
new UsdRepositoryCow(erc20Repository),
cacheRepository,
'usdCow',
DEFAULT_CACHE_VALUE_SECONDS,
Expand All @@ -60,25 +75,33 @@ function getUsdRepositoryCoingecko(
);
}

function getUsdRepository(cacheRepository: CacheRepository): UsdRepository {
function getUsdRepository(
cacheRepository: CacheRepository,
erc20Repository: Erc20Repository
): UsdRepository {
return new UsdRepositoryFallback([
getUsdRepositoryCoingecko(cacheRepository),
getUsdRepositoryCow(cacheRepository),
getUsdRepositoryCow(cacheRepository, erc20Repository),
]);
}

function getApiContainer(): Container {
const apiContainer = new Container();

// Repositories
const cacheRepository = getCacheRepository(apiContainer);
const erc20Repository = getErc20Repository(cacheRepository);

apiContainer
.bind<Erc20Repository>(erc20RepositorySymbol)
.toConstantValue(erc20Repository);

apiContainer
.bind<CacheRepository>(cacheRepositorySymbol)
.toConstantValue(cacheRepository);

apiContainer
.bind<UsdRepository>(usdRepositorySymbol)
.toConstantValue(getUsdRepository(cacheRepository));
.toConstantValue(getUsdRepository(cacheRepository, erc20Repository));

// Services
apiContainer
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/app/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ALL_CHAIN_IDS } from '@cowprotocol/repositories';

export const ChainIdSchema = {
title: 'Chain ID',
description: 'Chain ID',
enum: [1, 100, 42161, 11155111],
enum: ALL_CHAIN_IDS,
type: 'integer',
} as const
} as const;

export const ETHEREUM_ADDRESS_PATTERN = "^0x[a-fA-F0-9]{40}$"
export const ETHEREUM_ADDRESS_PATTERN = '^0x[a-fA-F0-9]{40}$';
19 changes: 19 additions & 0 deletions libs/repositories/src/Erc20Repository/Erc20Repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { SupportedChainId } from '../types';

export const erc20RepositorySymbol = Symbol.for('Erc20Repository');

export interface Erc20 {
address: string;
name?: string;
symbol?: string;
decimals?: number;
}

export interface Erc20Repository {
/**
* Return the ERC20 token information for the given address or null if the token address is not an ERC20 token for the given network.
* @param chainId
* @param tokenAddress
*/
get(chainId: SupportedChainId, tokenAddress: string): Promise<Erc20 | null>;
}
133 changes: 133 additions & 0 deletions libs/repositories/src/Erc20Repository/Erc20RepositoryCache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Erc20RepositoryCache } from './Erc20RepositoryCache';
import { Erc20, Erc20Repository } from './Erc20Repository';
import { CacheRepository } from '../CacheRepository/CacheRepository';
import { SupportedChainId } from '../types';

describe('Erc20RepositoryCache', () => {
let erc20RepositoryCache: Erc20RepositoryCache;
let mockProxy: jest.Mocked<Erc20Repository>;
let mockCache: jest.Mocked<CacheRepository>;

const chainId: SupportedChainId = 1;
const tokenAddress = '0xTokenAddress';
const cacheName = 'erc20';
const cacheTimeSeconds = 60;
const baseCacheKey = `repos:${cacheName}`;

const erc20Data: Erc20 = {
address: '0x1111111111111111111111111111111111111111',
name: 'Token Name',
symbol: 'TKN',
decimals: 18,
};

beforeEach(() => {
mockProxy = {
get: jest.fn(),
} as unknown as jest.Mocked<Erc20Repository>;

mockCache = {
get: jest.fn(),
set: jest.fn(),
} as unknown as jest.Mocked<CacheRepository>;

erc20RepositoryCache = new Erc20RepositoryCache(
mockProxy,
mockCache,
cacheName,
cacheTimeSeconds
);
});

it('should return cached value if available', async () => {
// GIVEN: Cached value is available
mockCache.get.mockResolvedValue(JSON.stringify(erc20Data));

// WHEN: get is called
const result = await erc20RepositoryCache.get(chainId, tokenAddress);

// THEN: The cache is called
const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`;
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);

// THEN: The proxy is not called
expect(mockProxy.get).not.toHaveBeenCalled();

// THEN: The cached value is returned
expect(result).toEqual(erc20Data);
});

it('should return null if cached value is NULL_VALUE', async () => {
// GIVEN: Cached value is null
mockCache.get.mockResolvedValue('null');

// WHEN: get is called
const result = await erc20RepositoryCache.get(chainId, tokenAddress);

// THEN: The cache is called
const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`;
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);

// THEN: The proxy is not called
expect(mockProxy.get).not.toHaveBeenCalled();

// THEN: null is returned
expect(result).toBeNull();
});

it('should fetch from proxy and cache the result if not cached', async () => {
// GIVEN: Cached value is not available
mockCache.get.mockResolvedValue(null);

// GIVEN: Proxy returns the value
mockProxy.get.mockResolvedValue(erc20Data);

// WHEN: get is called
const result = await erc20RepositoryCache.get(chainId, tokenAddress);

// THEN: The cache is called
const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`;
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);

// THEN: The proxy is called
expect(mockProxy.get).toHaveBeenCalledWith(chainId, tokenAddress);

// THEN: The value is cached
expect(mockCache.set).toHaveBeenCalledWith(
cacheKey,
JSON.stringify(erc20Data),
cacheTimeSeconds
);

// THEN: The value is returned
expect(result).toEqual(erc20Data);
});

it('should cache NULL_VALUE if proxy returns null', async () => {
// GIVEN: Cached value is not available
mockCache.get.mockResolvedValue(null);

// GIVEN: Proxy returns null
mockProxy.get.mockResolvedValue(null);

// WHEN: get is called
const result = await erc20RepositoryCache.get(chainId, tokenAddress);

// THEN: The cache is called
const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`;
expect(mockCache.get).toHaveBeenCalledWith(cacheKey);

// THEN: The proxy is called
expect(mockProxy.get).toHaveBeenCalledWith(chainId, tokenAddress);

// THEN: The null value is cached
expect(mockCache.set).toHaveBeenCalledWith(
cacheKey,
'null',
cacheTimeSeconds
);

// THEN: The result is null
expect(result).toBeNull();
});
});
43 changes: 43 additions & 0 deletions libs/repositories/src/Erc20Repository/Erc20RepositoryCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { inject, injectable } from 'inversify';
import NodeCache from 'node-cache';
import { Erc20, Erc20Repository } from './Erc20Repository';
import { CacheRepository } from '../CacheRepository/CacheRepository';
import { SupportedChainId } from '../types';

const NULL_VALUE = 'null';

@injectable()
export class Erc20RepositoryCache implements Erc20Repository {
private baseCacheKey: string;

constructor(
private proxy: Erc20Repository,
private cache: CacheRepository,
private cacheName: string,
private cacheTimeSeconds: number
) {
this.baseCacheKey = `repos:${this.cacheName}`;
}

async get(
chainId: SupportedChainId,
tokenAddress: string
): Promise<Erc20 | null> {
const cacheKey = `${this.baseCacheKey}:get:${chainId}:${tokenAddress}`;

// Get cached value
const valueString = await this.cache.get(cacheKey);
if (valueString) {
return valueString === NULL_VALUE ? null : JSON.parse(valueString);
}

// Get fresh value from proxy
const value = await this.proxy.get(chainId, tokenAddress);

// Cache value
const cacheValue = value === null ? NULL_VALUE : JSON.stringify(value);
await this.cache.set(cacheKey, cacheValue, this.cacheTimeSeconds);

return value;
}
}
Loading

0 comments on commit ffab2bb

Please sign in to comment.