Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.test.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copy this file to .env.test and replace the placeholder values with
# real endpoints before running the live integration suites.

CARBON_SDK_TEST_POLLING_API_URL=https://example.com/carbon-cache
CARBON_SDK_TEST_TENDERLY_RPC_URL=https://rpc.tenderly.co/fork/YOUR_FORK_ID
11 changes: 11 additions & 0 deletions .mocharc.integration.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extension": ["ts"],
"require": [
"ts-node/register",
"./tests/integration/load-env.cjs"
],
"node-option": [
"experimental-specifier-resolution=node",
"loader=ts-node/esm"
]
}
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ let sdkCache: ChainCache;
let carbonSDK: Toolkit;
let isInitialized = false;
let isInitializing = false;
const MAX_BLOCK_AGE = 2000; // past this many blocks, the SDK won't attempt to catch up by processing events and instead call the contracts for strategy info.

const init = async (
rpcUrl: string,
Expand All @@ -57,7 +56,12 @@ const init = async (
1
);
api = new ContractsApi(provider, config);
const { cache, startDataSync } = initSyncedCache(api.reader, cachedData, MAX_BLOCK_AGE);
const { cache, startDataSync } = initSyncedCache({
mode: 'polling',
cachedData,
cacheSyncApi: 'https://example.com/carbon-cache',
pollingIntervalMs: 5_000,
});
sdkCache = cache;
carbonSDK = new Toolkit(
api,
Expand All @@ -78,6 +82,34 @@ const init = async (
};
```

If `cacheSyncApi` is provided, the SDK polls that endpoint instead of reading cache data from the blockchain. The endpoint should return the same JSON schema produced by `ChainCache.serialize()`.

## Testing

The default unit-test lane remains deterministic and does not hit live backends:

```bash
yarn test
```

Live backend coverage is opt-in and uses environment variables instead of hardcoded infrastructure:

- `CARBON_SDK_TEST_POLLING_API_URL` for polling-mode tests against a real cache server
- `CARBON_SDK_TEST_TENDERLY_RPC_URL` for RPC-mode tests against a Tenderly virtual testnet that forks Ethereum mainnet

The live test scripts automatically read `.env.test`. A sample file is included at [`.env.test.sample`](/Users/zavelevsky/devel/carbon-sdk/.env.test.sample).

Run the live suites with:

```bash
cp .env.test.sample .env.test
yarn test:integration:polling
yarn test:integration:rpc
yarn test:integration
```

Shell environment variables still take precedence over `.env.test`. If either variable is still missing, its corresponding live suite is skipped.

## Notes

### 1. The SDK Logger supports 3 verbosity levels:
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@
"prebuild": "yarn clean && yarn compile-abis",
"build": "yarn lint && rollup -c",
"test": "yarn lint && mocha",
"test:integration": "mocha --config .mocharc.integration.json \"tests/integration/*.live.spec.ts\"",
"test:integration:polling": "mocha --config .mocharc.integration.json \"tests/integration/polling.live.spec.ts\"",
"test:integration:rpc": "mocha --config .mocharc.integration.json \"tests/integration/rpc.live.spec.ts\"",
"lint": "eslint src --ext .ts"
},
"publishConfig": {
Expand Down
114 changes: 101 additions & 13 deletions src/chain-cache/ChainCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import EventEmitter from 'events';
import { CacheEvents, TypedEventEmitter } from './types';
import {
CacheEvents,
SerializedChainCache,
TypedEventEmitter,
} from './types';
import {
fromPairKey,
toDirectionKey,
Expand Down Expand Up @@ -31,13 +35,6 @@ type PairToStrategiesMap = { [key: string]: EncodedStrategy[] };
type StrategyById = { [key: string]: EncodedStrategy };
type PairToDirectedOrdersMap = { [key: string]: OrdersMap };

type SerializableDump = {
schemeVersion: number;
strategiesByPair: RetypeBigIntToString<PairToStrategiesMap>;
tradingFeePPMByPair: { [key: string]: number };
latestBlockNumber: number;
};

export class ChainCache extends (EventEmitter as new () => TypedEventEmitter<CacheEvents>) {
//#region private members
private _strategiesByPair: PairToStrategiesMap = {};
Expand All @@ -64,8 +61,64 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter<Cac
return new ChainCache();
}

private _deserialize(serializedCache: string): void {
const parsedCache = JSON.parse(serializedCache) as SerializableDump;
public replaceFromSerialized(serializedCache: string): boolean {
try {
const nextCache = new ChainCache();
if (!nextCache._deserialize(serializedCache)) {
return false;
}

const wasInitialized = this._isCacheInitialized;
const currentPairKeys = new Set(Object.keys(this._strategiesByPair));
const nextPairKeys = new Set(Object.keys(nextCache._strategiesByPair));
const addedPairKeys = [...nextPairKeys].filter(
(pairKey) => !currentPairKeys.has(pairKey)
);
const changedPairKeys = [...new Set([
...currentPairKeys,
...nextPairKeys,
])].filter((pairKey) =>
this._didPairStrategiesChange(
this._strategiesByPair[pairKey] ?? [],
nextCache._strategiesByPair[pairKey] ?? []
)
);

this._strategiesByPair = nextCache._strategiesByPair;
this._strategiesById = nextCache._strategiesById;
this._ordersByDirectedPair = nextCache._ordersByDirectedPair;
this._latestBlockNumber = nextCache._latestBlockNumber;
this._blocksMetadata = nextCache._blocksMetadata;
this._tradingFeePPMByPair = nextCache._tradingFeePPMByPair;
this._isCacheInitialized = nextCache._isCacheInitialized;

if (!wasInitialized && this._isCacheInitialized) {
logger.debug('Emitting onCacheInitialized');
this.emit('onCacheInitialized');
}

for (const pairKey of addedPairKeys) {
logger.debug('Emitting onPairAddedToCache after serialized refresh');
this.emit('onPairAddedToCache', fromPairKey(pairKey));
}

if (changedPairKeys.length > 0) {
logger.debug('Emitting onPairDataChanged after serialized refresh');
this.emit(
'onPairDataChanged',
changedPairKeys.map(fromPairKey)
);
}

return true;
} catch (e) {
logger.error('Failed to replace cache from serialized data', e);
return false;
}
}

private _deserialize(serializedCache: string): boolean {
const parsedCache = JSON.parse(serializedCache) as SerializedChainCache;
const { schemeVersion: version } = parsedCache;
if (version !== schemeVersion) {
logger.log(
Expand All @@ -75,15 +128,15 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter<Cac
version,
'This may be due to a breaking change in the cache format since it was last persisted.'
);
return;
return false;
}

// if, due to a bug, the cached latest block number isn't a number, print an error and return
if (typeof parsedCache.latestBlockNumber !== 'number') {
logger.error(
'Cached latest block number is not a number, ignoring cache'
);
return;
return false;
}

// iterate over the pairs and their strategies and populate this._strategiesByPair, this._strategiesById and this._ordersByDirectedPair
Expand All @@ -98,10 +151,11 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter<Cac
this._latestBlockNumber = parsedCache.latestBlockNumber;
this._isCacheInitialized = true;
logger.debug('Cache initialized from serialized data');
return true;
}

public serialize(): string {
const dump: SerializableDump = {
const dump: SerializedChainCache = {
schemeVersion,
strategiesByPair: Object.entries(this._strategiesByPair).reduce(
(acc, [key, strategies]) => {
Expand Down Expand Up @@ -412,6 +466,40 @@ export class ChainCache extends (EventEmitter as new () => TypedEventEmitter<Cac
this._latestBlockNumber = blockNumber;
}

private _didPairStrategiesChange(
currentStrategies: EncodedStrategy[],
nextStrategies: EncodedStrategy[]
): boolean {
if (currentStrategies.length !== nextStrategies.length) {
return true;
}

const currentById = new Map(
currentStrategies.map((strategy) => [
strategy.id.toString(),
JSON.stringify(encodedStrategyBigIntToStr(strategy)),
])
);
const nextById = new Map(
nextStrategies.map((strategy) => [
strategy.id.toString(),
JSON.stringify(encodedStrategyBigIntToStr(strategy)),
])
);

if (currentById.size !== nextById.size) {
return true;
}

for (const [id, currentStrategy] of currentById.entries()) {
if (nextById.get(id) !== currentStrategy) {
return true;
}
}

return false;
}

private _addStrategyOrders(strategy: EncodedStrategy): void {
for (const tokenOrder of [
[strategy.token0, strategy.token1],
Expand Down
Loading
Loading