Skip to content
s1ns3nz0 | Known Unknowns
Go back

Lineth L2 Transaction Lifecycle (5) - Web3Signer: Transaction Signing, HTTPS, and L1 Sender Verification

5 min read

Part of the Lineth L2 Transaction Lifecycle series. The component map is in part 0.

The previous post showed the blob transaction and the finalization transaction coming from two different accounts. This post explains why: both are signed by Web3Signer, with a different key for each.

It covers what Web3Signer signs, how the Coordinator reaches it over HTTPS, and a check that the configured keys match the senders in the L1 receipts. I look at the two paths that submit to the rollup contract, blob submission and aggregation submission. Message anchoring uses a separate signing path and isn’t covered here.

What Web3Signer signs

Web3Signer signs the Ethereum transactions the Coordinator prepares. A transaction has fields like:

FieldWhat it’s for
Chain IDWhich chain it’s for
NonceIts position in the sender’s sequence
DestinationThe contract or account being called
ValueETH transferred
CalldataThe function and its arguments
Gas limit and fee fieldsExecution limits and fees
Blob fieldsFor type 0x3: the blob versioned hashes and the blob fee cap

Web3Signer signs the transaction’s signing payload with the selected key. Ethereum recovers the sender’s address from the signature, so changing any signed field breaks the signature for that sender.

For a blob transaction, the versioned hashes are signed. The blob data itself travels separately, in the sidecar.

Signing isn’t proving

An aggregation submission carries a rollup proof in its calldata. Web3Signer signs the transaction that carries it. It doesn’t make the proof and it doesn’t vouch for L2 execution. A valid signature says the key holder authorized the transaction. Whether the contract call succeeded is in the receipt.

Prover
  Writes a proof response
       ↓
Coordinator
  Encodes the contract call, builds the transaction
       ↓
Web3Signer
  Signs with the selected key
       ↓
Coordinator
  Sends the signed transaction to L1
       ↓
L1 contract
  Runs the call

A public key picks the signer

The Coordinator config holds a public key for each signing path. The public key tells Web3Signer which private key to sign with. Nothing is encrypted with it.

The request amounts to: sign this transaction with the key that matches this public key. Web3Signer sends back the signature, and the private key never leaves Web3Signer.

Two submission paths

Both paths in the deployed config use Web3Signer:

Signing pathWhat it signs
Blob submissionTransactions publishing compressed L2 data on L1
Aggregation submissionTransactions finalizing an L2 range

Both point at the same endpoint, https://web3signer:9000, each with its own public key. One signing service, two sender accounts: one for publishing data and one for finalizing.

Where HTTPS is set up

Coordinator: HTTPS client
          ↕
Web3Signer: HTTPS server

Certificates come from Kubernetes Secrets, and both services load them at startup.

Web3Signer, the server

--tls-keystore-file=/tls-files/web3signer-keystore.p12
--tls-keystore-password-file=/tls-files/web3signer-keystore-password.txt
--tls-known-clients-file=/tls-files/known-clients.txt
--http-listen-port=9000

The keystore is Web3Signer’s TLS identity. The known-clients file lists the client certificates it accepts. The web3signer-tls Secret is mounted at /tls-files, and the deployment describes port 9000 as mutual TLS.

Coordinator, the client

The blob signer:

[l1-submission.blob.signer]
type = "Web3signer"

[l1-submission.blob.signer.web3signer]
endpoint = "https://web3signer:9000"
max-pool-size = 10
keep-alive = true

[l1-submission.blob.signer.web3signer.tls]
key-store-path = "/tls-files/coordinator-client-keystore.p12"
trust-store-path = "/tls-files/web3signer-truststore.p12"

The aggregation signer uses the same TLS paths. The coordinator-tls Secret is mounted at /tls-files.

StoreWhat it holds
Client keystoreThe Coordinator’s client certificate and its TLS key
TruststoreWhat the Coordinator uses to check Web3Signer’s server certificate

These TLS keys have nothing to do with the Ethereum signing keys.

What mutual TLS does

With mutual TLS, each side checks the other’s certificate:

Coordinator connects to Web3Signer
    ↓
Coordinator checks Web3Signer's server certificate
    ↓
