Thursday, March 28, 2024
No menu items!
HomeDatabase ManagementHow to sign Ethereum EIP-1559 transactions using AWS KMS

How to sign Ethereum EIP-1559 transactions using AWS KMS

Ethereum is a popular public blockchain that enables you to create decentralized applications across a variety of use cases. In 2020 and 2021, it became widely used for decentralized finance (DeFi) apps and non-fungible token (NFT) apps. Due to its permissionless nature, it’s available to every user by just setting up an Ethereum account. These Ethereum accounts consist of a private key and a public key. The associated Ethereum public address is derived from the public key.

One of the challenges to interacting as a user with a public blockchain such as Ethereum is safely managing the private key. For a given Ethereum address, a user can transfer their funds and assets or trigger other sensitive operations by signing transactions with the corresponding private key. Therefore, the private key material must be carefully safeguarded against loss, theft, and malicious attackers.

Fully decentralized applications typically expect the users to manage their Ethereum accounts and transactions on their own.

However, for a certain type of application, it may be desirable to entrust the management of key material to an external process or service, such as when the key material is frequently accessed by automated operations. This is a common requirement for operations like minting on NFT platforms, or deposit and withdraw operations on exchanges.

This post is based on the two-post series detailing how to use AWS Key Management Service (AWS KMS) to securely manage Ethereum accounts. For all further references to the first or the second part of the series, we refer to Part 1 and Part 2.

The previous two posts explained the following:

Part 1 – How to create an Ethereum-compatible KMS key using the AWS Cloud Development Kit (AWS CDK)
Part 2 – How to create Ethereum transactions, sign them using AWS Lambda and the KMS key, and submit these transactions to the Ethereum network using Amazon Managed Blockchain Ethereum instances.

In this post, we guide you through the Ethereum Improvement Proposal (EIP) 1559 (EIP-1559), its user experience (UX) implications, the changes in the protocol, and how to use it with AWS KMS. This post is based on the two previous posts. It’s highly recommended to read these posts first because they give a detailed introduction to Ethereum transactions, the related elliptic curve cryptography, and the required AWS services and general infrastructure.

This post discusses the following:

What is EIP-1559
How are EIP-1559 transactions different from legacy transactions
How to create a valid EIP-1559 Ethereum transaction using AWS KMS and Lambda and make it portable

What is EIP-1559?

The Ethereum Improvement Proposal (EIP) 1559 is a major reform to the mechanics of the transaction fee market of the Ethereum blockchain protocol. In short, as stated in the Motivation section of EIP-1559, it’s about fixing the historic auction mechanism, where users send transactions with bids, (gas prices) and miners choose transactions with the highest bids, and transactions that get included pay the bid that they specify.

The auction mechanism previously used for legacy and EIP-155 transactions leads to multiple sources of inefficiencies. For the end-users, this results in a poor UX by overpaying gas costs for transactions and the uncertainty if and when their transactions go through.

Therefore, one of the main benefits of the proposal is better UX for end-users. Furthermore, it provides economic benefits (for example, by the deflationary pressure of burning ETH), and security benefits (such as by making DOS and spam attacks more costly over time). For more information, see Why 1559?

The conceptual research on how to improve the fee market of Ethereum was started by Vitalik Buterin in 2018. This led to the development of EIP-1559 from 2019 until 2021 by the Ethereum community. On August 5, 2021, the long-awaited EIP-1559 update to the fee market launched as part of Ethereum’s London network upgrade. Polygon, an Ethereum Virtual Machine (EVM)-compatible Ethereum sidechain, went live with EIP-1559 on their mainnet on January 18, 2022.

On a high level, with EIP-1559, the fee marketplace for transactions has been changed in multiple ways:

It moves from a first-price auction with a single gas price bid to a hybrid system that involves a base fee and tips (also called a priority fee).
It moves from a fixed block size to a flexible block size that expands and contracts based on demand. However, it has a maximum block gas limit of 30 million gas.
It moves from a “fees are fully distributed to miners” mechanism to a new model that burns the base fees and transfers only the tips to the miners. By burning the base fees of each block, the protocols add a deflationary pressure to the ETH currency.

Previously, before the EIP-1559 upgrade, a transaction fee was simply calculated by multiplying the amount of gas required for the transaction by a gas price parameter, which was provided by the user. The fee was entirely transferred to the miner that included the transaction to the block.

Moving to EIP-1559, there are three new parameters, which are central to transaction pricing, that must be fully understood:

baseFeePerGas (base fee) – The baseFeePerGas is multiplied by the amount of gas required for the transaction. The result depicts the fee that must be paid in ETH and is burned completely. The baseFeePerGas is algorithmically determined based on the congestion level of the network by the protocol itself and it can’t be set by the user. The base fee mechanism is designed to keep the network at a utilization rate of 100%. That means all blocks should be full blocks (equilibrium block size of 15 million). It adapts the base fee depending on the usage of the previous block: if the usage of the previous block is higher than 100%, the base fee increases. If the usage of the previous block is less than 100%, the base fee decreases.
maxPriorityFeePerGas (priority fee) – The maxPriorityFeePerGas is multiplied by the amount of gas required for the transaction. The resulting fee is paid directly to the miners, therefore it’s also referred to as a miner tip. It can be seen as an incentive for miners to include the transaction to the next block. Under normal network load conditions, there is still space on the block, and therefore a low miner tip is typically sufficient that a miner adds the transaction to the block.
maxFeePerGas (max fee) – The maxFeePerGas is multiplied with the required amount of gas for the transaction. The resulting fee is the absolute upper limit that is allowed to be spent on the transaction. Therefore, the sum of the base fee and priority fee must be less than the max fee. Multiple wallets estimate a higher max fee (for example, double the current base fee) for their users because there is a chance that the base fee goes up on the future blocks. This improves the probability that the transaction is added to one of the next blocks. It’s important to point out that the sender is refunded the difference between the max fee and the base fee (which is burned) and the priority fee (which goes to the miner). If the base fee + priority fee exceeds the configured max fee, the priority fee is automatically adjusted. Therefore, it’s called a maxPriorityFee.

To send a new EIP-1559 transaction, the max fee and priority fee must be specified. This is called a Type 2 transaction. Ethereum is backward-compatible for sending legacy transactions (Type 0) by specifying the gas price parameter.

With EIP-1559 under normal network congestion levels, users have an improved UX because they can be much more certain that their transaction is included in one of the next blocks without overpaying in terms of gas costs.

Let’s take the following example: a user wants to include their transaction on one of the next blocks. With the old mechanism, they had to check the market price, for example, 100 gwei (1 gwei = 0.000000001 ether), and then choose a gas price exceeding the current price. However, there is no guarantee that their transaction will eventually be added to one of the next blocks if the market price rises.

A user could choose 1000 gwei to be safe that their transaction will be added to the next block, but then they will heavily overpay for that transaction.

With EIP-1559, this is different due to the new fee mechanism and flexible block space. If the user chooses a max fee of 1000 gwei and a reasonably priced priority fee (for example, 3 gwei), they can be much more certain that the transaction will be included in one of the next blocks. Furthermore, they won’t overpay because the current base fee and priority fee are dynamically adjusted by the network, but not the manually set max fee.

However, if the network is congested (such as due to an NFT sale) and the blocks are 200% utilized, the fee market reverts to a first price auction mechanism based on the provided priority fee history to the EIP-1559 upgrade.

Solution overview

The following diagram illustrates the architecture of our solution:

The scope of the provided solution in the AWS CDK repository is limited to the area within the dotted red line.

Prerequisites

To follow along with this post, we recommend reading the Prerequisites and Deploy the solution with the AWS CDK sections of Part 1.

To just make the source code available to your local system, you have to clone the repository from GitHub:

git clone https://github.com/aws-samples/aws-kms-ethereum-accounts.git

Sign an Ethereum EIP-1559 transaction with a KMS key

Following the steps of this post, you can create and sign an Ethereum transaction without further requirements. To publish a transaction to the Ethereum network, you need to hold an account that is sufficiently funded. To fund an account on an Ethereum testnet like Rinkeby, you can use the Rinkeby faucet.

To determine the AWS KMS-based Ethereum address, you first have to run a Lambda function that returns the public Ethereum address of the AWS KMS-based account.

On the Lambda console, choose the newly created aws-kms-lambda-ethereum-KmsClientEIP1559Function Lambda function.

The random suffix attached to the Lambda function is related to how the AWS CDK names and identifies the resources.

After you choose the function, choose the Test tab.

Use the following JSON snippet as the request for your new test event:

{“operation”: “status”}

