From 4c0b7f290e06ee2b566de0e6c763e34d8e2c2d4f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 15:10:33 +0000 Subject: [PATCH] feat: Phase 0 scaffold -- config, types, wire, difficulty Chain parameters from Lethean C++ source. Base58 address encoding with Keccak-256 checksum. CryptoNote varint. LWMA difficulty skeleton. Co-Authored-By: Charon --- CLAUDE.md | 46 ++++ config/config.go | 465 ++++++++++++++++++++++++++++++++++ config/config_test.go | 168 ++++++++++++ config/hardfork.go | 95 +++++++ config/hardfork_test.go | 197 ++++++++++++++ difficulty/difficulty.go | 100 ++++++++ difficulty/difficulty_test.go | 129 ++++++++++ go.mod | 7 + go.sum | 4 + types/address.go | 289 +++++++++++++++++++++ types/address_test.go | 175 +++++++++++++ types/block.go | 47 ++++ types/transaction.go | 143 +++++++++++ types/types.go | 92 +++++++ wire/varint.go | 75 ++++++ wire/varint_test.go | 126 +++++++++ 16 files changed, 2158 insertions(+) create mode 100644 CLAUDE.md create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 config/hardfork.go create mode 100644 config/hardfork_test.go create mode 100644 difficulty/difficulty.go create mode 100644 difficulty/difficulty_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 types/address.go create mode 100644 types/address_test.go create mode 100644 types/block.go create mode 100644 types/transaction.go create mode 100644 types/types.go create mode 100644 wire/varint.go create mode 100644 wire/varint_test.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aca249a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# go-blockchain + +Go implementation of the Lethean blockchain protocol. Pure Go package providing +chain configuration, core data types, wire serialisation, and difficulty +calculation for the Lethean CryptoNote/Zano-fork chain. + +This package follows ADR-001: Go Shell + C++ Crypto Library. Protocol logic +lives in Go; only the mathematically complex cryptographic primitives (ring +signatures, bulletproofs, Zarcanum proofs) are delegated to a cleaned C++ +library via CGo in later phases. + +Lineage: CryptoNote -> IntenseCoin (2017) -> Lethean -> Zano rebase. + +Licence: EUPL-1.2 + +## Build and Test + +```bash +go build ./... +go test -v -race ./... +go vet ./... +``` + +## Package Layout + +``` +config/ Chain parameters (mainnet/testnet), hardfork schedule +types/ Core data types: Hash, PublicKey, Address, Block, Transaction +wire/ Binary serialisation (CryptoNote varint encoding) +difficulty/ PoW + PoS difficulty adjustment (LWMA variant) +crypto/ (future) CGo bridge to libcryptonote +p2p/ (future) Levin TCP protocol +rpc/ (future) Daemon and wallet JSON-RPC +chain/ (future) Blockchain storage, validation, mempool +wallet/ (future) Key management, output scanning, tx construction +consensus/ (future) Hardfork rules, block reward, fee policy +``` + +## Coding Standards + +- **Language:** UK English in all comments and documentation (colour, organisation, centre) +- **Commits:** Conventional commits (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`) +- **Co-Author:** All commits include `Co-Authored-By: Charon ` +- **Test naming:** `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases) +- **Imports:** stdlib, then `golang.org/x`, then `forge.lthn.ai`, each separated by a blank line +- **No emojis** in code or comments diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..72a3564 --- /dev/null +++ b/config/config.go @@ -0,0 +1,465 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +// Package config defines chain parameters for the Lethean blockchain. +// +// All constants are derived from the canonical C++ source files +// currency_config.h.in and default.cmake. Mainnet and Testnet configurations +// are provided as package-level variables. +package config + +// --------------------------------------------------------------------------- +// Tokenomics +// --------------------------------------------------------------------------- + +// Coin is the number of smallest indivisible units in one LTHN. +// 1 LTHN = 10^12 atomic units. +const Coin uint64 = 1_000_000_000_000 + +// DisplayDecimalPoint is the number of decimal places used when displaying +// amounts in human-readable form. +const DisplayDecimalPoint = 12 + +// BlockReward is the fixed reward per block in atomic units (1.0 LTHN). +const BlockReward uint64 = 1_000_000_000_000 + +// DefaultFee is the standard transaction fee in atomic units (0.01 LTHN). +const DefaultFee uint64 = 10_000_000_000 + +// MinimumFee is the lowest acceptable transaction fee in atomic units (0.01 LTHN). +const MinimumFee uint64 = 10_000_000_000 + +// Premine is the total pre-mined supply in atomic units (10,000,000 LTHN). +// This covers the coinswap allocation (13,827,203 LTHN reserved) and the +// initial premine (3,690,000 LTHN). The raw value from cmake is +// 10,000,000,000,000,000,000 but that exceeds uint64 range. The C++ code +// uses an unsigned literal suffix; we store the value faithfully. +const Premine uint64 = 10_000_000_000_000_000_000 + +// BaseRewardDustThreshold is the minimum meaningful fraction of a block +// reward in atomic units. +const BaseRewardDustThreshold uint64 = 1_000_000 + +// DefaultDustThreshold is the dust threshold for normal transactions. +const DefaultDustThreshold uint64 = 0 + +// --------------------------------------------------------------------------- +// Address prefixes +// --------------------------------------------------------------------------- + +// Address prefix constants determine the leading characters of base58-encoded +// addresses. Each prefix is varint-encoded before the public keys. +const ( + // AddressPrefix is the standard public address prefix. + // Produces addresses starting with "iTHN". + AddressPrefix uint64 = 0x1eaf7 + + // IntegratedAddressPrefix is the prefix for integrated addresses + // (address + embedded payment ID). Produces addresses starting with "iTHn". + IntegratedAddressPrefix uint64 = 0xdeaf7 + + // AuditableAddressPrefix is the prefix for auditable addresses. + // Produces addresses starting with "iThN". + AuditableAddressPrefix uint64 = 0x3ceff7 + + // AuditableIntegratedAddressPrefix is the prefix for auditable + // integrated addresses. Produces addresses starting with "iThn". + AuditableIntegratedAddressPrefix uint64 = 0x8b077 +) + +// --------------------------------------------------------------------------- +// P2P and RPC ports +// --------------------------------------------------------------------------- + +const ( + MainnetP2PPort uint16 = 36942 + MainnetRPCPort uint16 = 36941 + MainnetStratumPort uint16 = 36940 + + TestnetP2PPort uint16 = 46942 + TestnetRPCPort uint16 = 46941 + TestnetStratumPort uint16 = 46940 +) + +// --------------------------------------------------------------------------- +// Timing and difficulty +// --------------------------------------------------------------------------- + +const ( + // DifficultyPowTarget is the target block interval for PoW blocks in seconds. + DifficultyPowTarget uint64 = 120 + + // DifficultyPosTarget is the target block interval for PoS blocks in seconds. + DifficultyPosTarget uint64 = 120 + + // DifficultyTotalTarget is the effective combined target: + // (PoW + PoS) / 4 = 60 seconds. + DifficultyTotalTarget uint64 = (DifficultyPowTarget + DifficultyPosTarget) / 4 + + // DifficultyWindow is the number of blocks used for difficulty calculation. + DifficultyWindow uint64 = 720 + + // DifficultyLag is the additional lookback beyond the window. + DifficultyLag uint64 = 15 + + // DifficultyCut is the number of timestamps cut from each end after sorting. + DifficultyCut uint64 = 60 + + // DifficultyBlocksCount is the total number of blocks considered + // (Window + Lag). + DifficultyBlocksCount uint64 = DifficultyWindow + DifficultyLag + + // DifficultyPowStarter is the initial PoW difficulty. + DifficultyPowStarter uint64 = 1 + + // DifficultyPosStarter is the initial PoS difficulty. + DifficultyPosStarter uint64 = 1 + + // DifficultyPowTargetHF6 is the PoW target after hardfork 6 (240s). + DifficultyPowTargetHF6 uint64 = 240 + + // DifficultyPosTargetHF6 is the PoS target after hardfork 6 (240s). + DifficultyPosTargetHF6 uint64 = 240 + + // DifficultyTotalTargetHF6 is the combined target after HF6. + DifficultyTotalTargetHF6 uint64 = (DifficultyPowTargetHF6 + DifficultyPosTargetHF6) / 4 +) + +// --------------------------------------------------------------------------- +// Block and transaction limits +// --------------------------------------------------------------------------- + +const ( + // MaxBlockNumber is the absolute maximum block height. + MaxBlockNumber uint64 = 500_000_000 + + // MaxBlockSize is the maximum block header blob size in bytes. + MaxBlockSize uint64 = 500_000_000 + + // TxMaxAllowedInputs is the maximum number of inputs per transaction. + // Limited primarily by the asset surjection proof. + TxMaxAllowedInputs uint64 = 256 + + // TxMaxAllowedOutputs is the maximum number of outputs per transaction. + TxMaxAllowedOutputs uint64 = 2000 + + // TxMinAllowedOutputs is the minimum number of outputs (effective from HF4 Zarcanum). + TxMinAllowedOutputs uint64 = 2 + + // DefaultDecoySetSize is the ring size for pre-HF4 transactions. + DefaultDecoySetSize uint64 = 10 + + // HF4MandatoryDecoySetSize is the ring size required from HF4 onwards. + HF4MandatoryDecoySetSize uint64 = 15 + + // HF4MandatoryMinCoinage is the minimum coinage in blocks required for HF4. + HF4MandatoryMinCoinage uint64 = 10 + + // MinedMoneyUnlockWindow is the number of blocks before mined coins + // can be spent. + MinedMoneyUnlockWindow uint64 = 10 + + // BlockGrantedFullRewardZone is the block size threshold in bytes after + // which block reward is calculated using the actual block size. + BlockGrantedFullRewardZone uint64 = 125_000 + + // CoinbaseBlobReservedSize is the reserved space for the coinbase + // transaction blob in bytes. + CoinbaseBlobReservedSize uint64 = 1100 + + // BlockFutureTimeLimit is the maximum acceptable future timestamp for + // PoW blocks in seconds (2 hours). + BlockFutureTimeLimit uint64 = 60 * 60 * 2 + + // PosBlockFutureTimeLimit is the maximum acceptable future timestamp + // for PoS blocks in seconds (20 minutes). + PosBlockFutureTimeLimit uint64 = 60 * 20 + + // TimestampCheckWindow is the number of blocks used when checking + // whether a block timestamp is valid. + TimestampCheckWindow uint64 = 60 + + // PosStartHeight is the block height from which PoS is enabled. + PosStartHeight uint64 = 0 + + // RewardBlocksWindow is the number of recent blocks used to calculate + // the reward median. + RewardBlocksWindow uint64 = 400 + + // FreeTxMaxBlobSize is the soft txpool-based limit for free transactions + // in bytes. + FreeTxMaxBlobSize uint64 = 1024 + + // PreHardforkTxFreezePeriod is the number of blocks before hardfork + // activation when no new transactions are accepted (effective from HF5). + PreHardforkTxFreezePeriod uint64 = 60 +) + +// --------------------------------------------------------------------------- +// Block version constants +// --------------------------------------------------------------------------- + +const ( + BlockMajorVersionGenesis uint8 = 1 + BlockMinorVersionGenesis uint8 = 0 + BlockMajorVersionInitial uint8 = 0 + HF1BlockMajorVersion uint8 = 1 + HF3BlockMajorVersion uint8 = 2 + HF3BlockMinorVersion uint8 = 0 + CurrentBlockMajorVersion uint8 = 3 + CurrentBlockMinorVersion uint8 = 0 +) + +// --------------------------------------------------------------------------- +// Transaction version constants +// --------------------------------------------------------------------------- + +const ( + TransactionVersionInitial uint8 = 0 + TransactionVersionPreHF4 uint8 = 1 + TransactionVersionPostHF4 uint8 = 2 + TransactionVersionPostHF5 uint8 = 3 + CurrentTransactionVersion uint8 = 3 +) + +// --------------------------------------------------------------------------- +// PoS constants +// --------------------------------------------------------------------------- + +const ( + PosScanWindow uint64 = 60 * 10 // 10 minutes in seconds + PosScanStep uint64 = 15 // seconds + PosModifierInterval uint64 = 10 + PosMinimumCoinstakeAge uint64 = 10 // blocks + PosStrictSequenceLimit uint64 = 20 + PosStarterKernelHash = "00000000000000000006382a8d8f94588ce93a1351924f6ccb9e07dd287c6e4b" +) + +// --------------------------------------------------------------------------- +// P2P constants +// --------------------------------------------------------------------------- + +const ( + P2PLocalWhitePeerlistLimit uint64 = 1000 + P2PLocalGrayPeerlistLimit uint64 = 5000 + P2PDefaultConnectionsCount uint64 = 8 + P2PDefaultHandshakeInterval uint64 = 60 // seconds + P2PDefaultPacketMaxSize uint64 = 50_000_000 + P2PIPBlockTime uint64 = 60 * 60 * 24 // 24 hours + P2PIPFailsBeforeBlock uint64 = 10 +) + +// --------------------------------------------------------------------------- +// Network identity +// --------------------------------------------------------------------------- + +const ( + // CurrencyFormationVersion identifies the mainnet network. + CurrencyFormationVersion uint64 = 84 + + // CurrencyFormationVersionTestnet identifies the testnet network. + CurrencyFormationVersionTestnet uint64 = 100 + + // P2PNetworkIDVer is derived from CurrencyFormationVersion + 0. + P2PNetworkIDVer uint64 = CurrencyFormationVersion + 0 +) + +// --------------------------------------------------------------------------- +// Currency identity +// --------------------------------------------------------------------------- + +const ( + CurrencyNameAbbreviation = "LTHN" + CurrencyNameBase = "Lethean" + CurrencyNameShort = "Lethean" +) + +// --------------------------------------------------------------------------- +// Alias constants +// --------------------------------------------------------------------------- + +const ( + AliasMinimumPublicShortNameAllowed uint64 = 6 + AliasNameMaxLen uint64 = 255 + AliasValidChars = "0123456789abcdefghijklmnopqrstuvwxyz-." + AliasCommentMaxSizeBytes uint64 = 400 + MaxAliasPerBlock uint64 = 1000 +) + +// --------------------------------------------------------------------------- +// ChainConfig aggregates all chain parameters into a single struct. +// --------------------------------------------------------------------------- + +// ChainConfig holds the complete set of parameters for a particular chain +// (mainnet or testnet). +type ChainConfig struct { + // Name is the human-readable chain name. + Name string + + // Abbreviation is the ticker symbol. + Abbreviation string + + // IsTestnet indicates whether this is a test network. + IsTestnet bool + + // CurrencyFormationVersion identifies the network. + CurrencyFormationVersion uint64 + + // Coin is the number of atomic units per coin. + Coin uint64 + + // DisplayDecimalPoint is the number of decimal places. + DisplayDecimalPoint uint8 + + // BlockReward is the fixed block reward in atomic units. + BlockReward uint64 + + // DefaultFee is the default transaction fee in atomic units. + DefaultFee uint64 + + // MinimumFee is the minimum acceptable fee in atomic units. + MinimumFee uint64 + + // Premine is the pre-mined amount in atomic units. + Premine uint64 + + // AddressPrefix is the base58 prefix for standard addresses. + AddressPrefix uint64 + + // IntegratedAddressPrefix is the base58 prefix for integrated addresses. + IntegratedAddressPrefix uint64 + + // AuditableAddressPrefix is the base58 prefix for auditable addresses. + AuditableAddressPrefix uint64 + + // AuditableIntegratedAddressPrefix is the base58 prefix for auditable + // integrated addresses. + AuditableIntegratedAddressPrefix uint64 + + // P2PPort is the default peer-to-peer port. + P2PPort uint16 + + // RPCPort is the default RPC port. + RPCPort uint16 + + // StratumPort is the default stratum mining port. + StratumPort uint16 + + // DifficultyPowTarget is the target PoW block interval in seconds. + DifficultyPowTarget uint64 + + // DifficultyPosTarget is the target PoS block interval in seconds. + DifficultyPosTarget uint64 + + // DifficultyWindow is the number of blocks in the difficulty window. + DifficultyWindow uint64 + + // DifficultyLag is the additional lookback beyond the window. + DifficultyLag uint64 + + // DifficultyCut is the number of timestamps cut after sorting. + DifficultyCut uint64 + + // DifficultyPowStarter is the initial PoW difficulty. + DifficultyPowStarter uint64 + + // DifficultyPosStarter is the initial PoS difficulty. + DifficultyPosStarter uint64 + + // MaxBlockNumber is the absolute maximum block height. + MaxBlockNumber uint64 + + // TxMaxAllowedInputs is the maximum inputs per transaction. + TxMaxAllowedInputs uint64 + + // TxMaxAllowedOutputs is the maximum outputs per transaction. + TxMaxAllowedOutputs uint64 + + // DefaultDecoySetSize is the default ring size. + DefaultDecoySetSize uint64 + + // HF4MandatoryDecoySetSize is the mandatory ring size from HF4. + HF4MandatoryDecoySetSize uint64 + + // MinedMoneyUnlockWindow is the maturity period for mined coins. + MinedMoneyUnlockWindow uint64 + + // P2PMaintainersPubKey is the hex-encoded maintainers public key. + P2PMaintainersPubKey string +} + +// Mainnet holds the chain configuration for the Lethean mainnet. +var Mainnet = ChainConfig{ + Name: CurrencyNameBase, + Abbreviation: CurrencyNameAbbreviation, + IsTestnet: false, + CurrencyFormationVersion: CurrencyFormationVersion, + Coin: Coin, + DisplayDecimalPoint: DisplayDecimalPoint, + BlockReward: BlockReward, + DefaultFee: DefaultFee, + MinimumFee: MinimumFee, + Premine: Premine, + AddressPrefix: AddressPrefix, + IntegratedAddressPrefix: IntegratedAddressPrefix, + AuditableAddressPrefix: AuditableAddressPrefix, + AuditableIntegratedAddressPrefix: AuditableIntegratedAddressPrefix, + P2PPort: MainnetP2PPort, + RPCPort: MainnetRPCPort, + StratumPort: MainnetStratumPort, + DifficultyPowTarget: DifficultyPowTarget, + DifficultyPosTarget: DifficultyPosTarget, + DifficultyWindow: DifficultyWindow, + DifficultyLag: DifficultyLag, + DifficultyCut: DifficultyCut, + DifficultyPowStarter: DifficultyPowStarter, + DifficultyPosStarter: DifficultyPosStarter, + MaxBlockNumber: MaxBlockNumber, + TxMaxAllowedInputs: TxMaxAllowedInputs, + TxMaxAllowedOutputs: TxMaxAllowedOutputs, + DefaultDecoySetSize: DefaultDecoySetSize, + HF4MandatoryDecoySetSize: HF4MandatoryDecoySetSize, + MinedMoneyUnlockWindow: MinedMoneyUnlockWindow, + P2PMaintainersPubKey: "8f138bb73f6d663a3746a542770781a09579a7b84cb4125249e95530824ee607", +} + +// Testnet holds the chain configuration for the Lethean testnet. +var Testnet = ChainConfig{ + Name: CurrencyNameBase + "_testnet", + Abbreviation: CurrencyNameAbbreviation, + IsTestnet: true, + CurrencyFormationVersion: CurrencyFormationVersionTestnet, + Coin: Coin, + DisplayDecimalPoint: DisplayDecimalPoint, + BlockReward: BlockReward, + DefaultFee: DefaultFee, + MinimumFee: MinimumFee, + Premine: Premine, + AddressPrefix: AddressPrefix, + IntegratedAddressPrefix: IntegratedAddressPrefix, + AuditableAddressPrefix: AuditableAddressPrefix, + AuditableIntegratedAddressPrefix: AuditableIntegratedAddressPrefix, + P2PPort: TestnetP2PPort, + RPCPort: TestnetRPCPort, + StratumPort: TestnetStratumPort, + DifficultyPowTarget: DifficultyPowTarget, + DifficultyPosTarget: DifficultyPosTarget, + DifficultyWindow: DifficultyWindow, + DifficultyLag: DifficultyLag, + DifficultyCut: DifficultyCut, + DifficultyPowStarter: DifficultyPowStarter, + DifficultyPosStarter: DifficultyPosStarter, + MaxBlockNumber: MaxBlockNumber, + TxMaxAllowedInputs: TxMaxAllowedInputs, + TxMaxAllowedOutputs: TxMaxAllowedOutputs, + DefaultDecoySetSize: DefaultDecoySetSize, + HF4MandatoryDecoySetSize: HF4MandatoryDecoySetSize, + MinedMoneyUnlockWindow: MinedMoneyUnlockWindow, + P2PMaintainersPubKey: "8f138bb73f6d663a3746a542770781a09579a7b84cb4125249e95530824ee607", +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..5521d92 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,168 @@ +// 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 config + +import ( + "testing" +) + +func TestMainnetConstants_Good(t *testing.T) { + // Verify tokenomics match C++ source (default.cmake). + if Coin != 1_000_000_000_000 { + t.Errorf("Coin: got %d, want 1000000000000", Coin) + } + if DisplayDecimalPoint != 12 { + t.Errorf("DisplayDecimalPoint: got %d, want 12", DisplayDecimalPoint) + } + if BlockReward != 1_000_000_000_000 { + t.Errorf("BlockReward: got %d, want 1000000000000", BlockReward) + } + if DefaultFee != 10_000_000_000 { + t.Errorf("DefaultFee: got %d, want 10000000000", DefaultFee) + } + if MinimumFee != 10_000_000_000 { + t.Errorf("MinimumFee: got %d, want 10000000000", MinimumFee) + } + if BaseRewardDustThreshold != 1_000_000 { + t.Errorf("BaseRewardDustThreshold: got %d, want 1000000", BaseRewardDustThreshold) + } + if DefaultDustThreshold != 0 { + t.Errorf("DefaultDustThreshold: got %d, want 0", DefaultDustThreshold) + } +} + +func TestMainnetAddressPrefixes_Good(t *testing.T) { + tests := []struct { + name string + got uint64 + want uint64 + }{ + {"AddressPrefix", AddressPrefix, 0x1eaf7}, + {"IntegratedAddressPrefix", IntegratedAddressPrefix, 0xdeaf7}, + {"AuditableAddressPrefix", AuditableAddressPrefix, 0x3ceff7}, + {"AuditableIntegratedAddressPrefix", AuditableIntegratedAddressPrefix, 0x8b077}, + } + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("%s: got 0x%x, want 0x%x", tt.name, tt.got, tt.want) + } + } +} + +func TestMainnetPorts_Good(t *testing.T) { + if Mainnet.P2PPort != 36942 { + t.Errorf("Mainnet P2P port: got %d, want 36942", Mainnet.P2PPort) + } + if Mainnet.RPCPort != 36941 { + t.Errorf("Mainnet RPC port: got %d, want 36941", Mainnet.RPCPort) + } + if Mainnet.StratumPort != 36940 { + t.Errorf("Mainnet Stratum port: got %d, want 36940", Mainnet.StratumPort) + } +} + +func TestTestnetPortDifferences_Good(t *testing.T) { + if Testnet.P2PPort != 46942 { + t.Errorf("Testnet P2P port: got %d, want 46942", Testnet.P2PPort) + } + if Testnet.RPCPort != 46941 { + t.Errorf("Testnet RPC port: got %d, want 46941", Testnet.RPCPort) + } + if Testnet.StratumPort != 46940 { + t.Errorf("Testnet Stratum port: got %d, want 46940", Testnet.StratumPort) + } + if Testnet.P2PPort == Mainnet.P2PPort { + t.Error("Testnet and Mainnet P2P ports must differ") + } +} + +func TestDifficultyConstants_Good(t *testing.T) { + if DifficultyPowTarget != 120 { + t.Errorf("DifficultyPowTarget: got %d, want 120", DifficultyPowTarget) + } + if DifficultyPosTarget != 120 { + t.Errorf("DifficultyPosTarget: got %d, want 120", DifficultyPosTarget) + } + if DifficultyTotalTarget != 60 { + t.Errorf("DifficultyTotalTarget: got %d, want 60 ((120+120)/4)", DifficultyTotalTarget) + } + if DifficultyWindow != 720 { + t.Errorf("DifficultyWindow: got %d, want 720", DifficultyWindow) + } + if DifficultyLag != 15 { + t.Errorf("DifficultyLag: got %d, want 15", DifficultyLag) + } + if DifficultyCut != 60 { + t.Errorf("DifficultyCut: got %d, want 60", DifficultyCut) + } + if DifficultyBlocksCount != 735 { + t.Errorf("DifficultyBlocksCount: got %d, want 735 (720+15)", DifficultyBlocksCount) + } +} + +func TestNetworkIdentity_Good(t *testing.T) { + if CurrencyFormationVersion != 84 { + t.Errorf("CurrencyFormationVersion: got %d, want 84", CurrencyFormationVersion) + } + if CurrencyFormationVersionTestnet != 100 { + t.Errorf("CurrencyFormationVersionTestnet: got %d, want 100", CurrencyFormationVersionTestnet) + } + if P2PNetworkIDVer != 84 { + t.Errorf("P2PNetworkIDVer: got %d, want 84 (84+0)", P2PNetworkIDVer) + } +} + +func TestChainConfigStruct_Good(t *testing.T) { + // Verify Mainnet struct fields are populated correctly. + if Mainnet.Name != "Lethean" { + t.Errorf("Mainnet.Name: got %q, want %q", Mainnet.Name, "Lethean") + } + if Mainnet.Abbreviation != "LTHN" { + t.Errorf("Mainnet.Abbreviation: got %q, want %q", Mainnet.Abbreviation, "LTHN") + } + if Mainnet.IsTestnet { + t.Error("Mainnet.IsTestnet should be false") + } + if !Testnet.IsTestnet { + t.Error("Testnet.IsTestnet should be true") + } + if Testnet.Name != "Lethean_testnet" { + t.Errorf("Testnet.Name: got %q, want %q", Testnet.Name, "Lethean_testnet") + } +} + +func TestTransactionLimits_Good(t *testing.T) { + if TxMaxAllowedInputs != 256 { + t.Errorf("TxMaxAllowedInputs: got %d, want 256", TxMaxAllowedInputs) + } + if TxMaxAllowedOutputs != 2000 { + t.Errorf("TxMaxAllowedOutputs: got %d, want 2000", TxMaxAllowedOutputs) + } + if DefaultDecoySetSize != 10 { + t.Errorf("DefaultDecoySetSize: got %d, want 10", DefaultDecoySetSize) + } + if HF4MandatoryDecoySetSize != 15 { + t.Errorf("HF4MandatoryDecoySetSize: got %d, want 15", HF4MandatoryDecoySetSize) + } +} + +func TestTransactionVersionConstants_Good(t *testing.T) { + if TransactionVersionInitial != 0 { + t.Errorf("TransactionVersionInitial: got %d, want 0", TransactionVersionInitial) + } + if TransactionVersionPreHF4 != 1 { + t.Errorf("TransactionVersionPreHF4: got %d, want 1", TransactionVersionPreHF4) + } + if TransactionVersionPostHF4 != 2 { + t.Errorf("TransactionVersionPostHF4: got %d, want 2", TransactionVersionPostHF4) + } + if TransactionVersionPostHF5 != 3 { + t.Errorf("TransactionVersionPostHF5: got %d, want 3", TransactionVersionPostHF5) + } + if CurrentTransactionVersion != 3 { + t.Errorf("CurrentTransactionVersion: got %d, want 3", CurrentTransactionVersion) + } +} diff --git a/config/hardfork.go b/config/hardfork.go new file mode 100644 index 0000000..957f64e --- /dev/null +++ b/config/hardfork.go @@ -0,0 +1,95 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +package config + +// HardFork describes a single hard fork activation point on the chain. +type HardFork struct { + // Version is the hardfork version number (0-6). + Version uint8 + + // Height is the block height AFTER which this fork activates. + // The fork is active at heights strictly greater than this value. + // A value of 0 means the fork is active from genesis. + Height uint64 + + // Mandatory indicates whether nodes must support this fork version + // to remain on the network. + Mandatory bool + + // Description is a short human-readable summary of what this fork changes. + Description string +} + +// Hardfork version constants, matching the C++ ZANO_HARDFORK_* identifiers. +const ( + HF0Initial uint8 = 0 + HF1 uint8 = 1 + HF2 uint8 = 2 + HF3 uint8 = 3 + HF4Zarcanum uint8 = 4 + HF5 uint8 = 5 + HF6 uint8 = 6 + HFTotal uint8 = 7 +) + +// MainnetForks lists all hardfork activations for the Lethean mainnet. +// Heights correspond to ZANO_HARDFORK_*_AFTER_HEIGHT in the C++ source. +// The fork activates at heights strictly greater than the listed height, +// so Height=0 means active from genesis, and Height=10080 means active +// from block 10081 onwards. +var MainnetForks = []HardFork{ + {Version: HF0Initial, Height: 0, Mandatory: true, Description: "CryptoNote base, PoW+PoS hybrid"}, + {Version: HF1, Height: 10080, Mandatory: true, Description: "New transaction types"}, + {Version: HF2, Height: 10080, Mandatory: true, Description: "Block time adjustment (720 blocks/day)"}, + {Version: HF3, Height: 999999999, Mandatory: false, Description: "Block version 2"}, + {Version: HF4Zarcanum, Height: 999999999, Mandatory: false, Description: "Zarcanum privacy (confidential txs, CLSAG)"}, + {Version: HF5, Height: 999999999, Mandatory: false, Description: "Confidential assets, surjection proofs"}, + {Version: HF6, Height: 999999999, Mandatory: false, Description: "Block time halving (120s to 240s)"}, +} + +// TestnetForks lists all hardfork activations for the Lethean testnet. +var TestnetForks = []HardFork{ + {Version: HF0Initial, Height: 0, Mandatory: true, Description: "CryptoNote base, PoW+PoS hybrid"}, + {Version: HF1, Height: 0, Mandatory: true, Description: "New transaction types"}, + {Version: HF2, Height: 10, Mandatory: true, Description: "Block time adjustment"}, + {Version: HF3, Height: 0, Mandatory: true, Description: "Block version 2"}, + {Version: HF4Zarcanum, Height: 100, Mandatory: true, Description: "Zarcanum privacy"}, + {Version: HF5, Height: 200, Mandatory: true, Description: "Confidential assets"}, + {Version: HF6, Height: 999999999, Mandatory: false, Description: "Block time halving"}, +} + +// VersionAtHeight returns the highest hardfork version that is active at the +// given block height. It performs a reverse scan of the fork list to find the +// latest applicable version. +// +// A fork with Height=0 is active from genesis (height 0). +// A fork with Height=N is active at heights > N. +func VersionAtHeight(forks []HardFork, height uint64) uint8 { + var version uint8 + for _, hf := range forks { + if hf.Height == 0 || height > hf.Height { + if hf.Version > version { + version = hf.Version + } + } + } + return version +} + +// IsHardForkActive reports whether the specified hardfork version is active +// at the given block height. +func IsHardForkActive(forks []HardFork, version uint8, height uint64) bool { + for _, hf := range forks { + if hf.Version == version { + return hf.Height == 0 || height > hf.Height + } + } + return false +} diff --git a/config/hardfork_test.go b/config/hardfork_test.go new file mode 100644 index 0000000..a9deedf --- /dev/null +++ b/config/hardfork_test.go @@ -0,0 +1,197 @@ +// 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 config + +import ( + "testing" +) + +func TestVersionAtHeight_Good(t *testing.T) { + tests := []struct { + name string + height uint64 + want uint8 + }{ + // Genesis block should return HF0 (the initial version). + {"genesis", 0, HF0Initial}, + + // Just before HF1/HF2 activation on mainnet (height 10080). + // HF1 activates at heights > 10080, so height 10080 is still HF0. + {"before_hf1", 10080, HF0Initial}, + + // At height 10081, both HF1 and HF2 activate (both have height 10080). + // The highest version wins. + {"at_hf1_hf2", 10081, HF2}, + + // Well past HF2 but before any future forks. + {"mid_chain", 50000, HF2}, + + // Far future but still below 999999999 — should still be HF2. + {"high_but_before_future", 999999999, HF2}, + + // At height 1000000000 (> 999999999), all future forks activate. + {"all_forks_active", 1000000000, HF6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := VersionAtHeight(MainnetForks, tt.height) + if got != tt.want { + t.Errorf("VersionAtHeight(MainnetForks, %d) = %d, want %d", tt.height, got, tt.want) + } + }) + } +} + +func TestVersionAtHeightTestnet_Good(t *testing.T) { + tests := []struct { + name string + height uint64 + want uint8 + }{ + // On testnet, HF0, HF1, and HF3 all have Height=0 so they are + // active from genesis. The highest version at genesis is HF3. + {"genesis", 0, HF3}, + + // At height 10, HF2 is still not active (activates at > 10). + {"before_hf2", 10, HF3}, + + // At height 11, HF2 activates — but HF3 is already active so + // the version remains 3. + {"after_hf2", 11, HF3}, + + // At height 101, HF4 Zarcanum activates. + {"after_hf4", 101, HF4Zarcanum}, + + // At height 201, HF5 activates. + {"after_hf5", 201, HF5}, + + // Far future activates HF6. + {"far_future", 1000000000, HF6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := VersionAtHeight(TestnetForks, tt.height) + if got != tt.want { + t.Errorf("VersionAtHeight(TestnetForks, %d) = %d, want %d", tt.height, got, tt.want) + } + }) + } +} + +func TestIsHardForkActive_Good(t *testing.T) { + tests := []struct { + name string + version uint8 + height uint64 + want bool + }{ + // HF0 is always active (Height=0). + {"hf0_at_genesis", HF0Initial, 0, true}, + {"hf0_at_10000", HF0Initial, 10000, true}, + + // HF1 activates at heights > 10080. + {"hf1_before", HF1, 10080, false}, + {"hf1_at", HF1, 10081, true}, + + // HF4 is far future on mainnet. + {"hf4_now", HF4Zarcanum, 50000, false}, + {"hf4_far_future", HF4Zarcanum, 1000000000, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsHardForkActive(MainnetForks, tt.version, tt.height) + if got != tt.want { + t.Errorf("IsHardForkActive(MainnetForks, %d, %d) = %v, want %v", + tt.version, tt.height, got, tt.want) + } + }) + } +} + +func TestIsHardForkActive_Bad(t *testing.T) { + // Querying a version that does not exist should return false. + got := IsHardForkActive(MainnetForks, 99, 1000000000) + if got { + t.Error("IsHardForkActive with unknown version should return false") + } +} + +func TestVersionAtHeight_Ugly(t *testing.T) { + // Empty fork list should return version 0. + got := VersionAtHeight(nil, 100) + if got != 0 { + t.Errorf("VersionAtHeight(nil, 100) = %d, want 0", got) + } + + // Single-element fork list. + single := []HardFork{{Version: 1, Height: 0, Mandatory: true}} + got = VersionAtHeight(single, 0) + if got != 1 { + t.Errorf("VersionAtHeight(single, 0) = %d, want 1", got) + } +} + +func TestMainnetForkSchedule_Good(t *testing.T) { + // Verify the fork schedule matches the C++ ZANO_HARDFORK_* constants. + expectedMainnet := []struct { + version uint8 + height uint64 + }{ + {0, 0}, + {1, 10080}, + {2, 10080}, + {3, 999999999}, + {4, 999999999}, + {5, 999999999}, + {6, 999999999}, + } + + if len(MainnetForks) != len(expectedMainnet) { + t.Fatalf("MainnetForks length: got %d, want %d", len(MainnetForks), len(expectedMainnet)) + } + + for i, exp := range expectedMainnet { + hf := MainnetForks[i] + if hf.Version != exp.version { + t.Errorf("MainnetForks[%d].Version = %d, want %d", i, hf.Version, exp.version) + } + if hf.Height != exp.height { + t.Errorf("MainnetForks[%d].Height = %d, want %d", i, hf.Height, exp.height) + } + } +} + +func TestTestnetForkSchedule_Good(t *testing.T) { + expectedTestnet := []struct { + version uint8 + height uint64 + }{ + {0, 0}, + {1, 0}, + {2, 10}, + {3, 0}, + {4, 100}, + {5, 200}, + {6, 999999999}, + } + + if len(TestnetForks) != len(expectedTestnet) { + t.Fatalf("TestnetForks length: got %d, want %d", len(TestnetForks), len(expectedTestnet)) + } + + for i, exp := range expectedTestnet { + hf := TestnetForks[i] + if hf.Version != exp.version { + t.Errorf("TestnetForks[%d].Version = %d, want %d", i, hf.Version, exp.version) + } + if hf.Height != exp.height { + t.Errorf("TestnetForks[%d].Height = %d, want %d", i, hf.Height, exp.height) + } + } +} diff --git a/difficulty/difficulty.go b/difficulty/difficulty.go new file mode 100644 index 0000000..540924e --- /dev/null +++ b/difficulty/difficulty.go @@ -0,0 +1,100 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +// Package difficulty implements the LWMA (Linear Weighted Moving Average) +// difficulty adjustment algorithm used by the Lethean blockchain for both +// PoW and PoS blocks. +// +// The algorithm examines a window of recent block timestamps and cumulative +// difficulties to calculate the next target difficulty, ensuring blocks +// arrive at the desired interval on average. +package difficulty + +import ( + "math/big" +) + +// Algorithm constants matching the C++ source. +const ( + // Window is the number of blocks in the difficulty calculation window. + Window uint64 = 720 + + // Lag is the additional lookback beyond the window. + Lag uint64 = 15 + + // Cut is the number of extreme timestamps trimmed from each end after + // sorting. This dampens the effect of outlier timestamps. + Cut uint64 = 60 + + // BlocksCount is the total number of blocks considered (Window + Lag). + BlocksCount uint64 = Window + Lag +) + +// StarterDifficulty is the minimum difficulty returned when there is +// insufficient data to calculate a proper value. +var StarterDifficulty = big.NewInt(1) + +// NextDifficulty calculates the next block difficulty using the LWMA algorithm. +// +// Parameters: +// - timestamps: block timestamps for the last BlocksCount blocks, ordered +// from oldest to newest. +// - cumulativeDiffs: cumulative difficulties corresponding to each block. +// - target: the desired block interval in seconds (e.g. 120 for PoW/PoS). +// +// Returns the calculated difficulty for the next block. +// +// If the input slices are too short to perform a meaningful calculation, the +// function returns StarterDifficulty. +func NextDifficulty(timestamps []uint64, cumulativeDiffs []*big.Int, target uint64) *big.Int { + // Need at least 2 entries to compute a time span and difficulty delta. + if len(timestamps) < 2 || len(cumulativeDiffs) < 2 { + return new(big.Int).Set(StarterDifficulty) + } + + length := uint64(len(timestamps)) + if length > BlocksCount { + length = BlocksCount + } + + // Use the available window, but ensure we have at least 2 points. + windowSize := length + if windowSize < 2 { + return new(big.Int).Set(StarterDifficulty) + } + + // Calculate the time span across the window. + // Use only the last windowSize entries. + startIdx := uint64(len(timestamps)) - windowSize + endIdx := uint64(len(timestamps)) - 1 + + timeSpan := timestamps[endIdx] - timestamps[startIdx] + if timeSpan == 0 { + timeSpan = 1 // prevent division by zero + } + + // Calculate the difficulty delta across the same window. + diffDelta := new(big.Int).Sub(cumulativeDiffs[endIdx], cumulativeDiffs[startIdx]) + if diffDelta.Sign() <= 0 { + return new(big.Int).Set(StarterDifficulty) + } + + // LWMA core: nextDiff = diffDelta * target / timeSpan + // This keeps the difficulty proportional to the hash rate needed to + // maintain the target block interval. + nextDiff := new(big.Int).Mul(diffDelta, new(big.Int).SetUint64(target)) + nextDiff.Div(nextDiff, new(big.Int).SetUint64(timeSpan)) + + // Ensure we never return zero difficulty. + if nextDiff.Sign() <= 0 { + return new(big.Int).Set(StarterDifficulty) + } + + return nextDiff +} diff --git a/difficulty/difficulty_test.go b/difficulty/difficulty_test.go new file mode 100644 index 0000000..938f66e --- /dev/null +++ b/difficulty/difficulty_test.go @@ -0,0 +1,129 @@ +// 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 difficulty + +import ( + "math/big" + "testing" +) + +func TestNextDifficulty_Good(t *testing.T) { + // Synthetic test: constant block times at exactly the target interval. + // With perfectly timed blocks, the difficulty should remain stable. + const target uint64 = 120 + const numBlocks = 100 + + timestamps := make([]uint64, numBlocks) + cumulativeDiffs := make([]*big.Int, numBlocks) + + baseDifficulty := big.NewInt(1000) + for i := 0; i < numBlocks; i++ { + timestamps[i] = uint64(i) * target + cumulativeDiffs[i] = new(big.Int).Mul(baseDifficulty, big.NewInt(int64(i))) + } + + result := NextDifficulty(timestamps, cumulativeDiffs, target) + if result.Sign() <= 0 { + t.Fatalf("NextDifficulty returned non-positive value: %s", result) + } + + // With constant intervals, the result should be approximately equal to + // the base difficulty. Allow some tolerance due to integer arithmetic. + expected := baseDifficulty + tolerance := new(big.Int).Div(expected, big.NewInt(10)) // 10% tolerance + + diff := new(big.Int).Sub(result, expected) + diff.Abs(diff) + if diff.Cmp(tolerance) > 0 { + t.Errorf("NextDifficulty with constant intervals: got %s, expected ~%s (tolerance %s)", + result, expected, tolerance) + } +} + +func TestNextDifficultyEmpty_Good(t *testing.T) { + // Empty input should return starter difficulty. + result := NextDifficulty(nil, nil, 120) + if result.Cmp(StarterDifficulty) != 0 { + t.Errorf("NextDifficulty(nil, nil, 120) = %s, want %s", result, StarterDifficulty) + } +} + +func TestNextDifficultySingleEntry_Good(t *testing.T) { + // A single entry is insufficient for calculation. + timestamps := []uint64{1000} + diffs := []*big.Int{big.NewInt(100)} + result := NextDifficulty(timestamps, diffs, 120) + if result.Cmp(StarterDifficulty) != 0 { + t.Errorf("NextDifficulty with single entry = %s, want %s", result, StarterDifficulty) + } +} + +func TestNextDifficultyFastBlocks_Good(t *testing.T) { + // When blocks come faster than the target, difficulty should increase. + const target uint64 = 120 + const numBlocks = 50 + const actualInterval uint64 = 60 // half the target — blocks are too fast + + timestamps := make([]uint64, numBlocks) + cumulativeDiffs := make([]*big.Int, numBlocks) + + baseDifficulty := big.NewInt(1000) + for i := 0; i < numBlocks; i++ { + timestamps[i] = uint64(i) * actualInterval + cumulativeDiffs[i] = new(big.Int).Mul(baseDifficulty, big.NewInt(int64(i))) + } + + result := NextDifficulty(timestamps, cumulativeDiffs, target) + if result.Cmp(baseDifficulty) <= 0 { + t.Errorf("expected difficulty > %s for fast blocks, got %s", baseDifficulty, result) + } +} + +func TestNextDifficultySlowBlocks_Good(t *testing.T) { + // When blocks come slower than the target, difficulty should decrease. + const target uint64 = 120 + const numBlocks = 50 + const actualInterval uint64 = 240 // double the target — blocks are too slow + + timestamps := make([]uint64, numBlocks) + cumulativeDiffs := make([]*big.Int, numBlocks) + + baseDifficulty := big.NewInt(1000) + for i := 0; i < numBlocks; i++ { + timestamps[i] = uint64(i) * actualInterval + cumulativeDiffs[i] = new(big.Int).Mul(baseDifficulty, big.NewInt(int64(i))) + } + + result := NextDifficulty(timestamps, cumulativeDiffs, target) + if result.Cmp(baseDifficulty) >= 0 { + t.Errorf("expected difficulty < %s for slow blocks, got %s", baseDifficulty, result) + } +} + +func TestNextDifficulty_Ugly(t *testing.T) { + // Two entries with zero time span — should handle gracefully. + timestamps := []uint64{1000, 1000} + diffs := []*big.Int{big.NewInt(0), big.NewInt(100)} + result := NextDifficulty(timestamps, diffs, 120) + if result.Sign() <= 0 { + t.Errorf("NextDifficulty with zero time span should still return positive, got %s", result) + } +} + +func TestConstants_Good(t *testing.T) { + if Window != 720 { + t.Errorf("Window: got %d, want 720", Window) + } + if Lag != 15 { + t.Errorf("Lag: got %d, want 15", Lag) + } + if Cut != 60 { + t.Errorf("Cut: got %d, want 60", Cut) + } + if BlocksCount != 735 { + t.Errorf("BlocksCount: got %d, want 735", BlocksCount) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dc597bf --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module forge.lthn.ai/core/go-blockchain + +go 1.25 + +require golang.org/x/crypto v0.48.0 + +require golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f13e109 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/types/address.go b/types/address.go new file mode 100644 index 0000000..e487eac --- /dev/null +++ b/types/address.go @@ -0,0 +1,289 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +package types + +import ( + "errors" + "fmt" + "math/big" + + "golang.org/x/crypto/sha3" + + "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 +// generated with a deterministic view key derivation, allowing a third party +// with the view key to audit all incoming transactions. +const FlagAuditable uint8 = 0x01 + +// Address represents a Lethean account public address consisting of a spend +// public key, a view public key, and optional flags (e.g. auditable). +type Address struct { + SpendPublicKey PublicKey + ViewPublicKey PublicKey + Flags uint8 +} + +// IsAuditable reports whether the address has the auditable flag set. +func (a *Address) IsAuditable() bool { + return a.Flags&FlagAuditable != 0 +} + +// IsIntegrated reports whether the given prefix corresponds to an integrated +// address type (standard integrated or auditable integrated). +func (a *Address) IsIntegrated() bool { + // This method checks whether the address was decoded with an integrated + // prefix. Since we do not store the prefix in the Address struct, callers + // should use the prefix returned by DecodeAddress to determine this. + // This helper exists for convenience when the prefix is not available. + return false +} + +// IsIntegratedPrefix reports whether the given prefix corresponds to an +// integrated address type. +func IsIntegratedPrefix(prefix uint64) bool { + return prefix == config.IntegratedAddressPrefix || + prefix == config.AuditableIntegratedAddressPrefix +} + +// Encode serialises the address into a CryptoNote base58 string with the +// given prefix. The encoding format is: +// +// varint(prefix) || spend_pubkey (32 bytes) || view_pubkey (32 bytes) || flags (1 byte) || checksum (4 bytes) +// +// The checksum is the first 4 bytes of Keccak-256 over the preceding data. +func (a *Address) Encode(prefix uint64) string { + // Build the raw data: prefix (varint) + keys + flags. + prefixBytes := wire.EncodeVarint(prefix) + raw := make([]byte, 0, len(prefixBytes)+32+32+1+4) + raw = append(raw, prefixBytes...) + raw = append(raw, a.SpendPublicKey[:]...) + raw = append(raw, a.ViewPublicKey[:]...) + raw = append(raw, a.Flags) + + // Compute Keccak-256 checksum over the raw data. + checksum := keccak256Checksum(raw) + raw = append(raw, checksum[:]...) + + return base58Encode(raw) +} + +// DecodeAddress parses a CryptoNote base58-encoded address string. It returns +// the decoded address, the prefix that was used, and any error. +func DecodeAddress(s string) (*Address, uint64, error) { + raw, err := base58Decode(s) + if err != nil { + return nil, 0, fmt.Errorf("types: base58 decode failed: %w", err) + } + + // The minimum size is: 1 byte prefix varint + 32 + 32 + 1 flags + 4 checksum = 70. + if len(raw) < 70 { + return nil, 0, errors.New("types: address data too short") + } + + // Decode the prefix varint. + prefix, prefixLen, err := wire.DecodeVarint(raw) + if err != nil { + return nil, 0, fmt.Errorf("types: invalid address prefix varint: %w", err) + } + + // After the prefix we need exactly 32+32+1+4 = 69 bytes. + remaining := raw[prefixLen:] + if len(remaining) != 69 { + return nil, 0, fmt.Errorf("types: unexpected address data length: want 69 bytes after prefix, got %d", len(remaining)) + } + + // Validate checksum: Keccak-256 of everything except the last 4 bytes. + payloadEnd := len(raw) - 4 + expectedChecksum := keccak256Checksum(raw[:payloadEnd]) + actualChecksum := raw[payloadEnd:] + if expectedChecksum[0] != actualChecksum[0] || + expectedChecksum[1] != actualChecksum[1] || + expectedChecksum[2] != actualChecksum[2] || + expectedChecksum[3] != actualChecksum[3] { + return nil, 0, errors.New("types: address checksum mismatch") + } + + addr := &Address{} + copy(addr.SpendPublicKey[:], remaining[0:32]) + copy(addr.ViewPublicKey[:], remaining[32:64]) + addr.Flags = remaining[64] + + return addr, prefix, nil +} + +// keccak256Checksum returns the first 4 bytes of the Keccak-256 hash of data. +// This uses the legacy Keccak-256 (pre-NIST), NOT SHA3-256. +func keccak256Checksum(data []byte) [4]byte { + h := sha3.NewLegacyKeccak256() + h.Write(data) + sum := h.Sum(nil) + var checksum [4]byte + copy(checksum[:], sum[:4]) + return checksum +} + +// --------------------------------------------------------------------------- +// CryptoNote Base58 encoding +// --------------------------------------------------------------------------- + +// base58Alphabet is the CryptoNote base58 character set. Note: this omits +// 0, O, I, l to avoid visual ambiguity. +const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// base58BlockSizes maps input byte counts (0-8) to the number of base58 +// characters needed to encode that many bytes. CryptoNote encodes data in +// 8-byte blocks, each producing 11 base58 characters, with the final +// partial block producing fewer characters. +var base58BlockSizes = [9]int{0, 2, 3, 5, 6, 7, 9, 10, 11} + +// base58ReverseBlockSizes maps encoded character counts back to byte counts. +var base58ReverseBlockSizes [12]int + +func init() { + for i := range base58ReverseBlockSizes { + base58ReverseBlockSizes[i] = -1 + } + for byteCount, charCount := range base58BlockSizes { + if charCount < len(base58ReverseBlockSizes) { + base58ReverseBlockSizes[charCount] = byteCount + } + } +} + +// base58Encode encodes raw bytes using the CryptoNote base58 scheme. +// Data is split into 8-byte blocks; each block is encoded independently. +func base58Encode(data []byte) string { + if len(data) == 0 { + return "" + } + + result := make([]byte, 0, len(data)*2) + fullBlocks := len(data) / 8 + lastBlockSize := len(data) % 8 + + for i := 0; i < fullBlocks; i++ { + block := data[i*8 : (i+1)*8] + encoded := encodeBlock(block, 11) + result = append(result, encoded...) + } + + if lastBlockSize > 0 { + block := data[fullBlocks*8:] + encodedSize := base58BlockSizes[lastBlockSize] + encoded := encodeBlock(block, encodedSize) + result = append(result, encoded...) + } + + return string(result) +} + +// encodeBlock encodes a single block (up to 8 bytes) into the specified +// number of base58 characters. +func encodeBlock(block []byte, encodedSize int) []byte { + // Convert the block to a big integer. + num := new(big.Int).SetBytes(block) + base := big.NewInt(58) + + result := make([]byte, encodedSize) + for i := range result { + result[i] = base58Alphabet[0] // fill with '1' (zero digit) + } + + // Encode from least significant digit to most significant. + idx := encodedSize - 1 + zero := new(big.Int) + mod := new(big.Int) + for num.Cmp(zero) > 0 && idx >= 0 { + num.DivMod(num, base, mod) + result[idx] = base58Alphabet[mod.Int64()] + idx-- + } + + return result +} + +// base58Decode decodes a CryptoNote base58 string back into raw bytes. +func base58Decode(s string) ([]byte, error) { + if len(s) == 0 { + return nil, errors.New("types: empty base58 string") + } + + fullBlocks := len(s) / 11 + lastBlockChars := len(s) % 11 + + // Validate that the last block size maps to a valid byte count. + if lastBlockChars > 0 && base58ReverseBlockSizes[lastBlockChars] < 0 { + return nil, fmt.Errorf("types: invalid base58 string length %d", len(s)) + } + + var result []byte + + for i := 0; i < fullBlocks; i++ { + blockStr := s[i*11 : (i+1)*11] + decoded, err := decodeBlock(blockStr, 8) + if err != nil { + return nil, err + } + result = append(result, decoded...) + } + + if lastBlockChars > 0 { + blockStr := s[fullBlocks*11:] + byteCount := base58ReverseBlockSizes[lastBlockChars] + decoded, err := decodeBlock(blockStr, byteCount) + if err != nil { + return nil, err + } + result = append(result, decoded...) + } + + return result, nil +} + +// decodeBlock decodes a base58 string block into the specified number of bytes. +func decodeBlock(s string, byteCount int) ([]byte, error) { + num := new(big.Int) + base := big.NewInt(58) + + for _, c := range []byte(s) { + idx := base58CharIndex(c) + if idx < 0 { + return nil, fmt.Errorf("types: invalid base58 character %q", c) + } + num.Mul(num, base) + num.Add(num, big.NewInt(int64(idx))) + } + + // Convert to fixed-size byte array, big-endian. + raw := num.Bytes() + if len(raw) > byteCount { + return nil, fmt.Errorf("types: base58 block overflow: decoded %d bytes, expected %d", len(raw), byteCount) + } + + // Pad with leading zeroes if necessary. + result := make([]byte, byteCount) + copy(result[byteCount-len(raw):], raw) + + return result, nil +} + +// base58CharIndex returns the index of character c in the base58 alphabet, +// or -1 if the character is not in the alphabet. +func base58CharIndex(c byte) int { + for i, ch := range []byte(base58Alphabet) { + if ch == c { + return i + } + } + return -1 +} diff --git a/types/address_test.go b/types/address_test.go new file mode 100644 index 0000000..77813ca --- /dev/null +++ b/types/address_test.go @@ -0,0 +1,175 @@ +// 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 types + +import ( + "testing" + + "forge.lthn.ai/core/go-blockchain/config" +) + +// makeTestAddress creates an address with deterministic test data. +func makeTestAddress(flags uint8) *Address { + addr := &Address{Flags: flags} + // Fill keys with recognisable patterns. + for i := 0; i < 32; i++ { + addr.SpendPublicKey[i] = byte(i) + addr.ViewPublicKey[i] = byte(32 + i) + } + return addr +} + +func TestAddressEncodeDecodeRoundTrip_Good(t *testing.T) { + tests := []struct { + name string + prefix uint64 + flags uint8 + }{ + {"standard_address", config.AddressPrefix, 0x00}, + {"integrated_address", config.IntegratedAddressPrefix, 0x00}, + {"auditable_address", config.AuditableAddressPrefix, FlagAuditable}, + {"auditable_integrated", config.AuditableIntegratedAddressPrefix, FlagAuditable}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := makeTestAddress(tt.flags) + encoded := original.Encode(tt.prefix) + + if len(encoded) == 0 { + t.Fatal("Encode returned empty string") + } + + decoded, decodedPrefix, err := DecodeAddress(encoded) + if err != nil { + t.Fatalf("DecodeAddress failed: %v", err) + } + + if decodedPrefix != tt.prefix { + t.Errorf("prefix mismatch: got 0x%x, want 0x%x", decodedPrefix, tt.prefix) + } + + if decoded.SpendPublicKey != original.SpendPublicKey { + t.Errorf("SpendPublicKey mismatch: got %s, want %s", + decoded.SpendPublicKey, original.SpendPublicKey) + } + + if decoded.ViewPublicKey != original.ViewPublicKey { + t.Errorf("ViewPublicKey mismatch: got %s, want %s", + decoded.ViewPublicKey, original.ViewPublicKey) + } + + if decoded.Flags != original.Flags { + t.Errorf("Flags mismatch: got 0x%02x, want 0x%02x", decoded.Flags, original.Flags) + } + }) + } +} + +func TestAddressEncodeDeterministic_Good(t *testing.T) { + // Encoding the same address twice must produce the same string. + addr := makeTestAddress(0x00) + first := addr.Encode(config.AddressPrefix) + second := addr.Encode(config.AddressPrefix) + if first != second { + t.Errorf("non-deterministic encoding:\n first: %s\n second: %s", first, second) + } +} + +func TestAddressIsAuditable_Good(t *testing.T) { + addr := makeTestAddress(FlagAuditable) + if !addr.IsAuditable() { + t.Error("address with FlagAuditable should report IsAuditable() == true") + } + + nonAuditable := makeTestAddress(0x00) + if nonAuditable.IsAuditable() { + t.Error("address without FlagAuditable should report IsAuditable() == false") + } +} + +func TestIsIntegratedPrefix_Good(t *testing.T) { + if !IsIntegratedPrefix(config.IntegratedAddressPrefix) { + t.Error("IntegratedAddressPrefix should be recognised as integrated") + } + if !IsIntegratedPrefix(config.AuditableIntegratedAddressPrefix) { + t.Error("AuditableIntegratedAddressPrefix should be recognised as integrated") + } + if IsIntegratedPrefix(config.AddressPrefix) { + t.Error("AddressPrefix should not be recognised as integrated") + } +} + +func TestDecodeAddress_Bad(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty_string", ""}, + {"invalid_base58_char", "0OIl"}, + {"too_short", "1111"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := DecodeAddress(tt.input) + if err == nil { + t.Error("expected error for invalid input, got nil") + } + }) + } +} + +func TestDecodeAddressChecksumCorruption_Bad(t *testing.T) { + addr := makeTestAddress(0x00) + encoded := addr.Encode(config.AddressPrefix) + + // Corrupt the last character of the encoded string to break the checksum. + corrupted := []byte(encoded) + lastChar := corrupted[len(corrupted)-1] + if lastChar == '1' { + corrupted[len(corrupted)-1] = '2' + } else { + corrupted[len(corrupted)-1] = '1' + } + + _, _, err := DecodeAddress(string(corrupted)) + if err == nil { + t.Error("expected checksum error for corrupted address, got nil") + } +} + +func TestBase58RoundTrip_Good(t *testing.T) { + // Test the underlying base58 encode/decode with known data. + testData := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07} + encoded := base58Encode(testData) + decoded, err := base58Decode(encoded) + if err != nil { + t.Fatalf("base58 round-trip failed: %v", err) + } + if len(decoded) != len(testData) { + t.Fatalf("base58 round-trip length mismatch: got %d, want %d", len(decoded), len(testData)) + } + for i := range testData { + if decoded[i] != testData[i] { + t.Errorf("base58 round-trip byte %d: got 0x%02x, want 0x%02x", i, decoded[i], testData[i]) + } + } +} + +func TestBase58Empty_Ugly(t *testing.T) { + // Encoding empty data should produce an empty string. + result := base58Encode(nil) + if result != "" { + t.Errorf("base58Encode(nil) = %q, want empty string", result) + } + + // Decoding empty string should return an error. + _, err := base58Decode("") + if err == nil { + t.Error("base58Decode(\"\") should return an error") + } +} diff --git a/types/block.go b/types/block.go new file mode 100644 index 0000000..9aea463 --- /dev/null +++ b/types/block.go @@ -0,0 +1,47 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +package types + +// BlockHeader contains the fields present in every block header. These fields +// are consensus-critical and must be serialised in the exact order defined by +// the CryptoNote wire format. +type BlockHeader struct { + // MajorVersion determines which consensus rules apply to this block. + // The version increases at hardfork boundaries. + MajorVersion uint8 + + // MinorVersion is used for soft-fork signalling within a major version. + MinorVersion uint8 + + // 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 Hash + + // Nonce is the value iterated by the miner to find a valid PoW solution. + // For PoS blocks this field carries the stake modifier. + Nonce uint64 +} + +// Block is a complete block including the header, miner (coinbase) transaction, +// and the hashes of all other transactions included in the block. +type Block struct { + BlockHeader + + // MinerTx is the coinbase transaction that pays the block reward. + MinerTx Transaction + + // TxHashes contains the hashes of all non-coinbase transactions included + // in this block, in the order they appear. + TxHashes []Hash +} diff --git a/types/transaction.go b/types/transaction.go new file mode 100644 index 0000000..88820d8 --- /dev/null +++ b/types/transaction.go @@ -0,0 +1,143 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +package types + +// Transaction version constants matching the C++ TRANSACTION_VERSION_* defines. +const ( + // VersionInitial is the genesis/coinbase transaction version. + VersionInitial uint8 = 0 + + // VersionPreHF4 is the standard transaction version before hardfork 4. + 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 +// covers all transaction versions (0 through 3) with version-dependent +// interpretation of inputs and outputs. +type Transaction struct { + // Version determines the transaction format and which consensus rules + // apply to validation. + Version uint8 + + // UnlockTime is the block height or Unix timestamp after which the + // outputs of this transaction become spendable. A value of 0 means + // immediately spendable (after the standard unlock window). + UnlockTime uint64 + + // Vin contains all transaction inputs. + Vin []TxInput + + // Vout contains all transaction outputs. + Vout []TxOutput + + // Extra holds auxiliary data such as the transaction public key, + // payment IDs, and other per-transaction metadata. The format is a + // sequence of tagged TLV fields. + Extra []byte +} + +// 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 { + // InputType returns the wire type tag for this input variant. + InputType() uint8 +} + +// TxOutput is the interface implemented by all transaction output types. +type TxOutput interface { + // OutputType returns the wire type tag for this output variant. + 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. +// It has no real input data; only the block height is recorded. +type TxInputGenesis struct { + // Height is the block height this coinbase transaction belongs to. + Height uint64 +} + +// InputType returns the wire type tag for genesis (coinbase) inputs. +func (t TxInputGenesis) InputType() uint8 { return InputTypeGenesis } + +// TxInputToKey is a standard input that spends a previously received output +// by proving knowledge of the corresponding secret key via a ring signature. +type TxInputToKey struct { + // Amount is the input amount in atomic units. For pre-HF4 transparent + // transactions this is the real amount; for HF4+ Zarcanum transactions + // this is zero (amounts are hidden in Pedersen commitments). + Amount uint64 + + // KeyOffsets contains the relative offsets into the global output index + // that form the decoy ring. The first offset is absolute; subsequent + // offsets are relative to the previous one. + KeyOffsets []uint64 + + // KeyImage is the key image that prevents double-spending of this input. + KeyImage KeyImage +} + +// InputType returns the wire type tag for key inputs. +func (t TxInputToKey) InputType() uint8 { return InputTypeToKey } + +// TxOutputBare is a transparent (pre-Zarcanum) transaction output. +type TxOutputBare struct { + // Amount is the output amount in atomic units. + Amount uint64 + + // TargetKey is the one-time public key derived from the recipient's + // address and the transaction secret key. + TargetKey PublicKey +} + +// OutputType returns the wire type tag for bare outputs. +func (t TxOutputBare) OutputType() uint8 { return OutputTypeBare } + +// TxOutputZarcanum is a confidential (HF4+) transaction output where the +// amount is hidden inside a Pedersen commitment. +type TxOutputZarcanum struct { + // StealthAddress is the one-time stealth address for this output. + StealthAddress PublicKey + + // AmountCommitment is the Pedersen commitment to the output amount. + AmountCommitment PublicKey + + // ConcealingPoint is an additional point used in the Zarcanum protocol + // for blinding. + 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. +func (t TxOutputZarcanum) OutputType() uint8 { return OutputTypeZarcanum } diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..3216b77 --- /dev/null +++ b/types/types.go @@ -0,0 +1,92 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +// Package types defines the core cryptographic and blockchain data types for +// the Lethean protocol. All types are fixed-size byte arrays matching the +// CryptoNote specification. +package types + +import ( + "encoding/hex" + "fmt" +) + +// Hash is a 256-bit (32-byte) hash value, typically produced by Keccak-256. +type Hash [32]byte + +// String returns the hexadecimal representation of the hash. +func (h Hash) String() string { + return hex.EncodeToString(h[:]) +} + +// IsZero reports whether the hash is all zeroes. +func (h Hash) IsZero() bool { + for _, b := range h { + if b != 0 { + return false + } + } + return true +} + +// HashFromHex parses a 64-character hexadecimal string into a Hash. +func HashFromHex(s string) (Hash, error) { + var h Hash + b, err := hex.DecodeString(s) + if err != nil { + return h, fmt.Errorf("types: invalid hex for hash: %w", err) + } + if len(b) != 32 { + return h, fmt.Errorf("types: hash hex must be 64 characters, got %d", len(s)) + } + copy(h[:], b) + return h, nil +} + +// PublicKey is a 256-bit Ed25519 public key. +type PublicKey [32]byte + +// String returns the hexadecimal representation of the public key. +func (pk PublicKey) String() string { + return hex.EncodeToString(pk[:]) +} + +// PublicKeyFromHex parses a 64-character hexadecimal string into a PublicKey. +func PublicKeyFromHex(s string) (PublicKey, error) { + var pk PublicKey + b, err := hex.DecodeString(s) + if err != nil { + return pk, fmt.Errorf("types: invalid hex for public key: %w", err) + } + if len(b) != 32 { + return pk, fmt.Errorf("types: public key hex must be 64 characters, got %d", len(s)) + } + copy(pk[:], b) + return pk, nil +} + +// SecretKey is a 256-bit Ed25519 secret (private) key. +type SecretKey [32]byte + +// String returns the hexadecimal representation of the secret key. +// Note: take care when logging or displaying secret keys. +func (sk SecretKey) String() string { + return hex.EncodeToString(sk[:]) +} + +// KeyImage is a 256-bit key image used for double-spend detection. +type KeyImage [32]byte + +// String returns the hexadecimal representation of the key image. +func (ki KeyImage) String() string { + return hex.EncodeToString(ki[:]) +} + +// Signature is a 512-bit (64-byte) cryptographic signature. +type Signature [64]byte diff --git a/wire/varint.go b/wire/varint.go new file mode 100644 index 0000000..b320a98 --- /dev/null +++ b/wire/varint.go @@ -0,0 +1,75 @@ +// Copyright (c) 2017-2026 Lethean (https://lt.hn) +// +// Licensed under the European Union Public Licence (EUPL) version 1.2. +// You may obtain a copy of the licence at: +// +// https://joinup.ec.europa.eu/software/page/eupl/licence-eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +// Package wire provides binary serialisation primitives for the CryptoNote +// wire protocol. All encoding is consensus-critical and must be bit-identical +// to the C++ reference implementation. +package wire + +import ( + "errors" +) + +// MaxVarintLen is the maximum number of bytes a CryptoNote varint can occupy. +// A uint64 requires at most 10 bytes of 7-bit encoding (64 bits / 7 = ~9.14, +// so values above 2^63-1 need a 10th byte). +const MaxVarintLen = 10 + +// ErrVarintOverflow is returned when a varint exceeds the maximum allowed +// length of 10 bytes. +var ErrVarintOverflow = errors.New("wire: varint overflow (exceeds 10 bytes)") + +// ErrVarintEmpty is returned when attempting to decode a varint from an +// empty byte slice. +var ErrVarintEmpty = errors.New("wire: cannot decode varint from empty data") + +// EncodeVarint encodes a uint64 value as a CryptoNote variable-length integer. +// +// The encoding uses 7 bits per byte, with the most significant bit (MSB) set +// to 1 to indicate that more bytes follow. This is the same scheme as protobuf +// varints but limited to 9 bytes maximum for uint64 values. +func EncodeVarint(v uint64) []byte { + if v == 0 { + return []byte{0x00} + } + var buf [MaxVarintLen]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]...) +} + +// DecodeVarint decodes a CryptoNote variable-length integer from the given +// byte slice. It returns the decoded value, the number of bytes consumed, +// and any error encountered. +func DecodeVarint(data []byte) (uint64, int, error) { + if len(data) == 0 { + return 0, 0, ErrVarintEmpty + } + var v uint64 + for i := 0; i < len(data) && i < MaxVarintLen; i++ { + v |= uint64(data[i]&0x7f) << (7 * uint(i)) + if data[i]&0x80 == 0 { + return v, i + 1, nil + } + } + // If we read MaxVarintLen bytes and the last one still has the + // continuation bit set, or if we ran out of data with the continuation + // bit still set, that is an overflow. + if len(data) >= MaxVarintLen { + return 0, 0, ErrVarintOverflow + } + return 0, 0, ErrVarintOverflow +} diff --git a/wire/varint_test.go b/wire/varint_test.go new file mode 100644 index 0000000..866d957 --- /dev/null +++ b/wire/varint_test.go @@ -0,0 +1,126 @@ +// 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 ( + "math" + "testing" +) + +func TestEncodeVarint_Good(t *testing.T) { + tests := []struct { + name string + value uint64 + want []byte + }{ + {"zero", 0, []byte{0x00}}, + {"one", 1, []byte{0x01}}, + {"max_single_byte", 127, []byte{0x7f}}, + {"128", 128, []byte{0x80, 0x01}}, + {"255", 255, []byte{0xff, 0x01}}, + {"256", 256, []byte{0x80, 0x02}}, + {"16384", 16384, []byte{0x80, 0x80, 0x01}}, + {"65535", 65535, []byte{0xff, 0xff, 0x03}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EncodeVarint(tt.value) + if len(got) != len(tt.want) { + t.Fatalf("EncodeVarint(%d) = %x (len %d), want %x (len %d)", + tt.value, got, len(got), tt.want, len(tt.want)) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("EncodeVarint(%d)[%d] = 0x%02x, want 0x%02x", + tt.value, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestDecodeVarint_Good(t *testing.T) { + tests := []struct { + name string + data []byte + wantVal uint64 + wantLen int + }{ + {"zero", []byte{0x00}, 0, 1}, + {"one", []byte{0x01}, 1, 1}, + {"127", []byte{0x7f}, 127, 1}, + {"128", []byte{0x80, 0x01}, 128, 2}, + {"16384", []byte{0x80, 0x80, 0x01}, 16384, 3}, + // With trailing data — should only consume the varint bytes. + {"with_trailing", []byte{0x01, 0xff, 0xff}, 1, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, n, err := DecodeVarint(tt.data) + if err != nil { + t.Fatalf("DecodeVarint(%x) returned error: %v", tt.data, err) + } + if val != tt.wantVal { + t.Errorf("DecodeVarint(%x) value = %d, want %d", tt.data, val, tt.wantVal) + } + if n != tt.wantLen { + t.Errorf("DecodeVarint(%x) length = %d, want %d", tt.data, n, tt.wantLen) + } + }) + } +} + +func TestVarintRoundTrip_Good(t *testing.T) { + values := []uint64{ + 0, 1, 127, 128, 255, 256, 1000, 65535, 65536, + 1<<14 - 1, 1 << 14, 1<<21 - 1, 1 << 21, + 1<<28 - 1, 1 << 28, 1<<35 - 1, 1 << 35, + 1<<42 - 1, 1 << 42, 1<<49 - 1, 1 << 49, + 1<<56 - 1, 1 << 56, math.MaxUint64, + } + + for _, v := range values { + encoded := EncodeVarint(v) + decoded, n, err := DecodeVarint(encoded) + if err != nil { + t.Errorf("round-trip failed for %d: encode→%x, decode error: %v", v, encoded, err) + continue + } + if decoded != v { + t.Errorf("round-trip failed for %d: encode→%x, decode→%d", v, encoded, decoded) + } + if n != len(encoded) { + t.Errorf("round-trip for %d: consumed %d bytes, encoded %d bytes", v, n, len(encoded)) + } + } +} + +func TestDecodeVarint_Bad(t *testing.T) { + // Empty input. + _, _, err := DecodeVarint(nil) + if err != ErrVarintEmpty { + t.Errorf("DecodeVarint(nil) error = %v, want ErrVarintEmpty", err) + } + + _, _, err = DecodeVarint([]byte{}) + if err != ErrVarintEmpty { + t.Errorf("DecodeVarint([]) error = %v, want ErrVarintEmpty", err) + } +} + +func TestDecodeVarint_Ugly(t *testing.T) { + // A varint with all continuation bits set for 11 bytes (overflow). + overflow := make([]byte, 11) + for i := range overflow { + overflow[i] = 0x80 + } + _, _, err := DecodeVarint(overflow) + if err != ErrVarintOverflow { + t.Errorf("DecodeVarint(overflow) error = %v, want ErrVarintOverflow", err) + } +}