Skip to content

state: Recover sender address from transaction signature - #1615

Open
chfast wants to merge 3 commits into
masterfrom
state/tx-recover-sender
Open

state: Recover sender address from transaction signature#1615
chfast wants to merge 3 commits into
masterfrom
state/tx-recover-sender

Conversation

@chfast

@chfast chfast commented Jul 27, 2026

Copy link
Copy Markdown
Member

No description provided.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.48%. Comparing base (d8508b9) to head (70787b9).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1615      +/-   ##
==========================================
+ Coverage   97.46%   97.48%   +0.02%     
==========================================
  Files         170      170              
  Lines       15402    15451      +49     
  Branches     3604     3616      +12     
==========================================
+ Hits        15012    15063      +51     
  Misses        282      282              
+ Partials      108      106       -2     
Flag Coverage Δ
eest-develop 88.13% <71.42%> (-0.21%) ⬇️
eest-develop-gmp 25.77% <43.85%> (-0.01%) ⬇️
eest-legacy 17.31% <0.00%> (-0.06%) ⬇️
eest-libsecp256k1 27.90% <43.85%> (-0.02%) ⬇️
eest-stable 88.10% <71.42%> (-0.21%) ⬇️
evmone-unittests 93.05% <89.47%> (+0.04%) ⬆️

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

