diff --git a/src/cli.ts b/src/cli.ts index b74d3d90..58c353c1 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ import { getBatchEthBalance, } from "./account"; import { + downscaleProtection, prepareOrdersForRound, getOrderbookOwnersProfileMapFromSg, handleAddOrderbookOwnersProfileMap, @@ -802,6 +803,7 @@ export const main = async (argv: any, version?: string) => { startTime: lastReadOrdersTimestamp, }), ); + let ordersDidChange = false; const results = await Promise.allSettled( lastReadOrdersMap.map((v) => getOrderChanges( @@ -816,6 +818,9 @@ export const main = async (argv: any, version?: string) => { for (let i = 0; i < results.length; i++) { const res = results[i]; if (res.status === "fulfilled") { + if (res.value.addOrders.length || res.value.removeOrders.length) { + ordersDidChange = true; + } lastReadOrdersMap[i].skip += res.value.count; try { await handleAddOrderbookOwnersProfileMap( @@ -840,6 +845,15 @@ export const main = async (argv: any, version?: string) => { } } } + + // in case there are new orders or removed order, re evaluate owners limits + if (ordersDidChange) { + await downscaleProtection( + orderbooksOwnersProfileMap, + config.viemClient as any as ViemClient, + options.ownerProfile, + ); + } } catch { /**/ } diff --git a/src/order.ts b/src/order.ts index d27bf8c0..7ec7fda0 100644 --- a/src/order.ts +++ b/src/order.ts @@ -1,18 +1,22 @@ -import { OrderV3 } from "./abis"; import { SgOrder } from "./query"; import { Span } from "@opentelemetry/api"; import { hexlify } from "ethers/lib/utils"; import { addWatchedToken } from "./account"; +import { orderbookAbi, OrderV3 } from "./abis"; import { getTokenSymbol, shuffleArray } from "./utils"; -import { decodeAbiParameters, parseAbiParameters } from "viem"; +import { decodeAbiParameters, erc20Abi, parseAbi, parseAbiParameters } from "viem"; import { Pair, Order, + Vault, + OTOVMap, ViemClient, + OwnersVaults, TokenDetails, BundledOrders, OrdersProfileMap, OwnersProfileMap, + TokensOwnersVaults, OrderbooksOwnersProfileMap, } from "./types"; @@ -47,6 +51,7 @@ export function toOrder(orderLog: any): Order { * Get all pairs of an order */ export async function getOrderPairs( + orderHash: string, orderStruct: Order, viemClient: ViemClient, tokens: TokenDetails[], @@ -112,10 +117,13 @@ export async function getOrderPairs( sellTokenSymbol: _outputSymbol, sellTokenDecimals: _output.decimals, takeOrder: { - order: orderStruct, - inputIOIndex: k, - outputIOIndex: j, - signedContext: [], + id: orderHash, + takeOrder: { + order: orderStruct, + inputIOIndex: k, + outputIOIndex: j, + signedContext: [], + }, }, }); } @@ -137,6 +145,7 @@ export async function handleAddOrderbookOwnersProfileMap( const changes: Record = {}; for (let i = 0; i < ordersDetails.length; i++) { const orderDetails = ordersDetails[i]; + const orderHash = orderDetails.orderHash.toLowerCase(); const orderbook = orderDetails.orderbook.id.toLowerCase(); const orderStruct = toOrder( decodeAbiParameters( @@ -154,12 +163,13 @@ export async function handleAddOrderbookOwnersProfileMap( if (orderbookOwnerProfileItem) { const ownerProfile = orderbookOwnerProfileItem.get(orderStruct.owner.toLowerCase()); if (ownerProfile) { - const order = ownerProfile.orders.get(orderDetails.orderHash.toLowerCase()); + const order = ownerProfile.orders.get(orderHash); if (!order) { - ownerProfile.orders.set(orderDetails.orderHash.toLowerCase(), { + ownerProfile.orders.set(orderHash, { active: true, order: orderStruct, takeOrders: await getOrderPairs( + orderHash, orderStruct, viemClient, tokens, @@ -172,10 +182,16 @@ export async function handleAddOrderbookOwnersProfileMap( } } else { const ordersProfileMap: OrdersProfileMap = new Map(); - ordersProfileMap.set(orderDetails.orderHash.toLowerCase(), { + ordersProfileMap.set(orderHash, { active: true, order: orderStruct, - takeOrders: await getOrderPairs(orderStruct, viemClient, tokens, orderDetails), + takeOrders: await getOrderPairs( + orderHash, + orderStruct, + viemClient, + tokens, + orderDetails, + ), consumedTakeOrders: [], }); orderbookOwnerProfileItem.set(orderStruct.owner.toLowerCase(), { @@ -185,10 +201,16 @@ export async function handleAddOrderbookOwnersProfileMap( } } else { const ordersProfileMap: OrdersProfileMap = new Map(); - ordersProfileMap.set(orderDetails.orderHash.toLowerCase(), { + ordersProfileMap.set(orderHash, { active: true, order: orderStruct, - takeOrders: await getOrderPairs(orderStruct, viemClient, tokens, orderDetails), + takeOrders: await getOrderPairs( + orderHash, + orderStruct, + viemClient, + tokens, + orderDetails, + ), consumedTakeOrders: [], }); const ownerProfileMap: OwnersProfileMap = new Map(); @@ -359,10 +381,7 @@ function gatherPairs( if ( !bundleOrder.takeOrders.find((v) => v.id.toLowerCase() === orderHash.toLowerCase()) ) { - bundleOrder.takeOrders.push({ - id: orderHash, - takeOrder: pair.takeOrder, - }); + bundleOrder.takeOrders.push(pair.takeOrder); } } else { bundledOrders.push({ @@ -373,13 +392,217 @@ function gatherPairs( sellToken: pair.sellToken, sellTokenDecimals: pair.sellTokenDecimals, sellTokenSymbol: pair.sellTokenSymbol, - takeOrders: [ - { - id: orderHash, - takeOrder: pair.takeOrder, - }, - ], + takeOrders: [pair.takeOrder], + }); + } + } +} + +/** + * Builds a map with following form from an `OrderbooksOwnersProfileMap` instance: + * `orderbook -> token -> owner -> vaults` called `OTOVMap` + * This is later on used to evaluate the owners limits + */ +export function buildOtovMap(orderbooksOwnersProfileMap: OrderbooksOwnersProfileMap): OTOVMap { + const result: OTOVMap = new Map(); + orderbooksOwnersProfileMap.forEach((ownersProfileMap, orderbook) => { + const tokensOwnersVaults: TokensOwnersVaults = new Map(); + ownersProfileMap.forEach((ownerProfile, owner) => { + ownerProfile.orders.forEach((orderProfile) => { + orderProfile.takeOrders.forEach((pair) => { + const token = pair.sellToken.toLowerCase(); + const vaultId = + pair.takeOrder.takeOrder.order.validOutputs[ + pair.takeOrder.takeOrder.outputIOIndex + ].vaultId.toLowerCase(); + const ownersVaults = tokensOwnersVaults.get(token); + if (ownersVaults) { + const vaults = ownersVaults.get(owner.toLowerCase()); + if (vaults) { + if (!vaults.find((v) => v.vaultId === vaultId)) + vaults.push({ vaultId, balance: 0n }); + } else { + ownersVaults.set(owner.toLowerCase(), [{ vaultId, balance: 0n }]); + } + } else { + const newOwnersVaults: OwnersVaults = new Map(); + newOwnersVaults.set(owner.toLowerCase(), [{ vaultId, balance: 0n }]); + tokensOwnersVaults.set(token, newOwnersVaults); + } + }); + }); + }); + result.set(orderbook, tokensOwnersVaults); + }); + return result; +} + +/** + * Gets vault balances of an owner's vaults of a given token + */ +export async function fetchVaultBalances( + orderbook: string, + token: string, + owner: string, + vaults: Vault[], + viemClient: ViemClient, + multicallAddressOverride?: string, +) { + const multicallResult = await viemClient.multicall({ + multicallAddress: + (multicallAddressOverride as `0x${string}` | undefined) ?? + viemClient.chain?.contracts?.multicall3?.address, + allowFailure: false, + contracts: vaults.map((v) => ({ + address: orderbook as `0x${string}`, + allowFailure: false, + chainId: viemClient.chain!.id, + abi: parseAbi([orderbookAbi[3]]), + functionName: "vaultBalance", + args: [owner, token, v.vaultId], + })), + }); + + for (let i = 0; i < multicallResult.length; i++) { + vaults[i].balance = multicallResult[i]; + } +} + +/** + * Evaluates the owners limits by checking an owner vaults avg balances of a token against + * other owners total balances of that token to calculate a percentage, repeats the same + * process for every other token and owner and at the end ends up with map of owners with array + * of percentages, then calculates an avg of all those percenatges and that is applied as a divider + * factor to the owner's limit. + * This ensures that if an owner has many orders/vaults and has spread their balances across those + * many vaults and orders, he/she will get limited. + * Owners limits that are set by bot's admin as env or cli arg, are exluded from this evaluation process + */ +export async function evaluateOwnersLimits( + orderbooksOwnersProfileMap: OrderbooksOwnersProfileMap, + otovMap: OTOVMap, + viemClient: ViemClient, + ownerLimits?: Record, + multicallAddressOverride?: string, +) { + for (const [orderbook, tokensOwnersVaults] of otovMap) { + const ownersProfileMap = orderbooksOwnersProfileMap.get(orderbook); + if (ownersProfileMap) { + const ownersCuts: Map = new Map(); + for (const [token, ownersVaults] of tokensOwnersVaults) { + const obTokenBalance = await viemClient.readContract({ + address: token as `0x${string}`, + abi: erc20Abi, + functionName: "balanceOf", + args: [orderbook as `0x${string}`], + }); + for (const [owner, vaults] of ownersVaults) { + // skip if owner limit is set by bot admin + if (typeof ownerLimits?.[owner.toLowerCase()] === "number") continue; + + const ownerProfile = ownersProfileMap.get(owner); + if (ownerProfile) { + await fetchVaultBalances( + orderbook, + token, + owner, + vaults, + viemClient, + multicallAddressOverride, + ); + const ownerTotalBalance = vaults.reduce( + (a, b) => ({ + balance: a.balance + b.balance, + }), + { + balance: 0n, + }, + ).balance; + const avgBalance = ownerTotalBalance / BigInt(vaults.length); + const otherOwnersBalances = obTokenBalance - ownerTotalBalance; + const balanceRatioPercent = + otherOwnersBalances === 0n + ? 100n + : (avgBalance * 100n) / otherOwnersBalances; + + // divide into 4 segments + let ownerEvalDivideFactor = 1; + if (balanceRatioPercent >= 75n) { + ownerEvalDivideFactor = 1; + } else if (balanceRatioPercent >= 50n && balanceRatioPercent < 75n) { + ownerEvalDivideFactor = 2; + } else if (balanceRatioPercent >= 25n && balanceRatioPercent < 50n) { + ownerEvalDivideFactor = 3; + } else if (balanceRatioPercent > 0n && balanceRatioPercent < 25n) { + ownerEvalDivideFactor = 4; + } + + // gather owner divide factor for all of the owner's orders' tokens + // to calculate an avg from them all later on + const cuts = ownersCuts.get(owner.toLowerCase()); + if (cuts) { + cuts.push(ownerEvalDivideFactor); + } else { + ownersCuts.set(owner.toLowerCase(), [ownerEvalDivideFactor]); + } + } + } + } + + ownersProfileMap.forEach((ownerProfile, owner) => { + const cuts = ownersCuts.get(owner); + if (cuts?.length) { + const avgCut = cuts.reduce((a, b) => a + b, 0) / cuts.length; + // round to nearest int, if turned out 0, set it to 1 as minimum + ownerProfile.limit = Math.round(ownerProfile.limit / avgCut); + if (ownerProfile.limit === 0) ownerProfile.limit = 1; + } }); } } } + +/** + * This is a wrapper fn around evaluating owers limits. + * Provides a protection by evaluating and possibly reducing owner's limit, + * this takes place by checking an owners avg vault balance of a token against + * all other owners cumulative balances, the calculated ratio is used a reducing + * factor for the owner limit when averaged out for all of tokens the owner has + */ +export async function downscaleProtection( + orderbooksOwnersProfileMap: OrderbooksOwnersProfileMap, + viemClient: ViemClient, + ownerLimits?: Record, + reset = true, + multicallAddressOverride?: string, +) { + if (reset) { + resetLimits(orderbooksOwnersProfileMap, ownerLimits); + } + const otovMap = buildOtovMap(orderbooksOwnersProfileMap); + await evaluateOwnersLimits( + orderbooksOwnersProfileMap, + otovMap, + viemClient, + ownerLimits, + multicallAddressOverride, + ); +} + +/** + * Resets owners limit to default value + */ +export async function resetLimits( + orderbooksOwnersProfileMap: OrderbooksOwnersProfileMap, + ownerLimits?: Record, +) { + orderbooksOwnersProfileMap.forEach((ownersProfileMap) => { + if (ownersProfileMap) { + ownersProfileMap.forEach((ownerProfile, owner) => { + // skip if owner limit is set by bot admin + if (typeof ownerLimits?.[owner.toLowerCase()] === "number") return; + ownerProfile.limit = DEFAULT_OWNER_LIMIT; + }); + } + }); +} diff --git a/src/types.ts b/src/types.ts index 29643104..c67268ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,7 +115,7 @@ export type Pair = { sellToken: string; sellTokenDecimals: number; sellTokenSymbol: string; - takeOrder: TakeOrder; + takeOrder: TakeOrderDetails; }; export type OrderProfile = { active: boolean; @@ -131,6 +131,11 @@ export type OrdersProfileMap = Map; export type OwnersProfileMap = Map; export type OrderbooksOwnersProfileMap = Map; +export type Vault = { vaultId: string; balance: bigint }; +export type OwnersVaults = Map; +export type TokensOwnersVaults = Map; +export type OTOVMap = Map; + export type ViemClient = WalletClient & PublicActions & { BALANCE: BigNumber; diff --git a/test/e2e/e2e.test.js b/test/e2e/e2e.test.js index 8b653022..5b5f94e7 100644 --- a/test/e2e/e2e.test.js +++ b/test/e2e/e2e.test.js @@ -73,7 +73,7 @@ for (let i = 0; i < testData.length; i++) { const tracer = provider.getTracer("arb-bot-tracer"); config.rpc = [rpc]; - const dataFetcherPromise = getDataFetcher(config, liquidityProviders, false); + const dataFetcherPromise = getDataFetcher(config, liquidityProviders, true); // run tests on each rp version for (let j = 0; j < rpVersions.length; j++) { diff --git a/test/orders.test.js b/test/orders.test.js index a2ea81ff..980438b9 100644 --- a/test/orders.test.js +++ b/test/orders.test.js @@ -14,9 +14,14 @@ const { getOrderPairs, prepareOrdersForRound, getOrderbookOwnersProfileMapFromSg, + buildOtovMap, + fetchVaultBalances, + evaluateOwnersLimits, + resetLimits, + downscaleProtection, } = require("../src/order"); -describe("Test order details", async function () { +describe("Test order", async function () { beforeEach(() => mockServer.start(8081)); afterEach(() => mockServer.stop()); @@ -614,7 +619,7 @@ describe("Test order details", async function () { const orderStruct = toOrder( decodeAbiParameters(parseAbiParameters(OrderV3), order1.orderBytes)[0], ); - const result = await getOrderPairs(orderStruct, undefined, [], order1); + const result = await getOrderPairs(order1.orderHash, orderStruct, undefined, [], order1); const expected = [ { buyToken: orderStruct.validInputs[0].token, @@ -624,10 +629,13 @@ describe("Test order details", async function () { sellTokenSymbol: order1.outputs[0].token.symbol, sellTokenDecimals: orderStruct.validOutputs[0].decimals, takeOrder: { - order: orderStruct, - inputIOIndex: 0, - outputIOIndex: 0, - signedContext: [], + id: order1.orderHash, + takeOrder: { + order: orderStruct, + inputIOIndex: 0, + outputIOIndex: 0, + signedContext: [], + }, }, }, ]; @@ -944,6 +952,244 @@ describe("Test order details", async function () { ]; assert.deepEqual(result4, expected4); }); + + it("should build OTOV map", async function () { + const orderbook = hexlify(randomBytes(20)).toLowerCase(); + const owner1 = hexlify(randomBytes(20)).toLowerCase(); + const owner2 = hexlify(randomBytes(20)).toLowerCase(); + const token1 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const token2 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const [order1, order2] = [ + getNewOrder(orderbook, owner1, token1, token2, 1), + getNewOrder(orderbook, owner2, token2, token1, 1), + ]; + + // build orderbook owner profile map + const ownerProfileMap = await getOrderbookOwnersProfileMapFromSg( + [order1, order2], + undefined, + [], + ); + + const result = buildOtovMap(ownerProfileMap); + const expected = new Map([ + [ + orderbook, + new Map([ + [ + token2.address, + new Map([[owner1, [{ vaultId: order1.outputs[0].vaultId, balance: 0n }]]]), + ], + [ + token1.address, + new Map([[owner2, [{ vaultId: order2.outputs[0].vaultId, balance: 0n }]]]), + ], + ]), + ], + ]); + + assert.deepEqual(result, expected); + }); + + it("should get vault balances for owners vaults", async function () { + // mock viem client + const viemClient = { + chain: { id: 137 }, + multicall: async () => [8n, 3n, 5n], + }; + const orderbook = hexlify(randomBytes(20)).toLowerCase(); + const owner = hexlify(randomBytes(20)).toLowerCase(); + const token = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const vaults = [ + { vaultId: "1", balance: 0n }, + { vaultId: "2", balance: 0n }, + { vaultId: "3", balance: 0n }, + ]; + await fetchVaultBalances(orderbook, token.address, owner, vaults, viemClient); + const expected = [ + { vaultId: "1", balance: 8n }, + { vaultId: "2", balance: 3n }, + { vaultId: "3", balance: 5n }, + ]; + + assert.deepEqual(vaults, expected); + }); + + it("should evaluate owner limits", async function () { + // mock viem client + let counter = -1; + const viemClient = { + chain: { id: 137 }, + readContract: async () => 10n, + multicall: async () => { + counter++; + if (counter === 0) return [5n]; // for tkn1 owner2 + if (counter === 1) return [1n]; // for tkn2 owner2 + }, + }; + const orderbook = hexlify(randomBytes(20)).toLowerCase(); + const owner1 = hexlify(randomBytes(20)).toLowerCase(); + const owner2 = hexlify(randomBytes(20)).toLowerCase(); + const token1 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const token2 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const [owner1order1, owner2order1, owner1order2, owner2order2] = [ + getNewOrder(orderbook, owner1, token1, token2, 1), + getNewOrder(orderbook, owner2, token1, token2, 1), + getNewOrder(orderbook, owner1, token2, token1, 1), + getNewOrder(orderbook, owner2, token2, token1, 1), + ]; + + // build orderbook owner profile map + const ownerProfileMap = await getOrderbookOwnersProfileMapFromSg( + [owner1order1, owner2order1, owner1order2, owner2order2], + undefined, + [], + { [owner1]: 4 }, // set owner1 limit as 4, owner2 unset (defaults to 25) + ); + const otovMap = new Map([ + [ + orderbook, + new Map([ + [ + token2.address, + new Map([ + [owner1, [{ vaultId: owner1order1.outputs[0].vaultId, balance: 5n }]], + [owner2, [{ vaultId: owner2order1.outputs[0].vaultId, balance: 5n }]], + ]), + ], + [ + token1.address, + new Map([ + [owner1, [{ vaultId: owner1order2.outputs[0].vaultId, balance: 9n }]], + [owner2, [{ vaultId: owner2order2.outputs[0].vaultId, balance: 1n }]], + ]), + ], + ]), + ], + ]); + await evaluateOwnersLimits(ownerProfileMap, otovMap, viemClient, { [owner1]: 4 }); + + // after evaluation, owner 2 limit should be reduced to 10 from the default 25, + // that is because owner2 relative to owner1 has 2/9 of the total token1 supply + // and has 1/1 of token2 supply, 1/9 goes into the bracket of 0 - 25%, ie divide factor + // of 4 and 1/1 goes into barcket of 75 - >100%, ie divide factor of 1, avg of the factors + // equals to: (1 + 4) / 2 = 2.5 and then the default owner2 limit which was 25, + // divided by 2/5 equals to 10 + // owner1 limit stays unchanged because it was set originally by the admin + const expected = await getOrderbookOwnersProfileMapFromSg( + [owner1order1, owner2order1, owner1order2, owner2order2], + undefined, + [], + { [owner1]: 4, [owner2]: 10 }, + ); + + assert.deepEqual(ownerProfileMap, expected); + }); + + it("should reset owners limit", async function () { + const orderbook = hexlify(randomBytes(20)).toLowerCase(); + const owner1 = hexlify(randomBytes(20)).toLowerCase(); + const owner2 = hexlify(randomBytes(20)).toLowerCase(); + const token1 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const token2 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const [order1, order2] = [ + getNewOrder(orderbook, owner1, token1, token2, 1), + getNewOrder(orderbook, owner2, token2, token1, 1), + ]; + + // build orderbook owner profile map + const ownerProfileMap = await getOrderbookOwnersProfileMapFromSg( + [order1, order2], + undefined, + [], + { [owner1]: 4, [owner2]: 10 }, // explicitly set owner2 limit to 10 for reset test + ); + // reset owner limits, only resets non admin set owner limit, ie only owner2 limit back to 25 + resetLimits(ownerProfileMap, { [owner1]: 4 }); + + const expected = await getOrderbookOwnersProfileMapFromSg([order1, order2], undefined, [], { + [owner1]: 4, + }); + assert.deepEqual(ownerProfileMap, expected); + }); + + it("should run downscaleProtection", async function () { + // mock viem client + let counter = -1; + const viemClient = { + chain: { id: 137 }, + readContract: async () => 10n, + multicall: async () => { + counter++; + if (counter === 0) return [5n]; // for tkn1 owner2 + if (counter === 1) return [1n]; // for tkn2 owner2 + }, + }; + const orderbook = hexlify(randomBytes(20)).toLowerCase(); + const owner1 = hexlify(randomBytes(20)).toLowerCase(); + const owner2 = hexlify(randomBytes(20)).toLowerCase(); + const token1 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const token2 = { + address: hexlify(randomBytes(20)).toLowerCase(), + decimals: 6, + symbol: "NewToken1", + }; + const [owner1order1, owner2order1, owner1order2, owner2order2] = [ + getNewOrder(orderbook, owner1, token1, token2, 1), + getNewOrder(orderbook, owner2, token1, token2, 1), + getNewOrder(orderbook, owner1, token2, token1, 1), + getNewOrder(orderbook, owner2, token2, token1, 1), + ]; + + // build orderbook owner profile map + const ownerProfileMap = await getOrderbookOwnersProfileMapFromSg( + [owner1order1, owner2order1, owner1order2, owner2order2], + undefined, + [], + { [owner1]: 4 }, + ); + await downscaleProtection(ownerProfileMap, viemClient, { [owner1]: 4 }); + const expected = await getOrderbookOwnersProfileMapFromSg( + [owner1order1, owner2order1, owner1order2, owner2order2], + undefined, + [], + { [owner1]: 4, [owner2]: 10 }, + ); + + assert.deepEqual(ownerProfileMap, expected); + }); }); function getOrderStruct(order) {