Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"node": ">=22.18.0"
},
"dependencies": {
"@across-protocol/constants": "^3.1.100",
"@across-protocol/constants": "^3.1.102",
"@across-protocol/contracts": "5.0.0",
"@across-protocol/sdk": "4.3.135",
"@arbitrum/sdk": "^4.0.2",
Expand Down
38 changes: 32 additions & 6 deletions src/utils/BNUtils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { ConvertDecimals } from "./";
// eslint-disable-next-line no-restricted-imports
import { BigNumber } from "@ethersproject/bignumber";

// eslint-disable-next-line no-restricted-imports
export * from "@ethersproject/bignumber";

// Matches plain decimals and scientific notation such as:
// "123", "123.45", ".5", "1e-6", "-1.23E+4"
const DECIMAL_NUMBER_REGEX = /^([+-]?)(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:e([+-]?\d+))?$/i;

export function bnComparatorDescending(a: BigNumber, b: BigNumber): -1 | 0 | 1 {
if (b.gt(a)) {
return 1;
Expand All @@ -26,9 +29,32 @@ export function bnComparatorAscending(a: BigNumber, b: BigNumber): -1 | 0 | 1 {
}

export function floatToBN(float: string | number, precision: number): BigNumber {
const strFloat = String(float);
const adjustment = strFloat.length - strFloat.indexOf(".") - 1;
const scaledAmount = Number(float) * 10 ** adjustment;
const bnAmount = BigNumber.from(Math.round(scaledAmount));
return ConvertDecimals(adjustment, precision)(bnAmount);
// Always parse from the rendered string form so we never depend on JS float
// multiplication or toFixed(), both of which lose information for edge cases.
const match = `${float}`.trim().match(DECIMAL_NUMBER_REGEX);
if (!match) {
throw new Error(`Invalid decimal value: ${float}`);
}

const [, sign = "", integerPart = "", fractionalPartFromInteger = "", fractionalPartOnly = "", exponent = "0"] =
match;
const fractionalPart = fractionalPartFromInteger || fractionalPartOnly;

// Build the significand by removing the decimal point, then strip leading
// zeros so BigNumber sees the smallest valid integer representation.
const digits = `${integerPart || "0"}${fractionalPart}`.replace(/^0+/, "") || "0";

if (digits === "0") {
return BigNumber.from(0);
}

// `fractionalPart.length` is the number of decimal places in the significand.
// Subtracting the exponent gives the effective source precision. From there:
// - positive `zeroCount` means pad with zeros to reach the target precision
// - negative `zeroCount` means truncate extra fractional digits
const zeroCount = precision - (fractionalPart.length - Number(exponent));
const scaledDigits =
zeroCount >= 0 ? digits + "0".repeat(zeroCount) : digits.slice(0, Math.max(0, digits.length + zeroCount)) || "0";

return BigNumber.from(sign === "-" && scaledDigits !== "0" ? `-${scaledDigits}` : scaledDigits);
}
75 changes: 72 additions & 3 deletions src/utils/BinanceUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Binance, {
type Binance as BinanceApi,
} from "binance-api-node";
import minimist from "minimist";
import { getGckmsConfig, retrieveGckmsKeys, isDefined, assert, delay, CHAIN_IDs, getRedisCache } from "./";
import { getGckmsConfig, retrieveGckmsKeys, isDefined, assert, delay, CHAIN_IDs, getRedisCache, truncate } from "./";

// Store global promises on Gckms key retrieval actions so that we don't retrieve the same key multiple times.
let binanceSecretKeyPromise = undefined;
Expand Down Expand Up @@ -51,7 +51,7 @@ type Network = {
};

// A BinanceDeposit is either a simplified element of the return type of the Binance API's `depositHistory`.
type BinanceDeposit = {
export type BinanceDeposit = {
// The amount of `coin` transferred in this interaction.
amount: number;
// The coin used in this interaction (i.e. the token symbol).
Expand All @@ -60,12 +60,14 @@ type BinanceDeposit = {
network: string;
// The transaction hash of the deposit.
txId: string;
// The timestamp that Binance assigns the deposit.
insertTime: number;
// The status of the deposit/withdrawal.
status?: number;
};

// A BinanceWithdrawal is a simplified element of the return type of the Binance API's `withdrawHistory`.
export type BinanceWithdrawal = BinanceDeposit & {
export type BinanceWithdrawal = Omit<BinanceDeposit, "insertTime"> & {
// The recipient of `coin` on the destination network.
recipient: string;
// The unique withdrawal ID.
Expand Down Expand Up @@ -256,6 +258,7 @@ export async function getBinanceDeposits(
network: deposit.network,
txId: deposit.txId,
status: deposit.status,
insertTime: deposit.insertTime,
} satisfies BinanceDeposit;
});
}
Expand Down Expand Up @@ -323,3 +326,69 @@ export async function getAccountCoins(binanceApi: BinanceApi): Promise<ParsedAcc
} as Coin;
});
}

/**
* Computes outstanding deposits for a given deposit network given a list of executed withdrawals that
* match against non-outstanding or already-finalized deposits.
* @param deposits All deposits for the same coin across all networks for the monitored address.
* @param withdrawals All L1 withdrawals for the same coin for the monitored address.
* @param depositNetwork The network to compute unmatched volume for.
* @returns The list of unmatched deposits on `depositNetwork` along with their amount outstanding.
*/
export function getOutstandingBinanceDeposits(
deposits: BinanceDeposit[],
withdrawals: BinanceWithdrawal[],
depositNetwork: string
): BinanceDeposit[] {
assert(
withdrawals.every((withdrawal) => withdrawal.network === BINANCE_NETWORKS[CHAIN_IDs.MAINNET]),
"Withdrawals must be for the Mainnet network"
);
Comment on lines +343 to +346
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we intend to constrain this to mainnet, or just t ensure that each withdrawal is for the same chainId?

Constraining to mainnet is something we'd like to avoid moving forward, since ideally we can withdraw on any chain.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this function works unless all withdrawals are to the same chain, otherwise how can we differentiate between deposits that are supposed to be withdrawn to mainnet vs another chain?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but do we require that it's always a withdrawal to mainnet? Could we instead just verify that all withdrawal networks are equal, rather than that they are all mainnet?

if (deposits.length === 0) {
return [];
}
assert(
deposits.every((deposit) => deposit.coin === deposits[0].coin),
"Deposits must be for the same coin"
);
// Determining which deposits are outstanding is tricky for two reasons:
// - Binance withdrawals on Ethereum can batch together deposited amounts from different L2s.
// - It is not possible to determine which deposit is getting "finalized" by any withdrawal on Etheruem because
// there is no metadata associated with the withdrawal that indicates the L2 it originated from.
// - Binance withdrawals can be greater than or less than the deposited amount for individual deposits.

// First, find the net outstanding deposited amount.
// @dev amount + txnFee can often exceed deposited amount due to existing dust on the Binance account
// that also gets included in the batch withdrawal.
const totalWithdrawalAmount = withdrawals.reduce((acc, w) => acc + Number(w.amount) + Number(w.transactionFee), 0);
const totalDepositedAmount = deposits.reduce((acc, d) => acc + Number(d.amount), 0);
let remainingOutstanding = totalDepositedAmount - totalWithdrawalAmount;
if (remainingOutstanding <= 0) {
return [];
}

// There is outstanding deposited amount, so iterate through deposits from newest to oldest
// (newest deposits are most likely to be the ones not yet finalized) until we've accounted for
// all outstanding volume.
const sortedDepositsNewestFirst = deposits.slice().sort((a, b) => b.insertTime - a.insertTime);
const outstandingDeposits: BinanceDeposit[] = [];
for (const deposit of sortedDepositsNewestFirst) {
if (remainingOutstanding <= 0) {
break;
}

if (deposit.amount <= remainingOutstanding) {
// Entire deposit is outstanding.
outstandingDeposits.push({ ...deposit });
remainingOutstanding -= deposit.amount;
} else {
// Only part of this deposit is outstanding. The rest was covered by withdrawals.
// 8 decimal places is the precision of the Binance API and we truncate for simplicity sake and avoiding BN to float conversion issues.
outstandingDeposits.push({ ...deposit, amount: truncate(remainingOutstanding, 8) });
remainingOutstanding = 0;
}
}

// Filter for the deposits on the specific network and return them.
return outstandingDeposits.filter((deposit) => deposit.network === depositNetwork);
}
101 changes: 101 additions & 0 deletions src/utils/RunningBalanceUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { utils as sdkUtils } from "@across-protocol/sdk";
import { BigNumber, isDefined, toBN, EvmAddress } from ".";
import { ProposedRootBundle } from "../interfaces";
import { BundleDataApproxClient } from "../clients/BundleDataApproxClient";
import { HubPoolClient } from "../clients";

type RunningBalanceResult = {
absLatestRunningBalance: BigNumber;
lastValidatedRunningBalance: BigNumber;
upcomingDeposits: BigNumber;
upcomingRefunds: BigNumber;
bundleEndBlock: number;
proposedRootBundle: string | undefined;
};

