Skip to content

fix(ml-dsa): correct gas-key fee and meta-tx verify-compute charging#16064

Open
Wiezzel wants to merge 2 commits into
masterfrom
wiezzel/fix/gas-key-fee-charging
Open

fix(ml-dsa): correct gas-key fee and meta-tx verify-compute charging#16064
Wiezzel wants to merge 2 commits into
masterfrom
wiezzel/fix/gas-key-fee-charging

Conversation

@Wiezzel

@Wiezzel Wiezzel commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

This PR fixes two separate but related issues in how ML-DSA-65 (post-quantum) key costs are charged. Both are harmless for the classical ed25519/secp256k1 schemes (where the relevant quantities coincide), and both are gated behind the same new FixMlDsaCostCharging protocol feature.

  • Gas keys, exec fee priced on the wrong length. The gas-key host functions priced the AddKey/Transfer exec (storage) fee on the public key's wire length, while the pre-execution and refund paths priced it on the on-trie identifier length. For ML-DSA-65 these diverge (1953 wire vs 33 on-trie), so a contract-spawned receipt is over-reserved gas at the wire length but reconciled at the trie length, and the difference is neither burnt nor refunded, silently leaking total supply. The fix prices the exec (storage) fee on trie_id_len() and the send (transmission) fee on the wire length, applied consistently on both the transaction path (config.rs) and the runtime host-function path (both VM runners).
  • Meta transactions, verify compute metered on the wrong shard. The inner DelegateAction signature-verification compute was charged at transaction conversion on the signer/relayer shard, but the real verification runs at execution in apply_delegate_action on the receiver shard. For ML-DSA-65 (0.1 Tgas) this let the verification work escape the receiver shard's compute_limit. The fix charges only the gas at conversion (the signer still pays economically) and meters the verification compute on the receiver shard that actually performs it.

Both changes alter deterministic gas/compute accounting, so they are protocol-version-gated. Regression tests are added for each.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.25490% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.32%. Comparing base (55a0234) to head (dedccc0).

Files with missing lines Patch % Lines
runtime/near-vm-runner/src/logic/logic.rs 52.94% 8 Missing ⚠️
runtime/runtime/src/actions.rs 94.28% 0 Missing and 2 partials ⚠️
runtime/runtime/src/config.rs 94.28% 1 Missing and 1 partial ⚠️
core/parameters/src/parameter_table.rs 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #16064      +/-   ##
==========================================
+ Coverage   73.31%   73.32%   +0.01%     
==========================================
  Files         857      857              
  Lines      189233   189318      +85     
  Branches   189233   189318      +85     
==========================================
+ Hits       138731   138823      +92     
+ Misses      46068    46066       -2     
+ Partials     4434     4429       -5     
Flag Coverage Δ
pytests-nightly 1.22% <0.00%> (-0.01%) ⬇️
unittests 69.97% <76.47%> (+<0.01%) ⬆️
unittests-nightly 69.98% <78.43%> (+<0.01%) ⬆️
unittests-spice 65.59% <83.33%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Wiezzel
Wiezzel force-pushed the wiezzel/fix/gas-key-fee-charging branch 2 times, most recently from 9519759 to a8b5dba Compare July 14, 2026 14:51
@Wiezzel
Wiezzel requested a review from jancionear July 14, 2026 14:55
@Wiezzel
Wiezzel marked this pull request as ready for review July 14, 2026 14:55
@Wiezzel
Wiezzel requested a review from a team as a code owner July 14, 2026 14:55
@github-actions

Copy link
Copy Markdown

Pull request overview

Fixes a total-supply leak in the gas-key host functions for ML-DSA-65 keys. The VM host path priced the AddKey/TransferToGasKey exec fee on wire length (PublicKey::len() = 1953 for ML-DSA-65), while the pre-execution and refund paths priced it on the on-trie identifier length (trie_id_len() = 33). A contract-spawned receipt over-reserved gas at the wire length, then reconciled at the trie length — the delta was neither burnt nor refunded, silently reducing total supply. Symmetrically, the runtime total_send_fees path priced the send fee on trie_id_len() when it should reflect wire bytes. Both are corrected: exec uses trie_id_len(), send uses len(), gated behind FixGasKeyFeeCharging at protocol v153 (nightly). ed25519/secp256k1 are unaffected because len() == trie_id_len().

Changes:

  • New FixGasKeyFeeCharging protocol feature at v153; new fix_gas_key_fee_charging VM config bit and its parameter/config-store wiring.
  • runtime/near-vm-runner: added gas_key_exec_pk_len() helper; both the fast VMLogic path and the wasmtime path now compute the exec fee on trie_id_len() when the fix is enabled (with a wire-length fallback if decoding fails).
  • runtime/runtime/src/config.rs: added gas_key_send_pk_len(); total_send_fees for TransferToGasKey / WithdrawFromGasKey now uses len() when the fix is enabled.
  • Two nightly regression tests: a supply-conservation test that spawns an ML-DSA-65 gas key from a contract and asserts no silent leak, and a send-fee-scales-with-wire-length test.

Reviewed changes

