Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/docs-operate/operators/reference/changelog/v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ The `getL2Tips()` RPC endpoint now returns a restructured response with addition
- Replace `tips.latest` with `tips.proposed`
- For `checkpointed`, `proven`, and `finalized` tips, access block info via `.block` (e.g., `tips.proven.block.number`)

### Setup phase allow list requires function selectors

The transaction setup phase allow list now enforces function selectors, restricting which specific functions can run during setup on whitelisted contracts. Previously, any public function on a whitelisted contract or class was permitted.

The semantics of the environment variable `TX_PUBLIC_SETUP_ALLOWLIST` have changed:

**v3.x:**

```bash
--txPublicSetupAllowList <value> ($TX_PUBLIC_SETUP_ALLOWLIST)
```

The variable fully **replaced** the hardcoded defaults. Format allowed entries without selectors: `I:address`, `C:classId`.

**v4.0.0:**

```bash
--txPublicSetupAllowListExtend <value> ($TX_PUBLIC_SETUP_ALLOWLIST)
```

The variable now **extends** the hardcoded defaults (which are always present). Selectors are now mandatory. Format: `I:address:selector,C:classId:selector`.

**Migration**: If you were using `TX_PUBLIC_SETUP_ALLOWLIST`, ensure all entries include function selectors. Note the variable now adds to defaults rather than replacing them. If you were not setting this variable, no action is needed — the hardcoded defaults now include the correct selectors automatically.

## Removed features

## New features
Expand Down Expand Up @@ -137,6 +161,10 @@ Transaction submission via RPC now returns structured rejection codes when a tra

**Impact**: Improved developer experience — callers can now programmatically handle specific rejection reasons.

### Setup allow list extendable via network config

The setup phase allow list can now be extended via the network configuration JSON (`txPublicSetupAllowListExtend` field). This allows network operators to distribute additional allowed setup functions to all nodes without requiring code changes. The local environment variable takes precedence over the network-json value.

## Changed defaults

## Troubleshooting
Expand Down
10 changes: 5 additions & 5 deletions yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
deps.p2pClientDeps,
);

// We should really not be modifying the config object
config.txPublicSetupAllowList = config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions());

// We'll accumulate sentinel watchers here
const watchers: Watcher[] = [];

Expand Down Expand Up @@ -618,7 +615,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
}

public async getAllowedPublicSetup(): Promise<AllowedElement[]> {
return this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions());
return [...(await getDefaultAllowedSetupFunctions()), ...(this.config.txPublicSetupAllowListExtend ?? [])];
}

/**
Expand Down Expand Up @@ -1318,7 +1315,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable {
blockNumber,
l1ChainId: this.l1ChainId,
rollupVersion: this.version,
setupAllowList: this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()),
setupAllowList: [
...(await getDefaultAllowedSetupFunctions()),
...(this.config.txPublicSetupAllowListExtend ?? []),
],
gasFees: await this.getCurrentMinFees(),
skipFeeEnforcement,
txsPermitted: !this.config.disableTransactions,
Expand Down
3 changes: 3 additions & 0 deletions yarn-project/cli/src/config/network_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,7 @@ export async function enrichEnvironmentWithNetworkConfig(networkName: NetworkNam
if (networkConfig.blockDurationMs !== undefined) {
enrichVar('SEQ_BLOCK_DURATION_MS', String(networkConfig.blockDurationMs));
}
if (networkConfig.txPublicSetupAllowListExtend) {
enrichVar('TX_PUBLIC_SETUP_ALLOWLIST', networkConfig.txPublicSetupAllowListExtend);
}
}
1 change: 1 addition & 0 deletions yarn-project/foundation/src/config/network_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const NetworkConfigSchema = z
feeAssetHandlerAddress: z.string().optional(),
l1ChainId: z.number(),
blockDurationMs: z.number().positive().optional(),
txPublicSetupAllowListExtend: z.string().optional(),
})
.passthrough(); // Allow additional unknown fields to pass through

Expand Down
32 changes: 26 additions & 6 deletions yarn-project/p2p/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,14 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address';
import { getP2PDefaultConfig, parseAllowList } from './config.js';

describe('config', () => {
it('parses allow list', async () => {
const instance = { address: await AztecAddress.random() };
it('parses allow list with required selectors', async () => {
const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() };
const classId = { classId: Fr.random() };
const classFunction = { classId: Fr.random(), selector: FunctionSelector.random() };

const config = [instance, instanceFunction, classId, classFunction];
const config = [instanceFunction, classFunction];

const configStrings = [
`I:${instance.address}`,
`I:${instanceFunction.address}:${instanceFunction.selector}`,
`C:${classId.classId}`,
`C:${classFunction.classId}:${classFunction.selector}`,
];
const stringifiedAllowList = configStrings.join(',');
Expand All @@ -25,6 +21,30 @@ describe('config', () => {
expect(allowList).toEqual(config);
});

it('rejects instance entry without selector', async () => {
const address = await AztecAddress.random();
expect(() => parseAllowList(`I:${address}`)).toThrow('selector is required');
});

it('rejects class entry without selector', () => {
const classId = Fr.random();
expect(() => parseAllowList(`C:${classId}`)).toThrow('selector is required');
});

it('rejects entry with unknown type', () => {
expect(() => parseAllowList(`X:0x1234:0x12345678`)).toThrow('unknown type');
});

it('parses empty string', () => {
expect(parseAllowList('')).toEqual([]);
});

it('handles whitespace in entries', async () => {
const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() };
const allowList = parseAllowList(` I:${instanceFunction.address}:${instanceFunction.selector} `);
expect(allowList).toEqual([instanceFunction]);
});

it('defaults missing txs collector type to new', () => {
const config = getP2PDefaultConfig();
expect(config.txCollectionMissingTxsCollectorType).toBe('new');
Expand Down
66 changes: 34 additions & 32 deletions yarn-project/p2p/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ export interface P2PConfig
/** The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb. */
p2pStoreMapSizeKb?: number;

