moon

moon

Build for builders on blockchain
github
twitter

Ethereum取引を手動で構築して送信する方法

イーサリアムの dapp(分散型アプリケーション)や取引スクリプトを開発する際、開発者は通常、イーサリアムブロックチェーンとのインタラクションプロセスを簡素化するために、特定のライブラリやフレームワークを利用します。これらのツールは便利な API インターフェースを提供し、開発者は簡単に取引を送信したり、チェーン上のデータを読み取ったり、ブロックチェーンとのインタラクションに関連する他の操作を実行できます。これらのライブラリやフレームワークは、取引の作成と送信プロセスを大幅に簡素化することができますが、その内部の取引構造と送信メカニズムは、しばしば開発者の視界の外に隠れています。

これらのフレームワーク内部での取引送信の原理を理解するために、本記事では、いかなるフレームワークにも依存せずに手動で取引を開始する方法を深く探ります。

フレームワークなしの環境で取引を送信するには、通常、以下のいくつかの核心的なステップを経る必要があります:

  • 取引オブジェクトの構築:取引に関連する情報(送信者、受信者、金額、Gas 価格など)を含む取引オブジェクトを作成する
  • 取引オブジェクトに署名する:プライベートキーを使用して構築した取引オブジェクトに署名し、取引の安全性と完全性を確保する
  • 取引を送信する:署名された取引オブジェクトをイーサリアムネットワークに送信する

取引の構造#

取引の原始データ構造

interface Transaction {
  form: Address // 取引の送信者
  to: Address // 取引の受信者
  nonce: Hex // 送信者のnonce
  type: Hex // 取引タイプ, 0(legacy) または 1(EIP-2930) または 2(EIP-1559)
  value: Hex // 取引に伴う主通貨の数量, 単位は wei
  data: Hex // 取引に伴うデータ
  maxPriorityFeePerGas?: Hex // EIP-1559: 1単位のgasの優先費用, type=2時に提供
  maxFeePerGas?: Hex // EIP-1559: 1単位のgasの最大費用, type=2時に提供
  gas: Hex // 使用可能な最大gasの数量(gasLimit)
  gasPrice?: Hex // gas価格, type!=2時に提供
  accessList?: [] // EIP-2930の新しい属性, 値はアドレスとストレージキーを含むリストで、主にEIP-2929による副作用問題を解決するため
}

関連フィールドは JSON RPC を通じて取得する必要があります。

JSON RPC は本質的に HTTP post リクエストであり、リクエストパラメータは固定の形式である点が異なります。以下のようになります。

{
  jsonrpc: '2.0', // JSON-RPCプロトコルバージョンを指定
  method: '', // 呼び出すメソッド名
  params: [], // 呼び出しメソッドに必要なパラメータ
  id: 1 // 今回のリクエストの番号
}

レスポンス結果の形式は以下のようになります。

{
  jsonrpc: '2.0', // JSON-RPCプロトコルバージョンを指定
  id: 1, // 今回のリクエストの番号, リクエストパラメータのidと一致
  result: '' // リクエスト結果
}

次に、取引オブジェクト内の各フィールドについて詳しく説明します。

from#

取引の送信者、必ず EOA アドレスである必要があります。

イーサリアムには 2 種類のアカウントがあります:外部アカウント、契約アカウント

  • 外部アカウント: Externally Owned Accounts の略称 EOA、プライベートキーを持ち、その codeHash は空です。

  • 契約アカウント: Contract Account の略称 CA、プライベートキーを持たず、その codeHash は非空です。

to#

取引の受信者、EOA でも CA でも可能です。

nonce#

取引送信者の nonce、値はアカウントが送信した取引の数のカウントで、主に 2 つの側面で機能します:

  • 二重消費の防止(リプレイ攻撃):イーサリアムネットワークでは、各取引には関連付けられた nonce 値があります。nonce は一度だけ使用できる数字で、各取引がユニークであることを保証します。この方法により、イーサリアムネットワークは二重消費攻撃を防ぐことができ、ユーザーは同じ資金を使って 2 回以上取引を行うことができません。

  • 取引の順序:ユーザーが新しい取引を送信すると、そのアカウントの nonce 値は自動的に増加します。このメカニズムにより、イーサリアムネットワークは取引が正しい順序で処理されることを保証します。すなわち、先に送信された取引が先に処理され、後に送信された取引が後に処理されます。これにより、アカウントの状態の正確性と取引の原子性が確保されます。

JSON RPC メソッド eth_getTransactionCount を使用して nonce 値を取得します。

import axios from 'axios'

const rpc_url = 'https://rpc.ankr.com/eth_goerli'

const getNonce = async () => {
  const response = await axios.post(rpc_url, {
    jsonrpc: '2.0',
    method: 'eth_getTransactionCount',
    params: [account.address, 'pending'],
    id: 1
  })
  return response.data.result
}

eth_getTransactionCount メソッドには以下の 2 つのパラメータがあります:

  • address: アカウントアドレス
  • blockNumber: ブロック番号、16 進数のブロック高さの値、または latestearliestpending のいずれかであることができます。
    • 特定のブロック番号:そのブロックの指定アドレスの取引カウントを照会
    • latest:最新ブロック時の指定アドレスの取引カウントを照会
    • earliest:創世ブロック(最初のブロック)時の指定アドレスの取引カウントを照会
    • pending:現在の保留中のブロック(マイナーによって処理されていないブロック)時の指定アドレスの取引カウントを照会

type#

取引タイプ、イーサリアムには 3 種類の取引タイプがあり、以下の値を取ります:

  • 0: legacy, EIP-2718 以前の取引、取引フィールドは以下の通りです。
from, to, type, value, data, nonce, gas, gasPrice
  • 1: EIP-2930、新しいフィールド accessList が追加されます。
from, to, type, value, data, nonce, gas, gasPrice, accessList
  • 2: EIP-1559gasPrice が削除され、maxPriorityFeePerGasmaxFeePerGas が追加されます。
from, to, type, value, data, nonce, gas, maxPriorityFeePerGas, maxFeePerGas, accessList

value#

取引に伴う ETH の数量、単位は WEI($1\tt{ETH}=10^{18}\tt{WEI}$)

data#

取引に伴うデータ、転送取引の場合、このフィールドは空であることができます。契約を呼び出す取引の場合、data は契約関数のセレクタハッシュ値に関数パラメータのエンコードを追加したものです。

maxPriorityFeePerGas#

1 単位の Gas の優先価格、type が 2 の場合にのみ提供され、この部分の費用はマイナーに支払われます。

JSON RPC メソッド eth_maxPriorityFeePerGas を使用して現在の最新の maxPriorityFeePerGas を取得します。

const getMaxPriorityFeePerGas = async () => {
  const response = await axios.post(rpc_url, {
    jsonrpc: '2.0',
    method: 'eth_maxPriorityFeePerGas',
    params: [],
    id: 1
  })
  return response.data.result
}

このメソッドは標準メソッドではなく、通常は第三者ノードサービスプロバイダー(alchemy、infura など)が提供します。使用しているノードにこのメソッドが存在しない場合は、以下の代替案を試すことができます。

  • JSON RPC メソッド eth_gasPrice を使用して現在の最新の gasPrice を取得する
  • JSON RPC メソッド eth_getBlockByNumber を使用して現在の最新のブロック情報 block を取得する。ブロック情報には baseFeePerGas が含まれています。

両者を引き算することで maxPriorityFeePerGas を得ることができます。

maxPriorityFeePerGas = gasPrice - block.baseFeePerGas

maxFeePerGas#

1 単位の Gas の最大価格、type が 2 の場合にのみ提供されます。このフィールドの目的は、gasPrice の変動によって取引がパッキングシーケンスから除外されるのを防ぐことです。通常、計算式は baseFeePerGas にある倍数 multiple を掛けてから maxPriorityFeePerGas を加えたものです。

maxFeePerGas = block.baseFeePerGas * multiple + maxPriorityFeePerGas

multiple が 2 の場合、連続して 6 つのブロックが満杯の Gas の場合でもメモリプール内でパッキングを待つことが保証されます。

異なるフレームワークでは multiple が異なる値に設定されています。 viem では値が 1.2、 ethers.js では値が 2 です。

gas#

このフィールドは、その取引が最大で消費できる gas の数量、すなわち gasLimit を意味します。転送取引の場合、この値は固定で 21000 です。

JSON RPC メソッド eth_estimateGas を使用して取引の予測値をこのフィールドの値として取得できます。

const estimateGas = async (originTransaction) => {
  const response = await axios.post(rpc_url, {
    jsonrpc: '2.0',
    method: 'eth_estimateGas',
    params: [originTransaction],
    id: 1
  })
  return response.data.result
}

const originTransaction = {
  form: '0x...',
  to: '0x...',
  nonce: '0x...',
  type: '0x2',
  value: '0x2386f26fc10000',
  maxPriorityFeePerGas: '0x3f7',
  maxFeePerGas: '0x42a'
}
originTransaction.gas = await estimateGas(originTransaction)

署名#

取引がプライベートキーの保有者によって発信されたことを確認するために、取引に署名する必要があります。署名前にシリアル化とエンコードのプロセスを経る必要があります。

取引のエンコードには RLP エンコードアルゴリズムを使用し、取引タイプに応じて以下の公式に従います。

  • legacy, type = 0
RLP.encode([nonce, gasPrice, gasLimit, to, value, data, v, r, s])
  • EIP-2930, type = 1
0x01 || RLP.encode([chainId, nonce, gasPrice, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])
  • EIP-1559, type = 2
