Skip to content

feat(iota-core, iota-types): add validator attestation#11528

Open
vekkiokonio wants to merge 48 commits into
protocol-research/feat/transaction-attestation-featurefrom
protocol-research/feat/validator-attestation
Open

feat(iota-core, iota-types): add validator attestation#11528
vekkiokonio wants to merge 48 commits into
protocol-research/feat/transaction-attestation-featurefrom
protocol-research/feat/validator-attestation

Conversation

@vekkiokonio
Copy link
Copy Markdown
Contributor

@vekkiokonio vekkiokonio commented May 13, 2026

This PR merges into protocol-research/feat/transaction-attestation-feature, not develop. That feature branch is itself branched from consensus/feat/pcool-feature.

Description of change

This PR implements the Validator Attestation (Phase 1) — the mechanism by which a proposing validator certifies, pre-consensus, that a transaction has passed validation and provides an estimated computation cost for scheduler hints.

Note: related changes to sequencer and execution will be part of a separate PR.

What changed

New data structures (iota-types)

  • attestation.rs: Attestation (two variants — Validator, Explicit), AttestationData (versioned, carries estimated computation cost and observed shared-object versions), AttestedTransaction (transaction + attestation bundle). BCS round-trip tests included.
  • messages_consensus.rs: new UserTransactionV2(Box<AttestedTransaction>) variant in ConsensusTransactionKind, carrying the bundled attestation.
  • error.rs: AttestationAuthorMismatch and ExplicitAttestationNotSupported error variants.
  • iota-protocol-config: enable_validator_attestation feature flag. Attestation is only active when enable_white_flag_flow is also on; the two flags are enforced as co-dependent.

Pre-consensus attestation (authority.rs, validator_v2.rs)

  • attest_transaction: validates the transaction (deny checks, gas, ownership, coin deny list, full dry-run) and returns (AttestationData, Vec<ObjectRef>). It subsumes handle_transaction_validation_checks, so the two paths are mutually exclusive.
  • submit_single_tx restructured: when enable_validator_attestation is on, calls attest_transaction instead of handle_transaction_validation_checks. The pre-consensus soft lock is acquired after attestation (owned objects are returned by attest_transaction, so no extra pass is needed). The transaction is submitted as UserTransactionV2 with the attestation embedded.

Batch homogeneity (consensus_adapter.rs)

  • submit_batch enforces that all transactions in a soft-bundle are of the same kind: all-V1, all-V2, or all-certificates. Mixed batches are rejected with InvalidTxKindInSoftBundle.

Post-consensus attestor verification (post_consensus_validation.rs)

  • UserTransactionV2 match arm separated into pure classification; digest is pushed to all_user_tx_digests before any check, so dropped V2 transactions still release pre-consensus soft locks.
  • New Check # 3 (attestor verification): verifies that attestor_index in Attestation::Validator matches certificate_author_index (block author).
  • UserTransactionV2 transactions skip Check # 6 (handle_transaction_validation_checks) — the attestor already performed those checks before producing the attestation.

consensus_validator.rs: comment updated to clarify that attestor verification happens in post_consensus_validation, not during block ingestion (where block-author context is unavailable).

Unit tests

  • validator_v2_tests.rs: unit tests for the V2 submission path — happy path (transaction is attested and accepted) and rejection path (attestation fails with an invalid transaction).
  • post_consensus_validation_tests.rs: unit tests for UserTransactionV2 handling in post-consensus validation — correct attestor accepted, mismatched attestor rejected, and UserTransactionV2 skipping duplicate validation checks.

End-to-end test (iota-e2e-tests)

  • attestation_tests.rs: test_aa_tx_accepted_via_v2_attestation_path submits a Move abstract account transaction through the V2 gRPC path with both enable_white_flag_flow and enable_validator_attestation active. Exercises the authenticate_then_execute_transaction_to_effects branch of attest_transaction (MoveAuthenticator dry-run path).

