3MIKAN
仮想通貨直コン

abi.encodePackedの衝突はハッシュ前に起きる:abi.encodeとの違いを確認する

abi.encodePackedで複数の文字列を連結すると区切りが消える理由を解説します。同じバイト列になる例、abi.encodeとの違い、区切り文字や署名で残る注意点を確認します。

3MIKANのキャラクターが同じ長さのリボンから区切りを外し、元の区切り位置が分からなくなることを示す場面

keccak256でハッシュしているから、異なる入力は区別できる。Solidityでデータの識別子を作るとき、この考え方だけでは不十分です。

ハッシュする前に異なる入力が同じバイト列へ変わっていれば、ハッシュ値も同じになります。 abi.encodePackedへ複数の可変長データを渡す場面では、この「連結時に境界が消える問題」を先に確認する必要があります。SolidityのABI仕様にも、この注意が明記されています。1

本記事では、文字列のバイト列をローカルで比較します。署名、ウォレット接続、コントラクトのデプロイは行いません。

掲載例はNode.js 22以降で実行できます。各節のコードをexample.mjsへ保存し、同じ節で使う関数定義と呼び出しを合わせてからnode example.mjsを実行してください。再利用できる関数はpacked-strings.mjsから取得できます。

2つの文字列でも、区切りが違えば別の入力

部署と権限のように、2つの文字列を独立した項目として扱うとします。

入力A:( "teamA", "read" )
入力B:( "team",  "Aread" )

2つの組み合わせは異なります。しかし、文字列を境界なしで連結すると、どちらもteamAreadです。

teamA | read  → teamAread
team  | Aread → teamAread

abi.encodePackedで直接渡したstringは、長さの情報を付けずにその内容を符号化します。この例では、Solidity側でも項目の区切りを失います。1

// 違う2組の入力が、同じバイト列になる説明用の式
abi.encodePacked("teamA", "read");
abi.encodePacked("team", "Aread");

この現象は、異なるバイト列から同じハッシュ値を探し当てた話ではありません。ハッシュへの入力が、すでに同一だったという話です。問題の場所をハッシュ関数と取り違えると、修正する箇所もずれてしまいます。

まずはハッシュせず、連結後のバイト列を比べる

次のNode.jsコードは、上のASCII文字列について、長さ情報なしの連結を再現します。

import assert from 'node:assert/strict';

const a = Buffer.concat([
  Buffer.from('teamA', 'utf8'),
  Buffer.from('read', 'utf8')
]);
const b = Buffer.concat([
  Buffer.from('team', 'utf8'),
  Buffer.from('Aread', 'utf8')
]);

assert.deepEqual(a, b);
console.log(a.toString('hex'));
console.log(a.equals(b));

実行結果は次のとおりです。

7465616d4172656164
true

Node.jsのBufferを使い、文字列から作ったバイト列を連結・比較しています。2 確認したのは、この2組の入力が同一の9バイトになることです。SolidityコンパイラやEVMの実行結果として報告しているわけではありません。

同じバイト列へ同じハッシュ関数を適用すれば、結果は同じです。そのため、この問題の再現に実アカウントや署名は要りません。ハッシュ値だけを見る前に、ハッシュ直前のバイト列をテストするのが分かりやすい方法です。

abi.encodeは項目の境界を残すが、型名まで埋め込むわけではない

abi.encodeの通常のABI符号化では、動的型のオフセットや長さを使ってデータを配置します。同じ(string, string)という型の組を前提にすると、先ほどの入力AとBは異なる符号化になります。1

// 同じ(string, string)型の入力を区別するための説明用の式
keccak256(abi.encode("teamA", "read"));
keccak256(abi.encode("team", "Aread"));

ここで「abi.encodeなら、あらゆる意味の違いが自動で区別される」と広げないことが重要です。ABI符号化は自己記述的な形式ではありません。デコード側には、対応する型情報が必要です。1

たとえば、uint8(1)uint256(1)は、通常のABIでそれぞれ単独の引数として符号化すると同じ32バイトの値になります。「数量の1」と「権限レベルの1」といった業務上の意味も、値だけでは区別されません。

そのため、異なる用途で識別子を共有する設計なら、入力の型だけでなく、用途やバージョンを表す項目も固定するのが一つの設計方針です。まず「何と何を別物として扱いたいか」を定義し、それを符号化の入力へ反映します。

区切り文字を1個足すだけでは解決しない

「間に|を入れればよい」と考えることもできます。しかし、入力自体に|を許すなら、次の2組が再び一致します。

("team|alpha", "read") → team|alpha|read
("team", "alpha|read") → team|alpha|read

これは本記事のローカルテストでも一致を確認した例です。区切り文字方式を採用するなら、文字の禁止、エスケープ、長さ情報などを含めた形式の定義が必要になります。

単なる文字列連結の問題を解決するために、独自の符号化規則を増やしすぎないことも大切です。すでにABIで型と順序を決められるなら、その構造を使うほうがレビューの対象を絞れます。

関数の呼び出しデータと、識別用の符号化も分ける

abi.encodeabi.encodePackedで値をバイト列にすることと、呼び出す関数を含むcalldataを作ることは別です。

Solidityには、関数セレクターを指定するabi.encodeWithSelectorや、関数ポインターと引数の型を検査するabi.encodeCallがあります。通常の関数呼び出しデータが必要な場面で、単にpacked形式へ変更するべきではありません。3

この違いは、「短くなったから同じ意味の効率的な形式だ」と考えないために重要です。データ長を変えると、受け取る側が想定する形式との互換性も検討する必要があります。本記事ではガス削減量や実行性能の比較はしていません。

署名に使う場合は、符号化とは別の確認も残る

abi.encodeへ変更して項目の境界を保存しても、署名の再利用や対象コントラクトの取り違えが自動で防げるわけではありません。

EIP-712は型付きデータとドメイン分離のための標準ですが、標準自体は再利用防止を含まないと明記しています。ノンスや期限を使う設計では、それらを署名対象へ入れるだけでなく、アプリケーション側で検証・消費する仕組みが必要です。4

ここでは署名の実装手順へ広げず、役割を分けます。符号化は「どの情報を同じデータとみなすか」、ドメイン分離は「どの文脈のデータか」、ノンス等の検証は「その操作を再び認めるか」という異なる課題です。

まとめ

abi.encodePackedを見たら、最初にハッシュ関数ではなく、入力の型・長さ・項目の境界を確認します。

複数の可変長項目を境界なしで連結しない。通常のABI符号化でも型と用途の取り決めを省略しない。呼び出しデータや署名では、それぞれの形式と再利用防止を別に確認する。この整理をしてから、短さやガスコストを比較します。

検証メモ

例はNode.js v22.16.0とv24.14.0で確認しました。ASCII文字列の区切り位置だけを変えても、連結後は同じバイト列になることを確かめています。Solidityの式は仕様を説明するためのもので、コンパイル、EVM実行、Keccak-256計算、署名検証、ガス計測は行っていません。付属のモデルは汎用ABIエンコーダーではありません。

関連記事:ABIから関数識別子・入力データ・イベントログを読む方法EIP-712署名の読み方

Footnotes

  1. Solidity — Contract ABI Specification / Non-standard Packed Mode。参照日:2026-09-05。 2 3 4

  2. Node.js — Buffer。参照日:2026-09-05。

  3. Solidity — ABI Encoding and Decoding Functions。参照日:2026-09-05。

  4. EIP-712 — Typed structured data hashing and signing。参照日:2026-09-05。

確認した一次情報