What happens when you send 1 USDT in Ethereum (part 1)

Hao Chen
9 min readMay 16, 2023

--

Source: ethereum.org

This article is written as a tech blog of Ginco.

This is part 1 of the series “What Happens When You Send 1 USDT,” which will introduce the entire process from creating the transaction to the transaction being included in the block. Part 1 mainly focuses on the details of transaction creation, and in the future, topics such as transaction broadcasting and details of EVM execution will be covered.

We all love USDT, especially when another stablecoin, USDC, temporarily breaks its dollar peg during March of this year because of the SVB collapse.

But how do you send or receive it? If you have created a wallet and played around with it before, it sounds pretty simple, right? Open the wallet service such as Metamask, click “send”, then input the amount you want to send and the address of the receiver, and finally click “confirm”. It’s done. After waiting for a while, you can confirm if your transaction succeeded or not in a blockchain explorer such as Etherscan. No technical background is needed, and everyone can do it in less than 10 seconds.

However, behind the graphical interface that you have just interacted with, there are tons of processes that just happened. Though I’m not able to cover every single detail, I will try to explain it and hope you can have an idea about what is going on.

Creating the transaction

Transactions are cryptographically signed instructions from accounts. One example is sending 1 USDT to another account. Now, suppose we want to send 1 USDT to Vitalik, the founder of Ethereum. In the data of the transaction, we have to include something like:

{
"to":"0xdac17f958d2ee523a2206206994597c13d831ec7",
//...
}

Here 0xdac17f958d2ee523a2206206994597c13d831ec7 is the address of the USDT smart contract.

Wait, what?

What is a “smart contract”? Aren’t we going to send 1 USDT to Vitalik? Shouldn’t to be Vitalik's address?

Actually, no. A “smart contract” is a program that runs on the Ethereum blockchain. It’s a collection of code (its functions) and data (its state) that reside at a specific address. It’s one of the most interesting (and also vulnerable) parts of the blockchain. If you want to learn more, you can go check here.

Back to our case, to send USDT, we have to make a transaction that executes the code stored at its address and updates the recorded balances (a mapping from address to balance) of USDT.

As you can imagine, having only the to field is not enough. Let's look at the other needed fields:

{
"to":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"amount":0,
//...
}

You might get confused again. We are going to send 1 USDT, but why is the amount 0? Actually, the amount here represents how much ETH (the native currency of Ethereum) we want to send. Since we are not sending any ETH this time, I will keep this field as 0.

{
"to":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"amount":0,
"chainId":1,
//...
}