Components Coverage Δ
core 96.04% <100.00%> (+0.05%) ⬆️
tooling 90.46% <100.00%> (+0.03%) ⬆️
tests 99.80% <100.00%> (+<0.01%) ⬆️
Files with missing lines Coverage Δ
test/state/errors.hpp 83.95% <100.00%> (+2.93%) ⬆️
test/state/transaction.cpp 100.00% <100.00%> (ø)
test/state/transaction.hpp 100.00% <ø> (ø)
test/statetest/statetest_runner.cpp 91.83% <100.00%> (+6.12%) ⬆️
test/unittests/state_rlp_decode_test.cpp 99.75% <100.00%> (+0.01%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chfast
chfast force-pushed the state/tx-recover-sender branch from 47981b6 to d75a382 Compare July 27, 2026 08:30
@chfast chfast changed the title state: Remover sender address from transaction signature state: Recover sender address from transaction signature Jul 27, 2026
@chfast
chfast force-pushed the state/tx-recover-sender branch 2 times, most recently from a3e2e0c to 21a09df Compare July 27, 2026 14:10
@chfast
chfast requested a review from Copilot July 27, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds sender (signer) address recovery from transaction signatures in the state-test harness, aligning transaction execution with node-style behavior rather than trusting JSON-provided senders.

Changes:

  • Introduces state::recover_sender() to recover the signer address from (v, r, s) and the transaction’s serialized bytes.
  • Updates the state test runner to recover sender from txbytes and report INVALID_SIGNATURE when recovery fails.
  • Adds unit tests covering legacy protected/unprotected v handling and signature s-range rejection; adds a new error code/message for invalid signatures.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/unittests/state_rlp_decode_test.cpp Adds unit tests for sender recovery and invalid-s rejection.
test/statetest/statetest_runner.cpp Uses signature-based sender recovery when txbytes is present; maps failures to INVALID_SIGNATURE.
test/state/transaction.hpp Declares recover_sender() and documents signature validity expectations/limitations.
test/state/transaction.cpp Implements recover_sender() by slicing the signing preimage from the original RLP bytes and calling secp256k1 recovery.
test/state/errors.hpp Adds INVALID_SIGNATURE error code and message.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/state/transaction.cpp Outdated
Comment on lines +151 to +162
const auto typed = tx.type != Transaction::Type::legacy;
auto payload = txbytes.substr(typed ? 1 : 0); // Skip the EIP-2718 type byte.

rlp::Header header;
[[maybe_unused]] const auto header_decoded = rlp::decode_header(payload, header);
assert(header_decoded); // tx has been decoded from txbytes, so its list header is valid.

// The signature fields are canonically encoded, so their sizes locate the slice boundary.
const auto signature_size =
rlp::encode(tx.v).size() + rlp::encode(tx.r).size() + rlp::encode(tx.s).size();
assert(signature_size <= header.payload_length);
auto preimage = bytes{payload.substr(0, header.payload_length - signature_size)};
Comment thread test/state/transaction.hpp Outdated
Comment on lines +91 to +101
/// Recovers the sender (the signer) of the transaction @p tx decoded from @p txbytes,
/// std::nullopt if the signature is invalid: r or s outside [1, secp256k1n), or s in the upper
/// half (EIP-2).
///
/// The serialization is needed as well because the signing preimage is a slice of it; @p tx must
/// be what decode_transaction(@p txbytes) returned.
/// TODO: The rules are applied at every revision, but EIP-2 (low s) starts at Homestead and
/// EIP-155 (v carrying the chain id) at Spurious Dragon, so a pre-Homestead transaction with a
/// high s is rejected here and a pre-EIP-155 one with v >= 35 is accepted.
[[nodiscard]] std::optional<address> recover_sender(
const Transaction& tx, bytes_view txbytes) noexcept;
The state test runner still took the sender from the fixture's template,
so a signature was only ever checked for shape. The 39 remaining
frontier/validation/bad_v_r_s cases of execution-specs tests@v20.0.1 are
legacy transactions that decode cleanly and carry an out-of-range r or s;
nothing rejected them.

Add state::recover_sender() and use it, as a node does; a signature that
does not recover makes the transaction invalid (INVALID_SIGNATURE).
Recovery is strict, so EIP-2 low-s and r, s in [1, secp256k1n) come from
ecrecover itself.

The signing preimage is a slice of the serialization -- the payload
without the trailing (v, r, s), and for a protected legacy transaction
(chain_id, 0, 0) in their place -- so recover_sender() takes the decoded
transaction together with the bytes it came from, and finds the end of
the signed prefix by subtracting the sizes of the canonically encoded
signature fields. Reusing the slice avoids restating every transaction
type's field order next to rlp_encode(), which already states it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

recover_sender() read the transaction's list header by hand where
rlp::take_list_payload() does it -- and checks that it is a list, which
the open-coded version did not. It also picked the EIP-155 or the
pre-EIP-155 base to subtract from v before taking the parity; both bases
are odd, so the parity of v alone decides it either way.

In the runner the transaction's emptiness and the error code held the same
fact, kept in sync by resetting the optional; an engaged error code now
carries it alone. The JSON template the transaction starts from is built
only when the test has no encoding to decode it from, which for the EEST
fixtures is never.

The two recovery tests share the decode-then-recover helper.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

A transaction that does not decode, or whose signature does not recover,
is rejected by the runner rather than by the state library, so nothing
reached those two branches in CI: the EEST fixtures the coverage job runs
carry no such transaction, and the ones that do (frontier/validation/
bad_v_r_s) are in a release it does not download.

Add the two integration fixtures next to the invalid-nonce one, which
pins the same wiring for a transaction the state library rejects. Each
asserts the message the runner prints, so the error codes and their
strings are covered end to end.
@chfast
chfast force-pushed the state/tx-recover-sender branch from 85d3762 to 70787b9 Compare August 7, 2026 11:59
@chfast
chfast requested a lite review from Copilot August 7, 2026 12:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (3)

test/state/transaction.cpp:158

  • recover_sender() relies on assert(is_list) and assert(signature_size <= payload.size()). In release builds these checks disappear; if txbytes doesn’t match tx (accidentally or via future refactors), payload.size() - signature_size can underflow, producing an invalid slice and potentially huge allocation/UB inside a noexcept function. Consider making these hard runtime checks that return std::nullopt when violated.
    auto envelope = txbytes.substr(typed ? 1 : 0);  // Skip the EIP-2718 type byte.
    bytes_view payload;
    [[maybe_unused]] const auto is_list = rlp::take_list_payload(envelope, payload);
    assert(is_list);  // tx has been decoded from txbytes, so its list header is valid.

    // The decoder accepts only canonical integers, so re-encoding (v, r, s) gives their wire sizes.
    const auto signature_size =
        rlp::encode(tx.v).size() + rlp::encode(tx.r).size() + rlp::encode(tx.s).size();
    assert(signature_size <= payload.size());
    auto preimage = bytes{payload.substr(0, payload.size() - signature_size)};

test/state/transaction.hpp:103

  • The API/doc currently hard-codes EIP-2 (low-s) and EIP-155 handling as “applied at every revision”. That makes recover_sender() unable to model historical consensus rules (e.g. pre-Homestead allowing high-s, pre-Spurious-Dragon disallowing EIP-155 v values) and can cause state tests for early revisions to diverge if fixtures ever include such cases. Consider taking evmc_revision (or an equivalent fork indicator) and applying the signature validity rules conditionally, so the runner can truly “recover as a node does” for the selected rev.
/// Both rules are applied at every revision, although EIP-2 (low s) starts at Homestead and
/// EIP-155 (v carrying the chain id) at Spurious Dragon. The fixtures do not notice: they are
/// signed canonically, and no transaction predating Spurious Dragon carries an EIP-155 v.
/// Replaying real pre-Homestead history would, as about half of those signatures have a high s.
[[nodiscard]] std::optional<address> recover_sender(
    const Transaction& tx, bytes_view txbytes) noexcept;

test/unittests/state_rlp_decode_test.cpp:46

  • recover() uses EXPECT_TRUE(tx.has_value()) but then unconditionally calls tx.value(). If decoding fails, this will throw/terminate (the function is non-void), turning a test failure into a crash. Prefer reporting the failure and returning std::nullopt (or otherwise short-circuiting) before calling value().
    const auto tx = state::decode_transaction(txbytes);
    EXPECT_TRUE(tx.has_value());
    return state::recover_sender(tx.value(), txbytes);

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.

2 participants