Summary
In validate_fees_of_event_v0, the Paid execution-event arm advertises a FeeResult that excludes additional_fixed_fee_cost, even though block execution charges it. Because CheckTx turns that returned FeeResult into gas_wanted, the advertised gas for DataContractCreate and DataContractUpdate under-reports the data-contract registration cost by exactly that fixed amount.
This is mempool/observability only — block execution re-derives and books the real fee independently, and gas_wanted does not gate block inclusion — so there is no consensus impact and no funds are lost or mis-booked. But CheckTx gas_wanted (and the fee surfaced to clients via CheckTx) is wrong by the registration cost, which can skew mempool gas accounting/admission and any client that trusts the advertised fee.
This is the same bug that was just fixed for the sibling PaidFromAddressInputs arm in #3793 (commit 23e0c01631); this issue tracks the symmetric fix for the Paid arm, which is a shipped, non-shielded path and so was intentionally left out of that PR's scope.
Affected transitions
The Paid event carries additional_fixed_fee_cost: Some(registration_cost) for:
DataContractCreate — packages/rs-drive-abci/src/execution/types/execution_event/mod.rs (DataContractCreateAction arm): registration_cost = data_contract_ref().registration_cost(platform_version), then ExecutionEvent::Paid { …, additional_fixed_fee_cost: Some(registration_cost), … }.
DataContractUpdate — same file, DataContractUpdateAction arm (identical pattern).
All other Paid constructions pass additional_fixed_fee_cost: None, so they are unaffected.
Root cause
packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs, the ExecutionEvent::Paid { .. } arm (~L102–164):
let mut required_balance = estimated_fee_result.total_base_fee();
if let Some(additional_fee_cost) = additional_fee_cost {
required_balance = required_balance.saturating_add(*additional_fee_cost);
}
if balance_after_principal_operation >= required_balance {
Ok(ConsensusValidationResult::new_with_data(
estimated_fee_result, // <-- metered-only; the fixed cost is NOT folded in
))
} else {
// …error branch also returns the metered-only `estimated_fee_result`…
}
The additional_fee_cost is used to compute required_balance (the funding check) but is not folded into the returned FeeResult. The returned value is the metered estimate only.
Meanwhile block execution does charge it — packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs (the Paid booking, ~L69–73):
if let Some(additional_fixed_fee_cost) = additional_fixed_fee_cost {
individual_fee_result.processing_fee = individual_fee_result
.processing_fee
.saturating_add(additional_fixed_fee_cost);
}
let balance_change = individual_fee_result.into_balance_change(identity.id);
And CheckTx uses the validation result for gas_wanted — packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs (~L186–188):
let (estimated_fee_result, errors) = validation_result.into_data_and_errors()?;
…
check_tx_result.fee_result = Some(estimated_fee_result); // -> gas_wanted
Net: for DataContractCreate/DataContractUpdate, execution charges metered + registration_cost, but CheckTx advertises metered only.
Impact
Proposed fix
Mirror the fix applied to PaidFromAddressInputs in #3793: fold additional_fixed_fee_cost into the advertised processing_fee so the returned FeeResult matches what execution books. The required_balance value is unchanged.
// Fold the fixed cost into the advertised fee so CheckTx gas_wanted matches what
// execution charges (execution adds it to processing_fee). Advisory only — the booked
// fee is re-derived independently.
if let Some(additional_fee_cost) = additional_fee_cost {
estimated_fee_result.processing_fee = estimated_fee_result
.processing_fee
.saturating_add(*additional_fee_cost);
}
let required_balance = estimated_fee_result.total_base_fee();
// …then return `estimated_fee_result` in both the sufficient- and insufficient-balance branches.
Add a regression test analogous to validate_fees_of_event_v0_paid_from_address_inputs_advertises_additional_fixed_fee_cost (added in #3793): a Paid event with empty operations (metered = 0) and additional_fixed_fee_cost = X, asserting the advertised processing_fee == X.
References
Summary
In
validate_fees_of_event_v0, thePaidexecution-event arm advertises aFeeResultthat excludesadditional_fixed_fee_cost, even though block execution charges it. Because CheckTx turns that returnedFeeResultintogas_wanted, the advertised gas forDataContractCreateandDataContractUpdateunder-reports the data-contract registration cost by exactly that fixed amount.This is mempool/observability only — block execution re-derives and books the real fee independently, and
gas_wanteddoes not gate block inclusion — so there is no consensus impact and no funds are lost or mis-booked. But CheckTxgas_wanted(and the fee surfaced to clients viaCheckTx) is wrong by the registration cost, which can skew mempool gas accounting/admission and any client that trusts the advertised fee.This is the same bug that was just fixed for the sibling
PaidFromAddressInputsarm in #3793 (commit23e0c01631); this issue tracks the symmetric fix for thePaidarm, which is a shipped, non-shielded path and so was intentionally left out of that PR's scope.Affected transitions
The
Paidevent carriesadditional_fixed_fee_cost: Some(registration_cost)for:DataContractCreate—packages/rs-drive-abci/src/execution/types/execution_event/mod.rs(DataContractCreateActionarm):registration_cost = data_contract_ref().registration_cost(platform_version), thenExecutionEvent::Paid { …, additional_fixed_fee_cost: Some(registration_cost), … }.DataContractUpdate— same file,DataContractUpdateActionarm (identical pattern).All other
Paidconstructions passadditional_fixed_fee_cost: None, so they are unaffected.Root cause
packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs, theExecutionEvent::Paid { .. }arm (~L102–164):The
additional_fee_costis used to computerequired_balance(the funding check) but is not folded into the returnedFeeResult. The returned value is the metered estimate only.Meanwhile block execution does charge it —
packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs(thePaidbooking, ~L69–73):And CheckTx uses the validation result for
gas_wanted—packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs(~L186–188):Net: for
DataContractCreate/DataContractUpdate, execution chargesmetered + registration_cost, but CheckTx advertisesmeteredonly.Impact
execute_event) is correct and includes the registration cost; the validationFeeResultis advisory.gas_wantedis wrong by the registration cost (a fixed, non-trivial protocol fee), affecting mempool gas accounting/ordering and any client reading the CheckTx-reported fee.Proposed fix
Mirror the fix applied to
PaidFromAddressInputsin #3793: foldadditional_fixed_fee_costinto the advertisedprocessing_feeso the returnedFeeResultmatches what execution books. Therequired_balancevalue is unchanged.Add a regression test analogous to
validate_fees_of_event_v0_paid_from_address_inputs_advertises_additional_fixed_fee_cost(added in #3793): aPaidevent with empty operations (metered = 0) andadditional_fixed_fee_cost = X, asserting the advertisedprocessing_fee == X.References
PaidFromAddressInputs(the transparentShield/IdentityCreateFromAddressespath): PR feat(drive): shielded fees for Shield/ShieldFromAssetLock + shield credit conservation #3793, commit23e0c01631.PaidFromAssetLockToPoolalready advertises its authoritative fee, so after this fix all fee-bearing execution-event arms would be consistent.