← Book map

Chapter 13 · Platform

Where bytes become addresses

CompactSize versus varint, the name-derivation formula, and the reader/writer methods every class is built on. The bottom of the stack, and the place to look first when a parse goes wrong.

The utils layers Bytes at the bottom, then primitives for reading and writing them, then encodings for addresses, scripts and keys, then the structures built on top. EACH LAYER ONLY KNOWS THE ONE BELOW L4 structures KvMap · IdentityData · MMR proof index L3 encodings address · sapling · script · pushdata L2 readers & writers bufferutils · varint · varuint · hash L1 bytes Buffer · offsets · lengths Every class in the book bottoms out here. When bytes disagree, the bug is almost always in L1 or L2.

FIGURE 13.1 · Four layers, one direction.

BufferReader / BufferWriter

Every fromBuffer in the library is the same six lines: construct a reader at an offset, call the parent's fromBuffer, read fields in order, return reader.offset. The methods you will actually use:

WriterReaderWire form
writeUInt8 / 32 / 64readUInt8 / 32 / 64Little-endian fixed width
writeCompactSize(n)readCompactSize()Bitcoin compactSize (see below)
writeVarSlice(buf)readVarSlice()compactSize length, then bytes
writeSlice(buf)readSlice(n)Raw bytes, no length prefix
writeVector(bufs)readVector()compactSize count, then var slice each
writeInt64readInt64()Two's-complement 64-bit LE (used for fees)

CompactSize versus varint — the mistake that breaks everything

varuint.encode is Bitcoin's compactSize: one byte below 0xfd, then 0xfd + uint16, 0xfe + uint32, 0xff + uint64. Lengths and counts use this.

0    → 00
252  → fc
253  → fd fd 00        (2-byte form, little-endian)
65535→ fd ff ff
65536→ fe 00 00 01 00

varint.encode is a different beast: a signed Bitcoin varint (7 bits per byte, with the high-bit continuation and the −1 bias). It is used for values that can be negative, not for lengths. Feeding a varint where a compactSize is expected shifts every subsequent offset, and the parse succeeds with plausible garbage — the single most common way to "deserialise" nonsense.

address.ts — names, keys and the derivation formula

A Verus name has no stored id; the id is derived. For a root name:

idHash   = hash(name.toLowerCase())          // sha256d
address  = toBase58Check(hash160(idHash), I_ADDR_VERSION)

for a child name:
idHash   = hash(name.toLowerCase())
idHash   = hash(parentHash, idHash)          // note the order: parent first
address  = toBase58Check(hash160(idHash), I_ADDR_VERSION)

fqnToAddress extends this to dotted FQNs and applies three normalisation rules: components are lower-cased in the C locale, the root system name is appended when the FQN does not already end with it, and an @ suffix is allowed only as the last segment. Because the id depends on the parent, the same name under two systems produces two different addresses — that is the intended collision resistance, not a bug.

FunctionBehaviour worth knowing
fromBase58CheckReturns { version, hash }; throws on bad checksum or length
decodeDestinationValidates not just the version but round-trips the re-encoded address, so case/format variants fail
decodeEthDestinationAccepts optional 0x, requires exactly 40 hex characters
getDataKeyResolves namespace::name by building a parent id from :: under the namespace, then hashing the name under it
toIAddress / toXAddressSame derivation, different version byte (i- vs x-address)

sapling.ts

Bech32 with a deliberately large length limit (1000) because extended keys are long. The prefixes matter: secret-extended-key-main / -test are checked explicitly, and decodeSaplingAddress splits the payload into d and pk_d. Round-tripping through encodeSaplingAddress is the sanity check.

script, pushdata, ops

script.compile(chunks) turns a chunk array into script bytes; decompile turns bytes back into chunks; asMinimalOP and isPushOnly implement the minimal-encoding rules. pushdata.ts owns the OP_PUSHDATA1/2/4 length encoding, and ops.ts is the opcode table. VerusScript is a thin wrapper over these three.

The rest, briefly but concretely

When a buffer parses into plausible-but-wrong values, check three things in order: compactSize vs varint, byte order of a uint, and whether a slice length was read before the slice.

Reading the code

src/utils/
├── bufferutils.ts      # BufferReader / BufferWriter — the API table above
├── varuint.ts          # compactSize: fc / fd fd / fe / ff
├── varint.ts           # signed Bitcoin varint — not for lengths
├── address.ts          # name → i-address derivation, decodeDestination, getDataKey
├── hash.ts             # sha256d + hash160
├── sapling.ts          # bech32, extended keys
├── script.ts · pushdata.ts · ops.ts · reverseops.ts
├── KvMap.ts · mmr.ts · numberConversion.ts · IdentityData.ts
├── string.ts · tolower.ts
└── types/              # SerializableEntityBase, BigNumber, ScriptChunk