soroswap-utils
Version:
Utilities for interacting with Soroswap, the decentralized exchange (DEX) on Soroban, which is the smart contracts platform of the Stellar network.
175 lines (143 loc) • 5.63 kB
text/typescript
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { getConfig } from "./config";
import type {
Asset,
AssetData,
CacheEntry,
MainnetResponse,
SimpleAsset,
TestnetData,
TestnetResponse,
} from "./types";
const cacheDirectory = join(process.cwd(), "node_modules", "soroswap-utils", ".cache");
// eslint-disable-next-line unicorn/prefer-top-level-await
void (async () => {
try {
await access(cacheDirectory);
} catch {
// eslint-disable-next-line @typescript-eslint/naming-convention
await mkdir(cacheDirectory, { recursive: true });
}
})();
const mainnetCacheFile = join(cacheDirectory, "assets.json");
const testnetCacheFile = join(cacheDirectory, "testnet-assets.json");
const daysToCache = 30;
const hoursPerDay = 24;
const minutesPerHour = 60;
const secondsPerMinute = 60;
const millisecondsPerSecond = 1000;
const cacheTtl =
daysToCache * hoursPerDay * minutesPerHour * secondsPerMinute * millisecondsPerSecond;
const extractTestnetData = (data: TestnetResponse): TestnetData =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data.find((item: Readonly<TestnetData>): boolean => item.network === "testnet")!;
//
// Besides fetching fresh data, we also overwrite the cache file.
//
const fetchAssets = async (): Promise<AssetData> => {
// The env var name is the same regardless of the network; the command line
// sets the correct environment.
const config = getConfig();
const response = await fetch(config.assets.url);
const dataFromResponse = (await response.json()) as MainnetResponse | TestnetResponse;
const isTestnet = config.rpc.url.includes("testnet");
const data = isTestnet
? extractTestnetData(dataFromResponse as TestnetResponse)
: (dataFromResponse as MainnetResponse);
// We will consider as certified only the mainnet assets.
const certifiedData = {
...data,
assets: data.assets.map((asset) => ({ ...asset, isSoroswapCertified: !isTestnet })),
};
await writeFile(
isTestnet ? testnetCacheFile : mainnetCacheFile,
// Rule disabled because neither I nor Claude AI are smart enough to
// figure out how to make this work.
// eslint-disable-next-line total-functions/no-unsafe-readonly-mutable-assignment
JSON.stringify({ data: certifiedData, timestamp: Date.now() }),
);
return data;
};
const readCache = async (): Promise<CacheEntry> => {
const isTestnet = getConfig().rpc.url.includes("testnet");
const content = await readFile(isTestnet ? testnetCacheFile : mainnetCacheFile, "utf8");
return JSON.parse(content) as CacheEntry;
};
const getCachedOrFetch = async (): Promise<AssetData> => {
try {
const cache = await readCache();
const isDataFresh = Date.now() - cache.timestamp < cacheTtl;
if (!isDataFresh) {
return await fetchAssets();
}
const isTestnet = getConfig().rpc.url.includes("testnet");
if (!isTestnet) {
return cache.data as MainnetResponse;
}
return cache.data as TestnetData;
} catch {
return await fetchAssets();
}
};
const simplifyAssets = (data: AssetData): { assets: SimpleAsset[] } => ({
assets: data.assets.map(({ code, contract, isSoroswapCertified, issuer }) => ({
code,
contract,
isSoroswapCertified,
issuer,
})),
});
/**
* Fetches the list of certified assets from the Soroswap token list.
* Assets are cached for 30 days.
* @param shouldReturnSimpleAssets If true, returns a simpler object with only
* the code and issuer.
* @returns A promise that resolves to the list of certified assets.
* @throws If assets cannot be fetched or cached.
*/
const listCertifiedAssets = async (
shouldReturnSimpleAssets = false,
): Promise<AssetData | { assets: SimpleAsset[] }> => {
try {
const data: AssetData = await getCachedOrFetch();
return shouldReturnSimpleAssets ? simplifyAssets(data) : data;
} catch (error) {
console.error("Failed to get assets:", error);
throw error;
}
};
/**
* Checks if a given asset is certified by Soroswap.
* @param code The asset code.
* @param contract The address of the asset contract.
* @returns A promise that resolves to true if the asset is certified.
*/
const isCertifiedAsset = async (code: string, contract: string): Promise<boolean> => {
if (code === "XLM" && contract === "Native") {
return true;
}
const { assets } = await listCertifiedAssets();
return assets.some(
(asset: { readonly code: string; readonly contract: string }) =>
asset.code === code && asset.contract === contract,
);
};
/**
* Retrieves data about an asset.
*
* @param contract The address of the asset's contract.
* @returns A promise that resolves to the data about the asset.
* @throws If asset not found (in mainnet only)
*/
const getAssetData = async (contract: string): Promise<Asset> => {
const soroswapAssets = await getCachedOrFetch();
const assetData = soroswapAssets.assets.find((asset) => asset.contract === contract);
// We have full data for a list of certified assets, but it is possible to
// have other tokens in pools, and those we don't have data for.
if (assetData === undefined) {
return { contract, isSoroswapCertified: false };
}
return assetData;
};
export { getAssetData, getCachedOrFetch, isCertifiedAsset, listCertifiedAssets };