Now let’s look at chainId, which specifies the chain where the transaction is to be executed. In the Ethereum ecosystem, there is not only one chain, and many of these chains have exactly the same transaction format and signature scheme. For example, a transfer of 1 ETH from account A to account B will be identical on Ethereum mainnet, Goerli (Ethereum's testnet), Polygon, etc. If account A signed the transaction and broadcasted it on Goerli, then anyone can take it and broadcast it to the mainnet. This is also called a "replay attack". When you create transactions from scratch, make sure you get the correct chain ID from ChainList.

There is one more field being used to prevent a “replay attack”, which is the nonce field:

{
"to":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"amount":0,
"chainId":1,
"nonce":0,
//...
}

This is the number that increases every time you send a transaction to the network, and it’s specific to each account. When you create a new account, that value is 0, and after sending one transaction, it turns to 1. You have to make sure that you input the correct value when you create the transaction. To get that, you may ask the Ethereum node by executing remote calls following JSON-RPC.

{
"id":1,
"jsonrpc":"2.0",
"params":[
"0x30517A85fC883Eb226294B5c6D5d74aB87cEBD5b",
"latest"
],
"method":"eth_getTransactionCount"
}

The details of the request format are usually provided by node providers such as Web3 Cloud By Ginco, Alchemy, Infura, QuickNode, etc. You can also refer to the official documentation here. For getTransactionCount, we input our address and a block tag where latest means the most recent block observed by the client. The response looks like this:

{
"jsonrpc":"2.0",
"id":1,
"result":"0x0"
}

Here we have got the result 0x0, so our nonce will be 0.

Now we are still missing some important information: the address of the receiver (Vitalik’s address) and the amount of USDT we are going to send. Here comes the data field:

{
"to":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"amount":0,
"chainId":1,
"nonce":0,
"data":"0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
//...
}

As you can see, unlike other fields which are plain decimal numbers, the data field looks like a mysterious combination of letters and numbers and is totally unreadable. Let's take a closer look at it so you can understand what it means.

Demystifying data field

Remember I said that the to field is the address of the USDT smart contract. More specifically, USDT is an ERC20-compliant fungible token that should implement the interface defined in the ERC20 specification. There are many methods, and one of them is called transfer. In the USDT smart contract, it looks like this:

function transfer(address _to, uint _value) public {
...
}

This function transfers _value amount of USDT to address _to, and its signature is transfer(address,uint256). In our case, since we are sending USDT to Vitalik's address, this is the function we should interact with. The remaining question is: how do we convert the information we have into the string that the data field contains? Luckily, there is a standard way to interact with smart contracts in Ethereum called the "Contract ABI specification." It specifies how to encode data to interact with smart contracts. Here, I will use a tool called cast to help with encoding.

$ cast calldata "transfer(address,uint256)" 0xdAC17F958D2ee523a2206206994597C13D831ec7 1000000000000000000
0xa9059cbb000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000de0b6b3a7640000

You might be wondering, “Wait, am I sending just 1 USDT instead of 1000000000000000000?” Take it easy, no one owns that many USDT. (If you’re curious, the biggest holder of USDT now is an address on Binance.) So why do we write 1000000000000000000? There are several reasons, and one of them is because there are no float numbers in Solidity, a programming language for developing smart contracts. We can only use unsigned integers. As a result, we need these zeros to represent accuracy and perform calculations. So 1 USDT is stored as $1*10^{18}$ , which is 1000000000000000000.

Now we have almost gathered all the needed data to create a transaction. However, there is one more crucial part in maybe all blockchains, which is gas.

Gas and Fees

Blockchains are decentralized systems, and we need people to validate transactions, create blocks, and so on. But they are not doing it for free! They need a reward, just like you need a salary. In order to send a transaction, you need to pay a fee to validators (PoS) or miners (PoW). Similar to other blockchains, the fee is paid in ETH, the native token of Ethereum, and the final amount of ETH will depend on how much gas your transaction consumes (how much computational effort is required), how much you’re willing to pay for each gas unit spent, and how much the network is willing to accept at a minimum.

Just like in the real world, you will probably work harder when the company pays you more (although this may not be true for everyone). Your transaction will be handled (get included in a block) faster if you pay more. Different wallets use different mechanisms to determine the fee for you, so you don’t have to worry about it. Usually, they will use the API endpoint called eth_feeHistory, which returns a collection of historical gas information. Using that, we are able to calculate maxPriorityFeePerGas (the maximum price of the consumed gas to be included as a tip to the validator) and maxFeePerGas (the maximum fee per unit of gas willing to be paid for the transaction). One example of calling eth_feeHistory is:

{
"id":1,
"jsonrpc":"2.0",
"method":"eth_feeHistory",
"params":[
4,
"latest",
[
25,
74
]
]
}

While the response looks like:

{
"jsonrpc":"2.0",
"id":1,
"result":{
"oldestBlock":"0x105cc77",
"reward":[
[
"0x5f5e100",
"0x77359400"
],
[
"0x5f5e100",
"0x7866c100"
],
[
"0x5f5e100",
"0x3b9aca00"
],
[
"0x5f5e100",
"0x77359400"
]
],
"baseFeePerGas":[
"0x8dac64898",
"0x8a8a6abdb",
"0x859d5b7cb",
"0x8ecab17e9",
"0x8bd02c640"
],
"gasUsedRatio":[
0.411551,
0.35777423333333336,
0.7747331,
0.4165612
]
}
}

Computing the optimized fee is not an easy task, and if you are interested in it, please refer to this blog.

For the transaction we are going to send, three more fields need to be set:

{
"maxPriorityFeePerGas":"...",
"maxFeePerGas":"...",
"gasLimit":"..."
}

About gasLimit, we can simply use another API endpoint called eth_estimateGas, which returns an estimate of how much gas is necessary to allow the transaction to complete.

"id": 1,
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [
{
"from":"0x6fC27A75d76d8563840691DDE7a947d7f3F179ba",
"value":"0x0",
"data":"0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
"to":"0x6b175474e89094c44da98b954eedeac495271d0f"
}
]
{"jsonrpc":"2.0","id":1,"result":"0x8792"}

Now we add gas and fees to our transaction:

{
"to":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"amount":0,
"chainId":1,
"nonce":0,
"data":"0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
"maxPriorityFeePerGas":2000000000,
"maxFeePerGas":120000000000,
"gasLimit":40000,
//...
}

While maxPriorityFeePerGas and maxFeePerGas depend on the current Ethereum network conditions, I have just set them to some arbitrary numbers.

Access list and transaction type

Here are the final two fields. One is called accessList and the other is type.

accessList is a list of addresses and storage keys that the transaction plans to access and will somehow make it cheaper. However, for simple transactions such as sending 1 USDT, we don't have to set it, so I will leave it empty.

type is simply an indicator that our transaction follows the type 2 format specified in EIP-1559.

Finally, we have collected all the needed information.

{
"to":"0xdac17f958d2ee523a2206206994597c13d831ec7",
"amount":0,
"chainId":1,
"nonce":0,
"data":"0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000",
"maxPriorityFeePerGas":2000000000,
"maxFeePerGas":120000000000,
"gasLimit":40000,
"accessList":[],
"type":2
}

Signing the transaction

When sending the transaction, you have to prove that you OWN the account and all the assets inside it. That’s why we need to sign the transaction, just like signing your checks.

To sign your transaction, you need your private key and a cryptographically secure digital signature schema called ECDSA. You don’t need any cryptography knowledge to continue, but if you’re curious, you can refer to this article, which is great material for developers.

Be aware that signing is not encrypting; your transaction is visible to everyone. What is generated is the signature, which is usually represented as v, r, and s.

A sample implementation of the signing process in Golang is as follows:

to := common.HexToAddress("0xdac17f958d2ee523a2206206994597c13d831ec7")
data := "0xa9059cbb000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa960450000000000000000000000000000000000000000000000000de0b6b3a7640000"
tx := types.NewTx(&types.DynamicFeeTx{
ChainID: big.NewInt(1),
Nonce: 0,
GasTipCap: big.NewInt(2000000000),
GasFeeCap: big.NewInt(120000000000),
Gas: 40000,
To: &to,
Value: big.NewInt(0),
Data: common.FromHex(data),
AccessList: types.AccessList{},
})
signedTx, err := types.SignTx(tx, types.LatestSignerForChainID(big.NewInt(1)), privateKey)
v, r, s := signedTx.RawSignatureValues()
fmt.Printf("v: %d\\n", v)
// 1
fmt.Printf("r: %d\\n", r)
// 44822167867365961748165435397134595361878603658916440806469783159243400825958
fmt.Printf("s: %d\\n", s)
// 73217149502562773117976483059193876455947106524487763542550428130462049012

Let’s take a deeper look into the source code of types.SignTx:

func SignTx(tx *Transaction, s Signer, prv *ecdsa.PrivateKey) (*Transaction, error) {
h := s.Hash(tx)
sig, err := crypto.Sign(h[:], prv)
if err != nil {
return nil, err
}
return tx.WithSignature(s, sig)
}

It will first create a hash for tx , and for Hash(tx)it returns

return prefixedRlpHash(
tx.Type(),
[]interface{}{
s.chainId,
tx.Nonce(),
tx.GasTipCap(),
tx.GasFeeCap(),
tx.Gas(),
tx.To(),
tx.Value(),
tx.Data(),
tx.AccessList(),
})
}

