イーサリアムの 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
は本質的に HTTPpost
リクエストであり、リクエストパラメータは固定の形式である点が異なります。以下のようになります。{ 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 進数のブロック高さの値、またはlatest
、earliest
、pending
のいずれかであることができます。- 特定のブロック番号:そのブロックの指定アドレスの取引カウントを照会
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-1559
、gasPrice
が削除され、maxPriorityFeePerGas
とmaxFeePerGas
が追加されます。
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
}
署名結果 r
、s
、v
を得た後、公式に従って再度シリアル化配列に追加し、再度 RLP
エンコードを行います。
serializedTransaction.push(
signature.v === 27n ? '0x' : toHex(1), // yParity
r,
s
)
const lastRlp = toRlp(serializedTransaction)
最終結果 lastRlp
は Uint8
型のバイト配列であり、各要素は 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 を参照してください。