0x02 || RLP.encode([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, signatureYParity, signatureR, signatureS])

シリアル化#

シリアル化のプロセスは、本質的に取引オブジェクトのフィールドを特定の順序で並べることです。

異なる取引タイプに応じて、上記の公式に従って異なる順序があります。(未署名の場合、最後の 3 つのプライベートキー署名フィールドは空であることができます)

  • type=0: 順序は [nonce, gasPrice, gas, to, value, data] です。
  • type=1: 順序は [chainId, nonce, gasPrice, gas, to, value, data, accessList] です。
  • type=2: 順序は [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gas, to, value, data, accessList] です。

取引の例

{
  form: "0x2557D0d204a51CF37A0474b814Afa6f942f522cc",
  to: "0x87114ed56659216E7a1493F2Bdb870b2f2102156",
  nonce: "0x9",
  type: "0x2",
  value: "0x2386f26fc10000",
  maxPriorityFeePerGas: "0x3e6",
  maxFeePerGas: "0x482",
  gas: "0x5208"
}

goerli ネットワーク上でシリアル化された結果は以下の通りです。

const serializedTransaction = [
  '0x5', // chainId
  '0x9', // nonce
  '0x3e6', // maxPriorityFeePerGas
  '0x482', // maxFeePerGas
  '0x5208', // gas
  '0x87114ed56659216E7a1493F2Bdb870b2f2102156', // to
  '0x2386f26fc10000', // value
  '0x', // data
  [] // accessList
]

エンコード#

シリアル化された結果を RLP エンコードし、Uint8 型のバイト配列を得ると同時に、取引タイプを配列の最初の要素に追加します。

import RLP from 'rlp'
const toRlp = (serializedTransaction) => {
  // 取引タイプを配列の最初の要素に追加
  return new Uint8Array([2, ...RLP.encode(serializedTransaction)])
}
const rlp = toRlp(serializedTransaction)

上記の公式に従い、type = 0 の場合は取引タイプを配列に追加する必要はありません。

最後に、RLP エンコード結果に keccak_256 ハッシュ関数を適用し、32 バイトのハッシュ値を生成します。

import { keccak_256 } from '@noble/hashes/sha3'
const hash = toHex(keccak_256(rlp))

secp256k1 暗号化#

ハッシュ結果をプライベートキーで署名します。

import { secp256k1 } from '@noble/curves/secp256k1'

const { r, s, recovery } = secp256k1.sign(hash.slice(2), privateKey.slice(2))
return {
  r: toHex(r),
  s: toHex(s),
  v: recovery ? 28n : 27n
}

署名結果 rsv を得た後、公式に従って再度シリアル化配列に追加し、再度 RLP エンコードを行います。

serializedTransaction.push(
  signature.v === 27n ? '0x' : toHex(1), // yParity
  r,
  s
)
const lastRlp = toRlp(serializedTransaction)

最終結果 lastRlpUint8 型のバイト配列であり、各要素は 1 バイトを占め、範囲は 0 - 255 です。値は長さ 256 の配列のインデックスとして表されます。

// 0から255までの数字を16進数に変換し、配列に格納します。
// [ "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "0a", "0b", "0c", "0d", "0e", ...]
const hexes = Array.from({ length: 256 }, (_v, i) =>
  i.toString(16).padStart(2, '0')
)

// lastRlp 配列を反復処理し、配列要素に格納されたインデックス値を使用して hexes から対応する値を見つけて結合します。
const signedTransaction =
  '0x' +
  lastRlp.reduce((prev, current) => {
    return prev + hexes[current]
  }, '')

最後に、署名された取引 signedTransaction が得られます。これは 16 進数の文字列です。

取引の送信#

JSON RPC メソッド eth_sendRawTransaction を使用して、signedTransaction をノードに送信します。

const sendRawTransaction = async (signedTransaction) => {
  const response = await axios.post(rpc_url, {
    jsonrpc: '2.0',
    method: 'eth_sendRawTransaction',
    params: [signedTransaction],
    id: 1
  })
  return response.data.result
}

eth_sendRawTransaction メソッドは取引ハッシュを返します。

取引のレシートを取得#

取引を送信した後、取引が完了したことを確認するために、JSON RPC メソッド eth_getTransactionReceipt を呼び出して取引のレシートを取得できます。

const getTransactionReceipt = async (hash: string) => {
  const response = await axios.post(rpc_url, {
    jsonrpc: '2.0',
    method: 'eth_getTransactionReceipt',
    params: [hash],
    id: 1
  })
  return response.data.result
}

const interval = setInterval(async () => {
  const receipt = await getTransactionReceipt(hash)
  console.log(receipt)

  if (receipt && receipt.blockNumber) clearInterval(interval)
}, 4000)

eth_getTransactionReceipt は取引ハッシュをパラメータとして受け取ります。

完全なコードは Github を参照してください。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。