/**
* Returns running balances for an l1Token on the specified chains.
* @param l1Token L1 token address to query.
* @param chainsToEvaluate Chain IDs to compute running balances for.
* @param hubPoolClient HubPoolClient instance for querying validated bundles.
* @param bundleDataApproxClient BundleDataApproxClient for upcoming deposits/refunds.
* @returns Dictionary keyed by chain ID of running balance results.
*/
export async function getLatestRunningBalances(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note, this is copied from InventoryClient._getLatestRunningBalances

l1Token: EvmAddress,
chainsToEvaluate: number[],
hubPoolClient: HubPoolClient,
bundleDataApproxClient: BundleDataApproxClient
): Promise<{ [chainId: number]: RunningBalanceResult }> {
const chainIds = hubPoolClient.configStoreClient.getChainIdIndicesForBlock();

const entries = await sdkUtils.mapAsync(chainsToEvaluate, async (chainId) => {
const chainIdIndex = chainIds.indexOf(chainId);

// We need to find the latest validated running balance for this chain and token.
const lastValidatedRunningBalance = hubPoolClient.getRunningBalanceBeforeBlockForChain(
hubPoolClient.latestHeightSearched,
chainId,
l1Token
).runningBalance;

// Approximate latest running balance for a chain as last known validated running balance...
// - minus total deposit amount on chain since the latest validated end block
// - plus total refund amount on chain since the latest validated end block
const latestValidatedBundle = hubPoolClient.getLatestExecutedRootBundleContainingL1Token(
hubPoolClient.latestHeightSearched,
chainId,
l1Token
);
const l2Token = hubPoolClient.getL2TokenForL1TokenAtBlock(l1Token, Number(chainId));
if (!isDefined(l2Token)) {
return undefined;
}

// If there is no ExecutedRootBundle event in the hub pool client's lookback for the token and chain, then
// default the bundle end block to 0. This will force getUpcomingDepositAmount to count any deposit
// seen in the spoke pool client's lookback. It would be very odd however for there to be deposits or refunds
// for a token and chain without there being a validated root bundle containing the token, so really the
// following check will be hit if the chain's running balance is very stale. The best way to check
// its running balance at that point is to query the token balance directly but this is still potentially
// inaccurate if someone sent tokens directly to the contract, and it incurs an extra RPC call so we avoid
// it for now. The default running balance will be 0, and this function is primarily designed to choose
// which chains have too many running balances and therefore should be selected for repayment, so returning
// 0 here means this chain will never be selected for repayment as a "slow withdrawal" chain.
let lastValidatedBundleEndBlock = 0;
let proposedRootBundle: ProposedRootBundle | undefined;
if (latestValidatedBundle) {
// The ProposeRootBundle event must precede the ExecutedRootBundle event we grabbed above. However, it
// might not exist if the ExecutedRootBundle event is old enough that the preceding ProposeRootBundle is
// older than the lookback. In this case, leave the last validated bundle end block as 0, since it must be
// before the earliest lookback block.
proposedRootBundle = hubPoolClient.getLatestFullyExecutedRootBundle(latestValidatedBundle.blockNumber);
if (proposedRootBundle) {
lastValidatedBundleEndBlock = proposedRootBundle.bundleEvaluationBlockNumbers[chainIdIndex].toNumber();
}
}

const upcomingDeposits = bundleDataApproxClient.getUpcomingDeposits(chainId, l1Token);
const upcomingRefunds = bundleDataApproxClient.getUpcomingRefunds(chainId, l1Token);

// Updated running balance is last known running balance minus deposits plus upcoming refunds.
const latestRunningBalance = lastValidatedRunningBalance.sub(upcomingDeposits).add(upcomingRefunds);
Comment on lines +78 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Convert upcoming amounts to L1 decimals before balance math

lastValidatedRunningBalance is in hub/L1 token units, but BundleDataApproxClient.getUpcomingDeposits and getUpcomingRefunds return amounts in the spoke token’s native decimals (see the existing conversion logic in InventoryClient._getLatestRunningBalances). Subtracting/adding these values directly here mis-scales running balances whenever L2 and L1 decimals differ, which can cause incorrect excess-balance calculations and repayment-chain selection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good callout, however this function isn't used in any clients in this PR and the downstream PR (#3109) fixes this and makes sure that the BundleDataApproxClient functions return values in L1 units so I don't think this is necessary

// A negative running balance means that the spoke has a balance. If the running balance is positive, then the
// hub owes it funds and its below target so we don't want to take additional repayment.
const absLatestRunningBalance = latestRunningBalance.lt(0) ? latestRunningBalance.abs() : toBN(0);

return [
chainId,
{
absLatestRunningBalance,
lastValidatedRunningBalance,
upcomingDeposits,
upcomingRefunds,
bundleEndBlock: lastValidatedBundleEndBlock,
proposedRootBundle: proposedRootBundle?.txnRef,
},
];
});

return Object.fromEntries(entries.filter(isDefined));
}
70 changes: 69 additions & 1 deletion src/utils/TokenUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CHAIN_IDs, TOKEN_EQUIVALENCE_REMAPPING, TOKEN_SYMBOLS_MAP } from "@acro
import { constants, utils, arch } from "@across-protocol/sdk";
import { CONTRACT_ADDRESSES } from "../common";
import { BigNumberish, BigNumber } from "./BNUtils";
import { formatUnits, getTokenInfo } from "./SDKUtils";
import { formatUnits, getL1TokenAddress as resolveL1TokenAddress, getTokenInfo } from "./SDKUtils";
import { isDefined } from "./TypeGuards";
import { Address, toAddressType, EvmAddress, SvmAddress, SVMProvider, toBN } from "./";
import { TokenInfo } from "../interfaces";
Expand All @@ -11,6 +11,10 @@ const { ZERO_ADDRESS } = constants;