Choose Test.

A successful run of the test event calculates the matching Ethereum address for the KMS public key and returns it as the checksum enabled address (eth_checksum_address), as shown in the following screenshot.

To create and sign an Ethereum transaction with the given AWS KMS-based address, run a Lambda function using the following JSON snippet:

{
“operation”: “sign”,
“amount”: 0.01,
“dst_address”: “0xa5D3241A1591061F2a4bB69CA0215F66520E67cf”,
“nonce”: 0,
“type”: 2,
“chainid”: 4,
“max_fee_per_gas”: 100000000000,
“max_priority_fee_per_gas”: 3000000000
}

The preceding snippet has the following values:

amount specifies the amount of ether to send
dst_address specifies the Ethereum destination address
nonce specifies the current number of transactions on the sending address
type specifies the transaction type (here 2 refers to EIP-1559 transactions)
chainid refers to the destination network and represents a simple replay attack protection (EIP-155)
max_priority_fee_per_gas is optional and represents an amount of gas directly being paid to the miner
max_fee_per_gas is the max gas that you’re willing to pay to get your transaction included in a block, including base_fee_per_gas and max_priority_fee_per_gas
Choose Test.

Assuming the AWS KMS-based address has never been used, the nonce value must be 0 for the first transaction. If the account has been used before, the Ethereum eth_get_TransactionCount RPC method must be used with the AWS KMS-based Ethereum address as input to determine the right value for nonce.

Congratulations! You have created your first KMS key-backed Ethereum EIP-1559 transaction.

For instructions on sending off the transaction via a Managed Blockchain Ethereum node, see Deploying an Ethereum Node on Amazon Managed Blockchain. The newly created Lambda function from the referenced post authenticates with your dedicated Managed Blockchain Ethereum node using Signature Version 4 authentication. You have to provide the hex-encoded Ethereum transaction as an input parameter to the Ethereum client Lambda function, as shown in the following Node.JS example:

web3.eth.sendRawTransaction(signedTxPayload);

After you send it to the Ethereum Rinkeby test network, the web3 library returns the transaction hash (tx_id) of the transaction. You can use this value to track the state of the transaction, for example via Etherscan for the Ethereum Rinkeby network.

For details about the AWS CDK, refer to the section What is happening under the hood? in Part 1.

This particular post mainly focuses on how EIP-1559 transactions are different from legacy transactions explained in Part 1 and Part 2.

Protocol specification

On a protocol level, EIP-1559 introduces a new transaction type that also requires changes to the signature schema described in Part 2.

According to EIP-155: Simple replay attack protection, the legacy transaction type that was described in Part 2 worked based on the hashing of six elements (nonce, gasprice, startgas, to, value, and data) encoded in RLP format.

This mechanism is still valid after the introduction of the EIP-1559 standard via the Ethereum London update that happened on August 5, 2021.

To enable EIP-1559 transactions, a new EIP-2718 transaction type (2) has been introduced. When you set the type to 2 and follow the new schema, Ethereum peers recognize the transaction type as EIP-1559:

0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s]

The main differentiator in terms of the transaction body between the legacy transaction explained in Part 2 and the new EIP-1559 format is that now nine fields need to be included, instead of the previous six fields.

The newly required fields are:

chainid
max_prioirty_fee_per_gas
max_fee_per_gas
access_list

The change of the transaction format is also reflected in a change of the transaction signature.

A valid EIP-1559 signature consists of signature_y_parity, signature_r, and signature_s.

Compared to legacy transactions described in Part 1 and Part 2, the recovery value v has been replaced with signature_y_parity.

According to EIP-155, the signature_y_parity bit {0,1} is the parity of the y value of the curve point, for which r is the x-value in the secp256k1 signing process.

Also, legacy transactions don’t require the chainid parameter to be specified. The chainid parameter represents a simple replay protection between different Ethereum networks.

With the chainid representing the Ethereum network, for instance Rinkeby with chainid 4, is encoded as part of the transaction, the same transaction can’t be replayed on another network because the chainid would differ.

This prevents an attacker from replaying a transaction on a different Ethereum network, such as on the Ethereum mainnet with chainid 1.

Replaying the same transaction on the same network is impossible because the nonce needs to reflect the current transaction count of an Ethereum account. If the transaction has been accepted in the destination network already, the nonce must be incremented for subsequent transactions, otherwise they’re rejected by the network.

This chainid-based replay protection was first used by the EIP-155 standard and later picked up by EIP-1559.

JSON RPC considerations

A set of new JSON RPC methods have been introduced with the London hardfork to provide the required values to configure the newly introduced gas price variables in the EIP-1559 transaction types max_prioirty_fee_per_gas and max_fee_per_gas.

eth_feeHistory returns a collection of historical gas information, for example an array of block base fees per gas. We can use this information to determine the max_fee_per_gas parameter. Based on this parameter, alchemy for example sets the default max_fee_per_gas value to max_priority_fee_per_gas + 2 * base_fee.

max_priority_fee_per_gas returns a fee per gas that is an estimate of how much you can pay as priority fee (tip) to get a transaction included in the current block.

Lambda function (Ethereum EIP-1559 transaction generation)

The entire transaction-handling logic is located in the eth_client_eip1559 Lambda function. The source code to the Lambda function is located in the aws-kms-ethereum-accounts/_lambda/functions/eth_client_eip1559folder.

Opening the lambda_function.py file gives you an overview of how the request is handled. The lambda_handler(event, context) function expects a JSON request with an operation parameter defined. Based on this operation parameter, the handler runs the requested logic.

Similar to the explanation of Ethereum legacy transactions in Part 2, status and sign operations are supported in this Lambda function as well.

As depicted in the following code, the requested parameters have to be passed to the sign operation via the JSON request that is being processed as the event parameter.

This post puts the focus on the differences between legacy transactions described in Part 1 and Part 2 and the EIP-1559 type transactions also called typed transactions. Fundamentals like ASN.1 schema decoding and other aspects have already been covered in Part 2.

def get_tx_params(dst_address: str, amount: int, nonce: int,
chainid: int, type: int, max_fee_per_gas: int, max_priority_fee_per_gas: int) -> dict:
transaction = {
‘nonce’: nonce,
‘to’: dst_address,
‘value’: w3.toWei(amount, ‘ether’),
‘data’: ‘0x00’,
‘gas’: 160000,
‘maxFeePerGas’: max_fee_per_gas,
‘maxPriorityFeePerGas’: max_priority_fee_per_gas,
‘type’: type,
‘chainId’: chainid
}

return transaction

As shown in the preceding code, the get_tx_params() method now consumes four new data fields: type, maxFeePerGas, maxPriorityFeePerGass, and chainId.

Based on the type parameter in the transaction dictionary, we used the eth_account method serializable_unsigned_transaction_from_dict, which determines the intended transaction type. Because the intended transaction type is EIP-1559, the type has to be set to 2.

If the required parameters are present, the eth_account library returns a fully populated TypedTransaction object.

tx_hash = tx_unsigned.hash()

This typedTransaction object now provides a hash() function that returns an EIP-1559 compliant hash value. This transaction payload hash is defined in the following way:

keccak256(0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list]))

Depending on the web3 library and version being used, parameters that aren’t provided as input parameters, such as access_list in our case, are set to null.

We can now calculate the signature using tx_hash. Similar to the process for legacy transactions, the hash value now has to be signed via AWS KMS, and the ECDSA signature components have to be extracted and determined compliant with EIP-2. See the following code:

tx_eth_recovered_pub_addr = get_recovery_id(msg_hash=tx_hash,
r=tx_sig[‘r’],
s=tx_sig[‘s’],
eth_checksum_addr=eth_checksum_addr,
chainid=chainid)

To determine the missing signature parameter (signature_y_parity), the calculated signature (r, s) must be passed to the get_recovery_id method along with the tx_hash, eth_checksum_addr, and chainid parameter.

The purpose of the method is to recover the missing parameter v, also known as the recovery parameter.

The recovery parameter is required so that the original sender address can be calculated from the hash value and the provided signature represented by r, s, and parity_y. This mechanism allows smaller transaction sizes because it’s not required to attach the sender’s public address to the transaction, which is different from other blockchains, for example Bitcoin.

def get_recovery_id(msg_hash, r, s, eth_checksum_addr, chainid) -> dict:
# https://eips.ethereum.org/EIPS/eip-155
# calculate v according to EIP-155 based on chainid parameter
# {0,1} + CHAIN_ID * 2 + 35
v_lower = chainid * 2 + 35
v_range = [v_lower, v_lower + 1]

