From 1a17a872135e3b65d03b32adcec85fc1fd373f4b Mon Sep 17 00:00:00 2001 From: POPPIN-FUMI Date: Sat, 6 Jun 2026 13:46:10 +0900 Subject: [PATCH 1/2] feat(validator): SIMD-0387 BLS pubkey registration + Agave XDP support Two new testnet/mainnet validator requirements: BLS public key (SIMD-0387) - registerBlsPubkey() runs `solana vote-authorize-voter-checked` with `--use-v2-instruction` per host, signing with the identity keypair (the default authorized voter) to fill in the derived BLS pubkey. - Idempotent: skips hosts whose blsPubkeyCompressed is already set, and verifies the key landed on-chain before reporting success. When the feature gate is inactive the forced-v2 tx fails at simulation without mutating state (no wasted voter re-authorization). - Auto-runs at the end of `slv v deploy` (testnet + mainnet, non-fatal); also exposed as `slv v register:bls` for re-running after activation. Agave XDP (retransmit acceleration) - Per-host inventory vars (xdp_enabled / xdp_interface / xdp_cpu_cores / xdp_zero_copy / xdp_poh_pinned_cpu_core) on NodeConfigBase, captured by promptXdpConfig() during `slv v init` for agave/jito only (Firedancer uses XDP natively). - start-validator*.sh.j2 inject the --experimental-retransmit-xdp-* flags and solv.service.j2 grants CAP_NET_RAW/NET_ADMIN/BPF/PERFMON, all gated by `{% if xdp_enabled %}` so existing hosts are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deploy/deployValidatorMainnet.ts | 14 ++ .../deploy/deployValidatorTestnet.ts | 13 ++ cli/src/validator/index.ts | 24 +++ cli/src/validator/init/initMainnetConfig.ts | 4 + cli/src/validator/init/initTestnetConfig.ts | 4 + cli/src/validator/init/promptXdpConfig.ts | 80 +++++++++ cli/src/validator/registerBlsPubkey.ts | 170 ++++++++++++++++++ cmn/types/config.ts | 8 + .../jinja/mainnet-validator/solv.service.j2 | 4 + .../mainnet-validator/start-validator.sh.j2 | 10 ++ .../jinja/testnet-validator/solv.service.j2 | 4 + .../start-validator-agave.sh.j2 | 10 ++ .../start-validator-jito.sh.j2 | 10 ++ .../testnet-validator/start-validator.sh.j2 | 10 ++ 14 files changed, 365 insertions(+) create mode 100644 cli/src/validator/init/promptXdpConfig.ts create mode 100644 cli/src/validator/registerBlsPubkey.ts diff --git a/cli/src/validator/deploy/deployValidatorMainnet.ts b/cli/src/validator/deploy/deployValidatorMainnet.ts index 734bc8a4..6ee8aee0 100644 --- a/cli/src/validator/deploy/deployValidatorMainnet.ts +++ b/cli/src/validator/deploy/deployValidatorMainnet.ts @@ -5,6 +5,7 @@ import { colors } from '@cliffy/colors' import rpcLog from '/lib/config/rpcLog.ts' import { listValidators } from '/src/validator/listValidators.ts' import { getAnsibleHosts } from '/lib/yml/getAnsibleHost.ts' +import { registerBlsPubkey } from '/src/validator/registerBlsPubkey.ts' const deployValidatorMainnet = async (limit?: string) => { const inventoryType = 'mainnet_validators' @@ -29,6 +30,19 @@ const deployValidatorMainnet = async (limit?: string) => { if (result) { console.log('Successfully deployed validator on mainnet') rpcLog(ansibleHosts) + // SIMD-0387: register the BLS public key on each vote account post-deploy. + // No-op on mainnet until the feature gate is active there. + try { + await registerBlsPubkey('mainnet', limit) + } catch (e) { + console.warn( + colors.yellow( + `⚠️ BLS pubkey registration skipped: ${ + e instanceof Error ? e.message : e + }`, + ), + ) + } return true } console.log('Failed to deploy validator on mainnet') diff --git a/cli/src/validator/deploy/deployValidatorTestnet.ts b/cli/src/validator/deploy/deployValidatorTestnet.ts index 3ac7cadd..6c943c6d 100644 --- a/cli/src/validator/deploy/deployValidatorTestnet.ts +++ b/cli/src/validator/deploy/deployValidatorTestnet.ts @@ -5,6 +5,7 @@ import { colors } from '@cliffy/colors' import rpcLog from '/lib/config/rpcLog.ts' import { listValidators } from '/src/validator/listValidators.ts' import { getAnsibleHosts } from '/lib/yml/getAnsibleHost.ts' +import { registerBlsPubkey } from '/src/validator/registerBlsPubkey.ts' const deployValidatorTestnet = async (limit?: string) => { const inventoryType = 'testnet_validators' @@ -29,6 +30,18 @@ const deployValidatorTestnet = async (limit?: string) => { if (result) { console.log('Successfully deployed validator on testnet') rpcLog(ansibleHosts) + // SIMD-0387: register the BLS public key on each vote account post-deploy. + try { + await registerBlsPubkey('testnet', limit) + } catch (e) { + console.warn( + colors.yellow( + `⚠️ BLS pubkey registration skipped: ${ + e instanceof Error ? e.message : e + }`, + ), + ) + } return true } console.log('Failed to deploy validator on testnet') diff --git a/cli/src/validator/index.ts b/cli/src/validator/index.ts index 5a37e8cb..3489704d 100644 --- a/cli/src/validator/index.ts +++ b/cli/src/validator/index.ts @@ -19,6 +19,7 @@ import { transformValidatorTypeFile } from '/lib/migrate/transformValidatorTypes import { copyTemplateDirs } from '/src/rpc/init.ts' import { registerDoubleZeroCommands } from '/lib/doublezero.ts' import { registerSha256PatchCommands } from '/lib/sha256Patch.ts' +import { registerBlsPubkey } from '/src/validator/registerBlsPubkey.ts' export const validatorCmd = new Command() .description('🛠️ Manage Solana Validator Nodes 🛠️') @@ -60,6 +61,29 @@ validatorCmd.command('deploy') } }) +validatorCmd.command('register:bls') + .description( + '🔑 Register the BLS public key on vote accounts (SIMD-0387). Runs automatically after deploy; use this to re-run.', + ) + .option('-n, --network ', 'Solana Network') + .option('-p, --pubkey ', 'Inventory host name (defaults to all)') + .action(async (options) => { + let network = options.network as NetworkType | undefined + if (!network) { + const res = await prompt([ + { + name: 'network', + message: 'Select Solana Network', + type: Select, + options: ['testnet', 'mainnet'], + default: 'testnet', + }, + ]) + network = res.network as NetworkType + } + await registerBlsPubkey(network, options.pubkey) + }) + validatorCmd.command('list') .description('📋 List validators') .option('-n, --network ', 'Solana Network', { diff --git a/cli/src/validator/init/initMainnetConfig.ts b/cli/src/validator/init/initMainnetConfig.ts index 761bc605..7bd9e4c3 100644 --- a/cli/src/validator/init/initMainnetConfig.ts +++ b/cli/src/validator/init/initMainnetConfig.ts @@ -23,6 +23,7 @@ import { import type { SolanaNodeType } from '@cmn/types/config.ts' import { findNearestSnapshotUrl } from '/lib/snapshot/findNearestSnapshot.ts' import { getAllRegions } from '/lib/jito/jitoRegions.ts' +import { promptXdpConfig } from '/src/validator/init/promptXdpConfig.ts' const initMainnetConfig = async ( sshConnection: SSHConnection, @@ -136,6 +137,8 @@ const initMainnetConfig = async ( const blockEngineRegion = getNearRegion.info.blockEngineUrl const shredstream_address = getNearRegion.info.shredReceiver const relayer_url = getNearRegion.info.relayerUrl + // XDP retransmit acceleration (agave/jito only) + const xdpConfig = await promptXdpConfig(validatorType as SolanaNodeType) // Generate Vote Key const { voteAccount, authAccount } = await genVoteKey(identityAccount) const configMainnet: Partial = { @@ -152,6 +155,7 @@ const initMainnetConfig = async ( shred_receiver_address: String(shredstream_address), snapshot_url: snapshotUrl, staked_rpc_identity_account: rpcAccount, + ...xdpConfig, } // await updateAllowedSshIps() // await updateAllowedIps() diff --git a/cli/src/validator/init/initTestnetConfig.ts b/cli/src/validator/init/initTestnetConfig.ts index d72c7a1f..973fdf18 100644 --- a/cli/src/validator/init/initTestnetConfig.ts +++ b/cli/src/validator/init/initTestnetConfig.ts @@ -18,6 +18,7 @@ import { SolanaNodeTypes } from '@cmn/constants/config.ts' import { findNearestJitoRegion } from '/lib/jito/findNearestRegion.ts' import type { RegionLatency } from '/lib/jito/findNearestRegion.ts' import { getAllRegions } from '/lib/jito/jitoRegions.ts' +import { promptXdpConfig } from '/src/validator/init/promptXdpConfig.ts' const initTestnetConfig = async ( sshConnection: SSHConnection, @@ -94,6 +95,8 @@ const initTestnetConfig = async ( console.log(colors.red('❌ Failed to measure latencies. Please try again.')) return } + // XDP retransmit acceleration (agave/jito only) + const xdpConfig = await promptXdpConfig(validatorType as SolanaNodeType) // Generate Vote Key const { voteAccount, authAccount } = await genVoteKey(identityAccount) // Generate or Add Inventory @@ -124,6 +127,7 @@ const initTestnetConfig = async ( snapshot_url: '', port_rpc: 7211, dynamic_port_range: '8900-8925', + ...xdpConfig, } await updateInventory(name, configTestnet) // Create solv User on Ubuntu Server diff --git a/cli/src/validator/init/promptXdpConfig.ts b/cli/src/validator/init/promptXdpConfig.ts new file mode 100644 index 00000000..bed0486f --- /dev/null +++ b/cli/src/validator/init/promptXdpConfig.ts @@ -0,0 +1,80 @@ +import { Confirm, Input, prompt } from '@cliffy/prompt' +import { colors } from '@cliffy/colors' +import type { SolanaNodeType } from '@cmn/types/config.ts' + +export interface XdpConfig { + xdp_enabled?: boolean + xdp_interface?: string + xdp_cpu_cores?: number + xdp_zero_copy?: boolean + xdp_poh_pinned_cpu_core?: number +} + +// XDP (eXpress Data Path) accelerates Turbine retransmit. Only Agave/Jito +// validators take these flags; Firedancer uses its own XDP path natively, so +// for those types we return an empty config and skip the prompt entirely. +const promptXdpConfig = async ( + validatorType: SolanaNodeType, +): Promise => { + if (validatorType !== 'agave' && validatorType !== 'jito') { + return {} + } + const { enable } = await prompt([{ + name: 'enable', + message: '⚡ Enable XDP retransmit acceleration for this validator?', + type: Confirm, + default: true, + }]) + if (!enable) { + return { xdp_enabled: false } + } + console.log(colors.yellow( + '⚠️ XDP requires a recent kernel (6.8+, igb driver needs 6.14+) and grants\n' + + ' the validator CAP_NET_RAW/CAP_NET_ADMIN/CAP_BPF/CAP_PERFMON via systemd.', + )) + const answers = await prompt([ + { + name: 'iface', + message: 'XDP network interface (bond member NIC, e.g. enp196s0f0np0)', + type: Input, + }, + { + name: 'cores', + message: 'XDP retransmit CPU cores (count)', + type: Input, + default: '1', + }, + { + name: 'zeroCopy', + message: 'Enable XDP zero-copy? (do NOT enable on bnxt_en / ice drivers)', + type: Confirm, + default: false, + }, + { + name: 'pohCore', + message: 'PoH pinned CPU core (leave blank to skip)', + type: Input, + default: '', + }, + ]) + const iface = String(answers.iface || '').trim() + if (!iface) { + console.log( + colors.yellow('⚠️ No interface given — disabling XDP for this host.'), + ) + return { xdp_enabled: false } + } + const cfg: XdpConfig = { + xdp_enabled: true, + xdp_interface: iface, + xdp_cpu_cores: Number(answers.cores) || 1, + xdp_zero_copy: Boolean(answers.zeroCopy), + } + const poh = String(answers.pohCore || '').trim() + if (poh) { + cfg.xdp_poh_pinned_cpu_core = Number(poh) + } + return cfg +} + +export { promptXdpConfig } diff --git a/cli/src/validator/registerBlsPubkey.ts b/cli/src/validator/registerBlsPubkey.ts new file mode 100644 index 00000000..81180a7e --- /dev/null +++ b/cli/src/validator/registerBlsPubkey.ts @@ -0,0 +1,170 @@ +import { parse } from '@std/yaml' +import { colors } from '@cliffy/colors' +import { spawnSync } from '@elsoul/child-process' +import type { InventoryType, NetworkType } from '@cmn/types/config.ts' +import { getInventoryPath } from '@cmn/constants/path.ts' + +// SIMD-0387 (BLS pubkey management in vote account) requires every voting +// validator to register the BLS public key derived from its authorized voter +// keypair. Without it, the vote account behaves as unstaked once SIMD-0357 +// (Alpenglow voting) is active on the cluster. As of mid-2026 SIMD-0387 is +// active on testnet only; on mainnet the command is a safe no-op until then. +const RPC_URLS: Record = { + testnet: 'https://api.testnet.solana.com', + mainnet: 'https://api.mainnet-beta.solana.com', + devnet: 'https://api.devnet.solana.com', +} + +interface BlsHost { + name: string + identity_account: string + vote_account: string +} + +// Reads the on-chain BLS pubkey for a vote account, or null if unset. +// The plain `solana vote-account` table output omits this field, so we parse +// JSON and read `blsPubkeyCompressed` (null until SIMD-0387 fills it in). +const getBlsPubkey = async ( + voteAccount: string, + rpcUrl: string, +): Promise => { + try { + const out = await new Deno.Command('solana', { + args: [ + 'vote-account', + voteAccount, + '--url', + rpcUrl, + '--output', + 'json', + ], + stdout: 'piped', + stderr: 'piped', + }).output() + if (!out.success) return null + const json = JSON.parse(new TextDecoder().decode(out.stdout)) + return json?.blsPubkeyCompressed ?? null + } catch { + return null + } +} + +const readBlsHosts = async ( + inventoryType: InventoryType, + limit?: string, +): Promise => { + const filePath = getInventoryPath(inventoryType) + let yamlText: string + try { + yamlText = await Deno.readTextFile(filePath) + } catch (_e) { + console.error(colors.red(`❌ Failed to read inventory file: ${filePath}`)) + return [] + } + const data = parse(yamlText) as Record + const allHosts = data?.[inventoryType]?.hosts ?? {} + const names = limit && limit.trim().toLowerCase() !== 'all' + ? limit.split(',').map((s) => s.trim()).filter(Boolean) + : Object.keys(allHosts) + + const out: BlsHost[] = [] + for (const name of names) { + const h = allHosts[name] + if (!h) { + console.error(colors.red(`❌ Host not found in inventory: ${name}`)) + continue + } + if (!h.vote_account || !h.identity_account) { + console.warn( + colors.yellow( + `⚠️ ${name}: missing vote_account/identity_account — skipping BLS registration`, + ), + ) + continue + } + out.push({ + name, + identity_account: h.identity_account, + vote_account: h.vote_account, + }) + } + return out +} + +// Registers the BLS public key on each validator's vote account. +// The authorized voter defaults to the validator identity, so the identity +// keypair signs and `vote-authorize-voter-checked` (voter unchanged) fills in +// the derived BLS pubkey. Failures are non-fatal: the cluster may not have +// SIMD-0387 active yet, the key may already be set, or the CLI may be too old. +const registerBlsPubkey = async ( + network: NetworkType, + limit?: string, +): Promise => { + const inventoryType: InventoryType = network === 'mainnet' + ? 'mainnet_validators' + : 'testnet_validators' + const rpcUrl = RPC_URLS[network] + const home = Deno.env.get('HOME') || '' + + const hosts = await readBlsHosts(inventoryType, limit) + if (hosts.length === 0) { + console.log(colors.yellow('⚠️ No hosts to register BLS pubkey for')) + return false + } + + console.log( + colors.white( + `🔑 Registering BLS public key on vote accounts (${network})...`, + ), + ) + let allOk = true + for (const h of hosts) { + console.log(colors.white(`→ ${h.name}: ${h.vote_account}`)) + + // Idempotent: skip if the BLS pubkey is already on-chain. + if (await getBlsPubkey(h.vote_account, rpcUrl)) { + console.log( + colors.green(`✔︎ ${h.name}: BLS public key already set — skipping`), + ) + continue + } + + // `--use-v2-instruction` forces the SIMD-0387 (BLS) instruction. When the + // feature gate is inactive the tx fails cleanly at simulation WITHOUT + // mutating state — unlike the auto-detect path, which silently falls back + // to a plain voter re-authorization (a wasted tx that also burns the + // once-per-epoch voter-change slot). The authorized voter is unchanged. + const keypair = `${home}/.slv/keys/${h.identity_account}.json` + const cmd = + `solana vote-authorize-voter-checked ${h.vote_account} ${keypair} ${keypair} --use-v2-instruction --url ${rpcUrl}` + const result = await spawnSync(cmd) + if (!result.success) { + console.warn( + colors.yellow( + `⚠️ ${h.name}: could not set BLS pubkey — SIMD-0387 is likely not ` + + `active on ${network} yet (or the identity keypair is not the vote ` + + `authority / solana CLI is too old). No state changed; re-run ` + + `\`slv v register:bls\` after activation.`, + ), + ) + allOk = false + continue + } + + // Confirm the key actually landed on-chain before reporting success. + if (await getBlsPubkey(h.vote_account, rpcUrl)) { + console.log(colors.green(`✔︎ ${h.name}: BLS public key set`)) + } else { + console.warn( + colors.yellow( + `⚠️ ${h.name}: tx sent but BLS pubkey still unset — SIMD-0387 is not ` + + `active on ${network} yet. Re-run \`slv v register:bls\` after activation.`, + ), + ) + allOk = false + } + } + return allOk +} + +export { registerBlsPubkey } diff --git a/cmn/types/config.ts b/cmn/types/config.ts index 364395f4..5d3e8ede 100644 --- a/cmn/types/config.ts +++ b/cmn/types/config.ts @@ -47,6 +47,14 @@ export interface NodeConfigBase extends AnsibleHostConfig { dynamic_port_range: string validator_type: SolanaNodeType shred_receiver_address: string + // XDP (eXpress Data Path) retransmit acceleration. Only honored for + // `agave` / `jito` validator types; Firedancer uses its own XDP path. + // Values are per-host because they depend on the NIC driver and CPU layout. + xdp_enabled?: boolean + xdp_interface?: string + xdp_cpu_cores?: number + xdp_zero_copy?: boolean + xdp_poh_pinned_cpu_core?: number } export interface ValidatorConfigBase extends NodeConfigBase { diff --git a/template/2026.5.21.1448/jinja/mainnet-validator/solv.service.j2 b/template/2026.5.21.1448/jinja/mainnet-validator/solv.service.j2 index b7be1c80..61a58a78 100644 --- a/template/2026.5.21.1448/jinja/mainnet-validator/solv.service.j2 +++ b/template/2026.5.21.1448/jinja/mainnet-validator/solv.service.j2 @@ -10,6 +10,10 @@ RestartSec=1 LimitNOFILE=1000000 LimitMEMLOCK=2000000000 LogRateLimitIntervalSec=0 +{% if xdp_enabled | default(false) %} +AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN CAP_BPF CAP_PERFMON +CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN CAP_BPF CAP_PERFMON +{% endif %} User=solv Environment=PATH=/home/solv/.local/share/solana/install/active_release/bin WorkingDirectory=/home/solv diff --git a/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 b/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 index 1111e5f4..2c22d736 100644 --- a/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 +++ b/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 @@ -24,6 +24,16 @@ exec agave-validator \ --use-snapshot-archives-at-startup when-newest \ --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ +{% if xdp_enabled | default(false) %} +--experimental-retransmit-xdp-interface {{ xdp_interface }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +{% if xdp_zero_copy | default(false) %} +--experimental-retransmit-xdp-zero-copy \ +{% endif %} +{% if xdp_poh_pinned_cpu_core is defined %} +--experimental-poh-pinned-cpu-core {{ xdp_poh_pinned_cpu_core }} \ +{% endif %} +{% endif %} --staked-nodes-overrides overrides.yml \ --rpc-bind-address 0.0.0.0 \ {% if validator_type in ['jito', 'allnodes-jito'] %} diff --git a/template/2026.5.21.1448/jinja/testnet-validator/solv.service.j2 b/template/2026.5.21.1448/jinja/testnet-validator/solv.service.j2 index 527e7ad2..9c58b17a 100644 --- a/template/2026.5.21.1448/jinja/testnet-validator/solv.service.j2 +++ b/template/2026.5.21.1448/jinja/testnet-validator/solv.service.j2 @@ -10,6 +10,10 @@ RestartSec=1 LimitNOFILE=1000000 LimitMEMLOCK=2000000000 LogRateLimitIntervalSec=0 +{% if xdp_enabled | default(false) %} +AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN CAP_BPF CAP_PERFMON +CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN CAP_BPF CAP_PERFMON +{% endif %} User=solv Environment=PATH=/home/solv/.local/share/solana/install/active_release/bin WorkingDirectory=/home/solv diff --git a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 index 9d488872..39590514 100644 --- a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 +++ b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 @@ -19,4 +19,14 @@ exec agave-validator \ --expected-bank-hash {{ expected_bank_hash | default("YFxSkDcvSPiA7EQpSTbCsWbJvNYMAsWXGvwGc3bXHEA") }} \ --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ +{% if xdp_enabled | default(false) %} +--experimental-retransmit-xdp-interface {{ xdp_interface }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +{% if xdp_zero_copy | default(false) %} +--experimental-retransmit-xdp-zero-copy \ +{% endif %} +{% if xdp_poh_pinned_cpu_core is defined %} +--experimental-poh-pinned-cpu-core {{ xdp_poh_pinned_cpu_core }} \ +{% endif %} +{% endif %} --rpc-bind-address 0.0.0.0 \ No newline at end of file diff --git a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 index 974a98e9..605ce0db 100644 --- a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 +++ b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 @@ -19,6 +19,16 @@ exec agave-validator \ --expected-bank-hash {{ expected_bank_hash | default("YFxSkDcvSPiA7EQpSTbCsWbJvNYMAsWXGvwGc3bXHEA") }} \ --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ +{% if xdp_enabled | default(false) %} +--experimental-retransmit-xdp-interface {{ xdp_interface }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +{% if xdp_zero_copy | default(false) %} +--experimental-retransmit-xdp-zero-copy \ +{% endif %} +{% if xdp_poh_pinned_cpu_core is defined %} +--experimental-poh-pinned-cpu-core {{ xdp_poh_pinned_cpu_core }} \ +{% endif %} +{% endif %} --rpc-bind-address 0.0.0.0 \ --commission-bps {{ commission_bps | default(0) }} \ --bam-url {{ bam_url | default("http://ny.testnet.bam.jito.wtf") }} \ diff --git a/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 b/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 index 0cb3cecf..36f2a6ef 100644 --- a/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 +++ b/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 @@ -21,6 +21,16 @@ exec agave-validator \ {% endif %} --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ +{% if xdp_enabled | default(false) %} +--experimental-retransmit-xdp-interface {{ xdp_interface }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +{% if xdp_zero_copy | default(false) %} +--experimental-retransmit-xdp-zero-copy \ +{% endif %} +{% if xdp_poh_pinned_cpu_core is defined %} +--experimental-poh-pinned-cpu-core {{ xdp_poh_pinned_cpu_core }} \ +{% endif %} +{% endif %} {% if validator_type in ['jito', 'allnodes-jito'] %} --tip-payment-program-pubkey GJHtFqM9agxPmkeKjHny6qiRKrXZALvvFGiKf11QE7hy \ --tip-distribution-program-pubkey DzvGET57TAgEDxvm3ERUM4GNcsAJdqjDLCne9sdfY4wf \ From 7faea004fd26e161626daa3dc799f747d5573515 Mon Sep 17 00:00:00 2001 From: POPPIN-FUMI Date: Sat, 6 Jun 2026 14:01:35 +0900 Subject: [PATCH 2/2] fix(validator): harden BLS/XDP from code review - registerBlsPubkey: run vote-authorize via Deno.Command argv array (no shell word-splitting on keypair paths with spaces); treat empty blsPubkeyCompressed as unset; guard non-testnet/mainnet networks and empty HOME; catch malformed inventory YAML instead of throwing out of the standalone command. - promptXdpConfig: validate xdp_cpu_cores / xdp_poh_pinned_cpu_core as non-negative integers so a fat-fingered entry can't write NaN (.nan) to the inventory and emit a garbage launch flag. - start-validator*.j2: gate the XDP block on a non-empty xdp_interface and use default(1, true) for cpu-cores, so a hand-edited inventory with xdp_enabled but a blank interface no longer renders a valueless flag that eats the next. Co-Authored-By: Claude Opus 4.8 (1M context) --- cli/src/validator/init/promptXdpConfig.ts | 27 ++++++++-- cli/src/validator/registerBlsPubkey.ts | 51 ++++++++++++++++--- .../mainnet-validator/start-validator.sh.j2 | 4 +- .../start-validator-agave.sh.j2 | 4 +- .../start-validator-jito.sh.j2 | 4 +- .../testnet-validator/start-validator.sh.j2 | 4 +- 6 files changed, 76 insertions(+), 18 deletions(-) diff --git a/cli/src/validator/init/promptXdpConfig.ts b/cli/src/validator/init/promptXdpConfig.ts index bed0486f..c06027f7 100644 --- a/cli/src/validator/init/promptXdpConfig.ts +++ b/cli/src/validator/init/promptXdpConfig.ts @@ -10,6 +10,25 @@ export interface XdpConfig { xdp_poh_pinned_cpu_core?: number } +// Parses a non-negative integer from prompt input, or returns `fallback` when +// the input is blank or not a valid integer. Prevents NaN/garbage from reaching +// the inventory (and the validator launch flags) on a fat-fingered entry. +const parseNonNegativeInt = ( + raw: unknown, + fallback: number | null, +): number | null => { + const s = String(raw ?? '').trim() + if (!s) return fallback + const n = Number(s) + if (!Number.isInteger(n) || n < 0) { + console.log( + colors.yellow(`⚠️ "${s}" is not a valid non-negative integer — ignored.`), + ) + return fallback + } + return n +} + // XDP (eXpress Data Path) accelerates Turbine retransmit. Only Agave/Jito // validators take these flags; Firedancer uses its own XDP path natively, so // for those types we return an empty config and skip the prompt entirely. @@ -67,12 +86,12 @@ const promptXdpConfig = async ( const cfg: XdpConfig = { xdp_enabled: true, xdp_interface: iface, - xdp_cpu_cores: Number(answers.cores) || 1, + xdp_cpu_cores: parseNonNegativeInt(answers.cores, 1) ?? 1, xdp_zero_copy: Boolean(answers.zeroCopy), } - const poh = String(answers.pohCore || '').trim() - if (poh) { - cfg.xdp_poh_pinned_cpu_core = Number(poh) + const poh = parseNonNegativeInt(answers.pohCore, null) + if (poh !== null) { + cfg.xdp_poh_pinned_cpu_core = poh } return cfg } diff --git a/cli/src/validator/registerBlsPubkey.ts b/cli/src/validator/registerBlsPubkey.ts index 81180a7e..81c4b5ab 100644 --- a/cli/src/validator/registerBlsPubkey.ts +++ b/cli/src/validator/registerBlsPubkey.ts @@ -1,6 +1,5 @@ import { parse } from '@std/yaml' import { colors } from '@cliffy/colors' -import { spawnSync } from '@elsoul/child-process' import type { InventoryType, NetworkType } from '@cmn/types/config.ts' import { getInventoryPath } from '@cmn/constants/path.ts' @@ -43,7 +42,9 @@ const getBlsPubkey = async ( }).output() if (!out.success) return null const json = JSON.parse(new TextDecoder().decode(out.stdout)) - return json?.blsPubkeyCompressed ?? null + const bls = json?.blsPubkeyCompressed + // Treat null/empty as "unset" so a non-empty base58 key is the only "set". + return typeof bls === 'string' && bls.length > 0 ? bls : null } catch { return null } @@ -61,7 +62,19 @@ const readBlsHosts = async ( console.error(colors.red(`❌ Failed to read inventory file: ${filePath}`)) return [] } - const data = parse(yamlText) as Record + let data: Record + try { + data = parse(yamlText) as Record + } catch (e) { + console.error( + colors.red( + `❌ Failed to parse inventory file ${filePath}: ${ + e instanceof Error ? e.message : e + }`, + ), + ) + return [] + } const allHosts = data?.[inventoryType]?.hosts ?? {} const names = limit && limit.trim().toLowerCase() !== 'all' ? limit.split(',').map((s) => s.trim()).filter(Boolean) @@ -100,11 +113,25 @@ const registerBlsPubkey = async ( network: NetworkType, limit?: string, ): Promise => { + if (network !== 'testnet' && network !== 'mainnet') { + console.warn( + colors.yellow( + `⚠️ BLS registration supports testnet/mainnet only (got "${network}") — skipping.`, + ), + ) + return false + } const inventoryType: InventoryType = network === 'mainnet' ? 'mainnet_validators' : 'testnet_validators' const rpcUrl = RPC_URLS[network] const home = Deno.env.get('HOME') || '' + if (!home) { + console.error( + colors.red('❌ HOME is not set — cannot locate ~/.slv/keys keypairs.'), + ) + return false + } const hosts = await readBlsHosts(inventoryType, limit) if (hosts.length === 0) { @@ -134,10 +161,22 @@ const registerBlsPubkey = async ( // mutating state — unlike the auto-detect path, which silently falls back // to a plain voter re-authorization (a wasted tx that also burns the // once-per-epoch voter-change slot). The authorized voter is unchanged. + // Use an argv array (not a shell string) so keypair paths containing + // spaces are passed as a single argument rather than word-split. const keypair = `${home}/.slv/keys/${h.identity_account}.json` - const cmd = - `solana vote-authorize-voter-checked ${h.vote_account} ${keypair} ${keypair} --use-v2-instruction --url ${rpcUrl}` - const result = await spawnSync(cmd) + const result = await new Deno.Command('solana', { + args: [ + 'vote-authorize-voter-checked', + h.vote_account, + keypair, + keypair, + '--use-v2-instruction', + '--url', + rpcUrl, + ], + stdout: 'inherit', + stderr: 'inherit', + }).output() if (!result.success) { console.warn( colors.yellow( diff --git a/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 b/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 index 2c22d736..4ee32d84 100644 --- a/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 +++ b/template/2026.5.21.1448/jinja/mainnet-validator/start-validator.sh.j2 @@ -24,9 +24,9 @@ exec agave-validator \ --use-snapshot-archives-at-startup when-newest \ --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ -{% if xdp_enabled | default(false) %} +{% if xdp_enabled | default(false) and xdp_interface | default('') %} --experimental-retransmit-xdp-interface {{ xdp_interface }} \ ---experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1, true) }} \ {% if xdp_zero_copy | default(false) %} --experimental-retransmit-xdp-zero-copy \ {% endif %} diff --git a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 index 39590514..376412b0 100644 --- a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 +++ b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-agave.sh.j2 @@ -19,9 +19,9 @@ exec agave-validator \ --expected-bank-hash {{ expected_bank_hash | default("YFxSkDcvSPiA7EQpSTbCsWbJvNYMAsWXGvwGc3bXHEA") }} \ --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ -{% if xdp_enabled | default(false) %} +{% if xdp_enabled | default(false) and xdp_interface | default('') %} --experimental-retransmit-xdp-interface {{ xdp_interface }} \ ---experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1, true) }} \ {% if xdp_zero_copy | default(false) %} --experimental-retransmit-xdp-zero-copy \ {% endif %} diff --git a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 index 605ce0db..15aa1256 100644 --- a/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 +++ b/template/2026.5.21.1448/jinja/testnet-validator/start-validator-jito.sh.j2 @@ -19,9 +19,9 @@ exec agave-validator \ --expected-bank-hash {{ expected_bank_hash | default("YFxSkDcvSPiA7EQpSTbCsWbJvNYMAsWXGvwGc3bXHEA") }} \ --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ -{% if xdp_enabled | default(false) %} +{% if xdp_enabled | default(false) and xdp_interface | default('') %} --experimental-retransmit-xdp-interface {{ xdp_interface }} \ ---experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1, true) }} \ {% if xdp_zero_copy | default(false) %} --experimental-retransmit-xdp-zero-copy \ {% endif %} diff --git a/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 b/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 index 36f2a6ef..47e1b9b8 100644 --- a/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 +++ b/template/2026.5.21.1448/jinja/testnet-validator/start-validator.sh.j2 @@ -21,9 +21,9 @@ exec agave-validator \ {% endif %} --limit-ledger-size {{ limit_ledger_size | default(200000000) }} \ --no-port-check \ -{% if xdp_enabled | default(false) %} +{% if xdp_enabled | default(false) and xdp_interface | default('') %} --experimental-retransmit-xdp-interface {{ xdp_interface }} \ ---experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1) }} \ +--experimental-retransmit-xdp-cpu-cores {{ xdp_cpu_cores | default(1, true) }} \ {% if xdp_zero_copy | default(false) %} --experimental-retransmit-xdp-zero-copy \ {% endif %}