From d7a5909093577cf57b6bc6b2cba16c1512f2a3aa Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Fri, 13 Mar 2026 20:26:05 -0300 Subject: [PATCH 01/15] add params --- .../aztec-node/src/aztec-node/server.ts | 15 +++++++++ .../ethereum/src/publisher_manager.ts | 24 ++++++++++++-- yarn-project/foundation/src/config/env_var.ts | 2 ++ .../node-keystore/src/keystore_manager.ts | 5 +++ yarn-project/prover-node/src/factory.ts | 20 +++++++++++- .../src/client/sequencer-client.ts | 2 ++ .../sequencer-client/src/publisher/config.ts | 32 +++++++++++++++++++ 7 files changed, 96 insertions(+), 4 deletions(-) diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index e6c2108e985d..edc352e4308e 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -8,6 +8,7 @@ import { createEthereumChain } from '@aztec/ethereum/chain'; import { getPublicClient, makeL1HttpTransport } from '@aztec/ethereum/client'; import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; +import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { chunkBy, compactArray, pick, unique } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -492,6 +493,19 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { { telemetry, logger: log.createChild('l1-tx-utils'), dateProvider, kzg: Blob.getViemKzgInstance() }, ); + // Create a funder L1TxUtils from the keystore funding account (if configured) + const fundingSigner = keyStoreManager!.createFundingSigner(); + let funderL1TxUtils: L1TxUtils | undefined; + if (fundingSigner) { + const [funder] = await createL1TxUtilsFromSigners( + publicClient, + [fundingSigner], + { ...config, scope: 'sequencer' }, + { telemetry, logger: log.createChild('l1-tx-utils:funder'), dateProvider }, + ); + funderL1TxUtils = funder; + } + // Create and start the sequencer client const checkpointsBuilder = new CheckpointsBuilder( { ...config, l1GenesisTime, slotDuration: Number(slotDuration), rollupManaLimit }, @@ -506,6 +520,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { ...deps, epochCache, l1TxUtils, + funderL1TxUtils, validatorClient, p2pClient, worldStateSynchronizer, diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 3c620c37ec93..0c93543fefab 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -27,19 +27,27 @@ const busyStates: TxUtilsState[] = [ export type PublisherFilter = (utils: UtilsType) => boolean; +/** Config accepted by PublisherManager. */ +type PublisherManagerConfig = { + publisherAllowInvalidStates?: boolean; + publisherFundingThreshold?: bigint; + publisherFundingAmount?: bigint; +}; + export class PublisherManager { private log: Logger; - private config: { publisherAllowInvalidStates?: boolean }; + private config: PublisherManagerConfig; constructor( private publishers: UtilsType[], - config: { publisherAllowInvalidStates?: boolean }, + config: PublisherManagerConfig, bindings?: LoggerBindings, + private funder?: UtilsType, ) { this.log = createLogger('publisher:manager', bindings); this.log.info(`PublisherManager initialized with ${publishers.length} publishers.`); this.publishers = publishers; - this.config = pick(config, 'publisherAllowInvalidStates'); + this.config = pick(config, 'publisherAllowInvalidStates', 'publisherFundingThreshold', 'publisherFundingAmount'); } /** Loads the state of all publishers and resumes monitoring any pending txs */ @@ -105,4 +113,14 @@ export class PublisherManager { public interrupt() { this.publishers.forEach(pub => pub.interrupt()); } + + /** Check all publisher balances and fund those below threshold (background, non-blocking). */ + private triggerFundingIfNeeded(_publishersWithBalance: { balance: bigint; publisher: UtilsType }[]): void { + // Stage 1 stub — no-op. Implementation in Stage 3. + } + + /** Fund a single publisher by sending ETH from the funding account. */ + private fundPublisher(_publisher: UtilsType): Promise { + throw new Error('Not implemented'); + } } diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 6cd085cb9e16..ab294db962e6 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -217,6 +217,8 @@ export type EnvVar = | 'SEQ_PUBLISHER_ADDRESSES' | 'SEQ_PUBLISHER_ALLOW_INVALID_STATES' | 'SEQ_PUBLISHER_FORWARDER_ADDRESS' + | 'PUBLISHER_FUNDING_THRESHOLD' + | 'PUBLISHER_FUNDING_AMOUNT' | 'SEQ_POLLING_INTERVAL_MS' | 'SEQ_ENFORCE_TIME_TABLE' | 'SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT' diff --git a/yarn-project/node-keystore/src/keystore_manager.ts b/yarn-project/node-keystore/src/keystore_manager.ts index 23391fe6bbc1..c7e3a3a83b3c 100644 --- a/yarn-project/node-keystore/src/keystore_manager.ts +++ b/yarn-project/node-keystore/src/keystore_manager.ts @@ -270,6 +270,11 @@ export class KeystoreManager { return allPublishers; } + /** Create a signer for the top-level funding account, if configured. */ + createFundingSigner(): EthSigner | undefined { + throw new Error('Not implemented'); + } + /** * Create signers for slasher accounts */ diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index 48465c03d640..33243ccfb2e8 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -119,11 +119,29 @@ export async function createProverNode( { telemetry, logger: log.createChild('l1-tx-utils'), dateProvider }, ); + // Create a funder L1TxUtils from the keystore funding account (if configured) + const fundingSigner = keyStoreManager?.createFundingSigner(); + let funderL1TxUtils: L1TxUtils | undefined; + if (fundingSigner) { + const [funder] = await createL1TxUtilsFromSigners( + publicClient, + [fundingSigner], + { ...config, scope: 'prover' }, + { telemetry, logger: log.createChild('l1-tx-utils:funder'), dateProvider }, + ); + funderL1TxUtils = funder; + } + const publisherFactory = deps.publisherFactory ?? new ProverPublisherFactory(config, { rollupContract, - publisherManager: new PublisherManager(l1TxUtils, getPublisherConfigFromProverConfig(config), log.getBindings()), + publisherManager: new PublisherManager( + l1TxUtils, + getPublisherConfigFromProverConfig(config), + log.getBindings(), + funderL1TxUtils, + ), telemetry, }); diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index 0efeafb01f10..bc975d3acb2e 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -70,6 +70,7 @@ export class SequencerClient { dateProvider: DateProvider; epochCache?: EpochCache; l1TxUtils: L1TxUtils[]; + funderL1TxUtils?: L1TxUtils; nodeKeyStore: KeystoreManager; }, ) { @@ -96,6 +97,7 @@ export class SequencerClient { l1TxUtils, getPublisherConfigFromSequencerConfig(config), log.getBindings(), + deps.funderL1TxUtils, ); const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); const [l1GenesisTime, slotDuration, rollupVersion, rollupManaLimit] = await Promise.all([ diff --git a/yarn-project/sequencer-client/src/publisher/config.ts b/yarn-project/sequencer-client/src/publisher/config.ts index ba250c31f0d1..45e2065c58c7 100644 --- a/yarn-project/sequencer-client/src/publisher/config.ts +++ b/yarn-project/sequencer-client/src/publisher/config.ts @@ -4,6 +4,8 @@ import { type L1TxUtilsConfig, l1TxUtilsConfigMappings } from '@aztec/ethereum/l import { type ConfigMappingsType, SecretValue, booleanConfigHelper } from '@aztec/foundation/config'; import { EthAddress } from '@aztec/foundation/eth-address'; +import { parseEther } from 'viem'; + /** Configuration of the transaction publisher. */ export type TxSenderConfig = L1ReaderConfig & { /** The private key to be used by the publisher. */ @@ -50,13 +52,37 @@ export type PublisherConfig = L1TxUtilsConfig & publisherForwarderAddress?: EthAddress; /** Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path */ l1TxFailedStore?: string; + /** Min ETH balance (in wei) below which a publisher gets funded. Undefined = funding disabled. */ + publisherFundingThreshold?: bigint; + /** Amount of ETH (in wei) to send when funding a publisher. Undefined = funding disabled. */ + publisherFundingAmount?: bigint; }; +/** Shared config mappings for publisher funding, used by both sequencer and prover publisher configs. */ +const publisherFundingConfigMappings = { + publisherFundingThreshold: { + env: 'PUBLISHER_FUNDING_THRESHOLD' as const, + description: + 'Min ETH balance below which a publisher gets funded. Specified in ether (e.g. 0.1). Unset = funding disabled.', + parseEnv: (val: string) => parseEther(val), + }, + publisherFundingAmount: { + env: 'PUBLISHER_FUNDING_AMOUNT' as const, + description: + 'Amount of ETH to send when funding a publisher. Specified in ether (e.g. 0.5). Unset = funding disabled.', + parseEnv: (val: string) => parseEther(val), + }, +}; + export type ProverPublisherConfig = L1TxUtilsConfig & BlobClientConfig & { fishermanMode?: boolean; proverPublisherAllowInvalidStates?: boolean; proverPublisherForwarderAddress?: EthAddress; + /** Min ETH balance (in wei) below which a publisher gets funded. Undefined = funding disabled. */ + publisherFundingThreshold?: bigint; + /** Amount of ETH (in wei) to send when funding a publisher. Undefined = funding disabled. */ + publisherFundingAmount?: bigint; }; export type SequencerPublisherConfig = L1TxUtilsConfig & @@ -66,6 +92,10 @@ export type SequencerPublisherConfig = L1TxUtilsConfig & sequencerPublisherForwarderAddress?: EthAddress; /** Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path */ l1TxFailedStore?: string; + /** Min ETH balance (in wei) below which a publisher gets funded. Undefined = funding disabled. */ + publisherFundingThreshold?: bigint; + /** Amount of ETH (in wei) to send when funding a publisher. Undefined = funding disabled. */ + publisherFundingAmount?: bigint; }; export function getPublisherConfigFromProverConfig(config: ProverPublisherConfig): PublisherConfig { @@ -142,6 +172,7 @@ export const sequencerPublisherConfigMappings: ConfigMappingsType = { @@ -163,4 +194,5 @@ export const proverPublisherConfigMappings: ConfigMappingsType (val ? EthAddress.fromString(val) : undefined), }, + ...publisherFundingConfigMappings, }; From 014d9f374a9346b7d5e64fc9c1114022d0ef979d Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 11:14:32 -0300 Subject: [PATCH 02/15] e2e test --- .../src/e2e_publisher_funding.test.ts | 147 ++++++++++++++++++ .../ethereum/src/publisher_manager.ts | 4 +- .../node-keystore/src/keystore_manager.ts | 2 +- 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 yarn-project/end-to-end/src/e2e_publisher_funding.test.ts diff --git a/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts b/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts new file mode 100644 index 000000000000..81da854b35ba --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts @@ -0,0 +1,147 @@ +import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { EthCheatCodes } from '@aztec/aztec/testing'; +import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; +import type { PublisherManager } from '@aztec/ethereum/publisher-manager'; +import { SecretValue } from '@aztec/foundation/config'; +import { retryUntil } from '@aztec/foundation/retry'; +import type { SequencerClient } from '@aztec/sequencer-client'; +import type { TestSequencerClient } from '@aztec/sequencer-client/test'; + +import { jest } from '@jest/globals'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import 'jest-extended'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { type Hex, parseEther } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { getPrivateKeyFromIndex, setup } from './fixtures/utils.js'; + +// Key indices from the test MNEMONIC (all pre-funded by Anvil): +// 0 = L1 contract deployer (not in keystore) +// 1 = validator attester + publisher EOA +// 2 = funding account +const DEPLOYER_KEY_INDEX = 0; +const PUBLISHER_KEY_INDEX = 1; +const FUNDER_KEY_INDEX = 2; + +const toPrivateKeyHex = (index: number): Hex => { + const buf = getPrivateKeyFromIndex(index); + if (!buf) { + throw new Error(`Failed to derive private key for index ${index}`); + } + return `0x${buf.toString('hex')}`; +}; + +const FUNDING_THRESHOLD = parseEther('0.5'); +const FUNDING_AMOUNT = parseEther('1'); + +describe('e2e_publisher_funding', () => { + jest.setTimeout(5 * 60 * 1000); + + let logger: Logger; + let publisherManager: PublisherManager; + let ethCheatCodes: EthCheatCodes; + let teardown: () => Promise; + let keyStoreDirectory: string; + + beforeAll(async () => { + const attesterKey = toPrivateKeyHex(PUBLISHER_KEY_INDEX); + const publisherKey = attesterKey; + const funderKey = toPrivateKeyHex(FUNDER_KEY_INDEX); + const attesterAddress = privateKeyToAccount(attesterKey).address; + + // Write keystore JSON with fundingAccount + keyStoreDirectory = await mkdtemp(join(tmpdir(), 'publisher-funding-')); + const keystore = { + schemaVersion: 1, + validators: [ + { + attester: attesterKey, + publisher: [publisherKey], + coinbase: EthAddress.fromNumber(42).toChecksumString(), + feeRecipient: AztecAddress.fromNumber(42).toString(), + }, + ], + fundingAccount: funderKey, + }; + await writeFile(join(keyStoreDirectory, 'keystore.json'), JSON.stringify(keystore, null, 2)); + + // Stake the validator on L1 so the sequencer can propose blocks + const initialValidators = [ + { + attester: EthAddress.fromString(attesterAddress), + withdrawer: EthAddress.fromString(attesterAddress), + privateKey: attesterKey as Hex, + bn254SecretKey: new SecretValue(Fr.random().toBigInt()), + }, + ]; + + let sequencerClient: SequencerClient | undefined; + ({ + teardown, + logger, + sequencer: sequencerClient, + ethCheatCodes, + } = await setup(0, { + initialValidators, + keyStoreDirectory, + publisherFundingThreshold: FUNDING_THRESHOLD, + publisherFundingAmount: FUNDING_AMOUNT, + minTxsPerBlock: 0, + l1PublisherKey: new SecretValue(toPrivateKeyHex(DEPLOYER_KEY_INDEX)), + })); + + publisherManager = (sequencerClient! as TestSequencerClient).publisherManager; + }); + + afterAll(async () => { + await teardown(); + await rm(keyStoreDirectory, { recursive: true, force: true }); + }); + + it('funds publisher when balance drops below threshold', async () => { + const publishers: L1TxUtils[] = (publisherManager as any).publishers; + const funder: L1TxUtils | undefined = (publisherManager as any).funder; + + expect(publishers.length).toBe(1); + expect(funder).toBeDefined(); + + const publisherAddress = publishers[0].getSenderAddress(); + const funderAddress = funder!.getSenderAddress(); + logger.info(`Publisher: ${publisherAddress}, Funder: ${funderAddress}`); + + // Set publisher balance below threshold + const LOW_BALANCE = parseEther('0.1'); + await ethCheatCodes.setBalance(publisherAddress, LOW_BALANCE); + // Give funder plenty of ETH + await ethCheatCodes.setBalance(funderAddress, parseEther('100')); + + const funderBalanceBefore = await ethCheatCodes.getBalance(funderAddress); + + // The sequencer periodically calls getAvailablePublisher(), which triggers funding + // when it sees the publisher balance is below threshold. Wait for funding to land. + await retryUntil( + async () => { + const balance = await ethCheatCodes.getBalance(publisherAddress); + return balance > LOW_BALANCE ? true : undefined; + }, + 'waiting for publisher to be funded', + 60, + 1, + ); + + const publisherBalanceAfter = await ethCheatCodes.getBalance(publisherAddress); + const funderBalanceAfter = await ethCheatCodes.getBalance(funderAddress); + const funderSpent = funderBalanceBefore - funderBalanceAfter; + + logger.info(`Publisher balance after: ${publisherBalanceAfter} (was ${LOW_BALANCE})`); + logger.info(`Funder spent: ${funderSpent} (expected ~${FUNDING_AMOUNT})`); + + expect(publisherBalanceAfter).toBeGreaterThan(LOW_BALANCE); + // Funder should have sent exactly FUNDING_AMOUNT plus gas costs + expect(funderSpent).toBeGreaterThanOrEqual(FUNDING_AMOUNT); + }); +}); diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 0c93543fefab..464f18639fbf 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -120,7 +120,7 @@ export class PublisherManager { } /** Fund a single publisher by sending ETH from the funding account. */ - private fundPublisher(_publisher: UtilsType): Promise { - throw new Error('Not implemented'); + private async fundPublisher(_publisher: UtilsType): Promise { + // Stage 1 stub — no-op. Implementation in Stage 3. } } diff --git a/yarn-project/node-keystore/src/keystore_manager.ts b/yarn-project/node-keystore/src/keystore_manager.ts index c7e3a3a83b3c..c9d930986005 100644 --- a/yarn-project/node-keystore/src/keystore_manager.ts +++ b/yarn-project/node-keystore/src/keystore_manager.ts @@ -272,7 +272,7 @@ export class KeystoreManager { /** Create a signer for the top-level funding account, if configured. */ createFundingSigner(): EthSigner | undefined { - throw new Error('Not implemented'); + return undefined; } /** From c70f767f67fd1e94010adefd4b85bf71822b4f43 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 11:45:55 -0300 Subject: [PATCH 03/15] implementation stage 1 --- .../ethereum/src/publisher_manager.ts | 48 +++++++++++++++++-- .../node-keystore/src/keystore_manager.ts | 6 ++- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 464f18639fbf..4cd7b4fd9e3c 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -37,6 +37,7 @@ type PublisherManagerConfig = { export class PublisherManager { private log: Logger; private config: PublisherManagerConfig; + private isFunding = false; constructor( private publishers: UtilsType[], @@ -107,6 +108,8 @@ export class PublisherManager { return lastUsedComparison; }); + void this.triggerFundingIfNeeded().catch(err => this.log.error('Error in funding check', { err })); + return sortedPublishers[0].publisher; } @@ -115,12 +118,47 @@ export class PublisherManager { } /** Check all publisher balances and fund those below threshold (background, non-blocking). */ - private triggerFundingIfNeeded(_publishersWithBalance: { balance: bigint; publisher: UtilsType }[]): void { - // Stage 1 stub — no-op. Implementation in Stage 3. + private async triggerFundingIfNeeded(): Promise { + const { funder, config } = this; + if (!funder || config.publisherFundingThreshold === undefined || config.publisherFundingAmount === undefined) { + return; + } + if (this.isFunding) { + return; + } + + this.isFunding = true; + try { + const allBalances = await Promise.all( + this.publishers.map(async pub => ({ balance: await pub.getSenderBalance(), publisher: pub })), + ); + const lowBalance = allBalances.filter(p => p.balance < config.publisherFundingThreshold!); + if (lowBalance.length === 0) { + return; + } + + // TODO: check funder balance before funding, warn if < 10x fundingAmount, skip if < fundingAmount + // TODO: re-read funder balance after each fund, break if exhausted + await this.fundPublishers(lowBalance.map(p => p.publisher)); + } finally { + this.isFunding = false; + } } - /** Fund a single publisher by sending ETH from the funding account. */ - private async fundPublisher(_publisher: UtilsType): Promise { - // Stage 1 stub — no-op. Implementation in Stage 3. + /** Fund publishers sequentially. */ + private async fundPublishers(publishers: UtilsType[]): Promise { + const fundingAmount = this.config.publisherFundingAmount!; + + for (const publisher of publishers) { + const address = publisher.getSenderAddress(); + try { + // TODO: log funding amount in ETH, not wei + this.log.info(`Funding publisher ${address} with ${fundingAmount} wei`); + await this.funder!.sendAndMonitorTransaction({ to: address.toString(), data: '0x', value: fundingAmount }); + this.log.info(`Funded publisher ${address}`); + } catch (err) { + this.log.error(`Failed to fund publisher ${address}`, { err }); + } + } } } diff --git a/yarn-project/node-keystore/src/keystore_manager.ts b/yarn-project/node-keystore/src/keystore_manager.ts index c9d930986005..6bacbf6cc356 100644 --- a/yarn-project/node-keystore/src/keystore_manager.ts +++ b/yarn-project/node-keystore/src/keystore_manager.ts @@ -272,7 +272,11 @@ export class KeystoreManager { /** Create a signer for the top-level funding account, if configured. */ createFundingSigner(): EthSigner | undefined { - return undefined; + const fundingAccount = this.keystore.fundingAccount; + if (!fundingAccount) { + return undefined; + } + return this.createSignerFromEthAccount(fundingAccount, this.keystore.remoteSigner); } /** From 42756732a418170e6d8faad0478ac90cd7a29588 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 11:54:59 -0300 Subject: [PATCH 04/15] unit tests --- .../ethereum/src/publisher_manager.test.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index 3eb0141f824e..5e6afec71fb7 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -172,6 +172,178 @@ describe('PublisherManager', () => { }); }); + describe('publisher funding', () => { + let funder: TestL1TxUtils & L1TxUtils; + const threshold = 100n; + const fundingAmount = 50n; + + const createFundedManager = ( + publishers: (TestL1TxUtils & L1TxUtils)[], + funderInstance?: TestL1TxUtils & L1TxUtils, + config?: { publisherFundingThreshold?: bigint; publisherFundingAmount?: bigint }, + ) => { + return new PublisherManager( + publishers, + { publisherFundingThreshold: threshold, publisherFundingAmount: fundingAmount, ...config }, + undefined, + funderInstance, + ); + }; + + /** Wait for the background funding promise to settle. */ + const waitForFunding = () => new Promise(resolve => setTimeout(resolve, 10)); + + beforeEach(() => { + funder = new TestL1TxUtils(EthAddress.random()) as TestL1TxUtils & L1TxUtils; + funder.balance = 5000n; + }); + + it('funds publisher when balance is below threshold', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; // below threshold + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith( + expect.objectContaining({ to: mockPublishers[0].getSenderAddress().toString(), value: fundingAmount }), + ); + }); + + it('does not fund when publisher balance is above threshold', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 200n; // above threshold + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); + }); + + it('funds multiple publishers, only those below threshold', async () => { + mockPublishers = createMockPublishers(3); + mockPublishers[0].balance = 50n; // below + mockPublishers[1].balance = 200n; // above + mockPublishers[2].balance = 30n; // below + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith( + expect.objectContaining({ to: mockPublishers[0].getSenderAddress().toString(), value: fundingAmount }), + ); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith( + expect.objectContaining({ to: mockPublishers[2].getSenderAddress().toString(), value: fundingAmount }), + ); + }); + + it('handles funding transaction failure gracefully', async () => { + mockPublishers = createMockPublishers(2); + mockPublishers[0].balance = 50n; + mockPublishers[1].balance = 50n; + publisherManager = createFundedManager(mockPublishers, funder); + + // First call fails, second succeeds + funder.sendAndMonitorTransaction + .mockRejectedValueOnce(new Error('tx failed')) + .mockResolvedValueOnce({ receipt: { transactionHash: '0xdef' }, state: {} }); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + // Both attempted despite first failure + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); + }); + + it('correctly sends the funding transaction', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ + to: mockPublishers[0].getSenderAddress().toString(), + data: '0x', + value: fundingAmount, + }); + }); + + it('concurrent guard prevents overlapping funding runs', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; + publisherManager = createFundedManager(mockPublishers, funder); + + // Block the first funding call on a deferred promise we control + let resolveFunding!: () => void; + const fundingBlocked = new Promise(r => (resolveFunding = r)); + funder.sendAndMonitorTransaction.mockImplementation(async () => { + await fundingBlocked; + return { receipt: { transactionHash: '0x1' }, state: {} }; + }); + + // First call triggers funding (sets isFunding = true) + await publisherManager.getAvailablePublisher(); + // Second call while funding is still in progress — should skip + await publisherManager.getAvailablePublisher(); + + // Release the blocked funding and let it complete + resolveFunding(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + }); + + it('no funding triggered when no funder configured', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; + publisherManager = new PublisherManager(mockPublishers, { + publisherFundingThreshold: threshold, + publisherFundingAmount: fundingAmount, + }); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); + }); + + it('no funding triggered when config threshold/amount not set', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; + publisherManager = createFundedManager(mockPublishers, funder, { + publisherFundingThreshold: undefined, + publisherFundingAmount: undefined, + }); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); + }); + + it('funds publishers in busy states', async () => { + mockPublishers = createMockPublishers(2); + mockPublishers[0].balance = 50n; + mockPublishers[0].state = TxUtilsState.IDLE; + mockPublishers[1].balance = 50n; + mockPublishers[1].state = TxUtilsState.SENT; // busy + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + // Both should be funded, even the busy one + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); + }); + }); + function createMockPublishers(count: number, addresses: EthAddress[] = []): (TestL1TxUtils & L1TxUtils)[] { const tempAddress = [...addresses]; return times( @@ -185,6 +357,10 @@ class TestL1TxUtils { public state: TxUtilsState = TxUtilsState.IDLE; public lastMinedAtBlockNumber: bigint | undefined = undefined; public balance: bigint = 1000n; + public sendAndMonitorTransaction = jest.fn<() => Promise>().mockResolvedValue({ + receipt: { transactionHash: '0xabc' }, + state: {}, + }); constructor(public senderAddress: EthAddress) {} From bd2b4c9fdc81725fa1bae9b46e4bd2f75f455463 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 16:08:16 -0300 Subject: [PATCH 05/15] fix todos & expand e2e test --- .../src/e2e_publisher_funding.test.ts | 22 +++++++++- .../ethereum/src/publisher_manager.test.ts | 39 +++++++++++++++++- .../ethereum/src/publisher_manager.ts | 38 +++++++++++++---- .../src/keystore_manager.test.ts | 41 +++++++++++++++++++ yarn-project/prover-node/src/factory.ts | 10 ++--- .../src/client/sequencer-client.ts | 10 ++--- 6 files changed, 137 insertions(+), 23 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts b/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts index 81da854b35ba..a1e6b3c3d0e0 100644 --- a/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts +++ b/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts @@ -36,7 +36,8 @@ const toPrivateKeyHex = (index: number): Hex => { }; const FUNDING_THRESHOLD = parseEther('0.5'); -const FUNDING_AMOUNT = parseEther('1'); +// Small enough that publishing a few blocks will drain it below threshold again, triggering re-funding. +const FUNDING_AMOUNT = parseEther('0.1'); describe('e2e_publisher_funding', () => { jest.setTimeout(5 * 60 * 1000); @@ -143,5 +144,24 @@ describe('e2e_publisher_funding', () => { expect(publisherBalanceAfter).toBeGreaterThan(LOW_BALANCE); // Funder should have sent exactly FUNDING_AMOUNT plus gas costs expect(funderSpent).toBeGreaterThanOrEqual(FUNDING_AMOUNT); + + // Second round: the publisher will spend gas publishing blocks, eventually dropping + // below threshold again. The funder should automatically top it up a second time. + const funderBalanceBefore2 = await ethCheatCodes.getBalance(funderAddress); + logger.info(`Waiting for publisher to drain and get re-funded`); + + await retryUntil( + async () => { + const spent = funderBalanceBefore2 - (await ethCheatCodes.getBalance(funderAddress)); + return spent >= FUNDING_AMOUNT ? true : undefined; + }, + 'waiting for second funding round', + 120, + 1, + ); + + const funderSpent2 = funderBalanceBefore2 - (await ethCheatCodes.getBalance(funderAddress)); + logger.info(`Second funding round: funder spent ${funderSpent2}`); + expect(funderSpent2).toBeGreaterThanOrEqual(FUNDING_AMOUNT); }); }); diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index 5e6afec71fb7..8602c8ed1e99 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -185,8 +185,7 @@ describe('PublisherManager', () => { return new PublisherManager( publishers, { publisherFundingThreshold: threshold, publisherFundingAmount: fundingAmount, ...config }, - undefined, - funderInstance, + { funder: funderInstance }, ); }; @@ -328,6 +327,42 @@ describe('PublisherManager', () => { expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); + it('does not fund when funder balance is less than fundingAmount', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; + funder.balance = 30n; // less than fundingAmount (50n) + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); + }); + + it('stops funding when funder balance exhausted mid-run', async () => { + mockPublishers = createMockPublishers(3); + mockPublishers[0].balance = 10n; + mockPublishers[1].balance = 10n; + mockPublishers[2].balance = 10n; + // Funder starts with enough for all, but after first fund balance drops below fundingAmount + funder.balance = 5000n; + publisherManager = createFundedManager(mockPublishers, funder); + + let callCount = 0; + funder.sendAndMonitorTransaction.mockImplementation(async () => { + callCount++; + if (callCount === 1) { + funder.balance = 10n; // exhausted after first fund + } + return { receipt: { transactionHash: '0x1' }, state: {} }; + }); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + }); + it('funds publishers in busy states', async () => { mockPublishers = createMockPublishers(2); mockPublishers[0].balance = 50n; diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 4cd7b4fd9e3c..227293e87771 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -38,17 +38,24 @@ export class PublisherManager { private log: Logger; private config: PublisherManagerConfig; private isFunding = false; + private funder?: UtilsType; constructor( private publishers: UtilsType[], config: PublisherManagerConfig, - bindings?: LoggerBindings, - private funder?: UtilsType, + opts?: { bindings?: LoggerBindings; funder?: UtilsType }, ) { - this.log = createLogger('publisher:manager', bindings); + this.funder = opts?.funder; + this.log = createLogger('publisher:manager', opts?.bindings); this.log.info(`PublisherManager initialized with ${publishers.length} publishers.`); this.publishers = publishers; this.config = pick(config, 'publisherAllowInvalidStates', 'publisherFundingThreshold', 'publisherFundingAmount'); + + const hasThreshold = this.config.publisherFundingThreshold !== undefined; + const hasAmount = this.config.publisherFundingAmount !== undefined; + if (hasThreshold !== hasAmount) { + this.log.warn(`Incomplete funding config: both publisherFundingThreshold and publisherFundingAmount must be set`); + } } /** Loads the state of all publishers and resumes monitoring any pending txs */ @@ -137,27 +144,42 @@ export class PublisherManager { return; } - // TODO: check funder balance before funding, warn if < 10x fundingAmount, skip if < fundingAmount - // TODO: re-read funder balance after each fund, break if exhausted + const fundingAmount = config.publisherFundingAmount!; + let funderBalance = await funder.getSenderBalance(); + + if (funderBalance < 10n * fundingAmount) { + this.log.warn(`Funding account balance is low`, { funderBalance, threshold: 10n * fundingAmount }); + } + if (funderBalance < fundingAmount) { + this.log.error(`Funding account balance too low to fund any publisher`, { funderBalance, fundingAmount }); + return; + } + await this.fundPublishers(lowBalance.map(p => p.publisher)); } finally { this.isFunding = false; } } - /** Fund publishers sequentially. */ + /** Fund publishers sequentially. Re-reads funder balance after each transfer. */ private async fundPublishers(publishers: UtilsType[]): Promise { const fundingAmount = this.config.publisherFundingAmount!; for (const publisher of publishers) { const address = publisher.getSenderAddress(); try { - // TODO: log funding amount in ETH, not wei - this.log.info(`Funding publisher ${address} with ${fundingAmount} wei`); + this.log.info(`Funding publisher ${address}`, { fundingAmount }); await this.funder!.sendAndMonitorTransaction({ to: address.toString(), data: '0x', value: fundingAmount }); this.log.info(`Funded publisher ${address}`); } catch (err) { this.log.error(`Failed to fund publisher ${address}`, { err }); + continue; + } + + const funderBalance = await this.funder!.getSenderBalance(); + if (funderBalance < fundingAmount) { + this.log.warn(`Funder exhausted after funding, stopping`, { funderBalance, fundingAmount }); + break; } } } diff --git a/yarn-project/node-keystore/src/keystore_manager.test.ts b/yarn-project/node-keystore/src/keystore_manager.test.ts index b2861a964a8f..9da117dd4312 100644 --- a/yarn-project/node-keystore/src/keystore_manager.test.ts +++ b/yarn-project/node-keystore/src/keystore_manager.test.ts @@ -1560,4 +1560,45 @@ describe('KeystoreManager', () => { expect(validateAccessSpy).toHaveBeenCalledWith(url2, [address2.toString()]); }); }); + + describe('createFundingSigner', () => { + const fundingPrivateKey = '0x1234567890123456789012345678901234567890123456789012345678901234'; + + it('returns signer from top-level fundingAccount', async () => { + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: EthAddress.random(), + feeRecipient: await AztecAddress.random(), + }, + ], + fundingAccount: fundingPrivateKey, + }; + + const manager = new KeystoreManager(keystore); + const signer = manager.createFundingSigner(); + + expect(signer).toBeDefined(); + const expected = new LocalSigner(Buffer32.fromString(fundingPrivateKey)); + expect(signer!.address.equals(expected.address)).toBeTruthy(); + }); + + it('returns undefined when no fundingAccount configured', async () => { + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: EthAddress.random(), + feeRecipient: await AztecAddress.random(), + }, + ], + }; + + const manager = new KeystoreManager(keystore); + const signer = manager.createFundingSigner(); + + expect(signer).toBeUndefined(); + }); + }); }); diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index 33243ccfb2e8..6f2ceda0926d 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -136,12 +136,10 @@ export async function createProverNode( deps.publisherFactory ?? new ProverPublisherFactory(config, { rollupContract, - publisherManager: new PublisherManager( - l1TxUtils, - getPublisherConfigFromProverConfig(config), - log.getBindings(), - funderL1TxUtils, - ), + publisherManager: new PublisherManager(l1TxUtils, getPublisherConfigFromProverConfig(config), { + bindings: log.getBindings(), + funder: funderL1TxUtils, + }), telemetry, }); diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index bc975d3acb2e..b867973303e4 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -93,12 +93,10 @@ export class SequencerClient { publicClient, l1TxUtils.map(x => x.getSenderAddress()), ); - const publisherManager = new PublisherManager( - l1TxUtils, - getPublisherConfigFromSequencerConfig(config), - log.getBindings(), - deps.funderL1TxUtils, - ); + const publisherManager = new PublisherManager(l1TxUtils, getPublisherConfigFromSequencerConfig(config), { + bindings: log.getBindings(), + funder: deps.funderL1TxUtils, + }); const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); const [l1GenesisTime, slotDuration, rollupVersion, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), From 530140045b839592d6ca7330957df8d3924b5aee Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 16:17:51 -0300 Subject: [PATCH 06/15] fix self funding --- .../ethereum/src/publisher_manager.test.ts | 16 ++++++++++++++++ yarn-project/ethereum/src/publisher_manager.ts | 8 ++++++++ .../sequencer-client/src/publisher/config.ts | 12 ++++++------ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index 8602c8ed1e99..ef5e89d3c8fd 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -363,6 +363,22 @@ describe('PublisherManager', () => { expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); }); + it('disables funding when funder address matches a publisher', async () => { + const sharedAddress = EthAddress.random(); + mockPublishers = createMockPublishers(2, [sharedAddress]); + mockPublishers[0].balance = 50n; // same address as funder + mockPublishers[1].balance = 50n; // different address, also below threshold + funder = new TestL1TxUtils(sharedAddress) as TestL1TxUtils & L1TxUtils; + funder.balance = 5000n; + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + // Funding is fully disabled because funder overlaps with a publisher + expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); + }); + it('funds publishers in busy states', async () => { mockPublishers = createMockPublishers(2); mockPublishers[0].balance = 50n; diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 227293e87771..e377c6f4d1fb 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -56,6 +56,14 @@ export class PublisherManager { if (hasThreshold !== hasAmount) { this.log.warn(`Incomplete funding config: both publisherFundingThreshold and publisherFundingAmount must be set`); } + + if (this.funder) { + const funderAddress = this.funder.getSenderAddress(); + if (publishers.some(p => p.getSenderAddress().equals(funderAddress))) { + this.log.error(`Funding account ${funderAddress} is also a publisher, disabling funding to avoid self-funding`); + this.funder = undefined; + } + } } /** Loads the state of all publishers and resumes monitoring any pending txs */ diff --git a/yarn-project/sequencer-client/src/publisher/config.ts b/yarn-project/sequencer-client/src/publisher/config.ts index 45e2065c58c7..db10385a5983 100644 --- a/yarn-project/sequencer-client/src/publisher/config.ts +++ b/yarn-project/sequencer-client/src/publisher/config.ts @@ -52,9 +52,9 @@ export type PublisherConfig = L1TxUtilsConfig & publisherForwarderAddress?: EthAddress; /** Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path */ l1TxFailedStore?: string; - /** Min ETH balance (in wei) below which a publisher gets funded. Undefined = funding disabled. */ + /** Min ETH balance (in ether) below which a publisher gets funded. Undefined = funding disabled. */ publisherFundingThreshold?: bigint; - /** Amount of ETH (in wei) to send when funding a publisher. Undefined = funding disabled. */ + /** Amount of ETH (in ether) to send when funding a publisher. Undefined = funding disabled. */ publisherFundingAmount?: bigint; }; @@ -79,9 +79,9 @@ export type ProverPublisherConfig = L1TxUtilsConfig & fishermanMode?: boolean; proverPublisherAllowInvalidStates?: boolean; proverPublisherForwarderAddress?: EthAddress; - /** Min ETH balance (in wei) below which a publisher gets funded. Undefined = funding disabled. */ + /** Min ETH balance (in ether) below which a publisher gets funded. Undefined = funding disabled. */ publisherFundingThreshold?: bigint; - /** Amount of ETH (in wei) to send when funding a publisher. Undefined = funding disabled. */ + /** Amount of ETH (in ether) to send when funding a publisher. Undefined = funding disabled. */ publisherFundingAmount?: bigint; }; @@ -92,9 +92,9 @@ export type SequencerPublisherConfig = L1TxUtilsConfig & sequencerPublisherForwarderAddress?: EthAddress; /** Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path */ l1TxFailedStore?: string; - /** Min ETH balance (in wei) below which a publisher gets funded. Undefined = funding disabled. */ + /** Min ETH balance (in ether) below which a publisher gets funded. Undefined = funding disabled. */ publisherFundingThreshold?: bigint; - /** Amount of ETH (in wei) to send when funding a publisher. Undefined = funding disabled. */ + /** Amount of ETH (in ether) to send when funding a publisher. Undefined = funding disabled. */ publisherFundingAmount?: bigint; }; From 161c19432fd00157feabd35127d24b5f58d83007 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 16:44:45 -0300 Subject: [PATCH 07/15] fix build --- yarn-project/ethereum/src/publisher_manager.test.ts | 4 ++-- yarn-project/ethereum/src/publisher_manager.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index ef5e89d3c8fd..999450fc71c8 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -349,12 +349,12 @@ describe('PublisherManager', () => { publisherManager = createFundedManager(mockPublishers, funder); let callCount = 0; - funder.sendAndMonitorTransaction.mockImplementation(async () => { + funder.sendAndMonitorTransaction.mockImplementation(() => { callCount++; if (callCount === 1) { funder.balance = 10n; // exhausted after first fund } - return { receipt: { transactionHash: '0x1' }, state: {} }; + return Promise.resolve({ receipt: { transactionHash: '0x1' }, state: {} }); }); await publisherManager.getAvailablePublisher(); diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index e377c6f4d1fb..4c12e1c7d95c 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -153,7 +153,7 @@ export class PublisherManager { } const fundingAmount = config.publisherFundingAmount!; - let funderBalance = await funder.getSenderBalance(); + const funderBalance = await funder.getSenderBalance(); if (funderBalance < 10n * fundingAmount) { this.log.warn(`Funding account balance is low`, { funderBalance, threshold: 10n * fundingAmount }); From 5f553e105a0d9281023ba7287207cd22fd9e6199 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 17:20:23 -0300 Subject: [PATCH 08/15] fix build --- yarn-project/node-keystore/src/keystore_manager.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/yarn-project/node-keystore/src/keystore_manager.test.ts b/yarn-project/node-keystore/src/keystore_manager.test.ts index 9da117dd4312..85f355b5fcc2 100644 --- a/yarn-project/node-keystore/src/keystore_manager.test.ts +++ b/yarn-project/node-keystore/src/keystore_manager.test.ts @@ -12,6 +12,7 @@ import { join } from 'path'; import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts'; import { KeystoreError, KeystoreManager } from '../src/keystore_manager.js'; +import { ethPrivateKeySchema } from '../src/schemas.js'; import { LocalSigner, RemoteSigner } from '../src/signer.js'; import type { KeyStore } from '../src/types.js'; @@ -1562,7 +1563,9 @@ describe('KeystoreManager', () => { }); describe('createFundingSigner', () => { - const fundingPrivateKey = '0x1234567890123456789012345678901234567890123456789012345678901234'; + const fundingPrivateKey = ethPrivateKeySchema.parse( + '0x1234567890123456789012345678901234567890123456789012345678901234', + ); it('returns signer from top-level fundingAccount', async () => { const keystore: KeyStore = { From 2f12ae26677b9042d2025b1bf7543baf456ce6ae Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Mon, 16 Mar 2026 18:25:16 -0300 Subject: [PATCH 09/15] update comments --- yarn-project/aztec-node/src/aztec-node/server.ts | 2 +- .../end-to-end/src/e2e_publisher_funding.test.ts | 6 +++--- .../sequencer-client/src/publisher/config.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index edc352e4308e..ef49eeea39dc 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -494,7 +494,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { ); // Create a funder L1TxUtils from the keystore funding account (if configured) - const fundingSigner = keyStoreManager!.createFundingSigner(); + const fundingSigner = keyStoreManager?.createFundingSigner(); let funderL1TxUtils: L1TxUtils | undefined; if (fundingSigner) { const [funder] = await createL1TxUtilsFromSigners( diff --git a/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts b/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts index a1e6b3c3d0e0..069b6d6ac77b 100644 --- a/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts +++ b/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts @@ -145,10 +145,10 @@ describe('e2e_publisher_funding', () => { // Funder should have sent exactly FUNDING_AMOUNT plus gas costs expect(funderSpent).toBeGreaterThanOrEqual(FUNDING_AMOUNT); - // Second round: the publisher will spend gas publishing blocks, eventually dropping - // below threshold again. The funder should automatically top it up a second time. + // Second round: the publisher balance is still below threshold (FUNDING_AMOUNT < FUNDING_THRESHOLD), + // so the next getAvailablePublisher() call will trigger another funding round. const funderBalanceBefore2 = await ethCheatCodes.getBalance(funderAddress); - logger.info(`Waiting for publisher to drain and get re-funded`); + logger.info(`Waiting for second funding round`); await retryUntil( async () => { diff --git a/yarn-project/sequencer-client/src/publisher/config.ts b/yarn-project/sequencer-client/src/publisher/config.ts index db10385a5983..a68948536ce8 100644 --- a/yarn-project/sequencer-client/src/publisher/config.ts +++ b/yarn-project/sequencer-client/src/publisher/config.ts @@ -52,9 +52,9 @@ export type PublisherConfig = L1TxUtilsConfig & publisherForwarderAddress?: EthAddress; /** Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path */ l1TxFailedStore?: string; - /** Min ETH balance (in ether) below which a publisher gets funded. Undefined = funding disabled. */ + /** Min ETH balance below which a publisher gets funded. Undefined = funding disabled. */ publisherFundingThreshold?: bigint; - /** Amount of ETH (in ether) to send when funding a publisher. Undefined = funding disabled. */ + /** Amount of ETH to send when funding a publisher. Undefined = funding disabled. */ publisherFundingAmount?: bigint; }; @@ -79,9 +79,9 @@ export type ProverPublisherConfig = L1TxUtilsConfig & fishermanMode?: boolean; proverPublisherAllowInvalidStates?: boolean; proverPublisherForwarderAddress?: EthAddress; - /** Min ETH balance (in ether) below which a publisher gets funded. Undefined = funding disabled. */ + /** Min ETH balance below which a publisher gets funded. Undefined = funding disabled. */ publisherFundingThreshold?: bigint; - /** Amount of ETH (in ether) to send when funding a publisher. Undefined = funding disabled. */ + /** Amount of ETH to send when funding a publisher. Undefined = funding disabled. */ publisherFundingAmount?: bigint; }; @@ -92,9 +92,9 @@ export type SequencerPublisherConfig = L1TxUtilsConfig & sequencerPublisherForwarderAddress?: EthAddress; /** Store for failed L1 transaction inputs (test networks only). Format: gs://bucket/path */ l1TxFailedStore?: string; - /** Min ETH balance (in ether) below which a publisher gets funded. Undefined = funding disabled. */ + /** Min ETH balance below which a publisher gets funded. Undefined = funding disabled. */ publisherFundingThreshold?: bigint; - /** Amount of ETH (in ether) to send when funding a publisher. Undefined = funding disabled. */ + /** Amount of ETH to send when funding a publisher. Undefined = funding disabled. */ publisherFundingAmount?: bigint; }; From a54530e96c949b9b0d0428416667e6959baf1fbc Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Wed, 18 Mar 2026 13:43:46 -0300 Subject: [PATCH 10/15] use multicall --- .../src/e2e_publisher_funding.test.ts | 167 ------------------ .../ethereum/src/contracts/multicall.ts | 66 ++++++- .../ethereum/src/publisher_manager.test.ts | 114 ++++++------ .../ethereum/src/publisher_manager.ts | 26 +-- 4 files changed, 132 insertions(+), 241 deletions(-) delete mode 100644 yarn-project/end-to-end/src/e2e_publisher_funding.test.ts diff --git a/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts b/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts deleted file mode 100644 index 069b6d6ac77b..000000000000 --- a/yarn-project/end-to-end/src/e2e_publisher_funding.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; -import { Fr } from '@aztec/aztec.js/fields'; -import type { Logger } from '@aztec/aztec.js/log'; -import type { EthCheatCodes } from '@aztec/aztec/testing'; -import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; -import type { PublisherManager } from '@aztec/ethereum/publisher-manager'; -import { SecretValue } from '@aztec/foundation/config'; -import { retryUntil } from '@aztec/foundation/retry'; -import type { SequencerClient } from '@aztec/sequencer-client'; -import type { TestSequencerClient } from '@aztec/sequencer-client/test'; - -import { jest } from '@jest/globals'; -import { mkdtemp, rm, writeFile } from 'fs/promises'; -import 'jest-extended'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { type Hex, parseEther } from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; - -import { getPrivateKeyFromIndex, setup } from './fixtures/utils.js'; - -// Key indices from the test MNEMONIC (all pre-funded by Anvil): -// 0 = L1 contract deployer (not in keystore) -// 1 = validator attester + publisher EOA -// 2 = funding account -const DEPLOYER_KEY_INDEX = 0; -const PUBLISHER_KEY_INDEX = 1; -const FUNDER_KEY_INDEX = 2; - -const toPrivateKeyHex = (index: number): Hex => { - const buf = getPrivateKeyFromIndex(index); - if (!buf) { - throw new Error(`Failed to derive private key for index ${index}`); - } - return `0x${buf.toString('hex')}`; -}; - -const FUNDING_THRESHOLD = parseEther('0.5'); -// Small enough that publishing a few blocks will drain it below threshold again, triggering re-funding. -const FUNDING_AMOUNT = parseEther('0.1'); - -describe('e2e_publisher_funding', () => { - jest.setTimeout(5 * 60 * 1000); - - let logger: Logger; - let publisherManager: PublisherManager; - let ethCheatCodes: EthCheatCodes; - let teardown: () => Promise; - let keyStoreDirectory: string; - - beforeAll(async () => { - const attesterKey = toPrivateKeyHex(PUBLISHER_KEY_INDEX); - const publisherKey = attesterKey; - const funderKey = toPrivateKeyHex(FUNDER_KEY_INDEX); - const attesterAddress = privateKeyToAccount(attesterKey).address; - - // Write keystore JSON with fundingAccount - keyStoreDirectory = await mkdtemp(join(tmpdir(), 'publisher-funding-')); - const keystore = { - schemaVersion: 1, - validators: [ - { - attester: attesterKey, - publisher: [publisherKey], - coinbase: EthAddress.fromNumber(42).toChecksumString(), - feeRecipient: AztecAddress.fromNumber(42).toString(), - }, - ], - fundingAccount: funderKey, - }; - await writeFile(join(keyStoreDirectory, 'keystore.json'), JSON.stringify(keystore, null, 2)); - - // Stake the validator on L1 so the sequencer can propose blocks - const initialValidators = [ - { - attester: EthAddress.fromString(attesterAddress), - withdrawer: EthAddress.fromString(attesterAddress), - privateKey: attesterKey as Hex, - bn254SecretKey: new SecretValue(Fr.random().toBigInt()), - }, - ]; - - let sequencerClient: SequencerClient | undefined; - ({ - teardown, - logger, - sequencer: sequencerClient, - ethCheatCodes, - } = await setup(0, { - initialValidators, - keyStoreDirectory, - publisherFundingThreshold: FUNDING_THRESHOLD, - publisherFundingAmount: FUNDING_AMOUNT, - minTxsPerBlock: 0, - l1PublisherKey: new SecretValue(toPrivateKeyHex(DEPLOYER_KEY_INDEX)), - })); - - publisherManager = (sequencerClient! as TestSequencerClient).publisherManager; - }); - - afterAll(async () => { - await teardown(); - await rm(keyStoreDirectory, { recursive: true, force: true }); - }); - - it('funds publisher when balance drops below threshold', async () => { - const publishers: L1TxUtils[] = (publisherManager as any).publishers; - const funder: L1TxUtils | undefined = (publisherManager as any).funder; - - expect(publishers.length).toBe(1); - expect(funder).toBeDefined(); - - const publisherAddress = publishers[0].getSenderAddress(); - const funderAddress = funder!.getSenderAddress(); - logger.info(`Publisher: ${publisherAddress}, Funder: ${funderAddress}`); - - // Set publisher balance below threshold - const LOW_BALANCE = parseEther('0.1'); - await ethCheatCodes.setBalance(publisherAddress, LOW_BALANCE); - // Give funder plenty of ETH - await ethCheatCodes.setBalance(funderAddress, parseEther('100')); - - const funderBalanceBefore = await ethCheatCodes.getBalance(funderAddress); - - // The sequencer periodically calls getAvailablePublisher(), which triggers funding - // when it sees the publisher balance is below threshold. Wait for funding to land. - await retryUntil( - async () => { - const balance = await ethCheatCodes.getBalance(publisherAddress); - return balance > LOW_BALANCE ? true : undefined; - }, - 'waiting for publisher to be funded', - 60, - 1, - ); - - const publisherBalanceAfter = await ethCheatCodes.getBalance(publisherAddress); - const funderBalanceAfter = await ethCheatCodes.getBalance(funderAddress); - const funderSpent = funderBalanceBefore - funderBalanceAfter; - - logger.info(`Publisher balance after: ${publisherBalanceAfter} (was ${LOW_BALANCE})`); - logger.info(`Funder spent: ${funderSpent} (expected ~${FUNDING_AMOUNT})`); - - expect(publisherBalanceAfter).toBeGreaterThan(LOW_BALANCE); - // Funder should have sent exactly FUNDING_AMOUNT plus gas costs - expect(funderSpent).toBeGreaterThanOrEqual(FUNDING_AMOUNT); - - // Second round: the publisher balance is still below threshold (FUNDING_AMOUNT < FUNDING_THRESHOLD), - // so the next getAvailablePublisher() call will trigger another funding round. - const funderBalanceBefore2 = await ethCheatCodes.getBalance(funderAddress); - logger.info(`Waiting for second funding round`); - - await retryUntil( - async () => { - const spent = funderBalanceBefore2 - (await ethCheatCodes.getBalance(funderAddress)); - return spent >= FUNDING_AMOUNT ? true : undefined; - }, - 'waiting for second funding round', - 120, - 1, - ); - - const funderSpent2 = funderBalanceBefore2 - (await ethCheatCodes.getBalance(funderAddress)); - logger.info(`Second funding round: funder spent ${funderSpent2}`); - expect(funderSpent2).toBeGreaterThanOrEqual(FUNDING_AMOUNT); - }); -}); diff --git a/yarn-project/ethereum/src/contracts/multicall.ts b/yarn-project/ethereum/src/contracts/multicall.ts index c79243b80b1b..40e17970e5db 100644 --- a/yarn-project/ethereum/src/contracts/multicall.ts +++ b/yarn-project/ethereum/src/contracts/multicall.ts @@ -2,7 +2,7 @@ import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer'; import { TimeoutError } from '@aztec/foundation/error'; import type { Logger } from '@aztec/foundation/log'; -import { type EncodeFunctionDataParameters, type Hex, encodeFunctionData, multicall3Abi } from 'viem'; +import { type Address, type EncodeFunctionDataParameters, type Hex, encodeFunctionData, multicall3Abi } from 'viem'; import type { L1BlobInputs, L1TxConfig, L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js'; import type { ExtendedViemWalletClient } from '../types.js'; @@ -11,6 +11,39 @@ import { RollupContract } from './rollup.js'; export const MULTI_CALL_3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11' as const; +/** ABI fragment for aggregate3Value — not included in viem's multicall3Abi. */ +export const aggregate3ValueAbi = [ + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'target', type: 'address' }, + { internalType: 'bool', name: 'allowFailure', type: 'bool' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { internalType: 'bytes', name: 'callData', type: 'bytes' }, + ], + internalType: 'struct Multicall3.Call3Value[]', + name: 'calls', + type: 'tuple[]', + }, + ], + name: 'aggregate3Value', + outputs: [ + { + components: [ + { internalType: 'bool', name: 'success', type: 'bool' }, + { internalType: 'bytes', name: 'returnData', type: 'bytes' }, + ], + internalType: 'struct Multicall3.Result[]', + name: 'returnData', + type: 'tuple[]', + }, + ], + stateMutability: 'payable', + type: 'function', + }, +] as const; + export class Multicall3 { static async forward( requests: L1TxRequest[], @@ -122,6 +155,37 @@ export class Multicall3 { throw err; } } + + /** Batch multiple value transfers into a single aggregate3Value call on Multicall3. */ + static async forwardValue(calls: { to: Address; value: bigint }[], l1TxUtils: L1TxUtils, logger: Logger) { + const args = calls.map(c => ({ + target: c.to, + allowFailure: false, + value: c.value, + callData: '0x' as Hex, + })); + + const data = encodeFunctionData({ + abi: aggregate3ValueAbi, + functionName: 'aggregate3Value', + args: [args], + }); + + const totalValue = calls.reduce((sum, c) => sum + c.value, 0n); + + logger.info(`Sending aggregate3Value with ${calls.length} calls`, { totalValue }); + const { receipt } = await l1TxUtils.sendAndMonitorTransaction({ + to: MULTI_CALL_3_ADDRESS, + data, + value: totalValue, + }); + + if (receipt.status !== 'success') { + throw new Error(`aggregate3Value transaction reverted: ${receipt.transactionHash}`); + } + + return { receipt }; + } } export async function deployMulticall3(l1Client: ExtendedViemWalletClient, logger: Logger) { diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index 999450fc71c8..3a3f80f7cce7 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -2,10 +2,28 @@ import { times } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { jest } from '@jest/globals'; +import { type Hex, encodeFunctionData } from 'viem'; +import { MULTI_CALL_3_ADDRESS, aggregate3ValueAbi } from './contracts/multicall.js'; import { L1TxUtils, TxUtilsState } from './l1_tx_utils/index.js'; import { PublisherManager } from './publisher_manager.js'; +/** Encode the expected aggregate3Value calldata for the given addresses and funding amount. */ +function expectedFundingData(addresses: EthAddress[], fundingAmount: bigint): Hex { + return encodeFunctionData({ + abi: aggregate3ValueAbi, + functionName: 'aggregate3Value', + args: [ + addresses.map(addr => ({ + target: addr.toString() as `0x${string}`, + allowFailure: false, + value: fundingAmount, + callData: '0x' as Hex, + })), + ], + }); +} + describe('PublisherManager', () => { let mockPublishers: (TestL1TxUtils & L1TxUtils)[]; let publisherManager: PublisherManager; @@ -206,9 +224,11 @@ describe('PublisherManager', () => { await waitForFunding(); expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith( - expect.objectContaining({ to: mockPublishers[0].getSenderAddress().toString(), value: fundingAmount }), - ); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ + to: MULTI_CALL_3_ADDRESS, + data: expectedFundingData([mockPublishers[0].getSenderAddress()], fundingAmount), + value: fundingAmount, + }); }); it('does not fund when publisher balance is above threshold', async () => { @@ -232,46 +252,46 @@ describe('PublisherManager', () => { await publisherManager.getAvailablePublisher(); await waitForFunding(); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith( - expect.objectContaining({ to: mockPublishers[0].getSenderAddress().toString(), value: fundingAmount }), - ); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith( - expect.objectContaining({ to: mockPublishers[2].getSenderAddress().toString(), value: fundingAmount }), - ); + // Single multicall for both underfunded publishers + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ + to: MULTI_CALL_3_ADDRESS, + data: expectedFundingData( + [mockPublishers[0].getSenderAddress(), mockPublishers[2].getSenderAddress()], + fundingAmount, + ), + value: 2n * fundingAmount, + }); }); - it('handles funding transaction failure gracefully', async () => { - mockPublishers = createMockPublishers(2); + it('correctly sends the funding transaction', async () => { + mockPublishers = createMockPublishers(1); mockPublishers[0].balance = 50n; - mockPublishers[1].balance = 50n; publisherManager = createFundedManager(mockPublishers, funder); - // First call fails, second succeeds - funder.sendAndMonitorTransaction - .mockRejectedValueOnce(new Error('tx failed')) - .mockResolvedValueOnce({ receipt: { transactionHash: '0xdef' }, state: {} }); - await publisherManager.getAvailablePublisher(); await waitForFunding(); - // Both attempted despite first failure - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ + to: MULTI_CALL_3_ADDRESS, + data: expectedFundingData([mockPublishers[0].getSenderAddress()], fundingAmount), + value: fundingAmount, + }); }); - it('correctly sends the funding transaction', async () => { - mockPublishers = createMockPublishers(1); + it('handles funding transaction failure gracefully', async () => { + mockPublishers = createMockPublishers(2); mockPublishers[0].balance = 50n; + mockPublishers[1].balance = 50n; publisherManager = createFundedManager(mockPublishers, funder); + funder.sendAndMonitorTransaction.mockRejectedValueOnce(new Error('tx failed')); + await publisherManager.getAvailablePublisher(); await waitForFunding(); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ - to: mockPublishers[0].getSenderAddress().toString(), - data: '0x', - value: fundingAmount, - }); + // Single multicall attempted and failed — error caught by triggerFundingIfNeeded + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); }); it('concurrent guard prevents overlapping funding runs', async () => { @@ -284,7 +304,7 @@ describe('PublisherManager', () => { const fundingBlocked = new Promise(r => (resolveFunding = r)); funder.sendAndMonitorTransaction.mockImplementation(async () => { await fundingBlocked; - return { receipt: { transactionHash: '0x1' }, state: {} }; + return { receipt: { transactionHash: '0x1', status: 'success' }, state: {} }; }); // First call triggers funding (sets isFunding = true) @@ -339,30 +359,6 @@ describe('PublisherManager', () => { expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); - it('stops funding when funder balance exhausted mid-run', async () => { - mockPublishers = createMockPublishers(3); - mockPublishers[0].balance = 10n; - mockPublishers[1].balance = 10n; - mockPublishers[2].balance = 10n; - // Funder starts with enough for all, but after first fund balance drops below fundingAmount - funder.balance = 5000n; - publisherManager = createFundedManager(mockPublishers, funder); - - let callCount = 0; - funder.sendAndMonitorTransaction.mockImplementation(() => { - callCount++; - if (callCount === 1) { - funder.balance = 10n; // exhausted after first fund - } - return Promise.resolve({ receipt: { transactionHash: '0x1' }, state: {} }); - }); - - await publisherManager.getAvailablePublisher(); - await waitForFunding(); - - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); - }); - it('disables funding when funder address matches a publisher', async () => { const sharedAddress = EthAddress.random(); mockPublishers = createMockPublishers(2, [sharedAddress]); @@ -390,8 +386,16 @@ describe('PublisherManager', () => { await publisherManager.getAvailablePublisher(); await waitForFunding(); - // Both should be funded, even the busy one - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); + // Single multicall funds both, even the busy one + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ + to: MULTI_CALL_3_ADDRESS, + data: expectedFundingData( + [mockPublishers[0].getSenderAddress(), mockPublishers[1].getSenderAddress()], + fundingAmount, + ), + value: 2n * fundingAmount, + }); }); }); @@ -409,7 +413,7 @@ class TestL1TxUtils { public lastMinedAtBlockNumber: bigint | undefined = undefined; public balance: bigint = 1000n; public sendAndMonitorTransaction = jest.fn<() => Promise>().mockResolvedValue({ - receipt: { transactionHash: '0xabc' }, + receipt: { transactionHash: '0xabc', status: 'success' }, state: {}, }); diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 4c12e1c7d95c..8579de00b796 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -1,6 +1,7 @@ import { pick } from '@aztec/foundation/collection'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; +import { Multicall3 } from './contracts/multicall.js'; import { L1TxUtils, TxUtilsState } from './l1_tx_utils/index.js'; // Defines the order in which we prioritise publishers based on their state (first is better) @@ -169,26 +170,15 @@ export class PublisherManager { } } - /** Fund publishers sequentially. Re-reads funder balance after each transfer. */ + /** Fund publishers via a single Multicall3 aggregate3Value transaction. */ private async fundPublishers(publishers: UtilsType[]): Promise { const fundingAmount = this.config.publisherFundingAmount!; + const calls = publishers.map(pub => ({ + to: pub.getSenderAddress().toString(), + value: fundingAmount, + })); - for (const publisher of publishers) { - const address = publisher.getSenderAddress(); - try { - this.log.info(`Funding publisher ${address}`, { fundingAmount }); - await this.funder!.sendAndMonitorTransaction({ to: address.toString(), data: '0x', value: fundingAmount }); - this.log.info(`Funded publisher ${address}`); - } catch (err) { - this.log.error(`Failed to fund publisher ${address}`, { err }); - continue; - } - - const funderBalance = await this.funder!.getSenderBalance(); - if (funderBalance < fundingAmount) { - this.log.warn(`Funder exhausted after funding, stopping`, { funderBalance, fundingAmount }); - break; - } - } + await Multicall3.forwardValue(calls, this.funder!, this.log); + this.log.info(`Funded ${publishers.length} publishers`); } } From ef1c0522b8292b4356ebbb2cc51afce889cc1a55 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Wed, 18 Mar 2026 13:46:39 -0300 Subject: [PATCH 11/15] add e2e test --- .../src/e2e_publisher_funding_multi.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts diff --git a/yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts b/yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts new file mode 100644 index 000000000000..f378036041a9 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts @@ -0,0 +1,179 @@ +import { AztecAddress, EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { EthCheatCodes } from '@aztec/aztec/testing'; +import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; +import type { PublisherManager } from '@aztec/ethereum/publisher-manager'; +import { SecretValue } from '@aztec/foundation/config'; +import { retryUntil } from '@aztec/foundation/retry'; +import type { SequencerClient } from '@aztec/sequencer-client'; +import type { TestSequencerClient } from '@aztec/sequencer-client/test'; + +import { jest } from '@jest/globals'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import 'jest-extended'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { type Hex, parseEther } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { getPrivateKeyFromIndex, setup } from './fixtures/utils.js'; + +// Key indices from the test MNEMONIC (all pre-funded by Anvil): +// 0 = L1 contract deployer (not in keystore) +// 1 = validator attester + publisher 1 EOA +// 2 = funding account +// 3 = publisher 2 EOA +const DEPLOYER_KEY_INDEX = 0; +const PUBLISHER1_KEY_INDEX = 1; +const FUNDER_KEY_INDEX = 2; +const PUBLISHER2_KEY_INDEX = 3; + +const toPrivateKeyHex = (index: number): Hex => { + const buf = getPrivateKeyFromIndex(index); + if (!buf) { + throw new Error(`Failed to derive private key for index ${index}`); + } + return `0x${buf.toString('hex')}`; +}; + +const FUNDING_THRESHOLD = parseEther('2'); +const FUNDING_AMOUNT = parseEther('2.1'); + +describe('e2e_publisher_funding_multi', () => { + jest.setTimeout(5 * 60 * 1000); + + let logger: Logger; + let publisherManager: PublisherManager; + let ethCheatCodes: EthCheatCodes; + let teardown: () => Promise; + let keyStoreDirectory: string; + + beforeAll(async () => { + const attesterKey = toPrivateKeyHex(PUBLISHER1_KEY_INDEX); + const publisherKey1 = attesterKey; + const publisherKey2 = toPrivateKeyHex(PUBLISHER2_KEY_INDEX); + const funderKey = toPrivateKeyHex(FUNDER_KEY_INDEX); + const attesterAddress = privateKeyToAccount(attesterKey).address; + + // Write keystore JSON with two publishers and a fundingAccount + keyStoreDirectory = await mkdtemp(join(tmpdir(), 'publisher-funding-multi-')); + const keystore = { + schemaVersion: 1, + validators: [ + { + attester: attesterKey, + publisher: [publisherKey1, publisherKey2], + coinbase: EthAddress.fromNumber(42).toChecksumString(), + feeRecipient: AztecAddress.fromNumber(42).toString(), + }, + ], + fundingAccount: funderKey, + }; + await writeFile(join(keyStoreDirectory, 'keystore.json'), JSON.stringify(keystore, null, 2)); + + // Stake the validator on L1 so the sequencer can propose blocks + const initialValidators = [ + { + attester: EthAddress.fromString(attesterAddress), + withdrawer: EthAddress.fromString(attesterAddress), + privateKey: attesterKey as Hex, + bn254SecretKey: new SecretValue(Fr.random().toBigInt()), + }, + ]; + + let sequencerClient: SequencerClient | undefined; + ({ + teardown, + logger, + sequencer: sequencerClient, + ethCheatCodes, + } = await setup(0, { + initialValidators, + keyStoreDirectory, + publisherFundingThreshold: FUNDING_THRESHOLD, + publisherFundingAmount: FUNDING_AMOUNT, + minTxsPerBlock: 0, + l1PublisherKey: new SecretValue(toPrivateKeyHex(DEPLOYER_KEY_INDEX)), + })); + + publisherManager = (sequencerClient! as TestSequencerClient).publisherManager; + }); + + afterAll(async () => { + await teardown(); + await rm(keyStoreDirectory, { recursive: true, force: true }); + }); + + it('funds both publishers when balances drop below threshold', async () => { + const publishers: L1TxUtils[] = (publisherManager as any).publishers; + const funder: L1TxUtils | undefined = (publisherManager as any).funder; + + expect(publishers.length).toBe(2); + expect(funder).toBeDefined(); + + const publisher1Address = publishers[0].getSenderAddress(); + const publisher2Address = publishers[1].getSenderAddress(); + const funderAddress = funder!.getSenderAddress(); + logger.info(`Publisher1: ${publisher1Address}, Publisher2: ${publisher2Address}, Funder: ${funderAddress}`); + + // Set both publisher balances below threshold + const LOW_BALANCE = parseEther('0.1'); + await ethCheatCodes.setBalance(publisher1Address, LOW_BALANCE); + await ethCheatCodes.setBalance(publisher2Address, LOW_BALANCE); + // Give funder plenty of ETH + await ethCheatCodes.setBalance(funderAddress, parseEther('100')); + + const funderBalanceBefore = await ethCheatCodes.getBalance(funderAddress); + + // The sequencer periodically calls getAvailablePublisher(), which triggers funding + // when it sees publisher balances are below threshold. Both should be funded in a single multicall. + await retryUntil( + async () => { + const balance1 = await ethCheatCodes.getBalance(publisher1Address); + const balance2 = await ethCheatCodes.getBalance(publisher2Address); + return balance1 > LOW_BALANCE && balance2 > LOW_BALANCE ? true : undefined; + }, + 'waiting for both publishers to be funded', + 60, + 1, + ); + + const publisher1BalanceAfter = await ethCheatCodes.getBalance(publisher1Address); + const publisher2BalanceAfter = await ethCheatCodes.getBalance(publisher2Address); + const funderBalanceAfter = await ethCheatCodes.getBalance(funderAddress); + const funderSpent = funderBalanceBefore - funderBalanceAfter; + + logger.info(`Publisher1 balance after: ${publisher1BalanceAfter} (was ${LOW_BALANCE})`); + logger.info(`Publisher2 balance after: ${publisher2BalanceAfter} (was ${LOW_BALANCE})`); + logger.info(`Funder spent: ${funderSpent} (expected ~${2n * FUNDING_AMOUNT})`); + + expect(publisher1BalanceAfter).toBeGreaterThan(LOW_BALANCE); + expect(publisher2BalanceAfter).toBeGreaterThan(LOW_BALANCE); + // Both publishers should now be above the funding threshold + expect(publisher1BalanceAfter).toBeGreaterThanOrEqual(FUNDING_THRESHOLD); + expect(publisher2BalanceAfter).toBeGreaterThanOrEqual(FUNDING_THRESHOLD); + // Funder should have sent 2 * FUNDING_AMOUNT plus gas costs (single multicall) + expect(funderSpent).toBeGreaterThanOrEqual(2n * FUNDING_AMOUNT); + + // Second round: after funding, publishers are above threshold. The active publisher will + // spend gas publishing blocks and eventually drop below threshold again, triggering re-funding + // for that one publisher. + const funderBalanceBefore2 = await ethCheatCodes.getBalance(funderAddress); + logger.info(`Waiting for second funding round`); + + await retryUntil( + async () => { + const spent = funderBalanceBefore2 - (await ethCheatCodes.getBalance(funderAddress)); + return spent >= FUNDING_AMOUNT ? true : undefined; + }, + 'waiting for second funding round', + 120, + 1, + ); + + const funderSpent2 = funderBalanceBefore2 - (await ethCheatCodes.getBalance(funderAddress)); + logger.info(`Second funding round: funder spent ${funderSpent2} (expected ~${FUNDING_AMOUNT})`); + expect(funderSpent2).toBeGreaterThanOrEqual(FUNDING_AMOUNT); + }); +}); From a88a5cbe0366be3aa1f58e7ba13ee9f64bdf5823 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Wed, 18 Mar 2026 14:58:13 -0300 Subject: [PATCH 12/15] cap publishers --- .../ethereum/src/publisher_manager.test.ts | 22 +++++++++++++++++++ .../ethereum/src/publisher_manager.ts | 12 ++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index 3a3f80f7cce7..fafa3cfb610d 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -359,6 +359,28 @@ describe('PublisherManager', () => { expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); + it('caps funding to affordable number of publishers', async () => { + mockPublishers = createMockPublishers(3); + mockPublishers[0].balance = 10n; + mockPublishers[1].balance = 10n; + mockPublishers[2].balance = 10n; + funder.balance = 2n * fundingAmount; // enough for 2, not 3 + publisherManager = createFundedManager(mockPublishers, funder); + + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ + to: MULTI_CALL_3_ADDRESS, + data: expectedFundingData( + [mockPublishers[0].getSenderAddress(), mockPublishers[1].getSenderAddress()], + fundingAmount, + ), + value: 2n * fundingAmount, + }); + }); + it('disables funding when funder address matches a publisher', async () => { const sharedAddress = EthAddress.random(); mockPublishers = createMockPublishers(2, [sharedAddress]); diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 8579de00b796..2e0a1a3129ab 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -159,12 +159,20 @@ export class PublisherManager { if (funderBalance < 10n * fundingAmount) { this.log.warn(`Funding account balance is low`, { funderBalance, threshold: 10n * fundingAmount }); } - if (funderBalance < fundingAmount) { + const affordableCount = Number(funderBalance / fundingAmount); + if (affordableCount === 0) { this.log.error(`Funding account balance too low to fund any publisher`, { funderBalance, fundingAmount }); return; } + if (affordableCount < lowBalance.length) { + this.log.warn(`Funder can only afford ${affordableCount}/${lowBalance.length} publishers`, { + funderBalance, + fundingAmount, + }); + } - await this.fundPublishers(lowBalance.map(p => p.publisher)); + const toFund = lowBalance.slice(0, affordableCount).map(p => p.publisher); + await this.fundPublishers(toFund); } finally { this.isFunding = false; } From f616c97ab55963b813375d49676bb12983229604 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Wed, 18 Mar 2026 15:43:42 -0300 Subject: [PATCH 13/15] funding cooldown --- .../ethereum/src/publisher_manager.test.ts | 47 +++++++++++++++---- .../ethereum/src/publisher_manager.ts | 10 ++++ yarn-project/prover-node/src/factory.ts | 2 +- .../src/client/sequencer-client.ts | 13 +++-- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index fafa3cfb610d..a94853bb8ba5 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -1,5 +1,6 @@ import { times } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; +import { ManualDateProvider } from '@aztec/foundation/timer'; import { jest } from '@jest/globals'; import { type Hex, encodeFunctionData } from 'viem'; @@ -27,16 +28,18 @@ function expectedFundingData(addresses: EthAddress[], fundingAmount: bigint): He describe('PublisherManager', () => { let mockPublishers: (TestL1TxUtils & L1TxUtils)[]; let publisherManager: PublisherManager; + let dateProvider: ManualDateProvider; beforeEach(() => { jest.clearAllMocks(); + dateProvider = new ManualDateProvider(); }); describe('constructor', () => { it('should initialize with publishers', () => { mockPublishers = createMockPublishers(3); - expect(() => new PublisherManager(mockPublishers, {})).not.toThrow(); + expect(() => new PublisherManager(mockPublishers, {}, dateProvider)).not.toThrow(); }); }); @@ -45,7 +48,7 @@ describe('PublisherManager', () => { beforeEach(() => { addresses = Array.from({ length: 3 }, () => EthAddress.random()); mockPublishers = createMockPublishers(3, addresses); - publisherManager = new PublisherManager(mockPublishers, {}); + publisherManager = new PublisherManager(mockPublishers, {}, dateProvider); }); it('should throw error when no valid publishers found', async () => { @@ -70,7 +73,7 @@ describe('PublisherManager', () => { mockPublishers[1].state = TxUtilsState.CANCELLED; mockPublishers[2].state = TxUtilsState.NOT_MINED; - publisherManager = new PublisherManager(mockPublishers, { publisherAllowInvalidStates: true }); + publisherManager = new PublisherManager(mockPublishers, { publisherAllowInvalidStates: true }, dateProvider); await expect(publisherManager.getAvailablePublisher(p => p.state === TxUtilsState.CANCELLED)).resolves.toBe( mockPublishers[1], ); @@ -147,7 +150,7 @@ describe('PublisherManager', () => { it('should prioritise same state publishers based on balance and then least recently used', async () => { const ethAddresses = Array.from({ length: 5 }, () => EthAddress.random()); mockPublishers = createMockPublishers(5, ethAddresses); - publisherManager = new PublisherManager(mockPublishers, {}); + publisherManager = new PublisherManager(mockPublishers, {}, dateProvider); const filter = (utils: L1TxUtils) => utils.getSenderAddress() !== mockPublishers[2].getSenderAddress(); // Filter out publisher in index 2 @@ -203,6 +206,7 @@ describe('PublisherManager', () => { return new PublisherManager( publishers, { publisherFundingThreshold: threshold, publisherFundingAmount: fundingAmount, ...config }, + dateProvider, { funder: funderInstance }, ); }; @@ -322,10 +326,14 @@ describe('PublisherManager', () => { it('no funding triggered when no funder configured', async () => { mockPublishers = createMockPublishers(1); mockPublishers[0].balance = 50n; - publisherManager = new PublisherManager(mockPublishers, { - publisherFundingThreshold: threshold, - publisherFundingAmount: fundingAmount, - }); + publisherManager = new PublisherManager( + mockPublishers, + { + publisherFundingThreshold: threshold, + publisherFundingAmount: fundingAmount, + }, + dateProvider, + ); await publisherManager.getAvailablePublisher(); await waitForFunding(); @@ -397,6 +405,29 @@ describe('PublisherManager', () => { expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); + it('skips funding within cooldown and resumes after cooldown expires', async () => { + mockPublishers = createMockPublishers(1); + mockPublishers[0].balance = 50n; + publisherManager = createFundedManager(mockPublishers, funder); + + // First call triggers funding + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + + // Second call within cooldown — funding should be skipped + dateProvider.advanceTime(60); // 1 minute, less than 2 min cooldown + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); + + // Third call after cooldown expires — funding should trigger again + dateProvider.advanceTime(2 * 60); // another 2 minutes + await publisherManager.getAvailablePublisher(); + await waitForFunding(); + expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); + }); + it('funds publishers in busy states', async () => { mockPublishers = createMockPublishers(2); mockPublishers[0].balance = 50n; diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index 2e0a1a3129ab..f4c6e2dc0061 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -1,5 +1,6 @@ import { pick } from '@aztec/foundation/collection'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; +import { DateProvider } from '@aztec/foundation/timer'; import { Multicall3 } from './contracts/multicall.js'; import { L1TxUtils, TxUtilsState } from './l1_tx_utils/index.js'; @@ -38,12 +39,15 @@ type PublisherManagerConfig = { export class PublisherManager { private log: Logger; private config: PublisherManagerConfig; + private static readonly FUNDING_CHECK_INTERVAL_MS = 2 * 60 * 1000; private isFunding = false; + private lastFundingCheckAt = 0; private funder?: UtilsType; constructor( private publishers: UtilsType[], config: PublisherManagerConfig, + private dateProvider: DateProvider, opts?: { bindings?: LoggerBindings; funder?: UtilsType }, ) { this.funder = opts?.funder; @@ -139,6 +143,12 @@ export class PublisherManager { if (!funder || config.publisherFundingThreshold === undefined || config.publisherFundingAmount === undefined) { return; } + const now = this.dateProvider.now(); + if (now - this.lastFundingCheckAt < PublisherManager.FUNDING_CHECK_INTERVAL_MS) { + this.log.trace(`Skipping funding check`, { msSinceLastCheck: now - this.lastFundingCheckAt }); + return; + } + this.lastFundingCheckAt = now; if (this.isFunding) { return; } diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index 6f2ceda0926d..afbb111bc2ab 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -136,7 +136,7 @@ export async function createProverNode( deps.publisherFactory ?? new ProverPublisherFactory(config, { rollupContract, - publisherManager: new PublisherManager(l1TxUtils, getPublisherConfigFromProverConfig(config), { + publisherManager: new PublisherManager(l1TxUtils, getPublisherConfigFromProverConfig(config), dateProvider, { bindings: log.getBindings(), funder: funderL1TxUtils, }), diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index b867973303e4..43de60b2514a 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -93,10 +93,15 @@ export class SequencerClient { publicClient, l1TxUtils.map(x => x.getSenderAddress()), ); - const publisherManager = new PublisherManager(l1TxUtils, getPublisherConfigFromSequencerConfig(config), { - bindings: log.getBindings(), - funder: deps.funderL1TxUtils, - }); + const publisherManager = new PublisherManager( + l1TxUtils, + getPublisherConfigFromSequencerConfig(config), + deps.dateProvider, + { + bindings: log.getBindings(), + funder: deps.funderL1TxUtils, + }, + ); const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); const [l1GenesisTime, slotDuration, rollupVersion, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), From fe065a5c1c8f3a4547116a7eec9ed8f2548f17f8 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Fri, 20 Mar 2026 11:34:52 -0300 Subject: [PATCH 14/15] fix suggestions --- .../ethereum/src/publisher_manager.test.ts | 124 +++++------------- .../ethereum/src/publisher_manager.ts | 105 +++++++-------- yarn-project/prover-node/src/factory.ts | 2 +- .../src/prover-publisher-factory.ts | 6 +- .../src/client/sequencer-client.ts | 17 +-- .../publisher/sequencer-publisher-factory.ts | 6 +- .../src/sequencer/sequencer.ts | 2 +- 7 files changed, 102 insertions(+), 160 deletions(-) diff --git a/yarn-project/ethereum/src/publisher_manager.test.ts b/yarn-project/ethereum/src/publisher_manager.test.ts index a94853bb8ba5..834f517dfe45 100644 --- a/yarn-project/ethereum/src/publisher_manager.test.ts +++ b/yarn-project/ethereum/src/publisher_manager.test.ts @@ -1,6 +1,5 @@ import { times } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; -import { ManualDateProvider } from '@aztec/foundation/timer'; import { jest } from '@jest/globals'; import { type Hex, encodeFunctionData } from 'viem'; @@ -28,18 +27,20 @@ function expectedFundingData(addresses: EthAddress[], fundingAmount: bigint): He describe('PublisherManager', () => { let mockPublishers: (TestL1TxUtils & L1TxUtils)[]; let publisherManager: PublisherManager; - let dateProvider: ManualDateProvider; beforeEach(() => { jest.clearAllMocks(); - dateProvider = new ManualDateProvider(); + }); + + afterEach(async () => { + await publisherManager?.stop(); }); describe('constructor', () => { it('should initialize with publishers', () => { mockPublishers = createMockPublishers(3); - expect(() => new PublisherManager(mockPublishers, {}, dateProvider)).not.toThrow(); + expect(() => new PublisherManager(mockPublishers, {})).not.toThrow(); }); }); @@ -48,7 +49,7 @@ describe('PublisherManager', () => { beforeEach(() => { addresses = Array.from({ length: 3 }, () => EthAddress.random()); mockPublishers = createMockPublishers(3, addresses); - publisherManager = new PublisherManager(mockPublishers, {}, dateProvider); + publisherManager = new PublisherManager(mockPublishers, {}); }); it('should throw error when no valid publishers found', async () => { @@ -73,7 +74,7 @@ describe('PublisherManager', () => { mockPublishers[1].state = TxUtilsState.CANCELLED; mockPublishers[2].state = TxUtilsState.NOT_MINED; - publisherManager = new PublisherManager(mockPublishers, { publisherAllowInvalidStates: true }, dateProvider); + publisherManager = new PublisherManager(mockPublishers, { publisherAllowInvalidStates: true }); await expect(publisherManager.getAvailablePublisher(p => p.state === TxUtilsState.CANCELLED)).resolves.toBe( mockPublishers[1], ); @@ -150,7 +151,7 @@ describe('PublisherManager', () => { it('should prioritise same state publishers based on balance and then least recently used', async () => { const ethAddresses = Array.from({ length: 5 }, () => EthAddress.random()); mockPublishers = createMockPublishers(5, ethAddresses); - publisherManager = new PublisherManager(mockPublishers, {}, dateProvider); + publisherManager = new PublisherManager(mockPublishers, {}); const filter = (utils: L1TxUtils) => utils.getSenderAddress() !== mockPublishers[2].getSenderAddress(); // Filter out publisher in index 2 @@ -206,13 +207,17 @@ describe('PublisherManager', () => { return new PublisherManager( publishers, { publisherFundingThreshold: threshold, publisherFundingAmount: fundingAmount, ...config }, - dateProvider, { funder: funderInstance }, ); }; - /** Wait for the background funding promise to settle. */ - const waitForFunding = () => new Promise(resolve => setTimeout(resolve, 10)); + /** Start the manager and trigger one funding cycle via the RunningPromise. */ + const triggerFunding = async (manager: PublisherManager) => { + await manager.start(); + // RunningPromise calls the fn immediately on start, so we just need to wait for it to settle + await new Promise(resolve => setTimeout(resolve, 10)); + await manager.stop(); + }; beforeEach(() => { funder = new TestL1TxUtils(EthAddress.random()) as TestL1TxUtils & L1TxUtils; @@ -224,8 +229,7 @@ describe('PublisherManager', () => { mockPublishers[0].balance = 50n; // below threshold publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ @@ -240,8 +244,7 @@ describe('PublisherManager', () => { mockPublishers[0].balance = 200n; // above threshold publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); @@ -253,8 +256,7 @@ describe('PublisherManager', () => { mockPublishers[2].balance = 30n; // below publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); // Single multicall for both underfunded publishers expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); @@ -273,8 +275,7 @@ describe('PublisherManager', () => { mockPublishers[0].balance = 50n; publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ to: MULTI_CALL_3_ADDRESS, @@ -291,52 +292,21 @@ describe('PublisherManager', () => { funder.sendAndMonitorTransaction.mockRejectedValueOnce(new Error('tx failed')); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); - - // Single multicall attempted and failed — error caught by triggerFundingIfNeeded - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); - }); - - it('concurrent guard prevents overlapping funding runs', async () => { - mockPublishers = createMockPublishers(1); - mockPublishers[0].balance = 50n; - publisherManager = createFundedManager(mockPublishers, funder); - - // Block the first funding call on a deferred promise we control - let resolveFunding!: () => void; - const fundingBlocked = new Promise(r => (resolveFunding = r)); - funder.sendAndMonitorTransaction.mockImplementation(async () => { - await fundingBlocked; - return { receipt: { transactionHash: '0x1', status: 'success' }, state: {} }; - }); - - // First call triggers funding (sets isFunding = true) - await publisherManager.getAvailablePublisher(); - // Second call while funding is still in progress — should skip - await publisherManager.getAvailablePublisher(); - - // Release the blocked funding and let it complete - resolveFunding(); - await waitForFunding(); + await triggerFunding(publisherManager); + // Single multicall attempted and failed — error caught by RunningPromise expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); }); it('no funding triggered when no funder configured', async () => { mockPublishers = createMockPublishers(1); mockPublishers[0].balance = 50n; - publisherManager = new PublisherManager( - mockPublishers, - { - publisherFundingThreshold: threshold, - publisherFundingAmount: fundingAmount, - }, - dateProvider, - ); + publisherManager = new PublisherManager(mockPublishers, { + publisherFundingThreshold: threshold, + publisherFundingAmount: fundingAmount, + }); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); @@ -349,8 +319,7 @@ describe('PublisherManager', () => { publisherFundingAmount: undefined, }); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); @@ -361,8 +330,7 @@ describe('PublisherManager', () => { funder.balance = 30n; // less than fundingAmount (50n) publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); @@ -375,8 +343,7 @@ describe('PublisherManager', () => { funder.balance = 2n * fundingAmount; // enough for 2, not 3 publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); expect(funder.sendAndMonitorTransaction).toHaveBeenCalledWith({ @@ -398,36 +365,12 @@ describe('PublisherManager', () => { funder.balance = 5000n; publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); // Funding is fully disabled because funder overlaps with a publisher expect(funder.sendAndMonitorTransaction).not.toHaveBeenCalled(); }); - it('skips funding within cooldown and resumes after cooldown expires', async () => { - mockPublishers = createMockPublishers(1); - mockPublishers[0].balance = 50n; - publisherManager = createFundedManager(mockPublishers, funder); - - // First call triggers funding - await publisherManager.getAvailablePublisher(); - await waitForFunding(); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); - - // Second call within cooldown — funding should be skipped - dateProvider.advanceTime(60); // 1 minute, less than 2 min cooldown - await publisherManager.getAvailablePublisher(); - await waitForFunding(); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); - - // Third call after cooldown expires — funding should trigger again - dateProvider.advanceTime(2 * 60); // another 2 minutes - await publisherManager.getAvailablePublisher(); - await waitForFunding(); - expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(2); - }); - it('funds publishers in busy states', async () => { mockPublishers = createMockPublishers(2); mockPublishers[0].balance = 50n; @@ -436,8 +379,7 @@ describe('PublisherManager', () => { mockPublishers[1].state = TxUtilsState.SENT; // busy publisherManager = createFundedManager(mockPublishers, funder); - await publisherManager.getAvailablePublisher(); - await waitForFunding(); + await triggerFunding(publisherManager); // Single multicall funds both, even the busy one expect(funder.sendAndMonitorTransaction).toHaveBeenCalledTimes(1); @@ -479,4 +421,8 @@ class TestL1TxUtils { public getSenderAddress() { return this.senderAddress; } + + public async loadStateAndResumeMonitoring() {} + + public interrupt() {} } diff --git a/yarn-project/ethereum/src/publisher_manager.ts b/yarn-project/ethereum/src/publisher_manager.ts index f4c6e2dc0061..11149b7b0465 100644 --- a/yarn-project/ethereum/src/publisher_manager.ts +++ b/yarn-project/ethereum/src/publisher_manager.ts @@ -1,6 +1,6 @@ import { pick } from '@aztec/foundation/collection'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; -import { DateProvider } from '@aztec/foundation/timer'; +import { RunningPromise } from '@aztec/foundation/running-promise'; import { Multicall3 } from './contracts/multicall.js'; import { L1TxUtils, TxUtilsState } from './l1_tx_utils/index.js'; @@ -40,14 +40,12 @@ export class PublisherManager { private log: Logger; private config: PublisherManagerConfig; private static readonly FUNDING_CHECK_INTERVAL_MS = 2 * 60 * 1000; - private isFunding = false; - private lastFundingCheckAt = 0; private funder?: UtilsType; + private fundingPromise?: RunningPromise; constructor( private publishers: UtilsType[], config: PublisherManagerConfig, - private dateProvider: DateProvider, opts?: { bindings?: LoggerBindings; funder?: UtilsType }, ) { this.funder = opts?.funder; @@ -71,9 +69,32 @@ export class PublisherManager { } } - /** Loads the state of all publishers and resumes monitoring any pending txs */ - public async loadState(): Promise { - await Promise.all(this.publishers.map(pub => pub.loadStateAndResumeMonitoring())); + /** Loads the state of all publishers and the funder, and starts periodic funding checks. */ + public async start(): Promise { + await Promise.all([ + ...this.publishers.map(pub => pub.loadStateAndResumeMonitoring()), + this.funder?.loadStateAndResumeMonitoring(), + ]); + + if ( + this.funder && + this.config.publisherFundingThreshold !== undefined && + this.config.publisherFundingAmount !== undefined + ) { + this.fundingPromise = new RunningPromise( + () => this.triggerFundingIfNeeded(), + this.log, + PublisherManager.FUNDING_CHECK_INTERVAL_MS, + ); + this.fundingPromise.start(); + } + } + + /** Stops the funding loop and interrupts all publishers. */ + public async stop(): Promise { + await this.fundingPromise?.stop(); + this.publishers.forEach(pub => pub.interrupt()); + this.funder?.interrupt(); } // Finds and prioritises available publishers based on @@ -128,64 +149,44 @@ export class PublisherManager { return lastUsedComparison; }); - void this.triggerFundingIfNeeded().catch(err => this.log.error('Error in funding check', { err })); - return sortedPublishers[0].publisher; } - public interrupt() { - this.publishers.forEach(pub => pub.interrupt()); - } - - /** Check all publisher balances and fund those below threshold (background, non-blocking). */ + /** Check all publisher balances and fund those below threshold. */ private async triggerFundingIfNeeded(): Promise { const { funder, config } = this; if (!funder || config.publisherFundingThreshold === undefined || config.publisherFundingAmount === undefined) { return; } - const now = this.dateProvider.now(); - if (now - this.lastFundingCheckAt < PublisherManager.FUNDING_CHECK_INTERVAL_MS) { - this.log.trace(`Skipping funding check`, { msSinceLastCheck: now - this.lastFundingCheckAt }); - return; - } - this.lastFundingCheckAt = now; - if (this.isFunding) { + + const allBalances = await Promise.all( + this.publishers.map(async pub => ({ balance: await pub.getSenderBalance(), publisher: pub })), + ); + const lowBalance = allBalances.filter(p => p.balance < config.publisherFundingThreshold!); + if (lowBalance.length === 0) { return; } - this.isFunding = true; - try { - const allBalances = await Promise.all( - this.publishers.map(async pub => ({ balance: await pub.getSenderBalance(), publisher: pub })), - ); - const lowBalance = allBalances.filter(p => p.balance < config.publisherFundingThreshold!); - if (lowBalance.length === 0) { - return; - } - - const fundingAmount = config.publisherFundingAmount!; - const funderBalance = await funder.getSenderBalance(); - - if (funderBalance < 10n * fundingAmount) { - this.log.warn(`Funding account balance is low`, { funderBalance, threshold: 10n * fundingAmount }); - } - const affordableCount = Number(funderBalance / fundingAmount); - if (affordableCount === 0) { - this.log.error(`Funding account balance too low to fund any publisher`, { funderBalance, fundingAmount }); - return; - } - if (affordableCount < lowBalance.length) { - this.log.warn(`Funder can only afford ${affordableCount}/${lowBalance.length} publishers`, { - funderBalance, - fundingAmount, - }); - } + const fundingAmount = config.publisherFundingAmount!; + const funderBalance = await funder.getSenderBalance(); - const toFund = lowBalance.slice(0, affordableCount).map(p => p.publisher); - await this.fundPublishers(toFund); - } finally { - this.isFunding = false; + if (funderBalance < 10n * fundingAmount) { + this.log.warn(`Funding account balance is low`, { funderBalance, threshold: 10n * fundingAmount }); + } + const affordableCount = Number(funderBalance / fundingAmount); + if (affordableCount === 0) { + this.log.error(`Funding account balance too low to fund any publisher`, { funderBalance, fundingAmount }); + return; + } + if (affordableCount < lowBalance.length) { + this.log.warn(`Funder can only afford ${affordableCount}/${lowBalance.length} publishers`, { + funderBalance, + fundingAmount, + }); } + + const toFund = lowBalance.slice(0, affordableCount).map(p => p.publisher); + await this.fundPublishers(toFund); } /** Fund publishers via a single Multicall3 aggregate3Value transaction. */ diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index afbb111bc2ab..6f2ceda0926d 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -136,7 +136,7 @@ export async function createProverNode( deps.publisherFactory ?? new ProverPublisherFactory(config, { rollupContract, - publisherManager: new PublisherManager(l1TxUtils, getPublisherConfigFromProverConfig(config), dateProvider, { + publisherManager: new PublisherManager(l1TxUtils, getPublisherConfigFromProverConfig(config), { bindings: log.getBindings(), funder: funderL1TxUtils, }), diff --git a/yarn-project/prover-node/src/prover-publisher-factory.ts b/yarn-project/prover-node/src/prover-publisher-factory.ts index 8e1b88d1560e..054d742cf02a 100644 --- a/yarn-project/prover-node/src/prover-publisher-factory.ts +++ b/yarn-project/prover-node/src/prover-publisher-factory.ts @@ -19,11 +19,11 @@ export class ProverPublisherFactory { ) {} public async start() { - await this.deps.publisherManager.loadState(); + await this.deps.publisherManager.start(); } - public stop() { - this.deps.publisherManager.interrupt(); + public async stop() { + await this.deps.publisherManager.stop(); } /** diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index 43de60b2514a..077ca90eb54b 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -93,15 +93,10 @@ export class SequencerClient { publicClient, l1TxUtils.map(x => x.getSenderAddress()), ); - const publisherManager = new PublisherManager( - l1TxUtils, - getPublisherConfigFromSequencerConfig(config), - deps.dateProvider, - { - bindings: log.getBindings(), - funder: deps.funderL1TxUtils, - }, - ); + const publisherManager = new PublisherManager(l1TxUtils, getPublisherConfigFromSequencerConfig(config), { + bindings: log.getBindings(), + funder: deps.funderL1TxUtils, + }); const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); const [l1GenesisTime, slotDuration, rollupVersion, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), @@ -216,7 +211,7 @@ export class SequencerClient { await this.validatorClient?.start(); this.sequencer.start(); this.l1Metrics?.start(); - await this.publisherManager.loadState(); + await this.publisherManager.start(); } /** @@ -225,7 +220,7 @@ export class SequencerClient { public async stop() { await this.sequencer.stop(); await this.validatorClient?.stop(); - this.publisherManager.interrupt(); + await this.publisherManager.stop(); this.l1Metrics?.stop(); } diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts index 0583f969d49e..d9ed0a7ccc30 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts @@ -117,8 +117,8 @@ export class SequencerPublisherFactory { }; } - /** Interrupts all publishers managed by this factory. Used during sequencer shutdown. */ - public interruptAll(): void { - this.deps.publisherManager.interrupt(); + /** Stops all publishers managed by this factory. Used during sequencer shutdown. */ + public async stopAll(): Promise { + await this.deps.publisherManager.stop(); } } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index d75788ea3cf4..79cc973c9ab4 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -147,7 +147,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { this.log.info(`Stopping sequencer`); this.setState(SequencerState.STOPPING, undefined, { force: true }); - this.publisherFactory.interruptAll(); + await this.publisherFactory.stopAll(); await this.runningPromise?.stop(); this.setState(SequencerState.STOPPED, undefined, { force: true }); this.log.info('Stopped sequencer'); From d78f6c9ec1754096df09729ff61991d3749767d5 Mon Sep 17 00:00:00 2001 From: Nikita Meshcheriakov Date: Fri, 20 Mar 2026 12:06:16 -0300 Subject: [PATCH 15/15] increase timeouts --- .../end-to-end/src/e2e_publisher_funding_multi.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts b/yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts index f378036041a9..e48477c8fefa 100644 --- a/yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts +++ b/yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts @@ -126,8 +126,8 @@ describe('e2e_publisher_funding_multi', () => { const funderBalanceBefore = await ethCheatCodes.getBalance(funderAddress); - // The sequencer periodically calls getAvailablePublisher(), which triggers funding - // when it sees publisher balances are below threshold. Both should be funded in a single multicall. + // The RunningPromise checks funding every 2 minutes, so we need to wait long enough + // for the next cycle to detect the low balances and fund both publishers. await retryUntil( async () => { const balance1 = await ethCheatCodes.getBalance(publisher1Address); @@ -135,7 +135,7 @@ describe('e2e_publisher_funding_multi', () => { return balance1 > LOW_BALANCE && balance2 > LOW_BALANCE ? true : undefined; }, 'waiting for both publishers to be funded', - 60, + 180, 1, ); @@ -168,7 +168,7 @@ describe('e2e_publisher_funding_multi', () => { return spent >= FUNDING_AMOUNT ? true : undefined; }, 'waiting for second funding round', - 120, + 180, 1, );