diff --git a/package.json b/package.json index 12a8d90..017849c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@bancor/carbon-sdk", "type": "module", "source": "src/index.ts", - "version": "0.0.81-DEV", + "version": "0.0.84-DEV", "description": "The SDK is a READ-ONLY tool, intended to facilitate working with Carbon contracts. It's a convenient wrapper around our matching algorithm, allowing programs and users get a ready to use transaction data that will allow them to manage strategies and fulfill trades", "main": "dist/index.js", "module": "dist/index.js", diff --git a/src/abis/CarbonController.json b/src/abis/CarbonController.json index 3b136f8..504ca2b 100644 --- a/src/abis/CarbonController.json +++ b/src/abis/CarbonController.json @@ -121,6 +121,11 @@ "name": "AlreadyInitialized", "type": "error" }, + { + "inputs": [], + "name": "BalanceMismatch", + "type": "error" + }, { "inputs": [], "name": "DeadlineExpired", @@ -193,27 +198,27 @@ }, { "inputs": [], - "name": "OutDated", + "name": "OrderDisabled", "type": "error" }, { "inputs": [], - "name": "Overflow", + "name": "OutDated", "type": "error" }, { "inputs": [], - "name": "PairAlreadyExists", + "name": "Overflow", "type": "error" }, { "inputs": [], - "name": "PairDoesNotExist", + "name": "PairAlreadyExists", "type": "error" }, { "inputs": [], - "name": "StrategyDoesNotExist", + "name": "PairDoesNotExist", "type": "error" }, { @@ -300,6 +305,37 @@ "name": "PairCreated", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "Token", + "name": "token0", + "type": "address" + }, + { + "indexed": true, + "internalType": "Token", + "name": "token1", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "prevFeePPM", + "type": "uint32" + }, + { + "indexed": false, + "internalType": "uint32", + "name": "newFeePPM", + "type": "uint32" + } + ], + "name": "PairTradingFeePPMUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -1104,6 +1140,30 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "Token", + "name": "token0", + "type": "address" + }, + { + "internalType": "Token", + "name": "token1", + "type": "address" + } + ], + "name": "pairTradingFeePPM", + "outputs": [ + { + "internalType": "uint32", + "name": "", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "pairs", @@ -1225,6 +1285,29 @@ "stateMutability": "pure", "type": "function" }, + { + "inputs": [ + { + "internalType": "Token", + "name": "token0", + "type": "address" + }, + { + "internalType": "Token", + "name": "token1", + "type": "address" + }, + { + "internalType": "uint32", + "name": "newPairTradingFeePPM", + "type": "uint32" + } + ], + "name": "setPairTradingFeePPM", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/src/chain-cache/ChainCache.ts b/src/chain-cache/ChainCache.ts index 5893b40..85cdc6a 100644 --- a/src/chain-cache/ChainCache.ts +++ b/src/chain-cache/ChainCache.ts @@ -25,7 +25,7 @@ import { import { Logger } from '../common/logger'; const logger = new Logger('ChainCache.ts'); -const schemeVersion = 5; // bump this when the serialization format changes +const schemeVersion = 6; // bump this when the serialization format changes type PairToStrategiesMap = { [key: string]: EncodedStrategy[] }; type StrategyById = { [key: string]: EncodedStrategy }; @@ -36,6 +36,7 @@ type SerializableDump = { strategiesByPair: RetypeBigNumberToString; strategiesById: RetypeBigNumberToString; ordersByDirectedPair: RetypeBigNumberToString; + tradingFeePPMByPair: { [key: string]: number }; latestBlockNumber: number; latestTradesByPair: { [key: string]: TradeData }; latestTradesByDirectedPair: { [key: string]: TradeData }; @@ -51,6 +52,7 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter Promise) @@ -111,6 +113,7 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter ), + tradingFeePPMByPair: this._tradingFeePPMByPair, latestBlockNumber: this._latestBlockNumber, latestTradesByPair: this._latestTradesByPair, latestTradesByDirectedPair: this._latestTradesByDirectedPair, @@ -184,9 +188,6 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter { + await this._checkAndHandleCacheMiss(token0, token1); + const key = toPairKey(token0, token1); + return this._tradingFeePPMByPair[key]; + } + public get blocksMetadata(): BlockMetadata[] { return this._blocksMetadata; } @@ -310,6 +320,33 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter { + this._tradingFeePPMByPair[toPairKey(token0, token1)] = newFee; + }); this._setLatestBlockNumber(latestBlockNumber); if (affectedPairs.size > 0) { diff --git a/src/chain-cache/ChainSync.ts b/src/chain-cache/ChainSync.ts index 0c797ed..300e445 100644 --- a/src/chain-cache/ChainSync.ts +++ b/src/chain-cache/ChainSync.ts @@ -38,26 +38,58 @@ export class ChainSync { if (this._chainCache.getLatestBlockNumber() === 0) { logger.debug('startDataSync - cache is new', arguments); // cache starts from scratch so we want to avoid getting events from the beginning of time - this._chainCache.applyBatchedUpdates(blockNumber, [], [], [], []); + this._chainCache.applyBatchedUpdates(blockNumber, [], [], [], [], []); } + // let's fetch all pairs from the chain and set them to the cache - to be used by the following syncs + await this._updatePairsFromChain(); + + // _populateFeesData() should run first, before _populatePairsData() gets to manipulate the pairs list await Promise.all([ - this._trackFees(), + this._populateFeesData(this._pairs), this._populatePairsData(), this._syncEvents(), ]); } - private async _trackFees(): Promise { - logger.debug('_trackFees called'); - const tradingFeePPM = await this._fetcher.tradingFeePPM(); - this._chainCache.tradingFeePPM = tradingFeePPM; - this._fetcher.onTradingFeePPMUpdated( - (prevFeePPM: number, newFeePPM: number) => { - logger.debug('tradingFeePPM updated from', prevFeePPM, 'to', newFeePPM); - this._chainCache.tradingFeePPM = newFeePPM; - } - ); + // reads all pairs from chain and sets to private field + private async _updatePairsFromChain() { + logger.debug('_updatePairsFromChain fetches pairs'); + this._pairs = [...(await this._fetcher.pairs())]; + logger.debug('_updatePairsFromChain fetched pairs', this._pairs); + this._lastFetch = Date.now(); + if (this._pairs.length === 0) { + logger.error( + '_updatePairsFromChain fetched no pairs - this indicates a problem' + ); + } + } + + private async _populateFeesData( + pairs: TokenPair[], + skipCache = false + ): Promise { + logger.debug('populateFeesData called'); + if (pairs.length === 0) { + logger.error('populateFeesData called with no pairs - skipping'); + return; + } + const uncachedPairs = skipCache + ? pairs + : pairs.filter( + (pair) => !this._chainCache.hasCachedPair(pair[0], pair[1]) + ); + + if (uncachedPairs.length === 0) return; + + const feeUpdates: [string, string, number][] = + await this._fetcher.pairsTradingFeePPM(uncachedPairs); + + logger.debug('populateFeesData fetched fee updates', feeUpdates); + + feeUpdates.forEach((feeUpdate) => { + this._chainCache.addPairFees(feeUpdate[0], feeUpdate[1], feeUpdate[2]); + }); } // `_populatePairsData` sets timeout and returns immediately. It does the following: @@ -69,9 +101,6 @@ export class ChainSync { // 6. if there are no more pairs, it sets a timeout to call itself again private async _populatePairsData(): Promise { logger.debug('_populatePairsData called'); - this._pairs = []; - // keep the time stamp of last fetch - this._lastFetch = Date.now(); // this indicates we want to poll for pairs only once a minute. // Set this to false when we have an indication that new pair was created - which we want to fetch now this._slowPollPairs = false; @@ -85,10 +114,7 @@ export class ChainSync { setTimeout(processPairs, 1000); return; } - logger.debug('_populatePairsData fetches pairs'); - this._pairs = [...(await this._fetcher.pairs())]; - logger.debug('_populatePairsData fetched pairs', this._pairs); - this._lastFetch = Date.now(); + await this._updatePairsFromChain(); } // let's find the first pair that's not in the cache and clear it from the list along with all the items before it const nextPairToSync = findAndRemoveLeading( @@ -165,7 +191,14 @@ export class ChainSync { if (await this._detectReorg(currentBlock)) { logger.debug('_syncEvents detected reorg - resetting'); this._chainCache.clear(); - this._chainCache.applyBatchedUpdates(currentBlock, [], [], [], []); + this._chainCache.applyBatchedUpdates( + currentBlock, + [], + [], + [], + [], + [] + ); this._resetPairsFetching(); setTimeout(processEvents, 1); return; @@ -173,7 +206,7 @@ export class ChainSync { const cachedPairs = new Set( this._chainCache - .getCachedPairs() + .getCachedPairs(false) .map((pair) => toPairKey(pair[0], pair[1])) ); @@ -193,6 +226,8 @@ export class ChainSync { const updatedStrategiesChunks: EncodedStrategy[][] = []; const deletedStrategiesChunks: EncodedStrategy[][] = []; const tradesChunks: TradeData[][] = []; + const feeUpdatesChunks: [string, string, number][][] = []; + const defaultFeeUpdatesChunks: number[][] = []; for (const blockChunk of blockChunks) { logger.debug('_syncEvents fetches events for chunk', blockChunk); @@ -216,11 +251,23 @@ export class ChainSync { blockChunk[0], blockChunk[1] ); + const feeUpdatesChunk: [string, string, number][] = + await this._fetcher.getLatestPairTradingFeeUpdates( + blockChunk[0], + blockChunk[1] + ); + const defaultFeeUpdatesChunk: number[] = + await this._fetcher.getLatestTradingFeeUpdates( + blockChunk[0], + blockChunk[1] + ); createdStrategiesChunks.push(createdStrategiesChunk); updatedStrategiesChunks.push(updatedStrategiesChunk); deletedStrategiesChunks.push(deletedStrategiesChunk); tradesChunks.push(tradesChunk); + feeUpdatesChunks.push(feeUpdatesChunk); + defaultFeeUpdatesChunks.push(defaultFeeUpdatesChunk); logger.debug( '_syncEvents fetched the following events for chunks', blockChunks, @@ -229,6 +276,8 @@ export class ChainSync { updatedStrategiesChunk, deletedStrategiesChunk, tradesChunk, + feeUpdatesChunk, + defaultFeeUpdatesChunk, } ); } @@ -237,33 +286,34 @@ export class ChainSync { const updatedStrategies = updatedStrategiesChunks.flat(); const deletedStrategies = deletedStrategiesChunks.flat(); const trades = tradesChunks.flat(); + const feeUpdates = feeUpdatesChunks.flat(); + const defaultFeeWasUpdated = + defaultFeeUpdatesChunks.flat().length > 0; logger.debug( '_syncEvents fetched events', createdStrategies, updatedStrategies, deletedStrategies, - trades + trades, + feeUpdates, + defaultFeeWasUpdated ); // let's check created strategies and see if we have a pair that's not cached yet, // which means we need to set slow poll mode to false so that it will be fetched quickly + const newlyCreatedPairs: TokenPair[] = []; for (const strategy of createdStrategies) { if ( !this._chainCache.hasCachedPair(strategy.token0, strategy.token1) ) { - logger.debug( - '_syncEvents sets slow poll mode to false because of new pair', - strategy.token0, - strategy.token1 - ); - this._slowPollPairs = false; - break; + newlyCreatedPairs.push([strategy.token0, strategy.token1]); } } this._chainCache.applyBatchedUpdates( currentBlock, + feeUpdates, trades.filter((trade) => cachedPairs.has(toPairKey(trade.sourceToken, trade.targetToken)) ), @@ -277,6 +327,25 @@ export class ChainSync { cachedPairs.has(toPairKey(strategy.token0, strategy.token1)) ) ); + + // lastly - handle side effects such as new pair detected or default fee update + if (defaultFeeWasUpdated) { + logger.debug( + '_syncEvents noticed at least one default fee update - refetching pair fees for all pairs' + ); + await this._populateFeesData( + [...(await this._fetcher.pairs())], + true + ); + } + if (newlyCreatedPairs.length > 0) { + logger.debug( + '_syncEvents noticed at least one new pair created - setting slow poll mode to false' + ); + this._slowPollPairs = false; + logger.debug('_syncEvents fetching fees for the new pairs'); + await this._populateFeesData(newlyCreatedPairs, true); + } } } catch (err) { logger.error('Error syncing events:', err); diff --git a/src/common/types.ts b/src/common/types.ts index 3d2782d..baf4fbf 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -144,6 +144,12 @@ export type BlockMetadata = { export interface Fetcher { pairs(): Promise; strategiesByPair(token0: string, token1: string): Promise; + pairTradingFeePPM(token0: string, token1: string): Promise; + pairsTradingFeePPM(pairs: TokenPair[]): Promise<[string, string, number][]>; + tradingFeePPM(): Promise; + onTradingFeePPMUpdated( + listener: (prevFeePPM: number, newFeePPM: number) => void + ): void; getLatestStrategyCreatedStrategies( fromBlock: number, toBlock: number @@ -160,10 +166,14 @@ export interface Fetcher { fromBlock: number, toBlock: number ): Promise; + getLatestTradingFeeUpdates( + fromBlock: number, + toBlock: number + ): Promise; + getLatestPairTradingFeeUpdates( + fromBlock: number, + toBlock: number + ): Promise<[string, string, number][]>; // [token0, token1, feePPM] getBlockNumber(): Promise; - tradingFeePPM(): Promise; - onTradingFeePPMUpdated( - listener: (prevFeePPM: number, newFeePPM: number) => void - ): void; getBlock(blockNumber: number): Promise; } diff --git a/src/contracts-api/Reader.ts b/src/contracts-api/Reader.ts index 6188618..f8a52bf 100644 --- a/src/contracts-api/Reader.ts +++ b/src/contracts-api/Reader.ts @@ -5,6 +5,7 @@ import { StrategyCreatedEventObject, StrategyUpdatedEventObject, StrategyDeletedEventObject, + PairTradingFeePPMUpdatedEventObject, } from '../abis/types/CarbonController'; import Contracts from './Contracts'; import { isETHAddress, MultiCall, multicall } from './utils'; @@ -124,6 +125,49 @@ export default class Reader implements Fetcher { ); } + public pairTradingFeePPM(token0: string, token1: string): Promise { + return this._contracts.carbonController.pairTradingFeePPM(token0, token1); + } + + public async pairsTradingFeePPM( + pairs: TokenPair[] + ): Promise<[string, string, number][]> { + const results = await this._multicall( + pairs.map((pair) => ({ + contractAddress: this._contracts.carbonController.address, + interface: this._contracts.carbonController.interface, + methodName: 'pairTradingFeePPM', + methodParameters: [pair[0], pair[1]], + })) + ); + if (!results || results.length === 0) return []; + return results.map((res, i) => { + return [pairs[i][0], pairs[i][1], res[0]]; + }); + } + + public onPairTradingFeePPMUpdated( + listener: ( + token0: string, + token1: string, + prevFeePPM: number, + newFeePPM: number + ) => void + ) { + return this._contracts.carbonController.on( + 'PairTradingFeePPMUpdated', + function ( + token0: string, + token1: string, + prevFeePPM: number, + newFeePPM: number + ) { + logger.debug('PairTradingFeePPMUpdated fired with', arguments); + listener(token0, token1, prevFeePPM, newFeePPM); + } + ); + } + public getDecimalsByAddress = async (address: string) => { if (isETHAddress(address)) { return 18 as number; @@ -178,26 +222,26 @@ export default class Reader implements Fetcher { return strategies; }; - public getLatestStrategyCreatedStrategies = async ( + public async getLatestStrategyCreatedStrategies( fromBlock: number, toBlock: number - ): Promise => { + ): Promise { return this._getFilteredStrategies('StrategyCreated', fromBlock, toBlock); - }; + } - public getLatestStrategyUpdatedStrategies = async ( + public async getLatestStrategyUpdatedStrategies( fromBlock: number, toBlock: number - ): Promise => { + ): Promise { return this._getFilteredStrategies('StrategyUpdated', fromBlock, toBlock); - }; + } - public getLatestStrategyDeletedStrategies = async ( + public async getLatestStrategyDeletedStrategies( fromBlock: number, toBlock: number - ): Promise => { + ): Promise { return this._getFilteredStrategies('StrategyDeleted', fromBlock, toBlock); - }; + } public getLatestTokensTradedTrades = async ( fromBlock: number, @@ -234,6 +278,57 @@ export default class Reader implements Fetcher { return trades; }; + public async getLatestTradingFeeUpdates( + fromBlock: number, + toBlock: number + ): Promise { + const filter = + this._contracts.carbonController.filters.TradingFeePPMUpdated(null, null); + + const logs = await this._contracts.carbonController.queryFilter( + filter, + fromBlock, + toBlock + ); + + if (logs.length === 0) return []; + + const updates: number[] = logs.map((log) => { + const logArgs = log.args; + return logArgs.newFeePPM; + }); + + return updates; + } + + public async getLatestPairTradingFeeUpdates( + fromBlock: number, + toBlock: number + ): Promise<[string, string, number][]> { + const filter = + this._contracts.carbonController.filters.PairTradingFeePPMUpdated( + null, + null, + null, + null + ); + + const logs = await this._contracts.carbonController.queryFilter( + filter, + fromBlock, + toBlock + ); + + if (logs.length === 0) return []; + + const updates: [string, string, number][] = logs.map((log) => { + const logArgs: PairTradingFeePPMUpdatedEventObject = log.args; + return [logArgs.token0, logArgs.token1, logArgs.newFeePPM]; + }); + + return updates; + } + public getBlockNumber = async (): Promise => { return this._contracts.provider.getBlockNumber(); }; diff --git a/src/strategy-management/Toolkit.ts b/src/strategy-management/Toolkit.ts index 03c18f0..99cd525 100644 --- a/src/strategy-management/Toolkit.ts +++ b/src/strategy-management/Toolkit.ts @@ -467,10 +467,15 @@ export class Toolkit { }> { logger.debug('getTradeDataFromActions called', arguments); - const feePPM = this._cache.tradingFeePPM; + const feePPM = await this._cache.getTradingFeePPMByPair( + sourceToken, + targetToken + ); - // intentional == instead of === - if (feePPM == undefined) throw new Error('tradingFeePPM is undefined'); + if (feePPM === undefined) + throw new Error( + `tradingFeePPM is undefined for this pair: ${sourceToken}-${targetToken}` + ); const decimals = this._decimals; const sourceDecimals = await decimals.fetchDecimals(sourceToken); diff --git a/tests/ChainCache.spec.ts b/tests/ChainCache.spec.ts index 6d65a69..1709b02 100644 --- a/tests/ChainCache.spec.ts +++ b/tests/ChainCache.spec.ts @@ -57,7 +57,7 @@ describe('ChainCache', () => { cache = new ChainCache(); cache.addPair('abc', 'xyz', [encodedStrategy1, encodedStrategy2]); cache.addPair('foo', 'bar', []); - cache.applyBatchedUpdates(7, [trade], [], [], []); + cache.applyBatchedUpdates(7, [], [trade], [], [], []); serialized = cache.serialize(); deserialized = ChainCache.fromSerialized(serialized); }); @@ -127,17 +127,17 @@ describe('ChainCache', () => { affectedPairs = pairs; }); cache.addPair('abc', 'xyz', [encodedStrategy1]); - cache.applyBatchedUpdates(1, [trade], [], [], []); + cache.applyBatchedUpdates(1, [], [trade], [], [], []); expect(affectedPairs).to.deep.equal([['abc', 'xyz']]); - cache.applyBatchedUpdates(2, [], [encodedStrategy2], [], []); + cache.applyBatchedUpdates(2, [], [], [encodedStrategy2], [], []); expect(affectedPairs).to.deep.equal([['abc', 'xyz']]); - cache.applyBatchedUpdates(3, [], [], [encodedStrategy1], []); + cache.applyBatchedUpdates(3, [], [], [], [encodedStrategy1], []); expect(affectedPairs).to.deep.equal([['abc', 'xyz']]); - cache.applyBatchedUpdates(4, [], [], [], [encodedStrategy1]); + cache.applyBatchedUpdates(4, [], [], [], [], [encodedStrategy1]); expect(affectedPairs).to.deep.equal([['abc', 'xyz']]); // this shouldn't fire the event - so affectedPairs should remain the same - cache.applyBatchedUpdates(5, [], [], [], []); + cache.applyBatchedUpdates(5, [], [], [], [], []); expect(affectedPairs).to.deep.equal([['abc', 'xyz']]); }); it('should contain a single copy of a strategy that was updated', async () => { @@ -147,7 +147,7 @@ describe('ChainCache', () => { id: BigNumber.from(encodedStrategy1.id.toString()), }; cache.addPair('abc', 'xyz', [encodedStrategy1]); - cache.applyBatchedUpdates(10, [], [], [encodedStrategy1_mod], []); + cache.applyBatchedUpdates(10, [], [], [], [encodedStrategy1_mod], []); const strategies = await cache.getStrategiesByPair('abc', 'xyz'); expect(strategies).to.have.length(1); }); @@ -158,10 +158,20 @@ describe('ChainCache', () => { id: BigNumber.from(encodedStrategy1.id.toString()), }; cache.addPair('abc', 'xyz', [encodedStrategy1]); - cache.applyBatchedUpdates(10, [], [], [], [encodedStrategy1_mod]); + cache.applyBatchedUpdates(10, [], [], [], [], [encodedStrategy1_mod]); const strategies = await cache.getStrategiesByPair('abc', 'xyz'); expect(strategies).to.have.length(0); }); + it('should cache the latest fees', async () => { + const cache = new ChainCache(); + cache.applyBatchedUpdates(1, [['abc', 'xyz', 10]], [], [], [], []); + expect(await cache.getTradingFeePPMByPair('xyz', 'abc')).to.equal(10); + expect(await cache.getTradingFeePPMByPair('xyz', 'def')).to.be.undefined; + cache.applyBatchedUpdates(1, [['abc', 'xyz', 11]], [], [], [], []); + expect(await cache.getTradingFeePPMByPair('xyz', 'abc')).to.equal(11); + cache.applyBatchedUpdates(1, [['abc', 'xyz', 12], ['abc', 'xyz', 13]], [], [], [], []); + expect(await cache.getTradingFeePPMByPair('xyz', 'abc')).to.equal(13); + }); }); describe('cache miss', () => { it('getStrategiesByPair call miss handler when pair is not cached', async () => {