Web3Signer checks the Coordinator's client certificate
    ↓
Signing requests and responses go over that connection

TLS protects the connection. The Ethereum signature authenticates the transaction.

MechanismJob
HTTPS/TLSEncrypt the connection, authenticate both ends
Ethereum public keyPick the signing key, derive the account address
Ethereum private keySign the transaction

Web3Signer doesn’t decrypt anything with the Ethereum private key. TLS decryption and transaction signing are separate steps with separate keys.

The blob signer matches the blob sender

The blob path’s public key:

9d9031e97dd78ff8c15aa86939de9b1e791066a0224e331bc962a2099a7b1f0464b8bbafe1535f2301c72c2cb3535b172da30b02686ab0393d348614f157fbdb

For a 64-byte public key, the Ethereum address is the last 20 bytes of its Keccak-256 hash:

k -n lineth-l2 exec deploy/l2-users -- \
  cast keccak \
  0x9d9031e97dd78ff8c15aa86939de9b1e791066a0224e331bc962a2099a7b1f0464b8bbafe1535f2301c72c2cb3535b172da30b02686ab0393d348614f157fbdb \
  | awk '{print "0x" substr($0, length($0)-39)}'
0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc

If the key is in uncompressed form with a leading 04, drop the 04 before hashing.

The blob transaction from the previous post, for L2 blocks 25805..25884:

{
  "transactionHash": "0x963a7ef7c4f95b3fb80e2f0e82f04b2cdb769bb6b3e5724fdaa61468dc0b9bb3",
  "blockNumber": "0x1b4ee",
  "status": "0x1",
  "from": "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc",
  "to": "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9",
  "gasUsed": "0x1cc63"
}

from is exactly the derived address. That’s the type 0x3 transaction with one versioned hash, included in L1 block 111,854.

The aggregation signer matches the finalization sender

The aggregation path’s public key:

ba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4
k -n lineth-l2 exec deploy/l2-users -- \
  cast keccak \
  0xba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4 \
  | awk '{print "0x" substr($0, length($0)-39)}'
0x70997970c51812dc3a010c7d01b50e0d17dc79c8

An aggregation submission for L2 blocks 26125..26204:

2026-10-14 07:13:43.390+0000
AggregationSubmitterImpl
submitted aggregation=[26125..26204]80
transactionHash=0x810d2c20768f8b9a688f8590623a0c310556781cf8de88671d0cb166c45803ca
nonce=308 gasPriceCaps=null

Its receipt:

{
  "transactionHash": "0x810d2c20768f8b9a688f8590623a0c310556781cf8de88671d0cb166c45803ca",
  "blockNumber": "0x1bb5f",
  "status": "0x1",
  "from": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
  "to": "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9",
  "gasUsed": "0x68653"
}

Same match. The transaction succeeded in L1 block 113,503 and emitted DataFinalizedV3 for 26125..26204. The finalization transaction in the previous post, for 25805..25884, came from the same 0x7099…79c8.

One service, two accounts

PathDerived addressReceipt sender
Blob0x3c44cdddb6a900fa2b585dd299e03d12fa4293bcExact match
Aggregation0x70997970c51812dc3a010c7d01b50e0d17dc79c8Exact match

The Coordinator signs through one Web3Signer endpoint and picks a different identity per path. Each account has its own nonce sequence, so the blob nonces (304 in the previous post) and the aggregation nonces (308 here) count independently.

The chain of evidence:

Coordinator configured to sign through Web3Signer over mTLS
    ↓
Each submission path selects a public key
    ↓
Each public key derives to an Ethereum address
    ↓
That address is the sender on the L1 receipt

The match proves which key signed each transaction. It doesn’t say which software held the key; any holder of that key would produce the same sender.

In the series so far, the Coordinator builds the transaction, Web3Signer signs it, and the L1 receipts and events show what it did. The next post goes back to the transaction from the first post and follows its message to a claim on L1.


Share this post:

Previous Post
Lineth L2 Transaction Lifecycle (6) - Following an L2 Message to a Successful Claim on L1
Next Post
Lineth L2 Transaction Lifecycle (4) - Following an L2 Blob from Creation to Finalization on L1