Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add example for encoding and decoding raw transactions #164

Closed
wants to merge 5 commits into from

Conversation

junha-ahn
Copy link

@junha-ahn junha-ahn commented Dec 8, 2024

Motivation

This PR adds an example demonstrating how to encode, decode, sign, and send raw transactions using the Alloy library.

It shows the complete lifecycle of an EVM transaction.

This example will help developers understand how to work with raw transactions in Alloy.

Solution

the code creates a transaction, converts it to bytes, then back to a transaction object, verifying each step with assertions to ensure data integrity through the encoding/decoding process.

PR Checklist

@zerosnacks
Copy link
Member

Hi @junha-ahn thanks for your PR!

Please run cargo +nightly clippy --examples --all-features to address the linting issues

For reader's flow we generally try to avoid helper function unless strictly necessary

After the fix I need to double check if the proposed example reflects best practices

Comment on lines 21 to 32
fn build_unsigned_tx(chain_id: u64, to_address: Address) -> TxEip1559 {
let mut tx = TxEip1559::default();
tx.chain_id = chain_id;
tx.nonce = 0;
tx.gas_limit = 21_000;
tx.max_fee_per_gas = 20_000_000_000;
tx.max_priority_fee_per_gas = 1_000_000_000;
tx.to = TxKind::Call(to_address); // Change this to `TxKind::Create` if you'd like to deploy a contract instead
tx.value = U256::from(100);

tx
}
Copy link
Member

Choose a reason for hiding this comment

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

We can use the builder properties of TransactionRequest here

// Build a transaction to send 100 wei from Alice to Bob.
// The `from` field is automatically filled to the first signer's address (Alice).
let tx = TransactionRequest::default()
.with_to(bob)
.with_nonce(0)
.with_chain_id(provider.get_chain_id().await?)
.with_value(U256::from(100))
.with_gas_limit(21_000)
.with_max_priority_fee_per_gas(1_000_000_000)
.with_max_fee_per_gas(20_000_000_000);

Copy link
Author

@junha-ahn junha-ahn Dec 9, 2024

Choose a reason for hiding this comment

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

I couldn't find the RLP-encoding-method in the TransactionRequest struct.

So Thats why I use TxEip1559.

however, I changed the coding style

fn build_unsigned_tx(chain_id: u64, to_address: Address) -> TxEip1559 {
    TxEip1559 {
        chain_id,
        nonce: 0,
        gas_limit: 21_000,
        max_fee_per_gas: 20_000_000_000,
        max_priority_fee_per_gas: 1_000_000_000,
        to: TxKind::Call(to_address), // Change this to `TxKind::Create` if you'd like to deploy a contract instead
        value: U256::from(100),
        ..Default::default()
    }
}

Copy link
Member

Choose a reason for hiding this comment

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

I couldn't find the RLP-encoding-method in the TransactionRequest struct.

there is no RLP-encoding method becasue TransactionRequest cannot be RLP encoded. you should use build to construct and sign a transaction, converting TransactionRequest into TransactionEnvelope

Comment on lines 34 to 65
fn unsigned_tx_to_bytes(tx: TxEip1559) -> Vec<u8> {
tx.encoded_for_signing() // To use this, have to import "alloy::primitives::private::alloy_rlp::Encodable"
}

fn bytes_to_unsigned_tx(bytes: Vec<u8>) -> TxEip1559 {
let mut slice = &bytes.as_slice()[1..];
TxEip1559::decode(&mut slice).unwrap() // To use this, have to import "alloy::primitives::private::alloy_rlp::Decodable"
}

async fn sign_tx(tx: TxEip1559, wallet: EthereumWallet) -> TxEnvelope {
let tx_request: TransactionRequest = tx.into();

tx_request.build(&wallet).await.unwrap()
}

fn signed_tx_to_bytes(signed_tx: TxEnvelope) -> Vec<u8> {
let mut encoded = Vec::new();
signed_tx.encode(&mut encoded);
let encoded = &encoded[2..];
encoded.into()
}

fn bytes_to_signed_tx(bytes: Vec<u8>) -> TxEnvelope {
let mut slice = bytes.as_slice();
TxEnvelope::decode(&mut slice).unwrap()
}

fn get_tx_hash_from_signed_tx_bytes(signed_tx_bytes: Vec<u8>) -> FixedBytes<32> {
format!("0x{}", hex::encode(keccak256(signed_tx_bytes.clone())))
.parse::<FixedBytes<32>>()
.unwrap()
}
Copy link
Member

@zerosnacks zerosnacks Dec 9, 2024

Choose a reason for hiding this comment

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

this can all be inlined for clarity, using ? with eyre for conciseness

Copy link
Author

@junha-ahn junha-ahn Dec 9, 2024

Choose a reason for hiding this comment

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

fn bytes_to_unsigned_tx(bytes: Vec<u8>) -> eyre::Result<TxEip1559> {
    let mut slice = &bytes.as_slice()[1..];
    Ok(TxEip1559::decode(&mut slice)?) // To use this, have to import "alloy::primitives::private::alloy_rlp::Decodable"
}

