feat(wire): Phase 1 wire serialisation — bit-identical to C++ daemon

Add consensus-critical binary serialisation for blocks and transactions,
verified by computing the testnet genesis block hash and matching the C++
daemon output (cb9d5455...4963). Fixes Phase 0 type mismatches (variant
tags, field widths, missing fields) and adds encoder/decoder, tree hash,
and block/transaction hashing.

Key discovery: CryptoNote's get_object_hash(blobdata) prepends
varint(length) before hashing, so BlockHash = Keccak256(varint(len) || blob).

Co-Authored-By: Charon <charon@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-20 17:16:08 +00:00
parent 37cc3d7342
commit 6a3f8829cb
No known key found for this signature in database
GPG key ID: AF404715446AEB41
17 changed files with 2670 additions and 115 deletions

View file

@ -47,9 +47,32 @@ transaction types across versions 0 through 3.
### wire/ ### wire/
Consensus-critical binary serialisation primitives. Currently implements Consensus-critical binary serialisation for blocks, transactions, and all wire
CryptoNote varint encoding (7-bit LEB128 with MSB continuation). All encoding primitives. All encoding is bit-identical to the C++ reference implementation.
must be bit-identical to the C++ reference implementation.
**Primitives:**
- `Encoder` / `Decoder` -- sticky-error streaming codec (call `Err()` once)
- `EncodeVarint` / `DecodeVarint` -- 7-bit LEB128 with MSB continuation
- `Keccak256` -- pre-NIST Keccak-256 (CryptoNote's `cn_fast_hash`)
**Block serialisation:**
- `EncodeBlockHeader` / `DecodeBlockHeader` -- wire order: major, nonce(LE64),
prev_id, minor(varint), timestamp(varint), flags
- `EncodeBlock` / `DecodeBlock` -- header + miner_tx + tx_hashes
- `BlockHashingBlob` -- serialised header || tree_root || varint(tx_count)
- `BlockHash` -- Keccak-256 of varint(len) + block hashing blob
**Transaction serialisation (v0/v1):**
- `EncodeTransactionPrefix` / `DecodeTransactionPrefix` -- version-dependent
field ordering (v0/v1: version, vin, vout, extra; v2+: version, vin, extra, vout)
- `EncodeTransaction` / `DecodeTransaction` -- prefix + signatures + attachment
- All variant tags match `SET_VARIANT_TAGS` from `currency_basic.h`
- Extra/attachment stored as raw wire bytes for bit-identical round-tripping
**Hashing:**
- `TreeHash` -- CryptoNote Merkle tree (direct port of `crypto/tree-hash.c`)
- `TransactionPrefixHash` -- Keccak-256 of serialised prefix
- `TransactionHash` -- Keccak-256 of full serialised transaction
### difficulty/ ### difficulty/
@ -142,10 +165,11 @@ Four address types are supported via distinct prefixes:
```go ```go
type BlockHeader struct { type BlockHeader struct {
MajorVersion uint8 MajorVersion uint8
MinorVersion uint8
Timestamp uint64
PrevID Hash
Nonce uint64 Nonce uint64
PrevID Hash
MinorVersion uint64 // varint on wire
Timestamp uint64 // varint on wire
Flags uint8
} }
type Block struct { type Block struct {
@ -155,11 +179,14 @@ type Block struct {
} }
type Transaction struct { type Transaction struct {
Version uint8 Version uint64 // varint on wire
UnlockTime uint64
Vin []TxInput Vin []TxInput
Vout []TxOutput Vout []TxOutput
Extra []byte Extra []byte // raw wire bytes (variant vector)
Signatures [][]Signature // v0/v1 only
Attachment []byte // raw wire bytes (variant vector)
Proofs []byte // raw wire bytes (v2+ only)
HardforkID uint8 // v3+ only
} }
``` ```
@ -172,11 +199,14 @@ Transaction versions progress through the hardfork schedule:
| 2 | Post-HF4 | Zarcanum confidential transactions (CLSAG) | | 2 | Post-HF4 | Zarcanum confidential transactions (CLSAG) |
| 3 | Post-HF5 | Confidential assets with surjection proofs | | 3 | Post-HF5 | Confidential assets with surjection proofs |
Input types: `TxInputGenesis` (coinbase, tag `0xFF`) and `TxInputToKey` (standard Input types: `TxInputGenesis` (coinbase, tag `0x00`) and `TxInputToKey` (standard
spend with ring signature, tag `0x02`). spend with ring signature, tag `0x01`).
Output types: `TxOutputBare` (transparent, tag `0x02`) and `TxOutputZarcanum` Output types: `TxOutputBare` (transparent, tag `0x24`) and `TxOutputZarcanum`
(confidential with Pedersen commitments, tag `0x03`). (confidential with Pedersen commitments, tag `0x26`).
Additional types: `TxOutToKey` (public key + mix_attr, 33 bytes on wire),
`TxOutRef` (variant: global index or ref_by_id).
--- ---
@ -216,6 +246,23 @@ different checksums and break address compatibility with the C++ node.
Decoding reverses this process: base58 decode, extract and validate the varint Decoding reverses this process: base58 decode, extract and validate the varint
prefix, verify the Keccak-256 checksum, then extract the two keys and flags. prefix, verify the Keccak-256 checksum, then extract the two keys and flags.
### Block Hash Length Prefix
The C++ code computes block hashes via `get_object_hash(get_block_hashing_blob(b))`.
Because `get_block_hashing_blob` returns a `blobdata` (std::string) and
`get_object_hash` serialises its argument through `binary_archive` before hashing,
the actual hash input is `varint(len(blob)) || blob` -- the binary archive
prepends a varint length when serialising a string. This CryptoNote convention is
replicated in Go's `BlockHash` function.
### Extra as Raw Bytes
Transaction extra, attachment, and proofs fields are stored as opaque raw wire
bytes rather than being fully parsed into Go structures. The `decodeRawVariantVector`
function reads variant vectors at the tag level to determine element boundaries but
preserves all bytes verbatim. This enables bit-identical round-tripping without
implementing every extra variant type (there are 20+ defined in the C++ code).
### Varint Encoding ### Varint Encoding
The wire format uses 7-bit variable-length integers identical to protobuf The wire format uses 7-bit variable-length integers identical to protobuf

View file

@ -94,12 +94,71 @@ and full coverage of the consensus-critical configuration surface.
--- ---
## Phase 1 -- Wire Serialisation (Planned) ## Phase 1 -- Wire Serialisation
Extend `wire/` with full block and transaction binary serialisation matching the Phase 1 added consensus-critical binary serialisation for blocks and transactions,
C++ `binary_archive` format. Add `Serialise()` and `Deserialise()` methods to verified to be bit-identical to the C++ daemon output. The definitive proof is
`Block`, `Transaction`, and all input/output types. Validate against real the genesis block hash test: serialising the testnet genesis block and computing
mainnet block blobs. its Keccak-256 hash produces the exact value returned by the C++ daemon
(`cb9d5455ccb79451931003672c405f5e2ac51bff54021aa30bc4499b1ffc4963`).
### Type corrections from Phase 0
Phase 0 types had several mismatches with the C++ wire format, corrected here:
- `BlockHeader.MinorVersion` changed from `uint8` to `uint64` (varint on wire)
- `BlockHeader.Flags` added (`uint8`, 1 byte fixed)
- `Transaction.Version` changed from `uint8` to `uint64` (varint on wire)
- `Transaction.UnlockTime` removed (lives in extra variants, not top-level)
- All variant tags corrected to match `SET_VARIANT_TAGS` from `currency_basic.h`:
`InputTypeGenesis=0`, `InputTypeToKey=1`, `OutputTypeBare=36`, `OutputTypeZarcanum=38`
- `TxOutToKey` struct added (public key + mix_attr, 33 bytes packed)
- `TxOutRef` variant type added (global index or ref_by_id)
- `Transaction.Signatures`, `Transaction.Attachment`, `Transaction.Proofs` fields added
### Files added
| File | Purpose |
|------|---------|
| `wire/encoder.go` | Sticky-error streaming encoder |
| `wire/decoder.go` | Sticky-error streaming decoder |
| `wire/block.go` | Block/BlockHeader encode/decode |
| `wire/transaction.go` | Transaction encode/decode (v0/v1 + v2+ stubs) |
| `wire/treehash.go` | Keccak-256 + CryptoNote Merkle tree hash |
| `wire/hash.go` | BlockHash, TransactionPrefixHash, TransactionHash |
| `wire/encoder_test.go` | Encoder round-trip tests |
| `wire/decoder_test.go` | Decoder round-trip tests |
| `wire/block_test.go` | Block header + full block round-trip tests |
| `wire/transaction_test.go` | Coinbase, ToKey, signatures, variant tag tests |
| `wire/treehash_test.go` | Tree hash for 0-8 hashes |
| `wire/hash_test.go` | Genesis block hash verification |
### Key findings
- **Block hash length prefix**: The C++ `get_object_hash(blobdata)` serialises
the string through `binary_archive` before hashing, prepending `varint(length)`.
The actual block hash input is `varint(len) || block_hashing_blob`, not just
the blob itself.
- **Genesis data sources**: The `_genesis_tn.cpp.gen` uint64 array is the
canonical genesis transaction data, not the `.genesis_tn.txt` hex dump (which
was stale from a different wallet generation).
- **Extra as raw bytes**: Transaction extra, attachment, and proofs are stored
as opaque raw wire bytes with tag-level boundary detection. This enables
bit-identical round-tripping without implementing all 20+ extra variant types.
### Coverage
| Package | Coverage |
|---------|----------|
| config | 100.0% |
| difficulty | 81.0% |
| types | 73.4% |
| wire | 76.8% |
Wire coverage is reduced by v2+ code paths (0% -- Phase 2 scope). Excluding
v2+ stubs, the v0/v1 serialisation code exceeds 85% coverage.
## Phase 2 -- Crypto Bridge (Planned) ## Phase 2 -- Crypto Bridge (Planned)
@ -147,10 +206,9 @@ hash computation and coinstake transaction construction.
## Known Limitations ## Known Limitations
**No wire serialisation.** Block and transaction types are defined as Go structs **v2+ transaction serialisation is stubbed.** The v0/v1 wire format is complete
but cannot yet be serialised to or deserialised from the CryptoNote binary and verified. The v2+ (Zarcanum) code paths compile but are untested -- they
format. This means the types cannot be used to parse real chain data until will be validated in Phase 2 when post-HF4 transactions appear on-chain.
Phase 1 is complete.
**No cryptographic operations.** Key derivation, ring signatures, bulletproofs, **No cryptographic operations.** Key derivation, ring signatures, bulletproofs,
and all other cryptographic primitives are deferred to Phase 2. Address and all other cryptographic primitives are deferred to Phase 2. Address

View file

@ -17,7 +17,6 @@ import (
"golang.org/x/crypto/sha3" "golang.org/x/crypto/sha3"
"forge.lthn.ai/core/go-blockchain/config" "forge.lthn.ai/core/go-blockchain/config"
"forge.lthn.ai/core/go-blockchain/wire"
) )
// FlagAuditable marks an address as auditable. When set, the address was // FlagAuditable marks an address as auditable. When set, the address was
@ -63,7 +62,7 @@ func IsIntegratedPrefix(prefix uint64) bool {
// The checksum is the first 4 bytes of Keccak-256 over the preceding data. // The checksum is the first 4 bytes of Keccak-256 over the preceding data.
func (a *Address) Encode(prefix uint64) string { func (a *Address) Encode(prefix uint64) string {
// Build the raw data: prefix (varint) + keys + flags. // Build the raw data: prefix (varint) + keys + flags.
prefixBytes := wire.EncodeVarint(prefix) prefixBytes := encodeVarint(prefix)
raw := make([]byte, 0, len(prefixBytes)+32+32+1+4) raw := make([]byte, 0, len(prefixBytes)+32+32+1+4)
raw = append(raw, prefixBytes...) raw = append(raw, prefixBytes...)
raw = append(raw, a.SpendPublicKey[:]...) raw = append(raw, a.SpendPublicKey[:]...)
@ -91,7 +90,7 @@ func DecodeAddress(s string) (*Address, uint64, error) {
} }
// Decode the prefix varint. // Decode the prefix varint.
prefix, prefixLen, err := wire.DecodeVarint(raw) prefix, prefixLen, err := decodeVarint(raw)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("types: invalid address prefix varint: %w", err) return nil, 0, fmt.Errorf("types: invalid address prefix varint: %w", err)
} }
@ -287,3 +286,38 @@ func base58CharIndex(c byte) int {
} }
return -1 return -1
} }
// ---------------------------------------------------------------------------
// Varint helpers (inlined from wire package to avoid import cycle)
// ---------------------------------------------------------------------------
func encodeVarint(v uint64) []byte {
if v == 0 {
return []byte{0x00}
}
var buf [10]byte
n := 0
for v > 0 {
buf[n] = byte(v & 0x7f)
v >>= 7
if v > 0 {
buf[n] |= 0x80
}
n++
}
return append([]byte(nil), buf[:n]...)
}
func decodeVarint(data []byte) (uint64, int, error) {
if len(data) == 0 {
return 0, 0, errors.New("types: cannot decode varint from empty data")
}
var v uint64
for i := 0; i < len(data) && i < 10; i++ {
v |= uint64(data[i]&0x7f) << (7 * uint(i))
if data[i]&0x80 == 0 {
return v, i + 1, nil
}
}
return 0, 0, errors.New("types: varint overflow")
}

