From c46870dd58289a8d99d9ed7de57d56ab480bcb28 Mon Sep 17 00:00:00 2001 From: aperod-network Date: Wed, 21 Oct 2026 23:20:35 +0500 Subject: [PATCH] chore: maintain verified source distribution --- api/address_history.go | 367 ++++++ api/address_history_bounds_test.go | 247 ++++ api/audit_authority.go | 12 +- api/audit_authority_test.go | 31 +- api/authority_staking_integration_test.go | 89 +- api/disk_tx_lookup.go | 68 +- api/disk_tx_lookup_legacy_test.go | 103 ++ api/disk_tx_lookup_rawblock_test.go | 10 +- api/genesis_vesting.go | 70 +- api/genesis_vesting_test.go | 160 ++- api/genesis_vesting_wallet_test.go | 26 +- api/lpod_audit_daily.go | 294 ++--- api/lpod_audit_daily_benchmark_test.go | 172 +-- api/lpod_audit_daily_eviction_linux_test.go | 3 +- api/lpod_audit_daily_hardening_test.go | 4 +- api/lpod_audit_daily_test.go | 79 +- api/lpod_daily_beneficiary_test.go | 42 +- api/lpod_daily_job_integrity.go | 22 +- api/lpod_daily_job_integrity_test.go | 28 +- api/lpod_daily_jobs.go | 59 +- api/lpod_daily_jobs_test.go | 156 +-- api/lpod_earnings_outputs_test.go | 19 +- api/lpod_pool_test.go | 3 +- api/lpod_wallet_outputs.go | 12 +- api/middleware.go | 31 +- api/middleware_path_test.go | 8 +- api/middleware_rate_limit_test.go | 64 + api/rest.go | 302 +++-- api/rest_address_history_test.go | 149 +++ api/rest_avm.go | 34 +- api/rest_avm_sdk_test.go | 204 ++++ api/rest_export.go | 10 +- api/rest_stake_test.go | 38 +- api/rest_test.go | 43 +- api/rest_tx_disk_lookup_test.go | 2 +- api/rest_utxo_block_ts_test.go | 8 +- api/rest_utxo_prune_warning_test.go | 18 +- api/rest_utxo_viewkey_test.go | 21 + api/restart_survival_test.go | 104 +- api/rpc_wallet.go | 42 +- api/rpc_wallet_batch.go | 12 +- api/sale.go | 27 +- api/sale_test.go | 49 +- api/server.go | 120 +- api/utxo_address_cache_test.go | 50 + api/utxo_missing_reason_test.go | 6 +- api/wallet_changes_test.go | 3 +- api/wallet_history_proof.go | 3 + api/wallet_history_proof_test.go | 37 +- api/wallet_history_transactions_test.go | 5 +- api/wallet_snapshot.go | 23 +- api/wallet_snapshot_http_completion_test.go | 4 +- api/wallet_snapshot_integration_test.go | 3 +- api/wallet_snapshot_query_failure_test.go | 120 ++ api/wallet_snapshot_service_test.go | 4 +- api/ws.go | 3 +- avm/CHECKPOINTS.md | 101 ++ avm/admission_policy_test.go | 3 +- avm/checkpoint.go | 781 ++++++++++++ avm/checkpoint_benchmark_test.go | 182 +++ avm/checkpoint_quorum_test.go | 311 +++++ avm/checkpoint_test.go | 434 +++++++ avm/recovery.go | 659 ++++++++++- avm/recovery_crash_test.go | 185 +++ cmd/avm-checkpoint/main.go | 286 +++++ cmd/avm-checkpoint/main_test.go | 234 ++++ cmd/avm-fee-audit/audit_test.go | 75 +- cmd/avm-fee-audit/main.go | 169 ++- cmd/backup-verify/main.go | 4 +- cmd/cli/main.go | 33 +- cmd/cli/prune_warning_test.go | 16 +- cmd/cli/stake_lightnode_e2e_test.go | 4 +- cmd/explorer-indexer/main.go | 122 +- cmd/explorer-indexer/main_test.go | 118 ++ cmd/genesis-reserve/main.go | 136 ++- cmd/lpod-archive-check/endpoint_evidence.go | 36 + .../endpoint_evidence_test.go | 139 +++ cmd/lpod-archive-check/main.go | 29 +- cmd/lpod-public-input-check/main.go | 2 +- cmd/mint-record-audit/whole_opening.go | 1 + cmd/node/backup_socket.go | 2 +- cmd/node/config_reload.go | 32 +- cmd/node/configurable_window_test.go | 2 +- cmd/node/db_gap_auto_repair_test.go | 7 +- cmd/node/db_gap_restart_test.go | 2 +- cmd/node/finality_migration.go | 98 ++ cmd/node/finality_migration_test.go | 99 ++ cmd/node/finality_vote_test.go | 2 +- cmd/node/gomemlimit_check_test.go | 2 +- cmd/node/main.go | 300 +++-- cmd/node/maintenance_preflight_test.go | 354 +++--- cmd/node/mempool_persist_test.go | 64 +- cmd/node/mempool_tmp_cleanup_test.go | 2 +- cmd/node/nightly_restart_test.go | 30 +- cmd/node/rebuild_missing_utxos_test.go | 8 +- cmd/node/registry_bootstrap.go | 693 ++++++----- cmd/node/registry_bootstrap_keyimages.go | 116 +- cmd/node/registry_bootstrap_keyimages_test.go | 129 +- cmd/node/registry_bootstrap_storage_test.go | 124 +- cmd/node/registry_bootstrap_test.go | 739 ++++++++---- cmd/node/resume.go | 114 +- cmd/node/scan.go | 37 +- cmd/node/scan_test.go | 18 +- cmd/node/scan_txidx_test.go | 6 +- cmd/node/shutdown_save_log_test.go | 2 +- cmd/node/sighup_reload_test.go | 232 ++-- cmd/node/snapshot.go | 18 +- cmd/node/snapshot_checksum_test.go | 2 +- cmd/node/snapshot_coordinator_test.go | 95 +- cmd/node/snapshot_gap_fill_test.go | 2 +- cmd/node/snapshot_keyimage_filter_test.go | 2 +- cmd/node/snapshot_periodic_test.go | 14 +- cmd/node/snapshot_restart_test.go | 57 +- cmd/node/snapshot_resume_test.go | 158 +-- cmd/node/snapshot_shutdown_log_test.go | 14 +- cmd/node/snapshot_sigkill_flush_test.go | 332 +++--- cmd/node/snapshot_stream.go | 39 +- cmd/node/snapshot_stream_test.go | 115 +- cmd/node/snapshot_utxo_count_test.go | 4 +- cmd/node/snapshot_watchdog_test.go | 2 +- cmd/node/spent_decoy_restart_test.go | 49 +- cmd/node/startup_integrity_test.go | 2 +- cmd/node/utxo_index_fastpath_test.go | 2 +- cmd/node/validator_list.go | 2 +- cmd/node/validator_list_test.go | 10 +- cmd/recover-tip/main_test.go | 4 +- cmd/ring-index-maintenance/worker.go | 3 +- cmd/sale-epoch-proposal/authority.go | 105 +- cmd/sale-epoch-proposal/main.go | 56 +- cmd/wallet-wasm/lpod.go | 7 +- cmd/wallet-wasm/lpod_test.go | 2 +- cmd/wallet-wasm/main_js_wasm.go | 1 + config/config.go | 100 +- config/config_test.go | 76 +- config/snapshot_default_test.go | 8 +- consensus/avm_economics_test.go | 38 +- consensus/avm_http_stress_linux_test.go | 579 +++++++++ .../avm_two_validator_integration_test.go | 10 +- consensus/consensus_test.go | 18 +- consensus/finality_migration.go | 88 ++ consensus/finality_persistence_test.go | 124 ++ consensus/guardian_fund_test.go | 4 +- consensus/legacy_admin_mint_security_test.go | 4 +- consensus/lpod.go | 16 +- consensus/lpod_wallet_discovery_test.go | 11 +- consensus/native_chrome_transfer_test.go | 134 +-- consensus/oracle_test.go | 30 +- consensus/poa.go | 58 +- consensus/poa_test.go | 64 +- consensus/sale.go | 8 +- consensus/sale_finality.go | 34 +- consensus/sale_http_rehearsal_test.go | 1 + consensus/sale_readiness.go | 2 +- consensus/stake_e2e_test.go | 2 + consensus/vesting_e2e_test.go | 12 +- core/assets.go | 2 +- core/block.go | 12 +- core/block_verifier.go | 4 +- core/c0_c1_fix_test.go | 4 +- core/chain.go | 762 ++++++------ core/clsag_ring_store_test.go | 9 +- core/core_test.go | 2 +- core/coverage2_test.go | 252 ++-- core/coverage3_test.go | 280 ++--- core/coverage_test.go | 412 +++---- core/decoy_sampling_test.go | 6 +- core/genesis.go | 305 ++--- core/integration_test.go | 788 ++++++------ core/legacy_commitment_copy_test.go | 4 +- core/lpod_position.go | 1 + core/mempool.go | 14 +- core/merkle.go | 140 +-- core/mint.go | 90 +- core/mint_spend_test.go | 372 +++--- core/phase2_cap_test.go | 2 +- core/registry_snapshot.go | 2 +- core/ringct_v4_security_test.go | 4 +- core/ringct_v5_test.go | 92 +- core/stake_keyimage_v4_test.go | 110 ++ core/staking.go | 398 ++++++- core/staking_test.go | 6 +- core/stealth_crosslang_test.go | 6 +- core/sync_test.go | 20 +- core/transaction.go | 9 +- core/transaction_version_test.go | 2 +- core/tx_builder.go | 51 +- core/tx_builder_fallback_test.go | 4 +- core/tx_builder_ring_duplicates_test.go | 176 +++ core/tx_builder_test.go | 594 +++++----- core/tx_verifier.go | 107 +- core/utxo.go | 156 ++- core/utxo_preflight_test.go | 5 +- core/utxo_replay.go | 59 +- core/utxo_snapshot.go | 76 +- core/utxo_snapshot_view_test.go | 107 ++ core/vesting.go | 2 +- core/vesting_enforcement_test.go | 24 +- core/vesting_test.go | 6 +- core/wallet_scanner.go | 170 +-- crypto/address.go | 4 +- crypto/bench_test.go | 160 +-- crypto/bulletproof.go | 1054 +++++++++-------- crypto/coverage2_test.go | 284 ++--- crypto/coverage3_test.go | 266 ++--- crypto/coverage5_test.go | 310 ++--- crypto/coverage_test.go | 388 +++--- crypto/fuzz_test.go | 344 +++--- crypto/hash.go | 30 +- crypto/keyimage.go | 2 +- crypto/keyimage_proof.go | 132 +++ crypto/keyimage_proof_test.go | 68 ++ crypto/pedersen.go | 292 ++--- crypto/ringct.go | 372 +++--- crypto/ringct_v4_test.go | 2 +- crypto/security_test.go | 512 ++++---- crypto/wallet.go | 202 ++-- deploy/BURN_POLICY.md | 18 + deploy/aperod-node-watchdog.sh | 10 +- deploy/aperod-watchdog-set-interval.sh | 8 +- deploy/atomic_swap_test.go | 8 +- deploy/avm-stress.md | 120 ++ deploy/fixtures/node-lifecycle/go.mod | 2 +- deploy/historical_retirement_test.go | 2 +- deploy/install-node.sh | 2 +- deploy/install-validator.sh | 2 +- deploy/install_node_gomemlimit_test.go | 2 +- deploy/install_node_primary_ip_test.go | 6 +- deploy/join_network_identity_test.go | 6 +- deploy/rollout_cleanup_installer_test.go | 6 +- deploy/setup-backup.sh | 2 +- deploy/test-avm-http-stress.sh | 33 + deploy/test-avm-stress.sh | 2 +- deploy/test-avm-two-validator.sh | 2 +- deploy/test-watchdog-set-interval.sh | 54 +- deploy/test-watchdog.sh | 213 +++- go.mod | 2 +- lpod/accounting.go | 170 ++- lpod/accounting_test.go | 114 +- node/node.go | 43 +- p2p/badblock_ban_test.go | 18 +- p2p/ban_reconnect_test.go | 20 +- p2p/bootnode_dial_backoff_test.go | 10 +- p2p/bootnode_warn_test.go | 4 +- p2p/discovery.go | 4 +- p2p/discovery_test.go | 104 +- p2p/export_test.go | 8 +- p2p/fast_redial_startup_test.go | 2 +- p2p/gossip.go | 2 +- p2p/host.go | 46 +- p2p/host_test.go | 52 +- p2p/peermgr_persist_test.go | 2 +- p2p/peermgr_test.go | 2 +- p2p/pong_cooldown_test.go | 4 +- p2p/protocol.go | 2 +- p2p/reconnect_minpeers_test.go | 2 +- p2p/security_test.go | 8 +- p2p/stall_timeout_config_test.go | 12 +- p2p/sync.go | 16 +- p2p/transport_test.go | 2 +- p2p/txrate.go | 2 +- p2p/whitelist_integration_test.go | 12 +- p2p/whitelist_sidecar_test.go | 4 +- p2p/whitelist_test.go | 2 +- sale/accounting.go | 34 +- sale/accounting_test.go | 291 +++-- sale/canonical_test.go | 3 +- store/finality.go | 5 +- store/finality_migration.go | 101 ++ store/finality_migration_test.go | 56 + store/full_height_repair_test.go | 2 +- store/genesis_vesting.go | 66 +- store/genesis_vesting_funding.go | 102 +- store/genesis_vesting_test.go | 109 +- store/leveldb.go | 72 +- store/logical_backup.go | 3 +- store/logical_backup_test.go | 5 +- store/lpod.go | 49 +- store/lpod_block_audit.go | 13 +- store/lpod_earnings_backfill.go | 53 +- store/lpod_earnings_index_test.go | 60 +- store/lpod_indices.go | 3 +- store/lpod_migration.go | 18 +- store/lpod_positions.go | 37 +- store/lpod_test.go | 86 +- store/lpod_wallet_backfill.go | 259 ++++ store/lpod_wallet_index.go | 105 +- store/prune.go | 34 +- store/ring_member_index_test.go | 2 +- store/ring_sampling.go | 3 +- store/ring_sampling_disk_rehearsal_test.go | 5 +- store/ring_sampling_index.go | 3 +- store/ring_sampling_test.go | 5 +- store/ring_sampling_verify.go | 3 +- store/sale.go | 7 +- store/sale_invoice.go | 3 +- store/sale_ledger_reconcile.go | 5 +- store/sale_orders.go | 5 +- store/sale_reconciliation_floor.go | 1 + store/sale_reconciliation_floor_test.go | 3 +- store/sale_recovery.go | 3 +- store/sale_recovery_test.go | 1 + store/store2_test.go | 414 +++---- store/store_test.go | 608 +++++----- store/txindex_fastpath_test.go | 2 +- store/wallet_read_snapshot.go | 25 +- store/wallet_read_snapshot_test.go | 3 +- wallet/builder.go | 192 +-- wallet/builder_test.go | 202 ++-- wallet/hd.go | 6 +- wallet/keystore.go | 328 ++--- wallet/multisig.go | 5 +- wallet/wordlist.go | 2 +- 312 files changed, 19020 insertions(+), 9033 deletions(-) create mode 100644 api/address_history.go create mode 100644 api/address_history_bounds_test.go create mode 100644 api/disk_tx_lookup_legacy_test.go create mode 100644 api/middleware_rate_limit_test.go create mode 100644 api/rest_address_history_test.go create mode 100644 api/rest_avm_sdk_test.go create mode 100644 api/utxo_address_cache_test.go create mode 100644 api/wallet_snapshot_query_failure_test.go create mode 100644 avm/CHECKPOINTS.md create mode 100644 avm/checkpoint.go create mode 100644 avm/checkpoint_benchmark_test.go create mode 100644 avm/checkpoint_quorum_test.go create mode 100644 avm/checkpoint_test.go create mode 100644 avm/recovery_crash_test.go create mode 100644 cmd/avm-checkpoint/main.go create mode 100644 cmd/avm-checkpoint/main_test.go create mode 100644 cmd/lpod-archive-check/endpoint_evidence.go create mode 100644 cmd/lpod-archive-check/endpoint_evidence_test.go create mode 100644 cmd/node/finality_migration.go create mode 100644 cmd/node/finality_migration_test.go create mode 100644 consensus/avm_http_stress_linux_test.go create mode 100644 consensus/finality_migration.go create mode 100644 core/stake_keyimage_v4_test.go create mode 100644 core/tx_builder_ring_duplicates_test.go create mode 100644 core/utxo_snapshot_view_test.go create mode 100644 crypto/keyimage_proof.go create mode 100644 crypto/keyimage_proof_test.go create mode 100644 deploy/avm-stress.md create mode 100644 deploy/test-avm-http-stress.sh create mode 100644 store/finality_migration.go create mode 100644 store/finality_migration_test.go create mode 100644 store/lpod_wallet_backfill.go diff --git a/api/address_history.go b/api/address_history.go new file mode 100644 index 0000000..dbbfec4 --- /dev/null +++ b/api/address_history.go @@ -0,0 +1,367 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +const ( + maxAddressHistoryScanBlocks = 1_000 + maxAddressHistoryBlockBytes = 8 << 20 + maxAddressHistoryPageBytes = 64 << 20 + maxConcurrentAddressHistoryScans = 2 +) + +var addressHistoryScanSlots = make(chan struct{}, maxConcurrentAddressHistoryScans) + +func tryAcquireAddressHistoryScanSlot() bool { + select { + case addressHistoryScanSlots <- struct{}{}: + return true + default: + return false + } +} + +func releaseAddressHistoryScanSlot() { + <-addressHistoryScanSlots +} + +type addressHistoryQuery struct { + limit int + startHeight *uint64 + anchorHeight *uint64 + anchorHash *crypto.Hash32 +} + +func parseAddressHistoryQuery(r *http.Request) (addressHistoryQuery, error) { + query := addressHistoryQuery{limit: 50} + values := r.URL.Query() + + if raw := values.Get("limit"); raw != "" { + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 200 { + return query, fmt.Errorf("limit must be between 1 and 200") + } + query.limit = limit + } + if raw := values.Get("start_height"); raw != "" { + height, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return query, fmt.Errorf("invalid start_height") + } + query.startHeight = &height + } + + rawAnchorHeight := values.Get("anchor_height") + rawAnchorHash := values.Get("anchor_hash") + if (rawAnchorHeight == "") != (rawAnchorHash == "") { + return query, fmt.Errorf("anchor_height and anchor_hash must be provided together") + } + if rawAnchorHeight != "" { + height, err := strconv.ParseUint(rawAnchorHeight, 10, 64) + if err != nil { + return query, fmt.Errorf("invalid anchor_height") + } + hashBytes, err := hex.DecodeString(rawAnchorHash) + if err != nil || len(hashBytes) != len(crypto.Hash32{}) { + return query, fmt.Errorf("invalid anchor_hash") + } + var hash crypto.Hash32 + copy(hash[:], hashBytes) + query.anchorHeight = &height + query.anchorHash = &hash + } + return query, nil +} + +func (s *Server) restAddressTransactionsPage(w http.ResponseWriter, r *http.Request, addr string, spendPub crypto.Point32) { + query, err := parseAddressHistoryQuery(r) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + if !tryAcquireAddressHistoryScanSlot() { + w.Header().Set("Retry-After", "1") + writeJSONError(w, http.StatusServiceUnavailable, "address history scan capacity reached; retry shortly") + return + } + defer releaseAddressHistoryScanSlot() + + if s.blockStore != nil { + s.restAddressTransactionsFromStore(w, addr, spendPub, query) + return + } + s.restAddressTransactionsFromMemory(w, addr, spendPub, query) +} + +func (s *Server) restAddressTransactionsFromStore(w http.ResponseWriter, addr string, spendPub crypto.Point32, query addressHistoryQuery) { + read, err := s.blockStore.NewWalletReadSnapshot() + if err != nil { + writeJSONError(w, http.StatusServiceUnavailable, "canonical history snapshot unavailable") + return + } + defer read.Release() + + tipHash, tipHeight, err := read.GetTip() + if err != nil || tipHash == (crypto.Hash32{}) { + writeJSONError(w, http.StatusServiceUnavailable, "canonical history tip unavailable") + return + } + anchorHeight, anchorHash := tipHeight, tipHash + if query.anchorHeight == nil { + canonicalHash, found, hashErr := read.GetCanonicalHash(tipHeight) + if hashErr != nil { + writeJSONError(w, http.StatusServiceUnavailable, "canonical history tip lookup failed") + return + } + if !found || canonicalHash != tipHash { + writeJSONError(w, http.StatusServiceUnavailable, "canonical history tip metadata is inconsistent") + return + } + } + if query.anchorHeight != nil { + anchorHeight, anchorHash = *query.anchorHeight, *query.anchorHash + if anchorHeight > tipHeight { + writeJSONError(w, http.StatusConflict, "history anchor is above the canonical tip") + return + } + canonicalHash, found, hashErr := read.GetCanonicalHash(anchorHeight) + if hashErr != nil { + writeJSONError(w, http.StatusServiceUnavailable, "canonical history anchor lookup failed") + return + } + if !found || canonicalHash != anchorHash { + writeJSONError(w, http.StatusConflict, "history anchor is no longer canonical; restart pagination") + return + } + } + + startHeight := anchorHeight + if query.startHeight != nil { + startHeight = *query.startHeight + if startHeight > anchorHeight { + writeJSONError(w, http.StatusBadRequest, "start_height must not exceed anchor_height") + return + } + } + + page := scanAddressHistoryBlocks(startHeight, spendPub, query.limit, func(height uint64) (*core.Block, int, error) { + return read.ReadCanonicalBlockBoundedWithSize(height, maxAddressHistoryBlockBytes) + }, func(height uint64) (bool, error) { + _, found, hashErr := read.GetCanonicalHash(height) + if hashErr == nil && !found { + return false, fmt.Errorf("missing canonical history height %d", height) + } + return found, hashErr + }, pageEndForHeight(startHeight)) + if page.err != nil { + s.log.Warn("address history canonical page read failed", "address", addr, "error", page.err) + writeJSONError(w, http.StatusServiceUnavailable, "canonical address history page unavailable") + return + } + + response := map[string]interface{}{ + "address": addr, + "transactions": page.transactions, + "scan_limited": page.scanLimited, + "has_more": page.hasMore, + "anchor_height": anchorHeight, + "anchor_hash": fmt.Sprintf("%x", anchorHash[:]), + "history_scope": "canonical_public_key_outputs", + "note": "Only public-key-matched outputs are listed; stealth outputs require wallet-local view-key scanning.", + } + if page.hasMore { + response["next_start_height"] = page.nextStartHeight + } + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) restAddressTransactionsFromMemory(w http.ResponseWriter, addr string, spendPub crypto.Point32, query addressHistoryQuery) { + tip := s.chain.Tip() + if tip == nil { + writeJSONError(w, http.StatusServiceUnavailable, "canonical history tip unavailable") + return + } + anchorHeight, anchorHash := tip.Header.Height, tip.Hash() + if query.anchorHeight != nil { + anchorHeight, anchorHash = *query.anchorHeight, *query.anchorHash + canonical := s.chain.GetByHeight(anchorHeight) + if canonical == nil || canonical.Hash() != anchorHash { + writeJSONError(w, http.StatusConflict, "history anchor is no longer retained; restart pagination") + return + } + } + + startHeight := anchorHeight + if query.startHeight != nil { + startHeight = *query.startHeight + if startHeight > anchorHeight { + writeJSONError(w, http.StatusBadRequest, "start_height must not exceed anchor_height") + return + } + } + blocks, nextStart, pageHasMore, oldest := s.chain.BlocksNewestFirstPage(startHeight, false, maxAddressHistoryScanBlocks) + if len(blocks) == 0 && startHeight < oldest { + writeJSONError(w, http.StatusServiceUnavailable, "historical block body is outside the in-memory window") + return + } + if len(blocks) == 0 { + writeJSONError(w, http.StatusServiceUnavailable, "address history block is not retained") + return + } + pageEnd := blocks[len(blocks)-1].Header.Height + + page := scanAddressHistoryBlocks(startHeight, spendPub, query.limit, func(height uint64) (*core.Block, int, error) { + block := s.chain.GetByHeight(height) + if block == nil { + return nil, 0, fmt.Errorf("block %d is not retained in memory", height) + } + raw, marshalErr := json.Marshal(block) + if marshalErr != nil { + return nil, 0, marshalErr + } + return block, len(raw), nil + }, func(height uint64) (bool, error) { + return s.chain.GetByHeight(height) != nil, nil + }, pageEnd) + if page.err != nil { + writeJSONError(w, http.StatusServiceUnavailable, "in-memory address history page unavailable") + return + } + if !page.hasMore && pageHasMore { + page.hasMore = true + page.nextStartHeight = nextStart + page.scanLimited = true + } + if !page.hasMore && oldest > 0 && startHeight >= oldest { + page.historyWindowEnded = true + } + + response := map[string]interface{}{ + "address": addr, + "transactions": page.transactions, + "scan_limited": page.scanLimited, + "has_more": page.hasMore, + "anchor_height": anchorHeight, + "anchor_hash": fmt.Sprintf("%x", anchorHash[:]), + "history_scope": "in_memory_public_key_outputs", + "history_window_ended": page.historyWindowEnded, + "note": "Only public-key-matched outputs in the in-memory block window are available; stealth outputs require wallet-local view-key scanning.", + } + if page.hasMore { + response["next_start_height"] = page.nextStartHeight + } + writeJSON(w, http.StatusOK, response) +} + +type addressHistoryPage struct { + transactions []AddressTx + nextStartHeight uint64 + hasMore bool + scanLimited bool + historyWindowEnded bool + err error +} + +func scanAddressHistoryBlocks( + startHeight uint64, + spendPub crypto.Point32, + limit int, + readBlock func(uint64) (*core.Block, int, error), + heightExists func(uint64) (bool, error), + pageEnd uint64, +) addressHistoryPage { + page := addressHistoryPage{transactions: make([]AddressTx, 0, 16)} + pageBytes := 0 + scannedBlocks := 0 + + for height := startHeight; ; height-- { + block, bodyBytes, err := readBlock(height) + if err != nil { + page.err = err + return page + } + if bodyBytes < 0 || bodyBytes > maxAddressHistoryBlockBytes || + bodyBytes > maxAddressHistoryPageBytes-pageBytes { + page.err = fmt.Errorf("address history page decode budget exceeded") + return page + } + pageBytes += bodyBytes + scannedBlocks++ + + if block.Header.Height != height || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { + page.err = fmt.Errorf("canonical block body mismatch at height %d", height) + return page + } + heightPub, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(height)) + if err != nil { + page.err = fmt.Errorf("derive mint pub at height %d: %w", height, err) + return page + } + expectedMintPub, err := crypto.AddPoints(spendPub, heightPub) + if err != nil { + page.err = fmt.Errorf("derive expected mint pub at height %d: %w", height, err) + return page + } + + for txIndex, tx := range block.Txs { + txHash := tx.Hash() + for outputIndex, output := range tx.Outputs { + if output.OneTimePub == spendPub || output.OneTimePub == expectedMintPub { + page.transactions = append(page.transactions, AddressTx{ + TxHash: fmt.Sprintf("%x", txHash[:]), + BlockHeight: height, + TxIndex: txIndex, + OutputIndex: outputIndex, + IsCoinbase: tx.IsCoinbase(), + }) + } + } + } + + if len(page.transactions) >= limit { + if height > 0 { + exists, existsErr := heightExists(height - 1) + if existsErr != nil { + page.err = existsErr + return page + } + if exists { + page.hasMore = true + page.nextStartHeight = height - 1 + } + } + return page + } + + if height == pageEnd || scannedBlocks >= maxAddressHistoryScanBlocks || height == 0 { + if height > 0 { + exists, existsErr := heightExists(height - 1) + if existsErr != nil { + page.err = existsErr + return page + } + if exists { + page.hasMore = true + page.scanLimited = true + page.nextStartHeight = height - 1 + } + } + return page + } + } +} + +func pageEndForHeight(startHeight uint64) uint64 { + if startHeight >= maxAddressHistoryScanBlocks { + return startHeight - maxAddressHistoryScanBlocks + 1 + } + return 0 +} diff --git a/api/address_history_bounds_test.go b/api/address_history_bounds_test.go new file mode 100644 index 0000000..07ab187 --- /dev/null +++ b/api/address_history_bounds_test.go @@ -0,0 +1,247 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func syntheticAddressHistoryBlock(height uint64, spendPub crypto.Point32, match bool) *core.Block { + var txs []core.Transaction + if match { + txs = []core.Transaction{core.CoinbaseTx(spendPub, 1)} + } + return &core.Block{ + Header: core.BlockHeader{Height: height, MerkleRoot: core.MerkleRoot(txs)}, + Txs: txs, + } +} + +func syntheticAddressHistorySpendPub(t *testing.T) crypto.Point32 { + t.Helper() + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + return keys.Spend.Public +} + +func TestParseAddressHistoryQueryEnforcesMaximumPageLimit(t *testing.T) { + for _, limit := range []string{"1", "200"} { + request := httptest.NewRequest(http.MethodGet, "/api/v1/address/test/transactions?limit="+limit, nil) + query, err := parseAddressHistoryQuery(request) + if err != nil { + t.Errorf("limit %s rejected: %v", limit, err) + continue + } + if got := query.limit; got < 1 || got > 200 { + t.Errorf("limit %s parsed as %d, outside the allowed range", limit, got) + } + } + + for _, limit := range []string{"0", "201"} { + request := httptest.NewRequest(http.MethodGet, "/api/v1/address/test/transactions?limit="+limit, nil) + if _, err := parseAddressHistoryQuery(request); err == nil { + t.Errorf("limit %s accepted; want an error", limit) + } + } +} + +func TestScanAddressHistoryRejectsOversizedBlock(t *testing.T) { + spendPub := syntheticAddressHistorySpendPub(t) + reads := 0 + page := scanAddressHistoryBlocks(4, spendPub, 10, + func(height uint64) (*core.Block, int, error) { + reads++ + return syntheticAddressHistoryBlock(height, spendPub, false), maxAddressHistoryBlockBytes + 1, nil + }, + func(uint64) (bool, error) { return true, nil }, + 0, + ) + + if page.err == nil { + t.Fatal("oversized block was accepted") + } + if reads != 1 { + t.Fatalf("readBlock called %d times, want fail-fast after one block", reads) + } + if page.hasMore || page.nextStartHeight != 0 { + t.Fatalf("oversized page exposed continuation: %+v", page) + } +} + +func TestScanAddressHistoryPageBudgetExactLimitAndOverflow(t *testing.T) { + spendPub := syntheticAddressHistorySpendPub(t) + const largestAcceptedPageBlocks = maxAddressHistoryPageBytes / maxAddressHistoryBlockBytes + + t.Run("exact budget is accepted", func(t *testing.T) { + reads := 0 + page := scanAddressHistoryBlocks(largestAcceptedPageBlocks-1, spendPub, 200, + func(height uint64) (*core.Block, int, error) { + reads++ + return syntheticAddressHistoryBlock(height, spendPub, false), maxAddressHistoryBlockBytes, nil + }, + func(uint64) (bool, error) { return true, nil }, + 0, + ) + if page.err != nil { + t.Fatalf("exact page budget rejected: %v", page.err) + } + if reads != largestAcceptedPageBlocks { + t.Fatalf("read %d blocks, want %d", reads, largestAcceptedPageBlocks) + } + if page.hasMore { + t.Fatalf("genesis page unexpectedly has continuation: %+v", page) + } + }) + + t.Run("one byte over budget is rejected", func(t *testing.T) { + reads := 0 + page := scanAddressHistoryBlocks(largestAcceptedPageBlocks, spendPub, 200, + func(height uint64) (*core.Block, int, error) { + reads++ + return syntheticAddressHistoryBlock(height, spendPub, false), maxAddressHistoryBlockBytes, nil + }, + func(uint64) (bool, error) { return true, nil }, + 0, + ) + if page.err == nil { + t.Fatal("page exceeding the aggregate byte budget was accepted") + } + if reads != largestAcceptedPageBlocks+1 { + t.Fatalf("read %d blocks, want rejection on block %d", reads, largestAcceptedPageBlocks+1) + } + }) +} + +func TestScanAddressHistoryHardCapsHeightAttemptsAndAdvancesCursor(t *testing.T) { + spendPub := syntheticAddressHistorySpendPub(t) + reads := 0 + page := scanAddressHistoryBlocks(2_000, spendPub, 200, + func(height uint64) (*core.Block, int, error) { + reads++ + return syntheticAddressHistoryBlock(height, spendPub, false), 1, nil + }, + func(uint64) (bool, error) { return true, nil }, + 0, // Deliberately wider than production's normal pageEnd calculation. + ) + + if page.err != nil { + t.Fatalf("bounded scan failed: %v", page.err) + } + if reads != maxAddressHistoryScanBlocks { + t.Fatalf("read %d heights, want the hard cap of %d", reads, maxAddressHistoryScanBlocks) + } + if !page.hasMore || !page.scanLimited || page.nextStartHeight != 1_000 { + t.Fatalf("bounded scan continuation = %+v, want next height 1000", page) + } +} + +func TestScanAddressHistoryFollowingCursorsDoesNotRepeatRanges(t *testing.T) { + spendPub := syntheticAddressHistorySpendPub(t) + matchingHeights := map[uint64]bool{10: true, 6: true} + seen := make(map[uint64]int) + read := func(height uint64) (*core.Block, int, error) { + seen[height]++ + return syntheticAddressHistoryBlock(height, spendPub, matchingHeights[height]), 1, nil + } + exists := func(uint64) (bool, error) { return true, nil } + + start := uint64(10) + for _, wantNext := range []uint64{9, 5} { + page := scanAddressHistoryBlocks(start, spendPub, 1, read, exists, 0) + if page.err != nil { + t.Fatalf("scan from %d failed: %v", start, page.err) + } + if !page.hasMore || page.nextStartHeight != wantNext || page.nextStartHeight >= start { + t.Fatalf("cursor from %d = %+v, want strict backward cursor %d", start, page, wantNext) + } + start = page.nextStartHeight + } + + last := scanAddressHistoryBlocks(start, spendPub, 1, read, exists, 0) + if last.err != nil { + t.Fatalf("terminal scan from %d failed: %v", start, last.err) + } + if last.hasMore || last.scanLimited { + t.Fatalf("terminal scan unexpectedly continues: %+v", last) + } + for height, count := range seen { + if count != 1 { + t.Errorf("following returned cursors scanned height %d %d times", height, count) + } + } +} + +func TestAddressHistoryConcurrentScanLimitFailsFast(t *testing.T) { + if got := len(addressHistoryScanSlots); got != 0 { + t.Fatalf("history scan slots not empty before test: %d", got) + } + + const contenders = 32 + start := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + var attempted sync.WaitGroup + var finished sync.WaitGroup + var admitted atomic.Int32 + var active atomic.Int32 + var peak atomic.Int32 + attempted.Add(contenders) + finished.Add(contenders) + + for range contenders { + go func() { + defer finished.Done() + <-start + if tryAcquireAddressHistoryScanSlot() { + admitted.Add(1) + now := active.Add(1) + for prior := peak.Load(); now > prior && !peak.CompareAndSwap(prior, now); prior = peak.Load() { + } + attempted.Done() + <-release + active.Add(-1) + releaseAddressHistoryScanSlot() + return + } + attempted.Done() + }() + } + cleanup := func() { + releaseOnce.Do(func() { close(release) }) + finished.Wait() + } + defer cleanup() + + close(start) + attempted.Wait() + if got := admitted.Load(); got != maxConcurrentAddressHistoryScans { + t.Fatalf("admitted %d scans, want exactly %d", got, maxConcurrentAddressHistoryScans) + } + if got := peak.Load(); got > maxConcurrentAddressHistoryScans { + t.Fatalf("peak concurrent scans = %d, want at most %d", got, maxConcurrentAddressHistoryScans) + } + if got := len(addressHistoryScanSlots); got != maxConcurrentAddressHistoryScans { + t.Fatalf("occupied scan slots = %d, want %d", got, maxConcurrentAddressHistoryScans) + } + + server := NewServer(":0", core.NewChain(), core.NewMempool(core.DefaultMempoolConfig()), core.NewUTXOSet(), + slog.New(slog.NewTextHandler(io.Discard, nil))) + request := httptest.NewRequest(http.MethodGet, "/api/v1/address/test/transactions?limit=50", nil) + response := httptest.NewRecorder() + server.restAddressTransactionsPage(response, request, "test", crypto.Point32{}) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("over-capacity status = %d, want 503: %s", response.Code, response.Body.String()) + } + if response.Header().Get("Retry-After") != "1" { + t.Fatalf("Retry-After = %q, want 1", response.Header().Get("Retry-After")) + } +} diff --git a/api/audit_authority.go b/api/audit_authority.go index 58578d4..48287cc 100644 --- a/api/audit_authority.go +++ b/api/audit_authority.go @@ -29,7 +29,7 @@ func (s *Server) SetAuditValidatorAuthorityFork(f *core.ValidatorAuthorityFork) return err } if height >= f.Height-1 { - parent, found, err := s.blockStore.GetCanonicalHash(f.Height-1) + parent, found, err := s.blockStore.GetCanonicalHash(f.Height - 1) if err != nil || !found || parent != f.Parent { return fmt.Errorf("audit authority activation-parent binding mismatch") } @@ -51,7 +51,9 @@ func (s *Server) localValidatorIdentity(next bool) (crypto.ValidatorPubKey, erro var height uint64 if s.blockStore != nil { _, h, err := s.blockStore.GetTip() - if err != nil { return nil, err } + if err != nil { + return nil, err + } height = h } else if s.chain != nil && s.chain.Tip() != nil { height = s.chain.Tip().Header.Height @@ -59,7 +61,9 @@ func (s *Server) localValidatorIdentity(next bool) (crypto.ValidatorPubKey, erro return nil, fmt.Errorf("canonical validator authority height unavailable") } if next { - if height == ^uint64(0) { return nil, fmt.Errorf("validator authority height overflow") } + if height == ^uint64(0) { + return nil, fmt.Errorf("validator authority height overflow") + } height++ } identity := s.validatorAuthorityFork.LocalIdentity(signing, height) @@ -78,4 +82,4 @@ func (s *Server) verifyAuditHeader(header *core.BlockHeader) bool { return false } return copy.VerifySignature(s.validatorAuthorityFork.Authority(copy.ValidatorPub, copy.Height)) -} \ No newline at end of file +} diff --git a/api/audit_authority_test.go b/api/audit_authority_test.go index c2dd729..e0da0b9 100644 --- a/api/audit_authority_test.go +++ b/api/audit_authority_test.go @@ -9,25 +9,38 @@ import ( func TestAuditHeaderAuthorityEraAndRetiredKeyRejection(t *testing.T) { old, oldPub, err := crypto.GenerateValidatorKey() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } next, nextPub, err := crypto.GenerateValidatorKey() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } rule := &core.ValidatorAuthorityFork{ Version: 1, Height: 5, Identity: oldPub, Replacement: nextPub, Genesis: crypto.HashBytes([]byte("genesis")), Parent: crypto.HashBytes([]byte("parent")), RegistrySHA256: crypto.HashBytes([]byte("registry")), } rule.Possession, err = next.Sign(rule.Message()) - if err != nil { t.Fatal(err) } - if err := rule.Validate(); err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } + if err := rule.Validate(); err != nil { + t.Fatal(err) + } s := &Server{validatorAuthorityFork: rule.Clone()} for _, height := range []uint64{4, 5, 6} { - for _, signing := range []struct{ key crypto.ValidatorPrivKey; replacement bool }{ + for _, signing := range []struct { + key crypto.ValidatorPrivKey + replacement bool + }{ {old, false}, {next, true}, } { header := core.BlockHeader{Height: height, ValidatorPub: oldPub, PrevHash: rule.Parent} header.Signature, err = signing.key.Sign(header.Hash()) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } want := signing.replacement == (height >= rule.Height) if got := s.verifyAuditHeader(&header); got != want { t.Fatalf("height=%d replacement=%v got=%v want=%v", height, signing.replacement, got, want) @@ -35,11 +48,13 @@ func TestAuditHeaderAuthorityEraAndRetiredKeyRejection(t *testing.T) { if height == rule.Height && want { header.PrevHash = crypto.HashBytes([]byte("wrong-parent")) header.Signature, _ = next.Sign(header.Hash()) - if s.verifyAuditHeader(&header) { t.Fatal("accepted wrong activation parent") } + if s.verifyAuditHeader(&header) { + t.Fatal("accepted wrong activation parent") + } } } } if err := s.SetAuditValidatorAuthorityFork(&core.ValidatorAuthorityFork{}); err == nil { t.Fatal("accepted unvalidated authority rule") } -} \ No newline at end of file +} diff --git a/api/authority_staking_integration_test.go b/api/authority_staking_integration_test.go index a7d5b5d..0b2adb0 100644 --- a/api/authority_staking_integration_test.go +++ b/api/authority_staking_integration_test.go @@ -19,36 +19,58 @@ func TestDailyAuditSpansAuthorityRotationWithSignedStakeOperations(t *testing.T) rec := httptest.NewRecorder() s.restLPoDAuditDaily(rec, httptest.NewRequest(http.MethodGet, "/?date=2024-01-02", nil)) var result struct { - Status string `json:"status"` - Reason string `json:"reason"` - Coverage struct { Blocks int `json:"blocks"` } `json:"coverage"` + Status string `json:"status"` + Reason string `json:"reason"` + Coverage struct { + Blocks int `json:"blocks"` + } `json:"coverage"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatal(err) } - if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { t.Fatal(err) } if result.Status != "complete" || result.Coverage.Blocks != 8 { t.Fatalf("full rotated daily audit: %s", rec.Body.String()) } hash, found, err := db.GetCanonicalHash(3) - if err != nil || !found { t.Fatal("missing stake-operation block") } + if err != nil || !found { + t.Fatal("missing stake-operation block") + } raw, err := db.GetRawBlock(hash) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } var block core.Block - if err := json.Unmarshal(raw, &block); err != nil { t.Fatal(err) } + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatal(err) + } record, err := db.LoadLPoDAuditAtBounded(hash, dailyJobMaxBytes) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } // The signer must be checked, not merely accepted because the identity // owns collateral. A different/retired authority must fail replay. stakeCount := 0 for i := range block.Txs { - if !block.Txs[i].IsStake() { continue } + if !block.Txs[i].IsStake() { + continue + } stakeCount++ action, pub, amount, _, err := core.DecodeStakeExtra(block.Txs[i].Extra) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } sig, err := retired.Sign(core.StakeSignMsg(action, pub, amount)) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } block.Txs[i].Extra, err = core.EncodeStakeExtra(action, pub, amount, sig) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } + } + if stakeCount != 2 { + t.Fatalf("fixture must exercise deposit and withdrawal, got %d", stakeCount) } - if stakeCount != 2 { t.Fatalf("fixture must exercise deposit and withdrawal, got %d", stakeCount) } if _, _, err := s.verifyDailyRegistryEvidence(context.Background(), &block, record, nil); err == nil || !strings.Contains(err.Error(), "replay canonical stake transactions") { t.Fatalf("retired signature must fail stake replay, got %v", err) } @@ -60,30 +82,49 @@ func TestRotatedLocalStakingUsesCollateralIdentity(t *testing.T) { defer db.Close() identity := s.validatorAuthorityFork.Identity got, err := s.localValidatorIdentity(true) - if err != nil || !got.Equals(identity) { t.Fatalf("local collateral: %x, %v", got, err) } + if err != nil || !got.Equals(identity) { + t.Fatalf("local collateral: %x, %v", got, err) + } rec := httptest.NewRecorder() s.restMyValidator(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - var result struct { PubKey string `json:"pub_key"` } - if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { t.Fatal(err) } - if rec.Code != 200 || result.PubKey != identity.Hex() { t.Fatalf("identity response: %s", rec.Body.String()) } + var result struct { + PubKey string `json:"pub_key"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if rec.Code != 200 || result.PubKey != identity.Hex() { + t.Fatalf("identity response: %s", rec.Body.String()) + } // Cross-identity checks precede the UTXO preflight. A retained identity // reaches preflight, while a signing-key-as-identity request is forbidden. registry := core.NewValidatorRegistry() s.SetRegistry(registry) - for _, tc := range []struct{ pub crypto.ValidatorPubKey; forbidden bool }{ + for _, tc := range []struct { + pub crypto.ValidatorPubKey + forbidden bool + }{ {identity, false}, {s.myKey.Public(), true}, } { - body := `{"pub_key":"`+tc.pub.Hex()+`","amount_napr":1,"utxo_txhash":"invalid","output_index":0}` + body := `{"pub_key":"` + tc.pub.Hex() + `","amount_napr":1,"utxo_txhash":"invalid","output_index":0}` rec := httptest.NewRecorder() s.restAdminStakeDeposit(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) - if (rec.Code == http.StatusForbidden) != tc.forbidden { t.Fatalf("target=%x status=%d body=%s", tc.pub, rec.Code, rec.Body.String()) } + if (rec.Code == http.StatusForbidden) != tc.forbidden { + t.Fatalf("target=%x status=%d body=%s", tc.pub, rec.Code, rec.Body.String()) + } } retiredKey, err := crypto.NewLockedValidatorKey(retired.Bytes(), nil) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } defer retiredKey.Destroy() s.SetValidatorKey(retiredKey) - if _, err := s.localValidatorIdentity(true); err == nil { t.Fatal("retired collateral signer accepted") } + if _, err := s.localValidatorIdentity(true); err == nil { + t.Fatal("retired collateral signer accepted") + } rec = httptest.NewRecorder() s.restMyValidator(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if rec.Code != http.StatusForbidden { t.Fatal("retired local key advertised as active") } -} \ No newline at end of file + if rec.Code != http.StatusForbidden { + t.Fatal("retired local key advertised as active") + } +} diff --git a/api/disk_tx_lookup.go b/api/disk_tx_lookup.go index 714a50a..dbca426 100644 --- a/api/disk_tx_lookup.go +++ b/api/disk_tx_lookup.go @@ -1,6 +1,7 @@ package api import ( + "encoding/binary" "encoding/json" "errors" "fmt" @@ -23,7 +24,7 @@ var errDiskTxIndexMismatch = errors.New("disk tx index hash mismatch") // block height and tx position, then loads the block from LevelDB and // deserialises the transaction. // -// IMPORTANT: blocks are stored via PutRawBlock as marshalled core.Block JSON, +// IMPORTANT: blocks are stored via PutRawBlock as marshaled core.Block JSON, // NOT as StoredBlock JSON. GetBlockByHeight returns a StoredBlock and always // yields nil TxData when given a core.Block payload. We must use // GetRawBlockByHeight + json.Unmarshal into core.Block to recover the full @@ -48,7 +49,17 @@ func (s *Server) getTransactionFromDisk(hash crypto.Hash32) (core.Transaction, c return core.Transaction{}, core.TxLocation{}, false, nil } - // 2. Load the raw block bytes (stored as marshalled core.Block JSON by + canonicalHash, canonical, err := s.blockStore.GetCanonicalHash(entry.Height) + if err != nil { + return core.Transaction{}, core.TxLocation{}, false, + fmt.Errorf("disk canonical block hash at height %d: %w", entry.Height, err) + } + if !canonical { + return core.Transaction{}, core.TxLocation{}, false, + fmt.Errorf("%w: no canonical block hash at height=%d", errDiskTxIndexMismatch, entry.Height) + } + + // 2. Load the raw block bytes (stored as marshaled core.Block JSON by // storeBlock/PutRawBlock — not as StoredBlock JSON). raw, err := s.blockStore.GetRawBlockByHeight(entry.Height) if err != nil { @@ -65,13 +76,19 @@ func (s *Server) getTransactionFromDisk(hash crypto.Hash32) (core.Transaction, c return core.Transaction{}, core.TxLocation{}, false, fmt.Errorf("disk unmarshal block at height %d: %w", entry.Height, err) } - if entry.TxIdx >= len(b.Txs) { + if b.Header.Height != entry.Height || b.Hash() != canonicalHash { + return core.Transaction{}, core.TxLocation{}, false, + fmt.Errorf("%w: block at height=%d does not match its canonical index", + errDiskTxIndexMismatch, entry.Height) + } + if entry.TxIdx < 0 || entry.TxIdx >= len(b.Txs) { return core.Transaction{}, core.TxLocation{}, false, fmt.Errorf("disk tx index out of range: height=%d txIdx=%d txCount=%d", entry.Height, entry.TxIdx, len(b.Txs)) } tx := b.Txs[entry.TxIdx] - if actualHash := tx.Hash(); actualHash != hash { + if !matchesDiskTransactionHash(&tx, hash) { + actualHash := tx.Hash() return core.Transaction{}, core.TxLocation{}, false, fmt.Errorf("%w: height=%d txIdx=%d requested=%x actual=%x", errDiskTxIndexMismatch, @@ -81,10 +98,51 @@ func (s *Server) getTransactionFromDisk(hash crypto.Hash32) (core.Transaction, c // 4. Construct TxLocation using the full deserialized block so that // loc.Block.Hash() returns the correct block hash in the REST response. // A synthetic block with only Header.Height set would produce the wrong - // hash because Block.Hash() hashes the entire serialised block. + // hash because Block.Hash() hashes the entire serialized block. loc := core.TxLocation{ Block: &b, TxIndex: entry.TxIdx, } return tx, loc, true, nil } + +// matchesDiskTransactionHash supports transaction indexes written by the +// pre-CLSAG transaction-ID rule. This is a read-only compatibility check: +// consensus validation, signing, block construction, and new transaction IDs +// continue to use Transaction.Hash(). +func matchesDiskTransactionHash(tx *core.Transaction, requested crypto.Hash32) bool { + if tx == nil { + return false + } + if tx.Hash() == requested { + return true + } + if tx.Version != core.TxVersionCLSAG || tx.AVM != nil { + return false + } + return preCLSAGTransactionHash(tx) == requested +} + +// preCLSAGTransactionHash reproduces the transaction-ID fields used before +// CLSAG ring commitments and pseudo-outputs were included in the hash. It is +// intentionally limited to disk lookup of CLSAG transactions and must never +// be used to validate, sign, or create a transaction. +func preCLSAGTransactionHash(tx *core.Transaction) crypto.Hash32 { + fee := make([]byte, 8) + binary.LittleEndian.PutUint64(fee, tx.Fee) + parts := [][]byte{{byte(tx.Version)}, fee, tx.FeeCommit[:], tx.Extra} + for _, input := range tx.Inputs { + parts = append(parts, input.KeyImage[:], input.AmountCommit[:]) + for _, pub := range input.Ring { + parts = append(parts, pub[:]) + } + if tx.Version == core.TxVersionCommitmentBinding { + parts = append(parts, []byte{input.RealIndex}) + } + } + for _, output := range tx.Outputs { + parts = append(parts, output.OneTimePub[:], output.AmountCommit[:], + output.TxPubKey[:], output.EncAmount[:]) + } + return crypto.HashBytes(parts...) +} diff --git a/api/disk_tx_lookup_legacy_test.go b/api/disk_tx_lookup_legacy_test.go new file mode 100644 index 0000000..b79e93e --- /dev/null +++ b/api/disk_tx_lookup_legacy_test.go @@ -0,0 +1,103 @@ +package api + +import ( + "encoding/binary" + "encoding/json" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestGetTransactionFromDisk_AcceptsPreCLSAGTransactionID(t *testing.T) { + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + var input core.RingInput + input.KeyImage[0] = 0xa1 + input.AmountCommit[0] = 0xa2 + input.Ring = make([]crypto.RingMember, crypto.RingSize) + input.RingCommitments = make([]crypto.Commitment, crypto.RingSize) + for i := range input.Ring { + input.Ring[i][0] = byte(i + 1) + input.RingCommitments[i][0] = byte(i + 33) + } + input.PseudoOut[0] = 0xb1 + + var output core.Output + output.OneTimePub[0] = 0xc1 + output.AmountCommit[0] = 0xc2 + output.TxPubKey[0] = 0xc3 + output.EncAmount[0] = 0xc4 + + tx := core.Transaction{ + Version: core.TxVersionCLSAG, + Fee: 17, + Inputs: []core.RingInput{input}, + Outputs: []core.Output{output}, + } + tx.FeeCommit[0] = 0xd1 + + legacyID := preCLSAGIDForTest(&tx) + if legacyID == tx.Hash() { + t.Fatal("fixture must distinguish the pre-CLSAG ID from the current transaction hash") + } + + coinbase := core.CoinbaseTx(crypto.Point32{}, 1) + txs := []core.Transaction{coinbase, tx} + block := &core.Block{Header: core.BlockHeader{ + Height: 42, + MerkleRoot: core.MerkleRoot(txs), + }, Txs: txs} + raw, err := json.Marshal(block) + if err != nil { + t.Fatalf("marshal block: %v", err) + } + blockHash := block.Hash() + if err := db.PutRawBlock(blockHash, block.Header.Height, raw); err != nil { + t.Fatalf("PutRawBlock: %v", err) + } + if err := db.PutTip(blockHash, block.Header.Height); err != nil { + t.Fatalf("PutTip: %v", err) + } + if err := db.PutTxIdx(legacyID, block.Header.Height, 1); err != nil { + t.Fatalf("PutTxIdx: %v", err) + } + + server := &Server{blockStore: db} + got, loc, found, err := server.getTransactionFromDisk(legacyID) + if err != nil { + t.Fatalf("getTransactionFromDisk: %v", err) + } + if !found { + t.Fatal("historical transaction ID was not resolved") + } + if got.Hash() == legacyID || preCLSAGTransactionHash(&got) != legacyID { + t.Fatal("resolved body does not match the requested historical transaction ID") + } + if loc.Block.Header.Height != block.Header.Height || loc.TxIndex != 1 || loc.Block.Hash() != blockHash { + t.Fatalf("returned location = height %d, tx index %d, block hash %x; want height %d, tx index 1, hash %x", + loc.Block.Header.Height, loc.TxIndex, loc.Block.Hash(), block.Header.Height, blockHash) + } +} + +func preCLSAGIDForTest(tx *core.Transaction) crypto.Hash32 { + fee := make([]byte, 8) + binary.LittleEndian.PutUint64(fee, tx.Fee) + parts := [][]byte{{byte(tx.Version)}, fee, tx.FeeCommit[:], tx.Extra} + for _, input := range tx.Inputs { + parts = append(parts, input.KeyImage[:], input.AmountCommit[:]) + for _, member := range input.Ring { + parts = append(parts, member[:]) + } + } + for _, output := range tx.Outputs { + parts = append(parts, output.OneTimePub[:], output.AmountCommit[:], + output.TxPubKey[:], output.EncAmount[:]) + } + return crypto.HashBytes(parts...) +} diff --git a/api/disk_tx_lookup_rawblock_test.go b/api/disk_tx_lookup_rawblock_test.go index 83f7fc9..75a7924 100644 --- a/api/disk_tx_lookup_rawblock_test.go +++ b/api/disk_tx_lookup_rawblock_test.go @@ -18,10 +18,10 @@ package api_test // "simulating > 1000 block old UTXO" to document the production invariant. // // Critical assertion: -// The REST endpoint returns the correct block_hash (the full serialised +// The REST endpoint returns the correct block_hash (the full serialized // block hash, not a hash of a synthetic stub with only Header.Height set). // This assertion would FAIL if GetBlockByHeight were used instead of -// GetRawBlockByHeight, because a minimal stub serialises differently. +// GetRawBlockByHeight, because a minimal stub serializes differently. import ( "encoding/hex" @@ -52,8 +52,8 @@ func TestGetTransactionFromDisk_FindsTxViaRawBlock(t *testing.T) { // ── Constants ───────────────────────────────────────────────────────────── // Use a tiny window to avoid adding 1001 blocks in the test. // The disk path exercised is identical to the production 1000-block case. - const windowSize = uint64(4) // simulating > 1000 block old UTXO (production window = 1000) - const mintHeight = uint64(1) // height of the block containing the target tx + const windowSize = uint64(4) // simulating > 1000 block old UTXO (production window = 1000) + const mintHeight = uint64(1) // height of the block containing the target tx const mintAmount = uint64(100_000_000) // 1 APRO // ── 1. Keys ─────────────────────────────────────────────────────────────── @@ -168,7 +168,7 @@ func TestGetTransactionFromDisk_FindsTxViaRawBlock(t *testing.T) { // ── 8. Assert correct response fields ───────────────────────────────────── // block_hash must equal the real hash of the mint block — the full - // serialised block hash. Before the fix, GetBlockByHeight returned a + // serialized block hash. Before the fix, GetBlockByHeight returned a // StoredBlock with nil TxData; the handler then built a synthetic stub // Block{Header: {Height: h}} whose Hash() produced a wrong value. wantBlockHash := fmt.Sprintf("%x", mintBlock.Hash()) diff --git a/api/genesis_vesting.go b/api/genesis_vesting.go index 7c3f88f..a84710e 100644 --- a/api/genesis_vesting.go +++ b/api/genesis_vesting.go @@ -19,21 +19,29 @@ func (s *Server) SetGenesisVestingConfig(height uint64) { s.genesisVestingHeight // Public execution evidence comes from the committed reserve ledger, never // from editable SQL transaction hashes or the visitor's wall-clock countdown. func (s *Server) restGenesisVesting(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only"); return } + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "GET only") + return + } out := map[string]interface{}{ "state": "disabled", "activation_height": s.genesisVestingHeight, - "recipient": store.GenesisVestingRecipient, - "total_napro": strconv.FormatUint(store.GenesisVestingReserve, 10), - "funding_basis": store.GenesisVestingBasis, + "recipient": store.GenesisVestingRecipient, + "total_napro": strconv.FormatUint(store.GenesisVestingReserve, 10), + "funding_basis": store.GenesisVestingBasis, "historical_issuance_proven": false, - "remaining_napro": nil, "paid_napro": nil, + "remaining_napro": nil, "paid_napro": nil, + } + if s.genesisVestingHeight > 0 { + out["state"] = "pending_activation" } - if s.genesisVestingHeight > 0 { out["state"] = "pending_activation" } var v *store.GenesisVestingState var settledHeight uint64 if s.blockStore != nil { checkpoint, err := s.blockStore.LoadLPoDCheckpoint() - if err != nil { writeJSONError(w, 503, "canonical reserve unavailable"); return } + if err != nil { + writeJSONError(w, 503, "canonical reserve unavailable") + return + } if checkpoint != nil && checkpoint.GenesisVesting != nil { v = checkpoint.GenesisVesting settledHeight = checkpoint.State.LastHeight @@ -42,30 +50,34 @@ func (s *Server) restGenesisVesting(w http.ResponseWriter, r *http.Request) { out["remaining_napro"] = strconv.FormatUint(v.Remaining, 10) out["paid_napro"] = strconv.FormatUint(v.Paid, 10) out["reserve"] = v -if v.LiquidityPaid > 0 { -hash, ok, err := s.blockStore.GetCanonicalHash(v.FundingHeight) -if err != nil || !ok { writeJSONError(w, 503, "liquidity funding unavailable"); return } -raw, err := s.blockStore.GetRawBlockByHeight(v.FundingHeight) -var funding core.Block -if err != nil || json.Unmarshal(raw, &funding) != nil || funding.Hash() != hash || len(funding.Txs) < 2 { -writeJSONError(w, 503, "liquidity funding body unavailable"); return -} -liquidityTxHash := funding.Txs[0].Hash() -out["liquidity"] = map[string]interface{}{ -"address": v.LiquidityRecipient, "funded_napro": strconv.FormatUint(v.LiquidityPaid, 10), -"funding_height": v.FundingHeight, "block_hash": hex.EncodeToString(hash[:]), -"tx_hash": hex.EncodeToString(liquidityTxHash[:]), -"finalized": s.lpodFinalized != nil && s.lpodFinalized(v.FundingHeight, hash), -"custody": "operator_treasury_wallet", -} -} + if v.LiquidityPaid > 0 { + hash, ok, err := s.blockStore.GetCanonicalHash(v.FundingHeight) + if err != nil || !ok { + writeJSONError(w, 503, "liquidity funding unavailable") + return + } + raw, err := s.blockStore.GetRawBlockByHeight(v.FundingHeight) + var funding core.Block + if err != nil || json.Unmarshal(raw, &funding) != nil || funding.Hash() != hash || len(funding.Txs) < 2 { + writeJSONError(w, 503, "liquidity funding body unavailable") + return + } + liquidityTxHash := funding.Txs[0].Hash() + out["liquidity"] = map[string]interface{}{ + "address": v.LiquidityRecipient, "funded_napro": strconv.FormatUint(v.LiquidityPaid, 10), + "funding_height": v.FundingHeight, "block_hash": hex.EncodeToString(hash[:]), + "tx_hash": hex.EncodeToString(liquidityTxHash[:]), + "finalized": s.lpodFinalized != nil && s.lpodFinalized(v.FundingHeight, hash), + "custody": "operator_treasury_wallet", + } + } } } rows := make([]map[string]interface{}, 0, 10) for i := 0; i < 10; i++ { deadline, amount, _ := store.GenesisVestingTranche(i) row := map[string]interface{}{ - "year": 2027+i, "unlock_date_utc": time.Unix(0, deadline).UTC().Format("2006-01-02"), + "year": 2027 + i, "unlock_date_utc": time.Unix(0, deadline).UTC().Format("2006-01-02"), "amount_napro": strconv.FormatUint(amount, 10), "address": store.GenesisVestingRecipient, "paid_height": nil, "block_hash": nil, "tx_hash": nil, "finalized": false, } @@ -75,11 +87,13 @@ out["liquidity"] = map[string]interface{}{ var b core.Block if err != nil || json.Unmarshal(raw, &b) != nil || b.Header.Height != h || len(b.Txs) < 2 || !b.Txs[0].IsLPoDPayout() { - writeJSONError(w, 503, "canonical vesting payment body unavailable"); return + writeJSONError(w, 503, "canonical vesting payment body unavailable") + return } hash, ok, err := s.blockStore.GetCanonicalHash(h) if err != nil || !ok || hash != b.Hash() { - writeJSONError(w, 503, "vesting payment is not canonical"); return + writeJSONError(w, 503, "vesting payment is not canonical") + return } txHash := b.Txs[0].Hash() row["paid_height"], row["block_hash"], row["tx_hash"] = h, hex.EncodeToString(hash[:]), hex.EncodeToString(txHash[:]) @@ -99,4 +113,4 @@ out["liquidity"] = map[string]interface{}{ } out["schedule"] = rows writeJSON(w, http.StatusOK, out) -} \ No newline at end of file +} diff --git a/api/genesis_vesting_test.go b/api/genesis_vesting_test.go index f2c9563..1188386 100644 --- a/api/genesis_vesting_test.go +++ b/api/genesis_vesting_test.go @@ -21,57 +21,81 @@ import ( func TestGenesisVestingCanonicalPaymentRestartAndPruning(t *testing.T) { path := t.TempDir() db, migration, leader, signer := syntheticLPoDV3FundingAt(t, path, true) - activation := migration.Height+1 -treasury, err := crypto.GenerateWalletKeys() -if err != nil { t.Fatal(err) } -treasuryAddress := crypto.AddressFromKeys(crypto.MainnetByte, treasury) -proof := &store.GenesisVestingFunding{Version: 2, Genesis: migration.Genesis, Snapshot: migration.SnapshotRoot, -Height: activation, Recipient: store.GenesisVestingRecipient, Reserve: store.GenesisVestingReserve, -OtherAllocations: store.GenesisNominalTotal-store.GenesisVestingReserve-store.GenesisLiquidityAmount, Trust: store.GenesisVestingTrust, -LiquidityRecipient: treasuryAddress, LiquidityAmount: store.GenesisLiquidityAmount, LiquidityTrust: store.GenesisLiquidityTrust} + activation := migration.Height + 1 + treasury, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + treasuryAddress := crypto.AddressFromKeys(crypto.MainnetByte, treasury) + proof := &store.GenesisVestingFunding{Version: 2, Genesis: migration.Genesis, Snapshot: migration.SnapshotRoot, + Height: activation, Recipient: store.GenesisVestingRecipient, Reserve: store.GenesisVestingReserve, + OtherAllocations: store.GenesisNominalTotal - store.GenesisVestingReserve - store.GenesisLiquidityAmount, Trust: store.GenesisVestingTrust, + LiquidityRecipient: treasuryAddress, LiquidityAmount: store.GenesisLiquidityAmount, LiquidityTrust: store.GenesisLiquidityTrust} signature, err := signer.Sign(proof.Message()) -if err != nil { t.Fatal(err) } -proof.Attestations = []store.LPoDAttestation{{Validator: signer.Public(), Signature: signature}} + if err != nil { + t.Fatal(err) + } + proof.Attestations = []store.LPoDAttestation{{Validator: signer.Public(), Signature: signature}} commit := func(timestamp int64, tamper bool) *core.Block { t.Helper() parent, height, err := db.GetTip() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } r := &store.LPoDSettlement{Parent: parent, PositionProtocol: true, Timestamp: timestamp, Proposer: signer.Public().Hex(), Leader: leader, GenesisVestingActivationHeight: activation, -GenesisVestingFunding: proof, -Stake: map[string]store.LPoDValidatorStake{signer.Public().Hex(): {Amount: 100_000*100_000_000, Active: true}}} + GenesisVestingFunding: proof, + Stake: map[string]store.LPoDValidatorStake{signer.Public().Hex(): {Amount: 100_000 * 100_000_000, Active: true}}} next, payments, err := db.PreviewLPoD(height+1, r) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } payout, err := db.PayoutLPoD(height+1, r, next, payments) - if err != nil { t.Fatal(err) } - if tamper { payout.Outputs[0].EncAmount[0] ^= 1 } - b := &core.Block{Header: core.BlockHeader{Height: height+1, PrevHash: parent, + if err != nil { + t.Fatal(err) + } + if tamper { + payout.Outputs[0].EncAmount[0] ^= 1 + } + b := &core.Block{Header: core.BlockHeader{Height: height + 1, PrevHash: parent, Timestamp: timestamp, ValidatorPub: signer.Public()}, Txs: []core.Transaction{payout, core.LPoDCheckpointTx(next.Digest())}} b.Header.MerkleRoot = core.MerkleRoot(b.Txs) - if err := b.Header.Sign(signer); err != nil { t.Fatal(err) } + if err := b.Header.Sign(signer); err != nil { + t.Fatal(err) + } raw, _ := json.Marshal(b) err = db.CommitRawBlockWithAVM(b.Hash(), height+1, raw, nil, crypto.Hash32{}, r) if tamper { - if err == nil { t.Fatal("re-signed mutated payment accepted") } + if err == nil { + t.Fatal("re-signed mutated payment accepted") + } tip, _, _ := db.GetTip() - if tip != parent { t.Fatal("failed payment changed canonical tip") } + if tip != parent { + t.Fatal("failed payment changed canonical tip") + } return b } - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } return b } deadline, amount, _ := store.GenesisVestingTranche(0) funding := commit(deadline-1, false) -matchedLiquidity := 0 -for _, output := range funding.Txs[0].Outputs { -hs, err := crypto.ScanForOutput(treasury.View.Private, treasury.Spend.Public, output.TxPubKey, output.OneTimePub) -if err == nil && hs != nil { -if core.DecryptAmount(output.EncAmount, hs) != store.GenesisLiquidityAmount { t.Fatal("treasury amount is not exactly 300M") } -matchedLiquidity++ -} -} -if matchedLiquidity != 1 { t.Fatal("treasury did not receive one canonical spendable output") } + matchedLiquidity := 0 + for _, output := range funding.Txs[0].Outputs { + hs, err := crypto.ScanForOutput(treasury.View.Private, treasury.Spend.Public, output.TxPubKey, output.OneTimePub) + if err == nil && hs != nil { + if core.DecryptAmount(output.EncAmount, hs) != store.GenesisLiquidityAmount { + t.Fatal("treasury amount is not exactly 300M") + } + matchedLiquidity++ + } + } + if matchedLiquidity != 1 { + t.Fatal("treasury did not receive one canonical spendable output") + } c, err := db.LoadLPoDCheckpoint() if err != nil || c.GenesisVesting == nil || c.GenesisVesting.Paid != 0 || c.Allocation.Remaining != migration.NominalEligible-c.State.FundingDebit { @@ -80,22 +104,38 @@ if matchedLiquidity != 1 { t.Fatal("treasury did not receive one canonical spend commit(deadline, true) payment := commit(deadline, false) c, err = db.LoadLPoDCheckpoint() - if err != nil || c.GenesisVesting.Paid != amount { t.Fatalf("canonical payment failed: %v", err) } + if err != nil || c.GenesisVesting.Paid != amount { + t.Fatalf("canonical payment failed: %v", err) + } // Reconstruct the standard wallet-scannable output from public protocol // evidence; neither a fake tx hash nor a changed amount is acceptable. expected, err := core.BuildLPoDPayoutOutput(store.GenesisVestingRecipient, amount, payment.Header.Height, payment.Header.PrevHash, c.Digest()) -matches := 0 -for _, output := range payment.Txs[0].Outputs { if output == expected { matches++ } } -if err != nil || matches != 1 { t.Fatal("recipient or exact amount mismatch") } - if err := db.Close(); err != nil { t.Fatal(err) } + matches := 0 + for _, output := range payment.Txs[0].Outputs { + if output == expected { + matches++ + } + } + if err != nil || matches != 1 { + t.Fatal("recipient or exact amount mismatch") + } + if err := db.Close(); err != nil { + t.Fatal(err) + } db, err = store.Open(path) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } t.Cleanup(func() { db.Close() }) commit(deadline+1, false) c, err = db.LoadLPoDCheckpoint() - if err != nil || c.GenesisVesting.Paid != amount { t.Fatalf("restart paid twice: %v", err) } -if c.GenesisVesting.LiquidityPaid != store.GenesisLiquidityAmount { t.Fatal("restart duplicated liquidity release") } + if err != nil || c.GenesisVesting.Paid != amount { + t.Fatalf("restart paid twice: %v", err) + } + if c.GenesisVesting.LiquidityPaid != store.GenesisLiquidityAmount { + t.Fatal("restart duplicated liquidity release") + } server := api.NewServer(":0", core.NewChain(), core.NewMempool(core.DefaultMempoolConfig()), core.NewUTXOSet(), slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -106,19 +146,35 @@ if c.GenesisVesting.LiquidityPaid != store.GenesisLiquidityAmount { t.Fatal("res server.SetUTXOReady() rec := httptest.NewRecorder() server.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/genesis-vesting", nil)) - var response struct { State string; Liquidity struct { TxHash string `json:"tx_hash"`; Address string `json:"address"`; Funded string `json:"funded_napro"` }; Schedule []struct { - TxHash string `json:"tx_hash"`; Finalized bool `json:"finalized"`; Amount string `json:"amount_napro"` - } } + var response struct { + State string + Liquidity struct { + TxHash string `json:"tx_hash"` + Address string `json:"address"` + Funded string `json:"funded_napro"` + } + Schedule []struct { + TxHash string `json:"tx_hash"` + Finalized bool `json:"finalized"` + Amount string `json:"amount_napro"` + } + } if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || rec.Code != 200 || response.State != "active" || response.Schedule[0].TxHash == "" || !response.Schedule[0].Finalized || - response.Schedule[1].TxHash != "" { t.Fatalf("canonical schedule evidence %d %s %v", rec.Code, rec.Body, err) } -if response.Liquidity.TxHash == "" || response.Liquidity.Address != string(treasuryAddress) || response.Liquidity.Funded != "30000000000000000" { -t.Fatal("public liquidity receipt missing or incorrect") -} + response.Schedule[1].TxHash != "" { + t.Fatalf("canonical schedule evidence %d %s %v", rec.Code, rec.Body, err) + } + if response.Liquidity.TxHash == "" || response.Liquidity.Address != string(treasuryAddress) || response.Liquidity.Funded != "30000000000000000" { + t.Fatal("public liquidity receipt missing or incorrect") + } var cursor [8]byte binary.LittleEndian.PutUint64(cursor[:], migration.Height) - if err := db.PutMeta("prune_cursor", cursor[:]); err != nil { t.Fatal(err) } - if _, err := db.PruneBlocksOlderThan(payment.Header.Height+1); err != nil { t.Fatal(err) } + if err := db.PutMeta("prune_cursor", cursor[:]); err != nil { + t.Fatal(err) + } + if _, err := db.PruneBlocksOlderThan(payment.Header.Height + 1); err != nil { + t.Fatal(err) + } for _, b := range []*core.Block{funding, payment} { raw, err := db.GetRawBlockByHeight(b.Header.Height) var saved core.Block @@ -128,7 +184,11 @@ t.Fatal("public liquidity receipt missing or incorrect") } // Canonical tip selection after a reorg selects its reserve checkpoint, // not the highest/orphaned payment flag. - if err := db.PutTip(funding.Hash(), funding.Header.Height); err != nil { t.Fatal(err) } + if err := db.PutTip(funding.Hash(), funding.Header.Height); err != nil { + t.Fatal(err) + } c, err = db.LoadLPoDCheckpoint() - if err != nil || c.GenesisVesting.Paid != 0 { t.Fatalf("orphan payment flag survived: %v", err) } -} \ No newline at end of file + if err != nil || c.GenesisVesting.Paid != 0 { + t.Fatalf("orphan payment flag survived: %v", err) + } +} diff --git a/api/genesis_vesting_wallet_test.go b/api/genesis_vesting_wallet_test.go index 28b60c8..88c9b8a 100644 --- a/api/genesis_vesting_wallet_test.go +++ b/api/genesis_vesting_wallet_test.go @@ -25,7 +25,9 @@ func TestGenesisVestingLiquiditySizeNativeReceiptAndSpend(t *testing.T) { func testNativeReserveReceiptAndSpend(t *testing.T, amount uint64) { owner, err := crypto.GenerateWalletKeys() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } address := crypto.AddressFromKeys(crypto.MainnetByte, owner) parent := crypto.HashBytes([]byte("annual-output-control-parent")) digest := crypto.HashBytes([]byte("annual-output-control-checkpoint")) @@ -35,11 +37,15 @@ func testNativeReserveReceiptAndSpend(t *testing.T, amount uint64) { to := address if i > 1 { other, err := crypto.GenerateWalletKeys() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } to = crypto.AddressFromKeys(crypto.MainnetByte, other) } out, err := core.BuildLPoDPayoutOutput(to, amount, i, parent, digest) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } tx := core.Transaction{Version: core.TxVersionLPoDPayout} tx.Outputs = append(tx.Outputs, out) b := &core.Block{Header: core.BlockHeader{Height: i}, Txs: []core.Transaction{tx}} @@ -51,18 +57,24 @@ func testNativeReserveReceiptAndSpend(t *testing.T, amount uint64) { t.Fatal("annual-size output is not owned, exact and spendable") } blind, err := crypto.DeterministicPaymentBlind(*hs, amount) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } controlled = core.OwnedUTXO{UTXO: *u, Amount: amount, Blind: blind, HsScalar: *hs} } } recipient, err := crypto.GenerateWalletKeys() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } spend, err := core.NewTxBuilder(owner.Spend.Private, owner.View.Private, owner.Spend.Public, []core.OwnedUTXO{controlled}, core.InitialBaseFeePerByte). WithVersion(core.TxVersionCLSAG).WithDecoySet(utxos). Build(100_000_000, crypto.AddressFromKeys(crypto.MainnetByte, recipient), address) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } if err := core.NewTxVerifier(utxos).VerifyTx(&spend.Tx); err != nil { t.Fatalf("annual-size native output cannot be spent: %v", err) } -} \ No newline at end of file +} diff --git a/api/lpod_audit_daily.go b/api/lpod_audit_daily.go index a426061..bf4dd5f 100644 --- a/api/lpod_audit_daily.go +++ b/api/lpod_audit_daily.go @@ -32,7 +32,7 @@ const ( type lpodDailyPosition struct { ID string `json:"id"` -Beneficiary string `json:"beneficiary"` + Beneficiary string `json:"beneficiary"` EffectiveVault string `json:"effective_vault"` EffectiveVaultAfter string `json:"effective_vault_after"` Accrued string `json:"accrued_napro"` @@ -49,8 +49,8 @@ type lpodDailyVault struct { } type lpodDailyPositionSum struct { -id, beneficiary, vault, vaultAfter string - accrued, paid, principal uint64 + id, beneficiary, vault, vaultAfter string + accrued, paid, principal uint64 } type lpodDailyVaultSum struct { id string @@ -60,26 +60,28 @@ type lpodDailyVaultSum struct { // The caller has already reconciled this row with the signed deposit opening // and both historical checkpoints. Closure must not erase its beneficiary. func accumulateDailyPosition(positions map[string]*lpodDailyPositionSum, p store.LPoDAuditPosition) error { -if p.ID == "" || p.Beneficiary == "" { -return fmt.Errorf("position beneficiary unavailable") -} -prior := positions[p.ID] -a := lpodDailyPositionSum{id: p.ID, beneficiary: p.Beneficiary, vault: p.EffectiveVault} -if prior != nil { a = *prior } -if a.beneficiary != p.Beneficiary { -return fmt.Errorf("position beneficiary changed") -} -a.vaultAfter = p.EffectiveVaultAfter -if !sumDaily(&a.accrued, p.Accrued) || !sumDaily(&a.paid, p.Paid) || !sumDaily(&a.principal, p.PrincipalReturned) { -return fmt.Errorf("position aggregate overflow") -} -positions[p.ID] = &a -return nil + if p.ID == "" || p.Beneficiary == "" { + return fmt.Errorf("position beneficiary unavailable") + } + prior := positions[p.ID] + a := lpodDailyPositionSum{id: p.ID, beneficiary: p.Beneficiary, vault: p.EffectiveVault} + if prior != nil { + a = *prior + } + if a.beneficiary != p.Beneficiary { + return fmt.Errorf("position beneficiary changed") + } + a.vaultAfter = p.EffectiveVaultAfter + if !sumDaily(&a.accrued, p.Accrued) || !sumDaily(&a.paid, p.Paid) || !sumDaily(&a.principal, p.PrincipalReturned) { + return fmt.Errorf("position aggregate overflow") + } + positions[p.ID] = &a + return nil } func dailyPositionOutput(p *lpodDailyPositionSum) lpodDailyPosition { -return lpodDailyPosition{ID: p.id, Beneficiary: p.beneficiary, EffectiveVault: p.vault, EffectiveVaultAfter: p.vaultAfter, -Accrued: strconv.FormatUint(p.accrued, 10), Paid: strconv.FormatUint(p.paid, 10), PrincipalReturned: strconv.FormatUint(p.principal, 10)} + return lpodDailyPosition{ID: p.id, Beneficiary: p.beneficiary, EffectiveVault: p.vault, EffectiveVaultAfter: p.vaultAfter, + Accrued: strconv.FormatUint(p.accrued, 10), Paid: strconv.FormatUint(p.paid, 10), PrincipalReturned: strconv.FormatUint(p.principal, 10)} } func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { @@ -126,7 +128,7 @@ func (s *Server) restLPoDAuditDaily(w http.ResponseWriter, r *http.Request) { }, } fail := func(reason string) { -out["status"] = "incomplete" + out["status"] = "incomplete" out["reason"] = reason writeJSON(w, http.StatusOK, out) } @@ -148,16 +150,16 @@ out["status"] = "incomplete" fail("durable finality certificate unavailable") return } -progress, background := r.Context().Value(dailyProgressKey{}).(*dailyProgress) -if background && progress.Certificate != nil { -if !s.dailyBindingValid(progress) { -fail("saved canonical certificate or finality binding invalid") -return -} -// Continue through today's latest verified certificate. On restart the -// oracle need not retain the old certificate; the resumed signed ancestry -// must connect the saved predecessor all the way to the current oracle. -} + progress, background := r.Context().Value(dailyProgressKey{}).(*dailyProgress) + if background && progress.Certificate != nil { + if !s.dailyBindingValid(progress) { + fail("saved canonical certificate or finality binding invalid") + return + } + // Continue through today's latest verified certificate. On restart the + // oracle need not retain the old certificate; the resumed signed ancestry + // must connect the saved predecessor all the way to the current oracle. + } if s.lpodFinalized == nil || !s.lpodFinalized(cert.Height, cert.BlockHash) { fail("finality certificate is not verified by the configured oracle") return @@ -180,9 +182,12 @@ return fail("LPoD audit coverage marker missing") return } -markerBytes, markerErr := json.Marshal(marker) -if markerErr != nil { fail("coverage marker cannot be encoded"); return } -markerDigest := crypto.HashBytes(markerBytes) + markerBytes, markerErr := json.Marshal(marker) + if markerErr != nil { + fail("coverage marker cannot be encoded") + return + } + markerDigest := crypto.HashBytes(markerBytes) readBlock := func(height uint64) (*core.Block, crypto.Hash32, error) { if err := ctx.Err(); err != nil { @@ -196,13 +201,15 @@ markerDigest := crypto.HashBytes(markerBytes) if readErr != nil || len(raw) == 0 { return nil, crypto.Hash32{}, fmt.Errorf("full block body unavailable at height %d", height) } -if len(raw) > dailyJobMaxBytes { return nil, crypto.Hash32{}, fmt.Errorf("block exceeds daily verifier byte bound") } + if len(raw) > dailyJobMaxBytes { + return nil, crypto.Hash32{}, fmt.Errorf("block exceeds daily verifier byte bound") + } var block core.Block if readErr = json.Unmarshal(raw, &block); readErr != nil { return nil, crypto.Hash32{}, fmt.Errorf("invalid block body at height %d", height) } if block.Header.Height != height || block.Hash() != hash || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) || -!s.verifyAuditHeader(&block.Header) { + !s.verifyAuditHeader(&block.Header) { return nil, crypto.Hash32{}, fmt.Errorf("canonical block identity or Merkle proof invalid at height %d", height) } return &block, hash, nil @@ -216,11 +223,13 @@ if len(raw) > dailyJobMaxBytes { return nil, crypto.Hash32{}, fmt.Errorf("block if err != nil || len(raw) == 0 { return 0, fmt.Errorf("canonical block unavailable at timestamp probe height %d", height) } -if len(raw) > dailyJobMaxBytes { return 0, fmt.Errorf("timestamp probe exceeds daily verifier byte bound") } + if len(raw) > dailyJobMaxBytes { + return 0, fmt.Errorf("timestamp probe exceeds daily verifier byte bound") + } var block core.Block if err := json.Unmarshal(raw, &block); err == nil && block.Header.Height == height && block.Hash() == hash && -block.Header.MerkleRoot == core.MerkleRoot(block.Txs) && s.verifyAuditHeader(&block.Header) { + block.Header.MerkleRoot == core.MerkleRoot(block.Txs) && s.verifyAuditHeader(&block.Header) { return block.Header.Timestamp, nil } // Pruned StoredBlock metadata is unsigned and is only a binary-search @@ -379,10 +388,10 @@ block.Header.MerkleRoot == core.MerkleRoot(block.Txs) && s.verifyAuditHeader(&bl witnessHash := witnessBlock.Hash() witnessTimestamp := witnessBlock.Header.Timestamp anchorTimestamp := anchorBlock.Header.Timestamp -batchBinding := &dailyProgress{ -Certificate: cert, Anchor: anchorHash, Witness: witnessHash, -AnchorHeight: anchorHeight, WitnessHeight: witnessHeight, Marker: markerDigest, Prune: pruneCursorRaw, -} + batchBinding := &dailyProgress{ + Certificate: cert, Anchor: anchorHash, Witness: witnessHash, + AnchorHeight: anchorHeight, WitnessHeight: witnessHeight, Marker: markerDigest, Prune: pruneCursorRaw, + } out["opening_anchor"] = map[string]interface{}{ "height": anchorHeight, "hash": fmt.Sprintf("%x", anchorHash[:]), "header_timestamp": anchorTimestamp, "timestamp": time.Unix(0, anchorTimestamp).Format(time.RFC3339Nano), @@ -427,7 +436,7 @@ AnchorHeight: anchorHeight, WitnessHeight: witnessHeight, Marker: markerDigest, checkpointCache := make(map[crypto.Hash32]*store.LPoDCheckpoint, 2) vaults := make(map[string]*lpodDailyVaultSum) positions := make(map[string]*lpodDailyPositionSum) -rewards := newDailyRewardProgress() + rewards := newDailyRewardProgress() var burnBase, burnIntentional, burnAVM, burnTotal uint64 var included uint64 var lastAuditDigest crypto.Hash32 @@ -435,61 +444,69 @@ rewards := newDailyRewardProgress() firstIncluded, lastIncluded := uint64(0), uint64(0) var firstTimestamp, lastTimestamp int64 var expectedNextRegistry *core.RegistrySnapshot -nextHeight := anchorHeight -if background && progress.Next != 0 { -if progress.Anchor != anchorHash || progress.Witness != witnessHash || progress.Marker != markerDigest || -!bytes.Equal(progress.Prune, pruneCursorRaw) || progress.Next <= anchorHeight || cert.Height == ^uint64(0) || progress.Next > cert.Height+1 { -fail("saved boundary or prune binding changed") -return -} -previousHash, found, err := s.blockStore.GetCanonicalHash(progress.Next-1) -if err != nil || !found || previousHash != progress.Previous { -fail("saved progress predecessor is no longer canonical") -return -} -nextHeight, prevHash, priorTS = progress.Next, progress.Previous, progress.PriorTS -rewards = progress.Rewards.clone() // Old authenticated progress cannot invent new report evidence. -previousBlock, _, err = readBlock(nextHeight-1) -if err != nil { fail("saved predecessor body unavailable"); return } -for id, v := range progress.Vaults { -vaults[id] = &lpodDailyVaultSum{id: id, accrued: v[0], leader: v[1], guardian: v[2], unfunded: v[3]} -} -for id, p := range progress.Positions { -positions[id] = &lpodDailyPositionSum{id: id, beneficiary: p.Beneficiary, vault: p.Vault, vaultAfter: p.After, accrued: p.Accrued, paid: p.Paid, principal: p.Principal} -} -burnBase, burnIntentional, burnAVM, burnTotal = progress.Burns[0], progress.Burns[1], progress.Burns[2], progress.Burns[3] -included, firstIncluded, lastIncluded = progress.Included, progress.First, progress.Last -firstTimestamp, lastTimestamp = progress.FirstTS, progress.LastTS -lastAuditDigest, haveLastAuditDigest, expectedNextRegistry = progress.Digest, progress.HaveDigest, progress.Registry -} -checkpointProgress := func(next uint64) { -progress.Certificate, progress.Anchor, progress.Witness = cert, anchorHash, witnessHash -progress.AnchorHeight, progress.WitnessHeight, progress.Marker = anchorHeight, witnessHeight, markerDigest -progress.Prune = append([]byte(nil), pruneCursorRaw...) -progress.Next, progress.Previous, progress.PriorTS = next, prevHash, priorTS -progress.Rewards = rewards.clone() -progress.Vaults = make(map[string][4]uint64, len(vaults)) -for id, v := range vaults { progress.Vaults[id] = [4]uint64{v.accrued, v.leader, v.guardian, v.unfunded} } -progress.Positions = make(map[string]dailyPositionProgress, len(positions)) -for id, p := range positions { progress.Positions[id] = dailyPositionProgress{Vault: p.vault, After: p.vaultAfter, Beneficiary: p.beneficiary, Accrued: p.accrued, Paid: p.paid, Principal: p.principal} } -progress.Burns = [4]uint64{burnBase, burnIntentional, burnAVM, burnTotal} -progress.Included, progress.First, progress.Last = included, firstIncluded, lastIncluded -progress.FirstTS, progress.LastTS = firstTimestamp, lastTimestamp -progress.Digest, progress.HaveDigest, progress.Registry = lastAuditDigest, haveLastAuditDigest, expectedNextRegistry -} -batchStarted := time.Now() -for h := nextHeight; h <= cert.Height; h++ { -// Cooperative budget at verified block boundaries. A single block is not -// preemptible; the outer deadline cancels between verifier operations. -if background && h > nextHeight && (h-nextHeight >= dailyBatchBlocks || time.Since(batchStarted) >= dailyBatchTime) { -if !s.dailyBindingValid(batchBinding) { -fail("canonical certificate or finality changed during batch"); return -} -checkpointProgress(h) -out["coverage"] = map[string]interface{}{"blocks": included, "vaults": len(vaults), "positions": len(positions)} -fail("bounded background verification in progress") -return -} + nextHeight := anchorHeight + if background && progress.Next != 0 { + if progress.Anchor != anchorHash || progress.Witness != witnessHash || progress.Marker != markerDigest || + !bytes.Equal(progress.Prune, pruneCursorRaw) || progress.Next <= anchorHeight || cert.Height == ^uint64(0) || progress.Next > cert.Height+1 { + fail("saved boundary or prune binding changed") + return + } + previousHash, found, err := s.blockStore.GetCanonicalHash(progress.Next - 1) + if err != nil || !found || previousHash != progress.Previous { + fail("saved progress predecessor is no longer canonical") + return + } + nextHeight, prevHash, priorTS = progress.Next, progress.Previous, progress.PriorTS + rewards = progress.Rewards.clone() // Old authenticated progress cannot invent new report evidence. + previousBlock, _, err = readBlock(nextHeight - 1) + if err != nil { + fail("saved predecessor body unavailable") + return + } + for id, v := range progress.Vaults { + vaults[id] = &lpodDailyVaultSum{id: id, accrued: v[0], leader: v[1], guardian: v[2], unfunded: v[3]} + } + for id, p := range progress.Positions { + positions[id] = &lpodDailyPositionSum{id: id, beneficiary: p.Beneficiary, vault: p.Vault, vaultAfter: p.After, accrued: p.Accrued, paid: p.Paid, principal: p.Principal} + } + burnBase, burnIntentional, burnAVM, burnTotal = progress.Burns[0], progress.Burns[1], progress.Burns[2], progress.Burns[3] + included, firstIncluded, lastIncluded = progress.Included, progress.First, progress.Last + firstTimestamp, lastTimestamp = progress.FirstTS, progress.LastTS + lastAuditDigest, haveLastAuditDigest, expectedNextRegistry = progress.Digest, progress.HaveDigest, progress.Registry + } + checkpointProgress := func(next uint64) { + progress.Certificate, progress.Anchor, progress.Witness = cert, anchorHash, witnessHash + progress.AnchorHeight, progress.WitnessHeight, progress.Marker = anchorHeight, witnessHeight, markerDigest + progress.Prune = append([]byte(nil), pruneCursorRaw...) + progress.Next, progress.Previous, progress.PriorTS = next, prevHash, priorTS + progress.Rewards = rewards.clone() + progress.Vaults = make(map[string][4]uint64, len(vaults)) + for id, v := range vaults { + progress.Vaults[id] = [4]uint64{v.accrued, v.leader, v.guardian, v.unfunded} + } + progress.Positions = make(map[string]dailyPositionProgress, len(positions)) + for id, p := range positions { + progress.Positions[id] = dailyPositionProgress{Vault: p.vault, After: p.vaultAfter, Beneficiary: p.beneficiary, Accrued: p.accrued, Paid: p.paid, Principal: p.principal} + } + progress.Burns = [4]uint64{burnBase, burnIntentional, burnAVM, burnTotal} + progress.Included, progress.First, progress.Last = included, firstIncluded, lastIncluded + progress.FirstTS, progress.LastTS = firstTimestamp, lastTimestamp + progress.Digest, progress.HaveDigest, progress.Registry = lastAuditDigest, haveLastAuditDigest, expectedNextRegistry + } + batchStarted := time.Now() + for h := nextHeight; h <= cert.Height; h++ { + // Cooperative budget at verified block boundaries. A single block is not + // preemptible; the outer deadline cancels between verifier operations. + if background && h > nextHeight && (h-nextHeight >= dailyBatchBlocks || time.Since(batchStarted) >= dailyBatchTime) { + if !s.dailyBindingValid(batchBinding) { + fail("canonical certificate or finality changed during batch") + return + } + checkpointProgress(h) + out["coverage"] = map[string]interface{}{"blocks": included, "vaults": len(vaults), "positions": len(positions)} + fail("bounded background verification in progress") + return + } if err := ctx.Err(); err != nil { fail(dailyAuditContextReason(err)) return @@ -527,14 +544,15 @@ return fail("interval exceeds 40000-block bound") return } -record, auditErr := s.blockStore.LoadLPoDAuditAtBounded(hash, dailyJobMaxBytes) + record, auditErr := s.blockStore.LoadLPoDAuditAtBounded(hash, dailyJobMaxBytes) if auditErr != nil || record == nil { fail(fmt.Sprintf("exact block audit record missing at height %d", h)) return } -if len(record.Vaults)+len(record.Positions) > lpodDailyMaxEntries { -fail("block audit entry count exceeds bound"); return -} + if len(record.Vaults)+len(record.Positions) > lpodDailyMaxEntries { + fail("block audit entry count exceeds bound") + return + } if h == firstIncluded && marker.FundingHeight != 0 && (record.FundingHeight != marker.FundingHeight || record.FundingGenesis != marker.Genesis || record.FundingRoot != marker.Root) { @@ -572,9 +590,12 @@ fail("block audit entry count exceeds bound"); return return } lastAuditDigest, haveLastAuditDigest = record.AfterCheckpointDigest, true -if rewards != nil { -if err := rewards.accumulate(block, record); err != nil { fail(err.Error()); return } -} + if rewards != nil { + if err := rewards.accumulate(block, record); err != nil { + fail(err.Error()) + return + } + } if len(vaults)+len(positions)+len(record.Vaults)+len(record.Positions) > lpodDailyMaxEntries { fail("response entry count exceeds bound") return @@ -600,8 +621,8 @@ if err := rewards.accumulate(block, record); err != nil { fail(err.Error()); ret fail(dailyAuditContextReason(err)) return } -if err := accumulateDailyPosition(positions, p); err != nil { -fail(err.Error()) + if err := accumulateDailyPosition(positions, p); err != nil { + fail(err.Error()) return } } @@ -644,7 +665,7 @@ fail(err.Error()) afterTip, afterTipHeight, tipErr := s.blockStore.GetTip() afterCert, certFound, certErr := s.blockStore.GetCanonicalHash(cert.Height) afterPruneCursor, pruneCursorErr := s.blockStore.GetMeta("prune_cursor") -if tipErr != nil || (!background && (afterTip != tipHash || afterTipHeight != tipHeight)) || + if tipErr != nil || (!background && (afterTip != tipHash || afterTipHeight != tipHeight)) || certErr != nil || !certFound || afterCert != cert.BlockHash || pruneCursorErr != nil || !bytes.Equal(afterPruneCursor, pruneCursorRaw) { fail("canonical tip, certificate, or prune cursor changed during audit") @@ -659,13 +680,13 @@ if tipErr != nil || (!background && (afterTip != tipHash || afterTipHeight != ti return } latestCert, latestCertErr := s.blockStore.LoadFinalityCertificate() -if latestCertErr != nil || latestCert == nil || -(!background && (latestCert.Height != cert.Height || latestCert.BlockHash != cert.BlockHash)) || -(background && !s.dailyBindingValid(batchBinding)) { + if latestCertErr != nil || latestCert == nil || + (!background && (latestCert.Height != cert.Height || latestCert.BlockHash != cert.BlockHash)) || + (background && !s.dailyBindingValid(batchBinding)) { fail("durable certificate changed during audit") return } -finalCheckpoint, checkpointErr := s.blockStore.LoadLPoDCheckpointAtBounded(cert.BlockHash, dailyJobMaxBytes) + finalCheckpoint, checkpointErr := s.blockStore.LoadLPoDCheckpointAtBounded(cert.BlockHash, dailyJobMaxBytes) if checkpointErr != nil || finalCheckpoint == nil || finalCheckpoint.Allocation == nil { fail("certified LPoD funding identity unavailable") return @@ -675,7 +696,7 @@ finalCheckpoint, checkpointErr := s.blockStore.LoadLPoDCheckpointAtBounded(cert. return } lastAuditHash, lastAuditFound, lastAuditErr := s.blockStore.GetCanonicalHash(lastIncluded) -lastAuditCheckpoint, lastAuditCPError := s.blockStore.LoadLPoDCheckpointAtBounded(lastAuditHash, dailyJobMaxBytes) + lastAuditCheckpoint, lastAuditCPError := s.blockStore.LoadLPoDCheckpointAtBounded(lastAuditHash, dailyJobMaxBytes) if lastAuditErr != nil || !lastAuditFound || lastAuditCPError != nil || lastAuditCheckpoint == nil || verifyDailyFundingIdentity(lastAuditCheckpoint, finalCheckpoint) != nil { fail("funding identity does not continue from interval through certificate") @@ -696,18 +717,24 @@ lastAuditCheckpoint, lastAuditCPError := s.blockStore.LoadLPoDCheckpointAtBounde sort.Slice(vaultList, func(i, j int) bool { return vaultList[i].ID < vaultList[j].ID }) positionList := make([]lpodDailyPosition, 0, len(positions)) for _, p := range positions { -positionList = append(positionList, dailyPositionOutput(p)) + positionList = append(positionList, dailyPositionOutput(p)) } sort.Slice(positionList, func(i, j int) bool { return positionList[i].ID < positionList[j].ID }) out["vaults"], out["positions"] = vaultList, positionList -out["beneficiary_version"] = 1 -if rewards != nil { -openingCheckpoint, openingErr := s.blockStore.LoadLPoDCheckpointAtBounded(anchorHash, dailyJobMaxBytes) -if openingErr == nil { -summary, summaryErr := rewards.result(openingCheckpoint, lastAuditCheckpoint) -if summaryErr == nil { out["daily_rewards"] = summary } else { out["daily_rewards_unavailable"] = summaryErr.Error() } -} else { out["daily_rewards_unavailable"] = "opening reward funds unavailable" } -} + out["beneficiary_version"] = 1 + if rewards != nil { + openingCheckpoint, openingErr := s.blockStore.LoadLPoDCheckpointAtBounded(anchorHash, dailyJobMaxBytes) + if openingErr == nil { + summary, summaryErr := rewards.result(openingCheckpoint, lastAuditCheckpoint) + if summaryErr == nil { + out["daily_rewards"] = summary + } else { + out["daily_rewards_unavailable"] = summaryErr.Error() + } + } else { + out["daily_rewards_unavailable"] = "opening reward funds unavailable" + } + } out["burns_napro"] = map[string]string{ "protocol_base_fee": strconv.FormatUint(burnBase, 10), "signed_intentional": strconv.FormatUint(burnIntentional, 10), "avm_gas": strconv.FormatUint(burnAVM, 10), "total": strconv.FormatUint(burnTotal, 10), @@ -724,10 +751,13 @@ if summaryErr == nil { out["daily_rewards"] = summary } else { out["daily_reward return } out["status"], out["reason"] = "complete", "" -if background { -if cert.Height == ^uint64(0) { fail("certificate height overflows progress"); return } -checkpointProgress(cert.Height+1) -} + if background { + if cert.Height == ^uint64(0) { + fail("certificate height overflows progress") + return + } + checkpointProgress(cert.Height + 1) + } writeJSON(w, http.StatusOK, out) } @@ -735,7 +765,7 @@ func dailyAuditContextReason(err error) string { if err == context.DeadlineExceeded { return "daily audit exceeded its 20-second work budget" } - return "daily audit request was cancelled" + return "daily audit request was canceled" } func sumDaily(dst *uint64, value uint64) bool { @@ -886,9 +916,9 @@ func (s *Server) verifyDailyRegistryEvidence( } replay := core.NewValidatorRegistry() replay.RestoreFromSnapshot(replayStart) -if err := replay.ConfigureSignatureAuthority(s.validatorAuthorityFork, block.Header.Height); err != nil { -return fail(fmt.Errorf("configure pinned signature authority replay: %w", err)) -} + if err := replay.ConfigureSignatureAuthority(s.validatorAuthorityFork, block.Header.Height); err != nil { + return fail(fmt.Errorf("configure pinned signature authority replay: %w", err)) + } if withdrawalV2 { if err := replay.ConfigureStakeWithdrawalV2(withdrawalGenesis, withdrawalActivation); err != nil { return fail(fmt.Errorf("configure v2 withdrawal replay: %w", err)) @@ -979,7 +1009,7 @@ func (s *Server) verifyDailyLPoDAuditWithCache( return checkpoint, nil } } -checkpoint, err := s.blockStore.LoadLPoDCheckpointAtBounded(hash, dailyJobMaxBytes) + checkpoint, err := s.blockStore.LoadLPoDCheckpointAtBounded(hash, dailyJobMaxBytes) if err != nil || checkpoint == nil || checkpoints == nil { return checkpoint, err } @@ -1188,7 +1218,7 @@ func (s *Server) verifyDailyPositionRowsWithParent( parentRaw, readErr := s.blockStore.GetRawBlock(parentHash) var decoded core.Block if readErr != nil || len(parentRaw) == 0 || json.Unmarshal(parentRaw, &decoded) != nil || -decoded.Hash() != parentHash || !s.verifyAuditHeader(&decoded.Header) || + decoded.Hash() != parentHash || !s.verifyAuditHeader(&decoded.Header) || decoded.Header.MerkleRoot != core.MerkleRoot(decoded.Txs) { return fail(fmt.Errorf("position accrual parent body or timestamp unavailable")) } diff --git a/api/lpod_audit_daily_benchmark_test.go b/api/lpod_audit_daily_benchmark_test.go index 2facb65..d64987d 100644 --- a/api/lpod_audit_daily_benchmark_test.go +++ b/api/lpod_audit_daily_benchmark_test.go @@ -23,12 +23,13 @@ import ( "testing" "time" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" "github.com/aperod/aperod/store" - "github.com/syndtr/goleveldb/leveldb" - "github.com/syndtr/goleveldb/leveldb/opt" ) // Opt-in full-path synthetic load benchmark. Example: @@ -126,14 +127,14 @@ func BenchmarkLPoDAuditDailySynthetic(b *testing.B) { } // These checks use the same authenticated test server/fixture as the // benchmark and guard the request cancellation and single-flight behavior. - cancelled := authenticatedDailyAuditRequest() - cancelCtx, cancel := context.WithCancel(cancelled.Context()) + canceled := authenticatedDailyAuditRequest() + cancelCtx, cancel := context.WithCancel(canceled.Context()) cancel() - cancelled = cancelled.WithContext(cancelCtx) + canceled = canceled.WithContext(cancelCtx) cancelRecorder := httptest.NewRecorder() - server.ServeHTTP(cancelRecorder, cancelled) - if cancelRecorder.Code != http.StatusOK || !strings.Contains(cancelRecorder.Body.String(), "request was cancelled") { - b.Fatalf("cancelled request status=%d body=%s", cancelRecorder.Code, cancelRecorder.Body.String()) + server.ServeHTTP(cancelRecorder, canceled) + if cancelRecorder.Code != http.StatusOK || !strings.Contains(cancelRecorder.Body.String(), "request was canceled") { + b.Fatalf("canceled request status=%d body=%s", cancelRecorder.Code, cancelRecorder.Body.String()) } var beforeUsage syscall.Rusage @@ -399,15 +400,15 @@ func makeDailyAuditBenchmarkFixtureWithTail( preludeBlocks, positionCount, tailBlocks int, misorderFirstIncluded ...bool, ) (*store.DB, *Server, string, int64) { -misorder := len(misorderFirstIncluded) > 0 && misorderFirstIncluded[0] -return makeDailyAuditBenchmarkFixtureWithOptions(b, includedBlocks, fillerCount, extraBytes, vaultCount, -preludeBlocks, positionCount, tailBlocks, misorder, false) + misorder := len(misorderFirstIncluded) > 0 && misorderFirstIncluded[0] + return makeDailyAuditBenchmarkFixtureWithOptions(b, includedBlocks, fillerCount, extraBytes, vaultCount, + preludeBlocks, positionCount, tailBlocks, misorder, false) } func makeDailyAuditBenchmarkFixtureWithOptions( -b testing.TB, includedBlocks, fillerCount, extraBytes, vaultCount int, -preludeBlocks, positionCount, tailBlocks int, misorderFirstIncluded, rotateAuthority bool, -retiredOutput ...*crypto.ValidatorPrivKey, + b testing.TB, includedBlocks, fillerCount, extraBytes, vaultCount int, + preludeBlocks, positionCount, tailBlocks int, misorderFirstIncluded, rotateAuthority bool, + retiredOutput ...*crypto.ValidatorPrivKey, ) (*store.DB, *Server, string, int64) { b.Helper() if preludeBlocks < 0 || tailBlocks < 0 { @@ -434,19 +435,23 @@ retiredOutput ...*crypto.ValidatorPrivKey, if err != nil { b.Fatal(err) } -if len(retiredOutput) > 0 { *retiredOutput[0] = priv } + if len(retiredOutput) > 0 { + *retiredOutput[0] = priv + } walletKeys, err := crypto.GenerateWalletKeys() if err != nil { rawDB.Close() b.Fatal(err) } leader := crypto.AddressFromKeys(crypto.MainnetByte, walletKeys) -var fork *core.ValidatorAuthorityFork -var successor crypto.ValidatorPrivKey -if rotateAuthority { -successor, _, err = crypto.GenerateValidatorKey() -if err != nil { b.Fatal(err) } -} + var fork *core.ValidatorAuthorityFork + var successor crypto.ValidatorPrivKey + if rotateAuthority { + successor, _, err = crypto.GenerateValidatorKey() + if err != nil { + b.Fatal(err) + } + } location, err := time.LoadLocation("Europe/Moscow") if err != nil { b.Fatal(err) @@ -464,8 +469,10 @@ if err != nil { b.Fatal(err) } registryAfter := core.RegistrySnapshot{Validators: make(map[string]*core.ValidatorEntry, vaultCount)} previousStake := make(map[string]store.LPoDValidatorStake, vaultCount) stake := make(map[string]store.LPoDValidatorStake, vaultCount) -validatorStake := uint64(100_000_000) * lpod.Unit -if rotateAuthority { validatorStake = uint64(99_000_000) * lpod.Unit } + validatorStake := uint64(100_000_000) * lpod.Unit + if rotateAuthority { + validatorStake = uint64(99_000_000) * lpod.Unit + } const guardianStake = uint64(0) totalStake := validatorStake + guardianStake if vaultCount > 0 { @@ -562,10 +569,11 @@ if rotateAuthority { validatorStake = uint64(99_000_000) * lpod.Unit } // common benchmark scenario. timestamp = start.Add(-3 * time.Second) } -if misorderFirstIncluded { - if height == firstIncludedHeight { + if misorderFirstIncluded { + switch height { + case firstIncludedHeight: timestamp = start.Add(2 * time.Hour) - } else if height == firstIncludedHeight+1 { + case firstIncludedHeight + 1: timestamp = start.Add(time.Hour) } } @@ -656,12 +664,14 @@ if misorderFirstIncluded { } } checkpointDigest := checkpoint.Digest() -if rotateAuthority && height == 2 { -fork = &core.ValidatorAuthorityFork{Version: 1, Height: height, Identity: pub, Replacement: successor.Public(), -Genesis: genesisHash, Parent: parentHash, RegistrySHA256: crypto.HashBytes([]byte("operator-attested-fixture"))} -fork.Possession, err = successor.Sign(fork.Message()) -if err != nil { b.Fatal(err) } -} + if rotateAuthority && height == 2 { + fork = &core.ValidatorAuthorityFork{Version: 1, Height: height, Identity: pub, Replacement: successor.Public(), + Genesis: genesisHash, Parent: parentHash, RegistrySHA256: crypto.HashBytes([]byte("operator-attested-fixture"))} + fork.Possession, err = successor.Sign(fork.Message()) + if err != nil { + b.Fatal(err) + } + } txs := make([]core.Transaction, 0, 2+fillerCount+len(positionTxs)) if height > 0 { // An empty-output payout remains a valid zero-settlement commitment. @@ -674,15 +684,19 @@ if err != nil { b.Fatal(err) } core.Transaction{Version: core.TxVersionLPoDPayout, Extra: auth}, core.LPoDCheckpointTx(checkpointDigest)) txs = append(txs, positionTxs...) -if rotateAuthority && height == 3 { -for _, action := range []core.StakeAction{core.StakeDeposit, core.StakePartialWithdraw} { -signature, signErr := successor.Sign(core.StakeSignMsg(action, pub, core.MinStakeNAPR)) -if signErr != nil { b.Fatal(signErr) } -extra, encodeErr := core.EncodeStakeExtra(action, pub, core.MinStakeNAPR, signature) -if encodeErr != nil { b.Fatal(encodeErr) } -txs = append(txs, core.Transaction{Version: core.TxVersionStake, Extra: extra}) -} -} + if rotateAuthority && height == 3 { + for _, action := range []core.StakeAction{core.StakeDeposit, core.StakePartialWithdraw} { + signature, signErr := successor.Sign(core.StakeSignMsg(action, pub, core.MinStakeNAPR)) + if signErr != nil { + b.Fatal(signErr) + } + extra, encodeErr := core.EncodeStakeExtra(action, pub, core.MinStakeNAPR, signature) + if encodeErr != nil { + b.Fatal(encodeErr) + } + txs = append(txs, core.Transaction{Version: core.TxVersionStake, Extra: extra}) + } + } for n := 0; n < fillerCount; n++ { txs = append(txs, core.Transaction{Version: core.TxVersionBase, Extra: make([]byte, extraBytes)}) } @@ -691,9 +705,11 @@ txs = append(txs, core.Transaction{Version: core.TxVersionStake, Extra: extra}) Height: height, PrevHash: parentHash, Timestamp: timestamp.UnixNano(), ValidatorPub: pub, MerkleRoot: core.MerkleRoot(txs), }, Txs: txs} -signer := priv -if fork != nil && height >= fork.Height { signer = successor } -if err := block.Header.Sign(signer); err != nil { + signer := priv + if fork != nil && height >= fork.Height { + signer = successor + } + if err := block.Header.Sign(signer); err != nil { rawDB.Close() b.Fatal(err) } @@ -724,28 +740,38 @@ if err := block.Header.Sign(signer); err != nil { } batch.Put(append([]byte("lpod/checkpoint/v1/"), hash[:]...), checkpointRaw) if height >= firstIncludedHeight && height < closingHeight { -if rotateAuthority { -beforeRaw, _ := json.Marshal(registryAfter) -registryBefore = core.RegistrySnapshot{} -if err := json.Unmarshal(beforeRaw, ®istryBefore); err != nil { b.Fatal(err) } -replay := core.NewValidatorRegistry() -var replayStart core.RegistrySnapshot -if err := json.Unmarshal(beforeRaw, &replayStart); err != nil { b.Fatal(err) } -replay.RestoreFromSnapshot(replayStart) -if err := replay.ConfigureSignatureAuthority(fork, height); err != nil { b.Fatal(err) } -if err := replay.ReplayBlockStakeTxs(txs, height); err != nil { b.Fatal(err) } -registryAfter = replay.TakeSnapshot() -previousStake = map[string]store.LPoDValidatorStake{pub.Hex(): {Amount: validatorStake, Active: true}} -stake = map[string]store.LPoDValidatorStake{pub.Hex(): {Amount: validatorStake, Active: true}} -if height == 3 { stake[pub.Hex()] = store.LPoDValidatorStake{Amount: validatorStake-core.MinStakeNAPR, Active: true} } -} + if rotateAuthority { + beforeRaw, _ := json.Marshal(registryAfter) + registryBefore = core.RegistrySnapshot{} + if err := json.Unmarshal(beforeRaw, ®istryBefore); err != nil { + b.Fatal(err) + } + replay := core.NewValidatorRegistry() + var replayStart core.RegistrySnapshot + if err := json.Unmarshal(beforeRaw, &replayStart); err != nil { + b.Fatal(err) + } + replay.RestoreFromSnapshot(replayStart) + if err := replay.ConfigureSignatureAuthority(fork, height); err != nil { + b.Fatal(err) + } + if err := replay.ReplayBlockStakeTxs(txs, height); err != nil { + b.Fatal(err) + } + registryAfter = replay.TakeSnapshot() + previousStake = map[string]store.LPoDValidatorStake{pub.Hex(): {Amount: validatorStake, Active: true}} + stake = map[string]store.LPoDValidatorStake{pub.Hex(): {Amount: validatorStake, Active: true}} + if height == 3 { + stake[pub.Hex()] = store.LPoDValidatorStake{Amount: validatorStake - core.MinStakeNAPR, Active: true} + } + } recordVaults := append([]store.LPoDAuditVault(nil), vaultRows...) for i := range recordVaults { -if rotateAuthority && height == 3 && recordVaults[i].ID == pub.Hex() { -recordVaults[i].ValidatorStake -= core.MinStakeNAPR -recordVaults[i].TotalStake -= core.MinStakeNAPR -recordVaults[i].TierStake -= core.MinStakeNAPR -} + if rotateAuthority && height == 3 && recordVaults[i].ID == pub.Hex() { + recordVaults[i].ValidatorStake -= core.MinStakeNAPR + recordVaults[i].TotalStake -= core.MinStakeNAPR + recordVaults[i].TierStake -= core.MinStakeNAPR + } recordVaults[i].Accrued = blockAccruedByVault[recordVaults[i].ID] recordVaults[i].Unfunded = recordVaults[i].Accrued } @@ -851,13 +877,17 @@ recordVaults[i].TierStake -= core.MinStakeNAPR server.SetAPIKey(dailyAuditBenchmarkAPIKey) server.SetLPoDConfig(nil, func(uint64, crypto.Hash32) bool { return true }) benchmarkDailySigningKey(b, server) -if rotateAuthority { -key, keyErr := crypto.NewLockedValidatorKey(successor.Bytes(), nil) -if keyErr != nil { b.Fatal(keyErr) } -b.Cleanup(key.Destroy) -server.SetValidatorKey(key) -if err := server.SetAuditValidatorAuthorityFork(fork); err != nil { b.Fatal(err) } -} + if rotateAuthority { + key, keyErr := crypto.NewLockedValidatorKey(successor.Bytes(), nil) + if keyErr != nil { + b.Fatal(keyErr) + } + b.Cleanup(key.Destroy) + server.SetValidatorKey(key) + if err := server.SetAuditValidatorAuthorityFork(fork); err != nil { + b.Fatal(err) + } + } return db, server, path, blockBodyBytes } diff --git a/api/lpod_audit_daily_eviction_linux_test.go b/api/lpod_audit_daily_eviction_linux_test.go index 4c18187..1ee2737 100644 --- a/api/lpod_audit_daily_eviction_linux_test.go +++ b/api/lpod_audit_daily_eviction_linux_test.go @@ -3,10 +3,11 @@ package api import ( - "golang.org/x/sys/unix" "os" "path/filepath" "testing" + + "golang.org/x/sys/unix" ) // Advisory eviction of the closed synthetic fixture only, never global caches. diff --git a/api/lpod_audit_daily_hardening_test.go b/api/lpod_audit_daily_hardening_test.go index 2b34f16..3f7ba7c 100644 --- a/api/lpod_audit_daily_hardening_test.go +++ b/api/lpod_audit_daily_hardening_test.go @@ -228,8 +228,8 @@ func TestDailyAuditCanceledRequestStopsBeforeStoreScan(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/?date=2024-01-02", nil).WithContext(ctx) recorder := httptest.NewRecorder() server.restLPoDAuditDaily(recorder, request) - if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "request was cancelled") { - t.Fatalf("cancelled request response %d: %s", recorder.Code, recorder.Body.String()) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "request was canceled") { + t.Fatalf("canceled request response %d: %s", recorder.Code, recorder.Body.String()) } } diff --git a/api/lpod_audit_daily_test.go b/api/lpod_audit_daily_test.go index 22fc022..538b8ce 100644 --- a/api/lpod_audit_daily_test.go +++ b/api/lpod_audit_daily_test.go @@ -12,12 +12,13 @@ import ( "testing" "time" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/api" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" "github.com/aperod/aperod/store" - "github.com/syndtr/goleveldb/leveldb" ) const dailyAuditTestAPIKey = "daily-audit-test-admin-key" @@ -335,10 +336,12 @@ func TestLPoDAuditDailyFailsClosedWhenTimestampProbeBodyIsPruned(t *testing.T) { srv.SetLPoDConfig(nil, func(height uint64, hash crypto.Hash32) bool { return height == 14 && hash == certHash }) -key, err := crypto.NewLockedValidatorKey(priv.Bytes(), nil) -if err != nil { t.Fatal(err) } -t.Cleanup(key.Destroy) -srv.SetValidatorKey(key) + key, err := crypto.NewLockedValidatorKey(priv.Bytes(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(key.Destroy) + srv.SetValidatorKey(key) code, body := dailyAuditGet(t, srv, "/api/v1/lpod/audit/daily?date=2024-01-02") if code != http.StatusOK || body["status"] != "incomplete" || !containsText(body["reason"].(string), "signed-header metadata is unavailable") || @@ -568,10 +571,12 @@ func dailyAuditFixture(t *testing.T, markerHeight uint64) (*api.Server, *store.D srv := api.NewServer(":0", nil, nil, nil, testLogger()) srv.SetStore(db) srv.SetAPIKey(dailyAuditTestAPIKey) -key, err := crypto.NewLockedValidatorKey(priv.Bytes(), nil) -if err != nil { t.Fatal(err) } -t.Cleanup(key.Destroy) -srv.SetValidatorKey(key) + key, err := crypto.NewLockedValidatorKey(priv.Bytes(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(key.Destroy) + srv.SetValidatorKey(key) return srv, db, hashes, path } @@ -714,37 +719,41 @@ func dailyAuditCompleteFixture(t *testing.T) (*api.Server, *store.DB, []crypto.H srv := api.NewServer(":0", nil, nil, nil, testLogger()) srv.SetStore(db) srv.SetAPIKey(dailyAuditTestAPIKey) -key, err := crypto.NewLockedValidatorKey(priv.Bytes(), nil) -if err != nil { t.Fatal(err) } -t.Cleanup(key.Destroy) -srv.SetValidatorKey(key) + key, err := crypto.NewLockedValidatorKey(priv.Bytes(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(key.Destroy) + srv.SetValidatorKey(key) return srv, db, hashes, path } func dailyAuditGet(t *testing.T, srv *api.Server, path string) (int, map[string]interface{}) { t.Helper() -srv.StartDailyAuditWorker() -t.Cleanup(srv.StopDailyAuditWorker) -deadline := time.Now().Add(30*time.Second) -for { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, path, nil) - request.Host = "127.0.0.1" - request.RemoteAddr = "127.0.0.1:12345" - request.Header.Set("X-API-Key", dailyAuditTestAPIKey) - srv.ServeHTTP(recorder, request) - var body map[string]interface{} - if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { - t.Fatalf("decode response: %v (%s)", err, recorder.Body.String()) - } -reason, _ := body["reason"].(string) -if reason == "queued for bounded background verification" || reason == "bounded background verification in progress" { -if time.Now().After(deadline) { t.Fatal("background audit did not finish") } -time.Sleep(20*time.Millisecond) -continue -} - return recorder.Code, body -} + srv.StartDailyAuditWorker() + t.Cleanup(srv.StopDailyAuditWorker) + deadline := time.Now().Add(30 * time.Second) + for { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, path, nil) + request.Host = "127.0.0.1" + request.RemoteAddr = "127.0.0.1:12345" + request.Header.Set("X-API-Key", dailyAuditTestAPIKey) + srv.ServeHTTP(recorder, request) + var body map[string]interface{} + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v (%s)", err, recorder.Body.String()) + } + reason, _ := body["reason"].(string) + if reason == "queued for bounded background verification" || reason == "bounded background verification in progress" { + if time.Now().After(deadline) { + t.Fatal("background audit did not finish") + } + time.Sleep(20 * time.Millisecond) + continue + } + return recorder.Code, body + } } func containsText(value, fragment string) bool { diff --git a/api/lpod_daily_beneficiary_test.go b/api/lpod_daily_beneficiary_test.go index d75252d..a0bc38a 100644 --- a/api/lpod_daily_beneficiary_test.go +++ b/api/lpod_daily_beneficiary_test.go @@ -17,31 +17,51 @@ func TestDailyPositionBeneficiarySurvivesClosureAndResume(t *testing.T) { prior := positions[row.ID] progress := dailyPositionProgress{Vault: prior.vault, After: prior.vaultAfter, Beneficiary: prior.beneficiary, Accrued: prior.accrued, Paid: prior.paid} bytes, err := json.Marshal(progress) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } var resumed dailyPositionProgress - if err := json.Unmarshal(bytes, &resumed); err != nil { t.Fatal(err) } + if err := json.Unmarshal(bytes, &resumed); err != nil { + t.Fatal(err) + } positions[row.ID] = &lpodDailyPositionSum{id: row.ID, beneficiary: resumed.Beneficiary, vault: resumed.Vault, vaultAfter: resumed.After, accrued: resumed.Accrued, paid: resumed.Paid} // Final closed-position row repays the remaining reward and the principal. row.Accrued, row.Paid, row.PrincipalReturned = 0, 60, 500_000_000_000_000 - if err := accumulateDailyPosition(positions, row); err != nil { t.Fatal(err) } + if err := accumulateDailyPosition(positions, row); err != nil { + t.Fatal(err) + } output := dailyPositionOutput(positions[row.ID]) if output.Beneficiary != "guardian" || output.Accrued != "100" || output.Paid != "100" || output.PrincipalReturned != "500000000000000" { t.Fatalf("closed payout lost owner or exact amounts: %+v", output) } encoded, err := json.Marshal(output) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } var decoded map[string]string - if err := json.Unmarshal(encoded, &decoded); err != nil { t.Fatal(err) } - if decoded["beneficiary"] != "guardian" { t.Fatal("beneficiary missing from audit JSON") } + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded["beneficiary"] != "guardian" { + t.Fatal("beneficiary missing from audit JSON") + } } func TestDailyPositionRejectsBeneficiaryReplacementWithoutMutatingTotals(t *testing.T) { positions := make(map[string]*lpodDailyPositionSum) row := store.LPoDAuditPosition{ID: "position", Beneficiary: "owner", Accrued: 10} - if err := accumulateDailyPosition(positions, row); err != nil { t.Fatal(err) } + if err := accumulateDailyPosition(positions, row); err != nil { + t.Fatal(err) + } row.Beneficiary, row.Accrued = "attacker", 20 - if err := accumulateDailyPosition(positions, row); err == nil { t.Fatal("beneficiary replacement accepted") } - if positions[row.ID].accrued != 10 || positions[row.ID].beneficiary != "owner" { t.Fatal("failed row changed aggregate") } + if err := accumulateDailyPosition(positions, row); err == nil { + t.Fatal("beneficiary replacement accepted") + } + if positions[row.ID].accrued != 10 || positions[row.ID].beneficiary != "owner" { + t.Fatal("failed row changed aggregate") + } row.Beneficiary = "" - if err := accumulateDailyPosition(positions, row); err == nil { t.Fatal("missing beneficiary accepted") } -} \ No newline at end of file + if err := accumulateDailyPosition(positions, row); err == nil { + t.Fatal("missing beneficiary accepted") + } +} diff --git a/api/lpod_daily_job_integrity.go b/api/lpod_daily_job_integrity.go index aedf19d..19bced2 100644 --- a/api/lpod_daily_job_integrity.go +++ b/api/lpod_daily_job_integrity.go @@ -161,15 +161,15 @@ func validateDailyJob(j *dailyJob) error { return nil } var r struct { - Version int `json:"version"` -BeneficiaryVersion int `json:"beneficiary_version"` - Date string `json:"date"` - Status string `json:"status"` - Level string `json:"verification_level"` - Trust string `json:"trust_assumption"` - IntervalStart string `json:"interval_start"` - IntervalEnd string `json:"interval_end"` - Opening struct { + Version int `json:"version"` + BeneficiaryVersion int `json:"beneficiary_version"` + Date string `json:"date"` + Status string `json:"status"` + Level string `json:"verification_level"` + Trust string `json:"trust_assumption"` + IntervalStart string `json:"interval_start"` + IntervalEnd string `json:"interval_end"` + Opening struct { Height uint64 `json:"height"` Hash string `json:"hash"` } `json:"opening_anchor"` @@ -235,8 +235,8 @@ BeneficiaryVersion int `json:"beneficiary_version"` seen = make(map[string]bool) for _, v := range r.Positions { values, ok := p.Positions[v.ID] -if !ok || seen[v.ID] || r.BeneficiaryVersion != 1 || values.Beneficiary == "" || -values.Beneficiary != v.Beneficiary || values.Vault != v.EffectiveVault || values.After != v.EffectiveVaultAfter || + if !ok || seen[v.ID] || r.BeneficiaryVersion != 1 || values.Beneficiary == "" || + values.Beneficiary != v.Beneficiary || values.Vault != v.EffectiveVault || values.After != v.EffectiveVaultAfter || !matches(v.Accrued, values.Accrued) || !matches(v.Paid, values.Paid) || !matches(v.PrincipalReturned, values.Principal) { return fail() } diff --git a/api/lpod_daily_job_integrity_test.go b/api/lpod_daily_job_integrity_test.go index f7cedf7..2ae84f7 100644 --- a/api/lpod_daily_job_integrity_test.go +++ b/api/lpod_daily_job_integrity_test.go @@ -101,10 +101,10 @@ func TestDailyJobTamperingRejectedAcrossRestart(t *testing.T) { strings.Contains(rec.Body.String(), `"status":"complete"`) { t.Fatalf("unsafe result: %s", rec.Body.String()) } -afterGET, err := db.GetMeta(dailyJobKey) -if err != nil || !bytes.Equal(afterGET, tampered) { -t.Fatal("GET overwrote/re-signed corrupt state instead of reporting the load error") -} + afterGET, err := db.GetMeta(dailyJobKey) + if err != nil || !bytes.Equal(afterGET, tampered) { + t.Fatal("GET overwrote/re-signed corrupt state instead of reporting the load error") + } }) } if err := db.PutMetaSync(dailyJobKey, trusted); err != nil { @@ -221,20 +221,20 @@ func TestDailyJobResetUnreadableAndWrongKeyState(t *testing.T) { if _, err := s.loadDailyJob(); err == nil { t.Fatal("unreadable/wrong-key state accepted") } -getRecorder := httptest.NewRecorder() -s.ServeHTTP(getRecorder, authenticatedDailyAuditRequest()) -afterGET, err := db.GetMeta(dailyJobKey) -if err != nil || !bytes.Equal(afterGET, test.raw) { -t.Fatal("GET changed unreadable state") -} -if getRecorder.Code != http.StatusOK || strings.Contains(getRecorder.Body.String(), "queued for bounded") { -t.Fatalf("GET did not report unreadable state: %d %s", getRecorder.Code, getRecorder.Body.String()) -} + getRecorder := httptest.NewRecorder() + s.ServeHTTP(getRecorder, authenticatedDailyAuditRequest()) + afterGET, err := db.GetMeta(dailyJobKey) + if err != nil || !bytes.Equal(afterGET, test.raw) { + t.Fatal("GET changed unreadable state") + } + if getRecorder.Code != http.StatusOK || strings.Contains(getRecorder.Body.String(), "queued for bounded") { + t.Fatalf("GET did not report unreadable state: %d %s", getRecorder.Code, getRecorder.Body.String()) + } req := authenticatedDailyAuditRequest() req.Method = http.MethodDelete rec := httptest.NewRecorder() s.ServeHTTP(rec, req) -if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reason":"daily audit cancelled"`) { + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reason":"daily audit canceled"`) { t.Fatalf("reset failed: %d %s", rec.Code, rec.Body.String()) } raw, err := db.GetMeta(dailyJobKey) diff --git a/api/lpod_daily_jobs.go b/api/lpod_daily_jobs.go index ff4725d..ceffd53 100644 --- a/api/lpod_daily_jobs.go +++ b/api/lpod_daily_jobs.go @@ -18,6 +18,7 @@ import ( ) const dailyJobKey = "lpod/daily-job/v1" + // A full day must fit between light-pruning epochs. Keep the 250ms cooperative // CPU budget and single worker; a block-count cap must not force a 15min scan. const dailyBatchBlocks = 256 @@ -26,7 +27,7 @@ const dailyJobMaxBytes = 16 << 20 type dailyPositionProgress struct { Vault, After string -Beneficiary string + Beneficiary string Accrued, Paid, Principal uint64 } @@ -41,7 +42,7 @@ type dailyProgress struct { PriorTS int64 Vaults map[string][4]uint64 Positions map[string]dailyPositionProgress -Rewards *dailyRewardProgress `json:",omitempty"` + Rewards *dailyRewardProgress `json:",omitempty"` Burns [4]uint64 Included, First, Last uint64 FirstTS, LastTS int64 @@ -56,6 +57,7 @@ Rewards *dailyRewardProgress `json:",omitempty"` func dailyRewardsNeedReplay(p *dailyProgress) bool { return p != nil && p.Next > 0 && p.Rewards == nil } + type dailyJob struct { ID int64 Updated int64 @@ -86,25 +88,27 @@ func (s *Server) loadDailyJob() (*dailyJob, error) { return nil, fmt.Errorf("daily job exceeds storage bound") } var j dailyJob -if s.myKey == nil || len(s.myKey.Public()) != 32 { -return nil, fmt.Errorf("daily job authentication key unavailable") -} -var envelope dailyJobEnvelope -if err := decodeDailyStrict(raw, &envelope); err != nil { + if s.myKey == nil || len(s.myKey.Public()) != 32 { + return nil, fmt.Errorf("daily job authentication key unavailable") + } + var envelope dailyJobEnvelope + if err := decodeDailyStrict(raw, &envelope); err != nil { return nil, err } -if envelope.Version != 2 || !s.myKey.Public().Verify(dailyJobDigest(envelope.Payload), envelope.Signature) { -return nil, fmt.Errorf("daily job authentication failed; DELETE to reset and reverify") + if envelope.Version != 2 || !s.myKey.Public().Verify(dailyJobDigest(envelope.Payload), envelope.Signature) { + return nil, fmt.Errorf("daily job authentication failed; DELETE to reset and reverify") } -if err := decodeDailyStrict(envelope.Payload, &j); err != nil { return nil, err } -if err := validateDailyJob(&j); err != nil { -return nil, err + if err := decodeDailyStrict(envelope.Payload, &j); err != nil { + return nil, err + } + if err := validateDailyJob(&j); err != nil { + return nil, err } return &j, nil } func (s *Server) saveDailyJob(j *dailyJob) error { j.Updated = time.Now().Unix() -raw, err := s.encodeDailyJob(j) + raw, err := s.encodeDailyJob(j) if err != nil { return err } @@ -187,7 +191,7 @@ func (s *Server) restLPoDDailyJob(w http.ResponseWriter, r *http.Request) { return } if r.Context().Err() != nil { - writeJSON(w, http.StatusOK, dailyPending(date, "daily audit request was cancelled")) + writeJSON(w, http.StatusOK, dailyPending(date, "daily audit request was canceled")) return } d := &s.dailyJobs @@ -199,13 +203,16 @@ func (s *Server) restLPoDDailyJob(w http.ResponseWriter, r *http.Request) { } j, err := s.loadDailyJob() if r.Method == http.MethodDelete { -// Authenticated reset must work even for oversized, malformed, unsigned or -// wrong-key state. Only enforce date ownership when the record is trusted. -if err == nil && j != nil && j.Date != date { + // Authenticated reset must work even for oversized, malformed, unsigned or + // wrong-key state. Only enforce date ownership when the record is trusted. + if err == nil && j != nil && j.Date != date { writeJSONError(w, http.StatusConflict, "another date occupies the daily slot") return } -if s.blockStore == nil { writeJSONError(w, http.StatusServiceUnavailable, "block store unavailable"); return } + if s.blockStore == nil { + writeJSONError(w, http.StatusServiceUnavailable, "block store unavailable") + return + } if d.batchCancel != nil { d.batchCancel() } @@ -213,21 +220,21 @@ if s.blockStore == nil { writeJSONError(w, http.StatusServiceUnavailable, "block writeJSONError(w, http.StatusServiceUnavailable, err.Error()) return } - writeJSON(w, http.StatusOK, dailyPending(date, "daily audit cancelled")) + writeJSON(w, http.StatusOK, dailyPending(date, "daily audit canceled")) + return + } + if err != nil { + writeJSON(w, http.StatusOK, dailyPending(date, err.Error())) return } -if err != nil { -writeJSON(w, http.StatusOK, dailyPending(date, err.Error())) -return -} if j != nil && j.Date == date { -missingRewards := dailyRewardsNeedReplay(j.Progress) -if missingRewards || (j.State == "failed" && time.Now().Unix()-j.Updated >= 30) || + missingRewards := dailyRewardsNeedReplay(j.Progress) + if missingRewards || (j.State == "failed" && time.Now().Unix()-j.Updated >= 30) || (j.State == "complete" && (!s.dailyBindingValid(j.Progress) || !s.lpodFinalized(j.Progress.Certificate.Height, j.Progress.Certificate.BlockHash))) { // Re-attest an older complete report by verifying only its unprocessed // descendant tail. Invalidated/failed progress is never reused. -if missingRewards || j.State == "failed" || !s.dailyBindingValid(j.Progress) { + if missingRewards || j.State == "failed" || !s.dailyBindingValid(j.Progress) { j.Progress = &dailyProgress{} } j.ID, j.State, j.Result = time.Now().UnixNano(), "queued", nil diff --git a/api/lpod_daily_jobs_test.go b/api/lpod_daily_jobs_test.go index 8c0b6f0..72c93cb 100644 --- a/api/lpod_daily_jobs_test.go +++ b/api/lpod_daily_jobs_test.go @@ -11,9 +11,10 @@ import ( "testing" "time" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/store" - "github.com/syndtr/goleveldb/leveldb" ) func enqueueDailyTest(t *testing.T, s *Server) *dailyJob { @@ -40,12 +41,12 @@ func TestDailyJobRestartBudgetAndQueue(t *testing.T) { if rec.Code != http.StatusTooManyRequests { t.Fatalf("queue must reject second date: %d", rec.Code) } - cancelled, cancel := context.WithCancel(context.Background()) + canceled, cancel := context.WithCancel(context.Background()) cancel() - s.runDailyJobBatch(cancelled) + s.runDailyJobBatch(canceled) j, _ := s.loadDailyJob() if j.Progress.Next != 0 { - t.Fatal("cancelled batch changed progress") + t.Fatal("canceled batch changed progress") } s.runDailyJobBatch(context.Background()) j, err := s.loadDailyJob() @@ -63,10 +64,12 @@ func TestDailyJobRestartBudgetAndQueue(t *testing.T) { defer db.Close() restarted := NewServer(":0", nil, nil, nil, slog.Default()) restarted.SetStore(db) -reloadedKey, err := crypto.NewLockedValidatorKey(s.myKey.PrivKey().Bytes(), nil) -if err != nil { t.Fatal(err) } -defer reloadedKey.Destroy() -restarted.SetValidatorKey(reloadedKey) + reloadedKey, err := crypto.NewLockedValidatorKey(s.myKey.PrivKey().Bytes(), nil) + if err != nil { + t.Fatal(err) + } + defer reloadedKey.Destroy() + restarted.SetValidatorKey(reloadedKey) restarted.SetAPIKey(dailyAuditBenchmarkAPIKey) restarted.SetLPoDConfig(nil, func(uint64, crypto.Hash32) bool { return true }) j, err = restarted.loadDailyJob() @@ -205,8 +208,10 @@ func TestDailyJobStorageBudgetAndRetry(t *testing.T) { j.State, j.Progress = "failed", nil j.Result, _ = json.Marshal(dailyPending(j.Date, "closing witness not yet present")) j.Updated = time.Now().Add(-time.Minute).Unix() -raw, err := s.encodeDailyJob(j) -if err != nil { t.Fatal(err) } + raw, err := s.encodeDailyJob(j) + if err != nil { + t.Fatal(err) + } if err := db.PutMetaSync(dailyJobKey, raw); err != nil { t.Fatal(err) } @@ -217,60 +222,79 @@ if err != nil { t.Fatal(err) } // retain only the latest one. Require forward progress when EVERY previously // saved certificate disappears from the oracle, not merely after 256 heights. func TestDailyJobAdvancingCertificateOracleDoesNotResetCursor(t *testing.T) { -const intervalBlocks = 4 * dailyBatchBlocks -db, s, _, _ := makeDailyAuditBenchmarkFixtureWithTail(t, intervalBlocks, 1, 128, 4, 0, 4, intervalBlocks) -defer db.Close() -certificate, err := db.LoadFinalityCertificate() -if err != nil { t.Fatal(err) } -initialHeight := certificate.Height -_, tipHeight, err := db.GetTip() -if err != nil { t.Fatal(err) } -var oracleHeight uint64 -var oracleHash crypto.Hash32 -oracle := func(height uint64, hash crypto.Hash32) bool { -return height == oracleHeight && hash == oracleHash -} -enqueueDailyTest(t, s) -var priorNext uint64 -for batch := 0; batch < 100; batch++ { -height := initialHeight + uint64(batch)*uint64(dailyBatchBlocks/2) -if height > tipHeight { height = tipHeight } -hash, found, err := db.GetCanonicalHash(height) -if err != nil || !found { t.Fatal("missing tail certificate hash") } -if err := db.SaveFinalityCertificate(store.FinalityCertificate{ -Version: 1, Height: height, BlockHash: hash, -Votes: []store.FinalityVote{{Signature: []byte{1}}}, -}); err != nil { t.Fatal(err) } -oracleHeight, oracleHash = height, hash -if batch == 2 { -// Simulate loss of all server-local state while the durable job remains. -fresh := NewServer(":0", nil, nil, nil, slog.Default()) -fresh.SetStore(db) -fresh.SetValidatorKey(s.myKey) -fresh.SetAPIKey(dailyAuditBenchmarkAPIKey) -s = fresh -} -s.SetLPoDConfig(nil, oracle) -s.runDailyJobBatch(context.Background()) -j, err := s.loadDailyJob() -if err != nil || j == nil || j.State == "failed" { t.Fatalf("batch %d failed: %+v %v", batch, j, err) } -if j.Progress.Next <= priorNext || j.Progress.Next-priorNext > dailyBatchBlocks { -t.Fatalf("certificate advancement reset/stalled/unbounded cursor: batch=%d before=%d after=%d", batch, priorNext, j.Progress.Next) -} -priorNext = j.Progress.Next -if j.State == "complete" { -if oracleHeight-initialHeight <= 256 { t.Fatal("did not cross oracle retention window") } -var result struct { -Status string `json:"status"` -Coverage struct { Blocks int `json:"blocks"`; Positions int `json:"positions"` } `json:"coverage"` -} -if err := json.Unmarshal(j.Result, &result); err != nil { t.Fatal(err) } -if result.Status != "complete" || result.Coverage.Blocks != intervalBlocks || result.Coverage.Positions != 4 { -t.Fatalf("unexpected report after advancing certificates: %s", j.Result) -} -t.Logf("completed after %d batches; certificate advanced %d heights; cursor=%d; only latest certificate accepted by oracle", batch+1, oracleHeight-initialHeight, priorNext) -return -} -} -t.Fatal("advancing oracle prevented completion") + const intervalBlocks = 4 * dailyBatchBlocks + db, s, _, _ := makeDailyAuditBenchmarkFixtureWithTail(t, intervalBlocks, 1, 128, 4, 0, 4, intervalBlocks) + defer db.Close() + certificate, err := db.LoadFinalityCertificate() + if err != nil { + t.Fatal(err) + } + initialHeight := certificate.Height + _, tipHeight, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + var oracleHeight uint64 + var oracleHash crypto.Hash32 + oracle := func(height uint64, hash crypto.Hash32) bool { + return height == oracleHeight && hash == oracleHash + } + enqueueDailyTest(t, s) + var priorNext uint64 + for batch := 0; batch < 100; batch++ { + height := initialHeight + uint64(batch)*uint64(dailyBatchBlocks/2) + if height > tipHeight { + height = tipHeight + } + hash, found, err := db.GetCanonicalHash(height) + if err != nil || !found { + t.Fatal("missing tail certificate hash") + } + if err := db.SaveFinalityCertificate(store.FinalityCertificate{ + Version: 1, Height: height, BlockHash: hash, + Votes: []store.FinalityVote{{Signature: []byte{1}}}, + }); err != nil { + t.Fatal(err) + } + oracleHeight, oracleHash = height, hash + if batch == 2 { + // Simulate loss of all server-local state while the durable job remains. + fresh := NewServer(":0", nil, nil, nil, slog.Default()) + fresh.SetStore(db) + fresh.SetValidatorKey(s.myKey) + fresh.SetAPIKey(dailyAuditBenchmarkAPIKey) + s = fresh + } + s.SetLPoDConfig(nil, oracle) + s.runDailyJobBatch(context.Background()) + j, err := s.loadDailyJob() + if err != nil || j == nil || j.State == "failed" { + t.Fatalf("batch %d failed: %+v %v", batch, j, err) + } + if j.Progress.Next <= priorNext || j.Progress.Next-priorNext > dailyBatchBlocks { + t.Fatalf("certificate advancement reset/stalled/unbounded cursor: batch=%d before=%d after=%d", batch, priorNext, j.Progress.Next) + } + priorNext = j.Progress.Next + if j.State == "complete" { + if oracleHeight-initialHeight <= 256 { + t.Fatal("did not cross oracle retention window") + } + var result struct { + Status string `json:"status"` + Coverage struct { + Blocks int `json:"blocks"` + Positions int `json:"positions"` + } `json:"coverage"` + } + if err := json.Unmarshal(j.Result, &result); err != nil { + t.Fatal(err) + } + if result.Status != "complete" || result.Coverage.Blocks != intervalBlocks || result.Coverage.Positions != 4 { + t.Fatalf("unexpected report after advancing certificates: %s", j.Result) + } + t.Logf("completed after %d batches; certificate advanced %d heights; cursor=%d; only latest certificate accepted by oracle", batch+1, oracleHeight-initialHeight, priorNext) + return + } + } + t.Fatal("advancing oracle prevented completion") } diff --git a/api/lpod_earnings_outputs_test.go b/api/lpod_earnings_outputs_test.go index abf6c7c..c99ff66 100644 --- a/api/lpod_earnings_outputs_test.go +++ b/api/lpod_earnings_outputs_test.go @@ -14,12 +14,13 @@ import ( "strings" "testing" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" "github.com/aperod/aperod/store" - "github.com/syndtr/goleveldb/leveldb" - "github.com/syndtr/goleveldb/leveldb/util" ) // This uses a synthetic, signed v3 funding transition and its real earnings @@ -326,12 +327,12 @@ func seedSyntheticEarningsRows( } func syntheticLPoDV3Funding(t *testing.T) (*store.DB, *store.LPoDMigration, crypto.Address) { -db, m, address, _ := syntheticLPoDV3FundingWithSigner(t) -return db, m, address + db, m, address, _ := syntheticLPoDV3FundingWithSigner(t) + return db, m, address } func syntheticLPoDV3FundingWithSigner(t *testing.T) (*store.DB, *store.LPoDMigration, crypto.Address, crypto.ValidatorPrivKey) { -return syntheticLPoDV3FundingAt(t, t.TempDir()) + return syntheticLPoDV3FundingAt(t, t.TempDir()) } func syntheticLPoDV3FundingAt(t *testing.T, path string, withLiquidity ...bool) (*store.DB, *store.LPoDMigration, crypto.Address, crypto.ValidatorPrivKey) { @@ -401,9 +402,9 @@ func syntheticLPoDV3FundingAt(t *testing.T, path string, withLiquidity ...bool) ValidatorRemaining: migration.ValidatorRemaining, } migration.NominalSnapshot = &snapshot -if len(withLiquidity) > 0 && withLiquidity[0] { -snapshot.NominalCirculatingBefore += store.GenesisLiquidityAmount -} + if len(withLiquidity) > 0 && withLiquidity[0] { + snapshot.NominalCirculatingBefore += store.GenesisLiquidityAmount + } migration.SnapshotRoot = snapshot.Root() migration.ReconciliationRoot = migration.Root() signature, err := validator.Sign(migration.AttestationMessage()) @@ -446,5 +447,5 @@ snapshot.NominalCirculatingBefore += store.GenesisLiquidityAmount if err := db.CommitRawBlockWithAVM(block.Hash(), block.Header.Height, raw, nil, crypto.Hash32{}, request); err != nil { t.Fatalf("commit synthetic v3 funding: %v", err) } -return db, migration, leader, validator + return db, migration, leader, validator } diff --git a/api/lpod_pool_test.go b/api/lpod_pool_test.go index 8ce0926..5149c97 100644 --- a/api/lpod_pool_test.go +++ b/api/lpod_pool_test.go @@ -10,11 +10,12 @@ import ( "net/http/httptest" "testing" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" "github.com/aperod/aperod/store" - "github.com/syndtr/goleveldb/leveldb" ) func TestLPoDPoolFinalizedFullExitExcludesRetainedPositions(t *testing.T) { diff --git a/api/lpod_wallet_outputs.go b/api/lpod_wallet_outputs.go index 900be2a..8f16d05 100644 --- a/api/lpod_wallet_outputs.go +++ b/api/lpod_wallet_outputs.go @@ -238,11 +238,11 @@ func (s *Server) restWalletKeyImages(w http.ResponseWriter, r *http.Request) { return } var req struct { - Images []string `json:"key_images"` -IncludeHistory bool `json:"include_history,omitempty"` - SnapshotID string `json:"snapshot_id,omitempty"` - Address string `json:"address,omitempty"` - Refs []struct { + Images []string `json:"key_images"` + IncludeHistory bool `json:"include_history,omitempty"` + SnapshotID string `json:"snapshot_id,omitempty"` + Address string `json:"address,omitempty"` + Refs []struct { Hash string `json:"tx_hash"` Index uint32 `json:"out_idx"` } `json:"refs"` @@ -252,7 +252,7 @@ IncludeHistory bool `json:"include_history,omitempty"` return } if req.SnapshotID != "" { -s.restWalletKeyImagesSnapshot(w, req.Images, req.Refs, req.SnapshotID, req.Address, req.IncludeHistory) + s.restWalletKeyImagesSnapshot(w, req.Images, req.Refs, req.SnapshotID, req.Address, req.IncludeHistory) return } if s.blockStore == nil { diff --git a/api/middleware.go b/api/middleware.go index 547e683..86b0d1b 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -11,6 +11,7 @@ import ( "crypto/subtle" "net" "net/http" + "strings" "sync" "time" ) @@ -161,19 +162,24 @@ var globalHeavyRL = newHeavyRL() // isHeavyPath returns true for endpoints with chain- or UTXO-sized scan costs. // These get the stricter heavyRL bucket (2 req/s, burst 5). func isHeavyPath(path string) bool { - // Address history scans retained blocks; address UTXOs scan the full UTXO set. + // Normalize trailing slashes so alternate spellings cannot bypass the + // heavier bucket for address scans. + path = strings.TrimRight(path, "/") const pfx = "/api/v1/address/" - if len(path) > len(pfx) { - if path[:len(pfx)] == pfx { - for _, suffix := range [...]string{"/utxos", "/transactions"} { - if len(path) > len(pfx)+len(suffix) && path[len(path)-len(suffix):] == suffix { + if strings.HasPrefix(path, pfx) { + tail := strings.TrimPrefix(path, pfx) + slash := strings.IndexByte(tail, '/') + if slash > 0 { + endpoint := tail[slash+1:] + for _, name := range [...]string{"utxos", "transactions", "scan"} { + if endpoint == name || strings.HasPrefix(endpoint, name+"/") { return true } } } } switch path { - case "/api/v1/avm/query", "/api/v1/avm/simulate", "/api/v1/avm/transactions": + case "/api/v1/avm/query", "/api/v1/avm/simulate", "/api/v1/avm/estimate-gas", "/api/v1/avm/transactions": return true } return false @@ -201,11 +207,11 @@ func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { ip := realIP(r) // Heavy endpoints (O(N) UTXO scan) get a stricter bucket first. if isHeavyPath(r.URL.Path) && !globalHeavyRL.allow(ip) { - http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests) + writeRateLimitExceeded(w) return } if !rl.bucket(ip).allow() { - http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests) + writeRateLimitExceeded(w) return } } @@ -213,6 +219,13 @@ func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { }) } +func writeRateLimitExceeded(w http.ResponseWriter) { + // One second is conservative for both token buckets and gives clients a + // useful retry hint without changing the bucket limits or adding exemptions. + w.Header().Set("Retry-After", "1") + http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests) +} + // realIP extracts the real client IP. // X-Forwarded-For is only trusted when the request comes from the loopback // interface (127.0.0.1 / ::1), i.e. from a local reverse proxy. Accepting @@ -222,7 +235,7 @@ func realIP(r *http.Request) string { if remoteIP == "" { remoteIP = r.RemoteAddr } - // Only honour X-Forwarded-For from a trusted local proxy. + // Only honor X-Forwarded-For from a trusted local proxy. if remoteIP == "127.0.0.1" || remoteIP == "::1" { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // Take only the first (leftmost) address. diff --git a/api/middleware_path_test.go b/api/middleware_path_test.go index 9592187..577880f 100644 --- a/api/middleware_path_test.go +++ b/api/middleware_path_test.go @@ -5,8 +5,14 @@ import "testing" func TestIsHeavyPathIncludesAddressHistory(t *testing.T) { for _, path := range []string{ "/api/v1/address/aproExample/transactions", + "/api/v1/address/aproExample/transactions/", + "/api/v1/address/aproExample/transactions/extra", "/api/v1/address/aproExample/utxos", + "/api/v1/address/aproExample/utxos/", + "/api/v1/address/aproExample/scan", + "/api/v1/address/aproExample/scan/", "/api/v1/avm/query", + "/api/v1/avm/estimate-gas", } { if !isHeavyPath(path) { t.Errorf("isHeavyPath(%q) = false, want true", path) @@ -14,8 +20,6 @@ func TestIsHeavyPathIncludesAddressHistory(t *testing.T) { } for _, path := range []string{ "/api/v1/address/aproExample", - "/api/v1/address/aproExample/transactions/extra", - "/api/v1/address/aproExample/scan", "/api/v1/status", } { if isHeavyPath(path) { diff --git a/api/middleware_rate_limit_test.go b/api/middleware_rate_limit_test.go new file mode 100644 index 0000000..5217420 --- /dev/null +++ b/api/middleware_rate_limit_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRateLimiter429IncludesRetryAfterAndDoesNotExemptLoopback(t *testing.T) { + rl := NewRateLimiter() + bucket := rl.bucket("127.0.0.1") + bucket.mu.Lock() + bucket.tokens = 0 + bucket.lastFill = time.Now() + bucket.mu.Unlock() + + nextCalled := false + handler := rl.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "http://node.local/api/v1/blocks?limit=1", nil) + req.RemoteAddr = "127.0.0.1:12345" + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusTooManyRequests { + t.Fatalf("loopback status = %d, want 429", recorder.Code) + } + if got := recorder.Header().Get("Retry-After"); got != "1" { + t.Fatalf("Retry-After = %q, want 1", got) + } + if nextCalled { + t.Fatal("rate-limited loopback request reached the handler") + } +} + +func TestHeavyRateLimiter429IncludesRetryAfter(t *testing.T) { + const ip = "198.51.100.251" + globalHeavyRL.mu.Lock() + globalHeavyRL.buckets[ip] = &tokenBucket{tokens: 0, lastFill: time.Now()} + globalHeavyRL.mu.Unlock() + t.Cleanup(func() { + globalHeavyRL.mu.Lock() + delete(globalHeavyRL.buckets, ip) + globalHeavyRL.mu.Unlock() + }) + + handler := NewRateLimiter().Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "http://node.local/api/v1/address/aproExample/transactions", nil) + req.RemoteAddr = ip + ":12345" + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusTooManyRequests { + t.Fatalf("heavy endpoint status = %d, want 429", recorder.Code) + } + if got := recorder.Header().Get("Retry-After"); got != "1" { + t.Fatalf("Retry-After = %q, want 1", got) + } +} diff --git a/api/rest.go b/api/rest.go index 319c57a..faee11a 100644 --- a/api/rest.go +++ b/api/rest.go @@ -14,11 +14,13 @@ package api // POST /api/v1/admin/stake-deposit — admin-only: v2 UTXO-backed stake deposit import ( + "crypto/sha256" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" + "io" "math" "net" "net/http" @@ -75,7 +77,7 @@ func (s *Server) registerRESTRoutes() { s.mux.HandleFunc("/api/v1/wallet/snapshot", s.restWalletSnapshot) s.mux.HandleFunc("/api/v1/wallet/changes", s.restWalletChanges) s.mux.HandleFunc("/api/v1/keyimage/", s.restKeyImageIsSpent) - s.mux.HandleFunc("/api/v1/stake", s.restStakeBroadcast) + s.mux.HandleFunc("/api/v1/stake", s.requireMutationAPIKey(s.restStakeBroadcast)) s.mux.HandleFunc("/api/v1/status", s.restStatus) s.mux.HandleFunc("/api/v1/guardian-fund", s.restGuardianFund) s.mux.HandleFunc("/api/v1/lpod-pool", s.restLPoDPool) @@ -94,6 +96,7 @@ func (s *Server) registerRESTRoutes() { s.mux.HandleFunc("/api/v1/avm/signers/", s.restAVMSigner) s.mux.HandleFunc("/api/v1/avm/query", s.restAVMQuery) s.mux.HandleFunc("/api/v1/avm/simulate", s.restAVMSimulate) + s.mux.HandleFunc("/api/v1/avm/estimate-gas", s.restAVMEstimateGas) s.mux.HandleFunc("/api/v1/avm/transactions", s.restAVMTransaction) // Node-join workflow: export endpoints consumed by aperod-join.sh. s.mux.HandleFunc("/api/v1/snapshot/export", s.restSnapshotExport) @@ -216,7 +219,7 @@ func burnAddressString(tx *core.Transaction) string { } // pathSuffix extracts the path segment after prefix. -// e.g. pathSuffix("/api/v1/blocks/", "/api/v1/blocks/42") == "42" +// e.g. pathSuffix("/api/v1/blocks/", "/api/v1/blocks/42") == "42". func pathSuffix(prefix, path string) string { return strings.TrimPrefix(path, prefix) } @@ -430,7 +433,7 @@ func (s *Server) lookupBlockMem(id string) *core.Block { // stripped. Identified by the "tx_count" key (json struct tag). // // Returns (fullBlock, nil, nil) for the native format, (nil, prunedBlock, nil) -// for the pruned format, or (nil, nil, nil) if not found / unrecognised. +// for the pruned format, or (nil, nil, nil) if not found / unrecognized. func (s *Server) lookupBlockFromDisk(id string) (*core.Block, *store.StoredBlock, error) { var rawBytes []byte var ioErr error @@ -459,7 +462,7 @@ func (s *Server) lookupBlockFromDisk(id string) (*core.Block, *store.StoredBlock TxCount *int `json:"tx_count"` // present in StoredBlock JSON } if err := json.Unmarshal(rawBytes, &probe); err != nil { - return nil, nil, nil // unrecognised format — treat as not found + return nil, nil, nil // unrecognized format — treat as not found } if probe.Header != nil { @@ -737,8 +740,6 @@ type AddressUTXO struct { IsCoinbase bool `json:"is_coinbase"` } -const maxAddressHistoryScanBlocks = 1_000 - // restAddressUTXOs handles GET /api/v1/address/{addr}/utxos. // // Without a view key: returns transparent / mint outputs (OneTimePub matches @@ -769,36 +770,48 @@ func (s *Server) restAddressUTXOs(w http.ResponseWriter, r *http.Request, addrSt // query parameters are logged by reverse proxies, access logs, and browser // history, leaking the private view scalar to anyone with log access. viewKeyHex := r.Header.Get("X-View-Key") + requestViewKey := viewKeyHex != "" if viewKeyHex == "" { viewKeyHex = s.nodeViewKeyHex } - // Short-TTL response cache: restAddressUTXOs scans the entire UTXO set - // with Ed25519 point ops per entry (O(n), pins CPU for 20-30 s). - // The mint-UTXO monitor calls this every 5 min for each admin-mint address. - // A 90-second TTL lets the first call in each window do the scan; - // all subsequent calls within that window are served from cache in <1 ms. - cacheKey := addrStr + "|" + viewKeyHex - if raw, ok := s.utxoAddrCache.Load(cacheKey); ok { - e := raw.(*utxoCacheEntry) - if time.Now().Before(e.expiresAt) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(e.body) - return - } - s.utxoAddrCache.Delete(cacheKey) // expired, evict - } - var viewPriv *crypto.Scalar32 + var viewKey []byte if viewKeyHex != "" { + if requestViewKey && len(viewKeyHex) != 64 { + writeJSONError(w, http.StatusBadRequest, "X-View-Key must be exactly 32 bytes of hex") + return + } raw, hexErr := hex.DecodeString(viewKeyHex) + if requestViewKey && (hexErr != nil || len(raw) != 32) { + writeJSONError(w, http.StatusBadRequest, "X-View-Key must be exactly 32 bytes of hex") + return + } if hexErr == nil && len(raw) == 32 { var vk crypto.Scalar32 copy(vk[:], raw) viewPriv = &vk + viewKey = raw } - // Silently ignore invalid hex — fall back to no-key behaviour. + } + + // Short-TTL response cache: restAddressUTXOs scans the entire UTXO set + // with Ed25519 point ops per entry (O(n), pins CPU for 20-30 s). + // The mint-UTXO monitor calls this every 5 min for each admin-mint address. + // A 90-second TTL lets the first call in each window do the scan; + // all subsequent calls within that window are served from cache in <1 ms. + // Keep only a digest in the cache key so the cache does not retain the + // caller's private view key for the entry lifetime. + cacheKey := addrStr + if len(viewKey) == 32 { + viewKeyDigest := sha256.Sum256(viewKey) + cacheKey += "|" + hex.EncodeToString(viewKeyDigest[:]) + } + if body, ok := s.utxoAddrCache.load(cacheKey, time.Now()); ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + return } // F10 security fix: cap the number of UTXOs scanned to prevent a @@ -889,10 +902,11 @@ func (s *Server) restAddressUTXOs(w http.ResponseWriter, r *http.Request, addrSt "scan_limited": scanLimited, // true when UTXO set exceeds maxUTXOScan } if body, marshalErr := json.Marshal(respPayload); marshalErr == nil { - s.utxoAddrCache.Store(cacheKey, &utxoCacheEntry{ + now := time.Now() + s.utxoAddrCache.store(cacheKey, &utxoCacheEntry{ body: body, - expiresAt: time.Now().Add(utxoCacheTTL), - }) + expiresAt: now.Add(utxoCacheTTL), + }, now) } writeJSON(w, http.StatusOK, respPayload) } @@ -904,9 +918,13 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { tail := pathSuffix("/api/v1/address/", r.URL.Path) parts := strings.SplitN(tail, "/", 2) addrStr := parts[0] + subpath := "" + if len(parts) >= 2 { + subpath = strings.TrimRight(parts[1], "/") + } // Dispatch sub-paths first so they can accept their own HTTP methods. - if len(parts) >= 2 && parts[1] == "utxos" { + if subpath == "utxos" { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") return @@ -914,7 +932,7 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { s.restAddressUTXOs(w, r, addrStr) return } - if len(parts) >= 2 && parts[1] == "scan" { + if subpath == "scan" { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "POST only") return @@ -922,6 +940,10 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { s.restAddressScan(w, r, addrStr) return } + if subpath != "transactions" { + writeJSONError(w, http.StatusNotFound, "unknown address endpoint") + return + } if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -940,99 +962,7 @@ func (s *Server) restAddressTxs(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "cannot decode address") return } - - limit := 50 - if l := r.URL.Query().Get("limit"); l != "" { - if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 { - limit = n - } - } - - // Each request scans only a fixed number of retained block heights. Older - // pages can be requested with start_height; this keeps an address query from - // scaling with the configured in-memory chain window. - var startHeight uint64 - startAtTip := true - if raw := r.URL.Query().Get("start_height"); raw != "" { - parsed, err := strconv.ParseUint(raw, 10, 64) - if err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid start_height") - return - } - startHeight = parsed - startAtTip = false - } - blocks, pageNextStart, pageHasMore, oldestRetainedHeight := - s.chain.BlocksNewestFirstPage(startHeight, startAtTip, maxAddressHistoryScanBlocks) - if len(blocks) == 0 { - writeJSON(w, http.StatusOK, map[string]interface{}{ - "transactions": []AddressTx{}, - "address": addrStr, - "scan_limited": false, - }) - return - } - - results := make([]AddressTx, 0, 16) - scanLimited := false - var nextStartHeight uint64 - hasNextPage := false - for _, b := range blocks { - if len(results) >= limit { - break - } - h := b.Header.Height - txHash := func(i int) string { - hh := b.Txs[i].Hash() - return fmt.Sprintf("%x", hh[:]) - } - // Mint outputs at this height use mint_pub = spend_pub + height*G - // (see core.BuildMintTx). Recompute the expected key for this height - // so per-block coinbase rewards are still discoverable by address scan. - expectedMintPub := spendPub - if heightPub, hErr := crypto.ScalarMulBase(crypto.ScalarFromUint64(h)); hErr == nil { - if shifted, aErr := crypto.AddPoints(spendPub, heightPub); aErr == nil { - expectedMintPub = shifted - } - } - for i, tx := range b.Txs { - isCoinbase := tx.IsCoinbase() - for j, out := range tx.Outputs { - if out.OneTimePub == spendPub || out.OneTimePub == expectedMintPub { - results = append(results, AddressTx{ - TxHash: txHash(i), - BlockHeight: h, - TxIndex: i, - OutputIndex: j, - IsCoinbase: isCoinbase, - }) - } - } - } - if len(results) >= limit { - if h > oldestRetainedHeight { - nextStartHeight = h - 1 - hasNextPage = true - } - break - } - } - if len(results) < limit && pageHasMore { - scanLimited = true - nextStartHeight = pageNextStart - hasNextPage = true - } - - response := map[string]interface{}{ - "address": addrStr, - "transactions": results, - "note": "shows transparent (non-stealth) outputs only; full privacy scanning requires view key", - "scan_limited": scanLimited, - } - if hasNextPage { - response["next_start_height"] = nextStartHeight - } - writeJSON(w, http.StatusOK, response) + s.restAddressTransactionsPage(w, r, addrStr, spendPub) } // ─── POST /api/v1/address/{addr}/scan ──────────────────────────────────────── @@ -1049,12 +979,22 @@ type scanRequest struct { func (s *Server) restAddressScan(w http.ResponseWriter, r *http.Request, addrStr string) { var req scanRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } - if req.ViewKeyHex == "" { - writeJSONError(w, http.StatusBadRequest, "view_key_hex is required") + if err := decoder.Decode(&struct{}{}); err != io.EOF { + writeJSONError(w, http.StatusBadRequest, "request must contain one JSON object") + return + } + if len(req.ViewKeyHex) != 64 { + writeJSONError(w, http.StatusBadRequest, "view_key_hex must be a 32-byte hexadecimal value") + return + } + if _, err := hex.DecodeString(req.ViewKeyHex); err != nil { + writeJSONError(w, http.StatusBadRequest, "view_key_hex must be a 32-byte hexadecimal value") return } // Pass the view key via X-View-Key header (not query string — query params @@ -1368,7 +1308,7 @@ func (s *Server) restNetworkWhitelistByEntry(w http.ResponseWriter, r *http.Requ // the automatic ban strike was skipped. Accepts an optional `since` query // parameter (Unix milliseconds) to fetch only events recorded after that time. // -// Response: { "events": [ { ip, peer_addr, block_height, our_tip, at } ] } +// Response: { "events": [ { ip, peer_addr, block_height, our_tip, at } ] }. func (s *Server) restNetworkWhitelistExemptions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -1403,7 +1343,7 @@ func (s *Server) restNetworkWhitelistExemptions(w http.ResponseWriter, r *http.R // optional `since` query parameter (Unix milliseconds) to fetch only events // recorded after that time. // -// Response: { "events": [ { ip, peer_addr, peer_id, reason, violations, ban_duration_secs, at } ] } +// Response: { "events": [ { ip, peer_addr, peer_id, reason, violations, ban_duration_secs, at } ] }. func (s *Server) restNetworkBanEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -1446,7 +1386,7 @@ func (s *Server) restNetworkBanEvents(w http.ResponseWriter, r *http.Request) { // the validator's own tip_height to detect sync stalls before they escalate // to a ban. // -// Response: [ { "addr": "ip:port", "height": N, "direction": "in"|"out" }, … ] +// Response: [ { "addr": "ip:port", "height": N, "direction": "in"|"out" }, … ]. func (s *Server) restNetworkPeers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -1466,7 +1406,7 @@ func (s *Server) restNetworkPeers(w http.ResponseWriter, r *http.Request) { // Accepts an optional `since` query parameter (Unix milliseconds) to fetch // only events recorded after that time. // -// Response: { "events": [ { peer_addr, stalled_count, at } ] } +// Response: { "events": [ { peer_addr, stalled_count, at } ] }. func (s *Server) restNetworkStallEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -1502,7 +1442,7 @@ func (s *Server) restNetworkStallEvents(w http.ResponseWriter, r *http.Request) // Accepts an optional `since` query parameter (Unix milliseconds) to fetch only // events recorded after that time. // -// Response: { "events": [ { bootnode, err, age_secs, at } ] } +// Response: { "events": [ { bootnode, err, age_secs, at } ] }. func (s *Server) restNetworkBootnodeWarnEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -1538,7 +1478,7 @@ func (s *Server) restNetworkBootnodeWarnEvents(w http.ResponseWriter, r *http.Re // Accepts an optional `since` query parameter (Unix milliseconds) to fetch only // events recorded after that time. // -// Response: { "events": [ { addr, fingerprint, at } ] } +// Response: { "events": [ { addr, fingerprint, at } ] }. func (s *Server) restNetworkDuplicateIdentityEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -1573,10 +1513,10 @@ func (s *Server) restNetworkDuplicateIdentityEvents(w http.ResponseWriter, r *ht // // Response: // -// base_fee_per_byte uint64 nAPRO per serialised byte (current tip) +// base_fee_per_byte uint64 nAPRO per serialized byte (current tip) // estimated_fee_napro uint64 fee for a typical 1-in, 2-out APRO transfer // estimated_fee_apro float64 same value converted to APRO (÷ 1e8) -// tx_size_bytes int estimated serialised size used for the above +// tx_size_bytes int estimated serialized size used for the above func (s *Server) restFeeEstimate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -1584,7 +1524,7 @@ func (s *Server) restFeeEstimate(w http.ResponseWriter, r *http.Request) { } // Read current base fee from tip block header. - var baseFeePerByte uint64 = core.InitialBaseFeePerByte + var baseFeePerByte = core.InitialBaseFeePerByte if tip := s.chain.Tip(); tip != nil && tip.Header.BaseFee > 0 { baseFeePerByte = tip.Header.BaseFee } @@ -1949,7 +1889,7 @@ func (s *Server) restAdminPartialUnstake(w http.ResponseWriter, r *http.Request) return } if s.registry == nil { - writeJSONError(w, http.StatusServiceUnavailable, "validator registry not initialised") + writeJSONError(w, http.StatusServiceUnavailable, "validator registry not initialized") return } @@ -2070,7 +2010,7 @@ func (s *Server) restAdminFullUnstake(w http.ResponseWriter, r *http.Request) { return } if s.registry == nil { - writeJSONError(w, http.StatusServiceUnavailable, "validator registry not initialised") + writeJSONError(w, http.StatusServiceUnavailable, "validator registry not initialized") return } @@ -2184,7 +2124,7 @@ func (s *Server) restMyValidator(w http.ResponseWriter, r *http.Request) { // source UTXO can never be spent again. // // GET /api/v1/keyimage/{ki_hex}/is-spent -// Response: {"is_spent": true|false} +// Response: {"is_spent": true|false}. func (s *Server) restKeyImageIsSpent(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -2293,13 +2233,13 @@ func (s *Server) restUTXODecoys(w http.ResponseWriter, r *http.Request) { return } type snapshotDecoy struct { - TxHash string `json:"tx_hash"` - OutputIndex uint32 `json:"out_idx"` - BlockHeight uint64 `json:"block_height"` - OneTimePub string `json:"one_time_pub"` - TxPubKey string `json:"tx_pub_key"` + TxHash string `json:"tx_hash"` + OutputIndex uint32 `json:"out_idx"` + BlockHeight uint64 `json:"block_height"` + OneTimePub string `json:"one_time_pub"` + TxPubKey string `json:"tx_pub_key"` AmountCommit string `json:"amount_commit"` - EncAmount string `json:"enc_amount"` + EncAmount string `json:"enc_amount"` } // The caller's count is bounded above, but keep initial allocation // independent of request data so future parser changes cannot turn it @@ -2406,7 +2346,7 @@ func (s *Server) restUTXO(w http.ResponseWriter, r *http.Request) { // UTXO can therefore appear in s.utxos while su/ records it as spent // (populated by --rebuild-spent-index or normal block processing), // causing the endpoint to incorrectly report exists=true for a spent - // output. Consistent behaviour: if su/ says spent, report not-found. + // output. Consistent behavior: if su/ says spent, report not-found. if s.blockStore != nil && s.blockStore.IsUTXOSpent(txHash, outIdx) { // Spent — fall through to not-found handling. } else { @@ -2704,11 +2644,59 @@ func (s *Server) restAdminStakeDeposit(w http.ResponseWriter, r *http.Request) { return } - // ── Build v2 Extra payload ───────────────────────────────────────────────── - extra, err := core.EncodeStakeExtraV2( - core.StakeDeposit, targetPub, req.AmountNAPR, sig, - burnTxHash, req.UTXOOutIdx, burnBlind, - ) + // Select v4 when the caller supplies the key-image DLEQ proof. This is + // required once all validators reach the coordinated activation height. + proofFields := []string{ + req.KeyImageHex, req.ProofCommitmentGHex, req.ProofCommitmentHpHex, req.ProofResponseHex, + } + hasProofField := false + for _, value := range proofFields { + hasProofField = hasProofField || value != "" + } + var extra []byte + if hasProofField { + decoded := make([][]byte, len(proofFields)) + for i, value := range proofFields { + raw, decodeErr := hex.DecodeString(value) + if decodeErr != nil || len(raw) != 32 { + writeJSONError(w, http.StatusBadRequest, "all key-image proof fields must be exactly 64 hex characters") + return + } + decoded[i] = raw + } + var keyImage crypto.KeyImage + copy(keyImage[:], decoded[0]) + proof := crypto.KeyImageDLEQProof{} + copy(proof.CommitmentG[:], decoded[1]) + copy(proof.CommitmentHp[:], decoded[2]) + copy(proof.Response[:], decoded[3]) + context := core.StakeKeyImageProofContext( + core.StakeDeposit, targetPub, req.AmountNAPR, burnTxHash, req.UTXOOutIdx, burnBlind, + ) + if verifyErr := crypto.VerifyKeyImageProof(burnUTXO.OneTimePub, keyImage, context, proof); verifyErr != nil { + writeJSONError(w, http.StatusUnprocessableEntity, "invalid collateral key-image proof") + return + } + spent, spentErr := s.utxos.HasSpentKeyImage(keyImage) + if spentErr != nil { + s.log.Error("admin stake deposit: key-image spentness read failed", "err", spentErr) + writeJSONError(w, http.StatusServiceUnavailable, "cannot verify collateral spentness") + return + } + if spent { + writeJSONError(w, http.StatusUnprocessableEntity, "collateral key image is already spent") + return + } + extra, err = core.EncodeStakeExtraV4( + core.StakeDeposit, targetPub, req.AmountNAPR, sig, + burnTxHash, req.UTXOOutIdx, burnBlind, keyImage, proof, + ) + } else { + extra, err = core.EncodeStakeExtraV2( + core.StakeDeposit, targetPub, req.AmountNAPR, sig, + burnTxHash, req.UTXOOutIdx, burnBlind, + ) + } if err != nil { writeJSONError(w, http.StatusInternalServerError, "encode extra: "+err.Error()) return @@ -2750,12 +2738,16 @@ func (s *Server) restAdminStakeDeposit(w http.ResponseWriter, r *http.Request) { // stakeDepositRequest is the JSON body for POST /api/v1/admin/stake-deposit. // The node signs the deposit on behalf of its own configured validator key. type stakeDepositRequest struct { - PubKey string `json:"pub_key"` // 64-hex-char Ed25519 public key - AmountNAPR uint64 `json:"amount_napr"` // stake amount in nAPRO (base units) - UTXOTxHash string `json:"utxo_txhash"` // 64-hex-char tx hash of the burn UTXO - UTXOOutIdx uint32 `json:"utxo_out_idx"` // output index of the burn UTXO - BlindHex string `json:"blind_hex"` // 64-hex-char Pedersen blinding factor; "" for stealth outputs - HsScalarHex string `json:"hs_scalar_hex"` // optional: ECDH shared secret for stealth outputs; used when blind_hex is empty + PubKey string `json:"pub_key"` + AmountNAPR uint64 `json:"amount_napr"` + UTXOTxHash string `json:"utxo_txhash"` + UTXOOutIdx uint32 `json:"utxo_out_idx"` + BlindHex string `json:"blind_hex"` + HsScalarHex string `json:"hs_scalar_hex"` + KeyImageHex string `json:"key_image_hex,omitempty"` + ProofCommitmentGHex string `json:"proof_a_hex,omitempty"` + ProofCommitmentHpHex string `json:"proof_b_hex,omitempty"` + ProofResponseHex string `json:"proof_z_hex,omitempty"` } // ─── POST /api/v1/stake ─────────────────────────────────────────────────────── diff --git a/api/rest_address_history_test.go b/api/rest_address_history_test.go new file mode 100644 index 0000000..94fb1db --- /dev/null +++ b/api/rest_address_history_test.go @@ -0,0 +1,149 @@ +package api_test + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestREST_AddressTxsPagesCanonicalDiskHistoryBeyondReportedHeight(t *testing.T) { + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer db.Close() + + priv, validatorPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatalf("GenerateValidatorKey: %v", err) + } + targetKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + targetSpend := targetKeys.Spend.Public + otherSpend := crypto.Point32(validatorPub) + + const firstHeight = uint64(1_171_635) + const tipHeight = firstHeight + 2*maxTestAddressHistoryScanBlocks - 1 + const firstStoredHeight = firstHeight - 1 + var previousHash crypto.Hash32 + var tipParentHash crypto.Hash32 + var tipBlock *core.Block + + for height := firstStoredHeight; height <= tipHeight; height++ { + recipient := otherSpend + if height == firstHeight || height == tipHeight { + recipient = targetSpend + } + txs := []core.Transaction{core.CoinbaseTx(recipient, 1_000_000+height)} + header := core.BlockHeader{ + Height: height, + PrevHash: previousHash, + Timestamp: time.Now().UnixNano() + int64(height), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(txs), + } + if err := header.Sign(priv); err != nil { + t.Fatalf("Sign block %d: %v", height, err) + } + block := &core.Block{Header: header, Txs: txs} + raw, err := json.Marshal(block) + if err != nil { + t.Fatalf("marshal block %d: %v", height, err) + } + hash := block.Hash() + if err := db.PutRawBlock(hash, height, raw); err != nil { + t.Fatalf("PutRawBlock(%d): %v", height, err) + } + if height == tipHeight-1 { + tipParentHash = hash + } + previousHash = hash + tipBlock = block + } + tipHash := tipBlock.Hash() + if err := db.PutTip(tipHash, tipHeight); err != nil { + t.Fatalf("PutTip: %v", err) + } + + srv := api.NewServer(":0", core.NewChain(), core.NewMempool(core.DefaultMempoolConfig()), core.NewUTXOSet(), testLogger()) + srv.SetStore(db) + address := crypto.EncodeAddress(crypto.MainnetByte, targetSpend, targetSpend) + path := "/api/v1/address/" + string(address) + "/transactions?limit=10" + + code, first := restGet(t, srv, path) + if code != http.StatusOK { + t.Fatalf("first page status = %d, want 200: %v", code, first) + } + if first["scan_limited"] != true || first["has_more"] != true { + t.Fatalf("first page must expose its bounded continuation: %v", first) + } + firstRows, ok := first["transactions"].([]interface{}) + if !ok || len(firstRows) != 1 { + t.Fatalf("first page transactions = %v, want one high-height match", first["transactions"]) + } + firstRow, ok := firstRows[0].(map[string]interface{}) + if !ok || firstRow["block_height"] != float64(tipHeight) { + t.Fatalf("first-page transaction = %v, want height %d", firstRows[0], tipHeight) + } + if first["anchor_height"] != float64(tipHeight) || first["anchor_hash"] != fmt.Sprintf("%x", tipHash[:]) { + t.Fatalf("page anchor = (%v, %v), want (%d, %x)", first["anchor_height"], first["anchor_hash"], tipHeight, tipHash[:]) + } + nextStartHeight := firstHeight + maxTestAddressHistoryScanBlocks - 1 + if first["next_start_height"] != float64(nextStartHeight) { + t.Fatalf("next_start_height = %v, want %d", first["next_start_height"], nextStartHeight) + } + + nextPath := fmt.Sprintf( + "/api/v1/address/%s/transactions?limit=10&start_height=%d&anchor_height=%d&anchor_hash=%x", + address, nextStartHeight, tipHeight, tipHash[:], + ) + code, second := restGet(t, srv, nextPath) + if code != http.StatusOK { + t.Fatalf("second page status = %d, want 200: %v", code, second) + } + secondRows, ok := second["transactions"].([]interface{}) + if !ok || len(secondRows) != 1 { + t.Fatalf("second-page transactions = %v, want one older match", second["transactions"]) + } + secondRow, ok := secondRows[0].(map[string]interface{}) + if !ok || secondRow["block_height"] != float64(firstHeight) { + t.Fatalf("second-page transaction = %v, want height %d", secondRows[0], firstHeight) + } + if second["has_more"] != true || second["scan_limited"] != true || + second["next_start_height"] != float64(firstStoredHeight) { + t.Fatalf("second-page continuation metadata = %v, want next height %d", second, firstStoredHeight) + } + + replacementHeader := tipBlock.Header + replacementHeader.Timestamp++ + replacementHeader.PrevHash = tipParentHash + if err := replacementHeader.Sign(priv); err != nil { + t.Fatalf("sign replacement tip: %v", err) + } + replacement := &core.Block{Header: replacementHeader, Txs: tipBlock.Txs} + replacementRaw, err := json.Marshal(replacement) + if err != nil { + t.Fatalf("marshal replacement tip: %v", err) + } + replacementHash := replacement.Hash() + if err := db.PutRawBlock(replacementHash, tipHeight, replacementRaw); err != nil { + t.Fatalf("PutRawBlock replacement: %v", err) + } + if err := db.PutTip(replacementHash, tipHeight); err != nil { + t.Fatalf("PutTip replacement: %v", err) + } + if code, stale := restGet(t, srv, nextPath); code != http.StatusConflict { + t.Fatalf("stale anchor status = %d, want 409: %v", code, stale) + } +} + +const maxTestAddressHistoryScanBlocks = uint64(1_000) diff --git a/api/rest_avm.go b/api/rest_avm.go index eca4a5a..95cd021 100644 --- a/api/rest_avm.go +++ b/api/rest_avm.go @@ -275,7 +275,7 @@ func parseAVMExecution(req avmExecutionJSON) (crypto.Hash32, []byte, []byte, []a return id, code, input, access, nil } -func (s *Server) executeAVMReadOnly(w http.ResponseWriter, r *http.Request, simulation bool) { +func (s *Server) executeAVMReadOnly(w http.ResponseWriter, r *http.Request, simulation bool, gasEstimate bool) { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "POST only") return @@ -296,6 +296,14 @@ func (s *Server) executeAVMReadOnly(w http.ResponseWriter, r *http.Request, simu writeJSONError(w, http.StatusBadRequest, err.Error()) return } + if req.GasLimit < core.AVMMinGasLimit || req.GasLimit > core.AVMMaxGasLimit { + writeJSONError( + w, + http.StatusBadRequest, + fmt.Sprintf("gas_limit must be between %d and %d", core.AVMMinGasLimit, core.AVMMaxGasLimit), + ) + return + } action := req.Action if action == "" { action = "call" @@ -341,6 +349,22 @@ func (s *Server) executeAVMReadOnly(w http.ResponseWriter, r *http.Request, simu writeJSONError(w, status, executeErr.Error()) return } + if gasEstimate { + gasFee, feeErr := core.AVMGasFee(req.GasLimit) + if feeErr != nil { + writeJSONError(w, http.StatusBadRequest, feeErr.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "contract_id": req.ContractID, + "gas_used": result.GasUsed, + "gas_limit": req.GasLimit, + "gas_price_napr": core.AVMGasPriceNAPR, + "gas_fee_napr": gasFee, + "committed": false, + }) + return + } writeJSON(w, http.StatusOK, map[string]interface{}{ "contract_id": req.ContractID, "gas_used": result.GasUsed, "return_data_hex": hex.EncodeToString(result.ReturnData), @@ -352,11 +376,15 @@ func (s *Server) executeAVMReadOnly(w http.ResponseWriter, r *http.Request, simu } func (s *Server) restAVMQuery(w http.ResponseWriter, r *http.Request) { - s.executeAVMReadOnly(w, r, false) + s.executeAVMReadOnly(w, r, false, false) } func (s *Server) restAVMSimulate(w http.ResponseWriter, r *http.Request) { - s.executeAVMReadOnly(w, r, true) + s.executeAVMReadOnly(w, r, true, false) +} + +func (s *Server) restAVMEstimateGas(w http.ResponseWriter, r *http.Request) { + s.executeAVMReadOnly(w, r, true, true) } func (s *Server) restAVMTransaction(w http.ResponseWriter, r *http.Request) { diff --git a/api/rest_avm_sdk_test.go b/api/rest_avm_sdk_test.go new file mode 100644 index 0000000..a193423 --- /dev/null +++ b/api/rest_avm_sdk_test.go @@ -0,0 +1,204 @@ +package api_test + +import ( + "bytes" + stded25519 "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/avm" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func minimalAVMContractCode(t *testing.T) []byte { + t.Helper() + code, err := hex.DecodeString("0061736d01000000010401600000030201000707010372756e00000a040102000b") + if err != nil { + t.Fatal(err) + } + return code +} + +func requestAVM(t *testing.T, server *api.Server, path string, body any) (int, []byte) { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + response := httptest.NewRecorder() + server.ServeHTTP(response, req) + return response.Code, response.Body.Bytes() +} + +func TestAVMGasEstimateReturnsExecutionAndReservationCost(t *testing.T) { + server, _ := buildChainServer(t, 0) + state := avm.NewMemoryStore() + server.SetAVMStore(1, state) + server.SetReady() + code := minimalAVMContractCode(t) + contractID := strings.Repeat("09", 32) + id, _ := hex.DecodeString(contractID) + codeKey := append([]byte{0xff, 'a', 'v', 'm', '/', 'c', '/'}, id...) + if err := state.Apply([]avm.Write{{Key: codeKey, Value: code}}); err != nil { + t.Fatal(err) + } + + status, body := requestAVM(t, server, "/api/v1/avm/estimate-gas", map[string]any{ + "contract_id": contractID, + "entry": "run", + "gas_limit": uint64(1_000), + }) + if status != http.StatusOK { + t.Fatalf("status=%d body=%s", status, body) + } + var estimate struct { + ContractID string `json:"contract_id"` + GasUsed uint64 `json:"gas_used"` + GasLimit uint64 `json:"gas_limit"` + GasPrice uint64 `json:"gas_price_napr"` + GasFee uint64 `json:"gas_fee_napr"` + Committed bool `json:"committed"` + } + if err := json.Unmarshal(body, &estimate); err != nil { + t.Fatal(err) + } + if estimate.ContractID != contractID || estimate.GasLimit != 1_000 || + estimate.GasPrice != core.AVMGasPriceNAPR || + estimate.GasFee != 1_000*core.AVMGasPriceNAPR || + estimate.GasUsed == 0 || estimate.Committed { + t.Fatalf("unexpected gas estimate: %+v", estimate) + } +} + +func TestAVMQueryRejectsMalformedPayloadAndGasEstimateReportsOutOfGas(t *testing.T) { + server, _ := buildChainServer(t, 0) + state := avm.NewMemoryStore() + server.SetAVMStore(1, state) + server.SetReady() + code := minimalAVMContractCode(t) + contractID := strings.Repeat("0a", 32) + id, _ := hex.DecodeString(contractID) + codeKey := append([]byte{0xff, 'a', 'v', 'm', '/', 'c', '/'}, id...) + if err := state.Apply([]avm.Write{{Key: codeKey, Value: code}}); err != nil { + t.Fatal(err) + } + + status, body := requestAVM(t, server, "/api/v1/avm/query", map[string]any{ + "contract_id": contractID, + "entry": "run", + "gas_limit": core.AVMMinGasLimit, + "unexpected": true, + }) + if status != http.StatusBadRequest { + t.Fatalf("malformed query status=%d body=%s", status, body) + } + + status, body = requestAVM(t, server, "/api/v1/avm/estimate-gas", map[string]any{ + "contract_id": contractID, + "entry": "run", + "gas_limit": core.AVMMinGasLimit, + "calldata_hex": strings.Repeat("00", int(core.AVMMinGasLimit)+1), + }) + if status != http.StatusUnprocessableEntity || !bytes.Contains(body, []byte("out of gas")) { + t.Fatalf("out-of-gas estimate status=%d body=%s", status, body) + } +} + +func signedAVMSubmissionForTest(t *testing.T, nonce uint64) core.Transaction { + t.Helper() + publicKey, privateKey, err := stded25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + payload := &core.AVMPayload{ + Action: core.AVMDeployContract, + Code: []byte{0, 'a', 's', 'm'}, + Entry: "run", + Nonce: nonce, + GasLimit: core.AVMMinGasLimit, + } + copy(payload.Signer[:], publicKey) + payload.ContractID = core.DeriveAVMContractID(payload.Signer, nonce, payload.Code) + signingHash := payload.SigningHash() + copy(payload.Signature[:], stded25519.Sign(privateKey, signingHash[:])) + + var keyImage crypto.KeyImage + keyImage[0] = byte(nonce + 1) + responses := make([][32]byte, crypto.RingSize) + return core.Transaction{ + Version: core.TxVersionAVM, + Inputs: []core.RingInput{{ + KeyImage: keyImage, + Ring: make([]crypto.RingMember, crypto.RingSize), + RingCommitments: make([]crypto.Commitment, crypto.RingSize), + }}, + Outputs: []core.Output{{OneTimePub: crypto.Point32{1}}}, + Fee: 1_000_000_000, + RangeProofs: []*crypto.RangeProof{{}}, + CLSAGSignatures: []*crypto.CLSAGSignature{{S: responses}}, + AVM: payload, + } +} + +func newAVMSubmissionServer(t *testing.T, expectedNonce uint64, broadcasts *int) *api.Server { + t.Helper() + config := core.DefaultMempoolConfig() + config.AVMActivationHeight = 1 + config.RingCTCLSAGActivationHeight = 2 + config.CurrentHeight = func() uint64 { return 1 } + config.AVMNonceLookup = func([32]byte) (uint64, error) { return expectedNonce, nil } + server := api.NewServer(":0", core.NewChain(), core.NewMempool(config), core.NewUTXOSet(), testLogger()) + server.SetAVMStore(1, avm.NewMemoryStore()) + server.SetReady() + server.SetTransactionBroadcaster(func(*core.Transaction) { *broadcasts++ }) + return server +} + +func TestAVMSubmitRejectsNonceConflictAndBroadcastsValidTransaction(t *testing.T) { + var broadcasts int + for _, test := range []struct { + name string + expectedNonce uint64 + txNonce uint64 + wantStatus int + wantBroadcast int + }{ + {name: "stale nonce", expectedNonce: 7, txNonce: 6, wantStatus: http.StatusUnprocessableEntity}, + {name: "accepted", expectedNonce: 0, txNonce: 0, wantStatus: http.StatusAccepted, wantBroadcast: 1}, + } { + t.Run(test.name, func(t *testing.T) { + broadcasts = 0 + server := newAVMSubmissionServer(t, test.expectedNonce, &broadcasts) + status, body := requestAVM(t, server, "/api/v1/avm/transactions", map[string]any{ + "tx": signedAVMSubmissionForTest(t, test.txNonce), + }) + if status != test.wantStatus { + t.Fatalf("status=%d body=%s", status, body) + } + if broadcasts != test.wantBroadcast { + t.Fatalf("broadcasts=%d, want %d", broadcasts, test.wantBroadcast) + } + if test.wantStatus == http.StatusUnprocessableEntity && + !bytes.Contains(body, []byte("AVM nonce")) { + t.Fatalf("nonce conflict not reported: %s", body) + } + if test.wantStatus == http.StatusAccepted { + var response map[string]any + if err := json.Unmarshal(body, &response); err != nil { + t.Fatal(err) + } + if response["status"] != "pending" || response["version"] != float64(core.TxVersionAVM) { + t.Fatalf("unexpected submission response: %v", response) + } + } + }) + } +} diff --git a/api/rest_export.go b/api/rest_export.go index b378139..c0f740c 100644 --- a/api/rest_export.go +++ b/api/rest_export.go @@ -20,11 +20,11 @@ package api // // For each file the handler opens it before building the tar header so the size // in the header is derived from the open file descriptor (not Walk's pre-open -// FileInfo), minimising the time-of-check/time-of-use window. io.LimitReader +// FileInfo), minimizing the time-of-check/time-of-use window. io.LimitReader // caps the copy at exactly hdr.Size bytes; any shortfall is padded with zeros so // the tar block boundary stays consistent. // -// Tar entry paths are sanitised: only relative paths that do not escape the +// Tar entry paths are sanitized: only relative paths that do not escape the // archive root are included. import ( @@ -58,7 +58,7 @@ func (s *Server) requireAPIKeyOrOpen(w http.ResponseWriter, r *http.Request) boo // The client (aperod-join.sh) writes this file directly to the new node's // data_dir; the node reads it on startup via the fast-path logic in snapshot.go. // -// Snapshot filename format: snapshot-v2-.json.gz +// Snapshot filename format: snapshot-v2-.json.gz. func (s *Server) restSnapshotExport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") @@ -191,7 +191,7 @@ func (s *Server) restChainDBExport(w http.ResponseWriter, r *http.Request) { return nil } - // Compute and sanitise the archive entry path. + // Compute and sanitize the archive entry path. rel, relErr := filepath.Rel(dataDir, path) if relErr != nil { return nil @@ -224,7 +224,7 @@ func (s *Server) restChainDBExport(w http.ResponseWriter, r *http.Request) { // Open the file before building the tar header so the size comes from // the open fd (via Stat), not from Walk's pre-open FileInfo. This - // minimises the TOCTOU window: Walk captures FileInfo before the file + // minimizes the TOCTOU window: Walk captures FileInfo before the file // is opened; if a compaction replaced the file in between, the size in // the header would disagree with bytes copied. f, openErr := os.Open(path) diff --git a/api/rest_stake_test.go b/api/rest_stake_test.go index f6f3e38..4810ac9 100644 --- a/api/rest_stake_test.go +++ b/api/rest_stake_test.go @@ -25,6 +25,7 @@ import ( // a payload whose Pedersen commitment matches the UTXO stored in the server. type stakeServerFixture struct { srv *api.Server + mp *core.Mempool utxoTxHash crypto.Hash32 outIdx uint32 commit crypto.Commitment @@ -44,7 +45,7 @@ func buildStakeServerFull(t *testing.T) stakeServerFixture { mp := core.NewMempool(core.DefaultMempoolConfig()) utxos := core.NewUTXOSet() - // Synthesise a deterministic UTXO (amount = 100 000 APRO = 10_000_000_000_000 nAPRO). + // Synthesize a deterministic UTXO (amount = 100 000 APRO = 10_000_000_000_000 nAPRO). const amount uint64 = 10_000_000_000_000 priv, pub, err := crypto.GenerateValidatorKey() if err != nil { @@ -79,8 +80,10 @@ func buildStakeServerFull(t *testing.T) stakeServerFixture { utxos.Add(utxo) srv := api.NewServer(":0", chain, mp, utxos, testLogger()) + srv.SetAPIKey(testStakeAPIKey) return stakeServerFixture{ srv: srv, + mp: mp, utxoTxHash: txHash, outIdx: outIdx, commit: c, @@ -91,6 +94,8 @@ func buildStakeServerFull(t *testing.T) stakeServerFixture { } } +const testStakeAPIKey = "test-stake-api-key" + // buildStakeServer is a backward-compatible wrapper for tests that only need // the server, txhash, outIdx, and commitment. func buildStakeServer(t *testing.T) ( @@ -166,9 +171,16 @@ func makeV2StakeExtra(t *testing.T) ([]byte, crypto.ValidatorPubKey) { } func restPost(t *testing.T, srv *api.Server, path string, body string) (int, map[string]interface{}) { + return restPostWithAPIKey(t, srv, path, body, testStakeAPIKey) +} + +func restPostWithAPIKey(t *testing.T, srv *api.Server, path string, body, apiKey string) (int, map[string]interface{}) { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") + if apiKey != "" { + req.Header.Set("X-API-Key", apiKey) + } rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) var resp map[string]interface{} @@ -273,6 +285,30 @@ func TestREST_StakeBroadcast_ValidPayload(t *testing.T) { } } +func TestREST_StakeBroadcast_RequiresAPIKey(t *testing.T) { + f := buildStakeServerFull(t) + extra := makeV2StakeExtraFor(t, f) + body := fmt.Sprintf(`{"tx_extra_hex":%q}`, hex.EncodeToString(extra)) + + for _, tc := range []struct { + name string + key string + }{ + {name: "missing key"}, + {name: "wrong key", key: "wrong-api-key"}, + } { + t.Run(tc.name, func(t *testing.T) { + code, _ := restPostWithAPIKey(t, f.srv, "/api/v1/stake", body, tc.key) + if code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", code) + } + if got := f.mp.Count(); got != 0 { + t.Fatalf("mempool count = %d after unauthorized request, want 0", got) + } + }) + } +} + func TestREST_StakeBroadcast_DuplicateRejected(t *testing.T) { // Use the full fixture so the payload's Pedersen commitment matches the // UTXO stored in the server (handler now verifies this pre-mempool). diff --git a/api/rest_test.go b/api/rest_test.go index 3c083b5..9ba2d93 100644 --- a/api/rest_test.go +++ b/api/rest_test.go @@ -484,6 +484,24 @@ func TestREST_AddressTxsBoundsScanAndPaginates(t *testing.T) { if code, _ := restGet(t, srv, path+"?start_height=not-a-height"); code != http.StatusBadRequest { t.Fatalf("invalid start_height status = %d, want 400", code) } + + code, trailingSlash := restGet(t, srv, path+"/") + if code != http.StatusOK || trailingSlash["scan_limited"] != true { + t.Fatalf("trailing-slash page status/scan_limited = %d/%v, want 200/true", code, trailingSlash["scan_limited"]) + } +} + +func TestREST_AddressTxsRejectsUnknownSubpaths(t *testing.T) { + srv, _ := buildChainServer(t, 0) + wk, _ := crypto.GenerateWalletKeys() + addr := crypto.AddressFromKeys(crypto.MainnetByte, wk) + + for _, suffix := range []string{"/transactions/extra", "/unknown"} { + code, _ := restGet(t, srv, "/api/v1/address/"+addr.String()+suffix) + if code != http.StatusNotFound { + t.Errorf("suffix %q status = %d, want 404", suffix, code) + } + } } func TestREST_AddressTxs_MethodNotAllowed(t *testing.T) { @@ -946,7 +964,7 @@ func TestREST_AddressUTXOs_MintHeightMatch(t *testing.T) { // TestREST_AddressUTXOs_StealthNotReturnedWithoutViewKey confirms that a // genuine stealth UTXO (TxPubKey ≠ zero, OneTimePub derived via ECDH) is // intentionally NOT returned by the address/utxos endpoint when no view key is -// supplied. This is the expected behaviour, not a bug — the server cannot +// supplied. This is the expected behavior, not a bug — the server cannot // reverse the Diffie-Hellman without the receiver's private view key. // // The second sub-test verifies that supplying view_key_hex resolves the gap: @@ -1077,7 +1095,7 @@ func TestREST_AddressUTXOs_RollbackTransparent(t *testing.T) { } } -// TestREST_AddressUTXOs_RollbackCoinbase verifies the same rollback behaviour for +// TestREST_AddressUTXOs_RollbackCoinbase verifies the same rollback behavior for // coinbase (height-offset mint) outputs, where OneTimePub = spend_pub + height*G. func TestREST_AddressUTXOs_RollbackCoinbase(t *testing.T) { srv, utxos := buildUTXOServer(t) @@ -1267,7 +1285,7 @@ func TestREST_ScanOutputs_ReturnsStealthFields(t *testing.T) { } } -// TestREST_ScanOutputs_Pagination verifies that limit and from_height are honoured. +// TestREST_ScanOutputs_Pagination verifies that limit and from_height are honored. func TestREST_ScanOutputs_Pagination(t *testing.T) { srv, _ := buildChainServer(t, 5) @@ -1371,6 +1389,25 @@ func TestREST_AddressScan_PostViewKey(t *testing.T) { } } +func TestREST_AddressScan_RejectsOversizedBody(t *testing.T) { + srv, _ := buildUTXOServer(t) + body := append([]byte(`{"view_key_hex":"`), bytes.Repeat([]byte("a"), 5000)...) + body = append(body, []byte(`"}`)...) + code, _ := restPostJSON(t, srv, "/api/v1/address/invalid/scan", body) + if code != http.StatusBadRequest { + t.Fatalf("oversized scan body status = %d, want 400", code) + } +} + +func TestREST_AddressScan_RejectsMalformedViewKeyBeforeScanning(t *testing.T) { + srv, _ := buildUTXOServer(t) + body := []byte(`{"view_key_hex":"not-hex"}`) + code, _ := restPostJSON(t, srv, "/api/v1/address/invalid/scan", body) + if code != http.StatusBadRequest { + t.Fatalf("malformed view key status = %d, want 400", code) + } +} + func TestWS_Endpoint_Registered(t *testing.T) { // golang.org/x/net/websocket requires http.Hijacker; use a real test server. srv, _ := newTestServer(t) diff --git a/api/rest_tx_disk_lookup_test.go b/api/rest_tx_disk_lookup_test.go index 1142348..72228c4 100644 --- a/api/rest_tx_disk_lookup_test.go +++ b/api/rest_tx_disk_lookup_test.go @@ -7,7 +7,7 @@ package api_test // This is the regression test for the block_hash bug: the original // getTransactionFromDisk built a synthetic TxLocation.Block with only // Header.Height set. loc.Block.Hash() then produced a wrong hash because -// Block.Hash() serialises the full block — a minimal stub has different bytes. +// Block.Hash() serializes the full block — a minimal stub has different bytes. // The fix uses the full deserialized block (&b) so Hash() is correct. // // The test also confirms is_coinbase, inputs, outputs, fee, and version are diff --git a/api/rest_utxo_block_ts_test.go b/api/rest_utxo_block_ts_test.go index 94e7bd4..f660499 100644 --- a/api/rest_utxo_block_ts_test.go +++ b/api/rest_utxo_block_ts_test.go @@ -30,10 +30,10 @@ func addUTXOAtHeight(utxos *core.UTXOSet, txHashByte byte, height uint64) (crypt var commit [32]byte commit[0] = 0xAB utxos.Add(&core.UTXO{ - TxHash: txHash, - OutputIndex: 0, - AmountCommit: commit, - BlockHeight: height, + TxHash: txHash, + OutputIndex: 0, + AmountCommit: commit, + BlockHeight: height, }) return txHash, hex.EncodeToString(commit[:]) } diff --git a/api/rest_utxo_prune_warning_test.go b/api/rest_utxo_prune_warning_test.go index e69d7da..eb07fa7 100644 --- a/api/rest_utxo_prune_warning_test.go +++ b/api/rest_utxo_prune_warning_test.go @@ -86,9 +86,9 @@ func pruneGET(t *testing.T, srv *api.Server, txHash crypto.Hash32) (int, map[str // tipHeight = 18 → blocksLeft = 2 (exactly at threshold → present) func TestUTXOPruneWarning_FieldPresentWhenClose(t *testing.T) { const ( - keepBlocks uint64 = 20 - tipHeight = 18 // blocksLeft = 20 − 18 = 2 ≤ threshold(2) - utxoBlockHeight uint64 = 0 + keepBlocks uint64 = 20 + tipHeight = 18 // blocksLeft = 20 − 18 = 2 ≤ threshold(2) + utxoBlockHeight uint64 = 0 wantBlocksLeft float64 = 2 ) @@ -118,9 +118,9 @@ func TestUTXOPruneWarning_FieldPresentWhenClose(t *testing.T) { // tipHeight = 19 → blocksLeft = 1 (< threshold → present, value = 1) func TestUTXOPruneWarning_FieldPresentWithCorrectValue(t *testing.T) { const ( - keepBlocks uint64 = 20 - tipHeight = 19 - utxoBlockHeight uint64 = 0 + keepBlocks uint64 = 20 + tipHeight = 19 + utxoBlockHeight uint64 = 0 wantBlocksLeft float64 = 1 ) @@ -273,9 +273,9 @@ func TestUTXOPruneWarning_FieldAbsentWhenKeepBlocksZero(t *testing.T) { // tipHeight=90 → blocksLeft=10 == threshold → present func TestUTXOPruneWarning_ExactlyAtThreshold(t *testing.T) { const ( - keepBlocks uint64 = 100 - tipHeight = 90 - utxoBlockHeight uint64 = 0 + keepBlocks uint64 = 100 + tipHeight = 90 + utxoBlockHeight uint64 = 0 wantBlocksLeft float64 = 10 ) diff --git a/api/rest_utxo_viewkey_test.go b/api/rest_utxo_viewkey_test.go index a643cef..e346ac9 100644 --- a/api/rest_utxo_viewkey_test.go +++ b/api/rest_utxo_viewkey_test.go @@ -10,6 +10,7 @@ import ( "encoding/hex" "fmt" "net/http" + "strings" "testing" "github.com/aperod/aperod/core" @@ -84,6 +85,26 @@ func TestREST_AddressUTXOs_StealthDiscoveredByViewKeyHeader(t *testing.T) { } } +func TestREST_AddressUTXOs_RejectsMalformedViewKeyHeader(t *testing.T) { + srv, _ := buildUTXOServer(t) + + wk, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) + + for _, viewKey := range []string{ + strings.Repeat("a", 1<<16), + strings.Repeat("g", 64), + } { + code, _ := restGetHeader(t, srv, "/api/v1/address/"+string(addr)+"/utxos", "X-View-Key", viewKey) + if code != http.StatusBadRequest { + t.Errorf("status for malformed X-View-Key length %d = %d, want 400", len(viewKey), code) + } + } +} + // TestREST_AddressUTXOs_StealthDiscoveredByNodeViewKey verifies that a stealth // output is discovered and decoded when the view key is configured on the node // via SetNodeViewKey (as it would be from node.yaml view_key), with no query param. diff --git a/api/restart_survival_test.go b/api/restart_survival_test.go index f7d1e1a..9819c7b 100644 --- a/api/restart_survival_test.go +++ b/api/restart_survival_test.go @@ -1,7 +1,7 @@ package api_test // TestREST_NetworkStats_TxCountSurvivesRestart verifies that total_txs in -// /api/v1/network/stats is correctly initialised after a node restart. +// /api/v1/network/stats is correctly initialized after a node restart. // // Regression test for the bug where the count was rebuilt during the // key-image scan in cmd/node/main.go but never wired to the API server, @@ -27,7 +27,7 @@ import ( "github.com/aperod/aperod/store" ) -// storeBlockForTest serialises b and writes it into db, mirroring the +// storeBlockForTest serializes b and writes it into db, mirroring the // storeBlock helper in cmd/node/main.go (which lives in package main and // cannot be imported by tests). func storeBlockForTest(t *testing.T, db *store.DB, b *core.Block) { @@ -141,7 +141,7 @@ func simulateRestartScan(t *testing.T, db *store.DB, tipHeight uint64) int64 { t.Fatalf("unmarshal block h=%d: %v", h, err) } for txIdx, tx := range b.Txs { - if !(txIdx == 0 && tx.IsCoinbase()) { + if txIdx != 0 || !tx.IsCoinbase() { total++ } } @@ -260,58 +260,58 @@ func TestREST_NetworkStats_TxCountIncrements(t *testing.T) { // called again, /api/v1/network/identity must still return the correct values. func TestREST_NodeIdentity_AccessibleAfterRestart(t *testing.T) { -const ( - wantNodeID = "deadbeef01234567" - wantFingerpr = "AA:BB:CC:DD:EE:FF" - wantListenAddr = "0.0.0.0:30303" -) + const ( + wantNodeID = "deadbeef01234567" + wantFingerpr = "AA:BB:CC:DD:EE:FF" + wantListenAddr = "0.0.0.0:30303" + ) -_, chain := buildSeededStore(t, 1, 0) -mp := core.NewMempool(core.DefaultMempoolConfig()) -utxos := core.NewUTXOSet() + _, chain := buildSeededStore(t, 1, 0) + mp := core.NewMempool(core.DefaultMempoolConfig()) + utxos := core.NewUTXOSet() -// Simulate fresh node start -srv := api.NewServer(":0", chain, mp, utxos, testLogger()) -srv.SetNodeIdentity(wantFingerpr, wantListenAddr, wantNodeID) + // Simulate fresh node start + srv := api.NewServer(":0", chain, mp, utxos, testLogger()) + srv.SetNodeIdentity(wantFingerpr, wantListenAddr, wantNodeID) -// First request — must return identity -req := httptest.NewRequest(http.MethodGet, "/api/v1/network/identity", nil) -rr := httptest.NewRecorder() -srv.ServeHTTP(rr, req) -if rr.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rr.Code) -} -var first map[string]interface{} -_ = json.NewDecoder(rr.Body).Decode(&first) -if first["tls_fingerprint"] != wantFingerpr { - t.Errorf("tls_fingerprint = %v, want %q", first["tls_fingerprint"], wantFingerpr) -} -if first["node_id"] != wantNodeID { - t.Errorf("node_id = %v, want %q", first["node_id"], wantNodeID) -} -if first["listen_addr"] != wantListenAddr { - t.Errorf("listen_addr = %v, want %q", first["listen_addr"], wantListenAddr) -} + // First request — must return identity + req := httptest.NewRequest(http.MethodGet, "/api/v1/network/identity", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + var first map[string]interface{} + _ = json.NewDecoder(rr.Body).Decode(&first) + if first["tls_fingerprint"] != wantFingerpr { + t.Errorf("tls_fingerprint = %v, want %q", first["tls_fingerprint"], wantFingerpr) + } + if first["node_id"] != wantNodeID { + t.Errorf("node_id = %v, want %q", first["node_id"], wantNodeID) + } + if first["listen_addr"] != wantListenAddr { + t.Errorf("listen_addr = %v, want %q", first["listen_addr"], wantListenAddr) + } -// Simulate a restart: recreate the server and re-apply identity -srv2 := api.NewServer(":0", chain, mp, utxos, testLogger()) -srv2.SetNodeIdentity(wantFingerpr, wantListenAddr, wantNodeID) + // Simulate a restart: recreate the server and re-apply identity + srv2 := api.NewServer(":0", chain, mp, utxos, testLogger()) + srv2.SetNodeIdentity(wantFingerpr, wantListenAddr, wantNodeID) -req2 := httptest.NewRequest(http.MethodGet, "/api/v1/network/identity", nil) -rr2 := httptest.NewRecorder() -srv2.ServeHTTP(rr2, req2) -if rr2.Code != http.StatusOK { - t.Fatalf("post-restart status = %d, want 200", rr2.Code) -} -var second map[string]interface{} -_ = json.NewDecoder(rr2.Body).Decode(&second) -if second["tls_fingerprint"] != wantFingerpr { - t.Errorf("post-restart tls_fingerprint = %v, want %q", second["tls_fingerprint"], wantFingerpr) -} -if second["node_id"] != wantNodeID { - t.Errorf("post-restart node_id = %v, want %q", second["node_id"], wantNodeID) -} -if second["listen_addr"] != wantListenAddr { - t.Errorf("post-restart listen_addr = %v, want %q", second["listen_addr"], wantListenAddr) -} + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/network/identity", nil) + rr2 := httptest.NewRecorder() + srv2.ServeHTTP(rr2, req2) + if rr2.Code != http.StatusOK { + t.Fatalf("post-restart status = %d, want 200", rr2.Code) + } + var second map[string]interface{} + _ = json.NewDecoder(rr2.Body).Decode(&second) + if second["tls_fingerprint"] != wantFingerpr { + t.Errorf("post-restart tls_fingerprint = %v, want %q", second["tls_fingerprint"], wantFingerpr) + } + if second["node_id"] != wantNodeID { + t.Errorf("post-restart node_id = %v, want %q", second["node_id"], wantNodeID) + } + if second["listen_addr"] != wantListenAddr { + t.Errorf("post-restart listen_addr = %v, want %q", second["listen_addr"], wantListenAddr) + } } diff --git a/api/rpc_wallet.go b/api/rpc_wallet.go index 72afb22..ff82a14 100644 --- a/api/rpc_wallet.go +++ b/api/rpc_wallet.go @@ -67,9 +67,9 @@ type walletSendResult struct { // against GET /api/v1/keyimage/{ki}/is-spent gives an authoritative // Monero-style spent check without needing the wallet's private key. SpentKeyImages []spentInputKI `json:"spent_key_images,omitempty"` - IsBurn bool `json:"is_burn"` - BurnedNAPRO string `json:"burned_napro"` - BurnAddress string `json:"burn_address"` + IsBurn bool `json:"is_burn"` + BurnedNAPRO string `json:"burned_napro"` + BurnAddress string `json:"burn_address"` } // aprWalletSend builds, signs, verifies, and submits a real RingCT transaction. @@ -173,15 +173,15 @@ func (s *Server) aprWalletSend(ctx context.Context, rawParams json.RawMessage) ( // ── 6. Cryptographic verification ──────────────────────────────────────── // Phase 2: all ring members are real on-chain UTXOs — enable strict C-0 check. verifier := core.NewTxVerifier(s.utxos) -verifier.SetStakedCollateralRingExclusionActivationHeight(s.stakedCollateralRingExclusionActivationHeight) -verifyErr := verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()) + verifier.SetStakedCollateralRingExclusionActivationHeight(s.stakedCollateralRingExclusionActivationHeight) + verifyErr := verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()) if isKeyImageConflictErr(verifyErr) { repaired, repairErr := s.repairPhantomKeyImages(result) if repairErr != nil { return nil, fmt.Errorf("verify: %w", repairErr) } if repaired { -verifyErr = verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()) + verifyErr = verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()) } } if verifyErr != nil { @@ -320,7 +320,7 @@ func (s *Server) resolveOwnedUTXO(u walletUTXOInput, viewPriv crypto.Scalar32, s "tx", u.TxHash[:min(16, len(u.TxHash))], "out_idx", u.OutIdx, "height", su.BlockHeight, "commit", fmt.Sprintf("%x", su.AmountCommit[:8])) - // Synthesise core.Output from the stored UTXO fields. + // Synthesize core.Output from the stored UTXO fields. // Only OneTimePub, TxPubKey, and AmountCommit are needed for // RingCT input construction; EncAmount is not used downstream. out = core.Output{ @@ -373,13 +373,13 @@ func (s *Server) resolveOwnedUTXO(u walletUTXOInput, viewPriv crypto.Scalar32, s // a subsequent node restart finds this UTXO // in LevelDB without needing Fallback 4 again. healEntry := &store.StoredUTXO{ - TxHash: txHash, - OutputIndex: u.OutIdx, - OneTimePub: out.OneTimePub, - TxPubKey: out.TxPubKey, - AmountCommit: out.AmountCommit, - EncAmount: out.EncAmount, - BlockHeight: blk.Header.Height, + TxHash: txHash, + OutputIndex: u.OutIdx, + OneTimePub: out.OneTimePub, + TxPubKey: out.TxPubKey, + AmountCommit: out.AmountCommit, + EncAmount: out.EncAmount, + BlockHeight: blk.Header.Height, ProtocolLocked: bTx.IsGuardianFund(), } if putErr := s.blockStore.PutUTXO(txHash, u.OutIdx, healEntry); putErr != nil { @@ -420,13 +420,13 @@ func (s *Server) resolveOwnedUTXO(u walletUTXOInput, viewPriv crypto.Scalar32, s // catch it — the operator can re-mint to get a clean entry. if s.blockStore != nil { snapHeal := &store.StoredUTXO{ - TxHash: txHash, - OutputIndex: u.OutIdx, - OneTimePub: memUTXO.OneTimePub, - TxPubKey: memUTXO.TxPubKey, - AmountCommit: memUTXO.AmountCommit, - EncAmount: memUTXO.EncAmount, - BlockHeight: memUTXO.BlockHeight, + TxHash: txHash, + OutputIndex: u.OutIdx, + OneTimePub: memUTXO.OneTimePub, + TxPubKey: memUTXO.TxPubKey, + AmountCommit: memUTXO.AmountCommit, + EncAmount: memUTXO.EncAmount, + BlockHeight: memUTXO.BlockHeight, ProtocolLocked: memUTXO.ProtocolLocked, } if putErr := s.blockStore.PutUTXO(txHash, u.OutIdx, snapHeal); putErr != nil { diff --git a/api/rpc_wallet_batch.go b/api/rpc_wallet_batch.go index d3a14b7..7b3edac 100644 --- a/api/rpc_wallet_batch.go +++ b/api/rpc_wallet_batch.go @@ -22,11 +22,11 @@ type batchRecipientParam struct { } type walletBatchSendParams struct { - SpendKeyHex string `json:"spend_key_hex"` - ViewKeyHex string `json:"view_key_hex"` - ChangeAddress string `json:"change_address"` // empty → derived from keys + SpendKeyHex string `json:"spend_key_hex"` + ViewKeyHex string `json:"view_key_hex"` + ChangeAddress string `json:"change_address"` // empty → derived from keys Recipients []batchRecipientParam `json:"recipients"` // max 15 - UTXOs []walletUTXOInput `json:"utxos"` + UTXOs []walletUTXOInput `json:"utxos"` } type batchPayOutput struct { @@ -290,8 +290,8 @@ func (s *Server) aprWalletBatchSend(rawParams json.RawMessage) (interface{}, err // ── 5. Cryptographic verification ──────────────────────────────────────── verifier := core.NewTxVerifier(s.utxos) -verifier.SetStakedCollateralRingExclusionActivationHeight(s.stakedCollateralRingExclusionActivationHeight) -if err := verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()); err != nil { + verifier.SetStakedCollateralRingExclusionActivationHeight(s.stakedCollateralRingExclusionActivationHeight) + if err := verifier.VerifyTxAtHeight(&result.Tx, s.nextBlockHeight()); err != nil { return nil, fmt.Errorf("verify: %w", err) } diff --git a/api/sale.go b/api/sale.go index 58b2f6f..ffa0064 100644 --- a/api/sale.go +++ b/api/sale.go @@ -20,13 +20,17 @@ import ( func (s *Server) SetSaleConfig(m *store.SaleMigration, ready func() bool, finalized ...func(uint64, crypto.Hash32) bool) { s.saleMigration = m s.saleProducerReady = ready -if len(finalized) == 1 { s.saleFinalized = finalized[0] } + if len(finalized) == 1 { + s.saleFinalized = finalized[0] + } } func (s *Server) isSaleFinalized(height uint64, hash crypto.Hash32) bool { -f := s.saleFinalized -if f == nil { f = s.lpodFinalized } -return f != nil && f(height, hash) + f := s.saleFinalized + if f == nil { + f = s.lpodFinalized + } + return f != nil && f(height, hash) } func saleHash(text string) (crypto.Hash32, error) { @@ -60,7 +64,7 @@ func decodeSaleActivation(raw []byte) (*core.Block, error) { func (s *Server) activeSale(requireProducer bool) (*sale.State, error) { m := s.saleMigration -if m == nil || s.blockStore == nil || s.myKey == nil || (s.saleFinalized == nil && s.lpodFinalized == nil) || + if m == nil || s.blockStore == nil || s.myKey == nil || (s.saleFinalized == nil && s.lpodFinalized == nil) || (requireProducer && (s.saleProducerReady == nil || !s.saleProducerReady())) || !s.myKey.Public().Equals(m.Checkpoint.SaleAuthority) { return nil, fmt.Errorf("authorized sale authority unavailable") @@ -88,7 +92,7 @@ if m == nil || s.blockStore == nil || s.myKey == nil || (s.saleFinalized == nil return nil, err } activation, err := decodeSaleActivation(raw) -if err != nil || activation.Header.PrevHash != cp.Parent || !s.isSaleFinalized(cp.Height, activation.Hash()) { + if err != nil || activation.Header.PrevHash != cp.Parent || !s.isSaleFinalized(cp.Height, activation.Hash()) { return nil, fmt.Errorf("sale activation not finalized") } return state, nil @@ -156,7 +160,10 @@ func (s *Server) restSaleStatus(w http.ResponseWriter, r *http.Request) { // Private, authenticated diagnostics: closed codes only, never keys, endpoint // addresses, checkpoint hashes, wallet data or a claim of settled payment. func (s *Server) restSaleReadiness(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { saleJSON(w, 405, map[string]string{"error":"method not allowed"}); return } + if r.Method != http.MethodGet { + saleJSON(w, 405, map[string]string{"error": "method not allowed"}) + return + } code := "READY" switch { case s.saleMigration == nil: @@ -175,7 +182,7 @@ func (s *Server) restSaleReadiness(w http.ResponseWriter, r *http.Request) { code = "NO_READY_PRODUCER" } } - saleJSON(w, 200, map[string]any{"code":code, "ready":code=="READY"}) + saleJSON(w, 200, map[string]any{"code": code, "ready": code == "READY"}) } func saleAllocationBasis(r sale.Reconciliation) string { @@ -288,7 +295,7 @@ func (s *Server) restSaleReceipt(w http.ResponseWriter, r *http.Request) { result["tx_hash"] = saleHex(delivery.Transaction) result["block_hash"] = saleHex(delivery.Block) result["block_height"] = delivery.Height -if s.isSaleFinalized(delivery.Height, delivery.Block) { + if s.isSaleFinalized(delivery.Height, delivery.Block) { result["status"] = "finalized" result["owner_challenge"] = store.SaleRecoveryChallenge(s.saleMigration.Checkpoint.Genesis, order, delivery) recovered, recoveryErr := s.blockStore.SaleOrderRecovery(id) @@ -329,7 +336,7 @@ func (s *Server) restSaleRecover(w http.ResponseWriter, r *http.Request) { } delivery, err := s.blockStore.SaleOrderDelivery(id) if err != nil || delivery == nil || delivery.Block != block || delivery.Transaction != tx || -!s.isSaleFinalized(delivery.Height, delivery.Block) { + !s.isSaleFinalized(delivery.Height, delivery.Block) { saleJSON(w, 409, map[string]string{"error": "canonical finalized delivery required"}) return } diff --git a/api/sale_test.go b/api/sale_test.go index 9fa537a..271cd95 100644 --- a/api/sale_test.go +++ b/api/sale_test.go @@ -1,7 +1,7 @@ package api import ( -"encoding/json" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -9,25 +9,34 @@ import ( ) func TestSaleReadinessIsPrivateAndClosed(t *testing.T) { -s := &Server{} -s.mux = http.NewServeMux() -s.registerRESTRoutes() -path := "/api/v1/admin/protocol-sales/readiness" -for _, tc := range []struct{ remote, host string; status int }{ -{"203.0.113.9:1234", "localhost", http.StatusForbidden}, -{"127.0.0.1:1234", "localhost", http.StatusUnauthorized}, -} { -req := httptest.NewRequest(http.MethodGet, "http://"+tc.host+path, nil) -req.RemoteAddr = tc.remote -w := httptest.NewRecorder() -s.mux.ServeHTTP(w, req) -if w.Code != tc.status { t.Fatalf("private readiness status %d, expected %d",w.Code,tc.status) } -} -w := httptest.NewRecorder() -s.restSaleReadiness(w,httptest.NewRequest(http.MethodGet,path,nil)) -var result map[string]any -if err := json.Unmarshal(w.Body.Bytes(),&result); err != nil { t.Fatal(err) } -if len(result)!=2 || result["code"]!="SALE_NOT_ACTIVATED" || result["ready"]!=false { t.Fatal(result) } + s := &Server{} + s.mux = http.NewServeMux() + s.registerRESTRoutes() + path := "/api/v1/admin/protocol-sales/readiness" + for _, tc := range []struct { + remote, host string + status int + }{ + {"203.0.113.9:1234", "localhost", http.StatusForbidden}, + {"127.0.0.1:1234", "localhost", http.StatusUnauthorized}, + } { + req := httptest.NewRequest(http.MethodGet, "http://"+tc.host+path, nil) + req.RemoteAddr = tc.remote + w := httptest.NewRecorder() + s.mux.ServeHTTP(w, req) + if w.Code != tc.status { + t.Fatalf("private readiness status %d, expected %d", w.Code, tc.status) + } + } + w := httptest.NewRecorder() + s.restSaleReadiness(w, httptest.NewRequest(http.MethodGet, path, nil)) + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result) != 2 || result["code"] != "SALE_NOT_ACTIVATED" || result["ready"] != false { + t.Fatal(result) + } } func TestSaleEndpointsFailClosedBeforeConfiguration(t *testing.T) { diff --git a/api/server.go b/api/server.go index fbbc4c3..d5023ff 100644 --- a/api/server.go +++ b/api/server.go @@ -28,7 +28,7 @@ import ( "github.com/aperod/aperod/store" ) -// utxoCacheEntry holds a serialised /address/{addr}/utxos response body and +// utxoCacheEntry holds a serialized /address/{addr}/utxos response body and // an expiry timestamp. Entries are stored in Server.utxoAddrCache and evicted // lazily on next access after they expire. type utxoCacheEntry struct { @@ -36,6 +36,99 @@ type utxoCacheEntry struct { expiresAt time.Time } +const ( + maxUTXOAddrCacheEntries = 128 + maxUTXOAddrCacheBytes = 32 << 20 +) + +// boundedUTXOAddrCache keeps the public address-scan response cache bounded +// even when callers vary addresses or view keys. Expired entries are swept on +// writes, and the byte/entry ceilings prevent high-cardinality requests from +// growing process memory without limit. +type boundedUTXOAddrCache struct { + mu sync.Mutex + entries map[string]*utxoCacheEntry + bytes int +} + +func (c *boundedUTXOAddrCache) load(key string, now time.Time) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry := c.entries[key] + if entry == nil { + return nil, false + } + if !now.Before(entry.expiresAt) { + c.deleteLocked(key) + return nil, false + } + return entry.body, true +} + +func (c *boundedUTXOAddrCache) store(key string, entry *utxoCacheEntry, now time.Time) { + if entry == nil || !now.Before(entry.expiresAt) { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = make(map[string]*utxoCacheEntry) + } + c.pruneExpiredLocked(now) + c.deleteLocked(key) + if len(entry.body) > maxUTXOAddrCacheBytes { + return + } + for len(c.entries) >= maxUTXOAddrCacheEntries || + c.bytes+len(entry.body) > maxUTXOAddrCacheBytes { + if !c.evictOldestLocked() { + return + } + } + body := append([]byte(nil), entry.body...) + c.entries[key] = &utxoCacheEntry{body: body, expiresAt: entry.expiresAt} + c.bytes += len(body) +} + +func (c *boundedUTXOAddrCache) clear() { + c.mu.Lock() + c.entries = nil + c.bytes = 0 + c.mu.Unlock() +} + +func (c *boundedUTXOAddrCache) pruneExpiredLocked(now time.Time) { + for key, entry := range c.entries { + if !now.Before(entry.expiresAt) { + c.deleteLocked(key) + } + } +} + +func (c *boundedUTXOAddrCache) deleteLocked(key string) { + if entry := c.entries[key]; entry != nil { + c.bytes -= len(entry.body) + delete(c.entries, key) + } +} + +func (c *boundedUTXOAddrCache) evictOldestLocked() bool { + var oldestKey string + var oldestExpiry time.Time + found := false + for key, entry := range c.entries { + if !found || entry.expiresAt.Before(oldestExpiry) { + oldestKey = key + oldestExpiry = entry.expiresAt + found = true + } + } + if found { + c.deleteLocked(oldestKey) + } + return found +} + // utxoCacheTTL is how long a cached /address/{addr}/utxos response is kept. // The mint-UTXO monitor polls every 5 minutes, so a 90-second TTL means at // most one live scan per address per monitor cycle instead of one per call. @@ -45,7 +138,7 @@ const utxoCacheTTL = 90 * time.Second type Server struct { saleMigration *store.SaleMigration saleProducerReady func() bool -saleFinalized func(uint64, crypto.Hash32) bool + saleFinalized func(uint64, crypto.Hash32) bool lpodMigration *store.LPoDMigration genesisVestingHeight uint64 validatorAuthorityFork *core.ValidatorAuthorityFork @@ -284,7 +377,7 @@ saleFinalized func(uint64, crypto.Hash32) bool // responses. The mint-UTXO monitor calls this endpoint every 5 minutes // for each admin-mint address; without caching each call does an O(n) // UTXO scan with Ed25519 point ops that pins all CPU cores for 20-30 s. - utxoAddrCache sync.Map // key: addr+"|"+viewKeyHex value: *utxoCacheEntry + utxoAddrCache boundedUTXOAddrCache // dataDir is the node's data directory (cfg.DataDir). Set via SetDataDir // after the API server is created; used by snapshot and chaindb export @@ -311,14 +404,14 @@ saleFinalized func(uint64, crypto.Hash32) bool // avmStore is the canonical AVM state reader used by the public, // read-only contract API. It never receives private signing material. - avmStore avm.Store - avmActivationHeight uint64 - avmGasBurnActivationHeight uint64 -stakedCollateralRingExclusionActivationHeight uint64 - guardianFundActivationHeight uint64 - guardianFundChainAnchor crypto.Hash32 - broadcastTxMu sync.RWMutex - broadcastTxFn func(*core.Transaction) + avmStore avm.Store + avmActivationHeight uint64 + avmGasBurnActivationHeight uint64 + stakedCollateralRingExclusionActivationHeight uint64 + guardianFundActivationHeight uint64 + guardianFundChainAnchor crypto.Hash32 + broadcastTxMu sync.RWMutex + broadcastTxFn func(*core.Transaction) } // NewServer creates a new API server. @@ -589,10 +682,7 @@ func (s *Server) SetNodeViewKey(hexKey string) { s.nodeViewKeyHex = hexKey } // Intended for use in tests that mutate the UTXO set and then require a fresh // response from the REST handler on the very next call, bypassing the normal TTL. func (s *Server) FlushUTXOCache() { - s.utxoAddrCache.Range(func(k, _ interface{}) bool { - s.utxoAddrCache.Delete(k) - return true - }) + s.utxoAddrCache.clear() } // SetPeerCounter wires a function returning the live P2P peer count so diff --git a/api/utxo_address_cache_test.go b/api/utxo_address_cache_test.go new file mode 100644 index 0000000..ac23b26 --- /dev/null +++ b/api/utxo_address_cache_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "fmt" + "testing" + "time" +) + +func TestBoundedUTXOAddrCacheEvictsOldEntriesAtCapacity(t *testing.T) { + var cache boundedUTXOAddrCache + now := time.Now() + for i := 0; i <= maxUTXOAddrCacheEntries; i++ { + cache.store(fmt.Sprintf("key-%d", i), &utxoCacheEntry{ + body: []byte("response"), + expiresAt: now.Add(time.Minute + time.Duration(i)*time.Second), + }, now) + } + + if got := len(cache.entries); got != maxUTXOAddrCacheEntries { + t.Fatalf("cached entries = %d, want at most %d", got, maxUTXOAddrCacheEntries) + } + if _, ok := cache.load("key-0", now); ok { + t.Fatal("oldest cache entry was not evicted") + } + if _, ok := cache.load(fmt.Sprintf("key-%d", maxUTXOAddrCacheEntries), now); !ok { + t.Fatal("newest cache entry was evicted") + } +} + +func TestBoundedUTXOAddrCacheDeletesExpiredEntries(t *testing.T) { + var cache boundedUTXOAddrCache + now := time.Now() + cache.store("expired", &utxoCacheEntry{ + body: []byte("response"), + expiresAt: now.Add(-time.Second), + }, now.Add(-2*time.Second)) + + // A cache entry that is no longer valid is never returned; expired entries + // are also removed while a later write sweeps the map. + cache.store("live", &utxoCacheEntry{ + body: []byte("response"), + expiresAt: now.Add(time.Minute), + }, now) + if _, ok := cache.load("expired", now); ok { + t.Fatal("expired entry was returned") + } + if _, ok := cache.load("live", now); !ok { + t.Fatal("live entry was not retained") + } +} diff --git a/api/utxo_missing_reason_test.go b/api/utxo_missing_reason_test.go index 7574a87..a23a2b6 100644 --- a/api/utxo_missing_reason_test.go +++ b/api/utxo_missing_reason_test.go @@ -70,6 +70,7 @@ func buildMissingUTXOServer(t *testing.T, db *store.DB, pruningMode string) (*ap const outIdx uint32 = 0 srv := api.NewServer(":0", chain, mp, utxos, testLogger()) + srv.SetAPIKey(testStakeAPIKey) if db != nil { srv.SetStore(db) } @@ -116,6 +117,7 @@ func postStakePayload(t *testing.T, srv *api.Server, txHash crypto.Hash32, outId body := fmt.Sprintf(`{"tx_extra_hex":%q}`, hex.EncodeToString(extra)) req := httptest.NewRequest(http.MethodPost, "/api/v1/stake", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", testStakeAPIKey) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) return rr.Code, rr.Body.String() @@ -302,7 +304,7 @@ func buildAdminStakeServer(t *testing.T, db *store.DB, pruningMode string) ( const fakeOutIdx uint32 = 0 srv = api.NewServer(":0", chain, mp, utxos, testLogger()) - srv.SetAPIKey("test-api-key") + srv.SetAPIKey(testStakeAPIKey) srv.SetValidatorKey(lockedKey) if db != nil { srv.SetStore(db) @@ -330,7 +332,7 @@ func postAdminStakePayload(t *testing.T, srv *api.Server, pubHex string, txHash req.Host = "127.0.0.1" // localOnly DNS-rebinding guard requires a loopback Host req.RemoteAddr = "127.0.0.1:54321" req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", "test-api-key") + req.Header.Set("X-API-Key", testStakeAPIKey) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) return rr.Code, rr.Body.String() diff --git a/api/wallet_changes_test.go b/api/wallet_changes_test.go index 5e4a90f..fbc62cb 100644 --- a/api/wallet_changes_test.go +++ b/api/wallet_changes_test.go @@ -14,10 +14,11 @@ import ( "testing" "time" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/store" - "github.com/syndtr/goleveldb/leveldb" ) func TestWalletBlockEventsIncludeOrdinaryOutputsAndPublicSpends(t *testing.T) { diff --git a/api/wallet_history_proof.go b/api/wallet_history_proof.go index 9b940c1..d782191 100644 --- a/api/wallet_history_proof.go +++ b/api/wallet_history_proof.go @@ -81,6 +81,9 @@ func walletHistoryOutputEvidence(read *store.WalletReadSnapshot, refs []struct { if txIndex == 0 && block.Header.Height > 0 && tx.IsCoinbase() && !tx.IsStake() { origin = "validator_reward" } + if tx.IsLPoDPayout() { + origin = "lpod_payout" + } if tx.IsSale() { origin = "protocol_sale" } diff --git a/api/wallet_history_proof_test.go b/api/wallet_history_proof_test.go index 4ab80f4..4bb2d56 100644 --- a/api/wallet_history_proof_test.go +++ b/api/wallet_history_proof_test.go @@ -13,34 +13,53 @@ import ( func TestWalletHistoryRequiresCanonicalTransactionAndExactOutput(t *testing.T) { db, err := store.Open(filepath.Join(t.TempDir(), "chain")) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } defer db.Close() out := core.Output{OneTimePub: crypto.Point32{1}, TxPubKey: crypto.Point32{2}, AmountCommit: crypto.Commitment{3}} tx := core.Transaction{Version: core.TxVersionBase, Outputs: []core.Output{out}} block := core.Block{Header: core.BlockHeader{Height: 1}, Txs: []core.Transaction{tx}} block.Header.MerkleRoot = core.MerkleRoot(block.Txs) raw, err := json.Marshal(block) - if err != nil { t.Fatal(err) } - if err := db.PutRawBlock(block.Hash(), 1, raw); err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } + if err := db.PutRawBlock(block.Hash(), 1, raw); err != nil { + t.Fatal(err) + } stored := &store.StoredUTXO{TxHash: tx.Hash(), OutputIndex: 0, BlockHeight: 1, OneTimePub: out.OneTimePub, TxPubKey: out.TxPubKey, AmountCommit: out.AmountCommit, EncAmount: out.EncAmount} - if err := db.PutUTXO(tx.Hash(), 0, stored); err != nil { t.Fatal(err) } + if err := db.PutUTXO(tx.Hash(), 0, stored); err != nil { + t.Fatal(err) + } wrongHash := crypto.HashBytes([]byte("wallet-recorded-but-not-in-block")) - if err := db.PutUTXO(wrongHash, 0, stored); err != nil { t.Fatal(err) } - refs := []struct{ Hash string `json:"tx_hash"`; Index uint32 `json:"out_idx"` }{ + if err := db.PutUTXO(wrongHash, 0, stored); err != nil { + t.Fatal(err) + } + refs := []struct { + Hash string `json:"tx_hash"` + Index uint32 `json:"out_idx"` + }{ {fmt.Sprintf("%x", tx.Hash()), 0}, {fmt.Sprintf("%x", wrongHash), 0}, } snapshot, err := db.NewWalletReadSnapshot() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } defer snapshot.Release() proof := walletHistoryOutputProof(snapshot, refs) if !proof[refs[0].Hash+":0"] || proof[refs[1].Hash+":0"] { t.Fatal("index presence confused with canonical membership") } stored.AmountCommit[0]++ - if err := db.PutUTXO(tx.Hash(), 0, stored); err != nil { t.Fatal(err) } + if err := db.PutUTXO(tx.Hash(), 0, stored); err != nil { + t.Fatal(err) + } newSnapshot, err := db.NewWalletReadSnapshot() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } defer newSnapshot.Release() if walletHistoryOutputProof(newSnapshot, refs)[refs[0].Hash+":0"] { t.Fatal("wrong output commitment promoted to confirmed") diff --git a/api/wallet_history_transactions_test.go b/api/wallet_history_transactions_test.go index 54e4e5e..2f175b2 100644 --- a/api/wallet_history_transactions_test.go +++ b/api/wallet_history_transactions_test.go @@ -3,11 +3,12 @@ package api import ( "encoding/hex" "encoding/json" + "path/filepath" + "testing" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/store" - "path/filepath" - "testing" ) func TestWalletHistoryExactBudgetSendHasCanonicalEvidenceWithoutChange(t *testing.T) { diff --git a/api/wallet_snapshot.go b/api/wallet_snapshot.go index 161ac13..b2fe7b3 100644 --- a/api/wallet_snapshot.go +++ b/api/wallet_snapshot.go @@ -353,6 +353,11 @@ func writeWalletSnapshotError(w http.ResponseWriter, status int, code, message s writeJSON(w, status, map[string]string{"code": code, "error": message}) } +func writeWalletSnapshotQueryUnavailable(w http.ResponseWriter) { + w.Header().Set("Retry-After", "5") + writeWalletSnapshotError(w, http.StatusServiceUnavailable, "SNAPSHOT_QUERY_FAILED", "pinned wallet output query temporarily unavailable") +} + func writeSnapshotLeaseError(w http.ResponseWriter, err error) { if errors.Is(err, errWalletSnapshotExpired) { writeWalletSnapshotError(w, http.StatusConflict, "SNAPSHOT_EXPIRED", err.Error()) @@ -518,7 +523,11 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re return } canonical, found, readErr := entry.read.GetCanonicalHash(throughHeight) - if readErr != nil || !found { + if readErr != nil { + writeWalletSnapshotQueryUnavailable(w) + return + } + if !found { reconciliationError(w, throughHeight, throughHeight, "through_height canonical header is unavailable") return } @@ -555,7 +564,11 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re return } canonical, found, readErr := entry.read.GetCanonicalHash(afterHeight) - if readErr != nil || !found { + if readErr != nil { + writeWalletSnapshotQueryUnavailable(w) + return + } + if !found { reconciliationError(w, afterHeight, afterHeight, "after_height canonical header is unavailable") return } @@ -596,7 +609,11 @@ func (s *Server) restLPoDWalletOutputsSnapshot(w http.ResponseWriter, r *http.Re reorgError(w, cursorReorg.Height, cursorReorg.Error()) return } - writeWalletSnapshotError(w, http.StatusBadRequest, "SNAPSHOT_QUERY_FAILED", err.Error()) + if errors.Is(err, store.ErrInvalidWalletResumeCursor) { + writeWalletSnapshotError(w, http.StatusBadRequest, "INVALID_CURSOR", "wallet resume cursor is invalid for this query") + return + } + writeWalletSnapshotQueryUnavailable(w) return } outputs := make([]map[string]interface{}, 0, len(rows)) diff --git a/api/wallet_snapshot_http_completion_test.go b/api/wallet_snapshot_http_completion_test.go index 41eb577..8ab71b3 100644 --- a/api/wallet_snapshot_http_completion_test.go +++ b/api/wallet_snapshot_http_completion_test.go @@ -21,9 +21,9 @@ func (f completionTransport) RoundTrip(r *http.Request) (*http.Response, error) type completionBody struct { io.ReadCloser - first bool + first bool finish func() - eof *atomic.Bool + eof *atomic.Bool } func (b *completionBody) Read(p []byte) (int, error) { diff --git a/api/wallet_snapshot_integration_test.go b/api/wallet_snapshot_integration_test.go index bf5db80..ba471ff 100644 --- a/api/wallet_snapshot_integration_test.go +++ b/api/wallet_snapshot_integration_test.go @@ -23,11 +23,12 @@ import ( "testing" "time" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" "github.com/aperod/aperod/store" - "github.com/syndtr/goleveldb/leveldb" ) func TestWalletSnapshotPagesAndSpentnessStayAtCapturedTip(t *testing.T) { diff --git a/api/wallet_snapshot_query_failure_test.go b/api/wallet_snapshot_query_failure_test.go new file mode 100644 index 0000000..d74a8a2 --- /dev/null +++ b/api/wallet_snapshot_query_failure_test.go @@ -0,0 +1,120 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +// Use an actual closed LevelDB backend, not a handler error mock. A fresh +// lease over the healthy database must still resume the saved raw position. +func TestWalletOutputSnapshotQueryFailureKeepsDurablePosition(t *testing.T) { + db, migration, address, path, _, _ := syntheticWalletSnapshotLPoDV3Funding(t) + db, _, _, _, _, tipHash, height := seedWalletSnapshotAPIFixture(t, db, path, address) + server := snapshotTestServer(snapshotTestChain(t), core.NewMempool(core.DefaultMempoolConfig())) + server.SetStore(db) + server.SetLPoDConfig(migration, func(h uint64, hash crypto.Hash32) bool { + return h == height && hash == tipHash + }) + server.SetWalletSnapshotCapture(func() (*store.WalletReadSnapshot, error) { + return db.NewWalletReadSnapshot() + }) + httpServer := httptest.NewServer(server) + t.Cleanup(func() { server.walletSnapshots.closeAll(); httpServer.Close() }) + firstURL := httpServer.URL + "/api/v1/lpod/wallet-outputs?address=" + url.QueryEscape(string(address)) + "&snapshot=1" + status, first := snapshotRequest(t, httpServer.Client(), http.MethodGet, firstURL, nil) + if status != http.StatusOK { + t.Fatalf("valid control failed: %d %#v", status, first) + } + id, cursor, raw := stringField(t, first, "snapshot_id"), stringField(t, first, "next_cursor"), stringField(t, first, "resume_cursor") + continuation := snapshotURL(httpServer.URL, "/api/v1/lpod/wallet-outputs", address, id, cursor) + status, expected := snapshotRequest(t, httpServer.Client(), http.MethodGet, continuation, nil) + if status != http.StatusOK || len(expected["outputs"].([]interface{})) != 2 { + t.Fatalf("valid suffix control failed: %d %#v", status, expected) + } + afterHeight := height - 1 + afterHash, found, readErr := db.GetCanonicalHash(afterHeight) + if readErr != nil || !found { + t.Fatalf("missing valid after-bound fixture: %v", readErr) + } + throughFields := "&through_height=" + fmt.Sprint(height) + "&through_hash=" + fmt.Sprintf("%x", tipHash[:]) + afterFields := "&after_height=" + fmt.Sprint(afterHeight) + "&after_hash=" + fmt.Sprintf("%x", afterHash[:]) + targets := []string{ + snapshotURL(httpServer.URL, "/api/v1/lpod/wallet-outputs", address, id, ""), + continuation, continuation + throughFields, continuation + afterFields, continuation + throughFields + afterFields, + } + // All bounded variants are valid continuations while storage is healthy. + for _, target := range targets[1:] { + status, control := snapshotRequest(t, httpServer.Client(), http.MethodGet, target, nil) + if status != http.StatusOK || !reflect.DeepEqual(control["outputs"], expected["outputs"]) { + t.Fatalf("bounded positive control failed: %s: %d %#v", target, status, control) + } + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + // No cursor: exercises NewIterator/First/Error on the unavailable view. + // Bound cursor: exercises canonical lookup before Seek on the same view. + for _, target := range targets { + response, err := httpServer.Client().Get(target) + if err != nil { + t.Fatal(err) + } + if response.Header.Get("Retry-After") != "5" { + t.Fatal("missing retry guidance") + } + response.Body.Close() + status, failure := snapshotRequest(t, httpServer.Client(), http.MethodGet, target, nil) + if status != http.StatusServiceUnavailable || failure["code"] != "SNAPSHOT_QUERY_FAILED" || + failure["error"] != "pinned wallet output query temporarily unavailable" { + t.Fatalf("storage failure was not a sanitized operational error: %d %#v", status, failure) + } + for _, field := range []string{"outputs", "resume_cursor", "next_cursor"} { + if _, exists := failure[field]; exists { + t.Fatalf("failure published progress: %s", field) + } + } + if strings.Contains(fmt.Sprint(failure), "closed") { + t.Fatal("private storage error leaked") + } + } + var err error + db, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + server.SetStore(db) + query := url.Values{"address": {string(address)}, "snapshot": {"1"}, + "through_height": {fmt.Sprint(height)}, "through_hash": {fmt.Sprintf("%x", tipHash[:])}, "resume_cursor": {raw}} + status, resumed := snapshotRequest(t, httpServer.Client(), http.MethodGet, + httpServer.URL+"/api/v1/lpod/wallet-outputs?"+query.Encode(), nil) + if status != http.StatusOK || !reflect.DeepEqual(resumed["outputs"], expected["outputs"]) || + resumed["through_hash"] != first["through_hash"] || resumed["through_height"] != first["through_height"] { + t.Fatalf("fresh lease did not resume the exact valid suffix: %d %#v", status, resumed) + } + server.walletSnapshots.closeAll() + query.Set("after_height", fmt.Sprint(afterHeight)) + query.Set("after_hash", fmt.Sprintf("%x", afterHash[:])) + status, resumed = snapshotRequest(t, httpServer.Client(), http.MethodGet, + httpServer.URL+"/api/v1/lpod/wallet-outputs?"+query.Encode(), nil) + if status != http.StatusOK || !reflect.DeepEqual(resumed["outputs"], expected["outputs"]) || + resumed["through_hash"] != first["through_hash"] || resumed["through_height"] != first["through_height"] { + t.Fatalf("bounded fresh lease changed durable continuation: %d %#v", status, resumed) + } + server.walletSnapshots.closeAll() + query.Set("resume_cursor", "invalid-not-hex") + status, invalid := snapshotRequest(t, httpServer.Client(), http.MethodGet, + httpServer.URL+"/api/v1/lpod/wallet-outputs?"+query.Encode(), nil) + if status != http.StatusBadRequest || invalid["code"] != "INVALID_CURSOR" { + t.Fatalf("invalid caller cursor lost its definitive semantics: %d %#v", status, invalid) + } +} diff --git a/api/wallet_snapshot_service_test.go b/api/wallet_snapshot_service_test.go index 5c3daa1..c6a2487 100644 --- a/api/wallet_snapshot_service_test.go +++ b/api/wallet_snapshot_service_test.go @@ -85,7 +85,7 @@ func newWalletSnapshotLoopback(t *testing.T) *walletSnapshotLoopback { }) mux.HandleFunc("/_fixture/expire", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "POST only", 405) + http.Error(w, "POST only", http.StatusMethodNotAllowed) return } m := node.walletSnapshots @@ -99,7 +99,7 @@ func newWalletSnapshotLoopback(t *testing.T) *walletSnapshotLoopback { }) mux.HandleFunc("/_fixture/stop", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "POST only", 405) + http.Error(w, "POST only", http.StatusMethodNotAllowed) return } writeJSON(w, 200, map[string]bool{"stopping": true}) diff --git a/api/ws.go b/api/ws.go index 2c5fc70..3d61801 100644 --- a/api/ws.go +++ b/api/ws.go @@ -23,8 +23,9 @@ import ( "sync" "time" - "github.com/aperod/aperod/core" "golang.org/x/net/websocket" + + "github.com/aperod/aperod/core" ) // WSEvent is the envelope sent to every subscribed client. diff --git a/avm/CHECKPOINTS.md b/avm/CHECKPOINTS.md new file mode 100644 index 0000000..1c239b0 --- /dev/null +++ b/avm/CHECKPOINTS.md @@ -0,0 +1,101 @@ +# AVM recovery checkpoints + +AVM checkpoints bound startup replay without changing block or consensus +rules. A v2 checkpoint contains the complete ordered AVM state, deterministic +state commitment, activation height, genesis hash, canonical block hash, +per-block write-set commitment, format version, and an m-of-n set of detached +Ed25519 signatures over the same payload. + +## Trust model + +Quorum-configured nodes accept only v2 checkpoints with at least the +configured number of distinct, valid signatures from explicitly pinned +authorities. Every supplied signature is checked; an invalid extra signature +also rejects the checkpoint. The node does not need witness private keys. + +Legacy v1 checkpoints have a single operator-attested signature. They are not +quorum certificates and are rejected when the node is configured for quorum +mode. Do not describe v1 as independent consensus evidence. + +Startup binds the signatures to activation height, genesis, canonical block +hash, state commitment, and AVM write-set commitment. It then checks the +checkpoint block against the canonical height index and replays the suffix, +checking block hashes, linkage, Merkle roots, signatures, and stored AVM +markers. Missing checkpoints are allowed only when the complete AVM history +fits within the one-million-block replay limit. A bad, stale, forked, +under-signed, or over-limit checkpoint fails closed. + +## Build, sign, assemble + +Build an unsigned draft from a stopped-node database copy. Each witness must +sign only after checking the candidate against its own canonical database +copy. The signing command replays that witness's chain through the candidate +height and refuses to sign if the payload differs. Keep witness keys on their +respective witness hosts; do not copy them to the node or to a shared +assembly host. + +Build a draft: + +```sh +cd blockchain +go run ./cmd/avm-checkpoint \ + -mode build \ + -db /path/to/stopped-copy/chain.db \ + -activation-height 123456 \ + -out /shared/avm-checkpoint-draft.json +``` + +Each witness independently verifies and signs it. Use a key file with mode +`0600`: + +```sh +go run ./cmd/avm-checkpoint \ + -mode sign \ + -db /path/to/this-witness/chain.db \ + -activation-height 123456 \ + -candidate /shared/avm-checkpoint-draft.json \ + -key /path/to/this-witness/witness.key \ + -out /shared/witness-1-signature.json +``` + +Assemble at least `m` detached signature files against the complete pinned +authority list. The assembly step verifies every signature and rejects +duplicates, unknown keys, mismatched payloads, or insufficient count: + +```sh +go run ./cmd/avm-checkpoint \ + -mode assemble \ + -candidate /shared/avm-checkpoint-draft.json \ + -signature /shared/witness-1-signature.json \ + -signature /shared/witness-2-signature.json \ + -authority "" \ + -authority "" \ + -authority "" \ + -threshold 2 \ + -out /path/to/avm-state.checkpoint.json +``` + +Configure the same authority list and threshold on the node: + +```yaml +consensus: + avm_checkpoint_file: avm-state.checkpoint.json + avm_checkpoint_trust_authorities: + - "" + - "" + - "" + avm_checkpoint_witness_threshold: 2 +``` + +Relative checkpoint paths resolve under `data_dir`. Key rotation must use an +overlapping authority set to produce a new checkpoint signed by the new +quorum before removing retired keys. A safe sequence is: add new keys while +keeping the old threshold valid, install a checkpoint signed by the intended +new quorum, then remove retired keys. A checkpoint signed only by retired keys +must fail after the authority list is changed. + +The builder and every witness replay reject missing bodies, broken linkage, +invalid block signatures, and AVM commitment mismatches. Do not bypass those +checks to force signatures. Quorum signatures authenticate the state snapshot +as trusted witnesses; they do not replace the node's canonical block and +suffix validation. diff --git a/avm/admission_policy_test.go b/avm/admission_policy_test.go index 05f4d65..76e872d 100644 --- a/avm/admission_policy_test.go +++ b/avm/admission_policy_test.go @@ -4,9 +4,10 @@ import ( "strings" "testing" - "github.com/aperod/aperod/core" wabinbinary "github.com/tetratelabs/wabin/binary" "github.com/tetratelabs/wabin/wasm" + + "github.com/aperod/aperod/core" ) func TestInstructionAdmissionDepthBound(t *testing.T) { diff --git a/avm/checkpoint.go b/avm/checkpoint.go new file mode 100644 index 0000000..ede1558 --- /dev/null +++ b/avm/checkpoint.go @@ -0,0 +1,781 @@ +package avm + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/aperod/aperod/crypto" +) + +const ( + AVMCheckpointVersion uint32 = 1 + AVMCheckpointQuorumVersion uint32 = 2 + MaxAVMCheckpointReplay uint64 = 1_000_000 + maxAVMCheckpointFileBytes = 512 << 20 + maxAVMCheckpointEntries = 10_000_000 +) + +const avmCheckpointDomain = "APRO/AVM/CHECKPOINT/V1\x00" +const avmQuorumCheckpointDomain = "APRO/AVM/CHECKPOINT/V2\x00" +const avmStateSnapshotDomain = "APRO/AVM/SNAPSHOT-STATE/V1\x00" + +// CheckpointEntry is one key/value pair in a complete AVM state snapshot. +// Entries in a signed checkpoint must be strictly ordered by Key. +type CheckpointEntry struct { + Key []byte `json:"key"` + Value []byte `json:"value"` +} + +// AVMCheckpoint is a complete state snapshot. V1 uses a single +// operator-attested signature; v2 uses detached signatures from a configured +// witness quorum. It is not a consensus rule or a substitute for block +// validation: it only provides a trusted starting state for bounded replay. +type AVMCheckpoint struct { + Version uint32 + ActivationHeight uint64 + Height uint64 + GenesisHash crypto.Hash32 + BlockHash crypto.Hash32 + StateCommitment crypto.Hash32 + WriteSetCommitment crypto.Hash32 + Entries []CheckpointEntry + WitnessPublicKey crypto.ValidatorPubKey + WitnessSignature []byte + WitnessSignatures []CheckpointWitnessSignature +} + +// CheckpointWitnessSignature is a detached v2 signature over the complete +// checkpoint payload. Signatures are kept in public-key order. +type CheckpointWitnessSignature struct { + WitnessPublicKey crypto.ValidatorPubKey + Signature []byte +} + +// CheckpointSigner is implemented by both ValidatorPrivKey and the node's +// locked validator key, avoiding copies of private key material. +type CheckpointSigner interface { + Public() crypto.ValidatorPubKey + Sign(crypto.Hash32) ([]byte, error) +} + +type checkpointWire struct { + Version uint32 `json:"version"` + ActivationHeight uint64 `json:"activation_height"` + Height uint64 `json:"height"` + GenesisHash string `json:"genesis_hash"` + BlockHash string `json:"block_hash"` + StateCommitment string `json:"state_commitment"` + WriteSetCommitment string `json:"write_set_commitment"` + Entries []CheckpointEntry `json:"entries"` + WitnessPublicKey string `json:"witness_public_key"` + WitnessSignature string `json:"witness_signature"` + WitnessSignatures []checkpointWitnessSignatureWire `json:"witness_signatures,omitempty"` +} + +type checkpointWitnessSignatureWire struct { + WitnessPublicKey string `json:"witness_public_key"` + Signature string `json:"signature"` +} + +// ParseCheckpointAuthorities parses a non-empty set of explicitly pinned +// Ed25519 public keys. Duplicate and zero keys are rejected. +func ParseCheckpointAuthorities(configured []string) ([]crypto.ValidatorPubKey, error) { + if len(configured) == 0 { + return nil, fmt.Errorf("avm checkpoint: explicit trusted witness key is required") + } + authorities := make([]crypto.ValidatorPubKey, 0, len(configured)) + seen := make(map[string]struct{}, len(configured)) + for i, encoded := range configured { + raw, err := hex.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: malformed trusted witness key at index %d", i) + } + pub, err := crypto.ValidatorPubKeyFromBytes(raw) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: trusted witness key at index %d must be exactly 32 bytes", i) + } + allZero := true + for _, b := range pub { + if b != 0 { + allZero = false + break + } + } + if allZero { + return nil, fmt.Errorf("avm checkpoint: zero trusted witness key at index %d", i) + } + if _, duplicate := seen[string(pub)]; duplicate { + return nil, fmt.Errorf("avm checkpoint: duplicate trusted witness key at index %d", i) + } + seen[string(pub)] = struct{}{} + authorities = append(authorities, pub) + } + return authorities, nil +} + +// StateSnapshotCommitment hashes a deterministic, strictly key-ordered +// snapshot. It rejects duplicate or empty keys rather than normalizing them. +func StateSnapshotCommitment(entries []CheckpointEntry) (crypto.Hash32, error) { + if len(entries) > maxAVMCheckpointEntries { + return crypto.Hash32{}, fmt.Errorf("avm checkpoint: too many state entries") + } + hasher := sha256.New() + _, _ = hasher.Write([]byte(avmStateSnapshotDomain)) + var number [8]byte + binary.BigEndian.PutUint64(number[:], uint64(len(entries))) + _, _ = hasher.Write(number[:]) + for i, entry := range entries { + if len(entry.Key) == 0 { + return crypto.Hash32{}, fmt.Errorf("avm checkpoint: empty state key at entry %d", i) + } + if i > 0 && bytes.Compare(entries[i-1].Key, entry.Key) >= 0 { + return crypto.Hash32{}, fmt.Errorf("avm checkpoint: state entries are not strictly key ordered") + } + binary.BigEndian.PutUint64(number[:], uint64(len(entry.Key))) + _, _ = hasher.Write(number[:]) + _, _ = hasher.Write(entry.Key) + binary.BigEndian.PutUint64(number[:], uint64(len(entry.Value))) + _, _ = hasher.Write(number[:]) + _, _ = hasher.Write(entry.Value) + } + var commitment crypto.Hash32 + copy(commitment[:], hasher.Sum(nil)) + return commitment, nil +} + +func checkpointMessage(checkpoint *AVMCheckpoint) crypto.Hash32 { + var version [4]byte + var heights [16]byte + binary.BigEndian.PutUint32(version[:], checkpoint.Version) + binary.BigEndian.PutUint64(heights[:8], checkpoint.ActivationHeight) + binary.BigEndian.PutUint64(heights[8:], checkpoint.Height) + return crypto.HashBytes( + []byte(avmCheckpointDomain), + version[:], + heights[:], + checkpoint.GenesisHash[:], + checkpoint.BlockHash[:], + checkpoint.StateCommitment[:], + checkpoint.WriteSetCommitment[:], + checkpoint.WitnessPublicKey, + ) +} + +func checkpointQuorumMessage(checkpoint *AVMCheckpoint) crypto.Hash32 { + var version [4]byte + var heights [16]byte + binary.BigEndian.PutUint32(version[:], checkpoint.Version) + binary.BigEndian.PutUint64(heights[:8], checkpoint.ActivationHeight) + binary.BigEndian.PutUint64(heights[8:], checkpoint.Height) + return crypto.HashBytes( + []byte(avmQuorumCheckpointDomain), + version[:], + heights[:], + checkpoint.GenesisHash[:], + checkpoint.BlockHash[:], + checkpoint.StateCommitment[:], + checkpoint.WriteSetCommitment[:], + ) +} + +func checkpointAuthorityAllowed(authorities []crypto.ValidatorPubKey, key crypto.ValidatorPubKey) bool { + for _, authority := range authorities { + if authority.Equals(key) { + return true + } + } + return false +} + +func validateCheckpointAuthorities(authorities []crypto.ValidatorPubKey, threshold int) error { + if threshold < 2 { + return fmt.Errorf("avm checkpoint: quorum threshold must be at least 2") + } + if threshold > len(authorities) { + return fmt.Errorf("avm checkpoint: quorum threshold exceeds configured authority count") + } + seen := make(map[string]struct{}, len(authorities)) + for i, authority := range authorities { + if len(authority) != 32 { + return fmt.Errorf("avm checkpoint: trusted witness key at index %d must be 32 bytes", i) + } + allZero := true + for _, b := range authority { + if b != 0 { + allZero = false + break + } + } + if allZero { + return fmt.Errorf("avm checkpoint: zero trusted witness key at index %d", i) + } + if _, duplicate := seen[string(authority)]; duplicate { + return fmt.Errorf("avm checkpoint: duplicate trusted witness key at index %d", i) + } + seen[string(authority)] = struct{}{} + } + return nil +} + +func cloneCheckpointEntries(entries []CheckpointEntry) []CheckpointEntry { + cloned := make([]CheckpointEntry, len(entries)) + for i, entry := range entries { + cloned[i] = CheckpointEntry{ + Key: bytes.Clone(entry.Key), + Value: bytes.Clone(entry.Value), + } + } + return cloned +} + +func canonicalCheckpointEntries(entries []CheckpointEntry) ([]CheckpointEntry, error) { + canonical := cloneCheckpointEntries(entries) + sort.Slice(canonical, func(i, j int) bool { + return bytes.Compare(canonical[i].Key, canonical[j].Key) < 0 + }) + if _, err := StateSnapshotCommitment(canonical); err != nil { + return nil, err + } + return canonical, nil +} + +// NewQuorumCheckpointDraft creates a v2 checkpoint payload before any witness +// signs it. All witness signatures cover the same deterministic metadata and +// state root; the signature set itself is not part of the signed message. +func NewQuorumCheckpointDraft( + activationHeight uint64, + height uint64, + genesisHash crypto.Hash32, + blockHash crypto.Hash32, + writeSetCommitment crypto.Hash32, + entries []CheckpointEntry, +) (*AVMCheckpoint, error) { + if activationHeight == 0 || height < activationHeight { + return nil, fmt.Errorf("avm checkpoint: invalid activation or checkpoint height") + } + if genesisHash.Zero() || blockHash.Zero() { + return nil, fmt.Errorf("avm checkpoint: zero genesis or checkpoint block hash") + } + canonicalEntries, err := canonicalCheckpointEntries(entries) + if err != nil { + return nil, err + } + stateCommitment, err := StateSnapshotCommitment(canonicalEntries) + if err != nil { + return nil, err + } + return &AVMCheckpoint{ + Version: AVMCheckpointQuorumVersion, + ActivationHeight: activationHeight, + Height: height, + GenesisHash: genesisHash, + BlockHash: blockHash, + StateCommitment: stateCommitment, + WriteSetCommitment: writeSetCommitment, + Entries: canonicalEntries, + }, nil +} + +// ValidateQuorumCheckpointDraft checks the immutable payload before a witness +// signs it. It rejects already signed or legacy v1 checkpoints. +func ValidateQuorumCheckpointDraft(checkpoint *AVMCheckpoint, activationHeight uint64) error { + if checkpoint == nil { + return fmt.Errorf("avm checkpoint: nil checkpoint draft") + } + if checkpoint.Version != AVMCheckpointQuorumVersion { + return fmt.Errorf("avm checkpoint: expected quorum version %d, got %d", AVMCheckpointQuorumVersion, checkpoint.Version) + } + if checkpoint.ActivationHeight != activationHeight || activationHeight == 0 { + return fmt.Errorf("avm checkpoint: activation height mismatch") + } + if checkpoint.Height < activationHeight || checkpoint.GenesisHash.Zero() || checkpoint.BlockHash.Zero() { + return fmt.Errorf("avm checkpoint: invalid height or zero chain hash") + } + if len(checkpoint.WitnessPublicKey) != 0 || len(checkpoint.WitnessSignature) != 0 || + len(checkpoint.WitnessSignatures) != 0 { + return fmt.Errorf("avm checkpoint: expected unsigned quorum draft") + } + stateCommitment, err := StateSnapshotCommitment(checkpoint.Entries) + if err != nil { + return err + } + if stateCommitment != checkpoint.StateCommitment { + return fmt.Errorf("avm checkpoint: state commitment mismatch") + } + return nil +} + +// CheckpointPayloadEqual compares the signed fields and full state of two +// checkpoints while ignoring their signature sets. +func CheckpointPayloadEqual(left, right *AVMCheckpoint) bool { + if left == nil || right == nil || + left.Version != right.Version || + left.ActivationHeight != right.ActivationHeight || + left.Height != right.Height || + left.GenesisHash != right.GenesisHash || + left.BlockHash != right.BlockHash || + left.StateCommitment != right.StateCommitment || + left.WriteSetCommitment != right.WriteSetCommitment || + len(left.Entries) != len(right.Entries) { + return false + } + for i := range left.Entries { + if !bytes.Equal(left.Entries[i].Key, right.Entries[i].Key) || + !bytes.Equal(left.Entries[i].Value, right.Entries[i].Value) { + return false + } + } + return true +} + +// SignCheckpointDraft creates one detached signature. The signer retains +// custody of its key; the returned public signature can be assembled with +// signatures collected from other witnesses. +func SignCheckpointDraft( + checkpoint *AVMCheckpoint, + activationHeight uint64, + signer CheckpointSigner, +) (CheckpointWitnessSignature, error) { + if err := ValidateQuorumCheckpointDraft(checkpoint, activationHeight); err != nil { + return CheckpointWitnessSignature{}, err + } + if signer == nil { + return CheckpointWitnessSignature{}, fmt.Errorf("avm checkpoint: signer is required") + } + public := signer.Public() + if len(public) != 32 { + return CheckpointWitnessSignature{}, fmt.Errorf("avm checkpoint: signer public key must be 32 bytes") + } + publicKey := append(crypto.ValidatorPubKey(nil), public...) + message := checkpointQuorumMessage(checkpoint) + signature, err := signer.Sign(message) + if err != nil { + return CheckpointWitnessSignature{}, fmt.Errorf("avm checkpoint: sign quorum draft: %w", err) + } + if !publicKey.Verify(message, signature) { + return CheckpointWitnessSignature{}, fmt.Errorf("avm checkpoint: signer returned an invalid signature") + } + return CheckpointWitnessSignature{ + WitnessPublicKey: publicKey, + Signature: bytes.Clone(signature), + }, nil +} + +// AttachCheckpointSignatures returns a signed v2 checkpoint after verifying +// the complete collected signature set against the configured m-of-n policy. +func AttachCheckpointSignatures( + draft *AVMCheckpoint, + signatures []CheckpointWitnessSignature, + activationHeight uint64, + authorities []crypto.ValidatorPubKey, + threshold int, +) (*AVMCheckpoint, error) { + if err := ValidateQuorumCheckpointDraft(draft, activationHeight); err != nil { + return nil, err + } + if err := validateCheckpointAuthorities(authorities, threshold); err != nil { + return nil, err + } + signed := *draft + signed.Entries = cloneCheckpointEntries(draft.Entries) + signed.WitnessSignatures = make([]CheckpointWitnessSignature, len(signatures)) + for i, signature := range signatures { + signed.WitnessSignatures[i] = CheckpointWitnessSignature{ + WitnessPublicKey: append(crypto.ValidatorPubKey(nil), signature.WitnessPublicKey...), + Signature: bytes.Clone(signature.Signature), + } + } + sort.Slice(signed.WitnessSignatures, func(i, j int) bool { + return bytes.Compare(signed.WitnessSignatures[i].WitnessPublicKey, signed.WitnessSignatures[j].WitnessPublicKey) < 0 + }) + if err := VerifyCheckpointQuorum(&signed, activationHeight, authorities, threshold); err != nil { + return nil, err + } + return &signed, nil +} + +// VerifyCheckpointQuorum verifies the full v2 payload and every supplied +// signature. Invalid, duplicate, unauthorized, or unsorted signatures fail +// the checkpoint even if enough other signatures would meet the threshold. +func VerifyCheckpointQuorum( + checkpoint *AVMCheckpoint, + activationHeight uint64, + authorities []crypto.ValidatorPubKey, + threshold int, +) error { + if checkpoint == nil { + return fmt.Errorf("avm checkpoint: nil checkpoint") + } + if err := validateCheckpointAuthorities(authorities, threshold); err != nil { + return err + } + if checkpoint.Version != AVMCheckpointQuorumVersion { + return fmt.Errorf("avm checkpoint: quorum mode requires version %d, got %d", AVMCheckpointQuorumVersion, checkpoint.Version) + } + if checkpoint.ActivationHeight != activationHeight || activationHeight == 0 { + return fmt.Errorf("avm checkpoint: activation height mismatch") + } + if checkpoint.Height < activationHeight || checkpoint.GenesisHash.Zero() || checkpoint.BlockHash.Zero() { + return fmt.Errorf("avm checkpoint: invalid height or zero chain hash") + } + if len(checkpoint.WitnessPublicKey) != 0 || len(checkpoint.WitnessSignature) != 0 { + return fmt.Errorf("avm checkpoint: quorum checkpoint contains legacy v1 signature fields") + } + if len(checkpoint.WitnessSignatures) < threshold { + return fmt.Errorf("avm checkpoint: insufficient witness signatures: got %d, require %d", len(checkpoint.WitnessSignatures), threshold) + } + if len(checkpoint.WitnessSignatures) > len(authorities) { + return fmt.Errorf("avm checkpoint: signature count exceeds configured authority count") + } + stateCommitment, err := StateSnapshotCommitment(checkpoint.Entries) + if err != nil { + return err + } + if stateCommitment != checkpoint.StateCommitment { + return fmt.Errorf("avm checkpoint: state commitment mismatch") + } + message := checkpointQuorumMessage(checkpoint) + var previous crypto.ValidatorPubKey + for i, signature := range checkpoint.WitnessSignatures { + if i > 0 && bytes.Compare(previous, signature.WitnessPublicKey) >= 0 { + return fmt.Errorf("avm checkpoint: witness signatures are not uniquely key ordered") + } + if !checkpointAuthorityAllowed(authorities, signature.WitnessPublicKey) { + return fmt.Errorf("avm checkpoint: unauthorized witness signature at index %d", i) + } + if !signature.WitnessPublicKey.Verify(message, signature.Signature) { + return fmt.Errorf("avm checkpoint: invalid witness signature at index %d", i) + } + previous = signature.WitnessPublicKey + } + return nil +} + +// SignCheckpoint creates a legacy v1 operator-attested checkpoint. +// +// Deprecated: quorum-configured nodes must use NewQuorumCheckpointDraft, +// SignCheckpointDraft, and AttachCheckpointSignatures. +func SignCheckpoint( + activationHeight uint64, + height uint64, + genesisHash crypto.Hash32, + blockHash crypto.Hash32, + writeSetCommitment crypto.Hash32, + entries []CheckpointEntry, + signer CheckpointSigner, + authorities []crypto.ValidatorPubKey, +) (*AVMCheckpoint, error) { + if signer == nil { + return nil, fmt.Errorf("avm checkpoint: signer is required") + } + pub := signer.Public() + if len(pub) != 32 { + return nil, fmt.Errorf("avm checkpoint: signer public key must be 32 bytes") + } + if !checkpointAuthorityAllowed(authorities, pub) { + return nil, fmt.Errorf("avm checkpoint: local signer is not an explicitly trusted witness") + } + if activationHeight == 0 || height < activationHeight { + return nil, fmt.Errorf("avm checkpoint: invalid activation or checkpoint height") + } + if genesisHash.Zero() || blockHash.Zero() { + return nil, fmt.Errorf("avm checkpoint: zero genesis or checkpoint block hash") + } + canonicalEntries, err := canonicalCheckpointEntries(entries) + if err != nil { + return nil, err + } + stateCommitment, err := StateSnapshotCommitment(canonicalEntries) + if err != nil { + return nil, err + } + checkpoint := &AVMCheckpoint{ + Version: AVMCheckpointVersion, + ActivationHeight: activationHeight, + Height: height, + GenesisHash: genesisHash, + BlockHash: blockHash, + StateCommitment: stateCommitment, + WriteSetCommitment: writeSetCommitment, + Entries: canonicalEntries, + WitnessPublicKey: append(crypto.ValidatorPubKey(nil), pub...), + } + signature, err := signer.Sign(checkpointMessage(checkpoint)) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: sign: %w", err) + } + checkpoint.WitnessSignature = bytes.Clone(signature) + return checkpoint, nil +} + +// VerifyCheckpoint validates the format, snapshot digest, witness pin, and +// Ed25519 signature. The caller must separately verify the canonical block +// hash and bounded replay range against its local chain. +func VerifyCheckpoint( + checkpoint *AVMCheckpoint, + activationHeight uint64, + authorities []crypto.ValidatorPubKey, +) error { + if checkpoint == nil { + return fmt.Errorf("avm checkpoint: nil checkpoint") + } + if checkpoint.Version != AVMCheckpointVersion { + return fmt.Errorf("avm checkpoint: unsupported version %d", checkpoint.Version) + } + if len(checkpoint.WitnessSignatures) != 0 { + return fmt.Errorf("avm checkpoint: legacy v1 checkpoint contains v2 witness signatures") + } + if checkpoint.ActivationHeight != activationHeight || activationHeight == 0 { + return fmt.Errorf("avm checkpoint: activation height mismatch") + } + if checkpoint.Height < activationHeight || checkpoint.GenesisHash.Zero() || checkpoint.BlockHash.Zero() { + return fmt.Errorf("avm checkpoint: invalid height or zero chain hash") + } + if !checkpointAuthorityAllowed(authorities, checkpoint.WitnessPublicKey) { + return fmt.Errorf("avm checkpoint: witness is not configured as trusted") + } + if len(checkpoint.WitnessPublicKey) != 32 { + return fmt.Errorf("avm checkpoint: witness public key must be 32 bytes") + } + stateCommitment, err := StateSnapshotCommitment(checkpoint.Entries) + if err != nil { + return err + } + if stateCommitment != checkpoint.StateCommitment { + return fmt.Errorf("avm checkpoint: state commitment mismatch") + } + if !checkpoint.WitnessPublicKey.Verify(checkpointMessage(checkpoint), checkpoint.WitnessSignature) { + return fmt.Errorf("avm checkpoint: invalid trusted witness signature") + } + return nil +} + +// ReadCheckpointFile reads one bounded JSON checkpoint. A missing file is +// reported separately so callers can choose a safe full replay for short +// histories or require an offline-seeded checkpoint for long histories. +func ReadCheckpointFile(path string) (*AVMCheckpoint, bool, error) { + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("avm checkpoint: open: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, false, fmt.Errorf("avm checkpoint: stat: %w", err) + } + if !info.Mode().IsRegular() { + return nil, false, fmt.Errorf("avm checkpoint: not a regular file") + } + if info.Size() <= 0 || info.Size() > maxAVMCheckpointFileBytes { + return nil, false, fmt.Errorf("avm checkpoint: file size %d outside supported bounds", info.Size()) + } + decoder := json.NewDecoder(io.LimitReader(file, maxAVMCheckpointFileBytes+1)) + decoder.DisallowUnknownFields() + var wire checkpointWire + if err := decoder.Decode(&wire); err != nil { + return nil, false, fmt.Errorf("avm checkpoint: decode: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return nil, false, fmt.Errorf("avm checkpoint: trailing JSON value") + } + return nil, false, fmt.Errorf("avm checkpoint: trailing data: %w", err) + } + checkpoint, err := checkpointFromWire(wire) + if err != nil { + return nil, false, err + } + return checkpoint, true, nil +} + +// WriteCheckpointFile replaces a checkpoint atomically. The file and its +// temporary predecessor are mode 0600; a torn write never replaces the last +// valid checkpoint. +func WriteCheckpointFile(path string, checkpoint *AVMCheckpoint) error { + return writeCheckpointFileWithLimit(path, checkpoint, maxAVMCheckpointFileBytes) +} + +func writeCheckpointFileWithLimit(path string, checkpoint *AVMCheckpoint, maxBytes int64) error { + if checkpoint == nil { + return fmt.Errorf("avm checkpoint: nil checkpoint") + } + if maxBytes <= 0 { + return fmt.Errorf("avm checkpoint: file size limit must be positive") + } + if len(checkpoint.Entries) > maxAVMCheckpointEntries { + return fmt.Errorf("avm checkpoint: too many state entries") + } + wire := checkpointToWire(checkpoint) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("avm checkpoint: create directory: %w", err) + } + temp, err := os.CreateTemp(dir, ".avm-checkpoint-*.tmp") + if err != nil { + return fmt.Errorf("avm checkpoint: create temporary file: %w", err) + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return fmt.Errorf("avm checkpoint: chmod temporary file: %w", err) + } + encoder := json.NewEncoder(&checkpointLimitWriter{ + writer: temp, remaining: maxBytes, limit: maxBytes, + }) + if err := encoder.Encode(wire); err != nil { + _ = temp.Close() + return fmt.Errorf("avm checkpoint: encode: %w", err) + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return fmt.Errorf("avm checkpoint: sync temporary file: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("avm checkpoint: close temporary file: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("avm checkpoint: atomically replace: %w", err) + } + dirFile, err := os.Open(dir) + if err != nil { + return fmt.Errorf("avm checkpoint: open parent directory: %w", err) + } + defer dirFile.Close() + if err := dirFile.Sync(); err != nil { + return fmt.Errorf("avm checkpoint: sync parent directory: %w", err) + } + return nil +} + +type checkpointLimitWriter struct { + writer io.Writer + remaining int64 + limit int64 +} + +func (w *checkpointLimitWriter) Write(data []byte) (int, error) { + if int64(len(data)) > w.remaining { + return 0, fmt.Errorf("avm checkpoint: encoded file exceeds %d bytes", w.limit) + } + n, err := w.writer.Write(data) + w.remaining -= int64(n) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + return n, err +} + +func checkpointToWire(checkpoint *AVMCheckpoint) checkpointWire { + witnessSignatures := make([]checkpointWitnessSignatureWire, len(checkpoint.WitnessSignatures)) + for i, signature := range checkpoint.WitnessSignatures { + witnessSignatures[i] = checkpointWitnessSignatureWire{ + WitnessPublicKey: hex.EncodeToString(signature.WitnessPublicKey), + Signature: hex.EncodeToString(signature.Signature), + } + } + return checkpointWire{ + Version: checkpoint.Version, + ActivationHeight: checkpoint.ActivationHeight, + Height: checkpoint.Height, + GenesisHash: hex.EncodeToString(checkpoint.GenesisHash[:]), + BlockHash: hex.EncodeToString(checkpoint.BlockHash[:]), + StateCommitment: hex.EncodeToString(checkpoint.StateCommitment[:]), + WriteSetCommitment: hex.EncodeToString(checkpoint.WriteSetCommitment[:]), + Entries: checkpoint.Entries, + WitnessPublicKey: hex.EncodeToString(checkpoint.WitnessPublicKey), + WitnessSignature: hex.EncodeToString(checkpoint.WitnessSignature), + WitnessSignatures: witnessSignatures, + } +} + +func checkpointFromWire(wire checkpointWire) (*AVMCheckpoint, error) { + decodeHash := func(name, encoded string) (crypto.Hash32, error) { + var hash crypto.Hash32 + raw, err := hex.DecodeString(encoded) + if err != nil || len(raw) != len(hash) { + return hash, fmt.Errorf("avm checkpoint: malformed %s", name) + } + copy(hash[:], raw) + return hash, nil + } + genesisHash, err := decodeHash("genesis hash", wire.GenesisHash) + if err != nil { + return nil, err + } + blockHash, err := decodeHash("block hash", wire.BlockHash) + if err != nil { + return nil, err + } + stateCommitment, err := decodeHash("state commitment", wire.StateCommitment) + if err != nil { + return nil, err + } + writeSetCommitment, err := decodeHash("write-set commitment", wire.WriteSetCommitment) + if err != nil { + return nil, err + } + var publicKey crypto.ValidatorPubKey + var signature []byte + if wire.WitnessPublicKey != "" || wire.WitnessSignature != "" { + publicBytes, err := hex.DecodeString(wire.WitnessPublicKey) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: malformed witness public key") + } + publicKey, err = crypto.ValidatorPubKeyFromBytes(publicBytes) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: malformed witness public key") + } + signature, err = hex.DecodeString(wire.WitnessSignature) + if err != nil || len(signature) != 64 { + return nil, fmt.Errorf("avm checkpoint: malformed witness signature") + } + } + if len(wire.Entries) > maxAVMCheckpointEntries { + return nil, fmt.Errorf("avm checkpoint: too many state entries") + } + witnessSignatures := make([]CheckpointWitnessSignature, len(wire.WitnessSignatures)) + for i, encoded := range wire.WitnessSignatures { + publicBytes, err := hex.DecodeString(encoded.WitnessPublicKey) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: malformed witness public key at signature %d", i) + } + witnessPublicKey, err := crypto.ValidatorPubKeyFromBytes(publicBytes) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: malformed witness public key at signature %d", i) + } + witnessSignature, err := hex.DecodeString(encoded.Signature) + if err != nil || len(witnessSignature) != 64 { + return nil, fmt.Errorf("avm checkpoint: malformed witness signature at index %d", i) + } + witnessSignatures[i] = CheckpointWitnessSignature{ + WitnessPublicKey: witnessPublicKey, + Signature: witnessSignature, + } + } + return &AVMCheckpoint{ + Version: wire.Version, + ActivationHeight: wire.ActivationHeight, + Height: wire.Height, + GenesisHash: genesisHash, + BlockHash: blockHash, + StateCommitment: stateCommitment, + WriteSetCommitment: writeSetCommitment, + Entries: wire.Entries, + WitnessPublicKey: publicKey, + WitnessSignature: signature, + WitnessSignatures: witnessSignatures, + }, nil +} diff --git a/avm/checkpoint_benchmark_test.go b/avm/checkpoint_benchmark_test.go new file mode 100644 index 0000000..f04a9b6 --- /dev/null +++ b/avm/checkpoint_benchmark_test.go @@ -0,0 +1,182 @@ +package avm + +import ( + "encoding/binary" + "fmt" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/aperod/aperod/crypto" +) + +const ( + checkpointBenchmarkFixturePathEnv = "AVM_CHECKPOINT_BENCH_FIXTURE_PATH" + checkpointBenchmarkFixtureEntriesEnv = "AVM_CHECKPOINT_BENCH_FIXTURE_ENTRIES" +) + +var ( + checkpointBenchmarkCheckpointSink *AVMCheckpoint + checkpointBenchmarkStoreSink *MemoryStore +) + +// TestGenerateSyntheticAVMCheckpointBenchmarkFixture is an opt-in helper for +// preparing a fixture in a separate process before measuring read/restore RSS. +// The fixture is synthetic and must not be used as chain or consensus evidence. +func TestGenerateSyntheticAVMCheckpointBenchmarkFixture(t *testing.T) { + path := os.Getenv(checkpointBenchmarkFixturePathEnv) + countText := os.Getenv(checkpointBenchmarkFixtureEntriesEnv) + if path == "" && countText == "" { + t.Skip("set AVM_CHECKPOINT_BENCH_FIXTURE_PATH and AVM_CHECKPOINT_BENCH_FIXTURE_ENTRIES to generate a synthetic fixture") + } + if path == "" || countText == "" { + t.Fatalf("both %s and %s are required", checkpointBenchmarkFixturePathEnv, checkpointBenchmarkFixtureEntriesEnv) + } + count, err := strconv.Atoi(countText) + if err != nil || count < 1 || count > maxAVMCheckpointEntries { + t.Fatalf("invalid synthetic fixture entry count %q", countText) + } + if _, err := os.Lstat(path); err == nil { + t.Fatalf("refusing to overwrite existing benchmark fixture %q", path) + } else if !os.IsNotExist(err) { + t.Fatalf("inspect benchmark fixture path: %v", err) + } + checkpoint := syntheticAVMCheckpoint(t, count) + if err := WriteCheckpointFile(path, checkpoint); err != nil { + t.Fatalf("write synthetic checkpoint fixture: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat synthetic checkpoint fixture: %v", err) + } + t.Logf("synthetic_fixture_entries=%d file_bytes=%d", count, info.Size()) +} + +// BenchmarkAVMCheckpointSynthetic measures JSON checkpoint writing, bounded +// file decoding, and loading checkpoint entries into the in-memory AVM store. +// Entries use deterministic 32-byte keys and 128-byte values; this is a +// synthetic workload, not a substitute for a verified full-chain rehearsal. +func BenchmarkAVMCheckpointSynthetic(b *testing.B) { + for _, count := range []int{16_384, 131_072, 524_288} { + count := count + b.Run(fmt.Sprintf("entries_%d", count), func(b *testing.B) { + b.Run("write", func(b *testing.B) { + checkpoint := syntheticAVMCheckpoint(b, count) + path := filepath.Join(b.TempDir(), "checkpoint.json") + b.ReportAllocs() + b.ReportMetric(float64(count), "entries/op") + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := WriteCheckpointFile(path, checkpoint); err != nil { + b.Fatalf("write checkpoint: %v", err) + } + } + b.StopTimer() + info, err := os.Stat(path) + if err != nil { + b.Fatalf("stat checkpoint: %v", err) + } + b.ReportMetric(float64(info.Size()), "file-B") + }) + + b.Run("read", func(b *testing.B) { + path := benchmarkFixturePath(b) + fileBytes := checkpointFixtureSize(b, path) + b.SetBytes(fileBytes) + b.ReportAllocs() + b.ReportMetric(float64(count), "entries/op") + b.ReportMetric(float64(fileBytes), "file-B") + b.ResetTimer() + for i := 0; i < b.N; i++ { + checkpoint, found, err := ReadCheckpointFile(path) + if err != nil || !found { + b.Fatalf("read checkpoint: found=%v err=%v", found, err) + } + if len(checkpoint.Entries) != count { + b.Fatalf("read %d entries, want %d", len(checkpoint.Entries), count) + } + checkpointBenchmarkCheckpointSink = checkpoint + } + }) + + b.Run("restore", func(b *testing.B) { + path := benchmarkFixturePath(b) + fileBytes := checkpointFixtureSize(b, path) + b.SetBytes(fileBytes) + b.ReportAllocs() + b.ReportMetric(float64(count), "entries/op") + b.ReportMetric(float64(fileBytes), "file-B") + b.ResetTimer() + for i := 0; i < b.N; i++ { + checkpoint, found, err := ReadCheckpointFile(path) + if err != nil || !found { + b.Fatalf("read checkpoint for restore: found=%v err=%v", found, err) + } + writes := make([]Write, len(checkpoint.Entries)) + for j, entry := range checkpoint.Entries { + writes[j] = Write(entry) + } + restored := NewMemoryStore() + if err := restored.Apply(writes); err != nil { + b.Fatalf("apply restored state: %v", err) + } + if len(restored.data) != count { + b.Fatalf("restored %d entries, want %d", len(restored.data), count) + } + checkpointBenchmarkStoreSink = restored + } + }) + }) + } +} + +func syntheticAVMCheckpoint(tb testing.TB, count int) *AVMCheckpoint { + tb.Helper() + entries := make([]CheckpointEntry, count) + for i := range entries { + key := make([]byte, 32) + copy(key, []byte("avm-storage-v1")) + binary.BigEndian.PutUint64(key[24:], uint64(i)) + + value := make([]byte, 128) + binary.BigEndian.PutUint64(value[:8], uint64(i)) + for j := 8; j < len(value); j++ { + value[j] = byte((uint64(i)*31 + uint64(j)*17) & 0xff) + } + entries[i] = CheckpointEntry{Key: key, Value: value} + } + checkpoint, err := NewQuorumCheckpointDraft( + 1, + 2, + crypto.HashStr("synthetic-avm-checkpoint-genesis"), + crypto.HashStr("synthetic-avm-checkpoint-tip"), + crypto.HashStr("synthetic-avm-checkpoint-write-set"), + entries, + ) + if err != nil { + tb.Fatalf("build synthetic checkpoint: %v", err) + } + return checkpoint +} + +func benchmarkFixturePath(tb testing.TB) string { + tb.Helper() + path := os.Getenv(checkpointBenchmarkFixturePathEnv) + if path == "" { + tb.Fatalf("set %s to a separately generated synthetic fixture", checkpointBenchmarkFixturePathEnv) + } + return path +} + +func checkpointFixtureSize(tb testing.TB, path string) int64 { + tb.Helper() + info, err := os.Stat(path) + if err != nil { + tb.Fatalf("stat synthetic fixture: %v", err) + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxAVMCheckpointFileBytes { + tb.Fatalf("synthetic fixture has unsupported size or type: size=%d mode=%s", info.Size(), info.Mode()) + } + return info.Size() +} diff --git a/avm/checkpoint_quorum_test.go b/avm/checkpoint_quorum_test.go new file mode 100644 index 0000000..121ece0 --- /dev/null +++ b/avm/checkpoint_quorum_test.go @@ -0,0 +1,311 @@ +package avm + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func checkpointWitnessSet(t *testing.T, count int) ([]CheckpointSigner, []crypto.ValidatorPubKey) { + t.Helper() + signers := make([]CheckpointSigner, count) + authorities := make([]crypto.ValidatorPubKey, count) + for i := range signers { + privateKey, publicKey, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + signers[i] = privateKey + authorities[i] = publicKey + } + return signers, authorities +} + +func collectCheckpointSignatures(t *testing.T, draft *AVMCheckpoint, activationHeight uint64, signers ...CheckpointSigner) []CheckpointWitnessSignature { + t.Helper() + signatures := make([]CheckpointWitnessSignature, 0, len(signers)) + for _, signer := range signers { + signature, err := SignCheckpointDraft(draft, activationHeight, signer) + if err != nil { + t.Fatalf("sign checkpoint draft: %v", err) + } + signatures = append(signatures, signature) + } + return signatures +} + +func TestQuorumCheckpointRequiresThresholdAndVerifiesEverySignature(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 1, 0, "") + tipHash := mustCanonicalHash(t, chain.db, 1) + draft, err := BuildCheckpointDraftFromCanonical( + context.Background(), chain.db, chain.activation, 1, tipHash, + ) + if err != nil { + t.Fatal(err) + } + + signers, authorities := checkpointWitnessSet(t, 3) + partial := collectCheckpointSignatures(t, draft, chain.activation, signers[0]) + if _, err := AttachCheckpointSignatures(draft, partial, chain.activation, authorities, 2); err == nil || + !strings.Contains(err.Error(), "insufficient witness signatures") { + t.Fatalf("one signature unexpectedly met a 2-of-3 threshold: %v", err) + } + + twoSignatures := collectCheckpointSignatures(t, draft, chain.activation, signers[0], signers[2]) + checkpoint, err := AttachCheckpointSignatures(draft, twoSignatures, chain.activation, authorities, 2) + if err != nil { + t.Fatalf("attach 2-of-3 signatures: %v", err) + } + if err := VerifyCheckpointQuorum(checkpoint, chain.activation, authorities, 2); err != nil { + t.Fatalf("verify 2-of-3 checkpoint: %v", err) + } + + outsider, _ := checkpointWitnessSet(t, 1) + unauthorized := collectCheckpointSignatures(t, draft, chain.activation, signers[0], outsider[0]) + if _, err := AttachCheckpointSignatures(draft, unauthorized, chain.activation, authorities, 2); err == nil || + !strings.Contains(err.Error(), "unauthorized witness") { + t.Fatalf("unauthorized witness unexpectedly counted: %v", err) + } + + duplicate := collectCheckpointSignatures(t, draft, chain.activation, signers[0], signers[0]) + if _, err := AttachCheckpointSignatures(draft, duplicate, chain.activation, authorities, 2); err == nil || + !strings.Contains(err.Error(), "uniquely key ordered") { + t.Fatalf("duplicate witness unexpectedly counted: %v", err) + } + + threeSignatures := collectCheckpointSignatures(t, draft, chain.activation, signers[0], signers[1], signers[2]) + threeSignatures[2].Signature[0] ^= 0x01 + if _, err := AttachCheckpointSignatures(draft, threeSignatures, chain.activation, authorities, 2); err == nil || + !strings.Contains(err.Error(), "invalid witness signature") { + t.Fatalf("invalid extra signature was ignored: %v", err) + } +} + +func TestQuorumCheckpointRejectsUnsignedDraftAtStartup(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 1, 0, "") + tipHash := mustCanonicalHash(t, chain.db, 1) + draft, err := BuildCheckpointDraftFromCanonical( + context.Background(), chain.db, chain.activation, 1, tipHash, + ) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "unsigned-draft.json") + if err := WriteCheckpointFile(path, draft); err != nil { + t.Fatal(err) + } + _, authorities := checkpointWitnessSet(t, 3) + if _, err := EnsureCanonicalStateWithQuorumCheckpoint( + context.Background(), chain.db, chain.activation, 1, tipHash, + path, authorities, 2, 10, + ); err == nil || !strings.Contains(err.Error(), "insufficient witness signatures") { + t.Fatalf("unsigned draft authorized startup: %v", err) + } +} + +func TestQuorumCheckpointRejectsTamperingAndV1Downgrade(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 1, 0, "") + tipHash := mustCanonicalHash(t, chain.db, 1) + draft, err := BuildCheckpointDraftFromCanonical( + context.Background(), chain.db, chain.activation, 1, tipHash, + ) + if err != nil { + t.Fatal(err) + } + signers, authorities := checkpointWitnessSet(t, 2) + signatures := collectCheckpointSignatures(t, draft, chain.activation, signers...) + checkpoint, err := AttachCheckpointSignatures(draft, signatures, chain.activation, authorities, 2) + if err != nil { + t.Fatal(err) + } + tampered := *checkpoint + tampered.BlockHash = crypto.HashStr("forged tip") + if err := VerifyCheckpointQuorum(&tampered, chain.activation, authorities, 2); err == nil || + !strings.Contains(err.Error(), "invalid witness signature") { + t.Fatalf("tampered hash was not bound to signatures: %v", err) + } + + legacy, err := SignCheckpoint( + chain.activation, 1, checkpoint.GenesisHash, tipHash, + checkpoint.WriteSetCommitment, checkpoint.Entries, chain.privateKey, chain.authorities, + ) + if err != nil { + t.Fatal(err) + } + if err := VerifyCheckpointQuorum(legacy, chain.activation, authorities, 2); err == nil || + !strings.Contains(err.Error(), "requires version 2") { + t.Fatalf("v1 operator checkpoint was accepted in quorum mode: %v", err) + } +} + +func TestQuorumCheckpointBindsEverySignedField(t *testing.T) { + signers, authorities := checkpointWitnessSet(t, 2) + draft, err := NewQuorumCheckpointDraft( + 1, + 10, + crypto.HashStr("genesis"), + crypto.HashStr("block"), + crypto.HashStr("write-set"), + []CheckpointEntry{{Key: []byte("k"), Value: []byte("v")}}, + ) + if err != nil { + t.Fatal(err) + } + signatures := collectCheckpointSignatures(t, draft, 1, signers...) + valid, err := AttachCheckpointSignatures(draft, signatures, 1, authorities, 2) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + activationHeight uint64 + mutate func(*AVMCheckpoint) + }{ + {name: "activation height", activationHeight: 2, mutate: func(c *AVMCheckpoint) { c.ActivationHeight = 2 }}, + {name: "tip height", activationHeight: 1, mutate: func(c *AVMCheckpoint) { c.Height++ }}, + {name: "genesis hash", activationHeight: 1, mutate: func(c *AVMCheckpoint) { c.GenesisHash = crypto.HashStr("other genesis") }}, + {name: "block hash", activationHeight: 1, mutate: func(c *AVMCheckpoint) { c.BlockHash = crypto.HashStr("other block") }}, + {name: "write-set commitment", activationHeight: 1, mutate: func(c *AVMCheckpoint) { c.WriteSetCommitment = crypto.HashStr("other writes") }}, + {name: "state and commitment", activationHeight: 1, mutate: func(c *AVMCheckpoint) { + c.Entries[0].Value = []byte("tampered") + c.StateCommitment, _ = StateSnapshotCommitment(c.Entries) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tampered := cloneSignedCheckpoint(valid) + tc.mutate(tampered) + if err := VerifyCheckpointQuorum(tampered, tc.activationHeight, authorities, 2); err == nil || + !strings.Contains(err.Error(), "invalid witness signature") { + t.Fatalf("mutated signed field was not rejected by signatures: %v", err) + } + }) + } +} + +func TestQuorumCheckpointKeyRotationRequiresNewQuorum(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 1, 0, "") + tipHash := mustCanonicalHash(t, chain.db, 1) + draft, err := BuildCheckpointDraftFromCanonical( + context.Background(), chain.db, chain.activation, 1, tipHash, + ) + if err != nil { + t.Fatal(err) + } + + oldSigners, oldAuthorities := checkpointWitnessSet(t, 2) + oldSignatures := collectCheckpointSignatures(t, draft, chain.activation, oldSigners...) + oldCheckpoint, err := AttachCheckpointSignatures(draft, oldSignatures, chain.activation, oldAuthorities, 2) + if err != nil { + t.Fatal(err) + } + if err := VerifyCheckpointQuorum(oldCheckpoint, chain.activation, oldAuthorities, 2); err != nil { + t.Fatal(err) + } + + newSigners, newAuthorities := checkpointWitnessSet(t, 2) + if err := VerifyCheckpointQuorum(oldCheckpoint, chain.activation, newAuthorities, 2); err == nil { + t.Fatal("checkpoint signed by retired keys remained trusted after rotation") + } + newSignatures := collectCheckpointSignatures(t, draft, chain.activation, newSigners...) + newCheckpoint, err := AttachCheckpointSignatures(draft, newSignatures, chain.activation, newAuthorities, 2) + if err != nil { + t.Fatalf("checkpoint signed by rotated keys: %v", err) + } + if err := VerifyCheckpointQuorum(newCheckpoint, chain.activation, newAuthorities, 2); err != nil { + t.Fatalf("verify rotated-key checkpoint: %v", err) + } +} + +func cloneSignedCheckpoint(checkpoint *AVMCheckpoint) *AVMCheckpoint { + cloned := *checkpoint + cloned.Entries = cloneCheckpointEntries(checkpoint.Entries) + cloned.WitnessSignatures = make([]CheckpointWitnessSignature, len(checkpoint.WitnessSignatures)) + for i, signature := range checkpoint.WitnessSignatures { + cloned.WitnessSignatures[i] = CheckpointWitnessSignature{ + WitnessPublicKey: append(crypto.ValidatorPubKey(nil), signature.WitnessPublicKey...), + Signature: append([]byte(nil), signature.Signature...), + } + } + return &cloned +} + +func TestQuorumCheckpointRecoveryAndRestart(t *testing.T) { + chain := newCheckpointTestChain(t) + checkpointPath := filepath.Join(t.TempDir(), "avm-checkpoint.json") + chain.appendDeployAndEmptyBlocks(t, 2, 0, "") + draft, err := CreateQuorumCheckpointDraftFromCanonicalState( + chain.db, chain.activation, 2, mustCanonicalHash(t, chain.db, 2), + ) + if err != nil { + t.Fatal(err) + } + signers, authorities := checkpointWitnessSet(t, 3) + signatures := collectCheckpointSignatures(t, draft, chain.activation, signers[0], signers[2]) + checkpoint, err := AttachCheckpointSignatures(draft, signatures, chain.activation, authorities, 2) + if err != nil { + t.Fatal(err) + } + if err := WriteCheckpointFile(checkpointPath, checkpoint); err != nil { + t.Fatal(err) + } + + prev := mustCanonicalHash(t, chain.db, 2) + for height := uint64(3); height <= 4; height++ { + prev = chain.appendBlock(t, height, prev, nil) + } + rebuilt, err := EnsureCanonicalStateWithQuorumCheckpoint( + context.Background(), chain.db, chain.activation, 4, prev, + checkpointPath, authorities, 2, 2, + ) + if err != nil || rebuilt { + t.Fatalf("quorum bounded recovery rebuilt=%v err=%v", rebuilt, err) + } + storedCheckpoint, found, err := ReadCheckpointFile(checkpointPath) + if err != nil || !found || storedCheckpoint.Height != 2 || len(storedCheckpoint.WitnessSignatures) != 2 { + t.Fatalf("recovery unexpectedly rewrote quorum checkpoint: found=%v checkpoint=%+v err=%v", found, storedCheckpoint, err) + } + + if err := chain.db.Close(); err != nil { + t.Fatal(err) + } + chain.db, err = store.Open(chain.dir) + if err != nil { + t.Fatal(err) + } + rebuilt, err = EnsureCanonicalStateWithQuorumCheckpoint( + context.Background(), chain.db, chain.activation, 4, prev, + checkpointPath, authorities, 2, 2, + ) + if err != nil || rebuilt { + t.Fatalf("restart with quorum checkpoint rebuilt=%v err=%v", rebuilt, err) + } +} + +func TestQuorumCheckpointMissingAboveReplayLimitFailsClosed(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 3, 0, "") + _, authorities := checkpointWitnessSet(t, 2) + path := filepath.Join(t.TempDir(), "missing-quorum-checkpoint.json") + if _, err := EnsureCanonicalStateWithQuorumCheckpoint( + context.Background(), + chain.db, + chain.activation, + 3, + mustCanonicalHash(t, chain.db, 3), + path, + authorities, + 2, + 2, + ); err == nil || !strings.Contains(err.Error(), "checkpoint is missing") { + t.Fatalf("missing quorum checkpoint did not stop over-limit replay: %v", err) + } +} diff --git a/avm/checkpoint_test.go b/avm/checkpoint_test.go new file mode 100644 index 0000000..6ff71b0 --- /dev/null +++ b/avm/checkpoint_test.go @@ -0,0 +1,434 @@ +package avm + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +type checkpointTestChain struct { + db *store.DB + dir string + privateKey crypto.ValidatorPrivKey + authorities []crypto.ValidatorPubKey + activation uint64 +} + +func newCheckpointTestChain(t *testing.T) *checkpointTestChain { + t.Helper() + dir := t.TempDir() + db, err := store.Open(dir) + if err != nil { + t.Fatalf("open DB: %v", err) + } + privateKey, publicKey, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatalf("generate validator key: %v", err) + } + genesis := &core.Block{Header: core.BlockHeader{ + Height: 0, + Timestamp: 1, + MerkleRoot: core.MerkleRoot(nil), + }} + genesisRaw, err := json.Marshal(genesis) + if err != nil { + t.Fatalf("marshal genesis: %v", err) + } + if err := db.PutRawBlock(genesis.Hash(), 0, genesisRaw); err != nil { + t.Fatalf("store genesis: %v", err) + } + if err := db.PutTip(genesis.Hash(), 0); err != nil { + t.Fatalf("store genesis tip: %v", err) + } + chain := &checkpointTestChain{ + db: db, + dir: dir, + privateKey: privateKey, + authorities: []crypto.ValidatorPubKey{publicKey}, + activation: 1, + } + t.Cleanup(func() { _ = chain.db.Close() }) + return chain +} + +func (c *checkpointTestChain) appendBlock(t *testing.T, height uint64, previous crypto.Hash32, txs []core.Transaction) crypto.Hash32 { + t.Helper() + block := &core.Block{ + Header: core.BlockHeader{ + Height: height, + PrevHash: previous, + Timestamp: int64(height + 1), + Round: uint32(height), + ValidatorPub: c.privateKey.Public(), + MerkleRoot: core.MerkleRoot(txs), + }, + Txs: txs, + } + if err := block.Header.Sign(c.privateKey); err != nil { + t.Fatalf("sign block %d: %v", height, err) + } + prepared, err := NewBlockExecutor(LevelStore{DB: c.db}).PrepareBlock(context.Background(), block) + if err != nil { + t.Fatalf("prepare block %d: %v", height, err) + } + raw, err := json.Marshal(block) + if err != nil { + t.Fatalf("marshal block %d: %v", height, err) + } + if err := CommitCanonicalBlock(c.db, block, raw, prepared); err != nil { + t.Fatalf("commit block %d: %v", height, err) + } + return block.Hash() +} + +func (c *checkpointTestChain) appendDeployAndEmptyBlocks(t *testing.T, through uint64, checkpointAt uint64, checkpointPath string) { + t.Helper() + prev, _, err := c.db.GetTip() + if err != nil { + t.Fatalf("read initial tip: %v", err) + } + for height := uint64(1); height <= through; height++ { + var txs []core.Transaction + if height == 1 { + txs = []core.Transaction{ + signedAVMTransaction(t, core.AVMDeployContract, [32]byte{}, 0, stateWriteModule(false)), + } + } + prev = c.appendBlock(t, height, prev, txs) + if height == checkpointAt { + checkpoint, err := CreateCheckpointFromCanonicalState( + c.db, c.activation, height, prev, c.privateKey, c.authorities, + ) + if err != nil { + t.Fatalf("create checkpoint at %d: %v", height, err) + } + if err := WriteCheckpointFile(checkpointPath, checkpoint); err != nil { + t.Fatalf("write checkpoint at %d: %v", height, err) + } + } + } +} + +func TestCheckpointStateCommitmentIsOrderBoundAndDeterministic(t *testing.T) { + entries := []CheckpointEntry{ + {Key: []byte("a"), Value: []byte("one")}, + {Key: []byte("b"), Value: []byte("two")}, + } + first, err := StateSnapshotCommitment(entries) + if err != nil { + t.Fatal(err) + } + second, err := StateSnapshotCommitment(entries) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("identical AVM snapshots produced different commitments") + } + if _, err := StateSnapshotCommitment([]CheckpointEntry{ + {Key: []byte("b"), Value: []byte("two")}, + {Key: []byte("a"), Value: []byte("one")}, + }); err == nil { + t.Fatal("unordered snapshot was accepted") + } + if _, err := StateSnapshotCommitment([]CheckpointEntry{ + {Key: []byte("a"), Value: []byte("one")}, + {Key: []byte("a"), Value: []byte("two")}, + }); err == nil { + t.Fatal("duplicate state key was accepted") + } +} + +func TestEnsureCanonicalStateUsesSignedCheckpointAndSurvivesRestart(t *testing.T) { + chain := newCheckpointTestChain(t) + checkpointPath := filepath.Join(t.TempDir(), "avm-checkpoint.json") + chain.appendDeployAndEmptyBlocks(t, 4, 2, checkpointPath) + + rebuilt, err := EnsureCanonicalStateWithCheckpoint( + context.Background(), + chain.db, + chain.activation, + 4, + mustCanonicalHash(t, chain.db, 4), + checkpointPath, + chain.authorities, + chain.privateKey, + 2, + ) + if err != nil || rebuilt { + t.Fatalf("bounded recovery rebuilt=%v err=%v", rebuilt, err) + } + checkpoint, found, err := ReadCheckpointFile(checkpointPath) + if err != nil || !found || checkpoint.Height != 4 { + t.Fatalf("refreshed checkpoint height=%v found=%v err=%v", checkpoint, found, err) + } + + if err := chain.db.Close(); err != nil { + t.Fatalf("close DB: %v", err) + } + chain.db, err = store.Open(chain.dir) + if err != nil { + t.Fatalf("reopen DB: %v", err) + } + rebuilt, err = EnsureCanonicalStateWithCheckpoint( + context.Background(), + chain.db, + chain.activation, + 4, + mustCanonicalHash(t, chain.db, 4), + checkpointPath, + chain.authorities, + chain.privateKey, + 2, + ) + if err != nil || rebuilt { + t.Fatalf("restart recovery rebuilt=%v err=%v", rebuilt, err) + } +} + +func TestEnsureCanonicalStateRejectsTamperedCheckpoint(t *testing.T) { + chain := newCheckpointTestChain(t) + checkpointPath := filepath.Join(t.TempDir(), "avm-checkpoint.json") + chain.appendDeployAndEmptyBlocks(t, 2, 2, checkpointPath) + checkpoint, found, err := ReadCheckpointFile(checkpointPath) + if err != nil || !found { + t.Fatalf("read checkpoint found=%v err=%v", found, err) + } + checkpoint.Entries[0].Value = append(checkpoint.Entries[0].Value, 'x') + checkpoint.StateCommitment, err = StateSnapshotCommitment(checkpoint.Entries) + if err != nil { + t.Fatal(err) + } + if err := WriteCheckpointFile(checkpointPath, checkpoint); err != nil { + t.Fatal(err) + } + if _, err := EnsureCanonicalStateWithCheckpoint( + context.Background(), + chain.db, + chain.activation, + 2, + mustCanonicalHash(t, chain.db, 2), + checkpointPath, + chain.authorities, + chain.privateKey, + 2, + ); err == nil || !strings.Contains(err.Error(), "invalid trusted witness signature") { + t.Fatalf("tampered checkpoint was not rejected by signature: %v", err) + } +} + +func TestEnsureCanonicalStateRejectsCheckpointFromFork(t *testing.T) { + chain := newCheckpointTestChain(t) + checkpointPath := filepath.Join(t.TempDir(), "avm-checkpoint.json") + chain.appendDeployAndEmptyBlocks(t, 3, 2, checkpointPath) + checkpoint, found, err := ReadCheckpointFile(checkpointPath) + if err != nil || !found { + t.Fatalf("read checkpoint found=%v err=%v", found, err) + } + forkHash := crypto.HashStr("different canonical fork") + forkCheckpoint, err := SignCheckpoint( + checkpoint.ActivationHeight, + checkpoint.Height, + checkpoint.GenesisHash, + forkHash, + checkpoint.WriteSetCommitment, + checkpoint.Entries, + chain.privateKey, + chain.authorities, + ) + if err != nil { + t.Fatal(err) + } + if err := WriteCheckpointFile(checkpointPath, forkCheckpoint); err != nil { + t.Fatal(err) + } + if _, err := EnsureCanonicalStateWithCheckpoint( + context.Background(), + chain.db, + chain.activation, + 3, + mustCanonicalHash(t, chain.db, 3), + checkpointPath, + chain.authorities, + chain.privateKey, + 2, + ); err == nil || !strings.Contains(err.Error(), "not canonical") { + t.Fatalf("fork checkpoint was not rejected: %v", err) + } +} + +func TestEnsureCanonicalStateFailsClosedWhenCheckpointMissingAndReplayTooLong(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 3, 0, "") + checkpointPath := filepath.Join(t.TempDir(), "missing-checkpoint.json") + if _, err := os.Stat(checkpointPath); !os.IsNotExist(err) { + t.Fatalf("expected checkpoint to be absent, stat err=%v", err) + } + if _, err := EnsureCanonicalStateWithCheckpoint( + context.Background(), + chain.db, + chain.activation, + 3, + mustCanonicalHash(t, chain.db, 3), + checkpointPath, + chain.authorities, + chain.privateKey, + 2, + ); err == nil || !strings.Contains(err.Error(), "checkpoint is missing") { + t.Fatalf("missing checkpoint did not stop over-limit replay: %v", err) + } +} + +func TestBuildVerifiedCheckpointFromCanonicalRoundTrips(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 1, 0, "") + tipHash := mustCanonicalHash(t, chain.db, 1) + checkpoint, err := BuildVerifiedCheckpointFromCanonical( + context.Background(), + chain.db, + chain.activation, + 1, + tipHash, + chain.privateKey, + chain.authorities, + ) + if err != nil { + t.Fatalf("build checkpoint: %v", err) + } + if err := VerifyCheckpoint(checkpoint, chain.activation, chain.authorities); err != nil { + t.Fatalf("verify checkpoint: %v", err) + } + path := filepath.Join(t.TempDir(), "checkpoint.json") + if err := WriteCheckpointFile(path, checkpoint); err != nil { + t.Fatalf("write checkpoint: %v", err) + } + reloaded, found, err := ReadCheckpointFile(path) + if err != nil || !found { + t.Fatalf("read checkpoint found=%v err=%v", found, err) + } + if err := VerifyCheckpoint(reloaded, chain.activation, chain.authorities); err != nil { + t.Fatalf("verify reloaded checkpoint: %v", err) + } + if reloaded.Height != 1 || reloaded.BlockHash != tipHash || + reloaded.StateCommitment != checkpoint.StateCommitment { + t.Fatal("checkpoint round-trip changed its canonical binding") + } +} + +func TestReadCheckpointFileRejects512MiBPlusOneSparseFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "oversized-checkpoint.json") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxAVMCheckpointFileBytes + 1); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + checkpoint, found, err := ReadCheckpointFile(path) + if err == nil || found || checkpoint != nil || + !strings.Contains(err.Error(), "outside supported bounds") { + t.Fatalf("oversized checkpoint not rejected before decode: found=%v checkpoint=%v err=%v", found, checkpoint, err) + } +} + +func TestOversizedCheckpointWritePreservesExistingCheckpoint(t *testing.T) { + path := filepath.Join(t.TempDir(), "avm-checkpoint.json") + valid, err := NewQuorumCheckpointDraft( + 1, + 10, + crypto.HashStr("genesis"), + crypto.HashStr("tip"), + crypto.HashStr("write-set"), + []CheckpointEntry{{Key: []byte("contract/state"), Value: []byte("value")}}, + ) + if err != nil { + t.Fatal(err) + } + if err := WriteCheckpointFile(path, valid); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := writeCheckpointFileWithLimit(path, valid, 32); err == nil || + !strings.Contains(err.Error(), "encoded file exceeds 32 bytes") { + t.Fatalf("oversized checkpoint write was not rejected: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("failed oversized write replaced the prior checkpoint") + } + checkpoint, found, err := ReadCheckpointFile(path) + if err != nil || !found || !CheckpointPayloadEqual(checkpoint, valid) { + t.Fatalf("previous checkpoint became unreadable: found=%v err=%v", found, err) + } +} + +func TestBuildVerifiedCheckpointFromCanonicalRejectsInvalidBlockSignature(t *testing.T) { + chain := newCheckpointTestChain(t) + chain.appendDeployAndEmptyBlocks(t, 1, 0, "") + blockHash := mustCanonicalHash(t, chain.db, 1) + raw, err := chain.db.GetRawBlock(blockHash) + if err != nil { + t.Fatal(err) + } + var block core.Block + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatal(err) + } + block.Header.Signature[0] ^= 0x01 + raw, err = json.Marshal(&block) + if err != nil { + t.Fatal(err) + } + if err := chain.db.PutRawBlock(blockHash, 1, raw); err != nil { + t.Fatal(err) + } + if _, err := BuildVerifiedCheckpointFromCanonical( + context.Background(), + chain.db, + chain.activation, + 1, + blockHash, + chain.privateKey, + chain.authorities, + ); err == nil || !strings.Contains(err.Error(), "invalid validator signature") { + t.Fatalf("builder accepted invalid block signature: %v", err) + } +} + +func TestEnsureCanonicalStateRejectsFullReplayOverLimit(t *testing.T) { + if _, err := EnsureCanonicalState( + context.Background(), + nil, + 1, + MaxAVMCheckpointReplay+1, + crypto.Hash32{}, + ); err == nil || !strings.Contains(err.Error(), "authenticated checkpoint is required") { + t.Fatalf("over-limit replay was not refused before database access: %v", err) + } +} + +func mustCanonicalHash(t *testing.T, db *store.DB, height uint64) crypto.Hash32 { + t.Helper() + hash, found, err := db.GetCanonicalHash(height) + if err != nil || !found { + t.Fatalf("canonical hash at %d: found=%v err=%v", height, found, err) + } + return hash +} diff --git a/avm/recovery.go b/avm/recovery.go index 1d01da9..6a66982 100644 --- a/avm/recovery.go +++ b/avm/recovery.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "math" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" @@ -16,12 +17,124 @@ import ( // blocks. Any conflicting marker or state is treated as corruption and fails // closed. An activation height of zero keeps the historical "AVM disabled" // semantics and performs no work. +// +// This compatibility entry point performs the historical full replay. Node +// startup should use EnsureCanonicalStateWithQuorumCheckpoint when trusted +// witness keys and a quorum checkpoint are configured. func EnsureCanonicalState( ctx context.Context, db *store.DB, activationHeight uint64, tipHeight uint64, tipHash crypto.Hash32, +) (bool, error) { + if activationHeight > 0 && tipHeight >= activationHeight { + replayCount := tipHeight - activationHeight + 1 + if replayCount > MaxAVMCheckpointReplay { + return false, fmt.Errorf( + "avm startup: %d-block full replay exceeds limit %d; an authenticated checkpoint is required", + replayCount, MaxAVMCheckpointReplay, + ) + } + } + return ensureCanonicalState(ctx, db, activationHeight, tipHeight, tipHash, nil) +} + +// EnsureCanonicalStateWithCheckpoint resumes from a legacy v1 +// operator-attested checkpoint. +// +// Deprecated: quorum-configured production nodes should use +// EnsureCanonicalStateWithQuorumCheckpoint. +func EnsureCanonicalStateWithCheckpoint( + ctx context.Context, + db *store.DB, + activationHeight uint64, + tipHeight uint64, + tipHash crypto.Hash32, + checkpointPath string, + authorities []crypto.ValidatorPubKey, + signer CheckpointSigner, + maxReplayBlocks uint64, +) (bool, error) { + if activationHeight == 0 || tipHeight < activationHeight { + return false, nil + } + if checkpointPath == "" { + return false, fmt.Errorf("avm startup: checkpoint path is required") + } + if len(authorities) == 0 { + return false, fmt.Errorf("avm startup: trusted checkpoint witness is required") + } + if signer == nil || !checkpointAuthorityAllowed(authorities, signer.Public()) { + return false, fmt.Errorf("avm startup: local checkpoint signer must be a pinned witness") + } + if maxReplayBlocks == 0 { + maxReplayBlocks = MaxAVMCheckpointReplay + } + return ensureCanonicalState( + ctx, db, activationHeight, tipHeight, tipHash, + &checkpointRecovery{ + path: checkpointPath, + authorities: authorities, + signer: signer, + maxReplayBlocks: maxReplayBlocks, + }, + ) +} + +// EnsureCanonicalStateWithQuorumCheckpoint is the node startup path for +// checkpoint version 2. The checkpoint must carry a valid m-of-n signature +// set; this node does not need private keys from other witnesses. Successful +// replay never downgrades or re-signs a quorum checkpoint. +func EnsureCanonicalStateWithQuorumCheckpoint( + ctx context.Context, + db *store.DB, + activationHeight uint64, + tipHeight uint64, + tipHash crypto.Hash32, + checkpointPath string, + authorities []crypto.ValidatorPubKey, + threshold int, + maxReplayBlocks uint64, +) (bool, error) { + if activationHeight == 0 || tipHeight < activationHeight { + return false, nil + } + if checkpointPath == "" { + return false, fmt.Errorf("avm startup: checkpoint path is required") + } + if err := validateCheckpointAuthorities(authorities, threshold); err != nil { + return false, err + } + if maxReplayBlocks == 0 { + maxReplayBlocks = MaxAVMCheckpointReplay + } + return ensureCanonicalState( + ctx, db, activationHeight, tipHeight, tipHash, + &checkpointRecovery{ + path: checkpointPath, + authorities: authorities, + witnessThreshold: threshold, + maxReplayBlocks: maxReplayBlocks, + }, + ) +} + +type checkpointRecovery struct { + path string + authorities []crypto.ValidatorPubKey + signer CheckpointSigner + witnessThreshold int + maxReplayBlocks uint64 +} + +func ensureCanonicalState( + ctx context.Context, + db *store.DB, + activationHeight uint64, + tipHeight uint64, + tipHash crypto.Hash32, + checkpoint *checkpointRecovery, ) (rebuilt bool, err error) { if db == nil { return false, fmt.Errorf("avm startup: nil database") @@ -31,109 +144,547 @@ func EnsureCanonicalState( } replay := NewMemoryStore() + var ( + startHeight = activationHeight + previousHash crypto.Hash32 + havePreviousHash bool + tipCommitment crypto.Hash32 + haveTipCommitment bool + strictHistory bool + genesisHash crypto.Hash32 + skipReplay bool + ) + + if checkpoint != nil { + strictHistory = true + var found bool + genesisHash, found, err = db.GetCanonicalHash(0) + if err != nil || !found || genesisHash.Zero() { + if err != nil { + return false, fmt.Errorf("avm startup: canonical genesis hash: %w", err) + } + return false, fmt.Errorf("avm startup: canonical genesis hash is missing") + } + + snapshot, exists, readErr := ReadCheckpointFile(checkpoint.path) + if readErr != nil { + return false, readErr + } + if exists { + var verifyErr error + if checkpoint.witnessThreshold > 0 { + verifyErr = VerifyCheckpointQuorum( + snapshot, + activationHeight, + checkpoint.authorities, + checkpoint.witnessThreshold, + ) + } else { + verifyErr = VerifyCheckpoint(snapshot, activationHeight, checkpoint.authorities) + } + if verifyErr != nil { + return false, verifyErr + } + if snapshot.Height > tipHeight { + return false, fmt.Errorf("avm startup: checkpoint is ahead of the canonical tip") + } + if snapshot.GenesisHash != genesisHash { + return false, fmt.Errorf("avm startup: checkpoint belongs to a different genesis") + } + indexedHash, found, indexErr := db.GetCanonicalHash(snapshot.Height) + if indexErr != nil { + return false, fmt.Errorf("avm startup: checkpoint canonical index: %w", indexErr) + } + if !found || indexedHash != snapshot.BlockHash { + return false, fmt.Errorf("avm startup: checkpoint block is not canonical at height %d", snapshot.Height) + } + markerHash, markerCommitment, markerFound, markerErr := db.GetAVMCommitment(snapshot.Height) + if markerErr != nil { + return false, fmt.Errorf("avm startup: checkpoint commitment marker: %w", markerErr) + } + if !markerFound || markerHash != snapshot.BlockHash || + markerCommitment != snapshot.WriteSetCommitment { + return false, fmt.Errorf("avm startup: checkpoint write-set marker mismatch at height %d", snapshot.Height) + } + replayWrites := make([]Write, len(snapshot.Entries)) + for i, entry := range snapshot.Entries { + replayWrites[i] = Write(entry) + } + if err := replay.Apply(replayWrites); err != nil { + return false, fmt.Errorf("avm startup: load checkpoint state: %w", err) + } + previousHash = snapshot.BlockHash + havePreviousHash = true + tipCommitment = snapshot.WriteSetCommitment + haveTipCommitment = snapshot.Height == tipHeight + if snapshot.Height == tipHeight { + skipReplay = true + startHeight = tipHeight + } else { + startHeight = snapshot.Height + 1 + } + } else { + replayCount := tipHeight - activationHeight + 1 + if replayCount > checkpoint.maxReplayBlocks { + return false, fmt.Errorf( + "avm startup: trusted checkpoint is missing; replay of %d AVM blocks exceeds limit %d", + replayCount, checkpoint.maxReplayBlocks, + ) + } + previousHash, found, err = db.GetCanonicalHash(activationHeight - 1) + if err != nil || !found { + if err != nil { + return false, fmt.Errorf("avm startup: canonical parent index at height %d: %w", activationHeight-1, err) + } + return false, fmt.Errorf("avm startup: canonical parent missing at height %d", activationHeight-1) + } + havePreviousHash = true + } + if !skipReplay && tipHeight >= startHeight { + replayCount := tipHeight - startHeight + 1 + if replayCount > checkpoint.maxReplayBlocks { + return false, fmt.Errorf( + "avm startup: checkpoint is too old; replay of %d AVM blocks exceeds limit %d", + replayCount, checkpoint.maxReplayBlocks, + ) + } + } + } else { + // Preserve the existing full-history path for callers that have not + // configured an explicit checkpoint trust root. + if activationHeight > 0 { + if parentHash, found, parentErr := db.GetCanonicalHash(activationHeight - 1); parentErr == nil && found { + previousHash = parentHash + havePreviousHash = true + } + } + } + + if !skipReplay && tipHeight >= startHeight { + tipCommitment, err = replayCanonicalRange( + ctx, + db, + replay, + startHeight, + tipHeight, + tipHash, + previousHash, + havePreviousHash, + strictHistory, + ) + if err != nil { + return false, err + } + haveTipCommitment = true + } + if !haveTipCommitment { + return false, fmt.Errorf("avm startup: checkpoint does not cover the canonical tip") + } + + markerHash, markerCommitment, markerFound, err := db.GetAVMCommitment(tipHeight) + if err != nil { + return false, fmt.Errorf("avm startup: corrupt tip commitment marker: %w", err) + } + if markerFound && (markerHash != tipHash || markerCommitment != tipCommitment) { + return false, fmt.Errorf("avm startup: canonical tip commitment marker mismatch at height %d; preserve the database and restore a verified canonical backup; do not delete the marker to bypass verification", tipHeight) + } + + expectedWrites := replay.Writes() + replace := func() error { + storeWrites := make([]store.AVMWrite, len(expectedWrites)) + for i, write := range expectedWrites { + storeWrites[i] = store.AVMWrite{Key: write.Key, Value: write.Value} + } + return db.ReplaceAVMStateAndCommitment( + storeWrites, tipHeight, tipHash, tipCommitment, + ) + } + + if !markerFound { + if err := replace(); err != nil { + return false, fmt.Errorf("avm startup: persist rebuilt state: %w", err) + } + rebuilt = true + } else { + expected := make(map[string][]byte, len(expectedWrites)) + for _, write := range expectedWrites { + expected[string(write.Key)] = write.Value + } + actualCount := 0 + if err := db.IterAVMState(func(key, value []byte) error { + actualCount++ + expectedValue, ok := expected[string(key)] + if !ok { + return fmt.Errorf("unexpected AVM state key %x", key) + } + if !bytes.Equal(value, expectedValue) { + return fmt.Errorf("AVM state value mismatch for key %x", key) + } + return nil + }); err != nil { + return false, fmt.Errorf("avm startup: persisted state corruption: %w", err) + } + + stateMissing := actualCount != len(expected) + if stateMissing { + if err := replace(); err != nil { + return false, fmt.Errorf("avm startup: persist rebuilt state: %w", err) + } + rebuilt = true + } + } + + if checkpoint != nil && checkpoint.witnessThreshold == 0 { + entries := make([]CheckpointEntry, len(expectedWrites)) + for i, write := range expectedWrites { + entries[i] = CheckpointEntry(write) + } + fresh, err := SignCheckpoint( + activationHeight, + tipHeight, + genesisHash, + tipHash, + tipCommitment, + entries, + checkpoint.signer, + checkpoint.authorities, + ) + if err != nil { + return rebuilt, fmt.Errorf("avm startup: sign verified checkpoint: %w", err) + } + if err := WriteCheckpointFile(checkpoint.path, fresh); err != nil { + return rebuilt, fmt.Errorf("avm startup: persist verified checkpoint: %w", err) + } + } + return rebuilt, nil +} + +func replayCanonicalRange( + ctx context.Context, + db *store.DB, + replay *MemoryStore, + startHeight uint64, + tipHeight uint64, + tipHash crypto.Hash32, + previousHash crypto.Hash32, + havePreviousHash bool, + verifySignatures bool, +) (crypto.Hash32, error) { executor := NewBlockExecutor(replay) var tipCommitment crypto.Hash32 - var previousHash crypto.Hash32 - for height := activationHeight; height <= tipHeight; height++ { + for height := startHeight; ; height++ { if err := ctx.Err(); err != nil { - return false, fmt.Errorf("avm startup replay: %w", err) + return crypto.Hash32{}, fmt.Errorf("avm startup replay: %w", err) } indexedHash, found, err := db.GetCanonicalHash(height) if err != nil { - return false, fmt.Errorf("avm startup: canonical index at height %d: %w", height, err) + return crypto.Hash32{}, fmt.Errorf("avm startup: canonical index at height %d: %w", height, err) } if !found { - return false, fmt.Errorf("avm startup: canonical block missing at height %d", height) + return crypto.Hash32{}, fmt.Errorf("avm startup: canonical block missing at height %d", height) } raw, err := db.GetRawBlock(indexedHash) if err != nil { - return false, fmt.Errorf("avm startup: read block %d: %w", height, err) + return crypto.Hash32{}, fmt.Errorf("avm startup: read block %d: %w", height, err) } if raw == nil { - return false, fmt.Errorf("avm startup: canonical block body missing at height %d", height) + return crypto.Hash32{}, fmt.Errorf("avm startup: canonical block body missing at height %d", height) } var block core.Block if err := json.Unmarshal(raw, &block); err != nil { - return false, fmt.Errorf("avm startup: corrupt block %d: %w", height, err) + return crypto.Hash32{}, fmt.Errorf("avm startup: corrupt block %d: %w", height, err) } if block.Header.Height != height || block.Hash() != indexedHash { - return false, fmt.Errorf("avm startup: canonical block/index mismatch at height %d", height) + return crypto.Hash32{}, fmt.Errorf("avm startup: canonical block/index mismatch at height %d", height) } if core.MerkleRoot(block.Txs) != block.Header.MerkleRoot { - return false, fmt.Errorf("avm startup: corrupt transaction merkle root at height %d", height) + return crypto.Hash32{}, fmt.Errorf("avm startup: corrupt transaction merkle root at height %d", height) + } + if havePreviousHash && block.Header.PrevHash != previousHash { + return crypto.Hash32{}, fmt.Errorf("avm startup: broken canonical linkage at height %d", height) } - if height > activationHeight && block.Header.PrevHash != previousHash { - return false, fmt.Errorf("avm startup: broken canonical linkage at height %d", height) + if verifySignatures && !block.Header.VerifySignature() { + return crypto.Hash32{}, fmt.Errorf("avm startup: invalid validator signature at height %d", height) } if height == tipHeight && indexedHash != tipHash { - return false, fmt.Errorf("avm startup: supplied tip does not match canonical block at height %d", height) + return crypto.Hash32{}, fmt.Errorf("avm startup: supplied tip does not match canonical block at height %d", height) } prepared, err := executor.PrepareBlock(ctx, &block) if err != nil { - return false, fmt.Errorf("avm startup: replay block %d: %w", height, err) + return crypto.Hash32{}, fmt.Errorf("avm startup: replay block %d: %w", height, err) + } + markerHash, markerCommitment, markerFound, markerErr := db.GetAVMCommitment(height) + if markerErr != nil { + return crypto.Hash32{}, fmt.Errorf("avm startup: read commitment marker at height %d: %w", height, markerErr) + } + if markerFound && (markerHash != indexedHash || markerCommitment != prepared.WriteSetCommitment) { + return crypto.Hash32{}, fmt.Errorf("avm startup: commitment marker mismatch at height %d; preserve the database and restore a verified canonical backup; do not delete the marker to bypass verification", height) } if err := replay.Apply(prepared.Writes); err != nil { - return false, fmt.Errorf("avm startup: apply replay block %d: %w", height, err) + return crypto.Hash32{}, fmt.Errorf("avm startup: apply replay block %d: %w", height, err) } + tipCommitment = prepared.WriteSetCommitment + previousHash = indexedHash + havePreviousHash = true if height == tipHeight { - tipCommitment = prepared.WriteSetCommitment + break + } + if height == math.MaxUint64 { + return crypto.Hash32{}, fmt.Errorf("avm startup: replay height overflow") } - previousHash = indexedHash } + return tipCommitment, nil +} - markerHash, markerCommitment, markerFound, err := db.GetAVMCommitment(tipHeight) +// BuildCheckpointDraftFromCanonical performs a full, read-only AVM replay and +// returns an unsigned v2 draft. Each independent witness should run the same +// full replay against its own canonical database before signing the draft. +func BuildCheckpointDraftFromCanonical( + ctx context.Context, + db *store.DB, + activationHeight uint64, + tipHeight uint64, + tipHash crypto.Hash32, +) (*AVMCheckpoint, error) { + if db == nil { + return nil, fmt.Errorf("avm checkpoint: nil database") + } + if activationHeight == 0 || tipHeight < activationHeight { + return nil, fmt.Errorf("avm checkpoint: invalid activation or tip height") + } + genesisHash, found, err := db.GetCanonicalHash(0) + if err != nil || !found || genesisHash.Zero() { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: canonical genesis hash: %w", err) + } + return nil, fmt.Errorf("avm checkpoint: canonical genesis hash missing") + } + _, storedTipHeight, err := db.GetTip() if err != nil { - return false, fmt.Errorf("avm startup: corrupt tip commitment marker: %w", err) + return nil, fmt.Errorf("avm checkpoint: read stored tip: %w", err) } - if markerFound && (markerHash != tipHash || markerCommitment != tipCommitment) { - return false, fmt.Errorf("avm startup: canonical tip commitment marker mismatch at height %d", tipHeight) + if tipHeight > storedTipHeight { + return nil, fmt.Errorf("avm checkpoint: supplied tip is above the stored canonical tip") } + indexedTipHash, found, err := db.GetCanonicalHash(tipHeight) + if err != nil || !found || indexedTipHash != tipHash { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read canonical target tip: %w", err) + } + return nil, fmt.Errorf("avm checkpoint: supplied tip is not canonical") + } + parentHash, found, err := db.GetCanonicalHash(activationHeight - 1) + if err != nil || !found { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: canonical activation parent: %w", err) + } + return nil, fmt.Errorf("avm checkpoint: canonical activation parent missing") + } + replay := NewMemoryStore() + writeSetCommitment, err := replayCanonicalRange( + ctx, db, replay, activationHeight, tipHeight, tipHash, parentHash, true, true, + ) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: verify canonical history: %w", err) + } + writes := replay.Writes() + entries := make([]CheckpointEntry, len(writes)) + for i, write := range writes { + entries[i] = CheckpointEntry(write) + } + return NewQuorumCheckpointDraft( + activationHeight, tipHeight, genesisHash, tipHash, writeSetCommitment, entries, + ) +} - expectedWrites := replay.Writes() - replace := func() error { - storeWrites := make([]store.AVMWrite, len(expectedWrites)) - for i, write := range expectedWrites { - storeWrites[i] = store.AVMWrite{Key: write.Key, Value: write.Value} +// BuildVerifiedCheckpointFromCanonical builds a legacy operator-attested v1 +// checkpoint. Quorum-configured nodes reject v1 and must use a v2 draft plus +// independently collected signatures. +func BuildVerifiedCheckpointFromCanonical( + ctx context.Context, + db *store.DB, + activationHeight uint64, + tipHeight uint64, + tipHash crypto.Hash32, + signer CheckpointSigner, + authorities []crypto.ValidatorPubKey, +) (*AVMCheckpoint, error) { + if db == nil { + return nil, fmt.Errorf("avm checkpoint: nil database") + } + if activationHeight == 0 || tipHeight < activationHeight { + return nil, fmt.Errorf("avm checkpoint: invalid activation or tip height") + } + if signer == nil || !checkpointAuthorityAllowed(authorities, signer.Public()) { + return nil, fmt.Errorf("avm checkpoint: builder signer must be a pinned witness") + } + genesisHash, found, err := db.GetCanonicalHash(0) + if err != nil || !found || genesisHash.Zero() { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: canonical genesis hash: %w", err) } - return db.ReplaceAVMStateAndCommitment( - storeWrites, tipHeight, tipHash, tipCommitment, - ) + return nil, fmt.Errorf("avm checkpoint: canonical genesis hash missing") } - // Without the atomic marker there is no durable evidence that any existing - // state belongs to this tip. Replace it wholesale; this also handles an old - // value for a key overwritten by the interrupted block. - if !markerFound { - if err := replace(); err != nil { - return false, fmt.Errorf("avm startup: persist rebuilt state: %w", err) + storedTipHash, storedTipHeight, err := db.GetTip() + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read tip: %w", err) + } + if storedTipHash != tipHash || storedTipHeight != tipHeight { + return nil, fmt.Errorf("avm checkpoint: supplied tip differs from stored tip") + } + parentHash, found, err := db.GetCanonicalHash(activationHeight - 1) + if err != nil || !found { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: canonical activation parent: %w", err) } - return true, nil + return nil, fmt.Errorf("avm checkpoint: canonical activation parent missing") + } + replay := NewMemoryStore() + writeSetCommitment, err := replayCanonicalRange( + ctx, db, replay, activationHeight, tipHeight, tipHash, parentHash, true, true, + ) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: verify canonical history: %w", err) + } + writes := replay.Writes() + entries := make([]CheckpointEntry, len(writes)) + for i, write := range writes { + entries[i] = CheckpointEntry(write) + } + checkpoint, err := SignCheckpoint( + activationHeight, tipHeight, genesisHash, tipHash, writeSetCommitment, + entries, signer, authorities, + ) + if err != nil { + return nil, err } + return checkpoint, nil +} - expected := make(map[string][]byte, len(expectedWrites)) - for _, write := range expectedWrites { - expected[string(write.Key)] = write.Value +// CreateQuorumCheckpointDraftFromCanonicalState snapshots the already +// validated current AVM state and binds it to the current canonical tip. The +// caller must have validated this state through startup replay or consensus. +func CreateQuorumCheckpointDraftFromCanonicalState( + db *store.DB, + activationHeight uint64, + height uint64, + blockHash crypto.Hash32, +) (*AVMCheckpoint, error) { + if db == nil { + return nil, fmt.Errorf("avm checkpoint: nil database") } - actualCount := 0 - if err := db.IterAVMState(func(key, value []byte) error { - actualCount++ - expectedValue, ok := expected[string(key)] - if !ok { - return fmt.Errorf("unexpected AVM state key %x", key) + if activationHeight == 0 || height < activationHeight { + return nil, fmt.Errorf("avm checkpoint: invalid activation or checkpoint height") + } + tipHash, tipHeight, err := db.GetTip() + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read canonical tip: %w", err) + } + if tipHeight != height || tipHash != blockHash { + return nil, fmt.Errorf("avm checkpoint: requested block is not the current canonical tip") + } + indexedHash, found, err := db.GetCanonicalHash(height) + if err != nil || !found || indexedHash != blockHash { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read canonical checkpoint index: %w", err) + } + return nil, fmt.Errorf("avm checkpoint: checkpoint block is not canonical") + } + genesisHash, found, err := db.GetCanonicalHash(0) + if err != nil || !found || genesisHash.Zero() { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read canonical genesis: %w", err) } - if !bytes.Equal(value, expectedValue) { - return fmt.Errorf("AVM state value mismatch for key %x", key) + return nil, fmt.Errorf("avm checkpoint: canonical genesis is missing") + } + markerHash, writeSetCommitment, markerFound, err := db.GetAVMCommitment(height) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read tip write-set marker: %w", err) + } + if !markerFound || markerHash != blockHash { + return nil, fmt.Errorf("avm checkpoint: canonical tip write-set marker is missing or mismatched") + } + entries := make([]CheckpointEntry, 0) + if err := db.IterAVMState(func(key, value []byte) error { + entries = append(entries, CheckpointEntry{Key: key, Value: value}) + if len(entries) > maxAVMCheckpointEntries { + return fmt.Errorf("avm checkpoint: too many state entries") } return nil }); err != nil { - return false, fmt.Errorf("avm startup: persisted state corruption: %w", err) + return nil, fmt.Errorf("avm checkpoint: read current AVM state: %w", err) } + tipHashAfterSnapshot, tipHeightAfterSnapshot, err := db.GetTip() + if err != nil { + return nil, fmt.Errorf("avm checkpoint: recheck canonical tip: %w", err) + } + if tipHeightAfterSnapshot != height || tipHashAfterSnapshot != blockHash { + return nil, fmt.Errorf("avm checkpoint: canonical tip changed while snapshotting state") + } + return NewQuorumCheckpointDraft( + activationHeight, height, genesisHash, blockHash, writeSetCommitment, entries, + ) +} - stateMissing := actualCount != len(expected) - if markerFound && !stateMissing { - return false, nil +// CreateCheckpointFromCanonicalState creates a legacy v1 operator-attested +// checkpoint from the already-validated current AVM state. +// +// Deprecated: quorum-configured nodes must use a v2 draft and independent +// witness signatures. +func CreateCheckpointFromCanonicalState( + db *store.DB, + activationHeight uint64, + height uint64, + blockHash crypto.Hash32, + signer CheckpointSigner, + authorities []crypto.ValidatorPubKey, +) (*AVMCheckpoint, error) { + if db == nil { + return nil, fmt.Errorf("avm checkpoint: nil database") + } + if activationHeight == 0 || height < activationHeight { + return nil, fmt.Errorf("avm checkpoint: invalid activation or checkpoint height") + } + tipHash, tipHeight, err := db.GetTip() + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read canonical tip: %w", err) + } + if tipHeight != height || tipHash != blockHash { + return nil, fmt.Errorf("avm checkpoint: requested block is not the current canonical tip") } - if err := replace(); err != nil { - return false, fmt.Errorf("avm startup: persist rebuilt state: %w", err) + indexedHash, found, err := db.GetCanonicalHash(height) + if err != nil || !found || indexedHash != blockHash { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read canonical checkpoint index: %w", err) + } + return nil, fmt.Errorf("avm checkpoint: checkpoint block is not canonical") + } + genesisHash, found, err := db.GetCanonicalHash(0) + if err != nil || !found || genesisHash.Zero() { + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read canonical genesis: %w", err) + } + return nil, fmt.Errorf("avm checkpoint: canonical genesis is missing") + } + markerHash, writeSetCommitment, markerFound, err := db.GetAVMCommitment(height) + if err != nil { + return nil, fmt.Errorf("avm checkpoint: read tip write-set marker: %w", err) + } + if !markerFound || markerHash != blockHash { + return nil, fmt.Errorf("avm checkpoint: canonical tip write-set marker is missing or mismatched") + } + entries := make([]CheckpointEntry, 0) + if err := db.IterAVMState(func(key, value []byte) error { + entries = append(entries, CheckpointEntry{Key: key, Value: value}) + if len(entries) > maxAVMCheckpointEntries { + return fmt.Errorf("avm checkpoint: too many state entries") + } + return nil + }); err != nil { + return nil, fmt.Errorf("avm checkpoint: read current AVM state: %w", err) } - return true, nil + return SignCheckpoint( + activationHeight, height, genesisHash, blockHash, writeSetCommitment, + entries, signer, authorities, + ) } diff --git a/avm/recovery_crash_test.go b/avm/recovery_crash_test.go new file mode 100644 index 0000000..f991692 --- /dev/null +++ b/avm/recovery_crash_test.go @@ -0,0 +1,185 @@ +package avm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/store" +) + +// This helper deliberately exits without Close or deferred cleanup. The parent +// must reopen LevelDB, rather than inspect state through the original handle. +func TestAVMCanonicalCommitCrashHelper(t *testing.T) { + mode := os.Getenv("APEROD_TEST_AVM_CRASH") + if mode == "" { + return + } + dir := os.Getenv("APEROD_TEST_AVM_DIR") + raw, err := os.ReadFile(filepath.Join(dir, "candidate.json")) + if err != nil { + t.Fatal(err) + } + var block core.Block + if err := json.Unmarshal(raw, &block); err != nil { + t.Fatal(err) + } + db, err := store.Open(filepath.Join(dir, "db")) + if err != nil { + t.Fatal(err) + } + prepared, err := NewBlockExecutor(LevelStore{DB: db}).PrepareBlock(context.Background(), &block) + if err != nil { + t.Fatal(err) + } + if mode == "before" { + os.Exit(73) + } + if mode == "legacy-incomplete" { + // Reproduce a legacy non-atomic write: canonical data is fsynced, + // but neither AVM effects nor their commitment marker exists. + if err := db.PutRawBlock(block.Hash(), block.Header.Height, raw); err != nil { + t.Fatal(err) + } + if err := db.PutTip(block.Hash(), block.Header.Height); err != nil { + t.Fatal(err) + } + os.Exit(73) + } + if mode != "after" { + t.Fatalf("unknown crash phase %q", mode) + } + if err := CommitCanonicalBlock(db, &block, raw, prepared); err != nil { + t.Fatal(err) + } + os.Exit(73) +} + +func TestAVMCanonicalCommitSurvivesProcessCrash(t *testing.T) { + for _, phase := range []string{"before", "after", "legacy-incomplete"} { + t.Run(phase, func(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "db") + db, err := store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + genesis := &core.Block{Header: core.BlockHeader{MerkleRoot: core.MerkleRoot(nil)}} + empty, err := NewBlockExecutor(NewMemoryStore()).PrepareBlock(context.Background(), genesis) + if err != nil { + t.Fatal(err) + } + rawGenesis, err := json.Marshal(genesis) + if err != nil { + t.Fatal(err) + } + if err := CommitCanonicalBlock(db, genesis, rawGenesis, empty); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + tx := signedAVMTransaction(t, core.AVMDeployContract, [32]byte{}, 0, stateWriteModule(false)) + block := &core.Block{ + Header: core.BlockHeader{Height: 1, PrevHash: genesis.Hash()}, + Txs: []core.Transaction{tx}, + } + block.Header.MerkleRoot = core.MerkleRoot(block.Txs) + prepared, err := NewBlockExecutor(NewMemoryStore()).PrepareBlock(context.Background(), block) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "candidate.json"), raw, 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAVMCanonicalCommitCrashHelper$") + cmd.Env = append(os.Environ(), "APEROD_TEST_AVM_CRASH="+phase, "APEROD_TEST_AVM_DIR="+dir) + output, runErr := cmd.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) || exitErr.ExitCode() != 73 || ctx.Err() != nil { + t.Fatalf("crash helper: %v, context: %v\n%s", runErr, ctx.Err(), output) + } + db, err = store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + tipHash, tipHeight, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + wantHash, wantHeight := genesis.Hash(), uint64(0) + if phase != "before" { + wantHash, wantHeight = block.Hash(), 1 + } + if tipHash != wantHash || tipHeight != wantHeight { + t.Fatalf("durable tip = %x/%d, want %x/%d", tipHash, tipHeight, wantHash, wantHeight) + } + body, err := db.GetRawBlock(block.Hash()) + if err != nil || (len(body) > 0) != (phase != "before") { + t.Fatalf("candidate body: bytes=%d err=%v", len(body), err) + } + markerHash, commitment, found, err := db.GetAVMCommitment(1) + if err != nil || found != (phase == "after") { + t.Fatalf("candidate marker: found=%v err=%v", found, err) + } + if found && (markerHash != block.Hash() || commitment != prepared.WriteSetCommitment) { + t.Fatal("durable marker differs from prepared effects") + } + for _, write := range prepared.Writes { + value, found, err := db.GetAVMState(write.Key) + if err != nil { + t.Fatal(err) + } + if phase != "after" { + if found { + t.Fatalf("uncommitted AVM write survived crash: %x", write.Key) + } + } else if !found || !bytes.Equal(value, write.Value) { + t.Fatalf("committed AVM write lost after crash: %x", write.Key) + } + } + rebuilt, err := EnsureCanonicalState(context.Background(), db, 1, tipHeight, tipHash) + if err != nil || rebuilt != (phase == "legacy-incomplete") { + t.Fatalf("startup after %s crash: rebuilt=%v err=%v", phase, rebuilt, err) + } + if phase == "legacy-incomplete" { + if err := db.Close(); err != nil { + t.Fatal(err) + } + reopened, err := store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + for _, write := range prepared.Writes { + value, found, err := reopened.GetAVMState(write.Key) + if err != nil || !found || !bytes.Equal(value, write.Value) { + t.Fatalf("replayed write not durable: %x found=%v err=%v", write.Key, found, err) + } + } + hash, commitment, found, err := reopened.GetAVMCommitment(1) + if err != nil || !found || hash != block.Hash() || commitment != prepared.WriteSetCommitment { + t.Fatalf("replayed marker not durable: found=%v err=%v", found, err) + } + rebuilt, err := EnsureCanonicalState(context.Background(), reopened, 1, tipHeight, tipHash) + if err != nil || rebuilt { + t.Fatalf("second restart: rebuilt=%v err=%v", rebuilt, err) + } + } + }) + } +} diff --git a/cmd/avm-checkpoint/main.go b/cmd/avm-checkpoint/main.go new file mode 100644 index 0000000..2a00056 --- /dev/null +++ b/cmd/avm-checkpoint/main.go @@ -0,0 +1,286 @@ +// Command avm-checkpoint builds, independently signs, or assembles a v2 AVM +// quorum checkpoint using canonical database copies and witness keys. +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/aperod/aperod/avm" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +type stringListFlag []string + +type witnessSignatureFile struct { + WitnessPublicKey string `json:"witness_public_key"` + Signature string `json:"signature"` +} + +func (s *stringListFlag) String() string { + return strings.Join(*s, ",") +} + +func (s *stringListFlag) Set(value string) error { + *s = append(*s, value) + return nil +} + +func main() { + mode := flag.String("mode", "build", "build, sign, or assemble") + dbPath := flag.String("db", "", "path to a canonical chain.db copy") + activationHeight := flag.Uint64("activation-height", 0, "configured AVM activation height") + candidatePath := flag.String("candidate", "", "unsigned v2 checkpoint draft") + keyPath := flag.String("key", "", "0600 witness private-key file (sign mode only)") + outputPath := flag.String("out", "", "output draft, partial signature, or final checkpoint") + threshold := flag.Int("threshold", 0, "required m-of-n witness signature count (assemble mode)") + var authorityKeys stringListFlag + var signatureFiles stringListFlag + flag.Var(&authorityKeys, "authority", "trusted witness public key in hex (repeat for each key)") + flag.Var(&signatureFiles, "signature", "detached witness signature file (repeat for each signer)") + flag.Parse() + var err error + switch *mode { + case "build": + if *dbPath == "" || *activationHeight == 0 || *outputPath == "" { + flag.Usage() + os.Exit(2) + } + err = runBuild(*dbPath, *activationHeight, *outputPath) + case "sign": + if *dbPath == "" || *activationHeight == 0 || *candidatePath == "" || + *keyPath == "" || *outputPath == "" { + flag.Usage() + os.Exit(2) + } + err = runSign(*dbPath, *activationHeight, *candidatePath, *keyPath, *outputPath) + case "assemble": + if *candidatePath == "" || *outputPath == "" || *threshold < 2 || + len(authorityKeys) < *threshold || len(signatureFiles) < *threshold { + flag.Usage() + os.Exit(2) + } + err = runAssemble(*candidatePath, signatureFiles, authorityKeys, *threshold, *outputPath) + default: + flag.Usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "avm-checkpoint:", err) + os.Exit(1) + } +} + +func runBuild(dbPath string, activationHeight uint64, outputPath string) error { + db, err := store.OpenReadOnly(dbPath) + if err != nil { + return fmt.Errorf("open chain database read-only: %w", err) + } + defer db.Close() + tipHash, tipHeight, err := db.GetTip() + if err != nil { + return fmt.Errorf("read canonical tip: %w", err) + } + draft, err := avm.BuildCheckpointDraftFromCanonical( + context.Background(), + db, + activationHeight, + tipHeight, + tipHash, + ) + if err != nil { + return err + } + if err := avm.WriteCheckpointFile(outputPath, draft); err != nil { + return fmt.Errorf("write unsigned checkpoint draft: %w", err) + } + fmt.Printf( + "unsigned quorum checkpoint draft written: height=%d block_hash=%x state_commitment=%x\n", + draft.Height, + draft.BlockHash, + draft.StateCommitment, + ) + return nil +} + +func runSign(dbPath string, activationHeight uint64, candidatePath, keyPath, outputPath string) error { + draft, found, err := avm.ReadCheckpointFile(candidatePath) + if err != nil { + return err + } + if !found { + return fmt.Errorf("checkpoint draft does not exist") + } + if err := avm.ValidateQuorumCheckpointDraft(draft, activationHeight); err != nil { + return err + } + + db, err := store.OpenReadOnly(dbPath) + if err != nil { + return fmt.Errorf("open independent chain database read-only: %w", err) + } + defer db.Close() + localDraft, err := avm.BuildCheckpointDraftFromCanonical( + context.Background(), db, activationHeight, draft.Height, draft.BlockHash, + ) + if err != nil { + return fmt.Errorf("independent canonical replay: %w", err) + } + if !avm.CheckpointPayloadEqual(draft, localDraft) { + return fmt.Errorf("candidate differs from this witness's canonical replay") + } + + privateKey, err := readWitnessKey(keyPath) + if err != nil { + return err + } + defer crypto.ZeroBytes(privateKey.Bytes()) + signature, err := avm.SignCheckpointDraft(draft, activationHeight, privateKey) + if err != nil { + return err + } + if err := writeWitnessSignature(outputPath, signature); err != nil { + return fmt.Errorf("write detached witness signature: %w", err) + } + fmt.Printf( + "independent witness signature written: height=%d witness_public_key=%s\n", + draft.Height, + signature.WitnessPublicKey.Hex(), + ) + return nil +} + +func runAssemble(candidatePath string, signaturePaths, authorityHex []string, threshold int, outputPath string) error { + draft, found, err := avm.ReadCheckpointFile(candidatePath) + if err != nil { + return err + } + if !found { + return fmt.Errorf("checkpoint draft does not exist") + } + if err := avm.ValidateQuorumCheckpointDraft(draft, draft.ActivationHeight); err != nil { + return err + } + authorities, err := avm.ParseCheckpointAuthorities(authorityHex) + if err != nil { + return err + } + signatures := make([]avm.CheckpointWitnessSignature, 0, len(signaturePaths)) + for _, path := range signaturePaths { + signature, err := readWitnessSignature(path) + if err != nil { + return err + } + signatures = append(signatures, signature) + } + checkpoint, err := avm.AttachCheckpointSignatures( + draft, signatures, draft.ActivationHeight, authorities, threshold, + ) + if err != nil { + return err + } + if err := avm.WriteCheckpointFile(outputPath, checkpoint); err != nil { + return fmt.Errorf("write quorum checkpoint: %w", err) + } + fmt.Printf( + "quorum checkpoint assembled: height=%d signatures=%d threshold=%d\n", + checkpoint.Height, + len(checkpoint.WitnessSignatures), + threshold, + ) + return nil +} + +func writeWitnessSignature(path string, signature avm.CheckpointWitnessSignature) error { + wire := witnessSignatureFile{ + WitnessPublicKey: hex.EncodeToString(signature.WitnessPublicKey), + Signature: hex.EncodeToString(signature.Signature), + } + data, err := json.Marshal(wire) + if err != nil { + return err + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return err + } + return nil +} + +func readWitnessSignature(path string) (avm.CheckpointWitnessSignature, error) { + file, err := os.Open(path) + if err != nil { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("open witness signature %q: %w", path, err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("stat witness signature %q: %w", path, err) + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 4096 { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("witness signature %q is not a bounded regular file", path) + } + decoder := json.NewDecoder(io.LimitReader(file, 4097)) + decoder.DisallowUnknownFields() + var wire witnessSignatureFile + if err := decoder.Decode(&wire); err != nil { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("decode witness signature %q: %w", path, err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("witness signature %q has trailing data", path) + } + publicBytes, err := hex.DecodeString(wire.WitnessPublicKey) + if err != nil { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("malformed witness public key in %q", path) + } + publicKey, err := crypto.ValidatorPubKeyFromBytes(publicBytes) + if err != nil { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("malformed witness public key in %q", path) + } + signatureBytes, err := hex.DecodeString(wire.Signature) + if err != nil || len(signatureBytes) != 64 { + return avm.CheckpointWitnessSignature{}, fmt.Errorf("malformed witness signature in %q", path) + } + return avm.CheckpointWitnessSignature{ + WitnessPublicKey: publicKey, + Signature: signatureBytes, + }, nil +} + +func readWitnessKey(keyPath string) (crypto.ValidatorPrivKey, error) { + keyFile, err := os.Open(keyPath) + if err != nil { + return nil, fmt.Errorf("open witness key: %w", err) + } + defer keyFile.Close() + info, err := keyFile.Stat() + if err != nil { + return nil, fmt.Errorf("stat witness key: %w", err) + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("witness key must be a regular file with no group/other permissions") + } + keyBytes, err := io.ReadAll(io.LimitReader(keyFile, 65)) + if err != nil { + crypto.ZeroBytes(keyBytes) + return nil, fmt.Errorf("read witness key: %w", err) + } + parsed, err := crypto.ValidatorPrivKeyFromBytes(keyBytes) + if err != nil { + crypto.ZeroBytes(keyBytes) + return nil, fmt.Errorf("parse witness key: %w", err) + } + // ValidatorPrivKeyFromBytes accepts a 64-byte key without copying it. + // Copy before erasing the input, then erase both working buffers on exit. + privateKeyBytes := append([]byte(nil), parsed.Bytes()...) + crypto.ZeroBytes(keyBytes) + crypto.ZeroBytes(parsed.Bytes()) + return crypto.ValidatorPrivKey(privateKeyBytes), nil +} diff --git a/cmd/avm-checkpoint/main_test.go b/cmd/avm-checkpoint/main_test.go new file mode 100644 index 0000000..cddeffe --- /dev/null +++ b/cmd/avm-checkpoint/main_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/aperod/aperod/avm" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func TestReadWitnessKeyCopiesBeforeZeroingInput(t *testing.T) { + privateKey, _, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "witness.key") + if err := os.WriteFile(path, privateKey.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + loaded, err := readWitnessKey(path) + if err != nil { + t.Fatalf("read witness key: %v", err) + } + defer crypto.ZeroBytes(loaded.Bytes()) + if !loaded.Public().Equals(privateKey.Public()) { + t.Fatal("loaded key changed after the source buffer was cleared") + } +} + +func TestReadWitnessKeyRejectsGroupReadableFile(t *testing.T) { + privateKey, _, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "witness.key") + if err := os.WriteFile(path, privateKey.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o640); err != nil { + t.Fatal(err) + } + if _, err := readWitnessKey(path); err == nil || !strings.Contains(err.Error(), "no group/other permissions") { + t.Fatalf("unsafe key file was accepted: %v", err) + } +} + +func TestRunAssembleWritesQuorumCheckpoint(t *testing.T) { + first, firstPublic, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + second, secondPublic, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + authorities := []crypto.ValidatorPubKey{firstPublic, secondPublic} + draft, err := avm.NewQuorumCheckpointDraft( + 1, + 10, + crypto.HashStr("genesis"), + crypto.HashStr("tip"), + crypto.HashStr("write-set"), + []avm.CheckpointEntry{{Key: []byte("contract/state"), Value: []byte("value")}}, + ) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + candidate := filepath.Join(dir, "candidate.json") + if err := avm.WriteCheckpointFile(candidate, draft); err != nil { + t.Fatal(err) + } + signatureFiles := make([]string, 0, 2) + for i, signer := range []crypto.ValidatorPrivKey{first, second} { + signature, err := avm.SignCheckpointDraft(draft, 1, signer) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "witness-"+strconv.Itoa(i+1)+".json") + if err := writeWitnessSignature(path, signature); err != nil { + t.Fatal(err) + } + signatureFiles = append(signatureFiles, path) + } + out := filepath.Join(dir, "assembled.json") + authorityHex := []string{authorities[0].Hex(), authorities[1].Hex()} + if err := runAssemble(candidate, signatureFiles, authorityHex, 2, out); err != nil { + t.Fatalf("assemble signatures: %v", err) + } + checkpoint, found, err := avm.ReadCheckpointFile(out) + if err != nil || !found { + t.Fatalf("read assembled checkpoint found=%v err=%v", found, err) + } + if err := avm.VerifyCheckpointQuorum(checkpoint, 1, authorities, 2); err != nil { + t.Fatalf("assembled checkpoint failed quorum validation: %v", err) + } +} + +func TestRunSignRequiresMatchingIndependentCanonicalReplay(t *testing.T) { + dbPath, err := makeCheckpointTestDB(t) + if err != nil { + t.Fatal(err) + } + witnessDBPath := copyDatabaseForWitness(t, dbPath) + dir := t.TempDir() + candidatePath := filepath.Join(dir, "draft.json") + if err := runBuild(dbPath, 1, candidatePath); err != nil { + t.Fatalf("build draft: %v", err) + } + witnessKey, _, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + keyPath := filepath.Join(dir, "witness.key") + if err := os.WriteFile(keyPath, witnessKey.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + signedPath := filepath.Join(dir, "partial.json") + if err := runSign(witnessDBPath, 1, candidatePath, keyPath, signedPath); err != nil { + t.Fatalf("sign independently replayed candidate: %v", err) + } + partial, err := readWitnessSignature(signedPath) + if err != nil || !partial.WitnessPublicKey.Equals(witnessKey.Public()) { + t.Fatalf("read partial signature=%+v err=%v", partial, err) + } + + draft, found, err := avm.ReadCheckpointFile(candidatePath) + if err != nil || !found { + t.Fatalf("read draft found=%v err=%v", found, err) + } + draft.BlockHash = crypto.HashStr("not canonical") + forgedPath := filepath.Join(dir, "forged.json") + if err := avm.WriteCheckpointFile(forgedPath, draft); err != nil { + t.Fatal(err) + } + if err := runSign(witnessDBPath, 1, forgedPath, keyPath, filepath.Join(dir, "forged-signature.json")); err == nil || + !strings.Contains(err.Error(), "not canonical") { + t.Fatalf("witness signed a draft not found in its own canonical chain: %v", err) + } +} + +func copyDatabaseForWitness(t *testing.T, source string) string { + t.Helper() + destination := t.TempDir() + err := filepath.WalkDir(source, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(destination, relative) + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unexpected non-regular database fixture file %q", path) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, info.Mode().Perm()) + }) + if err != nil { + t.Fatalf("copy database for witness: %v", err) + } + return destination +} + +func makeCheckpointTestDB(t *testing.T) (string, error) { + t.Helper() + dir := t.TempDir() + db, err := store.Open(dir) + if err != nil { + return "", err + } + defer db.Close() + producerKey, producerPublic, err := crypto.GenerateValidatorKey() + if err != nil { + return "", err + } + genesis := &core.Block{Header: core.BlockHeader{ + Height: 0, + Timestamp: 1, + MerkleRoot: core.MerkleRoot(nil), + }} + genesisRaw, err := json.Marshal(genesis) + if err != nil { + return "", err + } + if err := db.PutRawBlock(genesis.Hash(), 0, genesisRaw); err != nil { + return "", err + } + if err := db.PutTip(genesis.Hash(), 0); err != nil { + return "", err + } + block := &core.Block{Header: core.BlockHeader{ + Height: 1, + PrevHash: genesis.Hash(), + Timestamp: 2, + Round: 1, + ValidatorPub: producerPublic, + MerkleRoot: core.MerkleRoot(nil), + }} + if err := block.Header.Sign(producerKey); err != nil { + return "", err + } + prepared, err := avm.NewBlockExecutor(avm.LevelStore{DB: db}).PrepareBlock(context.Background(), block) + if err != nil { + return "", err + } + blockRaw, err := json.Marshal(block) + if err != nil { + return "", err + } + if err := avm.CommitCanonicalBlock(db, block, blockRaw, prepared); err != nil { + return "", err + } + return dir, nil +} diff --git a/cmd/avm-fee-audit/audit_test.go b/cmd/avm-fee-audit/audit_test.go index 71e89f5..c523630 100644 --- a/cmd/avm-fee-audit/audit_test.go +++ b/cmd/avm-fee-audit/audit_test.go @@ -364,14 +364,21 @@ func TestAuditRewardErasBoundsAVMWireExamples(t *testing.T) { recipient := crypto.AddressFromKeys(crypto.MainnetByte, recipientKeys) previous := crypto.HashBytes([]byte("bounded AVM examples parent")) txs := make([]core.Transaction, maxRewardEraAVMWireExamples+2) + var legacyTips uint64 for i := range txs { txs[i] = core.Transaction{ Version: core.TxVersionAVM, Inputs: []core.RingInput{{}}, AVM: &core.AVMPayload{Action: core.AVMExecuteContract, GasLimit: 100}, } + gasFee, feeErr := core.AVMGasFee(txs[i].AVM.GasLimit) + if feeErr != nil { + t.Fatal(feeErr) + } + txs[i].Fee = txs[i].MinFeeAt(core.InitialBaseFeePerByte) + gasFee + legacyTips += gasFee } - block := makeAuthorizedRewardBlock(t, 2, previous, validatorPriv, validatorPub, recipient, 10_000_000, txs) + block := makeAuthorizedRewardBlock(t, 2, previous, validatorPriv, validatorPub, recipient, 10_000_000+legacyTips, txs) var report rewardEraAuditReport if err := auditRewardEraBlock(&report, block, 2, 300_000_000, 10_000_000); err != nil { t.Fatal(err) @@ -686,6 +693,72 @@ func TestAuditFailsClosedOnLPoDPayoutBlock(t *testing.T) { } } +func TestAuditRewardErasReportsAVMFeesInLPoDSettlementSeparately(t *testing.T) { + leaderKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + leader := crypto.AddressFromKeys(crypto.MainnetByte, leaderKeys) + digest := crypto.HashBytes([]byte("lpod settlement checkpoint")) + payoutExtra, err := json.Marshal(core.LPoDPayoutAuthorization{ + Leader: leader, + Digest: digest, + }) + if err != nil { + t.Fatal(err) + } + gasLimit := uint64(1_000) + baseFee := core.InitialBaseFeePerByte + avmTx := core.Transaction{ + Version: core.TxVersionAVM, + Inputs: []core.RingInput{{}}, + AVM: &core.AVMPayload{GasLimit: gasLimit}, + } + gasFee, err := core.AVMGasFee(gasLimit) + if err != nil { + t.Fatal(err) + } + const explicitTip = uint64(37) + avmTx.Fee = avmTx.MinFeeAt(baseFee) + gasFee + explicitTip + block := &core.Block{ + Header: core.BlockHeader{Height: 2, BaseFee: baseFee}, + Txs: []core.Transaction{ + {Version: core.TxVersionLPoDPayout, Extra: payoutExtra}, + core.LPoDCheckpointTx(digest), + avmTx, + }, + } + + var report rewardEraAuditReport + if err := auditRewardEraBlock(&report, block, 2, 300_000_000, 10_000_000); err != nil { + t.Fatalf("audit LPoD settlement block: %v", err) + } + if report.LPoDSettlementBlocks != 1 || report.LPoDAVMTransactions != 1 { + t.Fatalf("LPoD settlement/AVM counts = %d/%d, want 1/1", + report.LPoDSettlementBlocks, report.LPoDAVMTransactions) + } + if report.LPoDFeeBearingBlocks != 1 || report.LPoDPriorityFeeSurplusNAPRO != gasFee+explicitTip { + t.Fatalf("LPoD fee-bearing/surplus = %d/%d, want 1/%d", + report.LPoDFeeBearingBlocks, report.LPoDPriorityFeeSurplusNAPRO, gasFee+explicitTip) + } + if report.PostCutoverFeeBearingBlocks != 0 || report.PostCutoverPriorityTipsNAPRO != 0 { + t.Fatalf("LPoD fees were incorrectly counted as validator reward tips: blocks=%d tips=%d", + report.PostCutoverFeeBearingBlocks, report.PostCutoverPriorityTipsNAPRO) + } + if report.LPoDRequiredAVMGasNAPRO != gasFee { + t.Fatalf("LPoD required AVM gas = %d, want %d", + report.LPoDRequiredAVMGasNAPRO, gasFee) + } + if report.LPoDAVMLegacySurplusNAPRO != gasFee+explicitTip { + t.Fatalf("legacy AVM surplus = %d, want required gas plus explicit tip %d", + report.LPoDAVMLegacySurplusNAPRO, gasFee+explicitTip) + } + if report.LPoDAVMTipsExcludingGasNAPRO != explicitTip { + t.Fatalf("AVM tips excluding gas = %d, want %d", + report.LPoDAVMTipsExcludingGasNAPRO, explicitTip) + } +} + func TestValidateCopyPathRejectsOutsideTmpAndSymlinkEscape(t *testing.T) { if _, err := validateCopyPath("/etc"); err == nil { t.Fatal("accepted a path outside /tmp") diff --git a/cmd/avm-fee-audit/main.go b/cmd/avm-fee-audit/main.go index d549b01..ae3557c 100644 --- a/cmd/avm-fee-audit/main.go +++ b/cmd/avm-fee-audit/main.go @@ -71,7 +71,15 @@ type rewardEraAuditReport struct { TotalLegacyFeeFloorNAPRO uint64 `json:"total_legacy_fee_floor_napro"` TotalPriorityTipsNAPRO uint64 `json:"total_priority_tips_napro"` TotalRequiredAVMGasNAPRO uint64 `json:"total_required_avm_gas_napro"` + LPoDSettlementBlocks uint64 `json:"lpod_settlement_blocks"` + LPoDFeeBearingBlocks uint64 `json:"lpod_fee_bearing_blocks"` + LPoDPriorityFeeSurplusNAPRO uint64 `json:"lpod_priority_fee_surplus_napro"` + LPoDAVMTransactions uint64 `json:"lpod_avm_transactions"` + LPoDRequiredAVMGasNAPRO uint64 `json:"lpod_required_avm_gas_napro"` + LPoDAVMLegacySurplusNAPRO uint64 `json:"lpod_avm_legacy_surplus_napro"` + LPoDAVMTipsExcludingGasNAPRO uint64 `json:"lpod_avm_tips_excluding_gas_napro"` FeeBearingExamples []rewardEraFeeExample `json:"fee_bearing_examples,omitempty"` + LPoDAVMFeeExamples []lpodAVMFeeExample `json:"lpod_avm_fee_examples,omitempty"` AVMWireExamples []avmWireExample `json:"avm_wire_examples,omitempty"` } @@ -91,6 +99,13 @@ type avmWireExample struct { CodeLength uint64 `json:"code_length"` } +type lpodAVMFeeExample struct { + Height uint64 `json:"height"` + RequiredGasNAPRO uint64 `json:"required_gas_napro"` + LegacySurplusNAPRO uint64 `json:"legacy_fee_surplus_napro"` + TipsExcludingGasNAPRO uint64 `json:"tips_excluding_gas_napro"` +} + func main() { if err := run(os.Args[1:], os.Stdout); err != nil { fmt.Fprintf(os.Stderr, "avm-fee-audit: %v\n", err) @@ -513,14 +528,40 @@ func auditRewardEraBlock( block *core.Block, cutoverHeight, preCutoverBase, postCutoverBase uint64, ) error { + lpodSettlement := false + var lpodDigest crypto.Hash32 for i := range block.Txs { if block.Txs[i].IsLPoDPayout() { - return fmt.Errorf("unsupported LPoD payout block; reward-era audit is pre-LPoD only") + if lpodSettlement || i != 0 { + return fmt.Errorf("malformed LPoD payout position") + } + auth, err := block.Txs[i].LPoDPayoutAuthorization() + if err != nil { + return fmt.Errorf("invalid LPoD payout authorization") + } + lpodSettlement = true + lpodDigest = auth.Digest + } + } + if lpodSettlement { + if len(block.Txs) < 2 || !block.Txs[1].IsLPoD() { + return fmt.Errorf("LPoD settlement is missing its checkpoint transaction") + } + digest, err := block.Txs[1].LPoDCheckpointDigest() + if err != nil || digest != lpodDigest { + return fmt.Errorf("LPoD settlement checkpoint does not match payout authorization") + } + for i := 2; i < len(block.Txs); i++ { + if block.Txs[i].IsCoinbase() && !block.Txs[i].IsStake() { + return fmt.Errorf("LPoD settlement block contains an additional coinbase reward") + } } } var blockPriorityTips uint64 var blockRequiredGas uint64 + var blockAVMLegacySurplus uint64 + var blockAVMTipsExcludingGas uint64 var authorizedAmount uint64 var avmTransactions uint64 var coinbaseRewards uint64 @@ -560,13 +601,10 @@ func auditRewardEraBlock( } continue } - if tx.IsStake() { + if tx.IsStake() || tx.IsLPoD() || tx.IsLPoDPosition() || tx.IsLPoDPayout() || tx.IsSale() { continue } - if err := add(&report.TotalTransactionFeesNAPRO, tx.Fee); err != nil { - return fmt.Errorf("total transaction fees overflow uint64") - } requiredBeforeGas, err := rewardEraMinimumFee(tx, block.Header.BaseFee) if err != nil { return fmt.Errorf("transaction %d minimum fee: %w", i, err) @@ -577,16 +615,38 @@ func auditRewardEraBlock( return fmt.Errorf("transaction %d legacy fee floor overflows uint64", i) } } + if tx.IsAVM() { + gasFee, gasErr := core.AVMGasFee(tx.AVM.GasLimit) + if gasErr != nil { + return fmt.Errorf("transaction %d AVM gas fee: %w", i, gasErr) + } + requiredWithGas, sumErr := checkedSum(requiredBeforeGas, gasFee) + if sumErr != nil || tx.Fee < requiredWithGas { + return fmt.Errorf("transaction %d fee does not cover its byte minimum, intentional burn, and mandatory AVM gas", i) + } + surplus := tx.Fee - requiredBeforeGas + if err := add(&blockAVMLegacySurplus, surplus); err != nil { + return fmt.Errorf("block AVM legacy surplus overflows uint64") + } + if err := add(&blockAVMTipsExcludingGas, surplus-gasFee); err != nil { + return fmt.Errorf("block AVM tips excluding gas overflow uint64") + } + } if err := add(&report.TotalLegacyFeeFloorNAPRO, requiredBeforeGas); err != nil { return fmt.Errorf("total legacy fee floor overflows uint64") } + if err := add(&report.TotalTransactionFeesNAPRO, tx.Fee); err != nil { + return fmt.Errorf("total transaction fees overflow uint64") + } if tx.Fee > requiredBeforeGas { tips := tx.Fee - requiredBeforeGas if err := add(&blockPriorityTips, tips); err != nil { return fmt.Errorf("block priority tips overflow uint64") } - if err := add(&report.TotalPriorityTipsNAPRO, tips); err != nil { - return fmt.Errorf("total priority tips overflow uint64") + if !lpodSettlement { + if err := add(&report.TotalPriorityTipsNAPRO, tips); err != nil { + return fmt.Errorf("total priority tips overflow uint64") + } } } } @@ -597,20 +657,57 @@ func auditRewardEraBlock( if err := add(&report.TotalRequiredAVMGasNAPRO, blockRequiredGas); err != nil { return fmt.Errorf("total required AVM gas overflows uint64") } - if coinbaseRewards != 1 || verifiedRewards != 1 { - return fmt.Errorf("block must have exactly one signature-verified authorized coinbase reward; found %d coinbase and %d valid authorization(s)", coinbaseRewards, verifiedRewards) - } - - expectedAmount := preCutoverBase - if block.Header.Height >= cutoverHeight { - var err error - expectedAmount, err = checkedSum(postCutoverBase, blockPriorityTips) - if err != nil { - return fmt.Errorf("expected post-cutover reward overflows uint64") + expectedAmount := uint64(0) + if lpodSettlement { + if coinbaseRewards != 0 { + return fmt.Errorf("LPoD settlement block contains an unexpected validator coinbase") + } + if err := add(&report.LPoDSettlementBlocks, 1); err != nil { + return fmt.Errorf("LPoD settlement block count overflows uint64") + } + if blockPriorityTips > 0 { + if err := add(&report.LPoDFeeBearingBlocks, 1); err != nil { + return fmt.Errorf("LPoD fee-bearing block count overflows uint64") + } + if err := add(&report.LPoDPriorityFeeSurplusNAPRO, blockPriorityTips); err != nil { + return fmt.Errorf("LPoD priority fee surplus overflows uint64") + } + } + if err := add(&report.LPoDAVMTransactions, avmTransactions); err != nil { + return fmt.Errorf("LPoD AVM transaction count overflows uint64") + } + if err := add(&report.LPoDRequiredAVMGasNAPRO, blockRequiredGas); err != nil { + return fmt.Errorf("LPoD required AVM gas overflows uint64") + } + if err := add(&report.LPoDAVMLegacySurplusNAPRO, blockAVMLegacySurplus); err != nil { + return fmt.Errorf("LPoD AVM legacy surplus overflows uint64") + } + if err := add(&report.LPoDAVMTipsExcludingGasNAPRO, blockAVMTipsExcludingGas); err != nil { + return fmt.Errorf("LPoD AVM tips excluding gas overflow uint64") + } + if avmTransactions > 0 && len(report.LPoDAVMFeeExamples) < maxRewardEraFeeExamples { + report.LPoDAVMFeeExamples = append(report.LPoDAVMFeeExamples, lpodAVMFeeExample{ + Height: block.Header.Height, + RequiredGasNAPRO: blockRequiredGas, + LegacySurplusNAPRO: blockAVMLegacySurplus, + TipsExcludingGasNAPRO: blockAVMTipsExcludingGas, + }) + } + } else { + if coinbaseRewards != 1 || verifiedRewards != 1 { + return fmt.Errorf("block must have exactly one signature-verified authorized coinbase reward; found %d coinbase and %d valid authorization(s)", coinbaseRewards, verifiedRewards) + } + expectedAmount = preCutoverBase + if block.Header.Height >= cutoverHeight { + var err error + expectedAmount, err = checkedSum(postCutoverBase, blockPriorityTips) + if err != nil { + return fmt.Errorf("expected post-cutover reward overflows uint64") + } + } + if authorizedAmount != expectedAmount { + return fmt.Errorf("authorized reward mismatch at height %d: got %d nAPRO; expected %d nAPRO", block.Header.Height, authorizedAmount, expectedAmount) } - } - if authorizedAmount != expectedAmount { - return fmt.Errorf("authorized reward mismatch at height %d: got %d nAPRO; expected %d nAPRO", block.Header.Height, authorizedAmount, expectedAmount) } if block.Header.Height < cutoverHeight { @@ -620,12 +717,14 @@ func auditRewardEraBlock( if err := add(&report.PreCutoverAVMTransactions, avmTransactions); err != nil { return fmt.Errorf("pre-cutover AVM transaction count overflows uint64") } - if err := add(&report.PreCutoverPriorityTipsNAPRO, blockPriorityTips); err != nil { - return fmt.Errorf("pre-cutover priority tips overflow uint64") - } - if blockPriorityTips > 0 { - if err := add(&report.PreCutoverFeeBearingBlocks, 1); err != nil { - return fmt.Errorf("pre-cutover fee-bearing block count overflows uint64") + if !lpodSettlement { + if err := add(&report.PreCutoverPriorityTipsNAPRO, blockPriorityTips); err != nil { + return fmt.Errorf("pre-cutover priority tips overflow uint64") + } + if blockPriorityTips > 0 { + if err := add(&report.PreCutoverFeeBearingBlocks, 1); err != nil { + return fmt.Errorf("pre-cutover fee-bearing block count overflows uint64") + } } } } else { @@ -635,20 +734,22 @@ func auditRewardEraBlock( if err := add(&report.PostCutoverAVMTransactions, avmTransactions); err != nil { return fmt.Errorf("post-cutover AVM transaction count overflows uint64") } - if err := add(&report.PostCutoverPriorityTipsNAPRO, blockPriorityTips); err != nil { - return fmt.Errorf("post-cutover priority tips overflow uint64") - } - if blockPriorityTips > 0 { - if err := add(&report.PostCutoverFeeBearingBlocks, 1); err != nil { - return fmt.Errorf("post-cutover fee-bearing block count overflows uint64") + if !lpodSettlement { + if err := add(&report.PostCutoverPriorityTipsNAPRO, blockPriorityTips); err != nil { + return fmt.Errorf("post-cutover priority tips overflow uint64") + } + if blockPriorityTips > 0 { + if err := add(&report.PostCutoverFeeBearingBlocks, 1); err != nil { + return fmt.Errorf("post-cutover fee-bearing block count overflows uint64") + } } } } - if blockPriorityTips > 0 { + if blockPriorityTips > 0 && !lpodSettlement { if err := add(&report.FeeBearingBlocks, 1); err != nil { return err } - if len(report.FeeBearingExamples) < maxRewardEraFeeExamples { + if !lpodSettlement && len(report.FeeBearingExamples) < maxRewardEraFeeExamples { report.FeeBearingExamples = append(report.FeeBearingExamples, rewardEraFeeExample{ Height: block.Header.Height, PriorityTipsNAPRO: blockPriorityTips, diff --git a/cmd/backup-verify/main.go b/cmd/backup-verify/main.go index 7939664..45767b8 100644 --- a/cmd/backup-verify/main.go +++ b/cmd/backup-verify/main.go @@ -290,7 +290,7 @@ func validHash(value string) bool { return false } for _, c := range value { - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } @@ -387,7 +387,7 @@ func verifyStage(stage string) (string, uint64, error) { return "", 0, errors.New("manifest tip_hash must be 64 lowercase hexadecimal characters") } for _, c := range m.tipHash { - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return "", 0, errors.New("manifest tip_hash must be 64 lowercase hexadecimal characters") } } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 8ef712b..ef96f6f 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -15,12 +15,13 @@ import ( "strings" "syscall" + "github.com/spf13/cobra" + "golang.org/x/term" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/store" "github.com/aperod/aperod/wallet" - "github.com/spf13/cobra" - "golang.org/x/term" ) // ─── JSON-RPC helper ────────────────────────────────────────────────────────── @@ -157,7 +158,7 @@ var walletCmd = &cobra.Command{ Short: "Wallet management commands", } -// wallet create — BIP39 mnemonic + HD derivation + encrypted keystore +// wallet create — BIP39 mnemonic + HD derivation + encrypted keystore. var walletCreateCmd = &cobra.Command{ Use: "create", Short: "Generate a new HD wallet with BIP39 mnemonic", @@ -249,7 +250,7 @@ var walletCreateCmd = &cobra.Command{ }, } -// wallet restore — restore from BIP39 mnemonic phrase +// wallet restore — restore from BIP39 mnemonic phrase. var walletRestoreCmd = &cobra.Command{ Use: "restore", Short: "Restore wallet from BIP39 mnemonic phrase", @@ -324,7 +325,7 @@ var walletRestoreCmd = &cobra.Command{ }, } -// wallet unlock — decrypt keystore and show keys +// wallet unlock — decrypt keystore and show keys. var walletUnlockCmd = &cobra.Command{ Use: "unlock [keystore-file]", Short: "Decrypt a keystore file and show wallet info", @@ -446,7 +447,7 @@ var walletValidateCmd = &cobra.Command{ }, } -// wallet keygen — generate a raw Ed25519 validator key pair (not an HD wallet) +// wallet keygen — generate a raw Ed25519 validator key pair (not an HD wallet). var walletKeygenCmd = &cobra.Command{ Use: "keygen", Short: "Generate a new raw Ed25519 validator key pair", @@ -462,7 +463,7 @@ var walletKeygenCmd = &cobra.Command{ }, } -// wallet pubkey — derive public key from a private key hex +// wallet pubkey — derive public key from a private key hex. var walletPubkeyCmd = &cobra.Command{ Use: "pubkey ", Short: "Derive the Ed25519 public key from a private key (32 or 64-byte hex)", @@ -718,7 +719,7 @@ var validatorApproveLPoDExitCmd = &cobra.Command{ c.Action, c.PubKey, c.Amount, c.CurrentStake, c.Genesis, c.Nonce, c.Generation, c.Expiry, c.PubKey[len(c.PubKey)-8:]) line, _ := bufio.NewReader(os.Stdin).ReadString('\n') if strings.TrimSpace(line) != "EXIT "+c.PubKey[len(c.PubKey)-8:] { - return fmt.Errorf("operator cancelled") + return fmt.Errorf("operator canceled") } auth := core.StakeWithdrawalAuthorizationV2{Action: action, PubKey: pub, Amount: amount, Genesis: genesis, Nonce: nonce, Generation: generation, ExpiryHeight: expiry, StakeRef: ref} sig, err := priv.Sign(core.StakeWithdrawalSignMsgV2(auth)) @@ -845,6 +846,13 @@ StakeSignMsgV2 locally, and broadcasts the 173-byte signed payload. Your private key is never sent to the node.`, RunE: func(cmd *cobra.Command, _ []string) error { nodeURL, _ := cmd.Flags().GetString("node") + apiKey, _ := cmd.Flags().GetString("api-key") + if apiKey == "" { + apiKey = os.Getenv("APEROD_API_KEY") + } + if apiKey == "" { + return fmt.Errorf("API key is required: set --api-key or APEROD_API_KEY") + } privKeyHex, _ := cmd.Flags().GetString("priv-key") utxoTxHashHex, _ := cmd.Flags().GetString("utxo-txhash") utxoIdx, _ := cmd.Flags().GetUint32("utxo-idx") @@ -1007,7 +1015,13 @@ Your private key is never sent to the node.`, fmt.Printf(" utxo_out_idx : %d\n", utxoIdx) fmt.Printf("Broadcasting to %s ...\n", broadcastURL) - httpResp, err := http.Post(broadcastURL, "application/json", strings.NewReader(broadcastPayload)) //nolint:noctx + broadcastReq, err := http.NewRequest(http.MethodPost, broadcastURL, strings.NewReader(broadcastPayload)) + if err != nil { + return fmt.Errorf("build stake broadcast request: %w", err) + } + broadcastReq.Header.Set("Content-Type", "application/json") + broadcastReq.Header.Set("X-API-Key", apiKey) + httpResp, err := http.DefaultClient.Do(broadcastReq) //nolint:noctx if err != nil { return fmt.Errorf("broadcast stake tx: %w", err) } @@ -1061,6 +1075,7 @@ func init() { // validator stake — local signing, UTXO pre-flight check, broadcast validatorStakeCmd.Flags().String("node", "http://localhost:8080", "Node REST URL") + validatorStakeCmd.Flags().String("api-key", "", "Node API key (prefer APEROD_API_KEY environment variable)") validatorStakeCmd.Flags().String("priv-key", "", "Validator private key (64-hex Ed25519) — never sent to the node") validatorStakeCmd.Flags().String("utxo-txhash", "", "64-hex hash of the UTXO transaction to burn as stake proof") validatorStakeCmd.Flags().Uint32("utxo-idx", 0, "Output index within the UTXO transaction (default 0)") diff --git a/cmd/cli/prune_warning_test.go b/cmd/cli/prune_warning_test.go index e6824bb..e8694b3 100644 --- a/cmd/cli/prune_warning_test.go +++ b/cmd/cli/prune_warning_test.go @@ -3,7 +3,7 @@ package main // CLI integration tests for the prune-window guard in the `validator stake` // command (step 3 of the stake flow). // -// The command now has two distinct behaviours depending on blocks_until_pruned: +// The command now has two distinct behaviors depending on blocks_until_pruned: // // 1. blocks_until_pruned < core.PartialUnbondingBlocks // → hard rejection: RunE returns a non-nil error containing the block count. @@ -35,6 +35,8 @@ import ( "github.com/aperod/aperod/crypto" ) +const stakeTestAPIKey = "test-stake-api-key" + // captureOutput redirects os.Stdout to a pipe for the duration of fn, // then returns everything written to stdout as a string. func captureOutput(fn func()) string { @@ -153,6 +155,9 @@ func resetStakeCmd() { if n := f.Lookup("priv-key"); n != nil { n.Value.Set(n.DefValue) //nolint:errcheck } + if n := f.Lookup("api-key"); n != nil { + n.Value.Set(n.DefValue) //nolint:errcheck + } if n := f.Lookup("utxo-txhash"); n != nil { n.Value.Set(n.DefValue) //nolint:errcheck } @@ -170,10 +175,11 @@ func runStakeCmd(t *testing.T, nodeURL, privKey, txHashHex string, amount float6 t.Helper() f := validatorStakeCmd.Flags() - f.Set("node", nodeURL) //nolint:errcheck - f.Set("priv-key", privKey) //nolint:errcheck - f.Set("utxo-txhash", txHashHex) //nolint:errcheck - f.Set("utxo-idx", "0") //nolint:errcheck + f.Set("node", nodeURL) //nolint:errcheck + f.Set("api-key", stakeTestAPIKey) //nolint:errcheck + f.Set("priv-key", privKey) //nolint:errcheck + f.Set("utxo-txhash", txHashHex) //nolint:errcheck + f.Set("utxo-idx", "0") //nolint:errcheck f.Set("amount", fmt.Sprintf("%g", amount)) //nolint:errcheck var runErr error diff --git a/cmd/cli/stake_lightnode_e2e_test.go b/cmd/cli/stake_lightnode_e2e_test.go index c3e20ff..fa37143 100644 --- a/cmd/cli/stake_lightnode_e2e_test.go +++ b/cmd/cli/stake_lightnode_e2e_test.go @@ -113,6 +113,7 @@ func buildLightNodeE2EServer(t *testing.T, tipHeight int, keepBlocks uint64) (*h // (lines annotated "lets stake endpoints detect pruned UTXOs" and // "enables blocks_until_pruned warning in restUTXO"). apiSrv := api.NewServer(":0", chain, mp, utxos, nopLog) + apiSrv.SetAPIKey(stakeTestAPIKey) apiSrv.SetPruningMode("light") apiSrv.SetKeepBlocks(keepBlocks) @@ -243,7 +244,7 @@ func TestStakeLightNodeE2E_ArchiveModeNoWarning(t *testing.T) { makeBlock := func(height uint64, prevHash crypto.Hash32) *core.Block { txs := []core.Transaction{core.CoinbaseTx(crypto.Point32(pub), 1_000_000)} hdr := core.BlockHeader{ - Height: height, PrevHash: prevHash, + Height: height, PrevHash: prevHash, Timestamp: time.Now().UnixNano(), ValidatorPub: pub, MerkleRoot: core.MerkleRoot(txs), } @@ -268,6 +269,7 @@ func TestStakeLightNodeE2E_ArchiveModeNoWarning(t *testing.T) { mp := core.NewMempool(core.DefaultMempoolConfig()) nopLog := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) apiSrv := api.NewServer(":0", chain, mp, utxos, nopLog) + apiSrv.SetAPIKey(stakeTestAPIKey) apiSrv.SetPruningMode("archive") // archive mode — field must never appear apiSrv.SetKeepBlocks(keepBlocks) diff --git a/cmd/explorer-indexer/main.go b/cmd/explorer-indexer/main.go index 98d030f..c7c81da 100644 --- a/cmd/explorer-indexer/main.go +++ b/cmd/explorer-indexer/main.go @@ -8,10 +8,11 @@ // NODE_API_URL — legacy override for the Go node REST base URL // POLL_INTERVAL — seconds between tip polls after catch-up (default: 5) // BATCH_SIZE — blocks per batch during initial catch-up (default: 50) +// INDEXER_REQUESTS_PER_SECOND — maximum serial node REST requests/sec (default: 50, max: 50) // BACKFILL_FROM_HEIGHT — optional initial catch-up start; invalid values fail // startup and this value is never used for steady-state polling // -// Startup behaviour: +// Startup behavior: // - Waits for the Go node to report ok:true and syncing:false before indexing. // This prevents indexing a partial window while the node replays its chain. // - Reads chain_stats.last_indexed_height to resume where it left off. @@ -27,6 +28,8 @@ import ( "net/http" "os" "strconv" + "strings" + "sync" "time" "github.com/aperod/aperod/explorer" @@ -73,6 +76,11 @@ func run() error { } } + requestsPerSecond, err := indexerRequestsPerSecondFromEnv() + if err != nil { + return err + } + backfillFrom, err := backfillFromHeightFromEnv() if err != nil { return err @@ -82,6 +90,7 @@ func run() error { "node_url", nodeURL, "poll_interval", pollInterval, "batch_size", batchSize, + "requests_per_second", requestsPerSecond, ) if backfillFrom != nil { log.Info("initial catch-up override enabled; steady-state resumes chain_stats checkpoint", @@ -96,7 +105,11 @@ func run() error { defer idx.Close() log.Info("connected to PostgreSQL") - client := &nodeClient{base: nodeURL, http: &http.Client{Timeout: 30 * time.Second}} + client := &nodeClient{ + base: nodeURL, + http: &http.Client{Timeout: 30 * time.Second}, + pacer: &requestPacer{interval: time.Second / time.Duration(requestsPerSecond)}, + } // ── Wait for Go node to be fully ready ──────────────────────────────────── // The node REST API is reachable during startup replay/sync, so we must @@ -156,6 +169,69 @@ func backfillFromHeightFromEnv() (*uint64, error) { return &height, nil } +const ( + defaultIndexerRequestsPerSecond = 50 + maxIndexerRequestsPerSecond = 50 + maxRetryAfter = 24 * time.Hour + minRateLimitRetryDelay = 250 * time.Millisecond + maxFallbackRetryDelay = 30 * time.Second +) + +func indexerRequestsPerSecondFromEnv() (int, error) { + value := os.Getenv("INDEXER_REQUESTS_PER_SECOND") + if value == "" { + return defaultIndexerRequestsPerSecond, nil + } + requestsPerSecond, err := strconv.Atoi(value) + if err != nil || requestsPerSecond < 1 || requestsPerSecond > maxIndexerRequestsPerSecond { + return 0, fmt.Errorf("INDEXER_REQUESTS_PER_SECOND must be an integer from 1 to %d", maxIndexerRequestsPerSecond) + } + return requestsPerSecond, nil +} + +type requestPacer struct { + mu sync.Mutex + interval time.Duration + next time.Time +} + +// wait spaces requests from this process. Holding the mutex while waiting also +// keeps the bound correct if a future caller adds concurrent requests. +func (p *requestPacer) wait() { + p.mu.Lock() + defer p.mu.Unlock() + if wait := time.Until(p.next); wait > 0 { + time.Sleep(wait) + } + p.next = time.Now().Add(p.interval) +} + +func retryAfterDuration(header string, now time.Time) (time.Duration, bool) { + header = strings.TrimSpace(header) + if header == "" { + return 0, false + } + if seconds, err := strconv.ParseInt(header, 10, 64); err == nil && seconds >= 0 { + delay := time.Duration(seconds) * time.Second + if delay < 0 || delay > maxRetryAfter { + delay = maxRetryAfter + } + return delay, true + } + retryAt, err := http.ParseTime(header) + if err != nil { + return 0, false + } + delay := retryAt.Sub(now) + if delay < 0 { + delay = 0 + } + if delay > maxRetryAfter { + delay = maxRetryAfter + } + return delay, true +} + func catchUp(idx *explorer.Indexer, client *nodeClient, batchSize uint64, initialStartOverride *uint64, log *slog.Logger) error { stats, err := idx.GetStats() if err != nil { @@ -294,8 +370,9 @@ func indexBlock(idx *explorer.Indexer, client *nodeClient, height uint64, log *s // ─── Node REST client ───────────────────────────────────────────────────────── type nodeClient struct { - base string - http *http.Client + base string + http *http.Client + pacer *requestPacer } // nodeStatusResponse maps GET /api/v1/status. @@ -360,13 +437,38 @@ type blocksListResponse struct { } func (c *nodeClient) get(url string) ([]byte, int, error) { - resp, err := c.http.Get(url) - if err != nil { - return nil, 0, err + fallbackDelay := time.Second + for { + if c.pacer != nil { + c.pacer.wait() + } + resp, err := c.http.Get(url) + if err != nil { + return nil, 0, err + } + if resp.StatusCode == http.StatusTooManyRequests { + delay, hasRetryAfter := retryAfterDuration(resp.Header.Get("Retry-After"), time.Now()) + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + _ = resp.Body.Close() + + if !hasRetryAfter { + delay = fallbackDelay + fallbackDelay *= 2 + if fallbackDelay > maxFallbackRetryDelay { + fallbackDelay = maxFallbackRetryDelay + } + } else if delay < minRateLimitRetryDelay { + delay = minRateLimitRetryDelay + } + slog.Warn("node REST rate limited; retrying without restarting catch-up", + "retry_after", delay, "url", url) + time.Sleep(delay) + continue + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return body, resp.StatusCode, err } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - return body, resp.StatusCode, err } func (c *nodeClient) isNodeReady() (bool, error) { diff --git a/cmd/explorer-indexer/main_test.go b/cmd/explorer-indexer/main_test.go index 046aed2..85d53d7 100644 --- a/cmd/explorer-indexer/main_test.go +++ b/cmd/explorer-indexer/main_test.go @@ -1,7 +1,11 @@ package main import ( + "net/http" + "net/http/httptest" + "sync/atomic" "testing" + "time" ) func TestBackfillFromHeightFromEnv(t *testing.T) { @@ -31,3 +35,117 @@ func TestBackfillFromHeightFromEnvRejectsInvalidValue(t *testing.T) { }) } } + +func TestIndexerRequestsPerSecondFromEnv(t *testing.T) { + t.Setenv("INDEXER_REQUESTS_PER_SECOND", "") + if got, err := indexerRequestsPerSecondFromEnv(); err != nil || got != defaultIndexerRequestsPerSecond { + t.Fatalf("default request rate = %d, %v; want %d, nil", got, err, defaultIndexerRequestsPerSecond) + } + t.Setenv("INDEXER_REQUESTS_PER_SECOND", "37") + if got, err := indexerRequestsPerSecondFromEnv(); err != nil || got != 37 { + t.Fatalf("configured request rate = %d, %v; want 37, nil", got, err) + } + for _, value := range []string{"0", "51", "-1", "fast"} { + t.Run(value, func(t *testing.T) { + t.Setenv("INDEXER_REQUESTS_PER_SECOND", value) + if _, err := indexerRequestsPerSecondFromEnv(); err == nil { + t.Fatalf("INDEXER_REQUESTS_PER_SECOND=%q unexpectedly accepted", value) + } + }) + } +} + +func TestRetryAfterDuration(t *testing.T) { + now := time.Date(2026, time.October, 16, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + header string + want time.Duration + ok bool + }{ + {header: "3", want: 3 * time.Second, ok: true}, + {header: "0", want: 0, ok: true}, + {header: now.Add(5 * time.Second).Format(http.TimeFormat), want: 5 * time.Second, ok: true}, + {header: "invalid", ok: false}, + {header: "", ok: false}, + } { + t.Run(tc.header, func(t *testing.T) { + got, ok := retryAfterDuration(tc.header, now) + if got != tc.want || ok != tc.ok { + t.Fatalf("retryAfterDuration(%q) = %s, %t; want %s, %t", + tc.header, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestNodeClientRetries429AndHonorsRetryAfter(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + w.Header().Set("Retry-After", "0") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + client := &nodeClient{base: server.URL, http: server.Client()} + start := time.Now() + body, status, err := client.get(server.URL) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("get after 429: %v", err) + } + if status != http.StatusOK || string(body) != "ok" { + t.Fatalf("get = %q, %d; want ok, 200", body, status) + } + if got := requests.Load(); got != 2 { + t.Fatalf("request count = %d, want 2", got) + } + if elapsed < minRateLimitRetryDelay { + t.Fatalf("retried after %s, want at least %s", elapsed, minRateLimitRetryDelay) + } +} + +func TestNodeClientRetries429WithoutRetryAfter(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) == 1 { + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + + client := &nodeClient{base: server.URL, http: server.Client()} + start := time.Now() + body, status, err := client.get(server.URL) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("get after headerless 429: %v", err) + } + if status != http.StatusOK || string(body) != "ok" { + t.Fatalf("get = %q, %d; want ok, 200", body, status) + } + if got := requests.Load(); got != 2 { + t.Fatalf("request count = %d, want 2", got) + } + if elapsed < time.Second { + t.Fatalf("retried after %s, want fallback backoff of at least 1s", elapsed) + } +} + +func TestRequestPacerSpacesRequests(t *testing.T) { + const interval = 20 * time.Millisecond + pacer := &requestPacer{interval: interval} + pacer.wait() + start := time.Now() + pacer.wait() + if elapsed := time.Since(start); elapsed < interval*9/10 { + t.Fatalf("second request started after %s, want at least %s", elapsed, interval*9/10) + } +} diff --git a/cmd/genesis-reserve/main.go b/cmd/genesis-reserve/main.go index e7c78a7..f5bd1a7 100644 --- a/cmd/genesis-reserve/main.go +++ b/cmd/genesis-reserve/main.go @@ -18,17 +18,31 @@ import ( func writeExclusive(path string, value interface{}) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { return err } + if err != nil { + return err + } ok := false - defer func() { f.Close(); if !ok { os.Remove(path) } }() - if err = json.NewEncoder(f).Encode(value); err != nil { return err } - if err = f.Sync(); err != nil { return err } + defer func() { + f.Close() + if !ok { + os.Remove(path) + } + }() + if err = json.NewEncoder(f).Encode(value); err != nil { + return err + } + if err = f.Sync(); err != nil { + return err + } ok = true return nil } func main() { - if err := run(); err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) } + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } } func run() error { @@ -45,84 +59,136 @@ func run() error { var offlineDB *store.DB password := os.Getenv("SESSION_SECRET") if *action == "init-wallet" || *action == "rekey-wallet" || *action == "normalize-wallet" { - if len(password) < 32 || *path == "" { return fmt.Errorf("protected keystore password/path unavailable") } + if len(password) < 32 || *path == "" { + return fmt.Errorf("protected keystore password/path unavailable") + } if *action == "init-wallet" { mnemonic, err := wallet.GenerateMnemonic(256) - if err != nil { return err } + if err != nil { + return err + } keys, err := wallet.DeriveFromMnemonic(mnemonic, "", 0, 0) - if err != nil { return err } + if err != nil { + return err + } address := crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys) keystore, err := wallet.EncryptMnemonicWithDerivation(mnemonic, password, string(address), 0, 0) - if err != nil { return err } - if err = writeExclusive(*path, keystore); err != nil { return err } + if err != nil { + return err + } + if err = writeExclusive(*path, keystore); err != nil { + return err + } return json.NewEncoder(os.Stdout).Encode(map[string]string{"address": string(address), "custody": "encrypted_operator_keystore"}) } raw, err := os.ReadFile(*path) - if err != nil { return fmt.Errorf("protected keystore unavailable") } + if err != nil { + return fmt.Errorf("protected keystore unavailable") + } var keystore wallet.KeystoreV1 - if err = json.Unmarshal(raw, &keystore); err != nil { return err } + if err = json.Unmarshal(raw, &keystore); err != nil { + return err + } mnemonic, err := wallet.DecryptMnemonic(&keystore, password) - if err != nil { return fmt.Errorf("protected keystore authentication failed") } + if err != nil { + return fmt.Errorf("protected keystore authentication failed") + } keys, err := wallet.DeriveFromMnemonic(mnemonic, "", 0, 0) if *action == "normalize-wallet" { if err != nil || *output == "" || (string(keys.Address) != keystore.Address && string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) != keystore.Address) { return fmt.Errorf("wallet normalization integrity check failed") } keystore.Address = string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) - if err := writeExclusive(*output, &keystore); err != nil { return err } + if err := writeExclusive(*output, &keystore); err != nil { + return err + } return json.NewEncoder(os.Stdout).Encode(map[string]string{"address": keystore.Address}) } - if err != nil || string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) != keystore.Address { return fmt.Errorf("keystore address integrity failed") } + if err != nil || string(crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys)) != keystore.Address { + return fmt.Errorf("keystore address integrity failed") + } newPassword := os.Getenv("TREASURY_EXPORT_PASSWORD") - if len(newPassword) < 12 { return fmt.Errorf("export password must have at least 12 characters") } + if len(newPassword) < 12 { + return fmt.Errorf("export password must have at least 12 characters") + } rekeyed, err := wallet.EncryptMnemonicWithDerivation(mnemonic, newPassword, keystore.Address, 0, 0) - if err != nil { return err } + if err != nil { + return err + } return json.NewEncoder(os.Stdout).Encode(rekeyed) } if *action == "attest" && *database != "" { db, err := store.Open(*database) - if err != nil { return err } + if err != nil { + return err + } offlineDB = db defer db.Close() _, tip, err := db.GetTip() - if err != nil { return err } + if err != nil { + return err + } cp, err := db.LoadLPoDCheckpoint() if err != nil || cp == nil || cp.Allocation == nil || cp.Allocation.Version != 3 || cp.GenesisVesting != nil || cp.State.LastHeight != tip { return fmt.Errorf("canonical v3 prerequisite absent or annual reserve already active") } - *height = tip+1 + *height = tip + 1 *genesis = hex.EncodeToString(cp.Allocation.Genesis[:]) *snapshot = hex.EncodeToString(cp.Allocation.SnapshotRoot[:]) } - if *action != "attest" || *height == 0 || *output == "" { return fmt.Errorf("explicit action/future activation/output required") } + if *action != "attest" || *height == 0 || *output == "" { + return fmt.Errorf("explicit action/future activation/output required") + } g, err := hex.DecodeString(*genesis) - if err != nil || len(g) != 32 { return fmt.Errorf("invalid genesis hash") } + if err != nil || len(g) != 32 { + return fmt.Errorf("invalid genesis hash") + } s, err := hex.DecodeString(*snapshot) - if err != nil || len(s) != 32 { return fmt.Errorf("invalid nominal snapshot root") } + if err != nil || len(s) != 32 { + return fmt.Errorf("invalid nominal snapshot root") + } info, err := os.Lstat(*signer) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { return fmt.Errorf("signing key must be a 0600 regular file") } + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + return fmt.Errorf("signing key must be a 0600 regular file") + } fd, err := syscall.Open(*signer, syscall.O_RDONLY|syscall.O_NOFOLLOW, 0) - if err != nil { return fmt.Errorf("cannot securely open signing key") } + if err != nil { + return fmt.Errorf("cannot securely open signing key") + } file := os.NewFile(uintptr(fd), *signer) defer file.Close() raw, err := io.ReadAll(io.LimitReader(file, 65)) - if err != nil || (len(raw) != 32 && len(raw) != 64) { return fmt.Errorf("invalid signing key length") } + if err != nil || (len(raw) != 32 && len(raw) != 64) { + return fmt.Errorf("invalid signing key length") + } defer crypto.ZeroBytes(raw) - if len(raw) == 32 { raw = ed25519.NewKeyFromSeed(raw); defer crypto.ZeroBytes(raw) } + if len(raw) == 32 { + raw = ed25519.NewKeyFromSeed(raw) + defer crypto.ZeroBytes(raw) + } key, err := crypto.ValidatorPrivKeyFromBytes(raw) - if err != nil { return err } + if err != nil { + return err + } proof := &store.GenesisVestingFunding{Version: 2, Height: *height, Recipient: store.GenesisVestingRecipient, Reserve: store.GenesisVestingReserve, - OtherAllocations: store.GenesisNominalTotal-store.GenesisVestingReserve-store.GenesisLiquidityAmount, - Trust: store.GenesisVestingTrust, LiquidityRecipient: crypto.Address(*address), + OtherAllocations: store.GenesisNominalTotal - store.GenesisVestingReserve - store.GenesisLiquidityAmount, + Trust: store.GenesisVestingTrust, LiquidityRecipient: crypto.Address(*address), LiquidityAmount: store.GenesisLiquidityAmount, LiquidityTrust: store.GenesisLiquidityTrust} - copy(proof.Genesis[:], g); copy(proof.Snapshot[:], s) - if _, _, _, err = crypto.DecodeAddress(proof.LiquidityRecipient); err != nil { return fmt.Errorf("invalid public treasury address") } + copy(proof.Genesis[:], g) + copy(proof.Snapshot[:], s) + if _, _, _, err = crypto.DecodeAddress(proof.LiquidityRecipient); err != nil { + return fmt.Errorf("invalid public treasury address") + } signature, err := key.Sign(proof.Message()) - if err != nil { return err } + if err != nil { + return err + } proof.Attestations = []store.LPoDAttestation{{Validator: key.Public(), Signature: signature}} if offlineDB != nil { - if err := offlineDB.CheckGenesisVestingConfig(*height, proof); err != nil { return err } + if err := offlineDB.CheckGenesisVestingConfig(*height, proof); err != nil { + return err + } } return writeExclusive(*output, proof) -} \ No newline at end of file +} diff --git a/cmd/lpod-archive-check/endpoint_evidence.go b/cmd/lpod-archive-check/endpoint_evidence.go new file mode 100644 index 0000000..8e61e8c --- /dev/null +++ b/cmd/lpod-archive-check/endpoint_evidence.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +const archiveConsistencyTrust = "internal consistency only; no historical committee anchor or quorum proof" + +// No committee-authority input exists in this checker. Counting valid votes +// cannot authenticate a self-selected committee, even if every key signs. +type endpointEvidence struct { + SignatureValidVoteKeys map[string]bool + CommitteeAuthenticated bool + QuorumAuthenticated bool + ProductionReady bool + Trust string +} + +func endpointCertificateEvidence(cert *store.FinalityCertificate) (endpointEvidence, error) { + result := endpointEvidence{Trust: archiveConsistencyTrust, SignatureValidVoteKeys: map[string]bool{}} + if cert == nil || cert.Version != 1 || cert.BlockHash == (crypto.Hash32{}) || len(cert.Votes) == 0 { + return result, fmt.Errorf("invalid or empty certificate") + } + message := crypto.HashBytes([]byte("aperod/finalize/v1"), cert.BlockHash.Bytes()) + for _, vote := range cert.Votes { + key := vote.Validator.Hex() + if len(vote.Validator) != 32 || result.SignatureValidVoteKeys[key] || !vote.Validator.Verify(message, vote.Signature) { + return result, fmt.Errorf("invalid or duplicate certificate vote") + } + result.SignatureValidVoteKeys[key] = true + } + return result, nil +} diff --git a/cmd/lpod-archive-check/endpoint_evidence_test.go b/cmd/lpod-archive-check/endpoint_evidence_test.go new file mode 100644 index 0000000..d8a301b --- /dev/null +++ b/cmd/lpod-archive-check/endpoint_evidence_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/sale" + "github.com/aperod/aperod/store" +) + +func TestEndpointSignaturesDoNotAuthenticateCommittee(t *testing.T) { + priv, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + blockHash := crypto.HashStr("synthetic-canonical-endpoint") + message := crypto.HashBytes([]byte("aperod/finalize/v1"), blockHash.Bytes()) + signature, err := priv.Sign(message) + if err != nil { + t.Fatal(err) + } + if !pub.Verify(message, signature) { + t.Fatal("positive endpoint signature control failed") + } + original := store.FinalityCertificate{Version: 1, Height: 9, BlockHash: blockHash, + Votes: []store.FinalityVote{{Validator: pub, Signature: signature}}} + // A real valid sale authorization by the very same key is still scoped to + // sale, not an independent source of historical consensus membership. + checkpoint := sale.Checkpoint{Genesis: crypto.HashStr("synthetic-genesis"), Network: crypto.DevnetByte, + Height: 10, Parent: blockHash, SnapshotRoot: crypto.HashStr("synthetic-sale"), SaleRemaining: 100, + SaleAuthority: pub} + saleSignature, err := priv.Sign(checkpoint.Message()) + if err != nil { + t.Fatal(err) + } + checkpoint.Attestations = []sale.Attestation{{Validator: pub, Signature: saleSignature}} + if err := checkpoint.Verify(checkpoint.Genesis, blockHash, 10, []crypto.ValidatorPubKey{pub}); err != nil { + t.Fatal(err) + } + migration := store.LPoDMigration{Version: 1, PositionLifecycleVersion: 1, Height: 10, + Genesis: checkpoint.Genesis, TrustedValidators: []crypto.ValidatorPubKey{pub}} + migration.ReconciliationRoot = migration.Root() + migrationSignature, err := priv.Sign(migration.AttestationMessage()) + if err != nil { + t.Fatal(err) + } + migration.Attestations = []store.LPoDAttestation{{Validator: pub, Signature: migrationSignature}} + if err := migration.VerifyAuthorization(); err != nil { + t.Fatal(err) + } + for _, variant := range []string{"self-signed-committee", "operator-snapshot", "sale-witness", "migration-witness", "all-claimed-members-sign"} { + t.Run(variant, func(t *testing.T) { + raw, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + document["committee_authenticated"] = true + document["quorum_authenticated"] = true + document["production_ready"] = true + document["trust"] = "authenticated" + document["committee"] = []crypto.ValidatorPubKey{pub} + document[variant] = checkpoint + if variant == "migration-witness" { + document[variant] = migration + } + raw, err = json.Marshal(document) + if err != nil { + t.Fatal(err) + } + var decoded store.FinalityCertificate + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if variant == "all-claimed-members-sign" { + for i := 0; i < 2; i++ { + signer, member, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + sig, err := signer.Sign(message) + if err != nil { + t.Fatal(err) + } + decoded.Votes = append(decoded.Votes, store.FinalityVote{Validator: member, Signature: sig}) + } + } + result, err := endpointCertificateEvidence(&decoded) + if err != nil { + t.Fatal(err) + } + if !result.SignatureValidVoteKeys[pub.Hex()] || len(result.SignatureValidVoteKeys) != len(decoded.Votes) || result.CommitteeAuthenticated || result.QuorumAuthenticated || result.ProductionReady || + result.Trust != archiveConsistencyTrust { + t.Fatalf("scoped/untrusted evidence promoted authority: %#v", result) + } + }) + } + t.Run("scoped-signature-is-not-endpoint-vote", func(t *testing.T) { + invalid := original + invalid.Votes = []store.FinalityVote{{Validator: pub, Signature: saleSignature}} + if _, err := endpointCertificateEvidence(&invalid); err == nil { + t.Fatal("sale signature accepted as finality vote") + } + }) + t.Run("duplicate-valid-vote", func(t *testing.T) { + duplicate := original + duplicate.Votes = append(duplicate.Votes, duplicate.Votes[0]) + if _, err := endpointCertificateEvidence(&duplicate); err == nil { + t.Fatal("duplicate vote accepted") + } + }) + for _, variant := range []string{"nil", "empty", "bad-key", "wrong-version", "wrong-message"} { + t.Run(variant, func(t *testing.T) { + value := original + value.Votes = append([]store.FinalityVote(nil), original.Votes...) + candidate := &value + switch variant { + case "nil": + candidate = nil + case "empty": + value.Votes = nil + case "bad-key": + value.Votes[0].Validator = crypto.ValidatorPubKey{1} + case "wrong-version": + value.Version = 2 + case "wrong-message": + value.BlockHash = crypto.HashStr("different-canonical-endpoint") + } + result, err := endpointCertificateEvidence(candidate) + if err == nil || result.CommitteeAuthenticated || result.QuorumAuthenticated || result.ProductionReady { + t.Fatalf("invalid certificate accepted: %#v %v", result, err) + } + }) + } +} diff --git a/cmd/lpod-archive-check/main.go b/cmd/lpod-archive-check/main.go index 1fc6425..539515b 100644 --- a/cmd/lpod-archive-check/main.go +++ b/cmd/lpod-archive-check/main.go @@ -10,10 +10,10 @@ import ( "path/filepath" "syscall" + "golang.org/x/sys/unix" + "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/store" - "golang.org/x/sys/unix" ) func main() { @@ -101,17 +101,9 @@ func main() { if cert.Height != *end || cert.BlockHash != previous { fail("certificate height %d must equal interval end %d", cert.Height, *end) } - seen := map[string]bool{} - message := crypto.HashBytes([]byte("aperod/finalize/v1"), cert.BlockHash.Bytes()) - for _, vote := range cert.Votes { - key := vote.Validator.Hex() - if seen[key] || !vote.Validator.Verify(message, vote.Signature) { - fail("invalid or duplicate certificate vote") - } - seen[key] = true - } - if len(seen) == 0 { - fail("empty certificate") + evidence, err := endpointCertificateEvidence(cert) + if err != nil { + fail("certificate: %v", err) } if err := syscall.Getrusage(syscall.RUSAGE_SELF, &after); err != nil { fail("resource accounting: %v", err) @@ -119,9 +111,12 @@ func main() { result := map[string]any{ "start": *start, "end": *end, "blocks": count, "transactions": txs, "certificate_height": cert.Height, "certificate_hash": fmt.Sprintf("%x", cert.BlockHash), - "signature_valid_vote_keys": seen, "committee_authenticated": false, - "trust": "internal consistency only; no historical committee anchor or quorum proof", - "input_block_operations": after.Inblock - before.Inblock, "peak_rss_kib": after.Maxrss, + "signature_valid_vote_keys": evidence.SignatureValidVoteKeys, + "committee_authenticated": evidence.CommitteeAuthenticated, + "quorum_authenticated": evidence.QuorumAuthenticated, + "production_ready": evidence.ProductionReady, + "trust": evidence.Trust, + "input_block_operations": after.Inblock - before.Inblock, "peak_rss_kib": after.Maxrss, "file_eviction_requested": *evict, } if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { @@ -132,4 +127,4 @@ func main() { func fail(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(1) -} \ No newline at end of file +} diff --git a/cmd/lpod-public-input-check/main.go b/cmd/lpod-public-input-check/main.go index f6c4224..ffda1bd 100644 --- a/cmd/lpod-public-input-check/main.go +++ b/cmd/lpod-public-input-check/main.go @@ -78,4 +78,4 @@ func readJSON(path string, target any) { func fail(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) os.Exit(1) -} \ No newline at end of file +} diff --git a/cmd/mint-record-audit/whole_opening.go b/cmd/mint-record-audit/whole_opening.go index 01c0cbc..c2a8e67 100644 --- a/cmd/mint-record-audit/whole_opening.go +++ b/cmd/mint-record-audit/whole_opening.go @@ -6,6 +6,7 @@ import ( "math" "filippo.io/edwards25519" + "github.com/aperod/aperod/crypto" ) diff --git a/cmd/node/backup_socket.go b/cmd/node/backup_socket.go index 54e1fac..a86d439 100644 --- a/cmd/node/backup_socket.go +++ b/cmd/node/backup_socket.go @@ -115,7 +115,7 @@ func removeStaleBackupSocket(path string, info os.FileInfo) error { return fmt.Errorf("backup socket: another node is listening at %s", path) } var netErr net.Error - if !errors.Is(err, syscall.ECONNREFUSED) && !(errors.As(err, &netErr) && netErr.Timeout()) { + if !errors.Is(err, syscall.ECONNREFUSED) && (!errors.As(err, &netErr) || !netErr.Timeout()) { return fmt.Errorf("backup socket: could not verify existing socket %s: %w", path, err) } current, err := os.Lstat(path) diff --git a/cmd/node/config_reload.go b/cmd/node/config_reload.go index 459f596..cbd5d08 100644 --- a/cmd/node/config_reload.go +++ b/cmd/node/config_reload.go @@ -9,9 +9,9 @@ package main // live would corrupt running subsystems. import ( - "log/slog" + "log/slog" - "github.com/aperod/aperod/config" + "github.com/aperod/aperod/config" ) // reloadScanCheckpointInterval re-reads the YAML config at cfgPath and, if @@ -26,18 +26,18 @@ import ( // On any parse or validation error the existing value is left unchanged and a // warning is logged so the operator knows the reload was a no-op. func reloadScanCheckpointInterval(cfgPath string, cfg *config.Config, log *slog.Logger) { - newCfg, err := config.Load(cfgPath) - if err != nil { - log.Warn("SIGHUP: config reload failed — keeping current ScanCheckpointInterval", - "err", err, - "current_interval", cfg.Snapshot.ScanCheckpointInterval, - ) - return - } - old := cfg.Snapshot.ScanCheckpointInterval - cfg.Snapshot.ScanCheckpointInterval = newCfg.Snapshot.ScanCheckpointInterval - log.Info("SIGHUP: ScanCheckpointInterval reloaded", - "old", old, - "new", cfg.Snapshot.ScanCheckpointInterval, - ) + newCfg, err := config.Load(cfgPath) + if err != nil { + log.Warn("SIGHUP: config reload failed — keeping current ScanCheckpointInterval", + "err", err, + "current_interval", cfg.Snapshot.ScanCheckpointInterval, + ) + return + } + old := cfg.Snapshot.ScanCheckpointInterval + cfg.Snapshot.ScanCheckpointInterval = newCfg.Snapshot.ScanCheckpointInterval + log.Info("SIGHUP: ScanCheckpointInterval reloaded", + "old", old, + "new", cfg.Snapshot.ScanCheckpointInterval, + ) } diff --git a/cmd/node/configurable_window_test.go b/cmd/node/configurable_window_test.go index b0427e9..30a8743 100644 --- a/cmd/node/configurable_window_test.go +++ b/cmd/node/configurable_window_test.go @@ -211,7 +211,7 @@ func TestCountMissingBlocksCustomWindow(t *testing.T) { // TestConfigDefaultMaxInMemoryBlocks confirms that DefaultConfig sets // MaxInMemoryBlocks to 1000 so omitting the field in node.yaml produces the -// same behaviour as the old compile-time constant. +// same behavior as the old compile-time constant. func TestConfigDefaultMaxInMemoryBlocks(t *testing.T) { cfg := config.DefaultConfig() if cfg.MaxInMemoryBlocks != 1000 { diff --git a/cmd/node/db_gap_auto_repair_test.go b/cmd/node/db_gap_auto_repair_test.go index 7fd6e8e..dfddee6 100644 --- a/cmd/node/db_gap_auto_repair_test.go +++ b/cmd/node/db_gap_auto_repair_test.go @@ -31,9 +31,10 @@ import ( "path/filepath" "testing" - "github.com/aperod/aperod/store" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" + + "github.com/aperod/aperod/store" ) // deleteHeightIndexEntry removes the h/ entry from LevelDB directly, @@ -84,8 +85,8 @@ func deleteHeightIndexEntry(t *testing.T, dbDir string, db *store.DB, height uin // re-verifies the repaired index. func TestAutoRepairFixesCrashKillGap(t *testing.T) { const ( - nBlocks = 9 // heights 0–9 - gapHeight = 5 // h/<5> will be deleted to simulate crash + nBlocks = 9 // heights 0–9 + gapHeight = 5 // h/<5> will be deleted to simulate crash ) tipHeight := uint64(nBlocks) diff --git a/cmd/node/db_gap_restart_test.go b/cmd/node/db_gap_restart_test.go index af460e2..1d601b2 100644 --- a/cmd/node/db_gap_restart_test.go +++ b/cmd/node/db_gap_restart_test.go @@ -446,7 +446,7 @@ func TestDBGapAtStartOfWindow(t *testing.T) { // where every block in the loadRecentBlocksFromStore window (startLoad..tipHeight) // is absent from the store — i.e. the entire in-memory window is a gap. // -// With the old `break` behaviour this returned 0 blocks and left chain.Height() +// With the old `break` behavior this returned 0 blocks and left chain.Height() // at genesis, causing the consensus engine to produce a duplicate block at // height 1 and silently fork away from the canonical chain. Even with the // `continue` fix, if every height in the window is missing the returned slice diff --git a/cmd/node/finality_migration.go b/cmd/node/finality_migration.go new file mode 100644 index 0000000..31336d0 --- /dev/null +++ b/cmd/node/finality_migration.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "sort" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +const maxFinalityMigrationFileBytes = 1 << 20 + +func loadFinalityCommitteeMigration(path string, authorityHex []string) (*store.FinalityCommitteeMigration, []crypto.ValidatorPubKey, error) { + if path == "" { + if len(authorityHex) != 0 { + return nil, nil, fmt.Errorf("finality migration authorities configured without a migration file") + } + return nil, nil, nil + } + if len(authorityHex) == 0 { + return nil, nil, fmt.Errorf("finality migration file requires explicit trust authorities") + } + if len(authorityHex) > 256 { + return nil, nil, fmt.Errorf("finality migration has too many trust authorities") + } + info, err := os.Stat(path) + if err != nil { + return nil, nil, fmt.Errorf("stat finality migration: %w", err) + } + if !info.Mode().IsRegular() || info.Size() > maxFinalityMigrationFileBytes { + return nil, nil, fmt.Errorf("finality migration must be a regular file no larger than %d bytes", + maxFinalityMigrationFileBytes) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("read finality migration: %w", err) + } + if len(raw) > maxFinalityMigrationFileBytes { + return nil, nil, fmt.Errorf("finality migration exceeds the %d-byte limit", maxFinalityMigrationFileBytes) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var migration store.FinalityCommitteeMigration + if err := decoder.Decode(&migration); err != nil { + return nil, nil, fmt.Errorf("decode finality migration: %w", err) + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, nil, fmt.Errorf("finality migration contains trailing data") + } + if len(migration.Approvals) == 0 { + return nil, nil, fmt.Errorf("finality migration has no validator approvals") + } + if len(migration.Approvals) > 256 { + return nil, nil, fmt.Errorf("finality migration has too many validator approvals") + } + if _, err := migration.SigningMessage(); err != nil { + return nil, nil, err + } + + authorities := make([]crypto.ValidatorPubKey, 0, len(authorityHex)) + seen := make(map[string]bool, len(authorityHex)) + for _, value := range authorityHex { + rawKey, err := hex.DecodeString(value) + if err != nil || len(rawKey) != 32 { + return nil, nil, fmt.Errorf("finality migration trust authority must be 32-byte hex") + } + key, err := crypto.ValidatorPubKeyFromBytes(rawKey) + if err != nil || isZeroFinalityMigrationAuthority(key) { + return nil, nil, fmt.Errorf("finality migration trust authority is invalid") + } + if seen[key.Hex()] { + return nil, nil, fmt.Errorf("duplicate finality migration trust authority") + } + seen[key.Hex()] = true + authorities = append(authorities, key) + } + sort.Slice(authorities, func(i, j int) bool { + return bytes.Compare(authorities[i], authorities[j]) < 0 + }) + return &migration, authorities, nil +} + +func isZeroFinalityMigrationAuthority(key crypto.ValidatorPubKey) bool { + if len(key) != 32 { + return false + } + for _, b := range key { + if b != 0 { + return false + } + } + return true +} diff --git a/cmd/node/finality_migration_test.go b/cmd/node/finality_migration_test.go new file mode 100644 index 0000000..7c4a785 --- /dev/null +++ b/cmd/node/finality_migration_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func finalityMigrationFixture(t *testing.T) ([]byte, []string) { + t.Helper() + _, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + migration := store.FinalityCommitteeMigration{ + Version: store.FinalityCommitteeMigrationVersion, + Genesis: crypto.Hash32{1}, + CertificateHeight: 10, + CertificateBlockHash: crypto.Hash32{2}, + ApprovalHeight: 11, + ApprovalBlockHash: crypto.Hash32{3}, + BFTThresholdPPM: 667_000, + HistoricalCommittee: []crypto.ValidatorPubKey{pub}, + Approvals: []store.FinalityMigrationApproval{{ + Validator: pub, + Signature: make([]byte, 64), + }}, + } + raw, err := json.Marshal(migration) + if err != nil { + t.Fatal(err) + } + return raw, []string{pub.Hex()} +} + +func TestLoadFinalityCommitteeMigrationRequiresFileAndTrustPinsTogether(t *testing.T) { + if migration, authorities, err := loadFinalityCommitteeMigration("", nil); err != nil || + migration != nil || authorities != nil { + t.Fatalf("disabled migration returned migration=%v authorities=%v err=%v", + migration, authorities, err) + } + if _, _, err := loadFinalityCommitteeMigration("", []string{"01"}); err == nil { + t.Fatal("trust authorities without a migration file were accepted") + } + if _, _, err := loadFinalityCommitteeMigration(filepath.Join(t.TempDir(), "missing.json"), nil); err == nil { + t.Fatal("migration file without trust authorities was accepted") + } +} + +func TestLoadFinalityCommitteeMigrationValidatesAndLoads(t *testing.T) { + raw, trust := finalityMigrationFixture(t) + path := filepath.Join(t.TempDir(), "migration.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + migration, authorities, err := loadFinalityCommitteeMigration(path, trust) + if err != nil { + t.Fatal(err) + } + if migration == nil || len(migration.Approvals) != 1 || len(authorities) != 1 || + !authorities[0].Equals(migration.Approvals[0].Validator) { + t.Fatalf("unexpected loaded migration or trust authorities: migration=%+v authorities=%v", + migration, authorities) + } +} + +func TestLoadFinalityCommitteeMigrationRejectsMalformedAndOversizedFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "migration.json") + raw, trust := finalityMigrationFixture(t) + for name, contents := range map[string][]byte{ + "unknown field": []byte(`{"version":1,"unexpected":true}`), + "trailing value": append(append([]byte(nil), raw...), []byte(` {}`)...), + "oversized": make([]byte, maxFinalityMigrationFileBytes+1), + } { + t.Run(name, func(t *testing.T) { + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := loadFinalityCommitteeMigration(path, trust); err == nil { + t.Fatal("malformed migration file was accepted") + } + }) + } +} + +func TestLoadFinalityCommitteeMigrationRejectsDuplicateTrustPins(t *testing.T) { + raw, trust := finalityMigrationFixture(t) + path := filepath.Join(t.TempDir(), "migration.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := loadFinalityCommitteeMigration(path, []string{trust[0], trust[0]}); err == nil { + t.Fatal("duplicate trust authority was accepted") + } +} diff --git a/cmd/node/finality_vote_test.go b/cmd/node/finality_vote_test.go index f37ae15..92e70ae 100644 --- a/cmd/node/finality_vote_test.go +++ b/cmd/node/finality_vote_test.go @@ -35,4 +35,4 @@ func TestFinalityMsgFromP2PRejectsMalformedValidatorKey(t *testing.T) { t.Fatalf("accepted validator public key of length %d", len(pub)) } } -} \ No newline at end of file +} diff --git a/cmd/node/gomemlimit_check_test.go b/cmd/node/gomemlimit_check_test.go index ffa316a..a28cdad 100644 --- a/cmd/node/gomemlimit_check_test.go +++ b/cmd/node/gomemlimit_check_test.go @@ -3,7 +3,7 @@ package main // gomemlimit_check_test.go — coverage for checkGOMLEMLIMIT's startup memory-limit // guard (queue task #1710). // -// The behaviour under test: when neither the GOMEMLIMIT environment variable nor +// The behavior under test: when neither the GOMEMLIMIT environment variable nor // node.yaml's memory_limit_bytes is set, the node warns operators that it may OOM // under load. Operators who intentionally run uncapped can set // memory_limit_disabled: true in node.yaml to silence the per-startup WARN; diff --git a/cmd/node/main.go b/cmd/node/main.go index eb823c4..6a771d9 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -792,27 +792,32 @@ func checkStartupIntegrity( } func run() error { -// Preflight must never dispatch a config-free mutating maintenance mode. -preflightRequested := false -preflightCapability := false -for _, arg := range os.Args[1:] { -if arg == "--sale-upgrade-preflight" { preflightRequested = true } -if arg == "--sale-upgrade-preflight-capability" { preflightRequested = true; preflightCapability = true } -} -if preflightRequested { -for _, arg := range os.Args[1:] { -switch arg { -case "--check-store", "--check-snapshot", "--compact-db", "--repair-height-index": -return fmt.Errorf("sale preflight cannot be combined with other maintenance modes") -} -} -} -if preflightCapability { -// --validate-config accompanies this probe so an older binary which ignores -// unknown flags can only validate configuration, never start a live node. -fmt.Fprintln(os.Stdout, `{"schema":1,"command":"sale-upgrade-preflight"}`) -return nil -} + // Preflight must never dispatch a config-free mutating maintenance mode. + preflightRequested := false + preflightCapability := false + for _, arg := range os.Args[1:] { + if arg == "--sale-upgrade-preflight" { + preflightRequested = true + } + if arg == "--sale-upgrade-preflight-capability" { + preflightRequested = true + preflightCapability = true + } + } + if preflightRequested { + for _, arg := range os.Args[1:] { + switch arg { + case "--check-store", "--check-snapshot", "--compact-db", "--repair-height-index": + return fmt.Errorf("sale preflight cannot be combined with other maintenance modes") + } + } + } + if preflightCapability { + // --validate-config accompanies this probe so an older binary which ignores + // unknown flags can only validate configuration, never start a live node. + fmt.Fprintln(os.Stdout, `{"schema":1,"command":"sale-upgrade-preflight"}`) + return nil + } // ── 0. config-free maintenance subcommands ──────────────────────────────── // Processed before config loading so operators can verify a chain.db that // was just rsync'd without needing a fully-configured node.yaml. @@ -834,8 +839,8 @@ return nil configFlagExplicit := false resetP2PIdentity := false validateOnly := false -saleUpgradePreflight := false -saleUpgradeCopy := "" + saleUpgradePreflight := false + saleUpgradeCopy := "" strictMemLimit := false resetTip := false repairDB := false @@ -853,11 +858,13 @@ saleUpgradeCopy := "" resetP2PIdentity = true case "--validate-config": validateOnly = true -case "--sale-upgrade-preflight": -saleUpgradePreflight = true -case "--sale-upgrade-copy": -if i+2 >= len(os.Args) { return fmt.Errorf("--sale-upgrade-copy requires a completed backup directory") } -saleUpgradeCopy = os.Args[i+2] + case "--sale-upgrade-preflight": + saleUpgradePreflight = true + case "--sale-upgrade-copy": + if i+2 >= len(os.Args) { + return fmt.Errorf("--sale-upgrade-copy requires a completed backup directory") + } + saleUpgradeCopy = os.Args[i+2] case "--strict-memlimit": strictMemLimit = true case "--reset-tip": @@ -925,15 +932,19 @@ saleUpgradeCopy = os.Args[i+2] if err != nil { return fmt.Errorf("load config: %w", err) } -// Read-only inspection does not activate any consensus configuration. -if saleUpgradePreflight { -if !configFlagExplicit || cfg.DataDir == "" { return fmt.Errorf("sale preflight requires explicit --config and data_dir") } -if validateOnly || resetTip || repairDB || rebuildKeyImages || forcePurgeKIIndex || rebuildSpentIndex || resetP2PIdentity { -return fmt.Errorf("sale preflight cannot be combined with mutation or validation shortcuts") -} -return runSaleUpgradePreflight(cfg, saleUpgradeCopy, os.Stdout) -} -if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sale-upgrade-preflight") } + // Read-only inspection does not activate any consensus configuration. + if saleUpgradePreflight { + if !configFlagExplicit || cfg.DataDir == "" { + return fmt.Errorf("sale preflight requires explicit --config and data_dir") + } + if validateOnly || resetTip || repairDB || rebuildKeyImages || forcePurgeKIIndex || rebuildSpentIndex || resetP2PIdentity { + return fmt.Errorf("sale preflight cannot be combined with mutation or validation shortcuts") + } + return runSaleUpgradePreflight(cfg, saleUpgradeCopy, os.Stdout) + } + if saleUpgradeCopy != "" { + return fmt.Errorf("--sale-upgrade-copy requires --sale-upgrade-preflight") + } if err := guardGuardianFundActivation(cfg); err != nil { return err } @@ -953,7 +964,7 @@ if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sal absConfig, absDataDir) } -// --validate-config: exit 0 after a successful parse+validate so + // --validate-config: exit 0 after a successful parse+validate so // operators (and node-config.sh) can verify node.yaml without starting // the node. Prints the config path and network name so the caller can // confirm which file was checked. @@ -975,7 +986,7 @@ if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sal return nil } -// ── 2. Setup logger ─────────────────────────────────────────────────────── + // ── 2. Setup logger ─────────────────────────────────────────────────────── level := slog.LevelInfo switch cfg.LogLevel { case "debug": @@ -1166,6 +1177,27 @@ if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sal if err != nil { return fmt.Errorf("validator key: %w", err) } + var ( + avmCheckpointPath string + avmCheckpointAuthorities []crypto.ValidatorPubKey + avmCheckpointThreshold int + ) + if len(cfg.Consensus.AVMCheckpointTrustAuthorities) > 0 { + avmCheckpointAuthorities, err = avm.ParseCheckpointAuthorities( + cfg.Consensus.AVMCheckpointTrustAuthorities, + ) + if err != nil { + return err + } + avmCheckpointPath = cfg.Consensus.AVMCheckpointFile + if avmCheckpointPath == "" { + avmCheckpointPath = "avm-state.checkpoint.json" + } + if !filepath.IsAbs(avmCheckpointPath) { + avmCheckpointPath = filepath.Join(cfg.DataDir, avmCheckpointPath) + } + avmCheckpointThreshold = cfg.Consensus.AVMCheckpointWitnessThreshold + } // ── 5. Load genesis config ──────────────────────────────────────────────── genesisConfig, err := core.LoadGenesis(cfg.Genesis.File) @@ -1178,7 +1210,7 @@ if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sal // // Non-validator mode: use the public keys declared in the genesis config. // This ensures handleIncomingBlock's isKnownValidator() and proposer-slot - // checks recognise the real network validators so the node can sync. + // checks recognize the real network validators so the node can sync. // Our own (P2P-identity) key must NOT be in this list or it would be // treated as a genesis validator with stake, which could cause it to win // a proposer slot even though MyKey is nil in the engine. @@ -1328,7 +1360,7 @@ if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sal // next startup loads key images from k/ when no snapshot is found. repairTipHashHex := fmt.Sprintf("%x", tipHash[:]) if existingSnap, _, snapLoadErr := tryLoadStartupSnapshot(cfg.DataDir, tipHeight, repairTipHashHex, log); snapLoadErr == nil { - existingSnap.UTXOs.KeyImages = utxos.TakeSnapshot().KeyImages + existingSnap.UTXOs.KeyImages = utxos.SnapshotKeyImages() existingSnap.SavedAt = time.Now() if snapSaveErr := saveStartupSnapshot(cfg.DataDir, *existingSnap); snapSaveErr != nil { log.Warn("--repair-db: failed to update snapshot with rebuilt key images", @@ -1349,9 +1381,18 @@ if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sal // Declared here so both the genesis and resume branches can assign it. // The genesis path leaves it nil; NewEngine creates a fresh registry. - // The resume path initialises it inside the startup scan below. + // The resume path initializes it inside the startup scan below. var registry *core.ValidatorRegistry var lpodMigration *store.LPoDMigration + finalityMigrationPath := cfg.Consensus.FinalityMigrationFile + if finalityMigrationPath != "" && !filepath.IsAbs(finalityMigrationPath) { + finalityMigrationPath = filepath.Join(cfg.DataDir, finalityMigrationPath) + } + finalityMigration, finalityMigrationTrustAuthorities, err := loadFinalityCommitteeMigration( + finalityMigrationPath, cfg.Consensus.FinalityMigrationTrustAuthorities) + if err != nil { + return fmt.Errorf("load finality committee migration: %w", err) + } saleMigration, err := db.LoadSaleMigrationFile(cfg.Consensus.SaleMigrationFile) if err != nil { log.Error("protocol sale startup validation failed", "err", err) @@ -1373,9 +1414,9 @@ if saleUpgradeCopy != "" { return fmt.Errorf("--sale-upgrade-copy requires --sal var apiSrv *api.Server if cfg.API.Enabled && cfg.API.ListenAddr != "" { apiSrv = api.NewServer(cfg.API.ListenAddr, chain, mempool, utxos, log) -apiSrv.SetStakedCollateralRingExclusionActivationHeight( - cfg.Consensus.StakedCollateralRingExclusionActivationHeight, -) + apiSrv.SetStakedCollateralRingExclusionActivationHeight( + cfg.Consensus.StakedCollateralRingExclusionActivationHeight, + ) apiSrv.SetAllowedOrigins(cfg.API.CORS) apiSrv.SetNodeViewKey(cfg.Consensus.ViewKey) apiSrv.SetStore(db) @@ -1527,10 +1568,26 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( // consensus processing begins. A missing marker or missing state is // deterministically rebuilt from canonical blocks; conflicting data is // corruption and must fail closed. - avmRebuilt, avmErr := avm.EnsureCanonicalState( - context.Background(), db, cfg.Consensus.AVMActivationHeight, - tipHeight, tipHash, - ) + var avmRebuilt bool + var avmErr error + if len(avmCheckpointAuthorities) > 0 { + avmRebuilt, avmErr = avm.EnsureCanonicalStateWithQuorumCheckpoint( + context.Background(), + db, + cfg.Consensus.AVMActivationHeight, + tipHeight, + tipHash, + avmCheckpointPath, + avmCheckpointAuthorities, + avmCheckpointThreshold, + avm.MaxAVMCheckpointReplay, + ) + } else { + avmRebuilt, avmErr = avm.EnsureCanonicalState( + context.Background(), db, cfg.Consensus.AVMActivationHeight, + tipHeight, tipHash, + ) + } if avmErr != nil { return fmt.Errorf("startup AVM integrity: %w", avmErr) } @@ -1856,6 +1913,11 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( return err } } + if cfg.Consensus.StakeKeyImageV4ActivationHeight != 0 { + if err := registry.ConfigureStakeKeyImageV4(cfg.Consensus.StakeKeyImageV4ActivationHeight); err != nil { + return err + } + } // InitFromGenesis is idempotent (!exists guard), so NewEngine's later if authorityFork != nil { if err := registry.ConfigureSignatureAuthority(authorityFork, tipHeight+1); err != nil { @@ -1935,7 +1997,7 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( if utxoCountOK { utxos.RestoreFromSnapshot(snap.UTXOs) registry.RestoreFromSnapshot(snap.Registry) - registry.SetUTXOSet(utxos) // re-wire pointer (not serialised) + registry.SetUTXOSet(utxos) // re-wire pointer (not serialized) if snap.TxTotal > 0 { initialTxTotal = snap.TxTotal } @@ -1998,7 +2060,7 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( TipHashHex: snap.TipHashHex, TxTotal: txTotRec, SavedAt: time.Now(), - UTXOs: utxos.TakeSnapshot(), + UTXOs: utxos.TakeSnapshotView(), Registry: registry.TakeSnapshot(), } if saveErr := saveStartupSnapshot(cfg.DataDir, recSnap); saveErr != nil { @@ -2048,7 +2110,7 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( TipHashHex: snap.TipHashHex, TxTotal: oomTxTot, SavedAt: time.Now(), - UTXOs: utxos.TakeSnapshot(), + UTXOs: utxos.TakeSnapshotView(), Registry: registry.TakeSnapshot(), } if saveErr := saveStartupSnapshot(cfg.DataDir, oomSnap); saveErr != nil { @@ -2105,7 +2167,7 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( TipHashHex: snap.TipHashHex, TxTotal: txTotKI, SavedAt: time.Now(), - UTXOs: utxos.TakeSnapshot(), + UTXOs: utxos.TakeSnapshotView(), Registry: registry.TakeSnapshot(), } if saveErr := saveStartupSnapshot(cfg.DataDir, kiFixSnap); saveErr != nil { @@ -2567,13 +2629,13 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( // engine is pre-declared with var so that the OnBlockProduced closure // can reference it (e.g. engine.Registry()) via a captured pointer. - // By the time any block is produced, engine is fully initialised. + // By the time any block is produced, engine is fully initialized. var engine *consensus.Engine // gcInFlight guards the periodic CompactKeyImages+GC+FreeOSMemory // goroutine so at most one is active at any time. Without this guard, // rapid block acceptance (catch-up sync or multi-block bursts) can - // queue an unbounded backlog of goroutines that each serialise on the + // queue an unbounded backlog of goroutines that each serialize on the // UTXOSet write lock, then each force a stop-the-world GC, worsening // exactly the performance problem this maintenance is meant to solve. // Both the periodic-GC path and the post-snapshot cleanup path use @@ -2656,6 +2718,11 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( registry = core.NewValidatorRegistry() registry.SetUTXOSet(utxos) } + if cfg.Consensus.StakeKeyImageV4ActivationHeight != 0 { + if err := registry.ConfigureStakeKeyImageV4(cfg.Consensus.StakeKeyImageV4ActivationHeight); err != nil { + return err + } + } mempool.SetStakeAdmissionCheck(func(tx core.Transaction, height uint64) error { return registry.ValidateBlockStakeTxs([]core.Transaction{tx}, height) }) @@ -2671,33 +2738,35 @@ apiSrv.SetStakedCollateralRingExclusionActivationHeight( return fmt.Errorf("Development Fund startup safety: %w", err) } engine = consensus.NewEngine(consensus.Config{ - ValidatorAuthorityFork: authorityFork, - LPoDMigration: lpodMigration, - SaleMigration: saleMigration, - GenesisVestingActivationHeight: cfg.Consensus.GenesisVestingActivationHeight, - GenesisVestingFunding: genesisVestingFunding, - BlockTime: cfg.Consensus.BlockTime, - BFTThreshold: genesisConfig.BFTThreshold, - Validators: validators, - Registry: registry, - MyKey: consensusMyKey, - RewardAddress: cfg.Consensus.RewardAddress, - StopProducingAtHeight: cfg.Consensus.StopProducingAtHeight, - BlockRewardNAPR: cfg.Consensus.BlockRewardNAPR, - StakingPoolNAPR: cfg.Consensus.StakingPoolNAPR, - TailRewardNAPR: cfg.Consensus.TailRewardNAPR, - Store: db, - OracleURL: cfg.Consensus.OracleURL, - OracleMaxDeviation: cfg.Consensus.OracleMaxDeviation, - RingCTV4ActivationHeight: cfg.Consensus.RingCTV4ActivationHeight, - RingCTCLSAGActivationHeight: cfg.Consensus.RingCTCLSAGActivationHeight, -StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRingExclusionActivationHeight, - AVMActivationHeight: cfg.Consensus.AVMActivationHeight, - AVMGasBurnActivationHeight: cfg.Consensus.AVMGasBurnActivationHeight, - AVMExecutor: avm.NewBlockExecutor(avm.LevelStore{DB: db}), - RewardAuthorizationActivationHeight: cfg.Consensus.RewardAuthorizationActivationHeight, - GuardianFundActivationHeight: cfg.Consensus.GuardianFundActivationHeight, - GuardianFundChainAnchor: guardianAnchor, + ValidatorAuthorityFork: authorityFork, + FinalityMigration: finalityMigration, + FinalityMigrationTrustAuthorities: finalityMigrationTrustAuthorities, + LPoDMigration: lpodMigration, + SaleMigration: saleMigration, + GenesisVestingActivationHeight: cfg.Consensus.GenesisVestingActivationHeight, + GenesisVestingFunding: genesisVestingFunding, + BlockTime: cfg.Consensus.BlockTime, + BFTThreshold: genesisConfig.BFTThreshold, + Validators: validators, + Registry: registry, + MyKey: consensusMyKey, + RewardAddress: cfg.Consensus.RewardAddress, + StopProducingAtHeight: cfg.Consensus.StopProducingAtHeight, + BlockRewardNAPR: cfg.Consensus.BlockRewardNAPR, + StakingPoolNAPR: cfg.Consensus.StakingPoolNAPR, + TailRewardNAPR: cfg.Consensus.TailRewardNAPR, + Store: db, + OracleURL: cfg.Consensus.OracleURL, + OracleMaxDeviation: cfg.Consensus.OracleMaxDeviation, + RingCTV4ActivationHeight: cfg.Consensus.RingCTV4ActivationHeight, + RingCTCLSAGActivationHeight: cfg.Consensus.RingCTCLSAGActivationHeight, + StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRingExclusionActivationHeight, + AVMActivationHeight: cfg.Consensus.AVMActivationHeight, + AVMGasBurnActivationHeight: cfg.Consensus.AVMGasBurnActivationHeight, + AVMExecutor: avm.NewBlockExecutor(avm.LevelStore{DB: db}), + RewardAuthorizationActivationHeight: cfg.Consensus.RewardAuthorizationActivationHeight, + GuardianFundActivationHeight: cfg.Consensus.GuardianFundActivationHeight, + GuardianFundChainAnchor: guardianAnchor, OnCanonicalBlock: func(block *core.Block, prepared *avm.PreparedBlock) error { raw, err := json.Marshal(block) if err != nil { @@ -2809,7 +2878,7 @@ StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRin // Single-flight guard: skip if a prior GC goroutine is still // running. Without this, rapid block acceptance during catch-up // sync queues an unbounded backlog of goroutines that all - // serialise on the UTXOSet write lock, then each force a + // serialize on the UTXOSet write lock, then each force a // stop-the-world GC — worsening the problem this task solves. if gcInFlight.CompareAndSwap(false, true) { go func(height uint64) { @@ -2870,7 +2939,7 @@ StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRin TipHashHex: fmt.Sprintf("%x", hash[:]), TxTotal: txTot, SavedAt: time.Now(), - UTXOs: utxos.TakeSnapshot(), + UTXOs: utxos.TakeSnapshotView(), Registry: engine.Registry().TakeSnapshot(), } // Purge any mempool KIs that were never confirmed on-chain. @@ -2907,14 +2976,13 @@ StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRin "height", height, "err", metaErr) } } - // Explicitly release the deep-copy snapshot data so the GC can - // reclaim it immediately rather than waiting for the next - // scheduled collection. The disk write above is complete at this - // point, so there is no remaining reference to snap's slices. + // Drop the pointer view as soon as its disk write completes so the + // retained reference arrays can be reclaimed without waiting for + // the next scheduled collection. snap.UTXOs = core.UTXOSnapshot{} snap.Registry = core.RegistrySnapshot{} - // Compact key images after the snapshot: TakeSnapshot already - // serialised them, so we can now merge the 'recent' map + // Compact key images after the snapshot: the snapshot view already + // serialized them, so we can now merge the 'recent' map // (≈150 B/entry Go-map overhead) into the 'sorted' slice // (32 B/entry). GC reclaims the old map buckets immediately // after this call. @@ -2934,7 +3002,7 @@ StakedCollateralRingExclusionActivationHeight: cfg.Consensus.StakedCollateralRin }, chain, mempool, log) if apiSrv != nil { apiSrv.SetLPoDConfig(lpodMigration, engine.IsFinalizedHash) -apiSrv.SetSaleConfig(saleMigration, engine.CanSettleSale, engine.IsSaleFinalizedHash) + apiSrv.SetSaleConfig(saleMigration, engine.CanSettleSale, engine.IsSaleFinalizedHash) apiSrv.SetGenesisVestingConfig(cfg.Consensus.GenesisVestingActivationHeight) } if db != nil && lpodMigration != nil && lpodMigration.Version == 3 { @@ -2943,6 +3011,11 @@ apiSrv.SetSaleConfig(saleMigration, engine.CanSettleSale, engine.IsSaleFinalized } else { log.Info("v3 LPoD earnings index backfill complete") } + if err := db.BackfillLPoDWalletIndex(lpodMigration); err != nil { + log.Warn("v3 LPoD wallet-output index backfill incomplete; wallet balance API remains fail-closed", "err", err) + } else { + log.Info("v3 LPoD wallet-output index backfill complete") + } } // ── 8. Wire TxVerifier BEFORE starting the engine goroutine ────────────── @@ -2951,9 +3024,9 @@ apiSrv.SetSaleConfig(saleMigration, engine.CanSettleSale, engine.IsSaleFinalized // where blocks are accepted without cryptographic verification (nil verifier // = fail-open). Wiring first closes that window entirely. txVerifier := core.NewTxVerifier(utxos) -txVerifier.SetStakedCollateralRingExclusionActivationHeight( - cfg.Consensus.StakedCollateralRingExclusionActivationHeight, -) + txVerifier.SetStakedCollateralRingExclusionActivationHeight( + cfg.Consensus.StakedCollateralRingExclusionActivationHeight, + ) // Wire vesting enforcement so the verifier rejects spending of still-locked // genesis allocations at the protocol level (not just display-only). // Use the actual persisted genesis block timestamp (nanoseconds ÷ 1e9) rather @@ -3642,9 +3715,9 @@ func performShutdownWithGate( } // Step 3: reclaim as much memory as possible before the snapshot save so - // that TakeSnapshot() itself — which serialises the full UTXO set — does - // not push RSS above GOMEMLIMIT and trigger an OOM-kill mid-write. This - // mirrors the post-snapshot-load GC already performed on startup. + // that snapshot metadata and the bounded pointer view can be allocated + // without pushing RSS above GOMEMLIMIT. The UTXO records are streamed in + // small JSON batches rather than deep-copied. rssBeforeGC := readRSSBytes() runtime.GC() debug.FreeOSMemory() @@ -3824,7 +3897,7 @@ func saveShutdownSnapshotWithPaths( TipHashHex: fmt.Sprintf("%x", shutTipHash[:]), TxTotal: shutTxTotal, SavedAt: time.Now(), - UTXOs: utxos.TakeSnapshot(), + UTXOs: utxos.TakeSnapshotView(), Registry: registry.TakeSnapshot(), } // Purge any mempool KIs that were never confirmed on-chain before @@ -4072,16 +4145,16 @@ func readRSSBytes() int64 { // "1TiB" → 1099511627776, true // // Returns (bytes, ok). ok is false when the value is absent, "off", -// parses to zero or negative, overflows int64, or uses an unrecognised format. +// parses to zero or negative, overflows int64, or uses an unrecognized format. // -// Suffixes recognised by the Go 1.21+ runtime: B, KiB, MiB, GiB, TiB. +// Suffixes recognized by the Go 1.21+ runtime: B, KiB, MiB, GiB, TiB. // PiB and EiB are NOT in the runtime grammar and are rejected here. func parseGOMLEMLIMITBytes(raw string) (int64, bool) { if raw == "" || raw == "off" { return 0, false } - // The Go runtime recognises exactly these unit suffixes (longest first + // The Go runtime recognizes exactly these unit suffixes (longest first // so that "KiB" is matched before the bare "B" suffix). type unit struct { suffix string @@ -4121,7 +4194,7 @@ func parseGOMLEMLIMITBytes(raw string) (int64, bool) { // checkGOMLEMLIMIT inspects the GOMEMLIMIT environment variable and logs a // prominent warning when it is absent, explicitly disabled ("off"), set to -// zero, or uses an unrecognised format. If strictMode is true the function +// zero, or uses an unrecognized format. If strictMode is true the function // returns an error so the node exits with a non-zero status, preventing a // silent start without a memory cap. // @@ -4476,7 +4549,7 @@ func checkSystemdTimeout(dropinPath, servicePath, systemdDir string, log *slog.L // by lost transactions (e.g. after an OOM kill before the tx was confirmed in // a block). This makes UTXOs whose key images were erroneously marked "spent" // spendable again. -// forcePurge controls behaviour when the block scan is incomplete (pruned +// forcePurge controls behavior when the block scan is incomplete (pruned // node). When false the function falls back to the persistent k/ index so // no confirmed key image is lost. When true the persistent index is purged // of all entries that could not be verified from the readable blocks; this @@ -4496,7 +4569,7 @@ func rebuildKeyImagesFromBlocks(blockStore *store.DB, tipHeight uint64, utxos *c count := 0 // valid holds every key image that appears in a confirmed transaction, // in both raw and canonical form, so the LevelDB k/ index purge below - // recognises entries written via either MarkKeyImageSpent path. + // recognizes entries written via either MarkKeyImageSpent path. valid := make(map[crypto.KeyImage]bool) // scanComplete guards the purge below: deleting index entries based on an // INCOMPLETE block scan could remove a genuinely confirmed key image and @@ -4543,6 +4616,21 @@ func rebuildKeyImagesFromBlocks(blockStore *store.DB, tipHeight uint64, utxos *c } count++ } + if tx.IsStake() && len(tx.Extra) == core.StakePayloadSizeV4 { + _, _, _, _, _, _, _, keyImage, _, decodeErr := core.DecodeStakeExtraV4(tx.Extra) + if decodeErr != nil { + scanComplete = false + log.Error("--rebuild-key-images: invalid stake key-image payload", + "height", h, "err", decodeErr) + continue + } + utxos.MarkSpent(keyImage) + valid[keyImage] = true + if canonical, cErr := crypto.CanonicalKeyImage(keyImage); cErr == nil { + valid[canonical] = true + } + count++ + } } } @@ -4896,7 +4984,7 @@ func verifyUTXOStoreEntries(blockStore *store.DB, tipHeight uint64, log *slog.Lo return fixed, firstErr } -// storeBlock serialises a block to JSON and writes it via PutRawBlock. +// storeBlock serializes a block to JSON and writes it via PutRawBlock. // It also persists a UTXO record for every transaction output so that // api.utxoMissingReason can later distinguish "never existed" from "already // spent or burned" from "originated in a now-pruned block". The records are @@ -5111,7 +5199,7 @@ func validateAmountCommitsFromBlocks( ) (checked, fixed int) { // Take a read-only snapshot so we can iterate without holding the mutex // while performing potentially slow block reads from disk. - snap := utxos.TakeSnapshot() + snap := utxos.TakeSnapshotView() if len(snap.ActiveUTXOs) == 0 && len(snap.StakedUTXOs) == 0 { return } diff --git a/cmd/node/maintenance_preflight_test.go b/cmd/node/maintenance_preflight_test.go index a5eeb0f..95df8a6 100644 --- a/cmd/node/maintenance_preflight_test.go +++ b/cmd/node/maintenance_preflight_test.go @@ -2,18 +2,18 @@ package main import ( "bytes" -"crypto/sha256" -"encoding/binary" + "crypto/sha256" + "encoding/binary" "fmt" -"io/fs" + "io/fs" "os" "path/filepath" -"reflect" + "reflect" "strings" "testing" -"github.com/aperod/aperod/crypto" -"github.com/aperod/aperod/store" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) func TestMaintenancePreflightWarnsAndRefusesStaleTip(t *testing.T) { @@ -60,199 +60,199 @@ func TestMaintenancePreflightAllowsMatchingTip(t *testing.T) { } func createMaintenanceTestDB(t *testing.T, dataDir string, tipHeight uint64) { -t.Helper() -db, err := store.Open(filepath.Join(dataDir, "chain.db")) -if err != nil { -t.Fatalf("open fixture db: %v", err) -} -sum := sha256.Sum256([]byte("maintenance-test-tip")) -var hash crypto.Hash32 -copy(hash[:], sum[:]) -block := &store.StoredBlock{Height: tipHeight, Hash: hash} -if err := db.PutBlock(hash, block); err != nil { -db.Close() -t.Fatalf("write fixture block: %v", err) -} -if err := db.PutTip(hash, tipHeight); err != nil { -db.Close() -t.Fatalf("write fixture tip: %v", err) -} -if err := db.Close(); err != nil { -t.Fatalf("close fixture db: %v", err) -} + t.Helper() + db, err := store.Open(filepath.Join(dataDir, "chain.db")) + if err != nil { + t.Fatalf("open fixture db: %v", err) + } + sum := sha256.Sum256([]byte("maintenance-test-tip")) + var hash crypto.Hash32 + copy(hash[:], sum[:]) + block := &store.StoredBlock{Height: tipHeight, Hash: hash} + if err := db.PutBlock(hash, block); err != nil { + db.Close() + t.Fatalf("write fixture block: %v", err) + } + if err := db.PutTip(hash, tipHeight); err != nil { + db.Close() + t.Fatalf("write fixture tip: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close fixture db: %v", err) + } } func TestRepairDBRefusesInvalidTipMetadataBeforeRecovery(t *testing.T) { -testCases := []struct { -name string -setup func(t *testing.T, db *store.DB) -}{ -{ -name: "empty database", -setup: func(t *testing.T, db *store.DB) {}, -}, -{ -name: "missing tip hash", -setup: func(t *testing.T, db *store.DB) { -var height [8]byte -binary.LittleEndian.PutUint64(height[:], 42) -if err := db.PutMeta("tip/height", height[:]); err != nil { -t.Fatalf("write tip height: %v", err) -} -}, -}, -{ -name: "missing tip height", -setup: func(t *testing.T, db *store.DB) { -hash := sha256.Sum256([]byte("half-tip")) -if err := db.PutMeta("tip/hash", hash[:]); err != nil { -t.Fatalf("write tip hash: %v", err) -} -}, -}, -{ -name: "malformed tip metadata", -setup: func(t *testing.T, db *store.DB) { -if err := db.PutMeta("tip/hash", []byte{1, 2, 3}); err != nil { -t.Fatalf("write malformed tip hash: %v", err) -} -if err := db.PutMeta("tip/height", []byte{4, 5}); err != nil { -t.Fatalf("write malformed tip height: %v", err) -} -}, -}, -} + testCases := []struct { + name string + setup func(t *testing.T, db *store.DB) + }{ + { + name: "empty database", + setup: func(t *testing.T, db *store.DB) {}, + }, + { + name: "missing tip hash", + setup: func(t *testing.T, db *store.DB) { + var height [8]byte + binary.LittleEndian.PutUint64(height[:], 42) + if err := db.PutMeta("tip/height", height[:]); err != nil { + t.Fatalf("write tip height: %v", err) + } + }, + }, + { + name: "missing tip height", + setup: func(t *testing.T, db *store.DB) { + hash := sha256.Sum256([]byte("half-tip")) + if err := db.PutMeta("tip/hash", hash[:]); err != nil { + t.Fatalf("write tip hash: %v", err) + } + }, + }, + { + name: "malformed tip metadata", + setup: func(t *testing.T, db *store.DB) { + if err := db.PutMeta("tip/hash", []byte{1, 2, 3}); err != nil { + t.Fatalf("write malformed tip hash: %v", err) + } + if err := db.PutMeta("tip/height", []byte{4, 5}); err != nil { + t.Fatalf("write malformed tip height: %v", err) + } + }, + }, + } -for _, tc := range testCases { -t.Run(tc.name, func(t *testing.T) { -dataDir := t.TempDir() -db, err := store.Open(filepath.Join(dataDir, "chain.db")) -if err != nil { -t.Fatalf("open fixture db: %v", err) -} -tc.setup(t, db) -if err := db.Close(); err != nil { -t.Fatalf("close fixture db: %v", err) -} -before := maintenanceDBState(t, dataDir) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dataDir := t.TempDir() + db, err := store.Open(filepath.Join(dataDir, "chain.db")) + if err != nil { + t.Fatalf("open fixture db: %v", err) + } + tc.setup(t, db) + if err := db.Close(); err != nil { + t.Fatalf("close fixture db: %v", err) + } + before := maintenanceDBState(t, dataDir) -cfgPath := filepath.Join(t.TempDir(), "node.yaml") -cfgText := fmt.Sprintf("network: testnet\ndata_dir: %q\n", dataDir) -if err := os.WriteFile(cfgPath, []byte(cfgText), 0600); err != nil { -t.Fatalf("write config: %v", err) -} -t.Setenv("APEROD_DATA_DIR", "") -oldArgs := os.Args -os.Args = []string{"aperod-node", "--repair-db", "--config", cfgPath} -t.Cleanup(func() { os.Args = oldArgs }) + cfgPath := filepath.Join(t.TempDir(), "node.yaml") + cfgText := fmt.Sprintf("network: testnet\ndata_dir: %q\n", dataDir) + if err := os.WriteFile(cfgPath, []byte(cfgText), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("APEROD_DATA_DIR", "") + oldArgs := os.Args + os.Args = []string{"aperod-node", "--repair-db", "--config", cfgPath} + t.Cleanup(func() { os.Args = oldArgs }) -err = run() -if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { -t.Fatalf("expected invalid-tip refusal, got %v", err) -} -if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { -t.Fatalf("repair-db changed invalid chain.db before refusing:\nbefore=%v\nafter=%v", before, after) -} -}) -} + err = run() + if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { + t.Fatalf("expected invalid-tip refusal, got %v", err) + } + if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { + t.Fatalf("repair-db changed invalid chain.db before refusing:\nbefore=%v\nafter=%v", before, after) + } + }) + } } func maintenanceDBState(t *testing.T, dataDir string) map[string][32]byte { -t.Helper() -state := make(map[string][32]byte) -root := filepath.Join(dataDir, "chain.db") -err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { -if walkErr != nil { -return walkErr -} -if entry.IsDir() { -return nil -} -content, err := os.ReadFile(path) -if err != nil { -return err -} -relative, err := filepath.Rel(root, path) -if err != nil { -return err -} -state[relative] = sha256.Sum256(content) -return nil -}) -if err != nil { -t.Fatalf("capture chain.db state: %v", err) -} -return state + t.Helper() + state := make(map[string][32]byte) + root := filepath.Join(dataDir, "chain.db") + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + state[relative] = sha256.Sum256(content) + return nil + }) + if err != nil { + t.Fatalf("capture chain.db state: %v", err) + } + return state } func TestCompactDBRefusesStaleTargetBeforeCompaction(t *testing.T) { -dataDir := t.TempDir() -createMaintenanceTestDB(t, dataDir, 100) -snapshot := filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, 20_000)) -if err := os.WriteFile(snapshot, []byte("test"), 0600); err != nil { -t.Fatalf("write snapshot marker: %v", err) -} -before := maintenanceDBState(t, dataDir) + dataDir := t.TempDir() + createMaintenanceTestDB(t, dataDir, 100) + snapshot := filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, 20_000)) + if err := os.WriteFile(snapshot, []byte("test"), 0600); err != nil { + t.Fatalf("write snapshot marker: %v", err) + } + before := maintenanceDBState(t, dataDir) -oldArgs := os.Args -os.Args = []string{"aperod-node", "--compact-db", "--data-dir", dataDir} -t.Cleanup(func() { os.Args = oldArgs }) + oldArgs := os.Args + os.Args = []string{"aperod-node", "--compact-db", "--data-dir", dataDir} + t.Cleanup(func() { os.Args = oldArgs }) -err := runCompactDB() -if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { -t.Fatalf("expected stale-target refusal, got %v", err) -} -if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { -t.Fatalf("compact-db changed chain.db before refusing:\nbefore=%v\nafter=%v", before, after) -} + err := runCompactDB() + if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { + t.Fatalf("expected stale-target refusal, got %v", err) + } + if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { + t.Fatalf("compact-db changed chain.db before refusing:\nbefore=%v\nafter=%v", before, after) + } } func TestRepairHeightIndexRefusesStaleTargetBeforeRepair(t *testing.T) { -dataDir := t.TempDir() -createMaintenanceTestDB(t, dataDir, 100) -snapshot := filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, 20_000)) -if err := os.WriteFile(snapshot, []byte("test"), 0600); err != nil { -t.Fatalf("write snapshot marker: %v", err) -} -before := maintenanceDBState(t, dataDir) + dataDir := t.TempDir() + createMaintenanceTestDB(t, dataDir, 100) + snapshot := filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, 20_000)) + if err := os.WriteFile(snapshot, []byte("test"), 0600); err != nil { + t.Fatalf("write snapshot marker: %v", err) + } + before := maintenanceDBState(t, dataDir) -oldArgs := os.Args -os.Args = []string{"aperod-node", "--repair-height-index", "--data-dir", dataDir} -t.Cleanup(func() { os.Args = oldArgs }) + oldArgs := os.Args + os.Args = []string{"aperod-node", "--repair-height-index", "--data-dir", dataDir} + t.Cleanup(func() { os.Args = oldArgs }) -err := runRepairHeightIndex() -if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { -t.Fatalf("expected stale-target refusal, got %v", err) -} -if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { -t.Fatalf("repair-height-index changed chain.db before refusing:\nbefore=%v\nafter=%v", before, after) -} + err := runRepairHeightIndex() + if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { + t.Fatalf("expected stale-target refusal, got %v", err) + } + if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { + t.Fatalf("repair-height-index changed chain.db before refusing:\nbefore=%v\nafter=%v", before, after) + } } func TestRepairDBRefusesStaleTargetBeforeRecovery(t *testing.T) { -dataDir := t.TempDir() -createMaintenanceTestDB(t, dataDir, 100) -snapshot := filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, 20_000)) -if err := os.WriteFile(snapshot, []byte("test"), 0600); err != nil { -t.Fatalf("write snapshot marker: %v", err) -} -before := maintenanceDBState(t, dataDir) + dataDir := t.TempDir() + createMaintenanceTestDB(t, dataDir, 100) + snapshot := filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, 20_000)) + if err := os.WriteFile(snapshot, []byte("test"), 0600); err != nil { + t.Fatalf("write snapshot marker: %v", err) + } + before := maintenanceDBState(t, dataDir) -cfgPath := filepath.Join(t.TempDir(), "node.yaml") -cfgText := fmt.Sprintf("network: testnet\ndata_dir: %q\n", dataDir) -if err := os.WriteFile(cfgPath, []byte(cfgText), 0600); err != nil { -t.Fatalf("write config: %v", err) -} -t.Setenv("APEROD_DATA_DIR", "") -oldArgs := os.Args -os.Args = []string{"aperod-node", "--repair-db", "--config", cfgPath} -t.Cleanup(func() { os.Args = oldArgs }) + cfgPath := filepath.Join(t.TempDir(), "node.yaml") + cfgText := fmt.Sprintf("network: testnet\ndata_dir: %q\n", dataDir) + if err := os.WriteFile(cfgPath, []byte(cfgText), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("APEROD_DATA_DIR", "") + oldArgs := os.Args + os.Args = []string{"aperod-node", "--repair-db", "--config", cfgPath} + t.Cleanup(func() { os.Args = oldArgs }) -err := run() -if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { -t.Fatalf("expected stale-target refusal, got %v", err) -} -if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { -t.Fatalf("repair-db changed chain.db before refusing:\nbefore=%v\nafter=%v", before, after) -} + err := run() + if err == nil || !strings.Contains(err.Error(), "MAINTENANCE REFUSED") { + t.Fatalf("expected stale-target refusal, got %v", err) + } + if after := maintenanceDBState(t, dataDir); !reflect.DeepEqual(after, before) { + t.Fatalf("repair-db changed chain.db before refusing:\nbefore=%v\nafter=%v", before, after) + } } diff --git a/cmd/node/mempool_persist_test.go b/cmd/node/mempool_persist_test.go index 67074e2..3483e9c 100644 --- a/cmd/node/mempool_persist_test.go +++ b/cmd/node/mempool_persist_test.go @@ -59,12 +59,12 @@ func mempoolTestConfig() core.MempoolConfig { // passes TxVerifier.VerifyTx under the returned UTXOSet. // // The setup mirrors the production mint + spend flow: -// 1. Generate a wallet key pair (Alice). -// 2. Mint a faucet UTXO to Alice via BuildMintTx at height=1. -// 3. Register the mint UTXO in the UTXOSet so C-0 / C-0a checks pass. -// 4. Build a spend tx via TxBuilder (Phase 1 random decoys; all absent from -// the UTXOSet, so only the real input is present — the exact scenario the -// C-0 fix was designed for). +// 1. Generate a wallet key pair (Alice). +// 2. Mint a faucet UTXO to Alice via BuildMintTx at height=1. +// 3. Register the mint UTXO in the UTXOSet so C-0 / C-0a checks pass. +// 4. Build a spend tx via TxBuilder (Phase 1 random decoys; all absent from +// the UTXOSet, so only the real input is present — the exact scenario the +// C-0 fix was designed for). // // Returns the built transaction and the UTXOSet that makes it verifiable. func buildProductionTx(t *testing.T) (core.Transaction, *core.UTXOSet) { @@ -199,15 +199,15 @@ func makeTestTx(fee uint64, kiIdx int) core.Transaction { // TestMempoolPersistProductionPath exercises the exact main.go lifecycle: // -// "Before shutdown": -// pool.SetVerifier(txVerifier) // wired after UTXO scan completes -// pool.Add(tx) // full crypto verification runs -// pool.Save(dataDir) // SIGTERM handler persists to disk +// "Before shutdown": +// pool.SetVerifier(txVerifier) // wired after UTXO scan completes +// pool.Add(tx) // full crypto verification runs +// pool.Save(dataDir) // SIGTERM handler persists to disk // -// "After restart": -// pool.SetVerifier(txVerifier) // same UTXO set, same verifier setup -// pool.Load(dataDir, log) // re-adds with full verification -// pool.SelectTxs(1) // tx is returned for next block +// "After restart": +// pool.SetVerifier(txVerifier) // same UTXO set, same verifier setup +// pool.Load(dataDir, log) // re-adds with full verification +// pool.SelectTxs(1) // tx is returned for next block // // Uses a cryptographically valid RingCT transaction built by TxBuilder so that // the full ring-signature + range-proof + Pedersen-balance path is exercised. @@ -589,7 +589,7 @@ func TestMempoolPersistFeeRateOrderPreserved(t *testing.T) { // Test cases: // - Truncated JSON (simulates a mid-write OOM kill): `{"saved_at":"2026` // - Pure garbage bytes: `NOT JSON AT ALL` -// - Empty file (zero bytes): `` +// - Empty file (zero bytes): “ // - Valid JSON but wrong schema (an array, not an object): `[]` func TestMempoolLoadCorruptFileReturnsZero(t *testing.T) { cases := []struct { @@ -797,21 +797,21 @@ func TestCleanStaleTmpFilesCalledBeforeLoad(t *testing.T) { lines := strings.Split(string(data), "\n") - var cleanLines []int // all cleanStaleSnapshotTmpFiles call sites - branchLine := -1 // genesis/resume branch split + var cleanLines []int // all cleanStaleSnapshotTmpFiles call sites + branchLine := -1 // genesis/resume branch split var snapLoadLines []int // all tryLoadStartupSnapshot call sites -checkSnapshotStart := -1 -checkSnapshotEnd := -1 + checkSnapshotStart := -1 + checkSnapshotEnd := -1 for i, raw := range lines { lineNo := i + 1 trimmed := strings.TrimSpace(raw) -if strings.HasPrefix(trimmed, "func runCheckSnapshot()") { -checkSnapshotStart = lineNo -} else if checkSnapshotStart != -1 && checkSnapshotEnd == -1 && -lineNo > checkSnapshotStart && strings.HasPrefix(trimmed, "func ") { -checkSnapshotEnd = lineNo -} + if strings.HasPrefix(trimmed, "func runCheckSnapshot()") { + checkSnapshotStart = lineNo + } else if checkSnapshotStart != -1 && checkSnapshotEnd == -1 && + lineNo > checkSnapshotStart && strings.HasPrefix(trimmed, "func ") { + checkSnapshotEnd = lineNo + } switch { case strings.Contains(trimmed, "cleanStaleSnapshotTmpFiles("): cleanLines = append(cleanLines, lineNo) @@ -840,17 +840,17 @@ checkSnapshotEnd = lineNo ) } -// ── Invariant 2: every startup snapshot load has a preceding cleanup ─────── -// Checks that no new startup load site was added without cleanup coverage. -// The config-free checker is a deliberate exception because it is read-only. + // ── Invariant 2: every startup snapshot load has a preceding cleanup ─────── + // Checks that no new startup load site was added without cleanup coverage. + // The config-free checker is a deliberate exception because it is read-only. if len(snapLoadLines) == 0 { t.Error("tryLoadStartupSnapshot not found in main.go — was the snapshot load removed?") } for _, loadLine := range snapLoadLines { -if checkSnapshotStart != -1 && loadLine > checkSnapshotStart && -(checkSnapshotEnd == -1 || loadLine < checkSnapshotEnd) { -continue -} + if checkSnapshotStart != -1 && loadLine > checkSnapshotStart && + (checkSnapshotEnd == -1 || loadLine < checkSnapshotEnd) { + continue + } hasPrior := false for _, cleanLine := range cleanLines { if cleanLine < loadLine { diff --git a/cmd/node/mempool_tmp_cleanup_test.go b/cmd/node/mempool_tmp_cleanup_test.go index 6362411..01bc827 100644 --- a/cmd/node/mempool_tmp_cleanup_test.go +++ b/cmd/node/mempool_tmp_cleanup_test.go @@ -196,7 +196,7 @@ func TestMempoolStartupStaleTmpAlongsideSavedEntries(t *testing.T) { t.Errorf("stale .tmp still present after startupLoadMempool (err=%v)", err) } // Note: Load() removes mempool.json after consuming it — that is correct - // behaviour (prevents double-replay on a second restart). We do not assert + // behavior (prevents double-replay on a second restart). We do not assert // the file is still present; we assert the transaction was restored instead. // Cleanup message must appear. diff --git a/cmd/node/nightly_restart_test.go b/cmd/node/nightly_restart_test.go index 0ba2fc3..47cf5f5 100644 --- a/cmd/node/nightly_restart_test.go +++ b/cmd/node/nightly_restart_test.go @@ -29,37 +29,37 @@ func TestDurationUntilRestartAt(t *testing.T) { wantMax time.Duration }{ { - name: "10 min before restart window", - now: time.Date(2026, 8, 14, 3, 50, 0, 0, time.UTC), - hour: 4, minute: 0, + name: "10 min before restart window", + now: time.Date(2026, 8, 14, 3, 50, 0, 0, time.UTC), + hour: 4, minute: 0, wantMin: 9*time.Minute + 59*time.Second, wantMax: 10*time.Minute + 1*time.Second, }, { - name: "exactly at restart time → next day (24h)", - now: time.Date(2026, 8, 14, 4, 0, 0, 0, time.UTC), - hour: 4, minute: 0, + name: "exactly at restart time → next day (24h)", + now: time.Date(2026, 8, 14, 4, 0, 0, 0, time.UTC), + hour: 4, minute: 0, wantMin: 23*time.Hour + 59*time.Minute + 59*time.Second, wantMax: 24 * time.Hour, }, { - name: "1 s after restart time → ~24h from now", - now: time.Date(2026, 8, 14, 4, 0, 1, 0, time.UTC), - hour: 4, minute: 0, + name: "1 s after restart time → ~24h from now", + now: time.Date(2026, 8, 14, 4, 0, 1, 0, time.UTC), + hour: 4, minute: 0, wantMin: 23*time.Hour + 59*time.Minute, wantMax: 23*time.Hour + 59*time.Minute + 59*time.Second, }, { - name: "midnight restart from 23:50", - now: time.Date(2026, 8, 14, 23, 50, 0, 0, time.UTC), - hour: 0, minute: 0, + name: "midnight restart from 23:50", + now: time.Date(2026, 8, 14, 23, 50, 0, 0, time.UTC), + hour: 0, minute: 0, wantMin: 9*time.Minute + 59*time.Second, wantMax: 10*time.Minute + 1*time.Second, }, { - name: "14 h 30 min ahead", - now: time.Date(2026, 8, 14, 13, 30, 0, 0, time.UTC), - hour: 4, minute: 0, + name: "14 h 30 min ahead", + now: time.Date(2026, 8, 14, 13, 30, 0, 0, time.UTC), + hour: 4, minute: 0, wantMin: 14*time.Hour + 29*time.Minute, wantMax: 14*time.Hour + 31*time.Minute, }, diff --git a/cmd/node/rebuild_missing_utxos_test.go b/cmd/node/rebuild_missing_utxos_test.go index ff0ada9..9f5bdd9 100644 --- a/cmd/node/rebuild_missing_utxos_test.go +++ b/cmd/node/rebuild_missing_utxos_test.go @@ -48,11 +48,11 @@ func buildMintBlock(t *testing.T, dir string) (*store.DB, *core.Block, crypto.Ha // Genesis block (height 0, no transactions). genesis := &core.Block{ Header: core.BlockHeader{ - Height: 0, - PrevHash: crypto.Hash32{}, - Timestamp: time.Now().UnixNano(), + Height: 0, + PrevHash: crypto.Hash32{}, + Timestamp: time.Now().UnixNano(), ValidatorPub: pub, - MerkleRoot: core.MerkleRoot(nil), + MerkleRoot: core.MerkleRoot(nil), }, } if serr := genesis.Header.Sign(priv); serr != nil { diff --git a/cmd/node/registry_bootstrap.go b/cmd/node/registry_bootstrap.go index 0a27651..120fb0e 100644 --- a/cmd/node/registry_bootstrap.go +++ b/cmd/node/registry_bootstrap.go @@ -1,23 +1,24 @@ package main import ( - "compress/gzip" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" - "github.com/aperod/aperod/config" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" - "github.com/aperod/aperod/store" + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) const bootstrapMaxCompressedBytes int64 = 512 << 20 const bootstrapMaxExpandedBytes int64 = 2 << 30 + // Historical snapshots include millions of key images. The expanded-byte // ceiling is independent of (and usually tighter than) this entry ceiling. const bootstrapMaxArrayEntries = 8_000_000 @@ -26,254 +27,378 @@ const bootstrapMaxArrayEntries = 8_000_000 // signers. Neither a witness signature nor the DB index authenticates that // operator's historical committee assertion. func loadOperatorRegistryBootstrap(c *config.RegistryBootstrapConfig, nonValidator bool, db *store.DB, tip uint64) (*startupSnapshot, error) { - if c == nil { return nil, nil } - if !nonValidator || c.Trust != "operator_attested" || c.Height == 0 || c.Height > tip { - return nil, fmt.Errorf("registry bootstrap requires non-validator mode, operator_attested trust, and a nonzero height at or below tip") - } - digest, err := hex.DecodeString(c.SHA256) - if err != nil || len(digest) != sha256.Size { return nil, fmt.Errorf("registry bootstrap requires an exact snapshot SHA-256") } - f, err := os.Open(c.SnapshotFile) - if err != nil { return nil, fmt.Errorf("registry bootstrap: %w", err) } - h := sha256.New() - defer f.Close() -info, err := f.Stat() -if err != nil || !info.Mode().IsRegular() || info.Size() > bootstrapMaxCompressedBytes { -return nil, fmt.Errorf("registry bootstrap requires a regular compressed snapshot <=512 MiB") -} -// Authenticate compressed bytes BEFORE spending memory/CPU on decompression. -if _, err := io.Copy(h, io.LimitReader(f, bootstrapMaxCompressedBytes+1)); err != nil { -return nil, err -} -if hex.EncodeToString(h.Sum(nil)) != hex.EncodeToString(digest) { -return nil, fmt.Errorf("registry bootstrap snapshot digest mismatch before decompression") -} -if _, err := f.Seek(0, io.SeekStart); err != nil { return nil, err } -h.Reset() - reader := io.TeeReader(io.LimitReader(f, bootstrapMaxCompressedBytes+1), h) - gz, err := gzip.NewReader(reader) - if err != nil { return nil, fmt.Errorf("registry bootstrap gzip: %w", err) } - defer gz.Close() - snap := new(startupSnapshot) - expanded := &io.LimitedReader{R:gz, N:bootstrapMaxExpandedBytes+1} - decoder := json.NewDecoder(expanded) - if err := decodeBootstrapSnapshot(decoder, snap); err != nil { return nil, fmt.Errorf("registry bootstrap snapshot decode: %w", err) } - if err := decoder.Decode(new(any)); err != io.EOF { return nil, fmt.Errorf("registry bootstrap snapshot trailing data or checksum failure") } -if expanded.N <= 0 { return nil, fmt.Errorf("registry bootstrap expanded snapshot exceeds 2 GiB") } - if _, err := io.Copy(io.Discard, reader); err != nil { return nil, err } - if hex.EncodeToString(h.Sum(nil)) != hex.EncodeToString(digest) { return nil, fmt.Errorf("registry bootstrap snapshot digest mismatch") } - if snap.Version != snapVersion || snap.TipHeight != c.Height { return nil, fmt.Errorf("registry bootstrap snapshot version/height mismatch") } - hash, found, err := db.GetCanonicalHash(c.Height) - if err != nil || !found || snap.TipHashHex != fmt.Sprintf("%x", hash[:]) { - return nil, fmt.Errorf("registry bootstrap snapshot does not match canonical index at height %d", c.Height) - } - if err := validateBootstrapRegistry(snap.Registry); err != nil { return nil, err } -if err := validateBootstrapUTXOs(snap); err != nil { return nil, err } -if c.KeyImageFile!="" || c.KeyImageSHA256!="" { -images,err:=loadBootstrapKeyImageAnchor(c.KeyImageFile,c.KeyImageSHA256,snap.TipHeight,snap.TipHashHex) -if err!=nil { return nil,err } -if len(snap.UTXOs.KeyImages)!=0 { -if len(images)!=len(snap.UTXOs.KeyImages) { return nil,fmt.Errorf("snapshot and key-image anchor disagree") } -set:=make(map[crypto.KeyImage]bool,len(images)) -for _,image:=range images { set[image]=true } -for _,image:=range snap.UTXOs.KeyImages { if !set[image] { return nil,fmt.Errorf("snapshot and key-image anchor disagree") } } -} -snap.UTXOs.KeyImages=images -} -// Count metadata is only an anchor-height hint. Never compare the old snapshot -// to tip-height counts. Final replay state is checked against actual tip rows. -count, hasCount, err := db.LoadActiveUTXOCount(snap.TipHashHex) -if err != nil || (hasCount && count != len(snap.UTXOs.ActiveUTXOs)) { -return nil, fmt.Errorf("registry bootstrap anchor-height UTXO count metadata mismatch or read failure") -} - return snap, nil + if c == nil { + return nil, nil + } + if !nonValidator || c.Trust != "operator_attested" || c.Height == 0 || c.Height > tip { + return nil, fmt.Errorf("registry bootstrap requires non-validator mode, operator_attested trust, and a nonzero height at or below tip") + } + digest, err := hex.DecodeString(c.SHA256) + if err != nil || len(digest) != sha256.Size { + return nil, fmt.Errorf("registry bootstrap requires an exact snapshot SHA-256") + } + f, err := os.Open(c.SnapshotFile) + if err != nil { + return nil, fmt.Errorf("registry bootstrap: %w", err) + } + h := sha256.New() + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > bootstrapMaxCompressedBytes { + return nil, fmt.Errorf("registry bootstrap requires a regular compressed snapshot <=512 MiB") + } + // Authenticate compressed bytes BEFORE spending memory/CPU on decompression. + if _, err := io.Copy(h, io.LimitReader(f, bootstrapMaxCompressedBytes+1)); err != nil { + return nil, err + } + if hex.EncodeToString(h.Sum(nil)) != hex.EncodeToString(digest) { + return nil, fmt.Errorf("registry bootstrap snapshot digest mismatch before decompression") + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return nil, err + } + h.Reset() + reader := io.TeeReader(io.LimitReader(f, bootstrapMaxCompressedBytes+1), h) + gz, err := gzip.NewReader(reader) + if err != nil { + return nil, fmt.Errorf("registry bootstrap gzip: %w", err) + } + defer gz.Close() + snap := new(startupSnapshot) + expanded := &io.LimitedReader{R: gz, N: bootstrapMaxExpandedBytes + 1} + decoder := json.NewDecoder(expanded) + if err := decodeBootstrapSnapshot(decoder, snap); err != nil { + return nil, fmt.Errorf("registry bootstrap snapshot decode: %w", err) + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("registry bootstrap snapshot trailing data or checksum failure") + } + if expanded.N <= 0 { + return nil, fmt.Errorf("registry bootstrap expanded snapshot exceeds 2 GiB") + } + if _, err := io.Copy(io.Discard, reader); err != nil { + return nil, err + } + if hex.EncodeToString(h.Sum(nil)) != hex.EncodeToString(digest) { + return nil, fmt.Errorf("registry bootstrap snapshot digest mismatch") + } + if snap.Version != snapVersion || snap.TipHeight != c.Height { + return nil, fmt.Errorf("registry bootstrap snapshot version/height mismatch") + } + hash, found, err := db.GetCanonicalHash(c.Height) + if err != nil || !found || snap.TipHashHex != fmt.Sprintf("%x", hash[:]) { + return nil, fmt.Errorf("registry bootstrap snapshot does not match canonical index at height %d", c.Height) + } + if err := validateBootstrapRegistry(snap.Registry); err != nil { + return nil, err + } + if err := validateBootstrapUTXOs(snap); err != nil { + return nil, err + } + if c.KeyImageFile != "" || c.KeyImageSHA256 != "" { + images, err := loadBootstrapKeyImageAnchor(c.KeyImageFile, c.KeyImageSHA256, snap.TipHeight, snap.TipHashHex) + if err != nil { + return nil, err + } + if len(snap.UTXOs.KeyImages) != 0 { + if len(images) != len(snap.UTXOs.KeyImages) { + return nil, fmt.Errorf("snapshot and key-image anchor disagree") + } + set := make(map[crypto.KeyImage]bool, len(images)) + for _, image := range images { + set[image] = true + } + for _, image := range snap.UTXOs.KeyImages { + if !set[image] { + return nil, fmt.Errorf("snapshot and key-image anchor disagree") + } + } + } + snap.UTXOs.KeyImages = images + } + // Count metadata is only an anchor-height hint. Never compare the old snapshot + // to tip-height counts. Final replay state is checked against actual tip rows. + count, hasCount, err := db.LoadActiveUTXOCount(snap.TipHashHex) + if err != nil || (hasCount && count != len(snap.UTXOs.ActiveUTXOs)) { + return nil, fmt.Errorf("registry bootstrap anchor-height UTXO count metadata mismatch or read failure") + } + return snap, nil } func validateBootstrapRegistry(snap core.RegistrySnapshot) error { - for key, entry := range snap.Validators { - if entry == nil || key != hex.EncodeToString(entry.PubKey) || len(entry.PubKey) != 32 || isZeroLPoDAuthority(entry.PubKey) || entry.Seeded { - return fmt.Errorf("registry bootstrap contains malformed or observed-producer-seeded membership") - } - } - registry := core.NewValidatorRegistry() - registry.RestoreFromSnapshot(snap) - return guardLPoDConsensusAuthorities(registry.GetActiveValidators()) + for key, entry := range snap.Validators { + if entry == nil || key != hex.EncodeToString(entry.PubKey) || len(entry.PubKey) != 32 || isZeroLPoDAuthority(entry.PubKey) || entry.Seeded { + return fmt.Errorf("registry bootstrap contains malformed or observed-producer-seeded membership") + } + } + registry := core.NewValidatorRegistry() + registry.RestoreFromSnapshot(snap) + return guardLPoDConsensusAuthorities(registry.GetActiveValidators()) } // Replay is relative to the operator's pinned state. It proves continuity under // that assumption, not pre-anchor issuance or independently certified finality. func replayOperatorRegistryBootstrap(db *store.DB, utxos *core.UTXOSet, registry *core.ValidatorRegistry, snap *startupSnapshot, tip uint64, log *slog.Logger) error { -capturedHash,capturedHeight,err:=db.GetTip() -if err!=nil || capturedHeight!=tip || capturedHash==(crypto.Hash32{}) { -return fmt.Errorf("registry bootstrap initial durable tip unavailable or height changed: %v",err) -} -if err:=requireBootstrapTipUnchanged(db,capturedHash,capturedHeight);err!=nil { return err } -return utxos.WithAnchoredReplayKeyImages(snap.UTXOs.KeyImages, func() error { - previous, found, err := db.GetCanonicalHash(snap.TipHeight) - if err != nil || !found { return fmt.Errorf("registry bootstrap anchor index unavailable") } - for height := snap.TipHeight+1; height <= tip; height++ { - raw, err := db.GetRawBlockByHeight(height) - if err != nil || raw == nil { return fmt.Errorf("registry bootstrap missing block %d", height) } - var block core.Block - if err := json.Unmarshal(raw, &block); err != nil { return fmt.Errorf("registry bootstrap decode block %d: %w", height, err) } - index, found, err := db.GetCanonicalHash(height) - if err != nil || !found || block.Header.Height != height || block.Hash() != index || block.Header.PrevHash != previous || - !block.Header.VerifySignature() || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { - return fmt.Errorf("registry bootstrap block %d fails canonical continuity/signature/Merkle checks", height) - } - if !bootstrapProposerAt(registry.GetActiveValidators(), block.Header.ValidatorPub, block.Header.Round) { - return fmt.Errorf("registry bootstrap block %d proposer is not the restored committee round-slot leader", height) - } -// Stake replay's historical helper omits collateral checks. This restricted -// operator mode does not claim to validate stake changes: reject ALL of them. -for _, tx := range block.Txs { -if tx.IsStake() { return fmt.Errorf("registry bootstrap unsupported post-anchor stake transaction at %d", height) } -} -if err := utxos.ApplyBlock(&block); err != nil { return fmt.Errorf("registry bootstrap state replay at %d: %w", height, err) } -if height%core.EpochLength == 0 { registry.UpdateEpoch(height) } - previous = index - } -if err := guardLPoDConsensusAuthorities(registry.GetActiveValidators()); err != nil { return err } -if previous!=capturedHash { return fmt.Errorf("registry bootstrap replay endpoint differs from captured durable tip") } -if err := validateBootstrapTip(db, utxos, snap, log); err != nil { return err } -if err:=requireBootstrapTipUnchanged(db,capturedHash,capturedHeight);err!=nil { return err } -log.Info("operator-attested replay complete; key-image set matches durable tip; active-state completeness derives from pinned anchor and replay, not legacy disk indexes", "tip_height", tip,"active_utxos",utxos.Count()) -return nil -}) + capturedHash, capturedHeight, err := db.GetTip() + if err != nil || capturedHeight != tip || capturedHash == (crypto.Hash32{}) { + return fmt.Errorf("registry bootstrap initial durable tip unavailable or height changed: %v", err) + } + if err := requireBootstrapTipUnchanged(db, capturedHash, capturedHeight); err != nil { + return err + } + return utxos.WithAnchoredReplayKeyImages(snap.UTXOs.KeyImages, func() error { + previous, found, err := db.GetCanonicalHash(snap.TipHeight) + if err != nil || !found { + return fmt.Errorf("registry bootstrap anchor index unavailable") + } + for height := snap.TipHeight + 1; height <= tip; height++ { + raw, err := db.GetRawBlockByHeight(height) + if err != nil || raw == nil { + return fmt.Errorf("registry bootstrap missing block %d", height) + } + var block core.Block + if err := json.Unmarshal(raw, &block); err != nil { + return fmt.Errorf("registry bootstrap decode block %d: %w", height, err) + } + index, found, err := db.GetCanonicalHash(height) + if err != nil || !found || block.Header.Height != height || block.Hash() != index || block.Header.PrevHash != previous || + !block.Header.VerifySignature() || block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { + return fmt.Errorf("registry bootstrap block %d fails canonical continuity/signature/Merkle checks", height) + } + if !bootstrapProposerAt(registry.GetActiveValidators(), block.Header.ValidatorPub, block.Header.Round) { + return fmt.Errorf("registry bootstrap block %d proposer is not the restored committee round-slot leader", height) + } + // Stake replay's historical helper omits collateral checks. This restricted + // operator mode does not claim to validate stake changes: reject ALL of them. + for _, tx := range block.Txs { + if tx.IsStake() { + return fmt.Errorf("registry bootstrap unsupported post-anchor stake transaction at %d", height) + } + } + if err := utxos.ApplyBlock(&block); err != nil { + return fmt.Errorf("registry bootstrap state replay at %d: %w", height, err) + } + if height%core.EpochLength == 0 { + registry.UpdateEpoch(height) + } + previous = index + } + if err := guardLPoDConsensusAuthorities(registry.GetActiveValidators()); err != nil { + return err + } + if previous != capturedHash { + return fmt.Errorf("registry bootstrap replay endpoint differs from captured durable tip") + } + if err := validateBootstrapTip(db, utxos, snap, log); err != nil { + return err + } + if err := requireBootstrapTipUnchanged(db, capturedHash, capturedHeight); err != nil { + return err + } + log.Info("operator-attested replay complete; key-image set matches durable tip; active-state completeness derives from pinned anchor and replay, not legacy disk indexes", "tip_height", tip, "active_utxos", utxos.Count(), + "trust", "operator_attested", "committee_authenticated", false, "quorum_authenticated", false, "production_ready", false) + return nil + }) } func requireBootstrapTipUnchanged(db *store.DB, hash crypto.Hash32, height uint64) error { -current,currentHeight,err:=db.GetTip() -if err!=nil { return fmt.Errorf("registry bootstrap read durable tip: %w",err) } -if current!=hash || currentHeight!=height { return fmt.Errorf("registry bootstrap durable tip changed during replay") } -indexed,found,err:=db.GetCanonicalHash(height) -if err!=nil { return fmt.Errorf("registry bootstrap read tip index: %w",err) } -if !found || indexed!=hash { return fmt.Errorf("registry bootstrap captured tip no longer matches canonical index") } -return nil + current, currentHeight, err := db.GetTip() + if err != nil { + return fmt.Errorf("registry bootstrap read durable tip: %w", err) + } + if current != hash || currentHeight != height { + return fmt.Errorf("registry bootstrap durable tip changed during replay") + } + indexed, found, err := db.GetCanonicalHash(height) + if err != nil { + return fmt.Errorf("registry bootstrap read tip index: %w", err) + } + if !found || indexed != hash { + return fmt.Errorf("registry bootstrap captured tip no longer matches canonical index") + } + return nil } func bootstrapProposerAt(active []crypto.ValidatorPubKey, proposer crypto.ValidatorPubKey, round uint32) bool { -// GetActiveValidators supplies the same deterministic ordering as live engine. -return len(active)>0 && active[int(round)%len(active)].Equals(proposer) + // GetActiveValidators supplies the same deterministic ordering as live engine. + return len(active) > 0 && active[int(round)%len(active)].Equals(proposer) } // Decode large UTXO arrays one entry at a time. A single Decode of the entire // snapshot buffers gigabytes of JSON in addition to the resulting state. func decodeBootstrapObject(d *json.Decoder, field func(string) error) error { - token, err := d.Token() - if err != nil { return err } - if token != json.Delim('{') { return fmt.Errorf("expected snapshot object") } - seen := make(map[string]bool) - for d.More() { -if len(seen) >= 4096 { return fmt.Errorf("snapshot object exceeds field bound") } - token, err := d.Token() - if err != nil { return err } - key, ok := token.(string) - if !ok || seen[key] { return fmt.Errorf("invalid or duplicate snapshot field") } - seen[key] = true - if err := field(key); err != nil { return err } - } - _, err = d.Token() - return err + token, err := d.Token() + if err != nil { + return err + } + if token != json.Delim('{') { + return fmt.Errorf("expected snapshot object") + } + seen := make(map[string]bool) + for d.More() { + if len(seen) >= 4096 { + return fmt.Errorf("snapshot object exceeds field bound") + } + token, err := d.Token() + if err != nil { + return err + } + key, ok := token.(string) + if !ok || seen[key] { + return fmt.Errorf("invalid or duplicate snapshot field") + } + seen[key] = true + if err := field(key); err != nil { + return err + } + } + _, err = d.Token() + return err } func decodeBootstrapArray[T any](d *json.Decoder, dst *[]T) error { -return decodeBootstrapArrayBounded(d,dst,bootstrapMaxArrayEntries) + return decodeBootstrapArrayBounded(d, dst, bootstrapMaxArrayEntries) } func decodeBootstrapArrayBounded[T any](d *json.Decoder, dst *[]T, limit int) error { - token, err := d.Token() - if err != nil { return err } - if token == nil { return nil } - if token != json.Delim('[') { return fmt.Errorf("expected snapshot array") } - for d.More() { -if len(*dst) >= limit { return fmt.Errorf("snapshot array exceeds entry bound") } - var entry T - if err := d.Decode(&entry); err != nil { return err } - *dst = append(*dst, entry) - } - _, err = d.Token() - return err + token, err := d.Token() + if err != nil { + return err + } + if token == nil { + return nil + } + if token != json.Delim('[') { + return fmt.Errorf("expected snapshot array") + } + for d.More() { + if len(*dst) >= limit { + return fmt.Errorf("snapshot array exceeds entry bound") + } + var entry T + if err := d.Decode(&entry); err != nil { + return err + } + *dst = append(*dst, entry) + } + _, err = d.Token() + return err } func decodeBootstrapSnapshot(d *json.Decoder, snap *startupSnapshot) error { -seen := make(map[string]bool) -err := decodeBootstrapObject(d, func(key string) error { -seen[key] = true - switch key { - case "v": return d.Decode(&snap.Version) - case "tip_height": return d.Decode(&snap.TipHeight) - case "tip_hash": return d.Decode(&snap.TipHashHex) - case "tx_total": return d.Decode(&snap.TxTotal) - case "saved_at": return d.Decode(&snap.SavedAt) - case "registry": return decodeBootstrapRegistry(d, &snap.Registry) - case "utxos": - return decodeBootstrapUTXOObject(d, snap) - default: return fmt.Errorf("unknown snapshot field %q", key) - } - }) -if err != nil { return err } -for _, key := range []string{"v","tip_height","tip_hash","utxos","registry"} { -if !seen[key] { return fmt.Errorf("missing required snapshot field %q",key) } -} -return nil + seen := make(map[string]bool) + err := decodeBootstrapObject(d, func(key string) error { + seen[key] = true + switch key { + case "v": + return d.Decode(&snap.Version) + case "tip_height": + return d.Decode(&snap.TipHeight) + case "tip_hash": + return d.Decode(&snap.TipHashHex) + case "tx_total": + return d.Decode(&snap.TxTotal) + case "saved_at": + return d.Decode(&snap.SavedAt) + case "registry": + return decodeBootstrapRegistry(d, &snap.Registry) + case "utxos": + return decodeBootstrapUTXOObject(d, snap) + default: + return fmt.Errorf("unknown snapshot field %q", key) + } + }) + if err != nil { + return err + } + for _, key := range []string{"v", "tip_height", "tip_hash", "utxos", "registry"} { + if !seen[key] { + return fmt.Errorf("missing required snapshot field %q", key) + } + } + return nil } func decodeBootstrapRegistry(d *json.Decoder, registry *core.RegistrySnapshot) error { -seen:=make(map[string]bool) -err:=decodeBootstrapObject(d,func(key string) error { -seen[key]=true -switch key { -case "dynamic_min_napr": return d.Decode(®istry.DynamicMinNAPR) -case "validators": -registry.Validators=make(map[string]*core.ValidatorEntry) -return decodeBootstrapObject(d,func(key string) error { -var entry *core.ValidatorEntry -if err:=d.Decode(&entry); err!=nil { return err } -registry.Validators[key]=entry -return nil -}) -default:return fmt.Errorf("unknown registry field %q",key) -} -}) -if err!=nil { return err } -if !seen["validators"] || !seen["dynamic_min_napr"] { return fmt.Errorf("missing required registry fields") } -return nil + seen := make(map[string]bool) + err := decodeBootstrapObject(d, func(key string) error { + seen[key] = true + switch key { + case "dynamic_min_napr": + return d.Decode(®istry.DynamicMinNAPR) + case "validators": + registry.Validators = make(map[string]*core.ValidatorEntry) + return decodeBootstrapObject(d, func(key string) error { + var entry *core.ValidatorEntry + if err := d.Decode(&entry); err != nil { + return err + } + registry.Validators[key] = entry + return nil + }) + default: + return fmt.Errorf("unknown registry field %q", key) + } + }) + if err != nil { + return err + } + if !seen["validators"] || !seen["dynamic_min_napr"] { + return fmt.Errorf("missing required registry fields") + } + return nil } func decodeBootstrapUTXOObject(d *json.Decoder, snap *startupSnapshot) error { -seen := make(map[string]bool) -err := decodeBootstrapObject(d, func(key string) error { -seen[key] = true - switch key { - case "active_utxos": return decodeBootstrapArray(d, &snap.UTXOs.ActiveUTXOs) - case "staked_utxos": return decodeBootstrapArray(d, &snap.UTXOs.StakedUTXOs) - case "spent_decoys": return decodeBootstrapArray(d, &snap.UTXOs.SpentDecoys) - case "key_images": return decodeBootstrapArray(d, &snap.UTXOs.KeyImages) - case "rollback_journal": return decodeBootstrapArray(d, &snap.UTXOs.RollbackJournal) - default: return fmt.Errorf("unknown snapshot UTXO field %q", key) - } - }) -if err != nil { return err } -for _, key := range []string{"active_utxos","staked_utxos","spent_decoys","key_images"} { -if !seen[key] { return fmt.Errorf("missing required UTXO field %q",key) } -} -return nil + seen := make(map[string]bool) + err := decodeBootstrapObject(d, func(key string) error { + seen[key] = true + switch key { + case "active_utxos": + return decodeBootstrapArray(d, &snap.UTXOs.ActiveUTXOs) + case "staked_utxos": + return decodeBootstrapArray(d, &snap.UTXOs.StakedUTXOs) + case "spent_decoys": + return decodeBootstrapArray(d, &snap.UTXOs.SpentDecoys) + case "key_images": + return decodeBootstrapArray(d, &snap.UTXOs.KeyImages) + case "rollback_journal": + return decodeBootstrapArray(d, &snap.UTXOs.RollbackJournal) + default: + return fmt.Errorf("unknown snapshot UTXO field %q", key) + } + }) + if err != nil { + return err + } + for _, key := range []string{"active_utxos", "staked_utxos", "spent_decoys", "key_images"} { + if !seen[key] { + return fmt.Errorf("missing required UTXO field %q", key) + } + } + return nil } func validateBootstrapUTXOs(snap *startupSnapshot) error { -seen := make(map[core.UTXOKey]bool) -for _, list := range [][]*core.UTXO{snap.UTXOs.ActiveUTXOs, snap.UTXOs.StakedUTXOs, snap.UTXOs.SpentDecoys} { -for _, u := range list { -if u == nil || u.BlockHeight > snap.TipHeight { return fmt.Errorf("snapshot contains null or future UTXO") } -key := core.UTXOKey{TxHash:u.TxHash, OutputIndex:u.OutputIndex} -if seen[key] { return fmt.Errorf("snapshot contains duplicate UTXO identity") } -seen[key] = true -} -} -for _, entry := range snap.UTXOs.RollbackJournal { -if entry.UTXO == nil || entry.Height > snap.TipHeight || entry.UTXO.BlockHeight > entry.Height { return fmt.Errorf("snapshot contains invalid rollback entry") } -} -return nil + seen := make(map[core.UTXOKey]bool) + for _, list := range [][]*core.UTXO{snap.UTXOs.ActiveUTXOs, snap.UTXOs.StakedUTXOs, snap.UTXOs.SpentDecoys} { + for _, u := range list { + if u == nil || u.BlockHeight > snap.TipHeight { + return fmt.Errorf("snapshot contains null or future UTXO") + } + key := core.UTXOKey{TxHash: u.TxHash, OutputIndex: u.OutputIndex} + if seen[key] { + return fmt.Errorf("snapshot contains duplicate UTXO identity") + } + seen[key] = true + } + } + for _, entry := range snap.UTXOs.RollbackJournal { + if entry.UTXO == nil || entry.Height > snap.TipHeight || entry.UTXO.BlockHeight > entry.Height { + return fmt.Errorf("snapshot contains invalid rollback entry") + } + } + return nil } // u/ is neither a complete nor an active-only index on legacy archives. Existing @@ -282,55 +407,81 @@ return nil // Completeness is the operator-attested anchor + checked replay, not a claim // about u/ or su/ cardinality. No tolerance or repair is applied to conflicts. type bootstrapTipStore interface { -IterUTXOs(func(*store.StoredUTXO) error) error -IsUTXOSpentChecked(crypto.Hash32,uint32) (bool,error) -IterKeyImages(func(crypto.KeyImage) error) error + IterUTXOs(func(*store.StoredUTXO) error) error + IsUTXOSpentChecked(crypto.Hash32, uint32) (bool, error) + IterKeyImages(func(crypto.KeyImage) error) error } func validateBootstrapTip(db bootstrapTipStore, utxos *core.UTXOSet, snap *startupSnapshot, log *slog.Logger) error { -count := 0 -matched:=make(map[core.UTXOKey]bool) -err := db.IterUTXOs(func(stored *store.StoredUTXO) error { -u := utxos.Get(stored.TxHash, stored.OutputIndex) -if u==nil { return nil } // historical retained row, NOT an active-set claim -spent,err:=db.IsUTXOSpentChecked(stored.TxHash,stored.OutputIndex) -if err!=nil { return fmt.Errorf("replayed active UTXO spent-marker lookup: %w",err) } -if spent { return fmt.Errorf("replayed active UTXO conflicts with durable spent marker") } -if u.OneTimePub != stored.OneTimePub || u.TxPubKey != stored.TxPubKey || -u.AmountCommit != stored.AmountCommit || u.EncAmount != stored.EncAmount || -u.BlockHeight != stored.BlockHeight || u.ProtocolLocked != stored.ProtocolLocked { -return fmt.Errorf("registry bootstrap replayed active UTXO differs from durable tip: tx=%x index=%d stored_height=%d absent_from_replay=%t",stored.TxHash,stored.OutputIndex,stored.BlockHeight,u==nil) -} -count++ -key:=core.UTXOKey{TxHash:stored.TxHash,OutputIndex:stored.OutputIndex} -if matched[key] { return fmt.Errorf("duplicate durable UTXO identity") } -matched[key]=true -return nil -}) -if err != nil { return err } -anchorOnly:=0 -for _,original:=range snap.UTXOs.ActiveUTXOs { -key:=core.UTXOKey{TxHash:original.TxHash,OutputIndex:original.OutputIndex} -if matched[key] { continue } -current:=utxos.Get(original.TxHash,original.OutputIndex) -if current==nil { continue } // consumed by a checked post-anchor block -if original.BlockHeight>snap.TipHeight || *original!=*current { -return fmt.Errorf("missing durable UTXO is not identical to pinned pre-anchor state") -} -spent,err:=db.IsUTXOSpentChecked(original.TxHash,original.OutputIndex) -if err!=nil { return fmt.Errorf("pinned active UTXO spent-marker lookup: %w",err) } -if spent { return fmt.Errorf("pinned active UTXO conflicts with durable spent marker") } -anchorOnly++ + count := 0 + matched := make(map[core.UTXOKey]bool) + err := db.IterUTXOs(func(stored *store.StoredUTXO) error { + u := utxos.Get(stored.TxHash, stored.OutputIndex) + if u == nil { + return nil + } // historical retained row, NOT an active-set claim + spent, err := db.IsUTXOSpentChecked(stored.TxHash, stored.OutputIndex) + if err != nil { + return fmt.Errorf("replayed active UTXO spent-marker lookup: %w", err) + } + if spent { + return fmt.Errorf("replayed active UTXO conflicts with durable spent marker") + } + if u.OneTimePub != stored.OneTimePub || u.TxPubKey != stored.TxPubKey || + u.AmountCommit != stored.AmountCommit || u.EncAmount != stored.EncAmount || + u.BlockHeight != stored.BlockHeight || u.ProtocolLocked != stored.ProtocolLocked { + return fmt.Errorf("registry bootstrap replayed active UTXO differs from durable tip: tx=%x index=%d stored_height=%d absent_from_replay=%t", stored.TxHash, stored.OutputIndex, stored.BlockHeight, u == nil) + } + count++ + key := core.UTXOKey{TxHash: stored.TxHash, OutputIndex: stored.OutputIndex} + if matched[key] { + return fmt.Errorf("duplicate durable UTXO identity") + } + matched[key] = true + return nil + }) + if err != nil { + return err + } + anchorOnly := 0 + for _, original := range snap.UTXOs.ActiveUTXOs { + key := core.UTXOKey{TxHash: original.TxHash, OutputIndex: original.OutputIndex} + if matched[key] { + continue + } + current := utxos.Get(original.TxHash, original.OutputIndex) + if current == nil { + continue + } // consumed by a checked post-anchor block + if original.BlockHeight > snap.TipHeight || *original != *current { + return fmt.Errorf("missing durable UTXO is not identical to pinned pre-anchor state") + } + spent, err := db.IsUTXOSpentChecked(original.TxHash, original.OutputIndex) + if err != nil { + return fmt.Errorf("pinned active UTXO spent-marker lookup: %w", err) + } + if spent { + return fmt.Errorf("pinned active UTXO conflicts with durable spent marker") + } + anchorOnly++ + } + if count+anchorOnly != utxos.Count() { + return fmt.Errorf("replayed active state contains outputs backed by neither durable rows nor the pinned anchor") + } + log.Warn("operator-attested UTXO validation: legacy disk index is not an independent active-set count", + "active_count", utxos.Count(), "durable_rows_matched", count, "pinned_anchor_only", anchorOnly) + keyImages := 0 + if err := db.IterKeyImages(func(image crypto.KeyImage) error { + if !utxos.IsSpent(image) { + return fmt.Errorf("registry bootstrap durable tip key image absent from anchor+replay") + } + keyImages++ + return nil + }); err != nil { + return err + } + if keyImages != utxos.KeyImagesCount() { + return fmt.Errorf("registry bootstrap anchor+replay key-image count differs from durable tip") + } + return nil } -if count+anchorOnly!=utxos.Count() { return fmt.Errorf("replayed active state contains outputs backed by neither durable rows nor the pinned anchor") } -log.Warn("operator-attested UTXO validation: legacy disk index is not an independent active-set count", -"active_count",utxos.Count(),"durable_rows_matched",count,"pinned_anchor_only",anchorOnly) -keyImages := 0 -if err := db.IterKeyImages(func(image crypto.KeyImage) error { -if !utxos.IsSpent(image) { return fmt.Errorf("registry bootstrap durable tip key image absent from anchor+replay") } -keyImages++ -return nil -}); err != nil { return err } -if keyImages != utxos.KeyImagesCount() { return fmt.Errorf("registry bootstrap anchor+replay key-image count differs from durable tip") } -return nil -} \ No newline at end of file diff --git a/cmd/node/registry_bootstrap_keyimages.go b/cmd/node/registry_bootstrap_keyimages.go index f24f42e..72681ff 100644 --- a/cmd/node/registry_bootstrap_keyimages.go +++ b/cmd/node/registry_bootstrap_keyimages.go @@ -1,60 +1,82 @@ package main import ( -"bytes" -"crypto/sha256" -"encoding/hex" -"encoding/json" -"fmt" -"io" -"os" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" -"github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) type bootstrapKeyImageAnchor struct { -Height uint64 `json:"height"` -BlockHash string `json:"block_hash"` -KeyImages []crypto.KeyImage `json:"key_images"` + Height uint64 `json:"height"` + BlockHash string `json:"block_hash"` + KeyImages []crypto.KeyImage `json:"key_images"` } // This file is an operator-attested public state export, not a signature or an // independently authenticated historical spend proof. The operator must derive // it from a preserved copied DB whose durable tip is exactly this anchor. -func loadBootstrapKeyImageAnchor(path,digest string,height uint64,hash string)([]crypto.KeyImage,error) { -expected,err:=hex.DecodeString(digest) -if err!=nil || len(expected)!=32 { return nil,fmt.Errorf("key-image anchor requires SHA-256") } -f,err:=os.Open(path);if err!=nil { return nil,err };defer f.Close() -const maxBytes=64<<20 -info,err:=f.Stat() -if err!=nil || !info.Mode().IsRegular() || info.Size()>maxBytes { return nil,fmt.Errorf("key-image anchor must be a regular file <=64 MiB") } -raw,err:=io.ReadAll(io.LimitReader(f,maxBytes+1)) -if err!=nil || len(raw)>maxBytes { return nil,fmt.Errorf("key-image anchor read failed or exceeds 64 MiB") } -actual:=sha256.Sum256(raw) -if !bytes.Equal(actual[:],expected) { return nil,fmt.Errorf("key-image anchor digest mismatch") } -var anchor bootstrapKeyImageAnchor -d:=json.NewDecoder(bytes.NewReader(raw)) -seen:=make(map[string]bool) -err=decodeBootstrapObject(d,func(key string)error{ -seen[key]=true -switch key { -case "height":return d.Decode(&anchor.Height) -case "block_hash":return d.Decode(&anchor.BlockHash) -case "key_images":return decodeBootstrapArrayBounded(d,&anchor.KeyImages,250000) -default:return fmt.Errorf("unknown key-image anchor field %q",key) +func loadBootstrapKeyImageAnchor(path, digest string, height uint64, hash string) ([]crypto.KeyImage, error) { + expected, err := hex.DecodeString(digest) + if err != nil || len(expected) != 32 { + return nil, fmt.Errorf("key-image anchor requires SHA-256") + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + const maxBytes = 64 << 20 + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > maxBytes { + return nil, fmt.Errorf("key-image anchor must be a regular file <=64 MiB") + } + raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) + if err != nil || len(raw) > maxBytes { + return nil, fmt.Errorf("key-image anchor read failed or exceeds 64 MiB") + } + actual := sha256.Sum256(raw) + if !bytes.Equal(actual[:], expected) { + return nil, fmt.Errorf("key-image anchor digest mismatch") + } + var anchor bootstrapKeyImageAnchor + d := json.NewDecoder(bytes.NewReader(raw)) + seen := make(map[string]bool) + err = decodeBootstrapObject(d, func(key string) error { + seen[key] = true + switch key { + case "height": + return d.Decode(&anchor.Height) + case "block_hash": + return d.Decode(&anchor.BlockHash) + case "key_images": + return decodeBootstrapArrayBounded(d, &anchor.KeyImages, 250000) + default: + return fmt.Errorf("unknown key-image anchor field %q", key) + } + }) + if err != nil { + return nil, err + } + if err := d.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("key-image anchor trailing data") + } + if !seen["height"] || !seen["block_hash"] || !seen["key_images"] || + anchor.Height != height || anchor.BlockHash != hash || len(anchor.KeyImages) > 250000 { + return nil, fmt.Errorf("key-image anchor required fields/height/hash/count mismatch") + } + images := make(map[crypto.KeyImage]bool, len(anchor.KeyImages)) + for _, image := range anchor.KeyImages { + canonical, err := crypto.CanonicalKeyImage(image) + if err != nil || canonical != image || images[image] { + return nil, fmt.Errorf("key-image anchor contains invalid or duplicate image") + } + images[image] = true + } + return anchor.KeyImages, nil } -}) -if err!=nil { return nil,err } -if err:=d.Decode(new(any));err!=io.EOF { return nil,fmt.Errorf("key-image anchor trailing data") } -if !seen["height"] || !seen["block_hash"] || !seen["key_images"] || -anchor.Height!=height || anchor.BlockHash!=hash || len(anchor.KeyImages)>250000 { -return nil,fmt.Errorf("key-image anchor required fields/height/hash/count mismatch") -} -images:=make(map[crypto.KeyImage]bool,len(anchor.KeyImages)) -for _,image:=range anchor.KeyImages { -canonical,err:=crypto.CanonicalKeyImage(image) -if err!=nil || canonical!=image || images[image] { return nil,fmt.Errorf("key-image anchor contains invalid or duplicate image") } -images[image]=true -} -return anchor.KeyImages,nil -} \ No newline at end of file diff --git a/cmd/node/registry_bootstrap_keyimages_test.go b/cmd/node/registry_bootstrap_keyimages_test.go index 89f76b5..060f9fa 100644 --- a/cmd/node/registry_bootstrap_keyimages_test.go +++ b/cmd/node/registry_bootstrap_keyimages_test.go @@ -1,56 +1,99 @@ package main import ( -"crypto/sha256" -"encoding/json" -"fmt" -"os" -"path/filepath" -"testing" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" -"github.com/aperod/aperod/crypto" -"github.com/aperod/aperod/store" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) func TestBootstrapKeyImageAnchorBinding(t *testing.T) { -for _,variant:=range []string{"valid","height","hash","digest","missing","duplicate","invalid-image"} { -t.Run(variant,func(t *testing.T){ -anchor:=bootstrapKeyImageAnchor{Height:10,BlockHash:"abc",KeyImages:[]crypto.KeyImage{}} -switch variant { -case "height":anchor.Height++ -case "hash":anchor.BlockHash="wrong" -case "invalid-image":anchor.KeyImages=[]crypto.KeyImage{{}} -} -raw,err:=json.Marshal(anchor);if err!=nil { t.Fatal(err) } -if variant=="missing" { raw=[]byte(`{"height":10,"block_hash":"abc"}`) } -if variant=="duplicate" { raw=[]byte(`{"height":10,"height":10,"block_hash":"abc","key_images":[]}`) } -path:=filepath.Join(t.TempDir(),"anchor.json") -if err:=os.WriteFile(path,raw,0600);err!=nil { t.Fatal(err) } -digest:=fmt.Sprintf("%x",sha256.Sum256(raw)) -if variant=="digest" { digest=fmt.Sprintf("%064x",1) } -_,err=loadBootstrapKeyImageAnchor(path,digest,10,"abc") -if variant=="valid" { if err!=nil { t.Fatal(err) } } else if err==nil { t.Fatal("invalid anchor accepted") } -}) -} + for _, variant := range []string{"valid", "height", "hash", "digest", "missing", "duplicate", "invalid-image"} { + t.Run(variant, func(t *testing.T) { + anchor := bootstrapKeyImageAnchor{Height: 10, BlockHash: "abc", KeyImages: []crypto.KeyImage{}} + switch variant { + case "height": + anchor.Height++ + case "hash": + anchor.BlockHash = "wrong" + case "invalid-image": + anchor.KeyImages = []crypto.KeyImage{{}} + } + raw, err := json.Marshal(anchor) + if err != nil { + t.Fatal(err) + } + if variant == "missing" { + raw = []byte(`{"height":10,"block_hash":"abc"}`) + } + if variant == "duplicate" { + raw = []byte(`{"height":10,"height":10,"block_hash":"abc","key_images":[]}`) + } + path := filepath.Join(t.TempDir(), "anchor.json") + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + digest := fmt.Sprintf("%x", sha256.Sum256(raw)) + if variant == "digest" { + digest = fmt.Sprintf("%064x", 1) + } + _, err = loadBootstrapKeyImageAnchor(path, digest, 10, "abc") + if variant == "valid" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("invalid anchor accepted") + } + }) + } } // Explicit diagnostic export from an existing COPY, never a live database. // No membership/signature is generated; only existing public spent images. func TestBootstrapExportCopiedAnchorKeyImages(t *testing.T) { -source,output:=os.Getenv("LPOD_ANCHOR_EXPORT_DB"),os.Getenv("LPOD_ANCHOR_EXPORT_FILE") -if source=="" || output=="" { t.Skip("explicit copied DB and output required") } -db,err:=store.OpenReadOnly(source);if err!=nil { t.Fatal(err) };defer db.Close() -hash,height,err:=db.GetTip();if err!=nil { t.Fatal(err) } -if height!=2492066 || fmt.Sprintf("%x",hash)!="cc84407987fc753350d84b8e38d5b3b67dec0c8374e58f11ca166d8498d2364c" { -t.Fatal("copied DB is not the exact known snapshot anchor") + source, output := os.Getenv("LPOD_ANCHOR_EXPORT_DB"), os.Getenv("LPOD_ANCHOR_EXPORT_FILE") + if source == "" || output == "" { + t.Skip("explicit copied DB and output required") + } + db, err := store.OpenReadOnly(source) + if err != nil { + t.Fatal(err) + } + defer db.Close() + hash, height, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + if height != 2492066 || fmt.Sprintf("%x", hash) != "cc84407987fc753350d84b8e38d5b3b67dec0c8374e58f11ca166d8498d2364c" { + t.Fatal("copied DB is not the exact known snapshot anchor") + } + anchor := bootstrapKeyImageAnchor{Height: height, BlockHash: fmt.Sprintf("%x", hash), KeyImages: []crypto.KeyImage{}} + if err := db.IterKeyImages(func(image crypto.KeyImage) error { anchor.KeyImages = append(anchor.KeyImages, image); return nil }); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(anchor) + if err != nil { + t.Fatal(err) + } + // O_EXCL prevents accidentally replacing previously pinned evidence. + f, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + t.Fatal(err) + } + _, err = f.Write(raw) + closeErr := f.Close() + if err != nil || closeErr != nil { + t.Fatalf("write %v/%v", err, closeErr) + } + digest := fmt.Sprintf("%x", sha256.Sum256(raw)) + if _, err := loadBootstrapKeyImageAnchor(output, digest, height, anchor.BlockHash); err != nil { + t.Fatal(err) + } + t.Logf("copied anchor height=%d hash=%s key_images=%d export_sha256=%s", height, anchor.BlockHash, len(anchor.KeyImages), digest) } -anchor:=bootstrapKeyImageAnchor{Height:height,BlockHash:fmt.Sprintf("%x",hash),KeyImages:[]crypto.KeyImage{}} -if err:=db.IterKeyImages(func(image crypto.KeyImage)error{anchor.KeyImages=append(anchor.KeyImages,image);return nil});err!=nil { t.Fatal(err) } -raw,err:=json.Marshal(anchor);if err!=nil { t.Fatal(err) } -// O_EXCL prevents accidentally replacing previously pinned evidence. -f,err:=os.OpenFile(output,os.O_WRONLY|os.O_CREATE|os.O_EXCL,0600);if err!=nil { t.Fatal(err) } -_,err=f.Write(raw);closeErr:=f.Close();if err!=nil || closeErr!=nil { t.Fatalf("write %v/%v",err,closeErr) } -digest:=fmt.Sprintf("%x",sha256.Sum256(raw)) -if _,err:=loadBootstrapKeyImageAnchor(output,digest,height,anchor.BlockHash);err!=nil { t.Fatal(err) } -t.Logf("copied anchor height=%d hash=%s key_images=%d export_sha256=%s",height,anchor.BlockHash,len(anchor.KeyImages),digest) -} \ No newline at end of file diff --git a/cmd/node/registry_bootstrap_storage_test.go b/cmd/node/registry_bootstrap_storage_test.go index 49643fa..1394abe 100644 --- a/cmd/node/registry_bootstrap_storage_test.go +++ b/cmd/node/registry_bootstrap_storage_test.go @@ -1,60 +1,94 @@ package main import ( -"errors" -"testing" + "errors" + "testing" -"github.com/aperod/aperod/core" -"github.com/aperod/aperod/crypto" -"github.com/aperod/aperod/store" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) type failingBootstrapSpentLookup struct { -*store.DB -err error -calls int + *store.DB + err error + calls int } -func (s *failingBootstrapSpentLookup) IsUTXOSpentChecked(crypto.Hash32,uint32)(bool,error) { -s.calls++ -return false,s.err + +func (s *failingBootstrapSpentLookup) IsUTXOSpentChecked(crypto.Hash32, uint32) (bool, error) { + s.calls++ + return false, s.err } func TestBootstrapSpentLookupErrorsFailClosed(t *testing.T) { -for _,durableRow:=range []bool{false,true} { -name:="anchor-only" -if durableRow { name="durable-row" } -t.Run(name,func(t *testing.T){ -db,err:=store.Open(t.TempDir());if err!=nil { t.Fatal(err) };defer db.Close() -u:=&core.UTXO{TxHash:crypto.HashStr("lookup-error"),BlockHeight:1} -snap:=&startupSnapshot{TipHeight:1,UTXOs:core.UTXOSnapshot{ActiveUTXOs:[]*core.UTXO{u}}} -utxos:=core.NewUTXOSet();utxos.RestoreFromSnapshot(snap.UTXOs) -if durableRow { -if err:=db.PutUTXO(u.TxHash,0,&store.StoredUTXO{TxHash:u.TxHash,BlockHeight:1});err!=nil { t.Fatal(err) } -} -sentinel:=errors.New("injected spent-marker storage failure") -faulty:=&failingBootstrapSpentLookup{DB:db,err:sentinel} -if err:=validateBootstrapTip(faulty,utxos,snap,silentLog());!errors.Is(err,sentinel) { t.Fatalf("lookup error not propagated: %v",err) } -if faulty.calls!=1 { t.Fatalf("expected lookup in %s path; got %d",name,faulty.calls) } -}) -} + for _, durableRow := range []bool{false, true} { + name := "anchor-only" + if durableRow { + name = "durable-row" + } + t.Run(name, func(t *testing.T) { + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + u := &core.UTXO{TxHash: crypto.HashStr("lookup-error"), BlockHeight: 1} + snap := &startupSnapshot{TipHeight: 1, UTXOs: core.UTXOSnapshot{ActiveUTXOs: []*core.UTXO{u}}} + utxos := core.NewUTXOSet() + utxos.RestoreFromSnapshot(snap.UTXOs) + if durableRow { + if err := db.PutUTXO(u.TxHash, 0, &store.StoredUTXO{TxHash: u.TxHash, BlockHeight: 1}); err != nil { + t.Fatal(err) + } + } + sentinel := errors.New("injected spent-marker storage failure") + faulty := &failingBootstrapSpentLookup{DB: db, err: sentinel} + if err := validateBootstrapTip(faulty, utxos, snap, silentLog()); !errors.Is(err, sentinel) { + t.Fatalf("lookup error not propagated: %v", err) + } + if faulty.calls != 1 { + t.Fatalf("expected lookup in %s path; got %d", name, faulty.calls) + } + }) + } } func TestBootstrapCapturedTipBinding(t *testing.T) { -for _,variant:=range []string{"unchanged","height","hash","index","closed"} { -t.Run(variant,func(t *testing.T){ -db,err:=store.Open(t.TempDir());if err!=nil { t.Fatal(err) };defer db.Close() -hash,other:=crypto.HashStr("captured"),crypto.HashStr("changed") -if err:=db.PutRawBlock(hash,10,[]byte("{}"));err!=nil { t.Fatal(err) } -if err:=db.PutTip(hash,10);err!=nil { t.Fatal(err) } -switch variant { -case "height":err=db.PutTip(hash,11) -case "hash":err=db.PutTip(other,10) -case "index":err=db.PutRawBlock(other,10,[]byte("{}")) -case "closed":err=db.Close() -} -if err!=nil { t.Fatal(err) } -err=requireBootstrapTipUnchanged(db,hash,10) -if variant=="unchanged" { if err!=nil { t.Fatal(err) } } else if err==nil { t.Fatal("changed/unreadable tip accepted") } -}) + for _, variant := range []string{"unchanged", "height", "hash", "index", "closed"} { + t.Run(variant, func(t *testing.T) { + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + hash, other := crypto.HashStr("captured"), crypto.HashStr("changed") + if err := db.PutRawBlock(hash, 10, []byte("{}")); err != nil { + t.Fatal(err) + } + if err := db.PutTip(hash, 10); err != nil { + t.Fatal(err) + } + switch variant { + case "height": + err = db.PutTip(hash, 11) + case "hash": + err = db.PutTip(other, 10) + case "index": + err = db.PutRawBlock(other, 10, []byte("{}")) + case "closed": + err = db.Close() + } + if err != nil { + t.Fatal(err) + } + err = requireBootstrapTipUnchanged(db, hash, 10) + if variant == "unchanged" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("changed/unreadable tip accepted") + } + }) + } } -} \ No newline at end of file diff --git a/cmd/node/registry_bootstrap_test.go b/cmd/node/registry_bootstrap_test.go index 5c04563..72317e1 100644 --- a/cmd/node/registry_bootstrap_test.go +++ b/cmd/node/registry_bootstrap_test.go @@ -1,294 +1,529 @@ package main import ( - "compress/gzip" - "io" - "encoding/json" - "strings" - "crypto/sha256" - "fmt" - "os" - "testing" - "path/filepath" - "time" - "log/slog" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" - "github.com/aperod/aperod/config" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" - "github.com/aperod/aperod/store" + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) func TestBootstrapDatabaseBackedSpendReplay(t *testing.T) { -for _, variant := range []string{"tip-spend", "already-spent-at-anchor", "duplicate-gap-spend"} { -t.Run(variant, func(t *testing.T) { -db, err := store.Open(t.TempDir()); if err != nil { t.Fatal(err) }; defer db.Close() -priv,pub,err := crypto.GenerateValidatorKey(); if err != nil { t.Fatal(err) } -owner,err := crypto.GenerateWalletKeys(); if err != nil { t.Fatal(err) } -ring := make([]crypto.RingMember,crypto.RingSize) -ring[0]=owner.Spend.Public -for i:=1;i= tipHeight { - return nil - } - tipRaw, tipRawErr := db.GetRawBlockByHeight(tipHeight) - if tipRawErr != nil || tipRaw == nil { - return fmt.Errorf("tip block missing from store at height %d — database may be corrupt", tipHeight) - } - var tipBlock core.Block - if jsonErr := json.Unmarshal(tipRaw, &tipBlock); jsonErr != nil { - return fmt.Errorf("unmarshal tip block at %d: %w", tipHeight, jsonErr) - } - chain.FastForward([]*core.Block{&tipBlock}) - log.Info("tip block loaded as in-memory anchor", "height", tipHeight) - return nil + if chain.Height() >= tipHeight { + return nil + } + tipRaw, tipRawErr := db.GetRawBlockByHeight(tipHeight) + if tipRawErr != nil || tipRaw == nil { + return fmt.Errorf("tip block missing from store at height %d — database may be corrupt", tipHeight) + } + var tipBlock core.Block + if jsonErr := json.Unmarshal(tipRaw, &tipBlock); jsonErr != nil { + return fmt.Errorf("unmarshal tip block at %d: %w", tipHeight, jsonErr) + } + chain.FastForward([]*core.Block{&tipBlock}) + log.Info("tip block loaded as in-memory anchor", "height", tipHeight) + return nil } // countMissingBlocksInWindow scans the same height window as @@ -71,53 +71,53 @@ func anchorTipIfNeeded(chain tipAnchorable, db blockByHeightGetter, tipHeight ui // // maxBlocks controls the window size; pass 0 to use core.MaxInMemoryBlocks. func countMissingBlocksInWindow(db blockByHeightGetter, tipHeight uint64, maxBlocks uint64) (missingCount, firstMissing, lastMissing uint64) { - maxLoad := maxBlocks - if maxLoad == 0 { - maxLoad = uint64(core.MaxInMemoryBlocks) - } - startLoad := uint64(1) - if tipHeight >= maxLoad { - startLoad = tipHeight - maxLoad + 1 - } - for h := startLoad; h <= tipHeight; h++ { - raw, err := db.GetRawBlockByHeight(h) - if err != nil || raw == nil { - missingCount++ - if firstMissing == 0 { - firstMissing = h - } - lastMissing = h - } - } - return + maxLoad := maxBlocks + if maxLoad == 0 { + maxLoad = uint64(core.MaxInMemoryBlocks) + } + startLoad := uint64(1) + if tipHeight >= maxLoad { + startLoad = tipHeight - maxLoad + 1 + } + for h := startLoad; h <= tipHeight; h++ { + raw, err := db.GetRawBlockByHeight(h) + if err != nil || raw == nil { + missingCount++ + if firstMissing == 0 { + firstMissing = h + } + lastMissing = h + } + } + return } // loadRecentBlocksFromStore reads at most maxBlocks blocks ending at tipHeight, // skipping (via continue) any heights that are absent or corrupt in the store. // Pass maxBlocks=0 to use core.MaxInMemoryBlocks (1 000). func loadRecentBlocksFromStore(db blockByHeightGetter, tipHeight uint64, log *slog.Logger, maxBlocks ...uint64) []*core.Block { - maxLoad := uint64(core.MaxInMemoryBlocks) - if len(maxBlocks) > 0 && maxBlocks[0] > 0 { - maxLoad = maxBlocks[0] - } - startLoad := uint64(1) - if tipHeight >= maxLoad { - startLoad = tipHeight - maxLoad + 1 - } + maxLoad := uint64(core.MaxInMemoryBlocks) + if len(maxBlocks) > 0 && maxBlocks[0] > 0 { + maxLoad = maxBlocks[0] + } + startLoad := uint64(1) + if tipHeight >= maxLoad { + startLoad = tipHeight - maxLoad + 1 + } - recentBlocks := make([]*core.Block, 0, maxLoad) - for h := startLoad; h <= tipHeight; h++ { - raw, err := db.GetRawBlockByHeight(h) - if err != nil || raw == nil { - log.Warn("block missing in store during resume", "height", h) - continue // skip sparse gaps; don't abort the whole window - } - var b core.Block - if err := json.Unmarshal(raw, &b); err != nil { - log.Warn("block unmarshal failed", "height", h, "err", err) - continue - } - recentBlocks = append(recentBlocks, &b) - } - return recentBlocks + recentBlocks := make([]*core.Block, 0, maxLoad) + for h := startLoad; h <= tipHeight; h++ { + raw, err := db.GetRawBlockByHeight(h) + if err != nil || raw == nil { + log.Warn("block missing in store during resume", "height", h) + continue // skip sparse gaps; don't abort the whole window + } + var b core.Block + if err := json.Unmarshal(raw, &b); err != nil { + log.Warn("block unmarshal failed", "height", h, "err", err) + continue + } + recentBlocks = append(recentBlocks, &b) + } + return recentBlocks } diff --git a/cmd/node/scan.go b/cmd/node/scan.go index fea6b2a..a50b9f8 100644 --- a/cmd/node/scan.go +++ b/cmd/node/scan.go @@ -152,7 +152,7 @@ func runStartupScan(p startupScanParams) (startupScanResult, error) { TipHashHex: p.TipHashHex, TxTotal: p.InitTxTotal, SavedAt: time.Now(), - UTXOs: p.UTXOs.TakeSnapshot(), + UTXOs: p.UTXOs.TakeSnapshotView(), Registry: p.Registry.TakeSnapshot(), } if p.SnapshotWg != nil { @@ -201,7 +201,7 @@ func runStartupScan(p startupScanParams) (startupScanResult, error) { } else if partial := findLatestSnapshot(p.DataDir, p.TipHeight, p.Log); partial != nil { // Cross-check: verify the snapshot's recorded TipHashHex against the // actual block stored in the DB at that height. A mismatch means the - // block was reorganised (e.g. in dev/test) after the checkpoint was + // block was reorganized (e.g. in dev/test) after the checkpoint was // written; restoring stale UTXO state would cause silent corruption. // In that case we discard the checkpoint and scan from block 1. hashOK := false @@ -420,14 +420,14 @@ func runStartupScan(p startupScanParams) (startupScanResult, error) { } for i, out := range tx.Outputs { su := &store.StoredUTXO{ - TxHash: txHash, - OutputIndex: uint32(i), - OneTimePub: out.OneTimePub, - TxPubKey: out.TxPubKey, - AmountCommit: out.AmountCommit, - EncAmount: out.EncAmount, - BlockHeight: b.Header.Height, -ProtocolLocked: tx.IsGuardianFund(), + TxHash: txHash, + OutputIndex: uint32(i), + OneTimePub: out.OneTimePub, + TxPubKey: out.TxPubKey, + AmountCommit: out.AmountCommit, + EncAmount: out.EncAmount, + BlockHeight: b.Header.Height, + ProtocolLocked: tx.IsGuardianFund(), } _ = p.DB.PutUTXO(txHash, uint32(i), su) utxoCount++ @@ -454,7 +454,18 @@ ProtocolLocked: tx.IsGuardianFund(), kiCount++ _ = p.DB.MarkKeyImageSpent(inp.KeyImage) } - if !(txIdx == 0 && tx.IsCoinbase()) { + if tx.IsStake() && len(tx.Extra) == core.StakePayloadSizeV4 { + _, _, _, _, _, _, _, keyImage, _, decodeErr := core.DecodeStakeExtraV4(tx.Extra) + if decodeErr != nil { + return startupScanResult{}, fmt.Errorf("scan block %d stake key-image payload: %w", h, decodeErr) + } + p.UTXOs.MarkSpent(keyImage) + kiCount++ + if err := p.DB.MarkKeyImageSpent(keyImage); err != nil { + return startupScanResult{}, fmt.Errorf("persist stake key image at block %d: %w", h, err) + } + } + if txIdx != 0 || !tx.IsCoinbase() { txCount++ } } @@ -494,7 +505,7 @@ ProtocolLocked: tx.IsGuardianFund(), TipHashHex: fmt.Sprintf("%x", cpHashArr[:]), TxTotal: cpTxTotal, SavedAt: time.Now(), - UTXOs: p.UTXOs.TakeSnapshot(), + UTXOs: p.UTXOs.TakeSnapshotView(), Registry: p.Registry.TakeSnapshot(), } cpActiveCount := len(cpSnap.UTXOs.ActiveUTXOs) @@ -626,7 +637,7 @@ ProtocolLocked: tx.IsGuardianFund(), TipHashHex: p.TipHashHex, TxTotal: txTotal, SavedAt: time.Now(), - UTXOs: p.UTXOs.TakeSnapshot(), + UTXOs: p.UTXOs.TakeSnapshotView(), Registry: p.Registry.TakeSnapshot(), } if p.SnapshotWg != nil { diff --git a/cmd/node/scan_test.go b/cmd/node/scan_test.go index cf58e46..5ee7a44 100644 --- a/cmd/node/scan_test.go +++ b/cmd/node/scan_test.go @@ -327,9 +327,9 @@ func TestStartupScanGCCadenceSingleBlockMultipleThresholds(t *testing.T) { // after step 3, isKnownValidator() fails for every incoming block, and step 5 // times out with the chain stuck at height 10. func TestRelayNodeOldSnapshotAcceptsNewBlocks(t *testing.T) { - const chainHeight = 10 // total blocks produced by the validator - const snapAtHeight = 5 // intermediate snapshot that pre-dates registry data - const outsPerBlock = 2 // coinbase outputs per block + const chainHeight = 10 // total blocks produced by the validator + const snapAtHeight = 5 // intermediate snapshot that pre-dates registry data + const outsPerBlock = 2 // coinbase outputs per block priv, pub, err := crypto.GenerateValidatorKey() if err != nil { @@ -457,13 +457,13 @@ func TestRelayNodeOldSnapshotAcceptsNewBlocks(t *testing.T) { relayMp := core.NewMempool(core.DefaultMempoolConfig()) relayEng := consensus.NewEngine(consensus.Config{ - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: activeValidators, // seeded by the observed-producer fallback - Registry: registry, - MyKey: nil, // non-validator relay: never produces blocks + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: activeValidators, // seeded by the observed-producer fallback + Registry: registry, + MyKey: nil, // non-validator relay: never produces blocks RingCTV4ActivationHeight: ^uint64(0), - OnCanonicalBlock: noopCanonicalPersistence, + OnCanonicalBlock: noopCanonicalPersistence, }, relayChain, relayMp, discardLog()) relayEng.SetTxVerifier(core.NewTxVerifier(utxos), utxos) diff --git a/cmd/node/scan_txidx_test.go b/cmd/node/scan_txidx_test.go index c0b53b6..73490d3 100644 --- a/cmd/node/scan_txidx_test.go +++ b/cmd/node/scan_txidx_test.go @@ -5,7 +5,7 @@ package main // processes, and persists the txidx_complete_height marker so a subsequent // snapshot-fast-path restart knows those blocks are already covered. // -// Without this behaviour, admin-minted UTXOs whose blocks fall outside the +// Without this behavior, admin-minted UTXOs whose blocks fall outside the // in-memory chain window become permanently unspendable after a node restart // because getTransactionFromDisk (LookupTxIdx) finds no t/ entry and the // slower u/-store / in-memory fallbacks must cover the gap instead. @@ -519,8 +519,8 @@ func TestScan_CheckpointResume_MarkerAdvancedWhenContiguous(t *testing.T) { // // This matters in production: after --repair-db some heights may have their // height-index entry (b/ prefix) intact but the corresponding raw-block data -// (r/ prefix) absent. The old behaviour halted permanently at such a height; -// the new behaviour skips the gap and indexes the remaining chain so the tx +// (r/ prefix) absent. The old behavior halted permanently at such a height; +// the new behavior skips the gap and indexes the remaining chain so the tx // index marker eventually reaches tipHeight. func TestBackfillTxIdxRange_SkipsMissingBlockData(t *testing.T) { t.Parallel() diff --git a/cmd/node/shutdown_save_log_test.go b/cmd/node/shutdown_save_log_test.go index 0dbc31e..7d4ce1a 100644 --- a/cmd/node/shutdown_save_log_test.go +++ b/cmd/node/shutdown_save_log_test.go @@ -11,8 +11,8 @@ package main // the failure was logged via slog at WARN level, captured with the JSON handler. import ( - "bytes" "bufio" + "bytes" "encoding/json" "os" "path/filepath" diff --git a/cmd/node/sighup_reload_test.go b/cmd/node/sighup_reload_test.go index 2e83283..03b26dd 100644 --- a/cmd/node/sighup_reload_test.go +++ b/cmd/node/sighup_reload_test.go @@ -6,29 +6,29 @@ package main // call via startupScanParams.CheckpointInterval. import ( - "bytes" - "fmt" - "os" - "path/filepath" - "testing" + "bytes" + "fmt" + "os" + "path/filepath" + "testing" - "github.com/aperod/aperod/config" + "github.com/aperod/aperod/config" ) // writeIntervalConfig writes a minimal node.yaml with the given // scan_checkpoint_interval to cfgPath. func writeIntervalConfig(t *testing.T, cfgPath string, interval uint64) { - t.Helper() - content := fmt.Sprintf(`network: testnet + t.Helper() + content := fmt.Sprintf(`network: testnet data_dir: ./data consensus: block_time: 1s snapshot: scan_checkpoint_interval: %d `, interval) - if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { - t.Fatalf("writeIntervalConfig: %v", err) - } + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatalf("writeIntervalConfig: %v", err) + } } // TestReloadScanCheckpointInterval_UpdatesInMemoryValue verifies that after @@ -37,126 +37,126 @@ snapshot: // This is the value that would be forwarded to startupScanParams.CheckpointInterval // on the next runStartupScan call. func TestReloadScanCheckpointInterval_UpdatesInMemoryValue(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "node.yaml") - - // Write initial config and load it. - writeIntervalConfig(t, cfgPath, 25000) - cfg, err := config.Load(cfgPath) - if err != nil { - t.Fatalf("initial Load: %v", err) - } - if cfg.Snapshot.ScanCheckpointInterval != 25000 { - t.Fatalf("pre-condition: expected ScanCheckpointInterval=25000, got %d", - cfg.Snapshot.ScanCheckpointInterval) - } - - // Simulate operator editing node.yaml. - writeIntervalConfig(t, cfgPath, 75000) - - // Simulate SIGHUP: reload. - var buf bytes.Buffer - log := newCaptureLogger(&buf) - reloadScanCheckpointInterval(cfgPath, cfg, log) - - // cfg must now carry the new value. - if cfg.Snapshot.ScanCheckpointInterval != 75000 { - t.Errorf("after reload: expected ScanCheckpointInterval=75000, got %d", - cfg.Snapshot.ScanCheckpointInterval) - } - - // A structured log entry must confirm the change. - if !logContainsMsg(&buf, "SIGHUP: ScanCheckpointInterval reloaded") { - t.Errorf("expected reload log message not found; log output:\n%s", buf.String()) - } + dir := t.TempDir() + cfgPath := filepath.Join(dir, "node.yaml") + + // Write initial config and load it. + writeIntervalConfig(t, cfgPath, 25000) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("initial Load: %v", err) + } + if cfg.Snapshot.ScanCheckpointInterval != 25000 { + t.Fatalf("pre-condition: expected ScanCheckpointInterval=25000, got %d", + cfg.Snapshot.ScanCheckpointInterval) + } + + // Simulate operator editing node.yaml. + writeIntervalConfig(t, cfgPath, 75000) + + // Simulate SIGHUP: reload. + var buf bytes.Buffer + log := newCaptureLogger(&buf) + reloadScanCheckpointInterval(cfgPath, cfg, log) + + // cfg must now carry the new value. + if cfg.Snapshot.ScanCheckpointInterval != 75000 { + t.Errorf("after reload: expected ScanCheckpointInterval=75000, got %d", + cfg.Snapshot.ScanCheckpointInterval) + } + + // A structured log entry must confirm the change. + if !logContainsMsg(&buf, "SIGHUP: ScanCheckpointInterval reloaded") { + t.Errorf("expected reload log message not found; log output:\n%s", buf.String()) + } } // TestReloadScanCheckpointInterval_ReloadedValueFlowsToScanParams verifies that // the updated cfg.Snapshot.ScanCheckpointInterval is what would be forwarded as // startupScanParams.CheckpointInterval — the field runStartupScan reads. func TestReloadScanCheckpointInterval_ReloadedValueFlowsToScanParams(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "node.yaml") - - writeIntervalConfig(t, cfgPath, 10000) - cfg, err := config.Load(cfgPath) - if err != nil { - t.Fatalf("Load: %v", err) - } - - // Reload with a new value. - writeIntervalConfig(t, cfgPath, 100000) - var buf bytes.Buffer - reloadScanCheckpointInterval(cfgPath, cfg, newCaptureLogger(&buf)) - - // Construct the params the same way main.go does (line ~637): - // CheckpointInterval: cfg.Snapshot.ScanCheckpointInterval, - params := startupScanParams{ - CheckpointInterval: cfg.Snapshot.ScanCheckpointInterval, - } - if params.CheckpointInterval != 100000 { - t.Errorf("scan params: expected CheckpointInterval=100000 after reload, got %d", - params.CheckpointInterval) - } + dir := t.TempDir() + cfgPath := filepath.Join(dir, "node.yaml") + + writeIntervalConfig(t, cfgPath, 10000) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + + // Reload with a new value. + writeIntervalConfig(t, cfgPath, 100000) + var buf bytes.Buffer + reloadScanCheckpointInterval(cfgPath, cfg, newCaptureLogger(&buf)) + + // Construct the params the same way main.go does (line ~637): + // CheckpointInterval: cfg.Snapshot.ScanCheckpointInterval, + params := startupScanParams{ + CheckpointInterval: cfg.Snapshot.ScanCheckpointInterval, + } + if params.CheckpointInterval != 100000 { + t.Errorf("scan params: expected CheckpointInterval=100000 after reload, got %d", + params.CheckpointInterval) + } } // TestReloadScanCheckpointInterval_BadConfigKeepsOldValue verifies that a // parse error leaves the existing ScanCheckpointInterval unchanged so a // typo in node.yaml cannot silently reset the interval to the default. func TestReloadScanCheckpointInterval_BadConfigKeepsOldValue(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "node.yaml") - - // Write a malformed YAML file. - if err := os.WriteFile(cfgPath, []byte("network: [unclosed\n"), 0644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - cfg := config.DefaultConfig() - cfg.Snapshot.ScanCheckpointInterval = 30000 - - var buf bytes.Buffer - log := newCaptureLogger(&buf) - reloadScanCheckpointInterval(cfgPath, cfg, log) - - if cfg.Snapshot.ScanCheckpointInterval != 30000 { - t.Errorf("bad config: expected ScanCheckpointInterval unchanged at 30000, got %d", - cfg.Snapshot.ScanCheckpointInterval) - } - if !logContainsMsg(&buf, "SIGHUP: config reload failed — keeping current ScanCheckpointInterval") { - t.Errorf("expected failure log message not found; log output:\n%s", buf.String()) - } + dir := t.TempDir() + cfgPath := filepath.Join(dir, "node.yaml") + + // Write a malformed YAML file. + if err := os.WriteFile(cfgPath, []byte("network: [unclosed\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg := config.DefaultConfig() + cfg.Snapshot.ScanCheckpointInterval = 30000 + + var buf bytes.Buffer + log := newCaptureLogger(&buf) + reloadScanCheckpointInterval(cfgPath, cfg, log) + + if cfg.Snapshot.ScanCheckpointInterval != 30000 { + t.Errorf("bad config: expected ScanCheckpointInterval unchanged at 30000, got %d", + cfg.Snapshot.ScanCheckpointInterval) + } + if !logContainsMsg(&buf, "SIGHUP: config reload failed — keeping current ScanCheckpointInterval") { + t.Errorf("expected failure log message not found; log output:\n%s", buf.String()) + } } // TestReloadScanCheckpointInterval_ZeroFallsBackToDefault verifies that setting // scan_checkpoint_interval: 0 in node.yaml is treated as "use default" (50 000) -// by runStartupScan, matching the documented behaviour. +// by runStartupScan, matching the documented behavior. func TestReloadScanCheckpointInterval_ZeroFallsBackToDefault(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "node.yaml") - - writeIntervalConfig(t, cfgPath, 40000) - cfg, err := config.Load(cfgPath) - if err != nil { - t.Fatalf("Load: %v", err) - } - - // Reload with zero → runStartupScan will use its built-in default (50 000). - writeIntervalConfig(t, cfgPath, 0) - var buf bytes.Buffer - reloadScanCheckpointInterval(cfgPath, cfg, newCaptureLogger(&buf)) - - if cfg.Snapshot.ScanCheckpointInterval != 0 { - t.Errorf("expected ScanCheckpointInterval=0 after reload, got %d", - cfg.Snapshot.ScanCheckpointInterval) - } - - // Simulate what runStartupScan does with CheckpointInterval == 0. - effective := cfg.Snapshot.ScanCheckpointInterval - if effective == 0 { - effective = 50000 // default (see scan.go) - } - if effective != 50000 { - t.Errorf("expected effective interval 50000 when cfg is 0, got %d", effective) - } + dir := t.TempDir() + cfgPath := filepath.Join(dir, "node.yaml") + + writeIntervalConfig(t, cfgPath, 40000) + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + + // Reload with zero → runStartupScan will use its built-in default (50 000). + writeIntervalConfig(t, cfgPath, 0) + var buf bytes.Buffer + reloadScanCheckpointInterval(cfgPath, cfg, newCaptureLogger(&buf)) + + if cfg.Snapshot.ScanCheckpointInterval != 0 { + t.Errorf("expected ScanCheckpointInterval=0 after reload, got %d", + cfg.Snapshot.ScanCheckpointInterval) + } + + // Simulate what runStartupScan does with CheckpointInterval == 0. + effective := cfg.Snapshot.ScanCheckpointInterval + if effective == 0 { + effective = 50000 // default (see scan.go) + } + if effective != 50000 { + t.Errorf("expected effective interval 50000 when cfg is 0, got %d", effective) + } } diff --git a/cmd/node/snapshot.go b/cmd/node/snapshot.go index 1ec8b3f..f0fb7f1 100644 --- a/cmd/node/snapshot.go +++ b/cmd/node/snapshot.go @@ -100,18 +100,18 @@ const snapVersionLegacy = 1 // startupSnapshot is the on-disk format for the UTXOSet + registry snapshot. type startupSnapshot struct { - Version int `json:"v"` - TipHeight uint64 `json:"tip_height"` - TipHashHex string `json:"tip_hash"` - TxTotal int64 `json:"tx_total"` + Version int `json:"v"` + TipHeight uint64 `json:"tip_height"` + TipHashHex string `json:"tip_hash"` + TxTotal int64 `json:"tx_total"` // SavedAt records the wall-clock time when this snapshot was written. // Used at startup to determine whether the snapshot falls within the OOM // window (i.e., was saved close enough to an unclean shutdown that the // in-memory state it captured might have been partially corrupted). // Omitted from snapshots written by older binaries; treated as zero-value. - SavedAt time.Time `json:"saved_at,omitempty"` - UTXOs core.UTXOSnapshot `json:"utxos"` - Registry core.RegistrySnapshot `json:"registry"` + SavedAt time.Time `json:"saved_at,omitempty"` + UTXOs core.UTXOSnapshot `json:"utxos"` + Registry core.RegistrySnapshot `json:"registry"` } // ── Clean-shutdown marker ───────────────────────────────────────────────────── @@ -132,7 +132,7 @@ func cleanShutdownMarkerPath(dataDir string) string { // writeCleanShutdownMarker records that the last shutdown was graceful so the // next startup can skip the OOM-window AmountCommit validation. Non-fatal on -// error: an absent marker causes validation to run (fail-closed behaviour). +// error: an absent marker causes validation to run (fail-closed behavior). func writeCleanShutdownMarker(dataDir string) error { return os.WriteFile(cleanShutdownMarkerPath(dataDir), []byte("1\n"), 0644) } @@ -156,7 +156,7 @@ func readAndDeleteCleanShutdownMarker(dataDir string) (wasClean bool) { } if err := os.Remove(path); err != nil { // Cannot consume the marker — treat as unclean (fail-closed). - // Log to stderr (logger not yet initialised at call site) so the + // Log to stderr (logger not yet initialized at call site) so the // operator can diagnose the permission issue. fmt.Fprintf(os.Stderr, "aperod-node: warning: clean-shutdown marker exists but could not be removed (%v); "+ diff --git a/cmd/node/snapshot_checksum_test.go b/cmd/node/snapshot_checksum_test.go index 17e8581..e308cfb 100644 --- a/cmd/node/snapshot_checksum_test.go +++ b/cmd/node/snapshot_checksum_test.go @@ -310,7 +310,7 @@ func TestChecksum_PromotionWithoutSourceSidecarDropsStaleDst(t *testing.T) { prev := snapshotPrevPath(primary) if _, err := os.Stat(snapshotChecksumPath(prev)); err == nil { // A stale sidecar surviving would be tolerable ONLY if it matches; the - // implementation removes it, which is the simpler correct behaviour. + // implementation removes it, which is the simpler correct behavior. t.Error("stale prev sidecar should have been removed when source had none") } snap := tryLoadSnapshotFile(prev, 600) diff --git a/cmd/node/snapshot_coordinator_test.go b/cmd/node/snapshot_coordinator_test.go index b3b9245..cb9af07 100644 --- a/cmd/node/snapshot_coordinator_test.go +++ b/cmd/node/snapshot_coordinator_test.go @@ -1,10 +1,13 @@ package main import ( + "fmt" "io" "log/slog" "os" + "os/signal" "sync" + "syscall" "testing" "time" @@ -52,4 +55,94 @@ func TestShutdownWaitsForPeriodicWriterBeforeSavingFinalTip(t *testing.T) { if _, err := os.Stat(snapshotPath(dir, blocks[len(blocks)-1].Header.Height)); err != nil { t.Fatalf("final snapshot not published: %v", err) } -} \ No newline at end of file +} + +func TestSIGTERMWaitsForPeriodicWriterBeforeFinalSnapshot(t *testing.T) { + dir := t.TempDir() + db, blocks := buildChainInStore(t, dir, 2) + utxos := core.NewUTXOSet() + registry := core.NewValidatorRegistry() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + + stop := make(chan struct{}) + engineDone := make(chan struct{}) + go func() { + <-stop + close(engineDone) + }() + + gate := &sync.Mutex{} + gate.Lock() + releaseWriter := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseWriter) }) } + defer release() + writerDone := make(chan struct{}) + writerErr := make(chan error, 1) + go func() { + defer close(writerDone) + defer gate.Unlock() + <-releaseWriter + tip := blocks[len(blocks)-1] + writerErr <- saveStartupSnapshot(dir, startupSnapshot{ + Version: snapVersion, + TipHeight: tip.Header.Height, + TipHashHex: fmt.Sprintf("%x", tip.Hash()), + UTXOs: utxos.TakeSnapshotView(), + Registry: registry.TakeSnapshot(), + }) + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM) + defer signal.Stop(sigCh) + proc, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatal(err) + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("send SIGTERM to test process: %v", err) + } + select { + case <-sigCh: + case <-time.After(3 * time.Second): + t.Fatal("test process did not receive SIGTERM") + } + + finished := make(chan struct{}) + go func() { + performShutdownWithGate(stop, engineDone, db, utxos, registry, dir, log, nil, gate) + close(finished) + }() + select { + case <-engineDone: + case <-time.After(3 * time.Second): + release() + t.Fatal("SIGTERM shutdown did not stop the engine") + } + select { + case <-finished: + release() + t.Fatal("SIGTERM shutdown saved its final snapshot before the periodic writer finished") + case <-time.After(25 * time.Millisecond): + } + + release() + select { + case <-writerDone: + case <-time.After(5 * time.Second): + t.Fatal("periodic writer did not finish") + } + if err := <-writerErr; err != nil { + t.Fatalf("periodic snapshot writer: %v", err) + } + select { + case <-finished: + case <-time.After(5 * time.Second): + t.Fatal("SIGTERM shutdown did not save the final snapshot") + } + if snap := tryLoadSnapshotFile(snapshotPath(dir, blocks[len(blocks)-1].Header.Height), + blocks[len(blocks)-1].Header.Height); snap == nil { + t.Fatal("final snapshot is missing or unreadable after SIGTERM") + } +} diff --git a/cmd/node/snapshot_gap_fill_test.go b/cmd/node/snapshot_gap_fill_test.go index c9f30ae..c5bc4ae 100644 --- a/cmd/node/snapshot_gap_fill_test.go +++ b/cmd/node/snapshot_gap_fill_test.go @@ -25,7 +25,7 @@ import ( // silentLog returns a logger that discards all output. func silentLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// putRawBlk serialises blk as core.Block JSON and writes it via PutRawBlock, +// putRawBlk serializes blk as core.Block JSON and writes it via PutRawBlock, // matching exactly how storeBlock() persists blocks in production. func putRawBlk(t *testing.T, db *store.DB, blk *core.Block) { t.Helper() diff --git a/cmd/node/snapshot_keyimage_filter_test.go b/cmd/node/snapshot_keyimage_filter_test.go index bf16aab..971ad61 100644 --- a/cmd/node/snapshot_keyimage_filter_test.go +++ b/cmd/node/snapshot_keyimage_filter_test.go @@ -74,4 +74,4 @@ func TestFilterSnapshotKeyImagesLeavesSnapshotUnchangedOnIndexReadError(t *testi snap.UTXOs.KeyImages[1] != second { t.Fatalf("index read error changed snapshot key images: %v", snap.UTXOs.KeyImages) } -} \ No newline at end of file +} diff --git a/cmd/node/snapshot_periodic_test.go b/cmd/node/snapshot_periodic_test.go index 6ee33cf..3768d8c 100644 --- a/cmd/node/snapshot_periodic_test.go +++ b/cmd/node/snapshot_periodic_test.go @@ -23,7 +23,9 @@ func (nopHandler) Enabled(_ interface{ Deadline() (interface{}, bool) }, _ slog. return false } -func newNopLog() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) } +func newNopLog() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -90,12 +92,12 @@ func TestOnBlockAccepted_PeriodicSnapshot_IncomingPath(t *testing.T) { const testInterval uint64 = 2 eng := consensus.NewEngine(consensus.Config{ - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{pub}, - MyKey: nil, // not a producing validator in this test + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{pub}, + MyKey: nil, // not a producing validator in this test RingCTV4ActivationHeight: ^uint64(0), - OnCanonicalBlock: noopCanonicalPersistence, + OnCanonicalBlock: noopCanonicalPersistence, OnBlockAccepted: func(block *core.Block) { h := block.Header.Height mu.Lock() diff --git a/cmd/node/snapshot_restart_test.go b/cmd/node/snapshot_restart_test.go index b81d588..376db85 100644 --- a/cmd/node/snapshot_restart_test.go +++ b/cmd/node/snapshot_restart_test.go @@ -177,8 +177,9 @@ func TestShutdownSavesSnapshot(t *testing.T) { // TestFastPathLogsOnRestart verifies that when a valid snapshot exists for the // current tip, the startup path: -// (a) logs "startup fast path complete — snapshot loaded" ← must appear -// (b) does NOT log "running startup block scan" ← must be absent +// +// (a) logs "startup fast path complete — snapshot loaded" ← must appear +// (b) does NOT log "running startup block scan" ← must be absent // // This is achieved by running the same snapshot-presence logic that main.go // uses, but in-process with a captured logger. @@ -410,9 +411,9 @@ func TestCheckSystemdTimeout_MainService(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() - dropin := filepath.Join(dir, "timeout.conf") // absent + dropin := filepath.Join(dir, "timeout.conf") // absent service := writeConf(t, dir, "aperod-node.service", tc.service) - systemdDir := filepath.Join(dir, "no-systemd") // absent → no default-warn + systemdDir := filepath.Join(dir, "no-systemd") // absent → no default-warn var logBuf bytes.Buffer checkSystemdTimeout(dropin, service, systemdDir, newCaptureLogger(&logBuf)) @@ -472,8 +473,8 @@ func TestParseGOMLEMLIMITBytes(t *testing.T) { }{ // absent / disabled (exact Go runtime grammar) {"", false, 0}, - {"off", false, 0}, // exact spelling required - {"OFF", false, 0}, // case-fold NOT accepted by runtime + {"off", false, 0}, // exact spelling required + {"OFF", false, 0}, // case-fold NOT accepted by runtime {"Off", false, 0}, {"0", false, 0}, // plain byte counts @@ -518,10 +519,10 @@ func TestParseGOMLEMLIMITBytes(t *testing.T) { const dropin = "/etc/systemd/system/aperod-node.service.d/gomemlimit.conf" // TestCheckGOMLEMLIMIT_WarnCases verifies that absent, zero, "off" (exact), -// and unrecognised values all produce a warning and return nil in non-strict mode. +// and unrecognized values all produce a warning and return nil in non-strict mode. func TestCheckGOMLEMLIMIT_WarnCases(t *testing.T) { // All of these are effectively "no limit" — either unset, explicitly off, - // zero bytes, or unrecognised format that the runtime would reject anyway. + // zero bytes, or unrecognized format that the runtime would reject anyway. cases := []string{"", "0", "off", "OFF", "Off", "garbage", "5GB", "5MB", " 5GiB "} for _, val := range cases { var logBuf bytes.Buffer @@ -541,9 +542,9 @@ func TestCheckGOMLEMLIMIT_WarnCases(t *testing.T) { func TestCheckGOMLEMLIMIT_SilentWhenSet(t *testing.T) { validValues := []string{ "5368709120", "5905580032", "1073741824", // bare byte counts - "5368709120B", // explicit B suffix - "5B", // small explicit byte count - "5GiB", "512MiB", "1TiB", "1KiB", // IEC suffixes + "5368709120B", // explicit B suffix + "5B", // small explicit byte count + "5GiB", "512MiB", "1TiB", "1KiB", // IEC suffixes } for _, val := range validValues { var logBuf bytes.Buffer @@ -753,7 +754,7 @@ func TestExactTipFastPathHashMismatchDiscard(t *testing.T) { tip := blocks[len(blocks)-1] correctHash := tip.Hash() - tipHeight := tip.Header.Height // 4 + tipHeight := tip.Header.Height // 4 correctHex := fmt.Sprintf("%x", correctHash[:]) // Save a valid snapshot using the correct block hash. @@ -1062,7 +1063,7 @@ func TestBothSnapshotsCorruptFallsBackToScan(t *testing.T) { tip := blocks[len(blocks)-1] tipHash := tip.Hash() - tipHeight := tip.Header.Height // 3 + tipHeight := tip.Header.Height // 3 tipHashHex := fmt.Sprintf("%x", tipHash[:]) // ── Save a valid snapshot at the current tip. @@ -1400,7 +1401,7 @@ func TestBothSnapshotsHashMismatchFallsBackToScan(t *testing.T) { tip := blocks[len(blocks)-1] tipHash := tip.Hash() - tipHeight := tip.Header.Height // 2 + tipHeight := tip.Header.Height // 2 tipHashHex := fmt.Sprintf("%x", tipHash[:]) // Build a wrong hash that is guaranteed to differ from the real one. @@ -1490,9 +1491,10 @@ func TestBothSnapshotsHashMismatchFallsBackToScan(t *testing.T) { // TestSaveSnapshotAbortOnCopyFailLeavesOldPrimaryIntact confirms that when the // required same-height prev-backup copy inside saveStartupSnapshot fails (e.g. // disk full), the function: -// (a) removes the .tmp file and returns a non-nil error, and -// (b) leaves the pre-existing primary snapshot file untouched so the node -// can still recover at startup. +// +// (a) removes the .tmp file and returns a non-nil error, and +// (b) leaves the pre-existing primary snapshot file untouched so the node +// can still recover at startup. // // Scenario: // - A valid snapshot is saved at height H, establishing an old primary @@ -1503,9 +1505,9 @@ func TestBothSnapshotsHashMismatchFallsBackToScan(t *testing.T) { // returns an error (EISDIR on Linux/macOS), which causes copyFile to fail // before it writes any bytes. // - saveStartupSnapshot is called again for the same height H. It: -// 1. writes the new primary .tmp (succeeds — a different path) -// 2. calls copyFile(tmp, prevPath) — fails because prevPath+".tmp" is a dir -// 3. removes tmp and returns a non-nil error +// 1. writes the new primary .tmp (succeeds — a different path) +// 2. calls copyFile(tmp, prevPath) — fails because prevPath+".tmp" is a dir +// 3. removes tmp and returns a non-nil error // - The test confirms the error is non-nil. // - The test confirms the old primary is still readable and has the original // TipHashHex / TxTotal values (i.e. no partial overwrite occurred). @@ -1546,8 +1548,8 @@ func TestSaveSnapshotAbortOnCopyFailLeavesOldPrimaryIntact(t *testing.T) { // copyFile(tmp, dst) creates dst+".tmp" internally before renaming to dst. // Creating a directory at that path causes os.Create to return EISDIR, // which propagates as a copyFile error → saveStartupSnapshot aborts. - prevPath := snapshotPrevPath(primaryPath) // snapshot-v1-4-prev.json - blockPath := prevPath + ".tmp" // snapshot-v1-4-prev.json.tmp + prevPath := snapshotPrevPath(primaryPath) // snapshot-v1-4-prev.json + blockPath := prevPath + ".tmp" // snapshot-v1-4-prev.json.tmp if err := os.MkdirAll(blockPath, 0755); err != nil { t.Fatalf("MkdirAll blocker %s: %v", blockPath, err) } @@ -1602,10 +1604,11 @@ func TestSaveSnapshotAbortOnCopyFailLeavesOldPrimaryIntact(t *testing.T) { // TestSaveSnapshotRenameFailureLeavesOldPrimaryIntact confirms that when the // final os.Rename(tmp, path) step inside saveStartupSnapshot fails, the // function: -// (a) returns a non-nil error, -// (b) leaves no stale .tmp file in the data directory, and -// (c) leaves the pre-existing primary snapshot file untouched and readable -// with its original content. +// +// (a) returns a non-nil error, +// (b) leaves no stale .tmp file in the data directory, and +// (c) leaves the pre-existing primary snapshot file untouched and readable +// with its original content. // // Scenario: // - A valid snapshot is saved at height H, establishing an old primary @@ -1969,7 +1972,7 @@ func TestShutdownSnapshotMatchesFinalDBTip(t *testing.T) { // Simulates a real consensus engine that may be finishing a block when // the shutdown signal arrives. // - // Behaviour: upon receiving <-stop, it writes block H+1=6 to the DB (the + // Behavior: upon receiving <-stop, it writes block H+1=6 to the DB (the // "in-flight" block), then closes engineDone — just as the real engine.Run() // finishes its current tick and returns after close(stop). stop := make(chan struct{}) diff --git a/cmd/node/snapshot_resume_test.go b/cmd/node/snapshot_resume_test.go index c296e8b..65473c3 100644 --- a/cmd/node/snapshot_resume_test.go +++ b/cmd/node/snapshot_resume_test.go @@ -112,10 +112,10 @@ func applyBlockRange(t *testing.T, utxos *core.UTXOSet, blocks []*core.Block, lo // - The node crashed mid-scan at block 5: a partial checkpoint was written at // height 5 but the scan never reached the tip (height 10). // - On restart, runStartupScan (the production function) must: -// (a) find the height-5 checkpoint via findLatestSnapshot, -// (b) log "partial snapshot loaded — resuming scan from checkpoint", -// (c) return ScanFrom == 6 (not 1), -// (d) leave p.UTXOs with the same count as a full scan from block 1. +// (a) find the height-5 checkpoint via findLatestSnapshot, +// (b) log "partial snapshot loaded — resuming scan from checkpoint", +// (c) return ScanFrom == 6 (not 1), +// (d) leave p.UTXOs with the same count as a full scan from block 1. func TestPartialSnapshotResume(t *testing.T) { const totalBlocks = 10 // heights 1–10 plus genesis at 0 const checkpointAt = 5 // "crash" height: partial checkpoint saved here @@ -172,18 +172,18 @@ func TestPartialSnapshotResume(t *testing.T) { // ── Call the production function under test. var wg sync.WaitGroup result, err := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tipHeight, - TipHashHex: tipHashHex, - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, // fresh start — no index pre-loaded - InitTxTotal: 0, - Log: log, - SnapshotWg: &wg, + DataDir: dir, + TipHeight: tipHeight, + TipHashHex: tipHashHex, + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, // fresh start — no index pre-loaded + InitTxTotal: 0, + Log: log, + SnapshotWg: &wg, CheckpointInterval: 50000, - }) + }) if err != nil { t.Fatalf("runStartupScan: %v", err) } @@ -237,18 +237,18 @@ func TestNoPartialSnapshot_ScanFromOne(t *testing.T) { var wg sync.WaitGroup result, err := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tipHeight, - TipHashHex: tipHashHex, - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, - InitTxTotal: 0, - Log: log, - SnapshotWg: &wg, + DataDir: dir, + TipHeight: tipHeight, + TipHashHex: tipHashHex, + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, + InitTxTotal: 0, + Log: log, + SnapshotWg: &wg, CheckpointInterval: 50000, - }) + }) if err != nil { t.Fatalf("runStartupScan: %v", err) } @@ -265,7 +265,7 @@ func TestNoPartialSnapshot_ScanFromOne(t *testing.T) { } } -// ─── Test 3: findLatestSnapshot honours limitHeight ────────────────────────── +// ─── Test 3: findLatestSnapshot honors limitHeight ────────────────────────── // TestFindLatestSnapshot_LimitHeight verifies that findLatestSnapshot returns // the highest checkpoint strictly below limitHeight and never at or above it. @@ -460,18 +460,18 @@ func TestPartialSnapshot_HashMismatch(t *testing.T) { var wg sync.WaitGroup result, err := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tipHeight, - TipHashHex: tipHashHex, - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, - InitTxTotal: 0, - Log: log, - SnapshotWg: &wg, + DataDir: dir, + TipHeight: tipHeight, + TipHashHex: tipHashHex, + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, + InitTxTotal: 0, + Log: log, + SnapshotWg: &wg, CheckpointInterval: 50000, - }) + }) if err != nil { t.Fatalf("runStartupScan: %v", err) } @@ -571,7 +571,7 @@ func TestCorruptCheckpointRunStartupScan_PrevBackupAccepted(t *testing.T) { // Write the prev-backup explicitly so the recovery path has a valid file. // The first save at a height does not create a prev (nothing to preserve); // a same-height overwrite would, but we create it directly here to keep - // the test focused on the recovery behaviour. + // the test focused on the recovery behavior. prevP := snapshotPrevPath(snapshotPath(dir, uint64(checkpointAt))) writeGzipSnapFile(t, prevP, cpSnap) @@ -593,18 +593,18 @@ func TestCorruptCheckpointRunStartupScan_PrevBackupAccepted(t *testing.T) { // ── Call the production function under test. var wg sync.WaitGroup result, err := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tipHeight, - TipHashHex: tipHashHex, - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, - InitTxTotal: 0, - Log: log, - SnapshotWg: &wg, + DataDir: dir, + TipHeight: tipHeight, + TipHashHex: tipHashHex, + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, + InitTxTotal: 0, + Log: log, + SnapshotWg: &wg, CheckpointInterval: 50000, - }) + }) if err != nil { t.Fatalf("runStartupScan: %v", err) } @@ -722,7 +722,7 @@ func TestFindLatestSnapshot_BothCorrupt(t *testing.T) { // 3. The number of blocks processed equals tipHeight - checkpointAt. // 4. Final UTXOSet count equals a full-scan reference. func TestForcedKillRestart_ResumesFromPeriodicCheckpoint(t *testing.T) { - const totalBlocks = 15 // heights 1–15 plus genesis at 0; tip == 15 + const totalBlocks = 15 // heights 1–15 plus genesis at 0; tip == 15 const checkpointAt = 10 // periodic checkpoint height saved before the kill dir := t.TempDir() @@ -784,18 +784,18 @@ func TestForcedKillRestart_ResumesFromPeriodicCheckpoint(t *testing.T) { // ── Call the production function under test. var wg sync.WaitGroup result, err := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tipHeight, - TipHashHex: tipHashHex, - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, // fresh start — no index pre-loaded - InitTxTotal: 0, - Log: log, - SnapshotWg: &wg, + DataDir: dir, + TipHeight: tipHeight, + TipHashHex: tipHashHex, + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, // fresh start — no index pre-loaded + InitTxTotal: 0, + Log: log, + SnapshotWg: &wg, CheckpointInterval: 50000, - }) + }) if err != nil { t.Fatalf("runStartupScan: %v", err) } @@ -929,15 +929,15 @@ func TestRecoverTip_RelaxedHashFallback(t *testing.T) { // written during a first forced-kill (mid-full-scan) is still intact and // usable after a *second* forced-kill that occurs mid-delta-scan: // -// - Boot 1: scan starts from block 1. A periodic checkpoint is written at -// height N. The process is SIGKILL-ed before reaching the tip → no tip -// snapshot is saved. -// - Boot 2 (simulated kill): the node resumes from the height-N checkpoint -// and begins scanning the delta (N+1 … N+K). The process is SIGKILL-ed -// again before writing any new checkpoint or tip snapshot. -// - Boot 3 (the boot under test): the original checkpoint at N must still be -// on disk, findLatestSnapshot must return it, and runStartupScan must -// report ScanFrom == N+1 (not 1). +// - Boot 1: scan starts from block 1. A periodic checkpoint is written at +// height N. The process is SIGKILL-ed before reaching the tip → no tip +// snapshot is saved. +// - Boot 2 (simulated kill): the node resumes from the height-N checkpoint +// and begins scanning the delta (N+1 … N+K). The process is SIGKILL-ed +// again before writing any new checkpoint or tip snapshot. +// - Boot 3 (the boot under test): the original checkpoint at N must still be +// on disk, findLatestSnapshot must return it, and runStartupScan must +// report ScanFrom == N+1 (not 1). // // The invariant being tested: deleteOldSnapshots is only called inside the // goroutine that saves a *new* snapshot. A mid-delta kill before any new @@ -951,9 +951,9 @@ func TestRecoverTip_RelaxedHashFallback(t *testing.T) { // 4. The "partial snapshot loaded" log is emitted. // 5. Final UTXOSet count equals a full-scan reference. func TestDoubleKillMidDeltaScan_CheckpointSurvives(t *testing.T) { - const totalBlocks = 20 // heights 1–20 plus genesis at 0; tip == 20 - const checkpointAt = 8 // height N: checkpoint written before first kill - const deltaBlocks = 12 // K: delta that Boot 2 would have scanned + const totalBlocks = 20 // heights 1–20 plus genesis at 0; tip == 20 + const checkpointAt = 8 // height N: checkpoint written before first kill + const deltaBlocks = 12 // K: delta that Boot 2 would have scanned dir := t.TempDir() db, blocks, _, pub := buildChainResume(t, dir, totalBlocks) @@ -1105,8 +1105,8 @@ func TestUnwritableDataDir_CheckpointFailureNonFatal(t *testing.T) { t.Skip("cannot test unwritable directory as root (root ignores permission bits)") } - const totalBlocks = 10 // heights 1–10 plus genesis at 0; tip == 10 - const checkpointInterval = 5 // checkpoint goroutine fires at h == 5 + const totalBlocks = 10 // heights 1–10 plus genesis at 0; tip == 10 + const checkpointInterval = 5 // checkpoint goroutine fires at h == 5 dir := t.TempDir() db, blocks, _, pub := buildChainResume(t, dir, totalBlocks) @@ -1215,8 +1215,8 @@ func logContainsMsgWithIntField(buf *bytes.Buffer, wantMsg, fieldName string, wa // at h=12, resumes from the *highest* eligible checkpoint (h=10) rather than // from h=5 or from block 1. ScanFrom must equal 11. func TestCheckpointInterval_CheckpointsWrittenAndHighestResumed(t *testing.T) { - const totalBlocks = 12 // heights 1–12 plus genesis at 0; tip == 12 - const checkpointInterval = 5 // checkpoint fires at h=5 and h=10 (both < 12) + const totalBlocks = 12 // heights 1–12 plus genesis at 0; tip == 12 + const checkpointInterval = 5 // checkpoint fires at h=5 and h=10 (both < 12) // ── Phase 1: first scan — verify checkpoints logged at h=5 and h=10 ── @@ -1503,7 +1503,7 @@ func TestPostScanSnapshotCreated(t *testing.T) { // - The log contains "startup snapshot saved" with tip_height == 12. // - result.ScanFrom == 7 (resume path taken, not full scan). func TestPostScanSnapshotCreated_CheckpointResume(t *testing.T) { - const totalBlocks = 12 + const totalBlocks = 12 const checkpointAt = 6 dir := t.TempDir() diff --git a/cmd/node/snapshot_shutdown_log_test.go b/cmd/node/snapshot_shutdown_log_test.go index 8f015ba..e47df46 100644 --- a/cmd/node/snapshot_shutdown_log_test.go +++ b/cmd/node/snapshot_shutdown_log_test.go @@ -11,8 +11,8 @@ package main // can verify their TimeoutStopSec is high enough to let the write finish. import ( - "bytes" "bufio" + "bytes" "encoding/json" "errors" "fmt" @@ -232,12 +232,12 @@ func TestSaveShutdownSnapshot_LogsTipHeight(t *testing.T) { // TestSaveShutdownSnapshot_RatioPctFiresAfterRealWrite is an end-to-end test that // exercises the full path from saveShutdownSnapshotWithPaths → real snapshot -// serialisation → warnIfSnapshotSlowRelativeToTimeout → ratio_pct log field. +// serialization → warnIfSnapshotSlowRelativeToTimeout → ratio_pct log field. // // It uses a synthetic drop-in with a short TimeoutStopSec (100 ms) together with // a saveDurOverride (500 ms) so that the ratio is deterministically 500 %, well // above the 80 % Error threshold. The test also populates a configurable number -// of UTXOs (utxoCount) to exercise realistic snapshot serialisation — a future +// of UTXOs (utxoCount) to exercise realistic snapshot serialization — a future // format change that inflates the snapshot schema will still exercise the real // write path even though the ratio check uses the override. func TestSaveShutdownSnapshot_RatioPctFiresAfterRealWrite(t *testing.T) { @@ -246,7 +246,7 @@ func TestSaveShutdownSnapshot_RatioPctFiresAfterRealWrite(t *testing.T) { dir := t.TempDir() db, utxos, registry := buildMinimalChainForShutdown(t, dir) - // Populate synthetic UTXOs so the snapshot serialisation covers a + // Populate synthetic UTXOs so the snapshot serialization covers a // realistic payload. The fields are zero-valued except for BlockHeight and // OutputIndex to keep each UTXO key unique. for i := 0; i < utxoCount; i++ { @@ -516,7 +516,7 @@ func TestReadEffectiveTimeoutStopSec_FallsBackToService(t *testing.T) { } func TestReadEffectiveTimeoutStopSec_NeitherPresent(t *testing.T) { - dropinDir := t.TempDir() // empty + dropinDir := t.TempDir() // empty service := filepath.Join(t.TempDir(), "aperod-node.service") // absent got, found := readEffectiveTimeoutStopSec(dropinDir, service) @@ -746,7 +746,7 @@ func TestWarnIfSnapshotSlow_SuffixedTimeout(t *testing.T) { } func TestWarnIfSnapshotSlow_NoConfigNoOp(t *testing.T) { - dropinDir := t.TempDir() // empty — no .conf files + dropinDir := t.TempDir() // empty — no .conf files service := filepath.Join(t.TempDir(), "aperod-node.service") // absent var buf bytes.Buffer @@ -1228,7 +1228,7 @@ func TestCheckStartupSnapshotTiming_BelowThreshold(t *testing.T) { // file contains a TimeoutStopSec line (non-systemd host, or default config). // Even a very long save duration must produce no log output. func TestCheckStartupSnapshotTiming_NoTimeoutConfig(t *testing.T) { - dropinDir := t.TempDir() // empty — no .conf files + dropinDir := t.TempDir() // empty — no .conf files service := filepath.Join(t.TempDir(), "aperod-node.service") // absent var buf bytes.Buffer diff --git a/cmd/node/snapshot_sigkill_flush_test.go b/cmd/node/snapshot_sigkill_flush_test.go index a16fda2..895f195 100644 --- a/cmd/node/snapshot_sigkill_flush_test.go +++ b/cmd/node/snapshot_sigkill_flush_test.go @@ -29,209 +29,209 @@ package main // A clean, fully-written snapshot loads correctly (sanity / non-regression). import ( - "bytes" - "fmt" - "os" - "path/filepath" - "testing" + "bytes" + "fmt" + "os" + "path/filepath" + "testing" ) // sigkillSnapPath returns the path of the gzip snapshot at height h, matching // the snapshotPath() convention: snapshot-v-.json.gz. func sigkillSnapPath(dataDir string, height uint64) string { - return filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, height)) + return filepath.Join(dataDir, fmt.Sprintf("snapshot-v%d-%d.json.gz", snapVersion, height)) } // makePrevBackupFromPrimary copies the current primary (and its checksum // sidecar) to the prev-backup slot so loadPrevBackupSnapshot can find a valid // recovery file. The copy is done BEFORE corrupting the primary. func makePrevBackupFromPrimary(t *testing.T, primaryPath string) { - t.Helper() - data, err := os.ReadFile(primaryPath) - if err != nil { - t.Fatalf("read primary for prev-backup copy: %v", err) - } - prevPath := snapshotPrevPath(primaryPath) - if err := os.WriteFile(prevPath, data, 0o644); err != nil { - t.Fatalf("write prev-backup: %v", err) - } - // Copy the checksum sidecar so verifySnapshotChecksum succeeds on the - // prev-backup path. - csPath := snapshotChecksumPath(primaryPath) - if csData, csErr := os.ReadFile(csPath); csErr == nil { - // Sidecar exists — copy it. Ignore errors: loaders are tolerant - // of a missing sidecar on the prev path. - _ = os.WriteFile(snapshotChecksumPath(prevPath), csData, 0o644) - } + t.Helper() + data, err := os.ReadFile(primaryPath) + if err != nil { + t.Fatalf("read primary for prev-backup copy: %v", err) + } + prevPath := snapshotPrevPath(primaryPath) + if err := os.WriteFile(prevPath, data, 0o644); err != nil { + t.Fatalf("write prev-backup: %v", err) + } + // Copy the checksum sidecar so verifySnapshotChecksum succeeds on the + // prev-backup path. + csPath := snapshotChecksumPath(primaryPath) + if csData, csErr := os.ReadFile(csPath); csErr == nil { + // Sidecar exists — copy it. Ignore errors: loaders are tolerant + // of a missing sidecar on the prev path. + _ = os.WriteFile(snapshotChecksumPath(prevPath), csData, 0o644) + } } // TestSIGKILLFlush_TruncatedPrimaryFallsBackToPrev confirms that when the // primary snapshot is truncated (SIGKILL during gzip write), a valid prev-backup // is used and startup succeeds. func TestSIGKILLFlush_TruncatedPrimaryFallsBackToPrev(t *testing.T) { - dir := t.TempDir() - var buf bytes.Buffer - log := newCaptureLogger(&buf) - - const height = uint64(5) - const hashHex = "deadbeef" - - // 1. Write a valid primary. - saveSnapAtHeight(t, dir, height, hashHex) - primaryPath := sigkillSnapPath(dir, height) - - // 2. Promote the primary to prev-backup BEFORE corrupting it. - makePrevBackupFromPrimary(t, primaryPath) - - // 3. Truncate the primary to simulate a SIGKILL mid-write. - data, err := os.ReadFile(primaryPath) - if err != nil { - t.Fatalf("read primary: %v", err) - } - if err := os.WriteFile(primaryPath, data[:len(data)/2], 0o644); err != nil { - t.Fatalf("truncate primary: %v", err) - } - - // 4. loadStartupSnapshotWithFallback must succeed via the prev-backup. - snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) - if loadErr != nil { - t.Fatalf("expected success via prev-backup, got error: %v\nlog:\n%s", loadErr, buf.String()) - } - if snap == nil { - t.Fatal("returned nil snap without error") - } - if snap.TipHeight != height { - t.Errorf("snap.TipHeight = %d, want %d", snap.TipHeight, height) - } + dir := t.TempDir() + var buf bytes.Buffer + log := newCaptureLogger(&buf) + + const height = uint64(5) + const hashHex = "deadbeef" + + // 1. Write a valid primary. + saveSnapAtHeight(t, dir, height, hashHex) + primaryPath := sigkillSnapPath(dir, height) + + // 2. Promote the primary to prev-backup BEFORE corrupting it. + makePrevBackupFromPrimary(t, primaryPath) + + // 3. Truncate the primary to simulate a SIGKILL mid-write. + data, err := os.ReadFile(primaryPath) + if err != nil { + t.Fatalf("read primary: %v", err) + } + if err := os.WriteFile(primaryPath, data[:len(data)/2], 0o644); err != nil { + t.Fatalf("truncate primary: %v", err) + } + + // 4. loadStartupSnapshotWithFallback must succeed via the prev-backup. + snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) + if loadErr != nil { + t.Fatalf("expected success via prev-backup, got error: %v\nlog:\n%s", loadErr, buf.String()) + } + if snap == nil { + t.Fatal("returned nil snap without error") + } + if snap.TipHeight != height { + t.Errorf("snap.TipHeight = %d, want %d", snap.TipHeight, height) + } } // TestSIGKILLFlush_EmptyPrimaryFallsBackToPrev covers the edge case where the // SIGKILL strikes before the first byte is written (file exists, size = 0). func TestSIGKILLFlush_EmptyPrimaryFallsBackToPrev(t *testing.T) { - dir := t.TempDir() - var buf bytes.Buffer - log := newCaptureLogger(&buf) - - const height = uint64(3) - const hashHex = "a1b2c3d4" - - // 1. Write a valid primary. - saveSnapAtHeight(t, dir, height, hashHex) - primaryPath := sigkillSnapPath(dir, height) - - // 2. Promote to prev-backup. - makePrevBackupFromPrimary(t, primaryPath) - - // 3. Replace primary with a zero-byte file (SIGKILL before first write). - if err := os.WriteFile(primaryPath, []byte{}, 0o644); err != nil { - t.Fatalf("write empty primary: %v", err) - } - - // 4. Fallback must succeed. - snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) - if loadErr != nil { - t.Fatalf("expected success via prev-backup for empty primary, got: %v\nlog:\n%s", loadErr, buf.String()) - } - if snap == nil { - t.Fatal("returned nil snap without error") - } - if snap.TipHeight != height { - t.Errorf("snap.TipHeight = %d, want %d", snap.TipHeight, height) - } + dir := t.TempDir() + var buf bytes.Buffer + log := newCaptureLogger(&buf) + + const height = uint64(3) + const hashHex = "a1b2c3d4" + + // 1. Write a valid primary. + saveSnapAtHeight(t, dir, height, hashHex) + primaryPath := sigkillSnapPath(dir, height) + + // 2. Promote to prev-backup. + makePrevBackupFromPrimary(t, primaryPath) + + // 3. Replace primary with a zero-byte file (SIGKILL before first write). + if err := os.WriteFile(primaryPath, []byte{}, 0o644); err != nil { + t.Fatalf("write empty primary: %v", err) + } + + // 4. Fallback must succeed. + snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) + if loadErr != nil { + t.Fatalf("expected success via prev-backup for empty primary, got: %v\nlog:\n%s", loadErr, buf.String()) + } + if snap == nil { + t.Fatal("returned nil snap without error") + } + if snap.TipHeight != height { + t.Errorf("snap.TipHeight = %d, want %d", snap.TipHeight, height) + } } // TestSIGKILLFlush_TruncatedPrimaryNoPrevReturnsError confirms that when the // primary is corrupt and no prev-backup is available, the function returns a // non-nil error (never silently returns nil snap with nil err). func TestSIGKILLFlush_TruncatedPrimaryNoPrevReturnsError(t *testing.T) { - dir := t.TempDir() - var buf bytes.Buffer - log := newCaptureLogger(&buf) - - const height = uint64(7) - const hashHex = "cafebabe" - - // Write a valid primary and then truncate it; do NOT create a prev-backup. - saveSnapAtHeight(t, dir, height, hashHex) - primaryPath := sigkillSnapPath(dir, height) - data, err := os.ReadFile(primaryPath) - if err != nil { - t.Fatalf("read primary: %v", err) - } - if err := os.WriteFile(primaryPath, data[:len(data)/2], 0o644); err != nil { - t.Fatalf("truncate primary: %v", err) - } - - snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) - if loadErr == nil { - h := uint64(0) - if snap != nil { - h = snap.TipHeight - } - t.Fatalf("expected error for corrupt primary + no prev-backup, got snap at height %d\nlog:\n%s", h, buf.String()) - } - if snap != nil { - t.Errorf("expected nil snap on error, got height %d", snap.TipHeight) - } + dir := t.TempDir() + var buf bytes.Buffer + log := newCaptureLogger(&buf) + + const height = uint64(7) + const hashHex = "cafebabe" + + // Write a valid primary and then truncate it; do NOT create a prev-backup. + saveSnapAtHeight(t, dir, height, hashHex) + primaryPath := sigkillSnapPath(dir, height) + data, err := os.ReadFile(primaryPath) + if err != nil { + t.Fatalf("read primary: %v", err) + } + if err := os.WriteFile(primaryPath, data[:len(data)/2], 0o644); err != nil { + t.Fatalf("truncate primary: %v", err) + } + + snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) + if loadErr == nil { + h := uint64(0) + if snap != nil { + h = snap.TipHeight + } + t.Fatalf("expected error for corrupt primary + no prev-backup, got snap at height %d\nlog:\n%s", h, buf.String()) + } + if snap != nil { + t.Errorf("expected nil snap on error, got height %d", snap.TipHeight) + } } // TestSIGKILLFlush_BothCorruptReturnsError confirms that when both the primary // and the prev-backup contain garbage bytes, the function returns a non-nil error. func TestSIGKILLFlush_BothCorruptReturnsError(t *testing.T) { - dir := t.TempDir() - var buf bytes.Buffer - log := newCaptureLogger(&buf) - - const height = uint64(9) - const hashHex = "feedface" - - primaryPath := sigkillSnapPath(dir, height) - prevPath := snapshotPrevPath(primaryPath) - - if err := os.WriteFile(primaryPath, []byte("not gzip at all"), 0o644); err != nil { - t.Fatalf("write corrupt primary: %v", err) - } - if err := os.WriteFile(prevPath, []byte("also garbage"), 0o644); err != nil { - t.Fatalf("write corrupt prev: %v", err) - } - - snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) - if loadErr == nil { - h := uint64(0) - if snap != nil { - h = snap.TipHeight - } - t.Fatalf("expected error for both-corrupt, got snap at height %d\nlog:\n%s", h, buf.String()) - } - if snap != nil { - t.Errorf("expected nil snap on error, got height %d", snap.TipHeight) - } + dir := t.TempDir() + var buf bytes.Buffer + log := newCaptureLogger(&buf) + + const height = uint64(9) + const hashHex = "feedface" + + primaryPath := sigkillSnapPath(dir, height) + prevPath := snapshotPrevPath(primaryPath) + + if err := os.WriteFile(primaryPath, []byte("not gzip at all"), 0o644); err != nil { + t.Fatalf("write corrupt primary: %v", err) + } + if err := os.WriteFile(prevPath, []byte("also garbage"), 0o644); err != nil { + t.Fatalf("write corrupt prev: %v", err) + } + + snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) + if loadErr == nil { + h := uint64(0) + if snap != nil { + h = snap.TipHeight + } + t.Fatalf("expected error for both-corrupt, got snap at height %d\nlog:\n%s", h, buf.String()) + } + if snap != nil { + t.Errorf("expected nil snap on error, got height %d", snap.TipHeight) + } } // TestSIGKILLFlush_ValidSnapshotUnaffected is a non-regression sanity check: a // clean, fully-written snapshot must still load correctly after this set of tests // (guards against any accidental mutation of shared test state). func TestSIGKILLFlush_ValidSnapshotUnaffected(t *testing.T) { - dir := t.TempDir() - var buf bytes.Buffer - log := newCaptureLogger(&buf) - - const height = uint64(11) - const hashHex = "c0ffee11" - - saveSnapAtHeight(t, dir, height, hashHex) - - snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) - if loadErr != nil { - t.Fatalf("valid snapshot failed to load: %v\nlog:\n%s", loadErr, buf.String()) - } - if snap == nil { - t.Fatal("returned nil snap without error for valid snapshot") - } - if snap.TipHeight != height { - t.Errorf("snap.TipHeight = %d, want %d", snap.TipHeight, height) - } - if snap.TipHashHex != hashHex { - t.Errorf("snap.TipHashHex = %q, want %q", snap.TipHashHex, hashHex) - } + dir := t.TempDir() + var buf bytes.Buffer + log := newCaptureLogger(&buf) + + const height = uint64(11) + const hashHex = "c0ffee11" + + saveSnapAtHeight(t, dir, height, hashHex) + + snap, _, loadErr := loadStartupSnapshotWithFallback(dir, height, hashHex, log) + if loadErr != nil { + t.Fatalf("valid snapshot failed to load: %v\nlog:\n%s", loadErr, buf.String()) + } + if snap == nil { + t.Fatal("returned nil snap without error for valid snapshot") + } + if snap.TipHeight != height { + t.Errorf("snap.TipHeight = %d, want %d", snap.TipHeight, height) + } + if snap.TipHashHex != hashHex { + t.Errorf("snap.TipHashHex = %q, want %q", snap.TipHashHex, hashHex) + } } diff --git a/cmd/node/snapshot_stream.go b/cmd/node/snapshot_stream.go index a832eb9..1d8d2f0 100644 --- a/cmd/node/snapshot_stream.go +++ b/cmd/node/snapshot_stream.go @@ -1,43 +1,39 @@ package main import ( + "bufio" "encoding/json" "fmt" "io" ) -// writeSnapshotArray encodes one bounded chunk at a time. json.Encoder.Encode -// buffers its entire argument before writing, which previously kept a second -// multi-gigabyte copy of the UTXO snapshot alive during gzip compression. +// writeSnapshotArray encodes one value at a time through a fixed-size buffer. +// This keeps both the retained UTXO view and the JSON encoder bounded while +// gzip writes the snapshot to disk. func writeSnapshotArray[T any](w io.Writer, values []T) error { if values == nil { _, err := io.WriteString(w, "null") return err } - if _, err := io.WriteString(w, "["); err != nil { + bw := bufio.NewWriterSize(w, 64*1024) + if _, err := io.WriteString(bw, "["); err != nil { return err } - const chunkSize = 256 - for start := 0; start < len(values); start += chunkSize { - end := start + chunkSize - if end > len(values) { - end = len(values) - } - chunk, err := json.Marshal(values[start:end]) - if err != nil { - return err - } - if start > 0 { - if _, err = io.WriteString(w, ","); err != nil { + enc := json.NewEncoder(bw) + for i := range values { + if i > 0 { + if _, err := io.WriteString(bw, ","); err != nil { return err } } - if _, err = w.Write(chunk[1 : len(chunk)-1]); err != nil { + if err := enc.Encode(values[i]); err != nil { return err } } - _, err := io.WriteString(w, "]") - return err + if _, err := io.WriteString(bw, "]"); err != nil { + return err + } + return bw.Flush() } func writeSnapshotField(w io.Writer, name string, value any) error { @@ -50,8 +46,9 @@ func writeSnapshotField(w io.Writer, name string, value any) error { } // writeStartupSnapshotJSON preserves the v2 JSON schema while ensuring the -// encoder never constructs a second full-size JSON buffer. The detached -// UTXOSnapshot remains immutable until this function returns. +// encoder never constructs a second full-size JSON buffer. A UTXOSnapshot from +// TakeSnapshotView contains stable, read-only record pointers until this +// function returns. func writeStartupSnapshotJSON(w io.Writer, snap startupSnapshot) error { if _, err := io.WriteString(w, "{"); err != nil { return err diff --git a/cmd/node/snapshot_stream_test.go b/cmd/node/snapshot_stream_test.go index bfa36c2..f441acd 100644 --- a/cmd/node/snapshot_stream_test.go +++ b/cmd/node/snapshot_stream_test.go @@ -2,10 +2,15 @@ package main import ( "bytes" + "compress/gzip" + "encoding/binary" "encoding/json" "errors" "io" + "os" + "path/filepath" "reflect" + "runtime" "testing" "time" @@ -70,4 +75,112 @@ func TestWriteStartupSnapshotJSONPropagatesWriteErrors(t *testing.T) { if !errors.Is(err, io.ErrClosedPipe) { t.Fatalf("want writer failure, got %v", err) } -} \ No newline at end of file +} + +type snapshotScaleProbe struct { + bytesWritten int64 + largestWrite int + nextSample int64 + peakHeap uint64 +} + +func (p *snapshotScaleProbe) Write(b []byte) (int, error) { + p.bytesWritten += int64(len(b)) + if len(b) > p.largestWrite { + p.largestWrite = len(b) + } + if p.bytesWritten >= p.nextSample { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + if mem.HeapAlloc > p.peakHeap { + p.peakHeap = mem.HeapAlloc + } + p.nextSample += 8 << 20 + } + return len(b), nil +} + +// Run explicitly with APEROD_SNAPSHOT_SCALE_TEST=1. It builds a production- +// sized set and streams the full JSON to a counting sink without retaining the +// output. Keeping it opt-in avoids making every package test allocate hundreds +// of MiB. +func TestSnapshotViewTwoMillionUTXOsHasBoundedMemory(t *testing.T) { + if os.Getenv("APEROD_SNAPSHOT_SCALE_TEST") != "1" { + t.Skip("set APEROD_SNAPSHOT_SCALE_TEST=1 to run the 2M UTXO memory test") + } + const count = 2_000_000 + records := make([]*core.UTXO, count) + for i := range records { + var txHash [32]byte + var oneTimePub [32]byte + binary.LittleEndian.PutUint64(txHash[24:], uint64(i+1)) + binary.LittleEndian.PutUint64(oneTimePub[24:], uint64(i+1)) + records[i] = &core.UTXO{TxHash: txHash, OneTimePub: oneTimePub} + } + utxos := core.NewUTXOSet() + utxos.RestoreFromSnapshot(core.UTXOSnapshot{ActiveUTXOs: records}) + records = nil + runtime.GC() + + var before runtime.MemStats + runtime.ReadMemStats(&before) + snapshot := utxos.TakeSnapshotView() + var afterView runtime.MemStats + runtime.ReadMemStats(&afterView) + if len(snapshot.ActiveUTXOs) != count { + t.Fatalf("snapshot view has %d UTXOs, want %d", len(snapshot.ActiveUTXOs), count) + } + viewAlloc := afterView.TotalAlloc - before.TotalAlloc + if viewAlloc > 64<<20 { + t.Fatalf("snapshot view allocated %d MiB for pointer indexes; limit is 64 MiB", viewAlloc>>20) + } + + probe := &snapshotScaleProbe{ + nextSample: 8 << 20, + peakHeap: afterView.HeapAlloc, + } + snap := startupSnapshot{ + Version: snapVersion, + UTXOs: snapshot, + } + compressedPath := filepath.Join(t.TempDir(), "snapshot.json.gz") + compressedFile, err := os.Create(compressedPath) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(compressedFile) + writeErr := writeStartupSnapshotJSON(io.MultiWriter(gz, probe), snap) + gzipErr := gz.Close() + fileErr := compressedFile.Close() + if writeErr != nil { + t.Fatal(writeErr) + } + if gzipErr != nil { + t.Fatal(gzipErr) + } + if fileErr != nil { + t.Fatal(fileErr) + } + compressedInfo, err := os.Stat(compressedPath) + if err != nil { + t.Fatal(err) + } + if compressedInfo.Size() == 0 { + t.Fatal("gzip snapshot file is empty") + } + if probe.bytesWritten < count*100 { + t.Fatalf("stream wrote only %d bytes for %d UTXOs", probe.bytesWritten, count) + } + if probe.largestWrite > 256<<10 { + t.Fatalf("snapshot writer emitted a %d-byte burst; limit is 256 KiB", probe.largestWrite) + } + if extra := probe.peakHeap - afterView.HeapAlloc; extra > 128<<20 { + t.Fatalf("snapshot JSON grew live heap by %d MiB; limit is 128 MiB", extra>>20) + } + t.Logf("2M UTXOs: view allocated %d MiB; JSON/gzip peak grew heap by %d MiB; wrote %d MiB JSON / %d MiB gzip (largest write %d KiB)", + viewAlloc>>20, + (probe.peakHeap-afterView.HeapAlloc)>>20, + probe.bytesWritten>>20, + compressedInfo.Size()>>20, + probe.largestWrite>>10) +} diff --git a/cmd/node/snapshot_utxo_count_test.go b/cmd/node/snapshot_utxo_count_test.go index 031f9ea..74ed602 100644 --- a/cmd/node/snapshot_utxo_count_test.go +++ b/cmd/node/snapshot_utxo_count_test.go @@ -18,7 +18,7 @@ package main // 1. Within-tolerance — snapshot count matches stored count → accepted. // 2. Exceeds tolerance — snapshot count diverges beyond 1% → rejected, warning logged. // 3. No metadata key — DB has no entry for this hash → check skipped, snapshot accepted. -// 4. Historical vs active separation — total stored UTXOs >> active count is not penalised. +// 4. Historical vs active separation — total stored UTXOs >> active count is not penalized. // 5. Store/Load round-trip with hash key — unit test for the DB helpers. import ( @@ -231,7 +231,7 @@ func TestUTXOCountCheck_NoMetadata(t *testing.T) { // (spent/staked outputs are never deleted from the historical store). // The divergence check must compare against the hash-keyed active_utxo_count // metadata — not the raw u/ record count — so a node with many historical -// spends is never incorrectly penalised. +// spends is never incorrectly penalized. func TestUTXOCountCheck_HistoricalRecordsIgnored(t *testing.T) { dir := t.TempDir() db, blocks := buildChainInStore(t, dir, 4) diff --git a/cmd/node/snapshot_watchdog_test.go b/cmd/node/snapshot_watchdog_test.go index 0bda4cf..367e05a 100644 --- a/cmd/node/snapshot_watchdog_test.go +++ b/cmd/node/snapshot_watchdog_test.go @@ -42,7 +42,7 @@ func TestMemWatchdogSavesSnapshotBeforeKill(t *testing.T) { // ── Step 2: set up a fake consensus engine ─────────────────────────────── // performShutdown closes stop and then waits on engineDone. // The goroutine below simulates consensus.Engine.Run: it exits immediately - // when stop is closed, which is the only behaviour the shutdown path needs. + // when stop is closed, which is the only behavior the shutdown path needs. engineStop := make(chan struct{}) engineDone := make(chan struct{}) go func() { diff --git a/cmd/node/spent_decoy_restart_test.go b/cmd/node/spent_decoy_restart_test.go index e6056a4..1d14921 100644 --- a/cmd/node/spent_decoy_restart_test.go +++ b/cmd/node/spent_decoy_restart_test.go @@ -23,7 +23,7 @@ package main // // When a partial checkpoint is present (saved by a previous interrupted scan or // the periodic snapshot timer), runStartupScan restores from it via -// RestoreFromSnapshot — which includes the serialised SpentDecoys — and then +// RestoreFromSnapshot — which includes the serialized SpentDecoys — and then // applies only the blocks AFTER the checkpoint via ApplyBlock. The total // spentPubKeys after the scan therefore equals: // @@ -280,17 +280,17 @@ func TestSpentDecoyPool_RebuildAfterSnapshot(t *testing.T) { blk3Hash := blk3.Hash() var wg sync.WaitGroup result, err := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: tipHeight, - TipHashHex: fmt.Sprintf("%x", blk3Hash[:]), - DB: db, - UTXOs: utxosRestart, - Registry: registryRestart, - KiFromIndex: false, // no pre-loaded key-image index; scan builds them incrementally - Log: log, - SnapshotWg: &wg, + DataDir: dir, + TipHeight: tipHeight, + TipHashHex: fmt.Sprintf("%x", blk3Hash[:]), + DB: db, + UTXOs: utxosRestart, + Registry: registryRestart, + KiFromIndex: false, // no pre-loaded key-image index; scan builds them incrementally + Log: log, + SnapshotWg: &wg, CheckpointInterval: 50000, - }) + }) wg.Wait() // ensure snapshot goroutine finishes before assertions if err != nil { @@ -430,17 +430,17 @@ func TestSpentDecoyPool_FullScanRebuildsDecoys(t *testing.T) { blk1Hash := blk1.Hash() var wg sync.WaitGroup result, err := runStartupScan(startupScanParams{ - DataDir: dir, - TipHeight: 1, - TipHashHex: fmt.Sprintf("%x", blk1Hash[:]), - DB: db, - UTXOs: utxos, - Registry: registry, - KiFromIndex: false, - Log: log, - SnapshotWg: &wg, + DataDir: dir, + TipHeight: 1, + TipHashHex: fmt.Sprintf("%x", blk1Hash[:]), + DB: db, + UTXOs: utxos, + Registry: registry, + KiFromIndex: false, + Log: log, + SnapshotWg: &wg, CheckpointInterval: 50000, - }) + }) wg.Wait() if err != nil { @@ -464,11 +464,12 @@ func TestSpentDecoyPool_FullScanRebuildsDecoys(t *testing.T) { // index (KiFromIndex=true) via MarkSpent BEFORE runStartupScan runs. With the // old unconditional ApplyBlock call, pass-1's double-spend check then fired // for every spending block — the whole block was skipped, so: -// • spentPubKeys (the Phase 2 ring-decoy pool) stayed empty → ring privacy +// - spentPubKeys (the Phase 2 ring-decoy pool) stayed empty → ring privacy // silently degraded to Phase 1 on every fast-path restart; -// • the spending block's own outputs (e.g. change) never entered the active +// - the spending block's own outputs (e.g. change) never entered the active // set; -// • the spent ring member stayed wrongly active in byPubKey. +// - the spent ring member stayed wrongly active in byPubKey. +// // The fix routes KiFromIndex=true replays through ReplayBlockKnownSpends // (pass-2 only). This test asserts all three outcomes. func TestSpentDecoyPool_KiFromIndexRebuildsDecoys(t *testing.T) { diff --git a/cmd/node/startup_integrity_test.go b/cmd/node/startup_integrity_test.go index 5bb9a76..30080ab 100644 --- a/cmd/node/startup_integrity_test.go +++ b/cmd/node/startup_integrity_test.go @@ -4,7 +4,7 @@ package main // self-heal, exercising the REAL production code path (checkStartupIntegrity, // called verbatim from run()). // -// The behaviour under test: when the canonical height index (h/) is +// The behavior under test: when the canonical height index (h/) is // missing or zeroed but the tip pointer is intact, the node repairs the index // from the tip pointer and logs the self-heal at INFO — NOT WARN or ERROR. // Operators filter alerts by log level, so an accidental revert of INFO → WARN diff --git a/cmd/node/utxo_index_fastpath_test.go b/cmd/node/utxo_index_fastpath_test.go index 4e54eb8..db9c020 100644 --- a/cmd/node/utxo_index_fastpath_test.go +++ b/cmd/node/utxo_index_fastpath_test.go @@ -15,8 +15,8 @@ package main import ( "encoding/json" "fmt" - "log/slog" "io" + "log/slog" "path/filepath" "sync" "testing" diff --git a/cmd/node/validator_list.go b/cmd/node/validator_list.go index 06e5d1f..5a22d64 100644 --- a/cmd/node/validator_list.go +++ b/cmd/node/validator_list.go @@ -21,7 +21,7 @@ import ( // // Decodes and returns the public keys from genesisValidators (hex strings as // they appear in genesis.yaml). This ensures handleIncomingBlock's -// isKnownValidator() and proposer-slot checks recognise the real network +// isKnownValidator() and proposer-slot checks recognize the real network // validators so the node can sync without producing blocks. // The node's own myKey must NOT appear in the returned list. // diff --git a/cmd/node/validator_list_test.go b/cmd/node/validator_list_test.go index 23dc5f0..a8342c1 100644 --- a/cmd/node/validator_list_test.go +++ b/cmd/node/validator_list_test.go @@ -57,9 +57,9 @@ func TestBuildValidatorList_NonValidatorMode_UsesGenesisKeys(t *testing.T) { // ── Call the production wiring function ─────────────────────────────────── validators, err := buildValidatorList( - true, // nonValidator = true - []string{genesisHex}, // genesis config validators (as hex) - myKey, // node's own identity key + true, // nonValidator = true + []string{genesisHex}, // genesis config validators (as hex) + myKey, // node's own identity key ) if err != nil { t.Fatalf("buildValidatorList: %v", err) @@ -100,8 +100,8 @@ func TestBuildValidatorList_ValidatorMode_UsesOwnKey(t *testing.T) { defer myKey.Destroy() validators, err := buildValidatorList( - false, // nonValidator = false (validator mode) - []string{}, // genesis config validators unused in this mode + false, // nonValidator = false (validator mode) + []string{}, // genesis config validators unused in this mode myKey, ) if err != nil { diff --git a/cmd/recover-tip/main_test.go b/cmd/recover-tip/main_test.go index 3cbe9f7..dcf9955 100644 --- a/cmd/recover-tip/main_test.go +++ b/cmd/recover-tip/main_test.go @@ -106,8 +106,8 @@ func TestPatchSnapshot_NormalUpdate(t *testing.T) { path := snapshotPath(dir, height) writeGzipSnapshot(t, path, startupSnapshot{ - Version: snapVersion, - TipHeight: height, + Version: snapVersion, + TipHeight: height, TipHashHex: oldHash, }) diff --git a/cmd/ring-index-maintenance/worker.go b/cmd/ring-index-maintenance/worker.go index 9d33d0e..1305341 100644 --- a/cmd/ring-index-maintenance/worker.go +++ b/cmd/ring-index-maintenance/worker.go @@ -15,8 +15,9 @@ import ( "syscall" "time" - "github.com/aperod/aperod/store" "golang.org/x/sys/unix" + + "github.com/aperod/aperod/store" ) type tipReceipt struct { diff --git a/cmd/sale-epoch-proposal/authority.go b/cmd/sale-epoch-proposal/authority.go index d8dab68..6c385a3 100644 --- a/cmd/sale-epoch-proposal/authority.go +++ b/cmd/sale-epoch-proposal/authority.go @@ -1,50 +1,73 @@ package main import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "github.com/aperod/aperod/config" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" - "github.com/aperod/aperod/store" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/aperod/aperod/config" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) // Resolve only the independently pinned, canonical authority fork already // configured on the producer. Native validator identity is not its signing key. func signingAuthority(cfg config.ConsensusConfig, d *store.DB) (*core.ValidatorAuthorityFork, error) { - if cfg.ValidatorAuthorityForkFile == "" { - if cfg.ValidatorAuthorityForkSHA256 != "" { return nil, fmt.Errorf("authority digest without rule") } - return nil, nil - } - want, err := hex.DecodeString(cfg.ValidatorAuthorityForkSHA256) - if err != nil || len(want) != 32 { return nil, fmt.Errorf("independent authority digest required") } - f, err := os.Open(cfg.ValidatorAuthorityForkFile) - if err != nil { return nil, err } - defer f.Close() - raw, err := io.ReadAll(io.LimitReader(f, 65537)) - if err != nil || len(raw) > 65536 { return nil, fmt.Errorf("authority rule exceeds limit") } - sum := sha256.Sum256(raw) - if !bytes.Equal(sum[:], want) { return nil, fmt.Errorf("authority digest mismatch") } - dec := json.NewDecoder(bytes.NewReader(raw)); dec.DisallowUnknownFields() - var rule core.ValidatorAuthorityFork - if err := dec.Decode(&rule); err != nil { return nil, err } - if err := dec.Decode(new(any)); err != io.EOF { return nil, fmt.Errorf("authority trailing data") } - if err := rule.Validate(); err != nil { return nil, err } - for _, anchor := range []struct{ height uint64; hash crypto.Hash32 }{{0, rule.Genesis}, {rule.Height-1, rule.Parent}} { - hash, found, err := d.GetCanonicalHash(anchor.height) - if err != nil || !found || hash != anchor.hash { return nil, fmt.Errorf("authority canonical anchor mismatch") } - } - if cfg.LPoDMigrationFile != "" { - witness, err := os.ReadFile(cfg.LPoDMigrationFile) - if err != nil || rule.LegacyWitnessSHA256 == (crypto.Hash32{}) || - crypto.Hash32(sha256.Sum256(witness)) != rule.LegacyWitnessSHA256 { - return nil, fmt.Errorf("authority historical witness mismatch") - } - } - return &rule, nil + if cfg.ValidatorAuthorityForkFile == "" { + if cfg.ValidatorAuthorityForkSHA256 != "" { + return nil, fmt.Errorf("authority digest without rule") + } + return nil, nil + } + want, err := hex.DecodeString(cfg.ValidatorAuthorityForkSHA256) + if err != nil || len(want) != 32 { + return nil, fmt.Errorf("independent authority digest required") + } + f, err := os.Open(cfg.ValidatorAuthorityForkFile) + if err != nil { + return nil, err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, 65537)) + if err != nil || len(raw) > 65536 { + return nil, fmt.Errorf("authority rule exceeds limit") + } + sum := sha256.Sum256(raw) + if !bytes.Equal(sum[:], want) { + return nil, fmt.Errorf("authority digest mismatch") + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + var rule core.ValidatorAuthorityFork + if err := dec.Decode(&rule); err != nil { + return nil, err + } + if err := dec.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("authority trailing data") + } + if err := rule.Validate(); err != nil { + return nil, err + } + for _, anchor := range []struct { + height uint64 + hash crypto.Hash32 + }{{0, rule.Genesis}, {rule.Height - 1, rule.Parent}} { + hash, found, err := d.GetCanonicalHash(anchor.height) + if err != nil || !found || hash != anchor.hash { + return nil, fmt.Errorf("authority canonical anchor mismatch") + } + } + if cfg.LPoDMigrationFile != "" { + witness, err := os.ReadFile(cfg.LPoDMigrationFile) + if err != nil || rule.LegacyWitnessSHA256 == (crypto.Hash32{}) || + crypto.Hash32(sha256.Sum256(witness)) != rule.LegacyWitnessSHA256 { + return nil, fmt.Errorf("authority historical witness mismatch") + } + } + return &rule, nil } diff --git a/cmd/sale-epoch-proposal/main.go b/cmd/sale-epoch-proposal/main.go index e586cdc..1ac341b 100644 --- a/cmd/sale-epoch-proposal/main.go +++ b/cmd/sale-epoch-proposal/main.go @@ -89,25 +89,31 @@ func run() error { SaleRemaining: left, SaleAuthority: head.Header.ValidatorPub} m := store.SaleMigration{Checkpoint: cp, Reconciliation: r} if *sign { -if *cfgPath == "" { return fmt.Errorf("explicit node config required for signing") } -cfg, err := config.Load(*cfgPath) -if err != nil { return err } -authority, err := signingAuthority(cfg.Consensus, d) -if err != nil { return err } -certificate, err := d.LoadFinalityCertificate() -if err != nil || certificate == nil || certificate.Height != height || certificate.BlockHash != parent { -return fmt.Errorf("exact canonical parent finality certificate required before signing") -} -producerVote := false -message := crypto.HashBytes([]byte("aperod/finalize/v1"), parent[:]) -for _, vote := range certificate.Votes { -if vote.Validator.Equals(head.Header.ValidatorPub) && authority.Authority(vote.Validator, height).Verify(message, vote.Signature) { -producerVote = true -} -} -if !producerVote { -return fmt.Errorf("canonical producer finality vote required before signing") -} + if *cfgPath == "" { + return fmt.Errorf("explicit node config required for signing") + } + cfg, err := config.Load(*cfgPath) + if err != nil { + return err + } + authority, err := signingAuthority(cfg.Consensus, d) + if err != nil { + return err + } + certificate, err := d.LoadFinalityCertificate() + if err != nil || certificate == nil || certificate.Height != height || certificate.BlockHash != parent { + return fmt.Errorf("exact canonical parent finality certificate required before signing") + } + producerVote := false + message := crypto.HashBytes([]byte("aperod/finalize/v1"), parent[:]) + for _, vote := range certificate.Votes { + if vote.Validator.Equals(head.Header.ValidatorPub) && authority.Authority(vote.Validator, height).Verify(message, vote.Signature) { + producerVote = true + } + } + if !producerVote { + return fmt.Errorf("canonical producer finality vote required before signing") + } info, err := os.Stat(cfg.Consensus.ValidatorKey) if err != nil || info.Mode().Perm()&0077 != 0 { return fmt.Errorf("protected producer key file required") @@ -122,12 +128,12 @@ return fmt.Errorf("canonical producer finality vote required before signing") return fmt.Errorf("invalid configured signer") } defer key.Destroy() -if !key.Public().Equals(authority.Authority(head.Header.ValidatorPub, height+1)) { + if !key.Public().Equals(authority.Authority(head.Header.ValidatorPub, height+1)) { return fmt.Errorf("signer is not the canonical parent producer") } -// Sale transactions are signed by the active key, not the retained stake ID. -cp.SaleAuthority = key.Public() -m.Checkpoint.SaleAuthority = key.Public() + // Sale transactions are signed by the active key, not the retained stake ID. + cp.SaleAuthority = key.Public() + m.Checkpoint.SaleAuthority = key.Public() signature, err := key.Sign(cp.Message()) if err != nil { return err @@ -159,8 +165,8 @@ m.Checkpoint.SaleAuthority = key.Public() if err := f.Close(); err != nil { return err } -checkpointHash := cp.Message() -fmt.Printf("epoch_proposal height=%d available_napro=%d checkpoint=%x historical_supply_verified=false signed=%t\n", height+1, left, checkpointHash[:], *sign) + checkpointHash := cp.Message() + fmt.Printf("epoch_proposal height=%d available_napro=%d checkpoint=%x historical_supply_verified=false signed=%t\n", height+1, left, checkpointHash[:], *sign) return nil } diff --git a/cmd/wallet-wasm/lpod.go b/cmd/wallet-wasm/lpod.go index e3b9998..5711771 100644 --- a/cmd/wallet-wasm/lpod.go +++ b/cmd/wallet-wasm/lpod.go @@ -48,7 +48,8 @@ func signLPoD(r lpodSignRequest, keys *wallet.DerivedKeys, source *core.OwnedUTX address := crypto.AddressFromKeys(crypto.MainnetByte, keys.Keys) var a core.LPoDPositionAction var sourceKey crypto.Scalar32 - if r.Action == "deposit" { + switch r.Action { + case "deposit": if source == nil || source.Amount == 0 { return nil, fmt.Errorf("select an exact owned source output") } @@ -69,7 +70,7 @@ func signLPoD(r lpodSignRequest, keys *wallet.DerivedKeys, source *core.OwnedUTX if err != nil || pub != source.OneTimePub { return nil, fmt.Errorf("source ownership mismatch") } - } else if r.Action == "withdraw" { + case "withdraw": if len(r.DepositJSON) > 4096 || json.Unmarshal([]byte(r.DepositJSON), &a) != nil { return nil, fmt.Errorf("invalid canonical deposit") } @@ -93,7 +94,7 @@ func signLPoD(r lpodSignRequest, keys *wallet.DerivedKeys, source *core.OwnedUTX a.Action = core.LPoDWithdraw a.Nonce = nonce + 1 a.WithdrawAmount = amount - } else { + default: return nil, fmt.Errorf("invalid LPoD action") } return core.BuildLPoDPositionTx(a, keys.Keys.Spend.Private, sourceKey) diff --git a/cmd/wallet-wasm/lpod_test.go b/cmd/wallet-wasm/lpod_test.go index 3a9a2df..c3044d3 100644 --- a/cmd/wallet-wasm/lpod_test.go +++ b/cmd/wallet-wasm/lpod_test.go @@ -110,7 +110,7 @@ func TestNativeLPoDWASMFixture(t *testing.T) { t.Fatal(err) } decoys = append(decoys, map[string]string{ - "one_time_pub": hex.EncodeToString(d.OneTimePub[:]), + "one_time_pub": hex.EncodeToString(d.OneTimePub[:]), "amount_commit": hex.EncodeToString(d.AmountCommit[:]), }) } diff --git a/cmd/wallet-wasm/main_js_wasm.go b/cmd/wallet-wasm/main_js_wasm.go index b18d2a4..dc67b96 100644 --- a/cmd/wallet-wasm/main_js_wasm.go +++ b/cmd/wallet-wasm/main_js_wasm.go @@ -12,6 +12,7 @@ import ( "syscall/js" "filippo.io/edwards25519" + "github.com/aperod/aperod/wallet" ) diff --git a/config/config.go b/config/config.go index 8db269b..6e22c8e 100644 --- a/config/config.go +++ b/config/config.go @@ -9,8 +9,9 @@ import ( "strings" "time" - "github.com/aperod/aperod/crypto" "gopkg.in/yaml.v3" + + "github.com/aperod/aperod/crypto" ) // Network identifies which chain this node is on. @@ -22,7 +23,7 @@ const ( Devnet Network = "devnet" ) -// SnapshotConfig controls snapshot integrity-check behaviour. +// SnapshotConfig controls snapshot integrity-check behavior. type SnapshotConfig struct { // UTXOCountTolerancePct is the maximum allowed percentage difference // between the snapshot's active UTXO count and the count stored in the @@ -33,24 +34,22 @@ type SnapshotConfig struct { UTXOCountTolerancePct float64 `yaml:"utxo_count_tolerance_pct"` // PeriodicSnapshotInterval is the number of blocks between periodic - // in-process UTXO snapshots. Taking one deep-copies the UTXO set and - // serializes it while blocks continue, which can exceed GOMEMLIMIT and - // trigger a second snapshot during shutdown. Enable only after sizing - // the node for the peak memory of both operations. + // in-process UTXO snapshots. Snapshot capture uses copy-on-write UTXO records, + // a bounded pointer view, and a fixed-buffer JSON stream. Only one periodic + // writer may run at a time, and graceful shutdown waits for it before saving + // the final tip. // // The shutdown snapshot (taken on clean exit) is unaffected by this // setting; it remains the primary crash-recovery mechanism. // - // Default: 0 (shutdown snapshot only). Set a positive interval only - // when there is sufficient memory for the full periodic snapshot. + // Default: 500 blocks. Set to 0 to disable periodic snapshots. PeriodicSnapshotInterval uint64 `yaml:"periodic_snapshot_interval"` // ScanCheckpointInterval is the number of blocks between intermediate - // checkpoints saved during the startup block scan. A smaller value means - // the node can resume from a more recent checkpoint after a crash mid-scan - // (faster crash recovery) but takes more frequent TakeSnapshot() calls, - // each of which briefly doubles node RSS. A larger value reduces peak - // memory pressure at the cost of replaying more blocks on restart. + // checkpoints saved during the startup block scan. A smaller value means the + // node can resume from a more recent checkpoint after a crash mid-scan. Each + // checkpoint captures a bounded pointer view and streams JSON; a larger value + // reduces checkpoint I/O at the cost of replaying more blocks on restart. // // Default: 50000. Must be > 0; a value of 0 is replaced by the default // at runtime. @@ -206,7 +205,7 @@ type P2PConfig struct { // When non-empty, any inbound TCP connection whose source IP is not covered // by an entry is rejected immediately — before any P2P handshake occurs. // Outbound dial-outs to bootnodes and discovery peers are not affected. - // An empty list means all source IPs are allowed (default behaviour). + // An empty list means all source IPs are allowed (default behavior). PeerWhitelist []string `yaml:"peer_whitelist"` // MaxPendingHandshakes caps the number of inbound connections that are // concurrently executing the TLS handshake. A peer that opens many TCP @@ -364,6 +363,15 @@ type ConsensusConfig struct { // AVMActivationHeight is the first block allowed to execute native Wasm // contracts. Zero disables AVM consensus to prevent an uncoordinated fork. AVMActivationHeight uint64 `yaml:"avm_activation_height"` + // AVMCheckpointFile is the signed, versioned state snapshot used to bound + // AVM recovery replay. Relative paths are resolved under data_dir. + AVMCheckpointFile string `yaml:"avm_checkpoint_file"` + // AVMCheckpointTrustAuthorities are explicit Ed25519 public-key pins for + // AVM state witnesses. Private keys must not be stored in node.yaml. + AVMCheckpointTrustAuthorities []string `yaml:"avm_checkpoint_trust_authorities"` + // AVMCheckpointWitnessThreshold is the required number of distinct + // signatures on a v2 checkpoint. Quorum checkpoints require at least 2. + AVMCheckpointWitnessThreshold int `yaml:"avm_checkpoint_witness_threshold"` // AVMGasBurnActivationHeight is the first block that burns the mandatory // AVM gas fee instead of paying it as a validator tip. Zero disables the // new rule and preserves historical block reward accounting. This is @@ -379,6 +387,17 @@ type ConsensusConfig struct { // LPoDMigrationFile is a complete, height/genesis-bound reconciliation witness. // Empty disables the fork. Merely setting a target amount never funds the pool. LPoDMigrationFile string `yaml:"lpod_migration_file"` + // FinalityMigrationFile is a signed, versioned attestation for one exact + // historical finality certificate. Empty leaves ordinary quorum checks intact. + FinalityMigrationFile string `yaml:"finality_migration_file"` + // FinalityMigrationTrustAuthorities are pinned validator identities allowed + // to authorize that one-certificate migration. They are not genesis validators. + FinalityMigrationTrustAuthorities []string `yaml:"finality_migration_trust_authorities"` + // StakeKeyImageV4ActivationHeight requires v4 stake deposits to prove that + // collateral's key image has never been spent. It is consensus-critical and + // must use the same future height on every validator; zero leaves legacy + // deposit formats enabled. + StakeKeyImageV4ActivationHeight uint64 `yaml:"stake_key_image_v4_activation_height"` // SaleMigrationFile is a local signed checkpoint and disjoint allocation // witness. Empty disables sales; it is not an amount or payment-server flag. SaleMigrationFile string `yaml:"sale_migration_file"` @@ -477,7 +496,7 @@ func DefaultConfig() *Config { }, Snapshot: SnapshotConfig{ UTXOCountTolerancePct: 1.0, - PeriodicSnapshotInterval: 0, + PeriodicSnapshotInterval: 500, ScanCheckpointInterval: 50_000, }, Pprof: PprofConfig{ @@ -740,6 +759,55 @@ func (c *Config) Validate() error { avmActivation, clsagActivation, ) } + if c.Consensus.AVMCheckpointFile != "" && len(c.Consensus.AVMCheckpointTrustAuthorities) == 0 { + return fmt.Errorf("avm_checkpoint_file requires avm_checkpoint_trust_authorities") + } + if c.Consensus.FinalityMigrationFile != "" && len(c.Consensus.FinalityMigrationTrustAuthorities) == 0 { + return fmt.Errorf("finality_migration_file requires finality_migration_trust_authorities") + } + if c.Consensus.FinalityMigrationFile == "" && len(c.Consensus.FinalityMigrationTrustAuthorities) != 0 { + return fmt.Errorf("finality_migration_trust_authorities requires finality_migration_file") + } + if len(c.Consensus.FinalityMigrationTrustAuthorities) > 256 { + return fmt.Errorf("finality migration has too many trust authorities") + } + if c.Consensus.AVMCheckpointWitnessThreshold != 0 && + len(c.Consensus.AVMCheckpointTrustAuthorities) == 0 { + return fmt.Errorf("avm_checkpoint_witness_threshold requires avm_checkpoint_trust_authorities") + } + if len(c.Consensus.AVMCheckpointTrustAuthorities) > 0 { + if avmActivation == 0 { + return fmt.Errorf("avm_checkpoint_trust_authorities requires avm_activation_height") + } + if c.Consensus.AVMCheckpointWitnessThreshold < 2 || + c.Consensus.AVMCheckpointWitnessThreshold > len(c.Consensus.AVMCheckpointTrustAuthorities) { + return fmt.Errorf( + "avm_checkpoint_witness_threshold must be between 2 and the authority count (%d)", + len(c.Consensus.AVMCheckpointTrustAuthorities), + ) + } + seen := make(map[string]struct{}, len(c.Consensus.AVMCheckpointTrustAuthorities)) + for i, encoded := range c.Consensus.AVMCheckpointTrustAuthorities { + raw, err := hex.DecodeString(encoded) + if err != nil || len(raw) != 32 { + return fmt.Errorf("avm_checkpoint_trust_authorities[%d] must be exactly 32-byte hex", i) + } + allZero := true + for _, b := range raw { + if b != 0 { + allZero = false + break + } + } + if allZero { + return fmt.Errorf("avm_checkpoint_trust_authorities[%d] must not be zero", i) + } + if _, duplicate := seen[string(raw)]; duplicate { + return fmt.Errorf("avm_checkpoint_trust_authorities contains a duplicate key") + } + seen[string(raw)] = struct{}{} + } + } if rewardActivation > 0 && !c.Consensus.NonValidator && c.Consensus.RewardAddress == "" { return fmt.Errorf( "reward_address must be set for validator nodes when reward_authorization_activation_height is enabled", @@ -937,7 +1005,7 @@ func (c *Config) Validate() error { // A zero value arises either because the field was omitted from node.yaml // (YAML leaves the field at its zero value, overriding the DefaultConfig // value of 300) or because the operator explicitly wrote -// mempool_evict_interval_sec: 0 to request the default behaviour. +// mempool_evict_interval_sec: 0 to request the default behavior. func ResolveMempoolEvictInterval(sec uint64) time.Duration { if sec == 0 { return 5 * time.Minute diff --git a/config/config_test.go b/config/config_test.go index 32f58cc..a7e6e73 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "time" @@ -157,6 +158,49 @@ func TestValidate_AVMGasBurnActivation(t *testing.T) { } } +func TestValidate_AVMCheckpointTrustAuthorities(t *testing.T) { + validKeys := []string{strings.Repeat("01", 32), strings.Repeat("02", 32), strings.Repeat("03", 32)} + tests := []struct { + name string + activation uint64 + file string + authorities []string + threshold int + wantError bool + }{ + {name: "valid 2 of 3 quorum", activation: 3, authorities: validKeys, threshold: 2}, + {name: "checkpoint file requires trust root", activation: 3, file: "avm-checkpoint.json", wantError: true}, + {name: "trust root requires AVM activation", authorities: validKeys, threshold: 2, wantError: true}, + {name: "rejects thresholdless v1 trust", activation: 3, authorities: validKeys, wantError: true}, + {name: "rejects one signer", activation: 3, authorities: validKeys[:1], threshold: 1, wantError: true}, + {name: "rejects threshold above authority count", activation: 3, authorities: validKeys, threshold: 4, wantError: true}, + {name: "threshold requires authority keys", activation: 3, threshold: 2, wantError: true}, + {name: "rejects malformed key", activation: 3, authorities: []string{"abcd"}, threshold: 2, wantError: true}, + {name: "rejects zero key", activation: 3, authorities: []string{strings.Repeat("00", 32), validKeys[1]}, threshold: 2, wantError: true}, + {name: "rejects duplicate keys", activation: 3, authorities: []string{validKeys[0], validKeys[0]}, threshold: 2, wantError: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.Network = Testnet + cfg.Consensus.NonValidator = true + cfg.Consensus.RingCTV4ActivationHeight = 1 + cfg.Consensus.RingCTCLSAGActivationHeight = 2 + cfg.Consensus.AVMActivationHeight = tc.activation + cfg.Consensus.AVMCheckpointFile = tc.file + cfg.Consensus.AVMCheckpointTrustAuthorities = tc.authorities + cfg.Consensus.AVMCheckpointWitnessThreshold = tc.threshold + err := cfg.Validate() + if tc.wantError && err == nil { + t.Fatal("invalid AVM checkpoint configuration was accepted") + } + if !tc.wantError && err != nil { + t.Fatalf("valid AVM checkpoint configuration rejected: %v", err) + } + }) + } +} + func TestLoad_AVMGasBurnActivationPersistsAcrossRestart(t *testing.T) { configPath := filepath.Join(t.TempDir(), "node.yaml") configYAML := []byte(`consensus: @@ -182,19 +226,19 @@ func TestLoad_AVMGasBurnActivationPersistsAcrossRestart(t *testing.T) { if got := firstStart.Consensus.AVMGasBurnActivationHeight; got != 125 { t.Fatalf("loaded activation height = %d, want 125", got) } -if got := firstStart.Consensus.StakedCollateralRingExclusionActivationHeight; got != 130 { - t.Fatalf("loaded staked-collateral activation height = %d, want 130", got) -} -firstStart.Consensus.StakedCollateralRingExclusionActivationHeight = 100 -if err := firstStart.Validate(); err == nil { - t.Fatal("staked-collateral activation before CLSAG activation was accepted") -} -firstStart.Consensus.StakedCollateralRingExclusionActivationHeight = 0 -firstStart.Network = "mainnet" -firstStart.Consensus.NonValidator = true -if err := firstStart.Validate(); err == nil { - t.Fatal("mainnet node without an explicit staked-collateral activation was accepted") -} + if got := firstStart.Consensus.StakedCollateralRingExclusionActivationHeight; got != 130 { + t.Fatalf("loaded staked-collateral activation height = %d, want 130", got) + } + firstStart.Consensus.StakedCollateralRingExclusionActivationHeight = 100 + if err := firstStart.Validate(); err == nil { + t.Fatal("staked-collateral activation before CLSAG activation was accepted") + } + firstStart.Consensus.StakedCollateralRingExclusionActivationHeight = 0 + firstStart.Network = "mainnet" + firstStart.Consensus.NonValidator = true + if err := firstStart.Validate(); err == nil { + t.Fatal("mainnet node without an explicit staked-collateral activation was accepted") + } restarted, err := Load(configPath) if err != nil { @@ -204,9 +248,9 @@ if err := firstStart.Validate(); err == nil { t.Fatalf("activation after restart = %d, want %d", got, firstStart.Consensus.AVMGasBurnActivationHeight) } -if got := restarted.Consensus.StakedCollateralRingExclusionActivationHeight; got != 130 { - t.Fatalf("staked-collateral activation after restart = %d, want 130", got) -} + if got := restarted.Consensus.StakedCollateralRingExclusionActivationHeight; got != 130 { + t.Fatalf("staked-collateral activation after restart = %d, want 130", got) + } if err := restarted.Validate(); err != nil { t.Fatalf("Validate() after restart: %v", err) } diff --git a/config/snapshot_default_test.go b/config/snapshot_default_test.go index 3ac42f3..d96574a 100644 --- a/config/snapshot_default_test.go +++ b/config/snapshot_default_test.go @@ -2,13 +2,13 @@ package config import "testing" -func TestDefaultConfigDisablesPeriodicSnapshot(t *testing.T) { +func TestDefaultConfigEnablesBoundedPeriodicSnapshots(t *testing.T) { cfg := DefaultConfig() - if cfg.Snapshot.PeriodicSnapshotInterval != 0 { - t.Fatalf("periodic snapshots must be opt-in on memory-limited nodes: got %d", + if cfg.Snapshot.PeriodicSnapshotInterval != 500 { + t.Fatalf("bounded periodic snapshot interval = %d, want 500", cfg.Snapshot.PeriodicSnapshotInterval) } if cfg.Snapshot.ScanCheckpointInterval == 0 { t.Fatal("startup scan checkpoints must remain enabled") } -} \ No newline at end of file +} diff --git a/consensus/avm_economics_test.go b/consensus/avm_economics_test.go index 586c7c1..51bffd6 100644 --- a/consensus/avm_economics_test.go +++ b/consensus/avm_economics_test.go @@ -182,7 +182,7 @@ func TestAuthorizedRewardAVMGasBurnBoundaryAndRestart(t *testing.T) { } avmTx.Fee = minimumFee + gasFee - buildBlock := func(height, rewardAmount uint64) *core.Block { + buildBlock := func(height, rewardAmount uint64, tx core.Transaction) *core.Block { t.Helper() reward, buildErr := core.BuildAuthorizedRewardTx( rewardAddress, @@ -201,7 +201,7 @@ func TestAuthorizedRewardAVMGasBurnBoundaryAndRestart(t *testing.T) { ValidatorPub: validatorPub, BaseFee: baseFee, }, - Txs: []core.Transaction{*reward, avmTx}, + Txs: []core.Transaction{*reward, tx}, } } @@ -214,7 +214,7 @@ func TestAuthorizedRewardAVMGasBurnBoundaryAndRestart(t *testing.T) { t.Fatalf("pre-activation authorized reward = %d, want legacy gas tip total %d", preActivationReward, want) } - preActivationBlock := buildBlock(gasActivation-1, preActivationReward) + preActivationBlock := buildBlock(gasActivation-1, preActivationReward, avmTx) if err := engine.validateCoinbasePolicy(preActivationBlock); err != nil { t.Fatalf("pre-activation legacy gas-as-tip block rejected: %v", err) } @@ -228,24 +228,44 @@ func TestAuthorizedRewardAVMGasBurnBoundaryAndRestart(t *testing.T) { t.Fatalf("post-activation gas-only reward = %d, want base %d", postActivationReward, postActivationBase) } - oversizedLegacyBlock := buildBlock(gasActivation, postActivationBase+gasFee) + oversizedLegacyBlock := buildBlock(gasActivation, postActivationBase+gasFee, avmTx) if err := engine.validateCoinbasePolicy(oversizedLegacyBlock); err == nil { t.Fatal("pre-activation gas-as-tip reward accepted at AVM gas-burn activation") } - postActivationBlock := buildBlock(gasActivation, postActivationReward) + postActivationBlock := buildBlock(gasActivation, postActivationReward, avmTx) if err := engine.validateCoinbasePolicy(postActivationBlock); err != nil { t.Fatalf("gas-burn activation reward rejected: %v", err) } + const explicitTip = uint64(77) + tippedAVMTx := avmTx + tippedAVMTx.Fee += explicitTip + tippedReward, err := engine.authorizedRewardAmount(gasActivation, []core.Transaction{tippedAVMTx}) + if err != nil { + t.Fatalf("post-activation tipped reward: %v", err) + } + if want := postActivationBase + explicitTip; tippedReward != want { + t.Fatalf("post-activation reward with separate tip = %d, want base plus tip %d", + tippedReward, want) + } + tippedBlock := buildBlock(gasActivation, tippedReward, tippedAVMTx) + if err := engine.validateCoinbasePolicy(tippedBlock); err != nil { + t.Fatalf("gas-burn activation rejected the separately tipped reward: %v", err) + } + legacyTippedBlock := buildBlock(gasActivation, postActivationBase+gasFee+explicitTip, tippedAVMTx) + if err := engine.validateCoinbasePolicy(legacyTippedBlock); err == nil { + t.Fatal("gas-burn activation accepted a reward that paid mandatory gas as a tip") + } + restarted := newEngine() - restartedReward, err := restarted.expectedAuthorizedRewardAmount(postActivationBlock) + restartedReward, err := restarted.expectedAuthorizedRewardAmount(tippedBlock) if err != nil { t.Fatalf("restarted validator expected reward: %v", err) } - if restartedReward != postActivationReward { - t.Fatalf("restarted validator reward = %d, want %d", restartedReward, postActivationReward) + if restartedReward != tippedReward { + t.Fatalf("restarted validator reward = %d, want %d", restartedReward, tippedReward) } - if err := restarted.validateCoinbasePolicy(postActivationBlock); err != nil { + if err := restarted.validateCoinbasePolicy(tippedBlock); err != nil { t.Fatalf("restarted validator rejected activation block: %v", err) } } diff --git a/consensus/avm_http_stress_linux_test.go b/consensus/avm_http_stress_linux_test.go new file mode 100644 index 0000000..6198a27 --- /dev/null +++ b/consensus/avm_http_stress_linux_test.go @@ -0,0 +1,579 @@ +package consensus + +// This is a two-engine integration network, not an OS-process/P2P benchmark. +// Nodes have independent LevelDB/UTXO/mempool/AVM state. Public submissions use +// production HTTP middleware; private loopback HTTP carries peer txs and blocks. +// The client refuses non-loopback destinations, and all keys/funding are synthetic. + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "reflect" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/avm" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +const ( + httpStressPoolCount = 8 + httpStressPoolBytes = 24 << 10 + httpStressCadence = 3 * time.Second // normal production target, not an accelerated benchmark +) + +type httpStressCase struct { + name string + body []byte +} + +type httpStressStats struct { + mu sync.Mutex + attempts, accepted uint64 + rejected, throttled, other uint64 + latency []time.Duration // excludes 429: actual admission path +} + +func (s *httpStressStats) record(code int, elapsed time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.attempts++ + switch code { + case http.StatusAccepted: + s.accepted++ + case http.StatusBadRequest, http.StatusUnprocessableEntity: + s.rejected++ + case http.StatusTooManyRequests: + s.throttled++ + default: + s.other++ + } + if code != http.StatusTooManyRequests { + s.latency = append(s.latency, elapsed) + } +} + +func stressRequest(client *http.Client, endpoint, ip string, body []byte) (int, string) { + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return 0, err.Error() + } + req.Header.Set("Content-Type", "application/json") + if ip != "" { + // Model a bounded set of origins behind the trusted loopback proxy. + req.Header.Set("X-Forwarded-For", ip) + } + resp, err := client.Do(req) + if err != nil { + return 0, err.Error() + } + defer resp.Body.Close() + value, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return 0, err.Error() + } + return resp.StatusCode, string(value) +} + +func stressJSON(t *testing.T, value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +func stressRSS() (uint64, error) { + raw, err := os.ReadFile("/proc/self/statm") + if err != nil { + return 0, err + } + fields := strings.Fields(string(raw)) + if len(fields) < 2 { + return 0, fmt.Errorf("invalid statm") + } + pages, err := strconv.ParseUint(fields[1], 10, 64) + return pages * uint64(os.Getpagesize()), err +} + +func stressCPU() (float64, error) { + var usage syscall.Rusage + if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil { + return 0, err + } + return float64(usage.Utime.Sec+usage.Stime.Sec) + + float64(usage.Utime.Usec+usage.Stime.Usec)/1e6, nil +} + +func TestAVMMixedHTTPStress(t *testing.T) { + if os.Getenv("APEROD_RUN_AVM_STRESS") != "1" { + t.Skip("isolated stress test requires APEROD_RUN_AVM_STRESS=1") + } + duration := 2 * time.Minute + if raw := os.Getenv("APEROD_AVM_STRESS_DURATION"); raw != "" { + var err error + duration, err = time.ParseDuration(raw) + if err != nil || duration < 2*time.Second || duration > 5*time.Minute { + t.Fatal("APEROD_AVM_STRESS_DURATION must be between 2s and 5m") + } + } + workers := 12 + if raw := os.Getenv("APEROD_AVM_STRESS_WORKERS"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 6 || n > 32 { + t.Fatal("HTTP stress workers must be between 6 and 32") + } + workers = n + } + keys := []crypto.ValidatorPrivKey{validatorKey(t, 0x71), validatorKey(t, 0x72)} + pubs := []crypto.ValidatorPubKey{keys[0].Public(), keys[1].Public()} + root := t.TempDir() + nodes := []*avmEngineNode{ + newAVMEngineNode(t, root+"/a", keys, pubs), + newAVMEngineNode(t, root+"/b", keys, pubs), + } + for i, node := range nodes { + locked, err := crypto.NewLockedValidatorKey(keys[i].Bytes(), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(locked.Destroy) + node.engine.cfg.MyKey = locked + cfg := core.DefaultMempoolConfig() + cfg.MaxSize, cfg.MaxBytes = httpStressPoolCount, httpStressPoolBytes + cfg.CurrentHeight = node.chain.Height + cfg.RingCTV4ActivationHeight, cfg.RingCTCLSAGActivationHeight, cfg.AVMActivationHeight = 1, 1, 1 + cfg.Verifier = core.NewTxVerifier(node.utxos) + state := avm.LevelStore{DB: node.db} + cfg.AVMNonceLookup = func(signer [32]byte) (uint64, error) { return avm.SignerNonce(state, signer) } + cfg.AVMAdmissionCheck = func(p *core.AVMPayload) error { return avm.ValidateMempoolAdmission(state, p) } + node.engine.pool = core.NewMempool(cfg) + } + + // Enough independent, genuinely funded transactions to submit fresh valid + // work across the entire run; do not count duplicate replays as valid load. + const positiveCount = 64 + owners := make([]*crypto.WalletKeyPair, positiveCount+5) + for i := range owners { + var err error + owners[i], err = crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + } + recipient, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + genesis, blinds := engineGenesis(t, keys[0], owners, 2_000_000_000) + for _, node := range nodes { + node.installGenesis(t, genesis) + } + build := func(owner int, signer ed25519.PrivateKey, action core.AVMAction, contract [32]byte, nonce uint64, code []byte, gas uint64) core.Transaction { + tx, _, decoys := buildAVMCLSAG(t, genesis, owners[owner], recipient, blinds[owner], + signer, action, contract, nonce, code, func(p *core.AVMPayload) { p.GasLimit = gas }) + for _, d := range decoys { + for _, node := range nodes { + node.utxos.Add(&core.UTXO{OneTimePub: d.OneTimePub, AmountCommit: d.AmountCommit}) + } + } + // Controls must have valid wallet signatures, even when execution/nonce + // will fail. Mutating AVM fields after CLSAG signing is not such a control. + if err := core.NewTxVerifier(nodes[0].utxos).VerifyTxAtHeight(&tx, 1); err != nil { + t.Fatalf("invalid cryptographic fixture: %v", err) + } + return tx + } + newSigner := func() ed25519.PrivateKey { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return key + } + seedSigner := newSigner() + seed := build(0, seedSigner, core.AVMDeployContract, [32]byte{}, 0, stateWriteModule(), 1000) + positives := make([]core.Transaction, positiveCount) + for i := range positives { + positives[i] = build(i+5, newSigner(), core.AVMDeployContract, [32]byte{}, 0, + stateWriteModule(), core.AVMMaxBlockGas/2+1) + } + stale := build(1, seedSigner, core.AVMExecuteContract, seed.AVM.ContractID, 0, nil, 1000) + future := build(2, newSigner(), core.AVMDeployContract, [32]byte{}, 2, stateWriteModule(), 1000) + malformed := build(3, newSigner(), core.AVMDeployContract, [32]byte{}, 0, []byte{0, 'a', 's', 'm'}, 1000) + outOfGas := build(4, newSigner(), core.AVMDeployContract, [32]byte{}, 0, stateWriteModule(), core.AVMMinGasLimit) + var oversized core.Transaction + if err := json.Unmarshal(stressJSON(t, positives[0]), &oversized); err != nil { + t.Fatal(err) + } + oversized.AVM.Code = make([]byte, core.AVMMaxCodeSize+1) // structural rejection, before crypto + cases := []httpStressCase{ + {"stale_nonce", stressJSON(t, map[string]any{"tx": stale})}, + {"future_nonce", stressJSON(t, map[string]any{"tx": future})}, + {"malformed_wasm", stressJSON(t, map[string]any{"tx": malformed})}, + {"out_of_gas", stressJSON(t, map[string]any{"tx": outOfGas})}, + {"oversized_code", stressJSON(t, map[string]any{"tx": oversized})}, + {"http_body_limit", bytes.Repeat([]byte(" "), (4<<20)+1)}, + } + + dialer := &net.Dialer{Timeout: time.Second} + transport := &http.Transport{MaxConnsPerHost: workers + 8, MaxIdleConnsPerHost: workers + 8, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, _, err := net.SplitHostPort(address) + if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() { + return nil, fmt.Errorf("stress client refuses non-loopback destination") + } + return dialer.DialContext(ctx, network, address) + }} + t.Cleanup(transport.CloseIdleConnections) + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + servers := make([]*httptest.Server, 2) + for i, node := range nodes { + public := api.NewServer("", node.chain, node.engine.pool, node.utxos, testNopLogger()) + public.SetAVMStore(1, avm.LevelStore{DB: node.db}) + public.SetReady() + mux := http.NewServeMux() + mux.Handle("/api/", api.CORSConfig{}.Middleware(api.NewRateLimiter().Middleware(public))) + mux.HandleFunc("/peer/tx", func(w http.ResponseWriter, r *http.Request) { + var tx core.Transaction + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&tx); err != nil { + http.Error(w, err.Error(), 400) + return + } + if err := node.engine.pool.Add(tx); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + w.WriteHeader(202) + }) + mux.HandleFunc("/peer/block", func(w http.ResponseWriter, r *http.Request) { + var block core.Block + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&block); err != nil { + http.Error(w, err.Error(), 400) + return + } + if err := node.engine.handleIncomingBlock(&block); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + w.WriteHeader(204) + }) + servers[i] = httptest.NewServer(mux) + t.Cleanup(servers[i].Close) + peerIndex := 1 - i + public.SetTransactionBroadcaster(func(tx *core.Transaction) { + // No production P2P host or external peer is configured here. + raw, _ := json.Marshal(tx) + stressRequest(client, servers[peerIndex].URL+"/peer/tx", "", raw) + }) + } + var blockLatency, blockGaps []time.Duration + var maxBlockGas uint64 + gasSelectionExercised := false + produce := func(measure bool) { + t.Helper() + start := time.Now() + height := nodes[0].chain.Height() + 1 + expected := nodes[0].engine.proposerAt(uint32(height)) + producer := nodes[0] + if expected.Equals(keys[1].Public()) { + producer = nodes[1] + } + backlog := producer.engine.pool.Count() + block, err := producer.engine.produceBlock(height, height, nodes[0].chain.Tip()) + if err != nil { + t.Fatal(err) + } + var gas uint64 + for _, tx := range block.Txs { + if tx.AVM != nil { + gas += tx.AVM.GasLimit + } + } + if gas > core.AVMMaxBlockGas { + t.Fatal("producer exceeded protocol block-gas limit") + } + if gas > maxBlockGas { + maxBlockGas = gas + } + if backlog > 1 && gas == core.AVMMaxBlockGas/2+1 { + gasSelectionExercised = true + } + for _, server := range servers { + if status, body := stressRequest(client, server.URL+"/peer/block", "", stressJSON(t, block)); status != 204 { + t.Fatalf("peer block rejected: %d %s", status, body) + } + } + if measure { + blockLatency = append(blockLatency, time.Since(start)) + } + } + if status, body := stressRequest(client, servers[0].URL+"/api/v1/avm/transactions", "192.0.2.1", + stressJSON(t, map[string]any{"tx": seed})); status != 202 { + t.Fatalf("valid seed rejected: %d %s", status, body) + } + produce(false) + for n := range nodes { + for i, c := range cases { + status, body := stressRequest(client, servers[n].URL+"/api/v1/avm/transactions", + fmt.Sprintf("198.51.100.%d", n*len(cases)+i+1), c.body) + expected := 422 + if c.name == "http_body_limit" { + expected = 400 + } + if status != expected { + t.Fatalf("%s control on node %d: status=%d body=%s", c.name, n, status, body) + } + if (c.name == "malformed_wasm" || c.name == "out_of_gas") && !strings.Contains(body, "AVM admission rejected") { + t.Fatalf("%s did not reach dynamic AVM validation: %s", c.name, body) + } + if strings.Contains(c.name, "nonce") && !strings.Contains(body, "nonce") { + t.Fatalf("nonce control rejected for wrong reason: %s", body) + } + } + } + // Baseline uses exactly the same producer + peer validation/commit path. + var baseline []time.Duration + for i := 0; i < 10; i++ { + start := time.Now() + produce(false) + baseline = append(baseline, time.Since(start)) + time.Sleep(httpStressCadence) + } + stats := make([]*httpStressStats, len(cases)) + for i := range stats { + stats[i] = &httpStressStats{} + } + positiveStats := &httpStressStats{} + positiveBodies := make([][]byte, len(positives)) + for i, tx := range positives { + positiveBodies[i] = stressJSON(t, map[string]any{"tx": tx}) + } + var before runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + startRSS, err := stressRSS() + if err != nil { + t.Fatal(err) + } + cpuBefore, err := stressCPU() + if err != nil { + t.Fatal(err) + } + peakRSS, peakHeap, peakCount, peakBytes := startRSS, before.HeapAlloc, 0, 0 + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + c := cases[worker%len(cases)] + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + start := time.Now() + code, _ := stressRequest(client, servers[(worker/len(cases))%2].URL+"/api/v1/avm/transactions", + fmt.Sprintf("203.0.113.%d", worker+1), c.body) + stats[worker%len(cases)].record(code, time.Since(start)) + } + } + }(i) + } + defer func() { cancel(); wg.Wait() }() + resourceError := make(chan string, 1) + sample := func() error { + rss, err := stressRSS() + if err != nil { + return err + } + if rss > peakRSS { + peakRSS = rss + } + var m runtime.MemStats + runtime.ReadMemStats(&m) + if m.HeapAlloc > peakHeap { + peakHeap = m.HeapAlloc + } + for _, n := range nodes { + count, bytes := n.engine.pool.Count(), n.engine.pool.TotalBytes() + if count > peakCount { + peakCount = count + } + if bytes > peakBytes { + peakBytes = bytes + } + if count > httpStressPoolCount || bytes > httpStressPoolBytes { + return fmt.Errorf("mempool cap exceeded count=%d bytes=%d", count, bytes) + } + } + return nil + } + wg.Add(1) + go func() { + defer wg.Done() + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + if err := sample(); err != nil { + resourceError <- err.Error() + return + } + } + } + }() + started := time.Now() + // Deliberately saturate the lowered test pool limits, then continue fresh + // valid traffic throughout the soak. These are fixture caps, not a claim + // to have filled the production 256-MiB pool in this 8-GiB workspace. + // Keep client-side signing/submission off the block-production scheduler; + // otherwise time waiting for an HTTP response would fake a consensus stall. + positiveError := make(chan string, 1) + wg.Add(1) + go func() { + defer wg.Done() + tick := time.NewTicker(duration / (positiveCount - 12)) + defer tick.Stop() + for index, raw := range positiveBodies { + if index >= 16 { + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } + select { + case <-ctx.Done(): + return + default: + } + start := time.Now() + code, body := stressRequest(client, servers[index%2].URL+"/api/v1/avm/transactions", + fmt.Sprintf("192.0.2.%d", index+2), raw) + positiveStats.record(code, time.Since(start)) + if code != 202 { + positiveError <- fmt.Sprintf("valid submission failed: %d %s", code, body) + return + } + } + }() + lastBlock := time.Now() + for time.Since(started) < duration { + slot := time.Now() + produce(true) + now := time.Now() + blockGaps = append(blockGaps, now.Sub(lastBlock)) + lastBlock = now + if sleep := httpStressCadence - time.Since(slot); sleep > 0 { + time.Sleep(sleep) + } + } + cancel() + wg.Wait() + select { + case err := <-positiveError: + t.Fatal(err) + default: + } + select { + case err := <-resourceError: + t.Fatal(err) + default: + } + elapsed := time.Since(started) + cpuAfter, err := stressCPU() + if err != nil { + t.Fatal(err) + } + for i := 0; i < httpStressPoolCount+1 && (nodes[0].engine.pool.Count() > 0 || nodes[1].engine.pool.Count() > 0); i++ { + produce(false) + } + if !reflect.DeepEqual(nodes[0].snapshot(t), nodes[1].snapshot(t)) { + t.Fatal("independent validator states diverged") + } + for _, n := range nodes { + if n.engine.halted.Load() || n.engine.pool.Count() != 0 || n.engine.pool.TotalBytes() != 0 { + t.Fatal("node halted or retained mempool residue after drain") + } + } + confirmed := 0 + for _, tx := range positives { + h := tx.Hash() + key := append([]byte{0xff, 'a', 'v', 'm', '/', 'r', '/'}, h[:]...) + _, found, err := (avm.LevelStore{DB: nodes[0].db}).Get(key) + if err != nil { + t.Fatal(err) + } + if found { + confirmed++ + } + } + var total uint64 + for i, stat := range stats { + if stat.attempts == 0 || stat.rejected == 0 || stat.throttled == 0 || stat.accepted != 0 || stat.other != 0 { + t.Fatalf("invalid class %s not safely exercised: %+v", cases[i].name, stat) + } + sort.Slice(stat.latency, func(i, j int) bool { return stat.latency[i] < stat.latency[j] }) + p95, p99 := percentileDuration(stat.latency, 95), percentileDuration(stat.latency, 99) + if p99 > time.Second { + t.Fatalf("%s admission p99=%s exceeds 1s", cases[i].name, p99) + } + total += stat.attempts + t.Logf("AVM_HTTP_CLASS name=%s attempts=%d rejected=%d throttled=%d admission_p95=%s admission_p99=%s", + cases[i].name, stat.attempts, stat.rejected, stat.throttled, p95, p99) + } + runtime.GC() + var after runtime.MemStats + runtime.ReadMemStats(&after) + sort.Slice(blockLatency, func(i, j int) bool { return blockLatency[i] < blockLatency[j] }) + sort.Slice(blockGaps, func(i, j int) bool { return blockGaps[i] < blockGaps[j] }) + sort.Slice(baseline, func(i, j int) bool { return baseline[i] < baseline[j] }) + sort.Slice(positiveStats.latency, func(i, j int) bool { return positiveStats.latency[i] < positiveStats.latency[j] }) + blockP99 := percentileDuration(blockLatency, 99) + gapP99 := percentileDuration(blockGaps, 99) + cpuCores := (cpuAfter - cpuBefore) / elapsed.Seconds() + if confirmed < 5 || !gasSelectionExercised || peakCount < 4 { + t.Fatalf("insufficient positive/capacity controls: confirmed=%d gas_selection=%v peak_count=%d", confirmed, gasSelectionExercised, peakCount) + } + if len(blockLatency) < int(elapsed/httpStressCadence)*95/100 || blockP99 > 500*time.Millisecond || gapP99 > httpStressCadence+500*time.Millisecond || blockGaps[len(blockGaps)-1] > 2*httpStressCadence { + t.Fatalf("block cadence failed: blocks=%d elapsed=%s p99_commit=%s p99_gap=%s", len(blockLatency), elapsed, blockP99, gapP99) + } + if positiveStats.other != 0 || percentileDuration(positiveStats.latency, 99) > time.Second { + t.Fatal("valid admission failed latency/error bound") + } + if peakRSS > startRSS+(256<<20) || int64(after.HeapAlloc)-int64(before.HeapAlloc) > 64<<20 || cpuCores > float64(runtime.GOMAXPROCS(0))+0.5 { + t.Fatalf("process resource bound exceeded: rss_start=%d rss_peak=%d retained_heap_delta=%d cpu_cores=%.2f", + startRSS, peakRSS, int64(after.HeapAlloc)-int64(before.HeapAlloc), cpuCores) + } + t.Logf("AVM_HTTP_STRESS_SUMMARY duration=%s nodes=2 transport=loopback_http target_cadence=%s workers=%d attempts=%d requests_per_sec=%.2f valid_accepted=%d valid_confirmed=%d valid_p95=%s valid_p99=%s blocks=%d blocks_per_sec=%.2f baseline_commit_p99=%s commit_p95=%s commit_p99=%s gap_p99=%s gap_max=%s cpu_seconds=%.3f avg_cpu_cores=%.3f rss_start=%d rss_peak=%d heap_peak=%d retained_heap_delta=%d mempool_peak_count=%d/%d mempool_peak_bytes=%d/%d block_reserved_gas_peak=%d/%d", + elapsed.Round(time.Millisecond), httpStressCadence, workers, total+positiveStats.attempts, float64(total+positiveStats.attempts)/elapsed.Seconds(), + positiveStats.accepted, confirmed, percentileDuration(positiveStats.latency, 95), percentileDuration(positiveStats.latency, 99), + len(blockLatency), float64(len(blockLatency))/elapsed.Seconds(), percentileDuration(baseline, 99), + percentileDuration(blockLatency, 95), blockP99, gapP99, blockGaps[len(blockGaps)-1], cpuAfter-cpuBefore, cpuCores, startRSS, peakRSS, peakHeap, + int64(after.HeapAlloc)-int64(before.HeapAlloc), peakCount, httpStressPoolCount, peakBytes, httpStressPoolBytes, maxBlockGas, core.AVMMaxBlockGas) +} diff --git a/consensus/avm_two_validator_integration_test.go b/consensus/avm_two_validator_integration_test.go index 4a728e1..83d0d2d 100644 --- a/consensus/avm_two_validator_integration_test.go +++ b/consensus/avm_two_validator_integration_test.go @@ -18,12 +18,13 @@ import ( "testing" "time" + "github.com/tetratelabs/wabin/binary" + "github.com/tetratelabs/wabin/wasm" + "github.com/aperod/aperod/avm" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/store" - "github.com/tetratelabs/wabin/binary" - "github.com/tetratelabs/wabin/wasm" ) // TestAVMTwoValidatorEngineIntegration replaces the old deploy-package @@ -316,7 +317,7 @@ func engineGenesis(t *testing.T, validator crypto.ValidatorPrivKey, owners []*cr } func buildAVMCLSAG(t *testing.T, genesis *core.Block, owner, recipient *crypto.WalletKeyPair, blind crypto.BlindFactor, priv ed25519.PrivateKey, - action core.AVMAction, contract [32]byte, nonce uint64, code []byte) (core.Transaction, *core.BuildResult, []core.DecoyUTXO) { + action core.AVMAction, contract [32]byte, nonce uint64, code []byte, edits ...func(*core.AVMPayload)) (core.Transaction, *core.BuildResult, []core.DecoyUTXO) { t.Helper() chain := core.NewChain() if err := chain.SetGenesis(genesis); err != nil { @@ -351,6 +352,9 @@ func buildAVMCLSAG(t *testing.T, genesis *core.Block, owner, recipient *crypto.W if action == core.AVMDeployContract { payload.ContractID = core.DeriveAVMContractID(payload.Signer, nonce, code) } + for _, edit := range edits { + edit(&payload) + } hash := payload.SigningHash() copy(payload.Signature[:], ed25519.Sign(priv, hash[:])) from, to := crypto.AddressFromKeys(crypto.TestnetByte, owner), crypto.AddressFromKeys(crypto.TestnetByte, recipient) diff --git a/consensus/consensus_test.go b/consensus/consensus_test.go index b7006b0..4269d73 100644 --- a/consensus/consensus_test.go +++ b/consensus/consensus_test.go @@ -52,11 +52,11 @@ func newEngine(t *testing.T, validators []crypto.ValidatorPubKey, myKey *crypto. t.Helper() mp := core.NewMempool(core.DefaultMempoolConfig()) return consensus.NewEngine(consensus.Config{ -OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: validators, - MyKey: myKey, + OnCanonicalBlock: noopCanonicalPersistence, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: validators, + MyKey: myKey, RingCTV4ActivationHeight: ^uint64(0), }, chain, mp, newNopLogger()) } @@ -661,10 +661,10 @@ func TestEngine_NonValidatorMode_NeverProducesBlock(t *testing.T) { mp := core.NewMempool(core.DefaultMempoolConfig()) eng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{pub}, - MyKey: nil, // non-validator mode + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{pub}, + MyKey: nil, // non-validator mode }, chain, mp, newNopLogger()) stop := make(chan struct{}) diff --git a/consensus/finality_migration.go b/consensus/finality_migration.go new file mode 100644 index 0000000..f39342a --- /dev/null +++ b/consensus/finality_migration.go @@ -0,0 +1,88 @@ +package consensus + +import ( + "fmt" + "math" + + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" +) + +func (e *Engine) verifyFinalityCommitteeMigration(certificate *store.FinalityCertificate) ([]crypto.ValidatorPubKey, error) { + migration := e.cfg.FinalityMigration + if migration == nil || certificate == nil || e.cfg.Store == nil { + return nil, fmt.Errorf("finality migration: missing migration, certificate, or store") + } + genesis := e.chain.Genesis() + tip := e.chain.Tip() + if genesis == nil || tip == nil || migration.Genesis != genesis.Hash() { + return nil, fmt.Errorf("finality migration: network genesis mismatch") + } + if migration.CertificateHeight != certificate.Height || + migration.CertificateBlockHash != certificate.BlockHash { + return nil, fmt.Errorf("finality migration: certificate binding mismatch") + } + if migration.ApprovalHeight > tip.Header.Height { + return nil, fmt.Errorf("finality migration: approval anchor is above the canonical tip") + } + storedTipHash, storedTipHeight, err := e.cfg.Store.GetTip() + if err != nil || storedTipHeight != tip.Header.Height || storedTipHash != tip.Hash() { + return nil, fmt.Errorf("finality migration: durable tip does not match the canonical chain") + } + anchorHash, found, err := e.cfg.Store.GetCanonicalHash(migration.ApprovalHeight) + if err != nil || !found || anchorHash != migration.ApprovalBlockHash { + return nil, fmt.Errorf("finality migration: approval anchor is not canonical") + } + thresholdPPM := math.Round(e.cfg.BFTThreshold * 1_000_000) + if math.IsNaN(e.cfg.BFTThreshold) || math.IsInf(e.cfg.BFTThreshold, 0) || + thresholdPPM < 1 || thresholdPPM >= 1_000_000 || + uint32(thresholdPPM) != migration.BFTThresholdPPM { + return nil, fmt.Errorf("finality migration: quorum rule does not match node configuration") + } + + authorities := e.cfg.FinalityMigrationTrustAuthorities + if len(authorities) == 0 || len(authorities) > 256 { + return nil, fmt.Errorf("finality migration: explicit trust authorities are required") + } + trusted := make(map[string]bool, len(authorities)) + for _, authority := range authorities { + if len(authority) != 32 || isZeroFinalityValidator(authority) { + return nil, fmt.Errorf("finality migration: malformed trust authority") + } + id := authority.Hex() + if trusted[id] { + return nil, fmt.Errorf("finality migration: duplicate trust authority") + } + trusted[id] = true + } + message, err := migration.SigningMessage() + if err != nil { + return nil, err + } + verified := make(map[string]bool, len(migration.Approvals)) + for _, approval := range migration.Approvals { + id := approval.Validator.Hex() + if !trusted[id] || verified[id] || + !e.authority(approval.Validator, migration.ApprovalHeight).Verify(message, approval.Signature) { + return nil, fmt.Errorf("finality migration: invalid, duplicate, or untrusted approval") + } + verified[id] = true + } + neededApprovals := int(float64(len(authorities))*e.cfg.BFTThreshold) + 1 + if len(verified) < neededApprovals { + return nil, fmt.Errorf("finality migration: %d approvals, need %d", len(verified), neededApprovals) + } + return append([]crypto.ValidatorPubKey(nil), migration.HistoricalCommittee...), nil +} + +func isZeroFinalityValidator(key crypto.ValidatorPubKey) bool { + if len(key) != 32 { + return false + } + for _, b := range key { + if b != 0 { + return false + } + } + return true +} diff --git a/consensus/finality_persistence_test.go b/consensus/finality_persistence_test.go index 428e7cd..16288bb 100644 --- a/consensus/finality_persistence_test.go +++ b/consensus/finality_persistence_test.go @@ -1,9 +1,11 @@ package consensus import ( + "bytes" "encoding/json" "io" "log/slog" + "sort" "testing" "github.com/aperod/aperod/core" @@ -117,6 +119,128 @@ func TestForgedFinalityCertificateFailsClosed(t *testing.T) { } } +func TestFinalityCommitteeMigrationRestoresCertificateOnlyWithPinnedQuorum(t *testing.T) { + historicalPriv, historicalPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + var authorityPrivs []crypto.ValidatorPrivKey + var authorities []crypto.ValidatorPubKey + for range 2 { + priv, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + authorityPrivs = append(authorityPrivs, priv) + authorities = append(authorities, pub) + } + + chain, block := finalityTestChain(t, "migration-anchor") + db, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + rawBlock, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.PutRawBlock(block.Hash(), block.Header.Height, rawBlock); err != nil { + t.Fatal(err) + } + if err := db.PutTip(block.Hash(), block.Header.Height); err != nil { + t.Fatal(err) + } + certificateMessage := crypto.HashBytes([]byte("aperod/finalize/v1"), block.Hash().Bytes()) + historicalVote, err := historicalPriv.Sign(certificateMessage) + if err != nil { + t.Fatal(err) + } + certificate := store.FinalityCertificate{ + Version: 1, Height: block.Header.Height, BlockHash: block.Hash(), + Votes: []store.FinalityVote{{Validator: historicalPub, Signature: historicalVote}}, + } + if err := db.SaveFinalityCertificate(certificate); err != nil { + t.Fatal(err) + } + + type signer struct { + approval store.FinalityMigrationApproval + priv crypto.ValidatorPrivKey + } + signers := make([]signer, len(authorities)) + for i, authority := range authorities { + signers[i] = signer{ + approval: store.FinalityMigrationApproval{ + Validator: authority, + Signature: make([]byte, 64), + }, + priv: authorityPrivs[i], + } + } + sort.Slice(signers, func(i, j int) bool { + return bytes.Compare(signers[i].approval.Validator, signers[j].approval.Validator) < 0 + }) + migration := &store.FinalityCommitteeMigration{ + Version: store.FinalityCommitteeMigrationVersion, + Genesis: chain.Genesis().Hash(), + CertificateHeight: certificate.Height, + CertificateBlockHash: certificate.BlockHash, + ApprovalHeight: block.Header.Height, + ApprovalBlockHash: block.Hash(), + BFTThresholdPPM: 667_000, + HistoricalCommittee: []crypto.ValidatorPubKey{historicalPub}, + } + for _, signer := range signers { + migration.Approvals = append(migration.Approvals, signer.approval) + } + approvalMessage, err := migration.SigningMessage() + if err != nil { + t.Fatal(err) + } + for i := range signers { + signature, err := signers[i].priv.Sign(approvalMessage) + if err != nil { + t.Fatal(err) + } + migration.Approvals[i].Signature = signature + } + + registry := core.NewValidatorRegistry() + registry.InitFromGenesis(authorities, core.MinStakeNAPR*10) + cfg := Config{ + Validators: authorities, + Registry: registry, + Store: db, + BFTThreshold: 0.667, + FinalityMigration: migration, + FinalityMigrationTrustAuthorities: authorities, + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + engine := NewEngine(cfg, chain, core.NewMempool(core.DefaultMempoolConfig()), log) + if engine.halted.Load() || !engine.IsFinalizedHash(certificate.Height, certificate.BlockHash) { + t.Fatal("valid signed migration did not restore the exact historical certificate") + } + + insufficient := *migration + insufficient.Approvals = append([]store.FinalityMigrationApproval(nil), migration.Approvals[:1]...) + insufficientCfg := cfg + insufficientCfg.FinalityMigration = &insufficient + if !NewEngine(insufficientCfg, chain, core.NewMempool(core.DefaultMempoolConfig()), log).halted.Load() { + t.Fatal("migration with fewer than the pinned authority quorum was accepted") + } + + tampered := *migration + tampered.Approvals = append([]store.FinalityMigrationApproval(nil), migration.Approvals...) + tampered.Approvals[0].Signature = append([]byte(nil), tampered.Approvals[0].Signature...) + tampered.Approvals[0].Signature[0] ^= 0x01 + tamperedCfg := cfg + tamperedCfg.FinalityMigration = &tampered + if !NewEngine(tamperedCfg, chain, core.NewMempool(core.DefaultMempoolConfig()), log).halted.Load() { + t.Fatal("tampered migration signature was accepted") + } +} + func TestFinalityCertificateForCanonicalAncestorDoesNotFinalizeTip(t *testing.T) { priv, pub, err := crypto.GenerateValidatorKey() if err != nil { diff --git a/consensus/guardian_fund_test.go b/consensus/guardian_fund_test.go index f56d312..d84bb8c 100644 --- a/consensus/guardian_fund_test.go +++ b/consensus/guardian_fund_test.go @@ -40,7 +40,7 @@ func TestGuardianFundConsensusPlacementAndHeight(t *testing.T) { {"late", 3, []core.Transaction{guardian}}, {"missing", 2, nil}, {"duplicate", 2, []core.Transaction{guardian, guardian}}, - {"wrong_position", 2, []core.Transaction{coinbase, core.Transaction{Version: core.TxVersionBase}, guardian}}, + {"wrong_position", 2, []core.Transaction{coinbase, {Version: core.TxVersionBase}, guardian}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -88,4 +88,4 @@ func TestGuardianFundProducerAtHeightGreaterThanOne(t *testing.T) { if len(block.Txs) != 1 || !core.GuardianFundTxAt(&block.Txs[0], anchor, 3) { t.Fatalf("producer omitted canonical Guardian transaction: %#v", block.Txs) } -} \ No newline at end of file +} diff --git a/consensus/legacy_admin_mint_security_test.go b/consensus/legacy_admin_mint_security_test.go index 36c20e2..eb6dc42 100644 --- a/consensus/legacy_admin_mint_security_test.go +++ b/consensus/legacy_admin_mint_security_test.go @@ -40,7 +40,7 @@ func TestScheduleAdminMintFailsClosedBeforeFutureRingCTActivation(t *testing.T) address := crypto.AddressFromKeys(crypto.MainnetByte, keys) hash, height, err := engine.ScheduleAdminMint( -"legacy-unauthenticated-mint", + "legacy-unauthenticated-mint", string(address), 1_000_000*100_000_000, time.Second, @@ -54,7 +54,7 @@ func TestScheduleAdminMintFailsClosedBeforeFutureRingCTActivation(t *testing.T) if pool.Count() != 0 { t.Fatalf("rejected mint changed mempool: got %d transaction(s)", pool.Count()) } -if _, found, err := db.LoadAdminMintRecord("legacy-unauthenticated-mint"); err != nil { + if _, found, err := db.LoadAdminMintRecord("legacy-unauthenticated-mint"); err != nil { t.Fatal(err) } else if found { t.Fatal("rejected unauthenticated mint persisted an intent") diff --git a/consensus/lpod.go b/consensus/lpod.go index ee8f0e8..17f036d 100644 --- a/consensus/lpod.go +++ b/consensus/lpod.go @@ -70,9 +70,9 @@ func (e *Engine) prepareLPoD(block *core.Block, leader crypto.Address) (*store.L } } r := &store.LPoDSettlement{Parent: block.Header.PrevHash, PositionProtocol: true, Timestamp: block.Header.Timestamp, -GenesisVestingActivationHeight: e.cfg.GenesisVestingActivationHeight, -GenesisVestingFunding: e.cfg.GenesisVestingFunding, - Proposer: block.Header.ValidatorPub.Hex(), Leader: leader, Stake: map[string]store.LPoDValidatorStake{}, + GenesisVestingActivationHeight: e.cfg.GenesisVestingActivationHeight, + GenesisVestingFunding: e.cfg.GenesisVestingFunding, + Proposer: block.Header.ValidatorPub.Hex(), Leader: leader, Stake: map[string]store.LPoDValidatorStake{}, PreviousStake: map[string]store.LPoDValidatorStake{}, AuditPreviousStake: map[string]store.LPoDValidatorStake{}, AuditStake: map[string]store.LPoDValidatorStake{}} @@ -80,9 +80,11 @@ GenesisVestingFunding: e.cfg.GenesisVestingFunding, // and the dynamic minimum. Keep it before projecting any stake operation in // this block; this evidence is optional and never affects consensus hashes. registryBefore := e.cfg.Registry.TakeSnapshot() -if height == e.cfg.GenesisVestingActivationHeight && height > 0 { -if err := e.requireLPoDParentFinalized(height-1, block.Header.PrevHash); err != nil { return nil, nil, nil, err } -} + if height == e.cfg.GenesisVestingActivationHeight && height > 0 { + if err := e.requireLPoDParentFinalized(height-1, block.Header.PrevHash); err != nil { + return nil, nil, nil, err + } + } r.RegistryBefore = ®istryBefore if height == m.Height { r.Migration = m @@ -130,7 +132,7 @@ if err := e.requireLPoDParentFinalized(height-1, block.Header.PrevHash); err != if e.txVerifier == nil || e.utxos == nil { return nil, nil, nil, fmt.Errorf("lpod: position input verifier unavailable") } -if err := e.txVerifier.VerifyTxAtHeight(&tx, block.Header.Height); err != nil { + if err := e.txVerifier.VerifyTxAtHeight(&tx, block.Header.Height); err != nil { return nil, nil, nil, err } } diff --git a/consensus/lpod_wallet_discovery_test.go b/consensus/lpod_wallet_discovery_test.go index 232721b..c4fbdc7 100644 --- a/consensus/lpod_wallet_discovery_test.go +++ b/consensus/lpod_wallet_discovery_test.go @@ -7,11 +7,6 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/aperod/aperod/api" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" - "github.com/aperod/aperod/lpod" - "github.com/aperod/aperod/wallet" "io" "log/slog" "net/http" @@ -20,6 +15,12 @@ import ( "os/exec" "path/filepath" "testing" + + "github.com/aperod/aperod/api" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/lpod" + "github.com/aperod/aperod/wallet" ) func TestLPoDCanonicalWalletPayoutDiscovery(t *testing.T) { diff --git a/consensus/native_chrome_transfer_test.go b/consensus/native_chrome_transfer_test.go index 03ac14d..cf3abd3 100644 --- a/consensus/native_chrome_transfer_test.go +++ b/consensus/native_chrome_transfer_test.go @@ -51,12 +51,12 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { e.pool.SetVerifier(e.txVerifier) acceptPositionBlock(t, e, priv, 3_000_000_000, *deposit) // These are consensus-authorized payout outputs, not UTXO/decoy mocks. -manyRewards := os.Getenv("NATIVE_CHROME_MANY_REWARDS") == "1" -rewardBlocks := 20 -if manyRewards { -rewardBlocks = 160 -} -for i := 0; i < rewardBlocks; i++ { + manyRewards := os.Getenv("NATIVE_CHROME_MANY_REWARDS") == "1" + rewardBlocks := 20 + if manyRewards { + rewardBlocks = 160 + } + for i := 0; i < rewardBlocks; i++ { acceptPositionBlock(t, e, priv, 3_000_000_000) } exit := action @@ -67,44 +67,44 @@ for i := 0; i < rewardBlocks; i++ { if err != nil { t.Fatal(err) } -if !manyRewards { -acceptPositionBlock(t, e, priv, 1, *withdrawal) -} + if !manyRewards { + acceptPositionBlock(t, e, priv, 1, *withdrawal) + } tip := e.chain.Tip() if !e.IsFinalizedHash(tip.Header.Height, tip.Hash()) { t.Fatal("fixture lacks real finality") } -readAmounts := func() (uint64, int, error) { -var total uint64 -count := 0 -cursor := "" -for { -rows, next, err := db.LPoDWalletOutputs(action.Beneficiary, cursor, 128) -if err != nil { -return 0, 0, err -} -for _, u := range rows { -hs, err := crypto.ScanForOutput(keys.View.Private, keys.Spend.Public, u.TxPubKey, u.OneTimePub) -if err != nil || hs == nil { -return 0, 0, fmt.Errorf("fixture output is not owned") -} -total += core.DecryptAmount(u.EncAmount, hs) -count++ -} -if next == "" { -return total, count, nil -} -if next == cursor { -return 0, 0, fmt.Errorf("fixture cursor did not advance") -} -cursor = next -} -} -starting, count, err := readAmounts() -if err != nil { -t.Fatal(err) -} -if count < 16 || starting == 0 { + readAmounts := func() (uint64, int, error) { + var total uint64 + count := 0 + cursor := "" + for { + rows, next, err := db.LPoDWalletOutputs(action.Beneficiary, cursor, 128) + if err != nil { + return 0, 0, err + } + for _, u := range rows { + hs, err := crypto.ScanForOutput(keys.View.Private, keys.Spend.Public, u.TxPubKey, u.OneTimePub) + if err != nil || hs == nil { + return 0, 0, fmt.Errorf("fixture output is not owned") + } + total += core.DecryptAmount(u.EncAmount, hs) + count++ + } + if next == "" { + return total, count, nil + } + if next == cursor { + return 0, 0, fmt.Errorf("fixture cursor did not advance") + } + cursor = next + } + } + starting, count, err := readAmounts() + if err != nil { + t.Fatal(err) + } + if count < 16 || starting == 0 { t.Fatal("fixture funding/decoy prerequisites not met") } server := api.NewServer(":0", e.chain, e.pool, e.utxos, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -118,7 +118,7 @@ if count < 16 || starting == 0 { server.SetUTXOReady() var mu sync.Mutex issuedToOwner := func() uint64 { -total, _, err := readAmounts() + total, _, err := readAmounts() if err != nil { t.Error(err) return 0 @@ -128,14 +128,14 @@ total, _, err := readAmounts() mux := http.NewServeMux() mux.HandleFunc("/fixture/mine", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "POST required", 405) + http.Error(w, "POST required", http.StatusMethodNotAllowed) return } mu.Lock() defer mu.Unlock() transactions := e.pool.SelectTxs(100) if len(transactions) != 1 { - http.Error(w, "expected exactly one genuine mempool transaction", 409) + http.Error(w, "expected exactly one genuine mempool transaction", http.StatusConflict) return } beforeFunding := issuedToOwner() @@ -182,15 +182,15 @@ total, _, err := readAmounts() }) }) mux.Handle("/", server) -httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -diagnostic := &nativeChromeResponseDiagnostic{ResponseWriter: w, status: http.StatusOK} -mux.ServeHTTP(diagnostic, r) -if diagnostic.status >= http.StatusBadRequest { -// Log only fixture responses and the path, never credentials, request bodies, -// seeds or query parameters. Preserve the underlying response unchanged. -t.Logf("isolated node: %s %s -> %d: %s", r.Method, r.URL.Path, diagnostic.status, diagnostic.failure) -} -})) + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + diagnostic := &nativeChromeResponseDiagnostic{ResponseWriter: w, status: http.StatusOK} + mux.ServeHTTP(diagnostic, r) + if diagnostic.status >= http.StatusBadRequest { + // Log only fixture responses and the path, never credentials, request bodies, + // seeds or query parameters. Preserve the underlying response unchanged. + t.Logf("isolated node: %s %s -> %d: %s", r.Method, r.URL.Path, diagnostic.status, diagnostic.failure) + } + })) defer httpServer.Close() fixtureDir := t.TempDir() input := filepath.Join(fixtureDir, "private-input.json") @@ -198,7 +198,7 @@ t.Logf("isolated node: %s %s -> %d: %s", r.Method, r.URL.Path, diagnostic.status "senderPhrase": senderPhrase, "recipientPhrase": recipientPhrase, "sender": string(action.Beneficiary), "recipient": string(crypto.AddressFromKeys(crypto.MainnetByte, recipient.Keys)), "starting": fmt.Sprint(starting), "nodeURL": httpServer.URL, "rpcKey": rpcKey, -"manyRewards": manyRewards, + "manyRewards": manyRewards, }) if err := os.WriteFile(input, raw, 0600); err != nil { t.Fatal(err) @@ -207,7 +207,7 @@ t.Logf("isolated node: %s %s -> %d: %s", r.Method, r.URL.Path, diagnostic.status "src/routes/wallet-native-chrome.integration.test.ts", "--maxWorkers=1") cmd.Dir = "../.." // No workspace credentials may be inherited by the fixture or browser. -for _, name := range []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LIBRARY_PATH", "NATIVE_CHROME_ARCHIVE", "NATIVE_CHROME_FROZEN_SIGNER_DIR", "NATIVE_CHROME_EXECUTABLE", "NATIVE_WALLET_SCREENSHOT"} { + for _, name := range []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LIBRARY_PATH", "NATIVE_CHROME_ARCHIVE", "NATIVE_CHROME_FROZEN_SIGNER_DIR", "NATIVE_CHROME_EXECUTABLE", "NATIVE_WALLET_SCREENSHOT"} { if value, ok := os.LookupEnv(name); ok { cmd.Env = append(cmd.Env, name+"="+value) } @@ -222,23 +222,23 @@ for _, name := range []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LI } type nativeChromeResponseDiagnostic struct { -http.ResponseWriter -status int -failure []byte + http.ResponseWriter + status int + failure []byte } func (w *nativeChromeResponseDiagnostic) WriteHeader(status int) { -w.status = status -w.ResponseWriter.WriteHeader(status) + w.status = status + w.ResponseWriter.WriteHeader(status) } func (w *nativeChromeResponseDiagnostic) Write(body []byte) (int, error) { -if w.status >= http.StatusBadRequest && len(w.failure) < 512 { -remaining := 512-len(w.failure) -if len(body) < remaining { -remaining = len(body) -} -w.failure = append(w.failure, body[:remaining]...) -} -return w.ResponseWriter.Write(body) + if w.status >= http.StatusBadRequest && len(w.failure) < 512 { + remaining := 512 - len(w.failure) + if len(body) < remaining { + remaining = len(body) + } + w.failure = append(w.failure, body[:remaining]...) + } + return w.ResponseWriter.Write(body) } diff --git a/consensus/oracle_test.go b/consensus/oracle_test.go index ba63857..a6d4b01 100644 --- a/consensus/oracle_test.go +++ b/consensus/oracle_test.go @@ -123,11 +123,11 @@ func newOracleEngine( mp := core.NewMempool(core.DefaultMempoolConfig()) return consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 15 * time.Millisecond, - BFTThreshold: 0.667, - Validators: validators, - MyKey: myKey, - OracleURL: oracleURL, + BlockTime: 15 * time.Millisecond, + BFTThreshold: 0.667, + Validators: validators, + MyKey: myKey, + OracleURL: oracleURL, }, chain, mp, log) } @@ -349,11 +349,11 @@ func TestOraclePrice_NoURLSkipsEmbedding(t *testing.T) { // No OracleURL → embedding disabled. eng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 15 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{pub}, - MyKey: lk, - OracleURL: "", // intentionally empty + BlockTime: 15 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{pub}, + MyKey: lk, + OracleURL: "", // intentionally empty }, chain, mp, newNopLogger()) stop := make(chan struct{}) @@ -450,11 +450,11 @@ func TestOracleSlowFetch_DoesNotDelayBlockProduction(t *testing.T) { mp := core.NewMempool(core.DefaultMempoolConfig()) eng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: blockTime, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{pub}, - MyKey: lk, - OracleURL: slowSrv.URL, // slow — responds after 2 s + BlockTime: blockTime, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{pub}, + MyKey: lk, + OracleURL: slowSrv.URL, // slow — responds after 2 s }, chain, mp, newNopLogger()) stop := make(chan struct{}) diff --git a/consensus/poa.go b/consensus/poa.go index 63d890a..4377843 100644 --- a/consensus/poa.go +++ b/consensus/poa.go @@ -36,6 +36,12 @@ type Config struct { // ValidatorAuthorityFork separates collateral identity from future signing // authority. Nil preserves the deployed protocol without any change. ValidatorAuthorityFork *core.ValidatorAuthorityFork + // FinalityMigration is an explicitly signed, one-certificate historical + // committee attestation. It never manufactures votes or changes the quorum. + FinalityMigration *store.FinalityCommitteeMigration + // FinalityMigrationTrustAuthorities are operator-pinned validator identities + // authorized to sign the migration, independent of genesis validators. + FinalityMigrationTrustAuthorities []crypto.ValidatorPubKey LPoDMigration *store.LPoDMigration SaleMigration *store.SaleMigration // Approved durable purchase intents are built at the real slot and parent. @@ -127,9 +133,9 @@ type Config struct { // RingCTCLSAGActivationHeight is the first height requiring v5; zero // disables activation to avoid an accidental uncoordinated fork. RingCTCLSAGActivationHeight uint64 -// StakedCollateralRingExclusionActivationHeight is the first height at which -// CLSAG rings must exclude staked collateral. Zero disables this fork. -StakedCollateralRingExclusionActivationHeight uint64 + // StakedCollateralRingExclusionActivationHeight is the first height at which + // CLSAG rings must exclude staked collateral. Zero disables this fork. + StakedCollateralRingExclusionActivationHeight uint64 // AVMActivationHeight is the first height accepting v6 AVM transactions. // Zero disables AVM consensus. AVMActivationHeight uint64 @@ -346,10 +352,11 @@ func NewEngine(cfg Config, chain *core.Chain, pool *core.Mempool, log *slog.Logg } if cfg.LPoDMigration != nil { m := *cfg.LPoDMigration - if m.Version == 1 { + switch m.Version { + case 1: // Preserve the legacy v1 trust behavior exactly. m.TrustedValidators = append([]crypto.ValidatorPubKey(nil), cfg.Validators...) - } else if m.Version == 2 || m.Version == 3 { + case 2, 3: // Trusted checkpoint authority is configured separately from consensus. // Never replace it with the effective consensus validator set. m.TrustedValidators = append([]crypto.ValidatorPubKey(nil), m.TrustedValidators...) @@ -415,7 +422,7 @@ func NewEngine(cfg Config, chain *core.Chain, pool *core.Mempool, log *slog.Logg e.tailRewardNAPR = defaultTailRewardNAPR } // Try to restore persisted pool balance from LevelDB so restarts - // do not re-initialise to the full 2 B. + // do not re-initialize to the full 2 B. if cfg.Store != nil { if rem, found, err := cfg.Store.LoadStakingPoolRemaining(); err != nil { log.Warn("staking pool: failed to load from store; using config value", "err", err) @@ -430,7 +437,7 @@ func NewEngine(cfg Config, chain *core.Chain, pool *core.Mempool, log *slog.Logg // First boot with pool enabled — persist initial balance. atomic.StoreInt64(&e.stakingPoolRemaining, int64(cfg.StakingPoolNAPR)) _ = cfg.Store.StoreStakingPoolRemaining(cfg.StakingPoolNAPR) - log.Info("staking pool: initialised", + log.Info("staking pool: initialized", "total_napro", cfg.StakingPoolNAPR, "total_apro", float64(cfg.StakingPoolNAPR)/1e8, ) @@ -439,7 +446,7 @@ func NewEngine(cfg Config, chain *core.Chain, pool *core.Mempool, log *slog.Logg atomic.StoreInt64(&e.stakingPoolRemaining, int64(cfg.StakingPoolNAPR)) } } else { - // Pool disabled — -1 signals legacy mint behaviour. + // Pool disabled — -1 signals legacy mint behavior. atomic.StoreInt64(&e.stakingPoolRemaining, -1) } // Reconstruct the fee expected for the block after the current tip. Without @@ -596,9 +603,9 @@ func (e *Engine) TimestampRejectedCount() int64 { func (e *Engine) SetTxVerifier(v *core.TxVerifier, utxos *core.UTXOSet) { e.txVerifier = v e.utxos = utxos -if v != nil { -v.SetStakedCollateralRingExclusionActivationHeight(e.cfg.StakedCollateralRingExclusionActivationHeight) -} + if v != nil { + v.SetStakedCollateralRingExclusionActivationHeight(e.cfg.StakedCollateralRingExclusionActivationHeight) + } } // activeValidators returns the current active validator set. @@ -1721,7 +1728,7 @@ func (e *Engine) produceBlock(height, round uint64, parent *core.Block) (*core.B screened = append(screened, *tx) continue } - if err := e.txVerifier.VerifyTxAtHeight(tx, height); err != nil { + if err := e.txVerifier.VerifyTxAtHeight(tx, height); err != nil { hash := tx.Hash() e.log.Warn("produceBlock: banning mempool tx that failed re-verification", "hash", hash, @@ -2490,12 +2497,21 @@ func (e *Engine) handleVote(vote FinalizeMsg) error { func (e *Engine) restoreFinalityCertificate() error { if e.cfg.Store == nil { + if e.cfg.FinalityMigration != nil || len(e.cfg.FinalityMigrationTrustAuthorities) != 0 { + return fmt.Errorf("finality: migration configured without a durable store") + } return nil } certificate, err := e.cfg.Store.LoadFinalityCertificate() - if err != nil || certificate == nil { + if err != nil { return err } + if certificate == nil { + if e.cfg.FinalityMigration != nil || len(e.cfg.FinalityMigrationTrustAuthorities) != 0 { + return fmt.Errorf("finality: migration configured without its durable certificate") + } + return nil + } tip := e.chain.Tip() if tip == nil || certificate.Height > tip.Header.Height { return fmt.Errorf("finality: certificate is above the canonical tip") @@ -2541,11 +2557,21 @@ func (e *Engine) restoreFinalityCertificate() error { } } active := e.activeValidators() - committee := make(map[string]bool, len(active)) - for _, validator := range active { + committeeMembers := active + if e.cfg.FinalityMigration != nil { + migrationCommittee, err := e.verifyFinalityCommitteeMigration(certificate) + if err != nil { + return err + } + committeeMembers = migrationCommittee + } else if len(e.cfg.FinalityMigrationTrustAuthorities) != 0 { + return fmt.Errorf("finality: migration authorities configured without a migration file") + } + committee := make(map[string]bool, len(committeeMembers)) + for _, validator := range committeeMembers { committee[validator.Hex()] = true } - needed := int(float64(len(active))*e.cfg.BFTThreshold) + 1 + needed := int(float64(len(committeeMembers))*e.cfg.BFTThreshold) + 1 seen := make(map[string]bool, len(certificate.Votes)) message := crypto.HashBytes([]byte("aperod/finalize/v1"), certificate.BlockHash[:]) for _, vote := range certificate.Votes { diff --git a/consensus/poa_test.go b/consensus/poa_test.go index b78d07a..db3fb50 100644 --- a/consensus/poa_test.go +++ b/consensus/poa_test.go @@ -83,11 +83,11 @@ func buildValidatorGenesisAndBlock(t *testing.T) ( validatorMp := core.NewMempool(core.DefaultMempoolConfig()) validatorEng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{validatorPub}, - Registry: validatorReg, - MyKey: lk, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{validatorPub}, + Registry: validatorReg, + MyKey: lk, }, validatorChain, validatorMp, newNopLogger()) validatorEng.SetTxVerifier(core.NewTxVerifier(validatorUTXOs), validatorUTXOs) @@ -129,8 +129,8 @@ func TestNonValidatorAcceptsBlockFromRealValidator(t *testing.T) { nonValidatorMp := core.NewMempool(core.DefaultMempoolConfig()) nonValidatorEng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, // ← genesis validator's pub key (the fix): isKnownValidator() will return true. Validators: []crypto.ValidatorPubKey{validatorPub}, Registry: nonValidatorReg, @@ -164,7 +164,7 @@ func TestNonValidatorAcceptsBlockFromRealValidator(t *testing.T) { // TestNonValidatorRejectsBlock_WhenSeededWithOwnKey verifies the REGRESSION // scenario: if the non-validator engine seeds its registry with its own random -// P2P-identity key (the pre-fix behaviour), the incoming block from the real +// P2P-identity key (the pre-fix behavior), the incoming block from the real // validator is rejected because isKnownValidator() returns false. // // This test will FAIL if main.go regresses to: @@ -196,8 +196,8 @@ func TestNonValidatorRejectsBlock_WhenSeededWithOwnKey(t *testing.T) { nonValidatorMp := core.NewMempool(core.DefaultMempoolConfig()) nonValidatorEng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, // ← the node's OWN key (the regression): isKnownValidator() will return false // for the real validator's block → block must be rejected. Validators: []crypto.ValidatorPubKey{nodePub}, @@ -261,13 +261,13 @@ func buildEngineWithGenesis(t *testing.T) ( reg := core.NewValidatorRegistry() mp := core.NewMempool(core.DefaultMempoolConfig()) eng = consensus.NewEngine(consensus.Config{ - OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{validatorPub}, - Registry: reg, - MyKey: nil, // relay node — never produces blocks -RingCTV4ActivationHeight: ^uint64(0), + OnCanonicalBlock: noopCanonicalPersistence, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{validatorPub}, + Registry: reg, + MyKey: nil, // relay node — never produces blocks + RingCTV4ActivationHeight: ^uint64(0), }, chain, mp, newNopLogger()) eng.SetTxVerifier(core.NewTxVerifier(utxos), utxos) @@ -384,7 +384,7 @@ func TestTimejackingGuard_FutureBlockRejected(t *testing.T) { // sync on a restarting relay node. func TestCrashRecovery_RelayFillsMultiHourGap(t *testing.T) { const ( - numBlocks = 20 // blocks the validator produced while relay was offline + numBlocks = 20 // blocks the validator produced while relay was offline gapDuration = 4 * time.Hour // simulated outage duration ) @@ -451,13 +451,13 @@ func TestCrashRecovery_RelayFillsMultiHourGap(t *testing.T) { relayMp := core.NewMempool(core.DefaultMempoolConfig()) relayEng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, // Seeded with the genesis validator key so isKnownValidator() returns true. - Validators: []crypto.ValidatorPubKey{validatorPub}, - Registry: relayReg, - MyKey: nil, // relay node — never produces blocks -RingCTV4ActivationHeight: ^uint64(0), + Validators: []crypto.ValidatorPubKey{validatorPub}, + Registry: relayReg, + MyKey: nil, // relay node — never produces blocks + RingCTV4ActivationHeight: ^uint64(0), }, relayChain, relayMp, newNopLogger()) relayEng.SetTxVerifier(core.NewTxVerifier(relayUTXOs), relayUTXOs) @@ -482,10 +482,8 @@ RingCTV4ActivationHeight: ^uint64(0), // Poll until chain advances — fail fast if the engine stalls (which would // indicate the timejacking guard incorrectly rejected a past-ts block). deadline := time.After(500 * time.Millisecond) - for { - if relayChain.Height() >= wantHeight { - break - } + for relayChain.Height() < wantHeight { + select { case <-deadline: t.Fatalf("relay stalled at height %d (want %d) — "+ @@ -559,11 +557,11 @@ func TestTimejackingBan_PeerBannedAfterThreshold(t *testing.T) { mp := core.NewMempool(core.DefaultMempoolConfig()) eng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{validatorPub}, - Registry: reg, - MyKey: nil, // relay — never produces blocks + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{validatorPub}, + Registry: reg, + MyKey: nil, // relay — never produces blocks TimestampBanThreshold: threshold, OnTimestampBan: func(pub crypto.ValidatorPubKey, count int) { diff --git a/consensus/sale.go b/consensus/sale.go index 8e65d2e..5e04935 100644 --- a/consensus/sale.go +++ b/consensus/sale.go @@ -19,10 +19,10 @@ func (e *Engine) validateVersionWithSales(tx *core.Transaction, height, activati } func (e *Engine) prepareSale(b *core.Block) (*store.SaleSettlement, error) { -if e.cfg.SaleMigration != nil && e.cfg.GenesisVestingActivationHeight != 0 && -e.cfg.GenesisVestingActivationHeight >= e.cfg.SaleMigration.Checkpoint.Height { -return nil, fmt.Errorf("sale: scheduled new genesis reserve overlaps the sale allocation") -} + if e.cfg.SaleMigration != nil && e.cfg.GenesisVestingActivationHeight != 0 && + e.cfg.GenesisVestingActivationHeight >= e.cfg.SaleMigration.Checkpoint.Height { + return nil, fmt.Errorf("sale: scheduled new genesis reserve overlaps the sale allocation") + } if e.cfg.Store == nil { for _, tx := range b.Txs { if tx.IsSale() { diff --git a/consensus/sale_finality.go b/consensus/sale_finality.go index 4caea53..3aa95c1 100644 --- a/consensus/sale_finality.go +++ b/consensus/sale_finality.go @@ -7,17 +7,25 @@ import "github.com/aperod/aperod/crypto" // ancestor of that finalized block is finalized too. Do not apply this fallback // to LPoD witness/tier verification, which retains its stricter evidence path. func (e *Engine) IsSaleFinalizedHash(height uint64, hash crypto.Hash32) bool { - if height == 0 || hash == (crypto.Hash32{}) || e.cfg.Store == nil { return false } - e.mu.Lock() - var anchorHeight uint64 - var anchorHash crypto.Hash32 - for h, v := range e.lpodFinalizedHashes { - if h > anchorHeight && v != (crypto.Hash32{}) { anchorHeight, anchorHash = h, v } - } - e.mu.Unlock() - if anchorHeight < height || anchorHash == (crypto.Hash32{}) { return false } - anchor, found, err := e.cfg.Store.GetCanonicalHash(anchorHeight) - if err != nil || !found || anchor != anchorHash { return false } - canonical, found, err := e.cfg.Store.GetCanonicalHash(height) - return err == nil && found && canonical == hash + if height == 0 || hash == (crypto.Hash32{}) || e.cfg.Store == nil { + return false + } + e.mu.Lock() + var anchorHeight uint64 + var anchorHash crypto.Hash32 + for h, v := range e.lpodFinalizedHashes { + if h > anchorHeight && v != (crypto.Hash32{}) { + anchorHeight, anchorHash = h, v + } + } + e.mu.Unlock() + if anchorHeight < height || anchorHash == (crypto.Hash32{}) { + return false + } + anchor, found, err := e.cfg.Store.GetCanonicalHash(anchorHeight) + if err != nil || !found || anchor != anchorHash { + return false + } + canonical, found, err := e.cfg.Store.GetCanonicalHash(height) + return err == nil && found && canonical == hash } diff --git a/consensus/sale_http_rehearsal_test.go b/consensus/sale_http_rehearsal_test.go index 71d8fa5..80ff028 100644 --- a/consensus/sale_http_rehearsal_test.go +++ b/consensus/sale_http_rehearsal_test.go @@ -13,6 +13,7 @@ import ( "time" "filippo.io/edwards25519" + "github.com/aperod/aperod/api" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" diff --git a/consensus/sale_readiness.go b/consensus/sale_readiness.go index 21652d4..3a21af4 100644 --- a/consensus/sale_readiness.go +++ b/consensus/sale_readiness.go @@ -21,7 +21,7 @@ func (e *Engine) CanSettleSale() bool { return false } for _, member := range e.cfg.Registry.GetActiveValidators() { -if member.Equals(e.localIdentity(height+1)) { + if member.Equals(e.localIdentity(height + 1)) { return true } } diff --git a/consensus/stake_e2e_test.go b/consensus/stake_e2e_test.go index ceacea0..e3c2fda 100644 --- a/consensus/stake_e2e_test.go +++ b/consensus/stake_e2e_test.go @@ -706,6 +706,7 @@ func TestStakeDeposit_BadCommit_E2E_NeverReachesChain(t *testing.T) { // ── 4. REST Server wired to the same UTXOSet + Mempool + Chain ──────────── srv := api.NewServer(":0", chain, mp, utxos, newNopLogger()) + srv.SetAPIKey("test-stake-api-key") // ── 5. Build a stake payload with a WRONG (all-zero) blind ─────────────── // Commit(amount, zeroBlind) ≠ realCommit so the handler must reject it. @@ -727,6 +728,7 @@ func TestStakeDeposit_BadCommit_E2E_NeverReachesChain(t *testing.T) { // ── 6. POST to the REST handler ──────────────────────────────────────────── req := httptest.NewRequest(http.MethodPost, "/api/v1/stake", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", "test-stake-api-key") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) diff --git a/consensus/vesting_e2e_test.go b/consensus/vesting_e2e_test.go index ff4a484..610eff4 100644 --- a/consensus/vesting_e2e_test.go +++ b/consensus/vesting_e2e_test.go @@ -139,9 +139,9 @@ func TestHandleIncomingBlock_LockedGenesisSpendRejected(t *testing.T) { mp := core.NewMempool(core.DefaultMempoolConfig()) eng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{proposerPub}, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{proposerPub}, // MyKey intentionally nil — this node is not a validator/proposer; // we are testing the incoming-block acceptance path only. }, chain, mp, newNopLogger()) @@ -286,9 +286,9 @@ func TestHandleIncomingBlock_UnlockedGenesisSpendPassesVestingCheck(t *testing.T mp := core.NewMempool(core.DefaultMempoolConfig()) eng := consensus.NewEngine(consensus.Config{ OnCanonicalBlock: noopCanonicalPersistence, - BlockTime: 20 * time.Millisecond, - BFTThreshold: 0.667, - Validators: []crypto.ValidatorPubKey{proposerPub}, + BlockTime: 20 * time.Millisecond, + BFTThreshold: 0.667, + Validators: []crypto.ValidatorPubKey{proposerPub}, }, chain, mp, newNopLogger()) eng.SetTxVerifier(txVerifier, utxos) diff --git a/core/assets.go b/core/assets.go index 8b8be6a..abe0e73 100644 --- a/core/assets.go +++ b/core/assets.go @@ -84,7 +84,7 @@ func (p AssetPayload) validate() error { // ─── Encoding ───────────────────────────────────────────────────────────────── -// EncodeAssetPayload serialises p into the Transaction.Extra binary format. +// EncodeAssetPayload serializes p into the Transaction.Extra binary format. // The result is at most 255 bytes (enforced by Transaction.Validate). func EncodeAssetPayload(p AssetPayload) ([]byte, error) { if err := p.validate(); err != nil { diff --git a/core/block.go b/core/block.go index fad4df4..75557ed 100644 --- a/core/block.go +++ b/core/block.go @@ -81,11 +81,11 @@ func (h *BlockHeader) VerifySignature(authority ...crypto.ValidatorPubKey) bool h.Signature = nil hash := h.Hash() h.Signature = sig -pub := h.ValidatorPub -if len(authority) == 1 { -pub = authority[0] -} -return pub.Verify(hash, sig) + pub := h.ValidatorPub + if len(authority) == 1 { + pub = authority[0] + } + return pub.Verify(hash, sig) } // Hash returns the hash of the full block (= header hash, since txs are in MerkleRoot). @@ -112,7 +112,7 @@ func (b *Block) Validate(authority ...crypto.ValidatorPubKey) error { if len(b.Header.ValidatorPub) == 0 { return fmt.Errorf("block %d: missing validator public key", b.Header.Height) } -if !b.Header.VerifySignature(authority...) { + if !b.Header.VerifySignature(authority...) { return fmt.Errorf("block %d: invalid validator signature", b.Header.Height) } expectedRoot := MerkleRoot(b.Txs) diff --git a/core/block_verifier.go b/core/block_verifier.go index 10c3909..89c4571 100644 --- a/core/block_verifier.go +++ b/core/block_verifier.go @@ -33,8 +33,8 @@ func DefaultBlockVerifierConfig() BlockVerifierConfig { // BlockVerifier validates blocks against chain and consensus rules. // Cryptographic tx verification is delegated to TxVerifier. type BlockVerifier struct { - cfg BlockVerifierConfig - chain *Chain + cfg BlockVerifierConfig + chain *Chain txVerifier *TxVerifier } diff --git a/core/c0_c1_fix_test.go b/core/c0_c1_fix_test.go index c0b6183..d96eccb 100644 --- a/core/c0_c1_fix_test.go +++ b/core/c0_c1_fix_test.go @@ -1212,7 +1212,7 @@ func TestPhase2Transfer_WithDecoySet_EndToEnd(t *testing.T) { t.Fatalf("GenerateWalletKeys decoy[%d]: %v", i, kErr) } decoyBlind, _ := crypto.NewBlindFactor() - decoyAmount := uint64((i+1) * 3_000_000_000) // 30..450 APRO each + decoyAmount := uint64((i + 1) * 3_000_000_000) // 30..450 APRO each decoyCommit, _ := crypto.Commit(decoyAmount, decoyBlind) utxos.AddSpentDecoyForTest(&core.UTXO{ TxHash: crypto.Hash32{byte(i + 50)}, @@ -1355,7 +1355,7 @@ func TestPhase2Privacy_SurvivesNodeRestart(t *testing.T) { if berr != nil { t.Fatalf("NewBlindFactor victim[%d]: %v", i, berr) } - vAmount := uint64((i+1) * 1_000_000_000) // 10..200 APRO (distinct per victim) + vAmount := uint64((i + 1) * 1_000_000_000) // 10..200 APRO (distinct per victim) vCommit, cerr := crypto.Commit(vAmount, vBlind) if cerr != nil { t.Fatalf("Commit victim[%d]: %v", i, cerr) diff --git a/core/chain.go b/core/chain.go index 7e0f9c5..7f8c233 100644 --- a/core/chain.go +++ b/core/chain.go @@ -1,11 +1,11 @@ package core import ( - "fmt" - "sort" - "sync" + "fmt" + "sort" + "sync" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // MaxInMemoryBlocks is the default sliding-window size of blocks kept in RAM. @@ -16,36 +16,36 @@ const MaxInMemoryBlocks = 1_000 // TxLocation records where a transaction lives in the chain. type TxLocation struct { - Block *Block - TxIndex int + Block *Block + TxIndex int } // Chain tracks the canonical chain state: the tip block and the full index of // known blocks. Reorganizations are handled via Reorg(). type Chain struct { - mu sync.RWMutex - blocks map[crypto.Hash32]*Block // recent blocks by hash - byHeight map[uint64]*Block // canonical chain: height → block - txIndex map[crypto.Hash32]TxLocation // tx hash → location - tip *Block - genesis *Block - maxInMemoryBlocks uint64 // sliding-window size; default MaxInMemoryBlocks - lastAdd *chainAddRollback + mu sync.RWMutex + blocks map[crypto.Hash32]*Block // recent blocks by hash + byHeight map[uint64]*Block // canonical chain: height → block + txIndex map[crypto.Hash32]TxLocation // tx hash → location + tip *Block + genesis *Block + maxInMemoryBlocks uint64 // sliding-window size; default MaxInMemoryBlocks + lastAdd *chainAddRollback } type chainAddRollback struct { - blockHash crypto.Hash32 - previousTip *Block - evicted *Block + blockHash crypto.Hash32 + previousTip *Block + evicted *Block } // InMemoryBlockCount returns the number of blocks currently held in the // sliding in-memory window. Used by /api/health to expose live memory stats // without requiring the caller to SSH into the node. func (c *Chain) InMemoryBlockCount() int { - c.mu.RLock() - defer c.mu.RUnlock() - return len(c.blocks) + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.blocks) } // NewChain creates an empty chain. An optional maxInMemoryBlocks value may be @@ -53,187 +53,187 @@ func (c *Chain) InMemoryBlockCount() int { // MaxInMemoryBlocks constant (1 000). This allows node operators to tune the // in-memory block window via node.yaml without recompiling. func NewChain(maxBlocks ...uint64) *Chain { - max := uint64(MaxInMemoryBlocks) - if len(maxBlocks) > 0 && maxBlocks[0] > 0 { - max = maxBlocks[0] - } - return &Chain{ - blocks: make(map[crypto.Hash32]*Block), - byHeight: make(map[uint64]*Block), - txIndex: make(map[crypto.Hash32]TxLocation), - maxInMemoryBlocks: max, - } + max := uint64(MaxInMemoryBlocks) + if len(maxBlocks) > 0 && maxBlocks[0] > 0 { + max = maxBlocks[0] + } + return &Chain{ + blocks: make(map[crypto.Hash32]*Block), + byHeight: make(map[uint64]*Block), + txIndex: make(map[crypto.Hash32]TxLocation), + maxInMemoryBlocks: max, + } } // SetGenesis installs the genesis block. Must be called once before any other method. func (c *Chain) SetGenesis(b *Block) error { - if !b.IsGenesis() { - return fmt.Errorf("block at height %d is not a genesis block", b.Header.Height) - } - c.mu.Lock() - defer c.mu.Unlock() - h := b.Hash() - c.blocks[h] = b - c.byHeight[0] = b - c.tip = b - c.genesis = b - c.lastAdd = nil - c.indexTxs(b) - return nil + if !b.IsGenesis() { + return fmt.Errorf("block at height %d is not a genesis block", b.Header.Height) + } + c.mu.Lock() + defer c.mu.Unlock() + h := b.Hash() + c.blocks[h] = b + c.byHeight[0] = b + c.tip = b + c.genesis = b + c.lastAdd = nil + c.indexTxs(b) + return nil } // Tip returns the current canonical chain tip (highest finalized block). func (c *Chain) Tip() *Block { - c.mu.RLock() - defer c.mu.RUnlock() - return c.tip + c.mu.RLock() + defer c.mu.RUnlock() + return c.tip } // Height returns the current chain height. func (c *Chain) Height() uint64 { - c.mu.RLock() - defer c.mu.RUnlock() - if c.tip == nil { - return 0 - } - return c.tip.Header.Height + c.mu.RLock() + defer c.mu.RUnlock() + if c.tip == nil { + return 0 + } + return c.tip.Header.Height } // Genesis returns the genesis block. func (c *Chain) Genesis() *Block { - c.mu.RLock() - defer c.mu.RUnlock() - return c.genesis + c.mu.RLock() + defer c.mu.RUnlock() + return c.genesis } // GetByHash retrieves a block by its hash. Returns nil if unknown. func (c *Chain) GetByHash(h crypto.Hash32) *Block { - c.mu.RLock() - defer c.mu.RUnlock() - return c.blocks[h] + c.mu.RLock() + defer c.mu.RUnlock() + return c.blocks[h] } // GetByHeight retrieves the canonical block at height h. Returns nil if not yet known. func (c *Chain) GetByHeight(h uint64) *Block { - c.mu.RLock() - defer c.mu.RUnlock() - return c.byHeight[h] + c.mu.RLock() + defer c.mu.RUnlock() + return c.byHeight[h] } // BlocksNewestFirst returns a stable snapshot of the blocks currently retained // in memory, ordered from the canonical tip backwards. Older bodies have // already been evicted and cannot be returned by GetByHeight. func (c *Chain) BlocksNewestFirst() []*Block { - c.mu.RLock() - defer c.mu.RUnlock() - blocks := make([]*Block, 0, len(c.byHeight)) - for _, block := range c.byHeight { - blocks = append(blocks, block) - } - sort.Slice(blocks, func(i, j int) bool { - return blocks[i].Header.Height > blocks[j].Header.Height - }) - return blocks + c.mu.RLock() + defer c.mu.RUnlock() + blocks := make([]*Block, 0, len(c.byHeight)) + for _, block := range c.byHeight { + blocks = append(blocks, block) + } + sort.Slice(blocks, func(i, j int) bool { + return blocks[i].Header.Height > blocks[j].Header.Height + }) + return blocks } // BlocksNewestFirstPage returns at most maxHeightScan retained block heights, // newest first. startHeight is inclusive when startAtTip is false. The // returned nextStartHeight is an inclusive cursor for the next page. func (c *Chain) BlocksNewestFirstPage(startHeight uint64, startAtTip bool, maxHeightScan int) ( - blocks []*Block, - nextStartHeight uint64, - hasMore bool, - oldestRetainedHeight uint64, + blocks []*Block, + nextStartHeight uint64, + hasMore bool, + oldestRetainedHeight uint64, ) { - c.mu.RLock() - defer c.mu.RUnlock() - - if c.tip == nil || len(c.byHeight) == 0 || maxHeightScan <= 0 { - return nil, 0, false, 0 - } - - tipHeight := c.tip.Header.Height - if c.maxInMemoryBlocks > 0 && tipHeight >= c.maxInMemoryBlocks { - oldestRetainedHeight = tipHeight - c.maxInMemoryBlocks + 1 - } - if startAtTip || startHeight > tipHeight { - startHeight = tipHeight - } - if startHeight < oldestRetainedHeight { - return nil, 0, false, oldestRetainedHeight - } - - firstHeight := oldestRetainedHeight - if scannedHeights := startHeight - oldestRetainedHeight + 1; scannedHeights > uint64(maxHeightScan) { - firstHeight = startHeight - uint64(maxHeightScan) + 1 - hasMore = firstHeight > oldestRetainedHeight - if hasMore { - nextStartHeight = firstHeight - 1 - } - } - - blocks = make([]*Block, 0, int(startHeight-firstHeight+1)) - for height := startHeight; ; height-- { - if block := c.byHeight[height]; block != nil { - blocks = append(blocks, block) - } - if height == firstHeight { - break - } - } - return blocks, nextStartHeight, hasMore, oldestRetainedHeight + c.mu.RLock() + defer c.mu.RUnlock() + + if c.tip == nil || len(c.byHeight) == 0 || maxHeightScan <= 0 { + return nil, 0, false, 0 + } + + tipHeight := c.tip.Header.Height + if c.maxInMemoryBlocks > 0 && tipHeight >= c.maxInMemoryBlocks { + oldestRetainedHeight = tipHeight - c.maxInMemoryBlocks + 1 + } + if startAtTip || startHeight > tipHeight { + startHeight = tipHeight + } + if startHeight < oldestRetainedHeight { + return nil, 0, false, oldestRetainedHeight + } + + firstHeight := oldestRetainedHeight + if scannedHeights := startHeight - oldestRetainedHeight + 1; scannedHeights > uint64(maxHeightScan) { + firstHeight = startHeight - uint64(maxHeightScan) + 1 + hasMore = firstHeight > oldestRetainedHeight + if hasMore { + nextStartHeight = firstHeight - 1 + } + } + + blocks = make([]*Block, 0, int(startHeight-firstHeight+1)) + for height := startHeight; ; height-- { + if block := c.byHeight[height]; block != nil { + blocks = append(blocks, block) + } + if height == firstHeight { + break + } + } + return blocks, nextStartHeight, hasMore, oldestRetainedHeight } // HasBlock returns true if the block with this hash is already known. func (c *Chain) HasBlock(h crypto.Hash32) bool { - c.mu.RLock() - defer c.mu.RUnlock() - _, ok := c.blocks[h] - return ok + c.mu.RLock() + defer c.mu.RUnlock() + _, ok := c.blocks[h] + return ok } // AddBlock appends a validated block to the canonical chain. // The block must extend the current tip. func (c *Chain) AddBlock(b *Block) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.tip == nil { - return fmt.Errorf("chain not initialized: call SetGenesis first") - } - if b.Header.Height != c.tip.Header.Height+1 { - return fmt.Errorf("block height %d does not extend tip at %d", - b.Header.Height, c.tip.Header.Height) - } - if b.Header.PrevHash != c.tip.Hash() { - return fmt.Errorf("block %d: prev hash mismatch", b.Header.Height) - } - - h := b.Hash() - rollback := &chainAddRollback{ - blockHash: h, - previousTip: c.tip, - } - c.blocks[h] = b - c.byHeight[b.Header.Height] = b - c.tip = b - c.indexTxs(b) - - // Evict the block that has fallen outside the sliding window. - if b.Header.Height >= c.maxInMemoryBlocks { - evictH := b.Header.Height - c.maxInMemoryBlocks - if old, ok := c.byHeight[evictH]; ok { - rollback.evicted = old - delete(c.byHeight, evictH) - delete(c.blocks, old.Hash()) - for _, tx := range old.Txs { - delete(c.txIndex, tx.Hash()) - } - } - } - c.lastAdd = rollback - - return nil + c.mu.Lock() + defer c.mu.Unlock() + + if c.tip == nil { + return fmt.Errorf("chain not initialized: call SetGenesis first") + } + if b.Header.Height != c.tip.Header.Height+1 { + return fmt.Errorf("block height %d does not extend tip at %d", + b.Header.Height, c.tip.Header.Height) + } + if b.Header.PrevHash != c.tip.Hash() { + return fmt.Errorf("block %d: prev hash mismatch", b.Header.Height) + } + + h := b.Hash() + rollback := &chainAddRollback{ + blockHash: h, + previousTip: c.tip, + } + c.blocks[h] = b + c.byHeight[b.Header.Height] = b + c.tip = b + c.indexTxs(b) + + // Evict the block that has fallen outside the sliding window. + if b.Header.Height >= c.maxInMemoryBlocks { + evictH := b.Header.Height - c.maxInMemoryBlocks + if old, ok := c.byHeight[evictH]; ok { + rollback.evicted = old + delete(c.byHeight, evictH) + delete(c.blocks, old.Hash()) + for _, tx := range old.Txs { + delete(c.txIndex, tx.Hash()) + } + } + } + c.lastAdd = rollback + + return nil } // RollbackLastBlock reverses the most recent successful AddBlock. Consensus @@ -241,46 +241,46 @@ func (c *Chain) AddBlock(b *Block) error { // transition. It also restores any block evicted from the sliding window by // that AddBlock, so readers see the exact pre-add chain state. func (c *Chain) RollbackLastBlock(b *Block) error { - if b == nil { - return fmt.Errorf("rollback last block: nil block") - } - c.mu.Lock() - defer c.mu.Unlock() - - hash := b.Hash() - rollback := c.lastAdd - if c.tip == nil || c.tip.Hash() != hash || rollback == nil || - rollback.blockHash != hash || rollback.previousTip == nil { - return fmt.Errorf("rollback last block: block is not the latest AddBlock") - } - if b.Header.Height == 0 || - rollback.previousTip.Header.Height+1 != b.Header.Height || - b.Header.PrevHash != rollback.previousTip.Hash() { - return fmt.Errorf("rollback last block: invalid parent linkage") - } - - delete(c.blocks, hash) - delete(c.byHeight, b.Header.Height) - for _, tx := range b.Txs { - delete(c.txIndex, tx.Hash()) - } - - restore := func(block *Block) { - if block == nil { - return - } - blockHash := block.Hash() - c.blocks[blockHash] = block - c.byHeight[block.Header.Height] = block - c.indexTxs(block) - } - restore(rollback.previousTip) - if rollback.evicted != rollback.previousTip { - restore(rollback.evicted) - } - c.tip = rollback.previousTip - c.lastAdd = nil - return nil + if b == nil { + return fmt.Errorf("rollback last block: nil block") + } + c.mu.Lock() + defer c.mu.Unlock() + + hash := b.Hash() + rollback := c.lastAdd + if c.tip == nil || c.tip.Hash() != hash || rollback == nil || + rollback.blockHash != hash || rollback.previousTip == nil { + return fmt.Errorf("rollback last block: block is not the latest AddBlock") + } + if b.Header.Height == 0 || + rollback.previousTip.Header.Height+1 != b.Header.Height || + b.Header.PrevHash != rollback.previousTip.Hash() { + return fmt.Errorf("rollback last block: invalid parent linkage") + } + + delete(c.blocks, hash) + delete(c.byHeight, b.Header.Height) + for _, tx := range b.Txs { + delete(c.txIndex, tx.Hash()) + } + + restore := func(block *Block) { + if block == nil { + return + } + blockHash := block.Hash() + c.blocks[blockHash] = block + c.byHeight[block.Header.Height] = block + c.indexTxs(block) + } + restore(rollback.previousTip) + if rollback.evicted != rollback.previousTip { + restore(rollback.evicted) + } + c.tip = rollback.previousTip + c.lastAdd = nil + return nil } // FastForward installs a slice of pre-validated blocks into the chain without @@ -292,38 +292,38 @@ func (c *Chain) RollbackLastBlock(b *Block) error { // that large catch-up batches (e.g. P2P sync of 10 000+ blocks) do not // accumulate unboundedly in RAM. func (c *Chain) FastForward(blocks []*Block) { - if len(blocks) == 0 { - return - } - c.mu.Lock() - defer c.mu.Unlock() - c.lastAdd = nil - for _, b := range blocks { - h := b.Hash() - c.blocks[h] = b - c.byHeight[b.Header.Height] = b - c.tip = b - c.indexTxs(b) - // Evict the block that has fallen outside the sliding window, - // mirroring AddBlock so memory stays bounded during bulk loads. - if b.Header.Height >= c.maxInMemoryBlocks { - evictH := b.Header.Height - c.maxInMemoryBlocks - if old, ok := c.byHeight[evictH]; ok { - delete(c.byHeight, evictH) - delete(c.blocks, old.Hash()) - for _, tx := range old.Txs { - delete(c.txIndex, tx.Hash()) - } - } - } - } + if len(blocks) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.lastAdd = nil + for _, b := range blocks { + h := b.Hash() + c.blocks[h] = b + c.byHeight[b.Header.Height] = b + c.tip = b + c.indexTxs(b) + // Evict the block that has fallen outside the sliding window, + // mirroring AddBlock so memory stays bounded during bulk loads. + if b.Header.Height >= c.maxInMemoryBlocks { + evictH := b.Header.Height - c.maxInMemoryBlocks + if old, ok := c.byHeight[evictH]; ok { + delete(c.byHeight, evictH) + delete(c.blocks, old.Hash()) + for _, tx := range old.Txs { + delete(c.txIndex, tx.Hash()) + } + } + } + } } // TxIndexEntry is a pre-built tx location record used by FastForwardWithIndex // to avoid recomputing tx.Hash() at startup. type TxIndexEntry struct { - Height uint64 - TxIdx int + Height uint64 + TxIdx int } // FastForwardWithIndex is like FastForward but skips the expensive tx.Hash() @@ -335,61 +335,61 @@ type TxIndexEntry struct { // As with FastForward, the MaxInMemoryBlocks sliding-window eviction is applied // during the first pass so memory stays bounded. func (c *Chain) FastForwardWithIndex(blocks []*Block, txEntries map[crypto.Hash32]TxIndexEntry) { - if len(blocks) == 0 { - return - } - c.mu.Lock() - defer c.mu.Unlock() - c.lastAdd = nil - // First pass: populate blocks and byHeight maps with sliding-window eviction. - for _, b := range blocks { - h := b.Hash() - c.blocks[h] = b - c.byHeight[b.Header.Height] = b - c.tip = b - if b.Header.Height >= c.maxInMemoryBlocks { - evictH := b.Header.Height - c.maxInMemoryBlocks - if old, ok := c.byHeight[evictH]; ok { - delete(c.byHeight, evictH) - delete(c.blocks, old.Hash()) - for _, tx := range old.Txs { - delete(c.txIndex, tx.Hash()) - } - } - } - } - // Second pass: populate tx index from pre-built entries. - // Entries whose block was evicted above are silently skipped. - for txHash, entry := range txEntries { - blk := c.byHeight[entry.Height] - if blk == nil { - continue - } - if entry.TxIdx < 0 || entry.TxIdx >= len(blk.Txs) { - continue - } - c.txIndex[txHash] = TxLocation{Block: blk, TxIndex: entry.TxIdx} - } + if len(blocks) == 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.lastAdd = nil + // First pass: populate blocks and byHeight maps with sliding-window eviction. + for _, b := range blocks { + h := b.Hash() + c.blocks[h] = b + c.byHeight[b.Header.Height] = b + c.tip = b + if b.Header.Height >= c.maxInMemoryBlocks { + evictH := b.Header.Height - c.maxInMemoryBlocks + if old, ok := c.byHeight[evictH]; ok { + delete(c.byHeight, evictH) + delete(c.blocks, old.Hash()) + for _, tx := range old.Txs { + delete(c.txIndex, tx.Hash()) + } + } + } + } + // Second pass: populate tx index from pre-built entries. + // Entries whose block was evicted above are silently skipped. + for txHash, entry := range txEntries { + blk := c.byHeight[entry.Height] + if blk == nil { + continue + } + if entry.TxIdx < 0 || entry.TxIdx >= len(blk.Txs) { + continue + } + c.txIndex[txHash] = TxLocation{Block: blk, TxIndex: entry.TxIdx} + } } // indexTxs adds all transactions in b to the tx index. Caller must hold mu. func (c *Chain) indexTxs(b *Block) { - for i, tx := range b.Txs { - h := tx.Hash() - c.txIndex[h] = TxLocation{Block: b, TxIndex: i} - } + for i, tx := range b.Txs { + h := tx.Hash() + c.txIndex[h] = TxLocation{Block: b, TxIndex: i} + } } // GetTransaction looks up a transaction by its hash in the canonical chain. // Returns (tx, location, true) if found, or zero values and false otherwise. func (c *Chain) GetTransaction(hash crypto.Hash32) (Transaction, TxLocation, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - loc, ok := c.txIndex[hash] - if !ok { - return Transaction{}, TxLocation{}, false - } - return loc.Block.Txs[loc.TxIndex], loc, true + c.mu.RLock() + defer c.mu.RUnlock() + loc, ok := c.txIndex[hash] + if !ok { + return Transaction{}, TxLocation{}, false + } + return loc.Block.Txs[loc.TxIndex], loc, true } // BlockPruner is the interface used by PruneOldData to strip transaction data @@ -420,64 +420,64 @@ func (c *Chain) PruneOldData(pruner BlockPruner, keepBlocks uint64) (int, error) // Reorg replaces the canonical chain from forkPoint+1 onwards with newBlocks. // newBlocks must be contiguous, validated, and have higher cumulative weight. func (c *Chain) Reorg(forkPoint uint64, newBlocks []*Block) error { - c.mu.Lock() - defer c.mu.Unlock() - c.lastAdd = nil - - if len(newBlocks) == 0 { - return fmt.Errorf("reorg: no new blocks") - } - if newBlocks[0].Header.Height != forkPoint+1 { - return fmt.Errorf("reorg: new chain starts at %d, expected %d", - newBlocks[0].Header.Height, forkPoint+1) - } - - // Verify contiguity - for i := 1; i < len(newBlocks); i++ { - if newBlocks[i].Header.Height != newBlocks[i-1].Header.Height+1 { - return fmt.Errorf("reorg: gap between blocks %d and %d", - newBlocks[i-1].Header.Height, newBlocks[i].Header.Height) - } - if newBlocks[i].Header.PrevHash != newBlocks[i-1].Hash() { - return fmt.Errorf("reorg: prev hash mismatch at height %d", - newBlocks[i].Header.Height) - } - } - - // Remove old canonical blocks above fork point from both indexes. - oldTip := c.tip - for h := oldTip.Header.Height; h > forkPoint; h-- { - if old, ok := c.byHeight[h]; ok { - delete(c.blocks, old.Hash()) - delete(c.byHeight, h) - // Remove tx index entries for the orphaned block. - for _, tx := range old.Txs { - delete(c.txIndex, tx.Hash()) - } - } - } - - // Install new blocks and index their transactions so GetTransaction works. - // Apply the same MaxInMemoryBlocks sliding-window eviction as AddBlock so - // a reorg that brings in many blocks does not inflate the maps unboundedly. - for _, b := range newBlocks { - h := b.Hash() - c.blocks[h] = b - c.byHeight[b.Header.Height] = b - c.indexTxs(b) - if b.Header.Height >= c.maxInMemoryBlocks { - evictH := b.Header.Height - c.maxInMemoryBlocks - if old, ok := c.byHeight[evictH]; ok { - delete(c.byHeight, evictH) - delete(c.blocks, old.Hash()) - for _, tx := range old.Txs { - delete(c.txIndex, tx.Hash()) - } - } - } - } - c.tip = newBlocks[len(newBlocks)-1] - return nil + c.mu.Lock() + defer c.mu.Unlock() + c.lastAdd = nil + + if len(newBlocks) == 0 { + return fmt.Errorf("reorg: no new blocks") + } + if newBlocks[0].Header.Height != forkPoint+1 { + return fmt.Errorf("reorg: new chain starts at %d, expected %d", + newBlocks[0].Header.Height, forkPoint+1) + } + + // Verify contiguity + for i := 1; i < len(newBlocks); i++ { + if newBlocks[i].Header.Height != newBlocks[i-1].Header.Height+1 { + return fmt.Errorf("reorg: gap between blocks %d and %d", + newBlocks[i-1].Header.Height, newBlocks[i].Header.Height) + } + if newBlocks[i].Header.PrevHash != newBlocks[i-1].Hash() { + return fmt.Errorf("reorg: prev hash mismatch at height %d", + newBlocks[i].Header.Height) + } + } + + // Remove old canonical blocks above fork point from both indexes. + oldTip := c.tip + for h := oldTip.Header.Height; h > forkPoint; h-- { + if old, ok := c.byHeight[h]; ok { + delete(c.blocks, old.Hash()) + delete(c.byHeight, h) + // Remove tx index entries for the orphaned block. + for _, tx := range old.Txs { + delete(c.txIndex, tx.Hash()) + } + } + } + + // Install new blocks and index their transactions so GetTransaction works. + // Apply the same MaxInMemoryBlocks sliding-window eviction as AddBlock so + // a reorg that brings in many blocks does not inflate the maps unboundedly. + for _, b := range newBlocks { + h := b.Hash() + c.blocks[h] = b + c.byHeight[b.Header.Height] = b + c.indexTxs(b) + if b.Header.Height >= c.maxInMemoryBlocks { + evictH := b.Header.Height - c.maxInMemoryBlocks + if old, ok := c.byHeight[evictH]; ok { + delete(c.byHeight, evictH) + delete(c.blocks, old.Hash()) + for _, tx := range old.Txs { + delete(c.txIndex, tx.Hash()) + } + } + } + } + c.tip = newBlocks[len(newBlocks)-1] + return nil } // HeadersFrom returns up to limit block headers that come after the highest @@ -489,63 +489,63 @@ func (c *Chain) Reorg(forkPoint uint64, newBlocks []*Block) error { // (skipping genesis, which both parties are expected to share). // Implements the p2p.HeaderProvider interface. func (c *Chain) HeadersFrom(knownHashes []crypto.Hash32, limit int) []BlockHeader { - c.mu.RLock() - defer c.mu.RUnlock() - - if c.tip == nil { - return nil - } - - // Find the highest canonical height that matches any knownHash. - startHeight := uint64(1) // default: send from block 1 (skip genesis) - for _, h := range knownHashes { - if b, ok := c.blocks[h]; ok { - // Confirm this block is on the canonical chain. - if canon, ok2 := c.byHeight[b.Header.Height]; ok2 && canon.Hash() == h { - next := b.Header.Height + 1 - if next > startHeight { - startHeight = next - } - } - } - } - - if startHeight > c.tip.Header.Height { - return nil // already in sync - } - - if limit <= 0 || limit > 500 { - limit = 500 - } - - headers := make([]BlockHeader, 0, limit) - for h := startHeight; h <= c.tip.Header.Height && len(headers) < limit; h++ { - if b, ok := c.byHeight[h]; ok { - headers = append(headers, b.Header) - } - } - return headers + c.mu.RLock() + defer c.mu.RUnlock() + + if c.tip == nil { + return nil + } + + // Find the highest canonical height that matches any knownHash. + startHeight := uint64(1) // default: send from block 1 (skip genesis) + for _, h := range knownHashes { + if b, ok := c.blocks[h]; ok { + // Confirm this block is on the canonical chain. + if canon, ok2 := c.byHeight[b.Header.Height]; ok2 && canon.Hash() == h { + next := b.Header.Height + 1 + if next > startHeight { + startHeight = next + } + } + } + } + + if startHeight > c.tip.Header.Height { + return nil // already in sync + } + + if limit <= 0 || limit > 500 { + limit = 500 + } + + headers := make([]BlockHeader, 0, limit) + for h := startHeight; h <= c.tip.Header.Height && len(headers) < limit; h++ { + if b, ok := c.byHeight[h]; ok { + headers = append(headers, b.Header) + } + } + return headers } // TailHashes returns the hashes of the last n canonical blocks (for sync requests). func (c *Chain) TailHashes(n int) []crypto.Hash32 { - c.mu.RLock() - defer c.mu.RUnlock() - - tip := c.tip - if tip == nil { - return nil - } - - start := int(tip.Header.Height) - n + 1 - if start < 0 { - start = 0 - } - out := make([]crypto.Hash32, 0, n) - for h := uint64(start); h <= tip.Header.Height; h++ { - if b, ok := c.byHeight[h]; ok { - out = append(out, b.Hash()) - } - } - return out + c.mu.RLock() + defer c.mu.RUnlock() + + tip := c.tip + if tip == nil { + return nil + } + + start := int(tip.Header.Height) - n + 1 + if start < 0 { + start = 0 + } + out := make([]crypto.Hash32, 0, n) + for h := uint64(start); h <= tip.Header.Height; h++ { + if b, ok := c.byHeight[h]; ok { + out = append(out, b.Hash()) + } + } + return out } diff --git a/core/clsag_ring_store_test.go b/core/clsag_ring_store_test.go index e1dad06..1aba52e 100644 --- a/core/clsag_ring_store_test.go +++ b/core/clsag_ring_store_test.go @@ -12,8 +12,8 @@ type testRingStore struct { } func (*testRingStore) IsKeyImageSpent(crypto.KeyImage) (bool, error) { return false, nil } -func (*testRingStore) MarkKeyImageSpent(crypto.KeyImage) error { return nil } -func (*testRingStore) DeleteKeyImage(crypto.KeyImage) error { return nil } +func (*testRingStore) MarkKeyImageSpent(crypto.KeyImage) error { return nil } +func (*testRingStore) DeleteKeyImage(crypto.KeyImage) error { return nil } func (s *testRingStore) LookupRingMember(pub crypto.Point32) (*UTXO, error) { return s.members[pub], nil } @@ -49,7 +49,8 @@ func TestCLSAGOutputCacheIsBoundedAndFallsBackToPersistentIndex(t *testing.T) { if got := set.Get(first.TxHash, 0); got != nil { t.Fatal("old v5 output remained in bounded in-memory cache") } - if got := set.GetRingMember(first.OneTimePub); got != first { + if got := set.GetRingMember(first.OneTimePub); got == nil || + got.TxHash != first.TxHash || got.OneTimePub != first.OneTimePub { t.Fatal("persistent ring-member fallback did not resolve evicted output") } -} \ No newline at end of file +} diff --git a/core/core_test.go b/core/core_test.go index 5b514f6..9b5e57c 100644 --- a/core/core_test.go +++ b/core/core_test.go @@ -806,7 +806,7 @@ func makeCoinbaseTx() core.Transaction { func makeValidTx(t *testing.T) core.Transaction { t.Helper() - // Multiplier must exceed the serialised tx size (~1972 bytes at InitialBaseFeePerByte=200). + // Multiplier must exceed the serialized tx size (~1972 bytes at InitialBaseFeePerByte=200). // Use 3000 so fee = 200 * 3000 = 600_000 nAPRO ≥ 1972 * 200 = 394_400 nAPRO minimum. return makeValidTxWithFee(t, core.InitialBaseFeePerByte*3000) } diff --git a/core/coverage2_test.go b/core/coverage2_test.go index e20de55..b0ea170 100644 --- a/core/coverage2_test.go +++ b/core/coverage2_test.go @@ -3,11 +3,11 @@ package core_test // Second round of coverage tests to push core above 80%. import ( - "testing" - "time" + "testing" + "time" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // ─── mempool: evictOldest via MaxSize overflow ──────────────────────────────── @@ -16,155 +16,155 @@ import ( // We can't add real txs without a full RingCT setup, so just ensure Evict() // on an aged-out pool works without panicking. func TestMempool_Evict_Ages(t *testing.T) { - cfg := core.MempoolConfig{ - MaxSize: 5_000, - MaxTxSize: 100_000, - TTL: time.Millisecond, // very short TTL - BaseFeePerByte: core.InitialBaseFeePerByte, - } - mp := core.NewMempool(cfg) - time.Sleep(5 * time.Millisecond) - n := mp.Evict() - _ = n // 0 is fine — pool was empty + cfg := core.MempoolConfig{ + MaxSize: 5_000, + MaxTxSize: 100_000, + TTL: time.Millisecond, // very short TTL + BaseFeePerByte: core.InitialBaseFeePerByte, + } + mp := core.NewMempool(cfg) + time.Sleep(5 * time.Millisecond) + n := mp.Evict() + _ = n // 0 is fine — pool was empty } // ─── block_verifier: VerifyBlock paths ─────────────────────────────────────── func TestBlockVerifier_FutureTimestamp(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - - hdr := core.BlockHeader{ - Height: 1, - PrevHash: genesis.Hash(), - Timestamp: time.Now().Add(time.Hour).UnixNano(), - ValidatorPub: pub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdr.Sign(priv) - future := &core.Block{Header: hdr} - - chain := core.NewChain() - _ = chain.SetGenesis(genesis) - uset := core.NewUTXOSet() - txV := core.NewTxVerifier(uset) - verifier := core.NewBlockVerifier(core.DefaultBlockVerifierConfig(), chain, txV) - if err := verifier.VerifyBlock(future); err == nil { - t.Error("VerifyBlock must reject far-future timestamp") - } + priv, pub, _ := crypto.GenerateValidatorKey() + genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + + hdr := core.BlockHeader{ + Height: 1, + PrevHash: genesis.Hash(), + Timestamp: time.Now().Add(time.Hour).UnixNano(), + ValidatorPub: pub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdr.Sign(priv) + future := &core.Block{Header: hdr} + + chain := core.NewChain() + _ = chain.SetGenesis(genesis) + uset := core.NewUTXOSet() + txV := core.NewTxVerifier(uset) + verifier := core.NewBlockVerifier(core.DefaultBlockVerifierConfig(), chain, txV) + if err := verifier.VerifyBlock(future); err == nil { + t.Error("VerifyBlock must reject far-future timestamp") + } } func TestBlockVerifier_WrongPrevHash(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - - var wrongPrev crypto.Hash32 - wrongPrev[0] = 0xDE - hdr := core.BlockHeader{ - Height: 1, - PrevHash: wrongPrev, - Timestamp: time.Now().UnixNano(), - ValidatorPub: pub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdr.Sign(priv) - bad := &core.Block{Header: hdr} - - chain := core.NewChain() - _ = chain.SetGenesis(genesis) - uset := core.NewUTXOSet() - txV := core.NewTxVerifier(uset) - verifier := core.NewBlockVerifier(core.DefaultBlockVerifierConfig(), chain, txV) - if err := verifier.VerifyBlock(bad); err == nil { - t.Error("VerifyBlock must reject wrong PrevHash") - } + priv, pub, _ := crypto.GenerateValidatorKey() + genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + + var wrongPrev crypto.Hash32 + wrongPrev[0] = 0xDE + hdr := core.BlockHeader{ + Height: 1, + PrevHash: wrongPrev, + Timestamp: time.Now().UnixNano(), + ValidatorPub: pub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdr.Sign(priv) + bad := &core.Block{Header: hdr} + + chain := core.NewChain() + _ = chain.SetGenesis(genesis) + uset := core.NewUTXOSet() + txV := core.NewTxVerifier(uset) + verifier := core.NewBlockVerifier(core.DefaultBlockVerifierConfig(), chain, txV) + if err := verifier.VerifyBlock(bad); err == nil { + t.Error("VerifyBlock must reject wrong PrevHash") + } } // ─── genesis: Validate edge cases ──────────────────────────────────────────── func TestGenesis_Validate_EmptyChainID(t *testing.T) { - gc := &core.GenesisConfig{ - InitialSupply: 1_000_000, - BlockTimeMs: 1000, - MinValidators: 1, - BFTThreshold: 0.667, - RingSize: 2, - } - if err := gc.Validate(); err == nil { - t.Error("Validate must fail with empty chain_id") - } + gc := &core.GenesisConfig{ + InitialSupply: 1_000_000, + BlockTimeMs: 1000, + MinValidators: 1, + BFTThreshold: 0.667, + RingSize: 2, + } + if err := gc.Validate(); err == nil { + t.Error("Validate must fail with empty chain_id") + } } func TestGenesis_Validate_ZeroSupply(t *testing.T) { - _, pub, _ := crypto.GenerateValidatorKey() - gc := &core.GenesisConfig{ - ChainID: "test", - InitialSupply: 0, - BlockTimeMs: 1000, - MinValidators: 1, - BFTThreshold: 0.667, - RingSize: 2, - Validators: []string{pub.Hex()}, - } - if err := gc.Validate(); err == nil { - t.Error("Validate must fail with zero initial_supply") - } + _, pub, _ := crypto.GenerateValidatorKey() + gc := &core.GenesisConfig{ + ChainID: "test", + InitialSupply: 0, + BlockTimeMs: 1000, + MinValidators: 1, + BFTThreshold: 0.667, + RingSize: 2, + Validators: []string{pub.Hex()}, + } + if err := gc.Validate(); err == nil { + t.Error("Validate must fail with zero initial_supply") + } } func TestGenesis_Validate_BadThreshold(t *testing.T) { - _, pub, _ := crypto.GenerateValidatorKey() - gc := &core.GenesisConfig{ - ChainID: "test", - InitialSupply: 1_000_000, - BlockTimeMs: 1000, - MinValidators: 1, - BFTThreshold: 1.5, - RingSize: 2, - Validators: []string{pub.Hex()}, - } - if err := gc.Validate(); err == nil { - t.Error("Validate must fail with bft_threshold > 1") - } + _, pub, _ := crypto.GenerateValidatorKey() + gc := &core.GenesisConfig{ + ChainID: "test", + InitialSupply: 1_000_000, + BlockTimeMs: 1000, + MinValidators: 1, + BFTThreshold: 1.5, + RingSize: 2, + Validators: []string{pub.Hex()}, + } + if err := gc.Validate(); err == nil { + t.Error("Validate must fail with bft_threshold > 1") + } } // ─── chain.go: Height ──────────────────────────────────────────────────────── func TestChain_Height_AfterBlocks(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - chain := core.NewChain() - _ = chain.SetGenesis(genesis) - - if chain.Height() != 0 { - t.Errorf("height after genesis = %d, want 0", chain.Height()) - } - b1 := makeBlock(t, priv, pub, 1, genesis.Hash()) - _ = chain.AddBlock(b1) - if chain.Height() != 1 { - t.Errorf("height after b1 = %d, want 1", chain.Height()) - } + priv, pub, _ := crypto.GenerateValidatorKey() + genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + chain := core.NewChain() + _ = chain.SetGenesis(genesis) + + if chain.Height() != 0 { + t.Errorf("height after genesis = %d, want 0", chain.Height()) + } + b1 := makeBlock(t, priv, pub, 1, genesis.Hash()) + _ = chain.AddBlock(b1) + if chain.Height() != 1 { + t.Errorf("height after b1 = %d, want 1", chain.Height()) + } } // ─── tx_verifier: VerifyBlock with coinbase ─────────────────────────────────── func TestVerifyBlock_WithCoinbase(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - cb := core.CoinbaseTx(wk.Spend.Public, 5_000_000) - - priv, pub, _ := crypto.GenerateValidatorKey() - txs := []core.Transaction{cb} - hdr := core.BlockHeader{ - Height: 0, - Timestamp: time.Now().UnixNano(), - ValidatorPub: pub, - MerkleRoot: core.MerkleRoot(txs), - } - _ = hdr.Sign(priv) - block := &core.Block{Header: hdr, Txs: txs} - - uset := core.NewUTXOSet() - verifier := core.NewTxVerifier(uset) - if err := verifier.VerifyBlock(block); err != nil { - t.Errorf("VerifyBlock with coinbase: %v", err) - } + wk, _ := crypto.GenerateWalletKeys() + cb := core.CoinbaseTx(wk.Spend.Public, 5_000_000) + + priv, pub, _ := crypto.GenerateValidatorKey() + txs := []core.Transaction{cb} + hdr := core.BlockHeader{ + Height: 0, + Timestamp: time.Now().UnixNano(), + ValidatorPub: pub, + MerkleRoot: core.MerkleRoot(txs), + } + _ = hdr.Sign(priv) + block := &core.Block{Header: hdr, Txs: txs} + + uset := core.NewUTXOSet() + verifier := core.NewTxVerifier(uset) + if err := verifier.VerifyBlock(block); err != nil { + t.Errorf("VerifyBlock with coinbase: %v", err) + } } diff --git a/core/coverage3_test.go b/core/coverage3_test.go index c658685..7aea622 100644 --- a/core/coverage3_test.go +++ b/core/coverage3_test.go @@ -3,179 +3,179 @@ package core_test // Third round: target specific uncovered branches in core. import ( - "testing" + "testing" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // ─── transaction.Validate: all error branches ───────────────────────────────── func TestTransactionValidate_ZeroVersion(t *testing.T) { - tx := core.Transaction{Version: 0, Outputs: []core.Output{{}}} - if err := tx.Validate(); err == nil { - t.Error("Validate must reject version 0") - } + tx := core.Transaction{Version: 0, Outputs: []core.Output{{}}} + if err := tx.Validate(); err == nil { + t.Error("Validate must reject version 0") + } } func TestTransactionValidate_NoOutputs(t *testing.T) { - tx := core.Transaction{Version: core.TxVersionBase} - if err := tx.Validate(); err == nil { - t.Error("Validate must reject tx with no outputs") - } + tx := core.Transaction{Version: core.TxVersionBase} + if err := tx.Validate(); err == nil { + t.Error("Validate must reject tx with no outputs") + } } func TestTransactionValidate_TooManyOutputs(t *testing.T) { - outs := make([]core.Output, 17) - tx := core.Transaction{Version: core.TxVersionBase, Outputs: outs} - if err := tx.Validate(); err == nil { - t.Error("Validate must reject tx with >16 outputs") - } + outs := make([]core.Output, 17) + tx := core.Transaction{Version: core.TxVersionBase, Outputs: outs} + if err := tx.Validate(); err == nil { + t.Error("Validate must reject tx with >16 outputs") + } } func TestTransactionValidate_ExtraTooBig(t *testing.T) { - tx := core.Transaction{ - Version: core.TxVersionBase, - Outputs: []core.Output{{}}, - Extra: make([]byte, 256), - } - if err := tx.Validate(); err == nil { - t.Error("Validate must reject tx with extra > 255 bytes") - } + tx := core.Transaction{ + Version: core.TxVersionBase, + Outputs: []core.Output{{}}, + Extra: make([]byte, 256), + } + if err := tx.Validate(); err == nil { + t.Error("Validate must reject tx with extra > 255 bytes") + } } func TestTransactionValidate_Coinbase_Valid(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - cb := core.CoinbaseTx(wk.Spend.Public, 1_000_000) - if err := cb.Validate(); err != nil { - t.Errorf("coinbase Validate: %v", err) - } + wk, _ := crypto.GenerateWalletKeys() + cb := core.CoinbaseTx(wk.Spend.Public, 1_000_000) + if err := cb.Validate(); err != nil { + t.Errorf("coinbase Validate: %v", err) + } } func TestTransactionValidate_SigCountMismatch(t *testing.T) { - var ki crypto.KeyImage - ki[0] = 1 - ring := make([]crypto.RingMember, crypto.RingSize) - tx := core.Transaction{ - Version: core.TxVersionBase, - Inputs: []core.RingInput{{ - KeyImage: ki, - Ring: ring, - }}, - Outputs: []core.Output{{}, {}}, - Signatures: nil, // 0 sigs for 1 input - } - if err := tx.Validate(); err == nil { - t.Error("Validate must reject sig count mismatch") - } + var ki crypto.KeyImage + ki[0] = 1 + ring := make([]crypto.RingMember, crypto.RingSize) + tx := core.Transaction{ + Version: core.TxVersionBase, + Inputs: []core.RingInput{{ + KeyImage: ki, + Ring: ring, + }}, + Outputs: []core.Output{{}, {}}, + Signatures: nil, // 0 sigs for 1 input + } + if err := tx.Validate(); err == nil { + t.Error("Validate must reject sig count mismatch") + } } func TestTransactionValidate_RangeProofCountMismatch(t *testing.T) { - var ki crypto.KeyImage - ki[0] = 1 - ring := make([]crypto.RingMember, crypto.RingSize) - sig := &crypto.MLSAGSignature{} - tx := core.Transaction{ - Version: core.TxVersionBase, - Inputs: []core.RingInput{{ - KeyImage: ki, - Ring: ring, - }}, - Outputs: []core.Output{{}, {}}, - Signatures: []*crypto.MLSAGSignature{sig}, - RangeProofs: nil, // 0 proofs for 2 outputs - } - if err := tx.Validate(); err == nil { - t.Error("Validate must reject range proof count mismatch") - } + var ki crypto.KeyImage + ki[0] = 1 + ring := make([]crypto.RingMember, crypto.RingSize) + sig := &crypto.MLSAGSignature{} + tx := core.Transaction{ + Version: core.TxVersionBase, + Inputs: []core.RingInput{{ + KeyImage: ki, + Ring: ring, + }}, + Outputs: []core.Output{{}, {}}, + Signatures: []*crypto.MLSAGSignature{sig}, + RangeProofs: nil, // 0 proofs for 2 outputs + } + if err := tx.Validate(); err == nil { + t.Error("Validate must reject range proof count mismatch") + } } func TestTransactionValidate_WrongRingSize(t *testing.T) { - var ki crypto.KeyImage - ki[0] = 1 - ring := make([]crypto.RingMember, 3) // wrong (need 16) - sig := &crypto.MLSAGSignature{} - proof := &crypto.RangeProof{} - tx := core.Transaction{ - Version: core.TxVersionBase, - Inputs: []core.RingInput{{ - KeyImage: ki, - Ring: ring, - }}, - Outputs: []core.Output{{}}, - Signatures: []*crypto.MLSAGSignature{sig}, - RangeProofs: []*crypto.RangeProof{proof}, - } - if err := tx.Validate(); err == nil { - t.Error("Validate must reject wrong ring size") - } + var ki crypto.KeyImage + ki[0] = 1 + ring := make([]crypto.RingMember, 3) // wrong (need 16) + sig := &crypto.MLSAGSignature{} + proof := &crypto.RangeProof{} + tx := core.Transaction{ + Version: core.TxVersionBase, + Inputs: []core.RingInput{{ + KeyImage: ki, + Ring: ring, + }}, + Outputs: []core.Output{{}}, + Signatures: []*crypto.MLSAGSignature{sig}, + RangeProofs: []*crypto.RangeProof{proof}, + } + if err := tx.Validate(); err == nil { + t.Error("Validate must reject wrong ring size") + } } func TestTransactionValidate_ZeroKeyImage(t *testing.T) { - ring := make([]crypto.RingMember, crypto.RingSize) - sig := &crypto.MLSAGSignature{} - proof := &crypto.RangeProof{} - tx := core.Transaction{ - Version: core.TxVersionBase, - Inputs: []core.RingInput{{ - KeyImage: crypto.KeyImage{}, - Ring: ring, - }}, - Outputs: []core.Output{{}}, - Signatures: []*crypto.MLSAGSignature{sig}, - RangeProofs: []*crypto.RangeProof{proof}, - } - if err := tx.Validate(); err == nil { - t.Error("Validate must reject zero key image") - } + ring := make([]crypto.RingMember, crypto.RingSize) + sig := &crypto.MLSAGSignature{} + proof := &crypto.RangeProof{} + tx := core.Transaction{ + Version: core.TxVersionBase, + Inputs: []core.RingInput{{ + KeyImage: crypto.KeyImage{}, + Ring: ring, + }}, + Outputs: []core.Output{{}}, + Signatures: []*crypto.MLSAGSignature{sig}, + RangeProofs: []*crypto.RangeProof{proof}, + } + if err := tx.Validate(); err == nil { + t.Error("Validate must reject zero key image") + } } // ─── genesis: CreateGenesisBlock with allocations ───────────────────────────── func TestCreateGenesisBlock_WithAllocations(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - - // Generate two real wallet key pairs so we can build valid Aperod addresses. - wk1, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("GenerateWalletKeys: %v", err) - } - wk2, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("GenerateWalletKeys: %v", err) - } - addr1 := string(crypto.AddressFromKeys(crypto.TestnetByte, wk1)) - addr2 := string(crypto.AddressFromKeys(crypto.TestnetByte, wk2)) - - gc := &core.GenesisConfig{ - ChainID: "testnet-cov", - InitialSupply: 21_000_000_000_000, - BlockTimeMs: 2000, - MinValidators: 1, - BFTThreshold: 0.667, - RingSize: 16, - Validators: []string{pub.Hex()}, - Allocations: []core.GenesisAlloc{ - {Address: addr1, Amount: 1_000_000}, - {Address: addr2, Amount: 500_000}, - {Address: "", Amount: 999}, // placeholder — must be silently skipped - }, - } - block, err := core.CreateGenesisBlock(gc, priv) - if err != nil { - t.Fatalf("CreateGenesisBlock: %v", err) - } - if block == nil { - t.Fatal("CreateGenesisBlock returned nil") - } - if block.Header.Height != 0 { - t.Errorf("genesis height = %d, want 0", block.Header.Height) - } - // Two valid allocations → two transactions → non-zero, non-empty Merkle root. - if len(block.Txs) != 2 { - t.Errorf("genesis tx count = %d, want 2", len(block.Txs)) - } - if block.Header.MerkleRoot == (crypto.Hash32{}) { - t.Error("genesis MerkleRoot must not be the zero hash") - } + priv, pub, _ := crypto.GenerateValidatorKey() + + // Generate two real wallet key pairs so we can build valid Aperod addresses. + wk1, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + wk2, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + addr1 := string(crypto.AddressFromKeys(crypto.TestnetByte, wk1)) + addr2 := string(crypto.AddressFromKeys(crypto.TestnetByte, wk2)) + + gc := &core.GenesisConfig{ + ChainID: "testnet-cov", + InitialSupply: 21_000_000_000_000, + BlockTimeMs: 2000, + MinValidators: 1, + BFTThreshold: 0.667, + RingSize: 16, + Validators: []string{pub.Hex()}, + Allocations: []core.GenesisAlloc{ + {Address: addr1, Amount: 1_000_000}, + {Address: addr2, Amount: 500_000}, + {Address: "", Amount: 999}, // placeholder — must be silently skipped + }, + } + block, err := core.CreateGenesisBlock(gc, priv) + if err != nil { + t.Fatalf("CreateGenesisBlock: %v", err) + } + if block == nil { + t.Fatal("CreateGenesisBlock returned nil") + } + if block.Header.Height != 0 { + t.Errorf("genesis height = %d, want 0", block.Header.Height) + } + // Two valid allocations → two transactions → non-zero, non-empty Merkle root. + if len(block.Txs) != 2 { + t.Errorf("genesis tx count = %d, want 2", len(block.Txs)) + } + if block.Header.MerkleRoot == (crypto.Hash32{}) { + t.Error("genesis MerkleRoot must not be the zero hash") + } } diff --git a/core/coverage_test.go b/core/coverage_test.go index b91eb55..e13bb81 100644 --- a/core/coverage_test.go +++ b/core/coverage_test.go @@ -3,283 +3,283 @@ package core_test // Additional tests to push core coverage above 80%. import ( - "os" - "path/filepath" - "testing" - "time" + "os" + "path/filepath" + "testing" + "time" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // ─── block.go ───────────────────────────────────────────────────────────────── func TestBlock_Time(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - b := makeBlock(t, priv, pub, 1, crypto.Hash32{}) - tm := b.Time() - if tm.IsZero() { - t.Error("Block.Time() must not be zero") - } + priv, pub, _ := crypto.GenerateValidatorKey() + b := makeBlock(t, priv, pub, 1, crypto.Hash32{}) + tm := b.Time() + if tm.IsZero() { + t.Error("Block.Time() must not be zero") + } } func TestBlock_Validate_NilSignature(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - b.Header.Signature = nil - if err := b.Validate(); err == nil { - t.Error("Validate() must fail without signature") - } + priv, pub, _ := crypto.GenerateValidatorKey() + b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + b.Header.Signature = nil + if err := b.Validate(); err == nil { + t.Error("Validate() must fail without signature") + } } // TestBlock_Validate_MerkleRoot verifies that mismatched merkle roots are detected. func TestBlock_Validate_MerkleRootMismatch(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - hdr := core.BlockHeader{ - Height: 0, - Timestamp: time.Now().UnixNano(), - ValidatorPub: pub, - MerkleRoot: core.MerkleRoot(nil), // correct: no txs - } - if err := hdr.Sign(priv); err != nil { - t.Fatal(err) - } - b := &core.Block{Header: hdr} - // Block has no txs and correct MerkleRoot → should validate fine - if err := b.Validate(); err != nil { - t.Errorf("Validate() on valid empty block: %v", err) - } + priv, pub, _ := crypto.GenerateValidatorKey() + hdr := core.BlockHeader{ + Height: 0, + Timestamp: time.Now().UnixNano(), + ValidatorPub: pub, + MerkleRoot: core.MerkleRoot(nil), // correct: no txs + } + if err := hdr.Sign(priv); err != nil { + t.Fatal(err) + } + b := &core.Block{Header: hdr} + // Block has no txs and correct MerkleRoot → should validate fine + if err := b.Validate(); err != nil { + t.Errorf("Validate() on valid empty block: %v", err) + } } // ─── chain.go ───────────────────────────────────────────────────────────────── func TestChain_Genesis(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - chain := core.NewChain() - if err := chain.SetGenesis(genesis); err != nil { - t.Fatal(err) - } - g := chain.Genesis() - if g == nil { - t.Fatal("Genesis() must not be nil") - } - if g.Header.Height != 0 { - t.Errorf("genesis height = %d, want 0", g.Header.Height) - } + priv, pub, _ := crypto.GenerateValidatorKey() + genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + chain := core.NewChain() + if err := chain.SetGenesis(genesis); err != nil { + t.Fatal(err) + } + g := chain.Genesis() + if g == nil { + t.Fatal("Genesis() must not be nil") + } + if g.Header.Height != 0 { + t.Errorf("genesis height = %d, want 0", g.Header.Height) + } } func TestChain_HasBlock(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - chain := core.NewChain() - _ = chain.SetGenesis(genesis) - - if !chain.HasBlock(genesis.Hash()) { - t.Error("HasBlock(genesis) must be true") - } - var unknown crypto.Hash32 - unknown[0] = 0xFF - if chain.HasBlock(unknown) { - t.Error("HasBlock(unknown) must be false") - } - - b1 := makeBlock(t, priv, pub, 1, genesis.Hash()) - _ = chain.AddBlock(b1) - if !chain.HasBlock(b1.Hash()) { - t.Error("HasBlock(b1) must be true after AddBlock") - } + priv, pub, _ := crypto.GenerateValidatorKey() + genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + chain := core.NewChain() + _ = chain.SetGenesis(genesis) + + if !chain.HasBlock(genesis.Hash()) { + t.Error("HasBlock(genesis) must be true") + } + var unknown crypto.Hash32 + unknown[0] = 0xFF + if chain.HasBlock(unknown) { + t.Error("HasBlock(unknown) must be false") + } + + b1 := makeBlock(t, priv, pub, 1, genesis.Hash()) + _ = chain.AddBlock(b1) + if !chain.HasBlock(b1.Hash()) { + t.Error("HasBlock(b1) must be true after AddBlock") + } } func TestChain_TailHashes(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - chain := core.NewChain() - _ = chain.SetGenesis(genesis) - - prev := genesis.Hash() - for i := uint64(1); i <= 5; i++ { - b := makeBlock(t, priv, pub, i, prev) - _ = chain.AddBlock(b) - prev = b.Hash() - } - - hashes := chain.TailHashes(3) - if len(hashes) != 3 { - t.Errorf("TailHashes(3) = %d, want 3", len(hashes)) - } + priv, pub, _ := crypto.GenerateValidatorKey() + genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + chain := core.NewChain() + _ = chain.SetGenesis(genesis) + + prev := genesis.Hash() + for i := uint64(1); i <= 5; i++ { + b := makeBlock(t, priv, pub, i, prev) + _ = chain.AddBlock(b) + prev = b.Hash() + } + + hashes := chain.TailHashes(3) + if len(hashes) != 3 { + t.Errorf("TailHashes(3) = %d, want 3", len(hashes)) + } } func TestChain_TailHashes_LessThanN(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - chain := core.NewChain() - _ = chain.SetGenesis(genesis) - - hashes := chain.TailHashes(10) - if len(hashes) == 0 { - t.Error("TailHashes on chain[0] must return at least genesis") - } + priv, pub, _ := crypto.GenerateValidatorKey() + genesis := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + chain := core.NewChain() + _ = chain.SetGenesis(genesis) + + hashes := chain.TailHashes(10) + if len(hashes) == 0 { + t.Error("TailHashes on chain[0] must return at least genesis") + } } // ─── mempool.go ─────────────────────────────────────────────────────────────── func TestMempool_RemoveBlock(t *testing.T) { - mp := core.NewMempool(core.DefaultMempoolConfig()) - priv, pub, _ := crypto.GenerateValidatorKey() - b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - // RemoveBlock on empty mempool must not panic - mp.RemoveBlock(b) + mp := core.NewMempool(core.DefaultMempoolConfig()) + priv, pub, _ := crypto.GenerateValidatorKey() + b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + // RemoveBlock on empty mempool must not panic + mp.RemoveBlock(b) } func TestMempool_Hashes(t *testing.T) { - mp := core.NewMempool(core.DefaultMempoolConfig()) - hashes := mp.Hashes() - if hashes == nil { - t.Error("Hashes() must return non-nil") - } - if len(hashes) != 0 { - t.Errorf("fresh mempool Hashes() = %d, want 0", len(hashes)) - } + mp := core.NewMempool(core.DefaultMempoolConfig()) + hashes := mp.Hashes() + if hashes == nil { + t.Error("Hashes() must return non-nil") + } + if len(hashes) != 0 { + t.Errorf("fresh mempool Hashes() = %d, want 0", len(hashes)) + } } func TestMempool_Evict(t *testing.T) { - mp := core.NewMempool(core.DefaultMempoolConfig()) - // Evict with nothing to evict must not panic - mp.Evict() + mp := core.NewMempool(core.DefaultMempoolConfig()) + // Evict with nothing to evict must not panic + mp.Evict() } // ─── transaction.go ─────────────────────────────────────────────────────────── func TestTransaction_MinFee(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - cb := core.CoinbaseTx(wk.Spend.Public, 5_000_000) - fee := cb.MinFeeAt(core.InitialBaseFeePerByte) - // MinFee for a zero-input tx could be 0 — just must not panic - _ = fee + wk, _ := crypto.GenerateWalletKeys() + cb := core.CoinbaseTx(wk.Spend.Public, 5_000_000) + fee := cb.MinFeeAt(core.InitialBaseFeePerByte) + // MinFee for a zero-input tx could be 0 — just must not panic + _ = fee } func TestTransaction_KeyImages(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - cb := core.CoinbaseTx(wk.Spend.Public, 5_000_000) - ki := cb.KeyImages() - if ki == nil { - t.Error("KeyImages() must return non-nil") - } - if len(ki) != 0 { - t.Errorf("coinbase KeyImages len = %d, want 0", len(ki)) - } + wk, _ := crypto.GenerateWalletKeys() + cb := core.CoinbaseTx(wk.Spend.Public, 5_000_000) + ki := cb.KeyImages() + if ki == nil { + t.Error("KeyImages() must return non-nil") + } + if len(ki) != 0 { + t.Errorf("coinbase KeyImages len = %d, want 0", len(ki)) + } } func TestCoinbaseTx_Fields(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - reward := uint64(5_000_000) - cb := core.CoinbaseTx(wk.Spend.Public, reward) - if !cb.IsCoinbase() { - t.Error("CoinbaseTx must be detected as coinbase") - } - if cb.Fee != 0 { - t.Errorf("coinbase Fee = %d, want 0", cb.Fee) - } - if len(cb.Outputs) == 0 { - t.Error("coinbase must have at least one output") - } + wk, _ := crypto.GenerateWalletKeys() + reward := uint64(5_000_000) + cb := core.CoinbaseTx(wk.Spend.Public, reward) + if !cb.IsCoinbase() { + t.Error("CoinbaseTx must be detected as coinbase") + } + if cb.Fee != 0 { + t.Errorf("coinbase Fee = %d, want 0", cb.Fee) + } + if len(cb.Outputs) == 0 { + t.Error("coinbase must have at least one output") + } } // ─── utxo.go ───────────────────────────────────────────────────────────────── func TestUTXOSet_RollbackBlock(t *testing.T) { - uset := core.NewUTXOSet() - priv, pub, _ := crypto.GenerateValidatorKey() - b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - // RollbackBlock on empty block must not error - if err := uset.RollbackBlock(b); err != nil { - t.Errorf("RollbackBlock: %v", err) - } + uset := core.NewUTXOSet() + priv, pub, _ := crypto.GenerateValidatorKey() + b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + // RollbackBlock on empty block must not error + if err := uset.RollbackBlock(b); err != nil { + t.Errorf("RollbackBlock: %v", err) + } } func TestUTXOSet_CountAndAll(t *testing.T) { - uset := core.NewUTXOSet() - if uset.Count() != 0 { - t.Errorf("fresh UTXOSet Count = %d, want 0", uset.Count()) - } - all := uset.All() - if all == nil { - t.Error("All() must return non-nil") - } - if len(all) != 0 { - t.Errorf("fresh UTXOSet All() len = %d, want 0", len(all)) - } + uset := core.NewUTXOSet() + if uset.Count() != 0 { + t.Errorf("fresh UTXOSet Count = %d, want 0", uset.Count()) + } + all := uset.All() + if all == nil { + t.Error("All() must return non-nil") + } + if len(all) != 0 { + t.Errorf("fresh UTXOSet All() len = %d, want 0", len(all)) + } } // ─── genesis.go ─────────────────────────────────────────────────────────────── func TestGenesis_LoadAndCreate(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - - tmpDir := t.TempDir() - genesisYAML := "chain_id: \"aperod-test-1\"\ninitial_supply: 1000000000\nblock_time_ms: 1000\nmin_validators: 1\nbft_threshold: 0.667\nring_size: 2\nvalidators:\n - " + pub.Hex() + "\nallocations: []\n" - path := filepath.Join(tmpDir, "genesis.yaml") - if err := os.WriteFile(path, []byte(genesisYAML), 0644); err != nil { - t.Fatal(err) - } - - gc, err := core.LoadGenesis(path) - if err != nil { - t.Fatalf("LoadGenesis: %v", err) - } - if err := gc.Validate(); err != nil { - t.Fatalf("Validate: %v", err) - } - - block, err := core.CreateGenesisBlock(gc, priv) - if err != nil { - t.Fatalf("CreateGenesisBlock: %v", err) - } - if block.Header.Height != 0 { - t.Errorf("genesis height = %d, want 0", block.Header.Height) - } - - h := core.GenesisHash(gc) - h2 := core.GenesisHash(gc) - if h != h2 { - t.Error("GenesisHash must be deterministic") - } + priv, pub, _ := crypto.GenerateValidatorKey() + + tmpDir := t.TempDir() + genesisYAML := "chain_id: \"aperod-test-1\"\ninitial_supply: 1000000000\nblock_time_ms: 1000\nmin_validators: 1\nbft_threshold: 0.667\nring_size: 2\nvalidators:\n - " + pub.Hex() + "\nallocations: []\n" + path := filepath.Join(tmpDir, "genesis.yaml") + if err := os.WriteFile(path, []byte(genesisYAML), 0644); err != nil { + t.Fatal(err) + } + + gc, err := core.LoadGenesis(path) + if err != nil { + t.Fatalf("LoadGenesis: %v", err) + } + if err := gc.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + + block, err := core.CreateGenesisBlock(gc, priv) + if err != nil { + t.Fatalf("CreateGenesisBlock: %v", err) + } + if block.Header.Height != 0 { + t.Errorf("genesis height = %d, want 0", block.Header.Height) + } + + h := core.GenesisHash(gc) + h2 := core.GenesisHash(gc) + if h != h2 { + t.Error("GenesisHash must be deterministic") + } } func TestGenesis_Validate_NoValidators(t *testing.T) { - gc := &core.GenesisConfig{ - ChainID: "test", - InitialSupply: 1_000_000, - BlockTimeMs: 1000, - } - if err := gc.Validate(); err == nil { - t.Error("Validate() must fail with no validators") - } + gc := &core.GenesisConfig{ + ChainID: "test", + InitialSupply: 1_000_000, + BlockTimeMs: 1000, + } + if err := gc.Validate(); err == nil { + t.Error("Validate() must fail with no validators") + } } // ─── tx_verifier.go ─────────────────────────────────────────────────────────── func TestVerifyBlock_EmptyBlock(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() - b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) - uset := core.NewUTXOSet() - verifier := core.NewTxVerifier(uset) - if err := verifier.VerifyBlock(b); err != nil { - t.Errorf("VerifyBlock on empty block: %v", err) - } + priv, pub, _ := crypto.GenerateValidatorKey() + b := makeBlock(t, priv, pub, 0, crypto.Hash32{}) + uset := core.NewUTXOSet() + verifier := core.NewTxVerifier(uset) + if err := verifier.VerifyBlock(b); err != nil { + t.Errorf("VerifyBlock on empty block: %v", err) + } } func TestRingSignMessage_Deterministic(t *testing.T) { - var txHash crypto.Hash32 - txHash[0] = 0xAB - m1 := core.RingSignMessage(txHash, 0) - m2 := core.RingSignMessage(txHash, 0) - if m1 != m2 { - t.Error("RingSignMessage must be deterministic") - } - m3 := core.RingSignMessage(txHash, 1) - if m1 == m3 { - t.Error("RingSignMessage must differ for different inputIdx") - } + var txHash crypto.Hash32 + txHash[0] = 0xAB + m1 := core.RingSignMessage(txHash, 0) + m2 := core.RingSignMessage(txHash, 0) + if m1 != m2 { + t.Error("RingSignMessage must be deterministic") + } + m3 := core.RingSignMessage(txHash, 1) + if m1 == m3 { + t.Error("RingSignMessage must differ for different inputIdx") + } } diff --git a/core/decoy_sampling_test.go b/core/decoy_sampling_test.go index eefcda1..3957bfb 100644 --- a/core/decoy_sampling_test.go +++ b/core/decoy_sampling_test.go @@ -142,9 +142,9 @@ func TestDecoySelectorsFiltersCountsAndFailures(t *testing.T) { } type samplingStore struct { - result []DecoyUTXO - err error - calls int + result []DecoyUTXO + err error + calls int exclude map[crypto.Point32]bool } diff --git a/core/genesis.go b/core/genesis.go index 48f66c4..1ac4508 100644 --- a/core/genesis.go +++ b/core/genesis.go @@ -1,66 +1,67 @@ package core import ( - "fmt" - "os" - "time" + "fmt" + "os" + "time" - "github.com/aperod/aperod/crypto" - "gopkg.in/yaml.v3" + "gopkg.in/yaml.v3" + + "github.com/aperod/aperod/crypto" ) // StakingGenesisConfig holds stake-based validator-selection parameters // read from the genesis YAML staking: section. type StakingGenesisConfig struct { - MaxValidators int `yaml:"max_validators"` // hard cap (default 21) - MinStakeAPR uint64 `yaml:"min_stake_apr"` // minimum deposit in whole APRO - EpochLength uint64 `yaml:"epoch_length"` // blocks between set updates - ChurnLimit int `yaml:"churn_limit"` // max new activations per epoch - UnbondingBlocks uint64 `yaml:"unbonding_blocks"` // withdrawal lock duration in blocks - SlashPercent int `yaml:"slash_percent"` // % slashed for proven double-sign - GenesisStakeAPR uint64 `yaml:"genesis_stake_apr"` // stake credited to genesis validators + MaxValidators int `yaml:"max_validators"` // hard cap (default 21) + MinStakeAPR uint64 `yaml:"min_stake_apr"` // minimum deposit in whole APRO + EpochLength uint64 `yaml:"epoch_length"` // blocks between set updates + ChurnLimit int `yaml:"churn_limit"` // max new activations per epoch + UnbondingBlocks uint64 `yaml:"unbonding_blocks"` // withdrawal lock duration in blocks + SlashPercent int `yaml:"slash_percent"` // % slashed for proven double-sign + GenesisStakeAPR uint64 `yaml:"genesis_stake_apr"` // stake credited to genesis validators } // GenesisConfig is the configuration loaded from genesis.yaml. type GenesisConfig struct { - ChainID string `yaml:"chain_id"` - Timestamp int64 `yaml:"timestamp"` // Unix seconds; 0 = use current time - InitialSupply uint64 `yaml:"initial_supply"` // total APRO (in APRO, not base units) - BlockTimeMs int64 `yaml:"block_time_ms"` - RingSize int `yaml:"ring_size"` - MinValidators int `yaml:"min_validators"` - BFTThreshold float64 `yaml:"bft_threshold"` // e.g. 0.667 - CheckpointEvery uint64 `yaml:"checkpoint_every"` - Validators []string `yaml:"validators"` // hex-encoded ED25519 public keys - Staking StakingGenesisConfig `yaml:"staking"` - // Allocations for pre-mine / dev fund (optional) - Allocations []GenesisAlloc `yaml:"allocations"` + ChainID string `yaml:"chain_id"` + Timestamp int64 `yaml:"timestamp"` // Unix seconds; 0 = use current time + InitialSupply uint64 `yaml:"initial_supply"` // total APRO (in APRO, not base units) + BlockTimeMs int64 `yaml:"block_time_ms"` + RingSize int `yaml:"ring_size"` + MinValidators int `yaml:"min_validators"` + BFTThreshold float64 `yaml:"bft_threshold"` // e.g. 0.667 + CheckpointEvery uint64 `yaml:"checkpoint_every"` + Validators []string `yaml:"validators"` // hex-encoded ED25519 public keys + Staking StakingGenesisConfig `yaml:"staking"` + // Allocations for pre-mine / dev fund (optional) + Allocations []GenesisAlloc `yaml:"allocations"` } // GenesisAlloc is a pre-mine allocation with optional vesting schedule. type GenesisAlloc struct { - Address string `yaml:"address"` // Aperod address - Amount uint64 `yaml:"amount"` // in base units (APRO × 10^8) - Label string `yaml:"label,omitempty"` // human-readable name, e.g. "Team & Advisors" - Vesting *VestingSchedule `yaml:"vesting,omitempty"` // nil means immediate unlock + Address string `yaml:"address"` // Aperod address + Amount uint64 `yaml:"amount"` // in base units (APRO × 10^8) + Label string `yaml:"label,omitempty"` // human-readable name, e.g. "Team & Advisors" + Vesting *VestingSchedule `yaml:"vesting,omitempty"` // nil means immediate unlock } // VestedAmount returns how many base units of this allocation are unlocked at // `now` (Unix seconds), given the genesis timestamp. // If Vesting is nil the entire amount is immediately available. func (a *GenesisAlloc) VestedAmount(now, genesisTime int64) uint64 { - if a.Vesting == nil { - return a.Amount - } - return a.Vesting.VestedAmount(a.Amount, genesisTime, now) + if a.Vesting == nil { + return a.Amount + } + return a.Vesting.VestedAmount(a.Amount, genesisTime, now) } // LockedAmount returns how many base units are still vesting at `now`. func (a *GenesisAlloc) LockedAmount(now, genesisTime int64) uint64 { - if a.Vesting == nil { - return 0 - } - return a.Vesting.LockedAmount(a.Amount, genesisTime, now) + if a.Vesting == nil { + return 0 + } + return a.Vesting.LockedAmount(a.Amount, genesisTime, now) } // BaseUnitsPerAPR is the number of base units in one APRO (like satoshi in Bitcoin). @@ -68,134 +69,134 @@ const BaseUnitsPerAPR = 100_000_000 // LoadGenesis reads a genesis YAML file. func LoadGenesis(path string) (*GenesisConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read genesis %s: %w", path, err) - } - var g GenesisConfig - if err := yaml.Unmarshal(data, &g); err != nil { - return nil, fmt.Errorf("parse genesis %s: %w", path, err) - } - if err := g.Validate(); err != nil { - return nil, fmt.Errorf("invalid genesis: %w", err) - } - return &g, nil + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read genesis %s: %w", path, err) + } + var g GenesisConfig + if err := yaml.Unmarshal(data, &g); err != nil { + return nil, fmt.Errorf("parse genesis %s: %w", path, err) + } + if err := g.Validate(); err != nil { + return nil, fmt.Errorf("invalid genesis: %w", err) + } + return &g, nil } // Validate checks the genesis config for required fields and sane values. func (g *GenesisConfig) Validate() error { - if g.ChainID == "" { - return fmt.Errorf("chain_id is required") - } - if g.InitialSupply == 0 { - return fmt.Errorf("initial_supply must be > 0") - } - if g.MinValidators < 1 { - return fmt.Errorf("min_validators must be >= 1") - } - if g.BFTThreshold <= 0 || g.BFTThreshold > 1 { - return fmt.Errorf("bft_threshold must be in (0, 1]") - } - if g.RingSize < 2 { - return fmt.Errorf("ring_size must be >= 2") - } - if len(g.Validators) < g.MinValidators { - return fmt.Errorf("genesis has %d validators but min_validators=%d", - len(g.Validators), g.MinValidators) - } - // Reject duplicate allocation addresses and duplicate spend public keys. - // - // Two entries sharing the same literal address string are an obvious - // duplicate. Two entries can also share the same spend public key while - // using different view keys, producing distinct address strings — these - // are equally dangerous because BuildVestingLock keys by decoded spendPub, - // so the second entry would silently overwrite the first and could replace - // a strict vesting schedule with a weaker one (or, if the second entry - // passes the immediate-skip filter, leave a stale first entry in the map). - seenAddr := make(map[string]int, len(g.Allocations)) - seenPub := make(map[crypto.Point32]int, len(g.Allocations)) - for i, alloc := range g.Allocations { - if alloc.Address == "" { - continue // placeholder — allowed - } - if prev, ok := seenAddr[alloc.Address]; ok { - return fmt.Errorf("genesis allocation[%d] and allocation[%d] share the same address %q — "+ - "duplicate allocation addresses are not permitted (would bypass vesting enforcement)", - prev, i, alloc.Address) - } - seenAddr[alloc.Address] = i - _, spendPub, _, err := crypto.DecodeAddress(crypto.Address(alloc.Address)) - if err != nil { - // Invalid addresses are caught at block-creation time; skip here. - continue - } - if prev, ok := seenPub[spendPub]; ok { - return fmt.Errorf("genesis allocation[%d] and allocation[%d] share the same spend public key %x — "+ - "two addresses with identical spend keys but different view keys are not permitted "+ - "(would bypass vesting enforcement)", - prev, i, spendPub[:8]) - } - seenPub[spendPub] = i - } - return nil + if g.ChainID == "" { + return fmt.Errorf("chain_id is required") + } + if g.InitialSupply == 0 { + return fmt.Errorf("initial_supply must be > 0") + } + if g.MinValidators < 1 { + return fmt.Errorf("min_validators must be >= 1") + } + if g.BFTThreshold <= 0 || g.BFTThreshold > 1 { + return fmt.Errorf("bft_threshold must be in (0, 1]") + } + if g.RingSize < 2 { + return fmt.Errorf("ring_size must be >= 2") + } + if len(g.Validators) < g.MinValidators { + return fmt.Errorf("genesis has %d validators but min_validators=%d", + len(g.Validators), g.MinValidators) + } + // Reject duplicate allocation addresses and duplicate spend public keys. + // + // Two entries sharing the same literal address string are an obvious + // duplicate. Two entries can also share the same spend public key while + // using different view keys, producing distinct address strings — these + // are equally dangerous because BuildVestingLock keys by decoded spendPub, + // so the second entry would silently overwrite the first and could replace + // a strict vesting schedule with a weaker one (or, if the second entry + // passes the immediate-skip filter, leave a stale first entry in the map). + seenAddr := make(map[string]int, len(g.Allocations)) + seenPub := make(map[crypto.Point32]int, len(g.Allocations)) + for i, alloc := range g.Allocations { + if alloc.Address == "" { + continue // placeholder — allowed + } + if prev, ok := seenAddr[alloc.Address]; ok { + return fmt.Errorf("genesis allocation[%d] and allocation[%d] share the same address %q — "+ + "duplicate allocation addresses are not permitted (would bypass vesting enforcement)", + prev, i, alloc.Address) + } + seenAddr[alloc.Address] = i + _, spendPub, _, err := crypto.DecodeAddress(crypto.Address(alloc.Address)) + if err != nil { + // Invalid addresses are caught at block-creation time; skip here. + continue + } + if prev, ok := seenPub[spendPub]; ok { + return fmt.Errorf("genesis allocation[%d] and allocation[%d] share the same spend public key %x — "+ + "two addresses with identical spend keys but different view keys are not permitted "+ + "(would bypass vesting enforcement)", + prev, i, spendPub[:8]) + } + seenPub[spendPub] = i + } + return nil } // CreateGenesisBlock builds the deterministic genesis block from configuration. // The genesis block has height 0, zero PrevHash, and no real transactions. func CreateGenesisBlock(g *GenesisConfig, validatorPriv crypto.ValidatorPrivKey) (*Block, error) { - ts := g.Timestamp - if ts == 0 { - ts = time.Now().UTC().Unix() - } - - // Build coinbase-like allocations as outputs (simplified: no ring signatures). - // Each allocation with a valid, non-empty address becomes a genesis output. - // Entries with an empty address are silently skipped (placeholder config). - var txs []Transaction - for _, alloc := range g.Allocations { - if alloc.Address == "" { - continue // placeholder — skip until a real address is configured - } - _, spendPub, _, decErr := crypto.DecodeAddress(crypto.Address(alloc.Address)) - if decErr != nil { - return nil, fmt.Errorf("genesis: invalid allocation address %q: %w", alloc.Address, decErr) - } - blind, err := crypto.NewBlindFactor() - if err != nil { - return nil, err - } - commit, err := crypto.Commit(alloc.Amount, blind) - if err != nil { - return nil, err - } - txs = append(txs, Transaction{ - Version: TxVersionBase, - Outputs: []Output{{ - OneTimePub: spendPub, - AmountCommit: commit, - }}, - }) - } - - root := MerkleRoot(txs) - - header := BlockHeader{ - Height: 0, - PrevHash: crypto.Hash32{}, // all zeros - MerkleRoot: root, - Timestamp: ts * 1e9, // convert to nanoseconds - Round: 0, - ValidatorPub: validatorPriv.Public(), - } - if err := header.Sign(validatorPriv); err != nil { - return nil, fmt.Errorf("sign genesis: %w", err) - } - - return &Block{Header: header, Txs: txs}, nil + ts := g.Timestamp + if ts == 0 { + ts = time.Now().UTC().Unix() + } + + // Build coinbase-like allocations as outputs (simplified: no ring signatures). + // Each allocation with a valid, non-empty address becomes a genesis output. + // Entries with an empty address are silently skipped (placeholder config). + var txs []Transaction + for _, alloc := range g.Allocations { + if alloc.Address == "" { + continue // placeholder — skip until a real address is configured + } + _, spendPub, _, decErr := crypto.DecodeAddress(crypto.Address(alloc.Address)) + if decErr != nil { + return nil, fmt.Errorf("genesis: invalid allocation address %q: %w", alloc.Address, decErr) + } + blind, err := crypto.NewBlindFactor() + if err != nil { + return nil, err + } + commit, err := crypto.Commit(alloc.Amount, blind) + if err != nil { + return nil, err + } + txs = append(txs, Transaction{ + Version: TxVersionBase, + Outputs: []Output{{ + OneTimePub: spendPub, + AmountCommit: commit, + }}, + }) + } + + root := MerkleRoot(txs) + + header := BlockHeader{ + Height: 0, + PrevHash: crypto.Hash32{}, // all zeros + MerkleRoot: root, + Timestamp: ts * 1e9, // convert to nanoseconds + Round: 0, + ValidatorPub: validatorPriv.Public(), + } + if err := header.Sign(validatorPriv); err != nil { + return nil, fmt.Errorf("sign genesis: %w", err) + } + + return &Block{Header: header, Txs: txs}, nil } // GenesisHash returns the deterministic chain ID hash derived from the genesis config. // Used to prevent cross-chain replay attacks. func GenesisHash(g *GenesisConfig) crypto.Hash32 { - return crypto.HashStr("aperod/" + g.ChainID + "/genesis/v1") + return crypto.HashStr("aperod/" + g.ChainID + "/genesis/v1") } diff --git a/core/integration_test.go b/core/integration_test.go index 6dbeabb..cb287f8 100644 --- a/core/integration_test.go +++ b/core/integration_test.go @@ -6,11 +6,11 @@ package core_test // 1.9.8 — node falls behind then re-syncs via Reorg import ( - "testing" - "time" + "testing" + "time" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // TestIntegration_CoinbaseToMempool verifies the full lifecycle: @@ -18,224 +18,224 @@ import ( // 2. WalletScanner detects Alice's incoming UTXO // 3. New block is built and applied → UTXO set updated func TestIntegration_CoinbaseToMempool(t *testing.T) { - // ── Actors ──────────────────────────────────────────────────────────────── - aliceKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("generate Alice keys: %v", err) - } - aliceAddr := crypto.AddressFromKeys(crypto.TestnetByte, aliceKeys) - - _ = aliceAddr // used for scan check - - validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() - - // ── Block 0: coinbase + stealth output to Alice ─────────────────────────── - // CoinbaseTx uses the spend pub key directly (no ECDH), so for wallet - // scanner detection we also add a stealth-addressed output in a second tx. - reward := uint64(50_000_000) // 0.5 APRO - cb := core.CoinbaseTx(aliceKeys.Spend.Public, reward) - - // Build a stealth output tx so Alice can detect it with ScanBlock. - stealthOut, err := crypto.CreateStealthOutput(aliceKeys.Spend.Public, aliceKeys.View.Public) - if err != nil { - t.Fatalf("CreateStealthOutput: %v", err) - } - bf, _ := crypto.NewBlindFactor() - commit, _ := crypto.Commit(1_000_000, bf) - stealthTx := core.Transaction{ - Version: core.TxVersionBase, - Outputs: []core.Output{{ - OneTimePub: stealthOut.OneTimePub, - TxPubKey: stealthOut.TxPubKey, - AmountCommit: commit, - }}, - } - - txs0 := []core.Transaction{cb, stealthTx} - hdr0 := core.BlockHeader{ - Height: 0, - Timestamp: time.Now().UnixNano(), - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(txs0), - } - if err := hdr0.Sign(validatorPriv); err != nil { - t.Fatalf("sign block 0: %v", err) - } - block0 := &core.Block{Header: hdr0, Txs: txs0} - - // ── Chain + UTXO set ────────────────────────────────────────────────────── - chain := core.NewChain() - if err := chain.SetGenesis(block0); err != nil { - t.Fatalf("SetGenesis: %v", err) - } - - utxos := core.NewUTXOSet() - if err := utxos.ApplyBlock(block0); err != nil { - t.Fatalf("ApplyBlock(block0): %v", err) - } - - // block0 has 2 txs (coinbase + stealthTx) each with 1 output → 2 UTXOs - if utxos.Count() != 2 { - t.Errorf("UTXO count after block0 = %d, want 2 (coinbase + stealth)", utxos.Count()) - } - - // ── Mempool setup ───────────────────────────────────────────────────────── - mp := core.NewMempool(core.DefaultMempoolConfig()) - - // ── Wallet scanner: Alice detects her incoming stealth UTXO ────────────── - scanner := core.NewWalletScanner( - aliceKeys.View.Private, - aliceKeys.Spend.Public, - aliceKeys.View.Public, - crypto.TestnetByte, - ) - owned := scanner.ScanBlock(block0) - if len(owned) == 0 { - t.Fatal("WalletScanner: Alice did not detect her stealth output") - } - t.Logf("Alice owns %d stealth UTXOs", len(owned)) - - // ── Block 1: empty (no tx from Alice — ring size needs decoys we don't have) - // Instead we verify that a block can be added and UTXO count stays consistent. - hdr1 := core.BlockHeader{ - Height: 1, - PrevHash: block0.Hash(), - Timestamp: time.Now().UnixNano(), - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - if err := hdr1.Sign(validatorPriv); err != nil { - t.Fatalf("sign block 1: %v", err) - } - block1 := &core.Block{Header: hdr1} - if err := chain.AddBlock(block1); err != nil { - t.Fatalf("AddBlock(block1): %v", err) - } - if err := utxos.ApplyBlock(block1); err != nil { - t.Fatalf("ApplyBlock(block1): %v", err) - } - - if chain.Height() != 1 { - t.Errorf("chain height = %d, want 1", chain.Height()) - } - // Still 2 UTXOs — block1 has no txs - if utxos.Count() != 2 { - t.Errorf("UTXO count after block1 = %d, want 2 (unchanged)", utxos.Count()) - } - - // ── Mempool eviction test ────────────────────────────────────────────────── - n := mp.Evict() - t.Logf("Mempool.Evict removed %d expired entries (expected 0)", n) + // ── Actors ──────────────────────────────────────────────────────────────── + aliceKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("generate Alice keys: %v", err) + } + aliceAddr := crypto.AddressFromKeys(crypto.TestnetByte, aliceKeys) + + _ = aliceAddr // used for scan check + + validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() + + // ── Block 0: coinbase + stealth output to Alice ─────────────────────────── + // CoinbaseTx uses the spend pub key directly (no ECDH), so for wallet + // scanner detection we also add a stealth-addressed output in a second tx. + reward := uint64(50_000_000) // 0.5 APRO + cb := core.CoinbaseTx(aliceKeys.Spend.Public, reward) + + // Build a stealth output tx so Alice can detect it with ScanBlock. + stealthOut, err := crypto.CreateStealthOutput(aliceKeys.Spend.Public, aliceKeys.View.Public) + if err != nil { + t.Fatalf("CreateStealthOutput: %v", err) + } + bf, _ := crypto.NewBlindFactor() + commit, _ := crypto.Commit(1_000_000, bf) + stealthTx := core.Transaction{ + Version: core.TxVersionBase, + Outputs: []core.Output{{ + OneTimePub: stealthOut.OneTimePub, + TxPubKey: stealthOut.TxPubKey, + AmountCommit: commit, + }}, + } + + txs0 := []core.Transaction{cb, stealthTx} + hdr0 := core.BlockHeader{ + Height: 0, + Timestamp: time.Now().UnixNano(), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(txs0), + } + if err := hdr0.Sign(validatorPriv); err != nil { + t.Fatalf("sign block 0: %v", err) + } + block0 := &core.Block{Header: hdr0, Txs: txs0} + + // ── Chain + UTXO set ────────────────────────────────────────────────────── + chain := core.NewChain() + if err := chain.SetGenesis(block0); err != nil { + t.Fatalf("SetGenesis: %v", err) + } + + utxos := core.NewUTXOSet() + if err := utxos.ApplyBlock(block0); err != nil { + t.Fatalf("ApplyBlock(block0): %v", err) + } + + // block0 has 2 txs (coinbase + stealthTx) each with 1 output → 2 UTXOs + if utxos.Count() != 2 { + t.Errorf("UTXO count after block0 = %d, want 2 (coinbase + stealth)", utxos.Count()) + } + + // ── Mempool setup ───────────────────────────────────────────────────────── + mp := core.NewMempool(core.DefaultMempoolConfig()) + + // ── Wallet scanner: Alice detects her incoming stealth UTXO ────────────── + scanner := core.NewWalletScanner( + aliceKeys.View.Private, + aliceKeys.Spend.Public, + aliceKeys.View.Public, + crypto.TestnetByte, + ) + owned := scanner.ScanBlock(block0) + if len(owned) == 0 { + t.Fatal("WalletScanner: Alice did not detect her stealth output") + } + t.Logf("Alice owns %d stealth UTXOs", len(owned)) + + // ── Block 1: empty (no tx from Alice — ring size needs decoys we don't have) + // Instead we verify that a block can be added and UTXO count stays consistent. + hdr1 := core.BlockHeader{ + Height: 1, + PrevHash: block0.Hash(), + Timestamp: time.Now().UnixNano(), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + if err := hdr1.Sign(validatorPriv); err != nil { + t.Fatalf("sign block 1: %v", err) + } + block1 := &core.Block{Header: hdr1} + if err := chain.AddBlock(block1); err != nil { + t.Fatalf("AddBlock(block1): %v", err) + } + if err := utxos.ApplyBlock(block1); err != nil { + t.Fatalf("ApplyBlock(block1): %v", err) + } + + if chain.Height() != 1 { + t.Errorf("chain height = %d, want 1", chain.Height()) + } + // Still 2 UTXOs — block1 has no txs + if utxos.Count() != 2 { + t.Errorf("UTXO count after block1 = %d, want 2 (unchanged)", utxos.Count()) + } + + // ── Mempool eviction test ────────────────────────────────────────────────── + n := mp.Evict() + t.Logf("Mempool.Evict removed %d expired entries (expected 0)", n) } // TestIntegration_ChainReorg verifies that a 2-block reorg replaces the tip. func TestIntegration_ChainReorg(t *testing.T) { - validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() - - // Genesis - hdr0 := core.BlockHeader{ - Height: 0, - Timestamp: time.Now().UnixNano(), - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdr0.Sign(validatorPriv) - genesis := &core.Block{Header: hdr0} - - chain := core.NewChain() - _ = chain.SetGenesis(genesis) - - // Fork A: genesis → A1 → A2 (tip) - hdrA1 := core.BlockHeader{ - Height: 1, PrevHash: genesis.Hash(), - Timestamp: time.Now().UnixNano(), - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdrA1.Sign(validatorPriv) - blockA1 := &core.Block{Header: hdrA1} - _ = chain.AddBlock(blockA1) - - hdrA2 := core.BlockHeader{ - Height: 2, PrevHash: blockA1.Hash(), - Timestamp: time.Now().UnixNano(), - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdrA2.Sign(validatorPriv) - blockA2 := &core.Block{Header: hdrA2} - _ = chain.AddBlock(blockA2) - - if chain.Height() != 2 { - t.Errorf("chain height after A1+A2 = %d, want 2", chain.Height()) - } - - // Fork B from genesis: B1 → B2 → B3 (longer) - hdrB1 := core.BlockHeader{ - Height: 1, PrevHash: genesis.Hash(), - Timestamp: time.Now().UnixNano() + 1, - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdrB1.Sign(validatorPriv) - blockB1 := &core.Block{Header: hdrB1} - - hdrB2 := core.BlockHeader{ - Height: 2, PrevHash: blockB1.Hash(), - Timestamp: time.Now().UnixNano() + 2, - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdrB2.Sign(validatorPriv) - blockB2 := &core.Block{Header: hdrB2} - - hdrB3 := core.BlockHeader{ - Height: 3, PrevHash: blockB2.Hash(), - Timestamp: time.Now().UnixNano() + 3, - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - _ = hdrB3.Sign(validatorPriv) - blockB3 := &core.Block{Header: hdrB3} - - // Trigger reorg: B fork is longer (fork point = genesis at height 0) - _ = chain.Reorg(0, []*core.Block{blockB1, blockB2, blockB3}) - - if chain.Height() != 3 { - t.Errorf("chain height after reorg = %d, want 3", chain.Height()) - } + validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() + + // Genesis + hdr0 := core.BlockHeader{ + Height: 0, + Timestamp: time.Now().UnixNano(), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdr0.Sign(validatorPriv) + genesis := &core.Block{Header: hdr0} + + chain := core.NewChain() + _ = chain.SetGenesis(genesis) + + // Fork A: genesis → A1 → A2 (tip) + hdrA1 := core.BlockHeader{ + Height: 1, PrevHash: genesis.Hash(), + Timestamp: time.Now().UnixNano(), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdrA1.Sign(validatorPriv) + blockA1 := &core.Block{Header: hdrA1} + _ = chain.AddBlock(blockA1) + + hdrA2 := core.BlockHeader{ + Height: 2, PrevHash: blockA1.Hash(), + Timestamp: time.Now().UnixNano(), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdrA2.Sign(validatorPriv) + blockA2 := &core.Block{Header: hdrA2} + _ = chain.AddBlock(blockA2) + + if chain.Height() != 2 { + t.Errorf("chain height after A1+A2 = %d, want 2", chain.Height()) + } + + // Fork B from genesis: B1 → B2 → B3 (longer) + hdrB1 := core.BlockHeader{ + Height: 1, PrevHash: genesis.Hash(), + Timestamp: time.Now().UnixNano() + 1, + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdrB1.Sign(validatorPriv) + blockB1 := &core.Block{Header: hdrB1} + + hdrB2 := core.BlockHeader{ + Height: 2, PrevHash: blockB1.Hash(), + Timestamp: time.Now().UnixNano() + 2, + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdrB2.Sign(validatorPriv) + blockB2 := &core.Block{Header: hdrB2} + + hdrB3 := core.BlockHeader{ + Height: 3, PrevHash: blockB2.Hash(), + Timestamp: time.Now().UnixNano() + 3, + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + _ = hdrB3.Sign(validatorPriv) + blockB3 := &core.Block{Header: hdrB3} + + // Trigger reorg: B fork is longer (fork point = genesis at height 0) + _ = chain.Reorg(0, []*core.Block{blockB1, blockB2, blockB3}) + + if chain.Height() != 3 { + t.Errorf("chain height after reorg = %d, want 3", chain.Height()) + } } // TestIntegration_TxVerifier_Coinbase exercises the full VerifyBlock path. func TestIntegration_TxVerifier_CoinbaseBlock(t *testing.T) { - validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() - aliceKeys, _ := crypto.GenerateWalletKeys() - - // Build a block with a coinbase tx - cb := core.CoinbaseTx(aliceKeys.Spend.Public, 100_000_000) - txs := []core.Transaction{cb} - - hdr := core.BlockHeader{ - Height: 0, - Timestamp: time.Now().UnixNano(), - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(txs), - } - _ = hdr.Sign(validatorPriv) - block := &core.Block{Header: hdr, Txs: txs} - - // VerifyBlock should accept a block with only a coinbase - utxos := core.NewUTXOSet() - v := core.NewTxVerifier(utxos) - if err := v.VerifyBlock(block); err != nil { - t.Errorf("VerifyBlock coinbase block: %v", err) - } - - // Apply and verify UTXO count - _ = utxos.ApplyBlock(block) - if utxos.Count() != 1 { - t.Errorf("UTXO count = %d, want 1", utxos.Count()) - } + validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() + aliceKeys, _ := crypto.GenerateWalletKeys() + + // Build a block with a coinbase tx + cb := core.CoinbaseTx(aliceKeys.Spend.Public, 100_000_000) + txs := []core.Transaction{cb} + + hdr := core.BlockHeader{ + Height: 0, + Timestamp: time.Now().UnixNano(), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(txs), + } + _ = hdr.Sign(validatorPriv) + block := &core.Block{Header: hdr, Txs: txs} + + // VerifyBlock should accept a block with only a coinbase + utxos := core.NewUTXOSet() + v := core.NewTxVerifier(utxos) + if err := v.VerifyBlock(block); err != nil { + t.Errorf("VerifyBlock coinbase block: %v", err) + } + + // Apply and verify UTXO count + _ = utxos.ApplyBlock(block) + if utxos.Count() != 1 { + t.Errorf("UTXO count = %d, want 1", utxos.Count()) + } } // ─── 1.9.7: 10-block chain build + UTXO consistency ───────────────────────── @@ -244,123 +244,123 @@ func TestIntegration_TxVerifier_CoinbaseBlock(t *testing.T) { // and verifies chain height, UTXO state, and block hash linkage at every step. // This exercises the full "happy-path sync" flow without networking. func TestIntegration_TenBlockChain(t *testing.T) { - validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatalf("GenerateValidatorKey: %v", err) - } - aliceKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("GenerateWalletKeys: %v", err) - } - - chain := core.NewChain() - utxos := core.NewUTXOSet() - scanner := core.NewWalletScanner( - aliceKeys.View.Private, - aliceKeys.Spend.Public, - aliceKeys.View.Public, - crypto.TestnetByte, - ) - - const numBlocks = 10 - blocks := make([]*core.Block, numBlocks) - - for i := 0; i < numBlocks; i++ { - // Coinbase reward - cb := core.CoinbaseTx(aliceKeys.Spend.Public, uint64(50_000_000*(i+1))) - - // Stealth-addressed output so WalletScanner can detect Alice's funds - stealthOut, err := crypto.CreateStealthOutput(aliceKeys.Spend.Public, aliceKeys.View.Public) - if err != nil { - t.Fatalf("CreateStealthOutput block %d: %v", i, err) - } - bf, _ := crypto.NewBlindFactor() - commit, _ := crypto.Commit(uint64(1_000_000*(i+1)), bf) - stealthTx := core.Transaction{ - Version: core.TxVersionBase, - Outputs: []core.Output{{ - OneTimePub: stealthOut.OneTimePub, - TxPubKey: stealthOut.TxPubKey, - AmountCommit: commit, - }}, - } - - txs := []core.Transaction{cb, stealthTx} - - var prevHash crypto.Hash32 - if i == 0 { - prevHash = crypto.Hash32{} - } else { - prevHash = blocks[i-1].Hash() - } - - hdr := core.BlockHeader{ - Height: uint64(i), - PrevHash: prevHash, - Timestamp: time.Now().UnixNano() + int64(i), - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(txs), - } - if err := hdr.Sign(validatorPriv); err != nil { - t.Fatalf("sign block %d: %v", i, err) - } - blocks[i] = &core.Block{Header: hdr, Txs: txs} - - // Apply to chain - if i == 0 { - if err := chain.SetGenesis(blocks[0]); err != nil { - t.Fatalf("SetGenesis: %v", err) - } - } else { - if err := chain.AddBlock(blocks[i]); err != nil { - t.Fatalf("AddBlock height %d: %v", i, err) - } - } - if err := utxos.ApplyBlock(blocks[i]); err != nil { - t.Fatalf("ApplyBlock height %d: %v", i, err) - } - - // Height must match - if chain.Height() != uint64(i) { - t.Errorf("height after block %d = %d, want %d", i, chain.Height(), i) - } - // Two new UTXOs per block (coinbase + stealth output) - if utxos.Count() != (i+1)*2 { - t.Errorf("UTXO count after block %d = %d, want %d", i, utxos.Count(), (i+1)*2) - } - } - - // WalletScanner must detect Alice's output in every block - totalOwned := 0 - for _, b := range blocks { - owned := scanner.ScanBlock(b) - totalOwned += len(owned) - } - if totalOwned != numBlocks { - t.Errorf("WalletScanner found %d owned outputs across %d blocks, want %d", - totalOwned, numBlocks, numBlocks) - } - - // Verify tip is block 9 - tip := chain.Tip() - if tip == nil { - t.Fatal("chain.Tip() returned nil after 10 blocks") - } - if tip.Header.Height != numBlocks-1 { - t.Errorf("tip height = %d, want %d", tip.Header.Height, numBlocks-1) - } - - // Verify block linkage (each block's PrevHash matches the previous block's hash) - for i := 1; i < numBlocks; i++ { - want := blocks[i-1].Hash() - got := blocks[i].Header.PrevHash - if got != want { - t.Errorf("block %d: PrevHash mismatch", i) - } - } - - t.Logf("1.9.7 ✓ 10-block chain: height=%d UTXOs=%d owned=%d", - chain.Height(), utxos.Count(), totalOwned) + validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatalf("GenerateValidatorKey: %v", err) + } + aliceKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + + chain := core.NewChain() + utxos := core.NewUTXOSet() + scanner := core.NewWalletScanner( + aliceKeys.View.Private, + aliceKeys.Spend.Public, + aliceKeys.View.Public, + crypto.TestnetByte, + ) + + const numBlocks = 10 + blocks := make([]*core.Block, numBlocks) + + for i := 0; i < numBlocks; i++ { + // Coinbase reward + cb := core.CoinbaseTx(aliceKeys.Spend.Public, uint64(50_000_000*(i+1))) + + // Stealth-addressed output so WalletScanner can detect Alice's funds + stealthOut, err := crypto.CreateStealthOutput(aliceKeys.Spend.Public, aliceKeys.View.Public) + if err != nil { + t.Fatalf("CreateStealthOutput block %d: %v", i, err) + } + bf, _ := crypto.NewBlindFactor() + commit, _ := crypto.Commit(uint64(1_000_000*(i+1)), bf) + stealthTx := core.Transaction{ + Version: core.TxVersionBase, + Outputs: []core.Output{{ + OneTimePub: stealthOut.OneTimePub, + TxPubKey: stealthOut.TxPubKey, + AmountCommit: commit, + }}, + } + + txs := []core.Transaction{cb, stealthTx} + + var prevHash crypto.Hash32 + if i == 0 { + prevHash = crypto.Hash32{} + } else { + prevHash = blocks[i-1].Hash() + } + + hdr := core.BlockHeader{ + Height: uint64(i), + PrevHash: prevHash, + Timestamp: time.Now().UnixNano() + int64(i), + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(txs), + } + if err := hdr.Sign(validatorPriv); err != nil { + t.Fatalf("sign block %d: %v", i, err) + } + blocks[i] = &core.Block{Header: hdr, Txs: txs} + + // Apply to chain + if i == 0 { + if err := chain.SetGenesis(blocks[0]); err != nil { + t.Fatalf("SetGenesis: %v", err) + } + } else { + if err := chain.AddBlock(blocks[i]); err != nil { + t.Fatalf("AddBlock height %d: %v", i, err) + } + } + if err := utxos.ApplyBlock(blocks[i]); err != nil { + t.Fatalf("ApplyBlock height %d: %v", i, err) + } + + // Height must match + if chain.Height() != uint64(i) { + t.Errorf("height after block %d = %d, want %d", i, chain.Height(), i) + } + // Two new UTXOs per block (coinbase + stealth output) + if utxos.Count() != (i+1)*2 { + t.Errorf("UTXO count after block %d = %d, want %d", i, utxos.Count(), (i+1)*2) + } + } + + // WalletScanner must detect Alice's output in every block + totalOwned := 0 + for _, b := range blocks { + owned := scanner.ScanBlock(b) + totalOwned += len(owned) + } + if totalOwned != numBlocks { + t.Errorf("WalletScanner found %d owned outputs across %d blocks, want %d", + totalOwned, numBlocks, numBlocks) + } + + // Verify tip is block 9 + tip := chain.Tip() + if tip == nil { + t.Fatal("chain.Tip() returned nil after 10 blocks") + } + if tip.Header.Height != numBlocks-1 { + t.Errorf("tip height = %d, want %d", tip.Header.Height, numBlocks-1) + } + + // Verify block linkage (each block's PrevHash matches the previous block's hash) + for i := 1; i < numBlocks; i++ { + want := blocks[i-1].Hash() + got := blocks[i].Header.PrevHash + if got != want { + t.Errorf("block %d: PrevHash mismatch", i) + } + } + + t.Logf("1.9.7 ✓ 10-block chain: height=%d UTXOs=%d owned=%d", + chain.Height(), utxos.Count(), totalOwned) } // ─── 1.9.8: node falls behind then re-syncs via Reorg ──────────────────────── @@ -372,67 +372,67 @@ func TestIntegration_TenBlockChain(t *testing.T) { // // Verifies that after rejoin the node's tip matches the canonical chain. func TestIntegration_NodeRejoin(t *testing.T) { - validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatalf("GenerateValidatorKey: %v", err) - } - - buildBlock := func(height uint64, prevHash crypto.Hash32, extra int64) *core.Block { - hdr := core.BlockHeader{ - Height: height, - PrevHash: prevHash, - Timestamp: time.Now().UnixNano() + extra, - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(nil), - } - if err := hdr.Sign(validatorPriv); err != nil { - t.Fatalf("sign block height=%d: %v", height, err) - } - return &core.Block{Header: hdr} - } - - // ── Slow node: builds chain A (5 blocks: 0-4) ────────────────────────── - slowChain := core.NewChain() - - chainA := make([]*core.Block, 5) - chainA[0] = buildBlock(0, crypto.Hash32{}, 0) - if err := slowChain.SetGenesis(chainA[0]); err != nil { - t.Fatalf("SetGenesis A[0]: %v", err) - } - for i := 1; i < 5; i++ { - chainA[i] = buildBlock(uint64(i), chainA[i-1].Hash(), int64(i)) - if err := slowChain.AddBlock(chainA[i]); err != nil { - t.Fatalf("AddBlock A[%d]: %v", i, err) - } - } - if slowChain.Height() != 4 { - t.Fatalf("slow chain height = %d, want 4", slowChain.Height()) - } - - // ── Network builds chain B from genesis (8 blocks: 0-7) ──────────────── - // Fork point is height 0 (genesis) — completely different chain B. - chainB := make([]*core.Block, 8) - chainB[0] = buildBlock(0, crypto.Hash32{}, 1000) // different timestamp → different hash - for i := 1; i < 8; i++ { - chainB[i] = buildBlock(uint64(i), chainB[i-1].Hash(), int64(1000+i)) - } - - // ── Re-join: Reorg at fork point height 0, adopt chain B blocks 1-7 ─── - if err := slowChain.Reorg(0, chainB[1:]); err != nil { - t.Fatalf("Reorg: %v", err) - } - - if slowChain.Height() != 7 { - t.Errorf("height after rejoin = %d, want 7", slowChain.Height()) - } - - tip := slowChain.Tip() - if tip == nil { - t.Fatal("Tip() nil after rejoin") - } - if tip.Header.Height != 7 { - t.Errorf("tip height = %d, want 7", tip.Header.Height) - } - - t.Logf("1.9.8 ✓ node rejoin: adopted chain B, new tip height=%d", slowChain.Height()) + validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatalf("GenerateValidatorKey: %v", err) + } + + buildBlock := func(height uint64, prevHash crypto.Hash32, extra int64) *core.Block { + hdr := core.BlockHeader{ + Height: height, + PrevHash: prevHash, + Timestamp: time.Now().UnixNano() + extra, + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(nil), + } + if err := hdr.Sign(validatorPriv); err != nil { + t.Fatalf("sign block height=%d: %v", height, err) + } + return &core.Block{Header: hdr} + } + + // ── Slow node: builds chain A (5 blocks: 0-4) ────────────────────────── + slowChain := core.NewChain() + + chainA := make([]*core.Block, 5) + chainA[0] = buildBlock(0, crypto.Hash32{}, 0) + if err := slowChain.SetGenesis(chainA[0]); err != nil { + t.Fatalf("SetGenesis A[0]: %v", err) + } + for i := 1; i < 5; i++ { + chainA[i] = buildBlock(uint64(i), chainA[i-1].Hash(), int64(i)) + if err := slowChain.AddBlock(chainA[i]); err != nil { + t.Fatalf("AddBlock A[%d]: %v", i, err) + } + } + if slowChain.Height() != 4 { + t.Fatalf("slow chain height = %d, want 4", slowChain.Height()) + } + + // ── Network builds chain B from genesis (8 blocks: 0-7) ──────────────── + // Fork point is height 0 (genesis) — completely different chain B. + chainB := make([]*core.Block, 8) + chainB[0] = buildBlock(0, crypto.Hash32{}, 1000) // different timestamp → different hash + for i := 1; i < 8; i++ { + chainB[i] = buildBlock(uint64(i), chainB[i-1].Hash(), int64(1000+i)) + } + + // ── Re-join: Reorg at fork point height 0, adopt chain B blocks 1-7 ─── + if err := slowChain.Reorg(0, chainB[1:]); err != nil { + t.Fatalf("Reorg: %v", err) + } + + if slowChain.Height() != 7 { + t.Errorf("height after rejoin = %d, want 7", slowChain.Height()) + } + + tip := slowChain.Tip() + if tip == nil { + t.Fatal("Tip() nil after rejoin") + } + if tip.Header.Height != 7 { + t.Errorf("tip height = %d, want 7", tip.Header.Height) + } + + t.Logf("1.9.8 ✓ node rejoin: adopted chain B, new tip height=%d", slowChain.Height()) } diff --git a/core/legacy_commitment_copy_test.go b/core/legacy_commitment_copy_test.go index 81fd795..1b10664 100644 --- a/core/legacy_commitment_copy_test.go +++ b/core/legacy_commitment_copy_test.go @@ -19,7 +19,7 @@ import ( // policy must reject this legacy transaction at and after v4 activation. func TestLegacyCommitmentCopyTheftIsBlockedAtActivation(t *testing.T) { const ( - amount = uint64(1_000_000) + amount = uint64(1_000_000) activation = uint64(100) ) @@ -123,4 +123,4 @@ func TestLegacyCommitmentCopyTheftIsBlockedAtActivation(t *testing.T) { if utxos.GetByPubKey(victim.Spend.Public) != nil { t.Fatal("victim UTXO remained active; reproduction did not exercise copied-commit removal") } -} \ No newline at end of file +} diff --git a/core/lpod_position.go b/core/lpod_position.go index cb70393..3e5e81d 100644 --- a/core/lpod_position.go +++ b/core/lpod_position.go @@ -11,6 +11,7 @@ import ( "reflect" "filippo.io/edwards25519" + "github.com/aperod/aperod/crypto" ) diff --git a/core/mempool.go b/core/mempool.go index 9b56afe..acf9817 100644 --- a/core/mempool.go +++ b/core/mempool.go @@ -198,8 +198,8 @@ func (m *Mempool) Add(tx Transaction) error { return fmt.Errorf("mempool: coinbase (zero-input) transactions are not accepted from external sources") } -nextHeight := m.nextBlockHeight() -if m.cfg.CurrentHeight != nil { + nextHeight := m.nextBlockHeight() + if m.cfg.CurrentHeight != nil { if err := ValidateTxVersionAtHeight( &tx, nextHeight, @@ -218,7 +218,7 @@ if m.cfg.CurrentHeight != nil { // C-0/C-1: prevents supply inflation via forged AmountCommit or unbound stake amount. // Stake txs are included — they carry ring inputs whose proofs must be valid. if m.cfg.Verifier != nil { -if err := m.cfg.Verifier.VerifyTxAtHeight(&tx, nextHeight); err != nil { + if err := m.cfg.Verifier.VerifyTxAtHeight(&tx, nextHeight); err != nil { return fmt.Errorf("mempool: crypto verification failed: %w", err) } } @@ -514,14 +514,14 @@ func (m *Mempool) AddPrivileged(tx Transaction) error { if err := m.validateAVMNonce(&tx); err != nil { return err } -nextHeight := m.nextBlockHeight() + nextHeight := m.nextBlockHeight() // C1 fix: run full cryptographic verification even for privileged transactions, // EXCEPT for coinbase (zero-input) transactions. VerifyTx explicitly rejects // coinbase txs to prevent external inflation attacks; engine-synthesized mints // are trusted by construction and must not be re-verified through that path. // Any non-coinbase tx routed through AddPrivileged is still fully verified. if m.cfg.Verifier != nil && !tx.IsCoinbase() { -if err := m.cfg.Verifier.VerifyTxAtHeight(&tx, nextHeight); err != nil { + if err := m.cfg.Verifier.VerifyTxAtHeight(&tx, nextHeight); err != nil { return fmt.Errorf("mempool: crypto verification failed (privileged): %w", err) } } @@ -582,7 +582,7 @@ func (m *Mempool) IsPrivileged(hash crypto.Hash32) bool { } // SelectTxs returns up to n transactions ordered by fee rate nAPRO/byte (highest first). -// Sorting by fee/size selects transactions that pay the best rate, maximising +// Sorting by fee/size selects transactions that pay the best rate, maximizing // validator tip income and ensuring high-priority txs are included first. // Used by the block proposer to fill a block. func (m *Mempool) SelectTxs(n int) []Transaction { @@ -945,7 +945,7 @@ func (m *Mempool) Load(dataDir string, log *slog.Logger) int { // remain but caps are still exceeded. // // Using fee-rate (fee/byte) rather than absolute fee means a large low-priority -// tx is evicted before a tiny one — maximising freed bytes per eviction step. +// tx is evicted before a tiny one — maximizing freed bytes per eviction step. func (m *Mempool) evictLowestFeeRate() bool { var cheapest *mempoolEntry var cheapestRate uint64 diff --git a/core/merkle.go b/core/merkle.go index 7f498e4..d9a7e41 100644 --- a/core/merkle.go +++ b/core/merkle.go @@ -1,7 +1,7 @@ package core import ( - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // emptyMerkleRoot is the canonical Merkle root for a block that contains no @@ -15,98 +15,98 @@ var emptyMerkleRoot = crypto.HashBytes([]byte("aperod/merkle-empty/v1")) // well-known non-zero constant). If the list is odd, the last element is // duplicated. func MerkleRoot(txs []Transaction) crypto.Hash32 { - if len(txs) == 0 { - return emptyMerkleRoot - } + if len(txs) == 0 { + return emptyMerkleRoot + } - // Build leaf hashes - hashes := make([]crypto.Hash32, len(txs)) - for i, tx := range txs { - hashes[i] = tx.Hash() - } + // Build leaf hashes + hashes := make([]crypto.Hash32, len(txs)) + for i, tx := range txs { + hashes[i] = tx.Hash() + } - return merkleReduce(hashes) + return merkleReduce(hashes) } // merkleReduce reduces a slice of hashes to a single Merkle root. func merkleReduce(hashes []crypto.Hash32) crypto.Hash32 { - if len(hashes) == 1 { - return hashes[0] - } + if len(hashes) == 1 { + return hashes[0] + } - // Pad to even length - if len(hashes)%2 == 1 { - hashes = append(hashes, hashes[len(hashes)-1]) - } + // Pad to even length + if len(hashes)%2 == 1 { + hashes = append(hashes, hashes[len(hashes)-1]) + } - next := make([]crypto.Hash32, len(hashes)/2) - for i := 0; i < len(hashes); i += 2 { - next[i/2] = crypto.HashBytes(hashes[i][:], hashes[i+1][:]) - } + next := make([]crypto.Hash32, len(hashes)/2) + for i := 0; i < len(hashes); i += 2 { + next[i/2] = crypto.HashBytes(hashes[i][:], hashes[i+1][:]) + } - return merkleReduce(next) + return merkleReduce(next) } // MerkleProof is a Merkle inclusion proof for a single transaction. type MerkleProof struct { - // Leaf is the hash of the transaction being proven. - Leaf crypto.Hash32 - // Path is the list of sibling hashes from leaf to root. - Path []MerkleNode + // Leaf is the hash of the transaction being proven. + Leaf crypto.Hash32 + // Path is the list of sibling hashes from leaf to root. + Path []MerkleNode } // MerkleNode is one element in a Merkle proof path. type MerkleNode struct { - Hash crypto.Hash32 - Right bool // true if this sibling is on the right side + Hash crypto.Hash32 + Right bool // true if this sibling is on the right side } // GenerateMerkleProof generates an inclusion proof for the transaction at index idx. func GenerateMerkleProof(txs []Transaction, idx int) *MerkleProof { - if idx < 0 || idx >= len(txs) { - return nil - } - - hashes := make([]crypto.Hash32, len(txs)) - for i, tx := range txs { - hashes[i] = tx.Hash() - } - - proof := &MerkleProof{Leaf: hashes[idx]} - pos := idx - - for len(hashes) > 1 { - if len(hashes)%2 == 1 { - hashes = append(hashes, hashes[len(hashes)-1]) - } - var sibling MerkleNode - if pos%2 == 0 { - sibling = MerkleNode{Hash: hashes[pos+1], Right: true} - } else { - sibling = MerkleNode{Hash: hashes[pos-1], Right: false} - } - proof.Path = append(proof.Path, sibling) - - next := make([]crypto.Hash32, len(hashes)/2) - for i := 0; i < len(hashes); i += 2 { - next[i/2] = crypto.HashBytes(hashes[i][:], hashes[i+1][:]) - } - hashes = next - pos /= 2 - } - - return proof + if idx < 0 || idx >= len(txs) { + return nil + } + + hashes := make([]crypto.Hash32, len(txs)) + for i, tx := range txs { + hashes[i] = tx.Hash() + } + + proof := &MerkleProof{Leaf: hashes[idx]} + pos := idx + + for len(hashes) > 1 { + if len(hashes)%2 == 1 { + hashes = append(hashes, hashes[len(hashes)-1]) + } + var sibling MerkleNode + if pos%2 == 0 { + sibling = MerkleNode{Hash: hashes[pos+1], Right: true} + } else { + sibling = MerkleNode{Hash: hashes[pos-1], Right: false} + } + proof.Path = append(proof.Path, sibling) + + next := make([]crypto.Hash32, len(hashes)/2) + for i := 0; i < len(hashes); i += 2 { + next[i/2] = crypto.HashBytes(hashes[i][:], hashes[i+1][:]) + } + hashes = next + pos /= 2 + } + + return proof } // Verify checks that the Merkle proof is valid for the given root. func (p *MerkleProof) Verify(root crypto.Hash32) bool { - cur := p.Leaf - for _, node := range p.Path { - if node.Right { - cur = crypto.HashBytes(cur[:], node.Hash[:]) - } else { - cur = crypto.HashBytes(node.Hash[:], cur[:]) - } - } - return cur == root + cur := p.Leaf + for _, node := range p.Path { + if node.Right { + cur = crypto.HashBytes(cur[:], node.Hash[:]) + } else { + cur = crypto.HashBytes(node.Hash[:], cur[:]) + } + } + return cur == root } diff --git a/core/mint.go b/core/mint.go index abc7f77..58bd941 100644 --- a/core/mint.go +++ b/core/mint.go @@ -1,9 +1,9 @@ package core import ( - "fmt" + "fmt" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // BuildMintTx creates a transparent coinbase-style transaction that mints amount to addr. @@ -23,51 +23,51 @@ import ( // the block's own height) — otherwise the output will not be recognized by // scanning/spending code, which recomputes mint_pub from the actual block height. func BuildMintTx(addr crypto.Address, amount uint64, height uint64) (*Transaction, error) { - if amount == 0 { - return nil, fmt.Errorf("mint amount must be > 0") - } + if amount == 0 { + return nil, fmt.Errorf("mint amount must be > 0") + } - _, spendPub, _, err := crypto.DecodeAddress(addr) - if err != nil { - return nil, fmt.Errorf("decode address: %w", err) - } + _, spendPub, _, err := crypto.DecodeAddress(addr) + if err != nil { + return nil, fmt.Errorf("decode address: %w", err) + } - // Use a deterministic blind derived from spendPub + amount + height. - // For height > 0 (block-reward mints) we use V2 which includes height in - // the derivation so each block's reward has a distinct blind even for the - // same (address, amount) pair. For height=0 (one-off admin mints) we - // keep the legacy V1 formula for backward compatibility with existing UTXOs. - var blind crypto.BlindFactor - if height > 0 { - blind, err = crypto.DeterministicMintBlindV2(spendPub, amount, height) - } else { - blind, err = crypto.DeterministicMintBlind(spendPub, amount) - } - if err != nil { - return nil, fmt.Errorf("deterministic mint blind: %w", err) - } - commit, err := crypto.Commit(amount, blind) - if err != nil { - return nil, fmt.Errorf("pedersen commit: %w", err) - } + // Use a deterministic blind derived from spendPub + amount + height. + // For height > 0 (block-reward mints) we use V2 which includes height in + // the derivation so each block's reward has a distinct blind even for the + // same (address, amount) pair. For height=0 (one-off admin mints) we + // keep the legacy V1 formula for backward compatibility with existing UTXOs. + var blind crypto.BlindFactor + if height > 0 { + blind, err = crypto.DeterministicMintBlindV2(spendPub, amount, height) + } else { + blind, err = crypto.DeterministicMintBlind(spendPub, amount) + } + if err != nil { + return nil, fmt.Errorf("deterministic mint blind: %w", err) + } + commit, err := crypto.Commit(amount, blind) + if err != nil { + return nil, fmt.Errorf("pedersen commit: %w", err) + } - heightPub, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(height)) - if err != nil { - return nil, fmt.Errorf("derive height pub: %w", err) - } - oneTimePub, err := crypto.AddPoints(spendPub, heightPub) - if err != nil { - return nil, fmt.Errorf("derive mint one-time pub: %w", err) - } + heightPub, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(height)) + if err != nil { + return nil, fmt.Errorf("derive height pub: %w", err) + } + oneTimePub, err := crypto.AddPoints(spendPub, heightPub) + if err != nil { + return nil, fmt.Errorf("derive mint one-time pub: %w", err) + } - tx := &Transaction{ - Version: TxVersionBase, - Inputs: nil, // coinbase — no ring inputs - Outputs: []Output{{ - OneTimePub: oneTimePub, // transparent: spend_pub + height*G - AmountCommit: commit, - }}, - Fee: 0, // mints are fee-exempt (coinbase exemption in mempool) - } - return tx, nil + tx := &Transaction{ + Version: TxVersionBase, + Inputs: nil, // coinbase — no ring inputs + Outputs: []Output{{ + OneTimePub: oneTimePub, // transparent: spend_pub + height*G + AmountCommit: commit, + }}, + Fee: 0, // mints are fee-exempt (coinbase exemption in mempool) + } + return tx, nil } diff --git a/core/mint_spend_test.go b/core/mint_spend_test.go index 10c0bfe..5104518 100644 --- a/core/mint_spend_test.go +++ b/core/mint_spend_test.go @@ -1,10 +1,10 @@ package core_test import ( - "testing" + "testing" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // TestMintAndSpend replicates the exact real-world flow: @@ -12,104 +12,104 @@ import ( // 2. User spends it via TxBuilder (using DeterministicMintBlind) // This catches the "commitment balance check failed" bug. func TestMintAndSpend(t *testing.T) { - const mintAmount = uint64(200_000_000_000_000) // 2,000,000 APRO - const sendAmount = uint64(20_000_000_000_000) // 200,000 APRO - - // 1. Generate Alice wallet keys - aliceKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - aliceAddr := crypto.AddressFromKeys(crypto.MainnetByte, aliceKeys) - t.Logf("Alice address: %s", aliceAddr) - - // 2. Admin mints APRO to Alice (same as restAdminMint → BuildMintTx, height=0) - mintTx, err := core.BuildMintTx(aliceAddr, mintAmount, 0) - if err != nil { - t.Fatalf("BuildMintTx: %v", err) - } - t.Logf("Mint tx outputs: %d", len(mintTx.Outputs)) - t.Logf("Mint output.OneTimePub: %x", mintTx.Outputs[0].OneTimePub[:8]) - t.Logf("Mint output.AmountCommit: %x", mintTx.Outputs[0].AmountCommit[:8]) - - // 3. Verify the commitment recomputes correctly (as aprWalletSend does) - _, spendPub, _, err := crypto.DecodeAddress(aliceAddr) - if err != nil { - t.Fatalf("DecodeAddress: %v", err) - } - t.Logf("spendPub (from address): %x", spendPub[:8]) - - // 3b. Also derive spendPub from private key (as aprWalletSend does) - spendPubFromPriv, err := crypto.PublicKeyFromPrivate(aliceKeys.Spend.Private) - if err != nil { - t.Fatalf("PublicKeyFromPrivate: %v", err) - } - t.Logf("spendPub (from private): %x", spendPubFromPriv[:8]) - - if spendPub != spendPubFromPriv { - t.Errorf("MISMATCH: spendPub from address != spendPub from private key!") - } else { - t.Log("spendPub matches ✓") - } - - // 4. Recompute blind (as aprWalletSend does) - blind, err := crypto.DeterministicMintBlind(spendPubFromPriv, mintAmount) - if err != nil { - t.Fatalf("DeterministicMintBlind: %v", err) - } - - // 5. Verify commitment matches on-chain commitment - recomputedCommit, err := crypto.Commit(mintAmount, blind) - if err != nil { - t.Fatalf("Commit: %v", err) - } - - if recomputedCommit != mintTx.Outputs[0].AmountCommit { - t.Errorf("COMMITMENT MISMATCH:\n on-chain: %x\n recomputed: %x", - mintTx.Outputs[0].AmountCommit[:], recomputedCommit[:]) - } else { - t.Log("Commitment recomputed correctly ✓") - } - - // 6. Build the spending transaction (as TxBuilder does) - ownedUTXO := core.OwnedUTXO{ - UTXO: core.UTXO{ - OneTimePub: mintTx.Outputs[0].OneTimePub, - TxPubKey: mintTx.Outputs[0].TxPubKey, - AmountCommit: mintTx.Outputs[0].AmountCommit, - }, - HsScalar: crypto.Scalar32{}, // zero for mint outputs - Amount: mintAmount, - Blind: blind, - } - - bobKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - bobAddr := crypto.AddressFromKeys(crypto.MainnetByte, bobKeys) - - builder := core.NewTxBuilder( - aliceKeys.Spend.Private, - aliceKeys.View.Private, - spendPubFromPriv, - []core.OwnedUTXO{ownedUTXO}, - 0, - ) - - result, err := builder.Build(sendAmount, bobAddr, aliceAddr) - if err != nil { - t.Fatalf("Build: %v", err) - } - t.Logf("Built tx: %d inputs, %d outputs, fee=%d", result.InputCount, result.OutputCount, result.TotalFee) - - // 7. Run full cryptographic verification (same as aprWalletSend does) - verifier := core.NewTxVerifier(nil) - if err := verifier.VerifyTx(&result.Tx); err != nil { - t.Fatalf("VerifyTx FAILED: %v", err) - } else { - t.Log("VerifyTx passed ✓") - } + const mintAmount = uint64(200_000_000_000_000) // 2,000,000 APRO + const sendAmount = uint64(20_000_000_000_000) // 200,000 APRO + + // 1. Generate Alice wallet keys + aliceKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + aliceAddr := crypto.AddressFromKeys(crypto.MainnetByte, aliceKeys) + t.Logf("Alice address: %s", aliceAddr) + + // 2. Admin mints APRO to Alice (same as restAdminMint → BuildMintTx, height=0) + mintTx, err := core.BuildMintTx(aliceAddr, mintAmount, 0) + if err != nil { + t.Fatalf("BuildMintTx: %v", err) + } + t.Logf("Mint tx outputs: %d", len(mintTx.Outputs)) + t.Logf("Mint output.OneTimePub: %x", mintTx.Outputs[0].OneTimePub[:8]) + t.Logf("Mint output.AmountCommit: %x", mintTx.Outputs[0].AmountCommit[:8]) + + // 3. Verify the commitment recomputes correctly (as aprWalletSend does) + _, spendPub, _, err := crypto.DecodeAddress(aliceAddr) + if err != nil { + t.Fatalf("DecodeAddress: %v", err) + } + t.Logf("spendPub (from address): %x", spendPub[:8]) + + // 3b. Also derive spendPub from private key (as aprWalletSend does) + spendPubFromPriv, err := crypto.PublicKeyFromPrivate(aliceKeys.Spend.Private) + if err != nil { + t.Fatalf("PublicKeyFromPrivate: %v", err) + } + t.Logf("spendPub (from private): %x", spendPubFromPriv[:8]) + + if spendPub != spendPubFromPriv { + t.Errorf("MISMATCH: spendPub from address != spendPub from private key!") + } else { + t.Log("spendPub matches ✓") + } + + // 4. Recompute blind (as aprWalletSend does) + blind, err := crypto.DeterministicMintBlind(spendPubFromPriv, mintAmount) + if err != nil { + t.Fatalf("DeterministicMintBlind: %v", err) + } + + // 5. Verify commitment matches on-chain commitment + recomputedCommit, err := crypto.Commit(mintAmount, blind) + if err != nil { + t.Fatalf("Commit: %v", err) + } + + if recomputedCommit != mintTx.Outputs[0].AmountCommit { + t.Errorf("COMMITMENT MISMATCH:\n on-chain: %x\n recomputed: %x", + mintTx.Outputs[0].AmountCommit[:], recomputedCommit[:]) + } else { + t.Log("Commitment recomputed correctly ✓") + } + + // 6. Build the spending transaction (as TxBuilder does) + ownedUTXO := core.OwnedUTXO{ + UTXO: core.UTXO{ + OneTimePub: mintTx.Outputs[0].OneTimePub, + TxPubKey: mintTx.Outputs[0].TxPubKey, + AmountCommit: mintTx.Outputs[0].AmountCommit, + }, + HsScalar: crypto.Scalar32{}, // zero for mint outputs + Amount: mintAmount, + Blind: blind, + } + + bobKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + bobAddr := crypto.AddressFromKeys(crypto.MainnetByte, bobKeys) + + builder := core.NewTxBuilder( + aliceKeys.Spend.Private, + aliceKeys.View.Private, + spendPubFromPriv, + []core.OwnedUTXO{ownedUTXO}, + 0, + ) + + result, err := builder.Build(sendAmount, bobAddr, aliceAddr) + if err != nil { + t.Fatalf("Build: %v", err) + } + t.Logf("Built tx: %d inputs, %d outputs, fee=%d", result.InputCount, result.OutputCount, result.TotalFee) + + // 7. Run full cryptographic verification (same as aprWalletSend does) + verifier := core.NewTxVerifier(nil) + if err := verifier.VerifyTx(&result.Tx); err != nil { + t.Fatalf("VerifyTx FAILED: %v", err) + } else { + t.Log("VerifyTx passed ✓") + } } // TestMintUniquePerHeight guards against the coinbase-collision bug: minting @@ -120,74 +120,74 @@ func TestMintAndSpend(t *testing.T) { // tx was byte-for-byte identical, silently overwriting prior UTXOs and causing // false double-spend rejections. func TestMintUniquePerHeight(t *testing.T) { - const amount = uint64(1_000_000_000) // same amount every "block", like a fixed reward - - keys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - addr := crypto.AddressFromKeys(crypto.MainnetByte, keys) - - txA, err := core.BuildMintTx(addr, amount, 100) - if err != nil { - t.Fatalf("BuildMintTx height=100: %v", err) - } - txB, err := core.BuildMintTx(addr, amount, 101) - if err != nil { - t.Fatalf("BuildMintTx height=101: %v", err) - } - - if txA.Outputs[0].OneTimePub == txB.Outputs[0].OneTimePub { - t.Fatal("mint outputs at different heights must have distinct OneTimePub") - } - if txA.Hash() == txB.Hash() { - t.Fatal("mint txs at different heights must have distinct hashes") - } - - // Spending both rewards must yield distinct key images (no false double-spend). - spendPub, err := crypto.PublicKeyFromPrivate(keys.Spend.Private) - if err != nil { - t.Fatalf("PublicKeyFromPrivate: %v", err) - } - heightPubA, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(100)) - if err != nil { - t.Fatal(err) - } - heightPubB, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(101)) - if err != nil { - t.Fatal(err) - } - oneTimePrivA, err := crypto.AddScalars(crypto.ScalarFromUint64(100), keys.Spend.Private) - if err != nil { - t.Fatal(err) - } - oneTimePrivB, err := crypto.AddScalars(crypto.ScalarFromUint64(101), keys.Spend.Private) - if err != nil { - t.Fatal(err) - } - expectedPubA, err := crypto.AddPoints(spendPub, heightPubA) - if err != nil { - t.Fatal(err) - } - expectedPubB, err := crypto.AddPoints(spendPub, heightPubB) - if err != nil { - t.Fatal(err) - } - if expectedPubA != txA.Outputs[0].OneTimePub || expectedPubB != txB.Outputs[0].OneTimePub { - t.Fatal("recomputed mint pub does not match BuildMintTx output") - } - - kiA, err := crypto.ComputeKeyImage(oneTimePrivA, txA.Outputs[0].OneTimePub) - if err != nil { - t.Fatalf("ComputeKeyImage A: %v", err) - } - kiB, err := crypto.ComputeKeyImage(oneTimePrivB, txB.Outputs[0].OneTimePub) - if err != nil { - t.Fatalf("ComputeKeyImage B: %v", err) - } - if kiA == kiB { - t.Fatal("key images for mint outputs at different heights must be distinct") - } + const amount = uint64(1_000_000_000) // same amount every "block", like a fixed reward + + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addr := crypto.AddressFromKeys(crypto.MainnetByte, keys) + + txA, err := core.BuildMintTx(addr, amount, 100) + if err != nil { + t.Fatalf("BuildMintTx height=100: %v", err) + } + txB, err := core.BuildMintTx(addr, amount, 101) + if err != nil { + t.Fatalf("BuildMintTx height=101: %v", err) + } + + if txA.Outputs[0].OneTimePub == txB.Outputs[0].OneTimePub { + t.Fatal("mint outputs at different heights must have distinct OneTimePub") + } + if txA.Hash() == txB.Hash() { + t.Fatal("mint txs at different heights must have distinct hashes") + } + + // Spending both rewards must yield distinct key images (no false double-spend). + spendPub, err := crypto.PublicKeyFromPrivate(keys.Spend.Private) + if err != nil { + t.Fatalf("PublicKeyFromPrivate: %v", err) + } + heightPubA, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(100)) + if err != nil { + t.Fatal(err) + } + heightPubB, err := crypto.ScalarMulBase(crypto.ScalarFromUint64(101)) + if err != nil { + t.Fatal(err) + } + oneTimePrivA, err := crypto.AddScalars(crypto.ScalarFromUint64(100), keys.Spend.Private) + if err != nil { + t.Fatal(err) + } + oneTimePrivB, err := crypto.AddScalars(crypto.ScalarFromUint64(101), keys.Spend.Private) + if err != nil { + t.Fatal(err) + } + expectedPubA, err := crypto.AddPoints(spendPub, heightPubA) + if err != nil { + t.Fatal(err) + } + expectedPubB, err := crypto.AddPoints(spendPub, heightPubB) + if err != nil { + t.Fatal(err) + } + if expectedPubA != txA.Outputs[0].OneTimePub || expectedPubB != txB.Outputs[0].OneTimePub { + t.Fatal("recomputed mint pub does not match BuildMintTx output") + } + + kiA, err := crypto.ComputeKeyImage(oneTimePrivA, txA.Outputs[0].OneTimePub) + if err != nil { + t.Fatalf("ComputeKeyImage A: %v", err) + } + kiB, err := crypto.ComputeKeyImage(oneTimePrivB, txB.Outputs[0].OneTimePub) + if err != nil { + t.Fatalf("ComputeKeyImage B: %v", err) + } + if kiA == kiB { + t.Fatal("key images for mint outputs at different heights must be distinct") + } } // TestMintHeightZeroMatchesLegacy ensures height=0 (used by one-off admin @@ -195,21 +195,21 @@ func TestMintUniquePerHeight(t *testing.T) { // spend_pub directly. This must never regress or existing admin-minted // UTXOs become unspendable. func TestMintHeightZeroMatchesLegacy(t *testing.T) { - keys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - addr := crypto.AddressFromKeys(crypto.MainnetByte, keys) - _, spendPub, _, err := crypto.DecodeAddress(addr) - if err != nil { - t.Fatal(err) - } - - tx, err := core.BuildMintTx(addr, 42, 0) - if err != nil { - t.Fatal(err) - } - if tx.Outputs[0].OneTimePub != spendPub { - t.Fatal("height=0 must produce mint_pub == spend_pub (legacy transparent behavior)") - } + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addr := crypto.AddressFromKeys(crypto.MainnetByte, keys) + _, spendPub, _, err := crypto.DecodeAddress(addr) + if err != nil { + t.Fatal(err) + } + + tx, err := core.BuildMintTx(addr, 42, 0) + if err != nil { + t.Fatal(err) + } + if tx.Outputs[0].OneTimePub != spendPub { + t.Fatal("height=0 must produce mint_pub == spend_pub (legacy transparent behavior)") + } } diff --git a/core/phase2_cap_test.go b/core/phase2_cap_test.go index e5f078c..3300a02 100644 --- a/core/phase2_cap_test.go +++ b/core/phase2_cap_test.go @@ -91,7 +91,7 @@ func TestSpentDecoyPool_CapEnforced(t *testing.T) { smallCap, smallCap+1) } -// TestSpentDecoyPool_CapEnforced_ViaApplyBlock verifies the same cap behaviour +// TestSpentDecoyPool_CapEnforced_ViaApplyBlock verifies the same cap behavior // through ApplyBlock (the live-chain path), not just the startup-replay path. // The pool must not grow beyond maxSpentDecoys even when new blocks are committed. func TestSpentDecoyPool_CapEnforced_ViaApplyBlock(t *testing.T) { diff --git a/core/registry_snapshot.go b/core/registry_snapshot.go index 3eef6d9..21b16ef 100644 --- a/core/registry_snapshot.go +++ b/core/registry_snapshot.go @@ -1,7 +1,7 @@ package core // RegistrySnapshot is a serialisable image of the ValidatorRegistry state. -// The utxos pointer cannot be serialised and must be re-wired via +// The utxos pointer cannot be serialized and must be re-wired via // SetUTXOSet after RestoreFromSnapshot. type RegistrySnapshot struct { Validators map[string]*ValidatorEntry `json:"validators"` diff --git a/core/ringct_v4_security_test.go b/core/ringct_v4_security_test.go index d5467bf..42b7a5d 100644 --- a/core/ringct_v4_security_test.go +++ b/core/ringct_v4_security_test.go @@ -65,7 +65,7 @@ func TestRingCTV4RejectsCommitmentCopyTheft(t *testing.T) { OneTimePub: crypto.Point32{2}, AmountCommit: victimCommit, }}, - FeeCommit: feeCommit, + FeeCommit: feeCommit, RangeProofs: []*crypto.RangeProof{outputProof}, Signatures: make([]*crypto.MLSAGSignature, 1), } @@ -86,4 +86,4 @@ func TestRingCTV4RejectsCommitmentCopyTheft(t *testing.T) { if utxos.GetByPubKey(victim.Spend.Public) == nil { t.Fatal("victim UTXO was removed after rejected commitment-copy theft") } -} \ No newline at end of file +} diff --git a/core/ringct_v5_test.go b/core/ringct_v5_test.go index 1480a34..65e46fc 100644 --- a/core/ringct_v5_test.go +++ b/core/ringct_v5_test.go @@ -141,9 +141,9 @@ func TestRingCTV5RejectsStakedCollateralAcrossRestartAndRollback(t *testing.T) { set := core.NewUTXOSetWithDB(db) set.SetCLSAGActivationHeight(1) set.Add(&owned[0].UTXO) -const stakedCollateralActivationHeight = uint64(2_950_000) -verifier := core.NewTxVerifier(set) -verifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActivationHeight) + const stakedCollateralActivationHeight = uint64(2_950_000) + verifier := core.NewTxVerifier(set) + verifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActivationHeight) decoys := make([]core.DecoyUTXO, crypto.RingSize-1) decoyUTXOs := make([]*core.UTXO, crypto.RingSize-1) @@ -201,15 +201,15 @@ verifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActiva if err != nil { t.Fatalf("build v5: %v", err) } -if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight-1); err != nil { + if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight-1); err != nil { t.Fatalf("historically valid transaction rejected before stake transition: %v", err) } -if err := verifier.VerifyTx(&result.Tx); err == nil || !strings.Contains(err.Error(), "height-aware") { - t.Fatalf("heightless verification did not fail closed: %v", err) -} -if err := verifier.VerifyTxAtHeight(&result.Tx, 0); err == nil || !strings.Contains(err.Error(), "nonzero candidate height") { - t.Fatalf("zero-height verification did not fail closed: %v", err) -} + if err := verifier.VerifyTx(&result.Tx); err == nil || !strings.Contains(err.Error(), "height-aware") { + t.Fatalf("heightless verification did not fail closed: %v", err) + } + if err := verifier.VerifyTxAtHeight(&result.Tx, 0); err == nil || !strings.Contains(err.Error(), "nonzero candidate height") { + t.Fatalf("zero-height verification did not fail closed: %v", err) + } var validatorSig, ownershipSig [64]byte pub := crypto.ValidatorPubKey(alice.Spend.Public[:]) @@ -219,17 +219,17 @@ if err := verifier.VerifyTxAtHeight(&result.Tx, 0); err == nil || !strings.Conta t.Fatal(err) } stakeTx := core.Transaction{Version: core.TxVersionStake, Extra: stakeExtra} -if err := verifier.VerifyBlock(&core.Block{ - Header: core.BlockHeader{Height: stakedCollateralActivationHeight - 1}, + if err := verifier.VerifyBlock(&core.Block{ + Header: core.BlockHeader{Height: stakedCollateralActivationHeight - 1}, Txs: []core.Transaction{result.Tx, stakeTx}, -}); err != nil { - t.Fatalf("pre-activation block rejected under the new rule: %v", err) -} -if err := verifier.VerifyBlock(&core.Block{ - Header: core.BlockHeader{Height: stakedCollateralActivationHeight}, - Txs: []core.Transaction{result.Tx, stakeTx}, -}); err == nil || !strings.Contains(err.Error(), "staked collateral") { - t.Fatalf("same-block spend at activation was not rejected: %v", err) + }); err != nil { + t.Fatalf("pre-activation block rejected under the new rule: %v", err) + } + if err := verifier.VerifyBlock(&core.Block{ + Header: core.BlockHeader{Height: stakedCollateralActivationHeight}, + Txs: []core.Transaction{result.Tx, stakeTx}, + }); err == nil || !strings.Contains(err.Error(), "staked collateral") { + t.Fatalf("same-block spend at activation was not rejected: %v", err) } // Runtime stake transition must prevent the authentic output from being @@ -237,27 +237,27 @@ if err := verifier.VerifyBlock(&core.Block{ if err := set.MarkStaked(owned[0].TxHash, owned[0].OutputIndex); err != nil { t.Fatalf("MarkStaked: %v", err) } -if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight-1); err != nil { - t.Fatalf("pre-activation mempool transaction rejected: %v", err) -} -if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err == nil || !strings.Contains(err.Error(), "staked collateral") { - t.Fatalf("CLSAG transaction using staked collateral was not rejected at activation: %v", err) - } -preActivationPoolConfig := core.DefaultMempoolConfig() -preActivationPoolConfig.CurrentHeight = func() uint64 { return stakedCollateralActivationHeight - 2 } -preActivationPoolConfig.RingCTCLSAGActivationHeight = 1 -preActivationPoolConfig.BaseFeePerByte = 1 -preActivationPoolConfig.Verifier = verifier -if err := core.NewMempool(preActivationPoolConfig).Add(result.Tx); err != nil { -t.Fatalf("mempool rejected transaction before activation: %v", err) -} -activePoolConfig := preActivationPoolConfig -activePoolConfig.CurrentHeight = func() uint64 { return stakedCollateralActivationHeight - 1 } -if err := core.NewMempool(activePoolConfig).Add(result.Tx); err == nil || !strings.Contains(err.Error(), "staked collateral") { -t.Fatalf("mempool did not enforce activation-height rule: %v", err) -} -if err := verifier.VerifyBlock(&core.Block{ - Header: core.BlockHeader{Height: stakedCollateralActivationHeight}, + if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight-1); err != nil { + t.Fatalf("pre-activation mempool transaction rejected: %v", err) + } + if err := verifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err == nil || !strings.Contains(err.Error(), "staked collateral") { + t.Fatalf("CLSAG transaction using staked collateral was not rejected at activation: %v", err) + } + preActivationPoolConfig := core.DefaultMempoolConfig() + preActivationPoolConfig.CurrentHeight = func() uint64 { return stakedCollateralActivationHeight - 2 } + preActivationPoolConfig.RingCTCLSAGActivationHeight = 1 + preActivationPoolConfig.BaseFeePerByte = 1 + preActivationPoolConfig.Verifier = verifier + if err := core.NewMempool(preActivationPoolConfig).Add(result.Tx); err != nil { + t.Fatalf("mempool rejected transaction before activation: %v", err) + } + activePoolConfig := preActivationPoolConfig + activePoolConfig.CurrentHeight = func() uint64 { return stakedCollateralActivationHeight - 1 } + if err := core.NewMempool(activePoolConfig).Add(result.Tx); err == nil || !strings.Contains(err.Error(), "staked collateral") { + t.Fatalf("mempool did not enforce activation-height rule: %v", err) + } + if err := verifier.VerifyBlock(&core.Block{ + Header: core.BlockHeader{Height: stakedCollateralActivationHeight}, Txs: []core.Transaction{result.Tx}, }); err == nil { t.Fatal("canonical block validation accepted a CLSAG transaction using staked collateral") @@ -289,14 +289,14 @@ if err := verifier.VerifyBlock(&core.Block{ restarted.SetCLSAGActivationHeight(1) restarted.MarkStakedKnown(owned[0].TxHash, owned[0].OutputIndex, &core.UTXO{ TxHash: owned[0].TxHash, OutputIndex: owned[0].OutputIndex, - AmountCommit: owned[0].UTXO.AmountCommit, + AmountCommit: owned[0].AmountCommit, }) -restartedVerifier := core.NewTxVerifier(restarted) -restartedVerifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActivationHeight) + restartedVerifier := core.NewTxVerifier(restarted) + restartedVerifier.SetStakedCollateralRingExclusionActivationHeight(stakedCollateralActivationHeight) if got := restarted.GetRingMember(owned[0].OneTimePub); got == nil { t.Fatal("staked output's canonical ring record was destructively removed") } -if err := restartedVerifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err == nil || !strings.Contains(err.Error(), "staked collateral") { + if err := restartedVerifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err == nil || !strings.Contains(err.Error(), "staked collateral") { t.Fatalf("CLSAG transaction using staked collateral was not rejected after restart replay: %v", err) } sampledAfterRestart := restarted.SampleCLSAGDecoys(crypto.RingSize-1, nil) @@ -312,7 +312,7 @@ if err := restartedVerifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivat // Rolling back the stake transition restores the pre-stake canonical state. restarted.UnmarkStaked(owned[0].TxHash, owned[0].OutputIndex) -if err := restartedVerifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err != nil { + if err := restartedVerifier.VerifyTxAtHeight(&result.Tx, stakedCollateralActivationHeight); err != nil { t.Fatalf("transaction rejected after stake rollback: %v", err) } } diff --git a/core/stake_keyimage_v4_test.go b/core/stake_keyimage_v4_test.go new file mode 100644 index 0000000..7ad8531 --- /dev/null +++ b/core/stake_keyimage_v4_test.go @@ -0,0 +1,110 @@ +package core_test + +import ( + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func makeStakeKeyImageV4TestTx(t *testing.T, utxos *core.UTXOSet) (core.Transaction, crypto.KeyImage) { + t.Helper() + validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatalf("GenerateValidatorKey: %v", err) + } + owner, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + + var burnTxHash crypto.Hash32 + burnTxHash[0] = 0xA4 + amount := core.MinStakeNAPR + blind := crypto.BlindFactor(crypto.ScalarFromUint64(17)) + commitment, err := crypto.Commit(amount, blind) + if err != nil { + t.Fatalf("Commit: %v", err) + } + utxos.Add(&core.UTXO{ + TxHash: burnTxHash, + OutputIndex: 0, + OneTimePub: owner.Spend.Public, + AmountCommit: commitment, + }) + + context := core.StakeKeyImageProofContext(core.StakeDeposit, validatorPub, amount, burnTxHash, 0, blind) + keyImage, proof, err := crypto.ProveKeyImage(owner.Spend.Private, owner.Spend.Public, context) + if err != nil { + t.Fatalf("ProveKeyImage: %v", err) + } + msg := core.StakeSignMsgV2(core.StakeDeposit, validatorPub, amount, burnTxHash, 0) + signature, err := validatorPriv.Sign(msg) + if err != nil { + t.Fatalf("Sign: %v", err) + } + extra, err := core.EncodeStakeExtraV4( + core.StakeDeposit, validatorPub, amount, signature, burnTxHash, 0, blind, keyImage, proof, + ) + if err != nil { + t.Fatalf("EncodeStakeExtraV4: %v", err) + } + return core.Transaction{Version: core.TxVersionStake, Extra: extra}, keyImage +} + +func TestStakeKeyImageV4AcceptsUnspentAndRejectsSpentCollateral(t *testing.T) { + utxos := core.NewUTXOSet() + tx, keyImage := makeStakeKeyImageV4TestTx(t, utxos) + registry := core.NewValidatorRegistry() + registry.SetUTXOSet(utxos) + if err := registry.ConfigureStakeKeyImageV4(10); err != nil { + t.Fatalf("ConfigureStakeKeyImageV4: %v", err) + } + + if err := registry.ValidateBlockStakeTxs([]core.Transaction{tx}, 10); err != nil { + t.Fatalf("valid unspent collateral rejected: %v", err) + } + utxos.MarkSpent(keyImage) + if err := registry.ValidateBlockStakeTxs([]core.Transaction{tx}, 10); err == nil { + t.Fatal("already-spent collateral was accepted for stake") + } +} + +func TestStakeKeyImageV4RejectsPreActivationAndSameBlockSpend(t *testing.T) { + utxos := core.NewUTXOSet() + stakeTx, keyImage := makeStakeKeyImageV4TestTx(t, utxos) + registry := core.NewValidatorRegistry() + registry.SetUTXOSet(utxos) + if err := registry.ConfigureStakeKeyImageV4(10); err != nil { + t.Fatalf("ConfigureStakeKeyImageV4: %v", err) + } + if err := registry.ValidateBlockStakeTxs([]core.Transaction{stakeTx}, 9); err == nil { + t.Fatal("v4 stake was accepted before activation") + } + + spendTx := core.Transaction{ + Version: core.TxVersionCLSAG, + Inputs: []core.RingInput{{KeyImage: keyImage}}, + } + if err := registry.ValidateBlockStakeTxs([]core.Transaction{stakeTx, spendTx}, 10); err == nil { + t.Fatal("block accepted both a stake and a normal spend with the same key image") + } +} + +func TestStakeKeyImageV4UTXOApplyAndRollback(t *testing.T) { + utxos := core.NewUTXOSet() + stakeTx, keyImage := makeStakeKeyImageV4TestTx(t, utxos) + block := &core.Block{Header: core.BlockHeader{Height: 10}, Txs: []core.Transaction{stakeTx}} + if err := utxos.ApplyBlock(block); err != nil { + t.Fatalf("ApplyBlock: %v", err) + } + if !utxos.IsSpent(keyImage) { + t.Fatal("applied stake did not reserve the collateral key image") + } + if err := utxos.RollbackBlock(block); err != nil { + t.Fatalf("RollbackBlock: %v", err) + } + if utxos.IsSpent(keyImage) { + t.Fatal("rollback left the stake key image marked spent") + } +} diff --git a/core/staking.go b/core/staking.go index 72f0f02..cf0b37a 100644 --- a/core/staking.go +++ b/core/staking.go @@ -17,7 +17,7 @@ import ( "math" "sort" "sync" -"sync/atomic" + "sync/atomic" "github.com/aperod/aperod/crypto" ) @@ -113,6 +113,12 @@ const StakePayloadSizeV2 = 173 // long as the ownership proof verifies. const StakePayloadSizeV3 = 237 +// StakePayloadSizeV4 is the byte length of a deposit payload carrying a +// key-image link proof. It binds the collateral outpoint to the same nullifier +// used by ordinary ring spends, preventing already-spent outputs from backing +// stake a second time. +const StakePayloadSizeV4 = 301 + // StakeWithdrawalPayloadSizeV2 is the fixed encoding for chain-bound, // state-bound validator withdrawal authorization. // Layout: action(1) + pubkey(32) + amount(8) + genesis(32) + nonce(8) + @@ -349,6 +355,147 @@ func DecodeStakeExtraV3(extra []byte) ( return } +// StakeKeyImageProofContext binds a DLEQ proof to one stake deposit. The proof +// itself also commits to the UTXO one-time public key and key image. +func StakeKeyImageProofContext( + action StakeAction, + pub crypto.ValidatorPubKey, + amount uint64, + burnTxHash crypto.Hash32, + burnOutIdx uint32, + burnBlind crypto.BlindFactor, +) []byte { + context := make([]byte, 1+32+8+32+4+32) + context[0] = byte(action) + copy(context[1:33], pub) + binary.BigEndian.PutUint64(context[33:41], amount) + copy(context[41:73], burnTxHash[:]) + binary.BigEndian.PutUint32(context[73:77], burnOutIdx) + copy(context[77:109], burnBlind[:]) + return context +} + +func (r *ValidatorRegistry) validateStakeDepositV4( + height, activation uint64, + action StakeAction, + pub crypto.ValidatorPubKey, + amount uint64, + sig []byte, + burnTxHash crypto.Hash32, + burnOutIdx uint32, + burnBlind crypto.BlindFactor, + keyImage crypto.KeyImage, + proof crypto.KeyImageDLEQProof, + checkSpent bool, +) (*UTXO, error) { + if activation == 0 || height < activation { + return nil, fmt.Errorf("stake key-image proof v4 is not active at height %d", height) + } + if action != StakeDeposit { + return nil, fmt.Errorf("v4 payload is only valid for StakeDeposit, got action=%d", action) + } + msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) + if !r.verifyValidatorSignature(pub, height, msg, sig) { + return nil, fmt.Errorf("invalid v4 stake signature from %s", pub.ID()) + } + if r.utxos == nil { + return nil, fmt.Errorf("UTXO set not wired; cannot verify v4 stake collateral") + } + burnUTXO := r.utxos.Get(burnTxHash, burnOutIdx) + if burnUTXO == nil { + return nil, fmt.Errorf("burn UTXO %x:%d not found in active set", + burnTxHash[:8], burnOutIdx) + } + expectedCommit, err := crypto.Commit(amount, burnBlind) + if err != nil { + return nil, fmt.Errorf("burn commitment computation failed: %w", err) + } + if expectedCommit != burnUTXO.AmountCommit { + return nil, fmt.Errorf("burn UTXO %x:%d AmountCommit mismatch", + burnTxHash[:8], burnOutIdx) + } + context := StakeKeyImageProofContext(action, pub, amount, burnTxHash, burnOutIdx, burnBlind) + if err := crypto.VerifyKeyImageProof(burnUTXO.OneTimePub, keyImage, context, proof); err != nil { + return nil, fmt.Errorf("stake collateral key-image proof invalid: %w", err) + } + if checkSpent { + spent, err := r.utxos.HasSpentKeyImage(keyImage) + if err != nil { + return nil, fmt.Errorf("check collateral key-image spentness: %w", err) + } + if spent { + return nil, fmt.Errorf("stake collateral key image %x was already spent", keyImage[:8]) + } + } + return burnUTXO, nil +} + +// EncodeStakeExtraV4 packs a deposit with a key-image/DLEQ proof into 301 +// bytes. The DLEQ proof proves that the key image is derived from the +// referenced UTXO's one-time private key. +func EncodeStakeExtraV4( + action StakeAction, + pub crypto.ValidatorPubKey, + amount uint64, + sig []byte, + burnTxHash crypto.Hash32, + burnOutIdx uint32, + burnBlind crypto.BlindFactor, + keyImage crypto.KeyImage, + proof crypto.KeyImageDLEQProof, +) ([]byte, error) { + if len(pub) != 32 { + return nil, fmt.Errorf("stake extra v4: pubkey must be 32 bytes, got %d", len(pub)) + } + if len(sig) != 64 { + return nil, fmt.Errorf("stake extra v4: signature must be 64 bytes, got %d", len(sig)) + } + b := make([]byte, StakePayloadSizeV4) + b[0] = byte(action) + copy(b[1:33], pub) + binary.BigEndian.PutUint64(b[33:41], amount) + copy(b[41:105], sig) + copy(b[105:137], burnTxHash[:]) + binary.BigEndian.PutUint32(b[137:141], burnOutIdx) + copy(b[141:173], burnBlind[:]) + copy(b[173:205], keyImage[:]) + copy(b[205:237], proof.CommitmentG[:]) + copy(b[237:269], proof.CommitmentHp[:]) + copy(b[269:301], proof.Response[:]) + return b, nil +} + +// DecodeStakeExtraV4 unpacks a 301-byte key-image-protected deposit payload. +func DecodeStakeExtraV4(extra []byte) ( + action StakeAction, + pub crypto.ValidatorPubKey, + amount uint64, + sig []byte, + burnTxHash crypto.Hash32, + burnOutIdx uint32, + burnBlind crypto.BlindFactor, + keyImage crypto.KeyImage, + proof crypto.KeyImageDLEQProof, + err error, +) { + if len(extra) != StakePayloadSizeV4 { + err = fmt.Errorf("stake extra v4: expected %d bytes, got %d", StakePayloadSizeV4, len(extra)) + return + } + action = StakeAction(extra[0]) + pub = crypto.ValidatorPubKey(extra[1:33]) + amount = binary.BigEndian.Uint64(extra[33:41]) + sig = extra[41:105] + copy(burnTxHash[:], extra[105:137]) + burnOutIdx = binary.BigEndian.Uint32(extra[137:141]) + copy(burnBlind[:], extra[141:173]) + copy(keyImage[:], extra[173:205]) + copy(proof.CommitmentG[:], extra[205:237]) + copy(proof.CommitmentHp[:], extra[237:269]) + copy(proof.Response[:], extra[269:301]) + return +} + // StakeOwnershipSignMsg returns the canonical signing message for the F-049 // one-time-key ownership proof included in v3 stake deposits. // The depositor signs this message with the one-time private key of the burn UTXO @@ -471,8 +618,9 @@ type ValidatorRegistry struct { utxos *UTXOSet // required for C-1 UTXO-backed deposit check stakeWithdrawalGenesis crypto.Hash32 stakeWithdrawalActivation uint64 -signatureFork atomic.Pointer[ValidatorAuthorityFork] -signatureAdmissionHeight atomic.Uint64 + stakeKeyImageActivation uint64 + signatureFork atomic.Pointer[ValidatorAuthorityFork] + signatureAdmissionHeight atomic.Uint64 } // ConfigureStakeWithdrawalV2 binds the registry to the quorum-attested LPoD @@ -498,6 +646,28 @@ func (r *ValidatorRegistry) StakeWithdrawalV2Config() (crypto.Hash32, uint64, bo return r.stakeWithdrawalGenesis, r.stakeWithdrawalActivation, r.stakeWithdrawalActivation != 0 } +// ConfigureStakeKeyImageV4 binds the spent-collateral proof to an explicit +// consensus activation height. The same height must be configured on every +// validator before the fork. +func (r *ValidatorRegistry) ConfigureStakeKeyImageV4(activation uint64) error { + if activation == 0 { + return fmt.Errorf("stake key-image v4: activation height is required") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.stakeKeyImageActivation != 0 && r.stakeKeyImageActivation != activation { + return fmt.Errorf("stake key-image v4: conflicting activation height") + } + r.stakeKeyImageActivation = activation + return nil +} + +func (r *ValidatorRegistry) StakeKeyImageV4Activation() uint64 { + r.mu.RLock() + defer r.mu.RUnlock() + return r.stakeKeyImageActivation +} + // NewValidatorRegistry creates an empty registry. func NewValidatorRegistry() *ValidatorRegistry { return &ValidatorRegistry{ @@ -623,7 +793,7 @@ func (r *ValidatorRegistry) validateWithdrawalV2Locked(a StakeWithdrawalAuthoriz if a.StakeRef != wantRef { return nil, fmt.Errorf("stake withdrawal v2: current stake reference mismatch") } -if !r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { + if !r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { return nil, fmt.Errorf("stake withdrawal v2: invalid validator signature") } return entry, nil @@ -666,6 +836,9 @@ func (r *ValidatorRegistry) ProcessStakeTx(tx Transaction, height uint64) error if tx.Version != TxVersionStake { return fmt.Errorf("registry: not a stake tx (version=%d)", tx.Version) } + r.mu.RLock() + keyImageActivation := r.stakeKeyImageActivation + r.mu.RUnlock() switch len(tx.Extra) { @@ -685,7 +858,7 @@ func (r *ValidatorRegistry) ProcessStakeTx(tx Transaction, height uint64) error return fmt.Errorf("registry: legacy stake withdrawal disabled after attested v2 activation") } msg := StakeSignMsg(action, pub, amount) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature from %s", pub.ID()) } r.mu.Lock() @@ -715,6 +888,9 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return fmt.Errorf("registry: %w", err) } + if keyImageActivation != 0 && height >= keyImageActivation { + return fmt.Errorf("registry: v2 stake deposits disabled after key-image proof activation") + } if action != StakeDeposit { return fmt.Errorf("registry: v2 payload (173 bytes) is only valid for StakeDeposit, got action=%d", action) } @@ -722,7 +898,7 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { // Verify self-signature — binds the deposit to a specific burn UTXO, // preventing replay attacks with a different UTXO at the same amount. msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v2) from %s", pub.ID()) } @@ -772,13 +948,16 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return fmt.Errorf("registry: %w", err) } + if keyImageActivation != 0 && height >= keyImageActivation { + return fmt.Errorf("registry: v3 stake deposits disabled after key-image proof activation") + } if action != StakeDeposit { return fmt.Errorf("registry: v3 payload (237 bytes) is only valid for StakeDeposit, got action=%d", action) } // Verify validator self-signature (same signing msg as v2). msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v3) from %s", pub.ID()) } @@ -825,9 +1004,24 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { defer r.mu.Unlock() return r.applyDeposit(pub.Hex(), pub, amount, height) + case StakePayloadSizeV4: + action, pub, amount, sig, burnTxHash, burnOutIdx, burnBlind, keyImage, proof, err := DecodeStakeExtraV4(tx.Extra) + if err != nil { + return fmt.Errorf("registry: %w", err) + } + if _, err := r.validateStakeDepositV4(height, keyImageActivation, action, pub, amount, sig, + burnTxHash, burnOutIdx, burnBlind, keyImage, proof, true); err != nil { + return fmt.Errorf("registry: %w", err) + } + if err := r.utxos.MarkStaked(burnTxHash, burnOutIdx); err != nil { + return fmt.Errorf("registry: failed to burn stake UTXO: %w", err) + } + r.mu.Lock() + defer r.mu.Unlock() + return r.applyDeposit(pub.Hex(), pub, amount, height) + default: - return fmt.Errorf("registry: invalid stake extra length %d (expected %d for withdraw, %d or %d for deposit)", - len(tx.Extra), StakePayloadSize, StakePayloadSizeV2, StakePayloadSizeV3) + return fmt.Errorf("registry: invalid stake extra length %d", len(tx.Extra)) } } @@ -869,7 +1063,7 @@ func (r *ValidatorRegistry) applyOneTxLocked(tx Transaction, height uint64) (*UT return nil, fmt.Errorf("legacy stake withdrawal disabled after attested v2 activation") } msg := StakeSignMsg(action, pub, amount) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return nil, fmt.Errorf("invalid stake signature from %s", pub.ID()) } key := pub.Hex() @@ -894,11 +1088,14 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return nil, err } + if r.stakeKeyImageActivation != 0 && height >= r.stakeKeyImageActivation { + return nil, fmt.Errorf("v2 stake deposits disabled after key-image proof activation") + } if action != StakeDeposit { return nil, fmt.Errorf("v2 payload only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return nil, fmt.Errorf("invalid v2 stake signature from %s", pub.ID()) } if r.utxos == nil { @@ -941,11 +1138,14 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return nil, err } + if r.stakeKeyImageActivation != 0 && height >= r.stakeKeyImageActivation { + return nil, fmt.Errorf("v3 stake deposits disabled after key-image proof activation") + } if action != StakeDeposit { return nil, fmt.Errorf("v3 payload only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return nil, fmt.Errorf("invalid v3 stake signature from %s", pub.ID()) } if r.utxos == nil { @@ -982,9 +1182,28 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { k := UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx} return &k, nil + case StakePayloadSizeV4: + action, pub, amount, sig, burnTxHash, burnOutIdx, burnBlind, keyImage, proof, err := DecodeStakeExtraV4(tx.Extra) + if err != nil { + return nil, err + } + if _, err := r.validateStakeDepositV4(height, r.stakeKeyImageActivation, action, pub, amount, sig, + burnTxHash, burnOutIdx, burnBlind, keyImage, proof, false); err != nil { + return nil, err + } + if err := r.utxos.MarkStaked(burnTxHash, burnOutIdx); err != nil { + return nil, fmt.Errorf("MarkStaked: %w", err) + } + key := pub.Hex() + if applyErr := r.applyDeposit(key, pub, amount, height); applyErr != nil { + r.utxos.UnmarkStaked(burnTxHash, burnOutIdx) + return nil, applyErr + } + k := UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx} + return &k, nil + default: - return nil, fmt.Errorf("invalid stake extra length %d (expected %d, %d, or %d)", - len(tx.Extra), StakePayloadSize, StakePayloadSizeV2, StakePayloadSizeV3) + return nil, fmt.Errorf("invalid stake extra length %d", len(tx.Extra)) } } @@ -1097,6 +1316,7 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint snapshot[k] = shadowEntry{stakeNAPR: v.StakeNAPR, status: v.Status, generation: v.StakeGeneration, nonce: v.StakeAuthNonce, seeded: v.Seeded} } withdrawalGenesis, withdrawalActivation := r.stakeWithdrawalGenesis, r.stakeWithdrawalActivation + keyImageActivation := r.stakeKeyImageActivation r.mu.RUnlock() // Per-block reservation: burn UTXOs claimed by earlier txs in this block. @@ -1105,6 +1325,18 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint OutIdx uint32 } reserved := make(map[utxoKey]int) // key → first tx index that reserved it + reservedKeyImages := make(map[crypto.KeyImage]int) + blockInputKeyImages := make(map[crypto.KeyImage]int) + for txIdx, tx := range txs { + if tx.IsStake() { + continue + } + for _, input := range tx.Inputs { + if canonical, err := crypto.CanonicalKeyImage(input.KeyImage); err == nil { + blockInputKeyImages[canonical] = txIdx + } + } + } for i, tx := range txs { if !tx.IsStake() { @@ -1129,7 +1361,7 @@ func (r *ValidatorRegistry) ValidateBlockStakeTxs(txs []Transaction, height uint return fmt.Errorf("stake tx[%d]: legacy withdrawal disabled after attested v2 activation", i) } msg := StakeSignMsg(action, pub, amount) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("stake tx[%d]: invalid signature from %s", i, pub.ID()) } key := pub.Hex() @@ -1175,7 +1407,7 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { } if withdrawalActivation == 0 || height < withdrawalActivation || a.Genesis != withdrawalGenesis || a.ExpiryHeight < height || a.ExpiryHeight-height > StakeWithdrawalMaxExpiryBlocks || -!r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { + !r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { return fmt.Errorf("stake tx[%d]: invalid, expired, cross-chain, or pre-activation v2 withdrawal", i) } key := a.PubKey.Hex() @@ -1209,11 +1441,14 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return fmt.Errorf("stake tx[%d]: decode v2: %w", i, err) } + if keyImageActivation != 0 && height >= keyImageActivation { + return fmt.Errorf("stake tx[%d]: v2 deposits disabled after key-image proof activation", i) + } if action != StakeDeposit { return fmt.Errorf("stake tx[%d]: v2 payload only valid for StakeDeposit, got action=%d", i, action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("stake tx[%d]: invalid v2 signature from %s", i, pub.ID()) } // Within-block duplicate burn UTXO. @@ -1284,11 +1519,14 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return fmt.Errorf("stake tx[%d]: decode v3: %w", i, err) } + if keyImageActivation != 0 && height >= keyImageActivation { + return fmt.Errorf("stake tx[%d]: v3 deposits disabled after key-image proof activation", i) + } if action != StakeDeposit { return fmt.Errorf("stake tx[%d]: v3 payload only valid for StakeDeposit, got action=%d", i, action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("stake tx[%d]: invalid v3 signature from %s", i, pub.ID()) } // Within-block duplicate burn UTXO. @@ -1354,6 +1592,65 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { reserved[uk] = i snapshot[key] = se + case StakePayloadSizeV4: + action, pub, amount, sig, burnTxHash, burnOutIdx, burnBlind, keyImage, proof, err := DecodeStakeExtraV4(tx.Extra) + if err != nil { + return fmt.Errorf("stake tx[%d]: decode v4: %w", i, err) + } + burnUTXO, err := r.validateStakeDepositV4(height, keyImageActivation, action, pub, amount, sig, + burnTxHash, burnOutIdx, burnBlind, keyImage, proof, true) + if err != nil { + return fmt.Errorf("stake tx[%d]: %w", i, err) + } + uk := utxoKey{TxHash: burnTxHash, OutIdx: burnOutIdx} + if firstIdx, dup := reserved[uk]; dup { + return fmt.Errorf("stake tx[%d]: burn UTXO %x:%d already claimed by tx[%d] in this block", + i, burnTxHash[:8], burnOutIdx, firstIdx) + } + canonical, err := crypto.CanonicalKeyImage(keyImage) + if err != nil { + return fmt.Errorf("stake tx[%d]: invalid v4 key image: %w", i, err) + } + if inputIdx, dup := blockInputKeyImages[canonical]; dup { + return fmt.Errorf("stake tx[%d]: collateral key image is also spent by tx[%d] in this block", i, inputIdx) + } + if firstIdx, dup := reservedKeyImages[canonical]; dup { + return fmt.Errorf("stake tx[%d]: collateral key image already reserved by stake tx[%d]", i, firstIdx) + } + if burnUTXO == nil { + return fmt.Errorf("stake tx[%d]: v4 collateral UTXO unavailable", i) + } + if amount < effectiveMin { + return fmt.Errorf("stake tx[%d]: deposit %.4f APRO < minimum %.4f APRO", + i, float64(amount)/float64(BaseUnitsPerAPR), float64(effectiveMin)/float64(BaseUnitsPerAPR)) + } + if amount > MaxStakeNAPR { + return fmt.Errorf("stake tx[%d]: deposit %.4f APRO exceeds cap %.4f APRO", + i, float64(amount)/float64(BaseUnitsPerAPR), float64(MaxStakeNAPR)/float64(BaseUnitsPerAPR)) + } + key := pub.Hex() + se, existing := snapshot[key] + if existing { + switch se.status { + case ValidatorUnbonding, ValidatorExited: + return fmt.Errorf("stake tx[%d]: validator is unbonding/exited; cannot stake", i) + default: + if se.stakeNAPR > math.MaxUint64-amount { + return fmt.Errorf("stake tx[%d]: stake top-up overflow", i) + } + if se.generation == ^uint64(0) { + return fmt.Errorf("stake tx[%d]: collateral generation exhausted", i) + } + se.stakeNAPR += amount + se.generation++ + } + } else { + se = shadowEntry{stakeNAPR: amount, status: ValidatorPending, generation: 1} + } + reserved[uk] = i + reservedKeyImages[canonical] = i + snapshot[key] = se + default: return fmt.Errorf("stake tx[%d]: invalid extra length %d", i, len(tx.Extra)) } @@ -1374,7 +1671,10 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { // - Pedersen commitment match: Commit(amount, blind) == UTXO.AmountCommit (C-1) // - For v3: one-time-key ownership proof verified (F-049 fix) func (r *ValidatorRegistry) ValidateStakeTx(tx Transaction) error { -height := r.signatureAdmissionHeight.Load() + height := r.signatureAdmissionHeight.Load() + r.mu.RLock() + keyImageActivation := r.stakeKeyImageActivation + r.mu.RUnlock() if tx.Version != TxVersionStake { return fmt.Errorf("registry: not a stake tx (version=%d)", tx.Version) } @@ -1389,7 +1689,7 @@ height := r.signatureAdmissionHeight.Load() return fmt.Errorf("registry: StakeDeposit requires v2 payload (173 bytes) with UTXO burn proof — v1 deposits no longer accepted (C-1 fix)") } msg := StakeSignMsg(action, pub, amount) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature from %s", pub.ID()) } return nil @@ -1403,7 +1703,7 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { genesis := r.stakeWithdrawalGenesis enabled := r.stakeWithdrawalActivation != 0 r.mu.RUnlock() -if !enabled || a.Genesis != genesis || !r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { + if !enabled || a.Genesis != genesis || !r.verifyValidatorSignature(a.PubKey, height, StakeWithdrawalSignMsgV2(a), a.Signature) { return fmt.Errorf("registry: invalid or unconfigured stake withdrawal v2 authorization") } return nil @@ -1413,11 +1713,14 @@ if !enabled || a.Genesis != genesis || !r.verifyValidatorSignature(a.PubKey, hei if err != nil { return fmt.Errorf("registry: %w", err) } + if keyImageActivation != 0 && height >= keyImageActivation { + return fmt.Errorf("registry: v2 stake deposits disabled after key-image proof activation") + } if action != StakeDeposit { return fmt.Errorf("registry: v2 payload (173 bytes) is only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v2) from %s", pub.ID()) } if r.utxos == nil { @@ -1450,11 +1753,14 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return fmt.Errorf("registry: %w", err) } + if keyImageActivation != 0 && height >= keyImageActivation { + return fmt.Errorf("registry: v3 stake deposits disabled after key-image proof activation") + } if action != StakeDeposit { return fmt.Errorf("registry: v3 payload (237 bytes) is only valid for StakeDeposit, got action=%d", action) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("registry: invalid stake signature (v3) from %s", pub.ID()) } if r.utxos == nil { @@ -1485,9 +1791,19 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { } return nil + case StakePayloadSizeV4: + action, pub, amount, sig, burnTxHash, burnOutIdx, burnBlind, keyImage, proof, err := DecodeStakeExtraV4(tx.Extra) + if err != nil { + return fmt.Errorf("registry: %w", err) + } + if _, err := r.validateStakeDepositV4(height, keyImageActivation, action, pub, amount, sig, + burnTxHash, burnOutIdx, burnBlind, keyImage, proof, true); err != nil { + return fmt.Errorf("registry: %w", err) + } + return nil + default: - return fmt.Errorf("registry: invalid stake extra length %d (expected %d for withdraw, %d or %d for deposit)", - len(tx.Extra), StakePayloadSize, StakePayloadSizeV2, StakePayloadSizeV3) + return fmt.Errorf("registry: invalid stake extra length %d", len(tx.Extra)) } } @@ -1539,12 +1855,15 @@ func (r *ValidatorRegistry) replayOneTxLocked(tx Transaction, height uint64) err if err != nil { return fmt.Errorf("replay: %w", err) } + if action == StakeDeposit && r.stakeKeyImageActivation != 0 && height >= r.stakeKeyImageActivation { + return fmt.Errorf("replay: v1 stake deposit after key-image proof activation at height %d", height) + } if r.stakeWithdrawalActivation != 0 && height >= r.stakeWithdrawalActivation && (action == StakeWithdraw || action == StakePartialWithdraw) { return fmt.Errorf("replay: legacy withdrawal after attested v2 activation at height %d", height) } msg := StakeSignMsg(action, pub, amount) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("replay: invalid stake sig from %s at height %d", pub.ID(), height) } key := pub.Hex() @@ -1576,11 +1895,14 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return fmt.Errorf("replay: %w", err) } + if r.stakeKeyImageActivation != 0 && height >= r.stakeKeyImageActivation { + return fmt.Errorf("replay: v2 stake deposit after key-image proof activation at height %d", height) + } if action != StakeDeposit { return fmt.Errorf("replay: v2 payload with non-deposit action=%d at height %d", action, height) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("replay: invalid v2 stake sig from %s at height %d", pub.ID(), height) } // Reconstruct the burn-UTXO descriptor from payload data so MarkStakedKnown @@ -1606,11 +1928,14 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { if err != nil { return fmt.Errorf("replay: %w", err) } + if r.stakeKeyImageActivation != 0 && height >= r.stakeKeyImageActivation { + return fmt.Errorf("replay: v3 stake deposit after key-image proof activation at height %d", height) + } if action != StakeDeposit { return fmt.Errorf("replay: v3 payload with non-deposit action=%d at height %d", action, height) } msg := StakeSignMsgV2(action, pub, amount, burnTxHash, burnOutIdx) -if !r.verifyValidatorSignature(pub, height, msg, sig) { + if !r.verifyValidatorSignature(pub, height, msg, sig) { return fmt.Errorf("replay: invalid v3 stake sig from %s at height %d", pub.ID(), height) } expectedCommit, _ := crypto.Commit(amount, burnBlind) @@ -1625,6 +1950,21 @@ if !r.verifyValidatorSignature(pub, height, msg, sig) { key := pub.Hex() return r.applyDeposit(key, pub, amount, height) + case StakePayloadSizeV4: + action, pub, amount, sig, burnTxHash, burnOutIdx, burnBlind, keyImage, proof, err := DecodeStakeExtraV4(tx.Extra) + if err != nil { + return fmt.Errorf("replay: %w", err) + } + burnUTXO, err := r.validateStakeDepositV4(height, r.stakeKeyImageActivation, action, pub, amount, sig, + burnTxHash, burnOutIdx, burnBlind, keyImage, proof, false) + if err != nil { + return fmt.Errorf("replay: %w", err) + } + if r.utxos != nil { + r.utxos.MarkStakedKnown(burnTxHash, burnOutIdx, burnUTXO) + } + return r.applyDeposit(pub.Hex(), pub, amount, height) + default: return fmt.Errorf("replay: invalid stake extra length %d at height %d", len(tx.Extra), height) } @@ -1767,7 +2107,7 @@ func (r *ValidatorRegistry) applyPartialWithdraw(key string, amount, height uint } // UpdateEpoch is called at every epoch boundary (height % EpochLength == 0). -// It finalises unbonding validators, activates the top-staked pending ones +// It finalizes unbonding validators, activates the top-staked pending ones // (up to ChurnLimit), and rebalances the active set to MaxValidators. // Returns the new active validator set. func (r *ValidatorRegistry) UpdateEpoch(height uint64) []crypto.ValidatorPubKey { diff --git a/core/staking_test.go b/core/staking_test.go index 2925ad4..8f5bf5b 100644 --- a/core/staking_test.go +++ b/core/staking_test.go @@ -247,7 +247,7 @@ func TestReplayBlockStakeTxs_RestartSafety(t *testing.T) { // Idempotency: a second replay of the same block must not fail or double-count. if replayErr2 := reg2.ReplayBlockStakeTxs([]core.Transaction{stakeTx}, 1); replayErr2 != nil { // Second replay may fail because applyDeposit would see the validator already exists - // with the same stake amount — that's expected behaviour (top-up path). + // with the same stake amount — that's expected behavior (top-up path). t.Logf("second replay returned error (expected): %v", replayErr2) } // Regardless, burn UTXO must still be in staked set. @@ -333,8 +333,8 @@ func TestRestartSafety_UTXORebuildAndStakeReplay(t *testing.T) { reg1 := core.NewValidatorRegistry() reg1.SetUTXOSet(utxos1) - utxos1.ApplyBlock(genesisBlock) // unrelated UTXO added - utxos1.ApplyBlock(mintBlock) // burn UTXO added to active set + utxos1.ApplyBlock(genesisBlock) // unrelated UTXO added + utxos1.ApplyBlock(mintBlock) // burn UTXO added to active set reg1.ApplyBlockStakeTxs(stakeBlock.Txs, 2) // moves burn UTXO to staked set // Sanity check first run. diff --git a/core/stealth_crosslang_test.go b/core/stealth_crosslang_test.go index a1e9897..ef83ebf 100644 --- a/core/stealth_crosslang_test.go +++ b/core/stealth_crosslang_test.go @@ -90,9 +90,9 @@ func TestStealthScannerCrossLangVector(t *testing.T) { // ── Derive and log the vector (deterministic across runs) ───────────────── - spendPubHex := hex.EncodeToString(wk.Spend.Public[:]) - viewPrivHex := hex.EncodeToString(wk.View.Private[:]) - txPubKeyHex := hex.EncodeToString(stealthOut.TxPubKey[:]) + spendPubHex := hex.EncodeToString(wk.Spend.Public[:]) + viewPrivHex := hex.EncodeToString(wk.View.Private[:]) + txPubKeyHex := hex.EncodeToString(stealthOut.TxPubKey[:]) oneTimePubHex := hex.EncodeToString(stealthOut.OneTimePub[:]) encAmountHex := hex.EncodeToString(encAmount[:]) diff --git a/core/sync_test.go b/core/sync_test.go index 4bac49a..1b96a29 100644 --- a/core/sync_test.go +++ b/core/sync_test.go @@ -130,7 +130,7 @@ func TestSync_ThreeNodes_FiveBlocks(t *testing.T) { // Mine 5 blocks on A, broadcast to B and C manually const numBlocks = 5 blocks := make([]*core.Block, numBlocks) - var prev *core.Block = genesis + var prev = genesis for i := range numBlocks { b := buildBlock(t, prev, validatorPriv, validatorPub, uint64(i+1)) if err := chainA.AddBlock(b); err != nil { @@ -792,7 +792,7 @@ func TestSync_KeepalivePong_RetriggersHeaderSync(t *testing.T) { // 5. Relay performs a fresh GetHeaders sync and reaches the final validator height. // // This is the regression guard for the reconnect-and-resync path: a stall or -// wrong-fork ban that prevents re-dialling would leave the relay permanently +// wrong-fork ban that prevents re-dialing would leave the relay permanently // behind the main chain. func TestSync_RelayNode_ReconnectAfterDrop(t *testing.T) { log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) @@ -1062,7 +1062,7 @@ func TestSync_RelayNode_BlockMinedDuringReconnectHandshake(t *testing.T) { t.Cleanup(func() { client.Close(); server.Close() }) go io.Copy(client, server) //nolint:errcheck // validator→relay flows freely go func() { - <-gate // hold relay→validator until the block below is mined + <-gate // hold relay→validator until the block below is mined io.Copy(server, client) //nolint:errcheck }() }() @@ -1253,8 +1253,8 @@ func TestSync_RelayNode_MidStreamValidatorRestart(t *testing.T) { // returns normally) but the data is dropped, not forwarded. defer client.Close() // ensures relay sees EOF when the goroutine exits (after server FIN from Stop, // or on test cleanup). - gate := make(chan struct{}) // close to enter intercept mode - stalled := make(chan struct{}) // proxy ack: intercept mode is active + gate := make(chan struct{}) // close to enter intercept mode + stalled := make(chan struct{}) // proxy ack: intercept mode is active interceptedFirst := make(chan struct{}) // proxy ack: read ≥1 chunk mid-stream proxyLn, err := net.Listen("tcp", "127.0.0.1:0") @@ -1287,8 +1287,8 @@ func TestSync_RelayNode_MidStreamValidatorRestart(t *testing.T) { defer client.Close() defer server.Close() buf := make([]byte, 4096) - stalledOnce := sync.Once{} - interceptedOnce := sync.Once{} + stalledOnce := sync.Once{} + interceptedOnce := sync.Once{} for { select { case <-gate: @@ -1431,9 +1431,9 @@ func TestSync_RelayNode_MidStreamValidatorRestart(t *testing.T) { // until its goroutine exits, which happens when Stop closes the server conn). // proxyClient.Close() is called explicitly to ensure EOF reaches the relay // promptly regardless of proxy goroutine scheduling. - firstSent := make(chan struct{}) - proceedCh := make(chan struct{}) - broadcastDone := make(chan struct{}) + firstSent := make(chan struct{}) + proceedCh := make(chan struct{}) + broadcastDone := make(chan struct{}) postStopBroadcasts := 0 // written only in goroutine, read after <-broadcastDone go func() { diff --git a/core/transaction.go b/core/transaction.go index 9ee6add..6624dc3 100644 --- a/core/transaction.go +++ b/core/transaction.go @@ -259,12 +259,13 @@ func (tx *Transaction) Validate() error { // // v1 payload (105 bytes): StakeWithdraw / StakePartialWithdraw // v2 payload (173 bytes): StakeDeposit with UTXO burn proof (C-1 fix) - // v3 payload (237 bytes): StakeDeposit with UTXO burn proof + one-time-key ownership proof (F-049 fix) + // v3 payload (237 bytes): StakeDeposit with one-time-key ownership proof + // v4 payload (301 bytes): StakeDeposit with a key-image DLEQ proof if tx.IsStake() { extraLen := len(tx.Extra) - if extraLen != StakePayloadSize && extraLen != StakeWithdrawalPayloadSizeV2 && extraLen != StakePayloadSizeV2 && extraLen != StakePayloadSizeV3 { - return fmt.Errorf("stake tx: extra must be %d bytes (withdraw) or %d bytes (deposit), got %d", - StakePayloadSize, StakePayloadSizeV2, extraLen) + if extraLen != StakePayloadSize && extraLen != StakeWithdrawalPayloadSizeV2 && extraLen != StakePayloadSizeV2 && extraLen != StakePayloadSizeV3 && extraLen != StakePayloadSizeV4 { + return fmt.Errorf("stake tx: extra must be %d bytes (withdraw) or %d, %d, or %d bytes (deposit), got %d", + StakePayloadSize, StakePayloadSizeV2, StakePayloadSizeV3, StakePayloadSizeV4, extraLen) } action := StakeAction(tx.Extra[0]) if action != StakeDeposit && action != StakeWithdraw && action != StakePartialWithdraw { diff --git a/core/transaction_version_test.go b/core/transaction_version_test.go index d592d73..434e479 100644 --- a/core/transaction_version_test.go +++ b/core/transaction_version_test.go @@ -74,4 +74,4 @@ func TestMinFeeAtSaturatesOnOverflow(t *testing.T) { if got := tx.MinFeeAt(^uint64(0)); got != ^uint64(0) { t.Fatalf("overflowing minimum fee wrapped to %d", got) } -} \ No newline at end of file +} diff --git a/core/tx_builder.go b/core/tx_builder.go index 4006b65..d1653c7 100644 --- a/core/tx_builder.go +++ b/core/tx_builder.go @@ -6,8 +6,10 @@ package core import ( + cryptorand "crypto/rand" "fmt" "io" + "math/big" "sort" "github.com/aperod/aperod/crypto" @@ -250,7 +252,7 @@ func (b *TxBuilder) Build(amount uint64, recipient, changeAddr crypto.Address) ( available = uniq } - // ── UTXO selection: iterate until fee estimate stabilises ──────────────── + // ── UTXO selection: iterate until fee estimate stabilizes ──────────────── // Fee = estimated_tx_size_bytes × feePerByte. // We iterate: pick UTXOs → estimate size → recalculate fee → check again. // In practice this converges in ≤2 iterations because adding one UTXO @@ -750,8 +752,9 @@ func txBuildOutput(addr crypto.Address, amount uint64) (Output, crypto.BlindFact }, blind, nil } -// txBuildRing assembles a ring of RingSize with the real key at a deterministic -// position derived from the key bytes (so signing is consistent across retries). +// txBuildRing assembles a ring of RingSize with the real key at an independently +// randomized position. The real position is private signing state, not a +// function of the public key. // // Phase 2 (decoys provided): decoy slots use real on-chain UTXOs from the supplied // slice. Each DecoyUTXO contributes its OneTimePub to the ring; the caller must @@ -764,7 +767,21 @@ func txBuildOutput(addr crypto.Address, amount uint64) (Output, crypto.BlindFact // of slots that could not be filled with a real decoy and used a random key instead. // A non-zero fallbackCount means privacy is degraded for this ring. func txBuildRing(realPub crypto.Point32, decoys []DecoyUTXO) ([]crypto.RingMember, int, int, error) { - realIdx := int(realPub[0]) % crypto.RingSize + return txBuildRingWithEntropy(realPub, decoys, cryptorand.Reader) +} + +// txBuildRingWithEntropy makes real-position selection testable without +// weakening production randomness. crypto/rand.Int uses rejection sampling, +// so each slot is equally likely; entropy errors fail closed. +func txBuildRingWithEntropy(realPub crypto.Point32, decoys []DecoyUTXO, entropy io.Reader) ([]crypto.RingMember, int, int, error) { + if entropy == nil { + return nil, 0, 0, fmt.Errorf("select real ring position: cryptographic entropy is required") + } + position, err := cryptorand.Int(entropy, big.NewInt(int64(crypto.RingSize))) + if err != nil { + return nil, 0, 0, fmt.Errorf("select real ring position: %w", err) + } + realIdx := int(position.Int64()) ring := make([]crypto.RingMember, crypto.RingSize) seen := map[crypto.Point32]struct{}{realPub: {}} @@ -866,13 +883,13 @@ func (b *TxBuilder) MaxSpendable() MaxSpendableResult { prefixSums[k] = prefix } for k := 1; k <= limit; k++ { -fee := txEstimateFeeVersion(b.txVersion, k, 2, b.feePerByte) + fee := txEstimateFeeVersion(b.txVersion, k, 2, b.feePerByte) if prefixSums[k] <= fee { continue } amount := prefixSums[k] - fee -selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte, b.txVersion) -finalFee := txEstimateFeeVersion(b.txVersion, selCount, 2, b.feePerByte) + selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte, b.txVersion) + finalFee := txEstimateFeeVersion(b.txVersion, selCount, 2, b.feePerByte) if selTotal >= amount+finalFee && amount > res.MaxAmount { res.MaxAmount = amount res.Fee = finalFee @@ -931,15 +948,15 @@ func (b *TxBuilder) EstimateFeeForAmount(amount uint64) FeeEstimateResult { res.SpendableTotal += u.Amount } -selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte, b.txVersion) + selCount, selTotal := simulateGreedySelection(available, amount, b.feePerByte, b.txVersion) if selCount < 1 { selCount = 1 // a tx always has ≥1 input } -fee := txEstimateFeeVersion(b.txVersion, selCount, 2, b.feePerByte) + fee := txEstimateFeeVersion(b.txVersion, selCount, 2, b.feePerByte) res.Fee = fee res.InputCount = selCount -res.TxSizeBytes = txEstimatedSize(b.txVersion, selCount, 2) -res.Sufficient = amount <= ^uint64(0)-fee && selTotal >= amount+fee + res.TxSizeBytes = txEstimatedSize(b.txVersion, selCount, 2) + res.Sufficient = amount <= ^uint64(0)-fee && selTotal >= amount+fee return res } @@ -949,12 +966,12 @@ res.Sufficient = amount <= ^uint64(0)-fee && selTotal >= amount+fee // deduplicated by OneTimePub. func simulateGreedySelection(available []OwnedUTXO, amount, feePerByte uint64, version TxVersion) (int, uint64) { const maxInputs = 8 -fee := txEstimateFeeVersion(version, 1, 2, feePerByte) -// Build rejects an amount that overflows once the initial fee is added. -if amount > ^uint64(0)-fee { -return 0, 0 -} -needed := amount + fee + fee := txEstimateFeeVersion(version, 1, 2, feePerByte) + // Build rejects an amount that overflows once the initial fee is added. + if amount > ^uint64(0)-fee { + return 0, 0 + } + needed := amount + fee var ( count int totalIn uint64 diff --git a/core/tx_builder_fallback_test.go b/core/tx_builder_fallback_test.go index d1c799f..2074464 100644 --- a/core/tx_builder_fallback_test.go +++ b/core/tx_builder_fallback_test.go @@ -1,6 +1,6 @@ package core -// Internal unit tests for txBuildRing fallback-decoy behaviour. +// Internal unit tests for txBuildRing fallback-decoy behavior. // These tests live in package core (not core_test) so they can access // the unexported txBuildRing function directly. @@ -61,7 +61,7 @@ func TestTxBuildRing_FallbackCount_FewerDecoysThanNeeded(t *testing.T) { fallbackCount, nDecoys, crypto.RingSize-1) } -// TestTxBuildRing_FallbackCount_NoDecoys verifies Phase 1 behaviour: when no +// TestTxBuildRing_FallbackCount_NoDecoys verifies Phase 1 behavior: when no // decoys are provided at all, every ring slot (all RingSize-1 of them) must // fall back to randomly-generated keys. func TestTxBuildRing_FallbackCount_NoDecoys(t *testing.T) { diff --git a/core/tx_builder_ring_duplicates_test.go b/core/tx_builder_ring_duplicates_test.go new file mode 100644 index 0000000..6d30b0f --- /dev/null +++ b/core/tx_builder_ring_duplicates_test.go @@ -0,0 +1,176 @@ +package core + +import ( + "bytes" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/aperod/aperod/crypto" +) + +// Exercise pure ring placement only: generated curve points, no transaction +// construction, range proof, ring signature, broadcast or canonical-chain claim. +func TestRingPlacementDistinctDecoys(t *testing.T) { + point := func(t *testing.T) crypto.Point32 { + t.Helper() + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + return keys.Spend.Public + } + tests := []struct { + name string + edit func([]DecoyUTXO, crypto.Point32) []DecoyUTXO + fail bool + }{ + {"distinct members", func(d []DecoyUTXO, _ crypto.Point32) []DecoyUTXO { return d }, false}, + {"exact pair repeated", func(d []DecoyUTXO, _ crypto.Point32) []DecoyUTXO { d[1] = d[0]; return d }, true}, + {"same key conflicting commit", func(d []DecoyUTXO, _ crypto.Point32) []DecoyUTXO { d[1].OneTimePub = d[0].OneTimePub; return d }, true}, + {"real input repeated", func(d []DecoyUTXO, p crypto.Point32) []DecoyUTXO { d[1].OneTimePub = p; return d }, true}, + {"many aliases", func(d []DecoyUTXO, _ crypto.Point32) []DecoyUTXO { + aliases := make([]DecoyUTXO, 512) + for i := range aliases { + aliases[i] = d[i%3] + } + return aliases + }, true}, + {"shared commit distinct keys", func(d []DecoyUTXO, _ crypto.Point32) []DecoyUTXO { + for i := range d { + d[i].AmountCommit = d[0].AmountCommit + } + return d + }, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + real := point(t) + decoys := make([]DecoyUTXO, crypto.RingSize-1) + for i := range decoys { + decoys[i] = DecoyUTXO{OneTimePub: point(t), AmountCommit: crypto.Commitment(point(t))} + } + decoys = tc.edit(decoys, real) + before := append([]DecoyUTXO(nil), decoys...) + ring, index, fallback, err := txBuildRing(real, decoys) + if !reflect.DeepEqual(before, decoys) { + t.Fatal("ring placement mutated the supplied canonical pairs") + } + if tc.fail { + if err == nil || !strings.Contains(err.Error(), "duplicate decoy") || ring != nil { + t.Fatalf("duplicate accepted: ring=%v error=%v", ring, err) + } + return + } + if err != nil || len(ring) != crypto.RingSize || fallback != 0 || ring[index] != real { + t.Fatalf("invalid placement: size=%d fallback=%d error=%v", len(ring), fallback, err) + } + seen := make(map[crypto.Point32]bool) + for _, pub := range ring { + if seen[pub] { + t.Fatal("ring contains repeated public key") + } + seen[pub] = true + } + }) + } +} + +// Regression for the old public-key-derived placement rule. This intentionally +// does not construct transactions or signatures; it measures only synthetic +// public rings and the candidate set from the old public predicate. +func TestRandomizedRingPositionBreaksPublicKeyPredicate(t *testing.T) { + const trials = 256 + totalCandidates := 0 + minCandidates := crypto.RingSize + maxCandidates := 0 + truePositionMatches := 0 + positionCounts := make([]int, crypto.RingSize) + entropyBytes := make([]byte, trials) + for i := range entropyBytes { + entropyBytes[i] = byte(i % crypto.RingSize) + } + entropy := bytes.NewReader(entropyBytes) + realKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + for trial := 0; trial < trials; trial++ { + decoys := make([]DecoyUTXO, crypto.RingSize-1) + for i := range decoys { + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + decoys[i] = DecoyUTXO{OneTimePub: keys.Spend.Public} + } + ring, realIdx, _, err := txBuildRingWithEntropy(realKeys.Spend.Public, decoys, entropy) + if err != nil { + t.Fatal(err) + } + positionCounts[realIdx]++ + if realIdx == int(realKeys.Spend.Public[0])%crypto.RingSize { + truePositionMatches++ + } + candidates := 0 + for slot, member := range ring { + if int(member[0])%crypto.RingSize == slot { + candidates++ + } + } + totalCandidates += candidates + if candidates < minCandidates { + minCandidates = candidates + } + if candidates > maxCandidates { + maxCandidates = candidates + } + } + t.Logf("public predicate survivors across %d generated rings: mean %.2f, min %d, max %d; true-key relation %d/%d; positions %v; ring size %d", + trials, float64(totalCandidates)/trials, minCandidates, maxCandidates, truePositionMatches, trials, positionCounts, crypto.RingSize) + if truePositionMatches != trials/crypto.RingSize { + t.Fatalf("real-key position matched public predicate %d times, want %d for independent positions", truePositionMatches, trials/crypto.RingSize) + } + for index, count := range positionCounts { + if count != trials/crypto.RingSize { + t.Fatalf("position %d selected %d times; want %d", index, count, trials/crypto.RingSize) + } + } + if float64(totalCandidates)/trials >= 4 { + t.Fatalf("public predicate retained too many candidates on average: %.2f", float64(totalCandidates)/trials) + } +} + +func TestRingPositionSelectionFailsClosedOnEntropyError(t *testing.T) { + realKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + decoys := make([]DecoyUTXO, crypto.RingSize-1) + for i := range decoys { + keys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + decoys[i] = DecoyUTXO{OneTimePub: keys.Spend.Public} + } + for name, entropy := range map[string]io.Reader{ + "nil reader": nil, + "reader error": failingRingPositionEntropy{}, + } { + t.Run(name, func(t *testing.T) { + ring, _, _, err := txBuildRingWithEntropy(realKeys.Spend.Public, decoys, entropy) + if err == nil || ring != nil || !strings.Contains(err.Error(), "select real ring position") { + t.Fatalf("expected fail-closed entropy error, got ring=%v error=%v", ring, err) + } + }) + } +} + +type failingRingPositionEntropy struct{} + +func (failingRingPositionEntropy) Read([]byte) (int, error) { + return 0, errors.New("injected entropy failure") +} diff --git a/core/tx_builder_test.go b/core/tx_builder_test.go index fcf922f..bd878a5 100644 --- a/core/tx_builder_test.go +++ b/core/tx_builder_test.go @@ -8,237 +8,237 @@ package core_test // 4. Bob scans and finds his received output; change goes back to Alice. import ( - "testing" + "testing" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // makeGenesisWithCoinbase creates a minimal single-block chain where Alice // owns one coinbase output worth `supply` base units. func makeGenesisWithCoinbase(t *testing.T, aliceKeys *crypto.WalletKeyPair, supply uint64) (*core.Chain, *core.Block, crypto.BlindFactor) { - t.Helper() - - validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatal(err) - } - - net := crypto.TestnetByte - aliceAddr := crypto.AddressFromKeys(net, aliceKeys) - - _, aliceSpend, aliceView, err := crypto.DecodeAddress(aliceAddr) - if err != nil { - t.Fatal(err) - } - - // Create a stealth output for Alice - so, err := crypto.CreateStealthOutput(aliceSpend, aliceView) - if err != nil { - t.Fatal(err) - } - - blind, err := crypto.NewBlindFactor() - if err != nil { - t.Fatal(err) - } - commit, err := crypto.Commit(supply, blind) - if err != nil { - t.Fatal(err) - } - encAmount := core.EncryptAmount(supply, &so.HsScalar) - proof, err := crypto.ProveRange(supply, blind) - if err != nil { - t.Fatal(err) - } - - coinbase := core.Transaction{ - Version: 1, - Outputs: []core.Output{{ - OneTimePub: so.OneTimePub, - TxPubKey: so.TxPubKey, - AmountCommit: commit, - EncAmount: encAmount, - }}, - RangeProofs: []*crypto.RangeProof{proof}, - } - - txs := []core.Transaction{coinbase} - hdr := core.BlockHeader{ - Height: 0, - Timestamp: 1_000_000_000, - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(txs), - } - if err := hdr.Sign(validatorPriv); err != nil { - t.Fatal(err) - } - - genesis := &core.Block{Header: hdr, Txs: txs} - chain := core.NewChain() - if err := chain.SetGenesis(genesis); err != nil { - t.Fatal(err) - } - return chain, genesis, blind + t.Helper() + + validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + + net := crypto.TestnetByte + aliceAddr := crypto.AddressFromKeys(net, aliceKeys) + + _, aliceSpend, aliceView, err := crypto.DecodeAddress(aliceAddr) + if err != nil { + t.Fatal(err) + } + + // Create a stealth output for Alice + so, err := crypto.CreateStealthOutput(aliceSpend, aliceView) + if err != nil { + t.Fatal(err) + } + + blind, err := crypto.NewBlindFactor() + if err != nil { + t.Fatal(err) + } + commit, err := crypto.Commit(supply, blind) + if err != nil { + t.Fatal(err) + } + encAmount := core.EncryptAmount(supply, &so.HsScalar) + proof, err := crypto.ProveRange(supply, blind) + if err != nil { + t.Fatal(err) + } + + coinbase := core.Transaction{ + Version: 1, + Outputs: []core.Output{{ + OneTimePub: so.OneTimePub, + TxPubKey: so.TxPubKey, + AmountCommit: commit, + EncAmount: encAmount, + }}, + RangeProofs: []*crypto.RangeProof{proof}, + } + + txs := []core.Transaction{coinbase} + hdr := core.BlockHeader{ + Height: 0, + Timestamp: 1_000_000_000, + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(txs), + } + if err := hdr.Sign(validatorPriv); err != nil { + t.Fatal(err) + } + + genesis := &core.Block{Header: hdr, Txs: txs} + chain := core.NewChain() + if err := chain.SetGenesis(genesis); err != nil { + t.Fatal(err) + } + return chain, genesis, blind } // TestTxBuilder_AliceToBob exercises the full RingCT transaction lifecycle. func TestTxBuilder_AliceToBob(t *testing.T) { - const supply = 1_000_000_000 // 1 APRO = 1_000_000_000 nAPR - - // Generate Alice and Bob wallets - aliceKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - bobKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - - net := crypto.TestnetByte - aliceAddr := crypto.AddressFromKeys(net, aliceKeys) - bobAddr := crypto.AddressFromKeys(net, bobKeys) - - // Create a chain with Alice's coinbase UTXO - chain, _, inputBlind := makeGenesisWithCoinbase(t, aliceKeys, supply) - - // Alice scans her chain for owned outputs - scanner := core.NewWalletScanner( - aliceKeys.View.Private, - aliceKeys.Spend.Public, - aliceKeys.View.Public, - net, - ) - ownedByAlice := scanner.ScanChain(chain, 0, chain.Height()) - if len(ownedByAlice) == 0 { - t.Fatal("Alice found no outputs in her coinbase block") - } - ownedByAlice[0].Blind = inputBlind - t.Logf("Alice owns %d UTXOs, total balance: %d base units", - len(ownedByAlice), core.Balance(ownedByAlice)) - - // Verify Alice's balance - aliceBalance := core.Balance(ownedByAlice) - if aliceBalance != supply { - t.Fatalf("Alice balance = %d, want %d", aliceBalance, supply) - } - - // Build a transaction: Alice → Bob (30_000_000 base units, change back to Alice) - sendAmount := uint64(30_000_000) - builder := core.NewTxBuilder( - aliceKeys.Spend.Private, - aliceKeys.View.Private, - aliceKeys.Spend.Public, - ownedByAlice, - 1, // 1 nAPR/byte fee rate - ) - - result, err := builder.Build(sendAmount, bobAddr, aliceAddr) - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - t.Logf("Built tx: %d inputs, %d outputs, fee=%d, change=%d", - result.InputCount, result.OutputCount, result.TotalFee, result.ChangeAmount) - - // Verify change is correct: supply − sendAmount − fee - expectedChange := supply - sendAmount - result.TotalFee - if result.ChangeAmount != expectedChange { - t.Errorf("change = %d, want %d", result.ChangeAmount, expectedChange) - } - - // ── Structural validation ───────────────────────────────────────────────── - tx := result.Tx - if err := tx.Validate(); err != nil { - t.Fatalf("tx.Validate failed: %v", err) - } - if len(tx.Signatures) != len(tx.Inputs) { - t.Errorf("signature count mismatch: %d sigs for %d inputs", - len(tx.Signatures), len(tx.Inputs)) - } - for i, sig := range tx.Signatures { - if sig == nil { - t.Errorf("nil signature at input %d", i) - } - } - for i, rp := range tx.RangeProofs { - if rp == nil { - t.Errorf("nil range proof at output %d", i) - } - } - - // ── Bob scans a hypothetical block containing Alice's tx ───────────────── - // Wrap the tx in a block so the scanner can find Bob's output. - _, genesisBlock := chain.Tip().Header, chain.Tip() - _ = genesisBlock - - validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() - prevHash := chain.Tip().Hash() - txs := []core.Transaction{tx} - hdr := core.BlockHeader{ - Height: 1, - PrevHash: prevHash, - Timestamp: 1_000_001_000, - ValidatorPub: validatorPub, - MerkleRoot: core.MerkleRoot(txs), - } - _ = hdr.Sign(validatorPriv) - block1 := &core.Block{Header: hdr, Txs: txs} - - bobScanner := core.NewWalletScanner( - bobKeys.View.Private, - bobKeys.Spend.Public, - bobKeys.View.Public, - net, - ) - bobOutputs := bobScanner.ScanBlock(block1) - if len(bobOutputs) == 0 { - t.Fatal("Bob found no outputs — stealth scan failed") - } - bobReceived := core.Balance(bobOutputs) - if bobReceived != sendAmount { - t.Errorf("Bob received %d base units, want %d", bobReceived, sendAmount) - } - t.Logf("Bob received: %d base units", bobReceived) - - // Alice should also find her change output in block1 - aliceNewOutputs := scanner.ScanBlock(block1) - if result.ChangeAmount > 0 && len(aliceNewOutputs) == 0 { - t.Error("Alice found no change output") - } - aliceChange := core.Balance(aliceNewOutputs) - if result.ChangeAmount > 0 && aliceChange != result.ChangeAmount { - t.Errorf("Alice change = %d, want %d", aliceChange, result.ChangeAmount) - } - t.Logf("Alice change: %d base units", aliceChange) + const supply = 1_000_000_000 // 1 APRO = 1_000_000_000 nAPR + + // Generate Alice and Bob wallets + aliceKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + bobKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + + net := crypto.TestnetByte + aliceAddr := crypto.AddressFromKeys(net, aliceKeys) + bobAddr := crypto.AddressFromKeys(net, bobKeys) + + // Create a chain with Alice's coinbase UTXO + chain, _, inputBlind := makeGenesisWithCoinbase(t, aliceKeys, supply) + + // Alice scans her chain for owned outputs + scanner := core.NewWalletScanner( + aliceKeys.View.Private, + aliceKeys.Spend.Public, + aliceKeys.View.Public, + net, + ) + ownedByAlice := scanner.ScanChain(chain, 0, chain.Height()) + if len(ownedByAlice) == 0 { + t.Fatal("Alice found no outputs in her coinbase block") + } + ownedByAlice[0].Blind = inputBlind + t.Logf("Alice owns %d UTXOs, total balance: %d base units", + len(ownedByAlice), core.Balance(ownedByAlice)) + + // Verify Alice's balance + aliceBalance := core.Balance(ownedByAlice) + if aliceBalance != supply { + t.Fatalf("Alice balance = %d, want %d", aliceBalance, supply) + } + + // Build a transaction: Alice → Bob (30_000_000 base units, change back to Alice) + sendAmount := uint64(30_000_000) + builder := core.NewTxBuilder( + aliceKeys.Spend.Private, + aliceKeys.View.Private, + aliceKeys.Spend.Public, + ownedByAlice, + 1, // 1 nAPR/byte fee rate + ) + + result, err := builder.Build(sendAmount, bobAddr, aliceAddr) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + t.Logf("Built tx: %d inputs, %d outputs, fee=%d, change=%d", + result.InputCount, result.OutputCount, result.TotalFee, result.ChangeAmount) + + // Verify change is correct: supply − sendAmount − fee + expectedChange := supply - sendAmount - result.TotalFee + if result.ChangeAmount != expectedChange { + t.Errorf("change = %d, want %d", result.ChangeAmount, expectedChange) + } + + // ── Structural validation ───────────────────────────────────────────────── + tx := result.Tx + if err := tx.Validate(); err != nil { + t.Fatalf("tx.Validate failed: %v", err) + } + if len(tx.Signatures) != len(tx.Inputs) { + t.Errorf("signature count mismatch: %d sigs for %d inputs", + len(tx.Signatures), len(tx.Inputs)) + } + for i, sig := range tx.Signatures { + if sig == nil { + t.Errorf("nil signature at input %d", i) + } + } + for i, rp := range tx.RangeProofs { + if rp == nil { + t.Errorf("nil range proof at output %d", i) + } + } + + // ── Bob scans a hypothetical block containing Alice's tx ───────────────── + // Wrap the tx in a block so the scanner can find Bob's output. + _, genesisBlock := chain.Tip().Header, chain.Tip() + _ = genesisBlock + + validatorPriv, validatorPub, _ := crypto.GenerateValidatorKey() + prevHash := chain.Tip().Hash() + txs := []core.Transaction{tx} + hdr := core.BlockHeader{ + Height: 1, + PrevHash: prevHash, + Timestamp: 1_000_001_000, + ValidatorPub: validatorPub, + MerkleRoot: core.MerkleRoot(txs), + } + _ = hdr.Sign(validatorPriv) + block1 := &core.Block{Header: hdr, Txs: txs} + + bobScanner := core.NewWalletScanner( + bobKeys.View.Private, + bobKeys.Spend.Public, + bobKeys.View.Public, + net, + ) + bobOutputs := bobScanner.ScanBlock(block1) + if len(bobOutputs) == 0 { + t.Fatal("Bob found no outputs — stealth scan failed") + } + bobReceived := core.Balance(bobOutputs) + if bobReceived != sendAmount { + t.Errorf("Bob received %d base units, want %d", bobReceived, sendAmount) + } + t.Logf("Bob received: %d base units", bobReceived) + + // Alice should also find her change output in block1 + aliceNewOutputs := scanner.ScanBlock(block1) + if result.ChangeAmount > 0 && len(aliceNewOutputs) == 0 { + t.Error("Alice found no change output") + } + aliceChange := core.Balance(aliceNewOutputs) + if result.ChangeAmount > 0 && aliceChange != result.ChangeAmount { + t.Errorf("Alice change = %d, want %d", aliceChange, result.ChangeAmount) + } + t.Logf("Alice change: %d base units", aliceChange) } // TestTxBuilder_InsufficientFunds verifies that Build returns an error when // the sender does not have enough funds. func TestTxBuilder_InsufficientFunds(t *testing.T) { - aliceKeys, _ := crypto.GenerateWalletKeys() - bobKeys, _ := crypto.GenerateWalletKeys() - net := crypto.TestnetByte - bobAddr := crypto.AddressFromKeys(net, bobKeys) - aliceAddr := crypto.AddressFromKeys(net, aliceKeys) - - chain, _, _ := makeGenesisWithCoinbase(t, aliceKeys, 1000) - scanner := core.NewWalletScanner( - aliceKeys.View.Private, aliceKeys.Spend.Public, aliceKeys.View.Public, net, - ) - owned := scanner.ScanChain(chain, 0, chain.Height()) - - builder := core.NewTxBuilder( - aliceKeys.Spend.Private, aliceKeys.View.Private, - aliceKeys.Spend.Public, owned, 1, - ) - _, err := builder.Build(999_999_999, bobAddr, aliceAddr) - if err == nil { - t.Error("expected insufficient funds error, got nil") - } else { - t.Logf("Got expected error: %v", err) - } + aliceKeys, _ := crypto.GenerateWalletKeys() + bobKeys, _ := crypto.GenerateWalletKeys() + net := crypto.TestnetByte + bobAddr := crypto.AddressFromKeys(net, bobKeys) + aliceAddr := crypto.AddressFromKeys(net, aliceKeys) + + chain, _, _ := makeGenesisWithCoinbase(t, aliceKeys, 1000) + scanner := core.NewWalletScanner( + aliceKeys.View.Private, aliceKeys.Spend.Public, aliceKeys.View.Public, net, + ) + owned := scanner.ScanChain(chain, 0, chain.Height()) + + builder := core.NewTxBuilder( + aliceKeys.Spend.Private, aliceKeys.View.Private, + aliceKeys.Spend.Public, owned, 1, + ) + _, err := builder.Build(999_999_999, bobAddr, aliceAddr) + if err == nil { + t.Error("expected insufficient funds error, got nil") + } else { + t.Logf("Got expected error: %v", err) + } } // TestFeeEstimateMatchesTransactionSize asserts that ExportedEstimateFee(1,2,r)/r @@ -255,7 +255,7 @@ func TestFeeEstimateMatchesTransactionSize(t *testing.T) { // is fine because Transaction.Size() never dereferences field contents. ring := make([]crypto.RingMember, crypto.RingSize) tx := core.Transaction{ -Version: core.TxVersionCommitmentBinding, + Version: core.TxVersionCommitmentBinding, Inputs: []core.RingInput{ {Ring: ring}, }, @@ -263,10 +263,10 @@ Version: core.TxVersionCommitmentBinding, {}, // payment output {}, // change output }, -Signatures: []*crypto.MLSAGSignature{{ -BlindSS: make([][32]byte, crypto.RingSize), -ValueSS: make([][32]byte, crypto.RingSize), -}}, + Signatures: []*crypto.MLSAGSignature{{ + BlindSS: make([][32]byte, crypto.RingSize), + ValueSS: make([][32]byte, crypto.RingSize), + }}, RangeProofs: []*crypto.RangeProof{{}, {}}, } @@ -289,20 +289,20 @@ ValueSS: make([][32]byte, crypto.RingSize), // TestTxBuilder_ZeroAmount verifies that Build rejects a zero-amount transaction. func TestTxBuilder_ZeroAmount(t *testing.T) { - aliceKeys, _ := crypto.GenerateWalletKeys() - bobKeys, _ := crypto.GenerateWalletKeys() - net := crypto.TestnetByte - bobAddr := crypto.AddressFromKeys(net, bobKeys) - aliceAddr := crypto.AddressFromKeys(net, aliceKeys) - - builder := core.NewTxBuilder( - aliceKeys.Spend.Private, aliceKeys.View.Private, - aliceKeys.Spend.Public, nil, 1, - ) - _, err := builder.Build(0, bobAddr, aliceAddr) - if err == nil { - t.Error("expected error for zero amount") - } + aliceKeys, _ := crypto.GenerateWalletKeys() + bobKeys, _ := crypto.GenerateWalletKeys() + net := crypto.TestnetByte + bobAddr := crypto.AddressFromKeys(net, bobKeys) + aliceAddr := crypto.AddressFromKeys(net, aliceKeys) + + builder := core.NewTxBuilder( + aliceKeys.Spend.Private, aliceKeys.View.Private, + aliceKeys.Spend.Public, nil, 1, + ) + _, err := builder.Build(0, bobAddr, aliceAddr) + if err == nil { + t.Error("expected error for zero amount") + } } // TestTxBuilder_FallbackDecoyCount_SparseUTXOSet verifies that when a live @@ -315,66 +315,66 @@ func TestTxBuilder_ZeroAmount(t *testing.T) { // base units to Bob. Because only 1 input is selected and it needs RingSize-1 = 15 // decoy slots, exactly 15-3 = 12 slots must fall back to Phase 1 random keys. func TestTxBuilder_FallbackDecoyCount_SparseUTXOSet(t *testing.T) { - const supply = 1_000_000_000 // 1 APRO - - aliceKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - bobKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - net := crypto.TestnetByte - aliceAddr := crypto.AddressFromKeys(net, aliceKeys) - bobAddr := crypto.AddressFromKeys(net, bobKeys) - - // Create a single-block chain with Alice's coinbase UTXO. - chain, _, inputBlind := makeGenesisWithCoinbase(t, aliceKeys, supply) - scanner := core.NewWalletScanner( - aliceKeys.View.Private, aliceKeys.Spend.Public, aliceKeys.View.Public, net, - ) - ownedByAlice := scanner.ScanChain(chain, 0, chain.Height()) - if len(ownedByAlice) == 0 { - t.Fatal("Alice found no UTXOs") - } - ownedByAlice[0].Blind = inputBlind - - // Build a UTXOSet with only 3 spent decoys — far fewer than RingSize-1 = 15. - const nSpentDecoys = 3 - utxos := core.NewUTXOSet() - for i := 0; i < nSpentDecoys; i++ { - dk, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("GenerateWalletKeys decoy %d: %v", i, err) - } - utxos.AddSpentDecoyForTest(&core.UTXO{OneTimePub: dk.Spend.Public}) - } - - builder := core.NewTxBuilder( - aliceKeys.Spend.Private, aliceKeys.View.Private, - aliceKeys.Spend.Public, ownedByAlice, 1, - ).WithDecoySet(utxos) - - result, err := builder.Build(30_000_000, bobAddr, aliceAddr) - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - // 1 input × (RingSize-1) = 15 decoy slots total. - // Only nSpentDecoys=3 could be filled with real on-chain UTXOs. - wantRealDecoys := nSpentDecoys - wantFallback := (crypto.RingSize - 1) - nSpentDecoys - - if result.RealDecoyCount != wantRealDecoys { - t.Errorf("RealDecoyCount = %d, want %d", result.RealDecoyCount, wantRealDecoys) - } - if result.FallbackDecoyCount != wantFallback { - t.Errorf("FallbackDecoyCount = %d, want %d", result.FallbackDecoyCount, wantFallback) - } - if result.FallbackDecoyCount == 0 { - t.Error("FallbackDecoyCount must be > 0 when the decoy pool is sparse") - } - t.Logf("RealDecoyCount=%d FallbackDecoyCount=%d (RingSize-1=%d)", - result.RealDecoyCount, result.FallbackDecoyCount, crypto.RingSize-1) + const supply = 1_000_000_000 // 1 APRO + + aliceKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + bobKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + net := crypto.TestnetByte + aliceAddr := crypto.AddressFromKeys(net, aliceKeys) + bobAddr := crypto.AddressFromKeys(net, bobKeys) + + // Create a single-block chain with Alice's coinbase UTXO. + chain, _, inputBlind := makeGenesisWithCoinbase(t, aliceKeys, supply) + scanner := core.NewWalletScanner( + aliceKeys.View.Private, aliceKeys.Spend.Public, aliceKeys.View.Public, net, + ) + ownedByAlice := scanner.ScanChain(chain, 0, chain.Height()) + if len(ownedByAlice) == 0 { + t.Fatal("Alice found no UTXOs") + } + ownedByAlice[0].Blind = inputBlind + + // Build a UTXOSet with only 3 spent decoys — far fewer than RingSize-1 = 15. + const nSpentDecoys = 3 + utxos := core.NewUTXOSet() + for i := 0; i < nSpentDecoys; i++ { + dk, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys decoy %d: %v", i, err) + } + utxos.AddSpentDecoyForTest(&core.UTXO{OneTimePub: dk.Spend.Public}) + } + + builder := core.NewTxBuilder( + aliceKeys.Spend.Private, aliceKeys.View.Private, + aliceKeys.Spend.Public, ownedByAlice, 1, + ).WithDecoySet(utxos) + + result, err := builder.Build(30_000_000, bobAddr, aliceAddr) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + // 1 input × (RingSize-1) = 15 decoy slots total. + // Only nSpentDecoys=3 could be filled with real on-chain UTXOs. + wantRealDecoys := nSpentDecoys + wantFallback := (crypto.RingSize - 1) - nSpentDecoys + + if result.RealDecoyCount != wantRealDecoys { + t.Errorf("RealDecoyCount = %d, want %d", result.RealDecoyCount, wantRealDecoys) + } + if result.FallbackDecoyCount != wantFallback { + t.Errorf("FallbackDecoyCount = %d, want %d", result.FallbackDecoyCount, wantFallback) + } + if result.FallbackDecoyCount == 0 { + t.Error("FallbackDecoyCount must be > 0 when the decoy pool is sparse") + } + t.Logf("RealDecoyCount=%d FallbackDecoyCount=%d (RingSize-1=%d)", + result.RealDecoyCount, result.FallbackDecoyCount, crypto.RingSize-1) } diff --git a/core/tx_verifier.go b/core/tx_verifier.go index 921592c..59f8dfa 100644 --- a/core/tx_verifier.go +++ b/core/tx_verifier.go @@ -93,8 +93,8 @@ func (vl *VestingLock) lockedAt(pub crypto.Point32, now int64) uint64 { // TxVerifier validates the cryptographic integrity of transactions. // Separate from Validate() (structural) — this checks ring sigs and range proofs. type TxVerifier struct { - utxos *UTXOSet - vestingLock *VestingLock // nil = vesting enforcement disabled + utxos *UTXOSet + vestingLock *VestingLock // nil = vesting enforcement disabled stakedCollateralRingExclusionActivationHeight uint64 } @@ -129,9 +129,9 @@ func (v *TxVerifier) SetStakedCollateralRingExclusionActivationHeight(height uin // 5. Range proofs are valid for each output // 6. Pedersen commitment balance: ΣC_in = ΣC_out + C_fee func (v *TxVerifier) VerifyTx(tx *Transaction) error { -if v.stakedCollateralRingExclusionActivationHeight > 0 { -return fmt.Errorf("height-aware transaction verification required") -} + if v.stakedCollateralRingExclusionActivationHeight > 0 { + return fmt.Errorf("height-aware transaction verification required") + } return v.VerifyTxAtHeight(tx, 0) } @@ -139,9 +139,9 @@ return fmt.Errorf("height-aware transaction verification required") // that would include tx. Callers admitting transactions to a mempool must pass // the next candidate block height. func (v *TxVerifier) VerifyTxAtHeight(tx *Transaction, height uint64) error { -if v.stakedCollateralRingExclusionActivationHeight > 0 && height == 0 { -return fmt.Errorf("height-aware transaction verification requires a nonzero candidate height") -} + if v.stakedCollateralRingExclusionActivationHeight > 0 && height == 0 { + return fmt.Errorf("height-aware transaction verification requires a nonzero candidate height") + } return v.verifyTx(tx, nil, height) } @@ -150,18 +150,31 @@ func (v *TxVerifier) verifyTx(tx *Transaction, blockStakeBurns map[UTXOKey]struc if err := tx.Validate(); err != nil { return err } -if tx.IsLPoDPosition() { - a,err:=tx.LPoDPositionAction();if err!=nil{return err} - if a.Action==LPoDWithdraw {return nil} - if v.utxos==nil || v.utxos.IsSpent(tx.Inputs[0].KeyImage) {return fmt.Errorf("lpod: source key image unavailable or spent")} - u:=v.utxos.Get(a.SourceTx,a.SourceIndex) - if u==nil || u.OneTimePub!=a.SourcePub || u.AmountCommit!=tx.Inputs[0].AmountCommit || u.ProtocolLocked || - v.utxos.IsStaked(a.SourceTx,a.SourceIndex) {return fmt.Errorf("lpod: source is not an available matching UTXO")} - resolved:=v.utxos.GetByPubKey(a.SourcePub) - if resolved==nil || resolved.TxHash!=a.SourceTx || resolved.OutputIndex!=a.SourceIndex{return fmt.Errorf("lpod: ambiguous source output key")} - if v.vestingLock!=nil && v.vestingLock.lockedAt(a.SourcePub,v.vestingLock.nowFn())>0 {return fmt.Errorf("lpod: source is vesting locked")} - return nil -} + if tx.IsLPoDPosition() { + a, err := tx.LPoDPositionAction() + if err != nil { + return err + } + if a.Action == LPoDWithdraw { + return nil + } + if v.utxos == nil || v.utxos.IsSpent(tx.Inputs[0].KeyImage) { + return fmt.Errorf("lpod: source key image unavailable or spent") + } + u := v.utxos.Get(a.SourceTx, a.SourceIndex) + if u == nil || u.OneTimePub != a.SourcePub || u.AmountCommit != tx.Inputs[0].AmountCommit || u.ProtocolLocked || + v.utxos.IsStaked(a.SourceTx, a.SourceIndex) { + return fmt.Errorf("lpod: source is not an available matching UTXO") + } + resolved := v.utxos.GetByPubKey(a.SourcePub) + if resolved == nil || resolved.TxHash != a.SourceTx || resolved.OutputIndex != a.SourceIndex { + return fmt.Errorf("lpod: ambiguous source output key") + } + if v.vestingLock != nil && v.vestingLock.lockedAt(a.SourcePub, v.vestingLock.nowFn()) > 0 { + return fmt.Errorf("lpod: source is vesting locked") + } + return nil + } // Coinbase (zero-input) transactions must never arrive at the verifier // from external sources. They are synthesized by the consensus engine @@ -273,12 +286,12 @@ if tx.IsLPoDPosition() { if utxo.AmountCommit != inp.RingCommitments[j] { return fmt.Errorf("tx %x: input %d ring member %d commitment mismatch", txHashPrefix[:8], i, j) } - if activation := v.stakedCollateralRingExclusionActivationHeight; activation > 0 && height >= activation { - _, stakedInBlock := blockStakeBurns[UTXOKey{TxHash: utxo.TxHash, OutputIndex: utxo.OutputIndex}] - if stakedInBlock || v.utxos.IsStaked(utxo.TxHash, utxo.OutputIndex) { - return fmt.Errorf("tx %x: input %d ring member %d is staked collateral", - txHashPrefix[:8], i, j) - } + if activation := v.stakedCollateralRingExclusionActivationHeight; activation > 0 && height >= activation { + _, stakedInBlock := blockStakeBurns[UTXOKey{TxHash: utxo.TxHash, OutputIndex: utxo.OutputIndex}] + if stakedInBlock || v.utxos.IsStaked(utxo.TxHash, utxo.OutputIndex) { + return fmt.Errorf("tx %x: input %d ring member %d is staked collateral", + txHashPrefix[:8], i, j) + } } } continue @@ -457,36 +470,36 @@ if tx.IsLPoDPosition() { // reward) are enforced by validateCoinbasePolicy in the consensus engine before // this function is called. func (v *TxVerifier) VerifyBlock(block *Block) error { -if block == nil { -return fmt.Errorf("tx verifier: nil block") -} -if v.stakedCollateralRingExclusionActivationHeight > 0 && block.Header.Height == 0 { -return fmt.Errorf("height-aware block verification requires a nonzero block height") -} + if block == nil { + return fmt.Errorf("tx verifier: nil block") + } + if v.stakedCollateralRingExclusionActivationHeight > 0 && block.Header.Height == 0 { + return fmt.Errorf("height-aware block verification requires a nonzero block height") + } blockStakeBurns := make(map[UTXOKey]struct{}) enforceStakedCollateralRule := v.stakedCollateralRingExclusionActivationHeight > 0 && block.Header.Height >= v.stakedCollateralRingExclusionActivationHeight if enforceStakedCollateralRule { - for _, tx := range block.Txs { - if !tx.IsStake() { - continue - } - switch len(tx.Extra) { - case StakePayloadSizeV2: - action, _, _, _, burnTxHash, burnOutIdx, _, err := DecodeStakeExtraV2(tx.Extra) - if err == nil && action == StakeDeposit { - blockStakeBurns[UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx}] = struct{}{} + for _, tx := range block.Txs { + if !tx.IsStake() { + continue } - case StakePayloadSizeV3: - action, _, _, _, burnTxHash, burnOutIdx, _, _, err := DecodeStakeExtraV3(tx.Extra) - if err == nil && action == StakeDeposit { - blockStakeBurns[UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx}] = struct{}{} + switch len(tx.Extra) { + case StakePayloadSizeV2: + action, _, _, _, burnTxHash, burnOutIdx, _, err := DecodeStakeExtraV2(tx.Extra) + if err == nil && action == StakeDeposit { + blockStakeBurns[UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx}] = struct{}{} + } + case StakePayloadSizeV3: + action, _, _, _, burnTxHash, burnOutIdx, _, _, err := DecodeStakeExtraV3(tx.Extra) + if err == nil && action == StakeDeposit { + blockStakeBurns[UTXOKey{TxHash: burnTxHash, OutputIndex: burnOutIdx}] = struct{}{} + } } } } - } for i, tx := range block.Txs { -if tx.IsCoinbase() || tx.IsGuardianFund() || tx.IsLPoD() || tx.IsLPoDPayout() { + if tx.IsCoinbase() || tx.IsGuardianFund() || tx.IsLPoD() || tx.IsLPoDPayout() { continue // no crypto proofs on coinbase; policy checked separately } if err := v.verifyTx(&tx, blockStakeBurns, block.Header.Height); err != nil { diff --git a/core/utxo.go b/core/utxo.go index ecc2538..d80bbdb 100644 --- a/core/utxo.go +++ b/core/utxo.go @@ -12,7 +12,7 @@ import ( // KeyImageStore is the persistent backend for spent key images. // Implemented by *store.DB in production; nil in unit tests (all historical -// lookups return false — unspent — which is the correct isolated behaviour +// lookups return false — unspent — which is the correct isolated behavior // for tests that control their own small key-image set via MarkSpent only). type KeyImageStore interface { IsKeyImageSpent(ki crypto.KeyImage) (bool, error) @@ -54,7 +54,7 @@ type RingMemberFilterStore interface { // binary-search lookups work in tests without LevelDB. In production // sorted is always empty; the 700 MB baseline is eliminated entirely. // -// Snapshots serialise only 'recent' entries when kiDB is set (historical KIs +// Snapshots serialize only 'recent' entries when kiDB is set (historical KIs // are in LevelDB and survive restarts on disk). restoreFromSlice is a no-op // in production, eliminating phantom-KI bugs: mempool entries in old snapshots // no longer mark valid UTXOs as permanently spent. @@ -144,7 +144,7 @@ func (c *compactKeyImageSet) length() int { return len(c.sorted) + len(c.recent) // In production (kiDB set): returns only 'recent' entries; historical KIs // are in kiDB (LevelDB) and excluded — they survive restarts on disk. // In tests (kiDB nil): merges sorted+recent for a complete set, matching the -// old behaviour expected by snapshot-round-trip tests. +// old behavior expected by snapshot-round-trip tests. func (c *compactKeyImageSet) toSlice() []crypto.KeyImage { total := len(c.sorted) + len(c.recent) if total == 0 { @@ -230,7 +230,7 @@ var maxSpentDecoys = 10_000 var maxCLSAGRecentOutputs = 100_000 // maxRollbackDepth is the number of recent block heights kept in the rollback -// journal. Any chain reorganisation deeper than this cannot be reversed in +// journal. Any chain reorganization deeper than this cannot be reversed in // memory. On a PoA chain such deep reorgs are operationally impossible. const maxRollbackDepth = 256 @@ -312,6 +312,10 @@ func (s *UTXOSet) SetCLSAGActivationHeight(height uint64) { func (s *UTXOSet) Add(u *UTXO) { s.mu.Lock() defer s.mu.Unlock() + // Store an owned record. Snapshot views may retain pointers after this lock + // is released, so caller-owned block/restore data must not be able to mutate + // a live record behind the set's back. + u = cloneUTXO(u) key := UTXOKey{TxHash: u.TxHash, OutputIndex: u.OutputIndex} s.utxos[key] = u // Guardian outputs are retained for audit/rollback but are permanently @@ -322,6 +326,53 @@ func (s *UTXOSet) Add(u *UTXO) { s.trackCLSAGRecentLocked(key, u.BlockHeight) } +func cloneUTXO(u *UTXO) *UTXO { + if u == nil { + return nil + } + cp := *u + return &cp +} + +// replaceUTXOReferenceLocked swaps an immutable UTXO record everywhere the +// set currently references it. The caller must hold s.mu. +func (s *UTXOSet) replaceUTXOReferenceLocked(old, replacement *UTXO) { + if old == nil || replacement == nil { + return + } + key := UTXOKey{TxHash: old.TxHash, OutputIndex: old.OutputIndex} + active := s.utxos[key] == old + staked := s.stakedUTXOs[key] == old + spentDecoy := s.spentPubKeys[old.OneTimePub] == old + + if active { + s.utxos[key] = replacement + } + if staked { + s.stakedUTXOs[key] = replacement + } + if s.byPubKey[old.OneTimePub] == old { + delete(s.byPubKey, old.OneTimePub) + if active && !isProtocolLockedUTXO(replacement) { + s.byPubKey[replacement.OneTimePub] = replacement + } + } + if spentDecoy { + delete(s.spentPubKeys, old.OneTimePub) + if !isProtocolLockedUTXO(replacement) { + s.spentPubKeys[replacement.OneTimePub] = replacement + } + } + for height, entries := range s.rollbackJournal { + for i := range entries { + if entries[i].utxo == old { + entries[i].utxo = replacement + } + } + s.rollbackJournal[height] = entries + } +} + func (s *UTXOSet) trackCLSAGRecentLocked(key UTXOKey, height uint64) { if isProtocolLockedUTXO(s.utxos[key]) { return @@ -341,20 +392,20 @@ func (s *UTXOSet) trackCLSAGRecentLocked(key UTXOKey, height uint64) { } } -// Get retrieves a UTXO by its key. Returns nil if not found. +// Get retrieves a copy of a UTXO by its key. Returns nil if not found. func (s *UTXOSet) Get(txHash crypto.Hash32, outIdx uint32) *UTXO { s.mu.RLock() defer s.mu.RUnlock() - return s.utxos[UTXOKey{TxHash: txHash, OutputIndex: outIdx}] + return cloneUTXO(s.utxos[UTXOKey{TxHash: txHash, OutputIndex: outIdx}]) } -// GetByPubKey looks up a UTXO by its stealth one-time public key. +// GetByPubKey looks up a copy of a UTXO by its stealth one-time public key. // Used by TxVerifier to bind ring member AmountCommits to actual on-chain // UTXO commitments (C-0 fix: prevents attacker-supplied fake commitments). func (s *UTXOSet) GetByPubKey(pub crypto.Point32) *UTXO { s.mu.RLock() defer s.mu.RUnlock() - return s.byPubKey[pub] + return cloneUTXO(s.byPubKey[pub]) } // GetRingMember returns an active output or a retained spent output suitable @@ -364,12 +415,14 @@ func (s *UTXOSet) GetByPubKey(pub crypto.Point32) *UTXO { func (s *UTXOSet) GetRingMember(pub crypto.Point32) *UTXO { s.mu.RLock() if u := s.byPubKey[pub]; u != nil && !isProtocolLockedUTXO(u) { + result := cloneUTXO(u) s.mu.RUnlock() - return u + return result } if u := s.spentPubKeys[pub]; u != nil && !isProtocolLockedUTXO(u) { + result := cloneUTXO(u) s.mu.RUnlock() - return u + return result } store := s.ringMembers s.mu.RUnlock() @@ -380,7 +433,7 @@ func (s *UTXOSet) GetRingMember(pub crypto.Point32) *UTXO { if err != nil || isProtocolLockedUTXO(u) { return nil } - return u + return cloneUTXO(u) } // ClearKeyImages resets the in-memory 'recent' key-image map to empty. @@ -408,7 +461,9 @@ func (s *UTXOSet) PatchAmountCommit(oneTimePub crypto.Point32, correct crypto.Co s.mu.Lock() defer s.mu.Unlock() if u := s.byPubKey[oneTimePub]; u != nil { - u.AmountCommit = correct + repaired := cloneUTXO(u) + repaired.AmountCommit = correct + s.replaceUTXOReferenceLocked(u, repaired) } } @@ -426,7 +481,9 @@ func (s *UTXOSet) PatchStakedAmountCommit(txHash crypto.Hash32, outIdx uint32, c defer s.mu.Unlock() k := UTXOKey{TxHash: txHash, OutputIndex: outIdx} if u := s.stakedUTXOs[k]; u != nil { - u.AmountCommit = correct + repaired := cloneUTXO(u) + repaired.AmountCommit = correct + s.replaceUTXOReferenceLocked(u, repaired) } } @@ -473,6 +530,25 @@ func (s *UTXOSet) IsSpent(ki crypto.KeyImage) bool { return s.keyImages.contains(canonical) } +// HasSpentKeyImage returns whether a canonical key image is spent and +// propagates persistent-index errors. Consensus callers must use this method +// instead of IsSpent when a storage read failure must reject the transaction. +func (s *UTXOSet) HasSpentKeyImage(ki crypto.KeyImage) (bool, error) { + canonical, err := crypto.CanonicalKeyImage(ki) + if err != nil { + return false, fmt.Errorf("invalid key image: %w", err) + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.keyImages.contains(canonical) { + return true, nil + } + if s.keyImages.kiDB != nil { + return s.keyImages.kiDB.IsKeyImageSpent(canonical) + } + return false, nil +} + // ClearPhantomSpent removes a key image from the persistent spent set after an // authoritative confirmed-chain scan proves that it never appeared in a // transaction input. If the image was added to the current session's recent @@ -525,6 +601,28 @@ func (s *UTXOSet) ApplyBlock(block *Block) error { seen := make(map[crypto.KeyImage]int) // ki → first tx index (for error reporting) consumed := make(map[UTXOKey]int) // UTXO identity → first tx index for txIdx, tx := range block.Txs { + if tx.IsStake() && len(tx.Extra) == StakePayloadSizeV4 { + _, _, _, _, _, _, _, keyImage, _, err := DecodeStakeExtraV4(tx.Extra) + if err != nil { + return fmt.Errorf("block %d tx[%d]: decode stake key image: %w", + block.Header.Height, txIdx, err) + } + canonical, err := crypto.CanonicalKeyImage(keyImage) + if err != nil { + return fmt.Errorf("block %d tx[%d]: invalid stake key image: %w", + block.Header.Height, txIdx, err) + } + if s.keyImages.contains(canonical) { + return fmt.Errorf("stake key image %x is already spent at block %d", + keyImage[:8], block.Header.Height) + } + if firstIdx, duplicate := seen[canonical]; duplicate { + return fmt.Errorf("key image %x reused by tx[%d] and stake tx[%d] in block %d", + keyImage[:8], firstIdx, txIdx, block.Header.Height) + } + seen[canonical] = txIdx + continue + } if tx.IsCoinbase() || tx.IsStake() { continue } @@ -672,7 +770,7 @@ func (s *UTXOSet) applyTxsLocked(block *Block) { // Always record in the rollback journal, independent of the decoy // pool cap. RollbackBlock reads from here, not from spentPubKeys, // so that a UTXO spent when the decoy pool is full can still be - // restored on a chain reorganisation. + // restored on a chain reorganization. s.rollbackJournal[block.Header.Height] = append( s.rollbackJournal[block.Header.Height], rollbackEntry{ringMember: member, utxo: utxo}, @@ -680,7 +778,7 @@ func (s *UTXOSet) applyTxsLocked(block *Block) { // Prune the journal once it exceeds maxRollbackDepth distinct // heights, evicting the oldest entry to keep memory bounded. if len(s.rollbackJournal) > maxRollbackDepth { - var oldest uint64 = ^uint64(0) + var oldest = ^uint64(0) for h := range s.rollbackJournal { if h < oldest { oldest = h @@ -697,6 +795,14 @@ func (s *UTXOSet) applyTxsLocked(block *Block) { } } } + if tx.IsStake() && len(tx.Extra) == StakePayloadSizeV4 { + _, _, _, _, _, _, _, keyImage, _, err := DecodeStakeExtraV4(tx.Extra) + if err == nil { + if canonical, canonicalErr := crypto.CanonicalKeyImage(keyImage); canonicalErr == nil { + s.keyImages.insert(canonical) + } + } + } // Add outputs to both primary index and the byPubKey ring-member index. // byPubKey must be populated here so that TxVerifier.VerifyTx (C-0 full // check) can look up ring members created in any historical block after a @@ -797,6 +903,14 @@ func (s *UTXOSet) RollbackBlock(block *Block) error { } } } + if tx.IsStake() && len(tx.Extra) == StakePayloadSizeV4 { + _, _, _, _, _, _, _, keyImage, _, err := DecodeStakeExtraV4(tx.Extra) + if err == nil { + if canonical, canonicalErr := crypto.CanonicalKeyImage(keyImage); canonicalErr == nil { + s.keyImages.remove(canonical) + } + } + } } onRestored := s.OnUTXORestored onDeleted := s.OnUTXODeleted @@ -828,7 +942,7 @@ func (s *UTXOSet) RollbackBlock(block *Block) error { // cannot be spent in a normal RingCT transaction or re-used as a ring decoy. // The UTXO data is preserved in stakedUTXOs (not discarded) so that // UnmarkStaked can restore it for transactional rollback. -// Returns an error if the UTXO is not in the active set or already staked. (C-1 fix) +// Returns an error if the UTXO is not in the active set or already staked. (C-1 fix). func (s *UTXOSet) MarkStaked(txHash crypto.Hash32, outIdx uint32) error { s.mu.Lock() defer s.mu.Unlock() @@ -865,7 +979,7 @@ func (s *UTXOSet) MarkStakedKnown(txHash crypto.Hash32, outIdx uint32, u *UTXO) } // If the UTXO is currently in the active set (e.g. from the startup rebuild // scan), use its data for the staked record and remove it from the active - // indices — exactly matching MarkStaked behaviour. + // indices — exactly matching MarkStaked behavior. if existing, ok := s.utxos[k]; ok { delete(s.byPubKey, existing.OneTimePub) delete(s.utxos, k) @@ -875,7 +989,7 @@ func (s *UTXOSet) MarkStakedKnown(txHash crypto.Hash32, outIdx uint32, u *UTXO) // UTXO absent from active set (e.g. node upgraded mid-chain without a rebuild // or UTXO was already removed): store the provided descriptor so IsStaked() // returns true and prevents collateral reuse. - s.stakedUTXOs[k] = u + s.stakedUTXOs[k] = cloneUTXO(u) } // UnmarkStaked reverses MarkStaked for transactional rollback: moves the UTXO @@ -1142,7 +1256,7 @@ func (s *UTXOSet) KeyImagesRecentCount() int { // blocks seen since the last flush, not to total chain history. // // - Tests (kiDB nil): merges 'recent' into the compact 'sorted' slice -// (32 B/entry vs ≈150 B map bucket), mirroring the old two-tier behaviour +// (32 B/entry vs ≈150 B map bucket), mirroring the old two-tier behavior // so test assertions on KeyImagesRecentCount() and KeyImagesCount() pass. // // Call periodically (every GC cycle) with runtime.GC()+debug.FreeOSMemory() @@ -1178,7 +1292,9 @@ func (s *UTXOSet) CompactKeyImages() int { return before } -// All returns a snapshot of all UTXOs (for testing / export). +// All returns the current UTXO references (for read-only testing / export). +// Callers must not mutate the returned records; use PatchAmountCommit or the +// block mutation methods so snapshot views remain immutable. func (s *UTXOSet) All() []*UTXO { s.mu.RLock() defer s.mu.RUnlock() diff --git a/core/utxo_preflight_test.go b/core/utxo_preflight_test.go index bb33cbf..32f1dfb 100644 --- a/core/utxo_preflight_test.go +++ b/core/utxo_preflight_test.go @@ -114,7 +114,10 @@ func TestApplyBlockPreflightRejectsWithoutMutation(t *testing.T) { if set.IsSpent(inp.KeyImage) { t.Fatal("failed preflight marked key image spent") } - if set.Get(existing.TxHash, existing.OutputIndex) != existing { + gotExisting := set.Get(existing.TxHash, existing.OutputIndex) + if gotExisting == nil || gotExisting.TxHash != existing.TxHash || + gotExisting.OutputIndex != existing.OutputIndex || + gotExisting.AmountCommit != existing.AmountCommit { t.Fatal("failed preflight removed existing UTXO") } if set.Get(tx.Hash(), 0) != nil { diff --git a/core/utxo_replay.go b/core/utxo_replay.go index 0243c27..0864cff 100644 --- a/core/utxo_replay.go +++ b/core/utxo_replay.go @@ -1,8 +1,9 @@ package core import ( -"fmt" -"github.com/aperod/aperod/crypto" + "fmt" + + "github.com/aperod/aperod/crypto" ) // WithAnchoredReplayKeyImages is exclusively for single-threaded, pre-readiness @@ -10,31 +11,31 @@ import ( // Persistent callbacks and backends are disconnected until the callback ends. // UTXO mutations remain in memory on error; the caller MUST abort startup. func (s *UTXOSet) WithAnchoredReplayKeyImages(images []crypto.KeyImage, replay func() error) error { -recent := make(map[crypto.KeyImage]struct{}, len(images)) -for _, image := range images { -canonical, err := crypto.CanonicalKeyImage(image) -if err != nil || canonical != image { -return fmt.Errorf("anchor has invalid/noncanonical key image") -} -if _, exists := recent[image]; exists { -return fmt.Errorf("anchor has duplicate key image") -} -recent[image] = struct{}{} + recent := make(map[crypto.KeyImage]struct{}, len(images)) + for _, image := range images { + canonical, err := crypto.CanonicalKeyImage(image) + if err != nil || canonical != image { + return fmt.Errorf("anchor has invalid/noncanonical key image") + } + if _, exists := recent[image]; exists { + return fmt.Errorf("anchor has duplicate key image") + } + recent[image] = struct{}{} + } + s.mu.Lock() + original := s.keyImages + spent, restored, deleted := s.OnUTXOSpent, s.OnUTXORestored, s.OnUTXODeleted + members := s.ringMembers + s.keyImages = compactKeyImageSet{recent: recent} + s.OnUTXOSpent, s.OnUTXORestored, s.OnUTXODeleted = nil, nil, nil + s.ringMembers = nil + s.mu.Unlock() + defer func() { + s.mu.Lock() + s.keyImages = original + s.OnUTXOSpent, s.OnUTXORestored, s.OnUTXODeleted = spent, restored, deleted + s.ringMembers = members + s.mu.Unlock() + }() + return replay() } -s.mu.Lock() -original := s.keyImages -spent, restored, deleted := s.OnUTXOSpent, s.OnUTXORestored, s.OnUTXODeleted -members := s.ringMembers -s.keyImages = compactKeyImageSet{recent: recent} -s.OnUTXOSpent, s.OnUTXORestored, s.OnUTXODeleted = nil, nil, nil -s.ringMembers = nil -s.mu.Unlock() -defer func() { -s.mu.Lock() -s.keyImages = original -s.OnUTXOSpent, s.OnUTXORestored, s.OnUTXODeleted = spent, restored, deleted -s.ringMembers = members -s.mu.Unlock() -}() -return replay() -} \ No newline at end of file diff --git a/core/utxo_snapshot.go b/core/utxo_snapshot.go index f73f485..e82b278 100644 --- a/core/utxo_snapshot.go +++ b/core/utxo_snapshot.go @@ -5,7 +5,7 @@ import "github.com/aperod/aperod/crypto" // RollbackJournalEntry is the serialisable form of one rollbackEntry. // Exported fields allow JSON round-trips in UTXOSnapshot. // The rollback journal is bounded to maxRollbackDepth distinct heights, so -// the total serialised size is small relative to the full UTXO set. +// the total serialized size is small relative to the full UTXO set. type RollbackJournalEntry struct { Height uint64 `json:"height"` RingMember crypto.Point32 `json:"ring_member"` @@ -16,10 +16,10 @@ type RollbackJournalEntry struct { // UTXOSet. It is used to persist the set after the startup scan so that // subsequent restarts can skip the expensive block replay. type UTXOSnapshot struct { - ActiveUTXOs []*UTXO `json:"active_utxos"` - StakedUTXOs []*UTXO `json:"staked_utxos"` - SpentDecoys []*UTXO `json:"spent_decoys"` - KeyImages []crypto.KeyImage `json:"key_images"` + ActiveUTXOs []*UTXO `json:"active_utxos"` + StakedUTXOs []*UTXO `json:"staked_utxos"` + SpentDecoys []*UTXO `json:"spent_decoys"` + KeyImages []crypto.KeyImage `json:"key_images"` // RollbackJournal carries the bounded per-block rollback state so that // after a snapshot restore the node can roll back a newly applied block // even when the spentPubKeys decoy pool is already at capacity. @@ -36,34 +36,66 @@ func (s *UTXOSet) TakeSnapshot() UTXOSnapshot { s.mu.RLock() defer s.mu.RUnlock() + return s.snapshotLocked(true) +} + +// TakeSnapshotView captures the UTXO indexes without copying every UTXO +// record. The returned slices own their pointer arrays, while the referenced +// records remain immutable: Add stores a copy and repair paths replace records +// rather than mutating them. This lets a background snapshot writer retain a +// stable point-in-time view using O(number of UTXOs) pointers instead of a +// second full set of UTXO structs. +// +// Callers must treat the returned UTXO pointers as read-only. Use TakeSnapshot +// when an independently mutable deep copy is required. +func (s *UTXOSet) TakeSnapshotView() UTXOSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.snapshotLocked(false) +} + +// SnapshotKeyImages returns only the bounded in-memory key-image portion of +// the set. Historical production key images are stored in LevelDB. +func (s *UTXOSet) SnapshotKeyImages() []crypto.KeyImage { + s.mu.RLock() + defer s.mu.RUnlock() + return s.keyImages.toSlice() +} + +func (s *UTXOSet) snapshotLocked(deepCopy bool) UTXOSnapshot { + copyUTXO := func(u *UTXO) *UTXO { + if !deepCopy { + return u + } + cp := *u + return &cp + } + active := make([]*UTXO, 0, len(s.utxos)) for _, u := range s.utxos { - cp := *u - active = append(active, &cp) + active = append(active, copyUTXO(u)) } staked := make([]*UTXO, 0, len(s.stakedUTXOs)) for _, u := range s.stakedUTXOs { - cp := *u - staked = append(staked, &cp) + staked = append(staked, copyUTXO(u)) } decoys := make([]*UTXO, 0, len(s.spentPubKeys)) for _, u := range s.spentPubKeys { - cp := *u - decoys = append(decoys, &cp) + decoys = append(decoys, copyUTXO(u)) } // toSlice returns a sorted copy — no additional sort needed on restore. kis := s.keyImages.toSlice() - // Serialise the rollback journal as a flat slice (JSON map keys must be + // Serialize the rollback journal as a flat slice (JSON map keys must be // strings; using a slice with an explicit Height field avoids that limit). var journal []RollbackJournalEntry for height, entries := range s.rollbackJournal { for _, e := range entries { - cp := *e.utxo // deep copy so snapshot is independent of live maps journal = append(journal, RollbackJournalEntry{ Height: height, RingMember: e.ringMember, - UTXO: &cp, + UTXO: copyUTXO(e.utxo), }) } } @@ -122,15 +154,13 @@ func (s *UTXOSet) ReconcileWithStore( if onFix != nil { onFix(u, disk) } - u.AmountCommit = disk.AmountCommit - u.TxPubKey = disk.TxPubKey - u.EncAmount = disk.EncAmount - u.BlockHeight = disk.BlockHeight - u.ProtocolLocked = disk.ProtocolLocked - if u.ProtocolLocked { - delete(s.byPubKey, u.OneTimePub) - delete(s.spentPubKeys, u.OneTimePub) - } + repaired := *u + repaired.AmountCommit = disk.AmountCommit + repaired.TxPubKey = disk.TxPubKey + repaired.EncAmount = disk.EncAmount + repaired.BlockHeight = disk.BlockHeight + repaired.ProtocolLocked = disk.ProtocolLocked + s.replaceUTXOReferenceLocked(u, &repaired) fixed++ } } diff --git a/core/utxo_snapshot_view_test.go b/core/utxo_snapshot_view_test.go new file mode 100644 index 0000000..7f7ef23 --- /dev/null +++ b/core/utxo_snapshot_view_test.go @@ -0,0 +1,107 @@ +package core + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/aperod/aperod/crypto" +) + +func TestUTXOSnapshotViewStaysPointInTimeAcrossRecordRepair(t *testing.T) { + set := NewUTXOSet() + var txHash crypto.Hash32 + var pub crypto.Point32 + var oldCommit, repairedCommit crypto.Commitment + txHash[0], pub[0] = 1, 2 + oldCommit[0], repairedCommit[0] = 3, 4 + input := &UTXO{TxHash: txHash, OneTimePub: pub, AmountCommit: oldCommit} + set.Add(input) + + view := set.TakeSnapshotView() + if len(view.ActiveUTXOs) != 1 { + t.Fatalf("view has %d active outputs, want 1", len(view.ActiveUTXOs)) + } + + // Add owns its input and readers receive copies, so mutations outside the + // UTXOSet cannot alter a view or the live state. + input.AmountCommit = repairedCommit + read := set.Get(txHash, 0) + read.AmountCommit = repairedCommit + + set.PatchAmountCommit(pub, repairedCommit) + if got := view.ActiveUTXOs[0].AmountCommit; got != oldCommit { + t.Fatalf("captured view changed after repair: got %x, want %x", got, oldCommit) + } + if got := set.Get(txHash, 0).AmountCommit; got != repairedCommit { + t.Fatalf("live UTXO commitment = %x, want repaired %x", got, repairedCommit) + } +} + +func TestUTXOSnapshotViewStaysPointInTimeAcrossReconcile(t *testing.T) { + set := NewUTXOSet() + var txHash crypto.Hash32 + var pub crypto.Point32 + var oldCommit, diskCommit crypto.Commitment + txHash[0], pub[0] = 9, 10 + oldCommit[0], diskCommit[0] = 11, 12 + set.Add(&UTXO{TxHash: txHash, OneTimePub: pub, AmountCommit: oldCommit}) + view := set.TakeSnapshotView() + + checked, fixed, mismatched := set.ReconcileWithStore( + func(gotHash crypto.Hash32, outIdx uint32) (*UTXO, bool) { + return &UTXO{ + TxHash: gotHash, + OutputIndex: outIdx, + OneTimePub: pub, + AmountCommit: diskCommit, + }, true + }, + nil, + ) + if checked != 1 || fixed != 1 || mismatched != 0 { + t.Fatalf("reconcile = (%d, %d, %d), want (1, 1, 0)", checked, fixed, mismatched) + } + if got := view.ActiveUTXOs[0].AmountCommit; got != oldCommit { + t.Fatalf("captured view changed during reconcile: got %x, want %x", got, oldCommit) + } + if got := set.Get(txHash, 0).AmountCommit; got != diskCommit { + t.Fatalf("live UTXO commitment = %x, want disk %x", got, diskCommit) + } +} + +func TestUTXOSnapshotViewSerializesWhileRepairsReplaceRecords(t *testing.T) { + set := NewUTXOSet() + const count = 2048 + pubs := make([]crypto.Point32, count) + for i := range pubs { + var txHash crypto.Hash32 + pubs[i][0] = byte(i) + pubs[i][1] = byte(i >> 8) + txHash[0] = byte(i) + txHash[1] = byte(i >> 8) + set.Add(&UTXO{TxHash: txHash, OneTimePub: pubs[i]}) + } + view := set.TakeSnapshotView() + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 25; i++ { + if _, err := json.Marshal(view); err != nil { + t.Errorf("marshal immutable snapshot view: %v", err) + return + } + } + }() + go func() { + defer wg.Done() + var corrected crypto.Commitment + corrected[0] = 1 + for _, pub := range pubs { + set.PatchAmountCommit(pub, corrected) + } + }() + wg.Wait() +} diff --git a/core/vesting.go b/core/vesting.go index 72c5a36..a3d5983 100644 --- a/core/vesting.go +++ b/core/vesting.go @@ -7,7 +7,7 @@ // - immediate — all tokens unlocked at genesis (no lock-up) // - linear — tokens unlock gradually from genesis over VestSeconds // - cliff_linear — tokens are fully locked for CliffSeconds, then unlock -// linearly over VestSeconds +// linearly over VestSeconds // // All timestamps are Unix seconds. VestedAmount is monotone-increasing and // always returns a value in [0, totalAmount]. diff --git a/core/vesting_enforcement_test.go b/core/vesting_enforcement_test.go index a47d4cb..6c440ee 100644 --- a/core/vesting_enforcement_test.go +++ b/core/vesting_enforcement_test.go @@ -19,11 +19,11 @@ import ( // mapped to the provided pub key and overrides nowFn so tests control time. func buildTestVestingLock(teamPub crypto.Point32, genesisTime int64, nowFn func() int64) *core.VestingLock { const ( - cliffSeconds = int64(365 * 86400) // 1 year - vestSeconds = int64(4 * 365 * 86400) // 4 years after cliff + cliffSeconds = int64(365 * 86400) // 1 year + vestSeconds = int64(4 * 365 * 86400) // 4 years after cliff ) teamAlloc := &core.GenesisAlloc{ - Address: "", // not used directly in VestingLock + Address: "", // not used directly in VestingLock Amount: 1_000_000_000 * core.BaseUnitsPerAPR, // 1 000 000 000 APRO Label: "Team", Vesting: &core.VestingSchedule{ @@ -60,11 +60,11 @@ func TestVestingEnforcement_LockedGenesisRejected(t *testing.T) { for i := 1; i < crypto.RingSize; i++ { decoyPub := crypto.Point32{byte(0x10 + i)} utxos.Add(&core.UTXO{ - TxHash: crypto.Hash32{byte(i + 10)}, - OutputIndex: 0, - OneTimePub: decoyPub, + TxHash: crypto.Hash32{byte(i + 10)}, + OutputIndex: 0, + OneTimePub: decoyPub, AmountCommit: commit, - BlockHeight: 1, + BlockHeight: 1, }) } @@ -216,8 +216,8 @@ func TestVestingEnforcement_NonGenesisUnaffected(t *testing.T) { func TestVestingEnforcement_TimestampZeroBug(t *testing.T) { vs := &core.VestingSchedule{ Type: core.VestingCliffLinear, - CliffSeconds: int64(365 * 86400), // 1-year cliff - VestSeconds: int64(4 * 365 * 86400), // 4-year linear after cliff + CliffSeconds: int64(365 * 86400), // 1-year cliff + VestSeconds: int64(4 * 365 * 86400), // 4-year linear after cliff } total := uint64(1_000_000_000) * core.BaseUnitsPerAPR // 1 B APRO @@ -527,7 +527,7 @@ func TestBuildVestingLock_DuplicateAddressReturnsError(t *testing.T) { // TestGenesisValidate_DuplicateAddressRejected verifies that // GenesisConfig.Validate() rejects a config whose allocations contain two -// entries with the same address. This is the first defence — the config +// entries with the same address. This is the first defense — the config // should be rejected at load time before BuildVestingLock is ever called. func TestGenesisValidate_DuplicateAddressRejected(t *testing.T) { validatorPriv, validatorPub, err := crypto.GenerateValidatorKey() @@ -648,7 +648,7 @@ func TestGenesisValidate_SameSpendPubDifferentAddressRejected(t *testing.T) { } // TestBuildVestingLock_SameSpendPubDifferentAddressReturnsError verifies that -// BuildVestingLock also returns an error (second line of defence) when two +// BuildVestingLock also returns an error (second line of defense) when two // non-immediate allocations share a spendPub via distinct address strings. // This covers the case where the genesis config was not run through Validate() // before BuildVestingLock is called (e.g. programmatic construction in tooling). @@ -779,7 +779,7 @@ func TestGenesisValidate_UniqueAddressesPass(t *testing.T) { // // The test also confirms the complementary case: a non-genesis ring member // that IS absent from byPubKey fails with a C-0 error (not a vesting error), -// which is the correct behaviour for a genuinely missing UTXO. +// which is the correct behavior for a genuinely missing UTXO. func TestVestingEnforcement_PrunedGenesisStart(t *testing.T) { const genesisTime = int64(1_700_000_000) // "now" is 1 day after genesis — well within the 1-year cliff. diff --git a/core/vesting_test.go b/core/vesting_test.go index 2d74709..18c388d 100644 --- a/core/vesting_test.go +++ b/core/vesting_test.go @@ -38,9 +38,9 @@ func TestVesting_Linear(t *testing.T) { genesis := int64(1_700_000_000) tests := []struct { - elapsed int64 - wantMin uint64 - wantMax uint64 + elapsed int64 + wantMin uint64 + wantMax uint64 }{ {0, 0, 0}, {6 * month, total/2 - 1, total/2 + 1}, // ~50% diff --git a/core/wallet_scanner.go b/core/wallet_scanner.go index 54de6e3..5fe8f29 100644 --- a/core/wallet_scanner.go +++ b/core/wallet_scanner.go @@ -1,132 +1,132 @@ package core import ( - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // OwnedUTXO is a UTXO that belongs to the scanning wallet. type OwnedUTXO struct { - UTXO - HsScalar crypto.Scalar32 // H_s(rV): add spend private key to get one-time spend key - Amount uint64 // decrypted amount (0 if decryption failed) - Blind crypto.BlindFactor // Pedersen blind; zero = unknown for stealth outputs - // Blind is populated for transparent mint UTXOs via DeterministicMintBlind. - // For regular stealth outputs it must be supplied from the utxo_blinds DB. + UTXO + HsScalar crypto.Scalar32 // H_s(rV): add spend private key to get one-time spend key + Amount uint64 // decrypted amount (0 if decryption failed) + Blind crypto.BlindFactor // Pedersen blind; zero = unknown for stealth outputs + // Blind is populated for transparent mint UTXOs via DeterministicMintBlind. + // For regular stealth outputs it must be supplied from the utxo_blinds DB. } // WalletScanner scans blocks for outputs belonging to a wallet. type WalletScanner struct { - viewPriv crypto.Scalar32 - spendPub crypto.Point32 - viewPub crypto.Point32 - networkByte crypto.NetworkByte + viewPriv crypto.Scalar32 + spendPub crypto.Point32 + viewPub crypto.Point32 + networkByte crypto.NetworkByte } // NewWalletScanner creates a scanner for a view-only wallet. func NewWalletScanner(viewPriv crypto.Scalar32, spendPub, viewPub crypto.Point32, net crypto.NetworkByte) *WalletScanner { - return &WalletScanner{ - viewPriv: viewPriv, - spendPub: spendPub, - viewPub: viewPub, - networkByte: net, - } + return &WalletScanner{ + viewPriv: viewPriv, + spendPub: spendPub, + viewPub: viewPub, + networkByte: net, + } } // ScanBlock scans a block for outputs belonging to this wallet. // Returns a list of owned UTXOs (may be empty if none belong to us). func (s *WalletScanner) ScanBlock(block *Block) []OwnedUTXO { - var owned []OwnedUTXO - txHash := func(tx Transaction) crypto.Hash32 { return tx.Hash() } + var owned []OwnedUTXO + txHash := func(tx Transaction) crypto.Hash32 { return tx.Hash() } - for _, tx := range block.Txs { - hash := txHash(tx) - for i, out := range tx.Outputs { - hs, err := crypto.ScanForOutput( - s.viewPriv, - s.spendPub, - out.TxPubKey, - out.OneTimePub, - ) - if err != nil || hs == nil { - continue // not ours - } + for _, tx := range block.Txs { + hash := txHash(tx) + for i, out := range tx.Outputs { + hs, err := crypto.ScanForOutput( + s.viewPriv, + s.spendPub, + out.TxPubKey, + out.OneTimePub, + ) + if err != nil || hs == nil { + continue // not ours + } - amount := decryptAmount(out.EncAmount, hs) + amount := decryptAmount(out.EncAmount, hs) - owned = append(owned, OwnedUTXO{ - UTXO: UTXO{ - TxHash: hash, - OutputIndex: uint32(i), - OneTimePub: out.OneTimePub, - TxPubKey: out.TxPubKey, - AmountCommit: out.AmountCommit, - EncAmount: out.EncAmount, - BlockHeight: block.Header.Height, - }, - HsScalar: *hs, - Amount: amount, - }) - } - } - return owned + owned = append(owned, OwnedUTXO{ + UTXO: UTXO{ + TxHash: hash, + OutputIndex: uint32(i), + OneTimePub: out.OneTimePub, + TxPubKey: out.TxPubKey, + AmountCommit: out.AmountCommit, + EncAmount: out.EncAmount, + BlockHeight: block.Header.Height, + }, + HsScalar: *hs, + Amount: amount, + }) + } + } + return owned } // ScanChain scans all blocks in a range for owned UTXOs. // chain.GetByHeight is called for each height from startHeight to endHeight inclusive. func (s *WalletScanner) ScanChain(chain *Chain, startHeight, endHeight uint64) []OwnedUTXO { - var all []OwnedUTXO - for h := startHeight; h <= endHeight; h++ { - block := chain.GetByHeight(h) - if block == nil { - break - } - all = append(all, s.ScanBlock(block)...) - } - return all + var all []OwnedUTXO + for h := startHeight; h <= endHeight; h++ { + block := chain.GetByHeight(h) + if block == nil { + break + } + all = append(all, s.ScanBlock(block)...) + } + return all } // Balance sums the amounts of all owned UTXOs. func Balance(utxos []OwnedUTXO) uint64 { - var total uint64 - for _, u := range utxos { - total += u.Amount - } - return total + var total uint64 + for _, u := range utxos { + total += u.Amount + } + return total } // DecryptAmount XOR-decrypts an 8-byte encrypted amount using H_s as the key stream. // Used by the API layer when a view key is available to decode on-chain amounts inline. func DecryptAmount(enc [8]byte, hs *crypto.Scalar32) uint64 { - return decryptAmount(enc, hs) + return decryptAmount(enc, hs) } // decryptAmount XOR-decrypts an 8-byte encrypted amount using H_s as the key stream. -// Encryption: enc = amount_bytes XOR SHA3("amount" || Hs)[0:8] +// Encryption: enc = amount_bytes XOR SHA3("amount" || Hs)[0:8]. func decryptAmount(enc [8]byte, hs *crypto.Scalar32) uint64 { - keyStream := crypto.HashBytes([]byte("aperod/amount-enc/v1"), hs[:]) - var plain [8]byte - for i := range plain { - plain[i] = enc[i] ^ keyStream[i] - } - // little-endian uint64 - var v uint64 - for i := 7; i >= 0; i-- { - v = (v << 8) | uint64(plain[i]) - } - return v + keyStream := crypto.HashBytes([]byte("aperod/amount-enc/v1"), hs[:]) + var plain [8]byte + for i := range plain { + plain[i] = enc[i] ^ keyStream[i] + } + // little-endian uint64 + var v uint64 + for i := 7; i >= 0; i-- { + v = (v << 8) | uint64(plain[i]) + } + return v } // EncryptAmount encrypts an amount for inclusion in an output. // Used by the transaction builder. func EncryptAmount(amount uint64, hs *crypto.Scalar32) [8]byte { - var plain [8]byte - for i := range plain { - plain[i] = byte(amount >> (uint(i) * 8)) - } - keyStream := crypto.HashBytes([]byte("aperod/amount-enc/v1"), hs[:]) - var enc [8]byte - for i := range enc { - enc[i] = plain[i] ^ keyStream[i] - } - return enc + var plain [8]byte + for i := range plain { + plain[i] = byte(amount >> (uint(i) * 8)) + } + keyStream := crypto.HashBytes([]byte("aperod/amount-enc/v1"), hs[:]) + var enc [8]byte + for i := range enc { + enc[i] = plain[i] ^ keyStream[i] + } + return enc } diff --git a/crypto/address.go b/crypto/address.go index 3cf6c30..bf13bb3 100644 --- a/crypto/address.go +++ b/crypto/address.go @@ -19,7 +19,7 @@ const ( // Human-readable prefix prepended before the Base58Check payload. // Mainnet addresses look like: apro -// Testnet addresses look like: tapr +// Testnet addresses look like: tapr. const ( mainnetHRPrefix = "apro" testnetHRPrefix = "tapr" @@ -42,7 +42,7 @@ func hrPrefix(net NetworkByte) string { // Address is the human-readable representation of a wallet (spend + view public keys). // Format: + Base58Check( version_byte || spend_pubkey[32] || view_pubkey[32] ) // Example mainnet: aproXXXXXXX... (~98 chars total) -// Example testnet: taprXXXXXXX... (~99 chars total) +// Example testnet: taprXXXXXXX... (~99 chars total). type Address string // EncodeAddress encodes a spend+view public key pair into an Aperod address. diff --git a/crypto/bench_test.go b/crypto/bench_test.go index ca5930e..debabcc 100644 --- a/crypto/bench_test.go +++ b/crypto/bench_test.go @@ -4,138 +4,138 @@ package crypto_test // These benchmarks cover the hot paths in crypto. import ( - "testing" + "testing" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // ─── Key generation ─────────────────────────────────────────────────────────── func BenchmarkGenerateWalletKeys(b *testing.B) { - for i := 0; i < b.N; i++ { - _, _ = crypto.GenerateWalletKeys() - } + for i := 0; i < b.N; i++ { + _, _ = crypto.GenerateWalletKeys() + } } func BenchmarkGenerateValidatorKey(b *testing.B) { - for i := 0; i < b.N; i++ { - _, _, _ = crypto.GenerateValidatorKey() - } + for i := 0; i < b.N; i++ { + _, _, _ = crypto.GenerateValidatorKey() + } } // ─── Hashing ────────────────────────────────────────────────────────────────── func BenchmarkHashBytes_1KB(b *testing.B) { - data := make([]byte, 1024) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = crypto.HashBytes(data) - } + data := make([]byte, 1024) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = crypto.HashBytes(data) + } } // ─── Stealth output ─────────────────────────────────────────────────────────── func BenchmarkCreateStealthOutput(b *testing.B) { - wk, _ := crypto.GenerateWalletKeys() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) - } + wk, _ := crypto.GenerateWalletKeys() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) + } } func BenchmarkScanForOutput(b *testing.B) { - wk, _ := crypto.GenerateWalletKeys() - out, _ := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = crypto.ScanForOutput(wk.View.Private, wk.Spend.Public, out.TxPubKey, out.OneTimePub) - } + wk, _ := crypto.GenerateWalletKeys() + out, _ := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = crypto.ScanForOutput(wk.View.Private, wk.Spend.Public, out.TxPubKey, out.OneTimePub) + } } // ─── Pedersen ───────────────────────────────────────────────────────────────── func BenchmarkNewBlindFactor(b *testing.B) { - for i := 0; i < b.N; i++ { - _, _ = crypto.NewBlindFactor() - } + for i := 0; i < b.N; i++ { + _, _ = crypto.NewBlindFactor() + } } func BenchmarkCommit(b *testing.B) { - bf, _ := crypto.NewBlindFactor() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = crypto.Commit(uint64(i+1), bf) - } + bf, _ := crypto.NewBlindFactor() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = crypto.Commit(uint64(i+1), bf) + } } // ─── MLSAG ring signature ───────────────────────────────────────────────────── func BenchmarkMLSAGSign(b *testing.B) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = wk.Spend.Public - for i := 1; i < crypto.RingSize; i++ { - d, _ := crypto.GenerateWalletKeys() - ring[i] = d.Spend.Public - } - msg := crypto.Hash32{} - msg[0] = 0x01 - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = wk.Spend.Public + for i := 1; i < crypto.RingSize; i++ { + d, _ := crypto.GenerateWalletKeys() + ring[i] = d.Spend.Public + } + msg := crypto.Hash32{} + msg[0] = 0x01 + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + } } func BenchmarkMLSAGVerify(b *testing.B) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = wk.Spend.Public - for i := 1; i < crypto.RingSize; i++ { - d, _ := crypto.GenerateWalletKeys() - ring[i] = d.Spend.Public - } - msg := crypto.Hash32{} - sig, _ := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = crypto.MLSAGVerify(msg, ring, sig) - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = wk.Spend.Public + for i := 1; i < crypto.RingSize; i++ { + d, _ := crypto.GenerateWalletKeys() + ring[i] = d.Spend.Public + } + msg := crypto.Hash32{} + sig, _ := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = crypto.MLSAGVerify(msg, ring, sig) + } } // ─── Bulletproof ───────────────────────────────────────────────────────────── func BenchmarkProveRange(b *testing.B) { - bf, _ := crypto.NewBlindFactor() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = crypto.ProveRange(1_000_000, bf) - } + bf, _ := crypto.NewBlindFactor() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = crypto.ProveRange(1_000_000, bf) + } } func BenchmarkVerifyRange(b *testing.B) { - bf, _ := crypto.NewBlindFactor() - proof, _ := crypto.ProveRange(1_000_000, bf) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = crypto.VerifyRange(proof) - } + bf, _ := crypto.NewBlindFactor() + proof, _ := crypto.ProveRange(1_000_000, bf) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = crypto.VerifyRange(proof) + } } // ─── Address encode/decode ──────────────────────────────────────────────────── func BenchmarkEncodeAddress(b *testing.B) { - wk, _ := crypto.GenerateWalletKeys() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) - } + wk, _ := crypto.GenerateWalletKeys() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) + } } func BenchmarkDecodeAddress(b *testing.B) { - wk, _ := crypto.GenerateWalletKeys() - addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, _, _ = crypto.DecodeAddress(addr) - } + wk, _ := crypto.GenerateWalletKeys() + addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _, _ = crypto.DecodeAddress(addr) + } } diff --git a/crypto/bulletproof.go b/crypto/bulletproof.go index 82fb525..8220617 100644 --- a/crypto/bulletproof.go +++ b/crypto/bulletproof.go @@ -14,18 +14,18 @@ package crypto // try-and-increment hash-to-point so their mutual discrete logs are unknown. import ( - "crypto/sha256" - "crypto/sha512" - "encoding/binary" - "fmt" + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "fmt" - "filippo.io/edwards25519" + "filippo.io/edwards25519" ) // ─── Constants ──────────────────────────────────────────────────────────────── -const bpBits = 64 // range [0, 2^bpBits) -const bpLog = 6 // log₂(bpBits) +const bpBits = 64 // range [0, 2^bpBits) +const bpLog = 6 // log₂(bpBits) // ─── Generators ─────────────────────────────────────────────────────────────── @@ -35,55 +35,55 @@ const bpLog = 6 // log₂(bpBits) var BpH *edwards25519.Point var ( - bpU *edwards25519.Point // auxiliary IPA generator - bpGvec [bpBits]*edwards25519.Point // vector generators G₀..G₆₃ - bpHvec [bpBits]*edwards25519.Point // vector generators H₀..H₆₃ + bpU *edwards25519.Point // auxiliary IPA generator + bpGvec [bpBits]*edwards25519.Point // vector generators G₀..G₆₃ + bpHvec [bpBits]*edwards25519.Point // vector generators H₀..H₆₃ ) func init() { - BpH = hashToPoint([]byte("Aperod/BP/H/v2")) - bpU = hashToPoint([]byte("Aperod/BP/U/v2")) - for i := 0; i < bpBits; i++ { - var idx [8]byte - binary.LittleEndian.PutUint64(idx[:], uint64(i)) - bpGvec[i] = hashToPoint(append([]byte("Aperod/BP/Gvec/v2/"), idx[:]...)) - bpHvec[i] = hashToPoint(append([]byte("Aperod/BP/Hvec/v2/"), idx[:]...)) - } + BpH = hashToPoint([]byte("Aperod/BP/H/v2")) + bpU = hashToPoint([]byte("Aperod/BP/U/v2")) + for i := 0; i < bpBits; i++ { + var idx [8]byte + binary.LittleEndian.PutUint64(idx[:], uint64(i)) + bpGvec[i] = hashToPoint(append([]byte("Aperod/BP/Gvec/v2/"), idx[:]...)) + bpHvec[i] = hashToPoint(append([]byte("Aperod/BP/Hvec/v2/"), idx[:]...)) + } } // hashToPoint maps arbitrary bytes to a prime-order point on Ed25519 using // try-and-increment. No one knows log_G(result). func hashToPoint(seed []byte) *edwards25519.Point { - h := sha256.New() - for ctr := uint64(0); ; ctr++ { - h.Reset() - h.Write([]byte("Aperod/H2P/v1")) - h.Write(seed) - var cb [8]byte - binary.LittleEndian.PutUint64(cb[:], ctr) - h.Write(cb[:]) - digest := h.Sum(nil) - - var buf [32]byte - copy(buf[:], digest) - - // Try positive-x compressed point - buf[31] &= 0x7f - if p, err := new(edwards25519.Point).SetBytes(buf[:]); err == nil { - r := new(edwards25519.Point).MultByCofactor(p) - if r.Equal(edwards25519.NewIdentityPoint()) == 0 { - return r - } - } - // Try negative-x - buf[31] = digest[31] | 0x80 - if p, err := new(edwards25519.Point).SetBytes(buf[:]); err == nil { - r := new(edwards25519.Point).MultByCofactor(p) - if r.Equal(edwards25519.NewIdentityPoint()) == 0 { - return r - } - } - } + h := sha256.New() + for ctr := uint64(0); ; ctr++ { + h.Reset() + h.Write([]byte("Aperod/H2P/v1")) + h.Write(seed) + var cb [8]byte + binary.LittleEndian.PutUint64(cb[:], ctr) + h.Write(cb[:]) + digest := h.Sum(nil) + + var buf [32]byte + copy(buf[:], digest) + + // Try positive-x compressed point + buf[31] &= 0x7f + if p, err := new(edwards25519.Point).SetBytes(buf[:]); err == nil { + r := new(edwards25519.Point).MultByCofactor(p) + if r.Equal(edwards25519.NewIdentityPoint()) == 0 { + return r + } + } + // Try negative-x + buf[31] = digest[31] | 0x80 + if p, err := new(edwards25519.Point).SetBytes(buf[:]); err == nil { + r := new(edwards25519.Point).MultByCofactor(p) + if r.Equal(edwards25519.NewIdentityPoint()) == 0 { + return r + } + } + } } // ─── RangeProof ─────────────────────────────────────────────────────────────── @@ -91,21 +91,21 @@ func hashToPoint(seed []byte) *edwards25519.Point { // RangeProof proves that a committed value v satisfies 0 ≤ v < 2^64 // without revealing v. Fields are encoded as 32-byte compressed points/scalars. type RangeProof struct { - V [32]byte // V = γ·G + v·BpH (value commitment) - A [32]byte // A = ⟨aL,Gvec⟩ + ⟨aR,Hvec⟩ + α·G - S [32]byte // S = ⟨sL,Gvec⟩ + ⟨sR,Hvec⟩ + ρ·G - T1 [32]byte // T₁ = τ₁·G + t₁·BpH - T2 [32]byte // T₂ = τ₂·G + t₂·BpH - Taux [32]byte // τₓ = τ₂x² + τ₁x + z²γ - Mu [32]byte // μ = α + ρx - Tx [32]byte // t̂ = ⟨l,r⟩ - Ls [bpLog][32]byte // L commitments from inner-product argument - Rs [bpLog][32]byte // R commitments from inner-product argument - AFin [32]byte // final scalar a from IPA - BFin [32]byte // final scalar b from IPA - - // ValueCommit mirrors V for backward compatibility with tx_verifier.go. - ValueCommit Commitment + V [32]byte // V = γ·G + v·BpH (value commitment) + A [32]byte // A = ⟨aL,Gvec⟩ + ⟨aR,Hvec⟩ + α·G + S [32]byte // S = ⟨sL,Gvec⟩ + ⟨sR,Hvec⟩ + ρ·G + T1 [32]byte // T₁ = τ₁·G + t₁·BpH + T2 [32]byte // T₂ = τ₂·G + t₂·BpH + Taux [32]byte // τₓ = τ₂x² + τ₁x + z²γ + Mu [32]byte // μ = α + ρx + Tx [32]byte // t̂ = ⟨l,r⟩ + Ls [bpLog][32]byte // L commitments from inner-product argument + Rs [bpLog][32]byte // R commitments from inner-product argument + AFin [32]byte // final scalar a from IPA + BFin [32]byte // final scalar b from IPA + + // ValueCommit mirrors V for backward compatibility with tx_verifier.go. + ValueCommit Commitment } // ─── Transcript (Fiat-Shamir) ───────────────────────────────────────────────── @@ -114,23 +114,23 @@ type RangeProof struct { // challenges. Each absorbed value updates the state; challenges are derived // from the current state and then fed back to bind subsequent challenges. type bpTranscript struct { - acc [32]byte + acc [32]byte } func newBPTranscript(label string) *bpTranscript { - h := sha256.New() - h.Write([]byte("Aperod/BP/transcript/v2/")) - h.Write([]byte(label)) - var t bpTranscript - copy(t.acc[:], h.Sum(nil)) - return &t + h := sha256.New() + h.Write([]byte("Aperod/BP/transcript/v2/")) + h.Write([]byte(label)) + var t bpTranscript + copy(t.acc[:], h.Sum(nil)) + return &t } func (t *bpTranscript) absorb(b []byte) { - h := sha256.New() - h.Write(t.acc[:]) - h.Write(b) - copy(t.acc[:], h.Sum(nil)) + h := sha256.New() + h.Write(t.acc[:]) + h.Write(b) + copy(t.acc[:], h.Sum(nil)) } func (t *bpTranscript) absorbPoint(p *edwards25519.Point) { t.absorb(p.Bytes()) } @@ -139,100 +139,100 @@ func (t *bpTranscript) absorbScalar(s *edwards25519.Scalar) { t.absorb(s.Bytes() // challenge derives a uniform non-zero scalar from the current state, // then advances the state so the next challenge is independent. func (t *bpTranscript) challenge() *edwards25519.Scalar { - h := sha512.New() - h.Write([]byte("challenge")) - h.Write(t.acc[:]) - var wide [64]byte - copy(wide[:], h.Sum(nil)) - s, _ := edwards25519.NewScalar().SetUniformBytes(wide[:]) - t.absorb(s.Bytes()) // advance state - return s + h := sha512.New() + h.Write([]byte("challenge")) + h.Write(t.acc[:]) + var wide [64]byte + copy(wide[:], h.Sum(nil)) + s, _ := edwards25519.NewScalar().SetUniformBytes(wide[:]) + t.absorb(s.Bytes()) // advance state + return s } // ─── Scalar utilities ───────────────────────────────────────────────────────── func scalarOne() *edwards25519.Scalar { - var b [32]byte - b[0] = 1 - s, _ := edwards25519.NewScalar().SetCanonicalBytes(b[:]) - return s + var b [32]byte + b[0] = 1 + s, _ := edwards25519.NewScalar().SetCanonicalBytes(b[:]) + return s } // powerVec returns [base⁰, base¹, …, baseⁿ⁻¹]. func powerVec(base *edwards25519.Scalar, n int) []*edwards25519.Scalar { - v := make([]*edwards25519.Scalar, n) - v[0] = scalarOne() - for i := 1; i < n; i++ { - v[i] = edwards25519.NewScalar().Multiply(v[i-1], base) - } - return v + v := make([]*edwards25519.Scalar, n) + v[0] = scalarOne() + for i := 1; i < n; i++ { + v[i] = edwards25519.NewScalar().Multiply(v[i-1], base) + } + return v } // pow2Vec returns [1, 2, 4, …, 2ⁿ⁻¹]. func pow2Vec(n int) []*edwards25519.Scalar { - two := scalarFromUint64(2) - return powerVec(two, n) + two := scalarFromUint64(2) + return powerVec(two, n) } // innerProduct computes ⟨a,b⟩ = Σ aᵢ·bᵢ. func innerProduct(a, b []*edwards25519.Scalar) *edwards25519.Scalar { - r := edwards25519.NewScalar() - for i := range a { - r.MultiplyAdd(a[i], b[i], r) - } - return r + r := edwards25519.NewScalar() + for i := range a { + r.MultiplyAdd(a[i], b[i], r) + } + return r } func vecAddS(v []*edwards25519.Scalar, s *edwards25519.Scalar) []*edwards25519.Scalar { - out := make([]*edwards25519.Scalar, len(v)) - for i := range v { - out[i] = edwards25519.NewScalar().Add(v[i], s) - } - return out + out := make([]*edwards25519.Scalar, len(v)) + for i := range v { + out[i] = edwards25519.NewScalar().Add(v[i], s) + } + return out } func vecSubS(v []*edwards25519.Scalar, s *edwards25519.Scalar) []*edwards25519.Scalar { - out := make([]*edwards25519.Scalar, len(v)) - for i := range v { - out[i] = edwards25519.NewScalar().Subtract(v[i], s) - } - return out + out := make([]*edwards25519.Scalar, len(v)) + for i := range v { + out[i] = edwards25519.NewScalar().Subtract(v[i], s) + } + return out } func vecHadamard(a, b []*edwards25519.Scalar) []*edwards25519.Scalar { - out := make([]*edwards25519.Scalar, len(a)) - for i := range a { - out[i] = edwards25519.NewScalar().Multiply(a[i], b[i]) - } - return out + out := make([]*edwards25519.Scalar, len(a)) + for i := range a { + out[i] = edwards25519.NewScalar().Multiply(a[i], b[i]) + } + return out } func vecAddVec(a, b []*edwards25519.Scalar) []*edwards25519.Scalar { - out := make([]*edwards25519.Scalar, len(a)) - for i := range a { - out[i] = edwards25519.NewScalar().Add(a[i], b[i]) - } - return out + out := make([]*edwards25519.Scalar, len(a)) + for i := range a { + out[i] = edwards25519.NewScalar().Add(a[i], b[i]) + } + return out } func vecScale(s *edwards25519.Scalar, v []*edwards25519.Scalar) []*edwards25519.Scalar { - out := make([]*edwards25519.Scalar, len(v)) - for i := range v { - out[i] = edwards25519.NewScalar().Multiply(s, v[i]) - } - return out + out := make([]*edwards25519.Scalar, len(v)) + for i := range v { + out[i] = edwards25519.NewScalar().Multiply(s, v[i]) + } + return out } // msm is a convenience alias for variable-time multi-scalar multiplication. func msm(scalars []*edwards25519.Scalar, points []*edwards25519.Point) *edwards25519.Point { - return new(edwards25519.Point).VarTimeMultiScalarMult(scalars, points) + return new(edwards25519.Point).VarTimeMultiScalarMult(scalars, points) } // pointSlice converts a fixed array of *Point to a slice. func pointSlice(arr [bpBits]*edwards25519.Point) []*edwards25519.Point { - s := make([]*edwards25519.Point, bpBits) - copy(s, arr[:]) - return s + s := make([]*edwards25519.Point, bpBits) + copy(s, arr[:]) + return s } // ─── ProveRange ──────────────────────────────────────────────────────────────── @@ -240,152 +240,152 @@ func pointSlice(arr [bpBits]*edwards25519.Point) []*edwards25519.Point { // ProveRange creates a Bulletproof range proof that value v satisfies // 0 ≤ v < 2^64 using blinding factor blind. func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { - // 1. Value commitment V = γ·G + v·BpH ──────────────────────────────── - v := scalarFromUint64(value) - // H-2 fix: clampScalar fallback removed — Ed25519 clamping has no meaning - // for Bulletproof blinding scalars and could silently mutate the scalar. - // All production callers (NewBlindFactor, DeterministicMintBlind, - // DeterministicPaymentBlind, BlindSum) use SetUniformBytes → always canonical. - gamma, err := ScalarFromBytes(blind[:]) - if err != nil { - return nil, fmt.Errorf("bulletproof: invalid blind (non-canonical scalar): %w", err) - } - V := new(edwards25519.Point).Add( - new(edwards25519.Point).ScalarBaseMult(gamma), - new(edwards25519.Point).ScalarMult(v, BpH), - ) - - // 2. Bit decomposition: aL[i] = bit i of v, aR[i] = aL[i] - 1 ──────── - aL := make([]*edwards25519.Scalar, bpBits) - aR := make([]*edwards25519.Scalar, bpBits) - one := scalarOne() - for i := 0; i < bpBits; i++ { - aL[i] = scalarFromUint64((value >> uint(i)) & 1) - aR[i] = edwards25519.NewScalar().Subtract(aL[i], one) - } - - // 3. Commit to aL, aR: A = ⟨aL,Gvec⟩ + ⟨aR,Hvec⟩ + α·G ───────────── - alphaBF, _ := NewBlindFactor() - alpha, _ := ScalarFromBytes(alphaBF[:]) - gv := pointSlice(bpGvec) - hv := pointSlice(bpHvec) - A := new(edwards25519.Point).ScalarBaseMult(alpha) - A.Add(A, msm(aL, gv)) - A.Add(A, msm(aR, hv)) - - // 4. Blinding vectors sL, sR; S = ⟨sL,Gvec⟩ + ⟨sR,Hvec⟩ + ρ·G ────── - sL := make([]*edwards25519.Scalar, bpBits) - sR := make([]*edwards25519.Scalar, bpBits) - for i := 0; i < bpBits; i++ { - bf, _ := NewBlindFactor() - sL[i], _ = ScalarFromBytes(bf[:]) - bf, _ = NewBlindFactor() - sR[i], _ = ScalarFromBytes(bf[:]) - } - rhoBF, _ := NewBlindFactor() - rho, _ := ScalarFromBytes(rhoBF[:]) - S := new(edwards25519.Point).ScalarBaseMult(rho) - S.Add(S, msm(sL, gv)) - S.Add(S, msm(sR, hv)) - - // 5. Challenges y, z ─────────────────────────────────────────────────── - tr := newBPTranscript("range-proof/v2") - tr.absorbPoint(V) - tr.absorbPoint(A) - tr.absorbPoint(S) - y := tr.challenge() - z := tr.challenge() - - // 6. Polynomial l(x), r(x) ───────────────────────────────────────────── - // l(x) = (aL - z) + sL·x l₀ = aL-z, l₁ = sL - // r(x) = yn⊙(aR + z) + z²·2ⁿ + yn⊙sR·x - // r₀ = yn⊙(aR+z)+z²·2ⁿ, r₁ = yn⊙sR - yn := powerVec(y, bpBits) - twon := pow2Vec(bpBits) - z2 := edwards25519.NewScalar().Multiply(z, z) - - l0 := vecSubS(aL, z) - l1 := sL - - r0 := vecAddVec(vecHadamard(yn, vecAddS(aR, z)), vecScale(z2, twon)) - r1 := vecHadamard(yn, sR) - - // t₁ = ⟨l₀,r₁⟩ + ⟨l₁,r₀⟩, t₂ = ⟨l₁,r₁⟩ - t1v := edwards25519.NewScalar().Add(innerProduct(l0, r1), innerProduct(l1, r0)) - t2v := innerProduct(l1, r1) - - // 7. Commit to t₁, t₂: Tᵢ = τᵢ·G + tᵢ·BpH ──────────────────────────── - tau1BF, _ := NewBlindFactor() - tau1, _ := ScalarFromBytes(tau1BF[:]) - tau2BF, _ := NewBlindFactor() - tau2, _ := ScalarFromBytes(tau2BF[:]) - T1 := new(edwards25519.Point).Add( - new(edwards25519.Point).ScalarBaseMult(tau1), - new(edwards25519.Point).ScalarMult(t1v, BpH), - ) - T2 := new(edwards25519.Point).Add( - new(edwards25519.Point).ScalarBaseMult(tau2), - new(edwards25519.Point).ScalarMult(t2v, BpH), - ) - - // 8. Challenge x ─────────────────────────────────────────────────────── - tr.absorbPoint(T1) - tr.absorbPoint(T2) - x := tr.challenge() - x2 := edwards25519.NewScalar().Multiply(x, x) - - // 9. Evaluate polynomials at x ───────────────────────────────────────── - l := vecAddVec(l0, vecScale(x, l1)) - r := vecAddVec(r0, vecScale(x, r1)) - txHat := innerProduct(l, r) - - // τₓ = τ₂x² + τ₁x + z²γ - taux := edwards25519.NewScalar() - taux.MultiplyAdd(tau2, x2, taux) - taux.MultiplyAdd(tau1, x, taux) - taux.MultiplyAdd(z2, gamma, taux) - - // μ = α + ρx - mu := edwards25519.NewScalar().MultiplyAdd(rho, x, alpha) - - // 10. IPA ────────────────────────────────────────────────────────────── - tr.absorbScalar(taux) - tr.absorbScalar(mu) - tr.absorbScalar(txHat) - w := tr.challenge() - Uw := new(edwards25519.Point).ScalarMult(w, bpU) - - // H' = y⁻ⁱ · Hvec[i] (modified generators for IPA) - yInvPow := powerVec(edwards25519.NewScalar().Invert(y), bpBits) - hPrime := make([]*edwards25519.Point, bpBits) - for i := 0; i < bpBits; i++ { - hPrime[i] = new(edwards25519.Point).ScalarMult(yInvPow[i], bpHvec[i]) - } - - Gipa := pointSlice(bpGvec) - Ls, Rs, aFin, bFin, err := ipaProve(Gipa, hPrime, l, r, Uw, tr) - if err != nil { - return nil, fmt.Errorf("bulletproof IPA: %w", err) - } - - // 11. Assemble proof ─────────────────────────────────────────────────── - proof := &RangeProof{} - copy(proof.V[:], V.Bytes()) - copy(proof.A[:], A.Bytes()) - copy(proof.S[:], S.Bytes()) - copy(proof.T1[:], T1.Bytes()) - copy(proof.T2[:], T2.Bytes()) - copy(proof.Taux[:], taux.Bytes()) - copy(proof.Mu[:], mu.Bytes()) - copy(proof.Tx[:], txHat.Bytes()) - for k := 0; k < bpLog; k++ { - copy(proof.Ls[k][:], Ls[k].Bytes()) - copy(proof.Rs[k][:], Rs[k].Bytes()) - } - copy(proof.AFin[:], aFin.Bytes()) - copy(proof.BFin[:], bFin.Bytes()) - copy(proof.ValueCommit[:], V.Bytes()) - return proof, nil + // 1. Value commitment V = γ·G + v·BpH ──────────────────────────────── + v := scalarFromUint64(value) + // H-2 fix: clampScalar fallback removed — Ed25519 clamping has no meaning + // for Bulletproof blinding scalars and could silently mutate the scalar. + // All production callers (NewBlindFactor, DeterministicMintBlind, + // DeterministicPaymentBlind, BlindSum) use SetUniformBytes → always canonical. + gamma, err := ScalarFromBytes(blind[:]) + if err != nil { + return nil, fmt.Errorf("bulletproof: invalid blind (non-canonical scalar): %w", err) + } + V := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarBaseMult(gamma), + new(edwards25519.Point).ScalarMult(v, BpH), + ) + + // 2. Bit decomposition: aL[i] = bit i of v, aR[i] = aL[i] - 1 ──────── + aL := make([]*edwards25519.Scalar, bpBits) + aR := make([]*edwards25519.Scalar, bpBits) + one := scalarOne() + for i := 0; i < bpBits; i++ { + aL[i] = scalarFromUint64((value >> uint(i)) & 1) + aR[i] = edwards25519.NewScalar().Subtract(aL[i], one) + } + + // 3. Commit to aL, aR: A = ⟨aL,Gvec⟩ + ⟨aR,Hvec⟩ + α·G ───────────── + alphaBF, _ := NewBlindFactor() + alpha, _ := ScalarFromBytes(alphaBF[:]) + gv := pointSlice(bpGvec) + hv := pointSlice(bpHvec) + A := new(edwards25519.Point).ScalarBaseMult(alpha) + A.Add(A, msm(aL, gv)) + A.Add(A, msm(aR, hv)) + + // 4. Blinding vectors sL, sR; S = ⟨sL,Gvec⟩ + ⟨sR,Hvec⟩ + ρ·G ────── + sL := make([]*edwards25519.Scalar, bpBits) + sR := make([]*edwards25519.Scalar, bpBits) + for i := 0; i < bpBits; i++ { + bf, _ := NewBlindFactor() + sL[i], _ = ScalarFromBytes(bf[:]) + bf, _ = NewBlindFactor() + sR[i], _ = ScalarFromBytes(bf[:]) + } + rhoBF, _ := NewBlindFactor() + rho, _ := ScalarFromBytes(rhoBF[:]) + S := new(edwards25519.Point).ScalarBaseMult(rho) + S.Add(S, msm(sL, gv)) + S.Add(S, msm(sR, hv)) + + // 5. Challenges y, z ─────────────────────────────────────────────────── + tr := newBPTranscript("range-proof/v2") + tr.absorbPoint(V) + tr.absorbPoint(A) + tr.absorbPoint(S) + y := tr.challenge() + z := tr.challenge() + + // 6. Polynomial l(x), r(x) ───────────────────────────────────────────── + // l(x) = (aL - z) + sL·x l₀ = aL-z, l₁ = sL + // r(x) = yn⊙(aR + z) + z²·2ⁿ + yn⊙sR·x + // r₀ = yn⊙(aR+z)+z²·2ⁿ, r₁ = yn⊙sR + yn := powerVec(y, bpBits) + town := pow2Vec(bpBits) + z2 := edwards25519.NewScalar().Multiply(z, z) + + l0 := vecSubS(aL, z) + l1 := sL + + r0 := vecAddVec(vecHadamard(yn, vecAddS(aR, z)), vecScale(z2, town)) + r1 := vecHadamard(yn, sR) + + // t₁ = ⟨l₀,r₁⟩ + ⟨l₁,r₀⟩, t₂ = ⟨l₁,r₁⟩ + t1v := edwards25519.NewScalar().Add(innerProduct(l0, r1), innerProduct(l1, r0)) + t2v := innerProduct(l1, r1) + + // 7. Commit to t₁, t₂: Tᵢ = τᵢ·G + tᵢ·BpH ──────────────────────────── + tau1BF, _ := NewBlindFactor() + tau1, _ := ScalarFromBytes(tau1BF[:]) + tau2BF, _ := NewBlindFactor() + tau2, _ := ScalarFromBytes(tau2BF[:]) + T1 := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarBaseMult(tau1), + new(edwards25519.Point).ScalarMult(t1v, BpH), + ) + T2 := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarBaseMult(tau2), + new(edwards25519.Point).ScalarMult(t2v, BpH), + ) + + // 8. Challenge x ─────────────────────────────────────────────────────── + tr.absorbPoint(T1) + tr.absorbPoint(T2) + x := tr.challenge() + x2 := edwards25519.NewScalar().Multiply(x, x) + + // 9. Evaluate polynomials at x ───────────────────────────────────────── + l := vecAddVec(l0, vecScale(x, l1)) + r := vecAddVec(r0, vecScale(x, r1)) + txHat := innerProduct(l, r) + + // τₓ = τ₂x² + τ₁x + z²γ + taux := edwards25519.NewScalar() + taux.MultiplyAdd(tau2, x2, taux) + taux.MultiplyAdd(tau1, x, taux) + taux.MultiplyAdd(z2, gamma, taux) + + // μ = α + ρx + mu := edwards25519.NewScalar().MultiplyAdd(rho, x, alpha) + + // 10. IPA ────────────────────────────────────────────────────────────── + tr.absorbScalar(taux) + tr.absorbScalar(mu) + tr.absorbScalar(txHat) + w := tr.challenge() + Uw := new(edwards25519.Point).ScalarMult(w, bpU) + + // H' = y⁻ⁱ · Hvec[i] (modified generators for IPA) + yInvPow := powerVec(edwards25519.NewScalar().Invert(y), bpBits) + hPrime := make([]*edwards25519.Point, bpBits) + for i := 0; i < bpBits; i++ { + hPrime[i] = new(edwards25519.Point).ScalarMult(yInvPow[i], bpHvec[i]) + } + + Gipa := pointSlice(bpGvec) + Ls, Rs, aFin, bFin, err := ipaProve(Gipa, hPrime, l, r, Uw, tr) + if err != nil { + return nil, fmt.Errorf("bulletproof IPA: %w", err) + } + + // 11. Assemble proof ─────────────────────────────────────────────────── + proof := &RangeProof{} + copy(proof.V[:], V.Bytes()) + copy(proof.A[:], A.Bytes()) + copy(proof.S[:], S.Bytes()) + copy(proof.T1[:], T1.Bytes()) + copy(proof.T2[:], T2.Bytes()) + copy(proof.Taux[:], taux.Bytes()) + copy(proof.Mu[:], mu.Bytes()) + copy(proof.Tx[:], txHat.Bytes()) + for k := 0; k < bpLog; k++ { + copy(proof.Ls[k][:], Ls[k].Bytes()) + copy(proof.Rs[k][:], Rs[k].Bytes()) + } + copy(proof.AFin[:], aFin.Bytes()) + copy(proof.BFin[:], bFin.Bytes()) + copy(proof.ValueCommit[:], V.Bytes()) + return proof, nil } // ─── VerifyRange ────────────────────────────────────────────────────────────── @@ -393,179 +393,213 @@ func ProveRange(value uint64, blind BlindFactor) (*RangeProof, error) { // VerifyRange checks that the range proof is valid. // Returns (true, nil) only for proofs created by ProveRange. func VerifyRange(proof *RangeProof) (bool, error) { - if proof == nil { - return false, fmt.Errorf("nil proof") - } - - // ValueCommit is the externally-visible commitment field; it must match V. - // Tampering either field breaks the proof. - if [32]byte(proof.ValueCommit) != proof.V { - return false, nil - } - - // Decode proof elements (all must be valid encodings) - decPt := func(b [32]byte, name string) (*edwards25519.Point, error) { - p, err := new(edwards25519.Point).SetBytes(b[:]) - if err != nil { - return nil, fmt.Errorf("decode %s: %w", name, err) - } - return p, nil - } - decSc := func(b [32]byte, name string) (*edwards25519.Scalar, error) { - s, err := edwards25519.NewScalar().SetCanonicalBytes(b[:]) - if err != nil { - return nil, fmt.Errorf("decode %s: %w", name, err) - } - return s, nil - } - - V, err := decPt(proof.V, "V"); if err != nil { return false, err } - A, err := decPt(proof.A, "A"); if err != nil { return false, err } - S, err := decPt(proof.S, "S"); if err != nil { return false, err } - T1, err := decPt(proof.T1, "T1"); if err != nil { return false, err } - T2, err := decPt(proof.T2, "T2"); if err != nil { return false, err } - taux, err := decSc(proof.Taux, "taux"); if err != nil { return false, err } - mu, err := decSc(proof.Mu, "mu"); if err != nil { return false, err } - txHat,err := decSc(proof.Tx, "tx"); if err != nil { return false, err } - aFin, err := decSc(proof.AFin, "aFin"); if err != nil { return false, err } - bFin, err := decSc(proof.BFin, "bFin"); if err != nil { return false, err } - - Ls := make([]*edwards25519.Point, bpLog) - Rs := make([]*edwards25519.Point, bpLog) - for k := 0; k < bpLog; k++ { - Ls[k], err = decPt(proof.Ls[k], fmt.Sprintf("L%d", k)) - if err != nil { return false, err } - Rs[k], err = decPt(proof.Rs[k], fmt.Sprintf("R%d", k)) - if err != nil { return false, err } - } - - // ── Replay transcript to get challenges ─────────────────────────────── - tr := newBPTranscript("range-proof/v2") - tr.absorbPoint(V) - tr.absorbPoint(A) - tr.absorbPoint(S) - y := tr.challenge() - z := tr.challenge() - tr.absorbPoint(T1) - tr.absorbPoint(T2) - x := tr.challenge() - x2 := edwards25519.NewScalar().Multiply(x, x) - tr.absorbScalar(taux) - tr.absorbScalar(mu) - tr.absorbScalar(txHat) - w := tr.challenge() - - z2 := edwards25519.NewScalar().Multiply(z, z) - z3 := edwards25519.NewScalar().Multiply(z2, z) - yn := powerVec(y, bpBits) - twon := pow2Vec(bpBits) - - // ── Check 1: polynomial commitment ──────────────────────────────────── - // t̂·BpH + τₓ·G == z²·V + δ(y,z)·BpH + x·T₁ + x²·T₂ - // - // δ(y,z) = (z - z²)·⟨1ⁿ,yⁿ⟩ - z³·⟨1ⁿ,2ⁿ⟩ - sumYn := edwards25519.NewScalar() - for _, yi := range yn { - sumYn.Add(sumYn, yi) - } - sum2n := edwards25519.NewScalar() - for _, ti := range twon { - sum2n.Add(sum2n, ti) - } - zMinusZ2 := edwards25519.NewScalar().Subtract(z, z2) - negZ3s2n := edwards25519.NewScalar().Negate(edwards25519.NewScalar().Multiply(z3, sum2n)) - delta := edwards25519.NewScalar().MultiplyAdd(zMinusZ2, sumYn, negZ3s2n) - - lhs := new(edwards25519.Point).Add( - new(edwards25519.Point).ScalarMult(txHat, BpH), - new(edwards25519.Point).ScalarBaseMult(taux), - ) - rhs := new(edwards25519.Point).ScalarMult(z2, V) - rhs.Add(rhs, new(edwards25519.Point).ScalarMult(delta, BpH)) - rhs.Add(rhs, new(edwards25519.Point).ScalarMult(x, T1)) - rhs.Add(rhs, new(edwards25519.Point).ScalarMult(x2, T2)) - if lhs.Equal(rhs) != 1 { - return false, nil - } - - // ── Reconstruct P for IPA verification ─────────────────────────────── - // P = A + x·S - z·Σ Gvec[i] + z·Σ Hvec[i] + z²·Σ 2ⁱ·H'[i] + t̂·Uw - // - // (derived by expanding commitments A and S; the μ·G terms cancel) - Uw := new(edwards25519.Point).ScalarMult(w, bpU) - - yInvPow := powerVec(edwards25519.NewScalar().Invert(y), bpBits) - hPrime := make([]*edwards25519.Point, bpBits) - for i := 0; i < bpBits; i++ { - hPrime[i] = new(edwards25519.Point).ScalarMult(yInvPow[i], bpHvec[i]) - } - - // P = A + x·S - μ·G + t̂·Uw - z·⟨1ⁿ,Gvec⟩ + z·⟨1ⁿ,Hvec⟩ + z²·⟨2ⁿ,H'⟩ - // - // Derivation: - // ⟨l,G⟩ + ⟨r,H'⟩ = (A+x·S-μ·G) - z·⟨1,G⟩ + z·⟨1,Hvec⟩ + z²·⟨2ⁿ,H'⟩ - // (the z·⟨1,Hvec⟩ comes from the y·y⁻¹=1 cancellation in ⟨r,H'⟩) - P := new(edwards25519.Point).Add(A, new(edwards25519.Point).ScalarMult(x, S)) - // subtract μ·G (μ = α + ρx; this removes the G blinding from A+x·S) - P.Add(P, new(edwards25519.Point).ScalarBaseMult(edwards25519.NewScalar().Negate(mu))) - P.Add(P, new(edwards25519.Point).ScalarMult(txHat, Uw)) - negZ := edwards25519.NewScalar().Negate(z) - for i := 0; i < bpBits; i++ { - P.Add(P, new(edwards25519.Point).ScalarMult(negZ, bpGvec[i])) - P.Add(P, new(edwards25519.Point).ScalarMult(z, bpHvec[i])) - coeff := edwards25519.NewScalar().Multiply(z2, twon[i]) - P.Add(P, new(edwards25519.Point).ScalarMult(coeff, hPrime[i])) - } - - // ── Check 2: IPA ────────────────────────────────────────────────────── - // Replay IPA challenges and fold P: P_{k+1} = P_k + u_k·L_k + u_k⁻¹·R_k - ipaU := [bpLog]*edwards25519.Scalar{} - ipaUInv := [bpLog]*edwards25519.Scalar{} - for k := 0; k < bpLog; k++ { - tr.absorbPoint(Ls[k]) - tr.absorbPoint(Rs[k]) - ipaU[k] = tr.challenge() - ipaUInv[k] = edwards25519.NewScalar().Invert(ipaU[k]) - // P' = P + u·L + u⁻¹·R (our L/R use aR/bL split → linear u scaling) - P.Add(P, new(edwards25519.Point).ScalarMult(ipaU[k], Ls[k])) - P.Add(P, new(edwards25519.Point).ScalarMult(ipaUInv[k], Rs[k])) - } - // P is now P_final - - // Fold generators using the same challenges - Gfin := pointSlice(bpGvec) - Hfin := make([]*edwards25519.Point, bpBits) - copy(Hfin, hPrime) - for k := 0; k < bpLog; k++ { - half := len(Gfin) / 2 - uInv := ipaUInv[k] - uk := ipaU[k] - newG := make([]*edwards25519.Point, half) - newH := make([]*edwards25519.Point, half) - for i := 0; i < half; i++ { - newG[i] = new(edwards25519.Point).Add( - Gfin[i], - new(edwards25519.Point).ScalarMult(uInv, Gfin[half+i]), - ) - newH[i] = new(edwards25519.Point).Add( - Hfin[i], - new(edwards25519.Point).ScalarMult(uk, Hfin[half+i]), - ) - } - Gfin = newG - Hfin = newH - } - // Gfin[0], Hfin[0] are the final single generators - - // Final check: a·G_fin + b·H_fin + a·b·Uw == P_final - ab := edwards25519.NewScalar().Multiply(aFin, bFin) - check := new(edwards25519.Point).Add( - new(edwards25519.Point).ScalarMult(aFin, Gfin[0]), - new(edwards25519.Point).ScalarMult(bFin, Hfin[0]), - ) - check.Add(check, new(edwards25519.Point).ScalarMult(ab, Uw)) - - return check.Equal(P) == 1, nil + if proof == nil { + return false, fmt.Errorf("nil proof") + } + + // ValueCommit is the externally-visible commitment field; it must match V. + // Tampering either field breaks the proof. + if [32]byte(proof.ValueCommit) != proof.V { + return false, nil + } + + // Decode proof elements (all must be valid encodings) + decPt := func(b [32]byte, name string) (*edwards25519.Point, error) { + p, err := new(edwards25519.Point).SetBytes(b[:]) + if err != nil { + return nil, fmt.Errorf("decode %s: %w", name, err) + } + return p, nil + } + decSc := func(b [32]byte, name string) (*edwards25519.Scalar, error) { + s, err := edwards25519.NewScalar().SetCanonicalBytes(b[:]) + if err != nil { + return nil, fmt.Errorf("decode %s: %w", name, err) + } + return s, nil + } + + V, err := decPt(proof.V, "V") + if err != nil { + return false, err + } + A, err := decPt(proof.A, "A") + if err != nil { + return false, err + } + S, err := decPt(proof.S, "S") + if err != nil { + return false, err + } + T1, err := decPt(proof.T1, "T1") + if err != nil { + return false, err + } + T2, err := decPt(proof.T2, "T2") + if err != nil { + return false, err + } + taux, err := decSc(proof.Taux, "taux") + if err != nil { + return false, err + } + mu, err := decSc(proof.Mu, "mu") + if err != nil { + return false, err + } + txHat, err := decSc(proof.Tx, "tx") + if err != nil { + return false, err + } + aFin, err := decSc(proof.AFin, "aFin") + if err != nil { + return false, err + } + bFin, err := decSc(proof.BFin, "bFin") + if err != nil { + return false, err + } + + Ls := make([]*edwards25519.Point, bpLog) + Rs := make([]*edwards25519.Point, bpLog) + for k := 0; k < bpLog; k++ { + Ls[k], err = decPt(proof.Ls[k], fmt.Sprintf("L%d", k)) + if err != nil { + return false, err + } + Rs[k], err = decPt(proof.Rs[k], fmt.Sprintf("R%d", k)) + if err != nil { + return false, err + } + } + + // ── Replay transcript to get challenges ─────────────────────────────── + tr := newBPTranscript("range-proof/v2") + tr.absorbPoint(V) + tr.absorbPoint(A) + tr.absorbPoint(S) + y := tr.challenge() + z := tr.challenge() + tr.absorbPoint(T1) + tr.absorbPoint(T2) + x := tr.challenge() + x2 := edwards25519.NewScalar().Multiply(x, x) + tr.absorbScalar(taux) + tr.absorbScalar(mu) + tr.absorbScalar(txHat) + w := tr.challenge() + + z2 := edwards25519.NewScalar().Multiply(z, z) + z3 := edwards25519.NewScalar().Multiply(z2, z) + yn := powerVec(y, bpBits) + town := pow2Vec(bpBits) + + // ── Check 1: polynomial commitment ──────────────────────────────────── + // t̂·BpH + τₓ·G == z²·V + δ(y,z)·BpH + x·T₁ + x²·T₂ + // + // δ(y,z) = (z - z²)·⟨1ⁿ,yⁿ⟩ - z³·⟨1ⁿ,2ⁿ⟩ + sumYn := edwards25519.NewScalar() + for _, yi := range yn { + sumYn.Add(sumYn, yi) + } + sum2n := edwards25519.NewScalar() + for _, ti := range town { + sum2n.Add(sum2n, ti) + } + zMinusZ2 := edwards25519.NewScalar().Subtract(z, z2) + negZ3s2n := edwards25519.NewScalar().Negate(edwards25519.NewScalar().Multiply(z3, sum2n)) + delta := edwards25519.NewScalar().MultiplyAdd(zMinusZ2, sumYn, negZ3s2n) + + lhs := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarMult(txHat, BpH), + new(edwards25519.Point).ScalarBaseMult(taux), + ) + rhs := new(edwards25519.Point).ScalarMult(z2, V) + rhs.Add(rhs, new(edwards25519.Point).ScalarMult(delta, BpH)) + rhs.Add(rhs, new(edwards25519.Point).ScalarMult(x, T1)) + rhs.Add(rhs, new(edwards25519.Point).ScalarMult(x2, T2)) + if lhs.Equal(rhs) != 1 { + return false, nil + } + + // ── Reconstruct P for IPA verification ─────────────────────────────── + // P = A + x·S - z·Σ Gvec[i] + z·Σ Hvec[i] + z²·Σ 2ⁱ·H'[i] + t̂·Uw + // + // (derived by expanding commitments A and S; the μ·G terms cancel) + Uw := new(edwards25519.Point).ScalarMult(w, bpU) + + yInvPow := powerVec(edwards25519.NewScalar().Invert(y), bpBits) + hPrime := make([]*edwards25519.Point, bpBits) + for i := 0; i < bpBits; i++ { + hPrime[i] = new(edwards25519.Point).ScalarMult(yInvPow[i], bpHvec[i]) + } + + // P = A + x·S - μ·G + t̂·Uw - z·⟨1ⁿ,Gvec⟩ + z·⟨1ⁿ,Hvec⟩ + z²·⟨2ⁿ,H'⟩ + // + // Derivation: + // ⟨l,G⟩ + ⟨r,H'⟩ = (A+x·S-μ·G) - z·⟨1,G⟩ + z·⟨1,Hvec⟩ + z²·⟨2ⁿ,H'⟩ + // (the z·⟨1,Hvec⟩ comes from the y·y⁻¹=1 cancellation in ⟨r,H'⟩) + P := new(edwards25519.Point).Add(A, new(edwards25519.Point).ScalarMult(x, S)) + // subtract μ·G (μ = α + ρx; this removes the G blinding from A+x·S) + P.Add(P, new(edwards25519.Point).ScalarBaseMult(edwards25519.NewScalar().Negate(mu))) + P.Add(P, new(edwards25519.Point).ScalarMult(txHat, Uw)) + negZ := edwards25519.NewScalar().Negate(z) + for i := 0; i < bpBits; i++ { + P.Add(P, new(edwards25519.Point).ScalarMult(negZ, bpGvec[i])) + P.Add(P, new(edwards25519.Point).ScalarMult(z, bpHvec[i])) + coeff := edwards25519.NewScalar().Multiply(z2, town[i]) + P.Add(P, new(edwards25519.Point).ScalarMult(coeff, hPrime[i])) + } + + // ── Check 2: IPA ────────────────────────────────────────────────────── + // Replay IPA challenges and fold P: P_{k+1} = P_k + u_k·L_k + u_k⁻¹·R_k + ipaU := [bpLog]*edwards25519.Scalar{} + ipaUInv := [bpLog]*edwards25519.Scalar{} + for k := 0; k < bpLog; k++ { + tr.absorbPoint(Ls[k]) + tr.absorbPoint(Rs[k]) + ipaU[k] = tr.challenge() + ipaUInv[k] = edwards25519.NewScalar().Invert(ipaU[k]) + // P' = P + u·L + u⁻¹·R (our L/R use aR/bL split → linear u scaling) + P.Add(P, new(edwards25519.Point).ScalarMult(ipaU[k], Ls[k])) + P.Add(P, new(edwards25519.Point).ScalarMult(ipaUInv[k], Rs[k])) + } + // P is now P_final + + // Fold generators using the same challenges + Gfin := pointSlice(bpGvec) + Hfin := make([]*edwards25519.Point, bpBits) + copy(Hfin, hPrime) + for k := 0; k < bpLog; k++ { + half := len(Gfin) / 2 + uInv := ipaUInv[k] + uk := ipaU[k] + newG := make([]*edwards25519.Point, half) + newH := make([]*edwards25519.Point, half) + for i := 0; i < half; i++ { + newG[i] = new(edwards25519.Point).Add( + Gfin[i], + new(edwards25519.Point).ScalarMult(uInv, Gfin[half+i]), + ) + newH[i] = new(edwards25519.Point).Add( + Hfin[i], + new(edwards25519.Point).ScalarMult(uk, Hfin[half+i]), + ) + } + Gfin = newG + Hfin = newH + } + // Gfin[0], Hfin[0] are the final single generators + + // Final check: a·G_fin + b·H_fin + a·b·Uw == P_final + ab := edwards25519.NewScalar().Multiply(aFin, bFin) + check := new(edwards25519.Point).Add( + new(edwards25519.Point).ScalarMult(aFin, Gfin[0]), + new(edwards25519.Point).ScalarMult(bFin, Hfin[0]), + ) + check.Add(check, new(edwards25519.Point).ScalarMult(ab, Uw)) + + return check.Equal(P) == 1, nil } // ─── Inner Product Argument (IPA) ───────────────────────────────────────────── @@ -573,73 +607,73 @@ func VerifyRange(proof *RangeProof) (bool, error) { // ipaProve recursively proves ⟨a,b⟩ is consistent with generators G and H. // Returns L/R commitments per round and the final scalar pair (a,b). func ipaProve( - G, H []*edwards25519.Point, - a, b []*edwards25519.Scalar, - U *edwards25519.Point, - tr *bpTranscript, + G, H []*edwards25519.Point, + a, b []*edwards25519.Scalar, + U *edwards25519.Point, + tr *bpTranscript, ) (Ls [bpLog]*edwards25519.Point, Rs [bpLog]*edwards25519.Point, aFin, bFin *edwards25519.Scalar, err error) { - n := len(a) - for round := 0; n > 1; round++ { - if n%2 != 0 { - err = fmt.Errorf("IPA: n=%d is not a power of 2", n) - return - } - half := n / 2 - aL, aR := a[:half], a[half:] - bL, bR := b[:half], b[half:] - GL, GR := G[:half], G[half:] - HL, HR := H[:half], H[half:] - - // cL = ⟨aR,bL⟩, cR = ⟨aL,bR⟩ - cL := innerProduct(aR, bL) - cR := innerProduct(aL, bR) - - // L = ⟨aR,GL⟩ + ⟨bL,HR⟩ + cL·U - L := msm(aR, GL) - L.Add(L, msm(bL, HR)) - L.Add(L, new(edwards25519.Point).ScalarMult(cL, U)) - - // R = ⟨aL,GR⟩ + ⟨bR,HL⟩ + cR·U - R := msm(aL, GR) - R.Add(R, msm(bR, HL)) - R.Add(R, new(edwards25519.Point).ScalarMult(cR, U)) - - Ls[round] = L - Rs[round] = R - - // Fiat-Shamir challenge u for this round - tr.absorbPoint(L) - tr.absorbPoint(R) - u := tr.challenge() - uInv := edwards25519.NewScalar().Invert(u) - - // Fold: G' = GL + u⁻¹·GR, H' = HL + u·HR - newG := make([]*edwards25519.Point, half) - newH := make([]*edwards25519.Point, half) - for i := 0; i < half; i++ { - newG[i] = new(edwards25519.Point).Add(GL[i], new(edwards25519.Point).ScalarMult(uInv, GR[i])) - newH[i] = new(edwards25519.Point).Add(HL[i], new(edwards25519.Point).ScalarMult(u, HR[i])) - } - // Fold: a' = aL + u·aR, b' = bL + u⁻¹·bR - newA := make([]*edwards25519.Scalar, half) - newB := make([]*edwards25519.Scalar, half) - for i := 0; i < half; i++ { - newA[i] = edwards25519.NewScalar().MultiplyAdd(u, aR[i], aL[i]) - newB[i] = edwards25519.NewScalar().MultiplyAdd(uInv, bR[i], bL[i]) - } - G, H, a, b, n = newG, newH, newA, newB, half - } - return Ls, Rs, a[0], b[0], nil + n := len(a) + for round := 0; n > 1; round++ { + if n%2 != 0 { + err = fmt.Errorf("IPA: n=%d is not a power of 2", n) + return + } + half := n / 2 + aL, aR := a[:half], a[half:] + bL, bR := b[:half], b[half:] + GL, GR := G[:half], G[half:] + HL, HR := H[:half], H[half:] + + // cL = ⟨aR,bL⟩, cR = ⟨aL,bR⟩ + cL := innerProduct(aR, bL) + cR := innerProduct(aL, bR) + + // L = ⟨aR,GL⟩ + ⟨bL,HR⟩ + cL·U + L := msm(aR, GL) + L.Add(L, msm(bL, HR)) + L.Add(L, new(edwards25519.Point).ScalarMult(cL, U)) + + // R = ⟨aL,GR⟩ + ⟨bR,HL⟩ + cR·U + R := msm(aL, GR) + R.Add(R, msm(bR, HL)) + R.Add(R, new(edwards25519.Point).ScalarMult(cR, U)) + + Ls[round] = L + Rs[round] = R + + // Fiat-Shamir challenge u for this round + tr.absorbPoint(L) + tr.absorbPoint(R) + u := tr.challenge() + uInv := edwards25519.NewScalar().Invert(u) + + // Fold: G' = GL + u⁻¹·GR, H' = HL + u·HR + newG := make([]*edwards25519.Point, half) + newH := make([]*edwards25519.Point, half) + for i := 0; i < half; i++ { + newG[i] = new(edwards25519.Point).Add(GL[i], new(edwards25519.Point).ScalarMult(uInv, GR[i])) + newH[i] = new(edwards25519.Point).Add(HL[i], new(edwards25519.Point).ScalarMult(u, HR[i])) + } + // Fold: a' = aL + u·aR, b' = bL + u⁻¹·bR + newA := make([]*edwards25519.Scalar, half) + newB := make([]*edwards25519.Scalar, half) + for i := 0; i < half; i++ { + newA[i] = edwards25519.NewScalar().MultiplyAdd(u, aR[i], aL[i]) + newB[i] = edwards25519.NewScalar().MultiplyAdd(uInv, bR[i], bL[i]) + } + G, H, a, b, n = newG, newH, newA, newB, half + } + return Ls, Rs, a[0], b[0], nil } // ─── Legacy helpers (kept for isZero32 used by old tests) ───────────────────── func isZero32(b [32]byte) bool { - var zero [32]byte - for i := range b { - if b[i] != zero[i] { - return false - } - } - return true + var zero [32]byte + for i := range b { + if b[i] != zero[i] { + return false + } + } + return true } diff --git a/crypto/coverage2_test.go b/crypto/coverage2_test.go index c9e0e8c..b4fc231 100644 --- a/crypto/coverage2_test.go +++ b/crypto/coverage2_test.go @@ -3,199 +3,199 @@ package crypto_test // Second round of coverage tests targeting remaining gaps. import ( - "testing" + "testing" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // ─── wallet.go: WalletKeysFromSeed, PointFromBytes, ScalarFromBytes ─────────── func TestWalletKeysFromSeed_Deterministic(t *testing.T) { - seed := make([]byte, 32) - seed[0] = 42 - kp1, err := crypto.WalletKeysFromSeed(seed) - if err != nil { - t.Fatalf("WalletKeysFromSeed: %v", err) - } - kp2, err := crypto.WalletKeysFromSeed(seed) - if err != nil { - t.Fatalf("WalletKeysFromSeed 2nd: %v", err) - } - if kp1.Spend.Public != kp2.Spend.Public { - t.Error("WalletKeysFromSeed must be deterministic (SpendPub)") - } - if kp1.View.Public != kp2.View.Public { - t.Error("WalletKeysFromSeed must be deterministic (ViewPub)") - } + seed := make([]byte, 32) + seed[0] = 42 + kp1, err := crypto.WalletKeysFromSeed(seed) + if err != nil { + t.Fatalf("WalletKeysFromSeed: %v", err) + } + kp2, err := crypto.WalletKeysFromSeed(seed) + if err != nil { + t.Fatalf("WalletKeysFromSeed 2nd: %v", err) + } + if kp1.Spend.Public != kp2.Spend.Public { + t.Error("WalletKeysFromSeed must be deterministic (SpendPub)") + } + if kp1.View.Public != kp2.View.Public { + t.Error("WalletKeysFromSeed must be deterministic (ViewPub)") + } } func TestWalletKeysFromSeed_DifferentSeeds(t *testing.T) { - s1 := make([]byte, 32) - s1[0] = 1 - s2 := make([]byte, 32) - s2[0] = 2 - kp1, _ := crypto.WalletKeysFromSeed(s1) - kp2, _ := crypto.WalletKeysFromSeed(s2) - if kp1.Spend.Public == kp2.Spend.Public { - t.Error("different seeds must produce different keys") - } + s1 := make([]byte, 32) + s1[0] = 1 + s2 := make([]byte, 32) + s2[0] = 2 + kp1, _ := crypto.WalletKeysFromSeed(s1) + kp2, _ := crypto.WalletKeysFromSeed(s2) + if kp1.Spend.Public == kp2.Spend.Public { + t.Error("different seeds must produce different keys") + } } func TestPointFromBytes_Invalid(t *testing.T) { - _, err := crypto.PointFromBytes([]byte("not-a-valid-point")) - if err == nil { - t.Error("expected error for invalid point bytes") - } + _, err := crypto.PointFromBytes([]byte("not-a-valid-point")) + if err == nil { + t.Error("expected error for invalid point bytes") + } } func TestScalarFromBytes_Invalid(t *testing.T) { - big := make([]byte, 32) - for i := range big { - big[i] = 0xFF - } - _, err := crypto.ScalarFromBytes(big) - if err == nil { - t.Error("expected error for non-canonical scalar bytes") - } + big := make([]byte, 32) + for i := range big { + big[i] = 0xFF + } + _, err := crypto.ScalarFromBytes(big) + if err == nil { + t.Error("expected error for non-canonical scalar bytes") + } } func TestScalarFromBytes_Valid(t *testing.T) { - var b [32]byte - b[0] = 1 - s, err := crypto.ScalarFromBytes(b[:]) - if err != nil { - t.Fatalf("ScalarFromBytes([1,...0]): %v", err) - } - if s == nil { - t.Error("ScalarFromBytes must return non-nil for valid input") - } + var b [32]byte + b[0] = 1 + s, err := crypto.ScalarFromBytes(b[:]) + if err != nil { + t.Fatalf("ScalarFromBytes([1,...0]): %v", err) + } + if s == nil { + t.Error("ScalarFromBytes must return non-nil for valid input") + } } // ─── pedersen.go: Commit, CommitSum ────────────────────────────────────────── func TestCommit_Deterministic(t *testing.T) { - bf, _ := crypto.NewBlindFactor() - c1, err := crypto.Commit(42, bf) - if err != nil { - t.Fatal(err) - } - c2, _ := crypto.Commit(42, bf) - if c1 != c2 { - t.Error("Commit must be deterministic") - } - c3, _ := crypto.Commit(43, bf) - if c1 == c3 { - t.Error("different values must produce different commitments") - } + bf, _ := crypto.NewBlindFactor() + c1, err := crypto.Commit(42, bf) + if err != nil { + t.Fatal(err) + } + c2, _ := crypto.Commit(42, bf) + if c1 != c2 { + t.Error("Commit must be deterministic") + } + c3, _ := crypto.Commit(43, bf) + if c1 == c3 { + t.Error("different values must produce different commitments") + } } func TestCommit_ZeroValue(t *testing.T) { - bf, _ := crypto.NewBlindFactor() - _, err := crypto.Commit(0, bf) - if err != nil { - t.Fatalf("Commit(0): %v", err) - } + bf, _ := crypto.NewBlindFactor() + _, err := crypto.Commit(0, bf) + if err != nil { + t.Fatalf("Commit(0): %v", err) + } } func TestCommitSum_Basic(t *testing.T) { - bIn, _ := crypto.NewBlindFactor() - bOut, _ := crypto.NewBlindFactor() - bFee := crypto.BlindFactor{} // zero blind for fee - - commitIn, _ := crypto.Commit(100, bIn) - commitOut, _ := crypto.Commit(70, bOut) - feeCommit, _ := crypto.Commit(0, bFee) - - _, err := crypto.CommitSum( - []crypto.Commitment{commitIn}, - []crypto.Commitment{commitOut}, - feeCommit, - ) - if err != nil { - t.Fatalf("CommitSum: %v", err) - } + bIn, _ := crypto.NewBlindFactor() + bOut, _ := crypto.NewBlindFactor() + bFee := crypto.BlindFactor{} // zero blind for fee + + commitIn, _ := crypto.Commit(100, bIn) + commitOut, _ := crypto.Commit(70, bOut) + feeCommit, _ := crypto.Commit(0, bFee) + + _, err := crypto.CommitSum( + []crypto.Commitment{commitIn}, + []crypto.Commitment{commitOut}, + feeCommit, + ) + if err != nil { + t.Fatalf("CommitSum: %v", err) + } } // ─── stealth.go: CreateStealthAddress, ScanForOutput ───────────────────────── func TestCreateStealthAddress(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - sa, err := crypto.CreateStealthAddress(wk.Spend.Public, wk.View.Public) - if err != nil { - t.Fatal(err) - } - var zero crypto.Point32 - if sa.OneTimePub == zero { - t.Error("StealthAddress.OneTimePub must not be zero") - } - if sa.TxPubKey == zero { - t.Error("StealthAddress.TxPubKey must not be zero") - } + wk, _ := crypto.GenerateWalletKeys() + sa, err := crypto.CreateStealthAddress(wk.Spend.Public, wk.View.Public) + if err != nil { + t.Fatal(err) + } + var zero crypto.Point32 + if sa.OneTimePub == zero { + t.Error("StealthAddress.OneTimePub must not be zero") + } + if sa.TxPubKey == zero { + t.Error("StealthAddress.TxPubKey must not be zero") + } } func TestScanForOutput_Detects(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - sa, err := crypto.CreateStealthAddress(wk.Spend.Public, wk.View.Public) - if err != nil { - t.Fatal(err) - } - scalar, err := crypto.ScanForOutput(wk.View.Private, wk.Spend.Public, sa.TxPubKey, sa.OneTimePub) - if err != nil { - t.Fatal(err) - } - if scalar == nil { - t.Error("ScanForOutput must return a scalar for our own output") - } + wk, _ := crypto.GenerateWalletKeys() + sa, err := crypto.CreateStealthAddress(wk.Spend.Public, wk.View.Public) + if err != nil { + t.Fatal(err) + } + scalar, err := crypto.ScanForOutput(wk.View.Private, wk.Spend.Public, sa.TxPubKey, sa.OneTimePub) + if err != nil { + t.Fatal(err) + } + if scalar == nil { + t.Error("ScanForOutput must return a scalar for our own output") + } } func TestScanForOutput_Miss(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - wkOther, _ := crypto.GenerateWalletKeys() - sa, _ := crypto.CreateStealthAddress(wkOther.Spend.Public, wkOther.View.Public) - scalar, _ := crypto.ScanForOutput(wk.View.Private, wk.Spend.Public, sa.TxPubKey, sa.OneTimePub) - if scalar != nil { - t.Error("ScanForOutput must return nil for someone else's output") - } + wk, _ := crypto.GenerateWalletKeys() + wkOther, _ := crypto.GenerateWalletKeys() + sa, _ := crypto.CreateStealthAddress(wkOther.Spend.Public, wkOther.View.Public) + scalar, _ := crypto.ScanForOutput(wk.View.Private, wk.Spend.Public, sa.TxPubKey, sa.OneTimePub) + if scalar != nil { + t.Error("ScanForOutput must return nil for someone else's output") + } } // ─── bulletproof.go: ProveRange / VerifyRange ───────────────────────────────── func TestBulletproof_RoundTrip(t *testing.T) { - bf, _ := crypto.NewBlindFactor() - proof, err := crypto.ProveRange(100_000_000, bf) - if err != nil { - t.Fatalf("ProveRange: %v", err) - } - if proof == nil { - t.Error("proof must not be nil") - } - ok, err := crypto.VerifyRange(proof) - if err != nil { - t.Fatalf("VerifyRange: %v", err) - } - if !ok { - t.Error("VerifyRange must return true for a valid proof") - } + bf, _ := crypto.NewBlindFactor() + proof, err := crypto.ProveRange(100_000_000, bf) + if err != nil { + t.Fatalf("ProveRange: %v", err) + } + if proof == nil { + t.Error("proof must not be nil") + } + ok, err := crypto.VerifyRange(proof) + if err != nil { + t.Fatalf("VerifyRange: %v", err) + } + if !ok { + t.Error("VerifyRange must return true for a valid proof") + } } func TestBulletproof_ZeroValue(t *testing.T) { - bf, _ := crypto.NewBlindFactor() - _, err := crypto.ProveRange(0, bf) - if err != nil { - t.Fatalf("ProveRange(0): %v", err) - } + bf, _ := crypto.NewBlindFactor() + _, err := crypto.ProveRange(0, bf) + if err != nil { + t.Fatalf("ProveRange(0): %v", err) + } } // ─── keyimage.go: ComputeKeyImage with valid point ──────────────────────────── func TestComputeKeyImage_ValidPoint(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ki, err := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) - if err != nil { - t.Fatalf("ComputeKeyImage: %v", err) - } - var zero crypto.KeyImage - if ki == zero { - t.Error("KeyImage must be non-zero for valid inputs") - } + wk, _ := crypto.GenerateWalletKeys() + ki, err := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) + if err != nil { + t.Fatalf("ComputeKeyImage: %v", err) + } + var zero crypto.KeyImage + if ki == zero { + t.Error("KeyImage must be non-zero for valid inputs") + } } diff --git a/crypto/coverage3_test.go b/crypto/coverage3_test.go index fdbb9bd..6d185c5 100644 --- a/crypto/coverage3_test.go +++ b/crypto/coverage3_test.go @@ -3,198 +3,198 @@ package crypto_test // Third round: target specific uncovered branches in crypto. import ( - "testing" + "testing" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // ─── wallet.go: WalletKeysFromSeed short-seed error ─────────────────────────── func TestWalletKeysFromSeed_ShortSeed(t *testing.T) { - _, err := crypto.WalletKeysFromSeed([]byte("short")) - if err == nil { - t.Error("WalletKeysFromSeed must reject seeds shorter than 32 bytes") - } + _, err := crypto.WalletKeysFromSeed([]byte("short")) + if err == nil { + t.Error("WalletKeysFromSeed must reject seeds shorter than 32 bytes") + } } // ─── keys.go: ValidatorPrivKeyFromBytes / ValidatorPubKeyFromBytes ──────────── func TestValidatorPrivKeyFromBytes_WrongLen(t *testing.T) { - _, err := crypto.ValidatorPrivKeyFromBytes([]byte("too-short")) - if err == nil { - t.Error("expected error for wrong-length private key bytes") - } + _, err := crypto.ValidatorPrivKeyFromBytes([]byte("too-short")) + if err == nil { + t.Error("expected error for wrong-length private key bytes") + } } func TestValidatorPrivKeyFromBytes_RoundTrip(t *testing.T) { - priv, _, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatal(err) - } - priv2, err := crypto.ValidatorPrivKeyFromBytes([]byte(priv)) - if err != nil { - t.Fatalf("ValidatorPrivKeyFromBytes: %v", err) - } - if string(priv2) != string(priv) { - t.Error("round-trip must preserve private key bytes") - } + priv, _, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + priv2, err := crypto.ValidatorPrivKeyFromBytes([]byte(priv)) + if err != nil { + t.Fatalf("ValidatorPrivKeyFromBytes: %v", err) + } + if string(priv2) != string(priv) { + t.Error("round-trip must preserve private key bytes") + } } func TestValidatorPubKeyFromBytes_WrongLen(t *testing.T) { - _, err := crypto.ValidatorPubKeyFromBytes([]byte("nope")) - if err == nil { - t.Error("expected error for wrong-length public key bytes") - } + _, err := crypto.ValidatorPubKeyFromBytes([]byte("nope")) + if err == nil { + t.Error("expected error for wrong-length public key bytes") + } } func TestValidatorPubKeyFromBytes_RoundTrip(t *testing.T) { - _, pub, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatal(err) - } - pub2, err := crypto.ValidatorPubKeyFromBytes([]byte(pub)) - if err != nil { - t.Fatalf("ValidatorPubKeyFromBytes: %v", err) - } - if pub2.Hex() != pub.Hex() { - t.Error("round-trip must preserve public key bytes") - } + _, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + pub2, err := crypto.ValidatorPubKeyFromBytes([]byte(pub)) + if err != nil { + t.Fatalf("ValidatorPubKeyFromBytes: %v", err) + } + if pub2.Hex() != pub.Hex() { + t.Error("round-trip must preserve public key bytes") + } } // ─── keys.go: ID() ──────────────────────────────────────────────────────────── func TestValidatorPubKey_ID_Short(t *testing.T) { - _, pub, _ := crypto.GenerateValidatorKey() - id := pub.ID() - if len(id) != 8 { - t.Errorf("ID() length = %d, want 8", len(id)) - } - if id != pub.Hex()[:8] { - t.Errorf("ID() = %q, want first 8 chars of Hex()", id) - } + _, pub, _ := crypto.GenerateValidatorKey() + id := pub.ID() + if len(id) != 8 { + t.Errorf("ID() length = %d, want 8", len(id)) + } + if id != pub.Hex()[:8] { + t.Errorf("ID() = %q, want first 8 chars of Hex()", id) + } } // ─── pedersen.go: Commit with non-canonical blind triggers clampScalar ───────── func TestCommit_NonCanonicalBlind(t *testing.T) { - var bad crypto.BlindFactor - for i := range bad { - bad[i] = 0xFF - } - // Must not panic; may succeed (after clamping) or return error - _, err := crypto.Commit(42, bad) - t.Logf("Commit with 0xFF blind: err=%v", err) + var bad crypto.BlindFactor + for i := range bad { + bad[i] = 0xFF + } + // Must not panic; may succeed (after clamping) or return error + _, err := crypto.Commit(42, bad) + t.Logf("Commit with 0xFF blind: err=%v", err) } // ─── pedersen.go: BlindSum empty-slice paths ───────────────────────────────── func TestBlindSum_AllNegative(t *testing.T) { - b, _ := crypto.NewBlindFactor() - _, err := crypto.BlindSum(nil, []crypto.BlindFactor{b}) - if err != nil { - t.Fatalf("BlindSum(nil, [b]): %v", err) - } + b, _ := crypto.NewBlindFactor() + _, err := crypto.BlindSum(nil, []crypto.BlindFactor{b}) + if err != nil { + t.Fatalf("BlindSum(nil, [b]): %v", err) + } } func TestBlindSum_AllPositive(t *testing.T) { - b1, _ := crypto.NewBlindFactor() - b2, _ := crypto.NewBlindFactor() - result, err := crypto.BlindSum([]crypto.BlindFactor{b1, b2}, nil) - if err != nil { - t.Fatalf("BlindSum([b1,b2], nil): %v", err) - } - var zero crypto.BlindFactor - if result == zero { - t.Error("BlindSum([b1,b2], nil) must not be zero (unless b1+b2=0 mod l)") - } + b1, _ := crypto.NewBlindFactor() + b2, _ := crypto.NewBlindFactor() + result, err := crypto.BlindSum([]crypto.BlindFactor{b1, b2}, nil) + if err != nil { + t.Fatalf("BlindSum([b1,b2], nil): %v", err) + } + var zero crypto.BlindFactor + if result == zero { + t.Error("BlindSum([b1,b2], nil) must not be zero (unless b1+b2=0 mod l)") + } } // ─── address.go: Short() ───────────────────────────────────────────────────── func TestAddress_Short(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) - short := addr.Short() - if len(short) == 0 { - t.Error("Short() must return a non-empty string") - } - if len(short) >= len(addr.String()) { - t.Error("Short() must be shorter than the full address") - } + wk, _ := crypto.GenerateWalletKeys() + addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) + short := addr.Short() + if len(short) == 0 { + t.Error("Short() must return a non-empty string") + } + if len(short) >= len(addr.String()) { + t.Error("Short() must be shorter than the full address") + } } // ─── stealth.go: CreateStealthOutput ───────────────────────────────────────── func TestCreateStealthOutput_Fields(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - out, err := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) - if err != nil { - t.Fatalf("CreateStealthOutput: %v", err) - } - var zeroPoint crypto.Point32 - if out.OneTimePub == zeroPoint { - t.Error("StealthOutput.OneTimePub must not be zero") - } - if out.TxPubKey == zeroPoint { - t.Error("StealthOutput.TxPubKey must not be zero") - } + wk, _ := crypto.GenerateWalletKeys() + out, err := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) + if err != nil { + t.Fatalf("CreateStealthOutput: %v", err) + } + var zeroPoint crypto.Point32 + if out.OneTimePub == zeroPoint { + t.Error("StealthOutput.OneTimePub must not be zero") + } + if out.TxPubKey == zeroPoint { + t.Error("StealthOutput.TxPubKey must not be zero") + } } // ─── ringct.go: MLSAGSign / MLSAGVerify with RingMember ───────────────────── func TestMLSAG_SignAndVerify_v2(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = wk.Spend.Public - for i := 1; i < crypto.RingSize; i++ { - decoy, _ := crypto.GenerateWalletKeys() - ring[i] = decoy.Spend.Public - } - msg := crypto.Hash32{} - msg[0] = 0xBC - - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - t.Fatalf("MLSAGSign: %v", err) - } - ok, err := crypto.MLSAGVerify(msg, ring, sig) - if err != nil { - t.Fatalf("MLSAGVerify: %v", err) - } - if !ok { - t.Error("MLSAGVerify must return true for a valid ring signature") - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = wk.Spend.Public + for i := 1; i < crypto.RingSize; i++ { + decoy, _ := crypto.GenerateWalletKeys() + ring[i] = decoy.Spend.Public + } + msg := crypto.Hash32{} + msg[0] = 0xBC + + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + t.Fatalf("MLSAGSign: %v", err) + } + ok, err := crypto.MLSAGVerify(msg, ring, sig) + if err != nil { + t.Fatalf("MLSAGVerify: %v", err) + } + if !ok { + t.Error("MLSAGVerify must return true for a valid ring signature") + } } func TestMLSAG_VerifyTamperedMsg_v2(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = wk.Spend.Public - for i := 1; i < crypto.RingSize; i++ { - decoy, _ := crypto.GenerateWalletKeys() - ring[i] = decoy.Spend.Public - } - msg := crypto.Hash32{} - sig, _ := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - - msg[0] = 0xFF - ok, _ := crypto.MLSAGVerify(msg, ring, sig) - if ok { - t.Error("MLSAGVerify must return false for tampered message") - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = wk.Spend.Public + for i := 1; i < crypto.RingSize; i++ { + decoy, _ := crypto.GenerateWalletKeys() + ring[i] = decoy.Spend.Public + } + msg := crypto.Hash32{} + sig, _ := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + + msg[0] = 0xFF + ok, _ := crypto.MLSAGVerify(msg, ring, sig) + if ok { + t.Error("MLSAGVerify must return false for tampered message") + } } // ─── wallet.go: AddScalars ──────────────────────────────────────────────────── func TestAddScalars_SumIsValid(t *testing.T) { - wk1, _ := crypto.GenerateWalletKeys() - wk2, _ := crypto.GenerateWalletKeys() - sum, err := crypto.AddScalars(wk1.Spend.Private, wk2.Spend.Private) - if err != nil { - t.Fatalf("AddScalars: %v", err) - } - var zero crypto.Scalar32 - if sum == zero { - t.Error("AddScalars result must not be zero for random inputs") - } + wk1, _ := crypto.GenerateWalletKeys() + wk2, _ := crypto.GenerateWalletKeys() + sum, err := crypto.AddScalars(wk1.Spend.Private, wk2.Spend.Private) + if err != nil { + t.Fatalf("AddScalars: %v", err) + } + var zero crypto.Scalar32 + if sum == zero { + t.Error("AddScalars result must not be zero for random inputs") + } } diff --git a/crypto/coverage5_test.go b/crypto/coverage5_test.go index 79b2ae3..fb93446 100644 --- a/crypto/coverage5_test.go +++ b/crypto/coverage5_test.go @@ -6,10 +6,10 @@ package crypto_test // short-key path, BlindSum error paths, ScanForOutput miss sub-paths. import ( - "strings" - "testing" + "strings" + "testing" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // ─── address.go: hrPrefix default branch ───────────────────────────────────── @@ -17,24 +17,24 @@ import ( // TestEncodeAddress_UnknownNet exercises the default hrPrefix branch by using // a NetworkByte that is not Mainnet/Testnet/Devnet. func TestEncodeAddress_UnknownNet(t *testing.T) { - wk, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("GenerateWalletKeys: %v", err) - } - const unknownNet crypto.NetworkByte = 0x99 - addr := crypto.EncodeAddress(unknownNet, wk.Spend.Public, wk.View.Public) - s := addr.String() - // The prefix should be "net99" (fmt.Sprintf("net%02x", 0x99)) - if !strings.HasPrefix(s, "net99") { - t.Errorf("expected prefix 'net99', got %q", s[:min5(len(s), 10)]) - } + wk, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + const unknownNet crypto.NetworkByte = 0x99 + addr := crypto.EncodeAddress(unknownNet, wk.Spend.Public, wk.View.Public) + s := addr.String() + // The prefix should be "net99" (fmt.Sprintf("net%02x", 0x99)) + if !strings.HasPrefix(s, "net99") { + t.Errorf("expected prefix 'net99', got %q", s[:min5(len(s), 10)]) + } } func min5(a, b int) int { - if a < b { - return a - } - return b + if a < b { + return a + } + return b } // ─── address.go: Short() short-string path ──────────────────────────────────── @@ -42,99 +42,99 @@ func min5(a, b int) int { // TestAddress_Short_AlreadyShort tests the early-return branch when the // address string is ≤12 characters. func TestAddress_Short_AlreadyShort(t *testing.T) { - short := crypto.Address("aprXXX") - got := short.Short() - if got != "aprXXX" { - t.Errorf("Short() = %q, want %q", got, "aprXXX") - } + short := crypto.Address("aprXXX") + got := short.Short() + if got != "aprXXX" { + t.Errorf("Short() = %q, want %q", got, "aprXXX") + } } // TestAddress_Short_Long verifies that a normal-length address gets truncated. func TestAddress_Short_Long(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) - got := addr.Short() - if len(got) != 15 { // 12 + "..." - t.Errorf("Short() len = %d, want 15 (12 chars + ...): %s", len(got), got) - } - if !strings.HasSuffix(got, "...") { - t.Errorf("Short() missing trailing ...: %q", got) - } + wk, _ := crypto.GenerateWalletKeys() + addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) + got := addr.Short() + if len(got) != 15 { // 12 + "..." + t.Errorf("Short() len = %d, want 15 (12 chars + ...): %s", len(got), got) + } + if !strings.HasSuffix(got, "...") { + t.Errorf("Short() missing trailing ...: %q", got) + } } // ─── address.go: DecodeAddress error paths ─────────────────────────────────── // TestDecodeAddress_NoPrefix checks the "missing network prefix" error. func TestDecodeAddress_NoPrefix(t *testing.T) { - _, _, _, err := crypto.DecodeAddress("xyz123notaprefix") - if err == nil { - t.Fatal("expected error for missing prefix, got nil") - } - if !strings.Contains(err.Error(), "missing network prefix") { - t.Errorf("unexpected error: %v", err) - } + _, _, _, err := crypto.DecodeAddress("xyz123notaprefix") + if err == nil { + t.Fatal("expected error for missing prefix, got nil") + } + if !strings.Contains(err.Error(), "missing network prefix") { + t.Errorf("unexpected error: %v", err) + } } // TestDecodeAddress_WrongLength checks that a truncated address is rejected. // The exact error depends on whether the short payload fails at the network-byte // check or the length check first; both indicate an invalid address. func TestDecodeAddress_WrongLength(t *testing.T) { - // "apr" prefix + very short base58 (too short payload) - _, _, _, err := crypto.DecodeAddress("aprABC") - if err == nil { - t.Fatal("expected error for wrong-length address, got nil") - } - validErrors := []string{"invalid address length", "missing network prefix", "invalid address"} - for _, msg := range validErrors { - if strings.Contains(err.Error(), msg) { - return // any of these is acceptable - } - } - t.Errorf("unexpected error message for short address: %v", err) + // "apr" prefix + very short base58 (too short payload) + _, _, _, err := crypto.DecodeAddress("aprABC") + if err == nil { + t.Fatal("expected error for wrong-length address, got nil") + } + validErrors := []string{"invalid address length", "missing network prefix", "invalid address"} + for _, msg := range validErrors { + if strings.Contains(err.Error(), msg) { + return // any of these is acceptable + } + } + t.Errorf("unexpected error message for short address: %v", err) } // TestDecodeAddress_BadChecksum verifies checksum validation. func TestDecodeAddress_BadChecksum(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) - // Flip last character to corrupt checksum. Pick a replacement that - // differs from the current last character — otherwise (~1/58 runs) - // the address is unchanged and decoding succeeds. - s := string(addr) - repl := "X" - if s[len(s)-1] == 'X' { - repl = "Y" - } - corrupted := crypto.Address(s[:len(s)-1] + repl) - _, _, _, err := crypto.DecodeAddress(corrupted) - if err == nil { - t.Fatal("expected checksum error, got nil") - } + wk, _ := crypto.GenerateWalletKeys() + addr := crypto.EncodeAddress(crypto.MainnetByte, wk.Spend.Public, wk.View.Public) + // Flip last character to corrupt checksum. Pick a replacement that + // differs from the current last character — otherwise (~1/58 runs) + // the address is unchanged and decoding succeeds. + s := string(addr) + repl := "X" + if s[len(s)-1] == 'X' { + repl = "Y" + } + corrupted := crypto.Address(s[:len(s)-1] + repl) + _, _, _, err := crypto.DecodeAddress(corrupted) + if err == nil { + t.Fatal("expected checksum error, got nil") + } } // TestDecodeAddress_Devnet encodes and decodes a devnet address. func TestDecodeAddress_Devnet(t *testing.T) { - wk, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("GenerateWalletKeys: %v", err) - } - addr := crypto.EncodeAddress(crypto.DevnetByte, wk.Spend.Public, wk.View.Public) - if !strings.HasPrefix(string(addr), "dapr") { - t.Errorf("devnet address prefix wrong: %q", string(addr)[:6]) - } - net, spend, view, err := crypto.DecodeAddress(addr) - if err != nil { - t.Fatalf("DecodeAddress devnet: %v", err) - } - if net != crypto.DevnetByte { - t.Errorf("net = 0x%02x, want DevnetByte 0x%02x", byte(net), byte(crypto.DevnetByte)) - } - if spend != wk.Spend.Public { - t.Error("spend key roundtrip mismatch") - } - if view != wk.View.Public { - t.Error("view key roundtrip mismatch") - } + wk, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + addr := crypto.EncodeAddress(crypto.DevnetByte, wk.Spend.Public, wk.View.Public) + if !strings.HasPrefix(string(addr), "dapr") { + t.Errorf("devnet address prefix wrong: %q", string(addr)[:6]) + } + net, spend, view, err := crypto.DecodeAddress(addr) + if err != nil { + t.Fatalf("DecodeAddress devnet: %v", err) + } + if net != crypto.DevnetByte { + t.Errorf("net = 0x%02x, want DevnetByte 0x%02x", byte(net), byte(crypto.DevnetByte)) + } + if spend != wk.Spend.Public { + t.Error("spend key roundtrip mismatch") + } + if view != wk.View.Public { + t.Error("view key roundtrip mismatch") + } } // ─── keys.go: ValidatorPubKey.ID short-key path ────────────────────────────── @@ -143,46 +143,46 @@ func TestDecodeAddress_Devnet(t *testing.T) { // the Hex() method and the ValidatorPubKey.ID long-key path to improve branch // hits in keys.go. func TestValidatorPubKey_Hex(t *testing.T) { - _, pub, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatalf("GenerateValidatorKey: %v", err) - } - h := pub.Hex() - if len(h) != 64 { - t.Errorf("Hex() length = %d, want 64", len(h)) - } + _, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatalf("GenerateValidatorKey: %v", err) + } + h := pub.Hex() + if len(h) != 64 { + t.Errorf("Hex() length = %d, want 64", len(h)) + } } // ─── pedersen.go: BlindSum edge cases ──────────────────────────────────────── // TestBlindSum_NilInputs verifies BlindSum returns zero for empty slices. func TestBlindSum_NilInputs(t *testing.T) { - result, err := crypto.BlindSum(nil, nil) - if err != nil { - t.Fatalf("BlindSum(nil,nil): %v", err) - } - // Zero scalar — all bytes should be 0 - var zero [32]byte - if result != zero { - t.Error("BlindSum(nil,nil) should be zero scalar") - } + result, err := crypto.BlindSum(nil, nil) + if err != nil { + t.Fatalf("BlindSum(nil,nil): %v", err) + } + // Zero scalar — all bytes should be 0 + var zero [32]byte + if result != zero { + t.Error("BlindSum(nil,nil) should be zero scalar") + } } // TestBlindSum_InOutCancel verifies that summing a blind against itself // (one in, one out) yields zero. func TestBlindSum_InOutCancel(t *testing.T) { - bf, err := crypto.NewBlindFactor() - if err != nil { - t.Fatalf("NewBlindFactor: %v", err) - } - result, err := crypto.BlindSum([]crypto.BlindFactor{bf}, []crypto.BlindFactor{bf}) - if err != nil { - t.Fatalf("BlindSum cancel: %v", err) - } - var zero [32]byte - if result != zero { - t.Error("BlindSum(x, x) should yield zero scalar, got non-zero") - } + bf, err := crypto.NewBlindFactor() + if err != nil { + t.Fatalf("NewBlindFactor: %v", err) + } + result, err := crypto.BlindSum([]crypto.BlindFactor{bf}, []crypto.BlindFactor{bf}) + if err != nil { + t.Fatalf("BlindSum cancel: %v", err) + } + var zero [32]byte + if result != zero { + t.Error("BlindSum(x, x) should yield zero scalar, got non-zero") + } } // ─── stealth.go: ScanForOutput miss sub-paths ──────────────────────────────── @@ -190,47 +190,47 @@ func TestBlindSum_InOutCancel(t *testing.T) { // TestScanForOutput_WrongTxPubKey checks that ScanForOutput returns nil (miss) // when the TxPubKey belongs to a different recipient. func TestScanForOutput_WrongTxPubKey(t *testing.T) { - alice, _ := crypto.GenerateWalletKeys() - bob, _ := crypto.GenerateWalletKeys() - - // Create a stealth output addressed to Bob - out, err := crypto.CreateStealthOutput(bob.Spend.Public, bob.View.Public) - if err != nil { - t.Fatalf("CreateStealthOutput: %v", err) - } - - // Alice tries to scan — should miss (nil result) - scalar, err := crypto.ScanForOutput( - alice.View.Private, - alice.Spend.Public, - out.TxPubKey, - out.OneTimePub, - ) - if err != nil { - t.Fatalf("ScanForOutput returned unexpected error: %v", err) - } - if scalar != nil { - t.Error("Alice should NOT own Bob's stealth output (expected nil scalar)") - } + alice, _ := crypto.GenerateWalletKeys() + bob, _ := crypto.GenerateWalletKeys() + + // Create a stealth output addressed to Bob + out, err := crypto.CreateStealthOutput(bob.Spend.Public, bob.View.Public) + if err != nil { + t.Fatalf("CreateStealthOutput: %v", err) + } + + // Alice tries to scan — should miss (nil result) + scalar, err := crypto.ScanForOutput( + alice.View.Private, + alice.Spend.Public, + out.TxPubKey, + out.OneTimePub, + ) + if err != nil { + t.Fatalf("ScanForOutput returned unexpected error: %v", err) + } + if scalar != nil { + t.Error("Alice should NOT own Bob's stealth output (expected nil scalar)") + } } // TestCreateStealthAddress_Roundtrip exercises CreateStealthAddress → ScanForOutput. func TestCreateStealthAddress_Roundtrip(t *testing.T) { - alice, _ := crypto.GenerateWalletKeys() - stealthAddr, err := crypto.CreateStealthAddress(alice.Spend.Public, alice.View.Public) - if err != nil { - t.Fatalf("CreateStealthAddress: %v", err) - } - scalar, err := crypto.ScanForOutput( - alice.View.Private, - alice.Spend.Public, - stealthAddr.TxPubKey, - stealthAddr.OneTimePub, - ) - if err != nil { - t.Fatalf("ScanForOutput: %v", err) - } - if scalar == nil { - t.Error("Alice should own her own stealth address output (expected non-nil scalar)") - } + alice, _ := crypto.GenerateWalletKeys() + stealthAddr, err := crypto.CreateStealthAddress(alice.Spend.Public, alice.View.Public) + if err != nil { + t.Fatalf("CreateStealthAddress: %v", err) + } + scalar, err := crypto.ScanForOutput( + alice.View.Private, + alice.Spend.Public, + stealthAddr.TxPubKey, + stealthAddr.OneTimePub, + ) + if err != nil { + t.Fatalf("ScanForOutput: %v", err) + } + if scalar == nil { + t.Error("Alice should own her own stealth address output (expected non-nil scalar)") + } } diff --git a/crypto/coverage_test.go b/crypto/coverage_test.go index b3387e8..85035dd 100644 --- a/crypto/coverage_test.go +++ b/crypto/coverage_test.go @@ -3,266 +3,266 @@ package crypto_test // Additional tests targeting uncovered code paths to push coverage above 90%. import ( - "bytes" - "testing" + "bytes" + "testing" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // ─── hash.go ────────────────────────────────────────────────────────────────── func TestHash_IsZero(t *testing.T) { - var z crypto.Hash32 - if !z.Zero() { - t.Error("empty Hash32.Zero() must be true") - } - h := crypto.HashBytes([]byte("hello")) - if h.Zero() { - t.Error("non-zero hash.Zero() must be false") - } + var z crypto.Hash32 + if !z.Zero() { + t.Error("empty Hash32.Zero() must be true") + } + h := crypto.HashBytes([]byte("hello")) + if h.Zero() { + t.Error("non-zero hash.Zero() must be false") + } } func TestHash_Bytes(t *testing.T) { - h := crypto.HashBytes([]byte("hello")) - b := h.Bytes() - if len(b) != 32 { - t.Fatalf("Bytes() len = %d, want 32", len(b)) - } - if !bytes.Equal(b, h[:]) { - t.Error("Bytes() must equal underlying array") - } + h := crypto.HashBytes([]byte("hello")) + b := h.Bytes() + if len(b) != 32 { + t.Fatalf("Bytes() len = %d, want 32", len(b)) + } + if !bytes.Equal(b, h[:]) { + t.Error("Bytes() must equal underlying array") + } } func TestHash_HashConcat(t *testing.T) { - a := []byte("alpha") - b := []byte("beta") - ab := crypto.HashConcat(a, b) - ba := crypto.HashConcat(b, a) - if ab == ba { - t.Error("HashConcat order must matter") - } - if crypto.HashConcat(a, b) != ab { - t.Error("HashConcat must be deterministic") - } + a := []byte("alpha") + b := []byte("beta") + ab := crypto.HashConcat(a, b) + ba := crypto.HashConcat(b, a) + if ab == ba { + t.Error("HashConcat order must matter") + } + if crypto.HashConcat(a, b) != ab { + t.Error("HashConcat must be deterministic") + } } // ─── keys.go ────────────────────────────────────────────────────────────────── func TestValidatorKey_PublicIDHex(t *testing.T) { - priv, pub, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatal(err) - } - if priv.Public().ID() != pub.ID() { - t.Error("priv.Public() must match generated pub") - } - if len(pub.Hex()) != 64 { - t.Errorf("Hex len = %d, want 64", len(pub.Hex())) - } - // ID() is a short 8-char prefix for logging; Hex() is the full 64-char hex - if len(pub.ID()) != 8 { - t.Errorf("ID() len = %d, want 8", len(pub.ID())) - } - if pub.ID() != pub.Hex()[:8] { - t.Error("ID() must equal Hex()[:8]") - } + priv, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + if priv.Public().ID() != pub.ID() { + t.Error("priv.Public() must match generated pub") + } + if len(pub.Hex()) != 64 { + t.Errorf("Hex len = %d, want 64", len(pub.Hex())) + } + // ID() is a short 8-char prefix for logging; Hex() is the full 64-char hex + if len(pub.ID()) != 8 { + t.Errorf("ID() len = %d, want 8", len(pub.ID())) + } + if pub.ID() != pub.Hex()[:8] { + t.Error("ID() must equal Hex()[:8]") + } } func TestValidatorKey_FromBytes_RoundTrip(t *testing.T) { - priv, pub, _ := crypto.GenerateValidatorKey() + priv, pub, _ := crypto.GenerateValidatorKey() - priv2, err := crypto.ValidatorPrivKeyFromBytes(priv.Bytes()) - if err != nil { - t.Fatalf("ValidatorPrivKeyFromBytes: %v", err) - } - if priv2.Public().ID() != pub.ID() { - t.Error("priv roundtrip failed") - } + priv2, err := crypto.ValidatorPrivKeyFromBytes(priv.Bytes()) + if err != nil { + t.Fatalf("ValidatorPrivKeyFromBytes: %v", err) + } + if priv2.Public().ID() != pub.ID() { + t.Error("priv roundtrip failed") + } - pub2, err := crypto.ValidatorPubKeyFromBytes(pub.Bytes()) - if err != nil { - t.Fatalf("ValidatorPubKeyFromBytes: %v", err) - } - if pub2.ID() != pub.ID() { - t.Error("pub roundtrip failed") - } + pub2, err := crypto.ValidatorPubKeyFromBytes(pub.Bytes()) + if err != nil { + t.Fatalf("ValidatorPubKeyFromBytes: %v", err) + } + if pub2.ID() != pub.ID() { + t.Error("pub roundtrip failed") + } } func TestValidatorKey_Verify_BadSig(t *testing.T) { - _, pub, _ := crypto.GenerateValidatorKey() - msg := crypto.HashBytes([]byte("test message")) - if pub.Verify(msg, []byte("badsig")) { - t.Error("Verify must return false for bad signature") - } + _, pub, _ := crypto.GenerateValidatorKey() + msg := crypto.HashBytes([]byte("test message")) + if pub.Verify(msg, []byte("badsig")) { + t.Error("Verify must return false for bad signature") + } } func TestValidatorKey_FromBytes_Invalid(t *testing.T) { - if _, err := crypto.ValidatorPrivKeyFromBytes([]byte("short")); err == nil { - t.Error("expected error for short private key bytes") - } - if _, err := crypto.ValidatorPubKeyFromBytes([]byte("short")); err == nil { - t.Error("expected error for short public key bytes") - } + if _, err := crypto.ValidatorPrivKeyFromBytes([]byte("short")); err == nil { + t.Error("expected error for short private key bytes") + } + if _, err := crypto.ValidatorPubKeyFromBytes([]byte("short")); err == nil { + t.Error("expected error for short public key bytes") + } } // ─── address.go ─────────────────────────────────────────────────────────────── func TestAddress_FromKeys_StringShort(t *testing.T) { - wk, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatal(err) - } - addr := crypto.AddressFromKeys(crypto.MainnetByte, wk) - s := addr.String() - if len(s) < 90 { - t.Errorf("address string too short: %q", s) - } - sh := addr.Short() - if len(sh) == 0 { - t.Error("Short() must not be empty") - } - t.Logf("address=%s short=%s", s, sh) + wk, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + addr := crypto.AddressFromKeys(crypto.MainnetByte, wk) + s := addr.String() + if len(s) < 90 { + t.Errorf("address string too short: %q", s) + } + sh := addr.Short() + if len(sh) == 0 { + t.Error("Short() must not be empty") + } + t.Logf("address=%s short=%s", s, sh) } func TestAddress_Decode_RoundTrip(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - addr := crypto.AddressFromKeys(crypto.MainnetByte, wk) + wk, _ := crypto.GenerateWalletKeys() + addr := crypto.AddressFromKeys(crypto.MainnetByte, wk) - _, spend, view, err := crypto.DecodeAddress(addr) - if err != nil { - t.Fatalf("DecodeAddress: %v", err) - } - addr2 := crypto.EncodeAddress(crypto.MainnetByte, spend, view) - if addr2.String() != addr.String() { - t.Error("address roundtrip mismatch") - } + _, spend, view, err := crypto.DecodeAddress(addr) + if err != nil { + t.Fatalf("DecodeAddress: %v", err) + } + addr2 := crypto.EncodeAddress(crypto.MainnetByte, spend, view) + if addr2.String() != addr.String() { + t.Error("address roundtrip mismatch") + } } func TestAddress_Decode_Invalid(t *testing.T) { - if _, _, _, err := crypto.DecodeAddress("not-valid"); err == nil { - t.Error("expected error for invalid address") - } - if _, _, _, err := crypto.DecodeAddress(""); err == nil { - t.Error("expected error for empty address") - } + if _, _, _, err := crypto.DecodeAddress("not-valid"); err == nil { + t.Error("expected error for invalid address") + } + if _, _, _, err := crypto.DecodeAddress(""); err == nil { + t.Error("expected error for empty address") + } } // ─── keyimage.go ────────────────────────────────────────────────────────────── func TestKeyImageSet_AddContains(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ki, err := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) - if err != nil { - t.Fatal(err) - } - set := crypto.NewKeyImageSet() - if set.Contains(ki) { - t.Error("fresh set must not contain key image") - } - set.Add(ki) - if !set.Contains(ki) { - t.Error("set must contain key image after Add") - } + wk, _ := crypto.GenerateWalletKeys() + ki, err := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) + if err != nil { + t.Fatal(err) + } + set := crypto.NewKeyImageSet() + if set.Contains(ki) { + t.Error("fresh set must not contain key image") + } + set.Add(ki) + if !set.Contains(ki) { + t.Error("set must contain key image after Add") + } } func TestKeyImage_Equal(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ki1, err := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) - if err != nil { - t.Fatal(err) - } - ki2, _ := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) - if !ki1.Equal(ki2) { - t.Error("same inputs must produce equal key images") - } - wk2, _ := crypto.GenerateWalletKeys() - ki3, _ := crypto.ComputeKeyImage(wk2.Spend.Private, wk2.Spend.Public) - if ki1.Equal(ki3) { - t.Error("different keys must produce different key images") - } + wk, _ := crypto.GenerateWalletKeys() + ki1, err := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) + if err != nil { + t.Fatal(err) + } + ki2, _ := crypto.ComputeKeyImage(wk.Spend.Private, wk.Spend.Public) + if !ki1.Equal(ki2) { + t.Error("same inputs must produce equal key images") + } + wk2, _ := crypto.GenerateWalletKeys() + ki3, _ := crypto.ComputeKeyImage(wk2.Spend.Private, wk2.Spend.Public) + if ki1.Equal(ki3) { + t.Error("different keys must produce different key images") + } } // ─── wallet.go ──────────────────────────────────────────────────────────────── func TestScalarMulBase(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - pub, err := crypto.ScalarMulBase(wk.Spend.Private) - if err != nil { - t.Fatal(err) - } - if pub != wk.Spend.Public { - t.Error("ScalarMulBase(spendPriv) must equal SpendPub") - } + wk, _ := crypto.GenerateWalletKeys() + pub, err := crypto.ScalarMulBase(wk.Spend.Private) + if err != nil { + t.Fatal(err) + } + if pub != wk.Spend.Public { + t.Error("ScalarMulBase(spendPriv) must equal SpendPub") + } } func TestAddScalars(t *testing.T) { - var a, b crypto.Scalar32 - a[0] = 5 - b[0] = 3 - sum, err := crypto.AddScalars(a, b) - if err != nil { - t.Fatal(err) - } - sum2, _ := crypto.AddScalars(b, a) - if sum != sum2 { - t.Error("AddScalars must be commutative") - } - var zero crypto.Scalar32 - if sum == zero { - t.Error("AddScalars(5,3) must be non-zero") - } + var a, b crypto.Scalar32 + a[0] = 5 + b[0] = 3 + sum, err := crypto.AddScalars(a, b) + if err != nil { + t.Fatal(err) + } + sum2, _ := crypto.AddScalars(b, a) + if sum != sum2 { + t.Error("AddScalars must be commutative") + } + var zero crypto.Scalar32 + if sum == zero { + t.Error("AddScalars(5,3) must be non-zero") + } } // ─── stealth.go ─────────────────────────────────────────────────────────────── func TestCreateStealthOutput(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - so, err := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) - if err != nil { - t.Fatal(err) - } - var zeroP crypto.Point32 - if so.OneTimePub == zeroP { - t.Error("StealthOutput.OneTimePub must not be zero") - } - if so.TxPubKey == zeroP { - t.Error("StealthOutput.TxPubKey must not be zero") - } - var zeroS crypto.Scalar32 - if so.HsScalar == zeroS { - t.Error("StealthOutput.HsScalar must not be zero") - } + wk, _ := crypto.GenerateWalletKeys() + so, err := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) + if err != nil { + t.Fatal(err) + } + var zeroP crypto.Point32 + if so.OneTimePub == zeroP { + t.Error("StealthOutput.OneTimePub must not be zero") + } + if so.TxPubKey == zeroP { + t.Error("StealthOutput.TxPubKey must not be zero") + } + var zeroS crypto.Scalar32 + if so.HsScalar == zeroS { + t.Error("StealthOutput.HsScalar must not be zero") + } } // ─── pedersen.go ───────────────────────────────────────────────────────────── func TestBlindSum_AddAndSubtract(t *testing.T) { - // Use NewBlindFactor for valid random scalars (raw [32]byte values may be - // out of the Ed25519 group order and cause "invalid scalar" errors). - b1, err := crypto.NewBlindFactor() - if err != nil { - t.Fatal(err) - } - b2, err := crypto.NewBlindFactor() - if err != nil { - t.Fatal(err) - } + // Use NewBlindFactor for valid random scalars (raw [32]byte values may be + // out of the Ed25519 group order and cause "invalid scalar" errors). + b1, err := crypto.NewBlindFactor() + if err != nil { + t.Fatal(err) + } + b2, err := crypto.NewBlindFactor() + if err != nil { + t.Fatal(err) + } - sum, err := crypto.BlindSum([]crypto.BlindFactor{b1, b2}, []crypto.BlindFactor{}) - if err != nil { - t.Fatalf("BlindSum: %v", err) - } - var zero crypto.BlindFactor - if sum == zero { - t.Error("BlindSum of two random blinds must be non-zero (astronomically unlikely)") - } + sum, err := crypto.BlindSum([]crypto.BlindFactor{b1, b2}, []crypto.BlindFactor{}) + if err != nil { + t.Fatalf("BlindSum: %v", err) + } + var zero crypto.BlindFactor + if sum == zero { + t.Error("BlindSum of two random blinds must be non-zero (astronomically unlikely)") + } - // sum of {b1,b2} - b2 should equal b1 (mod l) - sum2, err := crypto.BlindSum([]crypto.BlindFactor{b1, b2}, []crypto.BlindFactor{b2}) - if err != nil { - t.Fatalf("BlindSum with sub: %v", err) - } - if sum2 != b1 { - t.Error("BlindSum(add={b1,b2}, sub={b2}) must equal b1") - } + // sum of {b1,b2} - b2 should equal b1 (mod l) + sum2, err := crypto.BlindSum([]crypto.BlindFactor{b1, b2}, []crypto.BlindFactor{b2}) + if err != nil { + t.Fatalf("BlindSum with sub: %v", err) + } + if sum2 != b1 { + t.Error("BlindSum(add={b1,b2}, sub={b2}) must equal b1") + } } diff --git a/crypto/fuzz_test.go b/crypto/fuzz_test.go index 1c85915..7185b1d 100644 --- a/crypto/fuzz_test.go +++ b/crypto/fuzz_test.go @@ -7,227 +7,227 @@ package crypto_test // that processes external bytes, verifying it never panics. import ( - "testing" + "testing" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // ─── Address encode/decode ──────────────────────────────────────────────────── // FuzzDecodeAddress verifies that DecodeAddress never panics on arbitrary input. func FuzzDecodeAddress(f *testing.F) { - // Seed with a valid address - wk, _ := crypto.GenerateWalletKeys() - addr := crypto.AddressFromKeys(crypto.MainnetByte, wk) - f.Add([]byte(addr.String())) - - // Seed with some invalid inputs - f.Add([]byte("")) - f.Add([]byte("not-an-address")) - f.Add([]byte("APRO" + string(make([]byte, 100)))) - - f.Fuzz(func(t *testing.T, data []byte) { - a := crypto.Address(data) - // Must not panic; error is allowed - _, _, _, _ = crypto.DecodeAddress(a) - _ = crypto.Validate(a) - }) + // Seed with a valid address + wk, _ := crypto.GenerateWalletKeys() + addr := crypto.AddressFromKeys(crypto.MainnetByte, wk) + f.Add([]byte(addr.String())) + + // Seed with some invalid inputs + f.Add([]byte("")) + f.Add([]byte("not-an-address")) + f.Add([]byte("APRO" + string(make([]byte, 100)))) + + f.Fuzz(func(t *testing.T, data []byte) { + a := crypto.Address(data) + // Must not panic; error is allowed + _, _, _, _ = crypto.DecodeAddress(a) + _ = crypto.Validate(a) + }) } // FuzzEncodeDecodeAddress verifies round-trip consistency. func FuzzEncodeDecodeAddress(f *testing.F) { - // Seed with a valid (spend, view) pair - wk, _ := crypto.GenerateWalletKeys() - f.Add(wk.Spend.Public[:], wk.View.Public[:]) - - f.Fuzz(func(t *testing.T, spendBytes, viewBytes []byte) { - if len(spendBytes) != 32 || len(viewBytes) != 32 { - return // skip — not valid key sizes - } - var spend, view crypto.Point32 - copy(spend[:], spendBytes) - copy(view[:], viewBytes) - - // May fail if bytes don't represent a valid curve point — that's OK - addr := crypto.EncodeAddress(crypto.MainnetByte, spend, view) - // Decode must not panic - _, _, _, _ = crypto.DecodeAddress(addr) - }) + // Seed with a valid (spend, view) pair + wk, _ := crypto.GenerateWalletKeys() + f.Add(wk.Spend.Public[:], wk.View.Public[:]) + + f.Fuzz(func(t *testing.T, spendBytes, viewBytes []byte) { + if len(spendBytes) != 32 || len(viewBytes) != 32 { + return // skip — not valid key sizes + } + var spend, view crypto.Point32 + copy(spend[:], spendBytes) + copy(view[:], viewBytes) + + // May fail if bytes don't represent a valid curve point — that's OK + addr := crypto.EncodeAddress(crypto.MainnetByte, spend, view) + // Decode must not panic + _, _, _, _ = crypto.DecodeAddress(addr) + }) } // ─── Hash ───────────────────────────────────────────────────────────────────── // FuzzHashBytes verifies hash never panics on any input. func FuzzHashBytes(f *testing.F) { - f.Add([]byte("hello")) - f.Add([]byte("")) - f.Add(make([]byte, 1024)) - - f.Fuzz(func(t *testing.T, data []byte) { - h := crypto.HashBytes(data) - if len(h) != 32 { - t.Errorf("hash len = %d, want 32", len(h)) - } - }) + f.Add([]byte("hello")) + f.Add([]byte("")) + f.Add(make([]byte, 1024)) + + f.Fuzz(func(t *testing.T, data []byte) { + h := crypto.HashBytes(data) + if len(h) != 32 { + t.Errorf("hash len = %d, want 32", len(h)) + } + }) } // ─── ScanForOutput (stealth addresses) ─────────────────────────────────────── // FuzzScanForOutput verifies the scanner never panics on garbage inputs. func FuzzScanForOutput(f *testing.F) { - wk, _ := crypto.GenerateWalletKeys() - so, _ := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) - f.Add( - wk.View.Private[:], - wk.Spend.Public[:], - so.TxPubKey[:], - so.OneTimePub[:], - ) - // Corrupt inputs - f.Add(make([]byte, 32), make([]byte, 32), make([]byte, 32), make([]byte, 32)) - - f.Fuzz(func(t *testing.T, viewPriv, spendPub, txPub, oneTime []byte) { - if len(viewPriv) != 32 || len(spendPub) != 32 || len(txPub) != 32 || len(oneTime) != 32 { - return - } - var vp crypto.Scalar32 - var sp, tp, op crypto.Point32 - copy(vp[:], viewPriv) - copy(sp[:], spendPub) - copy(tp[:], txPub) - copy(op[:], oneTime) - // Must not panic - _, _ = crypto.ScanForOutput(vp, sp, tp, op) - }) + wk, _ := crypto.GenerateWalletKeys() + so, _ := crypto.CreateStealthOutput(wk.Spend.Public, wk.View.Public) + f.Add( + wk.View.Private[:], + wk.Spend.Public[:], + so.TxPubKey[:], + so.OneTimePub[:], + ) + // Corrupt inputs + f.Add(make([]byte, 32), make([]byte, 32), make([]byte, 32), make([]byte, 32)) + + f.Fuzz(func(t *testing.T, viewPriv, spendPub, txPub, oneTime []byte) { + if len(viewPriv) != 32 || len(spendPub) != 32 || len(txPub) != 32 || len(oneTime) != 32 { + return + } + var vp crypto.Scalar32 + var sp, tp, op crypto.Point32 + copy(vp[:], viewPriv) + copy(sp[:], spendPub) + copy(tp[:], txPub) + copy(op[:], oneTime) + // Must not panic + _, _ = crypto.ScanForOutput(vp, sp, tp, op) + }) } // ─── MLSAGSign / MLSAGVerify ───────────────────────────────────────────────── // FuzzMLSAGVerify verifies the verifier never panics on arbitrary ring data. func FuzzMLSAGVerify(f *testing.F) { - // Seed with a real signature - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, 3) - for i := range ring { - kk, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(kk.Spend.Public) - } - ring[0] = crypto.RingMember(wk.Spend.Public) - var msg crypto.Hash32 - msg[0] = 0xDE - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - f.Skip("seed signature failed:", err) - } - - // Use the key image as seed bytes (fixed 32-byte size) - f.Add(msg[:], sig.KeyImage[:]) - f.Add(make([]byte, 32), make([]byte, 32)) - - f.Fuzz(func(t *testing.T, msgBytes, _ []byte) { - if len(msgBytes) != 32 { - return - } - var m crypto.Hash32 - copy(m[:], msgBytes) - // Verify with a real sig but fuzzed message — must not panic - _, _ = crypto.MLSAGVerify(m, ring, sig) - }) + // Seed with a real signature + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, 3) + for i := range ring { + kk, _ := crypto.GenerateWalletKeys() + ring[i] = crypto.RingMember(kk.Spend.Public) + } + ring[0] = crypto.RingMember(wk.Spend.Public) + var msg crypto.Hash32 + msg[0] = 0xDE + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + f.Skip("seed signature failed:", err) + } + + // Use the key image as seed bytes (fixed 32-byte size) + f.Add(msg[:], sig.KeyImage[:]) + f.Add(make([]byte, 32), make([]byte, 32)) + + f.Fuzz(func(t *testing.T, msgBytes, _ []byte) { + if len(msgBytes) != 32 { + return + } + var m crypto.Hash32 + copy(m[:], msgBytes) + // Verify with a real sig but fuzzed message — must not panic + _, _ = crypto.MLSAGVerify(m, ring, sig) + }) } // ─── Pedersen commitment ────────────────────────────────────────────────────── // FuzzCommit verifies Commit never panics on arbitrary amounts and blind factors. func FuzzCommit(f *testing.F) { - f.Add(uint64(0), make([]byte, 32)) - f.Add(uint64(1_000_000), make([]byte, 32)) - bf, _ := crypto.NewBlindFactor() - f.Add(uint64(999), bf[:]) - - f.Fuzz(func(t *testing.T, amount uint64, blindBytes []byte) { - if len(blindBytes) != 32 { - return - } - var blind crypto.BlindFactor - copy(blind[:], blindBytes) - // Must not panic - _, _ = crypto.Commit(amount, blind) - }) + f.Add(uint64(0), make([]byte, 32)) + f.Add(uint64(1_000_000), make([]byte, 32)) + bf, _ := crypto.NewBlindFactor() + f.Add(uint64(999), bf[:]) + + f.Fuzz(func(t *testing.T, amount uint64, blindBytes []byte) { + if len(blindBytes) != 32 { + return + } + var blind crypto.BlindFactor + copy(blind[:], blindBytes) + // Must not panic + _, _ = crypto.Commit(amount, blind) + }) } // ─── 3.4.2 Pedersen edge cases ─────────────────────────────────────────────── // FuzzPedersenEdgeCases verifies Commit handles boundary amounts (0, MAX) without panic. func FuzzPedersenEdgeCases(f *testing.F) { - // Seed with boundary values and random blind factors. - f.Add(uint64(0), make([]byte, 32)) - f.Add(uint64(1<<63-1), make([]byte, 32)) - f.Add(^uint64(0), make([]byte, 32)) // MaxUint64 - bf, _ := crypto.NewBlindFactor() - f.Add(uint64(0), bf[:]) - f.Add(^uint64(0), bf[:]) - - f.Fuzz(func(t *testing.T, amount uint64, blindBytes []byte) { - if len(blindBytes) != 32 { - return - } - var blind crypto.BlindFactor - copy(blind[:], blindBytes) - - c, err := crypto.Commit(amount, blind) - if err != nil { - return // simplified impl may reject; that's OK - } - // Commitment must be 32 bytes (non-zero for non-trivial inputs). - _ = c - }) + // Seed with boundary values and random blind factors. + f.Add(uint64(0), make([]byte, 32)) + f.Add(uint64(1<<63-1), make([]byte, 32)) + f.Add(^uint64(0), make([]byte, 32)) // MaxUint64 + bf, _ := crypto.NewBlindFactor() + f.Add(uint64(0), bf[:]) + f.Add(^uint64(0), bf[:]) + + f.Fuzz(func(t *testing.T, amount uint64, blindBytes []byte) { + if len(blindBytes) != 32 { + return + } + var blind crypto.BlindFactor + copy(blind[:], blindBytes) + + c, err := crypto.Commit(amount, blind) + if err != nil { + return // simplified impl may reject; that's OK + } + // Commitment must be 32 bytes (non-zero for non-trivial inputs). + _ = c + }) } // FuzzCommitSum verifies CommitSum never panics on arbitrary commitments. func FuzzCommitSum(f *testing.F) { - bf, _ := crypto.NewBlindFactor() - c, _ := crypto.Commit(1000, bf) - f.Add(c[:], c[:]) - f.Add(make([]byte, 32), make([]byte, 32)) - - f.Fuzz(func(t *testing.T, inBytes, outBytes []byte) { - if len(inBytes) != 32 || len(outBytes) != 32 { - return - } - var cIn, cOut crypto.Commitment - copy(cIn[:], inBytes) - copy(cOut[:], outBytes) - // Zero fee commitment. - var feeC crypto.Commitment - _, _ = crypto.CommitSum([]crypto.Commitment{cIn}, []crypto.Commitment{cOut}, feeC) - }) + bf, _ := crypto.NewBlindFactor() + c, _ := crypto.Commit(1000, bf) + f.Add(c[:], c[:]) + f.Add(make([]byte, 32), make([]byte, 32)) + + f.Fuzz(func(t *testing.T, inBytes, outBytes []byte) { + if len(inBytes) != 32 || len(outBytes) != 32 { + return + } + var cIn, cOut crypto.Commitment + copy(cIn[:], inBytes) + copy(cOut[:], outBytes) + // Zero fee commitment. + var feeC crypto.Commitment + _, _ = crypto.CommitSum([]crypto.Commitment{cIn}, []crypto.Commitment{cOut}, feeC) + }) } // ─── 3.4.3 Bulletproof edge cases ──────────────────────────────────────────── // FuzzVerifyRange verifies VerifyRange never panics on corrupted proof data. func FuzzVerifyRange(f *testing.F) { - // Seed with a real proof. - bf, _ := crypto.NewBlindFactor() - proof, err := crypto.ProveRange(1_000_000, bf) - if err == nil && proof != nil { - f.Add(proof.ValueCommit[:], proof.Ls[0][:], proof.Rs[0][:]) - } - f.Add(make([]byte, 32), make([]byte, 32), make([]byte, 32)) - - f.Fuzz(func(t *testing.T, commitBytes, lBytes, rBytes []byte) { - if len(commitBytes) != 32 || len(lBytes) != 32 || len(rBytes) != 32 { - return - } - // Build a tampered proof and verify — must not panic. - bf2, _ := crypto.NewBlindFactor() - p, err := crypto.ProveRange(42, bf2) - if err != nil || p == nil { - return - } - copy(p.ValueCommit[:], commitBytes) - copy(p.Ls[0][:], lBytes) - copy(p.Rs[0][:], rBytes) - _, _ = crypto.VerifyRange(p) - }) + // Seed with a real proof. + bf, _ := crypto.NewBlindFactor() + proof, err := crypto.ProveRange(1_000_000, bf) + if err == nil && proof != nil { + f.Add(proof.ValueCommit[:], proof.Ls[0][:], proof.Rs[0][:]) + } + f.Add(make([]byte, 32), make([]byte, 32), make([]byte, 32)) + + f.Fuzz(func(t *testing.T, commitBytes, lBytes, rBytes []byte) { + if len(commitBytes) != 32 || len(lBytes) != 32 || len(rBytes) != 32 { + return + } + // Build a tampered proof and verify — must not panic. + bf2, _ := crypto.NewBlindFactor() + p, err := crypto.ProveRange(42, bf2) + if err != nil || p == nil { + return + } + copy(p.ValueCommit[:], commitBytes) + copy(p.Ls[0][:], lBytes) + copy(p.Rs[0][:], rBytes) + _, _ = crypto.VerifyRange(p) + }) } diff --git a/crypto/hash.go b/crypto/hash.go index 3459dc1..0f2db77 100644 --- a/crypto/hash.go +++ b/crypto/hash.go @@ -4,7 +4,7 @@ package crypto import ( - "crypto/sha256" + "crypto/sha256" ) // Hash32 is a 32-byte hash. @@ -15,34 +15,34 @@ func (h Hash32) Zero() bool { return h == (Hash32{}) } // Bytes returns a slice copy. func (h Hash32) Bytes() []byte { - out := make([]byte, 32) - copy(out, h[:]) - return out + out := make([]byte, 32) + copy(out, h[:]) + return out } // HashBytes hashes arbitrary data with SHA-256. func HashBytes(data ...[]byte) Hash32 { - h := sha256.New() - for _, d := range data { - h.Write(d) - } - var out Hash32 - copy(out[:], h.Sum(nil)) - return out + h := sha256.New() + for _, d := range data { + h.Write(d) + } + var out Hash32 + copy(out[:], h.Sum(nil)) + return out } // HashStr is a convenience wrapper for string inputs. func HashStr(s string) Hash32 { - return HashBytes([]byte(s)) + return HashBytes([]byte(s)) } // HashConcat hashes the concatenation of all byte slices. func HashConcat(parts ...[]byte) Hash32 { - return HashBytes(parts...) + return HashBytes(parts...) } // DoubleSHA3 applies SHA3-256 twice (used for checksums). func DoubleSHA3(data []byte) Hash32 { - first := HashBytes(data) - return HashBytes(first[:]) + first := HashBytes(data) + return HashBytes(first[:]) } diff --git a/crypto/keyimage.go b/crypto/keyimage.go index 260e573..0570c35 100644 --- a/crypto/keyimage.go +++ b/crypto/keyimage.go @@ -83,7 +83,7 @@ func HashToCurvePoint(data []byte) *edwards25519.Point { // inv8ModL is 8^{-1} mod l in little-endian, where l is the Ed25519 prime // group order. Used by CanonicalKeyImage to clear torsion components. -// Precomputed: python3 -c "l=2**252+27742317777372353535851937790883648493; print(pow(8,-1,l).to_bytes(32,'little').hex())" +// Precomputed: python3 -c "l=2**252+27742317777372353535851937790883648493; print(pow(8,-1,l).to_bytes(32,'little').hex())". var inv8ModL = [32]byte{ 0x79, 0x2f, 0xdc, 0xe2, 0x29, 0xe5, 0x06, 0x61, 0xd0, 0xda, 0x1c, 0x7d, 0xb3, 0x9d, 0xd3, 0x07, diff --git a/crypto/keyimage_proof.go b/crypto/keyimage_proof.go new file mode 100644 index 0000000..e914fdb --- /dev/null +++ b/crypto/keyimage_proof.go @@ -0,0 +1,132 @@ +package crypto + +import ( + "crypto/subtle" + "encoding/binary" + "fmt" + + "filippo.io/edwards25519" +) + +// KeyImageDLEQProof proves knowledge of x such that P = x*G and +// I = x*Hp(P), without disclosing x. The context must bind the proof to the +// protocol action that consumes the key image. +type KeyImageDLEQProof struct { + CommitmentG Point32 + CommitmentHp Point32 + Response Scalar32 +} + +// ProveKeyImage creates the canonical key image and a Chaum-Pedersen +// equality-of-discrete-logs proof. It refuses a secret that does not match P. +func ProveKeyImage(secret Scalar32, P Point32, context []byte) (KeyImage, KeyImageDLEQProof, error) { + var proof KeyImageDLEQProof + x, err := ScalarFromBytes(secret[:]) + if err != nil { + return KeyImage{}, proof, fmt.Errorf("key-image secret: %w", err) + } + if _, err := checkedDLEQPoint("one-time public key", P); err != nil { + return KeyImage{}, proof, err + } + expectedP := new(edwards25519.Point).ScalarBaseMult(x) + if subtle.ConstantTimeCompare(expectedP.Bytes(), P[:]) != 1 { + return KeyImage{}, proof, fmt.Errorf("key-image secret does not match one-time public key") + } + + ki, err := ComputeKeyImage(secret, P) + if err != nil { + return KeyImage{}, proof, fmt.Errorf("compute key image: %w", err) + } + hp := hashToCurvePoint(P[:]) + w, err := randomScalar() + if err != nil { + return KeyImage{}, proof, fmt.Errorf("key-image proof nonce: %w", err) + } + // A zero nonce would produce identity commitments, which are forbidden by + // the verifier. Reroll rather than emit a non-canonical proof. + if w.Equal(edwards25519.NewScalar()) == 1 { + w, err = randomScalar() + if err != nil { + return KeyImage{}, proof, fmt.Errorf("key-image proof nonce: %w", err) + } + if w.Equal(edwards25519.NewScalar()) == 1 { + return KeyImage{}, proof, fmt.Errorf("key-image proof nonce is zero") + } + } + + A := new(edwards25519.Point).ScalarBaseMult(w) + B := new(edwards25519.Point).ScalarMult(w, hp) + copy(proof.CommitmentG[:], A.Bytes()) + copy(proof.CommitmentHp[:], B.Bytes()) + + c := keyImageDLEQChallenge(P, ki, proof.CommitmentG, proof.CommitmentHp, context) + z := new(edwards25519.Scalar).MultiplyAdd(c, x, w) + copy(proof.Response[:], z.Bytes()) + return ki, proof, nil +} + +// VerifyKeyImageProof checks that P and I use the same secret scalar and that +// the proof is bound to context. Invalid encodings and non-prime-subgroup +// points fail closed. +func VerifyKeyImageProof(P Point32, ki KeyImage, context []byte, proof KeyImageDLEQProof) error { + p, err := checkedDLEQPoint("one-time public key", P) + if err != nil { + return err + } + imagePoint, err := checkedDLEQPoint("key image", Point32(ki)) + if err != nil { + return err + } + A, err := checkedDLEQPoint("proof commitment G", proof.CommitmentG) + if err != nil { + return err + } + B, err := checkedDLEQPoint("proof commitment Hp", proof.CommitmentHp) + if err != nil { + return err + } + z, err := ScalarFromBytes(proof.Response[:]) + if err != nil { + return fmt.Errorf("key-image proof response: %w", err) + } + + hp := hashToCurvePoint(P[:]) + c := keyImageDLEQChallenge(P, ki, proof.CommitmentG, proof.CommitmentHp, context) + leftG := new(edwards25519.Point).ScalarBaseMult(z) + rightG := new(edwards25519.Point).Add(A, new(edwards25519.Point).ScalarMult(c, p)) + leftHp := new(edwards25519.Point).ScalarMult(z, hp) + rightHp := new(edwards25519.Point).Add(B, new(edwards25519.Point).ScalarMult(c, imagePoint)) + if leftG.Equal(rightG) != 1 || leftHp.Equal(rightHp) != 1 { + return fmt.Errorf("key-image DLEQ proof verification failed") + } + return nil +} + +func checkedDLEQPoint(label string, encoded Point32) (*edwards25519.Point, error) { + canonical, err := CanonicalKeyImage(KeyImage(encoded)) + if err != nil { + return nil, fmt.Errorf("%s: %w", label, err) + } + if subtle.ConstantTimeCompare(canonical[:], encoded[:]) != 1 { + return nil, fmt.Errorf("%s is not in the prime-order subgroup", label) + } + point, err := PointFromBytes(encoded[:]) + if err != nil { + return nil, fmt.Errorf("%s: %w", label, err) + } + return point, nil +} + +func keyImageDLEQChallenge(P Point32, ki KeyImage, A, B Point32, context []byte) *edwards25519.Scalar { + transcript := make([]byte, 0, 32*4+8+len(context)) + transcript = append(transcript, []byte("aperod/stake/key-image-dleq/v1")...) + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(context))) + transcript = append(transcript, size[:]...) + transcript = append(transcript, context...) + transcript = append(transcript, P[:]...) + transcript = append(transcript, ki[:]...) + transcript = append(transcript, A[:]...) + transcript = append(transcript, B[:]...) + return hashToScalar(transcript, "aperod/stake/key-image-dleq/challenge/v1") +} diff --git a/crypto/keyimage_proof_test.go b/crypto/keyimage_proof_test.go new file mode 100644 index 0000000..4d50230 --- /dev/null +++ b/crypto/keyimage_proof_test.go @@ -0,0 +1,68 @@ +package crypto + +import ( + "testing" +) + +func TestKeyImageDLEQProof(t *testing.T) { + keys, err := GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + context := []byte("stake-v4 test context") + ki, proof, err := ProveKeyImage(keys.Spend.Private, keys.Spend.Public, context) + if err != nil { + t.Fatalf("ProveKeyImage: %v", err) + } + wantKI, err := ComputeKeyImage(keys.Spend.Private, keys.Spend.Public) + if err != nil { + t.Fatalf("ComputeKeyImage: %v", err) + } + if ki != wantKI { + t.Fatal("proof returned a key image different from ComputeKeyImage") + } + if err := VerifyKeyImageProof(keys.Spend.Public, ki, context, proof); err != nil { + t.Fatalf("VerifyKeyImageProof: %v", err) + } +} + +func TestKeyImageDLEQProofRejectsMutationAndWrongContext(t *testing.T) { + keys, err := GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + context := []byte("stake-v4 test context") + ki, proof, err := ProveKeyImage(keys.Spend.Private, keys.Spend.Public, context) + if err != nil { + t.Fatalf("ProveKeyImage: %v", err) + } + if err := VerifyKeyImageProof(keys.Spend.Public, ki, []byte("different action"), proof); err == nil { + t.Fatal("proof verified with a different context") + } + + mutated := proof + mutated.Response[0] ^= 1 + if err := VerifyKeyImageProof(keys.Spend.Public, ki, context, mutated); err == nil { + t.Fatal("proof verified after response mutation") + } + + mutatedKI := ki + mutatedKI[0] ^= 1 + if err := VerifyKeyImageProof(keys.Spend.Public, mutatedKI, context, proof); err == nil { + t.Fatal("proof verified for a different key image") + } +} + +func TestProveKeyImageRejectsMismatchedSecret(t *testing.T) { + owner, err := GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + other, err := GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + if _, _, err := ProveKeyImage(owner.Spend.Private, other.Spend.Public, []byte("context")); err == nil { + t.Fatal("ProveKeyImage accepted a secret for a different public key") + } +} diff --git a/crypto/pedersen.go b/crypto/pedersen.go index 8b56c12..b1983bb 100644 --- a/crypto/pedersen.go +++ b/crypto/pedersen.go @@ -1,11 +1,11 @@ package crypto import ( - "crypto/sha512" - "encoding/binary" - "fmt" + "crypto/sha512" + "encoding/binary" + "fmt" - "filippo.io/edwards25519" + "filippo.io/edwards25519" ) // Commitment is a Pedersen commitment C = r·G + v·H. @@ -24,69 +24,69 @@ type BlindFactor [32]byte var hGenerator *edwards25519.Point func init() { - // BpH is initialised by bulletproof.go's init() which runs first - // (files are processed in alphabetical order: bulletproof.go < pedersen.go). - hGenerator = BpH + // BpH is initialized by bulletproof.go's init() which runs first + // (files are processed in alphabetical order: bulletproof.go < pedersen.go). + hGenerator = BpH } // Commit creates a Pedersen commitment to value v with blinding factor r. -// C = r·G + v·H +// C = r·G + v·H. func Commit(value uint64, blind BlindFactor) (Commitment, error) { - // H-2 fix: non-canonical blinding factors are rejected outright. - // The old clampScalar fallback silently mutated blind to an Ed25519-signing-key - // clamped form — an operation with no meaning for Pedersen scalars that could - // produce a different commitment than intended and undermine binding. All - // production callers (NewBlindFactor, DeterministicMintBlind, - // DeterministicPaymentBlind, BlindSum, and the zero-blind fee path) use - // SetUniformBytes or SetCanonicalBytes and always produce canonical scalars. - rScalar, err := ScalarFromBytes(blind[:]) - if err != nil { - return Commitment{}, fmt.Errorf("invalid blinding factor (non-canonical scalar): %w", err) - } - - vScalar := scalarFromUint64(value) - - // r·G - rG := (&edwards25519.Point{}).ScalarBaseMult(rScalar) - // v·H - vH := (&edwards25519.Point{}).ScalarMult(vScalar, hGenerator) - // C = r·G + v·H - C := (&edwards25519.Point{}).Add(rG, vH) - - var out Commitment - copy(out[:], C.Bytes()) - return out, nil + // H-2 fix: non-canonical blinding factors are rejected outright. + // The old clampScalar fallback silently mutated blind to an Ed25519-signing-key + // clamped form — an operation with no meaning for Pedersen scalars that could + // produce a different commitment than intended and undermine binding. All + // production callers (NewBlindFactor, DeterministicMintBlind, + // DeterministicPaymentBlind, BlindSum, and the zero-blind fee path) use + // SetUniformBytes or SetCanonicalBytes and always produce canonical scalars. + rScalar, err := ScalarFromBytes(blind[:]) + if err != nil { + return Commitment{}, fmt.Errorf("invalid blinding factor (non-canonical scalar): %w", err) + } + + vScalar := scalarFromUint64(value) + + // r·G + rG := (&edwards25519.Point{}).ScalarBaseMult(rScalar) + // v·H + vH := (&edwards25519.Point{}).ScalarMult(vScalar, hGenerator) + // C = r·G + v·H + C := (&edwards25519.Point{}).Add(rG, vH) + + var out Commitment + copy(out[:], C.Bytes()) + return out, nil } // CommitSum verifies that the sum of input commitments equals the sum of output // commitments plus the fee commitment: ΣC_in = ΣC_out + C_fee. // This is the fundamental privacy-preserving balance check. func CommitSum(inputs []Commitment, outputs []Commitment, feeCommit Commitment) (bool, error) { - sumIn := new(edwards25519.Point).Set(edwards25519.NewIdentityPoint()) - for _, c := range inputs { - pt, err := PointFromBytes(c[:]) - if err != nil { - return false, fmt.Errorf("invalid input commitment: %w", err) - } - sumIn.Add(sumIn, pt) - } - - sumOut := new(edwards25519.Point).Set(edwards25519.NewIdentityPoint()) - for _, c := range outputs { - pt, err := PointFromBytes(c[:]) - if err != nil { - return false, fmt.Errorf("invalid output commitment: %w", err) - } - sumOut.Add(sumOut, pt) - } - - feePt, err := PointFromBytes(feeCommit[:]) - if err != nil { - return false, fmt.Errorf("invalid fee commitment: %w", err) - } - sumOut.Add(sumOut, feePt) - - return sumIn.Equal(sumOut) == 1, nil + sumIn := new(edwards25519.Point).Set(edwards25519.NewIdentityPoint()) + for _, c := range inputs { + pt, err := PointFromBytes(c[:]) + if err != nil { + return false, fmt.Errorf("invalid input commitment: %w", err) + } + sumIn.Add(sumIn, pt) + } + + sumOut := new(edwards25519.Point).Set(edwards25519.NewIdentityPoint()) + for _, c := range outputs { + pt, err := PointFromBytes(c[:]) + if err != nil { + return false, fmt.Errorf("invalid output commitment: %w", err) + } + sumOut.Add(sumOut, pt) + } + + feePt, err := PointFromBytes(feeCommit[:]) + if err != nil { + return false, fmt.Errorf("invalid fee commitment: %w", err) + } + sumOut.Add(sumOut, feePt) + + return sumIn.Equal(sumOut) == 1, nil } // DeterministicMintBlind derives a stable blinding factor for transparent mint outputs. @@ -99,21 +99,21 @@ func CommitSum(inputs []Commitment, outputs []Commitment, feeCommit Commitment) // DeterministicMintBlind is preserved only for height=0 admin mints and // backward-compatible recovery of UTXOs created before the V2 migration. func DeterministicMintBlind(spendPub Point32, amount uint64) (BlindFactor, error) { - h := sha512.New() - h.Write([]byte("aperod-mint-blind-v1")) - h.Write(spendPub[:]) - var ab [8]byte - binary.LittleEndian.PutUint64(ab[:], amount) - h.Write(ab[:]) - var wide [64]byte - copy(wide[:], h.Sum(nil)) - s, err := edwards25519.NewScalar().SetUniformBytes(wide[:]) - if err != nil { - return BlindFactor{}, fmt.Errorf("deterministic mint blind: %w", err) - } - var bf BlindFactor - copy(bf[:], s.Bytes()) - return bf, nil + h := sha512.New() + h.Write([]byte("aperod-mint-blind-v1")) + h.Write(spendPub[:]) + var ab [8]byte + binary.LittleEndian.PutUint64(ab[:], amount) + h.Write(ab[:]) + var wide [64]byte + copy(wide[:], h.Sum(nil)) + s, err := edwards25519.NewScalar().SetUniformBytes(wide[:]) + if err != nil { + return BlindFactor{}, fmt.Errorf("deterministic mint blind: %w", err) + } + var bf BlindFactor + copy(bf[:], s.Bytes()) + return bf, nil } // DeterministicMintBlindV2 derives a stable blinding factor for transparent @@ -132,24 +132,24 @@ func DeterministicMintBlind(spendPub Point32, amount uint64) (BlindFactor, error // UTXO ownership theft in staking comes from the V3 one-time-key ownership // proof in ProcessStakeTx (F-049 fix). func DeterministicMintBlindV2(spendPub Point32, amount uint64, height uint64) (BlindFactor, error) { - h := sha512.New() - h.Write([]byte("aperod-mint-blind-v2")) - h.Write(spendPub[:]) - var ab [8]byte - binary.LittleEndian.PutUint64(ab[:], amount) - h.Write(ab[:]) - var hb [8]byte - binary.LittleEndian.PutUint64(hb[:], height) - h.Write(hb[:]) - var wide [64]byte - copy(wide[:], h.Sum(nil)) - s, err := edwards25519.NewScalar().SetUniformBytes(wide[:]) - if err != nil { - return BlindFactor{}, fmt.Errorf("deterministic mint blind v2: %w", err) - } - var bf BlindFactor - copy(bf[:], s.Bytes()) - return bf, nil + h := sha512.New() + h.Write([]byte("aperod-mint-blind-v2")) + h.Write(spendPub[:]) + var ab [8]byte + binary.LittleEndian.PutUint64(ab[:], amount) + h.Write(ab[:]) + var hb [8]byte + binary.LittleEndian.PutUint64(hb[:], height) + h.Write(hb[:]) + var wide [64]byte + copy(wide[:], h.Sum(nil)) + s, err := edwards25519.NewScalar().SetUniformBytes(wide[:]) + if err != nil { + return BlindFactor{}, fmt.Errorf("deterministic mint blind v2: %w", err) + } + var bf BlindFactor + copy(bf[:], s.Bytes()) + return bf, nil } // DeterministicPaymentBlind derives a stable blinding factor for stealth payment @@ -161,76 +161,76 @@ func DeterministicMintBlindV2(spendPub Point32, amount uint64, height uint64) (B // This replaces the previous random blind scheme and makes stealth UTXOs // self-recoverable from the view key alone. func DeterministicPaymentBlind(hs Scalar32, amount uint64) (BlindFactor, error) { - h := sha512.New() - h.Write([]byte("aperod-pay-blind-v1")) - h.Write(hs[:]) - var ab [8]byte - binary.LittleEndian.PutUint64(ab[:], amount) - h.Write(ab[:]) - var wide [64]byte - copy(wide[:], h.Sum(nil)) - s, err := edwards25519.NewScalar().SetUniformBytes(wide[:]) - if err != nil { - return BlindFactor{}, fmt.Errorf("deterministic payment blind: %w", err) - } - var bf BlindFactor - copy(bf[:], s.Bytes()) - return bf, nil + h := sha512.New() + h.Write([]byte("aperod-pay-blind-v1")) + h.Write(hs[:]) + var ab [8]byte + binary.LittleEndian.PutUint64(ab[:], amount) + h.Write(ab[:]) + var wide [64]byte + copy(wide[:], h.Sum(nil)) + s, err := edwards25519.NewScalar().SetUniformBytes(wide[:]) + if err != nil { + return BlindFactor{}, fmt.Errorf("deterministic payment blind: %w", err) + } + var bf BlindFactor + copy(bf[:], s.Bytes()) + return bf, nil } // NewBlindFactor generates a random blinding factor. func NewBlindFactor() (BlindFactor, error) { - var buf [64]byte - if _, err := randRead(buf[:]); err != nil { - return BlindFactor{}, err - } - s, err := edwards25519.NewScalar().SetUniformBytes(buf[:]) - if err != nil { - return BlindFactor{}, err - } - var bf BlindFactor - copy(bf[:], s.Bytes()) - return bf, nil + var buf [64]byte + if _, err := randRead(buf[:]); err != nil { + return BlindFactor{}, err + } + s, err := edwards25519.NewScalar().SetUniformBytes(buf[:]) + if err != nil { + return BlindFactor{}, err + } + var bf BlindFactor + copy(bf[:], s.Bytes()) + return bf, nil } // BlindSum computes the sum of blinding factors (for change output calculation). // change_blind = Σin_blind - Σout_blind (mod order) // All BlindFactor values must be canonical scalars (as produced by NewBlindFactor). func BlindSum(in []BlindFactor, out []BlindFactor) (BlindFactor, error) { - acc := edwards25519.NewScalar() - for _, b := range in { - s, err := ScalarFromBytes(b[:]) - if err != nil { - return BlindFactor{}, err - } - acc.Add(acc, s) - } - for _, b := range out { - s, err := ScalarFromBytes(b[:]) - if err != nil { - return BlindFactor{}, err - } - neg := edwards25519.NewScalar().Negate(s) - acc.Add(acc, neg) - } - var result BlindFactor - copy(result[:], acc.Bytes()) - return result, nil + acc := edwards25519.NewScalar() + for _, b := range in { + s, err := ScalarFromBytes(b[:]) + if err != nil { + return BlindFactor{}, err + } + acc.Add(acc, s) + } + for _, b := range out { + s, err := ScalarFromBytes(b[:]) + if err != nil { + return BlindFactor{}, err + } + neg := edwards25519.NewScalar().Negate(s) + acc.Add(acc, neg) + } + var result BlindFactor + copy(result[:], acc.Bytes()) + return result, nil } // scalarFromUint64 encodes a uint64 as a little-endian Edwards scalar. func scalarFromUint64(v uint64) *edwards25519.Scalar { - var b [32]byte - b[0] = byte(v) - b[1] = byte(v >> 8) - b[2] = byte(v >> 16) - b[3] = byte(v >> 24) - b[4] = byte(v >> 32) - b[5] = byte(v >> 40) - b[6] = byte(v >> 48) - b[7] = byte(v >> 56) - s, _ := edwards25519.NewScalar().SetCanonicalBytes(b[:]) - return s + var b [32]byte + b[0] = byte(v) + b[1] = byte(v >> 8) + b[2] = byte(v >> 16) + b[3] = byte(v >> 24) + b[4] = byte(v >> 32) + b[5] = byte(v >> 40) + b[6] = byte(v >> 48) + b[7] = byte(v >> 56) + s, _ := edwards25519.NewScalar().SetCanonicalBytes(b[:]) + return s } // clampScalar was removed (H-2 fix): Ed25519 signing-key clamping has no diff --git a/crypto/ringct.go b/crypto/ringct.go index 1de00d1..bf4803c 100644 --- a/crypto/ringct.go +++ b/crypto/ringct.go @@ -1,12 +1,12 @@ package crypto import ( - "crypto/sha512" - "crypto/subtle" + "crypto/sha512" + "crypto/subtle" "encoding/binary" - "fmt" + "fmt" - "filippo.io/edwards25519" + "filippo.io/edwards25519" ) // RingSize is the number of public keys in a ring (1 real + N-1 decoys). @@ -17,12 +17,12 @@ const RingSize = 16 // It proves that the signer holds the private key for one of the ring members // without revealing which one. type MLSAGSignature struct { - // C0 is the initial challenge scalar. - C0 [32]byte - // SS[i] is the response scalar for ring member i. - SS [][32]byte // length = RingSize - // KeyImage prevents double-spending. - KeyImage KeyImage + // C0 is the initial challenge scalar. + C0 [32]byte + // SS[i] is the response scalar for ring member i. + SS [][32]byte // length = RingSize + // KeyImage prevents double-spending. + KeyImage KeyImage // BlindSS and ValueSS are the v4 commitment-opening responses. They are // empty for legacy signatures and must contain RingSize canonical scalars // for v4 signatures. @@ -47,174 +47,176 @@ type RingMember = Point32 // - realIdx: index of the real key in ring (0 ≤ realIdx < RingSize) // - privKey: one-time private scalar for the real UTXO func MLSAGSign(message Hash32, ring []RingMember, realIdx int, privKey Scalar32) (*MLSAGSignature, error) { - if len(ring) != RingSize { - return nil, fmt.Errorf("ring size must be %d, got %d", RingSize, len(ring)) - } - if realIdx < 0 || realIdx >= RingSize { - return nil, fmt.Errorf("realIdx %d out of range [0, %d)", realIdx, RingSize) - } - // C3 fix: reject rings with duplicate members. A ring where all 16 keys - // are identical still produces a valid MLSAG challenge chain but collapses - // the anonymity set to 1, trivially identifying the signer. - { - seen := make(map[RingMember]struct{}, RingSize) - for i, m := range ring { - if _, dup := seen[m]; dup { - return nil, fmt.Errorf("ring member %d is a duplicate — ring must contain distinct keys", i) - } - seen[m] = struct{}{} - } - } - - x, err := ScalarFromBytes(privKey[:]) - if err != nil { - return nil, fmt.Errorf("private key: %w", err) - } - - // Compute key image: I = x · Hp(P_real) - ki, err := ComputeKeyImage(privKey, ring[realIdx]) - if err != nil { - return nil, fmt.Errorf("key image: %w", err) - } - - I, err := PointFromBytes(ki[:]) - if err != nil { - return nil, fmt.Errorf("key image point: %w", err) - } - - // --- MLSAG construction --- - ss := make([]*edwards25519.Scalar, RingSize) - cc := make([]*edwards25519.Scalar, RingSize+1) - - // Step 1: random alpha for the real index - alpha, err := randomScalar() - if err != nil { - return nil, fmt.Errorf("random alpha: %w", err) - } - - // Step 2: compute L_real = alpha·G, R_real = alpha·Hp(P_real) - L_real := (&edwards25519.Point{}).ScalarBaseMult(alpha) - HpReal := HashToCurvePoint(ring[realIdx][:]) - R_real := (&edwards25519.Point{}).ScalarMult(alpha, HpReal) - - // Step 3: compute challenge c_{real+1} - cc[nextIdx(realIdx)] = ringChallenge(message, L_real, R_real) - - // Step 4: walk the ring forward from real+1 - for i := 1; i < RingSize; i++ { - idx := (realIdx + i) % RingSize - nextI := (realIdx + i + 1) % RingSize - - // Random response for non-real members - ss[idx], err = randomScalar() - if err != nil { - return nil, fmt.Errorf("random s[%d]: %w", idx, err) - } - - // L_i = ss[i]·G + cc[i]·P_i - Pi, err := PointFromBytes(ring[idx][:]) - if err != nil { - return nil, fmt.Errorf("ring member %d: %w", idx, err) - } - L_i := ringL(ss[idx], cc[(realIdx+i)%RingSize], Pi) - - // R_i = ss[i]·Hp(P_i) + cc[i]·I - HpI := HashToCurvePoint(ring[idx][:]) - R_i := ringR(ss[idx], cc[(realIdx+i)%RingSize], HpI, I) - - cc[nextI] = ringChallenge(message, L_i, R_i) - } - - // Step 5: close the ring — compute s_real = alpha - cc[real]·x (mod order) - cx := edwards25519.NewScalar().Multiply(cc[realIdx], x) - ss[realIdx] = edwards25519.NewScalar().Subtract(alpha, cx) - - // Encode - sig := &MLSAGSignature{ - KeyImage: ki, - SS: make([][32]byte, RingSize), - } - copy(sig.C0[:], cc[0].Bytes()) - for i := range ss { - copy(sig.SS[i][:], ss[i].Bytes()) - } - return sig, nil + if len(ring) != RingSize { + return nil, fmt.Errorf("ring size must be %d, got %d", RingSize, len(ring)) + } + if realIdx < 0 || realIdx >= RingSize { + return nil, fmt.Errorf("realIdx %d out of range [0, %d)", realIdx, RingSize) + } + // C3 fix: reject rings with duplicate members. A ring where all 16 keys + // are identical still produces a valid MLSAG challenge chain but collapses + // the anonymity set to 1, trivially identifying the signer. + { + seen := make(map[RingMember]struct{}, RingSize) + for i, m := range ring { + if _, dup := seen[m]; dup { + return nil, fmt.Errorf("ring member %d is a duplicate — ring must contain distinct keys", i) + } + seen[m] = struct{}{} + } + } + + x, err := ScalarFromBytes(privKey[:]) + if err != nil { + return nil, fmt.Errorf("private key: %w", err) + } + + // Compute key image: I = x · Hp(P_real) + ki, err := ComputeKeyImage(privKey, ring[realIdx]) + if err != nil { + return nil, fmt.Errorf("key image: %w", err) + } + + I, err := PointFromBytes(ki[:]) + if err != nil { + return nil, fmt.Errorf("key image point: %w", err) + } + + // --- MLSAG construction --- + ss := make([]*edwards25519.Scalar, RingSize) + cc := make([]*edwards25519.Scalar, RingSize+1) + + // Step 1: random alpha for the real index + alpha, err := randomScalar() + if err != nil { + return nil, fmt.Errorf("random alpha: %w", err) + } + + // Step 2: compute L_real = alpha·G, R_real = alpha·Hp(P_real) + L_real := (&edwards25519.Point{}).ScalarBaseMult(alpha) + HpReal := HashToCurvePoint(ring[realIdx][:]) + R_real := (&edwards25519.Point{}).ScalarMult(alpha, HpReal) + + // Step 3: compute challenge c_{real+1} + cc[nextIdx(realIdx)] = ringChallenge(message, L_real, R_real) + + // Step 4: walk the ring forward from real+1 + for i := 1; i < RingSize; i++ { + idx := (realIdx + i) % RingSize + nextI := (realIdx + i + 1) % RingSize + + // Random response for non-real members + ss[idx], err = randomScalar() + if err != nil { + return nil, fmt.Errorf("random s[%d]: %w", idx, err) + } + + // L_i = ss[i]·G + cc[i]·P_i + Pi, err := PointFromBytes(ring[idx][:]) + if err != nil { + return nil, fmt.Errorf("ring member %d: %w", idx, err) + } + L_i := ringL(ss[idx], cc[(realIdx+i)%RingSize], Pi) + + // R_i = ss[i]·Hp(P_i) + cc[i]·I + HpI := HashToCurvePoint(ring[idx][:]) + R_i := ringR(ss[idx], cc[(realIdx+i)%RingSize], HpI, I) + + cc[nextI] = ringChallenge(message, L_i, R_i) + } + + // Step 5: close the ring — compute s_real = alpha - cc[real]·x (mod order) + cx := edwards25519.NewScalar().Multiply(cc[realIdx], x) + ss[realIdx] = edwards25519.NewScalar().Subtract(alpha, cx) + + // Encode + sig := &MLSAGSignature{ + KeyImage: ki, + SS: make([][32]byte, RingSize), + } + copy(sig.C0[:], cc[0].Bytes()) + for i := range ss { + copy(sig.SS[i][:], ss[i].Bytes()) + } + return sig, nil } // MLSAGVerify verifies an MLSAG ring signature. func MLSAGVerify(message Hash32, ring []RingMember, sig *MLSAGSignature) (bool, error) { - if sig == nil { - return false, fmt.Errorf("nil signature") - } - if len(ring) != RingSize || len(sig.SS) != RingSize { - return false, fmt.Errorf("ring size mismatch") - } - // C3 fix: reject rings with duplicate members at verification time too. - // Without this check a verifier would accept a ring crafted by a signer - // that skipped the uniqueness guard. - { - seen := make(map[RingMember]struct{}, RingSize) - for i, m := range ring { - if _, dup := seen[m]; dup { - return false, fmt.Errorf("ring member %d is a duplicate — ring must contain distinct keys", i) - } - seen[m] = struct{}{} - } - } - - I, err := PointFromBytes(sig.KeyImage[:]) - if err != nil { - return false, fmt.Errorf("key image: %w", err) - } - // Torsion subgroup check (CVE-2017-12424 analogue for APRO RingCT). - // Ed25519 has an 8-element torsion subgroup. A forged key image - // I' = I + T (T small-order) carries the same ring equations but maps - // to a different raw encoding, letting an attacker spend the same UTXO - // up to 8 times. Reject any key image whose canonical form differs - // from its raw encoding — i.e. any key image with a torsion component. - canonicalKI, canonErr := CanonicalKeyImage(sig.KeyImage) - if canonErr != nil { - return false, fmt.Errorf("key image subgroup check: %w", canonErr) - } - if canonicalKI != sig.KeyImage { - return false, fmt.Errorf("key image is not in the prime-order subgroup (torsion component detected)") - } - - c0, err := ScalarFromBytes(sig.C0[:]) - if err != nil { - return false, fmt.Errorf("c0: %w", err) - } - - cc := make([]*edwards25519.Scalar, RingSize+1) - cc[0] = c0 - - for i := 0; i < RingSize; i++ { - s_i, err := ScalarFromBytes(sig.SS[i][:]) - if err != nil { - return false, fmt.Errorf("ss[%d]: %w", i, err) - } - - Pi, err := PointFromBytes(ring[i][:]) - if err != nil { - return false, fmt.Errorf("ring[%d]: %w", i, err) - } - - L_i := ringL(s_i, cc[i], Pi) - HpI := HashToCurvePoint(ring[i][:]) - R_i := ringR(s_i, cc[i], HpI, I) - - cc[i+1] = ringChallenge(message, L_i, R_i) - } - - // Verify that the ring closes: c_0 == cc[RingSize] - return subtle.ConstantTimeCompare(cc[0].Bytes(), cc[RingSize].Bytes()) == 1, nil + if sig == nil { + return false, fmt.Errorf("nil signature") + } + if len(ring) != RingSize || len(sig.SS) != RingSize { + return false, fmt.Errorf("ring size mismatch") + } + // C3 fix: reject rings with duplicate members at verification time too. + // Without this check a verifier would accept a ring crafted by a signer + // that skipped the uniqueness guard. + { + seen := make(map[RingMember]struct{}, RingSize) + for i, m := range ring { + if _, dup := seen[m]; dup { + return false, fmt.Errorf("ring member %d is a duplicate — ring must contain distinct keys", i) + } + seen[m] = struct{}{} + } + } + + I, err := PointFromBytes(sig.KeyImage[:]) + if err != nil { + return false, fmt.Errorf("key image: %w", err) + } + // Torsion subgroup check (CVE-2017-12424 analog for APRO RingCT). + // Ed25519 has an 8-element torsion subgroup. A forged key image + // I' = I + T (T small-order) carries the same ring equations but maps + // to a different raw encoding, letting an attacker spend the same UTXO + // up to 8 times. Reject any key image whose canonical form differs + // from its raw encoding — i.e. any key image with a torsion component. + canonicalKI, canonErr := CanonicalKeyImage(sig.KeyImage) + if canonErr != nil { + return false, fmt.Errorf("key image subgroup check: %w", canonErr) + } + if canonicalKI != sig.KeyImage { + return false, fmt.Errorf("key image is not in the prime-order subgroup (torsion component detected)") + } + + c0, err := ScalarFromBytes(sig.C0[:]) + if err != nil { + return false, fmt.Errorf("c0: %w", err) + } + + cc := make([]*edwards25519.Scalar, RingSize+1) + cc[0] = c0 + + for i := 0; i < RingSize; i++ { + s_i, err := ScalarFromBytes(sig.SS[i][:]) + if err != nil { + return false, fmt.Errorf("ss[%d]: %w", i, err) + } + + Pi, err := PointFromBytes(ring[i][:]) + if err != nil { + return false, fmt.Errorf("ring[%d]: %w", i, err) + } + + L_i := ringL(s_i, cc[i], Pi) + HpI := HashToCurvePoint(ring[i][:]) + R_i := ringR(s_i, cc[i], HpI, I) + + cc[i+1] = ringChallenge(message, L_i, R_i) + } + + // Verify that the ring closes: c_0 == cc[RingSize] + return subtle.ConstantTimeCompare(cc[0].Bytes(), cc[RingSize].Bytes()) == 1, nil } // MLSAGSignV4 signs a ring while proving that the same hidden ring member // owns both the one-time private key and the opening of amountCommit. // // The additional response vectors form an OR proof of: -// P_i = x_i*G and amountCommit = r_i*G + value_i*H +// +// P_i = x_i*G and amountCommit = r_i*G + value_i*H +// // for one common i. The value is supplied as a uint64 because the transaction // amount domain is uint64; the Pedersen commitment and proof remain hidden. func MLSAGSignV4(message Hash32, ring []RingMember, realIdx int, @@ -481,31 +483,31 @@ func amountCommitForV4(r, v *edwards25519.Scalar) Commitment { return commitment } -// ringL computes L = s·G + c·P +// ringL computes L = s·G + c·P. func ringL(s, c *edwards25519.Scalar, P *edwards25519.Point) *edwards25519.Point { - sG := (&edwards25519.Point{}).ScalarBaseMult(s) - cP := (&edwards25519.Point{}).ScalarMult(c, P) - return (&edwards25519.Point{}).Add(sG, cP) + sG := (&edwards25519.Point{}).ScalarBaseMult(s) + cP := (&edwards25519.Point{}).ScalarMult(c, P) + return (&edwards25519.Point{}).Add(sG, cP) } -// ringR computes R = s·Hp + c·I +// ringR computes R = s·Hp + c·I. func ringR(s, c *edwards25519.Scalar, Hp, I *edwards25519.Point) *edwards25519.Point { - sHp := (&edwards25519.Point{}).ScalarMult(s, Hp) - cI := (&edwards25519.Point{}).ScalarMult(c, I) - return (&edwards25519.Point{}).Add(sHp, cI) + sHp := (&edwards25519.Point{}).ScalarMult(s, Hp) + cI := (&edwards25519.Point{}).ScalarMult(c, I) + return (&edwards25519.Point{}).Add(sHp, cI) } // ringChallenge computes SHA-512(message || L || R) as a scalar. func ringChallenge(msg Hash32, L, R *edwards25519.Point) *edwards25519.Scalar { - h := sha512.New() - h.Write([]byte("Aperod/MLSAG/v1")) - h.Write(msg[:]) - h.Write(L.Bytes()) - h.Write(R.Bytes()) - var wide [64]byte - copy(wide[:], h.Sum(nil)) - s, _ := edwards25519.NewScalar().SetUniformBytes(wide[:]) - return s + h := sha512.New() + h.Write([]byte("Aperod/MLSAG/v1")) + h.Write(msg[:]) + h.Write(L.Bytes()) + h.Write(R.Bytes()) + var wide [64]byte + copy(wide[:], h.Sum(nil)) + s, _ := edwards25519.NewScalar().SetUniformBytes(wide[:]) + return s } // nextIdx wraps index within RingSize. diff --git a/crypto/ringct_v4_test.go b/crypto/ringct_v4_test.go index 4195143..9df279c 100644 --- a/crypto/ringct_v4_test.go +++ b/crypto/ringct_v4_test.go @@ -86,4 +86,4 @@ func TestMLSAGV4RejectsTamperedMessageAndLink(t *testing.T) { if ok, err := crypto.MLSAGVerifyV4(message, ring, commitment, 5, sig); err == nil && ok { t.Fatal("v4 proof accepted a tampered direct ownership link") } -} \ No newline at end of file +} diff --git a/crypto/security_test.go b/crypto/security_test.go index 2cda2f6..b8734c8 100644 --- a/crypto/security_test.go +++ b/crypto/security_test.go @@ -8,71 +8,71 @@ package crypto_test // 3.4.6 Ring with duplicate public keys: detected or verification fails import ( - "testing" + "testing" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/crypto" ) // ─── 3.4.1: Ring size edge cases ───────────────────────────────────────────── // TestMLSAGSign_RingSize1 verifies that a ring of size 1 (< RingSize) is rejected. func TestMLSAGSign_RingSize1(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := []crypto.RingMember{wk.Spend.Public} - var msg crypto.Hash32 - _, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err == nil { - t.Error("MLSAGSign with ring size 1 should return error") - } + wk, _ := crypto.GenerateWalletKeys() + ring := []crypto.RingMember{wk.Spend.Public} + var msg crypto.Hash32 + _, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err == nil { + t.Error("MLSAGSign with ring size 1 should return error") + } } // TestMLSAGSign_RingSize2 verifies that ring size 2 (< RingSize=16) is rejected. func TestMLSAGSign_RingSize2(t *testing.T) { - wk1, _ := crypto.GenerateWalletKeys() - wk2, _ := crypto.GenerateWalletKeys() - ring := []crypto.RingMember{wk1.Spend.Public, wk2.Spend.Public} - var msg crypto.Hash32 - _, err := crypto.MLSAGSign(msg, ring, 0, wk1.Spend.Private) - if err == nil { - t.Error("MLSAGSign with ring size 2 should return error") - } + wk1, _ := crypto.GenerateWalletKeys() + wk2, _ := crypto.GenerateWalletKeys() + ring := []crypto.RingMember{wk1.Spend.Public, wk2.Spend.Public} + var msg crypto.Hash32 + _, err := crypto.MLSAGSign(msg, ring, 0, wk1.Spend.Private) + if err == nil { + t.Error("MLSAGSign with ring size 2 should return error") + } } // TestMLSAGSign_RingSize16_Valid verifies that the canonical ring size (16) works. func TestMLSAGSign_RingSize16_Valid(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) - for i := 1; i < crypto.RingSize; i++ { - d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) - } - var msg crypto.Hash32 - msg[0] = 0xAB - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - t.Fatalf("MLSAGSign ring size %d: %v", crypto.RingSize, err) - } - ok, err := crypto.MLSAGVerify(msg, ring, sig) - if err != nil || !ok { - t.Errorf("MLSAGVerify after valid sign: ok=%v err=%v", ok, err) - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = crypto.RingMember(wk.Spend.Public) + for i := 1; i < crypto.RingSize; i++ { + d, _ := crypto.GenerateWalletKeys() + ring[i] = crypto.RingMember(d.Spend.Public) + } + var msg crypto.Hash32 + msg[0] = 0xAB + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + t.Fatalf("MLSAGSign ring size %d: %v", crypto.RingSize, err) + } + ok, err := crypto.MLSAGVerify(msg, ring, sig) + if err != nil || !ok { + t.Errorf("MLSAGVerify after valid sign: ok=%v err=%v", ok, err) + } } // TestMLSAGSign_RingSize100 verifies that ring size 100 (> RingSize) is rejected. func TestMLSAGSign_RingSize100(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, 100) - for i := range ring { - d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) - } - ring[0] = crypto.RingMember(wk.Spend.Public) - var msg crypto.Hash32 - _, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err == nil { - t.Error("MLSAGSign with ring size 100 should return error") - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, 100) + for i := range ring { + d, _ := crypto.GenerateWalletKeys() + ring[i] = crypto.RingMember(d.Spend.Public) + } + ring[0] = crypto.RingMember(wk.Spend.Public) + var msg crypto.Hash32 + _, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err == nil { + t.Error("MLSAGSign with ring size 100 should return error") + } } // ─── 3.4.5: Transaction malleability ───────────────────────────────────────── @@ -81,93 +81,93 @@ func TestMLSAGSign_RingSize100(t *testing.T) { // is bound to the exact message bytes. Flipping any bit in the signed message // must cause MLSAGVerify to return false. func TestTxMalleability_SignatureInvalidatedAfterChange(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) - for i := 1; i < crypto.RingSize; i++ { - d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) - } - - // Sign a message representing a transaction hash - var msg crypto.Hash32 - msg[0] = 0xDE - msg[1] = 0xAD - msg[2] = 0xBE - msg[3] = 0xEF - - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - t.Fatalf("MLSAGSign: %v", err) - } - - // Verify with original message — must succeed - ok, err := crypto.MLSAGVerify(msg, ring, sig) - if err != nil || !ok { - t.Fatalf("MLSAGVerify original: ok=%v err=%v", ok, err) - } - - // Flip each of the first 4 bytes and verify that verification fails - for i := 0; i < 4; i++ { - tampered := msg - tampered[i] ^= 0xFF // flip all bits in byte i - ok, _ := crypto.MLSAGVerify(tampered, ring, sig) - if ok { - t.Errorf("MLSAGVerify with tampered byte %d should return false (malleability)", i) - } - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = crypto.RingMember(wk.Spend.Public) + for i := 1; i < crypto.RingSize; i++ { + d, _ := crypto.GenerateWalletKeys() + ring[i] = crypto.RingMember(d.Spend.Public) + } + + // Sign a message representing a transaction hash + var msg crypto.Hash32 + msg[0] = 0xDE + msg[1] = 0xAD + msg[2] = 0xBE + msg[3] = 0xEF + + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + t.Fatalf("MLSAGSign: %v", err) + } + + // Verify with original message — must succeed + ok, err := crypto.MLSAGVerify(msg, ring, sig) + if err != nil || !ok { + t.Fatalf("MLSAGVerify original: ok=%v err=%v", ok, err) + } + + // Flip each of the first 4 bytes and verify that verification fails + for i := 0; i < 4; i++ { + tampered := msg + tampered[i] ^= 0xFF // flip all bits in byte i + ok, _ := crypto.MLSAGVerify(tampered, ring, sig) + if ok { + t.Errorf("MLSAGVerify with tampered byte %d should return false (malleability)", i) + } + } } // TestTxMalleability_KeyImageTampering verifies that altering the key image // in a signature causes verification to fail. func TestTxMalleability_KeyImageTampering(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) - for i := 1; i < crypto.RingSize; i++ { - d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) - } - - var msg crypto.Hash32 - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - t.Fatalf("MLSAGSign: %v", err) - } - - // Tamper with the key image - sig.KeyImage[0] ^= 0x01 - ok, _ := crypto.MLSAGVerify(msg, ring, sig) - if ok { - t.Error("MLSAGVerify with tampered KeyImage should return false") - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = crypto.RingMember(wk.Spend.Public) + for i := 1; i < crypto.RingSize; i++ { + d, _ := crypto.GenerateWalletKeys() + ring[i] = crypto.RingMember(d.Spend.Public) + } + + var msg crypto.Hash32 + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + t.Fatalf("MLSAGSign: %v", err) + } + + // Tamper with the key image + sig.KeyImage[0] ^= 0x01 + ok, _ := crypto.MLSAGVerify(msg, ring, sig) + if ok { + t.Error("MLSAGVerify with tampered KeyImage should return false") + } } // TestTxMalleability_RingTampering verifies that swapping a ring member after // signing invalidates the signature. func TestTxMalleability_RingTampering(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) - for i := 1; i < crypto.RingSize; i++ { - d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) - } - - var msg crypto.Hash32 - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - t.Fatalf("MLSAGSign: %v", err) - } - - // Swap a decoy ring member with a fresh random key - fresh, _ := crypto.GenerateWalletKeys() - ring[1] = crypto.RingMember(fresh.Spend.Public) - - ok, _ := crypto.MLSAGVerify(msg, ring, sig) - if ok { - t.Error("MLSAGVerify with tampered ring member should return false") - } + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = crypto.RingMember(wk.Spend.Public) + for i := 1; i < crypto.RingSize; i++ { + d, _ := crypto.GenerateWalletKeys() + ring[i] = crypto.RingMember(d.Spend.Public) + } + + var msg crypto.Hash32 + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + t.Fatalf("MLSAGSign: %v", err) + } + + // Swap a decoy ring member with a fresh random key + fresh, _ := crypto.GenerateWalletKeys() + ring[1] = crypto.RingMember(fresh.Spend.Public) + + ok, _ := crypto.MLSAGVerify(msg, ring, sig) + if ok { + t.Error("MLSAGVerify with tampered ring member should return false") + } } // ─── 3.4.6: Ring with duplicate public keys ────────────────────────────────── @@ -176,50 +176,50 @@ func TestTxMalleability_RingTampering(t *testing.T) { // public keys either returns an error or produces a signature that fails // verification (the implementation must not silently accept it). func TestMLSAGSign_DuplicateRingKeys(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - // Build a ring of the correct size, but fill with wk's public key twice - ring := make([]crypto.RingMember, crypto.RingSize) - for i := range ring { - ring[i] = crypto.RingMember(wk.Spend.Public) // all identical - } - - var msg crypto.Hash32 - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - // Acceptable: the implementation rejected the ring outright. - t.Logf("MLSAGSign with all-identical ring returned error (expected): %v", err) - return - } - - // If signing succeeded, verification should still be deterministic. - // In a correct implementation the identical-key ring is degenerate, so we - // just assert that the function does not panic. - _, _ = crypto.MLSAGVerify(msg, ring, sig) - t.Log("MLSAGSign accepted duplicate-key ring; verification ran without panic") + wk, _ := crypto.GenerateWalletKeys() + // Build a ring of the correct size, but fill with wk's public key twice + ring := make([]crypto.RingMember, crypto.RingSize) + for i := range ring { + ring[i] = crypto.RingMember(wk.Spend.Public) // all identical + } + + var msg crypto.Hash32 + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + // Acceptable: the implementation rejected the ring outright. + t.Logf("MLSAGSign with all-identical ring returned error (expected): %v", err) + return + } + + // If signing succeeded, verification should still be deterministic. + // In a correct implementation the identical-key ring is degenerate, so we + // just assert that the function does not panic. + _, _ = crypto.MLSAGVerify(msg, ring, sig) + t.Log("MLSAGSign accepted duplicate-key ring; verification ran without panic") } // TestMLSAGSign_PartialDuplicateKeys verifies behavior when only some ring // members share a key (common attack attempt: replace a decoy with the signer). func TestMLSAGSign_PartialDuplicateKeys(t *testing.T) { - wk, _ := crypto.GenerateWalletKeys() - ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) - for i := 1; i < crypto.RingSize; i++ { - d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) - } - // Duplicate index 1 with index 0 - ring[1] = ring[0] - - var msg crypto.Hash32 - sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) - if err != nil { - t.Logf("MLSAGSign with partial duplicate keys returned error: %v", err) - return - } - // If it signed, verify — we only require no panic - ok, verifyErr := crypto.MLSAGVerify(msg, ring, sig) - t.Logf("MLSAGVerify with partial duplicate ring: ok=%v err=%v", ok, verifyErr) + wk, _ := crypto.GenerateWalletKeys() + ring := make([]crypto.RingMember, crypto.RingSize) + ring[0] = crypto.RingMember(wk.Spend.Public) + for i := 1; i < crypto.RingSize; i++ { + d, _ := crypto.GenerateWalletKeys() + ring[i] = crypto.RingMember(d.Spend.Public) + } + // Duplicate index 1 with index 0 + ring[1] = ring[0] + + var msg crypto.Hash32 + sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) + if err != nil { + t.Logf("MLSAGSign with partial duplicate keys returned error: %v", err) + return + } + // If it signed, verify — we only require no panic + ok, verifyErr := crypto.MLSAGVerify(msg, ring, sig) + t.Logf("MLSAGVerify with partial duplicate ring: ok=%v err=%v", ok, verifyErr) } // ─── 3.4.2: Pedersen commitment edge cases ─────────────────────────────────── @@ -227,51 +227,51 @@ func TestMLSAGSign_PartialDuplicateKeys(t *testing.T) { // TestCommit_MaxUint64 verifies that committing to the maximum uint64 value // does not panic and produces a valid commitment. func TestCommit_MaxUint64(t *testing.T) { - bf, err := crypto.NewBlindFactor() - if err != nil { - t.Fatalf("NewBlindFactor: %v", err) - } - commit, err := crypto.Commit(^uint64(0), bf) // max uint64 - if err != nil { - t.Fatalf("Commit(MaxUint64): %v", err) - } - if commit == (crypto.Commitment{}) { - t.Error("Commit(MaxUint64) returned zero commitment") - } + bf, err := crypto.NewBlindFactor() + if err != nil { + t.Fatalf("NewBlindFactor: %v", err) + } + commit, err := crypto.Commit(^uint64(0), bf) // max uint64 + if err != nil { + t.Fatalf("Commit(MaxUint64): %v", err) + } + if commit == (crypto.Commitment{}) { + t.Error("Commit(MaxUint64) returned zero commitment") + } } // TestCommit_ZeroWithNonZeroBlind verifies Commit(0, nonZeroBlind) is non-zero. func TestCommit_ZeroWithNonZeroBlind(t *testing.T) { - bf, _ := crypto.NewBlindFactor() - commit, err := crypto.Commit(0, bf) - if err != nil { - t.Fatalf("Commit(0, nonZero): %v", err) - } - // Even with v=0, the blinding factor makes the commitment non-zero - if commit == (crypto.Commitment{}) { - t.Error("Commit(0, nonZeroBlind) unexpectedly returned zero commitment") - } + bf, _ := crypto.NewBlindFactor() + commit, err := crypto.Commit(0, bf) + if err != nil { + t.Fatalf("Commit(0, nonZero): %v", err) + } + // Even with v=0, the blinding factor makes the commitment non-zero + if commit == (crypto.Commitment{}) { + t.Error("Commit(0, nonZeroBlind) unexpectedly returned zero commitment") + } } // TestProveRange_NegativeValueBehavior verifies that ProveRange on a negative // uint64 (which is a very large positive uint64) either errors or produces a proof. func TestProveRange_LargeValue(t *testing.T) { - bf, _ := crypto.NewBlindFactor() - // A large value that is still a valid uint64 - largeVal := uint64(1<<62 + 42) - proof, err := crypto.ProveRange(largeVal, bf) - if err != nil { - t.Logf("ProveRange(largeVal) returned error (may be expected for simplified impl): %v", err) - return - } - ok, verifyErr := crypto.VerifyRange(proof) - if verifyErr != nil { - t.Logf("VerifyRange error: %v", verifyErr) - return - } - if !ok { - t.Error("VerifyRange returned false for valid large proof") - } + bf, _ := crypto.NewBlindFactor() + // A large value that is still a valid uint64 + largeVal := uint64(1<<62 + 42) + proof, err := crypto.ProveRange(largeVal, bf) + if err != nil { + t.Logf("ProveRange(largeVal) returned error (may be expected for simplified impl): %v", err) + return + } + ok, verifyErr := crypto.VerifyRange(proof) + if verifyErr != nil { + t.Logf("VerifyRange error: %v", verifyErr) + return + } + if !ok { + t.Error("VerifyRange returned false for valid large proof") + } } // ─── 3.4.7 Independent Bulletproof verification ─────────────────────────────── @@ -280,63 +280,63 @@ func TestProveRange_LargeValue(t *testing.T) { // verifier") for the ProveRange / VerifyRange pair. // // Strategy: -// 1. Generate a proof with a known (value, blind) pair. -// 2. Confirm VerifyRange accepts it. -// 3. Re-derive the value commitment independently via Commit() and confirm -// it matches the proof's embedded ValueCommit — proving the prover and -// verifier share the same commitment scheme. -// 4. Tamper the ValueCommit → verifier must reject. -// 5. Tamper a BitCommit → verifier must reject. +// 1. Generate a proof with a known (value, blind) pair. +// 2. Confirm VerifyRange accepts it. +// 3. Re-derive the value commitment independently via Commit() and confirm +// it matches the proof's embedded ValueCommit — proving the prover and +// verifier share the same commitment scheme. +// 4. Tamper the ValueCommit → verifier must reject. +// 5. Tamper a BitCommit → verifier must reject. func TestBulletproof_IndependentVerification(t *testing.T) { - bf, err := crypto.NewBlindFactor() - if err != nil { - t.Fatalf("NewBlindFactor: %v", err) - } - - const value = uint64(42_000_000) - - // ── Step 1-2: prove and verify ──────────────────────────────────────────── - proof, err := crypto.ProveRange(value, bf) - if err != nil { - t.Fatalf("ProveRange: %v", err) - } - ok, err := crypto.VerifyRange(proof) - if err != nil { - t.Fatalf("VerifyRange (valid): %v", err) - } - if !ok { - t.Fatal("3.4.7: VerifyRange rejected a valid proof") - } - - // ── Step 3: independently re-derive the commitment ──────────────────────── - recomputed, err := crypto.Commit(value, bf) - if err != nil { - t.Fatalf("Commit (recompute): %v", err) - } - if recomputed != proof.ValueCommit { - t.Errorf("3.4.7: independent commitment mismatch\n proof has: %x\n recomputed: %x", - proof.ValueCommit, recomputed) - } else { - t.Logf("3.4.7 ✓ commitment cross-check passed: %x", recomputed[:8]) - } - - // ── Step 4: tamper ValueCommit → must fail ──────────────────────────────── - tampered := *proof - tampered.ValueCommit[0] ^= 0xFF - ok4, _ := crypto.VerifyRange(&tampered) - if ok4 { - t.Error("3.4.7: tampered ValueCommit should fail verification") - } else { - t.Log("3.4.7 ✓ tampered ValueCommit correctly rejected") - } - - // ── Step 5: tamper Ls[0] → must fail ──────────────────────────────────── - tampered2 := *proof - tampered2.Ls[0][0] ^= 0x01 - ok5, _ := crypto.VerifyRange(&tampered2) - if ok5 { - t.Error("3.4.7: tampered Ls[0] should fail verification") - } else { - t.Log("3.4.7 ✓ tampered Ls[0] correctly rejected") - } + bf, err := crypto.NewBlindFactor() + if err != nil { + t.Fatalf("NewBlindFactor: %v", err) + } + + const value = uint64(42_000_000) + + // ── Step 1-2: prove and verify ──────────────────────────────────────────── + proof, err := crypto.ProveRange(value, bf) + if err != nil { + t.Fatalf("ProveRange: %v", err) + } + ok, err := crypto.VerifyRange(proof) + if err != nil { + t.Fatalf("VerifyRange (valid): %v", err) + } + if !ok { + t.Fatal("3.4.7: VerifyRange rejected a valid proof") + } + + // ── Step 3: independently re-derive the commitment ──────────────────────── + recomputed, err := crypto.Commit(value, bf) + if err != nil { + t.Fatalf("Commit (recompute): %v", err) + } + if recomputed != proof.ValueCommit { + t.Errorf("3.4.7: independent commitment mismatch\n proof has: %x\n recomputed: %x", + proof.ValueCommit, recomputed) + } else { + t.Logf("3.4.7 ✓ commitment cross-check passed: %x", recomputed[:8]) + } + + // ── Step 4: tamper ValueCommit → must fail ──────────────────────────────── + tampered := *proof + tampered.ValueCommit[0] ^= 0xFF + ok4, _ := crypto.VerifyRange(&tampered) + if ok4 { + t.Error("3.4.7: tampered ValueCommit should fail verification") + } else { + t.Log("3.4.7 ✓ tampered ValueCommit correctly rejected") + } + + // ── Step 5: tamper Ls[0] → must fail ──────────────────────────────────── + tampered2 := *proof + tampered2.Ls[0][0] ^= 0x01 + ok5, _ := crypto.VerifyRange(&tampered2) + if ok5 { + t.Error("3.4.7: tampered Ls[0] should fail verification") + } else { + t.Log("3.4.7 ✓ tampered Ls[0] correctly rejected") + } } diff --git a/crypto/wallet.go b/crypto/wallet.go index a25e57e..ef116b2 100644 --- a/crypto/wallet.go +++ b/crypto/wallet.go @@ -1,12 +1,12 @@ package crypto import ( - "crypto/rand" - "crypto/sha512" - "encoding/binary" - "fmt" + "crypto/rand" + "crypto/sha512" + "encoding/binary" + "fmt" - "filippo.io/edwards25519" + "filippo.io/edwards25519" ) // Scalar32 is a 32-byte Edwards25519 scalar. @@ -18,130 +18,130 @@ type Point32 [32]byte // SpendKey is the private key used to spend funds (analogous to Monero's spend key). // It MUST stay secret — losing or exposing it means losing control of all funds. type SpendKey struct { - Private Scalar32 // 32-byte scalar - Public Point32 // 32-byte compressed point + Private Scalar32 // 32-byte scalar + Public Point32 // 32-byte compressed point } // ViewKey is derived from the spend key and is used to scan for incoming transactions. // It can be shared with a read-only wallet without granting spending power. type ViewKey struct { - Private Scalar32 // 32-byte scalar = H(spend_priv) - Public Point32 // 32-byte compressed point + Private Scalar32 // 32-byte scalar = H(spend_priv) + Public Point32 // 32-byte compressed point } // WalletKeyPair holds a spend/view key pair (the Monero-style dual-key system). type WalletKeyPair struct { - Spend SpendKey - View ViewKey + Spend SpendKey + View ViewKey } // GenerateWalletKeys creates a fresh random wallet key pair. func GenerateWalletKeys() (*WalletKeyPair, error) { - // Generate random 32-byte spend private key - var seed [32]byte - if _, err := rand.Read(seed[:]); err != nil { - return nil, fmt.Errorf("generate wallet seed: %w", err) - } - return WalletKeysFromSeed(seed[:]) + // Generate random 32-byte spend private key + var seed [32]byte + if _, err := rand.Read(seed[:]); err != nil { + return nil, fmt.Errorf("generate wallet seed: %w", err) + } + return WalletKeysFromSeed(seed[:]) } // WalletKeysFromSeed deterministically derives a wallet key pair from a 32-byte seed. // Used by HD wallets: seed comes from BIP32/BIP44 derivation. func WalletKeysFromSeed(seed []byte) (*WalletKeyPair, error) { - if len(seed) < 32 { - return nil, fmt.Errorf("seed must be at least 32 bytes, got %d", len(seed)) - } - - // Derive spend scalar: clamp SHA3-256(seed || "spend") - spendScalar := deriveScalar(seed, "aperod-spend") - spendPub, err := scalarMulBase(spendScalar) - if err != nil { - return nil, fmt.Errorf("spend public key: %w", err) - } - - // Derive view scalar: SHA3-256(spend_scalar || "view") - viewScalar := deriveScalar(spendScalar[:], "aperod-view") - viewPub, err := scalarMulBase(viewScalar) - if err != nil { - return nil, fmt.Errorf("view public key: %w", err) - } - - return &WalletKeyPair{ - Spend: SpendKey{Private: spendScalar, Public: spendPub}, - View: ViewKey{Private: viewScalar, Public: viewPub}, - }, nil + if len(seed) < 32 { + return nil, fmt.Errorf("seed must be at least 32 bytes, got %d", len(seed)) + } + + // Derive spend scalar: clamp SHA3-256(seed || "spend") + spendScalar := deriveScalar(seed, "aperod-spend") + spendPub, err := scalarMulBase(spendScalar) + if err != nil { + return nil, fmt.Errorf("spend public key: %w", err) + } + + // Derive view scalar: SHA3-256(spend_scalar || "view") + viewScalar := deriveScalar(spendScalar[:], "aperod-view") + viewPub, err := scalarMulBase(viewScalar) + if err != nil { + return nil, fmt.Errorf("view public key: %w", err) + } + + return &WalletKeyPair{ + Spend: SpendKey{Private: spendScalar, Public: spendPub}, + View: ViewKey{Private: viewScalar, Public: viewPub}, + }, nil } // deriveScalar computes a valid Ed25519 scalar via SHA-512 → SetUniformBytes. // This guarantees the result is a canonical scalar in [0, l) (group order), // suitable for both ScalarBaseMult and ScalarFromBytes. func deriveScalar(data []byte, tag string) Scalar32 { - h := sha512.New() - h.Write(data) - h.Write([]byte(tag)) - var wide [64]byte - copy(wide[:], h.Sum(nil)) - sc, _ := edwards25519.NewScalar().SetUniformBytes(wide[:]) - var s Scalar32 - copy(s[:], sc.Bytes()) - return s + h := sha512.New() + h.Write(data) + h.Write([]byte(tag)) + var wide [64]byte + copy(wide[:], h.Sum(nil)) + sc, _ := edwards25519.NewScalar().SetUniformBytes(wide[:]) + var s Scalar32 + copy(s[:], sc.Bytes()) + return s } // scalarMulBase computes scalar * G (base point) and returns the compressed point. func scalarMulBase(s Scalar32) (Point32, error) { - sc, err := edwards25519.NewScalar().SetCanonicalBytes(s[:]) - if err != nil { - return Point32{}, fmt.Errorf("invalid scalar: %w", err) - } - pt := (&edwards25519.Point{}).ScalarBaseMult(sc) - var out Point32 - copy(out[:], pt.Bytes()) - return out, nil + sc, err := edwards25519.NewScalar().SetCanonicalBytes(s[:]) + if err != nil { + return Point32{}, fmt.Errorf("invalid scalar: %w", err) + } + pt := (&edwards25519.Point{}).ScalarBaseMult(sc) + var out Point32 + copy(out[:], pt.Bytes()) + return out, nil } // PublicKeyFromPrivate derives the Ed25519 public key from a private scalar. // Used by the API server when it receives a spend/view private key from the wallet. func PublicKeyFromPrivate(priv Scalar32) (Point32, error) { - return scalarMulBase(priv) + return scalarMulBase(priv) } // ScalarMulBase is exported for use in other crypto sub-packages. func ScalarMulBase(s Scalar32) (Point32, error) { - return scalarMulBase(s) + return scalarMulBase(s) } // AddScalars returns (a + b) mod group order. // Used to derive one-time spend private keys: one_time_priv = Hs + spend_priv. func AddScalars(a, b Scalar32) (Scalar32, error) { - sa, err := ScalarFromBytes(a[:]) - if err != nil { - return Scalar32{}, fmt.Errorf("AddScalars a: %w", err) - } - sb, err := ScalarFromBytes(b[:]) - if err != nil { - return Scalar32{}, fmt.Errorf("AddScalars b: %w", err) - } - sum := edwards25519.NewScalar().Add(sa, sb) - var out Scalar32 - copy(out[:], sum.Bytes()) - return out, nil + sa, err := ScalarFromBytes(a[:]) + if err != nil { + return Scalar32{}, fmt.Errorf("AddScalars a: %w", err) + } + sb, err := ScalarFromBytes(b[:]) + if err != nil { + return Scalar32{}, fmt.Errorf("AddScalars b: %w", err) + } + sum := edwards25519.NewScalar().Add(sa, sb) + var out Scalar32 + copy(out[:], sum.Bytes()) + return out, nil } // AddPoints returns a + b on the Edwards25519 curve as a compressed point. // Used to derive per-block coinbase one-time keys: mint_pub = spend_pub + height*G. func AddPoints(a, b Point32) (Point32, error) { - pa, err := PointFromBytes(a[:]) - if err != nil { - return Point32{}, fmt.Errorf("AddPoints a: %w", err) - } - pb, err := PointFromBytes(b[:]) - if err != nil { - return Point32{}, fmt.Errorf("AddPoints b: %w", err) - } - sum := (&edwards25519.Point{}).Add(pa, pb) - var out Point32 - copy(out[:], sum.Bytes()) - return out, nil + pa, err := PointFromBytes(a[:]) + if err != nil { + return Point32{}, fmt.Errorf("AddPoints a: %w", err) + } + pb, err := PointFromBytes(b[:]) + if err != nil { + return Point32{}, fmt.Errorf("AddPoints b: %w", err) + } + sum := (&edwards25519.Point{}).Add(pa, pb) + var out Point32 + copy(out[:], sum.Bytes()) + return out, nil } // ScalarFromUint64 encodes a small non-negative integer as a canonical @@ -151,31 +151,31 @@ func AddPoints(a, b Point32) (Point32, error) { // (mint_pub = spend_pub + height*G) so identical reward amounts to the same // validator address no longer collide on tx hash / key image across blocks. func ScalarFromUint64(n uint64) Scalar32 { - var s Scalar32 - binary.LittleEndian.PutUint64(s[:8], n) - return s + var s Scalar32 + binary.LittleEndian.PutUint64(s[:8], n) + return s } // PointFromBytes decodes a compressed Ed25519 point. Returns error if invalid. func PointFromBytes(b []byte) (*edwards25519.Point, error) { - if len(b) != 32 { - return nil, fmt.Errorf("point must be 32 bytes, got %d", len(b)) - } - p := new(edwards25519.Point) - if _, err := p.SetBytes(b); err != nil { - return nil, fmt.Errorf("invalid curve point: %w", err) - } - return p, nil + if len(b) != 32 { + return nil, fmt.Errorf("point must be 32 bytes, got %d", len(b)) + } + p := new(edwards25519.Point) + if _, err := p.SetBytes(b); err != nil { + return nil, fmt.Errorf("invalid curve point: %w", err) + } + return p, nil } // ScalarFromBytes decodes a canonical scalar. Returns error if not in range. func ScalarFromBytes(b []byte) (*edwards25519.Scalar, error) { - if len(b) != 32 { - return nil, fmt.Errorf("scalar must be 32 bytes, got %d", len(b)) - } - s := new(edwards25519.Scalar) - if _, err := s.SetCanonicalBytes(b); err != nil { - return nil, fmt.Errorf("invalid scalar: %w", err) - } - return s, nil + if len(b) != 32 { + return nil, fmt.Errorf("scalar must be 32 bytes, got %d", len(b)) + } + s := new(edwards25519.Scalar) + if _, err := s.SetCanonicalBytes(b); err != nil { + return nil, fmt.Errorf("invalid scalar: %w", err) + } + return s, nil } diff --git a/deploy/BURN_POLICY.md b/deploy/BURN_POLICY.md index b2d8acc..aaf79fa 100644 --- a/deploy/BURN_POLICY.md +++ b/deploy/BURN_POLICY.md @@ -52,6 +52,24 @@ The burn is recorded in the `emission_config` table and visible on the [Block Ex --- +## AVM Mandatory Gas Fee + +An AVM transaction must pay both its byte-based minimum fee and the required +gas fee. Before the coordinated AVM gas-burn activation, the existing reward +rule treats fee surplus above the byte minimum as a proposer tip, including +the mandatory gas amount. Historical blocks keep that rule; they must not be +reinterpreted or rewritten. + +The consensus setting `avm_gas_burn_activation_height` selects a future, +network-wide rule boundary. From that height, mandatory AVM gas is burned and +only any fee surplus beyond the byte minimum, intentional burn, and required +gas can be paid as a validator tip. A zero value leaves the deployed rule +unchanged. Do not set or deploy a non-zero height until all validators run a +compatible release and use the same agreed height. Configuration also +requires the boundary to follow AVM activation and reward authorization. + +--- + ## Why 100 % Burn? Aperod is designed to offset tail emission through **deflationary usage**. diff --git a/deploy/aperod-node-watchdog.sh b/deploy/aperod-node-watchdog.sh index aadc1b2..875e0c9 100644 --- a/deploy/aperod-node-watchdog.sh +++ b/deploy/aperod-node-watchdog.sh @@ -274,9 +274,15 @@ write_timestamp "${LAST_CHECK_FILE}" # Health check (body captured for block-height parsing) # --------------------------------------------------------------------------- _body_file=$(mktemp /tmp/aperod-wdog-XXXXXX) -HTTP_CODE=$(curl -s -o "${_body_file}" -w "%{http_code}" \ +if HTTP_CODE=$(curl -s -o "${_body_file}" -w "%{http_code}" \ --max-time "${TIMEOUT_SECS}" \ - "${STATUS_URL}" 2>/dev/null || echo "000") + "${STATUS_URL}" 2>/dev/null); then + : +else + # curl still prints %{http_code} (usually 000) when it exits nonzero. Do not + # append another 000 to that output; preserve the real transport status. + HTTP_CODE="${HTTP_CODE:-000}" +fi RESPONSE_BODY=$(cat "${_body_file}" 2>/dev/null || echo "") rm -f "${_body_file}" diff --git a/deploy/aperod-watchdog-set-interval.sh b/deploy/aperod-watchdog-set-interval.sh index 5d80a48..7b6d96d 100644 --- a/deploy/aperod-watchdog-set-interval.sh +++ b/deploy/aperod-watchdog-set-interval.sh @@ -13,7 +13,7 @@ # 1. Validates N (integer 5–3600). # 2. Writes WATCHDOG_INTERVAL_SECS=N to /etc/aperod/watchdog.env # (creates the file / directory if absent; preserves other lines). -# 3. Writes a systemd drop-in that overrides OnBootSec / OnUnitActiveSec. +# 3. Writes a systemd drop-in that overrides OnActiveSec / OnUnitInactiveSec. # 4. Runs systemctl daemon-reload and restarts the timer. # # The Admin Panel API calls this with --interval=N via a sudoers rule @@ -99,9 +99,11 @@ cat > "${DROPIN_FILE}" </p2p_identity.key (Step 4) +// 2. Defense B — SSH rm -f /p2p_identity.key (Step 4) // Any key already on the target (from a previous install or a hypothetical // broken rsync without --exclude) is deleted so the node generates a fresh // TLS identity on first start. diff --git a/deploy/rollout_cleanup_installer_test.go b/deploy/rollout_cleanup_installer_test.go index d970604..dc1a102 100644 --- a/deploy/rollout_cleanup_installer_test.go +++ b/deploy/rollout_cleanup_installer_test.go @@ -46,10 +46,10 @@ func TestRolloutCleanupHooksStandaloneLayout(t *testing.T) { for _, name := range []string{ "test-rollout-cleanup-hooks.sh", "update-node.sh", "setup-rollout-cleanup.sh", "aperod-rollout-cleanup.service", "aperod-rollout-cleanup.timer", -"aperod-historical-retirement.service", "aperod-historical-retirement.timer", + "aperod-historical-retirement.service", "aperod-historical-retirement.timer", "rollout_cleanup/__init__.py", "rollout_cleanup/cli.py", -"rollout_cleanup/runtime.py", "rollout_cleanup/provider.py", "rollout_cleanup/retirement.py", - "rollout_cleanup/transition.py", + "rollout_cleanup/runtime.py", "rollout_cleanup/provider.py", "rollout_cleanup/retirement.py", + "rollout_cleanup/transition.py", } { content, err := os.ReadFile(name) if err != nil { diff --git a/deploy/setup-backup.sh b/deploy/setup-backup.sh index 589d8d7..55f7800 100755 --- a/deploy/setup-backup.sh +++ b/deploy/setup-backup.sh @@ -57,7 +57,7 @@ trap cleanup_verify_tmp EXIT if ! ( cd "${BLOCKCHAIN_DIR}" - CGO_ENABLED=0 GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.9+auto \ + CGO_ENABLED=0 GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.10+auto \ go build -o "${VERIFY_TMP}" ./cmd/backup-verify ); then die "Не удалось собрать aperod-backup-verify; существующий бинарный файл не изменён" diff --git a/deploy/test-avm-http-stress.sh b/deploy/test-avm-http-stress.sh new file mode 100644 index 0000000..cd94530 --- /dev/null +++ b/deploy/test-avm-http-stress.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${APEROD_RUN_AVM_STRESS:-0}" != "1" ]]; then + echo "[SKIP] isolated AVM HTTP stress requires APEROD_RUN_AVM_STRESS=1" + echo "SKIP_SUMMARY: AVM HTTP stress requires explicit opt-in" + exit 77 +fi +if [[ "$(uname -s)" != "Linux" ]]; then + echo "[SKIP] HTTP stress resource accounting requires Linux /proc and getrusage" + echo "SKIP_SUMMARY: Linux resource accounting unavailable" + exit 77 +fi +if ! command -v go >/dev/null 2>&1; then + echo "[SKIP] Go toolchain is unavailable" + echo "SKIP_SUMMARY: Go toolchain unavailable" + exit 77 +fi + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "${script_dir}/.." +scratch="$(mktemp -d "${TMPDIR:-/tmp}/aperod-avm-http-stress.XXXXXX")" +trap 'rm -rf -- "${scratch}"' EXIT +APEROD_RUN_AVM_STRESS=1 \ +APEROD_AVM_STRESS_DURATION="${APEROD_AVM_STRESS_DURATION:-2m}" \ +APEROD_AVM_STRESS_WORKERS="${APEROD_AVM_STRESS_WORKERS:-12}" \ +GOMAXPROCS="${GOMAXPROCS:-2}" \ +GOMEMLIMIT="${GOMEMLIMIT:-512MiB}" \ +GOCACHE="${GOCACHE:-${scratch}/go-cache}" \ +TMPDIR="${scratch}" \ +GOSUMDB=sum.golang.org \ +GOTOOLCHAIN=go1.26.10+auto \ +go test -p 1 ./consensus -run '^TestAVMMixedHTTPStress$' -count=1 -v -timeout=8m diff --git a/deploy/test-avm-stress.sh b/deploy/test-avm-stress.sh index 4d7ea6c..34f5462 100755 --- a/deploy/test-avm-stress.sh +++ b/deploy/test-avm-stress.sh @@ -21,5 +21,5 @@ APEROD_RUN_AVM_STRESS=1 \ APEROD_AVM_STRESS_DURATION="${duration}" \ APEROD_AVM_STRESS_WORKERS="${workers}" \ GOSUMDB=sum.golang.org \ -GOTOOLCHAIN=go1.26.9+auto \ +GOTOOLCHAIN=go1.26.10+auto \ go test ./consensus -run '^TestAVMSpamDoesNotDelayBlockProduction$' -count=1 -v -timeout=7m \ No newline at end of file diff --git a/deploy/test-avm-two-validator.sh b/deploy/test-avm-two-validator.sh index b5f570f..8f3490f 100755 --- a/deploy/test-avm-two-validator.sh +++ b/deploy/test-avm-two-validator.sh @@ -13,4 +13,4 @@ fi script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" cd "${script_dir}/.." -GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.9+auto go test ./consensus -run '^TestAVMTwoValidatorEngineIntegration$' -count=1 -v \ No newline at end of file +GOSUMDB=sum.golang.org GOTOOLCHAIN=go1.26.10+auto go test ./consensus -run '^TestAVMTwoValidatorEngineIntegration$' -count=1 -v \ No newline at end of file diff --git a/deploy/test-watchdog-set-interval.sh b/deploy/test-watchdog-set-interval.sh index f456d79..675c87d 100644 --- a/deploy/test-watchdog-set-interval.sh +++ b/deploy/test-watchdog-set-interval.sh @@ -66,89 +66,91 @@ echo "=== aperod-watchdog-set-interval tests ===" # 1. Valid integer — written correctly into the drop-in echo "--- Test 1: valid interval (30 s)" out=$(run_helper "WATCHDOG_INTERVAL_SECS=30") -boot=$(extract_value "OnBootSec" "${out}") -active=$(extract_value "OnUnitActiveSec" "${out}") +boot=$(extract_value "OnActiveSec" "${out}") +active=$(extract_value "OnUnitInactiveSec" "${out}") if [[ "${boot}" == "30" && "${active}" == "30" ]]; then - pass "valid integer 30 → OnBootSec=30, OnUnitActiveSec=30" + pass "valid integer 30 → OnActiveSec=30, OnUnitInactiveSec=30" else - fail "valid integer 30 → got OnBootSec='${boot}', OnUnitActiveSec='${active}'" + fail "valid integer 30 → got OnActiveSec='${boot}', OnUnitInactiveSec='${active}'" fi # 2. Inline comment is stripped (primary documented example) echo "--- Test 2: inline comment stripped (15 # faster detection for HA setups)" out=$(run_helper "WATCHDOG_INTERVAL_SECS=15 # faster detection for HA setups") -boot=$(extract_value "OnBootSec" "${out}") -active=$(extract_value "OnUnitActiveSec" "${out}") +boot=$(extract_value "OnActiveSec" "${out}") +active=$(extract_value "OnUnitInactiveSec" "${out}") if [[ "${boot}" == "15" && "${active}" == "15" ]]; then pass "inline comment stripped → 15" else - fail "inline comment stripped → got OnBootSec='${boot}', OnUnitActiveSec='${active}'" + fail "inline comment stripped → got OnActiveSec='${boot}', OnUnitInactiveSec='${active}'" fi # 3. Missing variable → default 60 echo "--- Test 3: missing WATCHDOG_INTERVAL_SECS → default 60" out=$(run_helper "# no interval set here") -boot=$(extract_value "OnBootSec" "${out}") -active=$(extract_value "OnUnitActiveSec" "${out}") +boot=$(extract_value "OnActiveSec" "${out}") +active=$(extract_value "OnUnitInactiveSec" "${out}") if [[ "${boot}" == "60" && "${active}" == "60" ]]; then pass "missing var → default 60" else - fail "missing var → got OnBootSec='${boot}', OnUnitActiveSec='${active}'" + fail "missing var → got OnActiveSec='${boot}', OnUnitInactiveSec='${active}'" fi # 4. Empty env file → default 60 echo "--- Test 4: empty env file → default 60" out=$(run_helper "") -boot=$(extract_value "OnBootSec" "${out}") -active=$(extract_value "OnUnitActiveSec" "${out}") +boot=$(extract_value "OnActiveSec" "${out}") +active=$(extract_value "OnUnitInactiveSec" "${out}") if [[ "${boot}" == "60" && "${active}" == "60" ]]; then pass "empty env file → default 60" else - fail "empty env file → got OnBootSec='${boot}', OnUnitActiveSec='${active}'" + fail "empty env file → got OnActiveSec='${boot}', OnUnitInactiveSec='${active}'" fi # 5. Value below minimum (< 5) → falls back to 60 echo "--- Test 5: value below minimum (3) → fallback 60" out=$(run_helper "WATCHDOG_INTERVAL_SECS=3") -boot=$(extract_value "OnBootSec" "${out}") -active=$(extract_value "OnUnitActiveSec" "${out}") +boot=$(extract_value "OnActiveSec" "${out}") +active=$(extract_value "OnUnitInactiveSec" "${out}") if [[ "${boot}" == "60" && "${active}" == "60" ]]; then pass "value below minimum → fallback 60" else - fail "value below minimum → got OnBootSec='${boot}', OnUnitActiveSec='${active}'" + fail "value below minimum → got OnActiveSec='${boot}', OnUnitInactiveSec='${active}'" fi # 6. Non-integer value → falls back to 60 echo "--- Test 6: non-integer value ('fast') → fallback 60" out=$(run_helper "WATCHDOG_INTERVAL_SECS=fast") -boot=$(extract_value "OnBootSec" "${out}") -active=$(extract_value "OnUnitActiveSec" "${out}") +boot=$(extract_value "OnActiveSec" "${out}") +active=$(extract_value "OnUnitInactiveSec" "${out}") if [[ "${boot}" == "60" && "${active}" == "60" ]]; then pass "non-integer → fallback 60" else - fail "non-integer → got OnBootSec='${boot}', OnUnitActiveSec='${active}'" + fail "non-integer → got OnActiveSec='${boot}', OnUnitInactiveSec='${active}'" fi # 7. Quoted value → parsed correctly echo "--- Test 7: quoted value (\"45\") → 45" out=$(run_helper 'WATCHDOG_INTERVAL_SECS="45"') -boot=$(extract_value "OnBootSec" "${out}") -active=$(extract_value "OnUnitActiveSec" "${out}") +boot=$(extract_value "OnActiveSec" "${out}") +active=$(extract_value "OnUnitInactiveSec" "${out}") if [[ "${boot}" == "45" && "${active}" == "45" ]]; then pass "quoted value \"45\" → 45" else - fail "quoted value → got OnBootSec='${boot}', OnUnitActiveSec='${active}'" + fail "quoted value → got OnActiveSec='${boot}', OnUnitInactiveSec='${active}'" fi -# 8. Drop-in includes the reset lines (OnBootSec= / OnUnitActiveSec=) before new values +# 8. Drop-in clears old timer values before setting the new active/inactive schedule echo "--- Test 8: drop-in contains reset lines before new values" out=$(run_helper "WATCHDOG_INTERVAL_SECS=20") reset_boot=$(echo "${out}" | grep -c "^OnBootSec=$" || true) +reset_onactive=$(echo "${out}" | grep -c "^OnActiveSec=$" || true) reset_active=$(echo "${out}" | grep -c "^OnUnitActiveSec=$" || true) -if [[ "${reset_boot}" -ge 1 && "${reset_active}" -ge 1 ]]; then - pass "drop-in contains reset lines (OnBootSec=, OnUnitActiveSec=)" +reset_inactive=$(echo "${out}" | grep -c "^OnUnitInactiveSec=$" || true) +if [[ "${reset_boot}" -ge 1 && "${reset_onactive}" -ge 1 && "${reset_active}" -ge 1 && "${reset_inactive}" -ge 1 ]]; then + pass "drop-in clears old timer values before setting the new interval" else - fail "drop-in missing reset lines: reset_boot=${reset_boot}, reset_active=${reset_active}" + fail "drop-in missing reset lines: reset_boot=${reset_boot}, reset_onactive=${reset_onactive}, reset_active=${reset_active}, reset_inactive=${reset_inactive}" fi # 9. systemctl called with daemon-reload and restart (via stub) diff --git a/deploy/test-watchdog.sh b/deploy/test-watchdog.sh index 76123c6..fda5e0e 100755 --- a/deploy/test-watchdog.sh +++ b/deploy/test-watchdog.sh @@ -136,11 +136,14 @@ wait_for_server() { local deadline=$(( $(date +%s) + timeout )) while (( $(date +%s) < deadline )); do if python3 -c " -import urllib.request, sys +import urllib.error, urllib.request, sys try: - urllib.request.urlopen('http://127.0.0.1:$port/', timeout=0.5) + with urllib.request.urlopen('http://127.0.0.1:$port/', timeout=0.5): + pass +except urllib.error.HTTPError: + pass # an HTTP response, including 4xx/5xx, proves the listener is up except Exception: - pass # non-200 raises but the server IS up + sys.exit(1) sys.exit(0) " 2>/dev/null; then return 0 @@ -298,7 +301,7 @@ NODE_API_URL="http://127.0.0.1:${PORT3}" \ SUPPORT_BOT_TOKEN="" \ SUPPORT_ADMIN_CHAT_ID="" \ PATH="$FAKE3:$PATH" \ - bash "$WATCHDOG_SH" >/dev/null 2>&1 + bash "$WATCHDOG_SH" >"$TMPDIR_TEST/t3-output.log" 2>&1 WDEXIT3=$? if [[ $WDEXIT3 -eq 0 ]]; then @@ -308,10 +311,15 @@ else fi if [[ -f "$LOG3" ]] && grep -q "systemctl restart aperod-node" "$LOG3"; then - pass "systemctl was called with 'restart aperod-node' on connection refused" + pass "systemctl restart was called on connection refused" else fail "systemctl was NOT called on connection refused (log: $(cat "$LOG3" 2>/dev/null || echo ''))" fi +if grep -Eq 'returned HTTP 000([[:space:]]|$)' "$TMPDIR_TEST/t3-output.log"; then + pass "connection-refused status is logged as HTTP 000, not a duplicated code" +else + fail "connection-refused status was not logged accurately (output: $(cat "$TMPDIR_TEST/t3-output.log" 2>/dev/null || echo ''))" +fi # ============================================================================= # Test 4: static analysis — NODE_API_URL is env-driven, not hardcoded @@ -548,9 +556,9 @@ STUB } # ============================================================================= -# Test 9: valid integer → correct OnBootSec/OnUnitActiveSec in the drop-in +# Test 9: valid integer → correct OnActiveSec/OnUnitInactiveSec in the drop-in # ============================================================================= -section "Test 9: valid WATCHDOG_INTERVAL_SECS=30 → drop-in contains OnBootSec=30" +section "Test 9: valid WATCHDOG_INTERVAL_SECS=30 → drop-in contains OnActiveSec=30" T9_DIR=$(mktemp -d "$TMPDIR_TEST/t9-XXXXXXXX") T9_ENV="$T9_DIR/watchdog.env" @@ -580,16 +588,16 @@ else fail "drop-in file was NOT created at $T9_DROPIN" fi -if grep -q "^OnBootSec=30$" "$T9_DROPIN" 2>/dev/null; then - pass "drop-in contains OnBootSec=30" +if grep -q "^OnActiveSec=30$" "$T9_DROPIN" 2>/dev/null; then + pass "drop-in contains OnActiveSec=30" else - fail "drop-in does NOT contain OnBootSec=30 (content: $(cat "$T9_DROPIN" 2>/dev/null || echo ''))" + fail "drop-in does NOT contain OnActiveSec=30 (content: $(cat "$T9_DROPIN" 2>/dev/null || echo ''))" fi -if grep -q "^OnUnitActiveSec=30$" "$T9_DROPIN" 2>/dev/null; then - pass "drop-in contains OnUnitActiveSec=30" +if grep -q "^OnUnitInactiveSec=30$" "$T9_DROPIN" 2>/dev/null; then + pass "drop-in contains OnUnitInactiveSec=30" else - fail "drop-in does NOT contain OnUnitActiveSec=30 (content: $(cat "$T9_DROPIN" 2>/dev/null || echo ''))" + fail "drop-in does NOT contain OnUnitInactiveSec=30 (content: $(cat "$T9_DROPIN" 2>/dev/null || echo ''))" fi if grep -q "daemon-reload" "$T9_SC_LOG" 2>/dev/null; then @@ -632,16 +640,16 @@ else fi T10_DROPIN="$T10_DROPIN_DIR/interval.conf" -if grep -q "^OnBootSec=60$" "$T10_DROPIN" 2>/dev/null; then - pass "drop-in defaults to OnBootSec=60 when key is missing" +if grep -q "^OnActiveSec=60$" "$T10_DROPIN" 2>/dev/null; then + pass "drop-in defaults to OnActiveSec=60 when key is missing" else - fail "drop-in does NOT contain OnBootSec=60 (content: $(cat "$T10_DROPIN" 2>/dev/null || echo ''))" + fail "drop-in does NOT contain OnActiveSec=60 (content: $(cat "$T10_DROPIN" 2>/dev/null || echo ''))" fi -if grep -q "^OnUnitActiveSec=60$" "$T10_DROPIN" 2>/dev/null; then - pass "drop-in defaults to OnUnitActiveSec=60 when key is missing" +if grep -q "^OnUnitInactiveSec=60$" "$T10_DROPIN" 2>/dev/null; then + pass "drop-in defaults to OnUnitInactiveSec=60 when key is missing" else - fail "drop-in does NOT contain OnUnitActiveSec=60 (content: $(cat "$T10_DROPIN" 2>/dev/null || echo ''))" + fail "drop-in does NOT contain OnUnitInactiveSec=60 (content: $(cat "$T10_DROPIN" 2>/dev/null || echo ''))" fi # ============================================================================= @@ -678,16 +686,16 @@ else fi T11_DROPIN="$T11_DROPIN_DIR/interval.conf" -if grep -q "^OnBootSec=60$" "$T11_DROPIN" 2>/dev/null; then - pass "drop-in falls back to OnBootSec=60 for value below minimum" +if grep -q "^OnActiveSec=60$" "$T11_DROPIN" 2>/dev/null; then + pass "drop-in falls back to OnActiveSec=60 for value below minimum" else fail "drop-in does NOT fall back to 60 for value below minimum (content: $(cat "$T11_DROPIN" 2>/dev/null || echo ''))" fi -if grep -q "^OnUnitActiveSec=60$" "$T11_DROPIN" 2>/dev/null; then - pass "drop-in falls back to OnUnitActiveSec=60 for value below minimum" +if grep -q "^OnUnitInactiveSec=60$" "$T11_DROPIN" 2>/dev/null; then + pass "drop-in falls back to OnUnitInactiveSec=60 for value below minimum" else - fail "drop-in does NOT fall back to 60 for OnUnitActiveSec (content: $(cat "$T11_DROPIN" 2>/dev/null || echo ''))" + fail "drop-in does NOT fall back to 60 for OnUnitInactiveSec (content: $(cat "$T11_DROPIN" 2>/dev/null || echo ''))" fi # ============================================================================= @@ -724,16 +732,16 @@ else fi T12_DROPIN="$T12_DROPIN_DIR/interval.conf" -if grep -q "^OnBootSec=60$" "$T12_DROPIN" 2>/dev/null; then - pass "drop-in falls back to OnBootSec=60 for non-integer" +if grep -q "^OnActiveSec=60$" "$T12_DROPIN" 2>/dev/null; then + pass "drop-in falls back to OnActiveSec=60 for non-integer" else fail "drop-in does NOT fall back to 60 for non-integer (content: $(cat "$T12_DROPIN" 2>/dev/null || echo ''))" fi -if grep -q "^OnUnitActiveSec=60$" "$T12_DROPIN" 2>/dev/null; then - pass "drop-in falls back to OnUnitActiveSec=60 for non-integer" +if grep -q "^OnUnitInactiveSec=60$" "$T12_DROPIN" 2>/dev/null; then + pass "drop-in falls back to OnUnitInactiveSec=60 for non-integer" else - fail "drop-in does NOT fall back to 60 for OnUnitActiveSec (non-integer) (content: $(cat "$T12_DROPIN" 2>/dev/null || echo ''))" + fail "drop-in does NOT fall back to 60 for OnUnitInactiveSec (non-integer) (content: $(cat "$T12_DROPIN" 2>/dev/null || echo ''))" fi fi # end of set-interval tests block (script exists guard) @@ -1970,6 +1978,153 @@ else fail "malformed active-enter timestamp did not fail closed (exit=$T37_EXIT, systemctl=$(cat "$T37_SC_LOG" 2>/dev/null || echo ''), output=$(cat "$T37_OUTPUT" 2>/dev/null || echo ''))" fi +# ============================================================================= +# Test 38: real API stop during a slow graceful snapshot, followed by recovery +# ============================================================================= +section "Test 38: stopped API during slow snapshot does not restart node; recovery remains healthy" + +T38_DIR=$(mktemp -d "$TMPDIR_TEST/t38-XXXXXXXX") +T38_NODE_DATA_DIR="$T38_DIR/node-data" +T38_STATE_DIR="$T38_DIR/watchdog-state" +T38_SC_LOG="$T38_DIR/systemctl.log" +T38_OUTPUT="$T38_DIR/output.log" +mkdir -p "$T38_NODE_DATA_DIR" "$T38_STATE_DIR" + +T38_SC=$(mktemp -d "$TMPDIR_TEST/t38-sc-XXXXXXXX") +cat >"$T38_SC/systemctl" <> "$T38_SC_LOG" +STUB +chmod +x "$T38_SC/systemctl" + +T38_PORT=$(find_free_port) +T38_API_URL="http://127.0.0.1:${T38_PORT}" +T38_SRV_PID=$(start_json_server "$T38_PORT" 200 '{"height":900,"syncing":false,"peer_count":1}') +if wait_for_server "$T38_PORT" 5; then + pass "real local HTTP API is healthy before graceful shutdown" +else + fail "real local HTTP API did not start before graceful shutdown" +fi + +NODE_API_URL="$T38_API_URL" \ + STATE_DIR="$T38_STATE_DIR" \ + NODE_DATA_DIR="$T38_NODE_DATA_DIR" \ + TIMEOUT_SECS="1" \ + RAM_THRESHOLD_MB="0" \ + DISK_WARN_PCT="0" \ + STALL_CHECKS_MAX="0" \ + PEER_WAIT_MINS="0" \ + SUPPORT_BOT_TOKEN="" \ + SUPPORT_ADMIN_CHAT_ID="" \ + PATH="$T38_SC:$PATH" \ + bash "$WATCHDOG_SH" >"$T38_OUTPUT" 2>&1 +T38_HEALTHY_EXIT=$? + +# Keep a live process with the same PID/start-time marker while the HTTP API is +# stopped. This models the node's SIGTERM handler remaining alive during a slow +# snapshot save; the watchdog performs multiple real HTTP probes in that window. +sleep 30 & +T38_NODE_PID=$! +T38_START_ID=$(sed 's/^.*) //' "/proc/$T38_NODE_PID/stat" | awk '{print $20}') +printf '%s %s %s\n' "$T38_NODE_PID" "$T38_START_ID" "$(date +%s)" \ + >"$T38_NODE_DATA_DIR/shutdown-intent" + +kill "$T38_SRV_PID" 2>/dev/null || true +wait "$T38_SRV_PID" 2>/dev/null || true +T38_SRV_PID="" +T38_DOWN_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \ + "$T38_API_URL/api/v1/status" 2>/dev/null || true) +if [[ "$T38_DOWN_CODE" == "000" ]]; then + pass "real API listener is confirmed stopped during the simulated snapshot" +else + fail "API listener did not stop (curl status: ${T38_DOWN_CODE:-empty})" +fi + +for T38_CHECK in 1 2; do + NODE_API_URL="$T38_API_URL" \ + STATE_DIR="$T38_STATE_DIR" \ + NODE_DATA_DIR="$T38_NODE_DATA_DIR" \ + TIMEOUT_SECS="1" \ + RAM_THRESHOLD_MB="0" \ + DISK_WARN_PCT="0" \ + STALL_CHECKS_MAX="0" \ + PEER_WAIT_MINS="0" \ + SUPPORT_BOT_TOKEN="" \ + SUPPORT_ADMIN_CHAT_ID="" \ + PATH="$T38_SC:$PATH" \ + bash "$WATCHDOG_SH" >>"$T38_OUTPUT" 2>&1 + T38_CHECK_EXIT=$? + if [[ $T38_CHECK_EXIT -ne 0 ]]; then + fail "watchdog failed during shutdown probe $T38_CHECK (exit=$T38_CHECK_EXIT)" + fi + sleep 2 +done + +if [[ ! -s "$T38_SC_LOG" ]] && + [[ $(grep -Fc 'valid graceful-shutdown intent' "$T38_OUTPUT" 2>/dev/null || echo 0) -ge 2 ]] && + [[ ! -e "$T38_STATE_DIR/watchdog-last-alert" ]]; then + pass "slow snapshot window with a stopped API produced no restart or outage alert" +else + fail "watchdog acted on the expected shutdown outage (systemctl=$(cat "$T38_SC_LOG" 2>/dev/null || echo ''), output=$(cat "$T38_OUTPUT" 2>/dev/null || echo ''))" +fi + +kill "$T38_NODE_PID" 2>/dev/null || true +wait "$T38_NODE_PID" 2>/dev/null || true +rm -f "$T38_NODE_DATA_DIR/shutdown-intent" + +T38_SRV_PID=$(start_json_server "$T38_PORT" 200 '{"height":900,"syncing":false,"peer_count":1}') +if wait_for_server "$T38_PORT" 5; then + pass "real local HTTP API recovered after the snapshot window" +else + fail "real local HTTP API did not recover after the snapshot window" +fi +T38_RECOVERY_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \ + "$T38_API_URL/api/v1/status" 2>/dev/null || true) +if [[ "$T38_RECOVERY_CODE" == "200" ]]; then + pass "recovered API returns HTTP 200" +else + fail "recovered API returned HTTP ${T38_RECOVERY_CODE:-empty}, expected 200" +fi + +NODE_API_URL="$T38_API_URL" \ + STATE_DIR="$T38_STATE_DIR" \ + NODE_DATA_DIR="$T38_NODE_DATA_DIR" \ + TIMEOUT_SECS="1" \ + RAM_THRESHOLD_MB="0" \ + DISK_WARN_PCT="0" \ + STALL_CHECKS_MAX="0" \ + PEER_WAIT_MINS="0" \ + SUPPORT_BOT_TOKEN="" \ + SUPPORT_ADMIN_CHAT_ID="" \ + PATH="$T38_SC:$PATH" \ + bash "$WATCHDOG_SH" >>"$T38_OUTPUT" 2>&1 +T38_RECOVERY_EXIT=$? +kill "$T38_SRV_PID" 2>/dev/null || true +wait "$T38_SRV_PID" 2>/dev/null || true +T38_SRV_PID="" + +if [[ $T38_HEALTHY_EXIT -eq 0 && $T38_RECOVERY_EXIT -eq 0 && + ! -s "$T38_SC_LOG" && ! -e "$T38_STATE_DIR/watchdog-last-alert" && + ! -e "$T38_NODE_DATA_DIR/shutdown-intent" ]]; then + pass "healthy-before and recovered-after API checks did not schedule a restart" +else + fail "API recovery check changed watchdog state unexpectedly (systemctl=$(cat "$T38_SC_LOG" 2>/dev/null || echo ''), output=$(cat "$T38_OUTPUT" 2>/dev/null || echo ''))" +fi +kill "$T38_NODE_PID" 2>/dev/null || true +wait "$T38_NODE_PID" 2>/dev/null || true + # ============================================================================= # Summary # ============================================================================= diff --git a/go.mod b/go.mod index 24ccac0..e9395ed 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/aperod/aperod -go 1.26.9 +go 1.26.10 require ( filippo.io/edwards25519 v1.1.1 diff --git a/lpod/accounting.go b/lpod/accounting.go index 6b987fc..18d6eeb 100644 --- a/lpod/accounting.go +++ b/lpod/accounting.go @@ -40,8 +40,10 @@ func TierFor(total uint64) (Tier, error) { if total < tiers[0].Threshold || total > tiers[len(tiers)-1].Threshold { return Tier{}, fmt.Errorf("lpod: vault stake outside 100K–100M APRO") } - for i := len(tiers)-1; i >= 0; i-- { - if total >= tiers[i].Threshold { return tiers[i], nil } + for i := len(tiers) - 1; i >= 0; i-- { + if total >= tiers[i].Threshold { + return tiers[i], nil + } } panic("unreachable") } @@ -51,46 +53,48 @@ func TierFor(total uint64) (Tier, error) { // FundingDebit may only originate from a future audited available-allocation // ledger debit, never validator reserves, arbitrary wallets, or issuance. type State struct { - FundingDebit uint64 `json:"funding_debit_napro,string"` - Balance uint64 `json:"balance_napro,string"` - RewardInflow uint64 `json:"reward_inflow_napro,string"` - LeaderPaid uint64 `json:"leader_paid_napro,string"` - AngelPaid uint64 `json:"angel_paid_napro,string"` - SurplusInflow uint64 `json:"surplus_inflow_napro,string"` - DeficitOutflow uint64 `json:"deficit_outflow_napro,string"` + FundingDebit uint64 `json:"funding_debit_napro,string"` + Balance uint64 `json:"balance_napro,string"` + RewardInflow uint64 `json:"reward_inflow_napro,string"` + LeaderPaid uint64 `json:"leader_paid_napro,string"` + AngelPaid uint64 `json:"angel_paid_napro,string"` + SurplusInflow uint64 `json:"surplus_inflow_napro,string"` + DeficitOutflow uint64 `json:"deficit_outflow_napro,string"` UnfundedLiability uint64 `json:"unfunded_liability_napro,string"` - AccruedLiability uint64 `json:"accrued_liability_napro,string"` - LastHeight uint64 `json:"last_settled_height"` + AccruedLiability uint64 `json:"accrued_liability_napro,string"` + LastHeight uint64 `json:"last_settled_height"` } // Carry belongs to a stable vault/principal accounting stream and must survive // tier changes, restart and rollback. Do not reset it at a block or year boundary. // A future depositor ledger must apportion funded AngelPaid to actual outputs. type Carry struct { - APR uint64 `json:"apr_remainder,string"` + APR uint64 `json:"apr_remainder,string"` Leader uint64 `json:"leader_remainder,string"` } type Vault struct { - ID string `json:"id"` - TotalStake uint64 `json:"total_stake_napro,string"` - GuardianStake uint64 `json:"guardian_stake_napro,string"` - ActualIncome uint64 `json:"actual_income_napro,string"` - ElapsedSeconds uint64 `json:"elapsed_seconds"` -ExactAccrual *uint64 `json:"exact_accrual_napro,omitempty"` -Arrears uint64 `json:"arrears_napro,string"` + ID string `json:"id"` + TotalStake uint64 `json:"total_stake_napro,string"` + GuardianStake uint64 `json:"guardian_stake_napro,string"` + ActualIncome uint64 `json:"actual_income_napro,string"` + ElapsedSeconds uint64 `json:"elapsed_seconds"` + ExactAccrual *uint64 `json:"exact_accrual_napro,omitempty"` + Arrears uint64 `json:"arrears_napro,string"` } type Payment struct { - VaultID string `json:"vault_id"` - Leader uint64 `json:"leader_napro,string"` - Angels uint64 `json:"angels_napro,string"` + VaultID string `json:"vault_id"` + Leader uint64 `json:"leader_napro,string"` + Angels uint64 `json:"angels_napro,string"` Unfunded uint64 `json:"unfunded_napro,string"` } func add(a, b uint64) (uint64, error) { - if math.MaxUint64-a < b { return 0, fmt.Errorf("lpod: uint64 overflow") } - return a+b, nil + if math.MaxUint64-a < b { + return 0, fmt.Errorf("lpod: uint64 overflow") + } + return a + b, nil } func rational(a, b, c, carry, divisor uint64) (uint64, uint64, error) { @@ -100,28 +104,46 @@ func rational(a, b, c, carry, divisor uint64) (uint64, uint64, error) { n.Add(n, new(big.Int).SetUint64(carry)) q, r := new(big.Int), new(big.Int) q.QuoRem(n, new(big.Int).SetUint64(divisor), r) - if !q.IsUint64() { return 0, 0, fmt.Errorf("lpod: rational result overflow") } + if !q.IsUint64() { + return 0, 0, fmt.Errorf("lpod: rational result overflow") + } return q.Uint64(), r.Uint64(), nil } // Validate enforces conservation, not funding authority or canonical finality. func (s State) Validate() error { - if s.FundingDebit != InitialNAPRO { return fmt.Errorf("lpod: missing conserved 1B allocation debit") } + if s.FundingDebit != InitialNAPRO { + return fmt.Errorf("lpod: missing conserved 1B allocation debit") + } left, err := add(s.FundingDebit, s.RewardInflow) - if err != nil { return err } + if err != nil { + return err + } right, err := add(s.Balance, s.LeaderPaid) - if err != nil { return err } + if err != nil { + return err + } right, err = add(right, s.AngelPaid) - if err != nil { return err } - if left != right { return fmt.Errorf("lpod: income conservation violated") } + if err != nil { + return err + } + if left != right { + return fmt.Errorf("lpod: income conservation violated") + } available, err := add(s.FundingDebit, s.SurplusInflow) - if err != nil { return err } + if err != nil { + return err + } if available < s.DeficitOutflow || available-s.DeficitOutflow != s.Balance { return fmt.Errorf("lpod: reserve conservation violated") } liability, err := add(s.AngelPaid, s.UnfundedLiability) - if err != nil { return err } - if liability != s.AccruedLiability { return fmt.Errorf("lpod: liability conservation violated") } + if err != nil { + return err + } + if liability != s.AccruedLiability { + return fmt.Errorf("lpod: liability conservation violated") + } return nil } @@ -132,62 +154,94 @@ func (s State) Validate() error { // This is a payout PLAN, never proof that payout outputs were actually committed. func Settle(before State, carries map[string]Carry, height uint64, vaults []Vault) (State, map[string]Carry, []Payment, error) { fail := func(err error) (State, map[string]Carry, []Payment, error) { return before, nil, nil, err } - if err := before.Validate(); err != nil { return fail(err) } + if err := before.Validate(); err != nil { + return fail(err) + } if before.LastHeight == math.MaxUint64 || height != before.LastHeight+1 { return fail(fmt.Errorf("lpod: duplicate or nonsequential settlement")) } next := before out := make(map[string]Carry, len(carries)) for id, c := range carries { - if id == "" || c.APR >= denominator || c.Leader >= 100 { return fail(fmt.Errorf("lpod: invalid carry")) } + if id == "" || c.APR >= denominator || c.Leader >= 100 { + return fail(fmt.Errorf("lpod: invalid carry")) + } out[id] = c } payments := make([]Payment, 0, len(vaults)) for i, v := range vaults { - if v.ID == "" || (i > 0 && vaults[i-1].ID >= v.ID) { return fail(fmt.Errorf("lpod: vault IDs must be sorted and unique")) } + if v.ID == "" || (i > 0 && vaults[i-1].ID >= v.ID) { + return fail(fmt.Errorf("lpod: vault IDs must be sorted and unique")) + } t, err := TierFor(v.TotalStake) - if err != nil { return fail(err) } - if v.GuardianStake > v.TotalStake { return fail(fmt.Errorf("lpod: guardian stake exceeds vault stake")) } + if err != nil { + return fail(err) + } + if v.GuardianStake > v.TotalStake { + return fail(fmt.Errorf("lpod: guardian stake exceeds vault stake")) + } c := out[v.ID] leader, rem, err := rational(v.ActualIncome, t.LeaderPercent, 1, c.Leader, 100) - if err != nil { return fail(err) } + if err != nil { + return fail(err) + } c.Leader = rem due, rem, err := rational(v.GuardianStake, t.APRPercent, v.ElapsedSeconds, c.APR, denominator) - if err != nil { return fail(err) } -newDue:=due -if v.ExactAccrual!=nil {newDue=*v.ExactAccrual;rem=c.APR} -if v.Arrears>next.UnfundedLiability {return fail(fmt.Errorf("lpod: arrears exceed outstanding liability"))} -next.UnfundedLiability-=v.Arrears -due,err=add(newDue,v.Arrears) -if err!=nil{return fail(err)} + if err != nil { + return fail(err) + } + newDue := due + if v.ExactAccrual != nil { + newDue = *v.ExactAccrual + rem = c.APR + } + if v.Arrears > next.UnfundedLiability { + return fail(fmt.Errorf("lpod: arrears exceed outstanding liability")) + } + next.UnfundedLiability -= v.Arrears + due, err = add(newDue, v.Arrears) + if err != nil { + return fail(err) + } c.APR = rem - residual := v.ActualIncome-leader + residual := v.ActualIncome - leader paid, missing := due, uint64(0) surplus, deficit := uint64(0), uint64(0) if residual >= due { - surplus = residual-due + surplus = residual - due next.Balance, err = add(next.Balance, surplus) - if err != nil { return fail(err) } + if err != nil { + return fail(err) + } } else { - deficit = due-residual - if deficit > next.Balance { deficit = next.Balance } + deficit = due - residual + if deficit > next.Balance { + deficit = next.Balance + } next.Balance -= deficit - paid = residual+deficit - missing = due-paid + paid = residual + deficit + missing = due - paid } - for _, inc := range []struct{ dst *uint64; amount uint64 }{ + for _, inc := range []struct { + dst *uint64 + amount uint64 + }{ {&next.RewardInflow, v.ActualIncome}, {&next.LeaderPaid, leader}, {&next.AngelPaid, paid}, {&next.UnfundedLiability, missing}, -{&next.AccruedLiability, newDue}, {&next.SurplusInflow, surplus}, + {&next.AccruedLiability, newDue}, {&next.SurplusInflow, surplus}, {&next.DeficitOutflow, deficit}, } { *inc.dst, err = add(*inc.dst, inc.amount) - if err != nil { return fail(err) } + if err != nil { + return fail(err) + } } out[v.ID] = c payments = append(payments, Payment{v.ID, leader, paid, missing}) } next.LastHeight = height - if err := next.Validate(); err != nil { return fail(err) } + if err := next.Validate(); err != nil { + return fail(err) + } return next, out, payments, nil -} \ No newline at end of file +} diff --git a/lpod/accounting_test.go b/lpod/accounting_test.go index 6aad9e5..257266c 100644 --- a/lpod/accounting_test.go +++ b/lpod/accounting_test.go @@ -16,69 +16,119 @@ func fundedFixture() State { return State{FundingDebit: InitialNAPRO, Balance: I func TestTierBoundaries(t *testing.T) { for i, tier := range tiers { got, err := TierFor(tier.Threshold) - if err != nil || got != tier { t.Fatalf("tier %d: %v %v", i, got, err) } - got, err = TierFor(tier.Threshold-1) - if i == 0 { if err == nil { t.Fatal("inactive vault accepted") }; continue } - if err != nil || got != tiers[i-1] { t.Fatalf("below tier %d: %v %v", i, got, err) } + if err != nil || got != tier { + t.Fatalf("tier %d: %v %v", i, got, err) + } + got, err = TierFor(tier.Threshold - 1) + if i == 0 { + if err == nil { + t.Fatal("inactive vault accepted") + } + continue + } + if err != nil || got != tiers[i-1] { + t.Fatalf("below tier %d: %v %v", i, got, err) + } + } + if _, err := TierFor(100_000_000*Unit + 1); err == nil { + t.Fatal("capacity exceeded") } - if _, err := TierFor(100_000_000*Unit+1); err == nil { t.Fatal("capacity exceeded") } } func TestConservationSurplusDeficitExhaustion(t *testing.T) { s := fundedFixture() - v := Vault{ID:"a", TotalStake:100_000_000*Unit, GuardianStake:90_000_000*Unit, ActualIncome:20_000_000*Unit, ElapsedSeconds:YearSeconds} + v := Vault{ID: "a", TotalStake: 100_000_000 * Unit, GuardianStake: 90_000_000 * Unit, ActualIncome: 20_000_000 * Unit, ElapsedSeconds: YearSeconds} next, c, p, err := Settle(s, nil, 1, []Vault{v}) - if err != nil { t.Fatal(err) } - if p[0].Leader != 4_000_000*Unit || p[0].Angels != 9_000_000*Unit || next.SurplusInflow != 7_000_000*Unit { t.Fatalf("%+v %+v", next, p) } + if err != nil { + t.Fatal(err) + } + if p[0].Leader != 4_000_000*Unit || p[0].Angels != 9_000_000*Unit || next.SurplusInflow != 7_000_000*Unit { + t.Fatalf("%+v %+v", next, p) + } v.ActualIncome = 0 - v.ElapsedSeconds = YearSeconds*200 + v.ElapsedSeconds = YearSeconds * 200 end, _, payments, err := Settle(next, c, 2, []Vault{v}) - if err != nil { t.Fatal(err) } - if end.Balance != 0 || end.UnfundedLiability == 0 || payments[0].Angels != next.Balance { t.Fatalf("insolvent payout: %+v", end) } - if err := end.Validate(); err != nil { t.Fatal(err) } - if s != fundedFixture() { t.Fatal("input mutated") } + if err != nil { + t.Fatal(err) + } + if end.Balance != 0 || end.UnfundedLiability == 0 || payments[0].Angels != next.Balance { + t.Fatalf("insolvent payout: %+v", end) + } + if err := end.Validate(); err != nil { + t.Fatal(err) + } + if s != fundedFixture() { + t.Fatal("input mutated") + } } func TestCarryRestartRollbackAndPartition(t *testing.T) { s := fundedFixture() - v := Vault{ID:"a", TotalStake:100_000*Unit, GuardianStake:101*Unit+1, ActualIncome:1, ElapsedSeconds:1} + v := Vault{ID: "a", TotalStake: 100_000 * Unit, GuardianStake: 101*Unit + 1, ActualIncome: 1, ElapsedSeconds: 1} start := s var carry map[string]Carry for height := uint64(1); height <= 100; height++ { var err error s, carry, _, err = Settle(s, carry, height, []Vault{v}) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } // Simulate exact checkpoint restoration including all fractional carry. - data, err := json.Marshal(struct{ S State; C map[string]Carry }{s, carry}) - if err != nil { t.Fatal(err) } - var restored struct{ S State; C map[string]Carry } - if err := json.Unmarshal(data, &restored); err != nil { t.Fatal(err) } + data, err := json.Marshal(struct { + S State + C map[string]Carry + }{s, carry}) + if err != nil { + t.Fatal(err) + } + var restored struct { + S State + C map[string]Carry + } + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } s, carry = restored.S, restored.C } v.ActualIncome, v.ElapsedSeconds = 100, 100 once, onceCarry, _, err := Settle(start, nil, 1, []Vault{v}) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } once.LastHeight = s.LastHeight - if once != s || !reflect.DeepEqual(carry, onceCarry) { t.Fatalf("partition lost precision: %+v %+v", once, s) } + if once != s || !reflect.DeepEqual(carry, onceCarry) { + t.Fatalf("partition lost precision: %+v %+v", once, s) + } // Rolling back to start and replaying gives the identical checkpoint. replay, _, _, err := Settle(start, nil, 1, []Vault{v}) once.LastHeight = 1 - if err != nil || replay != once { t.Fatal("rollback/replay mismatch") } + if err != nil || replay != once { + t.Fatal("rollback/replay mismatch") + } } func TestRejectionIsAtomic(t *testing.T) { - v := Vault{ID:"a", TotalStake:100_000*Unit, GuardianStake:100*Unit, ElapsedSeconds:1} + v := Vault{ID: "a", TotalStake: 100_000 * Unit, GuardianStake: 100 * Unit, ElapsedSeconds: 1} s := fundedFixture() - tests := []struct{ name string; s State; height uint64; v []Vault }{ + tests := []struct { + name string + s State + height uint64 + v []Vault + }{ {"unfunded", State{}, 1, []Vault{v}}, {"duplicate-height", s, 0, []Vault{v}}, {"gap", s, 2, []Vault{v}}, - {"duplicate-vault", s, 1, []Vault{v,v}}, - {"overflow", s, 1, []Vault{{ID:"a", TotalStake:100_000_000*Unit, GuardianStake:100_000_000*Unit, ElapsedSeconds:math.MaxUint64}}}, - {"income-overflow", s, 1, []Vault{{ID:"a", TotalStake:100_000*Unit, ActualIncome:math.MaxUint64}}}, + {"duplicate-vault", s, 1, []Vault{v, v}}, + {"overflow", s, 1, []Vault{{ID: "a", TotalStake: 100_000_000 * Unit, GuardianStake: 100_000_000 * Unit, ElapsedSeconds: math.MaxUint64}}}, + {"income-overflow", s, 1, []Vault{{ID: "a", TotalStake: 100_000 * Unit, ActualIncome: math.MaxUint64}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, c, p, err := Settle(tc.s, nil, tc.height, tc.v) + if err == nil || got != tc.s || c != nil || p != nil { + t.Fatalf("failure not atomic: %v", err) + } + }) } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got, c, p, err := Settle(tc.s, nil, tc.height, tc.v) - if err == nil || got != tc.s || c != nil || p != nil { t.Fatalf("failure not atomic: %v", err) } - }) } -} \ No newline at end of file +} diff --git a/node/node.go b/node/node.go index f7d8538..8fd53dc 100644 --- a/node/node.go +++ b/node/node.go @@ -35,19 +35,19 @@ type Node struct { // Config holds all node configuration. type Config struct { - DataDir string - Network string - LogLevel string - GenesisFile string - ValidatorKeyFile string - P2PAddr string - Bootnodes []string - MaxPeers int - MinPeers int - BlockTime time.Duration - APIAddr string - APIEnabled bool -StakedCollateralRingExclusionActivationHeight uint64 + DataDir string + Network string + LogLevel string + GenesisFile string + ValidatorKeyFile string + P2PAddr string + Bootnodes []string + MaxPeers int + MinPeers int + BlockTime time.Duration + APIAddr string + APIEnabled bool + StakedCollateralRingExclusionActivationHeight uint64 } // New creates a fully initialized (but not yet started) node. @@ -67,9 +67,9 @@ func New(cfg *Config, log *slog.Logger) (*Node, error) { utxos.OnUTXODeleted = func(txHash crypto.Hash32, outIdx uint32) error { return db.DeleteUTXO(txHash, outIdx) } -mempoolCfg := core.DefaultMempoolConfig() -mempoolCfg.CurrentHeight = chain.Height -mempool := core.NewMempool(mempoolCfg) + mempoolCfg := core.DefaultMempoolConfig() + mempoolCfg.CurrentHeight = chain.Height + mempool := core.NewMempool(mempoolCfg) // ── Genesis ─────────────────────────────────────────────────────────────── genesisCfg, err := core.LoadGenesis(cfg.GenesisFile) @@ -126,7 +126,7 @@ mempool := core.NewMempool(mempoolCfg) // ── Block/Tx verifiers ──────────────────────────────────────────────────── txV := core.NewTxVerifier(utxos) -txV.SetStakedCollateralRingExclusionActivationHeight(cfg.StakedCollateralRingExclusionActivationHeight) + txV.SetStakedCollateralRingExclusionActivationHeight(cfg.StakedCollateralRingExclusionActivationHeight) // Wire vesting enforcement using the actual genesis block timestamp. // chain.Genesis() is always non-nil here because both fresh-start and resume // paths above call chain.SetGenesis before this point. @@ -354,6 +354,15 @@ func persistBlockToDB(db *store.DB, block *core.Block) error { return fmt.Errorf("mark key image spent: %w", err) } } + if tx.IsStake() && len(tx.Extra) == core.StakePayloadSizeV4 { + _, _, _, _, _, _, _, keyImage, _, decodeErr := core.DecodeStakeExtraV4(tx.Extra) + if decodeErr != nil { + return fmt.Errorf("decode stake key image: %w", decodeErr) + } + if err := db.MarkKeyImageSpent(keyImage); err != nil { + return fmt.Errorf("mark stake key image spent: %w", err) + } + } for i, out := range tx.Outputs { su := &store.StoredUTXO{ TxHash: txHash, diff --git a/p2p/badblock_ban_test.go b/p2p/badblock_ban_test.go index 49c7cf1..702a87e 100644 --- a/p2p/badblock_ban_test.go +++ b/p2p/badblock_ban_test.go @@ -404,7 +404,7 @@ func TestBadBlockBan_ValidBlockResetsCounter(t *testing.T) { } } -// ─── Test: custom BadBlockBanThreshold is honoured ─────────────────────────── +// ─── Test: custom BadBlockBanThreshold is honored ─────────────────────────── // TestBadBlockBan_CustomThreshold configures a ban threshold of 3 (not the // default 10) and verifies the peer is banned after exactly 3 bad blocks. @@ -456,7 +456,7 @@ func TestBadBlockBan_CustomThreshold(t *testing.T) { } } -// ─── Test: custom BadBlockHeightLead is honoured ───────────────────────────── +// ─── Test: custom BadBlockHeightLead is honored ───────────────────────────── // TestBadBlockBan_CustomHeightLead configures a height lead of 50 (not the // default 1000) and verifies that a block at ourTip+51 triggers a strike. @@ -510,7 +510,7 @@ func TestBadBlockBan_CustomHeightLead(t *testing.T) { } } -// ─── Test: custom BadBlockBanDuration is honoured ──────────────────────────── +// ─── Test: custom BadBlockBanDuration is honored ──────────────────────────── // TestBadBlockBan_CustomBanDuration verifies that the ban's expiry time // reflects the configured duration (5 minutes here) rather than the 24h default. @@ -1959,7 +1959,7 @@ func TestBadBlockBan_TLSAuthenticatedPeer(t *testing.T) { if err != nil { // Host closed the connection before or during the TLS handshake — the // strongest form of rejection: ban gate fires before TLS records flow. - // This is the production behaviour (ban check in handleConn precedes + // This is the production behavior (ban check in handleConn precedes // the eager TLS Handshake() call). t.Logf("step 5 ✓ TLS reconnect rejected at/before TLS handshake (expected): %v", err) time.Sleep(50 * time.Millisecond) @@ -2509,7 +2509,7 @@ func TestConcurrentHandshakeFlood(t *testing.T) { // concurrentDial launches n goroutines that all dial hostAddr at the same // instant. Each goroutine signals readyCh when it is blocked at the start - // gate, then waits for startCh to be closed before dialling. The caller + // gate, then waits for startCh to be closed before dialing. The caller // must drain readyCh (n times) and then close startCh. // Returns a slice of n net.Conn values; nil entries mean the dial failed. concurrentDial := func(n int, mustSucceed bool) []net.Conn { @@ -2577,7 +2577,7 @@ func TestConcurrentHandshakeFlood(t *testing.T) { // ── Phase 2: send excess connections simultaneously ─────────────────────── // All extraFlood goroutines are released from the same gate so they race - // through acceptLoop concurrently, maximising the chance of a double-count + // through acceptLoop concurrently, maximizing the chance of a double-count // window in the atomic Add/compare. // // Each excess connection MUST have been accepted at the TCP level (dial must @@ -3070,7 +3070,7 @@ func TestBanSidecar_ConcurrentWritesNeverPartiallyVisible(t *testing.T) { } else { readerReads.Add(1) } - // Tight polling: no sleep, to maximise the chance of catching a + // Tight polling: no sleep, to maximize the chance of catching a // partial write between the truncate and re-write steps that a // non-atomic implementation would expose. runtime_gosched() @@ -3125,7 +3125,7 @@ func TestBanSidecar_ConcurrentWritesNeverPartiallyVisible(t *testing.T) { // Defined as a function (not a direct call) to keep the import confined to the // helper and avoid adding "runtime" to the top-level import block unnecessarily. func runtime_gosched() { - // Intentional tight loop without sleep: we want to maximise read frequency + // Intentional tight loop without sleep: we want to maximize read frequency // to stress the atomicity of the sidecar write. A brief yield prevents // the goroutine from starving the writers on single-CPU builds. // @@ -3220,7 +3220,7 @@ func TestWhitelistSidecar_NullJSONBlocksStartup(t *testing.T) { // CurrentHeight + BadBlockHeightLead, driving the strike counter through // the real dispatch path up to BadBlockBanThreshold. // 2. Below the threshold: no ban, no BanEvent, and HostCanDial still allows -// dialling the rogue IP (proves no premature ban). +// dialing the rogue IP (proves no premature ban). // 3. At the threshold: a BanEvent is recorded via GetBanEvents AND // HostCanDial returns false for that IP — both for the bare IP and for // the IP:port form (PeerMgr.IsBanned falls back to the bare-IP key). diff --git a/p2p/ban_reconnect_test.go b/p2p/ban_reconnect_test.go index e860c1a..f006d5c 100644 --- a/p2p/ban_reconnect_test.go +++ b/p2p/ban_reconnect_test.go @@ -84,8 +84,8 @@ func TestBanDial_PostConnect_BanDropsConnection(t *testing.T) { // (no context-cancellation check on return) so the raw TCP connection // always reaches dialPeer regardless of whether BanPeer cancels the context. p2p.SetDialFunc(h, func(ctx context.Context, network, addr string) (net.Conn, error) { - // Dial without honouring ctx so that the conn is returned even if - // BanPeer has already cancelled the dial context. + // Dial without honoring ctx so that the conn is returned even if + // BanPeer has already canceled the dial context. return net.Dial(network, addr) }) @@ -228,7 +228,7 @@ func TestBanDial_ConcurrentBan_KnownPeer(t *testing.T) { // Block until either BanPeer cancels us or the test times out. select { case <-ctx.Done(): - // BanPeer cancelled: return without connecting. + // BanPeer canceled: return without connecting. return nil, ctx.Err() case <-time.After(5 * time.Second): // Safety: avoid hanging the test forever if cancellation breaks. @@ -254,13 +254,13 @@ func TestBanDial_ConcurrentBan_KnownPeer(t *testing.T) { // context.Canceled, and (c) prevent the TCP connect from reaching ln. h.BanPeer(targetAddr, "concurrent ban test", 24*time.Hour) - // Allow time for the cancelled dial goroutine to finish. + // Allow time for the canceled dial goroutine to finish. time.Sleep(100 * time.Millisecond) if n := dialCount.Load(); n != 0 { - t.Errorf("listener received %d TCP connection(s) after BanPeer cancelled the in-flight dial — dial-gate race not closed", n) + t.Errorf("listener received %d TCP connection(s) after BanPeer canceled the in-flight dial — dial-gate race not closed", n) } - t.Logf("✓ concurrent ban cancelled in-flight dial to %s before TCP connect (dialCount=%d)", targetAddr, dialCount.Load()) + t.Logf("✓ concurrent ban canceled in-flight dial to %s before TCP connect (dialCount=%d)", targetAddr, dialCount.Load()) } // TestBanDial_ConcurrentBan_Bootnode mirrors TestBanDial_ConcurrentBan_KnownPeer @@ -347,9 +347,9 @@ func TestBanDial_ConcurrentBan_Bootnode(t *testing.T) { time.Sleep(100 * time.Millisecond) if n := dialCount.Load(); n != 0 { - t.Errorf("listener received %d TCP connection(s) after BanPeer cancelled the in-flight bootnode dial — dial-gate race not closed", n) + t.Errorf("listener received %d TCP connection(s) after BanPeer canceled the in-flight bootnode dial — dial-gate race not closed", n) } - t.Logf("✓ concurrent ban cancelled in-flight bootnode dial to %s before TCP connect (dialCount=%d)", targetAddr, dialCount.Load()) + t.Logf("✓ concurrent ban canceled in-flight bootnode dial to %s before TCP connect (dialCount=%d)", targetAddr, dialCount.Load()) } func TestBanReconnect_MaintainLoopSkipsBannedPeer(t *testing.T) { @@ -479,7 +479,7 @@ func TestBanReconnect_BootnodePathSkipsBannedAddr(t *testing.T) { t.Fatal("BanPeer did not register any ban entries") } - // Reset the dial counter: Start() may have already dialled bannedAddr + // Reset the dial counter: Start() may have already dialed bannedAddr // during the initial bootnode dial goroutine. We only care about dials // triggered by the maintainLoop tick below. time.Sleep(50 * time.Millisecond) // let any in-flight Start() dials land @@ -536,7 +536,7 @@ func TestBanReconnect_BareIPBanBlocksListenPort(t *testing.T) { }() ephemeralAddr := lnEphemeral.Addr().String() // "127.0.0.1:XXXXX" — banned addr - listenAddr := lnListen.Addr().String() // "127.0.0.1:YYYYY" — dial target + listenAddr := lnListen.Addr().String() // "127.0.0.1:YYYYY" — dial target h := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", diff --git a/p2p/bootnode_dial_backoff_test.go b/p2p/bootnode_dial_backoff_test.go index eb72937..a425ddf 100644 --- a/p2p/bootnode_dial_backoff_test.go +++ b/p2p/bootnode_dial_backoff_test.go @@ -9,7 +9,7 @@ package p2p_test // 2. Once the back-off window expires, maintainLoop retries the dial. // 3. MaxDialBackoff defaults to 5 m when the caller passes 0. // 4. The MinPeers known-peer redial path respects the same back-off window; -// a bootnode that has failed is NOT dialled through peerList while in back-off. +// a bootnode that has failed is NOT dialed through peerList while in back-off. // 5. A negative MaxDialBackoff is treated as invalid and replaced with the default. import ( @@ -134,7 +134,7 @@ func TestBootnodeDialBackoff_NegativeMaxDialBackoff(t *testing.T) { // Scenario: a bootnode's resolved address is also present in peerList (which // can happen via normal peer-exchange after the first successful connection). // When the bootnode is in back-off and the host is below MinPeers, the -// MinPeers loop must skip the address rather than dialling it directly. +// MinPeers loop must skip the address rather than dialing it directly. func TestBootnodeDialBackoff_MinPeersBypassBlocked(t *testing.T) { const bootnodeAddr = "127.0.0.1:19943" const backoff = 400 * time.Millisecond @@ -261,7 +261,7 @@ func TestBootnodeDialBackoff_ClearedAfterBriefConnect(t *testing.T) { var useHandshake atomic.Bool var dialCount atomic.Int64 - // connectCh is signalled once the Phase-2 server goroutine has written + // connectCh is signaled once the Phase-2 server goroutine has written // MsgPong (connectedAt will be set in handleConn shortly after). connectCh := make(chan struct{}, 1) @@ -353,7 +353,7 @@ func TestBootnodeDialBackoff_ClearedAfterBriefConnect(t *testing.T) { p2p.HostTriggerMaintain(h) time.Sleep(200 * time.Millisecond) if dialCount.Load() <= beforeCount { - t.Errorf("bootnode not re-dialled after back-off cleared by brief connect "+ + t.Errorf("bootnode not re-dialed after back-off cleared by brief connect "+ "(count before=%d, after=%d)", beforeCount, dialCount.Load()) } } @@ -524,6 +524,6 @@ func TestBootnodeDialBackoff_InboundDifferentIPDoesNotClear(t *testing.T) { t.Fatal("different-IP inbound peer incorrectly cleared configured bootnode back-off") } if got := dialCount.Load(); got != before { - t.Fatalf("bootnode was dialled during active back-off: count changed from %d to %d", before, got) + t.Fatalf("bootnode was dialed during active back-off: count changed from %d to %d", before, got) } } diff --git a/p2p/bootnode_warn_test.go b/p2p/bootnode_warn_test.go index adc49b6..c8481d1 100644 --- a/p2p/bootnode_warn_test.go +++ b/p2p/bootnode_warn_test.go @@ -93,8 +93,8 @@ func TestBootnodeWarn_StartupLog(t *testing.T) { NodeID: "test-bootnode-warn", UserAgent: "aperod-test/0.1", Bootnodes: []string{ - "/ip6/badaddr", // malformed — no /tcp/ component - "127.0.0.1:19998", // valid syntax but unreachable; node must try it + "/ip6/badaddr", // malformed — no /tcp/ component + "127.0.0.1:19998", // valid syntax but unreachable; node must try it }, }, &stubHandler{}, log) diff --git a/p2p/discovery.go b/p2p/discovery.go index 54412b0..85c978d 100644 --- a/p2p/discovery.go +++ b/p2p/discovery.go @@ -6,7 +6,7 @@ import ( ) // Discovery handles peer discovery by periodically requesting peer lists from -// connected peers and re-dialling known addresses when the peer count is low. +// connected peers and re-dialing known addresses when the peer count is low. // It is a lightweight pull-based "gossip about peers" — each round, we pick up // to discoverFanout random peers and ask them for their MsgGetPeers list. type Discovery struct { @@ -72,7 +72,7 @@ func (d *Discovery) round() { h.mu.RUnlock() if len(peers) == 0 { - h.log.Info("discovery: no peers, re-dialling bootnodes") + h.log.Info("discovery: no peers, re-dialing bootnodes") for _, addr := range h.cfg.Bootnodes { go func(a string) { resolved, err := resolveBootnode(a) diff --git a/p2p/discovery_test.go b/p2p/discovery_test.go index abebf57..0fa4eb6 100644 --- a/p2p/discovery_test.go +++ b/p2p/discovery_test.go @@ -3,78 +3,78 @@ package p2p_test // Tests for peer Discovery: lifecycle, round with/without peers, KnownPeerCount. import ( - "testing" - "time" + "testing" + "time" - "github.com/aperod/aperod/p2p" + "github.com/aperod/aperod/p2p" ) func TestDiscovery_StartStop(t *testing.T) { - host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) - d := p2p.NewDiscovery(host, 100*time.Millisecond) - d.Start() - time.Sleep(20 * time.Millisecond) - d.Stop() + host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) + d := p2p.NewDiscovery(host, 100*time.Millisecond) + d.Start() + time.Sleep(20 * time.Millisecond) + d.Stop() } func TestDiscovery_DefaultInterval(t *testing.T) { - // interval=0 should default to 30s without panicking - host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) - d := p2p.NewDiscovery(host, 0) - d.Start() - time.Sleep(10 * time.Millisecond) - d.Stop() + // interval=0 should default to 30s without panicking + host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) + d := p2p.NewDiscovery(host, 0) + d.Start() + time.Sleep(10 * time.Millisecond) + d.Stop() } func TestDiscovery_KnownPeerCount_Empty(t *testing.T) { - host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) - d := p2p.NewDiscovery(host, time.Minute) - if got := d.KnownPeerCount(); got != 0 { - t.Errorf("KnownPeerCount = %d, want 0", got) - } + host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) + d := p2p.NewDiscovery(host, time.Minute) + if got := d.KnownPeerCount(); got != 0 { + t.Errorf("KnownPeerCount = %d, want 0", got) + } } func TestDiscovery_RequestPeersFrom_NotConnected(t *testing.T) { - // Should not panic when peer is not connected - host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) - d := p2p.NewDiscovery(host, time.Minute) - d.RequestPeersFrom("192.168.1.1:7777") + // Should not panic when peer is not connected + host := p2p.NewHost(p2p.Config{MaxPeers: 10}, &stubHandler{}, newTestLogger()) + d := p2p.NewDiscovery(host, time.Minute) + d.RequestPeersFrom("192.168.1.1:7777") } func TestDiscovery_RoundNoBootnodes_NoPanic(t *testing.T) { - // No bootnodes, no peers — multiple rounds should be a no-op - host := p2p.NewHost(p2p.Config{MaxPeers: 10, Bootnodes: nil}, &stubHandler{}, newTestLogger()) - d := p2p.NewDiscovery(host, 50*time.Millisecond) - d.Start() - time.Sleep(130 * time.Millisecond) // at least 2 rounds - d.Stop() + // No bootnodes, no peers — multiple rounds should be a no-op + host := p2p.NewHost(p2p.Config{MaxPeers: 10, Bootnodes: nil}, &stubHandler{}, newTestLogger()) + d := p2p.NewDiscovery(host, 50*time.Millisecond) + d.Start() + time.Sleep(130 * time.Millisecond) // at least 2 rounds + d.Stop() } func TestDiscovery_RoundWithPeer_SendsGetPeers(t *testing.T) { - // Start a host, connect a peer, then run a discovery round. - // The round should send MsgGetPeers to the connected peer. - host := p2p.NewHost(p2p.Config{ - ListenAddr: "127.0.0.1:0", - MaxPeers: 10, - NodeID: "disc-host", - UserAgent: "aperod/test", - }, &stubHandler{}, newTestLogger()) - if err := host.Start(); err != nil { - t.Fatalf("Start: %v", err) - } - defer host.Stop() + // Start a host, connect a peer, then run a discovery round. + // The round should send MsgGetPeers to the connected peer. + host := p2p.NewHost(p2p.Config{ + ListenAddr: "127.0.0.1:0", + MaxPeers: 10, + NodeID: "disc-host", + UserAgent: "aperod/test", + }, &stubHandler{}, newTestLogger()) + if err := host.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer host.Stop() - addr := host.ListenAddr() - if addr == "" { - t.Skip("ListenAddr unavailable") - } + addr := host.ListenAddr() + if addr == "" { + t.Skip("ListenAddr unavailable") + } - // Connect and handshake — nil fn means just handshake and disconnect - connectAndHandshake(t, addr, nil) + // Connect and handshake — nil fn means just handshake and disconnect + connectAndHandshake(t, addr, nil) - // Now run discovery — may or may not reach the peer before it disconnects - d := p2p.NewDiscovery(host, 50*time.Millisecond) - d.Start() - time.Sleep(150 * time.Millisecond) - d.Stop() + // Now run discovery — may or may not reach the peer before it disconnects + d := p2p.NewDiscovery(host, 50*time.Millisecond) + d.Start() + time.Sleep(150 * time.Millisecond) + d.Stop() } diff --git a/p2p/export_test.go b/p2p/export_test.go index cd44640..18a1bbc 100644 --- a/p2p/export_test.go +++ b/p2p/export_test.go @@ -42,7 +42,7 @@ func (h *Host) RecordBadBlockStrike(ip string) int { return strike.count } -// HostCanDial reports whether the host's PeerMgr would allow dialling addr +// HostCanDial reports whether the host's PeerMgr would allow dialing addr // right now (not banned and back-off window has elapsed). Exported for // integration tests that need to inspect dial-backoff state without going // through the network layer. @@ -60,7 +60,7 @@ func HostRecordDialFail(h *Host, addr string) { // HostSetBootnodeResolved records resolved as the current IP:port addresses // for the raw bootnode string raw, then rebuilds bootnodeSet. An empty or // nil resolved slice removes raw's contribution so its addresses return to -// normal back-off behaviour. This mirrors what maintainLoop does on a +// normal back-off behavior. This mirrors what maintainLoop does on a // successful DNS resolution tick — use it in tests instead of calling Start(). func HostSetBootnodeResolved(h *Host, raw string, resolved []string) { h.mu.Lock() @@ -140,7 +140,7 @@ func SetListenFunc(h *Host, fn func(network, addr string) (net.Listener, error)) } // SetPostConnectHook sets a callback that dialPeer invokes after successfully -// dialling and registering the connection in pendingConns, but before calling +// dialing and registering the connection in pendingConns, but before calling // go handleConn. The hook gives a deterministic test window to call BanPeer // while the conn is pending: BanPeer will close it via cancelInFlightDials so // handleConn never receives a live connection to a banned peer. @@ -161,7 +161,7 @@ func HostPongGetHeadersTotal(h *Host) int64 { // SetDialFunc replaces the outbound TCP dial function used by dialPeer. // The replacement receives a context that BanPeer may cancel while the dial is -// in progress; the function must honour context cancellation and return +// in progress; the function must honor context cancellation and return // context.Canceled (or context.DeadlineExceeded) without establishing a // connection. This lets deterministic concurrency tests inject a blocking // dial that the test can release or abort on demand. diff --git a/p2p/fast_redial_startup_test.go b/p2p/fast_redial_startup_test.go index dcde7f2..f1aeca8 100644 --- a/p2p/fast_redial_startup_test.go +++ b/p2p/fast_redial_startup_test.go @@ -3,7 +3,7 @@ package p2p_test // Tests for TASK #2008: reconnect to known-good peers fast after a node restart. // // Problem: after a restart the node used to wait for the first discovery / -// maintain tick (10 s) before dialling its configured known-good peers. A +// maintain tick (10 s) before dialing its configured known-good peers. A // deploy health check that inspects peer_count at 30 s would then flap a // warning even though the network is healthy. Start() must fire an immediate // outbound dial of every configured bootnode AND every dialable "host:port" diff --git a/p2p/gossip.go b/p2p/gossip.go index 1221572..945ef1c 100644 --- a/p2p/gossip.go +++ b/p2p/gossip.go @@ -17,7 +17,7 @@ const ( // gossipEntry records a hash and its insertion time in FIFO order. type gossipEntry struct { hash crypto.Hash32 - at time.Time + at time.Time } // GossipFilter is a bounded seen-set that prevents a block or transaction from diff --git a/p2p/host.go b/p2p/host.go index ad3f4dd..6584bfd 100644 --- a/p2p/host.go +++ b/p2p/host.go @@ -117,7 +117,7 @@ type Config struct { WhitelistFile string // MaxBlockIngestPerSec caps the number of blocks per second that any // single peer may push to this node. The dispatch goroutine sleeps as - // needed to honour the limit, creating TCP-level backpressure without + // needed to honor the limit, creating TCP-level backpressure without // dropping blocks. Default: 50. 0 = disabled. MaxBlockIngestPerSec int // TxRateBurst is the token-bucket capacity for incoming P2P @@ -286,7 +286,7 @@ type Peer struct { outbound bool // lastPongAt is the Unix nanosecond timestamp of the most recent MsgPong - // received from this peer. Initialised to the connection start time so + // received from this peer. Initialized to the connection start time so // the first pong-deadline check does not fire before the peer has had a // chance to reply. Updated by dispatch on every MsgPong; read by the // keepalive goroutine to detect peers that have stopped replying altogether. @@ -389,7 +389,7 @@ const stableConnTime = 60 * time.Second // distributed attacker cannot exhaust node memory by registering many unique IPs. const badBlockMaxTrackedIPs = 1024 -// badBlockStrike records the misbehaviour history for one remote IP. +// badBlockStrike records the misbehavior history for one remote IP. type badBlockStrike struct { count int lastSeen time.Time @@ -539,7 +539,7 @@ type Host struct { wlNets []*net.IPNet wlIPs []net.IP - // wlMutate serialises the entire read-snapshot → compute-new-list → + // wlMutate serializes the entire read-snapshot → compute-new-list → // apply (wlMu.Lock swap) → persist sequence for all mutation paths // (SetPeerWhitelist, AddToWhitelist, RemoveFromWhitelist). Without // this lock, two concurrent AddToWhitelist calls would each snapshot @@ -547,7 +547,7 @@ type Host struct { // silently drop the other entry. wlMutate sync.Mutex - // wlPersistMu serialises the snapshot+write+rename sequence for the + // wlPersistMu serializes the snapshot+write+rename sequence for the // whitelist file so concurrent calls never interleave two partial writes. wlPersistMu sync.Mutex @@ -613,10 +613,10 @@ type Host struct { // keepaliveIntervalNs holds the current live keepalive Ping interval in // nanoseconds. Updated atomically by SetKeepaliveInterval so the change // is visible to all active peer goroutines on their next ping tick without - // any lock or restart. Initialised from cfg.KeepaliveInterval in NewHost. + // any lock or restart. Initialized from cfg.KeepaliveInterval in NewHost. keepaliveIntervalNs atomic.Int64 - // Live wrong-fork ban tuning (Task #1922). Initialised from + // Live wrong-fork ban tuning (Task #1922). Initialized from // cfg.BadBlockBanThreshold / BadBlockBanDuration / BadBlockHeightLead in // NewHost and updated atomically by SetBanConfig so operators can tighten // the rogue-peer ban sensitivity from the Admin Panel without a restart. @@ -628,14 +628,14 @@ type Host struct { // bootnodeLastResolved maps each raw bootnode string (as it appears in // cfg.Bootnodes) to the IP:port addresses it most recently resolved to. // Updated on every successful DNS resolution in Start() and maintainLoop. - // DNS failures leave the previous entry intact (retention behaviour). + // DNS failures leave the previous entry intact (retention behavior). // bootnodeSet is always rebuilt from this map so a bootnode that moves to // a new IP loses its old address's privileged status on the next tick. // Protected by h.mu (write-lock for mutations, read-lock for lookups). bootnodeLastResolved map[string][]string // bootnodeLastResolvedAt maps each raw bootnode string to the wall-clock - // time of its most recent successful DNS resolution. Initialised to + // time of its most recent successful DNS resolution. Initialized to // time.Now() when Start() is called so new nodes get a full grace period // before any stale-bootnode WARN fires. Updated by applyBootnodeResolution // on every successful resolution. Protected by h.mu. @@ -649,10 +649,10 @@ type Host struct { // maintainNow is an optional channel that tests can send on to trigger // an immediate maintainLoop tick without waiting for the 10-second ticker. - // nil in production (Start always initialises it). + // nil in production (Start always initializes it). maintainNow chan struct{} - // ── Dial-gate: serialises ban writes with dial initiations ──────────── + // ── Dial-gate: serializes ban writes with dial initiations ──────────── // // dialGateMu is held (briefly) by both dialPeer and BanPeer so that // "check IsBanned, then register intent to dial" is atomic with @@ -731,7 +731,7 @@ type Host struct { // NewHost creates a new p2p host. func NewHost(cfg Config, handler Handler, log *slog.Logger) *Host { // Apply defaults for rogue-peer ban parameters so callers that do not - // set these fields (e.g. unit tests) get safe, production-grade behaviour. + // set these fields (e.g. unit tests) get safe, production-grade behavior. if cfg.BadBlockHeightLead == 0 { cfg.BadBlockHeightLead = 1000 } @@ -875,7 +875,7 @@ func (h *Host) SetKeepaliveInterval(d time.Duration) error { // recordBootnodeFail updates the per-bootnode exponential back-off state after // a failed dial attempt. Called from the handleConn back-off defer when the -// dialled address is a configured bootnode. The back-off window grows +// dialed address is a configured bootnode. The back-off window grows // exponentially from 5 s (first failure) and is capped at MaxDialBackoff so // the relay always retries within a bounded interval. func (h *Host) recordBootnodeFail(addr string) { @@ -1178,7 +1178,7 @@ func (h *Host) SetPeerWhitelist(entries []string) error { // Returns an error if the entry is not a valid IP or CIDR, or if the sidecar // file cannot be written (in which case the in-memory list is not modified). // No-op (returns nil) when the entry is already present. -// The entire read-snapshot → append → apply → persist sequence is serialised +// The entire read-snapshot → append → apply → persist sequence is serialized // under wlMutate, preventing lost-update races with concurrent calls. func (h *Host) AddToWhitelist(entry string) error { // Validate before acquiring the lock to fail fast without contention. @@ -1209,7 +1209,7 @@ func (h *Host) AddToWhitelist(entry string) error { // Returns (false, nil) when the entry was not found in the list. // Returns (false, err) when the entry was found but the sidecar file could // not be written; in that case the in-memory list is not modified. -// The entire read-snapshot → filter → apply → persist sequence is serialised +// The entire read-snapshot → filter → apply → persist sequence is serialized // under wlMutate, preventing lost-update races with concurrent calls. func (h *Host) RemoveFromWhitelist(entry string) (bool, error) { h.wlMutate.Lock() @@ -1593,7 +1593,7 @@ func (h *Host) Start() error { } h.mu.Unlock() - // Dial bootnodes — resolve DNS hostnames before dialling so the + // Dial bootnodes — resolve DNS hostnames before dialing so the // canonical peer key in h.peers is always an IP:port string. // applyBootnodeResolution records each successful resolution in // bootnodeLastResolved and rebuilds bootnodeSet so dialPeer can @@ -1643,7 +1643,7 @@ func (h *Host) Start() error { // dialableWhitelistAddr reports whether a whitelist entry looks like a dialable // "host:port" address (an IP literal or DNS hostname followed by a decimal port // in [1,65535]). Bare IPs and CIDR ranges (the common whitelist form used for -// inbound access control) return ok=false so they are never dialled outbound. +// inbound access control) return ok=false so they are never dialed outbound. func dialableWhitelistAddr(entry string) (addr string, ok bool) { // CIDR entries contain '/' and are not dialable. if strings.ContainsRune(entry, '/') { @@ -2203,7 +2203,7 @@ func (h *Host) maintainLoop() { // Mirror the IsBanned guard applied to the MinPeers path // above: skip banned bootnodes before touching h.mu so the // ban check and the connection-table check are both done - // outside the lock, minimising lock contention and closing + // outside the lock, minimizing lock contention and closing // the goroutine-scheduling window described in task #1650. if h.mgr.IsBanned(addr) { continue @@ -2352,7 +2352,7 @@ func (h *Host) dialPeer(addr string) { // Configured bootnodes skip the exponential back-off window so a // simultaneous restart of both validators does not leave the network // isolated for up to 5 minutes. The ban list still applies — a - // bootnode that is actively banned cannot be re-dialled. + // bootnode that is actively banned cannot be re-dialed. if h.isBootnode(addr) { if h.mgr.IsBanned(addr) { h.dialGateMu.Unlock() @@ -2466,7 +2466,7 @@ func (h *Host) dialPeer(addr string) { var conn net.Conn if h.cfg.TLSConfig != nil { // Outbound TLS handshake: wrap the TCP conn and negotiate TLS. - // HandshakeContext honours the cancel so a ban during the TLS + // HandshakeContext honors the cancel so a ban during the TLS // phase also aborts cleanly. tlsConn := tls.Client(tcpConn, h.cfg.TLSConfig) if err := tlsConn.HandshakeContext(ctx); err != nil { @@ -2490,7 +2490,7 @@ func (h *Host) dialPeer(addr string) { // ── Post-connect gate: prevent handoff to handleConn if banned ──────── // // dialContextFunc may return a live conn even when the context was - // cancelled (e.g. if the TCP connect completed in the same scheduler + // canceled (e.g. if the TCP connect completed in the same scheduler // tick as BanPeer's cancel). Register the conn as pending under // dialGateMu: this is atomic with BanPeer's ban-write, so exactly one // of the following is guaranteed: @@ -3326,9 +3326,7 @@ func (h *Host) dispatch(peer *Peer, msgType MessageType, data []byte) error { } else if !isFutureTs { // Normal timestamp: reset any accumulated strikes for this IP. h.tsMu.Lock() - if _, ok := h.tsStrikeCounts[peerIP]; ok { - delete(h.tsStrikeCounts, peerIP) - } + delete(h.tsStrikeCounts, peerIP) h.tsMu.Unlock() } } diff --git a/p2p/host_test.go b/p2p/host_test.go index 9252921..ba5b8f3 100644 --- a/p2p/host_test.go +++ b/p2p/host_test.go @@ -167,15 +167,15 @@ func TestHost_SetBanConfig_Validation(t *testing.T) { heightLead uint64 wantErr bool }{ - {0, time.Hour, 1000, true}, // threshold below lower bound - {1001, time.Hour, 1000, true}, // threshold above upper bound - {10, 30 * time.Second, 1000, true}, // duration below 1m - {10, 31 * 24 * time.Hour, 1000, true}, // duration above 30d - {10, time.Hour, 0, true}, // heightLead below lower bound - {10, time.Hour, 1_000_001, true}, // heightLead above upper bound - {1, time.Minute, 1, false}, // all lower bounds — valid - {10, 24 * time.Hour, 1000, false}, // defaults — valid - {1000, 30 * 24 * time.Hour, 1_000_000, false}, // all upper bounds — valid + {0, time.Hour, 1000, true}, // threshold below lower bound + {1001, time.Hour, 1000, true}, // threshold above upper bound + {10, 30 * time.Second, 1000, true}, // duration below 1m + {10, 31 * 24 * time.Hour, 1000, true}, // duration above 30d + {10, time.Hour, 0, true}, // heightLead below lower bound + {10, time.Hour, 1_000_001, true}, // heightLead above upper bound + {1, time.Minute, 1, false}, // all lower bounds — valid + {10, 24 * time.Hour, 1000, false}, // defaults — valid + {1000, 30 * 24 * time.Hour, 1_000_000, false}, // all upper bounds — valid } for _, tc := range cases { err := h.SetBanConfig(tc.threshold, tc.duration, tc.heightLead) @@ -717,7 +717,7 @@ func TestHost_Handshake_InboundWithMessages(t *testing.T) { // cfg.PeerWhitelist is non-empty, Start() retains the cfg entries rather than // silently opening the network. // -// Without this retention behaviour, an admin "clear-all" operation in the +// Without this retention behavior, an admin "clear-all" operation in the // Admin Panel would write an empty sidecar and, on the next restart, the node // would accept connections from every IP — even ones that should still be // blocked via peer_whitelist in node.yaml. @@ -1457,7 +1457,7 @@ func TestHost_PeerWhitelist_OutboundDialUnaffected(t *testing.T) { // 3. The whitelist is still enforced (not silently reset to open): a second host // whose list contains only a valid CIDR (that excludes 127.0.0.1) plus the // same garbage entry rejects a connection from loopback — proving the CIDR -// entry was also kept despite the invalid neighbour. +// entry was also kept despite the invalid neighbor. func TestHost_PeerWhitelist_InvalidEntrySkipped(t *testing.T) { // ── Part 1 & 2: valid IP entry survives the garbage entry ───────────────── @@ -1585,7 +1585,7 @@ func TestHost_PeerWhitelist_InvalidEntrySkipped(t *testing.T) { } // TestBootnode_SkipsBackoffWindow verifies that after 3 consecutive dial -// failures back-off blocks a regular peer from being re-dialled while a +// failures back-off blocks a regular peer from being re-dialed while a // configured bootnode is still attempted on the very next call. // // Scenario: @@ -1684,7 +1684,7 @@ func TestBootnode_SkipsBackoffWindow(t *testing.T) { // TestBootnode_DNSRefresh_RemovesRetiredAddr verifies that when a bootnode's // DNS record changes (i.e. the bootnode moves to a new IP), the old address // is removed from the privileged set on the next resolution tick so it returns -// to normal exponential back-off behaviour. +// to normal exponential back-off behavior. // // Scenario: // 1. Bootnode "bn.example:30303" initially resolves to addrOld. @@ -2122,7 +2122,7 @@ func TestHost_AddToWhitelist_UnwritableDir_RollsBack(t *testing.T) { // TestHost_AddToWhitelist_ConcurrentNeverDropsEntry is a concurrent stress test // that verifies two goroutines calling AddToWhitelist simultaneously always -// produce a whitelist containing both entries. Without wlMutate serialisation +// produce a whitelist containing both entries. Without wlMutate serialization // both goroutines would snapshot the same (empty) list, append their own entry, // and the last writer would silently overwrite the first — a lost-update race. func TestHost_AddToWhitelist_ConcurrentNeverDropsEntry(t *testing.T) { @@ -2175,7 +2175,7 @@ func TestHost_AddToWhitelist_ConcurrentNeverDropsEntry(t *testing.T) { // TestHost_RemoveFromWhitelist_ConcurrentNeverDropsOtherRemoval verifies that // two goroutines calling RemoveFromWhitelist simultaneously each successfully // remove their own entry and neither call silently discards the other removal. -// Without wlMutate serialisation both goroutines would snapshot the same 2-item +// Without wlMutate serialization both goroutines would snapshot the same 2-item // list, filter out their own entry, and the last writer would restore the entry // that the first writer removed — a lost-update race. func TestHost_RemoveFromWhitelist_ConcurrentNeverDropsOtherRemoval(t *testing.T) { @@ -2352,9 +2352,9 @@ func TestMaintainLoop_ReconnectsBothBootnodesAfterHiccup(t *testing.T) { } t.Logf("after hiccup: PeerCount=%d", host.PeerCount()) - // ── Phase 3: fire one maintain tick; both bootnodes must be re-dialled ──── + // ── Phase 3: fire one maintain tick; both bootnodes must be re-dialed ──── // Back-off does NOT apply here: a connection that reached the message loop - // but then dropped (< stableConnTime) is not penalised with bootnode back-off + // but then dropped (< stableConnTime) is not penalized with bootnode back-off // so the relay reconnects immediately on the next tick. p2p.HostTriggerMaintain(host) @@ -2368,15 +2368,15 @@ func TestMaintainLoop_ReconnectsBothBootnodesAfterHiccup(t *testing.T) { // Each bootnode must have been contacted at least twice (initial + reconnect). if dialCountA.Load() < 2 { - t.Errorf("bootnode A (%s) not re-dialled after hiccup: total dials=%d, want ≥2", + t.Errorf("bootnode A (%s) not re-dialed after hiccup: total dials=%d, want ≥2", addrA, dialCountA.Load()) } if dialCountB.Load() < 2 { - t.Errorf("bootnode B (%s) not re-dialled after hiccup: total dials=%d, want ≥2", + t.Errorf("bootnode B (%s) not re-dialed after hiccup: total dials=%d, want ≥2", addrBMultiaddr, dialCountB.Load()) } if !t.Failed() { - t.Logf("✓ both bootnodes re-dialled after hiccup: A=%d B=%d", + t.Logf("✓ both bootnodes re-dialed after hiccup: A=%d B=%d", dialCountA.Load(), dialCountB.Load()) } } @@ -2384,7 +2384,7 @@ func TestMaintainLoop_ReconnectsBothBootnodesAfterHiccup(t *testing.T) { // TestHost_DialPeer_BackoffAfterHandshakeDrop verifies that when an outbound // TCP connection succeeds but the remote closes the socket before completing // the P2P handshake (no Pong), handleConn's back-off defer calls OnDialFail -// so the address enters a back-off window and cannot be re-dialled immediately. +// so the address enters a back-off window and cannot be re-dialed immediately. // This covers the flapping-peer reconnect-storm case where the peer is // reachable at TCP level but never finishes the application handshake. func TestHost_DialPeer_BackoffAfterHandshakeDrop(t *testing.T) { @@ -2460,7 +2460,7 @@ func (h *gapSyncHandler) OnBlock(b *core.Block) { h.mu.Lock() defer h.mu.Unlock() // Accept only the strictly next block so the handler mirrors a real node's - // sequential AddBlock behaviour. + // sequential AddBlock behavior. if b.Header.Height == h.tip+1 { h.chain = append(h.chain, b) h.byHash[b.Hash()] = b @@ -2876,7 +2876,7 @@ func TestSync_RelayNode_CatchesUpDuringLiveProduction(t *testing.T) { } defer conn.Close() - // done is closed when this goroutine exits, signalling the write goroutine + // done is closed when this goroutine exits, signaling the write goroutine // and the gossip forwarder to stop. Using a done channel (rather than // closing writeCh) avoids a send-on-closed-channel panic in the forwarder. done := make(chan struct{}) @@ -2888,7 +2888,7 @@ func TestSync_RelayNode_CatchesUpDuringLiveProduction(t *testing.T) { } writeCh := make(chan writeReq, 4096) - // Write goroutine: serialises all writes to conn. + // Write goroutine: serializes all writes to conn. go func() { for { select { @@ -3595,7 +3595,7 @@ func TestHost_RoguePeer_BannedAtProductionThreshold(t *testing.T) { // Phase 2: cross the threshold — send 2 more (total 6 > threshold). // The connection may be evicted mid-loop once the 5th strike lands, so a - // write error on the 6th block is acceptable attacker-side behaviour. + // write error on the 6th block is acceptable attacker-side behavior. for i := 0; i < 2; i++ { sb := p2p.SerializedBlock{Header: p2p.SerializedHeader{Height: 500_000}} conn.SetWriteDeadline(time.Now().Add(time.Second)) @@ -3983,7 +3983,7 @@ func TestBootnode_RefusedConnection_RetriedWithoutBlockingValidPeer(t *testing.T // TestHost_WhitelistSidecar_UnreadableFailsClosed verifies that Start() returns // a non-nil error when the whitelist sidecar file exists but cannot be read -// (e.g. permission denied). Without this fail-closed behaviour a refactor that +// (e.g. permission denied). Without this fail-closed behavior a refactor that // accidentally swallows the I/O error would let the node start in open-network // mode — accepting inbound connections from all IPs — after a restart. // diff --git a/p2p/peermgr_persist_test.go b/p2p/peermgr_persist_test.go index ed389c6..3b6c06d 100644 --- a/p2p/peermgr_persist_test.go +++ b/p2p/peermgr_persist_test.go @@ -383,7 +383,7 @@ func TestPeerMgr_ConcurrentBanAndLift(t *testing.T) { // restart is still blocked immediately after restart — captured at the exact // pre-Listen point inside Start(), before any inbound connection can arrive. // -// This is the ban-file analogue of the whitelist ordering test added in task +// This is the ban-file analog of the whitelist ordering test added in task // 1589. SetListenFunc fires after LoadBansFromFile but before net.Listen, so // the hook provides the earliest possible deterministic snapshot of the ban // state: if a future refactor reorders Start() so that the listener opens diff --git a/p2p/peermgr_test.go b/p2p/peermgr_test.go index 29d347f..527d60c 100644 --- a/p2p/peermgr_test.go +++ b/p2p/peermgr_test.go @@ -126,7 +126,7 @@ func TestPeerMgr_OnDialSuccess_ResetsBackoff(t *testing.T) { } // TestPeerMgr_RapidDisconnect_Throttled verifies that a peer whose connection -// keeps dropping quickly (< stableConnTime) cannot be dialled more than a +// keeps dropping quickly (< stableConnTime) cannot be dialed more than a // small number of times within a short observation window. // // In the real host, OnDialFail is called both for TCP/TLS dial errors AND when diff --git a/p2p/pong_cooldown_test.go b/p2p/pong_cooldown_test.go index 9c3e3c7..84143e4 100644 --- a/p2p/pong_cooldown_test.go +++ b/p2p/pong_cooldown_test.go @@ -142,7 +142,7 @@ func TestPongCooldown_SuppressesRedundantRequestHeaders(t *testing.T) { } defer catchingHost.Stop() - // Snapshot counter before dialling so any pre-connection noise is excluded. + // Snapshot counter before dialing so any pre-connection noise is excluded. baseline := p2p.HostPongGetHeadersTotal(catchingHost) catchingHost.DialPeer(ln.Addr().String()) @@ -228,7 +228,7 @@ func TestPongCooldown_SelfHealFallback(t *testing.T) { conn.SetDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck // ── Write goroutine ─────────────────────────────────────────────────── - // Serialises all writes to conn via a mutex shared with the Ping + // Serializes all writes to conn via a mutex shared with the Ping // responder below, so the read loop and test-directed Pong sends do not // interleave bytes on the wire. var wMu sync.Mutex diff --git a/p2p/protocol.go b/p2p/protocol.go index 7c512ea..ce5b3b4 100644 --- a/p2p/protocol.go +++ b/p2p/protocol.go @@ -110,7 +110,7 @@ type VoteMsg struct { // ─── Framing ────────────────────────────────────────────────────────────────── // WriteMsg encodes and sends a message over conn. -// Format: [4-byte big-endian length][msgType byte][JSON body] +// Format: [4-byte big-endian length][msgType byte][JSON body]. func WriteMsg(conn net.Conn, msgType MessageType, payload interface{}) error { return writeMsg(conn, msgType, payload) } diff --git a/p2p/reconnect_minpeers_test.go b/p2p/reconnect_minpeers_test.go index 7280a55..5af38ff 100644 --- a/p2p/reconnect_minpeers_test.go +++ b/p2p/reconnect_minpeers_test.go @@ -225,7 +225,7 @@ func TestSync_RelayNode_ReconnectViaMinPeers(t *testing.T) { // ── Relay p2p host — MinPeers=1, NO Bootnodes ──────────────────────────── // Without a Bootnodes entry the relay cannot bypass exponential back-off. // After the connection is dropped (lasting < stableConnTime=60 s) the peer - // manager records a dial failure and blocks re-dialling for 5 s. The test + // manager records a dial failure and blocks re-dialing for 5 s. The test // waits for that window via HostCanDial, then fires HostTriggerMaintain to // immediately advance the maintainLoop — which sees count < MinPeers and // re-dials the peer from peerList. diff --git a/p2p/security_test.go b/p2p/security_test.go index f4927ef..13538b6 100644 --- a/p2p/security_test.go +++ b/p2p/security_test.go @@ -182,7 +182,7 @@ func TestReplayAttack_GossipDedup(t *testing.T) { // ─── 3.5.4 Rate limiting / banned peer reconnect ───────────────────────────── // TestRateLimiting_BannedPeerDropped verifies that a peer banned for bad -// behaviour is rejected during the handshake phase. +// behavior is rejected during the handshake phase. func TestRateLimiting_BannedPeerDropped(t *testing.T) { handler := &stubHandler{} host := p2p.NewHost(p2p.Config{ @@ -216,7 +216,7 @@ func TestRateLimiting_BannedPeerDropped(t *testing.T) { time.Sleep(50 * time.Millisecond) // Ban the peer address. - host.BanPeer(conn.LocalAddr().String(), "bad behaviour", time.Hour) + host.BanPeer(conn.LocalAddr().String(), "bad behavior", time.Hour) conn.Close() // A subsequent connection from the same address should be handled; the ban @@ -368,7 +368,7 @@ func TestMaliciousPacket_NodeStaysAlive(t *testing.T) { // ─── Peer IP whitelist ──────────────────────────────────────────────────────── // TestPeerWhitelist_EmptyAllowsAll verifies that an empty peer_whitelist leaves -// the network open: connections from any IP are accepted (existing behaviour). +// the network open: connections from any IP are accepted (existing behavior). func TestPeerWhitelist_EmptyAllowsAll(t *testing.T) { host := p2p.NewHost(p2p.Config{ ListenAddr: "127.0.0.1:0", @@ -690,7 +690,7 @@ func TestDuplicateP2PIdentity_RejectedOutbound(t *testing.T) { } t.Cleanup(func() { ln.Close() }) - // remoteClosed is signalled when the host closes the connection (the + // remoteClosed is signaled when the host closes the connection (the // listener side observes EOF / reset on its first read). remoteClosed := make(chan error, 1) go func() { diff --git a/p2p/stall_timeout_config_test.go b/p2p/stall_timeout_config_test.go index 500cd8f..53b46fb 100644 --- a/p2p/stall_timeout_config_test.go +++ b/p2p/stall_timeout_config_test.go @@ -58,12 +58,12 @@ func TestStallTimeout_HostRetainsRaisedValue(t *testing.T) { // here is on a stall-specific observable — GetStallEvents — so a re-issued // MsgGetHeaders from the ordinary 3s sync ticker can never satisfy the test: // -// 1. No StallEvent may exist before the configured timeout has elapsed -// (measured from a baseline taken BEFORE the host is started, so no -// earlier ticker could have fired unobserved). -// 2. A StallEvent MUST be recorded within the [timeout, timeout+grace] -// window. If the config-to-host wiring broke and the host silently used -// the 15s default, no StallEvent appears in the window and the test fails. +// 1. No StallEvent may exist before the configured timeout has elapsed +// (measured from a baseline taken BEFORE the host is started, so no +// earlier ticker could have fired unobserved). +// 2. A StallEvent MUST be recorded within the [timeout, timeout+grace] +// window. If the config-to-host wiring broke and the host silently used +// the 15s default, no StallEvent appears in the window and the test fails. func TestStallTimeout_ConfiguredValueDrivesStallTicker(t *testing.T) { const peerHeight = uint64(3) const stallTimeout = 1200 * time.Millisecond diff --git a/p2p/sync.go b/p2p/sync.go index 4272653..2f93796 100644 --- a/p2p/sync.go +++ b/p2p/sync.go @@ -1,30 +1,30 @@ package p2p import ( - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // HeaderProvider can serve a contiguous range of block headers for chain sync. // Implemented by node.Node (which has access to the chain store). type HeaderProvider interface { - // HeadersFrom returns up to limit headers that come after any of knownHashes. - // Returns nil if all knownHashes are unknown (force full sync from genesis). - HeadersFrom(knownHashes []crypto.Hash32, limit int) []core.BlockHeader + // HeadersFrom returns up to limit headers that come after any of knownHashes. + // Returns nil if all knownHashes are unknown (force full sync from genesis). + HeadersFrom(knownHashes []crypto.Hash32, limit int) []core.BlockHeader } // syncState tracks in-flight sync requests to a single peer. type syncState struct { - inFlight map[crypto.Hash32]bool // hashes requested but not yet received + inFlight map[crypto.Hash32]bool // hashes requested but not yet received } func newSyncState() *syncState { - return &syncState{inFlight: make(map[crypto.Hash32]bool)} + return &syncState{inFlight: make(map[crypto.Hash32]bool)} } func (s *syncState) markRequested(h crypto.Hash32) { s.inFlight[h] = true } func (s *syncState) markReceived(h crypto.Hash32) { delete(s.inFlight, h) } -func (s *syncState) pending() int { return len(s.inFlight) } +func (s *syncState) pending() int { return len(s.inFlight) } // ─── Exported wrappers for tests ────────────────────────────────────────────── diff --git a/p2p/transport_test.go b/p2p/transport_test.go index 485ff7f..f3c5da6 100644 --- a/p2p/transport_test.go +++ b/p2p/transport_test.go @@ -680,7 +680,7 @@ func TestTLS_ConnectionSurvivesIdleSilence(t *testing.T) { // that is NOT on the host's AllowedPeers list must not be able to inject a // block into the chain via MsgBlock, even though the TLS handshake itself // succeeds (mutual TLS authenticates the cert as valid, but the fingerprint -// is not recognised). +// is not recognized). // // Done looks like: // - handler.OnBlock is never called after the attacker sends MsgBlock. diff --git a/p2p/txrate.go b/p2p/txrate.go index 1f10cc0..1c8ad30 100644 --- a/p2p/txrate.go +++ b/p2p/txrate.go @@ -118,7 +118,7 @@ func (l *txRateLimiter) allow(ip string) (allowed, banNow bool, violations int) // forget removes the entry for ip (called after the IP is banned so a // post-ban reconnect starts with a clean slate, mirroring the -// badBlockCounts behaviour). +// badBlockCounts behavior). func (l *txRateLimiter) forget(ip string) { l.mu.Lock() delete(l.buckets, ip) diff --git a/p2p/whitelist_integration_test.go b/p2p/whitelist_integration_test.go index bf88528..fcb8b63 100644 --- a/p2p/whitelist_integration_test.go +++ b/p2p/whitelist_integration_test.go @@ -27,12 +27,12 @@ import ( // handleConn will call the Handler (e.g. CurrentHeight); a nil handler panics. type wlIntegHandler struct{} -func (wlIntegHandler) OnBlock(_ *core.Block) {} -func (wlIntegHandler) OnTransaction(_ *core.Transaction) {} -func (wlIntegHandler) OnVote(_ VoteMsg) {} -func (wlIntegHandler) CurrentHeight() uint64 { return 0 } -func (wlIntegHandler) CurrentTailHashes(_ int) []crypto.Hash32 { return nil } -func (wlIntegHandler) GetBlock(_ crypto.Hash32) *core.Block { return nil } +func (wlIntegHandler) OnBlock(_ *core.Block) {} +func (wlIntegHandler) OnTransaction(_ *core.Transaction) {} +func (wlIntegHandler) OnVote(_ VoteMsg) {} +func (wlIntegHandler) CurrentHeight() uint64 { return 0 } +func (wlIntegHandler) CurrentTailHashes(_ int) []crypto.Hash32 { return nil } +func (wlIntegHandler) GetBlock(_ crypto.Hash32) *core.Block { return nil } // ─── spoofing listener helpers ──────────────────────────────────────────────── diff --git a/p2p/whitelist_sidecar_test.go b/p2p/whitelist_sidecar_test.go index e50d680..a967b55 100644 --- a/p2p/whitelist_sidecar_test.go +++ b/p2p/whitelist_sidecar_test.go @@ -1,6 +1,6 @@ package p2p_test -// Test for TASK #1770: fail-closed behaviour when the whitelist sidecar file +// Test for TASK #1770: fail-closed behavior when the whitelist sidecar file // exists but cannot be read (permission denied). // // loadWhitelistFromFile must return a fatal error in that case so Start() @@ -8,7 +8,7 @@ package p2p_test // which would defeat the whitelist's access-control purpose). // // NOTE: an EMPTY or ABSENT sidecar intentionally falls back to the node.yaml -// peer_whitelist entries; that behaviour is covered elsewhere and is not +// peer_whitelist entries; that behavior is covered elsewhere and is not // exercised here. Only the unreadable-file case must fail closed. import ( diff --git a/p2p/whitelist_test.go b/p2p/whitelist_test.go index b12ddd4..ccb5bca 100644 --- a/p2p/whitelist_test.go +++ b/p2p/whitelist_test.go @@ -29,7 +29,7 @@ func newWLTestHost(t *testing.T, cfgOpts func(*Config)) *Host { return NewHost(cfg, nil, wlTestLogger()) } -// TestWhitelist_AddRemove verifies basic add/remove/idempotent behaviour. +// TestWhitelist_AddRemove verifies basic add/remove/idempotent behavior. func TestWhitelist_AddRemove(t *testing.T) { h := newWLTestHost(t, nil) diff --git a/sale/accounting.go b/sale/accounting.go index a4b2950..d7c4411 100644 --- a/sale/accounting.go +++ b/sale/accounting.go @@ -116,14 +116,14 @@ func (c Checkpoint) Verify(genesis, parent crypto.Hash32, height uint64, trusted // block validator must additionally verify the exact credited output and // persist/rollback state atomically with the block before using this ledger. type Purchase struct { - Genesis crypto.Hash32 - Height uint64 - Parent crypto.Hash32 - OrderHash crypto.Hash32 - PaymentHash crypto.Hash32 - Recipient crypto.Address - Amount uint64 - Signature []byte + Genesis crypto.Hash32 + Height uint64 + Parent crypto.Hash32 + OrderHash crypto.Hash32 + PaymentHash crypto.Hash32 + Recipient crypto.Address + Amount uint64 + Signature []byte } func (p Purchase) Message() crypto.Hash32 { @@ -139,12 +139,12 @@ func (p Purchase) Message() crypto.Hash32 { // made durable alongside the canonical block in the eventual integration. type Ledger struct { checkpoint Checkpoint - height uint64 - hash crypto.Hash32 - remaining uint64 - sold uint64 - seen map[crypto.Hash32]struct{} - paid map[crypto.Hash32]struct{} + height uint64 + hash crypto.Hash32 + remaining uint64 + sold uint64 + seen map[crypto.Hash32]struct{} + paid map[crypto.Hash32]struct{} } func Begin(c Checkpoint, genesis, parent crypto.Hash32, height uint64, trusted []crypto.ValidatorPubKey) (*Ledger, error) { @@ -211,7 +211,7 @@ func (s *Ledger) Seen(order crypto.Hash32) bool { return ok } -func (s *Ledger) Remaining() uint64 { return s.remaining } -func (s *Ledger) Sold() uint64 { return s.sold } -func (s *Ledger) Height() uint64 { return s.height } +func (s *Ledger) Remaining() uint64 { return s.remaining } +func (s *Ledger) Sold() uint64 { return s.sold } +func (s *Ledger) Height() uint64 { return s.height } func (s *Ledger) Hash() crypto.Hash32 { return s.hash } diff --git a/sale/accounting_test.go b/sale/accounting_test.go index 0fd7a69..45ea216 100644 --- a/sale/accounting_test.go +++ b/sale/accounting_test.go @@ -7,11 +7,11 @@ import ( ) type fixture struct { - c Checkpoint - keys []crypto.ValidatorPrivKey - trusted []crypto.ValidatorPubKey + c Checkpoint + keys []crypto.ValidatorPrivKey + trusted []crypto.ValidatorPubKey merchant crypto.ValidatorPrivKey - address crypto.Address + address crypto.Address } func setup(t *testing.T) fixture { @@ -19,22 +19,28 @@ func setup(t *testing.T) fixture { var f fixture for i := 0; i < 4; i++ { key, pub, err := crypto.GenerateValidatorKey() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } f.keys = append(f.keys, key) f.trusted = append(f.trusted, pub) } key, pub, err := crypto.GenerateValidatorKey() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } f.merchant = key wallet, err := crypto.GenerateWalletKeys() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } f.address = crypto.AddressFromKeys(crypto.MainnetByte, wallet) f.c = Checkpoint{ Genesis: crypto.HashBytes([]byte("sale-test-genesis")), Network: crypto.MainnetByte, Height: 101, - Parent: crypto.HashBytes([]byte("sale-test-parent")), - SnapshotRoot: crypto.HashBytes([]byte("sale-test-nominal-snapshot")), - HistoricIssued: CapNAPRO/2, OtherReserved: CapNAPRO/4, - SaleRemaining: CapNAPRO/4, SaleAuthority: pub, + Parent: crypto.HashBytes([]byte("sale-test-parent")), + SnapshotRoot: crypto.HashBytes([]byte("sale-test-nominal-snapshot")), + HistoricIssued: CapNAPRO / 2, OtherReserved: CapNAPRO / 4, + SaleRemaining: CapNAPRO / 4, SaleAuthority: pub, } f.signCheckpoint(t) return f @@ -45,7 +51,9 @@ func (f *fixture) signCheckpoint(t *testing.T) { f.c.Attestations = nil for i := 0; i < 3; i++ { sig, err := f.keys[i].Sign(f.c.Message()) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } f.c.Attestations = append(f.c.Attestations, Attestation{Validator: f.trusted[i], Signature: sig}) } } @@ -53,52 +61,63 @@ func (f *fixture) signCheckpoint(t *testing.T) { func (f fixture) ledger(t *testing.T) *Ledger { t.Helper() s, err := Begin(f.c, f.c.Genesis, f.c.Parent, f.c.Height, f.trusted) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } return s } func (f fixture) purchase(t *testing.T, order, payment string, amount uint64, height uint64, parent crypto.Hash32) Purchase { t.Helper() - p := Purchase{Genesis:f.c.Genesis, Height:height, Parent:parent, - OrderHash:crypto.HashBytes([]byte(order)), PaymentHash:crypto.HashBytes([]byte(payment)), - Recipient:f.address, Amount:amount} + p := Purchase{Genesis: f.c.Genesis, Height: height, Parent: parent, + OrderHash: crypto.HashBytes([]byte(order)), PaymentHash: crypto.HashBytes([]byte(payment)), + Recipient: f.address, Amount: amount} sig, err := f.merchant.Sign(p.Message()) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } p.Signature = sig return p } func TestForwardCheckpointQuorumAndCap(t *testing.T) { - for _, tc := range []struct{ - name string + for _, tc := range []struct { + name string change func(*fixture) }{ - {"two_of_four_is_not_quorum", func(f *fixture){ f.c.Attestations=f.c.Attestations[:2] }}, - {"duplicate_signer", func(f *fixture){ f.c.Attestations[2]=f.c.Attestations[0] }}, - {"empty_quorum", func(f *fixture){ f.c.Attestations=nil }}, - {"untrusted_signer", func(f *fixture){ f.trusted=f.trusted[1:] }}, - {"duplicate_trusted_authority", func(f *fixture){ f.trusted=append(f.trusted,f.trusted[0]) }}, - {"missing_trust_set", func(f *fixture){ f.trusted=nil }}, - {"signature_tampering", func(f *fixture){ f.c.Attestations[0].Signature[0]^=1 }}, - {"budget_changed_after_signing", func(f *fixture){ f.c.SaleRemaining-- }}, - {"authority_changed_after_signing", func(f *fixture){ f.c.SaleAuthority=f.trusted[0] }}, - {"network_changed_after_signing", func(f *fixture){ f.c.Network=crypto.TestnetByte }}, - {"signed_cap_violation", func(f *fixture){ f.c.SaleRemaining++; f.signCheckpoint(t) }}, - {"signed_reserved_overflow", func(f *fixture){ f.c.OtherReserved=^uint64(0); f.signCheckpoint(t) }}, - {"signed_zero_budget", func(f *fixture){ f.c.SaleRemaining=0; f.signCheckpoint(t) }}, - {"signed_zero_snapshot", func(f *fixture){ f.c.SnapshotRoot=zeroHash; f.signCheckpoint(t) }}, - }{ - t.Run(tc.name,func(t *testing.T){ - f:=setup(t); tc.change(&f) - if _,err:=Begin(f.c,f.c.Genesis,f.c.Parent,f.c.Height,f.trusted); err==nil { + {"two_of_four_is_not_quorum", func(f *fixture) { f.c.Attestations = f.c.Attestations[:2] }}, + {"duplicate_signer", func(f *fixture) { f.c.Attestations[2] = f.c.Attestations[0] }}, + {"empty_quorum", func(f *fixture) { f.c.Attestations = nil }}, + {"untrusted_signer", func(f *fixture) { f.trusted = f.trusted[1:] }}, + {"duplicate_trusted_authority", func(f *fixture) { f.trusted = append(f.trusted, f.trusted[0]) }}, + {"missing_trust_set", func(f *fixture) { f.trusted = nil }}, + {"signature_tampering", func(f *fixture) { f.c.Attestations[0].Signature[0] ^= 1 }}, + {"budget_changed_after_signing", func(f *fixture) { f.c.SaleRemaining-- }}, + {"authority_changed_after_signing", func(f *fixture) { f.c.SaleAuthority = f.trusted[0] }}, + {"network_changed_after_signing", func(f *fixture) { f.c.Network = crypto.TestnetByte }}, + {"signed_cap_violation", func(f *fixture) { f.c.SaleRemaining++; f.signCheckpoint(t) }}, + {"signed_reserved_overflow", func(f *fixture) { f.c.OtherReserved = ^uint64(0); f.signCheckpoint(t) }}, + {"signed_zero_budget", func(f *fixture) { f.c.SaleRemaining = 0; f.signCheckpoint(t) }}, + {"signed_zero_snapshot", func(f *fixture) { f.c.SnapshotRoot = zeroHash; f.signCheckpoint(t) }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := setup(t) + tc.change(&f) + if _, err := Begin(f.c, f.c.Genesis, f.c.Parent, f.c.Height, f.trusted); err == nil { t.Fatal("invalid checkpoint accepted") } }) } - f:=setup(t) - if _,err:=Begin(f.c,f.c.Genesis,crypto.HashBytes([]byte("other-parent")),f.c.Height,f.trusted);err==nil { t.Fatal("wrong parent accepted") } - if _,err:=Begin(f.c,crypto.HashBytes([]byte("other-chain")),f.c.Parent,f.c.Height,f.trusted);err==nil { t.Fatal("wrong chain accepted") } - if _,err:=Begin(f.c,f.c.Genesis,f.c.Parent,f.c.Height+1,f.trusted);err==nil { t.Fatal("wrong activation height accepted") } + f := setup(t) + if _, err := Begin(f.c, f.c.Genesis, crypto.HashBytes([]byte("other-parent")), f.c.Height, f.trusted); err == nil { + t.Fatal("wrong parent accepted") + } + if _, err := Begin(f.c, crypto.HashBytes([]byte("other-chain")), f.c.Parent, f.c.Height, f.trusted); err == nil { + t.Fatal("wrong chain accepted") + } + if _, err := Begin(f.c, f.c.Genesis, f.c.Parent, f.c.Height+1, f.trusted); err == nil { + t.Fatal("wrong activation height accepted") + } } func TestExplorerNominalHeadlineCannotBeUsedAsSpendableSaleBudget(t *testing.T) { @@ -116,93 +135,149 @@ func TestExplorerNominalHeadlineCannotBeUsedAsSpendableSaleBudget(t *testing.T) } func TestIndependentReplayRestoresSameDebitAndReplayIndex(t *testing.T) { - f:=setup(t) - a:=f.ledger(t); b:=f.ledger(t) - for i,order:=range []string{"one","two"} { - height:=uint64(101+i) - if a.Hash()!=b.Hash() {t.Fatal("nodes diverged before replay") } - p:=f.purchase(t,order,"payment-"+order,100,height,a.Hash()) - hash:=crypto.HashBytes([]byte("block-"+order)) + f := setup(t) + a := f.ledger(t) + b := f.ledger(t) + for i, order := range []string{"one", "two"} { + height := uint64(101 + i) + if a.Hash() != b.Hash() { + t.Fatal("nodes diverged before replay") + } + p := f.purchase(t, order, "payment-"+order, 100, height, a.Hash()) + hash := crypto.HashBytes([]byte("block-" + order)) var err error - a,err=a.ApplyAccountedPurchases(height,a.Hash(),hash,[]Purchase{p}) - if err!=nil {t.Fatal(err)} - b,err=b.ApplyAccountedPurchases(height,b.Hash(),hash,[]Purchase{p}) - if err!=nil {t.Fatal(err)} - if a.Remaining()!=b.Remaining()||a.Sold()!=b.Sold()||!b.Seen(p.OrderHash) {t.Fatal("independent replay mismatch")} - } - duplicate:=f.purchase(t,"one","new-payment",50,103,a.Hash()) - if _,err:=b.ApplyAccountedPurchases(103,b.Hash(),crypto.HashBytes([]byte("block-103")),[]Purchase{duplicate});err==nil {t.Fatal("replayed node lost spent-order index")} + a, err = a.ApplyAccountedPurchases(height, a.Hash(), hash, []Purchase{p}) + if err != nil { + t.Fatal(err) + } + b, err = b.ApplyAccountedPurchases(height, b.Hash(), hash, []Purchase{p}) + if err != nil { + t.Fatal(err) + } + if a.Remaining() != b.Remaining() || a.Sold() != b.Sold() || !b.Seen(p.OrderHash) { + t.Fatal("independent replay mismatch") + } + } + duplicate := f.purchase(t, "one", "new-payment", 50, 103, a.Hash()) + if _, err := b.ApplyAccountedPurchases(103, b.Hash(), crypto.HashBytes([]byte("block-103")), []Purchase{duplicate}); err == nil { + t.Fatal("replayed node lost spent-order index") + } } func TestPurchaseDebitRollbackAndReplay(t *testing.T) { - f:=setup(t); s:=f.ledger(t) - p:=f.purchase(t,"order-a","payment-a",100,101,s.Hash()) - h:=crypto.HashBytes([]byte("block-101")) - next,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{p}) - if err!=nil { t.Fatal(err) } - if next.Remaining()!=f.c.SaleRemaining-100 || next.Sold()!=100 || !next.Seen(p.OrderHash) { t.Fatal("incorrect debit") } - if s.Seen(p.OrderHash) || s.Sold()!=0 || s.Remaining()!=f.c.SaleRemaining { t.Fatal("parent mutated") } - replay:=f.purchase(t,"order-a","another-payment",100,102,next.Hash()) - if _,err:=next.ApplyAccountedPurchases(102,next.Hash(),crypto.HashBytes([]byte("block-102")),[]Purchase{replay});err==nil { t.Fatal("order replay accepted") } - duplicatePayment:=f.purchase(t,"different-order","payment-a",100,102,next.Hash()) - if _,err:=next.ApplyAccountedPurchases(102,next.Hash(),crypto.HashBytes([]byte("block-102")),[]Purchase{duplicatePayment});err==nil { t.Fatal("payment replay accepted") } + f := setup(t) + s := f.ledger(t) + p := f.purchase(t, "order-a", "payment-a", 100, 101, s.Hash()) + h := crypto.HashBytes([]byte("block-101")) + next, err := s.ApplyAccountedPurchases(101, s.Hash(), h, []Purchase{p}) + if err != nil { + t.Fatal(err) + } + if next.Remaining() != f.c.SaleRemaining-100 || next.Sold() != 100 || !next.Seen(p.OrderHash) { + t.Fatal("incorrect debit") + } + if s.Seen(p.OrderHash) || s.Sold() != 0 || s.Remaining() != f.c.SaleRemaining { + t.Fatal("parent mutated") + } + replay := f.purchase(t, "order-a", "another-payment", 100, 102, next.Hash()) + if _, err := next.ApplyAccountedPurchases(102, next.Hash(), crypto.HashBytes([]byte("block-102")), []Purchase{replay}); err == nil { + t.Fatal("order replay accepted") + } + duplicatePayment := f.purchase(t, "different-order", "payment-a", 100, 102, next.Hash()) + if _, err := next.ApplyAccountedPurchases(102, next.Hash(), crypto.HashBytes([]byte("block-102")), []Purchase{duplicatePayment}); err == nil { + t.Fatal("payment replay accepted") + } // A branch rolled back to its immutable parent may consume the orphaned // order again. Neither the order nor payment index leaks from that branch. - branch,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("alternate-101")),[]Purchase{p}) - if err!=nil || branch.Remaining()!=next.Remaining() { t.Fatalf("rollback branch failed: %v",err) } - if _,err:=next.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{p});err==nil { t.Fatal("canonical block replay accepted") } + branch, err := s.ApplyAccountedPurchases(101, s.Hash(), crypto.HashBytes([]byte("alternate-101")), []Purchase{p}) + if err != nil || branch.Remaining() != next.Remaining() { + t.Fatalf("rollback branch failed: %v", err) + } + if _, err := next.ApplyAccountedPurchases(101, s.Hash(), h, []Purchase{p}); err == nil { + t.Fatal("canonical block replay accepted") + } } func TestBatchIsAtomicAndBounded(t *testing.T) { - f:=setup(t); s:=f.ledger(t); h:=crypto.HashBytes([]byte("batch")) - a:=f.purchase(t,"a","pa",100,101,s.Hash()) - b:=f.purchase(t,"b","pb",f.c.SaleRemaining,101,s.Hash()) - if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{a,b});err==nil { t.Fatal("overspend accepted") } - if s.Seen(a.OrderHash)||s.Sold()!=0 { t.Fatal("failed batch mutated parent") } - b.Amount=f.c.SaleRemaining-100; b.Signature,_=f.merchant.Sign(b.Message()) - next,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{a,b}) - if err!=nil||next.Remaining()!=0||next.Sold()!=f.c.SaleRemaining { t.Fatalf("exact exhaustion failed: %v",err) } - c:=f.purchase(t,"c","pc",1,102,next.Hash()) - if _,err:=next.ApplyAccountedPurchases(102,next.Hash(),crypto.HashBytes([]byte("next")),[]Purchase{c});err==nil { t.Fatal("empty allocation spent") } - if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),h,[]Purchase{a,a});err==nil { t.Fatal("duplicate within batch accepted") } + f := setup(t) + s := f.ledger(t) + h := crypto.HashBytes([]byte("batch")) + a := f.purchase(t, "a", "pa", 100, 101, s.Hash()) + b := f.purchase(t, "b", "pb", f.c.SaleRemaining, 101, s.Hash()) + if _, err := s.ApplyAccountedPurchases(101, s.Hash(), h, []Purchase{a, b}); err == nil { + t.Fatal("overspend accepted") + } + if s.Seen(a.OrderHash) || s.Sold() != 0 { + t.Fatal("failed batch mutated parent") + } + b.Amount = f.c.SaleRemaining - 100 + b.Signature, _ = f.merchant.Sign(b.Message()) + next, err := s.ApplyAccountedPurchases(101, s.Hash(), h, []Purchase{a, b}) + if err != nil || next.Remaining() != 0 || next.Sold() != f.c.SaleRemaining { + t.Fatalf("exact exhaustion failed: %v", err) + } + c := f.purchase(t, "c", "pc", 1, 102, next.Hash()) + if _, err := next.ApplyAccountedPurchases(102, next.Hash(), crypto.HashBytes([]byte("next")), []Purchase{c}); err == nil { + t.Fatal("empty allocation spent") + } + if _, err := s.ApplyAccountedPurchases(101, s.Hash(), h, []Purchase{a, a}); err == nil { + t.Fatal("duplicate within batch accepted") + } } func TestPurchaseAuthorizationBindings(t *testing.T) { - for _,tc:=range []struct{ - name string + for _, tc := range []struct { + name string change func(*Purchase) }{ - {"amount",func(p *Purchase){p.Amount++}}, - {"recipient",func(p *Purchase){p.Recipient="not-an-address"}}, - {"height",func(p *Purchase){p.Height++}}, - {"parent",func(p *Purchase){p.Parent=crypto.HashBytes([]byte("wrong"))}}, - {"genesis",func(p *Purchase){p.Genesis=crypto.HashBytes([]byte("other"))}}, - {"order",func(p *Purchase){p.OrderHash=crypto.HashBytes([]byte("other"))}}, - {"payment",func(p *Purchase){p.PaymentHash=crypto.HashBytes([]byte("other"))}}, - {"signature",func(p *Purchase){p.Signature[0]^=1}}, - {"unsigned",func(p *Purchase){p.Signature=nil}}, - }{ - t.Run(tc.name,func(t *testing.T){ - f:=setup(t); s:=f.ledger(t); p:=f.purchase(t,"a","p",100,101,s.Hash());tc.change(&p) - if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("block")),[]Purchase{p});err==nil {t.Fatal("tampered purchase accepted")} + {"amount", func(p *Purchase) { p.Amount++ }}, + {"recipient", func(p *Purchase) { p.Recipient = "not-an-address" }}, + {"height", func(p *Purchase) { p.Height++ }}, + {"parent", func(p *Purchase) { p.Parent = crypto.HashBytes([]byte("wrong")) }}, + {"genesis", func(p *Purchase) { p.Genesis = crypto.HashBytes([]byte("other")) }}, + {"order", func(p *Purchase) { p.OrderHash = crypto.HashBytes([]byte("other")) }}, + {"payment", func(p *Purchase) { p.PaymentHash = crypto.HashBytes([]byte("other")) }}, + {"signature", func(p *Purchase) { p.Signature[0] ^= 1 }}, + {"unsigned", func(p *Purchase) { p.Signature = nil }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := setup(t) + s := f.ledger(t) + p := f.purchase(t, "a", "p", 100, 101, s.Hash()) + tc.change(&p) + if _, err := s.ApplyAccountedPurchases(101, s.Hash(), crypto.HashBytes([]byte("block")), []Purchase{p}); err == nil { + t.Fatal("tampered purchase accepted") + } }) } } func TestRecipientMustBeOnAttestedNetwork(t *testing.T) { - f:=setup(t); s:=f.ledger(t) - wallet,err:=crypto.GenerateWalletKeys() - if err!=nil {t.Fatal(err)} - p:=f.purchase(t,"order","payment",100,101,s.Hash()) - p.Recipient=crypto.AddressFromKeys(crypto.TestnetByte,wallet) - p.Signature,err=f.merchant.Sign(p.Message()) - if err!=nil {t.Fatal(err)} - if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("block")),[]Purchase{p});err==nil {t.Fatal("signed payout to another network accepted")} + f := setup(t) + s := f.ledger(t) + wallet, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + p := f.purchase(t, "order", "payment", 100, 101, s.Hash()) + p.Recipient = crypto.AddressFromKeys(crypto.TestnetByte, wallet) + p.Signature, err = f.merchant.Sign(p.Message()) + if err != nil { + t.Fatal(err) + } + if _, err := s.ApplyAccountedPurchases(101, s.Hash(), crypto.HashBytes([]byte("block")), []Purchase{p}); err == nil { + t.Fatal("signed payout to another network accepted") + } } func TestWitnessAuthorityIsCopied(t *testing.T) { - f:=setup(t); s:=f.ledger(t) - p:=f.purchase(t,"a","p",100,101,s.Hash()) - for i:=range f.c.SaleAuthority {f.c.SaleAuthority[i]=0} - if _,err:=s.ApplyAccountedPurchases(101,s.Hash(),crypto.HashBytes([]byte("block")),[]Purchase{p});err!=nil {t.Fatalf("caller mutated internal authority: %v",err)} + f := setup(t) + s := f.ledger(t) + p := f.purchase(t, "a", "p", 100, 101, s.Hash()) + for i := range f.c.SaleAuthority { + f.c.SaleAuthority[i] = 0 + } + if _, err := s.ApplyAccountedPurchases(101, s.Hash(), crypto.HashBytes([]byte("block")), []Purchase{p}); err != nil { + t.Fatalf("caller mutated internal authority: %v", err) + } } diff --git a/sale/canonical_test.go b/sale/canonical_test.go index 921fea1..066ba79 100644 --- a/sale/canonical_test.go +++ b/sale/canonical_test.go @@ -2,9 +2,10 @@ package sale import ( "bytes" + "testing" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" - "testing" ) func TestCanonicalSaleBindsOutputAuthorityBudgetAndReplayIdentifiers(t *testing.T) { diff --git a/store/finality.go b/store/finality.go index c94f1a7..c6fcf5e 100644 --- a/store/finality.go +++ b/store/finality.go @@ -15,8 +15,9 @@ type FinalityVote struct { Signature []byte `json:"signature"` } -// FinalityCertificate is an authentic quorum proof for one canonical block. -// It is never inferred from a tip, snapshot, or checkpoint. +// FinalityCertificate stores votes for one canonical block. Consensus checks +// their signatures and independently authorized membership before accepting a +// quorum. Loading this record alone does not authenticate a historical committee. type FinalityCertificate struct { Version uint8 `json:"version"` Height uint64 `json:"height"` diff --git a/store/finality_migration.go b/store/finality_migration.go new file mode 100644 index 0000000..e4fa5a7 --- /dev/null +++ b/store/finality_migration.go @@ -0,0 +1,101 @@ +package store + +import ( + "bytes" + "encoding/binary" + "fmt" + + "github.com/aperod/aperod/crypto" +) + +const FinalityCommitteeMigrationVersion uint8 = 1 + +const finalityCommitteeMigrationDomain = "aperod/finality-committee-migration/v1\x00" + +type FinalityMigrationApproval struct { + Validator crypto.ValidatorPubKey `json:"validator"` + Signature []byte `json:"signature"` +} + +// FinalityCommitteeMigration is a one-certificate, validator-authorized +// historical committee attestation. It does not add votes to, rewrite, or +// weaken the quorum rules of the original certificate. +type FinalityCommitteeMigration struct { + Version uint8 `json:"version"` + Genesis crypto.Hash32 `json:"genesis"` + CertificateHeight uint64 `json:"certificate_height"` + CertificateBlockHash crypto.Hash32 `json:"certificate_block_hash"` + ApprovalHeight uint64 `json:"approval_height"` + ApprovalBlockHash crypto.Hash32 `json:"approval_block_hash"` + BFTThresholdPPM uint32 `json:"bft_threshold_ppm"` + HistoricalCommittee []crypto.ValidatorPubKey `json:"historical_committee"` + Approvals []FinalityMigrationApproval `json:"approvals"` +} + +// SigningMessage returns the domain-separated digest approved by migration +// authorities. Committee and approval order must be canonical to avoid +// multiple encodings of the same migration. +func (m FinalityCommitteeMigration) SigningMessage() (crypto.Hash32, error) { + if m.Version != FinalityCommitteeMigrationVersion || + m.Genesis == (crypto.Hash32{}) || + m.CertificateHeight == 0 || + m.CertificateBlockHash == (crypto.Hash32{}) || + m.ApprovalHeight < m.CertificateHeight || + m.ApprovalBlockHash == (crypto.Hash32{}) || + m.BFTThresholdPPM == 0 || + m.BFTThresholdPPM >= 1_000_000 || + len(m.HistoricalCommittee) == 0 || + len(m.HistoricalCommittee) > 256 { + return crypto.Hash32{}, fmt.Errorf("finality migration: invalid header or committee") + } + + var payload bytes.Buffer + payload.WriteString(finalityCommitteeMigrationDomain) + payload.WriteByte(m.Version) + payload.Write(m.Genesis[:]) + if err := binary.Write(&payload, binary.BigEndian, m.CertificateHeight); err != nil { + return crypto.Hash32{}, err + } + payload.Write(m.CertificateBlockHash[:]) + if err := binary.Write(&payload, binary.BigEndian, m.ApprovalHeight); err != nil { + return crypto.Hash32{}, err + } + payload.Write(m.ApprovalBlockHash[:]) + if err := binary.Write(&payload, binary.BigEndian, m.BFTThresholdPPM); err != nil { + return crypto.Hash32{}, err + } + if err := binary.Write(&payload, binary.BigEndian, uint32(len(m.HistoricalCommittee))); err != nil { + return crypto.Hash32{}, err + } + for i, validator := range m.HistoricalCommittee { + if len(validator) != 32 || isZeroFinalityKey(validator) { + return crypto.Hash32{}, fmt.Errorf("finality migration: invalid historical validator key") + } + if i > 0 && bytes.Compare(m.HistoricalCommittee[i-1], validator) >= 0 { + return crypto.Hash32{}, fmt.Errorf("finality migration: historical committee must be sorted and unique") + } + payload.Write(validator) + } + for i, approval := range m.Approvals { + if len(approval.Validator) != 32 || isZeroFinalityKey(approval.Validator) || + len(approval.Signature) != 64 { + return crypto.Hash32{}, fmt.Errorf("finality migration: malformed approval") + } + if i > 0 && bytes.Compare(m.Approvals[i-1].Validator, approval.Validator) >= 0 { + return crypto.Hash32{}, fmt.Errorf("finality migration: approvals must be sorted and unique") + } + } + return crypto.HashBytes(payload.Bytes()), nil +} + +func isZeroFinalityKey(key crypto.ValidatorPubKey) bool { + if len(key) != 32 { + return false + } + for _, b := range key { + if b != 0 { + return false + } + } + return true +} diff --git a/store/finality_migration_test.go b/store/finality_migration_test.go new file mode 100644 index 0000000..43605f5 --- /dev/null +++ b/store/finality_migration_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "bytes" + "testing" + + "github.com/aperod/aperod/crypto" +) + +func TestFinalityCommitteeMigrationSigningMessageBindsCanonicalCommittee(t *testing.T) { + _, first, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + _, second, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + committee := []crypto.ValidatorPubKey{first, second} + if bytes.Compare(committee[0], committee[1]) > 0 { + committee[0], committee[1] = committee[1], committee[0] + } + migration := FinalityCommitteeMigration{ + Version: FinalityCommitteeMigrationVersion, + Genesis: crypto.Hash32{1}, + CertificateHeight: 10, + CertificateBlockHash: crypto.Hash32{2}, + ApprovalHeight: 11, + ApprovalBlockHash: crypto.Hash32{3}, + BFTThresholdPPM: 667_000, + HistoricalCommittee: committee, + Approvals: []FinalityMigrationApproval{ + {Validator: first, Signature: make([]byte, 64)}, + }, + } + + message, err := migration.SigningMessage() + if err != nil { + t.Fatal(err) + } + changed := migration + changed.ApprovalBlockHash = crypto.Hash32{4} + changedMessage, err := changed.SigningMessage() + if err != nil { + t.Fatal(err) + } + if changedMessage == message { + t.Fatal("approval anchor was not bound into the signed migration message") + } + + unsorted := migration + unsorted.HistoricalCommittee = []crypto.ValidatorPubKey{committee[1], committee[0]} + if _, err := unsorted.SigningMessage(); err == nil { + t.Fatal("non-canonical historical committee order was accepted") + } +} diff --git a/store/full_height_repair_test.go b/store/full_height_repair_test.go index 342da2d..6d84b14 100644 --- a/store/full_height_repair_test.go +++ b/store/full_height_repair_test.go @@ -441,7 +441,7 @@ func TestRepairAllHeightIndex_PersistenceAfterRepair(t *testing.T) { // TestRepairAllHeightIndex_TwoBodiesAtOneHeight verifies that RepairAllHeightIndex // does NOT overwrite a canonical h/ entry when two parseable block bodies // both claim the same height. Without a canonical-chain traversal we cannot -// select between them; the correct behaviour is to leave the existing non-zero +// select between them; the correct behavior is to leave the existing non-zero // h/ entry intact and count the height as OK if it resolves to a valid body. func TestRepairAllHeightIndex_TwoBodiesAtOneHeight(t *testing.T) { dir := t.TempDir() diff --git a/store/genesis_vesting.go b/store/genesis_vesting.go index 0d1bce9..6d764ee 100644 --- a/store/genesis_vesting.go +++ b/store/genesis_vesting.go @@ -24,21 +24,21 @@ const GenesisNominalTotal uint64 = 10_000_000_000 * lpod.Unit // tip selection therefore rolls back both payments and their reserve debit. // Heights, rather than local job flags, provide restart/downtime idempotency. type GenesisVestingState struct { - Basis string `json:"basis"` - Genesis crypto.Hash32 `json:"genesis"` - FundingHeight uint64 `json:"funding_height"` - FundingParent crypto.Hash32 `json:"funding_parent"` - Recipient crypto.Address `json:"recipient"` - FundingDebit uint64 `json:"funding_debit_napro,string"` - SourceSnapshot crypto.Hash32 `json:"source_snapshot"` - FundingProof *GenesisVestingFunding `json:"funding_proof"` - OriginalNonCirculating uint64 `json:"original_nominal_noncirculating_napro,string"` - OtherNonCirculating uint64 `json:"other_nominal_noncirculating_napro,string"` - Remaining uint64 `json:"remaining_napro,string"` - Paid uint64 `json:"paid_napro,string"` - PaidHeights [10]uint64 `json:"paid_heights"` - LiquidityRecipient crypto.Address `json:"liquidity_recipient,omitempty"` - LiquidityPaid uint64 `json:"liquidity_paid_napro,string,omitempty"` + Basis string `json:"basis"` + Genesis crypto.Hash32 `json:"genesis"` + FundingHeight uint64 `json:"funding_height"` + FundingParent crypto.Hash32 `json:"funding_parent"` + Recipient crypto.Address `json:"recipient"` + FundingDebit uint64 `json:"funding_debit_napro,string"` + SourceSnapshot crypto.Hash32 `json:"source_snapshot"` + FundingProof *GenesisVestingFunding `json:"funding_proof"` + OriginalNonCirculating uint64 `json:"original_nominal_noncirculating_napro,string"` + OtherNonCirculating uint64 `json:"other_nominal_noncirculating_napro,string"` + Remaining uint64 `json:"remaining_napro,string"` + Paid uint64 `json:"paid_napro,string"` + PaidHeights [10]uint64 `json:"paid_heights"` + LiquidityRecipient crypto.Address `json:"liquidity_recipient,omitempty"` + LiquidityPaid uint64 `json:"liquidity_paid_napro,string,omitempty"` } func GenesisVestingTranche(index int) (int64, uint64, error) { @@ -102,16 +102,26 @@ func nominalNonCirculatingBound(a *LPoDAllocation) (uint64, error) { } // Guardian's 1B was a reclassification INSIDE circulating, not an // additional allocation. Validator remaining is outside that circulation. - return GenesisNominalTotal-circulating-a.InitialValidatorRemaining, nil + return GenesisNominalTotal - circulating - a.InitialValidatorRemaining, nil } func (v *GenesisVestingState) validateSource(a *LPoDAllocation, height uint64) error { - if v == nil { return nil } - if a == nil { return fmt.Errorf("genesis vesting: missing allocation") } - if err := v.Validate(a.Genesis, height); err != nil { return err } - if err := v.FundingProof.validate(a, v.FundingHeight); err != nil { return err } + if v == nil { + return nil + } + if a == nil { + return fmt.Errorf("genesis vesting: missing allocation") + } + if err := v.Validate(a.Genesis, height); err != nil { + return err + } + if err := v.FundingProof.validate(a, v.FundingHeight); err != nil { + return err + } bound, err := nominalNonCirculatingBound(a) - if err != nil { return err } + if err != nil { + return err + } if v.OriginalNonCirculating != bound || v.SourceSnapshot != a.SnapshotRoot || v.FundingHeight <= a.FundingHeight { return fmt.Errorf("genesis vesting: source differs from signed genesis accounting") @@ -129,9 +139,13 @@ func previewGenesisVesting(before, next *LPoDCheckpoint, r *LPoDSettlement, heig return fmt.Errorf("genesis vesting: funded v3 canonical nominal allocation required") } if before.GenesisVesting == nil { - if err := r.GenesisVestingFunding.verifyQuorum(before.Allocation, height, r.Stake); err != nil { return err } + if err := r.GenesisVestingFunding.verifyQuorum(before.Allocation, height, r.Stake); err != nil { + return err + } bound, err := nominalNonCirculatingBound(before.Allocation) - if err != nil { return err } + if err != nil { + return err + } if height != r.GenesisVestingActivationHeight || height <= before.Allocation.FundingHeight || bound < GenesisVestingReserve || r.Parent == (crypto.Hash32{}) { return fmt.Errorf("genesis vesting: invalid activation or insufficient unallocated genesis budget") @@ -144,8 +158,8 @@ func previewGenesisVesting(before, next *LPoDCheckpoint, r *LPoDSettlement, heig FundingHeight: height, FundingParent: r.Parent, Recipient: GenesisVestingRecipient, FundingDebit: GenesisVestingReserve, Remaining: GenesisVestingReserve, SourceSnapshot: before.Allocation.SnapshotRoot, OriginalNonCirculating: bound, - FundingProof: r.GenesisVestingFunding, - OtherNonCirculating: bound-GenesisVestingReserve, + FundingProof: r.GenesisVestingFunding, + OtherNonCirculating: bound - GenesisVestingReserve, } if r.GenesisVestingFunding.Version == 2 { next.GenesisVesting.LiquidityRecipient = r.GenesisVestingFunding.LiquidityRecipient @@ -189,4 +203,4 @@ func (v *GenesisVestingState) paymentAt(height uint64) uint64 { } } return amount -} \ No newline at end of file +} diff --git a/store/genesis_vesting_funding.go b/store/genesis_vesting_funding.go index 3312b58..4054286 100644 --- a/store/genesis_vesting_funding.go +++ b/store/genesis_vesting_funding.go @@ -21,22 +21,24 @@ const GenesisLiquidityAmount uint64 = 300_000_000 * 100_000_000 // the Development Fund reserve is unissued and excludes Guardian, validator, // user balances and every other allocation. Historical proof remains false. type GenesisVestingFunding struct { - Version uint32 `json:"version"` - Genesis crypto.Hash32 `json:"genesis"` - Snapshot crypto.Hash32 `json:"nominal_snapshot"` - Height uint64 `json:"activation_height"` - Recipient crypto.Address `json:"recipient"` - Reserve uint64 `json:"exclusive_unissued_development_napro,string"` - OtherAllocations uint64 `json:"other_genesis_allocations_napro,string"` - Trust string `json:"trust_assumption"` - LiquidityRecipient crypto.Address `json:"liquidity_recipient,omitempty"` - LiquidityAmount uint64 `json:"liquidity_napro,string,omitempty"` - LiquidityTrust string `json:"liquidity_trust_assumption,omitempty"` - Attestations []LPoDAttestation `json:"attestations"` + Version uint32 `json:"version"` + Genesis crypto.Hash32 `json:"genesis"` + Snapshot crypto.Hash32 `json:"nominal_snapshot"` + Height uint64 `json:"activation_height"` + Recipient crypto.Address `json:"recipient"` + Reserve uint64 `json:"exclusive_unissued_development_napro,string"` + OtherAllocations uint64 `json:"other_genesis_allocations_napro,string"` + Trust string `json:"trust_assumption"` + LiquidityRecipient crypto.Address `json:"liquidity_recipient,omitempty"` + LiquidityAmount uint64 `json:"liquidity_napro,string,omitempty"` + LiquidityTrust string `json:"liquidity_trust_assumption,omitempty"` + Attestations []LPoDAttestation `json:"attestations"` } func (f *GenesisVestingFunding) Message() crypto.Hash32 { - if f == nil { return crypto.Hash32{} } + if f == nil { + return crypto.Hash32{} + } c := *f c.Attestations = nil raw, _ := json.Marshal(c) @@ -83,14 +85,20 @@ func (f *GenesisVestingFunding) validate(a *LPoDAllocation, height uint64) error } func (f *GenesisVestingFunding) verifyQuorum(a *LPoDAllocation, height uint64, stake map[string]LPoDValidatorStake) error { - if err := f.validate(a, height); err != nil { return err } + if err := f.validate(a, height); err != nil { + return err + } total, signed := new(big.Int), new(big.Int) for _, s := range stake { - if s.Active { total.Add(total, new(big.Int).SetUint64(s.Amount)) } + if s.Active { + total.Add(total, new(big.Int).SetUint64(s.Amount)) + } } for _, attestation := range f.Attestations { s, ok := stake[attestation.Validator.Hex()] - if !ok || !s.Active || s.Amount == 0 { return fmt.Errorf("genesis vesting: funding signer is not an active validator") } + if !ok || !s.Active || s.Amount == 0 { + return fmt.Errorf("genesis vesting: funding signer is not an active validator") + } signed.Add(signed, new(big.Int).SetUint64(s.Amount)) } if total.Sign() == 0 || new(big.Int).Mul(signed, big.NewInt(3)).Cmp(new(big.Int).Mul(total, big.NewInt(2))) <= 0 { @@ -101,11 +109,19 @@ func (f *GenesisVestingFunding) verifyQuorum(a *LPoDAllocation, height uint64, s func LoadGenesisVestingFunding(path string, height uint64) (*GenesisVestingFunding, error) { raw, err := os.ReadFile(path) - if err != nil { return nil, err } - if len(raw) > 256*1024 { return nil, fmt.Errorf("genesis vesting: funding witness too large") } + if err != nil { + return nil, err + } + if len(raw) > 256*1024 { + return nil, fmt.Errorf("genesis vesting: funding witness too large") + } var f GenesisVestingFunding - if err := json.Unmarshal(raw, &f); err != nil { return nil, err } - if f.Height != height || height == 0 { return nil, fmt.Errorf("genesis vesting: funding height mismatch") } + if err := json.Unmarshal(raw, &f); err != nil { + return nil, err + } + if f.Height != height || height == 0 { + return nil, fmt.Errorf("genesis vesting: funding height mismatch") + } return &f, nil } @@ -113,7 +129,9 @@ func LoadGenesisVestingFunding(path string, height uint64) (*GenesisVestingFundi // starting consensus; do not silently run until the first production failure. func (d *DB) CheckGenesisVestingConfig(height uint64, proof *GenesisVestingFunding) error { c, err := d.LoadLPoDCheckpoint() - if err != nil { return err } + if err != nil { + return err + } if c != nil && c.GenesisVesting != nil { if height != c.GenesisVesting.FundingHeight || proof == nil || proof.Message() != c.GenesisVesting.FundingProof.Message() { @@ -122,21 +140,37 @@ func (d *DB) CheckGenesisVestingConfig(height uint64, proof *GenesisVestingFundi return proof.validate(c.Allocation, height) } if height == 0 { - if proof != nil { return fmt.Errorf("genesis vesting: funding file without activation") } + if proof != nil { + return fmt.Errorf("genesis vesting: funding file without activation") + } return nil } -saleState, err := d.LoadSaleState() -if err != nil { return err } -if saleState != nil { -return fmt.Errorf("genesis vesting: active sales require a versioned shared reserve transition") -} + saleState, err := d.LoadSaleState() + if err != nil { + return err + } + if saleState != nil { + return fmt.Errorf("genesis vesting: active sales require a versioned shared reserve transition") + } _, tipHeight, err := d.GetTip() - if err != nil { return err } - if tipHeight >= height { return fmt.Errorf("genesis vesting: activation already missed") } - if c == nil { return fmt.Errorf("genesis vesting: canonical v3 funding prerequisite missing") } - if err := proof.validate(c.Allocation, height); err != nil { return err } + if err != nil { + return err + } + if tipHeight >= height { + return fmt.Errorf("genesis vesting: activation already missed") + } + if c == nil { + return fmt.Errorf("genesis vesting: canonical v3 funding prerequisite missing") + } + if err := proof.validate(c.Allocation, height); err != nil { + return err + } bound, err := nominalNonCirculatingBound(c.Allocation) - if err != nil { return err } - if bound < GenesisVestingReserve { return fmt.Errorf("genesis vesting: original nominal cap exhausted") } + if err != nil { + return err + } + if bound < GenesisVestingReserve { + return fmt.Errorf("genesis vesting: original nominal cap exhausted") + } return nil -} \ No newline at end of file +} diff --git a/store/genesis_vesting_test.go b/store/genesis_vesting_test.go index 5da27e0..4620245 100644 --- a/store/genesis_vesting_test.go +++ b/store/genesis_vesting_test.go @@ -13,23 +13,27 @@ import ( func vestingFixture(t *testing.T) (*LPoDCheckpoint, *LPoDSettlement) { t.Helper() key, pub, err := crypto.GenerateValidatorKey() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } g := crypto.HashBytes([]byte("vesting-test-genesis")) - snapshot := &LPoDNominalSnapshot{Basis: LPoDV3NominalBasis, NominalCirculatingBefore: 6_000_000_000*lpod.Unit, - EligibleNominal: lpod.InitialNAPRO, ValidatorRemaining: 2_000_000_000*lpod.Unit, EligibilityRule: LPoDV3EligibilityRule} + snapshot := &LPoDNominalSnapshot{Basis: LPoDV3NominalBasis, NominalCirculatingBefore: 6_000_000_000 * lpod.Unit, + EligibleNominal: lpod.InitialNAPRO, ValidatorRemaining: 2_000_000_000 * lpod.Unit, EligibilityRule: LPoDV3EligibilityRule} proof := &GenesisVestingFunding{Version: 1, Genesis: g, Snapshot: snapshot.Root(), Height: 11, Recipient: GenesisVestingRecipient, Reserve: GenesisVestingReserve, - OtherAllocations: GenesisNominalTotal-GenesisVestingReserve, Trust: GenesisVestingTrust} + OtherAllocations: GenesisNominalTotal - GenesisVestingReserve, Trust: GenesisVestingTrust} signature, err := key.Sign(proof.Message()) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } proof.Attestations = []LPoDAttestation{{Validator: pub, Signature: signature}} return &LPoDCheckpoint{State: lpod.State{LastHeight: 10}, - Allocation: &LPoDAllocation{Version: 3, Genesis: g, FundingHeight: 2, Remaining: 0, - NominalEligible: lpod.InitialNAPRO, InitialValidatorRemaining: snapshot.ValidatorRemaining, - NominalSnapshot: snapshot, SnapshotRoot: snapshot.Root()}}, + Allocation: &LPoDAllocation{Version: 3, Genesis: g, FundingHeight: 2, Remaining: 0, + NominalEligible: lpod.InitialNAPRO, InitialValidatorRemaining: snapshot.ValidatorRemaining, + NominalSnapshot: snapshot, SnapshotRoot: snapshot.Root()}}, &LPoDSettlement{Parent: crypto.HashBytes([]byte("canonical-parent")), PositionProtocol: true, GenesisVestingActivationHeight: 11, GenesisVestingFunding: proof, - Stake: map[string]LPoDValidatorStake{pub.Hex(): {Amount: 100_000*lpod.Unit, Active: true}}} + Stake: map[string]LPoDValidatorStake{pub.Hex(): {Amount: 100_000 * lpod.Unit, Active: true}}} } func vestingStep(t *testing.T, before *LPoDCheckpoint, r *LPoDSettlement, height uint64) *LPoDCheckpoint { @@ -38,14 +42,16 @@ func vestingStep(t *testing.T, before *LPoDCheckpoint, r *LPoDSettlement, height a := *before.Allocation next.Allocation = &a next.State.LastHeight = height - if err := previewGenesisVesting(before, &next, r, height); err != nil { t.Fatal(err) } + if err := previewGenesisVesting(before, &next, r, height); err != nil { + t.Fatal(err) + } return &next } func TestGenesisVestingExactBoundaryAndConservation(t *testing.T) { before, r := vestingFixture(t) deadline, amount, _ := GenesisVestingTranche(0) - r.Timestamp = deadline-1 + r.Timestamp = deadline - 1 funded := vestingStep(t, before, r, 11) if funded.GenesisVesting.Paid != 0 || funded.Allocation.Remaining != 0 || funded.GenesisVesting.OtherNonCirculating != 2_000_000_000*lpod.Unit-GenesisVestingReserve || @@ -63,14 +69,18 @@ func TestGenesisVestingExactBoundaryAndConservation(t *testing.T) { repeated.Allocation.Remaining != paid.Allocation.Remaining { t.Fatal("duplicate payment or duplicate reserve debit") } - if funded.GenesisVesting.Paid != 0 { t.Fatal("preview mutated the parent") } + if funded.GenesisVesting.Paid != 0 { + t.Fatal("preview mutated the parent") + } } func TestGenesisVestingDowntimeCatchupAndFinalTranche(t *testing.T) { before, r := vestingFixture(t) r.Timestamp, _, _ = GenesisVestingTranche(2) next := vestingStep(t, before, r, 11) - if next.GenesisVesting.Paid != 3*84_900_000*lpod.Unit { t.Fatal("missed annual tranches not caught up") } + if next.GenesisVesting.Paid != 3*84_900_000*lpod.Unit { + t.Fatal("missed annual tranches not caught up") + } r.Timestamp, _, _ = GenesisVestingTranche(9) last := vestingStep(t, next, r, 12) if last.GenesisVesting.Paid != GenesisVestingReserve || last.GenesisVesting.Remaining != 0 || @@ -78,7 +88,9 @@ func TestGenesisVestingDowntimeCatchupAndFinalTranche(t *testing.T) { t.Fatal("final 84.91M tranche or finite reserve is wrong") } again := vestingStep(t, last, r, 13) - if again.GenesisVesting.paymentAt(13) != 0 { t.Fatal("exhausted schedule paid again") } + if again.GenesisVesting.paymentAt(13) != 0 { + t.Fatal("exhausted schedule paid again") + } } func TestGenesisVestingRejectsUnsafeFundingAndMutation(t *testing.T) { @@ -88,15 +100,21 @@ func TestGenesisVestingRejectsUnsafeFundingAndMutation(t *testing.T) { height := uint64(11) switch name { case "insufficient": - before.Allocation.NominalSnapshot.NominalCirculatingBefore = 7_500_000_000*lpod.Unit + before.Allocation.NominalSnapshot.NominalCirculatingBefore = 7_500_000_000 * lpod.Unit before.Allocation.SnapshotRoot = before.Allocation.NominalSnapshot.Root() - case "legacy": before.Allocation.Version = 2 - case "late": height = 12 - case "parent": r.Parent = crypto.Hash32{} - case "non-position": r.PositionProtocol = false + case "legacy": + before.Allocation.Version = 2 + case "late": + height = 12 + case "parent": + r.Parent = crypto.Hash32{} + case "non-position": + r.PositionProtocol = false } next := *before - if previewGenesisVesting(before, &next, r, height) == nil { t.Fatal("unsafe funding accepted") } + if previewGenesisVesting(before, &next, r, height) == nil { + t.Fatal("unsafe funding accepted") + } }) } before, r := vestingFixture(t) @@ -104,14 +122,20 @@ func TestGenesisVestingRejectsUnsafeFundingAndMutation(t *testing.T) { for _, h := range []uint64{0, 12} { r.GenesisVestingActivationHeight = h next := *funded - if previewGenesisVesting(funded, &next, r, 12) == nil { t.Fatal("funding fork changed after commit") } + if previewGenesisVesting(funded, &next, r, 12) == nil { + t.Fatal("funding fork changed after commit") + } } corrupt := *funded.GenesisVesting corrupt.PaidHeights[1] = 11 - if corrupt.Validate(funded.Allocation.Genesis, 11) == nil { t.Fatal("skipped tranche accepted") } + if corrupt.Validate(funded.Allocation.Genesis, 11) == nil { + t.Fatal("skipped tranche accepted") + } corrupt = *funded.GenesisVesting corrupt.Remaining-- - if corrupt.Validate(funded.Allocation.Genesis, 11) == nil { t.Fatal("lost reserve unit accepted") } + if corrupt.Validate(funded.Allocation.Genesis, 11) == nil { + t.Fatal("lost reserve unit accepted") + } } func TestGenesisVestingFundingRequiresPurposeAndCanonicalQuorum(t *testing.T) { @@ -119,26 +143,41 @@ func TestGenesisVestingFundingRequiresPurposeAndCanonicalQuorum(t *testing.T) { t.Run(name, func(t *testing.T) { before, r := vestingFixture(t) key, pub, err := crypto.GenerateValidatorKey() - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } p := *r.GenesisVestingFunding r.GenesisVestingFunding = &p - r.Stake = map[string]LPoDValidatorStake{pub.Hex(): {Amount: 100_000*lpod.Unit, Active: true}} + r.Stake = map[string]LPoDValidatorStake{pub.Hex(): {Amount: 100_000 * lpod.Unit, Active: true}} switch name { - case "wrong-purpose": p.Trust = "unrestricted-mint" - case "foreign-genesis": p.Genesis = crypto.HashBytes([]byte("foreign-chain")) - case "inactive": r.Stake[pub.Hex()] = LPoDValidatorStake{Amount: 100_000*lpod.Unit} + case "wrong-purpose": + p.Trust = "unrestricted-mint" + case "foreign-genesis": + p.Genesis = crypto.HashBytes([]byte("foreign-chain")) + case "inactive": + r.Stake[pub.Hex()] = LPoDValidatorStake{Amount: 100_000 * lpod.Unit} case "insufficient-quorum": _, other, err := crypto.GenerateValidatorKey() - if err != nil { t.Fatal(err) } - r.Stake[other.Hex()] = LPoDValidatorStake{Amount: 200_000*lpod.Unit, Active: true} + if err != nil { + t.Fatal(err) + } + r.Stake[other.Hex()] = LPoDValidatorStake{Amount: 200_000 * lpod.Unit, Active: true} } signature, err := key.Sign(p.Message()) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } p.Attestations = []LPoDAttestation{{Validator: pub, Signature: signature}} - if name == "bad-signature" { p.Attestations[0].Signature[0] ^= 1 } - if name == "missing" { r.GenesisVestingFunding = nil } + if name == "bad-signature" { + p.Attestations[0].Signature[0] ^= 1 + } + if name == "missing" { + r.GenesisVestingFunding = nil + } next := *before - if previewGenesisVesting(before, &next, r, 11) == nil { t.Fatal("unauthorized source declaration accepted") } + if previewGenesisVesting(before, &next, r, 11) == nil { + t.Fatal("unauthorized source declaration accepted") + } }) } -} \ No newline at end of file +} diff --git a/store/leveldb.go b/store/leveldb.go index 6ca65a9..7e49a60 100644 --- a/store/leveldb.go +++ b/store/leveldb.go @@ -11,11 +11,12 @@ import ( "path/filepath" "sync" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" "github.com/syndtr/goleveldb/leveldb/util" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // Key prefixes separate different namespaces in the single LevelDB instance. @@ -37,12 +38,14 @@ type DB struct { db *leveldb.DB path string canonicalMu sync.Mutex -ringMu sync.Mutex // serializes canonical rm/ and dense sampling-index writes + ringMu sync.Mutex // serializes canonical rm/ and dense sampling-index writes } // Open opens or creates a LevelDB database at path. func Open(path string) (*DB, error) { - if err := CheckRingMaintenanceFence(path); err != nil { return nil, err } + if err := CheckRingMaintenanceFence(path); err != nil { + return nil, err + } opts := &opt.Options{ BlockCacheCapacity: 32 * opt.MiB, WriteBuffer: 16 * opt.MiB, @@ -58,7 +61,9 @@ func Open(path string) (*DB, error) { // OpenReadOnly opens an existing LevelDB without recovery, compaction or writes. // The caller must close the returned handle. func OpenReadOnly(path string) (*DB, error) { - if err := CheckRingMaintenanceFence(path); err != nil { return nil, err } + if err := CheckRingMaintenanceFence(path); err != nil { + return nil, err + } opts := &opt.Options{ReadOnly: true, ErrorIfMissing: true} db, err := leveldb.OpenFile(path, opts) if err != nil { @@ -276,20 +281,20 @@ type StoredUTXO struct { // PutUTXO persists a UTXO. func (d *DB) PutUTXO(txHash crypto.Hash32, outIdx uint32, u *StoredUTXO) error { -if u == nil { -return fmt.Errorf("nil UTXO") -} -d.ringMu.Lock() -defer d.ringMu.Unlock() + if u == nil { + return fmt.Errorf("nil UTXO") + } + d.ringMu.Lock() + defer d.ringMu.Unlock() data, err := json.Marshal(u) if err != nil { return err } batch := new(leveldb.Batch) batch.Put(utxoKey(txHash, outIdx), data) -if err := d.stageRingSamplingChange(batch, u.OneTimePub, !u.ProtocolLocked); err != nil { -return err -} + if err := d.stageRingSamplingChange(batch, u.OneTimePub, !u.ProtocolLocked); err != nil { + return err + } batch.Put(ringMemberKey(u.OneTimePub), data) return d.db.Write(batch, nil) } @@ -310,8 +315,8 @@ func (d *DB) GetUTXO(txHash crypto.Hash32, outIdx uint32) (*StoredUTXO, error) { // DeleteUTXO removes a rolled-back canonical output. Spending uses su/ markers, // not this method: spent outputs must remain in the CLSAG anonymity set. func (d *DB) DeleteUTXO(txHash crypto.Hash32, outIdx uint32) error { -d.ringMu.Lock() -defer d.ringMu.Unlock() + d.ringMu.Lock() + defer d.ringMu.Unlock() u, err := d.GetUTXO(txHash, outIdx) if err != nil { return err @@ -320,9 +325,9 @@ defer d.ringMu.Unlock() batch.Delete(utxoKey(txHash, outIdx)) batch.Delete(spentUTXOKey(txHash, outIdx)) if u != nil { -if err := d.stageRingSamplingChange(batch, u.OneTimePub, false); err != nil { -return err -} + if err := d.stageRingSamplingChange(batch, u.OneTimePub, false); err != nil { + return err + } batch.Delete(ringMemberKey(u.OneTimePub)) } return d.db.Write(batch, &opt.WriteOptions{Sync: true}) @@ -360,11 +365,11 @@ func (d *DB) ensureLegacyRingMemberIndex() error { } else if ready != nil { return nil } -// An unfinished legacy backfill must not leave an apparently ready ordinal -// index. Sampling stays unavailable until the complete rm/ store is indexed. -if err := d.db.Delete(ringSamplingCountKey, &opt.WriteOptions{Sync: true}); err != nil { -return err -} + // An unfinished legacy backfill must not leave an apparently ready ordinal + // index. Sampling stays unavailable until the complete rm/ store is indexed. + if err := d.db.Delete(ringSamplingCountKey, &opt.WriteOptions{Sync: true}); err != nil { + return err + } iter := d.db.NewIterator(util.BytesPrefix(prefixUTXO), nil) defer iter.Release() batch := new(leveldb.Batch) @@ -869,12 +874,12 @@ func (d *DB) commitRawBlock(hash crypto.Hash32, height uint64, data []byte, writ if err := d.validateLPoDBlock(data, hash, height, lpodSettlement); err != nil { return err } -if len(lpodSettlement) == 1 { -if err := d.checkSaleGenesisFundingTransition(height, lpodSettlement[0], -saleSettlement != nil && saleSettlement.Before == nil); err != nil { -return err -} -} + if len(lpodSettlement) == 1 { + if err := d.checkSaleGenesisFundingTransition(height, lpodSettlement[0], + saleSettlement != nil && saleSettlement.Before == nil); err != nil { + return err + } + } batch, err := avmBatch(writes) if err != nil { return err @@ -910,6 +915,13 @@ return err for _, input := range tx.Inputs { batch.Put(keyImageSpentKey(input.KeyImage), []byte{0x01}) } + if tx.IsStake() && len(tx.Extra) == core.StakePayloadSizeV4 { + _, _, _, _, _, _, _, keyImage, _, decodeErr := core.DecodeStakeExtraV4(tx.Extra) + if decodeErr != nil { + return fmt.Errorf("decode stake key image for atomic index: %w", decodeErr) + } + batch.Put(keyImageSpentKey(keyImage), []byte{0x01}) + } } } blockKey := append(append([]byte{}, prefixBlock...), hash[:]...) @@ -1302,7 +1314,7 @@ func (d *DB) LookupTxIdx(txHash crypto.Hash32) (*TxIdxEntry, error) { // // The b/ key suffix is always the 32-byte block hash; the JSON body contains // the height (top-level "height" for StoredBlock, or nested "Header"."Height" -// for a serialised core.Block). If two parseable bodies exist for the same +// for a serialized core.Block). If two parseable bodies exist for the same // height, the slot is treated as ambiguous and skipped even if h/ is absent. // // For non-zero h/ entries the function verifies that the body (a) exists and diff --git a/store/logical_backup.go b/store/logical_backup.go index 12b2786..e5fb808 100644 --- a/store/logical_backup.go +++ b/store/logical_backup.go @@ -9,9 +9,10 @@ import ( "path/filepath" "strings" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" + + "github.com/aperod/aperod/crypto" ) const ( diff --git a/store/logical_backup_test.go b/store/logical_backup_test.go index 61cbd87..0f098c9 100644 --- a/store/logical_backup_test.go +++ b/store/logical_backup_test.go @@ -9,8 +9,9 @@ import ( "path/filepath" "testing" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" + + "github.com/aperod/aperod/crypto" ) func TestExportLogicalBackupConcurrentAtomicWrites(t *testing.T) { @@ -180,7 +181,7 @@ func TestExportLogicalBackupCancelledRemovesPartialDestination(t *testing.T) { baseContext, cancel := context.WithCancel(context.Background()) defer cancel() ctx := &cancelAfterErrChecks{Context: baseContext, cancel: cancel, cancelAt: 4} - dst := filepath.Join(t.TempDir(), "cancelled-backup") + dst := filepath.Join(t.TempDir(), "canceled-backup") if _, _, err := source.exportLogicalBackup(ctx, dst, testBackupFreeBytes); !errors.Is(err, context.Canceled) { t.Fatalf("ExportLogicalBackup error = %v, want context cancellation", err) } diff --git a/store/lpod.go b/store/lpod.go index 37a19e3..71041e8 100644 --- a/store/lpod.go +++ b/store/lpod.go @@ -9,10 +9,11 @@ import ( "errors" "fmt" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" - "github.com/syndtr/goleveldb/leveldb" ) // LPoD checkpoints are immutable and keyed by canonical block hash. Selecting @@ -20,7 +21,7 @@ import ( // carry, rather than an independently persisted mutable "remaining" counter. // Initial funding requires a fully reconciled, quorum-attested allocation debit. type LPoDCheckpoint struct { -GenesisVesting *GenesisVestingState `json:"genesis_vesting,omitempty"` + GenesisVesting *GenesisVestingState `json:"genesis_vesting,omitempty"` State lpod.State `json:"state"` Carries map[string]lpod.Carry `json:"carries"` Allocation *LPoDAllocation `json:"allocation,omitempty"` @@ -33,16 +34,16 @@ GenesisVesting *GenesisVestingState `json:"genesis_vesting,omitempty"` // LPoDSettlement is prepared from canonical registry/position state by consensus. // Store commits verify its clock, operations, checkpoint and actual payout plan. type LPoDSettlement struct { -GenesisVestingActivationHeight uint64 -GenesisVestingFunding *GenesisVestingFunding - Parent crypto.Hash32 - Vaults []lpod.Vault - Migration *LPoDMigration - PositionProtocol bool - Timestamp int64 - Proposer string - Leader crypto.Address - Stake map[string]LPoDValidatorStake + GenesisVestingActivationHeight uint64 + GenesisVestingFunding *GenesisVestingFunding + Parent crypto.Hash32 + Vaults []lpod.Vault + Migration *LPoDMigration + PositionProtocol bool + Timestamp int64 + Proposer string + Leader crypto.Address + Stake map[string]LPoDValidatorStake // PreviousStake is the established non-seeded routing projection before // stake operations. It is used only to rank destinations, never as a source // of funds. @@ -145,9 +146,11 @@ func loadLPoDCheckpointWithReadBudget( } if c.Allocation != nil { a := c.Allocation -if c.GenesisVesting != nil { -if err := c.GenesisVesting.validateSource(a, c.State.LastHeight); err != nil { return nil, err } -} + if c.GenesisVesting != nil { + if err := c.GenesisVesting.validateSource(a, c.State.LastHeight); err != nil { + return nil, err + } + } const publicAllocation = 9_000_000_000 * lpod.Unit const validatorAllocation = 2_000_000_000 * lpod.Unit if a.InitialValidatorRemaining > validatorAllocation || @@ -177,7 +180,7 @@ if err := c.GenesisVesting.validateSource(a, c.State.LastHeight); err != nil { r a.NominalEligible >= lpod.InitialNAPRO && a.NominalEligible <= LPoDV3NominalAllocationMaxNAPRO && a.NominalEligible <= publicAllocation-a.InitialValidatorRemaining && -a.Remaining == a.NominalEligible-lpod.InitialNAPRO && + a.Remaining == a.NominalEligible-lpod.InitialNAPRO && a.SnapshotRoot != (crypto.Hash32{}) && a.FundingParent != (crypto.Hash32{}) && a.Genesis != (crypto.Hash32{}) && a.TrustAssumption == LPoDV3TrustAssumption && @@ -310,9 +313,9 @@ type legacyLPoDAllocation struct { } func (d *DB) PreviewLPoD(height uint64, request *LPoDSettlement) (*LPoDCheckpoint, []lpod.Payment, error) { -if err := d.checkSaleGenesisFundingTransition(height, request, false); err != nil { -return nil, nil, err -} + if err := d.checkSaleGenesisFundingTransition(height, request, false); err != nil { + return nil, nil, err + } tip, _, err := d.GetTip() if err != nil || request == nil || request.Parent != tip { return nil, nil, fmt.Errorf("lpod: invalid preview parent") @@ -330,11 +333,11 @@ return nil, nil, err return nil, nil, fmt.Errorf("lpod: duplicate migration") } if request.PositionProtocol { -return d.previewPositions(before, height, request) + return d.previewPositions(before, height, request) + } + if request.GenesisVestingActivationHeight != 0 || before.GenesisVesting != nil { + return nil, nil, fmt.Errorf("genesis vesting: position protocol required") } -if request.GenesisVestingActivationHeight != 0 || before.GenesisVesting != nil { -return nil, nil, fmt.Errorf("genesis vesting: position protocol required") -} next, carries, payments, err := lpod.Settle(before.State, before.Carries, height, request.Vaults) if err != nil { return nil, nil, err diff --git a/store/lpod_block_audit.go b/store/lpod_block_audit.go index 8815dfd..39d7122 100644 --- a/store/lpod_block_audit.go +++ b/store/lpod_block_audit.go @@ -12,11 +12,12 @@ import ( "reflect" "sort" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" - "github.com/syndtr/goleveldb/leveldb" - "github.com/syndtr/goleveldb/leveldb/opt" ) const lpodBlockAuditVersion = 3 @@ -115,7 +116,7 @@ func lpodBlockAuditKey(hash crypto.Hash32) []byte { // LoadLPoDAuditAt is a read-only hash-keyed lookup. It never scans, backfills, // or follows the current tip. func (d *DB) LoadLPoDAuditAt(hash crypto.Hash32) (*LPoDBlockAudit, error) { -return d.LoadLPoDAuditAtBounded(hash, 0) + return d.LoadLPoDAuditAtBounded(hash, 0) } // LoadLPoDAuditAtBounded bounds JSON decoding in auxiliary readers. @@ -124,9 +125,9 @@ func (d *DB) LoadLPoDAuditAtBounded(hash crypto.Hash32, maxBytes int) (*LPoDBloc if err != nil || raw == nil { return nil, err } -if maxBytes > 0 && len(raw) > maxBytes { -return nil, fmt.Errorf("store: block audit exceeds auxiliary read budget") -} + if maxBytes > 0 && len(raw) > maxBytes { + return nil, fmt.Errorf("store: block audit exceeds auxiliary read budget") + } var record LPoDBlockAudit if err := json.Unmarshal(raw, &record); err != nil { return nil, fmt.Errorf("store: corrupt LPoD block audit: %w", err) diff --git a/store/lpod_earnings_backfill.go b/store/lpod_earnings_backfill.go index 3911948..6dce922 100644 --- a/store/lpod_earnings_backfill.go +++ b/store/lpod_earnings_backfill.go @@ -12,11 +12,12 @@ import ( "reflect" "sort" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" "github.com/syndtr/goleveldb/leveldb/util" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) const lpodEarningsBackfillBatchHeights = 64 @@ -81,13 +82,11 @@ func (d *DB) BackfillLPoDEarningsIndex(m *LPoDMigration) error { return err } var progress lpodEarningsProgress - resume := false - if progressRaw != nil && json.Unmarshal(progressRaw, &progress) == nil && + resume := progressRaw != nil && json.Unmarshal(progressRaw, &progress) == nil && progress.FundingHash == funding && progress.TargetHash == targetHash && progress.TargetHeight == targetHeight && progress.NextHeight >= m.Height && - progress.NextHeight <= targetHeight+1 { - resume = true - } + progress.NextHeight <= targetHeight+1 + if !resume { // Reuse a completed canonical prefix when extending readiness to a // newer durable tip. Otherwise discard all old-fork and partial rows. @@ -386,20 +385,22 @@ func (d *DB) classifyHistoricalLPoDBlock(block *core.Block, m *LPoDMigration, fu for address := range principal { addressSet[address] = true } -var vestingAddress crypto.Address -var vestingAmount uint64 -if after.GenesisVesting != nil { -vestingAddress = after.GenesisVesting.Recipient -vestingAmount = after.GenesisVesting.paymentAt(block.Header.Height) -if vestingAmount > 0 { addressSet[vestingAddress] = true } -if after.GenesisVesting.FundingHeight == block.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { -addressSet[after.GenesisVesting.LiquidityRecipient] = true -} -} + var vestingAddress crypto.Address + var vestingAmount uint64 + if after.GenesisVesting != nil { + vestingAddress = after.GenesisVesting.Recipient + vestingAmount = after.GenesisVesting.paymentAt(block.Header.Height) + if vestingAmount > 0 { + addressSet[vestingAddress] = true + } + if after.GenesisVesting.FundingHeight == block.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { + addressSet[after.GenesisVesting.LiquidityRecipient] = true + } + } addresses := make([]string, 0, len(addressSet)) for address := range addressSet { - if rewards[address] > 0 || principal[address] > 0 || (address == vestingAddress && vestingAmount > 0) || - (after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { + if rewards[address] > 0 || principal[address] > 0 || (address == vestingAddress && vestingAmount > 0) || + (after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { addresses = append(addresses, string(address)) } } @@ -412,10 +413,12 @@ addressSet[after.GenesisVesting.LiquidityRecipient] = true for _, text := range addresses { address := crypto.Address(text) total, err := lpodAdd(rewards[address], principal[address]) -if err == nil && address == vestingAddress { total, err = lpodAdd(total, vestingAmount) } -if err == nil && after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient { -total, err = lpodAdd(total, after.GenesisVesting.LiquidityPaid) -} + if err == nil && address == vestingAddress { + total, err = lpodAdd(total, vestingAmount) + } + if err == nil && after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient { + total, err = lpodAdd(total, after.GenesisVesting.LiquidityPaid) + } if err != nil { return nil, err } @@ -431,8 +434,8 @@ total, err = lpodAdd(total, after.GenesisVesting.LiquidityPaid) refs := make([]lpodEarningsRef, 0, len(addresses)) for i, text := range addresses { address := crypto.Address(text) - if !isLPoDPureReward(rewards[address], principal[address]) || (address == vestingAddress && vestingAmount > 0) || - (after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { + if !isLPoDPureReward(rewards[address], principal[address]) || (address == vestingAddress && vestingAmount > 0) || + (after.GenesisVesting != nil && block.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { continue } output := block.Txs[0].Outputs[i] diff --git a/store/lpod_earnings_index_test.go b/store/lpod_earnings_index_test.go index 88a9d9e..708ec21 100644 --- a/store/lpod_earnings_index_test.go +++ b/store/lpod_earnings_index_test.go @@ -11,10 +11,11 @@ import ( "testing" "time" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" - "github.com/syndtr/goleveldb/leveldb" ) func TestLPoDEarningsIndexFundingRollbackReplayAndPagination(t *testing.T) { @@ -663,3 +664,60 @@ func commitLPoDStoreTestBlock(t *testing.T, db *DB, migration *LPoDMigration, pr } return block, request, next, payout } + +func TestLPoDWalletIndexBackfillsMissingNativeOutputs(t *testing.T) { + db, _, migration, priv, address := trustedLPoDV3FundingFixture(t) + defer db.Close() + + fundingBlock, settlement, raw := lpodActivationBlock(t, db, migration, priv, address) + if err := db.CommitRawBlockWithAVM( + fundingBlock.Hash(), LPoDV2ActivationHeight, raw, nil, crypto.Hash32{}, settlement, + ); err != nil { + t.Fatalf("commit v3 funding: %v", err) + } + funding := fundingBlock.Hash() + outputs, _, err := db.LPoDWalletOutputs(address, "", 128) + if err != nil || len(outputs) == 0 { + t.Fatalf("expected indexed native payout outputs: outputs=%d err=%v", len(outputs), err) + } + target := outputs[0] + key := lpodWalletPrefix(address) + var suffix [12]byte + binary.BigEndian.PutUint64(suffix[:8], target.BlockHeight) + binary.BigEndian.PutUint32(suffix[8:], target.OutputIndex) + key = append(key, suffix[:]...) + key = append(key, funding[:]...) + key = append(key, target.TxHash[:]...) + if err := db.db.Delete(key, nil); err != nil { + t.Fatalf("remove native wallet output fixture: %v", err) + } + if err := db.db.Delete(lpodWalletBackfillReadyKey, nil); err != nil { + t.Fatalf("remove wallet index readiness marker: %v", err) + } + if ready, err := db.LPoDWalletIndexReady(funding); err != nil || ready { + t.Fatalf("wallet index remained ready after its backfill marker was removed: ready=%t err=%v", ready, err) + } + + if err := db.BackfillLPoDWalletIndex(migration); err != nil { + t.Fatalf("backfill native payout outputs from canonical blocks: %v", err) + } + if ready, err := db.LPoDWalletIndexReady(funding); err != nil || !ready { + t.Fatalf("wallet index not ready after replay: ready=%t err=%v", ready, err) + } + rebuilt, _, err := db.LPoDWalletOutputs(address, "", 128) + if err != nil { + t.Fatalf("read rebuilt native payout outputs: %v", err) + } + found := false + for _, output := range rebuilt { + if output.TxHash == target.TxHash && output.OutputIndex == target.OutputIndex { + found = output.OneTimePub == target.OneTimePub && + output.TxPubKey == target.TxPubKey && + output.AmountCommit == target.AmountCommit && + output.EncAmount == target.EncAmount + } + } + if !found { + t.Fatal("canonical payout output was not restored by backfill") + } +} diff --git a/store/lpod_indices.go b/store/lpod_indices.go index 79dc0bf..5de0797 100644 --- a/store/lpod_indices.go +++ b/store/lpod_indices.go @@ -7,9 +7,10 @@ import ( "encoding/json" "fmt" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" - "github.com/syndtr/goleveldb/leveldb" ) func appendLPoDIndices(batch *leveldb.Batch, b *core.Block, rollback bool) error { diff --git a/store/lpod_migration.go b/store/lpod_migration.go index e50f348..1534955 100644 --- a/store/lpod_migration.go +++ b/store/lpod_migration.go @@ -10,10 +10,11 @@ import ( "fmt" "sort" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" - "github.com/syndtr/goleveldb/leveldb" ) // Nominal public baseline before any validator-pool draw. Reconciliation uses @@ -540,9 +541,10 @@ func (d *DB) appendLPoDMigration(batch *leveldb.Batch, hash crypto.Hash32, heigh return nil, fmt.Errorf("lpod: migration at wrong height") } bindingKey := []byte("lpod/activation/v1") - if m.Version == 2 { + switch m.Version { + case 2: bindingKey = []byte("lpod/activation/v2") - } else if m.Version == 3 { + case 3: bindingKey = []byte("lpod/activation/v3") } binding, err := d.get(bindingKey) @@ -582,10 +584,11 @@ func (d *DB) appendLPoDMigration(batch *leveldb.Batch, hash crypto.Hash32, heigh binary.LittleEndian.PutUint64(activation[:], height) heightKey := []byte("lpod/activation_height/v1") poolKey := []byte("lpod/activation_validator_budget/v1") - if m.Version == 2 { + switch m.Version { + case 2: heightKey = []byte("lpod/activation_height/v2") poolKey = []byte("lpod/activation_validator_budget/v2") - } else if m.Version == 3 { + case 3: heightKey = []byte("lpod/activation_height/v3") poolKey = []byte("lpod/activation_validator_budget/v3") } @@ -594,9 +597,10 @@ func (d *DB) appendLPoDMigration(batch *leveldb.Batch, hash crypto.Hash32, heigh binary.LittleEndian.PutUint64(initialPool[:], m.ValidatorRemaining) batch.Put(poolKey, initialPool[:]) saleRemaining := 9_000_000_000*lpod.Unit - m.ValidatorRemaining - issued - if m.Version == 2 { + switch m.Version { + case 2: saleRemaining = m.HistoricalSaleRemain - } else if m.Version == 3 { + case 3: saleRemaining = m.NominalEligible } return &LPoDCheckpoint{ diff --git a/store/lpod_positions.go b/store/lpod_positions.go index 6533c82..ae47d4d 100644 --- a/store/lpod_positions.go +++ b/store/lpod_positions.go @@ -11,10 +11,11 @@ import ( "reflect" "sort" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" - "github.com/syndtr/goleveldb/leveldb" ) const LPoDMaxPositions = 4096 @@ -461,9 +462,9 @@ func (d *DB) previewPositions(before *LPoDCheckpoint, height uint64, r *LPoDSett if err := next.validatePositions(); err != nil { return nil, nil, err } -if err := previewGenesisVesting(before, next, r, height); err != nil { -return nil, nil, err -} + if err := previewGenesisVesting(before, next, r, height); err != nil { + return nil, nil, err + } return next, payments, nil } @@ -527,20 +528,20 @@ func (d *DB) PayoutLPoD(height uint64, r *LPoDSettlement, after *LPoDCheckpoint, return core.Transaction{}, err } } -if after.GenesisVesting != nil { -if err := after.GenesisVesting.Validate(after.Allocation.Genesis, height); err != nil { -return core.Transaction{}, err -} -if err := add(after.GenesisVesting.Recipient, after.GenesisVesting.paymentAt(height)); err != nil { -return core.Transaction{}, err -} -if height == after.GenesisVesting.FundingHeight && after.GenesisVesting.LiquidityPaid > 0 { -if err := add(after.GenesisVesting.LiquidityRecipient, after.GenesisVesting.LiquidityPaid); err != nil { -return core.Transaction{}, err -} -} -} -addresses := make([]string, 0, len(amounts)) + if after.GenesisVesting != nil { + if err := after.GenesisVesting.Validate(after.Allocation.Genesis, height); err != nil { + return core.Transaction{}, err + } + if err := add(after.GenesisVesting.Recipient, after.GenesisVesting.paymentAt(height)); err != nil { + return core.Transaction{}, err + } + if height == after.GenesisVesting.FundingHeight && after.GenesisVesting.LiquidityPaid > 0 { + if err := add(after.GenesisVesting.LiquidityRecipient, after.GenesisVesting.LiquidityPaid); err != nil { + return core.Transaction{}, err + } + } + } + addresses := make([]string, 0, len(amounts)) for a, n := range amounts { if n > 0 { addresses = append(addresses, string(a)) diff --git a/store/lpod_test.go b/store/lpod_test.go index 18d331a..0033edd 100644 --- a/store/lpod_test.go +++ b/store/lpod_test.go @@ -15,51 +15,89 @@ import ( func TestLPoDDurableCanonicalCheckpoint(t *testing.T) { path := t.TempDir() db, err := Open(path) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } defer func() { db.Close() }() parent := crypto.HashBytes([]byte("accounting-test-only-parent")) block := crypto.HashBytes([]byte("accounting-test-only-block")) - if err := db.PutTip(parent, 0); err != nil { t.Fatal(err) } - if c, err := db.LoadLPoDCheckpoint(); c != nil || err != nil { t.Fatalf("fresh ledger is funded: %+v %v", c, err) } - request := &LPoDSettlement{Parent:parent, Vaults:[]lpod.Vault{ - {ID:"a", TotalStake:100_000*lpod.Unit, GuardianStake:100*lpod.Unit+1, ActualIncome:1000, ElapsedSeconds:1}, + if err := db.PutTip(parent, 0); err != nil { + t.Fatal(err) + } + if c, err := db.LoadLPoDCheckpoint(); c != nil || err != nil { + t.Fatalf("fresh ledger is funded: %+v %v", c, err) + } + request := &LPoDSettlement{Parent: parent, Vaults: []lpod.Vault{ + {ID: "a", TotalStake: 100_000 * lpod.Unit, GuardianStake: 100*lpod.Unit + 1, ActualIncome: 1000, ElapsedSeconds: 1}, }} if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}, request); err == nil { t.Fatal("must not create initial funding") } // Test-only DB injection. No production initializer accepts this fixture. - initial := LPoDCheckpoint{State:lpod.State{FundingDebit:lpod.InitialNAPRO, Balance:lpod.InitialNAPRO}} + initial := LPoDCheckpoint{State: lpod.State{FundingDebit: lpod.InitialNAPRO, Balance: lpod.InitialNAPRO}} data, err := json.Marshal(initial) - if err != nil { t.Fatal(err) } - if err := db.db.Put(lpodKey(parent), data, nil); err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } + if err := db.db.Put(lpodKey(parent), data, nil); err != nil { + t.Fatal(err) + } if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}); err == nil { t.Fatal("must not skip funded settlement") } - if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}, request); err != nil { t.Fatal(err) } + if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}, request); err != nil { + t.Fatal(err) + } settled, err := db.LoadLPoDCheckpoint() - if err != nil || settled == nil { t.Fatalf("load: %+v %v", settled, err) } - if settled.State.LastHeight != 1 || settled.Carries["a"].APR == 0 { t.Fatal("missing settlement/carry") } - if err := db.Close(); err != nil { t.Fatal(err) } + if err != nil || settled == nil { + t.Fatalf("load: %+v %v", settled, err) + } + if settled.State.LastHeight != 1 || settled.Carries["a"].APR == 0 { + t.Fatal("missing settlement/carry") + } + if err := db.Close(); err != nil { + t.Fatal(err) + } db, err = Open(path) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } restarted, err := db.LoadLPoDCheckpoint() - if err != nil || !reflect.DeepEqual(settled, restarted) { t.Fatalf("restart: %+v %v", restarted, err) } - if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}, request); err == nil { t.Fatal("duplicate accepted") } + if err != nil || !reflect.DeepEqual(settled, restarted) { + t.Fatalf("restart: %+v %v", restarted, err) + } + if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}, request); err == nil { + t.Fatal("duplicate accepted") + } // The canonical tip rollback selects the entire old state, not merely balance. - if err := db.PutTip(parent, 0); err != nil { t.Fatal(err) } + if err := db.PutTip(parent, 0); err != nil { + t.Fatal(err) + } rolledBack, err := db.LoadLPoDCheckpoint() - if err != nil || !reflect.DeepEqual(rolledBack, &initial) { t.Fatalf("rollback: %+v %v", rolledBack, err) } - if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}, request); err != nil { t.Fatal(err) } + if err != nil || !reflect.DeepEqual(rolledBack, &initial) { + t.Fatalf("rollback: %+v %v", rolledBack, err) + } + if err := db.CommitRawBlockWithAVM(block, 1, []byte("test"), nil, crypto.Hash32{}, request); err != nil { + t.Fatal(err) + } replayed, err := db.LoadLPoDCheckpoint() - if err != nil || !reflect.DeepEqual(replayed, settled) { t.Fatalf("replay: %+v %v", replayed, err) } + if err != nil || !reflect.DeepEqual(replayed, settled) { + t.Fatalf("replay: %+v %v", replayed, err) + } // Rejected transitions must leave block, AVM, checkpoint and tip unchanged. bad := crypto.HashBytes([]byte("rejected")) request.Parent = block request.Vaults = append(request.Vaults, request.Vaults[0]) - if err := db.CommitRawBlockWithAVM(bad, 2, []byte("bad"), []AVMWrite{{Key:[]byte("must-not-commit"), Value:[]byte("bad")}}, crypto.Hash32{}, request); err == nil { + if err := db.CommitRawBlockWithAVM(bad, 2, []byte("bad"), []AVMWrite{{Key: []byte("must-not-commit"), Value: []byte("bad")}}, crypto.Hash32{}, request); err == nil { t.Fatal("invalid settlement committed") } - if _, exists, err := db.GetAVMState([]byte("must-not-commit")); err != nil || exists { t.Fatalf("partial AVM commit: %v", err) } - if c, err := db.lpodCheckpoint(bad); err != nil || c != nil { t.Fatalf("partial checkpoint: %+v %v", c, err) } - if h, height, err := db.GetTip(); err != nil || h != block || height != 1 { t.Fatalf("partial tip: %v", err) } -} \ No newline at end of file + if _, exists, err := db.GetAVMState([]byte("must-not-commit")); err != nil || exists { + t.Fatalf("partial AVM commit: %v", err) + } + if c, err := db.lpodCheckpoint(bad); err != nil || c != nil { + t.Fatalf("partial checkpoint: %+v %v", c, err) + } + if h, height, err := db.GetTip(); err != nil || h != block || height != 1 { + t.Fatalf("partial tip: %v", err) + } +} diff --git a/store/lpod_wallet_backfill.go b/store/lpod_wallet_backfill.go new file mode 100644 index 0000000..0f27a10 --- /dev/null +++ b/store/lpod_wallet_backfill.go @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) web3 Aperod APRO team + +package store + +import ( + "encoding/binary" + "encoding/json" + "fmt" + + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +const lpodWalletBackfillBatchHeights = 64 + +var lpodWalletBackfillReadyKey = []byte("lpod/wallet-ready/v2") +var lpodWalletBackfillProgressKey = []byte("lpod/wallet-progress/v2") + +type lpodWalletBackfillProgress struct { + FundingHash crypto.Hash32 `json:"funding_hash"` + TargetHash crypto.Hash32 `json:"target_hash"` + TargetHeight uint64 `json:"target_height"` + NextHeight uint64 `json:"next_height"` +} + +// BackfillLPoDWalletIndex rebuilds the native wallet discovery index from +// canonical v3 payout blocks. The readiness marker is only written after the +// complete funding-to-tip range has been replayed and rechecked. +func (d *DB) BackfillLPoDWalletIndex(m *LPoDMigration) error { + if m == nil || m.Version != 3 { + return fmt.Errorf("lpod: v3 migration required for wallet index backfill") + } + if err := d.CheckLPoDConfig(m); err != nil { + return err + } + if err := d.verifyLPoDV2Genesis(m.Genesis); err != nil { + return fmt.Errorf("lpod: migration genesis verification failed: %w", err) + } + + targetHash, targetHeight, err := d.GetTip() + if err != nil { + return err + } + indexedTip, found, err := d.GetCanonicalHash(targetHeight) + if err != nil || !found || indexedTip != targetHash { + return fmt.Errorf("lpod: durable tip does not match canonical height index") + } + target, err := d.LoadLPoDCheckpointAt(targetHash) + if err != nil || target == nil || target.Allocation == nil || + target.Allocation.Version != 3 || target.State.LastHeight != targetHeight || + target.Allocation.Genesis != m.Genesis || + target.Allocation.ReconciliationRoot != m.ReconciliationRoot || + target.Allocation.FundingHeight != m.Height || targetHeight < m.Height { + return fmt.Errorf("lpod: canonical funded v3 checkpoint required for wallet index backfill") + } + funding := target.Allocation.FundingBlock + fundingBlock, err := d.canonicalLPoDBlock(m.Height) + if err != nil || fundingBlock.Hash() != funding { + return fmt.Errorf("lpod: canonical v3 funding block mismatch") + } + if ready, err := d.LPoDWalletIndexReady(funding); err != nil { + return err + } else if ready { + return nil + } + + progressRaw, err := d.get(lpodWalletBackfillProgressKey) + if err != nil { + return err + } + var progress lpodWalletBackfillProgress + resume := progressRaw != nil && json.Unmarshal(progressRaw, &progress) == nil && + progress.FundingHash == funding && progress.TargetHash == targetHash && + progress.TargetHeight == targetHeight && progress.NextHeight >= m.Height && + progress.NextHeight <= targetHeight+1 + if !resume { + progress = lpodWalletBackfillProgress{ + FundingHash: funding, TargetHash: targetHash, + TargetHeight: targetHeight, NextHeight: m.Height, + } + } + progressRaw, err = json.Marshal(progress) + if err != nil { + return err + } + batch := new(leveldb.Batch) + batch.Delete(lpodWalletReadyKey) + batch.Delete(lpodWalletBackfillReadyKey) + batch.Put(lpodWalletBackfillProgressKey, progressRaw) + if err := d.db.Write(batch, &opt.WriteOptions{Sync: true}); err != nil { + return err + } + + for progress.NextHeight <= targetHeight { + end := progress.NextHeight + lpodWalletBackfillBatchHeights - 1 + if end < progress.NextHeight || end > targetHeight { + end = targetHeight + } + batch = new(leveldb.Batch) + for height := progress.NextHeight; height <= end; height++ { + block, err := d.canonicalLPoDBlock(height) + if err != nil { + return err + } + if height == m.Height { + if block.Hash() != funding { + return fmt.Errorf("lpod: funding height is not on current canonical branch") + } + } else { + parent, err := d.canonicalLPoDBlock(height - 1) + if err != nil || block.Header.PrevHash != parent.Hash() { + return fmt.Errorf("lpod: noncanonical or incomplete ancestry during wallet index replay") + } + } + if err := d.indexHistoricalLPoDWalletPayout(batch, block, m, funding); err != nil { + return fmt.Errorf("lpod: wallet index replay failed at height %d: %w", height, err) + } + } + progress.NextHeight = end + 1 + progressRaw, err = json.Marshal(progress) + if err != nil { + return err + } + batch.Put(lpodWalletBackfillProgressKey, progressRaw) + if err := d.db.Write(batch, &opt.WriteOptions{Sync: true}); err != nil { + return err + } + } + + currentHash, currentHeight, err := d.GetTip() + if err != nil || currentHash != targetHash || currentHeight != targetHeight { + return fmt.Errorf("lpod: durable tip changed during wallet index replay") + } + indexedTip, found, err = d.GetCanonicalHash(targetHeight) + if err != nil || !found || indexedTip != targetHash { + return fmt.Errorf("lpod: durable tip no longer matches canonical height index") + } + if _, err := d.canonicalLPoDBlock(targetHeight); err != nil { + return err + } + + batch = new(leveldb.Batch) + if err := d.mergeLPoDWalletBlockKeys(batch, funding, [][]byte{ + lpodWalletReadyKey, lpodWalletBackfillReadyKey, + }); err != nil { + return err + } + batch.Put(lpodWalletReadyKey, funding[:]) + batch.Put(lpodWalletBackfillReadyKey, funding[:]) + batch.Delete(lpodWalletBackfillProgressKey) + return d.db.Write(batch, &opt.WriteOptions{Sync: true}) +} + +func (d *DB) indexHistoricalLPoDWalletPayout( + batch *leveldb.Batch, + block *core.Block, + m *LPoDMigration, + funding crypto.Hash32, +) error { + if len(block.Txs) < 2 || !block.Txs[0].IsLPoDPayout() { + return fmt.Errorf("missing canonical v10 payout") + } + after, err := d.LoadLPoDCheckpointAt(block.Hash()) + if err != nil || after == nil || after.Allocation == nil || + after.Allocation.Version != 3 || after.Allocation.FundingBlock != funding || + after.Allocation.Genesis != m.Genesis || + after.Allocation.ReconciliationRoot != m.ReconciliationRoot || + after.Allocation.FundingHeight != m.Height || + after.State.LastHeight != block.Header.Height { + return fmt.Errorf("canonical v3 transition checkpoint mismatch") + } + if err := after.State.Validate(); err != nil { + return fmt.Errorf("invalid canonical v3 checkpoint state: %w", err) + } + checkpointDigest, err := block.Txs[1].LPoDCheckpointDigest() + if err != nil || checkpointDigest != after.Digest() { + return fmt.Errorf("block checkpoint digest mismatch") + } + auth, err := block.Txs[0].LPoDPayoutAuthorization() + if err != nil || auth.Digest != after.Digest() { + return fmt.Errorf("invalid payout authorization") + } + owners, err := lpodWalletOwners( + auth.Leader, after, block.Header.Height, block.Header.PrevHash, after.Digest(), + ) + if err != nil { + return err + } + + tx := &block.Txs[0] + txHash := tx.Hash() + blockHash := block.Hash() + keys := make([][]byte, 0, len(tx.Outputs)) + for outputIndex, output := range tx.Outputs { + address, ok := owners[output.OneTimePub] + if !ok { + return fmt.Errorf("payout beneficiary index mismatch") + } + key := lpodWalletPrefix(address) + var suffix [12]byte + binary.BigEndian.PutUint64(suffix[:8], block.Header.Height) + binary.BigEndian.PutUint32(suffix[8:], uint32(outputIndex)) + key = append(key, suffix[:]...) + key = append(key, blockHash[:]...) + key = append(key, txHash[:]...) + stored := StoredUTXO{ + TxHash: txHash, OutputIndex: uint32(outputIndex), + OneTimePub: output.OneTimePub, TxPubKey: output.TxPubKey, + AmountCommit: output.AmountCommit, EncAmount: output.EncAmount, + BlockHeight: block.Header.Height, + } + raw, err := json.Marshal(stored) + if err != nil { + return err + } + batch.Put(key, raw) + keys = append(keys, key) + } + return d.mergeLPoDWalletBlockKeys(batch, blockHash, keys) +} + +func (d *DB) mergeLPoDWalletBlockKeys( + batch *leveldb.Batch, + blockHash crypto.Hash32, + keys [][]byte, +) error { + blockKey := lpodWalletBlockKey(blockHash) + raw, err := d.get(blockKey) + if err != nil { + return err + } + var existing [][]byte + if raw != nil { + if err := json.Unmarshal(raw, &existing); err != nil { + return fmt.Errorf("invalid wallet block-index record: %w", err) + } + } + seen := make(map[string]struct{}, len(existing)+len(keys)) + for _, key := range existing { + seen[string(key)] = struct{}{} + } + for _, key := range keys { + if _, ok := seen[string(key)]; ok { + continue + } + seen[string(key)] = struct{}{} + existing = append(existing, append([]byte(nil), key...)) + } + encoded, err := json.Marshal(existing) + if err != nil { + return err + } + batch.Put(blockKey, encoded) + return nil +} diff --git a/store/lpod_wallet_index.go b/store/lpod_wallet_index.go index e5ca79d..0f32732 100644 --- a/store/lpod_wallet_index.go +++ b/store/lpod_wallet_index.go @@ -12,11 +12,12 @@ import ( "reflect" "sort" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" - "github.com/syndtr/goleveldb/leveldb" - "github.com/syndtr/goleveldb/leveldb/util" ) func lpodWalletPrefix(address crypto.Address) []byte { @@ -37,7 +38,11 @@ var lpodEarningsProgressKey = []byte("lpod/earnings-progress/v2") func (d *DB) LPoDWalletIndexReady(funding crypto.Hash32) (bool, error) { raw, err := d.get(lpodWalletReadyKey) - return bytes.Equal(raw, funding[:]), err + if err != nil { + return false, err + } + backfilled, err := d.get(lpodWalletBackfillReadyKey) + return bytes.Equal(raw, funding[:]) && bytes.Equal(backfilled, funding[:]), err } func (d *DB) LPoDEarningsIndexReady(funding, tip crypto.Hash32, height uint64) (bool, error) { @@ -65,6 +70,37 @@ func isLPoDPureReward(reward, returnedPrincipal uint64) bool { return reward > 0 && returnedPrincipal == 0 } +func lpodWalletOwners( + leader crypto.Address, + after *LPoDCheckpoint, + height uint64, + parent, digest crypto.Hash32, +) (map[crypto.Point32]crypto.Address, error) { + candidates := map[crypto.Address]bool{leader: true} + if after.GenesisVesting != nil && after.GenesisVesting.paymentAt(height) > 0 { + candidates[after.GenesisVesting.Recipient] = true + } + if after.GenesisVesting != nil && after.GenesisVesting.FundingHeight == height && + after.GenesisVesting.LiquidityPaid > 0 { + candidates[after.GenesisVesting.LiquidityRecipient] = true + } + for _, position := range after.Positions { + candidates[position.Deposit.Beneficiary] = true + } + owners := make(map[crypto.Point32]crypto.Address, len(candidates)) + for address := range candidates { + output, err := core.BuildLPoDPayoutOutput(address, 1, height, parent, digest) + if err != nil { + return nil, err + } + if existing, ok := owners[output.OneTimePub]; ok && existing != address { + return nil, fmt.Errorf("lpod: ambiguous payout beneficiary key") + } + owners[output.OneTimePub] = address + } + return owners, nil +} + // This is a discovery index only. Monetary transitions and payout validation // remain authoritative and unchanged. Keys are committed alongside the block, // settlement and UTXOs, never manufactured from wallet SQL metadata. @@ -76,30 +112,17 @@ func (d *DB) appendLPoDWalletIndex(batch *leveldb.Batch, b *core.Block, r *LPoDS if after == nil || after.Allocation == nil { return fmt.Errorf("lpod: funded checkpoint required for wallet discovery") } - candidates := map[crypto.Address]bool{r.Leader: true} -if after.GenesisVesting != nil && after.GenesisVesting.paymentAt(b.Header.Height) > 0 { -candidates[after.GenesisVesting.Recipient] = true -} -if after.GenesisVesting != nil && after.GenesisVesting.FundingHeight == b.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { -candidates[after.GenesisVesting.LiquidityRecipient] = true -} - for _, p := range after.Positions { - candidates[p.Deposit.Beneficiary] = true - } - owners := map[crypto.Point32]crypto.Address{} - for a := range candidates { - // The public deterministic ephemeral/one-time key is independent of amount. - out, err := core.BuildLPoDPayoutOutput(a, 1, b.Header.Height, r.Parent, after.Digest()) - if err != nil { - return err - } - owners[out.OneTimePub] = a + owners, err := lpodWalletOwners(r.Leader, after, b.Header.Height, r.Parent, after.Digest()) + if err != nil { + return err } keys := [][]byte{} if b.Header.Height == after.Allocation.FundingHeight { funding := b.Hash() batch.Put(lpodWalletReadyKey, funding[:]) keys = append(keys, lpodWalletReadyKey) + batch.Put(lpodWalletBackfillReadyKey, funding[:]) + keys = append(keys, lpodWalletBackfillReadyKey) if after.Allocation.Version == 3 { ready := earningsReadyValue(funding, funding, b.Header.Height) batch.Put(lpodEarningsReadyKey, ready) @@ -260,19 +283,21 @@ func (d *DB) classifyLPoDEarnings(b *core.Block, r *LPoDSettlement, after *LPoDC for a := range principal { seen[a] = true } -var vestingAddress crypto.Address -var vestingAmount uint64 -if after.GenesisVesting != nil { -vestingAddress = after.GenesisVesting.Recipient -vestingAmount = after.GenesisVesting.paymentAt(b.Header.Height) -if vestingAmount > 0 { seen[vestingAddress] = true } -if after.GenesisVesting.FundingHeight == b.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { -seen[after.GenesisVesting.LiquidityRecipient] = true -} -} + var vestingAddress crypto.Address + var vestingAmount uint64 + if after.GenesisVesting != nil { + vestingAddress = after.GenesisVesting.Recipient + vestingAmount = after.GenesisVesting.paymentAt(b.Header.Height) + if vestingAmount > 0 { + seen[vestingAddress] = true + } + if after.GenesisVesting.FundingHeight == b.Header.Height && after.GenesisVesting.LiquidityPaid > 0 { + seen[after.GenesisVesting.LiquidityRecipient] = true + } + } for a := range seen { -if rewards[a] > 0 || principal[a] > 0 || (a == vestingAddress && vestingAmount > 0) || -(after.GenesisVesting != nil && after.GenesisVesting.FundingHeight == b.Header.Height && a == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { + if rewards[a] > 0 || principal[a] > 0 || (a == vestingAddress && vestingAmount > 0) || + (after.GenesisVesting != nil && after.GenesisVesting.FundingHeight == b.Header.Height && a == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { addresses = append(addresses, string(a)) } } @@ -284,18 +309,20 @@ if rewards[a] > 0 || principal[a] > 0 || (a == vestingAddress && vestingAmount > for i, text := range addresses { address := crypto.Address(text) total, err := lpodAdd(rewards[address], principal[address]) -if err == nil && address == vestingAddress { total, err = lpodAdd(total, vestingAmount) } -if err == nil && after.GenesisVesting != nil && b.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient { -total, err = lpodAdd(total, after.GenesisVesting.LiquidityPaid) -} + if err == nil && address == vestingAddress { + total, err = lpodAdd(total, vestingAmount) + } + if err == nil && after.GenesisVesting != nil && b.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient { + total, err = lpodAdd(total, after.GenesisVesting.LiquidityPaid) + } if err != nil { return nil, err } if total == 0 { return nil, fmt.Errorf("lpod: zero canonical payout classification") } -if !isLPoDPureReward(rewards[address], principal[address]) || (address == vestingAddress && vestingAmount > 0) || -(after.GenesisVesting != nil && b.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { + if !isLPoDPureReward(rewards[address], principal[address]) || (address == vestingAddress && vestingAmount > 0) || + (after.GenesisVesting != nil && b.Header.Height == after.GenesisVesting.FundingHeight && address == after.GenesisVesting.LiquidityRecipient && after.GenesisVesting.LiquidityPaid > 0) { // Mixed outputs must never be presented as earnings-only, and // principal-only outputs are not earnings. continue diff --git a/store/prune.go b/store/prune.go index 4a7a43a..163a630 100644 --- a/store/prune.go +++ b/store/prune.go @@ -77,16 +77,22 @@ func (d *DB) PruneBlocksOlderThan(pruneBelow uint64) (int, error) { } } -protectedVestingHeights := map[uint64]bool{} -if hasFunding { -checkpoint, err := d.LoadLPoDCheckpoint() -if err != nil { return 0, fmt.Errorf("load reserve checkpoint before pruning: %w", err) } -if checkpoint != nil && checkpoint.GenesisVesting != nil { -protectedVestingHeights[checkpoint.GenesisVesting.FundingHeight] = true -for _, h := range checkpoint.GenesisVesting.PaidHeights { if h > 0 { protectedVestingHeights[h] = true } } -} -} -if cursor >= pruneBelow { + protectedVestingHeights := map[uint64]bool{} + if hasFunding { + checkpoint, err := d.LoadLPoDCheckpoint() + if err != nil { + return 0, fmt.Errorf("load reserve checkpoint before pruning: %w", err) + } + if checkpoint != nil && checkpoint.GenesisVesting != nil { + protectedVestingHeights[checkpoint.GenesisVesting.FundingHeight] = true + for _, h := range checkpoint.GenesisVesting.PaidHeights { + if h > 0 { + protectedVestingHeights[h] = true + } + } + } + } + if cursor >= pruneBelow { return 0, nil // nothing new to prune } @@ -119,9 +125,11 @@ if cursor >= pruneBelow { // Gap in chain — skip, advance cursor past it. continue } -// Retain the at-most eleven reserve/payment bodies for permanent canonical -// payout evidence, even when ordinary old block bodies are pruned. -if protectedVestingHeights[h] { continue } + // Retain the at-most eleven reserve/payment bodies for permanent canonical + // payout evidence, even when ordinary old block bodies are pruned. + if protectedVestingHeights[h] { + continue + } // Strip TxData: replace the block bytes with a header-only version. // We encode the pruned marker as the single JSON string `null` appended diff --git a/store/ring_member_index_test.go b/store/ring_member_index_test.go index 1dcf45a..1343bc5 100644 --- a/store/ring_member_index_test.go +++ b/store/ring_member_index_test.go @@ -71,4 +71,4 @@ func TestRingMemberIndexPersistsSamplesAndDeletesCanonicalOutputs(t *testing.T) if got != nil { t.Fatal("rolled-back output remains in ring-member index") } -} \ No newline at end of file +} diff --git a/store/ring_sampling.go b/store/ring_sampling.go index 286acb5..108e522 100644 --- a/store/ring_sampling.go +++ b/store/ring_sampling.go @@ -7,9 +7,10 @@ import ( "io" "math/bits" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" - "github.com/syndtr/goleveldb/leveldb" ) // Bound untrusted request size and pathological entropy rejection. The store diff --git a/store/ring_sampling_disk_rehearsal_test.go b/store/ring_sampling_disk_rehearsal_test.go index 562b531..f8105ea 100644 --- a/store/ring_sampling_disk_rehearsal_test.go +++ b/store/ring_sampling_disk_rehearsal_test.go @@ -17,11 +17,12 @@ import ( "testing" "time" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" "github.com/syndtr/goleveldb/leveldb/util" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // Synthetic storage identities, not wallet keys or spendable curve points. diff --git a/store/ring_sampling_index.go b/store/ring_sampling_index.go index 45d484f..299f7e0 100644 --- a/store/ring_sampling_index.go +++ b/store/ring_sampling_index.go @@ -8,10 +8,11 @@ import ( "fmt" "math" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" "github.com/syndtr/goleveldb/leveldb/util" + + "github.com/aperod/aperod/crypto" ) // These are derived local indexes, not consensus/wire-format changes. rm/ diff --git a/store/ring_sampling_test.go b/store/ring_sampling_test.go index d28fee1..463102e 100644 --- a/store/ring_sampling_test.go +++ b/store/ring_sampling_test.go @@ -11,10 +11,11 @@ import ( "sync" "testing" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) func samplingDB(t *testing.T) *DB { diff --git a/store/ring_sampling_verify.go b/store/ring_sampling_verify.go index 4ed55bb..4003296 100644 --- a/store/ring_sampling_verify.go +++ b/store/ring_sampling_verify.go @@ -8,9 +8,10 @@ import ( "errors" "fmt" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/util" + + "github.com/aperod/aperod/crypto" ) // RingSamplingInventory is an offline local integrity receipt, not a consensus diff --git a/store/sale.go b/store/sale.go index 6e05710..0e856e4 100644 --- a/store/sale.go +++ b/store/sale.go @@ -5,10 +5,11 @@ import ( "encoding/json" "fmt" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/sale" - "github.com/syndtr/goleveldb/leveldb" ) // SaleMigration is operator configuration, never accepted from a payment API. @@ -122,8 +123,8 @@ func (d *DB) PrepareSaleBlock(b *core.Block, m *SaleMigration) (*SaleSettlement, if err != nil { return nil, err } -if err = VerifySaleReserveFloors(l, m.Reconciliation); err != nil { -return nil, err + if err = VerifySaleReserveFloors(l, m.Reconciliation); err != nil { + return nil, err } knownIssued, err := SaleKnownIssuedFloor(l) if err != nil { diff --git a/store/sale_invoice.go b/store/sale_invoice.go index 4647ccb..3e54bd8 100644 --- a/store/sale_invoice.go +++ b/store/sale_invoice.go @@ -8,9 +8,10 @@ import ( "regexp" "strings" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" + + "github.com/aperod/aperod/crypto" ) // SaleInvoice is private merchant metadata, never consensus/public metadata. diff --git a/store/sale_ledger_reconcile.go b/store/sale_ledger_reconcile.go index d778065..760bef5 100644 --- a/store/sale_ledger_reconcile.go +++ b/store/sale_ledger_reconcile.go @@ -5,10 +5,11 @@ import ( "encoding/json" "fmt" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // SaleLedgerReconcileReport proves a bounded, hash-bound, sale-free interval. diff --git a/store/sale_orders.go b/store/sale_orders.go index fdc59cc..9d95aaa 100644 --- a/store/sale_orders.go +++ b/store/sale_orders.go @@ -4,11 +4,12 @@ import ( "encoding/json" "fmt" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" "github.com/syndtr/goleveldb/leveldb/util" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // SaleOrder is private operator storage, never public chain metadata. diff --git a/store/sale_reconciliation_floor.go b/store/sale_reconciliation_floor.go index 7e9e4a9..7b88e8e 100644 --- a/store/sale_reconciliation_floor.go +++ b/store/sale_reconciliation_floor.go @@ -2,6 +2,7 @@ package store import ( "fmt" + "github.com/aperod/aperod/sale" ) diff --git a/store/sale_reconciliation_floor_test.go b/store/sale_reconciliation_floor_test.go index 3169c9a..aa8463f 100644 --- a/store/sale_reconciliation_floor_test.go +++ b/store/sale_reconciliation_floor_test.go @@ -1,8 +1,9 @@ package store import ( - "github.com/aperod/aperod/sale" "testing" + + "github.com/aperod/aperod/sale" ) func TestSaleKnownIssuedFloorIncludesPostDeclarationRewards(t *testing.T) { diff --git a/store/sale_recovery.go b/store/sale_recovery.go index 83c6834..499dc75 100644 --- a/store/sale_recovery.go +++ b/store/sale_recovery.go @@ -8,8 +8,9 @@ import ( "strconv" "filippo.io/edwards25519" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb/opt" + + "github.com/aperod/aperod/crypto" ) // SaleRecovery is an owner-signed acknowledgement of a particular canonical diff --git a/store/sale_recovery_test.go b/store/sale_recovery_test.go index c1e7351..21b7228 100644 --- a/store/sale_recovery_test.go +++ b/store/sale_recovery_test.go @@ -6,6 +6,7 @@ import ( "testing" "filippo.io/edwards25519" + "github.com/aperod/aperod/crypto" ) diff --git a/store/store2_test.go b/store/store2_test.go index b5fd8cd..fcb524c 100644 --- a/store/store2_test.go +++ b/store/store2_test.go @@ -4,255 +4,255 @@ package store_test // IsKeyImageSpent, DeleteUTXO — all currently at 0% coverage. import ( - "testing" + "testing" - "github.com/aperod/aperod/crypto" - "github.com/aperod/aperod/store" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) // ─── StoredBlock: PutBlock / GetBlock / GetBlockByHeight ───────────────────── func TestDB_PutBlock_GetBlock(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - var hash crypto.Hash32 - hash[0] = 0xAA - - sb := &store.StoredBlock{ - Height: 5, - Hash: hash, - PrevHash: crypto.Hash32{}, - Timestamp: 1_000_000, - TxCount: 2, - } - - if err := db.PutBlock(hash, sb); err != nil { - t.Fatalf("PutBlock: %v", err) - } - - got, err := db.GetBlock(hash) - if err != nil { - t.Fatalf("GetBlock: %v", err) - } - if got == nil { - t.Fatal("GetBlock returned nil") - } - if got.Height != 5 || got.TxCount != 2 { - t.Errorf("GetBlock: height=%d txCount=%d, want 5,2", got.Height, got.TxCount) - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + var hash crypto.Hash32 + hash[0] = 0xAA + + sb := &store.StoredBlock{ + Height: 5, + Hash: hash, + PrevHash: crypto.Hash32{}, + Timestamp: 1_000_000, + TxCount: 2, + } + + if err := db.PutBlock(hash, sb); err != nil { + t.Fatalf("PutBlock: %v", err) + } + + got, err := db.GetBlock(hash) + if err != nil { + t.Fatalf("GetBlock: %v", err) + } + if got == nil { + t.Fatal("GetBlock returned nil") + } + if got.Height != 5 || got.TxCount != 2 { + t.Errorf("GetBlock: height=%d txCount=%d, want 5,2", got.Height, got.TxCount) + } } func TestDB_GetBlock_NotFound(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - var missing crypto.Hash32 - missing[0] = 0xFF - got, err := db.GetBlock(missing) - if err != nil { - t.Fatalf("GetBlock not-found: unexpected error: %v", err) - } - if got != nil { - t.Error("GetBlock must return nil for unknown hash") - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + var missing crypto.Hash32 + missing[0] = 0xFF + got, err := db.GetBlock(missing) + if err != nil { + t.Fatalf("GetBlock not-found: unexpected error: %v", err) + } + if got != nil { + t.Error("GetBlock must return nil for unknown hash") + } } func TestDB_GetBlockByHeight_NotFound(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - got, err := db.GetBlockByHeight(99999) - if err != nil { - t.Fatalf("GetBlockByHeight not-found: unexpected error: %v", err) - } - if got != nil { - t.Error("GetBlockByHeight must return nil for unknown height") - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + got, err := db.GetBlockByHeight(99999) + if err != nil { + t.Fatalf("GetBlockByHeight not-found: unexpected error: %v", err) + } + if got != nil { + t.Error("GetBlockByHeight must return nil for unknown height") + } } func TestDB_GetBlockByHeight_Found(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - var hash crypto.Hash32 - hash[1] = 0xBB - sb := &store.StoredBlock{Height: 7, Hash: hash, Timestamp: 99} - _ = db.PutBlock(hash, sb) - - got, err := db.GetBlockByHeight(7) - if err != nil { - t.Fatalf("GetBlockByHeight: %v", err) - } - if got == nil || got.Height != 7 { - t.Errorf("GetBlockByHeight: got %v, want height 7", got) - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + var hash crypto.Hash32 + hash[1] = 0xBB + sb := &store.StoredBlock{Height: 7, Hash: hash, Timestamp: 99} + _ = db.PutBlock(hash, sb) + + got, err := db.GetBlockByHeight(7) + if err != nil { + t.Fatalf("GetBlockByHeight: %v", err) + } + if got == nil || got.Height != 7 { + t.Errorf("GetBlockByHeight: got %v, want height 7", got) + } } // ─── UTXO CRUD ─────────────────────────────────────────────────────────────── func TestDB_UTXO_PutGetDelete(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - var txHash crypto.Hash32 - txHash[0] = 0x11 - outIdx := uint32(0) - - var pub crypto.Point32 - pub[0] = 0x42 - u := &store.StoredUTXO{ - TxHash: txHash, - OutputIndex: outIdx, - OneTimePub: pub, - BlockHeight: 3, - } - - // Put - if err := db.PutUTXO(txHash, outIdx, u); err != nil { - t.Fatalf("PutUTXO: %v", err) - } - - // Get - got, err := db.GetUTXO(txHash, outIdx) - if err != nil { - t.Fatalf("GetUTXO: %v", err) - } - if got == nil { - t.Fatal("GetUTXO returned nil") - } - if got.BlockHeight != 3 || got.OneTimePub != pub { - t.Errorf("GetUTXO: height=%d pub=%x", got.BlockHeight, got.OneTimePub[:4]) - } - - // Delete - if err := db.DeleteUTXO(txHash, outIdx); err != nil { - t.Fatalf("DeleteUTXO: %v", err) - } - - // Should be gone - after, err := db.GetUTXO(txHash, outIdx) - if err != nil { - t.Fatalf("GetUTXO after delete: %v", err) - } - if after != nil { - t.Error("GetUTXO must return nil after DeleteUTXO") - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + var txHash crypto.Hash32 + txHash[0] = 0x11 + outIdx := uint32(0) + + var pub crypto.Point32 + pub[0] = 0x42 + u := &store.StoredUTXO{ + TxHash: txHash, + OutputIndex: outIdx, + OneTimePub: pub, + BlockHeight: 3, + } + + // Put + if err := db.PutUTXO(txHash, outIdx, u); err != nil { + t.Fatalf("PutUTXO: %v", err) + } + + // Get + got, err := db.GetUTXO(txHash, outIdx) + if err != nil { + t.Fatalf("GetUTXO: %v", err) + } + if got == nil { + t.Fatal("GetUTXO returned nil") + } + if got.BlockHeight != 3 || got.OneTimePub != pub { + t.Errorf("GetUTXO: height=%d pub=%x", got.BlockHeight, got.OneTimePub[:4]) + } + + // Delete + if err := db.DeleteUTXO(txHash, outIdx); err != nil { + t.Fatalf("DeleteUTXO: %v", err) + } + + // Should be gone + after, err := db.GetUTXO(txHash, outIdx) + if err != nil { + t.Fatalf("GetUTXO after delete: %v", err) + } + if after != nil { + t.Error("GetUTXO must return nil after DeleteUTXO") + } } func TestDB_GetUTXO_NotFound(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - var txHash crypto.Hash32 - txHash[0] = 0xCC - got, err := db.GetUTXO(txHash, 0) - if err != nil { - t.Fatalf("GetUTXO not-found: unexpected error: %v", err) - } - if got != nil { - t.Error("GetUTXO must return nil for unknown key") - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + var txHash crypto.Hash32 + txHash[0] = 0xCC + got, err := db.GetUTXO(txHash, 0) + if err != nil { + t.Fatalf("GetUTXO not-found: unexpected error: %v", err) + } + if got != nil { + t.Error("GetUTXO must return nil for unknown key") + } } func TestDB_UTXO_MultipleOutputs(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - var txHash crypto.Hash32 - txHash[0] = 0x22 - - for i := uint32(0); i < 3; i++ { - var pub crypto.Point32 - pub[0] = byte(i + 1) - u := &store.StoredUTXO{TxHash: txHash, OutputIndex: i, OneTimePub: pub, BlockHeight: 1} - if err := db.PutUTXO(txHash, i, u); err != nil { - t.Fatalf("PutUTXO[%d]: %v", i, err) - } - } - - count := 0 - _ = db.IterUTXOs(func(u *store.StoredUTXO) error { - count++ - return nil - }) - if count != 3 { - t.Errorf("IterUTXOs: count=%d, want 3", count) - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + var txHash crypto.Hash32 + txHash[0] = 0x22 + + for i := uint32(0); i < 3; i++ { + var pub crypto.Point32 + pub[0] = byte(i + 1) + u := &store.StoredUTXO{TxHash: txHash, OutputIndex: i, OneTimePub: pub, BlockHeight: 1} + if err := db.PutUTXO(txHash, i, u); err != nil { + t.Fatalf("PutUTXO[%d]: %v", i, err) + } + } + + count := 0 + _ = db.IterUTXOs(func(u *store.StoredUTXO) error { + count++ + return nil + }) + if count != 3 { + t.Errorf("IterUTXOs: count=%d, want 3", count) + } } // ─── Key Image: IsKeyImageSpent ─────────────────────────────────────────────── func TestDB_IsKeyImageSpent(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - var ki crypto.KeyImage - ki[0] = 0x55 - - spent, err := db.IsKeyImageSpent(ki) - if err != nil { - t.Fatal(err) - } - if spent { - t.Error("key image must not be spent before MarkKeyImageSpent") - } - - _ = db.MarkKeyImageSpent(ki) - - spent, err = db.IsKeyImageSpent(ki) - if err != nil { - t.Fatal(err) - } - if !spent { - t.Error("key image must be spent after MarkKeyImageSpent") - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + var ki crypto.KeyImage + ki[0] = 0x55 + + spent, err := db.IsKeyImageSpent(ki) + if err != nil { + t.Fatal(err) + } + if spent { + t.Error("key image must not be spent before MarkKeyImageSpent") + } + + _ = db.MarkKeyImageSpent(ki) + + spent, err = db.IsKeyImageSpent(ki) + if err != nil { + t.Fatal(err) + } + if !spent { + t.Error("key image must be spent after MarkKeyImageSpent") + } } // ─── Meta: PutMeta / GetMeta ────────────────────────────────────────────────── func TestDB_Meta_RoundTrip(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - if err := db.PutMeta("sync_state", []byte(`{"height":42}`)); err != nil { - t.Fatalf("PutMeta: %v", err) - } - - val, err := db.GetMeta("sync_state") - if err != nil { - t.Fatalf("GetMeta: %v", err) - } - if string(val) != `{"height":42}` { - t.Errorf("GetMeta: got %q", val) - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + if err := db.PutMeta("sync_state", []byte(`{"height":42}`)); err != nil { + t.Fatalf("PutMeta: %v", err) + } + + val, err := db.GetMeta("sync_state") + if err != nil { + t.Fatalf("GetMeta: %v", err) + } + if string(val) != `{"height":42}` { + t.Errorf("GetMeta: got %q", val) + } } func TestDB_Meta_NotFound(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - val, err := db.GetMeta("nonexistent") - if err != nil { - t.Fatalf("GetMeta not-found: unexpected error: %v", err) - } - if val != nil { - t.Error("GetMeta must return nil for unknown key") - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + val, err := db.GetMeta("nonexistent") + if err != nil { + t.Fatalf("GetMeta not-found: unexpected error: %v", err) + } + if val != nil { + t.Error("GetMeta must return nil for unknown key") + } } // ─── GetTip: empty DB ───────────────────────────────────────────────────────── func TestDB_GetTip_Empty(t *testing.T) { - db, _ := store.Open(t.TempDir()) - defer db.Close() - - hash, height, err := db.GetTip() - if err != nil { - t.Fatalf("GetTip empty: %v", err) - } - var zero crypto.Hash32 - if hash != zero || height != 0 { - t.Errorf("GetTip empty: hash=%x height=%d, want zero", hash[:4], height) - } + db, _ := store.Open(t.TempDir()) + defer db.Close() + + hash, height, err := db.GetTip() + if err != nil { + t.Fatalf("GetTip empty: %v", err) + } + var zero crypto.Hash32 + if hash != zero || height != 0 { + t.Errorf("GetTip empty: hash=%x height=%d, want zero", hash[:4], height) + } } diff --git a/store/store_test.go b/store/store_test.go index 423661d..571be75 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -1,219 +1,219 @@ package store_test import ( - "encoding/json" - "fmt" - "os" - "testing" - - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" - "github.com/aperod/aperod/store" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/store" ) func TestDB_PutGetRawBlock(t *testing.T) { - dir := t.TempDir() - db, err := store.Open(dir) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - // Create a minimal block - priv, pub, _ := crypto.GenerateValidatorKey() - hdr := core.BlockHeader{ - Height: 0, - Timestamp: 1000000, - ValidatorPub: pub, - } - _ = hdr.Sign(priv) - block := &core.Block{Header: hdr} - - data, _ := json.Marshal(block) - hash := block.Hash() - - if err := db.PutRawBlock(hash, 0, data); err != nil { - t.Fatalf("PutRawBlock: %v", err) - } - - got, err := db.GetRawBlock(hash) - if err != nil { - t.Fatalf("GetRawBlock: %v", err) - } - if string(got) != string(data) { - t.Error("round-trip mismatch") - } - - // By height - gotH, err := db.GetRawBlockByHeight(0) - if err != nil { - t.Fatalf("GetRawBlockByHeight: %v", err) - } - if string(gotH) != string(data) { - t.Error("height index round-trip mismatch") - } + dir := t.TempDir() + db, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // Create a minimal block + priv, pub, _ := crypto.GenerateValidatorKey() + hdr := core.BlockHeader{ + Height: 0, + Timestamp: 1000000, + ValidatorPub: pub, + } + _ = hdr.Sign(priv) + block := &core.Block{Header: hdr} + + data, _ := json.Marshal(block) + hash := block.Hash() + + if err := db.PutRawBlock(hash, 0, data); err != nil { + t.Fatalf("PutRawBlock: %v", err) + } + + got, err := db.GetRawBlock(hash) + if err != nil { + t.Fatalf("GetRawBlock: %v", err) + } + if string(got) != string(data) { + t.Error("round-trip mismatch") + } + + // By height + gotH, err := db.GetRawBlockByHeight(0) + if err != nil { + t.Fatalf("GetRawBlockByHeight: %v", err) + } + if string(gotH) != string(data) { + t.Error("height index round-trip mismatch") + } } func TestDB_IterKeyImages(t *testing.T) { - dir := t.TempDir() - db, _ := store.Open(dir) - defer db.Close() - - var kis [3]crypto.KeyImage - for i := range kis { - kis[i][0] = byte(i + 1) - if err := db.MarkKeyImageSpent(kis[i]); err != nil { - t.Fatalf("MarkKeyImageSpent(%x): %v", kis[i][:4], err) - } - } - - // IterKeyImages may return canonical forms rather than raw bytes when the - // stored key image was a valid prime-order curve point. Verify via - // IsKeyImageSpent (which applies the same canonicalization) instead of - // comparing raw bytes, and separately verify the iteration count. - var count int - _ = db.IterKeyImages(func(_ crypto.KeyImage) error { - count++ - return nil - }) - if count != len(kis) { - t.Errorf("IterKeyImages returned %d entries, want %d", count, len(kis)) - } - for _, ki := range kis { - spent, err := db.IsKeyImageSpent(ki) - if err != nil { - t.Errorf("IsKeyImageSpent(%x): %v", ki[:4], err) - } else if !spent { - t.Errorf("key image %x stored but IsKeyImageSpent returned false", ki[:4]) - } - } + dir := t.TempDir() + db, _ := store.Open(dir) + defer db.Close() + + var kis [3]crypto.KeyImage + for i := range kis { + kis[i][0] = byte(i + 1) + if err := db.MarkKeyImageSpent(kis[i]); err != nil { + t.Fatalf("MarkKeyImageSpent(%x): %v", kis[i][:4], err) + } + } + + // IterKeyImages may return canonical forms rather than raw bytes when the + // stored key image was a valid prime-order curve point. Verify via + // IsKeyImageSpent (which applies the same canonicalization) instead of + // comparing raw bytes, and separately verify the iteration count. + var count int + _ = db.IterKeyImages(func(_ crypto.KeyImage) error { + count++ + return nil + }) + if count != len(kis) { + t.Errorf("IterKeyImages returned %d entries, want %d", count, len(kis)) + } + for _, ki := range kis { + spent, err := db.IsKeyImageSpent(ki) + if err != nil { + t.Errorf("IsKeyImageSpent(%x): %v", ki[:4], err) + } else if !spent { + t.Errorf("key image %x stored but IsKeyImageSpent returned false", ki[:4]) + } + } } func TestDB_TipRoundtrip(t *testing.T) { - dir := t.TempDir() - db, _ := store.Open(dir) - defer db.Close() - - var h crypto.Hash32 - h[0] = 0xAB - if err := db.PutTip(h, 42); err != nil { - t.Fatal(err) - } - gotH, gotHeight, err := db.GetTip() - if err != nil { - t.Fatal(err) - } - if gotH != h || gotHeight != 42 { - t.Errorf("tip mismatch: got hash=%x height=%d", gotH[:4], gotHeight) - } + dir := t.TempDir() + db, _ := store.Open(dir) + defer db.Close() + + var h crypto.Hash32 + h[0] = 0xAB + if err := db.PutTip(h, 42); err != nil { + t.Fatal(err) + } + gotH, gotHeight, err := db.GetTip() + if err != nil { + t.Fatal(err) + } + if gotH != h || gotHeight != 42 { + t.Errorf("tip mismatch: got hash=%x height=%d", gotH[:4], gotHeight) + } } func TestChainPersistRestore(t *testing.T) { - dir := t.TempDir() - - // ── First run: create genesis + 2 blocks ────────────────────────────────── - genesisFile := writeTempGenesis(t) - defer os.Remove(genesisFile) - - db1, _ := store.Open(dir) - - genesisCfg, err := core.LoadGenesis(genesisFile) - if err != nil { - t.Fatal(err) - } - priv, _, _ := crypto.GenerateValidatorKey() - genesis, _ := core.CreateGenesisBlock(genesisCfg, priv) - - chain1 := core.NewChain() - utxos1 := core.NewUTXOSet() - _ = chain1.SetGenesis(genesis) - - // helper: persist - persistBlock := func(db *store.DB, block *core.Block, utxos *core.UTXOSet) { - data, _ := json.Marshal(block) - bh := block.Hash() - _ = db.PutRawBlock(bh, block.Header.Height, data) - _ = utxos.ApplyBlock(block) - for _, tx := range block.Txs { - txHash := tx.Hash() - for _, inp := range tx.Inputs { - _ = db.MarkKeyImageSpent(inp.KeyImage) - } - for i, out := range tx.Outputs { - su := &store.StoredUTXO{ - TxHash: txHash, OutputIndex: uint32(i), - OneTimePub: out.OneTimePub, AmountCommit: out.AmountCommit, - BlockHeight: block.Header.Height, - } - _ = db.PutUTXO(txHash, uint32(i), su) - } - } - _ = db.PutTip(bh, block.Header.Height) - } - - persistBlock(db1, genesis, utxos1) - - // Add block 1 and block 2 (no txs, just header chain) - addAndPersist := func(db *store.DB, chain *core.Chain, utxos *core.UTXOSet, height uint64) *core.Block { - prev := chain.Tip() - prevHash := prev.Hash() - hdr := core.BlockHeader{ - Height: height, - PrevHash: prevHash, - Timestamp: int64(1000000 + height*1000), - ValidatorPub: genesis.Header.ValidatorPub, - } - hdr.MerkleRoot = core.MerkleRoot(nil) - _ = hdr.Sign(priv) - b := &core.Block{Header: hdr} - _ = chain.AddBlock(b) - persistBlock(db, b, utxos) - return b - } - addAndPersist(db1, chain1, utxos1, 1) - addAndPersist(db1, chain1, utxos1, 2) - db1.Close() - - // ── Second run: restore from DB ──────────────────────────────────────────── - db2, _ := store.Open(dir) - defer db2.Close() - - _, tipHeight, err := db2.GetTip() - if err != nil { - t.Fatal(err) - } - if tipHeight != 2 { - t.Fatalf("expected tip height 2, got %d", tipHeight) - } - - chain2 := core.NewChain() - utxos2 := core.NewUTXOSet() - - for h := uint64(0); h <= tipHeight; h++ { - data, err := db2.GetRawBlockByHeight(h) - if err != nil || data == nil { - t.Fatalf("missing block at height %d", h) - } - var block core.Block - if err := json.Unmarshal(data, &block); err != nil { - t.Fatalf("unmarshal block %d: %v", h, err) - } - if h == 0 { - _ = chain2.SetGenesis(&block) - } else { - _ = chain2.AddBlock(&block) - } - } - - _ = db2.IterUTXOs(func(su *store.StoredUTXO) error { - utxos2.Add(&core.UTXO{TxHash: su.TxHash, OutputIndex: su.OutputIndex}) - return nil - }) - - if chain2.Height() != 2 { - t.Errorf("restored chain height = %d, want 2", chain2.Height()) - } - t.Logf("chain restored: height=%d utxos=%d", chain2.Height(), utxos2.Count()) + dir := t.TempDir() + + // ── First run: create genesis + 2 blocks ────────────────────────────────── + genesisFile := writeTempGenesis(t) + defer os.Remove(genesisFile) + + db1, _ := store.Open(dir) + + genesisCfg, err := core.LoadGenesis(genesisFile) + if err != nil { + t.Fatal(err) + } + priv, _, _ := crypto.GenerateValidatorKey() + genesis, _ := core.CreateGenesisBlock(genesisCfg, priv) + + chain1 := core.NewChain() + utxos1 := core.NewUTXOSet() + _ = chain1.SetGenesis(genesis) + + // helper: persist + persistBlock := func(db *store.DB, block *core.Block, utxos *core.UTXOSet) { + data, _ := json.Marshal(block) + bh := block.Hash() + _ = db.PutRawBlock(bh, block.Header.Height, data) + _ = utxos.ApplyBlock(block) + for _, tx := range block.Txs { + txHash := tx.Hash() + for _, inp := range tx.Inputs { + _ = db.MarkKeyImageSpent(inp.KeyImage) + } + for i, out := range tx.Outputs { + su := &store.StoredUTXO{ + TxHash: txHash, OutputIndex: uint32(i), + OneTimePub: out.OneTimePub, AmountCommit: out.AmountCommit, + BlockHeight: block.Header.Height, + } + _ = db.PutUTXO(txHash, uint32(i), su) + } + } + _ = db.PutTip(bh, block.Header.Height) + } + + persistBlock(db1, genesis, utxos1) + + // Add block 1 and block 2 (no txs, just header chain) + addAndPersist := func(db *store.DB, chain *core.Chain, utxos *core.UTXOSet, height uint64) *core.Block { + prev := chain.Tip() + prevHash := prev.Hash() + hdr := core.BlockHeader{ + Height: height, + PrevHash: prevHash, + Timestamp: int64(1000000 + height*1000), + ValidatorPub: genesis.Header.ValidatorPub, + } + hdr.MerkleRoot = core.MerkleRoot(nil) + _ = hdr.Sign(priv) + b := &core.Block{Header: hdr} + _ = chain.AddBlock(b) + persistBlock(db, b, utxos) + return b + } + addAndPersist(db1, chain1, utxos1, 1) + addAndPersist(db1, chain1, utxos1, 2) + db1.Close() + + // ── Second run: restore from DB ──────────────────────────────────────────── + db2, _ := store.Open(dir) + defer db2.Close() + + _, tipHeight, err := db2.GetTip() + if err != nil { + t.Fatal(err) + } + if tipHeight != 2 { + t.Fatalf("expected tip height 2, got %d", tipHeight) + } + + chain2 := core.NewChain() + utxos2 := core.NewUTXOSet() + + for h := uint64(0); h <= tipHeight; h++ { + data, err := db2.GetRawBlockByHeight(h) + if err != nil || data == nil { + t.Fatalf("missing block at height %d", h) + } + var block core.Block + if err := json.Unmarshal(data, &block); err != nil { + t.Fatalf("unmarshal block %d: %v", h, err) + } + if h == 0 { + _ = chain2.SetGenesis(&block) + } else { + _ = chain2.AddBlock(&block) + } + } + + _ = db2.IterUTXOs(func(su *store.StoredUTXO) error { + utxos2.Add(&core.UTXO{TxHash: su.TxHash, OutputIndex: su.OutputIndex}) + return nil + }) + + if chain2.Height() != 2 { + t.Errorf("restored chain height = %d, want 2", chain2.Height()) + } + t.Logf("chain restored: height=%d utxos=%d", chain2.Height(), utxos2.Count()) } // ─── Snapshot save duration round-trip ─────────────────────────────────────── @@ -222,124 +222,124 @@ func TestChainPersistRestore(t *testing.T) { // and LoadSnapshotSaveDuration form a correct round-trip for representative // values: zero, a typical mid-range value, and the maximum int64. func TestDB_SnapshotSaveDuration_RoundTrip(t *testing.T) { - dir := t.TempDir() - db, err := store.Open(dir) - if err != nil { - t.Fatalf("open store: %v", err) - } - defer db.Close() - - cases := []struct { - name string - ms int64 - }{ - {"zero", 0}, - {"typical_1234ms", 1234}, - {"max_int64", 9223372036854775807}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := db.StoreSnapshotSaveDuration(tc.ms); err != nil { - t.Fatalf("StoreSnapshotSaveDuration(%d): %v", tc.ms, err) - } - got, found, err := db.LoadSnapshotSaveDuration() - if err != nil { - t.Fatalf("LoadSnapshotSaveDuration: %v", err) - } - if !found { - t.Fatal("LoadSnapshotSaveDuration: found=false after store") - } - if got != tc.ms { - t.Errorf("LoadSnapshotSaveDuration = %d, want %d", got, tc.ms) - } - }) - } + dir := t.TempDir() + db, err := store.Open(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer db.Close() + + cases := []struct { + name string + ms int64 + }{ + {"zero", 0}, + {"typical_1234ms", 1234}, + {"max_int64", 9223372036854775807}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := db.StoreSnapshotSaveDuration(tc.ms); err != nil { + t.Fatalf("StoreSnapshotSaveDuration(%d): %v", tc.ms, err) + } + got, found, err := db.LoadSnapshotSaveDuration() + if err != nil { + t.Fatalf("LoadSnapshotSaveDuration: %v", err) + } + if !found { + t.Fatal("LoadSnapshotSaveDuration: found=false after store") + } + if got != tc.ms { + t.Errorf("LoadSnapshotSaveDuration = %d, want %d", got, tc.ms) + } + }) + } } // TestDB_SnapshotSaveDuration_AbsentBeforeFirstStore verifies that // LoadSnapshotSaveDuration returns (0, false, nil) on a fresh database that // has never had a value written (pre-feature DB or first boot). func TestDB_SnapshotSaveDuration_AbsentBeforeFirstStore(t *testing.T) { - dir := t.TempDir() - db, err := store.Open(dir) - if err != nil { - t.Fatalf("open store: %v", err) - } - defer db.Close() - - got, found, err := db.LoadSnapshotSaveDuration() - if err != nil { - t.Fatalf("LoadSnapshotSaveDuration on empty DB: %v", err) - } - if found { - t.Errorf("expected found=false on empty DB, got found=true (value=%d)", got) - } - if got != 0 { - t.Errorf("expected ms=0 on empty DB, got %d", got) - } + dir := t.TempDir() + db, err := store.Open(dir) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer db.Close() + + got, found, err := db.LoadSnapshotSaveDuration() + if err != nil { + t.Fatalf("LoadSnapshotSaveDuration on empty DB: %v", err) + } + if found { + t.Errorf("expected found=false on empty DB, got found=true (value=%d)", got) + } + if got != 0 { + t.Errorf("expected ms=0 on empty DB, got %d", got) + } } // TestDB_SnapshotSaveDuration_SurvivesReopen verifies that the value persisted // by StoreSnapshotSaveDuration is readable after the DB is closed and reopened — // the core property that makes the restart-survival feature work. func TestDB_SnapshotSaveDuration_SurvivesReopen(t *testing.T) { - dir := t.TempDir() - - // First open: write the value. - db1, err := store.Open(dir) - if err != nil { - t.Fatalf("open store (first): %v", err) - } - const wantMs = int64(7890) - if err := db1.StoreSnapshotSaveDuration(wantMs); err != nil { - t.Fatalf("StoreSnapshotSaveDuration: %v", err) - } - db1.Close() - - // Second open: the value must still be present. - db2, err := store.Open(dir) - if err != nil { - t.Fatalf("open store (second): %v", err) - } - defer db2.Close() - - got, found, err := db2.LoadSnapshotSaveDuration() - if err != nil { - t.Fatalf("LoadSnapshotSaveDuration after reopen: %v", err) - } - if !found { - t.Fatal("LoadSnapshotSaveDuration: found=false after close+reopen — value did not survive restart") - } - if got != wantMs { - t.Errorf("LoadSnapshotSaveDuration after reopen = %d, want %d", got, wantMs) - } + dir := t.TempDir() + + // First open: write the value. + db1, err := store.Open(dir) + if err != nil { + t.Fatalf("open store (first): %v", err) + } + const wantMs = int64(7890) + if err := db1.StoreSnapshotSaveDuration(wantMs); err != nil { + t.Fatalf("StoreSnapshotSaveDuration: %v", err) + } + db1.Close() + + // Second open: the value must still be present. + db2, err := store.Open(dir) + if err != nil { + t.Fatalf("open store (second): %v", err) + } + defer db2.Close() + + got, found, err := db2.LoadSnapshotSaveDuration() + if err != nil { + t.Fatalf("LoadSnapshotSaveDuration after reopen: %v", err) + } + if !found { + t.Fatal("LoadSnapshotSaveDuration: found=false after close+reopen — value did not survive restart") + } + if got != wantMs { + t.Errorf("LoadSnapshotSaveDuration after reopen = %d, want %d", got, wantMs) + } } // writeTempGenesis creates a minimal genesis YAML file for testing. // It generates a fresh validator key so that min_validators=1 is satisfied. func writeTempGenesis(t *testing.T) string { - t.Helper() - _, pub, err := crypto.GenerateValidatorKey() - if err != nil { - t.Fatal(err) - } - // Encode public key as hex for the YAML - hexPub := fmt.Sprintf("%x", []byte(pub)) - content := "chain_id: test-persist\n" + - "timestamp: 1000000\n" + - "initial_supply: 21000000\n" + - "block_time_ms: 5000\n" + - "ring_size: 16\n" + - "min_validators: 1\n" + - "bft_threshold: 0.667\n" + - "validators:\n" + - " - " + hexPub + "\n" - f, err2 := os.CreateTemp("", "genesis-*.yaml") - if err2 != nil { - t.Fatal(err2) - } - _, _ = f.WriteString(content) - f.Close() - return f.Name() + t.Helper() + _, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + // Encode public key as hex for the YAML + hexPub := fmt.Sprintf("%x", []byte(pub)) + content := "chain_id: test-persist\n" + + "timestamp: 1000000\n" + + "initial_supply: 21000000\n" + + "block_time_ms: 5000\n" + + "ring_size: 16\n" + + "min_validators: 1\n" + + "bft_threshold: 0.667\n" + + "validators:\n" + + " - " + hexPub + "\n" + f, err2 := os.CreateTemp("", "genesis-*.yaml") + if err2 != nil { + t.Fatal(err2) + } + _, _ = f.WriteString(content) + f.Close() + return f.Name() } diff --git a/store/txindex_fastpath_test.go b/store/txindex_fastpath_test.go index c148b95..a7eeddb 100644 --- a/store/txindex_fastpath_test.go +++ b/store/txindex_fastpath_test.go @@ -276,7 +276,7 @@ func TestFastForwardWithIndex_TrustsProvidedHashes(t *testing.T) { blk1 := buildTxIdxBlock(t, priv, pub, 1, genesis.Hash(), 2) blk2 := buildTxIdxBlock(t, priv, pub, 2, blk1.Hash(), 3) - // Assign each tx a synthetic (non-real) hash with a recognisable pattern. + // Assign each tx a synthetic (non-real) hash with a recognizable pattern. // The real tx.Hash() values will be different from these. type syntheticEntry struct { hash crypto.Hash32 diff --git a/store/wallet_read_snapshot.go b/store/wallet_read_snapshot.go index bafac58..a554801 100644 --- a/store/wallet_read_snapshot.go +++ b/store/wallet_read_snapshot.go @@ -8,13 +8,15 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" "sync" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/util" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // WalletReadSnapshot pins the wallet-facing namespaces to one LevelDB view. @@ -223,7 +225,11 @@ func (s *WalletReadSnapshot) loadLPoDCheckpointAtWithBudget( // for the requested funding block. func (s *WalletReadSnapshot) LPoDWalletIndexReady(funding crypto.Hash32) (bool, error) { value, err := s.get(lpodWalletReadyKey) - return string(value) == string(funding[:]), err + if err != nil { + return false, err + } + backfilled, err := s.get(lpodWalletBackfillReadyKey) + return bytes.Equal(value, funding[:]) && bytes.Equal(backfilled, funding[:]), err } // LPoDEarningsIndexReady reports whether the earnings index was complete at @@ -350,6 +356,10 @@ type WalletCursorReorgError struct { Height uint64 } +// ErrInvalidWalletResumeCursor identifies invalid caller-supplied positions, +// independently of failures reading the pinned storage view. +var ErrInvalidWalletResumeCursor = errors.New("invalid wallet resume cursor") + func (e *WalletCursorReorgError) Error() string { return fmt.Sprintf("wallet resume cursor is noncanonical at height %d", e.Height) } @@ -413,14 +423,14 @@ func (s *WalletReadSnapshot) lpodWalletOutputsAfterHeight( if resumeCursor != "" { key, err := hex.DecodeString(resumeCursor) if err != nil || !bytes.HasPrefix(key, prefix) || len(key) != len(prefix)+76 { - return nil, "", "", fmt.Errorf("invalid resume_cursor") + return nil, "", "", fmt.Errorf("%w: invalid encoding or owner", ErrInvalidWalletResumeCursor) } height := binary.BigEndian.Uint64(key[len(prefix) : len(prefix)+8]) if height > throughHeight { - return nil, "", "", fmt.Errorf("resume_cursor is above through_height") + return nil, "", "", fmt.Errorf("%w: above through_height", ErrInvalidWalletResumeCursor) } if hasAfterHeight && height <= afterHeight { - return nil, "", "", fmt.Errorf("resume_cursor is not after after_height") + return nil, "", "", fmt.Errorf("%w: not after after_height", ErrInvalidWalletResumeCursor) } canonical, found, err := s.GetCanonicalHash(height) if err != nil { @@ -434,6 +444,9 @@ func (s *WalletReadSnapshot) lpodWalletOutputsAfterHeight( return nil, "", "", &WalletCursorReorgError{Height: height} } ok = iter.Seek(key) + if !ok && iter.Error() != nil { + return nil, "", "", iter.Error() + } if !ok || !bytes.Equal(iter.Key(), key) { return nil, "", "", &WalletReconciliationError{Height: height, Reason: "resume cursor index row is missing"} } diff --git a/store/wallet_read_snapshot_test.go b/store/wallet_read_snapshot_test.go index 61274ef..b4b7d11 100644 --- a/store/wallet_read_snapshot_test.go +++ b/store/wallet_read_snapshot_test.go @@ -5,9 +5,10 @@ import ( "encoding/json" "testing" + "github.com/syndtr/goleveldb/leveldb" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" - "github.com/syndtr/goleveldb/leveldb" ) func TestWalletReadSnapshotPinsWalletReadsAndReleases(t *testing.T) { diff --git a/wallet/builder.go b/wallet/builder.go index 4382951..ae7351f 100644 --- a/wallet/builder.go +++ b/wallet/builder.go @@ -6,18 +6,18 @@ // // Typical flow: // -// dk, _ := wallet.DeriveFromMnemonic(mnemonic, "", 0, 0) -// b, _ := wallet.NewBuilder(dk, chain) -// result, _ := b.Send(1_000_000, recipientAddr) -// // result.Tx is a signed core.Transaction — broadcast via API. +// dk, _ := wallet.DeriveFromMnemonic(mnemonic, "", 0, 0) +// b, _ := wallet.NewBuilder(dk, chain) +// result, _ := b.Send(1_000_000, recipientAddr) +// // result.Tx is a signed core.Transaction — broadcast via API. package wallet import ( - "encoding/json" - "fmt" + "encoding/json" + "fmt" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // DefaultFeePerByte is the baseline fee rate used when no custom rate is set. @@ -27,34 +27,34 @@ const DefaultFeePerByte uint64 = 1 // Broadcaster is an optional hook the Builder calls after signing a transaction. // Implementations can POST to the JSON-RPC endpoint or any other transport. type Broadcaster interface { - Broadcast(tx *core.Transaction) error + Broadcast(tx *core.Transaction) error } // Builder assembles and optionally broadcasts signed RingCT transactions. // It owns a Scanner to discover owned UTXOs and a DerivedKeys bundle for // spend-key signing. type Builder struct { - dk *DerivedKeys - scanner *Scanner - chain *core.Chain + dk *DerivedKeys + scanner *Scanner + chain *core.Chain - feePerByte uint64 - broadcaster Broadcaster + feePerByte uint64 + broadcaster Broadcaster } // BuildResult wraps the signed transaction and accounting summary. type BuildResult struct { - Tx core.Transaction `json:"tx"` - ChangeAmount uint64 `json:"change_amount"` - TotalFee uint64 `json:"total_fee"` - InputCount int `json:"input_count"` - OutputCount int `json:"output_count"` + Tx core.Transaction `json:"tx"` + ChangeAmount uint64 `json:"change_amount"` + TotalFee uint64 `json:"total_fee"` + InputCount int `json:"input_count"` + OutputCount int `json:"output_count"` } -// MarshalJSON serialises BuildResult to JSON (for API / CLI use). +// MarshalJSON serializes BuildResult to JSON (for API / CLI use). func (r *BuildResult) MarshalJSON() ([]byte, error) { - type Alias BuildResult - return json.Marshal((*Alias)(r)) + type Alias BuildResult + return json.Marshal((*Alias)(r)) } // Option is a functional option for Builder. @@ -62,50 +62,50 @@ type Option func(*Builder) // WithFeePerByte overrides the default fee rate (base units per byte). func WithFeePerByte(rate uint64) Option { - return func(b *Builder) { - if rate > 0 { - b.feePerByte = rate - } - } + return func(b *Builder) { + if rate > 0 { + b.feePerByte = rate + } + } } // WithBroadcaster sets a Broadcaster that is called automatically by Send(). func WithBroadcaster(br Broadcaster) Option { - return func(b *Builder) { b.broadcaster = br } + return func(b *Builder) { b.broadcaster = br } } // NewBuilder creates a Builder for the given HD-wallet keys and chain. // The chain is used by the Scanner to discover owned UTXOs. func NewBuilder(dk *DerivedKeys, chain *core.Chain, opts ...Option) (*Builder, error) { - if dk == nil { - return nil, fmt.Errorf("builder: DerivedKeys must not be nil") - } - if chain == nil { - return nil, fmt.Errorf("builder: Chain must not be nil") - } - scanner := NewScannerFromDerived(dk) - b := &Builder{ - dk: dk, - scanner: scanner, - chain: chain, - feePerByte: DefaultFeePerByte, - } - for _, o := range opts { - o(b) - } - return b, nil + if dk == nil { + return nil, fmt.Errorf("builder: DerivedKeys must not be nil") + } + if chain == nil { + return nil, fmt.Errorf("builder: Chain must not be nil") + } + scanner := NewScannerFromDerived(dk) + b := &Builder{ + dk: dk, + scanner: scanner, + chain: chain, + feePerByte: DefaultFeePerByte, + } + for _, o := range opts { + o(b) + } + return b, nil } // Balance scans the chain for owned UTXOs and returns the total spendable // balance in base units together with the individual UTXOs. func (b *Builder) Balance() (balance uint64, owned []core.OwnedUTXO, err error) { - balance, owned = b.scanner.Balance(b.chain) - return + balance, owned = b.scanner.Balance(b.chain) + return } // Address returns the Aperod address derived from the wallet keys. func (b *Builder) Address() crypto.Address { - return b.scanner.Address() + return b.scanner.Address() } // Send builds, signs and (if a Broadcaster is configured) broadcasts a @@ -113,58 +113,58 @@ func (b *Builder) Address() crypto.Address { // // changeAddr defaults to the sender's own address when left empty. func (b *Builder) Send(amount uint64, recipient crypto.Address, changeAddr ...crypto.Address) (*BuildResult, error) { - // ── Resolve change address ───────────────────────────────────────────── - var change crypto.Address - if len(changeAddr) > 0 && changeAddr[0] != "" { - change = changeAddr[0] - } else { - change = b.scanner.Address() - } - - // ── Scan for spendable UTXOs ─────────────────────────────────────────── - _, owned, err := b.Balance() - if err != nil { - return nil, fmt.Errorf("builder.Send: balance scan: %w", err) - } - spendable := SpendableUTXOs(owned) - if len(spendable) == 0 { - return nil, fmt.Errorf("builder.Send: no spendable UTXOs") - } - - // ── Delegate to core.TxBuilder ──────────────────────────────────────── - tb := core.NewTxBuilder( - b.dk.Keys.Spend.Private, - b.dk.Keys.View.Private, - b.dk.Keys.Spend.Public, - spendable, - b.feePerByte, - ) - coreResult, err := tb.Build(amount, recipient, change) - if err != nil { - return nil, fmt.Errorf("builder.Send: build tx: %w", err) - } - - result := &BuildResult{ - Tx: coreResult.Tx, - ChangeAmount: coreResult.ChangeAmount, - TotalFee: coreResult.TotalFee, - InputCount: coreResult.InputCount, - OutputCount: coreResult.OutputCount, - } - - // ── Optional broadcast ───────────────────────────────────────────────── - if b.broadcaster != nil { - if err := b.broadcaster.Broadcast(&result.Tx); err != nil { - return nil, fmt.Errorf("builder.Send: broadcast: %w", err) - } - } - - return result, nil + // ── Resolve change address ───────────────────────────────────────────── + var change crypto.Address + if len(changeAddr) > 0 && changeAddr[0] != "" { + change = changeAddr[0] + } else { + change = b.scanner.Address() + } + + // ── Scan for spendable UTXOs ─────────────────────────────────────────── + _, owned, err := b.Balance() + if err != nil { + return nil, fmt.Errorf("builder.Send: balance scan: %w", err) + } + spendable := SpendableUTXOs(owned) + if len(spendable) == 0 { + return nil, fmt.Errorf("builder.Send: no spendable UTXOs") + } + + // ── Delegate to core.TxBuilder ──────────────────────────────────────── + tb := core.NewTxBuilder( + b.dk.Keys.Spend.Private, + b.dk.Keys.View.Private, + b.dk.Keys.Spend.Public, + spendable, + b.feePerByte, + ) + coreResult, err := tb.Build(amount, recipient, change) + if err != nil { + return nil, fmt.Errorf("builder.Send: build tx: %w", err) + } + + result := &BuildResult{ + Tx: coreResult.Tx, + ChangeAmount: coreResult.ChangeAmount, + TotalFee: coreResult.TotalFee, + InputCount: coreResult.InputCount, + OutputCount: coreResult.OutputCount, + } + + // ── Optional broadcast ───────────────────────────────────────────────── + if b.broadcaster != nil { + if err := b.broadcaster.Broadcast(&result.Tx); err != nil { + return nil, fmt.Errorf("builder.Send: broadcast: %w", err) + } + } + + return result, nil } // EstimateFee returns the estimated fee in base units for a payment that // requires nInputs ring inputs and nOutputs outputs, at the configured rate. // Useful for showing a fee preview in the UI before building the transaction. func (b *Builder) EstimateFee(nInputs, nOutputs int) uint64 { - return core.ExportedEstimateFee(nInputs, nOutputs, b.feePerByte) + return core.ExportedEstimateFee(nInputs, nOutputs, b.feePerByte) } diff --git a/wallet/builder_test.go b/wallet/builder_test.go index 5b6cf9d..1f8b2e4 100644 --- a/wallet/builder_test.go +++ b/wallet/builder_test.go @@ -1,158 +1,158 @@ package wallet import ( - "testing" + "testing" - "github.com/aperod/aperod/core" - "github.com/aperod/aperod/crypto" + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" ) // ─── NewBuilder validation ──────────────────────────────────────────────────── func TestNewBuilder_NilDerivedKeys(t *testing.T) { - chain := core.NewChain() - _, err := NewBuilder(nil, chain) - if err == nil { - t.Fatal("expected error for nil DerivedKeys") - } + chain := core.NewChain() + _, err := NewBuilder(nil, chain) + if err == nil { + t.Fatal("expected error for nil DerivedKeys") + } } func TestNewBuilder_NilChain(t *testing.T) { - mnemonic, err := GenerateMnemonic(Strength128) - if err != nil { - t.Fatalf("GenerateMnemonic: %v", err) - } - dk, err := DeriveFromMnemonic(mnemonic, "", 0, 0) - if err != nil { - t.Fatalf("DeriveFromMnemonic: %v", err) - } - _, err = NewBuilder(dk, nil) - if err == nil { - t.Fatal("expected error for nil chain") - } + mnemonic, err := GenerateMnemonic(Strength128) + if err != nil { + t.Fatalf("GenerateMnemonic: %v", err) + } + dk, err := DeriveFromMnemonic(mnemonic, "", 0, 0) + if err != nil { + t.Fatalf("DeriveFromMnemonic: %v", err) + } + _, err = NewBuilder(dk, nil) + if err == nil { + t.Fatal("expected error for nil chain") + } } func TestNewBuilder_OK(t *testing.T) { - dk, chain := mustDeriveAndChain(t) - b, err := NewBuilder(dk, chain) - if err != nil { - t.Fatalf("NewBuilder: %v", err) - } - if b == nil { - t.Fatal("builder is nil") - } + dk, chain := mustDeriveAndChain(t) + b, err := NewBuilder(dk, chain) + if err != nil { + t.Fatalf("NewBuilder: %v", err) + } + if b == nil { + t.Fatal("builder is nil") + } } // ─── Address ───────────────────────────────────────────────────────────────── func TestBuilder_Address(t *testing.T) { - dk, chain := mustDeriveAndChain(t) - b, _ := NewBuilder(dk, chain) - addr := b.Address() - if err := crypto.Validate(addr); err != nil { - t.Fatalf("builder.Address() is invalid: %v", err) - } + dk, chain := mustDeriveAndChain(t) + b, _ := NewBuilder(dk, chain) + addr := b.Address() + if err := crypto.Validate(addr); err != nil { + t.Fatalf("builder.Address() is invalid: %v", err) + } } // ─── EstimateFee ────────────────────────────────────────────────────────────── func TestBuilder_EstimateFee(t *testing.T) { - dk, chain := mustDeriveAndChain(t) - b, _ := NewBuilder(dk, chain) - fee := b.EstimateFee(1, 2) - if fee == 0 { - t.Error("EstimateFee returned 0 for 1-in 2-out tx") - } - // More inputs → higher fee. - fee2 := b.EstimateFee(3, 2) - if fee2 <= fee { - t.Errorf("expected fee2(%d) > fee(%d) for more inputs", fee2, fee) - } + dk, chain := mustDeriveAndChain(t) + b, _ := NewBuilder(dk, chain) + fee := b.EstimateFee(1, 2) + if fee == 0 { + t.Error("EstimateFee returned 0 for 1-in 2-out tx") + } + // More inputs → higher fee. + fee2 := b.EstimateFee(3, 2) + if fee2 <= fee { + t.Errorf("expected fee2(%d) > fee(%d) for more inputs", fee2, fee) + } } // ─── WithFeePerByte ─────────────────────────────────────────────────────────── func TestBuilder_WithFeePerByte(t *testing.T) { - dk, chain := mustDeriveAndChain(t) - b1, _ := NewBuilder(dk, chain, WithFeePerByte(1)) - b10, _ := NewBuilder(dk, chain, WithFeePerByte(10)) - f1 := b1.EstimateFee(1, 2) - f10 := b10.EstimateFee(1, 2) - if f10 != f1*10 { - t.Errorf("fee@10 (%d) should be 10× fee@1 (%d)", f10, f1) - } + dk, chain := mustDeriveAndChain(t) + b1, _ := NewBuilder(dk, chain, WithFeePerByte(1)) + b10, _ := NewBuilder(dk, chain, WithFeePerByte(10)) + f1 := b1.EstimateFee(1, 2) + f10 := b10.EstimateFee(1, 2) + if f10 != f1*10 { + t.Errorf("fee@10 (%d) should be 10× fee@1 (%d)", f10, f1) + } } // ─── Balance (empty chain) ──────────────────────────────────────────────────── func TestBuilder_Balance_EmptyChain(t *testing.T) { - dk, chain := mustDeriveAndChain(t) - b, _ := NewBuilder(dk, chain) - balance, owned, err := b.Balance() - if err != nil { - t.Fatalf("Balance: %v", err) - } - if balance != 0 { - t.Errorf("expected 0 balance on empty chain, got %d", balance) - } - if len(owned) != 0 { - t.Errorf("expected 0 owned UTXOs on empty chain, got %d", len(owned)) - } + dk, chain := mustDeriveAndChain(t) + b, _ := NewBuilder(dk, chain) + balance, owned, err := b.Balance() + if err != nil { + t.Fatalf("Balance: %v", err) + } + if balance != 0 { + t.Errorf("expected 0 balance on empty chain, got %d", balance) + } + if len(owned) != 0 { + t.Errorf("expected 0 owned UTXOs on empty chain, got %d", len(owned)) + } } // ─── Send: no funds ────────────────────────────────────────────────────────── func TestBuilder_Send_InsufficientFunds(t *testing.T) { - dk, chain := mustDeriveAndChain(t) - b, _ := NewBuilder(dk, chain) - - // Recipient: generate a fresh wallet address. - recvKeys, err := crypto.GenerateWalletKeys() - if err != nil { - t.Fatalf("GenerateWalletKeys: %v", err) - } - recipientAddr := crypto.EncodeAddress(crypto.MainnetByte, recvKeys.Spend.Public, recvKeys.View.Public) - - _, err = b.Send(1_000_000, recipientAddr) - if err == nil { - t.Fatal("expected error when no UTXOs") - } + dk, chain := mustDeriveAndChain(t) + b, _ := NewBuilder(dk, chain) + + // Recipient: generate a fresh wallet address. + recvKeys, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatalf("GenerateWalletKeys: %v", err) + } + recipientAddr := crypto.EncodeAddress(crypto.MainnetByte, recvKeys.Spend.Public, recvKeys.View.Public) + + _, err = b.Send(1_000_000, recipientAddr) + if err == nil { + t.Fatal("expected error when no UTXOs") + } } // ─── WithBroadcaster ────────────────────────────────────────────────────────── func TestBuilder_WithBroadcaster_Option(t *testing.T) { - dk, chain := mustDeriveAndChain(t) - br := &mockBroadcaster{} - b, err := NewBuilder(dk, chain, WithBroadcaster(br)) - if err != nil { - t.Fatalf("NewBuilder: %v", err) - } - if b.broadcaster == nil { - t.Error("broadcaster was not set by WithBroadcaster") - } + dk, chain := mustDeriveAndChain(t) + br := &mockBroadcaster{} + b, err := NewBuilder(dk, chain, WithBroadcaster(br)) + if err != nil { + t.Fatalf("NewBuilder: %v", err) + } + if b.broadcaster == nil { + t.Error("broadcaster was not set by WithBroadcaster") + } } // ─── helpers ───────────────────────────────────────────────────────────────── func mustDeriveAndChain(t *testing.T) (*DerivedKeys, *core.Chain) { - t.Helper() - mnemonic, err := GenerateMnemonic(Strength128) - if err != nil { - t.Fatalf("GenerateMnemonic: %v", err) - } - dk, err := DeriveFromMnemonic(mnemonic, "", 0, 0) - if err != nil { - t.Fatalf("DeriveFromMnemonic: %v", err) - } - return dk, core.NewChain() + t.Helper() + mnemonic, err := GenerateMnemonic(Strength128) + if err != nil { + t.Fatalf("GenerateMnemonic: %v", err) + } + dk, err := DeriveFromMnemonic(mnemonic, "", 0, 0) + if err != nil { + t.Fatalf("DeriveFromMnemonic: %v", err) + } + return dk, core.NewChain() } type mockBroadcaster struct { - called int + called int } func (m *mockBroadcaster) Broadcast(_ *core.Transaction) error { - m.called++ - return nil + m.called++ + return nil } diff --git a/wallet/hd.go b/wallet/hd.go index 57b47d9..9277773 100644 --- a/wallet/hd.go +++ b/wallet/hd.go @@ -30,7 +30,7 @@ type extKey struct { } // masterFromSeed derives the root extended key from a 64-byte BIP39 seed. -// SLIP-0010: HMAC-SHA512("ed25519 seed", seed) +// SLIP-0010: HMAC-SHA512("ed25519 seed", seed). func masterFromSeed(seed []byte) (*extKey, error) { if len(seed) != 64 { return nil, errors.New("hd: seed must be 64 bytes") @@ -68,7 +68,7 @@ func (ek *extKey) child(index uint32) (*extKey, error) { // DerivedKeys holds the Aperod spend + view key pair at a specific BIP44 path. type DerivedKeys struct { - Path string // e.g. "m/44'/7777'/0'/0'/0'" + Path string // e.g. "m/44'/7777'/0'/0'/0'" Keys *crypto.WalletKeyPair Address crypto.Address AccountIndex uint32 @@ -76,7 +76,7 @@ type DerivedKeys struct { } // DeriveKeys derives Aperod spend+view keys from a 64-byte BIP39 seed. -// BIP44 path: m/44'/7777'/account'/0'/index' +// BIP44 path: m/44'/7777'/account'/0'/index'. func DeriveKeys(seed []byte, account, index uint32) (*DerivedKeys, error) { // The hardened bit is part of the encoded child index, not part of the // caller's index. Reject it up front so distinct uint32 inputs cannot diff --git a/wallet/keystore.go b/wallet/keystore.go index 570d171..eb98fc9 100644 --- a/wallet/keystore.go +++ b/wallet/keystore.go @@ -5,54 +5,54 @@ package wallet // Task 3.1.3. import ( - "crypto/aes" - "crypto/cipher" + "crypto/aes" + "crypto/cipher" "crypto/hmac" - "crypto/rand" - "crypto/sha256" + "crypto/rand" + "crypto/sha256" "encoding/binary" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "time" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "time" ) // KDF parameters for PBKDF2-SHA256. // 200000 iterations matches 2024 OWASP recommendations for PBKDF2-SHA256. const ( - pbkdf2Iter = 200000 - keyLen = 32 // AES-256 + pbkdf2Iter = 200000 + keyLen = 32 // AES-256 ) // KeystoreV1 is the JSON-serialisable encrypted wallet record. Version 1 is // the legacy format; version 2 authenticates the HD derivation metadata. type KeystoreV1 struct { - Version int `json:"version"` - Address string `json:"address"` - AccountIndex *uint32 `json:"account_index,omitempty"` - AddressIndex *uint32 `json:"address_index,omitempty"` - CreatedAt string `json:"created_at"` - Crypto keystoreCrypto `json:"crypto"` + Version int `json:"version"` + Address string `json:"address"` + AccountIndex *uint32 `json:"account_index,omitempty"` + AddressIndex *uint32 `json:"address_index,omitempty"` + CreatedAt string `json:"created_at"` + Crypto keystoreCrypto `json:"crypto"` } type keystoreCrypto struct { - Cipher string `json:"cipher"` // "aes-256-gcm" - CipherText string `json:"ciphertext"` // hex - Nonce string `json:"nonce"` // hex (12 bytes) - KDF string `json:"kdf"` // "pbkdf2-sha256" - KDFParams pbkdf2Params `json:"kdfparams"` - MAC string `json:"mac"` // hex authentication MAC + Cipher string `json:"cipher"` // "aes-256-gcm" + CipherText string `json:"ciphertext"` // hex + Nonce string `json:"nonce"` // hex (12 bytes) + KDF string `json:"kdf"` // "pbkdf2-sha256" + KDFParams pbkdf2Params `json:"kdfparams"` + MAC string `json:"mac"` // hex authentication MAC } type pbkdf2Params struct { - Iter int `json:"iter"` - Salt string `json:"salt"` // hex (32 bytes) + Iter int `json:"iter"` + Salt string `json:"salt"` // hex (32 bytes) } // EncryptMnemonic encrypts the mnemonic phrase with password and returns a -// KeystoreV1 struct that can be marshalled to JSON for persistent storage. +// KeystoreV1 struct that can be marshaled to JSON for persistent storage. func EncryptMnemonic(mnemonic, password, address string) (*KeystoreV1, error) { return encryptMnemonic(mnemonic, password, address, nil, nil) } @@ -68,67 +68,67 @@ func EncryptMnemonicWithDerivation(mnemonic, password, address string, account, } func encryptMnemonic(mnemonic, password, address string, account, index *uint32) (*KeystoreV1, error) { - if mnemonic == "" { - return nil, errors.New("keystore: mnemonic is empty") - } - if password == "" { - return nil, errors.New("keystore: password is required") - } + if mnemonic == "" { + return nil, errors.New("keystore: mnemonic is empty") + } + if password == "" { + return nil, errors.New("keystore: password is required") + } - // Generate random salt and nonce - salt := make([]byte, 32) - if _, err := io.ReadFull(rand.Reader, salt); err != nil { - return nil, fmt.Errorf("keystore: salt: %w", err) - } - nonce := make([]byte, 12) // GCM standard nonce size - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, fmt.Errorf("keystore: nonce: %w", err) - } + // Generate random salt and nonce + salt := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("keystore: salt: %w", err) + } + nonce := make([]byte, 12) // GCM standard nonce size + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("keystore: nonce: %w", err) + } - // Derive AES key from password using PBKDF2-SHA256 - aesKey := pbkdf2SHA256([]byte(password), salt, pbkdf2Iter, keyLen) + // Derive AES key from password using PBKDF2-SHA256 + aesKey := pbkdf2SHA256([]byte(password), salt, pbkdf2Iter, keyLen) - // Encrypt with AES-256-GCM - block, err := aes.NewCipher(aesKey) - if err != nil { - return nil, fmt.Errorf("keystore: cipher: %w", err) - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("keystore: gcm: %w", err) - } - ciphertext := gcm.Seal(nil, nonce, []byte(mnemonic), nil) + // Encrypt with AES-256-GCM + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("keystore: cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("keystore: gcm: %w", err) + } + ciphertext := gcm.Seal(nil, nonce, []byte(mnemonic), nil) - // Preserve the legacy MAC for keystores without derivation metadata. - // New metadata-bearing keystores authenticate the address and indices - // so an edited path cannot silently relabel the wallet. - mac := computeMAC(aesKey, ciphertext) - if account != nil && index != nil { - mac = computeMACWithDerivation(aesKey, ciphertext, address, *account, *index) - } + // Preserve the legacy MAC for keystores without derivation metadata. + // New metadata-bearing keystores authenticate the address and indices + // so an edited path cannot silently relabel the wallet. + mac := computeMAC(aesKey, ciphertext) + if account != nil && index != nil { + mac = computeMACWithDerivation(aesKey, ciphertext, address, *account, *index) + } - version := 1 - if account != nil && index != nil { - version = 2 - } - return &KeystoreV1{ - Version: version, - Address: address, - AccountIndex: account, - AddressIndex: index, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - Crypto: keystoreCrypto{ - Cipher: "aes-256-gcm", - CipherText: hex.EncodeToString(ciphertext), - Nonce: hex.EncodeToString(nonce), - KDF: "pbkdf2-sha256", - KDFParams: pbkdf2Params{ - Iter: pbkdf2Iter, - Salt: hex.EncodeToString(salt), - }, - MAC: hex.EncodeToString(mac), - }, - }, nil + version := 1 + if account != nil && index != nil { + version = 2 + } + return &KeystoreV1{ + Version: version, + Address: address, + AccountIndex: account, + AddressIndex: index, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Crypto: keystoreCrypto{ + Cipher: "aes-256-gcm", + CipherText: hex.EncodeToString(ciphertext), + Nonce: hex.EncodeToString(nonce), + KDF: "pbkdf2-sha256", + KDFParams: pbkdf2Params{ + Iter: pbkdf2Iter, + Salt: hex.EncodeToString(salt), + }, + MAC: hex.EncodeToString(mac), + }, + }, nil } // VerifyDerivation ensures a requested derivation matches the authenticated @@ -167,95 +167,95 @@ func (ks *KeystoreV1) VerifyDerivation(account, index uint32, address string) er // DecryptMnemonic decrypts a KeystoreV1 record using the provided password. // Returns the plaintext mnemonic or an error if the password is wrong. func DecryptMnemonic(ks *KeystoreV1, password string) (string, error) { - if ks == nil { - return "", errors.New("keystore: missing keystore") - } - if ks.Version != 1 && ks.Version != 2 { - return "", fmt.Errorf("keystore: unsupported version %d", ks.Version) - } - if ks.Crypto.Cipher != "aes-256-gcm" { - return "", fmt.Errorf("keystore: unsupported cipher %q", ks.Crypto.Cipher) - } + if ks == nil { + return "", errors.New("keystore: missing keystore") + } + if ks.Version != 1 && ks.Version != 2 { + return "", fmt.Errorf("keystore: unsupported version %d", ks.Version) + } + if ks.Crypto.Cipher != "aes-256-gcm" { + return "", fmt.Errorf("keystore: unsupported cipher %q", ks.Crypto.Cipher) + } - salt, err := hex.DecodeString(ks.Crypto.KDFParams.Salt) - if err != nil { - return "", fmt.Errorf("keystore: decode salt: %w", err) - } - nonce, err := hex.DecodeString(ks.Crypto.Nonce) - if err != nil { - return "", fmt.Errorf("keystore: decode nonce: %w", err) - } - ciphertext, err := hex.DecodeString(ks.Crypto.CipherText) - if err != nil { - return "", fmt.Errorf("keystore: decode ciphertext: %w", err) - } - expectedMAC, err := hex.DecodeString(ks.Crypto.MAC) - if err != nil { - return "", fmt.Errorf("keystore: decode mac: %w", err) - } + salt, err := hex.DecodeString(ks.Crypto.KDFParams.Salt) + if err != nil { + return "", fmt.Errorf("keystore: decode salt: %w", err) + } + nonce, err := hex.DecodeString(ks.Crypto.Nonce) + if err != nil { + return "", fmt.Errorf("keystore: decode nonce: %w", err) + } + ciphertext, err := hex.DecodeString(ks.Crypto.CipherText) + if err != nil { + return "", fmt.Errorf("keystore: decode ciphertext: %w", err) + } + expectedMAC, err := hex.DecodeString(ks.Crypto.MAC) + if err != nil { + return "", fmt.Errorf("keystore: decode mac: %w", err) + } - // Derive key using PBKDF2-SHA256 - iter := ks.Crypto.KDFParams.Iter - if iter <= 0 { - iter = pbkdf2Iter // default for old keystores - } - aesKey := pbkdf2SHA256([]byte(password), salt, iter, keyLen) + // Derive key using PBKDF2-SHA256 + iter := ks.Crypto.KDFParams.Iter + if iter <= 0 { + iter = pbkdf2Iter // default for old keystores + } + aesKey := pbkdf2SHA256([]byte(password), salt, iter, keyLen) - // Verify the legacy or derivation-bound MAC before decryption. - var gotMAC []byte - switch ks.Version { - case 1: - if ks.AccountIndex != nil || ks.AddressIndex != nil { - return "", errors.New("keystore: version 1 cannot contain derivation metadata") - } - gotMAC = computeMAC(aesKey, ciphertext) - case 2: - if ks.AccountIndex == nil || ks.AddressIndex == nil { - return "", errors.New("keystore: version 2 requires derivation metadata") - } - gotMAC = computeMACWithDerivation(aesKey, ciphertext, ks.Address, *ks.AccountIndex, *ks.AddressIndex) - } - if !macEqual(gotMAC, expectedMAC) { - return "", errors.New("keystore: wrong password or corrupted keystore") - } + // Verify the legacy or derivation-bound MAC before decryption. + var gotMAC []byte + switch ks.Version { + case 1: + if ks.AccountIndex != nil || ks.AddressIndex != nil { + return "", errors.New("keystore: version 1 cannot contain derivation metadata") + } + gotMAC = computeMAC(aesKey, ciphertext) + case 2: + if ks.AccountIndex == nil || ks.AddressIndex == nil { + return "", errors.New("keystore: version 2 requires derivation metadata") + } + gotMAC = computeMACWithDerivation(aesKey, ciphertext, ks.Address, *ks.AccountIndex, *ks.AddressIndex) + } + if !macEqual(gotMAC, expectedMAC) { + return "", errors.New("keystore: wrong password or corrupted keystore") + } - // Decrypt - block, err := aes.NewCipher(aesKey) - if err != nil { - return "", fmt.Errorf("keystore: cipher: %w", err) - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", fmt.Errorf("keystore: gcm: %w", err) - } - plain, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", errors.New("keystore: decryption failed (wrong password?)") - } - return string(plain), nil + // Decrypt + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("keystore: cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("keystore: gcm: %w", err) + } + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", errors.New("keystore: decryption failed (wrong password?)") + } + return string(plain), nil } -// Marshal serialises the keystore to JSON. +// Marshal serializes the keystore to JSON. func (ks *KeystoreV1) Marshal() ([]byte, error) { - return json.MarshalIndent(ks, "", " ") + return json.MarshalIndent(ks, "", " ") } // UnmarshalKeystore parses a JSON-encoded KeystoreV1 record. func UnmarshalKeystore(data []byte) (*KeystoreV1, error) { - var ks KeystoreV1 - if err := json.Unmarshal(data, &ks); err != nil { - return nil, fmt.Errorf("keystore: unmarshal: %w", err) - } - return &ks, nil + var ks KeystoreV1 + if err := json.Unmarshal(data, &ks); err != nil { + return nil, fmt.Errorf("keystore: unmarshal: %w", err) + } + return &ks, nil } // ─── internal helpers ───────────────────────────────────────────────────────── func computeMAC(key, ciphertext []byte) []byte { - h := sha256.New() - h.Write(key) - h.Write(ciphertext) - return h.Sum(nil) + h := sha256.New() + h.Write(key) + h.Write(ciphertext) + return h.Sum(nil) } func computeMACWithDerivation(key, ciphertext []byte, address string, account, index uint32) []byte { @@ -274,12 +274,12 @@ func computeMACWithDerivation(key, ciphertext []byte, address string, account, i } func macEqual(a, b []byte) bool { - if len(a) != len(b) { - return false - } - var diff byte - for i := range a { - diff |= a[i] ^ b[i] - } - return diff == 0 + if len(a) != len(b) { + return false + } + var diff byte + for i := range a { + diff |= a[i] ^ b[i] + } + return diff == 0 } diff --git a/wallet/multisig.go b/wallet/multisig.go index 80c5ec4..4368e03 100644 --- a/wallet/multisig.go +++ b/wallet/multisig.go @@ -24,11 +24,12 @@ import ( "math/big" "filippo.io/edwards25519" + "github.com/aperod/aperod/crypto" ) // edOrder is the prime order of the Ed25519 group. -// l = 2^252 + 27742317777372353535851937790883648493 +// l = 2^252 + 27742317777372353535851937790883648493. var edOrder, _ = new(big.Int).SetString( "7237005577332262213973186563042994240857116359379907606001950938285454250989", 10) @@ -241,7 +242,7 @@ func shamirSplit2of3(secret crypto.Scalar32) ([]crypto.Scalar32, error) { } // lagrangeCoeff returns L_xi(0) for a 2-point interpolation with the other point at xj. -// L_xi(0) = (0 - xj) / (xi - xj) = (−xj) · (xi − xj)^{−1} mod order +// L_xi(0) = (0 - xj) / (xi - xj) = (−xj) · (xi − xj)^{−1} mod order. func lagrangeCoeff(xi, xj int) *big.Int { num := new(big.Int).SetInt64(int64(-xj)) num.Mod(num, edOrder) diff --git a/wallet/wordlist.go b/wallet/wordlist.go index 0009693..a6f8d23 100644 --- a/wallet/wordlist.go +++ b/wallet/wordlist.go @@ -25,7 +25,7 @@ var bip39WordListBase = []string{ "ankle", "announce", "annual", "another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", "army", - "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", + "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", //nolint:misspell // Canonical BIP-39 English list entry. "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", "avoid",