{"content":{"title":"如何手动构造以太坊交易","body":"在开发以太坊的 `dapp`（去中心化应用）或交易脚本时，开发者通常会借助某些库或框架来简化与以太坊区块链的交互过程。这些工具提供了便捷的 API 接口，通过这些接口，开发者能够轻松地发送交易、读取链上数据、以及执行其他与区块链交互的操作。虽然这些库或框架能够极大简化交易的创建和发送过程，但其内部的交易构造和发送机制却往往隐藏于开发者的视线之外。\r\n\r\n为了解这些框架内部发送交易的原理，本文将深入探讨如何在不依赖于任何框架的情况下手动发起一笔交易。\r\n\r\n要在无框架的环境中发送一笔交易，通常需经历以下几个核心步骤：\r\n\r\n- 构建交易对象：创建一个包含了交易相关信息（如交易的发送方、接收方、金额、Gas 价格等）的交易对象\r\n- 对交易对象进行签名：利用私钥对构建好的交易对象进行签名，以确保交易的安全性和完整性\r\n- 发送交易：将签名后的交易对象发送到以太坊网络\r\n\r\n## 构造交易\r\n\r\n交易的原始数据结构\r\n\r\n```ts\r\ninterface Transaction {\r\n  form: Address // 交易的发送者\r\n  to: Address // 交易的接收者\r\n  nonce: Hex // 发送者的nonce\r\n  type: Hex // 交易类型, 0(legcy) 或 1(EIP-2930) 或 2(EIP-1559)\r\n  value: Hex // 交易携带的主币数量, 单位是 wei\r\n  data: Hex // 交易携带的数据\r\n  maxPriorityFeePerGas?: Hex // EIP-1559:每单位 gas 优先费用, type=2时提供\r\n  maxFeePerGas?: Hex // EIP-1559:每单位 gas 最大费用, type=2时提供\r\n  gas: Hex // 可使用的最大 gas 数量(gasLimit)\r\n  gasPrice?: Hex // gas 价格, type!=2时提供\r\n  accessList？: [] // EIP-2930新增属性, 值为包含地址和存储键的列表，主要为解决EIP-2929带来的副作用问题\r\n}\r\n```\r\n\r\n其中相关字段需要通过 `JSON RPC` 获取\r\n\r\n> `JSON RPC` 本质为 HTTP `post` 请求，区别在于请求参数为固定的格式，如下所示\r\n>\r\n> ```ts\r\n> {\r\n>   jsonrpc: '2.0', // 指定 JSON-RPC 协议版本\r\n>   method: '', // 调用的方法名称\r\n>   params: [], // 调用方法所需要参数\r\n>   id: 1 // 本次请求的编号\r\n> }\r\n> ```\r\n>\r\n> 响应结果格式如下所示\r\n>\r\n> ```ts\r\n> {\r\n>   jsonrpc: '2.0', // 指定 JSON-RPC 协议版本\r\n>   id: 1, // 本次请求的编号, 和请求参数中的 id 一致\r\n>   result: '' // 请求结果\r\n> }\r\n> ```\r\n\r\n下面将详细介绍交易对象中各个字段\r\n\r\n### from\r\n\r\n交易的发送者, 必须是 `EOA` 地址\r\n\r\n\r\n> 在以太坊中有 2 种账户：外部账户、合约账户\r\n>\r\n>- 外部账户: `Externally Owned Accounts` 简称 `EOA`, 拥有私钥, 其 `codeHash` 为空\r\n>\r\n>- 合约账户: `Contact Account` 简称 `CA`, 没有私钥, 其 `codeHash` 非空\r\n\r\n### to\r\n\r\n交易的接收者, 可以是 `EOA`, 也可以是 `CA`\r\n\r\n### nonce\r\n\r\n交易发送者的 `nonce`, 值为账户已发送交易数量的计数, 主要有两个方面的作用:\r\n\r\n- 防止双重消费（重放攻击）: 在以太坊网络中，每个交易都有一个与之关联的 `nonce` 值。`nonce` 是一个只能被使用一次的数字，它能确保每笔交易是独一无二的。通过这种方式，以太坊网络能够防止双重消费攻击，即用户不能使用同一笔资金进行两次或多次交易\r\n\r\n- 交易顺序: 当用户发送新的交易时，该账户的 `nonce` 值会自增。通过这种机制，以太坊网络能够确保交易按照正确的顺序被处理，即先发送的交易先被处理，后发送的交易后被处理。确保账户状态的正确性和交易的原子性\r\n\r\n通过 `JSON RPC` 方法 `eth_getTransactionCount` 获取 `nonce` 值\r\n\r\n```ts showLineNumbers\r\nimport axios from 'axios'\r\n\r\nconst rpc_url = 'https://rpc.ankr.com/eth_goerli'\r\n\r\nconst getNonce = async () => {\r\n  const response = await axios.post(rpc_url, {\r\n    jsonrpc: '2.0',\r\n    method: 'eth_getTransactionCount',\r\n    params: [account.address, 'pending'],\r\n    id: 1\r\n  })\r\n  return response.data.result\r\n}\r\n```\r\n\r\n`eth_getTransactionCount` 方法有以下两个参数:\r\n\r\n- `address`: 账户地址\r\n- `blockNumber`: 区块编号，可以是一个十六进制的区块高度值，或是 `latest`、`earliest`、`pending`中的一个\r\n  - 特定的区块号：查询该区块指定地址的交易计数\r\n  - `latest`： 查询最新区块时指定地址的交易计数\r\n  - `earliest`：查询创世区块（第一个区块）时指定地址的交易计数\r\n  - `pending`：查询当前挂起区块（尚未被矿工处理的区块）时指定地址的交易计数\r\n\r\n### type\r\n\r\n交易类型, 以太坊中存在三种交易类型，有以下取值:\r\n\r\n- 0: legcy, `EIP-2718` 之前的交易, 交易字段有\r\n\r\n```\r\nfrom, to, type, value, data, nonce, gas, gasPrice\r\n```\r\n\r\n- 1: `EIP-2930`, 新增字段 `accessList`\r\n\r\n```\r\nfrom, to, type, value, data, nonce, gas, gasPrice, accessList\r\n```\r\n\r\n- 2: `EIP-1559`, 移除了 `gasPrice`, 新增 `maxPriorityFeePerGas` 和 `maxFeePerGas`\r\n\r\n```\r\nfrom, to, type, value, data, nonce, gas, maxPriorityFeePerGas, maxFeePerGas, accessList\r\n```\r\n\r\n### value\r\n\r\n交易携带的 `ETH` 数量, 单位是 `WEI`($1\\tt{ETH}=10^{18}\\tt{WEI}$)\r\n\r\n### data\r\n\r\n交易携带的数据, 如果是转账交易, 该字段可为空。如果是调用合约的交易, `data` 则为合约函数的选择器哈希值拼接上函数参数编码\r\n\r\n### maxPriorityFeePerGas\r\n\r\n每单位 `Gas` 的优先价格，仅 `type` 为 2 时提供，这部分的费用将支付给矿工。\r\n\r\n通过 `JSON RPC` 方法 `eth_maxPriorityFeePerGas` 获取当前最新的 `maxPriorityFeePerGas`\r\n\r\n```ts showLineNumbers\r\nconst getMaxPriorityFeePerGas = async () => {\r\n  const response = await axios.post(rpc_url, {\r\n    jsonrpc: '2.0',\r\n    method: 'eth_maxPriorityFeePerGas',\r\n    params: [],\r\n    id: 1\r\n  })\r\n  return response.data.result\r\n}\r\n```\r\n\r\n该方法不是标准方法，通常由第三方节点服务商(alchemy, infura 等)提供，如果你使用的节点不存在该方法。则可以尝试下面的备选方案\r\n\r\n- 通过 `JSON RPC` 方法 `eth_gasPrice` 获取当前最新的 `gasPrice`\r\n- 通过 `JSON RPC` 方法 `eth_getBlockByNumber` 获取当前最新的区块信息 `block`, 区块信息中存在 `baseFeePerGas`\r\n\r\n将两者相减可以获得 `maxPriorityFeePerGas`\r\n\r\n```ts showLineNumbers\r\nmaxPriorityFeePerGas = gasPrice - block.baseFeePerGas\r\n```\r\n\r\n### maxFeePerGas\r\n\r\n每单位 `Gas` 的最大价格，仅 `type` 为 2 时提供。该字段的目的是为了防止因 `gasPrice` 波动而导致交易被剔除出打包序列。通常计算公式为 `baseFeePerGas` 乘以一个倍数 `multiple` 再加上 `maxPriorityFeePerGas`\r\n\r\n```ts showLineNumbers\r\nmaxFeePerGas = block.baseFeePerGas * multiple + maxPriorityFeePerGas\r\n```\r\n\r\n当 `multiple` 为 2 时, 可以保证连续 6 个区块满 `Gas` 的情况下仍在内存池中等待打包。\r\n\r\n在不同的框架中 `multiple` 被设置成不同的值, 在 [viem](https://github.com/wagmi-dev/viem) 中值为 1.2， 在 [ethers.js](https://github.com/ethers-io/ethers.js) 中值为 2\r\n\r\n### gas\r\n\r\n该字段意为该交易最多可花费的 `gas` 数量, 即 `gasLimit`。转账交易时, 该值固定为 21000\r\n\r\n通过 `JSON RPC` 方法 `eth_estimateGas` 可获取交易预估值作为该字段值\r\n\r\n```ts showLineNumbers\r\nconst estimateGas = async (originTransaction) => {\r\n  const response = await axios.post(rpc_url, {\r\n    jsonrpc: '2.0',\r\n    method: 'eth_estimateGas',\r\n    params: [originTransaction],\r\n    id: 1\r\n  })\r\n  return response.data.result\r\n}\r\n\r\nconst originTransaction = {\r\n  form: '0x...',\r\n  to: '0x...',\r\n  nonce: '0x...',\r\n  type: '0x2',\r\n  value: '0x2386f26fc10000',\r\n  maxPriorityFeePerGas: '0x3f7',\r\n  maxFeePerGas: '0x42a'\r\n}\r\noriginTransaction.gas = await estimateGas(originTransaction)\r\n```\r\n\r\n## 签名\r\n\r\n为了确保交易是由私钥持有者发出的，还需要使用私钥对交易进行签名。签名前需要先经过序列化、编码过程\r\n\r\n交易编码采用 `RLP` 编码算法, 根据交易类型的不同，遵循下列公式\r\n\r\n- legacy, type = 0\r\n\r\n```go\r\nRLP.encode([nonce, gasPrice, gasLimit, to, value, data, v, r, s])\r\n```\r\n\r\n- EIP-2930, type = 1\r\n\r\n```go\r\n0x01 || RLP.encode([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])\r\n```\r\n\r\n- EIP-1559, type = 2\r\n\r\n```go\r\n0x02 || RLP.encode([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])\r\n```\r\n\r\n### 序列化\r\n\r\n序列化的过程本质是将交易对象中的字段按照一定的顺序排列\r\n\r\n对于不同交易类型, 按照上述公式存在不同的顺序。(未签名时，最后三个私钥签名字段可为空)\r\n\r\n- `type=0`: 顺序为 [`nonce`, `gasPrice`, `gas`, `to`, `value`, `data`]\r\n- `type=1`: 顺序为 [`chainId`, `nonce`, `gasPrice`, `gas`, `to`, `value`, `data`, `accessList`]\r\n- `type=2`: 顺序为 [`chainId`, `nonce`, `maxPriorityFeePerGas`, `maxFeePerGas`, `gas`, `to`, `value`, `data`, `accessList`]\r\n\r\n对于交易\r\n\r\n```ts showLineNumbers\r\n{\r\n  form: \"0x2557D0d204a51CF37A0474b814Afa6f942f522cc\",\r\n  to: \"0x87114ed56659216E7a1493F2Bdb870b2f2102156\",\r\n  nonce: \"0x9\",\r\n  type: \"0x2\",\r\n  value: \"0x2386f26fc10000\",\r\n  maxPriorityFeePerGas: \"0x3e6\",\r\n  maxFeePerGas: \"0x482\",\r\n  gas: \"0x5208\"\r\n}\r\n```\r\n\r\n在 `goerli` 网络上序列化后的结果为\r\n\r\n```ts showLineNumbers\r\nconst serializedTransaction = [\r\n  '0x5', // chainId\r\n  '0x9', // nonce\r\n  '0x3e6', // maxPriorityFeePerGas\r\n  '0x482', // maxFeePerGas\r\n  '0x5208', // gas\r\n  '0x87114ed56659216E7a1493F2Bdb870b2f2102156', // to\r\n  '0x2386f26fc10000', // value\r\n  '0x', // data\r\n  [] // accessList\r\n]\r\n```\r\n\r\n### 编码\r\n\r\n将序列化的结果进行 `RLP` 编码, 得到 `Uint8` 类型的字节数组, 同时将交易类型加入到数组的第一个元素\r\n\r\n```ts showLineNumbers\r\nimport RLP from 'rlp'\r\nconst toRlp = (serializedTransaction) => {\r\n  // 交易类型加入到数组第一个元素\r\n  return new Uint8Array([2, ...RLP.encode(serializedTransaction)])\r\n}\r\nconst rlp = toRlp(serializedTransaction)\r\n```\r\n\r\n按照上述公式, 如果 `type = 0`, 则无需将交易类型加入数组\r\n\r\n最后对 `RLP` 编码结果应用 `keccak_256` 哈希函数, 生成 32 字节的哈希值\r\n\r\n```ts showLineNumbers\r\nimport { keccak_256 } from '@noble/hashes/sha3'\r\nconst hash = toHex(keccak_256(rlp))\r\n```\r\n\r\n### secp256k1 加密\r\n\r\n将哈希结果使用私钥签名\r\n\r\n```ts showLineNumbers\r\nimport { secp256k1 } from '@noble/curves/secp256k1'\r\n\r\nconst { r, s, recovery } = secp256k1.sign(hash.slice(2), privateKey.slice(2))\r\nreturn {\r\n  r: toHex(r),\r\n  s: toHex(s),\r\n  v: recovery ? 28n : 27n\r\n}\r\n```\r\n\r\n得到签名结果 `r`、`s`、`v` 后, 按照公式重新将其加入到序列化数组中, 并重新进行 `RLP` 编码\r\n\r\n```ts showLineNumbers\r\nserializedTransaction.push(\r\n  signature.v === 27n ? '0x' : toHex(1), // yParity\r\n  r,\r\n  s\r\n)\r\nconst lastRlp = toRlp(serializedTransaction)\r\n```\r\n\r\n得到最终结果 `lastRlp` 是一个 `Uint8` 类型的字节数组, 每个元素占用 1 个字节，范围在 0 - 255。值表示为在长度为 256 按顺序构成的 16 进制数组中的索引\r\n\r\n```ts showLineNumbers\r\n// 将数字从 0 到 255 转成 16 进制, 并存储数组中\r\n// [ \"00\", \"01\", \"02\", \"03\", \"04\", \"05\", \"06\", \"07\", \"08\", \"09\", \"0a\", \"0b\", \"0c\", \"0d\", \"0e\", ...]\r\nconst hexes = Array.from({ length: 256 }, (_v, i) =>\r\n  i.toString(16).padStart(2, '0')\r\n)\r\n\r\n// 遍历 lastRlp 数组, 将数组元素存储的索引值，在 hexes 找到对应的值进行拼接\r\nconst signedTransaction =\r\n  '0x' +\r\n  lastRlp.reduce((prev, current) => {\r\n    return prev + hexes[current]\r\n  }, '')\r\n```\r\n\r\n最后得到签名后的交易 `signedTransaction`，为一个 16 进制的字符串\r\n\r\n## 发送交易\r\n\r\n通过 `JSON RPC` 方法 `eth_sendRawTransaction`, 将 `signedTransaction` 发送到节点\r\n\r\n```ts\r\nconst sendRawTransaction = async (signedTransaction) => {\r\n  const response = await axios.post(rpc_url, {\r\n    jsonrpc: '2.0',\r\n    method: 'eth_sendRawTransaction',\r\n    params: [signedTransaction],\r\n    id: 1\r\n  })\r\n  return response.data.result\r\n}\r\n```\r\n\r\n`eth_sendRawTransaction` 方法将返回交易哈希\r\n\r\n## 获取交易回执\r\n\r\n发送交易后, 为了确保交易完成。可轮询调用 `JSON RPC` 方法 `eth_getTransactionReceipt` 获取交易回执\r\n\r\n```ts\r\nconst getTransactionReceipt = async (hash: string) => {\r\n  const response = await axios.post(rpc_url, {\r\n    jsonrpc: '2.0',\r\n    method: 'eth_getTransactionReceipt',\r\n    params: [hash],\r\n    id: 1\r\n  })\r\n  return response.data.result\r\n}\r\n\r\nconst interval = setInterval(async () => {\r\n  const receipt = await getTransactionReceipt(hash)\r\n  console.log(receipt)\r\n\r\n  if (receipt && receipt.blockNumber) clearInterval(interval)\r\n}, 4000)\r\n```\r\n\r\n`eth_getTransactionReceipt` 接收参数为交易哈希\r\n\r\n完整代码见 [Github](https://github.com/lybenson/send-primitive-transaction)"},"author":{"user":"https://learnblockchain.cn/people/9647","address":null},"history":null,"timestamp":1699959998,"version":1}