From e41304359a58761fcec11afbe61df6aedcc1fdd2 Mon Sep 17 00:00:00 2001 From: aperod-network Date: Fri, 6 Nov 2026 18:28:45 +0500 Subject: [PATCH] chore: sync verified Aperod node source from selected local main --- api/authority_staking_integration_test.go | 12 +- api/genesis_vesting.go | 1 + api/genesis_vesting_test.go | 8 +- api/genesis_vesting_wallet_test.go | 4 +- api/lpod_daily_jobs_test.go | 6 +- api/lpod_earnings_outputs_test.go | 18 +- api/lpod_positions.go | 31 +- api/lpod_public_docs_status_test.go | 160 ++++++++ api/rest_address_history_test.go | 6 +- api/sale.go | 5 +- api/wallet_history_proof_test.go | 6 +- api/wallet_history_transactions_test.go | 6 +- api/wallet_snapshot_query_failure_test.go | 4 +- api/wallet_snapshot_service_test.go | 4 +- avm/checkpoint.go | 49 ++- avm/recovery_crash_test.go | 15 +- avm/validate.go | 3 +- cmd/avm-checkpoint/main.go | 35 +- cmd/avm-checkpoint/main_test.go | 6 +- cmd/explorer-indexer/main.go | 59 ++- cmd/genesis-reserve/main.go | 36 +- cmd/mint-record-audit/main.go | 17 +- cmd/mint-record-audit/main_test.go | 20 +- cmd/mint-record-audit/whole_opening_test.go | 5 +- cmd/node/finality_migration.go | 1 + cmd/node/main.go | 16 +- cmd/node/maintenance_preflight_test.go | 8 +- cmd/node/registry_bootstrap.go | 15 +- cmd/node/registry_bootstrap_keyimages.go | 10 +- cmd/node/registry_bootstrap_keyimages_test.go | 6 +- cmd/node/registry_bootstrap_storage_test.go | 16 +- cmd/node/registry_bootstrap_test.go | 58 ++- cmd/node/sale_upgrade_preflight.go | 11 +- cmd/node/sale_upgrade_preflight_test.go | 44 ++- cmd/node/snapshot_stream_test.go | 1 - cmd/node/validator_authority.go | 9 +- cmd/ring-index-maintenance/files.go | 84 +++-- cmd/ring-index-maintenance/main.go | 11 +- .../maintenance_test.go | 38 +- cmd/ring-index-maintenance/policy.go | 36 +- cmd/ring-index-maintenance/services.go | 34 +- cmd/ring-index-maintenance/worker.go | 33 +- cmd/ring-index-maintenance/workflow.go | 88 +++-- cmd/sale-epoch-proposal/authority.go | 10 +- cmd/sale-epoch-proposal/main.go | 27 +- cmd/sale-preflight/claims.go | 10 +- cmd/sale-preflight/main.go | 9 +- cmd/wallet-wasm/lpod.go | 37 +- cmd/wallet-wasm/lpod_js_wasm.go | 2 +- cmd/wallet-wasm/lpod_test.go | 49 +++ config/config.go | 9 + consensus/avm_http_stress_linux_test.go | 8 +- consensus/finality_persistence_test.go | 12 +- consensus/lpod.go | 64 +++- consensus/native_chrome_transfer_test.go | 6 +- consensus/poa.go | 6 +- consensus/sale_finality.go | 1 + consensus/sale_http_rehearsal_test.go | 6 +- consensus/sale_integration_test.go | 94 ++++- core/chain.go | 18 +- core/genesis.go | 1 + core/lpod_position.go | 46 ++- core/mint.go | 1 + core/mint_spend_test.go | 1 + core/ringct_v5_test.go | 6 +- core/sale.go | 11 +- core/security_completion_test.go | 6 +- core/staking.go | 1 + core/staking_test.go | 8 +- core/transaction.go | 2 +- core/tx_verifier.go | 13 +- core/validator_authority.go | 5 + core/validator_authority_registry.go | 2 + core/wallet_scanner.go | 6 +- crypto/fuzz_test.go | 9 +- crypto/pedersen.go | 9 +- crypto/ringct.go | 20 +- crypto/security_test.go | 28 +- crypto/value_authorization.go | 3 + deploy/SOURCE-DATA-MIGRATION.md | 106 ++++++ deploy/publication_security.py | 30 ++ deploy/publish_public_repo.py | 124 ++++++- deploy/rollout_cleanup/__init__.py | 3 + deploy/rollout_cleanup/runtime.py | 2 + deploy/source-data-migration-preflight.py | 304 ++++++++++++++++ deploy/sync_public_repo.py | 30 +- deploy/test-backup.sh | 12 +- deploy/test-historical-retirement.py | 7 + deploy/test-rollout-cleanup.py | 154 ++++++++ deploy/test-sync-public-repo-safety.sh | 80 ++-- deploy/test_publication_security.py | 46 +++ deploy/test_publish_public_repo.py | 307 +++++++++++++++- .../test_source_data_migration_preflight.py | 211 +++++++++++ deploy/test_sync_public_repo.py | 22 +- lpod/accounting.go | 4 +- p2p/handshake_budget_test.go | 4 +- p2p/host.go | 11 +- p2p/security_test.go | 84 +++-- sale/accounting.go | 17 +- sale/allocation_epoch.go | 2 + sale/net_allocation.go | 2 + sale/reconciliation.go | 5 +- sale/state.go | 1 + store/finality_migration.go | 22 +- store/genesis_vesting.go | 11 +- store/genesis_vesting_funding.go | 21 +- store/leveldb.go | 56 ++- store/lpod.go | 5 +- store/lpod_earnings_index_test.go | 7 +- store/lpod_positions.go | 344 +++++++++++++++--- store/lpod_positions_test.go | 283 ++++++++++++++ store/lpod_vault_rank.go | 10 +- store/prune.go | 116 ++++-- store/prune_core_block_test.go | 58 +++ store/ring_maintenance_fence.go | 78 +++- store/ring_maintenance_fence_test.go | 48 +-- store/ring_sampling.go | 1 + store/ring_sampling_disk_metrics_test.go | 28 +- store/ring_sampling_disk_rehearsal_test.go | 4 +- store/ring_sampling_test.go | 42 ++- store/sale.go | 40 ++ store/sale_allocation_review.go | 1 + store/sale_config.go | 6 +- store/sale_invoice.go | 2 + store/sale_ledger_reconcile_test.go | 14 +- store/sale_net_reconciliation_test.go | 2 +- store/sale_orders.go | 7 +- store/sale_orders_test.go | 10 +- store/sale_recovery.go | 2 + store/sale_retention.go | 298 +++++++++++++++ store/sale_signed_reserve_test.go | 4 +- store/sale_test.go | 58 ++- store/sale_upgrade_preflight_test.go | 4 +- store/store2_test.go | 29 +- store/store_test.go | 32 +- store/test_helpers_test.go | 15 + store/wallet_read_snapshot_test.go | 20 +- wallet/keystore.go | 18 +- 138 files changed, 4162 insertions(+), 683 deletions(-) create mode 100644 api/lpod_public_docs_status_test.go create mode 100644 deploy/SOURCE-DATA-MIGRATION.md create mode 100644 deploy/source-data-migration-preflight.py create mode 100644 deploy/test_source_data_migration_preflight.py create mode 100644 store/prune_core_block_test.go create mode 100644 store/sale_retention.go create mode 100644 store/test_helpers_test.go diff --git a/api/authority_staking_integration_test.go b/api/authority_staking_integration_test.go index 0b2adb0..310f1c0 100644 --- a/api/authority_staking_integration_test.go +++ b/api/authority_staking_integration_test.go @@ -15,7 +15,11 @@ import ( func TestDailyAuditSpansAuthorityRotationWithSignedStakeOperations(t *testing.T) { var retired crypto.ValidatorPrivKey db, s, _, _ := makeDailyAuditBenchmarkFixtureWithOptions(t, 8, 0, 0, 1, 0, 0, 0, false, true, &retired) - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close authority staking fixture: %v", err) + } + }() rec := httptest.NewRecorder() s.restLPoDAuditDaily(rec, httptest.NewRequest(http.MethodGet, "/?date=2024-01-02", nil)) var result struct { @@ -79,7 +83,11 @@ func TestDailyAuditSpansAuthorityRotationWithSignedStakeOperations(t *testing.T) func TestRotatedLocalStakingUsesCollateralIdentity(t *testing.T) { var retired crypto.ValidatorPrivKey db, s, _, _ := makeDailyAuditBenchmarkFixtureWithOptions(t, 8, 0, 0, 1, 0, 0, 0, false, true, &retired) - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close authority staking fixture: %v", err) + } + }() identity := s.validatorAuthorityFork.Identity got, err := s.localValidatorIdentity(true) if err != nil || !got.Equals(identity) { diff --git a/api/genesis_vesting.go b/api/genesis_vesting.go index a84710e..4be610b 100644 --- a/api/genesis_vesting.go +++ b/api/genesis_vesting.go @@ -14,6 +14,7 @@ import ( "github.com/aperod/aperod/store" ) +// SetGenesisVestingConfig sets the activation height for genesis vesting. func (s *Server) SetGenesisVestingConfig(height uint64) { s.genesisVestingHeight = height } // Public execution evidence comes from the committed reserve ledger, never diff --git a/api/genesis_vesting_test.go b/api/genesis_vesting_test.go index 1188386..8f0edc0 100644 --- a/api/genesis_vesting_test.go +++ b/api/genesis_vesting_test.go @@ -127,7 +127,11 @@ func TestGenesisVestingCanonicalPaymentRestartAndPruning(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { db.Close() }) + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("close reopened genesis vesting database: %v", err) + } + }) commit(deadline+1, false) c, err = db.LoadLPoDCheckpoint() if err != nil || c.GenesisVesting.Paid != amount { @@ -179,7 +183,7 @@ func TestGenesisVestingCanonicalPaymentRestartAndPruning(t *testing.T) { raw, err := db.GetRawBlockByHeight(b.Header.Height) var saved core.Block if err != nil || json.Unmarshal(raw, &saved) != nil || len(saved.Txs) != 2 { - t.Fatal("annual funding/payment evidence was pruned") + t.Fatalf("annual funding/payment evidence at height %d was pruned: txs=%d err=%v", b.Header.Height, len(saved.Txs), err) } } // Canonical tip selection after a reorg selects its reserve checkpoint, diff --git a/api/genesis_vesting_wallet_test.go b/api/genesis_vesting_wallet_test.go index 88c9b8a..65d60a6 100644 --- a/api/genesis_vesting_wallet_test.go +++ b/api/genesis_vesting_wallet_test.go @@ -49,7 +49,9 @@ func testNativeReserveReceiptAndSpend(t *testing.T, amount uint64) { tx := core.Transaction{Version: core.TxVersionLPoDPayout} tx.Outputs = append(tx.Outputs, out) b := &core.Block{Header: core.BlockHeader{Height: i}, Txs: []core.Transaction{tx}} - utxos.ApplyBlock(b) + if err := utxos.ApplyBlock(b); err != nil { + t.Fatal(err) + } if i == 1 { u := utxos.Get(tx.Hash(), 0) hs, err := crypto.ScanForOutput(owner.View.Private, owner.Spend.Public, out.TxPubKey, out.OneTimePub) diff --git a/api/lpod_daily_jobs_test.go b/api/lpod_daily_jobs_test.go index 72c93cb..13ad296 100644 --- a/api/lpod_daily_jobs_test.go +++ b/api/lpod_daily_jobs_test.go @@ -224,7 +224,11 @@ func TestDailyJobStorageBudgetAndRetry(t *testing.T) { func TestDailyJobAdvancingCertificateOracleDoesNotResetCursor(t *testing.T) { const intervalBlocks = 4 * dailyBatchBlocks db, s, _, _ := makeDailyAuditBenchmarkFixtureWithTail(t, intervalBlocks, 1, 128, 4, 0, 4, intervalBlocks) - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close daily audit fixture: %v", err) + } + }() certificate, err := db.LoadFinalityCertificate() if err != nil { t.Fatal(err) diff --git a/api/lpod_earnings_outputs_test.go b/api/lpod_earnings_outputs_test.go index c99ff66..af29b11 100644 --- a/api/lpod_earnings_outputs_test.go +++ b/api/lpod_earnings_outputs_test.go @@ -284,7 +284,9 @@ func seedSyntheticEarningsRows( } if err := iter.Error(); err != nil { iter.Release() - raw.Close() + if closeErr := raw.Close(); closeErr != nil { + t.Errorf("close synthetic earnings index: %v", closeErr) + } t.Fatalf("scan synthetic earnings index: %v", err) } iter.Release() @@ -304,14 +306,18 @@ func seedSyntheticEarningsRows( key = append(key, row.TxHash[:]...) value, err := json.Marshal(row) if err != nil { - raw.Close() + if closeErr := raw.Close(); closeErr != nil { + t.Errorf("close synthetic earnings index: %v", closeErr) + } t.Fatalf("marshal synthetic earnings row %d for %s: %v", i, owner, err) } batch.Put(key, value) } } if err := raw.Write(batch, nil); err != nil { - raw.Close() + if closeErr := raw.Close(); closeErr != nil { + t.Errorf("close synthetic earnings index: %v", closeErr) + } t.Fatalf("write synthetic earnings rows: %v", err) } if err := raw.Close(); err != nil { @@ -322,7 +328,11 @@ func seedSyntheticEarningsRows( if err != nil { t.Fatalf("reopen synthetic store after seeding: %v", err) } - t.Cleanup(func() { reopened.Close() }) + t.Cleanup(func() { + if err := reopened.Close(); err != nil { + t.Errorf("close reopened synthetic store: %v", err) + } + }) return reopened } diff --git a/api/lpod_positions.go b/api/lpod_positions.go index 5f5e9dc..a3a5b03 100644 --- a/api/lpod_positions.go +++ b/api/lpod_positions.go @@ -23,13 +23,17 @@ type lpodWalletPosition struct { ID string `json:"id"` Vault string `json:"vault"` Principal string `json:"principal_napro"` + Original string `json:"original_principal_napro"` Returned string `json:"returned_napro"` Due string `json:"due_napro"` + PendingDue string `json:"pending_due_napro"` + NextPayoutNS string `json:"next_payout_ns,omitempty"` Nonce string `json:"nonce"` DepositJSON string `json:"deposit_action_json"` EffectiveVault string `json:"effective_vault"` RouteHeight string `json:"route_height"` AutoReturned bool `json:"auto_returned"` + TopUpCount int `json:"top_up_count"` Beneficiary string `json:"beneficiary"` } @@ -40,10 +44,11 @@ func lpodWalletProjection(c *store.LPoDCheckpoint, address crypto.Address) ([]lp if p.Deposit.Beneficiary != address { continue } - if p.Withdrawn > p.Deposit.Amount { + principalTotal, err := store.LPoDPositionPrincipal(p) + if err != nil || p.Withdrawn > principalTotal { return nil, 0, fmt.Errorf("invalid principal") } - principal := p.Deposit.Amount - p.Withdrawn + principal := principalTotal - p.Withdrawn if p.Returned { principal = 0 } @@ -58,8 +63,11 @@ func lpodWalletProjection(c *store.LPoDCheckpoint, address crypto.Address) ([]lp effective := store.LPoDEffectiveVault(p) rows = append(rows, lpodWalletPosition{ ID: id, Vault: fmt.Sprintf("%x", effective[:]), EffectiveVault: fmt.Sprintf("%x", effective[:]), - Principal: strconv.FormatUint(principal, 10), Returned: strconv.FormatUint(p.Withdrawn, 10), - Due: strconv.FormatUint(p.Due, 10), Nonce: strconv.FormatUint(p.Nonce, 10), + Principal: strconv.FormatUint(principal, 10), Original: strconv.FormatUint(principalTotal, 10), + Returned: strconv.FormatUint(p.Withdrawn, 10), + Due: strconv.FormatUint(p.Due, 10), Nonce: strconv.FormatUint(p.Nonce, 10), + PendingDue: strconv.FormatUint(p.PendingDue, 10), NextPayoutNS: strconv.FormatInt(p.NextPayoutNS, 10), + TopUpCount: len(p.TopUps), DepositJSON: string(action), RouteHeight: strconv.FormatUint(p.RouteHeight, 10), AutoReturned: p.AutoReturned, Beneficiary: string(p.Deposit.Beneficiary), }) @@ -75,10 +83,11 @@ func lpodVaultProjection(c *store.LPoDCheckpoint, vault crypto.Point32) ([]lpodW if store.LPoDEffectiveVault(p) != vault { continue } - if p.Withdrawn > p.Deposit.Amount { + principalTotal, err := store.LPoDPositionPrincipal(p) + if err != nil || p.Withdrawn > principalTotal { return nil, 0, fmt.Errorf("invalid principal") } - principal := p.Deposit.Amount - p.Withdrawn + principal := principalTotal - p.Withdrawn if p.Returned { principal = 0 } @@ -91,8 +100,10 @@ func lpodVaultProjection(c *store.LPoDCheckpoint, vault crypto.Point32) ([]lpodW return nil, 0, err } rows = append(rows, lpodWalletPosition{ID: id, Vault: fmt.Sprintf("%x", vault[:]), EffectiveVault: fmt.Sprintf("%x", vault[:]), - Principal: strconv.FormatUint(principal, 10), Returned: strconv.FormatUint(p.Withdrawn, 10), Due: strconv.FormatUint(p.Due, 10), - Nonce: strconv.FormatUint(p.Nonce, 10), DepositJSON: string(action), RouteHeight: strconv.FormatUint(p.RouteHeight, 10), + Principal: strconv.FormatUint(principal, 10), Original: strconv.FormatUint(principalTotal, 10), + Returned: strconv.FormatUint(p.Withdrawn, 10), Due: strconv.FormatUint(p.Due, 10), + PendingDue: strconv.FormatUint(p.PendingDue, 10), NextPayoutNS: strconv.FormatInt(p.NextPayoutNS, 10), + TopUpCount: len(p.TopUps), Nonce: strconv.FormatUint(p.Nonce, 10), DepositJSON: string(action), RouteHeight: strconv.FormatUint(p.RouteHeight, 10), AutoReturned: p.AutoReturned, Beneficiary: string(p.Deposit.Beneficiary)}) } sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) @@ -126,7 +137,7 @@ func (s *Server) restLPoDPositions(w http.ResponseWriter, r *http.Request) { } out := map[string]interface{}{"version": 1, "state": "disabled", "address": address, "reserved_napro": nil, "positions": nil, "checkpoint_hash": nil, "finalized_height": nil, - "wallet_mutations_supported": false} + "wallet_mutations_supported": false, "daily_payout_version": 0, "daily_topups_supported": false} defer func() { writeJSON(w, 200, out) }() if s.lpodMigration == nil { return @@ -198,6 +209,8 @@ func (s *Server) restLPoDPositions(w http.ResponseWriter, r *http.Request) { } out["state"] = "active" out["wallet_mutations_supported"] = true + out["daily_payout_version"] = c.DailyPayoutVersion + out["daily_topups_supported"] = c.DailyPayoutVersion > 0 out["chain_anchor"] = fmt.Sprintf("%x", a.Genesis[:]) out["vaults"] = vaults out["positions"] = rows diff --git a/api/lpod_public_docs_status_test.go b/api/lpod_public_docs_status_test.go new file mode 100644 index 0000000..2f6b594 --- /dev/null +++ b/api/lpod_public_docs_status_test.go @@ -0,0 +1,160 @@ +package api + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var ( + currentMainnetStatusMarker = regexp.MustCompile(`(?i)\b(?:public network status|current public network|mainnet status|current public mainnet|mainnet lpod v3)\b`) + nonCurrentLpodScope = regexp.MustCompile(`(?i)\b(?:historical|rehearsal|new network|another chain|default-disabled|repository/config)\b`) + staleLpodActivationClaim = regexp.MustCompile(`(?i)(?:\b(?:if|when|once)\s+lpod(?:\s+v3)?\b.{0,120}\b(?:activated|enabled|funded)\b|\blpod(?:\s+v3)?\b.{0,120}\b(?:will be activated|will activate|has not(?: yet)? been activated|(?:is|was) not(?: yet)? active|not yet active|remains inactive|awaits activation|activation is planned)\b)`) + futureLpodVersion = regexp.MustCompile(`(?i)\blpod\s+v[4-9][0-9]*\b`) + currentLpodVersion = regexp.MustCompile(`(?i)\blpod\s+v3\b`) +) + +func isStaleCurrentLpodClaim(text string) bool { + if futureLpodVersion.MatchString(text) && !currentLpodVersion.MatchString(text) { + return false + } + if nonCurrentLpodScope.MatchString(text) && !currentMainnetStatusMarker.MatchString(text) { + return false + } + return staleLpodActivationClaim.MatchString(text) +} + +func findPublishedDocsRoot(t *testing.T) string { + t.Helper() + workingDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for dir := workingDir; ; dir = filepath.Dir(dir) { + if exists(filepath.Join(dir, "README.md")) && + exists(filepath.Join(dir, "LPOD.md")) && + exists(filepath.Join(dir, "deploy", "README-public.md")) { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Skip("published root documents are not present in this monorepo checkout") + } + } +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func paragraphAtMarker(document, marker string) (string, bool) { + index := strings.Index(document, marker) + if index < 0 { + return "", false + } + start := strings.LastIndex(document[:index], "\n\n") + 2 + if end := strings.Index(document[index:], "\n\n"); end >= 0 { + return document[start : index+end], true + } + return document[start:], true +} + +func assertCurrentLpodStatus(t *testing.T, document, marker string) { + t.Helper() + paragraph, ok := paragraphAtMarker(document, marker) + if !ok { + t.Fatalf("current-status marker %q is missing", marker) + } + if !strings.Contains(strings.ToLower(paragraph), "lpod v3") { + t.Errorf("current-status paragraph %q does not name LPoD v3", marker) + } + normalizedHeight := strings.NewReplacer(",", "").Replace( + strings.Join(strings.Fields(paragraph), ""), + ) + if !strings.Contains(normalizedHeight, "2493218") { + t.Errorf("current-status paragraph %q does not include canonical activation height 2,493,218", marker) + } + if !regexp.MustCompile(`(?i)\b(?:active|activated|funded|complete)\b`).MatchString(paragraph) { + t.Errorf("current-status paragraph %q does not say the mainnet deployment is active and funded", marker) + } + if isStaleCurrentLpodClaim(paragraph) { + t.Errorf("current-status paragraph %q reverted to a future-activation claim: %s", marker, paragraph) + } +} + +func TestPublishedLpodDocumentsDescribeCurrentMainnetStatus(t *testing.T) { + root := findPublishedDocsRoot(t) + documents := []struct { + path string + markers []string + }{ + { + path: filepath.Join(root, "README.md"), + markers: []string{"LPoD v3 is active on Aperod from canonical height", "**Current public network:**"}, + }, + { + path: filepath.Join(root, "LPOD.md"), + markers: []string{"Mainnet status: LPoD v3", "Mainnet LPoD v3 funding and activation are complete:"}, + }, + { + path: filepath.Join(root, "deploy", "README-public.md"), + markers: []string{"The current public mainnet also has funded, active LPoD v3"}, + }, + } + + for _, doc := range documents { + content, err := os.ReadFile(doc.path) + if err != nil { + t.Fatalf("read published document %s: %v", doc.path, err) + } + for _, marker := range doc.markers { + t.Run(filepath.Base(doc.path)+"/"+marker, func(t *testing.T) { + assertCurrentLpodStatus(t, string(content), marker) + }) + } + } +} + +func TestLpodStatusRegressionScopePreservesHistoricalAndOperationalStates(t *testing.T) { + tests := []struct { + name string + text string + want bool + }{ + { + name: "unscoped future activation is stale", + text: "If LPoD v3 is activated and funded, the pool will distribute rewards.", + want: true, + }, + { + name: "historical report", + text: "Historical report: LPoD v3 was not active at that checkpoint.", + want: false, + }, + { + name: "new network default-disabled", + text: "New network repository/config default-disabled: LPoD v3 will be activated only with its own authenticated witness.", + want: false, + }, + { + name: "future protocol version", + text: "LPoD v4 will be activated in a future version.", + want: false, + }, + { + name: "truthful API state", + text: "The LPoD v3 API response state can be pending, disabled, or unavailable.", + want: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isStaleCurrentLpodClaim(test.text); got != test.want { + t.Fatalf("isStaleCurrentLpodClaim(%q) = %t, want %t", test.text, got, test.want) + } + }) + } +} diff --git a/api/rest_address_history_test.go b/api/rest_address_history_test.go index 94fb1db..0870917 100644 --- a/api/rest_address_history_test.go +++ b/api/rest_address_history_test.go @@ -18,7 +18,11 @@ func TestREST_AddressTxsPagesCanonicalDiskHistoryBeyondReportedHeight(t *testing if err != nil { t.Fatalf("store.Open: %v", err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close address history database: %v", err) + } + }() priv, validatorPub, err := crypto.GenerateValidatorKey() if err != nil { diff --git a/api/sale.go b/api/sale.go index ffa0064..13748b4 100644 --- a/api/sale.go +++ b/api/sale.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "strconv" @@ -102,7 +103,9 @@ func saleJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) - json.NewEncoder(w).Encode(value) + if err := json.NewEncoder(w).Encode(value); err != nil { + slog.Error("failed to encode sale API response", "error", err) + } } func saleBody(r *http.Request, value any) error { diff --git a/api/wallet_history_proof_test.go b/api/wallet_history_proof_test.go index 4bb2d56..81efcf2 100644 --- a/api/wallet_history_proof_test.go +++ b/api/wallet_history_proof_test.go @@ -16,7 +16,11 @@ func TestWalletHistoryRequiresCanonicalTransactionAndExactOutput(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close wallet history proof database: %v", err) + } + }() 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}} diff --git a/api/wallet_history_transactions_test.go b/api/wallet_history_transactions_test.go index 2f175b2..4651030 100644 --- a/api/wallet_history_transactions_test.go +++ b/api/wallet_history_transactions_test.go @@ -16,7 +16,11 @@ func TestWalletHistoryExactBudgetSendHasCanonicalEvidenceWithoutChange(t *testin if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close wallet history transaction database: %v", err) + } + }() image := crypto.KeyImage{7} tx := core.Transaction{Version: core.TxVersionCLSAG, Fee: 252750, Inputs: []core.RingInput{{KeyImage: image}}, diff --git a/api/wallet_snapshot_query_failure_test.go b/api/wallet_snapshot_query_failure_test.go index d74a8a2..5d24d1a 100644 --- a/api/wallet_snapshot_query_failure_test.go +++ b/api/wallet_snapshot_query_failure_test.go @@ -71,7 +71,9 @@ func TestWalletOutputSnapshotQueryFailureKeepsDurablePosition(t *testing.T) { if response.Header.Get("Retry-After") != "5" { t.Fatal("missing retry guidance") } - response.Body.Close() + if err := response.Body.Close(); err != nil { + t.Errorf("close rate-limit response body: %v", err) + } 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" { diff --git a/api/wallet_snapshot_service_test.go b/api/wallet_snapshot_service_test.go index c6a2487..03a8ac2 100644 --- a/api/wallet_snapshot_service_test.go +++ b/api/wallet_snapshot_service_test.go @@ -197,7 +197,9 @@ func TestWalletSnapshotLoopbackService(t *testing.T) { go func() { response, err := f.http.Client().Do(req) if response != nil { - response.Body.Close() + if closeErr := response.Body.Close(); closeErr != nil && err == nil { + err = closeErr + } } result <- err }() diff --git a/avm/checkpoint.go b/avm/checkpoint.go index ede1558..375692b 100644 --- a/avm/checkpoint.go +++ b/avm/checkpoint.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -15,6 +16,7 @@ import ( "github.com/aperod/aperod/crypto" ) +// AVMCheckpointVersion identifies the persisted replay checkpoint format. const ( AVMCheckpointVersion uint32 = 1 AVMCheckpointQuorumVersion uint32 = 2 @@ -38,6 +40,8 @@ type CheckpointEntry struct { // 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. +// +//nolint:revive // The AVM prefix is retained because this serialized type is AVM-scoped. type AVMCheckpoint struct { Version uint32 ActivationHeight uint64 @@ -132,13 +136,15 @@ func StateSnapshotCommitment(entries []CheckpointEntry) (crypto.Hash32, error) { var number [8]byte binary.BigEndian.PutUint64(number[:], uint64(len(entries))) _, _ = hasher.Write(number[:]) + var previousKey []byte 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 { + if i > 0 && bytes.Compare(previousKey, entry.Key) >= 0 { return crypto.Hash32{}, fmt.Errorf("avm checkpoint: state entries are not strictly key ordered") } + previousKey = entry.Key binary.BigEndian.PutUint64(number[:], uint64(len(entry.Key))) _, _ = hasher.Write(number[:]) _, _ = hasher.Write(entry.Key) @@ -561,7 +567,8 @@ func VerifyCheckpoint( // 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) { +func ReadCheckpointFile(path string) (checkpoint *AVMCheckpoint, present bool, retErr error) { + // #nosec G304 -- checkpoint paths are operator-configured and never request-controlled. file, err := os.Open(path) if err != nil { if os.IsNotExist(err) { @@ -569,7 +576,11 @@ func ReadCheckpointFile(path string) (*AVMCheckpoint, bool, error) { } return nil, false, fmt.Errorf("avm checkpoint: open: %w", err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("avm checkpoint: close: %w", err)) + } + }() info, err := file.Stat() if err != nil { return nil, false, fmt.Errorf("avm checkpoint: stat: %w", err) @@ -593,7 +604,7 @@ func ReadCheckpointFile(path string) (*AVMCheckpoint, bool, error) { } return nil, false, fmt.Errorf("avm checkpoint: trailing data: %w", err) } - checkpoint, err := checkpointFromWire(wire) + checkpoint, err = checkpointFromWire(wire) if err != nil { return nil, false, err } @@ -607,7 +618,7 @@ func WriteCheckpointFile(path string, checkpoint *AVMCheckpoint) error { return writeCheckpointFileWithLimit(path, checkpoint, maxAVMCheckpointFileBytes) } -func writeCheckpointFileWithLimit(path string, checkpoint *AVMCheckpoint, maxBytes int64) error { +func writeCheckpointFileWithLimit(path string, checkpoint *AVMCheckpoint, maxBytes int64) (retErr error) { if checkpoint == nil { return fmt.Errorf("avm checkpoint: nil checkpoint") } @@ -619,6 +630,7 @@ func writeCheckpointFileWithLimit(path string, checkpoint *AVMCheckpoint, maxByt } wire := checkpointToWire(checkpoint) dir := filepath.Dir(path) + // #nosec G703 -- checkpointPath is supplied by node configuration; no network input contributes to this path. if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("avm checkpoint: create directory: %w", err) } @@ -627,35 +639,48 @@ func writeCheckpointFileWithLimit(path string, checkpoint *AVMCheckpoint, maxByt return fmt.Errorf("avm checkpoint: create temporary file: %w", err) } tempPath := temp.Name() - defer os.Remove(tempPath) + tempOpen := true + defer func() { + if tempOpen { + if err := temp.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("avm checkpoint: close temporary file: %w", err)) + } + } + // #nosec G703 -- tempPath comes from os.CreateTemp in the controlled parent directory. + if err := os.Remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { + retErr = errors.Join(retErr, fmt.Errorf("avm checkpoint: remove temporary file: %w", err)) + } + }() 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 { + tempOpen = false return fmt.Errorf("avm checkpoint: close temporary file: %w", err) } + tempOpen = false + // #nosec G703 -- the destination is the configured checkpoint path and the temporary file is created in its directory. if err := os.Rename(tempPath, path); err != nil { return fmt.Errorf("avm checkpoint: atomically replace: %w", err) } + // #nosec G304 G703 -- dir is only the parent directory of the local operator-configured checkpoint path. 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) + syncErr := dirFile.Sync() + closeErr := dirFile.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return fmt.Errorf("avm checkpoint: sync or close parent directory: %w", err) } return nil } diff --git a/avm/recovery_crash_test.go b/avm/recovery_crash_test.go index f991692..aaf4b3a 100644 --- a/avm/recovery_crash_test.go +++ b/avm/recovery_crash_test.go @@ -116,7 +116,13 @@ func TestAVMCanonicalCommitSurvivesProcessCrash(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if db != nil { + if err := db.Close(); err != nil { + t.Errorf("close crash-recovery database: %v", err) + } + } + }() tipHash, tipHeight, err := db.GetTip() if err != nil { t.Fatal(err) @@ -160,11 +166,16 @@ func TestAVMCanonicalCommitSurvivesProcessCrash(t *testing.T) { if err := db.Close(); err != nil { t.Fatal(err) } + db = nil reopened, err := store.Open(dbPath) if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Errorf("close reopened crash-recovery database: %v", err) + } + }() for _, write := range prepared.Writes { value, found, err := reopened.GetAVMState(write.Key) if err != nil || !found || !bytes.Equal(value, write.Value) { diff --git a/avm/validate.go b/avm/validate.go index d31fbf6..a2b8e5a 100644 --- a/avm/validate.go +++ b/avm/validate.go @@ -9,6 +9,7 @@ import ( "github.com/tetratelabs/wabin/wasm" ) +// MaxCodeSize is the maximum accepted size, in bytes, of a Wasm module. const ( MaxCodeSize = 1 << 20 MaxMemoryPages = 512 // 32 MiB at 64 KiB per Wasm page. @@ -152,7 +153,7 @@ func validateInstructionsBounded(body []byte, importCount uint32, maxDepth int) return 0, err } offset += n - case op == byte(wasm.OpcodeEnd): + case op == wasm.OpcodeEnd: if depth > 0 { depth-- } diff --git a/cmd/avm-checkpoint/main.go b/cmd/avm-checkpoint/main.go index 2a00056..8829f86 100644 --- a/cmd/avm-checkpoint/main.go +++ b/cmd/avm-checkpoint/main.go @@ -6,6 +6,7 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "io" @@ -78,12 +79,16 @@ func main() { } } -func runBuild(dbPath string, activationHeight uint64, outputPath string) error { +func runBuild(dbPath string, activationHeight uint64, outputPath string) (retErr error) { db, err := store.OpenReadOnly(dbPath) if err != nil { return fmt.Errorf("open chain database read-only: %w", err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close checkpoint database: %w", err)) + } + }() tipHash, tipHeight, err := db.GetTip() if err != nil { return fmt.Errorf("read canonical tip: %w", err) @@ -110,7 +115,7 @@ func runBuild(dbPath string, activationHeight uint64, outputPath string) error { return nil } -func runSign(dbPath string, activationHeight uint64, candidatePath, keyPath, outputPath string) error { +func runSign(dbPath string, activationHeight uint64, candidatePath, keyPath, outputPath string) (retErr error) { draft, found, err := avm.ReadCheckpointFile(candidatePath) if err != nil { return err @@ -126,7 +131,11 @@ func runSign(dbPath string, activationHeight uint64, candidatePath, keyPath, out if err != nil { return fmt.Errorf("open independent chain database read-only: %w", err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close checkpoint database: %w", err)) + } + }() localDraft, err := avm.BuildCheckpointDraftFromCanonical( context.Background(), db, activationHeight, draft.Height, draft.BlockHash, ) @@ -213,12 +222,17 @@ func writeWitnessSignature(path string, signature avm.CheckpointWitnessSignature return nil } -func readWitnessSignature(path string) (avm.CheckpointWitnessSignature, error) { +func readWitnessSignature(path string) (signature avm.CheckpointWitnessSignature, retErr error) { + // #nosec G304 -- the signature file is selected by the local operator. file, err := os.Open(path) if err != nil { return avm.CheckpointWitnessSignature{}, fmt.Errorf("open witness signature %q: %w", path, err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close witness signature: %w", err)) + } + }() info, err := file.Stat() if err != nil { return avm.CheckpointWitnessSignature{}, fmt.Errorf("stat witness signature %q: %w", path, err) @@ -254,12 +268,17 @@ func readWitnessSignature(path string) (avm.CheckpointWitnessSignature, error) { }, nil } -func readWitnessKey(keyPath string) (crypto.ValidatorPrivKey, error) { +func readWitnessKey(keyPath string) (result crypto.ValidatorPrivKey, retErr error) { + // #nosec G304 -- the key file is selected by the local operator, not network input. keyFile, err := os.Open(keyPath) if err != nil { return nil, fmt.Errorf("open witness key: %w", err) } - defer keyFile.Close() + defer func() { + if err := keyFile.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close witness key: %w", err)) + } + }() info, err := keyFile.Stat() if err != nil { return nil, fmt.Errorf("stat witness key: %w", err) diff --git a/cmd/avm-checkpoint/main_test.go b/cmd/avm-checkpoint/main_test.go index cddeffe..cd2fc76 100644 --- a/cmd/avm-checkpoint/main_test.go +++ b/cmd/avm-checkpoint/main_test.go @@ -188,7 +188,11 @@ func makeCheckpointTestDB(t *testing.T) (string, error) { if err != nil { return "", err } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close checkpoint fixture database: %v", err) + } + }() producerKey, producerPublic, err := crypto.GenerateValidatorKey() if err != nil { return "", err diff --git a/cmd/explorer-indexer/main.go b/cmd/explorer-indexer/main.go index c7c81da..ee97f5f 100644 --- a/cmd/explorer-indexer/main.go +++ b/cmd/explorer-indexer/main.go @@ -26,6 +26,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "os" "strconv" "strings" @@ -61,6 +62,10 @@ func run() error { if nodeURL == "" { nodeURL = "http://127.0.0.1:8545" } + nodeBase, err := parseNodeBaseURL(nodeURL) + if err != nil { + return fmt.Errorf("invalid Go node URL: %w", err) + } pollInterval := 5 * time.Second if s := os.Getenv("POLL_INTERVAL"); s != "" { @@ -87,7 +92,7 @@ func run() error { } log.Info("explorer-indexer starting", - "node_url", nodeURL, + "node_host", nodeBase.Host, "poll_interval", pollInterval, "batch_size", batchSize, "requests_per_second", requestsPerSecond, @@ -436,13 +441,52 @@ type blocksListResponse struct { Total uint64 `json:"total"` } -func (c *nodeClient) get(url string) ([]byte, int, error) { +func parseNodeBaseURL(raw string) (*url.URL, error) { + if strings.TrimSpace(raw) != raw || strings.ContainsAny(raw, "\r\n\t") { + return nil, fmt.Errorf("URL contains whitespace or control characters") + } + base, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("parse URL: %w", err) + } + if (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" || + base.User != nil || base.RawQuery != "" || base.Fragment != "" || base.Opaque != "" { + return nil, fmt.Errorf("URL must be an HTTP(S) origin without credentials, query, or fragment") + } + return base, nil +} + +func validateNodeRequestURL(configuredBase, requestURL string) error { + base, err := parseNodeBaseURL(configuredBase) + if err != nil { + return err + } + target, err := url.Parse(requestURL) + if err != nil { + return fmt.Errorf("parse request URL: %w", err) + } + if target.Scheme != base.Scheme || !strings.EqualFold(target.Host, base.Host) || + target.User != nil || target.Fragment != "" || target.Opaque != "" { + return fmt.Errorf("request URL does not match configured node origin") + } + return nil +} + +func (c *nodeClient) get(requestURL string) ([]byte, int, error) { + if err := validateNodeRequestURL(c.base, requestURL); err != nil { + return nil, 0, fmt.Errorf("reject node request: %w", err) + } fallbackDelay := time.Second for { if c.pacer != nil { c.pacer.wait() } - resp, err := c.http.Get(url) + safeClient := *c.http + safeClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + // #nosec G704 -- requestURL is bound to the configured node origin above, and redirects are disabled. + resp, err := safeClient.Get(requestURL) if err != nil { return nil, 0, err } @@ -460,12 +504,15 @@ func (c *nodeClient) get(url string) ([]byte, int, error) { } else if delay < minRateLimitRetryDelay { delay = minRateLimitRetryDelay } - slog.Warn("node REST rate limited; retrying without restarting catch-up", - "retry_after", delay, "url", url) + slog.Warn("node REST rate limited; retrying without restarting catch-up") time.Sleep(delay) continue } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("failed to close node REST response body") + } + }() body, err := io.ReadAll(resp.Body) return body, resp.StatusCode, err } diff --git a/cmd/genesis-reserve/main.go b/cmd/genesis-reserve/main.go index f5bd1a7..96aa069 100644 --- a/cmd/genesis-reserve/main.go +++ b/cmd/genesis-reserve/main.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "io" @@ -17,24 +18,22 @@ import ( ) func writeExclusive(path string, value interface{}) error { + // #nosec G304 -- this operator-selected output path is created exclusively to prevent overwrites. f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) 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 + writeErr := json.NewEncoder(f).Encode(value) + if writeErr == nil { + writeErr = f.Sync() } - if err = f.Sync(); err != nil { + closeErr := f.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) { + err = errors.Join(err, fmt.Errorf("remove incomplete output: %w", removeErr)) + } return err } - ok = true return nil } @@ -45,7 +44,7 @@ func main() { } } -func run() error { +func run() (retErr error) { action := flag.String("action", "", "init-wallet, rekey-wallet or attest") path := flag.String("wallet", "", "encrypted treasury keystore path") output := flag.String("output", "", "new exclusive witness/keystore path") @@ -123,7 +122,11 @@ func run() error { return err } offlineDB = db - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close offline database: %w", err)) + } + }() _, tip, err := db.GetTip() if err != nil { return err @@ -155,8 +158,13 @@ func run() error { if err != nil { return fmt.Errorf("cannot securely open signing key") } + // #nosec G115 -- syscall.Open returned a valid nonnegative descriptor, which fits uintptr on supported Linux targets. file := os.NewFile(uintptr(fd), *signer) - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close signing key: %w", err)) + } + }() raw, err := io.ReadAll(io.LimitReader(file, 65)) if err != nil || (len(raw) != 32 && len(raw) != 64) { return fmt.Errorf("invalid signing key length") diff --git a/cmd/mint-record-audit/main.go b/cmd/mint-record-audit/main.go index d609e6e..cf62498 100644 --- a/cmd/mint-record-audit/main.go +++ b/cmd/mint-record-audit/main.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "io" @@ -67,6 +68,14 @@ func recoverAmount(r record, u *store.StoredUTXO, maxWhole uint64, extra []uint6 var blind crypto.BlindFactor copy(blind[:], raw) verify := func(n uint64) bool { + if u.BlockHeight > 0 { + b, err := crypto.DeterministicMintBlindV2(pub, n, u.BlockHeight) + if err == nil && bytes.Equal(b[:], blind[:]) { + c, err := crypto.Commit(n, b) + return err == nil && c == u.AmountCommit + } + } + //nolint:staticcheck // Legacy fallback is required to audit pre-V2 mints and height-zero admin mints. b, err := crypto.DeterministicMintBlind(pub, n) if err != nil || !bytes.Equal(b[:], blind[:]) { return false @@ -163,7 +172,7 @@ func audit(d *store.DB, r record, maxWhole uint64, extra []uint64) evidence { return out } -func run() error { +func run() (retErr error) { path := flag.String("copied-db", "", "closed, consistent LevelDB COPY only") max := flag.Uint64("max-whole-apro", 2000000, "bounded whole-APRO search, at most 2000000") extraText := flag.String("extra-candidates-napro", "[]", "JSON array of decimal STRINGS; search hints, not evidence") @@ -208,7 +217,11 @@ func run() error { if err != nil { return err } - defer d.Close() + defer func() { + if err := d.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close audit database: %w", err)) + } + }() results := make([]evidence, 0, len(rows)) for _, r := range rows { results = append(results, audit(d, r, *max, extra)) diff --git a/cmd/mint-record-audit/main_test.go b/cmd/mint-record-audit/main_test.go index 84b8389..c2f16c9 100644 --- a/cmd/mint-record-audit/main_test.go +++ b/cmd/mint-record-audit/main_test.go @@ -19,6 +19,7 @@ func TestMintDisplayHintRequiresActualOpening(t *testing.T) { spend, view := keys.Spend.Public, keys.View.Public address := crypto.EncodeAddress(crypto.MainnetByte, spend, view) const n = 55105 * 100000000 + //nolint:staticcheck // This fixture deliberately covers legacy height-zero mint recovery. blind, err := crypto.DeterministicMintBlind(spend, n) if err != nil { t.Fatal(err) @@ -62,18 +63,29 @@ func TestMintAuditNeedsCanonicalBodyAndNeverMutates(t *testing.T) { if err != nil { t.Fatal(err) } - defer d.Close() + defer func() { + if err := d.Close(); err != nil { + t.Errorf("close database: %v", err) + } + }() keys, err := crypto.GenerateWalletKeys() if err != nil { t.Fatal(err) } spend, view := keys.Spend.Public, keys.View.Public address := crypto.EncodeAddress(crypto.MainnetByte, spend, view) - blind, _ := crypto.DeterministicMintBlind(spend, 100000000) - commit, _ := crypto.Commit(100000000, blind) + const blockHeight = uint64(5) + blind, err := crypto.DeterministicMintBlindV2(spend, 100000000, blockHeight) + if err != nil { + t.Fatal(err) + } + commit, err := crypto.Commit(100000000, blind) + if err != nil { + t.Fatal(err) + } tx := core.Transaction{Outputs: []core.Output{{AmountCommit: commit}}} hash := tx.Hash() - u := &store.StoredUTXO{TxHash: hash, BlockHeight: 5, AmountCommit: commit} + u := &store.StoredUTXO{TxHash: hash, BlockHeight: blockHeight, AmountCommit: commit} if err := d.PutUTXO(hash, 0, u); err != nil { t.Fatal(err) } diff --git a/cmd/mint-record-audit/whole_opening_test.go b/cmd/mint-record-audit/whole_opening_test.go index 79eda3a..ed1825e 100644 --- a/cmd/mint-record-audit/whole_opening_test.go +++ b/cmd/mint-record-audit/whole_opening_test.go @@ -7,10 +7,7 @@ import ( ) func TestWholeOpeningUsesRecordedBlindWithoutAssumingMintDerivation(t *testing.T) { - blind, err := crypto.DeterministicMintBlind(crypto.Point32{7}, 123) - if err != nil { - t.Fatal(err) - } + blind := crypto.BlindFactor{1} for _, whole := range []uint64{0, 1, 37, 1000, 65535} { c, err := crypto.Commit(whole*100000000, blind) if err != nil { diff --git a/cmd/node/finality_migration.go b/cmd/node/finality_migration.go index 31336d0..adcc66e 100644 --- a/cmd/node/finality_migration.go +++ b/cmd/node/finality_migration.go @@ -36,6 +36,7 @@ func loadFinalityCommitteeMigration(path string, authorityHex []string) (*store. return nil, nil, fmt.Errorf("finality migration must be a regular file no larger than %d bytes", maxFinalityMigrationFileBytes) } + // #nosec G304 -- path is operator-configured and validated as a bounded regular file above. raw, err := os.ReadFile(path) if err != nil { return nil, nil, fmt.Errorf("read finality migration: %w", err) diff --git a/cmd/node/main.go b/cmd/node/main.go index 6a771d9..ae1251d 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -815,7 +815,9 @@ func run() error { 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"}`) + if _, err := fmt.Fprintln(os.Stdout, `{"schema":1,"command":"sale-upgrade-preflight"}`); err != nil { + return fmt.Errorf("write sale preflight capability receipt: %w", err) + } return nil } // ── 0. config-free maintenance subcommands ──────────────────────────────── @@ -1398,6 +1400,14 @@ func run() error { log.Error("protocol sale startup validation failed", "err", err) os.Exit(1) } + if saleMigration != nil { + if err := db.EnsureSaleRetention(saleMigration); err != nil { + // Keep the node online, but PruneBlocksOlderThan refuses to advance + // while this active sale ledger lacks a complete canonical retention + // index. This is not a finality or historical LPoD proof. + log.Error("protocol sale retention index is incomplete; sale block pruning will fail closed", "err", err) + } + } authorityFork, err := loadValidatorAuthorityFork(cfg.Consensus) if err != nil { return err @@ -2735,13 +2745,14 @@ func run() error { } } if err := db.CheckGenesisVestingConfig(cfg.Consensus.GenesisVestingActivationHeight, genesisVestingFunding); err != nil { - return fmt.Errorf("Development Fund startup safety: %w", err) + return fmt.Errorf("development fund startup safety: %w", err) } engine = consensus.NewEngine(consensus.Config{ ValidatorAuthorityFork: authorityFork, FinalityMigration: finalityMigration, FinalityMigrationTrustAuthorities: finalityMigrationTrustAuthorities, LPoDMigration: lpodMigration, + LPoDDailyUpgradeHeight: cfg.Consensus.LPoDDailyUpgradeHeight, SaleMigration: saleMigration, GenesisVestingActivationHeight: cfg.Consensus.GenesisVestingActivationHeight, GenesisVestingFunding: genesisVestingFunding, @@ -3027,6 +3038,7 @@ func run() error { txVerifier.SetStakedCollateralRingExclusionActivationHeight( cfg.Consensus.StakedCollateralRingExclusionActivationHeight, ) + txVerifier.SetLPoDDailyUpgradeHeight(cfg.Consensus.LPoDDailyUpgradeHeight) // 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 diff --git a/cmd/node/maintenance_preflight_test.go b/cmd/node/maintenance_preflight_test.go index 95df8a6..8631901 100644 --- a/cmd/node/maintenance_preflight_test.go +++ b/cmd/node/maintenance_preflight_test.go @@ -70,11 +70,15 @@ func createMaintenanceTestDB(t *testing.T, dataDir string, tipHeight uint64) { copy(hash[:], sum[:]) block := &store.StoredBlock{Height: tipHeight, Hash: hash} if err := db.PutBlock(hash, block); err != nil { - db.Close() + if closeErr := db.Close(); closeErr != nil { + t.Errorf("close fixture db after block write failure: %v", closeErr) + } t.Fatalf("write fixture block: %v", err) } if err := db.PutTip(hash, tipHeight); err != nil { - db.Close() + if closeErr := db.Close(); closeErr != nil { + t.Errorf("close fixture db after tip write failure: %v", closeErr) + } t.Fatalf("write fixture tip: %v", err) } if err := db.Close(); err != nil { diff --git a/cmd/node/registry_bootstrap.go b/cmd/node/registry_bootstrap.go index 120fb0e..8935b18 100644 --- a/cmd/node/registry_bootstrap.go +++ b/cmd/node/registry_bootstrap.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -26,7 +27,7 @@ const bootstrapMaxArrayEntries = 8_000_000 // This opt-in pins the entire operator-selected state, not merely a list of // 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) { +func loadOperatorRegistryBootstrap(c *config.RegistryBootstrapConfig, nonValidator bool, db *store.DB, tip uint64) (snapshot *startupSnapshot, retErr error) { if c == nil { return nil, nil } @@ -42,7 +43,11 @@ func loadOperatorRegistryBootstrap(c *config.RegistryBootstrapConfig, nonValidat return nil, fmt.Errorf("registry bootstrap: %w", err) } h := sha256.New() - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close registry bootstrap file: %w", err)) + } + }() 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") @@ -63,7 +68,11 @@ func loadOperatorRegistryBootstrap(c *config.RegistryBootstrapConfig, nonValidat if err != nil { return nil, fmt.Errorf("registry bootstrap gzip: %w", err) } - defer gz.Close() + defer func() { + if err := gz.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close registry bootstrap gzip stream: %w", err)) + } + }() snap := new(startupSnapshot) expanded := &io.LimitedReader{R: gz, N: bootstrapMaxExpandedBytes + 1} decoder := json.NewDecoder(expanded) diff --git a/cmd/node/registry_bootstrap_keyimages.go b/cmd/node/registry_bootstrap_keyimages.go index 72681ff..eea38ea 100644 --- a/cmd/node/registry_bootstrap_keyimages.go +++ b/cmd/node/registry_bootstrap_keyimages.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -21,16 +22,21 @@ type bootstrapKeyImageAnchor struct { // 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) { +func loadBootstrapKeyImageAnchor(path, digest string, height uint64, hash string) (keyImages []crypto.KeyImage, retErr error) { expected, err := hex.DecodeString(digest) if err != nil || len(expected) != 32 { return nil, fmt.Errorf("key-image anchor requires SHA-256") } + // #nosec G304 G703 -- the local operator-configured file is bound to its required SHA-256 digest. f, err := os.Open(path) if err != nil { return nil, err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close key-image anchor: %w", err)) + } + }() const maxBytes = 64 << 20 info, err := f.Stat() if err != nil || !info.Mode().IsRegular() || info.Size() > maxBytes { diff --git a/cmd/node/registry_bootstrap_keyimages_test.go b/cmd/node/registry_bootstrap_keyimages_test.go index 060f9fa..5dd3be6 100644 --- a/cmd/node/registry_bootstrap_keyimages_test.go +++ b/cmd/node/registry_bootstrap_keyimages_test.go @@ -65,7 +65,11 @@ func TestBootstrapExportCopiedAnchorKeyImages(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close anchor export database: %v", err) + } + }() hash, height, err := db.GetTip() if err != nil { t.Fatal(err) diff --git a/cmd/node/registry_bootstrap_storage_test.go b/cmd/node/registry_bootstrap_storage_test.go index 1394abe..d893f57 100644 --- a/cmd/node/registry_bootstrap_storage_test.go +++ b/cmd/node/registry_bootstrap_storage_test.go @@ -31,7 +31,11 @@ func TestBootstrapSpentLookupErrorsFailClosed(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close bootstrap storage database: %v", err) + } + }() u := &core.UTXO{TxHash: crypto.HashStr("lookup-error"), BlockHeight: 1} snap := &startupSnapshot{TipHeight: 1, UTXOs: core.UTXOSnapshot{ActiveUTXOs: []*core.UTXO{u}}} utxos := core.NewUTXOSet() @@ -60,7 +64,14 @@ func TestBootstrapCapturedTipBinding(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + dbClosed := false + defer func() { + if !dbClosed { + if err := db.Close(); err != nil { + t.Errorf("close captured-tip database: %v", err) + } + } + }() hash, other := crypto.HashStr("captured"), crypto.HashStr("changed") if err := db.PutRawBlock(hash, 10, []byte("{}")); err != nil { t.Fatal(err) @@ -77,6 +88,7 @@ func TestBootstrapCapturedTipBinding(t *testing.T) { err = db.PutRawBlock(other, 10, []byte("{}")) case "closed": err = db.Close() + dbClosed = true } if err != nil { t.Fatal(err) diff --git a/cmd/node/registry_bootstrap_test.go b/cmd/node/registry_bootstrap_test.go index 72317e1..59bbabb 100644 --- a/cmd/node/registry_bootstrap_test.go +++ b/cmd/node/registry_bootstrap_test.go @@ -27,7 +27,11 @@ func TestBootstrapDatabaseBackedSpendReplay(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close bootstrap test database: %v", err) + } + }() priv, pub, err := crypto.GenerateValidatorKey() if err != nil { t.Fatal(err) @@ -130,7 +134,11 @@ func TestBootstrapWrongRoundSlot(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close bootstrap test database: %v", err) + } + }() priv1, pub1, err := crypto.GenerateValidatorKey() if err != nil { t.Fatal(err) @@ -167,7 +175,11 @@ func TestBootstrapAuthenticationBeforeDecompressionAndNullEntries(t *testing.T) if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close bootstrap test database: %v", err) + } + }() path := filepath.Join(t.TempDir(), "bad.gz") if err := os.WriteFile(path, []byte("not gzip"), 0600); err != nil { t.Fatal(err) @@ -198,7 +210,11 @@ func TestBootstrapRealArchiveReadOnly(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close bootstrap test database: %v", err) + } + }() _, tip, err := db.GetTip() if err != nil { t.Fatal(err) @@ -250,7 +266,9 @@ func TestBootstrapCopiedDBInventory(t *testing.T) { count := 0 kiErr := db.IterKeyImages(func(crypto.KeyImage) error { count++; return nil }) t.Logf("%s tip=%d hash=%x key_images=%d errors=%v/%v", path, height, hash, count, err, kiErr) - db.Close() + if closeErr := db.Close(); closeErr != nil { + t.Errorf("close diagnostic registry database: %v", closeErr) + } } } @@ -261,7 +279,11 @@ func TestBootstrapUTXOValidationTrustBoundary(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close bootstrap UTXO validation database: %v", err) + } + }() original := &core.UTXO{TxHash: crypto.HashStr("pinned-output"), BlockHeight: 1} snap := &startupSnapshot{TipHeight: 1, UTXOs: core.UTXOSnapshot{ActiveUTXOs: []*core.UTXO{original}}} utxos := core.NewUTXOSet() @@ -304,7 +326,11 @@ func TestBootstrapExpandedLimit(t *testing.T) { if err != nil { t.Fatal(err) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + t.Errorf("close gzip reader: %v", err) + } + }() limited := &io.LimitedReader{R: r, N: 100} if err := json.NewDecoder(limited).Decode(new(startupSnapshot)); err == nil || limited.N != 0 { t.Fatal("expanded bound not enforced") @@ -333,7 +359,11 @@ func TestOperatorRegistryBootstrapPinsStateAndTrust(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close operator registry bootstrap database: %v", err) + } + }() priv, pub, err := crypto.GenerateValidatorKey() if err != nil { t.Fatal(err) @@ -432,7 +462,11 @@ func TestOperatorRegistryReplayEpochAndUnauthorizedProposer(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close operator registry replay database: %v", err) + } + }() priv, pub, err := crypto.GenerateValidatorKey() if err != nil { t.Fatal(err) @@ -481,7 +515,11 @@ func TestOperatorRegistryReplayRejectsBrokenContinuity(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close broken-continuity database: %v", err) + } + }() priv, pub, err := crypto.GenerateValidatorKey() if err != nil { t.Fatal(err) diff --git a/cmd/node/sale_upgrade_preflight.go b/cmd/node/sale_upgrade_preflight.go index ddcff5c..338aef6 100644 --- a/cmd/node/sale_upgrade_preflight.go +++ b/cmd/node/sale_upgrade_preflight.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" @@ -21,7 +22,7 @@ import ( // The normal updater obtains a new completed logical copy via the private Unix // socket while the service keeps running. An explicit copy is for offline // verification only; it must include the same completed manifest. -func runSaleUpgradePreflight(cfg *config.Config, stage string, out io.Writer) error { +func runSaleUpgradePreflight(cfg *config.Config, stage string, out io.Writer) (retErr error) { if stage == "" { socket := filepath.Join(cfg.DataDir, backupSocketName) transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { @@ -33,7 +34,11 @@ func runSaleUpgradePreflight(cfg *config.Config, stage string, out io.Writer) er if err != nil { return fmt.Errorf("sale preflight: fresh consistent backup unavailable; service must remain running") } - defer response.Body.Close() + defer func() { + if err := response.Body.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close sale backup response: %w", err)) + } + }() if response.StatusCode != http.StatusOK { return fmt.Errorf("sale preflight: backup refused (%d); service must remain running", response.StatusCode) } @@ -70,6 +75,7 @@ func decodeSalePreflightJSON(reader io.Reader, value any) error { } func verifySaleUpgradeStage(cfg *config.Config, stage string, reply *checkpointResponse, out io.Writer, disposable bool) error { + // #nosec G304 G703 -- stage is a local operator-selected copy or a resolved path under the owned backup directory. file, err := os.Open(filepath.Join(stage, "manifest.json")) if err != nil { return fmt.Errorf("sale preflight: completed backup manifest required") @@ -102,6 +108,7 @@ func verifySaleUpgradeStage(cfg *config.Config, stage string, reply *checkpointR return err } if disposable { + // #nosec G703 -- disposable is true only for the resolved checkpoint-* directory under node-owned .backup-staging. if err := os.RemoveAll(stage); err != nil { return fmt.Errorf("sale preflight: verified temporary copy cleanup failed") } diff --git a/cmd/node/sale_upgrade_preflight_test.go b/cmd/node/sale_upgrade_preflight_test.go index fe695c3..67ee796 100644 --- a/cmd/node/sale_upgrade_preflight_test.go +++ b/cmd/node/sale_upgrade_preflight_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net" @@ -64,7 +65,9 @@ func TestSaleUpgradeStageManifest(t *testing.T) { } manifest.Success = false raw, _ := json.Marshal(manifest) - os.WriteFile(filepath.Join(stage, "manifest.json"), raw, 0600) + if err := os.WriteFile(filepath.Join(stage, "manifest.json"), raw, 0600); err != nil { + t.Fatal(err) + } if err := verifySaleUpgradeStage(cfg, stage, nil, io.Discard, false); err == nil { t.Fatal("incomplete copy accepted") } @@ -88,10 +91,22 @@ func TestSaleUpgradeFreshSocketCopy(t *testing.T) { w.WriteHeader(400) return } - json.NewEncoder(w).Encode(checkpointResponse{Path: stage, TipHeight: manifest.TipHeight, TipHash: manifest.TipHash}) + if err := json.NewEncoder(w).Encode(checkpointResponse{Path: stage, TipHeight: manifest.TipHeight, TipHash: manifest.TipHash}); err != nil { + t.Errorf("encode checkpoint response: %v", err) + } })} - go server.Serve(listener) - defer server.Close() + serveDone := make(chan error, 1) + go func() { + serveDone <- server.Serve(listener) + }() + defer func() { + if err := server.Close(); err != nil { + t.Errorf("close test backup server: %v", err) + } + if err := <-serveDone; err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Errorf("test backup server: %v", err) + } + }() cfg := &config.Config{DataDir: dataDir} if err := runSaleUpgradePreflight(cfg, "", io.Discard); err != nil { t.Fatal(err) @@ -121,14 +136,29 @@ func TestSaleUpgradeCapabilityDoesNotLoadConfig(t *testing.T) { if err != nil { t.Fatal(err) } - defer reader.Close() - defer func() { os.Args, os.Stdout = beforeArgs, beforeStdout; writer.Close() }() + defer func() { + if err := reader.Close(); err != nil { + t.Errorf("close capability reader: %v", err) + } + }() + writerClosed := false + defer func() { + os.Args, os.Stdout = beforeArgs, beforeStdout + if !writerClosed { + if err := writer.Close(); err != nil { + t.Errorf("close capability writer: %v", err) + } + } + }() os.Args = []string{"node", "--sale-upgrade-preflight-capability", "--validate-config", "--config", "/definitely-missing-config"} os.Stdout = writer if err := run(); err != nil { t.Fatal(err) } - writer.Close() + writerClosed = true + if err := writer.Close(); err != nil { + t.Fatal(err) + } result, err := io.ReadAll(reader) if err != nil || string(result) != "{\"schema\":1,\"command\":\"sale-upgrade-preflight\"}\n" { t.Fatalf("capability receipt %s: %v", result, err) diff --git a/cmd/node/snapshot_stream_test.go b/cmd/node/snapshot_stream_test.go index f441acd..9008778 100644 --- a/cmd/node/snapshot_stream_test.go +++ b/cmd/node/snapshot_stream_test.go @@ -119,7 +119,6 @@ func TestSnapshotViewTwoMillionUTXOsHasBoundedMemory(t *testing.T) { } utxos := core.NewUTXOSet() utxos.RestoreFromSnapshot(core.UTXOSnapshot{ActiveUTXOs: records}) - records = nil runtime.GC() var before runtime.MemStats diff --git a/cmd/node/validator_authority.go b/cmd/node/validator_authority.go index 2915e21..041cca3 100644 --- a/cmd/node/validator_authority.go +++ b/cmd/node/validator_authority.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -16,7 +17,7 @@ import ( // A file is not authorization merely because it contains a valid possession // signature. The independently deployed configuration pins its full SHA-256. -func loadValidatorAuthorityFork(cfg config.ConsensusConfig) (*core.ValidatorAuthorityFork, error) { +func loadValidatorAuthorityFork(cfg config.ConsensusConfig) (result *core.ValidatorAuthorityFork, retErr error) { if cfg.ValidatorAuthorityForkFile == "" { if cfg.ValidatorAuthorityForkSHA256 != "" { return nil, fmt.Errorf("authority fork digest without a rule file") @@ -31,7 +32,11 @@ func loadValidatorAuthorityFork(cfg config.ConsensusConfig) (*core.ValidatorAuth if err != nil { return nil, err } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close validator authority file: %w", err)) + } + }() raw, err := io.ReadAll(io.LimitReader(file, 65537)) if err != nil || len(raw) > 65536 { return nil, fmt.Errorf("authority fork file exceeds limit") diff --git a/cmd/ring-index-maintenance/files.go b/cmd/ring-index-maintenance/files.go index fc03459..91036d2 100644 --- a/cmd/ring-index-maintenance/files.go +++ b/cmd/ring-index-maintenance/files.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -14,15 +15,17 @@ import ( ) func syncDirectory(path string) error { + // #nosec G304 -- path is a staging directory generated beneath the approved backup root. f, err := os.Open(path) if err != nil { return err } - defer f.Close() - return f.Sync() + syncErr := f.Sync() + closeErr := f.Close() + return errors.Join(syncErr, closeErr) } -func writeControl(path string, value any, exclusive bool) error { +func writeControl(path string, value any, exclusive bool) (retErr error) { if exclusive { if _, err := os.Lstat(path); err == nil { return fmt.Errorf("existing operation/control must be recovered, not overwritten") @@ -35,14 +38,16 @@ func writeControl(path string, value any, exclusive bool) error { return err } name := f.Name() - defer os.Remove(name) + defer func() { + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + retErr = errors.Join(retErr, fmt.Errorf("remove temporary control file: %w", err)) + } + }() if err := json.NewEncoder(f).Encode(value); err != nil { - f.Close() - return err + return errors.Join(err, f.Close()) } if err := f.Sync(); err != nil { - f.Close() - return err + return errors.Join(err, f.Close()) } if err := f.Close(); err != nil { return err @@ -74,22 +79,30 @@ type backupEntry struct { // Rooted source access cannot escape the approved data tree via raced symlinks. // Services/writers and the shared native DB lock must already be quiescent. // Destination is a new root-private directory, never a live/overlapping tree. -func copyTree(source, destination string, manifest *json.Encoder) error { +func copyTree(source, destination string, manifest *json.Encoder) (retErr error) { root, err := os.OpenRoot(source) if err != nil { return err } - defer root.Close() + defer func() { + if err := root.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close source root: %w", err)) + } + }() if err := os.Mkdir(destination, 0700); err != nil { return err } var visit func(string) error - visit = func(relative string) error { + visit = func(relative string) (retErr error) { dir, err := root.Open(relative) if err != nil { return err } - defer dir.Close() + defer func() { + if err := dir.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close source directory: %w", err)) + } + }() info, err := dir.Stat() if err != nil { return err @@ -130,32 +143,25 @@ func copyTree(source, destination string, manifest *json.Encoder) error { return err } opened, err := in.Stat() - if err != nil || !os.SameFile(info, opened) { - in.Close() - return fmt.Errorf("source file identity changed") + if err != nil { + return errors.Join(err, in.Close()) } + if !os.SameFile(info, opened) { + return errors.Join(fmt.Errorf("source file identity changed"), in.Close()) + } + // #nosec G304 -- target is beneath a new backup root and its relative name comes from the opened source directory. out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { - in.Close() - return err + return errors.Join(err, in.Close()) } hash := sha256.New() n, copyErr := io.Copy(io.MultiWriter(out, hash), in) after, statErr := in.Stat() - in.Close() + inputCloseErr := in.Close() syncErr := out.Sync() closeErr := out.Close() - if copyErr != nil { - return copyErr - } - if statErr != nil { - return statErr - } - if syncErr != nil { - return syncErr - } - if closeErr != nil { - return closeErr + if err := errors.Join(copyErr, statErr, inputCloseErr, syncErr, closeErr); err != nil { + return err } if n != info.Size() || after.Size() != info.Size() || after.ModTime() != info.ModTime() { return fmt.Errorf("source changed during coherent copy") @@ -182,17 +188,25 @@ func copyTree(source, destination string, manifest *json.Encoder) error { // Verify every copied file by re-reading the private destination, not only its // manifest or tip. This is byte-copy integrity, not a historical-chain proof. -func verifyBackup(backup string) error { +func verifyBackup(backup string) (retErr error) { root, err := os.OpenRoot(backup) if err != nil { return err } - defer root.Close() + defer func() { + if err := root.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close backup root: %w", err)) + } + }() f, err := root.Open("manifest.jsonl") if err != nil { return err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close backup manifest: %w", err)) + } + }() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 4096), 16384) header, complete := false, false @@ -254,9 +268,9 @@ func verifyBackup(backup string) error { } h := sha256.New() _, copyErr := io.Copy(h, in) - in.Close() - if copyErr != nil { - return copyErr + closeErr := in.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return err } if hex.EncodeToString(h.Sum(nil)) != row.SHA256 { return fmt.Errorf("backup file checksum differs") diff --git a/cmd/ring-index-maintenance/main.go b/cmd/ring-index-maintenance/main.go index 5d9e9f5..8d92e00 100644 --- a/cmd/ring-index-maintenance/main.go +++ b/cmd/ring-index-maintenance/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "flag" "fmt" "os" @@ -15,7 +16,7 @@ func main() { } } -func run(args []string) error { +func run(args []string) (retErr error) { flags := flag.NewFlagSet("ring-index-maintenance", flag.ContinueOnError) phase := flags.String("phase", "", "prepare, legacy, finalize or guard-update") workerMode := flags.String("worker", "", "internal inherited-capability worker") @@ -71,7 +72,7 @@ func run(args []string) error { if resolved, err := filepathCanonical(p.DBPath); err != nil || resolved != p.DBPath { return fmt.Errorf("canonical DB path changed") } - if err := protectedPath(p.NodeBinary, 0); err != nil { + if err := protectedPath(p.NodeBinary); err != nil { return err } s := serviceController{} @@ -82,7 +83,11 @@ func run(args []string) error { if err != nil { return err } - defer l.Close() + defer func() { + if err := l.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("release maintenance lease: %w", err)) + } + }() switch *phase { case "prepare": return prepare(p, l, s) diff --git a/cmd/ring-index-maintenance/maintenance_test.go b/cmd/ring-index-maintenance/maintenance_test.go index 56374f7..1c56ba8 100644 --- a/cmd/ring-index-maintenance/maintenance_test.go +++ b/cmd/ring-index-maintenance/maintenance_test.go @@ -66,15 +66,21 @@ func syntheticBackup(t *testing.T) (string, string) { t.Fatal(err) } encoder := json.NewEncoder(f) - encoder.Encode(map[string]any{"schema": 1, "db_path": source + "/chain.db", "tip": tipReceipt{Hash: strings.Repeat("a", 64), Height: 7}, "approval_id": "synthetic-only"}) + if err := encoder.Encode(map[string]any{"schema": 1, "db_path": source + "/chain.db", "tip": tipReceipt{Hash: strings.Repeat("a", 64), Height: 7}, "approval_id": "synthetic-only"}); err != nil { + t.Fatal(err) + } if err := copyTree(source, filepath.Join(backup, "data"), encoder); err != nil { t.Fatal(err) } - encoder.Encode(map[string]any{"complete": true}) + if err := encoder.Encode(map[string]any{"complete": true}); err != nil { + t.Fatal(err) + } if err := f.Sync(); err != nil { t.Fatal(err) } - f.Close() + if err := f.Close(); err != nil { + t.Fatal(err) + } return source, backup } @@ -97,7 +103,9 @@ func TestCoherentCopyChecksEveryFileAndCompleteness(t *testing.T) { t.Fatal(err) } body = []byte(strings.Replace(string(body), `{"complete":true}`, "", 1)) - os.WriteFile(manifest, body, 0600) + if err := os.WriteFile(manifest, body, 0600); err != nil { + t.Fatal(err) + } if err := verifyBackup(backup); err == nil { t.Fatal("partial backup accepted") } @@ -113,7 +121,11 @@ func TestCopyRefusesLinksAndSpecialFiles(t *testing.T) { if err != nil { t.Fatal(err) } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + t.Errorf("close test manifest: %v", err) + } + }() if err := copyTree(source, backup, json.NewEncoder(f)); err == nil { t.Fatal("source symlink accepted") } @@ -121,13 +133,21 @@ func TestCopyRefusesLinksAndSpecialFiles(t *testing.T) { func TestCgroupQuiescenceIncludesDescendants(t *testing.T) { root := t.TempDir() - os.WriteFile(filepath.Join(root, "cgroup.procs"), nil, 0600) - os.Mkdir(filepath.Join(root, "child"), 0700) - os.WriteFile(filepath.Join(root, "child", "cgroup.procs"), []byte("123\n"), 0600) + if err := os.WriteFile(filepath.Join(root, "cgroup.procs"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "child"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "child", "cgroup.procs"), []byte("123\n"), 0600); err != nil { + t.Fatal(err) + } if err := emptyCgroup(root); err == nil { t.Fatal("descendant writer ignored") } - os.WriteFile(filepath.Join(root, "child", "cgroup.procs"), nil, 0600) + if err := os.WriteFile(filepath.Join(root, "child", "cgroup.procs"), nil, 0600); err != nil { + t.Fatal(err) + } if err := emptyCgroup(root); err != nil { t.Fatal(err) } diff --git a/cmd/ring-index-maintenance/policy.go b/cmd/ring-index-maintenance/policy.go index b048d7e..ac1e7f3 100644 --- a/cmd/ring-index-maintenance/policy.go +++ b/cmd/ring-index-maintenance/policy.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -114,14 +115,14 @@ func filepathCanonical(path string) (string, error) { return filepath.EvalSymlin // Exact protected file bytes, independently installed outside the checkout, // authenticate tools and unit/config policies. No caller-supplied digest or // source-generated self-manifest can substitute for this trust anchor. -func protectedPath(path string, owner uint32) error { +func protectedPath(path string) error { for current := path; ; current = filepath.Dir(current) { info, err := os.Lstat(current) if err != nil { return err } stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || stat.Uid != owner || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0022 != 0 { + if !ok || stat.Uid != 0 || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0022 != 0 { return fmt.Errorf("untrusted policy/tool path") } if current == "/" { @@ -130,13 +131,19 @@ func protectedPath(path string, owner uint32) error { } } -func fileDigest(path string) (string, error) { +func fileDigest(path string) (digest string, retErr error) { fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC|syscall.O_NONBLOCK, 0) if err != nil { return "", err } + // #nosec G115 -- syscall.Open succeeded, so fd is a nonnegative OS descriptor within the process descriptor table. f := os.NewFile(uintptr(fd), path) - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close %s: %w", path, err)) + digest = "" + } + }() info, err := f.Stat() if err != nil { return "", err @@ -155,16 +162,19 @@ func loadApprovedPolicy() (maintenancePolicy, error) { return loadApprovedPolicyMode(true) } -func loadApprovedPolicyMode(checkConfig bool) (maintenancePolicy, error) { - var p maintenancePolicy - if err := protectedPath(policyPath, 0); err != nil { +func loadApprovedPolicyMode(checkConfig bool) (p maintenancePolicy, retErr error) { + if err := protectedPath(policyPath); err != nil { return p, fmt.Errorf("separate privileged-tool approval unavailable: %w", err) } f, err := os.Open(policyPath) if err != nil { return p, err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close approved maintenance policy: %w", err)) + } + }() info, err := f.Stat() if err != nil { return p, err @@ -191,7 +201,7 @@ func loadApprovedPolicyMode(checkConfig bool) (maintenancePolicy, error) { if self != approvedTool { return p, fmt.Errorf("only separately installed tool may execute") } - if err := protectedPath(self, 0); err != nil { + if err := protectedPath(self); err != nil { return p, err } image, err := os.Open("/proc/self/exe") @@ -199,9 +209,9 @@ func loadApprovedPolicyMode(checkConfig bool) (maintenancePolicy, error) { return p, err } hash := sha256.New() - _, err = io.Copy(hash, image) - image.Close() - if err != nil { + _, copyErr := io.Copy(hash, image) + closeErr := image.Close() + if err := errors.Join(copyErr, closeErr); err != nil { return p, err } digest := hex.EncodeToString(hash.Sum(nil)) @@ -214,7 +224,7 @@ func loadApprovedPolicyMode(checkConfig bool) (maintenancePolicy, error) { if !checkConfig { return p, nil } - if err := protectedPath(p.ConfigPath, 0); err != nil { + if err := protectedPath(p.ConfigPath); err != nil { return p, err } digest, err = fileDigest(p.ConfigPath) diff --git a/cmd/ring-index-maintenance/services.go b/cmd/ring-index-maintenance/services.go index 1932e1b..20f15a6 100644 --- a/cmd/ring-index-maintenance/services.go +++ b/cmd/ring-index-maintenance/services.go @@ -5,6 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "os" @@ -20,11 +21,12 @@ import ( type serviceController struct{} func systemctl(args ...string) (string, error) { - if err := protectedPath("/usr/bin/systemctl", 0); err != nil { + if err := protectedPath("/usr/bin/systemctl"); err != nil { return "", err } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() + // #nosec G204 -- fixed systemctl binary; call sites supply only allowlisted unit and property arguments. cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", args...) cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C"} var out bytes.Buffer @@ -49,7 +51,7 @@ func approvedUnitFiles(p maintenancePolicy) error { if strings.ContainsAny(path, " \t\r\n\\\"") { return fmt.Errorf("ambiguous unit-file path") } - if err := protectedPath(path, 0); err != nil { + if err := protectedPath(path); err != nil { return err } got, err := fileDigest(path) @@ -73,6 +75,7 @@ func blockerDropIn(u unitPolicy, control string, invert bool) error { if _, ok := u.Files[path]; !ok { return fmt.Errorf("independently approved persistent service interlock missing") } + // #nosec G304 -- this exact systemd drop-in path was matched against independently approved policy. body, err := os.ReadFile(path) if err != nil { return err @@ -154,7 +157,7 @@ func (serviceController) validate(p maintenancePolicy, reload bool) error { if enabled != "static" && enabled != "disabled" { return fmt.Errorf("legacy worker must not auto-start at boot") } - if err := protectedPath(p.LegacyBinary, 0); err != nil { + if err := protectedPath(p.LegacyBinary); err != nil { return err } digest, err := fileDigest(p.LegacyBinary) @@ -200,7 +203,8 @@ func (serviceController) stopped(u unitPolicy) error { return emptyCgroup(filepath.Join("/sys/fs/cgroup", group)) } -func emptyCgroup(path string) error { +func emptyCgroup(path string) (retErr error) { + // #nosec G304 -- path is constrained to the validated cgroup hierarchy under /sys/fs/cgroup. procs, err := os.Open(filepath.Join(path, "cgroup.procs")) if os.IsNotExist(err) { return nil @@ -208,19 +212,22 @@ func emptyCgroup(path string) error { if err != nil { return err } - data, err := io.ReadAll(io.LimitReader(procs, 65537)) - procs.Close() - if err != nil { - return err + data, readErr := io.ReadAll(io.LimitReader(procs, 65537)) + closeErr := procs.Close() + if readErr != nil || closeErr != nil { + return errors.Join(readErr, closeErr) } if len(data) > 65536 || strings.TrimSpace(string(data)) != "" { return fmt.Errorf("writer cgroup still contains processes") } + // #nosec G304 -- path is constrained to the validated cgroup hierarchy under /sys/fs/cgroup. dir, err := os.Open(path) if err != nil { return err } - defer dir.Close() + defer func() { + retErr = errors.Join(retErr, dir.Close()) + }() for { entries, err := dir.ReadDir(256) if err != nil && err != io.EOF { @@ -325,15 +332,16 @@ func (serviceController) witnessDB(p maintenancePolicy) error { continue } if target == filepath.Join(p.DBPath, "LOCK") { + // #nosec G304 -- text is a numeric PID read from the kernel-owned cgroup task list. image, err := os.Open(filepath.Join("/proc", text, "exe")) if err != nil { return err } h := sha256.New() - _, err = io.Copy(h, image) - image.Close() - if err != nil { - return err + _, copyErr := io.Copy(h, image) + closeErr := image.Close() + if copyErr != nil || closeErr != nil { + return errors.Join(copyErr, closeErr) } if hex.EncodeToString(h.Sum(nil)) != expected { return fmt.Errorf("running image differs from backup binary") diff --git a/cmd/ring-index-maintenance/worker.go b/cmd/ring-index-maintenance/worker.go index 1305341..9669966 100644 --- a/cmd/ring-index-maintenance/worker.go +++ b/cmd/ring-index-maintenance/worker.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -49,7 +50,7 @@ func tipMetadata(db *store.DB) (tipReceipt, error) { return tipReceipt{hex.EncodeToString(hash), binary.LittleEndian.Uint64(height)}, nil } -func worker(mode, path, generation string) error { +func worker(mode, path, generation string) (retErr error) { if os.Geteuid() == 0 { return fmt.Errorf("database worker must not run as root") } @@ -72,14 +73,22 @@ func worker(mode, path, generation string) error { if err != nil { return err } - defer lease.Close() + defer func() { + if err := lease.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close worker lease: %w", err)) + } + }() switch mode { case "backup-lock": db, err := lease.OpenReadOnly() if err != nil { return err } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close backup database: %w", err)) + } + }() tip, err := tipMetadata(db) if err != nil { return err @@ -129,6 +138,7 @@ func workerCommand(p maintenancePolicy, l *store.RingMaintenanceLease, mode stri return nil, nil, nil, err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + // #nosec G204 -- toolPath is separately installed and hash-verified; arguments are fixed flags and validated policy values. cmd := exec.CommandContext(ctx, toolPath, "--worker", mode, "--db", p.DBPath, "--generation", l.WorkerGeneration()) cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C", "GOMEMLIMIT=256MiB"} cmd.ExtraFiles = []*os.File{fd} @@ -136,24 +146,31 @@ func workerCommand(p maintenancePolicy, l *store.RingMaintenanceLease, mode stri return cmd, fd, cancel, nil } -func invalidateWorker(p maintenancePolicy, l *store.RingMaintenanceLease) error { +func invalidateWorker(p maintenancePolicy, l *store.RingMaintenanceLease) (retErr error) { cmd, fd, cancel, err := workerCommand(p, l, "invalidate") if err != nil { return err } - defer fd.Close() + defer func() { + if err := fd.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close invalidate worker descriptor: %w", err)) + } + }() defer cancel() cmd.Stderr = os.Stderr return cmd.Run() } -func rebuildWorker(p maintenancePolicy, l *store.RingMaintenanceLease) (store.RingMaintenanceWorkerReceipt, error) { - var receipt store.RingMaintenanceWorkerReceipt +func rebuildWorker(p maintenancePolicy, l *store.RingMaintenanceLease) (receipt store.RingMaintenanceWorkerReceipt, retErr error) { cmd, fd, cancel, err := workerCommand(p, l, "rebuild") if err != nil { return receipt, err } - defer fd.Close() + defer func() { + if err := fd.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close rebuild worker descriptor: %w", err)) + } + }() defer cancel() out, err := cmd.StdoutPipe() if err != nil { diff --git a/cmd/ring-index-maintenance/workflow.go b/cmd/ring-index-maintenance/workflow.go index b664937..305563b 100644 --- a/cmd/ring-index-maintenance/workflow.go +++ b/cmd/ring-index-maintenance/workflow.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -32,7 +33,7 @@ func control(p maintenancePolicy, kind string) string { return path } -func readJournal(p maintenancePolicy) (operationJournal, error) { +func readJournal(p maintenancePolicy) (j operationJournal, retErr error) { j, err := readJournalHeader(p) if err != nil { return j, err @@ -40,7 +41,7 @@ func readJournal(p maintenancePolicy) (operationJournal, error) { if filepath.Dir(j.Backup) != p.BackupRoot || !digestRE.MatchString(j.ManifestSHA256) { return j, fmt.Errorf("unverified coherent backup") } - if err := protectedPath(j.Backup, 0); err != nil { + if err := protectedPath(j.Backup); err != nil { return j, err } got, err := fileDigest(filepath.Join(j.Backup, "manifest.jsonl")) @@ -56,17 +57,21 @@ func readJournal(p maintenancePolicy) (operationJournal, error) { return j, nil } -func readJournalHeader(p maintenancePolicy) (operationJournal, error) { - var j operationJournal +func readJournalHeader(p maintenancePolicy) (j operationJournal, retErr error) { path := control(p, "journal.json") - if err := protectedPath(path, 0); err != nil { + if err := protectedPath(path); err != nil { return j, err } + // #nosec G304 -- path is derived from the protected control directory and checked with protectedPath above. f, err := os.Open(path) if err != nil { return j, err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close maintenance journal: %w", err)) + } + }() decoder := json.NewDecoder(io.LimitReader(f, 65537)) decoder.DisallowUnknownFields() if err := decoder.Decode(&j); err != nil { @@ -86,15 +91,19 @@ func readJournalHeader(p maintenancePolicy) (operationJournal, error) { return j, nil } -func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register func(string) error) (string, string, error) { - if err := protectedPath(p.BackupRoot, 0); err != nil { +func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register func(string) error) (backup string, digest string, retErr error) { + if err := protectedPath(p.BackupRoot); err != nil { return "", "", err } cmd, fd, cancel, err := workerCommand(p, l, "backup-lock") if err != nil { return "", "", err } - defer fd.Close() + defer func() { + if err := fd.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close backup worker descriptor: %w", err)) + } + }() defer cancel() input, err := cmd.StdinPipe() if err != nil { @@ -109,11 +118,21 @@ func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register return "", "", err } done := false + inputClosed := false + processWaited := false defer func() { if !done { cancel() - input.Close() - cmd.Wait() + if !inputClosed { + if err := input.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close backup worker input: %w", err)) + } + } + if !processWaited { + if err := cmd.Wait(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("wait for backup worker: %w", err)) + } + } } }() var original tipReceipt @@ -122,13 +141,14 @@ func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register if err := decoder.Decode(&original); err != nil { return "", "", err } - backup, err := os.MkdirTemp(p.BackupRoot, "ring-maintenance-") + backup, err = os.MkdirTemp(p.BackupRoot, "ring-maintenance-") if err != nil { return "", "", err } if err := register(backup); err != nil { return backup, "", err } + // #nosec G304 -- backup is a new protected staging directory created by MkdirTemp. manifest, err := os.OpenFile(filepath.Join(backup, "manifest.jsonl"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return backup, "", err @@ -136,7 +156,9 @@ func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register closed := false defer func() { if !closed { - manifest.Close() + if err := manifest.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close backup manifest: %w", err)) + } } }() encoder := json.NewEncoder(manifest) @@ -149,28 +171,23 @@ func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register // Preserve exact matching config/binary, but never install/restore either // automatically. A restored old writer needs this procedure again. for _, pair := range [][2]string{{p.ConfigPath, "node.yaml"}, {p.NodeBinary, "node.bin"}} { + // #nosec G304 -- pair[0] comes from the validated maintenance plan. in, err := os.Open(pair[0]) if err != nil { return backup, "", err } + // #nosec G304 -- the destination is a fixed filename under the new protected backup directory. out, err := os.OpenFile(filepath.Join(backup, pair[1]), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { - in.Close() - return backup, "", err + return backup, "", errors.Join(err, in.Close()) } h := sha256.New() n, copyErr := io.Copy(io.MultiWriter(out, h), in) - in.Close() + inputCloseErr := in.Close() syncErr := out.Sync() closeErr := out.Close() - if copyErr != nil { - return backup, "", copyErr - } - if syncErr != nil { - return backup, "", syncErr - } - if closeErr != nil { - return backup, "", closeErr + if err := errors.Join(copyErr, inputCloseErr, syncErr, closeErr); err != nil { + return backup, "", err } if err := encoder.Encode(backupEntry{Tree: "meta", Path: pair[1], SHA256: hex.EncodeToString(h.Sum(nil)), Bytes: n, Mode: 0600}); err != nil { return backup, "", err @@ -198,10 +215,11 @@ func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register if err := manifest.Sync(); err != nil { return backup, "", err } - if err := manifest.Close(); err != nil { - return backup, "", err - } + closeErr = manifest.Close() closed = true + if closeErr != nil { + return backup, "", closeErr + } if err := syncDirectory(backup); err != nil { return backup, "", err } @@ -214,12 +232,18 @@ func coherentBackup(p maintenancePolicy, l *store.RingMaintenanceLease, register if _, err := io.WriteString(input, "release\n"); err != nil { return backup, "", err } - input.Close() - if err := cmd.Wait(); err != nil { - return backup, "", err + closeErr = input.Close() + inputClosed = true + if closeErr != nil { + cancel() } + waitErr := cmd.Wait() + processWaited = true done = true - digest, err := fileDigest(filepath.Join(backup, "manifest.jsonl")) + if err := errors.Join(closeErr, waitErr); err != nil { + return backup, "", err + } + digest, err = fileDigest(filepath.Join(backup, "manifest.jsonl")) return backup, digest, err } @@ -296,7 +320,7 @@ func prepare(p maintenancePolicy, l *store.RingMaintenanceLease, s serviceContro if filepath.Dir(partial) != p.BackupRoot { return fmt.Errorf("partial backup scope invalid") } - if err := protectedPath(partial, 0); err != nil { + if err := protectedPath(partial); err != nil { return err } if err := os.RemoveAll(partial); err != nil { diff --git a/cmd/sale-epoch-proposal/authority.go b/cmd/sale-epoch-proposal/authority.go index 6c385a3..3ad6153 100644 --- a/cmd/sale-epoch-proposal/authority.go +++ b/cmd/sale-epoch-proposal/authority.go @@ -32,9 +32,15 @@ func signingAuthority(cfg config.ConsensusConfig, d *store.DB) (*core.ValidatorA if err != nil { return nil, err } - defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, 65537)) - if err != nil || len(raw) > 65536 { + closeErr := f.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + return nil, fmt.Errorf("close authority rule: %w", closeErr) + } + if len(raw) > 65536 { return nil, fmt.Errorf("authority rule exceeds limit") } sum := sha256.Sum256(raw) diff --git a/cmd/sale-epoch-proposal/main.go b/cmd/sale-epoch-proposal/main.go index 1ac341b..44fa1ac 100644 --- a/cmd/sale-epoch-proposal/main.go +++ b/cmd/sale-epoch-proposal/main.go @@ -4,6 +4,7 @@ package main import ( "encoding/json" + "errors" "flag" "fmt" "os" @@ -16,7 +17,7 @@ import ( "github.com/aperod/aperod/store" ) -func run() error { +func run() (retErr error) { dbPath := flag.String("db", "", "closed canonical chain.db path") cfgPath := flag.String("config", "", "node config; required only for signing") out := flag.String("out", "", "new configuration file, never overwritten") @@ -34,7 +35,11 @@ func run() error { if err != nil { return fmt.Errorf("closed database required: %w", err) } - defer d.Close() + defer func() { + if closeErr := d.Close(); closeErr != nil { + retErr = errors.Join(retErr, fmt.Errorf("close database: %w", closeErr)) + } + }() active, err := d.LoadSaleState() if err != nil || active != nil { return fmt.Errorf("existing sale ledger must not be reopened as a fresh budget") @@ -152,17 +157,15 @@ func run() error { if err != nil { return err } - if err := json.NewEncoder(f).Encode(&m); err != nil { - f.Close() - os.Remove(*out) - return err + writeErr := json.NewEncoder(f).Encode(&m) + if writeErr == nil { + writeErr = f.Sync() } - if err := f.Sync(); err != nil { - f.Close() - os.Remove(*out) - return err - } - if err := f.Close(); err != nil { + closeErr := f.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + if removeErr := os.Remove(*out); removeErr != nil && !os.IsNotExist(removeErr) { + err = errors.Join(err, fmt.Errorf("remove incomplete proposal: %w", removeErr)) + } return err } checkpointHash := cp.Message() diff --git a/cmd/sale-preflight/claims.go b/cmd/sale-preflight/claims.go index a3ce876..c2b8a18 100644 --- a/cmd/sale-preflight/claims.go +++ b/cmd/sale-preflight/claims.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "io" "os" @@ -31,12 +32,17 @@ func loadClaims(path string) (*operatorClaims, error) { return &claims, nil } -func decodeDeclaration(path string, target any) error { +func decodeDeclaration(path string, target any) (retErr error) { + // #nosec G304 -- the declaration path is a local operator-selected input file. f, err := os.Open(path) if err != nil { return err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close declaration: %w", err)) + } + }() info, err := f.Stat() if err != nil { return err diff --git a/cmd/sale-preflight/main.go b/cmd/sale-preflight/main.go index cf11241..f0dbeb4 100644 --- a/cmd/sale-preflight/main.go +++ b/cmd/sale-preflight/main.go @@ -5,6 +5,7 @@ package main import ( "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "os" @@ -15,7 +16,7 @@ import ( "github.com/aperod/aperod/store" ) -func run() error { +func run() (retErr error) { copied := flag.String("copied-db", "", "separate, quiesced LevelDB copy") claimsPath := flag.String("claims", "", "optional operator declaration; produces an unsigned snapshot candidate only") burnReview := flag.String("recorded-burn-napro", "", "optional recorded counter for unsigned post-burn classification review, not historical proof") @@ -51,7 +52,11 @@ func run() error { if err != nil { return err } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close preflight database: %w", err)) + } + }() tip, height, err := db.GetTip() if err != nil { return err diff --git a/cmd/wallet-wasm/lpod.go b/cmd/wallet-wasm/lpod.go index 5711771..81c3431 100644 --- a/cmd/wallet-wasm/lpod.go +++ b/cmd/wallet-wasm/lpod.go @@ -70,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") } - case "withdraw": + case "withdraw", "topup": if len(r.DepositJSON) > 4096 || json.Unmarshal([]byte(r.DepositJSON), &a) != nil { return nil, fmt.Errorf("invalid canonical deposit") } @@ -87,13 +87,38 @@ func signLPoD(r lpodSignRequest, keys *wallet.DerivedKeys, source *core.OwnedUTX if err != nil || nonce == ^uint64(0) { return nil, fmt.Errorf("invalid current nonce") } - amount, err := strconv.ParseUint(r.Withdraw, 10, 64) - if err != nil || amount > a.Amount { - return nil, fmt.Errorf("invalid withdrawal amount") + if r.Action == "withdraw" { + amount, err := strconv.ParseUint(r.Withdraw, 10, 64) + if err != nil || amount > a.Amount { + return nil, fmt.Errorf("invalid withdrawal amount") + } + a.Action = core.LPoDWithdraw + } else { + if source == nil || source.Amount == 0 { + return nil, fmt.Errorf("select an exact owned top-up output") + } + sourceKey, err = crypto.AddScalars(source.HsScalar, keys.Keys.Spend.Private) + if err != nil { + return nil, err + } + pub, err := crypto.ScalarMulBase(sourceKey) + if err != nil || pub != source.OneTimePub { + return nil, fmt.Errorf("top-up source ownership mismatch") + } + a.Action = core.LPoDTopUp + a.SourceTx = source.TxHash + a.SourceIndex = source.OutputIndex + a.SourcePub = source.OneTimePub + a.Amount = source.Amount + a.Blind = source.Blind } - a.Action = core.LPoDWithdraw a.Nonce = nonce + 1 - a.WithdrawAmount = amount + if r.Action == "withdraw" { + a.WithdrawAmount, err = strconv.ParseUint(r.Withdraw, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid withdrawal amount") + } + } default: return nil, fmt.Errorf("invalid LPoD action") } diff --git a/cmd/wallet-wasm/lpod_js_wasm.go b/cmd/wallet-wasm/lpod_js_wasm.go index f9aa78c..fcf2c08 100644 --- a/cmd/wallet-wasm/lpod_js_wasm.go +++ b/cmd/wallet-wasm/lpod_js_wasm.go @@ -31,7 +31,7 @@ func buildLPoDTransaction(_ js.Value, args []js.Value) any { return promiseResult(nil, err) } var source *core.OwnedUTXO - if r.Action == "deposit" { + if r.Action == "deposit" || r.Action == "topup" { if len(wr.Outputs) != 1 { return promiseResult(nil, fmt.Errorf("select exactly one source output")) } diff --git a/cmd/wallet-wasm/lpod_test.go b/cmd/wallet-wasm/lpod_test.go index c3044d3..ed56915 100644 --- a/cmd/wallet-wasm/lpod_test.go +++ b/cmd/wallet-wasm/lpod_test.go @@ -45,6 +45,55 @@ func TestNativeLPoDLocalDepositAndPartialFullWithdrawal(t *testing.T) { if strings.Contains(string(body), mnemonic) { t.Fatal("mnemonic leaked") } + topUpHS := crypto.ScalarFromUint64(23) + topUpPrivate, err := crypto.AddScalars(topUpHS, keys.Keys.Spend.Private) + if err != nil { + t.Fatal(err) + } + topUpPub, err := crypto.ScalarMulBase(topUpPrivate) + if err != nil { + t.Fatal(err) + } + topUpSource := core.OwnedUTXO{ + UTXO: core.UTXO{TxHash: crypto.HashBytes([]byte("top-up-source")), OutputIndex: 2, OneTimePub: topUpPub}, + HsScalar: topUpHS, Amount: 700000000, Blind: crypto.BlindFactor(crypto.ScalarFromUint64(29)), + } + topUpRequest := lpodSignRequest{Action: "topup", Genesis: r.Genesis, DepositJSON: string(body), + Nonce: "0", PositionID: hex.EncodeToString(a.PositionID[:])} + topUpTx, err := signLPoD(topUpRequest, keys, &topUpSource) + if err != nil { + t.Fatal(err) + } + topUpAction, err := topUpTx.LPoDPositionAction() + if err != nil || topUpTx.Version != core.TxVersionLPoDTopUp || + topUpAction.Action != core.LPoDTopUp || topUpAction.PositionID != a.PositionID || + topUpAction.Nonce != 1 || topUpAction.SourceTx != topUpSource.TxHash || + topUpAction.SourceIndex != topUpSource.OutputIndex || topUpAction.Amount != topUpSource.Amount { + t.Fatalf("signed top-up did not preserve position and bind the selected output: %v", err) + } + utxos := core.NewUTXOSet() + utxos.Add(&core.UTXO{TxHash: topUpAction.SourceTx, OutputIndex: topUpAction.SourceIndex, + OneTimePub: topUpAction.SourcePub, AmountCommit: topUpTx.Inputs[0].AmountCommit}) + verifier := core.NewTxVerifier(utxos) + if err := verifier.VerifyTxAtHeight(topUpTx, 1); err == nil { + t.Fatal("top-up was accepted while the protocol gate is disabled") + } + verifier.SetLPoDDailyUpgradeHeight(3) + if err := verifier.VerifyTxAtHeight(topUpTx, 2); err == nil { + t.Fatal("top-up was accepted before its activation height") + } + if err := verifier.VerifyTxAtHeight(topUpTx, 3); err != nil { + t.Fatalf("valid top-up rejected at activation height: %v", err) + } + topUpRequest.PositionID = strings.Repeat("44", 32) + if _, err := signLPoD(topUpRequest, keys, &topUpSource); err == nil { + t.Fatal("top-up accepted a different position identity") + } + topUpRequest.PositionID = hex.EncodeToString(a.PositionID[:]) + topUpSource.HsScalar = crypto.ScalarFromUint64(31) + if _, err := signLPoD(topUpRequest, keys, &topUpSource); err == nil { + t.Fatal("top-up accepted a source not owned by the signing wallet") + } r = lpodSignRequest{Action: "withdraw", Genesis: r.Genesis, DepositJSON: string(body), Nonce: "0", PositionID: hex.EncodeToString(a.PositionID[:]), Withdraw: "123"} partial, err := signLPoD(r, keys, nil) diff --git a/config/config.go b/config/config.go index 6e22c8e..214dad1 100644 --- a/config/config.go +++ b/config/config.go @@ -387,6 +387,9 @@ 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"` + // LPoDDailyUpgradeHeight activates signed position top-ups and daily 20:00 + // Moscow payout processing. Zero leaves the historical per-block rules intact. + LPoDDailyUpgradeHeight uint64 `yaml:"lpod_daily_upgrade_height"` // FinalityMigrationFile is a signed, versioned attestation for one exact // historical finality certificate. Empty leaves ordinary quorum checks intact. FinalityMigrationFile string `yaml:"finality_migration_file"` @@ -676,6 +679,9 @@ func (c *Config) Validate() error { return fmt.Errorf("stop_producing_at_height (%d) exceeds the supported maximum (%d)", c.Consensus.StopProducingAtHeight, uint64(1<<63-1)) } + if c.Consensus.LPoDDailyUpgradeHeight > uint64(1<<63-1) { + return fmt.Errorf("lpod_daily_upgrade_height exceeds the supported maximum") + } if c.Consensus.StopProducingAtHeight > 0 && c.Consensus.NonValidator { return fmt.Errorf("stop_producing_at_height is only valid for validator nodes") } @@ -730,6 +736,9 @@ func (c *Config) Validate() error { } } guardianActivation := c.Consensus.GuardianFundActivationHeight + if c.Consensus.LPoDDailyUpgradeHeight > 0 && c.Consensus.LPoDMigrationFile == "" { + return fmt.Errorf("lpod_daily_upgrade_height requires a canonical LPoD migration") + } if c.Consensus.GenesisVestingActivationHeight > 0 && c.Consensus.LPoDMigrationFile == "" { return fmt.Errorf("genesis_vesting_activation_height requires a canonical LPoD migration") } diff --git a/consensus/avm_http_stress_linux_test.go b/consensus/avm_http_stress_linux_test.go index 6198a27..fc8bd97 100644 --- a/consensus/avm_http_stress_linux_test.go +++ b/consensus/avm_http_stress_linux_test.go @@ -11,6 +11,7 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/json" + "errors" "fmt" "io" "net" @@ -84,10 +85,13 @@ func stressRequest(client *http.Client, endpoint, ip string, body []byte) (int, if err != nil { return 0, err.Error() } - defer resp.Body.Close() value, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + closeErr := resp.Body.Close() if err != nil { - return 0, err.Error() + return 0, errors.Join(err, closeErr).Error() + } + if closeErr != nil { + return 0, closeErr.Error() } return resp.StatusCode, string(value) } diff --git a/consensus/finality_persistence_test.go b/consensus/finality_persistence_test.go index 16288bb..cb4bed6 100644 --- a/consensus/finality_persistence_test.go +++ b/consensus/finality_persistence_test.go @@ -42,7 +42,11 @@ func TestFinalityCertificateSurvivesRestartAndRejectsWrongFork(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close finality persistence database: %v", err) + } + }() chain, block := finalityTestChain(t, "canonical") registry := core.NewValidatorRegistry() registry.InitFromGenesis(pubs, core.MinStakeNAPR*10) @@ -140,7 +144,11 @@ func TestFinalityCommitteeMigrationRestoresCertificateOnlyWithPinnedQuorum(t *te if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close finality migration database: %v", err) + } + }() rawBlock, err := json.Marshal(block) if err != nil { t.Fatal(err) diff --git a/consensus/lpod.go b/consensus/lpod.go index 17f036d..69585aa 100644 --- a/consensus/lpod.go +++ b/consensus/lpod.go @@ -34,12 +34,19 @@ func (e *Engine) lpodActive(height uint64) bool { return e.cfg.LPoDMigration != nil && height >= e.cfg.LPoDMigration.Height } +func (e *Engine) lpodDailyActive(height uint64) bool { + return e.cfg.LPoDDailyUpgradeHeight > 0 && height >= e.cfg.LPoDDailyUpgradeHeight +} + func (e *Engine) prepareLPoD(block *core.Block, leader crypto.Address) (*store.LPoDSettlement, *store.LPoDCheckpoint, []lpod.Payment, error) { height := block.Header.Height if !e.lpodActive(height) { return nil, nil, nil, nil } m := e.cfg.LPoDMigration + if e.cfg.LPoDDailyUpgradeHeight > 0 && e.cfg.LPoDDailyUpgradeHeight < m.Height { + return nil, nil, nil, fmt.Errorf("lpod: daily upgrade height precedes the position migration") + } if e.cfg.Store == nil || e.cfg.OnCanonicalBlock == nil || e.cfg.Registry == nil { return nil, nil, nil, fmt.Errorf("lpod: canonical store, atomic commit and validator registry required") } @@ -69,7 +76,8 @@ func (e *Engine) prepareLPoD(block *core.Block, leader crypto.Address) (*store.L return nil, nil, nil, err } } - r := &store.LPoDSettlement{Parent: block.Header.PrevHash, PositionProtocol: true, Timestamp: block.Header.Timestamp, + r := &store.LPoDSettlement{Parent: block.Header.PrevHash, PositionProtocol: true, + DailyPayout: e.lpodDailyActive(height), Timestamp: block.Header.Timestamp, GenesisVestingActivationHeight: e.cfg.GenesisVestingActivationHeight, GenesisVestingFunding: e.cfg.GenesisVestingFunding, Proposer: block.Header.ValidatorPub.Hex(), Leader: leader, Stake: map[string]store.LPoDValidatorStake{}, @@ -245,13 +253,18 @@ func (e *Engine) filterLPoDPositions(txs []core.Transaction, height uint64) ([]c } if c != nil { for id, p := range c.Positions { - if p.Returned && p.Due == 0 { + if p.Returned && p.Due == 0 && p.PendingDue == 0 { continue } positions[id] = p if p.UnlockHeight == 0 { effective := store.LPoDEffectiveVault(p) - totals[hex.EncodeToString(effective[:])] += p.Deposit.Amount - p.Withdrawn + principal, err := store.LPoDPositionPrincipal(p) + if err != nil || principal < p.Withdrawn || + principal-p.Withdrawn > ^uint64(0)-totals[hex.EncodeToString(effective[:])] { + return nil, fmt.Errorf("lpod: invalid position principal in mempool filter") + } + totals[hex.EncodeToString(effective[:])] += principal - p.Withdrawn } } } @@ -274,6 +287,9 @@ func (e *Engine) filterLPoDPositions(txs []core.Transaction, height uint64) ([]c continue } valid := e.lpodActive(height) + if tx.Version == core.TxVersionLPoDTopUp && !e.lpodDailyActive(height) { + valid = false + } var a core.LPoDPositionAction if json.Unmarshal(tx.Extra, &a) != nil { valid = false @@ -287,7 +303,7 @@ func (e *Engine) filterLPoDPositions(txs []core.Transaction, height uint64) ([]c case core.LPoDDeposit: _, exists := positions[id] stake, found := e.cfg.Registry.GetEntry(crypto.ValidatorPubKey(a.Vault[:])) - max := uint64(100_000_000 * lpod.Unit) + max := 100_000_000 * lpod.Unit if exists || len(positions) >= store.LPoDMaxPositions || !found || stake.Seeded || stake.Status != core.ValidatorActive || exiting[v] || stake.StakeNAPR > max || totals[v] > max-stake.StakeNAPR || a.Amount > max-stake.StakeNAPR-totals[v] { @@ -300,6 +316,10 @@ func (e *Engine) filterLPoDPositions(txs []core.Transaction, height uint64) ([]c break } positions[id] = store.LPoDPosition{Deposit: a} + if a.Amount > ^uint64(0)-totals[v] { + valid = false + break + } totals[v] += a.Amount case core.LPoDWithdraw: p, exists := positions[id] @@ -310,13 +330,47 @@ func (e *Engine) filterLPoDPositions(txs []core.Transaction, height uint64) ([]c } p.Nonce++ p.Withdrawn += amount - p.Returned = p.Withdrawn == p.Deposit.Amount + principal, err := store.LPoDPositionPrincipal(p) + if err != nil { + valid = false + break + } + p.Returned = p.Withdrawn == principal if p.Returned { p.UnlockHeight = height } effective := store.LPoDEffectiveVault(p) totals[hex.EncodeToString(effective[:])] -= amount positions[id] = p + case core.LPoDTopUp: + p, exists := positions[id] + amount, err := store.LPoDTopUpAmount(p, a) + if !exists || err != nil { + valid = false + break + } + effective := store.LPoDEffectiveVault(p) + effectiveID := hex.EncodeToString(effective[:]) + stake, found := e.cfg.Registry.GetEntry(crypto.ValidatorPubKey(effective[:])) + max := 100_000_000 * lpod.Unit + if !found || stake.Seeded || stake.Status != core.ValidatorActive || + effectiveID != v || stake.StakeNAPR > max || totals[effectiveID] > max-stake.StakeNAPR || + amount > max-stake.StakeNAPR-totals[effectiveID] { + valid = false + break + } + if _, err := lpod.TierFor(stake.StakeNAPR + totals[effectiveID] + amount); err != nil { + valid = false + break + } + p.Nonce++ + p.TopUps = append(p.TopUps, a) + positions[id] = p + if amount > ^uint64(0)-totals[effectiveID] { + valid = false + break + } + totals[effectiveID] += amount default: valid = false } diff --git a/consensus/native_chrome_transfer_test.go b/consensus/native_chrome_transfer_test.go index cf3abd3..15463a2 100644 --- a/consensus/native_chrome_transfer_test.go +++ b/consensus/native_chrome_transfer_test.go @@ -175,11 +175,13 @@ func TestNativeChromeCanonicalTransfer(t *testing.T) { return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + if err := json.NewEncoder(w).Encode(map[string]any{ "tx_hash": fmt.Sprintf("%x", accepted.Hash()), "block_hash": fmt.Sprintf("%x", block.Hash()), "height": block.Header.Height, "fee_napr": accepted.Fee, "finalized": true, "canonical_funding_credit_napr": fmt.Sprint(afterFunding - beforeFunding), - }) + }); err != nil { + t.Errorf("encode transfer response: %v", err) + } }) mux.Handle("/", server) httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/consensus/poa.go b/consensus/poa.go index 4377843..c401987 100644 --- a/consensus/poa.go +++ b/consensus/poa.go @@ -42,7 +42,10 @@ type Config struct { // FinalityMigrationTrustAuthorities are operator-pinned validator identities // authorized to sign the migration, independent of genesis validators. FinalityMigrationTrustAuthorities []crypto.ValidatorPubKey - LPoDMigration *store.LPoDMigration + LPoDMigration *store.LPoDMigration + // LPoDDailyUpgradeHeight is a coordinated fork gate for top-ups and daily + // Guardian-position settlement. Zero keeps the feature disabled. + LPoDDailyUpgradeHeight uint64 SaleMigration *store.SaleMigration // Approved durable purchase intents are built at the real slot and parent. // Nil never enables the legacy admin-mint path. @@ -605,6 +608,7 @@ func (e *Engine) SetTxVerifier(v *core.TxVerifier, utxos *core.UTXOSet) { e.utxos = utxos if v != nil { v.SetStakedCollateralRingExclusionActivationHeight(e.cfg.StakedCollateralRingExclusionActivationHeight) + v.SetLPoDDailyUpgradeHeight(e.cfg.LPoDDailyUpgradeHeight) } } diff --git a/consensus/sale_finality.go b/consensus/sale_finality.go index 3aa95c1..8c9718e 100644 --- a/consensus/sale_finality.go +++ b/consensus/sale_finality.go @@ -2,6 +2,7 @@ package consensus import "github.com/aperod/aperod/crypto" +// IsSaleFinalizedHash reports whether a block hash belongs to the finalized sale chain. // Sales must remain recoverable after restart, when the in-memory per-height // cache contains only the restored, quorum-verified certificate. A canonical // ancestor of that finalized block is finalized too. Do not apply this fallback diff --git a/consensus/sale_http_rehearsal_test.go b/consensus/sale_http_rehearsal_test.go index 80ff028..ed76406 100644 --- a/consensus/sale_http_rehearsal_test.go +++ b/consensus/sale_http_rehearsal_test.go @@ -71,7 +71,11 @@ func (f *saleHTTPRehearsal) request(path string, body any, expected int) map[str if err != nil { f.t.Fatal(err) } - defer response.Body.Close() + defer func() { + if err := response.Body.Close(); err != nil { + f.t.Errorf("close sale response body: %v", err) + } + }() var result map[string]any if err := json.NewDecoder(response.Body).Decode(&result); err != nil { f.t.Fatal(err) diff --git a/consensus/sale_integration_test.go b/consensus/sale_integration_test.go index a92c58b..256d96b 100644 --- a/consensus/sale_integration_test.go +++ b/consensus/sale_integration_test.go @@ -1,9 +1,14 @@ package consensus import ( + "encoding/binary" + "encoding/hex" "encoding/json" + "errors" "os" "path/filepath" + "strconv" + "strings" "testing" "time" @@ -14,6 +19,8 @@ import ( "github.com/aperod/aperod/sale" "github.com/aperod/aperod/store" "github.com/aperod/aperod/wallet" + + "github.com/syndtr/goleveldb/leveldb" ) // Independent engines use real quorum signatures and separately committed @@ -45,7 +52,9 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, basis string) { g := engineBlock(t, 0, 0, crypto.Hash32{}, keys[0], nil) parent := engineBlock(t, store.LPoDV2ActivationHeight-1, 0, g.Hash(), keys[0], nil) parent.Header.Timestamp = time.Now().UnixNano() - 6_000_000_000 - parent.Header.Sign(keys[0]) + if err := parent.Header.Sign(keys[0]); err != nil { + t.Fatal(err) + } lm := &store.LPoDMigration{Version: 3, PositionLifecycleVersion: 1, Height: store.LPoDV2ActivationHeight, Genesis: g.Hash(), ParentHash: parent.Hash(), TrustAssumption: store.LPoDV3TrustAssumption, NominalEligible: 6_000_000_000 * lpod.Unit, @@ -73,7 +82,11 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, basis string) { t.Fatal(err) } dbs[i] = d - t.Cleanup(func() { d.Close() }) + t.Cleanup(func() { + if err := d.Close(); err != nil && !errors.Is(err, leveldb.ErrClosed) { + t.Errorf("close sale integration database: %v", err) + } + }) raw, _ := json.Marshal(g) if err = d.CommitRawBlockWithAVM(g.Hash(), 0, raw, nil, crypto.Hash32{}); err != nil { t.Fatal(err) @@ -110,7 +123,9 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, basis string) { } b := engineBlock(t, lm.Height, 1, parent.Hash(), keys[0], []core.Transaction{reward, core.LPoDCheckpointTx(c.Digest())}) b.Header.Timestamp = settlement.Timestamp - b.Header.Sign(keys[0]) + if err := b.Header.Sign(keys[0]); err != nil { + t.Fatal(err) + } if funding != nil && funding.Hash() != b.Hash() { t.Fatal("independent funding differs") } @@ -446,8 +461,22 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, basis string) { t.Fatal("purchase did not debit the available allocation by its credited amount") } if httpRehearsal != nil { - // Sign only after the actual owner scanner found the exact credited output. - httpRehearsal.acknowledge(buyer.Keys) + // Exercise a legacy-style bounded prune window across the activation, + // intervening block, and finalized delivery without scanning millions of + // synthetic heights in this isolated fixture. + for _, d := range dbs { + if err := d.EnsureSaleRetention(migration); err != nil { + t.Fatal("sale retention setup failed", err) + } + var cursor [8]byte + binary.LittleEndian.PutUint64(cursor[:], cp.Height-2) + if err := d.PutMeta("prune_cursor", cursor[:]); err != nil { + t.Fatal(err) + } + if _, err := d.PruneBlocksOlderThan(block.Header.Height + 1); err != nil { + t.Fatal("bounded sale-history prune failed", err) + } + } } // Purchases must exist as actual active outputs in the node snapshot, not // merely as a payment callback or an incremented display total. @@ -504,7 +533,11 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, basis string) { if err != nil { t.Fatal(err) } - defer reopened.Close() + defer func() { + if err := reopened.Close(); err != nil { + t.Errorf("close reopened sale integration database: %v", err) + } + }() persisted, err := reopened.LoadSaleState() if err != nil || persisted == nil || *persisted != *a { t.Fatalf("validator %d lost sale debit on reopening: %v", i, err) @@ -512,14 +545,63 @@ func testSaleTwoValidatorCanonicalDelivery(t *testing.T, basis string) { if _, err := reopened.LoadSaleMigrationFile(configPath); err != nil { t.Fatal("reopened ledger rejected its signed configuration", err) } + if err := reopened.EnsureSaleRetention(migration); err != nil { + t.Fatal("reopened ledger could not validate sale retention", err) + } engines[i].cfg.Store = reopened if httpRehearsal != nil && producer == engines[i] { httpRehearsal.server.SetStore(reopened) + // Restore only the exact anchor that both validators finalized above; + // ancestor checks still require the persisted canonical height index. + restoredFinality := &Engine{cfg: engines[i].cfg, lpodFinalizedHashes: map[uint64]crypto.Hash32{ + block.Header.Height: block.Hash(), + }} + if !restoredFinality.IsSaleFinalizedHash(block.Header.Height, block.Hash()) { + t.Fatal("reopened canonical delivery lost its finalized anchor") + } + httpRehearsal.server.SetSaleConfig(migration, engines[i].CanSettleSale, restoredFinality.IsSaleFinalizedHash) + status := httpRehearsal.request("status", nil, 200) + if status["remaining_napro"] != strconv.FormatUint(a.Remaining, 10) || + status["sold_napro"] != strconv.FormatUint(a.Sold, 10) || + status["historical_supply_verified"] != false { + t.Fatal("post-prune status reset allocation or overstated history", status) + } + finalized := httpRehearsal.receipt("finalized") + delivery, err := reopened.SaleOrderDelivery(httpRehearsal.order) + if err != nil || delivery == nil || + finalized["block_hash"] != hex.EncodeToString(delivery.Block[:]) || + finalized["tx_hash"] != hex.EncodeToString(delivery.Transaction[:]) { + t.Fatal("post-prune receipt is not bound to the canonical delivery", err, finalized) + } + wrongBlock := crypto.HashBytes([]byte("noncanonical sale delivery")) + httpRehearsal.request("recover", map[string]any{ + "order": hex.EncodeToString(httpRehearsal.order[:]), + "block_hash": hex.EncodeToString(wrongBlock[:]), + "tx_hash": finalized["tx_hash"], "signature": strings.Repeat("00", 64), + }, 409) + // Sign only after the real owner scanner found the credited output and + // the pruned/reopened API revalidated its canonical receipt. + httpRehearsal.acknowledge(buyer.Keys) + recovery, err := reopened.SaleOrderRecovery(httpRehearsal.order) + if err != nil || recovery == nil || recovery.Block != delivery.Block || + recovery.Transaction != delivery.Transaction { + t.Fatal("owner recovery was not preserved against canonical hashes", err) + } + persisted, err := reopened.LoadSaleState() + if err != nil || persisted == nil || *persisted != *a { + t.Fatal("pruning/recovery changed the sale allocation or double-credited it", err) + } httpRehearsal.receipt("completed") pending, err := reopened.PendingPaidSales(cp.Genesis, block.Header.Height+1, block.Hash(), cp.Network, keys[0]) if err != nil || len(pending) != 0 { t.Fatal("restart requeued an already delivered payment", err) } + if consumed, err := reopened.SaleConsumed('o', httpRehearsal.order); err != nil || !consumed { + t.Fatal("order replay fence disappeared after pruning", err) + } + if consumed, err := reopened.SaleConsumed('p', httpRehearsal.payment); err != nil || !consumed { + t.Fatal("payment replay fence disappeared after pruning", err) + } } } } diff --git a/core/chain.go b/core/chain.go index 7f8c233..5332c5f 100644 --- a/core/chain.go +++ b/core/chain.go @@ -172,7 +172,7 @@ func (c *Chain) BlocksNewestFirstPage(startHeight uint64, startAtTip bool, maxHe } } - blocks = make([]*Block, 0, int(startHeight-firstHeight+1)) + blocks = make([]*Block, 0, maxHeightScan) for height := startHeight; ; height-- { if block := c.byHeight[height]; block != nil { blocks = append(blocks, block) @@ -533,19 +533,25 @@ func (c *Chain) TailHashes(n int) []crypto.Hash32 { defer c.mu.RUnlock() tip := c.tip - if tip == nil { + if tip == nil || n <= 0 { return nil } - start := int(tip.Header.Height) - n + 1 - if start < 0 { - start = 0 + tipHeight := tip.Header.Height + // #nosec G115 -- n is checked positive immediately above; int is at most 64 bits on supported targets. + count := uint64(n) + start := uint64(0) + if tipHeight >= count-1 { + start = tipHeight - (count - 1) } out := make([]crypto.Hash32, 0, n) - for h := uint64(start); h <= tip.Header.Height; h++ { + for h := start; ; h++ { if b, ok := c.byHeight[h]; ok { out = append(out, b.Hash()) } + if h == tipHeight { + break + } } return out } diff --git a/core/genesis.go b/core/genesis.go index 1ac4508..99b5323 100644 --- a/core/genesis.go +++ b/core/genesis.go @@ -69,6 +69,7 @@ const BaseUnitsPerAPR = 100_000_000 // LoadGenesis reads a genesis YAML file. func LoadGenesis(path string) (*GenesisConfig, error) { + // #nosec G304 -- this path comes from the operator-managed node configuration, not network input. data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read genesis %s: %w", path, err) diff --git a/core/lpod_position.go b/core/lpod_position.go index 3e5e81d..f0d21dc 100644 --- a/core/lpod_position.go +++ b/core/lpod_position.go @@ -62,9 +62,16 @@ func BuildLPoDPayoutOutput(address crypto.Address, amount, height uint64, parent const TxVersionLPoDPosition TxVersion = 9 const TxVersionLPoDPayout TxVersion = 10 + +// TxVersionLPoDTopUp is an independently signed source-output addition to an +// existing position. Version 9 deposit and withdrawal bytes remain unchanged. +const TxVersionLPoDTopUp TxVersion = 12 const LPoDDeposit uint8 = 1 const LPoDWithdraw uint8 = 2 +// LPoDTopUp is the action code for adding an independently signed output to an existing position. +const LPoDTopUp uint8 = 3 + // Position ownership is independent of validator keys. OwnerProof proves knowledge // of the beneficiary's wallet spend scalar; DepositProof proves knowledge of the // source one-time scalar, amount opening AND its correctly linked key image. @@ -88,6 +95,9 @@ type LPoDPositionAction struct { func (a LPoDPositionAction) Message() crypto.Hash32 { b, _ := json.Marshal(a) + if a.Action == LPoDTopUp { + return crypto.HashBytes([]byte("aperod/lpod/position-topup/v1"), b) + } return crypto.HashBytes([]byte("aperod/lpod/position/v1"), b) } @@ -110,8 +120,13 @@ func LPoDOwnershipRing(pub crypto.Point32) []crypto.RingMember { return r } -func (tx *Transaction) IsLPoDPosition() bool { return tx != nil && tx.Version == TxVersionLPoDPosition } -func (tx *Transaction) IsLPoDPayout() bool { return tx != nil && tx.Version == TxVersionLPoDPayout } +// IsLPoDPosition reports whether tx is an LPoD position or top-up transaction. +func (tx *Transaction) IsLPoDPosition() bool { + return tx != nil && (tx.Version == TxVersionLPoDPosition || tx.Version == TxVersionLPoDTopUp) +} + +// IsLPoDPayout reports whether tx is an LPoD payout transaction. +func (tx *Transaction) IsLPoDPayout() bool { return tx != nil && tx.Version == TxVersionLPoDPayout } func (tx *Transaction) LPoDPositionAction() (*LPoDPositionAction, error) { if !tx.IsLPoDPosition() || len(tx.Extra) > 4096 || len(tx.Extra) == 0 { @@ -125,8 +140,15 @@ func (tx *Transaction) LPoDPositionAction() (*LPoDPositionAction, error) { if string(canonical) != string(tx.Extra) { return nil, fmt.Errorf("lpod: noncanonical position encoding") } - if a.Genesis == (crypto.Hash32{}) || a.PositionID != LPoDPositionID(a.Genesis, a.SourceTx, a.SourceIndex) || - (a.Action != LPoDDeposit && a.Action != LPoDWithdraw) || a.Amount == 0 { + validIdentity := a.Genesis != (crypto.Hash32{}) && a.PositionID != (crypto.Hash32{}) && a.Amount != 0 + if a.Action != LPoDTopUp { + validIdentity = validIdentity && a.PositionID == LPoDPositionID(a.Genesis, a.SourceTx, a.SourceIndex) + } else { + validIdentity = validIdentity && a.SourceTx != (crypto.Hash32{}) && a.SourcePub != (crypto.Point32{}) + } + validVersion := (tx.Version == TxVersionLPoDPosition && (a.Action == LPoDDeposit || a.Action == LPoDWithdraw)) || + (tx.Version == TxVersionLPoDTopUp && a.Action == LPoDTopUp) + if !validIdentity || !validVersion { return nil, fmt.Errorf("lpod: invalid action or position identity") } _, spend, _, err := crypto.DecodeAddress(a.Beneficiary) @@ -138,7 +160,7 @@ func (tx *Transaction) LPoDPositionAction() (*LPoDPositionAction, error) { return nil, fmt.Errorf("lpod: positions cannot mint or contain unaccounted fields") } expectedSignatures := 1 - if a.Action == LPoDDeposit { + if a.Action == LPoDDeposit || a.Action == LPoDTopUp { expectedSignatures = 2 } if len(tx.Signatures) != expectedSignatures { @@ -154,8 +176,10 @@ func (tx *Transaction) LPoDPositionAction() (*LPoDPositionAction, error) { } return &a, nil } - if a.Nonce != 0 || a.WithdrawAmount != 0 || len(tx.Inputs) != 1 || tx.Signatures[1] == nil { - return nil, fmt.Errorf("lpod: invalid deposit") + if (a.Action == LPoDDeposit && a.Nonce != 0) || + (a.Action == LPoDTopUp && a.Nonce == 0) || + a.WithdrawAmount != 0 || len(tx.Inputs) != 1 || tx.Signatures[1] == nil { + return nil, fmt.Errorf("lpod: invalid source-backed position operation") } commit, err := crypto.Commit(a.Amount, a.Blind) if err != nil { @@ -181,8 +205,12 @@ func BuildLPoDPositionTx(a LPoDPositionAction, owner crypto.Scalar32, source cry if err != nil { return nil, err } - tx := &Transaction{Version: TxVersionLPoDPosition, Extra: body, Signatures: []*crypto.MLSAGSignature{sig}} - if a.Action == LPoDDeposit { + version := TxVersionLPoDPosition + if a.Action == LPoDTopUp { + version = TxVersionLPoDTopUp + } + tx := &Transaction{Version: version, Extra: body, Signatures: []*crypto.MLSAGSignature{sig}} + if a.Action == LPoDDeposit || a.Action == LPoDTopUp { proof, err := crypto.MLSAGSignV4(a.Message(), LPoDOwnershipRing(a.SourcePub), 0, source, a.Blind, a.Amount) if err != nil { return nil, err diff --git a/core/mint.go b/core/mint.go index 58bd941..56cf5a9 100644 --- a/core/mint.go +++ b/core/mint.go @@ -41,6 +41,7 @@ func BuildMintTx(addr crypto.Address, amount uint64, height uint64) (*Transactio if height > 0 { blind, err = crypto.DeterministicMintBlindV2(spendPub, amount, height) } else { + //nolint:staticcheck // Height 0 is the legacy/admin mint domain; block rewards use the height-bound V2 function above. blind, err = crypto.DeterministicMintBlind(spendPub, amount) } if err != nil { diff --git a/core/mint_spend_test.go b/core/mint_spend_test.go index 5104518..d6572b5 100644 --- a/core/mint_spend_test.go +++ b/core/mint_spend_test.go @@ -53,6 +53,7 @@ func TestMintAndSpend(t *testing.T) { } // 4. Recompute blind (as aprWalletSend does) + //nolint:staticcheck // This test exercises a legacy height-zero admin mint. blind, err := crypto.DeterministicMintBlind(spendPubFromPriv, mintAmount) if err != nil { t.Fatalf("DeterministicMintBlind: %v", err) diff --git a/core/ringct_v5_test.go b/core/ringct_v5_test.go index 65e46fc..b453596 100644 --- a/core/ringct_v5_test.go +++ b/core/ringct_v5_test.go @@ -280,7 +280,11 @@ func TestRingCTV5RejectsStakedCollateralAcrossRestartAndRollback(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close reopened RingCT database: %v", err) + } + }() // Reconstruct the normal restart state from the persistent ring index and // replayed canonical stake event. The retained rm/ record remains available diff --git a/core/sale.go b/core/sale.go index 455dc98..dcfcf8e 100644 --- a/core/sale.go +++ b/core/sale.go @@ -8,14 +8,15 @@ import ( "github.com/aperod/aperod/crypto" ) +// TxVersionSale identifies an authorized sale transaction. const TxVersionSale TxVersion = 11 const saleExtraSize = 280 var salePrefix = []byte{'A', 'P', 'S', 'A', 'L', 'E', 1, 0} -// Recipient addresses are deliberately absent from the public authorization. -// The trusted issuer signs the exact confidential output, and the buyer must -// discover that output locally before an off-chain order can be completed. +// SaleAuthorization is a signed purchase authorization that omits recipient +// addresses. The trusted issuer signs the exact confidential output, and the +// buyer must discover that output locally before an off-chain order completes. type SaleAuthorization struct { Genesis crypto.Hash32 Height uint64 @@ -27,6 +28,7 @@ type SaleAuthorization struct { Signature [64]byte } +// IsSale reports whether tx uses the sale transaction version. func (tx *Transaction) IsSale() bool { return tx != nil && tx.Version == TxVersionSale } func (a SaleAuthorization) extra() []byte { @@ -43,6 +45,7 @@ func (a SaleAuthorization) extra() []byte { return out } +// SaleAuthorization parses the sale authorization embedded in tx. func (tx *Transaction) SaleAuthorization() (*SaleAuthorization, error) { if !tx.IsSale() || len(tx.Extra) != saleExtraSize || !bytes.Equal(tx.Extra[:8], salePrefix) || len(tx.Inputs) != 0 || len(tx.Outputs) != 1 || tx.Fee != 0 || @@ -72,6 +75,7 @@ func (tx *Transaction) SaleAuthorization() (*SaleAuthorization, error) { return &a, nil } +// SaleSigningMessage returns the digest authorized by the sale signer. func (tx *Transaction) SaleSigningMessage() crypto.Hash32 { unsigned := *tx unsigned.Extra = append([]byte(nil), tx.Extra...) @@ -82,6 +86,7 @@ func (tx *Transaction) SaleSigningMessage() crypto.Hash32 { return crypto.HashBytes([]byte("aperod/sale/confidential-output-authorization/v1"), hash[:]) } +// BuildSaleTransaction creates and signs a transaction for an authorized sale purchase. func BuildSaleTransaction(a SaleAuthorization, recipient crypto.Address, network crypto.NetworkByte, key interface { Sign(crypto.Hash32) ([]byte, error) diff --git a/core/security_completion_test.go b/core/security_completion_test.go index 44f6661..91dabe9 100644 --- a/core/security_completion_test.go +++ b/core/security_completion_test.go @@ -118,7 +118,11 @@ func TestSecurityReview_KeyImageAcrossLevelDBReopen(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { + if err := db.Close(); err != nil { + t.Errorf("close reopened security completion database: %v", err) + } + }() restarted := core.NewUTXOSetWithDB(db) if !restarted.IsSpent(ki) { t.Fatal("spent image lost across actual LevelDB close/reopen") diff --git a/core/staking.go b/core/staking.go index cf0b37a..0a2c907 100644 --- a/core/staking.go +++ b/core/staking.go @@ -662,6 +662,7 @@ func (r *ValidatorRegistry) ConfigureStakeKeyImageV4(activation uint64) error { return nil } +// StakeKeyImageV4Activation returns the configured activation height for v4 stake key images. func (r *ValidatorRegistry) StakeKeyImageV4Activation() uint64 { r.mu.RLock() defer r.mu.RUnlock() diff --git a/core/staking_test.go b/core/staking_test.go index 8f5bf5b..c6651e6 100644 --- a/core/staking_test.go +++ b/core/staking_test.go @@ -333,8 +333,12 @@ 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 + if err := utxos1.ApplyBlock(genesisBlock); err != nil { // unrelated UTXO added + t.Fatal(err) + } + if err := utxos1.ApplyBlock(mintBlock); err != nil { // burn UTXO added to active set + t.Fatal(err) + } reg1.ApplyBlockStakeTxs(stakeBlock.Txs, 2) // moves burn UTXO to staked set // Sanity check first run. diff --git a/core/transaction.go b/core/transaction.go index 6624dc3..5a88ec6 100644 --- a/core/transaction.go +++ b/core/transaction.go @@ -207,7 +207,7 @@ func (tx *Transaction) Validate() error { return fmt.Errorf("tx version 0 is invalid") } switch tx.Version { - case TxVersionBase, TxVersionGameAsset, TxVersionStake, TxVersionCommitmentBinding, TxVersionCLSAG, TxVersionAVM, TxVersionGuardianFund, TxVersionLPoD, TxVersionLPoDPosition, TxVersionLPoDPayout, TxVersionSale: + case TxVersionBase, TxVersionGameAsset, TxVersionStake, TxVersionCommitmentBinding, TxVersionCLSAG, TxVersionAVM, TxVersionGuardianFund, TxVersionLPoD, TxVersionLPoDPosition, TxVersionLPoDPayout, TxVersionSale, TxVersionLPoDTopUp: default: return fmt.Errorf("unsupported tx version %d", tx.Version) } diff --git a/core/tx_verifier.go b/core/tx_verifier.go index 59f8dfa..94d5590 100644 --- a/core/tx_verifier.go +++ b/core/tx_verifier.go @@ -96,6 +96,7 @@ type TxVerifier struct { utxos *UTXOSet vestingLock *VestingLock // nil = vesting enforcement disabled stakedCollateralRingExclusionActivationHeight uint64 + lpodDailyUpgradeHeight uint64 } // NewTxVerifier creates a verifier backed by the given UTXO set. @@ -117,6 +118,12 @@ func (v *TxVerifier) SetStakedCollateralRingExclusionActivationHeight(height uin v.stakedCollateralRingExclusionActivationHeight = height } +// SetLPoDDailyUpgradeHeight gates the v12 source-backed top-up operation. +// Zero keeps top-ups disabled; every validator must use the same future height. +func (v *TxVerifier) SetLPoDDailyUpgradeHeight(height uint64) { + v.lpodDailyUpgradeHeight = height +} + // VerifyTx performs full cryptographic verification of a transaction when no // height-gated rules are configured. Call VerifyTxAtHeight for block/mempool // validation whenever a height-dependent consensus rule is active. @@ -129,7 +136,7 @@ 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 { + if v.stakedCollateralRingExclusionActivationHeight > 0 || v.lpodDailyUpgradeHeight > 0 { return fmt.Errorf("height-aware transaction verification required") } return v.VerifyTxAtHeight(tx, 0) @@ -142,6 +149,10 @@ 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 tx != nil && tx.Version == TxVersionLPoDTopUp && + (v.lpodDailyUpgradeHeight == 0 || height < v.lpodDailyUpgradeHeight) { + return fmt.Errorf("lpod: top-ups are not active at candidate height %d", height) + } return v.verifyTx(tx, nil, height) } diff --git a/core/validator_authority.go b/core/validator_authority.go index 48e0ce4..a6a04b8 100644 --- a/core/validator_authority.go +++ b/core/validator_authority.go @@ -25,6 +25,7 @@ type ValidatorAuthorityFork struct { Possession []byte `json:"possession"` } +// Message returns the domain-separated validator-authority fork message. func (f *ValidatorAuthorityFork) Message() crypto.Hash32 { copy := *f copy.Possession = nil @@ -32,6 +33,7 @@ func (f *ValidatorAuthorityFork) Message() crypto.Hash32 { return crypto.HashBytes([]byte("aperod/validator-authority-fork/v1"), raw) } +// Validate checks the fork's activation and authority configuration. func (f *ValidatorAuthorityFork) Validate() error { if f == nil || f.Version != 1 || f.Height < 2 || f.Genesis == (crypto.Hash32{}) || f.Parent == (crypto.Hash32{}) || @@ -46,6 +48,7 @@ func (f *ValidatorAuthorityFork) Validate() error { return nil } +// Clone returns a deep copy of the fork configuration. func (f *ValidatorAuthorityFork) Clone() *ValidatorAuthorityFork { if f == nil { return nil @@ -66,6 +69,7 @@ func (f *ValidatorAuthorityFork) Authority(identity crypto.ValidatorPubKey, heig return append(crypto.ValidatorPubKey(nil), identity...) } +// LocalIdentity returns the validator identity authorized at height for key. func (f *ValidatorAuthorityFork) LocalIdentity(key crypto.ValidatorPubKey, height uint64) crypto.ValidatorPubKey { if f != nil && height >= f.Height && key.Equals(f.Replacement) { return append(crypto.ValidatorPubKey(nil), f.Identity...) @@ -73,6 +77,7 @@ func (f *ValidatorAuthorityFork) LocalIdentity(key crypto.ValidatorPubKey, heigh return append(crypto.ValidatorPubKey(nil), key...) } +// CheckParent verifies the parent header against this fork's authority rules. func (f *ValidatorAuthorityFork) CheckParent(header BlockHeader) error { if f != nil && header.Height == f.Height && header.PrevHash != f.Parent { return fmt.Errorf("validator authority fork: activation parent mismatch") diff --git a/core/validator_authority_registry.go b/core/validator_authority_registry.go index f8c644c..46109b5 100644 --- a/core/validator_authority_registry.go +++ b/core/validator_authority_registry.go @@ -25,10 +25,12 @@ func (r *ValidatorRegistry) ConfigureSignatureAuthority(f *ValidatorAuthorityFor return nil } +// SetSignatureAdmissionHeight sets the height at which upgraded signatures are admitted. func (r *ValidatorRegistry) SetSignatureAdmissionHeight(nextHeight uint64) { r.signatureAdmissionHeight.Store(nextHeight) } +// SignatureAuthority returns the key allowed to sign for identity at height. func (r *ValidatorRegistry) SignatureAuthority(identity crypto.ValidatorPubKey, height uint64) crypto.ValidatorPubKey { return r.signatureFork.Load().Authority(identity, height) } diff --git a/core/wallet_scanner.go b/core/wallet_scanner.go index 5fe8f29..86332fc 100644 --- a/core/wallet_scanner.go +++ b/core/wallet_scanner.go @@ -1,6 +1,8 @@ package core import ( + "encoding/binary" + "github.com/aperod/aperod/crypto" ) @@ -120,9 +122,7 @@ func decryptAmount(enc [8]byte, hs *crypto.Scalar32) uint64 { // 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)) - } + binary.LittleEndian.PutUint64(plain[:], amount) keyStream := crypto.HashBytes([]byte("aperod/amount-enc/v1"), hs[:]) var enc [8]byte for i := range enc { diff --git a/crypto/fuzz_test.go b/crypto/fuzz_test.go index 7185b1d..521d6eb 100644 --- a/crypto/fuzz_test.go +++ b/crypto/fuzz_test.go @@ -64,10 +64,7 @@ func FuzzHashBytes(f *testing.F) { 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)) - } + _ = crypto.HashBytes(data) }) } @@ -110,9 +107,9 @@ func FuzzMLSAGVerify(f *testing.F) { ring := make([]crypto.RingMember, 3) for i := range ring { kk, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(kk.Spend.Public) + ring[i] = kk.Spend.Public } - ring[0] = crypto.RingMember(wk.Spend.Public) + ring[0] = wk.Spend.Public var msg crypto.Hash32 msg[0] = 0xDE sig, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) diff --git a/crypto/pedersen.go b/crypto/pedersen.go index b1983bb..016648c 100644 --- a/crypto/pedersen.go +++ b/crypto/pedersen.go @@ -221,14 +221,7 @@ func BlindSum(in []BlindFactor, out []BlindFactor) (BlindFactor, error) { // 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) + binary.LittleEndian.PutUint64(b[:8], v) s, _ := edwards25519.NewScalar().SetCanonicalBytes(b[:]) return s } diff --git a/crypto/ringct.go b/crypto/ringct.go index bf4803c..44a593f 100644 --- a/crypto/ringct.go +++ b/crypto/ringct.go @@ -93,12 +93,12 @@ func MLSAGSign(message Hash32, ring []RingMember, realIdx int, privKey Scalar32) } // Step 2: compute L_real = alpha·G, R_real = alpha·Hp(P_real) - L_real := (&edwards25519.Point{}).ScalarBaseMult(alpha) + lReal := (&edwards25519.Point{}).ScalarBaseMult(alpha) HpReal := HashToCurvePoint(ring[realIdx][:]) - R_real := (&edwards25519.Point{}).ScalarMult(alpha, HpReal) + rReal := (&edwards25519.Point{}).ScalarMult(alpha, HpReal) // Step 3: compute challenge c_{real+1} - cc[nextIdx(realIdx)] = ringChallenge(message, L_real, R_real) + cc[nextIdx(realIdx)] = ringChallenge(message, lReal, rReal) // Step 4: walk the ring forward from real+1 for i := 1; i < RingSize; i++ { @@ -116,13 +116,13 @@ func MLSAGSign(message Hash32, ring []RingMember, realIdx int, privKey Scalar32) if err != nil { return nil, fmt.Errorf("ring member %d: %w", idx, err) } - L_i := ringL(ss[idx], cc[(realIdx+i)%RingSize], Pi) + lI := 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) + rI := ringR(ss[idx], cc[(realIdx+i)%RingSize], HpI, I) - cc[nextI] = ringChallenge(message, L_i, R_i) + cc[nextI] = ringChallenge(message, lI, rI) } // Step 5: close the ring — compute s_real = alpha - cc[real]·x (mod order) @@ -189,7 +189,7 @@ func MLSAGVerify(message Hash32, ring []RingMember, sig *MLSAGSignature) (bool, cc[0] = c0 for i := 0; i < RingSize; i++ { - s_i, err := ScalarFromBytes(sig.SS[i][:]) + sI, err := ScalarFromBytes(sig.SS[i][:]) if err != nil { return false, fmt.Errorf("ss[%d]: %w", i, err) } @@ -199,11 +199,11 @@ func MLSAGVerify(message Hash32, ring []RingMember, sig *MLSAGSignature) (bool, return false, fmt.Errorf("ring[%d]: %w", i, err) } - L_i := ringL(s_i, cc[i], Pi) + lI := ringL(sI, cc[i], Pi) HpI := HashToCurvePoint(ring[i][:]) - R_i := ringR(s_i, cc[i], HpI, I) + rI := ringR(sI, cc[i], HpI, I) - cc[i+1] = ringChallenge(message, L_i, R_i) + cc[i+1] = ringChallenge(message, lI, rI) } // Verify that the ring closes: c_0 == cc[RingSize] diff --git a/crypto/security_test.go b/crypto/security_test.go index b8734c8..8aca32a 100644 --- a/crypto/security_test.go +++ b/crypto/security_test.go @@ -42,10 +42,10 @@ func TestMLSAGSign_RingSize2(t *testing.T) { func TestMLSAGSign_RingSize16_Valid(t *testing.T) { wk, _ := crypto.GenerateWalletKeys() ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) + ring[0] = wk.Spend.Public for i := 1; i < crypto.RingSize; i++ { d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) + ring[i] = d.Spend.Public } var msg crypto.Hash32 msg[0] = 0xAB @@ -65,9 +65,9 @@ func TestMLSAGSign_RingSize100(t *testing.T) { ring := make([]crypto.RingMember, 100) for i := range ring { d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) + ring[i] = d.Spend.Public } - ring[0] = crypto.RingMember(wk.Spend.Public) + ring[0] = wk.Spend.Public var msg crypto.Hash32 _, err := crypto.MLSAGSign(msg, ring, 0, wk.Spend.Private) if err == nil { @@ -83,10 +83,10 @@ func TestMLSAGSign_RingSize100(t *testing.T) { func TestTxMalleability_SignatureInvalidatedAfterChange(t *testing.T) { wk, _ := crypto.GenerateWalletKeys() ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) + ring[0] = wk.Spend.Public for i := 1; i < crypto.RingSize; i++ { d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) + ring[i] = d.Spend.Public } // Sign a message representing a transaction hash @@ -123,10 +123,10 @@ func TestTxMalleability_SignatureInvalidatedAfterChange(t *testing.T) { func TestTxMalleability_KeyImageTampering(t *testing.T) { wk, _ := crypto.GenerateWalletKeys() ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) + ring[0] = wk.Spend.Public for i := 1; i < crypto.RingSize; i++ { d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) + ring[i] = d.Spend.Public } var msg crypto.Hash32 @@ -148,10 +148,10 @@ func TestTxMalleability_KeyImageTampering(t *testing.T) { func TestTxMalleability_RingTampering(t *testing.T) { wk, _ := crypto.GenerateWalletKeys() ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) + ring[0] = wk.Spend.Public for i := 1; i < crypto.RingSize; i++ { d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) + ring[i] = d.Spend.Public } var msg crypto.Hash32 @@ -162,7 +162,7 @@ func TestTxMalleability_RingTampering(t *testing.T) { // Swap a decoy ring member with a fresh random key fresh, _ := crypto.GenerateWalletKeys() - ring[1] = crypto.RingMember(fresh.Spend.Public) + ring[1] = fresh.Spend.Public ok, _ := crypto.MLSAGVerify(msg, ring, sig) if ok { @@ -180,7 +180,7 @@ func TestMLSAGSign_DuplicateRingKeys(t *testing.T) { // 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 + ring[i] = wk.Spend.Public // all identical } var msg crypto.Hash32 @@ -203,10 +203,10 @@ func TestMLSAGSign_DuplicateRingKeys(t *testing.T) { func TestMLSAGSign_PartialDuplicateKeys(t *testing.T) { wk, _ := crypto.GenerateWalletKeys() ring := make([]crypto.RingMember, crypto.RingSize) - ring[0] = crypto.RingMember(wk.Spend.Public) + ring[0] = wk.Spend.Public for i := 1; i < crypto.RingSize; i++ { d, _ := crypto.GenerateWalletKeys() - ring[i] = crypto.RingMember(d.Spend.Public) + ring[i] = d.Spend.Public } // Duplicate index 1 with index 0 ring[1] = ring[0] diff --git a/crypto/value_authorization.go b/crypto/value_authorization.go index a2272ee..559d1bc 100644 --- a/crypto/value_authorization.go +++ b/crypto/value_authorization.go @@ -25,6 +25,7 @@ func valueAuthorizationChallenge(amount uint64, commit Commitment, nonce []byte) return s } +// ProveValueAuthorization creates a proof that a commitment opens to amount. func ProveValueAuthorization(amount uint64, blind BlindFactor) (Commitment, ValueAuthorization, error) { commit, err := Commit(amount, blind) if err != nil { @@ -51,6 +52,7 @@ func ProveValueAuthorization(amount uint64, blind BlindFactor) (Commitment, Valu return commit, proof, nil } +// VerifyValueAuthorization checks that proof authorizes amount for commit. func VerifyValueAuthorization(amount uint64, commit Commitment, proof ValueAuthorization) error { C, err := checkedCLSAGSubgroupPoint(Point32(commit), "sale commitment", true) if err != nil { @@ -76,6 +78,7 @@ func VerifyValueAuthorization(amount uint64, commit Commitment, proof ValueAutho return nil } +// ValidateAuthorizedOutputPoints rejects invalid public and ephemeral output points. func ValidateAuthorizedOutputPoints(pub, ephemeral Point32) error { if _, err := checkedCLSAGPrimePoint(pub, "sale recipient output"); err != nil { return err diff --git a/deploy/SOURCE-DATA-MIGRATION.md b/deploy/SOURCE-DATA-MIGRATION.md new file mode 100644 index 0000000..f257e8c --- /dev/null +++ b/deploy/SOURCE-DATA-MIGRATION.md @@ -0,0 +1,106 @@ +# Production source/data separation: readiness and maintenance plan + +## Current boundary + +The production checkout at `/opt/aperod` is data-bearing. The node's current +configuration points at `/opt/aperod/data/testnet`; that path must be treated as +live chain state, not disposable build output. Do not run `git pull`, `reset`, +`clean`, or the all-components `deploy-server.sh` against this checkout. + +`/opt/aperod-source-staging` is a separate, clean **sparse** source checkout. It +contains the reviewed API, node, deployment, and shared-library paths, while +excluding `data/testnet` and `blockchain/data/testnet`. It is not a complete +checkout for rebuilding every website artifact. Refreshing it with +`update-source-staging.sh` changes only that source checkout; it does not deploy +anything or change the running node. + +The intended node-data target is `/var/lib/aperod/testnet`. This is a proposed +cutover target, not a claim that the current chain has been copied there. Keep +API/runtime settings under `/opt/aperod/data` unchanged unless a separately +reviewed API-data migration is planned. + +## Read-only readiness gate + +Run this only in a separately approved maintenance window, after arranging a +verified backup and stopping the node. It does not stop services, copy or +delete files, edit configuration, or install a binary: + +```bash +sudo env APEROD_MIGRATION_EXPECTED_COMMIT= \ + python3 /opt/aperod-source-staging/blockchain/deploy/source-data-migration-preflight.py +``` + +This checker is available on the server only after the staging checkout has +been refreshed to a reviewed commit containing the script. Do not copy an +unreviewed helper onto production just to make the check pass. + +The checker fails closed unless all of these are true: + +- the staging checkout is clean, on `main`, at the explicitly selected commit, + and its local `origin/main` contains that commit; +- the reviewed sparse paths are exact and no chain-data tree is materialized + in staging; +- `/etc/aperod/node.yaml` has exactly one `data_dir`, and its existing data + directory resolves without symlinks; +- the node service is confirmed inactive; +- the target `/var/lib/aperod/testnet` does not already exist, is outside both + checkouts, and its parent belongs to `aperod`; +- all existing node-data entries belong to `aperod`, and the target filesystem + has room for a second copy plus at least 512 MiB or 10% headroom (whichever is + larger); +- the backup receipt binds the exact source and target paths to an archive whose + SHA-256 matches, and records a successful isolated restore test for that same + archive. + +The receipt is an operator-provided evidence record, not a digital signature. +The hash detects archive changes; it does not independently prove that the +restore test was performed. Preserve the restore logs and have a second operator +review the evidence before authorizing cutover. + +### Backup receipt format + +The JSON receipt must be stored outside both checkouts and the node-data target: + +```json +{ + "schema": 1, + "source_data_dir": "/opt/aperod/data/testnet", + "target_data_dir": "/var/lib/aperod/testnet", + "source_commit": "", + "archive_path": "/path/on/verified/backup/storage/node-data.tar", + "archive_sha256": "<64 lowercase hex characters>", + "node_stopped": true, + "isolated_restore_status": "passed", + "isolated_restore_archive_sha256": "" +} +``` + +Do not create a receipt by assertion alone. Generate it from the actual backup +and isolated restore rehearsal, then compare the restored chain database and +snapshot inventory against the source captured while the node was stopped. + +## Cutover sequence (not executed by this preparation) + +1. Confirm a maintenance window, current node height/hash, active service unit, + data/config paths, filesystem capacity, and a verified off-host backup. +2. Stop `aperod-node` and verify `systemctl is-active aperod-node` reports + `inactive`. Do not copy a live LevelDB directory with `rsync`. +3. Run the readiness gate above. A `BLOCKED` result means no cutover. +4. Copy `chain.db` and its associated snapshots together to the absent target; + preserve file modes and `aperod:aperod` ownership. Do not delete or rename + the original `/opt/aperod/data/testnet`. +5. Verify the copied LevelDB opens read-only, compare chain height/hash and + snapshot inventory, and confirm the target tree ownership before changing + `/etc/aperod/node.yaml` to `data_dir: /var/lib/aperod/testnet`. +6. Review the actual systemd unit's `ExecStart`, `WorkingDirectory`, config + path, and permissions. Keep the source path and runtime data path separate; + do not infer unit settings from an installer template. +7. Start the node only after the compatibility/replay approval is complete. + Verify canonical height/hash, API health, peer connectivity, validator + behavior, and wallet/reward history. Retain the original tree and backup + until the agreed observation period and rollback criteria pass. +8. Only after successful verification, schedule a separate cleanup of the old + copies. Never include old chain data in a Git cleanup operation. + +This task prepares the safety gate and documents the migration. It does not +perform the maintenance-window copy or alter production services/configuration. diff --git a/deploy/publication_security.py b/deploy/publication_security.py index e5f676b..86291c7 100644 --- a/deploy/publication_security.py +++ b/deploy/publication_security.py @@ -73,6 +73,16 @@ def content_problem(path, content): return None +def private_reference_problem(content, markers): + """Refuse references to the private source repository without echoing them.""" + normalized = content.lower() + if not markers or any(not isinstance(marker, bytes) or not marker for marker in markers): + return "private repository identity unavailable" + if any(marker.lower() in normalized for marker in markers): + return "private source repository reference" + return None + + def git(repo, *args): # Git otherwise silently discovers a containing repository when a # disposable candidate loses its .git directory. Never scan or publish @@ -152,6 +162,26 @@ def inspect_history(repo, ref=None): return len(commits), len(seen), failures +def inspect_private_references(repo, tree, markers): + """Check every blob in the frozen publication tree for private repo references.""" + if not markers or any(not isinstance(marker, bytes) or not marker for marker in markers): + raise ValueError("private repository identity unavailable") + failures = [] + for item in git(repo, "ls-tree", "-rz", "--full-tree", tree).split(b"\0"): + if not item: + continue + metadata, raw_path = item.split(b"\t", 1) + _, kind, oid = metadata.decode("ascii").split() + if kind != "blob": + continue + path = raw_path.decode("utf-8") + problem = (private_reference_problem(raw_path, markers) + or private_reference_problem(git(repo, "cat-file", "blob", oid), markers)) + if problem: + failures.append((path, problem)) + return failures + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", type=Path, default=Path(".")) diff --git a/deploy/publish_public_repo.py b/deploy/publish_public_repo.py index 35b5da8..a9f0c18 100644 --- a/deploy/publish_public_repo.py +++ b/deploy/publish_public_repo.py @@ -11,8 +11,11 @@ import sys import tempfile import time +from urllib.parse import urlsplit -from publication_security import git, inspect, inspect_history, path_problem +from publication_security import ( + git, inspect, inspect_history, inspect_private_references, path_problem, +) from sync_public_repo import API, AUTHOR, make_request EXTENSIONS = {".go", ".sh", ".py", ".mod", ".sum", ".yaml", ".yml", @@ -44,6 +47,11 @@ MERGE_MESSAGE = "Verified source distribution through protected pull request." GITHUB_ACTIONS_APP_ID = 15368 TRUSTED_CHECK_APP_IDS = {"CodeQL": 57789} +PUBLIC_REPO_ID = 1413008539 +BRANCH_REQUIRED_CHECKS = { + ("Whole-tree publication security", GITHUB_ACTIONS_APP_ID), + ("go build & vet & test", GITHUB_ACTIONS_APP_ID), +} REQUIRED_CHECKS = { "Whole-tree publication security", "go build & vet & test", @@ -60,6 +68,44 @@ CODEQL_NEUTRAL_ALLOWED_TITLE = "1 configuration not found" +def private_repository_markers(root): + """Resolve the private source identity at runtime; never embed it in public code.""" + slug = os.environ.get("GITHUB_REPOSITORY", "").strip() + if not slug: + result = subprocess.run( + ["git", "-C", str(root), "remote", "get-url", "origin"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if result.returncode: + raise RuntimeError("Private source repository identity unavailable") + remote = result.stdout.strip() + if remote.startswith("git@") and ":" in remote: + host, path = remote.split(":", 1) + host = host.rsplit("@", 1)[-1] + else: + parsed = urlsplit(remote) + host, path = parsed.hostname or "", parsed.path.lstrip("/") + if host.lower() not in ("github.com", "www.github.com"): + raise RuntimeError("Private source repository identity unavailable") + slug = path + slug = slug.removesuffix(".git").strip("/").lower() + parts = slug.split("/") + allowed = "abcdefghijklmnopqrstuvwxyz0123456789-._" + if (len(parts) != 2 or not all(parts) + or any(c not in allowed for part in parts for c in part) + or slug == "aperod-network/aperod-node"): + raise RuntimeError("Private source repository identity unavailable") + raw = slug.encode("utf-8") + return ( + raw, + b"github.com/" + raw, + b"github.com:" + raw, + b"git@github.com:" + raw, + b"api.github.com/repos/" + raw, + b"raw.githubusercontent.com/" + raw, + ) + + def run(*args, cwd=None, test_python_userbase=None): # Repository-controlled tests/builds must not inherit publication tokens, # SSH passwords, bot credentials or unrelated application secrets. @@ -156,11 +202,20 @@ def prepare(root, candidate, gate_only=False): return git(candidate, "write-tree").decode().strip() -def verify(candidate, tree, go_checks=True, with_tests=False): +def verify(candidate, tree, go_checks=True, with_tests=False, + private_repo_markers=None): checked, failures = inspect(candidate, tree=tree) if failures: raise RuntimeError("Publication policy refused paths: " + ", ".join(path for path, _ in failures)) + if not private_repo_markers: + raise RuntimeError("Private source repository identity unavailable") + private_references = inspect_private_references( + candidate, tree, private_repo_markers + ) + if private_references: + raise RuntimeError("Publication policy refused private repository references: " + + ", ".join(path for path, _ in private_references)) if not shutil.which("gitleaks"): raise RuntimeError("Secret scanner missing; publication refused") commits, pairs, history_failures = inspect_history(candidate) @@ -192,15 +247,47 @@ def verify(candidate, tree, go_checks=True, with_tests=False): print(f"Verified immutable candidate: {checked} files.") -def publish(candidate, tree, expected_head, request, with_tests=False): +def verify_public_repo_policy(request): + """Require the live target identity and protected-main policy before writes.""" + account = request("GET", "https://api.github.com/user") + if account.get("login") != "aperod-network": + raise RuntimeError("Publication token is not authenticated as aperod-network") + repo = request("GET", API) + if (repo.get("id") != PUBLIC_REPO_ID or repo.get("private") is not False + or repo.get("fork") is not False or repo.get("default_branch") != "main"): + raise RuntimeError("Public repository identity or default branch changed") + protection = request("GET", f"{API}/branches/main/protection") + checks = (protection.get("required_status_checks") or {}).get("checks") or [] + observed = {(item.get("context"), item.get("app_id")) for item in checks} + if (observed != BRANCH_REQUIRED_CHECKS + or len(checks) != len(BRANCH_REQUIRED_CHECKS) + or not (protection.get("required_status_checks") or {}).get("strict") + or not (protection.get("enforce_admins") or {}).get("enabled") + or (protection.get("allow_force_pushes") or {}).get("enabled") + or (protection.get("allow_deletions") or {}).get("enabled")): + raise RuntimeError("Public main protection no longer matches the trusted policy") + rulesets = request( + "GET", + "https://api.github.com/repos/aperod-network/aperod-node/" + "rulesets?includes_parents=true&per_page=100", + ) + if not isinstance(rulesets, list) or rulesets: + raise RuntimeError("Unreviewed repository rulesets prevent publication") + + +def publish(candidate, tree, expected_head, request, with_tests=False, + private_repo_markers=None): # Independently revalidate everything before the first upload; callers # cannot bypass verification by invoking this Python API directly. - verify(candidate, tree, with_tests=with_tests) + verify(candidate, tree, with_tests=with_tests, + private_repo_markers=private_repo_markers) if request("GET", f"{API}/git/ref/heads/main")["object"]["sha"] != expected_head: raise RuntimeError("Public main changed; prepare and verify again") changed = git(candidate, "diff", "--cached", "--name-only", "-z").split(b"\0") if not any(changed): return None + verify_public_repo_policy(request) + base = request("GET", f"{API}/git/commits/{expected_head}")["tree"]["sha"] frozen = {} for row in git(candidate, "ls-tree", "-rz", tree).split(b"\0"): if row: @@ -222,12 +309,12 @@ def publish(candidate, tree, expected_head, request, with_tests=False): if blob["sha"] != oid: raise RuntimeError("Uploaded object integrity mismatch") entries.append({"path": rel, "mode": mode, "type": "blob", "sha": oid}) - base = request("GET", f"{API}/git/commits/{expected_head}")["tree"]["sha"] remote_tree = request("POST", f"{API}/git/trees", {"base_tree": base, "tree": entries}) if remote_tree["sha"] != tree: raise RuntimeError("Remote publication tree differs from verified snapshot") commit = request("POST", f"{API}/git/commits", { - "message": MESSAGE, "tree": tree, "parents": [expected_head], "author": AUTHOR}) + "message": MESSAGE, "tree": tree, "parents": [expected_head], + "author": AUTHOR, "committer": AUTHOR}) branch = "verified-publication/" + commit["sha"][:16] request("POST", f"{API}/git/refs", {"ref": "refs/heads/" + branch, "sha": commit["sha"]}) pr = request("POST", f"{API}/pulls", { @@ -237,12 +324,15 @@ def publish(candidate, tree, expected_head, request, with_tests=False): "Go build, vet and dependency vulnerability checks before object upload. " "No production runtime state is changed."}) pr["_verified_head_sha"] = commit["sha"] + pr["_verified_base_sha"] = expected_head print("Verified publication awaiting required checks:", pr["html_url"]) return pr -def await_merge(pr, request, timeout=900, expected_head_sha=None): +def await_merge(pr, request, timeout=900, expected_head_sha=None, + expected_base_sha=None): expected_head_sha = expected_head_sha or pr.get("_verified_head_sha") + expected_base_sha = expected_base_sha or pr.get("_verified_base_sha") if not expected_head_sha: raise RuntimeError("Verified uploaded PR head SHA is required") deadline = time.monotonic() + timeout @@ -256,6 +346,9 @@ def await_merge(pr, request, timeout=900, expected_head_sha=None): raise RuntimeError("Publication PR closed without merging") if pull.get("base", {}).get("ref") != "main": raise RuntimeError("Publication PR base is not main") + if (expected_base_sha + and pull.get("base", {}).get("sha") != expected_base_sha): + raise RuntimeError("Publication PR base changed after source verification") if pull.get("head", {}).get("sha") != expected_head_sha: time.sleep(10) continue @@ -312,8 +405,11 @@ def await_merge(pr, request, timeout=900, expected_head_sha=None): latest = request("GET", f"{API}/pulls/{pr['number']}") if (latest.get("state") != "open" or latest.get("base", {}).get("ref") != "main" - or latest.get("head", {}).get("sha") != expected_head_sha): + or latest.get("head", {}).get("sha") != expected_head_sha + or (expected_base_sha + and latest.get("base", {}).get("sha") != expected_base_sha)): raise RuntimeError("Publication PR head or base changed after checks") + verify_public_repo_policy(request) result = request("PUT", f"{API}/pulls/{pr['number']}/merge", { "sha": expected_head_sha, "merge_method": "merge", "commit_title": "chore: maintain verified source distribution", @@ -354,13 +450,19 @@ def main(): if changed: raise SystemExit(1) elif args.dry_run: - verify(candidate, tree, with_tests=args.with_tests) + markers = private_repository_markers(args.root.resolve()) + verify(candidate, tree, with_tests=args.with_tests, + private_repo_markers=markers) print("Dry-run verified; no objects uploaded.") else: request = make_request(token) - pr = publish(candidate, tree, head, request, args.with_tests) + markers = private_repository_markers(args.root.resolve()) + pr = publish(candidate, tree, head, request, args.with_tests, markers) if pr and not args.no_wait: - await_merge(pr, request, expected_head_sha=pr["_verified_head_sha"]) + await_merge( + pr, request, expected_head_sha=pr["_verified_head_sha"], + expected_base_sha=pr["_verified_base_sha"], + ) if __name__ == "__main__": diff --git a/deploy/rollout_cleanup/__init__.py b/deploy/rollout_cleanup/__init__.py index 4d0b3e8..951826e 100644 --- a/deploy/rollout_cleanup/__init__.py +++ b/deploy/rollout_cleanup/__init__.py @@ -1507,6 +1507,9 @@ def _revalidate_artifact(mark_data: dict[str, Any], job: dict[str, Any], manifest, size = _manifest(artifact, mark_data["kind"], runtime) if manifest != mark_data["manifest"]: raise CleanupError("artifact fingerprint changed after registration") + after_fingerprint = _rt(runtime).after_artifact_fingerprint + if after_fingerprint is not None: + after_fingerprint(artifact, mark_data["kind"]) return size diff --git a/deploy/rollout_cleanup/runtime.py b/deploy/rollout_cleanup/runtime.py index ab9b0ee..e4ed50d 100644 --- a/deploy/rollout_cleanup/runtime.py +++ b/deploy/rollout_cleanup/runtime.py @@ -64,6 +64,8 @@ class Runtime: live_exe_sha256: Callable[[int], str] | None = None api_json: Callable[[str], Any] | None = None verify_process_binding: Callable[[int, Path, Path], None] | None = None + # Deterministic race-test seam after the artifact fingerprint is verified. + after_artifact_fingerprint: Callable[[Path, str], None] | None = None DEFAULT_RUNTIME = Runtime() \ No newline at end of file diff --git a/deploy/source-data-migration-preflight.py b/deploy/source-data-migration-preflight.py new file mode 100644 index 0000000..da49f4f --- /dev/null +++ b/deploy/source-data-migration-preflight.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Read-only readiness gate for separating the production node source and data. + +This tool deliberately does not stop services, copy files, change config, or +install a release. It verifies the evidence and path preconditions required +before a separately approved maintenance-window migration. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +EXPECTED_SPARSE_PATTERNS = [ + "/artifacts/api-server/", + "/blockchain/", + "/deploy/", + "/lib/api-zod/", + "/lib/db/", + "!/data/testnet/", + "!/blockchain/data/testnet/", +] +RUNTIME_PATHS = ( + "data/testnet", + "blockchain/data/testnet", + "chain.db", + "snapshots", +) + + +class PreflightError(Exception): + pass + + +def env_path(name: str, default: str) -> Path: + value = os.environ.get(name, default) + path = Path(value) + if not path.is_absolute() or ".." in path.parts: + raise PreflightError(f"{name} must be an absolute path without '..'") + return path + + +def reject_symlink_components(path: Path, *, allow_missing_leaf: bool = False) -> None: + current = Path("/") + parts = path.parts[1:] + for index, part in enumerate(parts): + current /= part + if current.is_symlink(): + raise PreflightError(f"path contains a symlink component: {current}") + if not current.exists() and not (allow_missing_leaf and index == len(parts) - 1): + raise PreflightError(f"path component does not exist: {current}") + + +def run(*args: str, cwd: Path | None = None) -> str: + result = subprocess.run( + args, + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode: + detail = result.stderr.strip() or "command failed" + raise PreflightError(f"{' '.join(args)}: {detail}") + return result.stdout.strip() + + +def canonical_existing(path: Path, label: str) -> Path: + reject_symlink_components(path) + try: + result = path.resolve(strict=True) + except OSError as exc: + raise PreflightError(f"cannot resolve {label}: {exc}") from exc + if not result.is_dir(): + raise PreflightError(f"{label} is not a directory: {result}") + return result + + +def canonical_future(path: Path, label: str) -> Path: + reject_symlink_components(path, allow_missing_leaf=True) + if path.exists(): + raise PreflightError(f"{label} already exists; refusing to overwrite it: {path}") + try: + parent = path.parent.resolve(strict=True) + except OSError as exc: + raise PreflightError(f"{label} parent is not available: {exc}") from exc + if not parent.is_dir(): + raise PreflightError(f"{label} parent is not a directory: {parent}") + return parent / path.name + + +def require_outside(path: Path, roots: list[Path], label: str) -> None: + for root in roots: + if path == root or root in path.parents: + raise PreflightError(f"{label} must be outside {root}: {path}") + + +def parse_data_dir(config_path: Path) -> Path: + if config_path.is_symlink() or not config_path.is_file(): + raise PreflightError(f"node config is missing or symlinked: {config_path}") + matches = re.findall( + r"^[ \t]*data_dir[ \t]*:[ \t]*(.*?)[ \t]*(?:#.*)?$", + config_path.read_text(encoding="utf-8"), + flags=re.MULTILINE, + ) + if len(matches) != 1: + raise PreflightError("node config must contain exactly one data_dir entry") + raw = matches[0].strip().strip("\"'") + if not raw: + raise PreflightError("node config has an empty data_dir") + candidate = Path(raw) + if ".." in candidate.parts: + raise PreflightError("node config data_dir must not contain '..'") + if not candidate.is_absolute(): + candidate = config_path.parent / candidate + return candidate + + +def verify_source_checkout(source: Path, live_source: Path, expected_commit: str) -> Path: + if not re.fullmatch(r"[0-9a-f]{40}", expected_commit): + raise PreflightError("APEROD_MIGRATION_EXPECTED_COMMIT must be a full 40-character commit") + reject_symlink_components(source) + source_real = source.resolve(strict=True) + live_real = live_source.resolve(strict=True) + if source_real == live_real or live_real in source_real.parents or source_real in live_real.parents: + raise PreflightError("staging and legacy live source must be separate, non-nested directories") + git_root = Path(run("git", "-C", str(source_real), "rev-parse", "--show-toplevel")).resolve() + if git_root != source_real: + raise PreflightError("staging path is not the root of its own Git checkout") + if run("git", "-C", str(source_real), "rev-parse", "HEAD") != expected_commit: + raise PreflightError("staging HEAD does not match the explicitly reviewed commit") + if run("git", "-C", str(source_real), "branch", "--show-current") != "main": + raise PreflightError("staging must be checked out on main") + run("git", "-C", str(source_real), "show-ref", "--verify", "refs/remotes/origin/main") + run("git", "-C", str(source_real), "merge-base", "--is-ancestor", expected_commit, "origin/main") + if run("git", "-C", str(source_real), "status", "--porcelain", "--untracked-files=all"): + raise PreflightError("staging checkout is dirty") + if run("git", "-C", str(source_real), "config", "--bool", "core.sparseCheckout") != "true": + raise PreflightError("staging checkout is not sparse") + patterns = run("git", "-C", str(source_real), "sparse-checkout", "list").splitlines() + if patterns != EXPECTED_SPARSE_PATTERNS: + raise PreflightError("staging sparse paths do not match the reviewed source allowlist") + for rel in RUNTIME_PATHS: + candidate = source_real / rel + if candidate.exists() or candidate.is_symlink(): + raise PreflightError(f"runtime data is materialized inside staging: {candidate}") + return source_real + + +def tree_size_and_owner(root: Path, expected_owner: str) -> int: + total = 0 + for base, dirs, files in os.walk(root, followlinks=False): + base_path = Path(base) + for name in dirs + files: + path = base_path / name + if path.is_symlink(): + raise PreflightError(f"runtime data contains a symlink: {path}") + try: + info = path.stat(follow_symlinks=False) + owner = __import__("pwd").getpwuid(info.st_uid).pw_name + except (OSError, KeyError) as exc: + raise PreflightError(f"cannot inspect runtime path {path}: {exc}") from exc + if owner != expected_owner: + raise PreflightError(f"runtime path has owner {owner}, expected {expected_owner}: {path}") + if path.name in {"validator.key", "p2p_identity.key"} and info.st_mode & 0o077: + raise PreflightError(f"private key permissions are broader than 0600: {path}") + if path.is_file(): + total += info.st_size + elif not path.is_dir(): + raise PreflightError(f"runtime data contains a non-regular entry: {path}") + root_owner = __import__("pwd").getpwuid(root.stat().st_uid).pw_name + if root_owner != expected_owner: + raise PreflightError(f"runtime data root has owner {root_owner}, expected {expected_owner}") + return total + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_backup_receipt( + receipt_path: Path, + source_data: Path, + target_data: Path, + expected_commit: str, + roots: list[Path], +) -> None: + reject_symlink_components(receipt_path) + if not receipt_path.is_file(): + raise PreflightError(f"verified backup receipt is missing: {receipt_path}") + receipt_real = receipt_path.resolve(strict=True) + require_outside(receipt_real, roots + [source_data, target_data], "backup receipt") + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PreflightError(f"cannot read backup receipt: {exc}") from exc + required = { + "schema": 1, + "source_data_dir": str(source_data), + "target_data_dir": str(target_data), + "source_commit": expected_commit, + "node_stopped": True, + "isolated_restore_status": "passed", + } + for key, expected in required.items(): + if receipt.get(key) != expected: + raise PreflightError(f"backup receipt has missing or mismatched {key}") + archive_raw = receipt.get("archive_path") + archive_hash = receipt.get("archive_sha256") + if not isinstance(archive_raw, str) or not Path(archive_raw).is_absolute(): + raise PreflightError("backup receipt archive_path must be absolute") + if not isinstance(archive_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", archive_hash): + raise PreflightError("backup receipt archive_sha256 is invalid") + archive = Path(archive_raw) + if ".." in archive.parts: + raise PreflightError("backup receipt archive_path must not contain '..'") + reject_symlink_components(archive) + if not archive.is_file(): + raise PreflightError("backup archive does not exist") + archive_real = archive.resolve(strict=True) + require_outside(archive_real, roots + [source_data, target_data], "backup archive") + if sha256_file(archive_real) != archive_hash: + raise PreflightError("backup archive SHA-256 does not match the receipt") + if receipt.get("isolated_restore_archive_sha256") != archive_hash: + raise PreflightError("restore-test receipt is not bound to this backup archive") + + +def main() -> int: + try: + source_dir = env_path("APEROD_MIGRATION_SOURCE_DIR", "/opt/aperod-source-staging") + live_source_dir = env_path("APEROD_MIGRATION_LIVE_SOURCE_DIR", "/opt/aperod") + config_path = env_path("APEROD_MIGRATION_NODE_CONFIG", "/etc/aperod/node.yaml") + target_raw = env_path("APEROD_MIGRATION_TARGET_DATA_DIR", "/var/lib/aperod/testnet") + receipt_path = env_path( + "APEROD_MIGRATION_BACKUP_RECEIPT", + "/var/lib/aperod/source-data-migration/verified-backup.json", + ) + expected_commit = os.environ.get("APEROD_MIGRATION_EXPECTED_COMMIT", "") + expected_owner = os.environ.get("APEROD_MIGRATION_DATA_OWNER", "aperod") + if not expected_owner: + raise PreflightError("APEROD_MIGRATION_DATA_OWNER cannot be empty") + + source_real = verify_source_checkout(source_dir, live_source_dir, expected_commit) + live_real = canonical_existing(live_source_dir, "legacy live source") + source_data = canonical_existing(parse_data_dir(config_path), "configured node data") + target_data = canonical_future(target_raw, "external data target") + require_outside(target_data, [source_real, live_real], "external data target") + if source_data == target_data: + raise PreflightError("source and target data paths are identical") + + if run("systemctl", "show", "--property=ActiveState", "--value", "aperod-node") != "inactive": + raise PreflightError("aperod-node must be confirmed inactive during the maintenance window") + + data_bytes = tree_size_and_owner(source_data, expected_owner) + target_parent_owner = __import__("pwd").getpwuid(target_data.parent.stat().st_uid).pw_name + if target_parent_owner != expected_owner: + raise PreflightError( + f"external target parent has owner {target_parent_owner}, expected {expected_owner}" + ) + target_parent_mode = target_data.parent.stat().st_mode + if target_parent_mode & 0o022: + raise PreflightError("external target parent is group- or world-writable") + required_bytes = data_bytes + max(512 * 1024 * 1024, data_bytes // 10) + free_bytes = shutil.disk_usage(target_data.parent).free + if free_bytes < required_bytes: + raise PreflightError( + f"insufficient target disk space: {free_bytes} free, at least {required_bytes} required" + ) + + verify_backup_receipt( + receipt_path, + source_data, + target_data, + expected_commit, + [source_real, live_real], + ) + print("READY: source/data path checks passed; no files or services were changed.") + print(f" source commit: {expected_commit}") + print(f" current data: {source_data} ({data_bytes} bytes)") + print(f" future data: {target_data} (must remain absent until the approved copy step)") + print(f" owner: {expected_owner}") + print(f" target free: {free_bytes} bytes") + print(" backup receipt: checksum and isolated-restore claim match") + print(" IMPORTANT: receipt fields are operator evidence, not a cryptographic signature.") + return 0 + except (PreflightError, OSError, subprocess.SubprocessError) as exc: + print(f"BLOCKED: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/sync_public_repo.py b/deploy/sync_public_repo.py index 4dc8ce7..c7dfce6 100644 --- a/deploy/sync_public_repo.py +++ b/deploy/sync_public_repo.py @@ -3,6 +3,7 @@ import hashlib import json import urllib.error +import urllib.parse import urllib.request API = "https://api.github.com/repos/aperod-network/aperod-node" @@ -13,15 +14,36 @@ def git_blob_sha1(content): return hashlib.sha1(f"blob {len(content)}\0".encode() + content).hexdigest() +class SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + target = urllib.parse.urlsplit(newurl) + if (target.scheme != "https" or target.hostname != "api.github.com" + or target.port not in (None, 443) + or target.username or target.password): + raise RuntimeError("GitHub API redirect refused") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + def make_request(token): def request(method, url, data=None): + parsed = urllib.parse.urlsplit(url) + if (parsed.scheme != "https" or parsed.hostname != "api.github.com" + or parsed.port not in (None, 443) + or parsed.username or parsed.password): + raise RuntimeError("Refuse authenticated request outside GitHub API") + headers = { + "Authorization": "Bearer " + token, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if data is not None: + headers["Content-Type"] = "application/json" req = urllib.request.Request( url, data=json.dumps(data).encode() if data is not None else None, - headers={"Authorization": "Bearer " + token, - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28"}, method=method) + headers=headers, method=method) try: - with urllib.request.urlopen(req) as response: + opener = urllib.request.build_opener(SafeRedirect()) + with opener.open(req, timeout=120) as response: return json.loads(response.read()) if response.status != 204 else {} except urllib.error.HTTPError as error: # Do not echo response payloads or request bodies from credential APIs. diff --git a/deploy/test-backup.sh b/deploy/test-backup.sh index 17fd09f..aee3e0b 100644 --- a/deploy/test-backup.sh +++ b/deploy/test-backup.sh @@ -361,6 +361,16 @@ run_backup_fail() { local fake_gpg fake_gpg=$(make_fake_bin "gpg" "$gpg_log" 0) + # Report enough space so the low-disk preflight does not hide the intended + # pg_dump failure on small CI tmpfs filesystems. + local fake_df_dir="$run_dir/fake-df" + mkdir -p "$fake_df_dir" + cat >"$fake_df_dir/df" <<'STUB' +#!/usr/bin/env bash +printf 'Avail\n20971520\n' +STUB + chmod +x "$fake_df_dir/df" + # Provide a writable backup dir so the new mkdir guard passes and the # script reaches the pg_dump stage (where the sudo stub fails as intended). local backup_dir="$run_dir/backup-tmp" @@ -374,7 +384,7 @@ run_backup_fail() { APEROD_BACKUP_DIR_OVERRIDE="$backup_dir" \ TELEGRAM_BOT_TOKEN="$bot_token" \ ADMIN_TELEGRAM_CHAT_ID="$chat_id" \ - PATH="$fake_curl_dir:$fake_sudo:$fake_rclone:$fake_gpg:$PATH" \ + PATH="$fake_curl_dir:$fake_df_dir:$fake_sudo:$fake_rclone:$fake_gpg:$PATH" \ bash "$BACKUP_SH" >/dev/null 2>&1 || exit_code=$? echo "$exit_code" diff --git a/deploy/test-historical-retirement.py b/deploy/test-historical-retirement.py index 5ad31d4..2a78b5e 100644 --- a/deploy/test-historical-retirement.py +++ b/deploy/test-historical-retirement.py @@ -686,6 +686,13 @@ def test_closed_parent_symlink_and_mount_are_rejected(self): class B2VersionTests(unittest.TestCase): + def test_missing_archive_proof_fails_closed(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = RetirementFixture(Path(tmp)) + record = cleanup.read_json(fixture.record_path()) + with self.assertRaisesRegex(cleanup.CleanupError, "archive proof is missing"): + retirement._validated_archive_proof(record, {}, {}) + def test_by_id_download_uses_account_authorization_token(self): settings = {"s3backup": { "endpoint": "https://s3.us-west-004.backblazeb2.com", "bucket": "vault", diff --git a/deploy/test-rollout-cleanup.py b/deploy/test-rollout-cleanup.py index 93063bf..7cc2524 100644 --- a/deploy/test-rollout-cleanup.py +++ b/deploy/test-rollout-cleanup.py @@ -10,6 +10,7 @@ import stat import sys import tempfile +import threading import unittest from contextlib import nullcontext from pathlib import Path @@ -439,6 +440,18 @@ def __init__(self, root: Path): self.settings.write_text(json.dumps({"s3backup": {"fixture": True}})) self.neighbor = self.release / "node.previous" self.neighbor.write_bytes(b"keep this neighboring release artifact") + self.live_db_sentinel = self.data / "chain.db" / "fixture-live-data" + self.live_db_sentinel.write_bytes(b"live chain data must remain") + self.key_fixture = root / "keys" / "validator-key.fixture" + self.key_fixture.parent.mkdir(parents=True) + self.key_fixture.write_bytes(b"fixture only; not a real key") + self.lpod_evidence = root / "lpod-evidence" / "witness.fixture" + self.lpod_evidence.parent.mkdir(parents=True) + self.lpod_evidence.write_bytes(b"preserve LPoD evidence") + self.frontend_asset = root / "frontend-assets" / "wallet.js" + self.frontend_asset.parent.mkdir(parents=True) + self.frontend_asset.write_bytes(b"preserve frontend asset") + self.neighbor_before = self.neighbor.read_bytes() self.stopped = self.release / "stopped-copy" self.stopped.mkdir(mode=0o700) (self.stopped / "CURRENT").write_text("MANIFEST-000001\n") @@ -516,6 +529,18 @@ def begin_and_mark(self): cleanup.mark(self.job_id, str(self.candidate), "candidate", runtime=self.runtime) return self.job_id + def complete_single_artifact_lifecycle(self, kind): + result = cleanup.begin( + str(self.release), str(self.data), str(self.config), "aperod-node", self.api, + runtime=self.runtime, + ) + self.job_id = result["id"] + artifact = self.candidate if kind == "candidate" else self.stopped + cleanup.mark(self.job_id, str(artifact), kind, runtime=self.runtime) + self.restart_and_complete() + self.backup_pin_and_publish() + return artifact + def restart_and_complete(self): self.current_pid[0] = 202 self.add_process(202) @@ -553,6 +578,40 @@ def complete_lifecycle(self): class ProcKernelThreadTests(unittest.TestCase): + def test_backup_capture_waits_for_concurrent_cleanup_state_lock(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = LifecycleFixture(Path(tmp)) + attempting = threading.Event() + captured_origin = threading.Event() + failures = [] + + def capture_origin(*_args, **_kwargs): + captured_origin.set() + raise cleanup.CleanupError("test stop after lock acquisition") + + def backup(): + attempting.set() + try: + cleanup.backup_begin( + str(fixture.data), str(fixture.config), "aperod-node", fixture.api, + str(fixture.anchors_path), str(fixture.context_path), + runtime=fixture.runtime, + ) + except cleanup.CleanupError as exc: + failures.append(str(exc)) + + with mock.patch.object(cleanup, "_capture_origin", side_effect=capture_origin): + with cleanup.state_lock(fixture.state, runtime=fixture.runtime): + worker = threading.Thread(target=backup) + worker.start() + self.assertTrue(attempting.wait(timeout=2)) + self.assertFalse(captured_origin.wait(timeout=0.15)) + worker.join(timeout=2) + + self.assertFalse(worker.is_alive()) + self.assertTrue(captured_origin.is_set()) + self.assertEqual(failures, ["test stop after lock acquisition"]) + def test_real_proc_stat_pf_kthread_allows_empty_cmdline_without_bracketed_comm(self): with tempfile.TemporaryDirectory() as tmp: fixture = LifecycleFixture(Path(tmp)) @@ -637,6 +696,101 @@ def setUp(self): self.addCleanup(self.temp.cleanup) self.fixture = LifecycleFixture(Path(self.temp.name)) + @staticmethod + def _install_final_fingerprint_mutation(fixture, target, mutation): + calls = {"count": 0} + + def after_fingerprint(artifact, _kind): + if artifact == target: + calls["count"] += 1 + if calls["count"] == 2: + mutation() + + fixture.runtime.after_artifact_fingerprint = after_fingerprint + return calls + + @staticmethod + def _protected_snapshot(fixture): + paths = ( + fixture.neighbor, fixture.live_db_sentinel, fixture.key_fixture, + fixture.lpod_evidence, fixture.frontend_asset, fixture.settings, + fixture.anchors_path, fixture.proof_path, + ) + return {path: path.read_bytes() for path in paths} + + def _assert_late_apply_refused(self, fixture, target, kind, reason): + before_manifest = cleanup._manifest(target, kind, fixture.runtime)[0] + protected = self._protected_snapshot(fixture) + report = cleanup.scan(True, runtime=fixture.runtime, provider=fixture.provider) + self.assertEqual(len(report["artifacts"]), 1) + row = report["artifacts"][0] + self.assertFalse(row["eligible"], row["reasons"]) + self.assertFalse(row["removed"], row["reasons"]) + self.assertEqual(report["bytes"], 0) + self.assertTrue(any(reason in item for item in row["reasons"]), row["reasons"]) + self.assertTrue(target.exists()) + self.assertEqual(cleanup._manifest(target, kind, fixture.runtime)[0], before_manifest) + self.assertEqual({path: path.read_bytes() for path in protected}, protected) + self.assertEqual(fixture.neighbor.read_bytes(), fixture.neighbor_before) + self.assertTrue(fixture.candidate.exists()) + self.assertTrue(fixture.stopped.exists()) + + def test_apply_rechecks_pid_ticks_and_config_after_final_fingerprint(self): + mutations = ("pid", "start_ticks", "config_content", "config_identity") + for mutation in mutations: + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temp: + fixture = LifecycleFixture(Path(temp)) + target = fixture.complete_single_artifact_lifecycle("candidate") + + def change_live_identity(): + if mutation == "pid": + fixture.current_pid[0] = 303 + fixture.add_process(303) + elif mutation == "start_ticks": + fixture.ticks[202] = 999 + elif mutation == "config_content": + fixture.config.write_text('{"network":"changed"}') + else: + original_identity = cleanup._identity(fixture.config) + replacement = fixture.root / "replacement-config.yaml" + replacement.write_bytes(fixture.config.read_bytes()) + os.replace(replacement, fixture.config) + self.assertNotEqual(original_identity, cleanup._identity(fixture.config)) + + calls = self._install_final_fingerprint_mutation( + fixture, target, change_live_identity, + ) + self._assert_late_apply_refused( + fixture, target, "candidate", + "changed" if mutation in ("pid", "start_ticks") else "config identity or content changed", + ) + self.assertEqual(calls["count"], 2) + + def test_apply_rechecks_new_fd_config_and_cwd_references_after_final_fingerprint(self): + for scenario in ("fd", "config", "cwd"): + with self.subTest(reference=scenario), tempfile.TemporaryDirectory() as temp: + fixture = LifecycleFixture(Path(temp)) + kind = "stopped-copy" if scenario == "cwd" else "candidate" + target = fixture.complete_single_artifact_lifecycle(kind) + + def add_reference(): + if scenario == "fd": + fixture.add_process(303, fds=((9, target),)) + elif scenario == "config": + process_config = fixture.root / "worker-config.json" + process_config.write_text(json.dumps({"sourceFile": str(target)})) + fixture.add_process( + 303, cmdline=["worker", "--config", str(process_config)], + ) + else: + fixture.add_process(303, cwd=target) + + calls = self._install_final_fingerprint_mutation(fixture, target, add_reference) + self._assert_late_apply_refused( + fixture, target, kind, "references cleanup artifact", + ) + self.assertEqual(calls["count"], 2) + def test_restart_backup_pin_publish_dry_run_apply_and_neighbor_preservation(self): fixture = self.fixture fixture.complete_lifecycle() diff --git a/deploy/test-sync-public-repo-safety.sh b/deploy/test-sync-public-repo-safety.sh index fc6fe26..80ae5b2 100755 --- a/deploy/test-sync-public-repo-safety.sh +++ b/deploy/test-sync-public-repo-safety.sh @@ -2,40 +2,43 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" CANONICAL="${SCRIPT_DIR}/sync-public-repo.sh" WRAPPER="${SCRIPT_DIR}/../../scripts/sync-public-repo.sh" -PUSHER="${SCRIPT_DIR}/sync_public_repo.py" +PUBLISHER="${SCRIPT_DIR}/publish_public_repo.py" +LEGACY="${SCRIPT_DIR}/sync_public_repo.py" DRIFT_WORKFLOW="${SCRIPT_DIR}/../../.github/workflows/check-public-repo-drift.yml" DRIFT_ALERT_TEST="${SCRIPT_DIR}/../../.github/scripts/test_alert_public_repo_drift.py" bash -n "${CANONICAL}" bash -n "${WRAPPER}" -python3 -m py_compile "${PUSHER}" +python3 -m py_compile "${PUBLISHER}" "${LEGACY}" python3 "${SCRIPT_DIR}/test_sync_public_repo.py" PYTHONPATH="${SCRIPT_DIR}/../../.github/scripts" python3 "${DRIFT_ALERT_TEST}" -grep -Fq 'git clone --depth 1' "${CANONICAL}" -grep -Fq 'go build ./...' "${CANONICAL}" -grep -Fq 'go vet ./...' "${CANONICAL}" -grep -Fq 'comm -13 "${VERIFY_DIR}/public.list" "${VERIFY_DIR}/local.list"' "${CANONICAL}" -grep -Fq 'comm -23 "${VERIFY_DIR}/public.list" "${VERIFY_DIR}/local.list"' "${CANONICAL}" -grep -Fq 'unsynced drift file(s) found. Push aborted.' "${CANONICAL}" -grep -Fq '"sha": None' "${PUSHER}" -grep -Fq 'f"{API}/git/trees"' "${PUSHER}" -grep -Fq 'f"{API}/git/commits"' "${PUSHER}" -grep -Fq 'f"{API}/git/refs/heads/main"' "${PUSHER}" - -if grep -Fq 'grep -v "_test\\.go"' "${CANONICAL}"; then - echo "sync must include Go test files" >&2 +grep -Fq 'publish_public_repo.py' "${CANONICAL}" +grep -Fq 'Unverified publication is forbidden.' "${CANONICAL}" +grep -Fq 'private_repo_markers=markers' "${PUBLISHER}" +grep -Fq 'verify_public_repo_policy(request)' "${PUBLISHER}" +grep -Fq 'f"{API}/git/blobs"' "${PUBLISHER}" +grep -Fq 'f"{API}/git/trees"' "${PUBLISHER}" +grep -Fq 'f"{API}/git/commits"' "${PUBLISHER}" +grep -Fq 'f"{API}/git/refs"' "${PUBLISHER}" +grep -Fq 'f"{API}/pulls"' "${PUBLISHER}" +grep -Fq '"author": AUTHOR' "${PUBLISHER}" +grep -Fq '"committer": AUTHOR' "${PUBLISHER}" +if grep -Fq '/contents/' "${PUBLISHER}" || grep -Fq 'refs/heads/main' "${PUBLISHER}"; then + echo "publisher must use scanned Git Data objects and protected pull requests" >&2 + exit 1 +fi +if ! grep -Fq 'Legacy publication disabled' "${LEGACY}"; then + echo "legacy publication route must remain disabled" >&2 exit 1 fi -grep -Fq -- '--no-verify has been removed' "${CANONICAL}" grep -Fq 'cron: "17 3 * * *"' "${DRIFT_WORKFLOW}" grep -Fq 'sync-public-repo.sh --check-drift' "${DRIFT_WORKFLOW}" grep -Fq 'steps.check.outputs.status != '\''0'\''' "${DRIFT_WORKFLOW}" -grep -Fq 'SUPPORT_BOT_TOKEN: ${{ secrets.SUPPORT_BOT_TOKEN }}' "${DRIFT_WORKFLOW}" -grep -Fq 'SUPPORT_ADMIN_CHAT_ID: ${{ secrets.SUPPORT_ADMIN_CHAT_ID }}' "${DRIFT_WORKFLOW}" +grep -Fq 'ADMIN_BOT_TOKEN: ${{ secrets.ADMIN_BOT_TOKEN }}' "${DRIFT_WORKFLOW}" +grep -Fq 'ADMIN_CHAT_ID: ${{ secrets.ADMIN_CHAT_ID }}' "${DRIFT_WORKFLOW}" grep -Fq 'exec "${REPO_ROOT}/blockchain/deploy/sync-public-repo.sh" "$@"' "${WRAPPER}" if grep -Fq '/contents/${remote_path}' "${WRAPPER}"; then @@ -43,41 +46,4 @@ if grep -Fq '/contents/${remote_path}' "${WRAPPER}"; then exit 1 fi -TEMP_DIR=$(mktemp -d) -trap 'rm -rf "${TEMP_DIR}"' EXIT -mkdir -p "${TEMP_DIR}/bin" "${TEMP_DIR}/fixture/orphan" -printf 'package orphan\n' > "${TEMP_DIR}/fixture/orphan/deleted.go" -printf 'package orphan\n' > "${TEMP_DIR}/fixture/orphan/deleted_test.go" -cat > "${TEMP_DIR}/bin/git" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -if [[ "${1:-}" != "clone" ]]; then - if [[ "${1:-}" == "-C" && "${3:-}" == "rev-parse" && "${4:-}" == "HEAD" ]]; then - printf 'head-sha\n' - exit 0 - fi - echo "unexpected fake git invocation: $*" >&2 - exit 1 -fi -dest="${!#}" -mkdir -p "${dest}" -cp -a "${PUBLIC_REPO_FIXTURE}/." "${dest}/" -EOF -cat > "${TEMP_DIR}/bin/go" <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF -chmod +x "${TEMP_DIR}/bin/git" "${TEMP_DIR}/bin/go" - -( - cd "${REPO_ROOT}" - PATH="${TEMP_DIR}/bin:${PATH}" \ - PUBLIC_REPO_FIXTURE="${TEMP_DIR}/fixture" \ - PUBLIC_GITHUB_TOKEN="test-token-not-a-secret" \ - "${CANONICAL}" --dry-run > "${TEMP_DIR}/dry-run.log" -) -grep -Fq 'Including deletion in verified sync: orphan/deleted.go' "${TEMP_DIR}/dry-run.log" -grep -Fq 'Including deletion in verified sync: orphan/deleted_test.go' "${TEMP_DIR}/dry-run.log" -grep -Fq -- '--- Dry-run: public files that would be deleted ---' "${TEMP_DIR}/dry-run.log" - -echo "PASS: public repo sync is verified, drift-safe, and atomic" \ No newline at end of file +echo "PASS: public source publication is scanner-gated, author-verified, and PR-only" \ No newline at end of file diff --git a/deploy/test_publication_security.py b/deploy/test_publication_security.py index 57bc12e..4239575 100644 --- a/deploy/test_publication_security.py +++ b/deploy/test_publication_security.py @@ -21,6 +21,7 @@ def test_forbidden_paths(self): ".local/source.txt", ".agents/memory.md", ".ssh/known_hosts", "artifacts/api-server/source.ts", "deploy/production_known_hosts", "p2p_whitelist.json", "backup.tgz", "operator.log", + "deploy/sync-public-repo.sh", ): with self.subTest(path=path): self.assertIsNotNone(policy.path_problem(path)) @@ -65,6 +66,51 @@ def test_private_pem_is_refused_without_echoing_contents(self): "config.txt", b"-----BEGIN " + b"PRIVATE KEY-----\nTEST_ONLY\n" )) + def test_renamed_binary_is_refused_by_frozen_tree_scan(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.invalid"], check=True) + (root / "aperod").write_bytes(b"\x7fELF" + b"safe-test-fixture") + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + subprocess.run(["git", "-C", str(root), "commit", "-qm", "binary fixture"], check=True) + (root / "aperod").rename(root / "release-notes.txt") + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + tree = subprocess.check_output( + ["git", "-C", str(root), "write-tree"], text=True + ).strip() + checked, failures = policy.inspect(root, tree=tree) + self.assertEqual(checked, 1) + self.assertEqual(failures[0][0], "release-notes.txt") + self.assertIn("disguised filename", failures[0][1]) + + def test_private_repository_reference_is_detected_without_echoing_marker(self): + marker = b"private-owner/" + b"private-source" + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.invalid"], check=True) + (root / "README.md").write_bytes( + b"Do not publish https://github.com/" + marker + b"\n" + ) + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + subprocess.run(["git", "-C", str(root), "commit", "-qm", "fixture"], check=True) + tree = subprocess.check_output( + ["git", "-C", str(root), "write-tree"], text=True + ).strip() + failures = policy.inspect_private_references(root, tree, (marker,)) + self.assertEqual(failures, [("README.md", "private source repository reference")]) + self.assertNotIn(marker.decode(), repr(failures)) + + def test_private_reference_scan_requires_a_marker(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + subprocess.run(["git", "init", "-q", str(root)], check=True) + with self.assertRaisesRegex(ValueError, "identity unavailable"): + policy.inspect_private_references(root, "HEAD", ()) + def test_missing_candidate_git_never_falls_back_to_parent(self): with tempfile.TemporaryDirectory() as temp: parent = Path(temp) diff --git a/deploy/test_publish_public_repo.py b/deploy/test_publish_public_repo.py index e9dfaff..d38d761 100644 --- a/deploy/test_publish_public_repo.py +++ b/deploy/test_publish_public_repo.py @@ -7,6 +7,9 @@ import publish_public_repo as publisher from publication_security import git, inspect +TEST_PRIVATE_REPO = "test-private-owner/" + "source-monorepo" +TEST_PRIVATE_REPO_MARKERS = (TEST_PRIVATE_REPO.encode(),) + def fixture(root): subprocess.run(["git", "init", "-q", str(root)], check=True) @@ -27,6 +30,185 @@ def test_publication_uses_only_aperod_network_as_author(self): self.assertNotIn("Co-authored-by:", publisher.MESSAGE) self.assertNotIn("Co-authored-by:", publisher.MERGE_MESSAGE) + def test_private_repository_identity_is_read_at_runtime(self): + with patch.dict(publisher.os.environ, { + "GITHUB_REPOSITORY": TEST_PRIVATE_REPO, + }): + markers = publisher.private_repository_markers(Path(".")) + self.assertIn(TEST_PRIVATE_REPO.encode(), markers) + self.assertNotIn(b"aperod-network/aperod-node", markers) + + def test_live_branch_policy_requires_trusted_checks_and_admin_enforcement(self): + calls = [] + + def request(method, url, data=None): + calls.append((method, url)) + if url == "https://api.github.com/user": + return {"login": "aperod-network"} + if url == publisher.API: + return { + "id": publisher.PUBLIC_REPO_ID, "private": False, + "fork": False, "default_branch": "main", + } + if url.endswith("/branches/main/protection"): + return { + "required_status_checks": { + "strict": True, + "checks": [ + {"context": name, "app_id": app_id} + for name, app_id in publisher.BRANCH_REQUIRED_CHECKS + ], + }, + "enforce_admins": {"enabled": True}, + "allow_force_pushes": {"enabled": False}, + "allow_deletions": {"enabled": False}, + } + if "/rulesets?" in url: + return [] + raise AssertionError(f"unexpected request: {method} {url}") + + publisher.verify_public_repo_policy(request) + self.assertEqual(len(calls), 4) + + def test_live_branch_policy_fails_closed_if_admins_can_bypass(self): + def request(method, url, data=None): + if url == "https://api.github.com/user": + return {"login": "aperod-network"} + if url == publisher.API: + return { + "id": publisher.PUBLIC_REPO_ID, "private": False, + "fork": False, "default_branch": "main", + } + if url.endswith("/branches/main/protection"): + return { + "required_status_checks": { + "strict": True, + "checks": [ + {"context": name, "app_id": app_id} + for name, app_id in publisher.BRANCH_REQUIRED_CHECKS + ], + }, + "enforce_admins": {"enabled": False}, + "allow_force_pushes": {"enabled": False}, + "allow_deletions": {"enabled": False}, + } + raise AssertionError(f"unexpected request: {method} {url}") + + with self.assertRaisesRegex(RuntimeError, "trusted policy"): + publisher.verify_public_repo_policy(request) + + def test_live_branch_policy_rejects_other_publisher_identity(self): + def request(method, url, data=None): + if url == "https://api.github.com/user": + return {"login": "another-account"} + raise AssertionError(f"unexpected request after identity mismatch: {url}") + + with self.assertRaisesRegex(RuntimeError, "not authenticated as aperod-network"): + publisher.verify_public_repo_policy(request) + + def test_live_branch_policy_rejects_unreviewed_rulesets(self): + def request(method, url, data=None): + if url == "https://api.github.com/user": + return {"login": "aperod-network"} + if url == publisher.API: + return { + "id": publisher.PUBLIC_REPO_ID, "private": False, + "fork": False, "default_branch": "main", + } + if url.endswith("/branches/main/protection"): + return { + "required_status_checks": { + "strict": True, + "checks": [ + {"context": name, "app_id": app_id} + for name, app_id in publisher.BRANCH_REQUIRED_CHECKS + ], + }, + "enforce_admins": {"enabled": True}, + "allow_force_pushes": {"enabled": False}, + "allow_deletions": {"enabled": False}, + } + if "/rulesets?" in url: + return [{"id": 9, "enforcement": "active"}] + raise AssertionError(f"unexpected request: {method} {url}") + + with self.assertRaisesRegex(RuntimeError, "rulesets"): + publisher.verify_public_repo_policy(request) + + def test_publish_uses_scanned_git_data_api_and_explicit_single_author(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + fixture(root) + base = git(root, "rev-parse", "HEAD").decode().strip() + (root / "source.go").write_text("package source\n") + subprocess.run(["git", "-C", str(root), "add", "source.go"], check=True) + tree = git(root, "write-tree").decode().strip() + blob_row = git(root, "ls-tree", "-r", tree, "--", "source.go") + blob_sha = blob_row.decode().split()[2] + base_tree = git(root, "rev-parse", "HEAD^{tree}").decode().strip() + events = [] + payloads = {} + + def request(method, url, data=None): + events.append((method, url)) + if method == "GET" and url.endswith("/git/ref/heads/main"): + return {"object": {"sha": base}} + if method == "GET" and url == "https://api.github.com/user": + return {"login": "aperod-network"} + if method == "GET" and url == publisher.API: + return { + "id": publisher.PUBLIC_REPO_ID, "private": False, + "fork": False, "default_branch": "main", + } + if method == "GET" and url.endswith("/branches/main/protection"): + return { + "required_status_checks": { + "strict": True, + "checks": [ + {"context": name, "app_id": app_id} + for name, app_id in publisher.BRANCH_REQUIRED_CHECKS + ], + }, + "enforce_admins": {"enabled": True}, + "allow_force_pushes": {"enabled": False}, + "allow_deletions": {"enabled": False}, + } + if method == "GET" and "/rulesets?" in url: + return [] + if method == "GET" and url.endswith("/git/commits/" + base): + return {"tree": {"sha": base_tree}} + if method == "POST" and url.endswith("/git/blobs"): + return {"sha": blob_sha} + if method == "POST" and url.endswith("/git/trees"): + return {"sha": tree} + if method == "POST" and url.endswith("/git/commits"): + payloads["commit"] = data + return {"sha": "c" * 40} + if method == "POST" and url.endswith("/git/refs"): + return {} + if method == "POST" and url.endswith("/pulls"): + return {"number": 1, "html_url": "https://github.com/aperod-network/aperod-node/pull/1"} + raise AssertionError(f"unexpected request: {method} {url}") + + def verified(*args, **kwargs): + events.append(("VERIFY", "whole frozen candidate")) + + with patch.object(publisher, "verify", side_effect=verified): + pr = publisher.publish( + root, tree, base, request, + private_repo_markers=TEST_PRIVATE_REPO_MARKERS, + ) + + self.assertEqual(events[0][0], "VERIFY") + self.assertEqual(payloads["commit"]["author"], publisher.AUTHOR) + self.assertEqual(payloads["commit"]["committer"], publisher.AUTHOR) + self.assertNotIn("Co-authored-by:", payloads["commit"]["message"]) + self.assertEqual(pr["_verified_base_sha"], base) + self.assertFalse(any("/contents/" in url for _, url in events)) + self.assertIn(("POST", f"{publisher.API}/git/blobs"), events) + self.assertTrue(all(method != "POST" or "/contents/" not in url + for method, url in events)) + def test_candidate_symlink_cannot_overwrite_external_file(self): with tempfile.TemporaryDirectory() as temp: base = Path(temp) @@ -173,7 +355,8 @@ def request(method, url, data=None): }) return {"check_runs": checks} - with patch.object(publisher.time, "sleep"): + with patch.object(publisher.time, "sleep"), \ + patch.object(publisher, "verify_public_repo_policy"): publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") self.assertIn( ("GET", f"{publisher.API}/code-scanning/alerts?pr=1&tool_name=CodeQL&state=open&per_page=100"), @@ -212,7 +395,8 @@ def request(method, url, data=None): }) return {"check_runs": checks} - with patch.object(publisher.time, "sleep"): + with patch.object(publisher.time, "sleep"), \ + patch.object(publisher, "verify_public_repo_policy"): publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") self.assertEqual(check_reads, 2) self.assertFalse(any("/code-scanning/alerts?" in url for _, url in methods)) @@ -269,6 +453,31 @@ def request(method, url, data=None): ) self.assertNotIn("PUT", methods) + def test_policy_change_after_checks_prevents_merge(self): + methods = [] + + def request(method, url, data=None): + methods.append(method) + if method == "PUT": + return {"merged": True} + if url.endswith("/pulls/1"): + return {"state": "open", "head": {"sha": "reviewed"}, "base": {"ref": "main"}} + return {"check_runs": [ + {"name": name, "status": "completed", "conclusion": "success", + "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( + name, publisher.GITHUB_ACTIONS_APP_ID)}} + for name in publisher.REQUIRED_CHECKS + ]} + + with patch.object(publisher, "verify_public_repo_policy", + side_effect=RuntimeError("protection changed")): + with self.assertRaisesRegex(RuntimeError, "protection changed"): + publisher.await_merge( + {"number": 1, "html_url": "test"}, request, + expected_head_sha="reviewed", + ) + self.assertNotIn("PUT", methods) + def test_merge_is_bound_to_reviewed_head(self): bodies = [] def request(method, url, data=None): @@ -282,7 +491,11 @@ def request(method, url, data=None): "app": {"id": publisher.TRUSTED_CHECK_APP_IDS.get( name, publisher.GITHUB_ACTIONS_APP_ID)}} for name in publisher.REQUIRED_CHECKS]} - publisher.await_merge({"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed") + with patch.object(publisher, "verify_public_repo_policy"): + publisher.await_merge( + {"number": 1, "html_url": "test"}, request, + expected_head_sha="reviewed", + ) self.assertEqual(bodies[0]["sha"], "reviewed") self.assertNotIn("force", bodies[0]) @@ -313,7 +526,8 @@ def request(method, url, data=None): ]} raise AssertionError(f"unexpected request: {method} {url}") - with patch.object(publisher.time, "sleep"): + with patch.object(publisher.time, "sleep"), \ + patch.object(publisher, "verify_public_repo_policy"): publisher.await_merge( {"number": 1, "html_url": "test"}, request, expected_head_sha="reviewed", @@ -376,7 +590,10 @@ def test_policy_failure_prevents_every_network_request(self): tree = git(root, "write-tree").decode().strip() request = Mock() with self.assertRaisesRegex(RuntimeError, "Publication policy"): - publisher.publish(root, tree, "head", request) + publisher.publish( + root, tree, "head", request, + private_repo_markers=TEST_PRIVATE_REPO_MARKERS, + ) request.assert_not_called() def test_missing_scanner_prevents_every_network_request(self): @@ -387,7 +604,10 @@ def test_missing_scanner_prevents_every_network_request(self): request = Mock() with patch.object(publisher.shutil, "which", return_value=None): with self.assertRaisesRegex(RuntimeError, "scanner missing"): - publisher.publish(root, tree, "head", request) + publisher.publish( + root, tree, "head", request, + private_repo_markers=TEST_PRIVATE_REPO_MARKERS, + ) request.assert_not_called() def test_deleted_historical_key_prevents_every_network_request(self): @@ -403,7 +623,10 @@ def test_deleted_historical_key_prevents_every_network_request(self): request = Mock() with patch.object(publisher.shutil, "which", return_value="/scanner"): with self.assertRaisesRegex(RuntimeError, "Historical publication"): - publisher.publish(root, tree, "head", request) + publisher.publish( + root, tree, "head", request, + private_repo_markers=TEST_PRIVATE_REPO_MARKERS, + ) request.assert_not_called() def test_scanner_error_cannot_upload(self): @@ -415,7 +638,57 @@ def test_scanner_error_cannot_upload(self): with patch.object(publisher.shutil, "which", return_value="/scanner"), \ patch.object(publisher, "run", side_effect=subprocess.CalledProcessError(2, "scanner")): with self.assertRaises(subprocess.CalledProcessError): - publisher.publish(root, tree, "head", request) + publisher.publish( + root, tree, "head", request, + private_repo_markers=TEST_PRIVATE_REPO_MARKERS, + ) + request.assert_not_called() + + def test_secret_in_unchanged_file_is_scanned_before_any_upload(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + fixture(root) + (root / "unchanged.md").write_text("TEST_SECRET_UNCHANGED_VALUE\n") + subprocess.run(["git", "-C", str(root), "add", "unchanged.md"], check=True) + subprocess.run(["git", "-C", str(root), "commit", "-qm", "unchanged fixture"], check=True) + (root / "new.go").write_text("package source\n") + subprocess.run(["git", "-C", str(root), "add", "new.go"], check=True) + tree = git(root, "write-tree").decode().strip() + request = Mock() + + def reject_secret_scan(*args, **kwargs): + if args[:2] == ("gitleaks", "git"): + paths = git(root, "ls-tree", "-r", "--name-only", "HEAD").decode() + self.assertIn("unchanged.md", paths) + self.assertIn("--redact=100", args) + raise subprocess.CalledProcessError(1, "gitleaks") + raise AssertionError(f"unexpected verification command: {args[:2]}") + + with patch.object(publisher.shutil, "which", return_value="/scanner"), \ + patch.object(publisher, "run", side_effect=reject_secret_scan): + with self.assertRaises(subprocess.CalledProcessError) as error: + publisher.publish( + root, tree, "head", request, + private_repo_markers=TEST_PRIVATE_REPO_MARKERS, + ) + self.assertNotIn("TEST_SECRET_UNCHANGED_VALUE", str(error.exception)) + request.assert_not_called() + + def test_private_repository_reference_blocks_before_any_upload(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + fixture(root) + marker = TEST_PRIVATE_REPO.encode() + (root / "README.md").write_bytes( + b"Private issue tracker: https://github.com/" + marker + b"\n" + ) + subprocess.run(["git", "-C", str(root), "add", "README.md"], check=True) + tree = git(root, "write-tree").decode().strip() + request = Mock() + with self.assertRaisesRegex(RuntimeError, "private repository references") as error: + publisher.publish(root, tree, "head", request, + private_repo_markers=(marker,)) + self.assertNotIn(marker.decode(), str(error.exception)) request.assert_not_called() def test_old_runtime_is_removed_only_from_disposable_candidate(self): @@ -462,6 +735,24 @@ def test_extensionless_build_files_are_included_in_public_snapshot(self): self.assertEqual((candidate / "Makefile").read_text(), (source / "Makefile").read_text()) + def test_private_sync_wrapper_is_not_in_public_candidate(self): + with tempfile.TemporaryDirectory() as temp: + base = Path(temp) + source, candidate = base / "source", base / "candidate" + source.mkdir() + candidate.mkdir() + fixture(candidate) + (source / "go.mod").write_text("module example.invalid/test\n") + wrapper = source / "deploy/sync-public-repo.sh" + wrapper.parent.mkdir(parents=True) + wrapper.write_text("# private publication entrypoint\n") + + tree = publisher.prepare(source, candidate) + paths = git(candidate, "ls-tree", "-r", "--name-only", tree).decode().splitlines() + + self.assertTrue(wrapper.exists()) + self.assertNotIn("deploy/sync-public-repo.sh", paths) + def test_disguised_binary_and_hex_secret_are_refused(self): from publication_security import content_problem for contents in (b"\x7fELF" + b"A" * 70, b"a" * 128): diff --git a/deploy/test_source_data_migration_preflight.py b/deploy/test_source_data_migration_preflight.py new file mode 100644 index 0000000..579fa50 --- /dev/null +++ b/deploy/test_source_data_migration_preflight.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import os +import pwd +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("source-data-migration-preflight.py") +SPARSE = [ + "/artifacts/api-server/", + "/blockchain/", + "/deploy/", + "/lib/api-zod/", + "/lib/db/", + "!/data/testnet/", + "!/blockchain/data/testnet/", +] + + +class SourceDataMigrationPreflightTest(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory(prefix="aperod-source-migration-") + self.root = Path(self.tmp.name) + self.live = self.root / "legacy" + self.stage = self.root / "source-stage" + self.origin = self.root / "origin.git" + self.config = self.root / "node.yaml" + self.data = self.live / "data" / "testnet" + self.target_parent = self.root / "var" / "lib" / "aperod" + self.target = self.target_parent / "testnet" + self.backup = self.root / "backup.tar" + self.receipt = self.root / "verified-backup.json" + self.systemctl = self.root / "systemctl" + self.owner = pwd.getpwuid(os.getuid()).pw_name + + self.live.mkdir() + self.data.mkdir(parents=True) + (self.data / "chain.db").mkdir() + (self.data / "chain.db" / "CURRENT").write_text("MANIFEST-000001\n") + (self.data / "chain.db" / "MANIFEST-000001").write_text("VersionSet\n") + self.config.write_text(f"data_dir: {self.data}\n") + self.target_parent.mkdir(parents=True) + + seed = self.root / "seed" + for rel in [ + "artifacts/api-server/src/index.ts", + "blockchain/main.go", + "deploy/update.sh", + "lib/api-zod/index.ts", + "lib/db/schema.ts", + "data/testnet/chain.db/CURRENT", + "blockchain/data/testnet/chain.db/CURRENT", + ]: + path = seed / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("runtime fixture\n" if "testnet" in rel else "source\n") + subprocess.run(["git", "init", "--bare", str(self.origin)], check=True, capture_output=True) + subprocess.run(["git", "init", "-b", "main", str(seed)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(seed), "config", "user.email", "test@example.invalid"], check=True) + subprocess.run(["git", "-C", str(seed), "config", "user.name", "Test"], check=True) + subprocess.run(["git", "-C", str(seed), "add", "."], check=True) + subprocess.run(["git", "-C", str(seed), "commit", "-m", "fixture"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(seed), "remote", "add", "origin", str(self.origin)], check=True) + subprocess.run(["git", "-C", str(seed), "push", "-u", "origin", "main"], check=True, capture_output=True) + subprocess.run( + ["git", "clone", "--no-checkout", "-b", "main", str(self.origin), str(self.stage)], + check=True, + capture_output=True, + ) + subprocess.run(["git", "-C", str(self.stage), "sparse-checkout", "init", "--no-cone"], check=True) + subprocess.run( + ["git", "-C", str(self.stage), "sparse-checkout", "set", "--no-cone", "--stdin"], + input="\n".join(SPARSE) + "\n", + text=True, + check=True, + capture_output=True, + ) + subprocess.run(["git", "-C", str(self.stage), "read-tree", "-mu", "HEAD"], check=True) + self.commit = subprocess.check_output( + ["git", "-C", str(self.stage), "rev-parse", "HEAD"], text=True + ).strip() + + self.backup.write_bytes(b"verified archive fixture") + self.archive_hash = hashlib.sha256(self.backup.read_bytes()).hexdigest() + self.write_receipt() + self.systemctl.write_text("#!/bin/sh\nprintf 'inactive\\n'\n") + self.systemctl.chmod(0o755) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def write_receipt(self, **updates: object) -> None: + receipt: dict[str, object] = { + "schema": 1, + "source_data_dir": str(self.data), + "target_data_dir": str(self.target), + "source_commit": self.commit, + "archive_path": str(self.backup), + "archive_sha256": self.archive_hash, + "node_stopped": True, + "isolated_restore_status": "passed", + "isolated_restore_archive_sha256": self.archive_hash, + } + receipt.update(updates) + self.receipt.write_text(json.dumps(receipt)) + + def run_preflight( + self, extra_env: dict[str, str] | None = None + ) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "APEROD_MIGRATION_SOURCE_DIR": str(self.stage), + "APEROD_MIGRATION_LIVE_SOURCE_DIR": str(self.live), + "APEROD_MIGRATION_NODE_CONFIG": str(self.config), + "APEROD_MIGRATION_TARGET_DATA_DIR": str(self.target), + "APEROD_MIGRATION_BACKUP_RECEIPT": str(self.receipt), + "APEROD_MIGRATION_EXPECTED_COMMIT": self.commit, + "APEROD_MIGRATION_DATA_OWNER": self.owner, + "PATH": str(self.root) + os.pathsep + os.environ.get("PATH", ""), + } + if extra_env: + env.update(extra_env) + (self.root / "systemctl").write_text(self.systemctl.read_text()) + (self.root / "systemctl").chmod(0o755) + return subprocess.run( + ["python3", str(SCRIPT)], + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + def test_ready_is_read_only_and_does_not_create_target(self) -> None: + before = (self.data / "chain.db" / "CURRENT").read_bytes() + result = self.run_preflight() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("READY:", result.stdout) + self.assertEqual((self.data / "chain.db" / "CURRENT").read_bytes(), before) + self.assertFalse(self.target.exists()) + + def test_wrong_source_commit_is_blocked(self) -> None: + result = self.run_preflight_with("APEROD_MIGRATION_EXPECTED_COMMIT", "0" * 40) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match", result.stderr) + + def test_modified_backup_is_blocked(self) -> None: + self.backup.write_bytes(b"changed after verification") + result = self.run_preflight() + self.assertNotEqual(result.returncode, 0) + self.assertIn("SHA-256", result.stderr) + + def test_active_node_is_blocked(self) -> None: + self.systemctl.write_text("#!/bin/sh\nprintf 'active\\n'\n") + self.systemctl.chmod(0o755) + result = self.run_preflight() + self.assertNotEqual(result.returncode, 0) + self.assertIn("must be confirmed inactive", result.stderr) + + def test_receipt_must_bind_the_exact_paths_and_restore_hash(self) -> None: + self.write_receipt(target_data_dir=str(self.root / "wrong-target")) + result = self.run_preflight() + self.assertNotEqual(result.returncode, 0) + self.assertIn("target_data_dir", result.stderr) + + def test_sparse_stage_must_not_materialize_chain_data(self) -> None: + forbidden = self.stage / "blockchain" / "data" / "testnet" + forbidden.mkdir(parents=True) + result = self.run_preflight() + self.assertNotEqual(result.returncode, 0) + self.assertIn("runtime data is materialized", result.stderr) + + def test_target_inside_source_checkout_is_blocked(self) -> None: + result = self.run_preflight( + {"APEROD_MIGRATION_TARGET_DATA_DIR": str(self.stage / "future-data")} + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must be outside", result.stderr) + + def test_existing_target_is_never_overwritten(self) -> None: + self.target.mkdir() + result = self.run_preflight() + self.assertNotEqual(result.returncode, 0) + self.assertIn("already exists", result.stderr) + + def test_receipt_must_bind_reviewed_source_commit(self) -> None: + self.write_receipt(source_commit="0" * 40) + result = self.run_preflight() + self.assertNotEqual(result.returncode, 0) + self.assertIn("source_commit", result.stderr) + + def test_private_key_permissions_are_preserved_fail_closed(self) -> None: + key = self.data / "validator.key" + key.write_bytes(b"test-only-key") + key.chmod(0o644) + result = self.run_preflight() + self.assertNotEqual(result.returncode, 0) + self.assertIn("broader than 0600", result.stderr) + + def run_preflight_with(self, key: str, value: str) -> subprocess.CompletedProcess[str]: + return self.run_preflight({key: value}) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/test_sync_public_repo.py b/deploy/test_sync_public_repo.py index ea58cc7..4aa505a 100644 --- a/deploy/test_sync_public_repo.py +++ b/deploy/test_sync_public_repo.py @@ -1,5 +1,6 @@ import unittest -from unittest.mock import Mock +import urllib.request +from unittest.mock import Mock, patch import sync_public_repo @@ -15,6 +16,25 @@ def test_cli_cannot_accept_a_token_argument(self): sync_public_repo.main() self.assertIn("Legacy publication disabled", str(result.exception)) + def test_authenticated_transport_rejects_non_api_hosts_before_request(self): + with patch.object(sync_public_repo.urllib.request, "build_opener") as opener: + with self.assertRaisesRegex(RuntimeError, "outside GitHub API"): + sync_public_repo.make_request("test-only-token")( + "POST", "https://example.invalid/repos/owner/repo/git/blobs", + {"content": "test"}, + ) + opener.assert_not_called() + + def test_redirect_cannot_forward_token_outside_github_api(self): + req = urllib.request.Request( + "https://api.github.com/repos/aperod-network/aperod-node", + headers={"Authorization": "Bearer test-only-token"}, + ) + with self.assertRaisesRegex(RuntimeError, "redirect refused"): + sync_public_repo.SafeRedirect().redirect_request( + req, None, 302, "Found", {}, "https://example.invalid/collect" + ) + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/lpod/accounting.go b/lpod/accounting.go index 18d6eeb..6f65ee7 100644 --- a/lpod/accounting.go +++ b/lpod/accounting.go @@ -169,10 +169,12 @@ func Settle(before State, carries map[string]Carry, height uint64, vaults []Vaul out[id] = c } payments := make([]Payment, 0, len(vaults)) + var previousID string for i, v := range vaults { - if v.ID == "" || (i > 0 && vaults[i-1].ID >= v.ID) { + if v.ID == "" || (i > 0 && previousID >= v.ID) { return fail(fmt.Errorf("lpod: vault IDs must be sorted and unique")) } + previousID = v.ID t, err := TierFor(v.TotalStake) if err != nil { return fail(err) diff --git a/p2p/handshake_budget_test.go b/p2p/handshake_budget_test.go index 0e1bee7..3679938 100644 --- a/p2p/handshake_budget_test.go +++ b/p2p/handshake_budget_test.go @@ -44,7 +44,9 @@ func TestInboundHandshakeBudgetConcurrent(t *testing.T) { if h.PendingHandshakes() != 2 { t.Fatalf("one source used %d reservations", h.PendingHandshakes()) } - if !h.reserveInboundHandshake("other") || !h.reserveInboundHandshake("other") { + firstOtherReserved := h.reserveInboundHandshake("other") + secondOtherReserved := h.reserveInboundHandshake("other") + if !firstOtherReserved || !secondOtherReserved { t.Fatal("reserved capacity unavailable to independent source") } if h.reserveInboundHandshake("third") { diff --git a/p2p/host.go b/p2p/host.go index 6584bfd..4079ef3 100644 --- a/p2p/host.go +++ b/p2p/host.go @@ -215,6 +215,7 @@ type BlockValidationHandler interface { ValidateBlock(*core.Block) error } +// SyncHeaderValidationHandler validates a peer-provided header before it is accepted. type SyncHeaderValidationHandler interface { ValidateSyncHeader(core.BlockHeader) error } @@ -2677,13 +2678,19 @@ func (h *Host) handleConn(conn net.Conn, outbound bool, dialID, pendingID uint64 } } defer releaseHS() // safety net: covers ban-check return and any other early exit - handshakeTimer := time.AfterFunc(h.cfg.HandshakeTimeout, func() { conn.Close() }) + handshakeTimer := time.AfterFunc(h.cfg.HandshakeTimeout, func() { + if err := conn.Close(); err != nil { + h.log.Debug("close timed-out handshake connection", "err", err) + } + }) defer handshakeTimer.Stop() // Reject banned peers immediately if h.mgr.IsBanned(addr) { h.log.Debug("handleConn: banned peer rejected", "addr", addr) - conn.Close() + if err := conn.Close(); err != nil { + h.log.Debug("close banned peer connection", "err", err) + } return } diff --git a/p2p/security_test.go b/p2p/security_test.go index 13538b6..f5a5b8d 100644 --- a/p2p/security_test.go +++ b/p2p/security_test.go @@ -25,6 +25,20 @@ import ( "github.com/aperod/aperod/p2p" ) +func closeP2PTestResource(t *testing.T, resource interface{ Close() error }) { + t.Helper() + if err := resource.Close(); err != nil { + t.Errorf("close P2P test resource: %v", err) + } +} + +func setP2PTestDeadline(t *testing.T, conn interface{ SetDeadline(time.Time) error }, deadline time.Time) { + t.Helper() + if err := conn.SetDeadline(deadline); err != nil { + t.Errorf("set P2P test deadline: %v", err) + } +} + // ─── 3.5.1 Eclipse attack ──────────────────────────────────────────────────── // TestEclipseAttack_MaxPeers verifies that once MaxPeers slots are filled, @@ -50,23 +64,23 @@ func TestEclipseAttack_MaxPeers(t *testing.T) { if err != nil { t.Fatalf("dial %d: %v", i, err) } - c.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, c, time.Now().Add(2*time.Second)) // Asymmetric handshake: dialer sends Ping, host replies with Pong. if err := p2p.WriteMsg(c, p2p.MsgPing, p2p.PingMsg{ NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), }); err != nil { - c.Close() + closeP2PTestResource(t, c) continue } if _, _, err := p2p.ReadMsg(c); err != nil { - c.Close() + closeP2PTestResource(t, c) continue } conns = append(conns, c) } defer func() { for _, c := range conns { - c.Close() + closeP2PTestResource(t, c) } }() @@ -79,8 +93,8 @@ func TestEclipseAttack_MaxPeers(t *testing.T) { t.Log("3.5.1 ✓ 3rd connection refused by OS (MaxPeers enforced)") return } - defer attacker.Close() - attacker.SetDeadline(time.Now().Add(500 * time.Millisecond)) + defer closeP2PTestResource(t, attacker) + setP2PTestDeadline(t, attacker, time.Now().Add(500*time.Millisecond)) // Try to handshake — host may drop the conn immediately if MaxPeers exceeded. if err := p2p.WriteMsg(attacker, p2p.MsgPing, p2p.PingMsg{ @@ -202,22 +216,22 @@ func TestRateLimiting_BannedPeerDropped(t *testing.T) { if err != nil { t.Fatalf("dial: %v", err) } - conn.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, conn, time.Now().Add(2*time.Second)) if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ NodeID: "bad-peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), }); err != nil { - conn.Close() + closeP2PTestResource(t, conn) t.Fatalf("write ping: %v", err) } if _, _, err := p2p.ReadMsg(conn); err != nil { - conn.Close() + closeP2PTestResource(t, conn) t.Fatalf("read pong: %v", err) } time.Sleep(50 * time.Millisecond) // Ban the peer address. host.BanPeer(conn.LocalAddr().String(), "bad behavior", time.Hour) - conn.Close() + closeP2PTestResource(t, conn) // A subsequent connection from the same address should be handled; the ban // is checked at dial/accept time. This test verifies BanPeer does not panic @@ -227,9 +241,9 @@ func TestRateLimiting_BannedPeerDropped(t *testing.T) { t.Log("3.5.4 ✓ reconnect from banned address refused at OS level") return } - defer conn2.Close() + defer closeP2PTestResource(t, conn2) // Ban check runs before the Ping wait; the host closes the conn immediately. - conn2.SetDeadline(time.Now().Add(500 * time.Millisecond)) + setP2PTestDeadline(t, conn2, time.Now().Add(500*time.Millisecond)) _, _, _ = p2p.ReadMsg(conn2) // expect EOF / connection closed t.Log("3.5.4 ✓ ban registered; host operational after ban") @@ -244,7 +258,7 @@ func TestMsgValidation_OversizedBlock(t *testing.T) { host, _, addr := startHost(t) connectAndHandshake(t, addr, func(conn net.Conn) { - conn.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, conn, time.Now().Add(2*time.Second)) // Build a block payload with an unrealistically large Txs slice. type fakeBlock struct { @@ -290,16 +304,16 @@ func TestPeerCountLimit_MaxPeers1(t *testing.T) { if err != nil { return nil, false } - c.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, c, time.Now().Add(2*time.Second)) // Asymmetric handshake: dialer sends Ping, host replies with Pong. if err := p2p.WriteMsg(c, p2p.MsgPing, p2p.PingMsg{ NodeID: id, Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), }); err != nil { - c.Close() + closeP2PTestResource(t, c) return nil, false } if _, _, err := p2p.ReadMsg(c); err != nil { - c.Close() + closeP2PTestResource(t, c) return nil, false } return c, true @@ -307,13 +321,13 @@ func TestPeerCountLimit_MaxPeers1(t *testing.T) { c1, ok1 := handshake("peer1") if ok1 { - defer c1.Close() + defer closeP2PTestResource(t, c1) } time.Sleep(60 * time.Millisecond) c2, ok2 := handshake("peer2") if ok2 { - defer c2.Close() + defer closeP2PTestResource(t, c2) } time.Sleep(80 * time.Millisecond) @@ -333,7 +347,7 @@ func TestMaliciousPacket_NodeStaysAlive(t *testing.T) { host, _, addr := startHost(t) connectAndHandshake(t, addr, func(conn net.Conn) { - conn.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, conn, time.Now().Add(2*time.Second)) // Craft a raw TCP frame that passes the length prefix but carries // pure binary garbage instead of valid JSON. This triggers a JSON @@ -386,11 +400,11 @@ func TestPeerWhitelist_EmptyAllowsAll(t *testing.T) { if err != nil { t.Fatalf("dial failed (should be open): %v", err) } - defer conn.Close() + defer closeP2PTestResource(t, conn) // Perform the Aperod inbound handshake so the connection is accepted into // the peer table. - conn.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, conn, time.Now().Add(2*time.Second)) if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), }); err != nil { @@ -427,9 +441,9 @@ func TestPeerWhitelist_ExactIPAccepted(t *testing.T) { if err != nil { t.Fatalf("dial failed: %v", err) } - defer conn.Close() + defer closeP2PTestResource(t, conn) - conn.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, conn, time.Now().Add(2*time.Second)) if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), }); err != nil { @@ -467,9 +481,9 @@ func TestPeerWhitelist_CIDRAccepted(t *testing.T) { if err != nil { t.Fatalf("dial failed: %v", err) } - defer conn.Close() + defer closeP2PTestResource(t, conn) - conn.SetDeadline(time.Now().Add(2 * time.Second)) + setP2PTestDeadline(t, conn, time.Now().Add(2*time.Second)) if err := p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ NodeID: "peer", Height: 0, UserAgent: "test", Timestamp: time.Now().Unix(), }); err != nil { @@ -511,10 +525,10 @@ func TestPeerWhitelist_NonMatchingIPRejected(t *testing.T) { t.Logf("whitelist reject ✓ connection refused by OS") return } - defer conn.Close() + defer closeP2PTestResource(t, conn) // The host should close the connection immediately without replying. - conn.SetDeadline(time.Now().Add(500 * time.Millisecond)) + setP2PTestDeadline(t, conn, time.Now().Add(500*time.Millisecond)) _ = p2p.WriteMsg(conn, p2p.MsgPing, p2p.PingMsg{ NodeID: "intruder", Height: 0, UserAgent: "evil", Timestamp: time.Now().Unix(), }) @@ -579,12 +593,18 @@ func TestDuplicateP2PIdentity_Rejected(t *testing.T) { } return } - defer conn.Close() + defer func() { + // This case requires the host to reject and close the peer; a reset + // during TLS close-notify is therefore an expected cleanup result. + if err := conn.Close(); err != nil { + t.Logf("duplicate-identity connection already rejected by host: %v", err) + } + }() // TLS handshake completed. The host's SelfFingerprint guard fires inside // handleConn after Handshake(); the host must close the connection without // ever sending a MsgPong back to the dialer. - conn.SetDeadline(time.Now().Add(2 * time.Second)) //nolint:errcheck + setP2PTestDeadline(t, conn, time.Now().Add(2*time.Second)) // Attempt the Aperod application handshake — expect the host to close // the conn before or immediately after it receives our Ping. @@ -688,7 +708,7 @@ func TestDuplicateP2PIdentity_RejectedOutbound(t *testing.T) { if err != nil { t.Fatalf("tls.Listen: %v", err) } - t.Cleanup(func() { ln.Close() }) + t.Cleanup(func() { closeP2PTestResource(t, ln) }) // remoteClosed is signaled when the host closes the connection (the // listener side observes EOF / reset on its first read). @@ -699,12 +719,12 @@ func TestDuplicateP2PIdentity_RejectedOutbound(t *testing.T) { remoteClosed <- err return } - defer c.Close() + defer closeP2PTestResource(t, c) // The server-side TLS handshake completes lazily on first Read. // If the host's identity-conflict guard fires, it closes the conn // right after the handshake, so this Read must fail — and it must // NOT return a valid Aperod Ping frame first. - c.SetDeadline(time.Now().Add(3 * time.Second)) //nolint:errcheck + setP2PTestDeadline(t, c, time.Now().Add(3*time.Second)) buf := make([]byte, 1) _, readErr := c.Read(buf) remoteClosed <- readErr diff --git a/sale/accounting.go b/sale/accounting.go index d7c4411..dc891de 100644 --- a/sale/accounting.go +++ b/sale/accounting.go @@ -13,6 +13,7 @@ import ( "github.com/aperod/aperod/crypto" ) +// CapNAPRO is the maximum protocol supply, expressed in nAPRO. const CapNAPRO uint64 = 10_000_000_000 * 100_000_000 var zeroHash crypto.Hash32 @@ -72,6 +73,7 @@ func nonzeroPub(key crypto.ValidatorPubKey) bool { return false } +// Verify checks checkpoint bounds, ancestry, and validator authorization. func (c Checkpoint) Verify(genesis, parent crypto.Hash32, height uint64, trusted []crypto.ValidatorPubKey) error { if height == 0 || c.Height != height || c.Parent != parent || parent == zeroHash || c.Genesis != genesis || genesis == zeroHash || c.SnapshotRoot == zeroHash || @@ -126,6 +128,7 @@ type Purchase struct { Signature []byte } +// Message returns the domain-separated digest signed for this purchase. func (p Purchase) Message() crypto.Hash32 { return crypto.HashBytes( []byte("aperod/sale/purchase/authorization/v1"), @@ -147,6 +150,7 @@ type Ledger struct { paid map[crypto.Hash32]struct{} } +// Begin creates a sale ledger from a verified checkpoint. func Begin(c Checkpoint, genesis, parent crypto.Hash32, height uint64, trusted []crypto.ValidatorPubKey) (*Ledger, error) { if err := c.Verify(genesis, parent, height, trusted); err != nil { return nil, err @@ -211,7 +215,14 @@ 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 } +// Remaining returns the sale allocation not yet purchased. +func (s *Ledger) Remaining() uint64 { return s.remaining } + +// Sold returns the total amount purchased from the sale allocation. +func (s *Ledger) Sold() uint64 { return s.sold } + +// Height returns the last block height applied to the ledger. +func (s *Ledger) Height() uint64 { return s.height } + +// Hash returns the hash of the last block applied to the ledger. func (s *Ledger) Hash() crypto.Hash32 { return s.hash } diff --git a/sale/allocation_epoch.go b/sale/allocation_epoch.go index b8208d8..d360ffe 100644 --- a/sale/allocation_epoch.go +++ b/sale/allocation_epoch.go @@ -20,6 +20,7 @@ type AllocationEpochBasis struct { LegacyHoldingsUnverified bool `json:"legacy_holdings_unverified"` } +// Validate checks the allocation epoch totals and supply bounds. func (e AllocationEpochBasis) Validate() error { if e.FundingHash == zeroHash || !e.LegacyHoldingsUnverified || e.Available == 0 || e.Available > CapNAPRO || e.Burned > CapNAPRO || @@ -29,6 +30,7 @@ func (e AllocationEpochBasis) Validate() error { return nil } +// Digest returns the canonical commitment to this allocation epoch. func (e AllocationEpochBasis) Digest() crypto.Hash32 { return crypto.HashBytes( []byte("aperod/sale/operator-allocation-epoch/legacy-holdings-unverified/v1"), diff --git a/sale/net_allocation.go b/sale/net_allocation.go index 3fcdf38..8201620 100644 --- a/sale/net_allocation.go +++ b/sale/net_allocation.go @@ -16,6 +16,7 @@ type NetAllocationBasis struct { WalletAccountingRoot crypto.Hash32 `json:"wallet_accounting_root"` } +// Consumed returns wallet net plus burns after validating supply bounds. func (n NetAllocationBasis) Consumed() (uint64, error) { if n.WalletAccountingRoot == zeroHash || n.Burned > CapNAPRO || n.WalletNet > CapNAPRO-n.Burned { return 0, fmt.Errorf("sale: attested post-burn wallet accounting required") @@ -23,6 +24,7 @@ func (n NetAllocationBasis) Consumed() (uint64, error) { return n.Burned + n.WalletNet, nil } +// Digest returns the canonical commitment to this net allocation basis. func (n NetAllocationBasis) Digest() crypto.Hash32 { return crypto.HashBytes([]byte("aperod/sale/post-burn-wallet-basis/v2"), u64(n.Burned), u64(n.WalletNet), n.WalletAccountingRoot[:]) } diff --git a/sale/reconciliation.go b/sale/reconciliation.go index 520f193..b31cf27 100644 --- a/sale/reconciliation.go +++ b/sale/reconciliation.go @@ -6,8 +6,7 @@ import ( "github.com/aperod/aperod/crypto" ) -// The initial protocol sale allocation must preserve the separately earmarked -// Guardian allocation within the primary 10B cap. +// MinimumGuardianReserveNAPRO is the minimum Guardian reserve protected within the primary 10B cap. const MinimumGuardianReserveNAPRO uint64 = 1_000_000_000 * 100_000_000 // Reconciliation is a forward, authority-attested accounting declaration, not @@ -33,6 +32,7 @@ type Reconciliation struct { OtherReserved uint64 } +// Digest returns the canonical commitment to this reconciliation. func (r Reconciliation) Digest() crypto.Hash32 { legacy := crypto.HashBytes( []byte("aperod/sale/disjoint-forward-reconciliation/v1"), @@ -55,6 +55,7 @@ func (r Reconciliation) Digest() crypto.Hash32 { return crypto.HashBytes([]byte("aperod/sale/disjoint-forward-reconciliation/v2"), legacy[:], net[:]) } +// Unassigned returns the remaining Guardian and non-Guardian allocation amounts. func (r Reconciliation) Unassigned() (uint64, uint64, error) { if r.AllocationEpoch != nil { if r.NetAllocation != nil { diff --git a/sale/state.go b/sale/state.go index c409268..9218033 100644 --- a/sale/state.go +++ b/sale/state.go @@ -17,6 +17,7 @@ type State struct { Sold uint64 `json:"sold_napro,string"` } +// InitialState constructs the ledger state represented by checkpoint c. func InitialState(c Checkpoint) State { return State{Checkpoint: c.Message(), Height: c.Height - 1, Hash: c.Parent, Remaining: c.SaleRemaining} } diff --git a/store/finality_migration.go b/store/finality_migration.go index e4fa5a7..b97412c 100644 --- a/store/finality_migration.go +++ b/store/finality_migration.go @@ -8,10 +8,12 @@ import ( "github.com/aperod/aperod/crypto" ) +// FinalityCommitteeMigrationVersion is the canonical encoding version for committee migrations. const FinalityCommitteeMigrationVersion uint8 = 1 const finalityCommitteeMigrationDomain = "aperod/finality-committee-migration/v1\x00" +// FinalityMigrationApproval records one validator's signature for a migration. type FinalityMigrationApproval struct { Validator crypto.ValidatorPubKey `json:"validator"` Signature []byte `json:"signature"` @@ -21,15 +23,15 @@ type FinalityMigrationApproval struct { // 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"` + 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 @@ -64,6 +66,8 @@ func (m FinalityCommitteeMigration) SigningMessage() (crypto.Hash32, error) { if err := binary.Write(&payload, binary.BigEndian, m.BFTThresholdPPM); err != nil { return crypto.Hash32{}, err } + // The committee is capped at 256 entries during validation above. + // #nosec G115 -- the validated maximum is well below uint32's range. if err := binary.Write(&payload, binary.BigEndian, uint32(len(m.HistoricalCommittee))); err != nil { return crypto.Hash32{}, err } diff --git a/store/genesis_vesting.go b/store/genesis_vesting.go index 6d764ee..86cfcda 100644 --- a/store/genesis_vesting.go +++ b/store/genesis_vesting.go @@ -11,13 +11,20 @@ import ( "github.com/aperod/aperod/lpod" ) +// GenesisVestingRecipient is the fixed address that receives genesis vesting. // This is a consensus schedule, not administrator-editable display metadata. // The reserve is reclassified out of the non-circulating genesis remainder // bound by the ALREADY attested nominal snapshot. The Guardian's eligible // circulating budget is distinct and MUST NOT be debited or reused. const GenesisVestingRecipient crypto.Address = "aproegAWbHEE8wunGatJwGLM68f3hskmbQmT8HJik9s827DymS2jScRb2hxqAgD34qfXwfQKKDCoj2okTWeVwsi2CXtA9Ybr76" + +// GenesisVestingReserve is the nominal reserve reclassified by the schedule. const GenesisVestingReserve uint64 = 849_010_000 * lpod.Unit + +// GenesisVestingBasis identifies the versioned genesis allocation policy. const GenesisVestingBasis = "attested-genesis-team-allocation-reclassification/v1" + +// GenesisNominalTotal is the maximum nominal genesis allocation in nAPRO. const GenesisNominalTotal uint64 = 10_000_000_000 * lpod.Unit // GenesisVestingState is part of the signed accounting checkpoint. Canonical @@ -41,17 +48,19 @@ type GenesisVestingState struct { LiquidityPaid uint64 `json:"liquidity_paid_napro,string,omitempty"` } +// GenesisVestingTranche returns the height and amount for a scheduled tranche. func GenesisVestingTranche(index int) (int64, uint64, error) { if index < 0 || index >= 10 { return 0, 0, fmt.Errorf("genesis vesting: invalid tranche") } - amount := uint64(84_900_000 * lpod.Unit) + amount := 84_900_000 * lpod.Unit if index == 9 { amount = 84_910_000 * lpod.Unit } return time.Date(2027+index, time.February, 10, 0, 0, 0, 0, time.UTC).UnixNano(), amount, nil } +// Validate checks the vesting state against its genesis hash and activation height. func (v *GenesisVestingState) Validate(genesis crypto.Hash32, height uint64) error { if v == nil { return nil diff --git a/store/genesis_vesting_funding.go b/store/genesis_vesting_funding.go index 4054286..eb1c8aa 100644 --- a/store/genesis_vesting_funding.go +++ b/store/genesis_vesting_funding.go @@ -6,16 +6,23 @@ package store import ( "encoding/json" "fmt" + "io" "math/big" "os" "github.com/aperod/aperod/crypto" ) +// GenesisVestingTrust identifies the operator attestation for the unissued vesting reserve. const GenesisVestingTrust = "operator-attested-exclusive-unissued-development-genesis-reserve/v1" + +// GenesisLiquidityTrust identifies the operator attestation for initial liquidity. const GenesisLiquidityTrust = "operator-attested-exclusive-unissued-initial-liquidity-genesis-allocation/v1" + +// GenesisLiquidityAmount is the nominal initial-liquidity allocation in nAPRO. const GenesisLiquidityAmount uint64 = 300_000_000 * 100_000_000 +// GenesisVestingFunding records the operator attestation for unissued reserve value. // Circulation arithmetic only supplies an upper bound, NOT source eligibility. // This distinct, purpose-bound governance attestation explicitly declares that // the Development Fund reserve is unissued and excludes Guardian, validator, @@ -35,6 +42,7 @@ type GenesisVestingFunding struct { Attestations []LPoDAttestation `json:"attestations"` } +// Message returns the digest signed by the configured governance witnesses. func (f *GenesisVestingFunding) Message() crypto.Hash32 { if f == nil { return crypto.Hash32{} @@ -107,11 +115,21 @@ func (f *GenesisVestingFunding) verifyQuorum(a *LPoDAllocation, height uint64, s return nil } +// LoadGenesisVestingFunding reads and validates the operator-configured witness file. func LoadGenesisVestingFunding(path string, height uint64) (*GenesisVestingFunding, error) { - raw, err := os.ReadFile(path) + // #nosec G304 -- this local governance witness path is selected in operator-managed node configuration. + file, err := os.Open(path) if err != nil { return nil, err } + raw, readErr := io.ReadAll(io.LimitReader(file, 256*1024+1)) + closeErr := file.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } if len(raw) > 256*1024 { return nil, fmt.Errorf("genesis vesting: funding witness too large") } @@ -125,6 +143,7 @@ func LoadGenesisVestingFunding(path string, height uint64) (*GenesisVestingFundi return &f, nil } +// CheckGenesisVestingConfig rejects missed activations and disabling committed funds. // Reject missed activations and disabling an already committed fund before // starting consensus; do not silently run until the first production failure. func (d *DB) CheckGenesisVestingConfig(height uint64, proof *GenesisVestingFunding) error { diff --git a/store/leveldb.go b/store/leveldb.go index 7e49a60..321aecd 100644 --- a/store/leveldb.go +++ b/store/leveldb.go @@ -675,16 +675,23 @@ func (d *DB) BindGuardianFundConfig(height uint64, genesis crypto.Hash32) error } type AdminMintRecord struct { - // State is intent, prepared, or completed. Empty is accepted as + // State is intent, prepared, completed, or failed. Empty is accepted as // completed for records written by versions predating this state // machine. State string `json:"state,omitempty"` + Failure string `json:"failure,omitempty"` Address string `json:"address"` AmountNAPR uint64 `json:"amount_napr"` TxHash crypto.Hash32 `json:"tx_hash"` Height uint64 `json:"height"` } +// AdminMintRecordEntry describes the canonical record for an administrator mint. +type AdminMintRecordEntry struct { + IdempotencyKey string + Record AdminMintRecord +} + // StoreAdminMintRecord synchronously persists an admin-mint state transition. // Callers must write intent before scheduling and prepared before attempting // the canonical chain commit. The synchronous write is the durable @@ -715,6 +722,32 @@ func (d *DB) LoadAdminMintRecord(idempotencyKey string) (record AdminMintRecord, return record, true, nil } +// ListAdminMintRecords returns a consistent snapshot of all durable mint +// records. Startup reconciliation uses it to retire queued intents that are +// invalid under the active node configuration. +func (d *DB) ListAdminMintRecords() ([]AdminMintRecordEntry, error) { + prefix := append(append([]byte(nil), prefixMeta...), []byte("admin_mint/")...) + iter := d.db.NewIterator(util.BytesPrefix(prefix), nil) + defer iter.Release() + + records := make([]AdminMintRecordEntry, 0) + for iter.Next() { + key := string(append([]byte(nil), iter.Key()[len(prefix):]...)) + if key == "" { + return nil, fmt.Errorf("list admin mint records: empty idempotency key") + } + var record AdminMintRecord + if err := json.Unmarshal(append([]byte(nil), iter.Value()...), &record); err != nil { + return nil, fmt.Errorf("list admin mint records: corrupt record") + } + records = append(records, AdminMintRecordEntry{IdempotencyKey: key, Record: record}) + } + if err := iter.Error(); err != nil { + return nil, fmt.Errorf("list admin mint records: %w", err) + } + return records, nil +} + // ─── Staking pool ───────────────────────────────────────────────────────────── // StoreStakingPoolRemaining persists the remaining staking reward pool in nAPRO. @@ -862,6 +895,7 @@ func (d *DB) CommitRawBlockWithAVM( return d.commitRawBlock(hash, height, data, writes, writeSetCommitment, nil, lpodSettlement) } +// CommitRawBlockWithSale atomically commits a canonical block and its sale settlement. func (d *DB) CommitRawBlockWithSale(hash crypto.Hash32, height uint64, data []byte, writes []AVMWrite, writeSetCommitment crypto.Hash32, saleSettlement *SaleSettlement, lpodSettlement ...*LPoDSettlement) error { return d.commitRawBlock(hash, height, data, writes, writeSetCommitment, saleSettlement, lpodSettlement) @@ -1071,22 +1105,22 @@ func (d *DB) GetRawBlockByHeight(height uint64) ([]byte, error) { return d.GetRawBlock(hash) } -// StoreTxIdxCompleteHeight records that all blocks from height 1 up to and -// including h have had their per-transaction location written to the t/ index. -// The value is updated incrementally by the startup scan (scan.go) and the -// background backfill goroutine (main.go) so that a subsequent restart -// resumes from where the previous run stopped rather than reprocessing the -// full history. +// StoreTxIdxCompleteHeight records the highest contiguous height covered by +// the tx-index startup scan or backfill. Available transaction bodies through +// this height have t/ entries; the background backfill may advance past an +// unavailable body after warning so it can index later blocks. The marker lets +// a later restart resume without reprocessing the full history. func (d *DB) StoreTxIdxCompleteHeight(h uint64) error { buf := make([]byte, 8) binary.LittleEndian.PutUint64(buf, h) return d.PutMeta("txidx_complete_height", buf) } -// LoadTxIdxCompleteHeight returns the highest block height through which the -// t/ tx-hash index is guaranteed to be fully populated. Returns (0, false, -// nil) when no backfill has run yet (first boot or a pre-feature DB); the -// caller should treat 0 as "nothing indexed yet". +// LoadTxIdxCompleteHeight returns the highest contiguous block height covered +// by the tx-index scan/backfill. A body skipped by the background backfill is +// considered covered, but cannot have transaction index entries. +// Returns (0, false, nil) when no backfill has run yet (first boot or a +// pre-feature DB); the caller should treat 0 as "nothing indexed yet". func (d *DB) LoadTxIdxCompleteHeight() (h uint64, found bool, err error) { v, err := d.GetMeta("txidx_complete_height") if err != nil { diff --git a/store/lpod.go b/store/lpod.go index 71041e8..7cda0e9 100644 --- a/store/lpod.go +++ b/store/lpod.go @@ -26,6 +26,8 @@ type LPoDCheckpoint struct { Carries map[string]lpod.Carry `json:"carries"` Allocation *LPoDAllocation `json:"allocation,omitempty"` Positions map[string]LPoDPosition `json:"positions,omitempty"` + DailyPayoutVersion uint8 `json:"daily_payout_version,omitempty"` + PendingLiability uint64 `json:"pending_liability_napro,string,omitempty"` PrincipalDeposited uint64 `json:"principal_deposited_napro,string"` PrincipalLocked uint64 `json:"principal_locked_napro,string"` PrincipalReturned uint64 `json:"principal_returned_napro,string"` @@ -40,6 +42,7 @@ type LPoDSettlement struct { Vaults []lpod.Vault Migration *LPoDMigration PositionProtocol bool + DailyPayout bool Timestamp int64 Proposer string Leader crypto.Address @@ -267,7 +270,7 @@ func (c LPoDCheckpoint) Digest() crypto.Hash32 { a := *c.Allocation a.FundingBlock = crypto.Hash32{} c.Allocation = &a - if a.Version == 1 { + if a.Version == 1 && c.DailyPayoutVersion == 0 { // Preserve the byte-for-byte legacy v1 checkpoint encoding. New v2 // allocation fields are not part of the historical v1 commitment. legacy := struct { diff --git a/store/lpod_earnings_index_test.go b/store/lpod_earnings_index_test.go index 708ec21..9b06ea4 100644 --- a/store/lpod_earnings_index_test.go +++ b/store/lpod_earnings_index_test.go @@ -316,11 +316,12 @@ func TestLPoDEarningsMixedPayoutBackfillRollbackAndReplay(t *testing.T) { mixedAudit.Outputs[0].Beneficiary != string(address) { t.Fatal("mixed payout audit output ref does not match the actual transaction") } - if len(mixedAudit.Positions) != 1 || mixedAudit.Positions[0].PrincipalReturned == 0 || + if len(mixedAudit.Positions) != 1 || mixedAudit.Positions[0].Beneficiary != string(address) || + mixedAudit.Positions[0].PrincipalReturned == 0 || mixedAudit.Positions[0].Paid == 0 || mixedAudit.Positions[0].Accrued == 0 || mixedAudit.Positions[0].APRCarryAfter == 0 || mixedAudit.Positions[0].DueAfter > mixedAudit.Positions[0].DueBefore+mixedAudit.Positions[0].Accrued { - t.Fatal("mixed position reward/principal transition was not captured") + t.Fatal("mixed position reward/principal transition or immutable beneficiary was not captured") } duplicateBytes, err := db.get(lpodBlockAuditKey(mixedBlock.Hash())) if err != nil { @@ -667,7 +668,7 @@ func commitLPoDStoreTestBlock(t *testing.T, db *DB, migration *LPoDMigration, pr func TestLPoDWalletIndexBackfillsMissingNativeOutputs(t *testing.T) { db, _, migration, priv, address := trustedLPoDV3FundingFixture(t) - defer db.Close() + defer closeStoreTestResource(t, db) fundingBlock, settlement, raw := lpodActivationBlock(t, db, migration, priv, address) if err := db.CommitRawBlockWithAVM( diff --git a/store/lpod_positions.go b/store/lpod_positions.go index ae47d4d..bc2dce3 100644 --- a/store/lpod_positions.go +++ b/store/lpod_positions.go @@ -10,6 +10,7 @@ import ( "math/big" "reflect" "sort" + "time" "github.com/syndtr/goleveldb/leveldb" @@ -27,13 +28,16 @@ type LPoDValidatorStake struct { Active bool } type LPoDPosition struct { - Deposit core.LPoDPositionAction `json:"deposit"` - Nonce uint64 `json:"nonce"` - UnlockHeight uint64 `json:"unlock_height"` // full exit's canonical height; not a guardian timelock - Returned bool `json:"returned"` - Due uint64 `json:"due_napro,string"` - APRCarry uint64 `json:"apr_carry,string"` - Withdrawn uint64 `json:"withdrawn_napro,string"` + Deposit core.LPoDPositionAction `json:"deposit"` + Nonce uint64 `json:"nonce"` + UnlockHeight uint64 `json:"unlock_height"` // full exit's canonical height; not a guardian timelock + Returned bool `json:"returned"` + Due uint64 `json:"due_napro,string"` + APRCarry uint64 `json:"apr_carry,string"` + Withdrawn uint64 `json:"withdrawn_napro,string"` + TopUps []core.LPoDPositionAction `json:"top_ups,omitempty"` + PendingDue uint64 `json:"pending_due_napro,string,omitempty"` + NextPayoutNS int64 `json:"next_payout_ns,omitempty"` // EffectiveVault is set only after a consensus-driven reassignment. The // original signed Deposit.Vault remains immutable so the owner's existing // withdrawal authorization continues to verify. @@ -45,7 +49,11 @@ type LPoDPosition struct { // LPoDWithdrawalAmount validates the signed request against remaining principal. // Nonces advance on every partial or full exit; the original deposit is immutable. func LPoDWithdrawalAmount(p LPoDPosition, a core.LPoDPositionAction) (uint64, error) { - if p.Returned || p.UnlockHeight != 0 || p.Withdrawn >= p.Deposit.Amount || p.Nonce == ^uint64(0) { + principal, err := LPoDPositionPrincipal(p) + if err != nil { + return 0, err + } + if p.Returned || p.UnlockHeight != 0 || p.Withdrawn >= principal || p.Nonce == ^uint64(0) { return 0, fmt.Errorf("lpod: unknown, closed or exhausted position") } expected := p.Deposit @@ -57,14 +65,96 @@ func LPoDWithdrawalAmount(p LPoDPosition, a core.LPoDPositionAction) (uint64, er } amount := a.WithdrawAmount if amount == 0 { - amount = p.Deposit.Amount - p.Withdrawn + amount = principal - p.Withdrawn } - if amount > p.Deposit.Amount-p.Withdrawn { + if amount > principal-p.Withdrawn { return 0, fmt.Errorf("lpod: withdrawal exceeds remaining principal") } return amount, nil } +// LPoDPositionPrincipal returns the original deposit plus every canonical, +// signed top-up. The original deposit action is never rewritten. +func LPoDPositionPrincipal(p LPoDPosition) (uint64, error) { + total := p.Deposit.Amount + for i, topUp := range p.TopUps { + if topUp.Action != core.LPoDTopUp || topUp.Genesis != p.Deposit.Genesis || + topUp.PositionID != p.Deposit.PositionID || topUp.Vault != p.Deposit.Vault || + topUp.Beneficiary != p.Deposit.Beneficiary || topUp.Owner != p.Deposit.Owner || + topUp.Nonce == 0 || (i > 0 && topUp.Nonce <= p.TopUps[i-1].Nonce) { + return 0, fmt.Errorf("lpod: corrupt top-up history") + } + var err error + total, err = lpodAdd(total, topUp.Amount) + if err != nil { + return 0, err + } + } + if p.Withdrawn > total { + return 0, fmt.Errorf("lpod: withdrawals exceed total principal") + } + return total, nil +} + +// LPoDTopUpAmount verifies that a signed top-up names this position, preserves +// its immutable owner/vault identity and consumes the next common operation nonce. +func LPoDTopUpAmount(p LPoDPosition, a core.LPoDPositionAction) (uint64, error) { + if p.Returned || p.UnlockHeight != 0 || p.Nonce == ^uint64(0) || + a.Action != core.LPoDTopUp || a.Nonce != p.Nonce+1 || + a.Genesis != p.Deposit.Genesis || a.PositionID != p.Deposit.PositionID || + a.Vault != p.Deposit.Vault || a.Beneficiary != p.Deposit.Beneficiary || + a.Owner != p.Deposit.Owner || a.Amount == 0 || a.WithdrawAmount != 0 || + a.SourceTx == (crypto.Hash32{}) || a.SourcePub == (crypto.Point32{}) { + return 0, fmt.Errorf("lpod: invalid top-up identity or nonce") + } + return a.Amount, nil +} + +const ( + lpodDayNS = int64(24 * time.Hour) + lpodMSKNS = int64(3 * time.Hour) + lpodPayHour = 20 +) + +var lpodMSK = time.FixedZone("MSK", 3*60*60) + +// LPoDNextDailyBoundaryAfter24h is the first 20:00 Moscow boundary at or after +// 24 full hours have elapsed from startNS. +func LPoDNextDailyBoundaryAfter24h(startNS int64) (int64, error) { + maxInt64 := int64(^uint64(0) >> 1) + if startNS <= 0 || startNS > maxInt64-lpodDayNS { + return 0, fmt.Errorf("lpod: invalid daily payout anchor") + } + eligible := time.Unix(0, startNS+lpodDayNS).In(lpodMSK) + y, m, d := eligible.Date() + boundary := time.Date(y, m, d, lpodPayHour, 0, 0, 0, lpodMSK) + if boundary.Before(eligible) { + boundary = boundary.AddDate(0, 0, 1) + } + ns := boundary.UnixNano() + if ns <= startNS { + return 0, fmt.Errorf("lpod: daily payout boundary overflow") + } + return ns, nil +} + +func lpodNextDailyBoundaryAfter(timestampNS int64) (int64, error) { + if timestampNS <= 0 || timestampNS == int64(^uint64(0)>>1) { + return 0, fmt.Errorf("lpod: invalid daily payout timestamp") + } + eligible := time.Unix(0, timestampNS+1).In(lpodMSK) + y, m, d := eligible.Date() + boundary := time.Date(y, m, d, lpodPayHour, 0, 0, 0, lpodMSK) + if boundary.Before(eligible) { + boundary = boundary.AddDate(0, 0, 1) + } + ns := boundary.UnixNano() + if ns <= timestampNS { + return 0, fmt.Errorf("lpod: daily payout boundary overflow") + } + return ns, nil +} + func lpodAdd(a, b uint64) (uint64, error) { if b > ^uint64(0)-a { return 0, fmt.Errorf("lpod: amount overflow") @@ -90,26 +180,38 @@ func lpodPositionVaultID(p LPoDPosition) string { } func (c *LPoDCheckpoint) validatePositions() error { - var locked, due uint64 - if len(c.Positions) > LPoDMaxPositions { + var locked, due, pending uint64 + if len(c.Positions) > LPoDMaxPositions || c.DailyPayoutVersion > 1 { return fmt.Errorf("lpod: position capacity exceeded") } for id, p := range c.Positions { + principal, err := LPoDPositionPrincipal(p) + if err != nil { + return err + } if id != lpodPositionID(p.Deposit) || p.Deposit.Action != core.LPoDDeposit || p.Deposit.Nonce != 0 || - p.Deposit.WithdrawAmount != 0 || p.Deposit.Amount == 0 || p.Withdrawn > p.Deposit.Amount || - p.Returned != (p.Withdrawn == p.Deposit.Amount) || - (!p.AutoReturned && (p.Nonce == 0) != (p.Withdrawn == 0)) || + p.Deposit.WithdrawAmount != 0 || p.Deposit.Amount == 0 || p.Withdrawn > principal || + p.Returned != (p.Withdrawn == principal) || + (!p.AutoReturned && (p.Nonce < uint64(len(p.TopUps)) || + (p.Withdrawn == 0 && p.Nonce != uint64(len(p.TopUps))) || + (p.Withdrawn > 0 && p.Nonce == uint64(len(p.TopUps))))) || (p.AutoReturned && !p.Returned) || p.APRCarry >= lpodAPRDenominator || (p.UnlockHeight == 0) != (!p.Returned) || - p.UnlockHeight > c.State.LastHeight { + p.UnlockHeight > c.State.LastHeight || + (c.DailyPayoutVersion == 0 && (len(p.TopUps) != 0 || p.PendingDue != 0 || p.NextPayoutNS != 0)) || + (c.DailyPayoutVersion > 0 && p.NextPayoutNS <= 0) { return fmt.Errorf("lpod: corrupt canonical position") } + for _, topUp := range p.TopUps { + if topUp.Nonce > p.Nonce { + return fmt.Errorf("lpod: top-up nonce exceeds position nonce") + } + } if p.EffectiveVault != nil && (*p.EffectiveVault == p.Deposit.Vault || p.RouteHeight == 0 || p.RouteHeight > c.State.LastHeight) { return fmt.Errorf("lpod: corrupt effective vault route") } - var err error if !p.Returned { - locked, err = lpodAdd(locked, p.Deposit.Amount-p.Withdrawn) + locked, err = lpodAdd(locked, principal-p.Withdrawn) if err != nil { return err } @@ -118,10 +220,14 @@ func (c *LPoDCheckpoint) validatePositions() error { if err != nil { return err } + pending, err = lpodAdd(pending, p.PendingDue) + if err != nil { + return err + } } total, err := lpodAdd(c.PrincipalLocked, c.PrincipalReturned) if err != nil || total != c.PrincipalDeposited || locked != c.PrincipalLocked || - (c.Allocation != nil && due != c.State.UnfundedLiability) { + (c.Allocation != nil && due != c.State.UnfundedLiability) || pending != c.PendingLiability { return fmt.Errorf("lpod: principal or position liability conservation failed") } return nil @@ -139,6 +245,9 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe if err := before.validatePositions(); err != nil { return nil, nil, nil, err } + if before.DailyPayoutVersion > 0 && !r.DailyPayout { + return nil, nil, nil, fmt.Errorf("lpod: daily payout protocol cannot be disabled after activation") + } parent, err := d.readLPoDCanonicalBlock(height - 1) if err != nil { return nil, nil, nil, err @@ -151,22 +260,35 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe elapsed = LPoDMaxElapsedNS } next := *before + if r.DailyPayout { + next.DailyPayoutVersion = 1 + } next.Positions = make(map[string]LPoDPosition, len(before.Positions)) vaultIDs := map[string]bool{r.Proposer: true} totals := map[string]uint64{} entitlementVault := map[string]string{} for id, p := range before.Positions { + principal, principalErr := LPoDPositionPrincipal(p) + if principalErr != nil { + return nil, nil, nil, principalErr + } // Fully closed positions no longer occupy capacity. Reopening their // deposit still requires its already-spent, cryptographically linked KI. - if p.Returned && p.Due == 0 { + if p.Returned && p.Due == 0 && p.PendingDue == 0 { continue } + if r.DailyPayout && p.NextPayoutNS == 0 { + p.NextPayoutNS, err = LPoDNextDailyBoundaryAfter24h(r.Timestamp) + if err != nil { + return nil, nil, nil, err + } + } next.Positions[id] = p v := lpodPositionVaultID(p) entitlementVault[id] = v vaultIDs[v] = true if p.UnlockHeight == 0 { - totals[v], err = lpodAdd(totals[v], p.Deposit.Amount-p.Withdrawn) + totals[v], err = lpodAdd(totals[v], principal-p.Withdrawn) if err != nil { return nil, nil, nil, err } @@ -176,45 +298,76 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe arrears := map[string]uint64{} for id, p := range next.Positions { v := lpodPositionVaultID(p) - arrears[v], err = lpodAdd(arrears[v], p.Due) - if err != nil { - return nil, nil, nil, err - } stake := r.Stake[v] if prior := r.PreviousStake[v]; !stake.Active && prior.Active { stake = prior } - if p.UnlockHeight != 0 || !stake.Active { - continue - } - total, err := lpodAdd(stake.Amount, totals[v]) - if err != nil { - return nil, nil, nil, err - } - if total > 100_000_000*lpod.Unit { - total = 100_000_000 * lpod.Unit - } - tier, err := lpod.TierFor(total) - if err != nil { - return nil, nil, nil, err - } - n := new(big.Int).SetUint64(p.Deposit.Amount - p.Withdrawn) - n.Mul(n, new(big.Int).SetUint64(tier.APRPercent)) - n.Mul(n, new(big.Int).SetUint64(elapsed)) - n.Add(n, new(big.Int).SetUint64(p.APRCarry)) - q, rem := new(big.Int), new(big.Int) - q.QuoRem(n, new(big.Int).SetUint64(lpodAPRDenominator), rem) - if !q.IsUint64() { - return nil, nil, nil, fmt.Errorf("lpod: accrued liability overflow") - } - p.APRCarry = rem.Uint64() - p.Due, err = lpodAdd(p.Due, q.Uint64()) - if err != nil { - return nil, nil, nil, err + var earned uint64 + if p.UnlockHeight == 0 && stake.Active { + total, err := lpodAdd(stake.Amount, totals[v]) + if err != nil { + return nil, nil, nil, err + } + if total > 100_000_000*lpod.Unit { + total = 100_000_000 * lpod.Unit + } + tier, err := lpod.TierFor(total) + if err != nil { + return nil, nil, nil, err + } + principal, err := LPoDPositionPrincipal(p) + if err != nil { + return nil, nil, nil, err + } + n := new(big.Int).SetUint64(principal - p.Withdrawn) + n.Mul(n, new(big.Int).SetUint64(tier.APRPercent)) + n.Mul(n, new(big.Int).SetUint64(elapsed)) + n.Add(n, new(big.Int).SetUint64(p.APRCarry)) + q, rem := new(big.Int), new(big.Int) + q.QuoRem(n, new(big.Int).SetUint64(lpodAPRDenominator), rem) + if !q.IsUint64() { + return nil, nil, nil, fmt.Errorf("lpod: accrued liability overflow") + } + earned = q.Uint64() + p.APRCarry = rem.Uint64() } - accrual[v], err = lpodAdd(accrual[v], q.Uint64()) - if err != nil { - return nil, nil, nil, err + if r.DailyPayout { + p.PendingDue, err = lpodAdd(p.PendingDue, earned) + if err != nil { + return nil, nil, nil, err + } + if r.Timestamp >= p.NextPayoutNS { + arrears[v], err = lpodAdd(arrears[v], p.Due) + if err != nil { + return nil, nil, nil, err + } + accrual[v], err = lpodAdd(accrual[v], p.PendingDue) + if err != nil { + return nil, nil, nil, err + } + p.Due, err = lpodAdd(p.Due, p.PendingDue) + if err != nil { + return nil, nil, nil, err + } + p.PendingDue = 0 + p.NextPayoutNS, err = lpodNextDailyBoundaryAfter(r.Timestamp) + if err != nil { + return nil, nil, nil, err + } + } + } else { + arrears[v], err = lpodAdd(arrears[v], p.Due) + if err != nil { + return nil, nil, nil, err + } + p.Due, err = lpodAdd(p.Due, earned) + if err != nil { + return nil, nil, nil, err + } + accrual[v], err = lpodAdd(accrual[v], earned) + if err != nil { + return nil, nil, nil, err + } } next.Positions[id] = p } @@ -252,7 +405,14 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe if err != nil || a.Amount < tier.MinimumDeposit { return nil, nil, nil, fmt.Errorf("lpod: deposit outside vault tier limits") } - next.Positions[id] = LPoDPosition{Deposit: *a} + position := LPoDPosition{Deposit: *a} + if r.DailyPayout { + position.NextPayoutNS, err = LPoDNextDailyBoundaryAfter24h(r.Timestamp) + if err != nil { + return nil, nil, nil, err + } + } + next.Positions[id] = position next.PrincipalDeposited, err = lpodAdd(next.PrincipalDeposited, a.Amount) if err != nil { return nil, nil, nil, err @@ -277,7 +437,11 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe } p.Nonce++ p.Withdrawn += amount - p.Returned = p.Withdrawn == p.Deposit.Amount + principal, err := LPoDPositionPrincipal(p) + if err != nil { + return nil, nil, nil, err + } + p.Returned = p.Withdrawn == principal if p.Returned { p.UnlockHeight = height } @@ -289,6 +453,51 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe effective := lpodPositionVaultID(p) totals[effective] -= amount next.Positions[id] = p + case core.LPoDTopUp: + if !r.DailyPayout { + return nil, nil, nil, fmt.Errorf("lpod: top-ups require the daily payout protocol") + } + p, exists := next.Positions[id] + if !exists { + return nil, nil, nil, fmt.Errorf("lpod: top-up position does not exist") + } + amount, err := LPoDTopUpAmount(p, *a) + if err != nil { + return nil, nil, nil, err + } + effectiveVault := LPoDEffectiveVault(p) + effective := hex.EncodeToString(effectiveVault[:]) + stake := r.Stake[effective] + max := 100_000_000 * lpod.Unit + if effective != v || !stake.Active || stake.Amount > max || totals[effective] > max-stake.Amount || + amount > max-stake.Amount-totals[effective] { + return nil, nil, nil, fmt.Errorf("lpod: top-up exceeds active vault capacity") + } + if _, err := lpod.TierFor(stake.Amount + totals[effective] + amount); err != nil { + return nil, nil, nil, fmt.Errorf("lpod: top-up outside vault tier limits") + } + p.Nonce++ + p.TopUps = append(p.TopUps, *a) + p.PendingDue = 0 + p.APRCarry = 0 + p.NextPayoutNS, err = LPoDNextDailyBoundaryAfter24h(r.Timestamp) + if err != nil { + return nil, nil, nil, err + } + next.Positions[id] = p + next.PrincipalDeposited, err = lpodAdd(next.PrincipalDeposited, amount) + if err != nil { + return nil, nil, nil, err + } + next.PrincipalLocked, err = lpodAdd(next.PrincipalLocked, amount) + if err != nil { + return nil, nil, nil, err + } + totals[effective], err = lpodAdd(totals[effective], amount) + if err != nil { + return nil, nil, nil, err + } + vaultIDs[effective] = true } } // Apply lifecycle routing after signed operations. A deposit targeting a @@ -324,14 +533,18 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe continue } sourceBefore := sourceBeforeTotals[source] - remaining := p.Deposit.Amount - p.Withdrawn + principal, err := LPoDPositionPrincipal(p) + if err != nil { + return nil, nil, nil, err + } + remaining := principal - p.Withdrawn best, err := lpodRouteDestination(source, sourceBefore, remaining, r.Stake, totals, eligible) if err != nil { return nil, nil, nil, err } totals[source] -= remaining if best == "" { - p.Withdrawn = p.Deposit.Amount + p.Withdrawn = principal p.Returned = true p.AutoReturned = true p.UnlockHeight = height @@ -374,7 +587,11 @@ func (d *DB) positionTransition(before *LPoDCheckpoint, height uint64, r *LPoDSe for _, p := range before.Positions { if p.UnlockHeight == 0 { v := lpodPositionVaultID(p) - priorTotals[v], err = lpodAdd(priorTotals[v], p.Deposit.Amount-p.Withdrawn) + principal, principalErr := LPoDPositionPrincipal(p) + if principalErr != nil { + return nil, nil, nil, principalErr + } + priorTotals[v], err = lpodAdd(priorTotals[v], principal-p.Withdrawn) if err != nil { return nil, nil, nil, err } @@ -459,6 +676,13 @@ func (d *DB) previewPositions(before *LPoDCheckpoint, height uint64, r *LPoDSett return nil, nil, fmt.Errorf("lpod: proportional payout rounding did not conserve funds") } } + next.PendingLiability = 0 + for _, p := range next.Positions { + next.PendingLiability, err = lpodAdd(next.PendingLiability, p.PendingDue) + if err != nil { + return nil, nil, err + } + } if err := next.validatePositions(); err != nil { return nil, nil, err } diff --git a/store/lpod_positions_test.go b/store/lpod_positions_test.go index 7b6acc7..88cc9c5 100644 --- a/store/lpod_positions_test.go +++ b/store/lpod_positions_test.go @@ -5,13 +5,296 @@ package store import ( "encoding/json" + "math/big" "testing" + "time" "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" "github.com/aperod/aperod/lpod" ) +func TestLPoDDailyBoundaryWaitsForFullDayAndUsesMoscowTime(t *testing.T) { + location := time.FixedZone("MSK", 3*60*60) + cases := []struct { + name string + start time.Time + want time.Time + }{ + {"before-evening", time.Date(2026, 10, 1, 19, 0, 0, 0, location), time.Date(2026, 10, 2, 20, 0, 0, 0, location)}, + {"five-minutes-before", time.Date(2026, 10, 1, 19, 55, 0, 0, location), time.Date(2026, 10, 2, 20, 0, 0, 0, location)}, + {"exact-evening", time.Date(2026, 10, 1, 20, 0, 0, 0, location), time.Date(2026, 10, 2, 20, 0, 0, 0, location)}, + {"one-second-after-evening", time.Date(2026, 10, 1, 20, 0, 1, 0, location), time.Date(2026, 10, 3, 20, 0, 0, 0, location)}, + {"after-evening", time.Date(2026, 10, 1, 21, 30, 0, 0, location), time.Date(2026, 10, 3, 20, 0, 0, 0, location)}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + got, err := LPoDNextDailyBoundaryAfter24h(test.start.UnixNano()) + if err != nil { + t.Fatal(err) + } + if got != test.want.UnixNano() || got < test.start.Add(24*time.Hour).UnixNano() { + t.Fatalf("next daily boundary = %s, want %s after 24 hours", + time.Unix(0, got).In(location), test.want) + } + }) + } + if _, err := LPoDNextDailyBoundaryAfter24h(0); err == nil { + t.Fatal("accepted an empty payout anchor") + } + if _, err := LPoDNextDailyBoundaryAfter24h(int64(^uint64(0) >> 1)); err == nil { + t.Fatal("accepted an overflowing payout anchor") + } +} + +func TestLPoDDailyAccrualWaitsForBoundaryAndTopUpResetsOnlyPendingAmount(t *testing.T) { + path := t.TempDir() + db, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + priv, pub, err := crypto.GenerateValidatorKey() + if err != nil { + t.Fatal(err) + } + owner, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + leader, err := crypto.GenerateWalletKeys() + if err != nil { + t.Fatal(err) + } + const principal = 100 * lpod.Unit + const debt = 50 * lpod.Unit + const pendingDue = 7 * lpod.Unit + location := time.FixedZone("MSK", 3*60*60) + boundary := time.Date(2026, 10, 2, 20, 0, 0, 0, location) + genesis := crypto.HashBytes([]byte("daily-position-fixture-genesis")) + sourceHash := crypto.HashBytes([]byte("daily-position-fixture-source")) + address := crypto.AddressFromKeys(crypto.MainnetByte, owner) + deposit := core.LPoDPositionAction{ + Action: core.LPoDDeposit, Genesis: genesis, Vault: crypto.Point32(pub), + Beneficiary: address, Owner: owner.Spend.Public, SourceTx: sourceHash, + SourcePub: owner.Spend.Public, Amount: principal, + PositionID: core.LPoDPositionID(genesis, sourceHash, 0), + Blind: crypto.BlindFactor(crypto.ScalarFromUint64(31)), + } + id := lpodPositionID(deposit) + checkpoint := &LPoDCheckpoint{ + State: lpod.State{FundingDebit: lpod.InitialNAPRO, Balance: 0, + RewardInflow: 300_000_000, LeaderPaid: 24_000_000, AngelPaid: lpod.InitialNAPRO + 276_000_000, + DeficitOutflow: lpod.InitialNAPRO, UnfundedLiability: debt, + AccruedLiability: lpod.InitialNAPRO + 276_000_000 + debt, LastHeight: 2}, + Carries: map[string]lpod.Carry{pub.Hex(): {}}, + Allocation: &LPoDAllocation{Version: 1, PositionLifecycleVersion: 1, Genesis: genesis, FundingHeight: 2, + HistoricalIssued: principal, InitialValidatorRemaining: 2_000_000_000 * lpod.Unit, + ValidatorRemaining: 2_000_000_000*lpod.Unit - 300_000_000, + Remaining: LPoDPublicAllocationNAPRO - principal - lpod.InitialNAPRO}, + DailyPayoutVersion: 1, PendingLiability: pendingDue, + PrincipalDeposited: principal, PrincipalLocked: principal, + Positions: map[string]LPoDPosition{id: { + Deposit: deposit, Due: debt, PendingDue: pendingDue, NextPayoutNS: boundary.UnixNano(), + }}, + } + parent := &core.Block{Header: core.BlockHeader{Height: 2, Timestamp: boundary.Add(-3 * time.Second).UnixNano(), ValidatorPub: pub}, + Txs: []core.Transaction{{Version: core.TxVersionLPoDPayout}, core.LPoDCheckpointTx(checkpoint.Digest())}} + parent.Header.MerkleRoot = core.MerkleRoot(parent.Txs) + if err := parent.Header.Sign(priv); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(parent) + if err != nil { + t.Fatal(err) + } + if err := db.PutRawBlock(parent.Hash(), parent.Header.Height, raw); err != nil { + t.Fatal(err) + } + cpraw, err := json.Marshal(checkpoint) + if err != nil { + t.Fatal(err) + } + if err := db.db.Put(lpodKey(parent.Hash()), cpraw, nil); err != nil { + t.Fatal(err) + } + if err := db.PutTip(parent.Hash(), parent.Header.Height); err != nil { + t.Fatal(err) + } + persisted, err := db.LoadLPoDCheckpoint() + if err != nil || persisted == nil || persisted.Digest() != checkpoint.Digest() || + persisted.DailyPayoutVersion != 1 || persisted.PendingLiability != pendingDue { + t.Fatalf("daily checkpoint did not persist canonical accrued state: %v", err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + db, err = Open(path) + if err != nil { + t.Fatal(err) + } + persisted, err = db.LoadLPoDCheckpoint() + if err != nil || persisted == nil || persisted.Digest() != checkpoint.Digest() || + persisted.Positions[id].PendingDue != pendingDue { + t.Fatalf("daily checkpoint replay changed pending accrual or digest: %v", err) + } + request := &LPoDSettlement{ + Parent: parent.Hash(), PositionProtocol: true, DailyPayout: true, + Timestamp: boundary.Add(-time.Second).UnixNano(), Proposer: pub.Hex(), + Leader: crypto.AddressFromKeys(crypto.MainnetByte, leader), + Stake: map[string]LPoDValidatorStake{pub.Hex(): {Amount: 100_000 * lpod.Unit, Active: true}}, + } + beforeBoundary, _, err := db.PreviewLPoD(3, request) + if err != nil { + t.Fatal(err) + } + if beforeBoundary.Positions[id].Due != debt || beforeBoundary.Positions[id].PendingDue < pendingDue { + t.Fatal("unrecognized accrual was paid before the daily boundary") + } + request.Timestamp = boundary.Add(time.Second).UnixNano() + atBoundary, boundaryPayments, err := db.PreviewLPoD(3, request) + if err != nil { + t.Fatal(err) + } + paidAtBoundary := uint64(0) + for _, payment := range boundaryPayments { + paidAtBoundary += payment.Angels + } + recognized := atBoundary.Positions[id].Due + paidAtBoundary + if recognized < debt+pendingDue || atBoundary.Positions[id].PendingDue != 0 || + atBoundary.Positions[id].NextPayoutNS <= boundary.UnixNano() { + t.Fatalf("daily boundary did not recognize pending accrual and schedule the next payout: due=%d paid=%d pending=%d next=%s payments=%+v", + atBoundary.Positions[id].Due, paidAtBoundary, atBoundary.Positions[id].PendingDue, + time.Unix(0, atBoundary.Positions[id].NextPayoutNS).In(location), boundaryPayments) + } + + topUpHS := crypto.ScalarFromUint64(41) + topUpPrivate, err := crypto.AddScalars(topUpHS, owner.Spend.Private) + if err != nil { + t.Fatal(err) + } + topUpPub, err := crypto.ScalarMulBase(topUpPrivate) + if err != nil { + t.Fatal(err) + } + topUp := deposit + topUp.Action, topUp.Nonce = core.LPoDTopUp, 1 + topUp.SourceTx, topUp.SourceIndex, topUp.SourcePub = crypto.HashBytes([]byte("daily-top-up-source")), 3, topUpPub + topUp.Amount, topUp.Blind = 5*lpod.Unit, crypto.BlindFactor(crypto.ScalarFromUint64(43)) + request.Timestamp = boundary.Add(-time.Second).UnixNano() + request.Transactions = nil + tooMuch := topUp + tooMuch.Amount = 100_000_000*lpod.Unit - request.Stake[pub.Hex()].Amount - principal + 1 + tooMuch.Blind = crypto.BlindFactor(crypto.ScalarFromUint64(47)) + tooMuchTx, err := core.BuildLPoDPositionTx(tooMuch, owner.Spend.Private, topUpPrivate) + if err != nil { + t.Fatal(err) + } + request.Transactions = []core.Transaction{*tooMuchTx} + if _, _, err := db.PreviewLPoD(3, request); err == nil { + t.Fatal("top-up beyond the active vault capacity was accepted") + } + wrongNonce := topUp + wrongNonce.Nonce = 2 + wrongNonceTx, err := core.BuildLPoDPositionTx(wrongNonce, owner.Spend.Private, topUpPrivate) + if err != nil { + t.Fatal(err) + } + request.Transactions = []core.Transaction{*wrongNonceTx} + if _, _, err := db.PreviewLPoD(3, request); err == nil { + t.Fatal("top-up replay with a skipped position nonce was accepted") + } + tx, err := core.BuildLPoDPositionTx(topUp, owner.Spend.Private, topUpPrivate) + if err != nil { + t.Fatal(err) + } + request.Transactions = []core.Transaction{*tx} + afterTopUp, _, err := db.PreviewLPoD(3, request) + if err != nil { + t.Fatal(err) + } + updated := afterTopUp.Positions[id] + if updated.Due != debt || updated.PendingDue != 0 || updated.Nonce != 1 || + len(updated.TopUps) != 1 || updated.NextPayoutNS <= request.Timestamp || + afterTopUp.PrincipalLocked != principal+topUp.Amount { + t.Fatal("top-up reset recognized debt, failed to reset pending accrual, or did not lock the exact new principal") + } + + commitPreview := func(height uint64, parent *core.Block, timestamp int64, operations ...core.Transaction) (*core.Block, *LPoDCheckpoint) { + t.Helper() + request.Parent = parent.Hash() + request.Timestamp = timestamp + request.Transactions = operations + next, payments, err := db.PreviewLPoD(height, request) + if err != nil { + t.Fatal(err) + } + payout, err := db.PayoutLPoD(height, request, next, payments) + if err != nil { + t.Fatal(err) + } + txs := []core.Transaction{payout, core.LPoDCheckpointTx(next.Digest())} + txs = append(txs, operations...) + block := &core.Block{Header: core.BlockHeader{ + Height: height, Round: parent.Header.Round + 1, Timestamp: timestamp, + PrevHash: parent.Hash(), ValidatorPub: pub, + }, Txs: txs} + block.Header.MerkleRoot = core.MerkleRoot(block.Txs) + if err := block.Header.Sign(priv); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.PutRawBlock(block.Hash(), height, raw); err != nil { + t.Fatal(err) + } + cpraw, err := json.Marshal(next) + if err != nil { + t.Fatal(err) + } + if err := db.db.Put(lpodKey(block.Hash()), cpraw, nil); err != nil { + t.Fatal(err) + } + if err := db.PutTip(block.Hash(), height); err != nil { + t.Fatal(err) + } + return block, next + } + // Commit sequential previews so the next transition uses its own canonical + // parent timestamp, as a real replay does. + request.Timestamp = boundary.Add(-time.Second).UnixNano() + topUpBlock, _ := commitPreview(3, parent, request.Timestamp, *tx) + withdrawAmount := 2 * lpod.Unit + withdraw := deposit + withdraw.Action, withdraw.Nonce, withdraw.WithdrawAmount = core.LPoDWithdraw, 2, withdrawAmount + withdrawTx, err := core.BuildLPoDPositionTx(withdraw, owner.Spend.Private, crypto.Scalar32{}) + if err != nil { + t.Fatal(err) + } + withdrawBlock, afterPartial := commitPreview(4, topUpBlock, topUpBlock.Header.Timestamp+int64(15*time.Second), *withdrawTx) + partial := afterPartial.Positions[id] + if partial.Withdrawn != withdrawAmount || partial.PendingDue == 0 || partial.Due != debt || + afterPartial.PrincipalLocked != principal+topUp.Amount-withdrawAmount { + t.Fatal("partial withdrawal did not preserve same-block accrued liability and remaining principal") + } + _, afterNextBlock := commitPreview(5, withdrawBlock, withdrawBlock.Header.Timestamp+int64(15*time.Second)) + nextPosition := afterNextBlock.Positions[id] + tier, err := lpod.TierFor(request.Stake[pub.Hex()].Amount + principal + topUp.Amount - withdrawAmount) + if err != nil { + t.Fatal(err) + } + expectedEarned := new(big.Int).SetUint64(principal + topUp.Amount - withdrawAmount) + expectedEarned.Mul(expectedEarned, new(big.Int).SetUint64(tier.APRPercent)) + expectedEarned.Mul(expectedEarned, big.NewInt(int64(15*time.Second))) + expectedEarned.Add(expectedEarned, new(big.Int).SetUint64(partial.APRCarry)) + expectedEarned.Quo(expectedEarned, new(big.Int).SetUint64(lpodAPRDenominator)) + if !expectedEarned.IsUint64() || nextPosition.PendingDue-partial.PendingDue != expectedEarned.Uint64() { + t.Fatal("post-withdrawal accrual did not use the remaining principal and preserved fractional carry") + } +} + // An explicitly isolated depleted-reserve arithmetic snapshot exercises arrears. // Real deposits, same-block refunds and ordinary spending are tested in consensus. func TestLPoDImmediatePrincipalReturnRetainsAndRepaysArrears(t *testing.T) { diff --git a/store/lpod_vault_rank.go b/store/lpod_vault_rank.go index f72f80e..c4e787b 100644 --- a/store/lpod_vault_rank.go +++ b/store/lpod_vault_rank.go @@ -4,6 +4,7 @@ package store import ( + "fmt" "sort" "github.com/aperod/aperod/lpod" @@ -88,7 +89,14 @@ func (c *LPoDCheckpoint) LPoDEffectiveGuardianTotals() (map[string]uint64, error continue } id := lpodPositionVaultID(p) - n, err := lpodAdd(totals[id], p.Deposit.Amount-p.Withdrawn) + principal, err := LPoDPositionPrincipal(p) + if err != nil { + return nil, err + } + if principal < p.Withdrawn { + return nil, fmt.Errorf("lpod: position withdrawal exceeds principal") + } + n, err := lpodAdd(totals[id], principal-p.Withdrawn) if err != nil { return nil, err } diff --git a/store/prune.go b/store/prune.go index 163a630..c7a0a61 100644 --- a/store/prune.go +++ b/store/prune.go @@ -3,8 +3,10 @@ package store import ( "bytes" "encoding/binary" + "encoding/json" "fmt" + "github.com/aperod/aperod/core" "github.com/aperod/aperod/crypto" ) @@ -78,20 +80,21 @@ 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 - } + 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 err := d.validateSaleRetentionIndex(); err != nil { + return 0, fmt.Errorf("validate sale retention before pruning: %w", err) + } if cursor >= pruneBelow { return 0, nil // nothing new to prune } @@ -107,6 +110,30 @@ func (d *DB) PruneBlocksOlderThan(pruneBelow uint64) (int, error) { if err != nil { return pruned, fmt.Errorf("get block at height %d: %w", h, err) } + retainedValue, err := d.get(saleRetainedBlockKey(h)) + if err != nil { + return pruned, fmt.Errorf("read sale retention marker at height %d: %w", h, err) + } + if flags, retainedHash, retained, err := saleRetentionMarker(retainedValue, h); err != nil { + return pruned, err + } else if retained { + canonicalHash, found, err := d.GetCanonicalHash(h) + if err != nil || !found || canonicalHash != retainedHash { + return pruned, fmt.Errorf("sale retention marker is not bound to canonical height %d: %v", h, err) + } + if raw == nil { + return pruned, fmt.Errorf("retained sale block body missing at height %d", h) + } + if flags&saleRetainedDelivery != 0 { + err = validateSaleRetentionBody(raw, h, retainedHash) + } else { + err = validateSaleRetentionHeader(raw, h, retainedHash) + } + if err != nil { + return pruned, err + } + continue + } if hasFunding && h == fundingHeight { canonicalHash, err := d.get(heightKey(h)) if err != nil { @@ -131,26 +158,13 @@ func (d *DB) PruneBlocksOlderThan(pruneBelow uint64) (int, error) { continue } - // Strip TxData: replace the block bytes with a header-only version. - // We encode the pruned marker as the single JSON string `null` appended - // to the height key only; the block hash key is left pointing to a - // stripped StoredBlock with TxData omitted. - sb, err := d.GetBlockByHeight(h) + stripped, err := d.stripCanonicalBlockTransactions(h, raw) if err != nil { - return pruned, fmt.Errorf("unmarshal block at height %d: %w", h, err) - } - if sb == nil { - continue - } - if len(sb.TxData) == 0 { - // Already pruned or empty block — just advance cursor. - continue + return pruned, err } - sb.TxData = nil // drop transaction payloads - if err := d.PutBlock(sb.Hash, sb); err != nil { - return pruned, fmt.Errorf("rewrite pruned block at height %d: %w", h, err) + if stripped { + pruned++ } - pruned++ } // Advance cursor. @@ -161,3 +175,49 @@ func (d *DB) PruneBlocksOlderThan(pruneBelow uint64) (int, error) { } return pruned, nil } + +// stripCanonicalBlockTransactions supports the current core.Block encoding and +// the older flattened StoredBlock encoding. Either representation must match +// the canonical height index before it can be rewritten. +func (d *DB) stripCanonicalBlockTransactions(height uint64, raw []byte) (bool, error) { + hash, found, err := d.GetCanonicalHash(height) + if err != nil || !found { + return false, fmt.Errorf("missing canonical hash while pruning height %d: %v", height, err) + } + + var block core.Block + if err := json.Unmarshal(raw, &block); err == nil && + block.Header.Height == height && block.Hash() == hash { + if len(block.Txs) == 0 { + // Empty blocks and blocks already pruned by an interrupted prior run + // need no rewrite. Sale delivery bodies were validated above. + return false, nil + } + if block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { + return false, fmt.Errorf("block body merkle root mismatch at height %d", height) + } + block.Txs = nil + encoded, err := json.Marshal(&block) + if err != nil { + return false, fmt.Errorf("marshal pruned block at height %d: %w", height, err) + } + if err := d.PutRawBlock(hash, height, encoded); err != nil { + return false, fmt.Errorf("rewrite pruned block at height %d: %w", height, err) + } + return true, nil + } + + var stored StoredBlock + if err := json.Unmarshal(raw, &stored); err != nil || + stored.Height != height || stored.Hash != hash { + return false, fmt.Errorf("block at height %d is not bound to its canonical hash", height) + } + if len(stored.TxData) == 0 { + return false, nil + } + stored.TxData = nil + if err := d.PutBlock(hash, &stored); err != nil { + return false, fmt.Errorf("rewrite legacy pruned block at height %d: %w", height, err) + } + return true, nil +} diff --git a/store/prune_core_block_test.go b/store/prune_core_block_test.go new file mode 100644 index 0000000..946c53b --- /dev/null +++ b/store/prune_core_block_test.go @@ -0,0 +1,58 @@ +package store + +import ( + "encoding/json" + "testing" + + "github.com/aperod/aperod/core" + "github.com/aperod/aperod/crypto" +) + +func TestPruneBlocksOlderThanStripsCanonicalCoreBlockBody(t *testing.T) { + db, err := Open(t.TempDir() + "/chain") + if err != nil { + t.Fatal(err) + } + defer closeStoreTestResource(t, db) + + genesis := &core.Block{Header: core.BlockHeader{Height: 0, Timestamp: 1, MerkleRoot: core.MerkleRoot(nil)}} + raw, err := json.Marshal(genesis) + if err != nil { + t.Fatal(err) + } + if err := db.CommitRawBlockWithAVM(genesis.Hash(), 0, raw, nil, crypto.Hash32{}); err != nil { + t.Fatal(err) + } + + txs := []core.Transaction{{}} + block := &core.Block{Header: core.BlockHeader{ + Height: 1, PrevHash: genesis.Hash(), Timestamp: 2, MerkleRoot: core.MerkleRoot(txs), + }, Txs: txs} + raw, err = json.Marshal(block) + if err != nil { + t.Fatal(err) + } + if err := db.CommitRawBlockWithAVM(block.Hash(), block.Header.Height, raw, nil, crypto.Hash32{}); err != nil { + t.Fatal(err) + } + + pruned, err := db.PruneBlocksOlderThan(2) + if err != nil || pruned != 1 { + t.Fatalf("current canonical block body was not pruned: count=%d err=%v", pruned, err) + } + raw, err = db.GetRawBlockByHeight(1) + if err != nil { + t.Fatal(err) + } + var headerOnly core.Block + if err := json.Unmarshal(raw, &headerOnly); err != nil { + t.Fatal(err) + } + if headerOnly.Header.Height != block.Header.Height || headerOnly.Hash() != block.Hash() || + len(headerOnly.Txs) != 0 || headerOnly.Header.MerkleRoot != block.Header.MerkleRoot { + t.Fatal("pruning changed canonical header or retained transaction payload") + } + if pruned, err := db.PruneBlocksOlderThan(2); err != nil || pruned != 0 { + t.Fatalf("repeat pruning was not idempotent: count=%d err=%v", pruned, err) + } +} diff --git a/store/ring_maintenance_fence.go b/store/ring_maintenance_fence.go index 9ef97d3..1a426f0 100644 --- a/store/ring_maintenance_fence.go +++ b/store/ring_maintenance_fence.go @@ -20,8 +20,35 @@ import ( // the maintenance fence. This is not installed as part of a node update. const RingMaintenanceStateDirectory = "/var/lib/aperod-ring-maintenance" +func ringMaintenanceFileFromFD(fd int, name string) (*os.File, error) { + if fd < 0 { + return nil, fmt.Errorf("ring maintenance: invalid file descriptor") + } + // #nosec G115 -- successful OS descriptors are nonnegative and bounded by the process descriptor table. + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = syscall.Close(fd) + return nil, fmt.Errorf("ring maintenance: cannot wrap file descriptor") + } + return file, nil +} + +func ringMaintenanceFDInt(file *os.File) (int, error) { + if file == nil { + return 0, fmt.Errorf("ring maintenance: missing file descriptor") + } + fd := file.Fd() + maxInt := uintptr(^uint(0) >> 1) + if fd > maxInt { + return 0, fmt.Errorf("ring maintenance: file descriptor exceeds int range") + } + // #nosec G115 -- fd was checked against the maximum representable int above. + return int(fd), nil +} + func protectedRingMaintenanceRoot(root string, owner uint32) error { for path := root; ; path = filepath.Dir(path) { + // #nosec G703 -- root is supplied by the trusted operator configuration and each ancestor is verified before use. dir, err := os.Lstat(path) if err != nil { return err @@ -119,6 +146,7 @@ func checkRingScopeFiles(state string) error { } func checkRingStateFile(state string) error { + // #nosec G703 -- state is a hash-derived filename beneath the independently verified protected root. _, err := os.Lstat(state) if errors.Is(err, os.ErrNotExist) { return nil @@ -163,6 +191,7 @@ func acquireRingMaintenanceAt(path, root string, owner uint32) (*RingMaintenance if err != nil || identity != absolute || identity == "/" { return nil, fmt.Errorf("ring maintenance: canonical non-symlink database path required") } + // #nosec G703 -- identity is an absolute, canonical, non-symlink database path verified immediately above. info, err := os.Lstat(identity) if err != nil { return nil, err @@ -181,8 +210,16 @@ func acquireRingMaintenanceAt(path, root string, owner uint32) (*RingMaintenance if err != nil { return nil, err } - file := os.NewFile(uintptr(fd), state+".lock") - fail := func(err error) (*RingMaintenanceLease, error) { file.Close(); return nil, err } + file, err := ringMaintenanceFileFromFD(fd, state+".lock") + if err != nil { + return nil, err + } + fail := func(err error) (*RingMaintenanceLease, error) { + if closeErr := file.Close(); closeErr != nil { + return nil, errors.Join(err, fmt.Errorf("close ring maintenance lock: %w", closeErr)) + } + return nil, err + } li, err := file.Stat() if err != nil { return fail(err) @@ -212,6 +249,7 @@ type RingMaintenanceWorkerReceipt struct { Inventory RingSamplingInventory `json:"inventory"` } +// WorkerGeneration returns the fenced generation assigned to the maintenance worker. func (l *RingMaintenanceLease) WorkerGeneration() string { if l == nil { return "" @@ -219,16 +257,25 @@ func (l *RingMaintenanceLease) WorkerGeneration() string { return l.generation } +// WorkerFile returns the inherited descriptor authorized for the maintenance worker. func (l *RingMaintenanceLease) WorkerFile() (*os.File, error) { if err := l.check(); err != nil { return nil, err } - fd, err := syscall.Dup(int(l.lock.Fd())) + lockFD, err := ringMaintenanceFDInt(l.lock) + if err != nil { + return nil, err + } + fd, err := syscall.Dup(lockFD) if err != nil { return nil, err } syscall.CloseOnExec(fd) - return os.NewFile(uintptr(fd), "ring-maintenance-lease"), nil + file, err := ringMaintenanceFileFromFD(fd, "ring-maintenance-lease") + if err != nil { + return nil, err + } + return file, nil } func borrowRingMaintenanceAt(path, root, generation string, file *os.File, owner uint32) (*RingMaintenanceLease, error) { @@ -265,7 +312,11 @@ func borrowRingMaintenanceAt(path, root, generation string, file *os.File, owner if _, err := hex.DecodeString(generation); err != nil || len(generation) != 32 { return nil, fmt.Errorf("ring maintenance: invalid worker generation") } - if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + fd, err := ringMaintenanceFDInt(file) + if err != nil { + return nil, err + } + if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { return nil, err } return &RingMaintenanceLease{path: identity, state: state, lock: file, generation: generation}, nil @@ -349,12 +400,17 @@ func (l *RingMaintenanceLease) check() error { } func syncRingStateDirectory(path string) error { + // #nosec G703 -- path is the trusted hash-derived state file under the protected root. dir, err := os.Open(filepath.Dir(path)) if err != nil { return err } - defer dir.Close() - return dir.Sync() + syncErr := dir.Sync() + closeErr := dir.Close() + if syncErr != nil || closeErr != nil { + return errors.Join(syncErr, closeErr) + } + return nil } // Fence publishes a durable external barrier BEFORE permitting a legacy writer, @@ -368,7 +424,10 @@ func (l *RingMaintenanceLease) Fence() error { if err != nil { return fmt.Errorf("ring maintenance: fence already present or cannot create: %w", err) } - f := os.NewFile(uintptr(fd), l.state) + f, err := ringMaintenanceFileFromFD(fd, l.state) + if err != nil { + return err + } _, writeErr := f.WriteString(l.path + "\n") syncErr := f.Sync() closeErr := f.Close() @@ -379,6 +438,7 @@ func (l *RingMaintenanceLease) existingFencedDB() (*DB, error) { if err := l.check(); err != nil { return nil, err } + // #nosec G703 -- l.state is derived from the canonical database identity beneath the verified protected root. fi, err := os.Lstat(l.state) if err != nil { return nil, err @@ -388,6 +448,7 @@ func (l *RingMaintenanceLease) existingFencedDB() (*DB, error) { } // OpenFile(ErrorIfMissing) still creates its filesystem directory before // detecting an absent CURRENT. Never initialize a removed import target. + // #nosec G703 -- l.path was canonicalized and identity-checked when the lease was acquired. fi, err = os.Lstat(l.path) if err != nil { return nil, err @@ -469,6 +530,7 @@ func (l *RingMaintenanceLease) Commit() error { return nil } +// Close releases the maintenance lock and invalidates the lease. func (l *RingMaintenanceLease) Close() error { if l == nil || l.lock == nil { return nil diff --git a/store/ring_maintenance_fence_test.go b/store/ring_maintenance_fence_test.go index 60d08d8..828c37f 100644 --- a/store/ring_maintenance_fence_test.go +++ b/store/ring_maintenance_fence_test.go @@ -31,20 +31,20 @@ func copyMaintenanceDB(t *testing.T, source, dest string) { } out, err := os.OpenFile(filepath.Join(dest, entry.Name()), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { - in.Close() + closeStoreTestResource(t, in) t.Fatal(err) } _, err = io.Copy(out, in) - in.Close() + closeStoreTestResource(t, in) if err != nil { - out.Close() + closeStoreTestResource(t, out) t.Fatal(err) } if err := out.Sync(); err != nil { - out.Close() + closeStoreTestResource(t, out) t.Fatal(err) } - out.Close() + closeStoreTestResource(t, out) } } @@ -65,7 +65,7 @@ func TestRingMaintenancePhysicalOlderGenerationImport(t *testing.T) { } copyMaintenanceDB(t, backup, path) // physically restores old valid ready count legacyPutMember(t, path, 8, false) // canonical change invisible to old ordinals - l.Close() + closeStoreTestResource(t, l) if err := checkRingMaintenanceFenceAt(path, state); err == nil { t.Fatal("older generation hid external fence") } @@ -84,7 +84,7 @@ func TestRingMaintenancePhysicalOlderGenerationImport(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) if _, err := db.VerifyRingSamplingIndex(); err != nil { t.Fatal(err) } @@ -111,7 +111,7 @@ func TestRingMaintenanceBorrowedWorkerAndReceiptFencing(t *testing.T) { if err != nil { t.Fatal(err) } - borrowed.Close() + closeStoreTestResource(t, borrowed) receipt := RingMaintenanceWorkerReceipt{Schema: 1, DBPath: path, Generation: l.WorkerGeneration(), Inventory: inventory} stale := receipt stale.Generation = "stale" @@ -136,9 +136,9 @@ func TestRingMaintenanceRejectsCounterfeitLeaseDescriptor(t *testing.T) { if err != nil { t.Fatal(err) } - defer fake.Close() + defer closeStoreTestResource(t, fake) if borrowed, err := borrowRingMaintenanceAt(path, state, l.WorkerGeneration(), fake, uint32(os.Geteuid())); err == nil { - borrowed.Close() + closeStoreTestResource(t, borrowed) t.Fatal("unrelated descriptor authorized maintenance") } other, _ := maintenanceFixture(t) @@ -146,9 +146,9 @@ func TestRingMaintenanceRejectsCounterfeitLeaseDescriptor(t *testing.T) { if err != nil { t.Fatal(err) } - defer fd.Close() + defer closeStoreTestResource(t, fd) if borrowed, err := borrowRingMaintenanceAt(other, state, l.WorkerGeneration(), fd, uint32(os.Geteuid())); err == nil { - borrowed.Close() + closeStoreTestResource(t, borrowed) t.Fatal("lease authorized different database") } } @@ -175,7 +175,7 @@ func TestRingMaintenanceRejectsConflictingLegacyBackfill(t *testing.T) { if err := db.db.Put(ringMemberKey(u.OneTimePub), raw, &opt.WriteOptions{Sync: true}); err != nil { t.Fatal(err) } - db.Close() + closeStoreTestResource(t, db) if _, err := l.RebuildAndVerify(); err == nil { t.Fatal("legacy backfill overwrote canonical repair") } @@ -183,7 +183,7 @@ func TestRingMaintenanceRejectsConflictingLegacyBackfill(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) got, err := db.db.Get(ringMemberKey(u.OneTimePub), nil) if err != nil || string(got) != string(raw) { t.Fatal("canonical repair lost on failed rebuild") @@ -198,7 +198,7 @@ func TestRingMaintenanceReadOnlyNeverCreatesMissingLock(t *testing.T) { t.Fatal(err) } if db, err := l.OpenReadOnly(); err == nil { - db.Close() + closeStoreTestResource(t, db) t.Fatal("missing native lock accepted") } if _, err := os.Lstat(lock); !os.IsNotExist(err) { @@ -238,7 +238,7 @@ func fixtureLease(t *testing.T, path, root string) *RingMaintenanceLease { if err != nil { t.Fatal(err) } - t.Cleanup(func() { l.Close() }) + t.Cleanup(func() { closeStoreTestResource(t, l) }) return l } @@ -248,7 +248,7 @@ func legacyPutMember(t *testing.T, path string, id byte, locked bool) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) u := sparseRingOutput(id) u.ProtocolLocked = locked raw, err := json.Marshal(u) @@ -276,7 +276,7 @@ func TestRingMaintenanceDowngradeRepairAndRetainedSpent(t *testing.T) { t.Fatal("unaudited commit") } if other, err := acquireRingMaintenanceAt(path, root, uint32(os.Geteuid())); err == nil { - other.Close() + closeStoreTestResource(t, other) t.Fatal("mixed controllers") } legacyPutMember(t, path, 7, false) @@ -300,7 +300,7 @@ func TestRingMaintenanceDowngradeRepairAndRetainedSpent(t *testing.T) { t.Fatal(err) } spent.Release() - db.Close() + closeStoreTestResource(t, db) if count != 1 { t.Fatal("spent anonymity marker lost") } @@ -329,7 +329,7 @@ func TestRingMaintenanceRestoreReadyMarkerAndFailureRemainFenced(t *testing.T) { if err := raw.Put(ringSamplingCountKey, ringSamplingUint(4), &opt.WriteOptions{Sync: true}); err != nil { t.Fatal(err) } - raw.Close() + closeStoreTestResource(t, raw) legacyPutMember(t, path, 8, false) if err := checkRingMaintenanceFenceAt(path, root); err == nil { t.Fatal("imported ready marker bypassed external fence") @@ -346,7 +346,7 @@ func TestRingMaintenanceRestoreReadyMarkerAndFailureRemainFenced(t *testing.T) { if err := raw.Put(ringMemberKey(sparseRingOutput(9).OneTimePub), []byte("{broken"), nil); err != nil { t.Fatal(err) } - raw.Close() + closeStoreTestResource(t, raw) if _, err := l.RebuildAndVerify(); err == nil { t.Fatal("bad repair accepted") } @@ -363,7 +363,7 @@ func TestRingMaintenanceRestoreReadyMarkerAndFailureRemainFenced(t *testing.T) { if _, ready, err := ringSamplingCount(raw); err != nil || ready { t.Fatal("failed repair left ready", err) } - raw.Close() + closeStoreTestResource(t, raw) } func TestRingMaintenanceMissingBusyAndRedirectedDatabase(t *testing.T) { @@ -379,7 +379,7 @@ func TestRingMaintenanceMissingBusyAndRedirectedDatabase(t *testing.T) { if err := l.Invalidate(); err == nil { t.Fatal("active legacy writer not rejected") } - writer.Close() + closeStoreTestResource(t, writer) moved := path + ".held" if err := os.Rename(path, moved); err != nil { t.Fatal(err) @@ -451,7 +451,7 @@ func TestRingMaintenanceLeaseRejectsUnsafeControlRoot(t *testing.T) { t.Fatal(err) } if l, err := acquireRingMaintenanceAt(path, root, uint32(os.Geteuid())); err == nil { - l.Close() + closeStoreTestResource(t, l) t.Fatal("writable control root accepted") } var l *RingMaintenanceLease diff --git a/store/ring_sampling.go b/store/ring_sampling.go index 108e522..0bef141 100644 --- a/store/ring_sampling.go +++ b/store/ring_sampling.go @@ -20,6 +20,7 @@ const maxRingSamplingRequest = 4096 const maxRingEntropyRejections = 128 const maxStakedRingExclusions = 1_000_000 +// SampleRingMembers selects canonical ring members excluding the supplied public keys. func (d *DB) SampleRingMembers(count int, exclude map[crypto.Point32]bool) ([]core.DecoyUTXO, error) { return d.sampleRingMembers(count, exclude, cryptorand.Reader) } diff --git a/store/ring_sampling_disk_metrics_test.go b/store/ring_sampling_disk_metrics_test.go index 8742187..54cdd38 100644 --- a/store/ring_sampling_disk_metrics_test.go +++ b/store/ring_sampling_disk_metrics_test.go @@ -177,12 +177,16 @@ func adviseDiskFilesCold(path string) error { if err != nil { return err } - defer file.Close() - return unix.Fadvise(int(file.Fd()), 0, 0, unix.FADV_DONTNEED) + adviseErr := unix.Fadvise(int(file.Fd()), 0, 0, unix.FADV_DONTNEED) + closeErr := file.Close() + if adviseErr != nil { + return adviseErr + } + return closeErr }) } -func writeDiskReport(path string, value any) error { +func writeDiskReport(path string, value any) (retErr error) { raw, err := json.MarshalIndent(value, "", " ") if err != nil { return err @@ -191,8 +195,17 @@ func writeDiskReport(path string, value any) error { if err != nil { return err } - defer os.Remove(file.Name()) - defer file.Close() + closed := false + defer func() { + if !closed { + if closeErr := file.Close(); closeErr != nil && retErr == nil { + retErr = closeErr + } + } + if removeErr := os.Remove(file.Name()); removeErr != nil && !os.IsNotExist(removeErr) && retErr == nil { + retErr = removeErr + } + }() if _, err := file.Write(append(raw, '\n')); err != nil { return err } @@ -202,6 +215,7 @@ func writeDiskReport(path string, value any) error { if err := file.Close(); err != nil { return err } + closed = true if err := os.Rename(file.Name(), path); err != nil { return err } @@ -209,11 +223,11 @@ func writeDiskReport(path string, value any) error { if err != nil { return err } - defer dir.Close() if err := dir.Sync(); err != nil { + _ = dir.Close() return fmt.Errorf("sync report directory: %w", err) } - return nil + return dir.Close() } func TestRingDiskReportAtomicCheckpoint(t *testing.T) { diff --git a/store/ring_sampling_disk_rehearsal_test.go b/store/ring_sampling_disk_rehearsal_test.go index f8105ea..92a52e7 100644 --- a/store/ring_sampling_disk_rehearsal_test.go +++ b/store/ring_sampling_disk_rehearsal_test.go @@ -320,7 +320,7 @@ func TestRingSamplingDiskRehearsal(t *testing.T) { } defer func() { if db != nil { - db.Close() + closeStoreTestResource(t, db) } }() defer func() { @@ -506,7 +506,7 @@ func TestRingDiskCrashDurablyBuildsNewNotOldSlots(t *testing.T) { } defer func() { if db != nil { - db.Close() + closeStoreTestResource(t, db) } }() const population = uint64(20_000) diff --git a/store/ring_sampling_test.go b/store/ring_sampling_test.go index 463102e..f7d300e 100644 --- a/store/ring_sampling_test.go +++ b/store/ring_sampling_test.go @@ -24,7 +24,7 @@ func samplingDB(t *testing.T) *DB { if err != nil { t.Fatal(err) } - t.Cleanup(func() { db.Close() }) + t.Cleanup(func() { closeStoreTestResource(t, db) }) return db } @@ -230,7 +230,9 @@ func TestRingSamplingStorageFaultDiscardsPartialSelection(t *testing.T) { t.Fatalf("storage fault %d leaked partial selection: %v %v", failAt, got, err) } } - db.Close() + if err := db.Close(); err != nil { + t.Fatal(err) + } if got, err := db.SampleRingMembers(3, nil); got != nil || err == nil { t.Fatal("closed backend fell back instead of failing") } @@ -246,23 +248,37 @@ func TestRingSamplingCorruptionFailsClosed(t *testing.T) { u := sparseRingOutput(3) // tail checked on every nonempty request switch fault { case "count": - db.db.Put(ringSamplingCountKey, []byte{1}, nil) + if err := db.db.Put(ringSamplingCountKey, []byte{1}, nil); err != nil { + t.Fatal(err) + } case "slot": - db.db.Delete(ringSamplingSlotKey(3), nil) + if err := db.db.Delete(ringSamplingSlotKey(3), nil); err != nil { + t.Fatal(err) + } case "reverse": - db.db.Put(ringSamplingReverseKey(u.OneTimePub), ringSamplingUint(2), nil) + if err := db.db.Put(ringSamplingReverseKey(u.OneTimePub), ringSamplingUint(2), nil); err != nil { + t.Fatal(err) + } case "payload": - db.db.Put(ringMemberKey(u.OneTimePub), []byte("{"), nil) + if err := db.db.Put(ringMemberKey(u.OneTimePub), []byte("{"), nil); err != nil { + t.Fatal(err) + } case "key": u.OneTimePub = sparseRingOutput(1).OneTimePub data, _ := json.Marshal(u) - db.db.Put(ringMemberKey(sparseRingOutput(3).OneTimePub), data, nil) + if err := db.db.Put(ringMemberKey(sparseRingOutput(3).OneTimePub), data, nil); err != nil { + t.Fatal(err) + } case "lock": u.ProtocolLocked = true data, _ := json.Marshal(u) - db.db.Put(ringMemberKey(u.OneTimePub), data, nil) + if err := db.db.Put(ringMemberKey(u.OneTimePub), data, nil); err != nil { + t.Fatal(err) + } case "tail": - db.db.Put(ringSamplingSlotKey(4), sparseRingOutput(0).OneTimePub[:], nil) + if err := db.db.Put(ringSamplingSlotKey(4), sparseRingOutput(0).OneTimePub[:], nil); err != nil { + t.Fatal(err) + } } if got, err := db.sampleRingMembers(3, nil, bytes.NewReader([]byte{0, 0, 0})); got != nil || err == nil { t.Fatalf("corrupt %s accepted: %v %v", fault, got, err) @@ -288,7 +304,7 @@ func TestRingSamplingSnapshotAtomicWritesRollbackAndReopen(t *testing.T) { if err != nil { t.Fatal(err) } - defer func() { db.Close() }() + defer closeStoreTestResource(t, db) for i := byte(0); i < 4; i++ { putSamplingOutput(t, db, sparseRingOutput(i)) } @@ -413,7 +429,7 @@ func TestRingSamplingInterruptedPreparedRMBuildRestartsAfterReopen(t *testing.T) if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) // The canonical source is explicitly repaired in the temporary fixture. if err := db.db.Delete(ringMemberKey(broken), nil); err != nil { t.Fatal(err) @@ -450,7 +466,9 @@ func TestRingSamplingFailedMutationCannotPartiallyWriteCanonicalState(t *testing t.Fatal("failed rollback deleted canonical output") } // A closed real backend fails snapshot acquisition and mutations. - db.Close() + if err := db.Close(); err != nil { + t.Fatal(err) + } if err := db.PutUTXO(next.TxHash, 0, next); err == nil { t.Fatal("closed backend accepted write") } diff --git a/store/sale.go b/store/sale.go index 0e856e4..3fc216f 100644 --- a/store/sale.go +++ b/store/sale.go @@ -19,6 +19,8 @@ type SaleMigration struct { Reconciliation sale.Reconciliation Trusted []crypto.ValidatorPubKey } + +// SaleSettlement contains the durable state transition prepared for a sale block. type SaleSettlement struct { Migration *SaleMigration Before *sale.State @@ -36,6 +38,7 @@ func saleFence(kind byte, id crypto.Hash32) []byte { } func saleJournalKey(hash crypto.Hash32) []byte { return append([]byte("sale/journal/v1/"), hash[:]...) } +// LoadSaleState returns the persisted sale ledger state, if one exists. func (d *DB) LoadSaleState() (*sale.State, error) { raw, err := d.get(saleStateKey) if err != nil || raw == nil { @@ -47,6 +50,8 @@ func (d *DB) LoadSaleState() (*sale.State, error) { } return &s, nil } + +// SaleConsumed reports whether an order or payment identifier is durably fenced. func (d *DB) SaleConsumed(kind byte, id crypto.Hash32) (bool, error) { if kind != 'o' && kind != 'p' { return false, fmt.Errorf("sale: invalid fence namespace") @@ -54,6 +59,7 @@ func (d *DB) SaleConsumed(kind byte, id crypto.Hash32) (bool, error) { return d.db.Has(saleFence(kind, id), nil) } +// PrepareSaleBlock validates and prepares sale-ledger effects for a candidate block. func (d *DB) PrepareSaleBlock(b *core.Block, m *SaleMigration) (*SaleSettlement, error) { if b == nil { return nil, fmt.Errorf("sale: nil block") @@ -186,10 +192,13 @@ func (d *DB) appendSaleSettlement(batch *leveldb.Batch, data []byte, hash crypto } batch.Put(saleStateKey, raw) journal := saleJournal{Before: actual.Before} + activationBlock := actual.Before == nil + hasSaleDelivery := false for txIndex, tx := range b.Txs { if !tx.IsSale() { continue } + hasSaleDelivery = true a, err := tx.SaleAuthorization() if err != nil { return err @@ -223,6 +232,29 @@ func (d *DB) appendSaleSettlement(batch *leveldb.Batch, data []byte, hash crypto batch.Put(ringMemberKey(out.OneTimePub), raw) } } + retentionKind := byte(0) + if activationBlock { + retentionKind |= saleRetainedActivation + } + if hasSaleDelivery { + retentionKind |= saleRetainedDelivery + } + if retentionKind != 0 { + if err := d.stageSaleRetainedBlock(batch, height, hash, retentionKind); err != nil { + return err + } + } + if activationBlock { + expectedIndex := saleRetentionHeightValue(p.Migration.Checkpoint.Height) + existingIndex, err := d.get(saleRetentionIndexKey) + if err != nil { + return err + } + if existingIndex != nil && string(existingIndex) != string(expectedIndex) { + return fmt.Errorf("sale: conflicting retained activation height") + } + batch.Put(saleRetentionIndexKey, expectedIndex) + } raw, err = json.Marshal(journal) if err != nil { return err @@ -260,6 +292,7 @@ func (d *DB) restoreSale(batch *leveldb.Batch, target crypto.Hash32, height uint if json.Unmarshal(body, &b) != nil || b.Hash() != cursor || b.Header.Height != current { return fmt.Errorf("sale: rollback body missing") } + retainBlock := journal.Before == nil for _, id := range journal.QueuedOrders { order, err := d.SaleOrder(id) if err != nil { @@ -274,6 +307,7 @@ func (d *DB) restoreSale(batch *leveldb.Batch, target crypto.Hash32, height uint if !tx.IsSale() { continue } + retainBlock = true a, err := tx.SaleAuthorization() if err != nil { return err @@ -288,6 +322,12 @@ func (d *DB) restoreSale(batch *leveldb.Batch, target crypto.Hash32, height uint batch.Delete(spentUTXOKey(th, uint32(i))) } } + if retainBlock { + batch.Delete(saleRetainedBlockKey(current)) + } + if journal.Before == nil { + batch.Delete(saleRetentionIndexKey) + } batch.Delete(heightKey(current)) batch.Delete(saleJournalKey(cursor)) cursor = b.Header.PrevHash diff --git a/store/sale_allocation_review.go b/store/sale_allocation_review.go index 690016c..3749d5a 100644 --- a/store/sale_allocation_review.go +++ b/store/sale_allocation_review.go @@ -21,6 +21,7 @@ type SaleAllocationReview struct { ActivationAuthorized bool `json:"activation_authorized"` } +// ReviewPostBurnSaleSource checks sale eligibility against the canonical post-burn allocation. // OutsideWalletNet includes all already issued value outside the LPoD reward // balance and its separately recorded guardian principal: free wallets, own // validator stake, and any other issued holdings. It must be a net figure using diff --git a/store/sale_config.go b/store/sale_config.go index 47992bc..b764ffd 100644 --- a/store/sale_config.go +++ b/store/sale_config.go @@ -72,15 +72,19 @@ func (d *DB) LoadSaleMigrationFile(path string) (*SaleMigration, error) { } func readSignedSaleMigrationFile(path string) (*SaleMigration, error) { + // #nosec G304 -- the caller supplies a local, operator-managed signed migration path. file, err := os.Open(path) if err != nil { return nil, err } - defer file.Close() raw, err := io.ReadAll(io.LimitReader(file, 1_048_577)) + closeErr := file.Close() if err != nil || len(raw) > 1_048_576 { return nil, fmt.Errorf("sale: configuration size invalid") } + if closeErr != nil { + return nil, closeErr + } var m SaleMigration decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() diff --git a/store/sale_invoice.go b/store/sale_invoice.go index 3e54bd8..3da960c 100644 --- a/store/sale_invoice.go +++ b/store/sale_invoice.go @@ -28,6 +28,7 @@ var saleProviderID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) var saleCurrency = regexp.MustCompile(`^[a-z0-9]{2,32}$`) var saleDecimal = regexp.MustCompile(`^(0|[1-9][0-9]{0,29})(\.[0-9]{1,30})?$`) +// Valid reports whether the invoice fields satisfy the provider wire contract. func (i SaleInvoice) Valid() bool { value, ok := new(big.Rat).SetString(i.Amount) return saleProviderID.MatchString(i.ProviderID) && saleCurrency.MatchString(i.Currency) && @@ -41,6 +42,7 @@ func validSalePrice(price string) bool { return saleDecimal.MatchString(price) && ok && v.Sign() > 0 } +// SaleProviderPayment derives the stable payment identifier for a provider invoice. func SaleProviderPayment(id string) crypto.Hash32 { return crypto.Hash32(sha256.Sum256([]byte("aperod/protocol-sale/payment/v1\x00NOWPayments\x00" + id))) } diff --git a/store/sale_ledger_reconcile_test.go b/store/sale_ledger_reconcile_test.go index e91092c..3d2aa65 100644 --- a/store/sale_ledger_reconcile_test.go +++ b/store/sale_ledger_reconcile_test.go @@ -22,7 +22,6 @@ func TestSaleLedgerReconcilePreservesPaidBudgetAndFences(t *testing.T) { activation, p := saleTestBlock(t, d, m, key, address, g.Header.Height+1, g.Hash(), "reconcile") commitSaleFixture(t, d, activation, m, p) // genuinely funded canonical sale before, _ := d.LoadSaleState() - old := saleUpgradeRecords(t, d) next := core.Block{Header: activation.Header} next.Header.Height++ next.Header.PrevHash = activation.Hash() @@ -55,13 +54,17 @@ func TestSaleLedgerReconcilePreservesPaidBudgetAndFences(t *testing.T) { t.Fatal(err) } if scenario == "missing-body" { - d.db.Delete(append([]byte("b/"), hash[:]...), nil) + if err := d.db.Delete(append([]byte("b/"), hash[:]...), nil); err != nil { + t.Fatal(err) + } } if scenario == "wrong-baseline" { m.Checkpoint.Attestations[0].Signature[0] ^= 1 } if scenario == "journal-conflict" { - d.db.Put(saleJournalKey(hash), []byte("conflict"), nil) + if err := d.db.Put(saleJournalKey(hash), []byte("conflict"), nil); err != nil { + t.Fatal(err) + } } config := filepath.Join(dir, "sale.json") encoded, _ := json.Marshal(m) @@ -69,7 +72,6 @@ func TestSaleLedgerReconcilePreservesPaidBudgetAndFences(t *testing.T) { t.Fatal(err) } snapshot := saleUpgradeRecords(t, d) - old = snapshot expected := hash if scenario == "wrong-tip" { expected = crypto.HashBytes([]byte("wrong")) @@ -101,7 +103,7 @@ func TestSaleLedgerReconcilePreservesPaidBudgetAndFences(t *testing.T) { t.Fatal("budget or canonical state changed incorrectly") } after := saleUpgradeRecords(t, d) - for k, v := range old { + for k, v := range snapshot { if k != string(saleStateKey) && !reflect.DeepEqual(v, after[k]) { t.Fatalf("original financial record changed: %q", k) } @@ -120,7 +122,7 @@ func TestSaleLedgerReconcilePreservesPaidBudgetAndFences(t *testing.T) { t.Fatal("refusal changed data") } } - d.Close() + closeStoreTestResource(t, d) }) } } diff --git a/store/sale_net_reconciliation_test.go b/store/sale_net_reconciliation_test.go index 129f801..bbc3445 100644 --- a/store/sale_net_reconciliation_test.go +++ b/store/sale_net_reconciliation_test.go @@ -96,7 +96,7 @@ func TestSaleNetCheckpointLoadsOnlyAtExactParentAndRejectsResignedPrincipalOmiss g, m, key, address, records := saleStoreFixture(t) chainPath := filepath.Join(t.TempDir(), "chain") d := seedSaleParent(t, chainPath, records) - defer func() { d.Close() }() + defer closeStoreTestResource(t, d) l, err := d.LoadLPoDCheckpoint() if err != nil { t.Fatal(err) diff --git a/store/sale_orders.go b/store/sale_orders.go index 9d95aaa..a06a9ef 100644 --- a/store/sale_orders.go +++ b/store/sale_orders.go @@ -32,6 +32,7 @@ type SaleDelivery struct { Height uint64 } +// SaleOrderDelivery loads the durable delivery record for an order. func (d *DB) SaleOrderDelivery(id crypto.Hash32) (*SaleDelivery, error) { d.canonicalMu.Lock() defer d.canonicalMu.Unlock() @@ -56,7 +57,8 @@ func (d *DB) saleOrderDeliveryLocked(id crypto.Hash32) (*SaleDelivery, error) { return nil, err } var block core.Block - if body == nil || json.Unmarshal(body, &block) != nil || block.Hash() != hash { + if body == nil || json.Unmarshal(body, &block) != nil || block.Hash() != hash || + block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { return nil, fmt.Errorf("sale: delivery body unavailable") } canonical, err := d.get(heightKey(block.Header.Height)) @@ -87,6 +89,7 @@ func saleOrderPaymentKey(id crypto.Hash32) []byte { return append([]byte("sale/order-payment/"), id[:]...) } +// SaleOrder loads the durable order record for an identifier. func (d *DB) SaleOrder(id crypto.Hash32) (*SaleOrder, error) { raw, err := d.get(saleOrderKey(id)) if err != nil || raw == nil { @@ -142,6 +145,7 @@ func (d *DB) ReservedSaleOrders() (uint64, error) { return total, nil } +// ReserveSaleOrder durably reserves an invoice and its payment identity. func (d *DB) ReserveSaleOrder(order SaleOrder, network crypto.NetworkByte) error { d.canonicalMu.Lock() defer d.canonicalMu.Unlock() @@ -211,6 +215,7 @@ func (d *DB) ReserveSaleOrder(order SaleOrder, network crypto.NetworkByte) error return d.db.Write(batch, &opt.WriteOptions{Sync: true}) } +// MarkSaleOrderPaid records the canonical payment transaction for an order. func (d *DB) MarkSaleOrderPaid(id, payment crypto.Hash32) error { d.canonicalMu.Lock() defer d.canonicalMu.Unlock() diff --git a/store/sale_orders_test.go b/store/sale_orders_test.go index a9e9f3c..42e8663 100644 --- a/store/sale_orders_test.go +++ b/store/sale_orders_test.go @@ -12,7 +12,7 @@ func TestSaleReservationPaymentAndRollbackObligation(t *testing.T) { g, m, key, address, records := saleStoreFixture(t) path := t.TempDir() d := seedSaleParent(t, path, records) - defer func() { d.Close() }() + defer closeStoreTestResource(t, d) initial, r := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "activation") commitSaleFixture(t, d, initial, m, r) order := SaleOrder{Order: crypto.HashBytes([]byte("reserved-order")), Checkpoint: m.Checkpoint.Message(), Recipient: address, Amount: 500 * 100000000, QuoteUSD: "0.08"} @@ -86,7 +86,9 @@ func TestSaleReservationPaymentAndRollbackObligation(t *testing.T) { block.Txs[len(block.Txs)-1] = txs[0] settlement.Transactions = append([]core.Transaction(nil), block.Txs[2:]...) block.Header.MerkleRoot = core.MerkleRoot(block.Txs) - block.Header.Sign(key) + if err := block.Header.Sign(key); err != nil { + t.Fatal(err) + } commitSaleFixture(t, d, block, m, settlement) delivery, err := d.SaleOrderDelivery(order.Order) if err != nil || delivery == nil || delivery.Block != block.Hash() || delivery.Transaction != txs[0].Hash() { @@ -119,7 +121,9 @@ func TestSaleReservationPaymentAndRollbackObligation(t *testing.T) { t.Fatal(err) } var persisted map[string]any - json.Unmarshal(raw, &persisted) + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatal(err) + } if _, ok := persisted["amount_napro"].(string); !ok { t.Fatal("inexact persisted amount") } diff --git a/store/sale_recovery.go b/store/sale_recovery.go index 499dc75..dae2100 100644 --- a/store/sale_recovery.go +++ b/store/sale_recovery.go @@ -21,6 +21,7 @@ type SaleRecovery struct { Signature string `json:"signature"` } +// SaleRecoveryChallenge returns the challenge bound to a sale order and delivery. func SaleRecoveryChallenge(genesis crypto.Hash32, order *SaleOrder, delivery *SaleDelivery) string { return "APRO_SALE_RECOVERY_V1\x00" + hex.EncodeToString(genesis[:]) + ":" + hex.EncodeToString(order.Checkpoint[:]) + ":" + hex.EncodeToString(order.Order[:]) + ":" + @@ -106,6 +107,7 @@ func (d *DB) RecordSaleRecovery(genesis, id, block, tx crypto.Hash32, signature return d.db.Put(saleRecoveryKey(id), encoded, &opt.WriteOptions{Sync: true}) } +// SaleOrderRecovery returns the verified recovery record for a sale order. func (d *DB) SaleOrderRecovery(id crypto.Hash32) (*SaleRecovery, error) { raw, err := d.get(saleRecoveryKey(id)) if err != nil || raw == nil { diff --git a/store/sale_retention.go b/store/sale_retention.go new file mode 100644 index 0000000..195adc2 --- /dev/null +++ b/store/sale_retention.go @@ -0,0 +1,298 @@ +package store + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + + "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" +) + +var saleRetentionIndexKey = []byte("sale/retention-index/v1") +var saleRetainedBlockPrefix = []byte("sale/retained-block/v1/") + +const ( + saleRetainedActivation byte = 1 << iota + saleRetainedDelivery + saleRetainedKnownFlags = saleRetainedActivation | saleRetainedDelivery +) + +func saleRetainedBlockKey(height uint64) []byte { + key := append([]byte(nil), saleRetainedBlockPrefix...) + var encoded [8]byte + binary.BigEndian.PutUint64(encoded[:], height) + return append(key, encoded[:]...) +} + +func saleRetentionHeightValue(height uint64) []byte { + value := make([]byte, 8) + binary.LittleEndian.PutUint64(value, height) + return value +} + +func validateSaleRetentionHeader(raw []byte, height uint64, hash crypto.Hash32) error { + var block core.Block + if len(raw) == 0 || json.Unmarshal(raw, &block) != nil || + block.Header.Height != height || block.Hash() != hash { + return fmt.Errorf("sale: retained block header is unavailable or not canonical at height %d", height) + } + return nil +} + +func validateSaleRetentionBody(raw []byte, height uint64, hash crypto.Hash32) error { + if err := validateSaleRetentionHeader(raw, height, hash); err != nil { + return err + } + var block core.Block + if err := json.Unmarshal(raw, &block); err != nil || + block.Header.MerkleRoot != core.MerkleRoot(block.Txs) { + return fmt.Errorf("sale: retained delivery body is incomplete or not bound to its header at height %d", height) + } + return nil +} + +func saleRetentionMarker(value []byte, height uint64) (byte, crypto.Hash32, bool, error) { + var hash crypto.Hash32 + if value == nil { + return 0, hash, false, nil + } + if len(value) != 1+len(hash) || value[0] == 0 || value[0]&^saleRetainedKnownFlags != 0 { + return 0, hash, false, fmt.Errorf("sale: corrupt retained block marker at height %d", height) + } + copy(hash[:], value[1:]) + return value[0], hash, true, nil +} + +func (d *DB) stageSaleRetainedBlock(batch *leveldb.Batch, height uint64, hash crypto.Hash32, kind byte) error { + if kind == 0 || kind&^saleRetainedKnownFlags != 0 { + return fmt.Errorf("sale: invalid retained block kind") + } + key := saleRetainedBlockKey(height) + existing, err := d.get(key) + if err != nil { + return err + } + flags := kind + if existing != nil { + oldFlags, oldHash, _, err := saleRetentionMarker(existing, height) + if err != nil { + return err + } + if oldHash != hash { + return fmt.Errorf("sale: conflicting retained canonical hash at height %d", height) + } + flags |= oldFlags + } + value := make([]byte, 1+len(hash)) + value[0] = flags + copy(value[1:], hash[:]) + batch.Put(key, value) + return nil +} + +// EnsureSaleRetention installs the pruning index for an already activated +// ledger. It only records canonical activation/delivery bodies; this metadata +// is not a finality certificate or proof of LPoD history. +func (d *DB) EnsureSaleRetention(m *SaleMigration) error { + if m == nil { + return nil + } + cp := m.Checkpoint + if cp.SnapshotRoot != m.Reconciliation.Digest() { + return fmt.Errorf("sale: retention configuration has unsigned reconciliation") + } + if err := cp.Verify(cp.Genesis, cp.Parent, cp.Height, m.Trusted); err != nil { + return fmt.Errorf("sale: retention configuration checkpoint: %w", err) + } + + d.canonicalMu.Lock() + defer d.canonicalMu.Unlock() + + state, err := d.LoadSaleState() + if err != nil || state == nil { + return err + } + if state.Checkpoint != cp.Message() { + return fmt.Errorf("sale: retention configuration does not match active ledger") + } + tip, tipHeight, err := d.GetTip() + if err != nil { + return err + } + if state.Hash != tip || state.Height != tipHeight || tipHeight < cp.Height { + return fmt.Errorf("sale: retention ledger does not match canonical tip") + } + + activationHash, found, err := d.GetCanonicalHash(cp.Height) + if err != nil || !found { + return fmt.Errorf("sale: canonical activation hash unavailable: %v", err) + } + activationRaw, err := d.GetRawBlock(activationHash) + if err != nil { + return err + } + if err := validateSaleRetentionHeader(activationRaw, cp.Height, activationHash); err != nil { + return err + } + var activation core.Block + if err := json.Unmarshal(activationRaw, &activation); err != nil || + activation.Header.PrevHash != cp.Parent { + return fmt.Errorf("sale: retained activation does not bind the signed parent") + } + + index, err := d.get(saleRetentionIndexKey) + if err != nil { + return err + } + expectedIndex := saleRetentionHeightValue(cp.Height) + if index != nil && (len(index) != 8 || !bytes.Equal(index, expectedIndex)) { + return fmt.Errorf("sale: retained activation height conflicts with signed configuration") + } + activationMarker, err := d.get(saleRetainedBlockKey(cp.Height)) + if err != nil { + return err + } + flags, markerHash, retained, markerErr := saleRetentionMarker(activationMarker, cp.Height) + if markerErr != nil { + return markerErr + } + if index != nil && retained && flags&saleRetainedActivation != 0 { + if markerHash != activationHash { + return fmt.Errorf("sale: retained activation marker conflicts with canonical hash") + } + return nil // the index and activation marker were committed atomically + } + + batch := new(leveldb.Batch) + if index != nil { + // A missing activation marker means the prior backfill was incomplete; + // clear its completion claim before any bounded batch is persisted. + batch.Delete(saleRetentionIndexKey) + } + if err := d.stageSaleRetainedBlock(batch, cp.Height, activationHash, saleRetainedActivation); err != nil { + return err + } + + // Older nodes did not index sale delivery heights. Backfill from durable + // order fences, validating every referenced body against the canonical index + // before pruning can rely on it. Batch size is bounded; the completion index + // is written only after the whole scan succeeds, so a restart can retry. + const maxBatchEntries = 4096 + entries := 1 + flush := func() error { + if entries == 0 { + return nil + } + if err := d.db.Write(batch, &opt.WriteOptions{Sync: true}); err != nil { + return err + } + batch.Reset() + entries = 0 + return nil + } + iter := d.db.NewIterator(util.BytesPrefix([]byte{'s', 'a', 'l', 'e', '/', 'o', '/'}), nil) + defer iter.Release() + prefixLen := len([]byte{'s', 'a', 'l', 'e', '/', 'o', '/'}) + for iter.Next() { + key, value := iter.Key(), iter.Value() + if len(key) != prefixLen+len(crypto.Hash32{}) || len(value) != len(crypto.Hash32{}) { + return fmt.Errorf("sale: corrupt order delivery fence during retention backfill") + } + var id, fencedHash crypto.Hash32 + copy(id[:], key[prefixLen:]) + copy(fencedHash[:], value) + delivery, err := d.saleOrderDeliveryLocked(id) + if err != nil { + return fmt.Errorf("sale: cannot backfill canonical delivery retention: %w", err) + } + if delivery == nil || delivery.Block != fencedHash { + return fmt.Errorf("sale: delivery fence disagrees with its canonical transaction") + } + raw, err := d.GetRawBlock(delivery.Block) + if err != nil { + return err + } + if err := validateSaleRetentionBody(raw, delivery.Height, delivery.Block); err != nil { + return err + } + kind := saleRetainedDelivery + if delivery.Height == cp.Height { + kind |= saleRetainedActivation + } + if err := d.stageSaleRetainedBlock(batch, delivery.Height, delivery.Block, kind); err != nil { + return err + } + entries++ + if entries >= maxBatchEntries { + if err := flush(); err != nil { + return fmt.Errorf("sale: persist delivery retention backfill: %w", err) + } + } + } + if err := iter.Error(); err != nil { + return fmt.Errorf("sale: scan delivery fences for retention: %w", err) + } + batch.Put(saleRetentionIndexKey, expectedIndex) + if err := d.db.Write(batch, &opt.WriteOptions{Sync: true}); err != nil { + return fmt.Errorf("sale: commit retention index: %w", err) + } + return nil +} + +func (d *DB) saleRetentionHeight() (uint64, bool, error) { + value, err := d.get(saleRetentionIndexKey) + if err != nil || value == nil { + return 0, false, err + } + if len(value) != 8 { + return 0, false, fmt.Errorf("sale: corrupt retained activation height") + } + return binary.LittleEndian.Uint64(value), true, nil +} + +func (d *DB) verifyRetainedSaleBlock(height uint64, hash crypto.Hash32, requireDeliveryBody bool) error { + canonical, found, err := d.GetCanonicalHash(height) + if err != nil || !found || canonical != hash { + return fmt.Errorf("sale: retained block is no longer canonical at height %d", height) + } + raw, err := d.GetRawBlock(hash) + if err != nil { + return err + } + if requireDeliveryBody { + return validateSaleRetentionBody(raw, height, hash) + } + return validateSaleRetentionHeader(raw, height, hash) +} + +func (d *DB) validateSaleRetentionIndex() error { + state, err := d.LoadSaleState() + if err != nil || state == nil { + return err + } + height, found, err := d.saleRetentionHeight() + if err != nil || !found { + return fmt.Errorf("sale: retention index missing for active ledger: %v", err) + } + marker, err := d.get(saleRetainedBlockKey(height)) + if err != nil { + return err + } + flags, hash, retained, markerErr := saleRetentionMarker(marker, height) + if markerErr != nil { + return fmt.Errorf("sale: activation retention marker corrupt: %w", markerErr) + } + if !retained || flags&saleRetainedActivation == 0 { + return fmt.Errorf("sale: activation retention marker missing") + } + if err := d.verifyRetainedSaleBlock(height, hash, flags&saleRetainedDelivery != 0); err != nil { + return err + } + return nil +} diff --git a/store/sale_signed_reserve_test.go b/store/sale_signed_reserve_test.go index 87f4e18..c5c6282 100644 --- a/store/sale_signed_reserve_test.go +++ b/store/sale_signed_reserve_test.go @@ -14,7 +14,7 @@ import ( func TestSaleFullyResignedGuardianOmissionRejectedAtStartupAndBlock(t *testing.T) { g, m, key, address, records := saleStoreFixture(t) d := seedSaleParent(t, filepath.Join(t.TempDir(), "chain"), records) - defer d.Close() + defer closeStoreTestResource(t, d) // Use a disposable, explicit one-authority trust root. Both controls // really pass its signature threshold; no signature check is mocked. m.Trusted = []crypto.ValidatorPubKey{key.Public()} @@ -80,7 +80,7 @@ func TestSaleFullyResignedGuardianOmissionRejectedAtStartupAndBlock(t *testing.T func TestSaleRejectsNewGenesisReserveWithoutChangingExistingFund(t *testing.T) { g, _, _, _, records := saleStoreFixture(t) d := seedSaleParent(t, filepath.Join(t.TempDir(), "chain"), records) - defer d.Close() + defer closeStoreTestResource(t, d) request := &LPoDSettlement{GenesisVestingActivationHeight: g.Header.Height + 1} if err := d.checkSaleGenesisFundingTransition(g.Header.Height+1, request, true); err == nil { t.Fatal("simultaneous reservation accepted") diff --git a/store/sale_test.go b/store/sale_test.go index 8f225e2..af1ea34 100644 --- a/store/sale_test.go +++ b/store/sale_test.go @@ -12,7 +12,7 @@ import ( func saleStoreFixture(t *testing.T) (*core.Block, *SaleMigration, crypto.ValidatorPrivKey, crypto.Address, map[string][]byte) { t.Helper() base, _, lm, lk, la := trustedLPoDV3FundingFixture(t) - defer base.Close() + defer closeStoreTestResource(t, base) g, settlement, raw := lpodActivationBlock(t, base, lm, lk, la) if err := base.CommitRawBlockWithAVM(g.Hash(), g.Header.Height, raw, nil, crypto.Hash32{}, settlement); err != nil { t.Fatal(err) @@ -105,12 +105,31 @@ func TestSaleDurableBatchRestartRollbackAndIndependentStores(t *testing.T) { g, m, key, address, records := saleStoreFixture(t) paths := []string{t.TempDir() + "/node-a", t.TempDir() + "/node-b"} a := seedSaleParent(t, paths[0], records) - defer func() { a.Close() }() + defer closeStoreTestResource(t, a) b := seedSaleParent(t, paths[1], records) - defer b.Close() + defer closeStoreTestResource(t, b) block, r := saleTestBlock(t, a, m, key, address, m.Checkpoint.Height, g.Hash(), "first") commitSaleFixture(t, a, block, m, r) commitSaleFixture(t, b, block, m, r) + // Simulate a database activated before the retention index existed, then + // exercise the startup backfill from its canonical delivery fence. + if err := a.db.Delete(saleRetainedBlockKey(block.Header.Height), nil); err != nil { + t.Fatal(err) + } + if err := a.db.Delete(saleRetentionIndexKey, nil); err != nil { + t.Fatal(err) + } + if err := a.EnsureSaleRetention(m); err != nil { + t.Fatal("legacy sale retention backfill failed", err) + } + retained, err := a.get(saleRetainedBlockKey(block.Header.Height)) + blockHash := block.Hash() + flags, retainedHash, found, markerErr := saleRetentionMarker(retained, block.Header.Height) + if err != nil || markerErr != nil || !found || + flags&(saleRetainedActivation|saleRetainedDelivery) != saleRetainedActivation|saleRetainedDelivery || + retainedHash != blockHash { + t.Fatal("legacy delivery was not indexed for pruning", err) + } as, _ := a.LoadSaleState() bs, _ := b.LoadSaleState() if as == nil || bs == nil || *as != *bs || as.Sold != 100*100000000 { @@ -144,6 +163,12 @@ func TestSaleDurableBatchRestartRollbackAndIndependentStores(t *testing.T) { if err = a.PutTip(g.Hash(), g.Header.Height); err != nil { t.Fatal(err) } + if marker, err := a.get(saleRetainedBlockKey(block.Header.Height)); err != nil || marker != nil { + t.Fatal("rollback retained an orphan sale-body marker", err) + } + if index, err := a.get(saleRetentionIndexKey); err != nil || index != nil { + t.Fatal("rollback retained an orphan sale activation index", err) + } after, err := a.LoadSaleState() if err != nil || after != nil { t.Fatal("rollback did not restore pre-activation allocation") @@ -161,11 +186,34 @@ func TestSaleDurableBatchRestartRollbackAndIndependentStores(t *testing.T) { if *reapplied != *as { t.Fatal("reapply differs from independent store") } + // If a legacy receipt body was already destroyed before migration, do not + // claim recovery or let pruning silently continue past the missing evidence. + if err := a.db.Delete(saleRetainedBlockKey(block.Header.Height), nil); err != nil { + t.Fatal(err) + } + if err := a.db.Delete(saleRetentionIndexKey, nil); err != nil { + t.Fatal(err) + } + prunedBody := *block + prunedBody.Txs = nil + rawBody, err := json.Marshal(&prunedBody) + if err != nil { + t.Fatal(err) + } + if err := a.PutRawBlock(block.Hash(), block.Header.Height, rawBody); err != nil { + t.Fatal(err) + } + if err := a.EnsureSaleRetention(m); err == nil { + t.Fatal("backfill accepted a delivery whose full body was already lost") + } + if _, err := a.PruneBlocksOlderThan(block.Header.Height + 1); err == nil { + t.Fatal("pruning advanced with an incomplete sale-retention index") + } } func TestSaleRejectedCommitLeavesTipBudgetOutputAndFencesUnchanged(t *testing.T) { g, m, key, address, records := saleStoreFixture(t) d := seedSaleParent(t, t.TempDir()+"/node", records) - defer d.Close() + defer closeStoreTestResource(t, d) b, r := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "rejected") p, err := d.PrepareSaleBlock(b, m) if err != nil { @@ -195,7 +243,7 @@ func TestSaleRejectedCommitLeavesTipBudgetOutputAndFencesUnchanged(t *testing.T) func TestSaleBodyMutationAndMissingRollbackJournalFailClosed(t *testing.T) { g, m, key, address, records := saleStoreFixture(t) d := seedSaleParent(t, t.TempDir()+"/node", records) - defer d.Close() + defer closeStoreTestResource(t, d) b, r := saleTestBlock(t, d, m, key, address, m.Checkpoint.Height, g.Hash(), "journal") b.Txs[len(b.Txs)-1].Outputs[0].EncAmount[0] ^= 1 if _, err := d.PrepareSaleBlock(b, m); err == nil { diff --git a/store/sale_upgrade_preflight_test.go b/store/sale_upgrade_preflight_test.go index b470198..e3dcd11 100644 --- a/store/sale_upgrade_preflight_test.go +++ b/store/sale_upgrade_preflight_test.go @@ -109,7 +109,7 @@ func TestSaleUpgradeCopyReadOnly(t *testing.T) { t.Fatal(exportErr) } exportRecords := saleUpgradeRecords(t, exported) - exported.Close() + closeStoreTestResource(t, exported) if len(before) != len(exportRecords) { t.Fatal("logical copy omitted records") } @@ -137,7 +137,7 @@ func TestSaleUpgradeCopyReadOnly(t *testing.T) { t.Fatal(err) } after := saleUpgradeRecords(t, readonly) - readonly.Close() + closeStoreTestResource(t, readonly) if len(before) != len(after) { t.Fatal("preflight changed record count") } diff --git a/store/store2_test.go b/store/store2_test.go index fcb524c..aec6f3f 100644 --- a/store/store2_test.go +++ b/store/store2_test.go @@ -10,11 +10,18 @@ import ( "github.com/aperod/aperod/store" ) +func closeStoreTestResource(t *testing.T, resource interface{ Close() error }) { + t.Helper() + if err := resource.Close(); err != nil { + t.Errorf("close test database: %v", err) + } +} + // ─── StoredBlock: PutBlock / GetBlock / GetBlockByHeight ───────────────────── func TestDB_PutBlock_GetBlock(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) var hash crypto.Hash32 hash[0] = 0xAA @@ -45,7 +52,7 @@ func TestDB_PutBlock_GetBlock(t *testing.T) { func TestDB_GetBlock_NotFound(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) var missing crypto.Hash32 missing[0] = 0xFF @@ -60,7 +67,7 @@ func TestDB_GetBlock_NotFound(t *testing.T) { func TestDB_GetBlockByHeight_NotFound(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) got, err := db.GetBlockByHeight(99999) if err != nil { @@ -73,7 +80,7 @@ func TestDB_GetBlockByHeight_NotFound(t *testing.T) { func TestDB_GetBlockByHeight_Found(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) var hash crypto.Hash32 hash[1] = 0xBB @@ -93,7 +100,7 @@ func TestDB_GetBlockByHeight_Found(t *testing.T) { func TestDB_UTXO_PutGetDelete(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) var txHash crypto.Hash32 txHash[0] = 0x11 @@ -142,7 +149,7 @@ func TestDB_UTXO_PutGetDelete(t *testing.T) { func TestDB_GetUTXO_NotFound(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) var txHash crypto.Hash32 txHash[0] = 0xCC @@ -157,7 +164,7 @@ func TestDB_GetUTXO_NotFound(t *testing.T) { func TestDB_UTXO_MultipleOutputs(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) var txHash crypto.Hash32 txHash[0] = 0x22 @@ -185,7 +192,7 @@ func TestDB_UTXO_MultipleOutputs(t *testing.T) { func TestDB_IsKeyImageSpent(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) var ki crypto.KeyImage ki[0] = 0x55 @@ -213,7 +220,7 @@ func TestDB_IsKeyImageSpent(t *testing.T) { func TestDB_Meta_RoundTrip(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) if err := db.PutMeta("sync_state", []byte(`{"height":42}`)); err != nil { t.Fatalf("PutMeta: %v", err) @@ -230,7 +237,7 @@ func TestDB_Meta_RoundTrip(t *testing.T) { func TestDB_Meta_NotFound(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) val, err := db.GetMeta("nonexistent") if err != nil { @@ -245,7 +252,7 @@ func TestDB_Meta_NotFound(t *testing.T) { func TestDB_GetTip_Empty(t *testing.T) { db, _ := store.Open(t.TempDir()) - defer db.Close() + defer closeStoreTestResource(t, db) hash, height, err := db.GetTip() if err != nil { diff --git a/store/store_test.go b/store/store_test.go index 571be75..76a4123 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -17,7 +17,7 @@ func TestDB_PutGetRawBlock(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) // Create a minimal block priv, pub, _ := crypto.GenerateValidatorKey() @@ -57,7 +57,7 @@ func TestDB_PutGetRawBlock(t *testing.T) { func TestDB_IterKeyImages(t *testing.T) { dir := t.TempDir() db, _ := store.Open(dir) - defer db.Close() + defer closeStoreTestResource(t, db) var kis [3]crypto.KeyImage for i := range kis { @@ -92,7 +92,7 @@ func TestDB_IterKeyImages(t *testing.T) { func TestDB_TipRoundtrip(t *testing.T) { dir := t.TempDir() db, _ := store.Open(dir) - defer db.Close() + defer closeStoreTestResource(t, db) var h crypto.Hash32 h[0] = 0xAB @@ -113,7 +113,11 @@ func TestChainPersistRestore(t *testing.T) { // ── First run: create genesis + 2 blocks ────────────────────────────────── genesisFile := writeTempGenesis(t) - defer os.Remove(genesisFile) + defer func() { + if err := os.Remove(genesisFile); err != nil && !os.IsNotExist(err) { + t.Errorf("remove temporary genesis file: %v", err) + } + }() db1, _ := store.Open(dir) @@ -172,11 +176,13 @@ func TestChainPersistRestore(t *testing.T) { } addAndPersist(db1, chain1, utxos1, 1) addAndPersist(db1, chain1, utxos1, 2) - db1.Close() + if err := db1.Close(); err != nil { + t.Fatal(err) + } // ── Second run: restore from DB ──────────────────────────────────────────── db2, _ := store.Open(dir) - defer db2.Close() + defer closeStoreTestResource(t, db2) _, tipHeight, err := db2.GetTip() if err != nil { @@ -227,7 +233,7 @@ func TestDB_SnapshotSaveDuration_RoundTrip(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - defer db.Close() + defer closeStoreTestResource(t, db) cases := []struct { name string @@ -266,7 +272,7 @@ func TestDB_SnapshotSaveDuration_AbsentBeforeFirstStore(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - defer db.Close() + defer closeStoreTestResource(t, db) got, found, err := db.LoadSnapshotSaveDuration() if err != nil { @@ -295,14 +301,16 @@ func TestDB_SnapshotSaveDuration_SurvivesReopen(t *testing.T) { if err := db1.StoreSnapshotSaveDuration(wantMs); err != nil { t.Fatalf("StoreSnapshotSaveDuration: %v", err) } - db1.Close() + if err := db1.Close(); err != nil { + t.Fatal(err) + } // 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() + defer closeStoreTestResource(t, db2) got, found, err := db2.LoadSnapshotSaveDuration() if err != nil { @@ -340,6 +348,8 @@ func writeTempGenesis(t *testing.T) string { t.Fatal(err2) } _, _ = f.WriteString(content) - f.Close() + if err := f.Close(); err != nil { + t.Fatal(err) + } return f.Name() } diff --git a/store/test_helpers_test.go b/store/test_helpers_test.go new file mode 100644 index 0000000..fa182d3 --- /dev/null +++ b/store/test_helpers_test.go @@ -0,0 +1,15 @@ +package store + +import ( + "errors" + "testing" + + "github.com/syndtr/goleveldb/leveldb" +) + +func closeStoreTestResource(t testing.TB, resource interface{ Close() error }) { + t.Helper() + if err := resource.Close(); err != nil && !errors.Is(err, leveldb.ErrClosed) { + t.Errorf("close test resource: %v", err) + } +} diff --git a/store/wallet_read_snapshot_test.go b/store/wallet_read_snapshot_test.go index b4b7d11..f92d8c2 100644 --- a/store/wallet_read_snapshot_test.go +++ b/store/wallet_read_snapshot_test.go @@ -16,7 +16,7 @@ func TestWalletReadSnapshotPinsWalletReadsAndReleases(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) txHash := crypto.Hash32{1} ki := crypto.KeyImage{} @@ -118,7 +118,7 @@ func TestWalletReadSnapshotPinsLPoDEarningsPagesAndReadiness(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) address := crypto.Address("earnings-snapshot-address") funding := crypto.Hash32{1} @@ -190,7 +190,7 @@ func TestWalletReadSnapshotLPoDEarningsJoinUsesOnlyExactUnspentOutputs(t *testin if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) address := crypto.Address("earnings-join-address") blockHash := crypto.Hash32{9} @@ -281,7 +281,7 @@ func TestWalletReadSnapshotLPoDEarningsJoinRejectsConflictingIndexRow(t *testing if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) address := crypto.Address("earnings-join-conflict-address") blockHash := crypto.Hash32{3} @@ -342,7 +342,7 @@ func BenchmarkLPoDWalletOutputRewardJoin30kSpentHistory(b *testing.B) { if err != nil { b.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(b, db) address := crypto.Address("benchmark-large-reward-wallet") walletPrefix := lpodWalletPrefix(address) earningsPrefix := lpodEarningsPrefix(address) @@ -466,7 +466,7 @@ func TestReadCanonicalBlockBoundedRejectsMissingAndMalformedBodies(t *testing.T) if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) block := &core.Block{Header: core.BlockHeader{ Height: 0, MerkleRoot: core.MerkleRoot(nil), Timestamp: 1, @@ -532,7 +532,7 @@ func TestLPoDWalletOutputsResumeCursorSurvivesSnapshotRenewal(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) address := crypto.Address("resume-cursor-test") var parent crypto.Hash32 blockHashes := make([]crypto.Hash32, 3) @@ -636,7 +636,7 @@ func TestWalletReadSnapshotCheckpointBudgetBeforeDecode(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) hash := crypto.Hash32{9} if err := db.put(lpodKey(hash), []byte("not json and larger than the limit")); err != nil { t.Fatal(err) @@ -654,7 +654,7 @@ func TestWalletReadSnapshotCheckpointBudgetBeforeDecode(t *testing.T) { func TestWalletReadSnapshotCheckpointBudgetIncludesFundingCheckpoint(t *testing.T) { db, _, migration, priv, address := trustedLPoDV3FundingFixture(t) - defer db.Close() + defer closeStoreTestResource(t, db) funding, settlement, raw := lpodActivationBlock(t, db, migration, priv, address) if err := db.CommitRawBlockWithAVM( @@ -698,7 +698,7 @@ func TestCommitRawBlockWithAVMCommitsKeyImagesAtomically(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer closeStoreTestResource(t, db) ki := crypto.KeyImage{} block := &core.Block{ diff --git a/wallet/keystore.go b/wallet/keystore.go index eb98fc9..e1dea03 100644 --- a/wallet/keystore.go +++ b/wallet/keystore.go @@ -104,7 +104,10 @@ func encryptMnemonic(mnemonic, password, address string, account, index *uint32) // 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) + mac, err = computeMACWithDerivation(aesKey, ciphertext, address, *account, *index) + if err != nil { + return nil, err + } } version := 1 @@ -213,7 +216,10 @@ func DecryptMnemonic(ks *KeystoreV1, password string) (string, error) { 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) + gotMAC, err = computeMACWithDerivation(aesKey, ciphertext, ks.Address, *ks.AccountIndex, *ks.AddressIndex) + if err != nil { + return "", err + } } if !macEqual(gotMAC, expectedMAC) { return "", errors.New("keystore: wrong password or corrupted keystore") @@ -258,11 +264,15 @@ func computeMAC(key, ciphertext []byte) []byte { return h.Sum(nil) } -func computeMACWithDerivation(key, ciphertext []byte, address string, account, index uint32) []byte { +func computeMACWithDerivation(key, ciphertext []byte, address string, account, index uint32) ([]byte, error) { + if len(address) > 256 { + return nil, fmt.Errorf("keystore: address exceeds maximum encoded length") + } h := hmac.New(sha256.New, key) h.Write([]byte("aperod/keystore-v1/hd-derivation\x00")) h.Write(ciphertext) var addressLength [4]byte + // #nosec G115 -- address length is capped at 256 bytes above. binary.BigEndian.PutUint32(addressLength[:], uint32(len(address))) h.Write(addressLength[:]) h.Write([]byte(address)) @@ -270,7 +280,7 @@ func computeMACWithDerivation(key, ciphertext []byte, address string, account, i binary.BigEndian.PutUint32(indices[:4], account) binary.BigEndian.PutUint32(indices[4:], index) h.Write(indices[:]) - return h.Sum(nil) + return h.Sum(nil), nil } func macEqual(a, b []byte) bool {