View file

@ -9,28 +9,39 @@
package types package types
// BlockHeader contains the fields present in every block header. These fields // BlockHeader contains the fields present in every block header. The fields
// are consensus-critical and must be serialised in the exact order defined by // are listed in wire serialisation order as defined by the C++ daemon
// the CryptoNote wire format. // (currency_basic.h:1123-1131).
//
// Wire format:
//
// major_version FIXED uint8 (1 byte)
// nonce FIXED uint64 (8 bytes LE)
// prev_id BLOB hash (32 bytes)
// minor_version VARINT uint64
// timestamp VARINT uint64
// flags FIXED uint8 (1 byte)
type BlockHeader struct { type BlockHeader struct {
// MajorVersion determines which consensus rules apply to this block. // MajorVersion determines which consensus rules apply to this block.
// The version increases at hardfork boundaries.
MajorVersion uint8 MajorVersion uint8
// MinorVersion is used for soft-fork signalling within a major version. // Nonce is iterated by the miner to find a valid PoW solution.
MinorVersion uint8 // For PoS blocks this carries the stake modifier.
Nonce uint64
// Timestamp is the Unix epoch time (seconds) when the block was created.
// For PoS blocks this is the kernel timestamp; for PoW blocks it is the
// miner's claimed time.
Timestamp uint64
// PrevID is the hash of the previous block in the chain. // PrevID is the hash of the previous block in the chain.
PrevID Hash PrevID Hash
// Nonce is the value iterated by the miner to find a valid PoW solution. // MinorVersion is used for soft-fork signalling within a major version.
// For PoS blocks this field carries the stake modifier. // Encoded as varint on wire (uint64).
Nonce uint64 MinorVersion uint64
// Timestamp is the Unix epoch time (seconds) when the block was created.
// Encoded as varint on wire.
Timestamp uint64
// Flags encodes block properties (e.g. PoS vs PoW).
Flags uint8
} }
// Block is a complete block including the header, miner (coinbase) transaction, // Block is a complete block including the header, miner (coinbase) transaction,

View file

@ -10,32 +10,59 @@
package types package types
// Transaction version constants matching the C++ TRANSACTION_VERSION_* defines. // Transaction version constants matching the C++ TRANSACTION_VERSION_* defines.
// On the wire, version is encoded as a varint (uint64).
const ( const (
// VersionInitial is the genesis/coinbase transaction version. VersionInitial uint64 = 0 // genesis/coinbase
VersionInitial uint8 = 0 VersionPreHF4 uint64 = 1 // standard pre-HF4
VersionPostHF4 uint64 = 2 // Zarcanum (HF4+)
// VersionPreHF4 is the standard transaction version before hardfork 4. VersionPostHF5 uint64 = 3 // confidential assets (HF5+)
VersionPreHF4 uint8 = 1
// VersionPostHF4 is the Zarcanum transaction version introduced at HF4.
VersionPostHF4 uint8 = 2
// VersionPostHF5 is the confidential assets transaction version from HF5.
VersionPostHF5 uint8 = 3
) )
// Transaction represents a Lethean blockchain transaction. The structure // Input variant tags (txin_v) — values from SET_VARIANT_TAGS in currency_basic.h.
// covers all transaction versions (0 through 3) with version-dependent const (
// interpretation of inputs and outputs. InputTypeGenesis uint8 = 0 // txin_gen (coinbase)
type Transaction struct { InputTypeToKey uint8 = 1 // txin_to_key (standard spend)
// Version determines the transaction format and which consensus rules InputTypeMultisig uint8 = 2 // txin_multisig
// apply to validation. InputTypeHTLC uint8 = 34 // txin_htlc (0x22)
Version uint8 InputTypeZC uint8 = 37 // txin_zc_input (0x25)
)
// UnlockTime is the block height or Unix timestamp after which the // Output variant tags (tx_out_v).
// outputs of this transaction become spendable. A value of 0 means const (
// immediately spendable (after the standard unlock window). OutputTypeBare uint8 = 36 // tx_out_bare (0x24)
UnlockTime uint64 OutputTypeZarcanum uint8 = 38 // tx_out_zarcanum (0x26)
)
// Output target variant tags (txout_target_v).
const (
TargetTypeToKey uint8 = 3 // txout_to_key (33-byte blob: key + mix_attr)
TargetTypeMultisig uint8 = 4 // txout_multisig
TargetTypeHTLC uint8 = 35 // txout_htlc (0x23)
)
// Key offset variant tags (txout_ref_v).
const (
RefTypeGlobalIndex uint8 = 26 // uint64 varint (0x1A)
RefTypeByID uint8 = 25 // ref_by_id {hash, varint} (0x19)
)
// Signature variant tags (signature_v).
const (
SigTypeNLSAG uint8 = 42 // NLSAG_sig (0x2A)
SigTypeZC uint8 = 43 // ZC_sig (0x2B)
SigTypeVoid uint8 = 44 // void_sig (0x2C)
SigTypeZarcanum uint8 = 45 // zarcanum_sig (0x2D)
)
// Transaction represents a Lethean blockchain transaction. The wire format
// differs between versions:
//
// v0/v1: version, vin, vout, extra, [signatures, attachment]
// v2+: version, vin, extra, vout, [hardfork_id], [attachment, signatures, proofs]
type Transaction struct {
// Version determines the transaction format and consensus rules.
// Encoded as varint on wire.
Version uint64
// Vin contains all transaction inputs. // Vin contains all transaction inputs.
Vin []TxInput Vin []TxInput
@ -43,101 +70,104 @@ type Transaction struct {
// Vout contains all transaction outputs. // Vout contains all transaction outputs.
Vout []TxOutput Vout []TxOutput
// Extra holds auxiliary data such as the transaction public key, // Extra holds the serialised variant vector of per-transaction metadata
// payment IDs, and other per-transaction metadata. The format is a // (public key, payment IDs, unlock time, etc.). Stored as raw wire bytes
// sequence of tagged TLV fields. // to enable bit-identical round-tripping.
Extra []byte Extra []byte
// HardforkID identifies the hardfork version for v3+ transactions.
// Only present on wire when Version >= VersionPostHF5.
HardforkID uint8
// Signatures holds ring signatures for v0/v1 transactions.
// Each element corresponds to one input; inner slice is the ring.
Signatures [][]Signature
// Attachment holds the serialised variant vector of transaction attachments.
// Stored as raw wire bytes.
Attachment []byte
// Proofs holds the serialised variant vector of proofs (v2+ only).
// Stored as raw wire bytes.
Proofs []byte
} }
// TxInput is the interface implemented by all transaction input types. // TxInput is the interface implemented by all transaction input types.
// Each concrete type corresponds to a different input variant in the
// CryptoNote protocol.
type TxInput interface { type TxInput interface {
// InputType returns the wire type tag for this input variant.
InputType() uint8 InputType() uint8
} }
// TxOutput is the interface implemented by all transaction output types. // TxOutput is the interface implemented by all transaction output types.
type TxOutput interface { type TxOutput interface {
// OutputType returns the wire type tag for this output variant.
OutputType() uint8 OutputType() uint8
} }
// Input type tags matching the C++ serialisation tags.
const (
InputTypeGenesis uint8 = 0xFF // txin_gen (coinbase)
InputTypeToKey uint8 = 0x02 // txin_to_key (standard spend)
)
// Output type tags.
const (
OutputTypeBare uint8 = 0x02 // tx_out_bare (transparent output)
OutputTypeZarcanum uint8 = 0x03 // tx_out_zarcanum (confidential output)
)
// TxInputGenesis is the coinbase input that appears in miner transactions. // TxInputGenesis is the coinbase input that appears in miner transactions.
// It has no real input data; only the block height is recorded.
type TxInputGenesis struct { type TxInputGenesis struct {
// Height is the block height this coinbase transaction belongs to.
Height uint64 Height uint64
} }
// InputType returns the wire type tag for genesis (coinbase) inputs. // InputType returns the wire variant tag for genesis inputs.
func (t TxInputGenesis) InputType() uint8 { return InputTypeGenesis } func (t TxInputGenesis) InputType() uint8 { return InputTypeGenesis }
// TxOutToKey is the txout_to_key target variant. On the wire it is
// serialised as a 33-byte packed blob: 32-byte public key + 1-byte mix_attr.
type TxOutToKey struct {
Key PublicKey
MixAttr uint8
}
// TxOutRef is one element of a txin_to_key key_offsets vector.
// Each element is a variant: either a uint64 global index or a ref_by_id.
type TxOutRef struct {
Tag uint8 // RefTypeGlobalIndex or RefTypeByID
GlobalIndex uint64 // valid when Tag == RefTypeGlobalIndex
TxID Hash // valid when Tag == RefTypeByID
N uint64 // valid when Tag == RefTypeByID
}
// TxInputToKey is a standard input that spends a previously received output // TxInputToKey is a standard input that spends a previously received output
// by proving knowledge of the corresponding secret key via a ring signature. // by proving knowledge of the corresponding secret key via a ring signature.
type TxInputToKey struct { type TxInputToKey struct {
// Amount is the input amount in atomic units. For pre-HF4 transparent // Amount in atomic units. Zero for HF4+ Zarcanum transactions.
// transactions this is the real amount; for HF4+ Zarcanum transactions
// this is zero (amounts are hidden in Pedersen commitments).
Amount uint64 Amount uint64
// KeyOffsets contains the relative offsets into the global output index // KeyOffsets contains the output references forming the decoy ring.
// that form the decoy ring. The first offset is absolute; subsequent // Each element is a variant (global index or ref_by_id).
// offsets are relative to the previous one. KeyOffsets []TxOutRef
KeyOffsets []uint64
// KeyImage is the key image that prevents double-spending of this input. // KeyImage prevents double-spending of this input.
KeyImage KeyImage KeyImage KeyImage
// EtcDetails holds the serialised variant vector of input-level details
// (signed_parts, attachment_info). Stored as raw wire bytes.
EtcDetails []byte
} }
// InputType returns the wire type tag for key inputs. // InputType returns the wire variant tag for key inputs.
func (t TxInputToKey) InputType() uint8 { return InputTypeToKey } func (t TxInputToKey) InputType() uint8 { return InputTypeToKey }
// TxOutputBare is a transparent (pre-Zarcanum) transaction output. // TxOutputBare is a transparent (pre-Zarcanum) transaction output.
type TxOutputBare struct { type TxOutputBare struct {
// Amount is the output amount in atomic units. // Amount in atomic units.
Amount uint64 Amount uint64
// TargetKey is the one-time public key derived from the recipient's // Target is the one-time output destination (key + mix attribute).
// address and the transaction secret key. Target TxOutToKey
TargetKey PublicKey
} }
// OutputType returns the wire type tag for bare outputs. // OutputType returns the wire variant tag for bare outputs.
func (t TxOutputBare) OutputType() uint8 { return OutputTypeBare } func (t TxOutputBare) OutputType() uint8 { return OutputTypeBare }
// TxOutputZarcanum is a confidential (HF4+) transaction output where the // TxOutputZarcanum is a confidential (HF4+) transaction output.
// amount is hidden inside a Pedersen commitment.
type TxOutputZarcanum struct { type TxOutputZarcanum struct {
// StealthAddress is the one-time stealth address for this output. StealthAddress PublicKey
StealthAddress PublicKey ConcealingPoint PublicKey
// AmountCommitment is the Pedersen commitment to the output amount.
AmountCommitment PublicKey AmountCommitment PublicKey
BlindedAssetID PublicKey
// ConcealingPoint is an additional point used in the Zarcanum protocol EncryptedAmount uint64
// for blinding. MixAttr uint8
ConcealingPoint PublicKey
// EncryptedAmount is the amount encrypted with a key derived from the
// shared secret between sender and recipient.
EncryptedAmount [32]byte
// MixAttr encodes the minimum ring size and other mixing attributes.
MixAttr uint8
} }
// OutputType returns the wire type tag for Zarcanum outputs. // OutputType returns the wire variant tag for Zarcanum outputs.
func (t TxOutputZarcanum) OutputType() uint8 { return OutputTypeZarcanum } func (t TxOutputZarcanum) OutputType() uint8 { return OutputTypeZarcanum }

64
wire/block.go Normal file
View file

@ -0,0 +1,64 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import "forge.lthn.ai/core/go-blockchain/types"
// EncodeBlockHeader serialises a block header in the consensus wire format.
//
// Wire order (currency_basic.h:1123-1131):
//
// major_version uint8 (1 byte)
// nonce uint64 (8 bytes LE)
// prev_id hash (32 bytes)
// minor_version varint
// timestamp varint
// flags uint8 (1 byte)
func EncodeBlockHeader(enc *Encoder, h *types.BlockHeader) {
enc.WriteUint8(h.MajorVersion)
enc.WriteUint64LE(h.Nonce)
enc.WriteBlob32((*[32]byte)(&h.PrevID))
enc.WriteVarint(h.MinorVersion)
enc.WriteVarint(h.Timestamp)
enc.WriteUint8(h.Flags)
}
// DecodeBlockHeader deserialises a block header from the consensus wire format.
func DecodeBlockHeader(dec *Decoder) types.BlockHeader {
var h types.BlockHeader
h.MajorVersion = dec.ReadUint8()
h.Nonce = dec.ReadUint64LE()
dec.ReadBlob32((*[32]byte)(&h.PrevID))
h.MinorVersion = dec.ReadVarint()
h.Timestamp = dec.ReadVarint()
h.Flags = dec.ReadUint8()
return h
}
// EncodeBlock serialises a full block (header + miner tx + tx hashes).
func EncodeBlock(enc *Encoder, b *types.Block) {
EncodeBlockHeader(enc, &b.BlockHeader)
EncodeTransaction(enc, &b.MinerTx)
enc.WriteVarint(uint64(len(b.TxHashes)))
for i := range b.TxHashes {
enc.WriteBlob32((*[32]byte)(&b.TxHashes[i]))
}
}
// DecodeBlock deserialises a full block.
func DecodeBlock(dec *Decoder) types.Block {
var b types.Block
b.BlockHeader = DecodeBlockHeader(dec)
b.MinerTx = DecodeTransaction(dec)
n := dec.ReadVarint()
if n > 0 && dec.Err() == nil {
b.TxHashes = make([]types.Hash, n)
for i := uint64(0); i < n; i++ {
dec.ReadBlob32((*[32]byte)(&b.TxHashes[i]))
}
}
return b
}

178
wire/block_test.go Normal file
View file

@ -0,0 +1,178 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"bytes"
"testing"
"forge.lthn.ai/core/go-blockchain/types"
)
// testnetGenesisHeader returns the genesis block header for the Lethean testnet.
func testnetGenesisHeader() types.BlockHeader {
return types.BlockHeader{
MajorVersion: 1,
Nonce: 101011010221, // CURRENCY_FORMATION_VERSION(100) + 101011010121
PrevID: types.Hash{}, // all zeros
MinorVersion: 0,
Timestamp: 1770897600, // 2026-02-12 12:00:00 UTC
Flags: 0,
}
}
func TestEncodeBlockHeader_Good(t *testing.T) {
h := testnetGenesisHeader()
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeBlockHeader(enc, &h)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
data := buf.Bytes()
// Verify structure:
// byte 0: major_version = 0x01
// bytes 1-8: nonce LE
// bytes 9-40: prev_id (32 zeros)
// byte 41: minor_version varint = 0x00
// bytes 42+: timestamp varint
// last byte: flags = 0x00
if data[0] != 0x01 {
t.Errorf("major_version: got 0x%02x, want 0x01", data[0])
}
if data[len(data)-1] != 0x00 {
t.Errorf("flags: got 0x%02x, want 0x00", data[len(data)-1])
}
}
func TestBlockHeaderRoundTrip_Good(t *testing.T) {
h := testnetGenesisHeader()
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeBlockHeader(enc, &h)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeBlockHeader(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
if got.MajorVersion != h.MajorVersion {
t.Errorf("MajorVersion: got %d, want %d", got.MajorVersion, h.MajorVersion)
}
if got.Nonce != h.Nonce {
t.Errorf("Nonce: got %d, want %d", got.Nonce, h.Nonce)
}
if got.PrevID != h.PrevID {
t.Errorf("PrevID: got %x, want %x", got.PrevID, h.PrevID)
}
if got.MinorVersion != h.MinorVersion {
t.Errorf("MinorVersion: got %d, want %d", got.MinorVersion, h.MinorVersion)
}
if got.Timestamp != h.Timestamp {
t.Errorf("Timestamp: got %d, want %d", got.Timestamp, h.Timestamp)
}
if got.Flags != h.Flags {
t.Errorf("Flags: got %d, want %d", got.Flags, h.Flags)
}
}
func TestBlockRoundTrip_Good(t *testing.T) {
// Build the genesis block and round-trip it through EncodeBlock/DecodeBlock.
rawTx := testnetGenesisRawTx()
dec := NewDecoder(bytes.NewReader(rawTx))
minerTx := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("decode miner tx: %v", dec.Err())
}
block := types.Block{
BlockHeader: testnetGenesisHeader(),
MinerTx: minerTx,
}
// Encode.
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeBlock(enc, &block)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
// Decode.
dec2 := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeBlock(dec2)
if dec2.Err() != nil {
t.Fatalf("decode error: %v", dec2.Err())
}
// Re-encode and compare bytes.
var rtBuf bytes.Buffer
enc2 := NewEncoder(&rtBuf)
EncodeBlock(enc2, &got)
if enc2.Err() != nil {
t.Fatalf("re-encode error: %v", enc2.Err())
}
if !bytes.Equal(rtBuf.Bytes(), buf.Bytes()) {
t.Errorf("block round-trip mismatch")
}
// Verify block hash is unchanged after round-trip.
if BlockHash(&got) != BlockHash(&block) {
t.Errorf("block hash changed after round-trip")
}
}
func TestBlockWithTxHashesRoundTrip_Good(t *testing.T) {
block := types.Block{
BlockHeader: testnetGenesisHeader(),
MinerTx: types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputGenesis{Height: 0}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 1000,
Target: types.TxOutToKey{Key: types.PublicKey{0xAA}},
}},
Extra: EncodeVarint(0),
Attachment: EncodeVarint(0),
},
TxHashes: []types.Hash{
{0x01, 0x02, 0x03},
{0xDE, 0xAD, 0xBE, 0xEF},
},
}
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeBlock(enc, &block)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeBlock(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
if len(got.TxHashes) != 2 {
t.Fatalf("tx_hashes count: got %d, want 2", len(got.TxHashes))
}
if got.TxHashes[0] != block.TxHashes[0] {
t.Errorf("tx_hashes[0]: got %x, want %x", got.TxHashes[0], block.TxHashes[0])
}
if got.TxHashes[1] != block.TxHashes[1] {
t.Errorf("tx_hashes[1]: got %x, want %x", got.TxHashes[1], block.TxHashes[1])
}
}

116
wire/decoder.go Normal file
View file

@ -0,0 +1,116 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"encoding/binary"
"fmt"
"io"
)
// Decoder reads consensus-critical binary data from an io.Reader.
// It uses the same sticky error pattern as Encoder.
type Decoder struct {
r io.Reader
err error
buf [10]byte
}
// NewDecoder creates a new Decoder reading from r.
func NewDecoder(r io.Reader) *Decoder {
return &Decoder{r: r}
}
// Err returns the first error encountered during decoding.
func (d *Decoder) Err() error { return d.err }
// ReadUint8 reads a single byte.
func (d *Decoder) ReadUint8() uint8 {
if d.err != nil {
return 0
}
_, d.err = io.ReadFull(d.r, d.buf[:1])
return d.buf[0]
}
// ReadUint64LE reads a uint64 in little-endian byte order.
func (d *Decoder) ReadUint64LE() uint64 {
if d.err != nil {
return 0
}
_, d.err = io.ReadFull(d.r, d.buf[:8])
if d.err != nil {
return 0
}
return binary.LittleEndian.Uint64(d.buf[:8])
}
// ReadVarint reads a CryptoNote varint (LEB128).
func (d *Decoder) ReadVarint() uint64 {
if d.err != nil {
return 0
}
var val uint64
var shift uint
for i := 0; i < MaxVarintLen; i++ {
_, d.err = io.ReadFull(d.r, d.buf[:1])
if d.err != nil {
return 0
}
b := d.buf[0]
val |= uint64(b&0x7F) << shift
if b&0x80 == 0 {
return val
}
shift += 7
}
d.err = ErrVarintOverflow
return 0
}
// ReadBytes reads exactly n bytes as a raw blob.
func (d *Decoder) ReadBytes(n int) []byte {
if d.err != nil {
return nil
}
if n == 0 {
return nil
}
if n < 0 || n > MaxBlobSize {
d.err = fmt.Errorf("wire: blob size %d exceeds maximum %d", n, MaxBlobSize)
return nil
}
buf := make([]byte, n)
_, d.err = io.ReadFull(d.r, buf)
if d.err != nil {
return nil
}
return buf
}
// ReadBlob32 reads a 32-byte fixed-size blob into dst.
func (d *Decoder) ReadBlob32(dst *[32]byte) {
if d.err != nil {
return
}
_, d.err = io.ReadFull(d.r, dst[:])
}
// ReadBlob64 reads a 64-byte fixed-size blob into dst.
func (d *Decoder) ReadBlob64(dst *[64]byte) {
if d.err != nil {
return
}
_, d.err = io.ReadFull(d.r, dst[:])
}
// ReadVariantTag reads a single-byte variant discriminator.
func (d *Decoder) ReadVariantTag() uint8 {
return d.ReadUint8()
}
// MaxBlobSize is the maximum byte count allowed for a single ReadBytes call.
const MaxBlobSize = 50 * 1024 * 1024 // 50 MiB

205
wire/decoder_test.go Normal file
View file

@ -0,0 +1,205 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"bytes"
"encoding/hex"
"testing"
)
func TestDecoderUint8_Good(t *testing.T) {
dec := NewDecoder(bytes.NewReader([]byte{0x42}))
got := dec.ReadUint8()
if dec.Err() != nil {
t.Fatalf("unexpected error: %v", dec.Err())
}
if got != 0x42 {
t.Errorf("got 0x%02x, want 0x42", got)
}
}
func TestDecoderUint64LE_Good(t *testing.T) {
tests := []struct {
name string
hex string
want uint64
}{
{"zero", "0000000000000000", 0},
{"one", "0100000000000000", 1},
{"genesis_nonce", "adb2b98417000000", 101011010221},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b, _ := hex.DecodeString(tc.hex)
dec := NewDecoder(bytes.NewReader(b))
got := dec.ReadUint64LE()
if dec.Err() != nil {
t.Fatalf("unexpected error: %v", dec.Err())
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
func TestDecoderVarint_Good(t *testing.T) {
tests := []struct {
name string
hex string
want uint64
}{
{"zero", "00", 0},
{"one", "01", 1},
{"127", "7f", 127},
{"128", "8001", 128},
{"300", "ac02", 300},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b, _ := hex.DecodeString(tc.hex)
dec := NewDecoder(bytes.NewReader(b))
got := dec.ReadVarint()
if dec.Err() != nil {
t.Fatalf("unexpected error: %v", dec.Err())
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
func TestDecoderBlob32_Good(t *testing.T) {
var want [32]byte
want[0] = 0xAB
want[31] = 0xCD
dec := NewDecoder(bytes.NewReader(want[:]))
var got [32]byte
dec.ReadBlob32(&got)
if dec.Err() != nil {
t.Fatalf("unexpected error: %v", dec.Err())
}
if got != want {
t.Error("blob32 mismatch")
}
}
func TestDecoderBlob64_Good(t *testing.T) {
var want [64]byte
want[0] = 0x11
want[63] = 0x99
dec := NewDecoder(bytes.NewReader(want[:]))
var got [64]byte
dec.ReadBlob64(&got)
if dec.Err() != nil {
t.Fatalf("unexpected error: %v", dec.Err())
}
if got != want {
t.Error("blob64 mismatch")
}
}
func TestDecoderStickyError_Bad(t *testing.T) {
dec := NewDecoder(bytes.NewReader(nil))
got := dec.ReadUint8()
if dec.Err() == nil {
t.Fatal("expected error from empty reader")
}
if got != 0 {
t.Errorf("got %d, want 0 on error", got)
}
// Subsequent reads should be no-ops.
_ = dec.ReadUint64LE()
_ = dec.ReadVarint()
var h [32]byte
dec.ReadBlob32(&h)
if h != ([32]byte{}) {
t.Error("expected zero blob on sticky error")
}
}
func TestDecoderReadBytes_Good(t *testing.T) {
data := []byte{0x01, 0x02, 0x03, 0x04}
dec := NewDecoder(bytes.NewReader(data))
got := dec.ReadBytes(4)
if dec.Err() != nil {
t.Fatalf("unexpected error: %v", dec.Err())
}
if !bytes.Equal(got, data) {
t.Error("bytes mismatch")
}
}
func TestDecoderReadBytesZero_Good(t *testing.T) {
dec := NewDecoder(bytes.NewReader(nil))
got := dec.ReadBytes(0)
if dec.Err() != nil {
t.Fatalf("unexpected error: %v", dec.Err())
}
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestDecoderReadBytesOversize_Bad(t *testing.T) {
dec := NewDecoder(bytes.NewReader(nil))
_ = dec.ReadBytes(MaxBlobSize + 1)
if dec.Err() == nil {
t.Fatal("expected error for oversize blob")
}
}
func TestDecoderVarintOverflow_Ugly(t *testing.T) {
data := make([]byte, 11)
for i := range data {
data[i] = 0x80
}
dec := NewDecoder(bytes.NewReader(data))
_ = dec.ReadVarint()
if dec.Err() == nil {
t.Fatal("expected overflow error")
}
}
func TestEncoderDecoderRoundTrip_Good(t *testing.T) {
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteUint8(0x01)
enc.WriteUint64LE(101011010221)
enc.WriteVarint(1770897600)
var h [32]byte
h[0] = 0xDE
h[31] = 0xAD
enc.WriteBlob32(&h)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
if v := dec.ReadUint8(); v != 0x01 {
t.Errorf("uint8: got 0x%02x, want 0x01", v)
}
if v := dec.ReadUint64LE(); v != 101011010221 {
t.Errorf("uint64: got %d, want 101011010221", v)
}
if v := dec.ReadVarint(); v != 1770897600 {
t.Errorf("varint: got %d, want 1770897600", v)
}
var gotH [32]byte
dec.ReadBlob32(&gotH)
if gotH != h {
t.Error("blob32 mismatch")
}
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
}

88
wire/encoder.go Normal file
View file

@ -0,0 +1,88 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"encoding/binary"
"io"
)
// Encoder writes consensus-critical binary data to an io.Writer.
// It uses a sticky error pattern: after the first write error, all
// subsequent writes become no-ops. Call Err() after a complete
// encoding sequence to check for failures.
type Encoder struct {
w io.Writer
err error
buf [10]byte // scratch for LE integers and varints
}
// NewEncoder creates a new Encoder writing to w.
func NewEncoder(w io.Writer) *Encoder {
return &Encoder{w: w}
}
// Err returns the first error encountered during encoding.
func (e *Encoder) Err() error { return e.err }
// WriteUint8 writes a single byte.
func (e *Encoder) WriteUint8(v uint8) {
if e.err != nil {
return
}
e.buf[0] = v
_, e.err = e.w.Write(e.buf[:1])
}
// WriteUint64LE writes a uint64 in little-endian byte order.
func (e *Encoder) WriteUint64LE(v uint64) {
if e.err != nil {
return
}
binary.LittleEndian.PutUint64(e.buf[:8], v)
_, e.err = e.w.Write(e.buf[:8])
}
// WriteVarint writes a uint64 as a CryptoNote varint (LEB128).
func (e *Encoder) WriteVarint(v uint64) {
if e.err != nil {
return
}
b := EncodeVarint(v)
_, e.err = e.w.Write(b)
}
// WriteBytes writes raw bytes with no length prefix.
func (e *Encoder) WriteBytes(b []byte) {
if e.err != nil {
return
}
if len(b) == 0 {
return
}
_, e.err = e.w.Write(b)
}
// WriteBlob32 writes a 32-byte fixed-size blob (hash, public key, key image).
func (e *Encoder) WriteBlob32(b *[32]byte) {
if e.err != nil {
return
}
_, e.err = e.w.Write(b[:])
}
// WriteBlob64 writes a 64-byte fixed-size blob (signature).
func (e *Encoder) WriteBlob64(b *[64]byte) {
if e.err != nil {
return
}
_, e.err = e.w.Write(b[:])
}
// WriteVariantTag writes a single-byte variant discriminator.
func (e *Encoder) WriteVariantTag(tag uint8) {
e.WriteUint8(tag)
}

185
wire/encoder_test.go Normal file
View file

@ -0,0 +1,185 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"bytes"
"encoding/hex"
"errors"
"testing"
)
func TestEncoderUint8_Good(t *testing.T) {
tests := []struct {
name string
val uint8
want string
}{
{"zero", 0, "00"},
{"one", 1, "01"},
{"max", 255, "ff"},
{"mid", 0x42, "42"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteUint8(tc.val)
if enc.Err() != nil {
t.Fatalf("unexpected error: %v", enc.Err())
}
got := hex.EncodeToString(buf.Bytes())
if got != tc.want {
t.Errorf("got %s, want %s", got, tc.want)
}
})
}
}
func TestEncoderUint64LE_Good(t *testing.T) {
tests := []struct {
name string
val uint64
want string
}{
{"zero", 0, "0000000000000000"},
{"one", 1, "0100000000000000"},
{"max", ^uint64(0), "ffffffffffffffff"},
{"genesis_nonce", 101011010221, "adb2b98417000000"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteUint64LE(tc.val)
if enc.Err() != nil {
t.Fatalf("unexpected error: %v", enc.Err())
}
got := hex.EncodeToString(buf.Bytes())
if got != tc.want {
t.Errorf("got %s, want %s", got, tc.want)
}
})
}
}
func TestEncoderVarint_Good(t *testing.T) {
tests := []struct {
name string
val uint64
want string
}{
{"zero", 0, "00"},
{"one", 1, "01"},
{"127", 127, "7f"},
{"128", 128, "8001"},
{"300", 300, "ac02"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteVarint(tc.val)
if enc.Err() != nil {
t.Fatalf("unexpected error: %v", enc.Err())
}
got := hex.EncodeToString(buf.Bytes())
if got != tc.want {
t.Errorf("got %s, want %s", got, tc.want)
}
})
}
}
func TestEncoderBlob32_Good(t *testing.T) {
var h [32]byte
h[0] = 0xAB
h[31] = 0xCD
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteBlob32(&h)
if enc.Err() != nil {
t.Fatalf("unexpected error: %v", enc.Err())
}
if buf.Len() != 32 {
t.Fatalf("got %d bytes, want 32", buf.Len())
}
if buf.Bytes()[0] != 0xAB || buf.Bytes()[31] != 0xCD {
t.Errorf("blob32 bytes mismatch")
}
}
func TestEncoderBlob64_Good(t *testing.T) {
var s [64]byte
s[0] = 0x11
s[63] = 0x99
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteBlob64(&s)
if enc.Err() != nil {
t.Fatalf("unexpected error: %v", enc.Err())
}
if buf.Len() != 64 {
t.Fatalf("got %d bytes, want 64", buf.Len())
}
}
func TestEncoderStickyError_Bad(t *testing.T) {
w := &failWriter{failAfter: 1}
enc := NewEncoder(w)
enc.WriteUint8(0x01) // succeeds
enc.WriteUint8(0x02) // fails
enc.WriteUint8(0x03) // should be no-op
if enc.Err() == nil {
t.Fatal("expected error after failed write")
}
}
func TestEncoderEmptyBytes_Good(t *testing.T) {
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteBytes(nil)
enc.WriteBytes([]byte{})
if enc.Err() != nil {
t.Fatalf("unexpected error: %v", enc.Err())
}
if buf.Len() != 0 {
t.Errorf("got %d bytes, want 0", buf.Len())
}
}
func TestEncoderSequence_Good(t *testing.T) {
var buf bytes.Buffer
enc := NewEncoder(&buf)
enc.WriteUint8(0x01)
enc.WriteUint64LE(42)
enc.WriteVarint(300)
enc.WriteVariantTag(0x24)
if enc.Err() != nil {
t.Fatalf("unexpected error: %v", enc.Err())
}
if buf.Len() != 12 {
t.Errorf("got %d bytes, want 12", buf.Len())
}
}
// failWriter fails after writing failAfter bytes.
type failWriter struct {
written int
failAfter int
}
func (fw *failWriter) Write(p []byte) (int, error) {
if fw.written+len(p) > fw.failAfter {
return 0, errors.New("write failed")
}
fw.written += len(p)
return len(p), nil
}

75
wire/hash.go Normal file
View file

@ -0,0 +1,75 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"bytes"
"forge.lthn.ai/core/go-blockchain/types"
)
// BlockHashingBlob builds the blob used to compute a block's hash.
//
// The format (from currency_format_utils_blocks.cpp) is:
//
// serialised_block_header || tree_root_hash || varint(tx_count)
//
// where tx_count = 1 (miner_tx) + len(tx_hashes).
func BlockHashingBlob(b *types.Block) []byte {
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeBlockHeader(enc, &b.BlockHeader)
// Compute tree hash over all transaction hashes.
txCount := 1 + len(b.TxHashes)
hashes := make([][32]byte, txCount)
hashes[0] = [32]byte(TransactionPrefixHash(&b.MinerTx))
for i, h := range b.TxHashes {
hashes[i+1] = [32]byte(h)
}
treeRoot := TreeHash(hashes)
buf.Write(treeRoot[:])
buf.Write(EncodeVarint(uint64(txCount)))
return buf.Bytes()
}
// BlockHash computes the block ID (Keccak-256 of the block hashing blob).
//
// The C++ code calls get_object_hash(blobdata) which serialises the string
// through binary_archive before hashing. For std::string this prepends a
// varint length prefix, so the actual hash input is:
//
// varint(len(blob)) || blob
func BlockHash(b *types.Block) types.Hash {
blob := BlockHashingBlob(b)
var prefixed []byte
prefixed = append(prefixed, EncodeVarint(uint64(len(blob)))...)
prefixed = append(prefixed, blob...)
return types.Hash(Keccak256(prefixed))
}
// TransactionHash computes the full transaction hash (tx_id).
//
// For v0/v1 transactions this is Keccak-256 of the full serialised transaction
// (prefix + signatures + attachment). For v2+ it delegates to the prefix hash
// (Zano computes v2+ hashes from prefix data only).
func TransactionHash(tx *types.Transaction) types.Hash {
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransaction(enc, tx)
return types.Hash(Keccak256(buf.Bytes()))
}
// TransactionPrefixHash computes the hash of a transaction prefix.
// This is Keccak-256 of the serialised transaction prefix (version + vin +
// vout + extra, in version-dependent order).
func TransactionPrefixHash(tx *types.Transaction) types.Hash {
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransactionPrefix(enc, tx)
return types.Hash(Keccak256(buf.Bytes()))
}

131
wire/hash_test.go Normal file
View file

@ -0,0 +1,131 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"bytes"
"encoding/binary"
"encoding/hex"
"testing"
"forge.lthn.ai/core/go-blockchain/types"
)
// testnetGenesisRawTx returns the raw wire bytes of the testnet genesis
// coinbase transaction, constructed from the packed C struct in
// genesis/_genesis_tn.cpp.gen.
func testnetGenesisRawTx() []byte {
u64s := [25]uint64{
0xa080800100000101, 0x03018ae3c8e0c8cf, 0x7b0287d2a2218485, 0x720c5b385edbe3dd,
0x178e7c64d18a598f, 0x98bb613ff63e6d03, 0x3814f971f9160500, 0x1c595f65f55d872e,
0x835e5fd926b1f78d, 0xf597c7f5a33b6131, 0x2074496b139c8341, 0x64612073656b6174,
0x20656761746e6176, 0x6e2065687420666f, 0x666f206572757461, 0x616d726f666e6920,
0x696562206e6f6974, 0x207973616520676e, 0x6165727073206f74, 0x6168207475622064,
0x7473206f74206472, 0x202d202e656c6669, 0x206968736f746153, 0x6f746f6d616b614e,
0x0a0e0d66020b0015,
}
u8s := [2]uint8{0x00, 0x00}
buf := make([]byte, 25*8+2)
for i, v := range u64s {
binary.LittleEndian.PutUint64(buf[i*8:], v)
}
buf[200] = u8s[0]
buf[201] = u8s[1]
return buf
}
// TestGenesisBlockHash_Good is the definitive correctness test for wire
// serialisation. It constructs the testnet genesis block, computes its
// hash, and verifies it matches the hash returned by the C++ daemon.
//
// If this test passes, the block header serialisation, transaction prefix
// serialisation, tree hash, and Keccak-256 implementation are all
// bit-identical to the C++ reference.
func TestGenesisBlockHash_Good(t *testing.T) {
wantHash := "cb9d5455ccb79451931003672c405f5e2ac51bff54021aa30bc4499b1ffc4963"
// Parse the raw genesis coinbase transaction.
rawTx := testnetGenesisRawTx()
dec := NewDecoder(bytes.NewReader(rawTx))
minerTx := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("failed to decode genesis miner tx: %v", dec.Err())
}
// Verify basic transaction structure.
if minerTx.Version != 1 {
t.Fatalf("miner tx version: got %d, want 1", minerTx.Version)
}
if len(minerTx.Vin) != 1 {
t.Fatalf("miner tx vin count: got %d, want 1", len(minerTx.Vin))
}
gen, ok := minerTx.Vin[0].(types.TxInputGenesis)
if !ok {
t.Fatalf("miner tx vin[0]: got %T, want TxInputGenesis", minerTx.Vin[0])
}
if gen.Height != 0 {
t.Fatalf("miner tx genesis height: got %d, want 0", gen.Height)
}
if len(minerTx.Vout) != 1 {
t.Fatalf("miner tx vout count: got %d, want 1", len(minerTx.Vout))
}
// Verify round-trip: re-encode and compare to original bytes.
var rtBuf bytes.Buffer
enc := NewEncoder(&rtBuf)
EncodeTransaction(enc, &minerTx)
if enc.Err() != nil {
t.Fatalf("re-encode error: %v", enc.Err())
}
if !bytes.Equal(rtBuf.Bytes(), rawTx) {
t.Fatalf("round-trip mismatch:\n got: %x\n want: %x", rtBuf.Bytes(), rawTx)
}
// Construct the genesis block.
block := types.Block{
BlockHeader: testnetGenesisHeader(),
MinerTx: minerTx,
TxHashes: nil, // genesis has no other transactions
}
// Compute and verify the block hash.
gotHash := BlockHash(&block)
if hex.EncodeToString(gotHash[:]) != wantHash {
t.Errorf("genesis block hash:\n got: %x\n want: %s", gotHash, wantHash)
// Debug: dump intermediate values.
prefixHash := TransactionPrefixHash(&block.MinerTx)
t.Logf("miner tx prefix hash: %x", prefixHash)
blob := BlockHashingBlob(&block)
t.Logf("block hashing blob (%d bytes): %x", len(blob), blob)
}
}
func TestTransactionPrefixHashRoundTrip_Good(t *testing.T) {
rawTx := testnetGenesisRawTx()
// Decode.
dec := NewDecoder(bytes.NewReader(rawTx))
tx := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
// Prefix hash should be deterministic.
h1 := TransactionPrefixHash(&tx)
h2 := TransactionPrefixHash(&tx)
if h1 != h2 {
t.Error("prefix hash not deterministic")
}
// The prefix hash should equal Keccak-256 of the prefix bytes (first 200 bytes).
wantPrefixHash := Keccak256(rawTx[:200])
if types.Hash(wantPrefixHash) != h1 {
t.Errorf("prefix hash mismatch:\n got: %x\n want: %x", h1, wantPrefixHash)
}
}

607
wire/transaction.go Normal file
View file

@ -0,0 +1,607 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"fmt"
"forge.lthn.ai/core/go-blockchain/types"
)
// EncodeTransactionPrefix serialises the transaction prefix (without
// signatures, attachment, or proofs) in the consensus wire format.
//
// The version field determines the field ordering:
//
// v0/v1: version, vin, vout, extra
// v2+: version, vin, extra, vout, [hardfork_id]
func EncodeTransactionPrefix(enc *Encoder, tx *types.Transaction) {
enc.WriteVarint(tx.Version)
if tx.Version <= types.VersionPreHF4 {
encodePrefixV1(enc, tx)
} else {
encodePrefixV2(enc, tx)
}
}
// EncodeTransaction serialises a full transaction including suffix fields.
func EncodeTransaction(enc *Encoder, tx *types.Transaction) {
EncodeTransactionPrefix(enc, tx)
if tx.Version <= types.VersionPreHF4 {
encodeSuffixV1(enc, tx)
} else {
encodeSuffixV2(enc, tx)
}
}
// DecodeTransactionPrefix deserialises a transaction prefix.
func DecodeTransactionPrefix(dec *Decoder) types.Transaction {
var tx types.Transaction
tx.Version = dec.ReadVarint()
if dec.Err() != nil {
return tx
}
if tx.Version <= types.VersionPreHF4 {
decodePrefixV1(dec, &tx)
} else {
decodePrefixV2(dec, &tx)
}
return tx
}
// DecodeTransaction deserialises a full transaction.
func DecodeTransaction(dec *Decoder) types.Transaction {
tx := DecodeTransactionPrefix(dec)
if dec.Err() != nil {
return tx
}
if tx.Version <= types.VersionPreHF4 {
decodeSuffixV1(dec, &tx)
} else {
decodeSuffixV2(dec, &tx)
}
return tx
}
// --- v0/v1 prefix ---
func encodePrefixV1(enc *Encoder, tx *types.Transaction) {
encodeInputs(enc, tx.Vin)
encodeOutputsV1(enc, tx.Vout)
enc.WriteBytes(tx.Extra) // raw wire bytes including varint count prefix
}
func decodePrefixV1(dec *Decoder, tx *types.Transaction) {
tx.Vin = decodeInputs(dec)
tx.Vout = decodeOutputsV1(dec)
tx.Extra = decodeRawVariantVector(dec)
}
// --- v2+ prefix ---
func encodePrefixV2(enc *Encoder, tx *types.Transaction) {
encodeInputs(enc, tx.Vin)
enc.WriteBytes(tx.Extra)
encodeOutputsV2(enc, tx.Vout)
if tx.Version >= types.VersionPostHF5 {
enc.WriteUint8(tx.HardforkID)
}
}
func decodePrefixV2(dec *Decoder, tx *types.Transaction) {
tx.Vin = decodeInputs(dec)
tx.Extra = decodeRawVariantVector(dec)
tx.Vout = decodeOutputsV2(dec)
if tx.Version >= types.VersionPostHF5 {
tx.HardforkID = dec.ReadUint8()
}
}
// --- v0/v1 suffix (signatures + attachment) ---
func encodeSuffixV1(enc *Encoder, tx *types.Transaction) {
enc.WriteVarint(uint64(len(tx.Signatures)))
for _, ring := range tx.Signatures {
enc.WriteVarint(uint64(len(ring)))
for i := range ring {
enc.WriteBlob64((*[64]byte)(&ring[i]))
}
}
enc.WriteBytes(tx.Attachment)
}
func decodeSuffixV1(dec *Decoder, tx *types.Transaction) {
sigCount := dec.ReadVarint()
if sigCount > 0 && dec.Err() == nil {
tx.Signatures = make([][]types.Signature, sigCount)
for i := uint64(0); i < sigCount; i++ {
ringSize := dec.ReadVarint()
if ringSize > 0 && dec.Err() == nil {
tx.Signatures[i] = make([]types.Signature, ringSize)
for j := uint64(0); j < ringSize; j++ {
dec.ReadBlob64((*[64]byte)(&tx.Signatures[i][j]))
}
}
}
}
tx.Attachment = decodeRawVariantVector(dec)
}
// --- v2+ suffix (attachment + signatures_raw + proofs) ---
func encodeSuffixV2(enc *Encoder, tx *types.Transaction) {
enc.WriteBytes(tx.Attachment)
// v2+ signatures and proofs are stored as raw wire bytes
enc.WriteBytes(tx.Proofs)
}
func decodeSuffixV2(dec *Decoder, tx *types.Transaction) {
tx.Attachment = decodeRawVariantVector(dec)
tx.Proofs = decodeRawVariantVector(dec)
}
// --- inputs ---
func encodeInputs(enc *Encoder, vin []types.TxInput) {
enc.WriteVarint(uint64(len(vin)))
for _, in := range vin {
enc.WriteVariantTag(in.InputType())
switch v := in.(type) {
case types.TxInputGenesis:
enc.WriteVarint(v.Height)
case types.TxInputToKey:
enc.WriteVarint(v.Amount)
encodeKeyOffsets(enc, v.KeyOffsets)
enc.WriteBlob32((*[32]byte)(&v.KeyImage))
enc.WriteBytes(v.EtcDetails)
}
}
}
func decodeInputs(dec *Decoder) []types.TxInput {
n := dec.ReadVarint()
if n == 0 || dec.Err() != nil {
return nil
}
vin := make([]types.TxInput, 0, n)
for i := uint64(0); i < n; i++ {
tag := dec.ReadVariantTag()
if dec.Err() != nil {
return vin
}
switch tag {
case types.InputTypeGenesis:
vin = append(vin, types.TxInputGenesis{Height: dec.ReadVarint()})
case types.InputTypeToKey:
var in types.TxInputToKey
in.Amount = dec.ReadVarint()
in.KeyOffsets = decodeKeyOffsets(dec)
dec.ReadBlob32((*[32]byte)(&in.KeyImage))
in.EtcDetails = decodeRawVariantVector(dec)
vin = append(vin, in)
default:
dec.err = fmt.Errorf("wire: unsupported input tag 0x%02x", tag)
return vin
}
}
return vin
}
// --- key offsets (txout_ref_v variant vector) ---
func encodeKeyOffsets(enc *Encoder, refs []types.TxOutRef) {
enc.WriteVarint(uint64(len(refs)))
for _, ref := range refs {
enc.WriteVariantTag(ref.Tag)
switch ref.Tag {
case types.RefTypeGlobalIndex:
enc.WriteVarint(ref.GlobalIndex)
case types.RefTypeByID:
enc.WriteBlob32((*[32]byte)(&ref.TxID))
enc.WriteVarint(ref.N)
}
}
}
func decodeKeyOffsets(dec *Decoder) []types.TxOutRef {
n := dec.ReadVarint()
if n == 0 || dec.Err() != nil {
return nil
}
refs := make([]types.TxOutRef, n)
for i := uint64(0); i < n; i++ {
refs[i].Tag = dec.ReadVariantTag()
switch refs[i].Tag {
case types.RefTypeGlobalIndex:
refs[i].GlobalIndex = dec.ReadVarint()
case types.RefTypeByID:
dec.ReadBlob32((*[32]byte)(&refs[i].TxID))
refs[i].N = dec.ReadVarint()
default:
dec.err = fmt.Errorf("wire: unsupported ref tag 0x%02x", refs[i].Tag)
return refs
}
}
return refs
}
// --- outputs ---
// encodeOutputsV1 serialises v0/v1 outputs. In v0/v1, outputs are tx_out_bare
// directly without an outer tx_out_v variant tag.
func encodeOutputsV1(enc *Encoder, vout []types.TxOutput) {
enc.WriteVarint(uint64(len(vout)))
for _, out := range vout {
switch v := out.(type) {
case types.TxOutputBare:
enc.WriteVarint(v.Amount)
// Target is a variant (txout_target_v)
enc.WriteVariantTag(types.TargetTypeToKey)
enc.WriteBlob32((*[32]byte)(&v.Target.Key))
enc.WriteUint8(v.Target.MixAttr)
}
}
}
func decodeOutputsV1(dec *Decoder) []types.TxOutput {
n := dec.ReadVarint()
if n == 0 || dec.Err() != nil {
return nil
}
vout := make([]types.TxOutput, 0, n)
for i := uint64(0); i < n; i++ {
var out types.TxOutputBare
out.Amount = dec.ReadVarint()
tag := dec.ReadVariantTag()
if dec.Err() != nil {
return vout
}
switch tag {
case types.TargetTypeToKey:
dec.ReadBlob32((*[32]byte)(&out.Target.Key))
out.Target.MixAttr = dec.ReadUint8()
default:
dec.err = fmt.Errorf("wire: unsupported target tag 0x%02x", tag)
return vout
}
vout = append(vout, out)
}
return vout
}
// encodeOutputsV2 serialises v2+ outputs with outer tx_out_v variant tags.
func encodeOutputsV2(enc *Encoder, vout []types.TxOutput) {
enc.WriteVarint(uint64(len(vout)))
for _, out := range vout {
enc.WriteVariantTag(out.OutputType())
switch v := out.(type) {
case types.TxOutputBare:
enc.WriteVarint(v.Amount)
enc.WriteVariantTag(types.TargetTypeToKey)
enc.WriteBlob32((*[32]byte)(&v.Target.Key))
enc.WriteUint8(v.Target.MixAttr)
case types.TxOutputZarcanum:
enc.WriteBlob32((*[32]byte)(&v.StealthAddress))
enc.WriteBlob32((*[32]byte)(&v.ConcealingPoint))
enc.WriteBlob32((*[32]byte)(&v.AmountCommitment))
enc.WriteBlob32((*[32]byte)(&v.BlindedAssetID))
enc.WriteUint64LE(v.EncryptedAmount)
enc.WriteUint8(v.MixAttr)
}
}
}
func decodeOutputsV2(dec *Decoder) []types.TxOutput {
n := dec.ReadVarint()
if n == 0 || dec.Err() != nil {
return nil
}
vout := make([]types.TxOutput, 0, n)
for i := uint64(0); i < n; i++ {
tag := dec.ReadVariantTag()
if dec.Err() != nil {
return vout
}
switch tag {
case types.OutputTypeBare:
var out types.TxOutputBare
out.Amount = dec.ReadVarint()
targetTag := dec.ReadVariantTag()
if targetTag == types.TargetTypeToKey {
dec.ReadBlob32((*[32]byte)(&out.Target.Key))
out.Target.MixAttr = dec.ReadUint8()
} else {
dec.err = fmt.Errorf("wire: unsupported target tag 0x%02x", targetTag)
return vout
}
vout = append(vout, out)
case types.OutputTypeZarcanum:
var out types.TxOutputZarcanum
dec.ReadBlob32((*[32]byte)(&out.StealthAddress))
dec.ReadBlob32((*[32]byte)(&out.ConcealingPoint))
dec.ReadBlob32((*[32]byte)(&out.AmountCommitment))
dec.ReadBlob32((*[32]byte)(&out.BlindedAssetID))
out.EncryptedAmount = dec.ReadUint64LE()
out.MixAttr = dec.ReadUint8()
vout = append(vout, out)
default:
dec.err = fmt.Errorf("wire: unsupported output tag 0x%02x", tag)
return vout
}
}
return vout
}
// --- raw variant vector encoding ---
// These helpers handle variant vectors stored as opaque raw bytes.
// The raw bytes include the varint count prefix and all tagged elements.
// decodeRawVariantVector reads a variant vector from the decoder and returns
// the raw wire bytes (including the varint count prefix). This enables
// bit-identical round-tripping of extra, attachment, etc_details, and proofs
// without needing to understand every variant type.
//
// For each element, the tag byte determines how to find the element boundary.
// Known fixed-size tags are skipped by size; unknown tags cause an error.
func decodeRawVariantVector(dec *Decoder) []byte {
if dec.err != nil {
return nil
}
// Read the count and capture the varint bytes.
count := dec.ReadVarint()
if dec.err != nil {
return nil
}
if count == 0 {
return EncodeVarint(0) // just the count prefix
}
// Build the raw bytes: start with the count varint.
raw := EncodeVarint(count)
for i := uint64(0); i < count; i++ {
tag := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, tag)
data := readVariantElementData(dec, tag)
if dec.err != nil {
return nil
}
raw = append(raw, data...)
}
return raw
}
// Variant element tags from SET_VARIANT_TAGS (currency_basic.h:1249-1322).
// These are used by readVariantElementData to determine element boundaries
// when reading raw variant vectors (extra, attachment, etc_details).
const (
tagTxComment = 7 // tx_comment — string
tagTxPayerOld = 8 // tx_payer_old — 2 public keys
tagString = 9 // std::string — string
tagTxCryptoChecksum = 10 // tx_crypto_checksum — two uint32 LE
tagTxDerivationHint = 11 // tx_derivation_hint — string
tagTxServiceAttachment = 12 // tx_service_attachment — 3 strings + vector<key> + uint8
tagUnlockTime = 14 // etc_tx_details_unlock_time — varint
tagExpirationTime = 15 // etc_tx_details_expiration_time — varint
tagTxDetailsFlags = 16 // etc_tx_details_flags — varint
tagSignedParts = 17 // signed_parts — uint32 LE
tagExtraAttachmentInfo = 18 // extra_attachment_info — string + hash + varint
tagExtraUserData = 19 // extra_user_data — string
tagExtraAliasEntryOld = 20 // extra_alias_entry_old — complex
tagExtraPadding = 21 // extra_padding — vector<uint8>
tagPublicKey = 22 // crypto::public_key — 32 bytes
tagEtcTxFlags16 = 23 // etc_tx_flags16_t — uint16 LE
tagUint16 = 24 // uint16_t — uint16 LE
tagUint64 = 26 // uint64_t — varint
tagEtcTxTime = 27 // etc_tx_time — varint
tagUint32 = 28 // uint32_t — uint32 LE
tagTxReceiverOld = 29 // tx_receiver_old — 2 public keys
tagUnlockTime2 = 30 // etc_tx_details_unlock_time2 — vector of entries
tagTxPayer = 31 // tx_payer — 2 keys + optional flag
tagTxReceiver = 32 // tx_receiver — 2 keys + optional flag
tagExtraAliasEntry = 33 // extra_alias_entry — complex
tagZarcanumTxDataV1 = 39 // zarcanum_tx_data_v1 — complex
)
// readVariantElementData reads the data portion of a variant element (after the
// tag byte) and returns the raw bytes. The tag determines the expected size.
func readVariantElementData(dec *Decoder, tag uint8) []byte {
switch tag {
// 32-byte fixed blob
case tagPublicKey:
return dec.ReadBytes(32)
// String fields (varint length + bytes)
case tagTxComment, tagString, tagTxDerivationHint, tagExtraUserData:
return readStringBlob(dec)
// Varint fields
case tagUnlockTime, tagExpirationTime, tagTxDetailsFlags, tagUint64, tagEtcTxTime:
v := dec.ReadVarint()
if dec.err != nil {
return nil
}
return EncodeVarint(v)
// Fixed-size integer fields
case tagTxCryptoChecksum: // two uint32 LE
return dec.ReadBytes(8)
case tagSignedParts, tagUint32: // uint32 LE
return dec.ReadBytes(4)
case tagEtcTxFlags16, tagUint16: // uint16 LE
return dec.ReadBytes(2)
// Vector of uint8 (varint count + bytes)
case tagExtraPadding:
return readVariantVectorFixed(dec, 1)
// Struct types: 2 public keys (64 bytes)
case tagTxPayerOld, tagTxReceiverOld:
return dec.ReadBytes(64)
// Struct types: 2 public keys + optional flag
case tagTxPayer, tagTxReceiver:
return readTxPayer(dec)
// Composite types
case tagExtraAttachmentInfo:
return readExtraAttachmentInfo(dec)
case tagUnlockTime2:
return readUnlockTime2(dec)
case tagTxServiceAttachment:
return readTxServiceAttachment(dec)
default:
dec.err = fmt.Errorf("wire: unsupported variant tag 0x%02x (%d)", tag, tag)
return nil
}
}
// readStringBlob reads a varint-prefixed string and returns the raw bytes
// including the length varint.
func readStringBlob(dec *Decoder) []byte {
length := dec.ReadVarint()
if dec.err != nil {
return nil
}
raw := EncodeVarint(length)
if length > 0 {
data := dec.ReadBytes(int(length))
if dec.err != nil {
return nil
}
raw = append(raw, data...)
}
return raw
}
// readVariantVectorFixed reads a vector of fixed-size elements and returns
// the raw bytes including the count varint.
func readVariantVectorFixed(dec *Decoder, elemSize int) []byte {
count := dec.ReadVarint()
if dec.err != nil {
return nil
}
raw := EncodeVarint(count)
if count > 0 {
data := dec.ReadBytes(int(count) * elemSize)
if dec.err != nil {
return nil
}
raw = append(raw, data...)
}
return raw
}
// readExtraAttachmentInfo reads the extra_attachment_info variant (tag 18).
// Structure: cnt_type (string) + hash (32 bytes) + sz (varint).
func readExtraAttachmentInfo(dec *Decoder) []byte {
var raw []byte
// cnt_type: string
str := readStringBlob(dec)
if dec.err != nil {
return nil
}
raw = append(raw, str...)
// hash: 32 bytes
h := dec.ReadBytes(32)
if dec.err != nil {
return nil
}
raw = append(raw, h...)
// sz: varint
v := dec.ReadVarint()
if dec.err != nil {
return nil
}
raw = append(raw, EncodeVarint(v)...)
return raw
}
// readUnlockTime2 reads etc_tx_details_unlock_time2 (tag 30).
// Structure: vector of {varint unlock_time, varint output_index}.
func readUnlockTime2(dec *Decoder) []byte {
count := dec.ReadVarint()
if dec.err != nil {
return nil
}
raw := EncodeVarint(count)
for i := uint64(0); i < count; i++ {
v1 := dec.ReadVarint()
if dec.err != nil {
return nil
}
raw = append(raw, EncodeVarint(v1)...)
v2 := dec.ReadVarint()
if dec.err != nil {
return nil
}
raw = append(raw, EncodeVarint(v2)...)
}
return raw
}
// readTxPayer reads tx_payer / tx_receiver (tags 31 / 32).
// Structure: spend_public_key (32 bytes) + view_public_key (32 bytes)
// + optional_field marker. In the binary_archive, the optional is
// serialised as uint8(1)+data or uint8(0) for empty.
func readTxPayer(dec *Decoder) []byte {
var raw []byte
// Two public keys
keys := dec.ReadBytes(64)
if dec.err != nil {
return nil
}
raw = append(raw, keys...)
// is_auditable flag (optional_field serialised as uint8 presence + data)
marker := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, marker)
if marker != 0 {
b := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, b)
}
return raw
}
// readTxServiceAttachment reads tx_service_attachment (tag 12).
// Structure: service_id (string) + instruction (string) + body (string)
// + security (vector<public_key>) + flags (uint8).
func readTxServiceAttachment(dec *Decoder) []byte {
var raw []byte
// Three string fields
for range 3 {
s := readStringBlob(dec)
if dec.err != nil {
return nil
}
raw = append(raw, s...)
}
// security: vector<crypto::public_key> (varint count + 32*N bytes)
v := readVariantVectorFixed(dec, 32)
if dec.err != nil {
return nil
}
raw = append(raw, v...)
// flags: uint8
b := dec.ReadUint8()
if dec.err != nil {
return nil
}
raw = append(raw, b)
return raw
}

469
wire/transaction_test.go Normal file
View file

@ -0,0 +1,469 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"bytes"
"testing"
"forge.lthn.ai/core/go-blockchain/types"
)
func TestCoinbaseTxEncodeDecode_Good(t *testing.T) {
// Build a minimal v1 coinbase transaction.
tx := types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputGenesis{Height: 42}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 1000000,
Target: types.TxOutToKey{
Key: types.PublicKey{0xDE, 0xAD},
MixAttr: 0,
},
}},
Extra: EncodeVarint(0), // empty extra (count=0)
}
// Encode prefix.
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransactionPrefix(enc, &tx)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
// Decode prefix.
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeTransactionPrefix(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
if got.Version != tx.Version {
t.Errorf("version: got %d, want %d", got.Version, tx.Version)
}
if len(got.Vin) != 1 {
t.Fatalf("vin count: got %d, want 1", len(got.Vin))
}
gen, ok := got.Vin[0].(types.TxInputGenesis)
if !ok {
t.Fatalf("vin[0] type: got %T, want TxInputGenesis", got.Vin[0])
}
if gen.Height != 42 {
t.Errorf("height: got %d, want 42", gen.Height)
}
if len(got.Vout) != 1 {
t.Fatalf("vout count: got %d, want 1", len(got.Vout))
}
bare, ok := got.Vout[0].(types.TxOutputBare)
if !ok {
t.Fatalf("vout[0] type: got %T, want TxOutputBare", got.Vout[0])
}
if bare.Amount != 1000000 {
t.Errorf("amount: got %d, want 1000000", bare.Amount)
}
if bare.Target.Key[0] != 0xDE || bare.Target.Key[1] != 0xAD {
t.Errorf("target key: got %x, want DE AD...", bare.Target.Key[:2])
}
}
func TestFullTxRoundTrip_Good(t *testing.T) {
// Build a v1 coinbase transaction with empty signatures and attachment.
tx := types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputGenesis{Height: 0}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 5000000000,
Target: types.TxOutToKey{
Key: types.PublicKey{0x01, 0x02, 0x03},
MixAttr: 0,
},
}},
Extra: EncodeVarint(0), // empty extra
Attachment: EncodeVarint(0), // empty attachment
}
// Encode full transaction.
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransaction(enc, &tx)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
encoded := buf.Bytes()
// Decode full transaction.
dec := NewDecoder(bytes.NewReader(encoded))
got := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
// Re-encode and compare bytes.
var rtBuf bytes.Buffer
enc2 := NewEncoder(&rtBuf)
EncodeTransaction(enc2, &got)
if enc2.Err() != nil {
t.Fatalf("re-encode error: %v", enc2.Err())
}
if !bytes.Equal(rtBuf.Bytes(), encoded) {
t.Errorf("round-trip mismatch:\n got: %x\n want: %x", rtBuf.Bytes(), encoded)
}
}
func TestTransactionHash_Good(t *testing.T) {
// TransactionHash for v0/v1 should equal TransactionPrefixHash
// (confirmed from C++ source: get_transaction_hash delegates to prefix hash).
tx := types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputGenesis{Height: 0}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 5000000000,
Target: types.TxOutToKey{Key: types.PublicKey{0x01, 0x02, 0x03}},
}},
Extra: EncodeVarint(0),
Attachment: EncodeVarint(0),
}
txHash := TransactionHash(&tx)
prefixHash := TransactionPrefixHash(&tx)
// For v1, full tx hash should differ from prefix hash because
// TransactionHash encodes the full transaction (prefix + suffix).
// The prefix is a subset of the full encoding.
var prefBuf bytes.Buffer
enc1 := NewEncoder(&prefBuf)
EncodeTransactionPrefix(enc1, &tx)
var fullBuf bytes.Buffer
enc2 := NewEncoder(&fullBuf)
EncodeTransaction(enc2, &tx)
// Prefix hash is over prefix bytes only.
if Keccak256(prefBuf.Bytes()) != [32]byte(prefixHash) {
t.Error("TransactionPrefixHash does not match manual prefix encoding")
}
// TransactionHash is over full encoding.
if Keccak256(fullBuf.Bytes()) != [32]byte(txHash) {
t.Error("TransactionHash does not match manual full encoding")
}
}
func TestTxInputToKeyRoundTrip_Good(t *testing.T) {
tx := types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputToKey{
Amount: 100,
KeyOffsets: []types.TxOutRef{
{Tag: types.RefTypeGlobalIndex, GlobalIndex: 42},
{Tag: types.RefTypeGlobalIndex, GlobalIndex: 7},
},
KeyImage: types.KeyImage{0xFF},
EtcDetails: EncodeVarint(0),
}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 50,
Target: types.TxOutToKey{Key: types.PublicKey{0xAB}},
}},
Extra: EncodeVarint(0),
Attachment: EncodeVarint(0),
}
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransaction(enc, &tx)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
toKey, ok := got.Vin[0].(types.TxInputToKey)
if !ok {
t.Fatalf("vin[0] type: got %T, want TxInputToKey", got.Vin[0])
}
if toKey.Amount != 100 {
t.Errorf("amount: got %d, want 100", toKey.Amount)
}
if len(toKey.KeyOffsets) != 2 {
t.Fatalf("key_offsets: got %d, want 2", len(toKey.KeyOffsets))
}
if toKey.KeyOffsets[0].GlobalIndex != 42 {
t.Errorf("key_offsets[0]: got %d, want 42", toKey.KeyOffsets[0].GlobalIndex)
}
if toKey.KeyImage[0] != 0xFF {
t.Errorf("key_image[0]: got 0x%02x, want 0xFF", toKey.KeyImage[0])
}
}
func TestExtraVariantTags_Good(t *testing.T) {
// Test that various extra variant tags decode and re-encode correctly.
tests := []struct {
name string
data []byte // raw extra bytes (including varint count prefix)
}{
{
name: "public_key",
// count=1, tag=22 (crypto::public_key), 32 bytes of key
data: append([]byte{0x01, tagPublicKey}, make([]byte, 32)...),
},
{
name: "unlock_time",
// count=1, tag=14 (etc_tx_details_unlock_time), varint(100)=0x64
data: []byte{0x01, tagUnlockTime, 0x64},
},
{
name: "tx_details_flags",
// count=1, tag=16, varint(1)=0x01
data: []byte{0x01, tagTxDetailsFlags, 0x01},
},
{
name: "derivation_hint",
// count=1, tag=11, string len=3, "abc"
data: []byte{0x01, tagTxDerivationHint, 0x03, 'a', 'b', 'c'},
},
{
name: "user_data",
// count=1, tag=19, string len=2, "hi"
data: []byte{0x01, tagExtraUserData, 0x02, 'h', 'i'},
},
{
name: "extra_padding",
// count=1, tag=21, vector count=4, 4 bytes
data: []byte{0x01, tagExtraPadding, 0x04, 0x00, 0x00, 0x00, 0x00},
},
{
name: "crypto_checksum",
// count=1, tag=10, 8 bytes (two uint32 LE)
data: []byte{0x01, tagTxCryptoChecksum, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00},
},
{
name: "signed_parts",
// count=1, tag=17, uint32 LE
data: []byte{0x01, tagSignedParts, 0xFF, 0x00, 0x00, 0x00},
},
{
name: "etc_tx_flags16",
// count=1, tag=23, uint16 LE
data: []byte{0x01, tagEtcTxFlags16, 0x01, 0x00},
},
{
name: "etc_tx_time",
// count=1, tag=27, varint(42)
data: []byte{0x01, tagEtcTxTime, 0x2A},
},
{
name: "tx_comment",
// count=1, tag=7, string len=5, "hello"
data: []byte{0x01, tagTxComment, 0x05, 'h', 'e', 'l', 'l', 'o'},
},
{
name: "tx_payer_old",
// count=1, tag=8, 64 bytes (2 public keys)
data: append([]byte{0x01, tagTxPayerOld}, make([]byte, 64)...),
},
{
name: "tx_receiver_old",
// count=1, tag=29, 64 bytes
data: append([]byte{0x01, tagTxReceiverOld}, make([]byte, 64)...),
},
{
name: "tx_payer_not_auditable",
// count=1, tag=31, 64 bytes (2 keys) + marker=0 (no auditable flag)
data: append(append([]byte{0x01, tagTxPayer}, make([]byte, 64)...), 0x00),
},
{
name: "tx_payer_auditable",
// count=1, tag=31, 64 bytes (2 keys) + marker=1 + auditable_flag=1
data: append(append([]byte{0x01, tagTxPayer}, make([]byte, 64)...), 0x01, 0x01),
},
{
name: "extra_attachment_info",
// count=1, tag=18, cnt_type(string len=0) + hash(32 zeros) + sz(varint 0)
data: append([]byte{0x01, tagExtraAttachmentInfo, 0x00}, append(make([]byte, 32), 0x00)...),
},
{
name: "unlock_time2",
// count=1, tag=30, vector count=1, entry: {unlock_time=10, output_index=0}
data: []byte{0x01, tagUnlockTime2, 0x01, 0x0A, 0x00},
},
{
name: "tx_service_attachment",
// count=1, tag=12, 3 empty strings + empty key vec + flags=0
data: []byte{0x01, tagTxServiceAttachment, 0x00, 0x00, 0x00, 0x00, 0x00},
},
{
name: "multiple_elements",
// count=2: public_key + unlock_time
data: append(
append([]byte{0x02, tagPublicKey}, make([]byte, 32)...),
tagUnlockTime, 0x64,
),
},
{
name: "empty_extra",
// count=0
data: []byte{0x00},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Build a v1 tx with this extra data.
tx := types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputGenesis{Height: 0}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 1000,
Target: types.TxOutToKey{Key: types.PublicKey{0xAA}},
}},
Extra: tt.data,
Attachment: EncodeVarint(0),
}
// Encode.
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransaction(enc, &tx)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
// Decode.
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
// Re-encode and compare.
var rtBuf bytes.Buffer
enc2 := NewEncoder(&rtBuf)
EncodeTransaction(enc2, &got)
if enc2.Err() != nil {
t.Fatalf("re-encode error: %v", enc2.Err())
}
if !bytes.Equal(rtBuf.Bytes(), buf.Bytes()) {
t.Errorf("round-trip mismatch:\n got: %x\n want: %x", rtBuf.Bytes(), buf.Bytes())
}
})
}
}
func TestTxWithSignaturesRoundTrip_Good(t *testing.T) {
// Test v1 transaction with non-empty signatures.
sig := types.Signature{}
sig[0] = 0xAA
sig[63] = 0xBB
tx := types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputToKey{
Amount: 100,
KeyOffsets: []types.TxOutRef{
{Tag: types.RefTypeGlobalIndex, GlobalIndex: 42},
},
KeyImage: types.KeyImage{0xFF},
EtcDetails: EncodeVarint(0),
}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 50,
Target: types.TxOutToKey{Key: types.PublicKey{0xAB}},
}},
Extra: EncodeVarint(0),
Signatures: [][]types.Signature{
{sig},
},
Attachment: EncodeVarint(0),
}
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransaction(enc, &tx)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
if len(got.Signatures) != 1 {
t.Fatalf("signatures count: got %d, want 1", len(got.Signatures))
}
if len(got.Signatures[0]) != 1 {
t.Fatalf("ring[0] size: got %d, want 1", len(got.Signatures[0]))
}
if got.Signatures[0][0][0] != 0xAA || got.Signatures[0][0][63] != 0xBB {
t.Error("signature data mismatch")
}
// Round-trip byte comparison.
var rtBuf bytes.Buffer
enc2 := NewEncoder(&rtBuf)
EncodeTransaction(enc2, &got)
if !bytes.Equal(rtBuf.Bytes(), buf.Bytes()) {
t.Errorf("round-trip mismatch")
}
}
func TestRefByIDRoundTrip_Good(t *testing.T) {
// Test TxOutRef with RefTypeByID tag.
tx := types.Transaction{
Version: 1,
Vin: []types.TxInput{types.TxInputToKey{
Amount: 100,
KeyOffsets: []types.TxOutRef{
{
Tag: types.RefTypeByID,
TxID: types.Hash{0xDE, 0xAD},
N: 3,
},
},
KeyImage: types.KeyImage{0xFF},
EtcDetails: EncodeVarint(0),
}},
Vout: []types.TxOutput{types.TxOutputBare{
Amount: 50,
Target: types.TxOutToKey{Key: types.PublicKey{0xAB}},
}},
Extra: EncodeVarint(0),
Attachment: EncodeVarint(0),
}
var buf bytes.Buffer
enc := NewEncoder(&buf)
EncodeTransaction(enc, &tx)
if enc.Err() != nil {
t.Fatalf("encode error: %v", enc.Err())
}
dec := NewDecoder(bytes.NewReader(buf.Bytes()))
got := DecodeTransaction(dec)
if dec.Err() != nil {
t.Fatalf("decode error: %v", dec.Err())
}
toKey := got.Vin[0].(types.TxInputToKey)
if len(toKey.KeyOffsets) != 1 {
t.Fatalf("key_offsets: got %d, want 1", len(toKey.KeyOffsets))
}
ref := toKey.KeyOffsets[0]
if ref.Tag != types.RefTypeByID {
t.Errorf("ref tag: got %d, want %d", ref.Tag, types.RefTypeByID)
}
if ref.TxID[0] != 0xDE || ref.TxID[1] != 0xAD {
t.Errorf("ref txid: got %x, want DEAD...", ref.TxID[:2])
}
if ref.N != 3 {
t.Errorf("ref N: got %d, want 3", ref.N)
}
}

86
wire/treehash.go Normal file
View file

@ -0,0 +1,86 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import "golang.org/x/crypto/sha3"
// Keccak256 computes the Keccak-256 hash (pre-NIST, no domain separation)
// used as cn_fast_hash throughout the CryptoNote protocol.
func Keccak256(data []byte) [32]byte {
h := sha3.NewLegacyKeccak256()
h.Write(data)
var out [32]byte
h.Sum(out[:0])
return out
}
// TreeHash computes the CryptoNote Merkle tree hash over a set of 32-byte
// hashes. This is a direct port of crypto/tree-hash.c from the C++ daemon.
//
// Algorithm:
// - 0 hashes: returns zero hash
// - 1 hash: returns the hash itself (identity)
// - 2 hashes: returns Keccak256(h0 || h1)
// - N hashes: pad to power-of-2 leaves, pairwise Keccak up the tree
func TreeHash(hashes [][32]byte) [32]byte {
count := len(hashes)
if count == 0 {
return [32]byte{}
}
if count == 1 {
return hashes[0]
}
if count == 2 {
var buf [64]byte
copy(buf[:32], hashes[0][:])
copy(buf[32:], hashes[1][:])
return Keccak256(buf[:])
}
// Find largest power of 2 that is <= count. This mirrors the C++ bit trick:
// cnt = count - 1; for (i = 1; i < bits; i <<= 1) cnt |= cnt >> i;
// cnt &= ~(cnt >> 1);
cnt := count - 1
for i := 1; i < 64; i <<= 1 {
cnt |= cnt >> uint(i)
}
cnt &= ^(cnt >> 1)
// Allocate intermediate hash buffer.
ints := make([][32]byte, cnt)
// Copy the first (2*cnt - count) hashes directly into ints.
direct := 2*cnt - count
copy(ints[:direct], hashes[:direct])
// Pair-hash the remaining hashes into ints.
i := direct
for j := direct; j < cnt; j++ {
var buf [64]byte
copy(buf[:32], hashes[i][:])
copy(buf[32:], hashes[i+1][:])
ints[j] = Keccak256(buf[:])
i += 2
}
// Iteratively pair-hash until we have 2 hashes left.
for cnt > 2 {
cnt >>= 1
for i, j := 0, 0; j < cnt; j++ {
var buf [64]byte
copy(buf[:32], ints[i][:])
copy(buf[32:], ints[i+1][:])
ints[j] = Keccak256(buf[:])
i += 2
}
}
// Final hash of the remaining pair.
var buf [64]byte
copy(buf[:32], ints[0][:])
copy(buf[32:], ints[1][:])
return Keccak256(buf[:])
}

171
wire/treehash_test.go Normal file
View file

@ -0,0 +1,171 @@
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package wire
import (
"encoding/hex"
"testing"
)
func TestKeccak256_Good(t *testing.T) {
// Empty input: well-known Keccak-256 of "" (pre-NIST).
got := Keccak256(nil)
want := "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
if hex.EncodeToString(got[:]) != want {
t.Errorf("Keccak256(nil) = %x, want %s", got, want)
}
}
func TestTreeHashSingle_Good(t *testing.T) {
var h [32]byte
h[0] = 0xAB
h[31] = 0xCD
got := TreeHash([][32]byte{h})
if got != h {
t.Error("TreeHash of single hash should be identity")
}
}
func TestTreeHashPair_Good(t *testing.T) {
var h0, h1 [32]byte
h0[0] = 0x01
h1[0] = 0x02
got := TreeHash([][32]byte{h0, h1})
// Manual: Keccak256(h0 || h1)
var buf [64]byte
copy(buf[:32], h0[:])
copy(buf[32:], h1[:])
want := Keccak256(buf[:])
if got != want {
t.Errorf("TreeHash pair: got %x, want %x", got, want)
}
}
func TestTreeHashThree_Good(t *testing.T) {
var h0, h1, h2 [32]byte
h0[0] = 0xAA
h1[0] = 0xBB
h2[0] = 0xCC
got := TreeHash([][32]byte{h0, h1, h2})
// For 3 hashes, cnt=2:
// ints[0] = h0 (direct copy)
// ints[1] = Keccak256(h1 || h2)
// result = Keccak256(ints[0] || ints[1])
var buf [64]byte
copy(buf[:32], h1[:])
copy(buf[32:], h2[:])
ints1 := Keccak256(buf[:])
copy(buf[:32], h0[:])
copy(buf[32:], ints1[:])
want := Keccak256(buf[:])
if got != want {
t.Errorf("TreeHash(3): got %x, want %x", got, want)
}
}
func TestTreeHashFour_Good(t *testing.T) {
hashes := make([][32]byte, 4)
for i := range hashes {
hashes[i][0] = byte(i + 1)
}
got := TreeHash(hashes)
// For 4 hashes, cnt=2:
// ints[0] = Keccak256(h0 || h1)
// ints[1] = Keccak256(h2 || h3)
// result = Keccak256(ints[0] || ints[1])
var buf [64]byte
copy(buf[:32], hashes[0][:])
copy(buf[32:], hashes[1][:])
ints0 := Keccak256(buf[:])
copy(buf[:32], hashes[2][:])
copy(buf[32:], hashes[3][:])
ints1 := Keccak256(buf[:])
copy(buf[:32], ints0[:])
copy(buf[32:], ints1[:])
want := Keccak256(buf[:])
if got != want {
t.Errorf("TreeHash(4): got %x, want %x", got, want)
}
}
func TestTreeHashFive_Good(t *testing.T) {
// 5 hashes exercises the iterative cnt > 2 loop.
hashes := make([][32]byte, 5)
for i := range hashes {
hashes[i][0] = byte(i + 1)
}
got := TreeHash(hashes)
// For 5 hashes: cnt=4, direct=3
// ints[0] = h0, ints[1] = h1, ints[2] = h2
// ints[3] = Keccak256(h3 || h4)
// Then cnt=2: ints[0] = Keccak256(ints[0] || ints[1])
// ints[1] = Keccak256(ints[2] || ints[3])
// Final = Keccak256(ints[0] || ints[1])
var buf [64]byte
// ints[3]
copy(buf[:32], hashes[3][:])
copy(buf[32:], hashes[4][:])
ints3 := Keccak256(buf[:])
// Round 1: pair-hash
copy(buf[:32], hashes[0][:])
copy(buf[32:], hashes[1][:])
r1_0 := Keccak256(buf[:])
copy(buf[:32], hashes[2][:])
copy(buf[32:], ints3[:])
r1_1 := Keccak256(buf[:])
// Final
copy(buf[:32], r1_0[:])
copy(buf[32:], r1_1[:])
want := Keccak256(buf[:])
if got != want {
t.Errorf("TreeHash(5): got %x, want %x", got, want)
}
}
func TestTreeHashEight_Good(t *testing.T) {
// 8 hashes = perfect power of 2, exercises multiple loop iterations.
hashes := make([][32]byte, 8)
for i := range hashes {
hashes[i][0] = byte(i + 1)
}
got := TreeHash(hashes)
// Verify determinism.
got2 := TreeHash(hashes)
if got != got2 {
t.Error("TreeHash(8) not deterministic")
}
// Sanity: result should not be zero.
if got == ([32]byte{}) {
t.Error("TreeHash(8) returned zero hash")
}
}
func TestTreeHashEmpty_Good(t *testing.T) {
got := TreeHash(nil)
if got != ([32]byte{}) {
t.Errorf("TreeHash(nil) should be zero hash, got %x", got)
}
}