EthereumはBitcoinと同様にTransactionHashをTransactionのrawデータから求めることができます。
今回はEthereumのTransactionHashの求め方について調べたことをまとめました。
EIP-155でTransactionへの署名方法が変わっているため、EIP-155適用前と後で分けて説明します。
- 1. EIP-155適用前のTransactionHashの求め方
- 2. EIP-155適用後のTransactionHashの求め方
- 下位互換
- サンプルコード
- chainIdとnetworkIdについて
- ganacheでの注意点
- まとめ
- 参考
1. EIP-155適用前のTransactionHashの求め方
まずは以前のTransactionHashの算出方法について説明します。
1-1.Transactionのpayloadからsign対象のHashを求める
Transactionのpayloadは以下の6つの要素からなります。
- nonce
- gasPrice
- gas limit
- to
- value
- data
この6つの要素をこの順番でrlp(Recursive Length Prefix)構造で結合したbyte列に対してkeccak256で算出したhash値がsign対象のhash値となります。
1-2. 1-1で求めたhash値にsignして、v,r,sを求める
1で求めたhash値に秘密鍵でsignをします。signで算出されるデータはbitcoinではr,sだけでしたがEthereumではそれにプラスしてpublicKeyを確定するためのv値も求めます。
- v: 27 or 28
- r
- s
1-3. 1-1と1-2で求めたデータを使いtransaction idを算出する
signで求めたデータも含めて以下の並びでrlp構造で結合します。
- nonce
- gasPrice
- gas limit
- to
- value
- data
- v
- r
- s
rlp構造で結合したbyte列に対して先ほどと同じくkeccak256で算出したhash値がTransactionHash(=transaction id)となります。
2. EIP-155適用後のTransactionHashの求め方
bitcoinではtestnetとmainnetで使うbitcoin addressのフォーマットが違うため、testnetで発行したtransactionはmainnetでは使えません。
(追記)間違いをご指摘いただきました。
Bitcoinのアドレスのバージョンバイトはトランザクションデータには含まれないからそこは作用しない。testnetとmainnetで同じトランザクションが流用できないのは同じTXIDになるUTXOが基本的にないからかと。
— Shigeyuki Azuchi (@techmedia_think) 2018年3月7日
Bitcoinではtransactionを生成するときに、以前のtransactionをinputとして与えるのでmainnetとtestnetではこのinput transactionが変わるため同じtransactionが使えないということでした。
ですが、Ethereumではtestnetで発行したtransactionがそのままmainnetでも使えてしまいます。つまり、リプレイアタックされてしまうわけです。
この問題を解決するためにEIP-155が提案され採択されました。以下ではEIP-155に対応したtransactionの署名方法とtransaction hashの求め方について説明します。
2-1.sign対象となるhashを求める
EIP-155ではsign対象のデータとしてchainIdも含めるようにしました。transactionにsignをする場合はこのchainIdを含めたデータをpayloadとしてsign対象のhash値を求めます。
EIP-155対応のtransaction payloadは以下の9つのデータからなります。
- nonce
- gasPrice
- gas limit
- to
- value
- data
- chainId
- 0: 固定
- 0: 固定
この9つの値をこの順番でrlp構造で結合したbyte列に対してkeccak256でhash値を算出します。このhash値がsignの対象となります。
2-2. 2-1で求めたhash値にsignしてv,r,sを求める
以前と同じようにhash値に秘密鍵でsignしてv,r,sを求めます。EIP-155ではさらにv値を以下のように変更します。
- v: chainId * 2 + 8 + (元のv値27 or 28)
- r
- s
これにより、from addressをsignから求める前にv値を検証することでこのTransactionが自分のNetworkに対して送られたものかを判別できます。
2-3. 2-1と2-2で求めたデータを使いtransaction idを算出する
ここは1-3と同じなので詳しい説明は省略します。
下位互換
EIP-155では下位互換のための仕様も定義されています。
chainId=0として作成されたTransactionは以前の方式で署名されます。そのため現行のnodeに対しても古い形式のTransactionの署名も有効とみなされます。
サンプルコード
EIP-155適用前、適用後のTransactionを生成するためのサンプルコードを以下に提示します。
このサンプルコードはtruffle console上で実行してください。
また、このサンプルコードでは ethereumjs-utilとethereumjs-txを使用しているので事前にnpm installしておいてください。
- EIP-155以前のTransaction(リプレイアタックの対象になるのでaddressを書き換える場合は注意してください)
EthTx = require("ethereumjs-tx") Util = require("ethereumjs-util") fromAddress = '0x627306090abab3a6e1400e9345bc60c78a8bef57' toAddress = '0xf17f52151ebef6c7334fad080c5704d77216b732' nonce = web3.eth.getTransactionCount(fromAddress) txparams = {} txparams.nonce = nonce txparams.gasPrice = parseInt(web3.toWei(2, "GWei")) txparams.gas = 21000 txparams.to = toAddress txparams.value = parseInt(web3.toWei(1, "ether")) txparams.data = '' txparams.chainId = 0 tx = new EthTx(txparams) priv = Buffer.from('c87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3', 'hex') hash = tx.hash(false) // これがsign対象のhash値 tx.sign(priv)
- EIP-155以降のTransaction
EthTx = require("ethereumjs-tx") Util = require("ethereumjs-util") fromAddress = '0x627306090abab3a6e1400e9345bc60c78a8bef57' toAddress = '0xf17f52151ebef6c7334fad080c5704d77216b732' nonce = web3.eth.getTransactionCount(fromAddress) txparams = {} txparams.nonce = nonce txparams.gasPrice = parseInt(web3.toWei(2, "GWei")) txparams.gas = 21000 txparams.to = toAddress txparams.value = parseInt(web3.toWei(1, "ether")) txparams.data = '' txparams.chainId = 19083 tx = new EthTx(txparams) priv = Buffer.from('c87509a1c067bbde78beb793e6fa76530b6382a4c0241e5e4a9ec0a0f44dc0d3', 'hex') hash = tx.hash(false) // これがsign対象のhash値 tx.sign(priv)
- 検証のサンプルコード
from address 0x627306090abab3a6e1400e9345bc60c78a8bef57 が復元され正しく署名されていることが確認できます。
pubkey = Util.ecrecover(hash, Util.bufferToInt(tx.v), tx.r, tx.s) Util.publicToAddress(pubkey)
- ブロードキャスト
以下のコードを実行すれば署名したtransactionをnodeに送信し正しいことを確認できます。
web3.eth.sendRawTransaction(Util.bufferToHex(tx.serialize()))
chainIdとnetworkIdについて
chainIdはnetworkIdではありません。go-ethereumではchainIdはgenesis.jsonで与えられます。networkIdはnode起動時に任意の値を付与できます。
また、web3.jsで取得できるのはnetworkIdでありchainIdではりません。networkの違いはchainIdによって区別されます。
private nodeを立てるときはこの辺りを意識しないと思わぬ落とし穴にはまるかもしれません。
ganacheでの注意点
ソースを深くまで調べるのが難しかったので以下は実際に動かしてみた結果で判断しています。間違いがあればご指摘ください。
ganacheはTestRPCのため、Transactionの署名に関しての検証が結構アバウトです。以下にgo-ethereumとは違う部分を列挙します。
- TransactionHash(=transaction id)には署名まえのhash値を使う。(つまり、1-1や2-1で求めたhash値がtransaction idになる)
- chainIdは持っているが、送られてきたtransactionのchainId(v値から算出される値)が違っていても検証エラーにはならない。
- v値からもとまるchainIdごとにnonceを保持しているみたい?(これは詳しくは調べてない。でも適当なchainIdの場合はnonceの値が違うよってエラーがでた)
まとめ
EIP-155以前と以降で処理が変わっているのですが、EIP-155が下位互換も持っているため、いろいろ混乱してしまいました。
また、ganacheのこの辺の扱いが結構適当なのも輪をかけて混乱させてくれました。。。。
ethereumではtestnetでもmainnetでも同じ秘密鍵がそのまま使えるので危ないなぁとは思っていたのですが、EIP-155ですでにリプレイアタックに対しては対応済みということがわかりました。ただし、chainId=0としてtransactionを発行、署名した場合は以前同様リプレイアタックの対象となるtransactionが発行されてしまうため、安全を考えるならtestnetとmainnetで使うaddressは違うものを利用した方が良さそうです。
EIP-155を見てもchainIdが新しく追加されたものかどうかよくわかりませんでした。(多分以前からあったっぽい?)この辺時間があればまた調べてみようかなぁ。