v2.2: real fundValidator() + live validator on testnet#17
Conversation
… spec) Reading qrysm upstream (cloned at /home/waterfall/myqrlwallet/qrysm) to close out three pre-mainnet open items: DEPOSIT_CONTRACT verification, staking-deposit-cli flow, slashing parameter snapshot. Documented in new docs/UPSTREAM-FINDINGS.md. The DEPOSIT_CONTRACT = Q4242... is confirmed correct for testnet (pre-deployed at genesis via qrysm/runtime/interop/genesis.go). Along the way found a real bug: DepositPool-v2.sol:559 enforced `bytes1(0x01)` for the withdrawal-credentials prefix, imported from Ethereum-spec muscle memory. QRL uses `byte(0)` uniformly because there is no BLS-key withdrawal path to distinguish from (qrysm/config/params/mainnet_config.go:74 — ExecutionAddressWith- drawalPrefixByte). Real staking-deposit-cli deposits would have reverted with InvalidWithdrawalCredentials and stuck the stake. Fixed in the Solidity source; Hyperion mirror regenerated; 178 Foundry tests still green. Live-testnet deployment still has the pre-fix bytecode — this path is exercised only by fundValidator(), which is gated on real validator keys (blocked on hardware), so the deployed contract redeploy can wait. No Foundry test covered the prefix check previously (fundValidator has no unit test coverage beyond length checks); a focused test covering the prefix is left as follow-up work. Also captures the current qrysm slashing constants as a diff baseline against the yet-to-be-finalized official QRL numbers.
Previous exporter targeted v1 ABI (totalAssets, pendingDeposits,
liquidReserve, pendingWithdrawals, rewards oracle, operator
registry) — none of those exist on v2 contracts, so safeCall
silently returned 0n and Grafana dashboards showed zeros across
the board.
Rewrite reads the live v2 triad (stQRLv2 / DepositPoolV2 /
ValidatorManager) using inline minimal ABIs — no dependency on
compiled artifacts so the exporter can ship standalone.
Metrics (21 gauges + 6 counters + 1 histogram):
stQRLv2:
total_pooled_qrl (totalPooledQRL)
total_shares (totalShares)
exchange_rate (getExchangeRate, raw 1e18)
exchange_rate_normalized (getExchangeRate / 1e18)
stqrl_paused (paused)
DepositPoolV2:
buffered_qrl (bufferedQRL)
validator_count (validatorCount)
withdrawal_reserve_qrl (withdrawalReserve)
pending_withdrawal_shares (totalWithdrawalShares)
total_rewards_qrl (totalRewardsReceived)
total_slashing_qrl (totalSlashingLosses)
min_deposit_qrl (minDeposit)
deposit_pool_paused (paused)
deposit_pool_balance_qrl (native balance)
ValidatorManager:
vm_total_validators (totalValidators)
vm_active_validators (activeValidatorCount)
vm_pending_validators (pendingValidatorCount)
Events (getPastEvents poll):
deposit_events_total (Deposited)
withdrawal_events_total{type=requested|claimed|cancelled}
validator_funded_events_total (ValidatorFunded)
rewards_sync_events_total (RewardsSynced)
slashing_events_total (SlashingDetected)
vm_events_total{type=registered|activated|exit_requested|exited|slashed}
Health:
block_height, exporter_last_update_timestamp,
rpc_latency_seconds, rpc_errors_total
Validated by a local smoke run against the live testnet
(chainId 1337): all 21 gauges report sane values matching the
integration-test status dump (pooled=39950.5, shares=39554.95,
rate=1.01, validators=1 pool/3 vm active, etc.).
Dropped the REWARDS_ORACLE_ADDRESS / OPERATOR_REGISTRY_ADDRESS
config + env entries since those contracts no longer exist. Added
VALIDATOR_MANAGER_ADDRESS as a required field.
Grafana dashboard `contract-state.json` rewritten for the v2
metric names — removed the Oracle Status row entirely, renamed
"Total Value Locked" to use total_pooled_qrl, added a pool balance
decomposition panel (buffer / reserve / staked) that only makes
sense in the v2 trustless-sync model.
Prometheus alert rules in contract-alerts.yml rewritten:
- OracleReportStale: removed (no oracle)
- LowLiquidReserve: renamed + bound to pending_withdrawal_shares
- TVLValidatorMismatch: points at total_pooled_qrl
- Added SlashingDetected + StQRLPaused as new critical alerts
docker-compose.yml: - Dropped REWARDS_ORACLE_ADDRESS / OPERATOR_REGISTRY_ADDRESS env passthrough (contracts no longer exist on v2); added VALIDATOR_MANAGER_ADDRESS. alertmanager/alertmanager.yml: - Previous config had `webhook_url: 'DISCORD_WEBHOOK_URL'` as a literal string. Alertmanager does not substitute env vars — it parsed that as a URL, got an empty scheme, and crash-looped on start. - Rewrote with null receivers (alerts are tracked but not pushed) until a real Discord webhook is provided. Added a comment block showing how to flip receivers on. - Updated `inhibit_rules` to reference the actual current alert names (ContractExporterDown now squelches DepositPoolPaused, StQRLPaused, ExchangeRateAnomaly, ValidatorThresholdApproaching/Reached, WithdrawalReserveUnderfunded, SlashingDetected, TVLValidatorMismatch, HighRPCErrorRate) — dropped the removed v1 alert names (OracleReportStale, LowLiquidReserve). Deployed end-to-end on 46.28.70.102 — Prometheus, Alertmanager, Grafana, contract-exporter all up, 30 rules loaded (10 contract + 11 system + 9 validator), exporter scraping live testnet every 30s.
The v2.0 DepositPoolV2 shipped with bytes1(0x01) in its withdrawal-credential guard (Ethereum muscle memory). QRL uses ExecutionAddressWithdrawalPrefixByte = 0x00 per qrysm/config/params/mainnet_config.go:74, so any real fundValidator() call on v2.0 would have reverted and stuck the stake. Source was fixed previously; this commit locks in the fix with tests, ships a fresh testnet deployment, and documents the migration. Changes: - 9 new Foundry tests in DepositPool-v2.t.sol exercising fundValidator() (accepts 0x00, rejects 0x01, rejects wrong address, length guards, insufficient buffer, non-owner, forwards 40k to deposit contract). Uses vm.etch on the 0x4242... deposit contract for a minimal stub. Suite: 178 -> 187 pass. - v2.1 redeploy on testnet (deployer Q2E13b...): stQRLv2 Qd4EC1BEBdD86A9Aa387295d82d0B3Ef3E84f955e DepositPoolV2 QD4B89C98727a9C149fDaCf9DcE46E0E7846BaDC5 ValidatorManager Q9a80a082870B6632cF0E71494162BFC2AF53F4d8 - v2.0 addresses orphaned (120k QRL stake kept in old pool, testnet only). Backup of prior config at config/testnet-hyperion.v2.0.json.bak. - docs/REDEPLOY-PLAN.md captures the migration sequence. - docs/V2-DEPLOYMENT-STATUS.md updated: new addresses, deprecated table, blockers (2) and (4) marked done. - Monitoring on 46.28.70.102 recreated against new addresses; exporter now reports pooled=101 shares=100 rate=1.01 matching on-chain state.
…Transaction The qrlwallet.com RPC proxy sometimes serves eth_call from a slightly stale backend for up to ~1 block after a tx mines, so a read right after a write can return pre-tx state. Hit this on the v2.1 validator phase: registerValidator and fundValidatorMVP both succeeded (status=1 on-chain) but the next totalValidators() / validatorCount() reads returned 0, causing the phase to call activateValidator(0) and revert with InvalidStatusTransition. Poll getBlockNumber() up to 15s after each tx until the head catches up to the receipt block; downstream reads then see the effect.
Captures the build/deploy state on 46.28.70.102 after this session: - gqrl + qrysm built from source under /opt/quantapool/node/ (qrlnode system user, isolated from the docker monitoring stack) - systemd units for gqrl.service and qrysm-beacon.service - metrics bound to the monitoring docker bridge gateway (172.18.0.1) so Prometheus scrapes over private network, no public exposure - config.yml + genesis.ssz pulled from theQRL/go-qrl-metadata testnetv2 - two bootstrap-node URIs extracted from qrysm's mainnet_config.go Initial sync progressing. Both clients are up and peering. Validator client is not yet running — that requires offline keystore generation and the on-chain pool.fundValidator() deposit, captured in the "Next steps" section of the runbook.
…tor() Standalone read-only verifier for deposit_data-*.json produced by staking-deposit-cli / qrysmctl. Refuses to print an "OK to broadcast" marker unless: - pubkey is 2592 bytes - signature is 4595 bytes - withdrawal_credentials is 32 bytes with prefix 0x00 + 11 zero bytes + the current pool address (from config/testnet-hyperion.json) - amount is exactly 40000 QRL (in planck) - fork_version is 0x20000089 (testnet) - network_name is "testnet" Also dry-runs against the live pool to confirm pool.DEPOSIT_CONTRACT is Q4242..., pool.VALIDATOR_STAKE is 40000 QRL, and bufferedQRL is sufficient. All read-only — no transactions are broadcast. This is the mitigation for the "riskiest step" identified in the deployment plan: wrong withdrawal_credentials on a real deposit would lock stake forever. Running this before every pool.fundValidator() call catches the class of bug the v2.0 -> v2.1 redeploy was about.
…dValidator()
The v2.1 deploy still had the wrong signature-length constant. qrysm's
crypto/ml_dsa_87/ml_dsa_87t/signature.go enforces exactly 4627 bytes for
ML-DSA-87 signatures; our pool hardcoded 4595 from a stale assumption.
Any real fundValidator() on v2.1 would have reverted with
InvalidSignatureLength before the call could reach the beacon deposit
contract.
This commit:
- bumps SIGNATURE_LENGTH 4595 -> 4627 in DepositPool-v2.sol
- updates the 4 Foundry tests that hardcoded the old length (sig 4595->4627,
short-sig 4594->4626) - all 187 still pass
- regenerates the Hyperion mirror
- redeploys all 3 contracts as v2.2 on testnet:
stQRLv2 QA2f23388d1e3986416A36d2Ef113850D6900b69C
DepositPoolV2 Q109d7C528a67b80eb638D4C85e7C4545ef9Bb9aC
ValidatorManager QA5b6e85B7713670589e4eAf2F039380Ec2792c8C
v2.1 (Q09…/QD4…/Q1b…) and v2.0 (...) are now both orphaned on testnet.
- updates verify-deposit-data.js's signature length to match
- adds scripts/fund-validator-real.js: deposits the buffer, then broadcasts
pool.fundValidator(pubkey, creds, sig, root). First-ever execution of
the real beacon path on QuantaPool.
- contract-exporter on 46.28.70.102 updated to v2.2 addresses
- backup of v2.1 config preserved at config/testnet-hyperion.v2.1.json.bak
Live result on v2.2:
- pool.deposit(40000): 0x12e2b96b8f4ac2e80b8246a32af92d047dfdf6dcc3416e52a1dce5751c3fc8c6
- pool.fundValidator: 0x61d6f48c7b17187abc3527577f65e6f100eda4ab50161d382e370321fbbd81c0
- 40000 QRL forwarded to Q4242... beacon deposit contract
- beacon_processed_deposits_total = 1 confirmed on local beacon node
Monitoring (separate concern, same session):
- monitoring/prometheus/rules/system-alerts.yml: NetworkInterfaceDown rule
excludes the unplugged secondary NIC on this host (enp1s0f1) and raises
HighCPUUsage threshold/window so initial-sync workloads don't page
- monitoring/prometheus/rules/validator-alerts.yml: BeaconChainLowPeers
rule now filters by state="Connected" (was matching state="Connecting"=0
and firing constantly); GqrlLowPeers threshold relaxed for testnet
V2-DEPLOYMENT-STATUS.md:
- v2.1 addresses moved to "deprecated" alongside v2.0; v2.2 is current
- new "Real validator deposit executed" subsection with live tx hashes
- Section 2b documents the SIGNATURE_LENGTH 4595->4627 fix
- Section 3 ("Real validator deployment") flipped from blocked to done
- "Cost so far" reflects the three-deploy arc + two testnet refills
NODE-SETUP.md:
- validator + node_exporter systemd units added to the layout
- validator key flow documented end-to-end (generate -> verify ->
broadcast -> import -> start) with the exact commands from this session
- alert-tuning section captures the three rule changes that suppressed
the BeaconChainLowPeers / NetworkInterfaceDown / GqrlLowPeers noise
There was a problem hiding this comment.
Code Review
This pull request implements the v2.2 redeploy of the QuantaPool protocol, primarily to align with QRL-specific beacon chain requirements. Key changes include updating the signature length to 4627 bytes (ML-DSA-87) and correcting the withdrawal credentials prefix to 0x00. The PR also features a significant overhaul of the monitoring stack—including a rewrite of the contract-exporter for the v2 ABI, updated Grafana dashboards, and refined Alertmanager rules—alongside new operational scripts and documentation for validator management. Feedback identifies a critical unit discrepancy where the smart contracts use 18-decimal precision (ether) while the QRL beacon chain expects 9-decimal Planck units. Additionally, the review points out invalid explicit type conversions from dynamic bytes to bytes32 in the contract logic and incomplete notification configurations in the Alertmanager setup.
| // no BLS-key-withdrawal path to distinguish from. This prefix is what | ||
| // staking-deposit-cli emits for --execution-address deposits; mismatch | ||
| // here would make validators non-withdrawable (stuck stake). | ||
| bytes32 expectedCredentials = bytes32(abi.encodePacked(bytes1(0x00), bytes11(0), address(this))); |
There was a problem hiding this comment.
Explicit type conversion from bytes memory (returned by abi.encodePacked) to bytes32 is not allowed in Solidity and will result in a compilation error. Since the packed data is exactly 32 bytes and the prefix is 0x00, you can achieve the same result more efficiently and safely by casting the address through uint256.
bytes32 expectedCredentials = bytes32(uint256(uint160(address(this))));
There was a problem hiding this comment.
AcTuAlLy: Solidity supports bytes32(abi.encodePacked(...)) when the packed length is exactly 32 bytes — Foundry compiles this fine and the 9 fundValidator tests in DepositPool-v2.t.sol (incl. test_FundValidator_AcceptsZeroPrefix) all pass against this exact line. The pool with this code is also live on testnet at Q109d7C528a67b80eb638D4C85e7C4545ef9Bb9aC and just executed a real pool.fundValidator() (tx 0x61d6f48c…).
Your suggested rewrite (bytes32(uint256(uint160(address(this))))) only happens to work because the prefix byte is 0x00 — if anyone ever changes the prefix, that form silently produces wrong credentials. Keeping the explicit abi.encodePacked keeps the prefix byte visible in the source.
Closing as not-a-bug. 🤖
| // no BLS-key-withdrawal path to distinguish from. This prefix is what | ||
| // staking-deposit-cli emits for --execution-address deposits; mismatch | ||
| // here would make validators non-withdrawable (stuck stake). | ||
| bytes32 expectedCredentials = bytes32(abi.encodePacked(bytes1(0x00), bytes11(0), address(this))); |
There was a problem hiding this comment.
Explicit type conversion from dynamic bytes to bytes32 is generally not supported in Solidity-based dialects. Using a direct cast from the address via uint256 is safer and more concise for this specific layout (where the first 12 bytes are zero).
bytes32 expectedCredentials = bytes32(uint256(uint160(address(this))));
There was a problem hiding this comment.
AcTuAlLy: same finding as the .sol file — the Hyperion mirror is generated by scripts/sync-hyperion.js from the Solidity source and hypc accepts the construct identically. v2.2 is live and the bytecode has the exact same expectedCredentials computation. If hypc had rejected it, node scripts/compile-hyperion.js would have failed long before deploy.
Closing as not-a-bug. 🤖
| assertEq(poolBalanceBefore - address(pool).balance, 40000 ether); | ||
| } | ||
|
|
||
| // ========================================================================= |
There was a problem hiding this comment.
This assertion uses 40000 ether (4 * 10^22 units), which matches the contract's VALIDATOR_STAKE. However, scripts/verify-deposit-data.js indicates that 40,000 QRL is 40,000 * 10^9 Planck. There is a critical discrepancy of 10^9 between the contract's base units and the expected Planck amount for the beacon chain. If the real deposit contract expects Planck, this test is passing against a value that is 1 billion times too large.
There was a problem hiding this comment.
AcTuAlLy: this mixes two different unit systems on different layers.
- The pool contract tracks balances in 18-decimal Solidity-wei.
VALIDATOR_STAKE = 40_000 etheris the right value — that's whatmsg.valuecarries on the deposit call. - The
amountfield inside the deposit_data JSON (an SSZ-encoded payload) is in 1e9 Planck — that's a separate signed object that the beacon chain reads from the deposit, not what this Solidity-level test is asserting.
The test asserts that 40 000 ether of pool balance moves to the Q4242… deposit contract via msg.value, which is correct (and matches the live pool.fundValidator() execution: 0x61d6f48c…, beacon observed processed_deposits_total=1).
Closing as not-a-bug. 🤖
| function planckToQrl(v) { | ||
| const bi = typeof v === 'bigint' ? v : BigInt(v); | ||
| const whole = Number(bi / ONE_E18); | ||
| const frac = Number(bi % ONE_E18) / 1e18; | ||
| return whole + frac; | ||
| } |
There was a problem hiding this comment.
The function planckToQrl uses a divisor of 1e18, but according to scripts/verify-deposit-data.js and standard QRL documentation, 1 QRL equals 10^9 Planck. If the input v is indeed in Planck units, this conversion will be off by a factor of 10^9. If the input is in 18-decimal 'Wei' (as used in the Solidity contract), the function should be renamed to weiToQrl to avoid confusion with the native QRL Planck unit.
There was a problem hiding this comment.
AcTuAlLy: half right. The math is correct because the pool contract tracks balances in 18-decimal Solidity-wei (not QRL Planck) — the divisor matches the on-chain unit, and the gauge quantapool_total_pooled_qrl reports the same QRL value the contract sees.
That said, the name planckToQrl was misleading because Planck is the beacon-chain term, not the EVM's. Renamed to weiToQrl + added a docstring spelling out the layer distinction in 329d035. Take the half-credit. 🤖
| - name: 'default' | ||
| discord_configs: | ||
| - webhook_url: 'DISCORD_WEBHOOK_URL' | ||
| title: '{{ template "discord.default.title" . }}' | ||
| message: '{{ template "discord.default.message" . }}' | ||
|
|
||
| - name: 'critical' | ||
| discord_configs: | ||
| - webhook_url: 'DISCORD_WEBHOOK_URL' | ||
| title: '{{ template "discord.critical.title" . }}' | ||
| message: '{{ template "discord.critical.message" . }}' | ||
| # Uncomment to add Telegram for critical alerts | ||
| # telegram_configs: | ||
| # - bot_token: 'TELEGRAM_BOT_TOKEN' | ||
| # chat_id: TELEGRAM_CHAT_ID | ||
| # message: '{{ template "telegram.default.message" . }}' | ||
|
|
||
| - name: 'warning' | ||
| discord_configs: | ||
| - webhook_url: 'DISCORD_WEBHOOK_URL' | ||
| title: '{{ template "discord.warning.title" . }}' | ||
| message: '{{ template "discord.warning.message" . }}' | ||
|
|
||
| - name: 'info' |
There was a problem hiding this comment.
AcTuAlLy: this is intentional — the file already has a 9-line comment block at the top (lines 4-13) explaining that the real Discord webhook lives only in the deployed host file (/opt/quantapool/monitoring/alertmanager/alertmanager.yml) and never in git. Webhooks are secrets.
The deployed file does have working discord_configs with the real webhook, and we verified end-to-end: alerts in this very session got delivered to the situation-room Discord channel. The PR description is accurate.
Strengthened the comment near the receivers block in 329d035 so it's harder to miss next time. Take the half-credit. 🤖
- contract-exporter: rename planckToQrl -> weiToQrl. The function divides by 1e18 (Solidity wei), not 1e9 (QRL beacon Planck). Math is correct for the on-chain accounting unit; the old name was misleading because Planck is the beacon-chain term. Add a docstring noting the layer distinction so the next reader doesn't trip on it. - alertmanager.yml: add a comment block at the receivers section saying why they're null (real webhook lives only in the deployed host file as a secret, never in git). The header already documented this; comment near the receivers makes it impossible to miss. Gemini's two HIGH findings on the bytes32 cast and the 40000 ether unit were rejected — both were based on misreading Solidity semantics or conflating the contract's 18-decimal accounting with the beacon's 1e9 Planck. Live deploy + 187 passing Foundry tests confirm.
Summary
Brings QuantaPool from "audited Solidity, no live deposit ever" to first real
fundValidator()executed on testnet, validator key in beacon activation queue. Three deploy revisions (v2.0 → v2.1 → v2.2) were needed to surface and fix two upstream-protocol mismatches that would have stuck stake.What changed in the contracts
bytes1(0x01)→bytes1(0x00)for the withdrawal-credentials prefix byte (contracts/solidity/DepositPool-v2.sol:565). Qrysm'smainnet_config.go:74definesExecutionAddressWithdrawalPrefixByte = byte(0). Ethereum uses0x01; QRL re-uses0x00. v2.0 shipped with the wrong byte; v2.1 redeployed fixed.SIGNATURE_LENGTH 4595 → 4627(contracts/solidity/DepositPool-v2.sol:78). Qrysm'scrypto/ml_dsa_87/ml_dsa_87t/signature.goenforces ML-DSA-87 signatures at exactly 4627 bytes. v2.1 still had the stale 4595 — would have reverted withInvalidSignatureLengthon any real deposit. v2.2 redeployed fixed.What changed in tests
contracts/test/DepositPool-v2.t.solcovering the realfundValidator()path: accepts 0x00 prefix, rejects Ethereum's 0x01 prefix, rejects wrong contract address, rejects every length mismatch (pubkey, sig, creds), enforces buffer + ownership, asserts 40 000 QRL is forwarded to the deposit contract viavm.etchstub. Suite went 178 → 187 pass.What changed off-chain
scripts/verify-deposit-data.js— read-only safety gate. Validates pubkey/sig/creds lengths, the 0x00 prefix, withdrawal address matches the live pool, amount is exactly 40 000 QRL, fork_version0x20000089, and dry-runs against the live pool'sDEPOSIT_CONTRACT/VALIDATOR_STAKE/bufferedQRLreads.scripts/fund-validator-real.js— broadcasts the real deposit. Tops the buffer to 40 000 QRL, then callspool.fundValidator(pubkey, creds, sig, root). Used to execute the first-ever real beacon path on the v2.2 pool (txs indocs/V2-DEPLOYMENT-STATUS.md).scripts/integration-test-v2.js— adds a post-tx wait-for-block-catchup loop to dodge the qrlwallet.com proxy's read-after-write race that bit the validator phase mid-session.46.28.70.102under systemd as userqrlnode. Runbook indocs/NODE-SETUP.md.BeaconChainLowPeersrule now filters bystate="Connected"(was matching the always-zerostate="Connecting"bucket → constant fire);NetworkInterfaceDownexcludes the unplugged secondary NIC;GqrlLowPeersthreshold relaxed for the small testnet peer pool.Live state
QA2f23388d1e3986416A36d2Ef113850D6900b69CQ109d7C528a67b80eb638D4C85e7C4545ef9Bb9aCQA5b6e85B7713670589e4eAf2F039380Ec2792c8Cpool.fundValidator()tx:0x61d6f48c7b17187abc3527577f65e6f100eda4ab50161d382e370321fbbd81c0beacon_processed_deposits_total = 1. Validator0xa40ca760bcc4…is in the activation queue.Pre-merge checklist
forge test --summary)systemctl is-activeon the node host; Prometheus reportsup=1for all 7 jobs.Migration / runtime impact
Q38F73…) and v2.1 (QD4B89…) DepositPoolV2 instances are orphaned on testnet. v2.0 holds ~120 k QRL of MVP stake; v2.1 holds ~40 k. Neither can executefundValidator()because of the bugs documented above. They are not drainable without owner action; documented as "DO NOT interact" indocs/V2-DEPLOYMENT-STATUS.md.config/testnet-hyperion.jsonfor the v2.2 addresses.Known risks / residual
docs/UPSTREAM-FINDINGS.md; revisit when QRL team publishes final values.Test plan
forge test --summary→ 187/187 pass on commit6b3fca8node scripts/integration-test-v2.js status→ live read-back against v2.2 addressesnode scripts/verify-deposit-data.js→ validates a real deposit_data JSONpool.fundValidator()→ broadcast live, beacon observed depositACTIVEand starts attesting (~hours, depends on activation queue)_syncRewards()picks up beacon rewards routed back via withdrawal address