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
12 changes: 12 additions & 0 deletions docs/docs-operate/operators/reference/changelog/v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ Transaction submission via RPC now returns structured rejection codes when a tra

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

### RPC transaction replacement price bump

Transactions submitted via RPC that clash on nullifiers with existing pool transactions must now pay at least X% more in priority fee to replace them. The same bump applies when the pool is full and the incoming tx needs to evict the lowest-priority tx. P2P gossip behavior is unchanged.

**Configuration:**

```bash
P2P_RPC_PRICE_BUMP_PERCENTAGE=10 # default: 10 (percent)
```

Set to `0` to disable the percentage-based bump (still requires strictly higher fee).

### 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.
Expand Down
1 change: 1 addition & 0 deletions yarn-project/foundation/src/config/env_var.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export type EnvVar =
| 'P2P_DROP_TX_CHANCE'
| 'P2P_TX_POOL_DELETE_TXS_AFTER_REORG'
| 'P2P_MIN_TX_POOL_AGE_MS'
| 'P2P_RPC_PRICE_BUMP_PERCENTAGE'
| 'DEBUG_P2P_INSTRUMENT_MESSAGES'
| 'PEER_ID_PRIVATE_KEY'
| 'PEER_ID_PRIVATE_KEY_PATH'
Expand Down
1 change: 1 addition & 0 deletions yarn-project/p2p/src/client/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export async function createP2PClient(
archivedTxLimit: config.archivedTxLimit,
minTxPoolAgeMs: config.minTxPoolAgeMs,
dropTransactionsProbability: config.dropTransactionsProbability,
priceBumpPercentage: config.priceBumpPercentage,
},
dateProvider,
);
Expand Down
10 changes: 10 additions & 0 deletions yarn-project/p2p/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type ConfigMappingsType,
SecretValue,
bigintConfigHelper,
booleanConfigHelper,
getConfigFromMappings,
getDefaultConfig,
Expand Down Expand Up @@ -190,6 +191,9 @@ export interface P2PConfig

/** Minimum age (ms) a transaction must have been in the pool before it's eligible for block building. */
minTxPoolAgeMs: number;

/** Minimum percentage fee increase required to replace an existing tx via RPC (0 = no bump). */
priceBumpPercentage: bigint;
}

export const DEFAULT_P2P_PORT = 40400;
Expand Down Expand Up @@ -465,6 +469,12 @@ export const p2pConfigMappings: ConfigMappingsType<P2PConfig> = {
description: 'Minimum age (ms) a transaction must have been in the pool before it is eligible for block building.',
...numberConfigHelper(2_000),
},
priceBumpPercentage: {
env: 'P2P_RPC_PRICE_BUMP_PERCENTAGE',
description:
'Minimum percentage fee increase required to replace an existing tx via RPC. Even at 0%, replacement still requires paying at least 1 unit more.',
...bigintConfigHelper(10n),
},
...sharedSequencerConfigMappings,
...p2pReqRespConfigMappings,
...batchTxRequesterConfigMappings,
Expand Down
10 changes: 9 additions & 1 deletion yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Checked before adding a transaction to the pending pool:

| Rule | Purpose |
|------|---------|
| `NullifierConflictRule` | Handles transactions with conflicting nullifiers. Higher priority tx wins. |
| `NullifierConflictRule` | Handles transactions with conflicting nullifiers. Higher priority tx wins. For RPC submissions, a configurable price bump percentage is required. |
| `FeePayerBalancePreAddRule` | Ensures fee payer has sufficient balance for all their pending txs. |
| `LowPriorityPreAddRule` | Rejects txs when pool is full and new tx has lowest priority. |

Expand Down Expand Up @@ -233,6 +233,14 @@ await pool.updateConfig({
});
```

### Price Bump (RPC Transaction Replacement)

When a transaction is submitted via RPC and clashes on nullifiers with an existing pool transaction, the incoming tx must pay at least `priceBumpPercentage`% more in priority fee (i.e. `>= existingFee + existingFee * bump / 100`) to replace it. This prevents spam via small fee increments. The same bump applies when the pool is full and the incoming tx needs to evict the lowest-priority tx.

- **Env var**: `P2P_RPC_PRICE_BUMP_PERCENTAGE` (default: 10)
- **Scope**: RPC submissions only. P2P gossip uses `comparePriority` (fee + hash tiebreaker) with no bump.
- Even with a 0% bump, a replacement tx must pay at least 1 unit more than the existing fee.

## Return Values

### AddTxsResult
Expand Down
12 changes: 11 additions & 1 deletion yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,15 @@ export type TxPoolRejectionError =
availableBalance: bigint;
feeLimit: bigint;
}
| { code: typeof TxPoolRejectionCode.NULLIFIER_CONFLICT; message: string; conflictingTxHash: string }
| {
code: typeof TxPoolRejectionCode.NULLIFIER_CONFLICT;
message: string;
conflictingTxHash: string;
/** Minimum fee needed to replace the conflicting tx (only set when price bump applies). */
minimumPriceBumpFee?: bigint;
/** Incoming tx's priority fee. */
txPriorityFee?: bigint;
}
| { code: typeof TxPoolRejectionCode.INTERNAL_ERROR; message: string };

/**
Expand All @@ -121,6 +129,8 @@ export interface PreAddResult {
export interface PreAddContext {
/** If true, compare priority fee only (no tx hash tiebreaker). Used for RPC submissions. */
feeComparisonOnly?: boolean;
/** Percentage-based price bump required for tx replacement. Only set for RPC submissions. */
priceBumpPercentage?: bigint;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,5 +209,71 @@ describe('LowPriorityPreAddRule', () => {
expect(result2.shouldIgnore).toBe(true);
});
});

describe('with priceBumpPercentage', () => {
it('evicts when incoming fee exceeds the bump threshold', async () => {
const lowestPriorityMeta = createMeta('0x2222', 100n);
const poolAccess = createPoolAccess(100, lowestPriorityMeta);
const incomingMeta = createMeta('0x1111', 111n); // Above 10% bump

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 10n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(false);
expect(result.txHashesToEvict).toContain(lowestPriorityMeta.txHash);
});

it('evicts when incoming fee is exactly at the bump threshold', async () => {
const lowestPriorityMeta = createMeta('0x2222', 100n);
const poolAccess = createPoolAccess(100, lowestPriorityMeta);
const incomingMeta = createMeta('0x1111', 110n); // Exactly 10% bump — accepted

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 10n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(false);
expect(result.txHashesToEvict).toContain(lowestPriorityMeta.txHash);
});

it('ignores when incoming fee is below the bump threshold', async () => {
const lowestPriorityMeta = createMeta('0x2222', 100n);
const poolAccess = createPoolAccess(100, lowestPriorityMeta);
const incomingMeta = createMeta('0x1111', 109n); // Below 10% bump

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 10n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(true);
expect(result.reason?.code).toBe(TxPoolRejectionCode.LOW_PRIORITY_FEE);
if (result.reason?.code === TxPoolRejectionCode.LOW_PRIORITY_FEE) {
expect(result.reason.minimumPriorityFee).toBe(110n);
expect(result.reason.txPriorityFee).toBe(109n);
}
});

it('without price bump (P2P path), behavior unchanged', async () => {
const lowestPriorityMeta = createMeta('0x2222', 100n);
const poolAccess = createPoolAccess(100, lowestPriorityMeta);
const incomingMeta = createMeta('0x1111', 101n);

// No context — uses comparePriority, 101 > 100 so incoming wins
const result = await rule.check(incomingMeta, poolAccess);

expect(result.shouldIgnore).toBe(false);
expect(result.txHashesToEvict).toContain(lowestPriorityMeta.txHash);
});

it('with 0% bump, rejects equal fee (minimum bump of 1)', async () => {
const lowestPriorityMeta = createMeta('0x2222', 100n);
const poolAccess = createPoolAccess(100, lowestPriorityMeta);
const incomingMeta = createMeta('0x1111', 100n);

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 0n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(true);
expect(result.txHashesToEvict).toHaveLength(0);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createLogger } from '@aztec/foundation/log';

import { type TxMetaData, comparePriority } from '../tx_metadata.js';
import { type TxMetaData, comparePriority, getMinimumPriceBumpFee } from '../tx_metadata.js';
import {
type EvictionConfig,
type PreAddContext,
Expand Down Expand Up @@ -48,10 +48,14 @@ export class LowPriorityPreAddRule implements PreAddRule {
}

// Compare incoming tx against lowest priority tx.
// feeOnly mode (RPC): use strict fee comparison only — avoids churn from hash ordering
// Default (gossip): use full comparePriority (fee + tx hash tiebreaker) for determinism
// feeOnly mode (RPC): use strict fee comparison only — avoids churn from hash ordering.
// When price bump is also set, require the bumped fee threshold.
// Default (gossip): use full comparePriority (fee + tx hash tiebreaker) for determinism.
const isHigherPriority = context?.feeComparisonOnly
? incomingMeta.priorityFee > lowestPriorityMeta.priorityFee
? context.priceBumpPercentage !== undefined
? incomingMeta.priorityFee >=
getMinimumPriceBumpFee(lowestPriorityMeta.priorityFee, context.priceBumpPercentage)
: incomingMeta.priorityFee > lowestPriorityMeta.priorityFee
: comparePriority(incomingMeta, lowestPriorityMeta) > 0;

if (isHigherPriority) {
Expand All @@ -66,6 +70,11 @@ export class LowPriorityPreAddRule implements PreAddRule {
}

// Incoming tx has equal or lower priority - ignore it (it would be evicted anyway)
const minimumFee =
context?.feeComparisonOnly && context.priceBumpPercentage !== undefined
? getMinimumPriceBumpFee(lowestPriorityMeta.priorityFee, context.priceBumpPercentage)
: lowestPriorityMeta.priorityFee + 1n;

this.log.debug(
`Pool at capacity (${currentCount}/${this.maxPoolSize}), ignoring ${incomingMeta.txHash} ` +
`(priority ${incomingMeta.priorityFee}) - lower than existing minimum (priority ${lowestPriorityMeta.priorityFee})`,
Expand All @@ -75,8 +84,8 @@ export class LowPriorityPreAddRule implements PreAddRule {
txHashesToEvict: [],
reason: {
code: TxPoolRejectionCode.LOW_PRIORITY_FEE,
message: `Tx does not meet minimum priority fee. Required: ${lowestPriorityMeta.priorityFee + 1n}, got: ${incomingMeta.priorityFee}`,
minimumPriorityFee: lowestPriorityMeta.priorityFee + 1n,
message: `Tx does not meet minimum priority fee. Required: ${minimumFee}, got: ${incomingMeta.priorityFee}`,
minimumPriorityFee: minimumFee,
txPriorityFee: incomingMeta.priorityFee,
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type TxMetaData, stubTxMetaData } from '../tx_metadata.js';
import type { PreAddPoolAccess } from './interfaces.js';
import { type PreAddContext, type PreAddPoolAccess, TxPoolRejectionCode } from './interfaces.js';
import { NullifierConflictRule } from './nullifier_conflict_rule.js';

describe('NullifierConflictRule', () => {
Expand Down Expand Up @@ -255,6 +255,108 @@ describe('NullifierConflictRule', () => {
});
});

describe('with priceBumpPercentage context', () => {
it('accepts tx when fee exceeds 10% bump threshold', async () => {
const sharedNullifier = '0xshared_null';
const existingMeta = createMeta('0x2222', 100n, [sharedNullifier]);
const incomingMeta = createMeta('0x1111', 111n, [sharedNullifier]); // Above 10%

const metadataMap = new Map<string, TxMetaData>([['0x2222', existingMeta]]);
const nullifierMap = new Map<string, string>([[sharedNullifier, '0x2222']]);
poolAccess = createPoolAccess(nullifierMap, metadataMap);

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 10n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(false);
expect(result.txHashesToEvict).toContain('0x2222');
});

it('accepts tx when fee is exactly at 10% bump threshold', async () => {
const sharedNullifier = '0xshared_null';
const existingMeta = createMeta('0x2222', 100n, [sharedNullifier]);
const incomingMeta = createMeta('0x1111', 110n, [sharedNullifier]); // Exactly 10% — accepted

const metadataMap = new Map<string, TxMetaData>([['0x2222', existingMeta]]);
const nullifierMap = new Map<string, string>([[sharedNullifier, '0x2222']]);
poolAccess = createPoolAccess(nullifierMap, metadataMap);

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 10n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(false);
expect(result.txHashesToEvict).toContain('0x2222');
});

it('rejects tx when fee is below 10% bump threshold', async () => {
const sharedNullifier = '0xshared_null';
const existingMeta = createMeta('0x2222', 100n, [sharedNullifier]);
const incomingMeta = createMeta('0x1111', 109n, [sharedNullifier]); // Below 10%

const metadataMap = new Map<string, TxMetaData>([['0x2222', existingMeta]]);
const nullifierMap = new Map<string, string>([[sharedNullifier, '0x2222']]);
poolAccess = createPoolAccess(nullifierMap, metadataMap);

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 10n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(true);
expect(result.reason?.code).toBe(TxPoolRejectionCode.NULLIFIER_CONFLICT);
if (result.reason?.code === TxPoolRejectionCode.NULLIFIER_CONFLICT) {
expect(result.reason.minimumPriceBumpFee).toBe(110n);
expect(result.reason.txPriorityFee).toBe(109n);
}
});

it('accepts tx well above bump threshold', async () => {
const sharedNullifier = '0xshared_null';
const existingMeta = createMeta('0x2222', 100n, [sharedNullifier]);
const incomingMeta = createMeta('0x1111', 200n, [sharedNullifier]);

const metadataMap = new Map<string, TxMetaData>([['0x2222', existingMeta]]);
const nullifierMap = new Map<string, string>([[sharedNullifier, '0x2222']]);
poolAccess = createPoolAccess(nullifierMap, metadataMap);

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 10n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(false);
expect(result.txHashesToEvict).toContain('0x2222');
});

it('without price bump (P2P path), behavior is unchanged', async () => {
const sharedNullifier = '0xshared_null';
const existingMeta = createMeta('0x2222', 100n, [sharedNullifier]);
const incomingMeta = createMeta('0x1111', 101n, [sharedNullifier]); // 1% above, not enough for 10% bump

const metadataMap = new Map<string, TxMetaData>([['0x2222', existingMeta]]);
const nullifierMap = new Map<string, string>([[sharedNullifier, '0x2222']]);
poolAccess = createPoolAccess(nullifierMap, metadataMap);

// No context (P2P) — uses comparePriority, 101 > 100 means incoming wins
const result = await rule.check(incomingMeta, poolAccess);

expect(result.shouldIgnore).toBe(false);
expect(result.txHashesToEvict).toContain('0x2222');
});

it('with 0% price bump, rejects equal fee (minimum bump of 1)', async () => {
const sharedNullifier = '0xshared_null';
const existingMeta = createMeta('0x2222', 100n, [sharedNullifier]);
const incomingMeta = createMeta('0x1111', 100n, [sharedNullifier]);

const metadataMap = new Map<string, TxMetaData>([['0x2222', existingMeta]]);
const nullifierMap = new Map<string, string>([[sharedNullifier, '0x2222']]);
poolAccess = createPoolAccess(nullifierMap, metadataMap);

const context: PreAddContext = { feeComparisonOnly: true, priceBumpPercentage: 0n };
const result = await rule.check(incomingMeta, poolAccess, context);

expect(result.shouldIgnore).toBe(true);
expect(result.txHashesToEvict).toHaveLength(0);
});
});

describe('edge cases', () => {
it('skips self-reference (incoming tx hash in conflict list)', async () => {
const nullifier = '0xnull1';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ export class NullifierConflictRule implements PreAddRule {

private log = createLogger('p2p:tx_pool_v2:nullifier_conflict_rule');

check(incomingMeta: TxMetaData, poolAccess: PreAddPoolAccess, _context?: PreAddContext): Promise<PreAddResult> {
check(incomingMeta: TxMetaData, poolAccess: PreAddPoolAccess, context?: PreAddContext): Promise<PreAddResult> {
const result = checkNullifierConflict(
incomingMeta,
nullifier => poolAccess.getTxHashByNullifier(nullifier),
txHash => poolAccess.getMetadata(txHash),
context?.priceBumpPercentage,
);

if (result.shouldIgnore) {
Expand Down
3 changes: 3 additions & 0 deletions yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type TxPoolV2Config = {
evictedTxCacheSize: number;
/** The probability (0-1) that a transaction is discarded. 0 disables dropping. For testing purposes only. */
dropTransactionsProbability: number;
/** Minimum percentage fee increase required to replace an existing tx via RPC (0 = no bump). */
priceBumpPercentage: bigint;
};

/**
Expand All @@ -57,6 +59,7 @@ export const DEFAULT_TX_POOL_V2_CONFIG: TxPoolV2Config = {
minTxPoolAgeMs: 2_000,
evictedTxCacheSize: 10_000,
dropTransactionsProbability: 0,
priceBumpPercentage: 10n,
};

/**
Expand Down
Loading
Loading