Per-file summary
File Description
core/parameters/res/runtime_configs/153.yaml New protocol config diff enabling fix_gas_key_fee_charging.
core/parameters/res/runtime_configs/parameters.snap Snapshot: adds the new parameter.
core/parameters/res/runtime_configs/parameters.yaml Adds fix_gas_key_fee_charging: false as default.
core/parameters/res/runtime_configs/parameters_testnet.yaml Same, for testnet baseline.
core/parameters/src/config_store.rs Wires the 153.yaml diff into the config store.
core/parameters/src/parameter.rs New FixGasKeyFeeCharging parameter enum variant.
core/parameters/src/parameter_table.rs Loads the parameter into RuntimeConfig.
core/parameters/src/snapshots/*__153.json.snap New expected snapshots at v153 (mainnet + testnet).
core/parameters/src/vm.rs New fix_gas_key_fee_charging bit on Config; enabled in nightly.
core/primitives-core/src/version.rs New FixGasKeyFeeCharging protocol feature at 153.
runtime/near-vm-runner/src/logic/logic.rs Adds gas_key_exec_pk_len; three gas-key host fns compute exec_pk_len before charging fees.
runtime/near-vm-runner/src/wasmtime_runner/logic.rs Same fix mirrored in the wasmtime runner.
runtime/runtime/src/config.rs New gas_key_send_pk_len; TransferToGasKey/WithdrawFromGasKey send paths use it.
runtime/runtime/src/tests/apply.rs Two nightly regression tests.

Findings

No blocking issues found. The fix is correct, cleanly gated, and consistent across both VM runners and the runtime send-fee path. Verified:

  • Pre-fix behavior is preserved when the feature is disabled: for a successfully decoded key, pk.len() == public_key_len (borsh try_from_slice requires exact consumption), so Ok(pk) => pk.len() in gas_key_exec_pk_len matches the old public_key_len as usize; on decode failure the wire_len fallback matches too.
  • Exec (storage) and send (transmission) sides are now consistent end-to-end (host path ↔ pre-exec/refund path).
  • Feature is nightly-only; stable behavior is unchanged.

Non-blocking (nits, follow-ups, suggestions):

  • core/parameters/src/vm.rs:689-693 — the doc comment for fix_gas_key_fee_charging describes only the exec-fee half of the fix ("rather than pricing the exec fee on the wire length"); the runtime send-fee side was also wrong (priced on trie_id_len()) and is corrected by this flag. Worth a sentence so a future reader doesn't think the flag is exec-only.
  • runtime/runtime/src/tests/apply.rs:4478 — the for round in 0..12 cap is asserted via settled, which is good, but the round count is a magic number. If the receipt cascade for a nonces=3 ML-DSA-65 AddKey ever grows (e.g. someone adds a follow-up receipt), the test starts silently failing on the cap rather than the real invariant. A brief comment noting why 12 is enough today (or bumping it with a comment) would help.
  • runtime/runtime/src/tests/apply.rs:4559 — the assertion computes wire_len(ml_dsa_65) - wire_len(ed25519) and send_fee(ml_dsa_65) - send_fee(ed25519) as u64; both directions rely on ML-DSA-65 being strictly larger. That's true today, but consider using checked_sub or asserting the ordering first so a future key-type reshuffle doesn't produce a confusing underflow-panic instead of an assertion failure.
  • Consider a symmetric nightly test for the AddGasKey transaction path (goes through total_send_fees via permission_send_fees) — the current test_gas_key_transfer_send_fee_uses_wire_length only covers TransferToGasKey. Not required, since the fix uses the same helper, but it would close the loop on the four host-path/runtime-path combinations.

✅ Approved

@Wiezzel
Wiezzel enabled auto-merge July 15, 2026 11:43
@Wiezzel
Wiezzel disabled auto-merge July 15, 2026 11:43
@Wiezzel
Wiezzel force-pushed the wiezzel/fix/gas-key-fee-charging branch 2 times, most recently from 879eb41 to f4791d4 Compare July 20, 2026 09:48
@Wiezzel Wiezzel changed the title fix(gas-keys): charge exec fee on stored length, not wire length fix(ml-dsa): correct gas-key fee and meta-tx verify-compute charging Jul 20, 2026
@Wiezzel
Wiezzel force-pushed the wiezzel/fix/gas-key-fee-charging branch from f4791d4 to 3e01fba Compare July 20, 2026 10:14
Wiezzel added 2 commits July 20, 2026 16:47
…er shard

The inner DelegateAction signature verification cost was charged (gas and
compute) at tx conversion on the signer/relayer shard, but the actual verify
runs at execution in apply_delegate_action on the receiver shard. For ML-DSA-65
inner signers (0.1 Tgas compute) this let the verify work escape the receiver
shard's compute_limit: naming delegate-senders onto a target shard and relayers
elsewhere concentrates un-budgeted verify compute on that shard.

Meter the inner-delegate verify compute on the receiver shard instead: charge
only its gas at conversion and add its compute to result.compute_usage in
apply_delegate_action. Gas and balances are unchanged.

This is the same class of ML-DSA-65 cost mislocation as the gas-key fee fix, so
gate it behind the same protocol feature, renamed FixGasKeyFeeCharging ->
FixMlDsaCostCharging to cover both.
@Wiezzel
Wiezzel force-pushed the wiezzel/fix/gas-key-fee-charging branch from 3e01fba to dedccc0 Compare July 20, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant