Skip to content
15 changes: 15 additions & 0 deletions yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 },
Expand All @@ -506,6 +520,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
...deps,
epochCache,
l1TxUtils,
funderL1TxUtils,
validatorClient,
p2pClient,
worldStateSynchronizer,
Expand Down
179 changes: 179 additions & 0 deletions yarn-project/end-to-end/src/e2e_publisher_funding_multi.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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);
});
});
66 changes: 65 additions & 1 deletion yarn-project/ethereum/src/contracts/multicall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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[],
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading