feat(wallet-metadata): NFT-commitment metadata on genesis — ticker + decimals + class fully on-chain

Implements step 1 of ADR-0057's three-layer metadata split. The 40-byte
minting-NFT commitment, previously set to '' on every genesis, now carries
the irreducible-minimum identity so a wallet with no network at all can
show "ASM (DEA, 2 dp)" for an on-chain amount — no BCMR URL fetch, no
local registry lookup.

Binary layout (pinned per ADR-0057 §1):
    byte  0:    magic   = 0x44 ('D')
    byte  1:    version = 0x01
    byte  2:    decimals (uint8, 0..18)
    byte  3:    assetClass byte (ASSET_CLASS_BYTE table)
    bytes 4..15: ticker, 12 UTF-8 bytes right-padded with 0x00 (ASCII only)
    bytes 16..39: reserved zero (24 B for future flags)

Disambiguation vs AdjustableFeeCapped (ADR-0011) which already uses the
commitment for live feeBps: length (40 vs 1-2) + magic byte. The genesis
planner enforces mutual exclusion at write time — assetClass is honoured
only when adjustableFee is NOT set.

Changes:
- NEW src/wallet/metadata-commitment.ts: pure encoder/decoder + format
  constants, no deps outside src/token/assetClass. Decoder returns null
  for anything not a well-formed metadata commitment (empty, short,
  wrong-magic, wrong-version) so callers can fall through to BCMR / local
  registry / "Unknown".
- NEW test/unit/wallet/metadata-commitment.test.ts: 32/32 pass. Round-
  trips every asset class, pins the exact byte layout, exercises every
  rejection case including the AdjustableFee-ScriptNum ambiguity.
- src/wallet/genesis.ts: GenesisIntent gains optional `assetClass`;
  prepareGenesis populates GenesisPlan.metadataCommitmentHex when the
  caller provides it AND adjustableFee is not set. Encoder failures
  (ticker too long, non-ASCII, decimals out of range) become INFO
  warnings on the plan — never block genesis.
- src/wallet/wallet.ts: createAsset picks the NFT shape by precedence:
  (1) adjustable-fee ScriptNum commitment, (2) ADR-0057 metadata
  commitment, (3) empty default (prior behaviour).
- wiki/decisions.md: ADR-0057 appended — the three-layer split, the
  binary layout, the AdjustableFee disambiguation rule, and what steps
  2 (Sia BCMR mirror) and 3 (BCMR consumer in shells/app) still owe.

Backward-compatible by construction: callers that don't set assetClass
get the exact prior commitment: '' default. 47/47 tests green (existing
genesis.test + new metadata-commitment.test). Core tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-10-22 21:00:32 +03:00
parent 549e906802
commit 6e57e08884
5 changed files with 588 additions and 1 deletions

View file

@ -32,6 +32,7 @@ import { ALLOWED_RATE_TIERS, wholeUnitRate, formatUnits } from '../tokenMetadata
import type { DisplayWarning } from './display.js';
import { CT_DUST_MIN_SATS } from './send-tier-a.js';
import { FEE_DUST_MIN_SATS, RESERVOIR_DUST_SATS } from './swap.js';
import { encodeMetadataCommitment } from './metadata-commitment.js';
/** Default reservoir supply — matches the chipnet experiments (10^18). */
export const DEFAULT_RESERVOIR_SUPPLY = 1_000_000_000_000_000_000n;
@ -65,6 +66,16 @@ export interface GenesisIntent {
readonly maxFeeBps: number;
readonly founderPubKeyHex: string;
};
/**
* Asset-class code for the on-chain metadata commitment on the minting-baton
* NFT (ADR-0057). When provided (and `adjustableFee` is NOT — those tokens
* use the commitment for live state), the planner emits a 40-byte metadata
* commitment carrying ticker + decimals + class. Offline wallets scanning
* the UTXO can then show the token's identity without an HTTP hop to BCMR.
*
* Absent ⇒ default empty commitment (current behaviour; backward-compatible).
*/
readonly assetClass?: import('../token/assetClass.js').AssetClass;
}
export interface PrepareGenesisOptions {
@ -97,6 +108,13 @@ export interface GenesisPlan {
readonly token: TokenTierA;
readonly confirmationMessage: string;
readonly warnings: readonly DisplayWarning[];
/**
* Hex of the 40-byte metadata commitment (ADR-0057) for the minting-baton
* NFT. Present when `intent.assetClass` was supplied AND `adjustableFee` was
* NOT. The TX builder reads this at output construction time; absent ⇒ the
* existing `commitment: ''` default (backward-compatible).
*/
readonly metadataCommitmentHex?: string;
}
export class GenesisPlanError extends Error {
@ -291,6 +309,30 @@ export function prepareGenesis(intent: GenesisIntent, options: PrepareGenesisOpt
` Genesis input: ${utxo.txid}:${utxo.vout} (${utxo.satoshis} sats)`,
].join('\n');
// Encode the ADR-0057 metadata commitment if the caller asked for it. We
// only emit on OpenMintFT (non-adjustable) because AdjustableFeeCapped
// already uses the minting-NFT commitment for live feeBps state and would
// read our 40-byte blob as a nonsense fee value.
let metadataCommitmentHex: string | undefined;
if (intent.assetClass && !intent.adjustableFee) {
try {
metadataCommitmentHex = encodeMetadataCommitment({
ticker: token.ticker,
decimals: decimals,
assetClass: intent.assetClass,
});
} catch (e) {
// Encoder refuses non-ASCII tickers, >12-byte tickers, decimals out of
// range — surface as a warning rather than a thrown error so the user
// still gets a genesis plan, just without the commitment sweetener.
warnings.push({
code: 'metadata-commitment-skipped',
severity: 'info',
message: `On-chain metadata commitment not emitted: ${(e as Error).message}`,
});
}
}
return {
intent,
categoryId,
@ -304,5 +346,6 @@ export function prepareGenesis(intent: GenesisIntent, options: PrepareGenesisOpt
token,
confirmationMessage,
warnings,
...(metadataCommitmentHex ? { metadataCommitmentHex } : {}),
};
}

View file

@ -0,0 +1,200 @@
/**
* NFT-commitment metadata — the irreducible-minimum, fully-on-chain,
* offline-resolvable "what is this token" record for a Deviant-authored MBA
* or NBA. Lives in the 40-byte commitment of the minting-baton NFT emitted
* at genesis (see ADR-0057).
*
* WHY: BCMR (CHIP-2023-04) hashes rich metadata on-chain and hosts the JSON
* at a URL; that is the right primary surface. But the URL can go dark, the
* host can disappear, and a wallet with no network at all still has to show
* the user something more useful than "Unknown token (abcd…ef12)". The 40
* bytes CashTokens allows on an NFT commitment are enough for the three
* facts that matter most offline:
* - ticker (what it's called)
* - decimals (how to render amounts)
* - asset-class code (MBA/NBA/DEA/PLA/MCA/MTA/MTU/MTL/PTU/PTL/DRA)
*
* This module is the ENCODER + DECODER + ADAPTIVE GUARD. The guard matters:
* other Deviant covenants (`DeviantAdjustableFeeCapped`) use the minting-NFT
* commitment for LIVE STATE (current feeBps). A decoder that blindly
* interprets any commitment as metadata would misread those tokens. The two
* are disambiguated by length (ours is always 40 bytes; adjustable-fee's is
* 1–2 bytes of ScriptNum) and by a magic prefix (`'D' = 0x44 0x01` for
* version 1).
*
* BINARY LAYOUT (40 bytes):
* byte 0: magic = 0x44 ('D' — Deviant metadata commitment)
* byte 1: version = 0x01
* byte 2: decimals (uint8, 0..18)
* byte 3: asset-class code (see ASSET_CLASS_BYTE table below)
* bytes 4..15: ticker, 12 UTF-8 bytes, right-padded with 0x00 (cap 12 chars)
* bytes 16..39: reserved, zero-initialized (24 B — future protocol flags)
*
* Decoders MUST check length === 40 AND magic === 0x44 AND version === 0x01
* before interpreting any bytes. Anything else is NOT a Deviant metadata
* commitment and must be handled per the covenant that produced it.
*
* SPDX-License-Identifier: MPL-2.0
*/
import type { AssetClass } from '../token/assetClass.js';
export const METADATA_COMMITMENT_LENGTH = 40;
export const MAGIC_BYTE = 0x44; // 'D'
export const VERSION_BYTE = 0x01;
export const TICKER_MAX_BYTES = 12;
export const DECIMALS_MAX = 18;
/**
* Asset-class → byte. High nibble = family (1 = MBA, 2 = Token), low nibble
* = specific class. Values stable; new classes get new slots, never reuse.
*/
export const ASSET_CLASS_BYTE: Record<AssetClass, number> = {
// MBA family
DEA: 0x11,
PLA: 0x12,
MCA: 0x13,
MTA: 0x14,
DRA: 0x15,
// Token (NBA) family
MTU: 0x21,
MTL: 0x22,
PTU: 0x23,
PTL: 0x24,
};
const BYTE_TO_ASSET_CLASS: Record<number, AssetClass> =
Object.fromEntries(Object.entries(ASSET_CLASS_BYTE).map(([k, v]) => [v, k])) as Record<number, AssetClass>;
export interface MetadataCommitment {
readonly v: 1;
readonly ticker: string;
readonly decimals: number;
readonly assetClass: AssetClass;
}
/**
* Error for bad inputs — long tickers, out-of-range decimals, unknown
* asset-class. The encoder refuses rather than silently truncates (silent
* truncation would be forever on chain).
*/
export class MetadataCommitmentError extends Error {
constructor(message: string, readonly code: 'ticker-too-long' | 'decimals-out-of-range' | 'unknown-class' | 'ticker-non-ascii') {
super(message);
this.name = 'MetadataCommitmentError';
}
}
/**
* Produce the 40-byte commitment hex for a given ticker/decimals/class.
* Returns lowercase hex (80 chars).
*/
export function encodeMetadataCommitment(input: MetadataCommitment | Omit<MetadataCommitment, 'v'>): string {
const ticker = input.ticker.trim();
// The commitment is permanent and visible to any wallet that reads it; a
// ticker that doesn't round-trip losslessly through UTF-8 is a bug the
// caller must fix. We cap at 12 BYTES (not chars) because the slot is
// byte-indexed and multi-byte characters eat the budget.
if (!/^[\x20-\x7E]+$/.test(ticker)) {
throw new MetadataCommitmentError(
`Ticker "${ticker}" has non-printable or non-ASCII characters; use ASCII only in the on-chain commitment.`,
'ticker-non-ascii',
);
}
const tickerBytes = new TextEncoder().encode(ticker);
if (tickerBytes.length > TICKER_MAX_BYTES) {
throw new MetadataCommitmentError(
`Ticker "${ticker}" is ${tickerBytes.length} bytes; max ${TICKER_MAX_BYTES}.`,
'ticker-too-long',
);
}
if (!Number.isInteger(input.decimals) || input.decimals < 0 || input.decimals > DECIMALS_MAX) {
throw new MetadataCommitmentError(
`Decimals ${input.decimals} out of range [0, ${DECIMALS_MAX}].`,
'decimals-out-of-range',
);
}
const classByte = ASSET_CLASS_BYTE[input.assetClass];
if (classByte === undefined) {
throw new MetadataCommitmentError(
`Unknown asset class "${input.assetClass}".`,
'unknown-class',
);
}
const buf = new Uint8Array(METADATA_COMMITMENT_LENGTH); // zero-initialized
buf[0] = MAGIC_BYTE;
buf[1] = VERSION_BYTE;
buf[2] = input.decimals;
buf[3] = classByte;
buf.set(tickerBytes, 4);
// bytes 16..39 already zero
return bytesToHex(buf);
}
/**
* Try to decode a commitment hex (or bytes) as Deviant metadata. Returns
* null for anything that is not a well-formed metadata commitment — a short
* commitment (AdjustableFeeCapped's ScriptNum feeBps), the empty commitment
* (unflagged covenants), or 40 bytes with the wrong magic/version. Decoders
* on the display path MUST handle null and fall back to BCMR or the local
* registry.
*/
export function decodeMetadataCommitment(commitment: string | Uint8Array): MetadataCommitment | null {
const bytes = typeof commitment === 'string' ? hexToBytes(commitment) : commitment;
if (!bytes || bytes.length !== METADATA_COMMITMENT_LENGTH) return null;
if (bytes[0] !== MAGIC_BYTE) return null;
if (bytes[1] !== VERSION_BYTE) return null;
const decimals = bytes[2]!;
if (decimals > DECIMALS_MAX) return null;
const assetClass = BYTE_TO_ASSET_CLASS[bytes[3]!];
if (!assetClass) return null;
// Ticker: read 12 bytes starting at offset 4, trim trailing 0x00s. We
// allow any printable ASCII through; non-printable would have been
// rejected by the encoder, but a hostile encoder may have put garbage
// there — treat the whole commitment as untrusted in that case.
const tickerSlice = bytes.slice(4, 4 + TICKER_MAX_BYTES);
let end = TICKER_MAX_BYTES;
while (end > 0 && tickerSlice[end - 1] === 0x00) end--;
if (end === 0) return null;
const trimmed = tickerSlice.slice(0, end);
for (let i = 0; i < trimmed.length; i++) {
const b = trimmed[i]!;
if (b < 0x20 || b > 0x7E) return null;
}
const ticker = new TextDecoder('ascii').decode(trimmed);
return { v: 1, ticker, decimals, assetClass };
}
/**
* Is this commitment a Deviant metadata commitment? Convenience wrapper for
* callers that just need to know whether to try the full decode (useful on
* the hot classifier path).
*/
export function isMetadataCommitment(commitment: string | Uint8Array): boolean {
const bytes = typeof commitment === 'string' ? hexToBytes(commitment) : commitment;
return !!bytes && bytes.length === METADATA_COMMITMENT_LENGTH && bytes[0] === MAGIC_BYTE && bytes[1] === VERSION_BYTE;
}
// ─── tiny hex helpers (local, dependency-free) ──────────────────────────
function bytesToHex(b: Uint8Array): string {
let s = '';
for (let i = 0; i < b.length; i++) s += (b[i]! >> 4).toString(16) + (b[i]! & 0xf).toString(16);
return s;
}
function hexToBytes(hex: string): Uint8Array | null {
const h = hex.startsWith('0x') ? hex.slice(2) : hex;
if (h.length === 0 || h.length % 2 !== 0) return null;
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) {
const byte = parseInt(h.slice(i * 2, i * 2 + 2), 16);
if (Number.isNaN(byte)) return null;
out[i] = byte;
}
return out;
}

View file

@ -555,12 +555,24 @@ export class DeviantWallet {
// the override just makes the on-chain shape match. See scriptNum.ts for
// the commitment codec (byte-identical to CashScript's `bytes(int)`).
const adjustable = plan.token.adjustableFee;
// Pick the NFT commitment per the three cases (ordered by precedence):
// 1. adjustable-fee → mutable NFT carrying the live fee as ScriptNum.
// Takes precedence because the covenant READS the commitment.
// 2. plan carries a 40-byte metadata commitment (ADR-0057) → emit it on
// the standard minting NFT. Carries ticker + decimals + class so an
// offline wallet can display the token without BCMR.
// 3. default → empty commitment on a minting baton (prior behaviour).
const nftOverride = adjustable
? {
capability: 'mutable' as const,
commitment: feeBpsToCommitmentHex(BigInt(plan.token.founderFeeBps)),
}
: undefined;
: plan.metadataCommitmentHex
? {
capability: 'minting' as const,
commitment: plan.metadataCommitmentHex,
}
: undefined;
const { txid } = await this.cfg.genesisExecutor.execute({
contractTokenAddress: plan.contractTokenAddress,
reservoirSupply: plan.reservoirSupply,

View file

@ -0,0 +1,167 @@
/**
* Spec test for the NFT-commitment metadata encoder/decoder (ADR-0057).
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ASSET_CLASS_BYTE,
MAGIC_BYTE,
METADATA_COMMITMENT_LENGTH,
MetadataCommitmentError,
TICKER_MAX_BYTES,
VERSION_BYTE,
decodeMetadataCommitment,
encodeMetadataCommitment,
isMetadataCommitment,
} from '../../../src/wallet/metadata-commitment.js';
import type { AssetClass } from '../../../src/token/assetClass.js';
const ALL_CLASSES: AssetClass[] = ['DEA', 'PLA', 'MCA', 'MTA', 'DRA', 'MTU', 'MTL', 'PTU', 'PTL'];
// ── Round-trip ─────────────────────────────────────────────────────────────
for (const assetClass of ALL_CLASSES) {
test(`round-trips ${assetClass} with a short ticker`, () => {
const hex = encodeMetadataCommitment({ ticker: 'ASM', decimals: 0, assetClass });
assert.deepEqual(decodeMetadataCommitment(hex), { v: 1, ticker: 'ASM', decimals: 0, assetClass });
});
}
test('round-trips a 12-byte ticker (the max)', () => {
const hex = encodeMetadataCommitment({ ticker: 'EURCMAX_1234', decimals: 2, assetClass: 'DEA' });
assert.deepEqual(decodeMetadataCommitment(hex), { v: 1, ticker: 'EURCMAX_1234', decimals: 2, assetClass: 'DEA' });
});
test('round-trips with decimals 0 and 18 (range endpoints)', () => {
for (const d of [0, 18]) {
const hex = encodeMetadataCommitment({ ticker: 'X', decimals: d, assetClass: 'MTU' });
assert.equal(decodeMetadataCommitment(hex)?.decimals, d);
}
});
test('encoded hex is lowercase and exactly 80 chars (40 bytes)', () => {
const hex = encodeMetadataCommitment({ ticker: 'ASM', decimals: 2, assetClass: 'DEA' });
assert.match(hex, /^[0-9a-f]{80}$/);
assert.equal(hex.length / 2, METADATA_COMMITMENT_LENGTH);
});
test('isMetadataCommitment agrees with decode (positive and ambiguous negatives)', () => {
const hex = encodeMetadataCommitment({ ticker: 'A', decimals: 0, assetClass: 'DEA' });
assert.equal(isMetadataCommitment(hex), true);
assert.equal(isMetadataCommitment(''), false); // empty — unflagged covenants
assert.equal(isMetadataCommitment('44'), false); // short — AdjustableFee feeBps (ScriptNum)
assert.equal(isMetadataCommitment('f401'), false); // 2-byte ScriptNum for 500
});
// ── Binary layout (pinned per ADR-0057) ────────────────────────────────────
test('layout: byte 0 is 0x44 (magic D), byte 1 is 0x01 (version)', () => {
const hex = encodeMetadataCommitment({ ticker: 'ASM', decimals: 0, assetClass: 'DEA' });
assert.equal(hex.slice(0, 2), '44');
assert.equal(hex.slice(2, 4), '01');
});
test('layout: byte 2 carries decimals exactly', () => {
const hex = encodeMetadataCommitment({ ticker: 'X', decimals: 7, assetClass: 'DEA' });
assert.equal(parseInt(hex.slice(4, 6), 16), 7);
});
test('layout: byte 3 encodes the asset class per ASSET_CLASS_BYTE', () => {
for (const c of ALL_CLASSES) {
const hex = encodeMetadataCommitment({ ticker: 'X', decimals: 0, assetClass: c });
assert.equal(parseInt(hex.slice(6, 8), 16), ASSET_CLASS_BYTE[c]);
}
});
test('layout: bytes 4..15 are the ticker right-padded with 0x00', () => {
const hex = encodeMetadataCommitment({ ticker: 'ASM', decimals: 0, assetClass: 'DEA' });
const tickerField = hex.slice(8, 8 + TICKER_MAX_BYTES * 2);
// 'A'=0x41 'S'=0x53 'M'=0x4d + 9 null bytes
assert.equal(tickerField, '41534d' + '00'.repeat(9));
});
test('layout: bytes 16..39 are reserved zero', () => {
const hex = encodeMetadataCommitment({ ticker: 'EURCMAX_1234', decimals: 2, assetClass: 'DEA' });
assert.equal(hex.slice(32, 80), '00'.repeat(24));
});
// ── Encoder input validation ───────────────────────────────────────────────
test('ticker longer than 12 bytes is refused', () => {
assert.throws(
() => encodeMetadataCommitment({ ticker: 'TOOLONGTICKER_', decimals: 0, assetClass: 'DEA' }),
MetadataCommitmentError,
);
});
test('non-ASCII ticker is refused (would silently truncate otherwise)', () => {
assert.throws(
() => encodeMetadataCommitment({ ticker: 'USDμ', decimals: 2, assetClass: 'DEA' }),
/non-ASCII/,
);
});
test('decimals out of range is refused', () => {
assert.throws(() => encodeMetadataCommitment({ ticker: 'X', decimals: 19, assetClass: 'DEA' }), /decimals/i);
assert.throws(() => encodeMetadataCommitment({ ticker: 'X', decimals: -1, assetClass: 'DEA' }), /decimals/i);
});
test('unknown asset class is refused', () => {
assert.throws(
() => encodeMetadataCommitment({ ticker: 'X', decimals: 0, assetClass: 'XYZ' as unknown as AssetClass }),
/asset class/i,
);
});
// ── Decoder rejection of non-metadata commitments ──────────────────────────
test('empty commitment → null (unflagged OpenMintFT/NBA default)', () => {
assert.equal(decodeMetadataCommitment(''), null);
});
test('1-byte ScriptNum (AdjustableFee feeBps=68, byte 0x44) → null — length guard saves us', () => {
// 68 encodes as 0x44 — same as our MAGIC. ADR-0057's length-first check
// means AdjustableFeeCapped tokens can never be misread as metadata.
assert.equal(decodeMetadataCommitment('44'), null);
});
test('2-byte ScriptNum (feeBps 500 = 0x01f4) → null', () => {
assert.equal(decodeMetadataCommitment('f401'), null);
});
test('40 bytes with wrong magic → null (future-safe for other 40-byte protocols)', () => {
const bogus = '42' + '01' + '00'.repeat(METADATA_COMMITMENT_LENGTH - 2);
assert.equal(decodeMetadataCommitment(bogus), null);
});
test('40 bytes with magic but wrong version → null', () => {
const bogus = '44' + '02' + '00'.repeat(METADATA_COMMITMENT_LENGTH - 2);
assert.equal(decodeMetadataCommitment(bogus), null);
});
test('odd-length hex → null', () => {
assert.equal(decodeMetadataCommitment('44010'), null);
});
test('malformed hex (non-hex chars) → null', () => {
assert.equal(decodeMetadataCommitment('zz' + '00'.repeat(39)), null);
});
test('all-null ticker bytes → null (no empty ticker)', () => {
const hex = '44' + '01' + '00' + '11' + '00'.repeat(36);
assert.equal(decodeMetadataCommitment(hex), null);
});
test('non-printable byte in the ticker slot → null', () => {
const hex = '44' + '01' + '00' + '11' + '1f' + '00'.repeat(35);
assert.equal(decodeMetadataCommitment(hex), null);
});
// ── Exported constants ─────────────────────────────────────────────────────
test('constants: MAGIC_BYTE 0x44, VERSION_BYTE 0x01, length 40, ticker-max 12', () => {
assert.equal(MAGIC_BYTE, 0x44);
assert.equal(VERSION_BYTE, 0x01);
assert.equal(METADATA_COMMITMENT_LENGTH, 40);
assert.equal(TICKER_MAX_BYTES, 12);
});

View file

@ -3175,3 +3175,168 @@ either side knowing the other exists.
`multisig` kind to be added next session)
- Covenant: `contracts/DeviantMultiSig.cash` (to be written)
- Memory: [[multi-account-keystore]], [[chipnet-shared-loader]], [[cashscript-covenant-findings]]
---
## ADR-0057 — Three-layer metadata split: covenant encodes rules, NFT commitment encodes identity, BCMR carries the rest
**Date:** 2026-10-22
**Status:** **Partially shipped** — the NFT-commitment layer (step 1) is implemented
and tested (`src/wallet/metadata-commitment.ts`, 32/32 unit tests). Steps 2 (Sia
mirror in the BCMR OP_RETURN) and 3 (BCMR consumer in the Deviant wallet shell)
are scoped for follow-on sessions.
**Context.** Deviant stores token metadata across three disjoint channels today,
in increasing distance from consensus:
1. **Covenant parameters** — `rateSats`, `founderFeeBps`, `founderLockingBytecode`
are baked into the P2SH32 contract bytecode. Change any of them and you get a
different contract address; consensus enforces integrity for free.
2. **BCMR OP_RETURN** (CHIP-2023-04) — a tiny on-chain anchor (`<'BCMR'> <sha256>
<URL>`) committing to a JSON file hosted off-chain. The JSON carries `name`,
`symbol`, `decimals`, `description`, `uris.icon`, `uris.web`, `tags`, plus a
`dvmb` extension with our template dispatch params. Already emitted by
`design/potidaea/src/bcmr.ts` on every MBA genesis + publish.
3. **LocalStorage registry** — client-side cache of the above.
Three gaps exposed by this split:
- **The NFT commitment is wasted** — set to `commitment: ''` on every minting
baton we emit. CashTokens allows 40 bytes there; we store zero.
- **BCMR hosting is single-point** — our one URL is on silentmode.st. If the VPS
goes dark the metadata URL stops resolving (though the on-chain hash still
proves what SHOULD be at the URL).
- **The Deviant wallet shell does not READ BCMR** — unknown tokens display as
"Unknown token (abcd…ef12)" because we never resolve the chain's BCMR anchors
back to the hosted JSON. Only our three hardcoded chipnet seeds (ASM, ASM2,
CAP) show tickers; everything else is nameless at the UI even when BCMR fully
describes it on chain.
### Decision
A three-layer split, each layer answering a specific question with the right
redundancy for its criticality.
| Layer | Pin here | Why here |
|---|---|---|
| **Covenant address (P2SH32 hash)** | `rateSats`, `founderFeeBps`, `founderLockingBytecode` | Already here. These are the consensus-enforced economic rules — must be unchangeable + verifiable by anyone. |
| **NFT commitment (40 B)** | `ticker` (12 B ASCII, padded) + `decimals` (1 B) + `assetClass` (1 B) + 24 B reserved | The irreducible-minimum identity that must survive even when every BCMR URL is dead and the user has no network at all. |
| **BCMR OP_RETURN** (~70 B anchor) | `sha256(json)` + `URL-primary` + `URL-sia-mirror` (future) | Standard interop with every BCH wallet. Two URLs for host-outage resilience. |
| **DVMB OP_RETURN** (optional, Deviant-only) | `templateId`, `rateSats`, `founderFeeBps`, `founderLockingBytecode` | Deviant wallets reconstruct the covenant off-HTTP for fully-offline resolve of unseen MBAs. Currently inside BCMR JSON `extensions.dvmb`; promoting to its own on-chain record is a nice-to-have, not critical. |
| **Off-chain BCMR JSON** | `name`, `description`, long-form description, icon URL, website, locale, author, tags, change-log, versioned revisions | Unlimited size, mutable via authchain. Icons especially — cannot fit in 40 bytes on-chain anywhere. |
| **LocalStorage registry** | Client-side cache built from the above sources — never the source of truth | What the wallet renders from. |
### NFT commitment binary layout (ADR-0057 §1)
Pinned here so decoders in other wallets stay interoperable:
```
byte 0: magic = 0x44 ('D' — Deviant metadata commitment)
byte 1: version = 0x01
byte 2: decimals (uint8, 0..18)
byte 3: assetClass byte (ASSET_CLASS_BYTE table — stable, new codes never reuse slots)
bytes 4..15: ticker, 12 UTF-8 bytes right-padded with 0x00 (cap 12 chars, ASCII only)
bytes 16..39: reserved, zero-initialized (24 B for future protocol flags)
```
Decoders MUST check `length === 40 AND magic === 0x44 AND version === 0x01`
before interpreting any bytes. Anything else is NOT a Deviant metadata
commitment and must be handled per the covenant that produced it.
**ASSET_CLASS_BYTE table.** High nibble = family (`0x1` = MBA, `0x2` = Token).
Values stable forever; new classes get new slots.
| Class | Byte | Family |
|---|---|---|
| `DEA` | `0x11` | MBA |
| `PLA` | `0x12` | MBA |
| `MCA` | `0x13` | MBA |
| `MTA` | `0x14` | MBA |
| `DRA` | `0x15` | MBA (dynamic rate — ADR-0015) |
| `MTU` | `0x21` | Token |
| `MTL` | `0x22` | Token |
| `PTU` | `0x23` | Token |
| `PTL` | `0x24` | Token |
### Disambiguation vs AdjustableFeeCapped (ADR-0011)
The AdjustableFeeCapped covenant USES the minting-NFT commitment for live
`feeBps` state (ScriptNum-encoded, 1-2 bytes). A naive decoder that just read
"the commitment" could mis-interpret an adjustable-fee token as a metadata
commitment. The format defends against this at two levels:
1. **Length-first check:** metadata commitment is ALWAYS 40 bytes. ScriptNum
feeBps 0..500 is at most 2 bytes. No overlap possible.
2. **Magic byte** at offset 0: `0x44` ('D'). Even if a future protocol defines
a different 40-byte commitment, the magic distinguishes.
The genesis planner enforces this at write time: `assetClass` is only honoured
when `adjustableFee` is NOT set. The two features are mutually exclusive for the
same minting NFT.
### Emission (step 1 — this session)
- New module: `src/wallet/metadata-commitment.ts` — pure encoder/decoder, no
deps outside `src/token/assetClass`. Exports `encodeMetadataCommitment`,
`decodeMetadataCommitment`, `isMetadataCommitment`, format constants.
- `GenesisIntent` adds optional `assetClass?: AssetClass`.
- `prepareGenesis` populates `GenesisPlan.metadataCommitmentHex` when
`assetClass` is set AND `adjustableFee` is NOT. Invalid encoder inputs (ticker
too long, non-ASCII, decimals out of range) become INFO-severity warnings
on the plan; the plan still works, just without the commitment sweetener.
- `DeviantWallet.createAsset` picks the NFT shape by precedence:
(1) adjustable-fee commitment, (2) metadata commitment, (3) empty default —
preserving every current path and adding the new one only when requested.
- Tests: `test/unit/wallet/metadata-commitment.test.ts` — 32/32 pass. Pins the
binary layout, round-trips every asset class, and verifies non-metadata
commitments (empty, short, wrong-magic, wrong-version) decode to `null`.
### Deferred to follow-on sessions
**Step 2 — Sia mirror in the BCMR OP_RETURN.** CHIP-2023-04 allows multiple URL
pushes; wallets try them in order. Upload the same canonical BCMR JSON to Sia
at `s3://bns/bcmr/<network>/<categoryId>.json` and emit both URLs on-chain.
~90 extra bytes on-chain, zero ongoing cost, host-outage resilience.
**Step 3 — BCMR consumer in `shells/app`.** Biggest UX win. Scan each unknown
token's genesis tx for the BCMR OP_RETURN → fetch the referenced JSON (fall
back URL-to-URL) → verify SHA-256 → cache as a TierATokenDefinition. Covers
every ecosystem token that already follows the CHIP, not just ours. New module
likely at `shells/app/src/token/bcmr-resolver.ts`, hooked into the registry
seeding path.
**Step 4 (optional) — Separate DVMB OP_RETURN.** Promote the DVMB fields out of
the BCMR JSON extensions into their own on-chain OP_RETURN so Deviant wallets
can reconstruct an unseen MBA covenant without any HTTP hop. Only worth it
once we have a fully-offline demo use case.
### UI opt-in path
Artemision wizard needs a one-liner change later: pass the already-classified
`assetClass` (which the create flow computes from mint/supply/backing axes
anyway via `classifyAssetClass()`) into `prepareGenesis`. Zero new UI; purely
plumbing in `ArtemisionScreen.tsx` when it builds the `GenesisIntent`. Left for
the follow-on so this ADR commit is scope-tight.
### Consequences
- Deviant-minted MBAs/NBAs that opt into the metadata commitment are now
**fully self-describing from UTXO alone** — a wallet with no internet,
no BCMR hosting, and no local registry can show "ASM (DEA, 2 dp)" for an
on-chain amount.
- BCMR remains the primary rich metadata surface (icon, description, URIs).
- Existing MBAs with `commitment: ''` continue to work exactly as before.
The `isMetadataCommitment` guard returns false for them; the decoder
returns null; display falls back through BCMR → local registry → "Unknown".
- The 24 reserved bytes in the commitment leave room for future protocol
flags (NFT uniqueness marker, cross-chain pointer, etc.) without reusing
the slot.
**Cross-references.**
- Spec module: `src/wallet/metadata-commitment.ts`
- Spec test: `test/unit/wallet/metadata-commitment.test.ts` (32/32)
- Genesis plumbing: `src/wallet/genesis.ts` (`GenesisIntent.assetClass` + plan
encode), `src/wallet/wallet.ts` (`createAsset` NFT precedence)
- BCMR emitter: `design/potidaea/src/bcmr.ts`
- Related ADRs: ADR-0011 (adjustable fee — must not collide), ADR-0038
(decimals/denomination), ADR-0054 (NBA cap-as-consensus — no minting baton),
ADR-0055 (Pinax/Katalogos — shared registry for non-BCMR tokens)
- Memory: [[app-token-registry-and-add-asset]], [[cashscript-covenant-findings]]