Skip to content

Commit d3e45a9

Browse files
authored
feat(keyfunder): automate deployer sweeps (#7211)
1 parent c71e252 commit d3e45a9

File tree

5 files changed

+264
-1
lines changed

5 files changed

+264
-1
lines changed

typescript/infra/config/environments/mainnet3/funding.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Contexts } from '../../contexts.js';
66

77
import desiredRebalancerBalances from './balances/desiredRebalancerBalances.json' with { type: 'json' };
88
import desiredRelayerBalances from './balances/desiredRelayerBalances.json' with { type: 'json' };
9+
import lowUrgencyKeyFunderBalances from './balances/lowUrgencyKeyFunderBalance.json' with { type: 'json' };
910
import { environment } from './chains.js';
1011
import { mainnet3SupportedChainNames } from './supportedChainNames.js';
1112

@@ -23,6 +24,13 @@ const desiredRebalancerBalancePerChain = objMap(
2324
(_, balance) => balance.toString(),
2425
) as Record<DesiredRebalancerBalanceChains, string>;
2526

27+
type LowUrgencyKeyFunderBalanceChains =
28+
keyof typeof lowUrgencyKeyFunderBalances;
29+
const lowUrgencyKeyFunderBalancePerChain = objMap(
30+
lowUrgencyKeyFunderBalances,
31+
(_, balance) => balance.toString(),
32+
) as Record<LowUrgencyKeyFunderBalanceChains, string>;
33+
2634
export const keyFunderConfig: KeyFunderConfig<
2735
typeof mainnet3SupportedChainNames
2836
> = {
@@ -153,4 +161,10 @@ export const keyFunderConfig: KeyFunderConfig<
153161
soon: '0',
154162
sonicsvm: '0',
155163
},
164+
// Low urgency key funder balance thresholds for sweep calculations
165+
// Automatic sweep enabled by default for all chains with these thresholds
166+
// Defaults: sweep to 0x478be6076f31E9666123B9721D0B6631baD944AF when balance > 2x threshold, leave 1.5x threshold
167+
lowUrgencyKeyFunderBalances: lowUrgencyKeyFunderBalancePerChain,
168+
// Per-chain overrides for sweep (optional)
169+
sweepOverrides: {},
156170
};

typescript/infra/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"prompts": "^2.4.2",
3636
"tmp": "^0.2.3",
3737
"yaml": "2.4.5",
38-
"yargs": "^17.7.2"
38+
"yargs": "^17.7.2",
39+
"zod": "^3.21.2"
3940
},
4041
"devDependencies": {
4142
"@hyperlane-xyz/tsconfig": "workspace:^",

typescript/infra/scripts/funding/fund-keys-from-deployer.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ChainName,
1010
HyperlaneIgp,
1111
MultiProvider,
12+
defaultMultisigConfigs,
1213
} from '@hyperlane-xyz/sdk';
1314
import { 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';
3336
import { FundableRole, Role } from '../../src/roles.js';
3437
import {
@@ -51,6 +54,11 @@ import L1ScrollMessenger from './utils/L1ScrollMessenger.json' with { type: 'jso
5154

5255
const 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+
5462
const 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> {

typescript/infra/src/config/funding.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { z } from 'zod';
2+
13
import { ChainMap, ChainName } from '@hyperlane-xyz/sdk';
24

35
import { Contexts } from '../../config/contexts.js';
@@ -29,8 +31,75 @@ export interface KeyFunderConfig<SupportedChains extends readonly ChainName[]>
2931
desiredRebalancerBalancePerChain: ChainMap<string>;
3032
igpClaimThresholdPerChain: ChainMap<string>;
3133
chainsToSkip: ChainName[];
34+
// Per-chain overrides for automatic sweep of excess funds to Safes
35+
// Defaults: sweep to 0x478be6076f31E9666123B9721D0B6631baD944AF when balance > 2x threshold, leave behind 1.5x threshold
36+
sweepOverrides?: ChainMap<SweepOverrideConfig>;
37+
// Low urgency key funder balance thresholds for sweep calculations
38+
lowUrgencyKeyFunderBalances?: ChainMap<string>;
3239
}
3340

3441
export interface CheckWarpDeployConfig extends CronJobConfig {
3542
registryCommit?: string;
3643
}
44+
45+
// Zod validation schema for sweep override configuration
46+
export type SweepOverrideConfig = z.infer<typeof SweepOverrideConfigSchema>;
47+
48+
const MIN_TRIGGER_DIFFERENCE = 0.05;
49+
const MIN_TARGET = 1.05;
50+
const MIN_TRIGGER = 1.1;
51+
const MAX_TARGET = 10.0;
52+
const MAX_TRIGGER = 200.0;
53+
54+
const SweepOverrideConfigSchema = z
55+
.object({
56+
sweepAddress: z
57+
.string()
58+
.regex(
59+
/^0x[a-fA-F0-9]{40}$/,
60+
'sweepAddress must be a valid Ethereum address (0x-prefixed, 40 hex characters)',
61+
)
62+
.optional(),
63+
targetMultiplier: z
64+
.number()
65+
.min(MIN_TARGET, `Target multiplier must be at least ${MIN_TARGET}`)
66+
.max(MAX_TARGET, `Target multiplier must be at most ${MAX_TARGET}`)
67+
.optional(),
68+
triggerMultiplier: z
69+
.number()
70+
.min(MIN_TRIGGER, `Trigger multiplier must be at least ${MIN_TRIGGER}`)
71+
.max(MAX_TRIGGER, `Trigger multiplier must be at most ${MAX_TRIGGER}`)
72+
.optional(),
73+
})
74+
.refine(
75+
(data) => {
76+
// Check both provided
77+
if (
78+
typeof data.targetMultiplier === 'number' &&
79+
typeof data.triggerMultiplier === 'number'
80+
) {
81+
// Enforce: trigger multiplier must be at least MIN_TRIGGER_DIFFERENCE greater than target
82+
return (
83+
data.triggerMultiplier >=
84+
data.targetMultiplier + MIN_TRIGGER_DIFFERENCE
85+
);
86+
}
87+
return true;
88+
},
89+
{
90+
message: `Trigger multiplier must be at least ${MIN_TRIGGER_DIFFERENCE} greater than target multiplier`,
91+
path: ['triggerMultiplier'],
92+
},
93+
);
94+
95+
/**
96+
* Validates a single sweep override configuration using Zod schema.
97+
* Ensures multipliers are within reasonable bounds and trigger > target.
98+
*
99+
* @param config - The sweep override configuration to validate
100+
* @returns Validated SweepOverrideConfig
101+
* @throws Error if validation fails with formatted error message
102+
*/
103+
export function validateSweepConfig(config: unknown): SweepOverrideConfig {
104+
return SweepOverrideConfigSchema.parse(config);
105+
}

0 commit comments

Comments
 (0)