From 8655d5b3c76b7d7d47fbd90b92a74268c9ea09fb Mon Sep 17 00:00:00 2001 From: Aleksa Colovic Date: Thu, 4 Dec 2025 15:57:30 +0100 Subject: [PATCH 1/5] chore: Refactor USDH Refiller to be more configurable --- src/refiller/Refiller.ts | 138 ++++++++++++++++++++++++--------- src/refiller/RefillerConfig.ts | 5 +- 2 files changed, 107 insertions(+), 36 deletions(-) diff --git a/src/refiller/Refiller.ts b/src/refiller/Refiller.ts index d6fb414e25..c6a2d7daf7 100644 --- a/src/refiller/Refiller.ts +++ b/src/refiller/Refiller.ts @@ -389,7 +389,11 @@ export class Refiller { } } - private async refillUsdh(currentBalance: BigNumber, decimals: number): Promise { + private async refillUsdh( + currentBalance: BigNumber, + decimals: number, + refillBalanceData: RefillBalanceData + ): Promise { // If either the apiUrl or apiKey is undefined, then return, since we can't do anything. if (!isDefined(this.config.nativeMarketsApiConfig)) { this.logger.warn({ @@ -398,6 +402,82 @@ export class Refiller { }); return; } + + const { trigger, target, checkOriginChainBalance, account } = refillBalanceData; + const triggerThreshold = parseUnits(trigger.toString(), decimals); + const targetThreshold = parseUnits(target.toString(), decimals); + const accountAddress = account.toNative(); + + // Early exit check: If checking destination chain balance, verify it's below trigger + if (!checkOriginChainBalance) { + const shouldRefill = currentBalance.lt(triggerThreshold); + this.logger.debug({ + at: "Refiller#refillUsdh", + message: "Checking destination chain (HyperEVM) USDH balance", + destinationChainBalance: formatUnits(currentBalance, decimals), + triggerThreshold: formatUnits(triggerThreshold, decimals), + targetThreshold: formatUnits(targetThreshold, decimals), + shouldRefill, + }); + + // Early exit if destination chain balance is above trigger + if (!shouldRefill) { + this.logger.debug({ + at: "Refiller#refillUsdh", + message: "Destination chain balance above trigger, skipping transfer", + destinationChainBalance: formatUnits(currentBalance, decimals), + triggerThreshold: formatUnits(triggerThreshold, decimals), + }); + return; + } + } + + // Get origin chain (Arbitrum) USDC balance + const srcProvider = this.clients.balanceAllocator.providers[CHAIN_IDs.ARBITRUM]; + const usdc = new Contract( + TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + ERC20_ABI, + this.baseSigner.connect(srcProvider) + ); + const originChainBalance = await usdc.balanceOf(this.baseSignerAddress.toNative()); + + // Calculate deficit for destination balance check (only needed when not checking origin) + const deficit = targetThreshold.sub(currentBalance); + + const originChainBalanceOverThreshold = originChainBalance.gt(this.config.minUsdhRebalanceAmount); + // Determine if we should send tokens and how much based on the check type + const shouldSendTokens = checkOriginChainBalance ? originChainBalanceOverThreshold : deficit.gt(bnZero); + + const amountToTransfer = checkOriginChainBalance ? originChainBalance : originChainBalance.sub(deficit); + + this.logger.debug({ + at: "Refiller#refillUsdh", + message: checkOriginChainBalance + ? "Checking origin chain (Arbitrum) USDC balance" + : "Destination balance below trigger, calculating top-up amount", + destinationChainBalance: formatUnits(currentBalance, decimals), + targetThreshold: formatUnits(targetThreshold, decimals), + deficit: formatUnits(deficit, decimals), + originChainBalance: formatUnits(originChainBalance, decimals), + amountToTransfer: formatUnits(amountToTransfer, decimals), + minThreshold: formatUnits(this.config.minUsdhRebalanceAmount, decimals), + shouldSendTokens, + }); + + // Early exit if we shouldn't send tokens + if (!shouldSendTokens || amountToTransfer.lte(bnZero)) { + this.logger.debug({ + at: "Refiller#refillUsdh", + message: "Skipping transfer", + reason: !shouldSendTokens ? "Origin chain balance insufficient or no deficit" : "Amount to transfer is zero", + originChainBalance: formatUnits(originChainBalance, decimals), + minThreshold: formatUnits(this.config.minUsdhRebalanceAmount, decimals), + amountToTransfer: formatUnits(amountToTransfer, decimals), + }); + return; + } + + // If we reach here, we need to send tokens - proceed with API calls const { apiUrl: nativeMarketsApiUrl, apiKey: nativeMarketsApiKey } = this.config.nativeMarketsApiConfig; const headers = { "Api-Version": "2025-11-01", @@ -406,32 +486,31 @@ export class Refiller { }; const day = 24 * 60 * 60; - // First, get the address ID of the base signer, which is used to determine the deposit address for Arb -> HyperEVM transfers. + // Get the address ID of the account, which is used to determine the deposit address for Arb -> HyperEVM transfers. let addressId; - // If we have the address ID for the base signer and token combo in cache, then do not request it from native markets. - const addressIdCacheKey = `nativeMarketsAddressId:${this.baseSignerAddress.toNative()}`; + // If we have the address ID for the account and token combo in cache, then do not request it from native markets. + const addressIdCacheKey = `nativeMarketsAddressId:${accountAddress}`; const addressIdCache = await this.redisCache.get(addressIdCacheKey); if (isDefined(addressIdCache)) { addressId = addressIdCache; } else { const { data: registeredAddresses } = await axios.get(`${nativeMarketsApiUrl}/addresses`, { headers }); addressId = registeredAddresses.items.find( - ({ chain, token, address_hex }) => - chain === "hyper_evm" && token === "usdh" && address_hex === this.baseSignerAddress.toNative() + ({ chain, token, address_hex }) => chain === "hyper_evm" && token === "usdh" && address_hex === accountAddress )?.id; // In the event the address is not currently available, create a new one by posting to the native markets API. if (!isDefined(addressId)) { const newAddressIdData = { - address: this.baseSignerAddress.toNative(), + address: accountAddress, chain: "hyper_evm", name: "across-refiller-test", token: "usdh", }; this.logger.info({ - at: "Refiller#refillNativeTokenBalances", - message: `Address ${this.baseSignerAddress.toNative()} is not registered in the native markets API. Creating new address ID.`, - address: this.baseSignerAddress.toNative(), + at: "Refiller#refillUsdh", + message: `Address ${accountAddress} is not registered in the native markets API. Creating new address ID.`, + address: accountAddress, }); const { data: _addressId } = await axios.post(`${nativeMarketsApiUrl}/addresses`, newAddressIdData, { headers, @@ -441,7 +520,7 @@ export class Refiller { await this.redisCache.set(addressIdCacheKey, addressId, 7 * day); } - // Next, get the transfer route deposit address on Arbitrum. + // Get the transfer route deposit address on Arbitrum. const { data: transferRoutes } = await axios.get(`${nativeMarketsApiUrl}/transfer_routes`, { headers }); let availableTransferRoute = transferRoutes.items .filter((route) => isDefined(route.source_address)) @@ -449,7 +528,7 @@ export class Refiller { ({ source_address, destination_address }) => source_address.chain === "arbitrum" && source_address.token === "usdc" && - destination_address.address_hex === this.baseSignerAddress.toNative() + destination_address.address_hex === accountAddress ); // Once again, if the transfer route is not defined, then create a new one by querying the native markets API. if (!isDefined(availableTransferRoute)) { @@ -461,9 +540,9 @@ export class Refiller { }; this.logger.info({ - at: "Refiller#refillNativeTokenBalances", + at: "Refiller#refillUsdh", message: `Address ID ${addressId} does not have an Arbitrum USDC -> HyperEVM USDH transfer route configured. Creating a new route.`, - address: this.baseSignerAddress.toNative(), + address: accountAddress, addressId, }); const { data: _availableTransferRoute } = await axios.post( @@ -476,27 +555,16 @@ export class Refiller { availableTransferRoute = _availableTransferRoute; } - // Create the transfer transaction. - const srcProvider = this.clients.balanceAllocator.providers[CHAIN_IDs.ARBITRUM]; - const usdc = new Contract( - TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], - ERC20_ABI, - this.baseSigner.connect(srcProvider) - ); - // By default, sweep the entire USDC balance of the base signer to HyperEVM USDH. - const amountToTransfer = await usdc.balanceOf(this.baseSignerAddress.toNative()); - - if (amountToTransfer.gt(this.config.minUsdhRebalanceAmount)) { - this.clients.multiCallerClient.enqueueTransaction({ - contract: usdc, - chainId: CHAIN_IDs.ARBITRUM, - method: "transfer", - args: [availableTransferRoute.source_address.address_hex, amountToTransfer], - message: "Rebalanced Arbitrum USDC to HyperEVM USDH", - nonMulticall: true, - mrkdwn: `Sent ${formatUnits(amountToTransfer, decimals)} USDC from Arbitrum to HyperEVM.`, - }); - } + // Send tokens + this.clients.multiCallerClient.enqueueTransaction({ + contract: usdc, + chainId: CHAIN_IDs.ARBITRUM, + method: "transfer", + args: [availableTransferRoute.source_address.address_hex, amountToTransfer], + message: "Rebalanced Arbitrum USDC to HyperEVM USDH", + nonMulticall: true, + mrkdwn: `Sent ${formatUnits(amountToTransfer, decimals)} USDC from Arbitrum to HyperEVM.`, + }); } private async _swapToRefill( diff --git a/src/refiller/RefillerConfig.ts b/src/refiller/RefillerConfig.ts index 5b3b70832e..9d9b547163 100644 --- a/src/refiller/RefillerConfig.ts +++ b/src/refiller/RefillerConfig.ts @@ -9,6 +9,8 @@ export type RefillBalanceData = { target: number; trigger: number; refillPeriod?: number; + // If true, check origin chain balance. If false, check destination chain balance (default). + checkOriginChainBalance: boolean; }; export class RefillerConfig extends CommonConfig { @@ -24,7 +26,7 @@ export class RefillerConfig extends CommonConfig { // Used to send tokens if available in wallet to balances under target balances. if (REFILL_BALANCES) { this.refillEnabledBalances = JSON.parse(REFILL_BALANCES).map( - ({ chainId, account, isHubPool, target, trigger, token }) => { + ({ chainId, account, isHubPool, target, trigger, token, checkOriginChainBalance }) => { if (Number.isNaN(target) || target <= 0) { throw new Error(`target for ${chainId} and ${account} must be > 0, got ${target}`); } @@ -43,6 +45,7 @@ export class RefillerConfig extends CommonConfig { // Optional fields that will set to defaults: isHubPool: Boolean(isHubPool), token: isDefined(token) ? toAddressType(token, chainId) : getNativeTokenAddressForChain(chainId), + checkOriginChainBalance: isDefined(checkOriginChainBalance) ? Boolean(checkOriginChainBalance) : false, }; } ); From 02cc5159babb31349477b8d5a05b804a6557b935 Mon Sep 17 00:00:00 2001 From: Aleksa Colovic Date: Thu, 4 Dec 2025 16:03:48 +0100 Subject: [PATCH 2/5] change message --- src/refiller/Refiller.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/refiller/Refiller.ts b/src/refiller/Refiller.ts index c6a2d7daf7..f608424a7d 100644 --- a/src/refiller/Refiller.ts +++ b/src/refiller/Refiller.ts @@ -452,9 +452,7 @@ export class Refiller { this.logger.debug({ at: "Refiller#refillUsdh", - message: checkOriginChainBalance - ? "Checking origin chain (Arbitrum) USDC balance" - : "Destination balance below trigger, calculating top-up amount", + message: "Determining if we should send tokens and how much to send", destinationChainBalance: formatUnits(currentBalance, decimals), targetThreshold: formatUnits(targetThreshold, decimals), deficit: formatUnits(deficit, decimals), From e67780c6f13c9ae686d24d4db985249126a94643 Mon Sep 17 00:00:00 2001 From: Aleksa Colovic Date: Thu, 4 Dec 2025 16:06:15 +0100 Subject: [PATCH 3/5] fix message --- src/refiller/Refiller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/refiller/Refiller.ts b/src/refiller/Refiller.ts index f608424a7d..8a5543fd10 100644 --- a/src/refiller/Refiller.ts +++ b/src/refiller/Refiller.ts @@ -467,7 +467,7 @@ export class Refiller { this.logger.debug({ at: "Refiller#refillUsdh", message: "Skipping transfer", - reason: !shouldSendTokens ? "Origin chain balance insufficient or no deficit" : "Amount to transfer is zero", + reason: "Origin chain balance insufficient or no deficit", originChainBalance: formatUnits(originChainBalance, decimals), minThreshold: formatUnits(this.config.minUsdhRebalanceAmount, decimals), amountToTransfer: formatUnits(amountToTransfer, decimals), From 355f2dffb3388f87ba6d5b0ba787df7ddb9c316f Mon Sep 17 00:00:00 2001 From: Aleksa Colovic Date: Fri, 5 Dec 2025 16:21:12 +0100 Subject: [PATCH 4/5] Fix amountToSend calculation --- src/refiller/Refiller.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/refiller/Refiller.ts b/src/refiller/Refiller.ts index 8a5543fd10..a60ef6edba 100644 --- a/src/refiller/Refiller.ts +++ b/src/refiller/Refiller.ts @@ -448,7 +448,17 @@ export class Refiller { // Determine if we should send tokens and how much based on the check type const shouldSendTokens = checkOriginChainBalance ? originChainBalanceOverThreshold : deficit.gt(bnZero); - const amountToTransfer = checkOriginChainBalance ? originChainBalance : originChainBalance.sub(deficit); + const amountToTransfer = checkOriginChainBalance ? originChainBalance : deficit; + + if (!checkOriginChainBalance && amountToTransfer.gt(originChainBalance)) { + this.logger.warn({ + at: "Refiller#refillUsdh", + message: "Amount to transfer is greater than origin chain balance, skipping transfer", + amountToTransfer: formatUnits(amountToTransfer, decimals), + originChainBalance: formatUnits(originChainBalance, decimals), + }); + return; + } this.logger.debug({ at: "Refiller#refillUsdh", From 036a8f76473ea8b2404c32f9cf3a9e5a078f40b9 Mon Sep 17 00:00:00 2001 From: Aleksa Colovic Date: Wed, 10 Dec 2025 11:41:07 +0100 Subject: [PATCH 5/5] Refactor refillUsdh --- src/refiller/Refiller.ts | 163 ++++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 78 deletions(-) diff --git a/src/refiller/Refiller.ts b/src/refiller/Refiller.ts index a60ef6edba..d4cde22cc4 100644 --- a/src/refiller/Refiller.ts +++ b/src/refiller/Refiller.ts @@ -403,87 +403,13 @@ export class Refiller { return; } - const { trigger, target, checkOriginChainBalance, account } = refillBalanceData; - const triggerThreshold = parseUnits(trigger.toString(), decimals); - const targetThreshold = parseUnits(target.toString(), decimals); - const accountAddress = account.toNative(); - - // Early exit check: If checking destination chain balance, verify it's below trigger - if (!checkOriginChainBalance) { - const shouldRefill = currentBalance.lt(triggerThreshold); - this.logger.debug({ - at: "Refiller#refillUsdh", - message: "Checking destination chain (HyperEVM) USDH balance", - destinationChainBalance: formatUnits(currentBalance, decimals), - triggerThreshold: formatUnits(triggerThreshold, decimals), - targetThreshold: formatUnits(targetThreshold, decimals), - shouldRefill, - }); - - // Early exit if destination chain balance is above trigger - if (!shouldRefill) { - this.logger.debug({ - at: "Refiller#refillUsdh", - message: "Destination chain balance above trigger, skipping transfer", - destinationChainBalance: formatUnits(currentBalance, decimals), - triggerThreshold: formatUnits(triggerThreshold, decimals), - }); - return; - } - } - - // Get origin chain (Arbitrum) USDC balance - const srcProvider = this.clients.balanceAllocator.providers[CHAIN_IDs.ARBITRUM]; - const usdc = new Contract( - TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], - ERC20_ABI, - this.baseSigner.connect(srcProvider) - ); - const originChainBalance = await usdc.balanceOf(this.baseSignerAddress.toNative()); - - // Calculate deficit for destination balance check (only needed when not checking origin) - const deficit = targetThreshold.sub(currentBalance); - - const originChainBalanceOverThreshold = originChainBalance.gt(this.config.minUsdhRebalanceAmount); - // Determine if we should send tokens and how much based on the check type - const shouldSendTokens = checkOriginChainBalance ? originChainBalanceOverThreshold : deficit.gt(bnZero); - - const amountToTransfer = checkOriginChainBalance ? originChainBalance : deficit; - - if (!checkOriginChainBalance && amountToTransfer.gt(originChainBalance)) { - this.logger.warn({ - at: "Refiller#refillUsdh", - message: "Amount to transfer is greater than origin chain balance, skipping transfer", - amountToTransfer: formatUnits(amountToTransfer, decimals), - originChainBalance: formatUnits(originChainBalance, decimals), - }); + // Check if we should refill and get the transfer details + const refillData = await this.shouldRefillUsdh(currentBalance, decimals, refillBalanceData); + if (!refillData) { return; } - this.logger.debug({ - at: "Refiller#refillUsdh", - message: "Determining if we should send tokens and how much to send", - destinationChainBalance: formatUnits(currentBalance, decimals), - targetThreshold: formatUnits(targetThreshold, decimals), - deficit: formatUnits(deficit, decimals), - originChainBalance: formatUnits(originChainBalance, decimals), - amountToTransfer: formatUnits(amountToTransfer, decimals), - minThreshold: formatUnits(this.config.minUsdhRebalanceAmount, decimals), - shouldSendTokens, - }); - - // Early exit if we shouldn't send tokens - if (!shouldSendTokens || amountToTransfer.lte(bnZero)) { - this.logger.debug({ - at: "Refiller#refillUsdh", - message: "Skipping transfer", - reason: "Origin chain balance insufficient or no deficit", - originChainBalance: formatUnits(originChainBalance, decimals), - minThreshold: formatUnits(this.config.minUsdhRebalanceAmount, decimals), - amountToTransfer: formatUnits(amountToTransfer, decimals), - }); - return; - } + const { usdc, amountToTransfer, accountAddress } = refillData; // If we reach here, we need to send tokens - proceed with API calls const { apiUrl: nativeMarketsApiUrl, apiKey: nativeMarketsApiKey } = this.config.nativeMarketsApiConfig; @@ -575,6 +501,87 @@ export class Refiller { }); } + /** + * Determines if a USDH refill should occur and calculates the amount to transfer. + * @returns Object with usdc contract, amount, and account address if refill should happen; null otherwise. + */ + private async shouldRefillUsdh( + currentBalance: BigNumber, + decimals: number, + refillBalanceData: RefillBalanceData + ): Promise<{ usdc: Contract; amountToTransfer: BigNumber; accountAddress: string } | null> { + const { trigger, target, checkOriginChainBalance, account } = refillBalanceData; + const triggerThreshold = parseUnits(trigger.toString(), decimals); + const targetThreshold = parseUnits(target.toString(), decimals); + const accountAddress = account.toNative(); + + // Early exit check: If checking destination chain balance, verify it's below trigger + if (!checkOriginChainBalance && currentBalance.gt(triggerThreshold)) { + this.logger.debug({ + at: "Refiller#shouldRefillUsdh", + message: "Destination chain balance above trigger, skipping transfer", + destinationChainBalance: formatUnits(currentBalance, decimals), + triggerThreshold: formatUnits(triggerThreshold, decimals), + }); + return null; + } + + // Get origin chain (Arbitrum) USDC balance + const srcProvider = this.clients.balanceAllocator.providers[CHAIN_IDs.ARBITRUM]; + const usdc = new Contract( + TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + ERC20_ABI, + this.baseSigner.connect(srcProvider) + ); + const originChainBalance = await usdc.balanceOf(this.baseSignerAddress.toNative()); + + // Calculate deficit for destination balance check (only needed when not checking origin) + const deficit = targetThreshold.sub(currentBalance); + + const originChainBalanceOverThreshold = originChainBalance.gt(this.config.minUsdhRebalanceAmount); + // Determine if we should send tokens and how much based on the check type + const shouldSendTokens = checkOriginChainBalance ? originChainBalanceOverThreshold : deficit.gt(bnZero); + + const amountToTransfer = checkOriginChainBalance ? originChainBalance : deficit; + + if (!checkOriginChainBalance && amountToTransfer.gt(originChainBalance)) { + this.logger.warn({ + at: "Refiller#shouldRefillUsdh", + message: "Amount to transfer is greater than origin chain balance, skipping transfer", + amountToTransfer: formatUnits(amountToTransfer, decimals), + originChainBalance: formatUnits(originChainBalance, decimals), + }); + return null; + } + + this.logger.debug({ + at: "Refiller#shouldRefillUsdh", + message: "Determining if we should send tokens and how much to send", + destinationChainBalance: formatUnits(currentBalance, decimals), + targetThreshold: formatUnits(targetThreshold, decimals), + deficit: formatUnits(deficit, decimals), + originChainBalance: formatUnits(originChainBalance, decimals), + amountToTransfer: formatUnits(amountToTransfer, decimals), + minThreshold: formatUnits(this.config.minUsdhRebalanceAmount, decimals), + shouldSendTokens, + }); + + // Return null if we shouldn't send tokens + if (!shouldSendTokens || amountToTransfer.lte(bnZero)) { + this.logger.debug({ + at: "Refiller#shouldRefillUsdh", + message: "Skipping transfer", + reason: "Origin chain balance insufficient or no deficit", + originChainBalance: formatUnits(originChainBalance, decimals), + minThreshold: formatUnits(this.config.minUsdhRebalanceAmount, decimals), + amountToTransfer: formatUnits(amountToTransfer, decimals), + }); + return null; + } + + return { usdc, amountToTransfer, accountAddress }; + } + private async _swapToRefill( swapRoute: SwapRoute, amount: BigNumber,