If I will change like above, then I have to handle all Result enum in the main function

image

I think, its not conciseness

@zerosnacks
Copy link
Member

zerosnacks commented Dec 9, 2024

From #86

regular users should only interact with TransactionRequest and TransactionEnvelope. they should never RLP anything
you should never decode raw rlp bytes, you should decode raw 2718 bytes. going from TxEnvelope to rpc::Transaction is not possible because rpc::Transaction contains more information

cc @prestwich / @yash-atreya would you mind double checking this example if this is in line with the Alloy API? If not, how should it be handled differently? I want to make sure that the examples reflect how we intend the API to be used.

@prestwich
Copy link
Member

this example seems to be the same thing, but more concise and more in line with intended API usage

@junha-ahn
Copy link
Author

junha-ahn commented Dec 9, 2024

@prestwich

I am currently developing two servers, both using alloy:

  1. build/broadcast transaction server
  2. sign only server (in a network-isolated environment for security purposes)

Using these two servers, I have the following transaction lifecycle:

  1. The build/broadcast server creates an unsigned transaction for sending 1 ETH
  2. It sends the unsigned RLP bytes to the sign server. The sign server then signs these bytes with a private key and responds with signed RLP bytes
  3. The build/broadcast server broadcasts the signed RLP bytes
build/broadcast transaction server <= rlp bytes => sign only server

The send_eip1559_transaction example doesn't use RLP bytes at all, so it's not the same as the PR's example. The PR's example focuses on using RLP.

Additional note1: While each server could communicate via JSON requests, the sign server is designed to support requests from all networks, not just EVM but also Solana and others. Therefore, I believe it's a more efficient design to exchange raw transactions rather than parsing specific JSON bodies for each network.

Additional note2: Currently, it seems like they're trying to hide RLP Encoding in the Alloy design (I had a really hard time because of this. I spent hours searching through various methods). I'm curious why users shouldn't handle RLP encoding directly. For use cases like mine, should I not be using Alloy?

@prestwich
Copy link
Member

the API was not designed to support blind signing setups like this, as they are considered not best practices.

To be precise, you do not actually want RLP bytes. Signing RLP will cause txns to be rejected by the network, as will broadcasting signed RLP. You want transaction-specific unsigned formats, which generally-but-not-always use RLP as a substring. Please be careful when implementing these sorts of things, as it is very easy to miss a detail and end up with subtly incorrect code (for an example, see this longstanding bug in the ledger hardware wallet app) . This is why we don't recommend users interact with RLP directly, and do not generally want examples of doing so

@prestwich
Copy link
Member

Your core problem is that there is no official format serialization format for unsigned transactions except the JSON-RPC TransactionRequest. Any unsigned RLP serialization you do is non-standard by definition.

You might find the hidden RlpEcdsaTx trait useful for your project, btw.

@junha-ahn
Copy link
Author

junha-ahn commented Dec 10, 2024

Due to my incomplete understanding of a large amount of Alloy code, there might be errors in each of my opinions.

  1. These issues seem to have arisen because there were no examples or explanations of best practices in the Alloy book. (I hope I can contribute by learning this content better)

  2. In that case, shouldn't we implement the RlpEcdsaTx trait for transactionRequest? Currently, as far as I can see, there
    is no impl RlpEcdsaTx for TransactionRequest. Therefore, it seems impossible to use unsigned transactionRequest for rlp encode/decode as a current best practice.

  3. "Not recommending RLP Encoding to users" ==> First of all, I am also a user, and especially considering security-critical environments like exchanges(as I worked before), I think server separation (isolation) for signing and RLP communication is a common design pattern. ==> Shouldn't we guide users toward best practices through modifications of points 1 and 2?"

@prestwich
Copy link
Member

As I said above, your core problem is that there is no official format serialization format for unsigned transactions except the JSON-RPC TransactionRequest. Any unsigned RLP serialization you do is non-standard by definition. There is no best practice for using RLP here. Doing so is non-standard and therefore not recommended or officially supported by the library

In that case, shouldn't we implement the RlpEcdsaTx trait for transactionRequest? Currently, as far as I can see, there
is no impl RlpEcdsaTx for TransactionRequest.

No, TransactionRequest is a JSON specification. There is no standard RLP encoding of an incomplete transaction. Any implementation here would be alloy-specific. Alloy supports only official, well-standardized serializations. It does not make any new serialization formats. This is also why TypedTransaction also does not implement RlpEcdsaTx (along with forward compatibility for non-ECDSA transaction types). If you want to make your own, bespoke, RLP encoding of TypedTransaction or TransactionRequest for your project, please do, but it is outside of alloy's scope.

Therefore, it seems impossible to use unsigned transactionRequest for rlp encode/decode as a current best practice.

Yes, that is intentional, as doing so would never be a best practice, as it is not an industry standard. As a side note, even signed transaction transmission via RLP is non-standard. EIP-2718 is the only standard binary format for serialized signed transactions

Not recommending RLP Encoding to users" ==> First of all, I am also a user, and especially considering security-critical environments like exchanges(as I worked before), I think server separation (isolation) for signing and RLP communication is a common design pattern. ==> Shouldn't we guide users toward best practices through modifications of points 1 and 2

Server separation and isolation is great. TransactionRequest over JSON-RPC is the ecosystem-wide standardized way to do that. If you would like to make a custom format for that, please do, but it is outside of alloy's scope. I would recommend you look into Trezor and Ledger's formats and the on-device validation they do (although they won't exactly suit your needs, as they're HID packet based)

@junha-ahn
Copy link
Author

I understand what the approach is aiming for.

I have one question.

Until now, I've been using RLP serialization as a matter of course while using SDKs in the Node.js ecosystem. I wasn't aware that JSON serialization was the best practice and RLP serialization was similar to being deprecated(?). Are there any official discussions or links about this topic that you could share? (Since this is the first time I'm hearing about this, I'm curious where I should look for references - for future server design discussions and suggestions)

@prestwich
Copy link
Member

Until now, I've been using RLP serialization as a matter of course while using SDKs in the Node.js ecosystem. I wasn't aware that JSON serialization was the best practice and RLP serialization was similar to being deprecated(?). Are there any official discussions or links about this topic that you could share? (Since this is the first time I'm hearing about this, I'm curious where I should look for references - for future server design discussions and suggestions)

it's not that RLP is being deprecated, it's that it is an implementation detail of the network's EIP-2718 specification for each transaction type, (and therefore an implementation detail of consensus). The EIP-2718 specification is used by (e.g.) eth_sendRawTransaction and is well-defined for (and only for) signed transactions, because only signed transactions are valid network obejcts or valid consensus objects. As a result, nobody ever bothered to standardize RLP encodings on unsigned transactions, as they are not valid in the contexts that use RLP

This means there is no well-specified, standardized RLP or 2718 encoding of unsigned transactions. Transmitting unsigned transactions at all is generally not standardized at all. So when writing a library, if you provide those methods you are likely pushing your users into behavior that is not compatible with other tooling in the ecosystem

Most signers (like metamask or full node wallets, etc) tend to use the JSON-RPC transaction request, while special-application signers like ledger, hsms, etc, design custom serializations. Some signers, like the GCP blind signer we have in alloy transmit only the hash to be signed (which is less-than-ideal 😮‍💨 )

@junha-ahn junha-ahn closed this Dec 10, 2024
@junha-ahn
Copy link
Author

junha-ahn commented Dec 10, 2024

@prestwich Is there any recommended way to rlp encode signed tx?

I currently use this way

fn signed_tx_to_bytes(signed_tx: TxEnvelope) -> Vec<u8> {
    let mut encoded = Vec::new();
    signed_tx.encode(&mut encoded);
    let encoded = &encoded[2..]; // I don't know why do we have to slice 2 bytes.
    encoded.into() // the result is "raw transaction" that you can see in the etherscan
}

@prestwich
Copy link
Member

I would strongly recommend using Encodable2718::encoded_2718 rather than RLP encoding it. RLP is almost never what you want. for TxEnvelope it will give you the p2p network encoding, which will be incompatible with RPC and other tooling.

@junha-ahn
Copy link
Author

junha-ahn commented Dec 10, 2024

image image

But I think, Its not a public method (Sorry my rust knowledge is bad )

image

@prestwich
Copy link
Member

i think you just need to import the Encodable2718 trait :)

@junha-ahn
Copy link
Author

junha-ahn commented Dec 10, 2024

@prestwich I apologize for the continuous questions in this thread. I have a question about signing.

Currently, it seems that to sign a transactionRequest, we must use the .build(networkWallet).await method.

If I want to use the sign_transaction_sync method in the TxSignerSync trait:

// fn signature
fn sign_transaction_sync(
        &self,
        tx: &mut dyn SignableTransaction<Signature>,
    ) -> alloy_signer::Result<Signature>;


// use like below:
let mut tx: alloy::consensus::TxEip1559 = ...;
let signature = signer.sign_transaction_sync(&mut tx).unwrap();
let signed_tx: TxEnvelope = tx.into_signed(signature).into();

I would need to convert TransactionRequest to TxEip1559 (SignableTransaction).

  • but its not a best practice. for example: impl From<TxEip1559> for TransactionRequest is implemented but not the reverse
  • and you mentioned already in this thread. I understand that we have to use TransactionRequest struct only (handling unsigned transaction best practice)

Does transactionRequest only support asynchronous signing?

  • I think there must be a reason, I'm curious why they made it to only use this sign pattern from a Rust programming perspective

@prestwich
Copy link
Member

I would need to convert TransactionRequest to TxEip1559 (SignableTransaction).

This is what the build_unsigned function is for. That conversion is fallible, as the TransactionRequest may not have the necessary information yet. You can use .complete_1559() to check if a 1559 tx can be built yet before attempting to build it

Does transactionRequest only support asynchronous signing?

I think so right now, but adding a build_sync method to TransactionRequest would be a pretty straightforward PR :)

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.

3 participants