Skip to content

fix: validate the type byte of colour identifiers - #28

Merged
azuchi merged 2 commits into
masterfrom
fix/validate_color_id_type_byte
Sep 30, 2026
Merged

azuchi merged 2 commits into
masterfrom
fix/validate_color_id_type_byte

Conversation

@Yamaguchi

Copy link
Copy Markdown

Summary

A colour identifier is a token type byte followed by a 32 byte payload.
tapyrus-core accepts three type bytes and rejects an all-zero payload, both when
decoding an address and when running OP_COLOR.

The library checked the length and nothing else, so anything 33 bytes long
passed:

payments.cp2pkh({ hash, colorId: Buffer.concat([Buffer.from([0x00]), payload]) }).address
// -> 'XiCeF6Ty...'   no error

An output built from such an address can never be spent: the node refuses the
address, and the script fails when OP_COLOR runs. Any TPC sent to it is lost,
and one pasted address string is enough to get there.

Details

ts_src/types.ts gained ColorId, a typeforce-compatible predicate requiring 33
bytes, a type byte of 0xc1, 0xc2 or 0xc3, and a payload that is not all
zero, along with the three constants. ts_src/metadata.ts had its own copies of
those constants and now imports them.

It is applied in four places:

place before
payments.cp2pkh, payments.cp2sh typef.BufferN(33), plus nothing on the colour that validation settles on
address.fromBase58Check length only
address.toBase58Check not checked at all

Two more faults in the same functions:

  • toBase58Check wrote uninitialised memory into addresses. It allocated
    with Buffer.allocUnsafe and copied the colour in, so a short colour left the
    rest as whatever was in memory and a long one was truncated. The checksum was
    correct either way, so the caller could not notice. Now Buffer.alloc, with
    the length checked.
  • toOutputScript returned undefined typed as Buffer. The version byte
    and the payload length were never compared, so a colour version with a 21 byte
    payload produced a cp2pkh call with no colour, whose output is undefined,
    passed on through .output as Buffer. An uncoloured version with a 54 byte
    payload was read as P2PKH and the colour dropped. Both are rejected now, and
    the as Buffer casts are gone.

Affected areas

ts_src/types.ts, ts_src/address.ts, ts_src/payments/cp2pkh.ts,
ts_src/payments/cp2sh.ts, and the constants in ts_src/metadata.ts.

Every existing fixture uses a c3... colour and still passes. Code that was
passing a malformed colour was producing unspendable outputs and now gets an
error.

How to verify

npm ci
./scripts/check.sh

3,128 tests pass. Thirteen fail against the previous code, covering the three
type bytes 0x00 / 0xc0 / 0xc4, an all-zero payload, a colour that is not 33
bytes, a valid colour round-tripping through toBase58Check / fromBase58Check,
and the two version/payload mismatches.

@Yamaguchi
Yamaguchi requested a review from azuchi September 28, 2026 00:43

@azuchi azuchi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

マージを妨げる問題は見つかりませんでした。型バイト(0xc1 / 0xc2 / 0xc3)と全ゼロペイロードの検証は tapyrus-core の OP_COLOR の実装と一致しており、不正な colorId で使用不能な出力を作れてしまう問題は塞がれています。手元でも ./scripts/check.sh(3,128 件パス)、format:ci、lint:tests が通り、ビルド後の src/ / types/ に差分が出ないことを確認しました。

インラインのコメントはすべて非ブロッキングです。この PR で対応するか後続に回すかはお任せします。diff に現れない箇所で 2 点あります。

classify のテンプレートは型バイトを見ない(ts_src/templates/coloredpubkeyhash/output.ts、coloredscripthash/output.ts)
check は長さとオペコードの位置しか見ないので、型バイト 0xc4 のスクリプトも coloredpubkeyhash と分類され、txb.addOutput(script, value) に生スクリプトで渡すと受理されます。テンプレートの check でも types.ColorId を使えば、分類と payments の判定が揃います。

同種の allocUnsafe が payments 側に残っている(ts_src/payments/cp2pkh.ts:45、cp2sh.ts:61)
toBase58Check と同じエンコード処理が lazy な address に重複していて、こちらは Buffer.allocUnsafe(54) のままです。検証済みの長さなら全バイトが上書きされるので、影響が出るのは validate: false のときだけですが、toBase58Check を呼ぶ形に寄せると修正が 1 箇所にまとまります。

Comment thread ts_src/address.ts
if (colored && !colorId)
throw new Error(address + ' is missing a color identifier');
if (!colored && colorId)
throw new Error(address + ' has an unexpected color identifier');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

色の有無のチェックがネットワークのバージョン照合より先に走るので、別ネットワークの彩色アドレスを渡したときのエラーが誤解を招くものになります。手元で確認した結果です。

入力(network は prod) この PR master
dev の彩色アドレス has an unexpected color identifier has no matching Script
dev の無彩色アドレス has no matching Script has no matching Script

実際の原因はネットワーク不一致で、色は正しく付いています。無彩色アドレスは従来どおりなので扱いも不揃いです。バージョンがこのネットワークの 4 つのいずれかに一致した後で整合性を見る順序にすると解消します(一致しなければ従来どおり has no matching Script)。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応した。色とバージョンの整合は、バージョンがそのネットワークの 4 つのいずれかに一致した後で見る。別ネットワークのアドレスは、カラードコインでもTPCでも従来どおり has no matching Script になる。

Comment thread ts_src/address.ts Outdated
@@ -81,20 +92,25 @@ export function toOutputScript(address: string, network?: Network): Buffer {
} catch (e) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fromBase58Check が新たに投げる has an invalid color identifier は、この空の catch に握りつぶされて has no matching Script になります。型バイト 0x00 や全ゼロペイロードのアドレスを toOutputScript に渡しても、この PR が伝えたい「色識別子が不正」という理由が利用者に届きません。バージョンと色の不整合には専用メッセージがあるので、扱いも揃っていません。base58 のデコード失敗だけを握りつぶし、色識別子のエラーはそのまま投げ直す形にできないでしょうか。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Color Identifierのエラーだけ再 throwするように修正した。
エラー文言は定数にして、判定と throw で共有するようにした。

Comment thread ts_src/address.ts
): string {
typeforce(types.tuple(types.Hash160bit, types.UInt8), arguments);
typeforce(
types.tuple(types.Hash160bit, types.UInt8, types.maybe(types.ColorId)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toBase58Check は version と colorId の整合を見ないので、toOutputScript が拒否するアドレスを生成できます。

  • toBase58Check(hash, 0x00, validColorId) は無彩色バージョンに色付きペイロードを持つアドレスを返す
  • toBase58Check(hash, 0x01) は彩色バージョンで色のないアドレスを返す

追加されたフィクスチャの 2 件(1QKpvPF... と RtqvBaud...)がまさにこの形です。network を受け取らない低レベル関数なので、ここで弾くかどうかは設計判断だと思います。対応しない場合は、その旨を doc コメントに書いておくと親切です。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

コードでは弾かず、doc コメントで明記しました。

Comment thread ts_src/types.ts Outdated
/**
* A Tapyrus colour identifier: one token type byte followed by a 32 byte
* payload. tapyrus-core rejects any other type byte, and rejects an all-zero
* payload, both when decoding an address and when executing OP_COLOR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

記述の正確さについてです。tapyrus-core のアドレスデコード(src/key_io.cpp の DecodeDestination)が見ているのは cid.type != TokenTypes::NONE だけで、全ゼロペイロードは拒否しません。全ゼロを SCRIPT_ERR_OP_COLORID_INVALID で拒否するのは OP_COLOR の実行時のみです。

ライブラリ側が厳しい分には安全なので挙動はこのままでよいと思いますが、このコメントと PR 本文の「both when decoding an address and when running OP_COLOR」は、型バイトについてのみ両方、全ゼロペイロードについては OP_COLOR のみ、という書き方に直しておくと正確です。

あわせて、この JSDoc は ColorId 関数ではなく COLOR_ID_REISSUABLE の直前にあるため、生成された types/types.d.ts では定数の説明として出力されます。ColorId の直前に移すのがよさそうです。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc を ColorId の直前に移し、「Tapyrus Coreではサポート外のトークンタイプはアドレスのでコード時と実行時に弾き、全ゼロは実行時のみ弾いているが、ここではどちら(トークンタイプとペイロード)も弾く」と書き直しました。

Comment thread ts_src/types.ts Outdated
COLOR_ID_NFT,
];

function isBuffer(value: unknown): value is Buffer {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

細かい点です。isBuffer は Buffer.isBuffer で足ります。また COLOR_ID_LENGTH = 33 は address.ts にも同じ定義があり、payments 側は 33 / 34 のマジックナンバーのままです。定数を types.ts に集約する PR なので、長さも export して共有すると揃います。

@Yamaguchi Yamaguchi Sep 29, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COLOR_ID_LENGTH を types.ts から export し、address.ts と payments の 33 / 1 + 33 を置き換えた。

isBuffer は削除したが、Buffer.isBuffer ではなく typeforce.Buffer(value) を使った。types.ts は export const Buffer = typeforce.Buffer を持つため、このファイルの中では Buffer が global ではなくそちらを指す。Buffer.isBuffer は typeforce.Buffer.isBuffer になり、呼ぶと例外になる。(TypeError: exports.Buffer.isBuffer is not a function)

Comment thread ts_src/payments/cp2pkh.ts
hash = hash2;
}

if (colorId.length > 0 && !types.ColorId(colorId))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この検証は opts.validate のブロック内にあるので、validate: false ではアドレスや出力スクリプト経由の色が未検証のまま通ります。

payments.cp2pkh({ address: <型バイト 0xc4 のアドレス> }, { validate: false }).output
// -> 21c4222222...  例外なし

colorId を直接渡す経路は typeforce で常に検証されるので、address / output 経由だけ抜ける形です(cp2sh.ts も同じ)。明示的なオプトアウトなので優先度は低いですが、使用不能な出力を作らせないという PR の目的からすると、lazy な colorId の側で検証するのも一案です。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cp2pkh と cp2sh の lazy な colorId 側

  • ts_src/payments/cp2pkh.ts:77-85
  • ts_src/payments/cp2sh.ts:104-112
    で検証するようにした。不正なら"Invalid color identifier"を例外として投げるようにしている。

@Yamaguchi
Yamaguchi force-pushed the fix/validate_color_id_type_byte branch from 14fb1f7 to cfb3e8c Compare September 29, 2026 11:15
@Yamaguchi
Yamaguchi requested a review from azuchi September 30, 2026 01:32
@azuchi
azuchi merged commit 8449139 into master Sep 30, 2026
6 checks passed
@azuchi
azuchi deleted the fix/validate_color_id_type_byte branch September 30, 2026 01:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants