did-btcr2-js

ADR 014: Canonicalization as Standalone Functions, toJSON() Convention, and base64urlnopad Default

Status: Accepted

Date: 2026-03-17

Commit: 11477d3

Context

Three concerns around object-to-bytes conversion came due at the same time and were resolved together in the @did-btcr2/common v6.0.0 refactor:

  1. Canonicalization shape. A Canonicalization class had accumulated static methods (canonicalize, hash, encodeHash, decodeHash) plus a small amount of state: options, cached output: that nothing actually used. It was a class in name only, and the import shape (Canonicalization.canonicalize(obj)) was noisier than a free function for a fundamentally stateless pipeline.

  2. JCS + class instances + toJSON(). JCS (RFC 8785) sorts object keys before serialization, and its implementation enumerates the object’s own enumerable keys. When a class instance is passed in, those are the class’s instance fields, not the JSON shape the class advertises via toJSON(). Two class instances with identical public JSON shapes could therefore hash to different canonical bytes because their internal field sets differed. This turned up as sporadic hash-mismatch bugs when a DID document was passed through canonicalization as a class instance in one path and as a JSON round-trip in another.

  3. Encoding policy. The previous canonicalization utilities only exposed hex and a base58 encoder named base58btc. The did:btcr2 spec uses base64urlnopad as the default encoding for document hashes, and distinguishes between raw base58 (base58) and base58 with a multibase prefix (base58btc). The existing code conflated the two. multiformats was in the dependency graph purely for encoding strings, which is a heavy package for a lightweight need.

Two smaller issues rode along:

Options considered

  1. Keep the class; patch the JCS bug by requiring callers to pass POJOs; keep hex/base58btc; skip base64url. Minimal churn. Leaves callers responsible for remembering to round-trip class instances before canonicalization: exactly the kind of invariant that will be forgotten in some code path and surface as a bug months later.
  2. Functional API (canonicalize, hash, encode, decode, canonicalHash); fix the JCS bug inside canonicalize; swap multiformats for @scure/base; add base64urlnopad as default encoding. Addresses every concern directly, aligns with browser-compat policy (ADR 019).
  3. Option 2 + elevate toJSON() as a package-wide serialization contract. Codifies that every class with a canonicalization-relevant representation implements toJSON(), and that the canonical hash pipeline always passes through a JSON.parse(JSON.stringify(...)) round-trip before JCS.

Decision

Option 3. Four coordinated changes:

Carried along with the refactor:

Consequences

Positive

Negative

Explicitly accepted trade-offs

References