Finally, code for prefixedRlpHash looks like

// prefixedRlpHash writes the prefix into the hasher before rlp-encoding x.
// It's used for typed transactions.
func prefixedRlpHash(prefix byte, x interface{}) (h common.Hash) {
sha := hasherPool.Get().(crypto.KeccakState)
defer hasherPool.Put(sha)
sha.Reset()
sha.Write([]byte{prefix})
rlp.Encode(sha, x)
sha.Read(h[:])
return h
}

prefix is our type, which is written to the hasher before RLP encoding. You can see that what's actually being signed is the keccak256 hash of the concatenation of the transaction's type and the RLP encoding of the transaction:

keccak256(0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, amount, data, accessList]))

Once you have obtained the signature, there is one final step before you can submit your transaction: serialization.

Serialization

Again, let’s take a look at source code to see what will happen when we send the transaction. You will find that our created transaction is being called with MarshalBinary, which returns an encoding.

// SendTransaction injects a signed transaction into the pending pool for execution.
//
// If the transaction was a contract creation use the TransactionReceipt method to get the
// contract address after the transaction has been mined.
func (ec *Client) SendTransaction(ctx context.Context, tx *types.Transaction) error {
data, err := tx.MarshalBinary()
//...
}

If we take a closer look at this source code, we can see that, similar to when we sign the transaction, the transaction is encoded as the concatenation of its type and RLP encoding of the underlying data.

// encodeTyped writes the canonical encoding of a typed transaction to w.
func (tx *Transaction) encodeTyped(w *bytes.Buffer) error {
w.WriteByte(tx.Type())
return rlp.Encode(w, tx.inner)
}

As follows:

0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s])

Finally, the serialized transaction is ready to be sent to an Ethereum node.

Notice that we are far from finished, and this is only the first part of sending 1 USDT. Remaining problems include how the transaction gets propagated, how the transaction is executed, and how the transaction is included in the block. We will discuss these topics in the future.

Ginco is actively looking for people who want to learn about blockchain and those who want to become knowledgeable about wallets. Please apply from the link below if you are interested.

株式会社Ginco の全ての求人一覧

References

  1. https://www.notonlyowner.com/learn/what-happens-when-you-send-one-dai
  2. https://ethereum.org/en/developers/docs/
  3. https://github.com/ethereum/go-ethereum/tree/v1.11.6
  4. https://goethereumbook.org/en/

--

--