for v in v_range:
recovered_addr = Account.recoverHash(message_hash=msg_hash, vrs=(v, r, s))

if recovered_addr == eth_checksum_addr:
return {“recovered_addr”: recovered_addr, “y_parity”: v – v_lower}

return {}

As shown in the preceding code, initially a range of v values is calculated based on the chainid. The calculation schema used is {0,1} + CHAIN_ID * 2 + 35, where {0,1} is the parity of the y value of the curve point for which r is the x-value in the secp256k1 signing process, according to EIP-155.

As depicted in the preceding code, the Account.recoverHash() method is run twice. If the calculated Ethereum checksum address returned by the recoverHash() method matches the provided eth_checksum_addr parameter, the right value v has been found and the missing signature_y_parity bit is returned.

To give an example, let’s assume a transaction should be sent to the Ethereum Rinkeby testnet. As specified in the list of chain IDs in EIP-155, Rinkeby has the chainid 4.

Applying the calculation schema explained earlier, {0,1} + CHAIN_ID * 2 + 35 gives us a v_lower value of 41. v_range represents a list of two values, 41 and 42.

Now these two values have to be fed into the Account.recoverHash() method. When the provided and recovered Ethereum addresses match, we know that v, the recovery parameter, pointed at the right address.

To determine the y_parity value, v_lower (41 = 0 + 3 * 2 + 35) needs to be subtracted from v. This gives us either 0 or 1 and represents the y_parity bit.

In the following code, the y_parity parameter can now be passed into the encode_transactions() method provided by the Python eth_account library:

tx_encoded = encode_transaction(unsigned_transaction=tx_unsigned,
vrs=(tx_eth_recovered_pub_addr[‘y_parity’], tx_sig[‘r’], tx_sig[‘s’]))

Lastly, the returned signed transaction must be converted into a hex string:

tx_encoded_hex = w3.toHex(tx_encoded)
tx_hash = w3.keccak(hexstr=tx_encoded_hex).hex()

return tx_hash, tx_encoded_hex

This has the benefit that it’s portable and protected against encoding issues. Furthermore, it allows the calculation of the Ethereum transaction ID or transaction hash.

As shown in the following code, the signed transaction and the associated hash value are being returned as the last step:

raw_tx_signed_hash, raw_tx_signed_payload = assemble_tx(tx_params=tx_params,
params=params,
eth_checksum_addr=eth_checksum_addr,
chainid=chainid)

return {“signed_tx_hash”: raw_tx_signed_hash,
“signed_tx_payload”: raw_tx_signed_payload}

As mentioned earlier, we can use the transaction hash value, for example on Etherscan, to query the status of an Ethereum transaction after the signed transaction has been submitted to a network.

Clean up

To avoid incurring future charges, delete the resources using the AWS CDK with the following command:

cdk destroy

You can also delete the stacks deployed by the AWS CDK via the AWS CloudFormation console.

Conclusion

This series of posts discussed managing Ethereum key material using a KMS key and Lambda.

In Part 1, we talked about how to set up the required services for an AWS-based Ethereum transaction and signature creation, namely AWS KMS and Lambda, using the AWS CDK. The post also pointed out how you can send signed transactions to the Ethereum Rinkeby test network using a Managed Blockchain Ethereum mode.

In Part 2, we explained the inner workings of the Ethereum signature process and the related elliptic curve cryptography, and demonstrated in detail how to create Ethereum legacy transactions and sign them using Lambda functions.

In this post, we introduced EIP-1559 and explained in detail how the new fee mechanism works and how to set the required parameters. We introduced the notion of typed transactions, and provided an in-depth comparison of legacy transactions, EIP-155, and the EIP-1559 transaction type. We also used the Lambda-based mechanism to create valid AWS KMS-based EIP-1559 Ethereum transactions.

Now go, deploy, sign your first EIP-1559 transaction and send it off via an Amazon Managed Blockchain Ethereum node!

About the Authors

David Dornseifer is a Blockchain Architect with the Amazon ProServe Blockchain team. He focuses on helping customers design, deploy and scale end-to-end Blockchain solutions.

Thomas von Bomhard is a Blockchain Architect on the AWS Professional Services team. He has been working with blockchain technology since 2017, and is excited to design and build end-to-end Ethereum blockchain solutions for our customers from various industries.

Read MoreAWS Database Blog

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments