「ウォレットのアドレスをペーストしたら typo がないか教えて欲しい」とき、エンジニアは
validate_address(addr)的な library に放り込みがちです。でも内部で何が起きているかを 1 度知っておくと、Bitcoin と Ethereum がアドレスチェックサムについて 完全に違うエンジニアリング哲学 を持っていることが見えてきます。Base58Check (BTC legacy)、Bech32 / Bech32m (BTC SegWit)、EIP-55 (Ethereum) を 1 ファイル 250 行で verify するブラウザツールを書きながら、その差分を整理した話です。
🔐 Demo: https://sen.ltd/portfolio/address-decoder/
📦 GitHub: https://github.com/sen-ltd/address-decoder
3 つの設計、3 つの世界観
| 規格 | アドレス例 | チェックサム方式 | 設計年 | 特徴 |
|---|---|---|---|---|
| Base58Check | 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa |
4 byte (= SHA256² の先頭) | 2009 | 防御的、絶対に偽陽性しない |
| EIP-55 | 0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed |
文字の大小で 1 nibble あたり 1 bit | 2016 | あと付け、後方互換 |
| Bech32 / Bech32m | bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 |
30 bit polynomial mod | 2017 / 2021 | QR / 音声フレンドリー |
それぞれを順番に見ていきます。
Bitcoin の Base58Check — 防御的パラノイア
Satoshi のオリジナル設計。アドレスは:
[version byte (1)] [payload (20)] [checksum (4)] = 25 bytes
これを Base58 (Bitcoin alphabet) でエンコード。チェックサムは payload を含む 21 バイトの SHA256 を 2 回回した先頭 4 バイト。
const expected = (await dsha256(versionedPayload)).slice(0, 4);
const valid = bytesEqual(checksum, expected);
dsha256 は SubtleCrypto で 1 行:
async function dsha256(b) {
return new Uint8Array(
await crypto.subtle.digest("SHA-256",
await crypto.subtle.digest("SHA-256", b))
);
}
SHA256 を 2 回回す理由は長さ拡張攻撃 (length extension attack) の予防。当時 (2009) には今ほど枯れていなかった攻撃ベクター。今では SHA256 自体が単発でも安全だが、Bitcoin はこの設計を変えられない (ハードフォーク necessary)。
4 バイトのチェックサム = 32 bit。タイポで偽陽性を引く確率は 1 / 2³² ≒ 4 億分の 1。事実上ゼロ。代わりにアドレスは見るからに気持ち悪い (1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa)。
Base58 自体の落とし穴
Base58 アルファベットは紛らわしい文字 (0, O, I, l) を意図的に外して 58 文字。実装で罠なのが「先頭の 1 は 0x00 バイト」。
let leadingZeros = 0;
while (leadingZeros < s.length && s[leadingZeros] === "1") leadingZeros++;
// ... base58 → base256 変換 ...
return new Uint8Array([...zeros(leadingZeros), ...converted]);
これを忘れて純粋に「base58 数値化」してしまうと、先頭が 0x00 のアドレスを 1 バイト短く decode してチェックサムが合わなくなる。書きながら最初のテストで踏みました。
Ethereum の EIP-55 — あと付けチェックサム
Ethereum の 2015 リリース時点ではアドレスにチェックサムがなく、純粋な hex 表記でした:
0xde709f2102306220921060314715629080e2fb77
hex は case insensitive (0xa も 0xA も同じバイト 0xA を表す) なので、letter case が情報伝送に余白として残っていました。
EIP-55 (2016) はその余白を使ってチェックサムを後付けしました:
- address (lowercase) を ASCII 文字列として
keccak-256でハッシュ - ハッシュの i 番目の nibble (4 bit) を見る
- address の i 番目の文字が a-f で、ハッシュ nibble ≥ 8 → 大文字 に変える
- 数字はそのまま
function checksumHex(lowerHex, hashBytes) {
let out = "";
for (let i = 0; i < lowerHex.length; i++) {
const c = lowerHex[i];
if (c >= "0" && c <= "9") { out += c; continue; }
const byte = hashBytes[i >>> 1];
const nibble = (i % 2 === 0) ? (byte >>> 4) : (byte & 0x0f);
out += nibble >= 8 ? c.toUpperCase() : c;
}
return out;
}
0xde709f... を再キャピタライズすると 0xDE709F2102306220921060314715629080e2FB77 (実際の出力は spec 通り)。これと入力の case を比較して checksum 検証。
ここで美しいのが 下位互換性:
- 古いウォレット (EIP-55 を知らない) は全 lowercase のまま送ってくる → チェックサム無しとして受理
- 新しいウォレットは mixed case で送ってくる → checksum 検証
- 全 uppercase は理論上 valid な checksum 形になることもあれば、ならないこともある (アドレス次第)
つまり「EIP-55 を知らない人を排除しない」設計。仕様自体に「大文字/小文字に統一されたアドレスは checksum 無しと見なす」という but-clause があります。
keccak-256 の罠 — NIST SHA3 ではない
Ethereum で使う Keccak-256 は NIST SHA3-256 とは別物です。両者は同じ Keccak-f[1600] permutation を使うが、padding バイトが違う:
- Keccak-256 (Ethereum):
0x01 - SHA3-256 (NIST):
0x06
このたった 1 バイトの違いでハッシュ値が完全に変わります。Ethereum 関連の実装で「SHA3 で検証してる」というのは大体間違いです (Web Crypto の crypto.subtle.digest("SHA3-256", ...) を使うと EIP-55 が動きません)。
crypto.subtle には Keccak が無いので自前実装が必要。150 行ほど書きました。コア部分:
function keccakF(state) {
for (let round = 0; round < 24; round++) {
// θ: column parity と隣接列での XOR
// ρ + π: lane rotation と permutation
// χ: 行内の非線形 (a[x] ^= ~a[x+1] & a[x+2])
// ι: round constant XOR
}
}
JS の bitwise op は 32 bit に丸まるので 64 bit lane を (lo, hi) ペアで扱います。BigInt は重いので使わない。実装デバッグでは EIP-55 公式テストベクター (8 個) が一発で検証できるのが最高でした:
const EIP55_VECTORS = [
"0x52908400098527886E0F7030069857D2E4169EE7",
"0x8617E340B3D01FA5F11F306F4090FD50E238070D",
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
// ... 5 more
];
for (const addr of EIP55_VECTORS) {
test(`EIP-55: ${addr.slice(0,12)}…`, () => {
const r = decodeEthereumAddress(addr);
assert.equal(r.eip55_checksum, addr); // recompute matches input
});
}
最初は round constant の hi 32 bit が 1 個間違っていて 8 個全滅。1 行直したら 8 個全 pass。Cryptographic test vectors の偉大さを実感する瞬間でした。
Bitcoin SegWit の Bech32 / Bech32m — QR フレンドリー
Bech32 (BIP-0173, 2017) は SegWit native address (bc1...) のために設計された新エンコーディング。設計目標:
- 全 lowercase で QR コードが小さい (case sensitive な Base58 より QR error correction が効く)
-
音声で読めるアルファベット (
b,i,o,1を抜いた alnum 32 文字) - 隣接エラー検出能力が高い polynomial checksum
構造:
[hrp (e.g. "bc")] "1" [data part (5-bit groups)] [checksum (6 chars = 30 bit)]
1 は separator (HRP と data の境界、HRP には現れない)。チェックサムは BCH code by polynomial mod over GF(2³⁰):
function bech32Polymod(values) {
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
let chk = 1;
for (const v of values) {
const top = chk >>> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (let i = 0; i < 5; i++) {
if ((top >>> i) & 1) chk ^= GEN[i];
}
}
return chk >>> 0;
}
このアルゴリズムは 1 文字エラーを 100% 検出 でき、2 文字エラーも数学的にほぼ全て検出します (Bitcoin Core の commit history にエラー検出能力の数学的解析が残っています)。
Bech32m の悲しい後付け話 (BIP-0350)
Bech32 は「2 文字交換 (insertion + deletion)」のあるパターンで偽陽性が出ることが 2020 年に発見されました (sipa の解説)。修正版 Bech32m (constant 0x2bc830a3 ↔ Bech32 の 1) が BIP-0350 で導入され、witness version の運用ルールも変わりました:
| witness version | encoding |
|---|---|
| 0 (P2WPKH, P2WSH) | bech32 (旧) を使い続ける (互換のため) |
| 1+ (Taproot 等) | bech32m を使う (新規分から) |
つまり 2 つの規格が混在している。実装は両方の polymod constant をチェックして「どちらに合致したか」で variant を決める必要があります:
const variant =
poly === BECH32_CONST ? "bech32" :
poly === BECH32M_CONST ? "bech32m" : null;
const variantOk =
(witnessVersion === 0 && variant === "bech32") ||
(witnessVersion >= 1 && variant === "bech32m");
ここを v0 でも bech32m を受理してしまうと、本来 invalid なアドレスに送金ミスが起きる可能性。実装を直す価値のある区別です。
実装上のもう 1 つの落とし穴 — dispatcher の false positive
このツールは「貼られたアドレスがどの format か」を自動判別する dispatcher を持っています。最初の実装で BTC Genesis (1A1zP1eP5...) を貼ると bech32 decoder も「mixed case is forbidden」エラーで反応してしまいました。
理由:
- bech32 decoder は input に
1が含まれていれば separator candidate と見なす - BTC legacy アドレスには
1が含まれる ことが多い - mixed case 検出は早すぎた
修正は HRP が [a-z]+ でなければそもそも bech32 として扱わない:
const hrp = lc.slice(0, idx);
if (!/^[a-z]+$/.test(hrp)) return null;
実用 HRP (bc, tb, bcrt, ltc, tltc 等) は全て純粋な小文字 letters なので、これで十分絞り込めて false positive が消えました。仕様上は HRP に数字も許されているのですが、実運用ではゼロ。Postel's law は Bitcoin のアドレスでは「strict」側に倒すのが正解です。
まとめ — 設計哲学の対比
- Bitcoin Base58Check はパラノイア。SHA256² で 32 bit チェックサム、絶対に typo を見逃さない。アドレスが醜いトレードオフを受け入れた
- Ethereum EIP-55 は実用主義。letter case の余白を使った 1 nibble = 1 bit のチェックサム。規格を知らないクライアントを排除しないために「全 lowercase は checksum 無しと見なす」but-clause を設計に組み込んだ
- Bech32 / Bech32m はエンジニアリングの教科書。QR・音声・タイポ検出能力という具体的な要件から逆算したアルファベット設計、polynomial による mathematical guarantee、そして発覚した weakness に対して 互換を保ちつつ別 constant の Bech32m に分岐 する手堅さ
Web Crypto に Keccak が無い悔しさはあれど、3 つの規格をブラウザだけで完全 verify できる時代は良いものです。
コード全文 — decoder.js (250 行)、keccak256.js (150 行)、tests/ に EIP-55 全 8 ベクター + BIP-0173/0350 reference + BTC mainnet/testnet。MIT。
ライブデモ はクリック 1 つで example 6 種類が試せます。
