Skip to content

Commit ada0571

Browse files
Add transaction filtering support to nitro-testnode
This adds support for testing Nitro's transaction filtering feature, which allows a sequencer to filter transactions from specific addresses using a hash-based blocklist stored in S3-compatible storage. Usage: Start testnode with transaction filtering enabled: ./test-node.bash --init --l2-tx-filtering Manage the filtered address list: # Add an address to the filter list ./test-node.bash script add-filtered-address --address 0x... # Remove an address from the filter list ./test-node.bash script remove-filtered-address --address 0x... # Compute hash for an address (for debugging) ./test-node.bash script hash-address --address 0x... The sequencer polls MinIO every 30s for updates. Check MinIO console at http://localhost:9001 (login: minioadmin/minioadmin). Components added: - MinIO container for S3-compatible storage of the address hash list - transaction-filterer service container for onchain filtering operations - filterer account with TransactionFilterer role granted via ArbOwner Changes: - docker-compose.yaml: Add minio and transaction-filterer services - test-node.bash: Add --l2-tx-filtering flag and initialization steps - scripts/config.ts: Add S3 upload via @aws-sdk/client-s3, sequencer config - scripts/ethcommands.ts: Add grant-filterer-role command - scripts/accounts.ts: Add filterer account - scripts/package.json: Add @aws-sdk/client-s3 dependency
1 parent dd3216e commit ada0571

File tree

7 files changed

+341
-7
lines changed

7 files changed

+341
-7
lines changed

docker-compose.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,37 @@ services:
480480
depends_on:
481481
- redis
482482

483+
minio:
484+
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
485+
ports:
486+
- "127.0.0.1:9000:9000"
487+
- "127.0.0.1:9001:9001"
488+
volumes:
489+
- "minio-data:/data"
490+
environment:
491+
MINIO_ROOT_USER: minioadmin
492+
MINIO_ROOT_PASSWORD: minioadmin
493+
command: server /data --console-address ":9001"
494+
healthcheck:
495+
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
496+
interval: 5s
497+
timeout: 5s
498+
retries: 5
499+
500+
transaction-filterer:
501+
pid: host
502+
image: nitro-node-dev-testnode
503+
entrypoint: /usr/local/bin/transaction-filterer
504+
ports:
505+
- "127.0.0.1:8549:8547"
506+
volumes:
507+
- "config:/config"
508+
- "l1keystore:/home/user/l1keystore"
509+
command:
510+
- --conf.file=/config/transaction_filterer_config.json
511+
depends_on:
512+
- sequencer
513+
483514
volumes:
484515
l1data:
485516
consensus:
@@ -503,3 +534,4 @@ volumes:
503534
timeboost-auctioneer-data:
504535
contracts:
505536
contracts-local:
537+
minio-data:

scripts/accounts.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as crypto from "crypto";
55
import { runStress } from "./stress";
66
const path = require("path");
77

8-
const specialAccounts = 7;
8+
const specialAccounts = 8;
99

1010
async function writeAccounts() {
1111
for (let i = 0; i < specialAccounts; i++) {
@@ -50,6 +50,9 @@ export function namedAccount(
5050
if (name == "auctioneer") {
5151
return specialAccount(6);
5252
}
53+
if (name == "filterer") {
54+
return specialAccount(7);
55+
}
5356
if (name.startsWith("user_")) {
5457
return new ethers.Wallet(
5558
ethers.utils.sha256(ethers.utils.toUtf8Bytes(name))
@@ -89,7 +92,7 @@ export function namedAddress(
8992
export const namedAccountHelpString =
9093
"Valid account names:\n" +
9194
" funnel | sequencer | validator | l2owner\n" +
92-
" | auctioneer - known keys used by l2\n" +
95+
" | auctioneer | filterer - known keys used by l2\n" +
9396
" l3owner | l3sequencer - known keys used by l3\n" +
9497
" user_[Alphanumeric] - key will be generated from username\n" +
9598
" threaduser_[Alphanumeric] - same as user_[Alphanumeric]_thread_[thread-id]\n" +

scripts/config.ts

Lines changed: 235 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import * as fs from 'fs';
2+
import * as crypto from 'crypto';
23
import * as consts from './consts'
34
import { ethers } from "ethers";
45
import { namedAddress } from './accounts'
6+
import { S3Client, PutObjectCommand, CreateBucketCommand, HeadBucketCommand } from "@aws-sdk/client-s3";
57

68
const path = require("path");
79

10+
const S3_CONFIG = {
11+
endpoint: "http://minio:9000",
12+
region: "us-east-1",
13+
credentials: {
14+
accessKeyId: "minioadmin",
15+
secretAccessKey: "minioadmin",
16+
},
17+
forcePathStyle: true,
18+
};
19+
20+
const S3_BUCKET = "tx-filtering";
21+
const S3_OBJECT_KEY = "address-hashes.json";
22+
823
function writePrysmConfig(argv: any) {
924
const prysm = `
1025
CONFIG_NAME: interop
@@ -315,6 +330,26 @@ function writeConfigs(argv: any) {
315330
if (argv.anytrust) {
316331
simpleConfig.node["data-availability"]["rpc-aggregator"].enable = true
317332
}
333+
if (argv.txfiltering) {
334+
simpleConfig.execution["address-filter"] = {
335+
"enable": true,
336+
"s3": {
337+
"access-key": "minioadmin",
338+
"secret-key": "minioadmin",
339+
"region": "us-east-1",
340+
"endpoint": "http://minio:9000",
341+
"bucket": "tx-filtering",
342+
"object-key": "address-hashes.json"
343+
},
344+
"poll-interval": "30s"
345+
};
346+
simpleConfig.execution["transaction-filterer-rpc-client"] = {
347+
"url": "http://transaction-filterer:8547"
348+
};
349+
simpleConfig["init"] = {
350+
"transaction-filtering-enabled": true
351+
};
352+
}
318353
fs.writeFileSync(path.join(consts.configpath, "sequencer_config.json"), JSON.stringify(simpleConfig))
319354
} else {
320355
let validatorConfig = JSON.parse(baseConfJSON)
@@ -338,6 +373,26 @@ function writeConfigs(argv: any) {
338373
"redis-url": argv.redisUrl
339374
};
340375
}
376+
if (argv.txfiltering) {
377+
sequencerConfig.execution["address-filter"] = {
378+
"enable": true,
379+
"s3": {
380+
"access-key": "minioadmin",
381+
"secret-key": "minioadmin",
382+
"region": "us-east-1",
383+
"endpoint": "http://minio:9000",
384+
"bucket": "tx-filtering",
385+
"object-key": "address-hashes.json"
386+
},
387+
"poll-interval": "30s"
388+
};
389+
sequencerConfig.execution["transaction-filterer-rpc-client"] = {
390+
"url": "http://transaction-filterer:8547"
391+
};
392+
sequencerConfig["init"] = {
393+
"transaction-filtering-enabled": true
394+
};
395+
}
341396
fs.writeFileSync(path.join(consts.configpath, "sequencer_config.json"), JSON.stringify(sequencerConfig))
342397

343398
let posterConfig = JSON.parse(baseConfJSON)
@@ -418,7 +473,7 @@ function writeL2ChainConfig(argv: any) {
418473
"EnableArbOS": true,
419474
"AllowDebugPrecompiles": true,
420475
"DataAvailabilityCommittee": argv.anytrust,
421-
"InitialArbOSVersion": 40,
476+
"InitialArbOSVersion": 60,
422477
"InitialChainOwner": argv.l2owner,
423478
"GenesisBlockNum": 0
424479
}
@@ -451,7 +506,7 @@ function writeL3ChainConfig(argv: any) {
451506
"EnableArbOS": true,
452507
"AllowDebugPrecompiles": true,
453508
"DataAvailabilityCommittee": false,
454-
"InitialArbOSVersion": 40,
509+
"InitialArbOSVersion": 60,
455510
"InitialChainOwner": argv.l2owner,
456511
"GenesisBlockNum": 0
457512
}
@@ -658,6 +713,11 @@ export const writeConfigCommand = {
658713
describe: "run sequencer in timeboost mode",
659714
default: false
660715
},
716+
txfiltering: {
717+
boolean: true,
718+
describe: "enable transaction filtering mode",
719+
default: false
720+
},
661721
},
662722
handler: (argv: any) => {
663723
writeConfigs(argv)
@@ -754,3 +814,176 @@ export const writeL2ReferenceDAConfigCommand = {
754814
writeL2ReferenceDAConfig(argv)
755815
}
756816
}
817+
818+
function writeTransactionFiltererConfig() {
819+
const config = {
820+
"chain-id": 412346,
821+
"sequencer": {
822+
"url": "http://sequencer:8547"
823+
},
824+
"wallet": {
825+
"account": namedAddress("filterer"),
826+
"password": consts.l1passphrase,
827+
"pathname": consts.l1keystore
828+
},
829+
"http": {
830+
"addr": "0.0.0.0",
831+
"port": 8547
832+
}
833+
};
834+
fs.writeFileSync(path.join(consts.configpath, "transaction_filterer_config.json"), JSON.stringify(config));
835+
}
836+
837+
export const writeTransactionFiltererConfigCommand = {
838+
command: "write-tx-filterer-config",
839+
describe: "writes transaction-filterer service config file",
840+
handler: () => {
841+
writeTransactionFiltererConfig()
842+
}
843+
}
844+
845+
export const initTxFilteringMinioCommand = {
846+
command: "init-tx-filtering-minio",
847+
describe: "initializes MinIO bucket and empty address hash list",
848+
handler: async () => {
849+
const salt = crypto.randomBytes(32).toString('hex');
850+
const initialAddressList = {
851+
"salt": salt,
852+
"hashing_scheme": "Sha256",
853+
"address_hashes": []
854+
};
855+
fs.writeFileSync(path.join(consts.configpath, "initial_address_hashes.json"), JSON.stringify(initialAddressList, null, 2));
856+
fs.writeFileSync(path.join(consts.configpath, "tx_filtering_salt.hex"), salt);
857+
858+
const s3Client = new S3Client(S3_CONFIG);
859+
860+
try {
861+
await s3Client.send(new HeadBucketCommand({ Bucket: S3_BUCKET }));
862+
console.log("Bucket already exists:", S3_BUCKET);
863+
} catch (err: any) {
864+
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
865+
await s3Client.send(new CreateBucketCommand({ Bucket: S3_BUCKET }));
866+
console.log("Created bucket:", S3_BUCKET);
867+
} else {
868+
throw err;
869+
}
870+
}
871+
872+
await uploadFilteredAddressesToMinio();
873+
console.log("Initialized tx-filtering bucket with empty address list.");
874+
}
875+
}
876+
877+
function computeAddressHash(address: string, salt: string): string {
878+
const normalizedAddress = address.toLowerCase();
879+
const data = salt + normalizedAddress.replace('0x', '');
880+
const hash = crypto.createHash('sha256').update(Buffer.from(data, 'hex')).digest('hex');
881+
return hash;
882+
}
883+
884+
async function uploadFilteredAddressesToMinio() {
885+
console.log("Uploading address list to MinIO...");
886+
const s3Client = new S3Client(S3_CONFIG);
887+
888+
const addressListPath = path.join(consts.configpath, "initial_address_hashes.json");
889+
const content = fs.readFileSync(addressListPath).toString();
890+
891+
await s3Client.send(new PutObjectCommand({
892+
Bucket: S3_BUCKET,
893+
Key: S3_OBJECT_KEY,
894+
Body: content,
895+
ContentType: "application/json",
896+
}));
897+
898+
console.log("Upload complete.");
899+
}
900+
901+
export const hashAddressCommand = {
902+
command: "hash-address",
903+
describe: "computes SHA256 hash for an address with salt",
904+
builder: {
905+
address: {
906+
string: true,
907+
describe: "address to hash",
908+
demandOption: true
909+
},
910+
},
911+
handler: (argv: any) => {
912+
const saltPath = path.join(consts.configpath, "tx_filtering_salt.hex");
913+
if (!fs.existsSync(saltPath)) {
914+
console.error("Salt file not found. Run init-tx-filtering-minio first.");
915+
process.exit(1);
916+
}
917+
const salt = fs.readFileSync(saltPath).toString().trim();
918+
const hash = computeAddressHash(argv.address, salt);
919+
console.log(hash);
920+
}
921+
}
922+
923+
export const addFilteredAddressCommand = {
924+
command: "add-filtered-address",
925+
describe: "adds an address hash to the S3 filter list",
926+
builder: {
927+
address: {
928+
string: true,
929+
describe: "address to add to filter list",
930+
demandOption: true
931+
},
932+
},
933+
handler: async (argv: any) => {
934+
const saltPath = path.join(consts.configpath, "tx_filtering_salt.hex");
935+
if (!fs.existsSync(saltPath)) {
936+
console.error("Salt file not found. Run init-tx-filtering-minio first.");
937+
process.exit(1);
938+
}
939+
const salt = fs.readFileSync(saltPath).toString().trim();
940+
const hash = computeAddressHash(argv.address, salt);
941+
942+
const addressListPath = path.join(consts.configpath, "initial_address_hashes.json");
943+
const addressList = JSON.parse(fs.readFileSync(addressListPath).toString());
944+
945+
const exists = addressList.address_hashes.some((entry: any) => entry.hash === hash);
946+
if (!exists) {
947+
addressList.address_hashes.push({ hash: hash });
948+
fs.writeFileSync(addressListPath, JSON.stringify(addressList, null, 2));
949+
console.log("Added address hash:", hash);
950+
await uploadFilteredAddressesToMinio();
951+
} else {
952+
console.log("Address hash already in list:", hash);
953+
}
954+
}
955+
}
956+
957+
export const removeFilteredAddressCommand = {
958+
command: "remove-filtered-address",
959+
describe: "removes an address hash from the S3 filter list",
960+
builder: {
961+
address: {
962+
string: true,
963+
describe: "address to remove from filter list",
964+
demandOption: true
965+
},
966+
},
967+
handler: async (argv: any) => {
968+
const saltPath = path.join(consts.configpath, "tx_filtering_salt.hex");
969+
if (!fs.existsSync(saltPath)) {
970+
console.error("Salt file not found. Run init-tx-filtering-minio first.");
971+
process.exit(1);
972+
}
973+
const salt = fs.readFileSync(saltPath).toString().trim();
974+
const hash = computeAddressHash(argv.address, salt);
975+
976+
const addressListPath = path.join(consts.configpath, "initial_address_hashes.json");
977+
const addressList = JSON.parse(fs.readFileSync(addressListPath).toString());
978+
979+
const index = addressList.address_hashes.findIndex((entry: any) => entry.hash === hash);
980+
if (index > -1) {
981+
addressList.address_hashes.splice(index, 1);
982+
fs.writeFileSync(addressListPath, JSON.stringify(addressList, null, 2));
983+
console.log("Removed address hash:", hash);
984+
await uploadFilteredAddressesToMinio();
985+
} else {
986+
console.log("Address hash not in list:", hash);
987+
}
988+
}
989+
}

scripts/ethcommands.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,3 +808,23 @@ export const waitForSyncCommand = {
808808
} while (syncStatus !== false)
809809
},
810810
};
811+
812+
export const grantFiltererRoleCommand = {
813+
command: "grant-filterer-role",
814+
describe: "grants TransactionFilterer role to the filterer account",
815+
handler: async (argv: any) => {
816+
argv.provider = new ethers.providers.WebSocketProvider(argv.l2url);
817+
818+
const arbOwnerIface = new ethers.utils.Interface([
819+
"function addTransactionFilterer(address filterer) external"
820+
]);
821+
822+
argv.data = arbOwnerIface.encodeFunctionData("addTransactionFilterer", [namedAddress("filterer")]);
823+
argv.from = "l2owner";
824+
argv.to = "address_" + ARB_OWNER;
825+
argv.ethamount = "0";
826+
827+
await runStress(argv, sendTransaction);
828+
argv.provider.destroy();
829+
}
830+
};

0 commit comments

Comments
 (0)