はじめに
先日『ウォレットアドレスの最後の2を3に変えたら、「ウォレットアドレスではありません」という警告が出たんだけどなぜわかるのか?』という質問をされました。
ここでは、Ethereumのウォレットアドレスとチェックサムの仕組みについて説明していこうと思います。
Ethereum address
Ethereumのウォレットアドレスは、公開鍵暗号(Public-key cryptography)の公開鍵(Public key)を加工したものとなります。
仕様
仕様は、Ethereum Yellow PaperのP.26に記載されています。
原文
For a given private key, $p_r$, the Ethereum address $A(p_r)$ (a 160-bit value) to which it corresponds is defined as the rightmost 160-bits of the Keccak-256 hash of the corresponding ECDSA public key:与えられた秘密鍵 $p_r$ に対して、それに対応するEthereumアドレス $A(p_r)$ (160ビットの値) は、対応するECDSA公開鍵のKeccak-256ハッシュの右端から160ビットとして定義されます。
\begin{align*}
&(314)&A(p_r)=\mathcal{B}_{96\ldots255}(\mathrm{KEC}(\mathrm{ECDSAPUBKEY}(p_r)))
\end{align*}
実装
$\mathrm{KEC}$ 関数を実装します。
Keccak-256は「web3.js - Ethereum JavaScript API」ライブラリを使用します。
/**
* Keccak-256 hash function
*
* @param {string} hex データ
* @returns ハッシュ値
*/
function KEC(hex) {
let web3 = new Web3();
let hash = web3.utils.keccak256(hex);
return hash;
}
$\mathrm{ECDSAPUBKEY}$ は、以前の記事利用します。
公開鍵の64バイト(先頭0埋め)をKeccak-256のハッシュ値を求め、その後半(96~255ビット)がアドレスとなります。
/**
* Ethereumアドレス取得
*
* @param {bigint} pr 秘密鍵
* @returns Ethereumアドレス
*/
function A(pr) {
let pubKey = ECDSAPUBKEY(pr);
let hash = KEC('0x' + hex32(pubKey.x) + hex32(pubKey.y));
let address = '0x' + hash.substr(2 + (96 / 8) * 2);
return address;
}
例
実際に値を見てみます。
web3.js - Ethereum JavaScript APIにもアカウントを作成する機能があるので、比較してみます。
/**
* 例
*/
function example() {
let pr = 40n;
let web3 = new Web3();
let account = web3.eth.accounts.privateKeyToAccount('0x' + hex32(pr));
let address = A(pr);
console.log(account.address);
console.log(address);
}
結果は、アルファベットの大文字小文字以外では一致していることがわかります。
0xd817D23c981472d703bE36da777FFDb1ABEFd972
0xd817d23c981472d703be36da777ffdb1abefd972
チェックサム
EIP-55で規定されています。
仕様
文章で書かれているわけではなく、リファレンスコードのコメントが仕様となります。
それを私がまとめたものを記述するので、流れだけ理解してもらえればと思います。
- 20バイトのアドレスをインプットとします。
(例:0xd817d23c981472d703be36da777ffdb1abefd972
) - アドレスのバイト部分を文字列(ascii/utf-8)として、keccak256ハッシュを取得します。
※:20バイトのアドレスをHEX(16進数)表記で文字列としますが、ここでアルファベットは全て小文字とします。
(例:0x2814cd2041eb714bf97ca201e3eccb7edbfa1eb892d164f18f25c80a2c240cd8 = keccak256("d817d23c981472d703be36da777ffdb1abefd972")
) - 16進数アドレスの各文字について処理を行います。
- 文字が数値の場合:
- そのまま数値を使用します。
- 文字が文字(
abcdef
)の場合- ハッシュの対応する4ビットが8以上である場合
- 大文字を使用します。
- それ以外の場合
- 小文字を使用します。(小文字)
- ハッシュの対応する4ビットが8以上である場合
- 文字が数値の場合:
例:
0xd817d23c981472d703be36da777ffdb1abefd972
0x2814cd2041eb714bf97ca201e3eccb7edbfa1eb892d164f18f25c80a2c240cd8
0xd817D23c981472d703bE36da777FFDb1ABEFd972
0x
の次から見ていきます。
0番目は、アドレスはd
で、ハッシュ値は2
(8未満)なので、アドレスは小文字のまま使います。
1番目は、アドレスは8
なので、数値のまま使います。
2番目は、アドレスは1
なので、数値のまま使います。
3番目は、アドレスは7
なので、数値のまま使います。
4番目は、アドレスはd
で、ハッシュ値はc
(8以上)なので、アドレスは大文字D
を使います。
:
これをアドレスの最後まで行います。
そうする事で以下のアドレスが取得できます。
0xd817D23c981472d703bE36da777FFDb1ABEFd972
根拠(Rationale)
EIP-55に根拠についての記述がありましたので、翻訳してみます。
原文
Benefits:- Backwards compatible with many hex parsers that accept mixed case, allowing it to be easily introduced over time
- Keeps the length at 40 characters
- On average there will be 15 check bits per address, and the net probability that a randomly generated address if mistyped will accidentally pass a check is 0.0247%. This is a ~50x improvement over ICAP, but not as good as a 4-byte check code.
利点:
- 大文字と小文字の混合を受け入れる多くの 16 進パーサーと下位互換性があるため、時間の経過とともに簡単に導入できます。
- 長さを 40 文字に保ちます。
- 平均して、アドレスごとに 15 のチェック ビットがあり、ランダムに生成されたアドレスがタイプミスされた場合に誤ってチェックを通過する正味の確率は 0.0247% です。 これは ICAP の 50 倍程度の改善ですが、4 バイトのチェック コードほどではありません。
実装
アドレスをチェックサムアドレスに変換する関数を実装してみます。
リファレンスコードを原型としてますが、こまかい部分で使用している関数の引数などが異なるのでカスタマイズしています。
/**
* チェックサムアドレス変換
*
* @param {string} address アドレス
* @returns チェックサムアドレス
*/
function toChecksumAddress(address) {
let addr = address.toLowerCase();
let hash = KEC(addr.substring(2));
let caddr = '0x';
for (let i = 2; i < addr.length; i++) {
if (parseInt(hash[i], 16) >= 8) {
caddr += addr[i].toUpperCase();
} else {
caddr += addr[i];
}
}
return caddr
}
例
実際に使用してみます。
web3.js - Ethereum JavaScript APIにもアカウントを作成する機能があるので、一致するのかも見てみます。
/**
* 例
*/
function example() {
let pr = 40n;
let web3 = new Web3();
let account = web3.eth.accounts.privateKeyToAccount('0x' + hex32(pr));
let address = A(pr);
console.log(account.address);
console.log(address, address == account.address);
let checksumAddress = toChecksumAddress(address);
console.log(checksumAddress, checksumAddress == account.address);
}
結果は以下のとおりとなります。
正常に変換できている事がわかります。
0xd817D23c981472d703bE36da777FFDb1ABEFd972
0xd817d23c981472d703be36da777ffdb1abefd972 false
0xd817D23c981472d703bE36da777FFDb1ABEFd972 true
判定
Ethereumアドレスを判定する関数を実装してみます。
EIP-55にある「Test Cases」を見てみます。
詳細な内容は書いてありませんが、全部大文字や全部小文字もアドレスとして許容する必要がありそうです。
# All caps
0x52908400098527886E0F7030069857D2E4169EE7
0x8617E340B3D01FA5F11F306F4090FD50E238070D
# All Lower
0xde709f2102306220921060314715629080e2fb77
0x27b1fdb04752bbc536007a920d24acb045561c26
# Normal
0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed
0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359
0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB
0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb
実装
0x
から始まる16進数表記であるかを正規表現で判定します。
そして、大文字だけなのか、小文字だけなのか、大文字小文字が混在しいてる場合はチェックサムと一致するかを判定します。
/**
* Ethereumアドレス判定
*
* @param {string} address アドレス
* @returns 結果
*/
function isAddress(address) {
let result = false;
if (address.match(/^0x[0-9a-fA-F]{40}$/)) {
if (address == address.toUpperCase()) {
result = true;
} else {
if (address == address.toLowerCase()) {
result = true;
} else {
if (address == toChecksumAddress(address)) {
result = true;
}
}
}
}
return result;
}
Test Case
EIP-55にあるテストケースを試します。
/**
* テスト(EIP55 Test Case)
*/
function testForEIP55() {
let addressList = [
// # All caps
'0x52908400098527886E0F7030069857D2E4169EE7',
'0x8617E340B3D01FA5F11F306F4090FD50E238070D',
// # All Lower
'0xde709f2102306220921060314715629080e2fb77',
'0x27b1fdb04752bbc536007a920d24acb045561c26',
// # Normal
'0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed',
'0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359',
'0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB',
'0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb',
];
for (let addr of addressList) {
console.log(addr, isAddress(addr));
}
}
結果は以下となり、全て成功していることがわかります。
0x52908400098527886E0F7030069857D2E4169EE7 true
0x8617E340B3D01FA5F11F306F4090FD50E238070D true
0xde709f2102306220921060314715629080e2fb77 true
0x27b1fdb04752bbc536007a920d24acb045561c26 true
0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed true
0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359 true
0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB true
0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb true
まとめ
仕様が若干わかりずらい(コードだけ)ですが、実装できました。
最初の「2」を「3」に変えた場合をやってみます。
console.log('0xd817D23c981472d703bE36da777FFDb1ABEFd972', isAddress('0xd817D23c981472d703bE36da777FFDb1ABEFd972'));
console.log('0xd817D23c981472d703bE36da777FFDb1ABEFd973', isAddress('0xd817D23c981472d703bE36da777FFDb1ABEFd973'));
結果は以下となり、末尾の2を3に変えるとチェックサムアドレスの判定に失敗します。
0xd817D23c981472d703bE36da777FFDb1ABEFd972 true
0xd817D23c981472d703bE36da777FFDb1ABEFd973 false