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:
parent
549e906802
commit
6e57e08884
5 changed files with 588 additions and 1 deletions
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
200
src/wallet/metadata-commitment.ts
Normal file
200
src/wallet/metadata-commitment.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
167
test/unit/wallet/metadata-commitment.test.ts
Normal file
167
test/unit/wallet/metadata-commitment.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue