99 ChainName ,
1010 HyperlaneIgp ,
1111 MultiProvider ,
12+ defaultMultisigConfigs ,
1213} from '@hyperlane-xyz/sdk' ;
1314import { Address , objFilter , objMap , rootLogger } from '@hyperlane-xyz/utils' ;
1415
@@ -29,6 +30,8 @@ import {
2930 ContextAndRoles ,
3031 ContextAndRolesMap ,
3132 KeyFunderConfig ,
33+ SweepOverrideConfig ,
34+ validateSweepConfig ,
3235} from '../../src/config/funding.js' ;
3336import { FundableRole , Role } from '../../src/roles.js' ;
3437import {
@@ -51,6 +54,11 @@ import L1ScrollMessenger from './utils/L1ScrollMessenger.json' with { type: 'jso
5154
5255const logger = rootLogger . child ( { module : 'fund-keys' } ) ;
5356
57+ // Default sweep configuration
58+ const DEFAULT_SWEEP_ADDRESS = '0x478be6076f31E9666123B9721D0B6631baD944AF' ;
59+ const DEFAULT_TARGET_MULTIPLIER = 1.5 ; // Leave 1.5x threshold after sweep
60+ const DEFAULT_TRIGGER_MULTIPLIER = 2.0 ; // Sweep when balance > 2x threshold
61+
5462const nativeBridges = {
5563 scrollsepolia : {
5664 l1ETHGateway : '0x8A54A2347Da2562917304141ab67324615e9866d' ,
@@ -173,6 +181,12 @@ async function main() {
173181 Role . Deployer , // Always fund from the deployer
174182 ) ;
175183
184+ // Load sweep overrides and low urgency balances from the environment config
185+ const keyFunderConfig = config . keyFunderConfig ;
186+ const sweepOverrides = keyFunderConfig ?. sweepOverrides ;
187+ const lowUrgencyKeyFunderBalances =
188+ keyFunderConfig ?. lowUrgencyKeyFunderBalances ?? { } ;
189+
176190 let contextFunders : ContextFunder [ ] ;
177191
178192 if ( argv . f ) {
@@ -187,6 +201,8 @@ async function main() {
187201 argv . desiredKathyBalancePerChain ?? { } ,
188202 argv . desiredRebalancerBalancePerChain ?? { } ,
189203 argv . igpClaimThresholdPerChain ?? { } ,
204+ sweepOverrides ,
205+ lowUrgencyKeyFunderBalances ,
190206 path ,
191207 ) ,
192208 ) ;
@@ -205,6 +221,8 @@ async function main() {
205221 argv . desiredKathyBalancePerChain ?? { } ,
206222 argv . desiredRebalancerBalancePerChain ?? { } ,
207223 argv . igpClaimThresholdPerChain ?? { } ,
224+ sweepOverrides ,
225+ lowUrgencyKeyFunderBalances ,
208226 ) ,
209227 ) ,
210228 ) ;
@@ -267,6 +285,8 @@ class ContextFunder {
267285 public readonly igpClaimThresholdPerChain : KeyFunderConfig <
268286 ChainName [ ]
269287 > [ 'igpClaimThresholdPerChain' ] ,
288+ public readonly sweepOverrides : ChainMap < SweepOverrideConfig > | undefined ,
289+ public readonly lowUrgencyKeyFunderBalances : ChainMap < string > ,
270290 ) {
271291 // At the moment, only blessed EVM chains are supported
272292 roleKeysPerChain = objFilter (
@@ -318,6 +338,8 @@ class ContextFunder {
318338 igpClaimThresholdPerChain : KeyFunderConfig <
319339 ChainName [ ]
320340 > [ 'igpClaimThresholdPerChain' ] ,
341+ sweepOverrides : ChainMap < SweepOverrideConfig > | undefined ,
342+ lowUrgencyKeyFunderBalances : ChainMap < string > ,
321343 filePath : string ,
322344 ) {
323345 logger . info ( { filePath } , 'Reading identifiers and addresses from file' ) ;
@@ -392,6 +414,8 @@ class ContextFunder {
392414 desiredKathyBalancePerChain ,
393415 desiredRebalancerBalancePerChain ,
394416 igpClaimThresholdPerChain ,
417+ sweepOverrides ,
418+ lowUrgencyKeyFunderBalances ,
395419 ) ;
396420 }
397421
@@ -415,6 +439,8 @@ class ContextFunder {
415439 igpClaimThresholdPerChain : KeyFunderConfig <
416440 ChainName [ ]
417441 > [ 'igpClaimThresholdPerChain' ] ,
442+ sweepOverrides : ChainMap < SweepOverrideConfig > | undefined ,
443+ lowUrgencyKeyFunderBalances : ChainMap < string > ,
418444 ) {
419445 // only roles that are fundable keys ie. relayer and kathy
420446 const fundableRoleKeys : Record < FundableRole , Address > = {
@@ -465,6 +491,8 @@ class ContextFunder {
465491 desiredKathyBalancePerChain ,
466492 desiredRebalancerBalancePerChain ,
467493 igpClaimThresholdPerChain ,
494+ sweepOverrides ,
495+ lowUrgencyKeyFunderBalances ,
468496 ) ;
469497 }
470498
@@ -573,6 +601,22 @@ class ContextFunder {
573601 }
574602 }
575603
604+ // Attempt to sweep excess funds after all claim/funding operations are complete
605+ // Only sweep when processing the Hyperlane context to avoid duplicate sweeps
606+ if ( this . context === Contexts . Hyperlane ) {
607+ try {
608+ await this . attemptToSweepExcessFunds ( chain ) ;
609+ } catch ( err ) {
610+ logger . error (
611+ {
612+ chain,
613+ error : err ,
614+ } ,
615+ `Error sweeping excess funds on chain ${ chain } ` ,
616+ ) ;
617+ }
618+ }
619+
576620 if ( failedKeys . length > 0 ) {
577621 throw new Error (
578622 `Failed to fund ${
@@ -619,6 +663,140 @@ class ContextFunder {
619663 }
620664 }
621665
666+ // Attempts to sweep excess funds to a given address when balance exceeds threshold.
667+ // To avoid churning txs, only sweep when balance > triggerMultiplier * threshold,
668+ // and leave targetMultiplier * threshold after sweep.
669+ private async attemptToSweepExcessFunds ( chain : ChainName ) : Promise < void > {
670+ // Skip if the chain isn't in production yet i.e. if the validator set size is still 1
671+ if ( defaultMultisigConfigs [ chain ] . validators . length === 1 ) {
672+ logger . debug (
673+ { chain } ,
674+ 'Chain is not in production yet, skipping sweep.' ,
675+ ) ;
676+ return ;
677+ }
678+
679+ // Skip if we don't have a threshold configured for this chain
680+ const lowUrgencyBalanceStr = this . lowUrgencyKeyFunderBalances [ chain ] ;
681+ if ( ! lowUrgencyBalanceStr ) {
682+ logger . debug (
683+ { chain } ,
684+ 'No low urgency balance configured for chain, skipping sweep' ,
685+ ) ;
686+ return ;
687+ }
688+
689+ const lowUrgencyBalance = ethers . utils . parseEther ( lowUrgencyBalanceStr ) ;
690+
691+ // Skip if threshold is zero or negligible
692+ if ( lowUrgencyBalance . lte ( 0 ) ) {
693+ logger . debug ( { chain } , 'Low urgency balance is zero, skipping sweep' ) ;
694+ return ;
695+ }
696+
697+ // Get override config for this chain, if any
698+ const override = this . sweepOverrides ?. [ chain ] ;
699+
700+ // Use override or default sweep address
701+ const sweepAddress = override ?. sweepAddress ?? DEFAULT_SWEEP_ADDRESS ;
702+
703+ // Use override or default multipliers
704+ const targetMultiplier =
705+ override ?. targetMultiplier ?? DEFAULT_TARGET_MULTIPLIER ;
706+ const triggerMultiplier =
707+ override ?. triggerMultiplier ?? DEFAULT_TRIGGER_MULTIPLIER ;
708+
709+ // If we have overrides, validate the full config with all overrides applied.
710+ if ( override ) {
711+ try {
712+ validateSweepConfig ( {
713+ sweepAddress,
714+ targetMultiplier,
715+ triggerMultiplier,
716+ } ) ;
717+ } catch ( error ) {
718+ logger . error (
719+ {
720+ chain,
721+ override,
722+ error : format ( error ) ,
723+ } ,
724+ 'Invalid sweep override configuration' ,
725+ ) ;
726+ throw new Error (
727+ `Invalid sweep override configuration for chain ${ chain } : ${ error } ` ,
728+ ) ;
729+ }
730+ }
731+
732+ // Calculate threshold amounts
733+ const targetBalance = lowUrgencyBalance
734+ . mul ( Math . floor ( targetMultiplier * 100 ) )
735+ . div ( 100 ) ;
736+ const triggerThreshold = lowUrgencyBalance
737+ . mul ( Math . floor ( triggerMultiplier * 100 ) )
738+ . div ( 100 ) ;
739+
740+ // Get current funder balance
741+ const funderAddress = await this . multiProvider . getSignerAddress ( chain ) ;
742+ const funderBalance = await this . multiProvider
743+ . getSigner ( chain )
744+ . getBalance ( ) ;
745+
746+ logger . info (
747+ {
748+ chain,
749+ funderAddress,
750+ funderBalance : ethers . utils . formatEther ( funderBalance ) ,
751+ lowUrgencyBalance : ethers . utils . formatEther ( lowUrgencyBalance ) ,
752+ targetBalance : ethers . utils . formatEther ( targetBalance ) ,
753+ triggerThreshold : ethers . utils . formatEther ( triggerThreshold ) ,
754+ targetMultiplier,
755+ triggerMultiplier,
756+ } ,
757+ 'Checking if sweep is needed' ,
758+ ) ;
759+
760+ // Only sweep if balance exceeds trigger threshold
761+ if ( funderBalance . gt ( triggerThreshold ) ) {
762+ const sweepAmount = funderBalance . sub ( targetBalance ) ;
763+
764+ logger . info (
765+ {
766+ chain,
767+ sweepAmount : ethers . utils . formatEther ( sweepAmount ) ,
768+ sweepAddress,
769+ funderBalance : ethers . utils . formatEther ( funderBalance ) ,
770+ remainingBalance : ethers . utils . formatEther ( targetBalance ) ,
771+ } ,
772+ 'Sweeping excess funds' ,
773+ ) ;
774+
775+ const tx = await this . multiProvider . sendTransaction ( chain , {
776+ to : sweepAddress ,
777+ value : sweepAmount ,
778+ } ) ;
779+
780+ logger . info (
781+ {
782+ chain,
783+ tx :
784+ this . multiProvider . tryGetExplorerTxUrl ( chain , {
785+ hash : tx . transactionHash ,
786+ } ) ?? tx . transactionHash ,
787+ sweepAmount : ethers . utils . formatEther ( sweepAmount ) ,
788+ sweepAddress,
789+ } ,
790+ 'Successfully swept excess funds' ,
791+ ) ;
792+ } else {
793+ logger . info (
794+ { chain } ,
795+ 'Funder balance below trigger threshold, no sweep needed' ,
796+ ) ;
797+ }
798+ }
799+
622800 // Attempts to claim from the IGP if the balance exceeds the claim threshold.
623801 // If no threshold is set, infer it by reading the desired balance and dividing that by 5.
624802 private async attemptToClaimFromIgp ( chain : ChainName ) : Promise < void > {
0 commit comments