Links to any relevant issues

Closes https://github.com/iotaledger/iota-private/issues/384
Closes https://github.com/iotaledger/iota-private/issues/385
Part of Epic https://github.com/iotaledger/iota-private/issues/383

How the change has been tested

  • Basic tests (linting, compilation, formatting, unit/integration tests)
  • Patch-specific tests (correctness, functionality coverage)
  • I have added tests that prove my fix is effective or that my feature works
  • I have checked that new and existing unit tests pass locally with my changes

BCS round-trip tests for AttestationData, Attestation::Validator, Attestation::Explicit, and AttestedTransaction are included in attestation.rs. Unit tests for the V2 submission and post-consensus validation paths are in validator_v2_tests.rs and post_consensus_validation_tests.rs. An end-to-end test exercising the full attestation pipeline is in attestation_tests.rs.

Release Notes

  • Protocol: New UserTransactionV2 consensus transaction kind and enable_validator_attestation protocol config flag (requires enable_white_flag_flow).
  • Nodes (Validators and Full nodes):
  • Indexer:
  • JSON-RPC:
  • GraphQL:
  • CLI:
  • Rust SDK:
  • gRPC:

@vekkiokonio vekkiokonio self-assigned this May 13, 2026
@vekkiokonio vekkiokonio changed the title Protocol research/feat/validator attestation feat: add UserTransactionV2 and validator self-attestation May 13, 2026
@vekkiokonio vekkiokonio changed the title feat: add UserTransactionV2 and validator self-attestation feat: add UserTransactionV2 and validator attestation May 13, 2026
@vekkiokonio vekkiokonio changed the title feat: add UserTransactionV2 and validator attestation feat: add validator attestation May 13, 2026
@vekkiokonio vekkiokonio changed the title feat: add validator attestation feat(iota-core, iota-types): add validator attestation May 13, 2026
@vekkiokonio
Copy link
Copy Markdown
Contributor Author

Attestation::Validator currently imports starfish_config::AuthorityIndex (u8). However, elsewhere in the codebase u32 is used for authority index. This inconsistency will be addressed once the ongoing AuthorityIndex refactor from validator score is complete. Tracked in https://github.com/iotaledger/iota-private/issues/404.

@vekkiokonio
Copy link
Copy Markdown
Contributor Author

check_system_overload() is called before submit_single_tx ends, but the inflight counter (num_inflight_transactions) is only incremented later. This means the entire attestation phase — including the full dry-run and Move authentication — runs outside the load-shedding window. Under load, a validator can accumulate many concurrent attestation dry-runs before any are counted as inflight. Without attestation this gap was cheap(er); with it, it is large enough to warrant a dedicated pre-consensus counter or an early increment at the top of submit_single_tx. Tracked in https://github.com/iotaledger/iota-private/issues/405.

@vekkiokonio vekkiokonio marked this pull request as ready for review May 14, 2026 14:45
@vekkiokonio vekkiokonio requested review from a team as code owners May 14, 2026 14:45
vekkiokonio and others added 3 commits May 22, 2026 13:42
Co-authored-by: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com>
….com:iotaledger/iota into protocol-research/feat/validator-attestation
Comment thread crates/iota-types/src/messages_consensus.rs Outdated
Comment thread crates/iota-types/src/attestation.rs Outdated
/// of `ConsensusTransactionKind::UserTransactionV2`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AttestedTransaction {
pub transaction: Box<Transaction>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this inner Box, keep the outer - the pattern is already there in the codebase to Box any variant carrying significant content:

  CertifiedTransaction(Box<CertifiedTransaction>),
  CheckpointSignature(Box<CheckpointSignatureMessage>),
  UserTransactionV1(Box<Transaction>),
  UserTransactionV2(Box<AttestedTransaction>),

