From b729333f8c8b7c93239c45b77c9d8882f95a29fa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 19:05:21 +0700 Subject: [PATCH 1/2] fix(platform)!: keys with a budget or an expiry sign no data contract create or update (PV15) A budget counts what the transitions a key signs take from its identity, and an expiry ends its signing. A data contract commits its owner beyond the transition that sets it up or changes it, so from protocol version 15 only a key without limits may sign a DataContractCreate or a DataContractUpdate. validate_state_transition_identity_signed 2 runs v1 and then refuses a signing key that carries a budget or an expiry on those two transitions with PublicKeyWithLimitsCannotSignDataContractTransitionError (20018). The refusal is unpaid: the identity balance, the key budget and the nonce stay as they are. DRIVE_ABCI_VALIDATION_VERSIONS_V11 selects it; protocol version 14 keeps v1. Co-Authored-By: Claude Fable 5.2 --- book/src/data-model/key-limits.md | 20 +- docs/protocol/authentication-key-limits.md | 17 + packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../src/errors/consensus/signature/mod.rs | 2 + ...not_sign_data_contract_transition_error.rs | 51 +++ .../consensus/signature/signature_error.rs | 15 + .../mod.rs | 12 +- .../v2/mod.rs | 77 ++++ .../batch/tests/key_limits.rs | 340 ++++++++++++++++++ .../identity_key_limits_update/tests.rs | 77 +++- .../drive_abci_validation_versions/v11.rs | 13 +- .../rs-platform-version/src/version/v15.rs | 12 +- .../src/errors/consensus/consensus_error.rs | 5 + 13 files changed, 618 insertions(+), 24 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/signature/public_key_with_limits_cannot_sign_data_contract_transition_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v2/mod.rs diff --git a/book/src/data-model/key-limits.md b/book/src/data-model/key-limits.md index 6a60a578641..2df70d1142d 100644 --- a/book/src/data-model/key-limits.md +++ b/book/src/data-model/key-limits.md @@ -288,6 +288,19 @@ The second term is `desired_removed_balance` from `FeeResult::into_balance_chang This mirrors the identity balance so closely on purpose. `BalanceChange::RemoveFromBalance` already carries a `required_removed_balance` (storage) and a `desired_removed_balance` (storage plus processing), and an identity that can cover the first but not the second goes into processing debt. A key budget is the same rule with the debt replaced by "and then the key is done". +## What a Limited Key May Not Sign + +The budget rule counts what the transitions *signed with the key* take from the identity, and an expiry ends the key's signing. Two kinds of transition reach past both, and a key with a budget or an expiry is refused on them at signature validation, unpaid: + +| Transition | From | Error | Why | +|---|---|---|---| +| `IdentityKeyLimitsUpdate` | protocol version 14 | `PublicKeyWithLimitsCannotUpdateKeyLimitsError` 20017 | a limited key must not top itself up | +| `DataContractCreate`, `DataContractUpdate` | protocol version 15 | `PublicKeyWithLimitsCannotSignDataContractTransitionError` 20018 | a data contract commits its owner beyond the transition that sets it up or changes it | + +`validate_state_transition_identity_signed` 2 makes the second refusal. It wraps v1 and adds one question: is this a contract create or update, and does the key that signed carry limits? The rule is about the transition kind and not about what the contract says, so it does not have to follow the contract keywords. + +Contract bounds never reached this path: a bound AUTHENTICATION key signs only batches (`ContractBoundedKeyNonBatchError` 20013). So the keys the rule changes anything for are unbound limited keys at CRITICAL or HIGH level, the levels a contract create accepts. + ## Where the Checks Run Three stages enforce limits at signing time. Which check goes where follows from what each stage knows: @@ -461,7 +474,7 @@ What the query returns is the state as of the last committed block. A transition ## Versioning Touchpoints -Everything is gated to protocol version 14. Tables that protocol version 14 already owned (it was unreleased at the time) were amended in place; one new table was needed. +Everything but the last row is gated to protocol version 14. Tables that protocol version 14 already owned (it was unreleased at the time) were amended in place; one new table was needed. The last row is the one change protocol version 15 makes. | Table | Slot | Change | |---|---|---| @@ -479,8 +492,9 @@ Everything is gated to protocol version 14. Tables that protocol version 14 alre | `DRIVE_IDENTITY_METHOD_VERSIONS_V2` | `update.update_identity_key_limits`, `keys.budget.add_to_identity_key_budget` | new, `None` → `Some(0)` | | `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1` to `_V4` | `convert_to_high_level_operations.identity_key_limits_update_transition` | new, 0 | | `STATE_TRANSITION_SERIALIZATION_VERSIONS_V1` to `_V3` | `identity_key_limits_update_state_transition` | new, 0 | +| `DRIVE_ABCI_VALIDATION_VERSIONS_V11` (protocol version 15) | `validate_state_transition_identity_signed` | 1 → 2: v1, then a limited key is refused on a contract create or update | -Two of these are worth a second look. Identity signature validation v1 and shielded proof validation v1 were introduced for protocol version 14 by the contract bounds work and had not shipped yet, so they were [extended in place](../contributing/coding-conventions.md#shipped-generations-are-frozen) rather than given a v2. `validate_fees_of_event` and `execute_event` had shipped, so they got new generations, and those generations delegate to v0 for every event they do not handle instead of copying it. +Two of these are worth a second look. Identity signature validation v1 and shielded proof validation v1 were introduced for protocol version 14 by the contract bounds work and had not shipped yet, so they were [extended in place](../contributing/coding-conventions.md#shipped-generations-are-frozen) rather than given a v2. By the time the contract rule was added, identity signature validation v1 had shipped, so that rule is a v2 that calls v1. `validate_fees_of_event` and `execute_event` had shipped, so they got new generations, and those generations delegate to v0 for every event they do not handle instead of copying it. ## Fees @@ -502,6 +516,7 @@ This change is the consensus core. Known gaps, all deliberate: - **dpp** (`identity_public_key/v1`, `public_key_in_creation/v1`, `validate_identity_public_keys_structure/v1`, `state_transition/mod.rs`): the version 1 round trip; the version 0 encoding unchanged byte for byte; the JSON shape; the expiry boundary; limits surviving the conversions and being signed over; the structure rules; the protocol version 13 and 14 sides of the decode gate; frozen error discriminants. - **drive** (`drive/identity/key/budget`): the budget written on key add and on identity create; two budgeted keys in one batch; the deduction stopping at zero and never changing storage; estimated at least actual; protocol version 13 untouched. - **drive-abci** (`batch/tests/key_limits.rs`), end to end through `process_raw_state_transitions` and `check_tx`: the exact deduction; the rule pinned one credit either side of the storage fee; the user fee increase counted up front; the expiry boundary; both limits on one key; a paid failure spending from the budget; an invalid transition through an unusable key not being charged; mempool admission. +- **drive-abci** (`batch/tests/key_limits.rs`, `data_contracts`): a contract create and a contract update signed by a key with a budget, or with only an expiry, refused unpaid with 20018 and kept out of the mempool; a key without limits still creating and updating; protocol version 14 unchanged. - **drive-abci** (`identity_key_limits_update/tests.rs`), end to end: a spent key topped up and admitted again; an expired key revived by an extension and refused while it stays expired; each refusal pinned to its code, paid ones bumping the nonce; MASTER and unlimited CRITICAL signers accepted, a limited or HIGH one refused; the mempool; the execution proof; protocol version 13. **drive** (`update_identity_key_limits`): the total and the remaining budget growing by the same amount; the expiry alone; the estimate covering the rewrite; the whole database consistent after the reference refresh; the addition to the remaining budget; protocol version 13. - **drive-abci** (`identity_update` `key_limits`, `identity_create_from_shielded_pool/tests.rs`): registration storing a version 1 key with its whole budget left; an already expired key as a paid failure; limits on TRANSFER and MASTER keys and a zero budget as unpaid; the shielded creation refusing a key with either limit and accepting a version 1 key without any. The builder has the same pair of cases in `rs-dpp` (`shielded/builder/identity_create_from_shielded_pool.rs`). @@ -523,6 +538,7 @@ cargo test -p drive-abci --lib -- identity_key_limits_update - Give `expires_at` in milliseconds of block time. If an SDK offers a relative TTL, resolve it to an absolute time before signing, since the absolute time is what gets signed. - Keep every new cost term on the correct side of the budget rule. If a transition gains a cost that is chosen by the signer or priced up front, it belongs in `required_from_key_budget`. - Check any new field on the public key against the shielded sighash preimage, not only against `PlatformSignable`. +- Ask of every new transition an AUTHENTICATION key can sign whether it commits the identity beyond that one transition. If it does, refuse a key with limits on it in identity signature validation, as contract creates and updates are. **Do not:** - Add a field to `IdentityPublicKeyV0` or `IdentityPublicKeyInCreationV0`. Every key in state is one, and there is no version byte to tell old bytes from new. diff --git a/docs/protocol/authentication-key-limits.md b/docs/protocol/authentication-key-limits.md index e78f4563e98..3657e232526 100644 --- a/docs/protocol/authentication-key-limits.md +++ b/docs/protocol/authentication-key-limits.md @@ -66,6 +66,23 @@ budget like a successful one. Storage refunds never add to a budget: it only goe What is left is kept by Platform next to the key, not in the key, and starts at the full budget. +## What a limited key may not sign + +A budget counts what the state transitions signed with the key take from the identity, and an +expiry ends the key's signing. Two kinds of transition reach past both, so a key that carries a +budget or an expiry may not sign them: + +- `IdentityKeyLimitsUpdate`, from protocol version 14 + (`PublicKeyWithLimitsCannotUpdateKeyLimitsError`, 20017): a limited key cannot raise limits, + its own included. +- `DataContractCreate` and `DataContractUpdate`, from protocol version 15 + (`PublicKeyWithLimitsCannotSignDataContractTransitionError`, 20018): a data contract commits + its owner beyond the transition that sets it up or changes it, so only a key without limits + may sign one, whatever the contract says. + +Both refusals are made at signature validation and are unpaid: the identity balance, the key +budget and the nonce stay as they are. + ## When a budgeted key is refused Before a state transition runs, everything it requires from the budget must fit in what is left: diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index eeeccb546ba..bb3f52b8c71 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -319,6 +319,7 @@ impl ErrorWithCode for SignatureError { Self::PublicKeyBudgetExhaustedError(_) => 20015, Self::PublicKeyExpiredError(_) => 20016, Self::PublicKeyWithLimitsCannotUpdateKeyLimitsError(_) => 20017, + Self::PublicKeyWithLimitsCannotSignDataContractTransitionError(_) => 20018, } } } diff --git a/packages/rs-dpp/src/errors/consensus/signature/mod.rs b/packages/rs-dpp/src/errors/consensus/signature/mod.rs index 0ba79c66b93..f15c5cee833 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/mod.rs @@ -38,6 +38,8 @@ mod public_key_budget_exhausted_error; pub use public_key_budget_exhausted_error::PublicKeyBudgetExhaustedError; mod public_key_with_limits_cannot_update_key_limits_error; pub use public_key_with_limits_cannot_update_key_limits_error::PublicKeyWithLimitsCannotUpdateKeyLimitsError; +mod public_key_with_limits_cannot_sign_data_contract_transition_error; +pub use public_key_with_limits_cannot_sign_data_contract_transition_error::PublicKeyWithLimitsCannotSignDataContractTransitionError; mod public_key_expired_error; pub use public_key_expired_error::PublicKeyExpiredError; diff --git a/packages/rs-dpp/src/errors/consensus/signature/public_key_with_limits_cannot_sign_data_contract_transition_error.rs b/packages/rs-dpp/src/errors/consensus/signature/public_key_with_limits_cannot_sign_data_contract_transition_error.rs new file mode 100644 index 00000000000..e96c5be1a09 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/public_key_with_limits_cannot_sign_data_contract_transition_error.rs @@ -0,0 +1,51 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::identity::KeyID; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Identity public key {public_key_id} carries a budget or an expiry and can not create or update a data contract: only a key without limits can")] +#[platform_serialize(unversioned)] +pub struct PublicKeyWithLimitsCannotSignDataContractTransitionError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + public_key_id: KeyID, +} + +impl PublicKeyWithLimitsCannotSignDataContractTransitionError { + pub fn new(public_key_id: KeyID) -> Self { + Self { public_key_id } + } + + pub fn public_key_id(&self) -> KeyID { + self.public_key_id + } +} + +impl From for ConsensusError { + fn from(err: PublicKeyWithLimitsCannotSignDataContractTransitionError) -> Self { + Self::SignatureError( + SignatureError::PublicKeyWithLimitsCannotSignDataContractTransitionError(err), + ) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs index 5901a0c4767..76b27559183 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs @@ -2,6 +2,7 @@ use crate::consensus::signature::ContractBoundedKeyNonBatchError; use crate::consensus::signature::ContractBoundedKeyOutOfBoundsError; use crate::consensus::signature::PublicKeyBudgetExhaustedError; use crate::consensus::signature::PublicKeyExpiredError; +use crate::consensus::signature::PublicKeyWithLimitsCannotSignDataContractTransitionError; use crate::consensus::signature::PublicKeyWithLimitsCannotUpdateKeyLimitsError; use crate::consensus::signature::{ BasicBLSError, BasicECDSAError, IdentityNotFoundError, InvalidIdentityPublicKeyTypeError, @@ -92,6 +93,12 @@ pub enum SignatureError { // Identity key limits update (protocol version 14). #[error(transparent)] PublicKeyWithLimitsCannotUpdateKeyLimitsError(PublicKeyWithLimitsCannotUpdateKeyLimitsError), + + // A key with limits may not create or update a data contract (protocol version 15). + #[error(transparent)] + PublicKeyWithLimitsCannotSignDataContractTransitionError( + PublicKeyWithLimitsCannotSignDataContractTransitionError, + ), } impl From for ConsensusError { @@ -158,5 +165,13 @@ mod tests { ), 17 ); + assert_eq!( + discriminant_of( + SignatureError::PublicKeyWithLimitsCannotSignDataContractTransitionError( + PublicKeyWithLimitsCannotSignDataContractTransitionError::new(1) + ) + ), + 18 + ); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs index 77f317fa4d2..dc51cf73ad8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs @@ -12,6 +12,8 @@ use crate::execution::validation::state_transition::common::validate_state_trans pub mod v0; mod v1; use v1::ValidateStateTransitionIdentitySignatureV1; +mod v2; +use v2::ValidateStateTransitionIdentitySignatureV2; pub trait ValidateStateTransitionIdentitySignature { #[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. @@ -59,9 +61,17 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { execution_context, platform_version, ), + 2 => self.validate_state_transition_identity_signed_v2( + drive, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "StateTransition::validate_state_transition_identity_signature".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v2/mod.rs new file mode 100644 index 00000000000..2b825686199 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v2/mod.rs @@ -0,0 +1,77 @@ +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_state_transition_identity_signed::v1::ValidateStateTransitionIdentitySignatureV1; +use dpp::consensus::signature::PublicKeyWithLimitsCannotSignDataContractTransitionError; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; +use dpp::identity::PartialIdentity; +use dpp::state_transition::StateTransition; +use dpp::validation::ConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; + +pub(super) trait ValidateStateTransitionIdentitySignatureV2 { + fn validate_state_transition_identity_signed_v2( + &self, + drive: &Drive, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ValidateStateTransitionIdentitySignatureV2 for StateTransition { + /// v2 runs the v1 checks, then refuses a signing key with a budget or an expiry on a + /// `DataContractCreate` and on a `DataContractUpdate`. + /// + /// A budget counts what the transitions the key signs take from the identity, and an expiry + /// ends the key's signing, while a data contract commits its owner beyond the transition + /// that sets it up or changes it. So a contract is something only a key without limits may + /// set up or change, whatever the contract says. + /// + /// Like every signature refusal it is unpaid: the identity balance, the key budget and the + /// nonce stay as they are. A key v1 already refuses (a spent budget) keeps v1's answer. + fn validate_state_transition_identity_signed_v2( + &self, + drive: &Drive, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let result = self.validate_state_transition_identity_signed_v1( + drive, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + )?; + if !matches!( + self, + StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) + ) { + return Ok(result); + } + // Looked up by id, as v1 does, so that only the key that signed is judged. + let limited_signing_key = result + .data + .as_ref() + .filter(|_| result.is_valid()) + .and_then(|identity| { + self.signature_public_key_id() + .and_then(|key_id| identity.loaded_public_keys.get(&key_id)) + }) + .filter(|key| key.has_limits()); + if let Some(key) = limited_signing_key { + return Ok(ConsensusValidationResult::new_with_error( + PublicKeyWithLimitsCannotSignDataContractTransitionError::new(key.id()).into(), + )); + } + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/key_limits.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/key_limits.rs index f64930db6a0..d11f878cbca 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/key_limits.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/key_limits.rs @@ -548,3 +548,343 @@ async fn should_keep_transitions_of_unusable_keys_out_of_the_mempool() { } } } + +/// A data contract commits its owner beyond the transition that sets it up or changes it. From +/// protocol version 15 a key with a budget or an expiry signs neither a contract create nor a +/// contract update. +mod data_contracts { + use super::*; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::DataContract; + use dpp::prelude::IdentityNonce; + use dpp::state_transition::data_contract_create_transition::methods::DataContractCreateTransitionMethodsV0; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; + use dpp::state_transition::data_contract_update_transition::methods::DataContractUpdateTransitionMethodsV0; + use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; + use dpp::tests::json_document::json_document_to_contract_with_ids; + + const PUBLIC_KEY_WITH_LIMITS_CANNOT_SIGN_DATA_CONTRACT_TRANSITION: u32 = 20018; + + const CONTRACT_PATH: &str = + "tests/supporting_files/contract/dashpay/dashpay-contract-all-mutable.json"; + + /// An identity whose signing key is stored with the given limits, on a chain at the given + /// protocol version. + struct ContractSigner { + platform: TempPlatform, + /// The identity as the signer knows it: its signing key carries no limits there. + identity: Identity, + signer: SimpleSigner, + signing_key: IdentityPublicKey, + version: &'static PlatformVersion, + } + + impl ContractSigner { + fn new( + version: &'static PlatformVersion, + budget: Option, + expires_at: Option, + ) -> Self { + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, signing_key) = + setup_identity_without_adding_it(958, dash_to_credits!(2)); + let mut stored_identity = identity.clone(); + if budget.is_some() || expires_at.is_some() { + stored_identity.add_public_key(signing_key.clone().with_limits(budget, expires_at)); + } + platform + .drive + .add_new_identity( + stored_identity, + false, + &BlockInfo::default(), + true, + None, + version, + ) + .expect("expected to add the identity"); + Self { + platform, + identity, + signer, + signing_key, + version, + } + } + + /// A contract of the identity, as a create with the first identity nonce names it + fn contract(&self) -> DataContract { + let mut contract = json_document_to_contract_with_ids( + CONTRACT_PATH, + Some(DataContract::generate_data_contract_id_v0( + self.identity.id(), + 1, + )), + Some(self.identity.id()), + false, + self.version, + ) + .expect("expected the contract"); + contract.set_config( + DataContractConfig::default_for_version(self.version) + .expect("expected the default config"), + ); + contract + } + + async fn contract_creation(&self) -> StateTransition { + DataContractCreateTransition::new_from_data_contract( + self.contract(), + 1, + &self.identity.clone().into_partial_identity_info(), + self.signing_key.id(), + &self.signer, + self.version, + None, + ) + .await + .expect("expected a contract create transition") + } + + /// Stores the contract, as a key without limits would have registered it, and returns + /// its update to version 2 signed by the signing key + async fn contract_update_of_a_stored_contract(&self) -> StateTransition { + let mut contract = self.contract(); + self.platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + None, + self.version, + ) + .expect("expected to store the contract"); + contract.set_version(2); + DataContractUpdateTransition::new_from_data_contract( + contract, + &self.identity.clone().into_partial_identity_info(), + self.signing_key.id(), + 1, + 0, + &self.signer, + self.version, + None, + ) + .await + .expect("expected a contract update transition") + } + + fn process( + &self, + transition: &StateTransition, + tx: &drive::grovedb::Transaction, + ) -> StateTransitionExecutionResult { + let state = self.platform.state.load(); + let result = self + .platform + .platform + .process_raw_state_transitions( + &vec![transition + .serialize_to_bytes() + .expect("expected to serialize")], + &state, + &BlockInfo { + time_ms: BLOCK_TIME_MS, + ..Default::default() + }, + tx, + self.version, + false, + None, + ) + .expect("expected to process the state transition"); + result.execution_results()[0].clone() + } + + fn balance(&self, tx: &drive::grovedb::Transaction) -> Credits { + self.platform + .drive + .fetch_identity_balance(self.identity.id().to_buffer(), Some(tx), self.version) + .expect("expected to fetch the balance") + .expect("expected a balance") + } + + fn remaining_budget(&self, tx: &drive::grovedb::Transaction) -> Option { + self.platform + .drive + .fetch_identity_key_remaining_budget( + self.identity.id().to_buffer(), + self.signing_key.id(), + Some(tx), + self.version, + ) + .expect("expected to fetch the remaining budget") + } + + fn identity_nonce(&self, tx: &drive::grovedb::Transaction) -> Option { + self.platform + .drive + .fetch_identity_nonce(self.identity.id().to_buffer(), true, Some(tx), self.version) + .expect("expected to fetch the identity nonce") + } + + /// Nothing was taken from the identity, from the key or from the nonce + fn assert_untouched(&self, budget: Option, tx: &drive::grovedb::Transaction) { + assert_eq!(self.balance(tx), self.identity.balance()); + assert_eq!(self.remaining_budget(tx), budget); + assert_eq!(self.identity_nonce(tx).unwrap_or_default(), 0); + } + } + + fn protocol_version_14() -> &'static PlatformVersion { + PlatformVersion::get(14).expect("expected protocol version 14") + } + + #[tokio::test] + async fn should_refuse_unpaid_a_contract_create_signed_by_a_key_with_a_budget() { + // The budget covers the registration many times over: what is refused is the kind of + // transition, not its price. + let budget = Some(dash_to_credits!(1.5)); + let setup = ContractSigner::new(PlatformVersion::latest(), budget, None); + let tx = setup.platform.drive.grove.start_transaction(); + + let execution = setup.process(&setup.contract_creation().await, &tx); + assert_unpaid_with_code( + &execution, + PUBLIC_KEY_WITH_LIMITS_CANNOT_SIGN_DATA_CONTRACT_TRANSITION, + ); + setup.assert_untouched(budget, &tx); + assert!( + setup + .platform + .drive + .fetch_contract( + setup.contract().id().to_buffer(), + None, + None, + Some(&tx), + setup.version + ) + .unwrap() + .expect("expected to look the contract up") + .is_none(), + "the contract was not registered" + ); + } + + #[tokio::test] + async fn should_refuse_unpaid_a_contract_create_signed_by_a_key_with_an_expiry() { + // An expiry alone is refused too, long before it is reached. + let setup = + ContractSigner::new(PlatformVersion::latest(), None, Some(BLOCK_TIME_MS * 1_000)); + let tx = setup.platform.drive.grove.start_transaction(); + + let execution = setup.process(&setup.contract_creation().await, &tx); + assert_unpaid_with_code( + &execution, + PUBLIC_KEY_WITH_LIMITS_CANNOT_SIGN_DATA_CONTRACT_TRANSITION, + ); + setup.assert_untouched(None, &tx); + } + + #[tokio::test] + async fn should_refuse_unpaid_a_contract_update_signed_by_a_key_with_limits() { + for (budget, expires_at) in [ + (Some(dash_to_credits!(1.5)), None), + (None, Some(BLOCK_TIME_MS * 1_000)), + ] { + let setup = ContractSigner::new(PlatformVersion::latest(), budget, expires_at); + let update = setup.contract_update_of_a_stored_contract().await; + let tx = setup.platform.drive.grove.start_transaction(); + + let execution = setup.process(&update, &tx); + assert_unpaid_with_code( + &execution, + PUBLIC_KEY_WITH_LIMITS_CANNOT_SIGN_DATA_CONTRACT_TRANSITION, + ); + setup.assert_untouched(budget, &tx); + } + } + + #[tokio::test] + async fn should_let_a_key_without_limits_create_and_update_a_contract() { + let setup = ContractSigner::new(PlatformVersion::latest(), None, None); + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&setup.contract_creation().await, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let setup = ContractSigner::new(PlatformVersion::latest(), None, None); + let update = setup.contract_update_of_a_stored_contract().await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&update, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_keep_a_contract_create_signed_by_a_key_with_limits_out_of_the_mempool() { + use crate::execution::check_tx::CheckTxLevel::FirstTimeCheck; + use crate::platform_types::platform::PlatformRef; + + let setup = + ContractSigner::new(PlatformVersion::latest(), Some(dash_to_credits!(1.5)), None); + let state = setup.platform.state.load(); + let platform_ref = PlatformRef { + drive: &setup.platform.drive, + state: &state, + config: &setup.platform.config, + core_rpc: &setup.platform.core_rpc, + }; + let raw = setup + .contract_creation() + .await + .serialize_to_bytes() + .expect("expected to serialize"); + + let check_result = setup + .platform + .check_tx(&raw, FirstTimeCheck, &platform_ref, setup.version) + .expect("expected to check tx"); + assert!( + matches!( + check_result.errors.as_slice(), + [error] if error.code() == PUBLIC_KEY_WITH_LIMITS_CANNOT_SIGN_DATA_CONTRACT_TRANSITION + ), + "{:?}", + check_result.errors + ); + } + + /// Protocol version 14 keeps `validate_state_transition_identity_signed` 1, so the blocks + /// committed under it replay with the same results. + #[tokio::test] + async fn should_let_a_key_with_limits_create_and_update_a_contract_at_protocol_version_14() { + let budget = dash_to_credits!(1.5); + let setup = ContractSigner::new(protocol_version_14(), Some(budget), None); + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&setup.contract_creation().await, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let paid_by_identity = setup.identity.balance() - setup.balance(&tx); + assert!(paid_by_identity > 0); + assert_eq!(setup.remaining_budget(&tx), Some(budget - paid_by_identity)); + + let setup = ContractSigner::new(protocol_version_14(), Some(budget), None); + let update = setup.contract_update_of_a_stored_contract().await; + let tx = setup.platform.drive.grove.start_transaction(); + assert_matches!( + setup.process(&update, &tx), + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs index 49d6564abd0..d8068aae18f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs @@ -15,21 +15,26 @@ use dpp::consensus::codes::ErrorWithCode; use dpp::consensus::signature::SignatureError; use dpp::consensus::ConsensusError; use dpp::dash_to_credits; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, +}; +use dpp::document::document_methods::DocumentMethodsV0; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey, KeyID, TimestampMillis}; +use dpp::platform_value::Bytes32; use dpp::serialization::{PlatformSerializable, Signable}; -use dpp::state_transition::data_contract_create_transition::methods::DataContractCreateTransitionMethodsV0; -use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::identity_key_limits_update_transition::methods::IdentityKeyLimitsUpdateTransitionMethodsV0; use dpp::state_transition::identity_key_limits_update_transition::v0::IdentityKeyLimitsUpdateTransitionV0; use dpp::state_transition::identity_key_limits_update_transition::IdentityKeyLimitsUpdateTransition; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::{StateTransition, StateTransitionSingleSigned}; -use dpp::tests::fixtures::get_data_contract_fixture; use dpp::version::PlatformVersion; use dpp::ProtocolError; use drive::drive::identity::key::fetch::{IdentityKeysRequest, KeyIDIdentityPublicKeyPairBTreeMap}; @@ -206,27 +211,61 @@ impl Setup { transition } - /// A data contract creation signed by the limited key: a transition a CRITICAL key may + /// A DPNS preorder creation signed by the limited key: a transition a key with limits may /// sign, so the mempool judges the key's budget on it - async fn contract_creation_signed_by_limited_key(&self) -> StateTransition { + async fn document_creation_signed_by_limited_key(&self) -> StateTransition { let platform_version = PlatformVersion::latest(); - let data_contract = get_data_contract_fixture( - Some(self.identity.id()), - 1, - platform_version.protocol_version, - ) - .data_contract_owned(); - DataContractCreateTransition::new_from_data_contract( - data_contract, - self.take_nonce(), - &self.identity.clone().into_partial_identity_info(), - LIMITED_KEY_ID, + let dpns = self + .platform + .drive + .cache + .system_data_contracts + .load_dpns(platform_version) + .expect("expected the DPNS contract"); + let preorder = dpns + .document_type_for_name("preorder") + .expect("expected the preorder type"); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = preorder + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a document"); + // The first identity contract nonce, which the document id commits to + let identity_contract_nonce = 2; + document + .set_id_for_creation( + preorder, + &entropy.0, + identity_contract_nonce, + platform_version, + ) + .expect("expected to set the document id"); + let limited_key = self + .identity + .public_keys() + .get(&LIMITED_KEY_ID) + .expect("expected the limited key"); + BatchTransition::new_document_creation_transition_from_document( + document, + preorder, + entropy.0, + limited_key, + identity_contract_nonce, + 0, + None, &self.signer, platform_version, None, ) .await - .expect("expected to build the contract creation") + .expect("expected to build the document creation") } fn process( @@ -346,7 +385,7 @@ async fn should_raise_the_budget_of_a_spent_key_so_it_can_sign_again() { version, ) .expect("expected to spend the whole budget"); - let errors = setup.check_tx(&setup.contract_creation_signed_by_limited_key().await); + let errors = setup.check_tx(&setup.document_creation_signed_by_limited_key().await); assert!( matches!(errors.as_slice(), [error] if error.code() == PUBLIC_KEY_BUDGET_EXHAUSTED), "a spent key is refused at admission: {errors:?}" @@ -377,7 +416,7 @@ async fn should_raise_the_budget_of_a_spent_key_so_it_can_sign_again() { .expect("expected to commit"); // The key is usable again: its transitions are admitted to the mempool. - let errors = setup.check_tx(&setup.contract_creation_signed_by_limited_key().await); + let errors = setup.check_tx(&setup.document_creation_signed_by_limited_key().await); assert!( errors.is_empty(), "a topped up key is admitted again: {errors:?}" diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs index 152b44a6ea1..7032b632e0b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -1,6 +1,7 @@ use super::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; use super::{ - DriveAbciDocumentsStateTransitionValidationVersions, DriveAbciStateTransitionValidationVersion, + DriveAbciDocumentsStateTransitionValidationVersions, + DriveAbciStateTransitionCommonValidationVersions, DriveAbciStateTransitionValidationVersion, DriveAbciStateTransitionValidationVersions, DriveAbciValidationDataTriggerAndBindingVersions, DriveAbciValidationDataTriggerVersions, DriveAbciValidationVersions, }; @@ -66,11 +67,21 @@ use super::{ /// - the `PrivateDocumentCreateFromShieldedPool` (type 29) `basic_structure`, `Some(0)` rather /// than `None`: the transition, new at protocol version 15, has its entries, value balance, /// proof and anchor checked. +/// - `validate_state_transition_identity_signed`, 2 rather than 1: v1, then a signing key that +/// carries a budget or an expiry is refused, unpaid, on a `DataContractCreate` and on a +/// `DataContractUpdate` (`PublicKeyWithLimitsCannotSignDataContractTransitionError`, 20018): a +/// data contract commits its owner beyond the transition that sets it up or changes it. /// /// v10 remains unchanged for `PROTOCOL_VERSION_14` chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = DriveAbciValidationVersions { state_transitions: DriveAbciStateTransitionValidationVersions { + common_validation_methods: DriveAbciStateTransitionCommonValidationVersions { + validate_state_transition_identity_signed: 2, // a key with a budget or an expiry signs no contract create or update + ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 + .state_transitions + .common_validation_methods + }, identity_create_state_transition: DriveAbciStateTransitionValidationVersion { basic_structure: Some(1), // the identity id is checked before the asset lock proof is verified advanced_structure: Some(2), // v1 without the identity id check, which basic structure now makes diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index d95e5e9be22..1d293abaa12 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -689,13 +689,23 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// reference again when its value or a property it reads the target from changed. /// Protocol v14 and earlier keep `apply_property_reference` 0, which does not read the /// keys, and `document_reference_validation` 0, which no dynamic reference reaches. +/// +/// 34. **A key with a budget or an expiry signs no data contract**: a budget counts what the +/// transitions a key signs take from its identity, and an expiry ends its signing, while a +/// data contract commits its owner beyond the transition that sets it up or changes it. +/// `validate_state_transition_identity_signed` 2 (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) +/// refuses, unpaid, a `DataContractCreate` and a `DataContractUpdate` signed by a key that +/// carries a budget or an expiry +/// (`PublicKeyWithLimitsCannotSignDataContractTransitionError`, 20018). Protocol v14 keeps +/// `validate_state_transition_identity_signed` 1, so the blocks committed under it replay +/// with the same results. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26); verify methods v4: verify_proof_keep_serialized 1 verifies a document that may be contested (note 29); document method versions v5: single_document_path_query 1 stops a single document lookup at a block time above the pointer to the latest version, and verify_proof_keep_serialized 1 verifies it (note 31); add_new_keys_to_masternode_identity 1 adds a masternode key whose hash the identity already holds a reference for (note 32) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock; consensus_params_update 2 installs the full evidence policy (note 16); update_core_info 1 records the chain locked height at which Core's V24 is first seen active (note 18); build_untied_withdrawal_transactions_from_documents 1 emits asset unlock payload version 2 once V24 is recorded as active (note 20); update_voter_identity 1 re-enables the voter key of the address a masternode returns to (note 22); update_masternode_list 2 and update_operator_identity 1 make operator payouts and the operator identities' TRANSFER keys follow Core's diffs (note 32) - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10); document_reference_validation 1 keeps what a consumed commitment's lookup read (note 11); document create structure_validation 2 holds a decision proposal to its fund, rule and end date (note 12); data trigger bindings 3 refuse a replace or a delete of a token market order (note 17) and drop the DashPay bindings (note 24); document create state_validation 3 keeps a create outside a contest off the values of a live one its index values do not decide (note 19); identity create basic_structure 1 checks the identity id before the asset lock proof and advanced_structure 2 no longer does (note 23); contract user moderation transform_into_action 1 writes a moderator's field change in the name of whoever holds the document's storage (note 28) + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10); document_reference_validation 1 keeps what a consumed commitment's lookup read (note 11); document create structure_validation 2 holds a decision proposal to its fund, rule and end date (note 12); data trigger bindings 3 refuse a replace or a delete of a token market order (note 17) and drop the DashPay bindings (note 24); document create state_validation 3 keeps a create outside a contest off the values of a live one its index values do not decide (note 19); identity create basic_structure 1 checks the identity id before the asset lock proof and advanced_structure 2 no longer does (note 23); contract user moderation transform_into_action 1 writes a moderator's field change in the name of whoever holds the document's storage (note 28); validate_state_transition_identity_signed 2 refuses a contract create or update signed by a key with a budget or an expiry (note 34) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 7edf6b9498b..ff2ef1c3839 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -152,6 +152,7 @@ use dpp::consensus::basic::identity::{ }; use dpp::consensus::signature::{ PublicKeyBudgetExhaustedError, PublicKeyExpiredError, + PublicKeyWithLimitsCannotSignDataContractTransitionError, PublicKeyWithLimitsCannotUpdateKeyLimitsError, }; use dpp::consensus::state::identity::identity_public_key_already_expired_error::IdentityPublicKeyAlreadyExpiredError; @@ -1527,6 +1528,10 @@ fn from_signature_error(signature_error: &SignatureError) -> JsValue { SignatureError::PublicKeyWithLimitsCannotUpdateKeyLimitsError(e) => { generic_consensus_error!(PublicKeyWithLimitsCannotUpdateKeyLimitsError, e).into() } + SignatureError::PublicKeyWithLimitsCannotSignDataContractTransitionError(e) => { + generic_consensus_error!(PublicKeyWithLimitsCannotSignDataContractTransitionError, e) + .into() + } } } From 264b0b9e3bdd91236624543a05c632417b1f526e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 19:27:36 +0700 Subject: [PATCH 2/2] docs(platform): point the key limits chapter at the shipped generations rule The two links used a fragment that matches no heading in the coding conventions; use the fragment of "Shipped generations are frozen unless the change cannot modify consensus". Co-Authored-By: Claude Fable 5.2 --- book/src/data-model/key-limits.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/book/src/data-model/key-limits.md b/book/src/data-model/key-limits.md index 2df70d1142d..4f612bb711b 100644 --- a/book/src/data-model/key-limits.md +++ b/book/src/data-model/key-limits.md @@ -332,7 +332,7 @@ The diagram shows the order in which the checks happen. In code the last two box **A spent key is refused first, with the signature.** Signature validation already reads the identity from Drive, so reading what is left of the budget there costs one more lookup, and an application that keeps trying with a spent key is turned away before any real work is done. This is the role the minimum balance pre-check plays for an empty identity. The read is billed as one extra key retrieval. -**Expiry waits for fee validation.** The natural place for it would be next to the `disabled_at` check, but signature validation is not given the block time, and the processor that calls it is a shipped `v0`. Threading a parameter through a shipped generation is exactly what the [versioning rules](../contributing/coding-conventions.md#shipped-generations-are-frozen) forbid, and a new generation of the processor and of check tx would copy some fourteen hundred lines to pass one integer. `validate_fees_of_event` is the first stage that has the block time, runs for every paid transition, and runs in check tx as well as in block execution. The cost of checking late is that an expired key's transition is fully validated before it is refused. An insufficient balance has always had that same profile. +**Expiry waits for fee validation.** The natural place for it would be next to the `disabled_at` check, but signature validation is not given the block time, and the processor that calls it is a shipped `v0`. Threading a parameter through a shipped generation is exactly what the [versioning rules](../contributing/coding-conventions.md#shipped-generations-are-frozen-unless-the-change-cannot-modify-consensus) forbid, and a new generation of the processor and of check tx would copy some fourteen hundred lines to pass one integer. `validate_fees_of_event` is the first stage that has the block time, runs for every paid transition, and runs in check tx as well as in block execution. The cost of checking late is that an expired key's transition is fully validated before it is refused. An insufficient balance has always had that same profile. **The budget comparison needs the estimated fee,** so it can only be in fee validation. **The deduction needs the actual fee,** so it can only be in execution. @@ -494,7 +494,7 @@ Everything but the last row is gated to protocol version 14. Tables that protoco | `STATE_TRANSITION_SERIALIZATION_VERSIONS_V1` to `_V3` | `identity_key_limits_update_state_transition` | new, 0 | | `DRIVE_ABCI_VALIDATION_VERSIONS_V11` (protocol version 15) | `validate_state_transition_identity_signed` | 1 → 2: v1, then a limited key is refused on a contract create or update | -Two of these are worth a second look. Identity signature validation v1 and shielded proof validation v1 were introduced for protocol version 14 by the contract bounds work and had not shipped yet, so they were [extended in place](../contributing/coding-conventions.md#shipped-generations-are-frozen) rather than given a v2. By the time the contract rule was added, identity signature validation v1 had shipped, so that rule is a v2 that calls v1. `validate_fees_of_event` and `execute_event` had shipped, so they got new generations, and those generations delegate to v0 for every event they do not handle instead of copying it. +Two of these are worth a second look. Identity signature validation v1 and shielded proof validation v1 were introduced for protocol version 14 by the contract bounds work and had not shipped yet, so they were [extended in place](../contributing/coding-conventions.md#shipped-generations-are-frozen-unless-the-change-cannot-modify-consensus) rather than given a v2. By the time the contract rule was added, identity signature validation v1 had shipped, so that rule is a v2 that calls v1. `validate_fees_of_event` and `execute_event` had shipped, so they got new generations, and those generations delegate to v0 for every event they do not handle instead of copying it. ## Fees