export const { fetchTokenInfo, getL2TokenAddresses } = utils;

// Returns the canonical token for the given L1 token on the given remote chain, assuming that the L1 token
// exists in only a single mapping in TOKEN_SYMBOLS_MAP. This is the case currently for all tokens except for
// USDC.e, but that's why we use the TOKEN_EQUIVALENCE_REMAPPING to remap the token back to its inventory
// equivalent L1 token.
export function getRemoteTokenForL1Token(
_l1Token: EvmAddress,
remoteChainId: number | string,
Expand All @@ -30,6 +34,70 @@ export function getRemoteTokenForL1Token(
);
}

// Returns the L1 token that is equivalent to the `l2Token` within the context of the inventory.
// This is used to link tokens that are not linked via pool rebalance routes, for example.
export function getInventoryEquivalentL1TokenAddress(
l2Token: Address,
chainId: number,
hubChainId = CHAIN_IDs.MAINNET
): EvmAddress {
try {
return resolveL1TokenAddress(l2Token, chainId);
} catch {
const { symbol } = getTokenInfo(l2Token, chainId);
const remappedSymbol = TOKEN_EQUIVALENCE_REMAPPING[symbol] ?? symbol;
const l1TokenAddress = TOKEN_SYMBOLS_MAP[remappedSymbol]?.addresses[hubChainId];
if (!isDefined(l1TokenAddress)) {
throw new Error(`Unable to resolve inventory-equivalent L1 token for ${l2Token} on chain ${chainId}`);
}
return EvmAddress.from(l1TokenAddress);
}
}

// Returns the L2 tokens that are equivalent for a given `l1Token` within the context of the inventory.
// Equivalency is defined by tokens that share the same L1 token within TOKEN_SYMBOLS_MAP or are
// mapped to each other in TOKEN_EQUIVALENCE_REMAPPING.
export function getInventoryBalanceContributorTokens(
l1Token: EvmAddress,
chainId: number,
hubChainId = CHAIN_IDs.MAINNET
): Address[] {
if (chainId === hubChainId) {
return [l1Token];
}

const hubTokenSymbol = getTokenInfo(l1Token, hubChainId).symbol;
const balanceContributorTokens: Address[] = [];
const canonicalToken = getRemoteTokenForL1Token(l1Token, chainId, hubChainId);
if (isDefined(canonicalToken)) {
balanceContributorTokens.push(canonicalToken);
}

Object.keys(TOKEN_SYMBOLS_MAP).forEach((tokenSymbol) => {
const token = TOKEN_SYMBOLS_MAP[tokenSymbol];
const remappedSymbol = TOKEN_EQUIVALENCE_REMAPPING[token.symbol] ?? token.symbol;
if (remappedSymbol === hubTokenSymbol && isDefined(token.addresses[chainId])) {
balanceContributorTokens.push(toAddressType(token.addresses[chainId], chainId));
}
});

return balanceContributorTokens.filter(
(token, index, allTokens) => allTokens.findIndex((candidate) => candidate.eq(token)) === index
);
}

// Returns true if the token symbol is an L2-only token that maps to a parent L1 token via
// TOKEN_EQUIVALENCE_REMAPPING (e.g. pathUSD -> USDC, USDH -> USDC). These tokens have no
// hub chain address and exist only on specific L2 chains.
export function isL2OnlyEquivalentToken(symbol: string, hubChainId = CHAIN_IDs.MAINNET): boolean {
const remappedSymbol = TOKEN_EQUIVALENCE_REMAPPING[symbol];
if (!isDefined(remappedSymbol)) {
return false;
}
const tokenInfo = TOKEN_SYMBOLS_MAP[symbol];
return isDefined(tokenInfo) && !isDefined(tokenInfo.addresses[hubChainId]);
}

export function getNativeTokenAddressForChain(chainId: number): Address {
return toAddressType(CONTRACT_ADDRESSES[chainId]?.nativeToken?.address ?? ZERO_ADDRESS, chainId);
}
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,4 @@ export * from "./HyperliquidUtils";
export * from "./Tasks";
export * from "./TimeUtils";
export * from "./DepositAddressUtils";
export * from "./RunningBalanceUtils";
Loading
Loading