/** Which calls are allowed in the public setup phase of a tx. */
txPublicSetupAllowList: AllowedElement[];
/** Additional entries to extend the default setup allow list. */
txPublicSetupAllowListExtend: AllowedElement[];

/** The maximum number of pending txs before evicting lower priority txs. */
maxPendingTxCount: number;
Expand Down Expand Up @@ -393,12 +393,13 @@ export const p2pConfigMappings: ConfigMappingsType<P2PConfig> = {
parseEnv: (val: string | undefined) => (val ? +val : undefined),
description: 'The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb.',
},
txPublicSetupAllowList: {
txPublicSetupAllowListExtend: {
env: 'TX_PUBLIC_SETUP_ALLOWLIST',
parseEnv: (val: string) => parseAllowList(val),
description: 'The list of functions calls allowed to run in setup',
description:
'Additional entries to extend the default setup allow list. Format: I:address:selector,C:classId:selector',
printDefault: () =>
'AuthRegistry, FeeJuice.increase_public_balance, Token.increase_public_balance, FPC.prepare_fee',
'Default: AuthRegistry._set_authorized, FeeJuice._increase_public_balance, Token._increase_public_balance, Token.transfer_in_public',
},
maxPendingTxCount: {
env: 'P2P_MAX_PENDING_TX_COUNT',
Expand Down Expand Up @@ -523,11 +524,9 @@ export const bootnodeConfigMappings = pickConfigMappings(

/**
* Parses a string to a list of allowed elements.
* Each encoded is expected to be of one of the following formats
* `I:${address}`
* `I:${address}:${selector}`
* `C:${classId}`
* `C:${classId}:${selector}`
* Each entry is expected to be of one of the following formats:
* `I:${address}:${selector}` — instance (contract address) with function selector
* `C:${classId}:${selector}` — class with function selector
*
* @param value The string to parse
* @returns A list of allowed elements
Expand All @@ -540,31 +539,34 @@ export function parseAllowList(value: string): AllowedElement[] {
}

for (const val of value.split(',')) {
const [typeString, identifierString, selectorString] = val.split(':');
const selector = selectorString !== undefined ? FunctionSelector.fromString(selectorString) : undefined;
const trimmed = val.trim();
if (!trimmed) {
continue;
}
const [typeString, identifierString, selectorString] = trimmed.split(':');

if (!selectorString) {
throw new Error(
`Invalid allow list entry "${trimmed}": selector is required. Expected format: I:address:selector or C:classId:selector`,
);
}

const selector = FunctionSelector.fromString(selectorString);

if (typeString === 'I') {
if (selector) {
entries.push({
address: AztecAddress.fromString(identifierString),
selector,
});
} else {
entries.push({
address: AztecAddress.fromString(identifierString),
});
}
entries.push({
address: AztecAddress.fromString(identifierString),
selector,
});
} else if (typeString === 'C') {
if (selector) {
entries.push({
classId: Fr.fromHexString(identifierString),
selector,
});
} else {
entries.push({
classId: Fr.fromHexString(identifierString),
});
}
entries.push({
classId: Fr.fromHexString(identifierString),
selector,
});
} else {
throw new Error(
`Invalid allow list entry "${trimmed}": unknown type "${typeString}". Expected "I" (instance) or "C" (class).`,
);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
import { FPCContract } from '@aztec/noir-contracts.js/FPC';
import { TokenContractArtifact } from '@aztec/noir-contracts.js/Token';
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
import { FunctionSelector } from '@aztec/stdlib/abi';
import { getContractClassFromArtifact } from '@aztec/stdlib/contract';
import type { AllowedElement } from '@aztec/stdlib/interfaces/server';

let defaultAllowedSetupFunctions: AllowedElement[] | undefined = undefined;
let defaultAllowedSetupFunctions: AllowedElement[] | undefined;

/** Returns the default list of functions allowed to run in the setup phase of a transaction. */
export async function getDefaultAllowedSetupFunctions(): Promise<AllowedElement[]> {
if (defaultAllowedSetupFunctions === undefined) {
const tokenClassId = (await getContractClassFromArtifact(TokenContractArtifact)).id;
const setAuthorizedInternalSelector = await FunctionSelector.fromSignature('_set_authorized((Field),Field,bool)');
const setAuthorizedSelector = await FunctionSelector.fromSignature('set_authorized(Field,bool)');
const increaseBalanceSelector = await FunctionSelector.fromSignature('_increase_public_balance((Field),u128)');
const transferInPublicSelector = await FunctionSelector.fromSignature(
'transfer_in_public((Field),(Field),u128,Field)',
);

defaultAllowedSetupFunctions = [
// needed for authwit support
// AuthRegistry: needed for authwit support via private path (set_authorized_private enqueues _set_authorized)
{
address: ProtocolContractAddress.AuthRegistry,
selector: setAuthorizedInternalSelector,
},
// AuthRegistry: needed for authwit support via public path (PublicFeePaymentMethod calls set_authorized directly)
{
address: ProtocolContractAddress.AuthRegistry,
selector: setAuthorizedSelector,
},
// needed for claiming on the same tx as a spend
// FeeJuice: needed for claiming on the same tx as a spend (claim_and_end_setup enqueues this)
{
address: ProtocolContractAddress.FeeJuice,
// We can't restrict the selector because public functions get routed via dispatch.
// selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'),
selector: increaseBalanceSelector,
},
// needed for private transfers via FPC
// Token: needed for private transfers via FPC (transfer_to_public enqueues this)
{
classId: (await getContractClassFromArtifact(TokenContractArtifact)).id,
// We can't restrict the selector because public functions get routed via dispatch.
// selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'),
classId: tokenClassId,
selector: increaseBalanceSelector,
},
// Token: needed for public transfers via FPC (fee_entrypoint_public enqueues this)
{
classId: (await getContractClassFromArtifact(FPCContract.artifact)).id,
// We can't restrict the selector because public functions get routed via dispatch.
// selector: FunctionSelector.fromSignature('prepare_fee((Field),Field,(Field),Field)'),
classId: tokenClassId,
selector: transferInPublicSelector,
},
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { FunctionSelector } from '@aztec/stdlib/abi';
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
import type { ContractDataSource } from '@aztec/stdlib/contract';
import { makeAztecAddress, makeSelector, mockTx } from '@aztec/stdlib/testing';
import { TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, type Tx } from '@aztec/stdlib/tx';
import {
TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED,
TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT,
type Tx,
} from '@aztec/stdlib/tx';

import { type MockProxy, mock, mockFn } from 'jest-mock-extended';

Expand Down Expand Up @@ -138,4 +142,60 @@ describe('PhasesTxValidator', () => {

await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED);
});

it('rejects address match with wrong selector', async () => {
const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 });
const wrongSelector = makeSelector(99);
await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: wrongSelector });

await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED);
});

it('rejects class match with wrong selector', async () => {
const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 });
const wrongSelector = makeSelector(99);
const address = await patchNonRevertibleFn(tx, 0, { selector: wrongSelector });

contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => {
if (timestamp !== atTimestamp) {
throw new Error('Unexpected timestamp');
}
if (address.equals(contractAddress)) {
return Promise.resolve({
currentContractClassId: allowedContractClass,
originalContractClassId: Fr.random(),
} as any);
} else {
return Promise.resolve(undefined);
}
});

await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED);
});

it('rejects with unknown contract error when contract is not found', async () => {
const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 });
const address = await patchNonRevertibleFn(tx, 0, { selector: allowedSetupSelector1 });

contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => {
if (timestamp !== atTimestamp) {
throw new Error('Unexpected timestamp');
}
if (address.equals(contractAddress)) {
return Promise.resolve(undefined);
}
return Promise.resolve(undefined);
});

await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT);
});

it('does not fetch contract instance when matching by address', async () => {
const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 });
await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: allowedSetupSelector1 });

await expectValid(tx);

expect(contractDataSource.getContract).not.toHaveBeenCalled();
});
});
Loading
Loading