UserTransactionV1(Box<Transaction>),
/// Attested user transaction. Carries a gas attestation produced either by
/// the proposing validator or a registered third-party attestor.
UserTransactionV2(Box<AttestedTransaction>),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or perhaps the Box wrapping inside of AttestedTransaction is unnecessary?

this

Comment on lines 153 to 157
warn!(
?digest,
error = ?e,
"UserTransactionV1 failed validity_check post-consensus, dropping"
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this applies to V2 as well, so the warning message should be updated

suggestion

            warn!(
                ?digest,
                kind = if attestation.is_some() { "UserTransactionV2" } else { "UserTransactionV1" },
                error = ?e,
                "user transaction failed validity_check post-consensus, dropping"
            );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ce36fd4

// Gated behind `enable_white_flag_flow`.
ConsensusTransactionKind::UserTransactionV1(_) => {
ConsensusTransactionKind::UserTransactionV1(_)
| ConsensusTransactionKind::UserTransactionV2(_) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V2 is gated by enable_validator_attestation, which transitively requires enable_white_flag_flow, but it is not the same flag

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 25e49fa

Comment thread crates/iota-core/src/authority/consensus_quarantine.rs Outdated
let committee = epoch_store.committee();
let attestor_index = committee
.names()
.position(|n| n == &state.name)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this search will run for every single tx, why doing that if validator position does not change within an epoch? either cache authority index if attestor_index field is needed indeed, or remove the field entirely

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This search is now a hashmap lookup so not worth caching

Comment on lines +585 to +593
let all_certificates = transactions
.iter()
.all(|tx| matches!(tx.kind, ConsensusTransactionKind::CertifiedTransaction(_)));
let all_v1 = transactions
.iter()
.all(|tx| matches!(tx.kind, ConsensusTransactionKind::UserTransactionV1(_)));
let all_v2 = transactions
.iter()
.all(|tx| matches!(tx.kind, ConsensusTransactionKind::UserTransactionV2(_)));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be done in a single scan rather than three
also could catch future user transaction V3, etc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ae50c30

|| if epoch_store.protocol_config().enable_white_flag_flow() {
// In the certificate-less mode, `UserTransactionV1` kind corresponds
// to user transactions.
// In the certificate-less mode, `UserTransactionV1`/`UserTransactionV2`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update other such comments in this file

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread crates/iota-core/src/consensus_handler.rs
Copy link
Copy Markdown
Contributor

@roman1e2f5p8s roman1e2f5p8s left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatting and clippy warning should be fixed

Comment on lines 529 to 532
(
"UserTransactionV1",
ConsensusTransactionKind::UserTransactionV1(Box::new(signed_tx)),
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V2 should be exercised here as well?

Copy link
Copy Markdown
Contributor Author

@vekkiokonio vekkiokonio May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I added in f29c8a1

starfish_config::AuthorityIndex::from(tx.0.certificate_author_index as u8);
let error = match attestation {
Attestation::Validator { attestor_index, .. } => {
if *attestor_index != block_author {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A malicious validator may create invalid attestations with attestor_index of a different authority.

but why is that even possible in the first place? isn't it because the field exists?

) -> IotaResult<ConsensusTransactionResult> {
let _scope = monitored_scope("HandleConsensusTransaction");
let VerifiedSequencedConsensusTransaction(SequencedConsensusTransaction {
certificate_author_index: _,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is where certificate_author_index could be remembered and propagated later in this function to execution phase, so attestor_index seems redundant (also exploitable) in block-signer attestation

SequencedConsensusTransactionKind::External(ConsensusTransaction {
kind: ConsensusTransactionKind::UserTransactionV2(a),
..
}) => &a.transaction,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so attestation data is discarded here? propagation to execution will be a next step?

let executable_tx = VerifiedExecutableTransaction::new_unchecked(
ExecutableTransaction::new_from_data_and_sig(
transaction.data().clone(),
CertificateProof::ConsensusOrdered(self.epoch()),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this PR, the attestation data is dropped, but in the future, it will have to be propagated to execution anyway.
this is exactly where certificate_author_index could be propagated to execution, so no attestor_index field is needed at all

starfish_config::AuthorityIndex::from(tx.0.certificate_author_index as u8);
let error = match attestation {
Attestation::Validator { attestor_index, .. } => {
if *attestor_index != block_author {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since after consensus the block author is lost

not lost, but explicitly discarded; see https://github.com/iotaledger/iota/pull/11528/changes#r3288664436 and https://github.com/iotaledger/iota/pull/11528/changes#r3288710190

the same mechanism that will propagate attestation data to execution could naturally carry certificate_author_index or block author along it

@vekkiokonio vekkiokonio marked this pull request as draft May 22, 2026 16:35
vekkiokonio and others added 14 commits May 26, 2026 11:06
Co-authored-by: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com>
Co-authored-by: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com>
Co-authored-by: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com>
Co-authored-by: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com>
Co-authored-by: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com>
….com:iotaledger/iota into protocol-research/feat/validator-attestation
Co-authored-by: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com>
….com:iotaledger/iota into protocol-research/feat/validator-attestation
@vekkiokonio vekkiokonio requested review from alexsporn and piotrm50 May 27, 2026 13:32
@vekkiokonio vekkiokonio marked this pull request as ready for review May 27, 2026 13:33

let authenticator_gas_budget = protocol_config.max_auth_gas();
let (exec_gas_status, per_auth_checked, auth_and_tx_checked) =
iota_transaction_checks::check_transaction_and_move_authenticator_input(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you are executing the same checks for the second time (with a small change that I will describe later):

  • iota_transaction_checks::check_transaction_and_move_authenticator_input here is equivalent to the check perfomed in self.run_validation_checks -> self.check_transaction_inputs_for_validation -> iota_transaction_checks::check_move_authenticator_input_for_validation + iota_transaction_checks::check_transaction_input
  • the only change is that iota_transaction_checks::check_transaction_and_move_authenticator_input calls check_transaction_input_inner with the last parameter (is_execute_transaction_to_effects) set to true, while iota_transaction_checks::check_transaction_input calls the same function but with that param set to false.
  • I suggest to remove this iota_transaction_checks::check_transaction_and_move_authenticator_input invocation here and modify self.run_validation_checks to support a new parameter, i.e., is_execute_transaction_to_effects, that is set to true for attest_transaction and false for handle_transaction_validation_checks, then propagate this parameter down to iota_transaction_checks::check_transaction_input

/// [`VerifiedExecutableTransaction`] is not yet available. Produces an
/// execution-mode gas status (full `transaction_gas_budget`, not the
/// reduced signing-time auth budget).
pub fn check_transaction_and_move_authenticator_input(
Copy link
Copy Markdown
Contributor

@miker83z miker83z May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_transaction_and_move_authenticator_input can be removed. See this #11528 (comment) comment

};

// Step 7: build AttestationData.
let estimated_computation_cost = effects.gas_cost_summary().computation_cost;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The advantage of letting OOG transaction pass is that it elimenates that as an attack vector on attestors i.e. sending transactions with insufficient gas to expend attestor resources without paying.

This is not applicable for AA TXs:

  • I use the gas coin of an AA account,
  • make the authenticator go OOG (it will abort the execution, but charge estimated_computation_cost)
  • and then repeat this to drain any gas coin that I don't own

// Build AttestationData.
let estimated_computation_cost = effects.gas_cost_summary().computation_cost;

// Collect all input object refs seen during execution. Start from the
Copy link
Copy Markdown
Contributor

@miker83z miker83z May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you collecting object refs with versions being the pre-execution ones? You will find the same info already in the TxData that is signed (through its digest) together with the attestation data. So it seems a duplication to me. The objects that will appear here and not in the TxData would only be the dynamic fields used during the execution, but I don't see why thisdynamic fields' information might be necessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants